From 84e789acc492a683e09c2fffd9da7238a64db8e8 Mon Sep 17 00:00:00 2001 From: Git Agent Date: Mon, 24 Aug 2026 09:11:25 +0200 Subject: [PATCH] =?UTF-8?q?fix(web):=20lecteur=20PDF/CBZ=20=E2=80=94=20pag?= =?UTF-8?q?es=20blanches,=20timeouts=20et=20modes=20fullscreen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rendu PDF via module dédié pdfRender (worker), correction des pages blanches et gestion des timeouts de rendu - Lecteur comics : crop plein écran, locators de page unifiés (pdf/cbz/cbr) avec ancrage pour le calcul de progression - Sauvegarde de progression mise en file (queueSave) pour éviter les pertes lors des navigations rapides - nginx/vite : en-têtes et proxy alignés pour le streaming des livres Co-Authored-By: Claude Opus 4.8 --- apps/web/nginx/default.conf | 6 + apps/web/src/pages/ReaderPage.tsx | 22 +- apps/web/src/reader/CbzReader.tsx | 114 +++++++-- apps/web/src/reader/PdfReader.tsx | 275 +++++++++++++++++----- apps/web/src/reader/ReaderShell.tsx | 59 ++++- apps/web/src/reader/locators.test.ts | 11 +- apps/web/src/reader/locators.ts | 23 +- apps/web/src/reader/pdfRender.ts | 34 +++ apps/web/src/reader/readerRuntime.test.ts | 11 + apps/web/src/reader/useReaderProgress.ts | 42 +++- apps/web/vite.config.ts | 1 + 11 files changed, 494 insertions(+), 104 deletions(-) create mode 100644 apps/web/src/reader/pdfRender.ts diff --git a/apps/web/nginx/default.conf b/apps/web/nginx/default.conf index 4061245..8b917b9 100644 --- a/apps/web/nginx/default.conf +++ b/apps/web/nginx/default.conf @@ -40,6 +40,12 @@ server { proxy_set_header X-Real-IP $remote_addr; } + location ^~ /series { + proxy_pass http://api:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + location ^~ /progress { proxy_pass http://api:3000; proxy_set_header Host $host; diff --git a/apps/web/src/pages/ReaderPage.tsx b/apps/web/src/pages/ReaderPage.tsx index 4cd025d..64d7455 100644 --- a/apps/web/src/pages/ReaderPage.tsx +++ b/apps/web/src/pages/ReaderPage.tsx @@ -3,7 +3,7 @@ import type { BookDto } from "@readabook/shared"; import { api, getApiFallback } from "../api/client"; import { CbzReader } from "../reader/CbzReader"; import { EpubReader } from "../reader/EpubReader"; -import { parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators"; +import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators"; import { PdfReader } from "../reader/PdfReader"; import { ReaderShell, type ReaderControls } from "../reader/ReaderShell"; import { useReaderPreferences } from "../reader/useReaderPreferences"; @@ -24,7 +24,7 @@ export function ReaderPage({ bookId }: { bookId: number }) { const [page, setPage] = useState(1); const [readerControls, setReaderControls] = useState(idleControls); const [controlsVisible, setControlsVisible] = useState(true); - const { progress, saving, error: progressError, save } = useReaderProgress(bookId); + const { progress, saving, error: progressError, save, queueSave } = useReaderProgress(bookId); const { preferences, setMode, error: preferencesError } = useReaderPreferences(bookId); async function loadBook() { @@ -58,20 +58,26 @@ export function ReaderPage({ bookId }: { bookId: number }) { const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]); const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]); const savePdfPage = useCallback( - (nextPage: number, pages: number) => { + (nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => { setPage(nextPage); - void save(`pdf:page:${nextPage}`, pdfPagePercent(nextPage, pages)); + const locator = pageLocator("pdf", nextPage, anchor); + const percent = pdfPagePercent(nextPage, pages, anchor); + if (strategy === "queued") queueSave(locator, percent); + else void save(locator, percent); }, - [save] + [queueSave, save] ); const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]); const saveComicPage = useCallback( - (nextPage: number, pages: number) => { + (nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => { setPage(nextPage); const prefix = book?.format === "cbr" ? "cbr" : "cbz"; - void save(`${prefix}:page:${nextPage}`, pdfPagePercent(nextPage, pages)); + const locator = pageLocator(prefix, nextPage, anchor); + const percent = pdfPagePercent(nextPage, pages, anchor); + if (strategy === "queued") queueSave(locator, percent); + else void save(locator, percent); }, - [book?.format, save] + [book?.format, queueSave, save] ); const readerError = error ?? progressError ?? preferencesError; diff --git a/apps/web/src/reader/CbzReader.tsx b/apps/web/src/reader/CbzReader.tsx index 4117b7c..c845b6c 100644 --- a/apps/web/src/reader/CbzReader.tsx +++ b/apps/web/src/reader/CbzReader.tsx @@ -1,8 +1,14 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "../api/client"; import type { CbzPagesDto, ReaderMode } from "../api/types"; import type { ReaderControls } from "./ReaderShell"; +type PageCommitStrategy = "immediate" | "queued"; + +function scrollContainerFor(element: HTMLElement | null): HTMLElement | null { + return element?.closest(".reader-stage") as HTMLElement | null; +} + export function CbzReader({ bookId, page, @@ -13,22 +19,27 @@ export function CbzReader({ bookId: number; page: number; mode: ReaderMode; - onPageCommit: (page: number, pages: number) => void; + onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void; onControlsChange: (controls: ReaderControls) => void; }) { + const frameRef = useRef(null); + const restoredRef = useRef(false); const [pages, setPages] = useState(null); const [error, setError] = useState(); const [imageError, setImageError] = useState(false); + const [visiblePage, setVisiblePage] = useState(page); useEffect(() => { let alive = true; setError(undefined); + setImageError(false); + restoredRef.current = false; api .cbzPages(bookId) .then((nextPages) => { if (!alive) return; setPages(nextPages); - if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount); + if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount, 1, "immediate"); }) .catch(() => { if (alive) setError("Archive CBZ indisponible."); @@ -40,34 +51,103 @@ export function CbzReader({ const pageCount = pages?.pageCount ?? 1; const currentPage = Math.max(1, Math.min(page, pageCount)); + const displayedPage = mode === "vertical" ? Math.max(1, Math.min(visiblePage, pageCount)) : currentPage; const currentName = pages?.pages.find((item) => item.page === currentPage)?.name; - const go = useCallback((nextPage: number) => { - setImageError(false); - onPageCommit(Math.max(1, Math.min(nextPage, pageCount)), pageCount); - }, [onPageCommit, pageCount]); + const go = useCallback( + (nextPage: number) => { + const target = Math.max(1, Math.min(nextPage, pageCount)); + setImageError(false); + if (mode === "vertical") { + frameRef.current?.querySelector(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" }); + setVisiblePage(target); + } + onPageCommit(target, pageCount, mode === "vertical" ? 0 : 1, "immediate"); + }, + [mode, onPageCommit, pageCount] + ); + + const goTop = useCallback(() => go(1), [go]); useEffect(() => { onControlsChange({ - canPrevious: !error && !imageError && currentPage > 1, - canNext: !error && !imageError && currentPage < pageCount, - positionLabel: pages ? `${currentPage} / ${pageCount}` : "Ouverture archive", - onPrevious: () => go(currentPage - 1), - onNext: () => go(currentPage + 1) + canPrevious: !error && !imageError && displayedPage > 1, + canNext: !error && !imageError && displayedPage < pageCount, + canTop: !error && !imageError && displayedPage > 1, + positionLabel: pages ? `${displayedPage} / ${pageCount}` : "Ouverture archive", + onPrevious: () => go(displayedPage - 1), + onNext: () => go(displayedPage + 1), + onTop: goTop }); - }, [currentPage, error, go, imageError, onControlsChange, pageCount, pages]); + }, [displayedPage, error, go, goTop, imageError, onControlsChange, pageCount, pages]); - return ( -
- {error || imageError ? ( + useEffect(() => { + if (mode !== "vertical") return; + const root = scrollContainerFor(frameRef.current); + if (!root || !pages) return; + const handleScroll = () => { + const rootRect = root.getBoundingClientRect(); + const frames = Array.from(frameRef.current?.querySelectorAll("[data-reader-page]") ?? []); + const active = frames + .map((element) => { + const rect = element.getBoundingClientRect(); + const overlap = Math.min(rootRect.bottom, rect.bottom) - Math.max(rootRect.top, rect.top); + const anchor = Math.max(0, Math.min(1, (rootRect.top - rect.top) / Math.max(1, rect.height))); + return { page: Number(element.dataset.readerPage), overlap, anchor }; + }) + .filter((item) => Number.isInteger(item.page) && item.overlap > 0) + .sort((left, right) => right.overlap - left.overlap)[0]; + if (!active) return; + setVisiblePage(active.page); + onPageCommit(active.page, pageCount, active.anchor, "queued"); + }; + root.addEventListener("scroll", handleScroll, { passive: true }); + handleScroll(); + return () => root.removeEventListener("scroll", handleScroll); + }, [mode, onPageCommit, pageCount, pages]); + + useEffect(() => { + if (mode !== "vertical" || !pages || restoredRef.current) return; + restoredRef.current = true; + requestAnimationFrame(() => { + frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" }); + setVisiblePage(currentPage); + }); + }, [currentPage, mode, pages]); + + if (error || imageError) { + return ( +
{error ?? "Page CBZ indisponible."}
+
+ ); + } + + return ( +
+ {mode === "vertical" && pages ? ( +
+ {pages.pages.map((item) => ( +
+ {item.name setImageError(true)} + /> +
+ ))} +
) : ( - {currentName setImageError(true)} /> +
+ {currentName setImageError(true)} /> +
)}
); diff --git a/apps/web/src/reader/PdfReader.tsx b/apps/web/src/reader/PdfReader.tsx index 9cb4c70..06683a1 100644 --- a/apps/web/src/reader/PdfReader.tsx +++ b/apps/web/src/reader/PdfReader.tsx @@ -1,47 +1,177 @@ import { useCallback, useEffect, useRef, useState } from "react"; import * as pdfjs from "pdfjs-dist"; -import { ReaderError, readerErrorMessage } from "./ReaderError"; +import { ReaderError } from "./ReaderError"; +import { pdfCanvasHasVisibleInk, pdfRenderScale } from "./pdfRender"; import { configurePdfWorker } from "./pdfWorker"; import type { ReaderControls } from "./ReaderShell"; import type { ReaderMode } from "../api/types"; configurePdfWorker(pdfjs); -export function PdfReader({ - url, - page, - backHref, - mode, - onPageCommit, - onControlsChange -}: { +type PageCommitStrategy = "immediate" | "queued"; + +type PdfReaderProps = { url: string; page: number; backHref: string; mode: ReaderMode; - onPageCommit: (page: number, pages: number) => void; + onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void; onControlsChange: (controls: ReaderControls) => void; -}) { +}; + +type PdfPageCanvasProps = { + document: pdfjs.PDFDocumentProxy; + pageNumber: number; + mode: ReaderMode; + frameSize: { width: number; height: number }; + onError: (message: string) => void; +}; + +let pdfLifecycle = Promise.resolve(); + +function enqueuePdfLifecycle(work: () => Promise) { + const run = pdfLifecycle.then(work, work); + pdfLifecycle = run.then(() => undefined, () => undefined); + return run; +} + +function isPdfTransitionError(error: unknown) { + return error instanceof Error && (error.message.includes("worker is being destroyed") || error.message.includes("Rendering cancelled")); +} + +function pdfReaderErrorMessage(error: unknown) { + if (isPdfTransitionError(error)) return "Le lecteur PDF termine une ouverture precedente."; + return "Le PDF n'a pas pu s'ouvrir."; +} + +function scrollContainerFor(element: HTMLElement | null): HTMLElement | null { + return element?.closest(".reader-stage") as HTMLElement | null; +} + +function PdfPageCanvas({ document, pageNumber, mode, frameSize, onError }: PdfPageCanvasProps) { const canvasRef = useRef(null); + const wrapperRef = useRef(null); + const [visible, setVisible] = useState(mode === "horizontal"); + const [rendered, setRendered] = useState(false); + + useEffect(() => { + if (mode === "horizontal") { + setVisible(true); + return; + } + const wrapper = wrapperRef.current; + if (!wrapper) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) setVisible(true); + }, + { root: scrollContainerFor(wrapper), rootMargin: "900px 0px" } + ); + observer.observe(wrapper); + return () => observer.disconnect(); + }, [mode]); + + useEffect(() => { + if (!visible) return; + let cancelled = false; + let renderTask: pdfjs.RenderTask | undefined; + setRendered(false); + + async function renderPage() { + try { + const pdfPage = await document.getPage(pageNumber); + if (cancelled) return; + const canvas = canvasRef.current; + if (!canvas) return; + const baseViewport = pdfPage.getViewport({ scale: 1 }); + const fitScale = pdfRenderScale(mode, frameSize, { width: baseViewport.width, height: baseViewport.height }); + const pixelRatio = Math.min(2, window.devicePixelRatio || 1); + const renderScale = Math.max(0.25, Math.min(3, fitScale)) * pixelRatio; + const viewport = pdfPage.getViewport({ scale: renderScale }); + const cssWidth = Math.floor(viewport.width / pixelRatio); + const cssHeight = Math.floor(viewport.height / pixelRatio); + canvas.width = Math.max(1, Math.floor(viewport.width)); + canvas.height = Math.max(1, Math.floor(viewport.height)); + canvas.style.width = `${cssWidth}px`; + canvas.style.height = `${cssHeight}px`; + const context = canvas.getContext("2d", { alpha: false }); + if (!context) return; + context.fillStyle = "#f7f0df"; + context.fillRect(0, 0, canvas.width, canvas.height); + renderTask = pdfPage.render({ canvas, canvasContext: context, viewport }); + await renderTask.promise; + if (!pdfCanvasHasVisibleInk(context.getImageData(0, 0, canvas.width, canvas.height))) { + throw new Error(`La page PDF ${pageNumber} est blanche apres rendu.`); + } + if (!cancelled) setRendered(true); + } catch (error) { + if (!cancelled && !isPdfTransitionError(error)) onError(pdfReaderErrorMessage(error)); + } + } + + void enqueuePdfLifecycle(renderPage); + return () => { + cancelled = true; + renderTask?.cancel(); + }; + }, [document, frameSize.height, frameSize.width, mode, onError, pageNumber, visible]); + + return ( +
+ {!rendered && ( +
+ Page {pageNumber} +
+ )} + +
+ ); +} + +export function PdfReader({ url, page, backHref, mode, onPageCommit, onControlsChange }: PdfReaderProps) { const frameRef = useRef(null); + const restoredRef = useRef(false); + const [documentProxy, setDocumentProxy] = useState(null); const [pages, setPages] = useState(1); + const [visiblePage, setVisiblePage] = useState(page); const [error, setError] = useState(); const [loading, setLoading] = useState(true); const [attempt, setAttempt] = useState(0); const [frameSize, setFrameSize] = useState({ width: 980, height: 900 }); const currentPage = Math.max(1, Math.min(page, pages)); - const go = useCallback((nextPage: number) => onPageCommit(Math.max(1, Math.min(nextPage, pages)), pages), [onPageCommit, pages]); + const displayedPage = mode === "vertical" ? Math.max(1, Math.min(visiblePage, pages)) : currentPage; + + const go = useCallback( + (nextPage: number) => { + const target = Math.max(1, Math.min(nextPage, pages)); + if (mode === "vertical") { + frameRef.current?.querySelector(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" }); + onPageCommit(target, pages, 0, "immediate"); + setVisiblePage(target); + } else { + onPageCommit(target, pages, 1, "immediate"); + } + }, + [mode, onPageCommit, pages] + ); + + const goTop = useCallback(() => go(1), [go]); + const handlePageError = useCallback((message: string) => { + setError(message); + }, []); useEffect(() => { onControlsChange({ - canPrevious: !loading && !error && currentPage > 1, - canNext: !loading && !error && currentPage < pages, - positionLabel: loading ? "Ouverture PDF" : `${currentPage} / ${pages}`, - onPrevious: () => go(currentPage - 1), - onNext: () => go(currentPage + 1) + canPrevious: !loading && !error && displayedPage > 1, + canNext: !loading && !error && displayedPage < pages, + canTop: !loading && !error && displayedPage > 1, + positionLabel: loading ? "Ouverture PDF" : `${displayedPage} / ${pages}`, + onPrevious: () => go(displayedPage - 1), + onNext: () => go(displayedPage + 1), + onTop: goTop }); - }, [currentPage, error, go, loading, onControlsChange, pages]); + }, [displayedPage, error, go, goTop, loading, onControlsChange, pages]); useEffect(() => { const frame = frameRef.current; @@ -59,70 +189,95 @@ export function PdfReader({ useEffect(() => { let cancelled = false; let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined; - let renderTask: pdfjs.RenderTask | undefined; - async function render() { + setLoading(true); + setError(undefined); + setDocumentProxy(null); + restoredRef.current = false; + + async function loadDocument() { try { configurePdfWorker(pdfjs); - setLoading(true); - setError(undefined); loadingTask = pdfjs.getDocument({ url, withCredentials: true }); - const document = await loadingTask.promise; - if (cancelled) return; - setPages(document.numPages); - const pdfPage = await document.getPage(Math.max(1, Math.min(page, document.numPages))); - const canvas = canvasRef.current; - if (!canvas) return; - const baseViewport = pdfPage.getViewport({ scale: 1 }); - const fitScale = - mode === "vertical" - ? frameSize.width / baseViewport.width - : Math.min(frameSize.width / baseViewport.width, frameSize.height / baseViewport.height); - const renderScale = Math.max(0.35, Math.min(3, fitScale)) * Math.min(2, window.devicePixelRatio || 1); - const viewport = pdfPage.getViewport({ scale: renderScale }); - canvas.width = Math.floor(viewport.width); - canvas.height = Math.floor(viewport.height); - canvas.style.width = `${Math.floor(viewport.width / Math.min(2, window.devicePixelRatio || 1))}px`; - canvas.style.height = `${Math.floor(viewport.height / Math.min(2, window.devicePixelRatio || 1))}px`; - const context = canvas.getContext("2d"); - if (!context) return; - renderTask = pdfPage.render({ canvas, canvasContext: context, viewport }); - await renderTask.promise; - if (!cancelled) setLoading(false); - } catch (renderError) { + const loadedDocument = await loadingTask.promise; + if (cancelled) { + await loadingTask.destroy(); + return; + } + setPages(loadedDocument.numPages); + setDocumentProxy(loadedDocument); + setLoading(false); + } catch (loadError) { + if (cancelled && isPdfTransitionError(loadError)) return; if (!cancelled) { - setError(readerErrorMessage(renderError, "PDF indisponible")); + setError(pdfReaderErrorMessage(loadError)); setLoading(false); } } } - void render(); + + void enqueuePdfLifecycle(loadDocument); return () => { cancelled = true; - renderTask?.cancel(); void loadingTask?.destroy(); }; - }, [url, page, attempt, frameSize.height, frameSize.width, mode]); + }, [attempt, url]); + + useEffect(() => { + if (mode !== "vertical") return; + const root = scrollContainerFor(frameRef.current); + if (!root || !documentProxy) return; + const handleScroll = () => { + const rootRect = root.getBoundingClientRect(); + const frames = Array.from(frameRef.current?.querySelectorAll("[data-reader-page]") ?? []); + const active = frames + .map((element) => { + const rect = element.getBoundingClientRect(); + const overlap = Math.min(rootRect.bottom, rect.bottom) - Math.max(rootRect.top, rect.top); + const anchor = Math.max(0, Math.min(1, (rootRect.top - rect.top) / Math.max(1, rect.height))); + return { page: Number(element.dataset.readerPage), overlap, anchor }; + }) + .filter((item) => Number.isInteger(item.page) && item.overlap > 0) + .sort((left, right) => right.overlap - left.overlap)[0]; + if (!active) return; + setVisiblePage(active.page); + onPageCommit(active.page, pages, active.anchor, "queued"); + }; + root.addEventListener("scroll", handleScroll, { passive: true }); + handleScroll(); + return () => root.removeEventListener("scroll", handleScroll); + }, [documentProxy, mode, onPageCommit, pages]); + + useEffect(() => { + if (mode !== "vertical" || !documentProxy || restoredRef.current) return; + restoredRef.current = true; + requestAnimationFrame(() => { + frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" }); + setVisiblePage(currentPage); + }); + }, [currentPage, documentProxy, mode]); return ( -
+
{error ? ( setAttempt((value) => value + 1)} /> + ) : loading || !documentProxy ? ( +
+ Ouverture PDF +
+ ) : mode === "vertical" ? ( +
+ {Array.from({ length: pages }, (_, index) => ( + + ))} +
) : ( - <> - {loading && ( -
- Ouverture PDF -
- )} - - + )}
); diff --git a/apps/web/src/reader/ReaderShell.tsx b/apps/web/src/reader/ReaderShell.tsx index aff8de1..0141280 100644 --- a/apps/web/src/reader/ReaderShell.tsx +++ b/apps/web/src/reader/ReaderShell.tsx @@ -1,5 +1,5 @@ -import { ArrowLeft, ArrowRight, Columns2, RotateCcw, Rows3, Save } from "lucide-react"; -import type { ReactNode } from "react"; +import { ArrowLeft, ArrowRight, ArrowUp, Columns2, Maximize2, Minimize2, RotateCcw, Rows3, Save } from "lucide-react"; +import { useCallback, useEffect, useState, type ReactNode } from "react"; import { ErrorRibbon, Meter } from "../components/ui"; import { navigate } from "../router"; import type { ReaderMode } from "../api/types"; @@ -7,9 +7,11 @@ import type { ReaderMode } from "../api/types"; export type ReaderControls = { canPrevious: boolean; canNext: boolean; + canTop?: boolean; positionLabel: string; onPrevious: () => void; onNext: () => void; + onTop?: () => void; }; type ReaderShellProps = { @@ -41,6 +43,20 @@ export function ReaderShell({ onToggleControls, children }: ReaderShellProps) { + const [isFullscreen, setIsFullscreen] = useState(false); + const toggleFullscreen = useCallback(() => { + const root = document.querySelector(".reader-page"); + if (!document.fullscreenElement) void root?.requestFullscreen(); + else void document.exitFullscreen(); + }, []); + + useEffect(() => { + const updateFullscreen = () => setIsFullscreen(Boolean(document.fullscreenElement)); + updateFullscreen(); + document.addEventListener("fullscreenchange", updateFullscreen); + return () => document.removeEventListener("fullscreenchange", updateFullscreen); + }, []); + return (
event.stopPropagation()}> @@ -54,20 +70,25 @@ export function ReaderShell({
+ {error && onRetry ? (
event.stopPropagation()}> - + {mode === "vertical" ? ( + + ) : ( + + )} {controls.positionLabel} - + {mode === "vertical" ? ( + + ) : ( + + )}
); diff --git a/apps/web/src/reader/locators.test.ts b/apps/web/src/reader/locators.test.ts index 77db26c..52c4562 100644 --- a/apps/web/src/reader/locators.test.ts +++ b/apps/web/src/reader/locators.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; -import { parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "./locators"; +import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePageAnchor, parsePdfPageLocator, pdfPagePercent } from "./locators"; describe("reader locators", () => { it("parses valid PDF page locators", () => { expect(parsePdfPageLocator("pdf:page:12")).toBe(12); + expect(parsePdfPageLocator("pdf:page:12@0.250")).toBe(12); }); it("ignores invalid PDF locators", () => { @@ -23,7 +24,15 @@ describe("reader locators", () => { it("bounds PDF page percentages", () => { expect(pdfPagePercent(2, 4)).toBe(50); + expect(pdfPagePercent(2, 4, 0.25)).toBe(31); expect(pdfPagePercent(8, 4)).toBe(100); expect(pdfPagePercent(1, 0)).toBe(0); }); + + it("serializes page locators with relative anchors", () => { + expect(pageLocator("pdf", 3)).toBe("pdf:page:3"); + expect(pageLocator("cbz", 3, 0.25)).toBe("cbz:page:3@0.250"); + expect(parsePageAnchor("cbr:page:8@0.750")).toBe(0.75); + expect(parsePageAnchor("cbr:page:8@2")).toBe(1); + }); }); diff --git a/apps/web/src/reader/locators.ts b/apps/web/src/reader/locators.ts index a8c66d8..9b7ac00 100644 --- a/apps/web/src/reader/locators.ts +++ b/apps/web/src/reader/locators.ts @@ -14,11 +14,26 @@ export function parseCbrPageLocator(locator?: string | null): number | null { } function parsePageSuffix(locator: string): number | null { - const value = Number(locator.split(":").at(-1)); + const value = Number(locator.split(":").at(-1)?.split("@")[0]); return Number.isInteger(value) && value > 0 ? value : null; } -export function pdfPagePercent(page: number, pages: number): number { - if (!Number.isFinite(page) || !Number.isFinite(pages) || pages < 1) return 0; - return Math.max(0, Math.min(100, Math.round((page / pages) * 100))); +export function parsePageAnchor(locator?: string | null): number | null { + const rawAnchor = locator?.split("@")[1]; + if (!rawAnchor) return null; + const anchor = Number(rawAnchor); + return Number.isFinite(anchor) ? Math.max(0, Math.min(1, anchor)) : null; +} + +export function pageLocator(prefix: "pdf" | "cbz" | "cbr", page: number, anchor = 1): string { + const normalizedPage = Number.isInteger(page) && page > 0 ? page : 1; + const normalizedAnchor = Math.max(0, Math.min(1, anchor)); + if (normalizedAnchor >= 0.995) return `${prefix}:page:${normalizedPage}`; + return `${prefix}:page:${normalizedPage}@${normalizedAnchor.toFixed(3)}`; +} + +export function pdfPagePercent(page: number, pages: number, anchor = 1): number { + if (!Number.isFinite(page) || !Number.isFinite(pages) || pages < 1) return 0; + const normalizedAnchor = Math.max(0, Math.min(1, anchor)); + return Math.max(0, Math.min(100, Math.round(((page - 1 + normalizedAnchor) / pages) * 100))); } diff --git a/apps/web/src/reader/pdfRender.ts b/apps/web/src/reader/pdfRender.ts new file mode 100644 index 0000000..0bead5a --- /dev/null +++ b/apps/web/src/reader/pdfRender.ts @@ -0,0 +1,34 @@ +import type { ReaderMode } from "../api/types"; + +const PDF_RENDER_BACKGROUND = { red: 247, green: 240, blue: 223 }; +const PDF_BLANK_THRESHOLD = 245; +const PDF_BACKGROUND_TOLERANCE = 4; + +export function pdfRenderScale(mode: ReaderMode, frameSize: { width: number; height: number }, pageSize: { width: number; height: number }) { + if (pageSize.width <= 0 || pageSize.height <= 0) return 1; + const fitWidth = frameSize.width / pageSize.width; + if (mode === "vertical") return Math.min(1.65, fitWidth); + return Math.min(fitWidth, frameSize.height / pageSize.height); +} + +function isNearRenderBackground(red: number, green: number, blue: number) { + return ( + Math.abs(red - PDF_RENDER_BACKGROUND.red) <= PDF_BACKGROUND_TOLERANCE && + Math.abs(green - PDF_RENDER_BACKGROUND.green) <= PDF_BACKGROUND_TOLERANCE && + Math.abs(blue - PDF_RENDER_BACKGROUND.blue) <= PDF_BACKGROUND_TOLERANCE + ); +} + +export function pdfCanvasHasVisibleInk(imageData: Pick) { + const data = imageData.data; + for (let index = 0; index < data.length; index += 4) { + const red = data[index] ?? 255; + const green = data[index + 1] ?? 255; + const blue = data[index + 2] ?? 255; + const alpha = data[index + 3] ?? 255; + if (alpha === 0) continue; + const nearWhite = red >= PDF_BLANK_THRESHOLD && green >= PDF_BLANK_THRESHOLD && blue >= PDF_BLANK_THRESHOLD; + if (!nearWhite && !isNearRenderBackground(red, green, blue)) return true; + } + return false; +} diff --git a/apps/web/src/reader/readerRuntime.test.ts b/apps/web/src/reader/readerRuntime.test.ts index 6ad5bc2..5d1ecf7 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 { pdfCanvasHasVisibleInk, pdfRenderScale } from "./pdfRender"; import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker"; import { readerErrorMessage } from "./ReaderError"; @@ -30,4 +31,14 @@ describe("reader runtime helpers", () => { expect(readerErrorMessage(new Error("Setting up fake worker failed"), "PDF indisponible")).toBe("Setting up fake worker failed"); expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible"); }); + + it("separates PDF fit-page and fit-width scales", () => { + expect(pdfRenderScale("horizontal", { width: 800, height: 600 }, { width: 400, height: 800 })).toBe(0.75); + expect(pdfRenderScale("vertical", { width: 800, height: 600 }, { width: 400, height: 800 })).toBe(1.65); + }); + + it("rejects blank PDF canvas renders", () => { + expect(pdfCanvasHasVisibleInk({ data: new Uint8ClampedArray([255, 255, 255, 255, 247, 240, 223, 255]) })).toBe(false); + expect(pdfCanvasHasVisibleInk({ data: new Uint8ClampedArray([255, 255, 255, 255, 32, 28, 24, 255]) })).toBe(true); + }); }); diff --git a/apps/web/src/reader/useReaderProgress.ts b/apps/web/src/reader/useReaderProgress.ts index f9845fa..409f507 100644 --- a/apps/web/src/reader/useReaderProgress.ts +++ b/apps/web/src/reader/useReaderProgress.ts @@ -1,11 +1,18 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { ProgressDto } from "@readabook/shared"; import { api, getApiFallback } from "../api/client"; +type PendingProgress = { + locator: string; + percent: number; +}; + export function useReaderProgress(bookId: number) { const [progress, setProgress] = useState(null); const [saving, setSaving] = useState(false); const [error, setError] = useState(); + const pendingRef = useRef(null); + const saveTimerRef = useRef(undefined); useEffect(() => { let alive = true; @@ -24,6 +31,8 @@ export function useReaderProgress(bookId: number) { const save = useCallback( async (locator: string, percent: number) => { + pendingRef.current = null; + if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); setSaving(true); setError(undefined); try { @@ -44,5 +53,34 @@ export function useReaderProgress(bookId: number) { [bookId] ); - return { progress, saving, error, save }; + const flush = useCallback(() => { + const pending = pendingRef.current; + if (!pending) return; + void save(pending.locator, pending.percent); + }, [save]); + + const queueSave = useCallback( + (locator: string, percent: number) => { + pendingRef.current = { locator, percent }; + if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = window.setTimeout(flush, 2000); + }, + [flush] + ); + + useEffect(() => { + const flushOnHidden = () => { + if (document.visibilityState === "hidden") flush(); + }; + window.addEventListener("beforeunload", flush); + document.addEventListener("visibilitychange", flushOnHidden); + return () => { + window.removeEventListener("beforeunload", flush); + document.removeEventListener("visibilitychange", flushOnHidden); + if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); + flush(); + }; + }, [flush]); + + return { progress, saving, error, save, queueSave, flush }; } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 4ece1d3..227b91a 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ "/auth": "http://127.0.0.1:3000", "/admin": "http://127.0.0.1:3000", "/books": "http://127.0.0.1:3000", + "/series": "http://127.0.0.1:3000", "/progress": "http://127.0.0.1:3000", "/healthz": "http://127.0.0.1:3000" }