mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(reader): parse EPUB package elements by namespace
Resolve OPF and Dublin Core namespaces so valid prefixed metadata retains book language and avoids incorrect English dictionary lookups. Preserve element order and identifiers, and cover reader metadata and narration with unit and HTTP regression tests. Closes #1789
This commit is contained in:
@@ -105,7 +105,7 @@ describe('EPUB media overlay duration', () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeReadAlongEpub(chapters: [Clip[], Clip[]], durationMetas: string): Promise<string> {
|
||||
async function writeReadAlongEpub(chapters: [Clip[], Clip[]], durationMetas: string, packageXml = opf(durationMetas)): Promise<string> {
|
||||
const path = join(fixtureRoot, `${Math.random().toString(36).slice(2)}.epub`);
|
||||
const archive = new ZipArchive({ zlib: { level: 0 } });
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -116,7 +116,7 @@ describe('EPUB media overlay duration', () => {
|
||||
});
|
||||
archive.append('application/epub+zip', { name: 'mimetype', store: true });
|
||||
archive.append(CONTAINER_XML, { name: 'META-INF/container.xml' });
|
||||
archive.append(opf(durationMetas), { name: 'OEBPS/content.opf' });
|
||||
archive.append(packageXml, { name: 'OEBPS/content.opf' });
|
||||
for (const [index, clips] of chapters.entries()) {
|
||||
const chapter = index + 1;
|
||||
archive.append(`<html xmlns="http://www.w3.org/1999/xhtml"><body><p>Chapter ${chapter}</p></body></html>`, {
|
||||
@@ -136,6 +136,25 @@ describe('EPUB media overlay duration', () => {
|
||||
return [[first[0]!, clip], second];
|
||||
}
|
||||
|
||||
it.each([false, true])('reads prefixed package duration metadata and narration resources with fullyPrefixed=%s', async (fullyPrefixed) => {
|
||||
const packageXml = opf(STORYTELLER_PACKAGE_DURATION)
|
||||
.replace('version="3.0"', 'xmlns:publication="http://www.idpf.org/2007/opf" version="3.0"')
|
||||
.replace(/<(\/?)(package|metadata|meta|manifest|item|spine|itemref)(?=[\s>])/g, (tag, closing: string, name: string) =>
|
||||
fullyPrefixed || name === 'metadata' || name === 'meta' ? `<${closing}publication:${name}` : tag,
|
||||
);
|
||||
const path = await writeReadAlongEpub(STORYTELLER_TRACK_BOUNDARY_CHAPTERS, STORYTELLER_PACKAGE_DURATION, packageXml);
|
||||
|
||||
const playlist = await buildEpubMediaOverlayPlaylistFromFile(path, 1, 2);
|
||||
|
||||
expect(playlist.durationSeconds).toBeCloseTo(STORYTELLER_PACKAGE_DURATION_SECONDS, 6);
|
||||
expect(playlist.sections).toHaveLength(2);
|
||||
expect(playlist.items).toHaveLength(4);
|
||||
expect(await inspectEpubMediaOverlayFile(path)).toEqual({
|
||||
available: true,
|
||||
durationSeconds: expect.closeTo(STORYTELLER_PACKAGE_DURATION_SECONDS, 6),
|
||||
});
|
||||
});
|
||||
|
||||
it('sums the clips when every clip can be measured, even when the package declares another total', async () => {
|
||||
const path = await writeReadAlongEpub(MEASURABLE_CHAPTERS, '<meta property="media:duration">01:00:00.00</meta>');
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as unzipper from 'unzipper';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { parseEpubPackage } from './epub-package';
|
||||
|
||||
import type {
|
||||
EpubBookInfo,
|
||||
@@ -119,19 +120,16 @@ function attr(node: unknown, name: string): string | null {
|
||||
async function readPackageMediaDurationSeconds(zip: unzipper.CentralDirectory, opfPath: string): Promise<number | null> {
|
||||
const entry = opfPath ? findEpubZipEntry(zip.files, opfPath) : undefined;
|
||||
if (!entry) return null;
|
||||
let doc: Record<string, unknown>;
|
||||
let pkg: Record<string, unknown>;
|
||||
try {
|
||||
doc = smilParser.parse(await entry.buffer()) as Record<string, unknown>;
|
||||
pkg = parseEpubPackage(await entry.buffer());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const pkg = (doc['package'] ?? doc) as Record<string, unknown>;
|
||||
const metadata = pkg['metadata'] as Record<string, unknown> | undefined;
|
||||
for (const meta of toArray(metadata?.meta)) {
|
||||
if (attr(meta, 'property') !== 'media:duration' || attr(meta, 'refines') != null) continue;
|
||||
// fast-xml-parser turns a bare timecount such as "21103.35" into a number.
|
||||
const value = (meta as Record<string, unknown>)['#text'];
|
||||
const seconds = typeof value === 'number' ? value : parseClock(value);
|
||||
const seconds = parseClock((meta as Record<string, unknown>)['#text']);
|
||||
if (seconds != null && Number.isFinite(seconds) && seconds > 0) return seconds;
|
||||
}
|
||||
return null;
|
||||
@@ -158,8 +156,7 @@ async function parseEpubMediaOverlayBookInfo(epubPath: string): Promise<EpubBook
|
||||
const opfEntry = findEpubZipEntry(zip.files, opfPath);
|
||||
if (!opfEntry) throw new Error(`OPF not found: ${opfPath}`);
|
||||
|
||||
const opfDoc = smilParser.parse(await opfEntry.buffer()) as Record<string, unknown>;
|
||||
const pkg = (opfDoc['package'] ?? opfDoc) as Record<string, unknown>;
|
||||
const pkg = parseEpubPackage(await opfEntry.buffer());
|
||||
const manifestEl = pkg['manifest'] as Record<string, unknown> | undefined;
|
||||
const spineEl = pkg['spine'] as Record<string, unknown> | undefined;
|
||||
|
||||
@@ -380,8 +377,7 @@ export async function inspectEpubMediaOverlayFile(epubPath: string): Promise<Epu
|
||||
const opfEntry = findEpubZipEntry(zip.files, opfPath);
|
||||
if (!opfEntry) return { available: false, durationSeconds: null };
|
||||
|
||||
const opfDoc = smilParser.parse(await opfEntry.buffer()) as Record<string, unknown>;
|
||||
const pkg = (opfDoc['package'] ?? opfDoc) as Record<string, unknown>;
|
||||
const pkg = parseEpubPackage(await opfEntry.buffer());
|
||||
const manifestEl = pkg['manifest'] as Record<string, unknown> | undefined;
|
||||
const spineEl = pkg['spine'] as Record<string, unknown> | undefined;
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { parseEpubPackage } from './epub-package';
|
||||
|
||||
describe('EPUB package namespaces', () => {
|
||||
it('keeps creator order across different aliases of the Dublin Core namespace', () => {
|
||||
const pkg = parseEpubPackage(`<package xmlns="http://www.idpf.org/2007/opf">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:d="http://purl.org/dc/elements/1.1/">
|
||||
<dc:creator>First author</dc:creator><d:creator>Second author</d:creator><dc:creator>Third author</dc:creator>
|
||||
</metadata>
|
||||
</package>`);
|
||||
|
||||
expect(pkg['metadata']).toMatchObject({ 'dc:creator': ['First author', 'Second author', 'Third author'] });
|
||||
});
|
||||
|
||||
it('does not let namespace rebinding on a sibling leak into later metadata fields', () => {
|
||||
const pkg = parseEpubPackage(`<package xmlns="http://www.idpf.org/2007/opf">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:language xmlns:dc="urn:other">de</dc:language><dc:language>pt</dc:language>
|
||||
<language xmlns="urn:other">fr</language>
|
||||
</metadata>
|
||||
</package>`);
|
||||
|
||||
expect(pkg['metadata']).toMatchObject({ 'dc:language': 'pt' });
|
||||
expect(pkg['metadata']).not.toHaveProperty('language');
|
||||
});
|
||||
|
||||
it.each(['xmlns:p="urn:other"', ''])('rejects a foreign or undeclared package prefix with %s', (declaration) => {
|
||||
expect(parseEpubPackage(`<p:package ${declaration}><metadata><dc:language>de</dc:language></metadata></p:package>`)).toEqual({});
|
||||
});
|
||||
|
||||
it('preserves opaque identifiers, entities, attribute names and colon-bearing attribute values', () => {
|
||||
const pkg = parseEpubPackage(`<p:package xmlns:p="http://www.idpf.org/2007/opf">
|
||||
<p:metadata xmlns:d="http://purl.org/dc/elements/1.1/">
|
||||
<d:title>Livro & biblioteca</d:title><d:identifier>0000123</d:identifier>
|
||||
<p:meta property="media:duration" refines="#overlay">21103.35</p:meta>
|
||||
</p:metadata>
|
||||
<p:manifest><p:item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml" media-overlay="overlay"/></p:manifest>
|
||||
</p:package>`);
|
||||
|
||||
expect(pkg['metadata']).toMatchObject({
|
||||
'dc:title': 'Livro & biblioteca',
|
||||
'dc:identifier': '0000123',
|
||||
meta: { '@_property': 'media:duration', '@_refines': '#overlay', '#text': '21103.35' },
|
||||
});
|
||||
expect(pkg['manifest']).toEqual({
|
||||
item: { '@_id': 'chapter', '@_href': 'chapter.xhtml', '@_media-type': 'application/xhtml+xml', '@_media-overlay': 'overlay' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
const OPF_NAMESPACE = 'http://www.idpf.org/2007/opf';
|
||||
const DUBLIN_CORE_NAMESPACE = 'http://purl.org/dc/elements/1.1/';
|
||||
|
||||
interface OrderedXmlNode {
|
||||
[name: string]: unknown;
|
||||
':@'?: Record<string, string>;
|
||||
'#text'?: string;
|
||||
}
|
||||
|
||||
const packageParser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
textNodeName: '#text',
|
||||
preserveOrder: true,
|
||||
parseTagValue: false,
|
||||
});
|
||||
|
||||
function normalizePackageElements(nodes: OrderedXmlNode[], inheritedNamespaces: ReadonlyMap<string, string>): Record<string, unknown> {
|
||||
const result = Object.create(null) as Record<string, unknown>;
|
||||
for (const node of nodes) {
|
||||
if (node['#text'] !== undefined) {
|
||||
result['#text'] = ((result['#text'] as string | undefined) ?? '') + node['#text'];
|
||||
continue;
|
||||
}
|
||||
const attributes = node[':@'] ?? {};
|
||||
const namespaces = new Map(inheritedNamespaces);
|
||||
for (const [name, value] of Object.entries(attributes)) {
|
||||
if (name === '@_xmlns') namespaces.set('', value);
|
||||
else if (name.startsWith('@_xmlns:')) namespaces.set(name.slice('@_xmlns:'.length), value);
|
||||
}
|
||||
for (const [qualifiedName, children] of Object.entries(node)) {
|
||||
if (!Array.isArray(children)) continue;
|
||||
const colon = qualifiedName.indexOf(':');
|
||||
const prefix = colon < 0 ? '' : qualifiedName.slice(0, colon);
|
||||
const localName = colon < 0 ? qualifiedName : qualifiedName.slice(colon + 1);
|
||||
const namespace = namespaces.get(prefix);
|
||||
let name: string;
|
||||
if (namespace === OPF_NAMESPACE || (!prefix && !namespace)) name = localName;
|
||||
else if (namespace === DUBLIN_CORE_NAMESPACE) name = `dc:${localName}`;
|
||||
else continue;
|
||||
|
||||
const content = normalizePackageElements(children as OrderedXmlNode[], namespaces);
|
||||
const value = Object.keys(attributes).length
|
||||
? { ...content, ...attributes }
|
||||
: Object.keys(content).every((key) => key === '#text')
|
||||
? (content['#text'] ?? '')
|
||||
: content;
|
||||
if (!Object.hasOwn(result, name)) result[name] = value;
|
||||
else if (Array.isArray(result[name])) (result[name] as unknown[]).push(value);
|
||||
else result[name] = [result[name], value];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseEpubPackage(xml: string | Buffer): Record<string, unknown> {
|
||||
// Resolve namespaces before grouping aliases, preserving creator order and excluding foreign lookalikes.
|
||||
const document = normalizePackageElements(packageParser.parse(xml) as OrderedXmlNode[], new Map());
|
||||
const pkg = document['package'];
|
||||
return pkg && typeof pkg === 'object' && !Array.isArray(pkg) ? (pkg as Record<string, unknown>) : {};
|
||||
}
|
||||
@@ -2,11 +2,14 @@ vi.mock('fs/promises', () => ({ stat: vi.fn() }));
|
||||
vi.mock('unzipper', () => ({ Open: { file: vi.fn() } }));
|
||||
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { stat } from 'fs/promises';
|
||||
import { Readable } from 'stream';
|
||||
import * as unzipper from 'unzipper';
|
||||
|
||||
import { EpubService } from './epub.service';
|
||||
import { BookReadService } from '../../book/book-read.service';
|
||||
import { LibraryService } from '../../library/library.service';
|
||||
|
||||
const mockStat = stat as MockedFunction<typeof stat>;
|
||||
const mockOpenFile = (unzipper as any).Open.file as vi.Mock;
|
||||
@@ -169,7 +172,7 @@ function makeMediaOverlayArchive(smil = SMIL_XML) {
|
||||
]);
|
||||
}
|
||||
|
||||
function makeEpubArchive(options?: { navBufferError?: boolean; omitChapterFile?: boolean; lowerCasePaths?: boolean }) {
|
||||
function makeEpubArchive(options?: { navBufferError?: boolean; omitChapterFile?: boolean; lowerCasePaths?: boolean; opf?: string }) {
|
||||
const lower = options?.lowerCasePaths === true;
|
||||
const ops = lower ? 'ops' : 'OPS';
|
||||
const navSpec: ZipEntrySpec = options?.navBufferError
|
||||
@@ -178,7 +181,7 @@ function makeEpubArchive(options?: { navBufferError?: boolean; omitChapterFile?:
|
||||
|
||||
return makeArchive([
|
||||
{ path: 'META-INF/container.xml', content: CONTAINER_XML },
|
||||
{ path: `${ops}/content.opf`, content: OPF_XML },
|
||||
{ path: `${ops}/content.opf`, content: options?.opf ?? OPF_XML },
|
||||
navSpec,
|
||||
{ path: `${ops}/toc.ncx`, content: NCX_XML },
|
||||
...(options?.omitChapterFile
|
||||
@@ -203,15 +206,77 @@ describe('EpubService', () => {
|
||||
};
|
||||
let service: EpubService;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
service = new EpubService(bookReadService as any, libraryService as any);
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [EpubService, { provide: BookReadService, useValue: bookReadService }, { provide: LibraryService, useValue: libraryService }],
|
||||
}).compile();
|
||||
service = module.get(EpubService);
|
||||
bookReadService.findLibraryIdByBookId.mockResolvedValue(3);
|
||||
bookReadService.findPrimaryFilesByBookIds.mockResolvedValue([{ format: 'epub', absolutePath: '/books/book.epub', sizeBytes: null }]);
|
||||
libraryService.verifyUserAccess.mockResolvedValue(undefined);
|
||||
mockStat.mockResolvedValue({ mtimeMs: 100 } as Awaited<ReturnType<typeof stat>>);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'the reported opf:metadata declaration', prefix: '', metadataPrefix: 'opf', dcPrefix: 'dc' },
|
||||
{ name: 'an arbitrary metadata prefix', prefix: '', metadataPrefix: 'publication', dcPrefix: 'dc' },
|
||||
{ name: 'fully prefixed package elements', prefix: 'opf', metadataPrefix: 'opf', dcPrefix: 'dc' },
|
||||
{ name: 'arbitrary OPF and Dublin Core prefixes', prefix: 'publication', metadataPrefix: 'publication', dcPrefix: 'bibliography' },
|
||||
{ name: 'an unprefixed package with a default namespace', prefix: '', metadataPrefix: '', dcPrefix: 'dc' },
|
||||
])('preserves metadata, cover, manifest and spine with $name', async ({ prefix, metadataPrefix, dcPrefix }) => {
|
||||
const qualify = (name: string, nsPrefix: string) => (nsPrefix ? `${nsPrefix}:${name}` : name);
|
||||
const opf = OPF_XML.replace('<package version="3.0">', '<package xmlns="http://www.idpf.org/2007/opf" version="2.0">')
|
||||
.replaceAll('<metadata ', `<metadata xmlns:${metadataPrefix || 'opf'}="http://www.idpf.org/2007/opf" `)
|
||||
.replaceAll('xmlns:dc=', `xmlns:${dcPrefix}=`)
|
||||
.replaceAll('dc:', `${dcPrefix}:`)
|
||||
.replaceAll('<dc:language>en</dc:language>'.replaceAll('dc:', `${dcPrefix}:`), `<${dcPrefix}:language>pt</${dcPrefix}:language>`)
|
||||
.replace(
|
||||
/<(\/?)(package|metadata|manifest|item|spine|itemref|meta)(?=[\s>])/g,
|
||||
(_, closing: string, name: string) => `<${closing}${qualify(name, name === 'metadata' ? metadataPrefix : prefix)}`,
|
||||
)
|
||||
.replace('version="2.0"', `xmlns:${prefix || 'opf'}="http://www.idpf.org/2007/opf" version="2.0"`);
|
||||
mockOpenFile.mockResolvedValueOnce(makeEpubArchive({ opf }) as any);
|
||||
|
||||
const info = await service.getBookInfo(99, undefined, user);
|
||||
|
||||
expect(info.metadata).toEqual({
|
||||
title: 'Reader Test',
|
||||
creator: 'Jane Doe',
|
||||
language: 'pt',
|
||||
publisher: 'Acme',
|
||||
description: 'A sample EPUB',
|
||||
identifier: 'book-1',
|
||||
});
|
||||
expect(info.coverPath).toBe('OPS/images/cover.jpg');
|
||||
expect(info.manifest).toHaveLength(6);
|
||||
expect(info.spine.map((item) => item.idref)).toEqual(['chap1', 'chap2']);
|
||||
expect(info.spine[1].linear).toBe(false);
|
||||
expect(info.toc?.children?.[0].label).toBe('Chapter 1');
|
||||
});
|
||||
|
||||
it('resolves namespace declarations on each element without accepting foreign metadata or prefix rebinding', async () => {
|
||||
const opf = OPF_XML.replace(
|
||||
'<package version="3.0">',
|
||||
'<package xmlns="http://www.idpf.org/2007/opf" xmlns:dc="urn:foreign" version="3.0">' +
|
||||
'<metadata xmlns="urn:foreign"><dc:language>de</dc:language></metadata>',
|
||||
)
|
||||
.replace(
|
||||
'<dc:title>',
|
||||
'<dc:title xmlns:dc="urn:foreign">Foreign title</dc:title><bibliography:title xmlns:bibliography="http://purl.org/dc/elements/1.1/">',
|
||||
)
|
||||
.replace('</dc:title>\n', '</bibliography:title>\n')
|
||||
.replace('<dc:language>en</dc:language>', '<language xmlns="http://purl.org/dc/elements/1.1/">pt-BR</language>')
|
||||
.replace('<dc:identifier>book-1</dc:identifier>', '<dc:identifier>0000123</dc:identifier>');
|
||||
mockOpenFile.mockResolvedValueOnce(makeEpubArchive({ opf }) as any);
|
||||
|
||||
const info = await service.getBookInfo(99, undefined, user);
|
||||
|
||||
expect(info.metadata).toMatchObject({ title: 'Reader Test', creator: 'Jane Doe', language: 'pt-BR', identifier: '0000123' });
|
||||
expect(info.coverPath).toBe('OPS/images/cover.jpg');
|
||||
expect(info.spine).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('parses EPUB metadata, spine, TOC, and optional META-INF files', async () => {
|
||||
mockOpenFile.mockResolvedValueOnce(makeEpubArchive({ lowerCasePaths: true }) as any);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BookReadService } from '../../book/book-read.service';
|
||||
import { LibraryService } from '../../library/library.service';
|
||||
import type { RequestUser } from '../../../common/types/request-user';
|
||||
import { buildEpubMediaOverlayPlaylist, findEpubZipEntry, normalizeEpubZipPath } from './epub-media-overlay';
|
||||
import { parseEpubPackage } from './epub-package';
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
'.xhtml': 'application/xhtml+xml',
|
||||
@@ -206,8 +207,7 @@ async function parseEpub(epubPath: string): Promise<EpubBookInfo> {
|
||||
|
||||
const opfEntry = findInZip(zip.files, opfPath);
|
||||
if (!opfEntry) throw new Error(`OPF not found: ${opfPath}`);
|
||||
const opfDoc = xmlParser.parse(await opfEntry.buffer()) as Record<string, unknown>;
|
||||
const pkg = (opfDoc['package'] ?? opfDoc) as Record<string, unknown>;
|
||||
const pkg = parseEpubPackage(await opfEntry.buffer());
|
||||
const manifestEl = pkg['manifest'] as Record<string, unknown> | undefined;
|
||||
const spineEl = pkg['spine'] as Record<string, unknown> | undefined;
|
||||
const metadataEl = pkg['metadata'] as Record<string, unknown> | undefined;
|
||||
|
||||
@@ -50,7 +50,13 @@ function escapeXml(value: string): string {
|
||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
||||
}
|
||||
|
||||
async function createReaderDeliveryEpub(rootPath: string, relativePath: string, title: string): Promise<string> {
|
||||
async function createReaderDeliveryEpub(
|
||||
rootPath: string,
|
||||
relativePath: string,
|
||||
title: string,
|
||||
options: { metadataPrefix?: string; language?: string } = {},
|
||||
): Promise<string> {
|
||||
const metadataPrefix = options.metadataPrefix ?? '';
|
||||
const identifier = `urn:uuid:${randomUUID()}`;
|
||||
const author = 'Reader Fixture Author';
|
||||
const publisher = 'Reader Fixture Publisher';
|
||||
@@ -63,15 +69,15 @@ async function createReaderDeliveryEpub(rootPath: string, relativePath: string,
|
||||
</container>`;
|
||||
const opfXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package version="3.0" unique-identifier="uid" xmlns="http://www.idpf.org/2007/opf">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<${metadataPrefix}metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
|
||||
<dc:identifier id="uid">${escapeXml(identifier)}</dc:identifier>
|
||||
<dc:title>${escapeXml(title)}</dc:title>
|
||||
<dc:creator>${escapeXml(author)}</dc:creator>
|
||||
<dc:language>en</dc:language>
|
||||
<dc:language>${escapeXml(options.language ?? 'en')}</dc:language>
|
||||
<dc:publisher>${escapeXml(publisher)}</dc:publisher>
|
||||
<dc:description>${escapeXml(description)}</dc:description>
|
||||
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
|
||||
</metadata>
|
||||
</${metadataPrefix}metadata>
|
||||
<manifest>
|
||||
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml" />
|
||||
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav" />
|
||||
@@ -125,6 +131,7 @@ describe('Reader format delivery (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
|
||||
let hiddenLibrary!: Awaited<ReturnType<typeof createLibraryWithFolder>>;
|
||||
|
||||
let sharedEpub!: LocatedBookFile;
|
||||
let prefixedEpub!: LocatedBookFile;
|
||||
let sharedCbz!: LocatedBookFile;
|
||||
let hiddenEpub!: LocatedBookFile;
|
||||
|
||||
@@ -139,6 +146,15 @@ describe('Reader format delivery (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
|
||||
hiddenLibrary = await createLibraryWithFolder(ctx, { name: `reader-delivery-hidden-${randomUUID()}` });
|
||||
|
||||
const sharedEpubPath = await createReaderDeliveryEpub(sharedLibrary.folderPath, 'delivery/reader-delivery.epub', 'Reader Delivery EPUB');
|
||||
const prefixedEpubPath = await createReaderDeliveryEpub(
|
||||
sharedLibrary.folderPath,
|
||||
'delivery/prefixed-metadata.epub',
|
||||
'Portuguese Namespace EPUB',
|
||||
{
|
||||
metadataPrefix: 'opf:',
|
||||
language: 'pt',
|
||||
},
|
||||
);
|
||||
const sharedCbzPath = await createReaderDeliveryCbz(sharedLibrary.folderPath, 'delivery/reader-delivery.cbz', 'Reader Delivery Comic');
|
||||
const hiddenEpubPath = await createEpubFixture(hiddenLibrary.folderPath, 'restricted/hidden-reader.epub', {
|
||||
title: 'Hidden Reader EPUB',
|
||||
@@ -148,6 +164,7 @@ describe('Reader format delivery (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
|
||||
await triggerAndWaitForLibraryScan(ctx, hiddenLibrary.libraryId);
|
||||
|
||||
sharedEpub = await locateBookByAbsolutePath(ctx, sharedEpubPath);
|
||||
prefixedEpub = await locateBookByAbsolutePath(ctx, prefixedEpubPath);
|
||||
sharedCbz = await locateBookByAbsolutePath(ctx, sharedCbzPath);
|
||||
hiddenEpub = await locateBookByAbsolutePath(ctx, hiddenEpubPath);
|
||||
|
||||
@@ -167,6 +184,30 @@ describe('Reader format delivery (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =>
|
||||
});
|
||||
|
||||
describe('EPUB info and asset delivery', () => {
|
||||
it('returns Portuguese metadata and readable chapters for the prefixed metadata reported in issue 1789', async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/epub/${prefixedEpub.bookId}/info?fileId=${prefixedEpub.bookFileId}`,
|
||||
headers: authHeader(viewer.accessToken),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
metadata: { title: 'Portuguese Namespace EPUB', creator: 'Reader Fixture Author', language: 'pt' },
|
||||
spine: [{ idref: 'chapter', href: 'OPS/chapter.xhtml', mediaType: 'application/xhtml+xml', linear: true }],
|
||||
coverPath: 'OPS/images/cover image.png',
|
||||
});
|
||||
|
||||
const chapter = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/epub/${prefixedEpub.bookId}/file/OPS/chapter.xhtml?fileId=${prefixedEpub.bookFileId}`,
|
||||
headers: authHeader(viewer.accessToken),
|
||||
});
|
||||
expect(chapter.statusCode).toBe(200);
|
||||
expect(chapter.headers['content-type']).toContain('application/xhtml+xml');
|
||||
expect(chapter.body).toContain('Portuguese Namespace EPUB');
|
||||
});
|
||||
|
||||
it('returns the EPUB info shape the reader expects and serves manifest and optional files', async () => {
|
||||
const infoResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
|
||||
Reference in New Issue
Block a user