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

+202 -22
View File
@@ -132,6 +132,10 @@ document.documentElement.setAttribute("data-theme",t);})();
.navb { display: flex; justify-content: space-between; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.art { display: flex; gap: 12px; align-items: flex-start; padding: 8px 0; border-top: 1px solid var(--line); }
.art img { width: 84px; height: 84px; object-fit: cover; border-radius: 4px; border: 1px solid var(--stroke); flex: none; }
.art .aph { width: 84px; height: 84px; border-radius: 4px; border: 1px solid var(--stroke); flex: none; display: grid; place-items: center; font-weight: 700; color: var(--mute); }
.tpl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 10px; align-items: center; margin: 2px 0 6px; }
.tpl small { color: var(--mute); }
.tpl .btn:disabled { opacity: 0.45; cursor: not-allowed; }
.art ul { list-style: none; padding: 0; margin: 4px 0; font-size: 13.5px; }
.phone { max-width: 390px; margin: 0 auto; border: 1px solid var(--stroke); border-radius: 18px; padding: 14px; background: var(--panel2); }
.fp-t { font-size: 20px; font-weight: 700; margin-top: 10px; }
@@ -213,6 +217,7 @@ document.documentElement.setAttribute("data-theme",t);})();
<div class="row"><span class="lbl">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="row"><span class="lbl">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="row"><span class="lbl">Jacket</span><div class="seg" data-k="jacket"><button type="button" data-v="none">I supply it</button><button type="button" data-v="plain">Plain</button><button type="button" data-v="printed">Printed</button><button type="button" data-v="gatefold" id="bGate">Gatefold</button></div></div>
<div class="row" id="rowSpine"><span class="lbl">Spine</span><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> <small id="spineHint"></small></span></div>
<div class="row"><span class="lbl">Extras</span><span class="checks2">
<label><input type="checkbox" data-b="shrink"> Shrink wrap</label>
<label><input type="checkbox" data-b="insert"> Printed insert</label>
@@ -220,7 +225,7 @@ document.documentElement.setAttribute("data-theme",t);})();
<label><input type="checkbox" data-b="numbered"> Numbered edition</label>
<label><input type="checkbox" data-b="dlcard"> Download card</label>
<label><input type="checkbox" data-b="barcode"> Barcode sticker</label></span></div>
<p class="hint">Labels are paper labels pressed into the record, printed or plain white or black. Printed jackets come from a local packaging partner and are passed through at the partner's price plus a stated handling margin.</p>
<p class="hint">Labels are paper labels pressed into the record, printed or plain white or black. Printed jackets come from a local packaging partner and are passed through at the partner's price plus a stated handling margin. The spine is 3 mm for one record and 5 mm for two, or for one with a thick insert.</p>
</div>
<div class="card">
<h2>Audio and test pressings</h2>
@@ -251,10 +256,11 @@ document.documentElement.setAttribute("data-theme",t);})();
</div>
<div class="card">
<h2>Artwork</h2>
<label class="drop" id="dropJacket"><input type="file" id="fileJacket" accept="image/*" hidden>&#x2B07; <span><b>Jacket front</b>: square, 300 dpi, 3 mm bleed. Drop it here or click.</span></label>
<label class="drop" id="dropLabel"><input type="file" id="fileLabel" accept="image/*" hidden>&#x2B07; <span><b>Label, side A</b>: square, 300 dpi. Drop it here or click.</span></label>
<div class="tpl" id="tplRow"></div>
<label class="drop" id="dropJacket"><input type="file" id="fileJacket" accept=".pdf,.ai,application/pdf,application/postscript,application/illustrator,image/*" hidden>&#x2B07; <span><b>Jacket</b>: the spread from the template (back, spine, front) as PDF, AI or a 300 dpi image. Drop it here or click.</span></label>
<label class="drop" id="dropLabel"><input type="file" id="fileLabel" accept=".pdf,.ai,application/pdf,application/postscript,application/illustrator,image/*" hidden>&#x2B07; <span><b>Labels</b>: PDF or AI from the template, a page per side, or a 300 dpi square image for side A. Drop it here or click.</span></label>
<div id="artRes"></div>
<p class="hint">The real platform checks every file against the CNX templates (size, centre hole, safe area, bleed) and renders a proof. Template sizes here are placeholders until those templates exist.</p>
<p class="hint">Files are read in this browser and checked against the template for the format and spine chosen in step 1: size, spine, bleed, resolution, colour space, fonts. PDF previews are drawn with pdf.js. Template sizes follow published plant templates until the packaging partner's dielines replace them.</p>
</div>
<div class="card">
<h2>Automatic checks</h2>
@@ -357,6 +363,7 @@ const R={
inner:{v:{paper:{12:7,10:6,7:4},poly:{12:15,10:12,7:8}},u:"per disc",t:"Inner sleeve",k:"est",src:"Paper is inside the 132 THB above; a poly-lined sleeve costs 3.0 to 5.7 THB (revenue model); prices are estimates."},
assy:{v:10,u:"per copy",t:"Assembly and packing",k:"est",src:"Part of the 132 THB above; the split is an estimate."},
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]}},u:"per copy, partner price",t:"Jacket from the packaging partner, price breaks at 300 / 500 / 1,000",k:"est",src:"Estimate until the packaging partner quotes. With handling and shrink a printed 12\" jacket comes to about 100 THB a copy at short run, as in the artist platform illustration."},
spine:{v:{12:12,10:10},u:"per copy",t:"Thick 5 mm spine instead of 3 mm (two records, or a thick insert)",k:"est",src:"Estimate. Quality Record Pressings (US) lists a generic wide-spine jacket at 0.90 USD against 0.55 for a standard one, about 12 THB more; Kudos (UK) charges 137.50 GBP per order for an extra wide spine (price lists, checked October 2026)."},
handling:{v:0.10,u:"on pass-through",t:"Handling margin on the jacket",k:"est",src:"Placeholder. The artist platform shows pass-through items at the partner price plus a stated margin."},
shrink:{v:5,u:"per copy",t:"Shrink wrap",k:"est",src:"Film costs 0.8 to 1.1 THB (revenue model); price an estimate."},
insert:{v:12,u:"per copy",t:"Printed insert",k:"est",src:"Estimate."},
@@ -378,7 +385,7 @@ const LIM={"12":{"33":[18,22],"45":[12,15]},"10":{"33":[12,15],"45":[8,12]},"7":
const SONG=272; /* seconds: the 4:32 average song the at-most table counts songs with */
const LOUD=0.6; /* Diggers Factory: dance music ~11:27 against 19:23 standard on a 12" LP */
const JNAME={none:"Jacket supplied by you",plain:"Plain jacket",printed:"Printed jacket",gatefold:"Printed gatefold jacket"};
const ART={jacket:{t:"Jacket front",mm:{12:320,10:266,7:190}},label:{t:"Label",mm:{12:106,10:106,7:96}}};
const ART={jacket:{t:"Jacket",mm:{12:320,10:266,7:190}},label:{t:"Labels",mm:{12:106,10:106,7:96}}};
/* stages, working days [low, high]: mature column of the production timeline */
const STG=[
{id:"intake",t:"Audio intake and technical QA",d:[1,2]},
@@ -412,7 +419,7 @@ const UPD={
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:"#c0392b",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:"Demo Artist",title:"Night Market Songs",
source:"digital",labels:"printed",cleaning:true,dinked:false,dlcard:false,barcode:false,catno:"DEMO001",
tracks:[],demoTracks:true,rights:false,gate1:false,art:{jacket:null,label:null},
@@ -461,6 +468,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});
@@ -576,18 +584,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",a.w+" × "+a.h+" px: "+dpi+" dpi at "+mm+" mm"+(k==="jacket"?" incl. 3 mm bleed":"")]:px>=need*0.8?["warn",dpi+" dpi at "+mm+" mm, under 300 (needs "+need+" px)"]:["bad",dpi+" dpi at "+mm+" mm: too low for print (needs "+need+" px)"]);
out.push(a.cmyk?["ok","CMYK"]:["warn","RGB: the printer converts it to CMYK, and 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="st pend">&#x25CB;</span> Rendering a preview</li>`:a.noPrev?`<li><span class="st pend">&#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();}
}
/* ---- checks before gate 1 ---- */
@@ -651,6 +829,7 @@ function mockSVG(id){
else if(S.art.jacket&&S.art.jacket.url&&S.jacket!=="plain")jk+=`<image href="${S.art.jacket.url}" x="${J.x}" y="${J.y}" width="${J.w}" height="${J.w}" preserveAspectRatio="xMidYMid slice"/>`;
else if(S.jacket==="plain")jk+=`<rect x="${J.x}" y="${J.y}" width="${J.w}" height="${J.w}" fill="#d8c3a0"/><circle cx="${J.x+J.w/2}" cy="${J.y+J.w/2}" r="40" fill="none" stroke="#b9a27c" stroke-width="2"/>`;
else jk+=`<rect x="${J.x}" y="${J.y}" width="${J.w}" height="${J.w}" fill="url(#${id}g)"/><circle cx="${J.x+150}" cy="${J.y+70}" r="46" fill="none" stroke="rgba(255,255,255,0.25)" stroke-width="10"/><text x="${J.x+16}" y="${J.y+J.w-38}" font-size="16" font-weight="700" fill="#fff">${esc(S.title.slice(0,22))}</text><text x="${J.x+16}" y="${J.y+J.w-18}" font-size="12" fill="rgba(255,255,255,0.8)">${esc(S.artist.slice(0,28))}</text>`;
if(singleJ()){const sw=spineMM()*J.w/Z.jk;jk+=`<rect class="spn" x="${(J.x-sw).toFixed(2)}" y="${J.y}" width="${sw.toFixed(2)}" height="${J.w}" fill="${S.jacket==="plain"?"#a88f68":"#070b14"}"/>`;}
if(S.jacket!=="none"){
if(S.shrink)jk+=`<polygon points="${J.x},${J.y} ${J.x+90},${J.y} ${J.x},${J.y+90}" fill="#fff" opacity="0.07"/>`;
if(S.sticker)jk+=`<circle cx="${J.x+J.w-30}" cy="${J.y+28}" r="19" fill="#ffd23f"/><text x="${J.x+J.w-30}" y="${J.y+32}" text-anchor="middle" font-size="10" font-weight="700" fill="#222">NEW</text>`;
@@ -658,7 +837,7 @@ function mockSVG(id){
}
/* a 12" jacket outline for scale when a smaller format is chosen */
const ghost=S.size==="12"?"":`<rect x="8" y="16" width="212" height="212" fill="none" style="stroke:var(--stroke)" stroke-dasharray="5 5"/><text x="14" y="30" font-size="10" style="fill:var(--mute)">12" jacket, for scale</text>`;
const cap=`<text x="398" y="243" text-anchor="end" font-size="10" style="fill:var(--mute)">${Z.name}: record ${Z.rec} mm, jacket ${Z.jk} mm</text>`;
const cap=`<text x="398" y="243" text-anchor="end" font-size="10" style="fill:var(--mute)">${Z.name}: record ${Z.rec} mm, jacket ${Z.jk} mm${spineMM()?", spine "+spineMM()+" mm":""}</text>`;
return `<svg viewBox="0 0 402 248" role="img" aria-label="Mock-up of the ${esc(Z.name)} record"><defs>${defs}</defs>${ghost}${recs}<g transform="translate(${JX} ${JY.toFixed(1)}) scale(${kj.toFixed(3)})">${jk}</g>${cap}</svg>`;
}
@@ -690,6 +869,9 @@ function syncSegs(){
const s7=S.size==="7";$$("#rowDink button").forEach(x=>{x.disabled=!s7;x.title=s7?"":"Only 7\" singles come with a large centre hole";});$("#rowDink").classList.toggle("off",!s7);
$$("#rowClean button").forEach(x=>{x.disabled=blk;x.title=blk?"Black vinyl needs no cleaning":"";});$("#rowClean").classList.toggle("off",blk);
$("#cleanHint").textContent=blk?"only for colour vinyl":S.cleaning?"purged first; light colours can still show the odd speck":"some records can carry streaks of the previous colour";
const sps=spineState();
$$("#rowSpine 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":"";x.classList.toggle("on",S.jacket!=="gatefold"&&S.jacket!=="none"&&(thickOn()?!thin:thin));});
$("#rowSpine").classList.toggle("off",sps.off);$("#spineHint").textContent=sps.hint;
const offJ=!(S.jacket==="printed"||S.jacket==="gatefold"),offL=S.labels!=="printed";
$("#dropJacket").classList.toggle("off",offJ);$("#fileJacket").disabled=offJ;$("#dropJacket").title=offJ?(S.jacket==="none"?"The customer supplies the jacket":"A plain jacket has no print"):"";
$("#dropLabel").classList.toggle("off",offL);$("#fileLabel").disabled=offL;$("#dropLabel").title=offL?"Plain labels have no print":"";
@@ -721,8 +903,9 @@ function renderSides(){
}
function renderArt(){
const ic={ok:"&#x2713;",warn:"!",bad:"&#x2715;",pend:"&#x25CB;"};
$("#tplRow").innerHTML=["jacket","label"].map(k=>{const off=tplOff(k);return `<button type="button" class="btn sec sm" data-act="tpl" data-k="${k}"${off?` disabled title="${off}"`:""}>&#x2913; ${k==="jacket"?(S.jacket==="gatefold"?"Gatefold":"Jacket")+" template":"Label template"}, PDF</button><small>${off?esc(off):esc(tplDims(k).txt)}</small>`;}).join("");
$("#artRes").innerHTML=["jacket","label"].map(k=>{const a=S.art[k];if(!a)return "";const cs=artChecks(k);
return `<div class="art">${a.url?`<img src="${a.url}" alt="">`:""}<div><b>${ART[k].t}</b> <small>${esc(a.name)}</small><ul>${cs.map(([s,t])=>`<li><span class="st ${s}">${ic[s]}</span> ${t}</li>`).join("")}</ul><button type="button" class="btn sec sm" data-act="artRm" data-k="${k}">Remove</button></div></div>`;}).join("");
return `<div class="art">${artThumb(a)}<div><b>${ART[k].t}</b> <small>${esc(a.name)}</small><ul>${cs.map(([s,t])=>`<li><span class="st ${s}">${ic[s]}</span> ${esc(t)}</li>`).join("")}${artNote(a)}</ul><button type="button" class="btn sec sm" data-act="artRm" data-k="${k}">Remove</button></div></div>`;}).join("");
}
function renderChecks(){
const ic={ok:"&#x2713;",warn:"!",bad:"&#x2715;",pend:"&#x25CB;"},L=checkList(),bad=L.filter(x=>x[0]==="bad").length;
@@ -881,6 +1064,7 @@ function rateText(k){
if(k==="colour")return `solid ${v.solid} · marbled ${v.marbled} · splatter ${v.splatter} THB`;
if(k==="inner")return `paper ${v.paper[12]} / ${v.paper[10]} / ${v.paper[7]}, poly-lined ${v.poly[12]} / ${v.poly[10]} / ${v.poly[7]} THB (12" / 10" / 7")`;
if(k==="jacket")return `printed 12" ${v.printed[12].join(" / ")}, 10" ${v.printed[10].join(" / ")}, 7" ${v.printed[7].join(" / ")}; gatefold 12" ${v.gatefold[12].join(" / ")}; plain 12" ${v.plain[12][0]}, 10" ${v.plain[10][0]}, 7" ${v.plain[7][0]} THB`;
if(k==="spine")return `12" ${v[12]} · 10" ${v[10]} THB`;
if(k==="handling"||k==="fee"||k==="deposit")return pctT(v);
if(k==="minRun")return v+" copies";
return fmtInt(v)+" THB";
@@ -904,7 +1088,7 @@ function renderAll(){syncSegs();renderNav();renderSides();renderArt();renderChec
function go(s){if(s<1||s>5)return;S.step=s;S.visited[s]=true;renderAll();const top=$("header").offsetHeight;if(window.scrollY>top)window.scrollTo(0,top);}
/* ---- 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";
@@ -922,6 +1106,7 @@ const ACT={
split:()=>{autoSplit();revokePlan();renderTracks();renderAll();},
gate1:()=>{S.gate1=true;renderAll();},
artRm:b=>{S.art[b.dataset.k]=null;renderAll();},
tpl:b=>downloadTpl(b.dataset.k),
direct:()=>{if(!S.gate1)return go(2);resetCampaign();S.mode="direct";newProd("direct",TODAY,0);go(5);},
launch:()=>{if(!S.gate1)return go(2);resetCampaign();S.mode="campaign";S.launched=true;S.launchDate=new Date(TODAY);go(5);},
play:()=>toggleSim(),
@@ -983,11 +1168,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));
@@ -1007,7 +1187,7 @@ $("#qty").value=S.qty;$("#qtyR").value=S.qty;$("#tps").value=S.tps;$("#c1").valu
$("#artist").value=S.artist;$("#title").value=S.title;$("#catno").value=S.catno;$("#fanPrice").value=S.fanPrice;$("#days").value=S.days;$("#cap").value=S.cap;$("#acop").value=S.artistCopies;
$$("input[data-b]").forEach(c=>c.checked=!!S[c.dataset.b]);
renderTracks();renderPL();renderAll();
window.__ao={S,quote,campEcon,leadInfo,sideStats,checkList,go,ACT};
window.__ao={S,tplPages,spineMM,artChecks,quote,campEcon,leadInfo,sideStats,checkList,go,ACT};
})();
</script>
</body>
+41 -2
View File
@@ -131,7 +131,7 @@ covered, and what the demos took from it:
demo.
- **Left out for now:** label print modes (CMYK, Pantone, one colour),
board grades and finishes (kraft, Hansaboard, matt or gloss varnish,
laminates), die-cut holes, 6 mm spines, booklets, foil numbering,
laminates), die-cut holes, spines other than 3 and 5 mm, booklets, foil numbering,
sticker position, protective outer sleeves, BIO PVC and a recycled
colour mix, and multi-address shipping. Each is a price-list line once
the packaging partner and the compound suppliers quote.
@@ -238,6 +238,44 @@ flags as its biggest open question. The real price list is set after
market validation ([roadmap](roadmap.md) item 1); the calculator only
makes whatever prices are chosen legible.
## Artwork templates and files
Decided for the demos (user, October 2026): the order tool offers a
**spine choice** and **templates to download**, and accepts the files
designers actually deliver, **PDF and Illustrator (AI)** as well as
images.
- **Spine.** Thin, 3 mm, holds one record in its inner sleeve; thick,
5 mm, holds two, or one with a thick insert or a poster. Plants publish
templates for both, and for 7 or 9 mm on a gatefold (Mighty Media Discs:
12" sleeve 633 x 315 mm with a 3 mm spine, 635 x 315 mm with 5 mm;
Implant: 5 mm for two records; RPM Records: gatefold with a 7 mm spine;
XVinylX: 9 mm gatefold; all checked October 2026). A double album in a
single jacket takes the 5 mm spine without asking. The thick spine is
an extra line on the quote, an estimate until the packaging partner
quotes: Quality Record Pressings (US) lists a generic wide-spine jacket
at 0.90 USD against 0.55 for a standard one, and Kudos (UK) charges
137.50 GBP per order for an extra wide spine (price lists, checked
October 2026). A 7" jacket keeps the 3 mm spine.
- **Templates** follow the format, the jacket type and the spine chosen:
the jacket spread (back, spine, front) with 3 mm bleed, a 5 mm safe
area and the folds marked, a second page for the inside of a gatefold,
and a label template with one page per side (centre hole, and the
large hole of a dinked 7"). They are vector PDFs with trim and bleed
boxes set, so they open in Illustrator, InDesign and Affinity. Until
the packaging partner supplies its dielines the sizes come from the
published templates above; 10" and 7" panels are the mock-up's sizes.
- **Files accepted:** PDF (PDF/X-4 preferred), Illustrator files saved
with PDF compatibility (an AI file without it is refused with
instructions), and 300 dpi images. Plants ask for the same: Mobineko
publishes PSD, AI and PDF templates, New Orleans Record Press takes
print PDFs and refuses flattened images (checked October 2026). The
preflight reads the page boxes, colour spaces and fonts from the file,
checks the trim against the chosen spine (a spread made for a 5 mm
spine is flagged on a 3 mm order), the bleed, CMYK against RGB, and
whether fonts are embedded or outlined, and renders the front panel
into the mock-up.
## Fan-funded campaigns
### Before the campaign goes live
@@ -254,7 +292,8 @@ Nothing goes in front of fans that the plant cannot make:
3. **Automatic artwork preflight**: label and jacket files against the
templates (size, centre hole and safe area for the pressed-in label,
bleed, resolution, colour space), with a rendered mock-up of the
jacket and the vinyl colour.
jacket and the vinyl colour; templates and accepted files in
[Artwork templates and files](#artwork-templates-and-files).
4. **Engineer check**: a cutting engineer confirms side length and level
are cuttable. On a Sillitoe lathe this is a number from the software's
virtual groove (disk capacity in percent) without a test cut; see
+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>