From 9e26beb834f17ec8d74fe3a006b10c21c7ea7017 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Thu, 24 Sep 2026 13:52:42 +1000 Subject: [PATCH 1/2] Tag an inline image or form field where it falls among its line's text Under PDF/UA an object in a line is drawn as its chunk is reached rather than after the line's text, with the text's marked content closed around it. Its Figure or Form is then read between the text before and after it instead of nested inside the text's MCID, and what it tags goes in the element its chunk is in, so an image in a link is the Link's content. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Mpdf.php | 112 ++++++++++---- .../Mpdf/Ua/InlineObjectReadingOrderTest.php | 139 ++++++++++++++++++ tests/Mpdf/Ua/PdfUaTestCase.php | 26 ++++ tests/data/snapshots/pdfua.pdf | Bin 93409 -> 93334 bytes 4 files changed, 251 insertions(+), 26 deletions(-) create mode 100644 tests/Mpdf/Ua/InlineObjectReadingOrderTest.php diff --git a/src/Mpdf.php b/src/Mpdf.php index 76bf7daec..825b8f50a 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -7291,6 +7291,83 @@ private function closeBlockBdcIfOpen() $this->flowingBlockAttr['pdfua_bdc_elem'] = null; } + /** + * Whether a list marker drawn now is the content of the Lbl element Li::open() made. + * + * @return bool + */ + private function listMarkerHasLbl() + { + return !$this->ColActive && isset($this->blk[$this->blklvl]['pdfua_li_lbl_elem']); + } + + /** + * Open the marked content a line's chunk is drawn in: that of the inline element restoreFont() + * says it is in, or else the block's. + * + * Table cell text always has an inline element (the cell, or one inside it). An object that + * marks its own content is drawn by printLineObject() after its chunk, and opens nothing here + * unless its chunk draws a span's background or border. In an artifact every object is drawn + * after the line's text, inside the artifact's marked content. + * + * @param int $k The chunk's key in $objectbuffer + * @param bool $is_table + * @return bool Whether the chunk is an object that marks its own content + */ + private function markLineChunk($k, $is_table) + { + $object = empty($this->objectbuffer[$k]) ? null : $this->objectbuffer[$k]; + $tagged = $object !== null + && in_array($object['type'], ['image', 'barcode', 'textcircle', 'listmarker', 'input', 'textarea', 'select'], true) + && ($object['type'] !== 'listmarker' || $this->listMarkerHasLbl()) + && empty($this->flowingBlockAttr['pdfua_artifact_open']) + && !$this->ua->getStructureTree()->isInArtifact(); + if ($tagged && !$this->spanbgcolor && empty($this->spanborddet)) { + return true; + } + $inlineElem = $object === null ? $this->ua->getAnchorState()->getInlineContentElem() : null; + if ($inlineElem !== null) { + $this->ensureInlineBdcOpen($inlineElem); + } elseif (!$is_table) { + $this->ensureBlockBdcOpen(); + } + + return $tagged; + } + + /** + * Draw the object of a line's chunk now, so its marked content comes between the text before + * and after it, and not inside the text's. The next chunk of text begins its marked content again. + * + * What the object tags goes in the element the chunk is in, such as the Link around an image. + * + * @param int $k The chunk's key in $objectbuffer + * @param bool $is_table + * @param string|bool $blockdir + * @return void + */ + private function printLineObject($k, $is_table, $blockdir) + { + $this->closeBlockBdcIfOpen(); + + $parent = $this->ua->getAnchorState()->getInlineContentElem(); + if ($parent === null && !$is_table && isset($this->flowingBlockAttr['pdfua_struct_elem'])) { + $parent = $this->flowingBlockAttr['pdfua_struct_elem']; + } + + $line = $this->objectbuffer; + $this->objectbuffer = [$k => $line[$k]]; + if ($parent !== null) { + $this->ua->getStructureTree()->pushExisting($parent); + } + $this->printobjectbuffer($is_table, $blockdir); + if ($parent !== null) { + $this->ua->getStructureTree()->close(); + } + unset($line[$k]); + $this->objectbuffer = $line; + } + /** * Mark where a table cell's block frames begin, once the TD or TH element is open. * @@ -7905,19 +7982,7 @@ function finishFlowingBlock($endofblock = false, $next = '') $this->restoreFont($font[$k]); // mPDF 5.7 - // Mark the chunk against the element restoreFont() says it is in. Table cell text - // always has one (the cell, or an element inside it); a cell chunk without one is - // an image or widget, which printobjectbuffer() marks itself. - if ($this->PDFUA) { - $pdfuaInlineElem = (!isset($this->objectbuffer[$k]) || !$this->objectbuffer[$k]) - ? $this->ua->getAnchorState()->getInlineContentElem() - : null; - if ($pdfuaInlineElem !== null) { - $this->ensureInlineBdcOpen($pdfuaInlineElem); - } elseif (!$is_table) { - $this->ensureBlockBdcOpen(); - } - } + $pdfuaLineObject = $this->PDFUA && $this->markLineChunk($k, $is_table); if ($is_table && substr($align, 0, 1) == 'D' && $aord == 0) { $dp = $this->decimal_align[substr($align, 0, 2)]; @@ -7997,6 +8062,9 @@ function finishFlowingBlock($endofblock = false, $next = '') } else { $this->Cell($stringWidth, $stackHeight, $chunk, '', 0, '', $fill, $this->HREF, 0, 0, 0, 'M', $fill, true, (isset($cOTLdata[$aord]) ? $cOTLdata[$aord] : false), $this->textvar, (isset($lineBox[$k]) ? $lineBox[$k] : false)); // first or middle part // mPDF 5.7.1 } + if ($pdfuaLineObject) { + $this->printLineObject($k, $is_table, $blockdir); + } if (!empty($this->spanborddet)) { @@ -8738,9 +8806,8 @@ function ($col) { // on the structure stack $pdfuaLblMcid = null; if ($this->PDFUA - && !$this->ColActive && !$this->ua->getStructureTree()->isInArtifact() - && isset($this->blk[$this->blklvl]['pdfua_li_lbl_elem']) + && $this->listMarkerHasLbl() ) { $structParents = $this->pdfuaStructParents(); $pdfuaLblMcid = $this->ua->getStructureTree()->addContentForElement( @@ -9805,17 +9872,7 @@ function WriteFlowingBlock($s, $sOTLdata) $this->restoreFont($font[$k]); // mPDF 5.7 - // As in finishFlowingBlock() - if ($this->PDFUA) { - $pdfuaInlineElem = (!isset($this->objectbuffer[$k]) || !$this->objectbuffer[$k]) - ? $this->ua->getAnchorState()->getInlineContentElem() - : null; - if ($pdfuaInlineElem !== null) { - $this->ensureInlineBdcOpen($pdfuaInlineElem); - } elseif (!$is_table) { - $this->ensureBlockBdcOpen(); - } - } + $pdfuaLineObject = $this->PDFUA && $this->markLineChunk($k, $is_table); $this->SetSpacing(($this->fixedlSpacing * Mpdf::SCALE) + $jcharspacing, ($this->fixedlSpacing + $this->minwSpacing) * Mpdf::SCALE + $jws); // Now unset these values so they don't influence GetStringwidth below or in fn. Cell @@ -9886,6 +9943,9 @@ function WriteFlowingBlock($s, $sOTLdata) } else { $this->Cell($stringWidth, $stackHeight, $chunk, '', 0, '', $fill, $this->HREF, 0, 0, 0, 'M', $fill, true, (isset($cOTLdata[$aord]) ? $cOTLdata[$aord] : false), $this->textvar, (isset($lineBox[$k]) ? $lineBox[$k] : false)); // first or middle part } + if ($pdfuaLineObject) { + $this->printLineObject($k, $is_table, $blockdir); + } if (!empty($this->spanborddet)) { diff --git a/tests/Mpdf/Ua/InlineObjectReadingOrderTest.php b/tests/Mpdf/Ua/InlineObjectReadingOrderTest.php new file mode 100644 index 000000000..0b0b12799 --- /dev/null +++ b/tests/Mpdf/Ua/InlineObjectReadingOrderTest.php @@ -0,0 +1,139 @@ +getOutput( + $this->makeMpdf(), + '

Before A picture after

' + ); + + $this->assertSame(['MCID 0', 'Figure', 'MCID 2'], $this->kidsOf($pdf, 'P')); + $this->assertNoNestedMarkedContent($pdf); + } + + /** + * A line broken off in the middle of a paragraph draws its image in order too. + * + * @return void + */ + public function testImageOnAWrappedLineIsReadBetweenTheTextAroundIt() + { + $pdf = $this->getOutput( + $this->makeMpdf(), + '

Before A picture after ' + . str_repeat('and then some more words ', 20) . '

' + ); + + $kids = $this->kidsOf($pdf, 'P'); + $this->assertSame(['MCID 0', 'Figure', 'MCID 2'], array_slice($kids, 0, 3)); + $this->assertNoNestedMarkedContent($pdf); + } + + /** + * An image in a link is the Link's content, beside its annotation, so a Link with text after it + * is not left empty. + * + * @return void + */ + public function testImageInALinkIsTheLinksContent() + { + $pdf = $this->getOutput( + $this->makeMpdf(), + '

x Home y

' + ); + + $this->assertSame(['MCID 0', 'Link', 'MCID 2'], $this->kidsOf($pdf, 'P')); + $this->assertSame(['Figure', 'OBJR'], $this->kidsOf($pdf, 'Link')); + $this->assertNoNestedMarkedContent($pdf); + } + + /** + * A checkbox before its label's text is read before it. + * + * @return void + */ + public function testFormFieldIsReadBetweenTheTextAroundIt() + { + $pdf = $this->getOutput( + $this->makeMpdf(['useActiveForms' => true]), + '

Start ' + . ' and end

' + ); + + $this->assertSame(['MCID 0', 'Form', 'MCID 1', 'Form', 'MCID 2'], $this->kidsOf($pdf, 'P')); + } + + /** + * Images in a table cell, a barcode and list markers open their marked content outside the text's. + * + * @return void + */ + public function testObjectsOpenNoMarkedContentInsideTheTexts() + { + $pdf = $this->getOutput( + $this->makeMpdf(), + '
cell C text
' + . '' + ); + + $this->assertSame(['MCID 0', 'Figure', 'MCID 2'], $this->kidsOf($pdf, 'TD')); + $this->assertNoNestedMarkedContent($pdf); + } + + /** + * The kids of the first structure element of a type, each named by the type of the element it + * refers to, 'MCID n' or 'OBJR'. + * + * @param string $pdf + * @param string $type + * + * @return string[] + */ + private function kidsOf($pdf, $type) + { + preg_match_all('@(\d+) 0 obj\s*<assertNotNull($body, 'no /' . $type . ' element'); + $this->assertSame(1, preg_match('@/K (\[.*\]|<<[^>]*>>|\d+ 0 R|\d+)\s*>>\s*$@s', $body, $k)); + + preg_match_all('@<]*/MCID (\d+)>>|<]*>>|(\d+) 0 R|\d+@', $k[1], $refs, PREG_SET_ORDER); + $kids = []; + foreach ($refs as $ref) { + if (strpos($ref[0], '/OBJR') !== false) { + $kids[] = 'OBJR'; + } elseif (isset($ref[2]) && $ref[2] !== '') { + $kids[] = $types[$ref[2]]; + } else { + $kids[] = 'MCID ' . ($ref[1] !== '' ? $ref[1] : $ref[0]); + } + } + + return $kids; + } +} diff --git a/tests/Mpdf/Ua/PdfUaTestCase.php b/tests/Mpdf/Ua/PdfUaTestCase.php index fa2fb5be8..1c9612125 100644 --- a/tests/Mpdf/Ua/PdfUaTestCase.php +++ b/tests/Mpdf/Ua/PdfUaTestCase.php @@ -64,4 +64,30 @@ protected function actualTextOf($text) { return 'FEFF' . strtoupper(bin2hex(mb_convert_encoding($text, 'UTF-16BE', 'UTF-8'))); } + + /** + * Marked content with an MCID may be inside no other marked content (ISO 32000-1 ยง14.7.4.2). + * + * @param string $pdf + * + * @return void + */ + protected function assertNoNestedMarkedContent($pdf) + { + preg_match_all('@stream\r?\n(.*?)endstream@s', $pdf, $streams); + foreach ($streams[1] as $stream) { + preg_match_all('@/(\w+) <> BDC|\bBDC\b|\bBMC\b|\bEMC\b@', $stream, $ops, PREG_SET_ORDER); + $depth = 0; + foreach ($ops as $op) { + if ($op[0] === 'EMC') { + $depth--; + continue; + } + if ($op[0] !== 'BMC' && $op[0] !== 'BDC') { + $this->assertSame(0, $depth, '/' . $op[1] . ' opened inside other marked content'); + } + $depth++; + } + } + } } diff --git a/tests/data/snapshots/pdfua.pdf b/tests/data/snapshots/pdfua.pdf index 2a6a0b9cdb525f74a02fd74461e2d1722f2c2985..cc7ef8662fe4e8f99ef61ea5b7747b73b7f4173e 100644 GIT binary patch delta 2395 zcma)7PiS047-tsK=%z6nHVP)RzD=42lx^nUH}g$dgQgnUB+}TWT5MX_-E2gW7Gte{ zdU%LZLA>aIgS~m_K`)ga_9Q(ks3;=Tn^zIRix&}{_qLmKX9DhC-t6~&^ZmZ>_x)zx zueV1&xjk~@tMLNmH*byqOYGs&f_0qW^8xrXemgE_C|TygJnP8&BG6fvB{N}UVMG0vrEjBy__uDQepbz*|7rC z`KFT-qxN9siv2B_v!7R*_G)GB-D9)eW3yy-!CkA9gKo1BHno+}(_>#e3%tX=JNP*uftn^w_iR&8=`4u$M~afU3Ap`h;BQBrktFOZ*ygC9CeJ{k`B<5{vk6tcU#NnXA0LE+`ee#wQT zm@iOB2JY()`5?_?9e76#7dnoa7$-qQIAdfGV^kO##U%9drF%s&s#QqL7?U9h=R$@J zaSqg5VW1H%D0r6yF)6)C_F}1DasezP<^m(txa2HMC_tL97y&9$RVap`f8>ZFK^=A?{W!zTK)DJ>0#u2xJH;XL z0&2+yIlTTTz=X1T@goux8V5ILdkqVTeJsu=sR|oG$s|hyS#h5k`xu5JK`CIsNPZ+J zr3jTl85`5bbie3y_ejck?kKg2I+iG6ViOq?vw4Qi`C9tKcX(5Zz~ZWb(})BFkGca9 z&ln(>u~!CETr_AVlI;B5eKCQ?$O~X-=zavFqhb^tJlokw6pQW@RD92@Y5zu4f<-g- zrjCkHQir!0Dz#5cN5>8o^01NA2lpW{vHP{ZN5gq)hLH*ySV-azcJ`$mP}(~tyD{C< zuaMe#%9_Ts+e{%7UNT1godL1~Tbj07EznCz?SCQXvXA_0mzq15-rVVI7Y&nu#rgTC HPOkh1TmKs8 delta 2423 zcma)7OK)367}Z3CP&aNUNs$O3u0tMxFqy|UGv6TNMPJBCs#YR1ET|A_muy&p#0oZ`GS{xtcm@eyFXrm_%=b9onYmxw z9{=U`_|CQBn1HL-i+9NQ+Tru9_id>#Z@-;>z#cmIoV_;n6yf&Qsef#Ja`FDy~v5|GbOq^FA+^tm%dD18=Ge)Z77ANjL?*2 zVVYP@xoMS~X>5) znBBiN$UFNUR$jdPNw$(g?*#>bb|19K#~FGlhyJ66E3agL2@&%Ez3@XkMk)6*2{7P( zY9VI&@v?hGF{(|Nm@$_7W@t0#LWd1;E`@j8Fi%i~2m_>hvCMDbLPD6B3yf6bz#$AM zq~u{SLaInrp*Rl}6iTrytlEPZN+^^uVsF*HXrVnCV1P(t%&0I27|HIAB-`}_7(>HO zJf_3Vl5!J{L{g>0?iBaGpg`1cckY9+d;QUC6UyqbCuOARu~&)*OcS>y$2MCg80lt#rUI()W$ zU{ox+Q&91HX3YjSq7o3zSos4VcA~uhBTWAXhBPi3F&s&T%BVlQCL_wcJlbYQ!pQi From f1dc7ba49d1f742b40ff22db5fc218fc759322a2 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Tue, 29 Sep 2026 08:31:59 +1000 Subject: [PATCH 2/2] Move the marking of a flowing block's lines out of Mpdf into Ua\FlowingBlockMarker Co-Authored-By: Claude Opus 5.5 --- src/Mpdf.php | 247 +++--------------- src/ServiceFactory.php | 5 + src/Ua/FlowingBlockMarker.php | 214 +++++++++++++++ .../Mpdf/Ua/InlineObjectReadingOrderTest.php | 2 +- 4 files changed, 254 insertions(+), 214 deletions(-) create mode 100644 src/Ua/FlowingBlockMarker.php diff --git a/src/Mpdf.php b/src/Mpdf.php index 825b8f50a..b65cd6378 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -102,6 +102,11 @@ class Mpdf implements \Psr\Log\LoggerAwareInterface */ private $ua; + /** + * @var \Mpdf\Ua\FlowingBlockMarker + */ + private $flowingBlockMarker; + var $printers_info; var $iterationCounter; var $smCapsScale; @@ -5415,7 +5420,7 @@ function Cell($w, $h = 0, $txt = '', $border = 0, $ln = 0, $align = '', $fill = $this->ResetSpacing(); // A marked-content sequence ends on the page it began - $this->closeBlockBdcIfOpen(); + $this->flowingBlockMarker->closeBlockBdcIfOpen(); $this->AddPage($this->CurOrientation); @@ -7139,104 +7144,11 @@ function newFlowingBlock($w, $h, $a = '', $is_table = false, $blockstate = 0, $n $this->flowingBlockAttr['blockdir'] = $blockdir; $this->flowingBlockAttr['cOTLdata'] = []; // mPDF 5.7.1 $this->flowingBlockAttr['lastBidiText'] = ''; // mPDF 5.7.1 - // The block's marked content is opened as its text is drawn, not when its tag opens, as a - // block across pages needs an MCID on each. A
starts a new flowing block inside the - // same one, so whatever the last line opened is closed first. if ($this->PDFUA) { - $this->closeBlockBdcIfOpen(); - $this->flowingBlockAttr['pdfua_struct_open'] = false; - $this->flowingBlockAttr['pdfua_type'] = 'P'; - $this->flowingBlockAttr['pdfua_artifact_open'] = false; - // Whether a BDC is open on this page that owes an EMC before the page or block ends - $this->flowingBlockAttr['pdfua_bdc_active'] = false; - // The block's own element, which the top of the structure stack may not be when an - // inline element is open inside it - $this->flowingBlockAttr['pdfua_struct_elem'] = null; - // The inline element the open BDC belongs to, or null when it is the block's - $this->flowingBlockAttr['pdfua_bdc_elem'] = null; + $this->flowingBlockMarker->reset(); } } - /** - * Open the block's marked content on this page, if it is not open already. - * - * The MCID is given to the block's own element rather than the top of the structure stack, - * which may be an inline element inside the block. - * - * @return void - */ - private function ensureBlockBdcOpen() - { - if (!$this->PDFUA) { - return; - } - // Text after an inline element goes back into the block's own marked content - if (!empty($this->flowingBlockAttr['pdfua_bdc_active']) - && !empty($this->flowingBlockAttr['pdfua_bdc_elem'])) { - $this->closeBlockBdcIfOpen(); - } - if (!empty($this->flowingBlockAttr['pdfua_bdc_active'])) { - return; - } - if (!empty($this->flowingBlockAttr['pdfua_artifact_open'])) { - $this->ua->getMarkedContentHelper()->begin('Artifact', -1); - $this->flowingBlockAttr['pdfua_bdc_active'] = true; - $this->flowingBlockAttr['pdfua_bdc_elem'] = null; - return; - } - if (empty($this->flowingBlockAttr['pdfua_struct_open'])) { - return; - } - $elem = isset($this->flowingBlockAttr['pdfua_struct_elem']) - ? $this->flowingBlockAttr['pdfua_struct_elem'] - : null; - if ($elem === null) { - return; - } - $structParents = $this->pdfuaStructParents(); - $mcid = $this->ua->getStructureTree()->addContentForElement($elem, $structParents); - $this->ua->getMarkedContentHelper()->begin($this->flowingBlockAttr['pdfua_type'], $mcid); - $this->flowingBlockAttr['pdfua_bdc_active'] = true; - $this->flowingBlockAttr['pdfua_bdc_elem'] = null; - } - - /** - * Open marked content for text inside an inline element (a Link, a Span with its own - * language, an Abbr, ruby), closing whatever the block had open. - * - * An MCID belongs to one element, so the text needs its own for the Link to have content and - * for the Span's /Lang or the Abbr's /E to apply to anything. - * - * @param \Mpdf\Ua\StructureElement $elem The inline element the chunk is in - * @return void - */ - private function ensureInlineBdcOpen($elem) - { - if (!$this->PDFUA || $elem === null) { - return; - } - if (!empty($this->flowingBlockAttr['pdfua_bdc_active']) - && isset($this->flowingBlockAttr['pdfua_bdc_elem']) - && $this->flowingBlockAttr['pdfua_bdc_elem'] === $elem) { - return; - } - if (!empty($this->flowingBlockAttr['pdfua_bdc_active'])) { - $this->closeBlockBdcIfOpen(); - } - // Inside an artifact block no inline element was made, so the text is artifact too - if (!empty($this->flowingBlockAttr['pdfua_artifact_open'])) { - $this->ua->getMarkedContentHelper()->begin('Artifact', -1); - $this->flowingBlockAttr['pdfua_bdc_active'] = true; - $this->flowingBlockAttr['pdfua_bdc_elem'] = null; - return; - } - $structParents = $this->pdfuaStructParents(); - $mcid = $this->ua->getStructureTree()->addContentForElement($elem, $structParents); - $this->ua->getMarkedContentHelper()->begin($elem->getType(), $mcid); - $this->flowingBlockAttr['pdfua_bdc_active'] = true; - $this->flowingBlockAttr['pdfua_bdc_elem'] = $elem; - } - /** * Keep the text of a line on the block's element, which AriaIdResolver reads as the name an * aria-labelledby or aria-describedby pointing at the block resolves to. @@ -7273,101 +7185,6 @@ private function captureBlockStructText($content) $elem->appendText($text); } - /** - * End the marked content the flowing block has open, if any. - * - * @return void - */ - private function closeBlockBdcIfOpen() - { - if (!$this->PDFUA) { - return; - } - if (empty($this->flowingBlockAttr['pdfua_bdc_active'])) { - return; - } - $this->ua->getMarkedContentHelper()->end(); - $this->flowingBlockAttr['pdfua_bdc_active'] = false; - $this->flowingBlockAttr['pdfua_bdc_elem'] = null; - } - - /** - * Whether a list marker drawn now is the content of the Lbl element Li::open() made. - * - * @return bool - */ - private function listMarkerHasLbl() - { - return !$this->ColActive && isset($this->blk[$this->blklvl]['pdfua_li_lbl_elem']); - } - - /** - * Open the marked content a line's chunk is drawn in: that of the inline element restoreFont() - * says it is in, or else the block's. - * - * Table cell text always has an inline element (the cell, or one inside it). An object that - * marks its own content is drawn by printLineObject() after its chunk, and opens nothing here - * unless its chunk draws a span's background or border. In an artifact every object is drawn - * after the line's text, inside the artifact's marked content. - * - * @param int $k The chunk's key in $objectbuffer - * @param bool $is_table - * @return bool Whether the chunk is an object that marks its own content - */ - private function markLineChunk($k, $is_table) - { - $object = empty($this->objectbuffer[$k]) ? null : $this->objectbuffer[$k]; - $tagged = $object !== null - && in_array($object['type'], ['image', 'barcode', 'textcircle', 'listmarker', 'input', 'textarea', 'select'], true) - && ($object['type'] !== 'listmarker' || $this->listMarkerHasLbl()) - && empty($this->flowingBlockAttr['pdfua_artifact_open']) - && !$this->ua->getStructureTree()->isInArtifact(); - if ($tagged && !$this->spanbgcolor && empty($this->spanborddet)) { - return true; - } - $inlineElem = $object === null ? $this->ua->getAnchorState()->getInlineContentElem() : null; - if ($inlineElem !== null) { - $this->ensureInlineBdcOpen($inlineElem); - } elseif (!$is_table) { - $this->ensureBlockBdcOpen(); - } - - return $tagged; - } - - /** - * Draw the object of a line's chunk now, so its marked content comes between the text before - * and after it, and not inside the text's. The next chunk of text begins its marked content again. - * - * What the object tags goes in the element the chunk is in, such as the Link around an image. - * - * @param int $k The chunk's key in $objectbuffer - * @param bool $is_table - * @param string|bool $blockdir - * @return void - */ - private function printLineObject($k, $is_table, $blockdir) - { - $this->closeBlockBdcIfOpen(); - - $parent = $this->ua->getAnchorState()->getInlineContentElem(); - if ($parent === null && !$is_table && isset($this->flowingBlockAttr['pdfua_struct_elem'])) { - $parent = $this->flowingBlockAttr['pdfua_struct_elem']; - } - - $line = $this->objectbuffer; - $this->objectbuffer = [$k => $line[$k]]; - if ($parent !== null) { - $this->ua->getStructureTree()->pushExisting($parent); - } - $this->printobjectbuffer($is_table, $blockdir); - if ($parent !== null) { - $this->ua->getStructureTree()->close(); - } - unset($line[$k]); - $this->objectbuffer = $line; - } - /** * Mark where a table cell's block frames begin, once the TD or TH element is open. * @@ -7694,7 +7511,7 @@ function finishFlowingBlock($endofblock = false, $next = '') $this->ResetSpacing(); // A marked-content sequence ends on the page it began; the next page opens its own - $this->closeBlockBdcIfOpen(); + $this->flowingBlockMarker->closeBlockBdcIfOpen(); $this->AddPage($this->CurOrientation); @@ -7982,7 +7799,7 @@ function finishFlowingBlock($endofblock = false, $next = '') $this->restoreFont($font[$k]); // mPDF 5.7 - $pdfuaLineObject = $this->PDFUA && $this->markLineChunk($k, $is_table); + $pdfuaLineObject = $this->PDFUA && $this->flowingBlockMarker->markLineChunk($k, $is_table); if ($is_table && substr($align, 0, 1) == 'D' && $aord == 0) { $dp = $this->decimal_align[substr($align, 0, 2)]; @@ -8063,7 +7880,7 @@ function finishFlowingBlock($endofblock = false, $next = '') $this->Cell($stringWidth, $stackHeight, $chunk, '', 0, '', $fill, $this->HREF, 0, 0, 0, 'M', $fill, true, (isset($cOTLdata[$aord]) ? $cOTLdata[$aord] : false), $this->textvar, (isset($lineBox[$k]) ? $lineBox[$k] : false)); // first or middle part // mPDF 5.7.1 } if ($pdfuaLineObject) { - $this->printLineObject($k, $is_table, $blockdir); + $this->flowingBlockMarker->printLineObject($k, $is_table, $blockdir); } @@ -8091,7 +7908,7 @@ function finishFlowingBlock($endofblock = false, $next = '') } // END IF CONTENT if ($endofblock) { - $this->closeBlockBdcIfOpen(); + $this->flowingBlockMarker->closeBlockBdcIfOpen(); } /* -- CSS-IMAGE-FLOAT -- */ @@ -8166,8 +7983,20 @@ function finishFlowingBlock($endofblock = false, $next = '') $stackHeight = $this->divheight; } - function printobjectbuffer($is_table = false, $blockdir = false) + /** + * Draw a line's images, form fields, list markers and other objects. + * + * @param bool $is_table + * @param string|bool $blockdir + * @param array|null $objects Those to draw, by chunk; $objectbuffer when null + * @return void + */ + function printobjectbuffer($is_table = false, $blockdir = false, $objects = null) { + if ($objects === null) { + $objects = $this->objectbuffer; + } + if (!$blockdir) { $blockdir = $this->directionality; } @@ -8191,7 +8020,7 @@ function printobjectbuffer($is_table = false, $blockdir = false) $rtlalign = 'L'; } - foreach ($this->objectbuffer as $ib => $objattr) { + foreach ($objects as $ib => $objattr) { if ($objattr['type'] == 'bookmark' || $objattr['type'] == 'indexentry' || $objattr['type'] == 'toc') { $x = $objattr['OUTER-X']; @@ -8487,10 +8316,7 @@ function printobjectbuffer($is_table = false, $blockdir = false) if (($pdfuaImageAlt === '' || $pdfuaImageAlt === null) && !$pdfuaImageDeferredName) { // An artifact may not sit inside the block's tagged content, so the block's // marked content is ended here and begun again after the image - if (!empty($this->flowingBlockAttr['pdfua_bdc_active'])) { - $this->closeBlockBdcIfOpen(); - $pdfuaImageClosedBlockBdc = true; - } + $pdfuaImageClosedBlockBdc = $this->flowingBlockMarker->closeBlockBdcIfOpen(); } if ($pdfuaImageAlt === '') { $pdfuaImageMcid = $this->ua->getStructureTree()->addArtifact(); @@ -8537,7 +8363,7 @@ function printobjectbuffer($is_table = false, $blockdir = false) } } if ($pdfuaImageClosedBlockBdc) { - $this->ensureBlockBdcOpen(); + $this->flowingBlockMarker->ensureBlockBdcOpen(); } // LINK @@ -8807,7 +8633,7 @@ function ($col) { $pdfuaLblMcid = null; if ($this->PDFUA && !$this->ua->getStructureTree()->isInArtifact() - && $this->listMarkerHasLbl() + && $this->flowingBlockMarker->listMarkerHasLbl() ) { $structParents = $this->pdfuaStructParents(); $pdfuaLblMcid = $this->ua->getStructureTree()->addContentForElement( @@ -8904,15 +8730,10 @@ function ($col) { /* -- FORMS -- */ // A drawn field's box is an artifact and its value a Form element of its own, neither of // which may sit inside the block's tagged content, so that is ended here and begun again after - $pdfuaFieldClosedBlockBdc = false; - if ($this->PDFUA + $pdfuaFieldClosedBlockBdc = $this->PDFUA && !$this->useActiveForms && in_array($objattr['type'], ['input', 'textarea', 'select'], true) - && !empty($this->flowingBlockAttr['pdfua_bdc_active']) - ) { - $this->closeBlockBdcIfOpen(); - $pdfuaFieldClosedBlockBdc = true; - } + && $this->flowingBlockMarker->closeBlockBdcIfOpen(); // TEXT/PASSWORD INPUT if ($objattr['type'] == 'input' && ($objattr['subtype'] == 'TEXT' || $objattr['subtype'] == 'PASSWORD')) { @@ -8950,7 +8771,7 @@ function ($col) { } if ($pdfuaFieldClosedBlockBdc) { - $this->ensureBlockBdcOpen(); + $this->flowingBlockMarker->ensureBlockBdcOpen(); } /* -- END FORMS -- */ } @@ -9759,7 +9580,7 @@ function WriteFlowingBlock($s, $sOTLdata) $this->ResetSpacing(); // A marked-content sequence ends on the page it began; the next page opens its own - $this->closeBlockBdcIfOpen(); + $this->flowingBlockMarker->closeBlockBdcIfOpen(); $this->AddPage($this->CurOrientation); @@ -9872,7 +9693,7 @@ function WriteFlowingBlock($s, $sOTLdata) $this->restoreFont($font[$k]); // mPDF 5.7 - $pdfuaLineObject = $this->PDFUA && $this->markLineChunk($k, $is_table); + $pdfuaLineObject = $this->PDFUA && $this->flowingBlockMarker->markLineChunk($k, $is_table); $this->SetSpacing(($this->fixedlSpacing * Mpdf::SCALE) + $jcharspacing, ($this->fixedlSpacing + $this->minwSpacing) * Mpdf::SCALE + $jws); // Now unset these values so they don't influence GetStringwidth below or in fn. Cell @@ -9944,7 +9765,7 @@ function WriteFlowingBlock($s, $sOTLdata) $this->Cell($stringWidth, $stackHeight, $chunk, '', 0, '', $fill, $this->HREF, 0, 0, 0, 'M', $fill, true, (isset($cOTLdata[$aord]) ? $cOTLdata[$aord] : false), $this->textvar, (isset($lineBox[$k]) ? $lineBox[$k] : false)); // first or middle part } if ($pdfuaLineObject) { - $this->printLineObject($k, $is_table, $blockdir); + $this->flowingBlockMarker->printLineObject($k, $is_table, $blockdir); } diff --git a/src/ServiceFactory.php b/src/ServiceFactory.php index 216fe8d59..f4b8834d6 100644 --- a/src/ServiceFactory.php +++ b/src/ServiceFactory.php @@ -40,6 +40,7 @@ use Mpdf\Ua\StructureWriter; use Mpdf\Ua\AriaIdResolver; use Mpdf\Ua\ActualTextWriter; +use Mpdf\Ua\FlowingBlockMarker; use Mpdf\Ua\AnchorState; use Mpdf\Ua\InlineStructStack; use Mpdf\Ua\ImageMap\ImageMapRegistry; @@ -185,6 +186,8 @@ public function getServices( $structureTree->setUaState($uaState); $imageMapRegistry->setUaState($uaState); + $flowingBlockMarker = new FlowingBlockMarker($mpdf, $uaState); + $tag = new Tag( $mpdf, $cache, @@ -227,6 +230,7 @@ public function getServices( return [ 'uaState' => $uaState, + 'flowingBlockMarker' => $flowingBlockMarker, 'otl' => $otl, 'bmp' => $bmp, 'cache' => $cache, @@ -272,6 +276,7 @@ public function getServiceIds() { return [ 'uaState', + 'flowingBlockMarker', 'otl', 'bmp', 'cache', diff --git a/src/Ua/FlowingBlockMarker.php b/src/Ua/FlowingBlockMarker.php new file mode 100644 index 000000000..cc14ce2d2 --- /dev/null +++ b/src/Ua/FlowingBlockMarker.php @@ -0,0 +1,214 @@ +mpdf = $mpdf; + $this->ua = $ua; + } + + /** + * Close what the last flowing block left open, and mark nothing until a tag opens the new + * block's element. + * + * The block's marked content is opened as its text is drawn, not when its tag opens, as a + * block across pages needs an MCID on each. A
starts a new flowing block inside the same + * one, so whatever the last line opened is closed first. + * + * @return void + */ + public function reset() + { + $this->closeBlockBdcIfOpen(); + $this->mpdf->flowingBlockAttr['pdfua_struct_open'] = false; + $this->mpdf->flowingBlockAttr['pdfua_type'] = 'P'; + $this->mpdf->flowingBlockAttr['pdfua_artifact_open'] = false; + // Whether a BDC is open on this page that owes an EMC before the page or block ends + $this->mpdf->flowingBlockAttr['pdfua_bdc_active'] = false; + // The inline element the open BDC belongs to, or null when it is the block's + $this->mpdf->flowingBlockAttr['pdfua_bdc_elem'] = null; + // The block's own element, which the top of the structure stack may not be when an inline + // element is open inside it + $this->mpdf->flowingBlockAttr['pdfua_struct_elem'] = null; + } + + /** + * Open the block's marked content on this page, if it is not open already. + * + * The MCID is given to the block's own element rather than the top of the structure stack, + * which may be an inline element inside the block. + * + * @return void + */ + public function ensureBlockBdcOpen() + { + $attr = $this->mpdf->flowingBlockAttr; + // Text after an inline element goes back into the block's own marked content + if (!empty($attr['pdfua_bdc_active']) && !empty($attr['pdfua_bdc_elem'])) { + $this->closeBlockBdcIfOpen(); + } elseif (!empty($attr['pdfua_bdc_active'])) { + return; + } + if (!empty($attr['pdfua_artifact_open'])) { + $this->begin('Artifact', null); + } elseif (!empty($attr['pdfua_struct_open']) && isset($attr['pdfua_struct_elem'])) { + $this->begin($attr['pdfua_type'], $attr['pdfua_struct_elem']); + } + } + + /** + * End the marked content the flowing block has open, if any. + * + * @return bool Whether any was open + */ + public function closeBlockBdcIfOpen() + { + if (empty($this->mpdf->flowingBlockAttr['pdfua_bdc_active'])) { + return false; + } + $this->ua->getMarkedContentHelper()->end(); + $this->mpdf->flowingBlockAttr['pdfua_bdc_active'] = false; + $this->mpdf->flowingBlockAttr['pdfua_bdc_elem'] = null; + + return true; + } + + /** + * Whether a list marker drawn now is the content of the Lbl element Li::open() made. + * + * @return bool + */ + public function listMarkerHasLbl() + { + return !$this->mpdf->ColActive && isset($this->mpdf->blk[$this->mpdf->blklvl]['pdfua_li_lbl_elem']); + } + + /** + * Open the marked content a line's chunk is drawn in: that of the inline element restoreFont() + * says it is in, or else the block's. + * + * Table cell text always has an inline element (the cell, or one inside it). An object that + * marks its own content is drawn by printLineObject() after its chunk, and opens nothing here + * unless its chunk draws a span's background or border. In an artifact every object is drawn + * after the line's text, inside the artifact's marked content. + * + * @param int $k The chunk's key in $objectbuffer + * @param bool $is_table + * @return bool Whether the chunk is an object that marks its own content + */ + public function markLineChunk($k, $is_table) + { + $object = empty($this->mpdf->objectbuffer[$k]) ? null : $this->mpdf->objectbuffer[$k]; + $tagged = $object !== null + && in_array($object['type'], ['image', 'barcode', 'textcircle', 'listmarker', 'input', 'textarea', 'select'], true) + && ($object['type'] !== 'listmarker' || $this->listMarkerHasLbl()) + && empty($this->mpdf->flowingBlockAttr['pdfua_artifact_open']) + && !$this->ua->getStructureTree()->isInArtifact(); + if ($tagged && !$this->mpdf->spanbgcolor && empty($this->mpdf->spanborddet)) { + return true; + } + $inlineElem = $object === null ? $this->ua->getAnchorState()->getInlineContentElem() : null; + if ($inlineElem !== null) { + $this->ensureInlineBdcOpen($inlineElem); + } elseif (!$is_table) { + $this->ensureBlockBdcOpen(); + } + + return $tagged; + } + + /** + * Draw the object of a line's chunk now, so its marked content comes between the text before + * and after it, and not inside the text's. The next chunk of text begins its marked content again. + * + * What the object tags goes in the element the chunk is in, such as the Link around an image. + * + * @param int $k The chunk's key in $objectbuffer + * @param bool $is_table + * @param string|bool $blockdir + * @return void + */ + public function printLineObject($k, $is_table, $blockdir) + { + $this->closeBlockBdcIfOpen(); + + $parent = $this->ua->getAnchorState()->getInlineContentElem(); + if ($parent === null && !$is_table && isset($this->mpdf->flowingBlockAttr['pdfua_struct_elem'])) { + $parent = $this->mpdf->flowingBlockAttr['pdfua_struct_elem']; + } + + if ($parent !== null) { + $this->ua->getStructureTree()->pushExisting($parent); + } + $this->mpdf->printobjectbuffer($is_table, $blockdir, [$k => $this->mpdf->objectbuffer[$k]]); + if ($parent !== null) { + $this->ua->getStructureTree()->close(); + } + unset($this->mpdf->objectbuffer[$k]); + } + + /** + * Open marked content for text inside an inline element (a Link, a Span with its own + * language, an Abbr, ruby), closing whatever the block had open. + * + * An MCID belongs to one element, so the text needs its own for the Link to have content and + * for the Span's /Lang or the Abbr's /E to apply to anything. + * + * @param StructureElement $elem The inline element the chunk is in + * @return void + */ + private function ensureInlineBdcOpen(StructureElement $elem) + { + if (!empty($this->mpdf->flowingBlockAttr['pdfua_bdc_active']) + && $this->mpdf->flowingBlockAttr['pdfua_bdc_elem'] === $elem) { + return; + } + $this->closeBlockBdcIfOpen(); + // Inside an artifact block no inline element was made, so the text is artifact too + if (!empty($this->mpdf->flowingBlockAttr['pdfua_artifact_open'])) { + $this->begin('Artifact', null); + } else { + $this->begin($elem->getType(), $elem, $elem); + } + } + + /** + * Begin the flowing block's marked content. + * + * @param string $type + * @param StructureElement|null $elem The element given the MCID, or null for an artifact + * @param StructureElement|null $bdcElem The inline element it is for, or null when it is the block's + * @return void + */ + private function begin($type, $elem, $bdcElem = null) + { + $mcid = $elem === null + ? -1 + : $this->ua->getStructureTree()->addContentForElement($elem, $this->mpdf->getPdfUaStructParents()); + $this->ua->getMarkedContentHelper()->begin($type, $mcid); + $this->mpdf->flowingBlockAttr['pdfua_bdc_active'] = true; + $this->mpdf->flowingBlockAttr['pdfua_bdc_elem'] = $bdcElem; + } +} diff --git a/tests/Mpdf/Ua/InlineObjectReadingOrderTest.php b/tests/Mpdf/Ua/InlineObjectReadingOrderTest.php index 0b0b12799..877470bda 100644 --- a/tests/Mpdf/Ua/InlineObjectReadingOrderTest.php +++ b/tests/Mpdf/Ua/InlineObjectReadingOrderTest.php @@ -120,7 +120,7 @@ private function kidsOf($pdf, $type) } } $this->assertNotNull($body, 'no /' . $type . ' element'); - $this->assertSame(1, preg_match('@/K (\[.*\]|<<[^>]*>>|\d+ 0 R|\d+)\s*>>\s*$@s', $body, $k)); + $this->assertSame(1, preg_match('@/K (\[.*\])\s*>>\s*$@s', $body, $k)); preg_match_all('@<]*/MCID (\d+)>>|<]*>>|(\d+) 0 R|\d+@', $k[1], $refs, PREG_SET_ORDER); $kids = [];