mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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');
|
||||
|
||||
|
||||
@@ -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`]),
|
||||
|
||||
Reference in New Issue
Block a user