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>
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
/*!
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user