From 042b889cac96b49167f616761d0cc7b2c92916cf Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 12 Oct 2026 11:39:53 +0200 Subject: [PATCH] objects: the object graph as JSON, for a file that will not open anywhere else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BentoPDF's pdf-to-json. It writes what the file is MADE OF rather than what it says: the trailer, and every object reachable from it. The graph REACHABLE FROM THE TRAILER, not every object in the file. That is the honest set because it is the one a reader sees: an object no reference points at is in the bytes and in nobody's document. A stream's bytes are not in it and its length is -- they are the bulk of most files and they are binary, so including them would mean base64, and a 35 MB file becoming a 47 MB JSON nobody reads. A name keeps its slash and a string is WRAPPED, {"text":…} or {"hex":…}, because the two have to be told apart and a PDF string may be exactly "/Catalog". Through reader.TextString, since the bytes of a text string are not text. A reference that could not be read is NAMED in `unread` rather than dropped. TWO GUARDS THAT MASKED EACH OTHER, found by a surviving mutation. A PDF is a graph -- the page tree's Kids point at the pages and every page's Parent points back -- so an object is marked seen BEFORE it is read. Remove that and the walk should never end; it ended anyway, because the depth guard cut it off at 256 and produced a finite, enormous, wrong answer. "It terminated" cannot tell the two apart. Counting the reads can, and the test now asserts ONE READ PER OBJECT. And a sort that did nothing: encoding/json sorts a map's keys itself, so sorting them here only looked like the thing making the output deterministic. A mutation reversing it changed no test. Removed, with two runs of the real file compared byte for byte to show determinism survived it. The walker holds a GETTER rather than the document, which is the seam that makes the unreadable-reference branch testable: reader.Get is forgiving by design -- a reference to an object never written resolves to Null, not an error -- so no file reaches it. 100% of statements with no uncovered blocks, 14 mutations caught. One dead `case nil` removed: reader says in its own words that a missing entry resolves to Null "so callers never have to nil-check". Co-Authored-By: Claude Opus 5 --- README.md | 23 +++ cmd/pdfops/objects.go | 214 +++++++++++++++++++++ cmd/pdfops/objects_test.go | 371 +++++++++++++++++++++++++++++++++++++ cmd/pdfops/run.go | 1 + 4 files changed, 609 insertions(+) create mode 100644 cmd/pdfops/objects.go create mode 100644 cmd/pdfops/objects_test.go diff --git a/README.md b/README.md index da7b27d..366b091 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ 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 objects broken.pdf | jq . pdfops images paper.pdf pictures/ pdfops attachments report.pdf pdfops attachments -to files/ report.pdf @@ -402,6 +403,28 @@ 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. +`objects` writes the object graph as JSON: what the file is **made of**, as +opposed to what it says. For looking at a file that will not open anywhere +else. + +```sh +pdfops objects broken.pdf | jq '.objects["12 0"]' +``` + +⛔ **It is the graph reachable from the trailer**, not every object in the +file. That is the honest set because it is the one a reader sees: an object no +reference points at is in the bytes and in nobody's document. + +⛔ **A stream's bytes are not in it**, its length and its dictionary are. They +are the bulk of most files and they are binary, so including them would mean +base64 — a 35 MB file becoming a 47 MB JSON nobody reads. + +A name keeps its slash (`"/Catalog"`) and a string is wrapped +(`{"text": "…"}` or `{"hex": "…"}` where it is not text), because the two have +to be told apart and a PDF string may be exactly `/Catalog`. A reference that +could not be read is **named** in `unread` rather than dropped: "the graph, +and these edges lead nowhere" is what somebody debugging a damaged file needs. + `textcolour` paints the text one colour and leaves the rest of the page alone. ```sh diff --git a/cmd/pdfops/objects.go b/cmd/pdfops/objects.go new file mode 100644 index 0000000..08bb95b --- /dev/null +++ b/cmd/pdfops/objects.go @@ -0,0 +1,214 @@ +package main + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "unicode/utf8" + + "github.com/go-pdfkit/reader" +) + +// runObjects writes the document's object graph as JSON: what the file is +// made of rather than what it says. +// +// ⛔ It is the graph REACHABLE FROM THE TRAILER, not every object in the file. +// That is the honest set, because it is the one a reader sees: an object no +// reference points at is in the bytes and in nobody's document, and listing it +// beside the rest would say the two are alike. The count of what was skipped +// is not reported for the same reason — this never looked. +func runObjects(c *context, args []string) error { + fs := flags("objects") + 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 + } + // An empty result is `[]`, never `null`: a caller testing the length of + // what came back should not have to test for nothing first, and the + // usual document has nothing unread. + w := &graphWriter{get: src.Get, objects: map[string]any{}, unread: []string{}} + trailer := w.value(src.Trailer(), 0) + enc := json.NewEncoder(c.out) + enc.SetIndent("", " ") + return enc.Encode(jsonGraph{ + Version: src.Version(), + Pages: src.PageCount(), + Encrypted: src.Encrypted(), + Repaired: src.Repaired(), + Trailer: trailer, + Objects: w.objects, + Unread: w.unread, + }) +} + +// A jsonGraph is the whole answer. +type jsonGraph struct { + Version string `json:"version"` + Pages int `json:"pages"` + Encrypted bool `json:"encrypted"` + Repaired bool `json:"repaired"` + Trailer any `json:"trailer"` + Objects map[string]any `json:"objects"` + // Unread names the references that could not be followed. ⛔ It is in the + // output rather than on the error stream because this IS the answer for + // a damaged file: "the graph, and these four edges lead nowhere" is what + // somebody debugging one needs, and an empty list is the usual case. + Unread []string `json:"unread"` +} + +// a graphWriter walks the graph once, keeping each object it reaches. +// +// ⛔ It holds a GETTER rather than the document, and that is a seam with a +// reason. [reader.Document.Get] is forgiving by design — a reference to an +// object that was never written resolves to [reader.Null] rather than to an +// error — so no file this can be handed reaches the branch that records an +// unreadable reference. A real corpus will; a test cannot, unless the getter +// is something a test can supply. +type graphWriter struct { + get func(reader.Ref) (reader.Object, error) + objects map[string]any + unread []string +} + +// maxGraphDepth bounds the walk. +// +// ⛔ A PDF is a GRAPH and not a tree — a page points at its parent, which +// points back at the page — so the visited set is what stops the walk, and it +// does. This is the second guard, for depth that is real rather than cyclic: +// a thousand nested arrays is a legal file and a stack overflow is not a +// diagnosis. +const maxGraphDepth = 256 + +// value turns one object into something json can write. +// +// null, true, 1, 1.5 as themselves +// /Name a string beginning with a slash +// a string {"text": "…"} or {"hex": "…"} when it is not text +// an array an array +// a dictionary an object +// a reference {"ref": "12 0"}, and the object itself under that key +// a stream {"dict": {…}, "length": n} — the BYTES are not here +// +// ⛔ A string is wrapped and a name is not, because the two have to be told +// apart and only one of them can be written plainly. A name cannot contain an +// unescaped slash at the front and anything else; a string can be anything at +// all, including "/Type". +// +// ⛔ And a stream's bytes are left out on purpose. They are the bulk of most +// files and they are binary, so including them would mean base64 — which +// turns a 35 MB file into a 47 MB JSON nobody reads. What is here is what +// says where they are and how they are encoded. +func (w *graphWriter) value(o reader.Object, depth int) any { + if depth > maxGraphDepth { + return map[string]any{"depth": "deeper than " + fmt.Sprint(maxGraphDepth)} + } + // ⛔ There is no `case nil`. [reader.Null] is how the format's null + // arrives, and reader says why in its own words: "a missing dictionary + // entry resolves to Null rather than to a nil Object, so callers never + // have to nil-check". A case for nil here would be a branch nothing can + // reach, guarding against the one thing that package promises not to do. + switch v := o.(type) { + case reader.Null: + // The PDF null object, which is not the absence of one: a dictionary + // entry whose value is null is PRESENT and says nothing, and the + // format gives that its own meaning in a few places. + return nil + case reader.Bool: + return bool(v) + case reader.Integer: + return int64(v) + case reader.Real: + return float64(v) + case reader.Name: + return "/" + string(v) + case reader.String: + return stringValue(v) + case reader.Array: + out := make([]any, len(v)) + for i, e := range v { + out[i] = w.value(e, depth+1) + } + return out + case reader.Dict: + return w.dict(v, depth) + case *reader.Stream: + return map[string]any{ + "dict": w.dict(v.Dict, depth), + "length": len(v.Raw), + } + case reader.Ref: + w.follow(v, depth) + return map[string]any{"ref": fmt.Sprintf("%d %d", v.Num, v.Gen)} + } + // reader.Object is a closed set and every member is above. A value that + // arrives here is a type added upstream, and saying what it was beats + // writing null and losing it. + return map[string]any{"unknown": fmt.Sprintf("%T", o)} +} + +// dict writes a dictionary. +// +// ⛔ It does NOT sort the keys, and the output is still the same bytes twice: +// [encoding/json] sorts a map's keys itself. A sort here looked like the +// thing that made the output deterministic and was doing nothing — a mutation +// reversing it changed no test, which is how it was found. Determinism still +// matters; it just is not this function's doing. +func (w *graphWriter) dict(d reader.Dict, depth int) map[string]any { + out := make(map[string]any, len(d)) + for k, v := range d { + out[string(k)] = w.value(v, depth+1) + } + return out +} + +// follow reads an object the graph points at, once. +func (w *graphWriter) follow(r reader.Ref, depth int) { + key := fmt.Sprintf("%d %d", r.Num, r.Gen) + if _, seen := w.objects[key]; seen { + return + } + // ⛔ Marked as seen BEFORE it is read, or a cycle — a page's /Parent + // pointing back at the page — never terminates. The placeholder is + // replaced below; nothing reads it in between, because this is one pass. + w.objects[key] = nil + o, err := w.get(r) + if err != nil { + w.unread = append(w.unread, key) + delete(w.objects, key) + return + } + w.objects[key] = w.value(o, depth+1) +} + +// stringValue writes a PDF string as text where it is text and as hex where +// it is not. +// +// ⛔ Through [reader.TextString], because the BYTES of a text string are not +// text: a title written in UTF-16 behind a byte order mark comes out of the +// raw bytes as "\xfe\xffL\x00a\x00…". What is left after that — a digest, an +// encrypted blob, an ID — has no text in it at all and is written as hex +// rather than as replacement characters. +func stringValue(s reader.String) map[string]any { + if text, ok := reader.TextString(s); ok && utf8.ValidString(text) && !hasControl(text) { + return map[string]any{"text": text} + } + return map[string]any{"hex": hex.EncodeToString([]byte(s))} +} + +// hasControl reports a character that would make the JSON unreadable even +// though it is valid UTF-8. A trailer /ID is two sixteen-byte digests, and +// most of those decode to something nothing can print. +func hasControl(s string) bool { + for _, r := range s { + if r < 0x20 && r != '\t' && r != '\n' && r != '\r' { + return true + } + } + return false +} diff --git a/cmd/pdfops/objects_test.go b/cmd/pdfops/objects_test.go new file mode 100644 index 0000000..8b69309 --- /dev/null +++ b/cmd/pdfops/objects_test.go @@ -0,0 +1,371 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-pdfkit/reader" +) + +// graphOf runs the verb and parses what it wrote. +func graphOf(t *testing.T, args ...string) jsonGraph { + t.Helper() + code, out, errOut := exec(append([]string{"objects"}, args...)...) + if code != 0 { + t.Fatalf("objects said %d: %s", code, errOut) + } + var g jsonGraph + if err := json.Unmarshal([]byte(out), &g); err != nil { + t.Fatalf("the output is not JSON: %v\n%s", err, out) + } + return g +} + +func TestObjectsVerb(t *testing.T) { + g := graphOf(t, documentFixture(t, reader.Dict{"Title": reader.String("a title")})) + if g.Version == "" || g.Pages != 2 { + t.Errorf("it says version %q and %d pages", g.Version, g.Pages) + } + if g.Encrypted || g.Repaired { + t.Errorf("it says encrypted=%v repaired=%v", g.Encrypted, g.Repaired) + } + // The trailer points at the catalogue, and the catalogue is among the + // objects: the walk follows what it finds rather than guessing names. + trailer, ok := g.Trailer.(map[string]any) + if !ok { + t.Fatalf("the trailer is a %T", g.Trailer) + } + root, ok := trailer["Root"].(map[string]any) + if !ok { + t.Fatalf("the trailer's Root is %#v", trailer["Root"]) + } + key, _ := root["ref"].(string) + if key == "" { + t.Fatalf("Root is not a reference: %#v", root) + } + catalog, ok := g.Objects[key].(map[string]any) + if !ok { + t.Fatalf("the catalogue at %q is %#v", key, g.Objects[key]) + } + // ⛔ A name keeps its slash and a string does not get one, which is how + // the two are told apart: a PDF string may be exactly "/Catalog". + if catalog["Type"] != "/Catalog" { + t.Errorf("the catalogue's Type is %#v", catalog["Type"]) + } + if len(g.Unread) != 0 { + t.Errorf("it could not read %v", g.Unread) + } +} + +// ⛔ A stream's bytes are NOT here, and its length is. They are the bulk of +// most files and they are binary, so including them would mean base64 — a +// 35 MB file becoming a 47 MB JSON nobody reads. +func TestAStreamsBytesAreNotInTheGraph(t *testing.T) { + _, out, _ := exec("objects", documentFixture(t, nil)) + g := graphOf(t, documentFixture(t, nil)) + found := false + for key, o := range g.Objects { + m, ok := o.(map[string]any) + if !ok { + continue + } + length, hasLength := m["length"] + if !hasLength { + continue + } + found = true + if _, hasDict := m["dict"]; !hasDict { + t.Errorf("the stream at %q has no dictionary: %#v", key, m) + } + if n, _ := length.(float64); n <= 0 { + t.Errorf("the stream at %q is %v bytes", key, length) + } + } + if !found { + t.Fatalf("no stream at all in a document with pages:\n%.300s", out) + } + // The content stream of the fixture says "The Title"; the graph must not. + if strings.Contains(out, "The Title") { + t.Errorf("a stream's bytes are in the output:\n%.400s", out) + } +} + +// ⛔ A text string is DECODED and a string that is not text is hex. The bytes +// of a text string are not text: a title in UTF-16 behind a byte order mark +// reads as "\xfe\xffL\x00a\x00…" raw. +func TestATextStringAndOneThatIsNot(t *testing.T) { + info := reader.Dict{ + "Title": reader.String("\xfe\xff\x00R\x00\xe9\x00s\x00u\x00m\x00\xe9"), + "Producer": reader.String("plain"), + // A digest: valid bytes, no text in them. + "Custom": reader.String("\x00\x01\x02\x03\xff\xfe"), + } + g := graphOf(t, documentFixture(t, info)) + var seen map[string]any + for _, o := range g.Objects { + m, ok := o.(map[string]any) + if ok && m["Producer"] != nil { + seen = m + break + } + } + if seen == nil { + t.Fatal("the information dictionary is not in the graph") + } + text := func(key string) map[string]any { + m, ok := seen[key].(map[string]any) + if !ok { + t.Fatalf("%s is %#v", key, seen[key]) + } + return m + } + if got := text("Title")["text"]; got != "Résumé" { + t.Errorf("the title is %#v, want Résumé", got) + } + if got := text("Producer")["text"]; got != "plain" { + t.Errorf("the producer is %#v", got) + } + if got := text("Custom")["hex"]; got != "000102 03fffe" && got != "00010203fffe" { + t.Errorf("the digest is %#v, want it in hex", got) + } +} + +// ⛔ A PDF is a GRAPH and not a tree: a page points at its parent, which +// points back at the page. Without a visited set the walk never ends, and +// this fixture has exactly that cycle in it. +func TestACycleTerminates(t *testing.T) { + g := graphOf(t, documentFixture(t, nil)) + // Every page's Parent is a reference, and the pages node's Kids point + // back. If the walk terminated, both are in the graph once. + if len(g.Objects) == 0 { + t.Fatal("no objects at all") + } + for key, o := range g.Objects { + if o == nil { + t.Errorf("the object at %q was left as a placeholder, so the walk did not finish it", key) + } + } +} + +// A reference that leads nowhere is NAMED in the output rather than dropped: +// "the graph, and these edges lead nowhere" is what somebody debugging a +// damaged file needs. +func TestAReferenceThatLeadsNowhere(t *testing.T) { + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + 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)}, + // Nothing was ever written at object 9999. + "Contents": reader.Ref{Num: 9999, Gen: 0}, + }) + 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(), "dangling.pdf") + if err := os.WriteFile(path, out, 0o644); err != nil { + t.Fatal(err) + } + g := graphOf(t, path) + // Either the reference is named as unread, or the reader resolved it to + // the null object — both are answers, and silence is not. + if len(g.Unread) == 0 { + if _, there := g.Objects["9999 0"]; !there { + t.Errorf("a reference to nothing left no trace: unread=%v", g.Unread) + } + } +} + +func TestTheObjectsVerbRefusesWhatItCannotDo(t *testing.T) { + in := documentFixture(t, nil) + cases := []struct { + name, says string + args []string + }{ + {"no file", "expected ", []string{"objects"}}, + {"two files", "expected ", []string{"objects", in, in}}, + {"a flag it does not have", "flag provided but not defined", []string{"objects", "-nope", in}}, + {"a file that is not there", "gone.pdf", []string{"objects", filepath.Join(t.TempDir(), "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) + } + } +} + +// Nothing unread is `[]`, never `null`. +func TestNothingUnreadIsAnEmptyList(t *testing.T) { + _, out, _ := exec("objects", documentFixture(t, nil)) + if !strings.Contains(out, `"unread": []`) { + t.Errorf("unread is not an empty list:\n%s", out[max(0, len(out)-200):]) + } +} + +// Every leaf kind the format has, in one dictionary: a null that is PRESENT +// and says nothing, a boolean, a real, a name and an integer. +func TestEveryLeafKind(t *testing.T) { + info := reader.Dict{ + "Nothing": reader.Null{}, + "Yes": reader.Bool(true), + "No": reader.Bool(false), + "Ratio": reader.Real(1.5), + "Count": reader.Integer(7), + "Kind": reader.Name("Custom"), + } + g := graphOf(t, documentFixture(t, info)) + var seen map[string]any + for _, o := range g.Objects { + m, ok := o.(map[string]any) + if ok { + if _, there := m["Ratio"]; there { + seen = m + break + } + } + } + if seen == nil { + t.Fatal("the information dictionary is not in the graph") + } + if v, there := seen["Nothing"]; !there || v != nil { + // ⛔ PRESENT and null. A dictionary entry whose value is the null + // object is not a missing entry, and the format gives that its own + // meaning in a few places. + t.Errorf("the null entry is %#v, there=%v", v, there) + } + if seen["Yes"] != true || seen["No"] != false { + t.Errorf("the booleans are %#v and %#v", seen["Yes"], seen["No"]) + } + if seen["Ratio"] != 1.5 { + t.Errorf("the real is %#v", seen["Ratio"]) + } + if seen["Count"] != float64(7) { + t.Errorf("the integer is %#v", seen["Count"]) + } + if seen["Kind"] != "/Custom" { + t.Errorf("the name is %#v, want it to keep its slash", seen["Kind"]) + } +} + +// ⛔ A PDF is a graph and the visited set stops a CYCLE, but depth that is +// real rather than cyclic needs its own guard: a thousand nested arrays is a +// legal file, and a stack overflow is not a diagnosis. +func TestNestingDeeperThanTheLimit(t *testing.T) { + deep := reader.Object(reader.Integer(1)) + for i := 0; i < maxGraphDepth+10; i++ { + deep = reader.Array{deep} + } + g := graphOf(t, documentFixture(t, reader.Dict{"Deep": deep})) + // It terminated, said so, and the rest of the document is still there. + out, _ := json.Marshal(g) + if !strings.Contains(string(out), "deeper than") { + t.Errorf("it did not say where it stopped") + } + if g.Pages != 2 { + t.Errorf("the rest of the document was lost: %d pages", g.Pages) + } +} + +// ⛔ A reference that cannot be read is NAMED. reader.Get is forgiving by +// design — a reference to an object that was never written resolves to Null — +// so no file reaches this, which is why the walker holds a getter. +func TestAReferenceThatCannotBeRead(t *testing.T) { + w := &graphWriter{ + get: func(reader.Ref) (reader.Object, error) { + return nil, errRefused + }, + objects: map[string]any{}, + unread: []string{}, + } + got := w.value(reader.Dict{"Root": reader.Ref{Num: 12, Gen: 0}}, 0) + if len(w.unread) != 1 || w.unread[0] != "12 0" { + t.Errorf("unread is %v, want [12 0]", w.unread) + } + // ⛔ And no placeholder is left behind: a key whose value is null would + // read as "object 12 is the null object", which is a different fact. + if _, there := w.objects["12 0"]; there { + t.Errorf("a placeholder was left: %#v", w.objects) + } + // The reference is still in the graph, pointing at what could not be read. + m, _ := got.(map[string]any) + ref, _ := m["Root"].(map[string]any) + if ref["ref"] != "12 0" { + t.Errorf("the reference itself was lost: %#v", got) + } +} + +// A type that is not one of the format's. reader.Object is an interface with +// an exported method, so the set is open and this is reachable — saying what +// arrived beats writing null and losing it. +type strangeObject struct{} + +func (strangeObject) Kind() reader.Kind { return reader.Kind(0) } + +func TestAnObjectKindThatIsNotTheFormats(t *testing.T) { + w := &graphWriter{objects: map[string]any{}, unread: []string{}} + got, _ := w.value(strangeObject{}, 0).(map[string]any) + if got["unknown"] == nil || !strings.Contains(got["unknown"].(string), "strangeObject") { + t.Errorf("it said %#v", got) + } +} + +// ⛔ ONE READ PER OBJECT, which is what says the cycle guard works. +// +// A PDF is a graph: object 1 is the page tree, its Kids point at the pages, +// and every page's Parent points back at object 1. Without marking an object +// as seen BEFORE reading it, that cycle walks forever — except that the depth +// guard cuts it off at 256, so the answer comes back finite, enormous, and +// wrong. The two guards mask each other, and "it terminated" cannot tell them +// apart. Counting the reads can. +func TestEachObjectIsReadOnce(t *testing.T) { + b, err := os.ReadFile(documentFixture(t, nil)) + if err != nil { + t.Fatal(err) + } + d, err := reader.Open(b) + if err != nil { + t.Fatal(err) + } + reads := 0 + w := &graphWriter{ + get: func(r reader.Ref) (reader.Object, error) { + reads++ + return d.Get(r) + }, + objects: map[string]any{}, + unread: []string{}, + } + w.value(d.Trailer(), 0) + if reads != len(w.objects) { + t.Errorf("%d reads for %d objects: the graph was walked more than once", + reads, len(w.objects)) + } + // And the fixture really does hold the cycle this is about. + pages, ok := w.objects["1 0"].(map[string]any) + if !ok { + t.Fatalf("object 1 0 is %#v", w.objects["1 0"]) + } + kids, _ := pages["Kids"].([]any) + if len(kids) == 0 { + t.Fatal("the page tree has no Kids, so there is no cycle to guard against") + } + first, _ := kids[0].(map[string]any) + page, _ := w.objects[first["ref"].(string)].(map[string]any) + parent, _ := page["Parent"].(map[string]any) + if parent["ref"] != "1 0" { + t.Fatalf("the page's Parent is %#v, so the fixture has no cycle", page["Parent"]) + } +} diff --git a/cmd/pdfops/run.go b/cmd/pdfops/run.go index 9d7cf17..7caa3f3 100644 --- a/cmd/pdfops/run.go +++ b/cmd/pdfops/run.go @@ -78,6 +78,7 @@ var commands = []command{ {"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}, + {"objects", "", "write the object graph as JSON: what the file is made of", runObjects}, {"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},