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)