PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 1.9.12 1.9.11 1.9.10 1.9.9 All 158 releases
woocommerce-pos / includes / Services / Pdf_Layout_Preprocessor.php

Pdf_Layout_Preprocessor.php in WCPOS – Point of Sale (POS) plugin for WooCommerce trunk, at includes/Services/Pdf_Layout_Preprocessor.php

1,260 lines 38.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Rewrites receipt HTML into Dompdf-friendly markup before PDF rendering.
4 *
5 * Dompdf has no CSS Flexbox or Grid layout engine — it silently maps
6 * `display:flex` / `display:grid` to `block`, which collapses receipt rows
7 * built as columns. The previous approach shimmed this with CSS attribute
8 * selectors keyed on inline-style substrings, but that only covered the exact
9 * pixel values used by the bundled templates and leaned on Dompdf floats,
10 * whose placement is buggy (consecutive floated values stack leftward).
11 *
12 * This preprocessor instead parses the HTML and rewrites every inline-styled
13 * flex/grid container into a real `<table>` — Dompdf's most reliable layout
14 * primitive — computing cell widths from the actual `flex` /
15 * `grid-template-columns` / `gap` values, so customized templates work as well
16 * as the bundled ones. It also lifts the root element's padding into `@page`
17 * margins so the PDF page box matches the on-screen preview (Dompdf's default
18 * 1.2cm page margin would otherwise be added on top of the template padding).
19 *
20 * Supported CSS subset (template authors take note):
21 * - Inline `style=""` declarations only; class/stylesheet-based flex is not
22 * rewritten (the bundled legacy template's classes are shimmed separately
23 * in Pdf_Renderer::LEGACY_FLEX_SHIM).
24 * - Lengths in `px`/`pt` only — other units in `gap`/`flex-basis`/`padding`
25 * fall back to shrink-to-content / unlifted padding.
26 * - `grid-template-columns`: `fr`, `px`, `auto`, and `repeat(N, …)`;
27 * `minmax()`/`%` degrade to an even flexible column. Children are chunked
28 * into rows of N columns (row auto-flow only).
29 * - `justify-content: space-between|space-around|space-evenly` rows become
30 * label/value tables with the last cell right-aligned; `center|flex-end`
31 * runs become text-aligned inline-blocks when no child has a fixed basis.
32 * - The padding lift requires a single root element.
33 *
34 * Only the PDF render path uses this class; gallery templates and live
35 * previews are never modified.
36 *
37 * @package WCPOS\WooCommercePOS\Services
38 */
39
40 namespace WCPOS\WooCommercePOS\Services;
41
42 use DOMDocument;
43 use DOMElement;
44
45 /**
46 * Pdf_Layout_Preprocessor class.
47 */
48 class Pdf_Layout_Preprocessor {
49
50 /**
51 * Points per CSS pixel (72dpi PDF space vs 96dpi CSS space).
52 */
53 private const PT_PER_PX = 0.75;
54
55 /**
56 * Root padding lifted off the receipt wrapper, as @page margins in pt
57 * (top, right, bottom, left). Zero margins when no padding was lifted —
58 * the preview shows none around an unpadded root either.
59 *
60 * @var float[]
61 */
62 private $page_margins_pt = array( 0.0, 0.0, 0.0, 0.0 );
63
64 /**
65 * Whether the last process() input was a full HTML document.
66 *
67 * @var bool
68 */
69 private $full_document = false;
70
71 /**
72 * Rewrite flex/grid receipt markup into Dompdf-friendly tables.
73 *
74 * Fragments (logicless/thermal output) additionally get their root padding
75 * lifted into @page margins. Full documents (the legacy-php template) keep
76 * their <head> stylesheet and page box untouched: only the in-body flex
77 * containers and known legacy classes are rewritten.
78 *
79 * @param string $html Receipt HTML (fragment or full document).
80 *
81 * @return string The rewritten HTML.
82 */
83 public function process( string $html ): string {
84 $this->page_margins_pt = array( 0.0, 0.0, 0.0, 0.0 );
85 $this->full_document = false;
86
87 if ( '' === trim( $html ) ) {
88 return $html;
89 }
90
91 $full_document = false !== stripos( $html, '<html' );
92 $this->full_document = $full_document;
93
94 $dom = new DOMDocument( '1.0', 'UTF-8' );
95 $previous = libxml_use_internal_errors( true );
96 // The processing instruction pins the parser to UTF-8 for fragments;
97 // loadHTML otherwise assumes ISO-8859-1 and mangles multibyte text.
98 $loaded = $dom->loadHTML( '<?xml encoding="UTF-8">' . $html );
99 libxml_clear_errors();
100 libxml_use_internal_errors( $previous );
101
102 if ( ! $loaded ) {
103 return $html;
104 }
105
106 $body = $dom->getElementsByTagName( 'body' )->item( 0 );
107 if ( ! $body instanceof DOMElement ) {
108 return $html;
109 }
110
111 if ( ! $full_document ) {
112 $this->lift_root_padding( $body );
113 }
114 $this->transform_children( $body );
115
116 if ( $full_document ) {
117 // Serialize the whole document so <head> styles survive, dropping
118 // the synthetic XML prolog the UTF-8 pinning added.
119 foreach ( $dom->childNodes as $node ) {
120 if ( XML_PI_NODE === $node->nodeType ) {
121 $dom->removeChild( $node );
122 break;
123 }
124 }
125
126 return (string) $dom->saveHTML();
127 }
128
129 $out = '';
130 foreach ( $body->childNodes as $child ) {
131 $out .= $dom->saveHTML( $child );
132 }
133
134 return $out;
135 }
136
137 /**
138 * Page margins (pt) lifted from the receipt root padding by process().
139 *
140 * Only meaningful after process() has run; zeros otherwise.
141 *
142 * @return float[] [top, right, bottom, left] in pt.
143 */
144 public function get_page_margins_pt(): array {
145 return $this->page_margins_pt;
146 }
147
148 /**
149 * Whether the last process() input was a full HTML document.
150 *
151 * Callers branch on this instead of sniffing the markup themselves, so the
152 * renderer and the preprocessor can never disagree about which treatment
153 * (fragment @page margins vs. document-owned page box) an input received.
154 *
155 * @return bool
156 */
157 public function is_full_document(): bool {
158 return $this->full_document;
159 }
160
161 /**
162 * Move the root element's padding into @page margins.
163 *
164 * The browser preview shows the template's own root padding as the only
165 * whitespace around the receipt. Replacing Dompdf's default 1.2cm page
166 * margin with that padding keeps page one identical to the preview and
167 * gives later pages the same consistent margins.
168 *
169 * @param DOMElement $body The document body.
170 */
171 private function lift_root_padding( DOMElement $body ): void {
172 $root = null;
173 foreach ( $body->childNodes as $child ) {
174 if ( $child instanceof DOMElement ) {
175 if ( null !== $root ) {
176 return; // Multiple roots — ambiguous, leave padding alone.
177 }
178 $root = $child;
179 } elseif ( XML_TEXT_NODE === $child->nodeType && '' !== trim( (string) $child->nodeValue ) ) {
180 return; // Loose text next to the root element.
181 }
182 }
183
184 if ( null === $root ) {
185 return;
186 }
187
188 $styles = self::parse_styles( $root->getAttribute( 'style' ) );
189 $padding = self::resolve_padding_px( $styles );
190 if ( null === $padding ) {
191 return;
192 }
193
194 unset( $styles['padding'], $styles['padding-top'], $styles['padding-right'], $styles['padding-bottom'], $styles['padding-left'] );
195 self::set_styles( $root, $styles );
196
197 $this->page_margins_pt = array_map(
198 static function ( float $px ): float {
199 return round( $px * self::PT_PER_PX, 2 );
200 },
201 $padding
202 );
203 }
204
205 /**
206 * Recursively transform flex/grid containers, deepest first.
207 *
208 * @param DOMElement $element The element whose children to transform.
209 */
210 private function transform_children( DOMElement $element ): void {
211 // Snapshot: transformation replaces nodes in place.
212 $children = array();
213 foreach ( $element->childNodes as $child ) {
214 if ( $child instanceof DOMElement ) {
215 $children[] = $child;
216 }
217 }
218
219 foreach ( $children as $child ) {
220 $this->transform_children( $child );
221 $this->transform_element( $child );
222 }
223 }
224
225 /**
226 * Transform a single flex/grid container into Dompdf-friendly markup.
227 *
228 * @param DOMElement $element The candidate container.
229 */
230 private function transform_element( DOMElement $element ): void {
231 $styles = self::parse_styles( $element->getAttribute( 'style' ) );
232 $display = isset( $styles['display'] ) ? strtolower( $styles['display'] ) : '';
233
234 if ( 'inline-flex' === $display || 'inline-grid' === $display ) {
235 $this->convert_inline_flex( $element, $styles );
236 return;
237 }
238
239 if ( 'flex' !== $display && 'grid' !== $display ) {
240 // The bundled legacy-php template declares its flex in a <head>
241 // stylesheet rather than inline styles; its class names are stable,
242 // so they get the same table treatment, keyed by class.
243 $this->convert_legacy_classes( $element );
244 return;
245 }
246
247 $children = self::element_children( $element );
248 if ( 0 === \count( $children ) ) {
249 // Spacer divs (e.g. divider lines) just need flex dropped.
250 unset( $styles['display'] );
251 self::set_styles( $element, $styles );
252 return;
253 }
254
255 $justify = isset( $styles['justify-content'] ) ? strtolower( $styles['justify-content'] ) : '';
256
257 // Column flex stacks children like normal block flow; a row table
258 // would rotate the content sideways.
259 $direction = isset( $styles['flex-direction'] ) ? strtolower( trim( $styles['flex-direction'] ) ) : '';
260 if ( 'flex' === $display && 0 === strpos( $direction, 'column' ) ) {
261 $this->convert_column_stack( $element, $styles, $children );
262 return;
263 }
264
265 // Simple aligned runs (a right-pushed barcode, a centered badge row)
266 // read better as text-aligned inline-blocks than as a table.
267 if ( \in_array( $justify, array( 'flex-end', 'end', 'right', 'center' ), true ) && ! self::has_sized_child( $children ) ) {
268 $this->convert_aligned_run( $element, $styles, $children, $justify );
269 return;
270 }
271
272 if ( 'grid' === $display ) {
273 $this->convert_grid( $element, $styles, $children );
274 return;
275 }
276
277 $this->convert_flex_row( $element, $styles, $children );
278 }
279
280 /**
281 * Convert a flex row into a single-row table.
282 *
283 * @param DOMElement $element The flex container.
284 * @param array $styles Parsed container styles.
285 * @param DOMElement[] $children The container's element children.
286 */
287 private function convert_flex_row( DOMElement $element, array $styles, array $children ): void {
288 $justify = isset( $styles['justify-content'] ) ? strtolower( $styles['justify-content'] ) : '';
289 $gap = self::parse_gap_px( $styles );
290
291 $specs = array();
292 $grow_count = 0;
293 $has_fixed = false;
294 foreach ( $children as $child ) {
295 $spec = self::flex_child_spec( $child );
296 if ( 'grow' === $spec['kind'] ) {
297 ++$grow_count;
298 }
299 if ( 'fixed' === $spec['kind'] ) {
300 $has_fixed = true;
301 }
302 $specs[] = $spec;
303 }
304
305 // space-between label/value rows: let the table spread the cells and
306 // right-align the last one, mirroring how the browser pushes it flush.
307 $space_between = \in_array( $justify, array( 'space-between', 'space-around', 'space-evenly' ), true );
308
309 // All children grow equally (e.g. three flex:1 sign-off columns):
310 // fixed layout splits the width evenly like flexbox would.
311 $equal_split = ! $space_between && ! $has_fixed && \count( $children ) === $grow_count && $grow_count > 1;
312
313 $table = $this->build_table( $element, $styles );
314 if ( $equal_split ) {
315 self::append_style( $table, 'table-layout', 'fixed' );
316 }
317
318 $row = $element->ownerDocument->createElement( 'tr' );
319 $table->appendChild( $row );
320
321 $valign = self::vertical_align( $styles );
322 $last = \count( $children ) - 1;
323
324 foreach ( $children as $i => $child ) {
325 $cell_styles = array( 'vertical-align' => $valign );
326
327 if ( $i > 0 && $gap[1] > 0 ) {
328 $cell_styles['padding-left'] = self::css_number( $gap[1] ) . 'px';
329 }
330
331 $spec = $specs[ $i ];
332 if ( 'fixed' === $spec['kind'] ) {
333 $cell_styles['width'] = $spec['width'];
334 } elseif ( 'shrink' === $spec['kind'] && ! $space_between ) {
335 // width:1% + nowrap shrinks the cell to its content like
336 // flex-basis:auto would; remaining width flows to grow cells.
337 $cell_styles['width'] = '1%';
338 $cell_styles['white-space'] = 'nowrap';
339 }
340
341 if ( $space_between && $i === $last && $i > 0 ) {
342 $cell_styles['text-align'] = 'right';
343 } elseif ( $space_between && $i > 0 && $i < $last ) {
344 $cell_styles['text-align'] = 'center';
345 }
346
347 $this->append_cell( $row, $child, $cell_styles );
348 }
349
350 $element->parentNode->replaceChild( $table, $element );
351 }
352
353 /**
354 * Convert a grid into a table, chunking children into rows of N columns.
355 *
356 * @param DOMElement $element The grid container.
357 * @param array $styles Parsed container styles.
358 * @param DOMElement[] $children The container's element children.
359 */
360 private function convert_grid( DOMElement $element, array $styles, array $children ): void {
361 $columns = self::parse_grid_columns( isset( $styles['grid-template-columns'] ) ? $styles['grid-template-columns'] : '' );
362 if ( 0 === \count( $columns ) ) {
363 $columns = array(
364 array(
365 'kind' => 'fr',
366 'value' => 1.0,
367 ),
368 );
369 }
370
371 $gap = self::parse_gap_px( $styles );
372 $col_n = \count( $columns );
373 $fr_total = 0.0;
374 $only_fr = true;
375 foreach ( $columns as $column ) {
376 if ( 'fr' === $column['kind'] ) {
377 $fr_total += $column['value'];
378 } else {
379 $only_fr = false;
380 }
381 }
382
383 $table = $this->build_table( $element, $styles );
384 if ( $only_fr && $col_n > 1 ) {
385 self::append_style( $table, 'table-layout', 'fixed' );
386 }
387
388 $valign = self::vertical_align( $styles );
389 $rows = array_chunk( $children, $col_n );
390
391 foreach ( $rows as $row_index => $row_children ) {
392 $row = $element->ownerDocument->createElement( 'tr' );
393 $table->appendChild( $row );
394
395 foreach ( $row_children as $i => $child ) {
396 $cell_styles = array( 'vertical-align' => $valign );
397
398 if ( $i > 0 && $gap[1] > 0 ) {
399 $cell_styles['padding-left'] = self::css_number( $gap[1] ) . 'px';
400 }
401 if ( $row_index > 0 && $gap[0] > 0 ) {
402 $cell_styles['padding-top'] = self::css_number( $gap[0] ) . 'px';
403 }
404
405 $column = $columns[ $i ];
406 if ( 'px' === $column['kind'] ) {
407 $cell_styles['width'] = self::css_number( $column['value'] ) . 'px';
408 } elseif ( 'auto' === $column['kind'] ) {
409 $cell_styles['width'] = '1%';
410 $cell_styles['white-space'] = 'nowrap';
411 } elseif ( $only_fr && $fr_total > 0 ) {
412 $cell_styles['width'] = self::css_number( $column['value'] / $fr_total * 100 ) . '%';
413 }
414
415 $this->append_cell( $row, $child, $cell_styles );
416 }
417 }
418
419 $element->parentNode->replaceChild( $table, $element );
420 }
421
422 /**
423 * Convert a column flex container into a plain block stack.
424 *
425 * @param DOMElement $element The flex container.
426 * @param array $styles Parsed container styles.
427 * @param DOMElement[] $children The container's element children.
428 */
429 private function convert_column_stack( DOMElement $element, array $styles, array $children ): void {
430 $gap = self::parse_gap_px( $styles );
431
432 self::strip_layout_styles( $styles );
433 self::set_styles( $element, $styles );
434
435 foreach ( $children as $i => $child ) {
436 $child_styles = self::parse_styles( $child->getAttribute( 'style' ) );
437 self::strip_flex_child_styles( $child_styles );
438 if ( $i > 0 && $gap[0] > 0 ) {
439 $child_styles['margin-top'] = self::css_number( $gap[0] ) . 'px';
440 }
441 self::set_styles( $child, $child_styles );
442 }
443 }
444
445 /**
446 * Convert a centered/right-aligned flex run into text-aligned inline-blocks.
447 *
448 * @param DOMElement $element The flex container.
449 * @param array $styles Parsed container styles.
450 * @param DOMElement[] $children The container's element children.
451 * @param string $justify The normalized justify-content value.
452 */
453 private function convert_aligned_run( DOMElement $element, array $styles, array $children, string $justify ): void {
454 $gap = self::parse_gap_px( $styles );
455
456 self::strip_layout_styles( $styles );
457 $styles['text-align'] = 'center' === $justify ? 'center' : 'right';
458 self::set_styles( $element, $styles );
459
460 foreach ( $children as $i => $child ) {
461 $child_styles = self::parse_styles( $child->getAttribute( 'style' ) );
462 self::strip_flex_child_styles( $child_styles );
463 $child_styles['display'] = 'inline-block';
464 if ( $i > 0 && $gap[1] > 0 ) {
465 $child_styles['margin-left'] = self::css_number( $gap[1] ) . 'px';
466 }
467 self::set_styles( $child, $child_styles );
468 }
469 }
470
471 /**
472 * Convert an inline-flex container (status pills) into inline-blocks.
473 *
474 * The pill's dot is a fixed-size span; inline-block lets its width/height
475 * apply, which plain inline display would collapse. Whitespace between the
476 * chip's parts becomes non-breaking: a flex row never wraps its items, and
477 * Dompdf's word-based minimum-width sizing would otherwise wrap the chip
478 * inside shrink-to-content table cells.
479 *
480 * @param DOMElement $element The inline-flex container.
481 * @param array $styles Parsed container styles.
482 */
483 private function convert_inline_flex( DOMElement $element, array $styles ): void {
484 $gap = self::parse_gap_px( $styles );
485
486 self::strip_layout_styles( $styles );
487 $styles['display'] = 'inline-block';
488 self::set_styles( $element, $styles );
489
490 foreach ( $element->childNodes as $node ) {
491 if ( XML_TEXT_NODE === $node->nodeType && null !== $node->nodeValue ) {
492 $node->nodeValue = (string) preg_replace( '/\s+/u', "\u{00A0}", $node->nodeValue );
493 }
494 }
495 // Multibyte-safe trim: ltrim/rtrim would strip the NBSP's individual
496 // bytes and corrupt adjacent UTF-8 characters (e.g. £ shares 0xC2).
497 $first = $element->firstChild;
498 if ( null !== $first && XML_TEXT_NODE === $first->nodeType ) {
499 $first->nodeValue = (string) preg_replace( '/^\x{00A0}+/u', '', (string) $first->nodeValue );
500 }
501 $last = $element->lastChild;
502 if ( null !== $last && XML_TEXT_NODE === $last->nodeType ) {
503 $last->nodeValue = (string) preg_replace( '/\x{00A0}+$/u', '', (string) $last->nodeValue );
504 }
505
506 $children = self::element_children( $element );
507 foreach ( $children as $child ) {
508 $child_styles = self::parse_styles( $child->getAttribute( 'style' ) );
509 self::strip_flex_child_styles( $child_styles );
510 // Natural baseline alignment, not vertical-align:middle — Dompdf
511 // raises "middle" inline-blocks to cap height, floating the chip's
512 // dot above the label. On the baseline a fixed-size dot's optical
513 // center lands at the uppercase midline, matching the browser's
514 // flex centering (Dompdf ignores length values for vertical-align,
515 // so a fine-tuned offset is not an option).
516 $child_styles['display'] = 'inline-block';
517 // Mirror the flex gap after every child that has following content
518 // — a chip's label may be a bare text node, which margin-left on
519 // the next element child could never reach.
520 if ( $gap[1] > 0 && self::has_following_content( $child ) ) {
521 $child_styles['margin-right'] = self::css_number( $gap[1] ) . 'px';
522 }
523 self::set_styles( $child, $child_styles );
524 }
525 }
526
527 /**
528 * Rewrite the bundled legacy template's class-based flex containers.
529 *
530 * The legacy receipt.php keeps its layout in a <head> stylesheet, which the
531 * inline-style transforms cannot see. Its class names are stable, so the
532 * known containers are wrapped IN PLACE: the element keeps its class (the
533 * stylesheet's colors/spacing still apply; its display:flex degrades to
534 * block under Dompdf) and the children move into a real table inside it.
535 * Without width hints Dompdf's auto table layout distributes leftover width
536 * across all cells, inflating the logo cell and drifting floated values.
537 *
538 * @param DOMElement $element The candidate element.
539 */
540 private function convert_legacy_classes( DOMElement $element ): void {
541 if ( self::has_class( $element, 'receipt-header' ) ) {
542 $this->wrap_legacy_header( $element );
543 return;
544 }
545
546 if ( self::has_class( $element, 'status-pill' ) ) {
547 $this->convert_legacy_status_pill( $element );
548 return;
549 }
550
551 $is_label_value_row = self::has_class( $element, 'totals-row' )
552 || self::has_class( $element, 'payment-row' )
553 || self::has_class( $element, 'payment-sub' )
554 || ( self::has_class( $element, 'row' ) && self::has_ancestor_class( $element, 'card' ) );
555
556 if ( $is_label_value_row ) {
557 $this->wrap_legacy_label_value_row( $element );
558 }
559 }
560
561 /**
562 * Convert the legacy status pill into an unbreakable inline-block chip.
563 *
564 * The stylesheet's inline-flex/gap are invisible here, so the chip gets
565 * inline display:inline-block (winning over the stylesheet), the dot keeps
566 * natural baseline alignment (Dompdf raises vertical-align:middle to cap
567 * height), and the flex gap is mirrored as a margin on element children
568 * that have following content (the label is a bare text node).
569 *
570 * @param DOMElement $element The .status-pill element.
571 */
572 private function convert_legacy_status_pill( DOMElement $element ): void {
573 self::append_style( $element, 'display', 'inline-block' );
574
575 foreach ( self::element_children( $element ) as $child ) {
576 $child_styles = self::parse_styles( $child->getAttribute( 'style' ) );
577 $child_styles['display'] = 'inline-block';
578 if ( self::has_following_content( $child ) ) {
579 // The stylesheet's flex gap (6px), mirrored from receipt.php.
580 $child_styles['margin-right'] = '6px';
581 }
582 self::set_styles( $child, $child_styles );
583 }
584
585 // Non-breaking whitespace: a flex chip never wraps, and Dompdf's
586 // word-based minimum width would otherwise wrap it inside
587 // shrink-to-content table cells.
588 foreach ( $element->childNodes as $node ) {
589 if ( XML_TEXT_NODE === $node->nodeType && null !== $node->nodeValue ) {
590 $node->nodeValue = (string) preg_replace( '/\s+/u', "\u{00A0}", trim( (string) $node->nodeValue ) );
591 }
592 }
593 }
594
595 /**
596 * Wrap the legacy header's logo/store/meta children in a hinted table.
597 *
598 * @param DOMElement $element The .receipt-header element.
599 */
600 private function wrap_legacy_header( DOMElement $element ): void {
601 $children = self::element_children( $element );
602 if ( 0 === \count( $children ) ) {
603 return;
604 }
605
606 $cells = array();
607 foreach ( $children as $i => $child ) {
608 $cell_styles = array( 'vertical-align' => 'top' );
609 if ( $i > 0 ) {
610 // The stylesheet's flex gap (22px) — gaps are unreachable from
611 // class-based CSS here, so the bundled value is mirrored.
612 $cell_styles['padding-left'] = '22px';
613 }
614
615 // .logo / .meta shrink to content like flex 0 0 auto; the .store
616 // column stays width-less and absorbs the leftover width.
617 if ( ! self::has_class( $child, 'store' ) ) {
618 $cell_styles['width'] = '1%';
619 $cell_styles['white-space'] = 'nowrap';
620 }
621
622 $cells[] = $cell_styles;
623 }
624
625 $this->wrap_children_in_row_table( $element, $children, $cells );
626 }
627
628 /**
629 * Wrap a legacy label/value row in a table with a right-aligned last cell.
630 *
631 * Replaces the old float-right shim for .totals-row/.payment-row/
632 * .payment-sub/.card .row: Dompdf stacks consecutive floats leftward,
633 * drifting the lower values (tendered/change) off the edge.
634 *
635 * @param DOMElement $element The row element.
636 */
637 private function wrap_legacy_label_value_row( DOMElement $element ): void {
638 $children = self::element_children( $element );
639 if ( \count( $children ) < 2 ) {
640 return;
641 }
642
643 $last = \count( $children ) - 1;
644 $cells = array();
645 foreach ( $children as $i => $child ) {
646 $cell_styles = array( 'vertical-align' => 'top' );
647 if ( $i === $last ) {
648 $cell_styles['text-align'] = 'right';
649 }
650 $cells[] = $cell_styles;
651 }
652
653 $this->wrap_children_in_row_table( $element, $children, $cells );
654 }
655
656 /**
657 * Move an element's children into a single-row table inside the element.
658 *
659 * The container element itself is preserved so its class-based styling
660 * (padding, borders, typography) keeps applying.
661 *
662 * @param DOMElement $element The container element.
663 * @param DOMElement[] $children The container's element children.
664 * @param array[] $cells Style maps for each cell, by child index.
665 */
666 private function wrap_children_in_row_table( DOMElement $element, array $children, array $cells ): void {
667 $document = $element->ownerDocument;
668
669 $table = $document->createElement( 'table' );
670 $table->setAttribute( 'style', 'width: 100%; border-spacing: 0' );
671 $row = $document->createElement( 'tr' );
672 $table->appendChild( $row );
673
674 foreach ( $children as $i => $child ) {
675 $cell = $document->createElement( 'td' );
676 $style_text = self::build_styles( isset( $cells[ $i ] ) ? $cells[ $i ] : array() );
677 if ( '' !== $style_text ) {
678 $cell->setAttribute( 'style', $style_text );
679 }
680 $row->appendChild( $cell );
681 $cell->appendChild( $child );
682 }
683
684 // Drop leftover inter-child whitespace so it cannot form an extra line
685 // box above the table.
686 foreach ( iterator_to_array( $element->childNodes ) as $node ) {
687 if ( XML_TEXT_NODE === $node->nodeType && '' === trim( (string) $node->nodeValue ) ) {
688 $element->removeChild( $node );
689 }
690 }
691
692 $element->appendChild( $table );
693 }
694
695 /**
696 * Whether a node is followed by rendered content (element or real text).
697 *
698 * Whitespace-only and NBSP-only text nodes do not count: chip edge
699 * whitespace is trimmed to empty nodes that must not attract gap margins.
700 *
701 * @param \DOMNode $node The node.
702 *
703 * @return bool
704 */
705 private static function has_following_content( \DOMNode $node ): bool {
706 for ( $next = $node->nextSibling; null !== $next; $next = $next->nextSibling ) {
707 if ( $next instanceof DOMElement ) {
708 return true;
709 }
710 if ( XML_TEXT_NODE === $next->nodeType && 1 === preg_match( '/[^\s\x{00A0}]/u', (string) $next->nodeValue ) ) {
711 return true;
712 }
713 }
714
715 return false;
716 }
717
718 /**
719 * Whether an element carries a class name.
720 *
721 * @param DOMElement $element The element.
722 * @param string $name The class name.
723 *
724 * @return bool
725 */
726 private static function has_class( DOMElement $element, string $name ): bool {
727 return \in_array( $name, preg_split( '/\s+/', trim( $element->getAttribute( 'class' ) ) ), true );
728 }
729
730 /**
731 * Whether any ancestor element carries a class name.
732 *
733 * @param DOMElement $element The element.
734 * @param string $name The class name.
735 *
736 * @return bool
737 */
738 private static function has_ancestor_class( DOMElement $element, string $name ): bool {
739 for ( $parent = $element->parentNode; $parent instanceof DOMElement; $parent = $parent->parentNode ) {
740 if ( self::has_class( $parent, $name ) ) {
741 return true;
742 }
743 }
744
745 return false;
746 }
747
748 /**
749 * Create the replacement table carrying the container's non-layout styles.
750 *
751 * @param DOMElement $element The flex/grid container being replaced.
752 * @param array $styles Parsed container styles.
753 *
754 * @return DOMElement The new (detached) table element.
755 */
756 private function build_table( DOMElement $element, array $styles ): DOMElement {
757 $table = $element->ownerDocument->createElement( 'table' );
758
759 self::strip_layout_styles( $styles );
760 $styles['width'] = '100%';
761 $styles['border-spacing'] = '0';
762 // border-collapse:separate keeps cell padding-based gaps intact.
763
764 $style_text = self::build_styles( $styles );
765 if ( '' !== $style_text ) {
766 $table->setAttribute( 'style', $style_text );
767 }
768
769 foreach ( array( 'class', 'id', 'dir' ) as $attribute ) {
770 if ( $element->hasAttribute( $attribute ) ) {
771 $table->setAttribute( $attribute, $element->getAttribute( $attribute ) );
772 }
773 }
774
775 return $table;
776 }
777
778 /**
779 * Append a cell wrapping an original flex/grid child.
780 *
781 * The child element is moved into the cell unchanged (minus its flex
782 * sizing properties), so its own borders, padding and backgrounds keep
783 * rendering exactly as authored.
784 *
785 * @param DOMElement $row The table row.
786 * @param DOMElement $child The original container child.
787 * @param array $cell_styles Styles for the new cell.
788 */
789 private function append_cell( DOMElement $row, DOMElement $child, array $cell_styles ): void {
790 $cell = $row->ownerDocument->createElement( 'td' );
791 $row->appendChild( $cell );
792
793 $style_text = self::build_styles( $cell_styles );
794 if ( '' !== $style_text ) {
795 $cell->setAttribute( 'style', $style_text );
796 }
797
798 $child_styles = self::parse_styles( $child->getAttribute( 'style' ) );
799 self::strip_flex_child_styles( $child_styles );
800 self::set_styles( $child, $child_styles );
801
802 $cell->appendChild( $child );
803 }
804
805 /**
806 * Whether any child carries an explicit flex basis width.
807 *
808 * @param DOMElement[] $children The container's element children.
809 *
810 * @return bool
811 */
812 private static function has_sized_child( array $children ): bool {
813 foreach ( $children as $child ) {
814 if ( 'fixed' === self::flex_child_spec( $child )['kind'] ) {
815 return true;
816 }
817 }
818
819 return false;
820 }
821
822 /**
823 * Classify a flex child's sizing from its `flex` shorthand.
824 *
825 * @param DOMElement $child The flex child.
826 *
827 * @return array{kind:string,width:string} kind: grow|shrink|fixed|auto.
828 */
829 private static function flex_child_spec( DOMElement $child ): array {
830 $styles = self::parse_styles( $child->getAttribute( 'style' ) );
831 $flex = isset( $styles['flex'] ) ? strtolower( trim( $styles['flex'] ) ) : '';
832
833 if ( '' === $flex ) {
834 if ( isset( $styles['flex-grow'] ) && (float) $styles['flex-grow'] > 0 ) {
835 return array(
836 'kind' => 'grow',
837 'width' => '',
838 );
839 }
840
841 return array(
842 'kind' => 'auto',
843 'width' => '',
844 );
845 }
846
847 // flex: auto is the 1 1 auto shorthand — a growing column.
848 if ( 'auto' === $flex ) {
849 return array(
850 'kind' => 'grow',
851 'width' => '',
852 );
853 }
854
855 $parts = preg_split( '/\s+/', $flex );
856 $grow = is_numeric( $parts[0] ) ? (float) $parts[0] : 0.0;
857 $basis = \count( $parts ) >= 3 ? $parts[2] : ( isset( $parts[1] ) && ! is_numeric( $parts[1] ) ? $parts[1] : 'auto' );
858 if ( 1 === \count( $parts ) && ! is_numeric( $parts[0] ) ) {
859 $basis = $parts[0];
860 }
861
862 if ( $grow > 0 ) {
863 return array(
864 'kind' => 'grow',
865 'width' => '',
866 );
867 }
868
869 $basis_px = self::length_to_px( $basis );
870 if ( null !== $basis_px && $basis_px > 0 ) {
871 return array(
872 'kind' => 'fixed',
873 'width' => self::css_number( $basis_px ) . 'px',
874 );
875 }
876
877 return array(
878 'kind' => 'shrink',
879 'width' => '',
880 );
881 }
882
883 /**
884 * Parse grid-template-columns into px/fr/auto column specs.
885 *
886 * @param string $value The grid-template-columns value.
887 *
888 * @return array<int,array{kind:string,value:float}>
889 */
890 private static function parse_grid_columns( string $value ): array {
891 $value = strtolower( trim( $value ) );
892 if ( '' === $value ) {
893 return array();
894 }
895
896 // Expand simple repeat(N, token) constructs.
897 $value = (string) preg_replace_callback(
898 '/repeat\(\s*(\d+)\s*,\s*([^()]+)\)/',
899 static function ( array $matches ): string {
900 return trim( implode( ' ', array_fill( 0, max( 1, (int) $matches[1] ), trim( $matches[2] ) ) ) );
901 },
902 $value
903 );
904
905 $columns = array();
906 foreach ( preg_split( '/\s+/', $value ) as $token ) {
907 if ( '' === $token ) {
908 continue;
909 }
910
911 if ( preg_match( '/^([0-9.]+)fr$/', $token, $m ) ) {
912 $columns[] = array(
913 'kind' => 'fr',
914 'value' => (float) $m[1],
915 );
916 continue;
917 }
918
919 if ( 'auto' === $token || 'min-content' === $token || 'max-content' === $token ) {
920 $columns[] = array(
921 'kind' => 'auto',
922 'value' => 0.0,
923 );
924 continue;
925 }
926
927 $px = self::length_to_px( $token );
928 if ( null !== $px ) {
929 $columns[] = array(
930 'kind' => 'px',
931 'value' => $px,
932 );
933 continue;
934 }
935
936 // Unknown token (minmax(), %) — treat as an even flexible column.
937 $columns[] = array(
938 'kind' => 'fr',
939 'value' => 1.0,
940 );
941 }
942
943 return $columns;
944 }
945
946 /**
947 * Parse the container gap into [row, column] pixels.
948 *
949 * @param array $styles Parsed container styles.
950 *
951 * @return array{0:float,1:float} [row gap, column gap] in px.
952 */
953 private static function parse_gap_px( array $styles ): array {
954 $row = 0.0;
955 $col = 0.0;
956
957 if ( isset( $styles['gap'] ) ) {
958 $parts = preg_split( '/\s+/', trim( $styles['gap'] ) );
959 $row = (float) ( self::length_to_px( $parts[0] ) ?? 0.0 );
960 $col = isset( $parts[1] ) ? (float) ( self::length_to_px( $parts[1] ) ?? 0.0 ) : $row;
961 }
962
963 if ( isset( $styles['row-gap'] ) ) {
964 $row = (float) ( self::length_to_px( $styles['row-gap'] ) ?? $row );
965 }
966 if ( isset( $styles['column-gap'] ) ) {
967 $col = (float) ( self::length_to_px( $styles['column-gap'] ) ?? $col );
968 }
969
970 return array( $row, $col );
971 }
972
973 /**
974 * Map align-items to a table-cell vertical-align.
975 *
976 * @param array $styles Parsed container styles.
977 *
978 * @return string The vertical-align value.
979 */
980 private static function vertical_align( array $styles ): string {
981 $align = isset( $styles['align-items'] ) ? strtolower( trim( $styles['align-items'] ) ) : '';
982
983 if ( 'flex-end' === $align || 'end' === $align ) {
984 return 'bottom';
985 }
986 if ( 'center' === $align ) {
987 return 'middle';
988 }
989 if ( 'baseline' === $align ) {
990 return 'baseline';
991 }
992
993 return 'top';
994 }
995
996 /**
997 * Format a float for CSS output, immune to LC_NUMERIC comma locales.
998 *
999 * @param float $value The value to format.
1000 *
1001 * @return string The formatted number.
1002 */
1003 private static function css_number( float $value ): string {
1004 $formatted = rtrim( rtrim( number_format( $value, 3, '.', '' ), '0' ), '.' );
1005
1006 return '' === $formatted ? '0' : $formatted;
1007 }
1008
1009 /**
1010 * Convert a CSS length to px (px and pt only — template inline styles).
1011 *
1012 * @param string $value The CSS length.
1013 *
1014 * @return float|null Pixels, or null when not convertible.
1015 */
1016 private static function length_to_px( string $value ): ?float {
1017 $value = strtolower( trim( $value ) );
1018
1019 if ( preg_match( '/^(-?[0-9.]+)px$/', $value, $m ) ) {
1020 return (float) $m[1];
1021 }
1022 if ( preg_match( '/^(-?[0-9.]+)pt$/', $value, $m ) ) {
1023 return (float) $m[1] / self::PT_PER_PX;
1024 }
1025 if ( '0' === $value ) {
1026 return 0.0;
1027 }
1028
1029 return null;
1030 }
1031
1032 /**
1033 * Resolve the root element's padding to [top, right, bottom, left] px.
1034 *
1035 * @param array $styles Parsed root styles.
1036 *
1037 * @return float[]|null Padding box, or null when absent/unparseable.
1038 */
1039 private static function resolve_padding_px( array $styles ): ?array {
1040 $padding = array( 0.0, 0.0, 0.0, 0.0 );
1041 $found = false;
1042
1043 if ( isset( $styles['padding'] ) ) {
1044 $parts = preg_split( '/\s+/', trim( $styles['padding'] ) );
1045 $px = array();
1046 foreach ( $parts as $part ) {
1047 $len = self::length_to_px( $part );
1048 if ( null === $len ) {
1049 return null; // Unsupported unit — leave the template alone.
1050 }
1051 $px[] = $len;
1052 }
1053
1054 switch ( \count( $px ) ) {
1055 case 1:
1056 $padding = array( $px[0], $px[0], $px[0], $px[0] );
1057 break;
1058 case 2:
1059 $padding = array( $px[0], $px[1], $px[0], $px[1] );
1060 break;
1061 case 3:
1062 $padding = array( $px[0], $px[1], $px[2], $px[1] );
1063 break;
1064 case 4:
1065 $padding = array( $px[0], $px[1], $px[2], $px[3] );
1066 break;
1067 default:
1068 return null;
1069 }
1070 $found = true;
1071 }
1072
1073 $sides = array(
1074 'padding-top' => 0,
1075 'padding-right' => 1,
1076 'padding-bottom' => 2,
1077 'padding-left' => 3,
1078 );
1079 foreach ( $sides as $property => $index ) {
1080 if ( ! isset( $styles[ $property ] ) ) {
1081 continue;
1082 }
1083 $len = self::length_to_px( $styles[ $property ] );
1084 if ( null === $len ) {
1085 return null;
1086 }
1087 $padding[ $index ] = $len;
1088 $found = true;
1089 }
1090
1091 return $found ? $padding : null;
1092 }
1093
1094 /**
1095 * Element children of a node (skipping text/comment nodes).
1096 *
1097 * @param DOMElement $element The parent element.
1098 *
1099 * @return DOMElement[]
1100 */
1101 private static function element_children( DOMElement $element ): array {
1102 $children = array();
1103 foreach ( $element->childNodes as $child ) {
1104 if ( $child instanceof DOMElement ) {
1105 $children[] = $child;
1106 }
1107 }
1108
1109 return $children;
1110 }
1111
1112 /**
1113 * Remove container-level layout properties before reuse on a table/block.
1114 *
1115 * @param array $styles Parsed styles, modified in place.
1116 */
1117 private static function strip_layout_styles( array &$styles ): void {
1118 unset(
1119 $styles['display'],
1120 $styles['flex-direction'],
1121 $styles['flex-wrap'],
1122 $styles['justify-content'],
1123 $styles['align-items'],
1124 $styles['align-content'],
1125 $styles['gap'],
1126 $styles['row-gap'],
1127 $styles['column-gap'],
1128 $styles['grid-template-columns'],
1129 $styles['grid-template-rows'],
1130 $styles['grid-auto-flow']
1131 );
1132 }
1133
1134 /**
1135 * Remove child-level flex sizing properties.
1136 *
1137 * @param array $styles Parsed styles, modified in place.
1138 */
1139 private static function strip_flex_child_styles( array &$styles ): void {
1140 unset(
1141 $styles['flex'],
1142 $styles['flex-grow'],
1143 $styles['flex-shrink'],
1144 $styles['flex-basis'],
1145 $styles['align-self'],
1146 $styles['justify-self'],
1147 $styles['min-width']
1148 );
1149 }
1150
1151 /**
1152 * Parse an inline style attribute into an ordered property map.
1153 *
1154 * @param string $style The style attribute value.
1155 *
1156 * @return array<string,string>
1157 */
1158 private static function parse_styles( string $style ): array {
1159 $styles = array();
1160 $declarations = array();
1161 $buffer = '';
1162 $depth = 0;
1163 $quote = '';
1164 $length = \strlen( $style );
1165
1166 for ( $i = 0; $i < $length; $i++ ) {
1167 $char = $style[ $i ];
1168
1169 if ( '' !== $quote ) {
1170 $buffer .= $char;
1171 if ( $char === $quote && ( 0 === $i || '\\' !== $style[ $i - 1 ] ) ) {
1172 $quote = '';
1173 }
1174 continue;
1175 }
1176
1177 if ( '"' === $char || "'" === $char ) {
1178 $quote = $char;
1179 $buffer .= $char;
1180 continue;
1181 }
1182
1183 if ( '(' === $char ) {
1184 $depth++;
1185 } elseif ( ')' === $char && $depth > 0 ) {
1186 $depth--;
1187 }
1188
1189 if ( ';' === $char && 0 === $depth ) {
1190 $declarations[] = $buffer;
1191 $buffer = '';
1192 continue;
1193 }
1194
1195 $buffer .= $char;
1196 }
1197
1198 $declarations[] = $buffer;
1199
1200 foreach ( $declarations as $declaration ) {
1201 $colon = strpos( $declaration, ':' );
1202 if ( false === $colon ) {
1203 continue;
1204 }
1205
1206 $property = strtolower( trim( substr( $declaration, 0, $colon ) ) );
1207 $value = trim( substr( $declaration, $colon + 1 ) );
1208 if ( '' !== $property && '' !== $value ) {
1209 $styles[ $property ] = $value;
1210 }
1211 }
1212
1213 return $styles;
1214 }
1215
1216 /**
1217 * Serialize a property map back to a style string.
1218 *
1219 * @param array $styles The property map.
1220 *
1221 * @return string
1222 */
1223 private static function build_styles( array $styles ): string {
1224 $declarations = array();
1225 foreach ( $styles as $property => $value ) {
1226 $declarations[] = $property . ': ' . $value;
1227 }
1228
1229 return implode( '; ', $declarations );
1230 }
1231
1232 /**
1233 * Write a property map to an element's style attribute.
1234 *
1235 * @param DOMElement $element The element.
1236 * @param array $styles The property map.
1237 */
1238 private static function set_styles( DOMElement $element, array $styles ): void {
1239 $style_text = self::build_styles( $styles );
1240 if ( '' === $style_text ) {
1241 $element->removeAttribute( 'style' );
1242 } else {
1243 $element->setAttribute( 'style', $style_text );
1244 }
1245 }
1246
1247 /**
1248 * Append one declaration to an element's existing style attribute.
1249 *
1250 * @param DOMElement $element The element.
1251 * @param string $property The CSS property.
1252 * @param string $value The CSS value.
1253 */
1254 private static function append_style( DOMElement $element, string $property, string $value ): void {
1255 $styles = self::parse_styles( $element->getAttribute( 'style' ) );
1256 $styles[ $property ] = $value;
1257 self::set_styles( $element, $styles );
1258 }
1259 }
1260