From 405170a90cecfeb68a57edc617017999f40a1860 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 3 Aug 2026 14:25:38 +0000 Subject: [PATCH] feat: add direct PDF recipe download --- .gitea/workflows/ci.yml | 7 ++- README.md | 1 + index.html | 109 ++++++++++++++++++++++++++++++++++++- tests/pdf-download.test.js | 47 ++++++++++++++++ 4 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 tests/pdf-download.test.js diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6918a67..8cfae2d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -16,8 +16,11 @@ jobs: - name: Repository auschecken uses: actions/checkout@v4 - - name: Hefekalibrierung testen - run: node tests/yeast-calibration.test.js + - name: Regressionstests ausführen + run: | + for test in tests/*.test.js; do + node "$test" + done - name: JavaScript-Syntax prüfen run: | diff --git a/README.md b/README.md index 2ac8596..63344e9 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Aktuelle Version: **v1.1** - Vorteig - Rückwärts-Zeitplan - Rezept-Link über URL-Hash +- Rezept direkt als PDF herunterladen Live: https://teig.deploybar.de/ diff --git a/index.html b/index.html index 66bc9e1..e306c06 100644 --- a/index.html +++ b/index.html @@ -502,7 +502,8 @@ footer b{color:var(--txt)}
- + +
@@ -929,6 +930,7 @@ function initUI(){ $("share").onclick=share; $("copyTxt").onclick=copyText; + $("downloadPdf").onclick=downloadPdf; $("pdf").onclick=savePdf; $("reset").onclick=()=>{ if(!confirm("Alle Werte zurücksetzen?")) return; S=Object.assign({},DEFAULT); S.bake=defaultBake(); location.hash=""; render(); }; } @@ -1176,6 +1178,104 @@ async function toClipboard(text, btn, label){ } function share(){ toClipboard(location.href, $("share"), "Link kopiert ✓"); } +/* PDF-DOWNLOAD */ +function pdfFilename(styleName, balls, ballWeight, planName){ + return `Pizzateig-${styleName}-${balls}x${Math.round(ballWeight)}g-${planName}.pdf` + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g,"") + .replace(/[\/\\:*?"<>|]+/g,"-") + .replace(/\s+/g,"-") + .replace(/-+/g,"-"); +} + +function winAnsiHex(text){ + const special={"€":128,"‚":130,"ƒ":131,"„":132,"…":133,"†":134,"‡":135,"ˆ":136,"‰":137,"Š":138,"‹":139,"Œ":140,"Ž":142,"‘":145,"’":146,"“":147,"”":148,"•":149,"–":150,"—":151,"˜":152,"™":153,"š":154,"›":155,"œ":156,"ž":158,"Ÿ":159}; + let out=""; + for(const char of String(text)){ + const point=char.codePointAt(0); + const byte=special[char] ?? (point<=255 ? point : 63); + out+=byte.toString(16).padStart(2,"0").toUpperCase(); + } + return out; +} + +function wrapPdfLines(lines, width=92){ + const out=[]; + lines.forEach(raw=>{ + const line=String(raw); + if(!line){ out.push(""); return; } + const indent=(line.match(/^\s*/)||[""])[0]; + let rest=line.trim(); + while(rest.length>width){ + let cut=rest.lastIndexOf(" ",width); + if(cut<20) cut=width; + out.push(indent+rest.slice(0,cut)); + rest=rest.slice(cut).trimStart(); + } + out.push(indent+rest); + }); + return out; +} + +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{ + 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} >>`; + + const chunks=[encoder.encode("%PDF-1.4\n")], offsets=[0]; + let length=chunks[0].length; + for(let id=1;id>\nstartxref\n${xrefAt}\n%%EOF\n`; + chunks.push(encoder.encode(tail)); length+=chunks[chunks.length-1].length; + + const pdf=new Uint8Array(length); + let at=0; + chunks.forEach(chunk=>{ pdf.set(chunk,at); at+=chunk.length; }); + return pdf; +} + +function downloadPdf(){ + const r=lastR, st=STYLES[S.style], btn=$("downloadPdf"); + const blob=new Blob([buildPdfBytes(recipeText().split("\n"))],{type:"application/pdf"}); + const url=URL.createObjectURL(blob), link=document.createElement("a"); + link.href=url; + link.download=pdfFilename(st.n,S.balls,r.ballW,PLANS[S.plan].n); + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(()=>URL.revokeObjectURL(url),1000); + const old=btn.textContent; + btn.textContent="PDF geladen ✓"; + setTimeout(()=>btn.textContent=old,1800); +} + /* PDF = Druckdialog mit sprechendem Dateinamen. Browser und Handys bieten dort „Als PDF sichern" an — ganz ohne externe Bibliothek. */ const ORIG_TITLE=document.title; @@ -1205,7 +1305,7 @@ function renderPrint(r){ `Erstellt am ${d.getDate()}.${d.getMonth()+1}.${d.getFullYear()} mit TEIG//TERMINAL · Rezept erneut öffnen: ${location.href}`; } -function copyText(){ +function recipeText(){ const r=lastR, tl=lastTl, st=STYLES[S.style], f=FLOURS.find(x=>x.id===S.flour); const L=[]; L.push(`PIZZATEIG — ${st.n}`); @@ -1230,7 +1330,10 @@ function copyText(){ tl.steps.forEach(s=>L.push(` ${fmtT(s.at)} ${fmtDay(s.at)} — ${s.a}${s.d?` (${dur(s.d)})`:""}`)); L.push(""); L.push(location.href); - toClipboard(L.join("\n"), $("copyTxt"), "Text kopiert ✓"); + return L.join("\n"); +} +function copyText(){ + toClipboard(recipeText(), $("copyTxt"), "Text kopiert ✓"); } window.addEventListener("hashchange",()=>{ if(!hashLock){ readHash(); render(); }}); diff --git a/tests/pdf-download.test.js b/tests/pdf-download.test.js new file mode 100644 index 0000000..c012609 --- /dev/null +++ b/tests/pdf-download.test.js @@ -0,0 +1,47 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); + +assert.match(html, /id="downloadPdf"[^>]*>PDF herunterladen<\/button>/, 'PDF-Download-Button fehlt'); + +const start = html.indexOf('/* PDF-DOWNLOAD */'); +const end = html.indexOf('/* PDF = Druckdialog', start); +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); + +const { pdfFilename, buildPdfBytes } = sandbox.api; +assert.equal( + pdfFilename('Napoletana', 4, 250, '24 Stunden'), + 'Pizzateig-Napoletana-4x250g-24-Stunden.pdf' +); +assert.equal( + pdfFilename('Frei/Test', 2, 199.6, 'Same:Day'), + 'Pizzateig-Frei-Test-2x200g-Same-Day.pdf' +); +assert.equal( + pdfFilename('Römisch tonda', 4, 200, 'Über Nacht'), + 'Pizzateig-Romisch-tonda-4x200g-Uber-Nacht.pdf' +); + +const bytes = buildPdfBytes([ + 'PIZZATEIG — Napoletana', + '4 Teiglinge à 250 g · 1.000 g Teig', + 'Wasser 620 g · Salz 28 g · 20 °C', +]); +assert.ok(bytes instanceof Uint8Array); +const ascii = Buffer.from(bytes).toString('latin1'); +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, /xref\n0 \d+/); +assert.match(ascii, /%%EOF\n$/); + +fs.writeFileSync('/tmp/teig-terminal-download-test.pdf', Buffer.from(bytes)); +console.log('pdf download tests: OK');