import { Box, Role, roleOf } from './svgModel'; /** * Turns parts of the live Archify SVG into images. Each element's computed * style is written inline into a standalone copy, so the image carries the * current theme, preset, focus and dimming exactly as the 2D view shows them. */ /** Presentation properties copied from computed style. */ const PROPS = [ 'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', // visibility is left out: the 3D view hides the 2D SVG with it. 'opacity', 'display', 'filter', 'paint-order', 'font-family', 'font-size', 'font-weight', 'font-style', 'letter-spacing', 'text-anchor', 'dominant-baseline', 'text-transform', ]; let fontCss: string | undefined; /** The page's @font-face rules (Archify embeds JetBrains Mono as data URIs). */ function fontFaces(): string { if (fontCss !== undefined) return fontCss; const rules: string[] = []; for (const sheet of [...document.styleSheets]) { let list: CSSRuleList; try { list = sheet.cssRules; } catch { continue; } for (const rule of [...list]) if (rule instanceof CSSFontFaceRule) rules.push(rule.cssText); } fontCss = rules.join('\n'); return fontCss; } export interface LayerOptions { /** Roles whose elements are drawn; everything else is hidden. */ keep: Role[]; /** Draw kept nodes fully opaque; the 3D view applies their dimming itself. */ opaqueNodes?: boolean; } /** Copies computed styles from the live tree onto a clone with the same shape. */ function inlineStyles(live: Element, copy: Element, options: LayerOptions, inherited: Role | undefined, root: boolean): void { const role = inherited ?? roleOf(live); const isDefs = live.tagName === 'defs' || !!live.closest('defs'); const style = getComputedStyle(live); const parts: string[] = []; for (const prop of PROPS) { const value = style.getPropertyValue(prop); if (value) parts.push(`${prop}:${value}`); } let hidden = false; if (root) { // The root carries viewer state (camera transform, our fade); keep only // what its children inherit. copy.setAttribute('style', parts.filter((p) => /^(font|letter|text)/.test(p)).join(';')); } else { if (!isDefs) { if (role) hidden = !options.keep.includes(role); else if (live.children.length === 0) hidden = !options.keep.includes('floor'); } if (options.opaqueNodes && role === 'node' && !inherited) parts.push('opacity:1'); if (hidden) parts.push('display:none'); copy.setAttribute('style', parts.join(';')); } copy.removeAttribute('class'); if (hidden) return; const liveKids = live.children; const copyKids = copy.children; for (let i = 0; i < liveKids.length && i < copyKids.length; i++) { inlineStyles(liveKids[i], copyKids[i], options, role, false); } } /** * Standalone SVG markup for one layer of the diagram, sized at `scale` * pixels per user unit so the browser rasterizes it sharply. */ export function layerMarkup(svg: SVGSVGElement, viewBox: Box, options: LayerOptions, scale: number): string { const copy = svg.cloneNode(true) as SVGSVGElement; inlineStyles(svg, copy, options, undefined, true); // Viewer-only children (focus overlays, hit areas) carry no data roles but // are not part of the authored drawing. copy.querySelectorAll('[data-viewer-only], .no-export').forEach((el) => el.remove()); copy.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); copy.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`); copy.setAttribute('width', String(Math.round(viewBox.w * scale))); copy.setAttribute('height', String(Math.round(viewBox.h * scale))); const style = document.createElementNS('http://www.w3.org/2000/svg', 'style'); style.textContent = fontFaces(); copy.insertBefore(style, copy.firstChild); return new XMLSerializer().serializeToString(copy); } export interface Raster { canvas: HTMLCanvasElement; /** Pixels per SVG user unit. */ scale: number; /** SVG area the canvas covers (the view box plus a margin). */ area: Box; } /** Rasterizes SVG markup over `area` (in user units) at `scale` pixels per unit. */ export async function rasterize(markup: string, viewBox: Box, area: Box, scale: number, paint?: (ctx: CanvasRenderingContext2D) => void): Promise { const canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.round(area.w * scale)); canvas.height = Math.max(1, Math.round(area.h * scale)); const ctx = canvas.getContext('2d')!; ctx.scale(scale, scale); ctx.translate(-area.x, -area.y); paint?.(ctx); const url = URL.createObjectURL(new Blob([markup], { type: 'image/svg+xml' })); try { const image = new Image(); image.src = url; await image.decode(); ctx.drawImage(image, viewBox.x, viewBox.y, viewBox.w, viewBox.h); } finally { URL.revokeObjectURL(url); } return { canvas, scale, area }; }