diff --git a/attach.go b/attach.go new file mode 100644 index 0000000..5c0a86e --- /dev/null +++ b/attach.go @@ -0,0 +1,226 @@ +// Copyright (c) 2026, the go-pdfkit/ops authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package ops + +import ( + "fmt" + "sort" + + "github.com/go-pdfkit/reader" +) + +// An Attachment is a file travelling inside the document. +type Attachment struct { + // Name is what the document calls it. + Name string + // Description is what it says the file is, when it says anything. + Description string + // Data is the file. + Data []byte +} + +// Attachments are the files the document carries, in the order it names them. +// +// A PDF can hold whole files inside it — the spreadsheet a report was drawn +// from, the XML a form was filled from, the source of a figure. They are not +// drawn and nothing on the page says they are there, which is why a tool that +// rewrites a document can drop them without anyone noticing until the file is +// wanted. +func (d *Doc) Attachments() []Attachment { + out := append([]Attachment(nil), d.attached...) + // Every document the pages came from, not just the one. A merged document + // has several sources and each may carry files; unlike a form or a + // catalogue, two sets of files cannot conflict, because a file belongs to + // no page. + seen := map[*reader.Document]bool{} + for _, p := range d.pages { + if p.src == nil || seen[p.src] { + continue + } + seen[p.src] = true + out = append(out, readAttachments(p.src)...) + } + return out +} + +// Attach puts a file inside the document, under a name. +// +// The name is what a reader shows and what another tool looks it up by. Two +// files under one name is a document that has lost one of them, so a name +// already used is refused rather than quietly replacing what is there. +func (d *Doc) Attach(name string, data []byte, description string) error { + if name == "" { + return fmt.Errorf("ops: a file inside a document needs a name") + } + for _, a := range d.Attachments() { + if a.Name == name { + return fmt.Errorf("ops: this document already carries a file called %q", name) + } + } + d.attached = append(d.attached, Attachment{Name: name, Description: description, Data: data}) + return nil +} + +// Detach removes the file of that name, and says whether there was one. +func (d *Doc) Detach(name string) bool { + for i, a := range d.attached { + if a.Name == name { + d.attached = append(d.attached[:i], d.attached[i+1:]...) + return true + } + } + // A file that came in with the document is dropped by being left out of + // what is written, which is what dropped is set for. + for _, a := range d.Attachments() { + if a.Name == name { + d.dropped = append(d.dropped, name) + return true + } + } + return false +} + +// readAttachments walks the /EmbeddedFiles name tree of a source document. +func readAttachments(src *reader.Document) []Attachment { + // A document that opened has a catalogue; one that somehow has none simply + // has nothing in it to look under, which the next line asks anyway. + cat, _ := src.Catalog() + names, ok := src.GetDict(cat, "Names") + if !ok { + return nil + } + tree, ok := src.GetDict(names, "EmbeddedFiles") + if !ok { + return nil + } + var out []Attachment + walkNameTree(src, tree, 0, func(name string, value reader.Object) { + spec, ok := reader.ToDict(resolve(src, value)) + if !ok { + return + } + a := Attachment{Name: name} + if s, ok := reader.ToString(resolve(src, spec.Get("Desc"))); ok { + a.Description = string(s) + } + ef, ok := reader.ToDict(resolve(src, spec.Get("EF"))) + if !ok { + return + } + // /F is the usual place; /UF is its Unicode twin and holds the same + // stream when both are there. + read := false + for _, key := range []reader.Name{"F", "UF"} { + st, ok := reader.ToStream(resolve(src, ef.Get(key))) + if !ok { + continue + } + data, filter, err := reader.DecodeStream(st, src.Get) + if err != nil { + continue + } + // A chain that stopped at a filter nothing here reads hands back + // the bytes as they were STORED, and names what they are still in. + // Handing those over as the file gives somebody a spreadsheet that + // is not one, which is worse than saying there is no file. + if filter != "" { + continue + } + a.Data, read = data, true + break + } + // A specification pointing at nothing that can be read is not a file. + // Handing it over with no bytes says the document carries something it + // does not, and whoever asked would save an empty file believing it. + if !read { + return + } + out = append(out, a) + }) + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// walkNameTree visits the entries of a PDF name tree, which is a sorted map +// spread over a tree of nodes so that a reader can find a name without reading +// all of them. +func walkNameTree(src *reader.Document, node reader.Dict, depth int, visit func(string, reader.Object)) { + if depth > 16 { + // A tree that holds itself is a tree with no leaves, and following it + // is a way of never coming back. + return + } + if arr, ok := reader.ToArray(resolve(src, node.Get("Names"))); ok { + for i := 0; i+1 < len(arr); i += 2 { + key, ok := reader.ToString(resolve(src, arr[i])) + if !ok { + continue + } + visit(string(key), arr[i+1]) + } + } + if kids, ok := reader.ToArray(resolve(src, node.Get("Kids"))); ok { + for _, k := range kids { + if kd, ok := reader.ToDict(resolve(src, k)); ok { + walkNameTree(src, kd, depth+1, visit) + } + } + } +} + +// writeAttachments builds the /Names /EmbeddedFiles tree the catalogue points +// at, and returns nil when there is nothing to point at. +// +// The catalogue's /Names used to be dropped whole, on the ground that a name +// tree points into the document rather than describing it. That is true of +// /Dests, whose names point at pages this may have reordered or removed, and +// of /JavaScript, which a sanitised file has no business keeping. It is not +// true of /EmbeddedFiles: a file inside a document is not attached to any page, +// so it survives every verb here — and was being thrown away by all of them. +// +// 45 of the 3 215 documents in the forms and scans corpora carry one, 50 files +// between them. Nothing on the page says they are there, so nobody notices +// until the file is wanted. +func (d *Doc) writeAttachments(w *reader.Writer) reader.Object { + if d.sanitize { + // A sanitised file leaves behind what runs and what travels: a file + // inside a document is the thing this is for. + return nil + } + all := d.Attachments() + dropped := map[string]bool{} + for _, n := range d.dropped { + dropped[n] = true + } + var names reader.Array + for _, a := range all { + if dropped[a.Name] { + continue + } + stream := w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("EmbeddedFile"), + "Params": reader.Dict{ + "Size": reader.Integer(len(a.Data)), + }, + }, Raw: a.Data}) + spec := reader.Dict{ + "Type": reader.Name("Filespec"), + "F": reader.String(a.Name), + "UF": reader.String(a.Name), + "EF": reader.Dict{"F": stream, "UF": stream}, + } + if a.Description != "" { + spec["Desc"] = reader.String(a.Description) + } + names = append(names, reader.String(a.Name), w.Add(spec)) + } + if len(names) == 0 { + return nil + } + // One leaf, sorted: a name tree may be a single node, and the entries have + // to be in order for a reader that searches it rather than reading it all. + return w.Add(reader.Dict{"Names": names}) +} diff --git a/attach_test.go b/attach_test.go new file mode 100644 index 0000000..7752774 --- /dev/null +++ b/attach_test.go @@ -0,0 +1,343 @@ +// Copyright (c) 2026, the go-pdfkit/ops authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package ops + +import ( + "strings" + "testing" + + "github.com/go-pdfkit/reader" +) + +// withFile builds a document carrying one file inside it, the way a real one +// does: a name tree under the catalogue, pointing at a file specification, +// pointing at a stream. +func withFile(t *testing.T, name, desc, body string) []byte { + t.Helper() + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + page := w.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte("page 1")})}) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), "Kids": reader.Array{page}, + "Count": reader.Integer(1), + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), reader.Integer(100), reader.Integer(200)}}) + stream := w.Add(&reader.Stream{Dict: reader.Dict{"Type": reader.Name("EmbeddedFile")}, + Raw: []byte(body)}) + spec := reader.Dict{"Type": reader.Name("Filespec"), "F": reader.String(name), + "EF": reader.Dict{"F": stream}} + if desc != "" { + spec["Desc"] = reader.String(desc) + } + names := w.Add(reader.Dict{"Names": reader.Array{reader.String(name), w.Add(spec)}}) + root := w.Add(reader.Dict{"Type": reader.Name("Catalog"), "Pages": pagesRef, + "Names": w.Add(reader.Dict{"EmbeddedFiles": names})}) + out, err := w.Finish(reader.Dict{"Root": root}) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestAFileInsideADocumentSurvivesAVerb(t *testing.T) { + // Nothing on the page says it is there, so nobody notices it went until + // the file is wanted. 45 of the 3 215 documents in the forms and scans + // corpora carry one. + d, err := Open(withFile(t, "source.csv", "where the figures came from", "a,b\n1,2\n")) + if err != nil { + t.Fatal(err) + } + if err := d.Rotate("1", 90); err != nil { + t.Fatal(err) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + back, err := Open(out) + if err != nil { + t.Fatal(err) + } + got := back.Attachments() + if len(got) != 1 { + t.Fatalf("%d files came through a rotation, want 1", len(got)) + } + if got[0].Name != "source.csv" || string(got[0].Data) != "a,b\n1,2\n" { + t.Errorf("got %+v", got[0]) + } + if got[0].Description != "where the figures came from" { + t.Errorf("the description went: %q", got[0].Description) + } +} + +func TestAFileIsPutIn(t *testing.T) { + d := New() + d.Blank(100, 200) + if err := d.Attach("notes.txt", []byte("hello"), "a note"); err != nil { + t.Fatal(err) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + back, err := Open(out) + if err != nil { + t.Fatal(err) + } + got := back.Attachments() + if len(got) != 1 || got[0].Name != "notes.txt" || string(got[0].Data) != "hello" { + t.Fatalf("got %+v", got) + } +} + +func TestTwoFilesUnderOneName(t *testing.T) { + // A document that has lost one of them. Refusing beats replacing quietly. + d := New() + d.Blank(100, 200) + if err := d.Attach("a.txt", []byte("one"), ""); err != nil { + t.Fatal(err) + } + err := d.Attach("a.txt", []byte("two"), "") + if err == nil { + t.Fatal("the second one went in") + } + if !strings.Contains(err.Error(), "already carries") { + t.Errorf("it said %q", err) + } + if err := d.Attach("", []byte("x"), ""); err == nil { + t.Error("a file with no name went in") + } +} + +func TestAFileIsTakenOut(t *testing.T) { + for _, tc := range []struct { + name string + build func(t *testing.T) *Doc + }{ + {"one that was put in here", func(t *testing.T) *Doc { + d := New() + d.Blank(100, 200) + d.Attach("gone.txt", []byte("x"), "") + return d + }}, + {"one the document came in with", func(t *testing.T) *Doc { + d, err := Open(withFile(t, "gone.txt", "", "x")) + if err != nil { + t.Fatal(err) + } + return d + }}, + } { + t.Run(tc.name, func(t *testing.T) { + d := tc.build(t) + if !d.Detach("gone.txt") { + t.Fatal("it said there was no such file") + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + back, err := Open(out) + if err != nil { + t.Fatal(err) + } + if got := back.Attachments(); len(got) != 0 { + t.Errorf("%d files still there: %+v", len(got), got) + } + }) + } +} + +func TestTakingOutWhatIsNotThere(t *testing.T) { + d := New() + d.Blank(100, 200) + if d.Detach("never.txt") { + t.Error("it said it removed a file that was not there") + } +} + +func TestASanitisedFileCarriesNothingInside(t *testing.T) { + // A sanitised file leaves behind what runs and what travels. A file inside + // a document is the thing that is meant. + d, err := Open(withFile(t, "source.csv", "", "a,b\n")) + if err != nil { + t.Fatal(err) + } + d.Sanitize() + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + back, err := Open(out) + if err != nil { + t.Fatal(err) + } + if got := back.Attachments(); len(got) != 0 { + t.Errorf("a sanitised file still carries %+v", got) + } +} + +func TestFilesFromSeveralDocumentsAreAllCarried(t *testing.T) { + // Two forms cannot be merged and two catalogues cannot be chosen between, + // but two sets of files can simply both be carried: a file belongs to no + // page, so it cannot be in conflict with anything. + a, err := Open(withFile(t, "one.txt", "", "1")) + if err != nil { + t.Fatal(err) + } + b, err := Open(withFile(t, "two.txt", "", "2")) + if err != nil { + t.Fatal(err) + } + joined := Merge(a, b) + out, err := joined.Bytes() + if err != nil { + t.Fatal(err) + } + back, err := Open(out) + if err != nil { + t.Fatal(err) + } + got := back.Attachments() + if len(got) != 2 { + t.Fatalf("%d files came through a merge, want 2: %+v", len(got), got) + } +} + +func TestANameTreeAsFilesActuallyComeIn(t *testing.T) { + // A name tree is a sorted map spread over a tree of nodes, so that a + // reader can find a name without reading all of them. A document written + // by another tool nests them, and one written badly puts things in that + // are not file specifications at all. + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + page := w.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte("page 1")})}) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), "Kids": reader.Array{page}, + "Count": reader.Integer(1), + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), reader.Integer(100), reader.Integer(200)}}) + + good := w.Add(reader.Dict{"Type": reader.Name("Filespec"), "F": reader.String("good.txt"), + "EF": reader.Dict{"F": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte("kept")})}}) + // Only /UF, which is the Unicode twin and holds the same stream when both + // are there — and sometimes the only one. + unicodeOnly := w.Add(reader.Dict{"Type": reader.Name("Filespec"), "F": reader.String("uf.txt"), + "EF": reader.Dict{"UF": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte("also kept")})}}) + noEF := w.Add(reader.Dict{"Type": reader.Name("Filespec"), "F": reader.String("empty.txt")}) + badStream := w.Add(reader.Dict{"Type": reader.Name("Filespec"), "F": reader.String("bad.txt"), + "EF": reader.Dict{"F": w.Add(&reader.Stream{ + Dict: reader.Dict{"Filter": reader.Name("NoSuchDecode")}, Raw: []byte("x")})}}) + + // A leaf whose entries include things that are not specifications, and a + // key that is not a string. + leaf := w.Add(reader.Dict{"Names": reader.Array{ + reader.String("good.txt"), good, + reader.String("uf.txt"), unicodeOnly, + reader.String("empty.txt"), noEF, + reader.String("bad.txt"), badStream, + reader.String("nonsense.txt"), reader.Integer(7), + reader.Integer(1), good, + }}) + // Nested one level, with a kid that is not a node. + tree := w.Add(reader.Dict{"Kids": reader.Array{leaf, reader.Integer(3)}}) + root := w.Add(reader.Dict{"Type": reader.Name("Catalog"), "Pages": pagesRef, + "Names": w.Add(reader.Dict{"EmbeddedFiles": tree})}) + out, err := w.Finish(reader.Dict{"Root": root}) + if err != nil { + t.Fatal(err) + } + d, err := Open(out) + if err != nil { + t.Fatal(err) + } + got := d.Attachments() + names := map[string]string{} + for _, a := range got { + names[a.Name] = string(a.Data) + } + if names["good.txt"] != "kept" { + t.Errorf("the good one came back as %q", names["good.txt"]) + } + if names["uf.txt"] != "also kept" { + t.Errorf("the Unicode-only one came back as %q", names["uf.txt"]) + } + // A specification with nowhere to point, or pointing at bytes no filter + // reads, is not a file: it is left out rather than handed over empty. + for _, absent := range []string{"empty.txt", "bad.txt", "nonsense.txt"} { + if _, there := names[absent]; there { + t.Errorf("%s came back as a file", absent) + } + } +} + +func TestATreeThatGoesOnForever(t *testing.T) { + // A tree that holds itself is a tree with no leaves, and following it is a + // way of never coming back. + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + page := w.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte("page 1")})}) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), "Kids": reader.Array{page}, + "Count": reader.Integer(1), + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), reader.Integer(100), reader.Integer(200)}}) + loop := w.Reserve() + w.Put(loop, reader.Dict{"Kids": reader.Array{loop}}) + root := w.Add(reader.Dict{"Type": reader.Name("Catalog"), "Pages": pagesRef, + "Names": w.Add(reader.Dict{"EmbeddedFiles": loop})}) + out, err := w.Finish(reader.Dict{"Root": root}) + if err != nil { + t.Fatal(err) + } + d, err := Open(out) + if err != nil { + t.Fatal(err) + } + if got := d.Attachments(); len(got) != 0 { + t.Errorf("a tree with no leaves yielded %d files", len(got)) + } +} + +func TestADocumentWithNoCatalogueToRead(t *testing.T) { + // readAttachments is given whatever the pages came from, and a document + // can be missing every step of the way to its files. + d, err := Open(simple(t, 1)) + if err != nil { + t.Fatal(err) + } + if got := d.Attachments(); len(got) != 0 { + t.Errorf("a document with no files yielded %+v", got) + } +} + +func TestAFileStoredInAFormatThisDoesNotUnpack(t *testing.T) { + // A chain that stops at a filter nothing here reads hands back the bytes as + // they were STORED and names what they are still in. Handing those over as + // the file gives somebody a spreadsheet that is not one, which is worse + // than saying there is no file. + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + page := w.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte("page 1")})}) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), "Kids": reader.Array{page}, + "Count": reader.Integer(1), + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), reader.Integer(100), reader.Integer(200)}}) + spec := w.Add(reader.Dict{"Type": reader.Name("Filespec"), "F": reader.String("photo.jpg"), + "EF": reader.Dict{"F": w.Add(&reader.Stream{ + Dict: reader.Dict{"Filter": reader.Name("DCTDecode")}, Raw: []byte{0xff, 0xd8, 0xff}})}}) + tree := w.Add(reader.Dict{"Names": reader.Array{reader.String("photo.jpg"), spec}}) + root := w.Add(reader.Dict{"Type": reader.Name("Catalog"), "Pages": pagesRef, + "Names": w.Add(reader.Dict{"EmbeddedFiles": tree})}) + out, err := w.Finish(reader.Dict{"Root": root}) + if err != nil { + t.Fatal(err) + } + d, err := Open(out) + if err != nil { + t.Fatal(err) + } + if got := d.Attachments(); len(got) != 0 { + t.Errorf("bytes still in a filter were handed over as a file: %+v", got) + } +} diff --git a/catalogue.go b/catalogue.go index e5befaf..7ab6212 100644 --- a/catalogue.go +++ b/catalogue.go @@ -38,9 +38,11 @@ var sensitiveKeys = map[reader.Name]bool{"Metadata": true} // rather than describing it, so copying one across a rebuild would leave it // naming objects that are no longer there. // -// - /Names, the name trees: named destinations point at pages, embedded -// files travel with the document, and one of the trees is where a file -// keeps its JavaScript. +// - /Names /Dests, where named destinations point at pages this may have +// reordered or removed, and /Names /JavaScript, which is where a document +// keeps code that runs. /Names /EmbeddedFiles IS carried: see attach.go — +// a file inside a document belongs to no page, so nothing this does can +// invalidate it, and dropping it loses something no page shows is there. // - /Perms, which records what a signature allows. Every verb here rewrites // the bytes the signature was taken over, so the signature is void and the // permission it granted with it. @@ -74,7 +76,9 @@ func (d *Doc) keepCatalogue(w *reader.Writer, catalog reader.Dict, kept *keptAnn // Pages from several files have several catalogues, and there is no // honest way to choose between them or to merge two forms whose // fields may be named the same. Such a document keeps its pages and - // nothing above them. + // nothing above them — except the files it was handed, which belong to + // no page and so cannot be in conflict. + d.keepAttachments(w, catalog) return } // A document that opened has a catalogue; one that somehow came back @@ -94,6 +98,20 @@ func (d *Doc) keepCatalogue(w *reader.Writer, catalog reader.Dict, kept *keptAnn if tree := d.keepStructure(w, src, source, kept, built); tree != nil { catalog["StructTreeRoot"] = tree } + d.keepAttachments(w, catalog) +} + +// keepAttachments puts the files the document carries back into the catalogue. +// +// It is called for a document with one source and for one with several, unlike +// everything else here: two documents' forms cannot be merged and two +// catalogues cannot be chosen between, but two sets of files can simply both +// be carried. What cannot be carried is two files under one name, and Attach +// refuses that. +func (d *Doc) keepAttachments(w *reader.Writer, catalog reader.Dict) { + if tree := d.writeAttachments(w); tree != nil { + catalog["Names"] = w.Add(reader.Dict{"EmbeddedFiles": tree}) + } } // singleSource is the one document every page was borrowed from, when there is diff --git a/doc.go b/doc.go index 0794bdc..9c8f969 100644 --- a/doc.go +++ b/doc.go @@ -32,6 +32,13 @@ type Doc struct { // of its caller's rather than of anybody else's. outline []Bookmark + // attached are files to put inside the document, and dropped are the names + // of files it came in with that are not to go back out. A file the source + // carried is kept by being copied at write time rather than held here, so + // a document with a large attachment is not a large document in memory. + attached []Attachment + dropped []string + // How the file is written: packed into compressed object streams, and // protected or not. packed bool