Skip to content

release: v1.7.3 — PDF/UA-1 (ISO 14289-1) accessibility conformance - #133

Merged
Tim81 merged 16 commits into
mainfrom
v1.7.3-conformance
Jun 22, 2026
Merged

release: v1.7.3 — PDF/UA-1 (ISO 14289-1) accessibility conformance#133
Tim81 merged 16 commits into
mainfrom
v1.7.3-conformance

Conversation

@Tim81

@Tim81 Tim81 commented Jun 22, 2026

Copy link
Copy Markdown
Owner

Summary

Implements PDF/UA-1 (ISO 14289-1) accessibility conformance in VellumPdf.Conformance, raising build-verified veraPDF parity from ~7.5% to ~71% (75 of 106 checks implemented, 1 partial). PDF/A-2b/2u/2a coverage is unchanged.

Every rule is authored clean-room from the ISO text and cross-validated against veraPDF 1.30.2 in CI. The work was built in reviewed batches; 4 false positives were caught and fixed during review, and a final 37-fixture adversarial sweep (crop-box geometry, structure-parent resolution, multi-hop role-mapping, font usage-scoping, indirect references) found no over-rejections — the cardinal "no false positives" invariant holds.

What's added

  • A reusable tagged-structure walker (/StructTreeRoot/KStructElem; role-map resolution to ISO 32000-1 Table 333 standard types; a /ParentTree reverse index for annotation↔structure binding; cycle/depth guards) underpinning all structure rules.
  • A content-stream glyph-extraction pass extending the shared ContentStreamUsage (per-glyph text rendering mode via a q/Q graphics-state stack + shown character codes), proven behaviour-identical for the existing PDF/A rules.
  • ~50 PDF/UA-1 rules across: document/metadata (§6.1, §7.1, §7.2-29), logical structure (table/list/TOC containment + counts + caption position §7.2-3…40, figure/formula alt-text §7.3/§7.7, notes §7.9, headings §7.4.4, natural-language §7.2-21…25), annotations (§7.18.1/2/3/4/5/8, §7.10/§7.11/§7.15/§7.20), and fonts (§7.21.3.x/4.x/6-3/7-2/8-1).

Verification

  • Full conformance gate green under REQUIRE_VERAPDF=1: 849 passed, 0 failed, 0 skipped (every fixture's in-process verdict cross-checked against veraPDF 1.30.2; PDF/A fixtures included as a no-regression proof).
  • Adversarial sweep: 37 counterexample PDFs, no false positives.

Deferred edges (documented per-rule in ConformanceCatalog): table-grid span analysis, heading-nesting level, TH scope, content-stream marked-content artifacts, the pdfuaid-prefix/encryption/media-clip checks, and PDF/A-2a §6.7 — targeted for follow-up.

Tim81 and others added 16 commits June 22, 2026 05:18
First slice of PDF/UA-1 (ISO 14289-1) coverage that does not need the tagged
structure-tree walker — three catalog/byte-level clauses, each authored from the
ISO text and cross-validated against veraPDF 1.30.2:

- §6.1-1 (UaFileHeaderRule): the file header line shall be exactly "%PDF-1.n"
  (0 ≤ n ≤ 7) followed immediately by an EOL — enforcing the profile's
  /^%PDF-1\.[0-7]$/ "$" anchor. Unlike the PDF/A-2 FileHeaderRule it adds no
  binary-marker check (UA-1 has none). veraPDF behaviour confirmed directly:
  trailing space/junk and a bad digit all fail 6.1-1; LF, CR, and CRLF line
  endings all pass.
- §7.1-4 (UaSuspectsRule): /MarkInfo /Suspects shall not be the boolean true;
  absent or false both pass.
- §7.2-29 (UaLangSyntaxRule): the catalog /Lang shall be a syntactically valid
  BCP-47 tag. Scoped to the catalog /Lang only — veraPDF 7.2-29 does not fire on
  a page-dictionary /Lang, and struct-element /Lang needs the walker (deferred).
  Handles a UTF-16BE BOM (the writer stores /Lang via FromUnicode). Empty /Lang
  is a violation (veraPDF 1.30.2 rejects it — empirically confirmed).

The §5-3/-4/-5 pdfuaid-prefix checks are left Deferred with a precise note: the
XMP reader matches by namespace URI, not prefix, so they need prefix-aware
parsing. Coverage catalog updated (6.1-1, 7.1-4, 7.2-29 → Implemented).

Adds 8 veraPDF-cross-validated oracle fixtures, including separate bad-digit and
trailing-char header cases so the rule's EOL/"$" logic has its own regression
guard. Full local gate green: REQUIRE_VERAPDF=1 → 665 passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
…§7.18/§7.20

Second Track-A slice (no structure-tree walker): seven non-structural annotation
and catalog clauses, each authored from the ISO 14289-1 text and cross-validated
against veraPDF 1.30.2.

- §7.18.2-1 (UaTrapNetAnnotRule): TrapNet annotations forbidden unless hidden or
  outside the crop box.
- §7.18.5-2 (UaLinkAnnotRule): Link annotations require a non-empty /Contents
  (hidden / outside-crop exempt).
- §7.18.1-2 (UaAnnotContentsRule, PARTIAL): non-Widget annotations require a
  non-empty /Contents or /Alt. veraPDF resolves the alt-text from the annotation's
  ENCLOSING STRUCTURE ELEMENT (via /StructParent), not just the annotation dict —
  confirmed by direct probe (an annotation with a struct-element /Alt is accepted).
  To stay false-positive-safe without the structure-tree walker the rule fires
  only on annotations with NO /StructParent (untagged); struct-bound annotations
  are skipped (under-detection). Marked Partial in the coverage catalog.
- §7.20-1 (UaReferenceXObjectRule): a drawn Form XObject shall not be a reference
  XObject (/Ref); reuses the content-usage scoping.
- §7.11-1 (UaEmbeddedFileRule): a filespec with /EF requires non-empty /F and /UF;
  identifies filespecs by /Type /Filespec or filespec-slot reachability (not bare
  /EF), and resolves /F and /UF through indirect references.
- §7.10-1 / §7.10-2 (UaOptionalContentRule): each OC configuration dict needs a
  non-empty /Name and must omit /AS.

A shared UaAnnotationHelper implements the isOutsideCropBox and Hidden-flag (F&2)
exemptions common to the §7.18 rules. §7.16-1 (encryption) and §7.18.6.2 (media
clip) left Deferred with precise notes. Coverage catalog gains a PdfUaPartial
mechanism for §7.18.1-2.

15 veraPDF-cross-validated oracle fixtures plus two focused unit tests guarding
the §7.18.1-2 struct-element-Alt false positive (and its untagged positive
control). Full local gate green: REQUIRE_VERAPDF=1 → 697 passed, 0 failed, 0
skipped. Two pre-existing UA tab-order tests gained /Contents on their Link annots
so the new §7.18.5-2/§7.18.1-2 rules do not fire, isolating the §7.18.3 assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
Third Track-A slice (no structure-tree walker): the two §7.21 font clauses that
are provably false-positive-safe with the current infrastructure, each authored
from the ISO 14289-1 text and cross-validated against veraPDF 1.30.2.

- §7.21.3.2-1 (UaCidToGidMapRule): an embedded CIDFontType2 (a Type0 descendant
  with a FontFile2/FontFile3 program) must carry a /CIDToGIDMap entry (name
  /Identity or a stream both satisfy). Non-embedded or non-CIDFontType2 descendants
  are exempt. Scoped to used fonts (EnumerateUsedFonts).
- §7.21.6-3 (UaSymbolicFontRule): a symbolic simple TrueType font must not carry an
  /Encoding entry. Symbolic is read FP-safely as Flags bit 3 set AND bit 6 (Non-
  symbolic) clear — a malformed font setting both flags is not treated as symbolic,
  so we never fire where veraPDF (which may classify it non-symbolic) accepts.

§7.21.7-2 (forbidden ToUnicode values) was implemented in review but REMOVED as a
false positive: a whole-CMap scan over-rejects an UNUSED bfchar/bfrange entry
mapping to U+0000/FEFF/FFFE, whereas veraPDF validates only glyphs actually shown.
Proven by direct probe — veraPDF returns PASS for a document whose ToUnicode maps
an unused <0025> to <0000>. It needs shown-glyph-code extraction from content
streams (same prerequisite as §7.21.8-1) and is now Deferred with that note.

The remaining twelve §7.21 clauses stay Deferred with precise FP-trap notes
(renderingMode-3 exemption, glyph-coverage, differencesAreUnicodeCompliant,
toUnicode-from-encoding). Three focused unit tests guard the §7.21.6-3 symbolic
logic. Full local gate green: REQUIRE_VERAPDF=1 → 706 passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
….21.3.1

Fourth Track-A slice: the §7.21 font-structure clauses whose PDF/UA-1 predicates
are identical to already-implemented, veraPDF-validated PDF/A-2 §6.2.11.x rules.
Each reuses the corresponding validated parsing/comparison logic and emits the
UA-1 rule id, all usage-scoped (EnumerateUsedFonts) like their PDF/A-2 originals.

- §7.21.3.3-1/-2/-3 (UaCMapRule): a composite font's /Encoding must be a predefined
  CMap (ISO 32000-1 Table 118) or an embedded CMap stream; an embedded CMap's dict
  /WMode must equal its program's WMode N def; a usecmap reference must name a
  predefined CMap. Mirrors §6.2.11.3.3-1/-2/-3 — the predefined-CMap list is a
  verbatim copy of the validated 61-entry list (diff-checked identical to
  FontStructureRule/CMapContentRule, so no name is wrongly rejected).
- §7.21.4.2-1 (UaType1CharSetRule): an embedded subset Type1 font's /CharSet must
  list every program glyph. Reuses Type1Glyphs + the subset-tag gate of §6.2.11.4.2-1.
- §7.21.4.2-2 (UaCidSetRule): an embedded subset CIDFontType2's /CIDSet must mark
  exactly the present CIDs. Reuses SfntMetrics; a non-Identity stream CIDToGIDMap is
  skipped (same FP-avoidance as §6.2.11.4.2-2).
- §7.21.3.1-1 (UaCidSystemInfoRule, PARTIAL): Type0 CIDSystemInfo must be compatible
  with its CMap. Same partial scope as §6.2.11.3.1-1 — Identity always passes,
  predefined-name CMaps deferred (no registry table), embedded CMap streams compared.
  Findings occur only on embedded-CMap mismatches, so a named encoding never fires.

The glyph-extraction-dependent §7.21 clauses (7.21.4.1-1/-2, 7.21.5-1, 7.21.7-1/-2,
7.21.8-1) and the FP-prone TrueType cmap ones (7.21.6-1/-2/-4) remain Deferred with
precise notes. Adds veraPDF-cross-validated oracle fixtures and 8 unit tests. Full
local gate green: REQUIRE_VERAPDF=1 → 722 passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
Review follow-up. The ISO 32000-1 Table 118 predefined-CMap name set was copied
verbatim into five rules (FontStructureRule, CMapContentRule, CidRangeRule, and
the new UaCMapRule / UaCidSystemInfoRule) — the existing copies even carried
"must stay in sync" comments. A copy that lost an entry would turn a conforming
predefined CMap into a false positive, so this is a real drift risk.

Extract one authoritative copy as Fonts.PredefinedCMaps.Names (an IReadOnlySet,
so it can't be mutated through the shared reference) and point all five rules at
it. The five lists were diff-verified identical (61 entries) before the merge, so
the change is behaviour-preserving — confirmed by the full local gate
(REQUIRE_VERAPDF=1 → 722 passed, 0 failed, 0 skipped), which re-runs every PDF/A
font oracle fixture against veraPDF.

Also drops an unused `using System.Text;` in UaXfaRule and adds the rationale
comment to the one bare CMap-program catch in UaCMapRule, matching the
defensive-parse convention used elsewhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
…mbedding

Extends the shared content-stream scanner with graphics-state-aware text-show
tracking, then uses it for the first glyph-level UA-1 font rule.

ContentStreamUsage (shared with the PDF/A-2 rules) is refactored from a 7-element
named tuple to a `ContentUsage` class exposing the SAME seven properties (so every
existing caller is behaviour-identical — proven by the PDF/A oracle fixtures still
passing) plus a new `TextShows` list. Each text-show operator (Tj/TJ/'/") records
(current font resource, text rendering mode, shown bytes), with the current font
and rendering mode maintained through a q/Q graphics-state stack. Operand tracking
is split into independent lastName/lastInt/lastString channels cleared together on
each operator, preserving the original "operands since the last operator" contract.
An indeterminate `Tr` operand yields rendering mode -1 (unknown), never a kept
visible value — the false-positive-safe direction.

§7.21.4.1-1 (UaFontEmbeddingRule): a simple font (Type1/TrueType/MMType1; Type0 and
Type3 are exempt per the veraPDF predicate) that has no embedded program and is
drawn in a visible text rendering mode must embed its program. Fires only on a
positively-observed visible draw (rendering mode 0..8 except 3, never the -1
unknown sentinel); a font drawn only with `3 Tr` (invisible) is exempt. Usage-scoped
via the Tf-selected font set. Reuses FontEmbeddingRule's embedded-program detection.

Review fixes folded in: (1) the cross-page dedup now marks a font reported only when
it actually fires, so a font drawn invisibly on one page and visibly on another is
still caught; (2) the indeterminate-Tr hardening above; dead no-op block removed.

veraPDF-cross-validated fixtures (non-embedded visible → fails; non-embedded
invisible-only and embedded-visible → pass 7.21.4.1-1) plus 4 unit tests incl. the
3-Tr / embedded / Type0 FP guards. Full local gate green: REQUIRE_VERAPDF=1 → 728
passed, 0 failed, 0 skipped (PDF/A regression proof included).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
… (ToUnicode)

Consumes the A5a content-stream glyph extraction (ContentUsage.TextShows) for the
two clauses that depend on which glyphs are ACTUALLY SHOWN. Both are scoped to the
unambiguous case — a composite (Type0) font with /Encoding Identity-H or Identity-V,
a CIDFontType2 descendant, and /CIDToGIDMap absent or /Identity — where the shown
bytes are 2-byte big-endian codes and code == CID == GID. All other font forms are
deferred (code→glyph needs encoding/cmap tables we don't have).

- §7.21.8-1 (UaNotdefGlyphRule): no shown glyph may be .notdef. Glyph index 0 is
  .notdef, so a shown code 0x0000 fires. Checked in every text rendering mode —
  the veraPDF predicate (name != ".notdef") has no Tr exemption (unlike §7.21.4.1-1,
  whose explicit renderingMode==3 term confirms veraPDF builds glyph objects for
  invisible text too).
- §7.21.7-2 (UaToUnicodeForbiddenRule): a shown glyph's /ToUnicode value may not be
  U+0000/FEFF/FFFE. The CMap is parsed into a code→Unicode map (bfchar + bfrange,
  single and array destinations, surrogate pairs), but ONLY codes that appear in
  TextShows are looked up. This is the fix for the earlier revert (ab5dc76): a
  whole-CMap scan over-rejected an UNUSED forbidden entry that veraPDF accepts. A
  dedicated regression fixture (unused <FFFF> <0000>, only a good code shown) is now
  compliant in both veraPDF and in-process.

Each clause has veraPDF-cross-validated oracle fixtures (violating + compliant) and
unit tests including the FP guards. Full local gate green: REQUIRE_VERAPDF=1 → 737
passed, 0 failed, 0 skipped (PDF/A regression proof included).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
…ogram)

Final glyph-extraction clause, consuming A5a's ContentUsage.TextShows. A glyph
shown in a visible text rendering mode must exist in the embedded font program.

§7.21.4.1-2 (UaGlyphPresenceRule): for an in-scope font (Type0 / Identity-H or
Identity-V / CIDFontType2 / CIDToGIDMap absent or /Identity / embedded FontFile2),
the shown 2-byte codes are GIDs; a GID >= the program's glyph count (SfntMetrics.
NumGlyphs) is positively absent → fire. Reuses the validated SfntMetrics parser and
the same in-scope predicate as the PDF/A-2 §6.2.11.4.1-2 rule. The veraPDF Glyph
predicate adds a Tr-3 exemption the PDF/A rule lacks: `renderingMode == 3 ||
isGlyphPresent == null || isGlyphPresent == true`. FP-safe — fires only on a
positively-determined visible draw (rendering mode 0..8 except 3, never the -1
unknown sentinel); GID 0 (.notdef) is skipped (§7.21.8-1's job); an unparseable
program or absent FontFile2 maps to isGlyphPresent == null (no finding).

veraPDF-cross-validated: GID 60000 drawn visibly fires; the same drawn with 3 Tr
does not (Tr-3 exemption); in-range baseline does not. Oracle fixtures + 4 unit
tests (visible-fires / 3-Tr / in-range / unknown-Tr guards). §7.21.5-1 (glyph
widths) stays Deferred. Full local gate green: REQUIRE_VERAPDF=1 → 745 passed,
0 failed, 0 skipped (PDF/A regression proof included).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
Starts the tagged-structure subsystem (gates ~75 UA-1 clauses + PDF/A-2a §6.7).

StructureTree (Rules/Structure/StructureTree.cs): a reusable, cached walker of the
catalog's /StructTreeRoot → /K → StructElem hierarchy. Each node exposes its dict,
/S raw type, the role-mapped ISO 32000-1 Table 333 standard type (multi-hop /RoleMap
chain with a cycle guard; null when non-standard/unmapped), its parent node, child
StructElem nodes, a HasNonElementKids flag (MCID/MCR/OBJR), and /Pg. Cycle guard by
object number and a depth cap. The Table 333 standard-type set lives here as the
single source for later containment rules.

Three FP-safe rules validate it:
- §7.1-12 (UaStructElemParentRule): every StructElem has a /P entry (reads the raw
  dict; the StructTreeRoot is not a node, so it is never flagged).
- §7.1-6 / §7.1-7 (UaRoleMapRule): a /RoleMap shall be acyclic and shall not remap a
  standard type.

Review fixes folded in:
- UaRoleMapRule is ELEMENT-DRIVEN, not RoleMap-dict-driven. Direct veraPDF probing
  showed veraPDF evaluates these on a PDStructElem that USES the offending type: a
  /RoleMap that remaps a standard type, or contains a cycle, is ACCEPTED when no
  element uses it. Firing on the /RoleMap contents alone over-rejected such
  conformant documents (a false positive). Now 7.1-7 fires only when an element's
  /S is a standard type that is a RoleMap key, and 7.1-6 only when an element's /S
  chain cycles. Two regression guards added.
- The walker built each node once with the correct HasNonElementKids flag instead of
  a placeholder-then-rebuild that left children pointing at a discarded node
  (corrupting StructureTreeNode.Parent for the later containment batches).

Full local gate green: REQUIRE_VERAPDF=1 → 758 passed, 0 failed, 0 skipped
(PDF/A regression proof included). Containment / figure / heading / natural-language
rules build on this foundation in later batches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
…lauses)

Builds on the B1 structure-tree walker: three rules check parent-type and kid-type
containment for table, list, and table-of-contents structure elements, keyed on
each element's role-mapped standard type (node.StandardType) and its parent's
(node.Parent.StandardType — now exact after the B1 walker fix).

- UaTableContainmentRule: §7.2-3 (Table kids), -4 (TR parent), -5/-6/-7 (THead/
  TBody/TFoot parent == Table), -8/-9 (TH/TD parent == TR), -10 (TR kids),
  -36/-37/-38 (THead/TBody/TFoot kids == TR).
- UaListContainmentRule: §7.2-17 (LI parent == L), -18 (LBody parent == LI),
  -19 (L kids), -20 (LI kids).
- UaTocContainmentRule: §7.2-26 (TOCI parent == TOC), -27 (TOC kids).

FP semantics matched to veraPDF 1.30.2 by direct probing of every edge:
- NULL parent (element is a direct StructTreeRoot child, or its parent's /S is
  non-standard/unmapped): the parent-type predicate is a string/regex test that is
  false for null, so such an element FIRES — confirmed (e.g. a root-level TR fires
  7.2-4).
- NULL-StandardType kid (a non-standard, unmapped /S child): OMITTED from the
  kid-type check — veraPDF's kidsStandardTypes skips unknown types, so a custom
  unmapped kid does NOT fire; a custom kid role-mapped to an allowed type IS
  accepted as that type.
- Empty kid list (only MCIDs, or no /K): vacuously satisfied — no fire.

Each rule uses the role-mapped standard type throughout (never the raw /S). 21 unit
tests (violating + compliant per group). Count/position (§7.2-11..16/28/39/40),
figure/heading/note (§7.3/7.4/7.5/7.7/7.9), and natural-language clauses stay
Deferred for later batches. Full local gate green: REQUIRE_VERAPDF=1 → 780 passed,
0 failed, 0 skipped (PDF/A regression proof included).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
…te rules

Three more structure-tree-walker rules (per-element attribute / document-level
heading checks), all keyed on the role-mapped node.StandardType.

- UaAltTextRule: §7.3-1 (Figure) and §7.7-1 (Formula) — the element must have a
  non-empty /Alt OR an /ActualText entry. veraPDF accepts an /ActualText that is
  present even when empty (presence-only), but requires /Alt to be non-empty —
  matched exactly by direct probing. Read element-only (no inheritance).
- UaNoteIdRule: §7.9-1 (every Note has a non-empty /ID) and §7.9-2 (Note /ID values
  are unique — byte-exact comparison; an element without a valid /ID is excluded
  from the uniqueness set).
- UaHeadingRule: §7.4.4-1 (at most one child whose standard type is exactly H — H1–H6
  children do not count), §7.4.4-2/§7.4.4-3 (a document must not mix the weakly-
  structured H and strongly-structured H1–H6 heading types; if both appear, both
  fire). usesH/usesHn computed document-wide in a first pass.

Every edge probed against veraPDF 1.30.2 (empty vs absent /Alt and /ActualText,
empty /ID, duplicate /ID, H-vs-Hn mixing, H-vs-H1 child counting). 16 unit tests
(violating + compliant per clause). §7.4.2-1 (heading nesting level) stays Deferred.
Full local gate green: REQUIRE_VERAPDF=1 → 796 passed, 0 failed, 0 skipped
(PDF/A regression proof included).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
… clauses)

Count- and position-based structure rules on the B1 walker, all operating on the
null-omitted ordered child standard-type list (node.Children → StandardType,
dropping unmapped/null types), matching veraPDF's kidsStandardTypes semantics
established in B2.

- UaTableCountRule (SETable): §7.2-11 (≤1 THead), §7.2-12 (≤1 TFoot), §7.2-13
  (TFoot requires ≥1 TBody), §7.2-14 (THead requires ≥1 TBody), §7.2-39 (≤1
  Caption), §7.2-16 (a Caption may only be the first or last child).
- UaTocContainmentRule extended: §7.2-28 (a TOC Caption may only be the first child).
- UaListContainmentRule extended: §7.2-40 (an L Caption may only be the first child).

Caption-position semantics confirmed by direct veraPDF 1.30.2 probing, including the
null-omission edge: a Caption physically at index 1 whose only preceding sibling is
an unmapped element is treated as index 0 in the null-omitted list → passes
7.2-16/28/40. Role-mapped types throughout; empty kid lists pass.

The table-grid clauses (§7.2-15 cell intersection, §7.2-41/42/43 row/column span)
remain Deferred — they need full grid construction with RowSpan/ColSpan tracking.
18 unit tests (violating + compliant per clause). Independently re-ran the full gate:
REQUIRE_VERAPDF=1 → 811 passed, 0 failed, 0 skipped (PDF/A regression proof included).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
….18.1-2

Extends the structure-tree walker with a /ParentTree reverse index and uses it for
the annotation-to-structure rules, then promotes §7.18.1-2 to fully Implemented.

StructureTree gains StructParentOf(int): the annotation's /StructParent integer →
the StructTreeRoot's /ParentTree number tree (/Nums + /Kids, depth-capped) → the
enclosing StructElem node. /Nums values that are single StructElem dicts are
annotation/OBJR parents and indexed; array values (page MCID maps) are ignored.
Nodes are matched by PdfDictionary reference identity, which is exact because the
reader caches resolved objects by number. A missing/unresolvable binding yields
null — the FP-safe result.

UaAnnotStructureRule (reusing UaAnnotationHelper's hidden/outside-crop exemptions):
- §7.18.1-1: a visible non-Widget/Link/PrinterMark annotation must be nested in an
  Annot tag (structParentStandardType == 'Annot').
- §7.18.4-1: a visible Widget must be nested in a Form tag.
- §7.18.5-1: a visible Link must be nested in a Link tag.
- §7.18.8-1: a visible PrinterMark must NOT be in the structure tree — fires only
  when a struct binding is positively confirmed (the FP-safe inverse).

§7.18.1-2 (UaAnnotContentsRule) promoted from Partial: it now resolves the DIRECT
enclosing struct element's /Alt via the ParentTree index instead of skipping
struct-bound annotations. veraPDF checks the direct struct element's /Alt only (not
ancestors) — confirmed by probe. The earlier A2 struct-Alt FP guard still passes.

Verified veraPDF uses the reverse /StructParent→/ParentTree path (not the forward
/K→/OBJR path): an OBJR binding with no ParentTree entry fires 7.18.1-1, matching
our resolution. 15 new unit tests; 2 pre-existing tab-order tests relaxed from
Assert.Single to Assert.Contains (they now also, correctly, trip the new rules).
Full local gate independently re-run: REQUIRE_VERAPDF=1 → 828 passed, 0 failed,
0 skipped (PDF/A regression proof included).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
Lang-determination rules on the structure-tree walker, all sharing the
gContainsCatalogLang short-circuit.

- §7.2-21/22/23 (PDStructElem): a struct element with /ActualText, /Alt, or /E must
  have a determinable language — its own /Lang, or any ancestor's /Lang (walked via
  node.Parent), or the catalog /Lang. An empty /Lang () counts as present (key
  presence, matching veraPDF).
- §7.2-24 (PDAnnot): an annotation with a non-empty /Contents must have its DIRECT
  enclosing struct element (via /StructParent → ParentTree, the B5 index) carry
  /Lang — confirmed empirically that veraPDF accepts neither the annotation-dict
  /Lang nor an ancestor struct-element /Lang here.
- §7.2-25 (PDFormField): a field with /TU must have its widget's enclosing struct
  element carry /Lang (same StructParentOf path).

FP-safety: ISO 14289-1 §7.2 requires a non-empty catalog /Lang, so every conformant
PDF/UA-1 document satisfies gContainsCatalogLang and this rule emits nothing — it
returns immediately when the catalog has a non-empty /Lang. It can only fire when
the catalog lacks /Lang; an empty catalog /Lang is itself a §7.2-29 violation, and
an absent catalog /Lang leaves the verdict driven by the per-element checks, which
were probe-matched to veraPDF. So the compliance verdict always tracks veraPDF.

§7.2-33 (XMP dc:title lang-alt) deferred — needs XMP lang-alt parsing and only fires
when the catalog also lacks /Lang. §7.2-30/31/32/34 (marked-content) stay Deferred.
22 unit tests (with-catalog-Lang passes / without fires, per clause). Independently
re-ran the full gate: REQUIRE_VERAPDF=1 → 849 passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
…fix)

Bumps VersionPrefix to 1.7.3 and documents the PDF/UA-1 (ISO 14289-1) conformance
work: build-verified veraPDF parity ~7.5% → ~71% (75/106 implemented, 1 partial),
all cross-validated against veraPDF 1.30.2 and clean under a 37-fixture adversarial
sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
Pure formatting fix — resolves the CI format-check failures (whitespace and import
ordering) in four UA-1 rule files; no logic change. The conformance gate is
unaffected (REQUIRE_VERAPDF=1 → 849 passed locally).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant