Order demos: spine choice, artwork templates, PDF and AI uploads
Deploy test site / deploy (push) Successful in 50s

User request (October 2026): the order tool needs a thin / thick spine
selection, templates for the jacket and label upload, and must accept AI
and PDF files.

Both artist demos (artist-order, make-a-record):
- Spine: thin 3 mm (one record) or thick 5 mm (two records, or a thick
  insert); a double album in a single jacket takes 5 mm, thin is then
  disabled; 7", gatefold (7 or 9 mm) and own jacket disable the row with
  the reason. Quote line 'Thick 5 mm spine' (R.spine 12 / 10 THB per
  copy, estimate from QRP 0.90 vs 0.55 USD and Kudos 137.50 GBP/order).
  Mock-up draws the spine edge to scale; caption names it.
- Templates: vector PDFs written in the browser for the current choice:
  jacket spread (back, spine, front; 633/635 x 315 mm for 12" from
  Mighty Media Discs), gatefold with an inside page, labels with a page
  per side and the dinked hole; trim and bleed boxes set, 3 mm bleed,
  5 mm safe area, folds, crop marks, guidance notes.
- Uploads accept PDF, AI (PDF-compatible) and images. PDFs are read in
  the browser (object streams inflated with DecompressionStream): page
  boxes, pages, PDF/X, creator, colour spaces, font embedding; checks
  against the chosen spine (a 5 mm spread on a 3 mm order is flagged),
  bleed, CMYK, fonts; old PostScript-only AI files are refused with the
  fix. Image spreads are measured the same way. Preview via pdf.js
  (jsDelivr, loaded only when a PDF arrives); the front panel goes into
  the mock-up.

artist-platform.md: new section 'Artwork templates and files' with the
sources; preflight step links it; RI 'left out' list updated. README
demo bullet. Checked headless on Edge: fixtures (3/5 mm spreads, no
bleed, AI, legacy AI, front only, labels, 150 dpi PNG), downloaded
templates parsed with PyMuPDF, 390/1024/1280 px no overflow, dark and
light, no page errors; pw_mr_check --tour and pw_ri unchanged
(37,836 / 232).
This commit is contained in:
grabowski committed 2026-10-16 12:34:58 +07:00
1 parent 5c303dfc61
commit 07ef9c0b9a
4 files changed
+446 -48

No files matched your search

+197 -20
View File
@@ -194,6 +194,10 @@ document.documentElement.setAttribute("data-theme",t);})();
/* artwork */
.art { display: flex; gap: 12px; align-items: flex-start; }
.art img { width: 72px; height: 72px; object-fit: cover; border-radius: 6px; border: 1px solid var(--line); flex: none; }
.art .aph { width: 72px; height: 72px; border-radius: 6px; border: 1px solid var(--line); flex: none; display: grid; place-items: center; font-weight: 700; color: var(--mute); }
.tplk { margin: 6px 2px 10px; font-size: 13px; color: var(--mute); line-height: 1.45; }
.tplk .lnk { padding: 0; font-size: 13px; color: var(--acc); }
.tplk .lnk:disabled { color: var(--faint); cursor: not-allowed; text-decoration: none; }
.art ul { list-style: none; padding: 0; margin: 2px 0 6px; font-size: 13px; }
.art li { display: flex; gap: 6px; }
@@ -503,6 +507,7 @@ document.documentElement.setAttribute("data-theme",t);})();
<div class="rowc mt">
<div class="field"><span class="fl">Labels</span><div class="seg" data-k="labels"><button type="button" data-v="printed">Printed</button><button type="button" data-v="white">Plain white</button><button type="button" data-v="black">Plain black</button></div></div>
<div class="field"><span class="fl">Inner sleeve</span><div class="seg" data-k="inner"><button type="button" data-v="paper">Paper</button><button type="button" data-v="poly">Poly-lined</button></div></div>
<div class="field" id="fSpine"><span class="fl">Spine <small class="mute" id="spineHint"></small></span><div class="seg" data-k="spine"><button type="button" data-v="thin">Thin, 3 mm</button><button type="button" data-v="thick">Thick, 5 mm</button></div></div>
<div class="field"><span class="fl">Extras</span><div class="chips"><button type="button" class="chip" data-b="shrink">Shrink wrap</button><button type="button" class="chip" data-b="insert">Printed insert</button><button type="button" class="chip" data-b="sticker">Hype sticker</button><button type="button" class="chip" data-b="numbered">Numbered edition</button><button type="button" class="chip" data-b="dlcard">Download card</button><button type="button" class="chip" data-b="barcode">Barcode sticker</button></div></div>
</div>
</div>
@@ -545,8 +550,8 @@ document.documentElement.setAttribute("data-theme",t);})();
<div class="block">
<h2>Artwork</h2>
<div class="g2f">
<div><label class="drop" id="dropJacket"><input type="file" id="fileJacket" accept="image/*" hidden><span class="dic">&#x25A3;</span><span><b>Jacket front</b>, square, 300 dpi</span></label><div id="artJacket"></div></div>
<div><label class="drop" id="dropLabel"><input type="file" id="fileLabel" accept="image/*" hidden><span class="dic">&#x25CE;</span><span><b>Label</b>, square, 300 dpi</span></label><div id="artLabel"></div></div>
<div><label class="drop" id="dropJacket"><input type="file" id="fileJacket" accept=".pdf,.ai,application/pdf,application/postscript,application/illustrator,image/*" hidden><span class="dic">&#x25A3;</span><span><b>Jacket</b>, PDF, AI or a 300 dpi image</span></label><div class="tplk" id="tplJacket"></div><div id="artJacket"></div></div>
<div><label class="drop" id="dropLabel"><input type="file" id="fileLabel" accept=".pdf,.ai,application/pdf,application/postscript,application/illustrator,image/*" hidden><span class="dic">&#x25CE;</span><span><b>Labels</b>, PDF, AI or a 300 dpi image</span></label><div class="tplk" id="tplLabel"></div><div id="artLabel"></div></div>
</div>
</div>
<div class="block panel" id="checkPanel">
@@ -658,7 +663,7 @@ const R={
press:{v:{12:115,10:95,7:55}},g180:{v:15},colour:{v:{solid:20,marbled:35,splatter:45}},
inner:{v:{paper:{12:7,10:6,7:4},poly:{12:15,10:12,7:8}}},assy:{v:10},
jacket:{v:{printed:{12:[86,73,64,55],10:[68,58,51,44],7:[27,23,20,17]},gatefold:{12:[127,109,95,82]},plain:{12:[27,27,27,27],10:[20,20,20,20],7:[8,8,8,8]}}},
handling:{v:0.10},shrink:{v:5},insert:{v:12},sticker:{v:6},numbered:{v:5},
spine:{v:{12:12,10:10}},handling:{v:0.10},shrink:{v:5},insert:{v:12},sticker:{v:6},numbered:{v:5},
/* added October 2026, estimates with Record Industry's calculator as the basis (see artist-order.html) */
tape:{v:3800},clean:{v:2300},plainLabel:{v:-12},dlcard:{v:43},barcode:{v:6},fee:{v:0.035},minRun:{v:100},deposit:{v:0.5}
};
@@ -707,7 +712,7 @@ const CITIES=["Chiang Mai","Bangkok","Chiang Rai","Khon Kaen","Lampang","Phuket"
const TODAY=new Date(); TODAY.setHours(12,0,0,0);
const S={
step:1,cur:"THB",size:"12",rpm:"33",discs:1,weight:140,colour:"black",c1:"#b3202a",c2:"#f2c14e",
qty:300,inner:"paper",jacket:"printed",shrink:true,insert:false,sticker:false,numbered:false,
qty:300,inner:"paper",jacket:"printed",spine:"thin",shrink:true,insert:false,sticker:false,numbered:false,
mastering:true,tps:5,loud:false,artist:"Mae Ping Collective",title:"Night Market Songs",
source:"digital",labels:"printed",cleaning:true,dinked:false,dlcard:false,barcode:false,catno:"MPC001",
tracks:[],demoTracks:true,rights:false,gate1:false,art:{jacket:null,label:null},
@@ -761,6 +766,7 @@ function quote(q){
C.push({t:"Inner sleeve, "+(S.inner==="poly"?"poly-lined":"paper"),n:d,u:du,r:R.inner.v[S.inner][sz]});
C.push({t:"Assembly and packing",n:1,u:"",r:R.assy.v});
if(S.jacket!=="none"){const p=R.jacket.v[S.jacket][sz][tierIdx(q)];C.push({t:JNAME[S.jacket]+(sz==="7"&&S.jacket!=="plain"?" (picture sleeve)":""),n:1,u:"",r:Math.round(p*(1+R.handling.v)),pass:p});}
if(thickOn())C.push({t:"Thick 5 mm spine",n:1,u:"",r:R.spine.v[sz]});
if(S.labels!=="printed")C.push({t:"Plain "+S.labels+" labels instead of printed",n:d,u:du,r:R.plainLabel.v});
if(S.shrink)C.push({t:"Shrink wrap",n:1,u:"",r:R.shrink.v});
if(S.dlcard)C.push({t:"Download card",n:1,u:"",r:R.dlcard.v});
@@ -876,18 +882,188 @@ async function jpegComponents(file){
const len=(b[i+2]<<8)|b[i+3];if(m>=0xC0&&m<=0xCF&&m!==0xC4&&m!==0xC8&&m!==0xCC)return b[i+9];i+=2+len;}
return null;
}
async function readImage(file){
/* ---- artwork: spine, templates, PDF and AI files (October 2026) ----
12" panels 315 mm: 633 x 315 mm with a 3 mm spine, 635 x 315 with 5 mm (Mighty Media Discs templates); 3 mm holds one
record, 5 mm two (Implant, Jungle Record Press); gatefold 7 mm (RPM Records), 9 mm for two (XVinylX), all checked October 2026.
10" and 7" panels and the labels follow SIZE_MM; 3 mm bleed and 5 mm safe area are this demo's choice; hole 7.24 mm, dinked 38.2 mm. */
const TPL={panel:{12:315,10:260,7:184},bleed:3,safe:5,hole:7.24,dink:38.2};
const singleJ=()=>S.jacket==="printed"||S.jacket==="plain";
const spineForced=()=>singleJ()&&S.discs>1&&S.size!=="7";
const thickOn=()=>singleJ()&&S.size!=="7"&&(S.spine==="thick"||S.discs>1);
function spineMM(){if(S.jacket==="none")return 0;if(S.jacket==="gatefold")return S.discs>1?9:7;return thickOn()?5:3;}
function spineState(){
const off=!singleJ()||S.size==="7";
return {off,forced:spineForced(),
why:S.jacket==="gatefold"?"A gatefold has its own "+spineMM()+" mm spine":S.jacket==="none"?"You supply the jacket":S.size==="7"?"7\" jackets come with a 3 mm spine":"",
hint:S.jacket==="none"?"you supply the jacket":S.jacket==="gatefold"?"the gatefold has a "+spineMM()+" mm spine":S.size==="7"?"3 mm on a 7\" jacket":thickOn()?(spineForced()?"5 mm: needed for two records":"5 mm: room for a thick insert or a poster"):"3 mm: one record in its inner sleeve"};
}
function tplDims(k){const B=TPL.bleed;
if(k==="label"){const d=SIZE_MM[S.size].lab;return {d,txt:d+" mm label, "+(d+2*B)+" mm with bleed, a page per side ("+sideNames().join(", ")+")"};}
const P=TPL.panel[S.size],sp=spineMM(),w=2*P+sp;
return {P,sp,w,h:P,txt:(w+2*B)+" × "+(P+2*B)+" mm with bleed, "+sp+" mm spine"+(S.jacket==="gatefold"?", outside and inside":"")};}
const tplOff=k=>k==="jacket"?(S.jacket==="printed"||S.jacket==="gatefold"?"":S.jacket==="none"?"You supply the jacket":"A plain jacket has no print"):(S.labels==="printed"?"":"Plain labels have no print");
/* templates as vector PDF, written here: trim and bleed boxes set, guides on the page */
function pdfStr(s){return "("+String(s).replace(/[^\x20-\x7e]/g,"?").replace(/[\\()]/g,m=>"\\"+m)+")";}
function pdfBlob(pages,title){
const objs=[],add=s=>objs.push(s);
add("");add("");add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>");
add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>");
add("<< /Title "+pdfStr(title)+" /Creator (CNX Vinyl demo template, working name) >>");
const kids=[],bx=a=>"["+a.map(v=>v.toFixed(2)).join(" ")+"]";
for(const p of pages){const c=add("<< /Length "+p.ops.length+" >>\nstream\n"+p.ops+"\nendstream");
kids.push(add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 "+p.w.toFixed(2)+" "+p.h.toFixed(2)+"] /TrimBox "+bx(p.trim)+" /BleedBox "+bx(p.bleed)+" /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents "+c+" 0 R >>"));}
objs[0]="<< /Type /Catalog /Pages 2 0 R >>";objs[1]="<< /Type /Pages /Kids ["+kids.map(k=>k+" 0 R").join(" ")+"] /Count "+kids.length+" >>";
let out="%PDF-1.4\n";const off=[];
objs.forEach((o,i)=>{off.push(out.length);out+=(i+1)+" 0 obj\n"+o+"\nendobj\n";});
const xr=out.length;
out+="xref\n0 "+(objs.length+1)+"\n0000000000 65535 f \n"+off.map(o=>String(o).padStart(10,"0")+" 00000 n \n").join("")+"trailer\n<< /Size "+(objs.length+1)+" /Root 1 0 R /Info 5 0 R >>\nstartxref\n"+xr+"\n%%EOF\n";
return new Blob([out],{type:"application/pdf"});
}
function tplPages(k){
const K=72/25.4,B=TPL.bleed,SF=TPL.safe,M=16,MB=30,TOP=14,f=v=>(v*K).toFixed(2),pages=[];
const ST={trim:"0 0 0 RG 0.7 w [] 0 d",bleed:"0.85 0.12 0.12 RG 0.6 w [3 2] 0 d",safe:"0.1 0.45 0.85 RG 0.6 w [1.5 1.5] 0 d",fold:"0.55 0.2 0.7 RG 0.6 w [4 2] 0 d",mark:"0 0 0 RG 0.3 w [] 0 d"};
const wrap=(s,mm,size)=>{const per=Math.max(20,Math.floor(mm*K/(size*0.5))),L=[];let ln="";for(const w of s.split(" ")){if((ln+" "+w).trim().length>per){L.push(ln);ln=w;}else ln=(ln+" "+w).trim();}if(ln)L.push(ln);return L;};
function page(tw,th,draw,head,foot){
const W=tw+2*B+2*M,H=th+2*B+M+MB+TOP,ox=M+B,oy=MB+B;let o="";
const P=(x,y)=>f(ox+x)+" "+f(oy+y);
const g={
rect:(x,y,w,h,s)=>{o+="q "+ST[s]+" "+P(x,y)+" "+f(w)+" "+f(h)+" re S Q\n";},
line:(x1,y1,x2,y2,s)=>{o+="q "+ST[s]+" "+P(x1,y1)+" m "+P(x2,y2)+" l S Q\n";},
circ:(cx,cy,r,s,fill)=>{const c=0.5523*r;o+="q "+(fill?fill+" g ":"")+ST[s]+" "+P(cx+r,cy)+" m "+P(cx+r,cy+c)+" "+P(cx+c,cy+r)+" "+P(cx,cy+r)+" c "+P(cx-c,cy+r)+" "+P(cx-r,cy+c)+" "+P(cx-r,cy)+" c "+P(cx-r,cy-c)+" "+P(cx-c,cy-r)+" "+P(cx,cy-r)+" c "+P(cx+c,cy-r)+" "+P(cx+r,cy-c)+" "+P(cx+r,cy)+" c "+(fill?"B":"S")+" Q\n";},
text:(x,y,size,s,op)=>{op=op||{};const x0=op.c?x-String(s).length*size*0.25/K:x;o+="q "+(op.grey||"0.25")+" g BT /"+(op.b?"F2":"F1")+" "+size+" Tf 1 0 0 1 "+P(x0,y)+" Tm "+pdfStr(s)+" Tj ET Q\n";}
};
draw(g);
[[0,0],[tw,0],[0,th],[tw,th]].forEach(([x,y])=>{const sx=x?1:-1,sy=y?1:-1;g.line(x+sx*(B+2),y,x+sx*(B+9),y,"mark");g.line(x,y+sy*(B+2),x,y+sy*(B+9),"mark");});
let y=th+B+M+TOP-7;const tm=tw+2*B+2*M-12;
head.forEach((s,i)=>wrap(s,tm,i?6.5:9).forEach(l=>{g.text(-B-M+6,y,i?6.5:9,l,{b:!i,grey:i?"0.3":"0.1"});y-=i?3:4.2;}));
y=-B-14;foot.forEach(s=>wrap(s,tm,6).forEach(l=>{g.text(-B-M+6,y,6,l,{grey:"0.35"});y-=2.8;}));
pages.push({w:W*K,h:H*K,trim:[ox*K,oy*K,(ox+tw)*K,(oy+th)*K],bleed:[(ox-B)*K,(oy-B)*K,(ox+tw+B)*K,(oy+th+B)*K],ops:o});
}
const foot=["Put your artwork on a layer under these guides and hide the guides before you export. Deliver a PDF (PDF/X-4 preferred) or an Illustrator file saved with PDF compatibility; CMYK; images 300 dpi at print size; text outlined or fonts embedded.",
"Demo template: sizes follow published plant templates (3 mm spine for one record, 5 mm for two). The production template comes from the packaging partner. CNX Vinyl is a working name."];
if(k==="jacket"){
const D=tplDims("jacket"),P=D.P,sp=D.sp,w=D.w,h=D.h,gf=S.jacket==="gatefold",nm=S.size+"\" "+(gf?"gatefold jacket":"jacket");
const spread=inside=>page(w,h,g=>{
g.rect(-B,-B,w+2*B,h+2*B,"bleed");g.rect(0,0,w,h,"trim");g.line(P,-B,P,h+B,"fold");g.line(P+sp,-B,P+sp,h+B,"fold");
g.rect(SF,SF,P-2*SF,h-2*SF,"safe");g.rect(P+sp+SF,SF,P-2*SF,h-2*SF,"safe");
g.text(P/2,h/2,16,inside?"INSIDE LEFT":"BACK",{c:1,b:1,grey:"0.78"});g.text(P+sp+P/2,h/2,16,inside?"INSIDE RIGHT":"FRONT",{c:1,b:1,grey:"0.78"});
g.text(P+sp/2,h+B+3,6,"spine "+sp+" mm",{c:1});
},["CNX Vinyl demo template: "+nm+", "+sp+" mm spine"+(gf?(inside?", page 2: inside":", page 1: outside"):""),
"Trim "+w+" x "+h+" mm: "+(inside?"inside left "+P+", spine "+sp+", inside right "+P:"back "+P+", spine "+sp+", front "+P)+" mm. Bleed "+B+" mm (red), safe area "+SF+" mm inside the trim (blue), folds (purple)."],foot);
spread(false);if(gf)spread(true);
}else{
const d=SIZE_MM[S.size].lab,r=d/2,dk=dinkOn(),hole=dk?TPL.dink:TPL.hole,keep=dk?3:8;
sideNames().forEach(sd=>page(d,d,g=>{
g.circ(r,r,r+B,"bleed");g.circ(r,r,r,"trim");g.circ(r,r,r-SF,"safe");g.circ(r,r,hole/2+keep,"safe");g.circ(r,r,hole/2,"trim","0.85");
g.text(r,r+hole/2+keep+4,8,"SIDE "+sd,{c:1,b:1,grey:"0.7"});
},["CNX Vinyl demo template: "+S.size+"\" label, side "+sd,
"Label "+d+" mm across, "+(d+2*B)+" mm with bleed (red). Centre hole "+hole+" mm"+(dk?" (dinked)":"")+": keep text "+keep+" mm clear of it and "+SF+" mm inside the edge (blue). One page per side."],foot));
}
return pages;
}
function downloadTpl(k){
if(tplOff(k))return;
const name="cnx-demo-template-"+S.size+"in-"+(k==="label"?"labels-"+sideNames().join(""):(S.jacket==="gatefold"?"gatefold":"jacket")+"-"+spineMM()+"mm-spine")+".pdf";
const a=document.createElement("a");a.href=URL.createObjectURL(pdfBlob(tplPages(k),name.slice(0,-4)));a.download=name;document.body.appendChild(a);a.click();
setTimeout(()=>{URL.revokeObjectURL(a.href);a.remove();},5000);toast("Template downloaded: "+name);
}
/* PDF and Illustrator files: read in this browser. Page boxes, colour spaces and fonts come from the file
(object streams inflated with DecompressionStream); the preview is rendered with pdf.js, loaded only when a PDF arrives. */
const lat=u=>new TextDecoder("latin1").decode(u);
async function inflate(u){const rd=new Blob([u]).stream().pipeThrough(new DecompressionStream("deflate")).getReader(),ch=[];let n=0;
try{for(;;){const x=await rd.read();if(x.done)break;ch.push(x.value);n+=x.value.length;}}catch(_){}
const o=new Uint8Array(n);let p=0;for(const c of ch){o.set(c,p);p+=c.length;}return o;}
async function pdfFacts(file){
const u8=new Uint8Array(await file.arrayBuffer()),head=lat(u8.subarray(0,4096)),a={name:file.name,kind:"pdf",ai:/\.ai$/i.test(file.name)};
if(/^%!PS-Adobe/.test(head)){a.kind="ps";return a;}
const vm=/%PDF-(\d\.\d)/.exec(head);if(!vm){a.kind="bad";return a;}
a.ver=vm[1];let T=lat(u8);const re=/\/Type\s*\/ObjStm/g,extra=[];let m,n=0;
while((m=re.exec(T))&&n<500){n++;let s=T.indexOf("stream",m.index);if(s<0)break;const d0=T.slice(Math.max(0,m.index-400),s);s+=6;if(T.charCodeAt(s)===13)s++;if(T.charCodeAt(s)===10)s++;
let e=T.indexOf("endstream",s);if(e<0)break;re.lastIndex=e;while(e>s&&(T.charCodeAt(e-1)===10||T.charCodeAt(e-1)===13))e--;
if(/FlateDecode/.test(d0))extra.push(lat(await inflate(u8.subarray(s,e))));}
T+="\n"+extra.join("\n");
const box=(src,nm)=>{const x=new RegExp("\\/"+nm+"\\s*\\[([^\\]]+)\\]").exec(src);if(!x)return null;const v=x[1].trim().split(/[\s,]+/).map(Number);return v.length===4&&v.every(isFinite)?v:null;};
const pm=/\/Type\s*\/Page(?![a-zA-Z])/.exec(T),win=pm?T.slice(Math.max(0,pm.index-1500),pm.index+1500):"";
a.media=box(win,"MediaBox")||box(T,"MediaBox");a.trimB=box(win,"TrimBox");a.bleedB=box(win,"BleedBox");
let c=0;for(const x of T.matchAll(/\/Count\s+(\d+)/g))c=Math.max(c,+x[1]);a.pages=c||1;
a.pdfx=/GTS_PDFX/.test(T);
const cr=/<xmp:CreatorTool>([^<]{1,80})</.exec(T)||/\/Creator\s*\(([^)]{1,80})\)/.exec(T);a.creator=cr?cr[1]:"";
a.ai=a.ai||/Illustrator/i.test(a.creator)||/AIPrivateData/.test(T);
a.cmyk=/\/DeviceCMYK|\/Separation|\/DeviceN/.test(T)||/\/N\s+4\b/.test(T);a.rgb=/\/DeviceRGB/.test(T)||/\/N\s+3\b/.test(T);
a.fonts=(T.match(/\/Type\s*\/Font\b/g)||[]).length;a.emb=(T.match(/\/FontFile[23]?\b/g)||[]).length;
a.u8=u8;return a;
}
let PDFJS=null;
async function pdfjs(){if(!PDFJS){const u="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/build/",m=await import(u+"pdf.min.mjs");m.GlobalWorkerOptions.workerSrc=u+"pdf.worker.min.mjs";PDFJS=m;}return PDFJS;}
function crop(src,sx,sy,sw,sh,max){const s=Math.min(1,max/Math.max(sw,sh)),c=document.createElement("canvas");c.width=Math.max(1,Math.round(sw*s));c.height=Math.max(1,Math.round(sh*s));c.getContext("2d").drawImage(src,sx,sy,sw,sh,0,0,c.width,c.height);return c.toDataURL("image/jpeg",0.88);}
async function pdfPreview(a,u8,k){
try{const m=await pdfjs(),doc=await m.getDocument({data:u8,isEvalSupported:false}).promise,pg=await doc.getPage(1),v1=pg.getViewport({scale:1}),vp=pg.getViewport({scale:Math.min(4,1600/Math.max(v1.width,v1.height))});
const cv=document.createElement("canvas");cv.width=Math.ceil(vp.width);cv.height=Math.ceil(vp.height);const cx=cv.getContext("2d");cx.fillStyle="#fff";cx.fillRect(0,0,cv.width,cv.height);
await pg.render({canvasContext:cx,viewport:vp}).promise;
const q=vp.convertToViewportRectangle(a.trimB||a.media||pg.view),L=Math.min(q[0],q[2]),R=Math.max(q[0],q[2]),T=Math.min(q[1],q[3]),h=Math.max(q[1],q[3])-T;
a.url=k==="jacket"&&(R-L)/h>1.5?crop(cv,R-h,T,h,h,800):crop(cv,L,T,R-L,h,800);doc.destroy();
}catch(e){a.noPrev=true;}
}
const mmOf=b=>b?[(b[2]-b[0])*25.4/72,(b[3]-b[1])*25.4/72]:null;
function pdfChecks(k,a){
const out=[],B=TPL.bleed,r1=v=>Math.round(v*10)/10,near=(x,y)=>Math.abs(x-y)<=1;
out.push(["ok",(a.ai?"Illustrator file, saved PDF-compatible":"PDF "+a.ver)+(a.pdfx?", PDF/X":"")]);
const tr=mmOf(a.trimB),md=mmOf(a.media),sz=tr||md||[0,0],tw=sz[0],th=sz[1];let bl=null;
if(tr){const ref=a.bleedB||a.media;bl=Math.min(a.trimB[0]-ref[0],a.trimB[1]-ref[1],ref[2]-a.trimB[2],ref[3]-a.trimB[3])*25.4/72;}
const fit=(W,H)=>{if(tr)return near(tw,W)&&near(th,H)?"trim":"";if(near(tw,W+2*B)&&near(th,H+2*B)){bl=B;return "bleed";}if(near(tw,W)&&near(th,H)){bl=0;return "trim";}return "";};
if(k==="label"){const d=SIZE_MM[S.size].lab,ns=sideNames().length,ok=fit(d,d);
out.push(ok?["ok",(ok==="trim"?"Trim ":"Page ")+r1(tw)+" mm: the "+S.size+"\" label"+(ok==="bleed"?" with 3 mm bleed":"")]:["bad",(tr?"Trim ":"Page ")+r1(tw)+" × "+r1(th)+" mm; the "+S.size+"\" label is "+d+" mm ("+(d+2*B)+" with bleed)"]);
out.push(a.pages>=ns?["ok",a.pages+" page"+(a.pages>1?"s":"")+": side"+(ns>1?"s ":" ")+sideNames().join(", ")]:["warn",a.pages+" page"+(a.pages>1?"s":"")+" for "+ns+" sides: side "+sideNames().slice(a.pages).join(", ")+" missing"]);
}else{const D=tplDims("jacket"),ok=fit(D.w,D.h),hh=ok==="bleed"?th-2*B:th,ww=ok==="bleed"?tw-2*B:tw;
if(ok)out.push(["ok",r1(ww)+" × "+r1(hh)+" mm: back, "+D.sp+" mm spine, front"]);
else if(fit(D.P,D.P))out.push(["warn","Front only: the back and the "+D.sp+" mm spine are needed before print (use the template)"]);
else{const H=near(th,D.h)?th:near(th,D.h+2*B)?th-2*B:0,W=H===th?tw:tw-2*B,sp=W-2*D.P;
out.push(H&&sp>0.5&&sp<12?["bad","The spread fits a "+Math.round(sp)+" mm spine; this order has "+D.sp+" mm. Use the template"]:["bad",r1(tw)+" × "+r1(th)+" mm does not match the template ("+D.w+" × "+D.h+" mm trim)"]);}
if(S.jacket==="gatefold")out.push(a.pages>=2?["ok","Page 2: the inside spread"]:["warn","Gatefold: the inside spread is needed as page 2"]);
}
out.push(bl==null?["warn","No trim box: the bleed could not be checked"]:bl>=B-0.2?["ok",r1(bl)+" mm bleed"]:["bad",(bl<=0.2?"No bleed":r1(bl)+" mm bleed")+": 3 mm needed"]);
out.push(a.cmyk&&!a.rgb?["ok","CMYK"]:a.cmyk?["warn","RGB and CMYK mixed: convert to CMYK"]:a.rgb?["warn","RGB: the printer converts it to CMYK, and bright colours can shift"]:["pend","Colour checked on the proof: no colour space declared in the file"]);
out.push(!a.fonts?["ok","No live text: outlined"]:a.emb?["ok","Fonts embedded"]:["bad","Fonts not embedded: outline the text or embed the fonts"]);
return out;
}
function rasterChecks(k,a){
const out=[],B=TPL.bleed;let mmH;
if(k==="label"){mmH=SIZE_MM[S.size].lab+2*B;out.push(Math.abs(a.w/a.h-1)<0.01?["ok","Square, for side A"]:["bad","Not square ("+a.w+" × "+a.h+" px)"]);}
else{const D=tplDims("jacket"),r=a.w/a.h;mmH=D.h+2*B;
if(r>1.5){const wmm=a.w/(a.h/mmH)-2*B,sp=wmm-2*D.P;
out.push(Math.abs(wmm-D.w)<=1.2?["ok","Spread: back, "+D.sp+" mm spine, front"]:sp>0.5&&sp<12?["bad","The spread fits a "+Math.round(sp)+" mm spine; this order has "+D.sp+" mm. Use the template"]:["bad","Spread "+Math.round(wmm)+" mm wide; the template is "+D.w+" mm plus bleed"]);
if(S.jacket==="gatefold")out.push(["warn","Gatefold: the inside spread is needed too (page 2 of a PDF)"]);}
else if(Math.abs(r-1)<0.01)out.push(["warn","Front only: the back and the "+D.sp+" mm spine are needed before print (use the template)"]);
else out.push(["bad","Neither the spread nor a square front ("+a.w+" × "+a.h+" px)"]);}
const need=Math.round(mmH/25.4*300),dpi=Math.round(a.h/(mmH/25.4));
out.push(a.h>=need?["ok",dpi+" dpi at "+mmH+" mm high"]:a.h>=need*0.8?["warn",dpi+" dpi, a little under 300 (needs "+need+" px high)"]:["bad",dpi+" dpi: too low for print (needs "+need+" px high)"]);
out.push(a.cmyk?["ok","CMYK"]:["warn","RGB: the printer converts it to CMYK, and bright colours can shift"]);
return out;
}
async function readImage(file,k){
const url=URL.createObjectURL(file),img=new Image();img.src=url;await img.decode();
const comps=(file.type==="image/jpeg"||/\.jpe?g$/i.test(file.name))?await jpegComponents(file):null;
return {url,w:img.naturalWidth,h:img.naturalHeight,cmyk:comps===4,name:file.name};
const a={kind:"img",url,w:img.naturalWidth,h:img.naturalHeight,cmyk:comps===4,name:file.name};
if(k==="jacket"&&a.w/a.h>1.5)a.url=crop(img,a.w-a.h,0,a.h,a.h,800);
return a;
}
function artChecks(k){
const a=S.art[k];if(!a)return null;if(a.err)return [["pend",a.err]];
const mm=ART[k].mm[S.size],need=Math.round(mm/25.4*300),px=Math.min(a.w,a.h),dpi=Math.round(px/(mm/25.4)),out=[];
out.push(Math.abs(a.w/a.h-1)<0.01?["ok","Square"]:["bad","Not square ("+a.w+" × "+a.h+" px)"]);
out.push(px>=need?["ok",dpi+" dpi at "+mm+" mm"+(k==="jacket"?" incl. 3 mm bleed":"")]:px>=need*0.8?["warn",dpi+" dpi, a little under 300 (needs "+need+" px)"]:["bad",dpi+" dpi: too low for print (needs "+need+" px)"]);
out.push(a.cmyk?["ok","CMYK"]:["warn","RGB: the printer converts it to CMYK, bright colours can shift"]);
return out;
if(a.kind==="ps")return [["bad","Illustrator file saved without PDF content: save it with Create PDF Compatible File, or export a PDF"]];
if(a.kind==="bad")return [["bad","Not a PDF, AI or image file"]];
return a.kind==="pdf"?pdfChecks(k,a):rasterChecks(k,a);
}
const artThumb=a=>a.url?`<img src="${a.url}" alt="">`:`<span class="aph">${a.kind==="ps"||a.ai?"AI":a.kind==="pdf"?"PDF":"?"}</span>`;
const artNote=a=>a.loading?`<li><span class="ci pend" style="width:18px;height:18px;font-size:10px">&#x25CB;</span> Rendering a preview</li>`:a.noPrev?`<li><span class="ci pend" style="width:18px;height:18px;font-size:10px">&#x25CB;</span> No preview: pdf.js could not be loaded. The checks above read the file itself.</li>`:"";
async function handleArt(k,files){
const f=files.find(f=>/^image\//.test(f.type)||/\.(jpe?g|png|tiff?|webp|pdf|ai)$/i.test(f.name)||/pdf|postscript|illustrator/.test(f.type));if(!f)return;
const vec=/\.(pdf|ai)$/i.test(f.name)||/pdf|postscript|illustrator/.test(f.type);
let a;try{a=vec?await pdfFacts(f):await readImage(f,k);}catch(err){a={name:f.name,err:"This browser cannot read the file; the real platform checks it after upload."};}
const u8=a.u8;delete a.u8;S.art[k]=a;if(a.kind==="pdf")a.loading=true;
if(S.mode)resetCampaign();renderAll();
if(a.kind==="pdf"){await pdfPreview(a,u8,k);a.loading=false;if(S.art[k]===a)renderAll();}
}
const jacketArt=()=>S.jacket==="printed"||S.jacket==="gatefold";
@@ -955,6 +1131,7 @@ function mockSVG(id){
else if(S.art.jacket&&S.art.jacket.url&&S.jacket!=="plain")jk+=`<image href="${S.art.jacket.url}" x="0" y="0" width="${J}" height="${J}" preserveAspectRatio="xMidYMid slice"/>`;
else if(S.jacket==="plain")jk+=`<rect x="0" y="0" width="${J}" height="${J}" fill="#d8c3a0"/><circle cx="${J/2}" cy="${J/2}" r="44" fill="none" stroke="#b9a27c" stroke-width="2"/><circle cx="${J/2}" cy="${J/2}" r="3" fill="#b9a27c"/>`;
else jk+=`<rect x="0" y="0" width="${J}" height="${J}" fill="url(#${id}g)"/><circle cx="168" cy="72" r="52" fill="none" stroke="rgba(255,255,255,0.22)" stroke-width="12"/><circle cx="168" cy="72" r="20" fill="rgba(255,255,255,0.16)"/><text x="18" y="${J-44}" font-size="19" font-weight="800" fill="#fff" font-family="system-ui,sans-serif" letter-spacing="-0.3">${esc(S.title.slice(0,20))}</text><text x="18" y="${J-22}" font-size="12.5" fill="rgba(255,255,255,0.82)" font-family="system-ui,sans-serif">${esc(S.artist.slice(0,30))}</text>`;
if(singleJ()){const sw=spineMM()*J/Z.jk;jk+=`<rect class="spn" x="${(-sw).toFixed(2)}" y="0" width="${sw.toFixed(2)}" height="${J}" fill="${S.jacket==="plain"?"#a88f68":"#070b14"}"/>`;}
if(S.jacket!=="none"){
jk+=`<rect x="0" y="0" width="${J}" height="${J}" fill="none" stroke="rgba(255,255,255,0.10)"/>`;
if(S.shrink)jk+=`<polygon points="0,0 100,0 0,100" fill="#fff" opacity="0.08"/><polygon points="${J},${J} ${J-60},${J} ${J},${J-60}" fill="#fff" opacity="0.05"/>`;
@@ -963,7 +1140,7 @@ function mockSVG(id){
}
const ghost=S.size==="12"?"":`<rect x="${JX}" y="${GY-236}" width="236" height="236" fill="none" style="stroke:var(--stroke)" stroke-dasharray="5 5"/><text x="${JX+8}" y="${GY-222}" font-size="10" style="fill:var(--faint)" font-family="system-ui,sans-serif">12" jacket, for scale</text>`;
const floor=`<ellipse cx="${(JX+cx+dR)/2}" cy="${GY+6}" rx="${((cx+dR-JX)/2+10).toFixed(1)}" ry="9" fill="url(#${id}fl)"/>`;
const cap=`<text x="452" y="286" text-anchor="end" font-size="10" style="fill:var(--faint)" font-family="system-ui,sans-serif">${esc(Z.name)}: record ${Z.rec} mm, jacket ${Z.jk} mm</text>`;
const cap=`<text x="452" y="286" text-anchor="end" font-size="10" style="fill:var(--faint)" font-family="system-ui,sans-serif">${esc(Z.name)}: record ${Z.rec} mm, jacket ${Z.jk} mm${spineMM()?", spine "+spineMM()+" mm":""}</text>`;
return `<svg viewBox="0 0 460 292" role="img" aria-label="${esc("Mock-up of the "+Z.name+" record")}"><defs>${defs}</defs>${floor}${ghost}${recs}<g transform="translate(${JX} ${JY.toFixed(1)}) scale(${(JW/236).toFixed(3)})"${S.jacket!=="none"?` filter="url(#${id}sd)"`:""}>${jk}</g>${cap}</svg>`;
}
function miniDisc(st){
@@ -1010,6 +1187,9 @@ function syncControls(){
inp.disabled=off;pal.title=off?why:"Open the palette and pick any colour";pal.classList.toggle("off",off);pal.classList.toggle("on",!off&&custom);pal.style.setProperty("--cc",S[c]);
pal.querySelector(".pt").textContent=!off&&custom?"Your colour":"Palette";if(inp.value.toLowerCase()!==S[c].toLowerCase())inp.value=S[c];});
$("#tpsV").textContent=S.tps;
const sps=spineState();
$$("#fSpine button").forEach(x=>{const thin=x.dataset.v==="thin",dis=sps.off||(sps.forced&&thin);x.disabled=dis;x.title=sps.off?sps.why:sps.forced&&thin?"Two records need the thick 5 mm spine":"";const on=S.jacket!=="gatefold"&&S.jacket!=="none"&&(thickOn()?!thin:thin);x.classList.toggle("on",on);x.setAttribute("aria-pressed",on?"true":"false");});
$("#fSpine").classList.toggle("off",sps.off);$("#spineHint").textContent="· "+sps.hint;
const s7=S.size==="7";$$("#fDink button").forEach(x=>{x.disabled=!s7;x.title=s7?"":"Only 7\" singles come with a large centre hole";});$("#fDink").classList.toggle("off",!s7);
$$("#fClean button").forEach(x=>{x.disabled=blk;x.title=blk?"Black vinyl needs no cleaning":"";});$("#fClean").classList.toggle("off",blk);
$("#cleanHint").textContent=blk?"Only for colour vinyl.":S.cleaning?"The press is purged before your run, so no trace of the previous colour gets in. Light colours can still show the odd speck.":"Saves the purge, but some records can carry streaks of the previous colour.";
@@ -1048,8 +1228,9 @@ function renderSides(){
}
function renderArt(){
const ic={ok:"&#x2713;",warn:"!",bad:"&#x2715;",pend:"&#x25CB;"};
[["jacket","#tplJacket"],["label","#tplLabel"]].forEach(([k,sel])=>{const off=tplOff(k);$(sel).innerHTML=`<button type="button" class="lnk" data-act="tpl" data-k="${k}"${off?` disabled title="${off}"`:""}>Download the ${k==="jacket"?(S.jacket==="gatefold"?"gatefold":"jacket"):"label"} template</button> (PDF): ${off?esc(off.toLowerCase()):esc(tplDims(k).txt)}`;});
[["jacket","#artJacket"],["label","#artLabel"]].forEach(([k,sel])=>{const a=S.art[k];if(!a){$(sel).innerHTML="";return;}const cs=artChecks(k);
$(sel).innerHTML=`<div class="art">${a.url?`<img src="${a.url}" alt="">`:""}<div><small class="mute">${esc(a.name)}</small><ul>${cs.map(([s,t])=>`<li><span class="ci ${s}" style="width:18px;height:18px;font-size:10px">${ic[s]}</span>${t}</li>`).join("")}</ul><button type="button" class="lnk" data-act="artRm" data-k="${k}">Remove</button></div></div>`;});
$(sel).innerHTML=`<div class="art">${artThumb(a)}<div><small class="mute">${esc(a.name)}</small><ul>${cs.map(([s,t])=>`<li><span class="ci ${s}" style="width:18px;height:18px;font-size:10px">${ic[s]}</span>${esc(t)}</li>`).join("")}${artNote(a)}</ul><button type="button" class="lnk" data-act="artRm" data-k="${k}">Remove</button></div></div>`;});
}
function renderChecks(){
const ic={ok:"&#x2713;",warn:"!",bad:"&#x2715;",pend:"&#x25CB;"},L=checkList(),bad=L.filter(x=>x[0]==="bad").length,warn=L.filter(x=>x[0]==="warn").length;
@@ -1290,7 +1471,7 @@ function resetDemo(){
}
/* ---- events ---- */
const PLAN_KEYS=["size","rpm","discs","loud","source"],RUN_KEYS=["weight","colour","inner","jacket","mastering","labels","cleaning","dinked"];
const PLAN_KEYS=["size","rpm","discs","loud","source"],RUN_KEYS=["weight","colour","inner","jacket","spine","mastering","labels","cleaning","dinked"];
function setK(k,v){
if(S[k]===v)return;S[k]=v;
if(k==="size"&&v!=="12"&&S.jacket==="gatefold")S.jacket="printed";
@@ -1310,6 +1491,7 @@ const ACT={
split:()=>{autoSplit();revokePlan();renderTracks();renderAll();},
approve:()=>{if(S.gate1)go(3);else approve();},
artRm:b=>{S.art[b.dataset.k]=null;renderAll();},
tpl:b=>downloadTpl(b.dataset.k),
direct:()=>{if(!S.gate1){toast("Approve the master plan first.");return go(2);}resetCampaign();S.mode="direct";newProd("direct",TODAY,0);go(5);},
launch:()=>launch(),
play:()=>toggleSim(),
@@ -1383,11 +1565,6 @@ async function handleAudio(files){
autoSplit();renderTracks();renderAll();
for(const t of added){if(!t.info.lossless)continue;t.info.scanning=true;renderTracks();renderChecks();await scanPeak(t);t.info.scanning=false;renderTracks();renderAll();}
}
async function handleArt(k,files){
const f=files.find(f=>/^image\//.test(f.type)||/\.(jpe?g|png|tiff?|webp)$/i.test(f.name));if(!f)return;
try{S.art[k]=await readImage(f);}catch(err){S.art[k]={name:f.name,err:"This browser cannot read the file; the real platform checks TIFF and PDF after upload."};}
if(S.mode)resetCampaign();renderAll();
}
dropZone($("#dropAudio"),$("#fileAudio"),handleAudio);
dropZone($("#dropJacket"),$("#fileJacket"),f=>handleArt("jacket",f));
dropZone($("#dropLabel"),$("#fileLabel"),f=>handleArt("label",f));
@@ -1443,7 +1620,7 @@ syncInputs();renderTracks();renderAll();
const H=location.hash.slice(1).split(/[&,]/);
if(H.includes("notes")){$("#invT").checked=true;document.body.classList.add("inv-on");}
if(H.includes("tour"))setTimeout(tour,700);
window.__mr={S,quote,campEcon,leadInfo,sideStats,checkList,go,ACT,setK,setQty,tour,stopTour,resetDemo};
window.__mr={S,tplPages,spineMM,artChecks,quote,campEcon,leadInfo,sideStats,checkList,go,ACT,setK,setQty,tour,stopTour,resetDemo};
})();
</script>
</body>