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)=> + '
'+escapeHTML(it.tit)+''+escapeHTML(it.desc)+'
'+ + '
'+rs(it.total)+'
'+ + '
').join(''); + $('itens').querySelectorAll('[data-rmi]').forEach(b=>b.addEventListener('click',()=>{ + pedido.splice(+b.dataset.rmi,1); pintaPedido(); + })); + const itens = pedido.reduce((t,it)=>t+it.total,0) + (itemAtual? itemAtual.total : 0); + const frete = entrega.tipo==='frete' && entrega.cotado ? entrega.valor : 0; + const soma = itens + frete; + const n = pedido.length + (itemAtual?1:0); + const eco = [...pedido, ...(itemAtual?[itemAtual]:[])] + .reduce((t,it)=>t+(TABELA[it.modo]-it.unit)*it.cob, 0); + $('resLin').innerHTML = + '
'+n+(n===1?' item':' itens')+''+rs(itens+eco)+'
'+ + (eco>0.01? '
Desconto pela nota'+ + '− '+rs(eco)+'
' : '')+ + '
Frete'+ + (entrega.tipo==='retira' ? 'Grátis' + : entrega.cotado ? rs(frete) : 'a cotar')+'
'+ + '
Revisão e reencaixe'+ + 'Grátis
'; + $('carrTot').textContent=rs(soma); + $('bPagar').textContent = (pedido.length + (itemAtual?1:0))>1 + ? 'Pagar os '+(pedido.length+(itemAtual?1:0))+' itens de uma vez' + : 'Ir para o pagamento'; + if(pedido.length || itemAtual) $('carr').classList.add('on'); + if($('cartLink')) $('cartLink').textContent='Carrinho ('+n+')'; + window.dispatchEvent(new Event('dtf-cart-changed')); +} + +function carrinho(){ + const base=precoBase(nota), cob=cobrar(metros); + const unit=base, tot=cob*unit, sobra=+(cob-metros).toFixed(2); + $('carr').classList.add('on'); + let l='
'+MODOS[modo].tit+''+rs(base)+'/m
'; + { + const teto=TABELA[modo], eco=(teto-base)*cob; + l = '
'+MODOS[modo].tit+' · tabela'+rs(teto)+'/m
'; + l += eco>0.01 + ? '
Desconto pela nota '+nota+'−'+rs(eco)+'
' + : '
Nota '+nota+' · sem desconto—
'; + } + if(!ehFolha()) l += '
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=> + '
'+l[1]+''+ + '
'+escapeHTML(l[2])+''+escapeHTML(l[3])+'
').join(''); + $('prev').classList.add('on'); + $('pArea').innerHTML='
montando a prévia…
'; + try{ previaArtes(); } + catch(e){ $('pArea').innerHTML='
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.')+'':'')+ + '
'; + }else{ + const teto=TABELA[modo], piso=pisoEscada(); + const dif=(x.m>0)? (teto-piso)*cobrar(x.m*(x.rep||1)) : 0; + medida='
'+ + ''+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.
' + : ''; + return '
'+ + (folhas.length>1? (i+1)+'. ':'')+escapeHTML(x.f.name)+''+ + ''+fmt(x.f.size)+''+ + '
'+ + barra+medida+ + (x.m>0 ? + '
'+ + '
'+ + '
'+ + '
'+fmtM(sub)+' m'+ + (x.rep>1? ' · '+x.rep+' × '+fmtM(x.m)+' m':'')+'
'+ + '
' : '')+ + '
'; + }).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':'')+'
'; + }else if(partes>1 && tot>0){ + $('lista').innerHTML += '
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='
'; + calc=''+n+'% · '+dpi+' DPI'+ + a.q+' × '+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm'; + const sug=sugestao(a); + dica = sug + ? '
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.
' + : ''; + dica = vizPeca(a) + dica; + } + return '
'+escapeHTML(a.f.name)+''+ + ''+a.px+' px'+ + '
'+ + '
'+ + '
'+ + '
'+ + (TAMANHOS[larguraFilme()]||[]).map(t=>'').join('')+ + ''+ + ''+ + ''+ + '
'+barra+'
'+calc+'
'+dica+'
'; + }).join(''); + $('lista').querySelectorAll('[data-rm]').forEach(b=>b.addEventListener('click',()=>{artes.splice(+b.dataset.rm,1);pintaArtes();})); + $('lista').querySelectorAll('[data-cm]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.cm].cm=+i.value||0;pintaArtes();})); + $('lista').querySelectorAll('[data-q]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.q].q=Math.max(1,Math.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',()=>{ + artes[+b.dataset.usar].cm=+b.dataset.cm; pintaArtes(); + })); + $('lista').querySelectorAll('[data-tam]').forEach(b=>b.addEventListener('click',()=>{ + artes[+b.dataset.tam].cm=+b.dataset.t; pintaArtes(); + })); + $('lista').querySelectorAll('[data-giro]').forEach(b=>b.addEventListener('click',()=>{ + const a=artes[+b.dataset.giro]; a.giro=a.giro?0:90; pintaArtes(); + })); + $('lista').querySelectorAll('[data-esp]').forEach(b=>b.addEventListener('click',()=>{ + const a=artes[+b.dataset.esp]; a.esp=!a.esp; pintaArtes(); + })); + resumoArtes(); + previaAoVivo(); +} +function resumoArtes(){ + const r=$('rA'), prontas=artes.filter(a=>a.cm>0); + if(!prontas.length){ r.style.display='none'; return; } + const m=montagemCm/100; // a mesma folha que aparece ao lado + const notas=prontas.map(a=>Math.max(6,Math.min(100,Math.round((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); + const falta=artes.length-prontas.length; + r.style.display='block'; r.className='resumoA on'; + r.innerHTML='
artes com tamanho'+prontas.length+' de '+artes.length+'
'+ + '
qualidade média'+media+'%
'+ + (piora pior arte'+pior+'%':'')+ + (m>0?'
folha montada'+fmtM(m)+' m
'+ + (cobrar(m)>m+0.001?'
cobrança'+fmtM(cobrar(m))+ + ' m'+(cobrar(m)===MINIMO_M?' · mínimo':'')+'
':''):'')+ + (falta?'
faltam informar'+falta+'
':''); +} + 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=> + '
'+ + (i[0]==='ok'?'✓':i[0]==='av'?'!':i[0]==='fix'?'↻':'×')+''+ + '
'+escapeHTML(i[1])+''+escapeHTML(i[2])+'
').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