fix(reader): open EPUBs whose package lost its default namespace

epubBuilder writes the OPF root as `mlns=` for `xmlns=`. Once a metadata save
bound `opf:` on <package>, foliate matched OPF elements by namespace, found no
spine, and failed with "reading 'children'". Match by namespace only when the
root is in it, and have the writer add the missing default namespace so
rewritten files also open in released iOS builds.

Closes #1680
This commit is contained in:
neon
2026-10-12 05:09:00 +00:00
parent 688cfdeaec
commit 30229c7451
4 changed files with 190 additions and 4 deletions
+7 -3
View File
@@ -90,8 +90,10 @@ const childGetter = (doc, ns) => {
$$: () => [],
$$$: () => [],
}
// ignore the namespace if it doesn't appear in document at all
const useNS = doc.lookupNamespaceURI(null) === ns || doc.lookupPrefix(ns)
// match by namespace only when the document's elements are actually in it: an OPF whose
// <package> lost its default xmlns (e.g. a `mlns` typo) can still bind the namespace to a
// prefix for its `opf:` attributes, which must not make its unqualified elements invisible
const useNS = doc.documentElement.namespaceURI === ns
const f = useNS ? (el, name) => (el) => el.namespaceURI === ns && el.localName === name : (el, name) => (el) => el.localName === name
return {
$: (el, name) => [...el.children].find(f(el, name)),
@@ -182,10 +184,12 @@ const getPropertyURL = (value, prefixes) => {
const getMetadata = (opf) => {
const { $ } = childGetter(opf, NS.OPF)
const $metadata = $(opf.documentElement, 'metadata')
// without the package's default xmlns its own elements, `meta` included, have no namespace
const opfNS = opf.documentElement.namespaceURI === NS.OPF ? NS.OPF : null
// first pass: convert to JS objects
const els = Object.groupBy($metadata.children, (el) =>
el.namespaceURI === NS.DC ? 'dc' : el.namespaceURI === NS.OPF && el.localName === 'meta' ? (el.hasAttribute('name') ? 'legacyMeta' : 'meta') : '',
el.namespaceURI === NS.DC ? 'dc' : el.namespaceURI === opfNS && el.localName === 'meta' ? (el.hasAttribute('name') ? 'legacyMeta' : 'meta') : '',
)
const baseLang = $metadata.getAttribute('xml:lang') ?? opf.documentElement.getAttribute('xml:lang') ?? 'und'
const prefixes = getPrefixes(opf)
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest'
// @ts-expect-error public Foliate asset has no TypeScript declarations.
import { EPUB } from '../../../../../public/assets/foliate/epub.js'
const OPF_NS = 'http://www.idpf.org/2007/opf'
const DC_NS = 'http://purl.org/dc/elements/1.1/'
const CONTAINER = `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OPS/fb.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`
const NCX = `<?xml version="1.0" encoding="UTF-8"?>
<ncx version="2005-1" xmlns="http://www.daisy.org/z3986/2005/ncx/">
<navMap>
<navPoint id="coverpage" playOrder="0"><navLabel><text>封面</text></navLabel><content src="coverpage.html"/></navPoint>
<navPoint id="chapter1" playOrder="1"><navLabel><text>第一章 白纸人</text></navLabel><content src="chapter1.html"/></navPoint>
</navMap>
</ncx>`
interface OpfShape {
rootAttributes: string
// `opf:` when every OPF element is qualified with a prefix instead of relying on a default xmlns
prefix?: string
version?: string
extraMetadata?: string
beforeSpine?: string
}
// The shape epubBuilder writes (bookorbit/bookorbit#1680): Dublin Core and `opf:` bound on <metadata>,
// EPUB 2 `meta name="cover"`, an NCX toc, and a guide.
function opfDocument({ rootAttributes, prefix = '', version = '2.0', extraMetadata = '', beforeSpine = '' }: OpfShape): string {
const p = prefix
return `<?xml version="1.0" encoding="UTF-8"?><${p}package version="${version}" unique-identifier="PrimaryID" ${rootAttributes}><${p}metadata xmlns:dc="${DC_NS}" xmlns:opf="${OPF_NS}"><dc:title>鬼吹灯Ⅰ</dc:title><dc:creator opf:role="aut" opf:file-as="天下霸唱">天下霸唱</dc:creator><dc:language>zh-CN</dc:language><dc:identifier id="PrimaryID">urn:isbn:9787539628042</dc:identifier><${p}meta name="cover" content="cover-image"/>${extraMetadata}</${p}metadata><${p}manifest><${p}item id="coverpage" href="coverpage.html" media-type="application/xhtml+xml"/><${p}item id="chapter1" href="chapter1.html" media-type="application/xhtml+xml"/><${p}item id="ncx" href="fb.ncx" media-type="application/x-dtbncx+xml"/><${p}item id="cover-image" href="images/cover.jpg" media-type="image/jpeg"/></${p}manifest>${beforeSpine}<${p}spine toc="ncx"><${p}itemref idref="coverpage" linear="yes"/><${p}itemref idref="chapter1" linear="yes"/></${p}spine><${p}guide><${p}reference type="cover" title="封面" href="coverpage.html"/></${p}guide></${p}package>`
}
async function openEpub(opf: string) {
const files: Record<string, string> = {
'META-INF/container.xml': CONTAINER,
'OPS/fb.opf': opf,
'OPS/fb.ncx': NCX,
}
const book = new EPUB({
loadText: async (uri: string) => files[uri] ?? null,
loadBlob: async () => null,
getSize: () => 0,
})
return book.init()
}
describe('EPUB package namespace handling', () => {
it.each([
{
name: 'the shipped file: `mlns` typo with `opf:` also bound on <package>',
rootAttributes: `mlns="${OPF_NS}" xmlns:opf="${OPF_NS}" xmlns:dc="${DC_NS}"`,
},
{
name: 'the epubBuilder original: `mlns` typo with `opf:` bound only on <metadata>',
rootAttributes: `mlns="${OPF_NS}"`,
},
{ name: 'no namespace at all', rootAttributes: '' },
{ name: 'a default xmlns', rootAttributes: `xmlns="${OPF_NS}"` },
{ name: 'a default xmlns with `opf:` also bound on <package>', rootAttributes: `xmlns="${OPF_NS}" xmlns:opf="${OPF_NS}"` },
{ name: 'prefixed `opf:` elements', rootAttributes: `xmlns:opf="${OPF_NS}"`, prefix: 'opf:' },
])('reads the spine, manifest, toc, cover and metadata with $name', async ({ rootAttributes, prefix }) => {
const book = await openEpub(opfDocument({ rootAttributes, prefix }))
expect(book.sections.map((section: { id: string }) => section.id)).toEqual(['OPS/coverpage.html', 'OPS/chapter1.html'])
expect(book.toc.map((item: { label: string; href: string }) => [item.label, item.href])).toEqual([
['封面', 'OPS/coverpage.html'],
['第一章 白纸人', 'OPS/chapter1.html'],
])
expect(book.resources.cover?.href).toBe('OPS/images/cover.jpg')
expect(book.landmarks).toEqual([{ label: '封面', type: ['cover'], href: 'OPS/coverpage.html' }])
expect(book.metadata.title).toBe('鬼吹灯Ⅰ')
expect(book.metadata.author).toMatchObject({ name: '天下霸唱', sortAs: '天下霸唱' })
expect(book.metadata.language).toBe('zh-CN')
})
it.each([
{ name: 'with `opf:` bound on <package>', rootAttributes: `mlns="${OPF_NS}" xmlns:opf="${OPF_NS}"` },
{ name: 'with `opf:` bound only on <metadata>', rootAttributes: `mlns="${OPF_NS}"` },
])('reads OPF <meta> entries when the package has lost its default xmlns, $name', async ({ rootAttributes }) => {
const book = await openEpub(
opfDocument({
rootAttributes,
version: '3.0',
extraMetadata:
'<meta property="rendition:layout">pre-paginated</meta><meta name="calibre:series" content="鬼吹灯"/><meta name="calibre:series_index" content="2"/>',
}),
)
expect(book.rendition.layout).toBe('pre-paginated')
expect(book.metadata.belongsTo.series).toEqual({ name: '鬼吹灯', position: 2 })
})
it('still ignores same-named elements from other namespaces when the package is namespaced', async () => {
const book = await openEpub(
opfDocument({
rootAttributes: `xmlns="${OPF_NS}"`,
beforeSpine: '<x:spine xmlns:x="urn:example:other"><x:itemref idref="chapter1"/></x:spine>',
extraMetadata: '<x:meta xmlns:x="urn:example:other" property="rendition:layout">pre-paginated</x:meta>',
}),
)
expect(book.sections.map((section: { id: string }) => section.id)).toEqual(['OPS/coverpage.html', 'OPS/chapter1.html'])
expect(book.rendition.layout).toBeUndefined()
})
})