diff --git a/README.md b/README.md index 1646965..9097605 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,13 @@ pdfops stamp -text "seen 25 August" -at top-right -bold notes.pdf seen.pdf pdfops sanitize downloaded.pdf safe.pdf pdfops flatten filled-form.pdf final.pdf pdfops strip -annotations -bookmarks private.pdf clean.pdf +pdfops compress fat.pdf lean.pdf +pdfops encrypt -user letmein -allow print,copy plain.pdf locked.pdf +pdfops -password letmein decrypt locked.pdf plain.pdf +pdfops -password letmein permissions locked.pdf +pdfops text paper.pdf +pdfops text -layout -pages 1 paper.pdf +pdfops images paper.pdf pictures/ pdfops info file.pdf ``` @@ -54,7 +61,13 @@ A page range is written `1-3,7,10-` and may say `all`, `even`, `odd` or `last`. It keeps its own order and its own repeats, so `select -pages 3-1` reverses three pages and `select -pages 1,1` gives you two copies. -`-password` opens an encrypted file; what is written out is not encrypted. +`-password` opens an encrypted file. + +`text` reads the page back as words, and `-layout` says where every piece of +it sits — a page, a place, a size, and what it says. A piece the document +gave no way to read comes back marked rather than guessed at, so you can tell +a page that says nothing from one that could not be read. `images` writes out +the pictures a page places, a JPEG as a JPEG. Links and bookmarks are carried over and pointed at the pages they became here, so extracting three pages of a book leaves the links between those diff --git a/cmd/pdfops/run.go b/cmd/pdfops/run.go index 40bda93..5f3dad8 100644 --- a/cmd/pdfops/run.go +++ b/cmd/pdfops/run.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" + "github.com/go-pdfkit/extract" "github.com/go-pdfkit/ops" "github.com/go-pdfkit/reader" ) @@ -55,6 +56,8 @@ var commands = []command{ {"encrypt", "-user [-owner ] [-allow ] [-aes128] ", "protect the file with a password", runEncrypt}, {"decrypt", " ", "write the file without its protection", runDecrypt}, {"permissions", "", "say how the file is protected and what it allows", runPermissions}, + {"text", "[-pages ] [-layout] ", "read the text off the pages", runText}, + {"images", "[-pages ] ", "write out the pictures the pages place", runImages}, } // run is the whole program, so that the tests can drive it. @@ -778,3 +781,121 @@ func openedAs(owner bool) string { } return "the user" } + +// runText reads the text off a document and prints it. +func runText(c *context, args []string) error { + fs := flags("text") + spec := fs.String("pages", "all", "which pages to read") + layout := fs.Bool("layout", false, "print where each piece of text sits as well as what it says") + if err := fs.Parse(args); err != nil { + return err + } + if err := wantArgs(fs, 1, ""); err != nil { + return err + } + src, err := c.read(fs.Arg(0)) + if err != nil { + return err + } + pages, err := ops.ParseRange(*spec, src.PageCount()) + if err != nil { + return err + } + for _, page := range pages { + // Every page here came from the range, so it is one the document + // has; neither of these can fail. + if *layout { + runs, _ := extract.Runs(src, page) + for _, r := range runs { + fmt.Fprintf(c.out, "%d\t%.2f\t%.2f\t%.2f\t%s%s\n", + page, r.X, r.Y, r.Size, marks(r), r.Text) + } + continue + } + text, _ := extract.Text(src, page) + fmt.Fprintln(c.out, text) + } + return nil +} + +// marks says what is unusual about a run, in front of what it says. +func marks(r extract.Run) string { + switch { + case r.Invisible && r.Unreadable: + return "[invisible, part unreadable] " + case r.Invisible: + return "[invisible] " + case r.Unreadable: + return "[part unreadable] " + } + return "" +} + +// runImages writes out the pictures a document places. +func runImages(c *context, args []string) error { + fs := flags("images") + spec := fs.String("pages", "all", "which pages to take pictures from") + if err := fs.Parse(args); err != nil { + return err + } + if err := wantArgs(fs, 2, " "); err != nil { + return err + } + src, err := c.read(fs.Arg(0)) + if err != nil { + return err + } + pages, err := ops.ParseRange(*spec, src.PageCount()) + if err != nil { + return err + } + dir := fs.Arg(1) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + n := 0 + for _, page := range pages { + // The range only names pages the document has. + images, _ := extract.Images(src, page) + for i, im := range images { + name := fmt.Sprintf("page%03d-%02d%s", page, i+1, imageSuffix(im)) + path := filepath.Join(dir, name) + if err := os.WriteFile(path, im.Data, 0o644); err != nil { + return err + } + fmt.Fprintf(c.out, "%s\t%dx%d\tat %.1f,%.1f\tdrawn %.1fx%.1f\n", + name, im.Width, im.Height, im.X, im.Y, im.DrawnWidth, im.DrawnHeight) + n++ + } + } + if n == 0 { + fmt.Fprintln(c.out, "no pictures") + } + return nil +} + +// imageSuffix names a picture by what it holds. A JPEG is written out as one; +// anything this has unfiltered into plain samples is written as it stands, +// since turning samples into pixels means reading a colour space and that is +// the renderer's work. +func imageSuffix(im extract.Image) string { + switch im.Filter { + case "DCTDecode": + return ".jpg" + case "JPXDecode": + return ".jp2" + case "JBIG2Decode": + return ".jbig2" + } + return ".samples" +} + +// read opens a document rather than a document being assembled, which is what +// reading a page back needs. +func (c *context) read(path string) (*reader.Document, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return reader.OpenWithPassword(b, c.password) +} diff --git a/cmd/pdfops/run_test.go b/cmd/pdfops/run_test.go index 5789082..f9bf5db 100644 --- a/cmd/pdfops/run_test.go +++ b/cmd/pdfops/run_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/go-pdfkit/extract" "github.com/go-pdfkit/ops" "github.com/go-pdfkit/reader" ) @@ -636,3 +637,212 @@ func TestTheVerbsThatProtectRefuseWhatTheyShould(t *testing.T) { } } } + +// pageWithText writes a document whose page carries real text and a picture, +// so that reading it back can be checked. +func pageWithText(t *testing.T) 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"), + }) + image := w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(2), "Height": reader.Integer(2), + "ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(8), + }, Raw: []byte{0, 64, 128, 255}}) + jpeg := w.Add(&reader.Stream{Dict: reader.Dict{ + "Subtype": reader.Name("Image"), "Width": reader.Integer(1), + "Height": reader.Integer(1), "Filter": reader.Name("DCTDecode"), + }, Raw: []byte("pretend")}) + kids := make(reader.Array, 0, 2) + for i := 1; i <= 2; i++ { + content := fmt.Sprintf("BT /F1 12 Tf 20 100 Td (page %d says hello) Tj ET "+ + "q 20 0 0 20 10 10 cm /Im Do Q q 5 0 0 5 50 50 cm /Jp Do Q", i) + kids = append(kids, w.Add(reader.Dict{ + "Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(200), reader.Integer(200)}, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte(content)}), + "Resources": reader.Dict{ + "Font": reader.Dict{"F1": font}, + "XObject": reader.Dict{"Im": image, "Jp": jpeg}, + }, + })) + } + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": kids, "Count": reader.Integer(len(kids))}) + 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(), "text.pdf") + if err := os.WriteFile(path, out, 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestTextVerb(t *testing.T) { + in := pageWithText(t) + code, out, errOut := exec("text", in) + if code != 0 { + t.Fatalf("text said %d: %s", code, errOut) + } + for _, want := range []string{"page 1 says hello", "page 2 says hello"} { + if !strings.Contains(out, want) { + t.Errorf("the text does not hold %q:\n%s", want, out) + } + } + if _, out, _ = exec("text", "-pages", "2", in); strings.Contains(out, "page 1") { + t.Errorf("a range of one page gave:\n%s", out) + } + // The layout form says where every piece sits. + code, out, errOut = exec("text", "-layout", "-pages", "1", in) + if code != 0 { + t.Fatalf("text -layout said %d: %s", code, errOut) + } + if !strings.Contains(out, "\t20.00\t100.00\t12.00\t") { + t.Errorf("the layout does not say where the text is:\n%s", out) + } + for _, args := range [][]string{ + {"text"}, + {"text", "nowhere.pdf"}, + {"text", "-nope", in}, + {"text", "-pages", "nonsense", in}, + } { + if code, _, _ := exec(args...); code == 0 { + t.Errorf("%v was accepted", args) + } + } +} + +func TestTextVerbSaysWhatItCouldNotRead(t *testing.T) { + // A run nothing in the document could name is marked rather than left + // out, and so is one drawn with no ink. + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + font := w.Add(reader.Dict{ + "Type": reader.Name("Font"), "Subtype": reader.Name("Type1"), + "FontDescriptor": w.Add(reader.Dict{"Flags": reader.Integer(4)}), + }) + page := w.Add(reader.Dict{ + "Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(200), reader.Integer(200)}, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, + Raw: []byte("BT 3 Tr /F1 12 Tf 20 100 Td (\x01\x02) Tj ET")}), + "Resources": reader.Dict{"Font": reader.Dict{"F1": font}}, + }) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{page}, "Count": reader.Integer(1)}) + 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(), "unreadable.pdf") + if err := os.WriteFile(path, out, 0o644); err != nil { + t.Fatal(err) + } + _, printed, _ := exec("text", "-layout", path) + if !strings.Contains(printed, "[invisible, part unreadable]") { + t.Errorf("the run was not marked:\n%s", printed) + } +} + +func TestImagesVerb(t *testing.T) { + in := pageWithText(t) + dir := filepath.Join(t.TempDir(), "pictures") + code, out, errOut := exec("images", in, dir) + if code != 0 { + t.Fatalf("images said %d: %s", code, errOut) + } + if !strings.Contains(out, "page001-01.samples") || !strings.Contains(out, "page001-02.jpg") { + t.Errorf("the pictures are:\n%s", out) + } + files, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(files) != 4 { + t.Errorf("%d files were written", len(files)) + } + jpg, err := os.ReadFile(filepath.Join(dir, "page001-02.jpg")) + if err != nil { + t.Fatal(err) + } + if string(jpg) != "pretend" { + t.Errorf("the JPEG was changed on the way out: %q", jpg) + } + // A document with no pictures says so. + plain := fixture(t, 1) + if _, out, _ = exec("images", plain, filepath.Join(t.TempDir(), "none")); !strings.Contains(out, "no pictures") { + t.Errorf("a document with no pictures said:\n%s", out) + } + for _, args := range [][]string{ + {"images", in}, + {"images", "nowhere.pdf", dir}, + {"images", "-nope", in, dir}, + {"images", "-pages", "nonsense", in, dir}, + {"images", in, "/dev/null/cannot"}, + } { + if code, _, _ := exec(args...); code == 0 { + t.Errorf("%v was accepted", args) + } + } +} + +func TestHowARunIsMarked(t *testing.T) { + for _, c := range []struct { + run extract.Run + want string + }{ + {extract.Run{}, ""}, + {extract.Run{Invisible: true}, "[invisible] "}, + {extract.Run{Unreadable: true}, "[part unreadable] "}, + {extract.Run{Invisible: true, Unreadable: true}, "[invisible, part unreadable] "}, + } { + if got := marks(c.run); got != c.want { + t.Errorf("%+v is marked %q, want %q", c.run, got, c.want) + } + } +} + +func TestAPictureThatCannotBeWritten(t *testing.T) { + // Somewhere to put the pictures where one of them cannot go: a directory + // already standing where a file should be written. + in := pageWithText(t) + dir := filepath.Join(t.TempDir(), "pictures") + if err := os.MkdirAll(filepath.Join(dir, "page001-01.samples"), 0o755); err != nil { + t.Fatal(err) + } + if code, _, _ := exec("images", in, dir); code == 0 { + t.Error("writing over a directory was accepted") + } +} + +func TestEveryWayAPictureIsNamed(t *testing.T) { + for _, c := range []struct { + filter reader.Name + want string + }{ + {"DCTDecode", ".jpg"}, + {"JPXDecode", ".jp2"}, + {"JBIG2Decode", ".jbig2"}, + {"", ".samples"}, + } { + if got := imageSuffix(extract.Image{Filter: c.filter}); got != c.want { + t.Errorf("%q is written as %q, want %q", c.filter, got, c.want) + } + } +} diff --git a/go.mod b/go.mod index 5224f40..2719c57 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,12 @@ module github.com/go-pdfkit/ops go 1.26.4 -require github.com/go-pdfkit/reader v0.4.0 +require ( + github.com/go-pdfkit/extract v0.1.0 + github.com/go-pdfkit/reader v0.4.0 +) + +require ( + github.com/go-opentype/opentype v0.9.0 // indirect + github.com/go-pdfkit/pdffont v0.1.0 // indirect +) diff --git a/go.sum b/go.sum index 3ff081a..0244ea4 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,10 @@ +github.com/go-opentype/fonts v0.8.0 h1:77i3VPIH90GbstzNb21mk+an4WvEOe2idC6W+J0n0fw= +github.com/go-opentype/fonts v0.8.0/go.mod h1:C6yQL2apHItfEZ5hztpsHF0S5mlX/hklLlq/Z5fRG/g= +github.com/go-opentype/opentype v0.9.0 h1:GFgcJ3nwTDp4NJr5O+Paw7lhZx5Jv/R+noZwvhYDlkM= +github.com/go-opentype/opentype v0.9.0/go.mod h1:AOixevJf7XQaH7+WG+OMIOZEbYPXfMqklVk26Y6YTUU= +github.com/go-pdfkit/extract v0.1.0 h1:7IJDJMH43l2wJZD+DkW5StmeAS1NDt2FnT2LBceXTog= +github.com/go-pdfkit/extract v0.1.0/go.mod h1:3ejdi87IrdR60wmjzLswdqzBzHCgFlrLtxkMxRN1KSU= +github.com/go-pdfkit/pdffont v0.1.0 h1:mThWvPn3LLETup2adm9xMcNZWECP2Wq3eTSyTW4O3I0= +github.com/go-pdfkit/pdffont v0.1.0/go.mod h1:y4vo5DgT95e57C3XxIWfA/xss+x6RwZwyj6KWdJc86s= github.com/go-pdfkit/reader v0.4.0 h1:qPbNZSO+Xl+4NBvQoV1PYt7HlqHaEAQ1tUc6/IL8JAU= github.com/go-pdfkit/reader v0.4.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8=