diff --git a/README.md b/README.md index ba585f2..05e0530 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,9 @@ pdfops -password letmein permissions locked.pdf pdfops text paper.pdf pdfops text -layout -pages 1 paper.pdf pdfops text -layout -json paper.pdf +pdfops tables accounts.pdf +pdfops tables -as md -pages 3 accounts.pdf +pdfops tables -as json -to tables/ accounts.pdf pdfops images paper.pdf pictures/ pdfops attachments report.pdf pdfops attachments -to files/ report.pdf @@ -308,6 +311,41 @@ has two "Markup" layers, and switching one of them at random is a change nobody asked for and nobody can see, so an ambiguous name is refused with the count. +`tables` finds the grids a page's text lines up into, and writes them as CSV +(RFC 4180, a blank line between one table and the next), as JSON (with the page +and the box, so a caller knows where to look), or as GFM pipe tables. + +```sh +pdfops tables accounts.pdf +pdfops tables -as md -pages 3 accounts.pdf +pdfops tables -as json -to tables/ accounts.pdf +``` + +A page holds no table — it holds text put at places — so this reports an +**inference**, and +[`extract`](https://github.com/go-pdfkit/extract#tables) is where the rules +are: a boundary is a stretch every row leaves empty, a repeated one-character +cell is a bullet and not a column, and a line far below the last row is not the +next one. Ruled lines are **not** read, so a table held apart by rules rather +than by whitespace is not found. + +⛔ **A page that could not be read is said on the error stream**, not left to +look like a page with no tables on it — and on the error stream rather than +standard output, so that it cannot end up inside the rows somebody is about to +parse. + +⛔ **The first row becomes the Markdown header.** A pipe table has nowhere to +put a table with no header row, and a grid found by looking at alignment does +not know whether its first row is one. The cells go through +[go-richdoc](https://github.com/go-richdoc/markdown) rather than through a +`strings.Join` here, so a cell holding a `|` is escaped by the code that +already knew it had to be. + +⛔ **A document may say a word sits 10^308 points along**, and a table with one +word at minus that and another at plus it is infinitely wide. JSON has no +spelling for an infinity, so `-as json` **fails** on such a file rather than +writing `"width": +Inf` into something that has already begun parsing it. + `textcolour` paints the text one colour and leaves the rest of the page alone. ```sh diff --git a/cmd/pdfops/run.go b/cmd/pdfops/run.go index 0c4d030..3ae94ed 100644 --- a/cmd/pdfops/run.go +++ b/cmd/pdfops/run.go @@ -74,6 +74,7 @@ var commands = []command{ {"decrypt", " ", "write the file without its protection", runDecrypt}, {"permissions", "", "say how the file is protected and what it allows", runPermissions}, {"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}, {"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/cmd/pdfops/tables.go b/cmd/pdfops/tables.go new file mode 100644 index 0000000..ef7c849 --- /dev/null +++ b/cmd/pdfops/tables.go @@ -0,0 +1,231 @@ +package main + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/go-pdfkit/extract" + "github.com/go-pdfkit/ops" + "github.com/go-richdoc/markdown" + "github.com/go-richdoc/richdoc" +) + +// runTables finds the grids a page's text lines up into and writes them out. +// +// A page holds no table — it holds text put at places — so what this reports +// is an inference, and [extract.Tables] says what it will and will not see. +// Worth knowing before piping the output anywhere: ruled lines are not read, +// so a table held apart by rules rather than by whitespace is not found. +func runTables(c *context, args []string) error { + fs := flags("tables") + spec := fs.String("pages", "all", "which pages to look at") + as := fs.String("as", "csv", "how to write them: csv, json or md") + to := fs.String("to", "", "a directory to write one file per table into, rather than all of them together") + if err := fs.Parse(args); err != nil { + return err + } + if err := wantArgs(fs, 1, ""); err != nil { + return err + } + form, err := formNamed(*as) + if 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 + } + var found []extract.Table + var unread []int + for _, page := range pages { + tables, err := extract.Tables(src, page) + if err != nil { + // ⛔ Kept and said at the end rather than swallowed. A page that + // cannot be read comes back here as no tables, which is + // indistinguishable from a page that holds none — and a caller + // piping this into a spreadsheet would never find out. + unread = append(unread, page) + continue + } + found = append(found, tables...) + } + if *to != "" { + err = writeTablesSeparately(c, found, form, *to) + } else { + err = writeTablesTogether(c, found, form) + } + if err != nil { + return err + } + if len(unread) > 0 { + fmt.Fprintf(c.errOut, "%d page(s) could not be read, so nothing is "+ + "reported for them: %s\n", len(unread), pageList(unread)) + } + return nil +} + +// pageList writes page numbers the way somebody would read them back. +func pageList(pages []int) string { + out := make([]string, len(pages)) + for i, p := range pages { + out[i] = fmt.Sprint(p) + } + return strings.Join(out, ", ") +} + +// A tableForm is one of the ways tables can be written out: the extension a +// file of them takes, and how they are turned into bytes. +// +// Each renderer takes the writer and returns its error, rather than returning +// bytes to be checked here: a check here would be a path no test can reach, +// since nothing between a table and its bytes can fail on its own. +type tableForm struct { + suffix string + render func(io.Writer, []extract.Table) error +} + +// formNamed is the form that name asks for, or an error saying what the +// choices are. +func formNamed(as string) (tableForm, error) { + switch strings.ToLower(as) { + case "csv": + return tableForm{".csv", tablesAsCSV}, nil + case "json": + return tableForm{".json", tablesAsJSON}, nil + case "md", "markdown": + return tableForm{".md", tablesAsMarkdown}, nil + } + return tableForm{}, fmt.Errorf("-as %s: write csv, json or md", as) +} + +// writeTablesTogether writes every table into one piece of output. +func writeTablesTogether(c *context, found []extract.Table, form tableForm) error { + if len(found) == 0 { + fmt.Fprintln(c.out, "no tables") + return nil + } + return form.render(c.out, found) +} + +// writeTablesSeparately writes one file per table, named the way the images +// verb names its own: the page it came from, and its place on that page. +func writeTablesSeparately(c *context, found []extract.Table, form tableForm, dir string) error { + if len(found) == 0 { + fmt.Fprintln(c.out, "no tables") + return nil + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + page, onPage := 0, 0 + for _, t := range found { + if t.Page != page { + page, onPage = t.Page, 0 + } + onPage++ + var b bytes.Buffer + if err := form.render(&b, []extract.Table{t}); err != nil { + return err + } + name := fmt.Sprintf("page%03d-%02d%s", t.Page, onPage, form.suffix) + if err := os.WriteFile(filepath.Join(dir, name), b.Bytes(), 0o644); err != nil { + return err + } + fmt.Fprintf(c.out, "%s\t%d rows\t%d columns\tat %.1f,%.1f\n", + name, len(t.Rows), len(t.Rows[0]), t.X, t.Y) + } + return nil +} + +// tablesAsCSV writes the rows of every table, RFC 4180 quoted, with a blank +// line between one table and the next so a reader can see where each ended. +// +// The writes are not checked one at a time because [csv.Writer] keeps the +// first error that happened and hands it over at the end, which is the same +// answer with none of the branches. +func tablesAsCSV(out io.Writer, found []extract.Table) error { + w := csv.NewWriter(out) + for i, t := range found { + if i > 0 { + w.Write(nil) + } + w.WriteAll(t.Rows) + } + w.Flush() + return w.Error() +} + +// jsonTable is one table as something that is going to parse it reads it: +// where it was found as well as what it says, because a caller pulling tables +// out of a long document needs to know which page to look at. +type jsonTable struct { + Page int `json:"page"` + X float64 `json:"x"` + Y float64 `json:"y"` + Width float64 `json:"width"` + Height float64 `json:"height"` + Rows [][]string `json:"rows"` +} + +func tablesAsJSON(out io.Writer, found []extract.Table) error { + list := make([]jsonTable, len(found)) + for i, t := range found { + list[i] = jsonTable{t.Page, t.X, t.Y, t.Width, t.Height, t.Rows} + } + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(list) +} + +// tablesAsMarkdown writes GFM pipe tables, through go-richdoc so that a cell +// holding a pipe is escaped by the code that already knew it had to be. +// +// ⛔ The first row becomes the header. A pipe table has nowhere to put a table +// with no header row, and a grid found by looking at alignment does not know +// whether its first row is one — so this picks the reading that renders, and +// says here that it picked. +func tablesAsMarkdown(out io.Writer, found []extract.Table) error { + doc := &richdoc.Document{} + for _, t := range found { + doc.Blocks = append(doc.Blocks, richdoc.Table{ + Header: cellsOf(t.Rows[0]), + Rows: rowsOf(t.Rows[1:]), + }) + } + // markdown.Write returns an error for the sake of its signature and never + // a non-nil one: every path through it builds a string and returns nil. + // Checking it here would add a path no test can reach, so the coupling is + // named instead — and it is a coupling to a pinned version. + b, _ := markdown.Write(doc) + _, err := out.Write(b) + return err +} + +// cellsOf turns a row of text into cells. +func cellsOf(row []string) []richdoc.Cell { + out := make([]richdoc.Cell, len(row)) + for i, s := range row { + // Inlines alone, and Blocks left nil: one line of text is one + // paragraph, which is the case richdoc.Cell says to do this for. + out[i] = richdoc.Cell{Inlines: []richdoc.Inline{richdoc.Text{Value: s}}} + } + return out +} + +func rowsOf(rows [][]string) [][]richdoc.Cell { + out := make([][]richdoc.Cell, len(rows)) + for i, row := range rows { + out[i] = cellsOf(row) + } + return out +} diff --git a/cmd/pdfops/tables_test.go b/cmd/pdfops/tables_test.go new file mode 100644 index 0000000..1108bb1 --- /dev/null +++ b/cmd/pdfops/tables_test.go @@ -0,0 +1,373 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-pdfkit/extract" + "github.com/go-pdfkit/reader" +) + +// tableFixture writes a document whose pages hold the content given, set in a +// font of even width so that a test can work out where every letter lands. +func tableFixture(t *testing.T, contents ...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(contents)) + for _, content := range contents { + 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}}, + })) + } + 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(), "tables.pdf") + if err := os.WriteFile(path, out, 0o644); err != nil { + t.Fatal(err) + } + return path +} + +// tablesOf is every table in a file, for the tests that work on the tables +// themselves rather than through the command. +func tablesOf(t *testing.T, path string) []extract.Table { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + d, err := reader.Open(b) + if err != nil { + t.Fatal(err) + } + var out []extract.Table + for i := 1; i <= d.PageCount(); i++ { + tables, err := extract.Tables(d, i) + if err != nil { + t.Fatal(err) + } + out = append(out, tables...) + } + return out +} + +// twoTables is a document with one two-column table on each of two pages. +func twoTables(t *testing.T) string { + t.Helper() + return tableFixture(t, + "BT /F1 10 Tf 20 150 Td (Name) Tj 40 0 Td (Age) Tj ET\n"+ + "BT /F1 10 Tf 20 130 Td (Ada) Tj 40 0 Td (36) Tj ET", + "BT /F1 10 Tf 20 150 Td (City) Tj 40 0 Td (Code) Tj ET\n"+ + "BT /F1 10 Tf 20 130 Td (Caen) Tj 40 0 Td (14000) Tj ET") +} + +func TestTablesVerbWritesCSV(t *testing.T) { + code, out, errOut := exec("tables", twoTables(t)) + if code != 0 { + t.Fatalf("tables said %d: %s", code, errOut) + } + for _, want := range []string{"Name,Age", "Ada,36", "City,Code", "Caen,14000"} { + if !strings.Contains(out, want) { + t.Errorf("%q is not in the output:\n%s", want, out) + } + } + // One blank line, between the two tables and nowhere else: it is what + // says where one ended. + if n := strings.Count(out, "\n\n"); n != 1 { + t.Errorf("%d blank lines, want 1:\n%q", n, out) + } +} + +func TestTablesVerbWritesJSON(t *testing.T) { + code, out, errOut := exec("tables", "-as", "json", twoTables(t)) + if code != 0 { + t.Fatalf("tables said %d: %s", code, errOut) + } + var got []jsonTable + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("the output is not JSON: %v\n%s", err, out) + } + if len(got) != 2 { + t.Fatalf("%d tables: %+v", len(got), got) + } + if got[0].Page != 1 || got[1].Page != 2 { + t.Errorf("the tables say they are on pages %d and %d", got[0].Page, got[1].Page) + } + // Where it was found is as much the answer as what it says: a caller + // pulling tables out of a long document needs to know where to look. + if got[0].X != 20 || got[0].Width != 55 { + t.Errorf("the first table is at x=%v and is %v wide", got[0].X, got[0].Width) + } + if want := [][]string{{"Name", "Age"}, {"Ada", "36"}}; fmt.Sprint(got[0].Rows) != fmt.Sprint(want) { + t.Errorf("the first table reads %q", got[0].Rows) + } +} + +func TestTablesVerbWritesMarkdown(t *testing.T) { + // md and markdown name the same thing, and the case is not part of it. + for _, as := range []string{"md", "MARKDOWN"} { + code, out, errOut := exec("tables", "-as", as, twoTables(t)) + if code != 0 { + t.Fatalf("tables -as %s said %d: %s", as, code, errOut) + } + want := "| Name | Age |\n| --- | --- |\n| Ada | 36 |\n\n" + + "| City | Code |\n| --- | --- |\n| Caen | 14000 |\n" + if out != want { + t.Errorf("-as %s wrote\n%q\nwant\n%q", as, out, want) + } + } +} + +// BentoPDF's own documentation says the JSON is "pretty-printed with 2-space +// indentation", and something written to be read by a person as well as by a +// parser is worth keeping that way. +func TestTheJSONIsIndentedForSomebodyToRead(t *testing.T) { + _, out, _ := exec("tables", "-as", "json", twoTables(t)) + if !strings.Contains(out, "[\n {\n \"page\": 1,") { + t.Errorf("the JSON is not indented two spaces at a time:\n%s", out) + } +} + +// refusingWriter is a writer that will not take anything, which is what a +// closed pipe or a full disk looks like from here. +type refusingWriter struct{} + +func (refusingWriter) Write([]byte) (int, error) { return 0, errRefused } + +var errRefused = fmt.Errorf("this writer takes nothing") + +// ⛔ Every renderer has to hand back the writer's error rather than finishing +// quietly. `pdfops tables … | head` closes the pipe, and a tool that returns +// nought from a write that did not happen reports success for an empty file. +func TestEveryFormSaysWhenTheWriterRefusesIt(t *testing.T) { + tables := tablesOf(t, twoTables(t)) + for _, as := range []string{"csv", "json", "md"} { + form, err := formNamed(as) + if err != nil { + t.Fatal(err) + } + if err := form.render(refusingWriter{}, tables); err == nil { + t.Errorf("-as %s wrote nothing and said nothing was wrong", as) + } + } +} + +// ⛔ A cell holding a pipe has to be escaped or it becomes two cells, and the +// escaping belongs to the code that already knew it had to do it. +func TestACellHoldingAPipe(t *testing.T) { + in := tableFixture(t, + "BT /F1 10 Tf 20 150 Td (a|b) Tj 40 0 Td (Age) Tj ET\n"+ + "BT /F1 10 Tf 20 130 Td (Ada) Tj 40 0 Td (36) Tj ET") + _, out, _ := exec("tables", "-as", "md", in) + if !strings.Contains(out, `a\|b`) { + t.Errorf("the pipe was not escaped:\n%s", out) + } +} + +func TestTablesVerbWritesOneFilePerTable(t *testing.T) { + dir := filepath.Join(t.TempDir(), "tables") + code, out, errOut := exec("tables", "-to", dir, twoTables(t)) + if code != 0 { + t.Fatalf("tables said %d: %s", code, errOut) + } + for _, name := range []string{"page001-01.csv", "page002-01.csv"} { + if !strings.Contains(out, name) { + t.Errorf("%s is not in the listing:\n%s", name, out) + } + b, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("%s was not written: %v", name, err) + } + if len(b) == 0 { + t.Errorf("%s is empty", name) + } + } + if !strings.Contains(out, "2 rows\t2 columns") { + t.Errorf("the listing does not say the shape:\n%s", out) + } +} + +// Two tables on one page are numbered within it, and the numbering starts +// again on the next page. +func TestTwoTablesOnOnePageAreNumberedWithinIt(t *testing.T) { + in := tableFixture(t, + "BT /F1 10 Tf 20 180 Td (Name) Tj 40 0 Td (Age) Tj ET\n"+ + "BT /F1 10 Tf 20 160 Td (Ada) Tj 40 0 Td (36) Tj ET\n"+ + "BT /F1 10 Tf 20 140 Td (CCCCCCCCCC) Tj 80 0 Td (D) Tj ET\n"+ + "BT /F1 10 Tf 20 120 Td (GGGGGGGGGG) Tj 80 0 Td (H) Tj ET", + "BT /F1 10 Tf 20 150 Td (City) Tj 40 0 Td (Code) Tj ET\n"+ + "BT /F1 10 Tf 20 130 Td (Caen) Tj 40 0 Td (14000) Tj ET") + dir := filepath.Join(t.TempDir(), "tables") + _, out, _ := exec("tables", "-to", dir, in) + for _, name := range []string{"page001-01.csv", "page001-02.csv", "page002-01.csv"} { + if !strings.Contains(out, name) { + t.Errorf("%s is not in the listing:\n%s", name, out) + } + } +} + +func TestTablesVerbWhenThereAreNone(t *testing.T) { + in := fixture(t, 1) + _, out, _ := exec("tables", in) + if !strings.Contains(out, "no tables") { + t.Errorf("it did not say there were none:\n%s", out) + } + // And nothing is written, not even the directory: a tool that makes an + // empty directory has told you it found something. + dir := filepath.Join(t.TempDir(), "tables") + _, out, _ = exec("tables", "-to", dir, in) + if !strings.Contains(out, "no tables") { + t.Errorf("it did not say there were none:\n%s", out) + } + if _, err := os.Stat(dir); err == nil { + t.Error("it made a directory for no tables") + } +} + +// ⛔ A page that cannot be read gives no tables, which is exactly what a page +// holding none gives. Whoever is piping this into a spreadsheet has to be +// told, so it is said on the error stream where it cannot corrupt the rows. +func TestTablesVerbSaysWhichPagesItCouldNotRead(t *testing.T) { + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + broken := w.Add(&reader.Stream{ + Dict: reader.Dict{"Filter": reader.Name("FlateDecode")}, + Raw: []byte("this is not deflated at all"), + }) + 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": broken, + }) + 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(), "broken.pdf") + if err := os.WriteFile(path, out, 0o644); err != nil { + t.Fatal(err) + } + code, printed, errOut := exec("tables", path) + if code != 0 { + t.Fatalf("tables said %d: %s", code, errOut) + } + if !strings.Contains(errOut, "could not be read") || !strings.Contains(errOut, "1") { + t.Errorf("it did not say which page it could not read: %q", errOut) + } + if strings.Contains(printed, "could not be read") { + t.Errorf("the warning went into the rows:\n%s", printed) + } +} + +func TestTablesVerbRefusesWhatItCannotDo(t *testing.T) { + in := twoTables(t) + cases := []struct { + name string + args []string + says string + }{ + {"a form it does not know", []string{"tables", "-as", "xml", in}, "csv, json or md"}, + {"no file at all", []string{"tables"}, "expected "}, + {"two files", []string{"tables", in, in}, "expected "}, + {"a flag it does not have", []string{"tables", "-nope", in}, "flag provided but not defined"}, + {"a range it cannot read", []string{"tables", "-pages", "nope", in}, "nope"}, + {"a file that is not there", []string{"tables", filepath.Join(t.TempDir(), "gone.pdf")}, "gone.pdf"}, + } + for _, c := range cases { + code, _, errOut := exec(c.args...) + if code == 0 { + t.Errorf("%s: it said nothing was wrong", c.name) + continue + } + if !strings.Contains(errOut, c.says) { + t.Errorf("%s: it said %q, which does not mention %q", c.name, errOut, c.says) + } + } +} + +// ⛔ A document is free to say a word sits 10^308 points along, and a table +// with one word at minus that and another at plus it is infinitely wide. JSON +// has no spelling for an infinity, so this has to come back as an error: +// emitting `"width": +Inf` would hand whatever parses it something it will +// reject, halfway through a file it has already started reading. +// +// The number is written out in full because a PDF real has no exponent — `1e308` +// in a content stream is not a number at all, and a fixture written that way +// quietly tests nothing. +func TestATableWhoseWidthIsAnInfinity(t *testing.T) { + big := "1" + strings.Repeat("0", 308) + in := tableFixture(t, + "BT /F1 10 Tf -"+big+" 150 Td (A) Tj ET\n"+ + "BT /F1 10 Tf 20 150 Td (Name) Tj 40 0 Td (Age) Tj ET\n"+ + "BT /F1 10 Tf "+big+" 150 Td (Z) Tj ET\n"+ + "BT /F1 10 Tf 20 130 Td (Ada) Tj 40 0 Td (36) Tj ET") + // CSV says what the cells say and never mentions the box, so it is fine. + if code, _, errOut := exec("tables", in); code != 0 { + t.Errorf("csv refused a table it could write: %s", errOut) + } + code, _, errOut := exec("tables", "-as", "json", in) + if code == 0 { + t.Fatalf("it wrote JSON holding an infinity") + } + if !strings.Contains(errOut, "Inf") { + t.Errorf("it did not say what it could not write: %q", errOut) + } + // The same on the way to a file, which is a different path through the + // same renderer. + code, _, errOut = exec("tables", "-as", "json", "-to", filepath.Join(t.TempDir(), "x"), in) + if code == 0 { + t.Errorf("it wrote a file holding an infinity: %s", errOut) + } +} + +func TestTablesVerbWhenTheDirectoryCannotBeMade(t *testing.T) { + // A file where a directory would have to go. + blocker := filepath.Join(t.TempDir(), "in-the-way") + if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + code, _, errOut := exec("tables", "-to", filepath.Join(blocker, "tables"), twoTables(t)) + if code == 0 { + t.Errorf("it wrote into a path under a file: %s", errOut) + } +} + +func TestTablesVerbWhenTheFileCannotBeWritten(t *testing.T) { + dir := filepath.Join(t.TempDir(), "tables") + // The name the first table wants, already taken by a directory. + if err := os.MkdirAll(filepath.Join(dir, "page001-01.csv"), 0o755); err != nil { + t.Fatal(err) + } + code, _, errOut := exec("tables", "-to", dir, twoTables(t)) + if code == 0 { + t.Errorf("it wrote a file over a directory: %s", errOut) + } +} diff --git a/go.mod b/go.mod index cd864f5..1f126bd 100644 --- a/go.mod +++ b/go.mod @@ -4,23 +4,27 @@ go 1.27.1 require ( github.com/go-gfx/gfx v0.34.1 - github.com/go-pdfkit/extract v0.5.0 + github.com/go-pdfkit/extract v0.6.0 github.com/go-pdfkit/forms v0.5.0 github.com/go-pdfkit/reader v0.8.0 + github.com/go-pdfkit/render v0.67.2 github.com/go-pdfkit/xfa v0.22.0 + github.com/go-richdoc/markdown v0.10.0 + github.com/go-richdoc/richdoc v0.6.0 ) require ( github.com/ajroetker/go-highway v0.0.12 // indirect + 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-opentype/fonts v0.10.0 // indirect - github.com/go-opentype/opentype v0.13.0 // indirect - github.com/go-pdfkit/pdffont v0.3.1 // indirect - github.com/go-pdfkit/render v0.67.2 // 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 github.com/sergeymakinen/go-bmp v1.0.0 // indirect github.com/sergeymakinen/go-ico v1.0.0 // indirect github.com/tannevaled/gobig2 v0.2.0 // indirect + github.com/yuin/goldmark v1.8.6 // indirect golang.org/x/image v0.46.0 // indirect golang.org/x/sys v0.48.0 // indirect ) diff --git a/go.sum b/go.sum index c54faa9..9fe668a 100644 --- a/go.sum +++ b/go.sum @@ -1,35 +1,43 @@ github.com/ajroetker/go-highway v0.0.12 h1:OcGp/l/W7WQtH5ELLuNnKXCuR7ePpWGh2RWZI/INtck= github.com/ajroetker/go-highway v0.0.12/go.mod h1:un7fcrwix7UOHJrr18Lgh5eeF+1yaZiGWdTw3FbfDv4= +github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI= +github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/go-gfx/gfx v0.34.1 h1:zXRptZlqRyfE0de/8h9N4Y4cSeySnQB9vWuK+LrwgOM= github.com/go-gfx/gfx v0.34.1/go.mod h1:iGZt+HN5CEwr/nPqxxLIAvNBR9pAKhoZl9scGEwQn9M= 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-opentype/fonts v0.10.0 h1:9f7ik795SlBEJjz6iqBE/ZV2PaTauAj537MMWw9t4OQ= -github.com/go-opentype/fonts v0.10.0/go.mod h1:b+o8v1zDZ6B3GixA398jGILAh09+WwfyoYz52zKe7DA= -github.com/go-opentype/opentype v0.13.0 h1:q9K7IGDfwMP/ik51ZkwiYf6jbaqPfocvAIuojetd0UQ= -github.com/go-opentype/opentype v0.13.0/go.mod h1:AOixevJf7XQaH7+WG+OMIOZEbYPXfMqklVk26Y6YTUU= -github.com/go-pdfkit/extract v0.5.0 h1:5q6YMf1xbmmplaU0hopjedEHsoDsXYpxOYbdNCK7w0c= -github.com/go-pdfkit/extract v0.5.0/go.mod h1:OWM4U1z/I6QiXt6wS62dcpki5FARI9dPeKwl/hsP+7U= +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= +github.com/go-opentype/opentype v0.15.0/go.mod h1:ZB31jTJl2mhPESXYMuUW+p36UrOabw/2+6mtyh/vNnI= +github.com/go-pdfkit/extract v0.6.0 h1:XoSZdCnVf7JcLFHw5c3wO1pmyCJtJ00bxqvZ2myOow8= +github.com/go-pdfkit/extract v0.6.0/go.mod h1:pUzGzNMx0WTsBOzfZ2XKT5ewqgv8cP/I6WpU4isPsmc= github.com/go-pdfkit/forms v0.5.0 h1:+P3WZBaLKPWV49YN23DQIHnhQeftWuosRRNBc1ltG9A= github.com/go-pdfkit/forms v0.5.0/go.mod h1:d8WR2fiAz/BHVbWcRcUBciGHao9gHxU6dMEgEh9xVR0= -github.com/go-pdfkit/pdffont v0.3.1 h1:+K7opHGRERDZ5EMkA12s1GT48t8GpncArhnyzyc5bxU= -github.com/go-pdfkit/pdffont v0.3.1/go.mod h1:eFJ/7t9AvcX76KY5qNJMMKetz8hUUUdVM1qSWLi3S1g= -github.com/go-pdfkit/reader v0.7.0 h1:+Fz5i8Zq6beBpTc3pNTbJ3Qf14aroA5txgeVzVGLTSY= -github.com/go-pdfkit/reader v0.7.0/go.mod h1:EYHHREPKT/orDzlGiFPnPVPPuaRDLiAYezIr0cCFXaA= +github.com/go-pdfkit/pdffont v0.4.0 h1:3CV2HnmUGHiIYuZ5dJWRLKvi7hMHGWS1RjgNYkEZ0Gk= +github.com/go-pdfkit/pdffont v0.4.0/go.mod h1:l7es3IPbKewQF+sSgPh/bQL18jSewoI0cMjttk7uPlc= github.com/go-pdfkit/reader v0.8.0 h1:vLxnOlGJjFV0un7chvTft559neLl6jmW4xrEXub4Y38= github.com/go-pdfkit/reader v0.8.0/go.mod h1:EYHHREPKT/orDzlGiFPnPVPPuaRDLiAYezIr0cCFXaA= github.com/go-pdfkit/render v0.67.2 h1:oBIFoROjorKednM/8Xz9Dhf9VArkFFaL4jZpCEcI72M= github.com/go-pdfkit/render v0.67.2/go.mod h1:S+p47RMo1vUykzxC+5YMzvQ8hjHBVX6HoHIAyJlzmp4= github.com/go-pdfkit/xfa v0.22.0 h1:IbdMcNYN40kataz/n+3pkz2METftMh+re/MkcY2eCbw= github.com/go-pdfkit/xfa v0.22.0/go.mod h1:nZzuk+EIVeUzKfwZkWhE2/fAWr4IW/bouLf5xMED+Bg= +github.com/go-richdoc/markdown v0.10.0 h1:fIQdrbJhWS+NVE+ExDRUQS71Prr6hKeVKSTOn4ooR4A= +github.com/go-richdoc/markdown v0.10.0/go.mod h1:ly2XGC8MpYNPuY7S4jVB2V3UEaUUh5g8hVNtFon0T+0= +github.com/go-richdoc/richdoc v0.6.0 h1:cema3hE5AAektDXTmvhWyBVnkCcZTVKhnkingwqsN7k= +github.com/go-richdoc/richdoc v0.6.0/go.mod h1:RhxAcqwoUg1pnTYCiRdKILzOZwOmraO0dMJI4N6yY70= github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M= github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY= github.com/sergeymakinen/go-ico v1.0.0 h1:uL3khgvKkY6WfAetA+RqsguClBuu7HpvBB/nq/Jvr80= github.com/sergeymakinen/go-ico v1.0.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk= github.com/tannevaled/gobig2 v0.2.0 h1:yx97/ltljALrIWDFVqHjPUIjAp3bfUUhH8Z8UROvNLc= github.com/tannevaled/gobig2 v0.2.0/go.mod h1:X0S1H+N35kg6zYgVYRqGMsGj3C35zs+iA2r1MXyPcV4= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=