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 2m31s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m0s
The product page is rebuilt around mockup 1. The two ways to send are
cards under the title; the PNG/CDR route choice is gone (it never changed
the price, and the picker now takes every allowed format). Each file card
has a thumbnail, its size, check chips with a plain sentence for any
problem, a "Ver detalhes da conferência" link and a copies stepper; a CDR
file says "Conferência manual · preço cheio" and asks for the length. The
buy box shows the price scale in DPI with the customer's step, the metre
and 10 cm length, the total and what the resolution saved, and "Sai em N
partes" only above 20 m. Folha já montada gets "Ampliar" instead of zoom.
Pricing, grade and checks are unchanged.
Fixes on top: the card thumbnail is a small image made once, not the full
preview re-parsed on every repaint; sizes from 1 GB up read in GB; a failed
upload is shown with "Tentar de novo" instead of retrying silently on every
cart change. The browser suites wait for the product and the reloaded page
instead of racing them, and the artwork suite delays imagemDaArte, which
the packing now uses, fixing the CI failure of f4aacb5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
620 lines
33 KiB
JavaScript
620 lines
33 KiB
JavaScript
/* 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…';
|
||
}
|
||
// The file card's thumbnail (52 x 96 px, drawn at twice that for sharp screens).
|
||
function miniatura(fonte){
|
||
const w=fonte.naturalWidth||fonte.width, h=fonte.naturalHeight||fonte.height;
|
||
if(!(w>0 && h>0)) return null;
|
||
const s=Math.min(1,104/w,192/h), cv=document.createElement('canvas');
|
||
cv.width=Math.max(1,Math.round(w*s)); cv.height=Math.max(1,Math.round(h*s));
|
||
cv.getContext('2d').drawImage(fonte,0,0,cv.width,cv.height);
|
||
return cv.toDataURL('image/webp',0.8);
|
||
}
|
||
// 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);
|
||
}
|
||
// What the check found, as chips: ok (verde), av (atenção), er (resolva você),
|
||
// fix (arrumamos de graça), nt (a equipe confere). Details stay one click away.
|
||
function chipsDaFolha(a){
|
||
const chip=(k,t)=>'<li class="chip '+k+'">'+t+'</li>';
|
||
const dpi = a.vetor ? chip('ok','Arte vetorial')
|
||
: chip(a.dpi>=DPI_AVISA?'ok':a.dpi>=DPI_RECUSA?'av':'er', a.dpi+' DPI');
|
||
if(a.grande) return dpi+chip('nt','Peças, resíduos e fundo: a equipe confere');
|
||
return dpi+
|
||
(a.fundoChapado? chip('er','Fundo chapado') : chip('ok','Fundo transparente'))+
|
||
(a.residuos? chip('er',a.residuos+' resíduo'+(a.residuos>1?'s':'')) : chip('ok','Sem resíduos'))+
|
||
(a.encostadas? chip('fix',a.encostadas+' peças coladas · separamos grátis') : chip('ok','Peças separadas'));
|
||
}
|
||
function avisosDaFolha(a){
|
||
if(a.grande) return '';
|
||
return (a.fundoChapado? '<p class="avisoF"><b>O fundo não é transparente.</b> '+
|
||
'Branco parecendo transparente sai branco no filme. Exporte com fundo transparente.</p>' : '')+
|
||
(a.residuos? '<p class="avisoF"><b>'+a.residuos+' pontinho'+(a.residuos>1?'s soltos serão impressos':' solto será impresso')+
|
||
'.</b> A impressora imprime qualquer resíduo de recorte. Apague no arquivo e envie de novo.</p>' : '');
|
||
}
|
||
function detalhesDaFolha(a,i,aberto){
|
||
const linhas = a.grande
|
||
? ['Resolução '+a.dpi+' DPI, lida do arquivo',
|
||
'Arquivo grande: peças, resíduos e fundo são conferidos pela equipe']
|
||
: [a.artes+' peça'+(a.artes===1?'':'s')+' · '+a.aproveitamento+'% da folha com arte',
|
||
'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'),
|
||
'Conferido numa grade de '+n1(a.grade)+' mm'];
|
||
return '<details class="confDet" data-det="'+i+'"'+(aberto?' open':'')+'>'+
|
||
'<summary>Ver detalhes da conferência</summary>'+
|
||
linhas.map(l=>'<span>'+l+'</span>').join('')+'</details>';
|
||
}
|
||
function pintaFolha(){
|
||
invalidaItemAtual();
|
||
const L=larguraFilme();
|
||
pintaTipoEnvio(); // trava o seletor enquanto houver folha
|
||
$('zona').classList.toggle('compacta', folhas.length>0);
|
||
if(!folhas.length){ $('lista').innerHTML=''; agendaAvaliacao(); previaAoVivo(); return; }
|
||
|
||
const tot=folhaTotalM();
|
||
$('lista').innerHTML = '<div class="listaCab"><h4>Seus arquivos</h4><span>'+
|
||
folhas.length+' folha'+(folhas.length>1?'s':'')+(tot>0? ' · '+fmtM(tot)+' m' : '')+'</span></div>'+
|
||
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), a=lido && x.an;
|
||
let medida='', manual='';
|
||
if(lido){
|
||
medida='<div class="repMed"><b>'+n1(x.med.larg)+' × '+n1(x.med.alt)+' cm</b>'+
|
||
'<span>'+escapeHTML(extDe(x.f.name))+' · '+fmt(x.f.size)+'</span></div>'+
|
||
(a? '<ul class="chips">'+chipsDaFolha(a)+'</ul>'+avisosDaFolha(a)+detalhesDaFolha(a,i,x.detAberto) : '')+
|
||
(suspeito? '<p class="avisoF"><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.</p>':'')+
|
||
(larga? '<p class="avisoF"><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.')+'</p>':'');
|
||
}else if(x.measurementError){
|
||
medida='<span class="repPeso">'+escapeHTML(extDe(x.f.name))+' · '+fmt(x.f.size)+'</span>'+
|
||
'<p class="avisoF"><b>Este PDF não pode ser orçado.</b> '+escapeHTML(x.measurementError)+'</p>';
|
||
}else{
|
||
const teto=TABELA[modo], piso=pisoEscada();
|
||
const dif=(x.m>0)? (teto-piso)*cobrar(x.m*(x.rep||1)) : 0;
|
||
medida='<span class="chipSo">Conferência manual · preço cheio</span>'+
|
||
'<p class="repTxt">O navegador não abre '+escapeHTML(extDe(x.f.name))+', então alguém da equipe '+
|
||
'confere antes de imprimir e o metro sai pela tabela, <b>'+rs(teto)+'</b>. '+
|
||
'Exportando em <b>PNG ou PDF</b>, o metro pode cair até <b>'+rs(piso)+'</b>'+
|
||
(dif>0.5? ' — <b>'+rs(dif)+' a menos</b> só nesta folha' : '')+'.</p>';
|
||
manual='<div class="campo"><label for="comp'+i+'">Comprimento</label>'+
|
||
'<span class="un"><input type="number" id="comp'+i+'" min="0.5" max="60" step="0.01" data-comp="'+i+'" '+
|
||
'placeholder="0,00" value="'+(x.m? x.m.toFixed(2):'')+'"><i>m</i></span></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>'
|
||
: '';
|
||
// A small thumbnail made once: the list is rebuilt on every change, and the
|
||
// full preview of a file up to 150 MB would be re-parsed each time.
|
||
const mini = x.miniSrc
|
||
? '<img src="'+escapeHTML(x.miniSrc)+'" alt="">'
|
||
: '<span>'+escapeHTML(extDe(x.f.name))+'</span>';
|
||
const copias = x.m>0 || manual ?
|
||
'<div class="cps">'+manual+
|
||
(x.m>0 ?
|
||
'<div class="campo"><label for="repf'+i+'">Cópias</label>'+
|
||
'<span class="passo"><button type="button" data-passo="-1" data-i="'+i+'" aria-label="Menos uma cópia">−</button>'+
|
||
'<input type="number" id="repf'+i+'" min="1" max="200" data-repf="'+i+'" value="'+x.rep+'">'+
|
||
'<button type="button" data-passo="1" data-i="'+i+'" aria-label="Mais uma cópia">+</button></span></div>'+
|
||
'<div class="conta">'+(x.rep>1? x.rep+' × '+fmtM(x.m)+' m = ' : '')+'<b>'+fmtM(sub)+' m</b></div>' : '')+
|
||
'</div>' : '';
|
||
return '<div class="rep" data-folha="'+i+'">'+
|
||
'<div class="repTopo"><div class="miniF">'+mini+'</div><div class="repInfo">'+
|
||
'<div class="l1"><b>'+(folhas.length>1? (i+1)+'. ':'')+escapeHTML(x.f.name)+'</b>'+
|
||
'<button data-rmf="'+i+'" aria-label="Remover '+escapeHTML(x.f.name)+'">×</button></div>'+
|
||
(lido || x.measurementError ? '' : '<span class="repPeso">'+escapeHTML(extDe(x.f.name))+' · '+fmt(x.f.size)+'</span>')+
|
||
barra+medida+
|
||
'</div></div>'+copias+
|
||
'</div>';
|
||
}).join('');
|
||
|
||
$('lista').querySelectorAll('[data-rmf]').forEach(b=>b.addEventListener('click',()=>{
|
||
folhas.splice(+b.dataset.rmf,1); pintaFolha();
|
||
}));
|
||
$('lista').querySelectorAll('[data-det]').forEach(d=>d.addEventListener('toggle',()=>{
|
||
folhas[+d.dataset.det].detAberto=d.open;
|
||
}));
|
||
$('lista').querySelectorAll('[data-passo]').forEach(b=>b.addEventListener('click',()=>{
|
||
const el=$('repf'+b.dataset.i);
|
||
el.value=Math.max(1,Math.min(200,(+el.value||1)+(+b.dataset.passo)));
|
||
el.dispatchEvent(new Event('change'));
|
||
}));
|
||
$('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('.',',');
|
||
|
||
// Moves one artwork's progress in place, without repainting the list.
|
||
function pintaProgressoArte(a){
|
||
const el=$('lista').querySelector('.art[data-arte="'+artes.indexOf(a)+'"]');
|
||
const barra=el && el.querySelector('.prog i'), texto=el && el.querySelector('.progT');
|
||
if(!barra){ pintaArtes(); return; }
|
||
barra.style.width=a.lendo+'%'; if(texto) texto.textContent='arquivo grande · gerando a prévia… '+a.lendo+'%';
|
||
}
|
||
function pintaArtes(){
|
||
invalidaItemAtual();
|
||
$('zona').classList.toggle('compacta', artes.length>0);
|
||
$('lista').innerHTML=artes.map((a,i)=>{
|
||
let barra='', dica='', calc='<span>informe a largura para ver a qualidade</span>';
|
||
if(a.lendo!=null){
|
||
barra='<div class="prog"><i style="width:'+a.lendo+'%"></i></div>';
|
||
calc='<span class="progT">arquivo grande · gerando a prévia… '+a.lendo+'%</span>';
|
||
}else if(a.decodeError==='grande'){
|
||
calc='<span class="er">Arquivo grande em JPG ou WebP: exporte em PNG para montar a folha</span>';
|
||
}else 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':'')+'" data-arte="'+i+'"><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>':'');
|
||
}
|