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:
@@ -1221,6 +1221,31 @@ func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For PDFs: fill blanks from the embedded Info dictionary. Same rule as
|
||||||
|
// EPUBs - sidecar values win, the file only fills gaps.
|
||||||
|
if ext == ".pdf" {
|
||||||
|
if embedded, err := s.readPDFInfoDict(path); err == nil && embedded != nil {
|
||||||
|
if metadata.Title == "" && embedded.Title != "" {
|
||||||
|
metadata.Title = embedded.Title
|
||||||
|
}
|
||||||
|
if metadata.Author == "" && embedded.Author != "" {
|
||||||
|
metadata.Author = embedded.Author
|
||||||
|
}
|
||||||
|
if metadata.Description == "" && embedded.Description != "" {
|
||||||
|
metadata.Description = embedded.Description
|
||||||
|
}
|
||||||
|
if metadata.Publisher == "" && embedded.Publisher != "" {
|
||||||
|
metadata.Publisher = embedded.Publisher
|
||||||
|
}
|
||||||
|
if len(metadata.Tags) == 0 && len(embedded.Tags) > 0 {
|
||||||
|
metadata.Tags = embedded.Tags
|
||||||
|
}
|
||||||
|
if metadata.PageCount == 0 && embedded.PageCount > 0 {
|
||||||
|
metadata.PageCount = embedded.PageCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// For comic archives, try to extract ComicInfo.xml
|
// For comic archives, try to extract ComicInfo.xml
|
||||||
if ext == ".cbz" || ext == ".cbr" || ext == ".cb7" || ext == ".cbt" {
|
if ext == ".cbz" || ext == ".cbr" || ext == ".cb7" || ext == ".cbt" {
|
||||||
comicInfo, cover, err := extractComicMetadata(path)
|
comicInfo, cover, err := extractComicMetadata(path)
|
||||||
@@ -2268,6 +2293,51 @@ func findSidecarCover(mediaPath string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readPDFInfoDict reads a PDF's embedded Info dictionary into MediaMetadata,
|
||||||
|
// using the same field conventions as extractPDFMetadata (creator falls back
|
||||||
|
// to author, subject maps to description, producer to publisher, keywords to
|
||||||
|
// tags). No cover or filename logic - purely the document's own metadata, for
|
||||||
|
// filling sidecar gaps in mergeMetadata.
|
||||||
|
func (s *MediaScanner) readPDFInfoDict(path string) (*MediaMetadata, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to close PDF file %s: %v\n", path, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
pdfInfo, err := pdfcpuapi.PDFInfo(f, filepath.Base(path), nil, false, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := &MediaMetadata{}
|
||||||
|
if pdfInfo.Title != "" {
|
||||||
|
metadata.Title = pdfInfo.Title
|
||||||
|
}
|
||||||
|
if pdfInfo.Author != "" {
|
||||||
|
metadata.Author = pdfInfo.Author
|
||||||
|
} else if pdfInfo.Creator != "" {
|
||||||
|
metadata.Author = pdfInfo.Creator
|
||||||
|
}
|
||||||
|
if pdfInfo.Subject != "" {
|
||||||
|
metadata.Description = pdfInfo.Subject
|
||||||
|
}
|
||||||
|
if pdfInfo.Producer != "" {
|
||||||
|
metadata.Publisher = pdfInfo.Producer
|
||||||
|
}
|
||||||
|
if len(pdfInfo.Keywords) > 0 {
|
||||||
|
metadata.Tags = utils.NormalizeTags(pdfInfo.Keywords)
|
||||||
|
}
|
||||||
|
if pdfInfo.PageCount > 0 {
|
||||||
|
metadata.PageCount = int32(pdfInfo.PageCount)
|
||||||
|
}
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MediaScanner) extractPDFMetadata(path string) (*MediaMetadata, error) {
|
func (s *MediaScanner) extractPDFMetadata(path string) (*MediaMetadata, error) {
|
||||||
metadata := &MediaMetadata{}
|
metadata := &MediaMetadata{}
|
||||||
|
|
||||||
|
|||||||
@@ -262,13 +262,18 @@ func createTestPDFWithImage() []byte {
|
|||||||
writeObj(5, func(w *bytes.Buffer) {
|
writeObj(5, func(w *bytes.Buffer) {
|
||||||
fmt.Fprintf(w, "<< /Length %d >>\nstream\n%s\nendstream\n", len(content), content)
|
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()
|
xrefStart := buf.Len()
|
||||||
fmt.Fprintf(&buf, "xref\n0 %d\n0000000000 65535 f \n", len(offsets))
|
fmt.Fprintf(&buf, "xref\n0 %d\n0000000000 65535 f \n", len(offsets))
|
||||||
for _, off := range offsets[1:] {
|
for _, off := range offsets[1:] {
|
||||||
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
|
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()
|
return buf.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,3 +368,43 @@ func TestMergeMetadataEPUBGapFill(t *testing.T) {
|
|||||||
t.Errorf("Language = %q, want embedded fill", merged.Language)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user