Skip to content

Let an embedded SVG keep the class it was styled by (mirrors mpdf/mpdf#1405) - #41

Merged
jakejackson1 merged 1 commit into
gravitypdffrom
mirror/1405-svg-class
Sep 7, 2026
Merged

Let an embedded SVG keep the class it was styled by (mirrors mpdf/mpdf#1405)#41
jakejackson1 merged 1 commit into
gravitypdffrom
mirror/1405-svg-class

Conversation

@jakejackson1

Copy link
Copy Markdown
Member

Summary

This mirrors mpdf/mpdf#1405, so the fork carries the fix while the upstream PR sits open. It closes mpdf/mpdf#1404.

AdjustHTML() writes an embedded SVG out to a file and replaces it with an <img> pointing at that file:

$file = $this->cache->write('/_tempSVG' . uniqid(random_int(1, 100000), true) . '_' . $i . '.svg', $svgi[0][$i]);
$html = str_replace($svgi[0][$i], '<img src="' . $file . '" />', $html);

Everything the svg tag carried is dropped on the way. A stylesheet written against .chart has nothing left to match, so the picture is drawn at whatever size the SVG asks for and there is no way to say anything about it. Styling img instead reaches every image in the document, which is the workaround the reporter was left with.

Read the class off the svg tag and put it on the img.

Try it

$mpdf = new \Mpdf\Mpdf();
$mpdf->WriteHTML(
    '<style>.framed { border: 1mm solid #d02020; padding: 2mm }</style>'
    . '<p><svg class="framed" width="100" height="100" viewBox="0 0 100 100">'
    . '<circle cx="50" cy="50" r="40" fill="#f0d878" /></svg></p>'
);
$mpdf->Output('try.pdf', 'F');

Before: the circle, with no frame — the rule matched nothing.
After: the circle inside a red frame.

Test plan

  • tests/Mpdf/SvgClassTest.php, eight cases. Six read the <img> that AdjustHTML() produced directly, which is narrower than rendering and says exactly what was carried over.
    • testTheClassIsCarriedOverToTheImg over four writings of the attribute — double quoted, single quoted, unquoted, and with spaces around the =. All four fail on gravitypdf, which produces an <img> with no class at all.
    • testAnSvgWithNoClassBecomesAnImgWithNone and testTheImgIsStillWrittenWhenTheClassIsEmpty — controls; no stray empty attribute either way.
    • testAClassOnSomethingInsideTheSvgIsNotTaken — a class on a circle inside the SVG belongs to the SVG reader and must not be lifted onto the image. Passes on both, and is what holds the pattern anchored to the opening tag.
    • testAStyleRuleWrittenForTheClassReachesTheImage — renders and asserts the red stroke is in the content stream. Fails on gravitypdf.
  • tests/Snapshots/SvgClassSnapshotTest.php draws the same circle five times: with no class, with a border and padding, with a width, with a width in a single-quoted attribute, and with three classes at once including a left margin. The document is tests/data/snapshots/svg-class.pdf. Rendered on gravitypdf it differs by 65,187 pixels — every one of them is drawn at its native size with nothing applied.
  • All twenty existing snapshot documents regenerated on this branch and on gravitypdf are byte-identical once the creation date and the random /ID are normalised, and the new one compares at zero differing pixels against a fresh render. The temporary SVG filename is random per run but does not reach the PDF, so two renders are byte-identical as well.
  • composer test — 1130 tests, 2650 assertions, up from 1122/2643.
  • composer cs clean. phpstan output identical to gravitypdf.
More info — reading the attribute, and the unquoted form

The pattern

private function svgClassAttribute($svg)
{
    $matches = [];
    if (!preg_match('/^<svg\b[^>]*?\sclass\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>"\']+))/si', $svg, $matches)) {
        return '';
    }

    // PCRE drops the groups after the one that took part
    $class = array_pop($matches);

    return str_replace('"', '', $class);
}

The three alternatives are the same three the fork's own attribute reader uses, from AdjustHTML():

preg_match_all('/([a-zA-Z][\w:.\-]*)(?:\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>"]+)))?/', $e, $contents, PREG_SET_ORDER);

^<svg\b[^>]*? cannot cross the >, so only the opening tag is looked at. A double quote is dropped from the value because no CSS class name can hold one and it would end the attribute early.

mpdf/mpdf#1405 reads it with one expression built around lookbehinds:

$re = '/<svg[^>]+class\s*=\s*["\']?((?:(?<=")(?:(?<=\\\\)"|[^"])*|(?<=\')(?:(?<=\\\\)\'|[^\'])*)|(?:(?!"|\')(?:(?!\/>|>|\s).)+))/i';

which its own test notes does not handle the unquoted form.

The unquoted form

An SVG written with unquoted attribute values is not well-formed XML, so the SVG reader cannot parse the file whatever class was lifted off it — with class=narrow in the document, Svg.php reports four undefined keys and Tag\Img then divides by zero. The class is read correctly, which testTheClassIsCarriedOverToTheImg covers, but the snapshot leaves that form out because there would be no picture to look at.

Only class

The issue asks for class, and that is all this carries. id and style are dropped the same way, but a selector is the thing there is no substitute for: img { … } reaches every image in the document, whereas an inline style on the svg tag could be moved onto a wrapper by hand.

AdjustHTML() writes an embedded SVG out to a file and replaces it with an img
element pointing at that file. Everything the svg tag carried is dropped on
the way, so a stylesheet written against `.chart` has nothing left to match
and the picture is drawn at whatever size the SVG asks for. There is no other
way to style one embedded SVG differently from the next: the img elements the
rest of the document uses all look the same to a selector.

Read the class off the svg tag and put it on the img. The value is read in
the three forms an attribute value may take, the way the rest of the parser
reads one, and a double quote is dropped from it because no class name can
hold one and it would end the attribute early.

Only the svg tag's own class is taken - a class on a shape inside it belongs
to the SVG reader and has nothing to do with the img element. All twenty
existing snapshots are unchanged.

An SVG written with unquoted attribute values is not well-formed XML, so the
SVG reader cannot draw it whatever class it carries; that form is covered in
the unit test but left out of the snapshot.

mpdf#1405 reads the value with a single expression built around
lookbehinds that does not cover the unquoted form.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@jakejackson1
jakejackson1 merged commit fa86725 into gravitypdf Sep 7, 2026
27 checks passed
@jakejackson1 jakejackson1 added bug Something isn't working create-upstream-pr labels Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working create-upstream-pr

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Retain class attribute of embedded svg element during conversion to img element

1 participant