CODE SOURCE
Fusionner des PDF : les sources
Une page indépendante, une logique séparée et les composants communs du site.
- src/pages/fusion.mjs
- src/assets/fusion.mjs
- src/assets/common.mjs
- src/assets/pdf-worker.js
- src/assets/selection.mjs
- src/shared/layout.mjs
- src/shared/support.mjs
- src/assets/style.css
- src/assets/calculator.css
- src/assets/conversion.css
- src/assets/percentage.css
- src/assets/image-compress.css
- src/assets/ad-layout.css
- src/assets/ads.mjs
- src/assets/webmcp.mjs
- site.config.mjs
src/pages/fusion.mjs
import { heading, upload, feedback, result, guide, commonFaqs } from '../shared/layout.mjs';
export default {
title: 'Fusionner des PDF gratuitement et sans inscription',
description: 'Assemblez plusieurs PDF dans l’ordre de votre choix. Fusion PDF gratuite, sans inscription, avec traitement des documents dans votre navigateur.',
route: '/outils/fusion-pdf', active: 'fusion', script: 'fusion.mjs',
body: heading('fusion', 'Fusionner des PDF', 'Assemblez plusieurs PDF en un seul document, dans l’ordre de votre choix.') + `<section class="workspace" aria-label="Fusion de PDF"><fieldset id="settings">${upload(true)}${feedback}<div id="selection" hidden><div class="list-heading"><h2>Votre sélection</h2><button id="clear" class="text-button">Tout effacer</button></div><p class="muted">Faites glisser les fichiers ou utilisez Monter et Descendre pour choisir l’ordre.</p><ol id="file-list" class="file-list"></ol><p id="totals" class="totals" aria-live="polite"></p></div><div class="action-bar"><p id="hint">Ajoutez au moins deux PDF pour les assembler.</p><button id="run" class="primary" disabled>Fusionner les PDF</button></div></fieldset>${result}</section>` + guide('fusion', [
['Importez vos PDF', 'Choisissez au moins deux documents. Vous pourrez en ajouter d’autres à tout moment.'],
['Choisissez leur ordre', 'Placez vos fichiers dans l’ordre souhaité. L’ordre des pages de chaque document reste inchangé.'],
['Fusionnez et téléchargez', 'Lancez la fusion puis récupérez votre PDF. Vous pouvez modifier son nom avant de le télécharger.']
], [ ['Dans quel ordre les pages sont-elles assemblées ?', 'Le premier fichier de la liste vient en premier, puis le deuxième, et ainsi de suite. À l’intérieur de chaque PDF, les pages restent dans leur ordre d’origine.'], ['Peut-on assembler le même PDF plusieurs fois ?', 'Oui. Les doublons probables sont signalés, mais restent dans votre sélection. Retirez-les si vous ne souhaitez pas répéter leurs pages.'], ...commonFaqs ])
};
src/assets/fusion.mjs
import {$,node,size,request,loadFile,setupImport,setStatus,showError,importError,setBusy,invalidate,displayResults,setupResults} from './common.mjs';
import {registerSelectionTool} from './webmcp.mjs';
let files=[],busy=false,lost=false;
document.addEventListener('pdf-worker-lost',()=>lost=true);
function refresh(){
$('file-list').replaceChildren();$('selection').hidden=!files.length;
files.forEach((f,i)=>{
const row=node('li',{class:'file-row',draggable:'true','data-key':f.key});row.append(node('span',{class:'position'},String(i+1)),node('span',{class:'pdf-icon','aria-hidden':'true'},'PDF'));
const details=node('div',{class:'file-details'});details.append(node('span',{class:'file-name',title:f.file.name},f.file.name),node('span',{class:'file-meta'},`${f.count} page(s) · ${size(f.file.size)}`));
if(files.some((other,j)=>j!==i&&other.file.name===f.file.name&&other.file.size===f.file.size))details.append(node('div',{class:'warning'},'Doublon probable — vérifiez votre sélection.'));
row.append(details);const actions=node('div',{class:'file-actions'});
for(const [label,delta]of [['Monter',-1],['Descendre',1]]){const b=node('button',{class:'secondary','aria-label':`${label} ${f.file.name}`},label);b.disabled=busy||i+delta<0||i+delta>=files.length;b.onclick=()=>move(i,i+delta,label);actions.append(b)}
const remove=node('button',{class:'text-button','aria-label':`Supprimer ${f.file.name}`},'Supprimer');remove.onclick=async()=>{if(busy)return;invalidate();files.splice(i,1);await request('remove',{key:f.key});refresh()};actions.append(remove);row.append(actions);
row.addEventListener('dragstart',e=>{if(busy){e.preventDefault();return}e.dataTransfer.setData('text/plain',f.key);e.dataTransfer.effectAllowed='move';row.classList.add('dragging')});row.addEventListener('dragend',()=>row.classList.remove('dragging'));
row.addEventListener('dragover',e=>{e.preventDefault();e.dataTransfer.dropEffect='move'});row.addEventListener('drop',e=>{e.preventDefault();if(busy)return;const index=files.findIndex(f=>f.key===e.dataTransfer.getData('text/plain'));if(index>=0)move(index,i)});$('file-list').append(row);
});
$('totals').textContent=`${files.length} fichier(s) · ${files.reduce((n,f)=>n+f.count,0)} page(s) · ${size(files.reduce((n,f)=>n+f.file.size,0))}`;
$('run').disabled=busy||files.length<2;$('hint').textContent=files.length<2?'Ajoutez au moins deux PDF pour les assembler.':'Les fichiers seront assemblés dans l’ordre ci-dessus.';
}
function move(from,to,label){if(busy||$('settings').disabled||from===to)return;invalidate();const [file]=files.splice(from,1);files.splice(to,0,file);refresh();setStatus(`Fichier déplacé en position ${to+1}.`);if(label){const row=$('file-list').children[to];const buttons=[...row.querySelectorAll('button')];(buttons.find(b=>b.textContent===label&&!b.disabled)||buttons.find(b=>!b.disabled))?.focus()}}
async function recover(){if(!lost)return;await request('clear');for(const f of files)await loadFile(f.file,f.key);lost=false}
setupImport(true,async incoming=>{
busy=true;setBusy(true);showError('');
try{await recover();for(const [i,file] of incoming.entries()){
setStatus(`Lecture du fichier ${i+1} sur ${incoming.length}…`,true);const key=crypto.randomUUID();
try{const {count}=await loadFile(file,key);invalidate();files.push({key,file,count})}catch(e){importError(file.name,e.message)}
}setStatus('Lecture terminée.');}catch(e){showError(e.message)}finally{busy=false;setBusy(false);refresh()}
},()=>busy||$('settings').disabled);
$('clear').onclick=async()=>{if(busy)return;files=[];invalidate(false);await request('clear');$('import-errors').replaceChildren();showError('');setStatus('Sélection effacée.');refresh()};
$('run').onclick=async()=>{
if(busy||files.length<2)return;busy=true;setBusy(true);showError('');invalidate(false);setStatus('Préparation de la fusion…',true);
try{await recover();const result=await request('merge',{keys:files.map(f=>f.key)});displayResults(result,{merge:true});setStatus('Fusion terminée. Vos fichiers originaux restent intacts.');}catch(e){showError(e.message);setStatus('La sélection est conservée.')}finally{busy=false;setBusy(false);refresh()}
};
setupResults(()=>$('clear').click());refresh();
registerSelectionTool({name:'configure_pdf_merge_order',title:'Configurer l’ordre des PDF',description:'Réordonne les documents déjà importés par leurs positions actuelles, sans lancer la fusion.',inputSchema:{type:'object',properties:{positions:{type:'array',items:{type:'integer',minimum:1}}},required:['positions'],additionalProperties:false},annotations:{readOnlyHint:false,untrustedContentHint:false},execute(input){if(busy||$('settings').disabled)throw new Error('Une opération est en cours.');const p=input?.positions;if(!Array.isArray(p)||p.length!==files.length||new Set(p).size!==files.length||p.some(n=>!Number.isInteger(n)||n<1||n>files.length))throw new Error('Fournissez chaque position actuelle exactement une fois.');files=p.map(n=>files[n-1]);invalidate();refresh();return {files:files.length,pages:files.reduce((n,f)=>n+f.count,0),configured:true}}});
src/assets/common.mjs
import {safeName,LIMITS} from './selection.mjs';
export const $ = id => document.getElementById(id);
export const size = bytes => bytes<1024*1024 ? `${(bytes/1024).toLocaleString('fr-FR',{maximumFractionDigits:1})} Ko` : `${(bytes/1024/1024).toLocaleString('fr-FR',{maximumFractionDigits:1})} Mo`;
export function node(tag,attrs={},text){const e=document.createElement(tag);for(const [k,v]of Object.entries(attrs)){if(k==='class')e.className=v;else e.setAttribute(k,v)}if(text!==undefined)e.textContent=text;return e}
let worker,seq=0,pending=new Map();
function getWorker(){
if(!worker){worker=new Worker('/assets/pdf-worker.js');worker.onmessage=({data})=>{const p=pending.get(data.id);if(!p)return;if(data.progress){p.onProgress?.(data.progress);return}pending.delete(data.id);data.error?p.reject(new Error(data.error)):p.resolve(data.result)};
worker.onerror=()=>{for(const p of pending.values())p.reject(new Error('Le moteur PDF s’est arrêté. Vos fichiers restent sélectionnés : relancez l’opération pour les recharger, ou réduisez la sélection.'));pending.clear();worker.terminate();worker=null;document.dispatchEvent(new Event('pdf-worker-lost'))};}
return worker;
}
export function request(action,payload={},transfers=[],onProgress=message=>setStatus(message,true)){
return new Promise((resolve,reject)=>{const id=++seq;pending.set(id,{resolve,reject,onProgress});try{getWorker().postMessage({id,action,payload},transfers)}catch(e){pending.delete(id);reject(new Error('Le moteur PDF ne peut pas démarrer. Actualisez la page dans un navigateur récent.'))}});
}
export const setStatus=(text,busy=false)=>{$('status').textContent=text;$('status').dataset.busy=String(busy)};
export function showError(message){$('error').textContent=message;$('error').hidden=!message}
export function importError(name,message){const item=node('div',{class:'import-error'});item.append(node('span',{},`${name} : ${message}`));const b=node('button',{class:'text-button','aria-label':`Fermer l’erreur pour ${name}`},'Fermer');b.onclick=()=>item.remove();item.append(b);$('import-errors').append(item)}
export async function loadFile(file,key){
if(!/\.pdf$/i.test(file.name)&&file.type!=='application/pdf')throw new Error('Choisissez un fichier PDF (.pdf).');
if(!file.size)throw new Error('Ce fichier est vide. Choisissez une copie valide.');
if(file.size>LIMITS.bytes)throw new Error('Ce fichier dépasse 150 Mo. Utilisez un document plus petit.');
const buffer=await file.arrayBuffer();return request('add',{key,buffer},[buffer]);
}
export function setupImport(multiple,onFiles,isBusy){
const zone=$('dropzone'),input=$('file-input');
const handle=files=>{if(isBusy())return;if(!multiple&&files.length!==1){importError('Sélection','Choisissez un seul PDF à la fois. Votre document actuel est conservé.');return}if(files.length)onFiles([...files])};
input.addEventListener('change',()=>{handle(input.files);input.value=''});
for(const type of ['dragenter','dragover'])zone.addEventListener(type,e=>{e.preventDefault();if(!isBusy())zone.classList.add('dragover')});
for(const type of ['dragleave','drop'])zone.addEventListener(type,e=>{e.preventDefault();zone.classList.remove('dragover')});
zone.addEventListener('drop',e=>handle(e.dataTransfer.files));
window.addEventListener('dragover',e=>e.preventDefault());window.addEventListener('drop',e=>e.preventDefault());
}
export function setBusy(busy){$('settings').disabled=busy;$('settings').setAttribute('aria-busy',String(busy));$('status').dataset.busy=String(busy);for(const b of $('result').querySelectorAll('button'))b.disabled=busy}
let outputs=[],zipURL=null,resultURLs=[];
export function invalidate(message=true){
const had=outputs.length>0;for(const url of resultURLs)URL.revokeObjectURL(url);resultURLs=[];if(zipURL)URL.revokeObjectURL(zipURL);zipURL=null;outputs=[];$('downloads').replaceChildren();$('result').hidden=true;$('zip-error').hidden=true;
if(had&&message)setStatus('La sélection a changé. Relancez l’opération pour créer un nouveau résultat.');
}
export function displayResults(files,{merge=false}={}){
invalidate(false);outputs=files.map(f=>({...f,name:safeName(f.name),blob:new Blob([f.buffer],{type:'application/pdf'})}));
const container=$('downloads');$('result-title').textContent=outputs.length===1?'Votre PDF est prêt':`${outputs.length} PDF sont prêts`;
if(merge){const label=node('label',{for:'output-name'},'Nom du fichier');const input=node('input',{id:'output-name',type:'text',value:outputs[0].name,maxlength:150});container.append(label,input);input.addEventListener('input',()=>{outputs[0].name=safeName(input.value);container.querySelector('a[download]').download=outputs[0].name});}
for(const file of outputs){
const url=URL.createObjectURL(file.blob);resultURLs.push(url);const row=node('div',{class:'download-row'});
row.append(node('span',{},`${merge?'Document fusionné':file.name} · ${file.pages} page(s) · ${size(file.blob.size)}`));row.append(node('a',{class:'button primary',href:url,download:file.name},outputs.length===1?'Télécharger le PDF':'Télécharger'));container.append(row);
delete file.buffer;
}
if(outputs.length>1){const button=node('button',{id:'download-zip',class:'primary'},'Télécharger tout en ZIP');button.style.marginTop='20px';container.append(button);button.onclick=async()=>{
if(zipURL){download(zipURL,'documents-separes.zip');return}
button.disabled=true;setBusy(true);showError('');$('zip-error').hidden=true;
try{
const files=await Promise.all(outputs.map(async f=>({name:f.name,buffer:await f.blob.arrayBuffer()})));
const zipped=await request('zip',{files},files.map(f=>f.buffer));zipURL=URL.createObjectURL(new Blob([zipped.buffer],{type:'application/zip'}));download(zipURL,'documents-separes.zip');setStatus('Archive ZIP prête.');
}catch(e){$('zip-error').hidden=false;$('zip-error').textContent=e.message+' Les téléchargements individuels restent disponibles.';setStatus('')}
finally{setBusy(false);button.disabled=false}
}}
$('result').hidden=false;$('result').scrollIntoView({block:'nearest',behavior:'instant'});const first=$('output-name')||container.querySelector('a');first?.focus({preventScroll:true});
}
function download(url,name){const a=node('a',{href:url,download:name});document.body.append(a);a.click();a.remove()}
export function setupResults(onReset){$('restart').onclick=onReset;$('back-settings').onclick=()=>{$('settings').scrollIntoView({block:'start',behavior:'instant'});$('file-input').focus({preventScroll:true})};window.addEventListener('pagehide',()=>{invalidate(false);worker?.terminate();worker=null});window.addEventListener('pageshow',e=>{if(e.persisted)location.reload()})}
src/assets/pdf-worker.js
/* Les documents restent dans ce worker, en mémoire, jusqu’à leur suppression. */
let library, zipLibrary;
const docs = new Map();
const MAX_BYTES = 150 * 1024 * 1024;
async function pdfLibrary() { if(!library){importScripts('/vendor/pdf-lib.min.js');library=PDFLib}return library }
function describe(error, action) {
if(error.friendly)return error.message;
if(/encrypt|password/i.test(error.message||''))return 'Ce PDF est protégé. Choisissez une copie non protégée que vous êtes autorisé à modifier.';
if(/memory|allocation|array buffer|out of|length/i.test(error.message||''))return 'La mémoire disponible ne suffit pas. Réduisez le nombre de documents ou de pages, puis réessayez.';
if(action==='add')return 'Ce fichier est illisible ou endommagé. Ouvrez-le dans un lecteur PDF et réexportez une copie valide.';
if(action==='zip')return 'La création du ZIP a échoué. Réessayez ou téléchargez les PDF individuellement.';
return 'Le PDF n’a pas pu être créé. Essayez avec moins de pages ou réexportez une copie de votre document.';
}
function fail(message) { const e=new Error(message);e.friendly=true;throw e }
self.onmessage = async ({data:{id,action,payload}}) => {
const progress = message => self.postMessage({id,progress:message});
try {
let result; const transfers=[];
if(action==='add') {
const bytes = new Uint8Array(payload.buffer);
if(bytes.length>MAX_BYTES||[...docs.values()].reduce((n,d)=>n+d.size,0)+bytes.length>MAX_BYTES)fail('La sélection dépasse 150 Mo. Retirez des fichiers et procédez en plusieurs fois.');
if(!new TextDecoder('latin1').decode(bytes.slice(0,1024)).includes('%PDF-'))fail('Ce fichier n’est pas un PDF valide. Choisissez un document au format PDF.');
const {PDFDocument}=await pdfLibrary();
const doc=await PDFDocument.load(bytes,{ignoreEncryption:false,throwOnInvalidObject:true,updateMetadata:false});
const count=doc.getPageCount();
if(!count)fail('Ce PDF ne contient aucune page. Choisissez un autre document.');
if(count+[...docs.values()].reduce((n,d)=>n+d.doc.getPageCount(),0)>5000)fail('La sélection dépasse 5 000 pages. Traitez moins de pages à la fois.');
docs.set(payload.key,{doc,size:bytes.length});result={count};
} else if(action==='remove'){docs.delete(payload.key);result=true}
else if(action==='clear'){docs.clear();result=true}
else if(action==='merge') {
const {PDFDocument}=await pdfLibrary();const out=await PDFDocument.create();
for(let i=0;i<payload.keys.length;i++){
const src=docs.get(payload.keys[i])?.doc;if(!src)fail('Un fichier manque. Réimportez-le avant de relancer la fusion.');
const pages=await out.copyPages(src,src.getPageIndices());for(const page of pages)out.addPage(page);
progress(`${i+1} document(s) assemblé(s) sur ${payload.keys.length}.`);
}
progress('Enregistrement du PDF…');const bytes=await out.save();transfers.push(bytes.buffer);result=[{buffer:bytes.buffer,name:'documents-fusionnes.pdf',pages:out.getPageCount()}];
} else if(action==='split') {
const {PDFDocument}=await pdfLibrary();const src=docs.get(payload.key)?.doc;
if(!src)fail('Le document n’est plus disponible. Importez-le à nouveau.');
if(!payload.groups.length||payload.groups.length>500)fail('Choisissez entre 1 et 500 fichiers de sortie par opération.');
result=[];let outputBytes=0;
for(let i=0;i<payload.groups.length;i++){
const group=payload.groups[i];
if(!group.length||group.some(p=>!Number.isInteger(p)||p<1||p>src.getPageCount()))fail('La sélection contient une page inexistante. Corrigez les réglages.');
const out=await PDFDocument.create();const pages=await out.copyPages(src,group.map(p=>p-1));for(const page of pages)out.addPage(page);
const bytes=await out.save();outputBytes+=bytes.byteLength;
if(outputBytes>MAX_BYTES*2)fail('Les fichiers créés dépassent 300 Mo. Exportez moins de pages à la fois.');
transfers.push(bytes.buffer);result.push({buffer:bytes.buffer,name:payload.names[i],pages:group.length});progress(`${i+1} fichier(s) créé(s) sur ${payload.groups.length}.`);
}
} else if(action==='zip') {
if(!zipLibrary){importScripts('/vendor/jszip.min.js');zipLibrary=JSZip}
const zip=new zipLibrary();for(const f of payload.files)zip.file(f.name,f.buffer);
const bytes=await zip.generateAsync({type:'uint8array',compression:'STORE'},meta=>progress(`Archive ZIP : ${Math.round(meta.percent)} %.`));
result={buffer:bytes.buffer};transfers.push(bytes.buffer);
} else fail('Action inconnue. Actualisez la page.');
self.postMessage({id,result},transfers);
} catch(error){self.postMessage({id,error:describe(error,action)})}
};
src/assets/selection.mjs
// Logique pure : aucun accès au DOM ni aux fichiers utilisateurs.
export const LIMITS = { bytes:150 * 1024 * 1024, pages:5000, outputs:500 };
export function parsePages(text, total) {
if (!text.trim()) return { pages:[], error:'Sélectionnez au moins une page.' };
const selected = new Set();
for(const token of text.split(',')) {
const match = token.trim().match(/^(\d+)(?:\s*-\s*(\d+))?$/);
if(!match) return {pages:[],error:'Utilisez des numéros et des plages séparés par des virgules, par exemple 1-3, 5.'};
const a=Number(match[1]), b=Number(match[2] ?? match[1]);
if(!Number.isSafeInteger(a)||!Number.isSafeInteger(b)||a<1||b>total||a>total||b<1) return {pages:[],error:`Choisissez des pages entre 1 et ${total}.`};
if(a>b) return {pages:[],error:`La plage ${a}-${b} est inversée. Placez la première page avant la dernière.`};
for(let p=a;p<=b;p++) selected.add(p);
}
return {pages:[...selected].sort((a,b)=>a-b),error:''};
}
export function formatPages(pages) {
const nums=[...new Set(pages)].sort((a,b)=>a-b), parts=[];
for(let i=0;i<nums.length;i++){const a=nums[i];let b=a;while(nums[i+1]===b+1)b=nums[++i];parts.push(a===b?`${a}`:`${a}-${b}`)}
return parts.join(', ');
}
export function validateRanges(ranges,total) {
if(!ranges.length)return {error:'Ajoutez au moins une plage.',groups:[],overlap:false};
if(ranges.length>LIMITS.outputs)return {error:`Limitez le découpage à ${LIMITS.outputs} fichiers à la fois.`,groups:[],overlap:false};
const groups=[], seen=new Set();let overlap=false;
for(const [i,r] of ranges.entries()){
const a=Number(r.from),b=Number(r.to);
if(!/^\d+$/.test(String(r.from))||!/^\d+$/.test(String(r.to))||!Number.isSafeInteger(a)||!Number.isSafeInteger(b)||a<1||b>total||a>total||b<1)return {error:`Plage ${i+1} : saisissez des pages entières entre 1 et ${total}.`,groups:[],overlap:false};
if(a>b)return {error:`Plage ${i+1} : la page de début doit précéder la page de fin.`,groups:[],overlap:false};
const group=[];for(let p=a;p<=b;p++){group.push(p);if(seen.has(p))overlap=true;seen.add(p)}groups.push(group);
}
if(groups.reduce((n,g)=>n+g.length,0)>LIMITS.pages)return {error:`Limitez l’export à ${LIMITS.pages} pages à la fois, en comptant les chevauchements.`,groups:[],overlap};
return {groups,overlap,error:''};
}
export const safeName = (name, ext='pdf') => (name.replace(/[<>:"/\\|?*\x00-\x1f]/g,'-').replace(/[. ]+$/,'').replace(/\.(pdf|zip)$/i,'').trim().slice(0,150)||'document')+'.'+ext;
src/shared/layout.mjs
import { support } from './support.mjs';
import config from '../../site.config.mjs';
export const escape = s => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
export function layout({ title, description, route, body, script, active }) {
const navigation=[['fusion-pdf','fusion','Fusionner'],['separation-pdf','separation','Séparer'],['pdf-vers','pdf-vers','PDF vers'],['compresser-image','image-compress','Images'],['calculateur-immobilier','immobilier','Immobilier'],['calculateur-pourcentage','pourcentage','% Pourcentage']].map(([slug,key,label])=>`<a href="/outils/${slug}" ${active===key?'aria-current="page"':''}>${label}</a>`).join('');
return `<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="google-adsense-account" content="ca-pub-5015303387352167"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(title)} | ${escape(config.name)}</title><meta name="description" content="${escape(description)}"><link rel="canonical" href="${config.origin}${route}"><link rel="icon" href="/assets/favicon.svg" type="image/svg+xml"><link rel="stylesheet" href="/assets/style.css">
<!-- Chargé en production par ads.mjs, avec la CMP Google publiée dans AdSense. -->
<link rel="stylesheet" href="/assets/calculator.css">
<link rel="stylesheet" href="/assets/conversion.css">
<link rel="stylesheet" href="/assets/percentage.css">
<link rel="stylesheet" href="/assets/image-compress.css">
<link rel="stylesheet" href="/assets/ad-layout.css">
<template id="adsense-script"><script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-5015303387352167" crossorigin="anonymous"></script></template>
<script type="module" src="/assets/ads.mjs"></script></head><body><a class="skip" href="#contenu">Aller au contenu</a><header><nav aria-label="Navigation principale"><a class="brand" href="/"><span class="brand-mark" aria-hidden="true">✳</span>${escape(config.name)}</a><div class="nav-links">${navigation}</div><a class="header-kofi" href="https://ko-fi.com/S0D7273NVT" target="_blank" rel="noopener noreferrer"><span aria-hidden="true">☕</span> Soutenez moi</a></nav></header><div class="site-layout"><aside class="ad-rail ad-rail-left" data-ad-rail="left" hidden aria-label="Publicité"><span>Publicité</span></aside><main id="contenu">${body}</main></div>${support}<footer><span>${escape(config.name)} · Des outils à portée de main</span><div><a href="/privacy">Confidentialité</a><a href="/cookies">Cookies</a><a href="/mentions-legales">Mentions légales</a></div></footer>${script ? `<script type="module" src="/assets/${script}"></script>` : ''}</body></html>`;
}
export const heading = (id, title, intro) => `<div class="page-heading"><a class="back" href="/">Tous les outils</a><div class="eyebrow">OUTILS PDF</div><h1>${title}</h1><p class="intro">${intro}</p><p class="assurance">Gratuit <span>·</span> Sans inscription <span>·</span> Traitement dans votre navigateur</p></div>`;
export const upload = multiple => `<div class="dropzone" id="dropzone"><span class="upload-symbol" aria-hidden="true">↑</span><h2>Déposez ${multiple ? 'vos PDF' : 'votre PDF'} ici</h2><p>ou sélectionnez ${multiple ? 'des fichiers' : 'un fichier'} sur votre appareil</p><label class="button primary file-picker">${multiple ? 'Choisir mes PDF' : 'Choisir mon PDF'}<input id="file-input" type="file" accept="application/pdf,.pdf" ${multiple ? 'multiple' : ''}></label><p class="muted small">PDF uniquement · Vos documents restent sur votre appareil</p></div>`;
export const feedback = `<div id="import-errors" aria-live="polite"></div><p id="status" role="status" aria-live="polite"></p><div id="error" role="alert" hidden></div>`;
export const result = `<section id="result" class="result" aria-labelledby="result-title" hidden><div class="eyebrow">TERMINÉ</div><h2 id="result-title">Vos documents sont prêts</h2><div id="downloads"></div><div class="actions"><button id="back-settings" class="secondary">Revenir aux réglages</button><button id="restart" class="text-button">Recommencer</button></div><p id="zip-error" role="alert" hidden></p></section>`;
export function guide(kind, steps, faqs) { return `<aside class="ad-space" data-ad-tool="${kind}" hidden aria-label="Publicité"><span>Publicité</span></aside><section class="guide"><div class="section-heading"><span class="eyebrow">MODE D’EMPLOI</span><h2>Trois étapes, tout simplement</h2></div><ol class="steps">${steps.map(([title, text]) => `<li><h3>${title}</h3><p>${text}</p></li>`).join('')}</ol></section><section class="faq"><h2>Vos questions, nos réponses</h2>${faqs.map(([q,a])=>`<details><summary>${q}</summary><p>${a}</p></details>`).join('')}</section><div class="source-link"><a href="/outils/${kind === 'fusion' ? 'fusion' : 'separation'}-pdf/source">Consulter le code source de cet outil</a></div>`; }
export const commonFaqs = [
['Mes documents sont-ils envoyés quelque part ?', 'La lecture, les aperçus, les PDF générés et les ZIP sont traités dans votre navigateur. Aucun document ni nom de fichier n’est envoyé à un serveur. Les documents ne sont pas enregistrés dans un stockage persistant. Les ressources du site et, si elles sont activées à l’avenir, les annonces peuvent effectuer des requêtes réseau.'],
['Puis-je utiliser un PDF protégé ?', 'Les PDF chiffrés ou protégés ne sont pas pris en charge. Utilisez une copie non protégée que vous êtes autorisé à modifier. Cet outil ne retire pas les protections.'],
['La mise en page est-elle conservée ?', 'Les pages sont copiées sans être converties en images, en conservant leurs dimensions et leur orientation. Les signatures numériques, formulaires interactifs, signets et fonctions avancées peuvent être perdus ou ne plus fonctionner. Vérifiez le résultat avant de l’utiliser.'],
['Et pour les documents volumineux ?', 'La capacité dépend de la mémoire de votre appareil. Cette version limite la sélection à 150 Mo, 5 000 pages et 500 fichiers de sortie par opération. En cas de difficulté, fermez les autres onglets ou traitez moins de pages à la fois. Un fichier très complexe peut atteindre les limites de mémoire plus tôt.']
];
src/shared/support.mjs
export const support = `<aside class="support" aria-label="Soutenir le site"><div><h2>Ces outils vous sont utiles ?</h2><p>Un don aide à les maintenir gratuits et à en créer de nouveaux.</p></div><div class="support-button"><a class="kofi-button" href="https://ko-fi.com/S0D7273NVT" target="_blank" rel="noopener noreferrer"><span aria-hidden="true">☕</span> Soutenez moi</a></div></aside>`;
src/assets/style.css
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#17243b;background:#f5f7fb;font-size:16px;line-height:1.55;font-synthesis:none;--blue:#2457db;--line:#dce3ee;--muted:#59677d}*{box-sizing:border-box}body{margin:0}button,input,a,label{ -webkit-tap-highlight-color:transparent}a{color:var(--blue);text-underline-offset:4px}button,input{font:inherit}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;min-height:46px;padding:.65rem 1.05rem;border-radius:8px;border:1px solid transparent;font-size:.9375rem;font-weight:600;cursor:pointer;text-decoration:none}button:disabled{cursor:not-allowed;opacity:.45}button:hover:not(:disabled),.button:hover{filter:brightness(.95)}:focus-visible{outline:3px solid #e39612;outline-offset:4px}.primary{color:#fff;background:var(--blue)}.secondary{background:#fff;border-color:var(--line);color:#263953}.text-button{background:transparent;color:var(--blue);padding-inline:.5rem}.skip{position:absolute;top:-70px;left:16px;background:#fff;padding:12px;z-index:10}.skip:focus{top:10px}header{background:#fff;border-bottom:1px solid var(--line)}nav{max-width:1160px;min-height:80px;padding:0 32px;margin:auto;display:flex;align-items:center;gap:42px}.brand{display:flex;gap:10px;align-items:center;color:#182844;text-decoration:none;font-weight:750;font-size:1.2rem;white-space:nowrap}.brand-mark{background:var(--blue);color:white;border-radius:8px;width:32px;height:32px;text-align:center;font-size:1.4rem;line-height:32px}.nav-links{display:flex;gap:28px}.nav-links a{text-decoration:none;color:var(--muted);font-size:.9375rem;padding:26px 0;border-bottom:3px solid transparent}.nav-links a[aria-current]{border-color:var(--blue);color:var(--blue);font-weight:650}.local-label{margin-left:auto;color:var(--muted);font-size:.8125rem;border:1px solid var(--line);padding:4px 9px;border-radius:5px}main{max-width:1000px;margin:auto;padding:32px 32px 56px}.back{color:var(--muted);font-size:.875rem;text-decoration:none;display:inline-block;margin-bottom:28px}.back:before{content:'←';margin-right:9px}.eyebrow{font-size:.75rem;letter-spacing:.12em;font-weight:750;color:var(--blue);margin-bottom:10px}h1{font-size:clamp(2rem,5vw,2.8rem);letter-spacing:-.04em;line-height:1.15;margin:0 0 16px;font-weight:750}h2{font-size:1.35rem;letter-spacing:-.02em;line-height:1.3;margin:0 0 12px}h3{font-size:1rem;margin:0 0 7px}.intro{font-size:1.0625rem;color:var(--muted);margin:0 0 18px}.assurance{font-size:.875rem;color:#475770;display:flex;gap:10px;flex-wrap:wrap;margin:0 0 28px}.workspace{background:#fff;border:1px solid var(--line);border-radius:14px;padding:24px;box-shadow:0 4px 16px #17243b04}fieldset{min-width:0;margin:0;padding:0;border:0}.dropzone{background:#f8faff;border:1.5px dashed #b9c9eb;border-radius:9px;padding:28px 18px;text-align:center}.dropzone.dragover{border-color:var(--blue);background:#edf2ff}.dropzone h2{font-size:1.2rem;margin:8px 0}.dropzone p{margin:6px 0 16px;color:var(--muted);font-size:.9375rem}.dropzone .small{margin:14px 0 0;font-size:.8125rem}.upload-symbol{display:block;font-size:1.8rem;line-height:1;margin:0 auto 14px;color:var(--blue);font-weight:500}.file-picker{position:relative}.file-picker:focus-within{outline:3px solid #e39612;outline-offset:4px}.file-picker input{position:absolute;width:100%;height:100%;opacity:0;inset:0;cursor:pointer}.muted{color:var(--muted)}.small{font-size:.875rem}.action-bar{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:24px;padding-top:20px;border-top:1px solid var(--line)}.action-bar p{font-size:.875rem;color:var(--muted);margin:0}.action-bar button{white-space:nowrap}.list-heading{display:flex;align-items:center;justify-content:space-between;margin-top:24px}.list-heading h2{margin:0}.file-list{list-style:none;padding:0;display:grid;gap:10px}.file-row{display:flex;align-items:center;gap:14px;border:1px solid var(--line);border-radius:8px;padding:12px}.file-row.dragging{opacity:.5}.position{color:var(--muted);font-size:.875rem;width:22px;flex:none;text-align:center}.pdf-icon{color:var(--blue);background:#edf2ff;padding:8px 6px;border-radius:5px;font-size:.75rem;font-weight:750}.file-details{min-width:0;flex:1}.file-name{display:block;overflow-wrap:anywhere;font-weight:600;font-size:.9375rem}.file-meta{font-size:.8125rem;color:var(--muted)}.file-actions{display:flex;flex-wrap:wrap;gap:4px}.file-actions button{min-height:40px;padding:5px 9px;font-size:.875rem}.totals{font-weight:600;font-size:.9375rem}.warning{color:#795012;font-size:.875rem;background:#fff8e8;padding:10px;border-radius:6px}.field-error,#error,.import-error,#zip-error{color:#9b263a;font-size:.9375rem}#error,.import-error{background:#fff2f3;border:1px solid #f2ccd1;padding:12px;border-radius:8px;margin-top:16px}.import-error{display:flex;justify-content:space-between;align-items:center;gap:12px}.import-error span{overflow-wrap:anywhere}#status:not(:empty){color:#3058aa;margin:16px 0;font-size:.9375rem}#status:empty{margin:0}#status[data-busy=true]:before{content:'';display:inline-block;width:12px;height:12px;margin-right:9px;border:2px solid #aec1ed;border-top-color:var(--blue);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){#status[data-busy=true]:before{animation:none}}.result{margin-top:24px;padding:24px;border-radius:9px;background:#f1f7ff;border:1px solid #c9dbf9}.actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:20px}.download-row{display:flex;justify-content:space-between;align-items:center;gap:14px;padding:12px 0;border-bottom:1px solid #d7e3f6}.download-row span{overflow-wrap:anywhere}.download-row a{flex:none}input[type=text],input[type=number]{background:white;border:1px solid #aab9cf;border-radius:7px;padding:10px 12px;min-height:46px;max-width:100%;color:#17243b}input[type=text]{width:100%;margin-top:7px}label{font-size:.9375rem;font-weight:550}input[aria-invalid=true]{border-color:#a0263b}.document-info{margin:20px 0;display:flex;align-items:center;gap:12px;padding-bottom:20px;border-bottom:1px solid var(--line)}.document-info .file-details{flex:1}.mode-picker{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:24px 0}.mode-picker legend{font-weight:650;margin-bottom:14px}.mode-picker label{display:flex;align-items:flex-start;gap:8px;border:1px solid var(--line);border-radius:8px;padding:14px 10px;cursor:pointer}.mode-picker label:has(:checked){border-color:var(--blue);background:#f2f6ff}.mode-picker input{margin-top:5px;accent-color:var(--blue);flex:none}.mode-picker small{display:block;font-size:.8125rem;font-weight:400;color:var(--muted);margin-top:4px}.selection-actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:16px 0}.selection-actions span{margin-left:auto;font-size:.875rem}.thumbnails{display:grid;grid-template-columns:repeat(auto-fill,minmax(115px,1fr));gap:12px;max-height:530px;overflow:auto;padding:6px}.thumbnail{display:flex;flex-direction:column;background:#f6f8fb;border:2px solid var(--line);padding:9px;position:relative;min-height:173px;border-radius:7px}.thumbnail[aria-pressed=true]{border-color:var(--blue);background:#edf3ff}.thumbnail img{width:100%;height:132px;object-fit:contain;background:#fff;pointer-events:none}.thumbnail .placeholder{height:132px;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:.75rem;font-weight:400}.thumbnail .page-label{font-size:.8125rem;margin-top:8px}.thumbnail[aria-pressed=true] .page-label:before{content:'✓ ';color:var(--blue)}.range-row{display:flex;gap:12px;align-items:end;margin:12px 0;flex-wrap:wrap}.range-row label{flex:1;min-width:115px}.range-row input{display:block;width:100%;margin-top:6px}.range-row span{align-self:center;min-width:80px;font-size:.875rem;color:var(--muted)}.ad-space{margin:48px 0;min-height:120px;text-align:center;font-size:.75rem;color:var(--muted)}.guide{margin-top:56px}.steps{display:grid;grid-template-columns:repeat(3,1fr);gap:32px;list-style:none;padding:0;counter-reset:step}.steps li:before{counter-increment:step;content:'0' counter(step);display:block;color:var(--blue);font-size:.875rem;font-weight:700;margin-bottom:13px}.steps p{font-size:.9375rem;color:var(--muted);margin:0}.section-heading{margin-bottom:28px}.faq{margin-top:48px;max-width:800px}.faq h2{margin-bottom:24px}details{border-top:1px solid var(--line);padding:18px 0}details:last-child{border-bottom:1px solid var(--line)}summary{cursor:pointer;font-weight:600;font-size:.9375rem}details p{font-size:.9375rem;color:var(--muted);margin:14px 0 0}.source-link{margin-top:32px;font-size:.875rem}footer{border-top:1px solid var(--line);max-width:1096px;margin:auto;padding:28px 32px 36px;display:flex;justify-content:space-between;gap:20px;color:var(--muted);font-size:.8125rem}footer div{display:flex;gap:20px;flex-wrap:wrap}footer a{color:var(--muted)}.home{padding-top:28px}.home .intro{max-width:580px}.tool-grid{display:grid;grid-template-columns:1fr 1fr;gap:22px;margin-top:36px}.tool-card{display:block;padding:30px;background:#fff;border:1px solid var(--line);border-radius:12px;text-decoration:none;color:inherit}.tool-card:hover{border-color:#9bb3ee}.tool-card h2{margin-top:20px;font-size:1.5rem}.tool-card p{color:var(--muted);font-size:.9375rem;margin-bottom:24px}.tool-card .go{font-size:.9375rem;color:var(--blue);font-weight:650}.tool-number{color:var(--blue);font-size:.875rem}.prose{max-width:780px}.prose p{color:var(--muted)}pre{padding:20px;background:#142239;color:#e5ecf7;border-radius:8px;overflow:auto;font-size:.8125rem;line-height:1.65;tab-size:2}code{font-family:ui-monospace,Consolas,monospace}.source-section{margin-top:28px}.source-section h2{overflow-wrap:anywhere}[hidden]{display:none!important}@media(max-width:720px){nav{padding:0 20px;gap:22px;min-height:70px;flex-wrap:wrap}.local-label{display:none}.nav-links{gap:18px;margin-left:auto}.nav-links a{padding:22px 0}main{padding:24px 18px 40px}.back{margin-bottom:24px}.workspace{padding:16px}.dropzone{padding:24px 10px}.action-bar{align-items:stretch;flex-direction:column;gap:14px}.action-bar button{width:100%}.steps,.tool-grid,.mode-picker{grid-template-columns:1fr}.steps{gap:24px}.guide{margin-top:38px}.file-row{gap:10px;flex-wrap:wrap}.file-details{flex-basis:65%}.file-actions{margin-left:auto}.mode-picker{gap:8px}.mode-picker label{padding:12px}.mode-picker small{margin:0}.download-row{align-items:stretch;flex-direction:column}.selection-actions span{width:100%;margin:0}.result{padding:16px}.document-info{flex-wrap:wrap}footer{padding:24px 18px;flex-direction:column}.tool-card{padding:24px}.thumbnails{grid-template-columns:repeat(auto-fill,minmax(100px,1fr))}}@media(min-width:721px){.page-heading{padding-top:4px}}
src/assets/calculator.css
/* Le calculateur reprend les couleurs et commandes du site. */
.calculator{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:24px;align-items:start}.calculator .workspace{padding:24px}.calculator fieldset+fieldset{margin-top:28px;padding-top:24px;border-top:1px solid var(--line)}.calculator legend{font-size:1.15rem;font-weight:700;margin-bottom:16px}.calculator legend span{font-size:.8rem;color:var(--blue);margin-right:8px}.calc-field{margin:16px 0}.calc-field p{margin:6px 0}.unit-input{position:relative}.unit-input input{width:100%;padding-right:70px;font-variant-numeric:tabular-nums}.unit-input span{position:absolute;right:12px;top:18px;color:var(--muted);font-size:.8125rem;pointer-events:none}.field-pair{display:grid;grid-template-columns:1fr 1fr;gap:14px}.required{color:var(--blue)}select{font:inherit;font-size:.9375rem;color:#17243b;border:1px solid #aab9cf;border-radius:7px;width:100%;padding:11px 30px 11px 10px;background:white;margin:8px 0;min-height:46px}.calc-result{background:#fff;border:1px solid var(--line);border-radius:14px;padding:26px;position:sticky;top:20px;min-width:0}.main-metric{background:#eef3ff;border-radius:9px;padding:20px;margin:20px 0}.main-metric span{display:block;font-size:.9375rem}.main-metric strong{display:block;font-size:clamp(1.75rem,3vw,2.3rem);letter-spacing:-.03em;color:var(--blue);margin:4px 0;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.main-metric small{font-size:.8125rem;color:var(--muted)}.key-results{margin:0 0 26px}.key-results>div{padding:13px 0;border-bottom:1px solid var(--line)}.key-results dt{font-size:.875rem;color:var(--muted)}.key-results dt small{display:block;font-size:.75rem}.key-results dd{margin:3px 0 0;font-weight:700;font-size:1.15rem}.result-details{margin:16px 0;font-size:.875rem}.result-details>div{display:flex;justify-content:space-between;gap:18px;margin:10px 0;break-inside:avoid}.result-details dt{color:var(--muted)}.result-details dd{margin:0;text-align:right;font-variant-numeric:tabular-nums;font-weight:550;white-space:nowrap}.simulation-limit{font-size:.8125rem;color:var(--muted);border-top:1px solid var(--line);padding-top:16px;margin:24px 0 0}.notice{background:#edf3ff;border-left:3px solid var(--blue);padding:12px;font-size:.875rem}.calc-guide p{color:var(--muted);font-size:.9375rem}.finance-details{margin-top:20px}.stale{opacity:.48}#calc-stale{opacity:1}figure{margin:24px 0}figcaption{font-size:.9375rem;font-weight:650;margin-bottom:12px}.repayment-bars{height:18px;display:flex;overflow:hidden;border-radius:4px}.repayment-bars span{height:100%}#repayment-legend{list-style:none;padding:0;font-size:.8125rem}#repayment-legend li{margin:7px 0}#repayment-legend li:before{content:'';display:inline-block;width:10px;height:10px;background:var(--legend-color);margin-right:8px;border-radius:2px}.schedule{margin-top:28px}.table-scroll{max-width:100%;overflow:auto}table{border-collapse:collapse;min-width:780px;width:100%;font-size:.8125rem}th,td{text-align:right;padding:12px 10px;border-bottom:1px solid var(--line);white-space:nowrap}thead th{background:#f2f5fc;font-weight:650}tbody tr:nth-child(even){background:#fafbfe}.pagination{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:20px}.pagination span{font-size:.875rem}.print-only{display:none}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}#calc-errors{color:#9b263a;background:#fff2f3;padding:12px;border-radius:7px}.tool-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.tool-card{padding:24px}.tool-card h2{font-size:1.3rem}.nav-links{gap:22px}@media(max-width:850px){.calculator{grid-template-columns:1fr}.calc-result{position:static}.tool-grid{grid-template-columns:1fr}.nav-links{gap:14px}.nav-links a{font-size:.875rem}}@media(max-width:450px){.brand{font-size:1rem;gap:7px}.brand-mark{width:26px;height:26px;font-size:1.15rem;line-height:26px}nav{gap:10px;padding:0 14px}.nav-links{gap:12px}.nav-links a{font-size:.8125rem}.calculator .workspace,.calc-result{padding:18px}.field-pair{gap:12px}.result-details>div{gap:10px;flex-wrap:wrap}.result-details dd{margin-left:auto}.pagination{flex-wrap:wrap}.pagination span{order:-1;flex-basis:100%;text-align:center}}
@media print{@page{size:A4;margin:15mm}body{background:#fff;color:#000;font-size:11pt}header,footer,.back,.assurance,.eyebrow,.calculator form,.print-hide,.source-link,.ad-space,.calc-guide,.faq,.schedule,.skip,#calc-stale{display:none!important}main{max-width:none;padding:0}.calculator{display:block}.calc-result{position:static;border:0;padding:0}.page-heading h1{font-size:24pt}.calc-result h2{font-size:16pt}.main-metric{background:none;padding:0}.main-metric strong{font-size:22pt;color:black}.key-results{display:grid;grid-template-columns:1fr 1fr 1fr;gap:18px}.print-only{display:block}.print-only h2{margin-top:25px}.main-metric,.key-results,figure{break-inside:avoid}.repayment-bars{-webkit-print-color-adjust:exact;print-color-adjust:exact}.simulation-limit:after{content:' Le taux nominal n’est pas le TAEG. Modèle à taux fixe sur périodes mensuelles régulières, hors prêts variables, différés, remboursements anticipés et montages multiprêts. Les frais estimés peuvent différer des frais réels. Ce calcul ne vaut pas accord de financement.'}.print-invalid #calc-values,.print-invalid #print-parameters{display:none!important}.print-invalid .calc-result:before{content:'Aucune simulation à jour. Recalculez les résultats avant de les imprimer.';display:block;font-weight:bold}.stale{opacity:1}}
src/assets/conversion.css
.conversion-steps{list-style:none;display:flex;gap:10px;flex-wrap:wrap;padding:0;margin:0 0 24px;counter-reset:steps}.conversion-steps li{font-size:.8125rem;padding:7px 12px;border:1px solid var(--line);background:#fff;border-radius:6px;color:var(--muted)}.conversion-heading{margin:30px 0 14px;font-size:1.15rem}.conversion-heading:first-of-type{margin-top:0}.converter .dropzone{padding:22px 15px}.converter .dropzone h2{margin-top:0}.converter .upload-symbol{display:none}.conversion-formats{margin:16px 0}.page-range-choice{margin-top:22px;padding-top:20px;border-top:1px solid var(--line)}.page-range-choice>select{max-width:340px;display:block}.conversion-file-status{margin:5px 0;font-size:.875rem;font-weight:600}.conversion-output-group{margin:22px 0;padding-bottom:12px;border-bottom:1px solid var(--line)}.conversion-output-group h3{overflow-wrap:anywhere}.text-preview{white-space:pre-wrap;max-height:320px;overflow:auto;overflow-wrap:anywhere}.conversion-help{max-width:800px}.conversion-help p{font-size:.9375rem;color:var(--muted)}.related-tools{margin-top:32px;display:flex;gap:14px;flex-wrap:wrap;font-size:.875rem}.related-tools h2{width:100%;font-size:1.1rem}.tool-grid{grid-template-columns:repeat(2,minmax(0,1fr))}#conversion-files .file-details{flex-basis:60%}.source-index{overflow-wrap:anywhere}.nav-links{flex-wrap:wrap}html:has(.converter) .field-pair{align-items:start}@media(max-width:720px){.conversion-steps{gap:6px}.conversion-steps li{font-size:.75rem;padding:5px 8px}.tool-grid{grid-template-columns:1fr}nav{padding-top:12px;gap:6px}.nav-links{width:100%;margin-left:0;justify-content:space-between;gap:12px}.nav-links a{padding:10px 0}.brand{margin-bottom:2px}.conversion-formats label{padding:12px}.converter .field-pair{grid-template-columns:1fr;gap:4px}}@media print{html{background:#fff}}
src/assets/percentage.css
.percentage-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(320px,.8fr);gap:24px;align-items:start}.percentage-tool{padding:24px}.percent-modes{grid-template-columns:1fr 1fr;margin:0 0 24px}.percent-modes label{min-height:86px}.direction{display:flex;gap:22px;align-items:center;margin:10px 0 20px}.direction legend{font-weight:600;margin-right:8px}.percent-result{background:#fff;border:1px solid var(--line);border-radius:14px;padding:24px;position:sticky;top:20px}.percent-main{background:#eef3ff;border-radius:9px;padding:20px;margin:20px 0}.percent-main span{display:block;font-size:.9rem;color:var(--muted)}.percent-main strong{display:block;color:var(--blue);font-size:2.4rem;letter-spacing:-.04em;margin-top:5px;overflow-wrap:anywhere}.formula{font-size:.9rem}.formula>div{padding:13px 0;border-bottom:1px solid var(--line)}.formula dt{color:var(--muted)}.formula dd{margin:5px 0 0;font-weight:600;overflow-wrap:anywhere}.local-note{margin-top:24px;padding-top:16px;border-top:1px solid var(--line);color:var(--muted);font-size:.8125rem}.explanation-title{margin-top:42px!important}.percentage-guide>p{max-width:820px;color:var(--muted)}.percent-result .stale{opacity:.5}.percent-result .notice{font-size:.875rem}.copy-status{color:#216b4b;min-height:1.4em}.percentage-tool .calc-field{margin:18px 0}.percentage-tool .unit-input input{padding-right:55px}.percentage-tool .rounding{border-top:1px solid var(--line);padding-top:18px;margin-top:8px}.rounding label{font-weight:600}.rounding select{width:auto;min-width:145px;margin-left:10px}.tool-grid{grid-template-columns:repeat(3,minmax(0,1fr))}@media(max-width:850px){.percentage-layout{grid-template-columns:1fr}.percent-result{position:static}.percent-modes{grid-template-columns:1fr 1fr}}@media(max-width:520px){.percent-modes{grid-template-columns:1fr}.rounding label{display:block}.rounding select{margin:8px 0 0;width:100%}.direction{flex-wrap:wrap;gap:12px}.tool-grid{grid-template-columns:1fr}}
src/assets/image-compress.css
.image-compressor{display:grid;grid-template-columns:minmax(0,1fr) minmax(320px,.8fr);gap:24px;align-items:start}.image-compressor>.workspace{padding:24px}.image-compressor h2{margin-top:28px}.image-compressor h2:first-child{margin-top:0}.compression-levels{grid-template-columns:repeat(3,1fr);margin:14px 0 24px}.compression-levels label{min-height:76px}.advanced-image{margin-top:22px}.advanced-image .field-pair{margin-top:18px}.advanced-image input[type=number]{width:100%;min-height:46px}.advanced-image label{display:block;margin:14px 0}.advanced-image .check{display:flex;gap:9px;align-items:center}.target-input{display:grid;grid-template-columns:1fr 120px;gap:10px;align-items:end;margin-top:14px}.target-input input{width:100%;min-height:46px;padding:10px 12px;border:1px solid #aab9cf;border-radius:7px}.target-input select{margin:0}.image-thumb{object-fit:contain;border-radius:6px;background:#f4f6fa;border:1px solid var(--line);flex:none}.image-compressor .file-row{align-items:center}.image-compressor .file-details{flex:1}.image-compressor .download-row{align-items:flex-start}.image-compressor .download-row span{line-height:1.5}.image-guide{max-width:820px}.image-guide p{color:var(--muted);font-size:.9375rem}.image-compressor .result{position:sticky;top:20px}.image-compressor .result>button{margin-top:16px}.related-tools{margin-top:32px;display:flex;gap:14px;flex-wrap:wrap;font-size:.875rem}.related-tools h2{width:100%;font-size:1.1rem;margin:0}.related-tools a{color:var(--blue)}@media(max-width:850px){.image-compressor{grid-template-columns:1fr}.image-compressor .result{position:static}}@media(max-width:560px){.compression-levels{grid-template-columns:1fr}.target-input{grid-template-columns:1fr}.image-compressor .file-row{flex-wrap:wrap}}
src/assets/ad-layout.css
.site-layout{max-width:1000px;margin:auto;display:grid;grid-template-columns:minmax(0,1fr);align-items:start}
.site-layout main{grid-column:1;min-width:0;width:100%;max-width:none;margin:0}
.ad-rail{display:none;min-height:600px;margin-top:80px;text-align:center;font-size:.75rem;color:var(--muted)}
.support{max-width:936px;margin:0 auto 32px;padding:24px;background:#fff;border:1px solid var(--line);border-radius:12px;display:flex;align-items:center;justify-content:space-between;gap:24px}
.support p{margin:0;color:var(--muted);font-size:.9375rem}.support h2{font-size:1.1rem;margin-bottom:6px}.support-button{flex:none}.support .kofi-button{background:#72a4f2;color:#142239;border-radius:8px;padding:12px 20px;font-weight:700;text-decoration:none;display:inline-flex;gap:8px;align-items:center}
nav{flex-wrap:wrap;gap:16px 28px}.nav-links{flex-wrap:wrap;gap:8px 22px}.nav-links a{white-space:nowrap}.local-label{white-space:nowrap}
@media(min-width:1100px){.site-layout:has(.ad-rail:not([hidden])){max-width:1260px;grid-template-columns:180px minmax(0,1fr);gap:24px}.site-layout:has(.ad-rail:not([hidden])) main{grid-column:2}.ad-rail:not([hidden]){display:block;grid-column:1;grid-row:1}}
@media(max-width:720px){.site-layout main{padding:24px 18px 40px}.support{margin:0 18px 24px;flex-direction:column;align-items:flex-start;gap:16px}.nav-links{margin-left:0;width:100%}.nav-links a{padding:8px 0}nav{padding-top:14px;padding-bottom:10px}}
@media print{.support,.ad-rail{display:none!important}}
.header-kofi{margin-left:auto;flex:none;display:inline-flex;align-items:center;gap:8px;padding:9px 14px;border-radius:8px;background:#72a4f2;color:#142239;text-decoration:none;font-size:.875rem;font-weight:700;white-space:nowrap}.privacy h2{margin-top:32px}.privacy{max-width:850px}.privacy p{line-height:1.75}
@media(max-width:1000px){nav .brand{order:0}.header-kofi{order:1}.nav-links{order:2;width:100%;margin-left:0}.nav-links a{padding:8px 0}nav{padding-block:12px}}
@media print{.header-kofi{display:none!important}}
.privacy-settings{font:inherit;color:var(--muted);background:transparent;padding:0;min-height:32px;text-decoration:underline;text-underline-offset:4px;text-align:left}footer{flex-wrap:wrap}
src/assets/ads.mjs
import config from './config.mjs';
// The AdSense tag deploys the Google CMP published in Privacy & messaging.
// Google's native TCF integration handles consent and ad eligibility.
// Do not wait for a custom consent event before loading the tag: it also loads the CMP.
export function startGoogleAds() {
if (!config.ads.enabled || location.hostname !== 'lupus-duj.pages.dev') return false;
if (document.head.querySelector('script[data-adsense-loaded]')) return false;
const template = document.getElementById('adsense-script');
if (!template) return false;
window.googlefc = window.googlefc || {};
window.googlefc.callbackQueue = window.googlefc.callbackQueue || [];
const settings = document.createElement('button');
settings.type = 'button';
settings.className = 'privacy-settings';
settings.textContent = 'Paramètres de confidentialité et de cookies';
settings.hidden = true;
document.querySelector('footer')?.append(settings);
window.googlefc.callbackQueue.push({ CONSENT_API_READY: () => {
if (typeof window.googlefc.showRevocationMessage === 'function') settings.hidden = false;
}});
settings.addEventListener('click', () => {
window.googlefc.callbackQueue.push(() => window.googlefc.showRevocationMessage());
});
const positions = [...document.querySelectorAll('[data-ad-tool],[data-ad-rail]')];
const desktop = window.matchMedia('(min-width:1100px)');
// No ad units on policy or source pages, but keep the CMP available for revocation.
const contentPage = /^\/outils\/?$/.test(location.pathname) || document.querySelector('[data-ad-tool]');
function placeAds() {
if (!contentPage) return;
for (const position of positions) {
if (position.querySelector('ins') || (position.dataset.adRail && !desktop.matches)) continue;
const slot = config.ads.slots[position.dataset.adTool || position.dataset.adRail];
if (!/^\d+$/.test(slot || '')) continue;
position.hidden = false;
const ad = document.createElement('ins');
ad.className = 'adsbygoogle';
ad.style.display = 'block';
ad.dataset.adClient = config.ads.client;
ad.dataset.adSlot = slot;
ad.dataset.adFormat = 'auto';
ad.dataset.fullWidthResponsive = 'true';
position.append(ad);
const observer = new MutationObserver(() => {
position.hidden = ad.dataset.adStatus === 'unfilled';
});
observer.observe(ad, { attributes: true, attributeFilter: ['data-ad-status'] });
(window.adsbygoogle = window.adsbygoogle || []).push({});
}
}
const script = template.content.querySelector('script').cloneNode(true);
script.dataset.adsenseLoaded = 'true';
script.referrerPolicy = 'strict-origin-when-cross-origin';
script.addEventListener('load', () => {
placeAds();
desktop.addEventListener('change', placeAds);
});
script.addEventListener('error', () => {
for (const position of positions) position.hidden = true;
});
document.head.append(script);
return true;
}
startGoogleAds();
src/assets/webmcp.mjs
// Extension facultative du navigateur ; ne reçoit ni contenu ni nom de document.
export function registerSelectionTool(tool) {
if(!document.modelContext?.registerTool)return;
const lifecycle=new AbortController();
try{Promise.resolve(document.modelContext.registerTool(tool,{signal:lifecycle.signal})).catch(()=>{})}catch{}
window.addEventListener('pagehide',()=>lifecycle.abort(),{once:true});
}
site.config.mjs
export default {
name: 'Les outils',
origin: 'https://lupus-duj.pages.dev',
ads: { enabled: true, client: 'ca-pub-5015303387352167', slots: { fusion: '4967098598', separation: '4967098598', immobilier: '4967098598', conversion: '4967098598', percentage: '4967098598', image: '4967098598', left: '6872786213' } }
};