style: render PDF in cyberpunk A4 layout
CI and Deploy / test-build-deploy (push) Successful in 4s

This commit is contained in:
Hermes Agent
2026-08-03 14:33:20 +00:00
parent 405170a90c
commit 9f8e130d41
3 changed files with 75 additions and 20 deletions
+1 -1
View File
@@ -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/
+47 -18
View File
@@ -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;i<Math.max(1,Math.ceil(body.length/pageSize));i++) pages.push(body.slice(i*pageSize,(i+1)*pageSize));
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 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;
+27 -1
View File
@@ -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');