style: redesign PDF as full-page cyberpunk dashboard
CI and Deploy / test-build-deploy (push) Successful in 4s

This commit is contained in:
Hermes Agent
2026-08-03 14:44:34 +00:00
parent 9f8e130d41
commit 07e7bf25c6
2 changed files with 153 additions and 41 deletions
+140 -37
View File
@@ -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 -4
View File
@@ -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'));