When files are picked, the product page shows the range the upload takes between a slow (10 Mbps) and a fast (150 Mbps) connection, and asks to keep the page open. The cart shows the same range until the real speed is measured, then the measured time left. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
641 lines
34 KiB
JavaScript
641 lines
34 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();
|
||
pintaAvisoEnvio();
|
||
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('[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();
|
||
pintaAvisoEnvio();
|
||
$('zona').classList.toggle('compacta', artes.length>0);
|
||
// The same card as a finished sheet: thumbnail, size, a DPI chip, then the
|
||
// width (with the usual sizes), copies, rotation and mirroring.
|
||
const copiasTot=artes.reduce((t,a)=>t+(a.q||1),0);
|
||
$('lista').innerHTML=(artes.length? '<div class="listaCab"><h4>Suas artes</h4><span>'+
|
||
artes.length+' arte'+(artes.length>1?'s':'')+' · '+copiasTot+' cópia'+(copiasTot>1?'s':'')+'</span></div>' : '')+
|
||
artes.map((a,i)=>{
|
||
let barra='', dica='', chips='', aviso='', medida='<b class="pede">Informe a largura</b>';
|
||
if(a.lendo!=null){
|
||
barra='<div class="prog"><i style="width:'+a.lendo+'%"></i></div>'+
|
||
'<span class="progT">arquivo grande · gerando a prévia… '+a.lendo+'%</span>';
|
||
}else if(a.decodeError==='grande'){
|
||
aviso='<p class="avisoF"><b>Arquivo grande em JPG ou WebP.</b> Exporte em PNG para montar a folha.</p>';
|
||
}else if(a.decodeError){
|
||
aviso='<p class="avisoF"><b>Não conseguimos ler esta imagem.</b> Exporte novamente em PNG ou JPG.</p>';
|
||
}else if(a.cm>larguraFilme()){
|
||
aviso='<p class="avisoF"><b>Largura maior que o filme.</b> O filme aqui tem '+n1(larguraFilme())+' cm.</p>';
|
||
}else if(a.cm>0 && a.src){
|
||
const dpi=Math.round(dpiDe(a));
|
||
medida='<b>'+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</b>';
|
||
chips='<ul class="chips"><li class="chip '+(dpi>=DPI_AVISA?'ok':dpi>=DPI_RECUSA?'av':'er')+'">'+dpi+' DPI</li>'+
|
||
'<li class="chip nt">'+cabemNaLinha(a.cm)+' por fileira</li></ul>';
|
||
const sug=sugestao(a);
|
||
// One hint per artwork: 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>'
|
||
: '';
|
||
}
|
||
const mini = a.miniSrc ? '<img src="'+escapeHTML(a.miniSrc)+'" alt="">' : '<span>'+escapeHTML(extDe(a.f.name))+'</span>';
|
||
const sub = a.cm>0 && a.src ? (a.q>1? a.q+' × ' : '')+'<b>'+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</b>' : '';
|
||
return '<div class="rep art2'+(a===recemChegada?' nova':'')+'" data-arte="'+i+'">'+
|
||
'<div class="repTopo"><div class="miniF">'+mini+'</div><div class="repInfo">'+
|
||
'<div class="l1"><b>'+escapeHTML(a.f.name)+'</b>'+(a===recemChegada?'<em class="novaT">nova</em>':'')+
|
||
'<button data-rm="'+i+'" aria-label="Remover '+escapeHTML(a.f.name)+'">×</button></div>'+
|
||
'<div class="repMed">'+medida+'<span>'+(a.px>0? a.px+' × '+a.py+' px · ' : '')+
|
||
escapeHTML(extDe(a.f.name))+' · '+fmt(a.f.size)+'</span></div>'+
|
||
barra+chips+aviso+dica+
|
||
'</div></div>'+
|
||
'<div class="cps">'+
|
||
'<div class="campo"><label for="cm'+i+'">Largura</label>'+
|
||
'<span class="un"><input type="number" id="cm'+i+'" min="1" max="'+larguraFilme()+'" step="0.1" '+
|
||
'placeholder="0" data-cm="'+i+'" value="'+(a.cm||'')+'"><i>cm</i></span></div>'+
|
||
'<div class="campo"><label for="q'+i+'">Cópias</label>'+
|
||
'<span class="passo"><button type="button" data-passoa="-1" data-i="'+i+'" aria-label="Menos uma cópia">−</button>'+
|
||
'<input type="number" id="q'+i+'" min="1" max="200" data-q="'+i+'" value="'+a.q+'">'+
|
||
'<button type="button" data-passoa="1" data-i="'+i+'" aria-label="Mais uma cópia">+</button></span></div>'+
|
||
'<span class="giros">'+
|
||
'<button class="gr'+(a.giro?' on':'')+'" data-giro="'+i+'" title="Girar 90°" aria-label="Girar 90°">⟳</button>'+
|
||
'<button class="gr'+(a.esp?' on':'')+'" data-esp="'+i+'" title="Espelhar" aria-label="Espelhar">⇋</button></span>'+
|
||
(sub? '<div class="conta">'+sub+'</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('')+'</div>'+
|
||
'</div>'+
|
||
'</div>';
|
||
}).join('');
|
||
$('lista').querySelectorAll('[data-passoa]').forEach(b=>b.addEventListener('click',()=>{
|
||
const el=$('q'+b.dataset.i);
|
||
el.value=Math.max(1,Math.min(200,(+el.value||1)+(+b.dataset.passoa)));
|
||
el.dispatchEvent(new Event('change'));
|
||
}));
|
||
$('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>':'');
|
||
}
|