Skip to content

Add automatic PDF/UA-1 (ISO 14289-1) support - #6

Open
jakejackson1 wants to merge 48 commits into
developmentfrom
ua1-support
Open

Add automatic PDF/UA-1 (ISO 14289-1) support#6
jakejackson1 wants to merge 48 commits into
developmentfrom
ua1-support

Conversation

@jakejackson1

@jakejackson1 jakejackson1 commented Jul 13, 2026

Copy link
Copy Markdown
Owner

This branch adds automatic PDF/UA-1 (ISO 14289-1) support to mPDF. When enabled, mPDF emits a tagged structure tree (headings, lists, tables, links, figures), marked-content and artifact bracketing, ARIA name resolution, a ToUnicode CMap for reliable text extraction, and conforming document metadata, so generated PDFs pass accessibility validation.

There are two modes. Strict mode ('PDFUA' => true) throws when a document cannot be made conformant, so authors catch problems at generation time. Auto mode ('PDFUAauto' => true) produces the most conformant output it can without throwing.

This PR also fixes a second round of conformance bugs found by reviewing the whole branch: FPDI tagged-import content references, encrypted-document metadata and paging, astral-codepoint text extraction, inline element tagging (links, language spans, abbreviations, ruby), table-cell and header-cell structure, ARIA naming and relationships, image-map hotspots, and locale-safe numeric output. Every fix ships with a regression test, and the veraPDF suite covers the shapes that previously failed.

Try it

require 'vendor/autoload.php';

// Strict mode: throws if the document can't be made PDF/UA-1 conformant.
// Use ['PDFUAauto' => true] for best-effort output without throwing.
$mpdf = new \Mpdf\Mpdf(['PDFUA' => true, 'title' => 'Quarterly report']);
$mpdf->WriteHTML('<h1>Quarterly report</h1><p>Revenue rose. <a href="https://example.com">See details</a>.</p>');
$mpdf->Output('ua1.pdf', \Mpdf\Output\Destination::FILE);
# Confirm the output validates as PDF/UA-1
verapdf --flavour ua1 ua1.pdf

Test plan

Run on PHP 8.2 (the PHPStan baseline targets 8.2):

  • composer install
  • Full suite passes: vendor/bin/phpunit
  • veraPDF conformance passes: VERAPDF_BIN=$(command -v verapdf) vendor/bin/phpunit --group=verapdf
  • Security suite passes: vendor/bin/phpunit --group=security
  • Static analysis clean: vendor/bin/phpstan analyse
  • The snippet above produces a file veraPDF reports as ua1 compliant
More info

Verification (PHP 8.2)

Gate Result
PHPStan 0 errors
Full suite 1482 tests, 3768 assertions, OK
veraPDF (--group=verapdf) 63/63
Security (--group=security) 78 tests, 101 assertions, OK

Test count grew from 1395 to 1482 with new regression and conformance fixtures.

Structure and tagging

  • Inline struct elements (links, language spans, <abbr>, ruby) receive their own marked content instead of the enclosing block owning the text.
  • Block content inside table cells is tagged; <th> header associations (Scope, Headers, id) are built on the TH element itself.
  • THead/TBody/TFoot row groups are emitted; ordered lists carry /ListNumbering derived from list-style-type.
  • Floated blocks are tagged in reading order rather than demoted to artifacts.
  • Link annotations created in an artifact scope (running headers/footers) no longer attach an OBJR to the document root.

Names and relationships

  • aria-labelledby/aria-describedby resolve to real descendant text and never emit an empty /Alt.
  • Alt-absent <img> is named from ARIA/title instead of aborting (strict) or hiding it (auto).
  • aria-owns/aria-controls emit /Ref; aria-flowto/aria-activedescendant warn rather than being silently dropped.

Import and encryption

  • FPDI tagged imports keep their content references — bare-integer /K MCIDs, and Form-XObject MCRs carrying /Pg and /Stm.
  • Encrypted sources recover their real page count and draw visible placeholders; untagged imports are signalled and can carry an author-supplied /Alt figure.
  • Encrypted metadata streams use a /V 4 Identity crypt filter instead of shipping corrupt XMP.

Text and geometry

  • The ToUnicode CMap emits 2-byte source tokens for astral codepoints, fixing text extraction for all documents — not only UA output.
  • Rotated and polygon image-map hotspots emit /QuadPoints; /BBox and numeric attributes format locale-independently.
  • spl_object_hash replaces spl_object_id for FPDI cycle keys (PHP 5.6+); PDF-name sanitisation truncates on #xx token boundaries.

Modes and hygiene

  • OverWrite() warns in auto mode instead of throwing; SetVisibility() preserves the declared version under PDF/A and PDF/X.
  • Sparse ParentTree keys are preserved. Dead code removed, the page-ref map cached, and ARIA/SVG helpers deduplicated.

Known follow-up

One cleanup was intentionally left out. Several artifact-emitting sites use divergent mechanisms (imperative writer vs string-buffer accumulation, BMC vs BDC, interleaved optional-content sequences), so collapsing them into a single withArtifact() bracket risks changing veraPDF-validated output. It is cosmetic and tracked for a dedicated pass with a byte-level parity diff.

See CHANGELOG.md for the full itemized list.

shapeyourbits and others added 3 commits March 26, 2026 21:08
… placeholders

When {PAGENO} or {nb} placeholders appear in right- or center-aligned
body content, mPDF calculated text positions based on the placeholder
string width (8 and 4 characters respectively) rather than the actual
rendered page number width. This caused misaligned text that shifted
further left as the placeholder-to-number width difference grew.

The fix replaces placeholders with current/estimated page numbers before
computing string widths in finishFlowingBlock(), so alignment offsets
are based on the final rendered text width. The actual placeholder text
in the PDF stream is still replaced by aliasReplace() during output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
    Pagebreaklookahead reset: also reset the lookahead height
    add unit test
    table rendering: if do-not-break block is bigger than one page, do not break page after each element
    table rendering: tr with page-break-{before,after} avoid will compute exact line heights to decide whether to break
@jakejackson1 jakejackson1 changed the title PDF/UA-1 (ISO 14289-1) support + A–E audit remediation Add automatic PDF/UA-1 (ISO 14289-1) support Jul 13, 2026
jakejackson1 and others added 26 commits July 13, 2026 13:21
Add Claude file

Add memory files

Refine UA support plan

Update namespace

Refine plan

Refine plan so the code will be appropriately documented

Start building

UA 1 support

Refine UA 1 support

PDF/UA-1: emit /Pg + /P on struct elements + add real-world veraPDF tests

Two structural fixes in StructureWriter that were causing veraPDF to flag
all content as "untagged real content" (Matterhorn 01-006):

* /Pg is now emitted on struct elements whose /K collapses to a bare-integer
  MCID. ISO 32000-1 §14.7.2 Table 322 requires /Pg so validators can resolve
  which page's content stream the MCID lives in.
* /P (parent ref) is now emitted on the Document root struct element pointing
  at the StructTreeRoot. ISO 32000-1 §14.7.2 Table 322 requires /P on every
  struct element except the StructTreeRoot itself; without it veraPDF cannot
  traverse the tree from the root downward.

Side effect: removed the redundant `BT /Fn N Tf ET` pre-amble emitted by
SetFont (4 sites in Mpdf.php). `Tf` is a text-state operator that may appear
outside a BT/ET text object (ISO 32000-1 §9.3.1); the empty wrapper created a
SimpleContentItem inside open BDC sequences with no painting operation.
Issue538Test byte-pattern assertions updated to match.

Test infrastructure:

* VeraPdfConformanceTest now handles the veraPDF 1.30+ JSON schema where
  validationResult is an array (one entry per profile) rather than a single
  object. Removed the --off CLI flag that disabled validation entirely.
* Added 16 real-world tests, one per mpdf-example listed in the plan's
  "Test Inputs" table — fixtures live in tests/data/html/pdfua-examples/
  with README.md provenance + regen recipe. Together with the 11 hand-rolled
  tests this brings the verapdf group to 27 tests; 9 currently pass against
  veraPDF 1.30.0 (was 0 before this change).

Plan §3b updated to describe the lazy-open per-page BDC contract introduced
in the previous commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

PDF/UA-1: unblock 3 verapdf tests that errored before validation (Round 1)

* testFpdiTier1ImportPassesUa1: switch from the deprecated SetImportUse() +
  two-arg ImportPage() API to the modern setSourceFile() + importPage() flow
  used by FpdiTrait — enableImports config already activates FPDI mode
* testExample01BasicPassesUa1: add missing close-fence in newFlowingBlock()
  reset so that BDCs opened before a BR-driven line break are always closed;
  finishFlowingBlock(false) (non-endofblock path) doesn't fire the close-fence,
  leaving depth > 0 at document end and triggering the "Unbalanced marked
  content operators (depth=2)" exception under multi-line <pre> and similar
  blocks that use BR internally
* testExample08ListsPassesUa1: guard list_style_position / list_style_type /
  list_style_image reads in BlockTag::open() with isset() defaults — long-
  standing latent notice exposed only by PHPUnit's strict error handler when a
  bare <li> appears outside a <ul>/<ol> context

verapdf group: 3 -> 0 errors. Errors-to-failures: the 3 previously-erroring
tests now reach veraPDF and surface real rule violations (Rounds 2-5 work).
No regressions in the 1070-test full suite or 162-test PDFUA group.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

PDF/UA-1: drive verapdf failures from 15 to 1 (Round 2)

§7.1 untagged content
- Wrap PaintDivBB, PrintPageBackgrounds, PrintTableBackgrounds, _tableRect,
  and the <hr> stroke in /Artifact BMC … EMC so block backgrounds, table
  cell borders, and horizontal rules pass §7.1 test 3.
- Add restoreFlowingBlockPdfuaState() and call it after every
  newFlowingBlock() inside printbuffer so <br>-separated lines get a
  per-line BDC reopened from the block's saved struct element.
- Auto-wrap loose body-level inline text (text directly inside <body>
  with no enclosing block tag, plus inter-image whitespace) in an
  implicit P struct element so each Tj is reachable from the structure
  tree.
- _printobjects() now closes and reopens the host block's BDC around
  decorative-image /Artifact BMC sequences so the no-overlap rule
  (Matterhorn 01-001/002) is preserved.
- _tableWrite() now pushes the TD/TH struct element onto the open
  stack before flushing the cell's textbuffer, so deferred Figure
  opens (cell <img>) attach to the cell instead of the parse-time
  stack top — fixes §7.2 test 3 (Table containing Figure children).

§7.2 test 43 column count
- Emit /ColSpan and /RowSpan attributes on TD/TH struct elements when
  the HTML uses colspan/rowspan, so verapdf's row-equality check
  reflects the visual column span.
- Add StructureTree::discardTop() and call it from Th::open() to
  remove the inherited TD from the parent TR's children before
  pushing the TH replacement; otherwise the discarded TD remains in
  /K and adds a phantom column.

§7.2 test 29 /Lang
- Catalog /Lang now goes through writer->string() so the bytes are
  RC4-encrypted with the catalog's object key when SetProtection is
  active. Previously the literal `/Lang (en-US)` was written and
  verapdf decrypted garbage.

§7.18.5 link tagging
- Add $pdfuaLinkStructElem on Mpdf, captured by Tag\A::open(),
  propagated through textbuffer entry [19] and saveFont/restoreFont
  so chunks buffered by WriteFlowingBlock keep the reference alive
  through the deferred Cell()→Link() emission.
- writeAnnotations() reads the captured element off the PageLinks
  entry, allocates a /StructParent integer, adds an OBJR kid to the
  Link struct element, and stamps /F 28 + /StructParent on the
  annotation dict (Matterhorn 02-003).
- Unify the page /StructParents counter and the annotation
  /StructParent counter via UaState — they share the same
  ParentTree NumTree, so two separate 0-based counters caused page
  MCID arrays and annotation references to overwrite each other in
  /Nums.

§7.18.1 form widget /TU
- _putform_tx, _putform_bt, and _putform_ch fall back to the field
  name (T) when /TU is empty, treating mPDF's UTF-16BE BOM-only
  encoding as empty for the check.
- _putform_ch now also emits /TU (it was previously absent on
  choice/select widgets).

§7.18.1 + §7.9 sticky-note annotations
- Sticky-note (and FileAttachment) annotations now use the Annot
  struct element instead of Note. Note is the footnote tag and
  requires /ID per §7.9 / Matterhorn 09-006; Annot is the correct
  general-purpose container.

Test fixes
- AnnotationsAndMiscTest: rename Note assertions to Annot; rewrite
  the /Nums regex so it captures past nested page-array brackets to
  reach annotation entries.

Status
- composer test: 1077 / 1077 pass.
- VERAPDF_BIN=verapdf vendor/bin/phpunit --group=verapdf:
  26 / 27 pass. Remaining failure is testExample64Protected
  (encrypted + compress=false), which passes with compression on —
  pending investigation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

PDF/UA-1: re-enable compression for testExample64Protected

veraPDF mis-parses RC4-encrypted, FlateDecode-disabled content streams
when the stream contains multiple lines of tagged text: every line beyond
the first is reported as untagged real content under §7.1 test 3 even
though the BDC/EMC pairing is correct and the decrypted bytes are
byte-identical to the unencrypted stream.

Reproducible at minimum scope (one H1 + one wrapping <p>):
- compress=true,  encrypt=true:  129 / 129 checks pass
- compress=false, encrypt=false: 128 / 128 checks pass
- compress=false, encrypt=true:  101 pass, 8 fail (§7.1 test 3)

pdftotext extracts the text correctly, and the decrypted page-content
stream diffs cleanly against the unencrypted version — so the failure
is not an mPDF tagging defect. PdfUaTestCase forces compress=false so
other tests can grep operators in raw bytes; this test does not
inspect the stream and the fixture-with-compression matches real-world
usage (production PDFs are virtually always compressed).

Re-enabling compression for this one test preserves its intent:
verifying that SetProtection() keeps the accessibility-extract
permission bit set (Matterhorn 07-001) and leaves the XMP stream
unencrypted (Identity crypt filter).

Status: composer test 1077 / 1077 pass, veraPDF group 27 / 27 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

PDF/UA-1: fix indentation of pdfuaLinkStructElem reset

The replace_all that introduced the per-iteration reset of
pdfuaLinkStructElem in printbuffer's resetting-values block produced
a line indented 2 tabs instead of the surrounding 3 — composer cs
flagged the inconsistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

PDF/UA-1 Phase 5: strict-mode throws + ValidationTest

Hard violations now throw \Mpdf\MpdfException when PDFUAauto is false
(strict mode) instead of silently emitting a non-conformant document.
PDFUAauto=true preserves the previous warn-and-continue behaviour by
recording the diagnostic in $mpdf->getPdfUaWarnings().

Two violations gain strict-mode throws:

- <img> with no alt attribute and Image() called without $alt
  (ISO 14289-1 §7.3 / Matterhorn 13-004) — intent cannot be guessed,
  caller must pass alt="" for decorative or alt="description" for
  content. Auto mode falls back to Artifact.

- SetJS() / <script> embedding (Matterhorn 17-001) — document-level
  JavaScript can interfere with assistive-technology behaviour and is
  not permitted in PDF/UA-1. Auto mode drops the script with a
  warning.

ValidationTest covers each path:

| test                                          | expectation |
|-----------------------------------------------|---|
| testImageMissingAltAddsWarning                | auto: warning recorded |
| testImageMissingAltThrowsWhenStrict           | strict: MpdfException |
| testImageEmptyAltAcceptedAsDecorative         | alt="" never warns |
| testJavaScriptEmbedAddsWarning                | auto: warning recorded |
| testJavaScriptEmbedThrowsWhenStrict           | strict: MpdfException |
| testOverWriteThrowsInPdfuaMode                | always throws |
| testHeadingLevelSkipAddsWarningInAutoMode     | auto: warning recorded |
| testHeadingLevelSkipThrowsInStrictMode        | strict: MpdfException |
| testMissingLangThrowsInStrictMode             | strict: MpdfException |
| testMissingLangFallsBackInAutoMode            | auto: /Lang (en-US) + warning |
| testEncryptionExtractBitPreservedInAutoMode   | auto: bit 10 force-set |
| testEncryptionExtractBitMissingThrowsInStrictMode | strict: MpdfException |

Status:
- composer test: 1089 / 1089 pass (was 1077; +12 new validation tests).
- veraPDF group: 27 / 27 pass.
- composer cs: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

PDF/UA-1 Phase 5: comprehensive smoke + warning-accumulation tests

Two additions to ValidationTest:

testAllExamplesRenderWithoutExceptions
  Loops every fixture in tests/data/html/pdfua-examples/ and renders it
  in PDFUAauto mode. Catches PHP-level fatals and exceptions for every
  pattern the project ships an example for. Per the plan §A9, this is
  a PHP smoke test (PDFUAauto converts violations to warnings) — the
  veraPDF gate is covered separately.

testMultipleViolationsAccumulateWarnings
  Triggers three independent violations in one document (heading-level
  skip, image without alt, SetJS) and asserts UaState::addWarning()
  records at least one warning per violation. Guards against state
  leaks between violation types in the warning system.

Also tagged the class with @group pdfua so it groups alongside the
other UA test files (AnnotationsAndMisc, ContentStream, etc.).

Status: 1091 / 1091 PHPUnit pass (was 1089), composer cs clean,
veraPDF group still 27 / 27.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

PDF/UA-1 Phase 5: assert ToUnicode CMap on every embedded font

ISO 14289-1:2014 §7.21.4.1 / Matterhorn 09-006 — every font used to
render real content must provide a /ToUnicode CMap so assistive
technology can recover the underlying character codes from glyph
indices. mPDF already emits /ToUnicode for /Type0 and /TrueType font
wrappers (FontWriter.php), but no test guarded against future
regressions.

Added testFontSubsetToUnicodeCoverage which:
  1. Generates a PDF/UA document with an embedded TrueType font.
  2. Greps every /Type /Font dict whose /Subtype is Type0 or TrueType
     (CIDFontType2 descendants inherit the wrapper's CMap and don't
     need their own).
  3. Asserts each dict references /ToUnicode.

Status: 1092 / 1092 PHPUnit pass, composer cs clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

PDF/UA-1: extend veraPDF coverage with 10 more mpdf-examples (27 → 37)

Adds fixtures + conformance tests for examples 03_backgrounds_and_borders,
09_forms, 11_overflow_auto, 18_headers_method_4, 19_page_sizes, 20_justify,
21_hyphenation, 23_orientation, 24_orientation_2, 38_dot_tab. Two real
tagging defects surfaced and were fixed:

- PrintBodyBackgrounds() now wraps body-level background color/gradient/
  image in /Artifact BMC…EMC under PDFUA mode (ISO 14289-1 §7.1 / Matterhorn
  01-002). Previously these decorations were rendered above the page-
  background BMC and reported as untagged real content (verapdf §7.1 test 3).

- Form::_putRadioItems() now emits /TU on the radio-group parent annotation
  with the field-name fallback when no tooltip is supplied (Matterhorn 19-003).
  Previously radio groups were the only AcroForm field type without /TU,
  causing 7-failure "form field shall have a TU key" reports.

ValidationTest::testAllExamplesRenderWithoutExceptions now sets
useActiveForms=true for example09_forms so the fixture's <input>/<select>
elements emit AcroForm widgets with embedded fonts rather than falling back
to non-embeddable core fonts under PDFUA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

PDF/UA-1: extend veraPDF coverage with 4 more mpdf-examples (37 → 41)

Adds fixtures + conformance tests for examples 02_CSS_styles (CSS classes,
font-variant: small-caps, font-kerning), 35_watermarks (text watermark via
SetWatermarkText), 37_barcodes (EAN-13/ISBN/UPC-A/EAN-8/RM4SCC/POSTNET/CODE
128 B/CODE 39/QR), 66_custom_properties (XMP customProperties config +
AddCustomProperty runtime calls).

All four fixtures pass veraPDF on first generation — no underlying tagging
defects surfaced by this round.

README extended with an "intentionally not covered" section documenting the
reasons the remaining upstream examples are excluded (non-bundled fonts,
`onlyCoreFonts` conflict with PDF/UA-1, missing FPDI source PDFs, Guzzle/DI
demos that don't exercise the rendering surface, and the smoke/scratch
example_test.php).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ruby annotation parts previously all mapped to Span (parked as "v2
deferred"). They now use their standard PDF struct types (ISO 32000-1
§14.8.5.6 Table 337): <ruby> -> Ruby, <rb> -> RB, <rt> -> RT, <rp> -> RP.
<rtc> has no PDF equivalent and renders transparently -- its <rt>
children attach directly to the Ruby -- taking a Span only when
lang/aria-label forces a host element.

StructType gains the Ruby/RB/RT/RP and Warichu/WT/WP standard types in
its valid-types whitelist; the ruby tag handlers open their element
beneath the enclosing Ruby. A bare-text base (no <rb>) attaches content
directly to the Ruby.

Verified veraPDF ua1 PASS on rb+rt, rp fallback, bare-text base,
lang-tagged rt, <rtc>, nested ruby, ruby in <h1>, and ruby in <a href>.
Adds a conformance case plus struct-element and tag-map regressions; the
six existing PoorHtmlAutoModeTest ruby probes stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Captures every issue from the branch audit as an ordered, no-deferrals
remediation plan (Phase A P0 corruption/data-loss, Phase B P1 veraPDF
FAILs, Phase C P2 requirement gaps, Phase D P3 hygiene). Each item lists
root cause, fix approach, files, verification, and size. C1a (ruby
standard struct types) is marked shipped; C1b (ruby visual stacking) is
committed as in-scope remaining work rather than deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A non-hyperlink <a> (destination anchor or empty/whitespace href) that
carried lang= / aria-label= opened a Span struct element but pushed no
strip frame, so Tag\A::close() -- which only pops when the strip stack is
non-empty -- never closed it. The leaked Span stayed on the struct-element
stack and swallowed every following block: the next <p> nested inside the
previous one instead of being its sibling, corrupting reading order for
the rest of the document.

Tag\A::open() now pushes exactly one strip frame in the non-hyperlink
branch (depth 1 when a Span was opened, depth 0 otherwise) so close()'s
single pop is always balanced 1:1 with open().

The corruption is invisible to veraPDF (P-inside-P is not a UA-1
violation), so the regression asserts the in-memory struct tree directly:
both paragraphs must be siblings under the Document root.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…udit A2)

DT/DD auto-open an implicit LI when the DOM omits it
(<dl><dt>…</dt><dd>…</dd></dl>). This was tracked with a single global
bool on UaState, so a <dl> nested inside a <dd> shared the outer list's
flag: the inner <dt> saw the flag set and closed the *inner* L instead of
a previous implicit LI, dropping the inner LI and leaving Lbl/LBody as
bare children of L. veraPDF flags this as clause 7.2 test 18 ("LBody
should be contained in LI").

Replace the bool with a per-<dl> frame stack (UaState::$implicitLIStack).
Dl::open() pushes a frame, Dl::close() closes any open implicit LI for its
frame then pops it; DT/DD read and set only the top frame, so each list
level is independent.

Adds a raw-bytes struct regression (two /S /LI for a nested <dl>) and a
nested-<dl> veraPDF conformance case; the flat-<dl> path stays green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t A3)

importPage() reads from whichever source setSourceFile() set last, but the
Tier 0 encrypted-placeholder check resolved the source via
currentEncryptedSourceKey(), which returned end($keys) -- the most
recently *flagged* encrypted key -- ignoring which reader is actually
active. So once any source was flagged encrypted, every later import from
a valid source hit the placeholder fast path and its page was silently
dropped (data loss).

Track the key of the last setSourceFile() target (set on both the success
and the caught-encrypted paths) and test *that* against the encrypted set.
A valid source set after an encrypted one now imports for real; a genuinely
encrypted active source still gets the placeholder. The late-detected
encryption path flags the active source so repeated pages stay consistent.
Removes the now-unused currentEncryptedSourceKey().

Adds an enc-then-valid regression asserting the valid import returns a real
Form XObject id (not a placeholder) and merges as Tier 2; the single-source
and strict-throw cases stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GSUBsubstitute() recorded GPOSinfo['ligature_source'] on every ligature,
even outside PDFUA. Only the ActualText writer (PDFUA) consumes it, and
recording it force-created a previously-empty GPOSinfo, which pushed
non-PDFUA ligature runs off the Tj fast path onto applyGPOSpdf's TJ path
(Mpdf render routing keys on !empty($OTLdata['GPOSinfo'])) for no benefit.

Wrap the capture in if ($this->mpdf->PDFUA). Non-PDFUA ligature text now
renders via a simple Tj show operator again; the PDFUA ActualText path is
unchanged. Extends the PDFUA-disabled ligature test to assert the Tj fast
path (no phantom TJ); the fi/ff/ffl ActualText cases stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
composer phpstan runs on PHP 8.2 in CI (.github/workflows/static-analysis
.yml). Two problems made it red there:

- Commit 471ad23 removed two baseline entries ("+"/"-" between string and
  (float|int), src/Mpdf.php) while editing the baseline on PHP 7.4, where
  phpstan does not infer those operands as string. On 8.2 the errors are
  real and pre-existing (Mpdf.php:24904/24906/25126, 2015-2016 code), so
  the entries are still needed.
- Two "*NEVER*" comparison entries (Mpdf.php, Tag/Table.php) went stale and
  were reported as ignore.unmatched on 8.2.

Regenerate the baseline on PHP 8.2 so it matches the CI toolchain: restores
the two binaryOp entries, drops the two stale *NEVER* entries. phpstan now
reports no errors on 8.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (D3)

While updating UA1_SECURITY_AUDIT.md to reflect the remediated state, the
M-5 finding turned out not to have been fixed: the imported-string sanity
gauntlet still used a strict `> 0.5` suspicious-codepoint threshold, so the
exact 50-legible + 50-U+FFFD PoC (ciphertext-through-PDFDocEncoding) still
passed. Tighten to `>= 0.5` so the boundary case is rejected. The gauntlet
is a forward-compat guard (FPDI refuses encrypted sources today), so this
is not reachable yet, but the fix closes the demonstrated PoC.

Adds Security/SanityGauntletThresholdTest.php pinning the boundary. Updates
the audit doc: all HIGH (H-1..H-3) and MEDIUM (M-1..M-5) findings are now
fixed and mapped to their regression tests; the DO-NOT-MERGE block is
lifted. H-1..H-3, M-1..M-4 were already remediated and tested; M-4's node
budget (NODE_BUDGET + sanityVisited) verified in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The deferred image-map queue is drained at the end of WriteHTML(), where
$mpdf->hPt holds the final page's height. Mpdf::Link() flips y with the
live hPt, so a hotspot on an <img usemap> placed on one page size lands at
the wrong y when the document ends on a page of a different size /
orientation (e.g. portrait host image, landscape final page: ~246pt off on
A4).

Capture the host page height at queue time (printobjectbuffer(),
'pageHpt' => $this->hPt) and restore $mpdf->hPt around the emit in drain(),
alongside the existing $mpdf->page save/restore. New ImageMapTest case
asserts a portrait-page hotspot's /Rect yTop is flipped with the portrait
height even when a landscape page follows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Barcodes, text-circles and images drawn inside a multi-column region
were forced into the Artifact scope by an extra `|| $this->ColActive`
guard, so they were never emitted as tagged /Figure content. The column
buffer replays the BDC/EMC marked-content pairs intact, so the guard was
unnecessary. Dropping it lets graphical objects inside columns produce
proper /Figure structure elements with /Alt text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… B2)

Link annotations created outside a Tag\A scope — direct Mpdf::Link() PHP
API calls, TOC entries, FPDI-imported page links — carried no captured
Link struct element, so writeAnnotations() left them untagged and veraPDF
flagged ISO 14289-1 §7.18.5 test 1 ("Links shall be tagged").

writeAnnotations() now synthesises a Link struct element for any link
annotation whose PageLinks entry has no 6th-slot struct reference,
attaching the annotation via OBJR + /StructParent. The annotation's
/Contents (already set for PDFUA) supplies the accessible name, and
pruneEmptyLinks() keeps the element because it carries an OBJR ref.
Anchor links that already provide their struct element are untouched, so
they are never double-tagged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hotspots on a rotated or CSS-transformed <img usemap> were dropped with a
warning (and strict mode did not throw) — a documented limitation. They are
now tagged: printobjectbuffer() captures the exact $tr (rotate) + $tr2
(transform) content-stream matrix mPDF renders the image with, and
ImageMapRegistry::buildHotspotMatrix() replays that placement (image cm
folded with the captured matrices, ISO 32000-1 §8.3.4) to map each area's
pixel-space corners into device space. emitForImage() emits them as a
/QuadPoints quad on the Link annotation; /Rect is the quad's bounding box.
Axis-aligned images keep the plain /Rect path unchanged.

Mpdf::Link() gained an optional device-space $quadPoints (7th PageLinks
slot) and writeAnnotations() emits /QuadPoints when present. The warn-and-skip
branch is removed.

Hotspot quads land on the rendered image corners to <0.001pt (verified by
composing the PDF's own cm operators independently of the production code)
for rotate 90/-90/180, CSS rotate, and CSS skewX. veraPDF ua1 PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
C1a tagged ruby with the correct standard struct types; the rt still flowed
linearly inline after the rb. It now stacks visually above the base.

Tag\Ruby/Rt/Rp mark each run's textparam['ruby'] (base|rt|rp), carried
end-to-end via saveFont/restoreFont. RT gains a DefaultCss font-size:50% and
Tag\Rt sets a text-baseline raise, reusing the <sup> machinery so
_setInlineBlockHeights() reserves the ascent and Cell() paints the annotation
raised for free. Mpdf::_buildRubyClusters() pairs consecutive ruby runs into
clusters (advance = max(base, annotation)) and the placement loops in both
finishFlowingBlock() and WriteFlowingBlock() centre the base group, centre the
raised annotation over it with zero net advance (a wider annotation centres
the base under it), and suppress rp parentheses (keeping the RP struct element).
Ruby runs are excluded from justification stretch (SetSpacing(0,0)) so the
painted base width matches the cluster advance and following text never overlaps.

All stacking logic is gated on the per-run marker, so non-ruby text takes the
unchanged code path (verified byte-identical: full suite 1395 green). Geometry
is asserted straight from the content stream in RubyStackingTest (8 cases):
annotation raised + centred, narrow annotation adds no advance, wide annotation
expands the cluster, ascent reserved, rp suppressed, wrapped-line ruby stacks,
justified ruby does not overlap. C1a struct tags + veraPDF ua1 retained
(gate 49 green); phpstan (PHP 8.2) clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
No-slop follow-up to the C1b commit: the 'widthZero' directive key was set on
rt/rp chunks but never read (the contentWidth delta uses 'measuredWidth'), and
the $clusterKeys array was accumulated only to read [0]. Remove the dead key,
document 'measuredWidth', and capture the cluster's first key directly. Pure
refactor — RubyStackingTest + the veraPDF gate stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… plan

Add Phase E (24 second-round findings, P0–P3) and a dated status banner
marking Phases A–D complete with green gates on the PHP 8.2 toolchain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
E1: normaliseKidsToArray() now wraps a bare-integer /K so single-MCID
imported struct elements reach the per-kid handler instead of cloning
empty. E5: writeElement() prefers the patched pageRef so Form-XObject
MCRs emit /Pg + /Stm per Table 324 instead of dropping to a bare integer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…it E3)

Switch PDF/UA documents to a /V 4 security handler with /EncryptMetadata
false so the Identity crypt filter on the XMP stream is a valid per-stream
bypass; the legacy /V 1|2 handler had no crypt filters and readers decrypted
the plaintext pdfuaid:part/dc:title into garbage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…audit E4)

The Identity-H bfchar loop used the raw Unicode scalar as the source token,
so a supplementary-plane codepoint produced an odd-width <1F600> token that
violated the <0000> <FFFF> codespacerange and made the whole ToUnicode stream
unusable, breaking text extraction for every glyph. Source tokens are now the
2-byte codes the content stream shows (BMP identity; astral split into its two
self-mapped UTF-16BE surrogate units), de-duplicated and guarded to the 2-byte
codespace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d sources (audit E2)

Auto mode now scans the encrypted source's cleartext page tree to honour its
real page count and draws a bordered, captioned Artifact placeholder per page
instead of one blank page; strict mode still throws. Fixes silent data loss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A hyperlink in a running header/footer or aria-hidden subtree ran in an
artifact scope where StructureTree::open('Link') is a no-op but getCurrent()
returned the Document root; capturing that hung the annotation OBJR/StructParent
off the Document root with no Link element (silent 7.18.5 / Matterhorn 02-003
FAIL). Artifact content cannot host a tagged link, so drop the annotation
(visible text stays as artifact) while body links still self-tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t E10)

A CSS float is a visual-positioning hint, not an accessibility one. Drop the
auto-artifact demotion in BlockTag::open() so floated content is tagged with
its normal struct type; role="presentation" stays the explicit opt-out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… E13)

spl_object_id() is PHP 7.2+, but composer.json still advertises 5.6/7.0/7.1,
so a tagged FPDI import fatalled on those versions. Swap both cycle/identity
key call sites to spl_object_hash() (PHP 5.x+); the two sites still agree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build the length-capped prefix token-by-token and stop before a token that
would overflow the limit, so a fixed-offset cut can no longer split a #xx
escape into a bare '#' and violate ISO 32000-1 §7.3.5 name production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… E8)

Retain block text on struct elements so aria-labelledby/-describedby
resolve to the target's actual text; a missing/empty target now throws in
strict mode and warns in auto instead of emitting a content-hiding empty
/Alt or /E (ISO 32000-1 Table 322, Matterhorn 13-004/28-002).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jakejackson1 and others added 19 commits July 13, 2026 13:24
… E6)

Bracket each flowing-text run in the BDC of the innermost inline struct
element that owns it (Link / lang-Span / Abbr /E-Span / Ruby RB·RT) instead
of tagging everything against the block, so the Link owns its text MCID plus
the annotation OBJR and each inline element owns its content rather than an
empty /K (ISO 14289-1 §7.18.5 / §7.2, Matterhorn 02-003 / 11-001).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the !tableLevel gate so a heading or list inside a <td>/<th> opens
its real H2 / L / LI struct element beneath the cell and its headings reach
the §7.4.2 sequence tracker; attribute each cell chunk's MCID to its owner
(reusing E6's inline BDC path), wrap cell <li> content in an LBody and
<dt>/<dd> in an implicit LI, and unwind cell frames at the cell boundary so
HTML's omitted end tags never corrupt the struct stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…audit E11)

The printobjectbuffer() alt-absent branch threw (strict) or demoted the image
to a decorative Artifact (auto) without consulting the accessible-name sources.
It now checks aria-labelledby (deferred via AriaIdResolver) / aria-label / title
in WAI-ARIA precedence and opens a named Figure; only a nameless image throws.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Factor Td::open()'s struct wiring into an overridable pdfuaOpenCellStruct()
so Th builds its TH once and registers its id/Headers/Scope against it,
behind the isInArtifact() guard - instead of round-tripping through a
throwaway TD that stole the id and dropped header associations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e (audit E15)

Tier 1 wrapped an imported untagged page wholesale in /Artifact <</Type
/Layout>> BDC..EMC with no signal in either mode. Auto now warns naming the
source page, strict throws, and useImportedPage($id, ['alt'=>...]) tags the
page as a captioned Figure carrying an accessible name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Push THead/TBody/TFoot struct elements from the section tag handlers and
nest TR beneath them; synthesise a TBody for rows written directly under
<table>, with StructureTree::closeRowGroup() as the Table::close backstop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t E17)

The StructureWriter /List attribute branch never fired because no handler set
ListNumbering. Ol/Ul now records the resolved marker style (decimal->Decimal,
lower-alpha->LowerAlpha, disc->Disc, …) on the L struct element so the writer
emits /A <</O /List /ListNumbering …>>.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cendant (audit E18)

Resolved ARIA relationships were stashed under attributes['_aria_relationships']
and never written. aria-owns/aria-controls now emit a struct /Ref; aria-flowto and
aria-activedescendant (no static PDF/UA-1 representation) warn visibly. Relationships
live off the attributes map so no _aria_* marker key leaks into output dicts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route unknown-usemap, malformed <area coords>, and the >1024-iteration
disambiguation guard through a new enforce() helper: strict throws naming
the offending map/area, PDFUAauto warns and skips. The registry no longer
warns unconditionally, so strict mode fails loudly per the branch contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
shapeToRect() collapsed poly/polygon areas to their bounding box, so the
Link activated non-hotspot area. Ear-clip the polygon into triangles and
map each through the C2 pixel->device matrix (buildAxisAlignedMatrix for
un-rotated hosts), emitting one degenerate quad per triangle via the shared
emitQuads(); /Rect stays the bounding box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OverWrite() threw even when PDFUAauto was set. In auto mode it now
addWarning()s that binary overwrite cannot preserve the structure tree
and proceeds; strict mode still throws.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(audit E22)

SetVisibility() guarded on the stale current visibility, letting the first
non-visible call emit an OCG under PDF/A/PDF/X. Factor a shared
allowOptionalContent() helper (used by BeginLayer too) that blocks OCGs
and preserves the declared base version for PDF/A, PDF/X and PDF/UA.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
writeParentTree() built each page value array positionally, so an imported
MCR's non-dense MCID shifted every later ref and mis-mapped MCIDs to elements.
Index the value array by MCID (null-pad gaps); dense maps stay byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…it E24)

buildAttrObject() concatenated PHP floats directly, so a comma-decimal
locale emitted "1,5" and corrupted /BBox. Route numerics through a
formatNumber() helper using locale-safe sprintf('%.3F', ...).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… test (audit E gate)

phpstan: guard $structType with isset() at BlockTag.php:582 (it is only
assigned on the non-DT/DD PDFUA path, mirroring the adjacent isset($elem)).

phpunit: ValidationTest::testOverWriteThrowsInPdfuaMode still asserted the
pre-E21 unconditional-throw contract using PDFUAauto=true. Audit E21
(commit d7ea29b) deliberately made auto mode warn-and-return, so the
strict-throw assertion now runs in strict mode (PDFUAauto=false); auto
mode is covered by PdfUaModeTest::testOverWriteInAutoModeWarnsAndReturns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Scope AddCustomProperty key validation to PDFUA; restore anchor CSS for
non-hyperlink <a href=""> when PDFUA off; treat title "0" as present in
MetadataWriter; guard Area UaState deref; byte-safe scheme case fold in
UaPolicy; document the enabledtags parsing change in CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the always-true registerImportedMcr method_exists guard and the dead
/MarkInfo read in mergeRoleMap; strip LigatureActualTextWriter's unused
BaseWriter+MarkedContentHelper deps (and their ServiceFactory wiring); drop
MarkedContentHelper::begin()'s unused $altText param.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… E-P3c)

Cache StructureWriter::buildPageRefMap() once per writeStructTree() instead
of rebuilding it per struct element. Extract AriaIdResolver::queueAriaRefs()
(shared by Tag\A, Tag\InlineTag and the image object paths), Mpdf::
svgAccessibleAlt() (shared by printobjectbuffer/Image), call the shared
restoreFlowingBlockPdfuaState() from BlockTag::close(), hoist MetadataWriter's
duplicated 'Internal link' /Contents, and wire ImageMapRegistry's UaState via
a plain setUaState() setter to match StructureTree. Behaviour-preserving; the
withArtifact() bracket was skipped (divergent emit mechanisms / return paths).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All 24 second-round findings (E1-E24 + P3 clusters) are implemented,
tested, and committed on ua1-support. Gates green on PHP 8.2: phpstan 0,
1482 tests / 3768 assertions, veraPDF 63/63, security 78/101. Records the
per-item commit map and the one deliberate partial (E-P3c withArtifact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

3 participants