style: redesign PDF as full-page cyberpunk dashboard
CI and Deploy / test-build-deploy (push) Successful in 4s
CI and Deploy / test-build-deploy (push) Successful in 4s
This commit is contained in:
+140
-37
@@ -1217,51 +1217,154 @@ function wrapPdfLines(lines, width=92){
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePdfRecipe(lines){
|
||||||
|
const source=lines.map(line=>String(line));
|
||||||
|
const title=source[0]||"PIZZATEIG";
|
||||||
|
const overview=[];
|
||||||
|
const sections=[];
|
||||||
|
let current=null, link="";
|
||||||
|
source.slice(1).forEach(raw=>{
|
||||||
|
const line=raw.trim();
|
||||||
|
if(!line) return;
|
||||||
|
if(/^https?:\/\//.test(line)){ link=line; return; }
|
||||||
|
if(/^(ZUTATEN|HAUPTTEIG|ZEITPLAN|VORTEIG\b)/.test(line)){
|
||||||
|
current={title:line,items:[]};
|
||||||
|
sections.push(current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(current) current.items.push(line);
|
||||||
|
else overview.push(...line.split(" · ").map(part=>part.trim()).filter(Boolean));
|
||||||
|
});
|
||||||
|
if(overview.length>4) sections.unshift({title:"WEITERE DATEN",items:overview.slice(4)});
|
||||||
|
return {title,overview:overview.slice(0,4),sections,link};
|
||||||
|
}
|
||||||
|
|
||||||
function buildPdfBytes(lines){
|
function buildPdfBytes(lines){
|
||||||
const encoder=new TextEncoder();
|
const encoder=new TextEncoder();
|
||||||
const title=String(lines[0]||"PIZZATEIG");
|
const data=parsePdfRecipe(lines);
|
||||||
let body=wrapPdfLines(lines.slice(1),88);
|
const ingredients=data.sections.filter(section=>section.title!=="ZEITPLAN");
|
||||||
const maxRows=62;
|
const timeline=data.sections.find(section=>section.title==="ZEITPLAN")||{title:"ZEITPLAN",items:[]};
|
||||||
if(body.length>maxRows) body=[...body.slice(0,maxRows-1),"[... weitere Angaben gekürzt]"];
|
const capItems=(items,max)=>items.length>max?[...items.slice(0,max-1),"... weitere Angaben gekürzt"]:items;
|
||||||
|
|
||||||
const units=body.reduce((sum,line)=>sum+(line ? 1 : .55),0)||1;
|
let stream="";
|
||||||
const lineHeight=Math.max(8.5,Math.min(13,610/units));
|
const rect=(x,y,w,h,fill,stroke="",lineWidth=.7)=>{
|
||||||
const bodySize=Math.max(6.8,Math.min(9.5,lineHeight-1.8));
|
if(fill) stream+=`${fill} rg ${x} ${y} ${w} ${h} re f\n`;
|
||||||
|
if(stroke) stream+=`${stroke} RG ${lineWidth} w ${x} ${y} ${w} ${h} re S\n`;
|
||||||
|
};
|
||||||
|
const line=(x1,y1,x2,y2,color,width=.7)=>{ stream+=`${color} RG ${width} w ${x1} ${y1} m ${x2} ${y2} l S\n`; };
|
||||||
|
const text=(value,x,y,size,font="F1",color="0.886 0.925 0.961")=>{
|
||||||
|
stream+=`BT\n/${font} ${size} Tf\n${color} rg\n${x} ${y} Td\n<${winAnsiHex(value)}> Tj\nET\n`;
|
||||||
|
};
|
||||||
|
const wrapped=(value,width)=>wrapPdfLines([String(value)],width);
|
||||||
|
|
||||||
let stream="q\n";
|
// Vollflächige Cyberpunk-Bühne mit Raster, Scanlines und Neonrahmen.
|
||||||
// Dunkle Grundfläche und dezentes Terminal-Raster.
|
rect(0,0,595,842,"0.027 0.031 0.043");
|
||||||
stream+="0.027 0.031 0.043 rg 0 0 595 842 re f\n";
|
rect(0,704,595,138,"0.039 0.051 0.078");
|
||||||
stream+="0.055 0.082 0.114 RG 0.3 w\n";
|
stream+="0.047 0.071 0.102 RG 0.25 w\n";
|
||||||
for(let x=19;x<595;x+=32) stream+=`${x} 0 m ${x} 842 l S\n`;
|
for(let x=18;x<595;x+=24) stream+=`${x} 0 m ${x} 842 l S\n`;
|
||||||
for(let y=10;y<842;y+=32) stream+=`0 ${y} m 595 ${y} l S\n`;
|
for(let y=8;y<842;y+=24) stream+=`0 ${y} m 595 ${y} l S\n`;
|
||||||
// Panel, Neon-Kanten und asymmetrische Akzentleisten.
|
stream+="0.075 0.094 0.133 RG 0.18 w\n";
|
||||||
stream+="0.063 0.082 0.122 rg 28 58 539 704 re f\n";
|
for(let y=3;y<842;y+=6) stream+=`0 ${y} m 595 ${y} l S\n`;
|
||||||
stream+="0 0.941 1 RG 0.9 w 28 58 539 704 re S\n";
|
rect(18,18,559,806,"","0.12 0.23 0.30",.6);
|
||||||
stream+="0 0.941 1 rg 28 786 539 3 re f\n";
|
line(18,823,577,823,"0 0.941 1",2.2);
|
||||||
stream+="1 0.176 0.471 rg 28 776 128 5 re f\n";
|
line(18,816,170,816,"1 0.176 0.471",4.5);
|
||||||
stream+="1 0.176 0.471 rg 552 58 15 15 re f\nQ\n";
|
line(424,26,577,26,"1 0.176 0.471",3);
|
||||||
|
|
||||||
// Kopfzeile im Stil der Anwendung.
|
// Kopf: doppelte Neon-Typografie, Statusblock und technische Marker.
|
||||||
stream+=`BT\n/F2 22 Tf\n0 0.941 1 rg\n36 805 Td\n<${winAnsiHex("TEIG//TERMINAL")}> Tj\nET\n`;
|
text("TEIG//TERMINAL",35,778,29,"F2","0.23 0.02 0.19");
|
||||||
stream+=`BT\n/F1 7.5 Tf\n1 0.176 0.471 rg\n37 793 Td\n<${winAnsiHex("RECIPE EXPORT // OFFLINE GENERATED // SYSTEM READY")}> Tj\nET\n`;
|
text("TEIG//TERMINAL",33,780,29,"F2","0 0.941 1");
|
||||||
stream+=`BT\n/F2 14 Tf\n0.922 0.965 1 rg\n42 741 Td\n<${winAnsiHex(title)}> Tj\nET\n`;
|
text("PIZZA FORMULATION SYSTEM",35,765,7.5,"F1","1 0.176 0.471");
|
||||||
stream+="0 0.941 1 RG 0.7 w 42 729 m 553 729 l S\n";
|
rect(414,770,145,28,"0.055 0.071 0.106","1 0.176 0.471",.8);
|
||||||
|
rect(425,781,5,5,"0.545 1 0.231");
|
||||||
|
text("RECIPE // READY",438,780,8.2,"F2","0.922 0.965 1");
|
||||||
|
text("06",507,716,42,"F2","0.10 0.19 0.24");
|
||||||
|
text(data.title,34,724,16,"F2","0.922 0.965 1");
|
||||||
|
text("FORMULA EXPORT // DIN A4 // ONE PAGE",35,709,6.8,"F1","0.53 0.64 0.74");
|
||||||
|
|
||||||
let y=711;
|
// Vier Dashboard-Kacheln nutzen die gesamte Breite für die Kerndaten.
|
||||||
body.forEach(line=>{
|
const cards=[34,167,300,433];
|
||||||
if(!line){ y-=lineHeight*.55; return; }
|
cards.forEach((x,index)=>{
|
||||||
const section=/^[A-ZÄÖÜ][A-ZÄÖÜ0-9 /()%-]{2,}$/.test(line.trim()) && line.trim().length<42;
|
rect(x,634,126,60,index%2?"0.055 0.071 0.106":"0.063 0.082 0.122",index===3?"1 0.176 0.471":"0 0.941 1",.65);
|
||||||
const isLink=/^https?:\/\//.test(line.trim());
|
text(`DATA // 0${index+1}`,x+9,678,6.3,"F2",index===3?"1 0.176 0.471":"0 0.941 1");
|
||||||
const font=section?"F2":"F1";
|
const value=data.overview[index]||"—";
|
||||||
const size=section?Math.min(10.5,bodySize+1):isLink?Math.max(6.5,bodySize-1):bodySize;
|
const parts=wrapped(value,23).slice(0,2);
|
||||||
const color=section?"1 0.176 0.471":isLink?"0 0.941 1":"0.886 0.925 0.961";
|
parts.forEach((part,row)=>text(part,x+9,659-row*11,8.2,"F2","0.922 0.965 1"));
|
||||||
if(section) stream+=`0 0.941 1 rg 42 ${y-1.5} 5 5 re f\n`;
|
rect(x+113,637,10,3,index===3?"1 0.176 0.471":"0 0.941 1");
|
||||||
stream+=`BT\n/${font} ${size.toFixed(1)} Tf\n${color} rg\n${section?53:42} ${y.toFixed(1)} Td\n<${winAnsiHex(line.trimEnd())}> Tj\nET\n`;
|
|
||||||
y-=lineHeight;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
stream+="0 0.941 1 RG 0.5 w 42 47 m 553 47 l S\n";
|
// Zwei große Module: Zutaten/Formel links, Prozess-Timeline rechts.
|
||||||
stream+=`BT\n/F1 7 Tf\n0 0.941 1 rg\n42 35 Td\n<${winAnsiHex("TEIG//TERMINAL // LOCAL PDF // A4 // 1 OF 1")}> Tj\nET\n`;
|
rect(34,112,238,500,"0.043 0.059 0.086","0 0.941 1",.75);
|
||||||
|
rect(286,112,273,500,"0.043 0.059 0.086","1 0.176 0.471",.75);
|
||||||
|
rect(34,568,238,44,"0.00 0.22 0.26");
|
||||||
|
rect(286,568,273,44,"0.25 0.035 0.15");
|
||||||
|
text("01",47,580,17,"F2","0 0.941 1");
|
||||||
|
text("FORMULA // ZUTATEN",79,584,10.5,"F2","0.922 0.965 1");
|
||||||
|
text("02",299,580,17,"F2","1 0.176 0.471");
|
||||||
|
text("PROCESS // ZEITPLAN",332,584,10.5,"F2","0.922 0.965 1");
|
||||||
|
|
||||||
|
let leftY=545, itemNo=1;
|
||||||
|
const ingredientRows=[];
|
||||||
|
ingredients.forEach(section=>{
|
||||||
|
ingredientRows.push({heading:section.title});
|
||||||
|
capItems(section.items,12).forEach(item=>ingredientRows.push({item}));
|
||||||
|
});
|
||||||
|
if(!ingredientRows.length) ingredientRows.push({heading:"ZUTATEN"},{item:"Keine Angaben"});
|
||||||
|
const ingredientCount=Math.max(1,ingredientRows.filter(row=>row.item).length);
|
||||||
|
const headingCount=ingredientRows.filter(row=>row.heading).length;
|
||||||
|
const leftStep=Math.max(28,Math.min(62,(390-headingCount*24)/ingredientCount));
|
||||||
|
ingredientRows.slice(0,15).forEach((row,index)=>{
|
||||||
|
if(row.heading){
|
||||||
|
text(row.heading,48,leftY,8.8,"F2","1 0.176 0.471");
|
||||||
|
line(48,leftY-7,255,leftY-7,"0.18 0.30 0.36",.45);
|
||||||
|
leftY-=24;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rowH=Math.max(25,leftStep-4);
|
||||||
|
rect(46,leftY-rowH+7,214,rowH,index%2?"0.055 0.071 0.106":"0.063 0.082 0.122");
|
||||||
|
rect(46,leftY-rowH+7,4,rowH,itemNo%2?"0 0.941 1":"1 0.176 0.471");
|
||||||
|
text(String(itemNo).padStart(2,"0"),57,leftY-8,7,"F2","0.35 0.52 0.61");
|
||||||
|
const amount=row.item.match(/^(.*\S)\s+([0-9][0-9.,]*\s*(?:g|%|ml))$/i);
|
||||||
|
if(amount){
|
||||||
|
const labelLines=wrapped(amount[1],20).slice(0,2);
|
||||||
|
labelLines.forEach((part,i)=>text(part,82,leftY-6-i*10,8.2,"F1","0.922 0.965 1"));
|
||||||
|
const amountX=Math.max(184,248-amount[2].length*6.1);
|
||||||
|
text(amount[2],amountX,leftY-7,10,"F2",itemNo%2?"0 0.941 1":"1 0.176 0.471");
|
||||||
|
}else{
|
||||||
|
const itemLines=wrapped(row.item,34).slice(0,2);
|
||||||
|
itemLines.forEach((part,i)=>text(part,78,leftY-5-i*10,8.3,"F1","0.922 0.965 1"));
|
||||||
|
}
|
||||||
|
leftY-=leftStep; itemNo++;
|
||||||
|
});
|
||||||
|
text("BAKER'S PERCENTAGE // MASS CONTROL",48,130,6.5,"F1","0.36 0.49 0.58");
|
||||||
|
for(let x=48;x<255;x+=9) rect(x,121,(x%27===0)?4:2,5,"0 0.941 1");
|
||||||
|
|
||||||
|
const steps=capItems(timeline.items,10);
|
||||||
|
const timelineTop=546, timelineBottom=150;
|
||||||
|
const slot=(timelineTop-timelineBottom)/Math.max(1,steps.length);
|
||||||
|
line(309,timelineBottom,309,timelineTop,"0 0.941 1",1.1);
|
||||||
|
steps.forEach((raw,index)=>{
|
||||||
|
const y=timelineTop-index*slot;
|
||||||
|
const parts=raw.split(/\s+—\s+/);
|
||||||
|
const when=parts.length>1?parts.shift():String(index+1).padStart(2,"0");
|
||||||
|
const action=parts.length?parts.join(" — "):raw;
|
||||||
|
rect(304,y-2,10,10,"0.043 0.059 0.086","0 0.941 1",1);
|
||||||
|
rect(307,y+1,4,4,index===steps.length-1?"1 0.176 0.471":"0 0.941 1");
|
||||||
|
text(when,324,y+1,8.6,"F2",index===steps.length-1?"1 0.176 0.471":"0 0.941 1");
|
||||||
|
const actionLines=wrapped(action,38).slice(0,2);
|
||||||
|
actionLines.forEach((part,row)=>text(part,324,y-11-row*10,7.8,"F1","0.922 0.965 1"));
|
||||||
|
if(index<steps.length-1) line(324,y-slot+9,545,y-slot+9,"0.12 0.19 0.24",.35);
|
||||||
|
});
|
||||||
|
if(!steps.length) text("Kein Zeitplan vorhanden",324,530,8.5,"F1","0.53 0.64 0.74");
|
||||||
|
text("FERMENTATION SEQUENCE // REVERSE SCHEDULE",304,130,6.5,"F1","0.45 0.39 0.57");
|
||||||
|
|
||||||
|
// Link-Zeile und Footer schließen die Seite wie ein System-Dashboard ab.
|
||||||
|
rect(34,61,525,35,"0.039 0.051 0.078","0.17 0.31 0.37",.5);
|
||||||
|
text("RECIPE LINK",46,82,6.3,"F2","1 0.176 0.471");
|
||||||
|
const linkLines=wrapped(data.link||"Lokales Rezept ohne Link",102).slice(0,2);
|
||||||
|
linkLines.forEach((part,index)=>text(part,111,80-index*8,5.7,"F1","0 0.941 1"));
|
||||||
|
text("SYS.ONLINE",34,38,6.5,"F2","0.545 1 0.231");
|
||||||
|
text("NO CLOUD // NO TRACKING // GENERATED LOCALLY",112,38,6.5,"F1","0.53 0.64 0.74");
|
||||||
|
text("A4 // 1 OF 1",484,38,6.5,"F2","1 0.176 0.471");
|
||||||
|
|
||||||
const objects=[];
|
const objects=[];
|
||||||
objects[1]="<< /Type /Catalog /Pages 2 0 R >>";
|
objects[1]="<< /Type /Catalog /Pages 2 0 R >>";
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ assert.ok(start >= 0 && end > start, 'PDF-Download-Implementierung fehlt');
|
|||||||
|
|
||||||
const sandbox = { Uint8Array, TextEncoder, Blob };
|
const sandbox = { Uint8Array, TextEncoder, Blob };
|
||||||
vm.createContext(sandbox);
|
vm.createContext(sandbox);
|
||||||
vm.runInContext(`${html.slice(start, end)}\nglobalThis.api={pdfFilename,buildPdfBytes};`, sandbox);
|
vm.runInContext(`${html.slice(start, end)}\nglobalThis.api={pdfFilename,parsePdfRecipe,buildPdfBytes};`, sandbox);
|
||||||
|
|
||||||
const { pdfFilename, buildPdfBytes } = sandbox.api;
|
const { pdfFilename, parsePdfRecipe, buildPdfBytes } = sandbox.api;
|
||||||
assert.equal(
|
assert.equal(
|
||||||
pdfFilename('Napoletana', 4, 250, '24 Stunden'),
|
pdfFilename('Napoletana', 4, 250, '24 Stunden'),
|
||||||
'Pizzateig-Napoletana-4x250g-24-Stunden.pdf'
|
'Pizzateig-Napoletana-4x250g-24-Stunden.pdf'
|
||||||
@@ -29,7 +29,7 @@ assert.equal(
|
|||||||
'Pizzateig-Romisch-tonda-4x200g-Uber-Nacht.pdf'
|
'Pizzateig-Romisch-tonda-4x200g-Uber-Nacht.pdf'
|
||||||
);
|
);
|
||||||
|
|
||||||
const bytes = buildPdfBytes([
|
const recipeLines = [
|
||||||
'PIZZATEIG — Napoletana',
|
'PIZZATEIG — Napoletana',
|
||||||
'4 Teiglinge à 250 g · 1.000 g Teig',
|
'4 Teiglinge à 250 g · 1.000 g Teig',
|
||||||
'Mehl: Caputo Pizzeria · Hydration 62 %',
|
'Mehl: Caputo Pizzeria · Hydration 62 %',
|
||||||
@@ -48,7 +48,16 @@ const bytes = buildPdfBytes([
|
|||||||
' 19:00 Di — Pizza backen',
|
' 19:00 Di — Pizza backen',
|
||||||
'',
|
'',
|
||||||
'https://teig.deploybar.de/#style=napo&balls=4&ball=250&plan=d24',
|
'https://teig.deploybar.de/#style=napo&balls=4&ball=250&plan=d24',
|
||||||
]);
|
];
|
||||||
|
const parsed = JSON.parse(JSON.stringify(parsePdfRecipe(recipeLines)));
|
||||||
|
assert.equal(parsed.title, 'PIZZATEIG — Napoletana');
|
||||||
|
assert.deepEqual(parsed.overview, ['4 Teiglinge à 250 g', '1.000 g Teig', 'Mehl: Caputo Pizzeria', 'Hydration 62 %']);
|
||||||
|
assert.deepEqual(parsed.sections.map(section => section.title), ['ZUTATEN', 'ZEITPLAN']);
|
||||||
|
assert.equal(parsed.sections[0].items.length, 4);
|
||||||
|
assert.equal(parsed.sections[1].items.length, 5);
|
||||||
|
assert.match(parsed.link, /^https:\/\/teig\.deploybar\.de\//);
|
||||||
|
|
||||||
|
const bytes = buildPdfBytes(recipeLines);
|
||||||
assert.ok(bytes instanceof Uint8Array);
|
assert.ok(bytes instanceof Uint8Array);
|
||||||
const ascii = Buffer.from(bytes).toString('latin1');
|
const ascii = Buffer.from(bytes).toString('latin1');
|
||||||
assert.ok(ascii.startsWith('%PDF-1.4'));
|
assert.ok(ascii.startsWith('%PDF-1.4'));
|
||||||
|
|||||||
Reference in New Issue
Block a user