From ccd1f290971bf52d1f86ae66198de775df76d8be Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 4 Jul 2026 03:50:26 -0400 Subject: [PATCH] test/chore: compare snapshots by pixels; small resource and robustness fixes - C1: compare visual snapshots by decoded pixels instead of raw PNG bytes. PixelPNGImageSnapshotExtension decodes both sides and diffs all channels (alpha_only=False), so a Pillow/zlib re-encode of identical pixels no longer breaks every snapshot. The 12 text-bearing baselines are refreshed to the supported Pillow 12 rasterization (they were generated on an older Pillow and differed by ~1px); shape/QR baselines were already pixel-identical and are unchanged. Full suite now 409 passed / 0 failed (was 29 failed). - A12: media_loader now calls Image.load() for file and HTTP sources so the file handle is released and decoding happens up front, not mid-composite; warmup continues past a failing font size instead of breaking (and logs it); plot normalizes naive last_changed timestamps to UTC instead of raising TypeError on aware-vs-naive subtraction; the DataProvider protocol documents the timezone expectation. - B9: load_image warns once (not every frame) when it has to create a throwaway aiohttp session, nudging callers to pass a shared one. Adds unit tests for the pixel snapshot matcher, warmup continue-on-failure, the file-handle release, and the naive-timestamp plot path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UVpLN4Rzdv7y3ud7QzutVJ --- src/odl_renderer/elements/visualizations.py | 4 ++ src/odl_renderer/media_loader.py | 21 +++++++- src/odl_renderer/types.py | 3 +- src/odl_renderer/warmup.py | 8 ++- tests/conftest.py | 30 +++++++++++- tests/integration/test_plot.py | 19 ++++++++ tests/unit/test_media_loader.py | 12 +++++ tests/unit/test_snapshot_extension.py | 46 ++++++++++++++++++ tests/unit/test_warmup.py | 21 ++++++++ ...egression.test_gray_text_inline_markup.png | Bin 899 -> 918 bytes ...VisualRegression.test_dashboard_layout.png | Bin 1699 -> 1714 bytes ...isualRegression.test_plot_right_legend.png | Bin 1757 -> 1752 bytes ...ression.test_plot_with_axes_and_legend.png | Bin 3894 -> 4143 bytes ...stTextVisualRegression.test_basic_text.png | Bin 719 -> 739 bytes ...tTextVisualRegression.test_text_colors.png | Bin 1390 -> 1427 bytes ...xtVisualRegression.test_text_sizes[12].png | Bin 493 -> 490 bytes ...xtVisualRegression.test_text_sizes[16].png | Bin 534 -> 549 bytes ...xtVisualRegression.test_text_sizes[24].png | Bin 668 -> 679 bytes ...xtVisualRegression.test_text_sizes[32].png | Bin 760 -> 782 bytes ...formVisualRegression.test_rotated_text.png | Bin 605 -> 620 bytes ...ssion.test_rotated_text_pivot_left_top.png | Bin 1913 -> 2141 bytes 21 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test_snapshot_extension.py create mode 100644 tests/unit/test_warmup.py diff --git a/src/odl_renderer/elements/visualizations.py b/src/odl_renderer/elements/visualizations.py index a586176..6355e6c 100644 --- a/src/odl_renderer/elements/visualizations.py +++ b/src/odl_renderer/elements/visualizations.py @@ -191,6 +191,10 @@ def _process_entity_segments( try: value = float(state["state"]) * value_scale timestamp = datetime.fromisoformat(state["last_changed"]) + # Normalize naive timestamps to UTC so they can be subtracted from the + # timezone-aware plot range without raising TypeError. + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=timezone.utc) should_break = False if isinstance(span_gaps, (int, float)) and span_gaps is not True and span_gaps is not False: diff --git a/src/odl_renderer/media_loader.py b/src/odl_renderer/media_loader.py index 2cee83a..3307edd 100644 --- a/src/odl_renderer/media_loader.py +++ b/src/odl_renderer/media_loader.py @@ -12,6 +12,10 @@ _LOGGER = logging.getLogger(__name__) +# Warn only once about creating a throwaway HTTP session, so a dashboard that +# re-renders every update doesn't repeat the message on every frame. +_warned_sessionless = False + async def load_image( source: str | bytes | Image.Image, @@ -97,6 +101,15 @@ async def _load_from_http( response.raise_for_status() image_bytes = await response.read() else: + global _warned_sessionless + if not _warned_sessionless: + _warned_sessionless = True + _LOGGER.warning( + "load_image called without a session; creating a temporary aiohttp " + "ClientSession per request. Pass a shared session for connection " + "pooling and DNS caching." + ) + import aiohttp async with aiohttp.ClientSession() as temp_session: @@ -104,7 +117,9 @@ async def _load_from_http( response.raise_for_status() image_bytes = await response.read() - return Image.open(io.BytesIO(image_bytes)) + img = Image.open(io.BytesIO(image_bytes)) + img.load() # decode now, not later mid-composite + return img except Exception as err: raise ValueError(f"Failed to load image from {url}: {err}") from err @@ -123,7 +138,9 @@ def _load_from_file(file_path: str) -> Image.Image: ValueError: If the file doesn't exist or cannot be loaded """ try: - return Image.open(file_path) + img = Image.open(file_path) + img.load() # force decode now so the file handle is released (no ResourceWarning) + return img except FileNotFoundError: raise ValueError(f"Image file not found: {file_path}") except Exception as err: diff --git a/src/odl_renderer/types.py b/src/odl_renderer/types.py index b57e5e4..5d378f0 100644 --- a/src/odl_renderer/types.py +++ b/src/odl_renderer/types.py @@ -39,7 +39,8 @@ async def get_history( Mapping of entity_id to a list of state records, ordered oldest-first. Each record is a dict with at minimum: - "state": str — the state value (e.g. "23.5", "on") - - "last_changed": str — ISO 8601 timestamp + - "last_changed": str — ISO 8601 timestamp. Prefer a timezone-aware + value (with offset); a naive timestamp is interpreted as UTC. Entities with no data in the range may be absent or map to an empty list. """ ... diff --git a/src/odl_renderer/warmup.py b/src/odl_renderer/warmup.py index b5cf445..b5057a4 100644 --- a/src/odl_renderer/warmup.py +++ b/src/odl_renderer/warmup.py @@ -2,6 +2,10 @@ from __future__ import annotations +import logging + +_LOGGER = logging.getLogger(__name__) + _DEFAULT_SIZES = (16, 24, 32, 48, 64, 96) @@ -19,4 +23,6 @@ def warmup(sizes: tuple[int, ...] = _DEFAULT_SIZES) -> None: try: _get_mdi_font(size) except Exception: # noqa: BLE001 - break + # One bad size shouldn't skip pre-warming the rest. + _LOGGER.warning("Failed to pre-warm MDI font at size %s", size, exc_info=True) + continue diff --git a/tests/conftest.py b/tests/conftest.py index 004ec2e..0476d37 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,9 @@ +from io import BytesIO from pathlib import Path import imagehash import pytest -from PIL import Image, ImageFont +from PIL import Image, ImageChops, ImageFont, UnidentifiedImageError from syrupy.extensions.image import PNGImageSnapshotExtension # Get package root @@ -10,6 +11,31 @@ ASSETS_DIR = PACKAGE_ROOT / "src" / "odl_renderer" / "assets" +class PixelPNGImageSnapshotExtension(PNGImageSnapshotExtension): + """PNG snapshot extension that matches on decoded pixels, not encoded bytes. + + The default image extension compares raw PNG bytes, so a Pillow/zlib upgrade + that re-encodes pixel-identical output into different bytes breaks every + snapshot (observed on main under Pillow 12). Decoding both sides and comparing + pixels keeps snapshots stable across encoder changes while still catching any + real visual difference. + """ + + def matches(self, *, serialized_data: object, snapshot_data: object) -> bool: + try: + new_img = Image.open(BytesIO(bytes(serialized_data))) # type: ignore[arg-type] + old_img = Image.open(BytesIO(bytes(snapshot_data))) # type: ignore[arg-type] + except (UnidentifiedImageError, OSError, TypeError, ValueError): + # Not decodable as images — fall back to the byte comparison. + return bool(serialized_data == snapshot_data) + if new_img.size != old_img.size: + return False + diff = ImageChops.difference(new_img.convert("RGBA"), old_img.convert("RGBA")) + # alpha_only=False so a difference in any RGB channel counts — the default + # (alpha-only) would ignore color changes on fully-opaque e-paper renders. + return diff.getbbox(alpha_only=False) is None + + @pytest.fixture def ppb_font(): """Provide path to bundled ppb font.""" @@ -50,4 +76,4 @@ def _compare(img1: Image.Image, img2: Image.Image, threshold: int = 5): @pytest.fixture def snapshot_png(snapshot): - return snapshot.use_extension(PNGImageSnapshotExtension) + return snapshot.use_extension(PixelPNGImageSnapshotExtension) diff --git a/tests/integration/test_plot.py b/tests/integration/test_plot.py index 1439aa5..2d00a61 100644 --- a/tests/integration/test_plot.py +++ b/tests/integration/test_plot.py @@ -198,6 +198,25 @@ async def test_plot_multiple_entities(self): ) assert isinstance(image, Image.Image) + async def test_plot_naive_timestamps(self): + """Naive (tz-less) last_changed values are treated as UTC, not crash (A12).""" + end = datetime.now(timezone.utc) + start = end - timedelta(hours=24) + step = timedelta(hours=2) + naive_states = [ + # isoformat() on a naive datetime has no offset suffix. + {"state": str(20 + i), "last_changed": (start + step * i).replace(tzinfo=None).isoformat()} + for i in range(12) + ] + provider = MockDataProvider({"sensor.temp": naive_states}) + image = await generate_image( + width=296, + height=128, + elements=[{"type": "plot", "data": [{"entity": "sensor.temp"}]}], + data_provider=provider, + ) + assert isinstance(image, Image.Image) + async def test_plot_span_gaps_numeric(self): """Time-based gap detection (span_gaps as seconds) doesn't crash.""" gapped = TEMP_STATES[:8] + TEMP_STATES[16:] # introduce a gap in the middle diff --git a/tests/unit/test_media_loader.py b/tests/unit/test_media_loader.py index 6660b86..e377435 100644 --- a/tests/unit/test_media_loader.py +++ b/tests/unit/test_media_loader.py @@ -63,6 +63,18 @@ async def test_file_path(self): result = await load_image(path) assert isinstance(result, Image.Image) + async def test_file_path_releases_handle(self): + """The file is decoded eagerly and its handle released (finding A12).""" + png_bytes = _make_png_bytes() + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + f.write(png_bytes) + path = f.name + result = await load_image(path) + # After .load(), Pillow drops the file pointer it owned. + assert getattr(result, "fp", None) is None + # The image is usable without touching the file again. + assert result.convert("RGBA").size == result.size + async def test_nonexistent_file_raises(self): with pytest.raises(ValueError, match="not found"): await load_image("/nonexistent/path/image.png") diff --git a/tests/unit/test_snapshot_extension.py b/tests/unit/test_snapshot_extension.py new file mode 100644 index 0000000..35a0984 --- /dev/null +++ b/tests/unit/test_snapshot_extension.py @@ -0,0 +1,46 @@ +"""Unit tests for the pixel-comparing PNG snapshot extension (finding C1).""" + +from io import BytesIO + +from PIL import Image + +from tests.conftest import PixelPNGImageSnapshotExtension + + +def _png(img: Image.Image, **save_kwargs) -> bytes: + buffer = BytesIO() + img.save(buffer, format="PNG", **save_kwargs) + return buffer.getvalue() + + +def _matches(a: bytes, b: bytes) -> bool: + # matches() only needs `self` for the (unused here) byte fallback. + ext = object.__new__(PixelPNGImageSnapshotExtension) + return ext.matches(serialized_data=a, snapshot_data=b) + + +def test_same_pixels_different_bytes_match(): + """Different PNG encodings of identical pixels must compare equal.""" + img = Image.new("RGBA", (20, 12), (10, 20, 30, 255)) + img.putpixel((5, 5), (200, 100, 50, 255)) + a = _png(img, compress_level=0) + b = _png(img, compress_level=9) + assert a != b # the encoders really did produce different bytes + assert _matches(a, b) is True + + +def test_different_pixels_do_not_match(): + a = _png(Image.new("RGBA", (20, 12), (0, 0, 0, 255))) + b = _png(Image.new("RGBA", (20, 12), (255, 255, 255, 255))) + assert _matches(a, b) is False + + +def test_different_size_does_not_match(): + a = _png(Image.new("RGBA", (20, 12), (0, 0, 0, 255))) + b = _png(Image.new("RGBA", (24, 12), (0, 0, 0, 255))) + assert _matches(a, b) is False + + +def test_non_image_falls_back_to_bytes(): + assert _matches(b"not-a-png", b"not-a-png") is True + assert _matches(b"not-a-png", b"other") is False diff --git a/tests/unit/test_warmup.py b/tests/unit/test_warmup.py new file mode 100644 index 0000000..f48bb63 --- /dev/null +++ b/tests/unit/test_warmup.py @@ -0,0 +1,21 @@ +"""Unit tests for warmup (finding A12: one bad size must not skip the rest).""" + +from odl_renderer.warmup import warmup + + +def test_warmup_continues_past_a_failing_size(monkeypatch): + attempted = [] + + def fake_get_mdi_font(size): + attempted.append(size) + if size == 24: + raise OSError("simulated font load failure") + return object() + + # warmup imports _get_mdi_font from elements.icons inside the function. + monkeypatch.setattr("odl_renderer.elements.icons._get_mdi_font", fake_get_mdi_font) + + warmup(sizes=(16, 24, 32)) + + # All sizes attempted despite 24 failing — no early break. + assert attempted == [16, 24, 32] diff --git a/tests/visual/__snapshots__/test_color_rendering/TestGrayColorVisualRegression.test_gray_text_inline_markup.png b/tests/visual/__snapshots__/test_color_rendering/TestGrayColorVisualRegression.test_gray_text_inline_markup.png index b132040db87baf9e180b24ac0d4c4ea87ce8813f..4ce7def62899f727aaf3ae564ff3945ca904cd48 100644 GIT binary patch delta 760 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 delta 1573 zcmXw%dsxzU6vn+|X42ZGvzusHIrG{uZHZB=lmZjXZl;FITWaW{1krwCv9@Yj7NLnZ zlE7PFk4U5zRAywDCWa^`^n^%eNM&M5#;~>Tf9E{sIp6p5p0{xkb5Y@%O{qJ#!}nyB z>4plpyc#EqXX;_0$qSkSaX;ktfq5(XfLBT9^wAKcZrsVkcdUg=wy>O?7+YvDynqT= zfyuM_3@b^ab9(o#D%BR#_&Ec*2w?dla-&2Qg!9jDp`+bNoVH|4vg-q>?FmAqp(pgS z^qFPdNSnqw3k`kXz`p$uilkhGok$+MAbri$k}HT;a^+WIk%QeX-7?m`T_nUo#Y<$Y z>Na{bc!z*r7SCH6LOzXYh_5_P?if}gu7JGA|Ni-2>*>;V?4hK}NVd{}#TL*3o{*27rk#x22{i~r_i z>PWcc$(hgPt)IKV^@K{V%safdKY%LX8ejGJ(rZEXcv0bekyr6ev;dAG$M)(1G*f+J z#o`v;+XzrOoS*9@jF>5KA?0?KzCnU|=hg5~q6q7E1Fta!)r!~WS|IFee(!HdZdbcI z!v>Dgi;f3YQH4S%Y?zf>$jb&=x%P0QQ}EyfxO}YgK8mew&as+7l8Nz7B5|@kH-fd~(#5TQ1(zn`=aLRRs^H(o zYBgJ*gh8Ade%zxb^9Sc&o=@l9i!6;QSP2ZKp()DQPv!l^S(SMNLqIXC!kFX6OhO|v zj6Kn1P#eR5JQoX3HCSY7K$R;zxO|ECbc zr1Yi1F3|Yhd!`s=ywvoR3?564_wzpxgndU4({ZL3Wbjd>My{Mr^3o1~Ji(CKr)QDn z_8g<|S=9G+t1#fEA;@qoq$!aDN~t^>*h`#{;!T2%sy!q%N)_24qxQicb1vckOOh`kE*e49VlQmpD& z%g7NKu93J-n$d1`fM@X7DO9n{Ac4>n{fBavqMME6Z$M^a({GK2UNrM}+vu^IOx{QmZ$F4YTJ zQZS1a7`-1+_?0bB)HfZrmIH)0t0mt|;p1V?BPH#!FHV@RPM)^&p#dnqe_M`ZXlc9k zA#oV~>N9DH9c|`Ds-%Inc;Nz+t64?u+sHbwMnWr1N6WY*-#O38=0E!$$!zdxmj&FU z!n&uO37D-1Lf^-^q11q0s%ysM#XI?37tQyZV@u@&h|viYfK9q=-4O4%*z5{7MA$JT z=1%^~VP?<#ukXV~d`i-BKDw4L(o!oc?tbt1neEOHTRO VHI+dFGA+z+XGr*V?r(9Y{{`O~FA4wv diff --git a/tests/visual/__snapshots__/test_plot_rendering/TestPlotVisualRegression.test_plot_right_legend.png b/tests/visual/__snapshots__/test_plot_rendering/TestPlotVisualRegression.test_plot_right_legend.png index f82a585d43e16f6ca3cdebcfe4c782a6bf064460..19d923cc44f23df4d4d69dfd0aaeb5efa9813d79 100644 GIT binary patch delta 1736 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 diff --git a/tests/visual/__snapshots__/test_plot_rendering/TestPlotVisualRegression.test_plot_with_axes_and_legend.png b/tests/visual/__snapshots__/test_plot_rendering/TestPlotVisualRegression.test_plot_with_axes_and_legend.png index a0be38e133b66b5740e289be6e8401d822ab2e1b..3ef0cc0559dd76152fd1146575268e6a5f913301 100644 GIT binary patch literal 4143 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 Ef`9G0rvLx| diff --git a/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_basic_text.png b/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_basic_text.png index 77702bc499a86b13def2232ad5ab9b2f3bf85365..a826d6eb462f68b1f15cb6e4055f41b5580612f5 100644 GIT binary patch delta 525 zcmV+o0`mRO1>*&fBsHo@L_t(|obB4tZks?9MNw1r|9_c2RZ1*449o!6W-X}?jUB@P z8wQ+dp6B@mEc$cz3P5F%VRVte3V(m^UV%NPY5Mj5d7hhI_w>GxbC_a%7;#)nAcZVlEx1>SdyuQA5o-tHS<9TJ>GU)Io-wUW0e}8{^u5NX zx;2n=#{B=<$c?;ph90%dd7hibwx#8LBgWKwCm6&3E!=<>#(lqPo z+oX!FK+b$J@&uoLc71{pSAP%(%5P6Pxm4fxW$!`u-1Hy$?3SPP8uhTDakgtU;y_z^w0-vD?5pfu zkKR9VIn!-Ztkw-NYJ=@rUbUUHroxzdKezhRyIb!;(vCZ0r*3`b+AwQ(UTs>w|2Q(H zk$U#w)|bS0o91e3R8Zw90Ou&MbS|8|9{z4A4cj#a4^^e18YfrXqp5J zY_6S3Ddh#s`kH?k5D2Vr1d(BOk0%%Im#i*S@dzAN#gWvqjDGk3D1Rr8KO&wTHtVBbmy`b9b_< zoCff)yI3#^fmCqxc&;JtVvR}|ID(O(Hj&T=k|a z7~W3zOfT15*U>iD~a)5r)5aG=lb61F@@>PaC_ z;2@u^uzCYDjo14QVeg$nt2TJ7&-bwAtElM)`jWk8tCp?@@H%P_Yb#KDtFX0=b%2@Q z`L8)^0U8ONn89O}S``4u;O}9R0R>ZketHg`e*^uJ`S(aq&+#48z$Xr0VYNgJV8kgS zp=beT=o$&9md3lDQ06xJBtgJAi;ADkAz#{_^r`w?T%zA z#zVNH;b5)#%}`iv!Lvj?)*w*uY^U5MJZmJY)q|+;WS7`&x_d13|kb$GCPg0Xh&Xq3bX?yc17*Br=jiA{Ha7L&09 zHj^&}8-G8aoLpXI@%8%hWzxX$hF4f?v3kF^^*+Fepb&rJe2+ha8wMb20 zf1mSyt>qe9V`l5|^Uu)xbAazEwbtg}(7jmooV#|Ny;WIjAV;pf_A`KM7~aD==7&= zhLL14lg|Sfe}VnUyIyU{7NZ9=dB$hR*>PV!8&Vn+>0HMX?P^u z`o(WqvFxpKT31l-^F~8tbp<(wgSBQP088i0_ptWPYda#6z_mU0u?De%Jg3|xETvpN z2nnAN_}AC(624x`tFqp^eeXMmTXzs!_sM{3I%U(n#e9#d^?IY{ZJlqW1sP6RVYLR} ztWXFL+|%K~>W#(P@mtrawGN%Pc8t*wGL&jL0))84raM3jlW+nxlWzhxlWzhk7Wf0< W8ddaQ!K7{g0000lC|bbf zBjMD16x)l`()PV^uH_iuv5s3|ZG~hlW?LgiS** zfDHa0lRgDMlaK)vf8+C?Z=hc?{~qb-F}`COxYY`)Bx(S+83{!TxO^m>S{m<4L$x<1 zZf#$B52Gf$L)1n0wIB|!Th+ny#RN-`fX^8LIeSD~8xx10#zT0n=3uS)Mlh_l;GH5K zYY-^lw7Y~yk7mQUJb(&Ze$jotdMa5>P4^k^5V}(c&v(j2F86Po%kx<9KBug(_Qt`M zaq*0E0xu6%X*kyQ+d97G7~na&zW3`6WVrmIJ3uLsfESVlIe(1Le-7?`y2aP~AKx7h z+-QZh7OQdF)^>oe1*-)Yx zDps|}ubpdeWmXf(+H>#Ses+A;F1vC}vU54%g)vU)0ceQo>r9>dS2V{7-H zA0txml3<;&LVr7ZwKe|?YuB`Zd$`quqjwnxfqQCRVa>;^+6&=J<|~|%k+Eif;6*E} z(kLCZ@|m7zd>D1>q14*_ftOvws(l3Ei1Q!uJQnbRYgofCe`x?(LQ#9?uy%hS*D23o z)kLl4+(*3*yx>W9fGgeVu?B!Gf(677EFgwp0Wky%h?9;2DUoj*f1jG2yRO^aXZ}Uf z(__B2)_sYi_U97Ai( diff --git a/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[12].png b/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[12].png index 840830e7f297f72b8832443cc0a9235d28ca47b8..ce073a019921526308a43492df2e34a0bef94314 100644 GIT binary patch literal 490 zcmeAS@N?(olHy`uVBq!ia0vp^CxAGGgAGU?ZmZ{JU|`(n>EaktG3V_q$Gm0(5r=~T zU;l-dg-;YvT5rlY>*$$D8fx73cI$Ta7#?H3@%8I{dj`9i4hkGBP4p)YWj5-Z{uD9o z>6)hxlxxn{B}yNZc)Uga^DDda)pttsD`w47s1|-yncGwNvTbek;m~F3CmwJ7?-Jj> z>F=6-+xP9;Yqps!=HUg&Cv#U8mI%ezE=)KdqVSsc*X@NhyBBVs{d+F&{<&cVQ(tm! zx_d+PWd3~FO5aaeN9Qi%e_3B!_5QDD*4w++jW0J_%UCTrZ+GEaktG3V{A!0e^~iH3(V zzyH_&>|tx{*mo|=^4ezyam}UT&%a*IkV@|1-ta29)>uK{Mq-nL00&DWeTf6BnPx6~ z`J46s(dQTWb(kfRZ9dnO^;_EClYD-t+S0`F+Ooq%ANOTVd^eYSj*;WKJV71tCvX2= z`}cF-*1yq(vD@=?|5=I6`NU(y|L|Vt#}_OWPq#d(mE}sQ{CYq7!fDq%+ZN2EaktG3V{Ai+QshL>wGf z{QAFtPi@hxT?I_pmv?;X5z#FA@Tk3-rF(Oq;W4J1Umx$vGsMkwP~c!`qCauyytY{E z^{mY$w_htgToK%O<$~_%_l&h$ z8D*0He7xy;DDd+3i0zEGyVA1xH;G?=p8T!vw$!uD&&64c^J&>ceZ**>5+$ zzP?i~virO7R(8uzw|e%Lrd_Ll7PV)6=?sY*Pw(GVq*2%&(l>vxWcRjie>-6GGkCiC KxvXEaktG3V{AhgnSt5)Kb# zcK?qq`*K+D@#if;tJgknJXo-j_g-_|HIwt7Yna;Xj?HFlJRrJGL4bwHk=}%zKl_Yj zFMofpoY!x``sc1g+}YF(+z*Q)6ztjG%{*L|`@_9Hep`1-Ec41l>u9!jq3rMf?9=+!YMzTK{Fa9&{faX1Krm{j~EjZrfWvOfWq$?O^Qf?S1vHE!WL0e*Lk6zkID@-R8G( z*X`a{eEV3Te>o>8Ve0zb&H@}PjSkc&=3J{~^nSEtH;aX{4KThLJYD@<);T3K0Ra3~ B!Jz;E diff --git a/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[24].png b/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[24].png index df2fc8725ae8b87d188ab5cf3a6dcf24bcd1016a..8832177bcb7e712628ac5a8f27385a2f1fabd48c 100644 GIT binary patch literal 679 zcmeAS@N?(olHy`uVBq!ia0vp^CxAGGgAGU?ZmZ{JU|`Dgba4!+nDh4b#iCUP46cDM z@BIJ&^vlX}8TC061OyY;E-wq|$=$|sVd<10Iftv~m$NgZs|j$hFf}?T3=H+*{R#Ex zX;1&3Uphs3zEqpcHulQC#ow&wTuiK<5Oga1jpMaLwy!HH*DvMy_k6v@#Hi}6YuB&c zHaGv@?mLm+g|Z{30mZh@zdFtE{4W0cha=Y<|7BhGHa4E&`=wHzPga)m)C1ls*xa3$ z7rl9#&&<6`D(l|7-WaSj@Ab@W>_3l4S?fOUi+k1ic-FFcN3Dz|%`5+UZO6C1-P_7V z&X~U8dXg{nW^RJCGyQ% z)DBo*`!|19$=d6ReXpgXlK%R(T>CmHCP+4A&~J4St5 zuiw74?8Ls)TQ)~Tf74ogdrs+o-E%^k&kf!>J&fDD-FB00;fJj6nfD}@hr9c|w6kCK zd{ymviA#^S%~|t8mihT*QIEGw5wq@8tGT{!xx0CbclhIMpKrfs)Xm!dy>9V2BffRH zFE&TK|M$Y}yv4P7+IQKadFvCo-W~YvNWF+|if37L?_=M*yK%q-$l&Sf=d#Wzp$Pz# CLlLh4 literal 668 zcmeAS@N?(olHy`uVBq!ia0vp^CxAGGgAGU?ZmZ{JU|>r2ba4!+nDh4b&7j2&3=VOHm>d-ZSm;bVe1Bc| zZr=9)`TU1JoXO{XZ`*w5xZyXum@2lP^9=10*=BNmk3B#A_%2@Q_@BmnOLj))o-LNP zzNY>^XI<7;q1V4#*EgDc|FJu&=$!Zb>*{v($98s!?tgo{?y>Zr>zlIpKUEjp;a{-P zx575A-*;xzBALB&cK*M0zk0bPlhyRYX%oWNtk1Um-nDd|#kuDfR!5xgNjY1W@NQ3^ z#ruttK?dUdPtqCQ$R@b2`MO=_<}`y7>vOLPt^UT~u9x}jo5aRB3E_xX*^L$fg=T~EnXYbl`Bz2-6RUI)p(Q8-s-tpPBzuC%_(;aty zu~|0n^PR%8M&f(Awx)#LtU9yU?)_w?rzz%K->rV#i0)J8w7L4c&W5rB?;>wqz5By(TFmvrAF21Y&fk7@_Z;@;+g|2x zocD3LwDG6Ck%3l`&CaucoV5H3Rv$_c40#j$SZmx#T?ZyNsAA%s%}zUw`IpS@~y#@~me0uCxK|T|Z`z zbL=|AYQ0J|>o~sk_h)HcKOXNn&+Z$ATQ{qo_1Y+p#xXNAb{tdc3YI4S%^gA3{jf&CCG0!djV5)Z< zYbV85jA~FEb9H%|_jwR|&K|vQ2ClvDnKucYkl)8r8rSrZ4XO6db?>KndoJ^qUY|8; z$3UB*Hwt_FUJqw}whi;M>^j71-3YF*(d=2~vvvkYK8vw-pQP7Z(!JWCpSjF;TtB>O lJ_mts;&0L+k#RZ}{sIk_N8VY`=H02KWV#BssrHL_t(|obB7uZksRwhS8+j`@b?%FCuLb{4ro-o17!gv@zdPmA&P(63wlinm z!7Z+zjs!>N8RW=L*gC?^97KtKf4{BIZEF@W*vDWUq=J&cT;pKy`x>u*XSRh#Gf3(6 zMt{lC^3$o;(H(ts@YJ5o&b;2*cCO97VwmJE*62I0^@Z-;PZhsev)jC*44R`Cx}$7A zHwdKUc4ZG|X0h78)3@BsyPIk=NbMtU%L~!>*KTz>_~~_PU#HOdE>_77eXV`;7<45! z3iceaTMoijvxM2F^KIU}k4Ic}q$}C3k$(oy+0#D;KE>L5m#WtJ{GM51&(HUAr!61 zUgLs%KF?+6Ve-s?+Ov>X<7wVn+ghULd$l?^FxWz2jh{Q?%J(!V@9F1?!O(N?9NE^Z z4gzQ1i@`6_OHPGG+4)y)>r9^u4X#1poA{e_k&!wW9^3u`J$OgnM*)>p00000NkvXX Hu0mjfwOB9h diff --git a/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text.png b/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text.png index 7b8aeeb42312947be65942f84bc1adca67b4c401..378bd3b0c9caddb4aa3a654065b676adec92740d 100644 GIT binary patch literal 620 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#P1|%(0%q}r7Fd2KgIEGZrd3*Pu-(dq0)Ro7{7;6wD;v4xWRdwnEK3I>&+9Y`+f5-)fG2!hqldr$|7FjbfdSi zd7enwV5+1K^UkxXQs-26omf5HX|=cybNk%A$L{Xir?thq8iPe5( z5*AP9CTJ^3OPotCIPj(|>wV$g@0T8LY~E3WRZQwN5Z;(MP*T}Lk;a13u+Yh_fmNcqj1l(y;Iit{#=mv*)lH{}}? zA5ZKO=6TN2uB!J(UH#SpMgKjUH<(V(uwiDmDXoe&Hkfz(0aN;Hm3TGHV+RW2KD2+9 zh&i&_GvHng+q1Lj2|((br^bDbxAL=h{#-LlIOfUZ?ZI9~1&xa*tX2lP92jGKJ2uUX xs#INR%6$CHzSzSTzlVHCVxA*RS`5BpH z7rTZ;vd`Z-b@k+L=E8d}oGM74`5@zlcH`p2 z^*R?PADY+u`)lFqdQG2#noGVPoTkfu_V1TXuWxDNjC(NG?)edx{Elj~A6@Z0!Zs%t zTU=j%zv^GC<;TO%)wV`1daQGCc0AYHdkvg>ewx1f`g*rh^rHBZhK;W`I6v^5$GJ^; z(}BWy&E|P4@@(II%GaLX&l>k&cfxl*>#&V3AQygM`abQ!-TJSJAC&Irt|%+sm^dl@ z@mjl!Mh6R-{6C#hd1uitR6^z#|V==E6Kd&2)-=VIZs7vdJh#~bP0l+XkKQnnUO diff --git a/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text_pivot_left_top.png b/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text_pivot_left_top.png index ae22beb3cd30ebda4026a063cb005005bc2a4281..b4916470d68761dc27f11c21e29b16486162f3e5 100644 GIT binary patch literal 2141 zcmdT`i$BxrAODh3NDa|1B)K=kQgbVp+RCssManGeEVV9%a+zy~<9^Nk&S}RShMDUW z!ckFbGgHz?C6^^(Rt}2u?a%nVUZ3ZAUeD|GeBQ6;{dqsH_wz}{<4*6C-zN_Mz)p-4 z>MXd@evR$2;B0*bISc^X^)M($cUtK}DbY!lxVL|+*|sE3pLctPXvl`TrKBmCBK8V&C!ZyOSbw z<&@7sKr)@hQu1uL1OY0ms_31a;~$cgW%Q+5TU%v%T3TCLthCObAep_vQ4eFT!r$On zbR&#@QT=<$aZis|m7kxPKzKnH-!)%5bC$>&+fk081_vL7LMJ-I1m9@{LP;N=XJ047 zJH_HobcDhS7C8QsUUnAB$$E!=wD+7^a^2xUB$oMUHH_YTXsEtt4}Wp88|$mqK~huE z3JwmguCGrIKc=t$goZ`oRUyD`rNs1~OeUSfQFqBVL405P8C_}&0$+~MFWl$cIWlu~>BC83_~fG~-Gg+80}k==@m1u%&s2oalM#*85lgNh z5f9Ws=u}ix)NyZ9b$5?=CbPPx29fc=+muqA(AI{myVxuX>{gWJkB-W5ggdbU2NcEM zUyk%h-h@_a*Eiz~UUeky^Yc8IL?kd2hj+ORx=u(Uf={1LTwViV^J^WiUNyL0eCj@a zpf`U@BNZ25rCF7g1r_d4a2?E_>XXFLG~H#qFl zmQyR>^Bo}&NXy-==$yHzjqh$~bUfzk$o`ixXn0{#b!~0x=lS_9FPt{-Qb$O7adGk7 zyRHZn>Ieei=( z?bmNo(@aJgDJKs#lKW@$Kymx>Z3rAH1AIA#>^+UWUTp5h>LOfFZ1L>Xk#t!(Irz$} z5~QDVz^BF>RUaZ)9t$r5AuA_m8%exOx_J}!{Q2`+gRqoXUWGm3duB$4^zR~s9#jfA zf-PBO&&vJHW21(JLYMj1^(D(Scq3Ih$(#~_@xOH|&1>;lj$vSuRYFNATZTnJcLNdCGdHa!QVF6-p?Mk=r(*_C)3)yw_kwNeF0I9IBus7|2 zx}BHIDB?B_={zxo4sC`pla6%5?9Ylig~8vn`l zfTf(+pV^;Fv_-@z_aY^$tAe+!H+KMK$HKCmU0mQk&H~YwFO_*PPp^dY>oXx8>Cco6 z^-|v(1%-xcgd@vMkIIfmATKZM?rkQm-4cRnUx;Wzh8ALhwDn4khZ;^{%SuY!UCe{3vyWW=33 z+0z3wH8q*r*-`awF`4@+R6IXM%OiY0o5{=x^WlWK`OyZHZ(Hw604)PFKmMEA`8p#%*I`W5DL(G)UE`mU-x(59Xj%fe88cjIl+j<+|%9 zw`!)Y5LsF&^u5i5_0?RJnC<1dF%CdaCB?(T?d;iDu+q|nKdvWLs zs?pt(5)0)(9hN09TW__RC3emOuitUuUJ6Ad#Y(}M+ZkBwqy9YDi!()-QU^^mEl z0Y`{6HZ}&2s|H7o4-MVBcfr@xM)qMxN1$ZRO)%bC?j$vns3O3ObQ5Lz2NHK_2Q0mJ z{3SXjchl^ZiaBV_9m*C^r|dntpgU3g{LmpGZ3u5^gX!>N`=?rQ^o^YD<#O`!@=IUF zERNBZdIl=5JHH+u9**@Bw&{DD9w?Y=H$Qw>x?wamH&<10=T03<%hARQr~u6&&m`B1 nz7T2fTb@0G&|xK!X2AF%hkw9`pU literal 1913 zcmdT_`#Tei7vK8g)rgVYRlG4F%w3pEu2IS@HkV;QlX%GMaIAv#R>BP;^|2aP& zHyWb!#sGld%V4%5j-B1jUT-NzL^AmWXU6{uyw;c z4P{Gphua}8_*#Qg&C4BOmeS^V{IEs))Hv9tRjP@*JKUVc$-o>leX+2~qmou5%<5jZ zW-Dfmv{@4Sbq2HM0F=iA9;pRZ|N39iUp>ibz5j1`p3my*v!-Ic3N2ZJbt-5yDRu8Q zWm65G4%zLsIbryDo$~7HlgC+$q}r+~nhfog02erTJ0RrGmvHJxItsyehwkcd1F&VCkf`!->r8woBmO%N(B zE^gjveBZ)f$wcFDxbdAE%3_h;QBf@mG5-GkemLAT>C@;aGH+sX64VKFS)+M)cr^QG zWMnk8h6VOj5e}srybG^^lA;RLM;RuA>(8x1W;opSM;>X6Eiikv= zOdo1kcO%SuX<&0R!bPHPJ23EgnErlvm_F>d8>$zG#frlcehG$zgk-Y0s?n3Ah6eEd z{{DFgq+j`Ux^rwE+{5D}o6YWnD$b~gCv?_>VQltLq2JN|7Bt0TfW;~&6qLS2QR>NL zvGo0YT=>$c{K7$_e?MWPrltlzw_b~Ug@Vx1ZL-d$RaPoA#_p^t|J{mR5jDbo*W^6x z^?go$`LfU8*Tz@J{`uT<>l7p^I@+D@+6L6fNOh-B!$Ad~4Q19Oc3YCIf(s1}&i%u_PbzR{K9rf{W)X!11*vr$Qhj|& zdOC=vqOGAJ+-3o|RJEVAexkIrG;DD|%ds{Hy)@&xE6W-rnBc?5otrdOtW5s#C2|8uxeCfn>aee9_aV$**qJ52{bMwTX8h zY<#S8oaf$P*z#m83BzD8*gw4vrR*RRH_Hlh<%2)fET)eSR@K%39}(A%&yIKGMtLtXxz$JIj#BZA!BCIUAo#7FU4W>VlE?HDm1dfS`X}(;fb6w!y}NH0gJNuTV*r;hpJt|U%t+8q5@u<@va z2atJroSmJa_kfRqEiB&3vq8Q1zBz>4T}JaM5D2swK5-eERLSvs^)6-P2l0%mn;90X zcR~7kgc>+MOD}+$=Lu;j+Y28@wd*GGYJeSZ%6}^dA8fCndw|_;L1o!@0t25&m|qZu z4nmZaP(OPQrPJ`)qcVVSAz1^k?k|)0Lu&0vv39`S1wYRViK^x~Kml)nq}lwKT$OJQ zoSagh-PN*9&B&_vq$%V8zg8XtiD6xaNX#mn*GZ+ziP@Bo8Xjw6SrN-MJ4JhB6MZd1 zIv@}XsO$kb;ZG9DwnKpD_dB|tPUs2Q*D;e9KQF7guPfMfM6I2Th>Vna^2RV|_1m|k zT11PImwJy^#EC9?yhA8UW_z|HPC!QQZlX(f@@c^Ay+*`#Pm`Ru199BP&%wgC`!C;a avl3IDJngj;OuqjfE5Od`x@DaOGWlPUvBkvz