From 9f8e130d411f78564929b9354f5c220e3d35c9ab Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 3 Aug 2026 14:33:20 +0000 Subject: [PATCH] style: render PDF in cyberpunk A4 layout --- README.md | 2 +- index.html | 65 +++++++++++++++++++++++++++----------- tests/pdf-download.test.js | 28 +++++++++++++++- 3 files changed, 75 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 63344e9..def3e9d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Aktuelle Version: **v1.1** - Vorteig - Rückwärts-Zeitplan - Rezept-Link über URL-Hash -- Rezept direkt als PDF herunterladen +- Rezept direkt als einseitige DIN-A4-PDF im Cyberpunk-Stil herunterladen Live: https://teig.deploybar.de/ diff --git a/index.html b/index.html index e306c06..d72ae73 100644 --- a/index.html +++ b/index.html @@ -1220,27 +1220,56 @@ function wrapPdfLines(lines, width=92){ function buildPdfBytes(lines){ const encoder=new TextEncoder(); const title=String(lines[0]||"PIZZATEIG"); - const body=wrapPdfLines(lines.slice(1)); - const pageSize=46; - const pages=[]; - for(let i=0;imaxRows) body=[...body.slice(0,maxRows-1),"[... weitere Angaben gekürzt]"]; + + 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="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"; + + // 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"; + + 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; + }); + + 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`; const objects=[]; objects[1]="<< /Type /Catalog /Pages 2 0 R >>"; - objects[3]="<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; - objects[4]="<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>"; - const pageRefs=[]; - - pages.forEach((pageLines,index)=>{ - const contentId=5+index*2, pageId=contentId+1; - pageRefs.push(`${pageId} 0 R`); - let stream=`BT\n/F2 16 Tf\n50 800 Td\n<${winAnsiHex(title)}> Tj\n/F1 10 Tf\n0 -26 Td\n14 TL\n`; - pageLines.forEach(line=>{ stream+=`<${winAnsiHex(line)}> Tj\nT*\n`; }); - stream+=`ET\nBT\n/F1 8 Tf\n50 32 Td\n<${winAnsiHex(`TEIG//TERMINAL · Seite ${index+1} / ${pages.length}`)}> Tj\nET\n`; - objects[contentId]=`<< /Length ${encoder.encode(stream).length} >>\nstream\n${stream}endstream`; - objects[pageId]=`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${contentId} 0 R >>`; - }); - objects[2]=`<< /Type /Pages /Kids [${pageRefs.join(" ")}] /Count ${pages.length} >>`; + objects[2]="<< /Type /Pages /Kids [6 0 R] /Count 1 >>"; + objects[3]="<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>"; + objects[4]="<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>"; + objects[5]=`<< /Length ${encoder.encode(stream).length} >>\nstream\n${stream}endstream`; + objects[6]="<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents 5 0 R >>"; const chunks=[encoder.encode("%PDF-1.4\n")], offsets=[0]; let length=chunks[0].length; diff --git a/tests/pdf-download.test.js b/tests/pdf-download.test.js index c012609..4525928 100644 --- a/tests/pdf-download.test.js +++ b/tests/pdf-download.test.js @@ -32,7 +32,22 @@ assert.equal( const bytes = buildPdfBytes([ 'PIZZATEIG — Napoletana', '4 Teiglinge à 250 g · 1.000 g Teig', - 'Wasser 620 g · Salz 28 g · 20 °C', + 'Mehl: Caputo Pizzeria · Hydration 62 %', + '', + 'ZUTATEN', + ' Mehl 605 g', + ' Wasser 375 g', + ' Frischhefe 2,3 g', + ' Salz 17 g', + '', + 'ZEITPLAN', + ' 19:00 Mo — Teig kneten und ruhen lassen (30 Min.)', + ' 19:30 Mo — Ballen formen und bei Raumtemperatur reifen lassen', + ' 23:00 Mo — Ballen in den Kühlschrank stellen', + ' 15:00 Di — Ballen aus dem Kühlschrank nehmen', + ' 19:00 Di — Pizza backen', + '', + 'https://teig.deploybar.de/#style=napo&balls=4&ball=250&plan=d24', ]); assert.ok(bytes instanceof Uint8Array); const ascii = Buffer.from(bytes).toString('latin1'); @@ -40,8 +55,19 @@ assert.ok(ascii.startsWith('%PDF-1.4')); assert.match(ascii, /\/Type \/Catalog/); assert.match(ascii, /\/Type \/Page\b/); assert.match(ascii, /\/Encoding \/WinAnsiEncoding/); +assert.match(ascii, /0\.027 0\.031 0\.043 rg/); // dunkler Cyberpunk-Hintergrund +assert.match(ascii, /0 0\.941 1 rg/); // Cyan-Akzent +assert.match(ascii, /1 0\.176 0\.471 rg/); // Magenta-Akzent assert.match(ascii, /xref\n0 \d+/); assert.match(ascii, /%%EOF\n$/); +assert.match(ascii, /\/Count 1\b/); + +const longPdf = Buffer.from(buildPdfBytes([ + 'PIZZATEIG — Belastungstest', + ...Array.from({ length: 120 }, (_, i) => `ZEILE ${i + 1} — Rezeptinformation`), +])).toString('latin1'); +assert.match(longPdf, /\/Count 1\b/, 'PDF muss auf genau eine Seite begrenzt bleiben'); +assert.equal((longPdf.match(/\/Type \/Page\b/g) || []).length, 1, 'PDF enthält mehr als eine A4-Seite'); fs.writeFileSync('/tmp/teig-terminal-download-test.pdf', Buffer.from(bytes)); console.log('pdf download tests: OK');