From 4d30a2bd51291602d9ba09a5990e4ff3c9ff0f5d Mon Sep 17 00:00:00 2001 From: Leopoldadmin Date: Mon, 22 Jun 2026 23:17:17 +0000 Subject: [PATCH] T21: Add printService.ts (page setup, scale, multi-page, print) --- frontend/src/services/printService.ts | 422 ++++++++++++++++++++++++++ 1 file changed, 422 insertions(+) create mode 100644 frontend/src/services/printService.ts diff --git a/frontend/src/services/printService.ts b/frontend/src/services/printService.ts new file mode 100644 index 0000000..80a5015 --- /dev/null +++ b/frontend/src/services/printService.ts @@ -0,0 +1,422 @@ +import type { YjsDocument } from '../crdt/YjsDocument'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type PageSize = 'a4' | 'a3' | 'a2' | 'a1'; +export type Orientation = 'portrait' | 'landscape'; +export type ScalePreset = '1:50' | '1:100' | '1:200' | 'custom'; + +export interface PrintSettings { + pageSize: PageSize; + orientation: Orientation; + scale: ScalePreset; + customScale: number; // e.g. 100 for 1:100 + showBorder: boolean; + showTitleBlock: boolean; + titleBlockData?: TitleBlockData; + margin: number; // mm +} + +export interface TitleBlockData { + title: string; + author: string; + date: string; + scale: string; + sheet: string; +} + +export interface PageLayout { + pageWidth: number; // mm + pageHeight: number; // mm + drawableWidth: number; // mm + drawableHeight: number; // mm + cols: number; + rows: number; + totalPages: number; + scale: number; // actual scale factor (1/scaleValue) + scaleValue: number; // e.g. 100 for 1:100 +} + +export interface BoundingBox { + minX: number; + minY: number; + width: number; + height: number; +} + +export interface PrintPage { + index: number; + col: number; + row: number; + offsetX: number; // CAD units + offsetY: number; // CAD units + width: number; // CAD units visible on this page + height: number; // CAD units visible on this page + svg: string; // SVG content for this page +} + +export interface PrintResult { + settings: PrintSettings; + layout: PageLayout; + bbox: BoundingBox; + pages: PrintPage[]; +} + +// ─── Page Size Dimensions (mm) ─────────────────────────────────────────────── + +const PAGE_DIMENSIONS: Record = { + a4: { width: 210, height: 297 }, + a3: { width: 297, height: 420 }, + a2: { width: 420, height: 594 }, + a1: { width: 594, height: 841 }, +}; + +const SCALE_VALUES: Record = { + '1:50': 50, + '1:100': 100, + '1:200': 200, + custom: 100, // overridden by customScale +}; + +// ─── Default Settings ──────────────────────────────────────────────────────── + +export const DEFAULT_PRINT_SETTINGS: PrintSettings = { + pageSize: 'a4', + orientation: 'landscape', + scale: '1:100', + customScale: 100, + showBorder: true, + showTitleBlock: true, + titleBlockData: { + title: 'CAD Drawing', + author: '', + date: new Date().toISOString().split('T')[0], + scale: '1:100', + sheet: '1/1', + }, + margin: 10, +}; + +// ─── Bounding Box Calculation ──────────────────────────────────────────────── + +export function calculateBoundingBox(yjsDoc: YjsDocument): BoundingBox { + const elements = yjsDoc.getElements(); + if (elements.length === 0) { + return { minX: 0, minY: 0, width: 100, height: 100 }; + } + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const el of elements) { + minX = Math.min(minX, el.x); + minY = Math.min(minY, el.y); + maxX = Math.max(maxX, el.x + el.width); + maxY = Math.max(maxY, el.y + el.height); + } + + return { + minX, + minY, + width: maxX - minX, + height: maxY - minY, + }; +} + +// ─── Page Layout Calculation ───────────────────────────────────────────────── + +export function calculatePageLayout( + bbox: BoundingBox, + settings: PrintSettings +): PageLayout { + const dims = PAGE_DIMENSIONS[settings.pageSize]; + const pageWidth = settings.orientation === 'landscape' ? dims.height : dims.width; + const pageHeight = settings.orientation === 'landscape' ? dims.width : dims.height; + + const margin = settings.margin; + const drawableWidth = pageWidth - margin * 2; + const drawableHeight = pageHeight - margin * 2; + + // Scale: 1:100 means 1 CAD unit = 1mm on paper at scale 1/100 + // So 100 CAD units = 1mm on paper → drawableWidth mm fits drawableWidth * scaleValue CAD units + const scaleValue = settings.scale === 'custom' ? settings.customScale : SCALE_VALUES[settings.scale]; + const scale = 1 / scaleValue; + + // How many CAD units fit on one page (drawable area in mm * scaleValue) + const cadUnitsPerPageW = drawableWidth * scaleValue; + const cadUnitsPerPageH = drawableHeight * scaleValue; + + // Number of pages needed + const cols = Math.max(1, Math.ceil(bbox.width / cadUnitsPerPageW)); + const rows = Math.max(1, Math.ceil(bbox.height / cadUnitsPerPageH)); + const totalPages = cols * rows; + + return { + pageWidth, + pageHeight, + drawableWidth, + drawableHeight, + cols, +n rows, + totalPages, + scale, + scaleValue, + }; +} + +// ─── SVG Generation for Print Pages ────────────────────────────────────────── + +function escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function generatePageSVG( + yjsDoc: YjsDocument, + bbox: BoundingBox, + layout: PageLayout, + settings: PrintSettings, + pageIndex: number, + col: number, + row: number +): string { + const elements = yjsDoc.getElements(); + const scaleValue = layout.scaleValue; + + // CAD units visible on this page + const cadUnitsPerPageW = layout.drawableWidth * scaleValue; + const cadUnitsPerPageH = layout.drawableHeight * scaleValue; + + const offsetX = bbox.minX + col * cadUnitsPerPageW; + const offsetY = bbox.minY + row * cadUnitsPerPageH; + const visibleW = Math.min(cadUnitsPerPageW, bbox.minX + bbox.width - offsetX); + const visibleH = Math.min(cadUnitsPerPageH, bbox.minY + bbox.height - offsetY); + + // SVG viewBox in mm (drawable area) + const vbW = layout.drawableWidth; + const vbH = layout.drawableHeight; + + // Transform: CAD coords → SVG mm coords + // svgX = (cadX - offsetX) / scaleValue + // svgY = vbH - (cadY - offsetY) / scaleValue (Y flip) + + const parts: string[] = []; + parts.push(``); + parts.push(``); + + // Clip rect for this page's visible area + parts.push(``); + parts.push(``); + + // Draw elements + for (const el of elements) { + const stroke = (el.properties as Record).stroke as string || '#000000'; + const fill = (el.properties as Record).fill as string || 'none'; + const strokeWidth = ((el.properties as Record).strokeWidth as number || 1) / scaleValue; + const fillResolved = fill === 'transparent' ? 'none' : fill; + + const sx = (cadX: number) => (cadX - offsetX) / scaleValue; + const sy = (cadY: number) => vbH - (cadY - offsetY) / scaleValue; + + switch (el.type) { + case 'line': { + const x1 = sx((el.properties as Record).x1 as number ?? el.x); + const y1 = sy((el.properties as Record).y1 as number ?? el.y); + const x2 = sx((el.properties as Record).x2 as number ?? el.x + el.width); + const y2 = sy((el.properties as Record).y2 as number ?? el.y + el.height); + parts.push(``); + break; + } + case 'circle': { + const radius = (el.properties as Record).radius as number ?? el.width / 2; + const cx = sx(el.x + radius); + const cy = sy(el.y + radius); + const r = radius / scaleValue; + parts.push(``); + break; + } + case 'rect': { + const x = sx(el.x); + const y = sy(el.y + el.height); + const w = el.width / scaleValue; + const h = el.height / scaleValue; + parts.push(``); + break; + } + case 'polyline': { + const pts = (el.properties as Record).points as Array<{ x: number; y: number }> | undefined; + if (pts && pts.length > 0) { + const ptsStr = pts.map(p => `${sx(p.x).toFixed(2)},${sy(p.y).toFixed(2)}`).join(' '); + parts.push(``); + } + break; + } + case 'polygon': { + const pts = (el.properties as Record).points as Array<{ x: number; y: number }> | undefined; + if (pts && pts.length > 0) { + const ptsStr = pts.map(p => `${sx(p.x).toFixed(2)},${sy(p.y).toFixed(2)}`).join(' '); + parts.push(``); + } + break; + } + case 'text': { + const text = escapeXml((el.properties as Record).text as string || ''); + const fontSize = ((el.properties as Record).fontSize as number || 14) / scaleValue; + parts.push(`${text}`); + break; + } + case 'arc': { + const radius = (el.properties as Record).radius as number ?? el.width / 2; + const cx = sx(el.x + radius); + const cy = sy(el.y + radius); + const r = radius / scaleValue; + parts.push(``); + break; + } + default: + break; + } + } + + parts.push(''); + + // Print border + if (settings.showBorder) { + parts.push(``); + } + + // Title block (bottom-right corner) + if (settings.showTitleBlock && settings.titleBlockData) { + const tb = settings.titleBlockData; + const tbW = 80; + const tbH = 30; + const tbX = vbW - tbW - 2; + const tbY = vbH - tbH - 2; + parts.push(``); + parts.push(``); + parts.push(``); + parts.push(`${escapeXml(tb.title)}`); + parts.push(`Scale: ${escapeXml(tb.scale)}`); + parts.push(`${escapeXml(tb.author)}`); + parts.push(`${escapeXml(tb.date)}`); + parts.push(`Sheet: ${pageIndex + 1}/${layout.totalPages}`); + } + + parts.push(''); + return parts.join('\n'); +} + +// ─── Main Print Preparation ────────────────────────────────────────────────── + +export function preparePrint( + yjsDoc: YjsDocument, + settings: PrintSettings +): PrintResult { + const bbox = calculateBoundingBox(yjsDoc); + const layout = calculatePageLayout(bbox, settings); + + const pages: PrintPage[] = []; + for (let row = 0; row < layout.rows; row++) { + for (let col = 0; col < layout.cols; col++) { + const index = row * layout.cols + col; + const cadUnitsPerPageW = layout.drawableWidth * layout.scaleValue; + const cadUnitsPerPageH = layout.drawableHeight * layout.scaleValue; + const offsetX = bbox.minX + col * cadUnitsPerPageW; + const offsetY = bbox.minY + row * cadUnitsPerPageH; + const visibleW = Math.min(cadUnitsPerPageW, bbox.minX + bbox.width - offsetX); + const visibleH = Math.min(cadUnitsPerPageH, bbox.minY + bbox.height - offsetY); + + pages.push({ + index, + col, + row, + offsetX, + offsetY, + width: visibleW, + height: visibleH, + svg: generatePageSVG(yjsDoc, bbox, layout, settings, index, col, row), + }); + } + } + + return { + settings, + layout, + bbox, + pages, + }; +} + +// ─── Browser Print ─────────────────────────────────────────────────────────── + +export function printPages(printResult: PrintResult): void { + const printWindow = window.open('', '_blank', 'width=800,height=600'); + if (!printWindow) { + alert('Please allow popups to print.'); + return; + } + + const { layout, pages, settings } = printResult; + + const html = ` + + + +CAD Print — ${settings.titleBlockData?.title || 'Drawing'} + + + +${pages.map(p => ``).join('\n')} + +`; + + printWindow.document.write(html); + printWindow.document.close(); + + // Wait for SVGs to render, then trigger print + printWindow.onload = () => { + setTimeout(() => { + printWindow.print(); + }, 500); + }; +} + +// ─── SVG Data URL for Preview ──────────────────────────────────────────────── + +export function svgToDataUrl(svg: string): string { + const encoded = encodeURIComponent(svg); + return `data:image/svg+xml;charset=utf-8,${encoded}`; +}