diff --git a/library/lib/apollon-editor.tsx b/library/lib/apollon-editor.tsx index 75db8cbc..08b58e20 100644 --- a/library/lib/apollon-editor.tsx +++ b/library/lib/apollon-editor.tsx @@ -186,7 +186,6 @@ export class ApollonEditor { options?: Apollon.ExportOptions, theme?: DeepPartial ): Promise { - void options void theme const container = document.createElement("div") container.style.display = "flex" @@ -278,7 +277,7 @@ export class ApollonEditor { height: bounds.height + margin * 2, } - const svgString = getSVG(container, clip) + const svgString = getSVG(container, clip, options) // Clean up svgRoot.unmount() diff --git a/library/lib/components/Sidebar.tsx b/library/lib/components/Sidebar.tsx index 8e64980b..7425520d 100644 --- a/library/lib/components/Sidebar.tsx +++ b/library/lib/components/Sidebar.tsx @@ -3,6 +3,7 @@ import { ColorDescriptionConfig, DROPS, dropElementConfigs, + LAYOUT, ZINDEX, } from "@/constants" import { DividerLine } from "./ui/DividerLine" @@ -17,6 +18,11 @@ import { DraggableGhost } from "./DraggableGhost" export const Sidebar = () => { const diagramType = useMetadataStore(useShallow((state) => state.diagramType)) + const labelPreviewTypes = new Set([ + "sfcTransitionBranch", + "petriNetPlace", + "petriNetTransition", + ]) if (dropElementConfigs[diagramType].length === 0) { return null @@ -38,30 +44,40 @@ export const Sidebar = () => { flexShrink: 0, }} > - {dropElementConfigs[diagramType].map((config, index) => ( - - -
- {React.createElement(config.svg, { - width: config.width, - height: config.height, - ...config.defaultData, - data: config.defaultData, - SIDEBAR_PREVIEW_SCALE: DROPS.SIDEBAR_PREVIEW_SCALE, - id: `sidebarElement_${index}`, - })} -
-
-
- ))} + {dropElementConfigs[diagramType].map((config, index) => { + const extraPreviewHeight = labelPreviewTypes.has(config.type) + ? LAYOUT.DEFAULT_ATTRIBUTE_HEIGHT + : 0 + const previewScale = DROPS.SIDEBAR_PREVIEW_SCALE + const previewWidth = config.width * previewScale + const previewHeight = + (config.height + extraPreviewHeight) * previewScale + + return ( + + +
+ {React.createElement(config.svg, { + width: config.width, + height: config.height, + ...config.defaultData, + data: config.defaultData, + SIDEBAR_PREVIEW_SCALE: previewScale, + id: `sidebarElement_${index}`, + })} +
+
+
+ ) + })} diff --git a/library/lib/components/svgs/nodes/petriNetDiagram/PetriNetPlaceSVG.tsx b/library/lib/components/svgs/nodes/petriNetDiagram/PetriNetPlaceSVG.tsx index a33d2f3d..b2c29f07 100644 --- a/library/lib/components/svgs/nodes/petriNetDiagram/PetriNetPlaceSVG.tsx +++ b/library/lib/components/svgs/nodes/petriNetDiagram/PetriNetPlaceSVG.tsx @@ -23,8 +23,13 @@ export const PetriNetPlaceSVG: React.FC = ({ const { name, tokens, capacity } = data const assessments = useDiagramStore(useShallow((state) => state.assessments)) const nodeScore = assessments[id]?.score - const scaledWidth = width * (SIDEBAR_PREVIEW_SCALE ?? 1) - const scaledHeight = height * (SIDEBAR_PREVIEW_SCALE ?? 1) + const previewScale = SIDEBAR_PREVIEW_SCALE ?? 1 + const scaledWidth = width * previewScale + const scaledHeight = height * previewScale + const labelHeight = LAYOUT.DEFAULT_ATTRIBUTE_HEIGHT + const scaledLabelHeight = labelHeight * previewScale + const svgHeight = height + labelHeight + const scaledSvgHeight = scaledHeight + scaledLabelHeight const centerX = width / 2 const centerY = height / 2 @@ -75,8 +80,8 @@ export const PetriNetPlaceSVG: React.FC = ({ return ( @@ -97,10 +102,10 @@ export const PetriNetPlaceSVG: React.FC = ({ {name} diff --git a/library/lib/components/svgs/nodes/petriNetDiagram/PetriNetTransitionSVG.tsx b/library/lib/components/svgs/nodes/petriNetDiagram/PetriNetTransitionSVG.tsx index 2381eb0e..c3598703 100644 --- a/library/lib/components/svgs/nodes/petriNetDiagram/PetriNetTransitionSVG.tsx +++ b/library/lib/components/svgs/nodes/petriNetDiagram/PetriNetTransitionSVG.tsx @@ -5,6 +5,7 @@ import { SVGComponentProps } from "@/types/SVG" import { CustomText } from "../CustomText" import { StyledRect } from "@/components" import { DefaultNodeProps } from "@/types" +import { LAYOUT } from "@/constants" import { getCustomColorsFromData } from "@/utils/layoutUtils" interface Props extends SVGComponentProps { @@ -23,15 +24,20 @@ export const PetriNetTransitionSVG: React.FC = ({ const { name } = data const assessments = useDiagramStore(useShallow((state) => state.assessments)) const nodeScore = assessments[id]?.score - const scaledWidth = width * (SIDEBAR_PREVIEW_SCALE ?? 1) - const scaledHeight = height * (SIDEBAR_PREVIEW_SCALE ?? 1) + const previewScale = SIDEBAR_PREVIEW_SCALE ?? 1 + const scaledWidth = width * previewScale + const scaledHeight = height * previewScale + const labelHeight = LAYOUT.DEFAULT_ATTRIBUTE_HEIGHT + const scaledLabelHeight = labelHeight * previewScale + const svgHeight = height + labelHeight + const scaledSvgHeight = scaledHeight + scaledLabelHeight const { fillColor, strokeColor, textColor } = getCustomColorsFromData(data) return ( @@ -46,10 +52,10 @@ export const PetriNetTransitionSVG: React.FC = ({ {name} diff --git a/library/lib/components/svgs/nodes/sfcDiagram/SfcTransitionBranchNodeSVG.tsx b/library/lib/components/svgs/nodes/sfcDiagram/SfcTransitionBranchNodeSVG.tsx index 0ec45d09..b257dfcd 100644 --- a/library/lib/components/svgs/nodes/sfcDiagram/SfcTransitionBranchNodeSVG.tsx +++ b/library/lib/components/svgs/nodes/sfcDiagram/SfcTransitionBranchNodeSVG.tsx @@ -16,8 +16,13 @@ export const SfcTransitionBranchNodeSVG: React.FC = ({ SIDEBAR_PREVIEW_SCALE, }) => { const { name, showHint } = data - const scaledWidth = width * (SIDEBAR_PREVIEW_SCALE ?? 1) - const scaledHeight = height * (SIDEBAR_PREVIEW_SCALE ?? 1) + const previewScale = SIDEBAR_PREVIEW_SCALE ?? 1 + const scaledWidth = width * previewScale + const scaledHeight = height * previewScale + const labelHeight = LAYOUT.DEFAULT_ATTRIBUTE_HEIGHT + const scaledLabelHeight = labelHeight * previewScale + const svgHeight = height + labelHeight + const scaledSvgHeight = scaledHeight + scaledLabelHeight const cx = width / 2 const cy = height / 2 @@ -28,8 +33,8 @@ export const SfcTransitionBranchNodeSVG: React.FC = ({ return ( @@ -42,7 +47,12 @@ export const SfcTransitionBranchNodeSVG: React.FC = ({ strokeWidth={LAYOUT.LINE_WIDTH} /> {showHint && ( - + {name} )} diff --git a/library/lib/typings.ts b/library/lib/typings.ts index 24957001..38b399b3 100644 --- a/library/lib/typings.ts +++ b/library/lib/typings.ts @@ -71,6 +71,8 @@ export enum ApollonView { Highlight = "Highlight", } +export type SvgExportMode = "web" | "compat" + export type ApollonOptions = { type?: UMLDiagramType mode?: ApollonMode @@ -110,6 +112,12 @@ export type ExportOptions = { keepOriginalSize?: boolean include?: string[] exclude?: string[] + /** + * Controls how SVG output is post-processed. + * - "web": keep CSS variables for theme-adaptive rendering in browsers + * - "compat": resolve CSS variables + inline attributes for PowerPoint/Inkscape + */ + svgMode?: SvgExportMode } export type SVG = { diff --git a/library/lib/utils/exportUtils.ts b/library/lib/utils/exportUtils.ts index 5ea8ec9b..e1f18a24 100644 --- a/library/lib/utils/exportUtils.ts +++ b/library/lib/utils/exportUtils.ts @@ -16,12 +16,38 @@ const svgFontStyles = ` } ` -export const getSVG = (container: HTMLElement, clip: Rect): string => { +type SvgExportMode = "web" | "compat" + +const buildRootVariableStyles = (): string => { + const lines = Object.entries(CSS_VARIABLE_FALLBACKS).map( + ([apollon2Var, fallback]) => { + const apollonVar = apollon2Var.replace("--apollon2-", "--apollon-") + return ` ${apollon2Var}: var(${apollonVar}, ${fallback});` + } + ) + + // Keep XYFlow edge vars defined so web SVGs render without external CSS. + lines.push( + " --xy-edge-stroke: var(--apollon2-primary-contrast);", + " --xy-edge-stroke-default: var(--apollon2-primary-contrast);", + ` --xy-edge-stroke-width: ${LAYOUT.LINE_WIDTH_EDGE}px;`, + ` --xy-edge-stroke-width-default: ${LAYOUT.LINE_WIDTH_EDGE}px;` + ) + + return `:root {\n${lines.join("\n")}\n}` +} + +export const getSVG = ( + container: HTMLElement, + clip: Rect, + options?: { svgMode?: SvgExportMode } +): string => { const emptySVG = "" const width = clip.width const height = clip.height + const svgMode = options?.svgMode ?? "web" const vp = container.querySelector(".react-flow__viewport") if (!vp) return emptySVG @@ -29,8 +55,10 @@ export const getSVG = (container: HTMLElement, clip: Rect): string => { const SVG_NS = "http://www.w3.org/2000/svg" const mainSVG = document.createElementNS(SVG_NS, "svg") mainSVG.setAttribute("xmlns", "http://www.w3.org/2000/svg") - mainSVG.appendChild(document.createElementNS(SVG_NS, "style")).textContent = - svgFontStyles + const styleEl = document.createElementNS(SVG_NS, "style") + styleEl.textContent = + (svgMode === "web" ? `${buildRootVariableStyles()}\n` : "") + svgFontStyles + mainSVG.appendChild(styleEl) mainSVG.setAttribute("viewBox", `${clip.x} ${clip.y} ${width} ${height}`) mainSVG.setAttribute("width", `${width}`) mainSVG.setAttribute("height", `${height}`) @@ -154,12 +182,14 @@ export const getSVG = (container: HTMLElement, clip: Rect): string => { }) }) - // Process the SVG for compatibility - replaceCSSVariables(mainSVG) - convertStyleToAttributes(mainSVG) - ensureTextFontDefaults(mainSVG) - removeMarkerElements(mainSVG) - replaceTextDecorationWithManualUnderline(mainSVG) + // Process the SVG for compatibility with non-browser renderers + if (svgMode === "compat") { + replaceCSSVariables(mainSVG) + convertStyleToAttributes(mainSVG) + ensureTextFontDefaults(mainSVG) + removeMarkerElements(mainSVG) + replaceTextDecorationWithManualUnderline(mainSVG) + } return mainSVG.outerHTML } @@ -729,11 +759,13 @@ function extractStyles(styleString: string) { const VARIABLE_REGEX = /var\((--[\w-]+)(?:\s*,\s*([^)]+(?:\([^)]*\)[^)]*)*))?\)/g +type CSSVariableMap = Readonly> + /** * Resolve a single CSS variable reference to its final value. * Handles recursive var() resolution and fallback values. */ -function resolveCSSVariable(value: string): string { +function resolveCSSVariable(value: string, cssVarMap?: CSSVariableMap): string { let result = value let prevResult = "" @@ -744,12 +776,13 @@ function resolveCSSVariable(value: string): string { VARIABLE_REGEX, (_match, variableName: string, fallback?: string) => { const trimmedName = variableName.trim() - const resolved = CSS_VARIABLE_FALLBACKS[trimmedName] - - if (resolved) { + const mapped = cssVarMap?.[trimmedName]?.trim() + if (mapped) { // If the resolved value itself contains var(), it will be resolved in the next iteration - return resolved + return mapped } + const resolved = CSS_VARIABLE_FALLBACKS[trimmedName] + if (resolved) return resolved if (fallback) { // Fallback may itself contain var() calls @@ -769,11 +802,15 @@ function resolveCSSVariable(value: string): string { /** * Resolve 'currentColor' keyword by looking up the resolved 'color' attribute. */ -function resolveCurrentColor(element: Element, inheritedColor: string): string { +function resolveCurrentColor( + element: Element, + inheritedColor: string, + cssVarMap?: CSSVariableMap +): string { // Check if element has a color attribute const colorAttr = element.getAttribute("color") if (colorAttr) { - const resolvedColor = resolveCSSVariable(colorAttr) + const resolvedColor = resolveCSSVariable(colorAttr, cssVarMap) // Handle case where color itself might be currentColor (shouldn't happen, but be safe) if (resolvedColor && resolvedColor !== "currentColor") { return resolvedColor @@ -790,18 +827,19 @@ function resolveCurrentColor(element: Element, inheritedColor: string): string { */ function replaceCSSVariables( node: Element | ChildNode, - inheritedColor: string = STROKE_COLOR + inheritedColor: string = STROKE_COLOR, + cssVarMap?: CSSVariableMap ): void { if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element // First, resolve the 'color' attribute if present (for currentColor inheritance) - const currentColor = resolveCurrentColor(element, inheritedColor) + const currentColor = resolveCurrentColor(element, inheritedColor, cssVarMap) // If element has a color attribute, resolve it first const colorAttr = element.getAttribute("color") if (colorAttr) { - const resolvedColor = resolveCSSVariable(colorAttr) + const resolvedColor = resolveCSSVariable(colorAttr, cssVarMap) if (resolvedColor !== colorAttr) { element.setAttribute("color", resolvedColor) } @@ -813,7 +851,7 @@ function replaceCSSVariables( if (!attrValue) return // Resolve CSS variables first - let resolvedValue = resolveCSSVariable(attrValue) + let resolvedValue = resolveCSSVariable(attrValue, cssVarMap) // Resolve 'currentColor' keyword if (resolvedValue === "currentColor") { @@ -853,7 +891,7 @@ function replaceCSSVariables( // Recursively process children, passing down the resolved color Array.from(element.childNodes).forEach((child) => - replaceCSSVariables(child, currentColor) + replaceCSSVariables(child, currentColor, cssVarMap) ) } // Text nodes are not processed — CSS variables only appear in attribute values, diff --git a/standalone/webapp/src/components/navbar/NavbarFile.tsx b/standalone/webapp/src/components/navbar/NavbarFile.tsx index 495346eb..0e0e0cb6 100644 --- a/standalone/webapp/src/components/navbar/NavbarFile.tsx +++ b/standalone/webapp/src/components/navbar/NavbarFile.tsx @@ -21,7 +21,7 @@ interface Props { export const NavbarFile: FC = ({ color, handleCloseNavMenu }) => { const { openModal } = useModalContext() - const exportAsSvg = useExportAsSVG() + const exportAsSvg = useExportAsSVG("compat") const exportAsPng = useExportAsPNG() const exportAsJSON = useExportAsJSON() const exportAsPDF = useExportAsPDF() diff --git a/standalone/webapp/src/hooks/useExportAsSVG.ts b/standalone/webapp/src/hooks/useExportAsSVG.ts index 3c36929d..4788637c 100644 --- a/standalone/webapp/src/hooks/useExportAsSVG.ts +++ b/standalone/webapp/src/hooks/useExportAsSVG.ts @@ -1,8 +1,15 @@ +import type { SvgExportMode } from "@tumaet/apollon" import { useFileDownload } from "./useFileDownload" import { useEditorContext } from "@/contexts" import { log } from "@/logger" -export const useExportAsSVG = () => { +const buildSvgFileName = (title: string, suffix?: string) => + suffix ? `${title}${suffix}.svg` : `${title}.svg` + +export const useExportAsSVG = ( + svgMode: SvgExportMode = "web", + fileNameSuffix?: string +) => { const { editor } = useEditorContext() const downloadFile = useFileDownload() @@ -12,7 +19,7 @@ export const useExportAsSVG = () => { return } - const apollonSVG = await editor.exportAsSVG() + const apollonSVG = await editor.exportAsSVG({ svgMode }) if (!apollonSVG) { log.error("Failed to export SVG") @@ -20,7 +27,7 @@ export const useExportAsSVG = () => { } const diagramTitle = editor?.model.title || "diagram" - const fileName = `${diagramTitle}.svg` + const fileName = buildSvgFileName(diagramTitle, fileNameSuffix) const fileToDownload = new File([apollonSVG.svg], fileName, { type: "image/svg+xml", diff --git a/standalone/webapp/tests/helpers/svgExport.ts b/standalone/webapp/tests/helpers/svgExport.ts index 67d9e176..c2423d6f 100644 --- a/standalone/webapp/tests/helpers/svgExport.ts +++ b/standalone/webapp/tests/helpers/svgExport.ts @@ -3,15 +3,15 @@ import type { Page } from "@playwright/test" /** * Extract the exported SVG string from the running Apollon editor. * - * This replicates the full export pipeline from exportUtils.ts inside - * the browser context via page.evaluate(): + * This replicates the compat export pipeline (svgMode: "compat") + * from exportUtils.ts inside the browser context via page.evaluate(): * 1. Find the React Flow container and compute bounding box * 2. Clone nodes and edges into a fresh SVG * 3. Replace CSS variables with fallback values * 4. Convert inline styles to SVG attributes (PowerPoint compat) * 5. Remove elements and marker-start/marker-end attributes * - * The result is identical to what getSVG() produces. + * The result is identical to getSVG() with svgMode: "compat". */ export async function extractSVGFromPage(page: Page): Promise { return page.evaluate(() => { @@ -40,6 +40,34 @@ export async function extractSVGFromPage(page: Page): Promise { "--apollon2-modal-bottom-border": "#e9ecef", } + function collectCSSVariables( + element: Element | null + ): Record { + if ( + !element || + typeof window === "undefined" || + typeof window.getComputedStyle !== "function" + ) { + return {} + } + + const vars: Record = {} + const style = window.getComputedStyle(element) + for (let i = 0; i < style.length; i += 1) { + const prop = style[i] + if (!prop || !prop.startsWith("--")) continue + const value = style.getPropertyValue(prop).trim() + if (value) vars[prop] = value + } + return vars + } + + const cssVarMap: Record = { + ...CSS_VARIABLE_FALLBACKS, + ...collectCSSVariables(document.documentElement), + ...collectCSSVariables(document.querySelector(".react-flow")), + } + // ---- CSS Variable Resolution ---- const VARIABLE_REGEX = /var\((--[\w-]+)(?:\s*,\s*([^)]+(?:\([^)]*\)[^)]*)*))?\)/g @@ -52,6 +80,8 @@ export async function extractSVGFromPage(page: Page): Promise { result = result.replace( VARIABLE_REGEX, (_match: string, variableName: string, fallback?: string) => { + const mapped = cssVarMap[variableName.trim()]?.trim() + if (mapped) return mapped const resolved = CSS_VARIABLE_FALLBACKS[variableName.trim()] if (resolved) return resolved if (fallback) return fallback.trim() diff --git a/standalone/webapp/tests/visual/svg-export.visual.spec.ts b/standalone/webapp/tests/visual/svg-export.visual.spec.ts index c7eadadd..61bddd1e 100644 --- a/standalone/webapp/tests/visual/svg-export.visual.spec.ts +++ b/standalone/webapp/tests/visual/svg-export.visual.spec.ts @@ -14,14 +14,14 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) /** - * SVG Export Visual Regression Tests + * Compat SVG Export Visual Regression Tests * - * Validates that SVG exports from the Apollon2 editor: + * Validates that compat SVG exports from the Apollon2 editor: * 1. Contain no CSS variables (var()), currentColor, or elements * 2. Render correctly in a non-browser SVG renderer (resvg — Rust-based) * 3. Produce pixel-consistent output across runs * - * These tests prove that exported SVGs work in PowerPoint, Keynote, Inkscape, + * These tests prove that compat SVGs work in PowerPoint, Keynote, Inkscape, * and any other application that doesn't support browser-specific CSS features. */ @@ -167,7 +167,7 @@ function assertNoCSSVariables(svg: string, diagramName: string) { test.describe("SVG export - diagram fixtures", () => { for (const { name, file, fixture, fitView } of diagramFixtures) { - test(`${name} SVG export has no CSS variables and renders in resvg`, async ({ + test(`${name} compat SVG export has no CSS variables and renders in resvg`, async ({ page, }) => { await injectFixtureIntoLocalStorage(page, fixture) @@ -209,7 +209,7 @@ test.describe("SVG export - diagram fixtures", () => { test.describe("SVG export - template diagrams", () => { for (const { name, file } of templateDiagrams) { - test(`${name} template SVG export has no CSS variables and renders in resvg`, async ({ + test(`${name} template compat SVG export has no CSS variables and renders in resvg`, async ({ page, }) => { const template = loadTemplate(`${file}.json`)