Render public resumes with PDF.js (#3061)

* fix(web): use native pdf viewer for public resumes

* fix(web): render public resumes with pdf.js

* chore: revert vite hook paths

* chore(web): address pdf viewer review
This commit is contained in:
Amruth Pillai
2026-05-14 02:49:43 +02:00
committed by GitHub
parent 1294d3354a
commit 6c4a4b2aa5
5 changed files with 422 additions and 9 deletions
@@ -0,0 +1,11 @@
.pdf-viewer .pdfViewer {
padding-block: 0;
}
.pdf-viewer .pdfViewer .page {
margin-block: 0 1rem;
}
.pdf-viewer .pdfViewer .page:last-child {
margin-bottom: 0;
}
@@ -0,0 +1,119 @@
// @vitest-environment happy-dom
import { render, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
const pdfViewerMock = vi.hoisted(() => {
const pdfDocument = {
destroy: vi.fn(),
numPages: 1,
};
const loadingTask = {
destroy: vi.fn(),
promise: Promise.resolve(pdfDocument),
};
return {
constructorOptions: [] as Array<{ abortSignal?: AbortSignal; container: HTMLDivElement }>,
createResumePdfBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
getDocument: vi.fn(() => loadingTask),
instances: [] as Array<{
abortSignal?: AbortSignal;
setDocument: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
}>,
loadingTask,
pdfDocument,
};
});
vi.mock("@/libs/resume/pdf-document", () => ({
createResumePdfBlob: pdfViewerMock.createResumePdfBlob,
}));
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({
AnnotationMode: { ENABLE_FORMS: 2 },
getDocument: pdfViewerMock.getDocument,
GlobalWorkerOptions: {},
}));
vi.mock("pdfjs-dist/legacy/web/pdf_viewer.mjs", () => {
class EventBus {
private readonly listeners = new Map<string, Set<() => void>>();
on(name: string, listener: () => void) {
const listeners = this.listeners.get(name) ?? new Set<() => void>();
listeners.add(listener);
this.listeners.set(name, listeners);
}
off(name: string, listener: () => void) {
this.listeners.get(name)?.delete(listener);
}
dispatch(name: string) {
for (const listener of this.listeners.get(name) ?? []) listener();
}
}
class PDFLinkService {
setDocument = vi.fn();
setViewer = vi.fn();
}
class PDFViewer {
currentScaleValue = "";
setDocument: ReturnType<typeof vi.fn>;
update = vi.fn();
constructor(options: { abortSignal?: AbortSignal; container: HTMLDivElement; eventBus: EventBus }) {
pdfViewerMock.constructorOptions.push(options);
this.setDocument = vi.fn((document: unknown) => {
if (document) options.eventBus.dispatch("pagesinit");
});
pdfViewerMock.instances.push(this);
}
}
return {
EventBus,
LinkTarget: { BLANK: 2 },
PDFLinkService,
PDFViewer,
};
});
const { PdfViewer } = await import("./pdf-viewer");
beforeEach(() => {
pdfViewerMock.constructorOptions.length = 0;
pdfViewerMock.instances.length = 0;
pdfViewerMock.createResumePdfBlob.mockClear();
pdfViewerMock.getDocument.mockClear();
pdfViewerMock.loadingTask.destroy.mockClear();
pdfViewerMock.pdfDocument.destroy.mockClear();
});
describe("PdfViewer", () => {
it("uses PDF.js' absolute container contract and disposes viewer state", async () => {
const view = render(<PdfViewer data={sampleResumeData} />);
await waitFor(() => expect(pdfViewerMock.constructorOptions).toHaveLength(1));
const [{ abortSignal, container }] = pdfViewerMock.constructorOptions;
const [viewer] = pdfViewerMock.instances;
expect(container).toHaveClass("absolute", "inset-0", "overflow-visible");
expect(abortSignal).toBeInstanceOf(AbortSignal);
expect(viewer.setDocument).toHaveBeenCalledWith(pdfViewerMock.pdfDocument);
view.unmount();
expect(abortSignal?.aborted).toBe(true);
expect(viewer.setDocument).toHaveBeenCalledWith(null);
expect(pdfViewerMock.pdfDocument.destroy).toHaveBeenCalledTimes(1);
expect(pdfViewerMock.loadingTask.destroy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,190 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { PDFDocumentLoadingTask, PDFDocumentProxy } from "pdfjs-dist";
import { AnnotationMode, GlobalWorkerOptions, getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { EventBus, LinkTarget, PDFLinkService, PDFViewer } from "pdfjs-dist/legacy/web/pdf_viewer.mjs";
import { useEffect, useRef, useState } from "react";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
import "pdfjs-dist/legacy/web/pdf_viewer.css";
import "./pdf-viewer.css";
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url).toString();
type PdfViewerProps = {
className?: string;
data: ResumeData;
};
type PdfViewerOptions = ConstructorParameters<typeof PDFViewer>[0] & {
abortSignal: AbortSignal;
};
const clearPdfViewerDocument = (pdfViewer: PDFViewer) => {
(pdfViewer.setDocument as (document: PDFDocumentProxy | null) => void)(null);
};
export function PdfViewer({ className, data }: PdfViewerProps) {
const rootRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<HTMLDivElement>(null);
const [file, setFile] = useState<Blob | null>(null);
const [error, setError] = useState(false);
const [isReady, setIsReady] = useState(false);
const [viewerHeight, setViewerHeight] = useState<number | null>(null);
useEffect(() => {
let isCancelled = false;
setError(false);
setFile(null);
setIsReady(false);
setViewerHeight(null);
void createResumePdfBlob(data)
.then((blob) => {
if (!isCancelled) setFile(blob);
})
.catch((error: unknown) => {
if (!isCancelled) {
console.error("Failed to generate public resume PDF", error);
setError(true);
}
});
return () => {
isCancelled = true;
};
}, [data]);
useEffect(() => {
const root = rootRef.current;
const container = containerRef.current;
const viewer = viewerRef.current;
if (!file || !root || !container || !viewer) return;
let isCancelled = false;
let animationFrameId = 0;
let resizeObserver: ResizeObserver | undefined;
const abortController = new AbortController();
let loadingTask: PDFDocumentLoadingTask | undefined;
let pdfDocument: PDFDocumentProxy | undefined;
let pdfViewer: PDFViewer | undefined;
const eventBus = new EventBus();
const linkService = new PDFLinkService({
eventBus,
externalLinkTarget: LinkTarget.BLANK,
externalLinkRel: "noreferrer",
});
const syncViewerHeight = () => {
if (isCancelled) return;
window.cancelAnimationFrame(animationFrameId);
animationFrameId = window.requestAnimationFrame(() => {
if (isCancelled) return;
const nextHeight = Math.ceil(viewer.scrollHeight);
if (nextHeight > 0) setViewerHeight(nextHeight);
pdfViewer?.update();
});
};
const setInitialScale = () => {
if (!isCancelled && pdfViewer) {
pdfViewer.currentScaleValue = "page-width";
syncViewerHeight();
}
};
eventBus.on("pagesinit", setInitialScale);
eventBus.on("pagesloaded", syncViewerHeight);
eventBus.on("pagerendered", syncViewerHeight);
viewer.replaceChildren();
setError(false);
setIsReady(false);
setViewerHeight(null);
resizeObserver = new ResizeObserver(syncViewerHeight);
resizeObserver.observe(viewer);
const loadDocument = async () => {
const arrayBuffer = await file.arrayBuffer();
if (isCancelled) return;
loadingTask = getDocument({
data: new Uint8Array(arrayBuffer),
docBaseUrl: window.location.href,
});
const nextDocument = await loadingTask.promise;
if (isCancelled) {
void nextDocument.destroy();
return;
}
pdfDocument = nextDocument;
const pdfViewerOptions = {
annotationMode: AnnotationMode.ENABLE_FORMS,
container,
eventBus,
linkService,
removePageBorders: true,
abortSignal: abortController.signal,
viewer,
} satisfies PdfViewerOptions;
pdfViewer = new PDFViewer(pdfViewerOptions);
linkService.setViewer(pdfViewer);
pdfViewer.setDocument(pdfDocument);
linkService.setDocument(pdfDocument);
syncViewerHeight();
setIsReady(true);
};
void loadDocument().catch((error: unknown) => {
if (!isCancelled) {
console.error("Failed to render public resume PDF with PDF.js", error);
setError(true);
}
});
return () => {
isCancelled = true;
eventBus.off("pagesinit", setInitialScale);
eventBus.off("pagesloaded", syncViewerHeight);
eventBus.off("pagerendered", syncViewerHeight);
abortController.abort();
window.cancelAnimationFrame(animationFrameId);
resizeObserver?.disconnect();
if (pdfViewer) clearPdfViewerDocument(pdfViewer);
void pdfDocument?.destroy();
void loadingTask?.destroy();
viewer.replaceChildren();
};
}, [file]);
return (
<div
ref={rootRef}
className={cn("pdf-viewer relative bg-neutral-100", viewerHeight ? "min-h-0" : "min-h-48", className)}
style={viewerHeight ? { height: viewerHeight } : undefined}
>
<div ref={containerRef} className="absolute inset-0 overflow-visible">
<div ref={viewerRef} className="pdfViewer" />
</div>
{error ? (
<div className="absolute inset-0 flex items-center justify-center bg-background px-6 text-center text-muted-foreground text-sm">
Unable to display PDF preview.
</div>
) : isReady ? null : (
<div className="absolute inset-0 flex items-center justify-center bg-background">
<Spinner className="size-6" />
</div>
)}
</div>
);
}
@@ -0,0 +1,97 @@
// @vitest-environment happy-dom
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { ReactNode } from "react";
import { render, screen } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
const publicResumeMock = vi.hoisted(() => ({
createResumePdfBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
downloadWithAnchor: vi.fn(),
generateFilename: vi.fn((name: string, extension: string) => `${name}.${extension}`),
PdfViewer: vi.fn<(_props: { className?: string; data: ResumeData }) => ReactNode>(() => null),
resume: undefined as
| undefined
| {
data: ResumeData;
name: string;
slug: string;
},
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: publicResumeMock.resume }),
}));
vi.mock("@tanstack/react-router", () => ({
getRouteApi: () => ({
useParams: () => ({ username: "amruth", slug: "sample" }),
}),
}));
vi.mock("@reactive-resume/utils/file", () => ({
downloadWithAnchor: publicResumeMock.downloadWithAnchor,
generateFilename: publicResumeMock.generateFilename,
}));
vi.mock("./pdf-viewer", () => ({
PdfViewer: publicResumeMock.PdfViewer,
}));
vi.mock("@/libs/orpc/client", () => ({
orpc: { resume: { getBySlug: { queryOptions: () => ({}) } } },
}));
vi.mock("@/libs/resume/pdf-document", () => ({
createResumePdfBlob: publicResumeMock.createResumePdfBlob,
}));
const { PublicResumeRoute } = await import("./public-resume");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
beforeEach(() => {
publicResumeMock.resume = {
data: sampleResumeData,
name: "Sample Resume",
slug: "sample",
};
publicResumeMock.PdfViewer.mockClear();
publicResumeMock.PdfViewer.mockImplementation(({ className }) => (
<div className={className} data-testid="pdf-viewer" />
));
});
const renderPublicResumeRoute = () =>
render(
<I18nProvider i18n={i18n}>
<PublicResumeRoute />
</I18nProvider>,
);
describe("PublicResumeRoute", () => {
it("renders the public resume through the route-local PDF.js viewer", () => {
renderPublicResumeRoute();
expect(screen.getByTestId("pdf-viewer")).toHaveClass("block", "w-full");
expect(publicResumeMock.PdfViewer).toHaveBeenCalledWith(
expect.objectContaining({ data: sampleResumeData }),
undefined,
);
});
it("lets the public resume page grow to the full PDF length", () => {
renderPublicResumeRoute();
const viewerFrame = screen.getByTestId("pdf-viewer").parentElement;
const page = viewerFrame?.parentElement;
expect(page).not.toHaveClass("min-h-svh", "h-svh", "max-h-svh", "overflow-hidden");
expect(viewerFrame).not.toHaveClass("min-h-0", "flex-1", "overflow-hidden");
});
});
@@ -8,9 +8,9 @@ import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { Button } from "@reactive-resume/ui/components/button";
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
import { LoadingScreen } from "@/components/layout/loading-screen";
import { ResumePreview } from "@/components/resume/preview";
import { orpc } from "@/libs/orpc/client";
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
import { PdfViewer } from "./pdf-viewer";
const publicResumeRoute = getRouteApi("/$username/$slug");
@@ -43,14 +43,10 @@ export function PublicResumeRoute() {
return (
<>
<div className="mx-auto my-12 flex flex-col items-center gap-12 print:m-0 print:block print:max-w-full print:px-0">
<ResumePreview
data={resume.data}
pageGap="1rem"
pageScale={1.25}
pageLayout="vertical"
pageClassName="print:w-full! w-full max-w-full"
/>
<div className="mx-auto flex w-full flex-col items-center gap-6 px-4 py-6 print:m-0 print:block print:max-w-full print:p-0">
<div className="w-full max-w-5xl bg-white print:max-w-full">
<PdfViewer data={resume.data} className="block w-full" />
</div>
<footer className="flex justify-center print:hidden">
<BrandIcon variant="icon" className="size-8 opacity-60" />