From f0be962eaf7fc6e3a3126ab0c8776701528eaf72 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 12 Oct 2026 10:55:15 +0200 Subject: [PATCH] word and excel: the same reading, written out as Office files pdfops word and pdfops excel write the document go-pdfkit/document reads off the pages as a .docx or an .xlsx, through go-ooxml v0.3.0. That is BentoPDF's pdf-to-word and pdf-to-excel. What comes out is the DOCUMENT, not the pages. The fonts, the margins and the page breaks are the typesetter's work, and 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. A page with no tables gives the one empty sheet a workbook has to have, not an error. Both are one 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. It also makes the writer a SEAM: the one branch no input can reach -- a writer that refuses -- is covered by passing one that does, rather than left as a branch nobody can see. Smoke-tested through LibreOffice on a real 2-page PDF, which is a reader other people wrote: the .docx came back with its headings, paragraphs and indented bullets; the .xlsx with its table as cells. 100% of statements with no uncovered blocks, 8 mutations caught. Two were survivors that said my tests were weak rather than the code: "it wrote some bytes" could not tell a correct range from an ignored one, and "it mentioned bytes" could not tell a real count from zero. Co-Authored-By: Claude Opus 5 --- README.md | 23 +++++ cmd/pdfops/office.go | 69 ++++++++++++++ cmd/pdfops/office_test.go | 185 ++++++++++++++++++++++++++++++++++++++ cmd/pdfops/run.go | 2 + go.mod | 4 +- go.sum | 4 + 6 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 cmd/pdfops/office.go create mode 100644 cmd/pdfops/office_test.go diff --git a/README.md b/README.md index 27c886d..da7b27d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/cmd/pdfops/office.go b/cmd/pdfops/office.go new file mode 100644 index 0000000..5c3db0f --- /dev/null +++ b/cmd/pdfops/office.go @@ -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, " "); 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 +} diff --git a/cmd/pdfops/office_test.go b/cmd/pdfops/office_test.go new file mode 100644 index 0000000..9de5126 --- /dev/null +++ b/cmd/pdfops/office_test.go @@ -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 "}, + {"no output", []string{verb, in}, "expected "}, + {"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") + } +} diff --git a/cmd/pdfops/run.go b/cmd/pdfops/run.go index eb09187..9d7cf17 100644 --- a/cmd/pdfops/run.go +++ b/cmd/pdfops/run.go @@ -76,6 +76,8 @@ var commands = []command{ {"text", "[-pages ] [-layout] [-json] ", "read the text off the pages", runText}, {"tables", "[-pages ] [-as csv|json|md] [-to ] ", "find the grids the text lines up into", runTables}, {"markdown", "[-pages ] [-json] ", "read the pages back as a document: headings, lists and tables", runMarkdown}, + {"word", "[-pages ] ", "write the pages back out as a Word document", runWord}, + {"excel", "[-pages ] ", "write the pages' tables out as a workbook", runExcel}, {"images", "[-pages ] ", "write out the pictures the pages place", runImages}, {"attachments", "[-to ] ", "list the files the document carries, or write them out", runAttachments}, {"attach", "-file [-as ] [-description ] ", "carry a file inside the document", runAttach}, diff --git a/go.mod b/go.mod index c9ade3f..40b1520 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/go.sum b/go.sum index fb9300a..de2c9e5 100644 --- a/go.sum +++ b/go.sum @@ -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=