Files
dtf-system/web/site-pdf.js
Cauê Faleiros 64e47ee481
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m30s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 57s
feat: skeletons where the page waits, no flicker while a sheet is read, repeated copies in the preview
Reading a large sheet repainted the list and "Sua folha" on every progress
step, so the preview flickered; progress now moves the bar in place and
"Sua folha" shows a skeleton until the preview is ready. A sheet printed
N times is shown N times (up to six, then a count). The payment page shows
skeletons while the order loads and until the card form is ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:52:58 -03:00

580 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Site DTF — PDF measurement and rasterisation, and the DPI of images inside one.
Extracted verbatim from the single inline script in web/index.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
// ── PDF também é conferido: rasteriza a página e mede o DPI das imagens de dentro.
// Arte vetorial não tem resolução — nesses casos a nota é máxima, e isso é correto.
// Served from this origin, not a CDN: the Site keeps working when a third party
// does not, and the CSP can name only 'self' for scripts and workers. Provenance
// and hashes are recorded in web/vendor/README.md.
const PDFJS_URL='/vendor/pdf.min.js';
const PDFJS_WORKER=new URL('/vendor/pdf.worker.min.js', location.origin).href;
let pdfLibP=null, temWorker=null;
// Check whether this browser can create a same-origin Worker. The worker file
// is served alongside this script and does not need a blob URL.
function testaWorker(url){
try{ const w=new Worker(url); w.terminate(); return true; }catch(e){ return false; }
}
function carregarPdfJs(){
if(typeof window==='undefined') return Promise.resolve(null);
if(window.pdfjsLib) return Promise.resolve(window.pdfjsLib);
if(pdfLibP) return pdfLibP;
pdfLibP=new Promise(res=>{
const sc=document.createElement('script');
sc.src=PDFJS_URL;
sc.onload=()=>{
try{
temWorker=testaWorker(PDFJS_WORKER);
window.pdfjsLib.GlobalWorkerOptions.workerSrc=PDFJS_WORKER;
}catch(e){
temWorker=false;
try{ window.pdfjsLib.GlobalWorkerOptions.workerSrc=PDFJS_WORKER; }catch(e2){}
}
res(window.pdfjsLib||null);
};
sc.onerror=()=>res(null);
document.head.appendChild(sc);
});
return pdfLibP;
}
// DPI real de cada imagem embutida: acompanha a matriz de transformação e compara
// o tamanho em que a imagem foi colocada com quantos pixels ela tem.
function dpiDasImagens(page, ops){
const mul=(a,b)=>[a[0]*b[0]+a[2]*b[1], a[1]*b[0]+a[3]*b[1],
a[0]*b[2]+a[2]*b[3], a[1]*b[2]+a[3]*b[3],
a[0]*b[4]+a[2]*b[5]+a[4], a[1]*b[4]+a[3]*b[5]+a[5]];
const O=window.pdfjsLib.OPS;
let ctm=[1,0,0,1,0,0]; const pilha=[]; const dpis=[];
for(let i=0;i<ops.fnArray.length;i++){
const fn=ops.fnArray[i], ar=ops.argsArray[i];
if(fn===O.save) pilha.push(ctm.slice());
else if(fn===O.restore) ctm=pilha.pop()||[1,0,0,1,0,0];
else if(fn===O.transform) ctm=mul(ctm,ar);
else if(fn===O.paintImageXObject||fn===O.paintJpegXObject||fn===O.paintImageXObjectRepeat){
let im=null;
try{ im=page.objs.get(ar[0]); }catch(e){}
if(!im||!im.width) continue;
const larguraPt=Math.hypot(ctm[0],ctm[1]);
if(larguraPt<1) continue;
const areaPt=Math.abs(ctm[0]*ctm[3]-ctm[1]*ctm[2]);
dpis.push({dpi: im.width/(larguraPt*page.userUnit/72), area: areaPt});
}
}
return dpis;
}
// A régua de 96 DPI vale por arte, não pela folha inteira. Numa folha de 250 peças,
// um selo pequeno em 65 DPI não pode barrar o pedido: o que decide é quanto da
// área impressa está abaixo do limite.
function resumoDpi(lista){
if(!lista.length) return null;
const total=lista.reduce((t,x)=>t+x.area,0) || 1;
const abaixoPiso=lista.filter(x=>x.dpi<DPI_RECUSA);
const abaixoAviso=lista.filter(x=>x.dpi<DPI_AVISA);
const pond=lista.reduce((t,x)=>t+x.dpi*x.area,0)/total;
return {
pior: Math.round(Math.min(...lista.map(x=>x.dpi))),
ponderado: Math.round(pond),
fracBaixa: abaixoPiso.reduce((t,x)=>t+x.area,0)/total,
fracAviso: abaixoAviso.reduce((t,x)=>t+x.area,0)/total,
quantasBaixas: abaixoPiso.length,
quantasAviso: abaixoAviso.length,
total: lista.length
};
}
// Rasteriza a primeira página a 1 mm por pixel e devolve a tela para a análise
async function rasterizarPdf(file, larguraCm, alturaCm){
const lib=await carregarPdfJs();
if(!lib) return {erro:'não foi possível carregar o leitor de PDF'};
// Decide after the worker check; the old timeout always used 30 seconds.
const espera=temWorker===false ? 90000 : 30000;
const folga=temWorker===false ? 2.2 : 1;
let loading=null, doc=null, rendering=null, expired=false, timer=null;
const trabalho=(async()=>{
try{
const buf=await file.arrayBuffer();
if(expired) return null;
loading=lib.getDocument({data:buf, disableFontFace:true,
isEvalSupported:false, useSystemFonts:false, verbosity:0});
doc=await loading.promise;
if(expired || doc.numPages!==1) return null;
const page=await doc.getPage(1);
if(expired) return null;
const vp1=page.getViewport({scale:1});
const passo=passoDe(larguraCm, alturaCm||larguraCm)*folga;
const alvo=Math.max(8,Math.round(larguraCm/passo));
const vp=page.getViewport({scale:alvo/vp1.width});
const cv=document.createElement('canvas');
cv.width=Math.max(8,Math.round(vp.width)); cv.height=Math.max(8,Math.round(vp.height));
const ctx=cv.getContext('2d',{willReadFrequently:true});
rendering=page.render({canvasContext:ctx, viewport:vp, background:'rgba(0,0,0,0)'});
await rendering.promise;
if(expired) return null;
const ops=await page.getOperatorList();
if(expired) return null;
let dpis=[];
try{ dpis=dpiDasImagens(page,ops); }catch(e){}
const r=resumoDpi(dpis);
return {tela:cv, dpi:r? r.ponderado:null, res:r};
}catch(e){
if(expired) return null;
return {erro:(e&&e.message)||'erro ao abrir o PDF', semWorker:temWorker===false};
}finally{
if(doc) doc.destroy().catch(()=>{});
else if(loading) loading.destroy().catch(()=>{});
}
})();
const tardio=new Promise(resolve=>{
timer=setTimeout(()=>{
expired=true;
try{ if(rendering) rendering.cancel(); }catch(e){}
if(loading) loading.destroy().catch(()=>{});
resolve({erro:'demorou demais neste navegador',semWorker:temWorker===false});
},espera);
});
const result=await Promise.race([trabalho,tardio]);
clearTimeout(timer);
return result||{erro:'não deu para conferir',semWorker:temWorker===false};
}
// Sheets of several GB are the normal order. The browser cannot decode an image
// that size (it would freeze or crash the tab), and it does not need to: the
// grade comes from the width in pixels, which PNG, JPEG and WebP store in their
// first bytes. Above GRANDE_BYTES only those bytes are read.
const GRANDE_BYTES=150*1048576;
async function dimensoesImagem(file){
const b=new Uint8Array(await file.slice(0,Math.min(file.size,4*1048576)).arrayBuffer());
const u16=(i,le)=>le? b[i]|(b[i+1]<<8) : (b[i]<<8)|b[i+1];
const u32=(i)=>((b[i]<<24)>>>0)+(b[i+1]<<16)+(b[i+2]<<8)+b[i+3];
if(b[0]===0x89 && b[1]===0x50 && b[2]===0x4E && b[3]===0x47) // PNG · IHDR
return {w:u32(16), h:u32(20)};
if(b[0]===0xFF && b[1]===0xD8){ // JPEG · SOFn
let i=2;
while(i+9<b.length){
if(b[i]!==0xFF){ i++; continue; }
const m=b[i+1], len=u16(i+2);
if(m>=0xC0 && m<=0xCF && ![0xC4,0xC8,0xCC].includes(m)) return {w:u16(i+7), h:u16(i+5)};
i+=2+len;
}
return null;
}
const tag=String.fromCharCode(...b.slice(8,16));
if(String.fromCharCode(...b.slice(0,4))==='RIFF' && tag.startsWith('WEBP')){ // WebP
if(tag==='WEBPVP8X') return {w:1+(b[24]|(b[25]<<8)|(b[26]<<16)), h:1+(b[27]|(b[28]<<8)|(b[29]<<16))};
if(tag==='WEBPVP8L'){ const n=b[21]|(b[22]<<8)|(b[23]<<16)|(b[24]<<24); return {w:(n&0x3FFF)+1, h:((n>>14)&0x3FFF)+1}; }
if(tag==='WEBPVP8 ') return {w:u16(26,true)&0x3FFF, h:u16(28,true)&0x3FFF};
}
return null;
}
// A small copy of a large PNG, read as a stream: the file is inflated in
// pieces and only every n-th pixel of every n-th row is kept, so memory stays
// at a few MB whatever the file's size. It is the preview, and what the sheet
// check (pieces, gaps, background) reads. 8- and 16-bit greyscale, RGB,
// palette and alpha; interlaced files return null.
async function previaPngGrande(file, larguraAlvo, progresso){
const leitor=file.stream().getReader();
let pedacos=[], guardado=0, lidos=0, fim=false;
// A queue of the file's pieces: take() never copies more than it returns.
const enche=async n=>{
while(guardado<n && !fim){
const {done,value}=await leitor.read();
if(done){ fim=true; break; }
pedacos.push(value); guardado+=value.length; lidos+=value.length;
progresso && progresso(lidos/file.size);
}
return guardado>=n;
};
const tira=n=>{
const out=new Uint8Array(n); let k=0;
while(k<n){ const p=pedacos[0], m=Math.min(p.length,n-k);
out.set(p.subarray(0,m),k); k+=m;
if(m===p.length) pedacos.shift(); else pedacos[0]=p.subarray(m); }
guardado-=n; return out;
};
const u32=b=>((b[0]<<24)>>>0)+(b[1]<<16)+(b[2]<<8)+b[3];
if(!await enche(8)) return null; tira(8);
// 1 · header chunks, up to the first image data
let w=0,h=0,prof=0,tipo=-1,paleta=null,trns=null, idat=0;
for(;;){
if(!await enche(8)) return null;
const cab=tira(8), len=u32(cab), nome=String.fromCharCode(...cab.subarray(4,8));
if(nome==='IDAT'){ idat=len; break; }
if(!await enche(len+4)) return null;
const d=tira(len); tira(4);
if(nome==='IHDR'){ w=u32(d); h=u32(d.subarray(4)); prof=d[8]; tipo=d[9];
if(d[12]!==0 || ![8,16].includes(prof) || !{0:1,2:1,3:1,4:1,6:1}[tipo]) return null; }
else if(nome==='PLTE') paleta=d;
else if(nome==='tRNS') trns=d;
else if(nome==='IEND') return null;
}
if(!(w>0 && h>0) || (tipo===3 && (!paleta || prof!==8))) return null;
// 2 · image data streamed into the inflater, a piece at a time
const inflador=new DecompressionStream('deflate'), escritor=inflador.writable.getWriter();
const alimenta=(async()=>{
let resta=idat;
for(;;){
while(resta>0){
if(!guardado && !await enche(1)) { await escritor.close(); return; }
const n=Math.min(resta,guardado); await escritor.write(tira(n)); resta-=n;
}
if(!await enche(12)) break;
tira(4); // CRC of the chunk just sent
const cab=tira(8), len=u32(cab), nome=String.fromCharCode(...cab.subarray(4,8));
if(nome==='IDAT'){ resta=len; continue; }
break; // anything after the image data
}
await escritor.close();
})();
alimenta.catch(()=>{});
// 3 · rows unfiltered one at a time; every passo-th pixel is kept
const canais={0:1,2:3,3:1,4:2,6:4}[tipo], b=prof/8, bpp=canais*b, linha=w*bpp;
const passo=Math.max(1,Math.ceil(w/larguraAlvo));
const cw=Math.ceil(w/passo), ch=Math.ceil(h/passo);
const cv=document.createElement('canvas'); cv.width=cw; cv.height=ch;
const ctx=cv.getContext('2d'), img=ctx.createImageData(cw,1), px=img.data;
// Each row is gathered whole (filter byte + data), then unfiltered in one
// tight loop for its filter type.
let ant=new Uint8Array(linha), atual=new Uint8Array(linha), y=0;
const bruta=new Uint8Array(linha+1); let cheio=0;
const desfiltra=()=>{
const f=bruta[0], d=bruta.subarray(1);
if(f===0) atual.set(d);
else if(f===1){ for(let i=0;i<bpp;i++) atual[i]=d[i]; for(let i=bpp;i<linha;i++) atual[i]=d[i]+atual[i-bpp]; }
else if(f===2){ for(let i=0;i<linha;i++) atual[i]=d[i]+ant[i]; }
else if(f===3){ for(let i=0;i<bpp;i++) atual[i]=d[i]+(ant[i]>>1);
for(let i=bpp;i<linha;i++) atual[i]=d[i]+((atual[i-bpp]+ant[i])>>1); }
else { for(let i=0;i<bpp;i++) atual[i]=d[i]+ant[i];
for(let i=bpp;i<linha;i++){ const a=atual[i-bpp], up=ant[i], c=ant[i-bpp], p=a+up-c;
const pa=p>a?p-a:a-p, pb=p>up?p-up:up-p, pc=p>c?p-c:c-p;
atual[i]=d[i]+(pa<=pb&&pa<=pc?a:pb<=pc?up:c); } }
};
const leitorInflado=inflador.readable.getReader();
for(;;){
const {done,value}=await leitorInflado.read(); if(done) break;
let j=0;
while(j<value.length){
const n=Math.min(linha+1-cheio, value.length-j);
bruta.set(value.subarray(j,j+n),cheio); cheio+=n; j+=n;
if(cheio<linha+1) break;
cheio=0;
// Every row is unfiltered: Up, Average and Paeth read the previous one.
desfiltra();
if(y%passo===0 && y/passo<ch){
for(let xx=0,k=0;xx<w;xx+=passo,k+=4){
const i=xx*bpp;
if(tipo===3){ const q=atual[i]; px[k]=paleta[q*3]; px[k+1]=paleta[q*3+1]; px[k+2]=paleta[q*3+2];
px[k+3]=trns&&q<trns.length?trns[q]:255; }
else if(tipo===0){ px[k]=px[k+1]=px[k+2]=atual[i]; px[k+3]=255; }
else if(tipo===4){ px[k]=px[k+1]=px[k+2]=atual[i]; px[k+3]=atual[i+b]; }
else { px[k]=atual[i]; px[k+1]=atual[i+b]; px[k+2]=atual[i+2*b]; px[k+3]=tipo===6?atual[i+3*b]:255; }
}
ctx.putImageData(img,0,y/passo);
}
const t=ant; ant=atual; atual=t; y++;
}
}
await alimenta.catch(()=>{});
leitor.cancel().catch(()=>{});
return y>=h ? cv : null;
}
// Large PDFs are read in ranges, never loaded whole into memory.
async function fontePdf(lib, file){
if(file.size<=GRANDE_BYTES) return {data:await file.arrayBuffer()};
const inicio=new Uint8Array(await file.slice(0,262144).arrayBuffer());
const t=new lib.PDFDataRangeTransport(file.size, inicio);
t.requestDataRange=(de,ate)=>{ file.slice(de,ate).arrayBuffer().then(buf=>t.onDataRange(de,new Uint8Array(buf))); };
return {range:t, rangeChunkSize:262144, disableAutoFetch:true, disableStream:true};
}
function medirFolha(file){
return new Promise(res=>{
const nome=(file.name||'').toLowerCase();
if(RENDERIZA.test(nome) && file.size>GRANDE_BYTES){
dimensoesImagem(file).then(d=>{
if(!d || !(d.w>0) || !(d.h>0)) return res(null);
const L=larguraFilme();
res({larg:L, alt:+(d.h/d.w*L).toFixed(1), fonte:'dimensões do arquivo',
dpiFolha:Math.round(d.w/(L/2.54)), px:d.w, grande:true});
}).catch(()=>res(null));
return;
}
if(RENDERIZA.test(nome)){
carregarImagem(file).then(img=>{
if(!img || !img.width) return res(null);
const L=larguraFilme();
// Uma imagem só é folha montada se tiver pixel para ocupar a largura do
// filme. Sem esta conferência, qualquer arte pequena vira "folha" e leva
// o preço por metro sem nunca ter sido montada.
res({larg:L, alt:+(img.height/img.width*L).toFixed(1), fonte:'proporção da imagem',
dpiFolha:Math.round(img.width/(L/2.54)), px:img.width});
});
return;
}
if(/\.pdf$/.test(nome)){
// PDF boxes can be inherited, compressed, rotated, and scaled by UserUnit.
// Searching raw bytes for /MediaBox can read the wrong object or miss it.
let doc=null;
carregarPdfJs().then(async lib=>{
if(!lib) throw new Error('Não foi possível carregar o leitor de PDF.');
doc=await lib.getDocument({...await fontePdf(lib,file),
disableFontFace:true, isEvalSupported:false, useSystemFonts:false,
verbosity:0}).promise;
if(doc.numPages!==1)
return {rejected:true, reason:'O PDF tem '+doc.numPages+
' páginas. Envie cada folha como um PDF de uma página para calcular o preço correto.'};
const page=await doc.getPage(1);
const view=page.view, unit=page.userUnit, vp=page.getViewport({scale:1});
if(!Array.isArray(view) || view.length!==4 ||
!Number.isFinite(unit) || unit<=0 ||
!Number.isFinite(vp.width) || !Number.isFinite(vp.height) ||
vp.width<=0 || vp.height<=0)
return {rejected:true, reason:'Não conseguimos determinar o tamanho desta página PDF. Exporte-a novamente.'};
const ptW=Math.abs(view[2]-view[0]), ptH=Math.abs(view[3]-view[1]);
if(!(ptW>0 && ptH>0) ||
(unit===1 && (ptW>PDF_MAX_PT || ptH>PDF_MAX_PT)))
return {rejected:true, reason:'A página passa do limite de 508 cm sem UserUnit. Divida a folha ou exporte em PNG.'};
// This pinned PDF.js build exposes the page's rotated view in points;
// userUnit is separate and must scale both physical dimensions.
const w=vp.width*unit*PT_CM, h=vp.height*unit*PT_CM;
if(!Number.isFinite(w) || !Number.isFinite(h) || w<=0 || h<=0 || h>6000)
return {rejected:true, reason:'O tamanho desta página PDF não é suportado. Divida a folha em partes menores.'};
// Keep precise geometry for the fit and quote; the UI rounds only
// when displaying dimensions.
return {larg:+w.toFixed(3), alt:+h.toFixed(3),
fonte:'página do PDF'+(unit!==1 ? ' · UserUnit '+unit : '')};
}).then(res).catch(()=>res({rejected:true,
reason:'Não conseguimos ler este PDF. Exporte-o novamente como PDF de uma página ou PNG.'}))
.finally(()=>{ if(doc) doc.destroy().catch(()=>{}); });
return;
}
res(null);
});
}
function textoProgresso(x){
if(x.med && x.med.grande) return 'gerando a prévia e conferindo a arte… '+x.pct+'%';
return x.pct<50?'lendo o arquivo…':x.pct<70?'medindo a folha…':'conferindo a arte…';
}
// Moves one sheet's progress bar in place: repainting the whole list and the
// preview on every step made "Sua folha" flicker while a large file was read.
function pintaProgressoFolha(x){
const el=$('lista').querySelector('.rep[data-folha="'+folhas.indexOf(x)+'"]');
const barra=el && el.querySelector('.prog i'), texto=el && el.querySelector('.progT');
if(!barra){ pintaFolha(); return; }
barra.style.width=x.pct+'%'; if(texto) texto.textContent=textoProgresso(x);
}
function pintaFolha(){
invalidaItemAtual();
const L=larguraFilme();
pintaTipoEnvio(); // trava o seletor enquanto houver folha
if(!folhas.length){ $('lista').innerHTML=''; agendaAvaliacao(); previaAoVivo(); return; }
$('lista').innerHTML = folhas.map((x,i)=>{
const lido=!!x.med, larga=lido && x.med.larg>L, suspeito=lido && x.med.foraDoPadrao;
const sub=(x.m||0)*(x.rep||1);
let medida='';
if(lido){
const a=x.an;
const conferido = a && a.grande
? '<div class="conf"><b>Arquivo grande · '+a.dpi+' DPI</b>'+
'<span>nota pela resolução · peças, resíduos e fundo conferidos pela equipe</span></div>'
: a
? '<div class="conf"><b>Conferido de verdade</b>'+
'<span>'+a.artes+' peça'+(a.artes===1?'':'s')+' · '+a.aproveitamento+
'% da folha virou arte · '+
(a.residuos? a.residuos+' resíduo'+(a.residuos>1?'s':'') : 'sem resíduo')+' · '+
(a.fundoChapado? 'fundo chapado' : 'fundo transparente')+'</span>'+
'<span>resolução '+a.dpi+' DPI · '+
(a.fonteDpi==='imagens'? 'medida imagem por imagem dentro do PDF'
: a.fonteDpi==='vetor'? 'arte vetorial, sem imagem embutida'
: 'lida do arquivo')+
' · grade de '+n1(a.grade)+' mm</span></div>'
: '';
medida='<div class="med'+(larga||suspeito?' alerta':'')+'">'+
'<b>'+n1(x.med.larg)+' × '+n1(x.med.alt)+' cm</b>'+
'<span>'+x.med.fonte+' · '+fmtM(x.m)+' m de folha</span>'+
conferido+
(suspeito? '<em><b>Este PDF passa do tamanho que o formato comporta.</b> '+
'A especificação para em 508 cm e o seu tem '+n1(x.med.alt)+' cm. '+
'Ele abre certo só em quem o gerou — aqui vai precisar de tratamento. '+
'Manda em <b>PNG ou TIFF</b>, ou divide em partes de até 5 m, que sai na hora.</em>':'')+
(larga? '<em><b>Esta folha não roda neste filme.</b> Ela tem '+n1(x.med.larg)+
' cm de largura e o filme aqui tem '+n1(L)+' cm. Não dá para reduzir: '+
'encolher a folha encolheria cada arte junto.'+
(x.med.larg<=57.5
? ' Ela cabe no <b>DTF têxtil de 57 cm</b> — troque de produto ali em cima.'
: ' Remonte a folha em '+n1(L)+' cm e suba de novo.')+'</em>':'')+
'</div>';
}else if(x.measurementError){
medida='<div class="med alerta"><b>Este PDF não pode ser orçado</b><span>'+
escapeHTML(x.measurementError)+'</span></div>';
}else{
const teto=TABELA[modo], piso=pisoEscada();
const dif=(x.m>0)? (teto-piso)*cobrar(x.m*(x.rep||1)) : 0;
medida='<div class="med pede">'+
'<b>'+escapeHTML(extDe(x.f.name))+' não abre no navegador</b>'+
'<span>Sem conseguir conferir o arquivo, não temos como dar nota — e sem nota '+
'o metro sai pela tabela, <b>'+rs(teto)+'</b>. Alguém vai abrir esse arquivo na mão '+
'antes de imprimir.</span>'+
'<div class="troca">Exportando a mesma folha em <b>PNG ou PDF</b>, a conferência é '+
'automática e o metro pode cair até <b>'+rs(piso)+'</b>'+
(dif>0.5? ' — <b>'+rs(dif)+' a menos</b> só nesta folha' : '')+'.</div>'+
'<span style="margin-top:9px">Informe o comprimento para seguir:</span>'+
'<input type="number" min="0.5" max="60" step="0.01" data-comp="'+i+'" '+
'placeholder="metros" value="'+(x.m? x.m.toFixed(2):'')+'">'+
'</div>';
}
const barra = x.pct!=null
? '<div class="prog"><i style="width:'+x.pct+'%"></i></div>'+
'<span class="progT">'+textoProgresso(x)+'</span>'
: x.semAnalise
? '<div class="progF"><b>Não conseguimos conferir esta folha.</b> '+escapeHTML(x.semAnalise)+
'. Seguimos com o preço de tabela — se quiser a nota e o desconto, exporte a mesma '+
'folha em <b>PNG</b>, que a conferência é bem mais leve.</div>'
: '';
return '<div class="rep" data-folha="'+i+'"><div class="l1"><b>'+
(folhas.length>1? (i+1)+'. ':'')+escapeHTML(x.f.name)+'</b>'+
'<span style="color:var(--fraco);font-size:10.5px">'+fmt(x.f.size)+'</span>'+
'<button data-rmf="'+i+'">×</button></div>'+
barra+medida+
(x.m>0 ?
'<div class="cps" style="margin-top:10px">'+
'<div><label>quantas vezes repetir</label>'+
'<input type="number" min="1" max="200" data-repf="'+i+'" value="'+x.rep+'"></div>'+
'<div class="conta"><b>'+fmtM(sub)+' m</b>'+
(x.rep>1? ' · '+x.rep+' × '+fmtM(x.m)+' m':'')+'</div>'+
'</div>' : '')+
'</div>';
}).join('');
// total do pedido, quando há mais de uma folha
const tot=folhaTotalM(), partes=Math.ceil(tot/20);
if(folhas.length>1 && tot>0){
$('lista').innerHTML += '<div class="somaF">'+
'<span>'+folhas.length+' folhas neste pedido</span>'+
'<b>'+fmtM(tot)+' m no total</b>'+
(partes>1? '<em>vai em '+partes+' partes de até 20 m</em>':'')+'</div>';
}else if(partes>1 && tot>0){
$('lista').innerHTML += '<div class="somaF"><span>folha longa</span>'+
'<b>'+fmtM(tot)+' m</b><em>vai em '+partes+' partes de até 20 m</em></div>';
}
$('lista').querySelectorAll('[data-rmf]').forEach(b=>b.addEventListener('click',()=>{
folhas.splice(+b.dataset.rmf,1); pintaFolha();
}));
$('lista').querySelectorAll('[data-repf]').forEach(el=>el.addEventListener('change',e=>{
folhas[+el.dataset.repf].rep=Math.max(1,Math.min(200,+e.target.value||1)); pintaFolha();
}));
$('lista').querySelectorAll('[data-comp]').forEach(el=>el.addEventListener('change',e=>{
folhas[+el.dataset.comp].m=Math.max(0,Math.min(60,+e.target.value||0)); pintaFolha();
}));
$('lista').querySelectorAll('[data-repf], [data-comp]').forEach(el=>
el.addEventListener('input',invalidaItemAtual));
agendaAvaliacao(); previaAoVivo();
}
// sugestão de largura
const cabemNaLinha=w=>Math.max(1,Math.floor((larguraFilme()+GAP_CM)/(w+GAP_CM)));
const larguraPara=n=>(larguraFilme()-(n-1)*GAP_CM)/n;
// Só sugere se a folha realmente encolher. Caber mais um por fileira não adianta
// nada se a quantidade pedida continuar ocupando o mesmo número de fileiras.
function sugestao(a){
const w=a.cm, q=Math.max(1,a.q||1), prop=propDe(a);
const n=cabemNaLinha(w), alvo=Math.floor(larguraPara(n+1)*10)/10;
if(!(alvo>0) || alvo>=w) return null;
if((w-alvo)/w>0.12) return null; // não corta mais que 12% da peça
const fa=Math.ceil(q/n), fd=Math.ceil(q/(n+1));
if(fd>=fa) return null; // mesmo número de fileiras: não muda nada
const economiaCm=fa*w*prop - fd*alvo*prop;
if(economiaCm<0.5) return null; // menos de meio centímetro não é notícia
return {alvo, de:n, para:n+1, fileiras:fa-fd, cm:economiaCm};
}
const n1=v=>v.toFixed(1).replace('.',',');
function pintaArtes(){
invalidaItemAtual();
$('lista').innerHTML=artes.map((a,i)=>{
let barra='', dica='', calc='<span>informe a largura para ver a qualidade</span>';
if(a.decodeError){
calc='<span class="er">Não conseguimos ler esta imagem; exporte novamente em PNG ou JPG</span>';
}else if(a.cm>larguraFilme()){
calc='<span class="er">Largura maior que o filme de '+n1(larguraFilme())+' cm</span>';
}else if(a.cm>0 && a.src){
const dpi=Math.round(dpiDe(a)), n=Math.max(6,Math.min(100,Math.round(dpi/300*100)));
barra='<div class="qb2"><i class="'+cls(n)+'" style="width:'+n+'%"></i></div>';
const fila=cabemNaLinha(a.cm);
calc='<span class="'+cls(n)+'"><b>'+n+'%</b> · '+dpi+' DPI</span><span>'+
a.q+' × '+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm · '+fila+' por fileira</span>';
const sug=sugestao(a);
// One hint per row, on the row it is about: resolution first, since it
// changes the price; otherwise a width that saves sheet.
const maxCm=Math.floor(a.px/DPI_AVISA*2.54*10)/10;
dica = dpi<DPI_AVISA && maxCm>0 && maxCm<a.cm
? '<div class="dica av">Pequena para '+n1(a.cm)+' cm. Em '+DPI_AVISA+' DPI ela sai com até '+
n1(maxCm)+' cm, ou envie um arquivo maior.</div>'
: sug
? '<div class="dica">Com '+n1(sug.alvo)+' cm cabem '+sug.para+' por fileira e a folha encolhe '+
n1(sug.cm)+' cm.<button data-usar="'+i+'" data-cm="'+sug.alvo+'">Usar '+n1(sug.alvo)+' cm</button></div>'
: '';
}
return '<div class="art'+(a===recemChegada?' nova':'')+'"><div class="l1"><b>'+escapeHTML(a.f.name)+'</b>'+
'<span style="color:var(--fraco);font-size:10.5px">'+
(a.px>0?a.px+' × '+a.py+' px':'a ler')+'</span>'+
'<button data-rm="'+i+'">×</button></div><div class="cps">'+
'<div><label>largura na peça</label><input type="number" min="1" max="'+larguraFilme()+
'" placeholder="cm" data-cm="'+i+'" value="'+(a.cm||'')+'"></div>'+
'<div><label>quantas vezes</label><input type="number" min="1" placeholder="1" data-q="'+i+
'" value="'+a.q+'"></div></div>'+
'<div class="atalhos">'+
(TAMANHOS[larguraFilme()]||[]).map(t=>'<button class="tm'+
(Math.abs(a.cm-t)<0.05?' on':'')+'" data-tam="'+i+'" data-t="'+t+'">'+
n1(t)+' cm</button>').join('')+
'<span class="sep"></span>'+
'<button class="gr'+(a.giro?' on':'')+'" data-giro="'+i+'" title="Girar 90°">⟳</button>'+
'<button class="gr'+(a.esp?' on':'')+'" data-esp="'+i+'" title="Espelhar">⇋</button>'+
'</div>'+barra+'<div class="calc">'+calc+'</div>'+dica+'</div>';
}).join('');
$('lista').querySelectorAll('[data-rm]').forEach(b=>b.addEventListener('click',()=>{artes.splice(+b.dataset.rm,1);pintaArtes();}));
$('lista').querySelectorAll('[data-cm]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.cm].cm=+i.value||0;pintaArtes();}));
$('lista').querySelectorAll('[data-q]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.q].q=Math.max(1,Math.min(200,Math.floor(+i.value||1)));pintaArtes();}));
$('lista').querySelectorAll('[data-cm], [data-q]').forEach(i=>i.addEventListener('input',()=>{
const largura=i.hasAttribute('data-cm');
const a=artes[+(largura?i.dataset.cm:i.dataset.q)];
a[largura?'cm':'q']=largura?Math.max(0,+i.value||0):Math.max(1,Math.min(200,Math.floor(+i.value||1)));
invalidaItemAtual();
previaAoVivo();
}));
$('lista').querySelectorAll('[data-usar]').forEach(b=>b.addEventListener('click',()=>{
artes[+b.dataset.usar].cm=+b.dataset.cm; pintaArtes();
}));
$('lista').querySelectorAll('[data-tam]').forEach(b=>b.addEventListener('click',()=>{
artes[+b.dataset.tam].cm=+b.dataset.t; pintaArtes();
}));
$('lista').querySelectorAll('[data-giro]').forEach(b=>b.addEventListener('click',()=>{
const a=artes[+b.dataset.giro]; a.giro=a.giro?0:90; pintaArtes();
}));
$('lista').querySelectorAll('[data-esp]').forEach(b=>b.addEventListener('click',()=>{
const a=artes[+b.dataset.esp]; a.esp=!a.esp; pintaArtes();
}));
resumoArtes();
previaAoVivo();
}
function resumoArtes(){
const r=$('rA'), prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && a.src);
if(!prontas.length){ r.style.display='none'; return; }
const m=montagemCm/100; // a mesma folha que aparece ao lado
const notas=prontas.map(a=>Math.max(6,Math.min(100,Math.round(dpiDe(a)/300*100))));
const media=Math.round(notas.reduce((s,n)=>s+n,0)/notas.length), pior=Math.min(...notas);
const falta=artes.length-prontas.length;
r.style.display='block'; r.className='resumoA on';
r.innerHTML='<div class="r"><span>artes com tamanho</span><b>'+prontas.length+' de '+artes.length+'</b></div>'+
'<div class="r"><span>qualidade média</span><b style="color:'+
(media>=85?'var(--verde)':media>=50?'var(--laranja)':'var(--vermelho)')+'">'+media+'%</b></div>'+
(pior<media?'<div class="r"><span>a pior arte</span><b>'+pior+'%</b></div>':'')+
(m>0?'<div class="r"><span>folha montada</span><b>'+fmtM(m)+' m</b></div>'+
(cobrar(m)>m+0.001?'<div class="r"><span>cobrança</span><b>'+fmtM(cobrar(m))+
' m'+(cobrar(m)===MINIMO_M?' · mínimo':'')+'</b></div>':''):'')+
(falta?'<div class="r" style="color:var(--laranja)"><span>faltam informar</span><b>'+falta+'</b></div>':'');
}