Ctrl K
ComunidadGuías…

export.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
'use strict';
// ============================================================
// InfraNet Pro - Export frontend module
// JSON backup, cable-label CSV, floor SVG and PDF report export.
// Loaded after netmapper.html so it can use the shared app state/helpers.
// ============================================================

(function(){
// Nome localizzato del tipo device. export.js è classic: usa il `typeName`
// globale esposto da app-types.js (via expose), con fallback al nome del
// catalogo se assente (es. in contesti di test senza bridge). `typeof` su un
// global non dichiarato non lancia.
const _typeName = (k) => (typeof typeName === 'function')
    ? typeName(k)
    : ((typeof TYPES !== 'undefined' && TYPES[k] && TYPES[k].name) || k);

// Frase localizzata. Come `_typeName`: export.js è uno <script> classic, quindi
// `t` arriva da lib/i18n.js su window (caricato prima, e queste sono chiamate a
// runtime). La guardia `typeof` c'era già in due punti sparsi — ora la forma è
// UNA, con scritto qui perché esiste: se qualcuno caricasse questo file da solo,
// la frase deve restare una frase, non sparire.
const _t = (key, fallback, vars) => (typeof t === 'function' ? t(key, vars) : fallback);

// Il Dossier di consegna (N4) è la CORNICE di ogni PDF: copertina in testa, storia
// modifiche in coda. Non è un'opzione — non ha una casella da spegnere — e
// `exportPDF` lo rimette comunque sopra a qualunque cosa gli si passi.
const PDF_DOSSIER = {
    includeCover:     true,
    includeChangelog: true,
};

// Le SEZIONI invece partono tutte spente: chi apre il dialogo sceglie cosa aggiungere
// alla cornice (il vecchio «Dossier di consegna» era il preset «tutte accese»: ora è
// «Seleziona tutto»). Nessuna preferenza persistita le riaccende: a ogni apertura il
// dialogo riparte da qui.
const PDF_EXPORT_DEFAULTS = {
    includePlanimetria: false,
    includeBackground:  false,
    includeOverview:    false,  // Panoramica: la sintesi esecutiva in testa al dossier
    includeInventory:   false,
    includeAsBuilt:     false,
    includeRacks:       false,
    includePorts:       false,
    includeVlans:       false,
    includeTopology:    false,
    includeVms:         false,  // capitolo "Macchine virtuali": catalogo delle VM per host
    includePdu:         false,  // capitolo "Alimentazione": riepilogo PDU + dettaglio prese
    includeSpare:       false,
    includeAssets:      false,  // registro asset per-device (NIS2/ISO): server-side da nodeToDevice
    includeRecovery:    false,  // sezione Ripristinabilità (DR): backup pointer + serial/firmware + posizione
    includeWan:         false,  // capitolo "WAN": mappa inter-sede + schede per rifare linee e collegamenti
};

// 🔒 Il file che si scarica passa SEMPRE dalla redazione delle credenziali
// (lib/project-format.js). C'era un ripiego che, se quel file non si fosse
// caricato, scaricava lo state GREZZO — community e password SNMPv3 comprese —
// in un file chiamato «backup»: proprio quello che si allega a una segnalazione,
// si manda a un collega, si mette in una cartella condivisa. Un ripiego non può
// esportare più della strada normale: senza redazione non si esporta niente, e
// lo si dice. La prova è test/credenziali-non-escono.test.js.
function exportJSON() {
    if (typeof createPortableProjectExport !== 'function') {
        if (typeof _showToast === 'function')
            _showToast(_t('impexp.jsonExportUnavailable', 'JSON export unavailable: the credential redaction did not load. Reload the page.'), 'warn');
        return;
    }
    const payload = createPortableProjectExport(state, { projectId: typeof currentProjectId !== 'undefined' ? currentProjectId : '' });
    const blob = new Blob([JSON.stringify(payload,null,2)],{type:'application/json'});
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = 'infranet-backup.json';
    a.click();
    setTimeout(()=>URL.revokeObjectURL(a.href),5000);
}

// Nome della stanza che contiene un node (geometrico: le stanze sono rettangoli
// strutturali sul floor, nessuna assegnazione esplicita). Per i device in rack
// si usa la posizione del rack. '' se il node non e' dentro nessuna stanza.
function _nodeRoomName(node){
    if(!node) return '';
    let px=node.x, py=node.y;
    if((px==null||py==null) && node.rackId && typeof getRackById==='function'){
        const rk=getRackById(node.rackId);
        if(rk){ px=rk.x; py=rk.y; }
    }
    if(px==null||py==null) return '';
    for(const r of state.nodes){
        if(r.type!=='room') continue;
        const rw=r.w||200, rh=r.h||200;
        if(px>=r.x && px<=r.x+rw && py>=r.y && py<=r.y+rh) return r.name||'';
    }
    return '';
}

// Righe etichetta cavo (unica sorgente per CSV + PDF): inietta i global
// dell'app nel builder puro lib/cable-labels.js (buildCableLabelRows).
function _cableLabelRows(){
    return buildCableLabelRows({
        links: state.links,
        helpers: {
            nodeByPortId:   getNodeByPortId,
            cableAutoLabel: _cableAutoLabel,
            linkVlan:       _getLinkVlan,
            vlanNames:      state.vlanNames || {},
            roomName:       _nodeRoomName,
        },
    });
}

// Campi etichetta selezionabili (ordine = ordine colonne CSV / righe etichetta).
// Unica definizione lato client; il server ha la lista equivalente in label-sheet.js.
//
// ⚠️ `t` è l'etichetta che si legge A SCHERMO, non l'intestazione del CSV: quella
// è la CHIAVE `k` (v. `_csvColumnsFor`). Tradurre queste parole non cambia di una
// virgola il file esportato — che è la ragione per cui erano rimaste indietro.
// Si valuta a ogni lettura (funzione, non costante): cambiare lingua a runtime
// deve cambiare anche queste, come ogni altra scritta dell'app.
// «VLAN» resta com'è: è un termine del glossario, non si traduce.
const LABEL_FIELDS = () => [
    { k:'label',         t:_t('impexp.lblField.label', 'Label (ID)') },
    { k:'da',            t:_t('impexp.lblField.from', 'From') },
    { k:'a',             t:_t('impexp.lblField.to', 'To') },
    { k:'lunghezza',     t:_t('impexp.lblField.length', 'Length') },
    { k:'tipo_cavo',     t:_t('impexp.lblField.cableType', 'Cable type') },
    { k:'vlan',          t:'VLAN' },
    { k:'permanente',    t:_t('impexp.lblField.permanent', 'Permanent/patch') },
    { k:'installato_il', t:_t('impexp.lblField.installedOn', 'Installed on') },
    { k:'installato_da', t:_t('impexp.lblField.installedBy', 'Installed by') },
    { k:'stanza',        t:_t('impexp.lblField.room', 'Room') },
];

// True se il progetto ha almeno un nome VLAN definito.
function _hasVlanNames(){
    return Object.values(state.vlanNames||{}).some(v=>v && String(v).trim());
}

// Colonne CSV per i campi scelti. La VLAN aggiunge la colonna `vlan_nome` SOLO
// se nel progetto esistono nomi VLAN (altrimenti sarebbe una colonna vuota che
// sembra un campo duplicato).
function _csvColumnsFor(fields){
    const cols=[]; const add=(h,v)=>cols.push({h,v});
    // etichetta: se NON ci sono anche da+a in export, usa l'etichetta completa
    // (con fallback "da → a", sempre stampabile); se invece esporti gia' da+a,
    // usa solo il codice assegnato a mano per non duplicare le colonne.
    if(fields.has('label')){
        const useFull = !(fields.has('da') && fields.has('a'));
        add('etichetta', r=>((useFull ? r.label : r.customLabel)||'').replace(/→/g,'->'));
    }
    if(fields.has('da'))            add('da',            r=>r.from);
    if(fields.has('a'))             add('a',             r=>r.to);
    if(fields.has('lunghezza'))     add('lunghezza_m',   r=>r.lengthM!=null?String(r.lengthM):'');
    if(fields.has('tipo_cavo'))     add('tipo_cavo',     r=>r.cableType);
    if(fields.has('vlan')){          add('vlan',          r=>r.vlan!=null?String(r.vlan):'');
                                     if(_hasVlanNames()) add('vlan_nome', r=>r.vlanName); }
    if(fields.has('permanente'))    add('permanente',    r=>r.isPermanent?'si':'no');
    if(fields.has('installato_il')) add('installato_il', r=>r.installedAt);
    if(fields.has('installato_da')) add('installato_da', r=>r.installedBy);
    if(fields.has('stanza'))        add('stanza',        r=>r.room);
    return cols;
}


// Nome del progetto APERTO, per titoli e nomi di file. `#header-proj-name` non esiste piu' (la barra
// del progetto e' quella in alto, `#project-select`) e `state.projectName` non lo scrive nessuno: tutti
// gli export uscivano «InfraNet Pro» / «infranet_cables.csv», qualunque progetto fosse aperto.
function _exportProjectName(){
    try{
        const sel=document.getElementById('project-select');
        const o=sel&&sel.selectedOptions&&sel.selectedOptions[0];
        const txt=o&&String(o.textContent||'').trim();
        if(txt) return txt;
    }catch(_){ /* nessun DOM: si ripiega */ }
    const st=(typeof state==='object'&&state)||{};
    return (st.projectName&&String(st.projectName).trim())||'';
}
// Stem sicuro per un file: lettere/cifre/_-, mai vuoto o fatto di soli trattini bassi.
function _exportFileStem(){
    const stem=(_exportProjectName()||'infranet').replace(/[^a-z0-9_-]/gi,'_').replace(/_{2,}/g,'_').replace(/^_+|_+$/g,'');
    return stem||'infranet';
}

function exportLabelsCSV(fields){
    const set = fields instanceof Set ? fields : new Set(LABEL_FIELDS().map(f=>f.k));
    const cols=_csvColumnsFor(set);
    if(!cols.length) cols.push({h:'etichetta',v:r=>(r.label||'').replace(/→/g,'->')});
    const rows=[cols.map(c=>c.h)];
    _cableLabelRows().forEach(r=>rows.push(cols.map(c=>c.v(r))));
    const csv=''+rows.map(r=>r.map(c=>`"${String(c==null?'':c).replace(/"/g,'""')}"`).join(',')).join('\r\n');
    const blob=new Blob([csv],{type:'text/csv;charset=utf-8'});
    const a=document.createElement('a');
    a.href=URL.createObjectURL(blob);
    a.download=`${_exportFileStem()}_cables.csv`;
    a.click();
    setTimeout(()=>URL.revokeObjectURL(a.href),5000);
}

// ---- Export etichette: overlay (CSV o PDF Avery/Dymo/generico) -------------

// Dimensioni nominali (mm) per il rapporto d'aspetto dell'anteprima PDF.
const LABEL_TEMPLATE_DIM = {
    'avery-l7651':[38.1,21.2], 'avery-22806':[38.1,38.1],
    'dymo-99010':[89,28], 'dymo-11353':[25,13], 'generic-grid':[48,25],
};
// Campi attivi di default all'apertura: solo l'etichetta (uso tipico = etichetta
// unica da stampare). Gli estremi da/a si aggiungono con le checkbox al bisogno.
const LABEL_DEFAULT_FIELDS = ['label'];

function openLabelExportOptions(){
    const ov=document.getElementById('label-export-overlay');
    if(!ov) return;
    // Genera le checkbox dei campi una sola volta.
    const box=document.getElementById('lblexp-fields');
    if(box && !box.dataset.built){
        box.innerHTML=LABEL_FIELDS().map(f=>
            `<label style="display:flex;align-items:center;gap:6px;font-size:0.95rem">`+
            `<input type="checkbox" class="lblexp-fld" value="${f.k}" `+
            `${LABEL_DEFAULT_FIELDS.includes(f.k)?'checked':''} onchange="syncLabelExportUi()"> ${f.t}</label>`
        ).join('');
        box.dataset.built='1';
    }
    ov.classList.add('open');
    syncLabelExportUi();
}

function closeLabelExportOptions(){
    document.getElementById('label-export-overlay')?.classList.remove('open');
}

function _selectedLabelFields(){
    return new Set([...document.querySelectorAll('.lblexp-fld:checked')].map(c=>c.value));
}

function syncLabelExportUi(){
    const fmt=document.getElementById('lblexp-format')?.value||'csv';
    const isCsv=fmt==='csv';
    const isGeneric=fmt==='generic-grid';
    const wrapRow=document.getElementById('lblexp-wrap-row');
    const grid=document.getElementById('lblexp-grid');
    const hint=document.getElementById('lblexp-hint');
    if(wrapRow) wrapRow.style.display=isCsv?'none':'flex';
    if(grid)    grid.style.display   =isGeneric?'flex':'none';
    if(hint){
        hint.textContent=isCsv
            ? 'CSV: una colonna per campo selezionato. Importalo nel tuo software etichette.'
            : 'Suggerimento: stampa prima una pagina di PROVA su carta normale e appoggiala al foglio etichette per verificare l\'allineamento.';
    }
    _renderLabelPreview();
}

// Righe testo dell'anteprima etichetta (mirror di _cellLines del server).
function _previewLines(r, fields){
    const lines=[];
    if(fields.has('label')) lines.push({t:(r.label||'').replace(/→/g,'->'), cls:'h'});
    const ft=[]; if(fields.has('da')) ft.push(r.from||'?'); if(fields.has('a')) ft.push(r.to||'?');
    if(ft.length) lines.push({t:ft.join(' → '), cls:'s'});
    const chips=[];
    if(fields.has('vlan')&&r.vlan!=null) chips.push('V'+r.vlan+(r.vlanName?' '+r.vlanName:''));
    if(fields.has('tipo_cavo')&&r.cableType) chips.push(r.cableType);
    if(fields.has('lunghezza')&&r.lengthM!=null) chips.push(r.lengthM+'m');
    if(fields.has('permanente')) chips.push(r.isPermanent?'permanente':'bretella');
    if(chips.length) lines.push({t:chips.join('  ·  '), cls:'m'});
    const inst=[]; if(fields.has('installato_il')&&r.installedAt) inst.push(r.installedAt);
    if(fields.has('installato_da')&&r.installedBy) inst.push(r.installedBy);
    if(inst.length) lines.push({t:inst.join(' · '), cls:'m'});
    if(fields.has('stanza')&&r.room) lines.push({t:r.room, cls:'m'});
    return lines;
}

function _renderLabelPreview(){
    const box=document.getElementById('lblexp-preview');
    if(!box) return;
    const fmt=document.getElementById('lblexp-format')?.value||'csv';
    const fields=_selectedLabelFields();
    const rows=_cableLabelRows();
    const count=rows.length;
    const head=`<div style="font-size:0.72rem;color:var(--text-muted);margin-bottom:6px">Anteprima — ${count} etichett${count===1?'a':'e'}</div>`;
    if(!count){ box.innerHTML=head+`<div style="font-size:0.78rem;color:var(--text-muted)">${_t('impexp.noCables','No cables in the project.')}</div>`; return; }

    if(fmt==='csv'){
        const cols=_csvColumnsFor(fields);
        if(!cols.length){ box.innerHTML=head+`<div style="font-size:0.78rem;color:var(--text-muted)">${_t('impexp.pickAField','Select at least one field.')}</div>`; return; }
        const esc=escapeHTML;   // una definizione sola: quella di app-util (esposta su window)
        const th=cols.map(c=>`<th style="text-align:left;padding:3px 8px;border-bottom:1px solid var(--border);white-space:nowrap">${esc(c.h)}</th>`).join('');
        const trs=rows.slice(0,4).map(r=>'<tr>'+cols.map(c=>`<td style="padding:3px 8px;white-space:nowrap;max-width:210px;overflow:hidden;text-overflow:ellipsis">${esc(c.v(r))}</td>`).join('')+'</tr>').join('');
        box.innerHTML=head+`<div style="overflow:auto;max-height:200px;border:1px solid var(--border);border-radius:6px">`+
            `<table style="border-collapse:collapse;font-size:0.95rem"><thead><tr>${th}</tr></thead><tbody>${trs}</tbody></table></div>`+
            (count>4?`<div style="font-size:0.9rem;color:var(--text-muted);margin-top:4px">…e altre ${count-4} righe</div>`:'');
        return;
    }

    // PDF: mock di UNA etichetta col rapporto d'aspetto del template.
    let dim=LABEL_TEMPLATE_DIM[fmt]||[48,25];
    if(fmt==='generic-grid'){ const g=_genericGridFromUi(); if(g.labelW&&g.labelH) dim=[g.labelW,g.labelH]; }
    const W=240, H=Math.max(46, Math.round(W*dim[1]/dim[0]));
    const wrap=!!document.getElementById('lblexp-wrap')?.checked;
    const esc=escapeHTML;   // una definizione sola: quella di app-util (esposta su window)
    const r=rows[0];
    let inner;
    if(wrap){
        const lbl=esc((r.label||'').replace(/→/g,'->'));
        // Bandierina: il testo si ripete nelle DUE metà (alto/basso) e OGNI riga è
        // centrata nella propria metà (al quarto dell'altezza, cioè a metà tra il
        // centro dell'etichetta e il bordo), con la piega al centro. Rispecchia il
        // layout del PDF (server/label-sheet.js _drawCell wrap: cy+(halfH-fs)/2).
        const half=`<div style="flex:1;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px">${lbl}</div>`;
        inner=`<div style="display:flex;flex-direction:column;width:100%;height:100%">${half}<div style="border-top:1px dashed #bbb;width:60%;align-self:center"></div>${half}</div>`;
    }else{
        const lines=_previewLines(r,fields);
        if(!lines.length){ box.innerHTML=head+`<div style="font-size:0.78rem;color:var(--text-muted)">${_t('impexp.pickAField','Select at least one field.')}</div>`; return; }
        inner=lines.map((l)=>{
            // h = Etichetta (ID) resta 14px; s/m (tutti gli altri campi) a 13px.
            const fs=l.cls==='h'?14:13;
            const col=l.cls==='h'?'#111':(l.cls==='s'?'#444':'#666');
            const fw=l.cls==='h'?'700':'400';
            return `<div style="font-size:${fs}px;color:${col};font-weight:${fw};line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(l.t)}</div>`;
        }).join('');
    }
    box.innerHTML=head+
        `<div style="display:inline-flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;`+
        `width:${W}px;height:${H}px;padding:6px;box-sizing:border-box;border:1px solid #cbd5e1;border-radius:4px;background:#fff;color:#111;overflow:hidden">`+
        `${inner}</div>`+
        `<div style="font-size:0.7rem;color:var(--text-muted);margin-top:4px">${dim[0]}×${dim[1]} mm</div>`;
}

function _genericGridFromUi(){
    const num=id=>{ const v=Number(document.getElementById(id)?.value); return Number.isFinite(v)?v:undefined; };
    return {
        cols:num('lblg-cols'), rows:num('lblg-rows'),
        labelW:num('lblg-labelW'), labelH:num('lblg-labelH'),
        marginLeft:num('lblg-marginLeft'), marginTop:num('lblg-marginTop'),
        pitchX:num('lblg-pitchX'), pitchY:num('lblg-pitchY'),
    };
}

async function confirmLabelExport(){
    const fmt=document.getElementById('lblexp-format')?.value||'csv';
    const fields=_selectedLabelFields();
    if(!fields.size){ _showToast?.(_t('impexp.pickAField','Select at least one field.'),'warn'); return; }

    if(fmt==='csv'){ closeLabelExportOptions(); exportLabelsCSV(fields); return; }

    const rows=_cableLabelRows();
    if(!rows.length){ _showToast?.(_t('impexp.noCablesToLabel','No cables to label'),'warn'); return; }

    const payload={
        rows,
        template: fmt,
        fields:   [...fields],
        wrap:     !!document.getElementById('lblexp-wrap')?.checked,
    };
    if(fmt==='generic-grid') payload.grid=_genericGridFromUi();

    closeLabelExportOptions();
    try{
        const resp=await fetch('/api/export-labels-pdf',{
            method:'POST',
            headers:{'Content-Type':'application/json'},
            body:JSON.stringify(payload),
        });
        if(!resp.ok){
            let msg=_t('impexp.serverError','Server error');
            try{ const j=await resp.json(); msg=j.error||msg; }catch(_){}
            throw new Error(msg);
        }
        const blob=await resp.blob();
        const url=URL.createObjectURL(blob);
        const a=document.createElement('a');
        a.href=url;
        a.download=`${_exportFileStem()}_etichette.pdf`;
        a.click();
        setTimeout(()=>URL.revokeObjectURL(url),5000);
    }catch(err){
        alert(_t('impexp.labelExportFailed','Label export failed:')+'\n'+err.message);
    }
}

function openPdfExportOptions(){
    document.getElementById('pdf-export-overlay').classList.add('open');
    document.getElementById('pdfopt-plan').checked      = PDF_EXPORT_DEFAULTS.includePlanimetria;
    document.getElementById('pdfopt-bg').checked        = PDF_EXPORT_DEFAULTS.includeBackground;
    document.getElementById('pdfopt-inventory').checked = PDF_EXPORT_DEFAULTS.includeInventory;
    document.getElementById('pdfopt-asbuilt').checked   = PDF_EXPORT_DEFAULTS.includeAsBuilt;
    document.getElementById('pdfopt-racks').checked     = PDF_EXPORT_DEFAULTS.includeRacks;
    document.getElementById('pdfopt-ports').checked     = PDF_EXPORT_DEFAULTS.includePorts;
    document.getElementById('pdfopt-vlans').checked     = PDF_EXPORT_DEFAULTS.includeVlans;
    document.getElementById('pdfopt-topology').checked  = PDF_EXPORT_DEFAULTS.includeTopology;
    { const _v = document.getElementById('pdfopt-vms'); if(_v) _v.checked = PDF_EXPORT_DEFAULTS.includeVms; }
    { const _p = document.getElementById('pdfopt-pdu'); if(_p) _p.checked = PDF_EXPORT_DEFAULTS.includePdu; }
    { const _s = document.getElementById('pdfopt-spare'); if(_s) _s.checked = PDF_EXPORT_DEFAULTS.includeSpare; }
    { const _a = document.getElementById('pdfopt-assets'); if(_a) _a.checked = PDF_EXPORT_DEFAULTS.includeAssets; }
    { const _r = document.getElementById('pdfopt-recovery'); if(_r) _r.checked = PDF_EXPORT_DEFAULTS.includeRecovery; }
    { const _o = document.getElementById('pdfopt-overview'); if(_o) _o.checked = PDF_EXPORT_DEFAULTS.includeOverview; }
    { const _w = document.getElementById('pdfopt-wan'); if(_w) _w.checked = PDF_EXPORT_DEFAULTS.includeWan; }
    syncPdfExportUi();
}

function closePdfExportOptions(){ document.getElementById('pdf-export-overlay').classList.remove('open'); }

function setPdfExportAll(val){
    ['pdfopt-plan','pdfopt-bg','pdfopt-inventory','pdfopt-asbuilt','pdfopt-racks','pdfopt-ports','pdfopt-vlans','pdfopt-topology','pdfopt-vms','pdfopt-pdu','pdfopt-spare','pdfopt-assets','pdfopt-recovery','pdfopt-wan','pdfopt-overview']
        .forEach(id=>{ const el=document.getElementById(id); if(el) el.checked=!!val; });
    // «Deseleziona tutto» può davvero svuotare le sezioni: il PDF non resta vuoto,
    // il dossier (copertina + storia modifiche) c'è comunque.
    syncPdfExportUi();
}

function syncPdfExportUi(){
    const plan=document.getElementById('pdfopt-plan');
    const bg=document.getElementById('pdfopt-bg');
    if(!plan||!bg) return;
    bg.disabled=!plan.checked;
    bg.parentElement.style.opacity=plan.checked?'0.95':'0.5';
    if(!plan.checked) bg.checked=false;
}

function _getPdfExportOptionsFromUi(){
    return {
        includePlanimetria: !!document.getElementById('pdfopt-plan')?.checked,
        includeBackground:  !!document.getElementById('pdfopt-bg')?.checked,
        includeInventory:   !!document.getElementById('pdfopt-inventory')?.checked,
        includeAsBuilt:     !!document.getElementById('pdfopt-asbuilt')?.checked,
        includeRacks:       !!document.getElementById('pdfopt-racks')?.checked,
        includePorts:       !!document.getElementById('pdfopt-ports')?.checked,
        includeVlans:       !!document.getElementById('pdfopt-vlans')?.checked,
        includeTopology:    !!document.getElementById('pdfopt-topology')?.checked,
        includeVms:         !!document.getElementById('pdfopt-vms')?.checked,
        includePdu:         !!document.getElementById('pdfopt-pdu')?.checked,
        includeSpare:       !!document.getElementById('pdfopt-spare')?.checked,
        includeAssets:      !!document.getElementById('pdfopt-assets')?.checked,
        includeRecovery:    !!document.getElementById('pdfopt-recovery')?.checked,
        includeWan:         !!document.getElementById('pdfopt-wan')?.checked,
        includeOverview:    !!document.getElementById('pdfopt-overview')?.checked,
    };
}

function confirmPdfExport(){
    const opts=_getPdfExportOptionsFromUi();
    closePdfExportOptions();
    exportPDF(opts);
}

function _buildFloorSVG(opts){
    opts = opts || {};
    const floorNodes=state.nodes.filter(n=>TYPES[n.type]?.isFloor);

    // --- Dimensioni reali dell'immagine di sfondo dal DOM ---
    const bgEl=document.getElementById('floor-bg-img');
    const bsc=state.bgImageScale||1;
    // naturalWidth è 0 in Chrome per SVG senza width/height espliciti →
    // fallback: leggi viewBox dall'XML del SVG
    const _bgMime=state.bgImage?(state.bgImage.split(';')[0].split(':')[1]||'').toLowerCase():'';
    let _bgNatW = bgEl ? bgEl.naturalWidth  : 0;
    let _bgNatH = bgEl ? bgEl.naturalHeight : 0;
    if(_bgMime==='image/svg+xml' && (!_bgNatW||!_bgNatH)){
        const _d=_parseSvgSize(state.bgImage);
        _bgNatW=_d.w||_bgNatW||800; _bgNatH=_d.h||_bgNatH||600;
    }
    const hasImg=!!state.bgImage && (_bgMime==='image/svg+xml' ? true : _bgNatW>0);
    const imgW=hasImg ? Math.round(_bgNatW * bsc) : 0;
    const imgH=hasImg ? Math.round(_bgNatH * bsc) : 0;

    // --- Bounding box basato sul contenuto reale ---
    let x0=Infinity,y0=Infinity,x1=-Infinity,y1=-Infinity;
    if(hasImg){ x0=0; y0=0; x1=imgW; y1=imgH; }
    floorNodes.forEach(n=>{
        if(TYPES[n.type]?.isStructural){
            x0=Math.min(x0,n.x);            y0=Math.min(y0,n.y);
            x1=Math.max(x1,n.x+(n.w||200)); y1=Math.max(y1,n.y+(n.h||200));
        } else {
            x0=Math.min(x0,n.x-50);  y0=Math.min(y0,n.y-50);
            x1=Math.max(x1,n.x+50);  y1=Math.max(y1,n.y+50);
        }
    });
    // Includi anche le icone rack posizionate sulla planimetria
    state.racks.filter(r=>r.x!==undefined).forEach(r=>{
        x0=Math.min(x0,r.x-50); y0=Math.min(y0,r.y-30);
        x1=Math.max(x1,r.x+50); y1=Math.max(y1,r.y+30);
    });
    if(!isFinite(x0)){x0=0;y0=0;x1=800;y1=600;}
    const pad=hasImg?0:40;
    x0=Math.max(0,x0-pad); y0=Math.max(0,y0-pad); x1+=pad; y1+=pad;
    const W=x1-x0, H=y1-y0;
    const bgCol=state.uiColors?.floorBg||'#0d1117';
    const pdf=!!opts.pdfMode;

    // Palette: screen (dark) vs PDF (light, stampabile)
    const P = pdf ? {
        // Colori
        devFill:    '#f8f9fa', devBorder: '#ced4da', devSep:    '#ced4da',
        devLabel:   '#374151',
        rackFill:   '#f8f9fa', rackStroke:'#868e96',
        rackName:   '#111827', rackCnt:   '#6b7280', rackBadge: '#e5e7eb',
        connLine:   '#6b7280', connBadge: '#e5e7eb', connTxt:   '#111827',
        roomTxt:    'rgba(0,0,0,0.55)',
        connOp:'1', fanOp:'1', cableOp:'1',
        // Spessori linee raddoppiati per visibilità su carta
        connSW: 4,   cableSW: 5,   fanSW: 3,
        devOutSW: 5, devInSW: 2,   devSepSW: 2,
        rackSW: 3,   rackInSW: 1.5,
        // Testo: più grande e bold per leggibilità
        iconSz: 18,  iconW: '900',
        lblSz:  14,  lblW:  '700',
        rackNSz:12,  rackNW:'700',
        rackCSz:10,  rackCW:'400',
        connBadgeR: 12,
    } : {
        // Colori
        devFill:    'url(#dev-bg)', devBorder:'rgba(255,255,255,0.10)', devSep:'rgba(255,255,255,0.07)',
        devLabel:   null,
        rackFill:   '#1c2128',      rackStroke:'#8b949e',
        rackName:   '#c9d1d9',      rackCnt:   '#8b949e', rackBadge:'#30363d',
        connLine:   '#8b949e',      connBadge: '#30363d', connTxt:  '#c9d1d9',
        roomTxt:    'rgba(255,255,255,0.4)',
        connOp:'0.65', fanOp:'0.7', cableOp:'0.85',
        // Spessori originali
        connSW: 2,   cableSW: 2.5, fanSW: 1.5,
        devOutSW:2.5,devInSW: 1,   devSepSW: 1,
        rackSW: 1.5, rackInSW: 1,
        // Testo originale
        iconSz: 17,  iconW: '900',
        lblSz:  9,   lblW:  '700',
        rackNSz:8,   rackNW:'700',
        rackCSz:7,   rackCW:'400',
        connBadgeR: 9,
    };

    // Font Awesome via CDN — funziona quando SVG è aperto in browser
    // In pdfMode inutile (e il @import causa warning nel parser CSS di svg-to-pdfkit)
    const faStyle = pdf ? '' : `@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css');`;

    let s=`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"`
         +` viewBox="${x0} ${y0} ${W} ${H}" width="${W}" height="${H}">`
         +`<defs>${faStyle?`<style>${faStyle}</style>`:''}`
         // Gradiente per box dispositivi: solo in screen mode
         +(pdf?'': `<linearGradient id="dev-bg" x1="0" y1="0" x2="0" y2="1">`
           +`<stop offset="59%" stop-color="#161b22"/><stop offset="59%" stop-color="#0d1117"/>`
           +`</linearGradient>`)
         +`</defs>`
         // In modalità PDF il rect di sfondo scuro viene omesso:
         // l'immagine è già disegnata da pdfkit sotto l'overlay SVG.
         +(pdf ? '' : `<rect x="${x0}" y="${y0}" width="${W}" height="${H}" fill="${bgCol}"/>`);

    // --- Background map: dimensioni da naturalWidth/Height × scala ---
    // skipBgImage=true → l'immagine viene gestita esternamente (es. pdfkit doc.image())
    if(hasImg && !opts.skipBgImage){
        s+=`<image href="${state.bgImage}" x="0" y="0" width="${imgW}" height="${imgH}"`
          +` opacity="0.4" preserveAspectRatio="none"/>`;
    }

    // --- Strutture (stanze) ---
    floorNodes.filter(n=>TYPES[n.type]?.isStructural).forEach(n=>{
        const col=n.color||TYPES[n.type].defaultColor;
        const alpha=n.opacity !== undefined ? n.opacity : 1;
        const rw=n.w||200, rh=n.h||200;
        const fontSize=n.fontSize!==undefined ? n.fontSize : Math.max(10,Math.min(Math.min(rw,rh)*0.1,36));
        s+=`<rect x="${n.x}" y="${n.y}" width="${rw}" height="${rh}"`
          +` fill="${col}" fill-opacity="${alpha}" stroke="rgba(120,120,120,0.5)" stroke-width="2" rx="2"/>`;
        if(n.name&&n.type==='room'){
            s+=`<text x="${n.x+rw/2}" y="${n.y+rh/2}"`
              +` text-anchor="middle" dominant-baseline="middle"`
              +` font-family="system-ui,sans-serif" font-size="${fontSize}" font-weight="bold"`
              +` fill="${P.roomTxt}">${escapeHTML(n.name)}</text>`;
        }
    });

    // --- Connessioni rack↔rack (da state.links, filtrate per VLAN se attivo) ---
    const _svgRackPairs=new Map();
    for(const l of state.links){
        if(_filterVlan&&!_linkMatchesVlanFilter(l)) continue;
        const snId=getPortNodeId(l.src),dnId=getPortNodeId(l.dst);
        const sn=nodeById(snId),dn=nodeById(dnId);
        if(!sn||!dn||!TYPES[sn.type]?.isRack||!TYPES[dn.type]?.isRack) continue;
        if(sn.rackId===dn.rackId) continue;
        const rA=state.racks.find(r=>r.id===sn.rackId),rB=state.racks.find(r=>r.id===dn.rackId);
        if(!rA||!rB||rA.x===undefined||rB.x===undefined) continue;
        const sorted=[rA,rB].sort((a,b)=>a.id.localeCompare(b.id));
        const key=sorted.map(r=>r.id).join('|');
        if(!_svgRackPairs.has(key)) _svgRackPairs.set(key,{sx:sorted[0].x,sy:sorted[0].y,dx:sorted[1].x,dy:sorted[1].y,count:0});
        _svgRackPairs.get(key).count++;
    }
    for(const [,p] of _svgRackPairs){
        s+=`<line x1="${p.sx}" y1="${p.sy}" x2="${p.dx}" y2="${p.dy}"`
          +` stroke="${P.connLine}" stroke-width="${P.connSW}" stroke-dasharray="7,3" stroke-linecap="round" opacity="${P.connOp}"/>`;
        if(p.count>1){
            const mx=Math.round((p.sx+p.dx)/2),my=Math.round((p.sy+p.dy)/2);
            s+=`<circle cx="${mx}" cy="${my}" r="${P.connBadgeR}" fill="${P.connBadge}" stroke="${P.connLine}" stroke-width="${P.connSW/2}"/>`
              +`<text x="${mx}" y="${my}" text-anchor="middle" dominant-baseline="middle"`
              +` font-family="system-ui,sans-serif" font-size="${P.rackCSz}" font-weight="${P.rackCW}" fill="${P.connTxt}">${p.count}</text>`;
        }
    }

    // --- Fanout rack→floor node (filtrate per VLAN se attivo) ---
    state.racks.filter(r=>r.x!==undefined).forEach(rack=>{
        const rLinks=_getRackFloorLinks(rack.id);
        for(const {link,floorNode} of rLinks){
            if(_filterVlan&&!_linkMatchesVlanFilter(link)) continue;
            // Colore: la STESSA definizione del canvas (src/app-link-color.js).
            const col=_linkColor(link);
            s+=`<line x1="${rack.x}" y1="${rack.y}" x2="${floorNode.x}" y2="${floorNode.y}"`
              +` stroke="${col}" stroke-width="${P.fanSW}" stroke-dasharray="4,3" stroke-linecap="round" opacity="${P.fanOp}"/>`;
        }
    });

    // --- Cavi floor→floor (con colore VLAN, filtrati se attivo) ---
    function floorPos(nid){
        const n=nodeById(nid); if(!n||!TYPES[n.type]?.isFloor) return null;
        return TYPES[n.type]?.isStructural?{x:n.x+(n.w||200)/2,y:n.y+(n.h||200)/2}:{x:n.x,y:n.y};
    }
    state.links.forEach(l=>{
        if(_filterVlan&&!_linkMatchesVlanFilter(l)) return;
        const sn=getNodeByPortId(l.src),dn=getNodeByPortId(l.dst);
        if(!sn||!dn||!TYPES[sn.type]?.isFloor||!TYPES[dn.type]?.isFloor) return;
        const p=floorPos(sn.id),q=floorPos(dn.id); if(!p||!q) return;
        const col=_linkColor(l);
        const cx=(p.x+q.x)/2;
        s+=`<path d="M${p.x},${p.y} C${cx},${p.y} ${cx},${q.y} ${q.x},${q.y}"`
          +` fill="none" stroke="${col}" stroke-width="${P.cableSW}" stroke-linecap="round" opacity="${P.cableOp}"/>`;
    });

    // --- Dispositivi floor (AP, wallport, webcam — filtrati per VLAN se attivo) ---
    // FE-M1 (audit 2026-07-21): i floor node si colorano per PRESENZA, la STESSA verità
    // dello schermo (src/app-render-core.js), non per stato-porta. Fonte: il drift report
    // dell'ultima Verifica — macOrphan → ROSSO "assente confermato", unverified → GRIGIO
    // "non verificabile" (attenuato). Guardia snmpStatus==='ok' come a schermo (un device
    // risincronizzato torna vivo). Nessun _driftReport (nessuna Verifica) → set vuoti →
    // colori pieni per TIPO. Prima l'export usava lo stato-porta: rosso=guasto-porta ≠
    // rosso=assente a schermo, e il grigio "incerto" mancava del tutto.
    const _floorAbsentIds = new Set((((typeof _driftReport!=='undefined' && _driftReport && _driftReport.macOrphan)||[]).map(r=>r.nodeId).filter(Boolean)));
    const _floorUnverifiedIds = new Set((((typeof _driftReport!=='undefined' && _driftReport && _driftReport.unverified)||[]).map(r=>r.nodeId).filter(Boolean)));
    floorNodes.filter(n=>!TYPES[n.type]?.isStructural).forEach(n=>{
        if(_filterVlan&&_floorNodeHiddenByVlan(n.id)) return;
        const def=TYPES[n.type]; if(!def) return;
        const col=_floorNodeColor(n.type);                 // colore base per TIPO (come a schermo)
        const devInfo=_SVG_DEV[n.type]||{ ab:(def.name||n.type).replace(/[^a-zA-Z]/g,'').substring(0,3).toUpperCase(), fa:null };
        const ab=devInfo.ab;
        const _nm=(typeof getNodeDisplayName==='function'?getNodeDisplayName(n):n.name)||_typeName(n.type);
        const label=escapeHTML((typeof _dispName==='function'?_dispName(_nm):_nm));
        // Presenza (stessa logica di _buildFloorNodeEl): present=colore tipo · absent=anello
        // rosso d'allerta (icona resta del tipo) · unverified=grigio + attenuato (opacità).
        // ⚠️ Schema ②: il default «present» è load-bearing DOPO una Verifica (copre i
        // device confermati per altra via — es. ARP — che non finiscono in nessun bucket).
        // Ma SENZA Verifica (nessun _driftReport → set vuoti) faceva sembrare «presenti»
        // TUTTI i device senza mai averli controllati: in un documento di consegna è un
        // falso «tutto a posto». Un export statico non congela uno stato non verificato
        // (stessa scelta del draw.io che omette la tacca SNMP) → senza Verifica: «non verificato».
        const _hasVerify = typeof _driftReport!=='undefined' && !!_driftReport;
        const presence = n.snmpStatus==='ok' ? 'present'
            : _floorAbsentIds.has(n.id) ? 'absent'
            : _floorUnverifiedIds.has(n.id) ? 'unverified'
            : _hasVerify ? 'present' : 'unverified';
        // «Non verificato»: resa LEGGERA — il device resta identificabile nel suo
        // colore-tipo (icona + etichetta), il segnale è il solo ANELLO grigio +
        // un'attenuazione lieve (0.7). Senza Verifica l'intera planimetria sarebbe
        // altrimenti grigia e lavata (0.45 + icona grigia): onesto ma illeggibile.
        const borderCol = presence==='absent' ? '#f85149' : presence==='unverified' ? '#6e7681' : col;
        const iconCol   = col;                                  // colore-tipo anche per «non verificato» (identificabile)
        const nodeOp    = presence==='unverified' ? 0.7 : 1;    // attenuazione lieve, non un lavaggio
        const faChar=devInfo.fa ? String.fromCodePoint(devInfo.fa) : null;
        const lblCol = P.devLabel || iconCol;
        s+=`<g transform="translate(${n.x},${n.y})" opacity="${nodeOp}">`
          +`<title>${label}</title>`
          // Bordo esterno = colore PRESENZA (rosso=assente · grigio=incerto · tipo=presente)
          +`<rect x="-23" y="-28" width="46" height="56" rx="7" fill="none" stroke="${borderCol}" stroke-width="${P.devOutSW}"/>`
          // Box interno
          +`<rect x="-22" y="-27" width="44" height="54" rx="6" fill="${P.devFill}" stroke="${P.devBorder}" stroke-width="${P.devInSW}"/>`
          // Separatore
          +`<line x1="-22" y1="5" x2="22" y2="5" stroke="${P.devSep}" stroke-width="${P.devSepSW}"/>`
          // Icona sezione superiore
          // Screen: glifo Font Awesome (se disponibile) o abbreviazione
          // PDF:    abbreviazione bold grande (font Helvetica garantito in PDF)
          +(pdf
            ? `<text x="0" y="-11" text-anchor="middle" dominant-baseline="middle"`
              +` font-family="system-ui,sans-serif" font-size="${P.iconSz}" font-weight="${P.iconW}" fill="${iconCol}">${ab}</text>`
            : (faChar
               ? `<text x="0" y="-11" text-anchor="middle" dominant-baseline="middle"`
                 +` font-family="'Font Awesome 6 Free'" font-weight="900" font-size="${P.iconSz}" fill="${iconCol}">${faChar}</text>`
               : `<text x="0" y="-11" text-anchor="middle" dominant-baseline="middle"`
                 +` font-family="system-ui,sans-serif" font-size="${P.iconSz}" font-weight="${P.iconW}" fill="${iconCol}">${ab}</text>`)
          )
          // Label dispositivo sezione inferiore
          +`<text x="0" y="16" text-anchor="middle" dominant-baseline="middle"`
          +` font-family="system-ui,sans-serif" font-size="${P.lblSz}" font-weight="${P.lblW}" fill="${lblCol}">${label}</text>`
          +`</g>`;
    });

    // --- Icone rack sulla planimetria ---
    state.racks.filter(r=>r.x!==undefined).forEach(rack=>{
        const rName=escapeHTML(rack.name||rack.id);
        const devCount=state.nodes.filter(n=>TYPES[n.type]?.isRack&&n.rackId===rack.id).length;
        const innerBorderStroke = pdf ? '#d1d5db' : 'rgba(139,148,158,0.25)';
        s+=`<g transform="translate(${rack.x},${rack.y})">`
          +`<title>${rName}</title>`
          +`<rect x="-30" y="-22" width="60" height="44" rx="5" fill="${P.rackFill}" stroke="${P.rackStroke}" stroke-width="${P.rackSW}"/>`
          +`<rect x="-29" y="-21" width="58" height="42" rx="4" fill="none" stroke="${innerBorderStroke}" stroke-width="${P.rackInSW}"/>`
          +`<text x="0" y="-6" text-anchor="middle" dominant-baseline="middle"`
          +` font-family="${pdf?'system-ui,sans-serif':"'Font Awesome 6 Free'"}" font-weight="900" font-size="${pdf?P.rackNSz:14}" fill="${P.rackStroke}">${pdf?'RCK':String.fromCodePoint(0xf233)}</text>`
          +`<text x="0" y="9" text-anchor="middle" dominant-baseline="middle"`
          +` font-family="system-ui,sans-serif" font-size="${P.rackNSz}" font-weight="${P.rackNW}" fill="${P.rackName}">${rName}</text>`
          +(devCount>0
            ? `<text x="0" y="20" text-anchor="middle" dominant-baseline="middle"`
              +` font-family="system-ui,sans-serif" font-size="${P.rackCSz}" font-weight="${P.rackCW}" fill="${P.rackCnt}">${devCount} dev</text>`
            : '')
          +`</g>`;
    });

    s+=`</svg>`;
    return s;
}

function exportFloorSVG(){
    const s=_buildFloorSVG();
    const blob=new Blob([s],{type:'image/svg+xml;charset=utf-8'});
    const url=URL.createObjectURL(blob);
    const a=document.createElement('a');
    a.href=url; a.download='infranetpro-planimetria.svg'; a.click();
    setTimeout(()=>URL.revokeObjectURL(url),2000);
}

// Un SVG usato come IMMAGINE (data URI) e' un documento standalone: RICHIEDE
// xmlns sul root (inline nell'HTML no, come immagine SI'). Senza, l'immagine e'
// rotta → draw.io mostra un placeholder. Alcune skin (hand-authored / inline
// legacy) non ce l'hanno: lo iniettiamo. + xmlns:xlink se usa xlink: senza dichiararlo.
function _ensureSvgXmlns(svg){
    let s = String(svg == null ? '' : svg);
    if(!/<svg\b[^>]*\bxmlns\s*=/.test(s)) {
        s = s.replace(/<svg\b/i, '<svg xmlns="http://www.w3.org/2000/svg"');
    }
    if(/\bxlink:/.test(s) && !/\bxmlns:xlink\s*=/.test(s)) {
        s = s.replace(/<svg\b/i, '<svg xmlns:xlink="http://www.w3.org/1999/xlink"');
    }
    return s;
}

// Data URI SVG per l'immagine faceplate skin. URL-encoded (NON base64): il prefisso
// "data:image/svg+xml;base64," conterrebbe un ';' che il parser di stile mxGraph
// tratterebbe da separatore-chiave, rompendo lo stile. encodeURIComponent scappa
// ';' '=' ',' '#' → l'unico ',' e' quello del prefisso data:.
function _svgDataUri(svg){
    return 'data:image/svg+xml,' + encodeURIComponent(String(svg == null ? '' : svg));
}

// Per un device con skin: immagine faceplate (data URI) + porte come frazioni del
// viewBox (posizioni reali via getBBox, misurate off-screen nel DOM). Ritorna null
// se il device non ha skin. _resolveNodeSkin (bundle) e parseViewBox/extractSkinPorts/
// skinPortPid (lib panel-skin.js <script>) esistono a click-time.
function _drawioSkinFor(n){
    if(typeof _resolveNodeSkin !== 'function') return null;
    const sk = _resolveNodeSkin(n);
    if(!sk || !sk.svg || typeof document === 'undefined') return null;
    // Difesa in profondita': ri-sanitizza (le skin dello store lo sono gia'; le
    // inline legacy potrebbero non esserlo) prima di iniettarle nel DOM.
    const clean = (typeof sanitizeSvg === 'function') ? (sanitizeSvg(sk.svg).svg || sk.svg) : sk.svg;
    const ready = _ensureSvgXmlns(clean);   // xmlns obbligatorio per l'SVG-immagine (data URI)
    const vb = (typeof parseViewBox === 'function') ? parseViewBox(ready) : null;
    const image = _svgDataUri(ready);
    const aspect = (vb && vb.height > 0) ? vb.width / vb.height : 0;
    const portsMeta = (sk.ports && sk.ports.length) ? sk.ports
        : ((typeof extractSkinPorts === 'function') ? extractSkinPorts(ready) : []);
    if(!vb || !portsMeta.length) return { image, aspect, ports: [] };
    let host = null;
    const located = [];
    try {
        // innerHTML (NON DOMParser+importNode): il parser HTML crea veri
        // SVGGraphicsElement → getBBox disponibile. (importNode da un doc
        // image/svg+xml lascia elementi senza getBBox.)
        host = document.createElement('div');
        host.setAttribute('aria-hidden', 'true');
        host.style.cssText = 'position:absolute;left:-99999px;top:0;opacity:0;pointer-events:none;width:800px;height:200px';
        host.innerHTML = ready;
        document.body.appendChild(host);
        const svgEl = host.querySelector('svg');
        if(svgEl){
            portsMeta.forEach(function(p){
                let el = svgEl.querySelector('[id="' + String(p.id).replace(/"/g, '') + '"]');
                if(!el) el = svgEl.querySelector('[data-' + (p.kind === 'mgmt' ? 'mgmt' : 'port') + '="' + p.num + '"]');
                if(!el || typeof el.getBBox !== 'function') return;
                let bb; try { bb = el.getBBox(); } catch(_) { return; }
                if(!bb || !(bb.width > 0) || !(bb.height > 0)) return;
                const pid = (typeof skinPortPid === 'function') ? skinPortPid(n.id, p)
                    : (p.kind === 'mgmt' ? `${n.id}-mgmt${p.num}` : `${n.id}-${p.num}`);
                located.push({
                    pid,
                    xf: (bb.x - vb.x) / vb.width,
                    yf: (bb.y - vb.y) / vb.height,
                    wf: bb.width / vb.width,
                    hf: bb.height / vb.height,
                });
            });
        }
    } catch(_){ /* faccia senza porte localizzate: mostro comunque l'immagine */ }
    finally { if(host && host.parentNode) host.parentNode.removeChild(host); }
    return { image, aspect, ports: located };
}

// Export draw.io / diagrams.net: l'elevazione dei rack come veri mxCell nativi
// (editabili, zero artefatti — niente immagine incollata). La costruzione e' PURA
// in lib/drawio-export.js (buildDrawioXml, <script> caricato prima); qui iniettiamo
// lo stato e gli helper dell'app, stessa convenzione di _cableLabelRows.
function exportDrawio(){
    if(typeof buildDrawioXml !== 'function') return;
    if(!(state.racks && state.racks.length)){
        if(typeof _showToast === 'function')
            _showToast(_t('impexp.drawioNoRacks','No racks to export'), 'warn');
        return;
    }
    // Device assenti all'ultima Verifica (bucket macOrphan del Drift) → attenuati,
    // come nella vista rack (.node-absent). Guardia snmpStatus!='ok' (un device
    // riacceso+sincronizzato torna vivo). _driftReport puo' non esistere.
    const _absentIds = new Set(
        (((typeof _driftReport !== 'undefined' && _driftReport && _driftReport.macOrphan) || [])
            .map(r => r.nodeId).filter(Boolean)));

    // Device con skin custom: faccia = immagine + porte native (getBBox). Salta i
    // nodi senza skin. Solo device rack (gli unici disegnati nell'export).
    const _skins = {};
    (state.nodes || []).forEach(n => {
        if(!(TYPES[n.type] && TYPES[n.type].isRack)) return;
        const s = _drawioSkinFor(n);
        if(s && s.image) _skins[n.id] = s;
    });

    const xml = buildDrawioXml({
        racks: state.racks || [],
        nodes: state.nodes || [],
        ports: state.ports || {},
        skins: _skins,
        links: state.links || [],
        opts: {
            rackUnitSize: 20,
            cablesLayerName: _t('impexp.drawioCablesLayer','Cables'),
        },
        helpers: {
            types: TYPES,
            getRackSize:        (typeof getRackSize === 'function')        ? getRackSize        : null,
            normalizePortStatus:    (typeof normalizePortStatus === 'function')    ? normalizePortStatus    : undefined,
            normalizeNumber:    (typeof normalizeNumber === 'function')    ? normalizeNumber    : undefined,
            hasSnmpIntegration: (typeof _hasSnmpIntegration === 'function') ? _hasSnmpIntegration : undefined,
            typeName:           _typeName,
            // Porte spente a mano / senza link: STESSA regola del rack a schermo
            // (lib/port-state.js, <script> caricato prima di questo).
            portShade:          (typeof portShade === 'function')          ? portShade          : null,
            isAbsent:           n => _absentIds.has(n.id) && n.snmpStatus !== 'ok',
            // Colore cavo = stessa convenzione della vista live (topologia/fanout):
            // override manuale, poi colore VLAN; null -> il builder usa il default.
            linkColor:          l => _linkColor(l),
            // VLAN del cavo + nome VLAN -> un layer draw.io per VLAN, nominato col nome.
            // STESSA VLAN che ne decide il colore: layer e colore che si contraddicono
            // sono il modo in cui questa classe di bug e' sempre ricominciata.
            linkVlan:           l => { const _p = _linkPaintVlan(l); return _p.known ? _p.vlan : _getLinkVlan(l); },
            vlanName:           vid => (state.vlanNames && state.vlanNames[vid]) || '',
        },
    });
    const blob=new Blob([xml],{type:'application/xml;charset=utf-8'});
    const a=document.createElement('a');
    a.href=URL.createObjectURL(blob);
    a.download=`${_exportFileStem()}_rack.drawio`;
    a.click();
    setTimeout(()=>URL.revokeObjectURL(a.href),5000);
}

function _buildRackSVG(rackId, opts){
    opts = opts || {};
    const rack = state.racks.find(r=>r.id===rackId);
    if(!rack) return null;

    const rackSize = getRackSize(rackId);
    const U = opts.pdfMode ? 18 : 20;
    // A 42U rack is much taller than it is wide. Use a compact 19-inch-style
    // front proportion so PDF export does not look stretched horizontally.
    const top = 46, left = 42, railW = 14, chassisW = 340;
    const innerX = left + railW, innerW = chassisW - railW * 2;
    const rackH = rackSize * U;
    const W = left + chassisW + 24;
    const H = top + rackH + 30;
    const devs = state.nodes
        .filter(n=>n.rackId===rackId && TYPES[n.type]?.isRack)
        .sort((a,b)=>(b.rackU||0)-(a.rackU||0));
    // Tacca verticale sinistra del device = STATO SNMP (come la vista rack live:
    // .rack-device border-left, vedi app-render-core.js / manuale sez. 11). Prima
    // era colorata per TIPO di device → vecchia classificazione, rimossa.
    const stripeColor = dev => {
        const on = (typeof _hasSnmpIntegration === 'function') && _hasSnmpIntegration(dev);
        if(!on) return '#6e7681';                       // snmp-na  (SNMP non configurato)
        if(dev.snmpStatus === 'ok')  return '#39d353';  // snmp-ok  (ultimo poll riuscito)
        if(dev.snmpStatus === 'err') return '#f85149';  // snmp-err (ultimo poll fallito)
        return '#d29922';                               // snmp-pending (configurato, non confermato)
    };
    // Colori LED identici alla vista rack dell'app (variabili CSS :root)
    const statusColor = st => ({
        active:'#39d353', fault:'#f85149', inactive:'#6e7681',
    })[normalizePortStatus(st)] || '#6e7681';
    // Le due porte «off» MISURATE dallo switch (--shut-color / --nolink-color).
    // Vincono su qualunque stato dichiarato: in tutto il disegno sono l'unico punto
    // in cui parla l'apparato invece del documento, e un dossier che stampa verde
    // una porta in `shutdown` è esattamente il genere di bugia che non vogliamo su
    // carta. La regola NON si riscrive qui: è portShade() di lib/port-state.js.
    const SHUT_COLOR = '#0a0d11', NOLINK_COLOR = '#d29922';   // ⚠️ gemelli in 01-tokens.css e lib/drawio-export.js
    const shadeColor = pi => {
        const s = (typeof portShade === 'function') ? portShade(pi) : null;
        return s === 'shut' ? SHUT_COLOR : (s === 'no-link' ? NOLINK_COLOR : null);
    };
    const safe = v => escapeHTML(String(v ?? ''));
    const portName = pid => {
        const pi = state.ports[pid] || {};
        return pi.ifName || pi.alias || pi.desc || pid.split('-').slice(1).join('-');
    };
    const portLag = pid => {
        const pi = state.ports[pid] || {};
        if(pi.lagGroup) return pi.lagGroup;
        const lid = parseInt(pi.lagId || 0, 10);
        return lid > 0 ? `snmp-lag-${getPortNodeId(pid)}-${lid}` : '';
    };
    const fitRackLabel = (text, maxChars) => {
        const s = String(text || '');
        return s.length > maxChars ? `${s.substring(0, Math.max(1, maxChars - 3))}...` : s;
    };
    // Disegna un blocco laterale (SFP o MGMT) come grid 2-righe stile rack view.
    // pids: lista di {pid,label}; borderColor: bordo cella (ciano/salmone).
    // Il blocco ha titolo opzionale sotto. Ritorna l'SVG generato.
    const drawSideBlock = (cells, bx, by, bw, bh, borderColor, blockTitle) => {
        if(!cells.length) return '';
        const cols = Math.ceil(cells.length / 2);
        const cellW = 7, cellH = 4.4, colGap = 1.4, rowGap = 1.4;
        const gridW = cols * cellW + (cols - 1) * colGap;
        const titleFs = 4.2;
        const gridH = 2 * cellH + rowGap;
        const totalH = blockTitle ? gridH + 1.4 + titleFs : gridH;
        // Centra il blocco verticalmente nello spazio assegnato
        const gridX = bx + (bw - gridW) / 2;
        const gridY = by + (bh - totalH) / 2;
        let out = '';
        cells.forEach((c, idx) => {
            const col = Math.floor(idx / 2), row = idx % 2;
            const cx = gridX + col * (cellW + colGap);
            const cy = gridY + row * (cellH + rowGap);
            const pi = state.ports[c.pid] || {};
            // La misura batte il dichiarato E il «non determinato»: una porta spenta
            // a mano si vede anche se il documento non dice niente di lei.
            const fillTone = shadeColor(pi)
                || (pi.statusOvr || pi.status ? statusColor(pi.statusOvr ?? pi.status) : 'transparent');
            out += `<g><title>${safe(c.tip)}</title>`
                +`<rect x="${cx.toFixed(2)}" y="${cy.toFixed(2)}" width="${cellW}" height="${cellH}" rx="0.6" fill="${fillTone === 'transparent' ? 'none' : fillTone}" fill-opacity="${fillTone === 'transparent' ? 0 : 0.4}" stroke="${borderColor}" stroke-width="0.7"/>`
                +`</g>`;
        });
        if(blockTitle){
            const titleY = gridY + gridH + 1.4 + titleFs * 0.85;
            out += `<text x="${(gridX + gridW/2).toFixed(2)}" y="${titleY.toFixed(2)}" text-anchor="middle" font-family="monospace" font-size="${titleFs}" font-weight="700" fill="${borderColor}">${safe(blockTitle)}</text>`;
        }
        return out;
    };

    const drawPorts = (dev, x, y, w, h) => {
        const pcTotal = dev.ports !== undefined ? dev.ports : (TYPES[dev.type]?.ports || 0);
        const fp = dev.frontPanel || {};
        const sfpCount = fp.separateSfp ? Math.max(0, Math.min(parseInt(fp.sfpCount,10)||0, pcTotal)) : 0;
        const sfpRight = fp.sfpRight !== false;
        const mgmtEligible = !!(TYPES[dev.type] && TYPES[dev.type].mgmtEligible);
        const mgmtCount = mgmtEligible ? Math.max(0, Math.min(parseInt(fp.mgmtCount,10)||0, 4)) : 0;
        const mgmtPos = fp.mgmtPosition === 'right' ? 'right' : 'left';
        const mgmtLabel = (typeof fp.mgmtLabel === 'string' && fp.mgmtLabel.trim()) ? fp.mgmtLabel.trim() : 'MGMT';
        const pc = Math.max(0, pcTotal - sfpCount); // solo data ports nel grid principale
        // Allocazioni laterali per i blocchi SFP/MGMT (larghezza in user units)
        const sideBlockW = (cells) => cells > 0 ? (Math.ceil(cells/2) * 7 + (Math.ceil(cells/2)-1) * 1.4 + 4) : 0;
        const sfpBlockW = sideBlockW(sfpCount);
        const mgmtBlockW = sideBlockW(mgmtCount);
        let leftAlloc = 0, rightAlloc = 0;
        if(mgmtCount > 0 && mgmtPos === 'left')  leftAlloc  += mgmtBlockW + 2;
        if(mgmtCount > 0 && mgmtPos === 'right') rightAlloc += mgmtBlockW + 2;
        if(sfpCount  > 0 && !sfpRight)           leftAlloc  += sfpBlockW + 2;
        if(sfpCount  > 0 &&  sfpRight)           rightAlloc += sfpBlockW + 2;
        // Genera blocchi laterali (SFP indici N-sfpCount+1..N, MGMT pids -mgmt1..-mgmtN)
        let sideSvg = '';
        const buildSfpCells = () => Array.from({length:sfpCount}, (_, k)=>{
            const i = pcTotal - sfpCount + k + 1;
            return { pid:`${dev.id}-${i}`, tip:`${portName(`${dev.id}-${i}`)} (SFP ${i})`, label:String(i) };
        });
        const buildMgmtCells = () => Array.from({length:mgmtCount}, (_, k)=>{
            const i = k + 1;
            const lbl = mgmtCount === 1 ? mgmtLabel : `${mgmtLabel}${i}`;
            return { pid:`${dev.id}-mgmt${i}`, tip:lbl, label:String(i) };
        });
        // Posizionamento orizzontale: [MGMT-left?] [SFP-left?] [data] [SFP-right?] [MGMT-right?]
        let cursor = x;
        if(mgmtCount > 0 && mgmtPos === 'left'){
            sideSvg += drawSideBlock(buildMgmtCells(), cursor, y, mgmtBlockW, h, '#00d4ff', mgmtLabel);
            cursor += mgmtBlockW + 2;
        }
        if(sfpCount > 0 && !sfpRight){
            sideSvg += drawSideBlock(buildSfpCells(), cursor, y, sfpBlockW, h, '#9aa5b1', null);   // ultimo blocco a sinistra: il cursore non serve più
        }
        // Cursor right side (start from far right going inward)
        let rcursor = x + w;
        if(mgmtCount > 0 && mgmtPos === 'right'){
            rcursor -= mgmtBlockW;
            sideSvg += drawSideBlock(buildMgmtCells(), rcursor, y, mgmtBlockW, h, '#00d4ff', mgmtLabel);
            rcursor -= 2;
        }
        if(sfpCount > 0 && sfpRight){
            rcursor -= sfpBlockW;
            sideSvg += drawSideBlock(buildSfpCells(), rcursor, y, sfpBlockW, h, '#9aa5b1', null);   // ultimo blocco a destra: il cursore non serve più
        }
        // Area data ports ridotta
        const dataX = x + leftAlloc;
        const dataW = w - leftAlloc - rightAlloc;
        if(!pc || dataW <= 0) return sideSvg;
        const isVisible = i => !((state.ports[`${dev.id}-${i}`] || {}).hidden);
        let anyVisible = false;
        for(let i=1;i<=pc;i++){ if(isVisible(i)){ anyVisible = true; break; } }
        if(!anyVisible) return sideSvg;
        // Sostituiamo (x, w) con (dataX, dataW) per il loop dei data ports
        x = dataX; w = dataW;

        // Disegna un singolo LED con il numero porta DENTRO il riquadro (centrato).
        // Inserire il numero dentro libera lo spazio verticale che prima serviva
        // sotto al LED, permettendo di ingrandire le porte.
        // LAG: dimensione e bordo IDENTICI alle altre porte — cambia solo il colore.
        const drawUnit = (i, cx, ledCenterY, pSize) => {
            if(!isVisible(i)) return '';
            const pid = `${dev.id}-${i}`;
            const pi = state.ports[pid] || {};
            const st = normalizePortStatus(pi.statusOvr ?? pi.status);
            const vlan = _effPortVlan(pid);
            const lag = portLag(pid);
            // Ordine: misura dello switch, poi LAG (ciano), poi stato dichiarato.
            const ledFill = shadeColor(pi) || (lag ? '#00d4ff' : statusColor(st));
            const px = cx - pSize / 2, py = ledCenterY - pSize / 2;
            let g = `<g><title>${safe(portName(pid))} - VLAN ${safe(vlan)}${lag ? ' - LAG' : ''}</title>`
                +`<rect x="${px.toFixed(1)}" y="${py.toFixed(1)}" width="${pSize.toFixed(1)}" height="${pSize.toFixed(1)}" rx="1.2" fill="${ledFill}" stroke="#334155" stroke-width="0.55"/>`;
            // Numero dentro il LED: font proporzionale (più stretto per 2 cifre),
            // colore scuro per contrasto sui LED colorati. Nascosto se il LED è
            // troppo piccolo per contenerlo in modo leggibile.
            const digits = String(i).length;
            const numFs = pSize * (digits >= 2 ? 0.52 : 0.64);
            if(pSize >= 3.6){
                g += `<text x="${cx.toFixed(1)}" y="${(ledCenterY + numFs * 0.36).toFixed(1)}" text-anchor="middle" font-family="Helvetica,Arial,sans-serif" font-size="${numFs.toFixed(1)}" fill="#0d1117">${i}</text>`;
            }
            return g + `</g>`;
        };

        const twoRows = pc > 24;
        let out = '';

        if(twoRows){
            // Stile Cisco/Aruba: una colonna per coppia (dispari sopra, pari sotto),
            // perfettamente ALLINEATE verticalmente (stessa cx per le due righe).
            // LED ingranditi: niente più numero esterno sotto le righe.
            const nCols = Math.ceil(pc / 2);
            // Altezza LED limitata a metà riga così le due file non si toccano.
            let pSize = Math.min(5.4, h * 0.42);
            const gapMin = 0.8, gapBreath = Math.min(3.5, pSize * 0.6);
            // Riduci il LED solo se non entra nemmeno col gap minimo.
            if(nCols * pSize + (nCols - 1) * gapMin > w)
                pSize = Math.max(2.8, (w - (nCols - 1) * gapMin) / nCols);
            // Gap ADATTIVO: distribuisce lo spazio libero tra le porte fino a
            // gapBreath → più respiro quando ci sono poche porte, compatto quando molte.
            const slack = nCols > 1 ? (w - nCols * pSize) / (nCols - 1) : 0;
            const gap = Math.max(gapMin, Math.min(gapBreath, slack));
            const colStep = pSize + gap;
            const total   = nCols * pSize + (nCols - 1) * gap;
            const startX  = x + Math.max(0, (w - total) / 2) + pSize / 2;
            const topLedY = y + h * 0.30;
            const botLedY = y + h * 0.70;
            for(let j=0;j<nCols;j++){
                const cx   = startX + j * colStep;
                const odd  = 2 * j + 1, even = 2 * j + 2;
                out += drawUnit(odd, cx, topLedY, pSize);
                if(even <= pc) out += drawUnit(even, cx, botLedY, pSize);
            }
        } else {
            // Riga singola: porte 1..N in sequenza, centrate. LED ingranditi e
            // centrati verticalmente (il numero è dentro, non serve spazio sotto).
            const visible = [];
            for(let i=1;i<=pc;i++) if(isVisible(i)) visible.push(i);
            const n = visible.length;
            // Non superare l'altezza utile della riga.
            let pSize = Math.min(7.2, h * 0.74);
            const gapMin = 1.4, gapBreath = Math.min(5.5, pSize * 0.7);
            // Riduci il LED solo se non entra nemmeno col gap minimo.
            if(n * pSize + (n - 1) * gapMin > w)
                pSize = Math.max(3.4, (w - (n - 1) * gapMin) / n);
            // Gap ADATTIVO: più respiro quando c'è spazio, compatto quando molte porte.
            const slack = n > 1 ? (w - n * pSize) / (n - 1) : 0;
            const gap = Math.max(gapMin, Math.min(gapBreath, slack));
            const colStep = pSize + gap;
            const total   = visible.length * pSize + (visible.length - 1) * gap;
            const startX  = x + Math.max(0, (w - total) / 2) + pSize / 2;
            const ledY    = y + h * 0.50;
            visible.forEach((i, k)=>{
                const cx = startX + k * colStep;
                out += drawUnit(i, cx, ledY, pSize);
            });
        }
        return sideSvg + out;
    };

    let s = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">`
        +`<defs>`
        +`<linearGradient id="rack-metal" x1="0" y1="0" x2="0" y2="1">`
        +`<stop offset="0" stop-color="#4a4a4a"/><stop offset="0.54" stop-color="#303030"/><stop offset="1" stop-color="#202020"/>`
        +`</linearGradient>`
        +`<linearGradient id="rack-label-bg" x1="0" y1="0" x2="0" y2="1">`
        +`<stop offset="0" stop-color="#111827"/><stop offset="1" stop-color="#05070a"/>`
        +`</linearGradient>`
        +`</defs>`
        +`<rect x="0" y="0" width="${W}" height="${H}" fill="#ffffff"/>`
        +`<text x="${left}" y="24" font-family="Helvetica,Arial,sans-serif" font-size="15" font-weight="700" fill="#0f172a">${safe(rack.name||rack.id)}</text>`
        +`<text x="${left}" y="38" font-family="Helvetica,Arial,sans-serif" font-size="8" fill="#64748b">${rackSize}U - ${devs.length} apparati</text>`
        +`<rect x="${left}" y="${top}" width="${chassisW}" height="${rackH}" rx="4" fill="#969696" stroke="#333333" stroke-width="2"/>`
        +`<rect x="${left}" y="${top}" width="${railW}" height="${rackH}" fill="#2a2a2a"/>`
        +`<rect x="${left+chassisW-railW}" y="${top}" width="${railW}" height="${rackH}" fill="#2a2a2a"/>`;

    // Numerazione coerente con la vista: se il rack ha uNumberFromTop il numero
    // stampato e' invertito (1 in alto) ma la posizione fisica dei device resta.
    const _fromTop = !!rack.uNumberFromTop;
    for(let u=1; u<=rackSize; u++){
        const y = top + (rackSize - u) * U;
        s += `<line x1="${innerX}" y1="${y}" x2="${innerX+innerW}" y2="${y}" stroke="#d1d5db" stroke-width="0.45"/>`;
        const uLabel = _fromTop ? (rackSize - u + 1) : u;
        s += `<text x="${left-6}" y="${y+U*0.68}" text-anchor="end" font-family="Helvetica,Arial,sans-serif" font-size="6" fill="#64748b">${uLabel}</text>`;
    }
    s += `<line x1="${innerX}" y1="${top+rackH}" x2="${innerX+innerW}" y2="${top+rackH}" stroke="#d1d5db" stroke-width="0.45"/>`;

    devs.forEach(dev=>{
        const def = TYPES[dev.type] || {};
        const sU = normalizeNumber(dev.sizeU ?? def.sizeU ?? 1, 1, 1, rackSize);
        const rackU = normalizeNumber(dev.rackU, 1, 1, Math.max(1, rackSize - sU + 1));
        const y = top + (rackSize - rackU - sU + 1) * U + 1;
        const h = Math.max(8, sU * U - 2);
        const fill = 'url(#rack-metal)';
        const stripe = stripeColor(dev);
        const rawLabel = String(dev.name || _typeName(dev.type));
        const brand = safe(dev.hostname || dev.brand || def.brand || '');
        const pc = dev.ports !== undefined ? dev.ports : (def.ports || 0);
        const labelW = 62;
        const labelX = innerX + innerW - labelW - 8;
        const labelH = Math.min(16, Math.max(10, h * 0.5));
        const labelY = y + h * 0.28;
        const showBrand = !!brand && pc > 0 && pc <= 8;
        const label = safe(fitRackLabel(rawLabel, labelW <= 62 ? 13 : 16));
        const portX = innerX + (showBrand ? 66 : 16);
        const portRight = labelX - 8;
        const portW = Math.max(40, portRight - portX);
        s += `<g>`
            +`<title>${label} - U${rackU}${sU>1 ? '-' + (rackU+sU-1) : ''}</title>`
            +`<rect x="${innerX}" y="${y}" width="${innerW}" height="${h}" rx="2" fill="${fill}" stroke="#111827" stroke-width="0.8"/>`
            +`<rect x="${innerX}" y="${y}" width="4" height="${h}" rx="1" fill="${stripe}"/>`;
        if(dev.type === 'cablemanager'){
            const fy = y + h * 0.38;
            // Passacavo orizzontale reso come pettine di anelli a "U" (fingers),
            // come i cable manager reali (foto di riferimento). Simmetrico e
            // INTERAMENTE dentro la cornice: gli arm partono da topY e la curva
            // arrotondata sta a botY (niente più sforo sotto come la vecchia "U").
            const _pad = 14, _mt = 3, _mb = 3, _fW = 14, _r = 3.5;
            const topY = y + _mt, botY = y + Math.max(_mt + 6, h - _mb);
            const fRegL = innerX + _pad;
            const fRegR = labelX - 10;                       // i fingers non invadono la targhetta
            const plateW = Math.max(8, (fRegR + 6) - (innerX + 10));
            // Piastra del pannello (corpo metallico) dietro gli anelli.
            s += `<rect x="${innerX+10}" y="${topY}" width="${plateW.toFixed(1)}" height="${Math.max(4,botY-topY).toFixed(1)}" rx="3" fill="#2b2f36" fill-opacity="0.7" stroke="#6b7280" stroke-opacity="0.4" stroke-width="0.7"/>`;
            const _cmN = 5;                                  // come la foto: 5 anelli
            const _span = Math.max(1, (fRegR - fRegL) - _fW);
            const _cmStep = _cmN > 1 ? _span / (_cmN - 1) : 0;
            for(let i=0;i<_cmN;i++){
                const fx = fRegL + i * _cmStep;
                // Anello a U: gambe verticali + base arrotondata, apertura in alto.
                s += `<path d="M${fx.toFixed(1)},${topY.toFixed(1)} V${(botY-_r).toFixed(1)} Q${fx.toFixed(1)},${botY.toFixed(1)} ${(fx+_r).toFixed(1)},${botY.toFixed(1)} H${(fx+_fW-_r).toFixed(1)} Q${(fx+_fW).toFixed(1)},${botY.toFixed(1)} ${(fx+_fW).toFixed(1)},${(botY-_r).toFixed(1)} V${topY.toFixed(1)}" fill="none" stroke="#0d1117" stroke-width="2.4" stroke-linejoin="round"/>`
                  +`<path d="M${(fx+1).toFixed(1)},${topY.toFixed(1)} V${(botY-_r-1).toFixed(1)}" fill="none" stroke="#9aa3ad" stroke-width="0.5" opacity="0.55"/>`;
            }
            s += `<rect x="${labelX}" y="${labelY}" width="${labelW}" height="${labelH}" rx="2" fill="url(#rack-label-bg)" stroke="#333333" stroke-width="0.7"/>`
                +`<text x="${labelX+labelW/2}" y="${fy+2}" text-anchor="middle" dominant-baseline="middle" font-family="Helvetica,Arial,sans-serif" font-size="7" font-weight="700" fill="#f8fafc">${label}</text>`;
        } else {
            if(showBrand){
                s += `<text x="${innerX+14}" y="${y+h*0.58}" font-family="Helvetica,Arial,sans-serif" font-size="6" font-weight="700" fill="#d0d0d0">${brand}</text>`;
            }
            s += drawPorts(dev, portX, y + 1, portW, h - 2);
            s += `<rect x="${labelX}" y="${labelY}" width="${labelW}" height="${labelH}" rx="2" fill="url(#rack-label-bg)" stroke="#333333" stroke-width="0.7"/>`
                +`<text x="${labelX+labelW/2}" y="${y+h*0.56}" text-anchor="middle" dominant-baseline="middle" font-family="Helvetica,Arial,sans-serif" font-size="7" font-weight="700" fill="#f8fafc">${label}</text>`;
        }
        s += `</g>`;
    });

    s += `</svg>`;
    return { rackId: rack.id, rackName: rack.name || rack.id, svg: s };
}

function _buildRackSvgs(){
    return (state.racks || [])
        .map(r=>_buildRackSVG(r.id, { pdfMode:true }))
        .filter(Boolean);
}

function _parseSvgSize(dataUri){
    try {
        const parts   = dataUri.split(',');
        const isB64   = parts[0].includes('base64');
        const raw     = isB64 ? atob(parts.slice(1).join(','))
                               : decodeURIComponent(parts.slice(1).join(','));
        // width / height espliciti (solo valori numerici, non percentuali)
        const wM = raw.match(/<svg[^>]*\swidth="([\d.]+)(?:px)?"/i);
        const hM = raw.match(/<svg[^>]*\sheight="([\d.]+)(?:px)?"/i);
        let w = wM ? parseFloat(wM[1]) : 0;
        let h = hM ? parseFloat(hM[1]) : 0;
        // Fallback a viewBox se width/height mancanti o sono percentuali
        if (!w || !h) {
            const vb = raw.match(/viewBox="([^"]+)"/i);
            if (vb) {
                const p = vb[1].trim().split(/[\s,]+/).map(Number);
                if (!w) w = p[2] || 0;
                if (!h) h = p[3] || 0;
            }
        }
        return { w, h };
    } catch (_) { return { w:0, h:0 }; }
}

async function _imgToPng(srcDataUri, naturalW, naturalH, scale){
    return new Promise(resolve=>{
        try {
            const img=new Image();
            img.onload=()=>{
                const w=Math.round(naturalW*scale);
                const h=Math.round(naturalH*scale);
                const c=document.createElement('canvas');
                c.width=w; c.height=h;
                const ctx=c.getContext('2d');
                // Sfondo bianco trasparente (preserva alpha se presente)
                ctx.clearRect(0,0,w,h);
                ctx.drawImage(img,0,0,w,h);
                resolve({ dataUri: c.toDataURL('image/png'), w, h });
            };
            img.onerror=()=>resolve(null);
            img.src=srcDataUri;
        } catch(_){ resolve(null); }
    });
}

function _buildPdfReportData() {
    // ── Pagina 2: Inventario Cavi ──────────────────────────────────────────
    const cables = state.links.map(l => {
        const sn = getNodeByPortId(l.src), dn = getNodeByPortId(l.dst);
        const sp = l.src.split('-').slice(1).join('-');
        const dp = l.dst.split('-').slice(1).join('-');
        // VLAN nel dossier = la stessa che DIPINGE il cavo a schermo. Prima stampava
        // la nativa: su un trunk usciva «VLAN 1» anche quando le VLAN vere viaggiavano
        // taggate — la stessa bugia della fotografia, ma su carta.
        const _p  = _linkPaintVlan(l);
        const vl  = _p.known ? _p.vlan : _getLinkVlan(l);
        const _tg = _p.vlans.filter(v => v > 1);
        return {
            label:    (l.label || _cableAutoLabel(l)).replace(/→/g, '->'),
            from:     `${(typeof _dispName==='function'?_dispName(sn?.name||'?'):(sn?.name||'?'))} P${sp}`,
            to:       `${(typeof _dispName==='function'?_dispName(dn?.name||'?'):(dn?.name||'?'))} P${dp}`,
            vlan:     vl > 1 ? vl : null,
            vlanName: vl > 1 ? (state.vlanNames?.[vl] || '') : '',
            // Un trunk porta piu' di una VLAN: la colonna VLAN ne mostra UNA, questa
            // dice quali sono davvero. Vuota sugli access (niente rumore in tabella).
            vlanCarried: _tg.length > 1 ? _tg.join(', ') : '',
            cableType:l.cableType || '',
            medium:   l.medium   || '',
            length:   l.lengthM != null ? String(l.lengthM) : (l.length != null ? String(l.length) : ''),
            category: l.cableCategory || l.category || '',
            color:    l.color || l.colorOvr || '',
            installedAt: l.installedAt || '',
            installedBy: l.installedBy || '',
            isPermanent: !!l.isPermanent,
            notes:    l.notes || '',
        };
    });

    // ── Pagina 3: Tracciato As-Built ──────────────────────────────────────
    const asBuilt = [];
    const usedLinks = new Set();
    state.nodes
        .filter(n => TYPES[n.type]?.isFloor && !TYPES[n.type]?.isStructural)
        .forEach(fn => {
            const pc = fn.ports !== undefined ? fn.ports : (TYPES[fn.type]?.ports || 1);
            for (let i = 1; i <= pc; i++) {
                const startPid = `${fn.id}-${i}`;
                if (!_linksForPort(startPid).length) continue;
                const steps = [`${getNodeDisplayName(fn) || _typeName(fn.type)} P${i}`];
                let cur = startPid;
                const vis = new Set([startPid]);
                for (let h = 0; h < 12; h++) {
                    const lnk = _linksForPort(cur).find(l => !usedLinks.has(l.id));
                    if (!lnk) break;
                    usedLinks.add(lnk.id);
                    const nxt = lnk.src === cur ? lnk.dst : lnk.src;
                    if (vis.has(nxt)) break;
                    vis.add(nxt);
                    const nn = getNodeByPortId(nxt); if (!nn) break;
                    steps.push(`${getNodeDisplayName(nn) || _typeName(nn.type)} P${nxt.split('-').slice(1).join('-')}`);
                    if (TYPES[nn.type]?.isActive) break;
                    cur = nxt;
                }
                if (steps.length > 1) {
                    const vl = _effPortVlan(startPid);
                    asBuilt.push({ steps, vlan: vl > 1 ? _vlanLabel(vl) : '', medium: '' });
                }
            }
        });

    // ── Pagina 4: Assegnazione porte ──────────────────────────────────────
    const portAssignment = [];
    state.racks.forEach(rack => {
        const devs = state.nodes
            .filter(n => n.rackId === rack.id && TYPES[n.type]?.isRack)
            .sort((a, b) => (b.rackU || 0) - (a.rackU || 0));
        devs.forEach(dev => {
            const pc = dev.ports !== undefined ? dev.ports : (TYPES[dev.type]?.ports || 0);
            // Le porte MGMT esistono anche con pc=0: non uscire subito.
            const fp = dev.frontPanel || {};
            const mgmtEligible = !!(TYPES[dev.type] && TYPES[dev.type].mgmtEligible);
            const mgmtCount = mgmtEligible ? Math.max(0, Math.min(parseInt(fp.mgmtCount,10)||0, 4)) : 0;
            const mgmtLabel = (typeof fp.mgmtLabel === 'string' && fp.mgmtLabel.trim()) ? fp.mgmtLabel.trim() : 'MGMT';
            if (!pc && !mgmtCount) return;
            const ports = [];
            const buildRow = (pid, num, alias) => {
                const pi = state.ports[pid] || {};
                if(pi.hidden) return null;
                const lks = _linksForPort(pid);
                const connTo = lks.map(l => {
                    const op = l.src === pid ? l.dst : l.src;
                    const on = getNodeByPortId(op);
                    return `${on?.name || '?'} P${op.split('-').slice(1).join('-')}`;
                }).join(', ') || '—';
                // Onesto: una porta senza stato misurato (SNMP) NÉ override manuale
                // non è "inactive" — è NON DETERMINATA. normalizePortStatus(undefined)
                // ritorna 'inactive' (default deliberato per la UI), ma nel dossier
                // affermerebbe "spenta/libera" su porte mai osservate (schema ①). Stessa
                // guardia del rack SVG poco sopra (statusOvr||status ? colore : trasparente).
                const _hasStatus = pi.statusOvr != null || pi.status != null;
                const st = _hasStatus ? normalizePortStatus(pi.statusOvr ?? pi.status) : '';
                const spd = pi.speedOvr ?? pi.speed;
                let spdStr = '';
                if(typeof spd === 'number' && Number.isFinite(spd) && spd > 0){
                    spdStr = spd >= 1000 ? `${(spd / 1000).toFixed(spd % 1000 ? 1 : 0)}G` : `${spd}M`;
                } else if(typeof spd === 'string' && spd.trim()){
                    spdStr = spd.trim();
                }
                const vl = _effPortVlan(pid);
                return { num, alias: alias || pi.desc || pi.alias || pi.ifName || '',
                         status: st, speed: spdStr,
                         vlan: vl > 1 ? _vlanLabel(vl) : '', connectedTo: connTo };
            };
            // Data ports (1..pc)
            for(let i = 1; i <= pc; i++){
                const row = buildRow(`${dev.id}-${i}`, i);
                if(row) ports.push(row);
            }
            // Porte MGMT (-mgmt1..-mgmtN): aggiunte dopo le data, con num=etichetta
            for(let i = 1; i <= mgmtCount; i++){
                const cellName = mgmtCount === 1 ? mgmtLabel : `${mgmtLabel}${i}`;
                const row = buildRow(`${dev.id}-mgmt${i}`, cellName, cellName);
                if(row) ports.push(row);
            }
            if (ports.length) portAssignment.push({
                rack: rack.name || rack.id, device: dev.name || dev.type,
                type: _typeName(dev.type), ports
            });
        });
    });

    // ── Pagina 5: Sommario VLAN ───────────────────────────────────────────
    // Trunk del sommario: usa la membership DERIVATA (_getLinkTrunk), la stessa
    // single-source-of-truth della vista live. I trunk normali sono ancorati allo
    // switchport e setLinkMode CANCELLA l.mode dal cavo (manual-first): leggere il
    // grezzo l.mode mostrerebbe SOLO i vecchi override legacy su run di soli
    // passivi → il sommario PDF restava quasi sempre vuoto. Si limita agli uplink
    // reali (almeno un capo è un device attivo) per non elencare gli hop passivi
    // interni al run (presa↔patch). Precalcolato una volta, non per-VLAN.
    const trunkUplinks = state.links.map(l => {
        const t = (typeof _getLinkTrunk === 'function')
            ? _getLinkTrunk(l)
            : { mode: l.mode, vlans: _parseTrunkVlans(l.trunkVlans || '') };
        if (!t || t.mode !== 'trunk') return null;
        const sn = getNodeByPortId(l.src), dn = getNodeByPortId(l.dst);
        if (!(TYPES[sn?.type]?.isActive || TYPES[dn?.type]?.isActive)) return null;
        return { l, sn, dn, vlans: (t.vlans || []).map(Number) };
    }).filter(Boolean);

    const vlans = Object.keys(state.vlanColors).sort((a, b) => +a - +b).map(vid => {
        const v = +vid;
        const byDevice = {}, trunkLinks = [];

        for (const [pid] of Object.entries(state.ports)) {
            if (_effPortVlan(pid) !== v) continue;
            const n = getNodeByPortId(pid);
            if (!n) continue;
            const def = TYPES[n.type];
            // Escludi dispositivi passivi (patch panel, passacavo, presa a muro, ecc.)
            // e strutturali (stanze): non generano VLAN proprie
            if (def?.isPassive || def?.isStructural) continue;
            const devName = getNodeDisplayName(n) || n.hostname || def?.name || n.type;
            const portNum = parseInt(pid.split('-').pop(), 10) || 0;
            if (!byDevice[devName]) byDevice[devName] = [];
            byDevice[devName].push(portNum);
        }

        // Ordina dispositivi alfabeticamente, porte numericamente
        const accessGroups = Object.entries(byDevice)
            .sort(([a], [b]) => a.localeCompare(b))
            .map(([device, ports]) => ({ device, ports: [...ports].sort((a, b) => a - b) }));

        for (const tu of trunkUplinks) {
            if (!tu.vlans.includes(v)) continue;
            const srcPort = parseInt(tu.l.src.split('-').pop(), 10) || 0;
            const dstPort = parseInt(tu.l.dst.split('-').pop(), 10) || 0;
            trunkLinks.push({
                src:      (typeof _dispName==='function'?_dispName(tu.sn?.name||'?'):(tu.sn?.name||'?')),
                srcPort,
                dst:      (typeof _dispName==='function'?_dispName(tu.dn?.name||'?'):(tu.dn?.name||'?')),
                dstPort,
                vlans:    tu.vlans.join(', ')
            });
        }

        // Ordina trunk per device sorgente, poi per numero porta
        trunkLinks.sort((a, b) => a.src.localeCompare(b.src) || a.srcPort - b.srcPort);

        const totalAccess = accessGroups.reduce((s, g) => s + g.ports.length, 0);
        // IPAM (da tabella VLAN): range IP (subnet), gateway di default, DNS.
        // Vista derivata dai prefissi (lib/ipam-model.js, sul bundle e quindi su
        // window): la subnet non è più un campo della VLAN.
        const ipam = vlanIpamView(state, vid) || {};
        return { id: v, name: state.vlanNames?.[v] || '', color: state.vlanColors[vid],
                 subnet: String(ipam.subnet || '').trim(),
                 gateway: String(ipam.gateway || '').trim(),
                 dns: String(ipam.dns || '').trim(),
                 accessGroups, totalAccess, trunkLinks };
    });

    // ── Pagina 6: Topologia SVG ───────────────────────────────────────────
    let topoSvg = null;
    if (_topoData) {
        const wasTopo = _topoVisible;
        _topoVisible  = true;
        topoSvg = _buildFloorSVG({ skipBgImage: true, pdfMode: true });
        _topoVisible  = wasTopo;
    }

    const rackSvgs = _buildRackSvgs();

    // ── Macchine virtuali: catalogo per il dossier ────────────────────────
    // Le VM vivono annidate in node.vms[] (l'host resta l'apparato documentato):
    // qui vengono APPIATTITE in righe host+VM per il capitolo del report. Tutto
    // dichiarato a mano — nessuna misura sui guest: i campi assenti restano
    // assenti e il PDF stampa '-' (② no-invenzioni). Ordine host, poi nome VM.
    const _vmNum = x => { const v = typeof x === 'number' ? x : parseFloat(x); return Number.isFinite(v) && v > 0 ? v : null; };
    const vms = [];
    state.nodes
        // Enumeriamo QUALSIASI nodo con VM documentate: `node.vms` è dato scritto
        // (import/migrazione/JSON), non un privilegio del catalogo. Filtrare su
        // TYPES.hostsVms — flag che `server` non ha — cancellava dal dossier VM
        // reali (schema ③: flag-di-catalogo usato come predicato sul dato).
        .filter(n => Array.isArray(n.vms) && n.vms.length)
        .forEach(h => {
            const hostName = getNodeDisplayName(h) || h.name || h.id;
            h.vms.forEach(vm => {
                if (!vm) return;
                // Una VM può avere più vNIC (firewall virtuale WAN/LAN/DMZ): il
                // dossier resta a UNA riga per macchina — è l'inventario delle
                // VM, non delle interfacce — e nelle colonne di rete elenca i
                // valori di tutte le schede separati da «·».
                const _nics = (typeof vmNics === 'function') ? vmNics(vm) : [];
                const _join = f => _nics.map(n => n[f]).filter(Boolean).join(' · ');
                vms.push({
                    host:  hostName,
                    name:  vm.name || '',
                    role:  vm.role || '',
                    // Tri-stato ONESTO: uno stato assente NON è «accesa» (schema ①).
                    // Passa il valore grezzo; il renderer PDF distingue running/
                    // stopped/non-spec (pdf-report.js). Solo lo stato SNMP-misurato
                    // o dichiarato a mano dice «accesa».
                    state: vm.state === 'running' ? 'running' : vm.state === 'stopped' ? 'stopped' : '',
                    vlan:  _join('vlan'),
                    ip:    _join('ip'),
                    mac:   _join('mac'),
                    nics:  _nics.length,
                    vcpu:  _vmNum(vm.vcpu),
                    ramGb: _vmNum(vm.ramGb),
                    diskGb:_vmNum(vm.diskGb),
                    owner: vm.owner || '',
                    criticality: vm.criticality || '',
                });
            });
        });
    vms.sort((a, b) => String(a.host).localeCompare(String(b.host)) || String(a.name).localeCompare(String(b.name)));

    // ── Dossier di consegna (N4): copertina + note + changelog ────────────
    // Sezioni opzionali assemblate dalla logica pura buildHandoffSections.
    let handoff = null;
    if (typeof buildHandoffSections === 'function') {
        const _vs = new Set();
        for (const pid in state.ports) { const v = state.ports[pid]?.vlan; if (v && v > 1) _vs.add(v); }
        // La VLAN di un cavo la dice il MODELLO, non la nativa: `_getLinkVlan`
        // raccoglieva 1 dove il cavo è in 99, e quelle VLAN sparivano dal dossier.
        for (const l of state.links) { const _p = _linkPaintVlan(l); const v = _p.known ? _p.vlan : _getLinkVlan(l); if (v > 1) _vs.add(v); }
        const _lg = (typeof getLang === 'function' ? getLang() : 'it');
        handoff = buildHandoffSections({
            title: (_lg === 'en' ? 'Handover dossier' : 'Dossier di consegna'),
            project: _exportProjectName() || 'InfraNet Pro',
            date: new Date().toLocaleDateString(_lg === 'en' ? 'en-GB' : 'it-IT'),
            user: (typeof _currentUser === 'object' && _currentUser && _currentUser.username) || '',
            // Le NOTE non passano piu' di qui: vivono nel registro asset, accanto al
            // loro apparato (server-side, applyDeviceNotes). Un secondo posto che
            // decide «qual e' la nota di un device» sarebbe la solita definizione
            // duplicata destinata a divergere.
            devices: state.nodes.map(n => ({
                name: getNodeDisplayName(n) || n.name || n.id,
                typeLabel: _typeName(n.type),
                structural: !!TYPES[n.type]?.isStructural,
            })),
            cableCount: state.links.length,
            vlanCount: _vs.size,
            vmCount: vms.length,          // contatore a se': mai sommato ai dispositivi
            auditLog: state.auditLog || [],
            changelogLimit: 50,
        });
    }

    // Porte libere (capacità): report opzionale nel Dossier (pagina A4 server-side).
    let spare = null;
    if (typeof buildSpareReport === 'function' && typeof _spareBuildDevices === 'function') {
        try { spare = buildSpareReport(_spareBuildDevices()); } catch (_) {}
    }

    // ── Ripristinabilità (DR): puntatore backup + identità + posizione ────────
    // Apparati GESTITI (isActive): quelli con un config da rimettere in piedi. Il
    // puntatore backup dice DOVE vive il config (MAI il config né credenziali: `ref`
    // le rifiuta per contratto). Serial/firmware = cosa procurare/riflashare; rack =
    // dove va. È il cuore della runbook: nessun segreto, stesso confine del registro asset.
    const recovery = {
        devices: state.nodes.filter(n => TYPES[n.type]?.isActive).map(n => {
            const b = n.backup || {};
            const inv = (n.integration && n.integration.inventory) || {};
            const rk = (state.racks || []).find(r => r.id === n.rackId);
            // Seriale DICHIARATO ≠ MISURATO = apparato sostituito: un'identità su cui
            // NON si può contare per il ripristino. Il flag viaggia a parte così il
            // PDF dà lo stesso verdetto della lente DR (prima il dossier consegnato
            // era più ottimista dell'app, che qui conta la lacuna).
            const _declS = String(n.serialNumber || '').trim().toLowerCase();
            const _measS = String(inv.serialNumber || '').trim().toLowerCase();
            // ...ma solo se la misura è RICONFERMATA dall'ultimo poll. Un inventory
            // rimasto «ultimo noto» (walk ENTITY-MIB troncata) informa ancora su cosa
            // ricomprare, non autorizza a scrivere «sostituito» in un documento
            // consegnato. Stessa regola di Verifica e Panoramica: lib/identity-reconcile.js
            // (<script> caricato prima di questo).
            const _idConfirmed = (typeof isConfirmedMeasure === 'function')
                ? isConfirmedMeasure(inv) : (inv.stale !== true);
            // Una matricola bloccata a mano non si contesta nemmeno nel documento consegnato (stesso lucchetto
            // della Verifica e della Panoramica).
            const _idLocked = (typeof isIdentityLocked === 'function') && isIdentityLocked(n, 'serialNumber');
            return {
                name: getNodeDisplayName(n) || n.name || n.id,
                backupRef: b.ref || '', backupMethod: b.method || '', backupAt: b.at || '',
                serial: n.serialNumber || inv.serialNumber || '',
                firmware: n.firmwareVer || inv.firmwareVer || '',
                model: n.model || inv.model || '',
                identityMismatch: !!(_idConfirmed && !_idLocked && _declS && _measS && _declS !== _measS),
                // Ciclo di vita: due date DICHIARATE (nessun apparato le misura).
                // In una runbook di procurement pesano quanto il seriale — «lo
                // ricompri?» viene subito dopo «cosa ricompri?». Vuote = non
                // dichiarate: il PDF scrive un trattino, mai una data inventata.
                warrantyUntil: n.warrantyUntil || '',
                eolDate: n.eolDate || '',
                rack: rk ? (rk.name || rk.id) : '',
            };
        }),
    };

    // ── Panoramica: la sintesi in testa al dossier ────────────────────────────
    // I tre verdetti che l'utente vede a schermo, con le PAROLE gia' risolte dal
    // glue (una sola fonte per i numeri, una sola per le parole). Se il modulo non
    // c'e' o inciampa, la sezione semplicemente non compare: mai un PDF a meta'.
    let overview = null;
    try { if (typeof buildOverviewReport === 'function') overview = buildOverviewReport(); } catch (_) { overview = null; }

    // ── Alimentazione / PDU ──────────────────────────────────────────────
    // A differenza degli altri capitoli qui NON componiamo le righe: servono gli
    // helper di lib/pdu-layout.js (stato presa, connessione, modalità di gestione)
    // che nel browser vivono solo dentro il bundle ESM e non sono globali — questo
    // file è uno script classico. Mandiamo quindi i soli campi PDU, in WHITELIST
    // esplicita, e le righe le compone il server (lib/pdu-report.js): una sola
    // implementazione, testata a tavolino.
    //
    // ⚠️ La whitelist è anche la barriera anti-leak: si copiano campi NOMINATI, mai
    // il nodo intero — `node.integration` porta community/credenziali SNMP e non
    // deve uscire dal browser (stessa regola dell'export JSON sanificato). Dell'
    // integrazione esce SOLO `host`, che è un indirizzo, non un segreto.
    const _rackNameById = new Map((state.racks || [])
        .filter(r => r && r.id != null).map(r => [String(r.id), String(r.name || r.id)]));
    // ⚠️ SPECCHIO di `OUTLET_DEVICE_TYPES` in lib/pdu-layout.js — chi ha delle prese
    // (barra e UPS; l'ATS è fuori per decisione). Questo file è uno script classico e
    // non può importare il modulo ESM, quindi la lista è ripetuta: un test la
    // confronta con l'originale e fallisce se divergono. Senza, un UPS entrerebbe
    // nel documento con le sue prese e sparirebbe dal capitolo che le stampa — cioè
    // proprio dal foglio che serve in sala quando manca la corrente.
    const _OUTLET_TYPES = ['pdu', 'ups'];
    const pdus = (state.nodes || [])
        .filter(n => n && _OUTLET_TYPES.indexOf(n.type) >= 0)
        .map(n => ({
            id: n.id,
            name: n.name,
            brand: n.brand,
            model: n.model,
            // Identità per il RIPRISTINO: chi la ricompra e chi la riconfigura ha
            // bisogno di matricola/firmware/asset tag e della copertura, non solo
            // del nome. `integration.inventory` è la misura ENTITY-MIB, il livello
            // nodo è il dichiarato: passano entrambi, a valle decide il dichiarato.
            serialNumber: n.serialNumber,
            firmwareVer: n.firmwareVer,
            assetTag: n.assetTag,
            warrantyUntil: n.warrantyUntil,
            mac: n.mac,
            notes: n.notes,
            sizeU: n.sizeU,
            // Puntatore al backup della configurazione: DOVE vive, mai il config e
            // mai le credenziali (`ref` è già validato credential-free a monte).
            backup: n.backup ? { ref: n.backup.ref, method: n.backup.method, at: n.backup.at } : undefined,
            // Alimentazione IN INGRESSO (power-port importate da NetBox): da quale
            // quadro/UPS arriva la corrente. Senza, la runbook di ripristino non
            // dice da dove ricollegarla.
            pduPowerPorts: Array.isArray(n.pduPowerPorts) ? n.pduPowerPorts : undefined,
            // Il nome del rack lo risolve il client (conosce state.racks); al server
            // arriva già leggibile, così il capitolo non deve reidratare nulla.
            rackName: n.rackId != null ? (_rackNameById.get(String(n.rackId)) || null) : null,
            rackU: n.rackU,
            ip: n.ip,
            // Dell'integrazione escono SOLO l'indirizzo, il nome del driver (che sia
            // SNMP o meno è un fatto di ripristino) e l'inventario MISURATO. Community
            // e credenziali non attraversano mai questo confine.
            integration: n.integration ? {
                host: n.integration.host,
                driver: n.integration.driver,
                inventory: n.integration.inventory ? {
                    serialNumber: n.integration.inventory.serialNumber,
                    model: n.integration.inventory.model,
                    firmwareVer: n.integration.inventory.firmwareVer,
                } : undefined,
            } : undefined,
            frontPanel: n.frontPanel ? { mgmtCount: n.frontPanel.mgmtCount } : undefined,
            ports: n.ports,
            // I campi PDU vivono in node.spec[...] (o al livello nodo nei progetti
            // vecchi): si copiano ENTRAMBI i livelli e a valle `nodeField` sceglie —
            // lo stesso getter che usa il renderer, non una seconda regola.
            spec: n.spec ? {
                pduType: n.spec.pduType, pduPhase: n.spec.pduPhase, pduCurrentA: n.spec.pduCurrentA,
                pduOutletCount: n.spec.pduOutletCount,
                pduMgmtMode: n.spec.pduMgmtMode, pduEthernetPorts: n.spec.pduEthernetPorts,
                pduSerialPorts: n.spec.pduSerialPorts, pduSensorPorts: n.spec.pduSensorPorts,
                pduUsbPorts: n.spec.pduUsbPorts, pduExpansionPorts: n.spec.pduExpansionPorts,
            } : undefined,
            pduType: n.pduType, pduPhase: n.pduPhase, pduCurrentA: n.pduCurrentA,
            pduOutletCount: n.pduOutletCount,
            pduMgmtMode: n.pduMgmtMode, pduEthernetPorts: n.pduEthernetPorts,
            pduSerialPorts: n.pduSerialPorts, pduSensorPorts: n.pduSensorPorts,
            pduUsbPorts: n.pduUsbPorts, pduExpansionPorts: n.pduExpansionPorts,
            powerOutlets: Array.isArray(n.powerOutlets) ? n.powerOutlets.map(o => ({
                id: o.id, name: o.name, label: o.label,
                status: o.status, rawStatus: o.rawStatus, state: o.state, powerState: o.powerState,
                statusOvr: o.statusOvr, connectionOvr: o.connectionOvr,
                connectedTo: o.connectedTo, connectedDeviceId: o.connectedDeviceId,
                connectedDeviceName: o.connectedDeviceName, connectedPortName: o.connectedPortName,
                powerPort: o.powerPort, connected: o.connected, cable: o.cable, cableStatus: o.cableStatus,
            })) : undefined,
        }));

    return { cables, asBuilt, portAssignment, vlans, rackSvgs, topoSvg, handoff, spare, vms, recovery, overview, pdus };
}

async function exportPDF(opts={}){
    const options={ ...PDF_EXPORT_DEFAULTS, ...(opts||{}), ...PDF_DOSSIER };
    const btn=document.getElementById('btn-export-pdf');
    const origHtml=btn?.innerHTML||'';
    try {
        if(btn){ btn.disabled=true; btn.innerHTML='<i class="fas fa-spinner fa-spin"></i>'; }

        const projName=_exportProjectName()||'InfraNet Pro';

        // Invia il bgImage separatamente: svg-to-pdfkit non gestisce i data URI
        // nei tag <image>; pdfkit aggiunge l'immagine via doc.image() nativo.
        // Convertiamo sempre in PNG perché pdfkit accetta solo JPEG e PNG.
        const bgEl=document.getElementById('floor-bg-img');
        const bsc=state.bgImageScale||1;
        const mime=state.bgImage
            ? (state.bgImage.split(';')[0].split(':')[1]||'').toLowerCase() : '';
        const isSvgBg = mime==='image/svg+xml';

        // naturalWidth è 0 in Chrome per SVG senza width/height espliciti:
        // in quel caso leggiamo le dimensioni direttamente dall'XML del SVG.
        let natW = bgEl ? bgEl.naturalWidth  : 0;
        let natH = bgEl ? bgEl.naturalHeight : 0;
        if(isSvgBg && (!natW||!natH)){
            const parsed=_parseSvgSize(state.bgImage);
            natW = parsed.w || natW || 800;
            natH = parsed.h || natH || 600;
        }
        const hasImg = !!state.bgImage && (isSvgBg ? true : natW>0);

        const payload={
            svg:         _buildFloorSVG({ skipBgImage: true, pdfMode: true }),
            projectName: projName,
            // projectId: il server carica il progetto per costruire il registro asset
            // (riuso nodeToDevice) e leggere project.updated_at ("ultima revisione").
            // currentProjectId e' un globale del ponte (store proxy → window).
            projectId:   (typeof currentProjectId !== 'undefined' ? currentProjectId : null),
            // lang: il server localizza il testo del report (it/en) sulla lingua UI corrente.
            lang:        (typeof getLang === 'function' ? getLang() : 'it'),
            reportData:  _buildPdfReportData(),
            reportOptions: options,
        };
        if(hasImg && options.includeBackground){
            if(isSvgBg){
                // SVG vettoriale: inviato direttamente, il server usa SVGtoPDF
                payload.bgImage     = state.bgImage;
                payload.bgImageW    = Math.round(natW * bsc);
                payload.bgImageH    = Math.round(natH * bsc);
                payload.bgImageType = 'svg';
            } else {
                // Raster (PNG/JPEG/WebP/BMP/…): normalizza in PNG per pdfkit
                const png=await _imgToPng(state.bgImage, natW, natH, bsc);
                if(png){
                    payload.bgImage     = png.dataUri;
                    payload.bgImageW    = png.w;
                    payload.bgImageH    = png.h;
                    payload.bgImageType = 'raster';
                }
            }
        }

        const resp=await fetch('/api/export-pdf',{
            method:'POST',
            headers:{'Content-Type':'application/json'},
            body:JSON.stringify(payload)
        });

        if(!resp.ok){
            let msg=_t('impexp.serverError','Server error');
            try{ const j=await resp.json(); msg=j.error||msg; }catch(_){}
            throw new Error(msg);
        }

        const blob=await resp.blob();
        const url=URL.createObjectURL(blob);
        const a=document.createElement('a');
        a.href=url;
        a.download=`${(projName||'infranetpro').replace(/[^a-z0-9_\- ]/gi,'_')}-dossier.pdf`;
        a.click();
        setTimeout(()=>URL.revokeObjectURL(url),5000);
    } catch(err){
        alert(_t('impexp.pdfExportFailed','PDF export failed:')+'\n'+err.message);
    } finally {
        if(btn){ btn.disabled=false; btn.innerHTML=origHtml; }
    }
}

window.exportJSON = exportJSON;
window.exportLabelsCSV = exportLabelsCSV;
window.openLabelExportOptions = openLabelExportOptions;
window.closeLabelExportOptions = closeLabelExportOptions;
window.syncLabelExportUi = syncLabelExportUi;
window.confirmLabelExport = confirmLabelExport;
window.openPdfExportOptions = openPdfExportOptions;
window.closePdfExportOptions = closePdfExportOptions;
window.setPdfExportAll = setPdfExportAll;
window.syncPdfExportUi = syncPdfExportUi;
window.confirmPdfExport = confirmPdfExport;
window.exportFloorSVG = exportFloorSVG;
window.exportPDF = exportPDF;
window.exportDrawio = exportDrawio;

// La voce di menu "Esporta draw.io" NON usa onclick inline (che gonfierebbe il
// cricchetto ASSE B degli handler inline in netmapper.html). export.js e' classic
// ed escluso dai ratchet: agganciamo il click qui. Il bottone e' HTML statico (gia'
// nel DOM quando export.js gira); closeImpExpMenu e' esposto dal bundle (caricato
// dopo) ma serve solo a click-time, quando esiste.
(function(){
    const b = document.getElementById('btn-export-drawio');
    if(b) b.addEventListener('click', ()=>{
        exportDrawio();
        if(typeof closeImpExpMenu === 'function') closeImpExpMenu();
    });
})();

// Hook di debug/test: espone i builder puri interni all'IIFE (SVG floor/rack e
// dati del report PDF) così che lo smoke E2E possa esercitarli con uno stato
// realistico senza un browser/PDF reale. Stessa convenzione di drivers/snmp.js
// (`_internals`). NON usato dal codice di produzione — solo dai test.
window._exportInternals = {
    _buildFloorSVG, _buildRackSVG, _buildRackSvgs, _buildPdfReportData,
    _cableLabelRows, _nodeRoomName, _csvColumnsFor, _parseSvgSize, _genericGridFromUi,
    _ensureSvgXmlns, _svgDataUri, _exportProjectName, _exportFileStem,
};
})();