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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ pdfops tables -as md -pages 3 accounts.pdf
pdfops tables -as json -to tables/ accounts.pdf
pdfops markdown report.pdf > report.md
pdfops markdown -json report.pdf > report-for-indexing.json
pdfops word report.pdf report.docx
pdfops excel accounts.pdf accounts.xlsx
pdfops images paper.pdf pictures/
pdfops attachments report.pdf
pdfops attachments -to files/ report.pdf
Expand Down Expand Up @@ -379,6 +381,27 @@ old code as `\xfe\xffL\x00a\x00…`. `info` printed it that way too, for as long
as the verb had existed; both now go through
[`reader.TextString`](https://github.com/go-pdfkit/reader).

`word` and `excel` write that same reading out as a `.docx` or an `.xlsx`,
through [`go-ooxml`](https://github.com/go-ooxml/ooxml).

```sh
pdfops word report.pdf report.docx
pdfops excel accounts.pdf accounts.xlsx
```

⛔ **What comes out is the document, not the pages.** The fonts, the margins,
the line breaks and the page breaks are the typesetter's work; a `.docx` that
reproduced them would be a picture of a PDF rather than something anybody can
edit.

⛔ **`excel` writes only the tables**, one sheet each. A spreadsheet is a grid
of cells and a document is not, so the prose around a table has nowhere to go
— and a page with no tables gives the one empty sheet a workbook has to have
rather than an error.

Both are the same function with a different writer, which is why `-pages`
means the same thing in each and a defect found in one is fixed in both.

`textcolour` paints the text one colour and leaves the rest of the page alone.

```sh
Expand Down
69 changes: 69 additions & 0 deletions cmd/pdfops/office.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package main

import (
"fmt"
"os"

"github.com/go-ooxml/ooxml"
"github.com/go-pdfkit/document"
"github.com/go-pdfkit/ops"
"github.com/go-richdoc/richdoc"
)

// runWord writes the pages back out as a Word document.
//
// ⛔ What comes out is the DOCUMENT this read off the pages — headings,
// paragraphs, lists and tables — and not the pages themselves. The fonts, the
// margins, the line breaks and the page breaks are the typesetter's work, and
// a .docx that reproduced those would be a picture of a PDF rather than
// something anybody can edit. [document] says what it reads and what it does
// not.
func runWord(c *context, args []string) error {
return runOffice(c, args, "word", ooxml.WriteWord)
}

// runExcel writes the pages' tables back out as a workbook.
//
// ⛔ Only the tables, one sheet each. A spreadsheet is a grid of cells and a
// document is not, so the prose around a table has nowhere to go.
func runExcel(c *context, args []string) error {
return runOffice(c, args, "excel", ooxml.WriteWorkbook)
}

// runOffice is both of the above: the same reading, written by a different
// writer. Keeping them one function is why -pages means the same thing in
// both, and why a defect found in one is fixed in both.
func runOffice(c *context, args []string, verb string, write func(*richdoc.Document) ([]byte, error)) error {
fs := flags(verb)
spec := fs.String("pages", "all", "which pages to read")
if err := fs.Parse(args); err != nil {
return err
}
if err := wantArgs(fs, 2, "<in.pdf> <out>"); err != nil {
return err
}
src, err := c.read(fs.Arg(0))
if err != nil {
return err
}
wanted, err := ops.ParseRange(*spec, src.PageCount())
if err != nil {
return err
}
// The range is applied AFTER the document is read, for the reason
// [chosen] gives: what counts as a heading is decided by the sizes the
// whole document uses.
doc := &richdoc.Document{}
for _, p := range chosen(document.Pages(src), wanted) {
doc.Blocks = append(doc.Blocks, p.Blocks...)
}
out, err := write(doc)
if err != nil {
return err
}
if err := os.WriteFile(fs.Arg(1), out, 0o644); err != nil {
return err
}
fmt.Fprintf(c.out, "%s\t%d bytes\n", fs.Arg(1), len(out))
return nil
}
185 changes: 185 additions & 0 deletions cmd/pdfops/office_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package main

import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"

"github.com/go-ooxml/ooxml"
"github.com/go-ooxml/opc"
"github.com/go-richdoc/richdoc"
)

// ⛔ THE CONTROL IS go-ooxml's READER, not a string of expected XML. What
// these verbs owe is a file something else can open, and ParseWord and
// ParseWorkbook were measured against files Word and LibreOffice produced.
func TestWordVerb(t *testing.T) {
in := documentFixture(t, nil)
out := filepath.Join(t.TempDir(), "out.docx")
code, printed, errOut := exec("word", in, out)
if code != 0 {
t.Fatalf("word said %d: %s", code, errOut)
}
b, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
// ⛔ What it SAYS it wrote has to be what is on disk. "0 bytes" beside a
// four-kilobyte file is a tool reporting on something else, and
// "it mentioned bytes" could not tell the two apart.
if want := fmt.Sprintf("out.docx\t%d bytes", len(b)); !strings.Contains(printed, want) {
t.Errorf("it said %q, want it to say %q", printed, want)
}
doc, err := ooxml.ParseWord(b, opc.Limits{})
if err != nil {
t.Fatalf("what it wrote could not be read: %v", err)
}
text := richdoc.PlainText(doc)
for _, want := range []string{"The Title", "one", "two", "Name", "Age", "Ada", "36"} {
if !strings.Contains(text, want) {
t.Errorf("%q is not in the .docx:\n%s", want, text)
}
}
// The heading is a heading, which is the whole reason a styles part is
// written — not just text that happens to be first.
var levels []int
for _, blk := range doc.Blocks {
if h, ok := blk.(richdoc.Heading); ok {
levels = append(levels, h.Level)
}
}
if len(levels) == 0 {
t.Errorf("no headings came back:\n%s", text)
}
}

func TestExcelVerb(t *testing.T) {
in := documentFixture(t, nil)
out := filepath.Join(t.TempDir(), "out.xlsx")
code, _, errOut := exec("excel", in, out)
if code != 0 {
t.Fatalf("excel said %d: %s", code, errOut)
}
b, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
doc, err := ooxml.ParseWorkbook(b, opc.Limits{})
if err != nil {
t.Fatalf("what it wrote could not be read: %v", err)
}
text := richdoc.PlainText(doc)
for _, want := range []string{"Name", "Age", "Ada", "36"} {
if !strings.Contains(text, want) {
t.Errorf("%q is not in the .xlsx:\n%s", want, text)
}
}
// ⛔ And the prose is NOT: a spreadsheet is a grid of cells, and putting
// the headings in column A would make a file that looks like a document
// and works as neither.
if strings.Contains(text, "The Title") {
t.Errorf("the document's prose went into the sheet:\n%s", text)
}
}

// A range names pages, and means the same thing in both verbs.
func TestTheOfficeVerbsTakeARange(t *testing.T) {
in := documentFixture(t, nil)
// ⛔ Page two and NOT page one. "it wrote some bytes" is true of a range
// the verb ignored and of a range it got wrong, so the test has to say
// what came back and what did not.
out := filepath.Join(t.TempDir(), "p2.docx")
if code, _, errOut := exec("word", "-pages", "2", in, out); code != 0 {
t.Fatalf("word -pages 2 said %d: %s", code, errOut)
}
b, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
doc, err := ooxml.ParseWord(b, opc.Limits{})
if err != nil {
t.Fatal(err)
}
text := richdoc.PlainText(doc)
if !strings.Contains(text, "the second page") {
t.Errorf("page two is missing:\n%s", text)
}
if strings.Contains(text, "The Title") {
t.Errorf("page one came too:\n%s", text)
}
// Page two of the fixture holds no table, so the workbook of it is the
// one empty sheet a workbook has to have rather than an error.
book := filepath.Join(t.TempDir(), "p2.xlsx")
if code, _, errOut := exec("excel", "-pages", "2", in, book); code != 0 {
t.Fatalf("a page with no tables was refused: %s", errOut)
}
raw, err := os.ReadFile(book)
if err != nil {
t.Fatal(err)
}
p, err := opc.Open(raw, opc.Limits{})
if err != nil {
t.Fatal(err)
}
if !p.Has("xl/worksheets/sheet1.xml") {
t.Errorf("no sheet at all: %v", p.Parts())
}
}

func TestTheOfficeVerbsRefuseWhatTheyCannotDo(t *testing.T) {
in := documentFixture(t, nil)
dir := t.TempDir()
for _, verb := range []string{"word", "excel"} {
cases := []struct {
name string
args []string
says string
}{
{"no arguments", []string{verb}, "expected <in.pdf> <out>"},
{"no output", []string{verb, in}, "expected <in.pdf> <out>"},
{"a flag it does not have", []string{verb, "-nope", in, "x"}, "flag provided but not defined"},
{"a range it cannot read", []string{verb, "-pages", "nope", in, "x"}, "nope"},
{"a file that is not there", []string{verb, filepath.Join(dir, "gone.pdf"), "x"}, "gone.pdf"},
// A directory where a file should go: the write is the last thing
// to fail and the one nothing else checks.
{"an output it cannot write", []string{verb, in, dir}, dir},
}
for _, c := range cases {
code, _, errOut := exec(c.args...)
if code == 0 {
t.Errorf("%s/%s: it said nothing was wrong", verb, c.name)
continue
}
if !strings.Contains(errOut, c.says) {
t.Errorf("%s/%s: it said %q, which does not mention %q", verb, c.name, errOut, c.says)
}
}
}
}

// ⛔ A writer that refuses. go-ooxml v0.3.0 fails only for a nil document and
// the document here is never nil, so nothing a caller can type reaches this —
// but the check stays, because a writer gaining a failure mode later must not
// mean a truncated file written and reported as bytes.
//
// runOffice takes the writer as a parameter because both verbs share it, and
// that seam is what makes this testable rather than a branch nobody can see.
func TestAWriterThatRefuses(t *testing.T) {
in := documentFixture(t, nil)
out := filepath.Join(t.TempDir(), "out.docx")
c := &context{out: &strings.Builder{}, errOut: &strings.Builder{}}
err := runOffice(c, []string{in, out}, "word",
func(*richdoc.Document) ([]byte, error) { return nil, errRefused })
if err == nil {
t.Fatal("a writer that refused was reported as a success")
}
if !strings.Contains(err.Error(), "takes nothing") {
t.Errorf("it said %q", err)
}
// And nothing was written: a file that exists is a file somebody opens.
if _, err := os.Stat(out); err == nil {
t.Error("it wrote a file anyway")
}
}
2 changes: 2 additions & 0 deletions cmd/pdfops/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ var commands = []command{
{"text", "[-pages <range>] [-layout] [-json] <in.pdf>", "read the text off the pages", runText},
{"tables", "[-pages <range>] [-as csv|json|md] [-to <directory>] <in.pdf>", "find the grids the text lines up into", runTables},
{"markdown", "[-pages <range>] [-json] <in.pdf>", "read the pages back as a document: headings, lists and tables", runMarkdown},
{"word", "[-pages <range>] <in.pdf> <out.docx>", "write the pages back out as a Word document", runWord},
{"excel", "[-pages <range>] <in.pdf> <out.xlsx>", "write the pages' tables out as a workbook", runExcel},
{"images", "[-pages <range>] <in.pdf> <out-directory>", "write out the pictures the pages place", runImages},
{"attachments", "[-to <directory>] <in.pdf>", "list the files the document carries, or write them out", runAttachments},
{"attach", "-file <path> [-as <name>] [-description <text>] <in.pdf> <out.pdf>", "carry a file inside the document", runAttach},
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
module github.com/go-pdfkit/ops

go 1.27.1
go 1.27.2

require (
github.com/go-gfx/gfx v0.34.1
github.com/go-ooxml/ooxml v0.3.0
github.com/go-pdfkit/document v0.1.0
github.com/go-pdfkit/extract v0.9.0
github.com/go-pdfkit/forms v0.5.0
Expand All @@ -19,6 +20,7 @@ require (
github.com/andybalholm/brotli v1.2.6 // indirect
github.com/go-images/jpeg v0.2.0 // indirect
github.com/go-images/jpeg2000 v0.13.2 // indirect
github.com/go-ooxml/opc v0.2.0 // indirect
github.com/go-opentype/fonts v0.12.0 // indirect
github.com/go-opentype/opentype v0.15.0 // indirect
github.com/go-pdfkit/pdffont v0.4.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ github.com/go-images/jpeg v0.2.0 h1:dn55cTczPdOJf+NQq+sETCFeov/KeP1haF1ES+FJa+4=
github.com/go-images/jpeg v0.2.0/go.mod h1:2C1DevAM+HaQlysTLbSdyol8X/RlvmsXmHAXs9teAqo=
github.com/go-images/jpeg2000 v0.13.2 h1:wthA3iZkyqWXzNXxd6EjjsrsQ7GOYX7vYZbaI4vU6+I=
github.com/go-images/jpeg2000 v0.13.2/go.mod h1:Qqxz1gBrUwFDBOGkA4RGyTsX7wifr0wvaTQ7k8FK0wY=
github.com/go-ooxml/ooxml v0.3.0 h1:jZD+HA5x7RmlmsqZzA4nEv2XfzK71+/Ipe3aVBztxo0=
github.com/go-ooxml/ooxml v0.3.0/go.mod h1:1lP6/YsrlUhXxXJiZkVCFzPyjuex3kIJ5XO+Sy1aF38=
github.com/go-ooxml/opc v0.2.0 h1:qdd3jPmPA6c8tT2CH0x9Jc0Wsj3lXIE3+cvQUvDJB8Y=
github.com/go-ooxml/opc v0.2.0/go.mod h1:WCilIcfGDpa5sRf2uNbXFKYg7MCog6I00ahuWTWXUyw=
github.com/go-opentype/fonts v0.12.0 h1:gjYYkbDZBK3JHpraakgGNnXdkGq8oADerhDYjC22ILQ=
github.com/go-opentype/fonts v0.12.0/go.mod h1:Kiep7ojxxaR2ALFeB/akcFHQyl1gG5OLIobp8EzaZSA=
github.com/go-opentype/opentype v0.15.0 h1:ioE3eCbNZavlk/w073J3uX5FKjqhgOHQw+wZZdJ+gE4=
Expand Down
Loading