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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,27 @@ 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
```

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
Expand Down
121 changes: 121 additions & 0 deletions cmd/pdfops/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strconv"
"strings"

"github.com/go-pdfkit/extract"
"github.com/go-pdfkit/ops"
"github.com/go-pdfkit/reader"
)
Expand Down Expand Up @@ -55,6 +56,8 @@ var commands = []command{
{"encrypt", "-user <password> [-owner <password>] [-allow <what>] [-aes128] <in.pdf> <out.pdf>", "protect the file with a password", runEncrypt},
{"decrypt", "<in.pdf> <out.pdf>", "write the file without its protection", runDecrypt},
{"permissions", "<in.pdf>", "say how the file is protected and what it allows", runPermissions},
{"text", "[-pages <range>] [-layout] <in.pdf>", "read the text off the pages", runText},
{"images", "[-pages <range>] <in.pdf> <out-directory>", "write out the pictures the pages place", runImages},
}

// run is the whole program, so that the tests can drive it.
Expand Down Expand Up @@ -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, "<in.pdf>"); 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, "<in.pdf> <out-directory>"); 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)
}
210 changes: 210 additions & 0 deletions cmd/pdfops/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"testing"

"github.com/go-pdfkit/extract"
"github.com/go-pdfkit/ops"
"github.com/go-pdfkit/reader"
)
Expand Down Expand Up @@ -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)
}
}
}
Loading
Loading