From 19a1b514e2cced63b64e8f0e541d3b51eb6fc924 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:27:06 +0000 Subject: [PATCH 01/17] Initial plan From 1de0aaad4c484c3aaf3b0dacd629e1009e75a814 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 17:19:19 +0000 Subject: [PATCH 02/17] Add typst source export Co-authored-by: Mr-Python-in-China <89737170+Mr-Python-in-China@users.noreply.github.com> --- .gitignore | 2 + package-lock.json | 95 ++++++++++++- package.json | 1 + prettier.config.ts => prettier.config.js | 0 src/router/editor/header.tsx | 16 +++ src/utils/contestDataUtils.ts | 168 +++++++++++++++++++++++ 6 files changed, 281 insertions(+), 1 deletion(-) rename prettier.config.ts => prettier.config.js (100%) diff --git a/.gitignore b/.gitignore index a1cf93d..848d75a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ lerna-debug.log* node_modules dist +node_modules +.vite dist-ssr *.local diff --git a/package-lock.json b/package-lock.json index 0b47b90..de4e82f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "browser-update": "^3.3.63", "dayjs": "^1.11.19", "dexie": "^4.2.1", + "jszip": "^3.10.1", "lodash-es": "^4.17.22", "react": "^19.2.3", "react-dom": "^19.2.3", @@ -6243,6 +6244,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -7238,6 +7245,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immer": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", @@ -7276,6 +7289,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -7361,6 +7380,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -7495,6 +7520,24 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/katex": { "version": "0.16.25", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.25.tgz", @@ -7535,6 +7578,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -9623,6 +9675,12 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -9709,6 +9767,21 @@ } } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/rehype-stringify": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", @@ -9899,6 +9972,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -9942,6 +10021,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -10071,6 +10156,15 @@ "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-convert": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", @@ -10500,7 +10594,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/vfile": { diff --git a/package.json b/package.json index 50be885..9c780fa 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "browser-update": "^3.3.63", "dayjs": "^1.11.19", "dexie": "^4.2.1", + "jszip": "^3.10.1", "lodash-es": "^4.17.22", "react": "^19.2.3", "react-dom": "^19.2.3", diff --git a/prettier.config.ts b/prettier.config.js similarity index 100% rename from prettier.config.ts rename to prettier.config.js diff --git a/src/router/editor/header.tsx b/src/router/editor/header.tsx index 7c8684f..d69f92d 100644 --- a/src/router/editor/header.tsx +++ b/src/router/editor/header.tsx @@ -15,6 +15,7 @@ import useTemplateManager from "@/components/templateManagerContext"; import useTypstInitStatus from "@/components/typstInitStatusContext"; import { exportDocument, + exportTypstArchive, importDocument, toImmerContent, } from "@/utils/contestDataUtils"; @@ -110,6 +111,15 @@ const ContestEditorHeader: FC<{ console.error("Error when exporting config.", error); } }, [message, doc]); + const onClickExportTypst = useCallback(async () => { + try { + await exportTypstArchive(doc); + message.success("导出 Typst 源码成功"); + } catch (error) { + message.error("导出 Typst 源码失败"); + console.error("Error when exporting typst archive.", error); + } + }, [doc, message]); const versionInfo = useVersionInfo(); const menuGroup = useMemo( (): MenuGroup[] => [ @@ -138,6 +148,11 @@ const ContestEditorHeader: FC<{ label: "备份文档", onSelect: onClickExportConfig, }, + { + key: "export typst", + label: "导出为 typst 源码", + onSelect: onClickExportTypst, + }, { key: "import document", label: "导入文档", @@ -161,6 +176,7 @@ const ContestEditorHeader: FC<{ navigate, onClickExportConfig, onClickExportPDF, + onClickExportTypst, onClickImportConfig, typstInitStatus, versionInfo.show, diff --git a/src/utils/contestDataUtils.ts b/src/utils/contestDataUtils.ts index 8fda0c6..bb66a76 100644 --- a/src/utils/contestDataUtils.ts +++ b/src/utils/contestDataUtils.ts @@ -6,6 +6,174 @@ import type { ImmerContent, ImmerDocument, } from "@/types/document"; +import JSZip from "jszip"; +import getProcessor from "@/compiler/getProcessor"; +import base64js from "base64-js"; +import { + importContentZod, + importFontUrlEnteries, + importTypstContents, + importUnifiedPlugins, +} from "@/utils/importTemplate"; + +function removeImages(content: ContentBase): ContentBase { + return { + ...content, + images: [], + }; +} + +type InlineAsset = { + filename: string; + assetUrl: string; +}; + +async function compileContentToTypst(content: ContentBase, templateId: string) { + const processor = getProcessor(await importUnifiedPlugins(templateId)); + const problems = await Promise.all( + content.problems.map(async (problem) => { + const processed = processor.processSync(problem.markdown); + return { + typst: processed.toString(), + assets: (processed.data.assets ?? []) as InlineAsset[], + }; + }), + ); + const extraContents = await Promise.all( + Object.entries(content.extraContents).map(async ([key, value]) => { + const processed = processor.processSync(value.markdown); + return [ + key, + { + typst: processed.toString(), + assets: (processed.data.assets ?? []) as InlineAsset[], + }, + ] as const; + }), + ); + return { + problems, + extraContents: Object.fromEntries(extraContents), + }; +} + +async function collectAssets( + typstResult: Awaited>, +) { + const assets = new Map(); + const addAsset = (filename: string, assetUrl: string) => { + if (!assets.has(filename)) assets.set(filename, assetUrl); + }; + // Inline image assets already resolved from markdown (data.assets). + for (const problem of typstResult.problems) { + for (const asset of problem.assets) + addAsset(asset.filename, asset.assetUrl); + } + for (const extra of Object.values(typstResult.extraContents)) { + for (const asset of extra.assets) addAsset(asset.filename, asset.assetUrl); + } + return assets; +} + +async function resolveAssetData(assetUrl: string): Promise { + if (assetUrl.startsWith("data:")) return dataUrlToUint8Array(assetUrl); + const response = await fetch(assetUrl); + return new Uint8Array(await response.arrayBuffer()); +} + +function resolveAssetExtension(assetUrl: string): string { + if (assetUrl.startsWith("data:")) return dataUrlToExtension(assetUrl); + const normalized = assetUrl.split("?")[0]; + const match = /\.([a-zA-Z0-9]+)$/.exec(normalized); + return match?.[1] ?? "bin"; +} + +function dataUrlToUint8Array(dataUrl: string): Uint8Array { + const match = /^data:(?[^;]+);base64,(?.+)$/u.exec(dataUrl); + if (!match?.groups?.data) throw new Error("Invalid data URL"); + return Uint8Array.from(base64js.toByteArray(match.groups.data)); +} + +function dataUrlToExtension(dataUrl: string): string { + const match = /^data:(?[^;]+);base64,/u.exec(dataUrl); + const type = match?.groups?.type ?? "application/octet-stream"; + const map: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/svg+xml": "svg", + "image/webp": "webp", + }; + return map[type] ?? "bin"; +} + +function replaceAssetReferences(typst: string, assets: Map) { + let result = typst; + for (const [filename, assetUrl] of assets) { + const ext = resolveAssetExtension(assetUrl); + const filePath = `assets/${filename}.${ext}`; + result = result.split(`image("${filename}"`).join(`image("${filePath}"`); + } + return result; +} + +async function buildTypstArchive(doc: DocumentBase) { + const zip = new JSZip(); + const typstContents = await importTypstContents(doc.templateId); + const contentZod = await importContentZod(doc.templateId); + const contentForJson = contentZod.parse(removeImages(doc.content)); + const typstResult = await compileContentToTypst(doc.content, doc.templateId); + const assets = await collectAssets(typstResult); + + const contentJson = JSON.stringify(contentForJson, null, 2); + zip.file("content.json", contentJson); + const configJson = await import("@/utils/jsonDocument").then((mod) => + mod.documentToJson(doc), + ); + zip.file("config.json", configJson); + + for (const [filename, content] of typstContents) { + zip.file(filename, content); + } + + for (const [filename, assetUrl] of assets) { + const ext = resolveAssetExtension(assetUrl); + const buffer = await resolveAssetData(assetUrl); + zip.file(`assets/${filename}.${ext}`, buffer); + } + + const extraEntries = Object.entries(typstResult.extraContents); + for (const [key, extra] of extraEntries) { + const typst = replaceAssetReferences(extra.typst, assets); + zip.file(`extra-${key}.typ`, typst); + } + + typstResult.problems.forEach((problem, index) => { + const typst = replaceAssetReferences(problem.typst, assets); + zip.file(`problem-${index}.typ`, typst); + }); + + const fonts = await importFontUrlEnteries(doc.templateId); + for (const [, fontUrl] of fonts) { + if (!fontUrl.startsWith("/")) continue; + const res = await fetch(fontUrl); + const buffer = new Uint8Array(await res.arrayBuffer()); + zip.file(fontUrl.slice(1), buffer); + } + + return zip; +} + +export async function exportTypstArchive(doc: DocumentBase) { + const zip = await buildTypstArchive(doc); + const blob = await zip.generateAsync({ type: "blob" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${doc.name}-${Date.now()}.zip`; + a.click(); + URL.revokeObjectURL(url); +} export function removeProblemCallback( modal: ModalHookAPI, From 7fdc52470ca7c8c633e73fc0be29e97730c43a9b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 01:45:14 +0000 Subject: [PATCH 03/17] Refactor typst export helper Co-authored-by: Mr-Python-in-China <89737170+Mr-Python-in-China@users.noreply.github.com> --- src/router/editor/header.tsx | 2 +- src/utils/contestDataUtils.ts | 168 ------------------------------- src/utils/exportTypstArchive.ts | 169 ++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 169 deletions(-) create mode 100644 src/utils/exportTypstArchive.ts diff --git a/src/router/editor/header.tsx b/src/router/editor/header.tsx index d69f92d..0900974 100644 --- a/src/router/editor/header.tsx +++ b/src/router/editor/header.tsx @@ -15,10 +15,10 @@ import useTemplateManager from "@/components/templateManagerContext"; import useTypstInitStatus from "@/components/typstInitStatusContext"; import { exportDocument, - exportTypstArchive, importDocument, toImmerContent, } from "@/utils/contestDataUtils"; +import { exportTypstArchive } from "@/utils/exportTypstArchive"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faAngleLeft } from "@fortawesome/free-solid-svg-icons"; import { useNavigate } from "react-router"; diff --git a/src/utils/contestDataUtils.ts b/src/utils/contestDataUtils.ts index bb66a76..8fda0c6 100644 --- a/src/utils/contestDataUtils.ts +++ b/src/utils/contestDataUtils.ts @@ -6,174 +6,6 @@ import type { ImmerContent, ImmerDocument, } from "@/types/document"; -import JSZip from "jszip"; -import getProcessor from "@/compiler/getProcessor"; -import base64js from "base64-js"; -import { - importContentZod, - importFontUrlEnteries, - importTypstContents, - importUnifiedPlugins, -} from "@/utils/importTemplate"; - -function removeImages(content: ContentBase): ContentBase { - return { - ...content, - images: [], - }; -} - -type InlineAsset = { - filename: string; - assetUrl: string; -}; - -async function compileContentToTypst(content: ContentBase, templateId: string) { - const processor = getProcessor(await importUnifiedPlugins(templateId)); - const problems = await Promise.all( - content.problems.map(async (problem) => { - const processed = processor.processSync(problem.markdown); - return { - typst: processed.toString(), - assets: (processed.data.assets ?? []) as InlineAsset[], - }; - }), - ); - const extraContents = await Promise.all( - Object.entries(content.extraContents).map(async ([key, value]) => { - const processed = processor.processSync(value.markdown); - return [ - key, - { - typst: processed.toString(), - assets: (processed.data.assets ?? []) as InlineAsset[], - }, - ] as const; - }), - ); - return { - problems, - extraContents: Object.fromEntries(extraContents), - }; -} - -async function collectAssets( - typstResult: Awaited>, -) { - const assets = new Map(); - const addAsset = (filename: string, assetUrl: string) => { - if (!assets.has(filename)) assets.set(filename, assetUrl); - }; - // Inline image assets already resolved from markdown (data.assets). - for (const problem of typstResult.problems) { - for (const asset of problem.assets) - addAsset(asset.filename, asset.assetUrl); - } - for (const extra of Object.values(typstResult.extraContents)) { - for (const asset of extra.assets) addAsset(asset.filename, asset.assetUrl); - } - return assets; -} - -async function resolveAssetData(assetUrl: string): Promise { - if (assetUrl.startsWith("data:")) return dataUrlToUint8Array(assetUrl); - const response = await fetch(assetUrl); - return new Uint8Array(await response.arrayBuffer()); -} - -function resolveAssetExtension(assetUrl: string): string { - if (assetUrl.startsWith("data:")) return dataUrlToExtension(assetUrl); - const normalized = assetUrl.split("?")[0]; - const match = /\.([a-zA-Z0-9]+)$/.exec(normalized); - return match?.[1] ?? "bin"; -} - -function dataUrlToUint8Array(dataUrl: string): Uint8Array { - const match = /^data:(?[^;]+);base64,(?.+)$/u.exec(dataUrl); - if (!match?.groups?.data) throw new Error("Invalid data URL"); - return Uint8Array.from(base64js.toByteArray(match.groups.data)); -} - -function dataUrlToExtension(dataUrl: string): string { - const match = /^data:(?[^;]+);base64,/u.exec(dataUrl); - const type = match?.groups?.type ?? "application/octet-stream"; - const map: Record = { - "image/png": "png", - "image/jpeg": "jpg", - "image/jpg": "jpg", - "image/svg+xml": "svg", - "image/webp": "webp", - }; - return map[type] ?? "bin"; -} - -function replaceAssetReferences(typst: string, assets: Map) { - let result = typst; - for (const [filename, assetUrl] of assets) { - const ext = resolveAssetExtension(assetUrl); - const filePath = `assets/${filename}.${ext}`; - result = result.split(`image("${filename}"`).join(`image("${filePath}"`); - } - return result; -} - -async function buildTypstArchive(doc: DocumentBase) { - const zip = new JSZip(); - const typstContents = await importTypstContents(doc.templateId); - const contentZod = await importContentZod(doc.templateId); - const contentForJson = contentZod.parse(removeImages(doc.content)); - const typstResult = await compileContentToTypst(doc.content, doc.templateId); - const assets = await collectAssets(typstResult); - - const contentJson = JSON.stringify(contentForJson, null, 2); - zip.file("content.json", contentJson); - const configJson = await import("@/utils/jsonDocument").then((mod) => - mod.documentToJson(doc), - ); - zip.file("config.json", configJson); - - for (const [filename, content] of typstContents) { - zip.file(filename, content); - } - - for (const [filename, assetUrl] of assets) { - const ext = resolveAssetExtension(assetUrl); - const buffer = await resolveAssetData(assetUrl); - zip.file(`assets/${filename}.${ext}`, buffer); - } - - const extraEntries = Object.entries(typstResult.extraContents); - for (const [key, extra] of extraEntries) { - const typst = replaceAssetReferences(extra.typst, assets); - zip.file(`extra-${key}.typ`, typst); - } - - typstResult.problems.forEach((problem, index) => { - const typst = replaceAssetReferences(problem.typst, assets); - zip.file(`problem-${index}.typ`, typst); - }); - - const fonts = await importFontUrlEnteries(doc.templateId); - for (const [, fontUrl] of fonts) { - if (!fontUrl.startsWith("/")) continue; - const res = await fetch(fontUrl); - const buffer = new Uint8Array(await res.arrayBuffer()); - zip.file(fontUrl.slice(1), buffer); - } - - return zip; -} - -export async function exportTypstArchive(doc: DocumentBase) { - const zip = await buildTypstArchive(doc); - const blob = await zip.generateAsync({ type: "blob" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${doc.name}-${Date.now()}.zip`; - a.click(); - URL.revokeObjectURL(url); -} export function removeProblemCallback( modal: ModalHookAPI, diff --git a/src/utils/exportTypstArchive.ts b/src/utils/exportTypstArchive.ts new file mode 100644 index 0000000..116956f --- /dev/null +++ b/src/utils/exportTypstArchive.ts @@ -0,0 +1,169 @@ +import type { ContentBase, DocumentBase } from "@/types/document"; +import JSZip from "jszip"; +import getProcessor from "@/compiler/getProcessor"; +import base64js from "base64-js"; +import { + importContentZod, + importFontUrlEnteries, + importTypstContents, + importUnifiedPlugins, +} from "@/utils/importTemplate"; + +function removeImages(content: ContentBase): ContentBase { + return { + ...content, + images: [], + }; +} + +type InlineAsset = { + filename: string; + assetUrl: string; +}; + +async function compileContentToTypst(content: ContentBase, templateId: string) { + const processor = getProcessor(await importUnifiedPlugins(templateId)); + const problems = await Promise.all( + content.problems.map(async (problem) => { + const processed = processor.processSync(problem.markdown); + return { + typst: processed.toString(), + assets: (processed.data.assets ?? []) as InlineAsset[], + }; + }), + ); + const extraContents = await Promise.all( + Object.entries(content.extraContents).map(async ([key, value]) => { + const processed = processor.processSync(value.markdown); + return [ + key, + { + typst: processed.toString(), + assets: (processed.data.assets ?? []) as InlineAsset[], + }, + ] as const; + }), + ); + return { + problems, + extraContents: Object.fromEntries(extraContents), + }; +} + +async function collectAssets( + typstResult: Awaited>, +) { + const assets = new Map(); + const addAsset = (filename: string, assetUrl: string) => { + if (!assets.has(filename)) assets.set(filename, assetUrl); + }; + // Inline image assets already resolved from markdown (data.assets). + for (const problem of typstResult.problems) { + for (const asset of problem.assets) + addAsset(asset.filename, asset.assetUrl); + } + for (const extra of Object.values(typstResult.extraContents)) { + for (const asset of extra.assets) addAsset(asset.filename, asset.assetUrl); + } + return assets; +} + +async function resolveAssetData(assetUrl: string): Promise { + if (assetUrl.startsWith("data:")) return dataUrlToUint8Array(assetUrl); + const response = await fetch(assetUrl); + return new Uint8Array(await response.arrayBuffer()); +} + +function resolveAssetExtension(assetUrl: string): string { + if (assetUrl.startsWith("data:")) return dataUrlToExtension(assetUrl); + const normalized = assetUrl.split("?")[0]; + const match = /\.([a-zA-Z0-9]+)$/.exec(normalized); + return match?.[1] ?? "bin"; +} + +function dataUrlToUint8Array(dataUrl: string): Uint8Array { + const match = /^data:(?[^;]+);base64,(?.+)$/u.exec(dataUrl); + if (!match?.groups?.data) throw new Error("Invalid data URL"); + return Uint8Array.from(base64js.toByteArray(match.groups.data)); +} + +function dataUrlToExtension(dataUrl: string): string { + const match = /^data:(?[^;]+);base64,/u.exec(dataUrl); + const type = match?.groups?.type ?? "application/octet-stream"; + const map: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/svg+xml": "svg", + "image/webp": "webp", + }; + return map[type] ?? "bin"; +} + +function replaceAssetReferences(typst: string, assets: Map) { + let result = typst; + for (const [filename, assetUrl] of assets) { + const ext = resolveAssetExtension(assetUrl); + const filePath = `assets/${filename}.${ext}`; + result = result.split(`image("${filename}"`).join(`image("${filePath}"`); + } + return result; +} + +async function buildTypstArchive(doc: DocumentBase) { + const zip = new JSZip(); + const typstContents = await importTypstContents(doc.templateId); + const contentZod = await importContentZod(doc.templateId); + const contentForJson = contentZod.parse(removeImages(doc.content)); + const typstResult = await compileContentToTypst(doc.content, doc.templateId); + const assets = await collectAssets(typstResult); + + const contentJson = JSON.stringify(contentForJson, null, 2); + zip.file("content.json", contentJson); + const configJson = await import("@/utils/jsonDocument").then((mod) => + mod.documentToJson(doc), + ); + zip.file("config.json", configJson); + + for (const [filename, content] of typstContents) { + zip.file(filename, content); + } + + for (const [filename, assetUrl] of assets) { + const ext = resolveAssetExtension(assetUrl); + const buffer = await resolveAssetData(assetUrl); + zip.file(`assets/${filename}.${ext}`, buffer); + } + + const extraEntries = Object.entries(typstResult.extraContents); + for (const [key, extra] of extraEntries) { + const typst = replaceAssetReferences(extra.typst, assets); + zip.file(`extra-${key}.typ`, typst); + } + + typstResult.problems.forEach((problem, index) => { + const typst = replaceAssetReferences(problem.typst, assets); + zip.file(`problem-${index}.typ`, typst); + }); + + const fonts = await importFontUrlEnteries(doc.templateId); + for (const [, fontUrl] of fonts) { + if (!fontUrl.startsWith("/")) continue; + const res = await fetch(fontUrl); + const buffer = new Uint8Array(await res.arrayBuffer()); + zip.file(fontUrl.slice(1), buffer); + } + + return zip; +} + +export async function exportTypstArchive(doc: DocumentBase) { + const zip = await buildTypstArchive(doc); + const blob = await zip.generateAsync({ type: "blob" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${doc.name}-${Date.now()}.zip`; + a.click(); + URL.revokeObjectURL(url); +} From 836a1bf5fea43d9b72c11c3c7a9bdceef75e49ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 01:46:58 +0000 Subject: [PATCH 04/17] Add typst export module Co-authored-by: Mr-Python-in-China <89737170+Mr-Python-in-China@users.noreply.github.com> --- src/utils/exportTypstArchive.ts | 4 ++-- src/utils/importTemplate.ts | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/utils/exportTypstArchive.ts b/src/utils/exportTypstArchive.ts index 116956f..417dbba 100644 --- a/src/utils/exportTypstArchive.ts +++ b/src/utils/exportTypstArchive.ts @@ -4,7 +4,7 @@ import getProcessor from "@/compiler/getProcessor"; import base64js from "base64-js"; import { importContentZod, - importFontUrlEnteries, + importFontUrlEntries, importTypstContents, importUnifiedPlugins, } from "@/utils/importTemplate"; @@ -146,7 +146,7 @@ async function buildTypstArchive(doc: DocumentBase) { zip.file(`problem-${index}.typ`, typst); }); - const fonts = await importFontUrlEnteries(doc.templateId); + const fonts = await importFontUrlEntries(doc.templateId); for (const [, fontUrl] of fonts) { if (!fontUrl.startsWith("/")) continue; const res = await fetch(fontUrl); diff --git a/src/utils/importTemplate.ts b/src/utils/importTemplate.ts index 8cbc3be..1fcd4dd 100644 --- a/src/utils/importTemplate.ts +++ b/src/utils/importTemplate.ts @@ -35,3 +35,7 @@ export async function importFontUrlEnteries(template: string) { font.url, ]); } + +export async function importFontUrlEntries(template: string) { + return importFontUrlEnteries(template); +} From e165dfc7172bb20d9df85eef41a0d74cbb3a58a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:01:31 +0000 Subject: [PATCH 05/17] Move typst export to worker Co-authored-by: Mr-Python-in-China <89737170+Mr-Python-in-China@users.noreply.github.com> --- src/compiler/compiler.worker.ts | 131 +++++++++++++++++++++- src/utils/exportTypstArchive.ts | 189 ++++---------------------------- 2 files changed, 153 insertions(+), 167 deletions(-) diff --git a/src/compiler/compiler.worker.ts b/src/compiler/compiler.worker.ts index c147bb8..cf5cf68 100644 --- a/src/compiler/compiler.worker.ts +++ b/src/compiler/compiler.worker.ts @@ -14,6 +14,8 @@ import { import { listen, sendToMain } from "@mr.python/promise-worker-ts"; import { Mutex } from "async-mutex"; import getProcessor from "./getProcessor"; +import JSZip from "jszip"; +import base64js from "base64-js"; const typstAccessModel = new MemoryAccessModel(); @@ -128,6 +130,49 @@ async function typstPrepare( const mutex = new Mutex(); +function removeImages(content: DocumentBase["content"]): DocumentBase["content"] { + return { + ...content, + images: [], + }; +} + +function dataUrlToUint8Array(dataUrl: string): Uint8Array { + const match = /^data:(?[^;]+);base64,(?.+)$/u.exec(dataUrl); + if (!match?.groups?.data) throw new Error("Invalid data URL"); + return Uint8Array.from(base64js.toByteArray(match.groups.data)); +} + +function dataUrlToExtension(dataUrl: string): string { + const match = /^data:(?[^;]+);base64,/u.exec(dataUrl); + const type = match?.groups?.type ?? "application/octet-stream"; + const map: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/svg+xml": "svg", + "image/webp": "webp", + }; + return map[type] ?? "bin"; +} + +function resolveUrlExtension(url: string): string { + const normalized = url.split("?")[0]; + const match = /\.([a-zA-Z0-9]+)$/.exec(normalized); + return match?.[1] ?? "bin"; +} + +function replaceAssetReferences( + typst: string, + assets: Map, +): string { + let result = typst; + for (const [filename, assetPath] of assets) { + result = result.split(`image("${filename}"`).join(`image("${assetPath}"`); + } + return result; +} + listen("compileTypst", async (data) => mutex.runExclusive(async () => { const [, assets] = compilerPrepare(data); @@ -148,11 +193,90 @@ listen("renderTypst", async (data) => }), ); +listen("exportTypstArchive", async (doc) => + mutex.runExclusive(async () => { + const zip = new JSZip(); + const typstContents = await importTypstContents(doc.templateId); + const contentZod = await importContentZod(doc.templateId); + const contentForJson = contentZod.parse(removeImages(doc.content)); + const contentForCompile = { + ...doc.content, + images: doc.content.images.map( + ({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + blob, + ...rest + }) => rest, + ), + } satisfies PrecompileContent; + const [compiledContent, assets] = compilerPrepare(contentForCompile); + + zip.file("content.json", JSON.stringify(contentForJson, null, 2)); + const configJson = await import("@/utils/jsonDocument").then((mod) => + mod.documentToJson(doc), + ); + zip.file("config.json", configJson); + + for (const [filename, content] of typstContents) { + zip.file(filename, content); + } + + const assetPaths = new Map(); + for (const [filename, assetUrl] of assets) { + let buffer: Uint8Array; + let ext = "bin"; + if (assetUrl.startsWith("asset://")) { + const uuid = assetUrl.substring("asset://".length); + const image = doc.content.images.find((img) => img.uuid === uuid); + if (!image) throw new Error(`Asset not found: ${uuid}`); + buffer = new Uint8Array(await image.blob.arrayBuffer()); + ext = resolveUrlExtension(image.name || ""); + } else if (assetUrl.startsWith("data:")) { + buffer = dataUrlToUint8Array(assetUrl); + ext = dataUrlToExtension(assetUrl); + } else { + const response = await fetch(assetUrl); + buffer = new Uint8Array(await response.arrayBuffer()); + ext = resolveUrlExtension(assetUrl); + } + const assetPath = `assets/${filename}.${ext}`; + assetPaths.set(filename, assetPath); + zip.file(assetPath, buffer); + } + + for (const [key, extra] of Object.entries(compiledContent.extraContents)) { + const typst = replaceAssetReferences(extra.typst, assetPaths); + zip.file(`extra-${key}.typ`, typst); + } + + compiledContent.problems.forEach((problem, index) => { + const typst = replaceAssetReferences(problem.typst, assetPaths); + zip.file(`problem-${index}.typ`, typst); + }); + + const fonts = await importFontUrlEntries(doc.templateId); + for (const [, fontUrl] of fonts) { + if (!fontUrl.startsWith("/")) continue; + const res = await fetch(fontUrl); + const buffer = new Uint8Array(await res.arrayBuffer()); + zip.file(fontUrl.slice(1), buffer); + } + + return zip.generateAsync({ type: "uint8array" }); + }), +); + import type { PromiseWorkerTagged } from "@mr.python/promise-worker-ts"; -import type { CompiledContent, PrecompileContent } from "@/types/document"; +import type { + CompiledContent, + DocumentBase, + PrecompileContent, +} from "@/types/document"; import { + importContentZod, importTypstContents, importUnifiedPlugins, + importFontUrlEntries, } from "@/utils/importTemplate"; export type InitMessage = PromiseWorkerTagged< "init", @@ -179,3 +303,8 @@ export type FetchAssetMessage = PromiseWorkerTagged< string, ArrayBuffer >; +export type ExportTypstArchiveMessage = PromiseWorkerTagged< + "exportTypstArchive", + DocumentBase, + Uint8Array +>; diff --git a/src/utils/exportTypstArchive.ts b/src/utils/exportTypstArchive.ts index 417dbba..5ae229a 100644 --- a/src/utils/exportTypstArchive.ts +++ b/src/utils/exportTypstArchive.ts @@ -1,169 +1,26 @@ -import type { ContentBase, DocumentBase } from "@/types/document"; -import JSZip from "jszip"; -import getProcessor from "@/compiler/getProcessor"; -import base64js from "base64-js"; -import { - importContentZod, - importFontUrlEntries, - importTypstContents, - importUnifiedPlugins, -} from "@/utils/importTemplate"; - -function removeImages(content: ContentBase): ContentBase { - return { - ...content, - images: [], - }; -} - -type InlineAsset = { - filename: string; - assetUrl: string; -}; - -async function compileContentToTypst(content: ContentBase, templateId: string) { - const processor = getProcessor(await importUnifiedPlugins(templateId)); - const problems = await Promise.all( - content.problems.map(async (problem) => { - const processed = processor.processSync(problem.markdown); - return { - typst: processed.toString(), - assets: (processed.data.assets ?? []) as InlineAsset[], - }; - }), - ); - const extraContents = await Promise.all( - Object.entries(content.extraContents).map(async ([key, value]) => { - const processed = processor.processSync(value.markdown); - return [ - key, - { - typst: processed.toString(), - assets: (processed.data.assets ?? []) as InlineAsset[], - }, - ] as const; - }), - ); - return { - problems, - extraContents: Object.fromEntries(extraContents), - }; -} - -async function collectAssets( - typstResult: Awaited>, -) { - const assets = new Map(); - const addAsset = (filename: string, assetUrl: string) => { - if (!assets.has(filename)) assets.set(filename, assetUrl); - }; - // Inline image assets already resolved from markdown (data.assets). - for (const problem of typstResult.problems) { - for (const asset of problem.assets) - addAsset(asset.filename, asset.assetUrl); - } - for (const extra of Object.values(typstResult.extraContents)) { - for (const asset of extra.assets) addAsset(asset.filename, asset.assetUrl); - } - return assets; -} - -async function resolveAssetData(assetUrl: string): Promise { - if (assetUrl.startsWith("data:")) return dataUrlToUint8Array(assetUrl); - const response = await fetch(assetUrl); - return new Uint8Array(await response.arrayBuffer()); -} - -function resolveAssetExtension(assetUrl: string): string { - if (assetUrl.startsWith("data:")) return dataUrlToExtension(assetUrl); - const normalized = assetUrl.split("?")[0]; - const match = /\.([a-zA-Z0-9]+)$/.exec(normalized); - return match?.[1] ?? "bin"; -} - -function dataUrlToUint8Array(dataUrl: string): Uint8Array { - const match = /^data:(?[^;]+);base64,(?.+)$/u.exec(dataUrl); - if (!match?.groups?.data) throw new Error("Invalid data URL"); - return Uint8Array.from(base64js.toByteArray(match.groups.data)); -} - -function dataUrlToExtension(dataUrl: string): string { - const match = /^data:(?[^;]+);base64,/u.exec(dataUrl); - const type = match?.groups?.type ?? "application/octet-stream"; - const map: Record = { - "image/png": "png", - "image/jpeg": "jpg", - "image/jpg": "jpg", - "image/svg+xml": "svg", - "image/webp": "webp", - }; - return map[type] ?? "bin"; -} - -function replaceAssetReferences(typst: string, assets: Map) { - let result = typst; - for (const [filename, assetUrl] of assets) { - const ext = resolveAssetExtension(assetUrl); - const filePath = `assets/${filename}.${ext}`; - result = result.split(`image("${filename}"`).join(`image("${filePath}"`); - } - return result; -} - -async function buildTypstArchive(doc: DocumentBase) { - const zip = new JSZip(); - const typstContents = await importTypstContents(doc.templateId); - const contentZod = await importContentZod(doc.templateId); - const contentForJson = contentZod.parse(removeImages(doc.content)); - const typstResult = await compileContentToTypst(doc.content, doc.templateId); - const assets = await collectAssets(typstResult); - - const contentJson = JSON.stringify(contentForJson, null, 2); - zip.file("content.json", contentJson); - const configJson = await import("@/utils/jsonDocument").then((mod) => - mod.documentToJson(doc), - ); - zip.file("config.json", configJson); - - for (const [filename, content] of typstContents) { - zip.file(filename, content); - } - - for (const [filename, assetUrl] of assets) { - const ext = resolveAssetExtension(assetUrl); - const buffer = await resolveAssetData(assetUrl); - zip.file(`assets/${filename}.${ext}`, buffer); - } - - const extraEntries = Object.entries(typstResult.extraContents); - for (const [key, extra] of extraEntries) { - const typst = replaceAssetReferences(extra.typst, assets); - zip.file(`extra-${key}.typ`, typst); - } - - typstResult.problems.forEach((problem, index) => { - const typst = replaceAssetReferences(problem.typst, assets); - zip.file(`problem-${index}.typ`, typst); - }); - - const fonts = await importFontUrlEntries(doc.templateId); - for (const [, fontUrl] of fonts) { - if (!fontUrl.startsWith("/")) continue; - const res = await fetch(fontUrl); - const buffer = new Uint8Array(await res.arrayBuffer()); - zip.file(fontUrl.slice(1), buffer); - } - - return zip; -} +import type { DocumentBase } from "@/types/document"; +import { send } from "@mr.python/promise-worker-ts"; +import type { ExportTypstArchiveMessage } from "@/compiler/compiler.worker"; +import TypstWorker from "@/compiler/compiler.worker?worker"; export async function exportTypstArchive(doc: DocumentBase) { - const zip = await buildTypstArchive(doc); - const blob = await zip.generateAsync({ type: "blob" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${doc.name}-${Date.now()}.zip`; - a.click(); - URL.revokeObjectURL(url); + const worker = new TypstWorker(); + try { + const archive = await send( + "exportTypstArchive", + worker, + doc, + ); + const blob = new Blob([new Uint8Array(archive)], { + type: "application/zip", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${doc.name}-${Date.now()}.zip`; + a.click(); + URL.revokeObjectURL(url); + } finally { + worker.terminate(); + } } From 3f110892165c1158c1fa843f9e95326fb78ec963 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:04:29 +0000 Subject: [PATCH 06/17] Improve worker asset handling Co-authored-by: Mr-Python-in-China <89737170+Mr-Python-in-China@users.noreply.github.com> --- src/compiler/compiler.worker.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/compiler/compiler.worker.ts b/src/compiler/compiler.worker.ts index cf5cf68..6cf0f80 100644 --- a/src/compiler/compiler.worker.ts +++ b/src/compiler/compiler.worker.ts @@ -146,6 +146,10 @@ function dataUrlToUint8Array(dataUrl: string): Uint8Array { function dataUrlToExtension(dataUrl: string): string { const match = /^data:(?[^;]+);base64,/u.exec(dataUrl); const type = match?.groups?.type ?? "application/octet-stream"; + return mimeTypeToExtension(type); +} + +function mimeTypeToExtension(type: string): string { const map: Record = { "image/png": "png", "image/jpeg": "jpg", @@ -156,21 +160,23 @@ function dataUrlToExtension(dataUrl: string): string { return map[type] ?? "bin"; } -function resolveUrlExtension(url: string): string { +function resolveUrlExtension(url: string): string | undefined { const normalized = url.split("?")[0]; const match = /\.([a-zA-Z0-9]+)$/.exec(normalized); - return match?.[1] ?? "bin"; + return match?.[1] ?? undefined; } function replaceAssetReferences( typst: string, assets: Map, ): string { - let result = typst; - for (const [filename, assetPath] of assets) { - result = result.split(`image("${filename}"`).join(`image("${assetPath}"`); - } - return result; + return typst.replaceAll( + /image\("(?[^"]+)"/g, + (match, name: string) => + name && assets.has(name) + ? `image("${assets.get(name)}"` + : match, + ); } listen("compileTypst", async (data) => @@ -224,13 +230,14 @@ listen("exportTypstArchive", async (doc) => const assetPaths = new Map(); for (const [filename, assetUrl] of assets) { let buffer: Uint8Array; - let ext = "bin"; + let ext: string | undefined; if (assetUrl.startsWith("asset://")) { const uuid = assetUrl.substring("asset://".length); const image = doc.content.images.find((img) => img.uuid === uuid); if (!image) throw new Error(`Asset not found: ${uuid}`); buffer = new Uint8Array(await image.blob.arrayBuffer()); ext = resolveUrlExtension(image.name || ""); + if (!ext) ext = mimeTypeToExtension(image.blob.type); } else if (assetUrl.startsWith("data:")) { buffer = dataUrlToUint8Array(assetUrl); ext = dataUrlToExtension(assetUrl); @@ -238,6 +245,7 @@ listen("exportTypstArchive", async (doc) => const response = await fetch(assetUrl); buffer = new Uint8Array(await response.arrayBuffer()); ext = resolveUrlExtension(assetUrl); + if (!ext) ext = "bin"; } const assetPath = `assets/${filename}.${ext}`; assetPaths.set(filename, assetPath); From 90a0511dbf2ab128528612eb8dd62297a414fee5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:06:17 +0000 Subject: [PATCH 07/17] Refine worker asset mapping Co-authored-by: Mr-Python-in-China <89737170+Mr-Python-in-China@users.noreply.github.com> --- src/compiler/compiler.worker.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/compiler/compiler.worker.ts b/src/compiler/compiler.worker.ts index 6cf0f80..a8db574 100644 --- a/src/compiler/compiler.worker.ts +++ b/src/compiler/compiler.worker.ts @@ -163,7 +163,7 @@ function mimeTypeToExtension(type: string): string { function resolveUrlExtension(url: string): string | undefined { const normalized = url.split("?")[0]; const match = /\.([a-zA-Z0-9]+)$/.exec(normalized); - return match?.[1] ?? undefined; + return match?.[1]; } function replaceAssetReferences( @@ -173,9 +173,7 @@ function replaceAssetReferences( return typst.replaceAll( /image\("(?[^"]+)"/g, (match, name: string) => - name && assets.has(name) - ? `image("${assets.get(name)}"` - : match, + assets.has(name) ? `image("${assets.get(name)}"` : match, ); } @@ -234,7 +232,8 @@ listen("exportTypstArchive", async (doc) => if (assetUrl.startsWith("asset://")) { const uuid = assetUrl.substring("asset://".length); const image = doc.content.images.find((img) => img.uuid === uuid); - if (!image) throw new Error(`Asset not found: ${uuid}`); + if (!image) + throw new Error(`Asset not found in document images: ${uuid}`); buffer = new Uint8Array(await image.blob.arrayBuffer()); ext = resolveUrlExtension(image.name || ""); if (!ext) ext = mimeTypeToExtension(image.blob.type); From 490f43b102979192ed6b7f9e0cb898d75ed18de8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:07:55 +0000 Subject: [PATCH 08/17] Polish worker export errors Co-authored-by: Mr-Python-in-China <89737170+Mr-Python-in-China@users.noreply.github.com> --- src/compiler/compiler.worker.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/compiler/compiler.worker.ts b/src/compiler/compiler.worker.ts index a8db574..eb1f3a5 100644 --- a/src/compiler/compiler.worker.ts +++ b/src/compiler/compiler.worker.ts @@ -129,6 +129,7 @@ async function typstPrepare( } const mutex = new Mutex(); +const ASSET_URL_PREFIX = "asset://"; function removeImages(content: DocumentBase["content"]): DocumentBase["content"] { return { @@ -229,11 +230,13 @@ listen("exportTypstArchive", async (doc) => for (const [filename, assetUrl] of assets) { let buffer: Uint8Array; let ext: string | undefined; - if (assetUrl.startsWith("asset://")) { - const uuid = assetUrl.substring("asset://".length); + if (assetUrl.startsWith(ASSET_URL_PREFIX)) { + const uuid = assetUrl.slice(ASSET_URL_PREFIX.length); const image = doc.content.images.find((img) => img.uuid === uuid); if (!image) - throw new Error(`Asset not found in document images: ${uuid}`); + throw new Error( + `Asset not found in document images: ${uuid}. The referenced image may have been deleted or the document corrupted.`, + ); buffer = new Uint8Array(await image.blob.arrayBuffer()); ext = resolveUrlExtension(image.name || ""); if (!ext) ext = mimeTypeToExtension(image.blob.type); From a0306a7e7584f448fe87b2334d585b7290ee4461 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:09:25 +0000 Subject: [PATCH 09/17] Simplify worker asset errors Co-authored-by: Mr-Python-in-China <89737170+Mr-Python-in-China@users.noreply.github.com> --- src/compiler/compiler.worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compiler/compiler.worker.ts b/src/compiler/compiler.worker.ts index eb1f3a5..0aa433e 100644 --- a/src/compiler/compiler.worker.ts +++ b/src/compiler/compiler.worker.ts @@ -235,7 +235,7 @@ listen("exportTypstArchive", async (doc) => const image = doc.content.images.find((img) => img.uuid === uuid); if (!image) throw new Error( - `Asset not found in document images: ${uuid}. The referenced image may have been deleted or the document corrupted.`, + "Referenced image not found. The image may have been deleted.", ); buffer = new Uint8Array(await image.blob.arrayBuffer()); ext = resolveUrlExtension(image.name || ""); From fbf1554f2be825fb072abfa6f18027e9b00ea4a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:11:03 +0000 Subject: [PATCH 10/17] Clarify missing asset error Co-authored-by: Mr-Python-in-China <89737170+Mr-Python-in-China@users.noreply.github.com> --- src/compiler/compiler.worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compiler/compiler.worker.ts b/src/compiler/compiler.worker.ts index 0aa433e..46cf108 100644 --- a/src/compiler/compiler.worker.ts +++ b/src/compiler/compiler.worker.ts @@ -235,7 +235,7 @@ listen("exportTypstArchive", async (doc) => const image = doc.content.images.find((img) => img.uuid === uuid); if (!image) throw new Error( - "Referenced image not found. The image may have been deleted.", + `Referenced image "${uuid}" not found. The image may have been deleted.`, ); buffer = new Uint8Array(await image.blob.arrayBuffer()); ext = resolveUrlExtension(image.name || ""); From d5aaf305237c6f11cf0c0e41e8a8624cd21e2e43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:12:37 +0000 Subject: [PATCH 11/17] Clarify worker error guidance Co-authored-by: Mr-Python-in-China <89737170+Mr-Python-in-China@users.noreply.github.com> --- src/compiler/compiler.worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compiler/compiler.worker.ts b/src/compiler/compiler.worker.ts index 46cf108..e49461a 100644 --- a/src/compiler/compiler.worker.ts +++ b/src/compiler/compiler.worker.ts @@ -235,7 +235,7 @@ listen("exportTypstArchive", async (doc) => const image = doc.content.images.find((img) => img.uuid === uuid); if (!image) throw new Error( - `Referenced image "${uuid}" not found. The image may have been deleted.`, + `Referenced image "${uuid}" not found. The image may have been deleted. Please remove the image reference or re-add the image to the document.`, ); buffer = new Uint8Array(await image.blob.arrayBuffer()); ext = resolveUrlExtension(image.name || ""); From 22479a1b756dfb7020ed2788b04da455f53d06cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:15:46 +0000 Subject: [PATCH 12/17] Add missing image guidance Co-authored-by: Mr-Python-in-China <89737170+Mr-Python-in-China@users.noreply.github.com> --- src/compiler/compiler.worker.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/compiler/compiler.worker.ts b/src/compiler/compiler.worker.ts index e49461a..e7e6c8b 100644 --- a/src/compiler/compiler.worker.ts +++ b/src/compiler/compiler.worker.ts @@ -233,12 +233,13 @@ listen("exportTypstArchive", async (doc) => if (assetUrl.startsWith(ASSET_URL_PREFIX)) { const uuid = assetUrl.slice(ASSET_URL_PREFIX.length); const image = doc.content.images.find((img) => img.uuid === uuid); + const imageName = image?.name; if (!image) throw new Error( - `Referenced image "${uuid}" not found. The image may have been deleted. Please remove the image reference or re-add the image to the document.`, + `Referenced image${imageName ? ` "${imageName}"` : ""} (id: ${uuid}) not found. The image may have been deleted. Please remove the image reference or re-add the image to the document.`, ); buffer = new Uint8Array(await image.blob.arrayBuffer()); - ext = resolveUrlExtension(image.name || ""); + ext = resolveUrlExtension(imageName || ""); if (!ext) ext = mimeTypeToExtension(image.blob.type); } else if (assetUrl.startsWith("data:")) { buffer = dataUrlToUint8Array(assetUrl); From fd04f1c9cdd08c2763f17ade046a334489220dfa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:18:34 +0000 Subject: [PATCH 13/17] Tidy worker export message Co-authored-by: Mr-Python-in-China <89737170+Mr-Python-in-China@users.noreply.github.com> --- src/compiler/compiler.worker.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/compiler/compiler.worker.ts b/src/compiler/compiler.worker.ts index e7e6c8b..14595bc 100644 --- a/src/compiler/compiler.worker.ts +++ b/src/compiler/compiler.worker.ts @@ -208,10 +208,12 @@ listen("exportTypstArchive", async (doc) => ...doc.content, images: doc.content.images.map( ({ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - blob, + blob: _blob, ...rest - }) => rest, + }) => { + void _blob; + return rest; + }, ), } satisfies PrecompileContent; const [compiledContent, assets] = compilerPrepare(contentForCompile); @@ -234,10 +236,14 @@ listen("exportTypstArchive", async (doc) => const uuid = assetUrl.slice(ASSET_URL_PREFIX.length); const image = doc.content.images.find((img) => img.uuid === uuid); const imageName = image?.name; - if (!image) + if (!image) { + const imageLabel = imageName + ? `"${imageName}" (id: ${uuid})` + : `id: ${uuid}`; throw new Error( - `Referenced image${imageName ? ` "${imageName}"` : ""} (id: ${uuid}) not found. The image may have been deleted. Please remove the image reference or re-add the image to the document.`, + `Referenced image ${imageLabel} not found. The image may have been deleted. Please remove the image reference or re-add the image to the document.`, ); + } buffer = new Uint8Array(await image.blob.arrayBuffer()); ext = resolveUrlExtension(imageName || ""); if (!ext) ext = mimeTypeToExtension(image.blob.type); From 0449d1299888695f72bca71f3092034cfcea1b79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:22:04 +0000 Subject: [PATCH 14/17] Clean worker export map Co-authored-by: Mr-Python-in-China <89737170+Mr-Python-in-China@users.noreply.github.com> --- src/compiler/compiler.worker.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/compiler/compiler.worker.ts b/src/compiler/compiler.worker.ts index 14595bc..bb02e85 100644 --- a/src/compiler/compiler.worker.ts +++ b/src/compiler/compiler.worker.ts @@ -206,15 +206,11 @@ listen("exportTypstArchive", async (doc) => const contentForJson = contentZod.parse(removeImages(doc.content)); const contentForCompile = { ...doc.content, - images: doc.content.images.map( - ({ - blob: _blob, - ...rest - }) => { - void _blob; - return rest; - }, - ), + images: doc.content.images.map((item) => { + const { blob, ...rest } = item; + void blob; + return rest; + }), } satisfies PrecompileContent; const [compiledContent, assets] = compilerPrepare(contentForCompile); @@ -253,8 +249,7 @@ listen("exportTypstArchive", async (doc) => } else { const response = await fetch(assetUrl); buffer = new Uint8Array(await response.arrayBuffer()); - ext = resolveUrlExtension(assetUrl); - if (!ext) ext = "bin"; + ext = resolveUrlExtension(assetUrl) ?? "bin"; } const assetPath = `assets/${filename}.${ext}`; assetPaths.set(filename, assetPath); From 00dfd299ff517af45deede164e2e134489ea672f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:24:36 +0000 Subject: [PATCH 15/17] Harden worker image handling Co-authored-by: Mr-Python-in-China <89737170+Mr-Python-in-China@users.noreply.github.com> --- src/compiler/compiler.worker.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/compiler/compiler.worker.ts b/src/compiler/compiler.worker.ts index bb02e85..ee42b26 100644 --- a/src/compiler/compiler.worker.ts +++ b/src/compiler/compiler.worker.ts @@ -207,8 +207,10 @@ listen("exportTypstArchive", async (doc) => const contentForCompile = { ...doc.content, images: doc.content.images.map((item) => { - const { blob, ...rest } = item; - void blob; + const rest = { ...item } as Omit & { + blob?: Blob; + }; + delete rest.blob; return rest; }), } satisfies PrecompileContent; @@ -233,8 +235,9 @@ listen("exportTypstArchive", async (doc) => const image = doc.content.images.find((img) => img.uuid === uuid); const imageName = image?.name; if (!image) { - const imageLabel = imageName - ? `"${imageName}" (id: ${uuid})` + const safeImageName = imageName?.replaceAll(/[\r\n\t]/g, " "); + const imageLabel = safeImageName + ? `"${safeImageName}" (id: ${uuid})` : `id: ${uuid}`; throw new Error( `Referenced image ${imageLabel} not found. The image may have been deleted. Please remove the image reference or re-add the image to the document.`, From c2bf49aadc6e40e0a76177dbf1bfc9d0679e9481 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:26:29 +0000 Subject: [PATCH 16/17] Sanitize worker export errors Co-authored-by: Mr-Python-in-China <89737170+Mr-Python-in-China@users.noreply.github.com> --- src/compiler/compiler.worker.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/compiler/compiler.worker.ts b/src/compiler/compiler.worker.ts index ee42b26..f80792d 100644 --- a/src/compiler/compiler.worker.ts +++ b/src/compiler/compiler.worker.ts @@ -235,16 +235,15 @@ listen("exportTypstArchive", async (doc) => const image = doc.content.images.find((img) => img.uuid === uuid); const imageName = image?.name; if (!image) { - const safeImageName = imageName?.replaceAll(/[\r\n\t]/g, " "); - const imageLabel = safeImageName - ? `"${safeImageName}" (id: ${uuid})` + const imageLabel = imageName + ? `"${imageName.replaceAll(/[\r\n\t]/g, " ")}" (id: ${uuid})` : `id: ${uuid}`; throw new Error( `Referenced image ${imageLabel} not found. The image may have been deleted. Please remove the image reference or re-add the image to the document.`, ); } buffer = new Uint8Array(await image.blob.arrayBuffer()); - ext = resolveUrlExtension(imageName || ""); + ext = imageName ? resolveUrlExtension(imageName) : undefined; if (!ext) ext = mimeTypeToExtension(image.blob.type); } else if (assetUrl.startsWith("data:")) { buffer = dataUrlToUint8Array(assetUrl); From 50f2712198cfca1a591fbca1e67c296ca396225a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:28:19 +0000 Subject: [PATCH 17/17] Sanitize worker image label Co-authored-by: Mr-Python-in-China <89737170+Mr-Python-in-China@users.noreply.github.com> --- src/compiler/compiler.worker.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/compiler/compiler.worker.ts b/src/compiler/compiler.worker.ts index f80792d..779bc74 100644 --- a/src/compiler/compiler.worker.ts +++ b/src/compiler/compiler.worker.ts @@ -174,7 +174,7 @@ function replaceAssetReferences( return typst.replaceAll( /image\("(?[^"]+)"/g, (match, name: string) => - assets.has(name) ? `image("${assets.get(name)}"` : match, + assets.has(name) ? `image("${assets.get(name)}")` : match, ); } @@ -236,7 +236,7 @@ listen("exportTypstArchive", async (doc) => const imageName = image?.name; if (!image) { const imageLabel = imageName - ? `"${imageName.replaceAll(/[\r\n\t]/g, " ")}" (id: ${uuid})` + ? `"${imageName.replaceAll(/\s+/g, " ")}" (id: ${uuid})` : `id: ${uuid}`; throw new Error( `Referenced image ${imageLabel} not found. The image may have been deleted. Please remove the image reference or re-add the image to the document.`,