From a2128ff801d3f8134f96b69ec7e328951235be89 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 30 Aug 2026 14:59:17 +0200 Subject: [PATCH] Stop throwing away the files a document carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Every verb here dropped them. Not out of carelessness: the catalogue's /Names was 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 reorders and removes, and of /JavaScript, which is code. It is not true of /EmbeddedFiles. A file inside a document belongs to no page, so nothing here can invalidate it — and 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, 50 files between them. So they are carried, and three verbs follow from having to read them anyway: Attachments lists what a document holds, Attach puts one in, Detach takes one out. 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. A sanitised file still carries nothing inside it. That is what sanitising is for. Files from SEVERAL documents are all kept, unlike everything else in the catalogue. Two forms cannot be merged and two catalogues cannot be chosen between; two sets of files can simply both be carried, because a file belongs to no page and so cannot be in conflict. The test for that is what found the bug: Attachments read the single source and a merged document has none. Two things a specification can point at are not files, and both are left out rather than handed over empty. One that names no stream at all. And one whose stream is still in a filter nothing here unpacks — DecodeStream hands those back as they were STORED and names what they are in, so passing them on gives somebody a spreadsheet that is not one. Judged by poppler rather than by ourselves: pdfdetach lists one file before and two after a rotation and an addition, and saves both with the right bytes. 100% statement coverage, go vet and -race clean, nine cross-compile targets. --- attach.go | 226 ++++++++++++++++++++++++++++++++ attach_test.go | 343 +++++++++++++++++++++++++++++++++++++++++++++++++ catalogue.go | 26 +++- doc.go | 7 + 4 files changed, 598 insertions(+), 4 deletions(-) create mode 100644 attach.go create mode 100644 attach_test.go 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