Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
84.25% |
1284 / 1524 |
|
35.14% |
26 / 74 |
CRAP | |
0.00% |
0 / 1 |
| Painter | |
84.25% |
1284 / 1524 |
|
35.14% |
26 / 74 |
1545.82 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| __destruct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| paint | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| paintBox | |
100.00% |
37 / 37 |
|
100.00% |
1 / 1 |
11 | |||
| boxEntirelyOffPage | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
5 | |||
| applyBoxTransform | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
8 | |||
| composeTransformMatrix | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| transformFunctionToPdfMatrix | |
65.00% |
13 / 20 |
|
0.00% |
0 / 1 |
12.47 | |||
| multiplyMatrices | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
| resolveTransformOrigin | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
3 | |||
| resolveOriginComponent | |
45.45% |
5 / 11 |
|
0.00% |
0 / 1 |
18.39 | |||
| lengthOrPercentageToFloat | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| shouldClampDecorationsToPage | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
5 | |||
| isCloneDecorationBreak | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| clampGeometryToPage | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
5.01 | |||
| paintImage | |
88.46% |
46 / 52 |
|
0.00% |
0 / 1 |
15.35 | |||
| objectFitKeyword | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
| resolveObjectFit | |
92.86% |
26 / 28 |
|
0.00% |
0 / 1 |
6.01 | |||
| resolveImageSrc | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| materializeDataUrl | |
76.92% |
10 / 13 |
|
0.00% |
0 / 1 |
7.60 | |||
| resolveBackgroundClip | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
| shouldOverflowClip | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
7.10 | |||
| emitOverflowClipPath | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
3.01 | |||
| isVisibilityHidden | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| paintBoxShadow | |
96.00% |
24 / 25 |
|
0.00% |
0 / 1 |
9 | |||
| paintInsetShadow | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
5 | |||
| collectShadowLayers | |
40.00% |
4 / 10 |
|
0.00% |
0 / 1 |
13.78 | |||
| parseShadowLayer | |
93.10% |
27 / 29 |
|
0.00% |
0 / 1 |
10.03 | |||
| resolveOpacityGsName | |
80.00% |
8 / 10 |
|
0.00% |
0 / 1 |
6.29 | |||
| paintListMarker | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
11 | |||
| formatCounterMarker | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
3.01 | |||
| listItemIndex | |
88.24% |
30 / 34 |
|
0.00% |
0 / 1 |
17.47 | |||
| paintCounterMarker | |
90.00% |
27 / 30 |
|
0.00% |
0 / 1 |
5.03 | |||
| paintMarkerSquare | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| paintMarkerCircle | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
2 | |||
| dominantFontSize | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| paintLineBoxes | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
7 | |||
| paintInlineBackgrounds | |
93.10% |
27 / 29 |
|
0.00% |
0 / 1 |
12.05 | |||
| collectTextShadowLayers | |
92.31% |
24 / 26 |
|
0.00% |
0 / 1 |
12.07 | |||
| paintLine | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
4.00 | |||
| paintTextDecorations | |
87.18% |
34 / 39 |
|
0.00% |
0 / 1 |
13.36 | |||
| emitDecorationStyled | |
53.33% |
16 / 30 |
|
0.00% |
0 / 1 |
14.50 | |||
| emitWavyDecoration | |
96.77% |
30 / 31 |
|
0.00% |
0 / 1 |
5 | |||
| resolveDecorationThickness | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| resolveUnderlineOffset | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
| textDecorationStyle | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
| textDecorationLines | |
85.71% |
12 / 14 |
|
0.00% |
0 / 1 |
8.19 | |||
| textDecorationColor | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| paintFragment | |
98.04% |
50 / 51 |
|
0.00% |
0 / 1 |
12 | |||
| collectBlockLinkRect | |
36.00% |
9 / 25 |
|
0.00% |
0 / 1 |
36.21 | |||
| snapKern | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| paintBackground | |
100.00% |
56 / 56 |
|
100.00% |
1 / 1 |
23 | |||
| paintRadialGradient | |
88.89% |
32 / 36 |
|
0.00% |
0 / 1 |
9.11 | |||
| paintLinearGradient | |
90.00% |
36 / 40 |
|
0.00% |
0 / 1 |
6.04 | |||
| paintBackgroundImage | |
88.89% |
64 / 72 |
|
0.00% |
0 / 1 |
21.60 | |||
| repeatAxes | |
60.00% |
12 / 20 |
|
0.00% |
0 / 1 |
21.22 | |||
| resolveBackgroundPosition | |
88.24% |
30 / 34 |
|
0.00% |
0 / 1 |
15.37 | |||
| axisOffsetFromValue | |
78.95% |
15 / 19 |
|
0.00% |
0 / 1 |
13.34 | |||
| resolveBackgroundSize | |
71.43% |
35 / 49 |
|
0.00% |
0 / 1 |
21.97 | |||
| intrinsicSize | |
50.00% |
8 / 16 |
|
0.00% |
0 / 1 |
16.00 | |||
| paintBorders | |
80.30% |
53 / 66 |
|
0.00% |
0 / 1 |
13.10 | |||
| paintBorderSide | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
11 | |||
| paintDashedDottedSide | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
4 | |||
| resolve3dBorderColor | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
11 | |||
| borderStyleName | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| bordersAreUniform | |
61.54% |
8 / 13 |
|
0.00% |
0 / 1 |
13.61 | |||
| emitRoundedStroke | |
0.00% |
0 / 52 |
|
0.00% |
0 / 1 |
30 | |||
| paintOutline | |
89.83% |
53 / 59 |
|
0.00% |
0 / 1 |
16.27 | |||
| paintColumnRules | |
81.82% |
27 / 33 |
|
0.00% |
0 / 1 |
11.73 | |||
| borderIsVisible | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
| borderColor | |
42.86% |
3 / 7 |
|
0.00% |
0 / 1 |
4.68 | |||
| emitRect | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| borderRadii | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
| emitRoundedFill | |
100.00% |
51 / 51 |
|
100.00% |
1 / 1 |
5 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\HtmlToPdf\Painter; |
| 6 | |
| 7 | use Phpdftk\Css\Value\Color; |
| 8 | use Phpdftk\Css\Value\Keyword; |
| 9 | use Phpdftk\HtmlToPdf\Box\Box; |
| 10 | use Phpdftk\HtmlToPdf\Layout\BoxGeometry; |
| 11 | use Phpdftk\HtmlToPdf\Layout\InlineFragment; |
| 12 | use Phpdftk\HtmlToPdf\Layout\LineBox; |
| 13 | use Phpdftk\Pdf\Core\Content\ContentStream; |
| 14 | use Phpdftk\Pdf\Core\Font\RegisteredFont; |
| 15 | use Phpdftk\Pdf\Writer\Font as WriterFont; |
| 16 | use Phpdftk\Pdf\Writer\Page as WriterPage; |
| 17 | use Phpdftk\Pdf\Writer\PdfWriter; |
| 18 | |
| 19 | /** |
| 20 | * Phase 1G — paints a laid-out box tree onto a {@see ContentStream}. |
| 21 | * |
| 22 | * The painter walks the box tree depth-first and emits PDF operators for |
| 23 | * each box's visual contributions: background colour (rect + fill), then |
| 24 | * border edges (four straight strokes one per side, honouring per-side |
| 25 | * widths and colours), then recurses into children. Text rendering uses |
| 26 | * the line-box / shaped-glyph data deposited by {@see InlineLayout}; |
| 27 | * Phase 1G.1 ships background + border painting and leaves text as a |
| 28 | * follow-up that depends on `@font-face` integration (1M) — for now line |
| 29 | * boxes are walked but text painting is a no-op, so the painter exercises |
| 30 | * end-to-end without requiring a font registration. |
| 31 | * |
| 32 | * **Coordinate-system flip**: the layout uses PDF user-space units but |
| 33 | * with Y growing downward from the top of the page (the convention of CSS |
| 34 | * and every other layout engine). PDF's native content-stream coordinates |
| 35 | * grow upward from the bottom. The painter flips Y when emitting |
| 36 | * rectangles so consumers see PDF-correct output; the underlying box |
| 37 | * geometry stays in top-down space for layout sanity. |
| 38 | */ |
| 39 | final class Painter |
| 40 | { |
| 41 | public function __construct( |
| 42 | private readonly float $pageHeight, |
| 43 | private readonly ?RegisteredFont $defaultFont = null, |
| 44 | private readonly ?WriterPage $page = null, |
| 45 | /** |
| 46 | * Layout-Y range this page covers. When set, the painter skips |
| 47 | * any box whose geometry sits entirely above or entirely below |
| 48 | * this range — a multi-page document no longer re-paints every |
| 49 | * box on every page, just the ones intersecting the current |
| 50 | * page slot. |
| 51 | */ |
| 52 | private readonly ?float $pageRangeStart = null, |
| 53 | private readonly ?float $pageRangeEnd = null, |
| 54 | /** |
| 55 | * When set, the painter can register Image XObjects via the |
| 56 | * writer's `addImage` and emit `Do` for `<img>` elements whose |
| 57 | * `src` is a `data:image/{png,jpeg}` URL. When null, image |
| 58 | * painting is a no-op (the alt-text fallback still flows). |
| 59 | */ |
| 60 | private readonly ?PdfWriter $writer = null, |
| 61 | /** |
| 62 | * Base directory for resolving relative `<img src>` paths against |
| 63 | * the filesystem. When null, only `data:` URLs paint. |
| 64 | */ |
| 65 | private readonly ?string $baseDir = null, |
| 66 | /** |
| 67 | * Map of `postScriptName → RegisteredFont` keyed by the font's |
| 68 | * raw PS name. Used to switch `Tf` per fragment when an inline |
| 69 | * subtree shaped against an alternate font from the `FontResolver`. |
| 70 | * Defaults to `[$defaultFont->postScriptName => $defaultFont]` |
| 71 | * when only the default is registered. |
| 72 | * |
| 73 | * @var array<string, RegisteredFont> |
| 74 | */ |
| 75 | private readonly array $registeredFonts = [], |
| 76 | ) {} |
| 77 | |
| 78 | /** |
| 79 | * Track tempfile paths created for `data:` URL images so we can |
| 80 | * delete them when the Painter is destroyed. |
| 81 | * |
| 82 | * @var list<string> |
| 83 | */ |
| 84 | private array $tempImagePaths = []; |
| 85 | |
| 86 | /** |
| 87 | * Cache `data:` URL → registered XObject resource name for the |
| 88 | * current page, so the same image used multiple times only spills |
| 89 | * + registers once. |
| 90 | * |
| 91 | * @var array<string, string> |
| 92 | */ |
| 93 | private array $imageNameCache = []; |
| 94 | |
| 95 | public function __destruct() |
| 96 | { |
| 97 | foreach ($this->tempImagePaths as $path) { |
| 98 | if (is_file($path)) { |
| 99 | @unlink($path); |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | /** |
| 105 | * Link rects in PDF coordinates collected during the most recent |
| 106 | * {@see paint()} call. Each entry is `{href, llx, lly, urx, ury, |
| 107 | * title}` with the Y-flip already applied. The Renderer reads this |
| 108 | * list to register `/Link` annotations on the current page. |
| 109 | * |
| 110 | * @var list<array{href: string, llx: float, lly: float, urx: float, ury: float, title: ?string}> |
| 111 | */ |
| 112 | public array $collectedLinks = []; |
| 113 | |
| 114 | public function paint(Box $root, ContentStream $stream): void |
| 115 | { |
| 116 | $this->collectedLinks = []; |
| 117 | $this->imageNameCache = []; |
| 118 | $this->paintBox($root, $stream); |
| 119 | } |
| 120 | |
| 121 | private function paintBox(Box $box, ContentStream $stream): void |
| 122 | { |
| 123 | // Off-page skip: the box's layout-Y range doesn't overlap this |
| 124 | // page's range. We still must descend into children for the |
| 125 | // `<a href>` link-rect collection (which uses the page constant |
| 126 | // to compute PDF-Y), but skip the heavy paint operations. |
| 127 | if ($this->boxEntirelyOffPage($box)) { |
| 128 | return; |
| 129 | } |
| 130 | $opacityGsName = $this->resolveOpacityGsName($box); |
| 131 | if ($opacityGsName !== null) { |
| 132 | $stream->saveGraphicsState(); |
| 133 | $stream->setGraphicsState($opacityGsName); |
| 134 | } |
| 135 | // CSS Transforms 2 §6: apply the box's transform (if any) |
| 136 | // before any drawing. The graphics state save/restore wraps |
| 137 | // the entire paint (background + content + children) so the |
| 138 | // transform affects every nested operation. |
| 139 | $hasTransform = $this->applyBoxTransform($box, $stream); |
| 140 | // CSS Visual Formatting Model 9.5: `visibility: hidden` boxes |
| 141 | // occupy layout space but paint nothing themselves; descendants |
| 142 | // with their own `visibility` declaration can still be visible. |
| 143 | $hidden = $this->isVisibilityHidden($box); |
| 144 | if (!$hidden) { |
| 145 | // CSS Fragmentation 4 §5.5: `box-decoration-break: clone` |
| 146 | // makes each fragment paint full decorations as if it were |
| 147 | // a standalone box. For a straddling box we temporarily |
| 148 | // swap in a geometry clamped to this page's visible |
| 149 | // extent, so background/border/shadow draw at the page |
| 150 | // seam as a synthetic edge. |
| 151 | $originalGeo = null; |
| 152 | if ($this->shouldClampDecorationsToPage($box)) { |
| 153 | $originalGeo = $box->geometry; |
| 154 | $box->geometry = $this->clampGeometryToPage($originalGeo); |
| 155 | } |
| 156 | // CSS Backgrounds 3 §6.1.1 — paint stack from bottom up: |
| 157 | // outset shadows → background → inset shadows → border. |
| 158 | $this->paintBoxShadow($box, $stream, insetOnly: false); |
| 159 | $this->paintBackground($box, $stream); |
| 160 | $this->paintBoxShadow($box, $stream, insetOnly: true); |
| 161 | $this->paintBorders($box, $stream); |
| 162 | if ($originalGeo !== null) { |
| 163 | $box->geometry = $originalGeo; |
| 164 | } |
| 165 | $this->paintOutline($box, $stream); |
| 166 | $this->paintColumnRules($box, $stream); |
| 167 | $this->paintImage($box, $stream); |
| 168 | $this->paintListMarker($box, $stream); |
| 169 | $this->paintLineBoxes($box, $stream); |
| 170 | $this->collectBlockLinkRect($box); |
| 171 | } |
| 172 | // CSS Overflow 3 §3 — `overflow: hidden | clip | scroll | auto` |
| 173 | // clips descendants to the box's padding-edge. `visible` (the |
| 174 | // initial value) lets descendants render outside the box. |
| 175 | // Print medium can't scroll, so `scroll` / `auto` behave like |
| 176 | // `hidden` here. The clip is push/popped around the children |
| 177 | // loop so siblings of this box stay unaffected. |
| 178 | $overflowClip = $this->shouldOverflowClip($box); |
| 179 | if ($overflowClip) { |
| 180 | $stream->saveGraphicsState(); |
| 181 | $this->emitOverflowClipPath($stream, $box); |
| 182 | } |
| 183 | foreach ($box->children as $child) { |
| 184 | $this->paintBox($child, $stream); |
| 185 | } |
| 186 | if ($overflowClip) { |
| 187 | $stream->restoreGraphicsState(); |
| 188 | } |
| 189 | if ($hasTransform) { |
| 190 | $stream->restoreGraphicsState(); |
| 191 | } |
| 192 | if ($opacityGsName !== null) { |
| 193 | $stream->restoreGraphicsState(); |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | /** |
| 198 | * Return `true` when the box's layout-Y range sits entirely above or |
| 199 | * entirely below the painter's configured page range. Skipping these |
| 200 | * subtrees lets a 100-page document not re-paint every box on every |
| 201 | * page. Falls back to `false` (always paint) when the page range |
| 202 | * isn't set — preserves the old behaviour for single-page renders. |
| 203 | */ |
| 204 | private function boxEntirelyOffPage(Box $box): bool |
| 205 | { |
| 206 | if ($this->pageRangeStart === null || $this->pageRangeEnd === null) { |
| 207 | return false; |
| 208 | } |
| 209 | $g = $box->geometry; |
| 210 | $top = $g->y; |
| 211 | $bottom = $g->y + $g->outerHeight(); |
| 212 | // Outline boxes / anonymous boxes can carry zero geometry; never |
| 213 | // skip them — descendants may still be in range. |
| 214 | if ($bottom === $top) { |
| 215 | return false; |
| 216 | } |
| 217 | return $bottom <= $this->pageRangeStart || $top >= $this->pageRangeEnd; |
| 218 | } |
| 219 | |
| 220 | /** |
| 221 | * Apply the box's CSS `transform` (if any) via the PDF `cm` |
| 222 | * operator. Returns `true` if a graphics-state save was emitted |
| 223 | * (the caller must restoreGraphicsState after painting); `false` |
| 224 | * if no transform applied. The transform is composed as |
| 225 | * T(origin) × M_css→pdf × T(-origin) |
| 226 | * where M is the composition of all transform functions and the |
| 227 | * origin sits at the box's `transform-origin` in PDF coordinates. |
| 228 | */ |
| 229 | private function applyBoxTransform(Box $box, ContentStream $stream): bool |
| 230 | { |
| 231 | $value = $box->style->get('transform'); |
| 232 | if (!$value instanceof \Phpdftk\Css\Value\Transform || $value->functions === []) { |
| 233 | return false; |
| 234 | } |
| 235 | $matrix = $this->composeTransformMatrix($value, $box); |
| 236 | if ($matrix === null) { |
| 237 | return false; |
| 238 | } |
| 239 | [$ox, $oy] = $this->resolveTransformOrigin($box); |
| 240 | // T(ox, oy) × M × T(-ox, -oy). PDF cm composes |
| 241 | // CTM_new = CTM_old × M_provided, so submit in |
| 242 | // outer-to-inner order: translate(+), matrix, translate(-). |
| 243 | $stream->saveGraphicsState(); |
| 244 | if ($ox !== 0.0 || $oy !== 0.0) { |
| 245 | $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, $ox, $oy); |
| 246 | } |
| 247 | $stream->concatMatrix($matrix[0], $matrix[1], $matrix[2], $matrix[3], $matrix[4], $matrix[5]); |
| 248 | if ($ox !== 0.0 || $oy !== 0.0) { |
| 249 | $stream->concatMatrix(1.0, 0.0, 0.0, 1.0, -$ox, -$oy); |
| 250 | } |
| 251 | return true; |
| 252 | } |
| 253 | |
| 254 | /** |
| 255 | * Compose the box's transform-function list into a single 2D |
| 256 | * matrix [a, b, c, d, e, f] in PDF coordinate space (Y-up). |
| 257 | * Returns `null` if no function produced output (3D-only |
| 258 | * transforms flatten to identity at Phase 2). |
| 259 | * |
| 260 | * @return ?array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} |
| 261 | */ |
| 262 | private function composeTransformMatrix(\Phpdftk\Css\Value\Transform $transform, Box $box): ?array |
| 263 | { |
| 264 | $result = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; // identity |
| 265 | $any = false; |
| 266 | foreach ($transform->functions as $fn) { |
| 267 | $m = $this->transformFunctionToPdfMatrix($fn, $box); |
| 268 | if ($m === null) { |
| 269 | continue; |
| 270 | } |
| 271 | $result = $this->multiplyMatrices($result, $m); |
| 272 | $any = true; |
| 273 | } |
| 274 | return $any ? $result : null; |
| 275 | } |
| 276 | |
| 277 | /** |
| 278 | * Convert a single CSS transform-function to a 2D PDF matrix |
| 279 | * (Y-up). The conversion negates the (b, c, f) entries — that's |
| 280 | * the matrix-form of conjugating by a Y-axis flip, which maps |
| 281 | * CSS's Y-down coords to PDF's Y-up. |
| 282 | * |
| 283 | * @return ?array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} |
| 284 | */ |
| 285 | private function transformFunctionToPdfMatrix( |
| 286 | \Phpdftk\Css\Value\TransformFunction $fn, |
| 287 | Box $box, |
| 288 | ): ?array { |
| 289 | if ($fn instanceof \Phpdftk\Css\Value\TranslateTransform) { |
| 290 | $tx = $this->lengthOrPercentageToFloat($fn->x, $box->geometry->width); |
| 291 | $ty = $this->lengthOrPercentageToFloat($fn->y, $box->geometry->height); |
| 292 | return [1.0, 0.0, 0.0, 1.0, $tx, -$ty]; |
| 293 | } |
| 294 | if ($fn instanceof \Phpdftk\Css\Value\RotateTransform) { |
| 295 | // 3D rotations around the X or Y axis flatten to identity |
| 296 | // at Phase 2 (we don't render in perspective); only the Z |
| 297 | // axis rotation produces a 2D effect. |
| 298 | if (($fn->ax !== 0.0 || $fn->ay !== 0.0) && $fn->az === 0.0) { |
| 299 | return null; |
| 300 | } |
| 301 | $rad = deg2rad($fn->angleDeg); |
| 302 | $cos = cos($rad); |
| 303 | $sin = sin($rad); |
| 304 | return [$cos, -$sin, $sin, $cos, 0.0, 0.0]; |
| 305 | } |
| 306 | if ($fn instanceof \Phpdftk\Css\Value\ScaleTransform) { |
| 307 | return [$fn->sx, 0.0, 0.0, $fn->sy, 0.0, 0.0]; |
| 308 | } |
| 309 | if ($fn instanceof \Phpdftk\Css\Value\SkewTransform) { |
| 310 | $tanX = tan(deg2rad($fn->xDeg)); |
| 311 | $tanY = tan(deg2rad($fn->yDeg)); |
| 312 | // CSS skewX: [1, 0, tan(x), 1]; PDF flips b+c → [1, 0, -tan(x), 1] |
| 313 | // CSS skewY: [1, tan(y), 0, 1]; PDF flips → [1, -tan(y), 0, 1] |
| 314 | return [1.0, -$tanY, -$tanX, 1.0, 0.0, 0.0]; |
| 315 | } |
| 316 | if ($fn instanceof \Phpdftk\Css\Value\MatrixTransform) { |
| 317 | return [$fn->a, -$fn->b, -$fn->c, $fn->d, $fn->e, -$fn->f]; |
| 318 | } |
| 319 | return null; |
| 320 | } |
| 321 | |
| 322 | /** |
| 323 | * Multiply two 2D affine matrices in [a, b, c, d, e, f] form: |
| 324 | * M = [a c e] M' = [a' c' e'] M × M' = [...] |
| 325 | * [b d f] [b' d' f'] |
| 326 | * [0 0 1] [0 0 1] |
| 327 | * |
| 328 | * @param array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} $m1 |
| 329 | * @param array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} $m2 |
| 330 | * @return array{0: float, 1: float, 2: float, 3: float, 4: float, 5: float} |
| 331 | */ |
| 332 | private function multiplyMatrices(array $m1, array $m2): array |
| 333 | { |
| 334 | [$a1, $b1, $c1, $d1, $e1, $f1] = $m1; |
| 335 | [$a2, $b2, $c2, $d2, $e2, $f2] = $m2; |
| 336 | return [ |
| 337 | $a1 * $a2 + $c1 * $b2, |
| 338 | $b1 * $a2 + $d1 * $b2, |
| 339 | $a1 * $c2 + $c1 * $d2, |
| 340 | $b1 * $c2 + $d1 * $d2, |
| 341 | $a1 * $e2 + $c1 * $f2 + $e1, |
| 342 | $b1 * $e2 + $d1 * $f2 + $f1, |
| 343 | ]; |
| 344 | } |
| 345 | |
| 346 | /** |
| 347 | * Resolve `transform-origin` to a PDF coordinate point. Default |
| 348 | * `50% 50%` puts the pivot at the box's centre. Lengths and |
| 349 | * percentages compose; percentages resolve against the box's |
| 350 | * border-box dimension on each axis. |
| 351 | * |
| 352 | * @return array{0: float, 1: float} (px, py) in PDF coords. |
| 353 | */ |
| 354 | private function resolveTransformOrigin(Box $box): array |
| 355 | { |
| 356 | $g = $box->geometry; |
| 357 | $width = $g->width + $g->paddingLeft + $g->paddingRight + $g->borderLeft + $g->borderRight; |
| 358 | $height = $g->height + $g->paddingTop + $g->paddingBottom + $g->borderTop + $g->borderBottom; |
| 359 | $boxX = $g->x - $g->paddingLeft - $g->borderLeft; |
| 360 | $boxY = $g->y - $g->paddingTop - $g->borderTop; |
| 361 | |
| 362 | $value = $box->style->get('transform-origin'); |
| 363 | $offX = $width / 2.0; |
| 364 | $offY = $height / 2.0; |
| 365 | if ($value instanceof \Phpdftk\Css\Value\ValueList && count($value->values) >= 2) { |
| 366 | $offX = $this->resolveOriginComponent($value->values[0], $width, $offX); |
| 367 | $offY = $this->resolveOriginComponent($value->values[1], $height, $offY); |
| 368 | } |
| 369 | $cssY = $boxY + $offY; |
| 370 | return [$boxX + $offX, $this->pageHeight - $cssY]; |
| 371 | } |
| 372 | |
| 373 | private function resolveOriginComponent( |
| 374 | \Phpdftk\Css\Value\Value $value, |
| 375 | float $extent, |
| 376 | float $fallback, |
| 377 | ): float { |
| 378 | if ($value instanceof \Phpdftk\Css\Value\Length) { |
| 379 | return $value->value; |
| 380 | } |
| 381 | if ($value instanceof \Phpdftk\Css\Value\Percentage) { |
| 382 | return $value->value / 100.0 * $extent; |
| 383 | } |
| 384 | if ($value instanceof \Phpdftk\Css\Value\Keyword) { |
| 385 | return match (strtolower($value->name)) { |
| 386 | 'left', 'top' => 0.0, |
| 387 | 'right', 'bottom' => $extent, |
| 388 | 'center' => $extent / 2.0, |
| 389 | default => $fallback, |
| 390 | }; |
| 391 | } |
| 392 | return $fallback; |
| 393 | } |
| 394 | |
| 395 | private function lengthOrPercentageToFloat( |
| 396 | \Phpdftk\Css\Value\Length|\Phpdftk\Css\Value\Percentage $value, |
| 397 | float $basis, |
| 398 | ): float { |
| 399 | if ($value instanceof \Phpdftk\Css\Value\Length) { |
| 400 | return $value->value; |
| 401 | } |
| 402 | return $value->value / 100.0 * $basis; |
| 403 | } |
| 404 | |
| 405 | /** |
| 406 | * `true` when this box (a) declares `box-decoration-break: clone` |
| 407 | * AND (b) actually straddles the current page boundary. Boxes that |
| 408 | * fit entirely on one page don't need the clamp — slice and clone |
| 409 | * paint identically in that case. |
| 410 | */ |
| 411 | private function shouldClampDecorationsToPage(Box $box): bool |
| 412 | { |
| 413 | if ($this->pageRangeStart === null || $this->pageRangeEnd === null) { |
| 414 | return false; |
| 415 | } |
| 416 | if (!$this->isCloneDecorationBreak($box)) { |
| 417 | return false; |
| 418 | } |
| 419 | $g = $box->geometry; |
| 420 | $outerTop = $g->y - $g->paddingTop - $g->borderTop - $g->marginTop; |
| 421 | $outerBottom = $g->y + $g->height + $g->paddingBottom + $g->borderBottom + $g->marginBottom; |
| 422 | return $outerTop < $this->pageRangeStart || $outerBottom > $this->pageRangeEnd; |
| 423 | } |
| 424 | |
| 425 | private function isCloneDecorationBreak(Box $box): bool |
| 426 | { |
| 427 | $value = $box->style->get('box-decoration-break'); |
| 428 | if (!($value instanceof Keyword)) { |
| 429 | return false; |
| 430 | } |
| 431 | return strtolower($value->name) === 'clone'; |
| 432 | } |
| 433 | |
| 434 | /** |
| 435 | * Return a clone of `$g` with content y / height clamped so the |
| 436 | * box's outer margin-box sits entirely inside the painter's |
| 437 | * current page range. Used by `box-decoration-break: clone` to |
| 438 | * make each fragment paint full borders at its visible extent. |
| 439 | */ |
| 440 | private function clampGeometryToPage(BoxGeometry $g): BoxGeometry |
| 441 | { |
| 442 | $clone = clone $g; |
| 443 | if ($this->pageRangeStart === null || $this->pageRangeEnd === null) { |
| 444 | return $clone; |
| 445 | } |
| 446 | $outerTop = $g->y - $g->paddingTop - $g->borderTop - $g->marginTop; |
| 447 | $outerBottom = $g->y + $g->height + $g->paddingBottom + $g->borderBottom + $g->marginBottom; |
| 448 | if ($outerTop < $this->pageRangeStart) { |
| 449 | $delta = $this->pageRangeStart - $outerTop; |
| 450 | $clone->y += $delta; |
| 451 | $clone->height = max(0.0, $clone->height - $delta); |
| 452 | } |
| 453 | if ($outerBottom > $this->pageRangeEnd) { |
| 454 | $delta = $outerBottom - $this->pageRangeEnd; |
| 455 | $clone->height = max(0.0, $clone->height - $delta); |
| 456 | } |
| 457 | return $clone; |
| 458 | } |
| 459 | |
| 460 | /** |
| 461 | * Phase-1 `<img src="data:image/...">` painter: decodes the data URL, |
| 462 | * spills the bytes to a tempfile, registers an Image XObject on the |
| 463 | * current page via the writer, and emits `q cm /Name Do Q` at the |
| 464 | * box's geometry. No-op when the writer or page is not wired in, or |
| 465 | * when the src isn't a `data:image/png|jpeg` URL we can paint. |
| 466 | */ |
| 467 | private function paintImage(Box $box, ContentStream $stream): void |
| 468 | { |
| 469 | if (!($box instanceof \Phpdftk\HtmlToPdf\Box\AtomicInlineBox)) { |
| 470 | return; |
| 471 | } |
| 472 | if ($this->writer === null || $this->page === null) { |
| 473 | return; |
| 474 | } |
| 475 | $element = $box->element; |
| 476 | if ($element === null || strtolower($element->localName) !== 'img') { |
| 477 | return; |
| 478 | } |
| 479 | $src = $element->getAttribute('src'); |
| 480 | if ($src === null) { |
| 481 | return; |
| 482 | } |
| 483 | // Per-page cache: the same src only spills + registers once on |
| 484 | // this page. Multi-page reuse still re-registers — XObject |
| 485 | // resource names are page-local in PdfWriter. |
| 486 | if (isset($this->imageNameCache[$src])) { |
| 487 | $name = $this->imageNameCache[$src]; |
| 488 | } else { |
| 489 | $resolvedPath = $this->resolveImageSrc($src); |
| 490 | if ($resolvedPath === null) { |
| 491 | return; |
| 492 | } |
| 493 | // ImageParser throws on malformed bytes; swallow + fall back to |
| 494 | // the alt-text path (or empty box) rather than crashing the whole |
| 495 | // render because of one bad asset. |
| 496 | try { |
| 497 | $name = $this->writer->addImage($resolvedPath, $this->page); |
| 498 | } catch (\Throwable) { |
| 499 | return; |
| 500 | } |
| 501 | $this->imageNameCache[$src] = $name; |
| 502 | } |
| 503 | $geo = $box->geometry; |
| 504 | if ($geo->width <= 0.0) { |
| 505 | // No declared size — skip; the alt-text fallback path covers |
| 506 | // unsized images via the BoxGenerator's InlineBox lowering. |
| 507 | return; |
| 508 | } |
| 509 | $height = $geo->height > 0.0 ? $geo->height : $geo->width; |
| 510 | // CSS Images 3 §5: `object-fit` controls the image's scale |
| 511 | // within its declared content rect. `fill` (default) stretches; |
| 512 | // `contain` / `cover` / `none` / `scale-down` preserve aspect. |
| 513 | // `object-position` selects which part of the box the image |
| 514 | // anchors to when there's slack — defaults to centre. |
| 515 | $fit = $this->objectFitKeyword($box); |
| 516 | $rect = $this->resolveObjectFit($fit, $src, $geo->width, $height); |
| 517 | $positionValue = $box->style->get('object-position'); |
| 518 | if ($positionValue !== null |
| 519 | && ($rect['w'] !== $geo->width || $rect['h'] !== $height) |
| 520 | ) { |
| 521 | $pos = $this->resolveBackgroundPosition( |
| 522 | $positionValue, |
| 523 | $rect['w'], |
| 524 | $rect['h'], |
| 525 | $geo->width, |
| 526 | $height, |
| 527 | ); |
| 528 | $rect['offsetX'] = $pos['offsetX']; |
| 529 | $rect['offsetY'] = $pos['offsetY']; |
| 530 | } |
| 531 | // PDF y-axis is inverted; the `cm` matrix maps the unit square |
| 532 | // [0,1]^2 to the box's PDF-space rect. |
| 533 | $pdfY = $this->pageHeight - $geo->y - $height; |
| 534 | $stream->saveGraphicsState(); |
| 535 | // Clip to the box rect so `cover` overflow doesn't bleed into |
| 536 | // sibling boxes. |
| 537 | $stream->rectangle($geo->x, $pdfY, $geo->width, $height); |
| 538 | $stream->clip(); |
| 539 | $stream->endPath(); |
| 540 | $stream->concatMatrix( |
| 541 | $rect['w'], |
| 542 | 0.0, |
| 543 | 0.0, |
| 544 | $rect['h'], |
| 545 | $geo->x + $rect['offsetX'], |
| 546 | $pdfY + ($height - $rect['h'] - $rect['offsetY']), |
| 547 | ); |
| 548 | $stream->doXObject($name); |
| 549 | $stream->restoreGraphicsState(); |
| 550 | } |
| 551 | |
| 552 | /** |
| 553 | * Read the box's cascaded `object-fit` value, normalised to one of |
| 554 | * `fill` / `contain` / `cover` / `none` / `scale-down`. Unknown |
| 555 | * keywords fall back to `fill`. |
| 556 | */ |
| 557 | private function objectFitKeyword(Box $box): string |
| 558 | { |
| 559 | $value = $box->style->get('object-fit'); |
| 560 | if ($value instanceof Keyword) { |
| 561 | $kw = strtolower($value->name); |
| 562 | if (in_array($kw, ['fill', 'contain', 'cover', 'none', 'scale-down'], true)) { |
| 563 | return $kw; |
| 564 | } |
| 565 | } |
| 566 | return 'fill'; |
| 567 | } |
| 568 | |
| 569 | /** |
| 570 | * Compute the painted rect for a replaced element under `object-fit`. |
| 571 | * Mirrors CSS Images 3 §5 semantics: |
| 572 | * - `fill` → stretch to the box. |
| 573 | * - `contain` → preserve aspect, fit inside; centred slack. |
| 574 | * - `cover` → preserve aspect, fill; clipped overflow. |
| 575 | * - `none` → natural size; centred slack. |
| 576 | * - `scale-down` → min(`none`, `contain`) — uses natural size when |
| 577 | * the image already fits, otherwise behaves like `contain`. |
| 578 | * |
| 579 | * @return array{w: float, h: float, offsetX: float, offsetY: float} |
| 580 | */ |
| 581 | private function resolveObjectFit( |
| 582 | string $fit, |
| 583 | string $src, |
| 584 | float $boxWidth, |
| 585 | float $boxHeight, |
| 586 | ): array { |
| 587 | if ($fit === 'fill') { |
| 588 | return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0]; |
| 589 | } |
| 590 | $intrinsic = $this->intrinsicSize($src); |
| 591 | if ($intrinsic === null) { |
| 592 | return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0]; |
| 593 | } |
| 594 | [$natW, $natH] = $intrinsic; |
| 595 | if ($fit === 'none') { |
| 596 | return [ |
| 597 | 'w' => (float) $natW, |
| 598 | 'h' => (float) $natH, |
| 599 | 'offsetX' => ($boxWidth - $natW) / 2, |
| 600 | 'offsetY' => ($boxHeight - $natH) / 2, |
| 601 | ]; |
| 602 | } |
| 603 | $scaleW = $boxWidth / $natW; |
| 604 | $scaleH = $boxHeight / $natH; |
| 605 | if ($fit === 'scale-down') { |
| 606 | // Use 1.0 (natural) when it already fits; else contain. |
| 607 | $scale = min(1.0, $scaleW, $scaleH); |
| 608 | } elseif ($fit === 'cover') { |
| 609 | $scale = max($scaleW, $scaleH); |
| 610 | } else { |
| 611 | // contain |
| 612 | $scale = min($scaleW, $scaleH); |
| 613 | } |
| 614 | $finalW = $natW * $scale; |
| 615 | $finalH = $natH * $scale; |
| 616 | return [ |
| 617 | 'w' => $finalW, |
| 618 | 'h' => $finalH, |
| 619 | 'offsetX' => ($boxWidth - $finalW) / 2, |
| 620 | 'offsetY' => ($boxHeight - $finalH) / 2, |
| 621 | ]; |
| 622 | } |
| 623 | |
| 624 | /** |
| 625 | * Resolve an `<img src>` value to a real path that |
| 626 | * {@see PdfWriter::addImage} can read. Handles: |
| 627 | * - `data:image/{png,jpeg}[;base64],...` → spilled tempfile |
| 628 | * - relative paths → joined with `baseDir`, must resolve under it |
| 629 | * |
| 630 | * Returns null when the source isn't a Phase-1 supported variant or |
| 631 | * when path resolution escapes `baseDir`. |
| 632 | */ |
| 633 | private function resolveImageSrc(string $src): ?string |
| 634 | { |
| 635 | if (str_starts_with($src, 'data:')) { |
| 636 | return $this->materializeDataUrl($src); |
| 637 | } |
| 638 | return (new \Phpdftk\Filesystem\ResourceLoader($this->baseDir)) |
| 639 | ->resolveLocalPath($src); |
| 640 | } |
| 641 | |
| 642 | /** |
| 643 | * Decode `data:image/{png,jpeg};base64,...` (or the rfc2397 non-base64 |
| 644 | * form) into a tempfile so {@see PdfWriter::addImage} can parse it. |
| 645 | * Returns null when the URL isn't a Phase-1 supported variant. |
| 646 | */ |
| 647 | private function materializeDataUrl(string $dataUrl): ?string |
| 648 | { |
| 649 | // `data:image/png;base64,iVBORw0K...` — match the MIME + optional |
| 650 | // `;base64` flag + the payload. |
| 651 | if (preg_match('~^data:image/(png|jpeg|jpg);(base64,)?(.*)$~s', $dataUrl, $m) !== 1) { |
| 652 | return null; |
| 653 | } |
| 654 | $mime = $m[1] === 'jpg' ? 'jpeg' : $m[1]; |
| 655 | $payload = $m[2] === 'base64,' |
| 656 | ? base64_decode($m[3], strict: true) |
| 657 | : urldecode($m[3]); |
| 658 | if ($payload === false || $payload === '') { |
| 659 | return null; |
| 660 | } |
| 661 | $ext = $mime === 'jpeg' ? 'jpg' : 'png'; |
| 662 | $tempPath = tempnam(sys_get_temp_dir(), 'phpdftk-img-') . '.' . $ext; |
| 663 | \Phpdftk\Filesystem\LocalFilesystem::writeFile($tempPath, $payload); |
| 664 | $this->tempImagePaths[] = $tempPath; |
| 665 | return $tempPath; |
| 666 | } |
| 667 | |
| 668 | /** |
| 669 | * Resolve CSS Backgrounds 3 §3.5 `background-clip` to one of |
| 670 | * `border-box` / `padding-box` / `content-box`. Unknown |
| 671 | * keywords fall back to the initial `border-box`. |
| 672 | */ |
| 673 | private function resolveBackgroundClip(Box $box): string |
| 674 | { |
| 675 | $value = $box->style->get('background-clip'); |
| 676 | if (!($value instanceof Keyword)) { |
| 677 | return 'border-box'; |
| 678 | } |
| 679 | $name = strtolower($value->name); |
| 680 | if (in_array($name, ['border-box', 'padding-box', 'content-box'], true)) { |
| 681 | return $name; |
| 682 | } |
| 683 | return 'border-box'; |
| 684 | } |
| 685 | |
| 686 | /** |
| 687 | * CSS Overflow 3 §3 — return true when this box should clip its |
| 688 | * descendants to its padding edge. `visible` (initial) → no clip; |
| 689 | * any of `hidden` / `clip` / `scroll` / `auto` → clip. PDF |
| 690 | * clipping is rectangular (both axes), so when EITHER `overflow-x` |
| 691 | * or `overflow-y` is non-visible we clip both axes — over-clipping |
| 692 | * relative to spec but visually correct for print where there's |
| 693 | * no scroll viewport. |
| 694 | */ |
| 695 | private function shouldOverflowClip(Box $box): bool |
| 696 | { |
| 697 | foreach (['overflow', 'overflow-x', 'overflow-y'] as $prop) { |
| 698 | $value = $box->style->get($prop); |
| 699 | if (!($value instanceof Keyword)) { |
| 700 | continue; |
| 701 | } |
| 702 | $name = strtolower($value->name); |
| 703 | if ($name === 'hidden' || $name === 'clip' || $name === 'scroll' || $name === 'auto') { |
| 704 | return true; |
| 705 | } |
| 706 | } |
| 707 | return false; |
| 708 | } |
| 709 | |
| 710 | /** |
| 711 | * Emit a clip rect at the box's padding-edge rectangle (CSS |
| 712 | * Overflow 3 §4) so descendants painted after this call are |
| 713 | * clipped to it. Caller is responsible for the `saveGraphicsState` |
| 714 | * / `restoreGraphicsState` envelope. |
| 715 | */ |
| 716 | private function emitOverflowClipPath(ContentStream $stream, Box $box): void |
| 717 | { |
| 718 | $g = $box->geometry; |
| 719 | $padX = $g->x - $g->paddingLeft; |
| 720 | $padTop = $g->y - $g->paddingTop; |
| 721 | $padWidth = $g->paddingLeft + $g->width + $g->paddingRight; |
| 722 | $padHeight = $g->paddingTop + $g->height + $g->paddingBottom; |
| 723 | if ($padWidth <= 0.0 || $padHeight <= 0.0) { |
| 724 | return; |
| 725 | } |
| 726 | $pdfY = $this->pageHeight - $padTop - $padHeight; |
| 727 | $stream->rectangle($padX, $pdfY, $padWidth, $padHeight); |
| 728 | $stream->clip(); |
| 729 | $stream->endPath(); |
| 730 | } |
| 731 | |
| 732 | private function isVisibilityHidden(Box $box): bool |
| 733 | { |
| 734 | $value = $box->style->get('visibility'); |
| 735 | return $value instanceof Keyword |
| 736 | && in_array(strtolower($value->name), ['hidden', 'collapse'], true); |
| 737 | } |
| 738 | |
| 739 | /** |
| 740 | * Paint CSS Backgrounds 3 §6 `box-shadow`. Phase-1 implementation: |
| 741 | * draws a hard-edged shadow rect (no blur — blur needs Filter Effects 1 |
| 742 | * which is Phase 2). Honours `<offset-x>`, `<offset-y>`, optional |
| 743 | * `<spread-radius>`, and `<color>` (defaults to cascaded `color`). |
| 744 | * Inset shadows are not yet emitted (would clip inward). |
| 745 | * |
| 746 | * Multi-shadow comma lists are read; each shadow paints in reverse |
| 747 | * order so the first listed sits on top — matching CSS stacking. |
| 748 | */ |
| 749 | private function paintBoxShadow(Box $box, ContentStream $stream, bool $insetOnly): void |
| 750 | { |
| 751 | $value = $box->style->get('box-shadow'); |
| 752 | if ($value === null |
| 753 | || ($value instanceof Keyword && strtolower($value->name) === 'none') |
| 754 | ) { |
| 755 | return; |
| 756 | } |
| 757 | $shadows = $this->collectShadowLayers($value); |
| 758 | if ($shadows === []) { |
| 759 | return; |
| 760 | } |
| 761 | $defaultColor = $box->style->get('color'); |
| 762 | $textColor = $defaultColor instanceof Color ? $defaultColor : new Color(0, 0, 0, 1); |
| 763 | $geo = $box->geometry; |
| 764 | |
| 765 | // Paint last shadow first so earlier-listed shadows sit on top. |
| 766 | foreach (array_reverse($shadows) as $shadow) { |
| 767 | if ($shadow['inset'] !== $insetOnly) { |
| 768 | continue; |
| 769 | } |
| 770 | $color = $shadow['color'] ?? $textColor; |
| 771 | $spread = $shadow['spread']; |
| 772 | if ($shadow['inset']) { |
| 773 | $this->paintInsetShadow($geo, $shadow, $color, $stream); |
| 774 | continue; |
| 775 | } |
| 776 | $x = $geo->x - $geo->paddingLeft - $geo->borderLeft + $shadow['offsetX'] - $spread; |
| 777 | $top = $geo->y - $geo->paddingTop - $geo->borderTop + $shadow['offsetY'] - $spread; |
| 778 | $width = $geo->paddingLeft + $geo->width + $geo->paddingRight |
| 779 | + $geo->borderLeft + $geo->borderRight + 2 * $spread; |
| 780 | $height = $geo->paddingTop + $geo->height + $geo->paddingBottom |
| 781 | + $geo->borderTop + $geo->borderBottom + 2 * $spread; |
| 782 | $this->emitRect($stream, $x, $top, $width, $height, fill: $color); |
| 783 | } |
| 784 | } |
| 785 | |
| 786 | /** |
| 787 | * Paint an inset box-shadow per CSS Backgrounds 3 §6. The shadow |
| 788 | * paints INSIDE the padding-box edge (not outside the border-box |
| 789 | * like the default outset case). Offsets are inverted in effect: |
| 790 | * a positive `offsetX` makes the shadow visible at the *left* |
| 791 | * edge of the inside (the shadow "comes from" the +X direction). |
| 792 | * Positive `spread` makes the visible inner shadow band thicker |
| 793 | * by shrinking the unshaded inner rect. |
| 794 | * |
| 795 | * Implementation: paint the padding-box outer rect plus the |
| 796 | * computed inner rect as two subpaths, then fill with the |
| 797 | * even-odd rule (`f*`) so PDF leaves the inner rect transparent |
| 798 | * and fills only the frame between them with the shadow colour. |
| 799 | * |
| 800 | * @param array{offsetX: float, offsetY: float, blur: float, spread: float, color: ?Color, inset: bool} $shadow |
| 801 | */ |
| 802 | private function paintInsetShadow(\Phpdftk\HtmlToPdf\Layout\BoxGeometry $geo, array $shadow, Color $color, ContentStream $stream): void |
| 803 | { |
| 804 | // Padding-box edge (one step inside the border edge). |
| 805 | $padX = $geo->x - $geo->paddingLeft; |
| 806 | $padTop = $geo->y - $geo->paddingTop; |
| 807 | $padWidth = $geo->paddingLeft + $geo->width + $geo->paddingRight; |
| 808 | $padHeight = $geo->paddingTop + $geo->height + $geo->paddingBottom; |
| 809 | if ($padWidth <= 0.0 || $padHeight <= 0.0) { |
| 810 | return; |
| 811 | } |
| 812 | $spread = $shadow['spread']; |
| 813 | // Inner unshaded rect — inset from the padding-box by the |
| 814 | // offset on the corresponding side, then further by spread on |
| 815 | // every side. CSS 2 §6: a positive +X offset moves the shadow |
| 816 | // toward +X (visible at the OPPOSITE edge — the left), so the |
| 817 | // inner rect's left edge advances by offsetX. |
| 818 | $innerX = $padX + max(0.0, $shadow['offsetX']) + $spread; |
| 819 | $innerTop = $padTop + max(0.0, $shadow['offsetY']) + $spread; |
| 820 | $innerRight = $padX + $padWidth + min(0.0, $shadow['offsetX']) - $spread; |
| 821 | $innerBottom = $padTop + $padHeight + min(0.0, $shadow['offsetY']) - $spread; |
| 822 | $innerWidth = $innerRight - $innerX; |
| 823 | $innerHeight = $innerBottom - $innerTop; |
| 824 | if ($innerWidth <= 0.0 || $innerHeight <= 0.0) { |
| 825 | // Spread+offset consumes the whole padding box — fill it |
| 826 | // solid with the shadow colour. |
| 827 | $this->emitRect($stream, $padX, $padTop, $padWidth, $padHeight, fill: $color); |
| 828 | return; |
| 829 | } |
| 830 | // PDF Y axis is inverted vs layout. Flip both rects. |
| 831 | $padPdfY = $this->pageHeight - $padTop - $padHeight; |
| 832 | $innerPdfY = $this->pageHeight - $innerTop - $innerHeight; |
| 833 | $stream->saveGraphicsState(); |
| 834 | $stream->setFillColorRGB($color->r, $color->g, $color->b); |
| 835 | $stream->rectangle($padX, $padPdfY, $padWidth, $padHeight); |
| 836 | $stream->rectangle($innerX, $innerPdfY, $innerWidth, $innerHeight); |
| 837 | $stream->fillEvenOdd(); |
| 838 | $stream->restoreGraphicsState(); |
| 839 | } |
| 840 | |
| 841 | /** |
| 842 | * Parse the value list(s) into per-shadow layer arrays. |
| 843 | * |
| 844 | * @return list<array{offsetX: float, offsetY: float, blur: float, spread: float, color: ?Color, inset: bool}> |
| 845 | */ |
| 846 | private function collectShadowLayers(\Phpdftk\Css\Value\Value $value): array |
| 847 | { |
| 848 | if ($value instanceof \Phpdftk\Css\Value\ValueList |
| 849 | && $value->separator === \Phpdftk\Css\Value\ListSeparator::Comma |
| 850 | ) { |
| 851 | $layers = []; |
| 852 | foreach ($value->values as $item) { |
| 853 | $parsed = $this->parseShadowLayer($item); |
| 854 | if ($parsed !== null) { |
| 855 | $layers[] = $parsed; |
| 856 | } |
| 857 | } |
| 858 | return $layers; |
| 859 | } |
| 860 | $single = $this->parseShadowLayer($value); |
| 861 | return $single === null ? [] : [$single]; |
| 862 | } |
| 863 | |
| 864 | /** |
| 865 | * @return array{offsetX: float, offsetY: float, blur: float, spread: float, color: ?Color, inset: bool}|null |
| 866 | */ |
| 867 | private function parseShadowLayer(\Phpdftk\Css\Value\Value $value): ?array |
| 868 | { |
| 869 | $components = $value instanceof \Phpdftk\Css\Value\ValueList |
| 870 | ? $value->values |
| 871 | : [$value]; |
| 872 | |
| 873 | $inset = false; |
| 874 | $color = null; |
| 875 | $lengths = []; |
| 876 | foreach ($components as $c) { |
| 877 | if ($c instanceof Keyword && strtolower($c->name) === 'inset') { |
| 878 | $inset = true; |
| 879 | continue; |
| 880 | } |
| 881 | if ($c instanceof Color) { |
| 882 | $color = $c; |
| 883 | continue; |
| 884 | } |
| 885 | if ($c instanceof \Phpdftk\Css\Value\Length) { |
| 886 | $lengths[] = $c->value; |
| 887 | continue; |
| 888 | } |
| 889 | // CSS Values 4 §6.2: a unitless `0` is a valid zero-length |
| 890 | // wherever a length is expected. Accept Integer/Number |
| 891 | // values as zero (and treat non-zero numerics as 0 — the |
| 892 | // grammar requires a unit otherwise). |
| 893 | if ($c instanceof \Phpdftk\Css\Value\Integer |
| 894 | || $c instanceof \Phpdftk\Css\Value\Number |
| 895 | ) { |
| 896 | $lengths[] = (float) $c->value; |
| 897 | } |
| 898 | } |
| 899 | if (count($lengths) < 2) { |
| 900 | return null; |
| 901 | } |
| 902 | return [ |
| 903 | 'offsetX' => $lengths[0], |
| 904 | 'offsetY' => $lengths[1], |
| 905 | 'blur' => $lengths[2] ?? 0.0, |
| 906 | 'spread' => $lengths[3] ?? 0.0, |
| 907 | 'color' => $color, |
| 908 | 'inset' => $inset, |
| 909 | ]; |
| 910 | } |
| 911 | |
| 912 | /** |
| 913 | * Resolve the box's cascaded `opacity`. Returns the page-level |
| 914 | * `ExtGState` resource name to invoke for partial opacity, or null |
| 915 | * when opacity is full (1.0) or the painter wasn't given a Page |
| 916 | * reference. Opacity affects this box plus every descendant since |
| 917 | * the `gs` operator persists until the matching `Q`. |
| 918 | */ |
| 919 | private function resolveOpacityGsName(Box $box): ?string |
| 920 | { |
| 921 | if ($this->page === null) { |
| 922 | return null; |
| 923 | } |
| 924 | $value = $box->style->get('opacity'); |
| 925 | $alpha = match (true) { |
| 926 | $value instanceof \Phpdftk\Css\Value\Number => $value->value, |
| 927 | $value instanceof \Phpdftk\Css\Value\Integer => (float) $value->value, |
| 928 | default => 1.0, |
| 929 | }; |
| 930 | $alpha = max(0.0, min(1.0, $alpha)); |
| 931 | if ($alpha >= 0.999) { |
| 932 | return null; |
| 933 | } |
| 934 | return $this->page->ensureOpacityState($alpha, $alpha); |
| 935 | } |
| 936 | |
| 937 | /** |
| 938 | * Paint a CSS Lists 3 list marker (the `::marker` pseudo) for boxes |
| 939 | * with `display: list-item`, honouring `list-style-type` for the |
| 940 | * three geometric markers (`disc` / `circle` / `square`). Counter- |
| 941 | * style markers (`decimal`, `lower-alpha`, etc.) require font |
| 942 | * rendering of the running counter and live in Phase 2; they fall |
| 943 | * through to a `disc` (filled circle) here. Marker colour follows |
| 944 | * the cascaded `color`. |
| 945 | */ |
| 946 | private function paintListMarker(Box $box, ContentStream $stream): void |
| 947 | { |
| 948 | $display = $box->style->get('display'); |
| 949 | if (!$display instanceof Keyword || strtolower($display->name) !== 'list-item') { |
| 950 | return; |
| 951 | } |
| 952 | $typeValue = $box->style->get('list-style-type'); |
| 953 | $type = $typeValue instanceof Keyword ? strtolower($typeValue->name) : 'disc'; |
| 954 | if ($type === 'none') { |
| 955 | return; |
| 956 | } |
| 957 | |
| 958 | $color = $box->style->get('color'); |
| 959 | $markerColor = $color instanceof Color ? $color : new Color(0, 0, 0, 1); |
| 960 | $fontSize = $this->dominantFontSize($box); |
| 961 | |
| 962 | // Counter-style markers — formatted text, requires a registered font. |
| 963 | $counterText = $this->formatCounterMarker($box, $type); |
| 964 | if ($counterText !== null && $this->defaultFont !== null) { |
| 965 | $this->paintCounterMarker($box, $stream, $markerColor, $fontSize, $counterText); |
| 966 | return; |
| 967 | } |
| 968 | |
| 969 | $size = max(2.0, $fontSize / 3.0); |
| 970 | $x = $box->geometry->x - max(6.0, $fontSize * 0.5); |
| 971 | $layoutY = $box->geometry->y + $fontSize * 0.35; |
| 972 | $pdfY = $this->pageHeight - $layoutY - $size; |
| 973 | |
| 974 | $stream->saveGraphicsState(); |
| 975 | $stream->setFillColorRGB($markerColor->r, $markerColor->g, $markerColor->b); |
| 976 | $stream->setStrokeColorRGB($markerColor->r, $markerColor->g, $markerColor->b); |
| 977 | match ($type) { |
| 978 | 'circle' => $this->paintMarkerCircle($stream, $x, $pdfY, $size, fill: false), |
| 979 | 'square' => $this->paintMarkerSquare($stream, $x, $pdfY, $size), |
| 980 | default => $this->paintMarkerCircle($stream, $x, $pdfY, $size, fill: true), |
| 981 | }; |
| 982 | $stream->restoreGraphicsState(); |
| 983 | } |
| 984 | |
| 985 | /** |
| 986 | * Format the marker text for counter-style list-style-types. Returns |
| 987 | * null for geometric / unknown / `none` types (caller paints the |
| 988 | * geometric stand-in or skips). |
| 989 | */ |
| 990 | private function formatCounterMarker(Box $box, string $type): ?string |
| 991 | { |
| 992 | $index = $this->listItemIndex($box); |
| 993 | if ($index < 1) { |
| 994 | return null; |
| 995 | } |
| 996 | $supported = [ |
| 997 | 'decimal', 'decimal-leading-zero', |
| 998 | 'lower-alpha', 'lower-latin', 'upper-alpha', 'upper-latin', |
| 999 | 'lower-roman', 'upper-roman', |
| 1000 | ]; |
| 1001 | if (!in_array(strtolower($type), $supported, true)) { |
| 1002 | return null; |
| 1003 | } |
| 1004 | return \Phpdftk\HtmlToPdf\Layout\CounterFormat::format($index, $type) . '.'; |
| 1005 | } |
| 1006 | |
| 1007 | /** |
| 1008 | * Compute the 1-based index of `$box` among its `<li>` siblings by |
| 1009 | * walking the originating Element's previousSibling chain. Returns |
| 1010 | * 0 when `$box` isn't bound to a DOM element (e.g. anonymous). |
| 1011 | */ |
| 1012 | private function listItemIndex(Box $box): int |
| 1013 | { |
| 1014 | if ($box->element === null) { |
| 1015 | return 0; |
| 1016 | } |
| 1017 | $thisLi = $box->element; |
| 1018 | // HTML 5 §4.4.5.2: `<li value="N">` sets the explicit ordinal — and |
| 1019 | // also resets the count for following siblings. Walk left-to-right |
| 1020 | // from the parent's first child until we hit `$thisLi`; bumping on |
| 1021 | // each `<li>` and snapping to `value` whenever a sibling provides |
| 1022 | // it. |
| 1023 | $parent = $thisLi->parentNode; |
| 1024 | if (!$parent instanceof \Phpdftk\Html\Dom\Element) { |
| 1025 | return 1; |
| 1026 | } |
| 1027 | // HTML 5 §4.4.5.3: `<ol start="N">` sets the starting count. |
| 1028 | // `<ol reversed>` counts down instead. |
| 1029 | $start = 1; |
| 1030 | $reversed = false; |
| 1031 | if (strtolower($parent->localName) === 'ol') { |
| 1032 | $rawStart = $parent->getAttribute('start'); |
| 1033 | if ($rawStart !== null && preg_match('/^-?\d+$/', trim($rawStart)) === 1) { |
| 1034 | $start = (int) trim($rawStart); |
| 1035 | } |
| 1036 | $reversed = $parent->getAttribute('reversed') !== null; |
| 1037 | } |
| 1038 | if ($reversed) { |
| 1039 | // Count `<li>` siblings to derive the reversed initial value. |
| 1040 | $liCount = 0; |
| 1041 | for ($n = $parent->firstChild; $n !== null; $n = $n->nextSibling) { |
| 1042 | if ($n instanceof \Phpdftk\Html\Dom\Element |
| 1043 | && strtolower($n->localName) === 'li' |
| 1044 | ) { |
| 1045 | $liCount++; |
| 1046 | } |
| 1047 | } |
| 1048 | $count = $start === 1 ? $liCount + 1 : $start + 1; |
| 1049 | $step = -1; |
| 1050 | } else { |
| 1051 | $count = $start - 1; |
| 1052 | $step = 1; |
| 1053 | } |
| 1054 | for ($n = $parent->firstChild; $n !== null; $n = $n->nextSibling) { |
| 1055 | if (!($n instanceof \Phpdftk\Html\Dom\Element) |
| 1056 | || strtolower($n->localName) !== 'li' |
| 1057 | ) { |
| 1058 | continue; |
| 1059 | } |
| 1060 | $raw = $n->getAttribute('value'); |
| 1061 | if ($raw !== null && preg_match('/^-?\d+$/', trim($raw)) === 1) { |
| 1062 | $count = (int) trim($raw); |
| 1063 | } else { |
| 1064 | $count += $step; |
| 1065 | } |
| 1066 | if ($n === $thisLi) { |
| 1067 | return $count; |
| 1068 | } |
| 1069 | } |
| 1070 | return $start; |
| 1071 | } |
| 1072 | |
| 1073 | /** |
| 1074 | * Paint a counter-style marker — shapes the text against the |
| 1075 | * registered font and emits a Tj at the marker position. |
| 1076 | */ |
| 1077 | private function paintCounterMarker( |
| 1078 | Box $box, |
| 1079 | ContentStream $stream, |
| 1080 | Color $color, |
| 1081 | float $fontSize, |
| 1082 | string $text, |
| 1083 | ): void { |
| 1084 | $font = $this->defaultFont; |
| 1085 | if (!$font instanceof WriterFont) { |
| 1086 | return; |
| 1087 | } |
| 1088 | $otd = $font->getParsedData(); |
| 1089 | if (!$otd instanceof \Phpdftk\FontParser\OpenTypeData) { |
| 1090 | return; |
| 1091 | } |
| 1092 | $shaper = new \Phpdftk\Text\Shaper(); |
| 1093 | $shapedRun = $shaper->shapeRun( |
| 1094 | $text, |
| 1095 | new \Phpdftk\Text\ShapingContext($otd, $fontSize), |
| 1096 | ); |
| 1097 | if ($shapedRun->glyphs === []) { |
| 1098 | return; |
| 1099 | } |
| 1100 | $ascent = ($otd->ascent / max(1, $otd->unitsPerEm)) * $fontSize; |
| 1101 | // Right-align the marker so it sits just to the left of the box content. |
| 1102 | $width = $shapedRun->totalAdvance; |
| 1103 | $x = $box->geometry->x - $width - max(2.0, $fontSize * 0.2); |
| 1104 | $baselineY = $box->geometry->y + $ascent; |
| 1105 | $pdfY = $this->pageHeight - $baselineY; |
| 1106 | |
| 1107 | $hex = ''; |
| 1108 | $gidMap = $font->getOldToNewGidMap(); |
| 1109 | foreach ($shapedRun->glyphs as $g) { |
| 1110 | $hex .= sprintf('%04X', $gidMap[$g->glyphId] ?? $g->glyphId); |
| 1111 | } |
| 1112 | |
| 1113 | $stream->saveGraphicsState(); |
| 1114 | $stream->setFillColorRGB($color->r, $color->g, $color->b); |
| 1115 | $stream->beginText(); |
| 1116 | $stream->setFont($font, $fontSize); |
| 1117 | $stream->setTextMatrix(1, 0, 0, 1, $x, $pdfY); |
| 1118 | $stream->showTextHex($hex); |
| 1119 | $stream->endText(); |
| 1120 | $stream->restoreGraphicsState(); |
| 1121 | } |
| 1122 | |
| 1123 | private function paintMarkerSquare(ContentStream $stream, float $x, float $y, float $size): void |
| 1124 | { |
| 1125 | $stream->rectangle($x, $y, $size, $size); |
| 1126 | $stream->fill(); |
| 1127 | } |
| 1128 | |
| 1129 | /** |
| 1130 | * Approximate a circle inside the bounding box (x, y, size, size) with |
| 1131 | * four cubic Bézier curves. The classic offset constant for unit-radius |
| 1132 | * approximation is `0.5522847498` — keeps the curve within ≈ 0.027% of |
| 1133 | * the true circle, more than enough at marker scale. |
| 1134 | */ |
| 1135 | private function paintMarkerCircle( |
| 1136 | ContentStream $stream, |
| 1137 | float $x, |
| 1138 | float $y, |
| 1139 | float $size, |
| 1140 | bool $fill, |
| 1141 | ): void { |
| 1142 | $r = $size / 2.0; |
| 1143 | $cx = $x + $r; |
| 1144 | $cy = $y + $r; |
| 1145 | $k = $r * 0.5522847498307933; |
| 1146 | $stream->moveTo($cx + $r, $cy); |
| 1147 | $stream->curveTo($cx + $r, $cy + $k, $cx + $k, $cy + $r, $cx, $cy + $r); |
| 1148 | $stream->curveTo($cx - $k, $cy + $r, $cx - $r, $cy + $k, $cx - $r, $cy); |
| 1149 | $stream->curveTo($cx - $r, $cy - $k, $cx - $k, $cy - $r, $cx, $cy - $r); |
| 1150 | $stream->curveTo($cx + $k, $cy - $r, $cx + $r, $cy - $k, $cx + $r, $cy); |
| 1151 | $stream->closePath(); |
| 1152 | if ($fill) { |
| 1153 | $stream->fill(); |
| 1154 | } else { |
| 1155 | $stream->setLineWidth(max(0.4, $r / 6.0)); |
| 1156 | $stream->stroke(); |
| 1157 | } |
| 1158 | } |
| 1159 | |
| 1160 | private function dominantFontSize(Box $box): float |
| 1161 | { |
| 1162 | $value = $box->style->get('font-size'); |
| 1163 | if ($value instanceof \Phpdftk\Css\Value\Length) { |
| 1164 | return $value->value; |
| 1165 | } |
| 1166 | return 12.0; |
| 1167 | } |
| 1168 | |
| 1169 | /** |
| 1170 | * Emit glyphs for every {@see InlineFragment} in this box's line boxes. |
| 1171 | * Requires a {@see RegisteredFont} on the painter — without one, text |
| 1172 | * painting is a no-op so block + border content still renders. |
| 1173 | * |
| 1174 | * Coordinates: layout space is top-down; PDF text positioning is |
| 1175 | * baseline-relative in bottom-up space. The baseline sits at |
| 1176 | * `lineBox.y + ascent` where ascent = (font.ascent / unitsPerEm) × |
| 1177 | * fontSize. The painter converts to PDF Y by subtracting from |
| 1178 | * `$this->pageHeight`. |
| 1179 | */ |
| 1180 | private function paintLineBoxes(Box $box, ContentStream $stream): void |
| 1181 | { |
| 1182 | if ($box->lineBoxes === [] || $this->defaultFont === null) { |
| 1183 | return; |
| 1184 | } |
| 1185 | $color = $box->style->get('color'); |
| 1186 | $textColor = $color instanceof Color ? $color : new Color(0, 0, 0, 1); |
| 1187 | |
| 1188 | // Inline backgrounds (`<mark>` and friends) paint as a strip behind |
| 1189 | // each fragment that carries a `backgroundColor`. Goes before the |
| 1190 | // text + shadow passes so the glyphs sit on top. |
| 1191 | foreach ($box->lineBoxes as $line) { |
| 1192 | $this->paintInlineBackgrounds($box, $line, $stream); |
| 1193 | } |
| 1194 | |
| 1195 | $shadows = $this->collectTextShadowLayers($box, $textColor); |
| 1196 | foreach ($box->lineBoxes as $line) { |
| 1197 | // Paint shadow layers behind the real text. CSS Text Decoration 4 |
| 1198 | // §6 says the first listed shadow is painted on top, so we |
| 1199 | // reverse the list for the back-to-front emission order. |
| 1200 | foreach (array_reverse($shadows) as $shadow) { |
| 1201 | $this->paintLine( |
| 1202 | $box, |
| 1203 | $line, |
| 1204 | $stream, |
| 1205 | $shadow['color'], |
| 1206 | $shadow['offsetX'], |
| 1207 | $shadow['offsetY'], |
| 1208 | ); |
| 1209 | } |
| 1210 | $this->paintLine($box, $line, $stream, $textColor); |
| 1211 | $this->paintTextDecorations($box, $line, $stream, $textColor); |
| 1212 | } |
| 1213 | } |
| 1214 | |
| 1215 | /** |
| 1216 | * Paint per-fragment inline background rectangles. Each fragment that |
| 1217 | * carries a `backgroundColor` (propagated from an inline element like |
| 1218 | * `<mark>` whose cascade sets `background-color`) gets a filled rect |
| 1219 | * spanning the fragment's width and the line's height. Adjacent |
| 1220 | * fragments with the same colour are merged so we emit one wider rect |
| 1221 | * per run of same-colour fragments — cheaper output without sub-pixel |
| 1222 | * gaps from neighbouring fills. |
| 1223 | */ |
| 1224 | private function paintInlineBackgrounds(Box $box, LineBox $line, ContentStream $stream): void |
| 1225 | { |
| 1226 | if ($line->fragments === []) { |
| 1227 | return; |
| 1228 | } |
| 1229 | // Coalesce contiguous fragments that share a background colour into |
| 1230 | // single rects so we emit cheaper output without sub-pixel gaps. |
| 1231 | /** @var list<array{x: float, width: float, color: Color}> $runs */ |
| 1232 | $runs = []; |
| 1233 | foreach ($line->fragments as $fragment) { |
| 1234 | $bg = $fragment->backgroundColor; |
| 1235 | if ($bg === null) { |
| 1236 | continue; |
| 1237 | } |
| 1238 | $last = $runs === [] ? null : $runs[array_key_last($runs)]; |
| 1239 | $sameAsLast = $last !== null |
| 1240 | && abs($last['x'] + $last['width'] - $fragment->x) < 0.001 |
| 1241 | && $last['color']->r === $bg->r |
| 1242 | && $last['color']->g === $bg->g |
| 1243 | && $last['color']->b === $bg->b; |
| 1244 | if ($sameAsLast) { |
| 1245 | $runs[array_key_last($runs)]['width'] = ($fragment->x + $fragment->width) - $last['x']; |
| 1246 | } else { |
| 1247 | $runs[] = [ |
| 1248 | 'x' => $fragment->x, |
| 1249 | 'width' => $fragment->width, |
| 1250 | 'color' => $bg, |
| 1251 | ]; |
| 1252 | } |
| 1253 | } |
| 1254 | if ($runs === []) { |
| 1255 | return; |
| 1256 | } |
| 1257 | $pdfY = $this->pageHeight - ($box->geometry->y + $line->y + $line->height); |
| 1258 | foreach ($runs as $run) { |
| 1259 | $stream->saveGraphicsState(); |
| 1260 | $stream->setFillColorRGB($run['color']->r, $run['color']->g, $run['color']->b); |
| 1261 | $stream->rectangle($box->geometry->x + $run['x'], $pdfY, $run['width'], $line->height); |
| 1262 | $stream->fill(); |
| 1263 | $stream->restoreGraphicsState(); |
| 1264 | } |
| 1265 | } |
| 1266 | |
| 1267 | /** |
| 1268 | * Parse the cascaded `text-shadow` value into layer entries. Returns |
| 1269 | * an empty array when text-shadow is `none` or absent. |
| 1270 | * |
| 1271 | * @return list<array{offsetX: float, offsetY: float, color: Color}> |
| 1272 | */ |
| 1273 | private function collectTextShadowLayers(Box $box, Color $fallback): array |
| 1274 | { |
| 1275 | $value = $box->style->get('text-shadow'); |
| 1276 | if ($value === null |
| 1277 | || ($value instanceof Keyword && strtolower($value->name) === 'none') |
| 1278 | ) { |
| 1279 | return []; |
| 1280 | } |
| 1281 | $layers = []; |
| 1282 | $items = $value instanceof \Phpdftk\Css\Value\ValueList |
| 1283 | && $value->separator === \Phpdftk\Css\Value\ListSeparator::Comma |
| 1284 | ? $value->values |
| 1285 | : [$value]; |
| 1286 | foreach ($items as $item) { |
| 1287 | $components = $item instanceof \Phpdftk\Css\Value\ValueList ? $item->values : [$item]; |
| 1288 | $lengths = []; |
| 1289 | $color = null; |
| 1290 | foreach ($components as $c) { |
| 1291 | if ($c instanceof \Phpdftk\Css\Value\Length) { |
| 1292 | $lengths[] = $c->value; |
| 1293 | } elseif ($c instanceof Color) { |
| 1294 | $color = $c; |
| 1295 | } |
| 1296 | } |
| 1297 | if (count($lengths) < 2) { |
| 1298 | continue; |
| 1299 | } |
| 1300 | $layers[] = [ |
| 1301 | 'offsetX' => $lengths[0], |
| 1302 | 'offsetY' => $lengths[1], |
| 1303 | 'color' => $color ?? $fallback, |
| 1304 | ]; |
| 1305 | } |
| 1306 | return $layers; |
| 1307 | } |
| 1308 | |
| 1309 | private function paintLine( |
| 1310 | Box $box, |
| 1311 | LineBox $line, |
| 1312 | ContentStream $stream, |
| 1313 | Color $color, |
| 1314 | float $offsetX = 0.0, |
| 1315 | float $offsetY = 0.0, |
| 1316 | ): void { |
| 1317 | if ($line->fragments === []) { |
| 1318 | return; |
| 1319 | } |
| 1320 | $stream->saveGraphicsState(); |
| 1321 | $stream->setFillColorRGB($color->r, $color->g, $color->b); |
| 1322 | $stream->beginText(); |
| 1323 | $activeColor = $color; |
| 1324 | foreach ($line->fragments as $fragment) { |
| 1325 | // Inline-level `color` override: when the fragment carries its |
| 1326 | // own colour (typically `<a>` inheriting blue from the UA |
| 1327 | // stylesheet inside a black `<p>`), reseat the fill colour. |
| 1328 | $fragColor = $fragment->textColor ?? $color; |
| 1329 | if ($fragColor !== $activeColor) { |
| 1330 | $stream->setFillColorRGB($fragColor->r, $fragColor->g, $fragColor->b); |
| 1331 | $activeColor = $fragColor; |
| 1332 | } |
| 1333 | $this->paintFragment($box, $line, $fragment, $stream, $fragColor, $offsetX, $offsetY); |
| 1334 | } |
| 1335 | // Reset rendering mode in case the last fragment left it set. |
| 1336 | $stream->setTextRenderingMode(0); |
| 1337 | $stream->endText(); |
| 1338 | $stream->restoreGraphicsState(); |
| 1339 | } |
| 1340 | |
| 1341 | /** |
| 1342 | * Paint text-decoration lines (underline / overline / line-through) for |
| 1343 | * every fragment whose parent style sets `text-decoration-line` to a |
| 1344 | * non-`none` value. |
| 1345 | * |
| 1346 | * Position approximation per CSS Text Decoration 3 §3: |
| 1347 | * - underline: baseline + 0.15 × fontSize |
| 1348 | * - overline: baseline − ascent (top of em box) |
| 1349 | * - line-through: baseline − 0.3 × fontSize (~x-height middle) |
| 1350 | * Thickness: `fontSize / 14`. |
| 1351 | * |
| 1352 | * The fallback approximations stand in until `phpdftk/font-parser` |
| 1353 | * exposes the OS/2 sTypoUnderlinePosition / underlineThickness fields. |
| 1354 | */ |
| 1355 | private function paintTextDecorations(Box $box, LineBox $line, ContentStream $stream, Color $color): void |
| 1356 | { |
| 1357 | $blockLines = $this->textDecorationLines($box); |
| 1358 | $decoColor = $this->textDecorationColor($box, $color); |
| 1359 | foreach ($line->fragments as $fragment) { |
| 1360 | // CSS Text Decoration 4 §2: a fragment's effective decoration |
| 1361 | // is the union of inherited (from inline ancestors) + block- |
| 1362 | // level lines. Block-level wins for color since the value |
| 1363 | // doesn't inherit through inlines. |
| 1364 | $lines = array_values(array_unique(array_merge($blockLines, $fragment->decorationLines))); |
| 1365 | if ($lines === []) { |
| 1366 | continue; |
| 1367 | } |
| 1368 | $shapedRun = $fragment->shapedRun; |
| 1369 | if ($shapedRun->glyphs === [] && $fragment->width <= 0.0) { |
| 1370 | continue; |
| 1371 | } |
| 1372 | $fontSize = $shapedRun->fontSizePt; |
| 1373 | $font = $shapedRun->font; |
| 1374 | $unitsPerEm = max(1, $font->unitsPerEm); |
| 1375 | $ascent = ($font->ascent / $unitsPerEm) * $fontSize; |
| 1376 | // Real OS/2 underline metrics when available; fall back to the |
| 1377 | // 1G.3 approximation otherwise. |
| 1378 | $underlineOffset = $font->underlinePosition !== null |
| 1379 | ? -($font->underlinePosition / $unitsPerEm) * $fontSize |
| 1380 | : 0.15 * $fontSize; |
| 1381 | $thickness = $font->underlineThickness !== null |
| 1382 | ? max(0.5, ($font->underlineThickness / $unitsPerEm) * $fontSize) |
| 1383 | : max(0.5, $fontSize / 14.0); |
| 1384 | // CSS Text Decoration 4 §4 — `text-decoration-thickness` |
| 1385 | // explicit Length / Percentage overrides the font metric. |
| 1386 | // `auto` defers to the metric above. |
| 1387 | $explicitThickness = $this->resolveDecorationThickness($box, $fontSize); |
| 1388 | if ($explicitThickness !== null) { |
| 1389 | $thickness = max(0.5, $explicitThickness); |
| 1390 | } |
| 1391 | // `text-underline-offset` shifts the underline ONLY (not |
| 1392 | // overline or line-through). Positive values push the line |
| 1393 | // further below the baseline. |
| 1394 | $explicitUnderlineOffset = $this->resolveUnderlineOffset($box, $fontSize); |
| 1395 | $x = $box->geometry->x + $fragment->x; |
| 1396 | $width = $fragment->width; |
| 1397 | $baselineY = $box->geometry->y + $line->y + $ascent; |
| 1398 | $style = $this->textDecorationStyle($box); |
| 1399 | // Per CSS Text Decoration 4 §3, the decoration colour follows |
| 1400 | // the *originating* element's `text-decoration-color` (when |
| 1401 | // explicitly set) — fall back to the fragment's `color` (so an |
| 1402 | // inline `<a>` with cascaded `color: blue` paints a blue |
| 1403 | // underline) and finally to the block-level value resolved at |
| 1404 | // the outer paint context. |
| 1405 | $effectiveColor = $fragment->decorationColor |
| 1406 | ?? $fragment->textColor |
| 1407 | ?? $decoColor; |
| 1408 | foreach ($lines as $lineKind) { |
| 1409 | $offsetY = match ($lineKind) { |
| 1410 | 'underline' => $underlineOffset + ($explicitUnderlineOffset ?? 0.0), |
| 1411 | 'overline' => -$ascent, |
| 1412 | 'line-through' => -0.3 * $fontSize, |
| 1413 | default => 0.0, |
| 1414 | }; |
| 1415 | $layoutY = $baselineY + $offsetY; |
| 1416 | $pdfY = $this->pageHeight - $layoutY - $thickness; |
| 1417 | $this->emitDecorationStyled($stream, $x, $pdfY, $width, $thickness, $effectiveColor, $style); |
| 1418 | } |
| 1419 | } |
| 1420 | } |
| 1421 | |
| 1422 | /** |
| 1423 | * Emit one text-decoration line in the given style. `solid` is one |
| 1424 | * rect; `double` is two parallel rects with a small gap; `dashed` |
| 1425 | * and `dotted` emit a series of segment rects; `wavy` strokes a |
| 1426 | * cubic-Bezier-approximated sine wave at the decoration position. |
| 1427 | */ |
| 1428 | private function emitDecorationStyled( |
| 1429 | ContentStream $stream, |
| 1430 | float $x, |
| 1431 | float $pdfY, |
| 1432 | float $width, |
| 1433 | float $thickness, |
| 1434 | Color $color, |
| 1435 | string $style, |
| 1436 | ): void { |
| 1437 | if ($style === 'wavy') { |
| 1438 | $this->emitWavyDecoration($stream, $x, $pdfY, $width, $thickness, $color); |
| 1439 | return; |
| 1440 | } |
| 1441 | $stream->saveGraphicsState(); |
| 1442 | $stream->setFillColorRGB($color->r, $color->g, $color->b); |
| 1443 | switch ($style) { |
| 1444 | case 'double': |
| 1445 | $gap = max(0.5, $thickness); |
| 1446 | $stream->rectangle($x, $pdfY, $width, $thickness); |
| 1447 | $stream->rectangle($x, $pdfY - $gap - $thickness, $width, $thickness); |
| 1448 | $stream->fill(); |
| 1449 | break; |
| 1450 | case 'dashed': |
| 1451 | $segment = max(2.0, $thickness * 3); |
| 1452 | $gap = max(1.5, $thickness * 2); |
| 1453 | for ($cx = $x; $cx < $x + $width; $cx += $segment + $gap) { |
| 1454 | $w = min($segment, $x + $width - $cx); |
| 1455 | $stream->rectangle($cx, $pdfY, $w, $thickness); |
| 1456 | } |
| 1457 | $stream->fill(); |
| 1458 | break; |
| 1459 | case 'dotted': |
| 1460 | $dotSize = max(1.0, $thickness); |
| 1461 | $gap = $dotSize * 1.2; |
| 1462 | for ($cx = $x; $cx < $x + $width; $cx += $dotSize + $gap) { |
| 1463 | $w = min($dotSize, $x + $width - $cx); |
| 1464 | $stream->rectangle($cx, $pdfY, $w, $thickness); |
| 1465 | } |
| 1466 | $stream->fill(); |
| 1467 | break; |
| 1468 | default: // solid |
| 1469 | $stream->rectangle($x, $pdfY, $width, $thickness); |
| 1470 | $stream->fill(); |
| 1471 | } |
| 1472 | $stream->restoreGraphicsState(); |
| 1473 | } |
| 1474 | |
| 1475 | /** |
| 1476 | * Stroke a sine-wave-shaped text decoration line, approximated by |
| 1477 | * cubic Bezier curves. Two Beziers per period (one half-cycle up, |
| 1478 | * one half-cycle down). The wave's period is `thickness × 6` and |
| 1479 | * amplitude is `thickness × 0.7` — these tune the look to match |
| 1480 | * the wavy spell-check underlines that browsers render. |
| 1481 | */ |
| 1482 | private function emitWavyDecoration( |
| 1483 | ContentStream $stream, |
| 1484 | float $x, |
| 1485 | float $pdfY, |
| 1486 | float $width, |
| 1487 | float $thickness, |
| 1488 | Color $color, |
| 1489 | ): void { |
| 1490 | if ($width <= 0.0 || $thickness <= 0.0) { |
| 1491 | return; |
| 1492 | } |
| 1493 | $period = max(4.0, $thickness * 6.0); |
| 1494 | $amp = max(1.0, $thickness * 0.7); |
| 1495 | $strokeWidth = max(0.5, $thickness * 0.7); |
| 1496 | $stream->saveGraphicsState(); |
| 1497 | $stream->setStrokeColorRGB($color->r, $color->g, $color->b); |
| 1498 | $stream->setLineWidth($strokeWidth); |
| 1499 | // Centerline of the wave sits at $pdfY + thickness/2 so the |
| 1500 | // visible band stays within the decoration's allocated band. |
| 1501 | $centerY = $pdfY + $thickness / 2.0; |
| 1502 | $stream->moveTo($x, $centerY); |
| 1503 | $halfPeriod = $period / 2.0; |
| 1504 | // Bezier control offset for a sine half-cycle (well-known |
| 1505 | // approximation: control points at 1/3 and 2/3 of the half). |
| 1506 | $cx1Offset = $halfPeriod / 3.0; |
| 1507 | $cx2Offset = ($halfPeriod * 2.0) / 3.0; |
| 1508 | $end = $x + $width; |
| 1509 | $curX = $x; |
| 1510 | $up = true; |
| 1511 | while ($curX < $end) { |
| 1512 | $segmentEnd = min($curX + $halfPeriod, $end); |
| 1513 | $controlY = $up ? $centerY + $amp : $centerY - $amp; |
| 1514 | $stream->curveTo( |
| 1515 | $curX + $cx1Offset, |
| 1516 | $controlY, |
| 1517 | $curX + $cx2Offset, |
| 1518 | $controlY, |
| 1519 | $segmentEnd, |
| 1520 | $centerY, |
| 1521 | ); |
| 1522 | $curX = $segmentEnd; |
| 1523 | $up = !$up; |
| 1524 | } |
| 1525 | $stream->stroke(); |
| 1526 | $stream->restoreGraphicsState(); |
| 1527 | } |
| 1528 | |
| 1529 | /** |
| 1530 | * Resolve CSS Text Decoration 4 §4 `text-decoration-thickness`. |
| 1531 | * Returns the resolved pixel value when an explicit Length or |
| 1532 | * Percentage is set (percentage is relative to the font size per |
| 1533 | * CSS UI 4 §6); returns null when the value is `auto` so the font |
| 1534 | * metric stays in effect. |
| 1535 | */ |
| 1536 | private function resolveDecorationThickness(Box $box, float $fontSize): ?float |
| 1537 | { |
| 1538 | $value = $box->style->get('text-decoration-thickness'); |
| 1539 | if ($value instanceof \Phpdftk\Css\Value\Length) { |
| 1540 | return $value->value; |
| 1541 | } |
| 1542 | if ($value instanceof \Phpdftk\Css\Value\Percentage) { |
| 1543 | return $value->value / 100.0 * $fontSize; |
| 1544 | } |
| 1545 | return null; |
| 1546 | } |
| 1547 | |
| 1548 | /** |
| 1549 | * Resolve CSS Text Decoration 4 §4.2 `text-underline-offset`. |
| 1550 | * Positive values push the underline further below the baseline; |
| 1551 | * `auto` (null) defers to the font-metric default. |
| 1552 | */ |
| 1553 | private function resolveUnderlineOffset(Box $box, float $fontSize): ?float |
| 1554 | { |
| 1555 | $value = $box->style->get('text-underline-offset'); |
| 1556 | if ($value instanceof \Phpdftk\Css\Value\Length) { |
| 1557 | return $value->value; |
| 1558 | } |
| 1559 | if ($value instanceof \Phpdftk\Css\Value\Percentage) { |
| 1560 | return $value->value / 100.0 * $fontSize; |
| 1561 | } |
| 1562 | return null; |
| 1563 | } |
| 1564 | |
| 1565 | private function textDecorationStyle(Box $box): string |
| 1566 | { |
| 1567 | $value = $box->style->get('text-decoration-style'); |
| 1568 | if ($value instanceof Keyword) { |
| 1569 | $name = strtolower($value->name); |
| 1570 | if (in_array($name, ['solid', 'double', 'dashed', 'dotted', 'wavy'], true)) { |
| 1571 | return $name; |
| 1572 | } |
| 1573 | } |
| 1574 | return 'solid'; |
| 1575 | } |
| 1576 | |
| 1577 | /** @return list<string> */ |
| 1578 | private function textDecorationLines(Box $box): array |
| 1579 | { |
| 1580 | $value = $box->style->get('text-decoration-line'); |
| 1581 | if ($value === null) { |
| 1582 | return []; |
| 1583 | } |
| 1584 | $items = $value instanceof \Phpdftk\Css\Value\ValueList ? $value->values : [$value]; |
| 1585 | $out = []; |
| 1586 | foreach ($items as $item) { |
| 1587 | if (!$item instanceof Keyword) { |
| 1588 | continue; |
| 1589 | } |
| 1590 | $lower = strtolower($item->name); |
| 1591 | if ($lower === 'none' || $lower === 'blink') { |
| 1592 | continue; |
| 1593 | } |
| 1594 | if (in_array($lower, ['underline', 'overline', 'line-through'], true)) { |
| 1595 | $out[] = $lower; |
| 1596 | } |
| 1597 | } |
| 1598 | return $out; |
| 1599 | } |
| 1600 | |
| 1601 | private function textDecorationColor(Box $box, Color $fallback): Color |
| 1602 | { |
| 1603 | $value = $box->style->get('text-decoration-color'); |
| 1604 | return $value instanceof Color ? $value : $fallback; |
| 1605 | } |
| 1606 | |
| 1607 | private function paintFragment( |
| 1608 | Box $box, |
| 1609 | LineBox $line, |
| 1610 | InlineFragment $fragment, |
| 1611 | ContentStream $stream, |
| 1612 | Color $color, |
| 1613 | float $offsetX = 0.0, |
| 1614 | float $offsetY = 0.0, |
| 1615 | ): void { |
| 1616 | $shapedRun = $fragment->shapedRun; |
| 1617 | if ($shapedRun->glyphs === []) { |
| 1618 | return; |
| 1619 | } |
| 1620 | $font = $shapedRun->font; |
| 1621 | $ascent = ($font->ascent / max(1, $font->unitsPerEm)) * $shapedRun->fontSizePt; |
| 1622 | $x = $box->geometry->x + $fragment->x + $offsetX; |
| 1623 | // CSS Inline 3 §4.5 vertical-align — `baselineShift` is negative |
| 1624 | // for `super`, positive for `sub`. Add it in layout-Y space, then |
| 1625 | // flip to PDF-Y. |
| 1626 | $baselineY = $box->geometry->y + $line->y + $ascent + $offsetY + $fragment->baselineShift; |
| 1627 | $pdfY = $this->pageHeight - $baselineY; |
| 1628 | |
| 1629 | // Pick the RegisteredFont matching this fragment's shaped font. |
| 1630 | // The map is keyed by OpenType postScriptName; the shaping context |
| 1631 | // chose the font, so this lookup just hands the painter the right |
| 1632 | // PDF Tf resource. Falls back to defaultFont when the fragment's |
| 1633 | // font wasn't registered (e.g., the resolver returned defaultFont |
| 1634 | // and there's no alt map). |
| 1635 | $registered = $this->registeredFonts[$font->postScriptName] ?? $this->defaultFont; |
| 1636 | $stream->setFont($registered ?? $font->postScriptName, $shapedRun->fontSizePt); |
| 1637 | // Fake-italic via a 12° skew in the Tm `c` slot (≈ tan(12°) = 0.213). |
| 1638 | // CSS Fonts 4 §6.4.1 lets browsers synthesise oblique from regular |
| 1639 | // when no real italic face is registered; this is the same trick. |
| 1640 | $skew = $fragment->isItalic ? 0.213 : 0.0; |
| 1641 | // Tm reseats the text matrix at each fragment's left baseline, which |
| 1642 | // is simpler than tracking incremental Td offsets between fragments. |
| 1643 | $stream->setTextMatrix(1, 0, $skew, 1, $x, $pdfY); |
| 1644 | // Fake-bold via text rendering mode 2 (fill + stroke). Stroke |
| 1645 | // contributes ≈ fontSize × 0.04 of extra thickness — visually close |
| 1646 | // to the design-weight increment for bold. Match the stroke color |
| 1647 | // to the cascaded fill color so the bold outline doesn't bleed in a |
| 1648 | // different hue. Always re-emit the Tr so a non-bold fragment that |
| 1649 | // follows a bold one resets to fill-only. |
| 1650 | if ($fragment->isBold) { |
| 1651 | $stream->setStrokeColorRGB($color->r, $color->g, $color->b); |
| 1652 | $stream->setLineWidth($shapedRun->fontSizePt * 0.04); |
| 1653 | $stream->setTextRenderingMode(2); |
| 1654 | } else { |
| 1655 | $stream->setTextRenderingMode(0); |
| 1656 | } |
| 1657 | |
| 1658 | // Per-font GID translation: CFF subsetting renumbers glyphs in the |
| 1659 | // embedded font, so the shaper's full-font GIDs must be mapped to |
| 1660 | // the subset GIDs before emission. Each registered font has its |
| 1661 | // own gid map. |
| 1662 | $gidMap = $registered instanceof WriterFont |
| 1663 | ? $registered->getOldToNewGidMap() |
| 1664 | : []; |
| 1665 | $unitsPerEm = max(1, $font->unitsPerEm); |
| 1666 | $fontSize = $shapedRun->fontSizePt; |
| 1667 | |
| 1668 | // Build a TJ array if the shaper's advances diverge from the font's |
| 1669 | // natural hmtx widths — that gap is the kern adjustment to encode. |
| 1670 | // Otherwise emit a plain Tj for the whole run. |
| 1671 | $items = []; |
| 1672 | $hex = ''; |
| 1673 | $hasKern = false; |
| 1674 | foreach ($shapedRun->glyphs as $g) { |
| 1675 | $emitted = $gidMap[$g->glyphId] ?? $g->glyphId; |
| 1676 | $hex .= sprintf('%04X', $emitted); |
| 1677 | |
| 1678 | $natural = ($font->glyphWidths[$g->glyphId] ?? 0) / $unitsPerEm * $fontSize; |
| 1679 | $delta = $natural - $g->advanceX; // positive = shaper pulled the glyph in (kern) |
| 1680 | $kern = $delta * 1000.0 / $fontSize; |
| 1681 | if (abs($kern) >= 0.5) { |
| 1682 | $items[] = $hex; |
| 1683 | $items[] = $this->snapKern($kern); |
| 1684 | $hex = ''; |
| 1685 | $hasKern = true; |
| 1686 | } |
| 1687 | } |
| 1688 | if ($hex !== '') { |
| 1689 | $items[] = $hex; |
| 1690 | } |
| 1691 | |
| 1692 | if ($hasKern) { |
| 1693 | $stream->showTextArrayHex($items); |
| 1694 | } else { |
| 1695 | $stream->showTextHex(implode('', array_filter($items, 'is_string'))); |
| 1696 | } |
| 1697 | |
| 1698 | // Inline `<a href>` — record the fragment's rect for /Link emission. |
| 1699 | // We only collect on the "real text" pass (offsetX / offsetY == 0) |
| 1700 | // so multi-layer text-shadow doesn't multiply the link count. |
| 1701 | if ($fragment->href !== null && $offsetX === 0.0 && $offsetY === 0.0) { |
| 1702 | $descent = abs($font->descent) / max(1, $unitsPerEm) * $fontSize; |
| 1703 | $this->collectedLinks[] = [ |
| 1704 | 'href' => $fragment->href, |
| 1705 | 'llx' => $x, |
| 1706 | 'lly' => $pdfY - $descent, |
| 1707 | 'urx' => $x + $fragment->width, |
| 1708 | 'ury' => $pdfY + $ascent, |
| 1709 | 'title' => $fragment->linkTitle, |
| 1710 | ]; |
| 1711 | } |
| 1712 | } |
| 1713 | |
| 1714 | /** |
| 1715 | * Block-level `<a href>` — emit a single link rect covering the box's |
| 1716 | * border box. Inline `<a>` is handled inside {@see paintFragment()}. |
| 1717 | */ |
| 1718 | private function collectBlockLinkRect(Box $box): void |
| 1719 | { |
| 1720 | if ($box->element === null |
| 1721 | || strtolower($box->element->localName) !== 'a' |
| 1722 | ) { |
| 1723 | return; |
| 1724 | } |
| 1725 | $href = $box->element->getAttribute('href'); |
| 1726 | if ($href === null || $href === '') { |
| 1727 | return; |
| 1728 | } |
| 1729 | // Inline `<a>` already produces per-fragment rects via paintFragment; |
| 1730 | // skip when there's nothing to do at the block level. |
| 1731 | if (!($box instanceof \Phpdftk\HtmlToPdf\Box\BlockBox) |
| 1732 | && !($box instanceof \Phpdftk\HtmlToPdf\Box\AnonymousBlockBox) |
| 1733 | && !($box instanceof \Phpdftk\HtmlToPdf\Box\AtomicInlineBox) |
| 1734 | ) { |
| 1735 | return; |
| 1736 | } |
| 1737 | $g = $box->geometry; |
| 1738 | if ($g->width <= 0.0 || $g->outerHeight() <= 0.0) { |
| 1739 | return; |
| 1740 | } |
| 1741 | $llx = $g->x; |
| 1742 | $urx = $g->x + $g->width; |
| 1743 | $ury = $this->pageHeight - $g->y; |
| 1744 | $lly = $this->pageHeight - ($g->y + $g->outerHeight()); |
| 1745 | $this->collectedLinks[] = [ |
| 1746 | 'href' => $href, |
| 1747 | 'llx' => $llx, |
| 1748 | 'lly' => $lly, |
| 1749 | 'urx' => $urx, |
| 1750 | 'ury' => $ury, |
| 1751 | 'title' => $box->element->getAttribute('title'), |
| 1752 | ]; |
| 1753 | } |
| 1754 | |
| 1755 | /** |
| 1756 | * Round to the nearest 0.1 unit so the emitted PDF stays compact. |
| 1757 | * PDF readers don't visually distinguish sub-tenth-unit kerns. |
| 1758 | */ |
| 1759 | private function snapKern(float $kern): float|int |
| 1760 | { |
| 1761 | $rounded = round($kern, 1); |
| 1762 | return $rounded == (int) $rounded ? (int) $rounded : $rounded; |
| 1763 | } |
| 1764 | |
| 1765 | private function paintBackground(Box $box, ContentStream $stream): void |
| 1766 | { |
| 1767 | // Inline-level backgrounds (InlineBox, TextBox, LineBreakBox) are |
| 1768 | // painted per-fragment by {@see paintInlineBackgrounds()}; their |
| 1769 | // own geometry is meaningless for block-style background painting |
| 1770 | // (layout doesn't size them as a single rect). AtomicInlineBox / |
| 1771 | // BlockBox / AnonymousBlockBox keep the block-style fill. |
| 1772 | if ($box instanceof \Phpdftk\HtmlToPdf\Box\InlineBox |
| 1773 | || $box instanceof \Phpdftk\HtmlToPdf\Box\TextBox |
| 1774 | || $box instanceof \Phpdftk\HtmlToPdf\Box\LineBreakBox |
| 1775 | ) { |
| 1776 | return; |
| 1777 | } |
| 1778 | $color = $box->style->get('background-color'); |
| 1779 | $bgImage = $box->style->get('background-image'); |
| 1780 | $hasColor = $color instanceof Color && $color->a > 0.0; |
| 1781 | $hasImage = $bgImage instanceof \Phpdftk\Css\Value\Url; |
| 1782 | $hasGradient = $bgImage instanceof \Phpdftk\Css\Value\LinearGradient; |
| 1783 | $hasRadial = $bgImage instanceof \Phpdftk\Css\Value\RadialGradient; |
| 1784 | if (!$hasColor && !$hasImage && !$hasGradient && !$hasRadial) { |
| 1785 | return; |
| 1786 | } |
| 1787 | $geo = $box->geometry; |
| 1788 | // CSS Backgrounds 3 §3.5 — `background-clip` controls which |
| 1789 | // box edge the background paint extends to. `border-box` |
| 1790 | // (initial) reaches the outer border edge; `padding-box` |
| 1791 | // stops at the inner border edge; `content-box` stays inside |
| 1792 | // padding. We honour all three keywords. |
| 1793 | $clip = $this->resolveBackgroundClip($box); |
| 1794 | switch ($clip) { |
| 1795 | case 'content-box': |
| 1796 | $x = $geo->x; |
| 1797 | $top = $geo->y; |
| 1798 | $width = $geo->width; |
| 1799 | $height = $geo->height; |
| 1800 | break; |
| 1801 | case 'padding-box': |
| 1802 | $x = $geo->x - $geo->paddingLeft; |
| 1803 | $top = $geo->y - $geo->paddingTop; |
| 1804 | $width = $geo->paddingLeft + $geo->width + $geo->paddingRight; |
| 1805 | $height = $geo->paddingTop + $geo->height + $geo->paddingBottom; |
| 1806 | break; |
| 1807 | default: // 'border-box' |
| 1808 | $x = $geo->x - $geo->paddingLeft - $geo->borderLeft; |
| 1809 | $top = $geo->y - $geo->paddingTop - $geo->borderTop; |
| 1810 | $width = $geo->paddingLeft + $geo->width + $geo->paddingRight |
| 1811 | + $geo->borderLeft + $geo->borderRight; |
| 1812 | $height = $geo->paddingTop + $geo->height + $geo->paddingBottom |
| 1813 | + $geo->borderTop + $geo->borderBottom; |
| 1814 | } |
| 1815 | if ($hasColor) { |
| 1816 | $radii = $this->borderRadii($box); |
| 1817 | if (array_sum($radii) > 0.0) { |
| 1818 | $this->emitRoundedFill($stream, $x, $top, $width, $height, $radii, $color); |
| 1819 | } else { |
| 1820 | $this->emitRect($stream, $x, $top, $width, $height, fill: $color); |
| 1821 | } |
| 1822 | } |
| 1823 | if ($hasImage && $width > 0.0 && $height > 0.0) { |
| 1824 | $sizeValue = $box->style->get('background-size'); |
| 1825 | $positionValue = $box->style->get('background-position'); |
| 1826 | $repeatValue = $box->style->get('background-repeat'); |
| 1827 | $this->paintBackgroundImage( |
| 1828 | $bgImage, |
| 1829 | $stream, |
| 1830 | $x, |
| 1831 | $top, |
| 1832 | $width, |
| 1833 | $height, |
| 1834 | $sizeValue, |
| 1835 | $positionValue, |
| 1836 | $repeatValue, |
| 1837 | ); |
| 1838 | } |
| 1839 | if ($hasGradient && $width > 0.0 && $height > 0.0) { |
| 1840 | $this->paintLinearGradient($bgImage, $stream, $x, $top, $width, $height); |
| 1841 | } |
| 1842 | if ($hasRadial && $width > 0.0 && $height > 0.0) { |
| 1843 | $this->paintRadialGradient($bgImage, $stream, $x, $top, $width, $height); |
| 1844 | } |
| 1845 | } |
| 1846 | |
| 1847 | /** |
| 1848 | * Paint a CSS `radial-gradient([<shape> <size>] [at <position>], <stops>)` |
| 1849 | * as the box's background. Phase-1 simplification: only the first |
| 1850 | * and last stops are honoured (PDF's basic ShadingType3 is two-stop), |
| 1851 | * the box centre is used when no `at <position>` is supplied, and |
| 1852 | * `circle` shapes default to half the box's smaller side while |
| 1853 | * `ellipse` shapes get half the box's dimensions per axis. |
| 1854 | * |
| 1855 | * Because PDF's radial shading is a *circular* primitive, ellipse |
| 1856 | * gradients are approximated by scaling the user-space matrix so a |
| 1857 | * unit circle becomes an ellipse — the gradient still expands |
| 1858 | * outward proportionally. |
| 1859 | */ |
| 1860 | private function paintRadialGradient( |
| 1861 | \Phpdftk\Css\Value\RadialGradient $gradient, |
| 1862 | ContentStream $stream, |
| 1863 | float $x, |
| 1864 | float $top, |
| 1865 | float $width, |
| 1866 | float $height, |
| 1867 | ): void { |
| 1868 | if ($this->writer === null || $gradient->stops === []) { |
| 1869 | return; |
| 1870 | } |
| 1871 | $first = $gradient->stops[0]; |
| 1872 | $last = $gradient->stops[array_key_last($gradient->stops)]; |
| 1873 | $pdfY = $this->pageHeight - $top - $height; |
| 1874 | // Centre: default to the box centre when no `at <position>` is |
| 1875 | // supplied. Author-supplied length values resolve relative to |
| 1876 | // the box's content rect. |
| 1877 | $cx = $x + ($gradient->centerX !== null ? $gradient->centerX->value : $width / 2); |
| 1878 | $cy = $pdfY + ($height - ($gradient->centerY !== null ? $gradient->centerY->value : $height / 2)); |
| 1879 | // Radii: prefer author lengths, otherwise default to the |
| 1880 | // farthest-corner distance for circles (PDF's two-stop primitive |
| 1881 | // assumes a single outer radius; we use the larger axis). |
| 1882 | $rx = $gradient->sizeX !== null ? $gradient->sizeX->value : $width / 2; |
| 1883 | $ry = $gradient->sizeY !== null ? $gradient->sizeY->value : $height / 2; |
| 1884 | // ShadingType3 takes inner+outer concentric circles. Phase-1 has |
| 1885 | // a single outer radius (inner = 0); scale the user-space matrix |
| 1886 | // for elliptical aspect when sizeX != sizeY. |
| 1887 | try { |
| 1888 | $doc = \Phpdftk\Pdf\Writer\PdfDoc::wrap($this->writer); |
| 1889 | $pattern = $doc->addRadialGradient( |
| 1890 | new \Phpdftk\Geometry\Point(0, 0), |
| 1891 | 0.0, |
| 1892 | new \Phpdftk\Geometry\Point(0, 0), |
| 1893 | max($rx, $ry), |
| 1894 | [$first->color->r, $first->color->g, $first->color->b], |
| 1895 | [$last->color->r, $last->color->g, $last->color->b], |
| 1896 | ); |
| 1897 | } catch (\Throwable) { |
| 1898 | return; |
| 1899 | } |
| 1900 | $patternName = $this->page?->useGradient($pattern); |
| 1901 | if ($patternName === null) { |
| 1902 | return; |
| 1903 | } |
| 1904 | $stream->saveGraphicsState(); |
| 1905 | // Clip to the box rect, then translate to the centre and scale |
| 1906 | // for elliptical shapes so the unit-radius gradient covers the |
| 1907 | // right footprint. |
| 1908 | $stream->rectangle($x, $pdfY, $width, $height); |
| 1909 | $stream->clip(); |
| 1910 | $stream->endPath(); |
| 1911 | $scaleX = $rx / max($rx, $ry); |
| 1912 | $scaleY = $ry / max($rx, $ry); |
| 1913 | $stream->concatMatrix($scaleX, 0.0, 0.0, $scaleY, $cx, $cy); |
| 1914 | $stream->setFillColorSpace('Pattern'); |
| 1915 | $stream->setFillColor($patternName); |
| 1916 | // Paint over a rect big enough to cover the largest possible |
| 1917 | // gradient extent (in the un-scaled space the gradient sits at |
| 1918 | // origin with radius `max(rx, ry)`, so a square of side 2*max |
| 1919 | // covers it). |
| 1920 | $extent = max($rx, $ry); |
| 1921 | $stream->rectangle(-$extent, -$extent, 2 * $extent, 2 * $extent); |
| 1922 | $stream->fill(); |
| 1923 | $stream->restoreGraphicsState(); |
| 1924 | } |
| 1925 | |
| 1926 | /** |
| 1927 | * Paint a CSS `linear-gradient(<angle>|to <side>, <stops>)` as the |
| 1928 | * box's background. Phase-1 simplification: only the first and last |
| 1929 | * stop colours are honoured (PDF's basic shading dictionary is |
| 1930 | * two-stop). The gradient line orientation comes from the CSS angle |
| 1931 | * (CSS direction: 0deg = upward, 90deg = rightward, 180deg = down, |
| 1932 | * 270deg = leftward; angles increase clockwise). |
| 1933 | */ |
| 1934 | private function paintLinearGradient( |
| 1935 | \Phpdftk\Css\Value\LinearGradient $gradient, |
| 1936 | ContentStream $stream, |
| 1937 | float $x, |
| 1938 | float $top, |
| 1939 | float $width, |
| 1940 | float $height, |
| 1941 | ): void { |
| 1942 | if ($this->writer === null || $gradient->stops === []) { |
| 1943 | return; |
| 1944 | } |
| 1945 | $first = $gradient->stops[0]; |
| 1946 | $last = $gradient->stops[array_key_last($gradient->stops)]; |
| 1947 | $pdfY = $this->pageHeight - $top - $height; |
| 1948 | // CSS angle convention: 0deg points up, increases clockwise. The |
| 1949 | // gradient line passes through the centre. Compute its start |
| 1950 | // and end points on the box's edge per CSS Images 3 §3.1. |
| 1951 | $angle = fmod($gradient->angleDeg, 360.0); |
| 1952 | if ($angle < 0.0) { |
| 1953 | $angle += 360.0; |
| 1954 | } |
| 1955 | $rad = deg2rad($angle); |
| 1956 | $cx = $x + $width / 2; |
| 1957 | $cy = $pdfY + $height / 2; |
| 1958 | // Gradient line half-length so the endpoints sit on the box |
| 1959 | // boundary corners (CSS spec): l/2 = |W sin θ| + |H cos θ| / 2 |
| 1960 | $sin = sin($rad); |
| 1961 | $cos = cos($rad); |
| 1962 | $halfLen = (abs($width * $sin) + abs($height * $cos)) / 2; |
| 1963 | // The CSS convention rotates the gradient line such that 0deg |
| 1964 | // points UP (towards the box top). In PDF space the y-axis |
| 1965 | // grows upward already (after our flip), so "up" is +y. |
| 1966 | $dx = $sin; |
| 1967 | $dy = $cos; |
| 1968 | $startPdfX = $cx - $dx * $halfLen; |
| 1969 | $startPdfY = $cy - $dy * $halfLen; |
| 1970 | $endPdfX = $cx + $dx * $halfLen; |
| 1971 | $endPdfY = $cy + $dy * $halfLen; |
| 1972 | try { |
| 1973 | $doc = \Phpdftk\Pdf\Writer\PdfDoc::wrap($this->writer); |
| 1974 | $pattern = $doc->addLinearGradient( |
| 1975 | new \Phpdftk\Geometry\Point($startPdfX, $startPdfY), |
| 1976 | new \Phpdftk\Geometry\Point($endPdfX, $endPdfY), |
| 1977 | [$first->color->r, $first->color->g, $first->color->b], |
| 1978 | [$last->color->r, $last->color->g, $last->color->b], |
| 1979 | ); |
| 1980 | } catch (\Throwable) { |
| 1981 | return; |
| 1982 | } |
| 1983 | $stream->saveGraphicsState(); |
| 1984 | $stream->rectangle($x, $pdfY, $width, $height); |
| 1985 | $stream->clip(); |
| 1986 | $stream->endPath(); |
| 1987 | $patternName = $this->page?->useGradient($pattern); |
| 1988 | if ($patternName !== null) { |
| 1989 | $stream->setFillColorSpace('Pattern'); |
| 1990 | $stream->setFillColor($patternName); |
| 1991 | $stream->rectangle($x, $pdfY, $width, $height); |
| 1992 | $stream->fill(); |
| 1993 | } |
| 1994 | $stream->restoreGraphicsState(); |
| 1995 | } |
| 1996 | |
| 1997 | /** |
| 1998 | * Paint a CSS `background-image: url(...)` over the box's |
| 1999 | * background-positioning area. CSS Backgrounds 3 §3.9 `background-size` |
| 2000 | * support: |
| 2001 | * - `auto` / unset → stretch to fill (Phase-1 default; the legacy |
| 2002 | * `100% 100%`-equivalent we shipped before). |
| 2003 | * - `cover` → preserve aspect, scale to fully cover the box (image |
| 2004 | * may overflow; clipped to box rect). |
| 2005 | * - `contain` → preserve aspect, scale to fit inside the box; |
| 2006 | * image is centred and may show background-color through the |
| 2007 | * letterbox area. |
| 2008 | * - `<length> <length>` → explicit width × height; centred. |
| 2009 | */ |
| 2010 | private function paintBackgroundImage( |
| 2011 | \Phpdftk\Css\Value\Url $url, |
| 2012 | ContentStream $stream, |
| 2013 | float $x, |
| 2014 | float $top, |
| 2015 | float $width, |
| 2016 | float $height, |
| 2017 | ?\Phpdftk\Css\Value\Value $sizeValue = null, |
| 2018 | ?\Phpdftk\Css\Value\Value $positionValue = null, |
| 2019 | ?\Phpdftk\Css\Value\Value $repeatValue = null, |
| 2020 | ): void { |
| 2021 | if ($this->writer === null || $this->page === null) { |
| 2022 | return; |
| 2023 | } |
| 2024 | $src = $url->url; |
| 2025 | if (isset($this->imageNameCache[$src])) { |
| 2026 | $name = $this->imageNameCache[$src]; |
| 2027 | } else { |
| 2028 | $resolved = $this->resolveImageSrc($src); |
| 2029 | if ($resolved === null) { |
| 2030 | return; |
| 2031 | } |
| 2032 | try { |
| 2033 | $name = $this->writer->addImage($resolved, $this->page); |
| 2034 | } catch (\Throwable) { |
| 2035 | return; |
| 2036 | } |
| 2037 | $this->imageNameCache[$src] = $name; |
| 2038 | } |
| 2039 | // Resolve final paint rect (final size + offset within the box). |
| 2040 | $paint = $this->resolveBackgroundSize($sizeValue, $src, $width, $height); |
| 2041 | // Author `background-position` may override the size resolver's |
| 2042 | // default centred offset. `auto` size keeps the stretch |
| 2043 | // behaviour so positioning has no effect (the rect equals the |
| 2044 | // box). For non-auto sizes, apply the position formula: |
| 2045 | // offset = (box - image) × percent. |
| 2046 | $isAuto = $sizeValue === null |
| 2047 | || ($sizeValue instanceof \Phpdftk\Css\Value\Keyword |
| 2048 | && strtolower($sizeValue->name) === 'auto'); |
| 2049 | if (!$isAuto && $positionValue !== null) { |
| 2050 | $pos = $this->resolveBackgroundPosition( |
| 2051 | $positionValue, |
| 2052 | $paint['w'], |
| 2053 | $paint['h'], |
| 2054 | $width, |
| 2055 | $height, |
| 2056 | ); |
| 2057 | $paint['offsetX'] = $pos['offsetX']; |
| 2058 | $paint['offsetY'] = $pos['offsetY']; |
| 2059 | } |
| 2060 | $pdfY = $this->pageHeight - $top - $height; |
| 2061 | $stream->saveGraphicsState(); |
| 2062 | // `cover` may overflow the box; clip to box rect so the overflow |
| 2063 | // doesn't bleed into adjacent boxes. |
| 2064 | $stream->rectangle($x, $pdfY, $width, $height); |
| 2065 | $stream->clip(); |
| 2066 | $stream->endPath(); |
| 2067 | // CSS Backgrounds 3 §3.8: `background-repeat` decides whether |
| 2068 | // to tile the image when its painted rect doesn't fill the box. |
| 2069 | // `no-repeat` paints one instance; `repeat` / `repeat-x` / |
| 2070 | // `repeat-y` tile across the relevant axes. The painter's box |
| 2071 | // clip handles edge tiles that extend past the box rect. |
| 2072 | $repeat = $this->repeatAxes($repeatValue); |
| 2073 | $tileW = $paint['w']; |
| 2074 | $tileH = $paint['h']; |
| 2075 | if ($tileW <= 0.0 || $tileH <= 0.0) { |
| 2076 | $stream->restoreGraphicsState(); |
| 2077 | return; |
| 2078 | } |
| 2079 | // Start positions: shift the anchor backwards by whole tile |
| 2080 | // widths until the leftmost / topmost tile sits at or before |
| 2081 | // the box origin. With `no-repeat`, no shift happens — single |
| 2082 | // tile at the resolved position. |
| 2083 | $startX = $paint['offsetX']; |
| 2084 | if ($repeat['x']) { |
| 2085 | while ($startX > 0.0) { |
| 2086 | $startX -= $tileW; |
| 2087 | } |
| 2088 | } |
| 2089 | $startY = $paint['offsetY']; |
| 2090 | if ($repeat['y']) { |
| 2091 | while ($startY > 0.0) { |
| 2092 | $startY -= $tileH; |
| 2093 | } |
| 2094 | } |
| 2095 | // Iterate forward emitting cm + Do per tile. Cap the tile count |
| 2096 | // defensively so a pathological 1×1 image in a 10000×10000 box |
| 2097 | // doesn't blow up the content stream. |
| 2098 | $maxTiles = 4096; |
| 2099 | $tileCount = 0; |
| 2100 | $offsetY = $startY; |
| 2101 | while ($offsetY < $height) { |
| 2102 | $offsetX = $startX; |
| 2103 | while ($offsetX < $width) { |
| 2104 | if ($tileCount >= $maxTiles) { |
| 2105 | break 2; |
| 2106 | } |
| 2107 | $stream->saveGraphicsState(); |
| 2108 | $stream->concatMatrix( |
| 2109 | $tileW, |
| 2110 | 0.0, |
| 2111 | 0.0, |
| 2112 | $tileH, |
| 2113 | $x + $offsetX, |
| 2114 | $pdfY + ($height - $tileH - $offsetY), |
| 2115 | ); |
| 2116 | $stream->doXObject($name); |
| 2117 | $stream->restoreGraphicsState(); |
| 2118 | $tileCount++; |
| 2119 | if (!$repeat['x']) { |
| 2120 | break; |
| 2121 | } |
| 2122 | $offsetX += $tileW; |
| 2123 | } |
| 2124 | if (!$repeat['y']) { |
| 2125 | break; |
| 2126 | } |
| 2127 | $offsetY += $tileH; |
| 2128 | } |
| 2129 | $stream->restoreGraphicsState(); |
| 2130 | } |
| 2131 | |
| 2132 | /** |
| 2133 | * Resolve a CSS `background-repeat` value to a `{x: bool, y: bool}` |
| 2134 | * pair indicating whether each axis should tile. |
| 2135 | * |
| 2136 | * Phase-1 handles the simple keyword set: |
| 2137 | * - `repeat` (default) → both axes |
| 2138 | * - `repeat-x` → x only |
| 2139 | * - `repeat-y` → y only |
| 2140 | * - `no-repeat` → neither |
| 2141 | * - two-value form (e.g. `repeat no-repeat`): per-axis keywords |
| 2142 | * The `space` / `round` per-axis variants land later — they need |
| 2143 | * extra-spacing / scaling math beyond the simple loop. |
| 2144 | * |
| 2145 | * @return array{x: bool, y: bool} |
| 2146 | */ |
| 2147 | private function repeatAxes(?\Phpdftk\Css\Value\Value $value): array |
| 2148 | { |
| 2149 | if ($value === null) { |
| 2150 | return ['x' => true, 'y' => true]; |
| 2151 | } |
| 2152 | $items = $value instanceof \Phpdftk\Css\Value\ValueList |
| 2153 | && $value->separator === \Phpdftk\Css\Value\ListSeparator::Space |
| 2154 | ? $value->values |
| 2155 | : [$value]; |
| 2156 | if (count($items) === 2 |
| 2157 | && $items[0] instanceof \Phpdftk\Css\Value\Keyword |
| 2158 | && $items[1] instanceof \Phpdftk\Css\Value\Keyword |
| 2159 | ) { |
| 2160 | return [ |
| 2161 | 'x' => strtolower($items[0]->name) !== 'no-repeat', |
| 2162 | 'y' => strtolower($items[1]->name) !== 'no-repeat', |
| 2163 | ]; |
| 2164 | } |
| 2165 | if ($items[0] instanceof \Phpdftk\Css\Value\Keyword) { |
| 2166 | return match (strtolower($items[0]->name)) { |
| 2167 | 'repeat-x' => ['x' => true, 'y' => false], |
| 2168 | 'repeat-y' => ['x' => false, 'y' => true], |
| 2169 | 'no-repeat' => ['x' => false, 'y' => false], |
| 2170 | default => ['x' => true, 'y' => true], |
| 2171 | }; |
| 2172 | } |
| 2173 | return ['x' => true, 'y' => true]; |
| 2174 | } |
| 2175 | |
| 2176 | /** |
| 2177 | * Resolve a CSS `background-position` value to a top-left offset of |
| 2178 | * the image rect within the background-positioning area (the box's |
| 2179 | * rect). Per CSS Backgrounds 3 §3.7, position offsets are |
| 2180 | * interpolated such that `0% 0%` puts the image's top-left at the |
| 2181 | * box's top-left and `100% 100%` puts the image's bottom-right at |
| 2182 | * the box's bottom-right (i.e. `offset = (box - image) × percent`). |
| 2183 | * |
| 2184 | * Phase-1 surface: |
| 2185 | * - 1 keyword (`center` / `top` / `bottom` / `left` / `right`) → centred on the missing axis |
| 2186 | * - 2 values (keyword | length | percentage), one per axis |
| 2187 | * - Lengths offset directly; percentages use the spec formula. |
| 2188 | * Edge syntax (`right 10px bottom 20px`, 4-value form) lands later. |
| 2189 | * |
| 2190 | * @return array{offsetX: float, offsetY: float} |
| 2191 | */ |
| 2192 | private function resolveBackgroundPosition( |
| 2193 | \Phpdftk\Css\Value\Value $value, |
| 2194 | float $imageWidth, |
| 2195 | float $imageHeight, |
| 2196 | float $boxWidth, |
| 2197 | float $boxHeight, |
| 2198 | ): array { |
| 2199 | $items = $value instanceof \Phpdftk\Css\Value\ValueList |
| 2200 | && $value->separator === \Phpdftk\Css\Value\ListSeparator::Space |
| 2201 | ? $value->values |
| 2202 | : [$value]; |
| 2203 | // Default both axes to centre (`50%`). |
| 2204 | $xPercent = 0.5; |
| 2205 | $yPercent = 0.5; |
| 2206 | $xLength = null; |
| 2207 | $yLength = null; |
| 2208 | // Single keyword: maps to one axis and centres the other. |
| 2209 | if (count($items) === 1 && $items[0] instanceof \Phpdftk\Css\Value\Keyword) { |
| 2210 | $kw = strtolower($items[0]->name); |
| 2211 | // Vertical-only keywords pin y; horizontal-only pin x. |
| 2212 | switch ($kw) { |
| 2213 | case 'top': $yPercent = 0.0; |
| 2214 | break; |
| 2215 | case 'bottom': $yPercent = 1.0; |
| 2216 | break; |
| 2217 | case 'left': $xPercent = 0.0; |
| 2218 | break; |
| 2219 | case 'right': $xPercent = 1.0; |
| 2220 | break; |
| 2221 | case 'center': default: break; |
| 2222 | } |
| 2223 | } else { |
| 2224 | // Two-value form: first is x, second is y. |
| 2225 | $xItem = $items[0] ?? null; |
| 2226 | $yItem = $items[1] ?? null; |
| 2227 | $xAxis = $this->axisOffsetFromValue($xItem, isHorizontal: true); |
| 2228 | $yAxis = $this->axisOffsetFromValue($yItem, isHorizontal: false); |
| 2229 | if ($xAxis['percent'] !== null) { |
| 2230 | $xPercent = $xAxis['percent']; |
| 2231 | } |
| 2232 | if ($xAxis['length'] !== null) { |
| 2233 | $xLength = $xAxis['length']; |
| 2234 | } |
| 2235 | if ($yAxis['percent'] !== null) { |
| 2236 | $yPercent = $yAxis['percent']; |
| 2237 | } |
| 2238 | if ($yAxis['length'] !== null) { |
| 2239 | $yLength = $yAxis['length']; |
| 2240 | } |
| 2241 | } |
| 2242 | $offsetX = $xLength ?? ($boxWidth - $imageWidth) * $xPercent; |
| 2243 | $offsetY = $yLength ?? ($boxHeight - $imageHeight) * $yPercent; |
| 2244 | return ['offsetX' => $offsetX, 'offsetY' => $offsetY]; |
| 2245 | } |
| 2246 | |
| 2247 | /** |
| 2248 | * Classify a single `background-position` axis value into a |
| 2249 | * `{percent?, length?}` pair. Keywords (`top`/`bottom`/`left`/ |
| 2250 | * `right`/`center`) become percentages; explicit `<length>` / |
| 2251 | * `<percentage>` values come through as-is. |
| 2252 | * |
| 2253 | * @return array{percent: ?float, length: ?float} |
| 2254 | */ |
| 2255 | private function axisOffsetFromValue( |
| 2256 | ?\Phpdftk\Css\Value\Value $value, |
| 2257 | bool $isHorizontal, |
| 2258 | ): array { |
| 2259 | if ($value === null) { |
| 2260 | return ['percent' => 0.5, 'length' => null]; |
| 2261 | } |
| 2262 | if ($value instanceof \Phpdftk\Css\Value\Keyword) { |
| 2263 | $kw = strtolower($value->name); |
| 2264 | $percent = match ($kw) { |
| 2265 | 'left', 'top' => 0.0, |
| 2266 | 'right', 'bottom' => 1.0, |
| 2267 | 'center' => 0.5, |
| 2268 | default => 0.5, |
| 2269 | }; |
| 2270 | return ['percent' => $percent, 'length' => null]; |
| 2271 | } |
| 2272 | if ($value instanceof \Phpdftk\Css\Value\Percentage) { |
| 2273 | return ['percent' => $value->value / 100.0, 'length' => null]; |
| 2274 | } |
| 2275 | if ($value instanceof \Phpdftk\Css\Value\Length) { |
| 2276 | return ['percent' => null, 'length' => $value->value]; |
| 2277 | } |
| 2278 | // CSS Values 4 §4.2: bare `0` is treated as `0` in any |
| 2279 | // dimensional context, so `background-position: 0 0` resolves |
| 2280 | // to top-left anchor (not the default centre). |
| 2281 | if (($value instanceof \Phpdftk\Css\Value\Number |
| 2282 | || $value instanceof \Phpdftk\Css\Value\Integer) |
| 2283 | && (float) $value->value === 0.0 |
| 2284 | ) { |
| 2285 | return ['percent' => null, 'length' => 0.0]; |
| 2286 | } |
| 2287 | return ['percent' => 0.5, 'length' => null]; |
| 2288 | } |
| 2289 | |
| 2290 | /** |
| 2291 | * Compute the final paint rect for a CSS `background-size` value. |
| 2292 | * Returns the width / height (in points) and the offset (top-left |
| 2293 | * corner) within the containing background-positioning area. Phase 1 |
| 2294 | * always centres `contain`-sized images; `background-position` |
| 2295 | * support lands later with the full Backgrounds 3 §3.7 grammar. |
| 2296 | * |
| 2297 | * @return array{w: float, h: float, offsetX: float, offsetY: float} |
| 2298 | */ |
| 2299 | private function resolveBackgroundSize( |
| 2300 | ?\Phpdftk\Css\Value\Value $sizeValue, |
| 2301 | string $src, |
| 2302 | float $boxWidth, |
| 2303 | float $boxHeight, |
| 2304 | ): array { |
| 2305 | // Default / unset / `auto`: legacy stretch behaviour. |
| 2306 | $isAuto = $sizeValue === null |
| 2307 | || ($sizeValue instanceof \Phpdftk\Css\Value\Keyword |
| 2308 | && strtolower($sizeValue->name) === 'auto'); |
| 2309 | if ($isAuto) { |
| 2310 | return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0]; |
| 2311 | } |
| 2312 | $keyword = $sizeValue instanceof \Phpdftk\Css\Value\Keyword |
| 2313 | ? strtolower($sizeValue->name) |
| 2314 | : null; |
| 2315 | if ($keyword === 'cover' || $keyword === 'contain') { |
| 2316 | $intrinsic = $this->intrinsicSize($src); |
| 2317 | if ($intrinsic === null) { |
| 2318 | // Fallback to stretch when we can't read natural size. |
| 2319 | return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0]; |
| 2320 | } |
| 2321 | [$natW, $natH] = $intrinsic; |
| 2322 | $scaleW = $boxWidth / $natW; |
| 2323 | $scaleH = $boxHeight / $natH; |
| 2324 | $scale = $keyword === 'cover' ? max($scaleW, $scaleH) : min($scaleW, $scaleH); |
| 2325 | $finalW = $natW * $scale; |
| 2326 | $finalH = $natH * $scale; |
| 2327 | return [ |
| 2328 | 'w' => $finalW, |
| 2329 | 'h' => $finalH, |
| 2330 | 'offsetX' => ($boxWidth - $finalW) / 2, |
| 2331 | 'offsetY' => ($boxHeight - $finalH) / 2, |
| 2332 | ]; |
| 2333 | } |
| 2334 | // `<length> <length>` — explicit dimensions in a 2-element |
| 2335 | // space-separated ValueList. Single value sets width, height auto |
| 2336 | // (which we resolve to the natural aspect). |
| 2337 | if ($sizeValue instanceof \Phpdftk\Css\Value\ValueList |
| 2338 | && $sizeValue->separator === \Phpdftk\Css\Value\ListSeparator::Space |
| 2339 | ) { |
| 2340 | $w = $sizeValue->values[0] ?? null; |
| 2341 | $h = $sizeValue->values[1] ?? null; |
| 2342 | $finalW = $w instanceof \Phpdftk\Css\Value\Length ? $w->value : $boxWidth; |
| 2343 | $finalH = $h instanceof \Phpdftk\Css\Value\Length ? $h->value : $boxHeight; |
| 2344 | return [ |
| 2345 | 'w' => $finalW, |
| 2346 | 'h' => $finalH, |
| 2347 | 'offsetX' => max(0.0, ($boxWidth - $finalW) / 2), |
| 2348 | 'offsetY' => max(0.0, ($boxHeight - $finalH) / 2), |
| 2349 | ]; |
| 2350 | } |
| 2351 | if ($sizeValue instanceof \Phpdftk\Css\Value\Length) { |
| 2352 | // Single length sets width; height = natural aspect. |
| 2353 | $intrinsic = $this->intrinsicSize($src); |
| 2354 | $finalW = $sizeValue->value; |
| 2355 | $finalH = $intrinsic !== null && $intrinsic[0] > 0 |
| 2356 | ? $finalW * ($intrinsic[1] / $intrinsic[0]) |
| 2357 | : $boxHeight; |
| 2358 | return [ |
| 2359 | 'w' => $finalW, |
| 2360 | 'h' => $finalH, |
| 2361 | 'offsetX' => max(0.0, ($boxWidth - $finalW) / 2), |
| 2362 | 'offsetY' => max(0.0, ($boxHeight - $finalH) / 2), |
| 2363 | ]; |
| 2364 | } |
| 2365 | return ['w' => $boxWidth, 'h' => $boxHeight, 'offsetX' => 0.0, 'offsetY' => 0.0]; |
| 2366 | } |
| 2367 | |
| 2368 | /** |
| 2369 | * Read the intrinsic pixel dimensions of an image referenced by an |
| 2370 | * `<img src>` or `background-image: url(...)` value. Tolerates both |
| 2371 | * `data:image/...` URIs and resolved local-file paths via the |
| 2372 | * painter's existing `resolveImageSrc`. Returns null when the bytes |
| 2373 | * can't be read or parsed. |
| 2374 | * |
| 2375 | * @return array{int, int}|null |
| 2376 | */ |
| 2377 | private function intrinsicSize(string $src): ?array |
| 2378 | { |
| 2379 | try { |
| 2380 | if (str_starts_with($src, 'data:image/')) { |
| 2381 | if (preg_match('~^data:image/(png|jpeg|jpg);(base64,)?(.*)$~s', $src, $m) !== 1) { |
| 2382 | return null; |
| 2383 | } |
| 2384 | $payload = $m[2] === 'base64,' |
| 2385 | ? base64_decode($m[3], strict: true) |
| 2386 | : urldecode($m[3]); |
| 2387 | if ($payload === false || $payload === '') { |
| 2388 | return null; |
| 2389 | } |
| 2390 | $info = \Phpdftk\ImageMetadata\ImageParser::parseString($payload); |
| 2391 | } else { |
| 2392 | $resolved = $this->resolveImageSrc($src); |
| 2393 | if ($resolved === null) { |
| 2394 | return null; |
| 2395 | } |
| 2396 | $info = \Phpdftk\ImageMetadata\ImageParser::parse($resolved); |
| 2397 | } |
| 2398 | } catch (\Throwable) { |
| 2399 | return null; |
| 2400 | } |
| 2401 | return [$info->width, $info->height]; |
| 2402 | } |
| 2403 | |
| 2404 | private function paintBorders(Box $box, ContentStream $stream): void |
| 2405 | { |
| 2406 | $geo = $box->geometry; |
| 2407 | $outerX = $geo->x - $geo->paddingLeft - $geo->borderLeft; |
| 2408 | $outerY = $geo->y - $geo->paddingTop - $geo->borderTop; |
| 2409 | $outerWidth = $geo->paddingLeft + $geo->width + $geo->paddingRight |
| 2410 | + $geo->borderLeft + $geo->borderRight; |
| 2411 | $outerHeight = $geo->paddingTop + $geo->height + $geo->paddingBottom |
| 2412 | + $geo->borderTop + $geo->borderBottom; |
| 2413 | |
| 2414 | // Rounded-uniform-border fast path: all four sides share width + |
| 2415 | // colour + style, and any radius is set → emit one stroked |
| 2416 | // rounded path. Mixed-width/colour borders or no radius fall back |
| 2417 | // to the per-side rectangle path (still straight corners). |
| 2418 | $radii = $this->borderRadii($box); |
| 2419 | if (array_sum($radii) > 0.0 && $this->bordersAreUniform($box)) { |
| 2420 | $width = $geo->borderTop; |
| 2421 | if ($width > 0.0) { |
| 2422 | $this->emitRoundedStroke( |
| 2423 | $stream, |
| 2424 | $outerX + $width / 2, |
| 2425 | $outerY + $width / 2, |
| 2426 | $outerWidth - $width, |
| 2427 | $outerHeight - $width, |
| 2428 | $radii, |
| 2429 | $this->borderColor($box, 'top'), |
| 2430 | $width, |
| 2431 | ); |
| 2432 | return; |
| 2433 | } |
| 2434 | } |
| 2435 | |
| 2436 | if ($geo->borderTop > 0.0 && $this->borderIsVisible($box, 'top')) { |
| 2437 | $this->paintBorderSide( |
| 2438 | $stream, |
| 2439 | $this->borderStyleName($box, 'top'), |
| 2440 | $this->borderColor($box, 'top'), |
| 2441 | $outerX, |
| 2442 | $outerY, |
| 2443 | $outerWidth, |
| 2444 | $geo->borderTop, |
| 2445 | side: 'top', |
| 2446 | ); |
| 2447 | } |
| 2448 | if ($geo->borderBottom > 0.0 && $this->borderIsVisible($box, 'bottom')) { |
| 2449 | $this->paintBorderSide( |
| 2450 | $stream, |
| 2451 | $this->borderStyleName($box, 'bottom'), |
| 2452 | $this->borderColor($box, 'bottom'), |
| 2453 | $outerX, |
| 2454 | $outerY + $outerHeight - $geo->borderBottom, |
| 2455 | $outerWidth, |
| 2456 | $geo->borderBottom, |
| 2457 | side: 'bottom', |
| 2458 | ); |
| 2459 | } |
| 2460 | if ($geo->borderLeft > 0.0 && $this->borderIsVisible($box, 'left')) { |
| 2461 | $this->paintBorderSide( |
| 2462 | $stream, |
| 2463 | $this->borderStyleName($box, 'left'), |
| 2464 | $this->borderColor($box, 'left'), |
| 2465 | $outerX, |
| 2466 | $outerY, |
| 2467 | $geo->borderLeft, |
| 2468 | $outerHeight, |
| 2469 | side: 'left', |
| 2470 | ); |
| 2471 | } |
| 2472 | if ($geo->borderRight > 0.0 && $this->borderIsVisible($box, 'right')) { |
| 2473 | $this->paintBorderSide( |
| 2474 | $stream, |
| 2475 | $this->borderStyleName($box, 'right'), |
| 2476 | $this->borderColor($box, 'right'), |
| 2477 | $outerX + $outerWidth - $geo->borderRight, |
| 2478 | $outerY, |
| 2479 | $geo->borderRight, |
| 2480 | $outerHeight, |
| 2481 | side: 'right', |
| 2482 | ); |
| 2483 | } |
| 2484 | } |
| 2485 | |
| 2486 | /** |
| 2487 | * Paint one border side honouring `border-style`. `axis` flags |
| 2488 | * whether the rect runs horizontally (top / bottom) or vertically |
| 2489 | * (left / right) so the `double` decomposition knows which |
| 2490 | * dimension to split into thirds. |
| 2491 | * |
| 2492 | * - `solid`: one filled rect (the original behaviour). |
| 2493 | * - `double` (CSS Backgrounds 3 §5): two parallel bands each |
| 2494 | * `thickness/3` thick with a `thickness/3` gap. When the |
| 2495 | * thickness is too small to split (< 3 units), falls back to |
| 2496 | * solid so the border doesn't disappear into a hairline. |
| 2497 | * - `dashed` / `dotted`: stroke a line at the centerline of the |
| 2498 | * side with a PDF dash pattern. Dashed uses 3w-on / 2w-off; |
| 2499 | * dotted uses 1w-on / 1w-off (PDF rounds dotted patterns to |
| 2500 | * square caps). |
| 2501 | * - Other style keywords (`groove`, `ridge`, `inset`, `outset`): |
| 2502 | * Phase-1 fallback to solid. |
| 2503 | */ |
| 2504 | private function paintBorderSide( |
| 2505 | ContentStream $stream, |
| 2506 | string $styleName, |
| 2507 | Color $color, |
| 2508 | float $x, |
| 2509 | float $y, |
| 2510 | float $width, |
| 2511 | float $height, |
| 2512 | string $side, |
| 2513 | ): void { |
| 2514 | $axis = ($side === 'top' || $side === 'bottom') ? 'horizontal' : 'vertical'; |
| 2515 | // CSS Backgrounds 3 §5.2 — 3D-effect styles. `inset` darkens |
| 2516 | // top + left; `outset` lightens them; `groove` and `ridge` |
| 2517 | // split per side as if etched / raised. |
| 2518 | if (in_array($styleName, ['inset', 'outset', 'groove', 'ridge'], true)) { |
| 2519 | $color = $this->resolve3dBorderColor($styleName, $color, $side); |
| 2520 | } |
| 2521 | if ($styleName === 'dashed' || $styleName === 'dotted') { |
| 2522 | $thickness = $axis === 'horizontal' ? $height : $width; |
| 2523 | $this->paintDashedDottedSide( |
| 2524 | $stream, |
| 2525 | $styleName, |
| 2526 | $color, |
| 2527 | $x, |
| 2528 | $y, |
| 2529 | $width, |
| 2530 | $height, |
| 2531 | $axis, |
| 2532 | $thickness, |
| 2533 | ); |
| 2534 | return; |
| 2535 | } |
| 2536 | if ($styleName === 'double') { |
| 2537 | $thickness = $axis === 'horizontal' ? $height : $width; |
| 2538 | if ($thickness >= 3.0) { |
| 2539 | $third = $thickness / 3.0; |
| 2540 | if ($axis === 'horizontal') { |
| 2541 | // Two horizontal bands stacked vertically. |
| 2542 | $this->emitRect($stream, $x, $y, $width, $third, fill: $color); |
| 2543 | $this->emitRect($stream, $x, $y + 2 * $third, $width, $third, fill: $color); |
| 2544 | } else { |
| 2545 | // Two vertical bands stacked horizontally. |
| 2546 | $this->emitRect($stream, $x, $y, $third, $height, fill: $color); |
| 2547 | $this->emitRect($stream, $x + 2 * $third, $y, $third, $height, fill: $color); |
| 2548 | } |
| 2549 | return; |
| 2550 | } |
| 2551 | } |
| 2552 | $this->emitRect($stream, $x, $y, $width, $height, fill: $color); |
| 2553 | } |
| 2554 | |
| 2555 | /** |
| 2556 | * Stroke one side of a border as a dashed / dotted line at the |
| 2557 | * centerline of the side, at line-width = thickness. PDF's `d` |
| 2558 | * operator takes a `[on, off]` array + phase; we use: |
| 2559 | * - dashed: `[thickness*3, thickness*2]` |
| 2560 | * - dotted: `[thickness, thickness]` (PDF strokes with square |
| 2561 | * caps so dotted shows as squares the size of thickness; |
| 2562 | * close enough to CSS dotted in print rendering). |
| 2563 | */ |
| 2564 | private function paintDashedDottedSide( |
| 2565 | ContentStream $stream, |
| 2566 | string $styleName, |
| 2567 | Color $color, |
| 2568 | float $x, |
| 2569 | float $y, |
| 2570 | float $width, |
| 2571 | float $height, |
| 2572 | string $axis, |
| 2573 | float $thickness, |
| 2574 | ): void { |
| 2575 | if ($thickness <= 0.0) { |
| 2576 | return; |
| 2577 | } |
| 2578 | $stream->saveGraphicsState(); |
| 2579 | $stream->setStrokeColorRGB($color->r, $color->g, $color->b); |
| 2580 | $stream->setLineWidth($thickness); |
| 2581 | $pattern = $styleName === 'dotted' |
| 2582 | ? [$thickness, $thickness] |
| 2583 | : [$thickness * 3, $thickness * 2]; |
| 2584 | $stream->setDashPattern($pattern, 0); |
| 2585 | if ($axis === 'horizontal') { |
| 2586 | // Stroke from (x, midY) to (x+width, midY). PDF Y is |
| 2587 | // inverted; midY in PDF coords = pageHeight - (y + height/2). |
| 2588 | $midPdfY = $this->pageHeight - ($y + $height / 2.0); |
| 2589 | $stream->moveTo($x, $midPdfY); |
| 2590 | $stream->lineTo($x + $width, $midPdfY); |
| 2591 | } else { |
| 2592 | $midX = $x + $width / 2.0; |
| 2593 | $topPdfY = $this->pageHeight - $y; |
| 2594 | $bottomPdfY = $this->pageHeight - ($y + $height); |
| 2595 | $stream->moveTo($midX, $topPdfY); |
| 2596 | $stream->lineTo($midX, $bottomPdfY); |
| 2597 | } |
| 2598 | $stream->stroke(); |
| 2599 | $stream->restoreGraphicsState(); |
| 2600 | } |
| 2601 | |
| 2602 | /** |
| 2603 | * Resolve the per-side colour for CSS Backgrounds 3 §5.2 3D-style |
| 2604 | * borders. The light source is conventionally top-left: |
| 2605 | * |
| 2606 | * - `inset` → top + left use a darker variant (carved-in look). |
| 2607 | * - `outset` → bottom + right use a darker variant (raised look). |
| 2608 | * - `groove` → top + left darker, bottom + right lighter (etched in). |
| 2609 | * - `ridge` → top + left lighter, bottom + right darker (raised ridge). |
| 2610 | * |
| 2611 | * "Darker" multiplies each RGB channel by 0.5; "lighter" lightens |
| 2612 | * toward white by 30%. These match common browser approximations. |
| 2613 | */ |
| 2614 | private function resolve3dBorderColor(string $styleName, Color $base, string $side): Color |
| 2615 | { |
| 2616 | $isTopLeft = $side === 'top' || $side === 'left'; |
| 2617 | $darken = static function (Color $c): Color { |
| 2618 | return new Color($c->r * 0.5, $c->g * 0.5, $c->b * 0.5, $c->a, $c->space); |
| 2619 | }; |
| 2620 | $lighten = static function (Color $c): Color { |
| 2621 | return new Color( |
| 2622 | $c->r + (1.0 - $c->r) * 0.3, |
| 2623 | $c->g + (1.0 - $c->g) * 0.3, |
| 2624 | $c->b + (1.0 - $c->b) * 0.3, |
| 2625 | $c->a, |
| 2626 | $c->space, |
| 2627 | ); |
| 2628 | }; |
| 2629 | return match ($styleName) { |
| 2630 | 'inset' => $isTopLeft ? $darken($base) : $base, |
| 2631 | 'outset' => $isTopLeft ? $base : $darken($base), |
| 2632 | 'groove' => $isTopLeft ? $darken($base) : $lighten($base), |
| 2633 | 'ridge' => $isTopLeft ? $lighten($base) : $darken($base), |
| 2634 | default => $base, |
| 2635 | }; |
| 2636 | } |
| 2637 | |
| 2638 | private function borderStyleName(Box $box, string $side): string |
| 2639 | { |
| 2640 | $value = $box->style->get("border-$side-style"); |
| 2641 | if (!$value instanceof Keyword) { |
| 2642 | return 'none'; |
| 2643 | } |
| 2644 | return strtolower($value->name); |
| 2645 | } |
| 2646 | |
| 2647 | /** |
| 2648 | * Uniform borders: same width / colour / visible-style on all 4 sides. |
| 2649 | * Enables the rounded-stroke fast path; mixed borders fall back to |
| 2650 | * straight per-side rectangles. |
| 2651 | */ |
| 2652 | private function bordersAreUniform(Box $box): bool |
| 2653 | { |
| 2654 | $g = $box->geometry; |
| 2655 | if (abs($g->borderTop - $g->borderRight) > 0.001 |
| 2656 | || abs($g->borderTop - $g->borderBottom) > 0.001 |
| 2657 | || abs($g->borderTop - $g->borderLeft) > 0.001 |
| 2658 | ) { |
| 2659 | return false; |
| 2660 | } |
| 2661 | $colorTop = $this->borderColor($box, 'top'); |
| 2662 | foreach (['right', 'bottom', 'left'] as $side) { |
| 2663 | if (!$this->borderIsVisible($box, $side)) { |
| 2664 | return false; |
| 2665 | } |
| 2666 | $c = $this->borderColor($box, $side); |
| 2667 | if ($c->r !== $colorTop->r || $c->g !== $colorTop->g || $c->b !== $colorTop->b) { |
| 2668 | return false; |
| 2669 | } |
| 2670 | } |
| 2671 | return $this->borderIsVisible($box, 'top'); |
| 2672 | } |
| 2673 | |
| 2674 | /** |
| 2675 | * Stroke a rounded-rectangle path. `x,topY,width,height` describe the |
| 2676 | * path's centreline (so the stroke straddles both inside and outside); |
| 2677 | * radii are clamped per spec. Used for uniform-border rendering when |
| 2678 | * border-radius is set. |
| 2679 | * |
| 2680 | * @param array{float, float, float, float} $radii |
| 2681 | */ |
| 2682 | private function emitRoundedStroke( |
| 2683 | ContentStream $stream, |
| 2684 | float $x, |
| 2685 | float $topY, |
| 2686 | float $width, |
| 2687 | float $height, |
| 2688 | array $radii, |
| 2689 | Color $color, |
| 2690 | float $lineWidth, |
| 2691 | ): void { |
| 2692 | $maxR = min($width, $height) / 2.0; |
| 2693 | [$rtl, $rtr, $rbr, $rbl] = array_map(static fn($r) => max(0.0, min($r, $maxR)), $radii); |
| 2694 | $k = 0.5522847498; |
| 2695 | $bottomPdfY = $this->pageHeight - $topY - $height; |
| 2696 | $topPdfY = $this->pageHeight - $topY; |
| 2697 | $stream->saveGraphicsState(); |
| 2698 | $stream->setStrokeColorRGB($color->r, $color->g, $color->b); |
| 2699 | $stream->setLineWidth($lineWidth); |
| 2700 | $stream->moveTo($x + $rtl, $topPdfY); |
| 2701 | $stream->lineTo($x + $width - $rtr, $topPdfY); |
| 2702 | if ($rtr > 0.0) { |
| 2703 | $stream->curveTo( |
| 2704 | $x + $width - $rtr + $rtr * $k, |
| 2705 | $topPdfY, |
| 2706 | $x + $width, |
| 2707 | $topPdfY - $rtr + $rtr * $k, |
| 2708 | $x + $width, |
| 2709 | $topPdfY - $rtr, |
| 2710 | ); |
| 2711 | } |
| 2712 | $stream->lineTo($x + $width, $bottomPdfY + $rbr); |
| 2713 | if ($rbr > 0.0) { |
| 2714 | $stream->curveTo( |
| 2715 | $x + $width, |
| 2716 | $bottomPdfY + $rbr - $rbr * $k, |
| 2717 | $x + $width - $rbr + $rbr * $k, |
| 2718 | $bottomPdfY, |
| 2719 | $x + $width - $rbr, |
| 2720 | $bottomPdfY, |
| 2721 | ); |
| 2722 | } |
| 2723 | $stream->lineTo($x + $rbl, $bottomPdfY); |
| 2724 | if ($rbl > 0.0) { |
| 2725 | $stream->curveTo( |
| 2726 | $x + $rbl - $rbl * $k, |
| 2727 | $bottomPdfY, |
| 2728 | $x, |
| 2729 | $bottomPdfY + $rbl - $rbl * $k, |
| 2730 | $x, |
| 2731 | $bottomPdfY + $rbl, |
| 2732 | ); |
| 2733 | } |
| 2734 | $stream->lineTo($x, $topPdfY - $rtl); |
| 2735 | if ($rtl > 0.0) { |
| 2736 | $stream->curveTo( |
| 2737 | $x, |
| 2738 | $topPdfY - $rtl + $rtl * $k, |
| 2739 | $x + $rtl - $rtl * $k, |
| 2740 | $topPdfY, |
| 2741 | $x + $rtl, |
| 2742 | $topPdfY, |
| 2743 | ); |
| 2744 | } |
| 2745 | $stream->closePath(); |
| 2746 | $stream->stroke(); |
| 2747 | $stream->restoreGraphicsState(); |
| 2748 | } |
| 2749 | |
| 2750 | /** |
| 2751 | * Paint CSS UI 3 §4 `outline`. Outlines don't take part in layout — |
| 2752 | * they're drawn just outside the border-box at `outline-offset`. We |
| 2753 | * only paint the visible outline-style values (everything except |
| 2754 | * `none` / `hidden`); `outline-width` and `outline-color` follow the |
| 2755 | * cascade. |
| 2756 | */ |
| 2757 | private function paintOutline(Box $box, ContentStream $stream): void |
| 2758 | { |
| 2759 | if ($box instanceof \Phpdftk\HtmlToPdf\Box\InlineBox |
| 2760 | || $box instanceof \Phpdftk\HtmlToPdf\Box\TextBox |
| 2761 | || $box instanceof \Phpdftk\HtmlToPdf\Box\LineBreakBox |
| 2762 | ) { |
| 2763 | return; |
| 2764 | } |
| 2765 | $style = $box->style->get('outline-style'); |
| 2766 | if (!$style instanceof Keyword) { |
| 2767 | return; |
| 2768 | } |
| 2769 | $styleName = strtolower($style->name); |
| 2770 | if ($styleName === 'none' || $styleName === 'hidden') { |
| 2771 | return; |
| 2772 | } |
| 2773 | $widthValue = $box->style->get('outline-width'); |
| 2774 | $width = $widthValue instanceof \Phpdftk\Css\Value\Length ? max(0.0, $widthValue->value) : 0.0; |
| 2775 | if ($width <= 0.0) { |
| 2776 | return; |
| 2777 | } |
| 2778 | $offsetValue = $box->style->get('outline-offset'); |
| 2779 | $offset = $offsetValue instanceof \Phpdftk\Css\Value\Length ? $offsetValue->value : 0.0; |
| 2780 | $colorValue = $box->style->get('outline-color'); |
| 2781 | $color = $colorValue instanceof Color ? $colorValue : ($box->style->get('color') instanceof Color |
| 2782 | ? $box->style->get('color') |
| 2783 | : new Color(0, 0, 0, 1)); |
| 2784 | |
| 2785 | $geo = $box->geometry; |
| 2786 | $outerX = $geo->x - $geo->paddingLeft - $geo->borderLeft - $offset - $width / 2; |
| 2787 | $outerY = $geo->y - $geo->paddingTop - $geo->borderTop - $offset - $width / 2; |
| 2788 | $outerWidth = $geo->paddingLeft + $geo->width + $geo->paddingRight |
| 2789 | + $geo->borderLeft + $geo->borderRight + 2 * $offset + $width; |
| 2790 | $outerHeight = $geo->paddingTop + $geo->height + $geo->paddingBottom |
| 2791 | + $geo->borderTop + $geo->borderBottom + 2 * $offset + $width; |
| 2792 | $pdfY = $this->pageHeight - $outerY - $outerHeight; |
| 2793 | $stream->saveGraphicsState(); |
| 2794 | $stream->setStrokeColorRGB($color->r, $color->g, $color->b); |
| 2795 | $stream->setLineWidth($width); |
| 2796 | // CSS Outline 3 §5 styles. `dashed` / `dotted` map onto PDF |
| 2797 | // line-dash patterns; `double` paints two concentric strokes |
| 2798 | // each `width/3` thick separated by a `width/3` gap; the rest |
| 2799 | // (`groove` / `ridge` / `inset` / `outset`) fall back to solid. |
| 2800 | if ($styleName === 'double' && $width >= 3.0) { |
| 2801 | $third = $width / 3.0; |
| 2802 | $stream->setLineWidth($third); |
| 2803 | // Outer ring: path centred between the outline's outer |
| 2804 | // edge and (outer edge + third). The stroke straddles the |
| 2805 | // path by ±third/2, so the outer face sits on the outline |
| 2806 | // outer edge. |
| 2807 | $stream->rectangle( |
| 2808 | $outerX + $third / 2, |
| 2809 | $pdfY + $third / 2, |
| 2810 | $outerWidth - $third, |
| 2811 | $outerHeight - $third, |
| 2812 | ); |
| 2813 | $stream->stroke(); |
| 2814 | // Inner ring: path centred two-thirds in from the outer. |
| 2815 | $stream->rectangle( |
| 2816 | $outerX + 2.5 * $third, |
| 2817 | $pdfY + 2.5 * $third, |
| 2818 | $outerWidth - 5 * $third, |
| 2819 | $outerHeight - 5 * $third, |
| 2820 | ); |
| 2821 | $stream->stroke(); |
| 2822 | $stream->restoreGraphicsState(); |
| 2823 | return; |
| 2824 | } |
| 2825 | switch ($styleName) { |
| 2826 | case 'dashed': |
| 2827 | $stream->setDashPattern([$width * 3, $width * 2], 0); |
| 2828 | break; |
| 2829 | case 'dotted': |
| 2830 | $stream->setDashPattern([$width, $width * 1.5], 0); |
| 2831 | break; |
| 2832 | // 'groove' / 'ridge' / 'inset' / 'outset' fall back |
| 2833 | // to solid for Phase 1. |
| 2834 | } |
| 2835 | $stream->rectangle($outerX, $pdfY, $outerWidth, $outerHeight); |
| 2836 | $stream->stroke(); |
| 2837 | $stream->restoreGraphicsState(); |
| 2838 | } |
| 2839 | |
| 2840 | /** |
| 2841 | * Stroke `column-rule` between adjacent columns inside a multi-column |
| 2842 | * container (CSS Multi-column 1 §3). Each rule is centred in its |
| 2843 | * column-gap, spans the container's content-area height, and honours |
| 2844 | * `column-rule-style` for `solid` / `dashed` / `dotted`. No-op when |
| 2845 | * the box isn't a multi-column container, the rule has zero width, or |
| 2846 | * the style is `none` / `hidden`. |
| 2847 | */ |
| 2848 | private function paintColumnRules(Box $box, ContentStream $stream): void |
| 2849 | { |
| 2850 | $mc = $box->multiColumn; |
| 2851 | if ($mc === null || $mc->columnCount < 2) { |
| 2852 | return; |
| 2853 | } |
| 2854 | if ($mc->ruleWidth <= 0.0 || $mc->ruleColor === null) { |
| 2855 | return; |
| 2856 | } |
| 2857 | $styleName = $mc->ruleStyle; |
| 2858 | if ($styleName === 'none' || $styleName === 'hidden') { |
| 2859 | return; |
| 2860 | } |
| 2861 | $geo = $box->geometry; |
| 2862 | $top = $geo->y; |
| 2863 | $height = $geo->height; |
| 2864 | if ($height <= 0.0) { |
| 2865 | return; |
| 2866 | } |
| 2867 | $pdfTop = $this->pageHeight - $top; |
| 2868 | $pdfBottom = $this->pageHeight - ($top + $height); |
| 2869 | $stream->saveGraphicsState(); |
| 2870 | $stream->setStrokeColorRGB($mc->ruleColor->r, $mc->ruleColor->g, $mc->ruleColor->b); |
| 2871 | $stream->setLineWidth($mc->ruleWidth); |
| 2872 | switch ($styleName) { |
| 2873 | case 'dashed': |
| 2874 | $stream->setDashPattern([$mc->ruleWidth * 3, $mc->ruleWidth * 2], 0); |
| 2875 | break; |
| 2876 | case 'dotted': |
| 2877 | $stream->setDashPattern([$mc->ruleWidth, $mc->ruleWidth * 1.5], 0); |
| 2878 | break; |
| 2879 | // Other styles (double / groove / ridge / inset / outset) |
| 2880 | // fall back to solid for Phase 1, mirroring the outline |
| 2881 | // painter's approximation. |
| 2882 | } |
| 2883 | for ($i = 0; $i < $mc->columnCount - 1; $i++) { |
| 2884 | // Centre line of the gap between column $i and $i+1. |
| 2885 | $gapCentreX = $geo->x |
| 2886 | + ($i + 1) * $mc->columnWidth |
| 2887 | + $i * $mc->columnGap |
| 2888 | + $mc->columnGap / 2.0; |
| 2889 | $stream->moveTo($gapCentreX, $pdfBottom); |
| 2890 | $stream->lineTo($gapCentreX, $pdfTop); |
| 2891 | $stream->stroke(); |
| 2892 | } |
| 2893 | $stream->restoreGraphicsState(); |
| 2894 | } |
| 2895 | |
| 2896 | private function borderIsVisible(Box $box, string $side): bool |
| 2897 | { |
| 2898 | $style = $box->style->get("border-$side-style"); |
| 2899 | if (!$style instanceof Keyword) { |
| 2900 | return false; |
| 2901 | } |
| 2902 | $lower = strtolower($style->name); |
| 2903 | return $lower !== 'none' && $lower !== 'hidden'; |
| 2904 | } |
| 2905 | |
| 2906 | private function borderColor(Box $box, string $side): Color |
| 2907 | { |
| 2908 | $color = $box->style->get("border-$side-color"); |
| 2909 | if ($color instanceof Color) { |
| 2910 | return $color; |
| 2911 | } |
| 2912 | // CSS Colors 4: border-color initial is currentColor, which means |
| 2913 | // the cascaded `color` property. |
| 2914 | $current = $box->style->get('color'); |
| 2915 | if ($current instanceof Color) { |
| 2916 | return $current; |
| 2917 | } |
| 2918 | return new Color(0, 0, 0, 1); |
| 2919 | } |
| 2920 | |
| 2921 | /** |
| 2922 | * Emit a rect in PDF coordinates (Y flipped from top-down layout space). |
| 2923 | * `topY` is the layout-space top edge; `height` is positive downward. |
| 2924 | */ |
| 2925 | private function emitRect( |
| 2926 | ContentStream $stream, |
| 2927 | float $x, |
| 2928 | float $topY, |
| 2929 | float $width, |
| 2930 | float $height, |
| 2931 | Color $fill, |
| 2932 | ): void { |
| 2933 | $pdfY = $this->pageHeight - $topY - $height; |
| 2934 | $stream->saveGraphicsState(); |
| 2935 | $stream->setFillColorRGB($fill->r, $fill->g, $fill->b); |
| 2936 | $stream->rectangle($x, $pdfY, $width, $height); |
| 2937 | $stream->fill(); |
| 2938 | $stream->restoreGraphicsState(); |
| 2939 | } |
| 2940 | |
| 2941 | /** |
| 2942 | * Read the box's four corner radii in pixel-equivalent units. CSS |
| 2943 | * Backgrounds 3 §6 requires each radius to be clamped to half the |
| 2944 | * shorter side; we do that here. |
| 2945 | * |
| 2946 | * @return array{float, float, float, float} [tl, tr, br, bl] |
| 2947 | */ |
| 2948 | private function borderRadii(Box $box): array |
| 2949 | { |
| 2950 | $read = function (string $name) use ($box): float { |
| 2951 | $v = $box->style->get($name); |
| 2952 | return $v instanceof \Phpdftk\Css\Value\Length ? max(0.0, $v->value) : 0.0; |
| 2953 | }; |
| 2954 | return [ |
| 2955 | $read('border-top-left-radius'), |
| 2956 | $read('border-top-right-radius'), |
| 2957 | $read('border-bottom-right-radius'), |
| 2958 | $read('border-bottom-left-radius'), |
| 2959 | ]; |
| 2960 | } |
| 2961 | |
| 2962 | /** |
| 2963 | * Emit a rounded-rectangle fill path using cubic Béziers at the four |
| 2964 | * corners. Topology in layout-Y (top-down) with the painter's flip |
| 2965 | * applied at emission time. The 0.5522847498 constant is the standard |
| 2966 | * cubic-Bézier circle approximation factor. |
| 2967 | * |
| 2968 | * @param array{float, float, float, float} $radii [tl, tr, br, bl] |
| 2969 | */ |
| 2970 | private function emitRoundedFill( |
| 2971 | ContentStream $stream, |
| 2972 | float $x, |
| 2973 | float $topY, |
| 2974 | float $width, |
| 2975 | float $height, |
| 2976 | array $radii, |
| 2977 | Color $fill, |
| 2978 | ): void { |
| 2979 | $maxR = min($width, $height) / 2.0; |
| 2980 | [$rtl, $rtr, $rbr, $rbl] = array_map(static fn($r) => min($r, $maxR), $radii); |
| 2981 | $k = 0.5522847498; |
| 2982 | // Flip to PDF coords for emission. |
| 2983 | $bottomPdfY = $this->pageHeight - $topY - $height; |
| 2984 | $topPdfY = $this->pageHeight - $topY; |
| 2985 | // Walk clockwise starting at the top-left straight edge. |
| 2986 | $stream->saveGraphicsState(); |
| 2987 | $stream->setFillColorRGB($fill->r, $fill->g, $fill->b); |
| 2988 | $stream->moveTo($x + $rtl, $topPdfY); |
| 2989 | $stream->lineTo($x + $width - $rtr, $topPdfY); |
| 2990 | if ($rtr > 0.0) { |
| 2991 | $stream->curveTo( |
| 2992 | $x + $width - $rtr + $rtr * $k, |
| 2993 | $topPdfY, |
| 2994 | $x + $width, |
| 2995 | $topPdfY - $rtr + $rtr * $k, |
| 2996 | $x + $width, |
| 2997 | $topPdfY - $rtr, |
| 2998 | ); |
| 2999 | } |
| 3000 | $stream->lineTo($x + $width, $bottomPdfY + $rbr); |
| 3001 | if ($rbr > 0.0) { |
| 3002 | $stream->curveTo( |
| 3003 | $x + $width, |
| 3004 | $bottomPdfY + $rbr - $rbr * $k, |
| 3005 | $x + $width - $rbr + $rbr * $k, |
| 3006 | $bottomPdfY, |
| 3007 | $x + $width - $rbr, |
| 3008 | $bottomPdfY, |
| 3009 | ); |
| 3010 | } |
| 3011 | $stream->lineTo($x + $rbl, $bottomPdfY); |
| 3012 | if ($rbl > 0.0) { |
| 3013 | $stream->curveTo( |
| 3014 | $x + $rbl - $rbl * $k, |
| 3015 | $bottomPdfY, |
| 3016 | $x, |
| 3017 | $bottomPdfY + $rbl - $rbl * $k, |
| 3018 | $x, |
| 3019 | $bottomPdfY + $rbl, |
| 3020 | ); |
| 3021 | } |
| 3022 | $stream->lineTo($x, $topPdfY - $rtl); |
| 3023 | if ($rtl > 0.0) { |
| 3024 | $stream->curveTo( |
| 3025 | $x, |
| 3026 | $topPdfY - $rtl + $rtl * $k, |
| 3027 | $x + $rtl - $rtl * $k, |
| 3028 | $topPdfY, |
| 3029 | $x + $rtl, |
| 3030 | $topPdfY, |
| 3031 | ); |
| 3032 | } |
| 3033 | $stream->closePath(); |
| 3034 | $stream->fill(); |
| 3035 | $stream->restoreGraphicsState(); |
| 3036 | } |
| 3037 | } |