diff --git a/frontend/src/services/pdfExport.ts b/frontend/src/services/pdfExport.ts index 8bfbac6..2808f3e 100644 --- a/frontend/src/services/pdfExport.ts +++ b/frontend/src/services/pdfExport.ts @@ -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 | 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 { + 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 => { + 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, + 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; + } + } +} diff --git a/frontend/tests/pdfLayoutExport.test.ts b/frontend/tests/pdfLayoutExport.test.ts new file mode 100644 index 0000000..d55d5cf --- /dev/null +++ b/frontend/tests/pdfLayoutExport.test.ts @@ -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); + }); +});