This commit is contained in:
@@ -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: |
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
+106
-3
@@ -502,7 +502,8 @@ footer b{color:var(--txt)}
|
||||
|
||||
<div class="actions noprint">
|
||||
<button type="button" class="btn pri" id="share">Rezept teilen</button>
|
||||
<button type="button" class="btn" id="pdf">Druck/PDF</button>
|
||||
<button type="button" class="btn" id="downloadPdf">PDF herunterladen</button>
|
||||
<button type="button" class="btn" id="pdf">Drucken</button>
|
||||
<button type="button" class="btn" id="copyTxt">Als Text kopieren</button>
|
||||
<button type="button" class="btn" id="reset">Zurücksetzen</button>
|
||||
</div>
|
||||
@@ -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<Math.max(1,Math.ceil(body.length/pageSize));i++) pages.push(body.slice(i*pageSize,(i+1)*pageSize));
|
||||
|
||||
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} >>`;
|
||||
|
||||
const chunks=[encoder.encode("%PDF-1.4\n")], offsets=[0];
|
||||
let length=chunks[0].length;
|
||||
for(let id=1;id<objects.length;id++){
|
||||
offsets[id]=length;
|
||||
const chunk=encoder.encode(`${id} 0 obj\n${objects[id]}\nendobj\n`);
|
||||
chunks.push(chunk); length+=chunk.length;
|
||||
}
|
||||
const xrefAt=length;
|
||||
let tail=`xref\n0 ${objects.length}\n0000000000 65535 f \n`;
|
||||
for(let id=1;id<objects.length;id++) tail+=`${String(offsets[id]).padStart(10,"0")} 00000 n \n`;
|
||||
tail+=`trailer\n<< /Size ${objects.length} /Root 1 0 R >>\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(); }});
|
||||
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user