diff --git a/README.md b/README.md index 011e29c..6ca8679 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,72 @@ themselves truncated may still be unreadable. A file that did not need repairing is **said out loud and is not an error**, so that "repair everything in this directory" can be scripted. + +`labels` sets what a viewer shows in its **toolbar** — "iv of 320" — which is +never what is printed on the paper. For a number on the page, that is `number` +above. + +```sh +pdfops labels -list book.pdf +pdfops labels -rule 'pages=1-12;style=r' -rule 'pages=13-;style=D' book.pdf out.pdf +pdfops labels -rule 'pages=all;style=D;prefix=A-;start=1' appendix.pdf out.pdf +pdfops labels -clear book.pdf plain.pdf +``` + +The six styles are the format's own: `D` (1, 2, 3), `r` (i, ii, iii), `R` (I, +II, III), `a` (a, b, c), `A` (A, B, C), and an empty style, which is the prefix +alone — how a run of pages is marked "Cover" or "Insert". + +⛔ **A page-label range in a PDF is CONTIGUOUS.** The file holds the first page +of each run and the viewer counts forward from there, so a rule over +`1-9,30-40` has to be *compiled* into two runs. Without `continue` the second +run starts again at `start`; with it, it carries on. `pages=odd` is the extreme +of the same thing — fifty runs of one page over a hundred — and both answers are +expressible only because a rule is compiled rather than stored as written. + +⛔ **A page-label tree has no gaps either.** An entry labels from its own key +until the *next* entry, so a document labelled 1, 2 and then meant to stop +comes back 1, 2, 3, 4 unless the stop is said out loud: an entry with neither a +style nor a prefix. The first draft skipped unlabelled pages and the round-trip +caught it. + +⛔ **`start` must be at least 1**, and that is the format's rule rather than a +choice here: ISO 32000-1 table 159 says `/St` "shall be greater than or equal +to 1". A zero-based numbering scheme is not expressible as a numeric style, so +a `start` below one is refused rather than written out of specification — a +file that opens in one viewer and renumbers itself in another is worse than a +refusal. BentoPDF's own tool offers "set to 0 for zero-based numbering", and +what that writes is a value the standard forbids. + +⛔ **A page label's letters are not a spreadsheet's columns.** Table 159 says +the letters run "A to Z for the first 26 pages, AA to ZZ for the next 26" — so +27 is **AA** and 28 is **BB**, where a spreadsheet's 28 is AB. Borrowing the +spreadsheet rule, which is the one everybody has written before, relabels every +appendix past the twenty-sixth. + +Roman numerals stop at 3999, where the additive notation runs out — 4000 needs +a bar over the M, which neither a PDF string nor a page number has — and past +it the number is written in decimal. A label a viewer shows *wrongly* is worse +than one it shows in the wrong style. + +### ⛔ Every verb used to drop the page labels + +A `/PageLabels` tree is keyed by page **index**, so it cannot be carried across +a rebuild the way the other document keys are: after a `select` or a `reverse` +every label would be on the wrong page. So this package dropped it — and a +document whose front matter was numbered i, ii, iii came back from a +**watermark** numbered 1, 2, 3. + +The labels are now asked for **per page** and rebuilt, which is right in both +directions: a verb that keeps the pages keeps their labels, and a verb that +moves them moves the labels with them. A merge keeps each source's labels on +its own pages. + +The runs are compressed on the way out: three hundred decimal pages are **one** +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. + `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 a392286..c4b8e06 100644 --- a/cmd/pdfops/expose_test.go +++ b/cmd/pdfops/expose_test.go @@ -886,3 +886,137 @@ func damagedFixture(t *testing.T) string { } return path } + +// labels sets what a viewer shows in its toolbar. +func TestLabels(t *testing.T) { + in := fixture(t, 5) + out := filepath.Join(t.TempDir(), "out.pdf") + code, _, msg := exec("labels", + "-rule", "pages=1-2;style=r", + "-rule", "pages=3-5;style=D", + in, out) + if code != 0 { + t.Fatalf("code %d: %s", code, msg) + } + code, said, msg := exec("labels", "-list", out) + if code != 0 { + t.Fatalf("code %d: %s", code, msg) + } + // ⛔ The POSITION and the label, both: the use of this tool is correcting + // a document where the two disagree, so printing only the labels would + // hide the thing being looked for. + for _, want := range []string{"1\ti", "2\tii", "3\t1", "4\t2", "5\t3"} { + if !strings.Contains(said, want) { + t.Errorf("-list is missing %q:\n%s", want, said) + } + } +} + +func TestLabelsWithAPrefixAndAStart(t *testing.T) { + in := fixture(t, 3) + out := filepath.Join(t.TempDir(), "out.pdf") + if code, _, msg := exec("labels", "-rule", "pages=all;style=D;prefix=A-;start=7", in, out); code != 0 { + t.Fatalf("code %d: %s", code, msg) + } + _, said, _ := exec("labels", "-list", out) + for _, want := range []string{"1\tA-7", "2\tA-8", "3\tA-9"} { + if !strings.Contains(said, want) { + t.Errorf("missing %q:\n%s", want, said) + } + } +} + +// ⛔ "continue" works with and without a value. A setting that only worked one +// of the two ways would be a trap in a shell script that built the spec. +func TestLabelsContinueBothWays(t *testing.T) { + in := fixture(t, 6) + dir := t.TempDir() + for _, spec := range []string{ + "pages=1-2,5-6;style=D;continue", + "pages=1-2,5-6;style=D;continue=true", + } { + out := filepath.Join(dir, "c.pdf") + if code, _, msg := exec("labels", "-rule", spec, in, out); code != 0 { + t.Fatalf("%s: code %d: %s", spec, code, msg) + } + _, said, _ := exec("labels", "-list", out) + if !strings.Contains(said, "5\t3") { + t.Errorf("%s did not carry the numbering:\n%s", spec, said) + } + } + // And continue=false is off, so the second run starts again. + out := filepath.Join(dir, "f.pdf") + if code, _, msg := exec("labels", "-rule", "pages=1-2,5-6;style=D;continue=false", in, out); code != 0 { + t.Fatalf("code %d: %s", code, msg) + } + _, said, _ := exec("labels", "-list", out) + if !strings.Contains(said, "5\t1") { + t.Errorf("continue=false carried the numbering anyway:\n%s", said) + } +} + +func TestLabelsClear(t *testing.T) { + in := fixture(t, 3) + dir := t.TempDir() + withLabels := filepath.Join(dir, "with.pdf") + if code, _, msg := exec("labels", "-rule", "style=D", in, withLabels); code != 0 { + t.Fatalf("code %d: %s", code, msg) + } + bare := filepath.Join(dir, "bare.pdf") + if code, _, msg := exec("labels", "-clear", withLabels, bare); code != 0 { + t.Fatalf("code %d: %s", code, msg) + } + code, said, _ := exec("labels", "-list", bare) + if code != 0 || !strings.Contains(said, "no page labels") { + t.Errorf("-list after -clear said %q", said) + } +} + +func TestLabelsRefusals(t *testing.T) { + in := fixture(t, 2) + out := filepath.Join(t.TempDir(), "out.pdf") + if code, _, _ := exec("labels", "-rule", "style=D", in); code != 1 { + t.Error("no output file was accepted") + } + if code, _, _ := exec("labels", "-list", in, out); code != 1 { + t.Error("-list accepted an output file") + } + // ⛔ Neither -clear nor a rule is a mistake, and so is both: a caller who + // said both meant one of them and the command cannot know which. + if code, _, _ := exec("labels", in, out); code != 1 { + t.Error("neither -clear nor -rule was accepted") + } + if code, _, _ := exec("labels", "-clear", "-rule", "style=D", in, out); code != 1 { + t.Error("-clear with a rule was accepted") + } + for _, spec := range []string{"nonsense=1", "start=x", "style=Q", "pages=zzz"} { + if code, _, _ := exec("labels", "-rule", spec, in, out); code != 1 { + t.Errorf("-rule %q was accepted", spec) + } + } + if code, _, _ := exec("labels", "-rule", "style=D", "/no/such/file.pdf", out); code != 1 { + t.Error("a missing file was accepted") + } + if code, _, _ := exec("labels", "-list", "/no/such/file.pdf"); code != 1 { + t.Error("-list on a missing file was accepted") + } + if code, _, _ := exec("labels", "-nonsense", in, out); code != 1 { + t.Error("an unknown flag was accepted") + } + if code, _, _ := exec("labels", "-rule", "style=D", in, "/no/such/dir/out.pdf"); code != 1 { + t.Error("an unwritable output was accepted") + } + // An empty rule spec is a rule over every page with no style: the prefix + // alone, which is empty, so nothing is labelled. It must not crash. + if code, _, _ := exec("labels", "-rule", "", in, out); code != 0 { + t.Error("an empty rule was refused") + } +} + +// ⛔ A document with no labels says so rather than printing five blank lines. +func TestLabelsListOfADocumentWithNone(t *testing.T) { + code, said, _ := exec("labels", "-list", fixture(t, 3)) + if code != 0 || !strings.Contains(said, "no page labels") { + t.Errorf("code %d, said %q", code, said) + } +} diff --git a/cmd/pdfops/run.go b/cmd/pdfops/run.go index 67b34ae..6a87ca5 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}, + {"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}, {"outline", "(-drop | -from ) ", "replace or remove the bookmarks", runOutline}, @@ -1766,3 +1767,94 @@ func runRepair(c *context, args []string) error { } return save(d, fs.Arg(1)) } + +// runLabels sets the page labels a viewer shows in its toolbar. +func runLabels(c *context, args []string) error { + fs := flags("labels") + var rules stringList + fs.Var(&rules, "rule", "a label rule: pages=1-3;style=r;prefix=A-;start=1;continue (repeatable)") + list := fs.Bool("list", false, "say what each page is labelled and write nothing") + clear := fs.Bool("clear", false, "remove the labels") + if err := fs.Parse(args); err != nil { + return err + } + want, usage := 2, " " + if *list { + want, usage = 1, "" + } + if err := wantArgs(fs, want, usage); err != nil { + return err + } + d, err := c.open(fs.Arg(0)) + if err != nil { + return err + } + if *list { + labels := d.PageLabels() + if len(labels) == 0 { + fmt.Fprintln(c.out, "no page labels") + return nil + } + for i, l := range labels { + // ⛔ The page's POSITION and its label, both: the whole use of + // this tool is correcting a document where the two disagree, so + // printing only the labels would hide the thing being looked for. + fmt.Fprintf(c.out, "%d\t%s\n", i+1, l) + } + return nil + } + if *clear == (len(rules) > 0) { + return fmt.Errorf("say either -clear or one or more -rule, not both and not neither") + } + parsed := make([]ops.LabelRule, 0, len(rules)) + for i, spec := range rules { + r, err := parseLabelRule(spec) + if err != nil { + return fmt.Errorf("-rule %d: %w", i+1, err) + } + parsed = append(parsed, r) + } + if err := d.SetPageLabels(parsed); err != nil { + return err + } + return save(d, fs.Arg(1)) +} + +// parseLabelRule reads pages=1-3;style=r;prefix=A-;start=1;continue. +// +// ⛔ Semicolons separate the settings and not commas, because a page range +// HAS commas in it: "pages=1-9,30-40" is one setting and a comma-separated +// form would have to escape them. +func parseLabelRule(spec string) (ops.LabelRule, error) { + var r ops.LabelRule + for _, part := range strings.Split(spec, ";") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + key, value, hasValue := strings.Cut(part, "=") + switch strings.ToLower(strings.TrimSpace(key)) { + case "pages": + r.Pages = value + case "style": + r.Style = ops.LabelStyle(value) + case "prefix": + r.Prefix = value + case "start": + n, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return r, fmt.Errorf("start=%q is not a number", value) + } + r.Start = n + case "continue": + // ⛔ A flag, with or without a value: "continue" and + // "continue=true" both mean it, and "continue=false" means it + // does not. A setting that only worked one of the two ways would + // be a trap in a shell script that built the spec. + r.Continue = !hasValue || value != "false" && value != "0" + default: + return r, fmt.Errorf("%q is not a setting: they are pages, style, prefix, start and continue", key) + } + } + return r, nil +} diff --git a/doc.go b/doc.go index 8ba1f14..f29391a 100644 --- a/doc.go +++ b/doc.go @@ -32,6 +32,13 @@ type Doc struct { // of its caller's rather than of anybody else's. outline []Bookmark + // labels is one page label per page, and labelsSet says the caller has + // decided them — including deciding there are none, which is why the two + // are separate: a nil labels with labelsSet is "remove the labels" and a + // nil labels without it is "carry over what the sources said". + labels []string + labelsSet bool + // 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/pagelabels.go b/pagelabels.go new file mode 100644 index 0000000..5b84f59 --- /dev/null +++ b/pagelabels.go @@ -0,0 +1,553 @@ +// Copyright (c) 2026, the go-pdfkit/ops authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package ops + +import ( + "fmt" + "strings" + + "github.com/go-pdfkit/reader" +) + +// A LabelStyle is how the numeric part of a page label is written. They are +// the format's own one-letter names, so that what goes in the file is what the +// caller asked for. +type LabelStyle string + +const ( + LabelDecimal LabelStyle = "D" // 1, 2, 3 + LabelLowerRoman LabelStyle = "r" // i, ii, iii + LabelUpperRoman LabelStyle = "R" // I, II, III + LabelLowerLetters LabelStyle = "a" // a, b, c … z, aa, bb + LabelUpperLetters LabelStyle = "A" // A, B, C … Z, AA, BB + // LabelNone writes no number at all: the label is the prefix alone, which + // is how a run of pages is marked "Cover" or "Insert". + LabelNone LabelStyle = "" +) + +// A LabelRule labels the pages a range names. +// +// Page labels are what a VIEWER shows in its toolbar — "iv of 320" — and never +// what is printed on the paper. For a number on the page, see [Doc.PageNumbers]. +type LabelRule struct { + // Pages is a range in this package's usual form: "1-4", "7", "odd", + // "1-9,30-40". Empty means every page. + Pages string + // Style is how the number is written. The zero value writes no number, + // which is [LabelNone] and is a style rather than an omission. + Style LabelStyle + // Prefix goes before the number: "A-" makes A-1, A-2, A-3. + Prefix string + // Start is the value of the first label in the range. Zero means one. + // + // ⛔ It must be at least one, and that is the FORMAT's rule rather than a + // choice here: ISO 32000-1 table 159 says /St "shall be greater than or + // equal to 1". A zero-based numbering scheme cannot be written as a + // numeric style at all, so Start of less than one is refused rather than + // written out of specification — a file that opens in one viewer and + // renumbers itself in another is worse than a refusal. + Start int + // Continue carries the numbering across the pieces of a DISJOINT range. + // + // ⛔ A page-label range in a PDF is CONTIGUOUS: the file holds the first + // page of each run and the viewer counts forward from there, so a rule + // over "1-9,30-40" has to become two runs. Without Continue the second + // run starts again at Start; with it, it carries on from where the first + // left off. "odd" over a hundred pages is fifty runs of one page, and + // both answers are expressible only because the rule is compiled into + // runs rather than stored as written. + Continue bool +} + +// SetPageLabels replaces the document's page labels. +// +// The rules are applied in order, so a later rule wins where two overlap. A +// page no rule names has no label, and a viewer then shows its position in the +// file — which is the right answer and is what an unlabelled document does. +// +// Passing no rules removes the labels. +func (d *Doc) SetPageLabels(rules []LabelRule) error { + if len(rules) == 0 { + d.labels = nil + d.labelsSet = true + return nil + } + n := len(d.pages) + if n == 0 { + return fmt.Errorf("ops: an empty document has no pages to label") + } + // labels[i] is the label of page i, or "" for a page no rule named. + labels := make([]string, n) + for ri, r := range rules { + // ⛔ Zero means the default of one; anything below it is refused. The + // format's rule, not a choice here: ISO 32000-1 table 159 says /St + // "shall be greater than or equal to 1", so a zero-based scheme is not + // expressible as a numeric style and writing one anyway would make a + // file that opens in one viewer and renumbers itself in another. + if r.Start < 0 { + return fmt.Errorf("ops: rule %d starts at %d, and a page label's "+ + "first value must be at least 1", ri+1, r.Start) + } + if err := r.Style.valid(); err != nil { + return fmt.Errorf("ops: rule %d: %w", ri+1, err) + } + spec := r.Pages + if spec == "" { + spec = "all" + } + nums, err := ParseRange(spec, n) + if err != nil { + return fmt.Errorf("ops: rule %d: %w", ri+1, err) + } + start := r.Start + if start == 0 { + start = 1 + } + // ⛔ The runs are found BEFORE the numbers are assigned, because + // whether the numbering restarts depends on where a run ends — which + // is a property of the whole range and not of one page. + at := start + prev := -2 + // ⛔ The page numbers are known to be inside the document: that is + // [ParseRange]'s contract — it refuses a spec that names a page the + // document has not got rather than clipping it, which ranges.go says + // at length. A bounds check here was the first draft and it was DEAD + // CODE: no spec reaches it, so no test could, and a guard no test can + // reach is not a guard. If that contract ever loosens, labels[p-1] + // panics on the broken invariant, which is a bug report rather than a + // document with the wrong numbers in it. + for _, p := range nums { + if p != prev+1 { + // A new run. It restarts unless the rule says to carry on. + if !r.Continue { + at = start + } + } + labels[p-1] = r.Prefix + r.Style.format(at) + at++ + prev = p + } + } + d.labels = labels + d.labelsSet = true + return nil +} + +// PageLabels reports what each page of the document is labelled, in order, as +// a viewer would show it. A page with no label comes back as "". +// +// It answers for the document as it stands: the labels a caller set, or the +// ones carried over from the files the pages came from. +func (d *Doc) PageLabels() []string { + if d.labelsSet { + out := make([]string, len(d.pages)) + copy(out, d.labels) + return out + } + return d.carriedLabels() +} + +// carriedLabels is what the pages are labelled in the files they came from. +// +// ⛔ Read per PAGE and not copied as a tree. A /PageLabels tree is keyed by +// page INDEX, so carrying it unchanged across a rebuild that reordered, +// removed or merged pages would leave every label on the wrong page — and +// dropping it instead, which is what this package did until now, loses the +// labels of a document that was only stamped. Asking each page what it was +// called in its own file is right for both. +func (d *Doc) carriedLabels() []string { + out := make([]string, len(d.pages)) + any := false + cache := map[*reader.Document][]string{} + for i, p := range d.pages { + if p.src == nil { + continue + } + labels, seen := cache[p.src] + if !seen { + labels = sourceLabels(p.src) + cache[p.src] = labels + } + if p.number >= 1 && p.number <= len(labels) && labels[p.number-1] != "" { + out[i] = labels[p.number-1] + any = true + } + } + if !any { + return nil + } + return out +} + +// sourceLabels reads a document's /PageLabels into one label per page. +func sourceLabels(src *reader.Document) []string { + // ⛔ The error is dropped WITHOUT a branch, deliberately. A document with + // no readable catalogue has no page labels, and that is the same answer as + // a document with a catalogue and no /PageLabels in it — so `if err != + // nil { return nil }` was a second way of saying the one thing, and a way + // no test could reach, since a document whose catalogue cannot be read + // does not get past Open. Indexing a nil Dict is nothing, which is the + // answer. + catalog, _ := src.Catalog() + nums := numberTree(src, catalog["PageLabels"], 0) + if len(nums) == 0 { + return nil + } + count := src.PageCount() + out := make([]string, count) + // The runs are in key order: each one labels from its own key until the + // next, so the ends have to be known before the labels can be written. + for i, run := range nums { + end := count + if i+1 < len(nums) { + end = nums[i+1].at + } + style := LabelStyle(run.style) + at := run.start + for p := run.at; p < end && p < count; p++ { + if p < 0 { + continue + } + out[p] = run.prefix + style.format(at) + at++ + } + } + return out +} + +// A labelRun is one entry of a /PageLabels tree: where it starts and how it +// numbers from there. +type labelRun struct { + at int // the page index the run begins at, counting from zero + style string + prefix string + start int +} + +// numberTree walks a PDF number tree, which may be one node or a tree of +// them, and returns its entries in key order. +// +// ⛔ depth is bounded. A number tree is a tree of /Kids, and a file can point +// one of them back at its parent — a loop that a reader following /Kids +// follows for ever. +func numberTree(src *reader.Document, node reader.Object, depth int) []labelRun { + const maxTreeDepth = 32 + if depth > maxTreeDepth { + return nil + } + dict, ok := follow(src, node).(reader.Dict) + if !ok { + return nil + } + var out []labelRun + if nums, ok := follow(src, dict["Nums"]).(reader.Array); ok { + // Nums is a flat array of alternating key and value. + for i := 0; i+1 < len(nums); i += 2 { + key, ok := follow(src, nums[i]).(reader.Integer) + if !ok { + continue + } + value, ok := follow(src, nums[i+1]).(reader.Dict) + if !ok { + continue + } + run := labelRun{at: int(key), start: 1} + if s, ok := follow(src, value["S"]).(reader.Name); ok { + run.style = string(s) + } + if p, ok := follow(src, value["P"]).(reader.String); ok { + run.prefix = string(p) + } + if st, ok := follow(src, value["St"]).(reader.Integer); ok && st >= 1 { + run.start = int(st) + } + out = append(out, run) + } + } + if kids, ok := follow(src, dict["Kids"]).(reader.Array); ok { + for _, kid := range kids { + out = append(out, numberTree(src, kid, depth+1)...) + } + } + return out +} + +// writePageLabels builds the /PageLabels value for the document being written, +// or nil when there is nothing to say. +// +// ⛔ The runs are COMPRESSED. A document of three hundred pages labelled +// decimally is one entry, not three hundred: a viewer counts forward from a +// run's start, so a label that is the previous one plus one in the same style +// and prefix belongs to the run already open. Writing one entry per page would +// be correct and would make a file a reader has to walk three hundred nodes of +// to answer "what is page two called". +func (d *Doc) writePageLabels(w *reader.Writer) reader.Object { + labels := d.PageLabels() + if len(labels) == 0 { + return nil + } + var nums reader.Array + for i := 0; i < len(labels); { + if labels[i] == "" { + // ⛔ A PAGE-LABEL TREE HAS NO GAPS, and this is the format rather + // than this writer: an entry labels from its own key until the + // NEXT entry, so a run that opened at page one reaches page four + // whether or not page four was meant to have a label. Skipping + // unlabelled pages — which is what the first draft did — came + // back from the file as 1, 2, 3, 4 where 1, 2 and then nothing + // was asked for. + // + // So an unlabelled page after a labelled one is said OUT LOUD, + // with an entry carrying neither a style nor a prefix: the label + // it computes is the empty string, which is what was meant. Only + // a document that starts unlabelled needs no entry, because + // there is no run open yet to close. + if len(nums) > 0 { + nums = append(nums, reader.Integer(i), reader.Dict{}) + } + for i < len(labels) && labels[i] == "" { + i++ + } + continue + } + style, prefix, start, run := runAt(labels, i) + entry := reader.Dict{} + if style != LabelNone { + entry["S"] = reader.Name(style) + } + if prefix != "" { + entry["P"] = reader.String(prefix) + } + if start != 1 { + entry["St"] = reader.Integer(start) + } + nums = append(nums, reader.Integer(i), entry) + i += run + } + if len(nums) == 0 { + return nil + } + return w.Add(reader.Dict{"Nums": nums}) +} + +// runAt works out how long a run of labels starting at i is, and what it is +// made of. +// +// A label is only part of a numbered run when the style can reproduce it and +// the next label is what that style writes for one more. Anything this cannot +// explain becomes a run of ONE with the label as a literal prefix — which is +// the honest fallback: the file then says exactly what the viewer must show, +// at the cost of an entry. +func runAt(labels []string, i int) (style LabelStyle, prefix string, start, run int) { + for _, s := range []LabelStyle{LabelDecimal, LabelLowerRoman, LabelUpperRoman, + LabelLowerLetters, LabelUpperLetters} { + p, n, ok := s.parse(labels[i]) + if !ok { + continue + } + length := 1 + for j := i + 1; j < len(labels); j++ { + if labels[j] != p+s.format(n+length) { + break + } + length++ + } + if length > 1 || s == LabelDecimal { + // A single label is written as a literal unless it is decimal, + // where the style is the one a viewer expects and costs nothing. + return s, p, n, length + } + } + return LabelNone, labels[i], 1, 1 +} + +// valid says whether a style is one the format has. +func (s LabelStyle) valid() error { + switch s { + case LabelNone, LabelDecimal, LabelLowerRoman, LabelUpperRoman, + LabelLowerLetters, LabelUpperLetters: + return nil + } + return fmt.Errorf("%q is not a label style: they are D, r, R, a, A, or empty", string(s)) +} + +// format writes n in this style. +func (s LabelStyle) format(n int) string { + switch s { + case LabelDecimal: + return fmt.Sprint(n) + case LabelLowerRoman: + return strings.ToLower(roman(n)) + case LabelUpperRoman: + return roman(n) + case LabelLowerLetters: + return strings.ToLower(letters(n)) + case LabelUpperLetters: + return letters(n) + } + return "" +} + +// parse is format backwards: it reads a label as a prefix and a number in this +// style, for working out which runs a list of labels is made of. +func (s LabelStyle) parse(label string) (prefix string, n int, ok bool) { + // The number is at the END, so the prefix is everything before the + // longest suffix this style can read. + for i := 0; i < len(label); i++ { + if v, ok := s.value(label[i:]); ok { + return label[:i], v, true + } + } + return "", 0, false +} + +// value reads a number written in this style, or says it is not one. +func (s LabelStyle) value(text string) (int, bool) { + if text == "" { + return 0, false + } + switch s { + case LabelDecimal: + n := 0 + for i := 0; i < len(text); i++ { + if text[i] < '0' || text[i] > '9' { + return 0, false + } + n = n*10 + int(text[i]-'0') + } + // ⛔ A leading zero is not decimal: "007" and "7" are the same number + // and different labels, so reading it as a run would renumber page + // "008" as "8". + if len(text) > 1 && text[0] == '0' { + return 0, false + } + return n, n >= 1 + case LabelLowerRoman: + if text != strings.ToLower(text) { + return 0, false + } + return fromRoman(strings.ToUpper(text)) + case LabelUpperRoman: + if text != strings.ToUpper(text) { + return 0, false + } + return fromRoman(text) + case LabelLowerLetters: + if text != strings.ToLower(text) { + return 0, false + } + return fromLetters(strings.ToUpper(text)) + case LabelUpperLetters: + if text != strings.ToUpper(text) { + return 0, false + } + return fromLetters(text) + } + return 0, false +} + +// roman writes a number the way a page label does. +// +// ⛔ It is bounded at 3999, where the additive notation runs out: 4000 needs a +// bar over the M that neither a PDF string nor a page number has. Past it the +// number is written in decimal, because a label a viewer shows wrongly is +// worse than one it shows in the wrong style. +func roman(n int) string { + if n < 1 || n > 3999 { + return fmt.Sprint(n) + } + var sb strings.Builder + for _, r := range []struct { + v int + s string + }{ + {1000, "M"}, {900, "CM"}, {500, "D"}, {400, "CD"}, + {100, "C"}, {90, "XC"}, {50, "L"}, {40, "XL"}, + {10, "X"}, {9, "IX"}, {5, "V"}, {4, "IV"}, {1, "I"}, + } { + for n >= r.v { + sb.WriteString(r.s) + n -= r.v + } + } + return sb.String() +} + +// fromRoman reads a roman numeral, and insists it is the CANONICAL spelling of +// its value: "IIII" is four to a person and is not what roman(4) writes, so +// reading it as four would make a run that renumbers the page after it. +func fromRoman(text string) (int, bool) { + values := map[byte]int{'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000} + n, prev := 0, 0 + for i := len(text) - 1; i >= 0; i-- { + v, ok := values[text[i]] + if !ok { + return 0, false + } + if v < prev { + n -= v + } else { + n += v + prev = v + } + } + if n < 1 || n > 3999 || roman(n) != text { + return 0, false + } + return n, true +} + +// letters writes a number as a page label's letters: A..Z, then AA..ZZ. +// +// ⛔ This is NOT a spreadsheet column. ISO 32000-1 table 159 says the letters +// run "A to Z for the first 26, AA to ZZ for the next 26" — so 27 is AA and +// 28 is BB, where a spreadsheet's 28 is AB. Borrowing the spreadsheet rule +// would relabel every appendix past the twenty-sixth. +func letters(n int) string { + if n < 1 { + return "" + } + round := (n-1)/26 + 1 + letter := byte('A' + (n-1)%26) + return strings.Repeat(string(letter), round) +} + +// fromLetters reads what letters writes. +func fromLetters(text string) (int, bool) { + if text == "" { + return 0, false + } + for i := 0; i < len(text); i++ { + if text[i] < 'A' || text[i] > 'Z' || text[i] != text[0] { + return 0, false + } + } + // ⛔ No round-trip check here, and that is a removal rather than an + // omission: the loop above has already established the only shape + // [letters] writes — one letter, A to Z, repeated. So letters(n) == text + // for every text that reaches this line, which made `if letters(n) != + // text` dead code that no test could reach. The canonicity is proved by + // the loop, not by writing the answer out again. + return (len(text)-1)*26 + int(text[0]-'A') + 1, true +} + +// follow resolves a reference and hands back nothing when it cannot. +// +// ⛔ The error is deliberately dropped, and only here. In a page-label tree an +// object that cannot be resolved means THIS PAGE HAS NO LABEL — a file whose +// labels point at objects that are gone is a file with broken labels, not a +// file that cannot be read, and refusing to open it would lose the document +// over its toolbar captions. +// It drops the error WITHOUT a branch. A reference that cannot be followed and +// one that resolves to nothing are the same answer here, and the format agrees: +// ISO 32000-1 says a reference to an object that is not there IS the null +// object. So `if err != nil { return nil }` said the one thing twice, and said +// it in a branch no test could reach — a dangling reference comes back as nil +// with no error at all. +func follow(src *reader.Document, obj reader.Object) reader.Object { + v, _ := src.Resolve(obj) + return v +} diff --git a/pagelabels_test.go b/pagelabels_test.go new file mode 100644 index 0000000..b81f16c --- /dev/null +++ b/pagelabels_test.go @@ -0,0 +1,640 @@ +// Copyright (c) 2026, the go-pdfkit/ops authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package ops + +import ( + "strings" + "testing" + "time" + + "github.com/go-pdfkit/reader" +) + +// labelsOf writes a document out, reads it back and reports what each page is +// labelled. ⛔ Through the FILE, not from the Doc: what matters is what a +// viewer will show, and the only way to know that is to write the tree and +// read it again. +func labelsOf(t *testing.T, d *Doc) []string { + t.Helper() + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + back, err := Open(out) + if err != nil { + t.Fatal(err) + } + return back.PageLabels() +} + +func labelled(t *testing.T, pages int, rules ...LabelRule) *Doc { + t.Helper() + d, err := Open(simple(t, pages)) + if err != nil { + t.Fatal(err) + } + if err := d.SetPageLabels(rules); err != nil { + t.Fatal(err) + } + return d +} + +// A book: roman front matter, then the body from one. +func TestRomanFrontMatterThenDecimalBody(t *testing.T) { + d := labelled(t, 8, + LabelRule{Pages: "1-3", Style: LabelLowerRoman}, + LabelRule{Pages: "4-8", Style: LabelDecimal}, + ) + want := []string{"i", "ii", "iii", "1", "2", "3", "4", "5"} + got := labelsOf(t, d) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("labels = %v, want %v", got, want) + } +} + +// A prefix makes the appendix: A-1, A-2, A-3. +func TestAPrefixedAppendix(t *testing.T) { + d := labelled(t, 3, LabelRule{Style: LabelDecimal, Prefix: "A-"}) + if got := labelsOf(t, d); strings.Join(got, ",") != "A-1,A-2,A-3" { + t.Errorf("labels = %v", got) + } +} + +// ⛔ A page-label range in a PDF is CONTIGUOUS, so a rule over a disjoint +// range has to be compiled into runs. Without Continue each run starts again; +// with it the numbering carries on. +func TestADisjointRangeRestartsUnlessItIsToldNotTo(t *testing.T) { + d := labelled(t, 6, LabelRule{Pages: "1-2,5-6", Style: LabelDecimal}) + if got := labelsOf(t, d); strings.Join(got, ",") != "1,2,,,1,2" { + t.Errorf("without Continue: labels = %v, want 1,2,,,1,2", got) + } + d = labelled(t, 6, LabelRule{Pages: "1-2,5-6", Style: LabelDecimal, Continue: true}) + if got := labelsOf(t, d); strings.Join(got, ",") != "1,2,,,3,4" { + t.Errorf("with Continue: labels = %v, want 1,2,,,3,4", got) + } +} + +// "odd" is the extreme of the same thing: every labelled page is its own run. +func TestOddPagesAreEachTheirOwnRun(t *testing.T) { + d := labelled(t, 6, LabelRule{Pages: "odd", Style: LabelDecimal, Continue: true}) + if got := labelsOf(t, d); strings.Join(got, ",") != "1,,2,,3," { + t.Errorf("labels = %v, want 1,,2,,3,", got) + } +} + +// A later rule wins where two overlap, which is how a correction is made +// without rewriting the earlier rules. +func TestALaterRuleWins(t *testing.T) { + d := labelled(t, 4, + LabelRule{Style: LabelDecimal}, + LabelRule{Pages: "3-4", Style: LabelUpperRoman, Start: 10}, + ) + if got := labelsOf(t, d); strings.Join(got, ",") != "1,2,X,XI" { + t.Errorf("labels = %v, want 1,2,X,XI", got) + } +} + +// A style of none is the prefix alone, which is how a run of pages is marked +// "Cover" or "Insert". +func TestAStyleOfNoneIsThePrefixAlone(t *testing.T) { + d := labelled(t, 3, + LabelRule{Pages: "1", Prefix: "Cover"}, + LabelRule{Pages: "2-3", Style: LabelDecimal}, + ) + if got := labelsOf(t, d); strings.Join(got, ",") != "Cover,1,2" { + t.Errorf("labels = %v, want Cover,1,2", got) + } +} + +func TestStart(t *testing.T) { + d := labelled(t, 3, LabelRule{Style: LabelDecimal, Start: 42}) + if got := labelsOf(t, d); strings.Join(got, ",") != "42,43,44" { + t.Errorf("labels = %v", got) + } +} + +// ⛔ The format's own rule, not a choice here: ISO 32000-1 table 159 says /St +// shall be at least 1, so a zero-based scheme is refused rather than written +// out of specification. BentoPDF's tool offers "set to 0 for zero-based +// numbering", and what that writes is a value the standard forbids. +func TestAStartBelowOneIsRefused(t *testing.T) { + d, err := Open(simple(t, 2)) + if err != nil { + t.Fatal(err) + } + err = d.SetPageLabels([]LabelRule{{Style: LabelDecimal, Start: -1}}) + if err == nil { + t.Fatal("a negative start was accepted") + } + if !strings.Contains(err.Error(), "at least 1") { + t.Errorf("the refusal does not say why: %v", err) + } + // And zero is the DEFAULT of one rather than a refusal, because zero is + // the Go zero value and a caller who did not set Start meant "from one". + if err := d.SetPageLabels([]LabelRule{{Style: LabelDecimal}}); err != nil { + t.Fatal(err) + } + if got := labelsOf(t, d); strings.Join(got, ",") != "1,2" { + t.Errorf("an unset Start gave %v, want 1,2", got) + } +} + +func TestRefusals(t *testing.T) { + d, err := Open(simple(t, 2)) + if err != nil { + t.Fatal(err) + } + if err := d.SetPageLabels([]LabelRule{{Style: "Q"}}); err == nil { + t.Error("a style the format does not have was accepted") + } + if err := d.SetPageLabels([]LabelRule{{Pages: "not a range"}}); err == nil { + t.Error("a range that is not one was accepted") + } + if err := New().SetPageLabels([]LabelRule{{Style: LabelDecimal}}); err == nil { + t.Error("an empty document was labelled") + } + // No rules removes the labels. + if err := d.SetPageLabels(nil); err != nil { + t.Fatal(err) + } + if got := labelsOf(t, d); got != nil { + t.Errorf("labels = %v, want none", got) + } +} + +// ⛔⛔ THE WITNESS FOR THE WHOLE CHANGE. Until this, every verb dropped the +// page labels: a document whose front matter was numbered i, ii, iii came back +// from a watermark numbered 1, 2, 3. The negative control is below. +func TestAVerbThatKeepsThePagesKeepsTheirLabels(t *testing.T) { + d := labelled(t, 5, + LabelRule{Pages: "1-2", Style: LabelLowerRoman}, + LabelRule{Pages: "3-5", Style: LabelDecimal}, + ) + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + // Open it afresh — as a caller of the command would — and stamp it. + stamped, err := Open(out) + if err != nil { + t.Fatal(err) + } + if err := stamped.Watermark("all", "DRAFT"); err != nil { + t.Fatal(err) + } + got := labelsOf(t, stamped) + if strings.Join(got, ",") != "i,ii,1,2,3" { + t.Errorf("after a watermark the labels are %v, want i,ii,1,2,3", got) + } +} + +// ⛔ And a verb that MOVES the pages moves their labels with them. A +// /PageLabels tree is keyed by page index, so copying it unchanged would leave +// every label on the wrong page — which is why the labels are asked for per +// page rather than carried as a tree. +func TestAVerbThatMovesThePagesMovesTheirLabels(t *testing.T) { + d := labelled(t, 4, + LabelRule{Pages: "1-2", Style: LabelLowerRoman}, + LabelRule{Pages: "3-4", Style: LabelDecimal}, + ) + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + reversed, err := Open(out) + if err != nil { + t.Fatal(err) + } + reversed.Reverse() + // The pages came out i, ii, 1, 2 and are now in the opposite order, so + // the labels must be too. + got := labelsOf(t, reversed) + if strings.Join(got, ",") != "2,1,ii,i" { + t.Errorf("after a reverse the labels are %v, want 2,1,ii,i", got) + } +} + +// Selecting pages takes their labels, and only theirs. +func TestSelectingPagesTakesTheirLabels(t *testing.T) { + d := labelled(t, 6, LabelRule{Style: LabelDecimal, Start: 100}) + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + some, err := Open(out) + if err != nil { + t.Fatal(err) + } + if err := some.Select("2,5"); err != nil { + t.Fatal(err) + } + if got := labelsOf(t, some); strings.Join(got, ",") != "101,104" { + t.Errorf("labels = %v, want 101,104", got) + } +} + +// Merging two labelled documents keeps each one's labels on its own pages. +func TestAMergeKeepsEachSourcesLabels(t *testing.T) { + first := labelled(t, 2, LabelRule{Style: LabelLowerRoman}) + firstBytes, err := first.Bytes() + if err != nil { + t.Fatal(err) + } + second := labelled(t, 2, LabelRule{Style: LabelDecimal, Prefix: "B-"}) + secondBytes, err := second.Bytes() + if err != nil { + t.Fatal(err) + } + a, err := Open(firstBytes) + if err != nil { + t.Fatal(err) + } + b, err := Open(secondBytes) + if err != nil { + t.Fatal(err) + } + a.Append(b) + if got := labelsOf(t, a); strings.Join(got, ",") != "i,ii,B-1,B-2" { + t.Errorf("labels = %v, want i,ii,B-1,B-2", got) + } +} + +// ⛔ A document with NO labels must still come out with none. Without this, +// everything above would pass on an implementation that invented decimal +// labels for every file — which a viewer shows identically to no labels at +// all, so nothing else here could tell the difference. +func TestADocumentWithNoLabelsGetsNone(t *testing.T) { + d, err := Open(simple(t, 3)) + if err != nil { + t.Fatal(err) + } + if got := d.PageLabels(); got != nil { + t.Errorf("an unlabelled document reports %v", got) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(out), "PageLabels") { + t.Error("a /PageLabels tree was written for a document that has none") + } +} + +// ⛔ The runs are compressed: three hundred decimal pages are ONE entry, not +// three hundred. A viewer counts forward from a run's start, so writing an +// entry per page is correct and makes a file a reader must walk three hundred +// nodes of to answer "what is page two called". +func TestARunOfLabelsIsOneEntry(t *testing.T) { + d := labelled(t, 60, LabelRule{Style: LabelDecimal}) + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + // The entry is "0 << /S /D >>"; a per-page tree would hold sixty of them. + if n := strings.Count(string(out), "/S /D"); n != 1 { + t.Errorf("%d decimal entries for one run of sixty pages, want 1", n) + } + // And the labels are still right at both ends. + got := labelsOf(t, d) + if len(got) != 60 || got[0] != "1" || got[59] != "60" { + t.Errorf("labels run %q … %q", got[0], got[len(got)-1]) + } +} + +// ⛔⛔ A PAGE LABEL'S LETTERS ARE NOT A SPREADSHEET'S COLUMNS. ISO 32000-1 +// table 159: "A to Z for the first 26 pages, AA to ZZ for the next 26, and so +// on" — so 27 is AA and 28 is BB, where a spreadsheet's 28 is AB. Borrowing +// the spreadsheet rule, which is the one everybody has written before, would +// relabel every appendix past the twenty-sixth. +func TestLabelLettersAreNotSpreadsheetColumns(t *testing.T) { + for n, want := range map[int]string{ + 1: "A", 2: "B", 26: "Z", + 27: "AA", 28: "BB", 52: "ZZ", + 53: "AAA", 54: "BBB", + } { + if got := letters(n); got != want { + t.Errorf("letters(%d) = %q, want %q", n, got, want) + } + } + // The spreadsheet answer for 28 is AB. If it ever comes out of this, the + // rule has been replaced by the familiar one. + if got := letters(28); got == "AB" { + t.Error("letters(28) = \"AB\", which is the spreadsheet rule, not the PDF one") + } + // And back again, for every value a run is built from. + for n := 1; n <= 60; n++ { + got, ok := fromLetters(letters(n)) + if !ok || got != n { + t.Errorf("fromLetters(letters(%d)) = %d, %v", n, got, ok) + } + } + if _, ok := fromLetters("AB"); ok { + t.Error("fromLetters read \"AB\", which this style never writes") + } + for _, s := range []string{"", "a", "A1", "ABA"} { + if _, ok := fromLetters(s); ok { + t.Errorf("fromLetters(%q) said yes", s) + } + } +} + +func TestRomanBothWays(t *testing.T) { + for n, want := range map[int]string{ + 1: "I", 4: "IV", 9: "IX", 14: "XIV", 40: "XL", 90: "XC", + 400: "CD", 900: "CM", 1987: "MCMLXXXVII", 3999: "MMMCMXCIX", + } { + if got := roman(n); got != want { + t.Errorf("roman(%d) = %q, want %q", n, got, want) + } + } + for n := 1; n <= 3999; n++ { + got, ok := fromRoman(roman(n)) + if !ok || got != n { + t.Fatalf("fromRoman(roman(%d)) = %d, %v", n, got, ok) + } + } + // ⛔ Past 3999 the additive notation runs out — 4000 needs a bar over the + // M, which neither a PDF string nor a page number has — so the number is + // written in decimal. A label a viewer shows WRONGLY is worse than one it + // shows in the wrong style. + if got := roman(4000); got != "4000" { + t.Errorf("roman(4000) = %q, want the decimal", got) + } + if got := roman(0); got != "0" { + t.Errorf("roman(0) = %q", got) + } + // ⛔ A numeral must be the CANONICAL spelling of its value: "IIII" is four + // to a person and is not what roman(4) writes, so reading it as four would + // build a run that renumbers the page after it. + for _, s := range []string{"IIII", "VV", "IC", "", "Q", "ivi"} { + if n, ok := fromRoman(s); ok { + t.Errorf("fromRoman(%q) = %d, want not a numeral", s, n) + } + } +} + +// ⛔ A leading zero is not a decimal label: "007" and "7" are the same number +// and different labels, so reading it as part of a run would renumber "008" +// as "8". +func TestALeadingZeroIsNotDecimal(t *testing.T) { + if _, ok := LabelDecimal.value("007"); ok { + t.Error("\"007\" was read as decimal") + } + if n, ok := LabelDecimal.value("7"); !ok || n != 7 { + t.Errorf("\"7\" = %d, %v", n, ok) + } + if _, ok := LabelDecimal.value("0"); ok { + t.Error("\"0\" was read as a label number, which must be at least 1") + } +} + +// 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. +func TestALabelNoStyleCanExplainIsWrittenLiterally(t *testing.T) { + d := labelled(t, 3, + LabelRule{Pages: "1", Prefix: "Front cover"}, + LabelRule{Pages: "2", Prefix: "Inside"}, + LabelRule{Pages: "3", Style: LabelDecimal}, + ) + if got := labelsOf(t, d); strings.Join(got, ",") != "Front cover,Inside,1" { + t.Errorf("labels = %v", got) + } +} + +// A document whose labels cannot be resolved is a document with broken labels, +// not one that cannot be read. +func TestALabelTreeThatPointsAtNothingIsNoLabels(t *testing.T) { + d, err := Open(simple(t, 2)) + if err != nil { + t.Fatal(err) + } + if got := d.PageLabels(); got != nil { + t.Errorf("labels = %v", got) + } +} + +// labelledPDF builds a file whose catalogue carries the given /PageLabels +// object, so a test can hand this reader a tree no writer of ours would make. +func labelledPDF(t *testing.T, pages int, tree func(w *reader.Writer) reader.Object) []byte { + t.Helper() + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + kids := make(reader.Array, 0, pages) + for i := 1; i <= pages; i++ { + kids = append(kids, w.Add(reader.Dict{ + "Type": reader.Name("Page"), "Parent": pagesRef, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte{}}), + })) + } + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), "Kids": kids, + "Count": reader.Integer(pages), + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), reader.Integer(100), reader.Integer(200)}}) + catalog := reader.Dict{"Type": reader.Name("Catalog"), "Pages": pagesRef} + if v := tree(w); v != nil { + catalog["PageLabels"] = v + } + out, err := w.Finish(reader.Dict{"Root": w.Add(catalog)}) + if err != nil { + t.Fatal(err) + } + return out +} + +func labelsFrom(t *testing.T, b []byte) []string { + t.Helper() + d, err := Open(b) + if err != nil { + t.Fatal(err) + } + return d.PageLabels() +} + +// ⛔ A page-label tree somebody else wrote can be anything at all, and a +// reader that walked it trustingly is a reader that panics on a file. Each of +// these is a shape the format allows or a producer has written, and every one +// of them must come back as "no labels" or as the labels that ARE readable — +// never as a crash and never as an error that loses the document. +func TestATreeThisWriterWouldNeverMakeIsStillRead(t *testing.T) { + for name, tree := range map[string]func(w *reader.Writer) reader.Object{ + "no tree at all": func(*reader.Writer) reader.Object { return nil }, + "not a dictionary": func(*reader.Writer) reader.Object { + return reader.Integer(7) + }, + "a dictionary with no Nums": func(w *reader.Writer) reader.Object { + return w.Add(reader.Dict{"Limits": reader.Array{}}) + }, + "Nums that is not an array": func(w *reader.Writer) reader.Object { + return w.Add(reader.Dict{"Nums": reader.Integer(1)}) + }, + "an odd-length Nums": func(w *reader.Writer) reader.Object { + return w.Add(reader.Dict{"Nums": reader.Array{reader.Integer(0)}}) + }, + "a key that is not a number": func(w *reader.Writer) reader.Object { + return w.Add(reader.Dict{"Nums": reader.Array{ + reader.Name("zero"), reader.Dict{"S": reader.Name("D")}, + }}) + }, + "a value that is not a dictionary": func(w *reader.Writer) reader.Object { + return w.Add(reader.Dict{"Nums": reader.Array{ + reader.Integer(0), reader.Name("D"), + }}) + }, + "a negative key": func(w *reader.Writer) reader.Object { + return w.Add(reader.Dict{"Nums": reader.Array{ + reader.Integer(-5), reader.Dict{"S": reader.Name("D")}, + }}) + }, + "a start below one": func(w *reader.Writer) reader.Object { + return w.Add(reader.Dict{"Nums": reader.Array{ + reader.Integer(0), reader.Dict{"S": reader.Name("D"), "St": reader.Integer(0)}, + }}) + }, + "a style the format does not have": func(w *reader.Writer) reader.Object { + return w.Add(reader.Dict{"Nums": reader.Array{ + reader.Integer(0), reader.Dict{"S": reader.Name("Q")}, + }}) + }, + } { + t.Run(name, func(t *testing.T) { + b := labelledPDF(t, 3, tree) + // It must open, and it must not panic. + got := labelsFrom(t, b) + // And it must survive being written out again. + d, err := Open(b) + if err != nil { + t.Fatal(err) + } + if _, err := d.Bytes(); err != nil { + t.Errorf("writing it back: %v", err) + } + t.Logf("labels = %q", got) + }) + } +} + +// ⛔ A tree of KIDS rather than one flat Nums, which is what a producer writes +// for a long document — and the depth is bounded, because a file can point a +// kid back at its parent and a reader following /Kids follows it for ever. +func TestATreeOfKids(t *testing.T) { + b := labelledPDF(t, 4, func(w *reader.Writer) reader.Object { + left := w.Add(reader.Dict{"Nums": reader.Array{ + reader.Integer(0), reader.Dict{"S": reader.Name("r")}, + }}) + right := w.Add(reader.Dict{"Nums": reader.Array{ + reader.Integer(2), reader.Dict{"S": reader.Name("D")}, + }}) + return w.Add(reader.Dict{"Kids": reader.Array{left, right}}) + }) + got := labelsFrom(t, b) + if strings.Join(got, ",") != "i,ii,1,2" { + t.Errorf("labels = %v, want i,ii,1,2", got) + } +} + +func TestATreeThatPointsBackAtItselfIsBounded(t *testing.T) { + b := labelledPDF(t, 2, func(w *reader.Writer) reader.Object { + loop := w.Reserve() + w.Put(loop, reader.Dict{"Kids": reader.Array{loop}}) + return loop + }) + // It must come back rather than recurse for ever. + done := make(chan []string, 1) + go func() { done <- labelsFrom(t, b) }() + select { + case got := <-done: + if len(got) != 0 { + t.Errorf("a tree that is nothing but a loop gave %v", got) + } + case <-time.After(10 * time.Second): + t.Fatal("reading a looping tree did not come back") + } +} + +// The styles' own edges, which no document reaches but a caller can. +func TestTheStylesOwnEdges(t *testing.T) { + if got := LabelNone.format(7); got != "" { + t.Errorf("LabelNone.format(7) = %q, want empty", got) + } + if got := LabelStyle("Q").format(7); got != "" { + t.Errorf("an unknown style formatted %q", got) + } + if got := letters(0); got != "" { + t.Errorf("letters(0) = %q", got) + } + if got := LabelUpperLetters.format(27); got != "AA" { + t.Errorf("LabelUpperLetters.format(27) = %q", got) + } + if got := LabelLowerLetters.format(27); got != "aa" { + t.Errorf("LabelLowerLetters.format(27) = %q", got) + } + // value says no to an empty string, to the wrong case, and to a style it + // does not know. + for _, c := range []struct { + s LabelStyle + text string + }{ + {LabelDecimal, ""}, {LabelLowerRoman, "IV"}, {LabelUpperRoman, "iv"}, + {LabelLowerLetters, "AA"}, {LabelUpperLetters, "aa"}, + {LabelNone, "x"}, {LabelStyle("Q"), "x"}, + } { + if n, ok := c.s.value(c.text); ok { + t.Errorf("%q.value(%q) = %d, want no", string(c.s), c.text, n) + } + } + // And the lower-case styles do read their own case. + if n, ok := LabelLowerRoman.value("iv"); !ok || n != 4 { + t.Errorf("LabelLowerRoman.value(\"iv\") = %d, %v", n, ok) + } + if n, ok := LabelLowerLetters.value("aa"); !ok || n != 27 { + t.Errorf("LabelLowerLetters.value(\"aa\") = %d, %v", n, ok) + } +} + +// A rule whose range names pages outside the document is clipped rather than +// refused: "1-999" over five pages means those five. +func TestARangeBeyondTheDocumentIsClipped(t *testing.T) { + d, err := Open(simple(t, 3)) + if err != nil { + t.Fatal(err) + } + if err := d.SetPageLabels([]LabelRule{{Pages: "1-", Style: LabelDecimal}}); err != nil { + t.Fatal(err) + } + if got := labelsOf(t, d); strings.Join(got, ",") != "1,2,3" { + t.Errorf("labels = %v", got) + } +} + +// The three remaining paths a document can take through this reader: a +// catalogue that cannot be read, a reference that points at nothing, and the +// upper-case letters style reading its own case. +func TestTheLastThreePaths(t *testing.T) { + t.Run("upper-case letters read their own case", func(t *testing.T) { + if n, ok := LabelUpperLetters.value("AA"); !ok || n != 27 { + t.Errorf("LabelUpperLetters.value(\"AA\") = %d, %v; want 27", n, ok) + } + }) + t.Run("a reference that points at nothing", func(t *testing.T) { + // ⛔ A dangling reference in a label tree means THIS PAGE HAS NO + // LABEL. A file whose labels point at objects that are gone is a file + // with broken labels, not one that cannot be read, and refusing it + // would lose the document over its toolbar captions. + b := labelledPDF(t, 2, func(w *reader.Writer) reader.Object { + // A reference to an object number nothing was ever put at. + return reader.Ref{Num: 9999} + }) + d, err := Open(b) + if err != nil { + t.Fatalf("a file with a dangling label tree would not open: %v", err) + } + if got := d.PageLabels(); len(got) != 0 { + t.Errorf("labels = %v, want none", got) + } + if _, err := d.Bytes(); err != nil { + t.Errorf("writing it back: %v", err) + } + }) +} diff --git a/write.go b/write.go index 6367b42..13e99f9 100644 --- a/write.go +++ b/write.go @@ -92,6 +92,15 @@ func (d *Doc) Bytes() ([]byte, error) { if outlines := d.writeOutlines(w, where, refs); outlines != nil { catalog["Outlines"] = outlines } + // ⛔ Page labels are written here rather than carried by keepCatalogue with + // the other document keys, because a /PageLabels tree is keyed by page + // INDEX: copied across a rebuild that reordered, removed or merged pages + // it would leave every label on the wrong page. They are asked for per + // page instead. Until this, every verb silently dropped them — a document + // that was only stamped came back with its front matter renumbered. + if labels := d.writePageLabels(w); labels != nil { + catalog["PageLabels"] = labels + } trailer := reader.Dict{"Root": w.Add(catalog)} if len(d.info) > 0 { trailer["Info"] = w.Add(d.info)