Revenir à l’outil
CODE SOURCE

Calculateur immobilier : les sources

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

src/pages/immobilier.mjs

const field=(id,label,{unit='€',help='',required=false,value=''}={})=>`<div class="calc-field"><label for="${id}">${label}${required?' <span class="required" aria-label="obligatoire">*</span>':''}</label><div class="unit-input"><input id="${id}" name="${id}" type="text" inputmode="decimal" value="${value}" ${required?'aria-required="true"':''} aria-describedby="${id}-error${help?' '+id+'-help':''}"><span>${unit}</span></div>${help?`<p class="muted small" id="${id}-help">${help}</p>`:''}<p class="field-error" id="${id}-error" hidden></p></div>`;
export const limits=`Simulation indicative fondée sur vos paramètres. Elle ne constitue pas une offre de prêt.`;
export default {
 route:'/outils/calculateur-immobilier',active:'immobilier',script:'immobilier.mjs',title:'Calculateur immobilier gratuit — Mensualités et coût du crédit',description:'Estimez le budget de votre achat immobilier, le capital à emprunter, vos mensualités et le coût du financement. Échéancier et CSV gratuits, sans inscription.',
 body:`<div class="page-heading"><a class="back" href="/">Tous les outils</a><div class="eyebrow">ACHAT IMMOBILIER</div><h1>Calculateur immobilier</h1><p class="intro">Estimez le budget de votre achat, vos mensualités et le coût de votre crédit.</p><p class="assurance">Gratuit <span>·</span> Sans inscription <span>·</span> Calcul dans votre navigateur</p></div>
 <div class="calculator"><form id="calculator-form" novalidate class="workspace"><p class="muted small">Les champs marqués * sont nécessaires en cas d’emprunt. Les frais facultatifs laissés vides valent zéro.</p><fieldset><legend><span>01</span> Mon achat</legend>${field('price','Prix du bien',{required:true})}<div class="field-pair">${field('works','Budget travaux')}${field('agency','Frais d’agence à ajouter',{help:'Si ces frais sont déjà inclus dans le prix du bien, laissez ce champ à zéro.'})}</div><label for="acquisitionMode">Frais d’acquisition (frais de notaire)</label><select id="acquisitionMode"><option value="amount">Montant connu en euros</option><option value="percent">Estimation en pourcentage du prix</option></select><div id="acquisition-amount-panel">${field('acquisitionAmount','Montant des frais d’acquisition')}</div><div id="acquisition-percent-panel" hidden>${field('acquisitionRate','Pourcentage estimé',{unit:'%',required:true})}<p id="acquisition-estimate" class="small" role="status"></p><p class="muted small">Estimation simplifiée sur le prix du bien uniquement, hors travaux et autres frais. Ce n’est pas un calcul réglementaire exact.</p></div>${field('otherProject','Autres frais du projet')}</fieldset>
 <fieldset><legend><span>02</span> Mon financement</legend>${field('contribution','Apport personnel',{help:'La somme de votre épargne que vous affectez à ce projet, pas nécessairement toute votre épargne.'})}<div class="field-pair">${field('years','Durée du prêt',{unit:'années',required:true})}${field('extraMonths','Mois supplémentaires',{unit:'mois',help:'De 0 à 11 mois.'})}</div>${field('rate','Taux nominal annuel, hors assurance',{unit:'%',required:true,help:'Saisissez votre hypothèse ou le taux proposé. Aucun taux du marché n’est prérempli.'})}</fieldset>
 <fieldset><legend><span>03</span> Assurance et frais du crédit</legend><label for="insuranceMode">Assurance emprunteur</label><select id="insuranceMode"><option value="none">Sans assurance dans la simulation</option><option value="monthly">Cotisation mensuelle connue</option><option value="rate">Taux annuel sur le capital initial</option></select><div id="insurance-value-panel" hidden>${field('insuranceValue','Assurance totale du prêt',{unit:'€ / mois',required:true})}<p id="insurance-help" class="muted small"></p></div><details class="finance-details"><summary>Frais de financement complémentaires</summary><div class="field-pair">${field('bankFees','Frais de dossier bancaire')}${field('guarantee','Frais de garantie')}${field('broker','Frais de courtage')}${field('otherFinance','Autres frais de financement')}</div><label for="feeMode">Comment payez-vous ces frais ?</label><select id="feeMode"><option value="financed">Intégrés au prêt</option><option value="separate">Payés séparément sur fonds personnels</option></select><p class="muted small">Payés séparément, ces frais s’ajoutent à l’apport utilisé et ne sont pas empruntés.</p></details></fieldset>
 <p id="calc-errors" role="alert" hidden></p><div class="actions"><button type="submit" class="primary">Calculer mon financement</button><button type="reset" class="secondary">Réinitialiser</button></div></form>
 <section id="calc-result" class="calc-result" aria-labelledby="calc-result-title"><div class="eyebrow">VOTRE FINANCEMENT</div><h2 id="calc-result-title">Votre simulation</h2><p id="calc-empty" class="muted">Renseignez votre achat et les conditions du prêt pour découvrir vos mensualités.</p><p id="calc-stale" class="warning" role="status" hidden>Les paramètres ont changé. Recalculez pour actualiser les résultats.</p><div id="calc-values" hidden><p id="no-loan" class="notice" hidden>Aucun emprunt n’est nécessaire. Il n’y a ni assurance ni échéancier de prêt dans cette simulation.</p><div class="main-metric"><span>Mensualité assurance comprise</span><strong id="monthly-total"></strong><small id="monthly-note"></small></div><dl class="key-results"><div><dt>Capital à emprunter</dt><dd id="capital"></dd></div><div><dt>Budget avant intérêts et assurance <small>Achat et frais de financement inclus</small></dt><dd id="budget"></dd></div><div><dt>Coût total du financement <small>Intérêts + assurance + frais</small></dt><dd id="financing-cost"></dd></div></dl><h3>Le détail de votre financement</h3><dl id="result-details" class="result-details"></dl><p id="excess" class="notice" hidden></p><figure id="repayment-chart"><figcaption>Répartition des remboursements</figcaption><div id="repayment-bars" class="repayment-bars" aria-hidden="true"></div><ul id="repayment-legend"></ul></figure><p id="calc-warning" class="warning" hidden></p><p id="rounding" class="muted small"></p><div class="actions print-hide"><button id="show-schedule" class="secondary" type="button" aria-expanded="false" aria-controls="schedule">Voir l’échéancier</button><button id="export-csv" class="secondary" type="button">Télécharger le CSV</button><button id="print-simulation" class="text-button" type="button">Imprimer la simulation</button></div></div><p class="simulation-limit">${limits}</p><p id="calc-announcement" class="sr-only" role="status"></p></section></div>
 <section id="schedule" class="workspace schedule" hidden><h2>Échéancier de remboursement</h2><p class="muted small">Les montants sont en euros. La dernière échéance est ajustée pour solder le capital.</p><div class="table-scroll" tabindex="0" role="region" aria-label="Tableau des mensualités"><table><caption class="sr-only">Détail mensuel du remboursement</caption><thead><tr><th scope="col">Échéance</th><th scope="col">Capital au début</th><th scope="col">Intérêts</th><th scope="col">Capital remboursé</th><th scope="col">Assurance</th><th scope="col">Paiement total</th><th scope="col">Capital à la fin</th></tr></thead><tbody id="schedule-body"></tbody></table></div><div class="pagination"><button id="schedule-prev" class="secondary">Précédent</button><span id="schedule-page" role="status"></span><button id="schedule-next" class="secondary">Suivant</button></div></section>
 <section id="print-parameters" class="print-only"><h2>Paramètres de la simulation</h2><dl id="print-inputs" class="result-details"></dl></section>
 <aside class="ad-space" data-ad-tool="immobilier" hidden aria-label="Publicité"><span>Publicité</span></aside>
 <section class="guide calc-guide"><div class="eyebrow">COMPRENDRE LES RÉSULTATS</div><h2>Du budget d’achat au coût du crédit</h2><p>Le budget du projet additionne le bien, les travaux, les frais d’agence à ajouter, les frais d’acquisition et les autres dépenses. Les frais de financement complètent ce budget. L’apport réduit la somme à emprunter ; lorsque les frais de financement sont payés séparément, ils s’ajoutent aux fonds personnels nécessaires.</p><p>Le coût du financement comprend les intérêts, l’assurance et les frais de financement. Le capital remboursé correspond à la somme prêtée : il n’est pas compté comme des intérêts. Les frais intégrés au prêt ne sont pas ajoutés une seconde fois au coût global.</p></section>
 <section class="faq"><h2>Questions sur votre simulation de crédit immobilier</h2><details><summary>Le taux nominal est-il le TAEG ?</summary><p>Non. Le taux nominal sert au calcul des intérêts. Le TAEG tient également compte des frais qui entrent dans son calcul. Ce calculateur n’affiche pas de TAEG. Pour en savoir plus, consultez <a href="https://www.economie.gouv.fr/particuliers/emprunter-et-sassurer/credit-quoi-correspond-le-taux-annuel-effectif-global-taeg">l’explication du ministère de l’Économie</a>.</p></details><details><summary>Comment est calculée l’assurance ?</summary><p>Vous pouvez exclure l’assurance, renseigner une cotisation mensuelle totale ou appliquer un taux annuel au capital initial. Ce dernier mode produit une cotisation constante. Les contrats calculés sur le capital restant dû et les quotités de plusieurs emprunteurs ne sont pas modélisés.</p></details><details><summary>Que se passe-t-il si mon apport est trop élevé ?</summary><p>L’outil n’utilise que l’apport nécessaire et indique le surplus non affecté. Sans emprunt, il ne calcule ni mensualité d’assurance ni échéancier. Des frais de financement saisis restent comptés : mettez-les à zéro s’ils ne s’appliquent pas.</p></details><details><summary>Quelles sont les limites de ce calcul ?</summary><p>Le modèle couvre un prêt unique, à taux fixe, amortissable, sur des périodes mensuelles régulières. Il exclut les taux variables, différés, remboursements anticipés et montages multiprêts. Les frais estimés peuvent différer des montants réels. La simulation ne vaut pas accord de financement.</p></details><details><summary>Mes données sont-elles enregistrées ?</summary><p>Les calculs sont effectués dans votre navigateur. Aucune donnée financière n’est envoyée à un serveur, à une régie publicitaire ou à un outil de mesure. La simulation n’est pas stockée de façon persistante. Vous pouvez télécharger l’échéancier en CSV ou imprimer les résultats.</p></details></section><div class="source-link"><a href="/outils/calculateur-immobilier/source">Consulter le code source du calculateur</a></div>`
};

src/assets/immobilier.mjs

import {compute,decimal,euros,csv,columns} from './immobilier-calcul.mjs';
import {registerSelectionTool} from './webmcp.mjs';
const $=id=>document.getElementById(id),form=$('calculator-form');
let current=null,snapshot=null,stale=false,page=0,csvUrl=null;
const fields=['price','works','agency','otherProject','contribution','bankFees','guarantee','broker','otherFinance','years','extraMonths','rate','acquisitionAmount','acquisitionRate','insuranceValue'];
const moneyLabels={price:'Prix du bien',works:'Travaux',agency:'Frais d’agence à ajouter',acquisition:'Frais d’acquisition',otherProject:'Autres frais du projet',contribution:'Apport personnel',bankFees:'Frais de dossier',guarantee:'Frais de garantie',broker:'Frais de courtage',otherFinance:'Autres frais de financement'};
function appendRow(parent,label,value){const wrap=document.createElement('div'),dt=document.createElement('dt'),dd=document.createElement('dd');dt.textContent=label;dd.textContent=value;wrap.append(dt,dd);parent.append(wrap)}
function updateModes(){
 const percent=$('acquisitionMode').value==='percent';$('acquisition-amount-panel').hidden=percent;$('acquisition-percent-panel').hidden=!percent;
 const price=decimal($('price').value),rate=decimal($('acquisitionRate').value);$('acquisition-estimate').textContent=price.error||rate.error?'Saisissez un prix et un pourcentage valides.':`${euros(price.value*rate.value/100)} estimés sur un prix de ${euros(price.value)}.`;
 const ins=$('insuranceMode').value;$('insurance-value-panel').hidden=ins==='none';$('insurance-value-panel').querySelector('.unit-input span').textContent=ins==='rate'?'% / an':'€ / mois';
 $('insurance-help').textContent=ins==='rate'?'Taux annuel pour l’assurance totale du prêt, appliqué au capital initial. Cotisation constante ; les contrats sur capital restant dû ne sont pas modélisés.':'Cotisation mensuelle totale pour l’ensemble du prêt simulé.';
}
function markStale(){updateModes();if(!current)return;stale=true;$('calc-stale').hidden=false;$('calc-values').classList.add('stale');$('schedule').hidden=true;$('show-schedule').setAttribute('aria-expanded','false');for(const id of ['show-schedule','export-csv','print-simulation'])$(id).disabled=true;if(csvUrl){URL.revokeObjectURL(csvUrl);csvUrl=null}}
function inputs(){
 const values={},errors={};
 const read=(key,required=false)=>{const r=decimal($(key).value,{required});if(r.error)errors[key]=r.error;values[key]=r.value??0;return values[key]};
 for(const key of Object.keys(moneyLabels).filter(k=>k!=='acquisition'))read(key,key==='price');
 const acquisitionMode=$('acquisitionMode').value;
 values.acquisition=acquisitionMode==='percent'?values.price*read('acquisitionRate',true)/100:read('acquisitionAmount');
 values.feeMode=$('feeMode').value;values.insuranceMode=$('insuranceMode').value;
 const cost=values.price+values.works+values.agency+values.acquisition+values.otherProject;
 const fees=values.bankFees+values.guarantee+values.broker+values.otherFinance;
 const needsLoan=cost+(values.feeMode==='financed'?fees:0)>values.contribution;
 const years=read('years'),months=read('extraMonths');read('rate',needsLoan);
 if(!Number.isInteger(months)||months>11)errors.extraMonths='Saisissez un nombre entier de 0 à 11 mois.';
 values.months=Math.round(years*12+months);
 if(needsLoan&&(values.months<1||values.months>1200))errors.years='Saisissez une durée entre 1 et 1 200 mois au total (limite technique de l’échéancier).';
 values.insuranceValue=values.insuranceMode==='none'?0:read('insuranceValue',needsLoan);
 if(!Number.isFinite(values.acquisition)||values.acquisition>1e12)errors[acquisitionMode==='percent'?'acquisitionRate':'acquisitionAmount']='Les frais estimés sont trop élevés. Vérifiez le prix et les frais.';
 return {values,errors,acquisitionMode,acquisitionRate:values.acquisitionRate};
}
function renderSchedule(){
 const rows=current.schedule.slice(page*12,(page+1)*12);$('schedule-body').replaceChildren();for(const row of rows){const tr=document.createElement('tr');for(const [key]of columns){const td=document.createElement(key==='number'?'th':'td');if(key==='number')td.scope='row';td.textContent=key==='number'?row[key]:euros(row[key]);tr.append(td)}$('schedule-body').append(tr)}
 $('schedule-page').textContent=`Échéances ${page*12+1} à ${Math.min((page+1)*12,current.months)} sur ${current.months}`;$('schedule-prev').disabled=page===0;$('schedule-next').disabled=(page+1)*12>=current.months;
}
function render(){
 const r=current;$('calc-values').hidden=false;$('calc-values').classList.remove('stale');$('calc-empty').hidden=true;$('calc-stale').hidden=true;$('no-loan').hidden=r.capital>0;
 $('monthly-total').textContent=euros(r.paymentWithInsurance);$('monthly-note').textContent=r.capital?`par mois, sur ${r.months} mensualités`:'Aucun remboursement de prêt';$('capital').textContent=euros(r.capital);$('budget').textContent=euros(r.budget);$('financing-cost').textContent=euros(r.financingCost);
 $('result-details').replaceChildren();for(const [label,key]of [['Budget de l’achat hors frais de financement','project'],['Mensualité hors assurance','payment'],['Assurance mensuelle','monthlyInsurance'],['Intérêts totaux','interest'],['Assurance totale','insurance'],['Frais de financement','fees'],['Fonds personnels nécessaires','personal'],['Total remboursé à la banque et à l’assureur','repaid'],['Coût global du projet financé','global']])appendRow($('result-details'),label,euros(r[key]));
 $('excess').hidden=!r.excess;$('excess').textContent=`Apport non utilisé : ${euros(r.excess)}. Cette somme reste en dehors du financement simulé.`;
 $('repayment-chart').hidden=!r.capital;$('repayment-bars').replaceChildren();$('repayment-legend').replaceChildren();for(const [label,key,color]of [['Capital','capital','#2457db'],['Intérêts','interest','#ea9b35'],['Assurance','insurance','#7481a0']]){const bar=document.createElement('span');bar.style.width=`${r.repaid?r[key]/r.repaid*100:0}%`;bar.style.background=color;$('repayment-bars').append(bar);const li=document.createElement('li');li.textContent=`${label} : ${euros(r[key])}`;li.style.setProperty('--legend-color',color);$('repayment-legend').append(li)}
 const high=snapshot.values.rate>15||r.capital>10000000||r.months>420||snapshot.values.insuranceValue>100&&snapshot.values.insuranceMode==='rate';$('calc-warning').hidden=!high;$('calc-warning').textContent='Certains paramètres sont élevés. Vérifiez votre saisie ; cet avertissement ne constitue pas une décision bancaire.';
 $('rounding').textContent=r.capital?`Échéances calculées au centime. Dernier paiement, assurance comprise : ${euros(r.lastPayment)}. Le capital restant dû final est nul.`:'';
 for(const id of ['show-schedule','export-csv']){$(id).hidden=!r.capital;$(id).disabled=false}$('print-simulation').disabled=false;$('schedule').hidden=true;$('show-schedule').textContent='Voir l’échéancier';$('show-schedule').setAttribute('aria-expanded','false');
 $('print-inputs').replaceChildren();for(const [key,label]of Object.entries(moneyLabels))appendRow($('print-inputs'),label,euros(snapshot.values[key]));appendRow($('print-inputs'),'Paiement des frais de financement',snapshot.values.feeMode==='financed'?'Intégrés au prêt':'Payés séparément sur fonds personnels');appendRow($('print-inputs'),'Frais d’acquisition',snapshot.acquisitionMode==='percent'?`${snapshot.acquisitionRate} % du prix du bien (estimation simplifiée)`:'Montant renseigné');appendRow($('print-inputs'),'Durée',`${r.months} mensualités`);appendRow($('print-inputs'),'Taux nominal annuel',`${snapshot.values.rate} %`);appendRow($('print-inputs'),'Assurance',snapshot.values.insuranceMode==='none'?'Sans assurance':snapshot.values.insuranceMode==='rate'?`${snapshot.values.insuranceValue} % par an sur le capital initial`:`${euros(snapshot.values.insuranceValue)} par mois`);
 $('calc-announcement').textContent=r.capital?`Simulation calculée. Mensualité avec assurance : ${euros(r.paymentWithInsurance)}. Capital à emprunter : ${euros(r.capital)}.`:'Simulation calculée. Aucun emprunt nécessaire.';
}
function calculate(){
 for(const key of fields){$(key).removeAttribute('aria-invalid');$(key+'-error').hidden=true}
 const result=inputs();$('calc-errors').hidden=true;
 if(Object.keys(result.errors).length){for(const [key,message]of Object.entries(result.errors)){$(key).setAttribute('aria-invalid','true');$(key+'-error').textContent=message;$(key+'-error').hidden=false;$(key).closest('details')?.setAttribute('open','')}$('calc-errors').hidden=false;$('calc-errors').textContent='Corrigez les champs indiqués avant de calculer.';$(Object.keys(result.errors)[0]).focus();return false}
 try{const computed=compute(result.values);current=computed;snapshot=result;stale=false;page=0;if(csvUrl)URL.revokeObjectURL(csvUrl);csvUrl=null;render();return true}catch(e){$('calc-errors').hidden=false;$('calc-errors').textContent=e.message;return false}
}
form.addEventListener('input',markStale);form.addEventListener('change',markStale);form.addEventListener('submit',e=>{e.preventDefault();calculate()});form.addEventListener('reset',()=>{current=null;snapshot=null;stale=false;page=0;if(csvUrl)URL.revokeObjectURL(csvUrl);csvUrl=null;setTimeout(()=>{updateModes();$('calc-empty').hidden=false;$('calc-values').hidden=true;$('calc-stale').hidden=true;$('schedule').hidden=true;$('print-inputs').replaceChildren();$('calc-errors').hidden=true;for(const key of fields){$(key+'-error').hidden=true;$(key).removeAttribute('aria-invalid')}$('price').focus();$('calc-announcement').textContent='Simulation réinitialisée.'},0)});
$('show-schedule').onclick=()=>{if(stale||!current)return;$('schedule').hidden=!$('schedule').hidden;$('show-schedule').setAttribute('aria-expanded',String(!$('schedule').hidden));$('show-schedule').textContent=$('schedule').hidden?'Voir l’échéancier':'Masquer l’échéancier';if(!$('schedule').hidden){renderSchedule();$('schedule').scrollIntoView({block:'nearest'})}};
$('schedule-prev').onclick=()=>{if(page>0){page--;renderSchedule()}};$('schedule-next').onclick=()=>{if((page+1)*12<current.months){page++;renderSchedule()}};
$('export-csv').onclick=()=>{if(stale||!current?.capital)return;csvUrl ||= URL.createObjectURL(new Blob([csv(current)],{type:'text/csv;charset=utf-8'}));const a=document.createElement('a');a.href=csvUrl;a.download='echeancier-immobilier.csv';a.click()};
$('print-simulation').onclick=()=>{if(!stale&&current)window.print()};
window.addEventListener('beforeprint',()=>document.body.classList.toggle('print-invalid',stale||!current));window.addEventListener('pagehide',()=>{if(csvUrl)URL.revokeObjectURL(csvUrl)});updateModes();
registerSelectionTool({name:'calculate_current_mortgage',title:'Calculer le financement renseigné',description:'Calcule la simulation avec les valeurs déjà présentes dans le formulaire. Ne transmet ni ne conserve les données.',inputSchema:{type:'object',properties:{},additionalProperties:false},annotations:{readOnlyHint:false,untrustedContentHint:false},execute(){if(!calculate())throw new Error('Les paramètres du formulaire sont invalides.');return {calculated:true,scheduleMonths:current.months}}});

src/assets/immobilier-calcul.mjs

// Tous les flux monétaires de l'échéancier sont calculés en centimes entiers.
export const euros = n => new Intl.NumberFormat('fr-FR',{style:'currency',currency:'EUR'}).format(n);
export function decimal(value,{required=false}={}) {
  const text=String(value??'').trim().replace(/[\s\u00a0\u202f]/g,'');
  if(!text)return required?{error:'Renseignez ce champ.'}:{value:0};
  if(!/^\+?\d+(?:[.,]\d+)?$/.test(text))return {error:'Saisissez un nombre positif ou nul, avec une virgule ou un point.'};
  const n=Number(text.replace(',','.'));
  return Number.isFinite(n)&&n<=1e12?{value:n}:{error:'Cette valeur dépasse la précision de cette simulation. Vérifiez la saisie.'};
}
const cents = n => Math.round((n+Number.EPSILON)*100);
export function compute(p) {
  const amounts=['price','works','agency','acquisition','otherProject','contribution','bankFees','guarantee','broker','otherFinance'];
  for(const key of amounts)if(!Number.isFinite(p[key])||p[key]<0||p[key]>1e12)throw new Error('Un montant est invalide. Vérifiez les paramètres.');
  if(!['financed','separate'].includes(p.feeMode)||!['none','monthly','rate'].includes(p.insuranceMode))throw new Error('Choisissez un mode de financement et d’assurance valide.');
  const A=amounts.slice(0,5).reduce((n,k)=>n+cents(p[k]),0),F=amounts.slice(6).reduce((n,k)=>n+cents(p[k]),0),P=cents(p.contribution);
  const target=A+(p.feeMode==='financed'?F:0),C=Math.max(0,target-P),personal=Math.min(P,target)+(p.feeMode==='separate'?F:0),excess=Math.max(0,P-target);
  const common={project:A/100,fees:F/100,budget:(A+F)/100,capital:C/100,personal:personal/100,excess:excess/100};
  if(!C)return {...common,months:0,payment:0,monthlyInsurance:0,paymentWithInsurance:0,interest:0,insurance:0,financingCost:F/100,repaid:0,global:(A+F)/100,schedule:[],lastPayment:0};
  if(!Number.isFinite(p.rate)||p.rate<0)throw new Error('Renseignez un taux nominal positif ou nul.');
  if(!Number.isInteger(p.months)||p.months<1||p.months>1200)throw new Error('La durée doit comprendre entre 1 et 1 200 mensualités (limite technique de l’échéancier).');
  if(p.insuranceMode!=='none'&&(!Number.isFinite(p.insuranceValue)||p.insuranceValue<0))throw new Error('Renseignez une assurance positive ou nulle.');
  const i=p.rate/1200;
  const raw=i===0?C/p.months:C*i/-Math.expm1(-p.months*Math.log1p(i));
  const regular=Math.round(raw),insurance=p.insuranceMode==='none'?0:p.insuranceMode==='monthly'?cents(p.insuranceValue):Math.round(C*p.insuranceValue/1200);
  if(!Number.isSafeInteger(regular)||!Number.isSafeInteger(insurance)||!Number.isSafeInteger((regular+insurance)*p.months))throw new Error('Les valeurs produisent des montants trop élevés pour cette simulation. Vérifiez les taux et les montants.');
  let balance=C,totalInterest=0,totalInsurance=0;const schedule=[];
  for(let n=1;n<=p.months;n++){
    const start=balance,interest=Math.round(start*i);
    const principal=n===p.months?balance:Math.min(balance,Math.max(0,regular-interest));
    balance-=principal;const ins=start>0?insurance:0;totalInterest+=interest;totalInsurance+=ins;
    schedule.push({number:n,start:start/100,interest:interest/100,principal:principal/100,insurance:ins/100,payment:(principal+interest+ins)/100,end:balance/100});
  }
  return {...common,months:p.months,payment:regular/100,monthlyInsurance:insurance/100,paymentWithInsurance:(regular+insurance)/100,interest:totalInterest/100,insurance:totalInsurance/100,financingCost:(F+totalInterest+totalInsurance)/100,repaid:(C+totalInterest+totalInsurance)/100,global:(A+F+totalInterest+totalInsurance)/100,schedule,lastPayment:schedule.at(-1).payment};
}
export const columns=[['number','Échéance'],['start','Capital restant dû au début (€)'],['interest','Intérêts (€)'],['principal','Capital remboursé (€)'],['insurance','Assurance (€)'],['payment','Paiement total (€)'],['end','Capital restant dû à la fin (€)']];
export function csv(result){return '\ufeff'+columns.map(c=>c[1]).join(';')+'\r\n'+result.schedule.map(row=>columns.map(([key])=>key==='number'?String(row[key]):row[key].toFixed(2).replace('.',',')).join(';')).join('\r\n')+'\r\n'}

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' } }
};