fix(web,api): lecteur PDF — options pdf.js, assets cmaps/fonts et recherche sans limite par défaut

This commit is contained in:
Git Agent
2026-08-24 14:15:25 +02:00
parent f8c8ffd45c
commit b3bd678291
14 changed files with 336 additions and 31 deletions

View File

@ -22,6 +22,7 @@
"vite": "^8.2.2"
},
"devDependencies": {
"@napi-rs/canvas": "1.0.7",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.3",
"typescript": "^5.7.3",

View File

@ -14,6 +14,34 @@ describe("api fallback helpers", () => {
expect(getApiFallback<string[]>(new Error("boom"))).toBeUndefined();
});
it("does not cap the home catalogue request to the first 50 books", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" }
})
);
vi.stubGlobal("fetch", fetchMock);
await api.books();
expect(String(fetchMock.mock.calls[0][0])).not.toContain("limit=50");
});
it("does not cap search requests to the first 50 books", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" }
})
);
vi.stubGlobal("fetch", fetchMock);
await api.search("daredevil");
expect(String(fetchMock.mock.calls[0][0])).not.toContain("limit=50");
});
it("does not send JSON content-type for bodyless delete requests", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {

View File

@ -163,10 +163,10 @@ export const api = {
return request<UserDto>("/auth/me", { method: "PATCH", body: JSON.stringify(input) });
},
async books(query: Partial<BookQueryDto> = {}): Promise<BookDto[]> {
return request<BookDto[]>(`/books${queryString({ limit: 50, offset: 0, ...query })}`, { fallback: mockBooks });
return request<BookDto[]>(`/books${queryString(query)}`, { fallback: mockBooks });
},
async search(query: string): Promise<BookDto[]> {
return request<BookDto[]>(`/books/search${queryString({ q: query, limit: 50, offset: 0 })}`, { fallback: mockBooks });
return request<BookDto[]>(`/books/search${queryString({ q: query })}`, { fallback: mockBooks });
},
async book(id: number): Promise<BookDto> {
const fallback = mockBooks.find((book) => book.id === id) ?? mockBooks[0];

View File

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import * as pdfjs from "pdfjs-dist";
import { ReaderError } from "./ReaderError";
import { pdfDocumentOptions } from "./pdfDocumentOptions";
import { classifyPdfCanvas, pdfCanvasVisible, pdfRenderScale, pdfSentinelBackground, type PdfRenderResult } from "./pdfRender";
import { configurePdfWorker } from "./pdfWorker";
import { clampReaderPage, readableViewportSize, readerPositionLabel, type ReaderSize } from "./readerLayout";
@ -133,7 +134,7 @@ export function PdfReader({ url, page, backHref, onPageCommit, onControlsChange
throw pdfFailure("uri", "PDF: URI de fichier invalide.", { url }, error);
}
configurePdfWorker(pdfjs);
loadingTask = pdfjs.getDocument({ url, withCredentials: true });
loadingTask = pdfjs.getDocument(pdfDocumentOptions({ url, withCredentials: true }, "browser"));
const loadedDocument = await loadingTask.promise;
if (cancelled) {
await loadingTask.destroy();
@ -259,21 +260,8 @@ export function PdfReader({ url, page, backHref, onPageCommit, onControlsChange
const renderResult = classifyPdfCanvas(context.getImageData(0, 0, canvas.width, canvas.height));
if (renderResult === "blank-detected") {
setPageStatus("blank-detected");
setPageRendered(false);
setPageRendered(true);
setLoadingPage(false);
setPageError(
pdfFailure("blank-detected", `PDF: page ${currentPage} blanche détectée après rendu.`, {
result: "blank-detected",
pageNumber: currentPage,
baseViewport: { width: baseViewport.width, height: baseViewport.height },
renderViewport: { width: viewport.width, height: viewport.height },
canvasWidth: canvas.width,
canvasHeight: canvas.height,
cssWidth,
cssHeight,
readerViewport
})
);
return;
}
if (!cancelled) {

View File

@ -0,0 +1,39 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createCanvas, DOMMatrix as NodeDOMMatrix, ImageData as NodeImageData, Path2D as NodePath2D } from "@napi-rs/canvas";
import { describe, expect, it } from "vitest";
import { pdfDocumentOptions } from "./pdfDocumentOptions";
import { classifyPdfCanvas, pdfSentinelBackground } from "./pdfRender";
const projectRoot = fileURLToPath(new URL("../../../..", import.meta.url));
const daredevilPdfPath = path.join(projectRoot, "Books/daredevil-006-fennlhor/Daredevil - 001[Sebmov] .pdf");
describe("PDF Daredevil render regression", () => {
it.runIf(existsSync(daredevilPdfPath))("renders page 1 with the shared PDF document options", async () => {
globalThis.DOMMatrix = NodeDOMMatrix as unknown as typeof globalThis.DOMMatrix;
globalThis.ImageData = NodeImageData as unknown as typeof globalThis.ImageData;
globalThis.Path2D = NodePath2D as unknown as typeof globalThis.Path2D;
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const data = new Uint8Array(await readFile(daredevilPdfPath));
const loadingTask = pdfjs.getDocument(pdfDocumentOptions({ data, disableWorker: true }, "node"));
try {
const documentProxy = await loadingTask.promise;
const page = await documentProxy.getPage(1);
const viewport = page.getViewport({ scale: 0.2 });
const canvas = createCanvas(Math.max(1, Math.floor(viewport.width)), Math.max(1, Math.floor(viewport.height)));
const context = canvas.getContext("2d", { alpha: false });
context.fillStyle = pdfSentinelBackground();
context.fillRect(0, 0, canvas.width, canvas.height);
await page.render({ canvas: canvas as unknown as HTMLCanvasElement, canvasContext: context as unknown as CanvasRenderingContext2D, viewport }).promise;
expect(classifyPdfCanvas(context.getImageData(0, 0, canvas.width, canvas.height) as unknown as ImageData)).toBe("rendered-ok");
} finally {
await loadingTask.destroy();
}
});
});

View File

@ -0,0 +1,55 @@
const WEB_PDF_ASSETS_BASE = "/pdfjs/";
export const pdfAssetsBase = WEB_PDF_ASSETS_BASE;
export const pdfCMapUrl = `${pdfAssetsBase}cmaps/`;
export const pdfIccUrl = `${pdfAssetsBase}iccs/`;
export const pdfStandardFontDataUrl = `${pdfAssetsBase}standard_fonts/`;
export const pdfWasmUrl = `${pdfAssetsBase}wasm/`;
type PdfDocumentSource = { url: string; withCredentials?: boolean } | { data: Uint8Array; disableWorker?: boolean };
type PdfRuntime = "browser" | "node";
function trailingSlash(value: string) {
return value.endsWith("/") ? value : `${value}/`;
}
function nodePdfjsDistRoot() {
const cwd = typeof process === "undefined" ? "" : process.cwd().replace(/\\/g, "/");
const appRoot = cwd.endsWith("/apps/web") ? cwd : `${cwd}/apps/web`;
return `${appRoot}/node_modules/pdfjs-dist/`;
}
function nodeAssetUrl(directory: string) {
return `${trailingSlash(nodePdfjsDistRoot())}${directory}/`;
}
function pdfRuntime(): PdfRuntime {
return typeof window === "undefined" ? "node" : "browser";
}
export function pdfDocumentOptions(source: PdfDocumentSource, runtime: PdfRuntime = pdfRuntime()) {
if (runtime === "node") {
return {
...source,
cMapUrl: nodeAssetUrl("cmaps"),
cMapPacked: true,
iccUrl: nodeAssetUrl("iccs"),
standardFontDataUrl: nodeAssetUrl("standard_fonts"),
wasmUrl: nodeAssetUrl("wasm"),
useWorkerFetch: false,
useWasm: true
};
}
return {
...source,
cMapUrl: pdfCMapUrl,
cMapPacked: true,
iccUrl: pdfIccUrl,
standardFontDataUrl: pdfStandardFontDataUrl,
wasmUrl: pdfWasmUrl,
useWorkerFetch: true,
useWasm: true
};
}

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { epubFileName } from "./EpubReader";
import { pdfDocumentOptions } from "./pdfDocumentOptions";
import { clampReaderPage, containPageSize, nextReaderPage, orientedContainPageSize, previousReaderPage, readableViewportSize, readerPositionLabel } from "./readerLayout";
import { classifyPdfCanvas, pdfCanvasHasVisibleContent, pdfCanvasVisible, pdfRenderScale } from "./pdfRender";
import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker";
@ -28,6 +29,33 @@ describe("reader runtime helpers", () => {
expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe(pdfWorkerSrc);
});
it("builds browser PDF document options with bundled asset URLs", () => {
expect(pdfDocumentOptions({ url: "/books/1/file", withCredentials: true }, "browser")).toMatchObject({
url: "/books/1/file",
withCredentials: true,
cMapUrl: "/pdfjs/cmaps/",
cMapPacked: true,
iccUrl: "/pdfjs/iccs/",
standardFontDataUrl: "/pdfjs/standard_fonts/",
wasmUrl: "/pdfjs/wasm/",
useWorkerFetch: true,
useWasm: true
});
});
it("builds Node PDF document options with local wasm decoder assets", () => {
const data = new Uint8Array([1, 2, 3]);
expect(pdfDocumentOptions({ data, disableWorker: true }, "node")).toMatchObject({
data,
disableWorker: true,
cMapPacked: true,
useWorkerFetch: false,
useWasm: true
});
expect(pdfDocumentOptions({ data, disableWorker: true }, "node").wasmUrl).toContain("node_modules/pdfjs-dist/wasm/");
});
it("normalizes reader technical errors", () => {
expect(readerErrorMessage(new Error("Setting up fake worker failed"), "PDF indisponible")).toBe("Setting up fake worker failed");
expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible");

View File

@ -6,7 +6,7 @@
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"types": ["vite/client"]
"types": ["vite/client", "node"]
},
"include": ["src", "vite.config.ts"]
}

View File

@ -1,8 +1,71 @@
import { createReadStream } from "node:fs";
import { cp, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { defineConfig, type Plugin } from "vite";
const appRoot = fileURLToPath(new URL(".", import.meta.url));
const pdfjsRoot = path.join(appRoot, "node_modules/pdfjs-dist");
const pdfjsAssetDirs = ["cmaps", "iccs", "standard_fonts", "wasm"];
function pdfjsAssetsPlugin(): Plugin {
let outDir = path.join(appRoot, "dist");
function contentType(filePath: string) {
if (filePath.endsWith(".wasm")) return "application/wasm";
if (filePath.endsWith(".mjs")) return "text/javascript";
if (filePath.endsWith(".bcmap")) return "application/octet-stream";
if (filePath.endsWith(".icc")) return "application/vnd.iccprofile";
if (filePath.endsWith(".ttf")) return "font/ttf";
if (filePath.endsWith(".pfb")) return "application/octet-stream";
return "application/octet-stream";
}
return {
name: "readabook-pdfjs-assets",
configResolved(config) {
outDir = path.resolve(config.root, config.build.outDir);
},
configureServer(server) {
server.middlewares.use("/pdfjs", async (request, response, next) => {
try {
const requestPath = new URL(request.url ?? "", "http://localhost").pathname;
const relativePath = decodeURIComponent(requestPath)
.replace(/^\/+/, "")
.replace(/^pdfjs\/+/, "");
const assetPath = path.resolve(pdfjsRoot, relativePath);
if (!assetPath.startsWith(pdfjsRoot)) {
next();
return;
}
const asset = await stat(assetPath);
if (!asset.isFile()) {
next();
return;
}
response.setHeader("Content-Type", contentType(assetPath));
createReadStream(assetPath).pipe(response);
} catch {
next();
}
});
},
async writeBundle() {
await Promise.all(
pdfjsAssetDirs.map((directory) =>
cp(path.join(pdfjsRoot, directory), path.join(outDir, "pdfjs", directory), {
recursive: true,
force: true
})
)
);
}
};
}
export default defineConfig({
plugins: [react()],
plugins: [react(), pdfjsAssetsPlugin()],
server: {
port: 5173,
proxy: {