/**
 * Fichier  : assets/js/pages/barcodes.js
 * Auteur   : KREMER Régis
 * Version  : 2.6.83
 * Date     : 2026-07-20
 * Description : JS — Générateur d'étiquettes et codes-barres.
 *
 * Changelog :
 *   v3.4.0  — Filtre magasin basé sur stock réel, PDF fidèle preview, codes longs, repères foncés.
 *   v2.6.18 — Images produits locales via proxy sécurisé.
 *   v2.6.83 — BARCODES WIZARD7 : état partagé avec l’assistant tactile et promesse PDF retournée.
 */
const BcPage = {
    prods: [],
    stock: [],
    selected: new Map(),
    style: 'img_barcode',
    format: 'A4_21',

    // Formats réalistes — rows calculés pour tenir sur A4 (297mm)
    // A4: 297mm - 2×8mm marge = 281mm dispo
    // 70×37mm: floor((281+2.5)/(37+2.5)) = floor(283.5/39.5) = 7 rows → 21/page
    // 105×37mm: 2 cols, 7 rows → 14/page
    formats: {
        A4_21:      { cols:3, rows:7, wmm:70,   hmm:37,   gapmm:2.5, marginmm:8, title:'A4 — 21 étiquettes (70×37)' },
        Avery_L7160:{ cols:3, rows:7, wmm:63.5, hmm:38.1, gapmm:2.5, marginmm:8, title:'Avery L7160 (63.5×38.1)' },
        '70x37':    { cols:3, rows:7, wmm:70,   hmm:37,   gapmm:2.5, marginmm:8, title:'70×37mm — 21/page' },
        '105x37':   { cols:2, rows:7, wmm:105,  hmm:37,   gapmm:3.0, marginmm:8, title:'105×37mm — 14/page' },
    },

    // ══════════════════════════════════════════════════════════════
    // INIT
    // ══════════════════════════════════════════════════════════════
    init: async function(){
        this.prods = await Api.products();
        try { this.stock = await Api.stock(); } catch(e){ console.warn('[barcodes] Stock indisponible pour le filtre magasin', e); this.stock = []; }
        const whs = await Api.warehouses();
        this.whs = Array.isArray(whs) ? whs : [];
        this._populateWarehouseFilter();
        await this._refreshCategoryFilter();
        this._bindTabs();
        this._bindControls();
        this._bindActions();
        this.renderProductGrid();
        this.renderPreview();
    },

    // ══════════════════════════════════════════════════════════════
    // UTILS
    // ══════════════════════════════════════════════════════════════
    _esc(s){ if(!s) return ''; const d=document.createElement('div'); d.textContent=s; return d.innerHTML; },

    _populateWarehouseFilter(){
        const sel = document.getElementById('lgWh');
        if(!sel) return;
        const cur = sel.value || '';
        sel.innerHTML = '<option value="">Tous magasins</option>' +
            (this.whs||[]).map(w=>`<option value="${this._esc(w.id)}">${this._esc(w.name||('Magasin #'+w.id))}</option>`).join('');
        sel.value = cur;
    },

    async _refreshCategoryFilter(){
        const whSel = document.getElementById('lgWh');
        const catSel = document.getElementById('lgCat');
        if(!catSel) return;
        const wh = whSel?.value || '';
        let cats = [];
        if(wh){
            try { cats = await Api.warehouseCategories(wh); } catch(e){ cats = []; }
            if(!Array.isArray(cats)) cats = [];
            const realCats = [...new Set((this.prods||[]).filter(p=>this._inWh(p,wh)).map(p=>p.category).filter(Boolean))];
            cats = [...new Set([...cats, ...realCats])];
        } else {
            cats = [...new Set((this.prods||[]).map(p=>p.category).filter(Boolean))];
        }
        cats.sort((a,b)=>String(a).localeCompare(String(b),'fr'));
        const prev = catSel.value || '';
        catSel.innerHTML = '<option value="">Toutes catégories</option>' + cats.map(c=>`<option value="${this._esc(c)}">${this._esc(c)}</option>`).join('');
        catSel.value = cats.includes(prev) ? prev : '';
    },

    _inWh(p, wh){
        if(!wh) return true;
        // Règle métier v2.3.21 : le filtre magasin montre les produits réellement intégrés
        // dans le stock du magasin (ligne stock existante), pas seulement autorisés.
        if(window.StockCascade && typeof StockCascade.productHasStockRow === 'function'){
            return StockCascade.productHasStockRow(p, wh, this.stock || [], { includeZeroRows: true });
        }
        const t = String(wh);
        return (this.stock || []).some(row => String(row?.productId ?? '') === String(p?.id ?? '') && String(row?.warehouseId ?? '') === t);
    },

    _getImg(p){
        for(const f of ['imagePath','image','imageUrl','photo','picture']){
            if(p[f]){
                const path = p[f];
                if(path.startsWith('http')||path.startsWith('data:')) return path;
                if(typeof Api !== 'undefined' && typeof Api.imageUrl === 'function') return Api.imageUrl(path);
                return path.startsWith('uploads/') ? path : `uploads/${path}`;
            }
        }
        return '';
    },

    _getCode(p){
        const custom = document.getElementById('lgCustomCode')?.value?.trim();
        if(custom) return custom;
        if(p.barcode) return p.barcode;
        if(Array.isArray(p.eans) && p.eans.length) return String(p.eans[0] || '');
        return p.sku || p.name || '';
    },

    _getBcFmt(){ return document.getElementById('lgBarcodeFmt')?.value || 'CODE128'; },

    _barcodeWidthFor(code, isPreview=false){
        const len = String(code || '').length;
        if(len > 42) return isPreview ? 0.7 : 0.55;
        if(len > 32) return isPreview ? 0.9 : 0.75;
        if(len > 24) return isPreview ? 1.2 : 1.0;
        return isPreview ? 2.0 : 1.45;
    },

    _assessCodeFit(code, fmt){
        const c = String(code || '').trim();
        if(!c) return { level:'ok', msg:'' };
        if(fmt === 'CODE128'){
            if(c.length > 48) return { level:'block', msg:'Code très long : privilégier le QR code ou une étiquette personnalisée plus large.' };
            if(c.length > 32) return { level:'warn', msg:'Code long : les barres seront très serrées. Vérifiez la prévisualisation ou utilisez le QR code.' };
        }
        return { level:'ok', msg:'' };
    },

    _hasBlockingOutputIssue(){
        const fmt = this._getBcFmt();
        if(this.style === 'qr') return false;
        for(const [id] of this.selected){
            const p = this.prods.find(x=>String(x.id)===String(id));
            if(!p) continue;
            const fit = this._assessCodeFit(this._getCode(p), fmt);
            if(fit.level === 'block') return fit.msg;
        }
        return '';
    },

    _getOpts(){
        return {
            showName:  document.getElementById('lgShowName')?.checked ?? true,
            showSku:   document.getElementById('lgShowSku')?.checked ?? true,
            showCode:  document.getElementById('lgShowCode')?.checked ?? true,
            showImage: document.getElementById('lgShowImage')?.checked ?? true,
            showSize:  document.getElementById('lgShowSize')?.checked ?? false,
        };
    },

    _getFmt(){
        if(this.format === 'custom'){
            const wmm = parseFloat(document.getElementById('lgCWidth')?.value)||70;
            const hmm = parseFloat(document.getElementById('lgCHeight')?.value)||37;
            const cols = parseInt(document.getElementById('lgCCols')?.value)||3;
            let rows = parseInt(document.getElementById('lgCRows')?.value)||7;
            const gapmm = 2.5, marginmm = 8;
            const maxRows = Math.max(1, Math.floor((297 - 2*marginmm + gapmm) / (hmm + gapmm)));
            if(rows > maxRows) rows = maxRows;
            return { cols, rows, wmm, hmm, gapmm, marginmm, title:'Personnalisé' };
        }
        return this.formats[this.format] || this.formats.A4_21;
    },

    // Validation EAN13/UPC
    _validateCode(code, fmt){
        if(fmt==='EAN13'){
            if(!/^\d{12,13}$/.test(code)) return {ok:false, msg:`EAN13 : le code "${code}" doit contenir 12 ou 13 chiffres.`};
        }
        if(fmt==='UPC'){
            if(!/^\d{11,12}$/.test(code)) return {ok:false, msg:`UPC : le code "${code}" doit contenir 11 ou 12 chiffres.`};
        }
        return {ok:true};
    },

    // ══════════════════════════════════════════════════════════════
    // BINDINGS
    // ══════════════════════════════════════════════════════════════
    _bindTabs(){
        document.querySelectorAll('.lg-tab').forEach(btn => {
            btn.onclick = () => {
                document.querySelectorAll('.lg-tab').forEach(x=>x.classList.remove('active'));
                btn.classList.add('active');
                document.querySelectorAll('.lg-tabpane').forEach(p=>p.classList.remove('active'));
                const pane = document.getElementById('lgTab'+btn.dataset.tab.charAt(0).toUpperCase()+btn.dataset.tab.slice(1));
                if(pane) pane.classList.add('active');
            };
        });
    },

    _bindControls(){
        document.querySelectorAll('input[name="lgStyle"]').forEach(r => {
            r.onchange = () => { this.style = document.querySelector('input[name="lgStyle"]:checked')?.value || 'img_barcode'; this.renderPreview(); };
        });
        document.getElementById('lgBarcodeFmt')?.addEventListener('change', () => this._validateAndPreview());
        document.getElementById('lgCustomCode')?.addEventListener('input', () => this._validateAndPreview());
        document.getElementById('lgCustomQr')?.addEventListener('input', () => this.renderPreview());
        document.querySelectorAll('input[name="lgFormat"]').forEach(r => {
            r.onchange = () => {
                this.format = document.querySelector('input[name="lgFormat"]:checked')?.value || 'A4_24';
                const cw = document.getElementById('lgCustomFormat');
                if(cw) cw.style.display = this.format==='custom' ? 'block' : 'none';
                this.renderPreview();
            };
        });
        ['lgCWidth','lgCHeight','lgCCols','lgCRows'].forEach(id => {
            document.getElementById(id)?.addEventListener('input', () => { if(this.format==='custom') this.renderPreview(); });
        });
        ['lgShowName','lgShowSku','lgShowCode','lgShowImage','lgShowSize'].forEach(id => {
            document.getElementById(id)?.addEventListener('change', () => this.renderPreview());
        });
        document.getElementById('lgSearch')?.addEventListener('input', () => this.renderProductGrid());
        document.getElementById('lgWh')?.addEventListener('change', async () => { await this._refreshCategoryFilter(); this.renderProductGrid(); });
        document.getElementById('lgCat')?.addEventListener('change', () => this.renderProductGrid());
    },

    _bindActions(){
        document.getElementById('btnLgClear')?.addEventListener('click', () => this.clearSelection());
        document.getElementById('btnLgPdf')?.addEventListener('click', () => this.exportPDF());
        document.getElementById('btnLgPrint')?.addEventListener('click', () => this.doPrint());
        document.getElementById('btnLgSelAll')?.addEventListener('click', () => this.selectAllVisible());
        document.getElementById('btnLgDeselAll')?.addEventListener('click', () => this.clearSelection());
    },

    _validateAndPreview(){
        const hint = document.getElementById('lgBarcodeHint');
        const fmt = this._getBcFmt();
        const custom = document.getElementById('lgCustomCode')?.value?.trim() || '';
        let msg = '';
        if(custom && fmt !== 'CODE128'){
            const v = this._validateCode(custom, fmt);
            if(!v.ok) msg = v.msg;
        }
        if(!msg && custom){
            const fit = this._assessCodeFit(custom, fmt);
            if(fit.level !== 'ok') msg = fit.msg;
        }
        if(hint){ hint.textContent = msg; hint.style.display = msg ? 'block' : 'none'; }
        this.renderPreview();
    },

    // ══════════════════════════════════════════════════════════════
    // SÉLECTION PRODUITS
    // ══════════════════════════════════════════════════════════════
    _updateSelBadge(){
        const badge = document.getElementById('lgSelBadge');
        const desel = document.getElementById('btnLgDeselAll');
        const total = [...this.selected.values()].reduce((s,q)=>s+q, 0);
        if(badge){
            badge.style.display = total > 0 ? 'inline-flex' : 'none';
            badge.textContent = total + ' étiquette(s)';
        }
        if(desel) desel.style.display = this.selected.size > 0 ? 'inline-flex' : 'none';
    },

    clearSelection(){
        this.selected.clear();
        this.renderProductGrid();
        this.renderPreview();
        this._updateSelBadge();
        toast('Sélection vidée', 'info');
    },

    toggleProduct(id){
        if(this.selected.has(id)) this.selected.delete(id);
        else this.selected.set(id, 1);
        this.renderProductGrid();
        this.renderPreview();
        this._updateSelBadge();
    },

    setQty(id, qty){
        qty = Math.max(0, Math.min(100, parseInt(qty)||0));
        if(qty === 0) this.selected.delete(id);
        else this.selected.set(id, qty);
        this.renderProductGrid();
        this.renderPreview();
        this._updateSelBadge();
    },

    _previewPage: 1,
    _prevPreviewPage(){ if(this._previewPage > 1){ this._previewPage--; this.renderPreview(); } },
    _nextPreviewPage(max){ if(this._previewPage < max){ this._previewPage++; this.renderPreview(); } },

    selectAllVisible(){
        const grid = document.getElementById('lgProdGrid');
        if(!grid) return;
        grid.querySelectorAll('[data-pid]').forEach(el => {
            const pid = parseInt(el.dataset.pid);
            if(pid && !this.selected.has(pid)) this.selected.set(pid, 1);
        });
        this.renderProductGrid();
        this.renderPreview();
        this._updateSelBadge();
    },

    // ══════════════════════════════════════════════════════════════
    // GRILLE PRODUITS
    // ══════════════════════════════════════════════════════════════
    _getFilteredProds(){
        const search = (document.getElementById('lgSearch')?.value||'').toLowerCase();
        const wh = document.getElementById('lgWh')?.value||'';
        const cat = document.getElementById('lgCat')?.value||'';
        return this.prods.filter(p => {
            if(wh && !this._inWh(p,wh)) return false;
            if(cat && p.category !== cat) return false;
            if(search){
                const hay = `${p.name||''} ${p.sku||''} ${p.barcode||''} ${(p.eans||[]).join(' ')}`.toLowerCase();
                if(!hay.includes(search)) return false;
            }
            return true;
        });
    },

    renderProductGrid(){
        const grid = document.getElementById('lgProdGrid');
        if(!grid) return;
        const filtered = this._getFilteredProds();

        // Group children by parentId for visual grouping
        const parentIds = new Set(filtered.filter(p=>!p.parentId && p.sizeType && p.sizeType!=='none').map(p=>p.id));

        grid.innerHTML = filtered.map(p => {
            const sel = this.selected.has(p.id);
            const qty = this.selected.get(p.id) || 0;
            const img = this._getImg(p);
            const isChild = !!p.parentId;
            const sizeTag = p.size ? `<span class="lg-prod-size">${this._esc(p.size)}</span>` : '';
            const wh = document.getElementById('lgWh')?.value || '';
            const stockRow = wh ? (this.stock||[]).find(r => String(r.productId)===String(p.id) && String(r.warehouseId)===String(wh)) : null;
            const stockHtml = wh && stockRow ? `<div class="lg-prod-stock">Stock magasin : ${Number(stockRow.quantity||0)}</div>` : '';

            return `<div class="lg-prod-card ${sel?'selected':''} ${isChild?'lg-prod-child':''}" data-pid="${p.id}"
                     onclick="BcPage.toggleProduct(${p.id})"
                     tabindex="0" role="checkbox" aria-checked="${sel}"
                     onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();BcPage.toggleProduct(${p.id})}">
                    <div class="lg-prod-check">${sel?'✓':''}</div>
                    <div class="lg-prod-img">${img?`<img src="${img}" onerror="this.parentElement.innerHTML='📦'" alt="">`:'📦'}</div>
                    <div class="lg-prod-info">
                        <div class="lg-prod-name">${this._esc(p.name)}${sizeTag}</div>
                        <div class="lg-prod-sku">${this._esc(p.sku||p.barcode||'')}</div>
                        ${stockHtml}
                    </div>
                    ${sel ? `<div class="lg-prod-qty" onclick="event.stopPropagation()">
                        <button class="lg-qty-btn" onclick="BcPage.setQty(${p.id},${qty-1})">−</button>
                        <input type="number" class="lg-qty-inp" value="${qty}" min="1" max="100"
                               onchange="BcPage.setQty(${p.id},this.value)" onclick="event.stopPropagation()">
                        <button class="lg-qty-btn" onclick="BcPage.setQty(${p.id},${qty+1})">+</button>
                    </div>` : ''}
                </div>`;
        }).join('');
        this._updateSelBadge();
    },

    // ══════════════════════════════════════════════════════════════
    // PREVIEW — Fidèle au format choisi
    // ══════════════════════════════════════════════════════════════
    renderPreview(){
        const sample = document.getElementById('lgSample');
        const sub = document.getElementById('lgPreviewSub');
        const meta = document.getElementById('lgPreviewMeta');
        if(!sample) return;

        const fmt = this._getFmt();
        const perPage = fmt.cols * fmt.rows;
        const totalLabels = [...this.selected.values()].reduce((s,q)=>s+q, 0);

        sample.classList.toggle('has-items', totalLabels > 0);
        if(sub) sub.textContent = totalLabels > 0 ? `${totalLabels} étiquette(s) · ${this.selected.size} produit(s)` : 'Aucune sélection';
        if(meta) meta.textContent = `${fmt.title} · ${perPage}/page`;

        if(totalLabels === 0){
            sample.innerHTML = '<div class="lg-empty">Sélectionnez des produits ci-dessous</div>';
            return;
        }

        // Expand selected products by their quantity
        const labels = [];
        for(const [id, qty] of this.selected){
            const p = this.prods.find(x=>x.id===id);
            if(p) for(let i=0;i<qty;i++) labels.push(p);
        }

        // Preview ratio: scale to fit
        const ratio = fmt.wmm / fmt.hmm;
        const previewW = Math.min(300, Math.max(160, Math.round(640 / fmt.cols)));
        const previewH = Math.round(previewW / ratio);

        // Paginate preview — one sheet at a time
        const totalPages = Math.max(1, Math.ceil(labels.length / perPage));
        if(!this._previewPage || this._previewPage > totalPages) this._previewPage = 1;
        const curPage = this._previewPage;
        const pageLabels = labels.slice((curPage-1)*perPage, curPage*perPage);

        const paginationHtml = totalPages > 1 ? `
            <div class="lg-pagination">
                <button class="lg-pg-btn" onclick="BcPage._prevPreviewPage()" ${curPage<=1?'disabled':''}>◀</button>
                <span class="lg-pg-info">Page ${curPage} / ${totalPages}</span>
                <button class="lg-pg-btn" onclick="BcPage._nextPreviewPage(${totalPages})" ${curPage>=totalPages?'disabled':''}>▶</button>
                <span class="lg-pg-total">${labels.length} étiquette(s)</span>
            </div>` : '';

        sample.innerHTML = `
            <div class="lg-sample-grid" style="--lg-cols:${fmt.cols};--lg-pw:${previewW}px;--lg-ph:${previewH}px">
                ${pageLabels.map((p,i) => this._renderLabel(p, `prevBC${i}`, 'preview')).join('')}
            </div>
            ${paginationHtml}
        `;

        setTimeout(() => {
            pageLabels.forEach((p,i) => { if(p) this._generateBarcode(p, `prevBC${i}`, true); });
        }, 50);
    },

    // ══════════════════════════════════════════════════════════════
    // RENDU ÉTIQUETTE UNIFIÉ
    // ══════════════════════════════════════════════════════════════
    _renderLabel(p, canvasId, context){
        if(!p) return `<div class="lg-lbl lg-lbl-empty"></div>`;
        const opts = this._getOpts();
        const custom = document.getElementById('lgCustomCode')?.value?.trim()||'';
        const code = this._esc(custom);
        const showCustom = !!custom;
        const img = this._getImg(p);
        const style = this.style;
        const cls = context==='preview' ? 'lg-preview-label' : 'lg-sheet-label';
        const sizeHtml = (opts.showSize && p.size) ? `<div class="lg-label-size">${this._esc(p.size)}</div>` : '';

        if(style === 'barcode') return `<div class="${cls}"><div class="lg-label-center">
            ${opts.showName ? `<div class="lg-label-name">${this._esc(p.name||'')}</div>` : ''}
            <div class="lg-bc-wrap"><canvas id="${canvasId}"></canvas></div>
            ${opts.showSku ? `<div class="lg-label-sku">${this._esc(p.sku||p.barcode||'')}</div>` : ''}
            ${(opts.showCode&&showCustom) ? `<div class="lg-label-code">${code}</div>` : ''}
            ${sizeHtml}
        </div></div>`;

        if(style === 'qr'){
            const payload = this._esc(document.getElementById('lgCustomQr')?.value?.trim() || JSON.stringify({type:'product',id:p.id,name:p.name,sku:p.sku||p.barcode}));
            return `<div class="${cls}"><div class="lg-label-center">
                <div class="lg-qr-wrap" data-qr="${payload}" id="${canvasId}"></div>
                ${opts.showName ? `<div class="lg-label-name">${this._esc(p.name||'')}</div>` : ''}
                ${opts.showSku ? `<div class="lg-label-sku">${this._esc(p.sku||p.barcode||'')}</div>` : ''}
                ${sizeHtml}
            </div></div>`;
        }

        const imgHtml = (opts.showImage && img)
            ? (style==='full' ? `<img src="${this._esc(img)}" class="lg-label-img-full" onerror="this.style.display='none'">`
                               : `<img src="${this._esc(img)}" class="lg-label-img" onerror="this.style.display='none'">`)
            : '';

        if(style === 'full') return `<div class="${cls}"><div class="lg-label-full">
            ${imgHtml}
            <div class="lg-label-body">
                ${opts.showName ? `<div class="lg-label-name">${this._esc(p.name||'')}</div>` : ''}
                <div class="lg-bc-wrap"><canvas id="${canvasId}"></canvas></div>
                ${opts.showSku ? `<div class="lg-label-sku">${this._esc(p.sku||p.barcode||'')}</div>` : ''}
                ${(opts.showCode&&showCustom) ? `<div class="lg-label-code">${code}</div>` : ''}
                ${sizeHtml}
            </div>
        </div></div>`;

        // img_barcode (default)
        return `<div class="${cls}"><div class="lg-label-row">
            ${imgHtml}
            <div class="lg-label-body">
                ${opts.showName ? `<div class="lg-label-name">${this._esc(p.name||'')}</div>` : ''}
                <div class="lg-bc-wrap"><canvas id="${canvasId}"></canvas></div>
                ${opts.showSku ? `<div class="lg-label-sku">${this._esc(p.sku||p.barcode||'')}</div>` : ''}
                ${(opts.showCode&&showCustom) ? `<div class="lg-label-code">${code}</div>` : ''}
                ${sizeHtml}
            </div>
        </div></div>`;
    },

    _generateBarcode(p, elementId, isPreview=false){
        const code = this._getCode(p);
        const fmt = this._getBcFmt();

        if(this.style === 'qr'){
            const div = document.getElementById(elementId);
            if(div && typeof qrcode !== 'undefined'){
                const customQr = document.getElementById('lgCustomQr')?.value?.trim()||'';
                const txt = customQr || JSON.stringify({type:'product',id:p.id,name:p.name,sku:p.sku||p.barcode});
                try {
                    const qr = qrcode(0,'M'); qr.addData(txt); qr.make();
                    div.innerHTML = qr.createImgTag(isPreview?3:4);
                } catch(e){ div.innerHTML = '<span style="color:red;font-size:10px">Erreur QR</span>'; }
            }
        } else {
            const canvas = document.getElementById(elementId);
            if(canvas && typeof JsBarcode !== 'undefined' && code){
                // Validate before generating
                const v = this._validateCode(code, fmt);
                if(!v.ok){
                    const parent = canvas.parentElement;
                    if(parent) parent.innerHTML = `<div style="font-size:8px;text-align:center;padding:2px;color:var(--orange)">⚠ ${v.msg || 'Code invalide'}</div>`;
                    return;
                }
                try {
                    JsBarcode(canvas, code, { format:fmt, width:this._barcodeWidthFor(code, isPreview), height:isPreview?40:30, displayValue:false, margin:0 });
                } catch(e){
                    const parent = canvas.parentElement;
                    if(parent) parent.innerHTML = `<div style="font-size:8px;text-align:center;padding:2px;color:red">Code invalide pour ${fmt}</div>`;
                }
            }
        }
    },

    // ══════════════════════════════════════════════════════════════
    // IMPRESSION
    // ══════════════════════════════════════════════════════════════
    doPrint(){
        const total = [...this.selected.values()].reduce((s,q)=>s+q,0);
        if(total === 0){ toast('Sélectionnez au moins un produit','warning'); return; }
        const blocking = this._hasBlockingOutputIssue();
        if(blocking){ toast(blocking, 'warning', 5500); return; }
        this._openPrintWindow('print');
    },

    exportPDF(){
        const total = [...this.selected.values()].reduce((s,q)=>s+q,0);
        if(total === 0){ toast('Sélectionnez au moins un produit','warning'); return; }
        const blocking = this._hasBlockingOutputIssue();
        if(blocking){ toast(blocking, 'warning', 5500); return; }
        // PDF fidèle à la prévisualisation : génération navigateur à partir du même rendu
        // visuel (styles, QR, EAN/UPC via JsBarcode, images, options).
        return this._exportPdfViaCanvas().catch(err => {
            console.error('PDF canvas error:', err);
            toast('Export PDF échoué: '+err.message,'error');
            return false;
        });
    },

    async _exportPdfServer(){
        toast('Génération PDF…','info',2000);
        const products = [];
        for(const [id, qty] of this.selected){
            const p = this.prods.find(x=>x.id===id);
            if(p) products.push({
                id: p.id, name: p.name||'', sku: p.sku||'',
                barcode: p.barcode||p.sku||'', image: this._getImg(p)||'',
                size: p.size||'', qty
            });
        }
        const fmt = this._getFmt();
        const payload = {
            products, style: this.style, format: fmt,
            bcFormat: this._getBcFmt(), options: this._getOpts(),
            customCode: document.getElementById('lgCustomCode')?.value?.trim()||'',
        };
        const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.CSRF_TOKEN || '';
        const res = await fetch(Api.url('api/barcode_pdf.php'), {
            method: 'POST',
            headers: { 'Content-Type':'application/json', 'X-Requested-With':'XMLHttpRequest', 'X-CSRF-TOKEN':csrfToken },
            credentials: 'same-origin',
            body: JSON.stringify(payload)
        });
        if(!res.ok){
            let errMsg = 'Erreur serveur';
            try { const j = await res.json(); errMsg = j.error||errMsg; } catch(e){}
            throw new Error(errMsg);
        }
        const blob = await res.blob();
        if(blob.type && blob.type.includes('json')){
            const txt = await blob.text();
            try { throw new Error(JSON.parse(txt).error||'Erreur'); } catch(e){ throw e; }
        }
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a'); a.href = url;
        a.download = `etiquettes_${new Date().toISOString().slice(0,10)}.pdf`;
        document.body.appendChild(a); a.click(); a.remove();
        setTimeout(()=>URL.revokeObjectURL(url), 2000);
        toast('PDF téléchargé ✓','success');
    },

    // Build expanded label list (product × quantity)
    _buildLabelList(){
        const labels = [];
        for(const [id, qty] of this.selected){
            const p = this.prods.find(x=>x.id===id);
            if(p) for(let i=0;i<qty;i++) labels.push(p);
        }
        return labels;
    },

    // ══════════════════════════════════════════════════════════════
    // PRINT WINDOW — Same structure as before but with qty support
    // ══════════════════════════════════════════════════════════════
    _openPrintWindow(mode){
        const fmt = this._getFmt();
        const opts = this._getOpts();
        const barcodeFormat = this._getBcFmt();
        const customCode = document.getElementById('lgCustomCode')?.value?.trim()||'';
        const customQr = document.getElementById('lgCustomQr')?.value?.trim()||'';
        const labels = this._buildLabelList();
        const perPage = fmt.cols * fmt.rows;
        const pages = [];
        for(let i=0; i<labels.length; i+=perPage){
            const pg = labels.slice(i, i+perPage);
            while(pg.length < perPage) pg.push(null);
            pages.push(pg);
        }
        const printWin = window.open('','_blank','width=1000,height=750');
        if(!printWin){ toast('Popup bloquée — autorisez les popups.','error'); return; }
        const html = this._generatePrintHTML(pages, fmt, opts, barcodeFormat, customCode, customQr, mode);
        printWin.document.write(html);
        printWin.document.close();
    },

    _generatePrintHTML(pages, fmt, opts, barcodeFormat, customCode, customQr, mode){
        const style = this.style;
        const wmm = fmt.wmm, hmm = fmt.hmm, gapmm = fmt.gapmm, cols = fmt.cols;
        const baseHref = (() => {
            try{ const u=new URL(window.location.href); const parts=u.pathname.split('/').filter(Boolean); parts.pop(); return `${u.origin}/${parts.join('/')}/`; }
            catch(e){ return '/'; }
        })();
        const esc = s => (s||'').replace(/[&<>"']/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m]));
        const pagesData = JSON.stringify(pages.map(pg=>pg.map(p=>p?{id:p.id,name:p.name||'',sku:p.sku||'',barcode:p.barcode||'',image:this._getImg(p)||'',size:p.size||''}:null)));

        const cutMarks = '<span class="cut cut-tl-h"></span><span class="cut cut-tl-v"></span><span class="cut cut-tr-h"></span><span class="cut cut-tr-v"></span><span class="cut cut-bl-h"></span><span class="cut cut-bl-v"></span><span class="cut cut-br-h"></span><span class="cut cut-br-v"></span>';

        const renderLabel = (p, cid) => {
            if(!p) return `<div class="lbl lbl-empty">${cutMarks}</div>`;
            const code = esc(customCode||'');
            const showCustom = !!(customCode && customCode.trim());
            const imgHtml = opts.showImage && p.image ? `<img class="lbl-img" src="${esc(p.image)}" onerror="this.style.display='none'" alt="">` : '';
            const sizeHtml = opts.showSize && p.size ? `<div class="lbl-size">${esc(p.size)}</div>` : '';

            if(style==='barcode') return `<div class="lbl">${cutMarks}<div class="lbl-center">${opts.showName?`<div class="lbl-name">${esc(p.name)}</div>`:''}<div class="lbl-bwrap"><canvas id="${cid}"></canvas></div>${opts.showSku?`<div class="lbl-sku">${esc(p.sku||p.barcode||'')}</div>`:''}${(opts.showCode&&showCustom)?`<div class="lbl-code">${code}</div>`:''}${sizeHtml}</div></div>`;
            if(style==='qr') return `<div class="lbl">${cutMarks}<div class="lbl-center"><div class="lbl-qrwrap" id="${cid}"></div>${opts.showName?`<div class="lbl-name">${esc(p.name)}</div>`:''}${opts.showSku?`<div class="lbl-sku">${esc(p.sku||p.barcode||'')}</div>`:''}${sizeHtml}</div></div>`;
            if(style==='full') return `<div class="lbl">${cutMarks}<div class="lbl-full">${imgHtml}<div class="lbl-body">${opts.showName?`<div class="lbl-name">${esc(p.name)}</div>`:''}<div class="lbl-bwrap"><canvas id="${cid}"></canvas></div>${opts.showSku?`<div class="lbl-sku">${esc(p.sku||p.barcode||'')}</div>`:''}${(opts.showCode&&showCustom)?`<div class="lbl-code">${code}</div>`:''}${sizeHtml}</div></div></div>`;
            return `<div class="lbl">${cutMarks}<div class="lbl-row">${imgHtml}<div class="lbl-body">${opts.showName?`<div class="lbl-name">${esc(p.name)}</div>`:''}<div class="lbl-bwrap"><canvas id="${cid}"></canvas></div>${opts.showSku?`<div class="lbl-sku">${esc(p.sku||p.barcode||'')}</div>`:''}${(opts.showCode&&showCustom)?`<div class="lbl-code">${code}</div>`:''}${sizeHtml}</div></div></div>`;
        };

        let body = '';
        pages.forEach((pg,pi) => {
            body += `<div class="sheet" style="--cols:${cols};--wmm:${wmm}mm;--hmm:${hmm}mm;--gapmm:${gapmm}mm;--margin:${fmt.marginmm||8}mm">`;
            pg.forEach((p,li) => { body += renderLabel(p, `bc_${pi}_${li}`); });
            body += '</div>';
        });

        const CSS = `
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{background:#e8eaed;font-family:Arial,Helvetica,sans-serif;color:#111}

/* ── HEADER IMPRESSION (écran seulement) ── */
.print-header{background:#fff;border-bottom:2px solid #e2e8f0;padding:16px 24px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:10}
.print-header h1{font-size:16px;font-weight:800;color:#1e293b}
.print-header .info{font-size:13px;color:#64748b}
.print-header .btn-print{padding:10px 24px;background:#2563eb;color:#fff;border:none;border-radius:10px;font-weight:700;font-size:14px;cursor:pointer}
.print-header .btn-print:hover{background:#1d4ed8}

/* ── PLANCHE (page A4) — Taille fixe pour ne jamais déborder ── */
.sheet{
  display:grid;
  grid-template-columns:repeat(var(--cols),var(--wmm));
  grid-auto-rows:var(--hmm);
  gap:var(--gapmm);
  width:210mm;
  height:297mm;
  box-sizing:border-box;
  padding:var(--margin,8mm);
  justify-content:center;
  align-content:start;
  background:#fff;
  margin:24px auto;
  box-shadow:0 2px 20px rgba(0,0,0,.12);
  border-radius:6px;
  position:relative;
  overflow:hidden;
}

/* ── ÉTIQUETTE ── */
.lbl{
  position:relative;
  width:var(--wmm);height:var(--hmm);
  padding:2mm;overflow:hidden;
  background:#fff;
  border:0.95pt dashed #000;
  box-sizing:border-box;
}
.lbl-empty{border-color:#000;background:transparent}

/* ── REPÈRES DE DÉCOUPE ── */
.cut{position:absolute;pointer-events:none;z-index:2}
.cut-tl-h{top:-0.5pt;left:-3.5mm;width:3mm;height:0;border-top:1.15pt solid #000}
.cut-tl-v{top:-3.5mm;left:-0.5pt;width:0;height:3mm;border-left:1.15pt solid #000}
.cut-tr-h{top:-0.5pt;right:-3.5mm;width:3mm;height:0;border-top:1.15pt solid #000}
.cut-tr-v{top:-3.5mm;right:-0.5pt;width:0;height:3mm;border-left:1.15pt solid #000}
.cut-bl-h{bottom:-0.5pt;left:-3.5mm;width:3mm;height:0;border-bottom:1.15pt solid #000}
.cut-bl-v{bottom:-3.5mm;left:-0.5pt;width:0;height:3mm;border-left:1.15pt solid #000}
.cut-br-h{bottom:-0.5pt;right:-3.5mm;width:3mm;height:0;border-bottom:1.15pt solid #000}
.cut-br-v{bottom:-3.5mm;right:-0.5pt;width:0;height:3mm;border-left:1.15pt solid #000}

/* ── LAYOUTS INTERNES ── */
.lbl-center{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:1mm;text-align:center}
.lbl-row{display:flex;align-items:center;gap:2mm;height:100%}
.lbl-full{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:1mm}

/* ── ÉLÉMENTS ── */
.lbl-img{width:14mm;height:14mm;flex:0 0 14mm;object-fit:contain;border:0.3pt solid #ddd;border-radius:1mm}
.lbl-body{flex:1;min-width:0;display:flex;flex-direction:column;justify-content:center;gap:0.5mm}
.lbl-name{font-size:7.5pt;font-weight:700;line-height:1.25;color:#111;overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}
.lbl-bwrap{width:100%;text-align:center}
.lbl-bwrap canvas{width:100%!important;height:auto!important;max-height:10mm!important;display:block!important}
.lbl-sku{font-family:'Courier New',monospace;font-size:6.5pt;color:#333;text-align:center;letter-spacing:0.3pt}
.lbl-code{font-family:'Courier New',monospace;font-size:5.5pt;color:#333;text-align:center;word-break:break-all;line-height:1.05;max-height:4mm;overflow:hidden}
.lbl-size{font-size:7pt;font-weight:700;color:#2563eb;text-align:center;margin-top:0.5mm}
.lbl-qrwrap img{width:15mm!important;height:15mm!important;display:block;margin:0 auto}

/* ── IMPRESSION ── */
@media print{
  @page{size:A4 portrait;margin:0}
  html,body{background:#fff!important;margin:0!important;padding:0!important}
  .print-header{display:none!important}
  .sheet{
    display:grid!important;
    width:210mm!important;height:297mm!important;
    margin:0!important;
    padding:var(--margin,8mm)!important;
    box-shadow:none!important;
    border-radius:0!important;
    overflow:hidden!important;
    page-break-after:always!important;
    page-break-inside:avoid!important;
    /* Scale down ~93% to fit within any printer hardware margins (3-5mm each side) */
    transform:scale(0.93)!important;
    transform-origin:center center!important;
  }
  .sheet:last-child{page-break-after:auto!important}
  .lbl{
    overflow:hidden!important;
    border:0.95pt dashed #000!important;
    -webkit-print-color-adjust:exact!important;
    print-color-adjust:exact!important;
  }
  .lbl-empty{border-color:#000!important}
  .cut{display:block!important}
}`;

        // Build header for screen view
        const headerHtml = `<div class="print-header">
            <div><h1>🏷️ Impression étiquettes — MageStock</h1><span class="info">${pages.length} page(s) · ${fmt.title} · ${fmt.cols}×${fmt.rows}</span></div>
            <button class="btn-print" onclick="window.print()">🖨️ Lancer l'impression</button>
        </div>`;

        const JS = `(function(){var STYLE=${JSON.stringify(style)};var FMT=${JSON.stringify(barcodeFormat)};var CC=${JSON.stringify(customCode||'')};var CQ=${JSON.stringify(customQr||'')};var PGS=${pagesData};function code(p){return CC||p.barcode||p.sku||p.name||'';}function bar(p,id){var c=document.getElementById(id);if(!c)return;var v=code(p);if(!v)return;try{var bw=v.length>42?0.55:(v.length>32?0.75:(v.length>24?1.0:1.45));JsBarcode(c,v,{format:FMT,width:bw,height:40,displayValue:false,margin:0});}catch(e){var w=c.parentElement;if(w)w.innerHTML='<div style="font-size:7pt;text-align:center;padding:1mm">'+v+'</div>';}}function qr(p,id){var el=document.getElementById(id);if(!el)return;try{var txt=CQ||JSON.stringify({type:'product',id:p.id,name:p.name,sku:p.sku||p.barcode});var q=qrcode(0,'M');q.addData(txt);q.make();el.innerHTML=q.createImgTag(3);}catch(e){el.innerHTML='<span style="color:red;font-size:7pt">Erreur QR</span>';}}PGS.forEach(function(pg,pi){pg.forEach(function(p,li){if(!p)return;var id='bc_'+pi+'_'+li;if(STYLE==='qr')qr(p,id);else bar(p,id);});});})();`;

        return `<!doctype html><html lang="fr"><head><meta charset="utf-8"><base href="${baseHref}"><title>${mode==='pdf'?'Export PDF':'Impression étiquettes'} — MageStock</title><style>${CSS}</style><script src="libs/jsbarcode.all.min.js"><\/script><script src="libs/qrcode.js"><\/script></head><body>${headerHtml}${body}<script>${JS}<\/script></body></html>`;
    },

    // ══════════════════════════════════════════════════════════════
    // PDF EXPORT — canvas → JPEG → PDF (same as before, with qty)
    // ══════════════════════════════════════════════════════════════
    async _exportPdfViaCanvas(){
        const fmt = this._getFmt();
        const opts = this._getOpts();
        const barcodeFormat = this._getBcFmt();
        const customCode = document.getElementById('lgCustomCode')?.value?.trim()||'';
        const customQr = document.getElementById('lgCustomQr')?.value?.trim()||'';
        const labels = this._buildLabelList();
        const perPage = fmt.cols * fmt.rows;
        const pages = [];
        for(let i=0;i<labels.length;i+=perPage){
            const pg = labels.slice(i,i+perPage);
            while(pg.length<perPage) pg.push(null);
            pages.push(pg);
        }
        toast('Génération PDF…','info');
        const DPI=300, PX_MM=DPI/25.4;
        const PAGE_W=Math.round(210*PX_MM), PAGE_H=Math.round(297*PX_MM);
        const MARGIN=Math.round(8*PX_MM), GAP=Math.round(fmt.gapmm*PX_MM);
        const LBL_W=Math.round(fmt.wmm*PX_MM), LBL_H=Math.round(fmt.hmm*PX_MM);
        const PAD=Math.round(2*PX_MM), CUT=Math.round(3*PX_MM);

        const loadImg = src => new Promise(res => {
            if(!src){res(null);return;} const img=new Image(); img.crossOrigin='anonymous';
            img.onload=()=>res(img); img.onerror=()=>res(null);
            img.src = src.startsWith('http')||src.startsWith('data:') ? src : (window.location.href.replace(/\/[^/]*$/,'/')+src);
        });
        const setFont = (ctx,pt,bold,mono) => { ctx.font=`${bold?'700':'400'} ${Math.round(pt*DPI/72)}px ${mono?"'Courier New',monospace":"Arial,sans-serif"}`; };
        const clamp = (ctx,t,w) => { let s=String(t||''); if(ctx.measureText(s).width<=w)return s; while(s.length>1&&ctx.measureText(s+'…').width>w)s=s.slice(0,-1); return s+'…'; };

        const canvasPages = [];
        for(const pg of pages){
            const cv=document.createElement('canvas'); cv.width=PAGE_W; cv.height=PAGE_H;
            const ctx=cv.getContext('2d',{alpha:false}); ctx.fillStyle='#fff'; ctx.fillRect(0,0,PAGE_W,PAGE_H);
            const GRID_W=fmt.cols*LBL_W+(fmt.cols-1)*GAP;
            const SX=Math.round((PAGE_W-GRID_W)/2), SY=MARGIN;

            for(let i=0;i<pg.length;i++){
                const p=pg[i]; const col=i%fmt.cols, row=Math.floor(i/fmt.cols);
                const x=SX+col*(LBL_W+GAP), y=SY+row*(LBL_H+GAP);
                // Cut marks
                ctx.strokeStyle='#000'; ctx.lineWidth=3.2;
                [[x-CUT,y,x,y],[x,y-CUT,x,y],[x+LBL_W,y,x+LBL_W+CUT,y],[x+LBL_W,y-CUT,x+LBL_W,y],
                 [x-CUT,y+LBL_H,x,y+LBL_H],[x,y+LBL_H,x,y+LBL_H+CUT],[x+LBL_W,y+LBL_H,x+LBL_W+CUT,y+LBL_H],[x+LBL_W,y+LBL_H,x+LBL_W,y+LBL_H+CUT]
                ].forEach(([x1,y1,x2,y2])=>{ ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke(); });
                if(!p) continue;
                const code=customCode||p.barcode||p.sku||p.name||'';
                const imgEl=opts.showImage?await loadImg(this._getImg(p)):null;
                const IX=x+PAD, IY=y+PAD, IW=LBL_W-PAD*2, IH=LBL_H-PAD*2;

                if(this.style==='barcode'){
                    let ty=IY; const bcH=Math.min(IH*.45,Math.round(10*PX_MM));
                    const nH=opts.showName?Math.round(2.8*PX_MM):0, sH=opts.showSku?Math.round(2.2*PX_MM):0;
                    ty=IY+Math.round((IH-nH-bcH-sH)/2);
                    if(opts.showName){setFont(ctx,7,true,false);ctx.fillStyle='#111';ctx.textAlign='center';ctx.fillText(clamp(ctx,p.name,IW),IX+IW/2,ty+nH);ty+=nH+Math.round(PX_MM);}
                    const bcCv=document.createElement('canvas');try{JsBarcode(bcCv,code,{format:barcodeFormat,width:this._barcodeWidthFor(code,false),height:60,displayValue:false,margin:0});ctx.drawImage(bcCv,IX,ty,IW,bcH);}catch(e){}
                    ty+=bcH+Math.round(PX_MM);
                    if(opts.showSku){setFont(ctx,6,false,true);ctx.fillStyle='#444';ctx.textAlign='center';ctx.fillText(clamp(ctx,p.sku||p.barcode||'',IW),IX+IW/2,ty+sH);}
                    ctx.textAlign='left';
                } else if(this.style==='qr'){
                    const qrSz=Math.min(IH*.55,Math.round(16*PX_MM));
                    const nH=opts.showName?Math.round(2.8*PX_MM):0,sH=opts.showSku?Math.round(2.2*PX_MM):0;
                    let ty=IY+Math.round((IH-qrSz-nH-sH)/2);
                    if(typeof qrcode!=='undefined'){try{const qrTxt=customQr||JSON.stringify({type:'product',id:p.id,name:p.name,sku:p.sku||p.barcode});const qr=qrcode(0,'M');qr.addData(qrTxt);qr.make();const qi=new Image();const qu='data:image/svg+xml;base64,'+btoa(qr.createSvgTag());await new Promise(r=>{qi.onload=qi.onerror=r;qi.src=qu;});if(qi.complete&&qi.naturalWidth)ctx.drawImage(qi,IX+Math.round((IW-qrSz)/2),ty,qrSz,qrSz);}catch(e){}}
                    ty+=qrSz+Math.round(PX_MM);ctx.textAlign='center';
                    if(opts.showName){setFont(ctx,7,true,false);ctx.fillStyle='#111';ctx.fillText(clamp(ctx,p.name,IW),IX+IW/2,ty+nH);ty+=nH+Math.round(PX_MM);}
                    if(opts.showSku){setFont(ctx,6,false,true);ctx.fillStyle='#444';ctx.fillText(clamp(ctx,p.sku||p.barcode||'',IW),IX+IW/2,ty+sH);}
                    ctx.textAlign='left';
                } else {
                    const IMG_W=(imgEl&&opts.showImage)?Math.min(Math.round(14*PX_MM),Math.round(IW*.28)):0;
                    const GAP_IMG=IMG_W?Math.round(2*PX_MM):0;
                    const BX=IX+IMG_W+GAP_IMG, BW=IW-IMG_W-GAP_IMG;
                    const nH=opts.showName?Math.round(2.8*PX_MM):0, bcH=Math.min(Math.round(IH*.38),Math.round(10*PX_MM)), sH=opts.showSku?Math.round(2.2*PX_MM):0;
                    let ty=IY+Math.round((IH-nH-Math.round(PX_MM)-bcH-Math.round(PX_MM)-sH)/2);
                    if(imgEl&&IMG_W){const iy=IY+Math.round((IH-IMG_W)/2);ctx.drawImage(imgEl,IX,iy,IMG_W,IMG_W);}
                    if(opts.showName){setFont(ctx,7,true,false);ctx.fillStyle='#111';ctx.textAlign='left';ctx.fillText(clamp(ctx,p.name,BW),BX,ty+nH);ty+=nH+Math.round(PX_MM);}
                    const bcCv=document.createElement('canvas');try{JsBarcode(bcCv,code,{format:barcodeFormat,width:this._barcodeWidthFor(code,false),height:60,displayValue:false,margin:0});ctx.drawImage(bcCv,BX,ty,BW,bcH);}catch(e){}
                    ty+=bcH+Math.round(PX_MM);
                    if(opts.showSku){setFont(ctx,6,false,true);ctx.fillStyle='#444';ctx.textAlign='center';ctx.fillText(clamp(ctx,p.sku||p.barcode||'',BW),BX+BW/2,ty+sH);}
                    ctx.textAlign='left';
                }
            }
            canvasPages.push(cv);
        }
        const pdfBytes=this._buildPdfFromCanvases(canvasPages);
        const blob=new Blob([pdfBytes],{type:'application/pdf'});
        const url=URL.createObjectURL(blob);
        const a=document.createElement('a'); a.href=url; a.download=`codes-barres_${new Date().toISOString().slice(0,10)}.pdf`;
        document.body.appendChild(a); a.click(); a.remove();
        setTimeout(()=>URL.revokeObjectURL(url),2000);
        toast('PDF téléchargé ✓','success');
    },

    _buildPdfFromCanvases(canvases){
        const enc=s=>new TextEncoder().encode(s);
        const cat=chunks=>{const len=chunks.reduce((a,c)=>a+c.length,0);const out=new Uint8Array(len);let o=0;for(const c of chunks){out.set(c,o);o+=c.length;}return out;};
        const PT_W=595.28,PT_H=841.89;let oid=0;const objs=[],offs=[];
        const add=data=>{oid++;objs.push({id:oid,data});return oid;};
        const pagesId=add(enc('<< /Type /Pages /Kids [] /Count 0 >>'));
        const catalogId=add(enc(`<< /Type /Catalog /Pages ${pagesId} 0 R >>`));
        const kids=[];
        for(let i=0;i<canvases.length;i++){
            const cv=canvases[i];const b64=cv.toDataURL('image/jpeg',0.92).split(',')[1]||'';
            const bin=atob(b64);const jpg=new Uint8Array(bin.length);for(let j=0;j<bin.length;j++)jpg[j]=bin.charCodeAt(j);
            const imgId=add(cat([enc(`<< /Type /XObject /Subtype /Image /Width ${cv.width} /Height ${cv.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpg.length} >>\nstream\n`),jpg,enc('\nendstream')]));
            const cs=`q\n${PT_W} 0 0 ${PT_H} 0 0 cm\n/Im${i} Do\nQ\n`;
            const csId=add(enc(`<< /Length ${cs.length} >>\nstream\n${cs}endstream`));
            const pgId=add(enc(`<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${PT_W} ${PT_H}] /Resources << /XObject << /Im${i} ${imgId} 0 R >> >> /Contents ${csId} 0 R >>`));
            kids.push(pgId);
        }
        objs[0].data=enc(`<< /Type /Pages /Kids [${kids.map(k=>k+' 0 R').join(' ')}] /Count ${kids.length} >>`);
        const hdr=enc('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n');const parts=[hdr];let offset=hdr.length;offs.push(0);
        for(const o of objs){offs.push(offset);const h=enc(`${o.id} 0 obj\n`);const t=enc('\nendobj\n');parts.push(h,o.data,t);offset+=h.length+o.data.length+t.length;}
        const xref=['xref\n',`0 ${objs.length+1}\n`,'0000000000 65535 f \n',...offs.slice(1).map(o=>String(o).padStart(10,'0')+' 00000 n \n')].join('');
        parts.push(enc(xref+`trailer\n<< /Size ${objs.length+1} /Root ${catalogId} 0 R >>\nstartxref\n${offset}\n%%EOF`));
        return cat(parts);
    },
};
