Skip to content

Commit a3f3551

Browse files
committed
feat: PDF/UA compliance for HTML tables, text decorations, and page-spanning content
Make tagged HTML output conform to PDF/UA-1 (ISO 14289-1) and PDF/UA-2, verified with veraPDF across the example suite. Structure tagging: - Serialize table cell ColSpan/RowSpan as PDF integers, not names - Record colspan/rowspan and honor th scope (row/col) on cell elements - Pass through explicit cell id/headers to the /ID and /Headers entries - Keep empty table cells in the structure tree so rows keep their column count - Emit <hr> and inline background/border decorations as Artifacts Correctness: - Suspend tagging during measurement and repeated-header replay so they no longer add phantom structure elements or desync the per-page MCID counter - Tag text-decoration (underline/line-through) and link underlines inside the text run instead of leaving them as untagged content - Map each marked-content MCID to the page it was emitted on, fixing orphaned MCIDs when an element's text wraps across a page break Fonts: - Add setHTMLMonospaceFont() and honor CSS font-family on <code>/<pre> so an embedded monospace font can replace the non-embedded standard-14 Courier Examples E015/E016/E017 now validate as PDF/UA compliant; fix the E072 autoload path. Add regression tests in test/HTMLTest.php. Bump version to 8.65.0.
1 parent a5caab5 commit a3f3551

10 files changed

Lines changed: 701 additions & 33 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
8.64.1
1+
8.65.0

examples/E015_pdfua.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@
3737
$pdf->setLanguage(code: 'en-US');
3838

3939
$font = $pdf->font->insert($pdf->pon, 'dejavusans', '', 10);
40+
41+
// PDF/UA requires every font to be embedded. <code>/<pre> default to the
42+
// non-embedded standard-14 Courier, so register an embedded monospace font and
43+
// select it for those elements.
44+
$pdf->font->insert($pdf->pon, 'dejavusansmono', '', 10);
45+
$pdf->setHTMLMonospaceFont('dejavusansmono');
46+
4047
$pdf->addPage();
4148
$pdf->page->addContent($font['out']);
4249

examples/E016_pdfua1.php

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@
4444
$pdf->setLanguage(code: 'en-US');
4545

4646
$font = $pdf->font->insert($pdf->pon, 'dejavusans', '', 10);
47+
48+
// PDF/UA requires every font to be embedded. <code>/<pre> default to the
49+
// non-embedded standard-14 Courier, so register an embedded monospace font and
50+
// select it for those elements.
51+
$pdf->font->insert($pdf->pon, 'dejavusansmono', '', 10);
52+
$pdf->setHTMLMonospaceFont('dejavusansmono');
53+
4754
$pdf->addPage();
4855
$pdf->page->addContent($font['out']);
4956

@@ -117,7 +124,32 @@
117124
. ' <a href="https://github.com/tecnickcom/tc-lib-pdf" style="text-decoration:none;">hyperlink to the tc-lib-pdf repository</a>;'
118125
. ' the <code>a</code> element is tagged with the <strong>Link</strong> structure role'
119126
. ' automatically. PDF/UA-1 requires that link annotations have an accessible name,'
120-
. ' which the library derives from the link text in the structure tree.</p>';
127+
. ' which the library derives from the link text in the structure tree.</p>'
128+
. '<h2>7 — Rules, Spans and Empty Cells</h2>'
129+
. '<p>A horizontal rule carries no semantics, so it is emitted as an <strong>Artifact</strong>'
130+
. ' rather than left as untagged content (PDF/UA-1 7.1):</p>'
131+
. '<hr>'
132+
. '<p>Cell <code>colspan</code>/<code>rowspan</code> are recorded on the cell structure'
133+
. ' element as integer <strong>/ColSpan</strong> and <strong>/RowSpan</strong> attributes,'
134+
. ' and an empty cell is kept in the tree so the row keeps its full column count (7.2):</p>'
135+
. '<table border="1" cellspacing="0" cellpadding="3">'
136+
. '<tr><th>Col A</th><th>Col B</th><th>Col C</th></tr>'
137+
. '<tr><td colspan="2">Spans columns A and B</td><td>C</td></tr>'
138+
. '<tr><td rowspan="2">Spans rows</td><td>B2</td><td>C2</td></tr>'
139+
. '<tr><td>B3</td><td></td></tr>'
140+
. '</table>';
141+
142+
// A long table that breaks across pages: the repeated header on each continuation
143+
// page is an Artifact, while the single Table structure element keeps all its rows.
144+
$html .=
145+
'<h2>8 — Page-Spanning Table</h2>'
146+
. '<table border="1" cellspacing="0" cellpadding="2">'
147+
. '<thead><tr><th>#</th><th>Description</th></tr></thead>';
148+
for ($row = 1; $row <= 60; ++$row) {
149+
$html .= '<tr><td>' . $row . '</td><td>Continuation row ' . $row . '</td></tr>';
150+
}
151+
152+
$html .= '</table>';
121153

122154
$pdf->addHTMLCell(html: $html, posx: 15, posy: 20, width: 180);
123155

examples/E017_pdfua2.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@
4242
$pdf->setLanguage(code: 'en-US');
4343

4444
$font = $pdf->font->insert($pdf->pon, 'dejavusans', '', 10);
45+
46+
// PDF/UA requires every font to be embedded. <code>/<pre> default to the
47+
// non-embedded standard-14 Courier, so register an embedded monospace font and
48+
// select it for those elements.
49+
$pdf->font->insert($pdf->pon, 'dejavusansmono', '', 10);
50+
$pdf->setHTMLMonospaceFont('dejavusansmono');
51+
4552
$pdf->addPage();
4653
$pdf->page->addContent($font['out']);
4754

examples/E072_import_new_font.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
// NOTE: run make deps fonts in the project root to generate the dependencies and example fonts.
2323

24-
require '../vendor/autoload.php';
24+
require __DIR__ . '/../vendor/autoload.php';
2525

2626
$defaultFontsDir = (string) \realpath(\dirname(__DIR__) . '/vendor/tecnickcom/tc-lib-pdf-font/target/fonts');
2727
$sourceFontsDir = \dirname(__DIR__) . '/target/fonts/source';

src/Base.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@
126126
* @phpstan-type TPdfUaStructKid array{
127127
* type: 'elem'|'mcid',
128128
* id: int,
129+
* pid?: int,
129130
* }
130131
*
131132
* @phpstan-type TPdfUaStructElem array{
@@ -136,6 +137,7 @@
136137
* alt?: string,
137138
* annots?: int[],
138139
* attr?: array<string, string>,
140+
* required?: bool,
139141
* }
140142
*
141143
* @phpstan-type TFileOptions array{
@@ -699,7 +701,7 @@ abstract class Base
699701
/**
700702
* TCPDF version.
701703
*/
702-
protected string $version = '8.64.1';
704+
protected string $version = '8.65.0';
703705

704706
/**
705707
* Encrypt object.

src/HTML.php

Lines changed: 134 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,16 @@ abstract class HTML extends \Com\Tecnick\Pdf\JavaScript
604604
*/
605605
protected string $ullidot = '!';
606606

607+
/**
608+
* Monospaced font applied to <pre>, <code> and <tt> elements when the author
609+
* has not set an explicit font-family. Defaults to the standard-14 Courier;
610+
* set an embedded font with setHTMLMonospaceFont() for PDF/UA output, which
611+
* requires every font to be embedded.
612+
*
613+
* @var string
614+
*/
615+
protected string $monospaceFontName = self::FONT_MONO;
616+
607617
/**
608618
* Custom vertical spacing overrides for HTML block tags.
609619
* Structure: [ tagname => [ 0 => ['h' => float, 'n' => int], 1 => ['h' => float, 'n' => int] ] ]
@@ -6126,7 +6136,26 @@ protected function parseHTMLAttributesMonospace(array &$dom, int $key): void
61266136
$node = &$dom[$key];
61276137

61286138
if ($node['value'] === 'pre' || $node['value'] === 'tt' || $node['value'] === 'code') {
6129-
$node['fontname'] = self::FONT_MONO;
6139+
// Respect an explicit author font-family; otherwise apply the configured
6140+
// monospace font (Courier by default, or an embedded font for PDF/UA).
6141+
if (!isset($node['style']['font-family']) || $node['style']['font-family'] === '') {
6142+
$node['fontname'] = $this->monospaceFontName;
6143+
}
6144+
}
6145+
}
6146+
6147+
/**
6148+
* Set the monospaced font used for <pre>, <code> and <tt> elements.
6149+
*
6150+
* PDF/UA requires every font to be embedded. The default Courier is a
6151+
* non-embedded standard-14 font, so set an embedded monospace font (after
6152+
* inserting it with the font stack) when producing tagged or accessible PDFs.
6153+
*/
6154+
public function setHTMLMonospaceFont(string $fontname): void
6155+
{
6156+
$fontname = \trim($fontname);
6157+
if ($fontname !== '') {
6158+
$this->monospaceFontName = $fontname;
61306159
}
61316160
}
61326161

@@ -7206,7 +7235,19 @@ protected function replayHTMLTableHead(
72067235
}
72077236

72087237
$theadh = $this->measureHTMLCellRenderedHeight($thead, $tpx, $tpy, $tpw, $tph);
7209-
$out = $this->getHTMLCell($thead, $tpx, $tpy, $tpw, $tph);
7238+
7239+
// The header is replayed visually at the top of each continuation page, but it
7240+
// is already represented once in the structure tree (on its first page). Suspend
7241+
// tagging during the replay so it does not open a second, nested Table subtree
7242+
// inside the still-open table, then emit the whole replayed header as an Artifact.
7243+
$savedPdfuaMode = $this->suspendPdfUaTagging();
7244+
try {
7245+
$rendered = $this->getHTMLCell($thead, $tpx, $tpy, $tpw, $tph);
7246+
} finally {
7247+
$this->resumePdfUaTagging($savedPdfuaMode);
7248+
}
7249+
7250+
$out = $this->tagPdfUaArtifactContent($rendered);
72107251
if ($theadh > 0.0) {
72117252
$tpy += $theadh;
72127253
$this->resetHTMLLineCursor($hrc, $tpx, $tpw);
@@ -7359,9 +7400,17 @@ protected function measureHTMLCellRenderedHeight(
73597400
$tph = $contenth;
73607401

73617402
$this->initHTMLCellContext($hrc, $contentx, $contenty, $contentw, $contenth);
7362-
$this->renderHTMLCellFragments($hrc, $tpx, $tpy, $tpw, $tph, static function (string $fragment): void {
7363-
unset($fragment);
7364-
});
7403+
// Measurement renders into a discarded buffer, so suspend PDF/UA tagging:
7404+
// otherwise this pass appends phantom structure elements and advances the
7405+
// per-page MCID counter for content that is never written to a page stream.
7406+
$savedPdfuaMode = $this->suspendPdfUaTagging();
7407+
try {
7408+
$this->renderHTMLCellFragments($hrc, $tpx, $tpy, $tpw, $tph, static function (string $fragment): void {
7409+
unset($fragment);
7410+
});
7411+
} finally {
7412+
$this->resumePdfUaTagging($savedPdfuaMode);
7413+
}
73657414

73667415
$this->clearHTMLCellContext($hrc);
73677416
$this->restoreHTMLCallerFontState($callerfont);
@@ -11723,6 +11772,40 @@ protected function getHTMLStructRole(array $elm): string
1172311772
};
1172411773
}
1172511774

11775+
/**
11776+
* Return a trimmed string HTML attribute from a DOM node, or '' when absent.
11777+
*
11778+
* @param array<string, mixed> $elm DOM element.
11779+
*/
11780+
protected function getHTMLNodeStringAttr(array $elm, string $name): string
11781+
{
11782+
if (
11783+
!isset($elm['attribute'])
11784+
|| !\is_array($elm['attribute'])
11785+
|| !isset($elm['attribute'][$name])
11786+
|| !\is_string($elm['attribute'][$name])
11787+
) {
11788+
return '';
11789+
}
11790+
11791+
return \trim($elm['attribute'][$name]);
11792+
}
11793+
11794+
/**
11795+
* Map an HTML th/td scope attribute to a PDF table header Scope value.
11796+
* row/rowgroup map to Row; col/colgroup and any other value (including the
11797+
* absent default) map to Column.
11798+
*
11799+
* @param array<string, mixed> $elm DOM element.
11800+
*/
11801+
protected function getHTMLCellScope(array $elm): string
11802+
{
11803+
return match (\strtolower($this->getHTMLNodeStringAttr($elm, 'scope'))) {
11804+
'row', 'rowgroup' => 'Row',
11805+
default => 'Column',
11806+
};
11807+
}
11808+
1172611809
/**
1172711810
* Return the PDF/UA ListNumbering value for a UL/OL element.
1172811811
*
@@ -17244,7 +17327,7 @@ protected function parseHTMLText(
1724417327
}
1724517328
$hrc['cellctx']['linewrapped'] = true;
1724617329

17247-
return $background . $out . $inlineBorder;
17330+
return $this->tagPdfUaArtifactContent($background) . $out . $this->tagPdfUaArtifactContent($inlineBorder);
1724817331
}
1724917332

1725017333
$tpx = $this->computeHTMLTextAdvanceX(
@@ -17269,7 +17352,7 @@ protected function parseHTMLText(
1726917352
}
1727017353
$hrc['cellctx']['linewrapped'] = false;
1727117354

17272-
return $background . $out . $inlineBorder;
17355+
return $this->tagPdfUaArtifactContent($background) . $out . $this->tagPdfUaArtifactContent($inlineBorder);
1727317356
}
1727417357

1727517358
// FUNCTIONS TO PROCESS HTML OPENING TAGS
@@ -17993,14 +18076,17 @@ protected function parseHTMLTagOPENhr(
1799318076
}
1799418077

1799518078
$lineY = $tpy + ($this->getHTMLLineAdvance($hrc, $key) / 2);
17996-
$out .= $this->graph->getLine($tpx, $lineY, $tpx + $width, $lineY, [
18079+
// A horizontal rule carries no semantics, so in PDF/UA mode its stroke is wrapped
18080+
// as an Artifact (tagPdfUaArtifactContent is a no-op outside PDF/UA mode). Without
18081+
// this the rule would be untagged real content, violating PDF/UA-1 7.1.
18082+
$out .= $this->tagPdfUaArtifactContent($this->graph->getLine($tpx, $lineY, $tpx + $width, $lineY, [
1799718083
'lineWidth' => $strokeWidth,
1799818084
'lineCap' => 'butt',
1799918085
'lineJoin' => 'miter',
1800018086
'dashArray' => [],
1800118087
'dashPhase' => 0,
1800218088
'lineColor' => $elm['fgcolor'] === '' ? 'black' : $elm['fgcolor'],
18003-
]);
18089+
]));
1800418090
$this->moveHTMLToNextLine($hrc, $key, $tpx, $tpy, $tpw);
1800518091

1800618092
return $out;
@@ -19448,16 +19534,6 @@ protected function parseHTMLTagOPENtd(
1944819534
return '';
1944919535
}
1945019536

19451-
if ($this->pdfuaMode !== '') {
19452-
$role = $elm['value'] === 'th' ? 'TH' : 'TD';
19453-
$attr = [];
19454-
if ($role === 'TH') {
19455-
$attr = ['O' => 'Table', 'Scope' => 'Column'];
19456-
}
19457-
19458-
$this->beginStructElem($role, $this->page->getPageId(), null, $attr);
19459-
}
19460-
1946119537
$table = $tableNode;
1946219538

1946319539
$colindex = $this->getHTMLTableNextFreeColumn($table);
@@ -19483,6 +19559,45 @@ protected function parseHTMLTagOPENtd(
1948319559
$colspan = \max(1, \min($remaining, $colspan));
1948419560
$rowspan = \max(1, $rowspan);
1948519561

19562+
if ($this->pdfuaMode !== '') {
19563+
$role = $elm['value'] === 'th' ? 'TH' : 'TD';
19564+
$attr = [];
19565+
if ($role === 'TH') {
19566+
// Honor an explicit scope; header cells default to column scope.
19567+
$attr = ['O' => 'Table', 'Scope' => $this->getHTMLCellScope($elm)];
19568+
}
19569+
19570+
// Record the cell spans on the structure element so assistive technology can
19571+
// reconstruct the table grid. The owner (/O /Table) is required for the span
19572+
// attributes; the serialiser writes ColSpan/RowSpan as integers.
19573+
if ($colspan > 1) {
19574+
$attr['O'] = 'Table';
19575+
$attr['ColSpan'] = (string) $colspan;
19576+
}
19577+
19578+
if ($rowspan > 1) {
19579+
$attr['O'] = 'Table';
19580+
$attr['RowSpan'] = (string) $rowspan;
19581+
}
19582+
19583+
// Pass through explicit header-association markup so complex tables can label
19584+
// header cells with an id and point data cells at them. The struct-tree
19585+
// serialiser promotes these to the StructElem /ID and /Headers entries.
19586+
$cellId = $this->getHTMLNodeStringAttr($elm, 'id');
19587+
if ($cellId !== '') {
19588+
$attr['ID'] = $cellId;
19589+
}
19590+
19591+
$cellHeaders = $this->getHTMLNodeStringAttr($elm, 'headers');
19592+
if ($cellHeaders !== '') {
19593+
$attr['Headers'] = $cellHeaders;
19594+
}
19595+
19596+
// Keep the cell in the structure tree even when empty so the row preserves
19597+
// its full column count and the table matrix stays regular.
19598+
$this->beginStructElem($role, $this->page->getPageId(), null, $attr, true);
19599+
}
19600+
1948619601
$cellx = $this->getHTMLTableColX($table, $colindex);
1948719602
$cellw = $this->getHTMLTableColSpanWidth($table, $colindex, $colspan);
1948819603
$rowtop = $table['rowtop'];

src/Output.php

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1726,11 +1726,15 @@ protected function getOutStructTreeRoot(): string
17261726
}
17271727

17281728
$mcid = $kid['id'];
1729-
if ($entryPageOid > 0) {
1730-
$parentTreeMap[$entryPageOid][$mcid] = $elemOids[$entryIdx] ?? 0;
1729+
// Each MCID belongs to the page it was emitted on, which may differ from
1730+
// the element's own page when the element's content wraps across a page
1731+
// break. Use the kid's page so the MCR /Pg and ParentTree key are correct.
1732+
$kidPageOid = $pidToOid[$kid['pid']] ?? $entryPageOid;
1733+
if ($kidPageOid > 0) {
1734+
$parentTreeMap[$kidPageOid][$mcid] = $elemOids[$entryIdx] ?? 0;
17311735
}
17321736

1733-
$kidsOut .= ' << /Type /MCR /Pg ' . $entryPageOid . ' 0 R /MCID ' . $mcid . ' >>';
1737+
$kidsOut .= ' << /Type /MCR /Pg ' . $kidPageOid . ' 0 R /MCID ' . $mcid . ' >>';
17341738
}
17351739

17361740
if ($entry['annots'] !== []) {
@@ -1784,6 +1788,15 @@ protected function getOutStructTreeRoot(): string
17841788
continue;
17851789
}
17861790

1791+
if ($akey === 'ColSpan' || $akey === 'RowSpan') {
1792+
// Table span attributes are integers, not names (ISO 32000-1 table 348).
1793+
if (\is_numeric($aval)) {
1794+
$attrPairs .= ' /' . $akey . ' ' . (int) $aval;
1795+
}
1796+
1797+
continue;
1798+
}
1799+
17871800
$attrPairs .= ' /' . $akey . ' /' . $aval;
17881801
}
17891802

0 commit comments

Comments
 (0)