diff --git a/apps/web/src/features/resume/export/pdf-document.test.ts b/apps/web/src/features/resume/export/pdf-document.test.ts new file mode 100644 index 000000000..652ab4f66 --- /dev/null +++ b/apps/web/src/features/resume/export/pdf-document.test.ts @@ -0,0 +1,69 @@ +import type { PdfWorkerRequest, PdfWorkerResponse } from "./pdf-document"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; + +const mocks = vi.hoisted(() => ({ renderHere: vi.fn() })); +vi.mock("@reactive-resume/pdf/browser", () => ({ createResumePdfBlob: mocks.renderHere })); +vi.mock("@/libs/resume/section-title-locale", () => ({ createSectionTitleResolverForLocale: async () => () => "" })); + +const pageMap = { pages: [{ width: 595, height: 842 }], nodes: [] }; + +/** Stands in for the browser's Worker: `reply` decides what it sends back for each request. */ +function stubWorker(reply: (request: PdfWorkerRequest, worker: EventTarget) => void) { + class FakeWorker extends EventTarget { + postMessage(request: PdfWorkerRequest) { + queueMicrotask(() => reply(request, this)); + } + terminate() {} + } + vi.stubGlobal("Worker", FakeWorker); +} + +const respond = (worker: EventTarget, data: PdfWorkerResponse) => + worker.dispatchEvent(new MessageEvent("message", { data })); + +beforeEach(() => { + vi.resetModules(); + mocks.renderHere.mockReset(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("createResumePdfBlob", () => { + it("renders in the worker and hands back its PDF and page map", async () => { + const blob = new Blob(["%PDF"], { type: "application/pdf" }); + stubWorker((request, worker) => respond(worker, { id: request.id, blob, pageMap })); + const { createResumePdfBlob } = await import("./pdf-document"); + const onPageMap = vi.fn(); + + await expect(createResumePdfBlob(sampleResumeData, undefined, undefined, { onPageMap })).resolves.toBe(blob); + expect(onPageMap).toHaveBeenCalledWith(pageMap); + expect(mocks.renderHere).not.toHaveBeenCalled(); + }); + + it("passes on the worker's failure, so callers can fall back to the server's PDF", async () => { + stubWorker((request, worker) => respond(worker, { id: request.id, error: "Fonts could not be loaded: Inter" })); + const { createResumePdfBlob } = await import("./pdf-document"); + + await expect(createResumePdfBlob(sampleResumeData)).rejects.toThrow("Fonts could not be loaded: Inter"); + }); + + it("renders on the main thread when the worker can't start", async () => { + const blob = new Blob(["%PDF"], { type: "application/pdf" }); + mocks.renderHere.mockImplementation(({ onPageMap }) => { + onPageMap(pageMap); + return Promise.resolve(blob); + }); + stubWorker((_request, worker) => worker.dispatchEvent(new Event("error"))); + const { createResumePdfBlob } = await import("./pdf-document"); + const onPageMap = vi.fn(); + + await expect(createResumePdfBlob(sampleResumeData, undefined, undefined, { onPageMap })).resolves.toBe(blob); + expect(onPageMap).toHaveBeenCalledWith(pageMap); + // Later renders skip the broken worker. + await createResumePdfBlob(sampleResumeData); + expect(mocks.renderHere).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/features/resume/export/pdf-document.ts b/apps/web/src/features/resume/export/pdf-document.ts index d4016df7c..b54e3cb74 100644 --- a/apps/web/src/features/resume/export/pdf-document.ts +++ b/apps/web/src/features/resume/export/pdf-document.ts @@ -1,7 +1,6 @@ import type { PageMap } from "@reactive-resume/pdf/page-map"; import type { ResumeData } from "@reactive-resume/schema/resume/data"; import type { Template } from "@reactive-resume/schema/templates"; -import { createResumePdfBlob as createPdfBlob } from "@reactive-resume/pdf/browser"; import { createSectionTitleResolverForLocale } from "@/libs/resume/section-title-locale"; type ResumePdfRenderOptions = { @@ -13,19 +12,85 @@ type CreateResumePdfBlobExtras = { onPageMap?: (pageMap: PageMap) => void; }; +type RenderInput = { data: ResumeData; template?: Template | undefined; renderOptions?: ResumePdfRenderOptions }; + +export type PdfWorkerRequest = RenderInput & { id: number }; +export type PdfWorkerResponse = + | { id: number; blob: Blob; pageMap: PageMap | undefined } + | { id: number; error: string }; + +type Rendered = { blob: Blob; pageMap: PageMap | undefined }; + +/** The same render on the main thread: where workers aren't available, or the worker failed to start. */ +async function renderHere({ data, template, renderOptions }: RenderInput): Promise { + const [{ createResumePdfBlob }, resolveSectionTitle] = await Promise.all([ + import("@reactive-resume/pdf/browser"), + createSectionTitleResolverForLocale(data.metadata.page.locale), + ]); + let pageMap: PageMap | undefined; + const blob = await createResumePdfBlob({ + data, + template, + ...(renderOptions ? { renderOptions } : {}), + resolveSectionTitle, + onPageMap: (map) => { + pageMap = map; + }, + }); + return { blob, pageMap }; +} + +type Pending = { input: RenderInput; resolve(value: Rendered): void; reject(error: Error): void }; + +// ponytail: one worker renders in order, so a preview can wait behind one thumbnail (~0.25 s); a second worker +// for thumbnails costs another engine and font set in memory. +let worker: Worker | null | undefined; +let nextId = 0; +const pending = new Map(); + +function getWorker(): Worker | null { + if (worker !== undefined) return worker; + worker = null; + if (typeof Worker === "undefined") return worker; + try { + worker = new Worker(new URL("./pdf.worker.ts", import.meta.url), { type: "module", name: "resume-pdf" }); + } catch { + return worker; + } + worker.addEventListener("message", ({ data: response }: MessageEvent) => { + const request = pending.get(response.id); + if (!request) return; + pending.delete(response.id); + if ("error" in response) request.reject(new Error(response.error)); + else request.resolve({ blob: response.blob, pageMap: response.pageMap }); + }); + // Every render is caught inside the worker, so an error here means it couldn't start: render here instead. + worker.addEventListener("error", () => { + worker?.terminate(); + worker = null; + for (const request of pending.values()) renderHere(request.input).then(request.resolve, request.reject); + pending.clear(); + }); + return worker; +} + +function render(input: RenderInput): Promise { + const target = getWorker(); + if (!target) return renderHere(input); + const id = ++nextId; + return new Promise((resolve, reject) => { + pending.set(id, { input, resolve, reject }); + target.postMessage({ ...input, id } satisfies PdfWorkerRequest); + }); +} + export const createResumePdfBlob = async ( data: ResumeData, template?: Template, renderOptions?: ResumePdfRenderOptions, { onPageMap }: CreateResumePdfBlobExtras = {}, ) => { - const sectionTitleResolver = await createSectionTitleResolverForLocale(data.metadata.page.locale); - - return createPdfBlob({ - data, - template, - ...(renderOptions ? { renderOptions } : {}), - resolveSectionTitle: sectionTitleResolver, - onPageMap, - }); + const { blob, pageMap } = await render({ data, template, ...(renderOptions ? { renderOptions } : {}) }); + if (pageMap) onPageMap?.(pageMap); + return blob; }; diff --git a/apps/web/src/features/resume/export/pdf.worker.ts b/apps/web/src/features/resume/export/pdf.worker.ts new file mode 100644 index 000000000..ff0ac94c5 --- /dev/null +++ b/apps/web/src/features/resume/export/pdf.worker.ts @@ -0,0 +1,26 @@ +/// + +import type { PageMap } from "@reactive-resume/pdf/page-map"; +import type { PdfWorkerRequest, PdfWorkerResponse } from "./pdf-document"; +import { createResumePdfBlob } from "@reactive-resume/pdf/browser"; +import { createSectionTitleResolverForLocale } from "@/libs/resume/section-title-locale"; + +// Renders off the main thread, so typing, scrolling and the gallery stay smooth while a page is laid out. +self.addEventListener("message", async ({ data: request }: MessageEvent) => { + const { id, data, template, renderOptions } = request; + try { + let pageMap: PageMap | undefined; + const blob = await createResumePdfBlob({ + data, + template, + renderOptions, + resolveSectionTitle: await createSectionTitleResolverForLocale(data.metadata.page.locale), + onPageMap: (map) => { + pageMap = map; + }, + }); + self.postMessage({ id, blob, pageMap } satisfies PdfWorkerResponse); + } catch (error) { + self.postMessage({ id, error: error instanceof Error ? error.message : String(error) } satisfies PdfWorkerResponse); + } +}); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 42056cb15..945fe822e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -45,6 +45,12 @@ export default defineConfig({ }, }, + // The PDF worker renders templates with translated section titles, so it needs the catalogs and macros too. + worker: { + format: "es", + plugins: () => [lingui(), babel({ presets: [linguiTransformerBabelPreset()] })], + }, + server: { host: true, strictPort: true,