Skip to content

Commit d5429ee

Browse files
authored
feat: button to copy preview SVG (#84)
In this PR, we add a new preview button that allows users to copy the preview SVG to their clipboard. This is especially useful to continue working in external programs like [Cavalry](https://cavalry.studio/). Hold shift while clicking on the button to copy the SVG with inverted colors.
1 parent 4373d2d commit d5429ee

13 files changed

Lines changed: 659 additions & 124 deletions

File tree

‎playwright.config.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export default defineConfig({
2626
use: {
2727
/* Base URL to use in actions like `await page.goto('')`. */
2828
baseURL: testBaseUrl,
29+
/* The preview copy button writes to the clipboard. */
30+
permissions: ["clipboard-read", "clipboard-write"],
2931
},
3032

3133
/* Configure projects for major browsers */

‎tests/_support/browser-mocks/typst-state-module.d.ts‎

Lines changed: 0 additions & 24 deletions
This file was deleted.

‎tests/_support/browser-mocks/typst-state.ts‎

Lines changed: 0 additions & 37 deletions
This file was deleted.
Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,45 @@
1-
import { typstMockState } from "/pptypst/__test__/typst-state.js";
1+
/**
2+
* Browser mock for `@myriaddreamin/typst.ts`.
3+
*
4+
* `TypstMock` (see ../typst-mock.ts) serves this in place of the real
5+
* WASM-backed library. Recorded compiler/renderer calls and the SVG the
6+
* renderer returns live on `window.__typstMock` so the Node-side helper can
7+
* read and tweak them via `page.evaluate`.
8+
*/
9+
10+
export type TypstMockState = {
11+
rendererInitOptions: { hasGetModule: boolean }[];
12+
addSourceCalls: { path: string; source: string }[];
13+
compileCalls: { mainFilePath: string }[];
14+
renderSvgCalls: {
15+
format: string;
16+
artifactContent: number[];
17+
data_selection: Record<string, boolean>;
18+
}[];
19+
previewSvg: string;
20+
};
21+
22+
declare global {
23+
interface Window {
24+
__typstMock?: TypstMockState;
25+
}
26+
}
27+
28+
const DEFAULT_PREVIEW_SVG = [
29+
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="40">',
30+
'<text x="0" y="20" fill="#000">integral preview</text>',
31+
"</svg>",
32+
].join("");
33+
34+
const state: TypstMockState = {
35+
rendererInitOptions: [],
36+
addSourceCalls: [],
37+
compileCalls: [],
38+
renderSvgCalls: [],
39+
previewSvg: DEFAULT_PREVIEW_SVG,
40+
};
41+
42+
window.__typstMock = state;
243

344
type CompilerInitOptions = { beforeBuild: unknown[]; getModule: unknown };
445
type CompileOptions = { mainFilePath: string };
@@ -8,12 +49,6 @@ type RenderSvgOptions = {
849
data_selection: Record<string, boolean>;
950
};
1051

11-
const previewSvg = [
12-
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="40">',
13-
'<text x="0" y="20" fill="#000">integral preview</text>',
14-
"</svg>",
15-
].join("");
16-
1752
export function createTypstCompiler() {
1853
return {
1954
init(options: CompilerInitOptions) {
@@ -25,10 +60,10 @@ export function createTypstCompiler() {
2560
return Promise.resolve();
2661
},
2762
addSource(path: string, source: string) {
28-
typstMockState.addSourceCalls.push({ path, source });
63+
state.addSourceCalls.push({ path, source });
2964
},
3065
compile(options: CompileOptions) {
31-
typstMockState.compileCalls.push(options);
66+
state.compileCalls.push(options);
3267
return Promise.resolve({ diagnostics: [], result: new Uint8Array([1, 2, 3]) });
3368
},
3469
};
@@ -37,16 +72,16 @@ export function createTypstCompiler() {
3772
export function createTypstRenderer() {
3873
return {
3974
init(options: { getModule: unknown }) {
40-
typstMockState.rendererInitOptions.push({ hasGetModule: typeof options.getModule === "function" });
75+
state.rendererInitOptions.push({ hasGetModule: typeof options.getModule === "function" });
4176
return Promise.resolve();
4277
},
4378
renderSvg(options: RenderSvgOptions) {
44-
typstMockState.renderSvgCalls.push({
79+
state.renderSvgCalls.push({
4580
format: options.format,
4681
artifactContent: Array.from(options.artifactContent),
4782
data_selection: options.data_selection,
4883
});
49-
return Promise.resolve(previewSvg);
84+
return Promise.resolve(state.previewSvg);
5085
},
5186
};
5287
}

‎tests/_support/typst-mock.ts‎

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
11
import type { Page } from "@playwright/test";
22
import path from "node:path";
33
import { compileBrowserMock } from "./transpile-browser-mock";
4+
import type { TypstMockState } from "./browser-mocks/typst";
45

5-
export type TypstMockCalls = {
6-
addSourceCalls: { path: string; source: string }[];
7-
compileCalls: { mainFilePath: string }[];
8-
renderSvgCalls: {
9-
format: string;
10-
artifactContent: number[];
11-
data_selection: Record<string, boolean>;
12-
}[];
13-
};
14-
15-
const stateModuleUrl = "/pptypst/__test__/typst-state.js";
6+
export type TypstMockCalls = Pick<
7+
TypstMockState,
8+
"addSourceCalls" | "compileCalls" | "renderSvgCalls"
9+
>;
1610

1711
function browserMockPath(fileName: string) {
1812
return path.join(process.cwd(), "tests", "_support", "browser-mocks", fileName);
@@ -28,7 +22,6 @@ export class TypstMock {
2822

2923
/** Routes only the Typst modules that web/src/typst.ts and font-cache.ts import. */
3024
async install() {
31-
await this.routeModule("**/__test__/typst-state.js", "typst-state.ts");
3225
await this.routeModule("**/@myriaddreamin_typst__ts.js*", "typst.ts");
3326
await this.routeModule("**/@myriaddreamin_typst__ts_dist_esm_options__init__mjs.js*", "typst-options.ts");
3427
await this.routeModule("**/@myriaddreamin_typst__ts_dist_esm_fs_package__node__mjs.js*", "typst-package-registry.ts");
@@ -37,24 +30,34 @@ export class TypstMock {
3730
await this.routeModule("**/typst_ts_renderer_bg.wasm?*", "typst-wasm-url.ts");
3831
}
3932

40-
/** Waits for the mocked renderer init call, which means the Typst wrapper initialized. */
33+
/** Resolves once the app has initialized the mocked renderer. */
4134
async waitUntilReady() {
42-
await this.page.waitForFunction(async (moduleUrl) => {
43-
const stateModule = await import(moduleUrl) as {
44-
typstMockReady: () => boolean;
45-
};
46-
return stateModule.typstMockReady();
47-
}, stateModuleUrl);
35+
await this.page.waitForFunction(
36+
() => window.__typstMock?.rendererInitOptions.length === 1,
37+
);
4838
}
4939

50-
/** Returns the Typst compiler and renderer calls recorded in the browser. */
40+
/** Snapshot of the compiler/renderer calls recorded so far. */
5141
async calls(): Promise<TypstMockCalls> {
52-
return this.page.evaluate(async (moduleUrl) => {
53-
const stateModule = await import(moduleUrl) as {
54-
typstMockCalls: () => TypstMockCalls;
55-
};
56-
return stateModule.typstMockCalls();
57-
}, stateModuleUrl);
42+
return this.page.evaluate(() => {
43+
const state = window.__typstMock;
44+
if (!state) {
45+
throw new Error("Typst mock has not been initialized yet.");
46+
}
47+
const { addSourceCalls, compileCalls, renderSvgCalls } = state;
48+
return { addSourceCalls, compileCalls, renderSvgCalls };
49+
});
50+
}
51+
52+
/** Overrides the SVG the mocked renderer returns for subsequent preview renders. */
53+
async setPreviewSvg(svg: string) {
54+
await this.page.evaluate((value) => {
55+
const state = window.__typstMock;
56+
if (!state) {
57+
throw new Error("Typst mock has not been initialized yet.");
58+
}
59+
state.previewSvg = value;
60+
}, svg);
5861
}
5962

6063
private async routeModule(url: string, fileName: string) {

‎tests/pages/powerpoint-page.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export type OfficeSnapshot = {
4949

5050
type OfficeMockWindow = Window & typeof globalThis & {
5151
__pptypstOfficeSeed?: OfficeMockSeed;
52+
__pptypstClipboardWriteTypes?: string[];
5253
__pptypstOfficeMock: {
5354
reset: (_seed?: OfficeMockSeed) => void;
5455
selectShapes: (_slideId: string, _shapeIds: string[]) => Promise<void>;
@@ -123,6 +124,10 @@ export class PowerPointPage {
123124
await this.page.locator("#fillColor").fill(fillColor);
124125
}
125126

127+
async setPreviewTypstFillEnabled(enabled: boolean) {
128+
await this.page.locator("#previewFillEnabled").setChecked(enabled);
129+
}
130+
126131
async insertOrUpdate() {
127132
await this.page.locator("#insertBtn").click();
128133
}
@@ -131,6 +136,39 @@ export class PowerPointPage {
131136
await this.page.locator("#bulkUpdateBtn").click();
132137
}
133138

139+
async copyPreviewSvg(options: { invertColors?: boolean } = {}) {
140+
await this.page.locator("#previewCopyBtn").click({
141+
modifiers: options.invertColors ? ["Shift"] : [],
142+
});
143+
}
144+
145+
async readClipboardText(): Promise<string> {
146+
return this.page.evaluate(() => navigator.clipboard.readText());
147+
}
148+
149+
async recordClipboardWrites() {
150+
await this.page.evaluate(() => {
151+
const appWindow = window as OfficeMockWindow;
152+
const originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard);
153+
Object.defineProperty(navigator.clipboard, "write", {
154+
configurable: true,
155+
value: async (items: ClipboardItem[]) => {
156+
appWindow.__pptypstClipboardWriteTypes = items.flatMap(item => item.types);
157+
const firstItem = items[0];
158+
const textBlob = await firstItem.getType("text/plain");
159+
await originalWriteText(await textBlob.text());
160+
},
161+
});
162+
});
163+
}
164+
165+
async clipboardWriteTypes(): Promise<string[]> {
166+
return this.page.evaluate(() => {
167+
const appWindow = window as OfficeMockWindow;
168+
return appWindow.__pptypstClipboardWriteTypes || [];
169+
});
170+
}
171+
134172
async selectShapes(slideId: string, shapeIds: string[]) {
135173
await this.page.evaluate(
136174
async ({ selectedSlideId, selectedShapeIds }) => {

‎tests/preview.spec.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,60 @@ test("previews Typst math expressions", async ({ powerPointPage, typstMock }) =>
3131
},
3232
]);
3333
});
34+
35+
test("copies the preview SVG with optional inverted colors", async ({ powerPointPage }) => {
36+
await powerPointPage.previewExpression("integral_a^b f(x) dif x");
37+
await powerPointPage.expectPreviewVisible();
38+
39+
await powerPointPage.recordClipboardWrites();
40+
await powerPointPage.copyPreviewSvg();
41+
await expect.poll(() => powerPointPage.readClipboardText()).toContain('fill="#000000"');
42+
const copiedSvg = await powerPointPage.readClipboardText();
43+
await expect.poll(() => powerPointPage.clipboardWriteTypes()).toEqual([
44+
"image/svg+xml",
45+
"text/plain",
46+
]);
47+
expect(copiedSvg).toContain("<svg");
48+
expect(copiedSvg).toContain("integral preview");
49+
expect(copiedSvg).toContain('fill="#000000"');
50+
expect(copiedSvg).not.toContain('style="width: 100%');
51+
52+
await powerPointPage.copyPreviewSvg({ invertColors: true });
53+
await expect.poll(() => powerPointPage.readClipboardText()).toContain('fill="#ffffff"');
54+
const invertedSvg = await powerPointPage.readClipboardText();
55+
expect(invertedSvg).toContain("integral preview");
56+
expect(invertedSvg).toContain('fill="#ffffff"');
57+
expect(invertedSvg).not.toContain('fill="#000000"');
58+
});
59+
60+
test("copies preview SVGs with alpha fills normalized for compatibility",
61+
async ({ powerPointPage, typstMock }) => {
62+
await typstMock.setPreviewSvg([
63+
'<svg xmlns="http://www.w3.org/2000/svg" width="196.39001" height="93.14115">',
64+
'<path fill="#ff000032" stroke="#000" d="M 0 0 L 10 0 L 10 10 Z"/>',
65+
'<path fill="#0000ff32" stroke="#000" d="M 20 0 L 30 0 L 30 10 Z"/>',
66+
'<path style="fill: #00ff0080; stroke: #00000080" d="M 40 0 L 50 0 L 50 10 Z"/>',
67+
"</svg>",
68+
].join(""));
69+
70+
await powerPointPage.setFillColor(null);
71+
await powerPointPage.setPreviewTypstFillEnabled(true);
72+
await powerPointPage.previewExpression("compatibility check");
73+
await powerPointPage.expectPreviewVisible();
74+
75+
await powerPointPage.copyPreviewSvg();
76+
await expect.poll(() => powerPointPage.readClipboardText())
77+
.toContain('fill-opacity="0.19607843137254902"');
78+
79+
const copiedSvg = await powerPointPage.readClipboardText();
80+
expect(copiedSvg).toContain('fill="#ff0000"');
81+
expect(copiedSvg).toContain('fill="#0000ff"');
82+
expect(copiedSvg).toContain('fill-opacity="0.19607843137254902"');
83+
expect(copiedSvg).toContain("fill: rgb(0, 255, 0);");
84+
expect(copiedSvg).toContain("fill-opacity: 0.5;");
85+
expect(copiedSvg).toContain("stroke-opacity: 0.5;");
86+
expect(copiedSvg).not.toContain("#ff000032");
87+
expect(copiedSvg).not.toContain("#0000ff32");
88+
expect(copiedSvg).not.toContain("#00ff0080");
89+
expect(copiedSvg).not.toContain("#00000080");
90+
});

0 commit comments

Comments
 (0)