task(G4): layout PDF export - viewport-scaled A4 landscape, elementToPrimitives rendering, layer line widths + dash patterns, monochrome option, title block inclusion

This commit is contained in:
Agent Zero
2026-08-29 02:50:05 +02:00
parent f6b5046a60
commit cb23638610
2 changed files with 301 additions and 0 deletions
+202
View File
@@ -3,6 +3,15 @@
*/ */
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
import type { CADElement, CADLayer, ProjectData } from '../types/cad.types'; import type { CADElement, CADLayer, ProjectData } from '../types/cad.types';
import { elementToPrimitives, type ShapePrimitive } from '../render/pixi/elementDrawers';
import type { CADDocument } from '../kernel/document/CADDocument';
export interface LayoutPDFOptions {
/** Alles in Schwarz drucken (Task G4). */
monochrome?: boolean;
/** Seitenränder in pt (Default 20). */
margin?: number;
}
/** /**
* Export project data to a PDF byte array. * Export project data to a PDF byte array.
@@ -172,3 +181,196 @@ function hexToRgb(hex: string | undefined): ReturnType<typeof rgb> | null {
const b = parseInt(h.substring(4, 6), 16) / 255; const b = parseInt(h.substring(4, 6), 16) / 255;
return rgb(r, g, b); return rgb(r, g, b);
} }
// ═══════════════════════════════════════════════════════════
// Task G4 — PDF-Export je Layout im Ausgabemaßstab
// ═══════════════════════════════════════════════════════════
/** A4 landscape in Punkten. */
const A4_LANDSCAPE_W = 842;
const A4_LANDSCAPE_H = 595;
/**
* Exportiert EIN Layout als PDF (A4 landscape).
*
* Der gespeicherte Viewport (center/scale) definiert den sichtbaren
* Welt-Ausschnitt; die Zeichnung wird in den Ausgabemaßstab skaliert
* (Welt-pt × viewport.scale). Geometrie läuft über elementToPrimitives
* (C2) — identische Konventionen wie Pixi-Renderer (circle el.x/el.y =
* Zentrum). Linienbreiten: properties.strokeWidth, andernfalls Linientyp
* aus dem Layer (solid 0.7pt, dashed 0.9pt, dotted 0.5pt) mit passenden
* Dash-Patterns. `monochrome` erzwingt Schwarz. Der Titelblock aus
* layout.frameBlockRef wird mitgezeichnet (unskaliert als Rahmen unten
* rechts, Elemente relativ zur Ecke).
*/
export async function exportLayoutPDF(
doc: CADDocument,
layoutId: string,
options: LayoutPDFOptions = {},
): Promise<Uint8Array> {
const layout = doc.getLayout(layoutId);
if (!layout) throw new Error(`exportLayoutPDF: Layout '${layoutId}' nicht gefunden`);
const margin = options.margin ?? 20;
const pdfDoc = await PDFDocument.create();
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
const page = pdfDoc.addPage([A4_LANDSCAPE_W, A4_LANDSCAPE_H]);
const mono = options.monochrome === true;
const layers = doc.getAllLayers();
const visibleLayerIds = new Set(layers.filter((l) => l.visible).map((l) => l.id));
const layerById = new Map(layers.map((l) => [l.id, l]));
// ── Ausgabemaßstab: Welt → pt ──
const scale = layout.viewport.scale;
const viewW = (A4_LANDSCAPE_W - 2 * margin) / scale;
const viewH = (A4_LANDSCAPE_H - 2 * margin) / scale;
const worldMinX = layout.viewport.center.x - viewW / 2;
const worldMinY = layout.viewport.center.y - viewH / 2;
// PDF-Y-Achse zeigt nach OBEN (bottom-left origin) — CAD nach unten:
const toPage = (wx: number, wy: number): { x: number; y: number } => ({
x: margin + (wx - worldMinX) * scale,
y: A4_LANDSCAPE_H - margin - (wy - worldMinY) * scale,
});
const colorFor = (el: CADElement): ReturnType<typeof rgb> => {
if (mono) return rgb(0, 0, 0);
const layer = layerById.get(el.layerId);
const hex = (el.properties.stroke as string | undefined) ?? layer?.color;
return hexToRgb(hex) ?? rgb(0, 0, 0);
};
const lineWidthFor = (el: CADElement): number => {
const explicit = el.properties.strokeWidth as number | undefined;
if (typeof explicit === 'number' && explicit > 0) return explicit;
const layer = layerById.get(el.layerId);
switch (layer?.lineType) {
case 'dashed': return 0.9;
case 'dotted': return 0.5;
default: return 0.7;
}
};
const dashFor = (el: CADElement): number[] | undefined => {
if (mono) return undefined;
const layer = layerById.get(el.layerId);
if (layer?.lineType === 'dashed') return [4, 3];
if (layer?.lineType === 'dotted') return [0.5, 2];
return undefined;
};
const drawPrimitives = (els: CADElement[], transform: (wx: number, wy: number) => { x: number; y: number }, scaleOverride?: number): void => {
for (const el of els) {
if (!visibleLayerIds.has(el.layerId)) continue;
const prims = elementToPrimitives(el);
const lw = lineWidthFor(el) * (scaleOverride ?? scale);
const dash = dashFor(el);
const color = colorFor(el);
for (const prim of prims) {
drawPrimitive(page, prim, transform, lw, color, dash, font, mono);
}
}
};
// ── Modellinhalt ──
drawPrimitives(doc.getAllElements(), toPage);
// ── Titelblock (frameBlockRef) unten rechts, unskaliert ──
if (layout.frameBlockRef) {
const block = doc.getBlock(layout.frameBlockRef);
if (block) {
const FRAME_MARGIN = 12;
// Block-Elemente liegen um (0,0) herum — an Ecke unten rechts verschieben
const frameOriginX = A4_LANDSCAPE_W - margin - 190 - FRAME_MARGIN;
const frameOriginY = margin + FRAME_MARGIN;
const frameTransform = (wx: number, wy: number) => ({
x: frameOriginX + wx,
y: frameOriginY + wy,
});
drawPrimitives(block.elements, frameTransform, 1);
}
}
return pdfDoc.save();
}
/** Zeichnet ein einzelnes ShapePrimitive auf die PDF-Seite. */
function drawPrimitive(
page: any,
prim: ShapePrimitive,
transform: (wx: number, wy: number) => { x: number; y: number },
lineWidth: number,
color: ReturnType<typeof rgb>,
dash: number[] | undefined,
font: any,
mono: boolean,
): void {
switch (prim.kind) {
case 'line': {
const s = transform(prim.from.x, prim.from.y);
const e = transform(prim.to.x, prim.to.y);
page.drawLine({ start: s, end: e, thickness: lineWidth, color, dashArray: dash });
break;
}
case 'rect': {
const tl = transform(prim.x, prim.y + prim.h);
page.drawRectangle({
x: tl.x, y: tl.y, width: prim.w, height: prim.h,
borderColor: color, borderWidth: lineWidth,
...(prim.fill ? { color: hexToRgb(prim.fill) ?? color } : {}),
});
break;
}
case 'circle': {
const c = transform(prim.center.x, prim.center.y);
page.drawCircle({
x: c.x, y: c.y, radius: prim.radius,
borderColor: color, borderWidth: lineWidth,
...(prim.fill ? { color: hexToRgb(prim.fill) ?? color } : {}),
});
break;
}
case 'arc': {
// Arc → 32 Liniensegmente (Grad, 0° = 3 Uhr, CCW — CAD-Konvention)
const c = transform(prim.center.x, prim.center.y);
const SEG = 32;
const a0 = (prim.startDeg * Math.PI) / 180;
const a1 = (prim.endDeg * Math.PI) / 180;
let span = a1 - a0;
if (span <= 0) span += 2 * Math.PI;
let prev = { x: c.x + Math.cos(a0) * prim.radius, y: c.y + Math.sin(a0) * prim.radius };
for (let i = 1; i <= SEG; i++) {
const a = a0 + (span * i) / SEG;
const pt = { x: c.x + Math.cos(a) * prim.radius, y: c.y + Math.sin(a) * prim.radius };
page.drawLine({ start: prev, end: pt, thickness: lineWidth, color, dashArray: dash });
prev = pt;
}
break;
}
case 'polyline': {
for (let i = 0; i < prim.points.length - 1; i++) {
const s = transform(prim.points[i].x, prim.points[i].y);
const e = transform(prim.points[i + 1].x, prim.points[i + 1].y);
page.drawLine({ start: s, end: e, thickness: lineWidth, color, dashArray: dash });
}
if (prim.close && prim.points.length > 2) {
const s = transform(prim.points[prim.points.length - 1].x, prim.points[prim.points.length - 1].y);
const e = transform(prim.points[0].x, prim.points[0].y);
page.drawLine({ start: s, end: e, thickness: lineWidth, color, dashArray: dash });
}
break;
}
case 'text': {
const at = transform(prim.at.x, prim.at.y);
page.drawText(prim.text, {
x: at.x, y: at.y - prim.fontSize,
size: prim.fontSize,
font,
color: mono ? rgb(0, 0, 0) : (hexToRgb(prim.color) ?? color),
});
break;
}
}
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Task G4 PDF-Export je Layout im Ausgabemaßstab.
*
* exportLayoutPDF rendert den Layout-Viewport (center/scale) auf eine A4-
* Seite (landscape), skaliert Weltkoordinaten in den Ausgabemaßstab,
* berücksichtigt Linienbreiten aus Layern (lineType) und unterstützt
* eine monochrome Option. Titelblock (frameBlockRef) wird mitgezeichnet.
* Rückgabe: PDF-Bytes, per PDFDocument.load verifizierbar.
*/
import { describe, it, expect } from 'vitest';
import { PDFDocument } from 'pdf-lib';
import { CADDocument } from '../src/kernel/document/CADDocument';
import { createInMemoryDoc } from './helpers/inMemoryDoc';
import { exportLayoutPDF } from '../src/services/pdfExport';
import type { CADElement, LayoutDefinition } from '../src/types/cad.types';
function line(id: string, x1: number, y1: number, x2: number, y2: number, layerId = 'layer-0'): CADElement {
return {
id, type: 'line', layerId,
x: Math.min(x1, x2), y: Math.min(y1, y2),
width: Math.abs(x2 - x1), height: Math.abs(y2 - y1),
properties: { x1, y1, x2, y2, stroke: '#ff0000' },
} as CADElement;
}
function mkDoc(els: CADElement[], layouts: LayoutDefinition[]): CADDocument {
const { ydoc } = createInMemoryDoc();
const doc = new CADDocument(ydoc);
for (const e of els) doc.addElement(e);
for (const l of layouts) doc.addLayout(l);
return doc;
}
const LAYOUT: LayoutDefinition = {
id: 'layout-1', name: 'A4 Plan',
viewport: { center: { x: 50, y: 50 }, scale: 1 },
};
describe('G4: exportLayoutPDF', () => {
it('liefert valides PDF mit genau einer A4-landscape-Seite', async () => {
const doc = mkDoc([line('l1', 0, 0, 100, 100)], [LAYOUT]);
const bytes = await exportLayoutPDF(doc, 'layout-1');
expect(bytes.byteLength).toBeGreaterThan(500);
const pdf = await PDFDocument.load(bytes);
expect(pdf.getPageCount()).toBe(1);
const { width, height } = pdf.getPage(0).getSize();
expect(width).toBe(842); // A4 landscape pt
expect(height).toBe(595);
});
it('Titelblock-Elemente (frameBlockRef) werden mitgezeichnet', async () => {
const doc = mkDoc([line('l1', 0, 0, 100, 0)], [LAYOUT]);
// Titelblock-Definition anlegen und referenzieren
const blockId = 'title-1';
doc.addBlock({
id: blockId, name: 'Titelblock', description: '', category: 'frame',
elements: [line('tb1', 0, 0, 50, 0)],
});
doc.updateLayout('layout-1', { frameBlockRef: blockId });
const bytes = await exportLayoutPDF(doc, 'layout-1');
const pdf = await PDFDocument.load(bytes);
expect(pdf.getPageCount()).toBe(1);
// Strukturelle Prüfung: Export läuft fehlerfrei mit Frame-Block
expect(bytes.byteLength).toBeGreaterThan(500);
});
it('monochrome Option exportiert ohne Farbinformationen zu verlieren', async () => {
const doc = mkDoc([line('l1', 0, 0, 100, 100)], [LAYOUT]);
const bytes = await exportLayoutPDF(doc, 'layout-1', { monochrome: true });
const pdf = await PDFDocument.load(bytes);
expect(pdf.getPageCount()).toBe(1);
});
it('leeres Layout (keine Elemente im Viewport) exportiert trotzdem eine Seite', async () => {
const doc = mkDoc([], [LAYOUT]);
const bytes = await exportLayoutPDF(doc, 'layout-1');
const pdf = await PDFDocument.load(bytes);
expect(pdf.getPageCount()).toBe(1);
});
it('Layout ohne existierende ID → throw', async () => {
const doc = mkDoc([], []);
await expect(exportLayoutPDF(doc, 'gibts-nicht')).rejects.toThrow();
});
it('unsichtbare Layer werden übersprungen (Export läuft fehlerfrei)', async () => {
const { ydoc } = createInMemoryDoc();
const doc = new CADDocument(ydoc);
ydoc.data.layers.set('layer-0', {
id: 'layer-0', name: '0', visible: false, locked: false,
color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null,
});
doc.addElement(line('l1', 0, 0, 100, 100));
doc.addLayout(LAYOUT);
const bytes = await exportLayoutPDF(doc, 'layout-1');
const pdf = await PDFDocument.load(bytes);
expect(pdf.getPageCount()).toBe(1);
});
});