feat(scanner): fill sparse sidecars from the PDF Info dictionary

Same gap-fill rule as the EPUB path, against the PDF's embedded Info
dictionary via a readPDFInfoDict helper reusing extractPDFMetadata's
field conventions (creator falls back to author, subject maps to
description, producer to publisher, keywords to tags, plus page count).
Sidecar values always win; unopenable PDFs skip silently.

TestMergeMetadataPDFGapFill uses an Info-bearing hand-built PDF fixture
(shared with the sidecar-cover test) and asserts the sidecar title is
kept while author/description/publisher/tags/page count fill in.
This commit is contained in:
John O'Keefe
2026-09-13 13:06:48 -04:00
parent 708598687e
commit e944f11415
2 changed files with 116 additions and 1 deletions
@@ -262,13 +262,18 @@ func createTestPDFWithImage() []byte {
writeObj(5, func(w *bytes.Buffer) {
fmt.Fprintf(w, "<< /Length %d >>\nstream\n%s\nendstream\n", len(content), content)
})
writeObj(6, func(w *bytes.Buffer) {
// Info dictionary deliberately richer than the sidecars in gap-fill
// tests: gap-fill must use these, never overwrite with them.
w.WriteString("<< /Title (Embedded Title) /Author (Embedded Author) /Subject (Embedded Subject) /Producer (Embedded Producer) /Keywords (embedded-kw) >>\n")
})
xrefStart := buf.Len()
fmt.Fprintf(&buf, "xref\n0 %d\n0000000000 65535 f \n", len(offsets))
for _, off := range offsets[1:] {
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
}
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xrefStart)
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R /Info 6 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xrefStart)
return buf.Bytes()
}
@@ -363,3 +368,43 @@ func TestMergeMetadataEPUBGapFill(t *testing.T) {
t.Errorf("Language = %q, want embedded fill", merged.Language)
}
}
// TestMergeMetadataPDFGapFill verifies the same for PDFs: a sparse sidecar
// keeps its title while author/description/publisher/tags/pagecount fill in
// from the embedded Info dictionary.
func TestMergeMetadataPDFGapFill(t *testing.T) {
dir := t.TempDir()
pdfPath := filepath.Join(dir, "gappy.pdf")
if err := os.WriteFile(pdfPath, createTestPDFWithImage(), 0644); err != nil {
t.Fatal(err)
}
sparse := &MediaMetadata{Title: "Kept Title"}
s := NewMediaScanner(nil)
merged, err := s.mergeMetadata(pdfPath, sparse)
if err != nil {
t.Fatalf("mergeMetadata() error: %v", err)
}
checks := []struct {
name string
got string
want string
}{
{"Title (sidecar wins)", merged.Title, "Kept Title"},
{"Author", merged.Author, "Embedded Author"},
{"Description", merged.Description, "Embedded Subject"},
{"Publisher", merged.Publisher, "Embedded Producer"},
}
for _, c := range checks {
if c.got != c.want {
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
}
}
if len(merged.Tags) == 0 {
t.Error("Tags empty, want keywords from Info dict")
}
if merged.PageCount != 1 {
t.Errorf("PageCount = %d, want 1 from Info dict", merged.PageCount)
}
}