Skip to content

release: v1.7.4 — PDF/UA-1 ~90%, PDF/A-2a ~90% conformance - #134

Merged
Tim81 merged 12 commits into
mainfrom
v1.7.4-conformance
Jun 23, 2026
Merged

release: v1.7.4 — PDF/UA-1 ~90%, PDF/A-2a ~90% conformance#134
Tim81 merged 12 commits into
mainfrom
v1.7.4-conformance

Conversation

@Tim81

@Tim81 Tim81 commented Jun 23, 2026

Copy link
Copy Markdown
Owner

Summary

Deepens VellumPdf.Conformance to ~90% build-verified veraPDF parity for PDF/UA-1 (95 of 106 checks implemented + 1 partial, up from ~71%) and ~90% for PDF/A-2a (up from ~87%). 12 commits; no public API changes.

Build-verified coverage (ConformanceCatalog.Coverage): PDF/A-2b 90.3%, 2u 90.4%, 2a 89.9%, PDF/UA-1 90.1%.

What's added

  • Marked-content interpreter over page content streams — BMC/BDC/EMC nesting, MCID resolution for both inline (/Tag << /MCID n >>) and named-reference (/Tag /Name via /Resources /Properties) forms, artifact ancestry, and a per-operator content-item model. Underpins the §7.1/§7.2 marked-content rules.
  • PDF/UA-1 logical structure — table grid / spans (§7.2-15/-41/-42/-43), heading nesting (§7.4.2-1), table connected-header (§7.5-1/-2), non-standard-type role-map (§7.1-5).
  • PDF/UA-1 marked content — content tagged-or-artifact + nesting (§7.1-1/-2/-3); marked-content & outline natural-language (§7.2-2/-30/-31/-32/-33/-34), resolving language through the structure tree.
  • PDF/UA-1 fonts — TrueType cmap/encoding (§7.21.6-1/-2/-4) backed by an embedded Adobe Glyph List (BSD-3-Clause, attributed in NOTICE); glyph-width consistency for Identity-H CIDFontType2 (§7.21.5-1).
  • PDF/A-2a §6.7 — non-standard structure-type role-mapping / RoleMap acyclicity / standard-remap (§6.7.3.4-1/-2/-3), /Lang syntax (§6.7.4-1).

Verification

  • Full conformance gate green with REQUIRE_VERAPDF=1: 994 passed, 0 failed, 0 skipped (every oracle fixture cross-validated against veraPDF 1.30.2); dotnet format --verify-no-changes clean.
  • No false positives. Every rule was reviewed against the cardinal invariant (never flag a PDF veraPDF accepts). Several were caught and fixed before/after commit (A2a multi-hop RoleMap chain, marked-content struct-/Lang resolution, a glyph-width rounding-boundary margin, and a latent §7.2-34 named-reference-BDC FP). Two pre-release adversarial sweeps — content-stream/marked-content rules (48 valid-document fixtures incl. named-reference + content-item cross-interactions) and the font rules (24 shapes incl. the rounding boundary) — found zero over-rejections; an earlier structure-rule sweep was also clean.
  • Remaining PDF/UA-1 checks are deferred with concrete reasons (each would over-reject conformant files or needs a subsystem not yet present).

Release

Merging this and pushing the v1.7.4 tag triggers the Release workflow (NuGet + GitHub Release). Left for human merge + tag.

https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq

Tim81 and others added 12 commits June 22, 2026 16:09
…column span)

Builds a cell-occupancy grid for each Table on the structure-tree walker and checks
the four grid clauses, with the placement algorithm derived empirically from
veraPDF 1.30.2.

- §7.2-15 (cell intersection): place each TH/TD at the first free column in its row
  (skipping slots already occupied by a row-spanning cell from an earlier row), fill
  RowSpan×ColSpan slots, and flag both cells when a slot collides.
- §7.2-41 (column row-span consistency): every column must cover the same number of
  rows; a hole fires.
- §7.2-42/-43 (row column-span consistency): every row must have the same effective
  width; a wider row fires 7.2-42, a narrower one 7.2-43.

Rows are gathered from direct TR children and from THead/TBody/TFoot wrappers; cell
spans are read from the `/A` attribute (single dict, array of dicts, or
`[dict revision]` array) and only an `/O /Table` owner contributes RowSpan/ColSpan
(default 1). Role-mapped cell/row types throughout; null-type kids omitted.

The skip-occupied placement was the key veraPDF behaviour to match (a rowspan from an
earlier row pushes the next cell right rather than overlapping). FP-safe: a valid
table yields a uniform grid, so the rules fire only on genuine irregularities —
13 unit tests include valid-rowspan, colspan-header, THead/TBody, and /A-array
guards that must NOT fire (each veraPDF-confirmed exit 0). Full local gate green:
REQUIRE_VERAPDF=1 → 862 passed, 0 failed, 0 skipped; dotnet format clean.

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

Completes the language-determination family with the two remaining
gContainsCatalogLang-dominated clauses.

- §7.2-2 (UaOutlineLangRule): a document outline requires a determinable natural
  language. Fires only when the catalog has an /Outlines dict with at least one item
  (/First present) AND the catalog has no /Lang.
- §7.2-33: a metadata language-alternative array (any rdf:Alt with an x-default item,
  not just dc:title — confirmed against veraPDF) requires a determinable language.
  Fires only when such an x-default lang-alt exists AND the catalog has no /Lang.
  XmpReader gains an additive HasXDefaultLangAlt(XDocument) helper.

FP-safety: both share the gContainsCatalogLang short-circuit — a conformant PDF/UA-1
document always has a non-empty catalog /Lang, so the rule returns immediately and
emits nothing. It can only fire when the catalog lacks /Lang (itself a §7.2-lang /
§7.2-29 violation), so the compliance verdict always tracks veraPDF; the precise
triggers (outline presence; XMP x-default presence) were probe-matched to veraPDF.

9 unit tests (with-catalog-Lang passes / without fires, per clause). Full local gate
green: REQUIRE_VERAPDF=1 → 871 passed, 0 failed, 0 skipped; dotnet format clean.

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

A structure element whose /S is non-standard must be role-mapped to an ISO 32000-1
Table 333 standard type. UaNonStandardTypeRule fires when a walked StructElem has a
/S (RawType != null) that does not resolve to a standard type (StandardType == null
— i.e. non-standard and unmapped, or its RoleMap chain does not terminate at a
standard type).

FP-safety hinges on StructureTree.StandardTypes exactly matching veraPDF's
standard-type determination, so this was empirically verified before implementing:
all 47 of our standard types were probed (veraPDF fires 7.1-5 for NONE of them → set
is not too big), and 10 extended/PDF-2.0 candidates (Aside, Title, Sub, FENote, Em,
Strong, …) were probed (veraPDF fires 7.1-5 for ALL of them → it does not treat them
as standard, so our set is missing none). The set is therefore a complete superset
of veraPDF's determination — no over-rejection. The shared StandardTypes set was NOT
modified.

Oracle fixture (custom type role-mapped to a standard type → compliant) + 4 unit
tests (custom unmapped fires; role-mapped / multi-hop role-mapped / standard baseline
do not). Full local gate green: REQUIRE_VERAPDF=1 → 877 passed, 0 failed, 0 skipped;
dotnet format clean.

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

Two structure-tree rules, each clause-level cross-validated against veraPDF
1.30.2 (the oracle's boolean verdict alone cannot prove clause agreement, so
an independent adversarial pass confirmed veraPDF fires the SAME clause on
every negative case and accepts every valid variant):

- §7.4.2-1 (UaHeadingNestingRule): document-wide depth-first scan of H1-H6;
  fires when a heading level skips upward (n > previousLevel + 1). Matches
  veraPDF's flat pre-order algorithm — first-heading-H2, sibling-section, and
  per-section-reset cases all corroborated.
- §7.5-1/-2 (UaTableHeaderRule): a TD has a connected header when its Table
  has any scoped TH (/A /O=Table /Scope) or the TD's /Headers resolve to real
  TH /IDs. 7.5-1 when neither holds; 7.5-2 when /Headers reference unknown IDs.
  Implicit-grid tables (TH header row, no /Scope, no /Headers) are rejected by
  veraPDF on clause 7.5 — confirmed across 2x2/3-col/multi-row shapes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
Reuses the structure-tree walker for four previously-deferred PDF/A-2a Level A
clauses, each cross-validated against veraPDF 1.30.2 at the clause level (the
oracle's boolean verdict alone cannot prove clause agreement):

- §6.7.3.4-1/-2/-3 (A2aStructureTypeRule): element-scoped — a non-standard /S
  type must role-map to a Table-333 standard type (-1), its RoleMap chain must
  be acyclic (-2), and a standard /S type must not be remapped to a non-standard
  type (-3). Narrower than UA-1 7.1-7 (which fires on any standard remap), so it
  is a distinct rule, not an alias.
- §6.7.4-1 (A2aLangSyntaxRule): catalog and struct-elem /Lang values must be an
  empty string or a valid RFC 3066 / BCP-47 tag.

§6.7.3.4-3 resolves the FULL /RoleMap chain, matching veraPDF: a standard type
remapped through a non-standard intermediate that ultimately reaches a standard
type (/P -> /Foo -> /Span) is accepted. An immediate-target-only check was a
confirmed false positive (veraPDF passes that document); a multi-hop guard
fixture locks the corrected behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
Adds a marked-content interpreter to the content-stream scan (BMC/BDC/EMC
nesting, MC tags, Lang/ActualText/Alt/E properties, MCID, per-text-show
language context) and implements the four §7.2 marked-content natural-language
clauses on top, each clause-level cross-validated against veraPDF 1.30.2.

Language for a text item / marked-content sequence is determinable when:
catalog /Lang is present (short-circuit), or the enclosing MC sequence carries
a BDC /Lang, or the owning struct element (resolved via MCID → /ParentTree
page-MCID array → StructElem) or any of its ancestors carries a /Lang. The
struct-element path (new StructureTree.StructNodeForMcid + HasLangInHierarchy)
is essential for FP-safety: a UA-1 doc with no catalog /Lang but /Lang on the
struct element wrapping the text is accepted by veraPDF, and an earlier
content-stream-only version over-rejected it — a confirmed false positive now
guarded by a regression fixture.

Adversarially verified at the clause level: artifact / no-MCID text correctly
fires 7.2-34 (veraPDF treats it as a SETextItem subject to the check), a /Span
with /ActualText and no MCID correctly fires 7.2-30 (no MCID gating), and an
/Artifact BDC carrying its own /Lang is silent in both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
Two marked-content nesting rules on the committed interpreter, each clause-level
cross-validated against veraPDF 1.30.2:

- §7.1-1 (SEMarkedContent, Artifact): an Artifact BDC must not be nested inside
  tagged content — fires when an ancestor BDC carries an MCID that resolves to a
  struct element in the page /ParentTree.
- §7.1-2 (SEMarkedContent): tagged content (a BDC whose own MCID resolves in the
  /ParentTree) must not be nested inside an Artifact — fires when an ancestor BDC
  carries the Artifact tag.

The interpreter gains HasArtifactAncestor + AncestorMcid (computed from the
marked-content stack at push time, propagating the nearest tagged ancestor's
MCID through intermediate no-MCID sequences). Both rules fire only when an MCID
genuinely resolves in the ParentTree; an unresolvable MCID is skipped, so the
rules over-reject nothing veraPDF accepts. FP guards (top-level Artifact,
Artifact-in-Artifact, top-level tagged content, dangling MCID) confirmed silent
in both engines.

§7.1-3 (SESimpleContentItem) is deferred: its scope includes path-painting
operators that ContentStreamUsage does not yet model, so a text-show-only
implementation could not be proven complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
Adds the Adobe Glyph List (embedded resource, extracted from veraPDF 1.30.2's
bundled copy for exact parity; BSD-3-Clause, attributed in NOTICE) and a small
AOT-safe verbatim-membership resolver, and implements the three TrueType
cmap/encoding clauses, each clause-level cross-validated against veraPDF:

- §7.21.6-1: a non-symbolic TrueType embedded program must contain non-symbol
  cmap entries (nrCmaps > 0, or > 1 when a Symbol (3,0) subtable is present).
- §7.21.6-2: a non-symbolic TrueType must use MacRoman/WinAnsi encoding; with a
  /Differences array, every glyph name must be VERBATIM in the AGL and the
  program must contain a Microsoft Unicode (3,1) cmap. veraPDF's
  differencesAreUnicodeCompliant requires raw AGL membership — uniXXXX/uXXXXX
  and period-suffix names fail (empirically confirmed, both engines agree).
- §7.21.6-4: a symbolic TrueType program must have exactly one cmap subtable or
  include the Microsoft Symbol (3,0) encoding.

SfntMetrics now exposes cmap subtable count + (3,0)/(3,1) presence. All rules
are usage-scoped (fonts selected via Tf) and fire only when the program is
embedded and parseable; ambiguous Symbolic/NonSymbolic flag combinations are
skipped (FP-safe). Adversarially verified: absent /Encoding, /Encoding dict
without /BaseEncoding, and uniXXXX/period-suffix Differences names all fire in
both veraPDF and the rule (true positives, not FPs).

§7.21.7-1 stays deferred: veraPDF derives Unicode from encoding for standard-
encoded simple fonts, so a glyph-/ToUnicode requirement would over-reject.

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

Implements the glyph-width-consistency check for the one font form where
code→glyph-id resolution is exact and needs no encoding/cmap guessing:
composite Type0 fonts with /Encoding /Identity-H or /Identity-V, a descendant
CIDFontType2, /CIDToGIDMap absent or /Identity, and an embedded /FontFile2 —
so the shown 2-byte codes are CIDs and GIDs directly (same proven scope as
UaGlyphPresenceRule). The dictionary width comes from the CIDFont /W array or
/DW default; the program advance from hmtx via SfntMetrics.AdvanceWidth1000.

veraPDF predicate (object Glyph, 7.21.5-1): renderingMode == 3 ||
widthFromFontProgram == null || widthFromDictionary == null ||
|widthFromFontProgram - widthFromDictionary| <= 1. The rule fires at a
conservative > 2 threshold: the rule's Math.Round and veraPDF's rounding of
the same scaled advance can differ by up to 1 unit, so a ±2 margin guarantees
the rule never over-rejects a font veraPDF accepts at the ±1 rounding boundary
(cost: an exact-2-unit mismatch is not flagged — FP-safe).

Usage-scoped (fonts selected via Tf) and Tr-3 / Tr-(-1) exempt, matching
veraPDF. Simple fonts, non-Identity encodings, stream /CIDToGIDMap, and CFF are
deferred — their code→glyph-id resolution is not proven exact against veraPDF.
Clause-level verified: /W-removed mismatch fires 7.21.5-1; matching widths,
unused-font mismatch, and Tr-3-only rendering stay silent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
The marked-content interpreter resolved an MCID only from the inline form
`/Tag << /MCID n >> BDC`, never the named-reference form `/Tag /Name BDC`
(where /Name points at a /Resources /Properties entry carrying /MCID). veraPDF
resolves both, so named-reference BDCs left MarkedContentSequence.Mcid null.

That was a latent false positive in the committed §7.2-34 rule: in a document
with no catalog /Lang whose text is tagged via a named-reference BDC and whose
language is on the owning struct element, veraPDF accepts (it resolves
MCID → struct-element /Lang) but the rule fired 7.2-34 because the struct-/Lang
resolution was skipped when Mcid was null. Surfaced while pinning §7.1-3's
content-item operator set.

ContentStreamUsage now builds a per-page name → MCID map from /Resources
/Properties (inherited-resource aware) and populates Mcid for named-reference
BDCs; the existing AncestorMcid propagation flows through unchanged. A miss
leaves Mcid null exactly as before, so the change can only add a resolution,
never remove one (FP-safe).

Clause-level verified: the named-reference lang case is now silent (new oracle
fixture, veraPDF-confirmed) while a genuine no-/Lang violation still fires; the
§7.1-1/-2 fires it newly enables on named-reference Artifact nesting match
veraPDF (previously under-detected, no new FP); §7.2-30/31/32 unaffected. The
§7.1-3 deferral note is updated — named-reference resolution is no longer its
blocker; only the painting/image/shading content-item operator tracking remains.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
Completes the §7.1 artifact/content cluster. The interpreter now emits a
content item per real-content operator — text-show (Tj/TJ/'/"), painting path
ops (S/s/f/F/f*/B/B*/b/b*), inline images (ID…EI), image XObject Do, and sh —
capturing the marked-content context at emit time (effective MCID via the
nearest enclosing BDC, inline or named-reference, plus artifact ancestry).
Color/state ops, path construction without a paint, clip-only (W n), and
form-XObject Do deliberately do not emit — the operator set is probe-verified
against veraPDF 1.30.2.

UaSimpleContentItemRule fires §7.1-3 only when a content item has NO enclosing
BDC MCID at all AND is not inside an Artifact — definitively untagged real
content. This is intentionally a strict subset of veraPDF's predicate
(isTaggedContent == true || parentsTags.contains('Artifact')): any present MCID
is treated as tagged without resolving it through the /ParentTree, so a
malformed/partial ParentTree can never produce a false positive. It under-
detects the dangling-MCID case (FP-safe) and never fires on a document veraPDF
accepts. Page content streams only.

Verified across a 9-document FP battery (tagged text inline + named-reference
BDC, artifact decorations, image figure, path in tagged/artifact context,
color/clip ops, form Do, artifact-only, the writer's own UA-1 output) — all
silent in both engines; a bare painting op outside any BDC fires 7.1-3 in both
(veraPDF clause-confirmed oracle fixture).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RghjbtTZ9hn9P8A1JEgXVq
Bump VersionPrefix 1.7.3 → 1.7.4; CHANGELOG 1.7.4 entry; README coverage
updated (UA-1 ~71%→~90%, 2a ~87%→~90%). Build-verified veraPDF parity:
PDF/A-2b 90.3%, 2u 90.4%, 2a 89.9%, PDF/UA-1 90.1% (95 impl + 1 partial of 106).

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