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
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
134 changes: 134 additions & 0 deletions cmd/pdfops/expose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
92 changes: 92 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},
{"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},
{"outline", "(-drop | -from <toc.txt>) <in.pdf> <out.pdf>", "replace or remove the bookmarks", runOutline},
Expand Down Expand Up @@ -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, "<in.pdf> <out.pdf>"
if *list {
want, usage = 1, "<in.pdf>"
}
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
}
7 changes: 7 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading