T21: Add printService.ts (page setup, scale, multi-page, print)

This commit is contained in:
2026-06-22 23:17:17 +00:00
parent b02597982f
commit 4d30a2bd51
+422
View File
@@ -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<PageSize, { width: number; height: number }> = {
a4: { width: 210, height: 297 },
a3: { width: 297, height: 420 },
a2: { width: 420, height: 594 },
a1: { width: 594, height: 841 },
};
const SCALE_VALUES: Record<ScalePreset, number> = {
'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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
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(`<?xml version="1.0" encoding="UTF-8"?>`);
parts.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${vbW} ${vbH}" width="${vbW}mm" height="${vbH}mm">`);
// Clip rect for this page's visible area
parts.push(`<defs><clipPath id="pageClip"><rect x="0" y="0" width="${vbW}" height="${vbH}" /></clipPath></defs>`);
parts.push(`<g clip-path="url(#pageClip)">`);
// Draw elements
for (const el of elements) {
const stroke = (el.properties as Record<string, unknown>).stroke as string || '#000000';
const fill = (el.properties as Record<string, unknown>).fill as string || 'none';
const strokeWidth = ((el.properties as Record<string, unknown>).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<string, unknown>).x1 as number ?? el.x);
const y1 = sy((el.properties as Record<string, unknown>).y1 as number ?? el.y);
const x2 = sx((el.properties as Record<string, unknown>).x2 as number ?? el.x + el.width);
const y2 = sy((el.properties as Record<string, unknown>).y2 as number ?? el.y + el.height);
parts.push(`<line x1="${x1.toFixed(2)}" y1="${y1.toFixed(2)}" x2="${x2.toFixed(2)}" y2="${y2.toFixed(2)}" stroke="${stroke}" stroke-width="${strokeWidth.toFixed(3)}" />`);
break;
}
case 'circle': {
const radius = (el.properties as Record<string, unknown>).radius as number ?? el.width / 2;
const cx = sx(el.x + radius);
const cy = sy(el.y + radius);
const r = radius / scaleValue;
parts.push(`<circle cx="${cx.toFixed(2)}" cy="${cy.toFixed(2)}" r="${r.toFixed(2)}" fill="${fillResolved}" stroke="${stroke}" stroke-width="${strokeWidth.toFixed(3)}" />`);
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(`<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${w.toFixed(2)}" height="${h.toFixed(2)}" fill="${fillResolved}" stroke="${stroke}" stroke-width="${strokeWidth.toFixed(3)}" />`);
break;
}
case 'polyline': {
const pts = (el.properties as Record<string, unknown>).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(`<polyline points="${ptsStr}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth.toFixed(3)}" />`);
}
break;
}
case 'polygon': {
const pts = (el.properties as Record<string, unknown>).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(`<polygon points="${ptsStr}" fill="${fillResolved}" stroke="${stroke}" stroke-width="${strokeWidth.toFixed(3)}" />`);
}
break;
}
case 'text': {
const text = escapeXml((el.properties as Record<string, unknown>).text as string || '');
const fontSize = ((el.properties as Record<string, unknown>).fontSize as number || 14) / scaleValue;
parts.push(`<text x="${sx(el.x).toFixed(2)}" y="${sy(el.y).toFixed(2)}" font-size="${fontSize.toFixed(1)}" fill="${stroke}">${text}</text>`);
break;
}
case 'arc': {
const radius = (el.properties as Record<string, unknown>).radius as number ?? el.width / 2;
const cx = sx(el.x + radius);
const cy = sy(el.y + radius);
const r = radius / scaleValue;
parts.push(`<ellipse cx="${cx.toFixed(2)}" cy="${cy.toFixed(2)}" rx="${r.toFixed(2)}" ry="${r.toFixed(2)}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth.toFixed(3)}" />`);
break;
}
default:
break;
}
}
parts.push('</g>');
// Print border
if (settings.showBorder) {
parts.push(`<rect x="0" y="0" width="${vbW}" height="${vbH}" fill="none" stroke="#000" stroke-width="0.5" />`);
}
// 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(`<rect x="${tbX}" y="${tbY}" width="${tbW}" height="${tbH}" fill="none" stroke="#000" stroke-width="0.3" />`);
parts.push(`<line x1="${tbX}" y1="${tbY + 10}" x2="${tbX + tbW}" y2="${tbY + 10}" stroke="#000" stroke-width="0.2" />`);
parts.push(`<line x1="${tbX + tbW / 2}" y1="${tbY}" x2="${tbX + tbW / 2}" y2="${tbY + tbH}" stroke="#000" stroke-width="0.2" />`);
parts.push(`<text x="${tbX + 3}" y="${tbY + 7}" font-size="3" fill="#000">${escapeXml(tb.title)}</text>`);
parts.push(`<text x="${tbX + 3}" y="${tbY + 17}" font-size="2.5" fill="#000">Scale: ${escapeXml(tb.scale)}</text>`);
parts.push(`<text x="${tbX + 3}" y="${tbY + 25}" font-size="2.5" fill="#000">${escapeXml(tb.author)}</text>`);
parts.push(`<text x="${tbX + tbW / 2 + 3}" y="${tbY + 17}" font-size="2.5" fill="#000">${escapeXml(tb.date)}</text>`);
parts.push(`<text x="${tbX + tbW / 2 + 3}" y="${tbY + 25}" font-size="2.5" fill="#000">Sheet: ${pageIndex + 1}/${layout.totalPages}</text>`);
}
parts.push('</svg>');
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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>CAD Print — ${settings.titleBlockData?.title || 'Drawing'}</title>
<style>
@page {
size: ${settings.pageSize} ${settings.orientation};
margin: ${settings.margin}mm;
}
body {
margin: 0;
padding: 0;
background: #fff;
}
.print-page {
width: ${layout.pageWidth}mm;
height: ${layout.pageHeight}mm;
page-break-after: always;
position: relative;
box-sizing: border-box;
padding: ${settings.margin}mm;
}
.print-page:last-child {
page-break-after: auto;
}
.print-page svg {
width: 100%;
height: 100%;
}
@media print {
.print-page {
width: auto;
height: auto;
}
}
</style>
</head>
<body>
${pages.map(p => `<div class="print-page">${p.svg}</div>`).join('\n')}
</body>
</html>`;
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}`;
}