fix(metadata): make epub metadata writes idempotent and lossless

Each save left the previous save's entries behind when a package bound the OPF namespace to a
prefix other than opf, added another title refinement on EPUB 3, and kept Dublin Core written
without the dc prefix beside its replacement. Match elements regardless of prefix and remove the
refinements of whatever is replaced.

Stop both OPF parsers reading untouched text as numbers and re-escaping character references,
which dropped leading zeros from identifiers the writer was meant to leave alone.

Fixes #1706
This commit is contained in:
neon
2026-10-10 16:30:24 +00:00
parent af8f55976f
commit 5a4560c9e2
5 changed files with 595 additions and 40 deletions
@@ -392,4 +392,42 @@ describe('epub-cover-handler', () => {
expect(result.updatedOpfXml).not.toContain('<meta name="cover"');
expect(result.updatedOpfXml).not.toContain('<item id="bookorbit-cover-image"');
});
describe('text outside the cover declarations', () => {
const opf = `
<package version="3.0" unique-identifier="uid" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata>
<dc:identifier id="uid">0441172717</dc:identifier>
<meta refines="#uid" property="identifier-type" scheme="onix:codelist5">02</meta>
<dc:rights>&#169; 2020 Caf&#xE9; &amp; Bar</dc:rights>
<dc:type>&amp;#233; &amp;amp;</dc:type>
<meta name="calibre:title_sort" content="Caf&#233; &amp; Bar" />
</metadata>
<manifest>
<item id="cover" href="images/cover.jpg" media-type="image/jpeg" properties="cover-image" />
</manifest>
</package>
`;
const expected = [
'<dc:identifier id="uid">0441172717</dc:identifier>',
'<meta refines="#uid" property="identifier-type" scheme="onix:codelist5">02</meta>',
'<dc:rights>© 2020 Café &amp; Bar</dc:rights>',
'<dc:type>&amp;#233; &amp;amp;</dc:type>',
'<meta name="calibre:title_sort" content="Café &amp; Bar"></meta>',
];
it('survives injecting a cover', () => {
const result = inject(opf, '', webpImage, []);
expect(expected.filter((element) => !result.updatedOpfXml.includes(element))).toEqual([]);
});
it('survives replacing the cover with another format', () => {
const slot = { entryPath: 'images/cover.jpg', mediaType: 'image/jpeg', manifestItemId: 'cover' };
const result = replace(opf, '', slot, webpImage, ['images/cover.jpg']);
expect(expected.filter((element) => !result!.updatedOpfXml.includes(element))).toEqual([]);
});
});
});
@@ -12,6 +12,9 @@ const parser = new XMLParser({
isArray: (name) => ['item', 'meta', 'reference'].includes(name),
textNodeName: '#text',
allowBooleanAttributes: true,
// The whole package is written back from this parse; see writerParser in epub-opf-builder.
parseTagValue: false,
htmlEntities: true,
});
const documentParser = new XMLParser({
@@ -223,3 +223,122 @@ describe('EPUB cover write round trip', () => {
});
});
});
describe('EPUB metadata write round trip', () => {
async function listEntryPaths(filePath: string): Promise<string[]> {
const zip = await unzipper.Open.file(filePath);
return zip.files.map((entry) => entry.path).sort();
}
async function readMetas(filePath: string): Promise<Array<Record<string, string>>> {
const pkg = objectParser.parse((await readArchiveEntry(filePath, 'OPS/content.opf')).toString()).package;
return asArray<Record<string, string>>(pkg.metadata.meta);
}
it('keeps one ISBN-10 entry in a package bound to a foreign OPF prefix across consecutive saves', async () => {
const filePath = join(testRoot, 'foreign-prefix.epub');
const opf = `
<ns0:package version="2.0" unique-identifier="uid" xmlns:ns0="http://www.idpf.org/2007/opf" xmlns:dc="http://purl.org/dc/elements/1.1/">
<ns0:metadata>
<dc:identifier id="uid">urn:test:foreign-prefix</dc:identifier>
<dc:title>Old title</dc:title>
<dc:language>pt</dc:language>
<ns0:meta name="cover" content="cover" />
</ns0:metadata>
<ns0:manifest>
<ns0:item id="cover" href="images/cover.jpg" media-type="image/jpeg" />
<ns0:item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml" />
</ns0:manifest>
<ns0:spine><ns0:itemref idref="chapter" /></ns0:spine>
</ns0:package>`;
const cover = await makeCover('jpeg', { r: 30, g: 60, b: 90 });
await writeArchive(filePath, opf, [
{ path: 'OPS/images/cover.jpg', content: await makeCover('jpeg', { r: 200, g: 20, b: 20 }) },
{ path: 'OPS/chapter.xhtml', content: '<html><body>chapter</body></html>' },
]);
const entryPaths = await listEntryPaths(filePath);
const titleFix = { title: 'Titulo', language: 'pt', isbn10: '9722623702', coverBytes: cover };
const authorFix = { ...titleFix, authors: [{ name: 'Autor', sortName: null }] };
const descriptionFix = { ...authorFix, description: 'Sinopse' };
for (const payload of [titleFix, authorFix, descriptionFix]) {
const result = await new EpubFormatWriter().write(filePath, payload, {
fieldMask: new Set(['title', 'language', 'isbn10', 'authors', 'description', 'coverBytes']),
dryRun: false,
});
expect(result.status).toBe('success');
const metas = await readMetas(filePath);
expect(metas.filter((meta) => meta['@_name'] === 'bookorbit:isbn10')).toEqual([{ '@_name': 'bookorbit:isbn10', '@_content': '9722623702' }]);
expect(metas.filter((meta) => meta['@_name'] === 'cover')).toEqual([{ '@_name': 'cover', '@_content': 'cover' }]);
}
await expect(listEntryPaths(filePath)).resolves.toEqual(entryPaths);
await expect(readArchiveEntry(filePath, 'OPS/images/cover.jpg')).resolves.toEqual(cover);
});
it('keeps one title refinement in an EPUB 3 package across consecutive saves', async () => {
const filePath = join(testRoot, 'epub3-repeated.epub');
const opf = `
<package version="3.0" unique-identifier="uid" xmlns="http://www.idpf.org/2007/opf">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">urn:test:epub3-repeated</dc:identifier>
<dc:title>Old title</dc:title>
<dc:language>en</dc:language>
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
</metadata>
<manifest>
<item id="cover" href="images/cover.png" media-type="image/png" properties="cover-image" />
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml" />
</manifest>
<spine><itemref idref="chapter" /></spine>
</package>`;
const cover = await makeCover('png', { r: 30, g: 60, b: 90 });
await writeArchive(filePath, opf, [
{ path: 'OPS/images/cover.png', content: await makeCover('png', { r: 200, g: 20, b: 20 }) },
{ path: 'OPS/chapter.xhtml', content: '<html><body>chapter</body></html>' },
]);
for (const title of ['First title', 'Second title', 'Third title']) {
await new EpubFormatWriter().write(filePath, { title, coverBytes: cover }, { fieldMask: new Set(['title', 'coverBytes']), dryRun: false });
const metas = await readMetas(filePath);
expect(metas.filter((meta) => meta['@_property'] === 'title-type')).toEqual([
{ '#text': 'main', '@_refines': '#t-main', '@_property': 'title-type' },
]);
expect(metas.filter((meta) => meta['@_property'] === 'dcterms:modified')).toHaveLength(1);
}
});
it('leaves the unique identifier and other untouched text as written when a cover is injected', async () => {
const filePath = join(testRoot, 'untouched-text.epub');
const opf = `
<package version="3.0" unique-identifier="uid" xmlns="http://www.idpf.org/2007/opf">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">0441172717</dc:identifier>
<meta refines="#uid" property="identifier-type" scheme="onix:codelist5">02</meta>
<dc:title>Old title</dc:title>
<dc:language>en</dc:language>
<dc:rights>&#169; 2020 Caf&#xE9; &amp; Bar</dc:rights>
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
</metadata>
<manifest>
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml" />
</manifest>
<spine><itemref idref="chapter" /></spine>
</package>`;
await writeArchive(filePath, opf, [{ path: 'OPS/chapter.xhtml', content: '<html><body>chapter</body></html>' }]);
const cover = await makeCover('webp', { r: 30, g: 60, b: 90 });
for (const title of ['First title', 'Second title']) {
await new EpubFormatWriter().write(filePath, { title, coverBytes: cover }, { fieldMask: new Set(['title', 'coverBytes']), dryRun: false });
const written = (await readArchiveEntry(filePath, 'OPS/content.opf')).toString();
expect(written).toContain('<dc:identifier id="uid">0441172717</dc:identifier>');
expect(written).toContain('<meta refines="#uid" property="identifier-type" scheme="onix:codelist5">02</meta>');
expect(written).toContain('<dc:rights>© 2020 Café &amp; Bar</dc:rights>');
}
await expect(readArchiveEntry(filePath, 'OPS/images/bookorbit-cover.webp')).resolves.toEqual(cover);
});
});
@@ -1,5 +1,6 @@
import { XMLParser } from 'fast-xml-parser';
import type { BookWritePayload } from '../../interfaces/book-write-payload.interface';
import { build } from './epub-opf-builder';
const parser = new XMLParser({
@@ -11,6 +12,104 @@ const parser = new XMLParser({
isArray: (name) => ['dc:creator', 'dc:identifier', 'dc:subject', 'dc:title', 'meta'].includes(name),
});
const inspector = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '',
preserveOrder: true,
textNodeName: '#text',
parseTagValue: false,
});
const OPF_NS = 'http://www.idpf.org/2007/opf';
const DC_NS = 'http://purl.org/dc/elements/1.1/';
type OrderedNode = Record<string, unknown>;
interface MetadataElement {
tag: string;
attrs: Record<string, string>;
text: string;
}
function tagOf(node: OrderedNode): string {
return Object.keys(node).find((key) => key !== ':@') ?? '';
}
function childrenOf(nodes: OrderedNode[], localName: string): OrderedNode[] {
const node = nodes.find((candidate) => tagOf(candidate).split(':').at(-1) === localName);
if (!node) throw new Error(`Missing <${localName}> in OPF`);
return node[tagOf(node)] as OrderedNode[];
}
function metadataElements(opfXml: string): MetadataElement[] {
const packageChildren = childrenOf(inspector.parse(opfXml) as OrderedNode[], 'package');
return childrenOf(packageChildren, 'metadata').map((node) => {
const tag = tagOf(node);
const text = (node[tag] as OrderedNode[]).map((child) => (typeof child['#text'] === 'string' ? child['#text'] : '')).join('');
return { tag, attrs: (node[':@'] ?? {}) as Record<string, string>, text };
});
}
function duplicatedElements(elements: MetadataElement[]): string[] {
const serialized = elements.map((element) => JSON.stringify(element));
return serialized.filter((element, index) => serialized.indexOf(element) !== index);
}
// `prefix` is what the file binds the OPF namespace to; '' is the usual default namespace.
function opfDocument(version: '2.0' | '3.0', prefix: string, metadata: (p: string) => string, packageAttrs = `xmlns:dc="${DC_NS}"`): string {
const p = prefix ? `${prefix}:` : '';
const opfBinding = prefix ? `xmlns:${prefix}` : 'xmlns';
return `<?xml version="1.0" encoding="utf-8"?>
<${p}package ${opfBinding}="${OPF_NS}" ${packageAttrs} version="${version}" unique-identifier="uid">
<${p}metadata>${metadata(p)}
</${p}metadata>
<${p}manifest><${p}item id="cover-image" href="cover.jpg" media-type="image/jpeg" /></${p}manifest>
</${p}package>`;
}
function sourceMetadata(p: string): string {
return `
<dc:identifier id="uid">urn:uuid:abc</dc:identifier>
<dc:title>Old title</dc:title>
<dc:creator>Old Author</dc:creator>
<dc:language>pt</dc:language>
<${p}meta name="cover" content="cover-image" />
<${p}meta name="calibre:timestamp" content="2020-01-01T00:00:00+00:00" />`;
}
function saveRepeatedly(opfXml: string, payload: BookWritePayload, saves: number): string[] {
const outputs: string[] = [];
let current = opfXml;
for (let save = 0; save < saves; save += 1) {
current = build(current, payload).newOpfXml;
outputs.push(current);
}
return outputs;
}
const FULL_PAYLOAD: BookWritePayload = {
title: 'Dune',
subtitle: 'Book One',
description: 'Arrakis',
publisher: 'Ace',
publishedDate: '1965-08-01',
language: 'en',
pageCount: 412,
seriesName: 'Dune Chronicles',
seriesIndex: '1',
isbn10: '0441172717',
isbn13: '9780441172719',
rating: 5,
authors: [
{ name: 'Frank Herbert', sortName: 'Herbert, Frank' },
{ name: 'Brian Herbert', sortName: null },
],
genres: ['Science Fiction', 'Classic'],
tags: ['space', 'desert'],
goodreadsId: '44767458',
customMetadata: [{ fieldId: 1, key: 'original_title', label: 'Original Title', type: 'text', displayOrder: 0, value: 'Dune' }],
};
describe('epub-opf-builder', () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -316,4 +415,286 @@ describe('epub-opf-builder', () => {
expect(result.newOpfXml).not.toContain('empty_field');
expect(result.fieldsWritten).toEqual(['customMetadata:original_title', 'customMetadata:translated']);
});
describe('repeated saves', () => {
const shapes = [
{ shape: 'an unprefixed EPUB 2 package', version: '2.0', prefix: '' },
{ shape: 'an opf-prefixed EPUB 2 package', version: '2.0', prefix: 'opf' },
{ shape: 'an ns0-prefixed EPUB 2 package', version: '2.0', prefix: 'ns0' },
{ shape: 'an unprefixed EPUB 3 package', version: '3.0', prefix: '' },
{ shape: 'an opf-prefixed EPUB 3 package', version: '3.0', prefix: 'opf' },
{ shape: 'an ns0-prefixed EPUB 3 package', version: '3.0', prefix: 'ns0' },
] as const;
it.each(shapes)('writes the same document on every save of $shape', ({ version, prefix }) => {
const [first, second, third] = saveRepeatedly(opfDocument(version, prefix, sourceMetadata), FULL_PAYLOAD, 3);
expect(second).toBe(first);
expect(third).toBe(first);
});
it.each(shapes)('writes every value once into $shape', ({ version, prefix }) => {
const elements = metadataElements(saveRepeatedly(opfDocument(version, prefix, sourceMetadata), FULL_PAYLOAD, 3)[2]);
expect(duplicatedElements(elements)).toEqual([]);
expect(elements.filter((element) => element.attrs.name === 'cover')).toHaveLength(1);
expect(elements.filter((element) => element.attrs.name === 'calibre:timestamp')).toHaveLength(1);
});
it('keeps one bookorbit:isbn10 entry while unrelated fields are saved one after another', () => {
const titleFix: BookWritePayload = { title: 'Titulo', publisher: 'Editora', language: 'pt', isbn10: '9722623702', genres: ['Romance'] };
const authorFix: BookWritePayload = { ...titleFix, authors: [{ name: 'Autor', sortName: null }] };
const descriptionFix: BookWritePayload = { ...authorFix, description: 'Sinopse' };
let opf = opfDocument('2.0', 'ns0', sourceMetadata);
for (const payload of [titleFix, authorFix, descriptionFix]) {
opf = build(opf, payload).newOpfXml;
expect(metadataElements(opf).filter((element) => element.attrs.name === 'bookorbit:isbn10')).toEqual([
{ tag: 'ns0:meta', attrs: { name: 'bookorbit:isbn10', content: '9722623702' }, text: '' },
]);
}
});
it('keeps one title-type refinement per title on EPUB 3', () => {
const [, , third] = saveRepeatedly(opfDocument('3.0', '', sourceMetadata), { title: 'Dune', subtitle: 'Book One' }, 3);
expect(metadataElements(third).filter((element) => element.attrs.property === 'title-type')).toEqual([
{ tag: 'meta', attrs: { refines: '#t-main', property: 'title-type' }, text: 'main' },
{ tag: 'meta', attrs: { refines: '#t-sub', property: 'title-type' }, text: 'subtitle' },
]);
});
it('removes the subtitle refinement once the subtitle is cleared', () => {
const withSubtitle = build(opfDocument('3.0', '', sourceMetadata), { title: 'Dune', subtitle: 'Book One' }).newOpfXml;
const withoutSubtitle = build(withSubtitle, { title: 'Dune', subtitle: null }).newOpfXml;
expect(metadataElements(withoutSubtitle).filter((element) => element.attrs.refines === '#t-sub')).toEqual([]);
});
it('collapses bookorbit entries duplicated by earlier saves', () => {
const duplicated = '<ns0:meta name="bookorbit:isbn10" content="9722623702"></ns0:meta>'.repeat(3);
const damaged = opfDocument('2.0', 'ns0', (p) => `${sourceMetadata(p)}${duplicated}`);
const repaired = build(damaged, { title: 'Titulo', isbn10: '9722623702' }).newOpfXml;
expect(metadataElements(repaired).filter((element) => element.attrs.name === 'bookorbit:isbn10')).toHaveLength(1);
});
it('collapses title refinements accumulated by earlier saves', () => {
const accumulated = '<meta refines="#t-main" property="title-type">main</meta>'.repeat(3);
const damaged = opfDocument(
'3.0',
'',
() => `<dc:identifier id="uid">urn:uuid:abc</dc:identifier><dc:title id="t-main">Dune</dc:title>${accumulated}`,
);
const repaired = build(damaged, { title: 'Dune' }).newOpfXml;
expect(metadataElements(repaired).filter((element) => element.attrs.property === 'title-type')).toHaveLength(1);
});
describe('subtitle refinements an earlier save left without a subtitle', () => {
const orphaned = '<meta refines="#t-sub" property="title-type">subtitle</meta>'.repeat(3);
const damaged = opfDocument(
'3.0',
'',
() => `<dc:identifier id="uid">urn:uuid:abc</dc:identifier><dc:title id="t-main">Dune</dc:title>${orphaned}`,
);
it('do not attach to a subtitle that is written again', () => {
const [first, second] = saveRepeatedly(damaged, { title: 'Dune', subtitle: 'Book One' }, 2);
expect(metadataElements(first).filter((element) => element.attrs.refines === '#t-sub')).toEqual([
{ tag: 'meta', attrs: { refines: '#t-sub', property: 'title-type' }, text: 'subtitle' },
]);
expect(second).toBe(first);
});
it('are removed when there is still no subtitle', () => {
const repaired = build(damaged, { title: 'Dune' }).newOpfXml;
expect(metadataElements(repaired).filter((element) => element.attrs.refines === '#t-sub')).toEqual([]);
});
});
});
describe('refinements', () => {
const source = opfDocument(
'3.0',
'',
() => `
<dc:identifier id="uid">urn:uuid:abc</dc:identifier>
<meta refines="#uid" property="identifier-type" scheme="onix:codelist5">22</meta>
<dc:title id="title">Old title</dc:title>
<meta refines="#title" property="title-type">main</meta>
<meta refines="#title" property="file-as">Old title, The</meta>
<dc:subject id="subject-1">Old subject</dc:subject>
<meta refines="#subject-1" property="authority">BISAC</meta>
<meta refines="subject-1" property="term">FIC000000</meta>
<dc:contributor id="translator">Translator</dc:contributor>
<meta refines="#translator" property="role" scheme="marc:relators">trl</meta>
<meta refines="#chapter-overlay" property="media:duration">0:01:00</meta>
<meta property="media:duration">0:10:00</meta>`,
);
function refinementsOf(opfXml: string, target: string): MetadataElement[] {
return metadataElements(opfXml).filter((element) => element.attrs.refines === target);
}
it('drops refinements of the elements it replaces', () => {
const result = build(source, { title: 'Dune', genres: ['Classic'] }).newOpfXml;
expect(refinementsOf(result, '#title')).toEqual([]);
expect(refinementsOf(result, '#subject-1')).toEqual([]);
expect(refinementsOf(result, 'subject-1')).toEqual([]);
});
it('keeps refinements of the elements it leaves in place', () => {
const result = build(source, { title: 'Dune', genres: ['Classic'] }).newOpfXml;
expect(refinementsOf(result, '#uid')).toEqual([
{ tag: 'meta', attrs: { refines: '#uid', property: 'identifier-type', scheme: 'onix:codelist5' }, text: '22' },
]);
expect(refinementsOf(result, '#translator')).toEqual([
{ tag: 'meta', attrs: { refines: '#translator', property: 'role', scheme: 'marc:relators' }, text: 'trl' },
]);
expect(refinementsOf(result, '#chapter-overlay')).toHaveLength(1);
expect(metadataElements(result).filter((element) => element.attrs.property === 'media:duration')).toHaveLength(2);
});
});
describe('elements the writer does not own', () => {
const source = opfDocument(
'3.0',
'',
() => `
<dc:identifier id="uid">0441172717</dc:identifier>
<meta refines="#uid" property="identifier-type" scheme="onix:codelist5">02</meta>
<dc:title>Old title</dc:title>
<dc:rights>&#169; 2020 Caf&#xE9; &amp; Bar</dc:rights>
<dc:type>&amp;#233; &amp;amp;</dc:type>
<dc:source>0x1F</dc:source>
<dc:coverage>1.50</dc:coverage>
<dc:relation>1e5</dc:relation>
<meta property="schema:numberOfPages">0320</meta>
<meta name="calibre:title_sort" content="Caf&#233; &amp; Bar" />`,
);
it('keeps their text exactly as written instead of reading it as a number', () => {
const elements = metadataElements(build(source, { title: 'Dune' }).newOpfXml);
const texts = Object.fromEntries(elements.map((element) => [element.attrs.property ?? element.tag, element.text]));
expect(texts).toMatchObject({
'dc:identifier': '0441172717',
'identifier-type': '02',
'dc:source': '0x1F',
'dc:coverage': '1.50',
'dc:relation': '1e5',
'schema:numberOfPages': '0320',
});
});
it('keeps character references readable instead of escaping them a second time', () => {
const result = build(source, { title: 'Dune' }).newOpfXml;
expect(result).toContain('<dc:rights>© 2020 Café &amp; Bar</dc:rights>');
expect(result).toContain('<meta name="calibre:title_sort" content="Café &amp; Bar"></meta>');
});
it('keeps an escaped ampersand escaped', () => {
const result = build(source, { title: 'Dune' }).newOpfXml;
expect(result).toContain('<dc:type>&amp;#233; &amp;amp;</dc:type>');
});
it('leaves them unchanged on every later save', () => {
const [first, second, third] = saveRepeatedly(source, { title: 'Dune' }, 3);
expect(second).toBe(first);
expect(third).toBe(first);
});
});
describe('packages that bind namespaces to other prefixes', () => {
function textsOf(opfXml: string, localName: string): string[] {
return metadataElements(opfXml)
.filter((element) => element.tag.split(':').at(-1) === localName)
.map((element) => element.text);
}
it('replaces calibre series entries and keeps unrelated ones under a foreign OPF prefix', () => {
const source = opfDocument(
'2.0',
'ns0',
(p) => `${sourceMetadata(p)}
<${p}meta name="calibre:series" content="Old Series" />
<${p}meta name="calibre:series_index" content="3" />`,
);
const [, second] = saveRepeatedly(source, { title: 'Dune', seriesName: 'Dune Chronicles', seriesIndex: '1' }, 2);
const contentsOf = (name: string) =>
metadataElements(second)
.filter((element) => element.attrs.name === name)
.map((element) => element.attrs.content);
expect(contentsOf('calibre:series')).toEqual(['Dune Chronicles']);
expect(contentsOf('calibre:series_index')).toEqual(['1']);
expect(contentsOf('cover')).toEqual(['cover-image']);
expect(contentsOf('calibre:timestamp')).toEqual(['2020-01-01T00:00:00+00:00']);
});
it('replaces Dublin Core elements bound to a foreign prefix', () => {
const source = opfDocument(
'2.0',
'ns0',
() => `
<ns1:identifier id="uid">urn:uuid:abc</ns1:identifier>
<ns1:title>Old title</ns1:title>
<ns1:creator>Old Author</ns1:creator>
<ns1:rights>All rights reserved</ns1:rights>`,
`xmlns:ns1="${DC_NS}"`,
);
const [, second] = saveRepeatedly(source, { title: 'Dune', authors: [{ name: 'Frank Herbert', sortName: null }] }, 2);
expect(textsOf(second, 'title')).toEqual(['Dune']);
expect(textsOf(second, 'creator')).toEqual(['Frank Herbert']);
expect(textsOf(second, 'identifier')).toEqual(['urn:uuid:abc']);
expect(textsOf(second, 'rights')).toEqual(['All rights reserved']);
});
it('replaces Dublin Core elements that declare the namespace on themselves', () => {
const source = opfDocument(
'2.0',
'',
() => `
<dc:identifier id="uid">urn:uuid:abc</dc:identifier>
<x:title xmlns:x="${DC_NS}">Old title</x:title>
<y:creator xmlns:y="${DC_NS}">Old Author</y:creator>
<publisher xmlns="${DC_NS}">Old publisher</publisher>`,
);
const [, second] = saveRepeatedly(source, { title: 'Dune', publisher: 'Ace', authors: [{ name: 'Frank Herbert', sortName: null }] }, 2);
expect(textsOf(second, 'title')).toEqual(['Dune']);
expect(textsOf(second, 'creator')).toEqual(['Frank Herbert']);
expect(textsOf(second, 'publisher')).toEqual(['Ace']);
});
it('leaves same-named elements from other namespaces in place', () => {
const source = opfDocument(
'2.0',
'',
() => `
<dc:identifier id="uid">urn:uuid:abc</dc:identifier>
<dc:title>Old title</dc:title>
<shelf:title xmlns:shelf="urn:example:shelf">Shelf label</shelf:title>`,
);
const result = build(source, { title: 'Dune' }).newOpfXml;
expect(textsOf(result, 'title')).toEqual(['Dune', 'Shelf label']);
});
});
});
@@ -13,6 +13,11 @@ const writerParser = new XMLParser({
isArray: (name) => ['dc:creator', 'dc:identifier', 'dc:subject', 'dc:title', 'meta', 'item', 'link', 'reference'].includes(name),
textNodeName: '#text',
allowBooleanAttributes: true,
// Everything the writer does not replace is written back from this parse, so it must come
// through as it was: number parsing turns an identifier like 0441172717 into 441172717, and an
// undecoded character reference (&#169;) has its ampersand escaped again on the way out.
parseTagValue: false,
htmlEntities: true,
});
const writerBuilder = new XMLBuilder({
@@ -27,6 +32,9 @@ type OrderedNode = Record<string, unknown>;
const EPUB_WRITABLE_FIELDS = new Set<BookWritePayloadKey>(EPUB_BOOK_FILE_WRITE_FIELDS);
const CUSTOM_METADATA_PREFIX = `${APP_WRITE_NAMESPACE}:custom:`;
const DC_NS_URI = 'http://purl.org/dc/elements/1.1/';
const MAIN_TITLE_ID = 't-main';
const SUBTITLE_ID = 't-sub';
function attr(obj: Record<string, unknown>, key: string): string {
const v = obj[key];
@@ -84,8 +92,14 @@ function localName(tag: string): string {
return tag.split(':').at(-1) ?? '';
}
function opfElementName(tag: string): string {
return tag.startsWith('opf:') ? tag.slice(4) : tag;
// A file may bind Dublin Core to any prefix, on <package>, <metadata>, or the element itself. A
// literal dc: always counts: older files bind it to a legacy URI or leave it undeclared.
function isDublinCore(tag: string, scopes: Record<string, unknown>[]): boolean {
if (tag.startsWith('dc:')) return true;
const separatorIndex = tag.indexOf(':');
const declaration = separatorIndex === -1 ? '@_xmlns' : `@_xmlns:${tag.slice(0, separatorIndex)}`;
const binding = scopes.map((scope) => attr(scope, declaration)).find((uri) => uri !== '');
return binding === DC_NS_URI;
}
function childTag(parentTag: string, name: string): string {
@@ -109,55 +123,55 @@ function makeEmptyNode(tag: string, attrs: Record<string, unknown> = {}): Ordere
return node;
}
// Strips known metadata elements, returns cleaned array + UID node + version
function stripMetadata(children: OrderedNode[], uidRef: string): { cleaned: OrderedNode[]; uidNode: OrderedNode | null } {
const KNOWN_DC = new Set(['dc:title', 'dc:creator', 'dc:description', 'dc:publisher', 'dc:date', 'dc:language', 'dc:subject', 'dc:identifier']);
// Strips known metadata elements, returns cleaned array + UID node. Elements are matched by local
// name: a file can bind the OPF namespace to any prefix, and buildFreshMetadata writes its metas
// under whatever prefix <metadata> carries, so matching a literal prefix would leave them behind.
function stripMetadata(
children: OrderedNode[],
uidRef: string,
ancestorAttrs: Record<string, unknown>[],
): { cleaned: OrderedNode[]; uidNode: OrderedNode | null } {
const KNOWN_DC = new Set(['title', 'creator', 'description', 'publisher', 'date', 'language', 'subject', 'identifier']);
const CALIBRE_SERIES_NAMES = new Set(['calibre:series', 'calibre:series_index']);
const BELONGS_TO_COLLECTION_PROPS = new Set(['belongs-to-collection', 'collection-type', 'group-position', 'dcterms:modified']);
// Collect ids of dc:creator nodes so we can remove their refines
const creatorIds = new Set<string>();
// Collect id of belonging-to-collection so we can remove their refines
const collectionIds = new Set<string>();
const knownDcName = (node: OrderedNode): string | null => {
const tag = nodeTagName(node);
const name = localName(tag);
return KNOWN_DC.has(name) && isDublinCore(tag, [getNodeAttrs(node), ...ancestorAttrs]) ? name : null;
};
const isUidNode = (node: OrderedNode): boolean => knownDcName(node) === 'identifier' && attr(getNodeAttrs(node), '@_id') === uidRef;
const isMeta = (node: OrderedNode): boolean => localName(nodeTagName(node)) === 'meta';
// Collect the id of every element being replaced so the metas refining it are removed with it.
// The replacements get new ids, so a refinement left behind dangles, and the ones written for
// the title ids would pile up on every save. Those two ids are always included: earlier versions
// left their refinements in the file when the title or subtitle itself was removed.
const replacedIds = new Set<string>([MAIN_TITLE_ID, SUBTITLE_ID]);
for (const node of children) {
const tag = opfElementName(nodeTagName(node));
if (tag === 'dc:creator') {
const id = attr(getNodeAttrs(node), '@_id');
if (id) creatorIds.add(id);
}
if (tag === 'meta') {
const nodeAttrs = getNodeAttrs(node);
const prop = attr(nodeAttrs, '@_property');
if (prop === 'belongs-to-collection') {
const id = attr(nodeAttrs, '@_id');
if (id) collectionIds.add(id);
}
}
const nodeAttrs = getNodeAttrs(node);
const id = attr(nodeAttrs, '@_id');
if (!id) continue;
const isReplaced = knownDcName(node) !== null ? !isUidNode(node) : isMeta(node) && attr(nodeAttrs, '@_property') === 'belongs-to-collection';
if (isReplaced) replacedIds.add(id);
}
let uidNode: OrderedNode | null = null;
const cleaned: OrderedNode[] = [];
for (const node of children) {
const tag = opfElementName(nodeTagName(node));
const nodeAttrs = getNodeAttrs(node);
if (KNOWN_DC.has(tag)) {
// Preserve the unique-identifier dc:identifier
if (tag === 'dc:identifier') {
const nodeId = attr(nodeAttrs, '@_id');
if (nodeId === uidRef) {
uidNode = node;
}
}
// All other known DC elements are stripped
if (knownDcName(node) !== null) {
// Preserve the unique-identifier dc:identifier; all other known DC elements are stripped
if (isUidNode(node)) uidNode = node;
continue;
}
if (tag === 'meta') {
if (isMeta(node)) {
const name = attr(nodeAttrs, '@_name');
const prop = attr(nodeAttrs, '@_property');
const refines = attr(nodeAttrs, '@_refines');
@@ -166,10 +180,10 @@ function stripMetadata(children: OrderedNode[], uidRef: string): { cleaned: Orde
if (CALIBRE_SERIES_NAMES.has(name)) continue;
// Strip belongs-to-collection and its refines
if (BELONGS_TO_COLLECTION_PROPS.has(prop)) continue;
// Strip refines that point to creator ids or collection ids
// Strip refines that point to a replaced element
if (refines) {
const refId = refines.startsWith('#') ? refines.slice(1) : refines;
if (creatorIds.has(refId) || collectionIds.has(refId)) continue;
if (replacedIds.has(refId)) continue;
}
// Strip bookorbit: named metas
if (name.startsWith(`${APP_WRITE_NAMESPACE}:`)) continue;
@@ -192,8 +206,8 @@ function buildFreshMetadata(payload: BookWritePayload, epubVersion: 3 | 2, uidNo
// dc:title
if (payload.title != null) {
if (epubVersion === 3) {
nodes.push(makeTextNode('dc:title', payload.title, { '@_id': 't-main' }));
nodes.push(makeTextNode(metaTag, 'main', { '@_refines': '#t-main', '@_property': 'title-type' }));
nodes.push(makeTextNode('dc:title', payload.title, { '@_id': MAIN_TITLE_ID }));
nodes.push(makeTextNode(metaTag, 'main', { '@_refines': `#${MAIN_TITLE_ID}`, '@_property': 'title-type' }));
} else {
nodes.push(makeTextNode('dc:title', payload.title));
}
@@ -202,8 +216,8 @@ function buildFreshMetadata(payload: BookWritePayload, epubVersion: 3 | 2, uidNo
// subtitle (EPUB3 as second dc:title, EPUB2 as bookorbit:subtitle meta)
if (payload.subtitle != null) {
if (epubVersion === 3) {
nodes.push(makeTextNode('dc:title', payload.subtitle, { '@_id': 't-sub' }));
nodes.push(makeTextNode(metaTag, 'subtitle', { '@_refines': '#t-sub', '@_property': 'title-type' }));
nodes.push(makeTextNode('dc:title', payload.subtitle, { '@_id': SUBTITLE_ID }));
nodes.push(makeTextNode(metaTag, 'subtitle', { '@_refines': `#${SUBTITLE_ID}`, '@_property': 'title-type' }));
} else {
nodes.push(makeEmptyNode(metaTag, { '@_name': `${APP_WRITE_NAMESPACE}:subtitle`, '@_content': payload.subtitle }));
}
@@ -380,7 +394,7 @@ export function build(opfXml: string, payload: BookWritePayload): { newOpfXml: s
const pkgContent = getPkgContentAll(pkgNode, pkgTag);
const metadata = findMetadata(pkgContent);
if (!metadata) throw new Error('Cannot find <metadata> element in OPF');
const { cleaned, uidNode } = stripMetadata(metadata.children, uidRef);
const { cleaned, uidNode } = stripMetadata(metadata.children, uidRef, [getNodeAttrs(metadata.node), pkgAttrs]);
const freshNodes = buildFreshMetadata(payload, epubVersion, uidNode, childTag(metadata.tag, 'meta'));
const newMetaChildren = [...freshNodes, ...cleaned];
metadata.node[metadata.tag] = newMetaChildren;