From f6b2ecccbe203efc21e47e8545b64458f6025f31 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 12 Oct 2026 09:59:51 +0200 Subject: [PATCH] markdown: read the pages back as a document, and decode what the file says pdfops markdown writes the pages as Markdown -- headings, paragraphs, lists and tables, through go-pdfkit/document v0.1.0. With -json it writes one record per page instead: the page's markdown plus the document's own metadata, which is the shape a retrieval pipeline already expects (LlamaIndex's document schema, which is what PyMuPDF4LLM's LlamaMarkdownReader produces). One verb for both of BentoPDF's pdf-to-markdown and prepare-pdf-for-ai, because prepare-pdf-for-ai IS per-page markdown with metadata. THE RANGE IS APPLIED AFTER THE DOCUMENT IS READ. What counts as a heading is decided by the sizes the whole document uses, so asking for page nine alone would give it different headings from asking for all of them -- and a caller doing a document a page at a time would get a different answer for every page. A test asserts that page one reads the same both ways. AND IT DECODES THE METADATA. A title written in UTF-16 behind a byte order mark came out of ToString as "\xfe\xffL\x00a\x00..."; `info` printed it that way too, for as long as that verb has existed. Both go through reader.TextString now, and `info` on a pandoc-produced file says creator LaTeX via pandoc where it used to print the raw UTF-16. 100% of statements with no uncovered blocks, 17 mutations caught. Four needed tests that did not exist: the indentation, `[]` rather than `null` for a document with no pages, the base name rather than the whole path, and the lowered keys. Co-Authored-By: Claude Opus 5 --- README.md | 33 +++++ cmd/pdfops/markdown.go | 150 ++++++++++++++++++++ cmd/pdfops/markdown_test.go | 272 ++++++++++++++++++++++++++++++++++++ cmd/pdfops/run.go | 7 +- go.mod | 5 +- go.sum | 10 +- 6 files changed, 470 insertions(+), 7 deletions(-) create mode 100644 cmd/pdfops/markdown.go create mode 100644 cmd/pdfops/markdown_test.go diff --git a/README.md b/README.md index 05e0530..27c886d 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ 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 markdown report.pdf > report.md +pdfops markdown -json report.pdf > report-for-indexing.json pdfops images paper.pdf pictures/ pdfops attachments report.pdf pdfops attachments -to files/ report.pdf @@ -346,6 +348,37 @@ 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. +`markdown` reads the pages back as a **document** — headings, paragraphs, lists +and tables — through +[`go-pdfkit/document`](https://github.com/go-pdfkit/document), and writes them +as Markdown. With `-json` it writes one record per page instead, each the +page's Markdown plus the document's own metadata, which is the shape a +retrieval pipeline already expects. + +```sh +pdfops markdown report.pdf > report.md +pdfops markdown -pages 3-7 report.pdf +pdfops markdown -json report.pdf > report-for-indexing.json +``` + +⛔ **A PDF has no headings.** What this writes is an inference from how big the +glyphs were and where they sat, and `document`'s README says what it will and +will not see — a page in columns, or a table held apart by ruled lines, is not +read as either. + +⛔ **A page range is applied after the document is read, never before.** What +counts as a heading is decided by the sizes the *whole document* uses, so +asking for page nine alone would otherwise give it different headings from +asking for all of them — and a caller doing a document a page at a time would +get a different answer for every page. A test asserts that page one reads the +same both ways. + +⛔ **`-json` decodes the metadata.** A title written in UTF-16 behind a byte +order mark — which is most of what a modern producer writes — came out of the +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). + `textcolour` paints the text one colour and leaves the rest of the page alone. ```sh diff --git a/cmd/pdfops/markdown.go b/cmd/pdfops/markdown.go new file mode 100644 index 0000000..166cf8f --- /dev/null +++ b/cmd/pdfops/markdown.go @@ -0,0 +1,150 @@ +package main + +import ( + "encoding/json" + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/go-pdfkit/document" + "github.com/go-pdfkit/ops" + "github.com/go-pdfkit/reader" + "github.com/go-richdoc/markdown" + "github.com/go-richdoc/richdoc" +) + +// runMarkdown reads the pages back as a document and writes them as Markdown, +// or as one record per page for something that is going to index them. +// +// ⛔ A PDF has no headings. What this writes is an inference from how big the +// glyphs were and where they sat, and [document] says what it will and will +// not see. The one thing worth knowing before piping it anywhere: a page laid +// out in columns, or held together by ruled lines, is not read as either. +func runMarkdown(c *context, args []string) error { + fs := flags("markdown") + spec := fs.String("pages", "all", "which pages to read") + asJSON := fs.Bool("json", false, + "write one record per page, with the document's own metadata, for something that is going to index it") + 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 + } + wanted, err := ops.ParseRange(*spec, src.PageCount()) + if err != nil { + return err + } + pages := chosen(document.Pages(src), wanted) + if *asJSON { + return writePageRecords(c, src, fs.Arg(0), pages) + } + return writeMarkdown(c, pages) +} + +// chosen keeps the pages a range named, in the document's order. +// +// ⛔ The range is applied AFTER the document is read, never before. What counts +// as a heading is decided by the sizes the whole document uses, so reading +// page nine alone would give it different headings from reading all of them — +// and a caller doing a document a page at a time would get a different answer +// for every page. +func chosen(pages []document.Page, wanted []int) []document.Page { + keep := make(map[int]bool, len(wanted)) + for _, n := range wanted { + keep[n] = true + } + var out []document.Page + for _, p := range pages { + if keep[p.Number] { + out = append(out, p) + } + } + return out +} + +// writeMarkdown writes the pages as one document, which is what somebody +// reading it wants: a page boundary in the middle of a sentence is an artefact +// of printing, not of the text. +func writeMarkdown(c *context, pages []document.Page) error { + doc := &richdoc.Document{} + for _, p := range pages { + doc.Blocks = append(doc.Blocks, p.Blocks...) + } + // 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 := c.out.Write(b) + return err +} + +// A pageRecord is one page as something that is going to index it reads it: +// the text, and where the text came from. +// +// ⛔ The shape is the one a retrieval pipeline already expects — text plus a +// metadata map, which is LlamaIndex's document schema and what PyMuPDF4LLM's +// LlamaMarkdownReader produces. Inventing a shape here would mean every caller +// writing an adapter for it. +type pageRecord struct { + Text string `json:"text"` + Metadata map[string]string `json:"metadata"` +} + +func writePageRecords(c *context, src *reader.Document, path string, pages []document.Page) error { + // An empty result is `[]`, never `null`: a caller testing the length of + // what came back should not have to test for nothing first. + out := []pageRecord{} + info := documentMetadata(src) + for _, p := range pages { + b, _ := markdown.Write(&richdoc.Document{Blocks: p.Blocks}) + meta := map[string]string{ + // file_path, page_count and page_number are the three keys + // LlamaMarkdownReader adds to a document's own metadata, spelled + // the way it spells them. + "file_path": filepath.Base(path), + "page_count": fmt.Sprint(src.PageCount()), + "page_number": fmt.Sprint(p.Number), + } + for k, v := range info { + meta[k] = v + } + out = append(out, pageRecord{Text: string(b), Metadata: meta}) + } + enc := json.NewEncoder(c.out) + enc.SetIndent("", " ") + return enc.Encode(out) +} + +// documentMetadata is what the file says about itself, with the keys lowered +// so that a caller does not have to know that PDF spells them /Title and +// /CreationDate. +// +// An entry that is not text is left out rather than rendered: the information +// dictionary is the one place a document is free to put anything at all, and +// "author: 4" is worse than no author. +func documentMetadata(src *reader.Document) map[string]string { + out := map[string]string{} + d := ops.FromDocument(src) + info := d.Info() + keys := make([]string, 0, len(info)) + for k := range info { + keys = append(keys, string(k)) + } + sort.Strings(keys) + for _, k := range keys { + // ⛔ TextString and not ToString: the bytes of a text string are not + // text. A title written in UTF-16 — which is most of what a modern + // producer writes — comes out of ToString as "\xfe\xffL\x00a\x00…". + if s, ok := reader.TextString(info[reader.Name(k)]); ok && s != "" { + out[strings.ToLower(k)] = s + } + } + return out +} diff --git a/cmd/pdfops/markdown_test.go b/cmd/pdfops/markdown_test.go new file mode 100644 index 0000000..103762d --- /dev/null +++ b/cmd/pdfops/markdown_test.go @@ -0,0 +1,272 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-pdfkit/reader" +) + +// documentFixture writes a document with a title, a paragraph, a list and a +// grid, in a font of even width so a test can work out where every letter +// lands. The body is long because whether a size is a heading size is decided +// by the share of the characters it holds: a fixture of one heading and one +// short line makes that heading a fifth of the text. +func documentFixture(t *testing.T, info reader.Dict) string { + t.Helper() + var lines []string + add := func(s string) { lines = append(lines, s) } + add("BT /F1 20 Tf 20 760 Td (The Title) Tj ET") + y := 720 + for i := 0; i < 6; i++ { + add(fmt.Sprintf("BT /F1 10 Tf 20 %d Td (a line of ordinary body text with a number of words on it) Tj ET", y)) + y -= 12 + } + y -= 20 + for _, s := range []string{"one", "two"} { + add(fmt.Sprintf("BT /F1 10 Tf 20 %d Td (\\225) Tj 20 0 Td (%s) Tj ET", y, s)) + y -= 12 + } + y -= 20 + add(fmt.Sprintf("BT /F1 10 Tf 20 %d Td (Name) Tj 60 0 Td (Age) Tj ET", y)) + add(fmt.Sprintf("BT /F1 10 Tf 20 %d Td (Ada) Tj 60 0 Td (36) Tj ET", y-12)) + return writeDocument(t, info, strings.Join(lines, "\n"), "BT /F1 10 Tf 20 700 Td (the second page) Tj ET") +} + +// writeDocument writes a document of the content streams given. +func writeDocument(t *testing.T, info reader.Dict, 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(612), reader.Integer(792)}, + "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}) + trailer := reader.Dict{"Root": root} + if info != nil { + trailer["Info"] = w.Add(info) + } + out, err := w.Finish(trailer) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "doc.pdf") + if err := os.WriteFile(path, out, 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestMarkdownVerb(t *testing.T) { + code, out, errOut := exec("markdown", documentFixture(t, nil)) + if code != 0 { + t.Fatalf("markdown said %d: %s", code, errOut) + } + for _, want := range []string{ + "# The Title", + "a line of ordinary body text", + "- one", + "- two", + "| Name | Age |", + "| --- | --- |", + "| Ada | 36 |", + "the second page", + } { + if !strings.Contains(out, want) { + t.Errorf("%q is not in the output:\n%s", want, out) + } + } +} + +// A range names pages, and the pages are the document's own. +func TestMarkdownVerbTakesARange(t *testing.T) { + _, out, _ := exec("markdown", "-pages", "2", documentFixture(t, nil)) + if !strings.Contains(out, "the second page") { + t.Errorf("page two is missing:\n%s", out) + } + if strings.Contains(out, "The Title") { + t.Errorf("page one came too:\n%s", out) + } +} + +// ⛔ The range is applied AFTER the document is read. What counts as a heading +// is decided by the sizes the WHOLE document uses, so asking for page two +// alone must not give page two different headings from asking for both. +func TestARangeDoesNotChangeWhatAHeadingIs(t *testing.T) { + in := documentFixture(t, nil) + _, whole, _ := exec("markdown", in) + _, first, _ := exec("markdown", "-pages", "1", in) + // Page one's own text, read both ways, has to read the same. + page1 := whole[:strings.Index(whole, "the second page")] + if strings.TrimSpace(page1) != strings.TrimSpace(first) { + t.Errorf("page one alone reads\n%s\nand as part of the document\n%s", first, page1) + } +} + +func TestMarkdownVerbWritesPageRecords(t *testing.T) { + // A title written the way a real producer writes one: UTF-16 behind a + // byte order mark. + info := reader.Dict{ + "Title": reader.String("\xfe\xff\x00R\x00\xe9\x00s\x00u\x00m\x00\xe9"), + "Author": reader.String("Ada"), + } + code, out, errOut := exec("markdown", "-json", documentFixture(t, info)) + if code != 0 { + t.Fatalf("markdown -json said %d: %s", code, errOut) + } + var got []pageRecord + 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 records, want one per page: %+v", len(got), got) + } + if !strings.Contains(got[0].Text, "# The Title") { + t.Errorf("the first record does not hold the page's markdown: %q", got[0].Text) + } + // The three keys a retrieval pipeline needs, spelled the way + // LlamaMarkdownReader spells them. + if got[1].Metadata["page_number"] != "2" || got[1].Metadata["page_count"] != "2" { + t.Errorf("the second record says %v", got[1].Metadata) + } + if !strings.HasSuffix(got[0].Metadata["file_path"], ".pdf") { + t.Errorf("file_path is %q", got[0].Metadata["file_path"]) + } + // ⛔ And the document's own metadata, DECODED. ToString would have put + // "\xfe\xffR\x00é\x00…" in here. + if got[0].Metadata["title"] != "Résumé" { + t.Errorf("title is %q, want %q", got[0].Metadata["title"], "Résumé") + } + if got[0].Metadata["author"] != "Ada" { + t.Errorf("author is %q", got[0].Metadata["author"]) + } +} + +// A document that says nothing about itself still gets the three keys that +// describe where the text came from. +func TestPageRecordsOfADocumentWithNoMetadata(t *testing.T) { + _, out, _ := exec("markdown", "-json", documentFixture(t, nil)) + var got []pageRecord + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("not JSON: %v", err) + } + if len(got) == 0 { + t.Fatal("no records") + } + if got[0].Metadata["page_number"] != "1" { + t.Errorf("metadata is %v", got[0].Metadata) + } +} + +// An entry that is not text is left out rather than rendered: the information +// dictionary is the one place a document may put anything at all, and +// "author: 4" is worse than no author. +func TestAMetadataEntryThatIsNotText(t *testing.T) { + info := reader.Dict{"Author": reader.Integer(4), "Title": reader.String("ok")} + _, out, _ := exec("markdown", "-json", documentFixture(t, info)) + var got []pageRecord + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("not JSON: %v", err) + } + if _, there := got[0].Metadata["author"]; there { + t.Errorf("a number was rendered as an author: %v", got[0].Metadata) + } + if got[0].Metadata["title"] != "ok" { + t.Errorf("metadata is %v", got[0].Metadata) + } +} + +// An empty entry is left out too: a key whose value is nothing says less than +// no key at all, and a pipeline filtering on "has a title" would be wrong. +func TestAnEmptyMetadataEntry(t *testing.T) { + info := reader.Dict{"Title": reader.String("")} + _, out, _ := exec("markdown", "-json", documentFixture(t, info)) + var got []pageRecord + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("not JSON: %v", err) + } + if _, there := got[0].Metadata["title"]; there { + t.Errorf("an empty title was kept: %v", got[0].Metadata) + } +} + +// No pages gives `[]`, never `null`: a caller testing the length of what came +// back should not have to test for nothing first. +func TestNoPagesIsAnEmptyListAndNotNull(t *testing.T) { + code, out, errOut := exec("markdown", "-json", writeDocument(t, nil)) + if code != 0 { + t.Fatalf("a document with no pages said %d: %s", code, errOut) + } + if strings.TrimSpace(out) != "[]" { + t.Errorf("it wrote %q, want []", strings.TrimSpace(out)) + } +} + +// The JSON is indented for somebody to read as well as for a parser, which is +// what the tools this answers for do. +func TestThePageRecordsAreIndented(t *testing.T) { + _, out, _ := exec("markdown", "-json", documentFixture(t, nil)) + if !strings.Contains(out, "[\n {\n \"text\":") { + t.Errorf("the JSON is not indented two spaces at a time:\n%.120s", out) + } +} + +func TestMarkdownVerbRefusesWhatItCannotDo(t *testing.T) { + in := documentFixture(t, nil) + cases := []struct { + name string + args []string + says string + }{ + {"no file at all", []string{"markdown"}, "expected "}, + {"two files", []string{"markdown", in, in}, "expected "}, + {"a flag it does not have", []string{"markdown", "-nope", in}, "flag provided but not defined"}, + {"a range it cannot read", []string{"markdown", "-pages", "nope", in}, "nope"}, + {"a file that is not there", []string{"markdown", 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) + } + } +} + +// ⛔ Both shapes have to hand back the writer's error rather than finishing +// quietly: `pdfops markdown … | head` closes the pipe. +func TestMarkdownVerbSaysWhenTheWriterRefusesIt(t *testing.T) { + in := documentFixture(t, nil) + for _, args := range [][]string{{"markdown", in}, {"markdown", "-json", in}} { + c := &context{out: refusingWriter{}, errOut: refusingWriter{}} + if err := runMarkdown(c, args[1:]); err == nil { + t.Errorf("%v wrote nothing and said nothing was wrong", args) + } + } +} diff --git a/cmd/pdfops/run.go b/cmd/pdfops/run.go index 3ae94ed..eb09187 100644 --- a/cmd/pdfops/run.go +++ b/cmd/pdfops/run.go @@ -75,6 +75,7 @@ var commands = []command{ {"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}, + {"markdown", "[-pages ] [-json] ", "read the pages back as a document: headings, lists and tables", runMarkdown}, {"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}, @@ -381,7 +382,11 @@ func runInfo(c *context, args []string) error { } sort.Strings(keys) for _, k := range keys { - if s, ok := reader.ToString(info[reader.Name(k)]); ok { + // ⛔ TextString and not ToString. This printed + // "creator \xfe\xffL\x00a\x00T\x00e\x00X" for as long as the verb has + // existed: the bytes of a text string are not text, and UTF-16 behind + // a byte order mark is what most producers write. + if s, ok := reader.TextString(info[reader.Name(k)]); ok { fmt.Fprintf(c.out, "%-10s %s\n", strings.ToLower(k), s) } } diff --git a/go.mod b/go.mod index 1f126bd..c9ade3f 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,10 @@ go 1.27.1 require ( github.com/go-gfx/gfx v0.34.1 - github.com/go-pdfkit/extract v0.6.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 - github.com/go-pdfkit/reader v0.8.0 + github.com/go-pdfkit/reader v0.9.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 diff --git a/go.sum b/go.sum index 9fe668a..fb9300a 100644 --- a/go.sum +++ b/go.sum @@ -12,14 +12,16 @@ github.com/go-opentype/fonts v0.12.0 h1:gjYYkbDZBK3JHpraakgGNnXdkGq8oADerhDYjC22 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/document v0.1.0 h1:PjkXDH4ZBCpfSZ7VL9J+ygKR8TAqPR/JqXS/aRx3FAA= +github.com/go-pdfkit/document v0.1.0/go.mod h1:fho9Q82CBLpxtuv6b0bR46uw8iEbscb/uGmln524CvI= +github.com/go-pdfkit/extract v0.9.0 h1:gmyIFqZBxBBx6ccpUbF0Sw67jUmAp7q9vQ8mEsTyuFA= +github.com/go-pdfkit/extract v0.9.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.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/reader v0.9.0 h1:4U2oZXozJl+1bRyzK90sb6pQB+Qm6dCPjBAk4c71tqU= +github.com/go-pdfkit/reader v0.9.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=