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:
@@ -3,6 +3,15 @@
|
||||
*/
|
||||
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
|
||||
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.
|
||||
@@ -172,3 +181,196 @@ function hexToRgb(hex: string | undefined): ReturnType<typeof rgb> | null {
|
||||
const b = parseInt(h.substring(4, 6), 16) / 255;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user