fix(web): lecteur PDF — pages blanches après rendu et base minimale stable

Le canvas PDF restait vide après rendu (blank-detected): la détection d'encre
reposait sur un seuil de blanc trop sensible et un fond de canvas non maîtrisé.
Le rendu passe à un fond sentinel explicite avec classification
rendered-ok/blank-detected et un seuil de pixels visibles.

Le lecteur est par ailleurs ramené à une base minimale stable: layout plein
viewport (app-main-reader), suppression des modes de lecture et des préférences
par livre, helpers de pagination/viewport extraits dans readerLayout.ts. Shell,
lecteurs CBZ/EPUB et styles alignés sur cette base.

Refs #31

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Git Agent
2026-08-24 12:52:49 +02:00
parent 7cc44871d9
commit 50a0b392e1
10 changed files with 662 additions and 714 deletions

View File

@ -41,7 +41,11 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi
<AdminPage /> <AdminPage />
); );
return <AppShell session={session}>{content}</AppShell>; return (
<AppShell session={session} readerLayout={route.name === "reader"}>
{content}
</AppShell>
);
} }
export function App() { export function App() {

View File

@ -11,7 +11,7 @@ const navItems = [
{ href: "/me", label: "Profil", icon: UserRound } { href: "/me", label: "Profil", icon: UserRound }
]; ];
export function AppShell({ children, session }: { children: ReactNode; session: Session }) { export function AppShell({ children, session, readerLayout = false }: { children: ReactNode; session: Session; readerLayout?: boolean }) {
return ( return (
<div className="app-shell"> <div className="app-shell">
<aside className="side-rail"> <aside className="side-rail">
@ -32,7 +32,7 @@ export function AppShell({ children, session }: { children: ReactNode; session:
</nav> </nav>
<div className="session-chip">{session.user ? session.user.email : "Mode vitrine"}</div> <div className="session-chip">{session.user ? session.user.email : "Mode vitrine"}</div>
</aside> </aside>
<main>{children}</main> <main className={readerLayout ? "app-main-reader" : undefined}>{children}</main>
</div> </div>
); );
} }

View File

@ -6,7 +6,6 @@ import { EpubReader } from "../reader/EpubReader";
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators"; import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
import { PdfReader } from "../reader/PdfReader"; import { PdfReader } from "../reader/PdfReader";
import { ReaderShell, type ReaderControls } from "../reader/ReaderShell"; import { ReaderShell, type ReaderControls } from "../reader/ReaderShell";
import { useReaderPreferences } from "../reader/useReaderPreferences";
import { useReaderProgress } from "../reader/useReaderProgress"; import { useReaderProgress } from "../reader/useReaderProgress";
const idleControls: ReaderControls = { const idleControls: ReaderControls = {
@ -23,9 +22,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls); const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
const [controlsVisible, setControlsVisible] = useState(true); const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId);
const { progress, saving, error: progressError, save, queueSave } = useReaderProgress(bookId);
const { preferences, setMode, error: preferencesError } = useReaderPreferences(bookId);
async function loadBook() { async function loadBook() {
setLoading(true); setLoading(true);
@ -47,7 +44,6 @@ export function ReaderPage({ bookId }: { bookId: number }) {
useEffect(() => { useEffect(() => {
setReaderControls(idleControls); setReaderControls(idleControls);
setControlsVisible(true);
}, [bookId]); }, [bookId]);
useEffect(() => { useEffect(() => {
@ -80,21 +76,15 @@ export function ReaderPage({ bookId }: { bookId: number }) {
[book?.format, queueSave, save] [book?.format, queueSave, save]
); );
const readerError = error ?? progressError ?? preferencesError; const readerError = error ?? progressError;
return ( return (
<ReaderShell <ReaderShell
title={book?.title ?? "Ouverture du lecteur"} title={book?.title ?? "Ouverture du lecteur"}
status={loading ? "Chargement" : saving ? "Sauvegarde" : "Progression synchronisee"}
backHref={backHref} backHref={backHref}
progress={progress?.percent ?? 0}
error={readerError} error={readerError}
onRetry={error ? () => void loadBook() : undefined} onRetry={error ? () => void loadBook() : undefined}
mode={preferences.mode}
onModeChange={setMode}
controls={readerControls} controls={readerControls}
controlsVisible={controlsVisible}
onToggleControls={() => setControlsVisible((visible) => !visible)}
> >
{!book ? ( {!book ? (
<div className="reader-fallback"> <div className="reader-fallback">
@ -108,7 +98,6 @@ export function ReaderPage({ bookId }: { bookId: number }) {
url={fileUrl} url={fileUrl}
page={page} page={page}
backHref={backHref} backHref={backHref}
mode={preferences.mode}
onPageCommit={savePdfPage} onPageCommit={savePdfPage}
onControlsChange={setReaderControls} onControlsChange={setReaderControls}
/> />
@ -116,7 +105,6 @@ export function ReaderPage({ bookId }: { bookId: number }) {
<CbzReader <CbzReader
bookId={book.id} bookId={book.id}
page={page} page={page}
mode={preferences.mode}
onPageCommit={saveComicPage} onPageCommit={saveComicPage}
onControlsChange={setReaderControls} onControlsChange={setReaderControls}
/> />
@ -125,7 +113,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
url={fileUrl} url={fileUrl}
locator={progress?.locator} locator={progress?.locator}
backHref={backHref} backHref={backHref}
mode={preferences.mode} mode="horizontal"
onLocatorChange={saveEpubLocator} onLocatorChange={saveEpubLocator}
onControlsChange={setReaderControls} onControlsChange={setReaderControls}
/> />

View File

@ -1,39 +1,36 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api/client"; import { api } from "../api/client";
import type { CbzPagesDto, ReaderMode } from "../api/types"; import type { CbzPagesDto } from "../api/types";
import { clampReaderPage, orientedContainPageSize, readableViewportSize, readerPositionLabel, type ReaderSize } from "./readerLayout";
import type { ReaderControls } from "./ReaderShell"; import type { ReaderControls } from "./ReaderShell";
type PageCommitStrategy = "immediate" | "queued"; type PageCommitStrategy = "immediate" | "queued";
function scrollContainerFor(element: HTMLElement | null): HTMLElement | null {
return element?.closest(".reader-stage") as HTMLElement | null;
}
export function CbzReader({ export function CbzReader({
bookId, bookId,
page, page,
mode,
onPageCommit, onPageCommit,
onControlsChange onControlsChange
}: { }: {
bookId: number; bookId: number;
page: number; page: number;
mode: ReaderMode;
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void; onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
onControlsChange: (controls: ReaderControls) => void; onControlsChange: (controls: ReaderControls) => void;
}) { }) {
const frameRef = useRef<HTMLDivElement>(null); const frameRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false);
const [pages, setPages] = useState<CbzPagesDto | null>(null); const [pages, setPages] = useState<CbzPagesDto | null>(null);
const [error, setError] = useState<string>(); const [documentError, setDocumentError] = useState<string>();
const [imageError, setImageError] = useState(false); const [pageError, setPageError] = useState<string>();
const [visiblePage, setVisiblePage] = useState(page); const [documentAttempt, setDocumentAttempt] = useState(0);
const [retryAttempt, setRetryAttempt] = useState(0);
const [viewportSize, setViewportSize] = useState<ReaderSize | null>(null);
const [imageSize, setImageSize] = useState<ReaderSize | null>(null);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setError(undefined); setDocumentError(undefined);
setImageError(false); setPageError(undefined);
restoredRef.current = false; setPages(null);
api api
.cbzPages(bookId) .cbzPages(bookId)
.then((nextPages) => { .then((nextPages) => {
@ -41,86 +38,95 @@ export function CbzReader({
setPages(nextPages); setPages(nextPages);
if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount, 1, "immediate"); if (page > nextPages.pageCount) onPageCommit(nextPages.pageCount, nextPages.pageCount, 1, "immediate");
}) })
.catch(() => { .catch((error) => {
if (alive) setError("Archive CBZ indisponible."); if (!alive) return;
setDocumentError(error instanceof Error && error.message ? `Archive indisponible: ${error.message}` : "Archive indisponible.");
}); });
return () => { return () => {
alive = false; alive = false;
}; };
}, [bookId]); }, [bookId, documentAttempt]);
const pageCount = pages?.pageCount ?? 1; const pageCount = pages?.pageCount ?? 1;
const currentPage = Math.max(1, Math.min(page, pageCount)); const currentPage = clampReaderPage(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 currentName = pages?.pages.find((item) => item.page === currentPage)?.name;
const fittedSize = viewportSize && imageSize ? orientedContainPageSize(viewportSize, imageSize) : null;
const imageStyle = fittedSize ? { width: `${fittedSize.width}px`, height: `${fittedSize.height}px` } : undefined;
const go = useCallback( const go = useCallback(
(nextPage: number) => { (nextPage: number) => {
const target = Math.max(1, Math.min(nextPage, pageCount)); if (!pages) return;
setImageError(false); const target = clampReaderPage(nextPage, pages.pageCount);
if (mode === "vertical") { setPageError(undefined);
frameRef.current?.querySelector(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" }); onPageCommit(target, pages.pageCount, 1, "immediate");
setVisiblePage(target);
}
onPageCommit(target, pageCount, mode === "vertical" ? 0 : 1, "immediate");
}, },
[mode, onPageCommit, pageCount] [onPageCommit, pages]
); );
const goTop = useCallback(() => go(1), [go]); useEffect(() => {
setPageError(undefined);
setImageSize(null);
}, [bookId, currentPage, retryAttempt]);
useEffect(() => {
const frame = frameRef.current;
const stage = frame?.closest(".reader-stage") as HTMLElement | null;
const observedElement = stage ?? frame;
if (!observedElement) return;
const updateSize = () => {
const rect = observedElement.getBoundingClientRect();
const nextSize = readableViewportSize({ width: rect.width, height: rect.height });
if (nextSize) setViewportSize(nextSize);
};
updateSize();
const frameId = requestAnimationFrame(updateSize);
const observer = new ResizeObserver(updateSize);
observer.observe(observedElement);
return () => {
cancelAnimationFrame(frameId);
observer.disconnect();
};
}, []);
useEffect(() => { useEffect(() => {
onControlsChange({ onControlsChange({
canPrevious: !error && !imageError && displayedPage > 1, canPrevious: Boolean(pages) && !documentError && !pageError && currentPage > 1,
canNext: !error && !imageError && displayedPage < pageCount, canNext: Boolean(pages) && !documentError && !pageError && currentPage < pageCount,
canTop: !error && !imageError && displayedPage > 1, positionLabel: pages ? readerPositionLabel(currentPage, pageCount) : "Chargement",
positionLabel: pages ? `${displayedPage} / ${pageCount}` : "Ouverture archive", onPrevious: () => go(currentPage - 1),
onPrevious: () => go(displayedPage - 1), onNext: () => go(currentPage + 1)
onNext: () => go(displayedPage + 1),
onTop: goTop
}); });
}, [displayedPage, error, go, goTop, imageError, onControlsChange, pageCount, pages]); }, [currentPage, documentError, go, onControlsChange, pageCount, pageError, pages]);
useEffect(() => { if (documentError) {
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<HTMLElement>("[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 ( return (
<div className={`cbz-reader cbz-reader-${mode}`} ref={frameRef}> <div className="cbz-reader" ref={frameRef}>
<div className="reader-fallback reader-fallback-error">
<span>{documentError}</span>
<button className="ghost-button" onClick={() => setDocumentAttempt((attempt) => attempt + 1)}>
Reessayer
</button>
</div>
</div>
);
}
if (!pages) {
return (
<div className="cbz-reader" ref={frameRef}>
<div className="reader-fallback"> <div className="reader-fallback">
<span>{error ?? "Page CBZ indisponible."}</span> <span>Chargement de l'archive</span>
<button className="ghost-button" onClick={() => go(currentPage)}> </div>
</div>
);
}
if (pageError) {
return (
<div className="cbz-reader" ref={frameRef}>
<div className="reader-fallback reader-fallback-error">
<span>{pageError}</span>
<button className="ghost-button" onClick={() => setRetryAttempt((attempt) => attempt + 1)}>
Reessayer Reessayer
</button> </button>
</div> </div>
@ -129,26 +135,25 @@ export function CbzReader({
} }
return ( return (
<div className={`cbz-reader cbz-reader-${mode}`} ref={frameRef}> <div className="cbz-reader" ref={frameRef}>
{mode === "vertical" && pages ? ( <figure className="comic-page-frame" data-reader-page={currentPage}>
<div className="comic-strip"> {!imageSize && (
{pages.pages.map((item) => ( <div className="reader-fallback">
<figure className="comic-page-frame" data-reader-page={item.page} key={item.page}> <span>Page {currentPage}</span>
</div>
)}
<img <img
src={api.cbzPageUrl(bookId, item.page)} key={`${currentPage}-${retryAttempt}`}
alt={item.name ?? `Page ${item.page}`} src={api.cbzPageUrl(bookId, currentPage)}
loading="lazy" alt={currentName ?? `Page ${currentPage}`}
decoding="async" style={imageStyle}
onError={() => setImageError(true)} hidden={!imageSize}
onLoad={(event) => {
setImageSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight });
}}
onError={() => setPageError(`Page ${currentPage} indisponible.`)}
/> />
</figure> </figure>
))}
</div>
) : (
<figure className="comic-page-frame" data-reader-page={currentPage}>
<img src={api.cbzPageUrl(bookId, currentPage)} alt={currentName ?? `Page ${currentPage}`} onError={() => setImageError(true)} />
</figure>
)}
</div> </div>
); );
} }

View File

@ -1,10 +1,10 @@
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 { pdfCanvasHasVisibleInk, pdfRenderScale } 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 type { ReaderControls } from "./ReaderShell"; import type { ReaderControls } from "./ReaderShell";
import type { ReaderMode } from "../api/types";
configurePdfWorker(pdfjs); configurePdfWorker(pdfjs);
@ -14,77 +14,196 @@ type PdfReaderProps = {
url: string; url: string;
page: number; page: number;
backHref: string; backHref: string;
mode: ReaderMode;
onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void; onPageCommit: (page: number, pages: number, anchor?: number, strategy?: PageCommitStrategy) => void;
onControlsChange: (controls: ReaderControls) => void; onControlsChange: (controls: ReaderControls) => void;
}; };
type PdfPageCanvasProps = { type PdfFailure = {
document: pdfjs.PDFDocumentProxy; detail: string;
pageNumber: number; technicalDetail: string;
mode: ReaderMode;
frameSize: { width: number; height: number };
onError: (message: string) => void;
}; };
let pdfLifecycle = Promise.resolve(); type PdfPageStatus = "idle" | "loading" | PdfRenderResult | "render-failed";
function enqueuePdfLifecycle<T>(work: () => Promise<T>) { function pdfTechnicalMessage(error: unknown) {
const run = pdfLifecycle.then(work, work); if (error instanceof Error && error.message.trim()) return `${error.name}: ${error.message}`;
pdfLifecycle = run.then(() => undefined, () => undefined); if (typeof error === "string" && error.trim()) return error;
return run; return "Erreur inconnue.";
}
function pdfFailure(source: string, detail: string, context: Record<string, unknown>, error?: unknown): PdfFailure {
return {
detail,
technicalDetail: JSON.stringify(
{
source,
...context,
error: error === undefined ? undefined : pdfTechnicalMessage(error)
},
null,
2
)
};
} }
function isPdfTransitionError(error: unknown) { function isPdfTransitionError(error: unknown) {
return error instanceof Error && (error.message.includes("worker is being destroyed") || error.message.includes("Rendering cancelled")); 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 { function scrollContainerFor(element: HTMLElement | null): HTMLElement | null {
return element?.closest(".reader-stage") as HTMLElement | null; return element?.closest(".reader-stage") as HTMLElement | null;
} }
function PdfPageCanvas({ document, pageNumber, mode, frameSize, onError }: PdfPageCanvasProps) { function afterNextPaint() {
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}
export function PdfReader({ url, page, backHref, onPageCommit, onControlsChange }: PdfReaderProps) {
const frameRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null); const [documentProxy, setDocumentProxy] = useState<pdfjs.PDFDocumentProxy | null>(null);
const [visible, setVisible] = useState(mode === "horizontal"); const [pages, setPages] = useState(1);
const [rendered, setRendered] = useState(false); const [documentError, setDocumentError] = useState<PdfFailure>();
const [pageError, setPageError] = useState<PdfFailure>();
const [loadingDocument, setLoadingDocument] = useState(true);
const [loadingPage, setLoadingPage] = useState(false);
const [pageRendered, setPageRendered] = useState(false);
const [pageStatus, setPageStatus] = useState<PdfPageStatus>("idle");
const [documentAttempt, setDocumentAttempt] = useState(0);
const [pageAttempt, setPageAttempt] = useState(0);
const [viewportSize, setViewportSize] = useState<ReaderSize | null>(null);
const currentPage = clampReaderPage(page, pages);
const showCanvas = pdfCanvasVisible(pageRendered, Boolean(pageError));
const go = useCallback(
(nextPage: number) => {
const target = clampReaderPage(nextPage, pages);
setPageError(undefined);
onPageCommit(target, pages, 1, "immediate");
},
[onPageCommit, pages]
);
useEffect(() => { useEffect(() => {
if (mode === "horizontal") { onControlsChange({
setVisible(true); canPrevious: !loadingDocument && !documentError && !pageError && currentPage > 1,
canNext: !loadingDocument && !documentError && !pageError && currentPage < pages,
positionLabel: loadingDocument ? "Chargement" : readerPositionLabel(currentPage, pages),
onPrevious: () => go(currentPage - 1),
onNext: () => go(currentPage + 1)
});
}, [currentPage, documentError, go, loadingDocument, onControlsChange, pageError, pages]);
useEffect(() => {
const frame = frameRef.current;
const stage = scrollContainerFor(frame);
const observedElement = stage ?? frame;
if (!observedElement) return;
const updateSize = () => {
const rect = observedElement.getBoundingClientRect();
const nextSize = readableViewportSize({ width: rect.width, height: rect.height });
if (nextSize) setViewportSize(nextSize);
};
updateSize();
const frameId = requestAnimationFrame(updateSize);
const observer = new ResizeObserver(updateSize);
observer.observe(observedElement);
return () => {
cancelAnimationFrame(frameId);
observer.disconnect();
};
}, []);
useEffect(() => {
let cancelled = false;
let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined;
setLoadingDocument(true);
setDocumentError(undefined);
setPageError(undefined);
setPageRendered(false);
setPageStatus("idle");
setDocumentProxy(null);
async function loadDocument() {
try {
try {
new URL(url, window.location.href);
} catch (error) {
throw pdfFailure("uri", "PDF: URI de fichier invalide.", { url }, error);
}
configurePdfWorker(pdfjs);
loadingTask = pdfjs.getDocument({ url, withCredentials: true });
const loadedDocument = await loadingTask.promise;
if (cancelled) {
await loadingTask.destroy();
return; return;
} }
const wrapper = wrapperRef.current; setPages(loadedDocument.numPages);
if (!wrapper) return; setDocumentProxy(loadedDocument);
const observer = new IntersectionObserver( setLoadingDocument(false);
(entries) => { } catch (error) {
if (entries.some((entry) => entry.isIntersecting)) setVisible(true); if (cancelled && isPdfTransitionError(error)) return;
if (!cancelled) {
setDocumentError(
typeof error === "object" && error && "detail" in error && "technicalDetail" in error
? (error as PdfFailure)
: pdfFailure(
"getDocument",
"PDF: getDocument() a échoué.",
{
url,
resolvedUrl: (() => {
try {
return new URL(url, window.location.href).toString();
} catch {
return null;
}
})(),
workerSrc: pdfjs.GlobalWorkerOptions.workerSrc,
hasWorkerPort: Boolean(pdfjs.GlobalWorkerOptions.workerPort)
}, },
{ root: scrollContainerFor(wrapper), rootMargin: "900px 0px" } error
)
); );
observer.observe(wrapper); setLoadingDocument(false);
return () => observer.disconnect(); }
}, [mode]); }
}
void loadDocument();
return () => {
cancelled = true;
void loadingTask?.destroy();
};
}, [documentAttempt, url]);
useEffect(() => { useEffect(() => {
if (!visible) return; if (!documentProxy || !viewportSize) return;
const loadedDocument = documentProxy;
const readerViewport = viewportSize;
let cancelled = false; let cancelled = false;
let renderTask: pdfjs.RenderTask | undefined; let renderTask: pdfjs.RenderTask | undefined;
setRendered(false); setLoadingPage(true);
setPageError(undefined);
setPageRendered(false);
setPageStatus("loading");
async function renderPage() { async function renderPage() {
try { try {
const pdfPage = await document.getPage(pageNumber); let pdfPage: pdfjs.PDFPageProxy;
try {
pdfPage = await loadedDocument.getPage(currentPage);
} catch (error) {
throw pdfFailure("getPage", `PDF: getPage(${currentPage}) a échoué.`, { pageNumber: currentPage }, error);
}
if (cancelled) return; if (cancelled) return;
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) {
throw pdfFailure("react-canvas-ref", `PDF: canvas absent pour la page ${currentPage}.`, { pageNumber: currentPage });
}
const baseViewport = pdfPage.getViewport({ scale: 1 }); const baseViewport = pdfPage.getViewport({ scale: 1 });
const fitScale = pdfRenderScale(mode, frameSize, { width: baseViewport.width, height: baseViewport.height }); const fitScale = pdfRenderScale(readerViewport, { width: baseViewport.width, height: baseViewport.height });
const pixelRatio = Math.min(2, window.devicePixelRatio || 1); const pixelRatio = Math.min(2, window.devicePixelRatio || 1);
const renderScale = Math.max(0.25, Math.min(3, fitScale)) * pixelRatio; const renderScale = Math.max(0.25, Math.min(3, fitScale)) * pixelRatio;
const viewport = pdfPage.getViewport({ scale: renderScale }); const viewport = pdfPage.getViewport({ scale: renderScale });
@ -95,189 +214,136 @@ function PdfPageCanvas({ document, pageNumber, mode, frameSize, onError }: PdfPa
canvas.style.width = `${cssWidth}px`; canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`; canvas.style.height = `${cssHeight}px`;
const context = canvas.getContext("2d", { alpha: false }); const context = canvas.getContext("2d", { alpha: false });
if (!context) return; if (!context) {
context.fillStyle = "#f7f0df"; throw pdfFailure("canvas-context", `PDF: contexte canvas 2D indisponible pour la page ${currentPage}.`, {
pageNumber: currentPage,
canvasWidth: canvas.width,
canvasHeight: canvas.height
});
}
if (canvas.width <= 0 || canvas.height <= 0 || cssWidth <= 0 || cssHeight <= 0) {
throw pdfFailure("canvas-size", `PDF: dimensions canvas invalides pour la page ${currentPage}.`, {
pageNumber: currentPage,
canvasWidth: canvas.width,
canvasHeight: canvas.height,
cssWidth,
cssHeight,
readerViewport
});
}
context.fillStyle = pdfSentinelBackground();
context.fillRect(0, 0, canvas.width, canvas.height); context.fillRect(0, 0, canvas.width, canvas.height);
try {
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport }); renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
await renderTask.promise; 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) { } catch (error) {
if (!cancelled && !isPdfTransitionError(error)) onError(pdfReaderErrorMessage(error)); throw pdfFailure(
"render-failed",
`PDF: render() a échoué pour la page ${currentPage}.`,
{
result: "render-failed",
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
},
error
);
}
await afterNextPaint();
if (cancelled) return;
const renderResult = classifyPdfCanvas(context.getImageData(0, 0, canvas.width, canvas.height));
if (renderResult === "blank-detected") {
setPageStatus("blank-detected");
setPageRendered(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;
}
if (!cancelled) {
setPageStatus("rendered-ok");
setPageRendered(true);
setLoadingPage(false);
}
} catch (error) {
if (cancelled || isPdfTransitionError(error)) return;
setPageRendered(false);
setPageStatus("render-failed");
setLoadingPage(false);
setPageError(
typeof error === "object" && error && "detail" in error && "technicalDetail" in error
? (error as PdfFailure)
: pdfFailure("render-unclassified", `PDF: erreur non classée pendant le rendu de la page ${currentPage}.`, { pageNumber: currentPage }, error)
);
} }
} }
void enqueuePdfLifecycle(renderPage); void renderPage();
return () => { return () => {
cancelled = true; cancelled = true;
renderTask?.cancel(); renderTask?.cancel();
}; };
}, [document, frameSize.height, frameSize.width, mode, onError, pageNumber, visible]); }, [currentPage, documentProxy, pageAttempt, viewportSize]);
if (documentError) {
return ( return (
<div className="pdf-page-frame" data-reader-page={pageNumber} ref={wrapperRef}> <div className="pdf-reader" ref={frameRef}>
{!rendered && (
<div className="reader-fallback">
<span>Page {pageNumber}</span>
</div>
)}
<canvas ref={canvasRef} />
</div>
);
}
export function PdfReader({ url, page, backHref, mode, onPageCommit, onControlsChange }: PdfReaderProps) {
const frameRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false);
const [documentProxy, setDocumentProxy] = useState<pdfjs.PDFDocumentProxy | null>(null);
const [pages, setPages] = useState(1);
const [visiblePage, setVisiblePage] = useState(page);
const [error, setError] = useState<string>();
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 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 && 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
});
}, [displayedPage, error, go, goTop, loading, onControlsChange, pages]);
useEffect(() => {
const frame = frameRef.current;
if (!frame) return;
const updateSize = () => {
const rect = frame.getBoundingClientRect();
setFrameSize({ width: Math.max(320, rect.width), height: Math.max(320, rect.height) });
};
updateSize();
const observer = new ResizeObserver(updateSize);
observer.observe(frame);
return () => observer.disconnect();
}, []);
useEffect(() => {
let cancelled = false;
let loadingTask: pdfjs.PDFDocumentLoadingTask | undefined;
setLoading(true);
setError(undefined);
setDocumentProxy(null);
restoredRef.current = false;
async function loadDocument() {
try {
configurePdfWorker(pdfjs);
loadingTask = pdfjs.getDocument({ url, withCredentials: true });
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(pdfReaderErrorMessage(loadError));
setLoading(false);
}
}
}
void enqueuePdfLifecycle(loadDocument);
return () => {
cancelled = true;
void loadingTask?.destroy();
};
}, [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<HTMLElement>("[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 (
<div className={`pdf-reader pdf-reader-${mode}`} ref={frameRef}>
{error ? (
<ReaderError <ReaderError
title="Lecture PDF indisponible" title="Lecture PDF indisponible"
detail={error} detail={documentError.detail}
technicalDetail={documentError.technicalDetail}
downloadUrl={url} downloadUrl={url}
backHref={backHref} backHref={backHref}
onRetry={() => setAttempt((value) => value + 1)} onRetry={() => setDocumentAttempt((value) => value + 1)}
/> />
) : loading || !documentProxy ? ( </div>
);
}
return (
<div className="pdf-reader" ref={frameRef}>
{loadingDocument || !documentProxy ? (
<div className="reader-fallback"> <div className="reader-fallback">
<span>Ouverture PDF</span> <span>Ouverture PDF</span>
</div> </div>
) : mode === "vertical" ? ( ) : pageError ? (
<div className="pdf-strip"> <div className="pdf-page-frame" data-reader-page={currentPage}>
{Array.from({ length: pages }, (_, index) => ( <div className="reader-fallback reader-fallback-error">
<PdfPageCanvas key={index + 1} document={documentProxy} pageNumber={index + 1} mode={mode} frameSize={frameSize} onError={handlePageError} /> <span>{pageError.detail}</span>
))} <button className="ghost-button" onClick={() => setPageAttempt((value) => value + 1)}>
Reessayer
</button>
<details>
<summary>Détail technique</summary>
<pre>{pageError.technicalDetail}</pre>
</details>
</div>
</div> </div>
) : ( ) : (
<PdfPageCanvas document={documentProxy} pageNumber={currentPage} mode={mode} frameSize={frameSize} onError={handlePageError} /> <div className="pdf-page-frame" data-reader-page={currentPage}>
{(!viewportSize || loadingPage || !pageRendered) && (
<div className="reader-fallback">
<span>Page {currentPage}</span>
</div>
)}
<canvas ref={canvasRef} data-pdf-render-status={pageStatus} style={{ visibility: showCanvas ? "visible" : "hidden" }} />
</div>
)} )}
</div> </div>
); );

View File

@ -1,180 +1,79 @@
import { ArrowLeft, ArrowRight, ArrowUp, Columns2, Maximize2, Minimize2, RotateCcw, Rows3, Save } from "lucide-react"; import { ArrowLeft, ArrowRight } from "lucide-react";
import { useCallback, useEffect, useState, type ReactNode } from "react"; import { useEffect, type ReactNode } from "react";
import { ErrorRibbon, Meter } from "../components/ui"; import { ErrorRibbon } from "../components/ui";
import { navigate } from "../router"; import { navigate } from "../router";
import type { ReaderMode } from "../api/types";
export type ReaderControls = { export type ReaderControls = {
canPrevious: boolean; canPrevious: boolean;
canNext: boolean; canNext: boolean;
canTop?: boolean;
positionLabel: string; positionLabel: string;
onPrevious: () => void; onPrevious: () => void;
onNext: () => void; onNext: () => void;
onTop?: () => void;
}; };
type ReaderShellProps = { type ReaderShellProps = {
title: string; title: string;
status: string;
backHref: string; backHref: string;
progress: number;
error?: string; error?: string;
onRetry?: () => void; onRetry?: () => void;
mode: ReaderMode;
onModeChange: (mode: ReaderMode) => void;
controls: ReaderControls; controls: ReaderControls;
controlsVisible: boolean;
onToggleControls: () => void;
children: ReactNode; children: ReactNode;
}; };
export function ReaderShell({ function isFormField(target: EventTarget | null) {
title, const element = target as HTMLElement | null;
status, const tagName = element?.tagName.toLowerCase();
backHref, return Boolean(element?.isContentEditable || tagName === "input" || tagName === "textarea" || tagName === "select");
progress, }
error,
onRetry,
mode,
onModeChange,
controls,
controlsVisible,
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();
}, []);
export function ReaderShell({ title, backHref, error, onRetry, controls, children }: ReaderShellProps) {
useEffect(() => { useEffect(() => {
const updateFullscreen = () => setIsFullscreen(Boolean(document.fullscreenElement)); const handleKeyDown = (event: KeyboardEvent) => {
updateFullscreen(); if (isFormField(event.target)) return;
document.addEventListener("fullscreenchange", updateFullscreen); if (event.key === "ArrowLeft" && controls.canPrevious) {
return () => document.removeEventListener("fullscreenchange", updateFullscreen); event.preventDefault();
}, []); controls.onPrevious();
}
if (event.key === "ArrowRight" && controls.canNext) {
event.preventDefault();
controls.onNext();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [controls]);
return ( return (
<div className={`reader-page reader-mode-${mode} ${controlsVisible ? "reader-controls-visible" : "reader-controls-hidden"}`}> <div className="reader-page reader-mode-horizontal">
<header className="reader-topbar" onClick={(event) => event.stopPropagation()}> <header className="reader-topbar">
<button className="ghost-button" onClick={() => navigate(backHref)}> <button className="ghost-button" onClick={() => navigate(backHref)}>
<ArrowLeft size={17} /> <ArrowLeft size={17} />
Fiche Retour
</button> </button>
<div> <strong className="reader-title">{title}</strong>
<strong>{title}</strong> <span className="reader-position">{controls.positionLabel}</span>
<span>{status}</span>
</div>
<div className="reader-toolbar">
<button
className={`ghost-button reader-mode-button ${mode === "horizontal" ? "active" : ""}`}
onClick={() => onModeChange("horizontal")}
aria-label="Lecture horizontale"
title="Lecture horizontale"
>
<Columns2 size={18} />
Horizontal
</button>
<button
className={`ghost-button reader-mode-button ${mode === "vertical" ? "active" : ""}`}
onClick={() => onModeChange("vertical")}
aria-label="Lecture verticale"
title="Lecture verticale"
>
<Rows3 size={18} />
Vertical
</button>
<button className="ghost-button icon-only" onClick={toggleFullscreen} aria-label={isFullscreen ? "Quitter le plein ecran" : "Plein ecran"}>
{isFullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
</button>
{error && onRetry ? (
<button className="ghost-button icon-only" onClick={onRetry} aria-label="Reessayer">
<RotateCcw size={18} />
</button>
) : (
<Save size={18} />
)}
</div>
</header>
<div className="reader-status" onClick={(event) => event.stopPropagation()}>
<ErrorRibbon message={error} />
<Meter value={progress} />
</div>
<div className="reader-stage" onClick={onToggleControls}>
{mode === "horizontal" && (
<>
<button
className="reader-side-button reader-side-left"
onClick={(event) => {
event.stopPropagation();
controls.onPrevious();
}}
disabled={!controls.canPrevious}
aria-label="Page precedente"
>
<ArrowLeft size={22} />
</button>
<button
className="reader-side-button reader-side-right"
onClick={(event) => {
event.stopPropagation();
controls.onNext();
}}
disabled={!controls.canNext}
aria-label="Page suivante"
>
<ArrowRight size={22} />
</button>
<button
className="reader-tap-zone reader-tap-left"
onClick={(event) => {
event.stopPropagation();
controls.onPrevious();
}}
disabled={!controls.canPrevious}
aria-label="Page precedente"
/>
<button
className="reader-tap-zone reader-tap-right"
onClick={(event) => {
event.stopPropagation();
controls.onNext();
}}
disabled={!controls.canNext}
aria-label="Page suivante"
/>
</>
)}
<div className="reader-content">{children}</div>
</div>
<footer className="reader-stepper" onClick={(event) => event.stopPropagation()}>
{mode === "vertical" ? (
<button className="ghost-button" onClick={controls.onTop} disabled={!controls.canTop}>
<ArrowUp size={16} />
Haut
</button>
) : (
<button className="ghost-button" onClick={controls.onPrevious} disabled={!controls.canPrevious}> <button className="ghost-button" onClick={controls.onPrevious} disabled={!controls.canPrevious}>
<ArrowLeft size={16} /> <ArrowLeft size={16} />
Precedent Precedent
</button> </button>
)}
<span>{controls.positionLabel}</span>
{mode === "vertical" ? (
<button className="ghost-button" onClick={controls.onPrevious} disabled={!controls.canPrevious}>
<ArrowLeft size={16} />
Page precedente
</button>
) : (
<button className="ghost-button" onClick={controls.onNext} disabled={!controls.canNext}> <button className="ghost-button" onClick={controls.onNext} disabled={!controls.canNext}>
Suivant Suivant
<ArrowRight size={16} /> <ArrowRight size={16} />
</button> </button>
</header>
{error && (
<div className="reader-status">
<ErrorRibbon message={error} />
{onRetry && (
<button className="ghost-button" onClick={onRetry}>
Reessayer
</button>
)} )}
</footer> </div>
)}
<main className="reader-stage">
<div className="reader-content">{children}</div>
</main>
</div> </div>
); );
} }

View File

@ -1,34 +1,52 @@
import type { ReaderMode } from "../api/types"; import type { ReaderSize } from "./readerLayout";
const PDF_RENDER_BACKGROUND = { red: 247, green: 240, blue: 223 }; const PDF_SENTINEL_BACKGROUND = { red: 247, green: 240, blue: 223 };
const PDF_BLANK_THRESHOLD = 245; const PDF_BACKGROUND_TOLERANCE = 5;
const PDF_BACKGROUND_TOLERANCE = 4; const PDF_VISIBLE_DELTA = 10;
const PDF_MIN_VISIBLE_PIXELS = 12;
export function pdfRenderScale(mode: ReaderMode, frameSize: { width: number; height: number }, pageSize: { width: number; height: number }) { export type PdfRenderResult = "rendered-ok" | "blank-detected";
if (pageSize.width <= 0 || pageSize.height <= 0) return 1;
const fitWidth = frameSize.width / pageSize.width; export function pdfRenderScale(frameSize: ReaderSize, pageSize: ReaderSize) {
if (mode === "vertical") return Math.min(1.65, fitWidth); if (frameSize.width <= 0 || frameSize.height <= 0 || pageSize.width <= 0 || pageSize.height <= 0) return 1;
return Math.min(fitWidth, frameSize.height / pageSize.height); return Math.min(frameSize.width / pageSize.width, frameSize.height / pageSize.height);
} }
function isNearRenderBackground(red: number, green: number, blue: number) { export function pdfCanvasVisible(pageRendered: boolean, hasPageError: boolean) {
return pageRendered && !hasPageError;
}
function isNearSentinel(red: number, green: number, blue: number) {
return ( return (
Math.abs(red - PDF_RENDER_BACKGROUND.red) <= PDF_BACKGROUND_TOLERANCE && Math.abs(red - PDF_SENTINEL_BACKGROUND.red) <= PDF_BACKGROUND_TOLERANCE &&
Math.abs(green - PDF_RENDER_BACKGROUND.green) <= PDF_BACKGROUND_TOLERANCE && Math.abs(green - PDF_SENTINEL_BACKGROUND.green) <= PDF_BACKGROUND_TOLERANCE &&
Math.abs(blue - PDF_RENDER_BACKGROUND.blue) <= PDF_BACKGROUND_TOLERANCE Math.abs(blue - PDF_SENTINEL_BACKGROUND.blue) <= PDF_BACKGROUND_TOLERANCE
); );
} }
export function pdfCanvasHasVisibleInk(imageData: Pick<ImageData, "data">) { function isVisibleInk(red: number, green: number, blue: number, alpha: number) {
if (alpha < 24) return false;
if (isNearSentinel(red, green, blue)) return false;
const distanceFromWhite = Math.max(255 - red, 255 - green, 255 - blue);
return distanceFromWhite >= PDF_VISIBLE_DELTA;
}
export function pdfCanvasHasVisibleContent(imageData: Pick<ImageData, "data">) {
let visiblePixels = 0;
const data = imageData.data; const data = imageData.data;
for (let index = 0; index < data.length; index += 4) { for (let index = 0; index < data.length; index += 4) {
const red = data[index] ?? 255; if (isVisibleInk(data[index] ?? 255, data[index + 1] ?? 255, data[index + 2] ?? 255, data[index + 3] ?? 255)) {
const green = data[index + 1] ?? 255; visiblePixels += 1;
const blue = data[index + 2] ?? 255; if (visiblePixels >= PDF_MIN_VISIBLE_PIXELS) return true;
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; return false;
} }
export function classifyPdfCanvas(imageData: Pick<ImageData, "data">): PdfRenderResult {
return pdfCanvasHasVisibleContent(imageData) ? "rendered-ok" : "blank-detected";
}
export function pdfSentinelBackground() {
return `rgb(${PDF_SENTINEL_BACKGROUND.red}, ${PDF_SENTINEL_BACKGROUND.green}, ${PDF_SENTINEL_BACKGROUND.blue})`;
}

View File

@ -0,0 +1,54 @@
export type ReaderSize = {
width: number;
height: number;
};
export function clampReaderPage(page: number, pageCount: number) {
const safePageCount = Math.max(1, Math.floor(pageCount));
return Math.max(1, Math.min(Math.floor(page), safePageCount));
}
export function previousReaderPage(page: number, pageCount: number) {
return clampReaderPage(page - 1, pageCount);
}
export function nextReaderPage(page: number, pageCount: number) {
return clampReaderPage(page + 1, pageCount);
}
export function readerPositionLabel(page: number, pageCount: number) {
return `Page ${clampReaderPage(page, pageCount)} / ${Math.max(1, Math.floor(pageCount))}`;
}
export function containPageSize(viewport: ReaderSize, page: ReaderSize): ReaderSize {
if (viewport.width <= 0 || viewport.height <= 0 || page.width <= 0 || page.height <= 0) {
return { width: 0, height: 0 };
}
const scale = Math.min(viewport.width / page.width, viewport.height / page.height);
return {
width: Math.max(1, Math.floor(page.width * scale)),
height: Math.max(1, Math.floor(page.height * scale))
};
}
export function orientedContainPageSize(viewport: ReaderSize, page: ReaderSize): ReaderSize {
if (viewport.width <= 0 || viewport.height <= 0 || page.width <= 0 || page.height <= 0) {
return { width: 0, height: 0 };
}
const fitByHeight = page.height >= page.width;
const primaryScale = fitByHeight ? viewport.height / page.height : viewport.width / page.width;
const crossScale = fitByHeight ? viewport.width / page.width : viewport.height / page.height;
const scale = Math.min(primaryScale, crossScale);
return {
width: Math.max(1, Math.floor(page.width * scale)),
height: Math.max(1, Math.floor(page.height * scale))
};
}
export function readableViewportSize(size: ReaderSize): ReaderSize | null {
if (size.width < 32 || size.height < 32) return null;
return {
width: Math.floor(size.width),
height: Math.floor(size.height)
};
}

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { epubFileName } from "./EpubReader"; import { epubFileName } from "./EpubReader";
import { pdfCanvasHasVisibleInk, pdfRenderScale } from "./pdfRender"; import { clampReaderPage, containPageSize, nextReaderPage, orientedContainPageSize, previousReaderPage, readableViewportSize, readerPositionLabel } from "./readerLayout";
import { classifyPdfCanvas, pdfCanvasHasVisibleContent, pdfCanvasVisible, pdfRenderScale } from "./pdfRender";
import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker"; import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker";
import { readerErrorMessage } from "./ReaderError"; import { readerErrorMessage } from "./ReaderError";
@ -32,13 +33,80 @@ describe("reader runtime helpers", () => {
expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible"); expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible");
}); });
it("separates PDF fit-page and fit-width scales", () => { it("fits PDF pages by containment inside the reader viewport", () => {
expect(pdfRenderScale("horizontal", { width: 800, height: 600 }, { width: 400, height: 800 })).toBe(0.75); expect(pdfRenderScale({ 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); expect(pdfRenderScale({ width: 800, height: 600 }, { width: 800, height: 400 })).toBe(1);
}); });
it("rejects blank PDF canvas renders", () => { it("does not expose a PDF canvas before a successful page render", () => {
expect(pdfCanvasHasVisibleInk({ data: new Uint8ClampedArray([255, 255, 255, 255, 247, 240, 223, 255]) })).toBe(false); expect(pdfCanvasVisible(false, false)).toBe(false);
expect(pdfCanvasHasVisibleInk({ data: new Uint8ClampedArray([255, 255, 255, 255, 32, 28, 24, 255]) })).toBe(true); expect(pdfCanvasVisible(true, true)).toBe(false);
expect(pdfCanvasVisible(true, false)).toBe(true);
});
it("classifies blank PDF canvas data separately from render failures", () => {
expect(classifyPdfCanvas({ data: new Uint8ClampedArray([255, 255, 255, 255, 255, 255, 255, 255]) })).toBe("blank-detected");
expect(classifyPdfCanvas({ data: new Uint8ClampedArray([247, 240, 223, 255, 247, 240, 223, 255]) })).toBe("blank-detected");
const inkPixels = new Uint8ClampedArray(12 * 4);
for (let index = 0; index < inkPixels.length; index += 4) {
inkPixels[index] = 32;
inkPixels[index + 1] = 28;
inkPixels[index + 2] = 24;
inkPixels[index + 3] = 255;
}
expect(classifyPdfCanvas({ data: inkPixels })).toBe("rendered-ok");
});
it("does not treat isolated PDF canvas noise as visible content", () => {
const noisyPixels = new Uint8ClampedArray([
255, 255, 255, 255,
32, 28, 24, 255,
255, 255, 255, 255
]);
expect(pdfCanvasHasVisibleContent({ data: noisyPixels })).toBe(false);
});
it("classifies very light but non-empty PDF canvas data as rendered", () => {
const lightInkPixels = new Uint8ClampedArray(12 * 4);
for (let index = 0; index < lightInkPixels.length; index += 4) {
lightInkPixels[index] = 244;
lightInkPixels[index + 1] = 244;
lightInkPixels[index + 2] = 244;
lightInkPixels[index + 3] = 255;
}
expect(classifyPdfCanvas({ data: lightInkPixels })).toBe("rendered-ok");
});
it("contains page media inside the reader viewport without cropping", () => {
expect(containPageSize({ width: 1200, height: 700 }, { width: 900, height: 1600 })).toEqual({ width: 393, height: 700 });
expect(containPageSize({ width: 1200, height: 700 }, { width: 1600, height: 900 })).toEqual({ width: 1200, height: 675 });
});
it("fits comic pages by intrinsic orientation without cropping", () => {
expect(orientedContainPageSize({ width: 1200, height: 700 }, { width: 900, height: 1600 })).toEqual({ width: 393, height: 700 });
expect(orientedContainPageSize({ width: 1200, height: 700 }, { width: 1600, height: 900 })).toEqual({ width: 1200, height: 675 });
expect(orientedContainPageSize({ width: 500, height: 900 }, { width: 1200, height: 900 })).toEqual({ width: 500, height: 375 });
});
it("ignores unusable reader viewport measurements", () => {
expect(readableViewportSize({ width: 0, height: 833 })).toBeNull();
expect(readableViewportSize({ width: 1, height: 1 })).toBeNull();
expect(readableViewportSize({ width: 1548.8, height: 833.2 })).toEqual({ width: 1548, height: 833 });
});
it("keeps the minimal reader on one clamped page", () => {
expect(clampReaderPage(0, 12)).toBe(1);
expect(clampReaderPage(8, 12)).toBe(8);
expect(clampReaderPage(99, 12)).toBe(12);
expect(readerPositionLabel(8, 12)).toBe("Page 8 / 12");
});
it("navigates page by page without leaving document bounds", () => {
expect(previousReaderPage(1, 12)).toBe(1);
expect(previousReaderPage(8, 12)).toBe(7);
expect(nextReaderPage(8, 12)).toBe(9);
expect(nextReaderPage(12, 12)).toBe(12);
}); });
}); });

View File

@ -68,6 +68,13 @@ main {
padding: 28px; padding: 28px;
} }
main.app-main-reader {
height: 100vh;
min-height: 0;
padding: 0;
overflow: hidden;
}
.page-grid { .page-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@ -856,87 +863,82 @@ select {
.reader-page { .reader-page {
display: grid; display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto; grid-template-rows: auto auto minmax(0, 1fr);
gap: 10px; gap: 8px;
height: 100vh; height: 100vh;
min-height: 100vh; min-height: 100vh;
padding: 14px; padding: 10px;
overflow: hidden; overflow: hidden;
background: #120e0b; background: #120e0b;
} }
.reader-page:fullscreen { main.app-main-reader .reader-page {
width: 100vw; height: 100%;
height: 100vh; min-height: 0;
} }
.reader-topbar { .reader-topbar {
position: relative; grid-row: 1;
z-index: 5; display: flex;
justify-content: space-between; align-items: center;
padding: 10px; gap: 8px;
min-width: 0;
min-height: 42px;
padding: 6px 8px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius);
background: rgba(38, 26, 18, 0.84); background: rgba(38, 26, 18, 0.84);
} }
.reader-topbar div { .reader-title {
display: grid; flex: 1 1 auto;
justify-items: center; overflow: hidden;
} min-width: 0;
max-width: none;
.reader-topbar span { text-overflow: ellipsis;
color: var(--ink-muted);
font-size: 0.82rem;
}
.reader-toolbar {
display: flex;
align-items: center;
gap: 8px;
}
.reader-toolbar .icon-only {
width: 40px;
padding: 0;
}
.reader-toolbar .reader-mode-button {
gap: 7px;
padding-inline: 10px;
white-space: nowrap; white-space: nowrap;
} }
.reader-toolbar .active { .reader-position {
border-color: rgba(213, 168, 77, 0.72); flex: 0 0 auto;
color: var(--brass); min-width: 92px;
background: rgba(213, 168, 77, 0.14); color: var(--ink-muted);
text-align: center;
}
.reader-topbar .ghost-button {
flex: 0 0 auto;
min-height: 32px;
white-space: nowrap;
} }
.reader-status { .reader-status {
position: relative; grid-row: 2;
z-index: 4; display: flex;
align-items: center;
gap: 8px;
min-width: 0;
} }
.reader-stage { .reader-stage {
grid-row: 3;
position: relative; position: relative;
display: grid; display: grid;
place-items: stretch; place-items: center;
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
} }
.reader-mode-vertical .reader-stage {
overflow-y: auto;
overscroll-behavior: contain;
}
.reader-content { .reader-content {
display: grid; display: grid;
place-items: center;
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
width: 100%;
height: 100%;
overflow: hidden;
} }
.pdf-reader, .pdf-reader,
@ -950,19 +952,19 @@ select {
height: 100%; height: 100%;
} }
.pdf-reader-vertical,
.cbz-reader-vertical {
place-items: start center;
height: auto;
}
.epub-host { .epub-host {
display: grid; display: grid;
width: min(100%, 980px); width: 100%;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
.epub-reader {
place-items: stretch;
overflow: auto;
padding: 14px;
}
.epub-view { .epub-view {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -982,56 +984,23 @@ select {
margin: 0; margin: 0;
} }
.pdf-page-frame canvas { .pdf-page-frame canvas,
.cbz-reader img {
display: block; display: block;
max-width: min(100%, 980px); width: auto;
height: auto;
max-width: 100%;
max-height: 100%; max-height: 100%;
box-sizing: border-box; box-sizing: border-box;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius);
background: #f7f0df; background: #f7f0df;
object-fit: contain;
} }
.cbz-reader img { .cbz-reader img {
display: block; max-width: 100%;
box-sizing: border-box;
max-width: min(100%, 980px);
max-height: 100%; max-height: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #f7f0df;
object-fit: contain;
}
.reader-mode-vertical .pdf-reader,
.reader-mode-vertical .cbz-reader {
align-content: start;
padding-bottom: 20px;
}
.pdf-strip,
.comic-strip {
display: grid;
justify-items: center;
gap: 18px;
width: 100%;
padding: 0 0 24px;
}
.reader-mode-vertical .pdf-page-frame canvas,
.reader-mode-vertical .cbz-reader img {
width: min(100%, 980px);
height: auto;
max-height: none;
object-fit: contain;
}
.reader-mode-horizontal .pdf-page-frame canvas,
.reader-mode-horizontal .cbz-reader img {
width: auto;
height: auto;
max-height: 100%;
object-fit: contain;
} }
.reader-fallback { .reader-fallback {
@ -1044,71 +1013,6 @@ select {
color: var(--ink-muted); color: var(--ink-muted);
} }
.reader-stepper {
position: relative;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 46px;
padding: 6px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(38, 26, 18, 0.84);
}
.reader-stepper span {
min-width: 92px;
color: var(--ink-muted);
text-align: center;
}
.reader-side-button,
.reader-tap-zone {
position: absolute;
top: 0;
bottom: 0;
z-index: 3;
border: 0;
background: transparent;
}
.reader-side-button {
display: grid;
place-items: center;
width: 58px;
color: var(--ink);
opacity: 0.66;
}
.reader-side-button:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.04);
}
.reader-side-button:disabled,
.reader-tap-zone:disabled {
pointer-events: none;
opacity: 0;
}
.reader-side-left,
.reader-tap-left {
left: 0;
}
.reader-side-right,
.reader-tap-right {
right: 0;
}
.reader-tap-zone {
display: none;
width: 34%;
opacity: 0;
}
.reader-error { .reader-error {
display: grid; display: grid;
gap: 14px; gap: 14px;
@ -1186,6 +1090,12 @@ select {
padding: 16px 16px 86px; padding: 16px 16px 86px;
} }
main.app-main-reader {
height: calc(100vh - 58px);
padding: 0 0 58px;
overflow: hidden;
}
.page-grid { .page-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -1251,71 +1161,7 @@ select {
gap: 8px; gap: 8px;
} }
.reader-topbar div {
min-width: 0;
}
.reader-topbar strong {
overflow: hidden;
max-width: 38vw;
text-overflow: ellipsis;
white-space: nowrap;
}
.reader-toolbar {
gap: 5px;
}
.reader-side-button {
display: none;
}
.reader-tap-zone {
display: block;
}
.reader-controls-hidden .reader-topbar,
.reader-controls-hidden .reader-status,
.reader-controls-hidden .reader-stepper {
opacity: 0;
pointer-events: none;
}
.reader-controls-hidden {
grid-template-rows: 0 0 minmax(0, 1fr) 0;
gap: 0;
}
.reader-controls-hidden .reader-topbar,
.reader-controls-hidden .reader-status,
.reader-controls-hidden .reader-stepper {
overflow: hidden;
min-height: 0;
padding-top: 0;
padding-bottom: 0;
}
.reader-topbar,
.reader-status,
.reader-stepper {
transition: opacity 0.16s ease;
}
.reader-stepper {
gap: 8px;
}
.reader-stepper .ghost-button {
flex: 1 1 0;
min-width: 0;
}
.epub-host { .epub-host {
width: 100%; width: 100%;
} }
.reader-mode-vertical .pdf-reader canvas,
.reader-mode-vertical .cbz-reader img {
width: 100%;
}
} }