From 07e7bf25c61a5f94f9848bb4fe441757b9efdbfb Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 3 Aug 2026 14:44:34 +0000 Subject: [PATCH] style: redesign PDF as full-page cyberpunk dashboard --- index.html | 177 +++++++++++++++++++++++++++++-------- tests/pdf-download.test.js | 17 +++- 2 files changed, 153 insertions(+), 41 deletions(-) diff --git a/index.html b/index.html index d72ae73..c2cd84a 100644 --- a/index.html +++ b/index.html @@ -1217,51 +1217,154 @@ function wrapPdfLines(lines, width=92){ 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){ const encoder=new TextEncoder(); - const title=String(lines[0]||"PIZZATEIG"); - let body=wrapPdfLines(lines.slice(1),88); - const maxRows=62; - if(body.length>maxRows) body=[...body.slice(0,maxRows-1),"[... weitere Angaben gekürzt]"]; + const data=parsePdfRecipe(lines); + const ingredients=data.sections.filter(section=>section.title!=="ZEITPLAN"); + const timeline=data.sections.find(section=>section.title==="ZEITPLAN")||{title:"ZEITPLAN",items:[]}; + 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; - const lineHeight=Math.max(8.5,Math.min(13,610/units)); - const bodySize=Math.max(6.8,Math.min(9.5,lineHeight-1.8)); + let stream=""; + const rect=(x,y,w,h,fill,stroke="",lineWidth=.7)=>{ + 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"; - // Dunkle Grundfläche und dezentes Terminal-Raster. - stream+="0.027 0.031 0.043 rg 0 0 595 842 re f\n"; - stream+="0.055 0.082 0.114 RG 0.3 w\n"; - for(let x=19;x<595;x+=32) 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`; - // Panel, Neon-Kanten und asymmetrische Akzentleisten. - stream+="0.063 0.082 0.122 rg 28 58 539 704 re f\n"; - stream+="0 0.941 1 RG 0.9 w 28 58 539 704 re S\n"; - stream+="0 0.941 1 rg 28 786 539 3 re f\n"; - stream+="1 0.176 0.471 rg 28 776 128 5 re f\n"; - stream+="1 0.176 0.471 rg 552 58 15 15 re f\nQ\n"; + // Vollflächige Cyberpunk-Bühne mit Raster, Scanlines und Neonrahmen. + rect(0,0,595,842,"0.027 0.031 0.043"); + rect(0,704,595,138,"0.039 0.051 0.078"); + stream+="0.047 0.071 0.102 RG 0.25 w\n"; + for(let x=18;x<595;x+=24) stream+=`${x} 0 m ${x} 842 l S\n`; + for(let y=8;y<842;y+=24) stream+=`0 ${y} m 595 ${y} l S\n`; + stream+="0.075 0.094 0.133 RG 0.18 w\n"; + for(let y=3;y<842;y+=6) stream+=`0 ${y} m 595 ${y} l S\n`; + rect(18,18,559,806,"","0.12 0.23 0.30",.6); + line(18,823,577,823,"0 0.941 1",2.2); + line(18,816,170,816,"1 0.176 0.471",4.5); + line(424,26,577,26,"1 0.176 0.471",3); - // Kopfzeile im Stil der Anwendung. - stream+=`BT\n/F2 22 Tf\n0 0.941 1 rg\n36 805 Td\n<${winAnsiHex("TEIG//TERMINAL")}> Tj\nET\n`; - 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`; - stream+=`BT\n/F2 14 Tf\n0.922 0.965 1 rg\n42 741 Td\n<${winAnsiHex(title)}> Tj\nET\n`; - stream+="0 0.941 1 RG 0.7 w 42 729 m 553 729 l S\n"; + // Kopf: doppelte Neon-Typografie, Statusblock und technische Marker. + text("TEIG//TERMINAL",35,778,29,"F2","0.23 0.02 0.19"); + text("TEIG//TERMINAL",33,780,29,"F2","0 0.941 1"); + text("PIZZA FORMULATION SYSTEM",35,765,7.5,"F1","1 0.176 0.471"); + 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; - body.forEach(line=>{ - if(!line){ y-=lineHeight*.55; return; } - const section=/^[A-ZÄÖÜ][A-ZÄÖÜ0-9 /()%-]{2,}$/.test(line.trim()) && line.trim().length<42; - const isLink=/^https?:\/\//.test(line.trim()); - const font=section?"F2":"F1"; - const size=section?Math.min(10.5,bodySize+1):isLink?Math.max(6.5,bodySize-1):bodySize; - const color=section?"1 0.176 0.471":isLink?"0 0.941 1":"0.886 0.925 0.961"; - if(section) stream+=`0 0.941 1 rg 42 ${y-1.5} 5 5 re f\n`; - 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; + // Vier Dashboard-Kacheln nutzen die gesamte Breite für die Kerndaten. + const cards=[34,167,300,433]; + cards.forEach((x,index)=>{ + 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); + text(`DATA // 0${index+1}`,x+9,678,6.3,"F2",index===3?"1 0.176 0.471":"0 0.941 1"); + const value=data.overview[index]||"—"; + const parts=wrapped(value,23).slice(0,2); + parts.forEach((part,row)=>text(part,x+9,659-row*11,8.2,"F2","0.922 0.965 1")); + rect(x+113,637,10,3,index===3?"1 0.176 0.471":"0 0.941 1"); }); - stream+="0 0.941 1 RG 0.5 w 42 47 m 553 47 l S\n"; - 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`; + // Zwei große Module: Zutaten/Formel links, Prozess-Timeline rechts. + 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(indextext(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=[]; objects[1]="<< /Type /Catalog /Pages 2 0 R >>"; diff --git a/tests/pdf-download.test.js b/tests/pdf-download.test.js index 4525928..d203aa0 100644 --- a/tests/pdf-download.test.js +++ b/tests/pdf-download.test.js @@ -13,9 +13,9 @@ assert.ok(start >= 0 && end > start, 'PDF-Download-Implementierung fehlt'); const sandbox = { Uint8Array, TextEncoder, Blob }; 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( pdfFilename('Napoletana', 4, 250, '24 Stunden'), 'Pizzateig-Napoletana-4x250g-24-Stunden.pdf' @@ -29,7 +29,7 @@ assert.equal( 'Pizzateig-Romisch-tonda-4x200g-Uber-Nacht.pdf' ); -const bytes = buildPdfBytes([ +const recipeLines = [ 'PIZZATEIG — Napoletana', '4 Teiglinge à 250 g · 1.000 g Teig', 'Mehl: Caputo Pizzeria · Hydration 62 %', @@ -48,7 +48,16 @@ const bytes = buildPdfBytes([ ' 19:00 Di — Pizza backen', '', '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); const ascii = Buffer.from(bytes).toString('latin1'); assert.ok(ascii.startsWith('%PDF-1.4'));