fix: use packed artwork model for standard image uploads
All checks were successful
Build and deploy / Validate source (push) Successful in 10s
Build and deploy / Publish images and notify Portainer (push) Successful in 53s

This commit is contained in:
Cauê Faleiros
2026-09-18 16:35:24 -03:00
parent 483a083a3d
commit 99fd92bdb8
3 changed files with 257 additions and 32 deletions

View File

@@ -153,6 +153,14 @@ The browser may display the calculation, but production payment creation must
recalculate and validate all price, quantity, freight, and discount values on recalculate and validate all price, quantity, freight, and discount values on
the server. Client-provided totals are never authoritative. the server. Client-provided totals are never authoritative.
Normal JPG/PNG uploads are individual artwork by default, including when uploaded
from the by-metre panel. They use the existing loose-artwork editor, printed
width, quantity, and live packing canvas; packed height determines metres.
Customers uploading an already assembled film-width image must explicitly select
the ready-sheet option before uploading. PDF and manual-format ready sheets keep
their existing workflow. These models retain their separate approved price tiers;
image dimensions alone must never classify a normal artwork as a finished sheet.
## Freight ## Freight
The original visual freight flow in `dtf-site.html` was a stub with only pickup The original visual freight flow in `dtf-site.html` was a stub with only pickup

View File

@@ -261,8 +261,7 @@ h1 em{font-style:normal;color:var(--laranja)}
.trocarModo{margin:-4px 0 11px;padding:10px 12px;border-radius:9px;background:#F1FBF5; .trocarModo{margin:-4px 0 11px;padding:10px 12px;border-radius:9px;background:#F1FBF5;
border:1px solid #BFE6CE;font-size:11.5px;color:#1F5C3A;line-height:1.5} border:1px solid #BFE6CE;font-size:11.5px;color:#1F5C3A;line-height:1.5}
.trocarModo b{font-family:"Inter",sans-serif;font-weight:700} .trocarModo b{font-family:"Inter",sans-serif;font-weight:700}
.trocarModo button{margin:7px 0 0;border:0;border-radius:6px;padding:7px 10px;background:var(--verde-ml); .trocarModo label{display:block;margin-top:7px;cursor:pointer}
color:#fff;font-family:"Inter",sans-serif;font-size:11.5px;font-weight:700;cursor:pointer}
.zona{border:2px dashed var(--laranja);border-radius:13px;padding:44px 20px;text-align:center; .zona{border:2px dashed var(--laranja);border-radius:13px;padding:44px 20px;text-align:center;
cursor:pointer;margin-bottom:14px;background:#FFF6E8;transition:var(--transition)} cursor:pointer;margin-bottom:14px;background:#FFF6E8;transition:var(--transition)}
.zona:hover,.zona.sobre{border-color:var(--laranja2);background:#FFEFD6; .zona:hover,.zona.sobre{border-color:var(--laranja2);background:#FFEFD6;
@@ -1002,8 +1001,11 @@ footer a:hover{color:var(--laranja2)}
<div class="fila"> <div class="fila">
<div class="trocouCam" id="trocouCam" style="display:none"></div> <div class="trocouCam" id="trocouCam" style="display:none"></div>
<div class="trocarModo" id="trocarParaAvulsa" hidden> <div class="trocarModo" id="trocarParaAvulsa" hidden>
<b>É uma imagem ou arte solta?</b> Nós montamos, repetimos e encaixamos na folha. <b>PNG e JPG entram como artes avulsas.</b> Informe largura e quantidade;
<button type="button" id="mudarParaAvulsa">Montar minha arte</button> montamos a folha, com o preço de artes avulsas.
<label><input type="checkbox" id="imagemComoFolha">
Minha imagem já é uma folha montada de <span id="larguraFolhaPronta">57</span> cm
· usar o preço de arquivo por metro</label>
</div> </div>
<div class="caminhos" id="caminhos"> <div class="caminhos" id="caminhos">
<button class="cam on" data-cam="auto"> <button class="cam on" data-cam="auto">
@@ -1462,6 +1464,17 @@ document.querySelectorAll('[data-modo-cta]').forEach(b=>b.addEventListener('clic
function abrir(m){ function abrir(m){
modo=m; artes=[]; folhas=[]; metros=0; nota=0; reencaixado=false; modo=m; artes=[]; folhas=[]; metros=0; nota=0; reencaixado=false;
montagemCm=0; montagemCm=0;
$('imagemComoFolha').checked=false;
$('imagemComoFolha').disabled=false;
pintaModo();
$('lista').innerHTML=''; $('rA').style.display='none'; recusa([]);
recemChegada=null; caminho='auto'; avisoCam(''); pintaCaminhos();
$('vMt').textContent='—'; previaAoVivo(); agendaAvaliacao();
limpaPaineis(); // o carrinho só some se o pedido estiver vazio
$('foco').scrollIntoView({behavior:'smooth',block:'start'});
}
function pintaModo(){
const m=modo;
const c=MODOS[m]; const c=MODOS[m];
$('cards').style.display='none'; $('foco').classList.add('on'); $('cards').style.display='none'; $('foco').classList.add('on');
$('fTit').textContent=c.tit; $('fLg').textContent=c.lg; $('fTit').textContent=c.tit; $('fLg').textContent=c.lg;
@@ -1472,21 +1485,10 @@ function abrir(m){
: '.png,.jpg,.jpeg,.webp'; : '.png,.jpg,.jpeg,.webp';
$('catFoco').className='cat '+(ehFolha(m)?'file':'av'); $('catFoco').className='cat '+(ehFolha(m)?'file':'av');
$('trocarParaAvulsa').hidden=!ehFolha(m); $('trocarParaAvulsa').hidden=!ehFolha(m);
$('larguraFolhaPronta').textContent=String(c.larg).replace('.',',');
$('foco2').classList.remove('sofila'); // a caixa de requisitos vale nos 4 modos $('foco2').classList.remove('sofila'); // a caixa de requisitos vale nos 4 modos
$('montcabTit').textContent = ehFolha(m) ? 'Sua folha' : 'Montagem ao vivo'; $('montcabTit').textContent = ehFolha(m) ? 'Sua folha' : 'Montagem ao vivo';
$('lista').innerHTML=''; $('rA').style.display='none'; recusa([]);
recemChegada=null; caminho='auto'; avisoCam(''); pintaCaminhos();
$('vMt').textContent='—'; previaAoVivo(); agendaAvaliacao();
limpaPaineis(); // o carrinho só some se o pedido estiver vazio
$('foco').scrollIntoView({behavior:'smooth',block:'start'});
} }
$('mudarParaAvulsa').addEventListener('click',()=>{
// A ready sheet and a loose artwork have different pricing units. Preserve the
// selected browser files, but ask for the artwork width in the packing flow.
const arquivos=folhas.map(x=>x.f);
abrir('avulsa');
if(arquivos.length) sel(arquivos);
});
$('bVoltar').addEventListener('click',()=>{ $('bVoltar').addEventListener('click',()=>{
itemAtual=null; $('atual').style.display='none'; $('carrLin').innerHTML=''; pintaPedido(); itemAtual=null; $('atual').style.display='none'; $('carrLin').innerHTML=''; pintaPedido();
$('foco').classList.remove('on'); $('cards').style.display=''; $('foco').classList.remove('on'); $('cards').style.display='';
@@ -1523,6 +1525,18 @@ Z.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){I.click();e.pr
I.addEventListener('change',()=>sel([...I.files])); I.addEventListener('change',()=>sel([...I.files]));
function sel(fs){ function sel(fs){
if(!fs.length) return; if(!fs.length) return;
// Pixels cannot tell a loose artwork from a finished sheet. Images default
// to the artwork model unless the customer explicitly declares a ready sheet.
// Update this same editor in place; do not reopen it or discard uploaded files.
if(ehFolha() && !$('imagemComoFolha').checked && fs.some(f=>ACEITA.avulsa.test(f.name))){
if(fs.some(f=>!ACEITA.avulsa.test(f.name))){
$('recusa').style.display='block';
$('recusa').textContent='Envie artes avulsas e folhas prontas separadamente. Para uma imagem já montada, marque a opção de folha pronta.';
return;
}
modo=modo==='uvfile'?'uv':'avulsa';
pintaModo(); pintaCaminhos();
}
const regra = ehFolha() ? ACEITA.folha : ACEITA.avulsa; const regra = ehFolha() ? ACEITA.folha : ACEITA.avulsa;
const fora = fs.filter(f=>!regra.test(f.name)); const fora = fs.filter(f=>!regra.test(f.name));
fs = fs.filter(f=>regra.test(f.name)); fs = fs.filter(f=>regra.test(f.name));
@@ -1545,6 +1559,7 @@ function sel(fs){
pintaCaminhos(); pintaCaminhos();
} }
const novas=fs.map(f=>({f, med:null, rep:1, m:0, semAnalise:null})); const novas=fs.map(f=>({f, med:null, rep:1, m:0, semAnalise:null}));
$('imagemComoFolha').checked=true;
folhas=folhas.concat(novas); // soma, não substitui folhas=folhas.concat(novas); // soma, não substitui
pintaFolha(); pintaFolha();
novas.forEach(x=>{ novas.forEach(x=>{
@@ -1867,6 +1882,8 @@ function medirFolha(file){
function pintaFolha(){ function pintaFolha(){
const L=larguraFilme(); const L=larguraFilme();
$('imagemComoFolha').disabled=folhas.length>0;
if(!folhas.length) $('imagemComoFolha').checked=false;
if(!folhas.length){ $('lista').innerHTML=''; agendaAvaliacao(); previaAoVivo(); return; } if(!folhas.length){ $('lista').innerHTML=''; agendaAvaliacao(); previaAoVivo(); return; }
$('lista').innerHTML = folhas.map((x,i)=>{ $('lista').innerHTML = folhas.map((x,i)=>{
@@ -2019,10 +2036,9 @@ function pintaArtes(){
let barra='', dica='', calc='<span>informe a largura para ver a qualidade</span>'; let barra='', dica='', calc='<span>informe a largura para ver a qualidade</span>';
if(a.cm>0){ if(a.cm>0){
const dpi=Math.round(a.px/(a.cm/2.54)), n=Math.max(6,Math.min(100,Math.round(dpi/300*100))); const dpi=Math.round(a.px/(a.cm/2.54)), n=Math.max(6,Math.min(100,Math.round(dpi/300*100)));
const alt=a.prop? a.cm*a.prop : null;
barra='<div class="qb2"><i class="'+cls(n)+'" style="width:'+n+'%"></i></div>'; barra='<div class="qb2"><i class="'+cls(n)+'" style="width:'+n+'%"></i></div>';
calc='<span class="'+cls(n)+'"><b>'+n+'%</b> · '+dpi+' DPI</span><span>'+ calc='<span class="'+cls(n)+'"><b>'+n+'%</b> · '+dpi+' DPI</span><span>'+
(a.q>1? a.q+' × ':'')+(a.cm*a.q/100).toFixed(2).replace('.',',')+' m no total</span>'; a.q+' × '+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</span>';
const sug=sugestao(a); const sug=sugestao(a);
dica = sug dica = sug
? '<div class="dica"><b>Reduzindo para '+n1(sug.alvo)+' cm você economiza '+ ? '<div class="dica"><b>Reduzindo para '+n1(sug.alvo)+' cm você economiza '+
@@ -2051,7 +2067,13 @@ function pintaArtes(){
}).join(''); }).join('');
$('lista').querySelectorAll('[data-rm]').forEach(b=>b.addEventListener('click',()=>{artes.splice(+b.dataset.rm,1);pintaArtes();})); $('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-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=+i.value||1;pintaArtes();})); $('lista').querySelectorAll('[data-q]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.q].q=Math.max(1,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.floor(+i.value||1));
previaAoVivo();
}));
$('lista').querySelectorAll('[data-usar]').forEach(b=>b.addEventListener('click',()=>{ $('lista').querySelectorAll('[data-usar]').forEach(b=>b.addEventListener('click',()=>{
artes[+b.dataset.usar].cm=+b.dataset.cm; pintaArtes(); artes[+b.dataset.usar].cm=+b.dataset.cm; pintaArtes();
})); }));
@@ -2348,7 +2370,7 @@ function dilatar(bruto,cw,ch,cheias){
} }
return {cw:cw2,ch:ch2,bits:out,cheias}; // cheias = área de arte, sem a folga return {cw:cw2,ch:ch2,bits:out,cheias}; // cheias = área de arte, sem a folga
} }
function mascara(img,w,h,rot){ 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 cw=Math.max(1,Math.ceil(w/CEL)), ch=Math.max(1,Math.ceil(h/CEL));
const cv=document.createElement('canvas'); const cv=document.createElement('canvas');
cv.width=cw; cv.height=ch; cv.width=cw; cv.height=ch;
@@ -2359,8 +2381,11 @@ function mascara(img,w,h,rot){
return dilatar(bruto,cw,ch,cheias); return dilatar(bruto,cw,ch,cheias);
} }
ctx.save(); ctx.save();
if(rot===90){ ctx.translate(cw,0); ctx.rotate(Math.PI/2); ctx.drawImage(img,0,0,ch,cw); } ctx.translate(cw/2,ch/2);
else ctx.drawImage(img,0,0,cw,ch); 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(); ctx.restore();
const d=ctx.getImageData(0,0,cw,ch).data; const d=ctx.getImageData(0,0,cw,ch).data;
bruto=new Uint8Array(cw*ch); cheias=0; bruto=new Uint8Array(cw*ch); cheias=0;
@@ -2406,7 +2431,8 @@ function encaixar(pecas,W){
oris.forEach(o=>{ oris.forEach(o=>{
if(o.w>W) return; if(o.w>W) return;
// a máscara segue a medida da orientação escolhida · girada, largura e altura trocam // 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); 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]); 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); }); 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)); const passoX=Math.max(1,Math.round(cols/34));
@@ -2436,7 +2462,7 @@ function encaixar(pecas,W){
if(!melhor){ if(!melhor){
const k=Math.min(1, W/p.w); // cabe na largura, nem que seja reduzindo const k=Math.min(1, W/p.w); // cabe na largura, nem que seja reduzindo
const w=p.w*k, h=p.h*k; const w=p.w*k, h=p.h*k;
const m=mascara(p.img,w,h,0); const m=mascara(p.img,w,h,p.a?.giro||0,p.a?.esp);
melhor={cx:0,cy:linhas,m,x:0,y:linhas*CEL,w,h,rot:0,ref:p}; melhor={cx:0,cy:linhas,m,x:0,y:linhas*CEL,w,h,rot:0,ref:p};
} }
// a folga só existe entre peças, não é folha cobrada // a folga só existe entre peças, não é folha cobrada
@@ -2449,8 +2475,9 @@ function encaixar(pecas,W){
} }
function desenhaMontagem(area,lista,mts,W,fora,aoTerminar){ function desenhaMontagem(area,lista,mts,W,fora,aoTerminar){
const PX_CM=W/larguraFilme(); const versao=area.montagemVersao=(area.montagemVersao||0)+1;
const itens=[]; lista.forEach(a=>{ for(let k=0;k<(a.q||1);k++) itens.push(a); }); const filme=larguraFilme(), PX_CM=W/filme;
const itens=lista.map(a=>({...a}));
if(fora) fora.innerHTML=''; if(fora) fora.innerHTML='';
if(!itens.length){ if(!itens.length){
area.innerHTML='<div class="semmont"><b>A montagem aparece aqui</b>'+ area.innerHTML='<div class="semmont"><b>A montagem aparece aqui</b>'+
@@ -2459,13 +2486,15 @@ function desenhaMontagem(area,lista,mts,W,fora,aoTerminar){
return; return;
} }
Promise.all(itens.map(a=>carregarImagem(a.f).then(img=>({a,img})))).then(cargas=>{ Promise.all(itens.map(a=>carregarImagem(a.f).then(img=>({a,img})))).then(imagens=>{
if(area.montagemVersao!==versao) return;
const cargas=imagens.flatMap(c=>Array.from({length:c.a.q||1},()=>({...c})));
cargas.forEach(c=>{ cargas.forEach(c=>{
c.w = c.a.cm; // o motor trabalha em centímetros c.w = c.a.cm; // o motor trabalha em centímetros
c.h = c.w * (c.img ? (c.a.giro? c.img.width/c.img.height : c.img.height/c.img.width) : 1); c.h = c.w * (c.img ? (c.a.giro? c.img.width/c.img.height : c.img.height/c.img.width) : 1);
c.travado = !!c.a.giro; // giro do cliente manda no encaixe c.travado = !!c.a.giro; // giro do cliente manda no encaixe
}); });
const enc=encaixar(cargas,larguraFilme()); const enc=encaixar(cargas,filme);
const alturaCm=enc.altura; 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 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 altura=alturaCm*PX_CM;
@@ -2483,7 +2512,7 @@ function desenhaMontagem(area,lista,mts,W,fora,aoTerminar){
ctx.save(); ctx.save();
const giro = (p.rot===90?90:0) + (c.a && c.a.giro? 90:0); 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.translate(p.x+p.w/2, p.y+p.h/2);
if(giro%180===90) ctx.rotate(Math.PI/2); ctx.rotate(giro*Math.PI/180);
if(c.a && c.a.esp) ctx.scale(-1,1); 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; 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); if(c.img) ctx.drawImage(c.img,-dw/2,-dh/2,dw,dh);
@@ -2494,7 +2523,7 @@ function desenhaMontagem(area,lista,mts,W,fora,aoTerminar){
ctx.fillText(c.a.f.name.split('.').pop().toUpperCase(),p.x+p.w/2,p.y+p.h/2+4); ctx.fillText(c.a.f.name.split('.').pop().toUpperCase(),p.x+p.w/2,p.y+p.h/2+4);
} }
}); });
molduraFolha(ctx,W,usado,larguraFilme(),alturaCm/100); molduraFolha(ctx,W,usado,filme,alturaCm/100);
marcaDagua(ctx,W,cv.height); marcaDagua(ctx,W,cv.height);
area.innerHTML=''; area.appendChild(cv); area.innerHTML=''; area.appendChild(cv);
@@ -2503,7 +2532,7 @@ function desenhaMontagem(area,lista,mts,W,fora,aoTerminar){
const areaArteCm=enc.pos.reduce((s,p)=> const areaArteCm=enc.pos.reduce((s,p)=>
s + (p.m && p.m.cheias!=null ? p.m.cheias*CEL*CEL : p.w*p.h), 0); 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, const uso=Math.max(1,Math.min(100,
Math.round(areaArteCm/(larguraFilme()*Math.max(alturaCm,0.1))*100))); Math.round(areaArteCm/(filme*Math.max(alturaCm,0.1))*100)));
const el=document.createElement('div'); const el=document.createElement('div');
el.className = uso>=70? 'usoBom' : 'usoRuim'; el.className = uso>=70? 'usoBom' : 'usoRuim';
el.innerHTML = (uso>=70 el.innerHTML = (uso>=70
@@ -2522,6 +2551,8 @@ function previaArtes(){ desenhaMontagem($('pArea'), artes, metros, 560); }
// montagem ao vivo, ao lado da fila · redesenha 140 ms depois da ultima mexida // montagem ao vivo, ao lado da fila · redesenha 140 ms depois da ultima mexida
let tMont=null; let tMont=null;
function previaAoVivo(){ function previaAoVivo(){
clearTimeout(tMont);
$('vArea').montagemVersao=($('vArea').montagemVersao||0)+1;
if(ehFolha()){ if(ehFolha()){
$('vUso').innerHTML=''; $('vUso').innerHTML='';
const tot=folhaTotalM(); const tot=folhaTotalM();
@@ -2551,7 +2582,6 @@ function previaAoVivo(){
} }
return; return;
} }
clearTimeout(tMont);
tMont=setTimeout(()=>{ tMont=setTimeout(()=>{
const prontas=artes.filter(a=>a.cm>0); const prontas=artes.filter(a=>a.cm>0);
const W=Math.round(300*ZOOMS[zoomI]); const W=Math.round(300*ZOOMS[zoomI]);

View File

@@ -0,0 +1,187 @@
// Isolated upload/editor regression: no API, database, storage, or Docker services.
// Run: node local/artwork_browser_test.mjs
// Optional: ARTWORK_FILE=/absolute/path/to/image.jpg to also inspect a real image.
import assert from 'node:assert/strict';
import {spawn} from 'node:child_process';
import {createServer} from 'node:http';
import {createHash} from 'node:crypto';
import {mkdtemp, readFile, writeFile, mkdir} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {resolve} from 'node:path';
const html=await readFile('dtf-site.html','utf8');
const hashes=[...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)]
.filter(m=>! /\bsrc\s*=/i.test(m[1]))
.map(m=>"'sha256-"+createHash('sha256').update(m[2]).digest('base64')+"'");
const server=createServer(async(req,res)=>{
if(req.url.startsWith('/api/')){
res.setHeader('Content-Type','application/json');
res.end(JSON.stringify(req.url==='/api/session'?{cart_scope:'isolated-artwork-test'}:{customer:null}));
return;
}
if(req.url==='/'){
res.setHeader('Content-Type','text/html');
res.setHeader('Content-Security-Policy',`default-src 'self'; script-src 'self' ${hashes.join(' ')}; script-src-attr 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'`);
res.end(html);return;
}
if(['/privacy.js','/upload.js','/checkout.js','/cart.js'].includes(req.url)){
res.setHeader('Content-Type','text/javascript');
res.end(await readFile('local/static'+req.url));return;
}
res.writeHead(404);res.end();
});
await new Promise(r=>server.listen(0,'127.0.0.1',r));
const profile=await mkdtemp(tmpdir()+'/dtf-artwork-');
const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
],{stdio:['ignore','ignore','pipe']});
let stderr='',ws,next=0;
chrome.stderr.on('data',b=>stderr+=b);
const pending=new Map(),errors=[];
const pause=ms=>new Promise(r=>setTimeout(r,ms));
async function waitFor(fn,label){
const end=Date.now()+15000;
while(Date.now()<end){if(await fn())return;await pause(50);}
throw new Error('Timeout: '+label);
}
function call(method,params={}){
return new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});ws.send(JSON.stringify({id,method,params}));});
}
async function evaluate(expression){
const r=await call('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});
if(r.exceptionDetails)throw new Error(JSON.stringify(r.exceptionDetails));
return r.result.value;
}
const click=selector=>evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`);
async function fill(selector,value){
await evaluate(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});el.value=${JSON.stringify(String(value))};el.dispatchEvent(new Event('input',{bubbles:true}));})()`);
}
async function packed(count){
await waitFor(()=>evaluate(`window.lastLayout?.pos.length===${count} && !!document.querySelector('#vArea canvas') && Math.abs(metros-window.lastLayout.altura/100)<0.0001`),'packed canvas and matching metres');
return evaluate(`({height:lastLayout.altura, pos:lastLayout.pos, metres:metros, billed:cobrar(metros), mode:modo, heading:$('montcabTit').textContent})`);
}
async function screenshot(name){
// Finish editing as a customer does when leaving the field, refreshing the
// item's quality/details as well as the already-live canvas.
await evaluate(`$('lista').querySelector('[data-cm]')?.dispatchEvent(new Event('change',{bubbles:true}))`);
await waitFor(()=>evaluate(`!!$('vArea').querySelector('canvas') && Math.abs(metros-montagemCm/100)<0.0001`),'screenshot layout');
await evaluate(`$('foco').scrollIntoView({behavior:'instant',block:'start'})`);
const r=await call('Page.captureScreenshot',{format:'png'});
await mkdir('output/local',{recursive:true});
await writeFile('output/local/'+name+'.png',Buffer.from(r.data,'base64'));
}
try{
let port;
await waitFor(async()=>{try{port=Number((await readFile(profile+'/DevToolsActivePort','utf8')).split('\n')[0]);return true;}catch{return false;}},'Chrome startup');
const tab=await(await fetch(`http://127.0.0.1:${port}/json/new?about:blank`,{method:'PUT'})).json();
ws=new WebSocket(tab.webSocketDebuggerUrl);
await new Promise((r,j)=>{ws.onopen=r;ws.onerror=j;});
ws.onmessage=event=>{
const m=JSON.parse(event.data);
if(m.id){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(m.error):p.resolve(m.result);}
else if(m.method==='Runtime.exceptionThrown')errors.push(m.params.exceptionDetails);
};
await call('Runtime.enable');await call('Page.enable');
await call('Emulation.setDeviceMetricsOverride',{width:1440,height:1100,deviceScaleFactor:1,mobile:false});
await call('Page.navigate',{url:`http://127.0.0.1:${server.address().port}/`});
await waitFor(()=>evaluate(`typeof sel==='function' && typeof AUTO!=='undefined'`),'Site scripts');
// Observe the actual engine, without replacing its placement or rendering.
await evaluate(`(()=>{
const original=encaixar;
encaixar=(pieces,width)=>{const result=original(pieces,width);
if(pieces.some(p=>p.a))window.lastLayout={altura:result.altura,pos:result.pos.map(p=>({x:p.x,y:p.y,w:p.w,h:p.h,rot:p.rot}))};
return result;};
window.sendImage=async(type='image/jpeg',drop=false)=>{
const c=document.createElement('canvas');c.width=200;c.height=400;
const ctx=c.getContext('2d');ctx.fillStyle='white';ctx.fillRect(0,0,200,400);
ctx.fillStyle='#222';ctx.fillRect(85,20,20,360);ctx.fillRect(25,100,140,20);
ctx.fillStyle='#e65c00';ctx.fillRect(130,280,45,80);
const blob=await new Promise(r=>c.toBlob(r,type));
const file=new File([blob],type==='image/jpeg'?'artwork.jpg':'artwork.png',{type});
const dt=new DataTransfer();dt.items.add(file);
if(drop)$('zona').dispatchEvent(new DragEvent('drop',{dataTransfer:dt,bubbles:true,cancelable:true}));
else{$('inp').files=dt.files;$('inp').dispatchEvent(new Event('change',{bubbles:true}));}
};
})()`);
// Reproduce the reported entry path, with no navigation workaround.
await click('[data-modo="file"]');
await evaluate('sendImage()');
await waitFor(()=>evaluate(`artes.length===1 && !!artes[0].src`),'JPG artwork model');
assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,width:artes[0].cm,quantity:artes[0].q})`),{mode:'avulsa',sheets:0,width:0,quantity:1});
await fill('[data-cm]',20);await fill('[data-q]',6);
let layout=await packed(6);
assert.equal(layout.heading,'Montagem ao vivo');assert.equal(layout.height,121);
assert.equal(layout.billed,1.3);
assert.deepEqual(layout.pos.map(p=>[p.x,p.y]),[[0,0],[20.5,0],[0,40.5],[20.5,40.5],[0,81],[20.5,81]]);
assert.ok(layout.pos.every(p=>p.h/p.w===2 && p.x+p.w<=57));
await screenshot('artwork-packed');
await fill('[data-cm]',15);
await waitFor(()=>evaluate('lastLayout?.altura===60.5 && metros===0.605'),'width updates without blur');
await fill('[data-q]',9);layout=await packed(9);assert.equal(layout.height,91);
await click('[data-giro]');
await waitFor(()=>evaluate('lastLayout?.altura===23.5 && metros===0.235'),'rotation updates packing');
const beforeMirror=await evaluate(`$('vArea').querySelector('canvas').toDataURL()`);
await click('[data-esp]');
await waitFor(()=>evaluate(`$('vArea').querySelector('canvas').toDataURL()!==${JSON.stringify(beforeMirror)}`),'mirror updates pixels');
assert.equal(await evaluate('metros'),0.235);
// A transparent asymmetric image exposes masks that ignore user transforms.
// Its top-left quarter becomes bottom-right after a mirror and 90° turn.
assert.equal(await evaluate(`(()=>{
const image=document.createElement('canvas');image.width=100;image.height=200;
image.getContext('2d').fillRect(0,0,50,100);
const result=encaixar([{w:20,h:10,img:image,a:{giro:90,esp:true},travado:true}],57);
const m=result.pos[0].m;
return !m.bits[2*m.cw+2] && !m.bits[2*m.cw+30] && !!m.bits[15*m.cw+30];
})()`),true);
// Explicit finished-sheet images retain their separate dimensions and price.
await click('#bVoltar');await click('[data-modo="file"]');
await click('#imagemComoFolha');await evaluate('sendImage()');
await waitFor(()=>evaluate('folhas.length===1 && !!folhas[0].previewSrc && metros===1.14'),'ready-sheet image');
assert.deepEqual(await evaluate(`({mode:modo,art:artes.length,title:$('montcabTit').textContent,rate:precoBase(100)})`),{mode:'file',art:0,title:'Sua folha',rate:14.9});
assert.equal(await evaluate(`document.querySelectorAll('[data-cm]').length`),0);
await evaluate(`(()=>{const el=$('lista').querySelector('[data-repf]');el.value=2;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await waitFor(()=>evaluate('metros===2.28'),'ready-sheet repetitions');
assert.equal(await evaluate(`document.querySelectorAll('.folhaPrevia').length`),1);
// Keep manual ready-sheet uploads intact and refuse ambiguous mixed batches.
await click('#bVoltar');await click('[data-modo="file"]');
await evaluate(`sel([new File(['sheet'],'sheet.cdr'),new File(['image'],'art.png')])`);
assert.deepEqual(await evaluate('[folhas.length,artes.length]'),[0,0]);
await evaluate(`sel([new File(['sheet'],'sheet.cdr')])`);
await waitFor(()=>evaluate(`!!$('lista').querySelector('[data-comp]')`),'manual ready sheet');
await evaluate(`(()=>{const el=$('lista').querySelector('[data-comp]');el.value=1.01;el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await waitFor(()=>evaluate('itemAtual?.total===21.89'),'unchanged manual pricing');
// PNG drag/drop follows exactly the same artwork path; UV stays UV.
for(const [mode,expected] of [['file','avulsa'],['avulsa','avulsa'],['uvfile','uv']]){
await click('#bVoltar');await click('[data-modo="'+mode+'"]');
await evaluate(`sendImage('image/png',true)`);
await waitFor(()=>evaluate('artes.length===1 && !!artes[0].src'),'PNG drop');
assert.equal(await evaluate('modo'),expected);
await fill('[data-cm]',10);await fill('[data-q]',4);await packed(4);
}
// An older image load must not overwrite a newer edit, even before debounce.
await evaluate(`(()=>{window.realLoad=carregarImagem;window.delayed=[];
carregarImagem=f=>new Promise(resolve=>delayed.push(()=>realLoad(f).then(resolve)));
})()`);
await fill('[data-q]',5);
await waitFor(()=>evaluate('delayed.length===1'),'delayed preview');
await fill('[data-q]',7);
await evaluate(`carregarImagem=realLoad;delayed[0]()`);
await packed(7);
// Inspect the supplied local artwork, when requested, using the real file input.
if(process.env.ARTWORK_FILE){
await click('#bVoltar');await click('[data-modo="file"]');
const doc=await call('DOM.getDocument');
const input=await call('DOM.querySelector',{nodeId:doc.root.nodeId,selector:'#inp'});
await call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve(process.env.ARTWORK_FILE)]});
await waitFor(()=>evaluate('artes.length===1 && !!artes[0].src'),'supplied artwork');
await fill('[data-cm]',20);await fill('[data-q]',6);layout=await packed(6);
assert.ok(new Set(layout.pos.map(p=>p.x)).size>1);
assert.ok(new Set(layout.pos.map(p=>p.y)).size>1);
await screenshot('artwork-supplied');
console.log('Supplied image:',JSON.stringify(layout));
}
assert.deepEqual(errors,[]);
console.log('PASS: JPG/PNG upload and drop, packed copies, 5 mm gaps, aspect ratio, live width/quantity/rotation/mirror, metres, ready-sheet pricing/repeats, UV, stale renders, strict inline CSP.');
}catch(error){console.error(error);if(stderr)console.error(stderr.slice(-1200));process.exitCode=1;}
finally{ws?.close();chrome.kill();server.closeAllConnections();await new Promise(r=>server.close(r));}