feat(reading): lecture adaptative pour mauvaises connexions — manifest de lecture avec fenêtre priorisée autour de la position du lecteur et préchargement progressif des pages voisines, ressources authentifiées servies avec Range/ETag (module reading API: controller/service/dto/ports, source archive et cache reconstructible filesystem), client web adaptatif useAdaptiveReading avec enrichissement du cache au fil de la lecture, EPUB chargé par ressources manifestées via loader Foliate avec credentials (fini le téléchargement complet du fichier), optimisation des images EPUB et fallback page PDF allégée, avec tests api/web — évolution #53
Ouvrir rapidement un très grand livre et continuer la lecture sans attendre le chargement complet sur très mauvaise connexion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user