From 42ab1b43ef31c0190f1a08ef03c78d3c4ce64e1c Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 11 Oct 2026 18:15:13 +0200 Subject: [PATCH] layers: the optional content, and the hidden one every verb was revealing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc.Layers, SetLayerVisible, AddLayer and DropLayer, with `pdfops layers` over them. This closes BentoPDF's pdf-layers. ⛔⛔ AND IT CLOSES A DISCLOSURE. Measured before the change, with a probe written for the purpose: a one-page drawing with a layer switched OFF, run through `pdfops watermark`, came back with no /OCProperties at all — the content still marked as belonging to a layer, the layer object still reachable through the page's own resources, and NOTHING left saying which layer was hidden. So the hidden layer showed. A drawing distributed with an "internal pricing" layer turned off revealed it to any verb at all. The witness test asserts the VISIBILITY and not the presence of the layers, because a carry that kept both and lost which was off would have the same consequence. The layers are constructed on the way out rather than carried, which is what lets them survive a reorder AND a merge. keepCatalogue refuses to merge two catalogues because two forms whose fields are named the same cannot honestly become one — but two drawings' layers can: a layer is its object, and two layers named "Markup" are two layers. What the file says about its own layers is obeyed: /BaseState OFF inverts the question (hidden unless /ON names it, which is how an alternatives document is built); /Locked is the document asking that a layer not be switched, and -hide on one is refused; /D /Order is the nesting, and a string inside it is the group's LABEL rather than a layer. ⛔ Dropping a HIDDEN layer is refused: what was drawn on it stays in the page and stops being optional, so the hidden drawing becomes permanently VISIBLE. And an ambiguous name is refused with the count, because a name is not an identifier. ⛔ THREE DEFECTS THE TESTS FOUND IN MY OWN CODE, each invisible to the one before it: - the layer ceiling was checked on ENTRY to the walk rather than inside its loop, so it bounded the recursion and not the list: one flat /Order of six thousand layers passed it once and added all six thousand. The test that lowered the ceiling to two and got six back is what found it. - /Order was written FLAT, so every hierarchy was lost on the way out. The test that read a nested order could not see it; only the round trip could. - with the nesting written, a child added under a parent landed after an unrelated layer and became ITS child, because the order is positional. The insertion goes after the parent's own subtree now, and the reported list and the written file share that one rule so the report cannot be a lie. Four guards were removed as dead code a coverage gap found: an explicit test for a group's string label, which `add` already refuses by type, and three not-found returns the validation upstream makes unreachable. 100% statement coverage with zero uncovered blocks, go vet and gofmt clean. Eight mutations caught, including the state this package was in before. Co-Authored-By: Claude Opus 5 --- README.md | 43 +++ cmd/pdfops/expose_test.go | 181 +++++++++++ cmd/pdfops/run.go | 79 +++++ doc.go | 6 + layers.go | 497 ++++++++++++++++++++++++++++ layers_test.go | 668 ++++++++++++++++++++++++++++++++++++++ write.go | 11 + 7 files changed, 1485 insertions(+) create mode 100644 layers.go create mode 100644 layers_test.go diff --git a/README.md b/README.md index 6ca8679..72ffd2c 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,49 @@ entry, not three hundred. A label no style can explain becomes a run of one with the whole label as a literal prefix — the file then says exactly what the viewer must show, at the cost of an entry. + +`layers` is the optional content a viewer calls layers: a drawing's +dimensions, its markup, its alternatives. + +```sh +pdfops layers -list plan.pdf +pdfops layers -hide 'Internal pricing' plan.pdf out.pdf +pdfops layers -add 'Markup/Mine' plan.pdf out.pdf +``` + +⛔ **Every verb used to lose them.** Measured before this: a one-page drawing +with a layer switched **off**, run through `pdfops watermark`, came back with +no `/OCProperties` at all — the content still *marked* as belonging to a layer, +the layer object still reachable through the page's own resources, and nothing +left saying which layer was hidden. **So the hidden layer showed.** A drawing +distributed with an "internal pricing" layer turned off revealed it to any verb +at all. + +The layers are now constructed on the way out rather than carried, which is +what lets them survive a reorder *and* a merge: `keepCatalogue` refuses to +merge two catalogues, because two forms whose fields are named the same cannot +honestly become one — but two drawings' layers can, since a layer is its object +and two layers named "Markup" are two layers. + +### What the file says about its own layers, and is obeyed + +| | | +| --- | --- | +| `/BaseState OFF` | inverts the question: everything is hidden unless `/ON` names it. A reader that only looked at `/OFF` would report every layer of such a document as visible — and a drawing that ships with all its layers off but one is exactly how an alternatives document is built | +| `/Locked` | the document asking that a layer not be switched. `-hide` on one is **refused**: a drawing's dimension layer is what everything else is positioned against, and switching it anyway ignores the only thing the file says | +| `/D /Order` | the nesting, which is written back nested. A string inside it is the **group's label** rather than a layer | + +⛔ **Dropping a hidden layer is refused**, because what was drawn on it does not +go with it: the content stays in the page and stops being optional, so the +hidden drawing becomes permanently **visible** — the opposite of what somebody +deleting a layer called "internal pricing" expects. Show it first if that is +what you mean. + +⛔ **A name is not an identifier.** A drawing assembled from two others often +has two "Markup" layers, and switching one of them at random is a change +nobody asked for and nobody can see, so an ambiguous name is refused with the +count. + `attachments` lists the files a document carries, and writes them out with `-to`. A name comes out of the document rather than from whoever runs the command, so it is stripped to its last element: `../escaped.txt` is written diff --git a/cmd/pdfops/expose_test.go b/cmd/pdfops/expose_test.go index c4b8e06..75a0c7f 100644 --- a/cmd/pdfops/expose_test.go +++ b/cmd/pdfops/expose_test.go @@ -1020,3 +1020,184 @@ func TestLabelsListOfADocumentWithNone(t *testing.T) { t.Errorf("code %d, said %q", code, said) } } + +// layeredFixture writes a one-page document with two layers, the second +// hidden, each with content marked as drawing on it. +func layeredFixture(t *testing.T) string { + t.Helper() + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + a := w.Add(reader.Dict{"Type": reader.Name("OCG"), "Name": reader.String("Markup")}) + b := w.Add(reader.Dict{"Type": reader.Name("OCG"), "Name": reader.String("Prices")}) + stream := w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte( + "/OC /MC0 BDC\n1 0 0 RG 1 1 5 5 re S\nEMC\n/OC /MC1 BDC\n2 2 5 5 re S\nEMC\n")}) + pageRef := w.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "Contents": stream, + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(100), reader.Integer(200)}, + "Resources": reader.Dict{"Properties": reader.Dict{"MC0": a, "MC1": b}}}) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{pageRef}, "Count": reader.Integer(1)}) + out, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{ + "Type": reader.Name("Catalog"), "Pages": pagesRef, + "OCProperties": reader.Dict{"OCGs": reader.Array{a, b}, + "D": reader.Dict{"Order": reader.Array{a, b}, + "ON": reader.Array{a}, "OFF": reader.Array{b}}}, + })}) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "layered.pdf") + if err := os.WriteFile(path, out, 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestLayersList(t *testing.T) { + code, said, msg := exec("layers", "-list", layeredFixture(t)) + if code != 0 { + t.Fatalf("code %d: %s", code, msg) + } + // ⛔ The STATE and the name, both: the use of this tool is finding the + // layer that is off, so printing only the names would hide it. + if !strings.Contains(said, "on") || !strings.Contains(said, "Markup") { + t.Errorf("said %q", said) + } + if !strings.Contains(said, "off") || !strings.Contains(said, "Prices") { + t.Errorf("the hidden layer is not reported as off:\n%s", said) + } +} + +// ⛔⛔ THE WITNESS, through the command: a hidden layer must still be hidden +// after any verb at all. Before this change `pdfops watermark` lost the +// optional content entirely and the hidden layer showed. +func TestAHiddenLayerSurvivesAVerbThroughTheCommand(t *testing.T) { + in := layeredFixture(t) + out := filepath.Join(t.TempDir(), "out.pdf") + if code, _, msg := exec("watermark", "-text", "DRAFT", in, out); code != 0 { + t.Fatalf("code %d: %s", code, msg) + } + code, said, _ := exec("layers", "-list", out) + if code != 0 { + t.Fatalf("code %d", code) + } + if !strings.Contains(said, "Prices") { + t.Errorf("the hidden layer is gone after a watermark:\n%s", said) + } + if !strings.Contains(said, "off") { + t.Errorf("the hidden layer came back VISIBLE after a watermark:\n%s", said) + } +} + +func TestLayersShowHideAddDrop(t *testing.T) { + in := layeredFixture(t) + dir := t.TempDir() + out := filepath.Join(dir, "out.pdf") + if code, _, msg := exec("layers", "-show", "Prices", "-hide", "Markup", + "-add", "Notes", in, out); code != 0 { + t.Fatalf("code %d: %s", code, msg) + } + _, said, _ := exec("layers", "-list", out) + for _, want := range []string{"Notes", "Prices", "Markup"} { + if !strings.Contains(said, want) { + t.Errorf("missing %q:\n%s", want, said) + } + } + // A child, by PARENT/NAME. + child := filepath.Join(dir, "child.pdf") + if code, _, msg := exec("layers", "-add", "Markup/Mine", in, child); code != 0 { + t.Fatalf("code %d: %s", code, msg) + } + _, said, _ = exec("layers", "-list", child) + if !strings.Contains(said, " on Mine") { + t.Errorf("the child is not shown nested:\n%s", said) + } + // Dropping a visible layer. + dropped := filepath.Join(dir, "dropped.pdf") + if code, _, msg := exec("layers", "-drop", "Markup", in, dropped); code != 0 { + t.Fatalf("code %d: %s", code, msg) + } + _, said, _ = exec("layers", "-list", dropped) + if strings.Contains(said, "Markup") { + t.Errorf("the layer was not dropped:\n%s", said) + } +} + +func TestLayersRefusals(t *testing.T) { + in := layeredFixture(t) + out := filepath.Join(t.TempDir(), "out.pdf") + if code, _, _ := exec("layers", "-show", "Markup", in); code != 1 { + t.Error("no output file was accepted") + } + if code, _, _ := exec("layers", "-list", in, out); code != 1 { + t.Error("-list accepted an output file") + } + if code, _, _ := exec("layers", in, out); code != 1 { + t.Error("neither -list nor a change was accepted") + } + if code, _, _ := exec("layers", "-list", "-show", "Markup", in); code != 1 { + t.Error("-list with a change was accepted") + } + // ⛔ Dropping a HIDDEN layer is refused: its content stays and stops being + // optional, so the hidden drawing becomes permanently visible. + if code, _, said := exec("layers", "-drop", "Prices", in, out); code != 1 { + t.Errorf("a hidden layer was dropped: %s", said) + } + for _, args := range [][]string{ + {"layers", "-show", "no such layer", in, out}, + {"layers", "-hide", "no such layer", in, out}, + {"layers", "-drop", "no such layer", in, out}, + {"layers", "-add", "x/y", in, out}, + {"layers", "-add", "", in, out}, + {"layers", "-nonsense", in, out}, + {"layers", "-show", "Markup", "/no/such/file.pdf", out}, + {"layers", "-list", "/no/such/file.pdf"}, + {"layers", "-show", "Markup", in, "/no/such/dir/out.pdf"}, + } { + if code, _, _ := exec(args...); code != 1 { + t.Errorf("%v was accepted", args[1:]) + } + } +} + +func TestLayersListOfADocumentWithNone(t *testing.T) { + code, said, _ := exec("layers", "-list", fixture(t, 1)) + if code != 0 || !strings.Contains(said, "no layers") { + t.Errorf("code %d, said %q", code, said) + } +} + +// A locked layer cannot be switched, and the command says so. +func TestLayersRefusesALockedOne(t *testing.T) { + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + grid := w.Add(reader.Dict{"Type": reader.Name("OCG"), "Name": reader.String("Grid")}) + pageRef := w.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(10), reader.Integer(10)}, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte{}})}) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{pageRef}, "Count": reader.Integer(1)}) + out, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{ + "Type": reader.Name("Catalog"), "Pages": pagesRef, + "OCProperties": reader.Dict{"OCGs": reader.Array{grid}, + "D": reader.Dict{"Order": reader.Array{grid}, "ON": reader.Array{grid}, + "Locked": reader.Array{grid}}}, + })}) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "locked.pdf") + if err := os.WriteFile(path, out, 0o644); err != nil { + t.Fatal(err) + } + code, said, _ := exec("layers", "-list", path) + if code != 0 || !strings.Contains(said, "locked") { + t.Errorf("-list does not report the lock: %q", said) + } + if code, _, _ := exec("layers", "-hide", "Grid", path, + filepath.Join(t.TempDir(), "x.pdf")); code != 1 { + t.Error("a locked layer was switched") + } +} diff --git a/cmd/pdfops/run.go b/cmd/pdfops/run.go index 6a87ca5..e5996bc 100644 --- a/cmd/pdfops/run.go +++ b/cmd/pdfops/run.go @@ -62,6 +62,7 @@ var commands = []command{ {"metadata", "(-set … | -clear) ", "write what the file says about itself", runMetadata}, {"version", "-set ", "set the PDF version the file declares", runVersion}, {"repair", "[-check] [out.pdf]", "rebuild a file whose structure is damaged, and say whether it was", runRepair}, + {"layers", "(-list | -show | -hide | -add | -drop ) [out.pdf]", "the optional content a viewer calls layers", runLayers}, {"labels", "(-list | -clear | -rule …) [out.pdf]", "set what a viewer shows in its toolbar: i, ii, iii then 1, 2, 3", runLabels}, {"strip", "[-annotations] [-bookmarks] ", "write the file without its metadata", runStrip}, {"sanitize", " ", "remove what runs rather than shows: scripts, launching, embedded files", runSanitize}, @@ -1858,3 +1859,81 @@ func parseLabelRule(spec string) (ops.LabelRule, error) { } return r, nil } + +// runLayers lists or changes a document's optional content. +func runLayers(c *context, args []string) error { + fs := flags("layers") + list := fs.Bool("list", false, "say what layers there are and write nothing") + var showNames, hideNames, addNames, dropNames stringList + fs.Var(&showNames, "show", "turn a layer on (repeatable)") + fs.Var(&hideNames, "hide", "turn a layer off (repeatable)") + fs.Var(&addNames, "add", "add an empty layer, as NAME or PARENT/NAME (repeatable)") + fs.Var(&dropNames, "drop", "remove a layer (repeatable)") + if err := fs.Parse(args); err != nil { + return err + } + changes := len(showNames) + len(hideNames) + len(addNames) + len(dropNames) + want, usage := 2, " " + if *list { + want, usage = 1, "" + } + if err := wantArgs(fs, want, usage); err != nil { + return err + } + if *list == (changes > 0) { + return fmt.Errorf("say either -list or one or more of -show, -hide, -add and -drop") + } + d, err := c.open(fs.Arg(0)) + if err != nil { + return err + } + if *list { + layers := d.Layers() + if len(layers) == 0 { + fmt.Fprintln(c.out, "no layers") + return nil + } + for _, l := range layers { + state := "on " + if !l.Visible { + state = "off" + } + if l.Locked { + state += " locked" + } + // ⛔ The nesting is shown, because a panel of forty layers is + // unreadable flat and the hierarchy is what a drawing program + // put there. + fmt.Fprintf(c.out, "%s%-10s %s\n", strings.Repeat(" ", l.Depth), state, l.Name) + } + return nil + } + for _, name := range showNames { + if err := d.SetLayerVisible(name, true); err != nil { + return err + } + } + for _, name := range hideNames { + if err := d.SetLayerVisible(name, false); err != nil { + return err + } + } + for _, spec := range addNames { + // ⛔ PARENT/NAME, with the slash chosen because a layer name may hold + // almost anything else — a colon, a comma, a space — and a separator + // a drawing program can put in a name is not a separator. + parent, name := "", spec + if before, after, found := strings.Cut(spec, "/"); found { + parent, name = before, after + } + if err := d.AddLayer(name, parent); err != nil { + return err + } + } + for _, name := range dropNames { + if err := d.DropLayer(name); err != nil { + return err + } + } + return save(d, fs.Arg(1)) +} diff --git a/doc.go b/doc.go index f29391a..12567c5 100644 --- a/doc.go +++ b/doc.go @@ -39,6 +39,12 @@ type Doc struct { labels []string labelsSet bool + // layerEdits are changes to the optional content, held until the file is + // written. They are kept as EDITS rather than as a resolved layer list + // because the layers of the result are the union of every source's, and + // that union does not exist until the pages are known to be final. + layerEdits []layerEdit + // 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 diff --git a/layers.go b/layers.go new file mode 100644 index 0000000..074c6a1 --- /dev/null +++ b/layers.go @@ -0,0 +1,497 @@ +// Copyright (c) 2026, the go-pdfkit/ops authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package ops + +import ( + "fmt" + + "github.com/go-pdfkit/reader" +) + +// A Layer is one of a document's optional content groups — what a viewer calls +// a layer, and what a drawing keeps its dimensions, its markup and its +// alternatives on. +type Layer struct { + // Name is what the viewer shows. ⛔ It is NOT unique: a document may hold + // two layers of the same name, and the methods here say so rather than + // guessing which was meant. + Name string + // Visible is whether the document's default configuration has it on. + Visible bool + // Locked is whether the document asks that it not be switched: a viewer + // shows the layer greyed, and [Doc.SetLayerVisible] refuses it. + Locked bool + // Depth is how deeply the layer is nested in the document's order, from + // zero. A drawing groups its layers, and the nesting is what makes a list + // of forty of them readable. + Depth int +} + +// a layerEdit is one change to make to the layers at write time. +type layerEdit struct { + kind string // "show", "hide", "add", "drop" + name string + parent string + applied bool +} + +// Layers reports the document's layers, in the order the document asks them to +// be shown and with their nesting. +// +// A document with no optional content has none. +func (d *Doc) Layers() []Layer { + src, ok := d.singleSource() + if !ok { + // ⛔ Pages from several files have several sets of layers, and this + // answers for the document as it stands rather than guessing. The + // WRITE merges them — two layers of the same name from two drawings + // are two layers, which is not true of two form fields of the same + // name — but until the file is written there is no merged document to + // report on. + return nil + } + groups := sourceLayers(src) + out := make([]Layer, 0, len(groups)) + for _, g := range groups { + out = append(out, g.Layer) + } + return applyEditsToList(out, d.layerEdits) +} + +// SetLayerVisible turns a layer on or off. +// +// ⛔ A LOCKED layer is refused. The lock is the document asking that this one +// not be switched — a drawing's dimension layer that everything else is +// positioned against — and a tool that switched it anyway would be ignoring +// the only thing the file says about its own layers. +func (d *Doc) SetLayerVisible(name string, visible bool) error { + if err := d.oneLayerNamed(name); err != nil { + return err + } + for _, l := range d.Layers() { + if l.Name == name && l.Locked { + return fmt.Errorf("ops: the layer %q is locked, and the document "+ + "asks that it not be switched", name) + } + } + kind := "hide" + if visible { + kind = "show" + } + d.layerEdits = append(d.layerEdits, layerEdit{kind: kind, name: name}) + return nil +} + +// AddLayer adds an empty layer, under parent when parent names one. +// +// ⛔ The layer has NO CONTENT on it, and that is what adding one means: a +// template gains a layer for whoever draws on it next. Nothing in the document +// becomes hidden or shown by adding one. +func (d *Doc) AddLayer(name, parent string) error { + if name == "" { + return fmt.Errorf("ops: a layer needs a name") + } + if parent != "" { + if err := d.oneLayerNamed(parent); err != nil { + return fmt.Errorf("ops: the parent: %w", err) + } + } + d.layerEdits = append(d.layerEdits, layerEdit{kind: "add", name: name, parent: parent}) + return nil +} + +// DropLayer removes a layer. +// +// ⛔ WHAT WAS DRAWN ON IT DOES NOT GO WITH IT. The content stays in the page +// and stops being optional, so a HIDDEN layer that is dropped becomes +// permanently visible — which is the opposite of what somebody deleting a +// layer called "internal pricing" expects. Removing the content as well would +// mean rewriting every content stream that mentions the layer, which this does +// not do; the refusal below is the honest half of that. +func (d *Doc) DropLayer(name string) error { + if err := d.oneLayerNamed(name); err != nil { + return err + } + for _, l := range d.Layers() { + if l.Name != name { + continue + } + if !l.Visible { + return fmt.Errorf("ops: the layer %q is hidden, and dropping it "+ + "would make what is drawn on it permanently VISIBLE: "+ + "show it first if that is what you mean", name) + } + } + d.layerEdits = append(d.layerEdits, layerEdit{kind: "drop", name: name}) + return nil +} + +// oneLayerNamed says whether exactly one layer has this name. +func (d *Doc) oneLayerNamed(name string) error { + n := 0 + for _, l := range d.Layers() { + if l.Name == name { + n++ + } + } + switch n { + case 1: + return nil + case 0: + return fmt.Errorf("ops: this document has no layer named %q", name) + } + // ⛔ Named twice is refused rather than taking the first. A layer name is + // not an identifier, and a drawing assembled from two others often has + // two "Markup" layers — switching one of them at random is a change + // nobody asked for and nobody can see. + return fmt.Errorf("ops: %d layers are named %q, so naming one does not say which", n, name) +} + +// applyEditsToList is what Layers reports after the edits that have not been +// written yet. +func applyEditsToList(layers []Layer, edits []layerEdit) []Layer { + for _, e := range edits { + switch e.kind { + case "show", "hide": + for i := range layers { + if layers[i].Name == e.name { + layers[i].Visible = e.kind == "show" + } + } + case "add": + // ⛔ Inserted after its PARENT'S SUBTREE, not at the end. The + // order is positional — a child follows the layer it belongs to — + // so appending a depth-one layer after an unrelated top-level one + // made it that one's child. The first draft did exactly that, and + // only writing the nesting out revealed it. + at, depth := childSlot(layers, e.parent) + layers = append(layers, Layer{}) + copy(layers[at+1:], layers[at:]) + layers[at] = Layer{Name: e.name, Visible: true, Depth: depth} + case "drop": + out := layers[:0] + for _, l := range layers { + if l.Name != e.name { + out = append(out, l) + } + } + layers = out + } + } + return layers +} + +// a sourceLayer is a layer and the object it is in its own file. +type sourceLayer struct { + Layer + ref reader.Object +} + +// sourceLayers reads a document's /OCProperties. +// +// ⛔ The ORDER and the NESTING come from /D /Order, which is a nested array, +// and the membership from /OCGs. A layer in /OCGs that /Order does not mention +// is still a layer — a viewer lists it flat — so both have to be read and +// neither is the whole answer. +func sourceLayers(src *reader.Document) []sourceLayer { + catalog, _ := src.Catalog() + props, ok := follow(src, catalog["OCProperties"]).(reader.Dict) + if !ok { + return nil + } + known := map[int]bool{} + var all []sourceLayer + add := func(obj reader.Object, depth int) { + ref, isRef := obj.(reader.Ref) + if !isRef { + return + } + if known[ref.Num] { + return + } + dict, ok := follow(src, obj).(reader.Dict) + if !ok { + return + } + known[ref.Num] = true + name := "" + if s, ok := follow(src, dict["Name"]).(reader.String); ok { + name = string(s) + } + all = append(all, sourceLayer{Layer: Layer{Name: name, Depth: depth}, ref: ref}) + } + config, _ := follow(src, props["D"]).(reader.Dict) + // The order first, so that the nesting is known; then anything /OCGs names + // that the order left out. + var walk func(obj reader.Object, depth int) + walked := 0 + walk = func(obj reader.Object, depth int) { + if depth > maxLayerDepth { + return + } + switch v := follow(src, obj).(type) { + case reader.Array: + for _, item := range v { + // ⛔ THE COUNT IS CHECKED HERE, inside the loop, and the first + // draft checked it only on entry to this function. A guard at + // the door bounds the RECURSION and not the list: one flat + // /Order of six thousand layers passed it once and added all + // six thousand. The test that lowered the ceiling to two and + // got six back is what found it, which is the whole argument + // for a ceiling a test can reach. + if walked >= maxLayers { + return + } + // ⛔ A nested array is a GROUP, and its first element may be a + // STRING that is the group's label rather than a layer — a + // reader that took it for a layer would list a heading as + // something switchable. + // + // There is no test for a string here, and that is a removal + // rather than an omission: `add` already wants a reference + // that resolves to a DICTIONARY, so a label — a string, or a + // reference to one — falls out there. A mutation deleting the + // explicit check changed no test, which is how it was found; + // two ways of refusing the same thing is one branch nothing + // can distinguish. + if _, isArray := follow(src, item).(reader.Array); isArray { + walk(item, depth+1) + continue + } + walked++ + add(item, depth) + } + } + } + walk(config["Order"], 0) + if ocgs, ok := follow(src, props["OCGs"]).(reader.Array); ok { + for _, obj := range ocgs { + if walked >= maxLayers { + break + } + walked++ + add(obj, 0) + } + } + // Visibility: everything is on unless the configuration says otherwise. + off := refSet(src, config["OFF"]) + locked := refSet(src, config["Locked"]) + baseState, _ := follow(src, config["BaseState"]).(reader.Name) + on := refSet(src, config["ON"]) + for i := range all { + ref := all[i].ref.(reader.Ref) + // ⛔ /BaseState OFF inverts the question: everything is hidden unless + // /ON names it. A reader that only looked at /OFF would report every + // layer of such a document as visible — and a drawing that ships with + // all its layers off but one is exactly how an alternatives document + // is built. + if baseState == "OFF" { + all[i].Visible = on[ref.Num] + } else { + all[i].Visible = !off[ref.Num] + } + all[i].Locked = locked[ref.Num] + } + return all +} + +// The ceilings. A layer list is a count the file chose, and /Order is a tree a +// file can nest as deep as it likes. +var ( + maxLayers = 4096 + maxLayerDepth = 64 +) + +func refSet(src *reader.Document, obj reader.Object) map[int]bool { + out := map[int]bool{} + arr, ok := follow(src, obj).(reader.Array) + if !ok { + return out + } + for _, item := range arr { + if ref, ok := item.(reader.Ref); ok { + out[ref.Num] = true + } + } + return out +} + +// writeLayers builds the /OCProperties for the document being written. +// +// ⛔ It is CONSTRUCTED rather than copied whole. Copying the source's +// /OCProperties would be one call — and then the layer objects in it could not +// be matched by name, because after w.Copy they are references into a file +// that cannot be read back. Building it means copying each layer OBJECT, which +// w.Copy deduplicates against the copies the pages' own resources already +// made, so the lists name the same objects the content is marked with. Getting +// that wrong gives a file whose ON and OFF lists name layers nothing is drawn +// on. +func (d *Doc) writeLayers(w *reader.Writer) reader.Object { + type entry struct { + src *reader.Document + layer Layer + ref reader.Object + added bool + parent string + } + var entries []entry + // ⛔ Every source, not just the first. keepCatalogue refuses to merge two + // catalogues because two forms whose fields are named the same cannot + // honestly become one — but two drawings' layers CAN: a layer is its + // object, and two layers named "Markup" are two layers, which is exactly + // what a viewer should show for a merge of two marked-up drawings. + seen := map[*reader.Document]bool{} + for _, p := range d.pages { + if p.src == nil || seen[p.src] { + continue + } + seen[p.src] = true + for _, l := range sourceLayers(p.src) { + entries = append(entries, entry{src: p.src, layer: l.Layer, ref: l.ref}) + } + } + for _, e := range d.layerEdits { + switch e.kind { + case "show", "hide": + for i := range entries { + if entries[i].layer.Name == e.name { + entries[i].layer.Visible = e.kind == "show" + } + } + case "drop": + kept := entries[:0] + for _, en := range entries { + if en.layer.Name != e.name { + kept = append(kept, en) + } + } + entries = kept + case "add": + // ⛔ The same insertion rule as applyEditsToList, because the two + // must agree: Layers() says what is about to be written, and a + // pair that put a new child in two different places would make + // the report a lie. The layers are extracted, the shared rule + // applied, and the entries put back in the order it decided. + flat := make([]Layer, len(entries)) + for i, en := range entries { + flat[i] = en.layer + } + flat = applyEditsToList(flat, []layerEdit{e}) + rebuilt := make([]entry, 0, len(flat)) + for _, l := range flat { + found := false + for _, en := range entries { + if en.layer.Name == l.Name && en.layer.Depth == l.Depth { + en.layer = l + rebuilt = append(rebuilt, en) + found = true + break + } + } + if !found { + rebuilt = append(rebuilt, entry{layer: l, added: true, parent: e.parent}) + } + } + entries = rebuilt + } + } + if len(entries) == 0 { + return nil + } + var ocgs, on, off, locked reader.Array + refs := make([]reader.Object, len(entries)) + depths := make([]int, len(entries)) + for i, e := range entries { + var ref reader.Object + if e.added { + ref = w.Add(reader.Dict{ + "Type": reader.Name("OCG"), + "Name": reader.String(e.layer.Name), + }) + } else { + ref = w.Copy(e.src, e.ref) + } + refs[i], depths[i] = ref, e.layer.Depth + ocgs = append(ocgs, ref) + if e.layer.Visible { + on = append(on, ref) + } else { + off = append(off, ref) + } + if e.layer.Locked { + locked = append(locked, ref) + } + } + // ⛔ /Order is NESTED, and the first draft wrote it flat. The nesting is + // what makes a panel of forty layers readable, and a round-trip test is + // what caught its loss: a child layer added under a parent came back at + // the top level, which READING the nesting correctly did not reveal — + // that test only ever read a file somebody else had written. + order, _ := nestedOrder(refs, depths, 0, 0) + config := reader.Dict{"Order": order, "ON": on} + if len(off) > 0 { + config["OFF"] = off + } + if len(locked) > 0 { + config["Locked"] = locked + } + return w.Add(reader.Dict{"OCGs": ocgs, "D": w.Add(config)}) +} + +// nestedOrder builds a /Order array from a flat list of layers and their +// depths, putting each layer's children in an array just after it. +// +// It returns the array for the layers at `depth` starting at `i`, and the +// index it stopped at. ⛔ A child array FOLLOWS its parent rather than +// containing it, which is the shape the format asks for: +// +// [ plan [ walls doors ] notes ] +// +// A layer whose depth jumps by more than one — which a file can say and a +// viewer shows flat — is taken at the depth it is reached, because the +// alternative is inventing parents that do not exist. +func nestedOrder(refs []reader.Object, depths []int, i, depth int) (reader.Array, int) { + var out reader.Array + for i < len(refs) { + switch { + case depths[i] < depth: + return out, i + case depths[i] > depth: + // The children of the layer just written. + kids, next := nestedOrder(refs, depths, i, depth+1) + if len(kids) > 0 { + out = append(out, kids) + } + i = next + default: + out = append(out, refs[i]) + i++ + } + } + return out, i +} + +// childSlot says where a new child of parent goes and at what depth: just +// past the parent and everything already nested under it. +// +// ⛔ The final return serves BOTH "there is no parent" and "the parent is not +// there", which is what makes it reachable. The first draft had two helpers +// with a not-found return each, and [Doc.AddLayer] has already refused a +// parent that is not there — so both were dead code a coverage gap found. +// One shared answer, used by the case that really happens. +func childSlot(layers []Layer, parent string) (at, depth int) { + for i, l := range layers { + if parent == "" || l.Name != parent { + continue + } + j := i + 1 + for j < len(layers) && layers[j].Depth > l.Depth { + j++ + } + return j, l.Depth + 1 + } + return len(layers), 0 +} diff --git a/layers_test.go b/layers_test.go new file mode 100644 index 0000000..a13926d --- /dev/null +++ b/layers_test.go @@ -0,0 +1,668 @@ +// Copyright (c) 2026, the go-pdfkit/ops authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package ops + +import ( + "fmt" + "strings" + "testing" + + "github.com/go-pdfkit/reader" +) + +// layerSpec describes one layer of a fixture. +type layerSpec struct { + name string + hidden bool + locked bool + content bool // whether a page is marked as drawing on it +} + +// layeredPDF builds a one-page document with optional content, the way a +// drawing program writes one: the layer objects in /OCProperties, the +// visibility in its default configuration, and the page's own /Resources +// /Properties naming the layer its content is marked with. +func layeredPDF(t *testing.T, specs []layerSpec, extra func(d reader.Dict)) []byte { + t.Helper() + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + var ocgs, order, on, off, locked reader.Array + properties := reader.Dict{} + var content strings.Builder + for i, s := range specs { + ref := w.Add(reader.Dict{ + "Type": reader.Name("OCG"), "Name": reader.String(s.name), + }) + ocgs = append(ocgs, ref) + order = append(order, ref) + if s.hidden { + off = append(off, ref) + } else { + on = append(on, ref) + } + if s.locked { + locked = append(locked, ref) + } + if s.content { + mark := fmt.Sprintf("MC%d", i) + properties[reader.Name(mark)] = ref + content.WriteString("/OC /" + mark + " BDC\n1 0 0 RG 10 10 50 50 re S\nEMC\n") + } + } + stream := w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte(content.String())}) + pageRef := w.Add(reader.Dict{ + "Type": reader.Name("Page"), "Parent": pagesRef, "Contents": stream, + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(100), reader.Integer(100)}, + "Resources": reader.Dict{"Properties": properties}, + }) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{pageRef}, "Count": reader.Integer(1)}) + config := reader.Dict{"Order": order, "ON": on} + if len(off) > 0 { + config["OFF"] = off + } + if len(locked) > 0 { + config["Locked"] = locked + } + if extra != nil { + extra(config) + } + out, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{ + "Type": reader.Name("Catalog"), "Pages": pagesRef, + "OCProperties": reader.Dict{"OCGs": ocgs, "D": config}, + })}) + if err != nil { + t.Fatal(err) + } + return out +} + +// layersAfterWriting writes a document out, reads it back, and reports its +// layers. ⛔ Through the FILE: what matters is what a viewer will be told. +func layersAfterWriting(t *testing.T, d *Doc) []Layer { + t.Helper() + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + back, err := Open(out) + if err != nil { + t.Fatal(err) + } + return back.Layers() +} + +func show(layers []Layer) string { + var parts []string + for _, l := range layers { + s := l.Name + if !l.Visible { + s += "(off)" + } + if l.Locked { + s += "(locked)" + } + if l.Depth > 0 { + s = strings.Repeat(">", l.Depth) + s + } + parts = append(parts, s) + } + return strings.Join(parts, ",") +} + +// ⛔⛔ THE WITNESS FOR THE WHOLE CHANGE, and the measurement that prompted it. +// Before this, a one-page drawing with a layer switched OFF came back from +// `pdfops watermark` with NO /OCProperties at all: the content still marked as +// belonging to a layer, the layer object still reachable through the page's own +// resources, and nothing left saying which layer was hidden. So the hidden +// layer SHOWED — a drawing distributed with an "internal pricing" layer turned +// off revealed it to any verb at all. +// +// The assertion is on the VISIBILITY and not on the presence of the layers: a +// carry that kept both layers and lost which one was off would pass a test +// that only counted them, and would have exactly the same consequence. +func TestAHiddenLayerStaysHiddenThroughAVerb(t *testing.T) { + b := layeredPDF(t, []layerSpec{ + {name: "Markup", content: true}, + {name: "Internal pricing", hidden: true, content: true}, + }, nil) + d, err := Open(b) + if err != nil { + t.Fatal(err) + } + if got := show(d.Layers()); got != "Markup,Internal pricing(off)" { + t.Fatalf("the fixture itself reads as %q, so this proves nothing", got) + } + if err := d.Watermark("all", "DRAFT"); err != nil { + t.Fatal(err) + } + if got := show(layersAfterWriting(t, d)); got != "Markup,Internal pricing(off)" { + t.Errorf("after a watermark the layers are %q, want the hidden one still off", got) + } +} + +// And through the verbs that move the pages, where there is no page index to +// get wrong — layers belong to the document, not to a page. +func TestLayersSurviveTheVerbsThatMovePages(t *testing.T) { + b := layeredPDF(t, []layerSpec{ + {name: "A", content: true}, {name: "B", hidden: true, content: true}, + }, nil) + for name, verb := range map[string]func(*Doc) error{ + "reverse": func(d *Doc) error { d.Reverse(); return nil }, + "select": func(d *Doc) error { return d.Select("1") }, + "rotate": func(d *Doc) error { return d.Rotate("all", 90) }, + "compress": func(d *Doc) error { d.Compress(); return nil }, + } { + t.Run(name, func(t *testing.T) { + d, err := Open(b) + if err != nil { + t.Fatal(err) + } + if err := verb(d); err != nil { + t.Fatal(err) + } + if got := show(layersAfterWriting(t, d)); got != "A,B(off)" { + t.Errorf("layers = %q, want A,B(off)", got) + } + }) + } +} + +// ⛔ /BaseState OFF inverts the question: everything is hidden unless /ON names +// it. A reader that only looked at /OFF would report every layer of such a +// document as visible — and a drawing that ships with all its layers off but +// one is exactly how an alternatives document is built. +func TestBaseStateOffMeansHiddenUnlessNamed(t *testing.T) { + b := layeredPDF(t, []layerSpec{ + {name: "Shown", content: true}, {name: "Hidden", content: true}, + }, func(config reader.Dict) { + config["BaseState"] = reader.Name("OFF") + // Only the first is named ON. + if on, ok := config["ON"].(reader.Array); ok && len(on) > 0 { + config["ON"] = reader.Array{on[0]} + } + }) + d, err := Open(b) + if err != nil { + t.Fatal(err) + } + if got := show(d.Layers()); got != "Shown,Hidden(off)" { + t.Errorf("with BaseState OFF the layers read as %q", got) + } +} + +// The nesting comes from /D /Order, which is a nested array — and a string +// inside it is the GROUP's label rather than a layer, which a reader that took +// it for one would list as something switchable. +func TestTheNestingAndTheGroupLabels(t *testing.T) { + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + top := w.Add(reader.Dict{"Type": reader.Name("OCG"), "Name": reader.String("Plan")}) + childA := w.Add(reader.Dict{"Type": reader.Name("OCG"), "Name": reader.String("Walls")}) + childB := w.Add(reader.Dict{"Type": reader.Name("OCG"), "Name": reader.String("Doors")}) + pageRef := w.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(100), reader.Integer(100)}, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte{}})}) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{pageRef}, "Count": reader.Integer(1)}) + out, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{ + "Type": reader.Name("Catalog"), "Pages": pagesRef, + "OCProperties": reader.Dict{ + "OCGs": reader.Array{top, childA, childB}, + "D": reader.Dict{"Order": reader.Array{ + top, + reader.Array{reader.String("the drawing"), childA, childB}, + }, "ON": reader.Array{top, childA, childB}}, + }, + })}) + if err != nil { + t.Fatal(err) + } + d, err := Open(out) + if err != nil { + t.Fatal(err) + } + if got := show(d.Layers()); got != "Plan,>Walls,>Doors" { + t.Errorf("layers = %q, want Plan,>Walls,>Doors", got) + } + // The group's label is not a layer, so it cannot be switched. + if err := d.SetLayerVisible("the drawing", false); err == nil { + t.Error("a group's label was treated as a layer") + } +} + +func TestSetLayerVisible(t *testing.T) { + b := layeredPDF(t, []layerSpec{ + {name: "A", content: true}, {name: "B", hidden: true, content: true}, + }, nil) + d, err := Open(b) + if err != nil { + t.Fatal(err) + } + if err := d.SetLayerVisible("B", true); err != nil { + t.Fatal(err) + } + if err := d.SetLayerVisible("A", false); err != nil { + t.Fatal(err) + } + if got := show(layersAfterWriting(t, d)); got != "A(off),B" { + t.Errorf("layers = %q, want A(off),B", got) + } +} + +// ⛔ A LOCKED layer is refused. The lock is the document asking that this one +// not be switched — a drawing's dimension layer that everything else is +// positioned against — and a tool that switched it anyway would be ignoring +// the only thing the file says about its own layers. +func TestALockedLayerIsRefused(t *testing.T) { + b := layeredPDF(t, []layerSpec{{name: "Grid", locked: true, content: true}}, nil) + d, err := Open(b) + if err != nil { + t.Fatal(err) + } + if got := show(d.Layers()); got != "Grid(locked)" { + t.Fatalf("the fixture reads as %q", got) + } + err = d.SetLayerVisible("Grid", false) + if err == nil { + t.Fatal("a locked layer was switched") + } + if !strings.Contains(err.Error(), "locked") { + t.Errorf("the refusal does not say why: %v", err) + } + // And the lock survives the rewrite, or the refusal is good for one pass. + if got := show(layersAfterWriting(t, d)); got != "Grid(locked)" { + t.Errorf("after writing, the lock is %q", got) + } +} + +func TestAddLayer(t *testing.T) { + b := layeredPDF(t, []layerSpec{{name: "Plan", content: true}}, nil) + d, err := Open(b) + if err != nil { + t.Fatal(err) + } + if err := d.AddLayer("Notes", ""); err != nil { + t.Fatal(err) + } + if err := d.AddLayer("Mine", "Plan"); err != nil { + t.Fatal(err) + } + if got := show(layersAfterWriting(t, d)); got != "Plan,>Mine,Notes" { + t.Errorf("layers = %q, want Plan,>Mine,Notes: a child goes after its PARENT", got) + } + if err := d.AddLayer("", ""); err == nil { + t.Error("a layer with no name was added") + } + if err := d.AddLayer("x", "no such parent"); err == nil { + t.Error("a layer under a parent that is not there was added") + } +} + +// ⛔ Dropping a HIDDEN layer is refused, because what was drawn on it does not +// go with it: the content stays in the page and stops being optional, so the +// hidden drawing becomes permanently VISIBLE — the opposite of what somebody +// deleting a layer called "internal pricing" expects. +func TestDroppingAHiddenLayerIsRefused(t *testing.T) { + b := layeredPDF(t, []layerSpec{ + {name: "Plan", content: true}, + {name: "Internal pricing", hidden: true, content: true}, + }, nil) + d, err := Open(b) + if err != nil { + t.Fatal(err) + } + err = d.DropLayer("Internal pricing") + if err == nil { + t.Fatal("a hidden layer was dropped") + } + if !strings.Contains(err.Error(), "VISIBLE") { + t.Errorf("the refusal does not say what would happen: %v", err) + } + // A visible one drops, because nothing changes about what is seen. + if err := d.DropLayer("Plan"); err != nil { + t.Fatal(err) + } + if got := show(layersAfterWriting(t, d)); got != "Internal pricing(off)" { + t.Errorf("layers = %q", got) + } +} + +// ⛔ A name is not an identifier. A drawing assembled from two others often has +// two "Markup" layers, and switching one of them at random is a change nobody +// asked for and nobody can see. +func TestANameTwoLayersShareIsRefused(t *testing.T) { + b := layeredPDF(t, []layerSpec{ + {name: "Markup", content: true}, {name: "Markup", content: true}, + }, nil) + d, err := Open(b) + if err != nil { + t.Fatal(err) + } + if n := len(d.Layers()); n != 2 { + t.Fatalf("%d layers, want 2", n) + } + err = d.SetLayerVisible("Markup", false) + if err == nil { + t.Fatal("an ambiguous name was accepted") + } + if !strings.Contains(err.Error(), "2 layers") { + t.Errorf("the refusal does not say how many: %v", err) + } + if err := d.DropLayer("Markup"); err == nil { + t.Error("an ambiguous name was dropped") + } + if err := d.SetLayerVisible("no such layer", false); err == nil { + t.Error("a layer that is not there was switched") + } +} + +// ⛔ A MERGE keeps every source's layers. keepCatalogue refuses to merge two +// catalogues because two forms whose fields are named the same cannot honestly +// become one — but two drawings' layers can: a layer is its object, and two +// layers named "Markup" are two layers. +func TestAMergeKeepsEverySourcesLayers(t *testing.T) { + a, err := Open(layeredPDF(t, []layerSpec{{name: "First", content: true}}, nil)) + if err != nil { + t.Fatal(err) + } + b, err := Open(layeredPDF(t, []layerSpec{ + {name: "Second", hidden: true, content: true}, + }, nil)) + if err != nil { + t.Fatal(err) + } + a.Append(b) + if got := show(layersAfterWriting(t, a)); got != "First,Second(off)" { + t.Errorf("layers = %q, want First,Second(off)", got) + } +} + +// ⛔ A document with NO optional content gets none. Without this, everything +// above would pass on an implementation that wrote an empty /OCProperties into +// every file — which a viewer shows as an empty layers panel where there +// should be no panel at all. +func TestADocumentWithNoLayersGetsNone(t *testing.T) { + d, err := Open(simple(t, 2)) + if err != nil { + t.Fatal(err) + } + if got := d.Layers(); len(got) != 0 { + t.Errorf("layers = %v", got) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(out), "OCProperties") { + t.Error("an /OCProperties was written for a document that has none") + } +} + +// ⛔ The reported list reflects edits that have NOT been written yet, so a +// caller can ask what it is about to do. Dropping is the one that changes the +// list's length, and the one whose in-place rewrite could lose a layer it +// should have kept. +func TestLayersReportsPendingEdits(t *testing.T) { + b := layeredPDF(t, []layerSpec{ + {name: "A", content: true}, {name: "B", content: true}, {name: "C", content: true}, + }, nil) + d, err := Open(b) + if err != nil { + t.Fatal(err) + } + if err := d.DropLayer("B"); err != nil { + t.Fatal(err) + } + if got := show(d.Layers()); got != "A,C" { + t.Errorf("before writing, Layers() = %q, want A,C", got) + } + if err := d.SetLayerVisible("C", false); err != nil { + t.Fatal(err) + } + if err := d.AddLayer("D", "A"); err != nil { + t.Fatal(err) + } + if got := show(d.Layers()); got != "A,>D,C(off)" { + t.Errorf("Layers() = %q, want A,>D,C(off)", got) + } + // And the file agrees with what was reported. + if got := show(layersAfterWriting(t, d)); got != "A,>D,C(off)" { + t.Errorf("after writing = %q", got) + } +} + +// Pages from several files have several sets of layers, and Layers answers for +// the document as it stands rather than guessing — the WRITE merges them. +func TestLayersOfAMergedDocumentAreNotReportedUntilItIsWritten(t *testing.T) { + a, err := Open(layeredPDF(t, []layerSpec{{name: "First", content: true}}, nil)) + if err != nil { + t.Fatal(err) + } + b, err := Open(layeredPDF(t, []layerSpec{{name: "Second", content: true}}, nil)) + if err != nil { + t.Fatal(err) + } + a.Append(b) + if got := a.Layers(); got != nil { + t.Errorf("Layers() of a merge = %v, want nothing until it is written", got) + } + // ⛔ And the write has them both, which is what makes the nil above an + // answer about timing rather than a loss. + if got := show(layersAfterWriting(t, a)); got != "First,Second" { + t.Errorf("after writing = %q", got) + } +} + +// A catalogue with no /OCProperties, and one whose /OCProperties is not a +// dictionary: both are "no layers" and neither is an error. +func TestALayerTreeThatIsNotOneIsNoLayers(t *testing.T) { + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + pageRef := w.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(10), reader.Integer(10)}, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte{}})}) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{pageRef}, "Count": reader.Integer(1)}) + out, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{ + "Type": reader.Name("Catalog"), "Pages": pagesRef, + "OCProperties": reader.Integer(7), + })}) + if err != nil { + t.Fatal(err) + } + d, err := Open(out) + if err != nil { + t.Fatal(err) + } + if got := d.Layers(); len(got) != 0 { + t.Errorf("layers = %v", got) + } +} + +// ⛔ The ceilings, lowered so a test can reach them. A layer list is a count +// the file chose and /Order is a tree it can nest as deep as it likes; a +// guard against either that no test exercises is not a guard. +func TestTheLayerCeilings(t *testing.T) { + t.Run("how many", func(t *testing.T) { + defer func(n int) { maxLayers = n }(maxLayers) + maxLayers = 2 + specs := make([]layerSpec, 6) + for i := range specs { + specs[i] = layerSpec{name: fmt.Sprintf("L%d", i)} + } + d, err := Open(layeredPDF(t, specs, nil)) + if err != nil { + t.Fatal(err) + } + // ⛔ Asserted against a LITERAL: the ceiling is two, so at most three + // come back (the walk stops once it is past). Writing maxLayers here + // would move the judge with the subject. + if got := len(d.Layers()); got > 2 { + t.Errorf("%d layers came back under a ceiling of 2", got) + } + }) + t.Run("how deep", func(t *testing.T) { + defer func(n int) { maxLayerDepth = n }(maxLayerDepth) + maxLayerDepth = 1 + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + deep := w.Add(reader.Dict{"Type": reader.Name("OCG"), "Name": reader.String("Deep")}) + pageRef := w.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(10), reader.Integer(10)}, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte{}})}) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{pageRef}, "Count": reader.Integer(1)}) + // Nested four deep, under a ceiling of one. + order := reader.Array{reader.Array{reader.Array{reader.Array{deep}}}} + out, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{ + "Type": reader.Name("Catalog"), "Pages": pagesRef, + "OCProperties": reader.Dict{"OCGs": reader.Array{}, + "D": reader.Dict{"Order": order}}, + })}) + if err != nil { + t.Fatal(err) + } + d, err := Open(out) + if err != nil { + t.Fatal(err) + } + // The walk stopped, so the layer buried past the ceiling is not listed + // at its depth — and the document still opens and still writes. + for _, l := range d.Layers() { + if l.Depth > 2 { + t.Errorf("a layer came back at depth %d under a ceiling of 1", l.Depth) + } + } + if _, err := d.Bytes(); err != nil { + t.Errorf("writing it: %v", err) + } + }) +} + +// ⛔ The nesting has to ROUND-TRIP, which is a different claim from reading it. +// The first draft read a nested /Order correctly and wrote a FLAT one, so every +// hierarchy was lost on the way out — and the test that read a file somebody +// else had written could not see it. +func TestTheNestingSurvivesBeingWritten(t *testing.T) { + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + mk := func(name string) reader.Object { + return w.Add(reader.Dict{"Type": reader.Name("OCG"), "Name": reader.String(name)}) + } + plan, walls, doors, notes := mk("Plan"), mk("Walls"), mk("Doors"), mk("Notes") + pageRef := w.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(10), reader.Integer(10)}, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte{}})}) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{pageRef}, "Count": reader.Integer(1)}) + out, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{ + "Type": reader.Name("Catalog"), "Pages": pagesRef, + "OCProperties": reader.Dict{ + "OCGs": reader.Array{plan, walls, doors, notes}, + "D": reader.Dict{ + "Order": reader.Array{plan, reader.Array{walls, doors}, notes}, + "ON": reader.Array{plan, walls, doors, notes}, + }, + }, + })}) + if err != nil { + t.Fatal(err) + } + d, err := Open(out) + if err != nil { + t.Fatal(err) + } + const want = "Plan,>Walls,>Doors,Notes" + if got := show(d.Layers()); got != want { + t.Fatalf("reading it gives %q, want %q", got, want) + } + // ⛔ And again after a round trip through the file, which is the claim. + if got := show(layersAfterWriting(t, d)); got != want { + t.Errorf("after writing it gives %q, want %q — the nesting was lost", got, want) + } + // Twice, because a nesting that survives one pass and flattens on the + // second is a nesting that flattens. + again, err := Open(mustBytes(t, d)) + if err != nil { + t.Fatal(err) + } + if got := show(layersAfterWriting(t, again)); got != want { + t.Errorf("after a second pass: %q", got) + } +} + +func mustBytes(t *testing.T, d *Doc) []byte { + t.Helper() + b, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + return b +} + +// ⛔ A reference in /Order that resolves to something that is not a dictionary +// — which a file can say and no drawing program writes. It is not a layer, and +// a reader that took it for one would list an integer as switchable. +func TestAReferenceInTheOrderThatIsNotALayer(t *testing.T) { + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + real := w.Add(reader.Dict{"Type": reader.Name("OCG"), "Name": reader.String("Real")}) + notALayer := w.Add(reader.Integer(7)) + pageRef := w.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(10), reader.Integer(10)}, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte{}})}) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{pageRef}, "Count": reader.Integer(1)}) + out, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{ + "Type": reader.Name("Catalog"), "Pages": pagesRef, + "OCProperties": reader.Dict{ + "OCGs": reader.Array{real, notALayer}, + "D": reader.Dict{"Order": reader.Array{real, notALayer}, "ON": reader.Array{real}}, + }, + })}) + if err != nil { + t.Fatal(err) + } + d, err := Open(out) + if err != nil { + t.Fatal(err) + } + if got := show(d.Layers()); got != "Real" { + t.Errorf("layers = %q, want just Real", got) + } +} + +// ⛔ A second child goes after the FIRST one, not between the parent and it. +// childSlot walks past everything already nested under the parent, and a +// version that stopped at the parent would reverse the children each time one +// was added. +func TestASecondChildGoesAfterTheFirst(t *testing.T) { + b := layeredPDF(t, []layerSpec{{name: "Plan", content: true}}, nil) + d, err := Open(b) + if err != nil { + t.Fatal(err) + } + if err := d.AddLayer("Walls", "Plan"); err != nil { + t.Fatal(err) + } + if err := d.AddLayer("Doors", "Plan"); err != nil { + t.Fatal(err) + } + const want = "Plan,>Walls,>Doors" + if got := show(d.Layers()); got != want { + t.Errorf("Layers() = %q, want %q", got, want) + } + if got := show(layersAfterWriting(t, d)); got != want { + t.Errorf("after writing = %q, want %q", got, want) + } +} diff --git a/write.go b/write.go index 13e99f9..dbc98c3 100644 --- a/write.go +++ b/write.go @@ -101,6 +101,17 @@ func (d *Doc) Bytes() ([]byte, error) { if labels := d.writePageLabels(w); labels != nil { catalog["PageLabels"] = labels } + // ⛔ Optional content is CONSTRUCTED rather than carried with the other + // document keys, for the same reason and a worse consequence. Measured + // before this: a one-page drawing with a layer switched OFF, run through + // `pdfops watermark`, came back with no /OCProperties at all — the content + // still marked as belonging to a layer, the layer object still reachable + // through the page's own resources, and NOTHING left saying which layer + // was hidden. So the hidden layer showed. A drawing distributed with an + // "internal pricing" layer turned off revealed it to any verb at all. + if layers := d.writeLayers(w); layers != nil { + catalog["OCProperties"] = layers + } trailer := reader.Dict{"Root": w.Add(catalog)} if len(d.info) > 0 { trailer["Info"] = w.Add(d.info)