Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
181 changes: 181 additions & 0 deletions cmd/pdfops/expose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
79 changes: 79 additions & 0 deletions cmd/pdfops/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ var commands = []command{
{"metadata", "(-set <Key=value> … | -clear) <in.pdf> <out.pdf>", "write what the file says about itself", runMetadata},
{"version", "-set <version> <in.pdf> <out.pdf>", "set the PDF version the file declares", runVersion},
{"repair", "[-check] <in.pdf> [out.pdf]", "rebuild a file whose structure is damaged, and say whether it was", runRepair},
{"layers", "(-list | -show <name> | -hide <name> | -add <name> | -drop <name>) <in.pdf> [out.pdf]", "the optional content a viewer calls layers", runLayers},
{"labels", "(-list | -clear | -rule <spec> …) <in.pdf> [out.pdf]", "set what a viewer shows in its toolbar: i, ii, iii then 1, 2, 3", runLabels},
{"strip", "[-annotations] [-bookmarks] <in.pdf> <out.pdf>", "write the file without its metadata", runStrip},
{"sanitize", "<in.pdf> <out.pdf>", "remove what runs rather than shows: scripts, launching, embedded files", runSanitize},
Expand Down Expand Up @@ -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, "<in.pdf> <out.pdf>"
if *list {
want, usage = 1, "<in.pdf>"
}
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))
}
6 changes: 6 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading