merge: feature/53-adaptive-reading dans develop (lecture adaptative pour mauvaises connexions — manifest, ressources Range/ETag, fenêtre de lecture priorisée, cache reconstructible, EPUB par ressources manifestées et fallback PDF allégé — évolution #53)

This commit is contained in:
Git Agent
2026-09-29 23:56:50 +02:00
27 changed files with 1384 additions and 95 deletions

View File

@ -6,11 +6,12 @@ import { BooksModule } from "./books/books.module.js";
import { DatabaseModule } from "./database/database.module.js";
import { ProgressModule } from "./progress/progress.module.js";
import { ReaderModule } from "./reader/reader.module.js";
import { ReadingModule } from "./reading/reading.module.js";
import { ScannerModule } from "./scanner/scanner.module.js";
import { HealthController } from "./health.controller.js";
@Module({
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ScannerModule, AutomationModule],
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ReadingModule, ScannerModule, AutomationModule],
controllers: [HealthController]
})
export class AppModule {}

View File

@ -0,0 +1,75 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import AdmZip from "adm-zip";
import { afterEach, describe, expect, it } from "vitest";
import { ArchiveReadingSourceAdapter } from "./archive-reading-source.adapter.js";
const directories: string[] = [];
afterEach(() => {
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true });
});
describe("ArchiveReadingSourceAdapter", () => {
it("builds an ordered EPUB spine and serves only opaque resource ids", async () => {
const directory = mkdtempSync(join(tmpdir(), "readabook-reading-"));
directories.push(directory);
const filePath = join(directory, "book.epub");
const zip = new AdmZip();
zip.addFile("META-INF/container.xml", Buffer.from(`<?xml version="1.0"?><container><rootfiles><rootfile full-path="OPS/package.opf"/></rootfiles></container>`));
zip.addFile("OPS/package.opf", Buffer.from(`<package><manifest><item id="one" href="chapter-1.xhtml" media-type="application/xhtml+xml"/><item id="pic" href="images/cover.jpg" media-type="image/jpeg"/><item id="two" href="chapter-2.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="one"/><itemref idref="two"/></spine></package>`));
zip.addFile("OPS/chapter-1.xhtml", Buffer.from('<html><head><link href="styles/book.css" rel="stylesheet"/></head><body><img src="images/cover.jpg"/><p>First</p></body></html>'));
zip.addFile("OPS/chapter-2.xhtml", Buffer.from("<p>Second</p>"));
zip.addFile("OPS/images/cover.jpg", Buffer.from([1, 2, 3]));
zip.addFile("OPS/styles/book.css", Buffer.from("body { color: black; }"));
zip.writeZip(filePath);
const stat = await import("node:fs").then(({ statSync }) => statSync(filePath));
const source = new ArchiveReadingSourceAdapter();
const book = { id: 7, format: "epub", filePath, fileSize: stat.size, fileMtime: stat.mtime.toISOString() };
const manifest = await source.manifest(book);
expect(manifest.spine).toHaveLength(2);
expect(manifest).toMatchObject({
containerPath: "META-INF/container.xml",
opfPath: "OPS/package.opf",
basePath: "OPS/",
revision: expect.any(String)
});
expect(JSON.stringify(manifest)).not.toContain(filePath);
expect(JSON.stringify(manifest)).not.toContain(`/books/${book.id}/file`);
expect(manifest.resources).toHaveLength(6);
expect(manifest.resources.map((resource) => resource.resourceId)).not.toContain("OPS/chapter-1.xhtml");
expect(manifest.resources).toEqual(expect.arrayContaining([
expect.objectContaining({ href: "META-INF/container.xml", mediaType: "application/xml", size: expect.any(Number), url: expect.stringContaining("/reading-resources/") }),
expect.objectContaining({ href: "OPS/package.opf", mediaType: "application/oebps-package+xml" }),
expect.objectContaining({ href: "OPS/images/cover.jpg", mediaType: "image/jpeg", size: 3 })
]));
expect(manifest.spine[0]).toMatchObject({ index: 0, href: "OPS/chapter-1.xhtml", mediaType: "application/xhtml+xml", size: expect.any(Number) });
const firstResource = manifest.resources.find((resource) => resource.href === "OPS/chapter-1.xhtml");
const coverResource = manifest.resources.find((resource) => resource.href === "OPS/images/cover.jpg");
expect(firstResource?.references).toContain(coverResource?.resourceId);
const chapter = await source.resource(book, manifest.spine[0].resourceId);
expect(chapter.contentType).toBe("application/xhtml+xml");
expect(chapter.data?.toString()).toContain("<p>First</p>");
const container = manifest.resources.find((resource) => resource.href === manifest.containerPath);
expect((await source.resource(book, container!.resourceId)).data?.toString()).toContain("container");
});
it("exposes a PDF as a range-capable original without leaking its path in the manifest", async () => {
const directory = mkdtempSync(join(tmpdir(), "readabook-reading-"));
directories.push(directory);
const filePath = join(directory, "secret-name.pdf");
writeFileSync(filePath, "%PDF-test");
const source = new ArchiveReadingSourceAdapter();
const book = { id: 8, format: "pdf", filePath, fileSize: 9, fileMtime: "2026-01-01T00:00:00.000Z" };
const manifest = await source.manifest(book);
const resource = await source.resource(book, "original");
expect(JSON.stringify(manifest)).not.toContain(filePath);
expect(manifest).toMatchObject({ format: "pdf", spine: [expect.objectContaining({ resourceId: "original" })], capabilities: { byteRanges: true, lightweightPages: false } });
expect(resource).toMatchObject({ filePath, contentType: "application/pdf", supportsRanges: true });
});
});

View File

@ -0,0 +1,220 @@
import { createHash } from "node:crypto";
import { existsSync, statSync } from "node:fs";
import { extname, posix } from "node:path";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import AdmZip from "adm-zip";
import { XMLParser } from "fast-xml-parser";
import { lookup } from "mime-types";
import { ReadingSourcePort } from "../reading.ports.js";
import { ReadingBook, ReadingManifest, ReadingResource, ReadingResourcePayload, ReadingSpineItem } from "../reading.types.js";
const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" });
const MAX_EPUB_ENTRIES = 20_000;
const MAX_RESOURCE_BYTES = 64 * 1024 * 1024;
@Injectable()
export class ArchiveReadingSourceAdapter implements ReadingSourcePort {
private readonly epubManifests = new Map<number, { revision: string; manifest: ReadingManifest }>();
async manifest(book: ReadingBook): Promise<ReadingManifest> {
this.assertReadable(book);
if (book.format === "pdf") return pdfManifest(book);
if (book.format !== "epub") throw new BadRequestException("Adaptive reading supports EPUB and PDF books");
const revision = revisionFor(book);
const cached = this.epubManifests.get(book.id);
if (cached?.revision === revision) return cached.manifest;
const manifest = epubManifest(book);
this.epubManifests.set(book.id, { revision, manifest });
return manifest;
}
async resource(book: ReadingBook, resourceId: string): Promise<ReadingResourcePayload> {
this.assertReadable(book);
if (book.format === "pdf") {
if (resourceId !== "original") throw new NotFoundException("Reading resource not found");
const size = statSync(book.filePath).size;
return { filePath: book.filePath, contentType: "application/pdf", size, etag: etag(book, resourceId), supportsRanges: true };
}
if (book.format !== "epub") throw new BadRequestException("Adaptive reading supports EPUB and PDF books");
const manifest = await this.manifest(book);
const resource = manifest.resources.find((candidate) => candidate.resourceId === resourceId);
if (!resource) throw new NotFoundException("Reading resource not found");
const zip = new AdmZip(book.filePath);
const entry = zip.getEntry(resource.href);
if (!entry || entry.isDirectory) throw new NotFoundException("Reading resource not found");
if (entry.header.size > MAX_RESOURCE_BYTES) throw new BadRequestException("EPUB resource is too large to extract");
const data = entry.getData();
return { data, contentType: resource.mediaType, size: data.length, etag: etag(book, resourceId), supportsRanges: false };
}
private assertReadable(book: ReadingBook): void {
if (!existsSync(book.filePath)) throw new NotFoundException("Book file not found on disk");
}
}
function epubManifest(book: ReadingBook): ReadingManifest {
const zip = new AdmZip(book.filePath);
const entries = zip.getEntries();
if (entries.length > MAX_EPUB_ENTRIES) throw new BadRequestException("EPUB archive has too many entries");
const containerPath = "META-INF/container.xml";
const container = xml.parse(zip.readAsText(containerPath));
const rootfiles = array(container?.container?.rootfiles?.rootfile);
const opfPath = rootfiles[0]?.["@_full-path"];
if (typeof opfPath !== "string") throw new BadRequestException("EPUB package document is missing");
const normalizedOpf = normalizeArchivePath(opfPath);
const parsed = xml.parse(zip.readAsText(normalizedOpf));
const packageDocument = parsed?.package;
const directory = posix.dirname(normalizedOpf);
const basePath = directory === "." ? "" : `${directory}/`;
const items = array(packageDocument?.manifest?.item);
const spineItems = array(packageDocument?.spine?.itemref);
const declaredMediaTypes = new Map<string, string>();
const packageIds = new Map<string, string>();
for (const item of items) {
const packageId = stringAttribute(item, "@_id");
const href = stringAttribute(item, "@_href");
if (!href) continue;
const archivePath = normalizeArchivePath(posix.join(directory, decodeHref(href)));
const mediaType = stringAttribute(item, "@_media-type");
if (mediaType) declaredMediaTypes.set(archivePath, mediaType);
if (packageId) packageIds.set(packageId, archivePath);
}
const byPackageId = new Map<string, ReadingResource>();
const resources = entries.flatMap((entry): ReadingResource[] => {
if (entry.isDirectory) return [];
const archivePath = normalizeArchivePath(entry.entryName);
const mediaType = declaredMediaTypes.get(archivePath) ?? archiveMediaType(archivePath, normalizedOpf);
const resource: ReadingResource = {
resourceId: opaqueId(archivePath),
href: archivePath,
url: resourceUrl(book.id, opaqueId(archivePath)),
kind: resourceKind(mediaType),
mediaType,
size: entry.header.size,
state: "ready"
};
return [resource];
});
const byHref = new Map(resources.map((resource) => [resource.href, resource]));
for (const [packageId, archivePath] of packageIds) {
const resource = byHref.get(archivePath);
if (resource) byPackageId.set(packageId, resource);
}
addResourceReferences(zip, resources, byHref);
const spine = spineItems.flatMap((item, index): ReadingSpineItem[] => {
if (stringAttribute(item, "@_linear") === "no") return [];
const resource = byPackageId.get(stringAttribute(item, "@_idref") ?? "");
if (!resource) return [];
return [{ index, href: resource.href, resourceId: resource.resourceId, url: resource.url, mediaType: resource.mediaType, size: resource.size }];
});
const revision = revisionFor(book);
return {
bookId: book.id,
format: "epub",
revision,
version: revision,
containerPath,
opfPath: normalizedOpf,
basePath,
resources,
spine,
capabilities: { byteRanges: false, derivedImages: true, lightweightPages: false }
};
}
function pdfManifest(book: ReadingBook): ReadingManifest {
const revision = revisionFor(book);
return {
bookId: book.id,
format: "pdf",
revision,
version: revision,
containerPath: null,
opfPath: null,
basePath: null,
resources: [{ resourceId: "original", href: "original", url: resourceUrl(book.id, "original"), kind: "document", mediaType: "application/pdf", size: book.fileSize, state: "ready" }],
spine: [{ index: 0, href: "original", resourceId: "original", url: resourceUrl(book.id, "original"), mediaType: "application/pdf", size: book.fileSize }],
capabilities: { byteRanges: true, derivedImages: false, lightweightPages: false }
};
}
function normalizeArchivePath(path: string): string {
const normalized = posix.normalize(path.replace(/^\/+/, ""));
if (normalized === ".." || normalized.startsWith("../")) throw new BadRequestException("Invalid EPUB resource path");
return normalized;
}
function decodeHref(href: string): string {
try {
return decodeURIComponent(href.split("#", 1)[0]);
} catch {
throw new BadRequestException("Invalid EPUB resource path");
}
}
function opaqueId(path: string): string {
return createHash("sha256").update(path).digest("base64url").slice(0, 22);
}
function revisionFor(book: ReadingBook): string {
return createHash("sha256").update(`${book.fileSize}:${book.fileMtime}`).digest("base64url");
}
function resourceUrl(bookId: number, resourceId: string): string {
return `/books/${bookId}/reading-resources/${encodeURIComponent(resourceId)}`;
}
function archiveMediaType(path: string, opfPath: string): string {
if (path === "META-INF/container.xml") return "application/xml";
if (path === opfPath) return "application/oebps-package+xml";
if (path === "mimetype") return "text/plain";
return String(lookup(extname(path)) || "application/octet-stream");
}
function addResourceReferences(zip: AdmZip, resources: ReadingResource[], byHref: Map<string, ReadingResource>): void {
for (const resource of resources) {
if ((resource.kind !== "chapter" && resource.kind !== "stylesheet") || resource.size > 2 * 1024 * 1024) continue;
const entry = zip.getEntry(resource.href);
if (!entry) continue;
const text = entry.getData().toString("utf8");
const candidates = resource.kind === "stylesheet"
? [...text.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/gi)].map((match) => match[1])
: [...text.matchAll(/(?:src|href)\s*=\s*["']([^"']+)["']/gi)].map((match) => match[1]);
const references = candidates.flatMap((candidate): string[] => {
const href = resolveReference(resource.href, candidate);
const target = href ? byHref.get(href) : undefined;
return target && target.kind !== "chapter" ? [target.resourceId] : [];
});
resource.references = [...new Set(references)];
}
}
function resolveReference(parent: string, reference: string): string | null {
const clean = reference.trim();
if (!clean || clean.startsWith("#") || /^(?:data|https?|mailto|tel):/i.test(clean) || clean.startsWith("//")) return null;
try {
return normalizeArchivePath(posix.join(posix.dirname(parent), decodeHref(clean)));
} catch {
return null;
}
}
function etag(book: ReadingBook, resourceId: string): string {
return `\"${createHash("sha256").update(`${book.id}:${book.fileSize}:${book.fileMtime}:${resourceId}`).digest("base64url")}\"`;
}
function array(value: any): any[] {
return Array.isArray(value) ? value : value ? [value] : [];
}
function stringAttribute(value: any, key: string): string | null {
return typeof value?.[key] === "string" ? value[key] : null;
}
function resourceKind(mediaType: string): ReadingResource["kind"] {
if (mediaType.startsWith("image/")) return "image";
if (mediaType === "text/css") return "stylesheet";
if (mediaType.includes("font") || mediaType.includes("woff")) return "font";
if (mediaType.includes("html") || mediaType.includes("xml")) return "chapter";
return "document";
}

View File

@ -0,0 +1,37 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Injectable } from "@nestjs/common";
import { DatabaseService } from "../../database/database.service.js";
import { ReadingCachePort } from "../reading.ports.js";
import { ReadingBook } from "../reading.types.js";
@Injectable()
export class FilesystemReadingCacheAdapter implements ReadingCachePort {
constructor(private readonly database: DatabaseService) {}
async get(book: ReadingBook, resourceId: string): Promise<Buffer | null> {
try {
return await readFile(this.path(book, resourceId));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
async put(book: ReadingBook, resourceId: string, data: Buffer): Promise<void> {
const path = this.path(book, resourceId);
await mkdir(join(path, ".."), { recursive: true });
await writeFile(path, data);
}
async remove(book: ReadingBook, resourceId: string): Promise<void> {
await rm(this.path(book, resourceId), { force: true });
}
private path(book: ReadingBook, resourceId: string): string {
const version = createHash("sha256").update(`${book.fileSize}:${book.fileMtime}`).digest("hex").slice(0, 16);
const resource = createHash("sha256").update(resourceId).digest("hex");
return join(this.database.config.storageDir, "reading-cache", String(book.id), version, resource);
}
}

View File

@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";
import { ReadingController } from "./reading.controller.js";
describe("ReadingController", () => {
it("returns 304 for a matching ETag without a range request", async () => {
const controller = new ReadingController({
resource: vi.fn().mockResolvedValue(resourcePayload({ partial: false }))
} as any);
const reply = fakeReply();
await controller.resource("12", "chapter-1", undefined, '"etag"', reply as any);
expect(reply.statusCode).toBe(304);
expect(reply.sent).toBeUndefined();
expect(reply.headers).toEqual({});
});
it("sets cache, ETag and range headers for partial PDF resources", async () => {
const controller = new ReadingController({
resource: vi.fn().mockResolvedValue(resourcePayload({ partial: true, supportsRanges: true, start: 2, end: 5, size: 10, data: Buffer.from("2345") }))
} as any);
const reply = fakeReply();
await controller.resource("12", "original", "bytes=2-5", undefined, reply as any);
expect(reply.statusCode).toBe(206);
expect(reply.headers).toMatchObject({
"Accept-Ranges": "bytes",
"Cache-Control": "private, max-age=3600, must-revalidate",
"Content-Length": "4",
"Content-Range": "bytes 2-5/10",
"Content-Type": "application/pdf",
ETag: '"etag"',
"X-Content-Type-Options": "nosniff"
});
expect(reply.sent?.toString()).toBe("2345");
});
});
function resourcePayload(overrides: Record<string, unknown>) {
return {
data: Buffer.from("chapter"),
contentType: "application/pdf",
size: 7,
etag: '"etag"',
supportsRanges: false,
start: 0,
end: 6,
partial: false,
...overrides
};
}
function fakeReply() {
return {
statusCode: 200,
headers: {} as Record<string, string>,
sent: undefined as unknown,
code(status: number) {
this.statusCode = status;
return this;
},
header(name: string, value: string) {
this.headers[name] = value;
return this;
},
send(payload?: unknown) {
this.sent = payload;
return this;
}
};
}

View File

@ -0,0 +1,50 @@
import { Body, Controller, Get, Headers, Param, Post, Res, UseGuards } from "@nestjs/common";
import { FastifyReply } from "fastify";
import { AuthGuard } from "../auth/auth.guard.js";
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
import { ReadingWindowDto, ReadingWindowSchema } from "./reading.dto.js";
import { ReadingService } from "./reading.service.js";
@Controller("books/:bookId")
@UseGuards(AuthGuard)
export class ReadingController {
constructor(private readonly reading: ReadingService) {}
@Get("reading-manifest")
manifest(@Param("bookId") bookId: string) {
return this.reading.manifest(Number(bookId));
}
@Get("reading-resources/:resourceId")
async resource(
@Param("bookId") bookId: string,
@Param("resourceId") resourceId: string,
@Headers("range") range: string | undefined,
@Headers("if-none-match") ifNoneMatch: string | undefined,
@Res() reply: FastifyReply
) {
const resource = await this.reading.resource(Number(bookId), resourceId, range);
if (!range && ifNoneMatch === resource.etag) return reply.code(304).send();
if (resource.partial) {
reply.code(206);
reply.header("Content-Range", `bytes ${resource.start}-${resource.end}/${resource.size}`);
}
if (resource.supportsRanges) reply.header("Accept-Ranges", "bytes");
reply.header("Content-Length", String(resource.end - resource.start + 1));
reply.header("Content-Type", resource.contentType);
reply.header("ETag", resource.etag);
reply.header("Cache-Control", "private, max-age=3600, must-revalidate");
reply.header("X-Content-Type-Options", "nosniff");
return reply.send(resource.stream ?? resource.data);
}
@Post("reading-window")
window(@Param("bookId") bookId: string, @Body(new ZodValidationPipe(ReadingWindowSchema)) input: ReadingWindowDto) {
return this.reading.window(Number(bookId), input);
}
@Post("reading-resources/:resourceId/retry")
retry(@Param("bookId") bookId: string, @Param("resourceId") resourceId: string) {
return this.reading.retry(Number(bookId), resourceId);
}
}

View File

@ -0,0 +1,9 @@
import { z } from "zod";
export const ReadingWindowSchema = z.object({
locator: z.string().trim().min(1).max(512),
mode: z.enum(["auto", "speed", "quality"]).default("auto"),
networkHint: z.enum(["offline", "slow", "normal", "fast", "unknown"]).default("unknown")
});
export type ReadingWindowDto = z.infer<typeof ReadingWindowSchema>;

View File

@ -0,0 +1,20 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { DatabaseModule } from "../database/database.module.js";
import { JobsModule } from "../jobs/jobs.module.js";
import { ArchiveReadingSourceAdapter } from "./adapters/archive-reading-source.adapter.js";
import { FilesystemReadingCacheAdapter } from "./adapters/filesystem-reading-cache.adapter.js";
import { ReadingController } from "./reading.controller.js";
import { ReadingCachePort, ReadingSourcePort } from "./reading.ports.js";
import { ReadingService } from "./reading.service.js";
@Module({
imports: [AuthModule, DatabaseModule, JobsModule],
controllers: [ReadingController],
providers: [
ReadingService,
{ provide: ReadingSourcePort, useClass: ArchiveReadingSourceAdapter },
{ provide: ReadingCachePort, useClass: FilesystemReadingCacheAdapter }
]
})
export class ReadingModule {}

View File

@ -0,0 +1,12 @@
import { ReadingBook, ReadingManifest, ReadingResourcePayload } from "./reading.types.js";
export abstract class ReadingSourcePort {
abstract manifest(book: ReadingBook): Promise<ReadingManifest>;
abstract resource(book: ReadingBook, resourceId: string): Promise<ReadingResourcePayload>;
}
export abstract class ReadingCachePort {
abstract get(book: ReadingBook, resourceId: string): Promise<Buffer | null>;
abstract put(book: ReadingBook, resourceId: string, data: Buffer): Promise<void>;
abstract remove(book: ReadingBook, resourceId: string): Promise<void>;
}

View File

@ -0,0 +1,169 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { DatabaseService } from "../database/database.service.js";
import { books, jobs, libraries } from "../database/schema.js";
import { JobsService } from "../jobs/jobs.service.js";
import { ReadingCachePort, ReadingSourcePort } from "./reading.ports.js";
import { ReadingService } from "./reading.service.js";
import { ReadingBook, ReadingManifest, ReadingResourcePayload } from "./reading.types.js";
const previousDatabasePath = process.env.DATABASE_PATH;
const previousStorageDir = process.env.STORAGE_DIR;
const directories: string[] = [];
afterEach(() => {
process.env.DATABASE_PATH = previousDatabasePath;
process.env.STORAGE_DIR = previousStorageDir;
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true });
});
describe("ReadingService", () => {
it.runIf(canLoadBetterSqlite())("rejects books in disabled libraries", async () => {
const { database, bookId } = fixture(false);
const service = new ReadingService(database, new JobsService(database), new FakeSource(), new MemoryCache());
await expect(service.manifest(bookId)).rejects.toThrow("accessible library");
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("reports a priority window around the current spine item without preparing while offline", async () => {
const { database, bookId } = fixture(true);
const service = new ReadingService(database, new JobsService(database), new FakeSource(), new MemoryCache());
const window = await service.window(bookId, { locator: "chapter-2", mode: "auto", networkHint: "offline" });
expect(window.current).toMatchObject({ resourceId: "chapter-2", relation: "current" });
expect(window.previous).toMatchObject({ resourceId: "chapter-1", relation: "previous" });
expect(window.next).toMatchObject({ resourceId: "chapter-3", relation: "next" });
expect(window.resources).toEqual([expect.objectContaining({ resourceId: "chapter-image", relation: "resource" })]);
expect(window.neighbors).toEqual([]);
expect(database.db.select().from((await import("../database/schema.js")).jobs).all()).toHaveLength(0);
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("queues the current resource before previous and next resources on slow connections", async () => {
const { database, bookId } = fixture(true);
const service = new ReadingService(database, new JobsService(database), new FakeSource(), new MemoryCache());
const window = await service.window(bookId, { locator: "chapter-2", mode: "speed", networkHint: "slow" });
expect(window.current).toMatchObject({ resourceId: "chapter-2", relation: "current", state: "pending" });
expect(window.previous).toMatchObject({ resourceId: "chapter-1", relation: "previous", state: "pending" });
expect(window.next).toMatchObject({ resourceId: "chapter-3", relation: "next", state: "pending" });
const [job] = database.db.select().from(jobs).all();
expect(job).toMatchObject({ type: "prepare-reading-window", status: "queued" });
expect(JSON.parse(job.detail ?? "{}")).toEqual({ bookId, resourceIds: ["chapter-2", "chapter-image", "chapter-1", "chapter-3"] });
await flushImmediates();
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("serves byte ranges for the original PDF resource", async () => {
const { database, bookId, directory } = fixture(true, "pdf");
const filePath = join(directory, "book.pdf");
writeFileSync(filePath, Buffer.from("0123456789"));
database.db.update(books).set({ filePath, fileSize: 10 }).where(eq(books.id, bookId)).run();
const service = new ReadingService(database, new JobsService(database), new FakeSource(), new MemoryCache());
const resource = await service.resource(bookId, "original", "bytes=2-5");
expect(resource).toMatchObject({
contentType: "application/pdf",
etag: '"pdf-etag"',
supportsRanges: true,
size: 10,
start: 2,
end: 5,
partial: true
});
await expect(readStream(resource.stream)).resolves.toEqual(Buffer.from("2345"));
database.onModuleDestroy();
});
});
class FakeSource extends ReadingSourcePort {
async manifest(book: ReadingBook): Promise<ReadingManifest> {
if (book.format === "pdf") {
return {
bookId: book.id,
format: "pdf",
version: "pdf-version",
revision: "pdf-version",
containerPath: null, opfPath: null, basePath: null,
resources: [{ resourceId: "original", href: "original", url: "/original", kind: "document", mediaType: "application/pdf", size: book.fileSize, state: "ready" }],
spine: [{ index: 0, href: "original", resourceId: "original", url: "/original", mediaType: "application/pdf", size: book.fileSize }],
capabilities: { byteRanges: true, derivedImages: false, lightweightPages: false }
};
}
return {
bookId: book.id,
format: "epub",
revision: "version",
version: "version",
containerPath: "META-INF/container.xml", opfPath: "OPS/package.opf", basePath: "OPS/",
resources: [
...["chapter-1", "chapter-2", "chapter-3"].map((id) => ({ resourceId: id, href: id, url: `/${id}`, kind: "chapter" as const, mediaType: "application/xhtml+xml", size: 7, state: "ready" as const, ...(id === "chapter-2" ? { references: ["chapter-image"] } : {}) })),
{ resourceId: "chapter-image", href: "image.jpg", url: "/chapter-image", kind: "image" as const, mediaType: "image/jpeg", size: 5, state: "ready" as const }
],
spine: ["chapter-1", "chapter-2", "chapter-3"].map((id, index) => ({ index, href: id, resourceId: id, url: `/${id}`, mediaType: "application/xhtml+xml", size: 7 })),
capabilities: { byteRanges: false, derivedImages: true, lightweightPages: false }
};
}
async resource(book: ReadingBook, resourceId: string): Promise<ReadingResourcePayload> {
if (book.format === "pdf" && resourceId === "original") {
return { filePath: book.filePath, contentType: "application/pdf", size: book.fileSize, etag: '"pdf-etag"', supportsRanges: true };
}
return { data: Buffer.from("chapter"), contentType: "application/xhtml+xml", size: 7, etag: '"etag"', supportsRanges: false };
}
}
class MemoryCache extends ReadingCachePort {
private readonly values = new Map<string, Buffer>();
async get(_book: ReadingBook, resourceId: string) { return this.values.get(resourceId) ?? null; }
async put(_book: ReadingBook, resourceId: string, data: Buffer) { this.values.set(resourceId, data); }
async remove(_book: ReadingBook, resourceId: string) { this.values.delete(resourceId); }
}
function fixture(enabled: boolean, format: "epub" | "pdf" = "epub") {
const directory = mkdtempSync(join(tmpdir(), "readabook-reading-service-"));
directories.push(directory);
process.env.DATABASE_PATH = join(directory, "test.sqlite");
process.env.STORAGE_DIR = join(directory, "storage");
const database = new DatabaseService();
const now = database.now();
const library = database.db.insert(libraries).values({ name: "Library", path: directory, enabled, createdAt: now, updatedAt: now }).returning().get();
const book = database.db.insert(books).values({
libraryId: library.id, seriesId: null, title: "Book", author: null, description: null, isbn: null, isbn13: null,
identifiersJson: null, localMetadataJson: null, language: null, publisher: null, publishedDate: null, volumeNumber: null,
volumeLabel: null, format, filePath: join(directory, `book.${format}`), coverPath: null, metadataStatus: "none",
metadataProvenanceJson: null, scanStatus: "succeeded", enrichmentStatus: "idle", fileSize: 10, fileMtime: now,
createdAt: now, updatedAt: now
}).returning().get();
return { database, bookId: book.id, directory };
}
function canLoadBetterSqlite(): boolean {
try {
return Boolean(DatabaseService);
} catch {
return false;
}
}
function flushImmediates() {
return new Promise<void>((resolve) => setImmediate(resolve));
}
function readStream(stream: NodeJS.ReadableStream | undefined) {
return new Promise<Buffer>((resolve, reject) => {
if (!stream) {
resolve(Buffer.alloc(0));
return;
}
const chunks: Buffer[] = [];
stream.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
stream.on("error", reject);
stream.on("end", () => resolve(Buffer.concat(chunks)));
});
}

View File

@ -0,0 +1,168 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { BadRequestException, HttpException, HttpStatus, Inject, Injectable, NotFoundException } from "@nestjs/common";
import { and, eq } from "drizzle-orm";
import { DatabaseService } from "../database/database.service.js";
import { books, libraries } from "../database/schema.js";
import { JobsService } from "../jobs/jobs.service.js";
import { ReadingCachePort, ReadingSourcePort } from "./reading.ports.js";
import { ReadingBook, ReadingResourcePayload, ReadingWindowInput } from "./reading.types.js";
@Injectable()
export class ReadingService {
private readonly preparing = new Set<string>();
constructor(
private readonly database: DatabaseService,
private readonly jobs: JobsService,
@Inject(ReadingSourcePort) private readonly source: ReadingSourcePort,
@Inject(ReadingCachePort) private readonly cache: ReadingCachePort
) {}
async manifest(bookId: number) {
return this.source.manifest(this.accessibleBook(bookId));
}
async resource(bookId: number, resourceId: string, range?: string): Promise<ReadingResourcePayload & { stream?: NodeJS.ReadableStream; start: number; end: number; partial: boolean }> {
const book = this.accessibleBook(bookId);
const cached = book.format === "epub" ? await this.cache.get(book, resourceId) : null;
const manifestResource = cached ? (await this.source.manifest(book)).resources.find((resource) => resource.resourceId === resourceId) : null;
if (cached && !manifestResource) throw new NotFoundException("Reading resource not found");
const payload = cached && manifestResource
? { data: cached, contentType: manifestResource.mediaType, size: cached.length, etag: manifestVersionEtag(book, resourceId), supportsRanges: false }
: await this.source.resource(book, resourceId);
const byteRange = parseByteRange(range, payload.size, payload.supportsRanges);
if (payload.filePath) {
return { ...payload, stream: createReadStream(payload.filePath, { start: byteRange.start, end: byteRange.end }), ...byteRange };
}
const data = payload.data;
if (!data) throw new NotFoundException("Reading resource not found");
if (!cached) await this.cache.put(book, resourceId, data);
return { ...payload, data: data.subarray(byteRange.start, byteRange.end + 1), ...byteRange };
}
async window(bookId: number, input: ReadingWindowInput) {
const book = this.accessibleBook(bookId);
const manifest = await this.source.manifest(book);
const position = resolvePosition(input.locator, manifest.spine);
const current = manifest.spine[position];
const referenced = referencedResources(current.resourceId, manifest.resources);
const ordered = priorityWindow(manifest.spine, position, referenced, input.networkHint === "slow" || input.mode === "speed" ? 1 : 2);
const states = await Promise.all(ordered.map(async ({ id, distance }) => ({
resourceId: id,
relation: distance === 0 ? "current" : distance === null ? "resource" : distance === -1 ? "previous" : distance === 1 ? "next" : "neighbor",
state: await this.resourceState(book, id)
})));
if (input.networkHint !== "offline") this.enqueuePreparation(book, ordered.map((entry) => entry.id));
return {
bookId,
locator: input.locator,
mode: input.mode,
networkHint: input.networkHint,
current: states.find((state) => state.relation === "current") ?? null,
previous: states.find((state) => state.relation === "previous") ?? null,
next: states.find((state) => state.relation === "next") ?? null,
resources: states.filter((state) => state.relation === "resource"),
neighbors: states.filter((state) => state.relation === "neighbor")
};
}
async retry(bookId: number, resourceId: string) {
const book = this.accessibleBook(bookId);
const manifest = await this.source.manifest(book);
if (!manifest.resources.some((resource) => resource.resourceId === resourceId)) throw new NotFoundException("Reading resource not found");
await this.cache.remove(book, resourceId);
this.enqueuePreparation(book, [resourceId], true);
return { bookId, resourceId, state: "pending" as const };
}
private accessibleBook(bookId: number): ReadingBook {
if (!Number.isInteger(bookId) || bookId < 1) throw new BadRequestException("Invalid book id");
const row = this.database.db
.select({ id: books.id, format: books.format, filePath: books.filePath, fileSize: books.fileSize, fileMtime: books.fileMtime })
.from(books)
.innerJoin(libraries, and(eq(libraries.id, books.libraryId), eq(libraries.enabled, true)))
.where(eq(books.id, bookId))
.get();
if (!row) throw new NotFoundException("Book not found in an accessible library");
return row;
}
private async resourceState(book: ReadingBook, resourceId: string) {
if (book.format === "pdf" && resourceId === "original") return "ready" as const;
return (await this.cache.get(book, resourceId)) ? "ready" as const : "pending" as const;
}
private enqueuePreparation(book: ReadingBook, resourceIds: string[], force = false): void {
const pending = resourceIds.filter((id) => force || !this.preparing.has(`${book.id}:${id}`));
if (!pending.length) return;
const job = this.jobs.create("prepare-reading-window", JSON.stringify({ bookId: book.id, resourceIds: pending }));
for (const id of pending) this.preparing.add(`${book.id}:${id}`);
setImmediate(async () => {
this.jobs.markRunning(job.id, JSON.stringify({ bookId: book.id, resourceIds: pending }));
try {
for (const resourceId of pending) {
const payload = await this.source.resource(book, resourceId);
if (payload.data) await this.cache.put(book, resourceId, payload.data);
}
this.jobs.markSucceeded(job.id, JSON.stringify({ bookId: book.id, prepared: pending }));
} catch (error) {
this.jobs.markFailed(job.id, error);
} finally {
for (const id of pending) this.preparing.delete(`${book.id}:${id}`);
}
});
}
}
function manifestVersionEtag(book: ReadingBook, resourceId: string): string {
return `\"${createHash("sha256").update(`${book.id}:${book.fileSize}:${book.fileMtime}:${resourceId}`).digest("base64url")}\"`;
}
function resolvePosition(locator: string, spine: Array<{ index: number; href: string; resourceId: string }>): number {
if (!spine.length) throw new BadRequestException("Book has no readable spine");
const direct = spine.findIndex((item) => item.resourceId === locator || item.href === locator);
if (direct >= 0) return direct;
const numeric = Number(locator.match(/(?:^|:)(\d+)(?:$|:)/)?.[1]);
if (Number.isInteger(numeric) && numeric >= 0 && numeric < spine.length) return numeric;
throw new BadRequestException("Locator does not identify a readable resource");
}
function priorityWindow(spine: Array<{ resourceId: string }>, position: number, referenced: string[], radius: number) {
const result: Array<{ id: string; distance: number | null }> = [{ id: spine[position].resourceId, distance: 0 }];
for (const resourceId of referenced) result.push({ id: resourceId, distance: null });
for (let distance = 1; distance <= radius; distance += 1) {
if (position - distance >= 0) result.push({ id: spine[position - distance].resourceId, distance: -distance });
if (position + distance < spine.length) result.push({ id: spine[position + distance].resourceId, distance });
}
return result.filter((entry, index) => result.findIndex((candidate) => candidate.id === entry.id) === index);
}
function referencedResources(currentId: string, resources: Array<{ resourceId: string; references?: string[] }>): string[] {
const byId = new Map(resources.map((resource) => [resource.resourceId, resource]));
const result: string[] = [];
const queue = [...(byId.get(currentId)?.references ?? [])];
const seen = new Set([currentId]);
while (queue.length) {
const resourceId = queue.shift()!;
if (seen.has(resourceId)) continue;
seen.add(resourceId);
result.push(resourceId);
queue.push(...(byId.get(resourceId)?.references ?? []));
}
return result;
}
function parseByteRange(range: string | undefined, size: number, supported: boolean) {
if (!range) return { start: 0, end: Math.max(size - 1, 0), partial: false };
if (!supported || size <= 0) throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
const match = range.match(/^bytes=(\d*)-(\d*)$/);
if (!match) throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
const suffix = !match[1] && match[2] ? Number(match[2]) : null;
const start = suffix === null ? Number(match[1]) : Math.max(size - suffix, 0);
const end = suffix === null && match[2] ? Number(match[2]) : size - 1;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= size) {
throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
}
return { start, end: Math.min(end, size - 1), partial: true };
}

View File

@ -0,0 +1,63 @@
export type ReadingFormat = "epub" | "pdf";
export type ReadingResourceKind = "chapter" | "image" | "stylesheet" | "font" | "document";
export type ReadingResourceState = "ready" | "pending" | "unsupported" | "failed";
export type ReadingResource = {
resourceId: string;
href: string;
url: string;
kind: ReadingResourceKind;
mediaType: string;
size: number;
state: ReadingResourceState;
references?: string[];
};
export type ReadingSpineItem = {
index: number;
href: string;
resourceId: string;
url: string;
mediaType: string;
size: number;
};
export type ReadingManifest = {
bookId: number;
format: ReadingFormat;
revision: string;
version: string;
containerPath: string | null;
opfPath: string | null;
basePath: string | null;
resources: ReadingResource[];
spine: ReadingSpineItem[];
capabilities: {
byteRanges: boolean;
derivedImages: boolean;
lightweightPages: boolean;
};
};
export type ReadingBook = {
id: number;
format: string;
filePath: string;
fileSize: number;
fileMtime: string;
};
export type ReadingResourcePayload = {
data?: Buffer;
filePath?: string;
contentType: string;
size: number;
etag: string;
supportsRanges: boolean;
};
export type ReadingWindowInput = {
locator: string;
mode: "auto" | "speed" | "quality";
networkHint: "offline" | "slow" | "normal" | "fast" | "unknown";
};

View File

@ -59,6 +59,27 @@ describe("api fallback helpers", () => {
expect(headers.has("Content-Type")).toBe(false);
});
it("sends the adaptive reading window with the selected network policy", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ bookId: 12, locator: "chapter-2", current: null, previous: null, next: null, neighbors: [] }), {
status: 200,
headers: { "Content-Type": "application/json" }
})
);
vi.stubGlobal("fetch", fetchMock);
await api.readingWindow(12, "chapter-2", "speed", "slow");
expect(fetchMock.mock.calls[0][0]).toBe("/books/12/reading-window");
const init = fetchMock.mock.calls[0][1] as RequestInit;
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({ locator: "chapter-2", mode: "speed", networkHint: "slow" });
});
it("builds an encoded reading resource URL", () => {
expect(api.readingResourceUrl(12, "chapter/one")).toBe("/books/12/reading-resources/chapter%2Fone");
});
it("surfaces create library API errors without fallback", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "Library path does not exist" }), {

View File

@ -27,7 +27,16 @@ import {
mockProgress,
mockUser
} from "./mockData";
import type { CbzPagesDto, ContinueItem, ReaderPreferencesDto, Session } from "./types";
import type {
CbzPagesDto,
ContinueItem,
ReaderPreferencesDto,
ReadingConnectionMode,
ReadingManifestDto,
ReadingNetworkHint,
ReadingWindowDto,
Session
} from "./types";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
const READER_PREFERENCES_PREFIX = "readabook:reader-preferences:";
@ -179,6 +188,21 @@ export const api = {
bookCoverUrl(id: number): string {
return `${API_BASE}/books/${id}/cover`;
},
async readingManifest(id: number): Promise<ReadingManifestDto> {
return request<ReadingManifestDto>(`/books/${id}/reading-manifest`);
},
readingResourceUrl(id: number, resourceId: string): string {
return `${API_BASE}/books/${id}/reading-resources/${encodeURIComponent(resourceId)}`;
},
async readingWindow(id: number, locator: string, mode: ReadingConnectionMode, networkHint: ReadingNetworkHint): Promise<ReadingWindowDto> {
return request<ReadingWindowDto>(`/books/${id}/reading-window`, {
method: "POST",
body: JSON.stringify({ locator, mode, networkHint })
});
},
async retryReadingResource(id: number, resourceId: string): Promise<void> {
await request(`/books/${id}/reading-resources/${encodeURIComponent(resourceId)}/retry`, { method: "POST" });
},
async cbzPages(id: number): Promise<CbzPagesDto> {
return request<CbzPagesDto>(`/books/${id}/pages`);
},

View File

@ -32,6 +32,63 @@ export type ReaderMode = "horizontal" | "vertical";
export type ReaderFit = "page" | "width";
export type ReadingConnectionMode = "auto" | "speed" | "quality";
export type ReadingNetworkHint = "offline" | "slow" | "normal" | "fast" | "unknown";
export type ReadingResourceDto = {
resourceId: string;
href: string;
url: string;
kind: "chapter" | "image" | "stylesheet" | "font" | "document";
mediaType: string;
size: number;
state: "ready" | "pending" | "unsupported" | "failed";
references?: string[];
};
export type ReadingSpineItemDto = {
index: number;
href: string;
resourceId: string;
url: string;
mediaType: string;
size: number;
};
export type ReadingManifestDto = {
bookId: number;
format: "epub" | "pdf";
revision: string;
version?: string;
containerPath: string | null;
opfPath: string | null;
basePath: string | null;
resources: ReadingResourceDto[];
spine: ReadingSpineItemDto[];
capabilities: {
byteRanges: boolean;
derivedImages: boolean;
lightweightPages: boolean;
};
};
export type ReadingWindowItemDto = {
resourceId: string;
relation: "current" | "previous" | "next" | "neighbor";
state: ReadingResourceDto["state"];
};
export type ReadingWindowDto = {
bookId: number;
locator: string;
mode: ReadingConnectionMode;
networkHint: ReadingNetworkHint;
current: ReadingWindowItemDto | null;
previous: ReadingWindowItemDto | null;
next: ReadingWindowItemDto | null;
neighbors: ReadingWindowItemDto[];
};
export type ReaderPreferencesDto = {
mode: ReaderMode;
fit?: ReaderFit;

View File

@ -10,6 +10,7 @@ import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reade
import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
import { useReaderPreferences } from "../reader/useReaderPreferences";
import { useReaderProgress } from "../reader/useReaderProgress";
import { useAdaptiveReading } from "../reader/useAdaptiveReading";
import { navigate } from "../router";
import { EmptyState, LoadingState, Panel } from "../components/ui";
@ -69,6 +70,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId);
const { preferences, setMode: saveReaderMode, error: preferencesError } = useReaderPreferences(bookId);
const adaptive = useAdaptiveReading(bookId, book?.format === "epub" || book?.format === "pdf");
async function loadBook() {
setLoading(true);
@ -104,7 +106,11 @@ export function ReaderPage({ bookId }: { bookId: number }) {
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
}, [progress]);
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
const legacyFileUrl = useMemo(() => (book?.format === "epub" ? "" : api.bookFileUrl(bookId)), [book?.format, bookId]);
const adaptiveFileUrl = useMemo(() => {
if (book?.format !== "pdf" || !adaptive.manifest) return legacyFileUrl;
return adaptive.manifest.spine[0]?.url ?? api.readingResourceUrl(bookId, "original");
}, [adaptive.manifest, book?.format, bookId, legacyFileUrl]);
const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]);
const savePdfPage = useCallback(
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
@ -113,10 +119,19 @@ export function ReaderPage({ bookId }: { bookId: number }) {
const percent = pdfPagePercent(nextPage, pages, anchor);
if (strategy === "queued") queueSave(locator, percent);
else void save(locator, percent);
void adaptive.prioritize(adaptive.manifest?.spine[0]?.resourceId ?? "original");
},
[queueSave, save]
[adaptive.manifest?.spine, adaptive.prioritize, queueSave, save]
);
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
const saveEpubLocator = useCallback((locator: string, percent: number, readingLocator: string) => {
void save(locator, percent);
void adaptive.prioritize(readingLocator);
}, [adaptive.prioritize, save]);
useEffect(() => {
const first = adaptive.manifest?.spine[0]?.resourceId;
if (first) void adaptive.prioritize(first);
}, [adaptive.manifest?.revision, adaptive.mode]);
const saveComicPage = useCallback(
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
setPage(nextPage);
@ -129,7 +144,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
[book?.format, queueSave, save]
);
const readerError = error ?? progressError ?? preferencesError;
const readerError = error ?? progressError ?? preferencesError ?? adaptive.error;
const supportsZoom = book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr";
const supportsMode = supportsZoom;
const currentVisiblePage = useCallback(() => {
@ -231,10 +246,12 @@ export function ReaderPage({ bookId }: { bookId: number }) {
title={book?.title ?? "Ouverture du lecteur"}
backHref={backHref}
error={readerError}
onRetry={error ? () => void loadBook() : undefined}
controls={readerControls}
zoomControls={zoomControls}
modeControls={modeControls}
connectionControls={book.format === "epub" || book.format === "pdf" ? { mode: adaptive.mode, onModeChange: adaptive.setMode } : undefined}
activity={book.format === "epub" || book.format === "pdf" ? adaptive.statusLabel : undefined}
onRetry={error ? () => void loadBook() : adaptive.error ? adaptive.retry : undefined}
>
<ReaderCrashBoundary
resetKey={`${bookId}:${book?.format ?? "loading"}:${mode}`}
@ -273,7 +290,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
>
{book.format === "pdf" ? (
<PdfReader
url={fileUrl}
url={adaptiveFileUrl}
page={page}
backHref={backHref}
zoom={zoom}
@ -290,15 +307,17 @@ export function ReaderPage({ bookId }: { bookId: number }) {
onPageCommit={saveComicPage}
onControlsChange={setReaderControls}
/>
) : (
) : adaptive.manifest?.format === "epub" ? (
<EpubReader
url={fileUrl}
manifest={adaptive.manifest}
locator={progress?.locator}
backHref={backHref}
mode="horizontal"
onLocatorChange={saveEpubLocator}
onControlsChange={setReaderControls}
/>
) : (
<div className="reader-fallback"><span>Ouverture de la page…</span></div>
)}
</ReaderCrashBoundary>
</ReaderShell>

View File

@ -1,36 +1,60 @@
import { useEffect, useRef, useState } from "react";
import type { ReadingManifestDto, ReaderMode } from "../api/types";
import { ReaderError, readerErrorMessage } from "./ReaderError";
import type { ReaderControls } from "./ReaderShell";
import type { ReaderMode } from "../api/types";
type FoliateLocation = {
cfi?: string;
fraction?: number;
current?: number;
total?: number;
};
type FoliateLocation = { cfi?: string; fraction?: number; current?: number; total?: number };
type FoliateView = HTMLElement & {
open(input: File | Blob | string): Promise<void>;
open(input: unknown): Promise<void>;
close(): void;
goLeft(): Promise<void>;
goRight(): Promise<void>;
goTo(target: string): Promise<unknown>;
next(): Promise<void>;
lastLocation?: FoliateLocation;
};
type EpubApiLoader = {
loadText(path: string): Promise<string | null>;
loadBlob(path: string): Promise<Blob | null>;
getSize(path: string): number;
};
export function epubFileName(url: string): string {
function normalizedResourcePath(path: string): string {
const withoutFragment = path.split("#", 1)[0].split("?", 1)[0];
try {
const base = globalThis.location?.href ?? "http://readabook.local/";
const pathname = new URL(url, base).pathname;
const name = pathname.split("/").filter(Boolean).at(-1);
return name && name.includes(".") ? name : "book.epub";
return decodeURI(withoutFragment).replace(/^\.\//, "").replace(/^\/+/, "");
} catch {
return "book.epub";
return withoutFragment.replace(/^\.\//, "").replace(/^\/+/, "");
}
}
export function createEpubApiLoader(manifest: ReadingManifestDto, fetcher: typeof fetch = fetch): EpubApiLoader {
const resources = new Map(manifest.resources.map((resource) => [normalizedResourcePath(resource.href), resource]));
const responses = new Map<string, Promise<Blob>>();
const loadBlob = async (path: string) => {
const resource = resources.get(normalizedResourcePath(path));
if (!resource) return null;
let pending = responses.get(resource.resourceId);
if (!pending) {
pending = fetcher(resource.url, { credentials: "include", cache: "default" }).then(async (response) => {
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
return response.blob();
});
responses.set(resource.resourceId, pending);
}
try {
return await pending;
} catch (error) {
responses.delete(resource.resourceId);
throw error;
}
};
return {
loadBlob,
loadText: async (path) => (await loadBlob(path))?.text() ?? null,
getSize: (path) => resources.get(normalizedResourcePath(path))?.size ?? 0
};
}
function locationPercent(location: FoliateLocation): number {
if (typeof location.fraction === "number") return Math.max(0, Math.min(100, location.fraction * 100));
if (typeof location.current === "number" && typeof location.total === "number" && location.total > 0) {
@ -39,24 +63,18 @@ function locationPercent(location: FoliateLocation): number {
return 1;
}
export function EpubReader({
url,
locator,
backHref,
mode,
onLocatorChange,
onControlsChange
}: {
url: string;
export function EpubReader({ manifest, locator, backHref, mode, onLocatorChange, onControlsChange }: {
manifest: ReadingManifestDto;
locator?: string;
backHref: string;
mode: ReaderMode;
onLocatorChange: (locator: string, percent: number) => void;
onLocatorChange: (locator: string, percent: number, readingLocator: string) => void;
onControlsChange: (controls: ReaderControls) => void;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<FoliateView | null>(null);
const locatorRef = useRef(locator);
const sectionIndexRef = useRef(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>();
const [attempt, setAttempt] = useState(0);
@ -65,43 +83,39 @@ export function EpubReader({
onControlsChange({
canPrevious: !loading && !error,
canNext: !loading && !error,
positionLabel: loading ? "Ouverture EPUB" : "Lecture integree",
positionLabel: loading ? "Ouverture de la page…" : "Lecture intégrée",
onPrevious: () => void viewRef.current?.goLeft(),
onNext: () => void viewRef.current?.goRight()
});
}, [error, loading, onControlsChange]);
useEffect(() => {
locatorRef.current = locator;
}, [locator]);
useEffect(() => { locatorRef.current = locator; }, [locator]);
useEffect(() => {
let cancelled = false;
let view: FoliateView | null = null;
async function mount() {
try {
setLoading(true);
setError(undefined);
await import("foliate-js/view.js");
const [{ EPUB }] = await Promise.all([import("foliate-js/epub.js"), import("foliate-js/view.js")]);
if (cancelled || !hostRef.current) return;
const response = await fetch(url, { credentials: "include" });
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
const blob = await response.blob();
const book = await new EPUB(createEpubApiLoader(manifest)).init();
if (cancelled || !hostRef.current) return;
view = document.createElement("foliate-view") as FoliateView;
view.classList.add("epub-view");
view.addEventListener("load", (event) => {
const index = (event as CustomEvent<{ index?: number }>).detail?.index;
if (typeof index === "number") sectionIndexRef.current = index;
});
view.addEventListener("relocate", (event) => {
const location = (event as CustomEvent<FoliateLocation>).detail;
if (location?.cfi) onLocatorChange(location.cfi, locationPercent(location));
const spine = manifest.spine[sectionIndexRef.current];
if (location?.cfi && spine) onLocatorChange(location.cfi, locationPercent(location), spine.resourceId);
});
hostRef.current.replaceChildren(view);
viewRef.current = view;
const file = new File([blob], epubFileName(url), { type: blob.type || "application/epub+zip" });
await view.open(file);
await view.open(book);
if (cancelled) return;
if (locatorRef.current) await view.goTo(locatorRef.current);
else await view.next();
@ -113,7 +127,6 @@ export function EpubReader({
}
}
}
void mount();
return () => {
cancelled = true;
@ -121,7 +134,7 @@ export function EpubReader({
view?.remove();
if (viewRef.current === view) viewRef.current = null;
};
}, [attempt, onLocatorChange, url]);
}, [attempt, manifest, onLocatorChange]);
useEffect(() => {
viewRef.current?.classList.toggle("epub-view-vertical", mode === "vertical");
@ -129,28 +142,10 @@ export function EpubReader({
}, [mode]);
if (error) {
return (
<div className="epub-reader">
<ReaderError
title="Lecture EPUB indisponible"
detail="ReadaBook n'a pas pu ouvrir ce fichier dans le lecteur web."
technicalDetail={error}
downloadUrl={url}
backHref={backHref}
onRetry={() => setAttempt((value) => value + 1)}
/>
</div>
);
return <div className="epub-reader"><ReaderError title="Lecture EPUB indisponible" detail="ReadaBook n'a pas pu ouvrir cette page dans le lecteur web." technicalDetail={error} backHref={backHref} onRetry={() => setAttempt((value) => value + 1)} /></div>;
}
return (
<div className={`epub-reader epub-reader-${mode}`}>
{loading && (
<div className="reader-fallback">
<span>Ouverture EPUB</span>
</div>
)}
return <div className={`epub-reader epub-reader-${mode}`}>
{loading && <div className="reader-fallback"><span>Ouverture de la page…</span></div>}
<div className="epub-host" ref={hostRef} />
</div>
);
</div>;
}

View File

@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import type { ReadingManifestDto } from "../api/types";
import { createEpubApiLoader } from "./EpubReader";
const manifest: ReadingManifestDto = {
bookId: 4,
format: "epub",
revision: "rev-1",
containerPath: "META-INF/container.xml",
opfPath: "OPS/package.opf",
basePath: "OPS/",
resources: [
{ resourceId: "container", href: "META-INF/container.xml", url: "/books/4/reading-resources/container", mediaType: "application/xml", size: 12, kind: "document", state: "ready" },
{ resourceId: "chapter", href: "OPS/text/chapter.xhtml", url: "/books/4/reading-resources/chapter", mediaType: "application/xhtml+xml", size: 18, kind: "chapter", state: "ready" }
],
spine: [{ index: 0, href: "OPS/text/chapter.xhtml", resourceId: "chapter", url: "/books/4/reading-resources/chapter", mediaType: "application/xhtml+xml", size: 18 }],
capabilities: { byteRanges: false, derivedImages: true, lightweightPages: false }
};
describe("EPUB adaptive loader", () => {
it("loads manifest resources with credentials and reuses a fetched entry", async () => {
const fetcher = vi.fn().mockResolvedValue(new Response("<html>chapter</html>", { status: 200 }));
const loader = createEpubApiLoader(manifest, fetcher);
await expect(loader.loadText("OPS/text/chapter.xhtml#part")).resolves.toBe("<html>chapter</html>");
await loader.loadBlob("OPS/text/chapter.xhtml");
expect(fetcher).toHaveBeenCalledTimes(1);
expect(fetcher).toHaveBeenCalledWith("/books/4/reading-resources/chapter", { credentials: "include", cache: "default" });
expect(loader.getSize("OPS/text/chapter.xhtml")).toBe(18);
});
it("does not contain the complete-book fetch path", () => {
const source = readFileSync(new URL("./EpubReader.tsx", import.meta.url), "utf8");
expect(source).not.toContain("bookFileUrl");
expect(source).not.toContain("fetch(url");
expect(source).not.toContain("new File(");
expect(source).toContain("new EPUB(createEpubApiLoader(manifest)).init()");
});
});

View File

@ -285,7 +285,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
onControlsChange({
canPrevious: !loadingDocument && !documentError && !pageError && currentPage > 1,
canNext: !loadingDocument && !documentError && !pageError && currentPage < pages,
positionLabel: loadingDocument ? "Chargement" : readerPositionLabel(currentPage, pages),
positionLabel: loadingDocument ? "Ouverture de la page…" : readerPositionLabel(currentPage, pages),
onPrevious: () => go(currentPage - 1),
onNext: () => go(currentPage + 1)
});
@ -584,7 +584,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
<div className={`pdf-reader${mode === "vertical" ? " pdf-reader-vertical" : ""}`} ref={frameRef}>
{loadingDocument || !documentProxy ? (
<div className="reader-fallback">
<span>Ouverture PDF</span>
<span>Ouverture de la page…</span>
</div>
) : mode === "vertical" ? (
<div className="reader-vertical-stack">
@ -619,7 +619,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
<div className="pdf-page-frame" data-reader-page={currentPage}>
{(!viewportSize || loadingPage || !pageRendered) && (
<div className="reader-fallback">
<span>Page {currentPage}</span>
<span>Ouverture de la page…</span>
</div>
)}
<canvas ref={canvasRef} data-pdf-render-status={pageStatus} style={{ visibility: showCanvas ? "visible" : "hidden" }} />

View File

@ -18,7 +18,7 @@ export function ReaderError({
title: string;
detail: string;
technicalDetail?: string;
downloadUrl: string;
downloadUrl?: string;
backHref: string;
onRetry: () => void;
}) {
@ -37,10 +37,12 @@ export function ReaderError({
<ArrowLeft size={16} />
Retour à la fiche
</button>
{downloadUrl && (
<a className="ghost-button" href={downloadUrl} download>
<Download size={16} />
Télécharger
</a>
)}
</div>
{technicalDetail && (
<details>

View File

@ -14,6 +14,7 @@ import {
import { readerMovedBeyondTap, readerPinchZoom, readerPointDistance, type ReaderPoint } from "./readerGestures";
import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout";
import type { ReaderMode } from "./readerScroll";
import type { ReadingConnectionMode } from "../api/types";
export type ReaderControls = {
canPrevious: boolean;
@ -36,6 +37,11 @@ export type ReaderModeControls = {
onModeChange: (mode: ReaderMode) => void;
};
export type ReaderConnectionControls = {
mode: ReadingConnectionMode;
onModeChange: (mode: ReadingConnectionMode) => void;
};
type ReaderShellProps = {
title: string;
backHref: string;
@ -44,6 +50,8 @@ type ReaderShellProps = {
controls: ReaderControls;
zoomControls?: ReaderZoomControls;
modeControls?: ReaderModeControls;
connectionControls?: ReaderConnectionControls;
activity?: string;
children: ReactNode;
};
@ -105,7 +113,7 @@ function scheduleReaderZoomAnchorRestore(stage: HTMLElement, anchor: ReaderZoomA
});
}
export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, children }: ReaderShellProps) {
export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, connectionControls, activity, children }: ReaderShellProps) {
const readerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLElement>(null);
const stageRef = useRef<HTMLElement>(null);
@ -368,6 +376,16 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
</select>
</label>
)}
{connectionControls && (
<label className="reader-mode-control reader-connection-control">
<span>Mode connexion lente</span>
<select value={connectionControls.mode} onChange={(event) => connectionControls.onModeChange(event.currentTarget.value as ReadingConnectionMode)}>
<option value="auto">Automatique</option>
<option value="speed">Toujours privilégier la vitesse</option>
<option value="quality">Toujours privilégier la qualité</option>
</select>
</label>
)}
<button className="ghost-button reader-nav-button reader-previous-button" onClick={controls.onPrevious} disabled={!controls.canPrevious} aria-label="Page precedente">
<ArrowLeft size={16} />
<span>Precedent</span>
@ -405,6 +423,11 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
)}
</div>
)}
{!error && activity && (
<div className="reader-status reader-activity" role="status" aria-live="polite">
<span>{activity}</span>
</div>
)}
<main className="reader-stage" ref={stageRef}>
<div className="reader-content">{children}</div>
</main>

View File

@ -0,0 +1,41 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { ReaderShell } from "./ReaderShell";
describe("ReaderShell adaptive connection controls", () => {
it("renders the slow connection mode choices without technical wording or a global percentage", () => {
const html = renderToStaticMarkup(
<ReaderShell
title="Livre"
backHref="/books/1"
controls={{ canPrevious: false, canNext: true, positionLabel: "Page 2 / 12", onPrevious: () => undefined, onNext: () => undefined }}
connectionControls={{ mode: "auto", onModeChange: () => undefined }}
>
<p>Page courante</p>
</ReaderShell>
);
expect(html).toContain("Mode connexion lente");
expect(html).toContain("Automatique");
expect(html).toContain("Toujours privilégier la vitesse");
expect(html).toContain("Toujours privilégier la qualité");
expect(html).not.toMatch(/%|pourcentage global|manifest|spine|range|etag|cache|resource|locator/i);
});
it("shows retry next to offline or loading errors", () => {
const html = renderToStaticMarkup(
<ReaderShell
title="Livre"
backHref="/books/1"
error="Cette page n’a pas pu être chargée. Votre position est conservée."
onRetry={() => undefined}
controls={{ canPrevious: false, canNext: false, positionLabel: "Page 2 / 12", onPrevious: () => undefined, onNext: () => undefined }}
>
<p>Page courante</p>
</ReaderShell>
);
expect(html).toContain("Cette page n’a pas pu être chargée. Votre position est conservée.");
expect(html).toContain("Reessayer");
});
});

View File

@ -1,6 +1,5 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { epubFileName } from "./EpubReader";
import { pdfDocumentOptions } from "./pdfDocumentOptions";
import {
clampReaderPage,
@ -23,11 +22,6 @@ import { isPageInRenderWindow, majorityVisiblePage, verticalRenderWindow } from
import { readerErrorMessage } from "./ReaderError";
describe("reader runtime helpers", () => {
it("extracts an EPUB file name from the file URL", () => {
expect(epubFileName("http://readabook.local/books/12/file?token=abc")).toBe("book.epub");
expect(epubFileName("http://readabook.local/files/example.epub")).toBe("example.epub");
});
it("keeps PDF.js worker fallback source on the bundled module worker", () => {
expect(pdfWorkerSrc).toContain("pdf.worker.min.mjs");
});

View File

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { adaptiveStatusLabel } from "./useAdaptiveReading";
describe("adaptive reading status", () => {
it("uses the short reader wording without a global percentage", () => {
expect(adaptiveStatusLabel("opening", false)).toBe("Ouverture de la page…");
expect(adaptiveStatusLabel("next", false)).toBe("Préparation de la page suivante…");
expect(adaptiveStatusLabel("neighbors", false)).toBe("Chargement des pages voisines…");
expect(adaptiveStatusLabel("slow", false)).toBe("Connexion lente. On charge d’abord cette page.");
expect(adaptiveStatusLabel("offline", false)).toBe("Hors connexion. La page déjà chargée reste disponible.");
for (const status of ["opening", "next", "neighbors", "slow", "offline", "idle"] as const) {
const label = adaptiveStatusLabel(status, false) ?? "";
expect(label).not.toMatch(/%|pourcentage|global|manifest|spine|range|etag|cache|resource|locator/i);
}
});
it("announces a lightweight resource when the manifest supports it", () => {
expect(adaptiveStatusLabel("opening", true)).toBe("Version allégée");
});
});

View File

@ -0,0 +1,134 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api/client";
import type { ReadingConnectionMode, ReadingManifestDto, ReadingNetworkHint, ReadingWindowItemDto } from "../api/types";
const CONNECTION_MODE_KEY = "readabook:reading-connection-mode";
export type AdaptiveReadingStatus = "opening" | "next" | "neighbors" | "slow" | "offline" | "idle";
function storedMode(): ReadingConnectionMode {
if (typeof localStorage === "undefined") return "auto";
const value = localStorage.getItem(CONNECTION_MODE_KEY);
return value === "speed" || value === "quality" ? value : "auto";
}
export function readingNetworkHint(): ReadingNetworkHint {
if (typeof navigator === "undefined") return "unknown";
if (!navigator.onLine) return "offline";
const connection = (navigator as Navigator & { connection?: { effectiveType?: string; saveData?: boolean } }).connection;
if (connection?.saveData || connection?.effectiveType === "slow-2g" || connection?.effectiveType === "2g") return "slow";
if (connection?.effectiveType === "3g") return "normal";
if (connection?.effectiveType === "4g") return "fast";
return "unknown";
}
export function adaptiveStatusLabel(status: AdaptiveReadingStatus, lightweight: boolean): string | undefined {
if (lightweight) return "Version allégée";
if (status === "opening") return "Ouverture de la page…";
if (status === "next") return "Préparation de la page suivante…";
if (status === "neighbors") return "Chargement des pages voisines…";
if (status === "slow") return "Connexion lente. On charge d’abord cette page.";
if (status === "offline") return "Hors connexion. La page déjà chargée reste disponible.";
return undefined;
}
export function useAdaptiveReading(bookId: number, enabled: boolean) {
const [manifest, setManifest] = useState<ReadingManifestDto | null>(null);
const [mode, setModeState] = useState<ReadingConnectionMode>(storedMode);
const [status, setStatus] = useState<AdaptiveReadingStatus>("opening");
const [error, setError] = useState<string>();
const generationRef = useRef(0);
const lastLocatorRef = useRef<string | null>(null);
const currentResourceRef = useRef<string | null>(null);
const loadManifest = useCallback(async () => {
if (!enabled) return;
setError(undefined);
setStatus("opening");
try {
setManifest(await api.readingManifest(bookId));
} catch {
setError("Impossible de préparer la lecture. Réessayez sans quitter cette page.");
setStatus(readingNetworkHint() === "offline" ? "offline" : "idle");
}
}, [bookId, enabled]);
useEffect(() => {
setManifest(null);
void loadManifest();
}, [loadManifest]);
useEffect(() => {
const update = () => setStatus(navigator.onLine ? "idle" : "offline");
window.addEventListener("online", update);
window.addEventListener("offline", update);
return () => {
window.removeEventListener("online", update);
window.removeEventListener("offline", update);
};
}, []);
const setMode = useCallback((next: ReadingConnectionMode) => {
setModeState(next);
localStorage.setItem(CONNECTION_MODE_KEY, next);
}, []);
const prioritize = useCallback(
async (locator: string, prefetch = true) => {
if (!manifest) return;
lastLocatorRef.current = locator;
const generation = ++generationRef.current;
const networkHint = readingNetworkHint();
setError(undefined);
setStatus(networkHint === "offline" ? "offline" : networkHint === "slow" || mode === "speed" ? "slow" : "opening");
try {
const window = await api.readingWindow(bookId, locator, mode, networkHint);
currentResourceRef.current = window.current?.resourceId ?? null;
if (generation !== generationRef.current || networkHint === "offline") return;
// pdf.js owns its byte-range requests; fetching the resource here would download the whole PDF.
if (manifest.format === "pdf" || !prefetch) {
setStatus("idle");
return;
}
const fetchItem = async (item: ReadingWindowItemDto | null) => {
if (!item) return;
const response = await fetch(api.readingResourceUrl(bookId, item.resourceId), { credentials: "include", cache: "default" });
if (!response.ok) throw new Error(String(response.status));
await response.arrayBuffer();
};
await fetchItem(window.current);
if (generation !== generationRef.current) return;
setStatus("next");
await Promise.allSettled([fetchItem(window.previous), fetchItem(window.next)]);
if (generation !== generationRef.current) return;
if (window.neighbors.length) {
setStatus("neighbors");
await Promise.allSettled(window.neighbors.map(fetchItem));
}
if (generation === generationRef.current) setStatus("idle");
} catch {
if (generation !== generationRef.current) return;
setError("Cette page n’a pas pu être chargée. Votre position est conservée.");
setStatus(readingNetworkHint() === "offline" ? "offline" : "idle");
}
},
[bookId, manifest, mode]
);
const retry = useCallback(async () => {
const resourceId = currentResourceRef.current;
const locator = lastLocatorRef.current;
if (!resourceId || !locator) {
await loadManifest();
return;
}
try {
await api.retryReadingResource(bookId, resourceId);
} catch {
// Retrying the GET still gives an already cached response a chance while offline.
}
await prioritize(locator);
}, [bookId, loadManifest, prioritize]);
return { manifest, mode, setMode, status, statusLabel: adaptiveStatusLabel(status, Boolean(manifest?.capabilities.lightweightPages)), error, retry, prioritize };
}

View File

@ -1209,6 +1209,17 @@ main.app-main-reader .reader-page {
min-width: 0;
}
.reader-activity {
justify-content: center;
min-height: 30px;
color: var(--ink-muted);
font-size: 0.86rem;
}
.reader-connection-control select {
max-width: 220px;
}
.reader-stage {
grid-row: 3;
position: relative;
@ -1536,6 +1547,11 @@ main.app-main-reader .reader-page {
min-width: 0;
}
.reader-connection-control {
order: 8;
flex-basis: 100%;
}
.reader-mode-control select {
width: 100%;
min-width: 0;
@ -1566,7 +1582,7 @@ main.app-main-reader .reader-page {
.reader-back-button span,
.reader-nav-button span,
.reader-fullscreen-toggle span,
.reader-mode-control > span {
.reader-mode-control > span:not(:only-child) {
display: none;
}

View File

@ -1,9 +1,14 @@
/// <reference types="vite/client" />
declare module "foliate-js/epub.js" {
const module: unknown;
export default module;
export const EPUB: unknown;
export class EPUB {
constructor(loader: {
loadText(path: string): Promise<string | null>;
loadBlob(path: string): Promise<Blob | null>;
getSize(path: string): number;
});
init(): Promise<unknown>;
}
}
declare module "foliate-js/view.js" {