fix(metadata): add poppler pdfinfo fallback for pdf metadata parsing

Some PDF files contain malformed metadata that pdf-lib cannot correctly
decode, despite being readable by external tools. This introduces a
fallback to extract metadata using Poppler's pdfinfo utility, and improves
XMP keyword parsing as a fallback for missing tags.

Closes #491
This commit is contained in:
neon
2026-07-02 18:15:55 +00:00
parent f0374f01c3
commit c63ac6cc6c
6 changed files with 308 additions and 19 deletions
@@ -18,6 +18,10 @@ vi.mock('./pdf-cover', () => ({
extractPdfCover: vi.fn(),
}));
vi.mock('./pdf-poppler-metadata', () => ({
extractPopplerPdfMetadata: vi.fn(),
}));
vi.mock('./pdf-parse-worker-runner', () => ({
parsePdfFileInWorker: vi.fn(),
}));
@@ -27,6 +31,7 @@ import type { MockedFunction } from 'vitest';
import { PDFDocument } from 'pdf-lib';
import { extractPdfCover } from './pdf-cover';
import { extractPopplerPdfMetadata } from './pdf-poppler-metadata';
import { parsePdfFile, PDF_BUFFER_WARNING_BYTES, type PdfParsed } from './pdf-parser';
import { parsePdfFileInWorker } from './pdf-parse-worker-runner';
import { extractXmpXml, parseXmp } from './pdf-xmp-reader';
@@ -37,6 +42,7 @@ const mockPdfLoad = PDFDocument.load as MockedFunction<typeof PDFDocument.load>;
const mockExtractXmpXml = extractXmpXml as MockedFunction<typeof extractXmpXml>;
const mockParseXmp = parseXmp as MockedFunction<typeof parseXmp>;
const mockExtractPdfCover = extractPdfCover as MockedFunction<typeof extractPdfCover>;
const mockExtractPopplerPdfMetadata = extractPopplerPdfMetadata as MockedFunction<typeof extractPopplerPdfMetadata>;
const mockParsePdfFileInWorker = parsePdfFileInWorker as MockedFunction<typeof parsePdfFileInWorker>;
function makePdfDoc(overrides: Partial<Record<string, unknown>> = {}) {
@@ -95,6 +101,7 @@ describe('parsePdfFile', () => {
mockExtractXmpXml.mockReturnValue(null);
mockParseXmp.mockReturnValue(null);
mockExtractPdfCover.mockResolvedValue(Buffer.from([0xff, 0xd8, 0xff]));
mockExtractPopplerPdfMetadata.mockResolvedValue(null);
mockParsePdfFileInWorker.mockResolvedValue({ parsed: makeParsed(), warnings: [] });
});
@@ -159,6 +166,135 @@ describe('parsePdfFile', () => {
);
});
it('uses Poppler XMP metadata for encrypted PDFs instead of encrypted pdf-lib strings', async () => {
mockPdfLoad.mockResolvedValue(
makePdfDoc({
isEncrypted: true,
getTitle: () => 'hfá\u0012G²\u0017Ã$ÎŁe',
getAuthor: () => 'hfá\u0012G²',
getKeywords: () => 'ihè\u00171Wü"',
}) as never,
);
mockExtractPopplerPdfMetadata.mockResolvedValue({
title: 'Lonely Planet - Southern Italy',
author: 'Lonely Planet',
subject: null,
keywords: 'Magazine',
creator: 'Adobe InDesign CS6 (Windows)',
producer: 'Adobe PDF Library 10.0.1',
pageCount: 299,
xmpXml: '<xmp/>',
});
mockParseXmp.mockReturnValue({
title: 'Lonely Planet - Southern Italy',
subtitle: null,
description: null,
publisher: null,
publishedYear: 2015,
language: 'en',
authors: [{ name: 'Lonely Planet', sortName: null }],
genres: [],
tags: ['Magazine'],
isbn10: null,
isbn13: null,
seriesName: null,
seriesIndex: null,
rating: null,
pageCount: null,
googleBooksId: null,
goodreadsId: null,
amazonId: null,
hardcoverId: null,
hardcoverEditionId: null,
openLibraryId: null,
ranobedbId: null,
itunesId: null,
});
const parsed = await parsePdfFile('/books/southern-italy.pdf');
expect(mockExtractPopplerPdfMetadata).toHaveBeenCalledWith('/books/southern-italy.pdf');
expect(mockExtractXmpXml).not.toHaveBeenCalled();
expect(parsed).toEqual(
expect.objectContaining({
title: 'Lonely Planet - Southern Italy',
authors: [{ name: 'Lonely Planet', sortName: null }],
tags: ['Magazine'],
publishedYear: 2015,
language: 'en',
pageCount: 299,
}),
);
});
it('uses Poppler Info metadata for encrypted PDFs when decrypted XMP is absent', async () => {
mockPdfLoad.mockResolvedValue(
makePdfDoc({
isEncrypted: true,
getTitle: () => 'o\u0001ÍžÐ_<ÐK',
getAuthor: () => 'o\u0001ÍžÐ',
getKeywords: () => 'n\u000fÄıÆOrå',
}) as never,
);
mockExtractPopplerPdfMetadata.mockResolvedValue({
title: 'Lonely Planet - Greece',
author: 'Lonely Planet',
subject: 'Travel guide',
keywords: 'Magazine',
creator: 'Adobe InDesign CS6 (Windows)',
producer: 'Adobe PDF Library 10.0.1',
pageCount: 755,
xmpXml: null,
});
const parsed = await parsePdfFile('/books/greece.pdf');
expect(mockExtractXmpXml).not.toHaveBeenCalled();
expect(parsed).toEqual(
expect.objectContaining({
title: 'Lonely Planet - Greece',
authors: [{ name: 'Lonely Planet', sortName: null }],
description: 'Travel guide',
tags: ['Magazine'],
pageCount: 755,
}),
);
});
it('does not persist encrypted pdf-lib metadata when the encrypted fallback fails', async () => {
mockPdfLoad.mockResolvedValue(
makePdfDoc({
isEncrypted: true,
getTitle: () => 'hfá\u0012G²\u0017Ã$ÎŁe',
getAuthor: () => 'hfá\u0012G²',
getKeywords: () => 'ihè\u00171Wü"',
}) as never,
);
mockExtractPopplerPdfMetadata.mockRejectedValue(new Error('pdfinfo missing'));
const onWarning = vi.fn();
const parsed = await parsePdfFile('/books/encrypted.pdf', { onWarning });
expect(mockExtractXmpXml).not.toHaveBeenCalled();
expect(parsed).toEqual(
expect.objectContaining({
title: null,
authors: [],
description: null,
tags: [],
pageCount: 123,
}),
);
expect(onWarning).toHaveBeenCalledWith(
expect.objectContaining({
code: 'encrypted-metadata-fallback-failed',
absolutePath: '/books/encrypted.pdf',
errorClass: 'Error',
errorMessage: 'pdfinfo missing',
}),
);
});
it('skips cover extraction by default for metadata-only callers', async () => {
const parsed = await parsePdfFile('/books/book.pdf');
+34 -18
View File
@@ -5,6 +5,7 @@ import { BOOKORBIT_NS_PREFIX } from '../../../common/bookorbit-ns';
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
import { parsePdfFileInWorker } from './pdf-parse-worker-runner';
import { extractPdfCover } from './pdf-cover';
import { extractPopplerPdfMetadata, type PopplerPdfMetadata } from './pdf-poppler-metadata';
import { extractXmpXml, parseXmp, type XmpParsed } from './pdf-xmp-reader';
export interface PdfParsed {
@@ -38,7 +39,7 @@ export interface PdfParsed {
}
export interface PdfParseWarning {
code: 'buffered-large-pdf' | 'cover-extraction-failed' | 'parse-failed';
code: 'buffered-large-pdf' | 'cover-extraction-failed' | 'encrypted-metadata-fallback-failed' | 'parse-failed';
absolutePath: string;
errorClass?: string;
errorMessage?: string;
@@ -67,6 +68,15 @@ function splitCommaList(value: string | null): string[] {
.filter(Boolean);
}
function splitAuthorList(value: string | null): { name: string; sortName: string | null }[] {
if (!value) return [];
return value
.split(/[,;]/)
.map((s) => s.trim())
.filter(Boolean)
.map((name) => ({ name, sortName: null }));
}
function createWarning(code: PdfParseWarning['code'], absolutePath: string, error: unknown): PdfParseWarning {
const errorClass = error instanceof Error ? error.name : 'Error';
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : String(error));
@@ -106,28 +116,32 @@ export async function parsePdfFile(absolutePath: string, options: PdfParseOption
}
export async function parsePdfBuffer(absolutePath: string, buf: Buffer, options: PdfParseOptions = {}): Promise<PdfParsed | null> {
const doc = await PDFDocument.load(buf, { ignoreEncryption: true });
const doc = await PDFDocument.load(buf, { ignoreEncryption: true, updateMetadata: false });
const isEncrypted = doc.isEncrypted === true;
let popplerMetadata: PopplerPdfMetadata | null = null;
if (isEncrypted) {
try {
popplerMetadata = await extractPopplerPdfMetadata(absolutePath);
} catch (error) {
options.onWarning?.(createWarning('encrypted-metadata-fallback-failed', absolutePath, error));
}
}
// XMP is the authoritative source; Info Dictionary is fallback-only.
const xmpXml = extractXmpXml(doc);
const xmpXml = popplerMetadata?.xmpXml ?? (isEncrypted ? null : extractXmpXml(doc));
const xmp: XmpParsed | null = xmpXml ? parseXmp(xmpXml) : null;
const hasXmp = xmp !== null;
const infoTitle = clean(doc.getTitle());
const infoAuthorRaw = clean(doc.getAuthor());
const infoCreator = clean(doc.getCreator());
const infoProducer = clean(doc.getProducer());
const infoSubject = clean(doc.getSubject());
const infoKeywords = splitCommaList(clean(doc.getKeywords()));
const canTrustPdfLibInfo = !isEncrypted || popplerMetadata !== null;
const infoTitle = popplerMetadata ? popplerMetadata.title : canTrustPdfLibInfo ? clean(doc.getTitle()) : null;
const infoAuthorRaw = popplerMetadata ? popplerMetadata.author : canTrustPdfLibInfo ? clean(doc.getAuthor()) : null;
const infoCreator = popplerMetadata ? popplerMetadata.creator : canTrustPdfLibInfo ? clean(doc.getCreator()) : null;
const infoProducer = popplerMetadata ? popplerMetadata.producer : canTrustPdfLibInfo ? clean(doc.getProducer()) : null;
const infoSubject = popplerMetadata ? popplerMetadata.subject : canTrustPdfLibInfo ? clean(doc.getSubject()) : null;
const infoKeywords = splitCommaList(popplerMetadata ? popplerMetadata.keywords : canTrustPdfLibInfo ? clean(doc.getKeywords()) : null);
const isBookorbitInfo = infoCreator === BOOKORBIT_NS_PREFIX;
const infoAuthors = infoAuthorRaw
? infoAuthorRaw
.split(/[,;]/)
.map((s) => s.trim())
.filter(Boolean)
.map((name) => ({ name, sortName: null }))
: [];
const infoAuthors = splitAuthorList(infoAuthorRaw);
let coverBuffer: Buffer | null = null;
if (options.extractCover === true) {
@@ -154,7 +168,9 @@ export async function parsePdfBuffer(absolutePath: string, buf: Buffer, options:
seriesName: xmp?.seriesName ?? null,
seriesIndex: xmp?.seriesIndex ?? null,
rating: xmp?.rating ?? null,
pageCount: hasXmp ? (xmp.pageCount ?? (isBookorbitInfo ? null : doc.getPageCount())) : doc.getPageCount(),
pageCount: hasXmp
? (xmp.pageCount ?? (isBookorbitInfo ? null : (popplerMetadata?.pageCount ?? doc.getPageCount())))
: (popplerMetadata?.pageCount ?? doc.getPageCount()),
googleBooksId: xmp?.googleBooksId ?? null,
goodreadsId: xmp?.goodreadsId ?? null,
amazonId: xmp?.amazonId ?? null,
@@ -0,0 +1,43 @@
import { parsePdfInfoOutput } from './pdf-poppler-metadata';
describe('parsePdfInfoOutput', () => {
it('extracts metadata fields from pdfinfo output', () => {
const parsed = parsePdfInfoOutput(`
Title: Lonely Planet - Southern Italy
Keywords: Magazine, Travel
Author: Lonely Planet
Subject: Travel guide
Creator: Adobe InDesign CS6 (Windows)
Producer: Adobe PDF Library 10.0.1
Pages: 299
Encrypted: yes (print:yes copy:no change:no addNotes:yes algorithm:RC4)
`);
expect(parsed).toEqual({
title: 'Lonely Planet - Southern Italy',
author: 'Lonely Planet',
subject: 'Travel guide',
keywords: 'Magazine, Travel',
creator: 'Adobe InDesign CS6 (Windows)',
producer: 'Adobe PDF Library 10.0.1',
pageCount: 299,
});
});
it('returns nulls for missing or blank fields', () => {
const parsed = parsePdfInfoOutput(`
Title:
Pages: unknown
`);
expect(parsed).toEqual({
title: null,
author: null,
subject: null,
keywords: null,
creator: null,
producer: null,
pageCount: null,
});
});
});
@@ -0,0 +1,71 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
const PDFINFO_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
export interface PopplerPdfMetadata {
title: string | null;
author: string | null;
subject: string | null;
keywords: string | null;
creator: string | null;
producer: string | null;
pageCount: number | null;
xmpXml: string | null;
}
function clean(value: string | undefined): string | null {
if (!value) return null;
const s = value.trim();
return s.length > 0 ? s : null;
}
export function parsePdfInfoOutput(output: string): Omit<PopplerPdfMetadata, 'xmpXml'> {
const fields = new Map<string, string>();
for (const line of output.split(/\r?\n/)) {
const match = line.match(/^([^:]+):\s*(.*)$/);
if (!match) continue;
fields.set(match[1].trim().toLowerCase(), match[2].trim());
}
const pageCountRaw = clean(fields.get('pages'));
const pageCount = pageCountRaw ? Number.parseInt(pageCountRaw, 10) : Number.NaN;
return {
title: clean(fields.get('title')),
author: clean(fields.get('author')),
subject: clean(fields.get('subject')),
keywords: clean(fields.get('keywords')),
creator: clean(fields.get('creator')),
producer: clean(fields.get('producer')),
pageCount: Number.isFinite(pageCount) ? pageCount : null,
};
}
function cleanXmp(value: string | null): string | null {
if (!value) return null;
const trimmed = value.trim();
return trimmed.includes('<rdf:RDF') || trimmed.includes('<x:xmpmeta') ? trimmed : null;
}
async function runPdfInfo(args: string[]): Promise<string> {
const { stdout } = await execFileAsync('pdfinfo', args, { maxBuffer: PDFINFO_MAX_BUFFER_BYTES });
return stdout;
}
export async function extractPopplerPdfMetadata(absolutePath: string): Promise<PopplerPdfMetadata | null> {
const [infoResult, xmpResult] = await Promise.allSettled([runPdfInfo([absolutePath]), runPdfInfo(['-meta', absolutePath])]);
if (infoResult.status === 'rejected' && xmpResult.status === 'rejected') {
throw infoResult.reason instanceof Error ? infoResult.reason : new Error(String(infoResult.reason));
}
const info = infoResult.status === 'fulfilled' ? parsePdfInfoOutput(infoResult.value) : parsePdfInfoOutput('');
const xmpXml = xmpResult.status === 'fulfilled' ? cleanXmp(xmpResult.value) : null;
return {
...info,
xmpXml,
};
}
@@ -163,6 +163,19 @@ describe('parseXmp', () => {
expect(r?.tags).toEqual(['favorites', 'to-read']);
});
it('parses pdf:Keywords as tags when bookorbit tags are absent', () => {
const xml = `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
<pdf:Keywords>Magazine, Travel</pdf:Keywords>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>`;
const r = parseXmp(xml);
expect(r?.tags).toEqual(['Magazine', 'Travel']);
});
it('parses bookorbit:rating', () => {
const r = parseXmp(xmpDoc('<bookorbit:rating>4.5</bookorbit:rating>'));
expect(r?.rating).toBe(4.5);
@@ -101,6 +101,15 @@ function str(val: unknown): string | null {
return t ?? null;
}
function splitListText(val: unknown): string[] {
const t = getText(val);
if (!t) return [];
return t
.split(/[,;]/)
.map((s) => s.trim())
.filter(Boolean);
}
function num(val: unknown): number | null {
const t = getText(val);
if (!t) return null;
@@ -138,6 +147,7 @@ export function parseXmp(xmpXml: string): XmpParsed | null {
}
const px = BOOKORBIT_NS_PREFIX;
const tags = getList(merged[`${px}:tags`]);
return {
title: str(merged['dc:title']),
@@ -148,7 +158,7 @@ export function parseXmp(xmpXml: string): XmpParsed | null {
language: str(merged['dc:language']),
authors: getList(merged['dc:creator']).map((name) => ({ name, sortName: null })),
genres: getList(merged['dc:subject']),
tags: getList(merged[`${px}:tags`]),
tags: tags.length ? tags : splitListText(merged['pdf:Keywords']),
isbn10: str(merged[`${px}:isbn10`]),
isbn13: str(merged[`${px}:isbn13`]),
seriesName: str(merged[`${px}:seriesName`]),