fix(metadata): preserve epub creators with multiple roles

Read every EPUB 3 role refinement so authors and narrators survive
regardless of role order, including creators who hold both roles.
Preserve EPUB 2 role precedence and contributor handling, and add
regression coverage for ordering, normalization, and deduplication.

Fixes #1344
This commit is contained in:
neon
2026-10-12 18:04:31 +00:00
parent 1ab087dbd2
commit 9e2cbfd3a5
2 changed files with 161 additions and 8 deletions
@@ -167,6 +167,97 @@ describe('parseOpf', () => {
expect(r.authors[0].sortName).toBe('Pratchett, Terry');
});
it.each(
[
['aut', 'ann', 'dto', 'ill', 'wpr'],
['ann', 'aut', 'dto', 'ill', 'wpr'],
['ann', 'dto', 'aut', 'ill', 'wpr'],
['ann', 'dto', 'ill', 'aut', 'wpr'],
['ann', 'dto', 'ill', 'wpr', 'aut'],
].map((roles) => ({ roles })),
)('includes a multi-role author with roles $roles', ({ roles }) => {
const xml = epub3Opf(`
<dc:title>Flatland</dc:title>
<dc:creator id="author">Edwin A. Abbott</dc:creator>
<meta property="file-as" refines="#author">Abbott, Edwin A.</meta>
${roles.map((role) => `<meta property="role" refines="#author" scheme="marc:relators">${role}</meta>`).join('\n')}
`);
const r = parseOpf(xml);
expect(r.title).toBe('Flatland');
expect(r.authors).toEqual([{ name: 'Edwin A. Abbott', sortName: 'Abbott, Edwin A.' }]);
expect(r.narrators).toEqual([]);
});
it.each([' AUT ', 'http://id.loc.gov/vocabulary/relators/aut', 'marc:aut'])('normalizes a later author role %s', (role) => {
const xml = epub3Opf(`
<dc:creator id="author">Author Name</dc:creator>
<meta refines="#author" property="role">ann</meta>
<meta refines="#author" property="role">${role}</meta>
`);
expect(parseOpf(xml).authors).toEqual([{ name: 'Author Name', sortName: null }]);
});
it.each([{ roles: ['ann', 'ill'] }, { roles: ['', 'edt'] }, { roles: ['edt', ''] }])(
'excludes a creator with no author role among $roles',
({ roles }) => {
const xml = epub3Opf(`
<dc:creator id="person">Editor Name</dc:creator>
${roles.map((role) => `<meta refines="#person" property="role">${role}</meta>`).join('\n')}
`);
expect(parseOpf(xml).authors).toEqual([]);
expect(parseOpf(xml).narrators).toEqual([]);
},
);
it('keeps a creator whose role refinements are all empty as an author', () => {
const xml = epub3Opf(`
<dc:creator id="author">Author Name</dc:creator>
<meta refines="#author" property="role"></meta>
<meta refines="#author" property="role"> </meta>
`);
expect(parseOpf(xml).authors).toEqual([{ name: 'Author Name', sortName: null }]);
});
it.each([
{ attribute: 'edt', refinements: ['ann', 'aut'], isAuthor: true },
{ attribute: 'aut', refinements: ['ann', 'ill'], isAuthor: false },
{ attribute: 'edt', refinements: [], isAuthor: false },
{ attribute: 'aut', refinements: [], isAuthor: true },
{ attribute: 'edt', refinements: [''], isAuthor: true },
])('preserves role precedence for attribute $attribute and refinements $refinements', ({ attribute, refinements, isAuthor }) => {
const xml = epub3Opf(`
<dc:creator xmlns:opf="http://www.idpf.org/2007/opf" id="author" opf:role="${attribute}" opf:file-as="Name, Author">Author Name</dc:creator>
${refinements.map((role) => `<meta refines="#author" property="role">${role}</meta>`).join('\n')}
`);
expect(parseOpf(xml).authors).toEqual(isAuthor ? [{ name: 'Author Name', sortName: 'Name, Author' }] : []);
});
it('keeps roles scoped to their person and emits each creator once in document order', () => {
const xml = epub3Opf(`
<dc:creator id="editor">Editor Name</dc:creator>
<dc:creator id="first">First Author</dc:creator>
<dc:creator id="second">Second Author</dc:creator>
<meta refines="#editor" property="role">edt</meta>
<meta refines="#second" property="role">ill</meta>
<meta refines="#first" property="role">ann</meta>
<meta refines="#second" property="role">aut</meta>
<meta refines="#first" property="role">aut</meta>
<meta refines="#first" property="role">aut</meta>
<meta refines="#first" property="file-as">Author, First</meta>
<meta refines="#first" property="file-as">Unused Sort Name</meta>
`);
expect(parseOpf(xml).authors).toEqual([
{ name: 'First Author', sortName: 'Author, First' },
{ name: 'Second Author', sortName: null },
]);
});
it('accepts EPUB3 role values in full relator URI form', () => {
const xml = epub3Opf(`
<dc:creator id="cr1">Ursula Le Guin</dc:creator>
@@ -229,6 +320,59 @@ describe('parseOpf', () => {
expect(r.authors).toEqual([{ name: 'Neil Gaiman', sortName: null }]);
});
it.each(['creator', 'contributor'])('recognizes a later narrator role on a %s', (element) => {
const xml = epub3Opf(`
<dc:${element} id="person">Narrator Name</dc:${element}>
<meta refines="#person" property="role">ann</meta>
<meta refines="#person" property="role">http://id.loc.gov/vocabulary/relators/nrt</meta>
`);
const r = parseOpf(xml);
expect(r.narrators).toEqual(['Narrator Name']);
expect(r.authors).toEqual([]);
});
it.each([{ roles: ['aut', 'nrt'] }, { roles: ['nrt', 'aut'] }])('records a self-narrating creator in both roles for $roles', ({ roles }) => {
const xml = epub3Opf(`
<dc:creator id="person">Author Narrator</dc:creator>
<meta refines="#person" property="file-as">Narrator, Author</meta>
${roles.map((role) => `<meta refines="#person" property="role">${role}</meta>`).join('\n')}
`);
const r = parseOpf(xml);
expect(r.authors).toEqual([{ name: 'Author Narrator', sortName: 'Narrator, Author' }]);
expect(r.narrators).toEqual(['Author Narrator']);
});
it('does not skip authorship when a multi-role creator repeats a previously collected narrator', () => {
const xml = epub3Opf(`
<dc:creator id="narrator">Author Narrator</dc:creator>
<dc:creator id="author">author narrator</dc:creator>
<meta refines="#narrator" property="role">nrt</meta>
<meta refines="#author" property="role">nrt</meta>
<meta refines="#author" property="role">aut</meta>
<meta refines="#author" property="role">narrator</meta>
`);
const r = parseOpf(xml);
expect(r.authors).toEqual([{ name: 'author narrator', sortName: null }]);
expect(r.narrators).toEqual(['Author Narrator']);
});
it.each([{ roles: [] }, { roles: [''] }, { roles: ['aut'] }, { roles: ['aut', 'nrt'] }])(
'does not promote a contributor with roles $roles to an author',
({ roles }) => {
const xml = epub3Opf(`
<dc:contributor id="person">Contributor Name</dc:contributor>
${roles.map((role) => `<meta refines="#person" property="role">${role}</meta>`).join('\n')}
`);
const r = parseOpf(xml);
expect(r.authors).toEqual([]);
expect(r.narrators).toEqual(roles.includes('nrt') ? ['Contributor Name'] : []);
},
);
it('accepts a narrator role in full relator URI form', () => {
const xml = epub3Opf(`
<dc:contributor id="ct1">Rosamund Pike</dc:contributor>
+17 -8
View File
@@ -279,6 +279,15 @@ function getRefineValue(refines: Map<string, unknown[]>, id: string, property: s
return null;
}
function getRefineValues(refines: Map<string, unknown[]>, id: string, property: string): string[] {
const values: string[] = [];
for (const node of refines.get(id) ?? []) {
const m = node as Record<string, unknown>;
if (m['@_property'] === property) values.push(getText(m));
}
return values;
}
export function parseOpf(xml: string): ParsedOpf {
const root = parser.parse(xml) as Record<string, unknown>;
const pkg = (root['package'] ?? root['opf:package'] ?? {}) as Record<string, unknown>;
@@ -350,21 +359,21 @@ export function parseOpf(xml: string): ParsedOpf {
const id = mo['@_id'] as string | undefined;
// EPUB 3: role via refines
const role3 = id ? getRefineValue(refineMap, id, 'role') : null;
const roles3 = id ? getRefineValues(refineMap, id, 'role') : [];
// EPUB 2: opf:role attribute
const role2 = (mo['@_opf:role'] ?? mo['@_role']) as string | undefined;
const role = normalizeCreatorRole(role3 ?? role2 ?? defaults.role);
const roles = (roles3.length > 0 ? roles3 : [role2 ?? defaults.role]).map(normalizeCreatorRole).filter(Boolean);
if (NARRATOR_ROLES.has(role)) {
if (roles.some((role) => NARRATOR_ROLES.has(role))) {
const key = name.toLowerCase();
if (seenNarrators.has(key)) return;
seenNarrators.add(key);
narrators.push(name);
return;
if (!seenNarrators.has(key)) {
seenNarrators.add(key);
narrators.push(name);
}
}
if (!defaults.allowAuthor) return;
if (role !== 'aut' && role !== '') return; // skip editors, illustrators, etc.
if (roles.length > 0 && !roles.includes('aut')) return; // skip editors, illustrators, etc.
// EPUB 3: file-as via refines; EPUB 2: opf:file-as attribute
const sortName = (id ? getRefineValue(refineMap, id, 'file-as') : null) ?? ((mo['@_opf:file-as'] ?? mo['@_file-as'] ?? null) as string | null);