Skip to content

Commit 12bef7b

Browse files
authored
Merge pull request #40 from zazuko/export-design
Improve design of exports
2 parents dc551ec + c0c2224 commit 12bef7b

4 files changed

Lines changed: 156 additions & 10 deletions

File tree

.changeset/dry-corners-dream.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"graph-explorer": patch
3+
---
4+
5+
Improve design of exported content

src/graph-explorer/viewUtils/toSvg.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,14 @@ function exportSVG(options: ToSVGOptions): Promise<SVGElement> {
104104
return convertingImages.then(() => {
105105
// workaround to include only graph-explorer-related stylesheets
106106
const exportedCssText = extractCSSFromDocument(svgClone);
107+
// design tokens have to be re-declared on the detached export root,
108+
// otherwise the `var(--…)` references in the rules above resolve to nothing
109+
const exportedVariables = extractCSSVariables(
110+
options.paper.closest(".graph-explorer")
111+
);
107112

108113
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
109-
defs.innerHTML = `<style>${exportedCssText}</style>`;
114+
defs.innerHTML = `<style>${exportedVariables}\n${exportedCssText}</style>`;
110115
svgClone.insertBefore(defs, svgClone.firstChild);
111116

112117
if (options.elementsToRemoveSelector) {
@@ -176,8 +181,7 @@ function _clearAttributes(svg: SVGElement) {
176181
}
177182
}
178183

179-
function extractCSSFromDocument(targetSubtree: Element): string {
180-
const exportedRules = new Set<CSSStyleRule>();
184+
function forEachStyleRule(callback: (rule: CSSStyleRule) => void) {
181185
for (let i = 0; i < document.styleSheets.length; i++) {
182186
let rules: CSSRuleList;
183187
try {
@@ -187,18 +191,68 @@ function extractCSSFromDocument(targetSubtree: Element): string {
187191
continue;
188192
}
189193
} catch (_e) {
194+
// cross-origin stylesheets cannot be inspected
190195
continue;
191196
}
192197

193198
for (let j = 0; j < rules.length; j++) {
194199
const rule = rules[j];
195200
if (rule instanceof CSSStyleRule) {
196-
if (targetSubtree.querySelector(rule.selectorText)) {
197-
exportedRules.add(rule);
198-
}
201+
callback(rule);
199202
}
200203
}
201204
}
205+
}
206+
207+
/**
208+
* The exported SVG is detached from the workspace, so CSS custom properties
209+
* (the design tokens) declared on the workspace root are not in scope and every
210+
* `var(--…)` reference in the exported rules would resolve to nothing, dropping
211+
* colours, radii and shadows. Re-declare the resolved values on the exported
212+
* root so the export matches what is on the canvas.
213+
*/
214+
function extractCSSVariables(source: Element | null | undefined): string {
215+
if (!source) {
216+
return "";
217+
}
218+
const computed = getComputedStyle(source);
219+
const declarations = new Map<string, string>();
220+
221+
forEachStyleRule((rule) => {
222+
for (let i = 0; i < rule.style.length; i++) {
223+
const property = rule.style.item(i);
224+
if (!property.startsWith("--") || declarations.has(property)) {
225+
continue;
226+
}
227+
const value = computed.getPropertyValue(property).trim();
228+
if (value) {
229+
declarations.set(property, value);
230+
}
231+
}
232+
});
233+
234+
if (declarations.size === 0) {
235+
return "";
236+
}
237+
const variables = Array.from(
238+
declarations,
239+
([name, value]) => `${name}: ${value};`
240+
).join(" ");
241+
return `svg { ${variables} }`;
242+
}
243+
244+
function extractCSSFromDocument(targetSubtree: Element): string {
245+
const exportedRules = new Set<CSSStyleRule>();
246+
forEachStyleRule((rule) => {
247+
try {
248+
if (targetSubtree.querySelector(rule.selectorText)) {
249+
exportedRules.add(rule);
250+
}
251+
} catch (_e) {
252+
// selectors that are not valid for querySelector (e.g. pseudo-elements)
253+
// must not abort the whole export
254+
}
255+
});
202256

203257
const exportedCssTexts: string[] = [];
204258
exportedRules.forEach((rule) => exportedCssTexts.push(rule.cssText));

styles/diagram/_elementLayer.scss

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,24 @@
55

66
.graph-explorer-overlayed-element,
77
.graph-explorer-exported-element {
8-
// set defaults for all inherited properties
8+
// Set defaults for all inherited properties. These also apply to the exported
9+
// SVG/PNG, which is detached from the workspace root, so the design tokens are
10+
// spelled out with fallbacks to keep exports looking like the canvas.
911
box-sizing: border-box;
10-
color: black;
11-
font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
12+
color: var(--ge-text, #0f172a);
13+
font-family: var(
14+
--ge-font-sans,
15+
"Inter",
16+
system-ui,
17+
-apple-system,
18+
"Segoe UI",
19+
Roboto,
20+
"Helvetica Neue",
21+
Arial,
22+
sans-serif
23+
);
1224
font-size: 14px;
13-
line-height: 1.42857143;
25+
line-height: 1.5;
1426

1527
// http://stackoverflow.com/questions/6664460/line-height-affects-images
1628
img { vertical-align: middle; }

tests/e2e/export.spec.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { readFileSync } from "node:fs";
2+
3+
import { expect, test } from "@playwright/test";
4+
5+
import { createElementOnCanvas, openLocalDemo } from "./helpers";
6+
7+
/** Clicks a toolbar export button and returns the downloaded file's contents. */
8+
async function download(
9+
page: import("@playwright/test").Page,
10+
title: string
11+
): Promise<Buffer> {
12+
const [downloaded] = await Promise.all([
13+
page.waitForEvent("download", { timeout: 30_000 }),
14+
page.getByTitle(title).click(),
15+
]);
16+
return readFileSync(await downloaded.path());
17+
}
18+
19+
test.describe("diagram export", () => {
20+
test.beforeEach(async ({ page }) => {
21+
await openLocalDemo(page);
22+
await createElementOnCanvas(page);
23+
});
24+
25+
/**
26+
* Regression guard: the exported SVG is detached from the workspace root, so
27+
* the design tokens declared there are out of scope. If they are not
28+
* re-declared on the export, every `var(--…)` resolves to nothing and the
29+
* diagram loses its background, corner radius, shadow and text colour.
30+
*/
31+
test("SVG export declares every design token it references", async ({
32+
page,
33+
}) => {
34+
const svg = (await download(page, "Export diagram as SVG")).toString(
35+
"utf8"
36+
);
37+
38+
const referenced = new Set(
39+
(svg.match(/var\(\s*(--[\w-]+)/g) ?? []).map((match) =>
40+
match.replace(/var\(\s*/, "")
41+
)
42+
);
43+
const declared = new Set(svg.match(/--[\w-]+(?=\s*:)/g) ?? []);
44+
45+
// the templates do use tokens - otherwise this test proves nothing
46+
expect(referenced.size).toBeGreaterThan(0);
47+
48+
const undeclared = [...referenced].filter((name) => !declared.has(name));
49+
expect(undeclared, "tokens referenced but never declared").toEqual([]);
50+
});
51+
52+
test("SVG export contains the diagram content and its styles", async ({
53+
page,
54+
}) => {
55+
const svg = (await download(page, "Export diagram as SVG")).toString(
56+
"utf8"
57+
);
58+
59+
expect(svg).toContain("<svg");
60+
expect(svg).toContain("<style>");
61+
// the element is exported as foreignObject content
62+
expect(svg).toContain("graph-explorer-exported-element");
63+
expect(svg).toContain("lemma");
64+
});
65+
66+
test("PNG export produces a real image", async ({ page }) => {
67+
const png = await download(page, "Export diagram as PNG");
68+
69+
// PNG magic number
70+
expect([...png.subarray(0, 8)]).toEqual([
71+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
72+
]);
73+
expect(png.byteLength).toBeGreaterThan(2000);
74+
});
75+
});

0 commit comments

Comments
 (0)