From b3bd678291c46a6939f587422bbb539f8fae06c4 Mon Sep 17 00:00:00 2001 From: Git Agent Date: Mon, 24 Aug 2026 14:15:25 +0200 Subject: [PATCH] =?UTF-8?q?fix(web,api):=20lecteur=20PDF=20=E2=80=94=20opt?= =?UTF-8?q?ions=20pdf.js,=20assets=20cmaps/fonts=20et=20recherche=20sans?= =?UTF-8?q?=20limite=20par=20d=C3=A9faut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/books/books.service.test.ts | 88 ++++++++++++++++++- apps/api/src/books/books.service.ts | 27 ++++-- apps/web/package.json | 1 + apps/web/src/api/client.test.ts | 28 ++++++ apps/web/src/api/client.ts | 4 +- apps/web/src/reader/PdfReader.tsx | 18 +--- .../web/src/reader/pdfDaredevilRender.test.ts | 39 ++++++++ apps/web/src/reader/pdfDocumentOptions.ts | 55 ++++++++++++ apps/web/src/reader/readerRuntime.test.ts | 28 ++++++ apps/web/tsconfig.json | 2 +- apps/web/vite.config.ts | 67 +++++++++++++- packages/shared/src/index.test.ts | 5 +- packages/shared/src/index.ts | 2 +- pnpm-lock.yaml | 3 + 14 files changed, 336 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/reader/pdfDaredevilRender.test.ts create mode 100644 apps/web/src/reader/pdfDocumentOptions.ts diff --git a/apps/api/src/books/books.service.test.ts b/apps/api/src/books/books.service.test.ts index f17f9ba..3903156 100644 --- a/apps/api/src/books/books.service.test.ts +++ b/apps/api/src/books/books.service.test.ts @@ -1,13 +1,15 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { extname, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { BookQuerySchema } from "@readabook/shared"; import { DatabaseService } from "../database/database.service.js"; import { books, libraries, series } from "../database/schema.js"; import { BooksService } from "./books.service.js"; const previousDatabasePath = process.env.DATABASE_PATH; const previousStorageDir = process.env.STORAGE_DIR; +const realBooksPath = "/home/anthony/Documents/Projects/ReadaBook/Books"; const tempDirs: string[] = []; afterEach(() => { @@ -19,6 +21,59 @@ afterEach(() => { }); describe("BooksService", () => { + it.runIf(canLoadBetterSqlite() && canReadRealBooksCorpus())("exposes every persisted real corpus book through the default catalogue query", () => { + const database = createDatabase(); + try { + const service = new BooksService(database); + const now = database.now(); + const library = database.db + .insert(libraries) + .values({ name: "Real corpus", path: realBooksPath, enabled: true, createdAt: now, updatedAt: now }) + .returning() + .get(); + const files = realCorpusBookFiles(); + + for (const [index, filePath] of files.entries()) { + database.db + .insert(books) + .values({ + libraryId: library.id, + seriesId: null, + title: `Corpus ${String(index + 1).padStart(3, "0")}`, + author: null, + description: null, + isbn: null, + isbn13: null, + identifiersJson: null, + localMetadataJson: null, + language: null, + publisher: null, + publishedDate: null, + volumeNumber: null, + volumeLabel: null, + format: bookFormatFromPath(filePath), + filePath, + coverPath: null, + metadataStatus: "none", + metadataProvenanceJson: JSON.stringify({ title: "local" }), + scanStatus: "succeeded", + enrichmentStatus: "idle", + fileSize: statSync(filePath).size, + fileMtime: statSync(filePath).mtime.toISOString(), + createdAt: now, + updatedAt: now + }) + .run(); + } + + expect(files.length).toBeGreaterThan(50); + expect(service.count()).toBe(files.length); + expect(service.list(BookQuerySchema.parse({}))).toHaveLength(files.length); + } finally { + database.onModuleDestroy(); + } + }); + it.runIf(canLoadBetterSqlite())("exposes metadata status and parsed provenance on book API rows", () => { const database = createDatabase(); const service = new BooksService(database); @@ -178,3 +233,32 @@ function canLoadBetterSqlite(): boolean { return false; } } + +function canReadRealBooksCorpus(): boolean { + try { + return realCorpusBookFiles().length > 0; + } catch { + return false; + } +} + +function realCorpusBookFiles(root = realBooksPath): string[] { + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const path = join(root, entry.name); + if (entry.isDirectory()) return realCorpusBookFiles(path); + if (!entry.isFile()) return []; + return isBookFile(path) ? [path] : []; + }); +} + +function isBookFile(filePath: string): boolean { + return [".epub", ".pdf", ".cbz", ".cbr"].includes(extname(filePath).toLowerCase()); +} + +function bookFormatFromPath(filePath: string): "epub" | "pdf" | "cbz" | "cbr" { + const extension = extname(filePath).toLowerCase(); + if (extension === ".epub") return "epub"; + if (extension === ".cbz") return "cbz"; + if (extension === ".cbr") return "cbr"; + return "pdf"; +} diff --git a/apps/api/src/books/books.service.ts b/apps/api/src/books/books.service.ts index dd83c48..73e7571 100644 --- a/apps/api/src/books/books.service.ts +++ b/apps/api/src/books/books.service.ts @@ -20,18 +20,33 @@ export class BooksService { if (query.q) { return this.search(query.q, query.limit, query.offset); } - return this.database.db + const statement = this.database.db .select() .from(books) .where(filters.length ? and(...filters) : undefined) - .orderBy(books.title) - .limit(query.limit) - .offset(query.offset) - .all() + .orderBy(books.title); + const rows = + query.limit === undefined ? statement.all() : statement.limit(query.limit).offset(query.offset).all(); + return rows .map((book) => this.mapBookSelect(book)); } - search(q: string, limit = 50, offset = 0) { + search(q: string, limit?: number, offset = 0) { + if (limit === undefined) { + const rows = this.database.sqlite + .prepare( + ` + SELECT books.* + FROM book_fts + JOIN books ON books.id = book_fts.rowid + WHERE book_fts MATCH ? + ORDER BY bm25(book_fts) + ` + ) + .all(`${q.replace(/"/g, '""')}*`); + return (rows as Array>).map((row) => this.mapBookRow(row)); + } + const rows = this.database.sqlite .prepare( ` diff --git a/apps/web/package.json b/apps/web/package.json index a8b62e3..82154c1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/api/client.test.ts b/apps/web/src/api/client.test.ts index 09198c9..2811fbc 100644 --- a/apps/web/src/api/client.test.ts +++ b/apps/web/src/api/client.test.ts @@ -14,6 +14,34 @@ describe("api fallback helpers", () => { expect(getApiFallback(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 }), { diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index b9ba82a..1fdf1a0 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -163,10 +163,10 @@ export const api = { return request("/auth/me", { method: "PATCH", body: JSON.stringify(input) }); }, async books(query: Partial = {}): Promise { - return request(`/books${queryString({ limit: 50, offset: 0, ...query })}`, { fallback: mockBooks }); + return request(`/books${queryString(query)}`, { fallback: mockBooks }); }, async search(query: string): Promise { - return request(`/books/search${queryString({ q: query, limit: 50, offset: 0 })}`, { fallback: mockBooks }); + return request(`/books/search${queryString({ q: query })}`, { fallback: mockBooks }); }, async book(id: number): Promise { const fallback = mockBooks.find((book) => book.id === id) ?? mockBooks[0]; diff --git a/apps/web/src/reader/PdfReader.tsx b/apps/web/src/reader/PdfReader.tsx index 373f322..d062a8b 100644 --- a/apps/web/src/reader/PdfReader.tsx +++ b/apps/web/src/reader/PdfReader.tsx @@ -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) { diff --git a/apps/web/src/reader/pdfDaredevilRender.test.ts b/apps/web/src/reader/pdfDaredevilRender.test.ts new file mode 100644 index 0000000..8513b31 --- /dev/null +++ b/apps/web/src/reader/pdfDaredevilRender.test.ts @@ -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(); + } + }); +}); diff --git a/apps/web/src/reader/pdfDocumentOptions.ts b/apps/web/src/reader/pdfDocumentOptions.ts new file mode 100644 index 0000000..53594a6 --- /dev/null +++ b/apps/web/src/reader/pdfDocumentOptions.ts @@ -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 + }; +} diff --git a/apps/web/src/reader/readerRuntime.test.ts b/apps/web/src/reader/readerRuntime.test.ts index 467a858..dedbb19 100644 --- a/apps/web/src/reader/readerRuntime.test.ts +++ b/apps/web/src/reader/readerRuntime.test.ts @@ -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"); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index df33532..9bf5c74 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -6,7 +6,7 @@ "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true, - "types": ["vite/client"] + "types": ["vite/client", "node"] }, "include": ["src", "vite.config.ts"] } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 227b91a..df4ab42 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -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: { diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 5a9565b..16bc430 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -8,7 +8,8 @@ describe("shared contracts", () => { ); }); - it("normalizes catalogue query defaults", () => { - expect(BookQuerySchema.parse({}).limit).toBe(50); + it("keeps catalogue queries unbounded unless a limit is explicit", () => { + expect(BookQuerySchema.parse({}).limit).toBeUndefined(); + expect(BookQuerySchema.parse({ limit: "50" }).limit).toBe(50); }); }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 12e4753..9824d90 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -128,7 +128,7 @@ export const BookQuerySchema = z.object({ q: z.string().optional(), format: z.enum(["epub", "pdf", "cbz", "cbr"]).optional(), libraryId: z.coerce.number().int().positive().optional(), - limit: z.coerce.number().int().min(1).max(100).default(50), + limit: z.coerce.number().int().min(1).max(100).optional(), offset: z.coerce.number().int().min(0).default(0) }); export type BookQueryDto = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32882f9..daf6219 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,9 @@ importers: specifier: ^8.2.2 version: 8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(tsx@4.23.12) devDependencies: + '@napi-rs/canvas': + specifier: 1.0.7 + version: 1.0.7 '@types/react': specifier: ^19.2.18 version: 19.2.18