fix(reader): support epub loading without native grouping APIs

Keep EPUB loading functional on browsers without Object.groupBy or Map.groupBy.
Replace the native calls with local helpers and cover missing APIs with EPUB 2
and EPUB 3 metadata regression tests.

Closes #1683
This commit is contained in:
neon
2026-10-12 17:12:49 +00:00
parent 2b89c30a8d
commit 1ab087dbd2
2 changed files with 114 additions and 7 deletions
+24 -7
View File
@@ -181,6 +181,25 @@ const getPropertyURL = (value, prefixes) => {
return baseURL ? baseURL + reference : null
}
const groupBy = (items, getKey) => {
const groups = Object.create(null)
for (const item of items) {
const key = getKey(item)
;(groups[key] ??= []).push(item)
}
return groups
}
const groupByMap = (items, getKey) => {
const groups = new Map()
for (const item of items) {
const key = getKey(item)
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(item)
}
return groups
}
const getMetadata = (opf) => {
const { $ } = childGetter(opf, NS.OPF)
const $metadata = $(opf.documentElement, 'metadata')
@@ -188,7 +207,7 @@ const getMetadata = (opf) => {
const opfNS = opf.documentElement.namespaceURI === NS.OPF ? NS.OPF : null
// first pass: convert to JS objects
const els = Object.groupBy($metadata.children, (el) =>
const els = groupBy($metadata.children, (el) =>
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'
@@ -210,13 +229,13 @@ const getMetadata = (opf) => {
),
}
}
const refines = Map.groupBy(els.meta ?? [], (el) => el.getAttribute('refines'))
const refines = groupByMap(els.meta ?? [], (el) => el.getAttribute('refines'))
const getProperties = (el) => {
const els = refines.get(el ? '#' + el.getAttribute('id') : null)
if (!els) return null
return Object.groupBy(els.map(parse), (x) => x.property)
return groupBy(els.map(parse), (x) => x.property)
}
const dc = Object.fromEntries(Object.entries(Object.groupBy(els.dc, (el) => el.localName)).map(([name, els]) => [name, els.map(parse)]))
const dc = Object.fromEntries(Object.entries(groupBy(els.dc, (el) => el.localName)).map(([name, els]) => [name, els.map(parse)]))
const properties = getProperties() ?? {}
const legacyMeta = Object.fromEntries(els.legacyMeta?.map((el) => [el.getAttribute('name'), el.getAttribute('content')]) ?? [])
@@ -268,9 +287,7 @@ const getMetadata = (opf) => {
}
return value
}
const belongsTo = Object.groupBy(properties['belongs-to-collection'] ?? [], (x) =>
prop(x, 'collection-type') === 'series' ? 'series' : 'collection',
)
const belongsTo = groupBy(properties['belongs-to-collection'] ?? [], (x) => (prop(x, 'collection-type') === 'series' ? 'series' : 'collection'))
const mainTitle = dc.title?.find((x) => prop(x, 'title-type') === 'main') ?? dc.title?.[0]
const metadata = {
identifier: getIdentifier(opf),
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it } from 'vitest'
// @ts-expect-error public Foliate asset has no TypeScript declarations.
import { EPUB } from '../../../../../public/assets/foliate/epub.js'
const objectGroupBy = Object.getOwnPropertyDescriptor(Object, 'groupBy')!
const mapGroupBy = Object.getOwnPropertyDescriptor(Map, 'groupBy')!
afterEach(() => {
Object.defineProperty(Object, 'groupBy', objectGroupBy)
Object.defineProperty(Map, 'groupBy', mapGroupBy)
})
async function openEpub(version: string, extraMetadata: string) {
const files: Record<string, string> = {
'META-INF/container.xml': `<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0"><rootfiles><rootfile full-path="OPS/book.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`,
'OPS/book.opf': `<package xmlns="http://www.idpf.org/2007/opf" version="${version}" unique-identifier="id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf"><dc:identifier id="id">urn:uuid:compatibility-test</dc:identifier><dc:title id="title">Main title</dc:title><dc:creator id="author" opf:role="aut">Test author</dc:creator><dc:language>en</dc:language>${extraMetadata}</metadata><manifest><item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="chapter"/></spine></package>`,
'OPS/chapter.xhtml': `<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Chapter</title></head><body><p>Readable content.</p></body></html>`,
}
return new EPUB({
loadText: async (uri: string) => files[uri] ?? null,
loadBlob: async () => null,
getSize: () => 0,
}).init()
}
describe.each([
{ name: 'native grouping APIs', objectMissing: false, mapMissing: false },
{ name: 'Object.groupBy unavailable', objectMissing: true, mapMissing: false },
{ name: 'Map.groupBy unavailable', objectMissing: false, mapMissing: true },
{ name: 'both grouping APIs unavailable', objectMissing: true, mapMissing: true },
])('EPUB metadata with $name', ({ objectMissing, mapMissing }) => {
function disableGroupingApis() {
if (objectMissing) Object.defineProperty(Object, 'groupBy', { ...objectGroupBy, value: undefined })
if (mapMissing) Object.defineProperty(Map, 'groupBy', { ...mapGroupBy, value: undefined })
}
it('opens EPUB 3 with grouped values, refinements, rendition and media metadata intact', async () => {
const metadata = `<dc:title id="subtitle">Subtitle</dc:title><dc:language>fr</dc:language>
<meta refines="#title" property="title-type">main</meta>
<meta refines="#title" property="file-as">Title, Main</meta>
<meta refines="#subtitle" property="title-type">subtitle</meta>
<meta refines="#author" property="file-as">Author, Test</meta>
<meta refines="#author" property="role">aut</meta>
<meta property="belongs-to-collection" id="collection">Test collection</meta>
<meta refines="#collection" property="collection-type">set</meta>
<meta refines="#collection" property="group-position">2</meta>
<meta property="rendition:layout">pre-paginated</meta>
<meta property="media:duration">00:01:30</meta>
<meta property="__proto__">Untrusted property</meta>
<meta property="constructor">Another untrusted property</meta>`
const baseline = await openEpub('3.0', metadata)
disableGroupingApis()
const book = await openEpub('3.0', metadata)
expect(book.metadata).toEqual(baseline.metadata)
expect(book.metadata).toMatchObject({
title: 'Main title',
sortAs: 'Title, Main',
subtitle: 'Subtitle',
author: { name: 'Test author', sortAs: 'Author, Test' },
language: ['en', 'fr'],
belongsTo: { collection: { name: 'Test collection', position: '2' } },
})
expect(book.rendition).toEqual({ layout: 'pre-paginated' })
expect(book.media).toEqual({ duration: 90 })
expect(book.sections.map((section: { id: string }) => section.id)).toEqual(['OPS/chapter.xhtml'])
const chapter = await book.sections[0].createDocument()
expect(chapter.querySelector('p')?.textContent).toBe('Readable content.')
expect(Object.getOwnPropertyDescriptor(Object, 'groupBy')?.value).toBe(objectMissing ? undefined : objectGroupBy.value)
expect(Object.getOwnPropertyDescriptor(Map, 'groupBy')?.value).toBe(mapMissing ? undefined : mapGroupBy.value)
})
it('opens EPUB 2 without refinement metadata and preserves legacy series metadata', async () => {
disableGroupingApis()
const book = await openEpub('2.0', '<meta name="calibre:series" content="Test series"/><meta name="calibre:series_index" content="3"/>')
expect(book.metadata).toMatchObject({
title: 'Main title',
author: { name: 'Test author', role: 'aut' },
language: 'en',
belongsTo: { series: { name: 'Test series', position: 3 } },
})
expect(book.rendition).toEqual({})
expect(book.media).toEqual({})
expect(book.sections).toHaveLength(1)
})
})