CODE SOURCE
Séparer un PDF : les sources
Une page indépendante, une logique séparée et les composants communs du site.
- src/pages/separation.mjs
- src/assets/separation.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/separation.mjs
import { heading, upload, feedback, result, guide, commonFaqs } from '../shared/layout.mjs';
export default {
title: 'Séparer un PDF et extraire des pages gratuitement', description: 'Extrayez des pages PDF, créez un fichier par page ou découpez un PDF par plages. Téléchargez vos PDF individuellement ou en ZIP, sans inscription.',
route: '/outils/separation-pdf', active: 'separation', script: 'separation.mjs',
body: heading('separation', 'Séparer un PDF', 'Extrayez les pages qui vous intéressent ou découpez votre PDF en plusieurs fichiers.') + `<section class="workspace" aria-label="Séparation de PDF"><fieldset id="settings">${upload(false)}${feedback}<div id="document-settings" hidden><div id="document-info" class="document-info"></div><fieldset class="mode-picker"><legend>Comment souhaitez-vous séparer votre PDF ?</legend><label><input type="radio" name="mode" value="extract" checked><span>Extraire des pages<small>Un seul PDF avec votre sélection</small></span></label><label><input type="radio" name="mode" value="pages"><span>Une page par fichier<small>Un PDF indépendant pour chaque page</small></span></label><label><input type="radio" name="mode" value="ranges"><span>Découper par plages<small>Un PDF pour chaque intervalle</small></span></label></fieldset><div id="extract-panel"><label for="pages-input">Pages à extraire</label><input id="pages-input" type="text" placeholder="Ex. : 1-3, 5, 8" aria-describedby="pages-help pages-error"><p id="pages-help" class="muted small">Séparez les pages par des virgules. Les pages sont exportées dans l’ordre du document, sans doublons.</p><p id="pages-error" class="field-error" role="status"></p><div class="selection-actions"><button id="select-all" class="secondary">Tout sélectionner</button><button id="select-none" class="secondary">Tout désélectionner</button><span id="selected-count" aria-live="polite"></span></div><p id="thumbnail-status" class="muted small" role="status"></p><div id="thumbnails" class="thumbnails" aria-label="Sélection des pages"></div></div><div id="pages-panel" hidden><h3>Chaque page devient un PDF indépendant.</h3><p id="pages-count"></p></div><div id="ranges-panel" hidden><p>Une ligne correspond à un PDF. Par exemple, 1 à 3, 4 à 6 et 7 à 10 produisent trois documents.</p><div id="ranges"></div><button id="add-range" class="secondary">Ajouter une plage</button><p class="muted small">Les pages non couvertes ne sont pas exportées.</p><p id="ranges-error" class="field-error" role="status"></p><p id="overlap" class="warning" hidden>Certaines pages apparaîtront dans plusieurs fichiers : vos plages se chevauchent.</p></div></div><div class="action-bar"><p id="hint">Choisissez un PDF pour commencer.</p><button id="run" class="primary" disabled>Extraire les pages</button></div></fieldset>${result}</section>` + guide('separation', [
['Importez votre document', 'Choisissez le PDF dont vous souhaitez extraire ou séparer les pages.'],
['Sélectionnez les pages', 'Cliquez sur les miniatures, saisissez les numéros ou définissez des plages. Vous pouvez aussi séparer toutes les pages.'],
['Récupérez vos fichiers', 'Téléchargez un PDF, plusieurs fichiers individuellement ou l’ensemble dans une archive ZIP.']
], [ ['Comment extraire les pages 1 à 3 et 5 ?', 'Dans le mode Extraire des pages, saisissez 1-3, 5 ou sélectionnez ces miniatures. Un seul PDF contiendra les quatre pages. Répéter un numéro n’ajoute pas de copie supplémentaire.'], ['Comment télécharger plusieurs PDF ?', 'Les modes Une page par fichier et Découper par plages peuvent produire plusieurs PDF. Téléchargez chacun séparément ou utilisez Télécharger tout en ZIP pour les regrouper.'], ['Que deviennent les pages hors des plages ?', 'Elles ne figurent pas dans les fichiers créés. Le document original reste intact. Les chevauchements sont autorisés : une même page peut apparaître dans plusieurs fichiers.'], ...commonFaqs ])
};
src/assets/separation.mjs
import {$,node,size,request,loadFile,setupImport,setStatus,showError,importError,setBusy,invalidate,displayResults,setupResults} from './common.mjs';
import {parsePages,formatPages,validateRanges,safeName,LIMITS} from './selection.mjs';
import {registerSelectionTool} from './webmcp.mjs';
let doc=null,busy=false,lost=false,mode='extract',ranges=[{from:'1',to:'1'}],groups=[];
let pdfjs,previewDoc,previewTask,observer,generation=0,thumbURLs=[],rendering=false,queue=[];
document.addEventListener('pdf-worker-lost',()=>lost=true);
async function disposePreview(){generation++;queue=[];observer?.disconnect();observer=null;for(const url of thumbURLs)URL.revokeObjectURL(url);thumbURLs=[];$('thumbnails').replaceChildren();if(previewTask){await previewTask.destroy().catch(()=>{});previewTask=null}previewDoc=null;$('thumbnail-status').textContent=''}
function validate(){
groups=[];$('run').disabled=true;if(!doc)return;
let error='';
if(mode==='extract'){
const result=parsePages($('pages-input').value,doc.count);error=result.error;groups=result.pages.length?[result.pages]:[];
$('pages-error').textContent=error;$('pages-input').setAttribute('aria-invalid',String(!!error));$('selected-count').textContent=`${result.pages.length} page(s) sélectionnée(s)`;
const selected=new Set(result.pages);for(const b of $('thumbnails').children){const p=Number(b.dataset.page);b.setAttribute('aria-pressed',String(selected.has(p)));b.setAttribute('aria-label',`Page ${p}${selected.has(p)?', sélectionnée':''}`)}
}else if(mode==='pages'){
if(doc.count>LIMITS.outputs)error=`Ce PDF a ${doc.count} pages. Exportez au maximum ${LIMITS.outputs} fichiers à la fois avec le mode Découper par plages.`;
else groups=Array.from({length:doc.count},(_,i)=>[i+1]);
}else{
const result=validateRanges(ranges,doc.count);groups=result.groups;error=result.error;$('ranges-error').textContent=error;$('overlap').hidden=!result.overlap;
for(const [i,row]of [...$('ranges').children].entries()){
const a=Number(ranges[i].from),b=Number(ranges[i].to);const valid=Number.isInteger(a)&&Number.isInteger(b)&&a>=1&&b>=a&&b<=doc.count;
row.querySelector('span').textContent=valid?`${b-a+1} page(s)`:'À corriger';for(const input of row.querySelectorAll('input'))input.setAttribute('aria-invalid',String(!valid));
}
}
$('run').disabled=busy||!!error||!groups.length;$('hint').textContent=error||`${groups.length} PDF à créer · ${groups.reduce((n,g)=>n+g.length,0)} page(s) au total.`;
}
function drawRanges(){
$('ranges').replaceChildren();ranges.forEach((range,i)=>{
const row=node('div',{class:'range-row'});
for(const [key,text]of [['from','De la page'],['to','À la page']]){const id=`range-${i}-${key}`;const label=node('label',{for:id},text);const input=node('input',{type:'number',id,min:'1',max:String(doc.count),step:'1',value:range[key],'aria-label':`Plage ${i+1} : ${text.toLowerCase()}`});input.oninput=()=>{range[key]=input.value;invalidate();validate()};label.append(input);row.append(label)}
row.append(node('span'));const remove=node('button',{class:'text-button','aria-label':`Supprimer la plage ${i+1}`},'Supprimer');remove.onclick=()=>{ranges.splice(i,1);invalidate();drawRanges();validate();($('ranges').querySelector('input')||$('add-range')).focus()};row.append(remove);$('ranges').append(row);
});
}
function switchMode(){
mode=document.querySelector('input[name=mode]:checked').value;for(const m of ['extract','pages','ranges'])$(m+'-panel').hidden=m!==mode;
$('run').textContent={extract:'Extraire les pages',pages:'Séparer toutes les pages',ranges:'Découper le PDF'}[mode];invalidate();validate();
}
async function thumbnails(file){
await disposePreview();const epoch=generation;$('thumbnail-status').textContent='Préparation des miniatures…';
// Les boutons de sélection restent utilisables même si une miniature échoue.
for(let p=1;p<=doc.count;p++){
const b=node('button',{class:'thumbnail','data-page':String(p),'aria-pressed':'false','aria-label':`Page ${p}`});b.append(node('span',{class:'placeholder'},'Aperçu en attente'),node('span',{class:'page-label'},`Page ${p}`));
b.onclick=()=>{if(busy)return;const parsed=parsePages($('pages-input').value,doc.count);const selected=new Set(parsed.pages);selected.has(p)?selected.delete(p):selected.add(p);$('pages-input').value=formatPages([...selected]);invalidate();validate()};$('thumbnails').append(b);
}
validate();
try{
pdfjs ||= await import('/vendor/pdf.mjs');if(epoch!==generation)return;
pdfjs.GlobalWorkerOptions.workerSrc='/vendor/pdf.worker.mjs';const data=await file.arrayBuffer();if(epoch!==generation)return;
const task=pdfjs.getDocument({data,cMapUrl:'/vendor/cmaps/',cMapPacked:true,standardFontDataUrl:'/vendor/standard_fonts/',wasmUrl:'/vendor/wasm/',isEvalSupported:false,enableXfa:false,verbosity:0});previewTask=task;
previewDoc=await task.promise;if(epoch!==generation){await task.destroy();return}
$('thumbnail-status').textContent='Les aperçus se chargent au fur et à mesure du défilement.';
observer=new IntersectionObserver(entries=>{for(const e of entries)if(e.isIntersecting){observer?.unobserve(e.target);queue.push({button:e.target,epoch,pdf:previewDoc})}renderNext()},{root:$('thumbnails'),rootMargin:'150px'});
for(const b of $('thumbnails').children)observer.observe(b);
}catch(e){if(epoch===generation)$('thumbnail-status').textContent='Les aperçus ne sont pas disponibles pour ce document. Vous pouvez sélectionner les pages par leur numéro.'}
}
async function renderNext(){
if(rendering)return;rendering=true;
try{while(queue.length){const {button,epoch,pdf}=queue.shift();if(epoch!==generation)continue;
let page;try{page=await pdf.getPage(Number(button.dataset.page));const vp=page.getViewport({scale:1});const viewport=page.getViewport({scale:Math.min(240/vp.width,270/vp.height)});const canvas=document.createElement('canvas');canvas.width=Math.ceil(viewport.width);canvas.height=Math.ceil(viewport.height);
await page.render({canvasContext:canvas.getContext('2d'),viewport}).promise;const blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));canvas.width=canvas.height=0;
if(epoch!==generation||!blob)continue;const url=URL.createObjectURL(blob);thumbURLs.push(url);const img=node('img',{src:url,alt:'',width:'120',height:'132'});button.querySelector('.placeholder')?.replaceWith(img);
}catch{if(epoch===generation){const placeholder=button.querySelector('.placeholder');if(placeholder)placeholder.textContent='Aperçu indisponible'}}finally{page?.cleanup()}
}}finally{rendering=false}
}
async function recover(){if(!lost||!doc)return;await request('clear');await loadFile(doc.file,doc.key);lost=false}
setupImport(false,async files=>{
busy=true;setBusy(true);showError('');setStatus('Lecture du PDF…',true);const file=files[0],key=crypto.randomUUID(),previous=doc;
try{
// Libérer l’ancien document du moteur, puis le restaurer si le nouveau est invalide.
if(previous)await request('remove',{key:previous.key});
let count;try{({count}=await loadFile(file,key))}catch(e){if(previous){try{await loadFile(previous.file,previous.key);lost=false}catch{lost=true}}throw e}
invalidate(false);doc={key,file,count};lost=false;ranges=[{from:'1',to:String(count)}];$('pages-input').value='';
$('document-settings').hidden=false;$('document-info').replaceChildren(node('span',{class:'pdf-icon','aria-hidden':'true'},'PDF'));const details=node('div',{class:'file-details'});details.append(node('span',{class:'file-name'},file.name),node('span',{class:'file-meta'},`${count} page(s) · ${size(file.size)}`));$('document-info').append(details);const remove=node('button',{class:'text-button'},'Retirer le PDF');remove.onclick=reset;$('document-info').append(remove);
$('pages-count').textContent=`${count} fichier(s) prévu(s), nommés dans l’ordre des pages.`;drawRanges();setStatus('PDF prêt. Choisissez vos pages ou un mode de découpage.');void thumbnails(file);
}catch(e){importError(file.name,e.message);setStatus(previous?'Votre document précédent est conservé.':'Choisissez un PDF valide pour continuer.')}finally{busy=false;setBusy(false);validate()}
},()=>busy||$('settings').disabled);
async function reset(){if(busy||$('settings').disabled)return;busy=true;setBusy(true);invalidate(false);try{await disposePreview();await request('clear');doc=null;lost=false;$('document-settings').hidden=true;$('import-errors').replaceChildren();$('pages-input').value='';$('run').disabled=true;$('hint').textContent='Choisissez un PDF pour commencer.';setStatus('Sélection effacée.');showError('')}catch(e){showError(e.message)}finally{busy=false;setBusy(false);$('file-input').focus()}}
document.querySelectorAll('input[name=mode]').forEach(r=>r.onchange=switchMode);
$('pages-input').oninput=()=>{invalidate();validate()};$('select-all').onclick=()=>{if(!doc)return;$('pages-input').value=`1-${doc.count}`;invalidate();validate()};$('select-none').onclick=()=>{$('pages-input').value='';invalidate();validate()};
$('add-range').onclick=()=>{if(ranges.length>=LIMITS.outputs){$('ranges-error').textContent=`Limite de ${LIMITS.outputs} plages atteinte.`;return}ranges.push({from:'1',to:String(doc.count)});invalidate();drawRanges();validate();$('ranges').lastElementChild.querySelector('input').focus()};
$('run').onclick=async()=>{
validate();if(busy||$('run').disabled)return;busy=true;setBusy(true);showError('');invalidate(false);setStatus('Préparation des fichiers…',true);
const base=safeName(doc.file.name).replace(/\.pdf$/i,'').slice(0,65);
const names=groups.map((g,i)=>mode==='pages'?`${base}-page-${String(g[0]).padStart(3,'0')}.pdf`:mode==='ranges'?`${base}-plage-${String(i+1).padStart(3,'0')}-pages-${g[0]}-${g.at(-1)}.pdf`:`${base}-pages-${formatPages(g).replace(/, /g,'_').slice(0,70)}.pdf`);
try{await recover();const result=await request('split',{key:doc.key,groups,names});displayResults(result);setStatus('Séparation terminée. Le document original reste intact.')}catch(e){showError(e.message);setStatus('Le document et les réglages sont conservés.')}finally{busy=false;setBusy(false);validate()}
};
setupResults(reset);window.addEventListener('pagehide',()=>void disposePreview());
registerSelectionTool({name:'configure_pdf_page_extraction',title:'Sélectionner les pages à extraire',description:'Configure la sélection de pages du PDF déjà importé et synchronise les miniatures, sans créer de fichier.',inputSchema:{type:'object',properties:{pages:{type:'string'}},required:['pages'],additionalProperties:false},annotations:{readOnlyHint:false,untrustedContentHint:false},execute(input){if(!doc||busy||$('settings').disabled)throw new Error('Importez un PDF et attendez la fin de l’opération.');if(typeof input?.pages!=='string')throw new Error('Saisissez une sélection de pages.');const parsed=parsePages(input.pages,doc.count);if(parsed.error)throw new Error(parsed.error);document.querySelector('input[value=extract]').checked=true;$('pages-input').value=formatPages(parsed.pages);switchMode();return {selectedPages:parsed.pages,outputs:1,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' } }
};