Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
92.68% |
38 / 41 |
|
50.00% |
2 / 4 |
CRAP | |
0.00% |
0 / 1 |
| SvgCascadeProjector | |
92.68% |
38 / 41 |
|
50.00% |
2 / 4 |
16.10 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| project | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| walk | |
93.94% |
31 / 33 |
|
0.00% |
0 / 1 |
11.03 | |||
| findDocument | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\SvgToPdf; |
| 6 | |
| 7 | use Phpdftk\Css\Cascade\CascadedValues; |
| 8 | use Phpdftk\Svg\Css\CssBridge; |
| 9 | use Phpdftk\Svg\Element; |
| 10 | use Phpdftk\Svg\SvgDocument; |
| 11 | |
| 12 | /** |
| 13 | * Project author CSS (cascaded values from `<style>` blocks inside |
| 14 | * the SVG) into each element's inline `style` attribute so the |
| 15 | * existing `Element::presentationOrStyle()` fallback picks them up |
| 16 | * during paint. |
| 17 | * |
| 18 | * Why a projector and not direct cascade reads at paint time: |
| 19 | * |
| 20 | * - The painter is shape-by-shape with deeply tree-recursive |
| 21 | * paths. Re-resolving the cascade at every accessor call would |
| 22 | * dominate the render budget. |
| 23 | * - The projection runs once per SvgDocument before paint, so |
| 24 | * each element only pays the cascade cost once regardless of |
| 25 | * how many accessors read it. |
| 26 | * - The projection writes back to the same `style` attribute |
| 27 | * that author-supplied inline declarations live in, so |
| 28 | * accessors don't need to know whether a value came from |
| 29 | * `<rect style="...">` or `<style>.s { ... }`. Same code path. |
| 30 | * |
| 31 | * Per-property allowlist: |
| 32 | * |
| 33 | * The projection only touches properties the painter actually |
| 34 | * reads through {@see Element::presentationOrStyle()}. |
| 35 | * Projecting the full computed-style set would inject browser- |
| 36 | * computed defaults (`stroke: none` everywhere, etc.) and shift |
| 37 | * the paint behaviour for properties our renderer hasn't wired |
| 38 | * yet, which would regress unrelated fixtures. |
| 39 | * |
| 40 | * Adding a new accessor means adding the property here too. |
| 41 | */ |
| 42 | final class SvgCascadeProjector |
| 43 | { |
| 44 | /** |
| 45 | * Properties projected into inline `style`. Add to this list |
| 46 | * when a new {@see Element} accessor starts reading via |
| 47 | * `presentationOrStyle`. |
| 48 | */ |
| 49 | private const array PROJECTED = [ |
| 50 | 'fill', |
| 51 | 'stroke', |
| 52 | 'fill-rule', |
| 53 | 'stroke-width', |
| 54 | 'stroke-linecap', |
| 55 | 'stroke-linejoin', |
| 56 | 'stroke-miterlimit', |
| 57 | 'stroke-dasharray', |
| 58 | 'stroke-dashoffset', |
| 59 | 'fill-opacity', |
| 60 | 'stroke-opacity', |
| 61 | 'opacity', |
| 62 | 'font-family', |
| 63 | 'font-size', |
| 64 | 'font-weight', |
| 65 | 'font-style', |
| 66 | ]; |
| 67 | |
| 68 | public function __construct( |
| 69 | private readonly CssBridge $bridge = new CssBridge(), |
| 70 | ) {} |
| 71 | |
| 72 | /** |
| 73 | * Walk `$document` and project the cascade into every element's |
| 74 | * `style` attribute. Mutates the document in place. Safe to |
| 75 | * call more than once - the second pass overwrites the |
| 76 | * previous projection (it's idempotent for the same document |
| 77 | * state). |
| 78 | */ |
| 79 | public function project(SvgDocument $document): void |
| 80 | { |
| 81 | $this->walk($document, null); |
| 82 | } |
| 83 | |
| 84 | /** |
| 85 | * Recursive walker. Each call computes the cascade for |
| 86 | * `$element` (using `$parentValues` for inheritance), writes |
| 87 | * the relevant properties back as a `style` attribute |
| 88 | * declaration, then recurses into children with the new |
| 89 | * cascade as their parent values. |
| 90 | */ |
| 91 | private function walk(Element $element, ?CascadedValues $parentValues): void |
| 92 | { |
| 93 | $document = $element instanceof SvgDocument |
| 94 | ? $element |
| 95 | : $this->findDocument($element); |
| 96 | if ($document === null) { |
| 97 | return; |
| 98 | } |
| 99 | $values = $this->bridge->computeStyle( |
| 100 | $element, |
| 101 | $document, |
| 102 | $parentValues, |
| 103 | ); |
| 104 | |
| 105 | $declarations = []; |
| 106 | foreach (self::PROJECTED as $property) { |
| 107 | // The author intent on the element itself (presentation |
| 108 | // attribute, inline style) already feeds the painter |
| 109 | // through `presentationOrStyle`. Skip properties where |
| 110 | // the element has its own source so we never overwrite |
| 111 | // the author's per-element value with the cascaded one. |
| 112 | if ($element->getAttribute($property) !== null) { |
| 113 | continue; |
| 114 | } |
| 115 | if (!$values->has($property)) { |
| 116 | continue; |
| 117 | } |
| 118 | $value = $values->get($property); |
| 119 | if ($value === null) { |
| 120 | continue; |
| 121 | } |
| 122 | $declarations[] = $property . ': ' . $value->toCss(); |
| 123 | } |
| 124 | |
| 125 | if ($declarations !== []) { |
| 126 | $existing = $element->getAttribute('style') ?? ''; |
| 127 | $projection = '/* svg-cascade-projector */ ' |
| 128 | . implode('; ', $declarations); |
| 129 | $element->setAttribute( |
| 130 | 'style', |
| 131 | $existing === '' |
| 132 | ? $projection |
| 133 | : $existing . '; ' . $projection, |
| 134 | ); |
| 135 | } |
| 136 | |
| 137 | foreach ($element->children as $child) { |
| 138 | if ($child instanceof Element) { |
| 139 | $this->walk($child, $values); |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | /** |
| 145 | * Find the {@see SvgDocument} root for `$element` by walking |
| 146 | * up parent links. Falls back to null when the element is |
| 147 | * detached - we skip projection on those. |
| 148 | */ |
| 149 | private function findDocument(Element $element): ?SvgDocument |
| 150 | { |
| 151 | $node = $element; |
| 152 | while ($node !== null) { |
| 153 | if ($node instanceof SvgDocument) { |
| 154 | return $node; |
| 155 | } |
| 156 | $node = $node->parent ?? null; |
| 157 | } |
| 158 | return null; |
| 159 | } |
| 160 | } |