diff --git a/CHANGELOG.md b/CHANGELOG.md index 10485b4a..b0ca7f27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to UniDoc PDF Debugger are recorded here. Format follows Kee ### Added - `dump structure [--json] ` 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 +- `validate --profile pdfua-1-structural` checks the marked content of every page's `/Contents` (an array is read as one stream, as ISO 32000-1 14.6.1 says) and the structure elements, with five new rules. `marked-content-balance` reports an EMC with nothing open and sequences still open at the end of the page, and also a page content stream that cannot be decoded or a `/Contents` that cannot be read, so unreadable content is a finding rather than a silent pass. `mcid-unique` reports an MCID that marks more than one BDC on a page, listing every occurrence. `mcid-in-parent-tree` reports an MCID that does not reach a structure element through the page's `/StructParents` and ParentTree array, a BDC whose MCID cannot be read, and page content or a content stream it could not check because it cannot be read or decoded. `mcid-not-nested` reports an MCID sequence opened inside another MCID sequence (a BMC or an MCID-less BDC around one is fine). `struct-elem-type` reports a structure element whose `/S` is absent, empty or not a name, or that cannot be read. Content-rule findings name the page, plus the content stream where one is involved, and a finding about one MCID gives the line and column of its BDC; `struct-elem-type` findings name the structure element. Form XObject, annotation appearance and Type3 glyph content is not scanned - 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 @@ -14,6 +15,9 @@ All notable changes to UniDoc PDF Debugger are recorded here. Format follows Kee ### Changed +- `validate --profile pdfua-1-structural` now exits 1 when it finds an MCID that does not reach the ParentTree or cannot be read, page content whose MCIDs could not be checked because it cannot be read or decoded, or a structure element without a usable `/S`, or when `mcid-in-parent-tree` or `struct-elem-type` could not be evaluated (for example when the page tree or the structure tree root could not be read). It used to exit 0 whatever it found. These are the two faults veraPDF fails a file on (ua1 clauses 7.1-3 and 7.1-5). `marked-content-balance`, `mcid-unique` and `mcid-not-nested` report warnings, as veraPDF does, so a marked-content imbalance, a duplicate MCID or a nested MCID exits 0; the catalog rules (`marked`, `struct-tree-root`, `lang`) stay warnings. A warning rule that could not be evaluated still exits 0. `pdfa-1b` exit codes are unchanged. The profile's scope sentence now names the content rules and what it does not examine +- The Validate tab keeps each open document's profile, result and rules section when you switch to another document and back, so the checks no longer have to be run again. A run still in progress when you switch away finishes for its own document. A document starts on PDF/A-1b until its profile is changed, and a document reloaded from disk starts with no result. Switching documents used to clear the result +- The Validate tab shows the findings first. The profile's scope sentence and the list of rules checked moved below them into a "Rules checked (N)" section that starts collapsed and stays open once expanded for that document; its label also counts rules that could not be evaluated, as in "Rules checked (8, 1 not evaluated)" - Non-zero bytes after the CMS in a signature's /Contents, or after the SignedData inside the ContentInfo's `[0]` wrapper, are now reported, and the signature still decomposes in full. Bytes after the CMS show once, as `Trailing bytes: 4 bytes after the CMS at offset 2028 contain non-zero data` in plain text, as `cmsTrailingBytes` (`offset`, `count`) in `--json`, and as a "Trailing bytes" row in the Signatures panel. Bytes after the SignedData get a note with the byte count. Such bytes used to be ignored without a word. Zero padding is not reported - A CMS nested more than 64 levels deep is now a decompose error for DER signatures too. DER signatures used to decompose with no nesting limit diff --git a/cmd/cli/cmd_validate.go b/cmd/cli/cmd_validate.go index c2f6b38d..a575fa5f 100644 --- a/cmd/cli/cmd_validate.go +++ b/cmd/cli/cmd_validate.go @@ -21,7 +21,7 @@ var validateUsage = usageLine("validate") // // 0 ran successfully, ZERO error-severity problems (warnings/info allowed) // 1 ran successfully AND found >=1 error-severity problem (the CI gate), or -// a rule of a gating profile could not be evaluated +// an error-severity rule could not be evaluated // 2 operational or usage error (missing/unreadable file, unknown profile, bad // args, view failure) // @@ -97,10 +97,11 @@ func execValidate(filePath, profile string, jsonOut, pretty bool) (exitCode int) // renderValidate writes the result (plain text default, --json opts in) and // returns the compliance-gate exit code: 1 when any error-severity problem was -// found, else 0. It ALSO returns 1 (fail closed) when a gating-profile rule -// could not be evaluated (degraded to an info problem) - reporting exit 0 "no -// structural errors" for a file whose required rule never actually ran would -// silently pass a CI gate on an unverified document. +// found, else 0. It ALSO returns 1 (fail closed) when a rule whose registry +// severity is error could not be evaluated (degraded to an info problem) - +// reporting exit 0 "no structural errors" for a file whose required rule never +// actually ran would silently pass a CI gate on an unverified document. A +// warning-severity rule that could not be evaluated does not gate. func renderValidate(res *pdfcore.ValidationResult, jsonOut, pretty bool) int { if jsonOut { if err := emit(os.Stdout, res, pretty); err != nil { @@ -111,15 +112,23 @@ func renderValidate(res *pdfcore.ValidationResult, jsonOut, pretty bool) int { writeJSONError(os.Stderr, fmt.Sprintf("failed to write output: %v", err)) return 2 } - if res.Summary.Errors > 0 { - return 1 - } - if res.Summary.Info > 0 && pdfcore.ProfileGates(res.Profile) { + if res.Summary.Errors > 0 || errorRuleNotEvaluated(res) { return 1 } return 0 } +// errorRuleNotEvaluated reports whether a rule whose registry severity is +// error could not be evaluated in res. +func errorRuleNotEvaluated(res *pdfcore.ValidationResult) bool { + for _, r := range res.Rules { + if !r.Evaluated && r.Severity == "error" { + return true + } + } + return false +} + // severityOrder is the display order for the grouped plain-text problem list. var severityOrder = []string{"error", "warning", "info"} @@ -207,9 +216,9 @@ func printValidatePlain(out io.Writer, res *pdfcore.ValidationResult) error { fmt.Fprintf(&b, ", %d %s", res.Summary.Info, plural(res.Summary.Info, "info problem")) } b.WriteString("\n") - // renderValidate exits 1 on an info problem under a gating profile even with - // no errors; name that cause next to the summary. - if res.Summary.Errors == 0 && res.Summary.Info > 0 && pdfcore.ProfileGates(res.Profile) { + // renderValidate exits 1 when an error-severity rule could not be + // evaluated even with no errors; name that cause next to the summary. + if res.Summary.Errors == 0 && errorRuleNotEvaluated(res) { b.WriteString("Exit status 1: a rule that gates the exit code could not be evaluated\n") } _, err := io.WriteString(out, b.String()) diff --git a/cmd/cli/validate_gate_test.go b/cmd/cli/validate_gate_test.go index be978973..6a763296 100644 --- a/cmd/cli/validate_gate_test.go +++ b/cmd/cli/validate_gate_test.go @@ -9,11 +9,11 @@ import ( ) // TestRenderValidate_ExitCodeGate locks the three-way exit contract at the point -// it is decided (renderValidate), including the fail-closed rule: an info-only -// degraded run under a GATING profile (pdfa-1b) must exit 1, so a required rule -// that never actually ran cannot silently report a clean exit 0. The same -// info-only run under the non-gating pdfua-1-structural profile exits 0. The -// plain-text exit explanation prints exactly when the exit is 1 with no errors. +// it is decided (renderValidate), including the fail-closed rule: a run where an +// error-severity rule degraded to info must exit 1, so a required rule that +// never actually ran cannot silently report a clean exit 0. A run where only a +// warning-severity rule degraded exits 0. The plain-text exit explanation +// prints exactly when the exit is 1 with no errors. func TestRenderValidate_ExitCodeGate(t *testing.T) { // Silence the plain-text report the function writes to os.Stdout. devnull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) @@ -25,18 +25,22 @@ func TestRenderValidate_ExitCodeGate(t *testing.T) { os.Stdout = devnull defer func() { os.Stdout = old }() + degraded := func(id string, sev pdfcore.Severity) []pdfcore.RuleInfo { + return []pdfcore.RuleInfo{{RuleID: id, Severity: sev, Evaluated: false}} + } tests := []struct { name string profile string summary pdfcore.ValidationSummary + rules []pdfcore.RuleInfo want int }{ - {"clean pdfa-1b exits 0", pdfcore.ProfilePDFA1B, pdfcore.ValidationSummary{}, 0}, - {"error problem exits 1", pdfcore.ProfilePDFA1B, pdfcore.ValidationSummary{Errors: 1}, 1}, - {"info-only under gating profile fails closed (exit 1)", pdfcore.ProfilePDFA1B, pdfcore.ValidationSummary{Info: 1}, 1}, - {"info-only under non-gating profile exits 0", pdfcore.ProfilePDFUA1Structural, pdfcore.ValidationSummary{Info: 1}, 0}, - {"warnings alone do not gate", pdfcore.ProfilePDFUA1Structural, pdfcore.ValidationSummary{Warnings: 3}, 0}, - {"errors and info under gating profile exit 1", pdfcore.ProfilePDFA1B, pdfcore.ValidationSummary{Errors: 1, Info: 1}, 1}, + {"clean pdfa-1b exits 0", pdfcore.ProfilePDFA1B, pdfcore.ValidationSummary{}, nil, 0}, + {"error problem exits 1", pdfcore.ProfilePDFA1B, pdfcore.ValidationSummary{Errors: 1}, nil, 1}, + {"info-only from a degraded error rule fails closed (exit 1)", pdfcore.ProfilePDFA1B, pdfcore.ValidationSummary{Info: 1}, degraded("font-embedding", "error"), 1}, + {"info-only from a degraded warning rule exits 0", pdfcore.ProfilePDFUA1Structural, pdfcore.ValidationSummary{Info: 1}, degraded("lang", "warning"), 0}, + {"warnings alone do not gate", pdfcore.ProfilePDFUA1Structural, pdfcore.ValidationSummary{Warnings: 3}, nil, 0}, + {"errors and a degraded error rule exit 1", pdfcore.ProfilePDFA1B, pdfcore.ValidationSummary{Errors: 1, Info: 1}, degraded("font-embedding", "error"), 1}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -45,6 +49,7 @@ func TestRenderValidate_ExitCodeGate(t *testing.T) { Summary: tc.summary, Problems: []pdfcore.Problem{}, Disclaimer: pdfcore.DisclaimerText, + Rules: tc.rules, } got := renderValidate(res, false, false) if got != tc.want { diff --git a/cmd/cli/validate_rule_gate_test.go b/cmd/cli/validate_rule_gate_test.go new file mode 100644 index 00000000..3451034b --- /dev/null +++ b/cmd/cli/validate_rule_gate_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "os" + "strings" + "testing" + + "unidoc-pdf-debugger/internal/pdfcore" +) + +// renderValidate gates per rule: a run exits 1 on an error-severity problem or +// when a rule whose registry severity is error could not be evaluated, under +// either profile. A warning-severity rule that could not be evaluated does not +// gate. The plain-text exit explanation prints exactly when the exit is 1 with +// no errors. +func TestRenderValidate_GatesOnUnevaluatedErrorRule(t *testing.T) { + devnull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatalf("open devnull: %v", err) + } + defer func() { _ = devnull.Close() }() + old := os.Stdout + os.Stdout = devnull + defer func() { os.Stdout = old }() + + rule := func(id string, sev pdfcore.Severity, evaluated bool, findings int) pdfcore.RuleInfo { + return pdfcore.RuleInfo{RuleID: id, Severity: sev, SpecRef: "spec", Checks: "checks", Evaluated: evaluated, Findings: findings} + } + tests := []struct { + name string + profile string + summary pdfcore.ValidationSummary + rules []pdfcore.RuleInfo + want int + }{ + { + "UA error rule not evaluated exits 1", pdfcore.ProfilePDFUA1Structural, + pdfcore.ValidationSummary{Info: 1}, + []pdfcore.RuleInfo{rule("lang", "warning", true, 0), rule("mcid-unique", "error", false, 0)}, + 1, + }, + { + "UA warning rule not evaluated exits 0", pdfcore.ProfilePDFUA1Structural, + pdfcore.ValidationSummary{Info: 1}, + []pdfcore.RuleInfo{rule("lang", "warning", false, 0), rule("mcid-unique", "error", true, 0)}, + 0, + }, + { + "UA error finding exits 1", pdfcore.ProfilePDFUA1Structural, + pdfcore.ValidationSummary{Errors: 1}, + []pdfcore.RuleInfo{rule("lang", "warning", true, 0), rule("mcid-unique", "error", true, 1)}, + 1, + }, + { + "UA warnings only exit 0", pdfcore.ProfilePDFUA1Structural, + pdfcore.ValidationSummary{Warnings: 2}, + []pdfcore.RuleInfo{rule("lang", "warning", true, 1), rule("struct-elem-type", "warning", true, 1), rule("mcid-unique", "error", true, 0)}, + 0, + }, + { + "pdfa-1b error rule not evaluated exits 1", pdfcore.ProfilePDFA1B, + pdfcore.ValidationSummary{Info: 1}, + []pdfcore.RuleInfo{rule("font-embedding", "error", false, 0), rule("output-intent", "error", true, 0)}, + 1, + }, + { + "pdfa-1b clean exits 0", pdfcore.ProfilePDFA1B, + pdfcore.ValidationSummary{}, + []pdfcore.RuleInfo{rule("font-embedding", "error", true, 0)}, + 0, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + res := &pdfcore.ValidationResult{ + Profile: tc.profile, + Summary: tc.summary, + Problems: []pdfcore.Problem{}, + Disclaimer: pdfcore.DisclaimerText, + Rules: tc.rules, + } + if got := renderValidate(res, false, false); got != tc.want { + t.Errorf("renderValidate exit = %d, want %d", got, tc.want) + } + if got := renderValidate(res, true, false); got != tc.want { + t.Errorf("renderValidate --json exit = %d, want %d", got, tc.want) + } + var b strings.Builder + if err := printValidatePlain(&b, res); err != nil { + t.Fatalf("printValidatePlain: %v", err) + } + wantLine := tc.want == 1 && tc.summary.Errors == 0 + if has := strings.Contains(b.String(), "Exit status 1: a rule that gates the exit code could not be evaluated"); has != wantLine { + t.Errorf("exit explanation present = %v, want %v:\n%s", has, wantLine, b.String()) + } + }) + } +} diff --git a/docs/cli-usage.md b/docs/cli-usage.md index 6fc908b1..2154df87 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -28,9 +28,12 @@ pdfdebug validate --profile pdfua-1-structural file.pdf After the profile name and its scope sentence, the output lists the rules it checked with `0 found` or `N found` beside each, then the problems and a -summary line. The profile checks catalog-level entries only, such as whether -the document is marked as tagged. A file that passes every one of them can -still fail PDF/UA, so a clean run is not a verdict; use veraPDF for that. +summary line. The profile checks catalog entries such as whether the document +is marked as tagged, that every structure element has a usable `/S`, and the +marked content of each page: balanced BMC/BDC and EMC, MCIDs that are unique, +not nested and reachable through the ParentTree. A file with no finding from +any of them can still fail PDF/UA, so a clean run is not a verdict; use +veraPDF for that. `pdfdebug --help` lists the current rules of each profile. ### Compare two versions of a file @@ -69,9 +72,11 @@ or `--ref` for any stream object. An attachment comes out with pdfdebug validate --json --profile pdfua-1-structural file.pdf > report.json ``` -Branch on the exit code first and read `summary` from the JSON second, because -`pdfua-1-structural` findings are warnings and a rule that could not run is -info, and both exit 0. The full script is under +Branch on the exit code first and read `summary` from the JSON second. Under +`pdfua-1-structural`, `mcid-in-parent-tree` and `struct-elem-type` report +errors and exit 1; the other rules report warnings, and one of those that could +not run reports info; both exit 0. An error rule that could not run also +reports info but exits 1. The full script is under [Exit codes](cli/scripting.md#exit-codes). ## Commands diff --git a/docs/cli/scripting.md b/docs/cli/scripting.md index e7235036..3cffbaf9 100644 --- a/docs/cli/scripting.md +++ b/docs/cli/scripting.md @@ -23,17 +23,23 @@ The three command families use different contracts. `validate`: - 0 - it ran and found no errors. This is not a conformance verdict. -- 1 - it found at least one error. Under `pdfa-1b` it also exits 1 when a rule - could not be evaluated, and for an encrypted file, which is reported as one - `no-encryption` error. +- 1 - it found at least one error, or a rule whose severity is error could + not be evaluated. Every `pdfa-1b` rule is an error rule. Under + `pdfua-1-structural` the error rules are `mcid-in-parent-tree` (an MCID + that does not reach the ParentTree or cannot be read, or page content that + cannot be read or decoded, so its MCIDs were not checked) and + `struct-elem-type` (a structure element without a usable `/S`). An encrypted file under `pdfa-1b` is + reported as one `no-encryption` error and also exits 1. - 2 - operational or usage error: a missing file, an unknown profile, an unknown flag, the wrong number of files, a flag after the file, or an encrypted file under `pdfua-1-structural`. -Every `pdfua-1-structural` rule reports a warning, so that profile exits 0 no -matter what it finds. A rule that could not be evaluated reports an info -problem and also exits 0. A job that should fail on either has to read -`summary.warnings` and `summary.info` from `--json`. +The other `pdfua-1-structural` rules report warnings, which exit 0: the +catalog rules (`marked`, `struct-tree-root`, `lang`) and the marked-content +rules `marked-content-balance` (which also reports a page content stream that +cannot be decoded), `mcid-unique` and `mcid-not-nested`. One of them that could +not be evaluated reports an info problem and also exits 0. A job that should +fail on those has to read `summary.warnings` and `summary.info` from `--json`. `diff`: @@ -46,8 +52,9 @@ problem and also exits 0. A job that should fail on either has to read `pdfdebug --help` exits 0, but `--help` on a subcommand exits non-zero: 1 for `dump`, 2 for `validate` and `diff`. -This script fails a CI step when `validate` cannot run or reports anything. It -is plain `sh` and needs `jq`; it drops into any CI system that runs shell steps. +This script fails a CI step when `validate` cannot run, exits 1 on an error, +or reports anything else. It is plain `sh` and needs `jq`; it drops into any +CI system that runs shell steps. ```sh rc=0 diff --git a/docs/cli/validate-and-diff.md b/docs/cli/validate-and-diff.md index ad24c005..f807b87d 100644 --- a/docs/cli/validate-and-diff.md +++ b/docs/cli/validate-and-diff.md @@ -16,12 +16,18 @@ prints the profile's scope sentence and a `Rules checked (N)` block listing each rule's id, spec clause, outcome (`0 found`, `N found` or `not evaluated`) and what it checks; `--json` carries the same as `scope` and `rules`. A clean run says "none of the N rules checked found a problem". `pdfua-1-structural` -checks catalog-level entries only and does not look at marked content, the -structure tree's contents, alternate text, fonts or the rest of the catalog, so -it is not a PDF/UA-1 conformance check. The rule list changes as rules are -added; `pdfdebug --help` lists the current rules of each profile, and the -`rules` array of `validate --json` names the rules a run checked. Exit codes -are under [Exit codes](scripting.md#exit-codes). +checks catalog entries, the `/S` of each structure element, and the marked +content of each page's `/Contents`: BMC/BDC and EMC balance, MCID uniqueness, +MCID nesting and whether each MCID reaches a structure element through the +ParentTree. An MCID that does not reach the ParentTree and a structure element +without a usable `/S` are errors and exit 1; the other marked-content rules and +the catalog rules are warnings. It does not check that all real content is tagged or +marked as an artifact, and it does not read form XObject or annotation content, +alternate text, fonts, role mapping or the rest of the structure tree, so it is +not a PDF/UA-1 conformance check. The rule list changes as rules are added; +`pdfdebug --help` lists the current rules of each profile, and the `rules` +array of `validate --json` names the rules a run checked. Exit codes are under +[Exit codes](scripting.md#exit-codes). ### `diff` diff --git a/frontend/src/components/DetailPanel.tsx b/frontend/src/components/DetailPanel.tsx index 8673f84a..4012b415 100644 --- a/frontend/src/components/DetailPanel.tsx +++ b/frontend/src/components/DetailPanel.tsx @@ -1405,6 +1405,7 @@ function DetailPanelInner() { tabId={activeTabId ?? ''} active={shownView === 'validate'} onNavigate={handleValidateNavigate} + openTabIds={tabs.map((t) => t.tabId)} /> diff --git a/frontend/src/components/ValidateView.test.tsx b/frontend/src/components/ValidateView.test.tsx index f76018d2..e320d04c 100644 --- a/frontend/src/components/ValidateView.test.tsx +++ b/frontend/src/components/ValidateView.test.tsx @@ -16,10 +16,12 @@ * called (the no-jump fallback). * - Problems without an object ref (document-level, e.g. missing /Lang) are * surfaced under a "Document" group. - * - Every result shows the profile's scope sentence - * (data-testid="validate-scope") and the rules checked + * - Every result ends with a "Rules checked" toggle + * (data-testid="validate-rules-toggle"), collapsed by default, naming the + * rule count and any rules not evaluated. Expanded, it shows the profile's + * scope sentence (data-testid="validate-scope") and the rules checked * (data-testid="validate-rules", one data-testid="validate-rule" each) - * above the result. + * below the findings. The expanded state is kept per document tab. * - Empty/clean result shows data-testid="validate-empty" naming the rule * count: "None of the N rules checked found a problem". * - The not-authoritative disclaimer (data-testid="validate-disclaimer", @@ -28,7 +30,7 @@ * * Run: cd frontend && npx vitest run src/components/ValidateView.test.tsx */ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; import { describe, test, expect, vi, beforeEach } from 'vitest'; import { ValidateView } from './ValidateView'; @@ -337,6 +339,13 @@ const scopedProblemResult = { ], }; +/** Expands the collapsed "Rules checked" section once a result is shown. */ +async function expandRules() { + const toggle = await waitFor(() => screen.getByTestId('validate-rules-toggle')); + fireEvent.click(toggle); + await waitFor(() => expect(screen.getByTestId('validate-rules')).toBeInTheDocument()); +} + /** True when node a precedes node b in document order. */ function precedes(a: Element, b: Element) { return Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); @@ -348,11 +357,18 @@ describe('ValidateView scope and rules checked', () => { const { container } = render(); fireEvent.click(screen.getByTestId('validate-run')); - await waitFor(() => expect(screen.getByTestId('validate-scope')).toBeInTheDocument()); + const toggle = await waitFor(() => screen.getByTestId('validate-rules-toggle')); + expect(toggle.textContent).toMatch(/rules checked \(3\)/i); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(toggle.hasAttribute('aria-controls')).toBe(false); + expect(screen.queryByTestId('validate-scope')).toBeNull(); + expect(screen.queryByTestId('validate-rules')).toBeNull(); + await expandRules(); + const expanded = screen.getByTestId('validate-rules-toggle'); + expect(expanded.getAttribute('aria-expanded')).toBe('true'); + expect(container.querySelector(`#${expanded.getAttribute('aria-controls')}`)).not.toBeNull(); expect(screen.getByTestId('validate-scope').textContent).toContain(uaScope); - const list = screen.getByTestId('validate-rules'); - expect(list.textContent).toMatch(/rules checked \(3\)/i); const rows = screen.getAllByTestId('validate-rule'); expect(rows).toHaveLength(uaRules.length); uaRules.forEach((rule, i) => { @@ -376,7 +392,9 @@ describe('ValidateView scope and rules checked', () => { expect(clean.toLowerCase()).toContain('none'); expect(clean).toMatch(/\b3 rules\b/); expect((container.textContent ?? '').toLowerCase()).not.toContain('no structural problems found'); - expect(precedes(screen.getByTestId('validate-rules'), screen.getByTestId('validate-empty'))).toBe(true); + expect(precedes(screen.getByTestId('validate-empty'), screen.getByTestId('validate-rules-toggle'))).toBe( + true, + ); }); test('clean sentence follows the rule count of the result', async () => { @@ -387,15 +405,19 @@ describe('ValidateView scope and rules checked', () => { await waitFor(() => expect(screen.getByTestId('validate-empty')).toBeInTheDocument()); expect(screen.getByTestId('validate-empty').textContent).toMatch(/\b2 rules\b/); + await expandRules(); expect(screen.getAllByTestId('validate-rule')).toHaveLength(2); }); - test('result with problems shows scope and rules above the summary', async () => { + test('result with problems shows the findings above the collapsed scope and rules', async () => { mockValidate.mockResolvedValue(scopedProblemResult); render(); fireEvent.click(screen.getByTestId('validate-run')); - await waitFor(() => expect(screen.getByTestId('validate-rules')).toBeInTheDocument()); + const toggle = await waitFor(() => screen.getByTestId('validate-rules-toggle')); + expect(toggle.textContent).toMatch(/rules checked \(3, 1 not evaluated\)/i); + expect(precedes(screen.getByTestId('validate-summary'), toggle)).toBe(true); + await expandRules(); expect(screen.getByTestId('validate-scope').textContent).toContain(scopedProblemResult.scope); const rows = screen.getAllByTestId('validate-rule'); @@ -407,7 +429,7 @@ describe('ValidateView scope and rules checked', () => { expect(rows[2].textContent).toContain('not evaluated'); expect(rows[2].textContent).not.toContain('0 found'); - expect(precedes(screen.getByTestId('validate-rules'), screen.getByTestId('validate-summary'))).toBe(true); + expect(precedes(screen.getByTestId('validate-summary'), screen.getByTestId('validate-scope'))).toBe(true); expect(precedes(screen.getByTestId('validate-scope'), screen.getByTestId('validate-rules'))).toBe(true); expect(screen.queryByTestId('validate-empty')).toBeNull(); }); @@ -449,12 +471,13 @@ describe('ValidateView scope and rules checked', () => { render(); fireEvent.click(screen.getByTestId('validate-run')); - await waitFor(() => expect(screen.getByTestId('validate-rules')).toBeInTheDocument()); + await expandRules(); mockValidate.mockRejectedValueOnce(new Error('validate failed')); fireEvent.click(screen.getByTestId('validate-run')); await waitFor(() => expect(screen.getByTestId('validate-error')).toBeInTheDocument()); + expect(screen.queryByTestId('validate-rules-toggle')).toBeNull(); expect(screen.queryByTestId('validate-scope')).toBeNull(); expect(screen.queryByTestId('validate-rules')).toBeNull(); }); @@ -476,3 +499,207 @@ describe('ValidateView scope and rules checked', () => { expect(screen.getByTestId('validate-disclaimer').textContent).toBe(DISCLAIMER_TEXT); }); }); + +/** A pdfua-1-structural result with one page-scoped content error and one catalog warning. */ +const uaContentErrorResult = { + profile: 'pdfua-1-structural', + summary: { errors: 1, warnings: 1, info: 0 }, + problems: [ + { + ruleId: 'mcid-in-parent-tree', + profile: 'pdfua-1-structural', + severity: 'error', + message: 'page 1: MCID 0 (stream 7 0 R line 1 col 1) has no structure element in ParentTree key 0', + objRef: '3 0 R', + objNodeId: 'obj:0:3', + specRef: 'ISO 14289-1:2014, 7.1; ISO 32000-1:2008, 14.7.4.4', + }, + { + ruleId: 'lang', + profile: 'pdfua-1-structural', + severity: 'warning', + message: 'document /Lang is missing', + objRef: '', + objNodeId: '', + specRef: 'ISO 14289-1:2014, 7.2', + }, + ], + disclaimer: DISCLAIMER_TEXT, + scope: uaScope, + rules: [ + ...uaRules.map((r) => (r.ruleId === 'lang' ? { ...r, findings: 1 } : r)), + { + ruleId: 'mcid-in-parent-tree', + specRef: 'ISO 14289-1:2014, 7.1; ISO 32000-1:2008, 14.7.4.4', + severity: 'error', + checks: "every MCID in a page's content stream reaches a structure element through the page's /StructParents and its ParentTree array", + evaluated: true, + findings: 1, + }, + ], +}; + +describe('ValidateView PDF/UA content-rule errors', () => { + beforeEach(() => { + mockValidate.mockResolvedValue(uaContentErrorResult); + }); + + async function runUA(onNavigate = vi.fn()) { + render(); + fireEvent.change(screen.getByTestId('validate-profile'), { target: { value: 'pdfua-1-structural' } }); + fireEvent.click(screen.getByTestId('validate-run')); + await waitFor(() => screen.getByText(/MCID 0 .* has no structure element/)); + return onNavigate; + } + + test('a PDF/UA error finding renders under Errors and the catalog warning under Warnings', async () => { + await runUA(); + + expect(mockValidate).toHaveBeenCalledWith('tab-1', 'pdfua-1-structural'); + const errors = screen.getByTestId('validate-group-error'); + const warnings = screen.getByTestId('validate-group-warning'); + expect(within(errors).getByText(/MCID 0 .* has no structure element/)).toBeInTheDocument(); + expect(within(errors).queryByText(/\/Lang is missing/)).toBeNull(); + expect(within(warnings).getByText(/\/Lang is missing/)).toBeInTheDocument(); + expect(within(warnings).queryByText(/MCID 0 .* has no structure element/)).toBeNull(); + }); + + test('the summary line counts the PDF/UA error and warning', async () => { + await runUA(); + + expect(screen.getByTestId('validate-summary').textContent?.startsWith('1 error, 1 warning')).toBe(true); + }); + + test('clicking the PDF/UA error row navigates to its page node', async () => { + const onNavigate = await runUA(); + + const row = screen.getByText(/MCID 0 .* has no structure element/).closest('[data-testid="validate-problem"]'); + expect(row).not.toBeNull(); + fireEvent.click(row as Element); + expect(onNavigate).toHaveBeenCalledWith('obj:0:3'); + }); + + test('pressing Enter on the PDF/UA error row navigates to its page node', async () => { + const onNavigate = await runUA(); + + const row = screen.getByText(/MCID 0 .* has no structure element/).closest('[data-testid="validate-problem"]'); + expect(row).not.toBeNull(); + fireEvent.keyDown(row as Element, { key: 'Enter' }); + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onNavigate).toHaveBeenCalledWith('obj:0:3'); + }); +}); + +describe('ValidateView across document tabs', () => { + test('switching back to a document restores its result; another document starts empty', async () => { + const { rerender } = render(); + fireEvent.click(screen.getByTestId('validate-run')); + await waitFor(() => screen.getByText(/not embedded/i)); + + rerender(); + await waitFor(() => expect(screen.queryByText(/not embedded/i)).toBeNull()); + + rerender(); + await waitFor(() => screen.getByText(/not embedded/i)); + expect(mockValidate).toHaveBeenCalledTimes(1); + }); + + test('each document keeps its own profile', async () => { + const { rerender } = render(); + fireEvent.change(screen.getByTestId('validate-profile'), { target: { value: 'pdfua-1-structural' } }); + + rerender(); + fireEvent.change(screen.getByTestId('validate-profile'), { target: { value: 'pdfa-1b' } }); + + rerender(); + await waitFor(() => + expect((screen.getByTestId('validate-profile') as HTMLSelectElement).value).toBe('pdfua-1-structural'), + ); + }); + + test('a document not yet validated starts on the default profile', async () => { + const { rerender } = render(); + fireEvent.change(screen.getByTestId('validate-profile'), { target: { value: 'pdfua-1-structural' } }); + + rerender(); + await waitFor(() => + expect((screen.getByTestId('validate-profile') as HTMLSelectElement).value).toBe('pdfa-1b'), + ); + fireEvent.click(screen.getByTestId('validate-run')); + await waitFor(() => expect(mockValidate).toHaveBeenCalledWith('tab-2', 'pdfa-1b')); + }); + + test('a run that finishes while another document is shown is kept for its own document', async () => { + let resolve: (v: unknown) => void = () => {}; + mockValidate.mockReturnValueOnce(new Promise((r) => (resolve = r))); + const { rerender } = render(); + fireEvent.click(screen.getByTestId('validate-run')); + await waitFor(() => expect(screen.getByTestId('validate-run').textContent).toBe('Running...')); + + rerender(); + await waitFor(() => expect(screen.getByTestId('validate-run').textContent).toBe('Run checks')); + resolve(mixedResult); + await waitFor(() => expect(mockValidate).toHaveBeenCalledTimes(1)); + expect(screen.queryByText(/not embedded/i)).toBeNull(); + + rerender(); + await waitFor(() => screen.getByText(/not embedded/i)); + expect(screen.getByTestId('validate-run').textContent).toBe('Run checks'); + }); + + test('the rules section stays expanded across a rerun and is kept per document', async () => { + const { rerender } = render(); + fireEvent.click(screen.getByTestId('validate-run')); + await expandRules(); + + fireEvent.click(screen.getByTestId('validate-run')); + await waitFor(() => expect(mockValidate).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.getByTestId('validate-run').textContent).toBe('Run checks')); + expect(screen.getByTestId('validate-rules')).toBeInTheDocument(); + + rerender(); + fireEvent.click(screen.getByTestId('validate-run')); + const toggle = await waitFor(() => screen.getByTestId('validate-rules-toggle')); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + + rerender(); + await waitFor(() => expect(screen.getByTestId('validate-rules')).toBeInTheDocument()); + }); + + test('a closed document\'s result is dropped', async () => { + const { rerender } = render( + , + ); + fireEvent.click(screen.getByTestId('validate-run')); + await waitFor(() => screen.getByText(/not embedded/i)); + + rerender(); + rerender(); + await waitFor(() => expect(screen.queryByText(/not embedded/i)).toBeNull()); + }); + + test('a run on a document closed before it finishes is not kept', async () => { + let resolve: (v: unknown) => void = () => {}; + mockValidate.mockReturnValueOnce(new Promise((r) => (resolve = r))); + const { rerender } = render( + , + ); + fireEvent.click(screen.getByTestId('validate-run')); + rerender(); + resolve(mixedResult); + await waitFor(() => expect(mockValidate).toHaveBeenCalledTimes(1)); + + rerender(); + await waitFor(() => expect(screen.getByTestId('validate-run').textContent).toBe('Run checks')); + expect(screen.queryByText(/not embedded/i)).toBeNull(); + }); + + test('a document reloaded under a new tab id starts with no result', async () => { + const { rerender } = render(); + fireEvent.click(screen.getByTestId('validate-run')); + await waitFor(() => screen.getByText(/not embedded/i)); + + rerender(); + await waitFor(() => expect(screen.queryByText(/not embedded/i)).toBeNull()); + }); +}); diff --git a/frontend/src/components/ValidateView.tsx b/frontend/src/components/ValidateView.tsx index d694a844..8e536441 100644 --- a/frontend/src/components/ValidateView.tsx +++ b/frontend/src/components/ValidateView.tsx @@ -1,10 +1,11 @@ /** * @file Validate view -- document-level panel for a named subset of structural * checks. A "Run checks" action with a profile selector runs the bounded - * PDF/A-1b or PDF/UA-1-structural rule set. Each result shows the profile's - * scope sentence and the rules checked (id, spec ref, outcome, what each rule - * checks) above either a clean sentence naming the rule count or the problems - * grouped by severity. Clicking an object-scoped problem jumps TreePanel to the offending + * PDF/A-1b or PDF/UA-1-structural rule set. Each result shows either a clean + * sentence naming the rule count or the problems grouped by severity, then a + * collapsible section with the profile's scope sentence and the rules checked + * (id, spec ref, outcome, what each rule checks). Each document tab keeps its + * own profile, result and section state. Clicking an object-scoped problem jumps TreePanel to the offending * object via the existing NAVIGATE_TO_REF wiring; a problem with no object node * id is shown but not clickable, while a non-empty-but-unresolvable id degrades * gracefully through the reducer's navigation-error path (never a broken jump). @@ -53,12 +54,26 @@ export interface ValidateResult { export interface ValidateViewProps { /** Active document tab ID. */ tabId: string; - /** True when the Validate tab is active (unused: results persist per mount). */ + /** True when the Validate tab is active (unused: results persist per document tab). */ active: boolean; /** Dispatches NAVIGATE_TO_REF to jump TreePanel to an object-scoped problem. */ onNavigate: (nodeId: string) => void; + /** Open document tab IDs; saved panel state of any other tab is dropped. */ + openTabIds?: string[]; } +/** One document's Validate panel state, kept while another document is shown. */ +interface SavedValidation { + profile: string; + result: ValidateResult | null; + error: string | null; + running: boolean; + rulesExpanded: boolean; +} + +/** The profile a document starts on before its selector is changed. */ +const DEFAULT_PROFILE = 'pdfa-1b'; + /** The two bounded, explicit profiles. */ const PROFILES: { value: string; label: string }[] = [ { value: 'pdfa-1b', label: 'PDF/A-1b (structural subset)' }, @@ -140,30 +155,56 @@ function ruleOutcome(rule: ValidateRuleInfo): string { return rule.evaluated ? `${rule.findings} found` : 'not evaluated'; } -/** The scope sentence and the rules that ran, shown above every result. */ -function RulesChecked({ result }: { result: ValidateResult }) { +/** + * The scope sentence and the rules that ran, in a section below the findings + * that starts collapsed. Its toggle names the rule count and how many rules + * could not be evaluated, so a collapsed section never hides a degraded rule. + */ +function RulesChecked({ + result, + expanded, + onToggle, +}: { + result: ValidateResult; + expanded: boolean; + onToggle: () => void; +}) { + const notEvaluated = result.rules.filter((r) => !r.evaluated).length; return ( -
-
- {result.scope} -
-
-

- Rules checked ({result.rules.length}) -

- {/* One grid for all rows so the columns line up; a long description +
+ + {expanded && ( +
+
+ {result.scope} +
+
+ {/* One grid for all rows so the columns line up; a long description wraps inside its own column, next to the rule it belongs to. */} -
- {result.rules.map((rule) => ( -
- {rule.ruleId} - {rule.specRef} - {ruleOutcome(rule)} - {rule.checks} +
+ {result.rules.map((rule) => ( +
+ {rule.ruleId} + {rule.specRef} + {ruleOutcome(rule)} + {rule.checks} +
+ ))}
- ))} +
-
+ )}
); } @@ -172,50 +213,89 @@ function RulesChecked({ result }: { result: ValidateResult }) { * Document-level structural-validation panel. Self-contained: it runs the * chosen profile against `tabId` on demand and renders the grouped result. */ -export function ValidateView({ tabId, active: _active, onNavigate }: ValidateViewProps) { - const [profile, setProfile] = useState('pdfa-1b'); +export function ValidateView({ + tabId, + active: _active, + onNavigate, + openTabIds, +}: ValidateViewProps) { + const [profile, setProfile] = useState(DEFAULT_PROFILE); const [result, setResult] = useState(null); const [error, setError] = useState(null); const [running, setRunning] = useState(false); + // The scope sentence and rule list sit in a section that starts collapsed; + // the choice is kept per document and across runs. + const [rulesExpanded, setRulesExpanded] = useState(false); - // The panel is a single instance reused across document tabs (not remounted - // per tab), so switching documents must clear the previous document's result - // and error - otherwise stale findings from tab A are shown under tab B. + // The panel is a single instance reused across document tabs, so each + // document's profile, result, error, in-flight state and rules-section state + // is kept here by tabId and restored when that document becomes active + // again. A reload mints a new tabId, so a reloaded document starts with no + // result. + const savedRef = useRef(new Map()); + const save = (id: string, patch: Partial) => { + const prev = savedRef.current.get(id) ?? { + profile: profileRef.current, + result: null, + error: null, + running: false, + rulesExpanded: false, + }; + savedRef.current.set(id, { ...prev, ...patch }); + }; + + const openKey = openTabIds?.join('\n'); useEffect(() => { - setResult(null); - setError(null); - setRunning(false); - }, [tabId]); + if (openKey === undefined) return; + const open = new Set(openKey.split('\n')); + for (const id of savedRef.current.keys()) { + if (!open.has(id)) savedRef.current.delete(id); + } + }, [openKey]); // Tracks the currently-active tab and selected profile so an in-flight run - // is discarded if either changes before it resolves. Profile is guarded in - // addition to tab because a tab switch resets `running`, which re-enables the - // profile selector and would otherwise let a stale run overwrite the result - // under a different profile. + // only updates the screen when its document and profile are still shown. const tabIdRef = useRef(tabId); tabIdRef.current = tabId; const profileRef = useRef(profile); profileRef.current = profile; + useEffect(() => { + const saved = savedRef.current.get(tabId); + setProfile(saved?.profile ?? DEFAULT_PROFILE); + setResult(saved?.result ?? null); + setError(saved?.error ?? null); + setRunning(saved?.running ?? false); + setRulesExpanded(saved?.rulesExpanded ?? false); + }, [tabId]); + const runChecks = () => { if (!tabId) return; const runTabId = tabId; const runProfile = profile; - const stale = () => tabIdRef.current !== runTabId || profileRef.current !== runProfile; + // A run is applied to its document's saved entry unless that document's + // profile changed since; the screen follows only while it is still shown. + const current = () => savedRef.current.get(runTabId)?.profile === runProfile; + const shown = () => tabIdRef.current === runTabId && profileRef.current === runProfile; + save(runTabId, { profile: runProfile, error: null, running: true }); setRunning(true); setError(null); - Validate(tabId, profile) + Validate(runTabId, runProfile) .then((res: unknown) => { - if (stale()) return; // tab or profile changed mid-run - setResult(res as ValidateResult); + if (!current()) return; + save(runTabId, { result: res as ValidateResult }); + if (shown()) setResult(res as ValidateResult); }) .catch((err: unknown) => { - if (stale()) return; - setError(extractErrorMessage(err)); + if (!current()) return; + const msg = extractErrorMessage(err); + save(runTabId, { error: msg }); + if (shown()) setError(msg); }) .finally(() => { - if (stale()) return; - setRunning(false); + if (!current()) return; + save(runTabId, { running: false }); + if (shown()) setRunning(false); }); }; @@ -241,6 +321,12 @@ export function ValidateView({ tabId, active: _active, onNavigate }: ValidateVie setProfile(e.target.value); setResult(null); setError(null); + if (tabId) + save(tabId, { + profile: e.target.value, + result: null, + error: null, + }); }} > {PROFILES.map((p) => ( @@ -274,23 +360,23 @@ export function ValidateView({ tabId, active: _active, onNavigate }: ValidateVie
)} - {!error && result && } - {!error && result && result.problems.length === 0 && (
- None of the {result.rules.length} rule{result.rules.length === 1 ? '' : 's'} checked - found a problem (structural subset only) + None of the {result.rules.length} rule + {result.rules.length === 1 ? '' : 's'} checked found a problem (structural subset only)
)} {!error && result && result.problems.length > 0 && ( <>
- {result.summary.errors} error{result.summary.errors === 1 ? '' : 's'},{' '} - {result.summary.warnings} warning{result.summary.warnings === 1 ? '' : 's'} + {result.summary.errors} error + {result.summary.errors === 1 ? '' : 's'}, {result.summary.warnings} warning + {result.summary.warnings === 1 ? '' : 's'} {result.summary.info ? ( <> - , {result.summary.info} info problem{result.summary.info === 1 ? '' : 's'} + , {result.summary.info} info problem + {result.summary.info === 1 ? '' : 's'} ) : null}{' '} (structural checks only) @@ -311,6 +397,17 @@ export function ValidateView({ tabId, active: _active, onNavigate }: ValidateVie })} )} + + {!error && result && ( + { + setRulesExpanded(!rulesExpanded); + if (tabId) save(tabId, { rulesExpanded: !rulesExpanded }); + }} + /> + )}
); diff --git a/internal/pdfcore/model.go b/internal/pdfcore/model.go index 3dcd409c..75a41aaf 100644 --- a/internal/pdfcore/model.go +++ b/internal/pdfcore/model.go @@ -783,6 +783,12 @@ type StructureReport struct { OtherParentTree []ParentTreeEntry `json:"otherParentTree"` // ParentTree keys no page's /StructParents names Problems []StructureProblem `json:"problems"` // findings with no row of their own Counts StructureCounts `json:"counts"` + + // Each holds the problem that left part of the document unread, or "". + pagesIncomplete string // pages the page-tree walk did not list + rootNotDict string // /StructTreeRoot is not a dictionary + rootReadFailed string // the root's read failed part way + parentTreeIncomplete string // ParentTree entries that were not read } // RoleMapEntry is one /RoleMap mapping from a structure type to another. @@ -803,6 +809,7 @@ type StructKid struct { ObjRef string `json:"objRef,omitempty"` // kind "objr": /Obj ObjType string `json:"objType,omitempty"` // kind "objr": "Annot/Link", "XObject/Form", ... Err string `json:"error,omitempty"` + index int // position in the /K array, counting null entries } // StructElem is one structure element. Text fields are decoded text strings; @@ -822,6 +829,8 @@ type StructElem struct { E *string `json:"e,omitempty"` Kids []*StructKid `json:"kids"` Err string `json:"error,omitempty"` + sErr string // why /S is unusable; "" when S is set or /S was not read + readErr string // the guarded read of the element failed } // StructPage is one numbered page of a StructureReport: its ParentTree array @@ -837,6 +846,7 @@ type StructPage struct { ContentMCIDs []ContentMCID `json:"contentMcids"` // every BDC carrying an MCID, in content order Alignment []MCIDAlignment `json:"alignment"` // ascending MCID Err string `json:"error,omitempty"` // page unreadable, /Contents malformed + nodeID string // "" for a direct page dictionary } // ParentTreeSlot is one entry of a page's ParentTree array: the element that @@ -859,6 +869,7 @@ type MarkedStream struct { DepthAtEnd int `json:"depthAtEnd"` // depth after this stream MinDepth int `json:"minDepth"` // lowest depth reached; negative when an EMC had nothing to close Err string `json:"error,omitempty"` // decode failure: the stream is not scanned + nodeID string } // ContentMCID is one BDC in a page's content whose property list carries an diff --git a/internal/pdfcore/numbertree.go b/internal/pdfcore/numbertree.go index 8ecc02fb..05bfd72c 100644 --- a/internal/pdfcore/numbertree.go +++ b/internal/pdfcore/numbertree.go @@ -20,18 +20,21 @@ type numberTreeWalker struct { keyNode map[int]string // key -> label of the node that first held it visited map[int]bool // object numbers of nodes already walked problems []StructureProblem + // incomplete is the first problem after which some entries were not read. + incomplete string } // walkNumberTree reads the number tree (ISO 32000-1 7.9.7) rooted at root and // returns every key with its value as stored (possibly an indirect reference; -// callers dereference) and every malformed node it met. A malformed node is reported and the walk goes on: a node that is +// callers dereference), every malformed node it met, and incomplete: the first +// problem that left entries unread, or "" when every entry was read. A malformed node is reported and the walk goes on: a node that is // not a dictionary, has neither /Kids nor /Nums, an odd-length /Nums, a // non-integer key, a bad /Limits or a key outside it, keys out of order // within one leaf, a // duplicate key (the first value is kept), a node reached twice, and a node // deeper than maxNumberTreeDepth. where prefixes every problem location. // Callers hold pdfMu. -func walkNumberTree(ctx *pdfcpu_model.Context, root pdfcpu_types.Object, where string) (entries map[int]pdfcpu_types.Object, problems []StructureProblem) { +func walkNumberTree(ctx *pdfcpu_model.Context, root pdfcpu_types.Object, where string) (entries map[int]pdfcpu_types.Object, problems []StructureProblem, incomplete string) { w := &numberTreeWalker{ ctx: ctx, where: where, @@ -40,7 +43,7 @@ func walkNumberTree(ctx *pdfcpu_model.Context, root pdfcpu_types.Object, where s visited: map[int]bool{}, } w.visit(root, true, 1) - return w.entries, w.problems + return w.entries, w.problems, w.incomplete } // ntNodeLabel names a node for problem locations: its reference, or "(direct)". @@ -56,11 +59,20 @@ func (w *numberTreeWalker) problem(label, msg string) { w.problems = append(w.problems, StructureProblem{Where: w.where + ", node " + label, Message: msg}) } +// lost records msg like problem and marks the walk incomplete: entries under +// the node were not read. +func (w *numberTreeWalker) lost(label, msg string) { + w.problem(label, msg) + if w.incomplete == "" { + w.incomplete = msg + } +} + // visit reads one node and its descendants. func (w *numberTreeWalker) visit(node pdfcpu_types.Object, isRoot bool, depth int) { label := ntNodeLabel(node) if depth > maxNumberTreeDepth { - w.problem(label, fmt.Sprintf("number tree deeper than %d levels; node not walked", maxNumberTreeDepth)) + w.lost(label, fmt.Sprintf("number tree deeper than %d levels; node not walked", maxNumberTreeDepth)) return } if ref, ok := node.(pdfcpu_types.IndirectRef); ok { @@ -72,7 +84,7 @@ func (w *numberTreeWalker) visit(node pdfcpu_types.Object, isRoot bool, depth in w.visited[n] = true } guard(func() { w.read(node, label, isRoot, depth) }, func(msg string) { - w.problem(label, "node could not be read: "+msg) + w.lost(label, "node could not be read: "+msg) }) } @@ -81,7 +93,7 @@ func (w *numberTreeWalker) read(node pdfcpu_types.Object, label string, isRoot b obj := derefIn(w.ctx, node) if obj == nil { if _, ok := node.(pdfcpu_types.IndirectRef); ok { - w.problem(label, "node does not resolve to an object") + w.lost(label, "node does not resolve to an object") } else { w.problem(label, "node is null") } @@ -89,7 +101,7 @@ func (w *numberTreeWalker) read(node pdfcpu_types.Object, label string, isRoot b } d, ok := obj.(pdfcpu_types.Dict) if !ok { - w.problem(label, fmt.Sprintf("node is %s, not a dictionary", objKind(obj))) + w.lost(label, fmt.Sprintf("node is %s, not a dictionary", objKind(obj))) return } @@ -98,13 +110,13 @@ func (w *numberTreeWalker) read(node pdfcpu_types.Object, label string, isRoot b kidsObj, hasKids := d.Find("Kids") numsObj, hasNums := d.Find("Nums") if !hasKids && !hasNums { - w.problem(label, "node has neither /Kids nor /Nums") + w.lost(label, "node has neither /Kids nor /Nums") return } if hasKids { kids, ok := derefIn(w.ctx, kidsObj).(pdfcpu_types.Array) if !ok { - w.problem(label, fmt.Sprintf("/Kids is %s, not an array", objKind(derefIn(w.ctx, kidsObj)))) + w.lost(label, fmt.Sprintf("/Kids is %s, not an array", objKind(derefIn(w.ctx, kidsObj)))) } else { for _, kid := range kids { w.visit(kid, false, depth+1) @@ -114,7 +126,7 @@ func (w *numberTreeWalker) read(node pdfcpu_types.Object, label string, isRoot b if hasNums { nums, ok := derefIn(w.ctx, numsObj).(pdfcpu_types.Array) if !ok { - w.problem(label, fmt.Sprintf("/Nums is %s, not an array", objKind(derefIn(w.ctx, numsObj)))) + w.lost(label, fmt.Sprintf("/Nums is %s, not an array", objKind(derefIn(w.ctx, numsObj)))) return } w.readNums(nums, label, lo, hi, hasLimits) @@ -154,14 +166,14 @@ func (w *numberTreeWalker) limits(d pdfcpu_types.Dict, label string, isRoot bool // readNums records the key/value pairs of one leaf. func (w *numberTreeWalker) readNums(nums pdfcpu_types.Array, label string, lo, hi int, hasLimits bool) { if len(nums)%2 != 0 { - w.problem(label, fmt.Sprintf("/Nums has odd length %d; the last entry has no value", len(nums))) + w.lost(label, fmt.Sprintf("/Nums has odd length %d; the last entry has no value", len(nums))) } prev, havePrev := 0, false for i := 0; i+1 < len(nums); i += 2 { keyObj := derefIn(w.ctx, nums[i]) k, ok := keyObj.(pdfcpu_types.Integer) if !ok { - w.problem(label, fmt.Sprintf("/Nums entry %d: key is %s, not an integer", i, objKind(keyObj))) + w.lost(label, fmt.Sprintf("/Nums entry %d: key is %s, not an integer", i, objKind(keyObj))) continue } key := int(k) diff --git a/internal/pdfcore/numbertree_test.go b/internal/pdfcore/numbertree_test.go index b922117c..b60aaf9e 100644 --- a/internal/pdfcore/numbertree_test.go +++ b/internal/pdfcore/numbertree_test.go @@ -19,7 +19,7 @@ const numberTreeWhere = "NT" func walkRawNumberTree(t *testing.T, objs ...rawObj) (map[int]pdfcpu_types.Object, []StructureProblem) { t.Helper() _, doc := openUnvalidated(t, numberTreePDF(objs...)) - entries, problems := walkNumberTree(doc.PDFContext, *pdfcpu_types.NewIndirectRef(10, 0), numberTreeWhere) + entries, problems, _ := walkNumberTree(doc.PDFContext, *pdfcpu_types.NewIndirectRef(10, 0), numberTreeWhere) for _, p := range problems { if !strings.Contains(p.Where, numberTreeWhere) { t.Errorf("problem location %q does not carry the caller's prefix %q", p.Where, numberTreeWhere) @@ -235,3 +235,29 @@ func TestWalkNumberTree_RepeatedKeyInOneLeafIsReportedOnceAsDuplicate(t *testing } checkIntEntries(t, entries, map[int]int{1: 101, 3: 103}) } + +func TestWalkNumberTree_IncompleteNamesTheFirstNodeWithUnreadEntries(t *testing.T) { + cases := []struct { + name string + objs []rawObj + want string + }{ + {"well formed", []rawObj{{10, "<< /Kids [11 0 R] >>"}, {11, "<< /Limits [0 0] /Nums [0 100] >>"}}, ""}, + {"/Kids not an array", []rawObj{{10, "<< /Kids 5 >>"}}, "/Kids is an integer, not an array"}, + {"/Nums not an array", []rawObj{{10, "<< /Nums 5 >>"}}, "/Nums is an integer, not an array"}, + {"kid does not resolve", []rawObj{{10, "<< /Kids [99 0 R] >>"}}, "node does not resolve to an object"}, + {"kid not a dictionary", []rawObj{{10, "<< /Kids [11 0 R] >>"}, {11, "[1 2]"}}, "node is an array, not a dictionary"}, + {"non-integer key", []rawObj{{10, "<< /Nums [(x) 101] >>"}}, "/Nums entry 0: key is a string, not an integer"}, + {"null kid", []rawObj{{10, "<< /Kids [null 11 0 R] >>"}, {11, "<< /Limits [0 0] /Nums [0 100] >>"}}, ""}, + {"node reached twice", []rawObj{{10, "<< /Kids [11 0 R 11 0 R] >>"}, {11, "<< /Limits [0 0] /Nums [0 100] >>"}}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, doc := openUnvalidated(t, numberTreePDF(tc.objs...)) + _, problems, incomplete := walkNumberTree(doc.PDFContext, *pdfcpu_types.NewIndirectRef(10, 0), numberTreeWhere) + if incomplete != tc.want { + t.Errorf("incomplete = %q, want %q; problems:\n%s", incomplete, tc.want, problemsText(problems)) + } + }) + } +} diff --git a/internal/pdfcore/pageindex.go b/internal/pdfcore/pageindex.go index 0ba21cab..8b4536f7 100644 --- a/internal/pdfcore/pageindex.go +++ b/internal/pdfcore/pageindex.go @@ -39,10 +39,13 @@ func (ins *Inspector) GetPageIndex(tabID string) ([]*PageIndexEntry, error) { // numbered page in document order, so leaves[n-1] is page n. err is set only // when the walk failed outside any page-tree node (each node is read under its // own guard); leaves then holds the pages reached before the failure. +// skipped is the first node error after which a subtree was not walked (too +// deep, or its read failed), so pages under it may be missing; "" when none. type pageTree struct { entries []*PageIndexEntry leaves []*pageLeaf err error + skipped string } // pageTree returns the cached walk of d's page tree, building it on first @@ -60,7 +63,7 @@ func (d *DocumentState) pageTree() (*pageTree, error) { }); err != nil { t.err = wrapPDFError(err) } - t.entries, t.leaves = w.entries, w.leaves + t.entries, t.leaves, t.skipped = w.entries, w.leaves, w.skipped return t, nil }) return t, t.err @@ -156,6 +159,8 @@ type pageWalker struct { depth int // leaves holds every numbered page in document order. leaves []*pageLeaf + // skipped is the first error row of a subtree that was not walked. + skipped string } // pageLeaf is one numbered page from the page-tree walk: its dictionary, its @@ -204,10 +209,19 @@ func (w *pageWalker) walk() { return } guard(func() { w.visitNode(root, ref, pageAttrs{}) }, func(msg string) { - w.entries = append(w.entries, errorRow(ref, "the page tree could not be read: "+msg)) + w.skip(ref, "the page tree could not be read: "+msg) }) } +// skip adds the error row of a subtree that was not walked and records the +// first one in w.skipped. +func (w *pageWalker) skip(ref *pdfcpu_types.IndirectRef, msg string) { + w.entries = append(w.entries, errorRow(ref, msg)) + if w.skipped == "" { + w.skipped = msg + } +} + // guard runs fn and, when pdfcpu panics inside it, reports the panic through // fail and returns, so one unreadable node does not end the page-tree walk. // Go runtime errors are not recovered (see safeCall). @@ -256,7 +270,7 @@ func (w *pageWalker) visitNode(d pdfcpu_types.Dict, ref *pdfcpu_types.IndirectRe // /Kids entry in order. func (w *pageWalker) visitIntermediate(d pdfcpu_types.Dict, ref *pdfcpu_types.IndirectRef, kidsObj pdfcpu_types.Object, inherited pageAttrs) { if w.depth >= maxPageTreeDepth { - w.entries = append(w.entries, errorRow(ref, fmt.Sprintf("page tree deeper than %d levels; subtree not walked", maxPageTreeDepth))) + w.skip(ref, fmt.Sprintf("page tree deeper than %d levels; subtree not walked", maxPageTreeDepth)) return } w.depth++ @@ -286,7 +300,7 @@ func (w *pageWalker) visitIntermediate(d pdfcpu_types.Dict, ref *pdfcpu_types.In if r, ok := kid.(pdfcpu_types.IndirectRef); ok { kidRef = &r } - w.entries = append(w.entries, errorRow(kidRef, fmt.Sprintf("/Kids entry %d could not be read: %s", i, msg))) + w.skip(kidRef, fmt.Sprintf("/Kids entry %d could not be read: %s", i, msg)) }) } } diff --git a/internal/pdfcore/structure.go b/internal/pdfcore/structure.go index 633d351d..857b4fc8 100644 --- a/internal/pdfcore/structure.go +++ b/internal/pdfcore/structure.go @@ -152,8 +152,15 @@ func (d *DocumentState) structurePhase1(w *structureWalker) ([]*pageLeaf, error) leaves = pt.leaves w.readTree(leaves) w.countStreamUses(leaves) - if ptErr != nil { - w.problem("page tree", fmt.Sprintf("page-tree walk stopped after %d pages: %v; later pages not listed", len(leaves), ptErr)) + switch { + case ptErr != nil: + msg := fmt.Sprintf("page-tree walk stopped after %d pages: %v; later pages not listed", len(leaves), ptErr) + w.problem("page tree", msg) + w.report.pagesIncomplete = msg + case pt.skipped != "": + msg := "page-tree subtree not walked: " + pt.skipped + "; its pages not listed" + w.problem("page tree", msg) + w.report.pagesIncomplete = msg } return nil }) @@ -227,6 +234,7 @@ func (w *structureWalker) readTree(leaves []*pageLeaf) { } if leaf.ref != nil { sp.Ref = refString(*leaf.ref) + sp.nodeID = nodeIDForRef(*leaf.ref) } w.report.Pages = append(w.report.Pages, sp) if leaf.err != nil { @@ -278,6 +286,12 @@ func (w *structureWalker) readCatalog(catalog pdfcpu_types.Dict) { if !found || rootObj == nil { return } + // A reference to a missing object is null (ISO 32000-1 7.3.10), so the + // root is absent. + if ref, ok := rootObj.(pdfcpu_types.IndirectRef); ok && w.deref(rootObj) == nil { + w.problem("catalog", fmt.Sprintf("/StructTreeRoot %s does not resolve to an object; treated as absent", refString(ref))) + return + } r.HasStructTree = true where := "StructTreeRoot" if ref, ok := rootObj.(pdfcpu_types.IndirectRef); ok { @@ -286,11 +300,15 @@ func (w *structureWalker) readCatalog(catalog pdfcpu_types.Dict) { } root, ok := w.deref(rootObj).(pdfcpu_types.Dict) if !ok { - w.problem(where, fmt.Sprintf("/StructTreeRoot is %s, not a dictionary", objKind(w.deref(rootObj)))) + msg := "/StructTreeRoot is " + objKind(w.deref(rootObj)) + ", not a dictionary" + w.problem(where, msg) + r.rootNotDict = msg return } guard(func() { w.readRoot(root, where) }, func(msg string) { - w.problem(where, "the structure tree root could not be read: "+msg) + msg = "the structure tree root could not be read: " + msg + w.problem(where, msg) + r.rootReadFailed = msg }) } @@ -334,9 +352,10 @@ func (w *structureWalker) readRoot(root pdfcpu_types.Dict, where string) { r.ParentTreeRef = refString(ref) ptWhere += " " + r.ParentTreeRef } - entries, problems := walkNumberTree(w.ctx, o, ptWhere) + entries, problems, incomplete := walkNumberTree(w.ctx, o, ptWhere) w.parentTree = entries r.Problems = append(r.Problems, problems...) + r.parentTreeIncomplete = incomplete } r.Elements = w.walkK(root["K"], "", 0, 0) } @@ -364,11 +383,13 @@ func (w *structureWalker) walkK(k pdfcpu_types.Object, owner string, page, depth items = arr } } - for _, item := range items { + for i, item := range items { if item == nil { continue } - kids = append(kids, w.kid(item, owner, page, depth)) + kid := w.kid(item, owner, page, depth) + kid.index = i + kids = append(kids, kid) } return kids } @@ -552,6 +573,7 @@ func (w *structureWalker) elementKid(d pdfcpu_types.Dict, ref *pdfcpu_types.Indi } w.report.Counts.Elements++ guard(func() { w.readElement(d, e, id, parentPage, depth) }, func(msg string) { + e.readErr = msg e.Err = joinErr(e.Err, "element could not be read: "+msg) }) return &StructKid{Kind: "element", Element: e, Page: e.Page, PageInherited: e.PageInherited} @@ -564,14 +586,15 @@ func (w *structureWalker) readElement(d pdfcpu_types.Dict, e *StructElem, id str sv := w.deref(sObj) switch s, isName := sv.(pdfcpu_types.Name); { case !hasS || sv == nil: - errs = append(errs, "no /S") + e.sErr = "no /S" case !isName: - errs = append(errs, fmt.Sprintf("/S is %s, not a name", objKind(sv))) + e.sErr = fmt.Sprintf("/S is %s, not a name", objKind(sv)) case s.Value() == "": - errs = append(errs, "/S is an empty name") + e.sErr = "/S is an empty name" default: e.S = s.Value() } + errs = appendErr(errs, e.sErr) if typ := nameOf(w.deref(d["Type"])); typ != "" && typ != "StructElem" { errs = append(errs, fmt.Sprintf("/Type /%s is not StructElem", typ)) } @@ -762,7 +785,7 @@ func (w *structureWalker) scanPage(d *DocumentState, leaf *pageLeaf, sp *StructP props := w.propertyLookup(leaf) st := &mcState{} for i, ref := range refs { - ms := MarkedStream{Ref: refString(ref)} + ms := MarkedStream{Ref: refString(ref), nodeID: nodeIDForRef(ref)} ds := w.decodeStream(d, nodeIDForRef(ref)) ms.Err = ds.err if ms.Err != "" { diff --git a/internal/pdfcore/validate.go b/internal/pdfcore/validate.go index bb875615..48bde947 100644 --- a/internal/pdfcore/validate.go +++ b/internal/pdfcore/validate.go @@ -1,6 +1,7 @@ package pdfcore import ( + "context" "errors" "fmt" "maps" @@ -9,13 +10,16 @@ import ( "strconv" "strings" + pdfcpu_model "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model" pdfcpu_types "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/types" ) // Severity classifies a Problem. It is a fixed property declared per rule in -// the registry (never computed per run): PDF/A-1b structural rules are always -// "error" and gate the CLI exit code; PDF/UA-1 structural rules are always -// "warning" and never gate. "info" is reserved for the degraded-rule signal. +// the registry (never computed per run). An "error" rule gates the CLI exit +// code, both through its findings and when it could not be evaluated; a +// "warning" rule never gates. Every PDF/A-1b rule is "error"; PDF/UA-1 rules +// are "error" or "warning" per rule. "info" is reserved for the degraded-rule +// signal. type Severity = string // Profile names the bounded, versioned rule subset selected for a run. Exactly @@ -24,8 +28,9 @@ const ( // ProfilePDFA1B is the default profile: the PDF/A-1b structural rule set // (all error severity). ProfilePDFA1B = "pdfa-1b" - // ProfilePDFUA1Structural is the PDF/UA-1 structural subset (all warning - // severity, non-gating). + // ProfilePDFUA1Structural is the PDF/UA-1 structural subset: catalog, + // balance, uniqueness and nesting rules (warning), ParentTree reachability + // and /S rules (error). ProfilePDFUA1Structural = "pdfua-1-structural" ) @@ -129,9 +134,11 @@ type ruleHit struct { // rule is one entry in the bounded, documented rule registry. Adding a rule is // a code change, not a config change. Severity is a fixed property of the rule // here, not computed per run. checks is a short plain-ASCII phrase saying what -// the check tests, including its coverage bound. check returns an error when it -// could not read what it needs to decide (e.g. the catalog); the rule is then -// reported as not evaluated. +// the check tests, including its coverage bound. Exactly one of check and +// checkStructure is set. check reads the document; checkStructure reads the +// run's structure report, after the engine has read the catalog. Either +// returns an error when it could not read what it needs to decide; the rule +// is then reported as not evaluated. type rule struct { id string profile string @@ -139,11 +146,16 @@ type rule struct { specRef string checks string check func(doc *DocumentState) ([]ruleHit, error) + // checkStructure, when set instead of check, reads the run's structure + // report. + checkStructure func(doc *DocumentState, rep *StructureReport) ([]ruleHit, error) } // ruleRegistry is the single source of truth for the bounded structural rule -// set. PDF/A-1b rules are all "error" (they gate the CLI exit code); PDF/UA-1 -// structural rules are all "warning" (informational, never gating). +// set. PDF/A-1b rules are all "error" (they gate the CLI exit code). PDF/UA-1 +// mcid-in-parent-tree and struct-elem-type are "error" and gate, matching the +// faults veraPDF fails a file on; the catalog rules and the balance, +// uniqueness and nesting rules are "warning" (they do not gate). // // RULE DELTA (veraPDF checks we deliberately do NOT implement - the structural // firewall, cross-referenced from tests/compliance-validation/ @@ -193,7 +205,7 @@ var ruleRegistry = []rule{ checks: "trailer has an /ID entry", check: checkDocumentID, }, - // --- PDF/UA-1 structural subset (all warning, non-gating) ---------------- + // --- PDF/UA-1 structural subset (ParentTree and /S error, rest warning) -- { id: "marked", profile: ProfilePDFUA1Structural, severity: "warning", specRef: "ISO 14289-1:2014, 7.1", @@ -203,7 +215,7 @@ var ruleRegistry = []rule{ { id: "struct-tree-root", profile: ProfilePDFUA1Structural, severity: "warning", specRef: "ISO 14289-1:2014, 7.1", - checks: "catalog has a /StructTreeRoot key (presence only, contents not read)", + checks: "catalog has a /StructTreeRoot that is not null or a reference to a missing object (presence only, contents not read)", check: checkStructTreeRoot, }, { @@ -212,6 +224,36 @@ var ruleRegistry = []rule{ checks: "catalog /Lang is a non-empty string", check: checkLang, }, + { + id: "marked-content-balance", profile: ProfilePDFUA1Structural, severity: "warning", + specRef: "ISO 14289-1:2014, 7.1; ISO 32000-1:2008, 14.6.1", + checks: "every page's content stream (its /Contents, an array read as one stream) closes every BMC/BDC it opens and has no EMC without an open sequence; a content stream that cannot be decoded or a page whose /Contents cannot be read is reported here; form XObjects, annotation appearances and Type3 glyphs not scanned", + checkStructure: checkMarkedContentBalance, + }, + { + id: "mcid-unique", profile: ProfilePDFUA1Structural, severity: "warning", + specRef: "ISO 14289-1:2014, 7.1; ISO 32000-1:2008, 14.7.4.2", + checks: "no MCID marks more than one BDC in a page's content stream (its /Contents read as one stream); unreadable content is reported under marked-content-balance; form XObjects not scanned", + checkStructure: checkMCIDUnique, + }, + { + id: "mcid-in-parent-tree", profile: ProfilePDFUA1Structural, severity: "error", + specRef: "ISO 14289-1:2014, 7.1; ISO 32000-1:2008, 14.7.4.4", + checks: "every MCID in a page's content stream reaches a structure element through the page's /StructParents and its ParentTree array; a BDC whose MCID cannot be read, and page content or a content stream that cannot be decoded, is reported here as not checked; whether that element's /K claims the MCID is not checked; form XObjects not scanned", + checkStructure: checkMCIDInParentTree, + }, + { + id: "mcid-not-nested", profile: ProfilePDFUA1Structural, severity: "warning", + specRef: "ISO 14289-1:2014, 7.1; ISO 32000-1:2008, 14.7.4.1", + checks: "no BDC carrying an MCID opens inside another open sequence that carries an MCID in a page's content stream (BMC and MCID-less BDC may wrap one); unreadable content is reported under marked-content-balance; form XObjects not scanned", + checkStructure: checkMCIDNotNested, + }, + { + id: "struct-elem-type", profile: ProfilePDFUA1Structural, severity: "error", + specRef: "ISO 14289-1:2014, 7.1; ISO 32000-1:2008, 14.7.2", + checks: "every structure element reached from /StructTreeRoot /K has a non-empty /S name (absent, empty or not a name is reported, and an element that cannot be read is reported as not checked); role mapping to standard types is not checked", + checkStructure: checkStructElemType, + }, } // profileInfo is the per-profile display data: the standard the profile is a @@ -232,9 +274,11 @@ var profileInfos = map[string]profileInfo{ }, ProfilePDFUA1Structural: { standard: "PDF/UA-1", - scope: "A structural subset of PDF/UA-1 limited to the catalog-level rules listed. Marked content, the " + - "structure tree's contents, alternate text, fonts and the rest of the catalog are not examined. This is " + - "not a PDF/UA-1 conformance check.", + scope: "A structural subset of PDF/UA-1 limited to the rules listed: catalog entries, the /S of each " + + "structure element, and marked-content balance, MCID uniqueness, MCID nesting and ParentTree " + + "reachability in page content. Content outside marked content, form XObject and annotation content, " + + "alternate text, fonts, role mapping and the rest of the structure tree are not examined. This is not " + + "a PDF/UA-1 conformance check.", }, } @@ -268,29 +312,24 @@ func ruleInfoOf(r rule) RuleInfo { return RuleInfo{RuleID: r.id, SpecRef: r.specRef, Severity: r.severity, Checks: r.checks} } -// ProfileGates reports whether a profile's rules gate the CLI exit code, i.e. -// the profile contains at least one error-severity rule (pdfa-1b does; -// pdfua-1-structural, all warnings, does not). Used by the CLI so a gating-rule -// that could not be evaluated (degraded to info) fails closed rather than -// reporting a clean exit 0. -func ProfileGates(profile string) bool { - for _, r := range ruleRegistry { - if r.profile == profile && r.severity == "error" { - return true - } - } - return false -} - // Validate runs the bounded rule set for the selected profile against the // document in tabID and returns the problem list, tally, disclaimer, scope // sentence and the per-rule outcomes (Rules, registry order; a degraded rule // has Evaluated false and Findings 0). An // empty profile defaults to pdfa-1b; an unrecognized profile returns -// ErrUnknownProfile (the CLI maps it to the operational exit). Runs under -// doc.pdfMu; each rule is safeCall-wrapped so a rule that panics internally -// degrades to one info-severity problem rather than failing the whole run. +// ErrUnknownProfile (the CLI maps it to the operational exit). When the +// profile has structure rules, the structure report is built once before +// doc.pdfMu is taken: a close during that walk returns an error satisfying +// errors.Is(err, context.Canceled), and any other walk failure leaves each +// structure rule not evaluated. The rules then run under doc.pdfMu; a rule +// that panics or errors degrades to one info-severity problem rather than +// failing the whole run. func (ins *Inspector) Validate(tabID, profile string) (*ValidationResult, error) { + return ins.validate(tabID, profile, newStructureWalker) +} + +// validate is Validate with the structure walker factory as a parameter. +func (ins *Inspector) validate(tabID, profile string, newWalker func(*pdfcpu_model.Context) *structureWalker) (*ValidationResult, error) { if profile == "" { profile = ProfilePDFA1B } @@ -302,6 +341,15 @@ func (ins *Inspector) Validate(tabID, profile string) (*ValidationResult, error) if err != nil { return nil, err } + // The walk takes pdfMu itself, so it runs before the run's hold. + var rep *StructureReport + var repErr error + if profileReadsStructure(profile) { + rep, repErr = buildValidateStructure(doc, newWalker) + if errors.Is(repErr, context.Canceled) { + return nil, repErr + } + } // Serialize pdfcpu access: every rule dereferences catalog/trailer/object // entries that touch pdfcpu's XRefTable. doc.pdfMu.Lock() @@ -318,7 +366,7 @@ func (ins *Inspector) Validate(tabID, profile string) (*ValidationResult, error) if r.profile != profile { continue } - hits, ruleErr := runRule(doc, r) + hits, ruleErr := runRule(doc, r, rep, repErr) info := ruleInfoOf(r) if ruleErr != nil { res.Rules = append(res.Rules, info) @@ -372,21 +420,34 @@ func summarize(problems []Problem) ValidationSummary { } // runRule runs one rule's check and returns its hits, or the error the check -// returned when it could not read what it needs (no hits then). It also +// returned when it could not read what it needs (no hits then). A structure +// rule reads the catalog first, so an unreadable catalog reads the same under +// every rule; then a failed structure walk (repErr) leaves it not evaluated; +// else its checkStructure reads rep. It also // converts ANY panic (including a runtime.Error that safeCall re-panics) into // an error so the caller can degrade the rule to an info problem rather than // crashing the whole run. This is a deliberate exception to the project-wide // "runtime.Error surfaces loudly" rule: a read-only inspector over arbitrary // PDFs must keep evaluating the remaining rules when one rule trips on // malformed input. -func runRule(doc *DocumentState, r rule) (hits []ruleHit, err error) { +func runRule(doc *DocumentState, r rule, rep *StructureReport, repErr error) (hits []ruleHit, err error) { defer func() { if rec := recover(); rec != nil { hits = nil err = fmt.Errorf("%v", rec) } }() - hits, err = r.check(doc) + if r.checkStructure != nil { + if _, err := readCatalog(doc); err != nil { + return nil, err + } + if repErr != nil { + return nil, fmt.Errorf("structure walk failed: %w", repErr) + } + hits, err = r.checkStructure(doc, rep) + } else { + hits, err = r.check(doc) + } if err != nil { return nil, err } @@ -731,17 +792,18 @@ func checkMarked(doc *DocumentState) ([]ruleHit, error) { } // checkStructTreeRoot flags a document whose catalog lacks a /StructTreeRoot -// (PDF/UA-1 requires a structure tree). Document-level. An unreadable catalog -// leaves the rule not evaluated. +// (PDF/UA-1 requires a structure tree). A /StructTreeRoot that is null or a +// reference to a missing object is absent (ISO 32000-1 7.3.10). +// Document-level. An unreadable catalog leaves the rule not evaluated. func checkStructTreeRoot(doc *DocumentState) ([]ruleHit, error) { cat, err := readCatalog(doc) if err != nil { return nil, err } - if _, ok := cat["StructTreeRoot"]; ok { + if dereference(doc, cat["StructTreeRoot"]) != nil { return nil, nil } - return []ruleHit{{message: "document has no structure tree root (/StructTreeRoot absent)"}}, nil + return []ruleHit{{message: "document has no structure tree root (/StructTreeRoot absent, null or a reference to a missing object)"}}, nil } // checkLang flags a document whose catalog lacks a natural-language /Lang entry diff --git a/internal/pdfcore/validate_rules_test.go b/internal/pdfcore/validate_rules_test.go index a39027f7..49058b2b 100644 --- a/internal/pdfcore/validate_rules_test.go +++ b/internal/pdfcore/validate_rules_test.go @@ -174,9 +174,15 @@ func TestValidate_RulesAndScopeOnUntaggedDocument(t *testing.T) { if err != nil { t.Fatalf("Validate: %v", err) } + // The catalog rules each find their missing entry; the structure rules + // find nothing in a document with no marked content and no structure tree. for _, r := range res.Rules { - if r.Findings != 1 { - t.Errorf("untagged doc: rule %q Findings = %d, want 1", r.RuleID, r.Findings) + want := 0 + if r.RuleID == "marked" || r.RuleID == "struct-tree-root" || r.RuleID == "lang" { + want = 1 + } + if r.Findings != want { + t.Errorf("untagged doc: rule %q Findings = %d, want %d", r.RuleID, r.Findings, want) } } } diff --git a/internal/pdfcore/validate_structure.go b/internal/pdfcore/validate_structure.go new file mode 100644 index 00000000..47dd9197 --- /dev/null +++ b/internal/pdfcore/validate_structure.go @@ -0,0 +1,294 @@ +package pdfcore + +import ( + "cmp" + "fmt" + "slices" + "strconv" + "strings" + + pdfcpu_model "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model" +) + +// --- PDF/UA-1 structure report rule checks ----------------------------------- + +// buildValidateStructure builds the structure report for a validate run. Any +// panic in the walk, a runtime.Error included, becomes the returned error, so +// the structure rules degrade instead of the run failing. buildStructure +// releases pdfMu through defer, so the lock is free after a recovered panic. +func buildValidateStructure(doc *DocumentState, newWalker func(*pdfcpu_model.Context) *structureWalker) (rep *StructureReport, err error) { + defer func() { + if rec := recover(); rec != nil { + rep = nil + err = fmt.Errorf("%v", rec) + } + }() + return doc.buildStructure(newWalker(doc.PDFContext)) +} + +// profileReadsStructure reports whether any rule of profile reads the +// structure report. +func profileReadsStructure(profile string) bool { + for _, r := range ruleRegistry { + if r.profile == profile && r.checkStructure != nil { + return true + } + } + return false +} + +// asciiText returns s unchanged when it is printable ASCII, else quoted with +// strconv.QuoteToASCII, so text taken from the file keeps messages ASCII. +func asciiText(s string) string { + for i := 0; i < len(s); i++ { + if s[i] < 0x20 || s[i] > 0x7e { + return strconv.QuoteToASCII(s) + } + } + return s +} + +// objHit is a finding on the object with reference ref ("N G R") and tree +// node nodeID, or a document-level finding when nodeID is empty. +func objHit(ref, nodeID, msg string) ruleHit { + if nodeID == "" { + return ruleHit{message: msg} + } + return ruleHit{message: msg, objRef: ref, objNodeID: nodeID} +} + +// pagesComplete returns an error when the page-tree walk did not list every +// page, so a content rule is not evaluated instead of passing on the pages it +// saw. +func pagesComplete(rep *StructureReport) error { + if rep.pagesIncomplete != "" { + return fmt.Errorf("could not read every page: %s", asciiText(rep.pagesIncomplete)) + } + return nil +} + +// firstUndecodedStream returns the index of the page's first content stream +// that failed to decode, or len(sp.Streams) when every stream decoded. The +// scan stack is unknown from that stream on. +func firstUndecodedStream(sp *StructPage) int { + for i, ms := range sp.Streams { + if ms.Err != "" { + return i + } + } + return len(sp.Streams) +} + +// mcidLoc renders the location of a content row as "stream R line L col C". +func mcidLoc(sp *StructPage, c ContentMCID) string { + ref := "?" + if c.Stream >= 0 && c.Stream < len(sp.Streams) { + ref = sp.Streams[c.Stream].Ref + } + return fmt.Sprintf("stream %s line %d col %d", ref, c.Line, c.Col) +} + +// mcidOccurrences groups a page's content rows with a readable MCID by MCID +// and returns the MCIDs in order of first occurrence with their locations in +// content order. +func mcidOccurrences(sp *StructPage) ([]int, map[int][]string) { + locs := map[int][]string{} + var mcids []int + for _, c := range sp.ContentMCIDs { + if c.MCID < 0 { + continue + } + if _, seen := locs[c.MCID]; !seen { + mcids = append(mcids, c.MCID) + } + locs[c.MCID] = append(locs[c.MCID], mcidLoc(sp, c)) + } + return mcids, locs +} + +// checkMarkedContentBalance flags, per page, unreadable page content, content +// streams that fail to decode, EMC operators with no open sequence and +// sequences still open at the end of the page's /Contents, which is read as +// one stream (ISO 32000-1 14.6.1). From a stream that failed to decode on, the +// scan stack is unknown, so later streams of that page are not judged for +// balance and no open-at-end finding is made for it. +func checkMarkedContentBalance(_ *DocumentState, rep *StructureReport) ([]ruleHit, error) { + if err := pagesComplete(rep); err != nil { + return nil, err + } + var hits []ruleHit + for _, sp := range rep.Pages { + if sp.Err != "" { + hits = append(hits, objHit(sp.Ref, sp.nodeID, fmt.Sprintf("page %d content could not be read (%s); marked content not checked", sp.PageNum, asciiText(sp.Err)))) + } + bad := firstUndecodedStream(sp) + for i, ms := range sp.Streams { + switch { + case ms.Err != "": + hits = append(hits, objHit(ms.Ref, ms.nodeID, fmt.Sprintf("content stream %s of page %d could not be decoded (%s); its marked content was not checked", ms.Ref, sp.PageNum, asciiText(ms.Err)))) + case i < bad && ms.MinDepth < 0: + hits = append(hits, objHit(sp.Ref, sp.nodeID, fmt.Sprintf("page %d: EMC with no open marked-content sequence (in content stream %s)", sp.PageNum, ms.Ref))) + } + } + if sp.Err == "" && bad == len(sp.Streams) && len(sp.Streams) > 0 { + if open := sp.Streams[len(sp.Streams)-1].DepthAtEnd; open > 0 { + hits = append(hits, objHit(sp.Ref, sp.nodeID, fmt.Sprintf("page %d: %d marked-content sequence(s) still open at the end of the page's content", sp.PageNum, open))) + } + } + } + return hits, nil +} + +// checkMCIDUnique flags, per page, every MCID that marks more than one BDC in +// the page's content, listing every occurrence (ISO 32000-1 14.7.4.2). +func checkMCIDUnique(_ *DocumentState, rep *StructureReport) ([]ruleHit, error) { + if err := pagesComplete(rep); err != nil { + return nil, err + } + var hits []ruleHit + for _, sp := range rep.Pages { + mcids, locs := mcidOccurrences(sp) + for _, m := range mcids { + if l := locs[m]; len(l) > 1 { + hits = append(hits, objHit(sp.Ref, sp.nodeID, fmt.Sprintf("page %d: MCID %d marks %d BDC sequences: %s", sp.PageNum, m, len(l), strings.Join(l, ", ")))) + } + } + } + return hits, nil +} + +// checkMCIDInParentTree flags, per page, content MCIDs that do not reach a +// structure element through the page's /StructParents and its ParentTree +// array (ISO 32000-1 14.7.4.4), and content whose MCIDs could not be checked: +// page content that could not be read, a content stream that failed to +// decode, and every BDC whose MCID could not be read. Per page, the content +// that could not be read comes first, then the MCIDs in content order, each +// at its first occurrence. A page-wide cause (no /StructParents, a ParentTree +// entry that is missing or unreadable) is one finding naming every MCID of the +// page in ascending order. A structure tree root whose read failed part way, +// or ParentTree entries that were not read, leave the rule not evaluated. +func checkMCIDInParentTree(_ *DocumentState, rep *StructureReport) ([]ruleHit, error) { + if err := pagesComplete(rep); err != nil { + return nil, err + } + if rep.rootReadFailed != "" { + return nil, fmt.Errorf("structure tree root could not be read, so the ParentTree is unknown: %s", asciiText(rep.rootReadFailed)) + } + if rep.parentTreeIncomplete != "" { + return nil, fmt.Errorf("the ParentTree could not be read in full, so its entries are unknown: %s", asciiText(rep.parentTreeIncomplete)) + } + var hits []ruleHit + for _, sp := range rep.Pages { + if sp.Err != "" { + hits = append(hits, objHit(sp.Ref, sp.nodeID, fmt.Sprintf("page %d content could not be read (%s); its MCIDs were not checked against the ParentTree", sp.PageNum, asciiText(sp.Err)))) + } + for _, ms := range sp.Streams { + if ms.Err != "" { + hits = append(hits, objHit(ms.Ref, ms.nodeID, fmt.Sprintf("content stream %s of page %d could not be decoded (%s); its MCIDs were not checked against the ParentTree", ms.Ref, sp.PageNum, asciiText(ms.Err)))) + } + } + mcids, locs := mcidOccurrences(sp) + cause := "" + switch { + case sp.ParentTreeErr != "": + cause = asciiText(sp.ParentTreeErr) + case sp.StructParents == nil: + cause = "page has no /StructParents" + } + if len(mcids) > 0 && cause != "" { + sorted := slices.Sorted(slices.Values(mcids)) + list := make([]string, len(sorted)) + for i, m := range sorted { + list[i] = strconv.Itoa(m) + } + hits = append(hits, objHit(sp.Ref, sp.nodeID, fmt.Sprintf("page %d: MCIDs %s not reachable: %s", sp.PageNum, strings.Join(list, ", "), cause))) + } + reported := map[int]bool{} + for _, c := range sp.ContentMCIDs { + if c.MCID < 0 { + hits = append(hits, objHit(sp.Ref, sp.nodeID, fmt.Sprintf("page %d: BDC at %s has no readable MCID (%s); not checked against the ParentTree", sp.PageNum, mcidLoc(sp, c), asciiText(c.Err)))) + continue + } + m := c.MCID + if cause != "" || reported[m] { + continue + } + reported[m] = true + var slot ParentTreeSlot + if m < len(sp.ParentTree) { + slot = sp.ParentTree[m] + } + if slot.ElementRef != "" && slot.Err == "" { + continue + } + msg := fmt.Sprintf("page %d: MCID %d (%s) has no structure element in ParentTree key %d", sp.PageNum, m, strings.Join(locs[m], ", "), *sp.StructParents) + if slot.Err != "" { + msg += ": " + asciiText(slot.Err) + } + hits = append(hits, objHit(sp.Ref, sp.nodeID, msg)) + } + } + return hits, nil +} + +// checkMCIDNotNested flags every BDC carrying an MCID that opens inside +// another open sequence carrying an MCID (ISO 32000-1 14.7.4.1). BMC and +// MCID-less BDC sequences may wrap one. Rows in or after a page's first +// content stream that failed to decode are not judged, since their enclosing +// sequences are unknown. +func checkMCIDNotNested(_ *DocumentState, rep *StructureReport) ([]ruleHit, error) { + if err := pagesComplete(rep); err != nil { + return nil, err + } + var hits []ruleHit + for _, sp := range rep.Pages { + bad := firstUndecodedStream(sp) + for _, c := range sp.ContentMCIDs { + if c.MCID < 0 || c.ParentMCID == nil || c.Stream >= bad { + continue + } + hits = append(hits, objHit(sp.Ref, sp.nodeID, fmt.Sprintf("page %d: MCID %d (%s) is nested inside MCID %d", sp.PageNum, c.MCID, mcidLoc(sp, c), *c.ParentMCID))) + } + } + return hits, nil +} + +// checkStructElemType flags every structure element reached from +// /StructTreeRoot /K whose /S is absent, empty or not a name (ISO 32000-1 +// 14.7.2), and every element whose read failed before its /S was read, as not +// checked. A structure tree root that is not a dictionary or could not be read +// leaves the rule not evaluated; an absent root, a dangling reference +// included, is struct-tree-root's finding, not this rule's. +func checkStructElemType(_ *DocumentState, rep *StructureReport) ([]ruleHit, error) { + if msg := cmp.Or(rep.rootNotDict, rep.rootReadFailed); msg != "" { + return nil, fmt.Errorf("structure tree root could not be read: %s", asciiText(msg)) + } + var hits []ruleHit + var walk func(kids []*StructKid, owner string) + walk = func(kids []*StructKid, owner string) { + for _, k := range kids { + if k.Kind != "element" || k.Element == nil { + continue + } + e := k.Element + at := fmt.Sprintf("/K index %d of %s", k.index, owner) + name := "direct structure element at " + at + if e.Ref != "" { + name = "structure element " + e.Ref + } + switch { + case e.sErr != "": + hits = append(hits, objHit(e.Ref, e.NodeID, fmt.Sprintf("%s has no usable /S (%s)", name, e.sErr))) + case e.S == "" && e.readErr != "": + hits = append(hits, objHit(e.Ref, e.NodeID, fmt.Sprintf("%s could not be read (%s); its /S was not checked", name, asciiText(e.readErr)))) + } + next := e.Ref + if next == "" { + next = at + } + walk(e.Kids, next) + } + } + walk(rep.Elements, "the structure tree root") + return hits, nil +} diff --git a/internal/pdfcore/validate_structure_test.go b/internal/pdfcore/validate_structure_test.go new file mode 100644 index 00000000..33c420b2 --- /dev/null +++ b/internal/pdfcore/validate_structure_test.go @@ -0,0 +1,1158 @@ +package pdfcore + +import ( + "errors" + "fmt" + "slices" + "strings" + "sync" + "testing" +) + +// PDF/UA-1 structural content rules of the pdfua-1-structural profile. The +// rules read the structure report, so expected locations (stream ref, line, +// col), ParentTree errors and element errors are taken from GetStructure on +// the same document rather than restated here. + +// uaStructureRuleIDs are the rules that read the structure report, in +// registry order. +var uaStructureRuleIDs = []string{"marked-content-balance", "mcid-unique", "mcid-in-parent-tree", "mcid-not-nested", "struct-elem-type"} + +// uaContentRuleIDs are the four page-content rules. +var uaContentRuleIDs = uaStructureRuleIDs[:4] + +// uaCatalogRuleIDs are the catalog-level rules. +var uaCatalogRuleIDs = []string{"marked", "struct-tree-root", "lang"} + +// uaTaggedPDF is a one-page tagged document. The catalog has /MarkInfo, +// /Lang and /StructTreeRoot 5; the root's /K is a Document element (10) with +// one P element (11) on page 3; object 6 is the ParentTree with the given +// /Nums body. page is the page dictionary body (object 3). +func uaTaggedPDF(page, nums string, extra ...rawObj) []byte { + all := []rawObj{ + {1, "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /Lang (en-US) /StructTreeRoot 5 0 R >>"}, + {2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"}, + {3, page}, + {5, "<< /Type /StructTreeRoot /K [10 0 R] /ParentTree 6 0 R /ParentTreeNextKey 1 >>"}, + {6, "<< /Nums [" + nums + "] >>"}, + {10, "<< /Type /StructElem /S /Document /P 5 0 R /K [11 0 R] >>"}, + {11, "<< /Type /StructElem /S /P /P 10 0 R /Pg 3 0 R >>"}, + } + return rawPDF(append(all, extra...)...) +} + +// uaSlots returns a ParentTree /Nums body whose key 0 array has n slots, all +// pointing at the P element. +func uaSlots(n int) string { + s := make([]string, n) + for i := range s { + s[i] = "11 0 R" + } + return "0 [" + strings.Join(s, " ") + "]" +} + +// uaStreamsPDF is uaTaggedPDF whose page has /StructParents 0 and a /Contents +// array of the given streams (objects 20, 21, ...). A stream given as a full +// object body ("<<...") is used as is, anything else becomes a stream with +// that content. pageExtra is added to the page dictionary. +func uaStreamsPDF(nums, pageExtra string, streams ...string) []byte { + refs := make([]string, len(streams)) + objs := make([]rawObj, len(streams)) + for i, s := range streams { + refs[i] = fmt.Sprintf("%d 0 R", 20+i) + if !strings.HasPrefix(s, "<<") { + s = rawStream(s) + } + objs[i] = rawObj{20 + i, s} + } + page := fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents [%s] %s >>", strings.Join(refs, " "), pageExtra) + return uaTaggedPDF(page, nums, objs...) +} + +// uaContentPDF is uaStreamsPDF with /StructParents 0 and ParentTree slots for +// MCIDs 0..9. +func uaContentPDF(streams ...string) []byte { + return uaStreamsPDF(uaSlots(10), "/StructParents 0", streams...) +} + +// uaRun validates pdf under pdfua-1-structural and returns the result with +// the structure report of the same document. +func uaRun(t *testing.T, pdf []byte) (*ValidationResult, *StructureReport) { + t.Helper() + ins, _ := openUnvalidated(t, pdf) + rep := getStructure(t, ins, "raw") + res, err := ins.Validate("raw", ProfilePDFUA1Structural) + if err != nil { + t.Fatalf("Validate: %v", err) + } + return res, rep +} + +// uaRuleOf returns the named rule's outcome, failing when it is absent. +func uaRuleOf(t *testing.T, res *ValidationResult, id string) RuleInfo { + t.Helper() + for _, r := range res.Rules { + if r.RuleID == id { + return r + } + } + ids := make([]string, len(res.Rules)) + for i, r := range res.Rules { + ids[i] = r.RuleID + } + t.Fatalf("rule %q missing from the run's rules %v", id, ids) + return RuleInfo{} +} + +// uaFindings returns the problems of one rule, in output order. +func uaFindings(res *ValidationResult, id string) []Problem { + var out []Problem + for _, p := range res.Problems { + if p.RuleID == id { + out = append(out, p) + } + } + return out +} + +// uaMessages returns the messages of one rule's problems. +func uaMessages(res *ValidationResult, id string) []string { + var out []string + for _, p := range uaFindings(res, id) { + out = append(out, p.Message) + } + return out +} + +// uaExpect checks that every structure rule was evaluated, that the rules in +// want have exactly that many findings and that every other structure rule +// has none. +func uaExpect(t *testing.T, res *ValidationResult, want map[string]int) { + t.Helper() + for _, id := range uaStructureRuleIDs { + r := uaRuleOf(t, res, id) + if !r.Evaluated { + t.Errorf("rule %s not evaluated: %+v; problems %+v", id, r, uaFindings(res, id)) + continue + } + if r.Findings != want[id] || len(uaFindings(res, id)) != want[id] { + t.Errorf("rule %s findings = %d, want %d: %q", id, r.Findings, want[id], uaMessages(res, id)) + } + } +} + +// uaNodeID returns the obj:{gen}:{num} node id of an "N G R" reference. +func uaNodeID(t *testing.T, ref string) string { + t.Helper() + var num, gen int + if _, err := fmt.Sscanf(ref, "%d %d R", &num, &gen); err != nil { + t.Fatalf("reference %q is not \"N G R\": %v", ref, err) + } + return fmt.Sprintf("obj:%d:%d", gen, num) +} + +// uaOnPage checks that p points at the page object 3 0 R. +func uaOnPage(t *testing.T, p Problem) { + t.Helper() + if p.ObjRef != "3 0 R" || p.ObjNodeID != "obj:0:3" { + t.Errorf("problem %q objRef %q objNodeId %q, want page 3 0 R obj:0:3", p.Message, p.ObjRef, p.ObjNodeID) + } +} + +// uaLoc returns "stream R line L col C" for content row i of page 1. +func uaLoc(t *testing.T, rep *StructureReport, i int) string { + t.Helper() + p := mustPage(t, rep, 1) + if i >= len(p.ContentMCIDs) { + t.Fatalf("page 1 has %d content rows, want row %d", len(p.ContentMCIDs), i) + } + c := p.ContentMCIDs[i] + return fmt.Sprintf("stream %s line %d col %d", p.Streams[c.Stream].Ref, c.Line, c.Col) +} + +// uaOnlyMessage returns the one message of rule id, failing on any other count. +func uaOnlyMessage(t *testing.T, res *ValidationResult, id string) Problem { + t.Helper() + ps := uaFindings(res, id) + if len(ps) != 1 { + t.Fatalf("rule %s problems = %q, want exactly one", id, uaMessages(res, id)) + } + return ps[0] +} + +// --------------------------------------------------------------------------- +// Registry and scope +// --------------------------------------------------------------------------- + +func TestProfileRules_PDFUAStructureRulesInOrder(t *testing.T) { + want := []struct { + id, severity, specRef string + }{ + {"marked", "warning", "ISO 14289-1:2014, 7.1"}, + {"struct-tree-root", "warning", "ISO 14289-1:2014, 7.1"}, + {"lang", "warning", "ISO 14289-1:2014, 7.2"}, + {"marked-content-balance", "warning", "ISO 14289-1:2014, 7.1; ISO 32000-1:2008, 14.6.1"}, + {"mcid-unique", "warning", "ISO 14289-1:2014, 7.1; ISO 32000-1:2008, 14.7.4.2"}, + {"mcid-in-parent-tree", "error", "ISO 14289-1:2014, 7.1; ISO 32000-1:2008, 14.7.4.4"}, + {"mcid-not-nested", "warning", "ISO 14289-1:2014, 7.1; ISO 32000-1:2008, 14.7.4.1"}, + {"struct-elem-type", "error", "ISO 14289-1:2014, 7.1; ISO 32000-1:2008, 14.7.2"}, + } + got := ProfileRules(ProfilePDFUA1Structural) + if len(got) != len(want) { + ids := make([]string, len(got)) + for i, r := range got { + ids[i] = r.RuleID + } + t.Fatalf("pdfua-1-structural rules = %v, want %d rules", ids, len(want)) + } + for i, w := range want { + g := got[i] + if g.RuleID != w.id || string(g.Severity) != w.severity || g.SpecRef != w.specRef { + t.Errorf("rule %d = %s %s %q, want %s %s %q", i, g.RuleID, g.Severity, g.SpecRef, w.id, w.severity, w.specRef) + } + assertHonestText(t, "checks of "+g.RuleID, g.Checks) + } +} + +func TestProfileRules_PDFUAChecksPhrasesNameTheirCoverageBound(t *testing.T) { + phrases := map[string]string{} + for _, r := range ProfileRules(ProfilePDFUA1Structural) { + phrases[r.RuleID] = strings.ToLower(r.Checks) + } + for _, id := range uaContentRuleIDs { + c, ok := phrases[id] + if !ok { + t.Errorf("rule %s missing", id) + continue + } + if !strings.Contains(c, "form xobject") || !strings.Contains(c, "not scanned") { + t.Errorf("%s checks %q must say form XObjects are not scanned", id, c) + } + if !strings.Contains(c, "/contents") && !strings.Contains(c, "content stream") { + t.Errorf("%s checks %q must say it reads page content", id, c) + } + } + if c := phrases["marked-content-balance"]; !strings.Contains(c, "decoded") { + t.Errorf("marked-content-balance checks %q must say an undecodable content stream is reported there", c) + } + for _, id := range []string{"mcid-unique", "mcid-not-nested"} { + if c := phrases[id]; !strings.Contains(c, "reported under marked-content-balance") { + t.Errorf("%s checks %q must say unreadable content is reported under marked-content-balance", id, c) + } + } + if c := phrases["mcid-in-parent-tree"]; !strings.Contains(c, "cannot be decoded") || !strings.Contains(c, "not checked") { + t.Errorf("mcid-in-parent-tree checks %q must say undecodable content is reported there as not checked", c) + } + if c := phrases["struct-elem-type"]; !strings.Contains(c, "/s") || !strings.Contains(c, "role mapping") { + t.Errorf("struct-elem-type checks %q must name /S and that role mapping is not checked", c) + } +} + +func TestProfileRules_PDFARulesUnchanged(t *testing.T) { + var ids []string + for _, r := range ProfileRules(ProfilePDFA1B) { + ids = append(ids, r.RuleID) + if r.Severity != "error" { + t.Errorf("pdfa-1b rule %s severity %q, want error", r.RuleID, r.Severity) + } + } + for _, id := range uaStructureRuleIDs { + if slices.Contains(ids, id) { + t.Errorf("pdfa-1b lists the PDF/UA rule %s", id) + } + } +} + +func TestProfileScope_PDFUANamesContentRulesAndWhatIsNotExamined(t *testing.T) { + s := ProfileScope(ProfilePDFUA1Structural) + low := strings.ToLower(s) + for _, w := range []string{ + "marked-content balance", "mcid uniqueness", "mcid nesting", "parenttree reachability", + "/s", "alternate text", "font", "role mapping", "form xobject", "marked content", + "structure tree", "not a pdf/ua-1 conformance check", + } { + if !strings.Contains(low, w) { + t.Errorf("scope %q does not mention %q", s, w) + } + } + if strings.Contains(low, "catalog-level rules listed") { + t.Errorf("scope %q still limits the profile to catalog-level rules", s) + } + assertHonestText(t, "pdfua-1-structural scope", s) +} + +// --------------------------------------------------------------------------- +// Committed fixtures +// --------------------------------------------------------------------------- + +// uaFixture validates a testdata fixture under pdfua-1-structural. +func uaFixture(t *testing.T, name string) (*ValidationResult, *StructureReport) { + t.Helper() + ins := openStructureFixture(t, name) + rep := getStructure(t, ins, "s") + res, err := ins.Validate("s", ProfilePDFUA1Structural) + if err != nil { + t.Fatalf("Validate %s: %v", name, err) + } + return res, rep +} + +func TestValidateUA_CleanFixtures(t *testing.T) { + for _, name := range []string{"ua-structural-clean.pdf", "structure.pdf"} { + t.Run(name, func(t *testing.T) { + res, _ := uaFixture(t, name) + if len(res.Rules) != 8 { + t.Errorf("got %d rules, want 8", len(res.Rules)) + } + for _, r := range res.Rules { + if !r.Evaluated || r.Findings != 0 { + t.Errorf("rule %+v, want evaluated with 0 findings", r) + } + } + if len(res.Problems) != 0 || res.Summary != (ValidationSummary{}) { + t.Errorf("problems %+v summary %+v, want none", res.Problems, res.Summary) + } + }) + } +} + +func TestValidateUA_FixturesWithoutMarkedContentGetNoStructureFinding(t *testing.T) { + for _, name := range []string{"tagged.pdf", "untagged.pdf"} { + t.Run(name, func(t *testing.T) { + res, _ := uaFixture(t, name) + uaExpect(t, res, nil) + }) + } +} + +func TestValidateUA_EachFaultFixtureTripsOneRule(t *testing.T) { + cases := []struct { + name, rule string + severity Severity + }{ + {"ua-structural-unbalanced.pdf", "marked-content-balance", "warning"}, + {"ua-structural-duplicate-mcid.pdf", "mcid-unique", "warning"}, + {"ua-structural-orphan-mcid.pdf", "mcid-in-parent-tree", "error"}, + {"ua-structural-nested-mcid.pdf", "mcid-not-nested", "warning"}, + {"ua-structural-empty-s.pdf", "struct-elem-type", "error"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res, rep := uaFixture(t, tc.name) + uaExpect(t, res, map[string]int{tc.rule: 1}) + for _, id := range uaCatalogRuleIDs { + if r := uaRuleOf(t, res, id); !r.Evaluated || r.Findings != 0 { + t.Errorf("catalog rule %s = %+v, want evaluated with 0 findings", id, r) + } + } + if len(res.Problems) != 1 { + t.Fatalf("problems = %+v, want exactly one", res.Problems) + } + p := res.Problems[0] + if p.Severity != tc.severity { + t.Errorf("severity = %q, want %q", p.Severity, tc.severity) + } + wantRef := mustPage(t, rep, 1).Ref + if tc.rule == "struct-elem-type" { + wantRef = "" + eachKid(rep.Elements, func(k *StructKid) { + if k.Element != nil && k.Element.S == "" { + wantRef = k.Element.Ref + } + }) + } + if wantRef == "" || p.ObjRef != wantRef || p.ObjNodeID != uaNodeID(t, wantRef) { + t.Errorf("objRef %q objNodeId %q, want %q", p.ObjRef, p.ObjNodeID, wantRef) + } + if tc.rule != "struct-elem-type" && !strings.HasPrefix(p.Message, "page 1") { + t.Errorf("message %q does not name the page", p.Message) + } + }) + } +} + +// --------------------------------------------------------------------------- +// marked-content-balance +// --------------------------------------------------------------------------- + +func TestValidateUA_BalanceUnmatchedEMCNamesStream(t *testing.T) { + res, _ := uaRun(t, uaContentPDF("EMC /P <> BDC EMC")) + uaExpect(t, res, map[string]int{"marked-content-balance": 1}) + p := uaOnlyMessage(t, res, "marked-content-balance") + uaOnPage(t, p) + if want := "page 1: EMC with no open marked-content sequence (in content stream 20 0 R)"; p.Message != want { + t.Errorf("message = %q, want %q", p.Message, want) + } + if p.Severity != "warning" { + t.Errorf("severity = %q, want warning", p.Severity) + } +} + +func TestValidateUA_BalanceUnmatchedEMCInEachStreamIsOneFindingPerStream(t *testing.T) { + res, _ := uaRun(t, uaContentPDF("EMC", "EMC EMC")) + uaExpect(t, res, map[string]int{"marked-content-balance": 2}) + msgs := uaMessages(res, "marked-content-balance") + if len(msgs) == 2 && (!strings.Contains(msgs[0], "20 0 R") || !strings.Contains(msgs[1], "21 0 R")) { + t.Errorf("messages = %q, want one per stream in stream order", msgs) + } +} + +func TestValidateUA_BalanceOpenAtEndGivesOpenCount(t *testing.T) { + res, _ := uaRun(t, uaContentPDF("/P <> BDC EMC", "/P <> BDC /Span BMC")) + uaExpect(t, res, map[string]int{"marked-content-balance": 1}) + p := uaOnlyMessage(t, res, "marked-content-balance") + uaOnPage(t, p) + if want := "page 1: 2 marked-content sequence(s) still open at the end of the page's content"; p.Message != want { + t.Errorf("message = %q, want %q", p.Message, want) + } +} + +func TestValidateUA_BalanceSequenceAcrossContentsElementsIsNotAFinding(t *testing.T) { + res, _ := uaRun(t, uaContentPDF("/P <> BDC (a) Tj", "(b) Tj EMC")) + uaExpect(t, res, nil) +} + +func TestValidateUA_BalanceBMCOnlyNestingIsBalanced(t *testing.T) { + res, _ := uaRun(t, uaContentPDF("/A BMC /B BMC /C BMC EMC EMC EMC")) + uaExpect(t, res, nil) +} + +func TestValidateUA_BalanceUndecodableStreamIsReportedAndLaterStreamsStillRead(t *testing.T) { + res, rep := uaRun(t, uaContentPDF(undecodableStream, "/P <> BDC EMC /P <> BDC EMC", undecodableStream)) + if mustPage(t, rep, 1).Streams[0].Err == "" { + t.Fatal("precondition: stream 20 0 R decodes") + } + uaExpect(t, res, map[string]int{"marked-content-balance": 2, "mcid-unique": 1, "mcid-in-parent-tree": 2}) + ps := uaFindings(res, "marked-content-balance") + for i, ref := range []string{"20 0 R", "22 0 R"} { + if i >= len(ps) { + break + } + p := ps[i] + if p.ObjRef != ref || p.ObjNodeID != uaNodeID(t, ref) { + t.Errorf("finding %d objRef %q objNodeId %q, want stream %s", i, p.ObjRef, p.ObjNodeID, ref) + } + wantPrefix := "content stream " + ref + " of page 1 could not be decoded (" + if !strings.HasPrefix(p.Message, wantPrefix) || !strings.HasSuffix(p.Message, "); its marked content was not checked") { + t.Errorf("finding %d = %q, want %q ... \"); its marked content was not checked\"", i, p.Message, wantPrefix) + } + } +} + +func TestValidateUA_BalanceMalformedContentsIsAPageFinding(t *testing.T) { + page := "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 42 /StructParents 0 >>" + res, rep := uaRun(t, uaTaggedPDF(page, uaSlots(1))) + sp := mustPage(t, rep, 1) + if sp.Err == "" { + t.Fatal("precondition: a /Contents of 42 leaves no page error in the report") + } + uaExpect(t, res, map[string]int{"marked-content-balance": 1, "mcid-in-parent-tree": 1}) + p := uaOnlyMessage(t, res, "marked-content-balance") + uaOnPage(t, p) + if want := "page 1 content could not be read (" + sp.Err + "); marked content not checked"; p.Message != want { + t.Errorf("message = %q, want %q", p.Message, want) + } +} + +func TestValidateUA_BalanceStackAfterUndecodableStreamIsNotJudged(t *testing.T) { + res, _ := uaRun(t, uaContentPDF("/P <> BDC", undecodableStream, "EMC")) + uaExpect(t, res, map[string]int{"marked-content-balance": 1, "mcid-in-parent-tree": 1}) + p := uaOnlyMessage(t, res, "marked-content-balance") + if p.ObjRef != "21 0 R" || !strings.Contains(p.Message, "could not be decoded") { + t.Errorf("finding = %+v, want only the decode finding on 21 0 R", p) + } +} + +func TestValidateUA_BalanceEMCAfterUndecodableStreamIsNotJudged(t *testing.T) { + res, _ := uaRun(t, uaContentPDF(undecodableStream, "EMC")) + uaExpect(t, res, map[string]int{"marked-content-balance": 1, "mcid-in-parent-tree": 1}) + p := uaOnlyMessage(t, res, "marked-content-balance") + if p.ObjRef != "20 0 R" || !strings.Contains(p.Message, "could not be decoded") { + t.Errorf("finding = %+v, want only the decode finding on 20 0 R", p) + } +} + +func TestValidateUA_BalanceOpenAtEndAfterUndecodableStreamIsNotJudged(t *testing.T) { + res, _ := uaRun(t, uaContentPDF("/P <> BDC", undecodableStream)) + uaExpect(t, res, map[string]int{"marked-content-balance": 1, "mcid-in-parent-tree": 1}) + p := uaOnlyMessage(t, res, "marked-content-balance") + if p.ObjRef != "21 0 R" || !strings.Contains(p.Message, "could not be decoded") { + t.Errorf("finding = %+v, want only the decode finding on 21 0 R", p) + } +} + +func TestValidateUA_BalanceUnmatchedEMCBeforeUndecodableStreamIsReported(t *testing.T) { + res, _ := uaRun(t, uaContentPDF("EMC", undecodableStream)) + uaExpect(t, res, map[string]int{"marked-content-balance": 2, "mcid-in-parent-tree": 1}) + msgs := uaMessages(res, "marked-content-balance") + if !slices.Contains(msgs, "page 1: EMC with no open marked-content sequence (in content stream 20 0 R)") { + t.Errorf("messages = %q, want the unmatched EMC in stream 20 0 R", msgs) + } + if !slices.ContainsFunc(msgs, func(m string) bool { + return strings.HasPrefix(m, "content stream 21 0 R of page 1 could not be decoded") + }) { + t.Errorf("messages = %q, want the decode finding on 21 0 R", msgs) + } +} + +// --------------------------------------------------------------------------- +// mcid-unique +// --------------------------------------------------------------------------- + +func TestValidateUA_DuplicateMCIDWithinOneStream(t *testing.T) { + res, rep := uaRun(t, uaContentPDF("/P <> BDC EMC /P <> BDC EMC /P <> BDC EMC /P <> BDC EMC")) + uaExpect(t, res, map[string]int{"mcid-unique": 1}) + p := uaOnlyMessage(t, res, "mcid-unique") + uaOnPage(t, p) + want := fmt.Sprintf("page 1: MCID 0 marks 3 BDC sequences: %s, %s, %s", uaLoc(t, rep, 0), uaLoc(t, rep, 2), uaLoc(t, rep, 3)) + if p.Message != want { + t.Errorf("message = %q, want %q", p.Message, want) + } +} + +func TestValidateUA_DuplicateMCIDAcrossContentsElements(t *testing.T) { + res, rep := uaRun(t, uaContentPDF("/P <> BDC EMC /P <> BDC EMC", "/P <> BDC EMC /P <> BDC EMC")) + uaExpect(t, res, map[string]int{"mcid-unique": 2}) + want := []string{ + fmt.Sprintf("page 1: MCID 0 marks 2 BDC sequences: %s, %s", uaLoc(t, rep, 0), uaLoc(t, rep, 3)), + fmt.Sprintf("page 1: MCID 1 marks 2 BDC sequences: %s, %s", uaLoc(t, rep, 1), uaLoc(t, rep, 2)), + } + if got := uaMessages(res, "mcid-unique"); !slices.Equal(got, want) { + t.Errorf("messages = %q, want %q", got, want) + } +} + +func TestValidateUA_SameMCIDOnTwoPagesIsNotADuplicate(t *testing.T) { + pdf := rawPDF( + rawObj{1, "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /Lang (en-US) /StructTreeRoot 5 0 R >>"}, + rawObj{2, "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>"}, + rawObj{3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 20 0 R /StructParents 0 >>"}, + rawObj{4, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 21 0 R /StructParents 1 >>"}, + rawObj{5, "<< /Type /StructTreeRoot /K [10 0 R] /ParentTree 6 0 R /ParentTreeNextKey 2 >>"}, + rawObj{6, "<< /Nums [0 [11 0 R] 1 [12 0 R]] >>"}, + rawObj{10, "<< /Type /StructElem /S /Document /P 5 0 R /K [11 0 R 12 0 R] >>"}, + rawObj{11, "<< /Type /StructElem /S /P /P 10 0 R /Pg 3 0 R /K 0 >>"}, + rawObj{12, "<< /Type /StructElem /S /P /P 10 0 R /Pg 4 0 R /K 0 >>"}, + rawObj{20, rawStream("/P <> BDC EMC")}, + rawObj{21, rawStream("/P <> BDC EMC")}, + ) + res, _ := uaRun(t, pdf) + uaExpect(t, res, nil) +} + +// --------------------------------------------------------------------------- +// mcid-in-parent-tree +// --------------------------------------------------------------------------- + +func TestValidateUA_NoStructParentsIsOnePageWideFinding(t *testing.T) { + res, _ := uaRun(t, uaStreamsPDF(uaSlots(10), "", "/P <> BDC EMC /P <> BDC EMC /P <> BDC EMC /P <> BDC EMC")) + uaExpect(t, res, map[string]int{"mcid-in-parent-tree": 1, "mcid-unique": 1}) + p := uaOnlyMessage(t, res, "mcid-in-parent-tree") + uaOnPage(t, p) + if want := "page 1: MCIDs 0, 1, 5 not reachable: page has no /StructParents"; p.Message != want { + t.Errorf("message = %q, want %q", p.Message, want) + } +} + +func TestValidateUA_StructParentsKeyMissingFromParentTree(t *testing.T) { + res, rep := uaRun(t, uaStreamsPDF(uaSlots(10), "/StructParents 7", "/P <> BDC EMC /P <> BDC EMC")) + sp := mustPage(t, rep, 1) + if sp.ParentTreeErr == "" || !strings.Contains(sp.ParentTreeErr, "7") { + t.Fatalf("precondition: ParentTreeErr %q does not name key 7", sp.ParentTreeErr) + } + uaExpect(t, res, map[string]int{"mcid-in-parent-tree": 1}) + p := uaOnlyMessage(t, res, "mcid-in-parent-tree") + uaOnPage(t, p) + if want := "page 1: MCIDs 0, 1 not reachable: " + sp.ParentTreeErr; p.Message != want { + t.Errorf("message = %q, want %q", p.Message, want) + } +} + +func TestValidateUA_StructParentsNotAnIntegerNamesTheParentTreeError(t *testing.T) { + res, rep := uaRun(t, uaStreamsPDF(uaSlots(10), "/StructParents /Zero", "/P <> BDC EMC")) + sp := mustPage(t, rep, 1) + if sp.StructParents != nil || sp.ParentTreeErr == "" { + t.Fatalf("precondition: StructParents %v ParentTreeErr %q, want nil and an error", sp.StructParents, sp.ParentTreeErr) + } + uaExpect(t, res, map[string]int{"mcid-in-parent-tree": 1}) + p := uaOnlyMessage(t, res, "mcid-in-parent-tree") + if want := "page 1: MCIDs 0 not reachable: " + sp.ParentTreeErr; p.Message != want { + t.Errorf("message = %q, want %q", p.Message, want) + } + if strings.Contains(p.Message, "page has no /StructParents") { + t.Errorf("message %q blames a missing /StructParents for a non-integer one", p.Message) + } +} + +func TestValidateUA_NullSlotAndSlotWithErrorAreOneFindingPerMCID(t *testing.T) { + res, rep := uaRun(t, uaStreamsPDF("0 [11 0 R null (not-an-element)]", "/StructParents 0", + "/P <> BDC EMC /P <> BDC EMC /P <> BDC EMC")) + sp := mustPage(t, rep, 1) + if len(sp.ParentTree) < 3 || sp.ParentTree[2].Err == "" { + t.Fatalf("precondition: ParentTree slots %+v, want slot 2 with an error", sp.ParentTree) + } + uaExpect(t, res, map[string]int{"mcid-in-parent-tree": 2}) + ps := uaFindings(res, "mcid-in-parent-tree") + if len(ps) != 2 { + t.FailNow() + } + for _, p := range ps { + uaOnPage(t, p) + } + if want := fmt.Sprintf("page 1: MCID 1 (%s) has no structure element in ParentTree key 0", uaLoc(t, rep, 1)); ps[0].Message != want { + t.Errorf("null slot finding = %q, want %q", ps[0].Message, want) + } + wantPrefix := fmt.Sprintf("page 1: MCID 2 (%s) has no structure element in ParentTree key 0", uaLoc(t, rep, 2)) + if !strings.HasPrefix(ps[1].Message, wantPrefix) || !strings.Contains(ps[1].Message, sp.ParentTree[2].Err) { + t.Errorf("error slot finding = %q, want %q followed by the slot error %q", ps[1].Message, wantPrefix, sp.ParentTree[2].Err) + } +} + +func TestValidateUA_UnreachableMCIDMarkedTwiceIsOneFindingListingBoth(t *testing.T) { + res, rep := uaRun(t, uaStreamsPDF(uaSlots(1), "/StructParents 0", "/P <> BDC EMC /P <> BDC EMC", "/P <> BDC EMC")) + uaExpect(t, res, map[string]int{"mcid-in-parent-tree": 1, "mcid-unique": 1}) + p := uaOnlyMessage(t, res, "mcid-in-parent-tree") + if want := fmt.Sprintf("page 1: MCID 1 (%s, %s) has no structure element in ParentTree key 0", uaLoc(t, rep, 0), uaLoc(t, rep, 2)); p.Message != want { + t.Errorf("message = %q, want %q", p.Message, want) + } +} + +func TestValidateUA_UnreadableMCIDIsAParentTreeFinding(t *testing.T) { + res, rep := uaRun(t, uaContentPDF("/P <> BDC EMC")) + row := mustPage(t, rep, 1).ContentMCIDs + if len(row) != 1 || row[0].MCID != -1 || row[0].Err == "" { + t.Fatalf("precondition: content rows %+v, want one unreadable MCID", row) + } + uaExpect(t, res, map[string]int{"mcid-in-parent-tree": 1}) + p := uaOnlyMessage(t, res, "mcid-in-parent-tree") + uaOnPage(t, p) + if want := fmt.Sprintf("page 1: BDC at %s has no readable MCID (%s); not checked against the ParentTree", uaLoc(t, rep, 0), row[0].Err); p.Message != want { + t.Errorf("message = %q, want %q", p.Message, want) + } +} + +func TestValidateUA_ParentTreeFindingsAreInContentOrder(t *testing.T) { + res, _ := uaRun(t, uaContentPDF("/P <> BDC EMC /P <> BDC EMC /P <> BDC EMC /P <> BDC EMC")) + msgs := uaMessages(res, "mcid-in-parent-tree") + if len(msgs) != 3 || !strings.Contains(msgs[0], "has no readable MCID") || !strings.HasPrefix(msgs[1], "page 1: MCID 12 ") || !strings.HasPrefix(msgs[2], "page 1: MCID 11 ") { + t.Errorf("messages = %q, want the unreadable BDC, then MCID 12, then MCID 11", msgs) + } +} + +func TestValidateUA_DuplicateMCIDFindingsAreInContentOrder(t *testing.T) { + res, _ := uaRun(t, uaContentPDF("/P <> BDC EMC /P <> BDC EMC /P <> BDC EMC /P <> BDC EMC")) + msgs := uaMessages(res, "mcid-unique") + if len(msgs) != 2 || !strings.HasPrefix(msgs[0], "page 1: MCID 5 ") || !strings.HasPrefix(msgs[1], "page 1: MCID 2 ") { + t.Errorf("messages = %q, want MCID 5 then MCID 2", msgs) + } +} + +func TestValidateUA_FileTextInMessagesIsQuotedASCII(t *testing.T) { + badFilter := "<< /Length 9 /Filter /Fo\xe9o >>\nstream\nnot-zlib!\nendstream" + res, _ := uaRun(t, uaContentPDF("/P <> BDC EMC", badFilter)) + for _, id := range []string{"marked-content-balance", "mcid-in-parent-tree"} { + ps := uaFindings(res, id) + if len(ps) == 0 { + t.Errorf("%s: no finding, want one carrying the non-ASCII file text", id) + } + for _, p := range ps { + for i := 0; i < len(p.Message); i++ { + if p.Message[i] < 0x20 || p.Message[i] > 0x7e { + t.Errorf("%s message %q has byte 0x%02x at %d", id, p.Message, p.Message[i], i) + break + } + } + if !strings.Contains(p.Message, `\u00e9`) && !strings.Contains(p.Message, `\xe9`) { + t.Errorf("%s message %q, want the file text in quoted form", id, p.Message) + } + } + } +} + +// --------------------------------------------------------------------------- +// mcid-not-nested +// --------------------------------------------------------------------------- + +func TestValidateUA_MCIDInsideMCIDNamesBoth(t *testing.T) { + res, rep := uaRun(t, uaContentPDF("/P <> BDC /Span <> BDC EMC EMC")) + uaExpect(t, res, map[string]int{"mcid-not-nested": 1}) + p := uaOnlyMessage(t, res, "mcid-not-nested") + uaOnPage(t, p) + if want := fmt.Sprintf("page 1: MCID 1 (%s) is nested inside MCID 0", uaLoc(t, rep, 1)); p.Message != want { + t.Errorf("message = %q, want %q", p.Message, want) + } +} + +func TestValidateUA_MCIDInsideNonStructuralSequenceIsAllowed(t *testing.T) { + for name, content := range map[string]string{ + "inside BMC": "/Artifact BMC /P <> BDC EMC EMC", + "inside MCID-less BDC": "/Span <> BDC /P <> BDC EMC EMC", + "MCID-less BDC in a MCID": "/P <> BDC /Span <> BDC EMC EMC", + } { + t.Run(name, func(t *testing.T) { + res, _ := uaRun(t, uaContentPDF(content)) + uaExpect(t, res, nil) + }) + } +} + +func TestValidateUA_MCIDThreeDeepIsOneFindingPerNestedMCID(t *testing.T) { + res, rep := uaRun(t, uaContentPDF("/Div <> BDC /P <> BDC /Art BMC /Span <> BDC EMC EMC EMC EMC")) + uaExpect(t, res, map[string]int{"mcid-not-nested": 2}) + want := []string{ + fmt.Sprintf("page 1: MCID 1 (%s) is nested inside MCID 0", uaLoc(t, rep, 1)), + fmt.Sprintf("page 1: MCID 2 (%s) is nested inside MCID 1", uaLoc(t, rep, 2)), + } + if got := uaMessages(res, "mcid-not-nested"); !slices.Equal(got, want) { + t.Errorf("messages = %q, want %q", got, want) + } +} + +func TestValidateUA_NestingAfterUndecodableStreamIsNotJudged(t *testing.T) { + res, _ := uaRun(t, uaContentPDF("/P <> BDC", undecodableStream, "/Span <> BDC EMC EMC")) + if r := uaRuleOf(t, res, "mcid-not-nested"); !r.Evaluated || r.Findings != 0 { + t.Errorf("mcid-not-nested = %+v, want evaluated with 0 findings after an undecodable stream: %q", r, uaMessages(res, "mcid-not-nested")) + } +} + +func TestValidateUA_UnreadableMCIDInsideMCIDIsNotANestingFinding(t *testing.T) { + res, _ := uaRun(t, uaContentPDF("/P <> BDC /Span <> BDC EMC EMC")) + uaExpect(t, res, map[string]int{"mcid-in-parent-tree": 1}) +} + +// --------------------------------------------------------------------------- +// struct-elem-type +// --------------------------------------------------------------------------- + +// uaTreePDF is a one-page document with the UA catalog entries, no marked +// content and a /StructTreeRoot 5 with the given body. +func uaTreePDF(rootBody string, objs ...rawObj) []byte { + all := []rawObj{ + {1, "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /Lang (en-US) /StructTreeRoot 5 0 R >>"}, + {2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"}, + {3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>"}, + {5, rootBody}, + } + return rawPDF(append(all, objs...)...) +} + +// uaElement returns the element with the given ref from the report. +func uaElement(t *testing.T, rep *StructureReport, ref string) *StructElem { + t.Helper() + var found *StructElem + eachKid(rep.Elements, func(k *StructKid) { + if k.Element != nil && k.Element.Ref == ref { + found = k.Element + } + }) + if found == nil { + t.Fatalf("element %s missing from the report", ref) + } + return found +} + +func TestValidateUA_ElementWithoutUsableSIsAnErrorOnTheElement(t *testing.T) { + res, rep := uaRun(t, uaTreePDF("<< /Type /StructTreeRoot /K [10 0 R] >>", + rawObj{10, "<< /Type /StructElem /S /Document /K [11 0 R 12 0 R 13 0 R 14 0 R] >>"}, + rawObj{11, "<< /Type /StructElem /T (no S) >>"}, + rawObj{12, "<< /Type /StructElem /S / >>"}, + rawObj{13, "<< /Type /StructElem /S (P) >>"}, + rawObj{14, "<< /Type /StructElem /S /P >>"}, + )) + uaExpect(t, res, map[string]int{"struct-elem-type": 3}) + ps := uaFindings(res, "struct-elem-type") + for i, ref := range []string{"11 0 R", "12 0 R", "13 0 R"} { + if i >= len(ps) { + break + } + e := uaElement(t, rep, ref) + if e.S != "" || e.Err == "" { + t.Fatalf("precondition: element %s S %q Err %q", ref, e.S, e.Err) + } + p := ps[i] + if p.Severity != "error" || p.ObjRef != ref || p.ObjNodeID != uaNodeID(t, ref) { + t.Errorf("finding %d = %+v, want an error on %s", i, p, ref) + } + if want := "structure element " + ref + " has no usable /S (" + e.Err + ")"; p.Message != want { + t.Errorf("finding %d = %q, want %q", i, p.Message, want) + } + } + if res.Summary.Errors != 3 || res.Summary.Warnings != 0 { + t.Errorf("summary = %+v, want 3 errors and no warnings for the struct-elem-type findings", res.Summary) + } +} + +func TestValidateUA_DirectElementWithoutSNamesItsPosition(t *testing.T) { + res, _ := uaRun(t, uaTreePDF("<< /Type /StructTreeRoot /K [<< /Type /StructElem /T (root direct) >> 10 0 R] >>", + rawObj{10, "<< /Type /StructElem /S /Div /K [11 0 R << /Type /StructElem /T (inner) >> << /Type /StructElem /S /P >>] >>"}, + rawObj{11, "<< /Type /StructElem /S /P >>"}, + )) + uaExpect(t, res, map[string]int{"struct-elem-type": 2}) + ps := uaFindings(res, "struct-elem-type") + wants := []string{"direct structure element at /K index 0 of the structure tree root", "direct structure element at /K index 1 of 10 0 R"} + for i, w := range wants { + if i >= len(ps) { + break + } + if ps[i].ObjRef != "" || ps[i].ObjNodeID != "" || !strings.Contains(ps[i].Message, w) || !strings.Contains(ps[i].Message, "/S") { + t.Errorf("finding %d = %+v, want a document-level finding containing %q", i, ps[i], w) + } + } +} + +func TestValidateUA_ErrorKidsAreNotAnSFinding(t *testing.T) { + res, _ := uaRun(t, uaTreePDF("<< /Type /StructTreeRoot /K [10 0 R] >>", + rawObj{10, "<< /Type /StructElem /S /Div /K [99 0 R (str) 10 0 R] >>"}, + )) + uaExpect(t, res, nil) +} + +func TestValidateUA_RoleMappedSIsUsable(t *testing.T) { + res, _ := uaRun(t, uaTreePDF("<< /Type /StructTreeRoot /RoleMap << /Heading1 /H1 >> /K [10 0 R] >>", + rawObj{10, "<< /Type /StructElem /S /Heading1 >>"}, + )) + uaExpect(t, res, nil) +} + +func TestValidateUA_ElementWithoutSAndBadPgNamesOnlyTheSProblem(t *testing.T) { + res, rep := uaRun(t, uaTreePDF("<< /Type /StructTreeRoot /K [10 0 R] >>", + rawObj{10, "<< /Type /StructElem /S / /Pg (not a page) >>"}, + )) + e := uaElement(t, rep, "10 0 R") + if !strings.Contains(e.Err, "; ") || !strings.Contains(e.Err, "/Pg") { + t.Fatalf("precondition: element Err %q, want the /S and /Pg problems joined", e.Err) + } + uaExpect(t, res, map[string]int{"struct-elem-type": 1}) + p := uaOnlyMessage(t, res, "struct-elem-type") + if want := "structure element 10 0 R has no usable /S (/S is an empty name)"; p.Message != want { + t.Errorf("message = %q, want %q", p.Message, want) + } + if p.ObjRef != "10 0 R" || p.ObjNodeID != "obj:0:10" { + t.Errorf("objRef %q objNodeId %q, want the element 10 0 R", p.ObjRef, p.ObjNodeID) + } +} + +func TestValidateUA_DirectElementIndexCountsNullKEntries(t *testing.T) { + res, _ := uaRun(t, uaTreePDF("<< /Type /StructTreeRoot /K [10 0 R] >>", + rawObj{10, "<< /Type /StructElem /S /Document /K [null << /Type /StructElem /S / >>] >>"}, + )) + uaExpect(t, res, map[string]int{"struct-elem-type": 1}) + p := uaOnlyMessage(t, res, "struct-elem-type") + if want := "direct structure element at /K index 1 of 10 0 R has no usable /S (/S is an empty name)"; p.Message != want { + t.Errorf("message = %q, want %q", p.Message, want) + } +} + +func TestCheckStructElemType_ElementReadFailedBeforeSIsNotCheckedFinding(t *testing.T) { + rep := &StructureReport{Elements: []*StructKid{ + {Kind: "element", Element: &StructElem{Ref: "6 0 R", NodeID: "obj:0:6", readErr: "pdf parsing panic: boom"}}, + {Kind: "element", Element: &StructElem{Ref: "7 0 R", NodeID: "obj:0:7", S: "P", readErr: "pdf parsing panic: later"}}, + }} + hits, err := checkStructElemType(nil, rep) + if err != nil || len(hits) != 1 { + t.Fatalf("hits %+v err %v, want one finding for the element whose /S was not read", hits, err) + } + h := hits[0] + if want := "structure element 6 0 R could not be read (pdf parsing panic: boom); its /S was not checked"; h.message != want || h.objNodeID != "obj:0:6" { + t.Errorf("hit %+v, want %q on obj:0:6", h, want) + } +} + +func TestValidateUA_DanglingStructTreeRootIsAbsent(t *testing.T) { + res, rep := uaRun(t, rawPDF( + rawObj{1, "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /Lang (en-US) /StructTreeRoot 99 0 R >>"}, + rawObj{2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"}, + rawObj{3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>"}, + )) + if rep.HasStructTree { + t.Error("report has a structure tree for a /StructTreeRoot that does not resolve") + } + uaExpect(t, res, nil) + if r := uaRuleOf(t, res, "struct-tree-root"); !r.Evaluated || r.Findings != 1 { + t.Errorf("struct-tree-root = %+v, want evaluated with 1 finding", r) + } + if res.Summary.Errors != 0 { + t.Errorf("summary %+v, want no errors", res.Summary) + } +} + +func TestValidateUA_UndecodableStreamMCIDsAreNotCheckedErrors(t *testing.T) { + res, _ := uaRun(t, uaStreamsPDF(uaSlots(1), "/StructParents 0", "/P <> BDC EMC /P <> BDC EMC", undecodableStream)) + uaExpect(t, res, map[string]int{"marked-content-balance": 1, "mcid-in-parent-tree": 2}) + ps := uaFindings(res, "mcid-in-parent-tree") + if len(ps) != 2 { + t.Fatalf("findings %q, want 2", uaMessages(res, "mcid-in-parent-tree")) + } + if !strings.HasPrefix(ps[0].Message, "content stream 21 0 R of page 1 could not be decoded (") || + !strings.HasSuffix(ps[0].Message, "); its MCIDs were not checked against the ParentTree") { + t.Errorf("first finding = %q, want the undecodable stream first", ps[0].Message) + } + if ps[0].ObjRef != "21 0 R" || ps[0].ObjNodeID != "obj:0:21" || ps[0].Severity != "error" { + t.Errorf("first finding %+v, want an error on stream 21 0 R", ps[0]) + } + if !strings.HasPrefix(ps[1].Message, "page 1: MCID 5 ") { + t.Errorf("second finding = %q, want MCID 5 of the decoded stream still checked", ps[1].Message) + } +} + +func TestValidateUA_UnreadableContentsMCIDsAreNotCheckedError(t *testing.T) { + page := "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 42 /StructParents 0 >>" + res, rep := uaRun(t, uaTaggedPDF(page, uaSlots(1))) + sp := mustPage(t, rep, 1) + p := uaOnlyMessage(t, res, "mcid-in-parent-tree") + uaOnPage(t, p) + if want := "page 1 content could not be read (" + sp.Err + "); its MCIDs were not checked against the ParentTree"; p.Message != want { + t.Errorf("message = %q, want %q", p.Message, want) + } + if p.Severity != "error" { + t.Errorf("severity = %q, want error", p.Severity) + } +} + +func TestValidateUA_ParentTreeKidsNotAnArrayLeavesParentTreeRuleNotEvaluated(t *testing.T) { + res, _ := uaRun(t, rawPDF( + rawObj{1, "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /Lang (en-US) /StructTreeRoot 5 0 R >>"}, + rawObj{2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"}, + rawObj{3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 20 0 R /StructParents 0 >>"}, + rawObj{5, "<< /Type /StructTreeRoot /K [11 0 R] /ParentTree 6 0 R >>"}, + rawObj{6, "<< /Kids 7 >>"}, + rawObj{11, "<< /Type /StructElem /S /P /P 5 0 R /Pg 3 0 R /K 0 >>"}, + rawObj{20, rawStream("/P <> BDC EMC")}, + )) + r := uaRuleOf(t, res, "mcid-in-parent-tree") + if r.Evaluated { + t.Fatalf("mcid-in-parent-tree = %+v, want not evaluated", r) + } + if msgs := uaMessages(res, "mcid-in-parent-tree"); len(msgs) != 1 || !strings.Contains(msgs[0], "/Kids is an integer, not an array") { + t.Errorf("messages %q, want the /Kids problem named", msgs) + } +} + +func TestValidateUA_StructTreeRootNotADictionaryLeavesSRuleNotEvaluated(t *testing.T) { + res, _ := uaRun(t, uaTreePDF("[1 2 3]")) + r := uaRuleOf(t, res, "struct-elem-type") + if r.Evaluated || r.Findings != 0 { + t.Errorf("struct-elem-type = %+v, want not evaluated", r) + } + p := uaOnlyMessage(t, res, "struct-elem-type") + if p.Severity != "info" || !strings.Contains(p.Message, "structure tree root could not be read") { + t.Errorf("problem = %+v, want an info problem naming the structure tree root", p) + } + for _, id := range uaContentRuleIDs { + if r := uaRuleOf(t, res, id); !r.Evaluated { + t.Errorf("content rule %s = %+v, want evaluated", id, r) + } + } +} + +func TestValidateUA_InlineStructTreeRootNotADictionaryLeavesSRuleNotEvaluated(t *testing.T) { + res, _ := uaRun(t, rawPDF( + rawObj{1, "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /Lang (en-US) /StructTreeRoot [1 2 3] >>"}, + rawObj{2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"}, + rawObj{3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>"}, + )) + r := uaRuleOf(t, res, "struct-elem-type") + if r.Evaluated || r.Findings != 0 { + t.Errorf("struct-elem-type = %+v, want not evaluated", r) + } + p := uaOnlyMessage(t, res, "struct-elem-type") + if p.Severity != "info" || !strings.Contains(p.Message, "structure tree root could not be read") { + t.Errorf("problem = %+v, want an info problem naming the structure tree root", p) + } +} + +func TestValidateUA_NoStructTreeRootEvaluatesSRuleWithNoFinding(t *testing.T) { + res, _ := uaRun(t, rawPDF( + rawObj{1, "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /Lang (en-US) >>"}, + rawObj{2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"}, + rawObj{3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>"}, + )) + if r := uaRuleOf(t, res, "struct-elem-type"); !r.Evaluated || r.Findings != 0 { + t.Errorf("struct-elem-type = %+v, want evaluated with 0 findings", r) + } + if r := uaRuleOf(t, res, "struct-tree-root"); r.Findings != 1 { + t.Errorf("struct-tree-root = %+v, want the one finding for the absent root", r) + } +} + +// --------------------------------------------------------------------------- +// Not evaluated, fail closed +// --------------------------------------------------------------------------- + +func TestCheckMCIDInParentTree_RootReadFailedPartWayIsNotEvaluated(t *testing.T) { + key := 0 + rep := func(readFailed, notDict string) *StructureReport { + return &StructureReport{ + rootReadFailed: readFailed, + rootNotDict: notDict, + Pages: []*StructPage{{ + PageNum: 1, + Ref: "3 0 R", + StructParents: &key, + ParentTreeErr: "/StructParents 0 but the document has no ParentTree", + Streams: []MarkedStream{{Ref: "20 0 R"}}, + ContentMCIDs: []ContentMCID{{MCID: 0, Stream: 0, Line: 1, Col: 1}}, + }}, + } + } + hits, err := checkMCIDInParentTree(nil, rep("the structure tree root could not be read: pdf parsing panic: boom", "")) + if err == nil || len(hits) != 0 || !strings.Contains(err.Error(), "boom") || !strings.Contains(err.Error(), "ParentTree") { + t.Errorf("hits %+v err %v, want not evaluated naming the root failure", hits, err) + } + hits, err = checkMCIDInParentTree(nil, rep("", "/StructTreeRoot is an array, not a dictionary")) + if err != nil || len(hits) != 1 { + t.Errorf("hits %+v err %v, want one finding when the root is not a dictionary", hits, err) + } +} + +func TestCheckMCIDInParentTree_ParentTreeNotReadInFullIsNotEvaluated(t *testing.T) { + key := 0 + rep := func(incomplete string) *StructureReport { + return &StructureReport{ + parentTreeIncomplete: incomplete, + Pages: []*StructPage{{ + PageNum: 1, + Ref: "3 0 R", + StructParents: &key, + ParentTreeErr: "ParentTree has no key 0", + Streams: []MarkedStream{{Ref: "20 0 R"}}, + ContentMCIDs: []ContentMCID{{MCID: 0, Stream: 0, Line: 1, Col: 1}}, + }}, + } + } + hits, err := checkMCIDInParentTree(nil, rep("node could not be read: pdf parsing panic: boom")) + if err == nil || len(hits) != 0 || !strings.Contains(err.Error(), "ParentTree could not be read in full") || !strings.Contains(err.Error(), "boom") { + t.Errorf("hits %+v err %v, want not evaluated naming the cause", hits, err) + } + hits, err = checkMCIDInParentTree(nil, rep("")) + if err != nil || len(hits) != 1 { + t.Errorf("hits %+v err %v, want one finding when every node that exists was read", hits, err) + } +} + +func TestCheckStructElemType_RootReadFailedPartWayIsNotEvaluated(t *testing.T) { + rep := &StructureReport{ + rootReadFailed: "the structure tree root could not be read: pdf parsing panic: boom", + Elements: []*StructKid{{Kind: "element", Element: &StructElem{Ref: "6 0 R", S: "P"}}}, + } + hits, err := checkStructElemType(nil, rep) + if err == nil || len(hits) != 0 || !strings.Contains(err.Error(), "boom") { + t.Errorf("hits %+v err %v, want not evaluated naming the root failure", hits, err) + } +} + +func TestValidateUA_PageTreeStoppedEarlyLeavesContentRulesNotEvaluated(t *testing.T) { + pdf := rawPDF(append([]rawObj{ + {1, "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /Lang (en-US) /StructTreeRoot 5 0 R >>"}, + {5, "<< /Type /StructTreeRoot /K [] >>"}, + }, structPagesObjs(2, []string{"EMC", "EMC"}, nil)...)...) + ins, doc := openUnvalidated(t, pdf) + pw := newPageWalker(doc.PDFContext) + pw.walk() + if _, err := doc.pageIndex.get(func() (*pageTree, error) { + return &pageTree{entries: pw.entries[:1], leaves: pw.leaves[:1], err: errors.New("pdf parsing panic: boom")}, nil + }); err != nil { + t.Fatal(err) + } + res, err := ins.Validate("raw", ProfilePDFUA1Structural) + if err != nil { + t.Fatalf("Validate: %v", err) + } + for _, id := range uaContentRuleIDs { + r := uaRuleOf(t, res, id) + if r.Evaluated || r.Findings != 0 { + t.Errorf("rule %s = %+v, want not evaluated", id, r) + } + ps := uaFindings(res, id) + if len(ps) != 1 || ps[0].Severity != "info" || !strings.Contains(ps[0].Message, "could not read every page") || !strings.Contains(ps[0].Message, "boom") { + t.Errorf("rule %s problems = %+v, want one info problem naming the page-tree failure", id, ps) + } + } + for _, id := range append([]string{"struct-elem-type"}, uaCatalogRuleIDs...) { + if r := uaRuleOf(t, res, id); !r.Evaluated { + t.Errorf("rule %s = %+v, want evaluated", id, r) + } + } +} + +func TestValidateUA_UnreadableCatalogWinsOverStructureFaults(t *testing.T) { + ins, doc := openUnvalidated(t, uaContentPDF("EMC /P <> BDC EMC /P <> BDC")) + doc.PDFContext.XRefTable.RootDict = nil + doc.PDFContext.XRefTable.Root = nil + res, err := ins.Validate("raw", ProfilePDFUA1Structural) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if len(res.Rules) != 8 { + t.Fatalf("got %d rules, want 8", len(res.Rules)) + } + for _, r := range res.Rules { + if r.Evaluated { + t.Errorf("rule %s evaluated with an unreadable catalog", r.RuleID) + } + ps := uaFindings(res, r.RuleID) + if len(ps) != 1 || ps[0].Severity != "info" || !strings.Contains(ps[0].Message, "catalog") { + t.Errorf("rule %s problems = %+v, want one info problem naming the catalog", r.RuleID, ps) + } + } +} + +func TestValidateUA_ConcurrentWithOtherReads(t *testing.T) { + ins, _ := openUnvalidated(t, uaContentPDF("/P <> BDC EMC", "/P <> BDC EMC")) + var wg sync.WaitGroup + errs := make(chan error, 30) + for i := 0; i < 10; i++ { + wg.Add(3) + go func() { + defer wg.Done() + res, err := ins.Validate("raw", ProfilePDFUA1Structural) + if err != nil { + errs <- fmt.Errorf("Validate: %w", err) + return + } + if len(res.Rules) != 8 { + errs <- fmt.Errorf("Validate ran %d rules, want 8", len(res.Rules)) + } + }() + go func() { + defer wg.Done() + if _, err := ins.GetContentStream("raw", "obj:0:20"); err != nil { + errs <- fmt.Errorf("GetContentStream: %w", err) + } + }() + go func() { + defer wg.Done() + if _, err := ins.GetPageIndex("raw"); err != nil { + errs <- fmt.Errorf("GetPageIndex: %w", err) + } + }() + } + wg.Wait() + close(errs) + for err := range errs { + t.Error(err) + } +} + +func TestValidateUA_PageSubtreeNotWalkedLeavesContentRulesNotEvaluated(t *testing.T) { + chainLen := maxPageTreeDepth + 5 + last := 3 + chainLen + objs := []rawObj{ + {1, "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /Lang (en-US) >>"}, + {2, "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 " + box + " >>"}, + {3, "<< /Type /Page /Parent 2 0 R >>"}, + } + for n := 4; n < last; n++ { + objs = append(objs, rawObj{n, fmt.Sprintf("<< /Type /Pages /Kids [%d 0 R] /Count 1 >>", n+1)}) + } + objs = append(objs, rawObj{last, "<< /Type /Page >>"}) + res, _ := uaRun(t, rawPDF(objs...)) + for _, id := range []string{"marked-content-balance", "mcid-unique", "mcid-in-parent-tree", "mcid-not-nested"} { + if r := uaRuleOf(t, res, id); r.Evaluated { + t.Errorf("rule %s = %+v, want not evaluated", id, r) + } + if msgs := uaMessages(res, id); len(msgs) != 1 || !strings.Contains(msgs[0], "subtree not walked") { + t.Errorf("rule %s messages %q, want the skipped subtree named", id, msgs) + } + } +} diff --git a/internal/pdfcore/validate_structure_walk_test.go b/internal/pdfcore/validate_structure_walk_test.go new file mode 100644 index 00000000..bf8b9d27 --- /dev/null +++ b/internal/pdfcore/validate_structure_walk_test.go @@ -0,0 +1,190 @@ +package pdfcore + +import ( + "context" + "errors" + "slices" + "strings" + "testing" + "time" + + pdfcpu_model "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model" +) + +// The structure walk of a validate run, driven through the walker factory +// that Inspector.validate takes. + +// uaWalkPDF is a two-page tagged document with clean marked content. +func uaWalkPDF() []byte { + return rawPDF( + rawObj{1, "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /Lang (en-US) /StructTreeRoot 5 0 R >>"}, + rawObj{2, "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>"}, + rawObj{3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 20 0 R /StructParents 0 >>"}, + rawObj{4, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 21 0 R /StructParents 1 >>"}, + rawObj{5, "<< /Type /StructTreeRoot /K [10 0 R] /ParentTree 6 0 R /ParentTreeNextKey 2 >>"}, + rawObj{6, "<< /Nums [0 [11 0 R] 1 [12 0 R]] >>"}, + rawObj{10, "<< /Type /StructElem /S /Document /P 5 0 R /K [11 0 R 12 0 R] >>"}, + rawObj{11, "<< /Type /StructElem /S /P /P 10 0 R /Pg 3 0 R /K 0 >>"}, + rawObj{12, "<< /Type /StructElem /S /P /P 10 0 R /Pg 4 0 R /K 0 >>"}, + rawObj{20, rawStream("/P <> BDC EMC")}, + rawObj{21, rawStream("/P <> BDC EMC")}, + ) +} + +// walkerWith returns a walker factory whose walkers run hook before each +// pdfMu acquisition. +func walkerWith(hook func(page int)) func(*pdfcpu_model.Context) *structureWalker { + return func(ctx *pdfcpu_model.Context) *structureWalker { + w := newStructureWalker(ctx) + w.beforeLock = hook + return w + } +} + +// requireLockFree fails when GetPageIndex on tabID does not return promptly, +// which it cannot do while pdfMu is still held. +func requireLockFree(t *testing.T, ins *Inspector, tabID string) { + t.Helper() + done := make(chan error, 1) + go func() { + _, err := ins.GetPageIndex(tabID) + done <- err + }() + select { + case err := <-done: + if err != nil { + t.Errorf("GetPageIndex after the run: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("GetPageIndex blocked after the run; pdfMu was not released") + } +} + +func TestValidateUA_WalkPanicLeavesStructureRulesNotEvaluated(t *testing.T) { + cases := map[string]func(){ + "plain value": func() { panic("walk boom") }, + "runtime error": func() { + var s []int + _ = s[len(s)] + }, + } + for name, boom := range cases { + t.Run(name, func(t *testing.T) { + ins, _ := openUnvalidated(t, uaWalkPDF()) + res, err := ins.validate("raw", ProfilePDFUA1Structural, walkerWith(func(page int) { + if page == 1 { + boom() + } + })) + if err != nil { + t.Fatalf("validate returned %v, want a result with the structure rules degraded", err) + } + for _, id := range uaStructureRuleIDs { + r := uaRuleOf(t, res, id) + if r.Evaluated || r.Findings != 0 { + t.Errorf("rule %s = %+v, want not evaluated", id, r) + } + ps := uaFindings(res, id) + if len(ps) != 1 || ps[0].Severity != "info" || !strings.Contains(ps[0].Message, "structure walk failed") { + t.Errorf("rule %s problems = %+v, want one info problem naming the structure walk", id, ps) + } + } + for _, id := range uaCatalogRuleIDs { + if r := uaRuleOf(t, res, id); !r.Evaluated || r.Findings != 0 { + t.Errorf("catalog rule %s = %+v, want evaluated with 0 findings", id, r) + } + } + requireLockFree(t, ins, "raw") + }) + } +} + +func TestValidateUA_UnreadableCatalogWinsOverWalkPanic(t *testing.T) { + ins, doc := openUnvalidated(t, uaWalkPDF()) + walked := false + res, err := ins.validate("raw", ProfilePDFUA1Structural, walkerWith(func(page int) { + if page == 1 { + walked = true + doc.PDFContext.XRefTable.RootDict = nil + doc.PDFContext.XRefTable.Root = nil + panic("walk boom") + } + })) + if err != nil { + t.Fatalf("validate returned %v, want a result with every rule degraded", err) + } + if !walked { + t.Fatal("walk never reached page 1; the panic was not exercised") + } + if len(res.Rules) != 8 { + t.Fatalf("got %d rules, want 8", len(res.Rules)) + } + for _, r := range res.Rules { + if r.Evaluated || r.Findings != 0 { + t.Errorf("rule %s = %+v, want not evaluated", r.RuleID, r) + } + ps := uaFindings(res, r.RuleID) + if len(ps) != 1 || ps[0].Severity != "info" || !strings.Contains(ps[0].Message, "catalog") || strings.Contains(ps[0].Message, "structure walk failed") { + t.Errorf("rule %s problems = %+v, want one info problem naming the catalog, not the walk", r.RuleID, ps) + } + } + requireLockFree(t, ins, "raw") +} + +func TestValidateUA_CloseDuringWalkCancelsTheRun(t *testing.T) { + ins, _ := openUnvalidated(t, uaWalkPDF()) + res, err := ins.validate("raw", ProfilePDFUA1Structural, walkerWith(func(page int) { + if page == 1 { + if err := ins.Close("raw"); err != nil { + t.Errorf("Close: %v", err) + } + } + })) + if !errors.Is(err, context.Canceled) { + t.Errorf("validate = %+v, %v; want an error satisfying errors.Is(err, context.Canceled)", res, err) + } +} + +func TestValidate_WalkRunsOncePerUARunAndNeverForPDFA(t *testing.T) { + ins, _ := openUnvalidated(t, uaWalkPDF()) + calls := 0 + counting := func(ctx *pdfcpu_model.Context) *structureWalker { + calls++ + return newStructureWalker(ctx) + } + + if _, err := ins.validate("raw", ProfilePDFA1B, counting); err != nil { + t.Fatalf("validate pdfa-1b: %v", err) + } + if calls != 0 { + t.Errorf("a pdfa-1b run built the structure report %d times, want 0", calls) + } + + res, err := ins.validate("raw", ProfilePDFUA1Structural, counting) + if err != nil { + t.Fatalf("validate pdfua-1-structural: %v", err) + } + if calls != 1 { + t.Errorf("a pdfua-1-structural run built the structure report %d times, want 1", calls) + } + uaExpect(t, res, nil) +} + +func TestValidateUA_WalkRunsWithoutPdfMuHeld(t *testing.T) { + ins, doc := openUnvalidated(t, uaWalkPDF()) + var pages []int + _, err := ins.validate("raw", ProfilePDFUA1Structural, walkerWith(func(page int) { + pages = append(pages, page) + if !doc.pdfMu.TryLock() { + t.Errorf("pdfMu held when the walk reached page %d", page) + return + } + doc.pdfMu.Unlock() + })) + if err != nil { + t.Fatalf("validate: %v", err) + } + if !slices.Equal(pages, []int{0, 1, 2}) { + t.Errorf("walk hook ran for %v, want [0 1 2]", pages) + } +} diff --git a/internal/pdfcore/validate_test.go b/internal/pdfcore/validate_test.go index 2499427b..b27eeb1c 100644 --- a/internal/pdfcore/validate_test.go +++ b/internal/pdfcore/validate_test.go @@ -392,23 +392,6 @@ func TestValidate_XMPInfo_GenuineMismatchStillFires(t *testing.T) { } } -// --- ProfileGates: fail-closed gate foundation (review #2) -------------------- - -// ProfileGates is the pdfcore decision the CLI uses to fail closed when a gating -// rule degrades to info. pdfa-1b has error rules (gates); pdfua-1-structural is -// all warnings (never gates); an unknown profile matches nothing. -func TestProfileGates(t *testing.T) { - if !ProfileGates(ProfilePDFA1B) { - t.Errorf("ProfileGates(%q) = false, want true (has error rules)", ProfilePDFA1B) - } - if ProfileGates(ProfilePDFUA1Structural) { - t.Errorf("ProfileGates(%q) = true, want false (all warnings)", ProfilePDFUA1Structural) - } - if ProfileGates("bogus-profile") { - t.Errorf("ProfileGates(unknown) = true, want false") - } -} - // --- Type0 / CIDFont double-report skip (review #1) -------------------------- // A non-embedded Type0 composite font must be reported ONCE (on the Type0 diff --git a/testdata/generate_test.go b/testdata/generate_test.go index bc9b04a5..392418a2 100644 --- a/testdata/generate_test.go +++ b/testdata/generate_test.go @@ -770,6 +770,64 @@ func structureBrokenPDFContent() []byte { }, "") } +// uaStructuralXMP is the XMP packet of the ua-structural fixtures: dc:title +// matching /Info /Title and pdfuaid:part 1. +const uaStructuralXMP = ` + + + + UA structural fixture + 1 + + + +` + +// uaStructuralPDF is a one-page tagged document for the pdfua-1-structural +// content rules with at most one fault. The clean document has the catalog +// entries PDF/UA-1 asks for (/MarkInfo, /Lang, /StructTreeRoot, +// /ViewerPreferences /DisplayDocTitle, an XMP /Metadata with dc:title and +// pdfuaid:part, a matching /Info /Title), a page whose /Contents is the array +// [6 0 R 7 0 R] with MCID 0 in the first stream and MCID 1 in the second, and +// a Document element with two P children, each claiming one MCID through a +// ParentTree slot. fault is one of "", "unbalanced" (an EMC with nothing open +// at the start of the content), "duplicate-mcid" (the second stream marks +// MCID 0 again), "orphan-mcid" (the second stream also marks MCID 2, which has +// no ParentTree slot), "nested-mcid" (MCID 1 opens inside the open MCID 0 +// sequence) and "empty-s" (the second P has /S with the empty name). +func uaStructuralPDF(fault string) []byte { + first := "/P <> BDC 0 0 m 100 100 l S EMC" + second := "/P <> BDC 0 100 m 100 0 l S EMC" + secondS := "/P" + switch fault { + case "unbalanced": + first = "EMC " + first + case "duplicate-mcid": + second = "/P <> BDC 0 100 m 100 0 l S EMC" + case "orphan-mcid": + second += " /P <> BDC 50 0 m 50 100 l S EMC" + case "nested-mcid": + first = "/P <> BDC 0 0 m 100 100 l S /P <> BDC 0 100 m 100 0 l S EMC EMC" + second = "/Artifact BMC 0 50 m 100 50 l S EMC" + case "empty-s": + secondS = "/" + } + return complAssemblePDF([]string{ + "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /Lang (en-US) /StructTreeRoot 5 0 R /ViewerPreferences << /DisplayDocTitle true >> /Metadata 4 0 R >>\nendobj\n", + "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n", + "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents [6 0 R 7 0 R] /StructParents 0 /Resources << >> >>\nendobj\n", + complStreamObj(4, "/Type /Metadata /Subtype /XML ", uaStructuralXMP), + "5 0 obj\n<< /Type /StructTreeRoot /K 10 0 R /ParentTree 8 0 R /ParentTreeNextKey 1 >>\nendobj\n", + complStreamObj(6, "", first), + complStreamObj(7, "", second), + "8 0 obj\n<< /Nums [0 [11 0 R 12 0 R]] >>\nendobj\n", + "9 0 obj\n<< /Title (UA structural fixture) >>\nendobj\n", + "10 0 obj\n<< /Type /StructElem /S /Document /P 5 0 R /K [11 0 R 12 0 R] >>\nendobj\n", + "11 0 obj\n<< /Type /StructElem /S /P /P 10 0 R /Pg 3 0 R /K 0 >>\nendobj\n", + "12 0 obj\n<< /Type /StructElem /S " + secondS + " /P 10 0 R /Pg 3 0 R /K 1 >>\nendobj\n", + }, "/Info 9 0 R ") +} + // pdfaCleanContent builds a minimal PDF/A-1b file that veraPDF --flavour 1b // passes: a PDF/A-identification XMP packet (pdfaid part 1 / conformance B), a // document /ID, no fonts, no device color, and a blank page. Provenance: @@ -977,6 +1035,12 @@ func TestGenerateFixtures(t *testing.T) { {"pdfa-1b-clean.pdf", pdfaCleanContent}, {"structure.pdf", structurePDFContent}, {"structure-broken.pdf", structureBrokenPDFContent}, + {"ua-structural-clean.pdf", func() []byte { return uaStructuralPDF("") }}, + {"ua-structural-unbalanced.pdf", func() []byte { return uaStructuralPDF("unbalanced") }}, + {"ua-structural-duplicate-mcid.pdf", func() []byte { return uaStructuralPDF("duplicate-mcid") }}, + {"ua-structural-orphan-mcid.pdf", func() []byte { return uaStructuralPDF("orphan-mcid") }}, + {"ua-structural-nested-mcid.pdf", func() []byte { return uaStructuralPDF("nested-mcid") }}, + {"ua-structural-empty-s.pdf", func() []byte { return uaStructuralPDF("empty-s") }}, } for _, fx := range complFixtures { t.Run(fx.name, func(t *testing.T) { diff --git a/testdata/ua-structural-clean.pdf b/testdata/ua-structural-clean.pdf new file mode 100644 index 00000000..e3b647b2 Binary files /dev/null and b/testdata/ua-structural-clean.pdf differ diff --git a/testdata/ua-structural-duplicate-mcid.pdf b/testdata/ua-structural-duplicate-mcid.pdf new file mode 100644 index 00000000..0f556bd6 Binary files /dev/null and b/testdata/ua-structural-duplicate-mcid.pdf differ diff --git a/testdata/ua-structural-empty-s.pdf b/testdata/ua-structural-empty-s.pdf new file mode 100644 index 00000000..ed6e89bc Binary files /dev/null and b/testdata/ua-structural-empty-s.pdf differ diff --git a/testdata/ua-structural-nested-mcid.pdf b/testdata/ua-structural-nested-mcid.pdf new file mode 100644 index 00000000..ddb07cc9 Binary files /dev/null and b/testdata/ua-structural-nested-mcid.pdf differ diff --git a/testdata/ua-structural-orphan-mcid.pdf b/testdata/ua-structural-orphan-mcid.pdf new file mode 100644 index 00000000..ce8650f9 Binary files /dev/null and b/testdata/ua-structural-orphan-mcid.pdf differ diff --git a/testdata/ua-structural-unbalanced.pdf b/testdata/ua-structural-unbalanced.pdf new file mode 100644 index 00000000..9781bf81 Binary files /dev/null and b/testdata/ua-structural-unbalanced.pdf differ diff --git a/testdata/verapdf-ua1/ua-structural-clean.json b/testdata/verapdf-ua1/ua-structural-clean.json new file mode 100644 index 00000000..ef73b4c9 --- /dev/null +++ b/testdata/verapdf-ua1/ua-structural-clean.json @@ -0,0 +1,7 @@ +{ + "veraPDFVersion": "1.30.2", + "flavour": "ua1", + "compliant": true, + "failedRules": [], + "logs": [] +} diff --git a/testdata/verapdf-ua1/ua-structural-duplicate-mcid.json b/testdata/verapdf-ua1/ua-structural-duplicate-mcid.json new file mode 100644 index 00000000..95d64536 --- /dev/null +++ b/testdata/verapdf-ua1/ua-structural-duplicate-mcid.json @@ -0,0 +1,13 @@ +{ + "veraPDFVersion": "1.30.2", + "flavour": "ua1", + "compliant": true, + "failedRules": [], + "logs": [ + { + "level": "WARNING", + "message": "Content stream (object 3 0 obj): Duplicate MCID - 0", + "occurrences": 1 + } + ] +} diff --git a/testdata/verapdf-ua1/ua-structural-empty-s.json b/testdata/verapdf-ua1/ua-structural-empty-s.json new file mode 100644 index 00000000..c0ae5138 --- /dev/null +++ b/testdata/verapdf-ua1/ua-structural-empty-s.json @@ -0,0 +1,13 @@ +{ + "veraPDFVersion": "1.30.2", + "flavour": "ua1", + "compliant": false, + "failedRules": [ + { + "clause": "7.1", + "testNumber": 5, + "failedChecks": 1 + } + ], + "logs": [] +} diff --git a/testdata/verapdf-ua1/ua-structural-nested-mcid.json b/testdata/verapdf-ua1/ua-structural-nested-mcid.json new file mode 100644 index 00000000..d701c3e0 --- /dev/null +++ b/testdata/verapdf-ua1/ua-structural-nested-mcid.json @@ -0,0 +1,13 @@ +{ + "veraPDFVersion": "1.30.2", + "flavour": "ua1", + "compliant": true, + "failedRules": [], + "logs": [ + { + "level": "WARNING", + "message": "Content stream (object 3 0 obj): Nested MCID - 1", + "occurrences": 1 + } + ] +} diff --git a/testdata/verapdf-ua1/ua-structural-orphan-mcid.json b/testdata/verapdf-ua1/ua-structural-orphan-mcid.json new file mode 100644 index 00000000..e758e7ad --- /dev/null +++ b/testdata/verapdf-ua1/ua-structural-orphan-mcid.json @@ -0,0 +1,13 @@ +{ + "veraPDFVersion": "1.30.2", + "flavour": "ua1", + "compliant": false, + "failedRules": [ + { + "clause": "7.1", + "testNumber": 3, + "failedChecks": 1 + } + ], + "logs": [] +} diff --git a/testdata/verapdf-ua1/ua-structural-unbalanced.json b/testdata/verapdf-ua1/ua-structural-unbalanced.json new file mode 100644 index 00000000..41bb1dfd --- /dev/null +++ b/testdata/verapdf-ua1/ua-structural-unbalanced.json @@ -0,0 +1,13 @@ +{ + "veraPDFVersion": "1.30.2", + "flavour": "ua1", + "compliant": true, + "failedRules": [], + "logs": [ + { + "level": "WARNING", + "message": "Content stream (object 3 0 obj): EMC operator does not have a balancing BMC/BDC operator", + "occurrences": 1 + } + ] +} diff --git a/tests/compliance-validation/helpers_test.go b/tests/compliance-validation/helpers_test.go index c94a62cd..b1025439 100644 --- a/tests/compliance-validation/helpers_test.go +++ b/tests/compliance-validation/helpers_test.go @@ -371,9 +371,9 @@ func nonEmbeddedFontPDF() []byte { }) } -// untaggedPDF builds a one-page PDF with NO /MarkInfo, NO /StructTreeRoot, and -// NO /Lang -- the PDF/UA-1 structural warnings (all non-gating). The missing -// /Lang is the story's canonical document-level (no object ref) problem. +// untaggedPDF builds a one-page PDF with NO /MarkInfo, NO /StructTreeRoot, NO +// /Lang and no content -- the three PDF/UA-1 catalog warnings, which do not +// gate. The missing /Lang is a document-level (no object ref) problem. func untaggedPDF() []byte { return assemblePDF([]string{ "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n", diff --git a/tests/compliance-validation/validate_cli_test.go b/tests/compliance-validation/validate_cli_test.go index 192b510e..63685008 100644 --- a/tests/compliance-validation/validate_cli_test.go +++ b/tests/compliance-validation/validate_cli_test.go @@ -279,19 +279,19 @@ func TestValidate_UnknownProfileExitsTwo(t *testing.T) { } // --------------------------------------------------------------------------- -// The PDF/UA-1 structural rules are WARNINGS (non-gating). An untagged doc -// under pdfua-1-structural yields warnings but ZERO errors -> exit 0. The -// missing /Lang is a document-level problem (empty objRef) -- the story's -// canonical "Document" group member. +// The PDF/UA-1 catalog rules are WARNINGS and do not gate. An untagged doc +// with no marked content under pdfua-1-structural yields only those warnings +// and ZERO errors -> exit 0. The missing /Lang is a document-level problem +// (empty objRef). // --------------------------------------------------------------------------- -func TestValidate_PDFUAWarningsDoNotGate(t *testing.T) { +func TestValidate_PDFUACatalogWarningsDoNotGate(t *testing.T) { bin := buildCLI(t) pdf := writeTempPDF(t, "untagged.pdf", untaggedPDF()) stdout, stderr, ec := runCLI(t, bin, "validate", "--profile", "pdfua-1-structural", "--json", pdf) if ec != 0 { - t.Fatalf("PDF/UA warnings must NOT gate; expected exit 0, got %d\nstdout: %s\nstderr: %s", ec, stdout, stderr) + t.Fatalf("PDF/UA catalog warnings must NOT gate; expected exit 0, got %d\nstdout: %s\nstderr: %s", ec, stdout, stderr) } res := validateResult(t, stdout) if got := getStr(res, "profile"); got != "pdfua-1-structural" { @@ -300,12 +300,12 @@ func TestValidate_PDFUAWarningsDoNotGate(t *testing.T) { ps := problemsOf(t, res) errs, warns := summaryOf(t, res) if errs != 0 { - t.Errorf("PDF/UA structural profile must emit zero errors, got %d", errs) + t.Errorf("an untagged doc with no marked content must emit zero PDF/UA errors, got %d", errs) } if warns < 1 { t.Errorf("untagged doc should raise >=1 PDF/UA warning, got %d", warns) } - // Every emitted PDF/UA problem must be a warning and carry the profile tag. + // Every problem on this doc is a catalog warning carrying the profile tag. for _, p := range ps { if getStr(p, "severity") != "warning" { t.Errorf("PDF/UA problem severity = %q, want warning: %v", getStr(p, "severity"), p) diff --git a/tests/compliance-validation/verapdf_ua1_golden_test.go b/tests/compliance-validation/verapdf_ua1_golden_test.go new file mode 100644 index 00000000..1d690c00 --- /dev/null +++ b/tests/compliance-validation/verapdf_ua1_golden_test.go @@ -0,0 +1,190 @@ +package compliance_validation_test + +// veraPDF PDF/UA-1 goldens of the ua-structural-*.pdf fixtures. +// +// Opt-in: the test re-runs veraPDF --flavour ua1 --addlogs on each fixture and +// compares the reduced report with the committed golden in +// testdata/verapdf-ua1/: in full when the installed veraPDF is the golden's +// version, else only the verdict and the failed rules. With PDFDEBUG_WRITE_VERAPDF_GOLDENS=1 it rewrites +// the goldens instead. It skips when veraPDF is absent, so CI never needs it; +// the tests/ua-structural-rules suite reads the goldens without veraPDF. + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" +) + +// uaStructuralFixtures are the fixtures with a committed veraPDF ua1 golden. +var uaStructuralFixtures = []string{ + "ua-structural-clean.pdf", + "ua-structural-unbalanced.pdf", + "ua-structural-duplicate-mcid.pdf", + "ua-structural-orphan-mcid.pdf", + "ua-structural-nested-mcid.pdf", + "ua-structural-empty-s.pdf", +} + +// veraUAFailedRule is one failed ua1 rule of a reduced report. +type veraUAFailedRule struct { + Clause string `json:"clause"` + TestNumber int `json:"testNumber"` + FailedChecks int `json:"failedChecks"` +} + +// veraUALog is one job log message of a reduced report. +type veraUALog struct { + Level string `json:"level"` + Message string `json:"message"` + Occurrences int `json:"occurrences"` +} + +// veraUAGolden is the reduced veraPDF report committed per fixture. +type veraUAGolden struct { + VeraPDFVersion string `json:"veraPDFVersion"` + Flavour string `json:"flavour"` + Compliant bool `json:"compliant"` + FailedRules []veraUAFailedRule `json:"failedRules"` + Logs []veraUALog `json:"logs"` +} + +// veraUAReport runs veraPDF --flavour ua1 --format json --addlogs on path and +// reduces its report to the golden shape: failed rules sorted by clause and +// test number, log messages sorted by level and message. +func veraUAReport(t *testing.T, veraBin, path string) veraUAGolden { + t.Helper() + out, _ := exec.Command(veraBin, "--flavour", "ua1", "--format", "json", "--addlogs", path).Output() + var doc struct { + Report struct { + BuildInformation struct { + ReleaseDetails []struct { + ID string `json:"id"` + Version string `json:"version"` + } `json:"releaseDetails"` + } `json:"buildInformation"` + Jobs []struct { + ValidationResult []struct { + Compliant bool `json:"compliant"` + Details struct { + RuleSummaries []struct { + Clause string `json:"clause"` + TestNumber int `json:"testNumber"` + FailedChecks int `json:"failedChecks"` + } `json:"ruleSummaries"` + } `json:"details"` + } `json:"validationResult"` + Logs struct { + Logs []veraUALog `json:"logs"` + } `json:"logs"` + } `json:"jobs"` + } `json:"report"` + } + if err := json.Unmarshal(out, &doc); err != nil { + t.Fatalf("veraPDF JSON parse failed for %s (flag drift? re-check `verapdf --help`): %v\nraw: %s", path, err, out) + } + if len(doc.Report.Jobs) == 0 || len(doc.Report.Jobs[0].ValidationResult) == 0 { + t.Fatalf("veraPDF produced no validationResult for %s:\n%s", path, out) + } + job := doc.Report.Jobs[0] + vr := job.ValidationResult[0] + g := veraUAGolden{Flavour: "ua1", Compliant: vr.Compliant, FailedRules: []veraUAFailedRule{}, Logs: []veraUALog{}} + for _, rd := range doc.Report.BuildInformation.ReleaseDetails { + if rd.ID == "core" { + g.VeraPDFVersion = rd.Version + } + } + for _, r := range vr.Details.RuleSummaries { + g.FailedRules = append(g.FailedRules, veraUAFailedRule(r)) + } + sort.Slice(g.FailedRules, func(i, j int) bool { + a, b := g.FailedRules[i], g.FailedRules[j] + if a.Clause != b.Clause { + return a.Clause < b.Clause + } + return a.TestNumber < b.TestNumber + }) + g.Logs = append(g.Logs, job.Logs.Logs...) + sort.Slice(g.Logs, func(i, j int) bool { + a, b := g.Logs[i], g.Logs[j] + if a.Level != b.Level { + return a.Level < b.Level + } + return a.Message < b.Message + }) + return g +} + +// asciiJSON marshals v indented with every non-ASCII rune escaped as \uXXXX. +func asciiJSON(t *testing.T, v any) []byte { + t.Helper() + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + t.Fatalf("marshal golden: %v", err) + } + var b strings.Builder + for _, r := range string(data) { + switch { + case r < 0x80: + b.WriteRune(r) + case r > 0xffff: + r -= 0x10000 + fmt.Fprintf(&b, `\u%04x\u%04x`, 0xd800+(r>>10), 0xdc00+(r&0x3ff)) + default: + fmt.Fprintf(&b, `\u%04x`, r) + } + } + b.WriteString("\n") + return []byte(b.String()) +} + +func TestVeraPDFUA1Goldens_MatchVeraPDF(t *testing.T) { + veraBin := veraPDFPath() + if veraBin == "" { + t.Skip("veraPDF not available (set VERAPDF=/path or install at the default path); golden comparison skipped") + } + write := os.Getenv("PDFDEBUG_WRITE_VERAPDF_GOLDENS") == "1" + goldenDir := filepath.Join(testdataDir(t), "verapdf-ua1") + for _, name := range uaStructuralFixtures { + t.Run(name, func(t *testing.T) { + report := veraUAReport(t, veraBin, filepath.Join(testdataDir(t), name)) + got := asciiJSON(t, report) + goldenPath := filepath.Join(goldenDir, strings.TrimSuffix(name, ".pdf")+".json") + if write { + if err := os.MkdirAll(goldenDir, 0o755); err != nil { + t.Fatalf("create %s: %v", goldenDir, err) + } + if err := os.WriteFile(goldenPath, got, 0o644); err != nil { + t.Fatalf("write %s: %v", goldenPath, err) + } + return + } + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("golden %s missing (run with PDFDEBUG_WRITE_VERAPDF_GOLDENS=1 to capture it): %v", goldenPath, err) + } + var g veraUAGolden + if err := json.Unmarshal(want, &g); err != nil { + t.Fatalf("golden %s: %v", goldenPath, err) + } + if g.VeraPDFVersion != report.VeraPDFVersion { + // Log text differs between releases; the verdict and the failed + // rules are what the rule mapping depends on. + if report.Compliant != g.Compliant || !reflect.DeepEqual(report.FailedRules, g.FailedRules) { + t.Errorf("veraPDF %s ua1 verdict on %s differs from the %s golden\ngot: compliant=%v %+v\nwant: compliant=%v %+v", + report.VeraPDFVersion, name, g.VeraPDFVersion, report.Compliant, report.FailedRules, g.Compliant, g.FailedRules) + } + return + } + if !bytes.Equal(got, want) { + t.Errorf("veraPDF ua1 report on %s differs from its golden\ngot:\n%s\nwant:\n%s", name, got, want) + } + }) + } +} diff --git a/tests/naming-and-comment-hygiene/testdata/unclaimed-citation-shapes.txt b/tests/naming-and-comment-hygiene/testdata/unclaimed-citation-shapes.txt index b5a8e799..1c19814f 100644 --- a/tests/naming-and-comment-hygiene/testdata/unclaimed-citation-shapes.txt +++ b/tests/naming-and-comment-hygiene/testdata/unclaimed-citation-shapes.txt @@ -15,8 +15,9 @@ N-N JSON # Text that only resembles a citation: a licence version, a font bit range, a -# tool version, a count in prose, a coordinate, and line:column positions in -# assertions. +# tool version, a count in prose, a coordinate, line:column positions in +# assertions, and a standard's clause followed by the next standard in a rule's +# spec reference ("ISO 14289-1:2014, 7.1; ISO 32000-1:2008, ..."). N.N LICENSE N-N MUST N.N (ESL @@ -25,3 +26,4 @@ N/N, XMPC N:N NOT N:N (NOT N:N, NOT +N.N; ISO diff --git a/tests/open-source-docs/cli_usage_layout_test.go b/tests/open-source-docs/cli_usage_layout_test.go index db9b7d43..c41bccb5 100644 --- a/tests/open-source-docs/cli_usage_layout_test.go +++ b/tests/open-source-docs/cli_usage_layout_test.go @@ -168,7 +168,7 @@ func TestCLIUsageGuideCIExampleFailsOnFindingsAndOperationalErrors(t *testing.T) t.Fatalf("%s CI example does not use the file.pdf placeholder:\n%s", where, snippet) } if !strings.Contains(snippet, "pdfua-1-structural") { - t.Fatalf("%s CI example does not run the pdfua-1-structural profile, whose findings are warnings with exit 0:\n%s", + t.Fatalf("%s CI example does not run the pdfua-1-structural profile, whose structure rules can fail the run:\n%s", where, snippet) } diff --git a/tests/ua-structural-rules/exit_codes_test.go b/tests/ua-structural-rules/exit_codes_test.go new file mode 100644 index 00000000..5314b969 --- /dev/null +++ b/tests/ua-structural-rules/exit_codes_test.go @@ -0,0 +1,86 @@ +package ua_structural_rules_test + +import ( + "strings" + "testing" +) + +func TestValidateUA_ExitCodesOnFixtures(t *testing.T) { + cases := []struct { + file string + want int + }{ + {"ua-structural-clean.pdf", 0}, + {"untagged.pdf", 0}, + {"ua-structural-empty-s.pdf", 1}, + {"ua-structural-unbalanced.pdf", 0}, + {"ua-structural-duplicate-mcid.pdf", 0}, + {"ua-structural-orphan-mcid.pdf", 1}, + {"ua-structural-nested-mcid.pdf", 0}, + } + for _, tc := range cases { + t.Run(tc.file, func(t *testing.T) { + for _, format := range [][]string{nil, {"--json"}} { + args := append([]string{"validate", "--profile", uaProfile}, format...) + _, stderr, code := runCLI(t, append(args, fixture(t, tc.file))...) + if code != tc.want { + t.Errorf("validate %v %s exit = %d, want %d\nstderr: %s", format, tc.file, code, tc.want, stderr) + } + } + }) + } +} + +func TestValidateUA_BrokenStructureFixtureExitsOne(t *testing.T) { + r, code := validateJSON(t, fixture(t, "structure-broken.pdf")) + if code != 1 { + t.Errorf("exit = %d, want 1 for a file with an MCID that has no ParentTree slot", code) + } + for _, id := range []string{"marked-content-balance", "mcid-unique", "mcid-in-parent-tree"} { + if ri := ruleByID(t, r, id); !ri.Evaluated || ri.Findings == 0 { + t.Errorf("rule %s on structure-broken.pdf = %+v, want evaluated with findings", id, ri) + } + } +} + +func TestValidateUA_EncryptedFileIsOperationalError(t *testing.T) { + _, _, code := runCLI(t, "validate", "--profile", uaProfile, fixture(t, "encrypted.pdf")) + if code != 2 { + t.Errorf("validate --profile %s encrypted.pdf exit = %d, want 2", uaProfile, code) + } +} + +func TestValidatePDFA_ExitCodes(t *testing.T) { + cases := []struct { + file string + want int + }{ + {"pdfa-1b-clean.pdf", 0}, + {"no-output-intent.pdf", 1}, + {"non-embedded-font.pdf", 1}, + {"encrypted.pdf", 1}, + {"ua-structural-duplicate-mcid.pdf", 1}, + } + for _, tc := range cases { + t.Run(tc.file, func(t *testing.T) { + stdout, stderr, code := runCLI(t, "validate", "--profile", "pdfa-1b", fixture(t, tc.file)) + if code != tc.want { + t.Errorf("validate --profile pdfa-1b %s exit = %d, want %d\nstdout: %s\nstderr: %s", tc.file, code, tc.want, stdout, stderr) + } + for _, id := range structureRuleIDs { + if strings.Contains(stdout, id) { + t.Errorf("a pdfa-1b run names the PDF/UA rule %s:\n%s", id, stdout) + } + } + }) + } +} + +func TestValidateUA_ExitLineOnlyWithoutErrors(t *testing.T) { + for _, file := range []string{"ua-structural-unbalanced.pdf", "ua-structural-clean.pdf", "ua-structural-empty-s.pdf"} { + stdout, _, _ := runCLI(t, "validate", "--profile", uaProfile, fixture(t, file)) + if strings.Contains(stdout, "Exit status 1: a rule that gates the exit code could not be evaluated") { + t.Errorf("%s: the not-evaluated exit line printed on a run where every rule was evaluated:\n%s", file, stdout) + } + } +} diff --git a/tests/ua-structural-rules/go.mod b/tests/ua-structural-rules/go.mod new file mode 100644 index 00000000..8bf19cf9 --- /dev/null +++ b/tests/ua-structural-rules/go.mod @@ -0,0 +1,3 @@ +module ua-structural-rules-test + +go 1.25 diff --git a/tests/ua-structural-rules/golden_mapping_test.go b/tests/ua-structural-rules/golden_mapping_test.go new file mode 100644 index 00000000..df21a659 --- /dev/null +++ b/tests/ua-structural-rules/golden_mapping_test.go @@ -0,0 +1,154 @@ +package ua_structural_rules_test + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" +) + +// veraFailedRule is one failed ua1 rule of a reduced veraPDF golden. +type veraFailedRule struct { + Clause string `json:"clause"` + TestNumber int `json:"testNumber"` + FailedChecks int `json:"failedChecks"` +} + +// veraLog is one job log message of a reduced veraPDF golden. +type veraLog struct { + Level string `json:"level"` + Message string `json:"message"` + Occurrences int `json:"occurrences"` +} + +// veraGolden is a committed testdata/verapdf-ua1/.json file. +type veraGolden struct { + VeraPDFVersion string `json:"veraPDFVersion"` + Flavour string `json:"flavour"` + Compliant bool `json:"compliant"` + FailedRules []veraFailedRule `json:"failedRules"` + Logs []veraLog `json:"logs"` +} + +// readGolden reads and decodes the golden of a fixture, rejecting unknown keys. +func readGolden(t *testing.T, file string) veraGolden { + t.Helper() + name := strings.TrimSuffix(file, ".pdf") + ".json" + path := filepath.Join(projectRoot(t), "testdata", "verapdf-ua1", name) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("veraPDF golden %s missing: %v", name, err) + } + for i, c := range data { + if c > 0x7f { + t.Fatalf("golden %s has a non-ASCII byte at offset %d", name, i) + } + } + dec := json.NewDecoder(strings.NewReader(string(data))) + dec.DisallowUnknownFields() + var g veraGolden + if err := dec.Decode(&g); err != nil { + t.Fatalf("golden %s does not decode: %v", name, err) + } + if g.VeraPDFVersion == "" || g.Flavour != "ua1" { + t.Errorf("golden %s version %q flavour %q, want a version and flavour ua1", name, g.VeraPDFVersion, g.Flavour) + } + if !slices.IsSortedFunc(g.FailedRules, func(a, b veraFailedRule) int { + if c := strings.Compare(a.Clause, b.Clause); c != 0 { + return c + } + return a.TestNumber - b.TestNumber + }) { + t.Errorf("golden %s failedRules are not sorted by clause and test number", name) + } + if !slices.IsSortedFunc(g.Logs, func(a, b veraLog) int { + if c := strings.Compare(a.Level, b.Level); c != 0 { + return c + } + return strings.Compare(a.Message, b.Message) + }) { + t.Errorf("golden %s logs are not sorted by level and message", name) + } + for _, l := range g.Logs { + if l.Occurrences < 1 { + t.Errorf("golden %s log %q has occurrences %d", name, l.Message, l.Occurrences) + } + } + return g +} + +// signals returns the veraPDF signals of a golden as "clause-testNumber" for +// failed rules and "LEVEL: message" for log lines. +func signals(g veraGolden) []string { + var out []string + for _, r := range g.FailedRules { + out = append(out, r.Clause+"-"+strconv.Itoa(r.TestNumber)) + } + for _, l := range g.Logs { + out = append(out, l.Level+": "+l.Message) + } + return out +} + +// noSignal marks a rule that veraPDF does not flag on its fixture. +const noSignal = "" + +// unrecorded marks a fixture whose veraPDF signal has not been captured yet. +const unrecorded = "unrecorded" + +// veraSignalForFixture is the veraPDF ua1 signal each fault fixture adds beyond +// the clean fixture's golden: a substring that every added signal must +// contain, or noSignal when the fault adds none. It is updated when the +// goldens are captured again. +var veraSignalForFixture = map[string]string{ + "ua-structural-unbalanced.pdf": "EMC operator does not have a balancing BMC/BDC operator", + "ua-structural-duplicate-mcid.pdf": "Duplicate MCID - 0", + "ua-structural-orphan-mcid.pdf": "7.1-3", + "ua-structural-nested-mcid.pdf": "Nested MCID - 1", + "ua-structural-empty-s.pdf": "7.1-5", +} + +func TestVeraPDFGoldens_FaultSignalsMapToTheirRule(t *testing.T) { + clean := readGolden(t, "ua-structural-clean.pdf") + base := signals(clean) + + cleanRes, _ := validateJSON(t, fixture(t, "ua-structural-clean.pdf")) + for _, id := range structureRuleIDs { + if ri := ruleByID(t, cleanRes, id); !ri.Evaluated || ri.Findings != 0 { + t.Errorf("clean fixture: rule %s = %+v, want evaluated with 0 findings", id, ri) + } + } + + for _, tc := range faultCases { + t.Run(tc.file, func(t *testing.T) { + g := readGolden(t, tc.file) + var added []string + for _, s := range signals(g) { + if !slices.Contains(base, s) { + added = append(added, s) + } + } + want, ok := veraSignalForFixture[tc.file] + switch { + case !ok || want == unrecorded: + t.Errorf("no recorded veraPDF signal for %s; its golden adds %q over the clean golden", tc.file, added) + case want == noSignal: + if len(added) != 0 { + t.Errorf("%s is recorded as having no veraPDF signal, but its golden adds %q", tc.file, added) + } + default: + if len(added) == 0 || slices.ContainsFunc(added, func(s string) bool { return !strings.Contains(s, want) }) { + t.Errorf("%s golden adds %q over the clean golden, want only signals containing %q", tc.file, added, want) + } + } + + r, _ := validateJSON(t, fixture(t, tc.file)) + if ri := ruleByID(t, r, tc.rule); !ri.Evaluated || ri.Findings == 0 { + t.Errorf("rule %s = %+v on %s, want it to fire", tc.rule, ri, tc.file) + } + }) + } +} diff --git a/tests/ua-structural-rules/helpers_test.go b/tests/ua-structural-rules/helpers_test.go new file mode 100644 index 00000000..3a1315ba --- /dev/null +++ b/tests/ua-structural-rules/helpers_test.go @@ -0,0 +1,395 @@ +// Acceptance harness for the PDF/UA-1 structural content rules of +// `pdfdebug validate --profile pdfua-1-structural`. +// +// Black-box: the CLI is built once and run as a subprocess over the committed +// ua-structural-*.pdf fixtures in testdata/ and over documents written by the +// test. Covers exit codes, the typed --json shape with per-rule IDs, untruncated +// output and the mapping of each fixture to its committed veraPDF golden. Rule +// edge cases live in the pdfcore unit tests. +// +// Run: cd tests/ua-structural-rules && go test -count=1 ./... +package ua_structural_rules_test + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +const uaProfile = "pdfua-1-structural" + +// uaRuleIDs is every pdfua-1-structural rule in registry order. +var uaRuleIDs = []string{ + "marked", "struct-tree-root", "lang", + "marked-content-balance", "mcid-unique", "mcid-in-parent-tree", "mcid-not-nested", "struct-elem-type", +} + +// contentRuleIDs are the structural content rules, each one gating the exit code. +var contentRuleIDs = []string{"marked-content-balance", "mcid-unique", "mcid-in-parent-tree", "mcid-not-nested"} + +// structureRuleIDs are the rules that read the structure report. +var structureRuleIDs = append(append([]string{}, contentRuleIDs...), "struct-elem-type") + +// structureRuleSpec is the severity and spec reference of each structure rule. +var structureRuleSpec = map[string]struct{ severity, specRef string }{ + "marked-content-balance": {"warning", "ISO 14289-1:2014, 7.1; ISO 32000-1:2008, 14.6.1"}, + "mcid-unique": {"warning", "ISO 14289-1:2014, 7.1; ISO 32000-1:2008, 14.7.4.2"}, + "mcid-in-parent-tree": {"error", "ISO 14289-1:2014, 7.1; ISO 32000-1:2008, 14.7.4.4"}, + "mcid-not-nested": {"warning", "ISO 14289-1:2014, 7.1; ISO 32000-1:2008, 14.7.4.1"}, + "struct-elem-type": {"error", "ISO 14289-1:2014, 7.1; ISO 32000-1:2008, 14.7.2"}, +} + +// projectRoot walks up from the test directory to the main module's go.mod. +func projectRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + for { + if content, err := os.ReadFile(filepath.Join(dir, "go.mod")); err == nil { + if strings.Contains(string(content), "module unidoc-pdf-debugger") { + return dir + } + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("could not find project root (no go.mod with module unidoc-pdf-debugger found)") + } + dir = parent + } +} + +// fixture returns the absolute path of a file in testdata/, failing when it +// is missing. +func fixture(t *testing.T, name string) string { + t.Helper() + p := filepath.Join(projectRoot(t), "testdata", name) + if _, err := os.Stat(p); err != nil { + t.Fatalf("fixture %s missing: %v", name, err) + } + return p +} + +var ( + cliBuildOnce sync.Once + cliBinPath string + cliBuildErr string +) + +// buildCLI compiles the CLI once per test package and returns the binary path. +func buildCLI(t *testing.T) string { + t.Helper() + cliBuildOnce.Do(func() { + binName := "pdfdebug" + if runtime.GOOS == "windows" { + binName += ".exe" + } + tmpDir, err := os.MkdirTemp("", "pdfdebug-ua-structural-") + if err != nil { + cliBuildErr = "failed to create temp dir: " + err.Error() + return + } + binPath := filepath.Join(tmpDir, binName) + cmd := exec.Command("go", "build", "-o", binPath, "./cmd/cli/") + cmd.Dir = projectRoot(t) + if out, err := cmd.CombinedOutput(); err != nil { + cliBuildErr = "failed to build CLI binary: " + err.Error() + "\n" + string(out) + return + } + cliBinPath = binPath + }) + if cliBuildErr != "" { + t.Fatalf("%s", cliBuildErr) + } + return cliBinPath +} + +// runCLI runs the CLI with args and returns stdout, stderr and the exit code. +func runCLI(t *testing.T, args ...string) (stdout, stderr string, exitCode int) { + t.Helper() + cmd := exec.Command(buildCLI(t), args...) + var outBuf, errBuf strings.Builder + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + exitErr, ok := err.(*exec.ExitError) + if !ok { + t.Fatalf("failed to run CLI: %v", err) + } + exitCode = exitErr.ExitCode() + } + return outBuf.String(), errBuf.String(), exitCode +} + +// problem is one entry of the validate --json problems list. +type problem struct { + RuleID string `json:"ruleId"` + Profile string `json:"profile"` + Severity string `json:"severity"` + Message string `json:"message"` + ObjRef string `json:"objRef"` + ObjNodeID string `json:"objNodeId"` + SpecRef string `json:"specRef"` +} + +// ruleInfo is one entry of the validate --json rules list. +type ruleInfo struct { + RuleID string `json:"ruleId"` + SpecRef string `json:"specRef"` + Severity string `json:"severity"` + Checks string `json:"checks"` + Evaluated bool `json:"evaluated"` + Findings int `json:"findings"` +} + +// summary is the validate --json tally. +type summary struct { + Errors int `json:"errors"` + Warnings int `json:"warnings"` + Info int `json:"info"` +} + +// validationResult is the validate --json object. +type validationResult struct { + Profile string `json:"profile"` + Summary summary `json:"summary"` + Problems []problem `json:"problems"` + Disclaimer string `json:"disclaimer"` + Scope string `json:"scope"` + Rules []ruleInfo `json:"rules"` +} + +// validateJSON runs `validate --profile pdfua-1-structural --json` on path and +// returns the parsed result and the exit code. +func validateJSON(t *testing.T, path string) (validationResult, int) { + t.Helper() + stdout, stderr, code := runCLI(t, "validate", "--profile", uaProfile, "--json", path) + if code != 0 && code != 1 { + t.Fatalf("validate --json %s exit = %d, want 0 or 1\nstderr: %s", filepath.Base(path), code, stderr) + } + var r validationResult + if err := json.Unmarshal([]byte(stdout), &r); err != nil { + t.Fatalf("--json output is not a validation result: %v\n%s", err, stdout) + } + return r, code +} + +// ruleByID returns the named rule of a result, failing when it is absent. +func ruleByID(t *testing.T, r validationResult, id string) ruleInfo { + t.Helper() + for _, ri := range r.Rules { + if ri.RuleID == id { + return ri + } + } + t.Fatalf("rule %q missing from rules %+v", id, r.Rules) + return ruleInfo{} +} + +// problemsOf returns the problems a rule emitted, in output order. +func problemsOf(r validationResult, id string) []problem { + var out []problem + for _, p := range r.Problems { + if p.RuleID == id { + out = append(out, p) + } + } + return out +} + +// nodeIDOf returns the obj:{gen}:{num} tree-node id of an "N G R" reference. +func nodeIDOf(t *testing.T, ref string) string { + t.Helper() + var num, gen int + if _, err := fmt.Sscanf(ref, "%d %d R", &num, &gen); err != nil { + t.Fatalf("reference %q is not \"N G R\": %v", ref, err) + } + return fmt.Sprintf("obj:%d:%d", gen, num) +} + +// structStream is one content stream row of a dump structure page. +type structStream struct { + Ref string `json:"ref"` +} + +// structMCID is one content MCID row of a dump structure page. +type structMCID struct { + MCID int `json:"mcid"` + Stream int `json:"stream"` + Line int `json:"line"` + Col int `json:"col"` +} + +// structPage is the subset of a dump structure page this suite reads. +type structPage struct { + PageNum int `json:"pageNum"` + Ref string `json:"ref"` + Streams []structStream `json:"streams"` + ContentMCIDs []structMCID `json:"contentMcids"` +} + +// structElem is the subset of a dump structure element this suite reads. +type structElem struct { + Ref string `json:"ref"` + S string `json:"s"` + Kids []*structKid `json:"kids"` +} + +// structKid is one /K entry of a dump structure element. +type structKid struct { + Kind string `json:"kind"` + Element *structElem `json:"element"` +} + +// structureReport is the subset of `dump structure --json` this suite reads to +// locate pages, streams, MCIDs and elements independently of validate. +type structureReport struct { + Elements []*structKid `json:"elements"` + Pages []*structPage `json:"pages"` +} + +// dumpStructure runs `dump structure --json` on path and requires exit 0. +func dumpStructure(t *testing.T, path string) structureReport { + t.Helper() + stdout, stderr, code := runCLI(t, "dump", "structure", "--json", path) + if code != 0 { + t.Fatalf("dump structure --json %s exit = %d, want 0\nstderr: %s", filepath.Base(path), code, stderr) + } + var r structureReport + if err := json.Unmarshal([]byte(stdout), &r); err != nil { + t.Fatalf("dump structure --json output is not a structure report: %v\n%s", err, stdout) + } + return r +} + +// firstPage returns page 1 of a structure report. +func firstPage(t *testing.T, r structureReport) *structPage { + t.Helper() + for _, p := range r.Pages { + if p.PageNum == 1 { + return p + } + } + t.Fatalf("page 1 missing from the structure report") + return nil +} + +// occurrences returns "stream R line L col C" for every content row of mcid +// on page p, in content order. +func occurrences(t *testing.T, p *structPage, mcid int) []string { + t.Helper() + var out []string + for _, c := range p.ContentMCIDs { + if c.MCID != mcid { + continue + } + if c.Stream < 0 || c.Stream >= len(p.Streams) { + t.Fatalf("MCID %d row names stream %d of %d", mcid, c.Stream, len(p.Streams)) + } + out = append(out, fmt.Sprintf("stream %s line %d col %d", p.Streams[c.Stream].Ref, c.Line, c.Col)) + } + return out +} + +// eachElement calls fn on every element of kids, depth first. +func eachElement(kids []*structKid, fn func(e *structElem)) { + for _, k := range kids { + if k.Element != nil { + fn(k.Element) + eachElement(k.Element.Kids, fn) + } + } +} + +// pdfDoc assembles a classic-xref PDF from object bodies; object n is +// objs[n-1] and the catalog is object 1. +func pdfDoc(objs []string) []byte { + var b strings.Builder + b.WriteString("%PDF-1.7\n") + offsets := make([]int, len(objs)) + for i, body := range objs { + offsets[i] = b.Len() + fmt.Fprintf(&b, "%d 0 obj\n%s\nendobj\n", i+1, body) + } + xref := b.Len() + fmt.Fprintf(&b, "xref\n0 %d\n0000000000 65535 f \n", len(objs)+1) + for _, off := range offsets { + fmt.Fprintf(&b, "%010d 00000 n \n", off) + } + fmt.Fprintf(&b, "trailer\n<< /Size %d /Root 1 0 R /Info %d 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objs)+1, len(objs), xref) + return []byte(b.String()) +} + +// streamObj renders a stream object body whose /Length matches data. +func streamObj(dict, data string) string { + return fmt.Sprintf("<< %s /Length %d >>\nstream\n%s\nendstream", dict, len(data), data) +} + +// xmpPacket is an XMP packet with dc:title and pdfuaid:part 1. +const xmpPacket = ` + + + +Many MCIDs +1 + + + +` + +// manyFindingsPDF is a one-page tagged document carrying the clean fixture's +// catalog entries. Its content marks each MCID m in 0..n-1 twice and opens MCID +// m+n inside the second, still open, sequence of m; every sequence is closed. +// The page's ParentTree array has 2n slots that all reference one P element +// under Document. +func manyFindingsPDF(n int) []byte { + var content strings.Builder + for m := 0; m < n; m++ { + fmt.Fprintf(&content, "/P <> BDC EMC\n/P <> BDC /Span <> BDC EMC EMC\n", m, m, m+n) + } + slots := make([]string, 2*n) + kids := make([]string, 2*n) + for i := range slots { + slots[i] = "8 0 R" + kids[i] = fmt.Sprint(i) + } + return pdfDoc([]string{ + // 1 catalog + "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /Lang (en-US) /StructTreeRoot 5 0 R /ViewerPreferences << /DisplayDocTitle true >> /Metadata 9 0 R >>", + // 2 pages + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + // 3 page + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << >> /Contents 4 0 R /StructParents 0 >>", + // 4 content + streamObj("", content.String()), + // 5 structure tree root + "<< /Type /StructTreeRoot /K 7 0 R /ParentTree 6 0 R /ParentTreeNextKey 1 >>", + // 6 parent tree + fmt.Sprintf("<< /Nums [0 [%s]] >>", strings.Join(slots, " ")), + // 7 Document + "<< /Type /StructElem /S /Document /P 5 0 R /K [8 0 R] >>", + // 8 P claiming every MCID + fmt.Sprintf("<< /Type /StructElem /S /P /P 7 0 R /Pg 3 0 R /K [%s] >>", strings.Join(kids, " ")), + // 9 metadata + streamObj("/Type /Metadata /Subtype /XML", xmpPacket), + // 10 info + "<< /Title (Many MCIDs) >>", + }) +} + +// writeTemp writes data to a file in a test temp dir and returns its path. +func writeTemp(t *testing.T, name string, data []byte) string { + t.Helper() + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, data, 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + return p +} diff --git a/tests/ua-structural-rules/json_test.go b/tests/ua-structural-rules/json_test.go new file mode 100644 index 00000000..4e9fd89b --- /dev/null +++ b/tests/ua-structural-rules/json_test.go @@ -0,0 +1,266 @@ +package ua_structural_rules_test + +import ( + "fmt" + "slices" + "strings" + "testing" +) + +// requireRuleList checks that rules lists every pdfua-1-structural rule in +// order with the registry severity and spec reference of the structure rules. +func requireRuleList(t *testing.T, r validationResult) { + t.Helper() + ids := make([]string, len(r.Rules)) + for i, ri := range r.Rules { + ids[i] = ri.RuleID + } + if !slices.Equal(ids, uaRuleIDs) { + t.Fatalf("rules = %v, want %v", ids, uaRuleIDs) + } + for _, id := range structureRuleIDs { + ri := ruleByID(t, r, id) + want := structureRuleSpec[id] + if ri.Severity != want.severity || ri.SpecRef != want.specRef { + t.Errorf("rule %s severity %q specRef %q, want %q %q", id, ri.Severity, ri.SpecRef, want.severity, want.specRef) + } + if strings.TrimSpace(ri.Checks) == "" { + t.Errorf("rule %s has no checks phrase", id) + } + } +} + +func TestValidateUAJSON_CleanFixtureHasNoFinding(t *testing.T) { + r, code := validateJSON(t, fixture(t, "ua-structural-clean.pdf")) + if code != 0 { + t.Errorf("exit = %d, want 0", code) + } + requireRuleList(t, r) + for _, ri := range r.Rules { + if !ri.Evaluated || ri.Findings != 0 { + t.Errorf("rule %+v on the clean fixture, want evaluated with 0 findings", ri) + } + } + if len(r.Problems) != 0 || r.Summary != (summary{}) { + t.Errorf("problems %+v summary %+v, want none", r.Problems, r.Summary) + } +} + +func TestValidateUAJSON_ExistingTaggedFixturesGetNoStructureFinding(t *testing.T) { + for _, file := range []string{"structure.pdf", "tagged.pdf", "untagged.pdf"} { + t.Run(file, func(t *testing.T) { + r, _ := validateJSON(t, fixture(t, file)) + requireRuleList(t, r) + for _, id := range structureRuleIDs { + if ri := ruleByID(t, r, id); !ri.Evaluated || ri.Findings != 0 { + t.Errorf("rule %s on %s = %+v, want evaluated with 0 findings", id, file, ri) + } + } + if file == "structure.pdf" && (len(r.Problems) != 0 || r.Summary != (summary{})) { + t.Errorf("structure.pdf problems %+v summary %+v, want none", r.Problems, r.Summary) + } + }) + } +} + +// faultCase is one fault fixture with the one rule it trips. +type faultCase struct { + file string + rule string + exit int + severity string + // message returns the substrings the finding's message must carry, from + // the fixture's own dump structure report. + message func(t *testing.T, rep structureReport) []string + // objRef returns the reference the finding must point at. + objRef func(t *testing.T, rep structureReport) string +} + +func pageRef(t *testing.T, rep structureReport) string { + return firstPage(t, rep).Ref +} + +var faultCases = []faultCase{ + { + file: "ua-structural-unbalanced.pdf", rule: "marked-content-balance", exit: 0, severity: "warning", + message: func(t *testing.T, rep structureReport) []string { + p := firstPage(t, rep) + if len(p.Streams) == 0 { + t.Fatal("page 1 has no content streams") + } + return []string{"page 1: EMC with no open marked-content sequence", "content stream " + p.Streams[0].Ref} + }, + objRef: pageRef, + }, + { + file: "ua-structural-duplicate-mcid.pdf", rule: "mcid-unique", exit: 0, severity: "warning", + message: func(t *testing.T, rep structureReport) []string { + p := firstPage(t, rep) + occ := occurrences(t, p, 0) + if len(occ) != 2 { + t.Fatalf("dump structure lists MCID 0 at %v, want 2 occurrences", occ) + } + if len(p.Streams) != 2 || !strings.Contains(occ[1], p.Streams[1].Ref) { + t.Errorf("second MCID 0 at %q, want it in the second /Contents stream of %+v", occ[1], p.Streams) + } + return append([]string{"page 1: MCID 0 marks 2 BDC sequences"}, occ...) + }, + objRef: pageRef, + }, + { + file: "ua-structural-orphan-mcid.pdf", rule: "mcid-in-parent-tree", exit: 1, severity: "error", + message: func(t *testing.T, rep structureReport) []string { + occ := occurrences(t, firstPage(t, rep), 2) + if len(occ) != 1 { + t.Fatalf("dump structure lists MCID 2 at %v, want 1 occurrence", occ) + } + return []string{"page 1: MCID 2 (" + occ[0], "has no structure element in ParentTree key 0"} + }, + objRef: pageRef, + }, + { + file: "ua-structural-nested-mcid.pdf", rule: "mcid-not-nested", exit: 0, severity: "warning", + message: func(t *testing.T, rep structureReport) []string { + occ := occurrences(t, firstPage(t, rep), 1) + if len(occ) != 1 { + t.Fatalf("dump structure lists MCID 1 at %v, want 1 occurrence", occ) + } + return []string{"page 1: MCID 1 (" + occ[0] + ") is nested inside MCID 0"} + }, + objRef: pageRef, + }, + { + file: "ua-structural-empty-s.pdf", rule: "struct-elem-type", exit: 1, severity: "error", + message: func(t *testing.T, rep structureReport) []string { + return []string{"structure element " + emptySElement(t, rep) + " has no usable /S ("} + }, + objRef: emptySElement, + }, +} + +// emptySElement returns the ref of the one element whose /S is empty. +func emptySElement(t *testing.T, rep structureReport) string { + t.Helper() + var refs []string + eachElement(rep.Elements, func(e *structElem) { + if e.S == "" { + refs = append(refs, e.Ref) + } + }) + if len(refs) != 1 || refs[0] == "" { + t.Fatalf("elements with an empty /S = %v, want exactly one indirect element", refs) + } + return refs[0] +} + +func TestValidateUAJSON_EachFaultFixtureTripsOneRule(t *testing.T) { + for _, tc := range faultCases { + t.Run(tc.file, func(t *testing.T) { + path := fixture(t, tc.file) + r, code := validateJSON(t, path) + if code != tc.exit { + t.Errorf("exit = %d, want %d", code, tc.exit) + } + requireRuleList(t, r) + for _, ri := range r.Rules { + if !ri.Evaluated { + t.Errorf("rule %s not evaluated: %+v", ri.RuleID, ri) + } + if ri.RuleID == tc.rule { + if ri.Findings != 1 { + t.Errorf("rule %s findings = %d, want 1", ri.RuleID, ri.Findings) + } + } else if ri.Findings != 0 { + t.Errorf("rule %s findings = %d, want 0 (only %s trips on this fixture)", ri.RuleID, ri.Findings, tc.rule) + } + } + ps := problemsOf(r, tc.rule) + if len(ps) != 1 || len(r.Problems) != 1 { + t.Fatalf("problems = %+v, want exactly one from %s", r.Problems, tc.rule) + } + p := ps[0] + spec := structureRuleSpec[tc.rule] + if p.Profile != uaProfile || p.Severity != tc.severity || p.SpecRef != spec.specRef { + t.Errorf("problem profile %q severity %q specRef %q, want %q %q %q", p.Profile, p.Severity, p.SpecRef, uaProfile, tc.severity, spec.specRef) + } + rep := dumpStructure(t, path) + if want := tc.objRef(t, rep); p.ObjRef != want || p.ObjNodeID != nodeIDOf(t, want) { + t.Errorf("problem objRef %q objNodeId %q, want %q %q", p.ObjRef, p.ObjNodeID, want, nodeIDOf(t, want)) + } + for _, sub := range tc.message(t, rep) { + if !strings.Contains(p.Message, sub) { + t.Errorf("message %q does not contain %q", p.Message, sub) + } + } + wantSummary := summary{} + if tc.severity == "error" { + wantSummary.Errors = 1 + } else { + wantSummary.Warnings = 1 + } + if r.Summary != wantSummary { + t.Errorf("summary = %+v, want %+v", r.Summary, wantSummary) + } + }) + } +} + +func TestValidateUA_ThousandsOfFindingsAreAllReported(t *testing.T) { + const n = 2000 + path := writeTemp(t, "many-mcids.pdf", manyFindingsPDF(n)) + + r, code := validateJSON(t, path) + if code != 0 { + t.Errorf("exit = %d, want 0 for warning findings only", code) + } + requireRuleList(t, r) + for _, ri := range r.Rules { + want := 0 + if ri.RuleID == "mcid-unique" || ri.RuleID == "mcid-not-nested" { + want = n + } + if !ri.Evaluated || ri.Findings != want { + t.Errorf("rule %s = %+v, want evaluated with %d findings", ri.RuleID, ri, want) + } + } + if len(r.Problems) != 2*n || r.Summary != (summary{Warnings: 2 * n}) { + t.Fatalf("got %d problems, summary %+v; want %d warning problems and nothing else", len(r.Problems), r.Summary, 2*n) + } + + rep := dumpStructure(t, path) + pg := firstPage(t, rep) + unique := problemsOf(r, "mcid-unique") + nested := problemsOf(r, "mcid-not-nested") + if len(unique) != n || len(nested) != n { + t.Fatalf("mcid-unique %d, mcid-not-nested %d findings; want %d each", len(unique), len(nested), n) + } + for m := 0; m < n; m++ { + u := unique[m] + occ := occurrences(t, pg, m) + wantU := fmt.Sprintf("page 1: MCID %d marks 2 BDC sequences: %s, %s", m, occ[0], occ[1]) + if u.Message != wantU { + t.Fatalf("mcid-unique finding %d = %q, want %q", m, u.Message, wantU) + } + inner := occurrences(t, pg, m+n) + wantN := fmt.Sprintf("page 1: MCID %d (%s) is nested inside MCID %d", m+n, inner[0], m) + if nested[m].Message != wantN { + t.Fatalf("mcid-not-nested finding %d = %q, want %q", m, nested[m].Message, wantN) + } + if u.ObjRef != pg.Ref || nested[m].ObjRef != pg.Ref { + t.Fatalf("finding %d objRefs %q %q, want page %q", m, u.ObjRef, nested[m].ObjRef, pg.Ref) + } + } + + stdout, _, code := runCLI(t, "validate", "--profile", uaProfile, path) + if code != 0 { + t.Errorf("plain exit = %d, want 0", code) + } + for _, p := range r.Problems { + if !strings.Contains(stdout, "["+p.RuleID+"] "+p.Message+"\n") { + t.Fatalf("plain text does not print the finding %q in full", p.Message) + } + } + if !strings.Contains(stdout, fmt.Sprintf("Summary: 0 errors, %d warnings\n", 2*n)) { + t.Errorf("plain text summary does not count all %d warnings", 2*n) + } +} diff --git a/tests/validate-output-honesty/validate_output_test.go b/tests/validate-output-honesty/validate_output_test.go index ec354799..7f24bf7f 100644 --- a/tests/validate-output-honesty/validate_output_test.go +++ b/tests/validate-output-honesty/validate_output_test.go @@ -254,22 +254,30 @@ func TestValidatePlain_CleanRunNamesRuleCount(t *testing.T) { } } -func TestValidatePlain_UntaggedUnderUAShowsOneFoundPerRule(t *testing.T) { +// On an untagged file each catalog rule finds its missing entry and the +// structure rules find nothing, since there is no marked content and no +// structure tree. +func TestValidatePlain_UntaggedUnderUAShowsCatalogFindings(t *testing.T) { path := fixture(t, "untagged.pdf") res := runJSON(t, profilePDFUA, path, 0) out := runPlain(t, profilePDFUA, path, 0) if len(res.Rules) == 0 { t.Fatalf("rules must list every pdfua-1-structural rule, got none") } + catalogRules := map[string]bool{"marked": true, "struct-tree-root": true, "lang": true} for _, rule := range res.Rules { - if rule.Findings != 1 || !rule.Evaluated { - t.Errorf("untagged file: rule %q = %+v, want evaluated with 1 finding", rule.RuleID, rule) + want := 0 + if catalogRules[rule.RuleID] { + want = 1 } - if line := ruleLine(out, rule.RuleID); !strings.Contains(line, "1 found") { - t.Errorf("rule %q line must show 1 found, got %q", rule.RuleID, line) + if rule.Findings != want || !rule.Evaluated { + t.Errorf("untagged file: rule %q = %+v, want evaluated with %d finding(s)", rule.RuleID, rule, want) + } + if line := ruleLine(out, rule.RuleID); !strings.Contains(line, fmt.Sprintf("%d found", want)) { + t.Errorf("rule %q line must show %d found, got %q", rule.RuleID, want, line) } } - if want := fmt.Sprintf("Summary: 0 errors, %d warnings", len(res.Rules)); !strings.Contains(out, want) { + if want := fmt.Sprintf("Summary: 0 errors, %d warnings", len(catalogRules)); !strings.Contains(out, want) { t.Errorf("summary must read %q:\n%s", want, out) } if strings.Contains(strings.ToLower(out), "none of the") {