Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to UniDoc PDF Debugger are recorded here. Format follows Kee

### Added

- `dump structure [--json] <file>` prints a tagged PDF's structure tree and lines it up with the page content. Each element shows its `/S` (and the role it maps to), reference, page, `/T`, `/Lang`, `/Alt`, `/ActualText` and `/E`, with its MCID, MCR and OBJR kids underneath. Each page gets a table, one row per MCID, with how often the content marks it, the element in the page's ParentTree slot and the elements whose `/K` claims it; rows where those do not agree on one element are marked `*`. Cycles, a tree deeper than 256 levels, a `/Pg` that is not a page, an EMC with nothing open, a missing property list, a `/StructParents` key the ParentTree lacks and malformed ParentTree nodes are reported where they occur, and the command still exits 0. `--json` carries the full report. MCIDs inside Form XObjects are not aligned
- File > "Reload from Disk" (Cmd+R on macOS, Ctrl+R on Windows and Linux, and F5 everywhere) opens the active tab's file again in place. The tab keeps its position and its left-rail view; the tree, selection, detail panel, history and recent jumps reset as for a fresh open. A short "reloaded - view reset" note appears at the top of the window. A reload that finishes after you switched to another tab replaces that tab without taking focus. If the file is gone or no longer parses, the old tab stays as it was and the error banner says why; on Windows a file another app has locked shows "in use by another app - try again" instead
- Open files are watched for changes on disk, background tabs included. When a file is rewritten, replaced or deleted, its tab gets a small amber dot (2 to 4 seconds after the write while the window has focus, 10 to 20 seconds otherwise, also for a file a generator keeps rewriting) and a tooltip with the time it changed. The detail panel shows "Document updated" with Reload and Compare buttons, or "File not found". Nothing reloads by itself, and the marker never moves focus or shifts the layout. Detection compares size, modification time and file identity, so an in-place rewrite that keeps all three is not seen
- File > "Compare with File on Disk" diffs the open version against the file as it is now on disk, in the Diff panel, labelled "On disk (14:05)". Files over 500 MB ask first. Reload in the Diff header then makes the compared copy the tab's document without parsing it again
Expand Down
7 changes: 4 additions & 3 deletions cmd/cli/cmd_docview.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ import (
)

// docViewFlags holds the parsed flags common to the document-level dump
// subcommands (xref, objects, pages, images, bytes, signatures). None take --ref.
// subcommands (xref, objects, pages, structure, images, bytes, signatures). None
// take --ref.
type docViewFlags struct {
pretty bool
json bool // opt into JSON: structured for xref/objects/pages/images/signatures, the text wrapper for bytes
json bool // opt into JSON: structured for xref/objects/pages/structure/images/signatures, the text wrapper for bytes
}

// parseDocViewFlags builds and parses a FlagSet for a document-level dump
// subcommand. resource is the bare resource name used in the usage message.
// --json is read into f.json uniformly; the handler decides its meaning: a
// format switch for xref/objects/pages/images/signatures (plain-text default
// format switch for xref/objects/pages/structure/images/signatures (plain-text default
// -> JSON) or a payload wrapper for bytes (raw bytes default -> decoded JSON
// payload). On failure it writes usage and returns ok=false.
func parseDocViewFlags(resource string, args []string) (filePath string, f docViewFlags, ok bool) {
Expand Down
346 changes: 346 additions & 0 deletions cmd/cli/cmd_structure.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,346 @@
package main

import (
"fmt"
"io"
"os"
"strconv"
"strings"

"unidoc-pdf-debugger/internal/pdfcore"
)

// runStructureDump parses flags and dispatches the tagged-structure dump.
func runStructureDump(args []string) int {
filePath, f, ok := parseDocViewFlags("structure", args)
if !ok {
return 1
}
return execStructureDump(filePath, f)
}

// execStructureDump opens the PDF and renders its structure report.
func execStructureDump(filePath string, f docViewFlags) (exitCode int) {
defer func() {
if r := recover(); r != nil {
writeJSONError(os.Stderr, fmt.Sprintf("internal error: %v", r))
exitCode = 2
}
}()

ins, _, code := openForCLI(filePath)
if code != 0 {
return code
}
defer func() { _ = ins.Close("cli") }()

report, err := ins.GetStructure("cli")
if err != nil {
writeJSONError(os.Stderr, err.Error())
return 2
}
return writeStructureDump(os.Stdout, os.Stderr, report, f)
}

// writeStructureDump writes the report to stdout: the JSON object with
// --json, the plain-text view otherwise. Findings never change the exit code;
// a failed write exits 2 with the error object on stderr.
func writeStructureDump(stdout, stderr io.Writer, r *pdfcore.StructureReport, f docViewFlags) int {
var err error
if f.json {
err = emit(stdout, r, f.pretty)
} else {
_, err = io.WriteString(stdout, structurePlain(r))
}
if err != nil {
writeJSONError(stderr, fmt.Sprintf("failed to write output: %v", err))
return 2
}
return 0
}

// structurePlain renders the report as plain text. NON-CONTRACTUAL; use
// --json to parse.
func structurePlain(r *pdfcore.StructureReport) string {
var b strings.Builder
if !r.HasStructTree {
b.WriteString("No structure tree (/StructTreeRoot absent)\n")
} else {
writeStructureHeader(&b, r)
}
if r.HasStructTree {
c := r.Counts
fmt.Fprintf(&b, "\nElements (%d; kids: %d MCID, %d MCR, %d OBJR, %d error):\n",
c.Elements, c.MCIDKids, c.MCRKids, c.OBJRKids, c.ErrorKids)
writeStructKids(&b, r.Elements, 0)
}
b.WriteString("\n")
writeStructPages(&b, r.Pages)
if len(r.OtherParentTree) > 0 {
b.WriteString("\nOther ParentTree keys:\n")
for _, e := range r.OtherParentTree {
line := fmt.Sprintf(" %d -> %s", e.Key, joinNonEmpty(asciiSafe(e.ElementRef), asciiSafe(e.S)))
if e.Note != "" {
line += " note: " + asciiSafe(e.Note)
}
if e.Err != "" {
line += " error: " + asciiSafe(e.Err)
}
b.WriteString(line + "\n")
}
}
if len(r.Problems) > 0 {
fmt.Fprintf(&b, "\nProblems (%d):\n", len(r.Problems))
for _, p := range r.Problems {
b.WriteString(" " + asciiSafe(p.Where) + ": " + asciiSafe(p.Message) + "\n")
}
}
return b.String()
}

// writeStructureHeader writes the document-level lines.
func writeStructureHeader(b *strings.Builder, r *pdfcore.StructureReport) {
line := "Structure tree: " + orText(r.RootRef, "(direct)")
if r.ParentTreeNextKey != nil {
line += fmt.Sprintf(" (ParentTreeNextKey %d)", *r.ParentTreeNextKey)
}
b.WriteString(line + "\n")
if r.HasParentTree {
b.WriteString("ParentTree: " + orText(r.ParentTreeRef, "(direct)") + "\n")
} else {
b.WriteString("ParentTree: absent\n")
}
marked := "absent"
if r.Marked != nil {
marked = strconv.FormatBool(*r.Marked)
}
b.WriteString("MarkInfo /Marked: " + marked + "\n")
b.WriteString("Document /Lang: " + asciiSafe(orText(r.DocLang, "absent")) + "\n")
if len(r.RoleMap) > 0 {
pairs := make([]string, len(r.RoleMap))
for i, e := range r.RoleMap {
pairs[i] = asciiSafe(e.From) + " -> " + asciiSafe(orText(e.To, "?"))
}
b.WriteString("RoleMap: " + strings.Join(pairs, ", ") + "\n")
}
}

// writeStructKids writes kids, two spaces of indent per level.
func writeStructKids(b *strings.Builder, kids []*pdfcore.StructKid, level int) {
indent := strings.Repeat(" ", level)
for _, k := range kids {
var line string
switch k.Kind {
case "element":
line = elementLine(k.Element)
case "mcid":
line = fmt.Sprintf("MCID %d", *k.MCID)
case "mcr":
line = "MCR"
if k.MCID != nil {
line += fmt.Sprintf(" MCID %d", *k.MCID)
}
if k.Page > 0 {
line += fmt.Sprintf(" page %d", k.Page)
}
if k.StmRef != "" {
line += " stream " + asciiSafe(k.StmRef)
}
case "objr":
line = "OBJR " + joinNonEmpty(asciiSafe(orText(k.ObjRef, "-")), asciiSafe(k.ObjType))
default:
line = "error: " + asciiSafe(k.Err)
}
if k.Kind != "error" && k.Kind != "element" && k.Err != "" {
line += " error: " + asciiSafe(k.Err)
}
b.WriteString(indent + line + "\n")
if k.Element != nil {
writeStructKids(b, k.Element.Kids, level+1)
}
}
}

// elementLine renders one element: S, mapped S, ref, page and text fields.
// Each value is made ASCII-safe on its own, so the line is never escaped
// again as a whole.
func elementLine(e *pdfcore.StructElem) string {
parts := []string{asciiSafe(orText(e.S, "(no /S)"))}
if e.MappedS != "" {
parts[0] += " (" + asciiSafe(e.MappedS) + ")"
}
parts[0] += " " + asciiSafe(orText(e.Ref, "(direct)"))
if e.Page > 0 {
p := fmt.Sprintf("page %d", e.Page)
if e.PageInherited {
p += " (inherited)"
}
parts = append(parts, p)
} else if e.PgRef != "" {
parts = append(parts, "/Pg "+asciiSafe(e.PgRef))
}
text := func(label, v string) {
c := pdfcore.ClampDisplayValue(v, pdfcore.TreeValueCap)
if s := asciiSafe(c); s != c {
parts = append(parts, label+" "+s)
} else {
parts = append(parts, label+" \""+c+"\"")
}
}
if e.Title != "" {
text("T", e.Title)
}
if e.Lang != "" {
text("Lang", e.Lang)
}
for _, f := range []struct {
label string
v *string
}{{"Alt", e.Alt}, {"ActualText", e.ActualText}, {"E", e.E}} {
if f.v != nil {
text(f.label, *f.v)
}
}
if e.Err != "" {
parts = append(parts, "error: "+asciiSafe(e.Err))
}
return strings.Join(parts, " ")
}

// pageIsEmpty reports whether a page has nothing to show: no /StructParents,
// no marked content, no alignment row (a tree element may claim an MCID on a
// page with neither) and no error.
func pageIsEmpty(p *pdfcore.StructPage) bool {
if p.StructParents != nil || p.ParentTreeErr != "" || p.Err != "" || len(p.ContentMCIDs) > 0 || len(p.Alignment) > 0 {
return false
}
for _, s := range p.Streams {
if s.Opens > 0 || s.Closes > 0 || s.Err != "" {
return false
}
}
return true
}

// writeStructPages writes one block per page with structure content and
// collapses the rest into one range list.
func writeStructPages(b *strings.Builder, pages []*pdfcore.StructPage) {
var empty []int
legend := false
for _, p := range pages {
if pageIsEmpty(p) {
empty = append(empty, p.PageNum)
continue
}
opens, closes := 0, 0
for _, s := range p.Streams {
opens += s.Opens
closes += s.Closes
}
sp := "absent"
if p.StructParents != nil {
sp = strconv.Itoa(*p.StructParents)
}
streams := fmt.Sprintf("%d streams", len(p.Streams))
if len(p.Streams) == 1 {
streams = "1 stream"
}
fmt.Fprintf(b, "Page %d /StructParents %s %s BDC/BMC %d, EMC %d\n", p.PageNum, sp, streams, opens, closes)
if p.Err != "" {
b.WriteString(" error: " + asciiSafe(p.Err) + "\n")
}
if p.ParentTreeErr != "" {
b.WriteString(" ParentTree error: " + asciiSafe(p.ParentTreeErr) + "\n")
}
for _, s := range p.ParentTree {
if s.Err != "" {
fmt.Fprintf(b, " ParentTree slot %d: error: %s\n", s.MCID, asciiSafe(s.Err))
}
}
for i, s := range p.Streams {
// Depth carries across a page's streams, so only the last stream's
// DepthAtEnd shows a sequence left open.
unclosed := i == len(p.Streams)-1 && s.DepthAtEnd != 0
switch {
case s.Err != "":
b.WriteString(" stream " + s.Ref + ": error: " + asciiSafe(s.Err) + "\n")
case s.MinDepth < 0 || unclosed:
fmt.Fprintf(b, " stream %s: BDC/BMC %d, EMC %d, depth at end %d, min depth %d\n", s.Ref, s.Opens, s.Closes, s.DepthAtEnd, s.MinDepth)
}
}
for _, c := range p.ContentMCIDs {
if c.Err != "" {
ref := strconv.Itoa(c.Stream)
if c.Stream < len(p.Streams) {
ref = p.Streams[c.Stream].Ref
}
fmt.Fprintf(b, " stream %s line %d col %d: %s %s\n", ref, c.Line, c.Col, asciiSafe(orText(c.Tag, "?")), asciiSafe(c.Err))
}
}
if len(p.Alignment) == 0 {
continue
}
t := newTable("MCID", "CONTENT", "PARENTTREE", "TREE", "")
starred := false
for _, a := range p.Alignment {
mark := ""
if !a.Consistent {
mark, starred = "*", true
}
// Cells are escaped before rendering so column widths count the
// escaped text.
t.AddRow(strconv.Itoa(a.MCID), strconv.Itoa(a.ContentCount),
orText(joinNonEmpty(asciiSafe(a.ParentTreeElement), asciiSafe(a.ParentTreeS)), "-"),
asciiSafe(orText(strings.Join(a.TreeElements, ", "), "-")), mark)
}
var tb strings.Builder
_ = t.Render(&tb)
for line := range strings.SplitSeq(strings.TrimSuffix(tb.String(), "\n"), "\n") {
b.WriteString(" " + strings.TrimRight(line, " ") + "\n")
}
if starred && !legend {
b.WriteString(" * = content count, ParentTree slot and tree claimants do not line up on one element\n")
legend = true
}
}
if len(empty) > 0 {
b.WriteString("Pages with no /StructParents and no marked content: " + pageRanges(empty) + "\n")
}
}

// pageRanges renders ascending page numbers as "1-3, 5".
func pageRanges(nums []int) string {
var parts []string
for i := 0; i < len(nums); {
j := i
for j+1 < len(nums) && nums[j+1] == nums[j]+1 {
j++
}
if i == j {
parts = append(parts, strconv.Itoa(nums[i]))
} else {
parts = append(parts, fmt.Sprintf("%d-%d", nums[i], nums[j]))
}
i = j + 1
}
return strings.Join(parts, ", ")
}

// orText returns s, or alt when s is empty.
func orText(s, alt string) string {
if s == "" {
return alt
}
return s
}

// joinNonEmpty joins the non-empty values with a space.
func joinNonEmpty(vals ...string) string {
var out []string
for _, v := range vals {
if v != "" {
out = append(out, v)
}
}
return strings.Join(out, " ")
}
Loading
Loading