diff --git a/ROADMAP.md b/ROADMAP.md
index bba49b4..1e53066 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -410,10 +410,11 @@ charges. Fix as part of 1.1.
`smoke_test` failed on `/session`, blocking the release. Browser tests skip with
a warning when the runner has no Chrome — **install `google-chrome-stable` on the
runner (or set `CHROME_BIN`) to make them gate as well.**
-- `[ ]` 5.2 — Remove or archive the dead prototypes `(F30)`: `portal/`, `kanban/`,
- `agente/`, root `schema.sql` (~1,500 lines describing an abandoned model). Several
- expose unauthenticated endpoints taking the acting user from the request body
- (`/api/puxar`, `/api/devolver`).
+- `[x]` 5.2 — `portal/`, `kanban/`, `agente/`, the root `schema.sql` and
+ `.env.exemplo` removed: 2,283 lines implementing a model this system abandoned,
+ referenced by nothing, with several endpoints taking the acting user from the
+ request body. The documents describing them are archived under `docs/historico/`
+ with a header saying they are background, not instructions.
- `[x]` 5.3 — Root `requirements.txt` deleted. It pinned by wildcard, listed
packages the system does not use, and sat next to the hash-locked
`local/requirements.lock` inviting the wrong one to be installed. Only historical
@@ -427,9 +428,16 @@ charges. Fix as part of 1.1.
`SECURITY_REPORT.md` describe a MinIO localhost stack, an API with "no external
network route", a `operator` / `local-operator-only` login the email-validated
model rejects, and a release gate — none match the current tree.
-- `[ ]` 5.6 — `dtf-site.html` is 2,889 lines with commercial rules, the packing
- engine, PDF analysis, UI and checkout inline `(F29)`. Split at least the pricing
- table and the packer so 3.2 has somewhere to land.
+- `[x]` 5.6 — The Site's 1,575-line inline script is now nine files under
+ `local/static/`, cut at the author's own section boundaries so no function was
+ split: config, modes, upload, sheet analysis, PDF, quality, packing, cart, flow.
+ `dtf-site.html` is 1,394 lines of markup and style. The extraction was verified
+ byte-identical before the tags were swapped in, and they load as classic scripts
+ in the original order, so evaluation semantics are unchanged.
+
+ A consequence worth having: with no inline script anywhere, the policy needs no
+ hash allowlist and is now simply `script-src 'self'`. `site-packing.js` is also
+ where a server-side packer (3.2) has to agree, which was the point of splitting.
- `[ ]` 5.7 — Commercial rules duplicated between `FAIXAS` (JS) and `TIERS` (Python)
`(F26)`. `test_pricing` guards parity; generate one from the other instead.
- `[ ]` 5.11 — Nothing tests the schema against an empty database. The 4.1 indexes
diff --git a/Relatorio-Semana-1-DTF.docx b/Relatorio-Semana-1-DTF.docx
new file mode 100644
index 0000000..c8e646d
Binary files /dev/null and b/Relatorio-Semana-1-DTF.docx differ
diff --git a/Relatorio-Semana-1-DTF.pdf b/Relatorio-Semana-1-DTF.pdf
new file mode 100644
index 0000000..ef04411
Binary files /dev/null and b/Relatorio-Semana-1-DTF.pdf differ
diff --git a/dtf-site.html b/dtf-site.html
index 10836d0..6fa2503 100644
--- a/dtf-site.html
+++ b/dtf-site.html
@@ -1373,1581 +1373,18 @@ footer a:hover{color:var(--laranja2)}
-
+
+
+
+
+
+
+
+
+
+
diff --git a/imagem-teste.jpg b/imagem-teste.jpg
new file mode 100644
index 0000000..35a13ea
Binary files /dev/null and b/imagem-teste.jpg differ
diff --git a/local/artwork_browser_test.mjs b/local/artwork_browser_test.mjs
index b40a1f2..f772acc 100644
--- a/local/artwork_browser_test.mjs
+++ b/local/artwork_browser_test.mjs
@@ -24,9 +24,14 @@ const server=createServer(async(req,res)=>{
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;
+ // Serve any script the page asks for, resolved inside local/static, rather than
+ // a hardcoded list: the Site's behaviour is split across several files and a
+ // list would silently 404 the next one added.
+ if(/^\/[\w.-]+\.js$/.test(req.url)){
+ try{
+ const body=await readFile(resolve('local/static'+req.url));
+ res.setHeader('Content-Type','text/javascript');res.end(body);return;
+ }catch{ res.writeHead(404);res.end();return; }
}
res.writeHead(404);res.end();
});
diff --git a/local/compile_web.py b/local/compile_web.py
index a0c9340..f4d3be9 100644
--- a/local/compile_web.py
+++ b/local/compile_web.py
@@ -1,15 +1,26 @@
-"""Hash only trusted source inline scripts at image build time for strict CSP."""
+"""Compile the gateway configuration: hash any trusted inline script for the CSP.
+
+The Site's behaviour now lives in separate files, so normally there is nothing to
+hash and the policy is simply script-src 'self' — no allowlist to get wrong. The
+hashing stays because an inline script added later must not silently need
+'unsafe-inline'; it is hashed automatically instead.
+"""
import base64
import hashlib
import os
from pathlib import Path
import re
-root=Path('/build')
-hashes=[]
-for html in [root/'dtf-site.html',*(root/'local/static').glob('*.html')]:
- for attributes,body in re.findall(r'',html.read_text(),re.S|re.I):
- if not re.search(r'\bsrc\s*=',attributes,re.I):
- hashes.append("'sha256-"+base64.b64encode(hashlib.sha256(body.encode()).digest()).decode()+"'")
-template=Path(os.environ.get('NGINX_TEMPLATE', root/'local/nginx.conf.template')).read_text()
-(root/'default.conf.template').write_text(template.replace('@SCRIPT_HASHES@',' '.join(hashes)))
+root = Path('/build')
+hashes = []
+for html in [root / 'dtf-site.html', *(root / 'local/static').glob('*.html')]:
+ for attributes, script in re.findall(r'', html.read_text(), re.S | re.I):
+ if not re.search(r'\bsrc\s*=', attributes, re.I) and script.strip():
+ hashes.append("'sha256-" + base64.b64encode(hashlib.sha256(script.encode()).digest()).decode() + "'")
+
+template = Path(os.environ.get('NGINX_TEMPLATE', root / 'local/nginx.conf.template')).read_text()
+rendered = template.replace('@SCRIPT_HASHES@', ' '.join(hashes))
+# Collapse the gap an empty hash list leaves behind, so the policy reads cleanly.
+rendered = re.sub(r"(script-src 'self')\s+;", r'\1;', rendered)
+(root / 'default.conf.template').write_text(rendered)
+print(f'CSP script-src: {len(hashes)} inline hash(es)')
diff --git a/local/security_test.py b/local/security_test.py
index a4e5377..e3b2055 100644
--- a/local/security_test.py
+++ b/local/security_test.py
@@ -21,7 +21,14 @@ def run():
headers=raw('/',200)
policy=headers['Content-Security-Policy']
assert "script-src-attr 'none'" in policy and "frame-ancestors 'none'" in policy
- assert "'sha256-" in policy and "object-src 'none'" in policy
+ # The Site has no inline script, so the policy needs no hash allowlist at all.
+ # Assert the property that matters rather than the mechanism: nothing inline
+ # executes, and any hash that does appear was added deliberately at build time.
+ script_src = next(d.strip() for d in policy.split(';') if d.strip().startswith('script-src '))
+ assert "'unsafe-inline'" not in script_src and "'unsafe-eval'" not in script_src, script_src
+ assert script_src == "script-src 'self'", script_src
+ assert "object-src 'none'" in policy
+ assert 'cdnjs' not in policy, 'pdf.js is vendored; no CDN belongs in the policy'
raw('/api/health',400,{'Host':'attacker.invalid'})
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'https://attacker.invalid'},b'{}')
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'http://localhost:9999'},b'{}')
diff --git a/local/static/site-cart.js b/local/static/site-cart.js
new file mode 100644
index 0000000..7b9e9bf
--- /dev/null
+++ b/local/static/site-cart.js
@@ -0,0 +1,77 @@
+/* Site DTF — Cart totals and the order summary.
+ Extracted verbatim from the single inline script in dtf-site.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. */
+// ── carrinho
+let itemAtual=null;
+function limpaPaineis(){
+ ['qual','prev'].forEach(id=>$(id).classList.remove('on'));
+ if(!pedido.length){ $('carr').classList.remove('on'); }
+}
+
+// O carrinho junta itens de produtos diferentes e cobra uma vez só.
+function pintaPedido(){
+ $('itens').innerHTML = pedido.map((it,i)=>
+ '
Montagem da folha'+
+ 'inclusa · '+rs(MONTAGEM[modo])+'/m
';
+ l += '
Metragem'+(sobra>0.001?' · arredondada para 10 cm':'')+
+ ''+fmtM(cob)+' m
';
+ l += '
Preço final do metro'+rs(unit)+'
'+
+ '
Revisão e reencaixegrátis
';
+ $('carrLin').innerHTML=l;
+ $('atual').style.display='';
+ itemAtual={modo, tit:MODOS[modo].tit, nota, metros, cob, unit, total:tot,
+ localFiles:(ehFolha()?folhas:artes).map(x=>x.f),
+ desc:fmtM(cob)+' m · '+rs(unit)+'/m · nota '+nota};
+ pintaPedido();
+ previa();
+ if(nota<90){
+ const piso=pisoEscada(), ganho=(base-piso)*cob;
+ if(ganho>0.5) qm('Dá para chegar ao melhor preço. Corrigindo os pontos e chegando a 90, '+
+ 'o metro cai para '+rs(piso)+' — são '+rs(ganho)+' a menos neste pedido.','var(--verde)');
+ }
+}
+function qm(t,c){ const m=$('qmsg'); m.style.display='block'; m.style.color=c||'var(--verde)'; m.innerHTML=t; }
+
diff --git a/local/static/site-config.js b/local/static/site-config.js
new file mode 100644
index 0000000..bcc9ec8
--- /dev/null
+++ b/local/static/site-config.js
@@ -0,0 +1,108 @@
+/* Site DTF — Product tables, commercial ladders, shared state and field validation.
+ Extracted verbatim from the single inline script in dtf-site.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. */
+
+const $=id=>document.getElementById(id);
+const rs=v=>'R$ '+v.toFixed(2).replace('.',',');
+const fmt=b=>b<1048576?(b/1024).toFixed(0)+' KB':(b/1048576).toFixed(1).replace('.',',')+' MB';
+
+// ── modos
+const MODOS={
+ file: {tit:'Arquivo por metro', lg:'DTF têxtil · 57 cm', larg:57,
+ preco:'tabela R$ 19,90/m · até R$ 14,90', multi:true, folha:true,
+ sub:'Sua folha montada. Revisamos, corrigimos e reencaixamos antes de imprimir.',
+ zt:'Arraste suas folhas montadas',
+ zs:'uma ou várias · PNG, JPG, TIFF ou PDF · PSD, AI e CDR passam por tratamento'},
+ avulsa:{tit:'Artes avulsas', lg:'DTF têxtil · 57 cm', larg:57,
+ preco:'tabela R$ 29,90/m · até R$ 24,90', multi:true,
+ sub:'Cada arte num arquivo. Montamos a folha com o melhor encaixe.',
+ zt:'Arraste suas artes aqui',
+ zs:'PNG ou JPG com fundo transparente · informe a largura de cada uma'},
+ uvfile:{tit:'Arquivo por metro · UV', lg:'Adesivo UV · 28,5 cm', larg:28.5,
+ preco:'tabela R$ 85,90/m · até R$ 69,90', multi:true, folha:true,
+ sub:'Sua folha de UV montada. Revisamos, corrigimos e reencaixamos antes de imprimir.',
+ zt:'Arraste suas folhas de UV',
+ zs:'uma ou várias · PNG, JPG, TIFF ou PDF · PSD, AI e CDR passam por tratamento'},
+ uv: {tit:'Artes avulsas · UV', lg:'Adesivo UV · 28,5 cm', larg:28.5,
+ preco:'tabela R$ 99,90/m · até R$ 83,90', multi:true,
+ sub:'Cada arte de UV num arquivo. Montamos a folha com o melhor encaixe.',
+ zt:'Arraste suas artes de UV',
+ zs:'PNG ou JPG com fundo transparente · informe a largura de cada arte'}
+};
+// Quatro escadas. A de artes avulsas e a da folha pronta mais a montagem:
+// R$ 10,00 no têxtil, R$ 14,00 no UV, constante em todas as faixas.
+const FAIXAS={
+ file: [[90,14.90],[75,16.20],[60,17.50],[40,18.70],[0,19.90]],
+ avulsa:[[90,24.90],[75,26.20],[60,27.50],[40,28.70],[0,29.90]],
+ uvfile:[[90,69.90],[75,73.90],[60,77.90],[40,81.90],[0,85.90]],
+ uv: [[90,83.90],[75,87.90],[60,91.90],[40,95.90],[0,99.90]]
+};
+const TABELA={file:19.90, avulsa:29.90, uvfile:85.90, uv:99.90}; // teto de cada escada
+const MONTAGEM={avulsa:10.00, uv:14.00}; // o que a montagem soma sobre a folha pronta
+const ehFolha=m=>!!MODOS[m||modo].folha;
+const pisoEscada=m=>FAIXAS[m||modo][0][1];
+const MINIMO_M=1, GAP_CM=0.5, RODAPE_CM=5;
+const RENDERIZA=/[.](png|jpe?g|webp|gif|bmp)$/i;
+// Folha pronta: dá para medir PNG, JPG e PDF. PSD, AI, TIFF e CDR entram, mas
+// o navegador não abre — o cliente informa o comprimento e o arquivo passa por
+// tratamento antes de imprimir.
+const ACEITA={
+ folha: /[.](png|jpe?g|tiff?|pdf|psd|psb|ai|cdr)$/i,
+ avulsa: /[.](png|jpe?g|webp)$/i
+};
+const MEDE=/[.](png|jpe?g|webp|gif|bmp|pdf)$/i;
+const DPI_RECUSA=96, DPI_AVISA=300; // régua dos designers · 02/09/2026
+// A especificação do PDF limita a página a 14.400 unidades — 200 pol, 508 cm.
+// Passar disso só é válido com UserUnit (PDF 1.6+), que a maioria dos programas
+// não escreve: o arquivo sai fora do padrão e só o gerador lê direito.
+const PDF_MAX_PT=14400, PT_CM=2.54/72;
+const extDe=n=>(n.split('.').pop()||'').toUpperCase();
+let CLIENTE='SEU NOME AQUI', PEDIDO='000000';
+
+let modo=null, artes=[], recemChegada=null, metros=0, nota=0, reencaixado=false;
+let folhas=[]; // [{f, med, rep, m}] · o cliente pode subir várias
+const folhaTotalM=()=>folhas.reduce((t,x)=>t+(x.m||0)*(x.rep||1),0);
+// O cliente declara o que está mandando escolhendo o produto, em vez de o site
+// adivinhar pelo formato do arquivo. A declaração de folha montada é conferida na
+// medição: uma imagem sem pixel para ocupar a largura do filme não é uma folha.
+// Cada modo por metro tem o seu par de artes avulsas, e o seletor anda nos dois sentidos.
+const PAR={file:'avulsa', avulsa:'file', uvfile:'uv', uv:'uvfile'};
+let montagemCm=0; // altura da folha montada pelo motor, em cm
+const ZOOMS=[1,1.5,2,3,4]; let zoomI=0; // ampliação da montagem ao vivo
+let pedido=[]; // itens já fechados · o pagamento é um só
+// A escolha de entrega vale para o pedido inteiro, não por item. No Tiny, retirada
+// grava forma_envio X, transportador DropStar e frete por conta do remetente — igual
+// ao que a expedição já recebe hoje, sem criar processo novo.
+let entrega={tipo:'retira', cep:'', valor:0, cotado:true};
+let cliente={cnpj:'', zap:'', mail:''};
+let caminho='auto'; // o que o cliente diz que vai mandar
+
+// CNPJ com os dois dígitos verificadores · máscara sem conta não vale nada
+function cnpjOk(v){
+ const d=(v||'').replace(/\D/g,'');
+ if(d.length!==14 || /^(\d)\1{13}$/.test(d)) return false;
+ const calc=(base,pesos)=>{
+ const soma=base.split('').reduce((t,n,i)=>t+ +n*pesos[i],0);
+ const r=soma%11; return r<2? 0 : 11-r;
+ };
+ const p1=[5,4,3,2,9,8,7,6,5,4,3,2], p2=[6,...p1];
+ return calc(d.slice(0,12),p1)===+d[12] && calc(d.slice(0,13),p2)===+d[13];
+}
+const zapOk=v=>{ const d=(v||'').replace(/\D/g,''); return d.length===10||d.length===11; };
+const mailOk=v=>/^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i.test((v||'').trim());
+const clienteOk=()=>cnpjOk(cliente.cnpj)&&zapOk(cliente.zap)&&mailOk(cliente.mail);
+const larguraFilme=()=>MODOS[modo].larg;
+// cobrança proporcional: arredonda para cima em 10 cm · 2,75 m vira 2,80 m
+const cobrar=m=>Math.max(MINIMO_M, Math.ceil(m*10)/10);
+const fmtM=m=>m.toFixed(2).replace('.',',');
+const precoBase=n=>
+ (FAIXAS[modo].find(f=>n>=f[0])||FAIXAS[modo][FAIXAS[modo].length-1])[1];
+const descontoMetragem=()=>0; // sem progressão por quantidade
+const cls=n=>n>=85?'ok':n>=50?'av':'er';
+const dpiDe=a=>a.cm>0? a.px/(a.cm/2.54) : 0;
+// girar 90° inverte a proporção · espelhar não muda medida, só o desenho
+const propDe=a=>{ const p=a.prop||1; return a.giro? 1/p : p; };
+const TAMANHOS={57:[10,15,20,25,28.2], 28.5:[5,8,10,14,28.2]};
+const px=f=>Math.round(Math.min(6000,Math.max(600,Math.sqrt(f.size/1024)*95)));
+
diff --git a/local/static/site-flow.js b/local/static/site-flow.js
new file mode 100644
index 0000000..bd9bd9a
--- /dev/null
+++ b/local/static/site-flow.js
@@ -0,0 +1,173 @@
+/* Site DTF — Carousel, path selector, delivery choice and input masks.
+ Extracted verbatim from the single inline script in dtf-site.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. */
+// ── decisões
+// Remanejar conserta encaixe, não resolução — e a nota mede só resolução.
+// Por isso a nota NÃO muda aqui. E a metragem nova só existe depois que o motor
+// roda na folha de verdade, no servidor: o navegador recebe a folha fechada,
+// não as artes separadas, então não há como recalcular aqui sem inventar número.
+$('qOk').addEventListener('click',()=>{
+ if($('qOk').disabled) return;
+ const ok=$('ciente').classList.contains('on');
+ qm(ok? 'Registrado. Você confirmou que viu a ressalva de resolução.'
+ : 'Certo. Seu pedido está montado abaixo.','var(--tx)');
+ $('carr').scrollIntoView({behavior:'smooth',block:'nearest'});
+});
+
+$('pOk').addEventListener('click',()=>$('carr').scrollIntoView({behavior:'smooth'}));
+$('pMais').addEventListener('click',()=>$('foco').scrollIntoView({behavior:'smooth'}));
+$('pVolta').addEventListener('click',()=>{
+ folhas=[]; artes=[]; reencaixado=false; pintaFolha(); pintaArtes();
+ limpaPaineis(); // o carrinho só some se o pedido estiver vazio
+ $('foco').scrollIntoView({behavior:'smooth'});
+});
+// carrossel
+(function(){
+ const tr=$('trilho'); if(!tr) return;
+ const slides=[...tr.children], pontos=$('cPontos');
+ slides.forEach((_,i)=>{
+ const b=document.createElement('button');
+ b.className='pt'+(i?'':' on'); b.setAttribute('aria-label','Cartão '+(i+1));
+ b.addEventListener('click',()=>ir(i)); pontos.appendChild(b);
+ });
+ function ir(i){
+ i=Math.max(0,Math.min(slides.length-1,i));
+ tr.scrollTo({left:slides[i].offsetLeft-tr.offsetLeft,behavior:'smooth'});
+ }
+ function atual(){
+ const meio=tr.scrollLeft+tr.clientWidth/2;
+ let a=0,d=Infinity;
+ slides.forEach((s,i)=>{
+ const c=s.offsetLeft-tr.offsetLeft+s.offsetWidth/2;
+ if(Math.abs(c-meio){
+ const a=atual();
+ [...pontos.children].forEach((p,i)=>p.classList.toggle('on',i===a));
+ },{passive:true});
+ $('cAnt').addEventListener('click',()=>ir(atual()-1));
+ $('cPro').addEventListener('click',()=>ir(atual()+1));
+})();
+
+// A declaração de formato só orienta: filtra o seletor e mostra o preço esperado.
+// Se o arquivo que chegar contrariar, o arquivo ganha e o site avisa.
+const AUTO=/[.](png|jpe?g|webp|pdf)$/i;
+function pintaCaminhos(){
+ if(!$('caminhos')) return;
+ const ehF=modo&&ehFolha();
+ $('caminhos').style.display = ehF? '' : 'none';
+ if(!ehF) return;
+ document.querySelectorAll('#caminhos .cam').forEach(b=>b.classList.toggle('on', b.dataset.cam===caminho));
+ $('camA').textContent='a partir de '+rs(pisoEscada());
+ $('camB').textContent=rs(TABELA[modo])+' fixo';
+ $('inp').accept = caminho==='auto' ? '.png,.jpg,.jpeg,.pdf'
+ : '.tif,.tiff,.psd,.psb,.ai,.cdr';
+ $('zSub').textContent = caminho==='auto'
+ ? 'PNG, JPG ou PDF · conferência automática, nota na tela'
+ : 'CDR, AI, PSD ou TIFF · sem conferência, metro pela tabela';
+}
+document.querySelectorAll('#caminhos .cam').forEach(b=>b.addEventListener('click',()=>{
+ caminho=b.dataset.cam; avisoCam(''); pintaCaminhos();
+}));
+function avisoCam(txt){
+ const el=$('trocouCam'); if(!el) return;
+ el.innerHTML=txt; el.style.display = txt? '' : 'none';
+}
+
+function pintaEntrega(){
+ document.querySelectorAll('.opE').forEach(b=>
+ b.classList.toggle('on', b.dataset.ent===entrega.tipo));
+ $('cep').classList.toggle('on', entrega.tipo==='frete');
+ $('vFrete').textContent = entrega.tipo!=='frete' ? '—'
+ : entrega.cotado ? rs(entrega.valor) : 'a cotar';
+ $('avisoE').innerHTML = entrega.tipo==='retira'
+ ? 'DTF é impresso depois que você paga. Avisamos no WhatsApp quando estiver '+
+ 'pronto para retirar — não venha antes do aviso.'
+ : '';
+ $('bPagar').disabled = !entrega.cotado || !clienteOk();
+ pintaPedido();
+}
+
+// máscaras e conferência dos campos do cliente
+function liga(id,fmt,val,chave,erro,msg){
+ const el=$(id), er=$(erro);
+ el.addEventListener('input',e=>{
+ e.target.value=fmt(e.target.value);
+ cliente[chave]=e.target.value;
+ el.classList.remove('ruim','bom'); er.textContent='';
+ if(val(e.target.value)) el.classList.add('bom');
+ pintaEntrega();
+ });
+ el.addEventListener('blur',()=>{
+ if(!el.value){ el.classList.remove('ruim','bom'); er.textContent=''; return; }
+ const ok=val(el.value);
+ el.classList.toggle('ruim',!ok); el.classList.toggle('bom',ok);
+ er.textContent = ok? '' : msg;
+ });
+}
+liga('fCnpj', v=>{
+ const d=v.replace(/\D/g,'').slice(0,14);
+ return d.replace(/^(\d{2})(\d)/,'$1.$2').replace(/^(\d{2})\.(\d{3})(\d)/,'$1.$2.$3')
+ .replace(/\.(\d{3})(\d)/,'.$1/$2').replace(/(\d{4})(\d)/,'$1-$2');
+}, cnpjOk, 'cnpj', 'eCnpj', 'CNPJ inválido · confira os números');
+liga('fZap', v=>{
+ const d=v.replace(/\D/g,'').slice(0,11);
+ return d.length>6 ? '('+d.slice(0,2)+') '+d.slice(2,d.length-4)+'-'+d.slice(-4)
+ : d.length>2 ? '('+d.slice(0,2)+') '+d.slice(2) : d;
+}, zapOk, 'zap', 'eZap', 'Faltam dígitos no número');
+liga('fMail', v=>v.trim(), mailOk, 'mail', 'eMail', 'E-mail inválido');
+function pintaZoom(){
+ $('zTxt').textContent=Math.round(ZOOMS[zoomI]*100)+'%';
+ $('zMenos').disabled = zoomI===0;
+ $('zMais').disabled = zoomI===ZOOMS.length-1;
+}
+$('zMais').addEventListener('click',()=>{
+ if(zoomI{
+ if(zoomI>0){ zoomI--; pintaZoom(); previaAoVivo(); }
+});
+pintaZoom();
+
+document.querySelectorAll('.opE').forEach(b=>b.addEventListener('click',()=>{
+ entrega.tipo=b.dataset.ent;
+ entrega.cotado = entrega.tipo==='retira';
+ entrega.valor=0;
+ if(entrega.tipo==='frete') $('cepMsg').innerHTML='';
+ pintaEntrega();
+}));
+$('cepIn').addEventListener('input',e=>{
+ const v=e.target.value.replace(/\D/g,'').slice(0,8);
+ e.target.value = v.length>5 ? v.slice(0,5)+'-'+v.slice(5) : v;
+ entrega.cep=v; entrega.cotado=false; entrega.valor=0; pintaEntrega();
+});
+$('bCep').addEventListener('click',async()=>{
+ if(entrega.cep.length!==8){ $('cepMsg').innerHTML='CEP incompleto.'; return; }
+ if(window.dtfFreight){ await window.dtfFreight(); return; }
+ // A cotação vem da transportadora. Enquanto a integração não existir, o pedido não
+ // fecha por frete: o valor precisa estar fechado antes do Mercado Pago.
+ $('cepMsg').innerHTML='Cotação de frete ainda não integrada. '+
+ 'O valor precisa vir da transportadora antes do pagamento — senão você pagaria um '+
+ 'total que não é o final. Por enquanto, retire em Franca ou fale conosco no WhatsApp.';
+ pintaEntrega();
+});
+
+$('bMais').addEventListener('click',()=>{
+ if(itemAtual){ pedido.push(itemAtual); itemAtual=null; }
+ $('atual').style.display='none'; $('carrLin').innerHTML='';
+ $('foco').classList.remove('on'); $('cards').style.display='';
+ ['qual','prev'].forEach(id=>$(id).classList.remove('on'));
+ modo=null; artes=[]; folhas=[]; metros=0; nota=0; montagemCm=0;
+ pintaPedido();
+ document.getElementById('envio').scrollIntoView({behavior:'smooth'});
+});
+
+$('bPagar').addEventListener('click',()=>{
+ if(window.dtfCheckout) window.dtfCheckout();
+ else alert('O pedido online está indisponível no momento. Tente novamente em instantes.');
+});
+pintaEntrega();
+
diff --git a/local/static/site-modes.js b/local/static/site-modes.js
new file mode 100644
index 0000000..d12fdca
--- /dev/null
+++ b/local/static/site-modes.js
@@ -0,0 +1,95 @@
+/* Site DTF — Choosing a product, and the declared sheet/loose-artwork switch.
+ Extracted verbatim from the single inline script in dtf-site.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. */
+// ── escolha
+document.querySelectorAll('.ec').forEach(b=>b.addEventListener('click',()=>abrir(b.dataset.modo)));
+function abrir(m){
+ modo=m; artes=[]; folhas=[]; metros=0; nota=0; reencaixado=false;
+ montagemCm=0;
+ avisoTipo('');
+ 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];
+ $('cards').style.display='none'; $('foco').classList.add('on');
+ $('fTit').textContent=c.tit; $('fLg').textContent=c.lg;
+ $('fPreco').textContent=c.preco; $('fSub').textContent=c.sub;
+ $('zTit').textContent=c.zt; $('zSub').textContent=c.zs;
+ $('inp').multiple=c.multi;
+ $('inp').accept = ehFolha(m) ? '.png,.jpg,.jpeg,.pdf'
+ : '.png,.jpg,.jpeg,.webp';
+ $('catFoco').className='cat '+(ehFolha(m)?'file':'av');
+ $('larguraFolhaPronta').textContent=String(c.larg).replace('.',',');
+ $('foco2').classList.remove('sofila'); // a caixa de requisitos vale nos 4 modos
+ $('montcabTit').textContent = ehFolha(m) ? 'Sua folha' : 'Montagem ao vivo';
+ pintaTipoEnvio();
+}
+// O par folha montada / artes separadas fica visível e com preço nos dois lados,
+// nos dois modos: nenhuma troca acontece sem o cliente ver o que muda, e sempre
+// dá para voltar atrás sem recomeçar o pedido.
+function pintaTipoEnvio(){
+ const el=$('tipoEnvio'); if(!el) return;
+ el.hidden=!modo;
+ if(el.hidden) return;
+ const folha = ehFolha() ? modo : PAR[modo];
+ const avulsa = ehFolha() ? PAR[modo] : modo;
+ $('tipoFolhaPreco').textContent='a partir de '+rs(pisoEscada(folha));
+ $('tipoAvulsaPreco').textContent='a partir de '+rs(pisoEscada(avulsa))+' · montagem inclusa';
+ $('larguraFolhaPronta').textContent=String(MODOS[folha].larg).replace('.',',');
+ const atual = ehFolha() ? 'folha' : 'avulsa';
+ el.querySelectorAll('.cam').forEach(b=>{
+ b.classList.toggle('on', b.dataset.tipo===atual);
+ b.disabled=folhas.length>0 || artes.length>0; // já tem arquivo: a escolha está feita
+ });
+}
+function avisoTipo(txt){
+ const el=$('avisoTipo'); if(!el) return;
+ el.innerHTML=txt; el.style.display = txt? '' : 'none';
+}
+// A imagem foi declarada folha montada mas não tem resolução para isso. Devolve o
+// arquivo com o motivo e o caminho certo — sem trocar preço por conta própria.
+function recusaFolha(x, md){
+ const arquivo=x.f, destino = modo==='uvfile' ? 'uv' : 'avulsa';
+ folhas=folhas.filter(y=>y!==x);
+ pintaFolha();
+ avisoTipo(''+escapeHTML(arquivo.name)+' não é uma folha montada. '+
+ 'Para ocupar os '+n1(larguraFilme())+' cm do filme ela teria só '+md.dpiFolha+
+ ' DPI, e o mínimo para imprimir é '+DPI_RECUSA+'. '+
+ 'Se for uma arte para montarmos, use '+MODOS[destino].tit+', '+
+ 'a partir de '+rs(pisoEscada(destino))+'/m. '+
+ '');
+ const botao=$('usarAvulsa');
+ if(botao) botao.addEventListener('click',()=>{
+ avisoTipo(''); trocaTipo(PAR[modo]); sel([arquivo]);
+ });
+}
+document.querySelectorAll('#tipoEnvio .cam').forEach(b=>b.addEventListener('click',()=>{
+ if(b.disabled) return;
+ const querFolha=b.dataset.tipo==='folha';
+ if(querFolha===ehFolha()) return;
+ avisoTipo(''); trocaTipo(PAR[modo]); // escolha explícita do cliente
+}));
+// Troca declarada, nunca silenciosa: o cliente já viu os dois preços no seletor.
+function trocaTipo(destino){
+ if(!destino || modo===destino) return;
+ folhas=[]; artes=[]; recemChegada=null; metros=0; nota=0; montagemCm=0;
+ modo=destino;
+ $('lista').innerHTML=''; $('rA').style.display='none'; recusa([]);
+ caminho='auto'; avisoCam('');
+ pintaModo(); pintaCaminhos(); limpaPaineis(); agendaAvaliacao(); previaAoVivo();
+}
+$('bVoltar').addEventListener('click',()=>{
+ itemAtual=null; $('atual').style.display='none'; $('carrLin').innerHTML=''; pintaPedido();
+ $('foco').classList.remove('on'); $('cards').style.display='';
+ ['qual','prev'].forEach(id=>$(id).classList.remove('on'));
+ if(!pedido.length) $('carr').classList.remove('on');
+ document.getElementById('envio').scrollIntoView({behavior:'smooth'});
+});
+
diff --git a/local/static/site-packing.js b/local/static/site-packing.js
new file mode 100644
index 0000000..0bb5674
--- /dev/null
+++ b/local/static/site-packing.js
@@ -0,0 +1,353 @@
+/* Site DTF — Masks, the nesting engine, and drawing the assembled film.
+ Extracted verbatim from the single inline script in dtf-site.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);
+ });
+}
+// 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;y16 ? 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(linhascols) return true;
+ for(let y=0;y=linhas) continue; // vazio abaixo do que já existe
+ const L=mapa[gy], off=y*m.cw;
+ for(let x=0;x(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.yp.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=>({...a}));
+ if(fora) fora.innerHTML='';
+ if(!itens.length){
+ area.innerHTML='
A montagem aparece aqui'+
+ 'informe a largura de cada arte e a folha se desenha sozinha
';
+ if(aoTerminar) aoTerminar({alturaCm:0, uso:0, girou:0});
+ return;
+ }
+
+ 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=>{
+ 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.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
+ ? ''+uso+'% da folha virou arte bom aproveitamento'
+ : ''+uso+'% da folha virou arte dá para melhorar: artes de larguras parecidas '+
+ 'se encaixam melhor lado a lado')
+ + (girou? ' · '+girou+
+ (girou>1?' artes giradas':' arte girada')+' 90° para caber melhor':'');
+ (fora||area).appendChild(el);
+ if(aoTerminar) aoTerminar({alturaCm, uso, girou});
+ });
+}
+
+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);
+ $('vArea').montagemVersao=($('vArea').montagemVersao||0)+1;
+ if(ehFolha()){
+ $('vUso').innerHTML='';
+ const tot=folhaTotalM();
+ $('vMt').textContent = tot>0 ? fmtM(cobrar(tot))+' m' : '—';
+ const medidas=folhas.filter(x=>x.med);
+ const previews=folhas.filter(x=>x.previewSrc);
+ if(previews.length){
+ const painel=document.createElement('div'); painel.className='folhaPrevias';
+ previews.forEach((x,index)=>{
+ const figura=document.createElement('figure'); figura.className='folhaPrevia';
+ const imagem=new Image(); imagem.src=x.previewSrc; imagem.alt=x.f.name;
+ const legenda=document.createElement('figcaption');
+ legenda.textContent=(previews.length>1 ? (index+1)+'. ' : '')+x.f.name+
+ (x.med ? ' · '+n1(x.med.larg)+' × '+n1(x.med.alt)+' cm' : '');
+ figura.append(imagem,legenda); painel.append(figura);
+ });
+ $('vArea').replaceChildren(painel);
+ }else{
+ $('vArea').innerHTML = medidas.length
+ ? '
'+(medidas.length>1
+ ? medidas.length+' folhas · '+fmtM(tot)+' m'
+ : n1(medidas[0].med.larg)+' × '+n1(medidas[0].med.alt)+' cm')+''+
+ ''+(medidas.length>1? 'medidas lidas de cada arquivo' : medidas[0].med.fonte)+
+ ' · vão para a máquina do jeito que você montou
'
+ : '
Sua folha aparece aqui'+
+ 'a medida é lida do próprio arquivo, não do peso dele
';
+ }
+ return;
+ }
+ tMont=setTimeout(()=>{
+ const prontas=artes.filter(a=>a.cm>0);
+ 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
+ $('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 && (a.px/(a.cm/2.54))<300);
+ if(!ruins.length) return 'nota '+nota+' · todas as artes em 300 DPI ou mais';
+ const pior=ruins.reduce((p,a)=>(a.px/(a.cm/2.54))<(p.px/(p.cm/2.54))?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(pior.px/(pior.cm/2.54))+' 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)+' m';
+ $('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=>
+ '
Prévia indisponível'+
+ 'O arquivo será processado normalmente.
'; }
+}
+
diff --git a/local/static/site-pdf.js b/local/static/site-pdf.js
new file mode 100644
index 0000000..4011ebd
--- /dev/null
+++ b/local/static/site-pdf.js
@@ -0,0 +1,400 @@
+/* Site DTF — PDF measurement and rasterisation, and the DPI of images inside one.
+ Extracted verbatim from the single inline script in dtf-site.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 local/static/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;
+// Alguns navegadores e iframes bloqueiam Worker de blob. Descobrimos antes de
+// tentar, porque sem worker o PDF é lido na thread principal e precisa de outra
+// estratégia: grade mais grossa e mais tempo.
+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{
+ const codigo='importScripts("'+PDFJS_WORKER+'");';
+ const url=URL.createObjectURL(new Blob([codigo],{type:'application/javascript'}));
+ temWorker=testaWorker(url);
+ window.pdfjsLib.GlobalWorkerOptions.workerSrc = temWorker? url : 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;it+x.area,0) || 1;
+ const abaixoPiso=lista.filter(x=>x.dpix.dpit+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
+function rasterizarPdf(file, larguraCm, alturaCm){
+ let motivo=null;
+ const trabalho=carregarPdfJs().then(lib=>{
+ if(!lib){ motivo='não foi possível carregar o leitor de PDF'; return null; }
+ // sem worker tudo roda na thread principal · grade mais grossa para caber
+ const folga = temWorker===false ? 2.2 : 1;
+ return file.arrayBuffer().then(buf=>lib.getDocument({
+ data:buf, disableFontFace:true, isEvalSupported:false, useSystemFonts:false,
+ verbosity:0 // sem worker é fallback, não erro
+ }).promise).then(doc=>
+ doc.getPage(1).then(page=>{
+ const vp1=page.getViewport({scale:1});
+ const passo=passoDe(larguraCm, alturaCm||larguraCm)*folga;
+ const alvo=Math.max(8, Math.round(larguraCm/passo));
+ const esc=alvo/vp1.width;
+ const vp=page.getViewport({scale:esc});
+ 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});
+ return page.render({canvasContext:ctx, viewport:vp, background:'rgba(0,0,0,0)'})
+ .promise.then(()=>page.getOperatorList()).then(ops=>{
+ 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=>{ motivo=(e&&e.message)? e.message : 'erro ao abrir o PDF'; return null; });
+ // sem worker o processo é lento: damos mais tempo antes de desistir
+ const espera = temWorker===false ? 90000 : 30000;
+ const tarde=new Promise(r=>setTimeout(()=>{ motivo='demorou demais neste navegador'; r('tempo'); }, espera));
+ return Promise.race([trabalho, tarde]).then(r=>{
+ const saida = r==='tempo'? null : r;
+ if(!saida) return {erro: motivo||'não deu para conferir', semWorker: temWorker===false};
+ return saida;
+ });
+}
+
+function medirFolha(file){
+ return new Promise(res=>{
+ const nome=(file.name||'').toLowerCase();
+ 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)){
+ const pedaco=(inicio,fim)=>new Promise(r=>{
+ const fr=new FileReader();
+ fr.onload=()=>r(new TextDecoder('latin1').decode(new Uint8Array(fr.result)));
+ fr.onerror=()=>r('');
+ fr.readAsArrayBuffer(file.slice(inicio,fim));
+ });
+ const M=4*1048576;
+ Promise.all([pedaco(0,M), pedaco(Math.max(0,file.size-M), file.size)]).then(([a,b])=>{
+ const txt=a+b;
+ const re=/\/MediaBox\s*\[\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)/;
+ const m=txt.match(re);
+ if(!m) return res(null);
+ // UserUnit escala a página inteira · sem ele, 1 unidade = 1/72 pol
+ const uu=txt.match(/\/UserUnit\s+([\d.]+)/);
+ const esc=uu? Math.max(1,Math.min(75000,+uu[1])) : 1;
+ const ptW=Math.abs(+m[3]-+m[1]), ptH=Math.abs(+m[4]-+m[2]);
+ if(!(ptW>0&&ptH>0)) return res(null);
+ const w=ptW*PT_CM*esc, h=ptH*PT_CM*esc;
+ // acima de 14.400 unidades sem UserUnit o arquivo está fora da especificação
+ const foraDoPadrao = !uu && (ptW>PDF_MAX_PT || ptH>PDF_MAX_PT);
+ res({larg:+w.toFixed(1), alt:+h.toFixed(1),
+ fonte:'página do PDF'+(uu? ' · UserUnit '+esc : ''), foraDoPadrao});
+ });
+ return;
+ }
+ res(null);
+ });
+}
+
+function pintaFolha(){
+ const L=larguraFilme();
+ pintaTipoEnvio(); // trava o seletor enquanto houver folha
+ if(!folhas.length){ $('lista').innerHTML=''; agendaAvaliacao(); previaAoVivo(); return; }
+
+ $('lista').innerHTML = folhas.map((x,i)=>{
+ const lido=!!x.med, larga=lido && x.med.larg>L+0.5, suspeito=lido && x.med.foraDoPadrao;
+ const sub=(x.m||0)*(x.rep||1);
+ let medida='';
+ if(lido){
+ const a=x.an;
+ const conferido = a
+ ? '
Conferido de verdade'+
+ ''+a.artes+' peça'+(a.artes===1?'':'s')+' · '+a.aproveitamento+
+ '% da folha virou arte · '+
+ (a.residuos? a.residuos+' resíduo'+(a.residuos>1?'s':'') : 'sem resíduo')+' · '+
+ (a.fundoChapado? 'fundo chapado' : 'fundo transparente')+''+
+ 'resolução '+a.dpi+' DPI · '+
+ (a.fonteDpi==='imagens'? 'medida imagem por imagem dentro do PDF'
+ : a.fonteDpi==='vetor'? 'arte vetorial, sem imagem embutida'
+ : 'lida do arquivo')+
+ ' · grade de '+n1(a.grade)+' mm
'
+ : '';
+ medida='
'+
+ ''+n1(x.med.larg)+' × '+n1(x.med.alt)+' cm'+
+ ''+x.med.fonte+' · '+fmtM(x.m)+' m de folha'+
+ conferido+
+ (suspeito? 'Este PDF passa do tamanho que o formato comporta. '+
+ '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 PNG ou TIFF, ou divide em partes de até 5 m, que sai na hora.':'')+
+ (larga? 'Esta folha não roda neste filme. 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 DTF têxtil de 57 cm — troque de produto ali em cima.'
+ : ' Remonte a folha em '+n1(L)+' cm e suba de novo.')+'':'')+
+ '
'+
+ ''+escapeHTML(extDe(x.f.name))+' não abre no navegador'+
+ 'Sem conseguir conferir o arquivo, não temos como dar nota — e sem nota '+
+ 'o metro sai pela tabela, '+rs(teto)+'. Alguém vai abrir esse arquivo na mão '+
+ 'antes de imprimir.'+
+ '
Exportando a mesma folha em PNG ou PDF, a conferência é '+
+ 'automática e o metro pode cair até '+rs(piso)+''+
+ (dif>0.5? ' — '+rs(dif)+' a menos só nesta folha' : '')+'.
'+
+ 'Informe o comprimento para seguir:'+
+ ''+
+ '
';
+ }
+ const barra = x.pct!=null
+ ? '
'+
+ ''+(x.pct<50?'lendo o arquivo…':
+ x.pct<70?'medindo a folha…':'conferindo a arte…')+''
+ : x.semAnalise
+ ? '
Não conseguimos conferir esta folha. '+x.semAnalise+
+ '. Seguimos com o preço de tabela — se quiser a nota e o desconto, exporte a mesma '+
+ 'folha em PNG, que a conferência é bem mais leve.
';
+ }).join('');
+
+ // total do pedido, quando há mais de uma folha
+ const tot=folhaTotalM(), partes=Math.ceil(tot/20);
+ if(folhas.length>1 && tot>0){
+ $('lista').innerHTML += '
'+
+ ''+folhas.length+' folhas neste pedido'+
+ ''+fmtM(tot)+' m no total'+
+ (partes>1? 'vai em '+partes+' partes de até 20 m':'')+'
folha longa'+
+ ''+fmtM(tot)+' mvai em '+partes+' partes de até 20 m
';
+ }
+
+ $('lista').querySelectorAll('[data-rmf]').forEach(b=>b.addEventListener('click',()=>{
+ folhas.splice(+b.dataset.rmf,1); pintaFolha();
+ }));
+ $('lista').querySelectorAll('[data-repf]').forEach(el=>el.addEventListener('change',e=>{
+ folhas[+el.dataset.repf].rep=Math.max(1,Math.min(200,+e.target.value||1)); pintaFolha();
+ }));
+ $('lista').querySelectorAll('[data-comp]').forEach(el=>el.addEventListener('change',e=>{
+ folhas[+el.dataset.comp].m=Math.max(0,Math.min(60,+e.target.value||0)); pintaFolha();
+ }));
+ 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};
+}
+// A cota em escala: o cliente ve a peca dentro da largura do filme, quantas cabem
+// lado a lado e quanto sobra. Numero em texto ninguem le; retangulo todo mundo entende.
+function vizPeca(a){
+ const BASE=300, L=larguraFilme();
+ const e0=BASE/L, alt0=a.cm*propDe(a)*e0;
+ const k = alt0>132 ? 132/alt0 : 1; // tira nunca fica alta demais
+ const e = e0*k;
+ const n=cabemNaLinha(a.cm), pw=a.cm*e, ph=a.cm*propDe(a)*e, gp=GAP_CM*e;
+ const sobra=L-(n*a.cm+(n-1)*GAP_CM);
+ const ext=escapeHTML(a.f.name.split('.').pop().toUpperCase());
+ let pecas='';
+ for(let i=0;i'+(a.src?'':''+ext+'')+'';
+ const Wt=(L*e+6).toFixed(1); // +6 = borda e respiro da tira
+ return '
'+
+ '
'+
+ '
'+n1(a.cm*propDe(a))+' cm
'+
+ '
'+
+ '
'+n1(L)+' cm de filme
'+
+ '
'+pecas+
+ (sobra>0.25?'
sobra '+n1(sobra)+' cm
':'')+
+ '
'+
+ '
'+
+ '
'+
+ '
'+n1(a.cm)+' cm de largura · '+n+
+ (n>1?' peças':' peça')+' por fileira'+
+ (sobra>0.25?' · sobram '+n1(sobra)+' cm':' · largura toda aproveitada')+'
'+
+ '
';
+}
+const n1=v=>v.toFixed(1).replace('.',',');
+
+function pintaArtes(){
+ $('lista').innerHTML=artes.map((a,i)=>{
+ let barra='', dica='', calc='informe a largura para ver a qualidade';
+ 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)));
+ barra='
Reduzindo para '+n1(sug.alvo)+' cm você economiza '+
+ n1(sug.cm)+' cm de folhaPassam de '+sug.de+' para '+sug.para+
+ ' por fileira, e as suas '+a.q+' peças ocupam '+sug.fileiras+
+ ' fileira'+(sug.fileiras>1?'s':'')+' a menos.
':'');
+}
+
diff --git a/local/static/site-quality.js b/local/static/site-quality.js
new file mode 100644
index 0000000..208be0a
--- /dev/null
+++ b/local/static/site-quality.js
@@ -0,0 +1,186 @@
+/* Site DTF — The grade shown to the customer, and the reservations attached to it.
+ Extracted verbatim from the single inline script in dtf-site.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. */
+// ── enviar
+// Avalia sozinho conforme o cliente sobe e preenche. Devolve o que ainda falta,
+// para a fila dizer em texto em vez de travar num botão.
+function avaliar(){
+ if(ehFolha()){
+ if(!folhas.length) return {pronto:false, falta:'Arraste sua folha montada para começar.'};
+ const larga=folhas.find(x=>x.med && x.med.larg > larguraFilme()+0.5);
+ if(larga) return {pronto:false, falta:'A folha '+larga.f.name+' tem '+n1(larga.med.larg)+
+ ' cm e não cabe no filme de '+n1(larguraFilme())+' cm.'};
+ const semMedida=folhas.filter(x=>!(x.m>0)).length;
+ if(semMedida) return {pronto:false, falta:'Informe o comprimento de '+semMedida+
+ ' folha'+(semMedida>1?'s':'')+'.'};
+ metros=folhaTotalM();
+ reencaixado=false;
+ const ans=folhas.map(x=>x.an).filter(Boolean);
+ if(!ans.length){
+ // sem análise possível (PDF, CDR, PSD): não inventa nota
+ const exts=[...new Set(folhas.map(f=>extDe(f.f.name)))].join(', ');
+ mostraNota([
+ ['av','Sem nota · '+exts+' não abre no navegador',
+ 'não temos como conferir a arte antes de imprimir'],
+ ['er','Metro pela tabela · '+rs(TABELA[modo]),
+ 'exportando em PNG ou PDF, cai até '+rs(pisoEscada())],
+ ['fix','Alguém abre seu arquivo','a conferência é feita na mão, na sala']
+ ], 0, folhas.length>1? folhas.length+' folhas' : folhas[0].f.name,
+ larguraFilme()+' cm · '+fmtM(metros)+' m · preço de tabela');
+ return {pronto:true};
+ }
+ const dpi=Math.min(...ans.map(a=>a.dpi));
+ const n=Math.max(6,Math.min(100,Math.round(dpi/DPI_AVISA*100)));
+ const soma=(k)=>ans.reduce((t,a)=>t+a[k],0);
+ const itens=[
+ ans.every(a=>a.vetor)
+ ? ['ok','Arte vetorial','sem imagem embutida · imprime em qualquer tamanho']
+ : [dpi>=DPI_AVISA?'ok':dpi>=DPI_RECUSA?'av':'er','Resolução '+dpi+' DPI',
+ ans.some(a=>a.fonteDpi==='imagens')
+ ? 'média por área das imagens dentro do PDF · pior em '+
+ Math.min(...ans.map(a=>a.res? a.res.pior : a.dpi))+' DPI'
+ : 'resolução do arquivo · arte ampliada antes de exportar não aparece aqui'],
+ ['ok', soma('artes')+' peças na folha',
+ Math.round(soma('aproveitamento')/ans.length)+'% da folha virou arte'],
+ soma('residuos') ? ['er','Resíduo de recorte',
+ soma('residuos')+' ponto'+(soma('residuos')>1?'s':'')+' abaixo de 2 mm · a impressora imprime']
+ : ['ok','Sem resíduo de recorte','nada solto na folha'],
+ soma('encostadas') ? ['fix', soma('encostadas')+' peças a menos de 5 mm',
+ 'se forem artes diferentes, separamos com 5 mm sem custo']
+ : ['ok','Espaço entre peças','nenhuma abaixo de 6 mm'],
+ ans.some(a=>a.fundoChapado) ? ['er','Fundo chapado','a folha está sem transparência']
+ : ['ok','Fundo transparente','canal alfa conferido'],
+ null
+ ].filter(Boolean);
+ mostraNota(itens, n, folhas.length>1? folhas.length+' folhas' : folhas[0].f.name,
+ larguraFilme()+' cm · '+fmtM(metros)+' m');
+ return {pronto:true};
+ }
+ if(!artes.length) return {pronto:false, falta:'Arraste suas artes para começar.'};
+ const sem=artes.filter(a=>!a.cm).length;
+ if(sem) return {pronto:false, falta:'Informe a largura de '+sem+' arte'+(sem>1?'s':'')+
+ ' para ver a nota e o preço.'};
+ // metragem = altura da folha depois de montada, com os 5 mm entre peças
+ metros=+(montagemCm/100).toFixed(4);
+ if(!(metros>0)) return {pronto:false, falta:'Montando a folha…'};
+ const notas=artes.map(a=>Math.max(6,Math.min(100,Math.round((a.px/(a.cm/2.54))/300*100))));
+ const media=Math.round(notas.reduce((s,n)=>s+n,0)/notas.length), pior=Math.min(...notas);
+ mostraNota([
+ ['ok','Montagem por nossa conta','5 mm garantidos'],
+ [cls(pior),'Pior arte em '+pior+'%',pior>=85?'todas em boa resolução':'reenviar maior sai mais barato']
+ ],media,artes.length+' arte'+(artes.length>1?'s':''),
+ 'folha montada por nós · '+fmtM(metros)+' m');
+ return {pronto:true};
+}
+
+// redesenha 220 ms depois da última mexida, para não avaliar a cada tecla
+let tAval=null;
+function agendaAvaliacao(){
+ clearTimeout(tAval);
+ tAval=setTimeout(()=>{
+ const r=avaliar();
+ const el=$('aoVivo');
+ if(!el) return;
+ if(r.pronto){
+ el.className='aoVivo pronto';
+ el.innerHTML='Nota e preço calculados. '+
+ 'Confira abaixo e feche o pedido.';
+ }else{
+ el.className='aoVivo';
+ el.innerHTML=''+escapeHTML(r.falta)+'';
+ limpaPaineis(); // o carrinho só some se o pedido estiver vazio
+ }
+ },220);
+}
+function mostraNota(itens,n,titulo,sub){
+ nota=n;
+ $('qual').classList.add('on');
+ $('qNome').textContent=titulo; $('qSub').textContent=sub;
+ const semNota = n===0;
+ $('pct').innerHTML = semNota? '—/100' : n+'/100';
+ const c = semNota? 'var(--fraco)' : n>=85?'var(--verde)':n>=50?'var(--laranja)':'var(--vermelho)';
+ $('pct').style.color=c;
+ $('qbi').style.width=(semNota?0:n)+'%'; $('qbi').style.background=c;
+ $('qitens').innerHTML=itens.map(i=>
+ '
').join('');
+ $('qmsg').style.display='none';
+ ressalvaResolucao();
+ carrinho();
+}
+
+// A resolução do arquivo não é a das artes dentro dele: um PDF em 300 DPI
+// pode ter uma imagem inserida em 180, e é essa que sai serrilhada.
+function ressalvaResolucao(){
+ const box=$('ciente'), chk=$('cienteOk');
+ let baixas=[], geral=300, recusadas=[];
+ if(ehFolha()){
+ // Uma régua só: o número que precifica é o número que bloqueia.
+ // Recusa quando a média por área fica abaixo de 96 DPI — a folha inteira não
+ // vale o filme. Se a média passa mas há peças ruins, é ressalva com aceite.
+ folhas.forEach((x,k)=>{
+ if(!x.an) return;
+ const r=x.an.res, cm=x.med? x.med.larg : larguraFilme();
+ const nome=(folhas.length>1? (k+1)+'. ':'')+x.f.name;
+ const extra = r ? {pior:r.pior, de:r.total,
+ quantas:r.quantasBaixas, parte:Math.round(r.fracBaixa*100),
+ quantasAviso:r.quantasAviso, parteAviso:Math.round(r.fracAviso*100)} : {};
+ if(x.an.dpix.an) ? Math.max(...folhas.filter(x=>x.an).map(x=>x.an.dpi)) : 300;
+ }else{
+ const comTamanho=artes.filter(a=>a.cm>0);
+ recusadas=comTamanho.filter(a=>dpiDe(a)({nome:a.f.name, dpi:Math.round(dpiDe(a)), cm:a.cm}));
+ baixas=comTamanho.filter(a=>dpiDe(a)>=DPI_RECUSA && dpiDe(a)({nome:a.f.name, dpi:Math.round(dpiDe(a)), cm:a.cm}));
+ const todas=comTamanho.map(a=>dpiDe(a));
+ geral = todas.length? Math.round(Math.max(...todas)) : 300;
+ }
+ // Abaixo de 96 DPI a sala não imprime. Não é desconto, é recusa —
+ // e não existe aceite que destrave.
+ if(recusadas.length){
+ $('cienteTxt').innerHTML=
+ '
A média da folha ficou abaixo de '+DPI_RECUSA+' DPI. Nesse ponto a peça '+
+ 'sai serrilhada a ponto de não valer o filme. Reenvie maior ou reduza a largura.
'+
+ '
'+recusadas.map(b=>'
'+escapeHTML(b.nome)+' — '+
+ (b.quantas
+ ? 'média de '+b.dpi+' DPI · pior imagem em '+b.pior+' DPI · '+b.quantas+
+ ' de '+b.de+' imagens abaixo de '+DPI_RECUSA+' DPI, cobrindo '+b.parte+'% da arte'
+ : b.dpi+' DPI em '+n1(b.cm)+' cm · precisa de '+
+ Math.ceil(DPI_AVISA*b.cm/2.54)+' px de largura')+'
')
+ .join('')+'
';
+ $('cienteTit').textContent='Resolução abaixo do que imprimimos';
+ box.className='ciente on recusa'; chk.checked=false; $('qOk').disabled=true;
+ return;
+ }
+ $('cienteTit').textContent='Ressalva de resolução';
+ box.className='ciente';
+ if(!baixas.length){
+ box.classList.remove('on'); chk.checked=false; $('qOk').disabled=false; return;
+ }
+ const menor=baixas.reduce((p,b)=>b.dpis+b.dpi,0)/baixas.length);
+ $('cienteTxt').innerHTML=
+ '
Parte da arte está abaixo de '+DPI_AVISA+' DPI. Essas peças podem sair '+
+ 'com traço serrilhado, e isso não tem conserto depois de impresso. '+
+ 'O resto da folha sai normal.
'+
+ '
'+baixas.map(b=>'
'+escapeHTML(b.nome)+' — '+
+ (b.quantasAviso
+ ? 'média de '+b.dpi+' DPI · '+b.quantasAviso+' de '+b.de+' imagens abaixo de '+
+ DPI_AVISA+' DPI, cobrindo '+b.parteAviso+'% da arte'+
+ (b.quantas? ' · dessas, '+b.quantas+' abaixo de '+DPI_RECUSA+' DPI ('+
+ b.parte+'% da arte)' : '')
+ : b.dpi+' DPI'+(b.cm? ' em '+n1(b.cm)+' cm':''))+
+ '
').join('')+'
';
+ box.className='ciente on'; chk.checked=false; $('qOk').disabled=true;
+}
+$('cienteOk').addEventListener('change',e=>{
+ if($('ciente').classList.contains('recusa')) return; // recusa não se aceita
+ $('qOk').disabled=!e.target.checked;
+});
+
diff --git a/local/static/site-sheet.js b/local/static/site-sheet.js
new file mode 100644
index 0000000..91a0186
--- /dev/null
+++ b/local/static/site-sheet.js
@@ -0,0 +1,107 @@
+/* Site DTF — Reading a finished sheet from its alpha channel: pieces, gaps, waste.
+ Extracted verbatim from the single inline script in dtf-site.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. */
+// ── Análise real da folha: lê o canal alfa, separa cada ilha de arte e mede.
+// Grade de 1 mm. Só roda em arquivo que o navegador abre; PDF precisa do servidor.
+const MM=0.1; // grade fina: 1 mm
+const CELULAS_MAX=1200000; // teto de custo da análise
+// Folha grande vira grade mais grossa. Sem isso, 57 x 480 cm dá 2,7 milhões de
+// células e o navegador congela antes de terminar.
+function passoDe(larguraCm, alturaCm){
+ const fino=(larguraCm/MM)*(alturaCm/MM);
+ return fino<=CELULAS_MAX ? MM : MM*Math.sqrt(fino/CELULAS_MAX);
+}
+
+// Dilata a máscara em r células · usada para agrupar por distância real, não por caixa
+function inchar(bits,cw,ch,r){
+ const a=new Uint8Array(cw*ch), b=new Uint8Array(cw*ch);
+ for(let y=0;y=0) continue;
+ const id=n++; let ini=0,fim=0; fila[fim++]=p0; marca[p0]=id;
+ while(ini=cw||ny>=ch) continue;
+ const q=ny*cw+nx;
+ if(bits[q]&&marca[q]<0){ marca[q]=id; fila[fim++]=q; }
+ }
+ }
+ }
+ return {marca,n};
+}
+
+// ── O que a folha permite medir, e só isso.
+// Peça = pixels a menos de 2 mm entre si · letras de uma palavra ficam juntas.
+// Encostadas = peças que só se unem quando a folga chega a 6 mm.
+const R_PECA=1, R_PERTO=2; // peça: 2 mm · aviso de encostadas: 4 mm
+function analisarFolha(img, larguraCm){
+ const alturaCmEst=larguraCm*img.height/img.width;
+ const passo=passoDe(larguraCm, alturaCmEst);
+ const cw=Math.max(8,Math.round(larguraCm/passo));
+ const ch=Math.max(8,Math.round(cw*img.height/img.width));
+ const cv=document.createElement('canvas'); cv.width=cw; cv.height=ch;
+ const ctx=cv.getContext('2d',{willReadFrequently:true});
+ ctx.clearRect(0,0,cw,ch); ctx.drawImage(img,0,0,cw,ch);
+ const d=ctx.getImageData(0,0,cw,ch).data;
+
+ const bits=new Uint8Array(cw*ch);
+ let opacos=0;
+ for(let i=0,j=3;i10){ bits[i]=1; opacos++; }
+
+ // peças: agrupa pela distância real entre pixels
+ const gp=grupos(inchar(bits,cw,ch,R_PECA), cw, ch);
+ const caixas=[];
+ for(let p=0;pc.x1)c.x1=x; if(yc.y1)c.y1=y;
+ c.area++;
+ }
+ const pecasTodas=caixas.filter(Boolean);
+ const limRes=Math.max(1, Math.round(0.2/passo)); // 2 mm na grade em uso
+ const residuos=pecasTodas.filter(o=>(o.x1-o.x0)!((o.x1-o.x0){ if(set.size>1) encostadas+=set.size; });
+
+ return {
+ dpi: Math.round(img.width/(larguraCm/2.54)),
+ artes: artes.length,
+ residuos: residuos.length,
+ encostadas,
+ fundoChapado: opacos/(cw*ch) > 0.985,
+ aproveitamento: Math.max(1,Math.min(100,Math.round(opacos/(cw*ch)*100))),
+ alturaCm: ch*passo,
+ grade: +(passo*10).toFixed(1) // mm por célula analisada
+ };
+}
+
diff --git a/local/static/site-upload.js b/local/static/site-upload.js
new file mode 100644
index 0000000..353c3c2
--- /dev/null
+++ b/local/static/site-upload.js
@@ -0,0 +1,112 @@
+/* Site DTF — Accepting files: the drop zone, the accept rules, first measurement.
+ Extracted verbatim from the single inline script in dtf-site.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. */
+// ── upload
+// Untrusted filenames and recovered cart text must never become HTML.
+function escapeHTML(value){
+ return String(value).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+}
+function recusa(lista){
+ const el=$('recusa'); if(!el) return;
+ if(!lista.length){ el.style.display='none'; el.innerHTML=''; return; }
+ const exts=escapeHTML([...new Set(lista.map(f=>extDe(f.name)))].join(', '));
+ el.style.display='block';
+ el.innerHTML = ehFolha()
+ ? ''+exts+' não dá para enviar aqui. A folha pronta aceita PNG, JPG, TIFF, '+
+ 'PDF, PSD, AI e CDR.'
+ : ''+exts+' não dá para enviar aqui. Artes avulsas precisam de PNG ou JPG com '+
+ 'fundo transparente — é o que a montagem enxerga para encaixar. '+
+ 'Se o seu arquivo é CDR, AI ou PSD, exporte em PNG ou use Arquivo por metro.';
+}
+const Z=$('zona'), I=$('inp');
+Z.addEventListener('click',()=>I.click());
+Z.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){I.click();e.preventDefault();}});
+['dragover','dragenter'].forEach(ev=>Z.addEventListener(ev,e=>{e.preventDefault();Z.classList.add('sobre')}));
+['dragleave','drop'].forEach(ev=>Z.addEventListener(ev,e=>{
+ e.preventDefault(); Z.classList.remove('sobre');
+ if(ev==='drop') sel([...e.dataTransfer.files]);
+}));
+I.addEventListener('change',()=>sel([...I.files]));
+function sel(fs){
+ if(!fs.length) return;
+ // Pixels cannot tell a loose artwork from a finished sheet, so the customer
+ // declares it by choosing the product in the selector above. The declaration is
+ // verified in medirFolha; the file never changes product or price on its own.
+ const regra = ehFolha() ? ACEITA.folha : ACEITA.avulsa;
+ const fora = fs.filter(f=>!regra.test(f.name));
+ fs = fs.filter(f=>regra.test(f.name));
+ recusa(fora);
+ if(!fs.length) return;
+ if(ehFolha()){
+ const auto=fs.filter(f=>AUTO.test(f.name)).length;
+ const manual=fs.length-auto;
+ if(caminho==='auto' && manual){
+ caminho='tabela';
+ avisoCam('Trocamos o caminho para você. Você marcou conferência automática, '+
+ 'mas '+(manual>1? manual+' arquivos vieram':'o arquivo veio')+' em formato que não '+
+ 'abrimos. O metro vai pela tabela, '+rs(TABELA[modo])+'.');
+ pintaCaminhos();
+ }else if(caminho==='tabela' && auto){
+ caminho='auto';
+ avisoCam('Boa notícia. Você marcou o caminho sem conferência, mas '+
+ (auto>1? 'os arquivos vieram':'o arquivo veio')+' em formato que a gente confere. '+
+ 'Vamos avaliar e o metro pode cair até '+rs(pisoEscada())+'.');
+ pintaCaminhos();
+ }
+ const novas=fs.map(f=>({f, med:null, rep:1, m:0, semAnalise:null}));
+ folhas=folhas.concat(novas); // soma, não substitui
+ pintaFolha();
+ novas.forEach(x=>{
+ x.pct=5; pintaFolha();
+ medirFolha(x.f).then(md=>{
+ if(md && md.dpiFolha!=null && md.dpiFolha{
+ if(img){ x.previewSrc=img.src; try{ x.an=analisarFolha(img, md.larg); if(x.an) x.an.fonteDpi='arquivo'; }
+ catch(e){} }
+ x.pct=null; pintaFolha();
+ });
+ }else if(md && /\.pdf$/i.test(x.f.name)){
+ x.pct=70; pintaFolha();
+ rasterizarPdf(x.f, md.larg, md.alt).then(r=>{
+ if(r && r.erro){
+ x.semAnalise = r.erro + (r.semWorker
+ ? ' · este navegador não deixa o leitor rodar em segundo plano'
+ : '');
+ }
+ if(r && r.tela){
+ try{
+ x.an=analisarFolha(r.tela, md.larg);
+ // vetor puro não tem resolução · nota máxima, e está certo
+ x.an.dpi = r.dpi==null ? DPI_AVISA : r.dpi;
+ x.an.vetor = r.dpi==null;
+ x.an.fonteDpi = r.dpi==null ? 'vetor' : 'imagens';
+ x.an.res = r.res || null;
+ }catch(e){}
+ }
+ x.pct=null; pintaFolha();
+ });
+ }else{ x.pct=null; pintaFolha(); }
+ });
+ });
+ return;
+ }
+ const novos=fs.map(f=>({f,px:px(f),cm:0,q:1,prop:1,giro:0,esp:false}));
+ artes=novos.concat(artes); // a mais recente fica no topo da fila
+ recemChegada=novos[0];
+ pintaArtes();
+ $('lista').scrollIntoView({behavior:'smooth',block:'nearest'});
+ Promise.all(novos.map(medir)).then(pintaArtes);
+}
+function medir(a){
+ return carregarImagem(a.f).then(img=>{
+ if(img){ a.prop=img.height/img.width; a.px=img.width; a.src=img.src; } else a.prop=a.prop||1;
+ return a;
+ });
+}
+// A metragem sai do arquivo, nunca do peso em bytes.
+// imagem → proporção da imagem aplicada à largura do filme
+// PDF → MediaBox da primeira página, em pontos (1 pt = 1/72 pol)
+// TIFF, PSD, AI, CDR → o navegador não abre; o cliente informa o comprimento
diff --git a/local/test_pricing.py b/local/test_pricing.py
index d01fe5e..8593ba3 100644
--- a/local/test_pricing.py
+++ b/local/test_pricing.py
@@ -7,7 +7,9 @@ from .pricing import price, TIERS
class PricingTests(unittest.TestCase):
def test_every_grade_against_actual_site_javascript(self):
- source = Path('dtf-site.html').read_text()
+ # The ladders live with the rest of the Site's behaviour; this test exists
+ # to prove the server agrees with whatever the customer is shown.
+ source = Path('local/static/site-config.js').read_text()
tiers = source.split('const FAIXAS=')[1].split('\n};',1)[0]+'\n}'
calculator = source.split('const cobrar=')[1].split(';',1)[0]
js = f'const FAIXAS={tiers};const MINIMO_M=1;const cobrar={calculator};'
diff --git a/output/documents/relatorio-semana-1-dtf.docx b/output/documents/relatorio-semana-1-dtf.docx
new file mode 100644
index 0000000..72a8ab6
Binary files /dev/null and b/output/documents/relatorio-semana-1-dtf.docx differ