fix(web,api): lecteur PDF — options pdf.js, assets cmaps/fonts et recherche sans limite par défaut
This commit is contained in:
@ -1,13 +1,15 @@
|
|||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { extname, join } from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { BookQuerySchema } from "@readabook/shared";
|
||||||
import { DatabaseService } from "../database/database.service.js";
|
import { DatabaseService } from "../database/database.service.js";
|
||||||
import { books, libraries, series } from "../database/schema.js";
|
import { books, libraries, series } from "../database/schema.js";
|
||||||
import { BooksService } from "./books.service.js";
|
import { BooksService } from "./books.service.js";
|
||||||
|
|
||||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||||
const previousStorageDir = process.env.STORAGE_DIR;
|
const previousStorageDir = process.env.STORAGE_DIR;
|
||||||
|
const realBooksPath = "/home/anthony/Documents/Projects/ReadaBook/Books";
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -19,6 +21,59 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("BooksService", () => {
|
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", () => {
|
it.runIf(canLoadBetterSqlite())("exposes metadata status and parsed provenance on book API rows", () => {
|
||||||
const database = createDatabase();
|
const database = createDatabase();
|
||||||
const service = new BooksService(database);
|
const service = new BooksService(database);
|
||||||
@ -178,3 +233,32 @@ function canLoadBetterSqlite(): boolean {
|
|||||||
return false;
|
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";
|
||||||
|
}
|
||||||
|
|||||||
@ -20,18 +20,33 @@ export class BooksService {
|
|||||||
if (query.q) {
|
if (query.q) {
|
||||||
return this.search(query.q, query.limit, query.offset);
|
return this.search(query.q, query.limit, query.offset);
|
||||||
}
|
}
|
||||||
return this.database.db
|
const statement = this.database.db
|
||||||
.select()
|
.select()
|
||||||
.from(books)
|
.from(books)
|
||||||
.where(filters.length ? and(...filters) : undefined)
|
.where(filters.length ? and(...filters) : undefined)
|
||||||
.orderBy(books.title)
|
.orderBy(books.title);
|
||||||
.limit(query.limit)
|
const rows =
|
||||||
.offset(query.offset)
|
query.limit === undefined ? statement.all() : statement.limit(query.limit).offset(query.offset).all();
|
||||||
.all()
|
return rows
|
||||||
.map((book) => this.mapBookSelect(book));
|
.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<Record<string, unknown>>).map((row) => this.mapBookRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
const rows = this.database.sqlite
|
const rows = this.database.sqlite
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
"vite": "^8.2.2"
|
"vite": "^8.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@napi-rs/canvas": "1.0.7",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
|||||||
@ -14,6 +14,34 @@ describe("api fallback helpers", () => {
|
|||||||
expect(getApiFallback<string[]>(new Error("boom"))).toBeUndefined();
|
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 () => {
|
it("does not send JSON content-type for bodyless delete requests", async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue(
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
new Response(JSON.stringify({ ok: true }), {
|
new Response(JSON.stringify({ ok: true }), {
|
||||||
|
|||||||
@ -163,10 +163,10 @@ export const api = {
|
|||||||
return request<UserDto>("/auth/me", { method: "PATCH", body: JSON.stringify(input) });
|
return request<UserDto>("/auth/me", { method: "PATCH", body: JSON.stringify(input) });
|
||||||
},
|
},
|
||||||
async books(query: Partial<BookQueryDto> = {}): Promise<BookDto[]> {
|
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[]> {
|
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> {
|
async book(id: number): Promise<BookDto> {
|
||||||
const fallback = mockBooks.find((book) => book.id === id) ?? mockBooks[0];
|
const fallback = mockBooks.find((book) => book.id === id) ?? mockBooks[0];
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import * as pdfjs from "pdfjs-dist";
|
import * as pdfjs from "pdfjs-dist";
|
||||||
import { ReaderError } from "./ReaderError";
|
import { ReaderError } from "./ReaderError";
|
||||||
|
import { pdfDocumentOptions } from "./pdfDocumentOptions";
|
||||||
import { classifyPdfCanvas, pdfCanvasVisible, pdfRenderScale, pdfSentinelBackground, type PdfRenderResult } from "./pdfRender";
|
import { classifyPdfCanvas, pdfCanvasVisible, pdfRenderScale, pdfSentinelBackground, type PdfRenderResult } from "./pdfRender";
|
||||||
import { configurePdfWorker } from "./pdfWorker";
|
import { configurePdfWorker } from "./pdfWorker";
|
||||||
import { clampReaderPage, readableViewportSize, readerPositionLabel, type ReaderSize } from "./readerLayout";
|
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);
|
throw pdfFailure("uri", "PDF: URI de fichier invalide.", { url }, error);
|
||||||
}
|
}
|
||||||
configurePdfWorker(pdfjs);
|
configurePdfWorker(pdfjs);
|
||||||
loadingTask = pdfjs.getDocument({ url, withCredentials: true });
|
loadingTask = pdfjs.getDocument(pdfDocumentOptions({ url, withCredentials: true }, "browser"));
|
||||||
const loadedDocument = await loadingTask.promise;
|
const loadedDocument = await loadingTask.promise;
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
await loadingTask.destroy();
|
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));
|
const renderResult = classifyPdfCanvas(context.getImageData(0, 0, canvas.width, canvas.height));
|
||||||
if (renderResult === "blank-detected") {
|
if (renderResult === "blank-detected") {
|
||||||
setPageStatus("blank-detected");
|
setPageStatus("blank-detected");
|
||||||
setPageRendered(false);
|
setPageRendered(true);
|
||||||
setLoadingPage(false);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!cancelled) {
|
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 { describe, expect, it } from "vitest";
|
||||||
import { epubFileName } from "./EpubReader";
|
import { epubFileName } from "./EpubReader";
|
||||||
|
import { pdfDocumentOptions } from "./pdfDocumentOptions";
|
||||||
import { clampReaderPage, containPageSize, nextReaderPage, orientedContainPageSize, previousReaderPage, readableViewportSize, readerPositionLabel } from "./readerLayout";
|
import { clampReaderPage, containPageSize, nextReaderPage, orientedContainPageSize, previousReaderPage, readableViewportSize, readerPositionLabel } from "./readerLayout";
|
||||||
import { classifyPdfCanvas, pdfCanvasHasVisibleContent, pdfCanvasVisible, pdfRenderScale } from "./pdfRender";
|
import { classifyPdfCanvas, pdfCanvasHasVisibleContent, pdfCanvasVisible, pdfRenderScale } from "./pdfRender";
|
||||||
import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker";
|
import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker";
|
||||||
@ -28,6 +29,33 @@ describe("reader runtime helpers", () => {
|
|||||||
expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe(pdfWorkerSrc);
|
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", () => {
|
it("normalizes reader technical errors", () => {
|
||||||
expect(readerErrorMessage(new Error("Setting up fake worker failed"), "PDF indisponible")).toBe("Setting up fake worker failed");
|
expect(readerErrorMessage(new Error("Setting up fake worker failed"), "PDF indisponible")).toBe("Setting up fake worker failed");
|
||||||
expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible");
|
expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible");
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["vite/client"]
|
"types": ["vite/client", "node"]
|
||||||
},
|
},
|
||||||
"include": ["src", "vite.config.ts"]
|
"include": ["src", "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 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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), pdfjsAssetsPlugin()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
@ -8,7 +8,8 @@ describe("shared contracts", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes catalogue query defaults", () => {
|
it("keeps catalogue queries unbounded unless a limit is explicit", () => {
|
||||||
expect(BookQuerySchema.parse({}).limit).toBe(50);
|
expect(BookQuerySchema.parse({}).limit).toBeUndefined();
|
||||||
|
expect(BookQuerySchema.parse({ limit: "50" }).limit).toBe(50);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -128,7 +128,7 @@ export const BookQuerySchema = z.object({
|
|||||||
q: z.string().optional(),
|
q: z.string().optional(),
|
||||||
format: z.enum(["epub", "pdf", "cbz", "cbr"]).optional(),
|
format: z.enum(["epub", "pdf", "cbz", "cbr"]).optional(),
|
||||||
libraryId: z.coerce.number().int().positive().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)
|
offset: z.coerce.number().int().min(0).default(0)
|
||||||
});
|
});
|
||||||
export type BookQueryDto = z.infer<typeof BookQuerySchema>;
|
export type BookQueryDto = z.infer<typeof BookQuerySchema>;
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -115,6 +115,9 @@ importers:
|
|||||||
specifier: ^8.2.2
|
specifier: ^8.2.2
|
||||||
version: 8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(tsx@4.23.12)
|
version: 8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(tsx@4.23.12)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@napi-rs/canvas':
|
||||||
|
specifier: 1.0.7
|
||||||
|
version: 1.0.7
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.2.18
|
specifier: ^19.2.18
|
||||||
version: 19.2.18
|
version: 19.2.18
|
||||||
|
|||||||
Reference in New Issue
Block a user