Revenir à l’outil
CODE SOURCE

PDF vers : les sources

Une page indépendante, une logique séparée et les composants communs du site.

src/pages/pdf-vers.mjs

import {upload,feedback} from '../shared/layout.mjs';
import {conversionConfig as cfg} from '../assets/conversion-config.mjs';
export default {
 route:'/outils/pdf-vers',active:'pdf-vers',script:'pdf-vers.mjs',title:'Convertir un PDF gratuitement en ligne',description:'Convertissez vos PDF en DOCX modifiable, images PNG ou JPEG, ou texte UTF-8. Traitement local, sans inscription, avec limites de fidélité expliquées.',
 body:`<div class="page-heading"><a class="back" href="/">Tous les outils</a><div class="eyebrow">OUTILS PDF</div><h1>PDF vers</h1><p class="intro">Convertissez vos PDF vers Word, images ou texte, gratuitement dans votre navigateur.</p><p class="assurance">Gratuit <span>·</span> Sans inscription <span>·</span> Traitement local</p></div><ol class="conversion-steps" aria-label="Étapes de conversion"><li>1. Importer</li><li>2. Choisir la sortie</li><li>3. Régler</li><li>4. Convertir</li><li>5. Télécharger</li></ol>
 <section class="workspace converter"><fieldset id="settings"><legend class="sr-only">Paramètres de conversion PDF</legend><h2 class="conversion-heading">1. Importer vos PDF</h2><p class="muted small" id="conversion-limits">Jusqu’à ${cfg.maxFiles} PDF · ${cfg.maxFileBytes/1024/1024} Mo par fichier · ${cfg.maxTotalBytes/1024/1024} Mo au total · ${cfg.maxPagesPerFile} pages par PDF · ${cfg.maxOutputPages} pages à convertir par lot. Au-delà, utilisez des lots plus petits.</p>${upload(true)}<p class="small muted">Vos PDF sont traités dans votre navigateur et ne sont pas envoyés sur nos serveurs.</p>${feedback}<div id="conversion-selection" hidden><div class="list-heading"><h2>Vos documents</h2><button id="conversion-clear" class="text-button">Vider la liste</button></div><ul id="conversion-files" class="file-list" aria-label="PDF importés"></ul></div>
 <h2 class="conversion-heading">2. Choisir la sortie</h2><fieldset class="mode-picker conversion-formats"><legend class="sr-only">Format de sortie</legend><label><input type="radio" name="output-format" value="docx" checked><span>Word / DOCX<small>Modifier le texte et reconstruire le document</small></span></label><label><input type="radio" name="output-format" value="images"><span>Images<small>Obtenir des pages en PNG ou JPEG</small></span></label><label><input type="radio" name="output-format" value="text"><span>Texte<small>Récupérer rapidement le texte en TXT</small></span></label></fieldset>
 <h2 class="conversion-heading">3. Régler la conversion</h2><div id="docx-options"><p class="warning">La fidélité de la mise en page dépend de la complexité du PDF. Vérifiez le document obtenu.</p><p class="muted small">Le texte devient modifiable. Les scans restent des images sans OCR ; les colonnes, tableaux, polices et objets complexes peuvent être dégradés.</p></div><div id="images-options" hidden><div class="field-pair"><div><label for="image-type">Format d’image</label><select id="image-type"><option value="png">PNG · net, transparence possible</option><option value="jpeg">JPEG · plus léger, avec perte</option></select></div><div><label for="image-resolution">Résolution</label><select id="image-resolution">${Object.entries(cfg.resolutions).map(([key,r])=>`<option value="${key}"${key==='standard'?' selected':''}>${r.label}</option>`).join('')}</select></div></div><p id="resolution-help" class="muted small">Standard : un compromis entre finesse et poids. Chaque page garde ses proportions et son orientation.</p><p id="resolution-warning" class="warning" hidden>Le rendu à 300 ppp peut consommer beaucoup de mémoire. Si une page échoue, choisissez Écran ou Standard. Limite : ${cfg.maxPixels/1000000} millions de pixels par image.</p><label for="image-download">Téléchargement des images</label><select id="image-download"><option value="both">Images individuelles et ZIP groupé</option><option value="zip">Privilégier le ZIP groupé</option></select></div><div id="text-options" hidden><p class="muted small">Fichier TXT en UTF-8. Les colonnes et tableaux peuvent être linéarisés. Un scan sans texte nécessite un OCR, absent de cette version.</p></div>
 <div class="page-range-choice"><label for="page-scope">Pages à convertir</label><select id="page-scope"><option value="all">Toutes les pages</option><option value="selection">Sélection de pages</option></select><div id="page-selection-panel" hidden><label for="conversion-pages">Numéros ou plages</label><input id="conversion-pages" type="text" placeholder="Ex. : 1-3, 5" aria-describedby="conversion-pages-help"><p id="conversion-pages-help" class="small muted">La sélection s’applique à chaque PDF. Les doublons sont retirés et les pages triées. Un PDF dont la plage est invalide sera signalé et ignoré.</p></div><p id="conversion-page-count" class="small" role="status">Importez au moins un PDF valide.</p></div><div class="action-bar"><p>4. Convertir vos documents</p><button id="convert" class="primary" disabled>Convertir en DOCX</button></div></fieldset>
 <section id="conversion-results" class="result" hidden aria-labelledby="conversion-results-title"><div class="eyebrow">5. TÉLÉCHARGER</div><h2 id="conversion-results-title">Résultats de conversion</h2><p id="conversion-summary" class="small"></p><div id="conversion-downloads"></div><p id="conversion-zip-error" class="field-error" role="alert" hidden></p><div class="actions"><button id="conversion-zip" class="primary" hidden>Télécharger tout en ZIP</button><button id="conversion-edit" class="secondary">Modifier les réglages</button><button id="conversion-restart" class="text-button">Recommencer</button></div><p class="muted small">Les fichiers originaux restent inchangés.</p></section></section>
 <aside class="ad-space" data-ad-tool="conversion" hidden aria-label="Publicité"><span>Publicité</span></aside><section class="guide"><div class="eyebrow">MODE D’EMPLOI</div><h2>Convertir un PDF en trois étapes</h2><ol class="steps"><li><h3>Choisissez vos PDF</h3><p>Importez un ou plusieurs fichiers. Chaque PDF est vérifié séparément.</p></li><li><h3>Définissez la sortie</h3><p>Sélectionnez Word, images ou texte, puis les pages utiles et les réglages adaptés.</p></li><li><h3>Récupérez les résultats</h3><p>Vérifiez les avertissements et téléchargez vos fichiers ou une archive ZIP.</p></li></ol></section>
 <section class="guide conversion-help"><h2>Quel format choisir pour votre PDF ?</h2><p>Ce convertisseur PDF gratuit propose trois sorties sur une seule page. Le choix dépend de ce que vous souhaitez récupérer : du texte à modifier, une reproduction visuelle ou des mots à réutiliser. Les PDF originaux restent disponibles pendant la session pour essayer une autre sortie. Les documents complexes peuvent donner des résultats différents selon le format retenu.</p><p>La conversion PDF vers Word produit un fichier DOCX reconstruit. Le texte détecté devient éditable et les changements de page sont repris. Les titres sont estimés à partir des tailles de caractères ; les tableaux simples sont reconstruits lorsque leurs colonnes semblent alignées. Les images et liens détectables sont regroupés après le texte de leur page. Des listes peuvent conserver leurs puces sous forme de texte. Cette méthode ne reproduit pas exactement les positions, polices incorporées, colonnes ou tableaux imbriqués. Vérifiez donc le document avant de le modifier ou de le partager.</p><p>Pour convertir un PDF en image, choisissez PNG ou JPEG. Le PNG préserve des contours nets et peut conserver la transparence ; le JPEG utilise une compression avec perte et un fond blanc. Une résolution plus élevée augmente le nombre de pixels, le poids des fichiers et la mémoire nécessaire. Les pages conservent leurs proportions et leur orientation. Le ZIP regroupe plusieurs images sans imposer de téléchargement séparé.</p><p>La sortie PDF en texte extrait les caractères détectés dans un fichier UTF-8, avec des retours à la ligne. L’aperçu reste masqué tant que vous ne le demandez pas. Un PDF scanné peut n’offrir aucun texte exploitable : un OCR serait nécessaire. Cet outil n’en effectue pas. Le DOCX peut alors conserver les pages en images ; il ne transforme pas leur contenu en texte modifiable.</p><p>Les formulaires, annotations, pièces jointes et fonctions avancées ne sont pas garantis dans la conversion. Une signature numérique n’est pas conservée comme preuve dans le document converti. Pour un gros fichier, diminuez la résolution ou sélectionnez moins de pages. Les fichiers sont traités localement et ne sont pas conservés après la session.</p></section>
 <section class="faq"><h2>Questions fréquentes</h2><details><summary>Mes PDF quittent-ils mon appareil ?</summary><p>Non. Les documents, leurs noms et leur contenu ne sont pas envoyés à un serveur. Les bibliothèques sont servies par ce site dans cette version. Aucun document n’est enregistré dans un historique persistant. Les annonces en ligne sont gérées par Google selon vos choix de confidentialité.</p></details><details><summary>Pourquoi le DOCX est-il différent du PDF ?</summary><p>Le PDF décrit une page fixe ; Word reconstruit un document modifiable. L’ordre de lecture, les tableaux et les images peuvent changer. Pour privilégier l’apparence visuelle, choisissez PNG ou JPEG.</p></details><details><summary>Est-ce possible avec un scan ?</summary><p>Les pages peuvent être converties en images. Sans OCR, une sortie texte vide est refusée avec une explication ; le DOCX conserve les pages sans texte en images et vous avertit.</p></details><details><summary>Que faire si un fichier échoue ?</summary><p>Les autres fichiers continuent à être traités et leurs résultats restent téléchargeables. Pour le fichier concerné, réduisez la sélection ou la résolution, ou réexportez une copie lisible. Les PDF protégés sont refusés et aucun mot de passe n’est demandé.</p></details><details><summary>Quels navigateurs sont pris en charge ?</summary><p>${cfg.supportedBrowsers}. Les limites de mémoire varient selon l’appareil. Cette version est vérifiée sur Edge de bureau avec un affichage mobile simulé ; une validation sur appareils iOS et Android réels reste nécessaire.</p></details></section><div class="related-tools"><h2>Continuer avec vos documents</h2><a href="/outils/fusion-pdf">Fusionner des PDF</a><a href="/outils/separation-pdf">Séparer un PDF</a><a href="/outils/calculateur-immobilier">Calculateur immobilier</a></div><div class="source-link"><a href="/outils/pdf-vers/source">Consulter le code source de PDF vers</a></div>`
};

src/assets/pdf-vers.mjs

import {$,node,size,setupImport,setStatus,showError,importError} from './common.mjs';
import {parsePages,formatPages} from './selection.mjs';
import {conversionConfig as cfg} from './conversion-config.mjs';
import {openPDF,convertPDF,userError,zipFiles} from './conversion-engine.mjs';
import {downloadBlob,releaseDownloadURLs} from './conversion-downloads.mjs';
import {registerSelectionTool} from './webmcp.mjs';
let entries=[],outputs=[],busy=false,ordinal=0,lastSettings=null;
const settings=()=>({format:document.querySelector('input[name=output-format]:checked').value,imageType:$('image-type').value,resolution:$('image-resolution').value,scope:$('page-scope').value,pageText:$('conversion-pages').value,downloadMode:$('image-download').value});
lastSettings=settings();
function lock(value){busy=value;$('settings').disabled=value;$('settings').setAttribute('aria-busy',String(value));for(const button of $('conversion-results').querySelectorAll('button'))button.disabled=value;$('status').dataset.busy=String(value)}
function undownloaded(){return outputs.some(o=>!o.downloaded)}
function discard(){outputs=[];$('conversion-downloads').replaceChildren();$('conversion-results').hidden=true;$('conversion-zip-error').hidden=true;releaseDownloadURLs();for(const e of entries)if(e.pdf){e.status='En attente';e.progress='';e.warnings=[]}}
function confirmDiscard(){if(undownloaded()&&!window.confirm('Certains résultats n’ont pas été téléchargés. Les supprimer et continuer ?'))return false;discard();return true}
function restoreSettings(s){document.querySelector(`input[name=output-format][value="${s.format}"]`).checked=true;$('image-type').value=s.imageType;$('image-resolution').value=s.resolution;$('page-scope').value=s.scope;$('conversion-pages').value=s.pageText;$('image-download').value=s.downloadMode}
function options(){const s=settings();for(const f of ['docx','images','text'])$(f+'-options').hidden=s.format!==f;$('page-selection-panel').hidden=s.scope!=='selection';$('resolution-warning').hidden=s.resolution!=='print';$('resolution-help').textContent={screen:'Écran : moins de pixels et des fichiers plus légers.',standard:'Standard : un compromis entre finesse et poids.',print:'Impression : plus de détail et des fichiers plus lourds.'}[s.resolution]+' Les proportions et l’orientation sont conservées.';$('convert').textContent={docx:'Convertir en DOCX',images:'Convertir en images',text:'Extraire le texte'}[s.format]}
function pagesFor(e){return settings().scope==='all'?{pages:Array.from({length:e.count},(_,i)=>i+1),error:''}:parsePages($('conversion-pages').value,e.count)}
function validSelection(){let total=0,valid=0;for(const e of entries)if(e.pdf){const selected=pagesFor(e);if(!selected.error){total+=selected.pages.length;valid++}}return {total,valid,error:total>cfg.maxOutputPages?`La sélection dépasse ${cfg.maxOutputPages} pages. Réduisez le nombre de pages ou de PDF.`:''}}
function refresh(){
 $('conversion-selection').hidden=!entries.length;$('conversion-files').replaceChildren();
 for(const e of entries){const row=node('li',{class:'file-row'});row.append(node('span',{class:'pdf-icon','aria-hidden':'true'},'PDF'));const detail=node('div',{class:'file-details'});detail.append(node('span',{class:'file-name'},e.name),node('span',{class:'file-meta'},`${size(e.size)}${e.count?' · '+e.count+' page(s)':''}`));
   const state=node('p',{class:'conversion-file-status'},e.status+(e.progress?' · '+e.progress:''));detail.append(state);
   if(e.error)detail.append(node('p',{class:'field-error'},e.error));
   if(e.pdf){const s=pagesFor(e);detail.append(node('p',{class:s.error?'field-error':'muted small'},s.error?`Réglage à corriger : ${s.error}`:`${s.pages.length} page(s) retenue(s)`));if(e.scanHint)detail.append(node('p',{class:'muted small'},'Première page sans texte détectable : un scan est possible.'))}
   const remove=node('button',{class:'text-button','aria-label':`Supprimer ${e.name}`},'Supprimer');remove.disabled=busy;remove.onclick=async()=>{if(busy||!confirmDiscard())return;lock(true);entries=entries.filter(x=>x!==e);try{await e.destroy?.()}finally{lock(false);refresh()}};row.append(detail,remove);$('conversion-files').append(row);
 }
 const s=validSelection();$('conversion-page-count').textContent=s.error||(s.valid?`${s.total} page(s) à convertir dans ${s.valid} PDF valide(s).`:'Importez un PDF valide et vérifiez la sélection des pages.');$('convert').disabled=busy||!s.valid||!!s.error;
}
function onChange(){if(busy)return;if(!confirmDiscard()){restoreSettings(lastSettings);options();refresh();return}lastSettings=settings();options();refresh();setStatus('Réglages modifiés. Relancez la conversion pour créer les nouveaux fichiers.')}
document.querySelectorAll('input[name=output-format],#image-type,#image-resolution,#page-scope,#image-download').forEach(e=>e.addEventListener('change',onChange));$('conversion-pages').addEventListener('change',onChange);
$('conversion-pages').addEventListener('input',()=>{if(outputs.length){if(!confirmDiscard()){restoreSettings(lastSettings);return}lastSettings=settings()}refresh()});
setupImport(true,async files=>{
 if(!confirmDiscard())return;lock(true);showError('');
 try{for(const file of files){
   if(entries.length>=cfg.maxFiles){importError(file.name,`La liste est limitée à ${cfg.maxFiles} PDF. Retirez des fichiers avant d’en ajouter.`);continue}
   if(entries.reduce((n,e)=>n+(e.pdf?e.size:0),0)+file.size>cfg.maxTotalBytes){importError(file.name,`Le lot dépasse ${cfg.maxTotalBytes/1024/1024} Mo. Retirez des fichiers ou importez un lot plus petit.`);continue}
   const e={id:crypto.randomUUID(),ordinal:String(++ordinal).padStart(3,'0'),name:file.name,size:file.size,count:0,status:'En cours',progress:'Vérification…',warnings:[]};entries.push(e);refresh();setStatus('Vérification des PDF…',true);
   try{const opened=await openPDF(file);e.pdf=opened.pdf;e.destroy=opened.destroy;e.count=e.pdf.numPages;const first=await e.pdf.getPage(1);const content=await first.getTextContent();e.scanHint=!content.items.some(i=>i.str?.trim());first.cleanup();e.status='En attente';e.progress=''}catch(error){await e.destroy?.().catch(()=>{});e.pdf=null;e.destroy=null;e.status='Erreur';e.error=userError(error);e.progress=''}
   refresh();
 }setStatus('Vérification terminée. Choisissez la sortie et les pages.')}finally{lock(false);refresh()}
},()=>busy);
async function clear(){if(busy||!confirmDiscard())return;lock(true);try{for(const e of entries)await e.destroy?.();entries=[];$('import-errors').replaceChildren();setStatus('Liste vidée.');showError('')}finally{lock(false);refresh()}}
$('conversion-clear').onclick=clear;$('conversion-restart').onclick=clear;
$('conversion-edit').onclick=()=>{if(busy||!confirmDiscard())return;refresh();document.querySelector('input[name=output-format]:checked').focus();$('docx-options').parentElement.scrollIntoView({block:'start'})};
function renderResults(summary){
 const host=$('conversion-downloads');host.replaceChildren();$('conversion-results').hidden=!outputs.length;$('conversion-summary').textContent=summary;
 for(const entry of entries){const files=outputs.filter(o=>o.entryId===entry.id);if(!files.length)continue;const group=node('section',{class:'conversion-output-group'});group.append(node('h3',{},entry.name),node('p',{class:'muted small'},`${files.length} fichier(s) produit(s) · ${entry.status}${entry.error?' — '+entry.error:''}`));for(const warning of entry.warnings||[])group.append(node('p',{class:'warning'},warning));
   for(const output of files){const row=node('div',{class:'download-row'});row.append(node('span',{},`${output.name} · ${size(output.blob.size)}`));const button=node('button',{class:'primary','aria-label':`Télécharger ${output.name}`},'Télécharger');button.onclick=()=>{downloadBlob(output.blob,output.name);output.downloaded=true;button.textContent='Télécharger à nouveau'};row.append(button);group.append(row);
     if(output.preview){const preview=node('pre',{class:'text-preview',hidden:''});preview.textContent=output.preview;const toggle=node('button',{class:'text-button','aria-expanded':'false'},'Afficher un aperçu du texte');toggle.onclick=()=>{preview.hidden=!preview.hidden;toggle.setAttribute('aria-expanded',String(!preview.hidden));toggle.textContent=preview.hidden?'Afficher un aperçu du texte':'Masquer le texte'};group.append(toggle,preview,node('p',{class:'muted small'},'Aperçu limité à 4 000 caractères, masqué par défaut.'))}
   }host.append(group);
 }
 $('conversion-zip').hidden=outputs.length<2;$('conversion-zip').className=settings().downloadMode==='zip'?'primary':'secondary';
}
$('convert').onclick=async()=>{
 if(busy||$('convert').disabled||!confirmDiscard())return;lock(true);showError('');const s=settings();let completed=0,totalBytes=0;const summary=`${{docx:'Word / DOCX',images:s.imageType==='png'?'Images PNG':'Images JPEG',text:'Texte UTF-8'}[s.format]} · ${s.scope==='all'?'Toutes les pages':'Pages '+s.pageText}${s.format==='images'?' · '+cfg.resolutions[s.resolution].label:''}`;
 try{for(const entry of entries){if(!entry.pdf)continue;const selected=pagesFor(entry);if(selected.error){entry.status='Erreur';entry.error=selected.error;continue}entry.status='En cours';entry.error='';entry.warnings=[];refresh();
     try{entry.warnings=await convertPDF(entry,{...s,pages:selected.pages},message=>{entry.progress=message;setStatus(`Document ${entries.indexOf(entry)+1} sur ${entries.length} · ${message}`,true);const index=entries.indexOf(entry);const status=$('conversion-files').children[index]?.querySelector('.conversion-file-status');if(status)status.textContent='En cours · '+message},async output=>{if(totalBytes+output.blob.size>cfg.maxOutputBytes)throw Object.assign(new Error('La taille totale des sorties dépasse 200 Mo. Convertissez moins de pages à la fois.'),{friendly:true});totalBytes+=output.blob.size;outputs.push({...output,entryId:entry.id,downloaded:false})});entry.status='Terminé';completed++;
     }catch(error){entry.status=outputs.some(o=>o.entryId===entry.id)?'Erreur partielle':'Erreur';entry.error=userError(error)}finally{entry.progress='';refresh()}
   }
   renderResults(summary);setStatus(`${completed} PDF terminé(s). ${outputs.length} fichier(s) disponible(s).${entries.some(e=>e.status.startsWith('Erreur'))?' Consultez les erreurs indiquées pour les autres documents.':''}`);if(!outputs.length)showError('Aucun fichier n’a été produit. Consultez les erreurs de chaque PDF et adaptez les réglages.');
 }finally{lock(false);refresh()}
};
$('conversion-zip').onclick=async()=>{if(busy||outputs.length<2)return;lock(true);$('conversion-zip-error').hidden=true;try{const files=await Promise.all(outputs.map(async o=>({name:o.name,buffer:await o.blob.arrayBuffer()})));const buffer=await zipFiles(files,p=>setStatus(`Création du ZIP : ${p} %.`,true));downloadBlob(new Blob([buffer],{type:'application/zip'}),'documents-convertis.zip');for(const o of outputs)o.downloaded=true;setStatus('Archive prête. Les téléchargements individuels restent disponibles.')}catch(e){$('conversion-zip-error').hidden=false;$('conversion-zip-error').textContent=userError(e);setStatus('Le ZIP n’a pas été créé. Vous pouvez télécharger les résultats séparément.')}finally{lock(false);refresh()}};
window.addEventListener('beforeunload',e=>{if(undownloaded()){e.preventDefault();e.returnValue=''}});window.addEventListener('pagehide',()=>{for(const e of entries)void e.destroy?.();entries=[];outputs=[];releaseDownloadURLs()});window.addEventListener('pageshow',e=>{if(e.persisted)location.reload()});
for(const format of document.querySelectorAll('input[name=output-format]')){format.disabled=!cfg.formats.includes(format.value)}
options();refresh();
registerSelectionTool({name:'configure_pdf_conversion_format',title:'Choisir la sortie PDF',description:'Configure un format de conversion pour les PDF importés. Refuse de supprimer des résultats non téléchargés.',inputSchema:{type:'object',properties:{format:{enum:['docx','images','text']}},required:['format'],additionalProperties:false},annotations:{readOnlyHint:false,untrustedContentHint:false},execute(input){if(busy||undownloaded())throw new Error('Terminez le traitement ou téléchargez les résultats avant de changer de format.');if(!cfg.formats.includes(input?.format))throw new Error('Format indisponible.');discard();document.querySelector(`input[name=output-format][value="${input.format}"]`).checked=true;lastSettings=settings();options();refresh();return {format:input.format,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/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/assets/conversion-config.mjs

export const conversionConfig={maxFileBytes:40*1024*1024,maxTotalBytes:100*1024*1024,maxFiles:8,maxPagesPerFile:200,maxOutputPages:300,maxPixels:16_000_000,maxSide:8192,maxOutputBytes:200*1024*1024,maxImagesPerPage:30,formats:['docx','images','text'],supportedBrowsers:'Versions récentes de Chrome, Edge, Firefox et Safari',resolutions:{screen:{dpi:96,label:'Écran · 96 ppp'},standard:{dpi:150,label:'Standard · 150 ppp'},print:{dpi:300,label:'Impression · 300 ppp'}}};

src/assets/conversion-engine.mjs

import {conversionConfig as limits} from './conversion-config.mjs';
import {extractLines,pageText} from './conversion-text.mjs';
let lib;
export async function pdfLibrary(){if(!lib){lib=await import('/vendor/pdf.mjs');lib.GlobalWorkerOptions.workerSrc='/vendor/pdf.worker.mjs'}return lib}
export function userError(error){if(error?.name==='PasswordException'||/password|encrypt|protégé/i.test(error?.message||''))return 'Ce PDF est protégé. Utilisez une copie non protégée que vous êtes autorisé à convertir.';if(error?.friendly)return error.message;if(/memory|alloc|canvas|size|length/i.test(error?.message||''))return 'La mémoire ne suffit pas pour ce rendu. Réduisez la résolution ou sélectionnez moins de pages.';return 'Ce PDF est illisible ou la conversion a échoué. Réexportez une copie valide, ou essayez une autre sortie locale.'}
function fail(message){const e=new Error(message);e.friendly=true;throw e}
export async function openPDF(file){
 if(!file.size)fail('Ce fichier est vide. Choisissez un PDF contenant des pages.');
 if(file.size>limits.maxFileBytes)fail(`Ce fichier dépasse ${limits.maxFileBytes/1024/1024} Mo. Choisissez un document plus petit.`);
 if(!/\.pdf$/i.test(file.name)&&file.type!=='application/pdf')fail('Choisissez un fichier au format PDF.');
 const bytes=new Uint8Array(await file.arrayBuffer());if(!new TextDecoder('latin1').decode(bytes.slice(0,1024)).includes('%PDF-'))fail('Ce fichier n’est pas un PDF valide.');
 const pdfjs=await pdfLibrary();const task=pdfjs.getDocument({data:bytes,cMapUrl:'/vendor/cmaps/',cMapPacked:true,standardFontDataUrl:'/vendor/standard_fonts/',wasmUrl:'/vendor/wasm/',stopAtErrors:true,isEvalSupported:false,enableXfa:false,verbosity:0,canvasMaxAreaInBytes:limits.maxPixels*4});
 try{const pdf=await task.promise;if(pdf.numPages>limits.maxPagesPerFile)fail(`Ce PDF dépasse ${limits.maxPagesPerFile} pages. Séparez-le en documents plus petits.`);const permissions=await pdf.getPermissions();if(permissions!==null)fail('Ce PDF est protégé. Utilisez une copie non protégée que vous êtes autorisé à convertir.');return {pdf,destroy:()=>task.destroy()}}catch(e){await task.destroy().catch(()=>{});throw e}
}
export async function renderImage(page,{dpi=150,type='png'}={}){
 const viewport=page.getViewport({scale:dpi/72});const width=Math.ceil(viewport.width),height=Math.ceil(viewport.height);
 if(width*height>limits.maxPixels||width>limits.maxSide||height>limits.maxSide)fail('Cette page dépasse la limite de rendu à cette résolution. Choisissez Écran ou Standard.');
 const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;
 try{await page.render({canvasContext:canvas.getContext('2d'),viewport,background:type==='jpeg'?'#ffffff':'rgba(0,0,0,0)',annotationMode:0}).promise;const blob=await new Promise(resolve=>canvas.toBlob(resolve,type==='jpeg'?'image/jpeg':'image/png',.9));if(!blob?.size)fail('L’image n’a pas pu être créée. Réduisez la résolution.');return {blob,width,height}}finally{canvas.width=canvas.height=0}
}
async function rasterImages(page,pdfjs,warnings){
 const ops=await page.getOperatorList(),images=[];const keys=new Set();
 for(let i=0;i<ops.fnArray.length;i++){
   const op=ops.fnArray[i];if(![pdfjs.OPS.paintImageXObject,pdfjs.OPS.paintInlineImageXObject].includes(op))continue;
   if(images.length>=limits.maxImagesPerPage){warnings.add('Certaines images n’ont pas été reprises : limite d’images par page atteinte.');break}
   let img;
   try{if(op===pdfjs.OPS.paintImageXObject){const key=ops.argsArray[i][0];if(keys.has(key))continue;keys.add(key);img=await Promise.race([new Promise(resolve=>page.objs.get(key,resolve)),new Promise((_,reject)=>setTimeout(()=>reject(new Error()),3000))])}else img=ops.argsArray[i][0];
     if(!img||img.width*img.height>limits.maxPixels)throw new Error();const canvas=document.createElement('canvas');canvas.width=img.width;canvas.height=img.height;const ctx=canvas.getContext('2d');
     if(img.bitmap)ctx.drawImage(img.bitmap,0,0);else if(img.data){const rgba=new Uint8ClampedArray(img.width*img.height*4);if(img.kind===3)rgba.set(img.data);else if(img.kind===2){for(let p=0,j=0;p<img.data.length;p+=3,j+=4){rgba[j]=img.data[p];rgba[j+1]=img.data[p+1];rgba[j+2]=img.data[p+2];rgba[j+3]=255}}else throw new Error();ctx.putImageData(new ImageData(rgba,img.width,img.height),0,0)}else throw new Error();
     const blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));canvas.width=canvas.height=0;if(!blob)throw new Error();images.push({width:img.width,height:img.height,buffer:await blob.arrayBuffer()});
   }catch{warnings.add('Une image n’a pas pu être extraite dans le DOCX. La sortie PNG/JPEG préserve mieux son apparence.')}
 }
 return images;
}
export function zipFiles(files,progress=()=>{}){
 return new Promise((resolve,reject)=>{const worker=new Worker('/assets/conversion-zip-worker.js');worker.onmessage=({data})=>{if('progress'in data){progress(data.progress);return}worker.terminate();data.error?reject(Object.assign(new Error(data.error),{friendly:true})):resolve(data.buffer)};worker.onerror=()=>{worker.terminate();reject(Object.assign(new Error('La création du fichier a échoué. Réessayez avec moins de pages.'),{friendly:true}))};const transfers=files.filter(f=>f.buffer).map(f=>f.buffer);worker.postMessage({files},transfers)})
}
export async function convertPDF(entry,settings,progress,onOutput){
 const pdfjs=await pdfLibrary(),warnings=new Set(),pages=[],texts=[];let chars=0,totalBytes=0;
 const fields=await entry.pdf.getFieldObjects().catch(()=>null);if(fields)warnings.add('Ce PDF contient un formulaire ou une signature : les champs interactifs et signatures ne sont pas conservés.');
 const attachments=await entry.pdf.getAttachments().catch(()=>null);if(attachments)warnings.add('Les pièces jointes du PDF ne sont pas reprises.');
 for(const [i,number]of settings.pages.entries()){
   progress(`Page ${i+1} sur ${settings.pages.length}…`);const page=await entry.pdf.getPage(number);
   try{
     if(settings.format==='images'){
       const rendered=await renderImage(page,{dpi:limits.resolutions[settings.resolution].dpi,type:settings.imageType});const output={name:`document-${entry.ordinal}-page-${String(number).padStart(3,'0')}.${settings.imageType==='jpeg'?'jpg':'png'}`,blob:rendered.blob};totalBytes+=output.blob.size;if(totalBytes>limits.maxOutputBytes)fail('Les sorties dépassent 200 Mo. Sélectionnez moins de pages ou une résolution inférieure.');await onOutput(output);
     }else{
       const content=await page.getTextContent();const lines=extractLines(content,page.getViewport({scale:1}),pdfjs.Util.transform);const text=pageText(lines);texts.push(text);chars+=text.trim().length;if(chars*2>limits.maxOutputBytes)fail('Le texte extrait dépasse la mémoire prévue. Sélectionnez moins de pages.');
       if(lines.some(l=>l.cells.length>1))warnings.add('Colonnes ou tableaux détectés : vérifiez l’ordre de lecture et les cellules reconstruites.');
       if(settings.format==='docx'){
         let images=[];const links=[];const annotations=await page.getAnnotations();for(const a of annotations){if(a.url)links.push(a.url);else if(a.subtype)warnings.add('Certaines annotations, signatures ou fonctions interactives ne sont pas reprises.')}
         if(!text.trim()){
           const rendered=await renderImage(page,{dpi:96,type:'png'});images=[{buffer:await rendered.blob.arrayBuffer(),width:rendered.width,height:rendered.height}];warnings.add('Au moins une page sans texte détectable a été conservée en image. Aucun OCR n’est effectué ; cette page n’est pas modifiable comme du texte.');
         }else images=await rasterImages(page,pdfjs,warnings);
         totalBytes+=images.reduce((n,img)=>n+img.buffer.byteLength,0)+text.length*2;if(totalBytes>limits.maxOutputBytes)fail('Ce DOCX dépasserait la mémoire prévue. Convertissez moins de pages.');
         pages.push({lines,images,links});
       }
     }
   }finally{page.cleanup()}
   await new Promise(resolve=>setTimeout(resolve,0));
 }
 if(settings.format==='text'){
   if(!chars)fail('Aucun texte détecté. Ce PDF est probablement scanné ou composé d’images. Un OCR serait nécessaire ; choisissez Images ou DOCX pour conserver son apparence.');
   const text=texts.map((text,i)=>text||`[Page ${settings.pages[i]} : aucun texte détecté]`).join('\n\n\f\n\n');if(texts.some(t=>!t.trim()))warnings.add('Certaines pages ne contiennent aucun texte détectable : un repère remplace leur contenu dans le TXT.');await onOutput({name:`document-${entry.ordinal}-extrait.txt`,blob:new Blob([text],{type:'text/plain;charset=utf-8'}),preview:text.slice(0,4000)});
 }else if(settings.format==='docx'){
   const {docxParts}=await import('./conversion-docx.mjs');const buffer=await zipFiles(docxParts(pages),p=>progress(`Création du DOCX : ${p} %.`));await onOutput({name:`document-${entry.ordinal}-converti.docx`,blob:new Blob([buffer],{type:'application/vnd.openxmlformats-officedocument.wordprocessingml.document'})});
   warnings.add('Le texte est reconstruit en paragraphes ; les images et liens détectés sont placés après le texte de leur page. Les polices et la mise en page d’origine ne sont pas garanties.');
 }
 return [...warnings];
}

src/assets/conversion-text.mjs

// Heuristiques de lecture, indépendantes de l’interface. Aucune reconnaissance OCR.
export function extractLines(content,viewport,transform){
 const entries=content.items.filter(i=>typeof i.str==='string'&&i.str.trim()).map(i=>{const t=transform(viewport.transform,i.transform);return {text:i.str,x:t[4],y:t[5],width:Math.abs(i.width||0),height:Math.max(6,Math.hypot(t[2],t[3])),font:i.fontName,dir:i.dir}}).sort((a,b)=>a.y-b.y||a.x-b.x);
 const rows=[];
 for(const item of entries){let row=rows.find(r=>Math.abs(r.y-item.y)<Math.max(2,item.height*.22));if(!row){row={y:item.y,items:[]};rows.push(row)}row.items.push(item)}
 return rows.sort((a,b)=>a.y-b.y).map(r=>{r.items.sort((a,b)=>a.x-b.x);const cells=[];let cell=[],previous=null;for(const item of r.items){if(previous&&item.x-(previous.x+previous.width)>Math.max(18,item.height*2)){cells.push(cell);cell=[]}cell.push(item);previous=item}if(cell.length)cells.push(cell);const join=items=>items.map((item,i)=>{if(!i)return item.text;const prev=items[i-1];return (item.x-prev.x-prev.width>item.height*.15?' ':'')+item.text}).join('');return {y:r.y,x:r.items[0]?.x??0,height:Math.max(...r.items.map(i=>i.height)),text:cells.map(join).join('\t'),cells:cells.map(c=>({x:c[0].x,text:join(c)})),rtl:r.items[0]?.dir==='rtl'}});
}
export function pageText(lines){return lines.map(l=>l.text).join('\n')}
export const xmlEscape=s=>String(s).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g,'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[c]));
export function safeLink(value){try{const u=new URL(value);return ['http:','https:','mailto:'].includes(u.protocol)?u.href:null}catch{return null}}

src/assets/conversion-docx.mjs

import {xmlEscape as x,safeLink} from './conversion-text.mjs';
const W='http://schemas.openxmlformats.org/wordprocessingml/2006/main';
const R='http://schemas.openxmlformats.org/officeDocument/2006/relationships';
const REL='http://schemas.openxmlformats.org/package/2006/relationships';
const xml=s=>'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+s;
// Le contenu PDF ne sert jamais à nommer un fichier ni à créer une URL de téléchargement.
export function docxParts(pages){
 const files=[],rels=[{id:'styles',type:'styles',target:'styles.xml'}];let imageId=0,linkId=0;
 function paragraph(text,{heading=false,rtl=false}={}){return `<w:p><w:pPr>${heading?'<w:pStyle w:val="Heading1"/>':''}${rtl?'<w:bidi/>':''}<w:spacing w:after="100"/></w:pPr><w:r><w:t xml:space="preserve">${x(text)}</w:t></w:r></w:p>`}
 function image(img){const id=++imageId;const maxW=6.3,maxH=8.8,scale=Math.min(maxW/img.width,maxH/img.height);const cx=Math.round(img.width*scale*914400),cy=Math.round(img.height*scale*914400);files.push({name:`word/media/image-${id}.png`,buffer:img.buffer});rels.push({id:`img${id}`,type:'image',target:`media/image-${id}.png`});return `<w:p><w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="${id}" name="Image ${id}" descr="Image extraite du PDF"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="${id}" name="Image ${id}"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="img${id}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>`}
 function table(rows){const cols=rows[0].cells.length;return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders>${['top','left','bottom','right','insideH','insideV'].map(k=>`<w:${k} w:val="single" w:sz="4" w:color="D9D9D9"/>`).join('')}</w:tblBorders></w:tblPr><w:tblGrid>${Array.from({length:cols},()=>'<w:gridCol w:w="2200"/>').join('')}</w:tblGrid>${rows.map(r=>`<w:tr>${r.cells.map(c=>`<w:tc><w:tcPr><w:tcW w:w="2200" w:type="dxa"/></w:tcPr>${paragraph(c.text)}</w:tc>`).join('')}</w:tr>`).join('')}</w:tbl>`}
 let body='';for(const [index,page]of pages.entries()){
   if(index)body+='<w:p><w:r><w:br w:type="page"/></w:r></w:p>';
   const median=page.lines.map(l=>l.height).sort((a,b)=>a-b)[Math.floor(page.lines.length/2)]||12;
   for(let i=0;i<page.lines.length;i++){
     const row=page.lines[i];let j=i+1;
     // Tableau simple : au moins trois rangées courtes, alignées sur les mêmes colonnes.
     if(row.cells.length>=2&&row.cells.every(c=>c.text.length<=70))while(j<page.lines.length){const next=page.lines[j];if(next.cells.length!==row.cells.length||next.cells.some((c,k)=>Math.abs(c.x-row.cells[k].x)>10||c.text.length>70))break;j++}
     if(j-i>=3){body+=table(page.lines.slice(i,j));i=j-1}else body+=paragraph(row.text,{heading:row.height>median*1.3,rtl:row.rtl});
   }
   for(const img of page.images)body+=image(img);
   for(const url of [...new Set(page.links)].map(safeLink).filter(Boolean)){const id=`link${++linkId}`;rels.push({id,type:'hyperlink',target:url,external:true});body+=`<w:p><w:hyperlink r:id="${id}"><w:r><w:rPr><w:color w:val="2457DB"/><w:u w:val="single"/></w:rPr><w:t xml:space="preserve">${x(url)}</w:t></w:r></w:hyperlink></w:p>`}
 }
 body+='<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="850" w:right="850" w:bottom="850" w:left="850"/></w:sectPr>';
 const textFiles={
 '[Content_Types].xml':xml('<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/></Types>'),
 '_rels/.rels':xml(`<Relationships xmlns="${REL}"><Relationship Id="document" Type="${R}/officeDocument" Target="word/document.xml"/></Relationships>`),
 'word/document.xml':xml(`<w:document xmlns:w="${W}" xmlns:r="${R}" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><w:body>${body}</w:body></w:document>`),
 'word/styles.xml':xml(`<w:styles xmlns:w="${W}"><w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial"/><w:sz w:val="22"/></w:rPr></w:rPrDefault></w:docDefaults><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:pPr><w:keepNext/><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:b/><w:sz w:val="30"/></w:rPr></w:style></w:styles>`),
 'word/_rels/document.xml.rels':xml(`<Relationships xmlns="${REL}">${rels.map(r=>`<Relationship Id="${r.id}" Type="${R}/${r.type}" Target="${x(r.target)}"${r.external?' TargetMode="External"':''}/>`).join('')}</Relationships>`)
 };for(const [name,text]of Object.entries(textFiles))files.push({name,text});return files;
}

src/assets/conversion-zip-worker.js

importScripts('/vendor/jszip.min.js');
self.onmessage=async({data})=>{try{const zip=new JSZip();for(const f of data.files)zip.file(f.name,f.text??f.buffer);const bytes=await zip.generateAsync({type:'uint8array',compression:'STORE'},m=>postMessage({progress:Math.round(m.percent)}));postMessage({buffer:bytes.buffer},[bytes.buffer])}catch{postMessage({error:'L’archive n’a pas pu être créée. Essayez avec moins de pages ; les sorties déjà réussies restent disponibles.'})}};

src/assets/conversion-downloads.mjs

const urls=new Set();
export function downloadBlob(blob,name){const url=URL.createObjectURL(blob);urls.add(url);const a=document.createElement('a');a.href=url;a.download=name;document.body.append(a);a.click();a.remove();setTimeout(()=>{URL.revokeObjectURL(url);urls.delete(url)},30000)}
export function releaseDownloadURLs(){for(const url of urls)URL.revokeObjectURL(url);urls.clear()}
window.addEventListener('pagehide',releaseDownloadURLs);

src/shared/layout.mjs

import { support } from './support.mjs';
import config from '../../site.config.mjs';
export const escape = s => String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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' } }
};