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/compiler/compiler.worker.ts b/src/compiler/compiler.worker.ts index c147bb8..779bc74 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(); @@ -127,6 +129,54 @@ async function typstPrepare( } const mutex = new Mutex(); +const ASSET_URL_PREFIX = "asset://"; + +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"; + return mimeTypeToExtension(type); +} + +function mimeTypeToExtension(type: string): string { + 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 | undefined { + const normalized = url.split("?")[0]; + const match = /\.([a-zA-Z0-9]+)$/.exec(normalized); + return match?.[1]; +} + +function replaceAssetReferences( + typst: string, + assets: Map, +): string { + return typst.replaceAll( + /image\("(?[^"]+)"/g, + (match, name: string) => + assets.has(name) ? `image("${assets.get(name)}")` : match, + ); +} listen("compileTypst", async (data) => mutex.runExclusive(async () => { @@ -148,11 +198,99 @@ 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((item) => { + const rest = { ...item } as Omit & { + blob?: Blob; + }; + delete rest.blob; + return 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: string | undefined; + 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) { + const imageLabel = imageName + ? `"${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.`, + ); + } + buffer = new Uint8Array(await image.blob.arrayBuffer()); + ext = imageName ? resolveUrlExtension(imageName) : undefined; + if (!ext) ext = mimeTypeToExtension(image.blob.type); + } 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) ?? "bin"; + } + 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 +317,8 @@ export type FetchAssetMessage = PromiseWorkerTagged< string, ArrayBuffer >; +export type ExportTypstArchiveMessage = PromiseWorkerTagged< + "exportTypstArchive", + DocumentBase, + Uint8Array +>; diff --git a/src/router/editor/header.tsx b/src/router/editor/header.tsx index 7c8684f..0900974 100644 --- a/src/router/editor/header.tsx +++ b/src/router/editor/header.tsx @@ -18,6 +18,7 @@ import { 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"; @@ -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/exportTypstArchive.ts b/src/utils/exportTypstArchive.ts new file mode 100644 index 0000000..5ae229a --- /dev/null +++ b/src/utils/exportTypstArchive.ts @@ -0,0 +1,26 @@ +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 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(); + } +} 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); +}