Files
dtf-system/web/site-packing.js
Cauê Faleiros b7e2ea12d0 feat: accept large JPG and WebP artworks instead of refusing them
Above 150 MB the browser cannot read JPG or WebP in parts, so a large
artwork was refused. It is now measured from its header, graded and priced
with the discount like any other, and placed on the sheet as a full box,
which is exact for JPG. The card says there is no preview and that the team
checks the art before printing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:14:04 -03:00

396 lines
18 KiB
JavaScript
Raw Permalink 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 — Masks, the nesting engine, and drawing the assembled film.
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. */
// ── imagens e prévia
function carregarImagem(file){
return new Promise(res=>{
if(!RENDERIZA.test(file.name)) return res(null);
const fr=new FileReader();
fr.onload=()=>{ const im=new Image(); im.onload=()=>res(im); im.onerror=()=>res(null); im.src=fr.result; };
fr.onerror=()=>res(null);
fr.readAsDataURL(file);
});
}
// The image each artwork was measured with, loaded once and reused on every
// repaint: the packing used to read and decode the whole file again each
// time. Kept outside the artwork, which is saved with the cart.
const imagensDasArtes=new WeakMap();
function imagemDaArte(a){
if(!a.src) return Promise.resolve(null);
const guardada=imagensDasArtes.get(a);
if(guardada && guardada.src===a.src) return guardada.img;
const img=new Promise(res=>{ const im=new Image(); im.onload=()=>res(im); im.onerror=()=>res(null); im.src=a.src; });
imagensDasArtes.set(a,{src:a.src,img});
return img;
}
// A folha precisa parecer uma folha: borda, largura marcada e régua de metros.
// Sem isso o cliente não tem noção de escala e acha que está mal encaixado.
function molduraFolha(ctx,W,H,larguraCm,metrosReais){
const REG=34; // faixa da régua, à direita
ctx.save();
// fundo da régua
ctx.fillStyle='#FAFAF8'; ctx.fillRect(W,0,REG,H);
ctx.strokeStyle='#D5DAE3'; ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(W+.5,0); ctx.lineTo(W+.5,H); ctx.stroke();
// marcas a cada 10 cm, número a cada metro
const pxPorM = H/Math.max(metrosReais,0.01);
ctx.font='9px "Roboto", sans-serif'; ctx.textAlign='left';
for(let cm=0; cm<=metrosReais*100; cm+=10){
const y=cm/100*pxPorM;
if(y>H) break;
const metro = cm%100===0;
ctx.strokeStyle = metro? '#B4BAC5' : '#E1E5EC';
ctx.beginPath(); ctx.moveTo(W+1,y+.5); ctx.lineTo(W+(metro?12:6),y+.5); ctx.stroke();
if(metro && cm>0){
ctx.fillStyle='#8A8F98';
ctx.fillText((cm/100)+' m', W+14, y+3);
}
}
// borda da folha
ctx.strokeStyle='#B4BAC5'; ctx.lineWidth=1;
ctx.strokeRect(.5,.5,W-1,H-1);
// cota da largura, no topo
ctx.fillStyle='#FFFFFFDD'; ctx.fillRect(0,0,W,18);
ctx.strokeStyle='#8A8F98'; ctx.lineWidth=1;
ctx.beginPath();
ctx.moveTo(8,9); ctx.lineTo(W-8,9);
ctx.moveTo(8,5); ctx.lineTo(8,13);
ctx.moveTo(W-8,5); ctx.lineTo(W-8,13);
ctx.stroke();
const t=String(larguraCm).replace('.',',')+' cm';
ctx.font='600 10px "Roboto", sans-serif'; ctx.textAlign='center';
const tw=ctx.measureText(t).width;
ctx.fillStyle='#FAFAF8'; ctx.fillRect(W/2-tw/2-6,2,tw+12,14);
ctx.fillStyle='#5A6570'; ctx.fillText(t,W/2,13);
ctx.restore();
}
const LOGO_URL='https://cdn.vnda.com.br/dropstar/2025/12/17/logo-header-15125700-16129080.png?v=1766009286';
const logoImg=new Image(); logoImg.crossOrigin='anonymous'; logoImg.src=LOGO_URL;
function marcaDagua(ctx,w,h){
ctx.save(); ctx.globalAlpha=.14;
const passo=Math.max(90,w/2.1);
ctx.translate(w/2,h/2); ctx.rotate(-Math.PI/7); ctx.translate(-w/2,-h/2);
if(logoImg.complete && logoImg.naturalWidth){
const lw=Math.max(70,w/3.4), lh=lw*(logoImg.naturalHeight/logoImg.naturalWidth);
for(let y=-h;y<h*2;y+=passo) for(let x=-w;x<w*2;x+=passo*1.7)
ctx.drawImage(logoImg,x,y,lw,lh);
}else{
ctx.fillStyle='#111';
ctx.font='700 '+Math.max(13,Math.round(w/13))+'px Inter, sans-serif'; ctx.textAlign='center';
for(let y=-h;y<h*2;y+=passo) for(let x=-w;x<w*2;x+=passo*1.5) ctx.fillText('PRÉVIA',x,y);
}
ctx.restore();
}
const CEL=GAP_CM; // célula da máscara, em cm = a folga de 5 mm
// Dilata 1 célula para a direita e para baixo. Como toda peça é dilatada igual,
// duas vizinhas ficam separadas por exatamente 1 célula — os 5 mm da regra.
function dilatar(bruto,cw,ch,cheias){
const cw2=cw+1, ch2=ch+1;
const out=new Uint8Array(cw2*ch2);
for(let y=0;y<ch;y++) for(let x=0;x<cw;x++){
if(!bruto[y*cw+x]) continue;
out[y*cw2+x]=1; out[y*cw2+x+1]=1; out[(y+1)*cw2+x]=1; out[(y+1)*cw2+x+1]=1;
}
return {cw:cw2,ch:ch2,bits:out,cheias}; // cheias = área de arte, sem a folga
}
function mascara(img,w,h,rot,esp=false){
const cw=Math.max(1,Math.ceil(w/CEL)), ch=Math.max(1,Math.ceil(h/CEL));
const cv=document.createElement('canvas');
cv.width=cw; cv.height=ch;
const ctx=cv.getContext('2d',{willReadFrequently:true});
let bruto, cheias;
if(!img){ // sem imagem: caixa cheia
bruto=new Uint8Array(cw*ch).fill(1); cheias=cw*ch;
return dilatar(bruto,cw,ch,cheias);
}
ctx.save();
ctx.translate(cw/2,ch/2);
ctx.rotate(rot*Math.PI/180);
if(esp) ctx.scale(-1,1);
const dw=rot%180===90?ch:cw, dh=rot%180===90?cw:ch;
ctx.drawImage(img,-dw/2,-dh/2,dw,dh);
ctx.restore();
const d=ctx.getImageData(0,0,cw,ch).data;
bruto=new Uint8Array(cw*ch); cheias=0;
for(let i=0,j=3;i<bruto.length;i++,j+=4){ bruto[i] = d[j]>16 ? 1 : 0; cheias+=bruto[i]; }
return dilatar(bruto,cw,ch,cheias);
}
// Encaixe pela forma, como o motor do servidor: mapa de ocupação real,
// máscara por arte e candidatos numa grade ao longo da largura.
// pecas em centímetros: {w,h,img}. W é a largura do filme em cm.
function encaixar(pecas,W){
const cols=Math.ceil(W/CEL)+1; // +1 = a coluna de folga da dilatação
let linhas=0; const mapa=[];
function garantir(ate){ while(linhas<ate){ mapa.push(new Uint8Array(cols)); linhas++; } }
function colide(m,cx,cy){
if(cx<0||cy<0||cx+m.cw>cols) return true;
for(let y=0;y<m.ch;y++){
const gy=cy+y;
if(gy>=linhas) continue; // vazio abaixo do que já existe
const L=mapa[gy], off=y*m.cw;
for(let x=0;x<m.cw;x++)
if(m.bits[off+x] && L[cx+x]) return true;
}
return false;
}
function ocupar(m,cx,cy){
garantir(cy+m.ch);
for(let y=0;y<m.ch;y++){
const L=mapa[cy+y], off=y*m.cw;
for(let x=0;x<m.cw;x++) if(m.bits[off+x]) L[cx+x]=1;
}
}
const postas=[];
if(pecas.some(p=>!Number.isFinite(p.w) || p.w<=0 || p.w>W))
throw new RangeError('A largura solicitada não cabe no filme.');
const ordem=[...pecas].sort((a,b)=>(b.w*b.h)-(a.w*a.h));
ordem.forEach(p=>{
const oris=[{w:p.w,h:p.h,rot:0}];
if(!p.mPronta && !p.travado && Math.abs(p.w-p.h)>1 && p.h<=W) oris.push({w:p.h,h:p.w,rot:90});
let melhor=null;
oris.forEach(o=>{
if(o.w>W) return;
// a máscara segue a medida da orientação escolhida · girada, largura e altura trocam
const m = (p.mPronta && o.rot===0) ? p.mPronta : mascara(p.img, o.w, o.h,
o.rot+(p.a?.giro||0), p.a?.esp);
const xs=new Set([0]), ys=new Set([0]);
postas.forEach(q=>{ xs.add(q.cx+q.m.cw); xs.add(q.cx); ys.add(q.cy+q.m.ch); ys.add(q.cy); });
const passoX=Math.max(1,Math.round(cols/34));
for(let x=0;x+m.cw<=cols;x+=passoX) xs.add(x);
xs.add(cols-m.cw);
const passoY=Math.max(1,Math.round(Math.max(linhas,1)/50));
for(let y=0;y<=linhas;y+=passoY) ys.add(y);
const XS=[...xs].filter(x=>x>=0 && x+m.cw<=cols && x*CEL+o.w<=W+0.01).sort((a,b)=>a-b);
const YS=[...ys].filter(y=>y>=0).sort((a,b)=>a-b);
for(const cy of YS){
if(melhor && cy*CEL > melhor.y + CEL) break;
for(const cx of XS){
if(cx*CEL + o.w > W + 0.01) continue; // não deixa a peça sair do filme
if(colide(m,cx,cy)) continue;
const cand={cx,cy,m,x:cx*CEL,y:cy*CEL,w:o.w,h:o.h,rot:o.rot,ref:p};
// menor y, depois menor x · em empate, mantém a orientação original.
// Desempatar por largura gira tudo, e texto deitado na prévia parece erro.
const ganha = !melhor || cand.y<melhor.y ||
(cand.y===melhor.y && cand.x<melhor.x) ||
(cand.y===melhor.y && cand.x===melhor.x && cand.rot===0 && melhor.rot!==0);
if(ganha) melhor=cand;
break;
}
}
});
if(!melhor){
const m=mascara(p.img,p.w,p.h,p.a?.giro||0,p.a?.esp);
melhor={cx:0,cy:linhas,m,x:0,y:linhas*CEL,w:p.w,h:p.h,rot:0,ref:p};
}
// a folga só existe entre peças, não é folha cobrada
ocupar(melhor.m,melhor.cx,melhor.cy);
postas.push(melhor);
});
const altura=Math.max(...postas.map(p=>p.y+p.h),1);
return {pos:postas, altura};
}
function desenhaMontagem(area,lista,mts,W,fora,aoTerminar){
const versao=area.montagemVersao=(area.montagemVersao||0)+1;
const filme=larguraFilme(), PX_CM=W/filme;
const itens=lista.map((a,sourceIndex)=>({...a,sourceIndex}));
if(fora) fora.innerHTML='';
if(!itens.length){
area.innerHTML='<div class="semmont"><b>A montagem aparece aqui</b>'+
'<span>informe a largura de cada arte e a folha se desenha sozinha</span></div>';
if(aoTerminar) aoTerminar({alturaCm:0, uso:0, girou:0});
return;
}
Promise.all(itens.map(a=>imagemDaArte(a).then(img=>({a,img})))).then(imagens=>{
if(area.montagemVersao!==versao) return;
const cargas=imagens.flatMap(c=>Array.from({length:c.a.q||1},(_,copyIndex)=>({...c,copyIndex})));
cargas.forEach(c=>{
c.w = c.a.cm; // o motor trabalha em centímetros
// The file's own proportion: a large artwork is drawn from a smaller copy.
const prop = c.a.prop>0 ? c.a.prop : c.img ? c.img.height/c.img.width : 1;
c.h = c.w * (c.a.giro? 1/prop : prop);
c.travado = !!c.a.giro; // giro do cliente manda no encaixe
});
const enc=encaixar(cargas,filme);
const alturaCm=enc.altura;
const pos=enc.pos.map(p=>({...p, x:p.x*PX_CM, y:p.y*PX_CM, w:p.w*PX_CM, h:p.h*PX_CM}));
const altura=alturaCm*PX_CM;
const usado=Math.max(altura,10);
const REG=34;
const cv=document.createElement('canvas');
cv.width=W+REG; cv.height=Math.round(usado);
const ctx=cv.getContext('2d');
ctx.fillStyle='#fff'; ctx.fillRect(0,0,cv.width,cv.height);
const pal=['#C3CEDD','#B7A9C8','#A9B7C8','#A8BFA9','#C8C0A0','#CBB6B6'];
let girou=0;
pos.forEach((p,j)=>{
const c=p.ref;
if(p.rot===90) girou++;
ctx.save();
const giro = (p.rot===90?90:0) + (c.a && c.a.giro? 90:0);
ctx.translate(p.x+p.w/2, p.y+p.h/2);
ctx.rotate(giro*Math.PI/180);
if(c.a && c.a.esp) ctx.scale(-1,1);
const dw = giro%180===90 ? p.h : p.w, dh = giro%180===90 ? p.w : p.h;
if(c.img) ctx.drawImage(c.img,-dw/2,-dh/2,dw,dh);
else{ ctx.fillStyle=pal[j%pal.length]; ctx.fillRect(-dw/2,-dh/2,dw,dh); }
ctx.restore();
if(!c.img){
ctx.fillStyle='#5A6570'; ctx.font='11px "Roboto", sans-serif'; ctx.textAlign='center';
ctx.fillText(c.a.f.name.split('.').pop().toUpperCase(),p.x+p.w/2,p.y+p.h/2+4);
}
});
molduraFolha(ctx,W,usado,filme,alturaCm/100);
marcaDagua(ctx,W,cv.height);
area.innerHTML=''; area.appendChild(cv);
// O que virou arte é a área pintada, não a soma das caixas: com encaixe por forma
// as caixas se sobrepõem de propósito e a soma delas passa de 100%.
const areaArteCm=enc.pos.reduce((s,p)=>
s + (p.m && p.m.cheias!=null ? p.m.cheias*CEL*CEL : p.w*p.h), 0);
const uso=Math.max(1,Math.min(100,
Math.round(areaArteCm/(filme*Math.max(alturaCm,0.1))*100)));
const el=document.createElement('div');
el.className = uso>=70? 'usoBom' : 'usoRuim';
el.innerHTML = (uso>=70
? '<b>'+uso+'% da folha virou arte</b> bom aproveitamento'
: '<b>'+uso+'% da folha virou arte</b> dá para melhorar: artes de larguras parecidas '+
'se encaixam melhor lado a lado')
+ (girou? ' · <b style="display:inline">'+girou+
(girou>1?' artes giradas':' arte girada')+' 90°</b> para caber melhor':'');
(fora||area).appendChild(el);
if(aoTerminar) aoTerminar({alturaCm, uso, girou,
placements:enc.pos.map(p=>({source_index:p.ref.a.sourceIndex,
copy_index:p.ref.copyIndex,x_cm:p.x,y_cm:p.y,
width_cm:+p.w.toFixed(4),length_cm:+p.h.toFixed(4),
rotation_degrees:(p.rot+(p.ref.a.giro||0))%360,
mirrored:!!p.ref.a.esp}))});
});
}
function previaArtes(){ desenhaMontagem($('pArea'), artes, metros, 560); }
// montagem ao vivo, ao lado da fila · redesenha 140 ms depois da ultima mexida
let tMont=null;
function previaAoVivo(){
clearTimeout(tMont);
montagemLayout=null;
$('vArea').montagemVersao=($('vArea').montagemVersao||0)+1;
if(!ehFolha()) $('bAmpliar').hidden=true;
if(ehFolha()){
$('vUso').innerHTML='';
const tot=folhaTotalM();
$('vMt').textContent = tot>0 ? fmtM(cobrar(tot))+' m' : '—';
const medidas=folhas.filter(x=>x.med);
// Sheets still being read show a placeholder; a sheet printed N times is
// shown N times (up to REPETE_MAX, then a count).
const REPETE_MAX=6;
const previews=folhas.filter(x=>x.previewSrc || (x.pct!=null && x.med && x.med.grande));
if(previews.length){
const painel=document.createElement('div'); painel.className='folhaPrevias';
previews.forEach((x,index)=>{
const figura=document.createElement('figure'); figura.className='folhaPrevia';
const legenda=document.createElement('figcaption');
const vezes=Math.max(1,x.rep||1);
legenda.textContent=(previews.length>1 ? (index+1)+'. ' : '')+x.f.name+
(x.med ? ' · '+n1(x.med.larg)+' × '+n1(x.med.alt)+' cm' : '')+(vezes>1 ? ' · '+vezes+' cópias' : '');
if(!x.previewSrc){
const esqueleto=document.createElement('div'); esqueleto.className='skel folhaSkel';
esqueleto.style.aspectRatio=x.med ? x.med.larg+' / '+Math.min(x.med.alt,x.med.larg*4) : '1 / 2';
esqueleto.append(Object.assign(document.createElement('span'),{textContent:'Gerando a prévia da folha…'}));
figura.append(esqueleto,legenda); painel.append(figura); return;
}
const pilha=document.createElement('div'); pilha.className='folhaPilha';
for(let k=0;k<Math.min(vezes,REPETE_MAX);k++){
const imagem=new Image(); imagem.src=x.previewSrc; imagem.alt=k? '' : x.f.name;
imagem.style.maxHeight=Math.floor(520/Math.min(vezes,REPETE_MAX))+'px'; pilha.append(imagem);
}
if(vezes>REPETE_MAX) pilha.append(Object.assign(document.createElement('div'),
{className:'folhaMais', textContent:'+ '+(vezes-REPETE_MAX)+' cópia'+(vezes-REPETE_MAX>1?'s':'')}));
figura.append(pilha,legenda); painel.append(figura);
});
$('vArea').replaceChildren(painel);
$('bAmpliar').hidden=false;
}else{
$('bAmpliar').hidden=true;
$('vArea').innerHTML = medidas.length
? '<div class="semmont"><b>'+(medidas.length>1
? medidas.length+' folhas · '+fmtM(tot)+' m'
: n1(medidas[0].med.larg)+' × '+n1(medidas[0].med.alt)+' cm')+'</b>'+
'<span>'+(medidas.length>1? 'medidas lidas de cada arquivo' : medidas[0].med.fonte)+
' · vão para a máquina do jeito que você montou</span></div>'
: '<div class="semmont"><b>Sua folha aparece aqui</b>'+
'<span>a medida é lida do próprio arquivo, não do peso dele</span></div>';
}
return;
}
tMont=setTimeout(()=>{
// o produto pode ter sido fechado nesses 140 ms (item foi para o carrinho)
if(!modo) return;
const prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && (a.src||a.semPrevia));
const W=Math.round(300*ZOOMS[zoomI]);
$('vArea').classList.toggle('ampliado', zoomI>0);
desenhaMontagem($('vArea'), prontas, 0, W, $('vUso'), r=>{
montagemCm = r.alturaCm; // altura real da folha montada
montagemLayout=r.placements ? {height_cm:+r.alturaCm.toFixed(4),
placements:r.placements} : null;
$('vMt').textContent = r.alturaCm? fmtM(cobrar(r.alturaCm/100))+' m' : '—';
resumoArtes(); // o resumo lê a mesma folha
agendaAvaliacao(); // a nota só sai com a metragem pronta
});
$('vArea').scrollTop=0;
},140);
}
function motivoNota(){
if(!nota) return 'sem nota ainda · a conferência roda na sala antes de imprimir';
if(!ehFolha()){
const ruins=artes.filter(a=>a.cm>0 && dpiDe(a)<300);
if(!ruins.length) return 'nota '+nota+' · todas as artes em 300 DPI ou mais';
const pior=ruins.reduce((p,a)=>dpiDe(a)<dpiDe(p)?a:p);
return 'nota '+nota+' · '+ruins.length+(ruins.length>1?' artes abaixo':' arte abaixo')+
' de 300 DPI · a menor é "'+pior.f.name+'" com '+
Math.round(dpiDe(pior))+' DPI em '+pior.cm.toFixed(1).replace('.',',')+' cm';
}
return nota>=90 ? 'nota '+nota+' · resolução ótima em toda a folha'
: 'nota '+nota+' · a resolução é o único ponto — o resto corrigimos por você';
}
function previa(){
// Na folha pronta o painel não tem o que mostrar: a folha vem fechada, ninguém
// olhou dentro dela. Largura e metragem — o que de fato medimos — já aparecem
// na coluna ao lado e no carrinho.
if(ehFolha()){ $('prev').classList.remove('on'); return; }
const cob=cobrar(metros), sobra=+(cob-metros).toFixed(2);
$('pMt').innerHTML=fmtM(cob)+'<small> m</small>';
$('pSub').textContent='folha montada por nós com as artes que você enviou';
const linhas=[
['var(--verde)','✓','Largura',larguraFilme()+' cm · dentro do padrão da máquina'],
['var(--verde)','✓','Espaço entre artes','5 mm garantidos'],
[nota>=75?'var(--verde)':'var(--laranja)', nota>=75?'✓':'!','Qualidade da folha',motivoNota()],
['var(--verde)','✓','Metragem proporcional',
'sua folha tem '+fmtM(metros)+' m e você paga '+fmtM(cob)+
' m · arredondamos só até os 10 cm mais próximos'],
];
linhas.push(['var(--verde)','+','Identificação do pedido',
'aplicamos seu nome e o número do pedido no fim da folha · por nossa conta']);
$('pInfo').innerHTML=linhas.map(l=>
'<div class="li"><span class="c" style="background:'+l[0]+
(l[0]==='var(--laranja)'?';color:#241703':'')+'">'+l[1]+'</span>'+
'<div><b>'+escapeHTML(l[2])+'</b><span>'+escapeHTML(l[3])+'</span></div></div>').join('');
$('prev').classList.add('on');
$('pArea').innerHTML='<div class="carregando">montando a prévia…</div>';
try{ previaArtes(); }
catch(e){ $('pArea').innerHTML='<div class="semprev"><b>Prévia indisponível</b>'+
'O arquivo será processado normalmente.</div>'; }
}