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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pdfops select -pages 3-7,last report.pdf extract.pdf
pdfops delete -pages even scan.pdf fronts.pdf
pdfops rotate -pages all -by 90 sideways.pdf upright.pdf
pdfops turn -by 30 tilted.pdf turned.pdf
pdfops compare draft1.pdf draft2.pdf
pdfops crop -box 20,20,575,820 wide.pdf trimmed.pdf
pdfops resize -to a4 odd-sizes.pdf a4.pdf
pdfops resize -to a4 -landscape slides.pdf wide.pdf
Expand Down Expand Up @@ -252,6 +253,41 @@ the gate is a render: a mark at the top of a square page must come back at the
right-hand edge after +90°, the way a clock's hand goes from twelve to three —
measured at 89°, 90° and 91° so that the seam itself is crossed.

`compare` says what changed between two versions of a document.

```sh
pdfops compare draft1.pdf draft2.pdf
```

```text
- -> 2 page added
2 -> 3 text removed zeta
2 -> 3 text added theta
2 of 3 page(s) changed
```

⛔ **The pages are paired by what they say, not by their numbers.** A page
inserted in the middle moves nothing after it: the pages that follow are still
paired with the pages they came from, and the insertion is one *page added*.
Pairing by number would report that page as an edit of the next, that one as an
edit of the one after, and so on to the end — every page of the document
changed, which is both useless and false.

A pair has to share **half its words**, or it is one page removed and another
added. Without that floor the alignment pairs the least unalike pages it can
find, so a replaced chapter reads as a rewrite of the one before it.

Inside a pair the diff is **Myers** (*An O(ND) Difference Algorithm and Its
Variations*, Algorithmica **1** (1986) 251–266), over words, and the words that
changed together are reported together: *"quick brown fox"* → *"slow grey
mole"* is two changes and not six. Pictures are told apart by a **hash of their
samples**, so a swapped logo of the same size is a change; annotations by their
subtype and their text.

⛔ `compare` **exits 0 whether or not the documents differ**, unlike `diff`: in
this command an exit of 1 means the work could not be done, and a document that
changed is the work being done. The answer is in the last line, in words.

⛔ `divide` makes the **same grid** as `poster` and is the opposite operation.
`poster` keeps every piece the size of the page it came from, so it
**enlarges**: the pieces taped together make one page the size of a wall.
Expand Down
60 changes: 60 additions & 0 deletions cmd/pdfops/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ var commands = []command{
{"reverse", "<in.pdf> <out.pdf>", "put the pages in the opposite order", runReverse},
{"rotate", "-pages <range> -by <degrees> <in.pdf> <out.pdf>", "turn pages by a multiple of ninety", runRotate},
{"turn", "-pages <range> -by <degrees> <in.pdf> <out.pdf>", "turn pages by any angle, clockwise, growing the paper so no corner is cut off", runTurn},
{"compare", "<earlier.pdf> <later.pdf>", "say what changed between two versions of a document", runCompare},
{"crop", "-pages <range> -box <l,b,r,t> <in.pdf> <out.pdf>", "set the visible area, in points", runCrop},
{"resize", "[-pages <range>] (-box <l,b,r,t> | -to <name> [-landscape]) <in.pdf> <out.pdf>", "set how big the pages are", runResize},
{"move", "-from <page> -to <page> <in.pdf> <out.pdf>", "take a page out and put it back elsewhere", runMove},
Expand Down Expand Up @@ -390,6 +391,65 @@ func runSplit(c *context, args []string) error {
return nil
}

// runCompare prints what changed between two documents.
//
// ⛔ IT EXITS 0 WHETHER OR NOT THEY DIFFER. `diff` exits 1 on a difference and
// this verb deliberately does not: in this command an exit of 1 means the work
// could not be done, and a document that changed is the work being done. A
// caller who wants a yes-or-no reads the last line, which says it in words.
func runCompare(c *context, args []string) error {
fs := flags("compare")
if err := fs.Parse(args); err != nil {
return err
}
if err := wantArgs(fs, 2, "<earlier.pdf> <later.pdf>"); err != nil {
return err
}
before, err := os.ReadFile(fs.Arg(0))
if err != nil {
return err
}
after, err := os.ReadFile(fs.Arg(1))
if err != nil {
return err
}
pairs, err := ops.Compare(before, after)
if err != nil {
return err
}
changed := 0
for _, p := range pairs {
if !p.Changed() {
continue
}
changed++
for _, ch := range p.Changes {
fmt.Fprintf(c.out, "%-9s %-18s %s\n", pageName(p), ch.Kind, ch.What)
}
}
switch changed {
case 0:
fmt.Fprintf(c.out, "the two documents say the same thing on all %d page(s)\n", len(pairs))
case 1:
fmt.Fprintf(c.out, "1 of %d page(s) changed\n", len(pairs))
default:
fmt.Fprintf(c.out, "%d of %d page(s) changed\n", changed, len(pairs))
}
return nil
}

// pageName is how a pair of pages is written in the report: "3 -> 4", with a
// dash for the side that has no page.
func pageName(p ops.PagePair) string {
side := func(n int) string {
if n == 0 {
return "-"
}
return strconv.Itoa(n)
}
return side(p.Before) + " -> " + side(p.After)
}

func runInfo(c *context, args []string) error {
fs := flags("info")
if err := fs.Parse(args); err != nil {
Expand Down
94 changes: 94 additions & 0 deletions cmd/pdfops/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,48 @@ func fixture(t *testing.T, n int) string {
return path
}

// wordedFixture writes a document whose pages say what they are given to say,
// as real drawn text, and returns its path. `fixture` above writes content
// streams that draw nothing, which is all most verbs need and is no use at all
// to a verb that compares what two documents SAY.
func wordedFixture(t *testing.T, name string, pages ...string) string {
t.Helper()
w := reader.NewWriter("1.7")
pagesRef := w.Reserve()
widths := make(reader.Array, 0, 224)
for i := 32; i < 256; i++ {
widths = append(widths, reader.Integer(500))
}
font := w.Add(reader.Dict{
"Type": reader.Name("Font"), "Subtype": reader.Name("Type1"),
"BaseFont": reader.Name("Helvetica"), "FirstChar": reader.Integer(32),
"LastChar": reader.Integer(255), "Widths": widths,
"Encoding": reader.Name("WinAnsiEncoding"),
})
kids := make(reader.Array, 0, len(pages))
for _, says := range pages {
content := w.Add(&reader.Stream{Dict: reader.Dict{},
Raw: fmt.Appendf(nil, "BT /F1 10 Tf 20 300 Td (%s) Tj ET\n", says)})
kids = append(kids, w.Add(reader.Dict{
"Type": reader.Name("Page"), "Parent": pagesRef, "Contents": content,
"Resources": reader.Dict{"Font": reader.Dict{"F1": font}},
}))
}
w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), "Kids": kids,
"Count": reader.Integer(len(pages)),
"MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), reader.Integer(300), reader.Integer(400)}})
root := w.Add(reader.Dict{"Type": reader.Name("Catalog"), "Pages": pagesRef})
out, err := w.Finish(reader.Dict{"Root": root})
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, out, 0o644); err != nil {
t.Fatal(err)
}
return path
}

// exec runs the tool and returns its exit code and what it printed.
func exec(args ...string) (int, string, string) {
var out, errOut bytes.Buffer
Expand Down Expand Up @@ -261,6 +303,58 @@ func TestTurn(t *testing.T) {
}
}

// ⛔ `compare` EXITS 0 WHETHER OR NOT THE DOCUMENTS DIFFER, unlike `diff`. In
// this command an exit of 1 means the work could not be done, and a document
// that changed is the work being done — so the answer is in the words, and
// this test reads the words.
func TestCompare(t *testing.T) {
before := wordedFixture(t, "before.pdf", "alpha beta gamma", "delta epsilon zeta")
after := wordedFixture(t, "after.pdf", "alpha beta gamma", "a wholly new page", "delta epsilon theta")

code, printed, msg := exec("compare", before, after)
if code != 0 {
t.Fatalf("code %d: %s", code, msg)
}
for _, want := range []string{
"- -> 2 page added",
"2 -> 3 text removed zeta",
"2 -> 3 text added theta",
"2 of 3 page(s) changed",
} {
if !strings.Contains(printed, want) {
t.Errorf("the report does not hold %q:\n%s", want, printed)
}
}
// A document compared with itself says so in words, and still exits 0.
code, printed, msg = exec("compare", before, before)
if code != 0 {
t.Fatalf("code %d: %s", code, msg)
}
if !strings.Contains(printed, "the two documents say the same thing on all 2 page(s)") {
t.Errorf("comparing a document with itself prints %q", printed)
}
// One page changed is said in the singular.
one := wordedFixture(t, "one.pdf", "alpha beta gamma", "delta epsilon theta")
if _, printed, _ = exec("compare", before, one); !strings.Contains(printed, "1 of 2 page(s) changed") {
t.Errorf("one page changed prints %q", printed)
}
notAPDF := filepath.Join(t.TempDir(), "notes.txt")
if err := os.WriteFile(notAPDF, []byte("this is not a PDF at all"), 0o644); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{
{"compare", before},
{"compare", "-nosuchflag", before, after},
{"compare", "/no/such/file.pdf", after},
{"compare", before, "/no/such/file.pdf"},
{"compare", notAPDF, after},
} {
if code, _, _ := exec(args...); code != 1 {
t.Errorf("%v should fail", args)
}
}
}

func TestCrop(t *testing.T) {
in := fixture(t, 1)
out := filepath.Join(t.TempDir(), "out.pdf")
Expand Down
Loading
Loading