Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
87.50% |
28 / 32 |
|
50.00% |
2 / 4 |
CRAP | |
0.00% |
0 / 1 |
| CssBridge | |
87.50% |
28 / 32 |
|
50.00% |
2 / 4 |
12.28 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| computeStyle | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| collectAuthorSheets | |
77.78% |
7 / 9 |
|
0.00% |
0 / 1 |
4.18 | |||
| presentationAttributeSheet | |
84.62% |
11 / 13 |
|
0.00% |
0 / 1 |
6.13 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\Svg\Css; |
| 6 | |
| 7 | use Phpdftk\Css\Cascade\Cascade; |
| 8 | use Phpdftk\Css\Cascade\CascadedValues; |
| 9 | use Phpdftk\Css\Cascade\PropertyRegistry; |
| 10 | use Phpdftk\Css\Parser as CssParser; |
| 11 | use Phpdftk\Css\Selector\SelectorParser; |
| 12 | use Phpdftk\Css\Sheet\Declaration; |
| 13 | use Phpdftk\Css\Sheet\Origin; |
| 14 | use Phpdftk\Css\Sheet\StyleRule; |
| 15 | use Phpdftk\Css\Sheet\Stylesheet; |
| 16 | use Phpdftk\Css\ValueParser; |
| 17 | use Phpdftk\Svg\Element; |
| 18 | use Phpdftk\Svg\StyleElement; |
| 19 | use Phpdftk\Svg\SvgDocument; |
| 20 | |
| 21 | /** |
| 22 | * Bridge between an SVG element tree and the `phpdftk/css` cascade. |
| 23 | * |
| 24 | * `computeStyle()` resolves the effective CSS values for one element by |
| 25 | * layering, in increasing precedence: |
| 26 | * |
| 27 | * 1. SVG presentation attributes — author origin, specificity 0,0,0,0 |
| 28 | * per SVG 2 §6.7. Synthesised into a one-rule stylesheet with a `*` |
| 29 | * selector so it matches but doesn't out-rank anything. |
| 30 | * 2. `<style>` element CSS from the document — author origin, normal |
| 31 | * selector specificity per Selectors 4. |
| 32 | * 3. The element's inline `style=""` attribute — author origin, |
| 33 | * specificity `(1024, 0, 0)` per CSS Cascade 5 §6.4.4. The `Cascade` |
| 34 | * reads this directly from `MatchableElement::getAttributeValue` |
| 35 | * so we don't need a manual overlay. |
| 36 | * |
| 37 | * The bridge depends on `phpdftk/css`. It lives in its own namespace so |
| 38 | * loading `Phpdftk\Svg\Element` doesn't transitively pull the CSS |
| 39 | * dependency — callers who don't need the cascade keep working without |
| 40 | * `phpdftk/css` installed. |
| 41 | */ |
| 42 | final class CssBridge |
| 43 | { |
| 44 | /** |
| 45 | * The subset of SVG presentation attributes we synthesise into CSS |
| 46 | * declarations. Matches the typed accessors landed in 3E and 3F; the |
| 47 | * painter is free to extend the list when it adds support for more. |
| 48 | * |
| 49 | * @var list<string> |
| 50 | */ |
| 51 | private const array PRESENTATION_ATTRIBUTES = [ |
| 52 | 'fill', 'stroke', |
| 53 | 'fill-opacity', 'stroke-opacity', 'opacity', |
| 54 | 'fill-rule', |
| 55 | 'stroke-width', 'stroke-linecap', 'stroke-linejoin', |
| 56 | 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', |
| 57 | 'font-family', 'font-size', 'font-weight', 'font-style', |
| 58 | 'color', 'display', 'visibility', 'overflow', |
| 59 | // SVG 2 §11.6.2 — the marker family. Inherited, so a |
| 60 | // presentation attribute on a `<g>` has to enter the cascade |
| 61 | // to reach the shapes underneath it. |
| 62 | 'marker', 'marker-start', 'marker-mid', 'marker-end', |
| 63 | // SVG 2 §13.6 — `vector-effect`. |
| 64 | 'vector-effect', |
| 65 | // SVG 2 §10.1 geometry properties. Present so the cascade can |
| 66 | // do what only the cascade can: substitute `var()` out of a |
| 67 | // presentation attribute (§6.7 makes one an ordinary CSS |
| 68 | // declaration at specificity 0). The PAINTER still reads these |
| 69 | // off the attribute — see SvgCascadeProjector, which writes the |
| 70 | // substituted value back — so adding them here does not change |
| 71 | // which value wins for anything that has no `var()` in it. |
| 72 | 'x', 'y', 'width', 'height', 'cx', 'cy', 'r', 'rx', 'ry', |
| 73 | ]; |
| 74 | |
| 75 | /** |
| 76 | * The cascade the bridge resolves through. |
| 77 | * |
| 78 | * NOT `new Cascade()`: that builds a BARE `PropertyRegistry`, which |
| 79 | * is empty. The registry is what tells `Cascade::applyInheritance` |
| 80 | * which properties inherit and what each one's initial value is, so |
| 81 | * a cascade over an empty registry resolves every element in |
| 82 | * isolation and NOTHING inherits — a standalone |
| 83 | * `<svg><g fill="green"><rect/></g></svg>` gave its `<rect>` no |
| 84 | * `fill` at all and the painter fell back to black. |
| 85 | * |
| 86 | * `PropertyRegistry::default()` is a static call, which PHP does not |
| 87 | * accept as a parameter default, so the fallback lives in the body. |
| 88 | */ |
| 89 | private readonly Cascade $cascade; |
| 90 | |
| 91 | public function __construct( |
| 92 | ?Cascade $cascade = null, |
| 93 | private readonly CssParser $cssParser = new CssParser(), |
| 94 | private readonly ValueParser $valueParser = new ValueParser(), |
| 95 | ) { |
| 96 | $this->cascade = $cascade ?? new Cascade(PropertyRegistry::default()); |
| 97 | } |
| 98 | |
| 99 | /** |
| 100 | * Compute the cascade for `$element`. `$parentValues` is the |
| 101 | * already-computed result for the element's parent — pass `null` for |
| 102 | * the root. |
| 103 | */ |
| 104 | public function computeStyle( |
| 105 | Element $element, |
| 106 | SvgDocument $document, |
| 107 | ?CascadedValues $parentValues = null, |
| 108 | ): CascadedValues { |
| 109 | $sheets = [ |
| 110 | $this->presentationAttributeSheet($element), |
| 111 | ...$this->collectAuthorSheets($document), |
| 112 | ]; |
| 113 | return $this->cascade->computeFor( |
| 114 | $sheets, |
| 115 | new MatchableSvgElement($element), |
| 116 | $parentValues, |
| 117 | ); |
| 118 | } |
| 119 | |
| 120 | /** |
| 121 | * Walk the document and parse every `<style>` element's body into a |
| 122 | * Stylesheet. Returned in document order so later sheets shadow |
| 123 | * earlier ones on specificity ties, matching how browsers resolve |
| 124 | * multiple `<style>` blocks. |
| 125 | * |
| 126 | * @return list<Stylesheet> |
| 127 | */ |
| 128 | public function collectAuthorSheets(SvgDocument $document): array |
| 129 | { |
| 130 | $sheets = []; |
| 131 | foreach ($document->findByTag('style') as $node) { |
| 132 | if (!$node instanceof StyleElement) { |
| 133 | continue; |
| 134 | } |
| 135 | $css = $node->cssText(); |
| 136 | if (trim($css) === '') { |
| 137 | continue; |
| 138 | } |
| 139 | $sheets[] = $this->cssParser->parseStylesheet($css, Origin::Author); |
| 140 | } |
| 141 | return $sheets; |
| 142 | } |
| 143 | |
| 144 | /** |
| 145 | * Build a single-rule Stylesheet whose declarations are the |
| 146 | * presentation attributes carried by `$element`. The rule's selector |
| 147 | * is `*` so it matches the element (and would match every other |
| 148 | * element too, but `computeStyle` only ever passes this sheet for |
| 149 | * one specific element). |
| 150 | */ |
| 151 | public function presentationAttributeSheet(Element $element): Stylesheet |
| 152 | { |
| 153 | $declarations = []; |
| 154 | foreach (self::PRESENTATION_ATTRIBUTES as $name) { |
| 155 | $raw = $element->getAttribute($name); |
| 156 | if ($raw === null || trim($raw) === '') { |
| 157 | continue; |
| 158 | } |
| 159 | try { |
| 160 | $value = $this->valueParser->parseFromString($raw); |
| 161 | } catch (\Throwable) { |
| 162 | continue; |
| 163 | } |
| 164 | $declarations[] = new Declaration($name, $value, important: false); |
| 165 | } |
| 166 | if ($declarations === []) { |
| 167 | return new Stylesheet([], Origin::Author); |
| 168 | } |
| 169 | $rule = new StyleRule(SelectorParser::parse('*'), $declarations); |
| 170 | return new Stylesheet([$rule], Origin::Author); |
| 171 | } |
| 172 | } |