Files
Tectum/js/core/pdf-writer.js
T
MachielandClaude Opus 5.5 c690e72d13 Tectum v0.4.0 — mijlpaal gevelopbouw
Configurator voor Van Emous Mantelzorgwoningen: wanden, vloeren, units,
woningen (L-vorm), plat dak met dakrand, buitenverlichting, latwerk op
vochtdoek, trasraam, sandwichpanelen en PDF-tekenbladen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:38:54 +02:00

164 lines
5.7 KiB
JavaScript

/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// js/core/pdf-writer.js — minimale, dependency-vrije PDF-generator.
// Schrijft geldige PDF 1.4 bytes rechtstreeks (geen jsPDF: geen
// netwerktoegang om dat te vendoren). Ondersteunt lijnen, rechthoeken,
// tekst (standaard Helvetica, geen embedding nodig) en kleuren, over
// meerdere pagina's. Coordinaten in PDF-punten (1pt = 1/72 inch),
// oorsprong linksonder — precies zoals PDF dat zelf verwacht.
//
// Puur string/byte-opbouw, geen browser-specifieke API's nodig om het
// DOCUMENT te bouwen (alleen het downloaden ervan is browser-specifiek,
// zie savePdfBlob hieronder).
// ============================================================
function createPdfDocument() {
const objects = [];
let nextObjId = 1;
const pageDefs = [];
let fontId, catalogId, pagesId;
function newObj(content) {
const id = nextObjId++;
objects.push({ id, content });
return id;
}
function fmt(n) {
return Math.round(n * 100) / 100;
}
function escapePdfText(str) {
return str.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
function toLatin1(str) {
let out = '';
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
out += code <= 255 ? str[i] : '?';
}
return out;
}
// Ruwe schatting van Helvetica-tekstbreedte (geen AFM-metrics embedded).
function estimateTextWidth(str, size) {
return str.length * size * 0.55;
}
fontId = newObj('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>');
function addPage(widthPt, heightPt) {
const ops = [];
let strokeKey = null, fillKey = null, widthKey = null;
function setStroke(r, g, b) {
const key = r + ',' + g + ',' + b;
if (key === strokeKey) return;
strokeKey = key;
ops.push(`${fmt(r)} ${fmt(g)} ${fmt(b)} RG`);
}
function setFill(r, g, b) {
const key = r + ',' + g + ',' + b;
if (key === fillKey) return;
fillKey = key;
ops.push(`${fmt(r)} ${fmt(g)} ${fmt(b)} rg`);
}
function setLineWidth(w) {
if (w === widthKey) return;
widthKey = w;
ops.push(`${fmt(w)} w`);
}
function line(x1, y1, x2, y2) {
ops.push(`${fmt(x1)} ${fmt(y1)} m ${fmt(x2)} ${fmt(y2)} l S`);
}
function rect(x, y, w, h, mode) {
ops.push(`${fmt(x)} ${fmt(y)} ${fmt(w)} ${fmt(h)} re ${mode || 'S'}`);
}
function polyline(points, close) {
if (!points.length) return;
ops.push(`${fmt(points[0][0])} ${fmt(points[0][1])} m`);
for (let i = 1; i < points.length; i++) ops.push(`${fmt(points[i][0])} ${fmt(points[i][1])} l`);
ops.push(close ? 's' : 'S');
}
function text(x, y, size, str, align) {
const s = escapePdfText(toLatin1(str));
let tx = x;
if (align === 'center') tx = x - estimateTextWidth(str, size) / 2;
else if (align === 'right') tx = x - estimateTextWidth(str, size);
ops.push(`BT /F1 ${fmt(size)} Tf ${fmt(tx)} ${fmt(y)} Td (${s}) Tj ET`);
}
function textRotated(x, y, size, str, degrees) {
const rad = (degrees * Math.PI) / 180;
const cos = Math.cos(rad), sin = Math.sin(rad);
const s = escapePdfText(toLatin1(str));
ops.push(`BT /F1 ${fmt(size)} Tf ${fmt(cos)} ${fmt(sin)} ${fmt(-sin)} ${fmt(cos)} ${fmt(x)} ${fmt(y)} Tm (${s}) Tj ET`);
}
const contentId = newObj('');
const pageId = newObj('');
pageDefs.push({ pageId, contentId, ops, widthPt, heightPt });
return { setStroke, setFill, setLineWidth, line, rect, polyline, text, textRotated, estimateTextWidth };
}
function build() {
pagesId = newObj('');
catalogId = newObj(`<< /Type /Catalog /Pages ${pagesId} 0 R >>`);
pageDefs.forEach(p => {
const streamBody = p.ops.join('\n');
objects.find(o => o.id === p.contentId).content =
`<< /Length ${streamBody.length} >>\nstream\n${streamBody}\nendstream`;
objects.find(o => o.id === p.pageId).content =
`<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${fmt(p.widthPt)} ${fmt(p.heightPt)}] ` +
`/Resources << /Font << /F1 ${fontId} 0 R >> >> /Contents ${p.contentId} 0 R >>`;
});
objects.find(o => o.id === pagesId).content =
`<< /Type /Pages /Kids [${pageDefs.map(p => p.pageId + ' 0 R').join(' ')}] /Count ${pageDefs.length} >>`;
objects.sort((a, b) => a.id - b.id);
let out = '%PDF-1.4\n%\xE2\xE3\xCF\xD3\n';
const offsets = [0];
objects.forEach(obj => {
offsets[obj.id] = out.length;
out += `${obj.id} 0 obj\n${obj.content}\nendobj\n`;
});
const xrefOffset = out.length;
out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (let i = 1; i <= objects.length; i++) {
out += String(offsets[i]).padStart(10, '0') + ' 00000 n \n';
}
out += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
return out;
}
return { addPage, build };
}
// Zet een Latin1-string (elk char = 1 byte, zie hierboven) om naar een
// Blob en start een download. Browser-specifiek, puur I/O — de opbouw
// hierboven is dat niet.
function savePdfBlob(latin1String, filename) {
const bytes = new Uint8Array(latin1String.length);
for (let i = 0; i < latin1String.length; i++) bytes[i] = latin1String.charCodeAt(i) & 0xff;
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}