fix(web,api): lecteur PDF — options pdf.js, assets cmaps/fonts et recherche sans limite par défaut
This commit is contained in:
@ -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 }), {
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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) {
|
||||
|
||||
39
apps/web/src/reader/pdfDaredevilRender.test.ts
Normal file
39
apps/web/src/reader/pdfDaredevilRender.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
55
apps/web/src/reader/pdfDocumentOptions.ts
Normal file
55
apps/web/src/reader/pdfDocumentOptions.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@ -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");
|
||||
|
||||
Reference in New Issue
Block a user