Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ lerna-debug.log*

node_modules
dist
node_modules
.vite
dist-ssr
*.local

Expand Down
95 changes: 94 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
File renamed without changes.
145 changes: 144 additions & 1 deletion src/compiler/compiler.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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:(?<type>[^;]+);base64,(?<data>.+)$/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:(?<type>[^;]+);base64,/u.exec(dataUrl);
const type = match?.groups?.type ?? "application/octet-stream";
return mimeTypeToExtension(type);
}

function mimeTypeToExtension(type: string): string {
const map: Record<string, string> = {
"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, string>,
): string {
return typst.replaceAll(
/image\("(?<name>[^"]+)"/g,
(match, name: string) =>
assets.has(name) ? `image("${assets.get(name)}")` : match,
);
}

listen<CompileTypstMessage>("compileTypst", async (data) =>
mutex.runExclusive(async () => {
Expand All @@ -148,11 +198,99 @@ listen<RenderTypstMessage>("renderTypst", async (data) =>
}),
);

listen<ExportTypstArchiveMessage>("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<typeof item, "blob"> & {
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<string, string>();
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",
Expand All @@ -179,3 +317,8 @@ export type FetchAssetMessage = PromiseWorkerTagged<
string,
ArrayBuffer
>;
export type ExportTypstArchiveMessage = PromiseWorkerTagged<
"exportTypstArchive",
DocumentBase,
Uint8Array
>;
Loading
Loading