From 92b16fd4d00f07e21dfedc28ef6ca60fd37a9cce Mon Sep 17 00:00:00 2001 From: gabriel Date: Thu, 25 Jun 2026 13:25:07 +0200 Subject: [PATCH 1/2] feat: add element rotation/mirror transforms and coerce string visible values Add generic rotation and mirror as standard fields supported on every element type, handled centrally so no element handler changes. Each transformed element is rendered onto its own layer, then mirrored/rotated about a pivot and composited back. - rotation: degrees, positive = clockwise (arbitrary values) - mirror: "h", "v", or "hv" - pivot: bbox anchor keyword (e.g. "tl") or [x, y] canvas coords; defaults to the element's visual center Distinct from the image-only `rotate` field (which rotates the source image before fitting it to the box). Also coerce string `visible` values so templated output works as expected: "false" (case-insensitive) and empty/whitespace strings hide the element; any other non-empty string shows it. Previously bool("false") was truthy, so a template rendering "false" wrongly showed the element. Docs and auto-generated rotation/mirror screenshots added. --- README.md | 48 +++++- docs/screenshots/mirror.png | Bin 0 -> 645 bytes docs/screenshots/rotation.png | Bin 0 -> 4468 bytes scripts/generate_screenshots.py | 5 +- src/odl_renderer/core.py | 63 +++++++- src/odl_renderer/transforms.py | 137 ++++++++++++++++++ tests/integration/test_transforms.py | 74 ++++++++++ tests/unit/test_transforms.py | 109 ++++++++++++++ tests/unit/test_visible.py | 49 +++++++ ...VisualRegression.test_mirrored_polygon.png | Bin 0 -> 403 bytes ...formVisualRegression.test_rotated_text.png | Bin 0 -> 605 bytes ...ssion.test_rotated_text_pivot_top_left.png | Bin 0 -> 1913 bytes tests/visual/test_transform_rendering.py | 69 +++++++++ 13 files changed, 549 insertions(+), 5 deletions(-) create mode 100644 docs/screenshots/mirror.png create mode 100644 docs/screenshots/rotation.png create mode 100644 src/odl_renderer/transforms.py create mode 100644 tests/integration/test_transforms.py create mode 100644 tests/unit/test_transforms.py create mode 100644 tests/unit/test_visible.py create mode 100644 tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_mirrored_polygon.png create mode 100644 tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text.png create mode 100644 tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text_pivot_top_left.png create mode 100644 tests/visual/test_transform_rendering.py diff --git a/README.md b/README.md index 821c89e..5075896 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ Used by `text`, `icon`, and `icon_sequence` to set which point of the element al ### The `visible` field -Every element type accepts an optional `visible` field. When `false`, the element is skipped entirely (no rendering, no position update). Defaults to `true`. +Every element type accepts an optional `visible` field. When falsy, the element is skipped entirely (no rendering, no position update). Defaults to `true`. ```yaml { @@ -174,6 +174,52 @@ Every element type accepts an optional `visible` field. When `false`, the elemen } ``` +String values are coerced, which matters when `visible` is produced by a Home Assistant template (templates render to strings). The string `"false"` (case-insensitive) and empty/whitespace-only strings hide the element; any other non-empty string shows it. So a template that renders `"false"` hides the element as expected — rendering down to an empty string is no longer required. + +### `rotation` + +Every element type accepts an optional `rotation` (degrees, **positive = clockwise**, arbitrary values allowed) and an optional `pivot` controlling the point it rotates about. The element is rendered, then rotated in place and composited back, so any element type works. + +| Field | Required | Default | Notes | +|------------|----------|------------------|------------------------------------------------------------------------------------| +| `rotation` | no | `0` | Degrees, positive = clockwise | +| `pivot` | no | `"mm"` (center) | Anchor keyword (`tl`, `mm`, `br`, …) relative to the element, **or** `[x, y]` canvas coords (percentages allowed, e.g. `["50%", "50%"]`) | + +> Not to be confused with the `image`/`dlimg`-only `rotate` field, which rotates the *source image before fitting it to the box*. Use `rotation` to tilt any rendered element on the canvas. + +```yaml +[ + {"type": "text", "value": "Rotated", "x": 148, "y": 64, "size": 30, "anchor": "mm", "rotation": 45}, + {"type": "text", "value": "Rotated", "x": 148, "y": 64, "size": 30, "anchor": "mm", "color": "gray"}, + {"type": "circle", "x": 148, "y": 64, "radius": 3, "fill": "red"} +] +``` + +Only the first element matters for usage — the gray "ghost" shows the un-rotated original and the red dot marks the pivot (`pivot` defaults to the element's center; set e.g. `"pivot": "tl"` or `"pivot": [148, 64]` to move it). + +![rotation example](https://raw.githubusercontent.com/OpenDisplay/odl-renderer/main/docs/screenshots/rotation.png) + +### `mirror` + +Every element type accepts an optional `mirror` to flip it about its `pivot`: `"h"` (horizontal), `"v"` (vertical), or `"hv"` (both). Unknown values are ignored. + +| Field | Required | Default | Notes | +|----------|----------|-----------------|------------------------------------------------| +| `mirror` | no | — | `"h"`, `"v"`, or `"hv"` (case-insensitive) | +| `pivot` | no | `"mm"` (center) | Same as `rotation`: anchor keyword or `[x, y]` | + +```yaml +[ + {"type": "polygon", "points": [[60, 30], [110, 30], [110, 45], [80, 45], [80, 98], [60, 98]], "fill": "red", "mirror": "h"}, + {"type": "polygon", "points": [[60, 30], [110, 30], [110, 45], [80, 45], [80, 98], [60, 98]], "outline": "gray", "width": 1}, + {"type": "line", "x_start": 85, "y_start": 20, "x_end": 85, "y_end": 108, "fill": "red", "width": 1} +] +``` + +The filled red shape is mirrored; the gray outline shows the original and the vertical red line marks the flip axis (the element's center by default). + +![mirror example](https://raw.githubusercontent.com/OpenDisplay/odl-renderer/main/docs/screenshots/mirror.png) + --- ## Text diff --git a/docs/screenshots/mirror.png b/docs/screenshots/mirror.png new file mode 100644 index 0000000000000000000000000000000000000000..7476a6b78d04605f4c90d56b3d0a9d4f71ab2802 GIT binary patch literal 645 zcmeAS@N?(olHy`uVBq!ia0y~yVAKM#n>g5jsORNlPw!NiI638&};^$>@70gRRCoV~}GWsevBm9yV^p8pMF zd3DN)ZI55Se*E3y-h1!s?OgqJSao4B?_Tn9^*KMQE_^o+=K1!7Z~_wNx7Z<$wnzv2rs*}fwuM99yd8ULv aNAp#t>UIeJxM~edUJRbDelF{r5}E+dBo1r< literal 0 HcmV?d00001 diff --git a/docs/screenshots/rotation.png b/docs/screenshots/rotation.png new file mode 100644 index 0000000000000000000000000000000000000000..6caa7df53acd5036fc1d9c7852bf168f0370776c GIT binary patch literal 4468 zcma)=cQ~8x_s3Cbh^o;f_DYiywA3h78bT^+MTps;QKLrbK*d&S)~wl}v13!CRl7!q zJ*sAvR*R|`e60S`KYssz|9P(ay07Os*SW9zIq%o|oM@bpE+Y(k`f!USscc+vH=P%6n1Mh4qkjQ zJ2~s~_SK=&!7=>P?}6s*0b9GE^}Qh7c)WBTn?x7MdOjB$ZjE89Nuf}LQ(vsbiiaoO z!IiK{eAcGI!Y#QXVz@~fU}qn>Z{2zlblu~JlA zgiZl#421nWxHK`!EEd{STKANM={y}F4No&${aiszK$^o1a3yGQ{NBvNgsB>b$fo^; zSmGgLTlV|Wbof)EOPDksfk3RZ?9Gkt`L0bB-h2J+G=0<<^`^)EYou6EMv@x>K3mOU z*p6}S%eWkm4ZSzpL`%4SfF+Wb3kQI4(~PVYeOkiznTvy%!L+oCHl<-59kix2S!NS!yNIpww1@m!z6mY%2!o1;-vTBfb1+Lq4i{7hbek&jAlAs)pk;9 zUU_SI3T5BUv{uEE8@QWk-SmjI`Z)J`;Mh|Ci@;`A^pW3;%dv7htlGtKhdnE8afY#{quF9azdWO(mhB~(i8 z@40e$)^J|lJ5pxQ;aASaA{j^$x|%VSSa8oRr{{nI%felGv#lGWubyw$@FoH@f;2RI{yAlMO_=q z>bhIo0bFiziQ?^^l@U8@(-vRzCaZ62$0;Lj15+*-K6n;^?O69-%5*O674T=g}!V%-nE%Yy{^;vuCr6sK!CkQKyCCCZ4n>rbW3Ms{=Aw zg<+TY_i2eTQl>rG*L?Bi|Ef$D*vWz#%~E!wjBDOc{z_i0sH}-REAk-PO#R!rOpSe$)V?8PFozaK$}9yrpE_#QwzJmdY`Bl&@{I6Wv@u=x2sZrNkv1= zM_Zp(ehuf7>Bc<{fW+a|t{W~UfHURaD{LB#Bn4jtY;*u!=bHTvTBFR$%q!}!oB|3u zC@7BtrY|5)Y8Su(g}0nGw-?WR3MDYTSt%{s=`S!XHi|dqNITcX8YIbstb$JpHD!YVE2U;@x+`+NQaKN>&cw|y2rve?})04Nd!~W znY_NxWj4OyP3iRq^X<$Qb1&HlL}Ip^upnk$o%q&=kQ=ZSJs<-9se$)p`vCrh)<$dI zy^9>Xkerf|-^fsd%0)VzPPQQsAjnIXK0EYHZ+v@4Tx|6GDtdRe$-AJsS{`q14*k&I z@BJ)S{x~9+Fn9`g1gFfN`D7@m{A@ zXIpeu;uHsPqs7L)qWR5zgQ&lhq0qUu;9%u@Gq07}1kt41_y_MxN=k;(xLwOQ*t8Qh zjZ}y7SMgc}+y>;e`iZ7pJKtIFmF+>)-qE#RA=@X1>ux;oE@Ml@7J+Djl2@XGG{12{ z>(Q3J@}plv8Ox&KXFDTZ3)nOfU7k{A5>BHY<|-DSm?zONBfxASfPt}^xmJ@@s;s1 zZ}M4*AT2746H3#1a(WpZ7|SQ;vldwot?K5JSPogM&EgrEk+A*l2TST)5tUWgFWLxD zdd8&a)b|pb6xU8bC7Tt@uPRvVJ>?RstSFnX3vQWg@gF&VdGdPlbr%!IsGguC#s9j~ z+vxj?3D*vt1}Fj+1?^MyZW?ZH-;wm|M>zo@@G$0FXqcEs!{79HN&IlY-{U=U?3B5! z_bj^}5G)=0AUpdZ;y)^Rqi(6Gok<3%x4NVwz}F0h3`-qzsavO47sj7Dgu5{8Be^gaDj+|n-=5*Y<&oK zxPObX6_otDUU^5uC5jE7SPKj^V}=WrMY;5ku@v#njPSzU>T#A;=8Dbw? zilSsHrhshnR$a=!BFVu`bAv@Wu}UJ=W>&kpnT?MZoB#9*jb_~6tXR zkr!#H>{Y@SxchlxoxY99>9c+VO2TRb{(Iov0pRNrk<8Alw>*}_MSlnKtR^?dorFc- zya3z`JiiNH9qC&AQ}6;})0ESJ8eA{p~?9D{ELkBtNpK>2|OJHz$U>urC7 z2AM{mUb$NT;}~|LN&To`2r1MdrJog-X0T%{@Pu3c2i^B`g-tQwA_`>}c(mC&P@_X- zFR7C!b+Iwv0P#J;VlaFnW1D59fH&0p*u?_4E6h0ZP1A*#qkSrMvF~Ez%z~f$`$UP- z=H^>`^XHPgjj1=Ky5x9D`n-N-`(F8VfRDc9{21-^6646coUu~49ent>6IioVTZ-39 z@3{KUTpSZNL*y^rdM&@CcGLhH4ZJk+$i>9UKrJ{f$ea%gc;q(s`hNcQ>l&nr0rjpe zgCi0|_6Azqo__hle`!O_;C@3lSJzvI$6%QY2%r!s^#{B*TyO!rTc*I&lW%7aOH!~n zWjKUo`7hkI0A@~8>lN}BO(w#^3qCTAex8A+8c&oA5sL6maKtjYF^6p`>w6 zU&2$J4Wd1glnll3d~4nw*rxpHkc4JjFq`7Q1LVUqD_x+q*TKr$<|l? zd-ujGY*YpvY+(?UNO|62twP!^C%glYi^^W^vbgl-J8!Y6p#z4&)e$6cT08RW=BR5C zt|ZFy<;GlY@Cj}Ge6F$Pd`UVs6Cx=3c%IpN;Ob9Rgj4m}7%jyX62RboMTIlwDD=AY zf>3psxS4ad_vf~Y3D$K(dr5J^9=AgS4|i#uooHo;o-_15#X(i&nho`3At`&c=w|dT z=NS~`fX6P>f!K6W+ufdU`v5AE0#Ezr-|9o6vCL7Xfq^Yh*_ukPCm2BSTQSFH(Scqo zx&fH*F3N!h!+%&m&s(*nBATxbutFj6db2V#FsLT%T~4?V2(k$==*J*^gHpG^`1m$A-3SF7}p9 zzH)w>bN+h8KNrTZqpL`GQPB2MzbV7&c=v>y6;V4{?cJ~HxyCMbbVu(-H)zItf@tj( z{x$foMp2NOEZkskK2DB-O2qB(+FAwgNyqf7mkIxLUnUgZj4Q!1KA{g6_B=Nj*Lm5u z!5Sq{j;s#;8?+Xy(CykAFEZtfNZJ4Z07r1`GZ8U&z+Gr|7>;)gnQ$2rp3%YLHr-G$ z%F03_SXt4;^v|S42^9x^dy2*=97ZS}VPOVeoQ)3Sg|Fr{o&uhY<0+eK@n5BMw)pQg zl`AN{6#o{1#?(Hw)MjYl1H;3qy1R=rVoENFmx5(U04?6KoBSH&I%y>G!x!(AK1N?- zrCnaq9m`ktXZG#2f%{59@{8q_&09C=iwP(-66Bes{U)ipZyCXU!2ly^1(5NNNdORG?oq7YJSY@BrFD(GQ}XF2{a1d%$|0K_(|YrE{8_6I)!UfOKX2EK z-1N!Gei^H}unj zHT4w_!uLy<=n0Vc%5^d25E)C|>V}Zzwy=lCTc6}6v|j3`UmDL&Q}LK8uwXTeY*PKz zj}&7)+u5VDWZzM6hvbNpYRyQVkc=ukC2_bAE4QO0pMzyqEWDBPLRN*_On;xQS zpJ!C=qj6t3ByzZSsDwp%K3b#NNsV3hpV23vTSCM*J_{7ZO9`F&1d@rykMMsxmW3smJleM(8bc~s~ zc@L%c_q`|6TR%S0Po_QFW=B_CM~j}F2=yk24SsP$%*}7x3qxuIk=nv7f|3w$-xpe5 zUS0-o@4`{XYO1d96`2bac9qR)H(&6$UTG)C)kC8e6R$0SKpLV}e|xBa7NkvAJS zV#8jI{~6MYaS5$+B*ezSYL%}!B;TsqJ~Vz7wyPN%!wTX_e;3Jht&IG|cVk{lgqkCz zcKoHrfy@!AXaP2q!uz19uu$}fJC5Z>IRZ%^X$|z3RGa1a4rW>A;pfDBw3RZ0Lotrtw=ocgm0h0-^AX>k-t=S8(03Zm$4)`S$g_mO+3eRtDXt?t2ag15DJ)M;ol$c^+ zYML)2W$!;~sM1{_gGmBih%=C+G6A;R3-nUP7~P0^FC#36#W7YYQFHLJLr+&-0uW5I ze@8&^9*k=;0#OyS!>2mts_aliz->miG1)3S!pcZ|ti2{aRg>5K-%`BjX5Oft7Zt~Y zE|0u#7M`ftzQOuG&ldiF3ei!boBD51!JQfs%ygi9@M^YhC7m%%Vba4G=~SZa!~PFc C)@{oG literal 0 HcmV?d00001 diff --git a/scripts/generate_screenshots.py b/scripts/generate_screenshots.py index ee96da6..22a2844 100644 --- a/scripts/generate_screenshots.py +++ b/scripts/generate_screenshots.py @@ -83,10 +83,13 @@ def parse_element_examples(readme: str) -> dict[str, dict]: async def render_element(name: str, config: dict, session: aiohttp.ClientSession) -> None: """Render a single element config to a PNG file.""" + # An example may be a single element dict or a list of elements (used by the + # annotated rotation/mirror demos, where helper elements illustrate the pivot). + elements = config if isinstance(config, list) else [config] image = await generate_image( width=CANVAS_WIDTH, height=CANVAS_HEIGHT, - elements=[config], + elements=elements, background="white", session=session, data_provider=MockDataProvider(), diff --git a/src/odl_renderer/core.py b/src/odl_renderer/core.py index 2555f16..20bc71c 100644 --- a/src/odl_renderer/core.py +++ b/src/odl_renderer/core.py @@ -13,6 +13,7 @@ from .elements import debug, icons, media, shapes, text, visualizations # noqa: F401 from .fonts import FontManager from .registry import get_all_handlers +from .transforms import apply_transform, has_transform from .types import DataProvider, DrawingContext, ElementType _LOGGER = logging.getLogger(__name__) @@ -84,7 +85,7 @@ async def generate_image( # Process each element for i, element in enumerate(elements): # Skip hidden elements - if not element.get("visible", True): + if not _coerce_visible(element.get("visible", True)): continue try: @@ -96,7 +97,10 @@ async def generate_image( # Get the appropriate handler and call it handler = draw_handlers.get(element_type) if handler: - await handler(ctx, element) + if has_transform(element): + await _render_transformed(ctx, handler, element) + else: + await handler(ctx, element) else: error_msg = f"No handler found for element type: {element_type}" _LOGGER.warning(error_msg) @@ -115,6 +119,59 @@ async def generate_image( return img +async def _render_transformed(ctx: DrawingContext, handler: Any, element: dict[str, Any]) -> None: + """Render an element onto its own layer, transform it, then composite back. + + The element is drawn onto a transparent full-canvas layer by temporarily + pointing the context at it, so the handler is used unchanged. The layer is + then rotated/mirrored and alpha-composited onto the base image. Mutating + ``ctx.img`` in place preserves any ``ctx.pos_y`` flow updates the handler + makes (e.g. text auto-flow). + """ + base = ctx.img + layer = Image.new("RGBA", base.size, (0, 0, 0, 0)) + ctx.img = layer + try: + await handler(ctx, element) + finally: + ctx.img = base + + layer = apply_transform( + layer, + rotation=element.get("rotation"), + mirror=element.get("mirror"), + pivot=element.get("pivot"), + coords=ctx.coords, + ) + base.alpha_composite(layer) + + +_FALSY_VISIBLE_STRINGS = frozenset({"false", ""}) + + +def _coerce_visible(value: Any) -> bool: + """Coerce a `visible` field value to a boolean. + + Lenient by design: Home Assistant templates render to strings, so values + like "false"/"False" must read as hidden even though any non-empty string + is normally truthy. Whitespace-only strings fold to False, preserving the + "render an empty string to hide" workaround. + + Args: + value: Raw `visible` value (bool, str, number, or other). + + Returns: + bool: True if the element should be shown, False if hidden. + """ + if isinstance(value, str): + return value.strip().lower() not in _FALSY_VISIBLE_STRINGS + if value is None: + return False + if isinstance(value, bool): + return value + return bool(value) + + def should_show_element(element: dict[str, Any]) -> bool: """Check if an element should be displayed. @@ -127,4 +184,4 @@ def should_show_element(element: dict[str, Any]) -> bool: Returns: bool: True if the element should be displayed, False otherwise """ - return bool(element.get("visible", True)) + return _coerce_visible(element.get("visible", True)) diff --git a/src/odl_renderer/transforms.py b/src/odl_renderer/transforms.py new file mode 100644 index 0000000..a70639b --- /dev/null +++ b/src/odl_renderer/transforms.py @@ -0,0 +1,137 @@ +"""Generic per-element transforms (rotation, mirror) applied centrally. + +An element is rendered onto its own transparent full-canvas layer, then the +layer is transformed and composited back onto the base image. This keeps every +element handler unchanged: any element type gains `rotation`/`mirror` support +for free. + +Both transforms act around a pivot point. By default the pivot is the element's +rendered visual center (its bounding box center), so the element rotates/mirrors +in place. The pivot can be overridden per element. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from PIL import Image + +if TYPE_CHECKING: + from .coordinates import CoordinateParser + +# Horizontal/vertical pivot components resolved against a bbox (left, top, right, bottom). +_H_COMPONENTS = frozenset("lmr") +_V_COMPONENTS = frozenset("tmb") + + +def has_transform(element: dict[str, Any]) -> bool: + """Return True if an element requests a rotation or mirror transform.""" + return bool(element.get("rotation")) or bool(element.get("mirror")) + + +def apply_transform( + layer: Image.Image, + *, + rotation: float | int | None = None, + mirror: str | None = None, + pivot: Any = None, + coords: CoordinateParser | None = None, +) -> Image.Image: + """Apply mirror then rotation to a transparent element layer. + + Args: + layer: Transparent RGBA layer containing a single rendered element. + rotation: Degrees, positive = clockwise. None/0 skips rotation. + mirror: "h", "v", or "hv" (case-insensitive). None/"" skips mirror. + pivot: Optional pivot. Either an anchor keyword (e.g. "tl", "mm", "br") + resolved against the element bbox, or an [x, y] canvas-coordinate + pair parsed via ``coords``. Defaults to the bbox center. + coords: CoordinateParser, required only to resolve an [x, y] pivot. + + Returns: + The transformed layer (same size as the input). If the layer is empty + (nothing was drawn), it is returned unchanged. + """ + bbox = layer.getbbox() + if bbox is None: + # Nothing was drawn — no transform to apply, nothing to composite. + return layer + + px, py = _resolve_pivot(pivot, bbox, coords) + + if mirror: + layer = _apply_mirror(layer, mirror, px, py) + + if rotation: + # PIL rotates counter-clockwise; negate so positive = clockwise. + layer = layer.rotate( + -float(rotation), + resample=Image.Resampling.BICUBIC, + center=(px, py), + expand=False, + ) + + return layer + + +def _apply_mirror(layer: Image.Image, mirror: str, px: float, py: float) -> Image.Image: + """Mirror a layer about the vertical line x=px and/or horizontal line y=py. + + Uses an affine transform so the flip happens about an arbitrary pivot axis + rather than the image center. Unknown mirror codes are ignored (lenient). + """ + code = mirror.strip().lower() + flip_h = "h" in code + flip_v = "v" in code + if not (flip_h or flip_v): + return layer + + # Affine maps output (x, y) -> input (a*x + b*y + c, d*x + e*y + f). + # Horizontal flip about x=px: input_x = 2*px - x. Vertical about y=py: input_y = 2*py - y. + a = -1.0 if flip_h else 1.0 + c = 2.0 * px if flip_h else 0.0 + e = -1.0 if flip_v else 1.0 + f = 2.0 * py if flip_v else 0.0 + + return layer.transform( + layer.size, + Image.Transform.AFFINE, + (a, 0.0, c, 0.0, e, f), + resample=Image.Resampling.NEAREST, + fillcolor=(0, 0, 0, 0), + ) + + +def _resolve_pivot( + pivot: Any, + bbox: tuple[int, int, int, int], + coords: CoordinateParser | None, +) -> tuple[float, float]: + """Resolve a pivot spec to absolute canvas coordinates. + + An [x, y] pair is parsed as canvas coordinates (percentages supported); a + string is treated as a bbox-relative anchor keyword. Anything else (or a + missing ``coords`` for the coordinate form) falls back to the bbox center. + """ + if isinstance(pivot, (list, tuple)) and len(pivot) == 2 and coords is not None: + return float(coords.parse_x(pivot[0])), float(coords.parse_y(pivot[1])) + + key = pivot if isinstance(pivot, str) else "mm" + return _anchor_point(key, bbox) + + +def _anchor_point(key: str, bbox: tuple[int, int, int, int]) -> tuple[float, float]: + """Resolve an anchor keyword (e.g. "tl", "mm", "br") to a bbox point. + + Order-independent and lenient: the first horizontal char in {l, m, r} and + the first vertical char in {t, m, b} are used; missing components default to + the middle (center). + """ + left, top, right, bottom = bbox + x_by = {"l": left, "m": (left + right) / 2, "r": right} + y_by = {"t": top, "m": (top + bottom) / 2, "b": bottom} + + lowered = key.strip().lower() + h = next((ch for ch in lowered if ch in _H_COMPONENTS), "m") + v = next((ch for ch in lowered if ch in _V_COMPONENTS), "m") + return float(x_by[h]), float(y_by[v]) diff --git a/tests/integration/test_transforms.py b/tests/integration/test_transforms.py new file mode 100644 index 0000000..d38d72a --- /dev/null +++ b/tests/integration/test_transforms.py @@ -0,0 +1,74 @@ +"""End-to-end tests for generic rotation/mirror transforms via generate_image.""" + +import pytest +from PIL import ImageChops + +from odl_renderer import generate_image + + +def _ink_bbox(image): + """Bounding box of non-background (non-white) pixels.""" + from PIL import Image + + bg = Image.new("RGB", image.size, "white") + return ImageChops.difference(image.convert("RGB"), bg).getbbox() + + +async def _render(element, size=(120, 120)): + return await generate_image(size[0], size[1], [element], background="white") + + +@pytest.mark.asyncio +class TestVisibleStrings: + async def test_string_false_hides(self): + shown = await _render({"type": "text", "value": "Hi", "x": 10, "y": 10, "size": 24}) + hidden = await _render({"type": "text", "value": "Hi", "x": 10, "y": 10, "size": 24, "visible": "false"}) + assert _ink_bbox(shown) is not None + assert _ink_bbox(hidden) is None + + async def test_string_true_shows(self): + image = await _render({"type": "text", "value": "Hi", "x": 10, "y": 10, "size": 24, "visible": "true"}) + assert _ink_bbox(image) is not None + + +@pytest.mark.asyncio +class TestRotation: + async def test_rotation_changes_layout(self): + base = {"type": "text", "value": "Fg", "x": 40, "y": 45, "size": 40} + plain = await _render(base) + rotated = await _render({**base, "rotation": 90}) + assert _ink_bbox(plain) != _ink_bbox(rotated) + + async def test_rotation_zero_is_noop(self): + base = {"type": "text", "value": "Fg", "x": 40, "y": 45, "size": 40} + plain = await _render(base) + rot0 = await _render({**base, "rotation": 0}) + assert ImageChops.difference(plain.convert("RGB"), rot0.convert("RGB")).getbbox() is None + + async def test_pivot_changes_result(self): + base = {"type": "text", "value": "Fg", "x": 40, "y": 45, "size": 40, "rotation": 90} + center = await _render(base) + corner = await _render({**base, "pivot": "tl"}) + assert ImageChops.difference(center.convert("RGB"), corner.convert("RGB")).getbbox() is not None + + async def test_coordinate_pivot_renders(self): + image = await _render( + {"type": "text", "value": "Fg", "x": 40, "y": 45, "size": 40, "rotation": 90, "pivot": ["50%", "50%"]} + ) + assert image.size == (120, 120) + + +@pytest.mark.asyncio +class TestMirror: + async def test_horizontal_mirror_flips_content(self): + base = {"type": "text", "value": "Rq", "x": 40, "y": 45, "size": 40} + plain = await _render(base) + mirrored = await _render({**base, "mirror": "h"}) + # Bounding box is preserved (flip about its own center) but content differs. + assert ImageChops.difference(plain.convert("RGB"), mirrored.convert("RGB")).getbbox() is not None + + async def test_unknown_mirror_is_noop(self): + base = {"type": "text", "value": "Rq", "x": 40, "y": 45, "size": 40} + plain = await _render(base) + weird = await _render({**base, "mirror": "diagonal"}) + assert ImageChops.difference(plain.convert("RGB"), weird.convert("RGB")).getbbox() is None diff --git a/tests/unit/test_transforms.py b/tests/unit/test_transforms.py new file mode 100644 index 0000000..379fb6d --- /dev/null +++ b/tests/unit/test_transforms.py @@ -0,0 +1,109 @@ +"""Unit tests for the generic transform module.""" + +from PIL import Image, ImageChops + +from odl_renderer.coordinates import CoordinateParser +from odl_renderer.transforms import ( + _anchor_point, + _resolve_pivot, + apply_transform, + has_transform, +) + + +def _layer_with_box(size=(100, 100), box=(10, 10, 40, 30), color=(0, 0, 0, 255)): + """Transparent RGBA layer with one opaque rectangle drawn on it.""" + layer = Image.new("RGBA", size, (0, 0, 0, 0)) + from PIL import ImageDraw + + ImageDraw.Draw(layer).rectangle(box, fill=color) + return layer + + +class TestHasTransform: + def test_none(self): + assert has_transform({"type": "text"}) is False + + def test_zero_rotation_is_noop(self): + assert has_transform({"rotation": 0}) is False + + def test_rotation(self): + assert has_transform({"rotation": 90}) is True + + def test_mirror(self): + assert has_transform({"mirror": "h"}) is True + + +class TestAnchorPoint: + def test_center_default(self): + assert _anchor_point("mm", (10, 20, 30, 40)) == (20.0, 30.0) + + def test_top_left(self): + assert _anchor_point("tl", (10, 20, 30, 40)) == (10.0, 20.0) + + def test_bottom_right(self): + assert _anchor_point("br", (10, 20, 30, 40)) == (30.0, 40.0) + + def test_order_independent(self): + assert _anchor_point("lt", (10, 20, 30, 40)) == _anchor_point("tl", (10, 20, 30, 40)) + + def test_invalid_falls_back_to_center(self): + assert _anchor_point("??", (10, 20, 30, 40)) == (20.0, 30.0) + + +class TestResolvePivot: + def test_default_is_bbox_center(self): + assert _resolve_pivot(None, (0, 0, 100, 50), None) == (50.0, 25.0) + + def test_anchor_keyword(self): + assert _resolve_pivot("tl", (0, 0, 100, 50), None) == (0.0, 0.0) + + def test_coord_pair_pixels(self): + coords = CoordinateParser(200, 100) + assert _resolve_pivot([20, 30], (0, 0, 10, 10), coords) == (20.0, 30.0) + + def test_coord_pair_percent(self): + coords = CoordinateParser(200, 100) + assert _resolve_pivot(["50%", "50%"], (0, 0, 10, 10), coords) == (100.0, 50.0) + + +class TestApplyTransform: + def test_empty_layer_unchanged(self): + layer = Image.new("RGBA", (50, 50), (0, 0, 0, 0)) + out = apply_transform(layer, rotation=45) + assert out.getbbox() is None + + def test_rotation_changes_pixels(self): + layer = _layer_with_box(box=(10, 10, 60, 20)) # wide bar + rotated = apply_transform(layer, rotation=90) + assert ImageChops.difference(layer, rotated).getbbox() is not None + + def test_180_rotation_is_involutive(self): + layer = _layer_with_box(box=(10, 10, 40, 20)) + once = apply_transform(layer, rotation=180) + twice = apply_transform(once, rotation=180) + # Two 180° turns about the same bbox center return to the original. + assert ImageChops.difference(layer, twice).getbbox() is None + + def test_horizontal_mirror_is_involutive(self): + layer = _layer_with_box(box=(10, 10, 40, 20)) + once = apply_transform(layer, mirror="h") + twice = apply_transform(once, mirror="h") + assert ImageChops.difference(layer, twice).getbbox() is None + + def test_mirror_moves_asymmetric_content(self): + # An L-shape is asymmetric about its bbox center, so a horizontal flip + # must actually move pixels (a single rectangle would be a no-op). + from PIL import ImageDraw + + layer = Image.new("RGBA", (100, 100), (0, 0, 0, 0)) + draw = ImageDraw.Draw(layer) + draw.rectangle((10, 10, 20, 60), fill=(0, 0, 0, 255)) # vertical stem + draw.rectangle((10, 50, 60, 60), fill=(0, 0, 0, 255)) # bottom foot to the right + mirrored = apply_transform(layer, mirror="h") + assert ImageChops.difference(layer, mirrored).getbbox() is not None + + def test_unknown_mirror_code_is_noop(self): + layer = _layer_with_box() + out = apply_transform(layer, mirror="x") + assert ImageChops.difference(layer, out).getbbox() is None diff --git a/tests/unit/test_visible.py b/tests/unit/test_visible.py new file mode 100644 index 0000000..84727dc --- /dev/null +++ b/tests/unit/test_visible.py @@ -0,0 +1,49 @@ +"""Unit tests for `visible` field coercion (core._coerce_visible).""" + +import pytest + +from odl_renderer.core import _coerce_visible + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + # Booleans pass through. + (True, True), + (False, False), + # Templated string forms — the motivating case. + ("true", True), + ("True", True), + ("TRUE", True), + ("false", False), + ("False", False), + ("FALSE", False), + # Empty / whitespace-only strings hide (preserves the HA workaround). + ("", False), + (" ", False), + ("\n \t", False), + # Surrounding whitespace is stripped before comparison. + (" false ", False), + (" true ", True), + # Narrowed set: only "false"/empty are falsy. Other words stay truthy + # so a value that happens to be "no"/"0"/"none" is not silently hidden. + ("no", True), + ("off", True), + ("0", True), + ("none", True), + ("null", True), + ("1", True), + ("yes", True), + ("anything", True), + # Numbers via bool(). + (0, False), + (1, True), + (2, True), + (0.0, False), + (2.5, True), + # None hides. + (None, False), + ], +) +def test_coerce_visible(value, expected): + assert _coerce_visible(value) is expected diff --git a/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_mirrored_polygon.png b/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_mirrored_polygon.png new file mode 100644 index 0000000000000000000000000000000000000000..a1699eafad49e6db464879e57f205e4764250320 GIT binary patch literal 403 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#P1|%(0%q}r7Fh+U0IEGZrd3*OD=V1c@21o53 z|EEpmO8PWGIen()0gh{TUOzZCr&um!Z<&2VHK&Yv!JGqyOvec@?(F={=x=%~;@?@> z*ZEBG5i^tjzW*evY~}aBqVX|HpRmOMF?M{G&-?dr!|m4n_6JX>yOj{*7oeK$Yu?;A q#l=4FvfH)E5*G=3A7re2ZvLv>JU^D$U3v`+Yz9wPKbLh*2~7YwUZH;g literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..7b8aeeb42312947be65942f84bc1adca67b4c401 GIT binary patch literal 605 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#P1|%(0%q}r7FsXRDIEGZrd3*O^mXxD}!$qH$ zzxRI 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 literal 0 HcmV?d00001 diff --git a/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text_pivot_top_left.png b/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text_pivot_top_left.png new file mode 100644 index 0000000000000000000000000000000000000000..ae22beb3cd30ebda4026a063cb005005bc2a4281 GIT binary patch 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 literal 0 HcmV?d00001 diff --git a/tests/visual/test_transform_rendering.py b/tests/visual/test_transform_rendering.py new file mode 100644 index 0000000..a35a061 --- /dev/null +++ b/tests/visual/test_transform_rendering.py @@ -0,0 +1,69 @@ +"""Visual regression tests for rotation/mirror transforms. + +Local-only (excluded from CI due to cross-platform font rendering). These lock +in the visual correctness of the geometric transforms, complementing the +behavioral assertions in tests/integration/test_transforms.py. +""" + +from io import BytesIO + +import pytest + +from odl_renderer import generate_image + + +def _png_bytes(image): + buffer = BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + +@pytest.mark.asyncio +class TestTransformVisualRegression: + async def test_rotated_text(self, snapshot_png, ppb_font): + image = await generate_image( + width=120, + height=120, + background="white", + elements=[ + {"type": "text", "value": "Rotate", "x": 60, "y": 60, "font": ppb_font, "size": 28, "rotation": 90}, + ], + ) + assert _png_bytes(image) == snapshot_png + + async def test_rotated_text_pivot_top_left(self, snapshot_png, ppb_font): + image = await generate_image( + width=120, + height=120, + background="white", + elements=[ + { + "type": "text", + "value": "Pivot", + "x": 60, + "y": 60, + "font": ppb_font, + "size": 28, + "rotation": 45, + "pivot": "tl", + }, + ], + ) + assert _png_bytes(image) == snapshot_png + + async def test_mirrored_polygon(self, snapshot_png): + # Asymmetric L-shape makes the horizontal flip visually obvious. + image = await generate_image( + width=120, + height=120, + background="white", + elements=[ + { + "type": "polygon", + "points": [[20, 20], [40, 20], [40, 80], [90, 80], [90, 100], [20, 100]], + "fill": "red", + "mirror": "h", + }, + ], + ) + assert _png_bytes(image) == snapshot_png From 723319a426a268a6ce0638b50c882532afed6b43 Mon Sep 17 00:00:00 2001 From: gabriel Date: Thu, 25 Jun 2026 13:49:59 +0200 Subject: [PATCH 2/2] fix: align pivot anchors with the text/PIL convention Pivot anchor keywords now parse positionally like Pillow text anchors (first char horizontal l/m/r, second vertical t/m/b), instead of an order-independent scan that mis-resolved 'mt'/'mb' to the center. Also unify the README anchor docs: text, icon, icon_sequence and pivot share one anchor table. The separate icon table documented a vertical-first scheme (tl/bl/br) that the Pillow-native icon handler no longer uses. --- README.md | 77 ++++++++++++------ src/odl_renderer/transforms.py | 19 ++--- tests/integration/test_transforms.py | 2 +- tests/unit/test_transforms.py | 21 +++-- ...sion.test_rotated_text_pivot_left_top.png} | Bin tests/visual/test_transform_rendering.py | 4 +- uv.lock | 2 +- 7 files changed, 81 insertions(+), 44 deletions(-) rename tests/visual/__snapshots__/test_transform_rendering/{TestTransformVisualRegression.test_rotated_text_pivot_top_left.png => TestTransformVisualRegression.test_rotated_text_pivot_left_top.png} (100%) diff --git a/README.md b/README.md index 5075896..11c435b 100644 --- a/README.md +++ b/README.md @@ -130,9 +130,9 @@ When `y` is omitted, elements stack automatically: each element is placed below ### Anchors -Used by `text`, `icon`, and `icon_sequence` to set which point of the element aligns to the given `x`/`y` coordinates. +Used by `text`, `icon`, and `icon_sequence` to set which point of the element aligns to the given `x`/`y` coordinates, and by `pivot` (see [rotation](#rotation)/[mirror](#mirror)) to set the transform origin. -`text` uses PIL anchor format — horizontal axis first, then vertical: +All of them use the PIL anchor format — horizontal axis first, then vertical: | Code | Meaning | |------|-----------------| @@ -146,19 +146,7 @@ Used by `text`, `icon`, and `icon_sequence` to set which point of the element al | `mb` | Middle-bottom | | `rb` | Right-bottom | -`icon` and `icon_sequence` use a different format for corner anchors — vertical axis first: - -| Code | Meaning | -|------|-----------------| -| `tl` | Top-left | -| `tr` | Top-right | -| `mt` | Middle-top | -| `lm` | Left-middle | -| `mm` | Middle (center) | -| `rm` | Right-middle | -| `bl` | Bottom-left | -| `br` | Bottom-right | -| `mb` | Middle-bottom | +`icon` and `icon_sequence` default to `la` (left-ascender) rather than `lt`; otherwise the codes are identical. ### The `visible` field @@ -183,19 +171,43 @@ Every element type accepts an optional `rotation` (degrees, **positive = clockwi | Field | Required | Default | Notes | |------------|----------|------------------|------------------------------------------------------------------------------------| | `rotation` | no | `0` | Degrees, positive = clockwise | -| `pivot` | no | `"mm"` (center) | Anchor keyword (`tl`, `mm`, `br`, …) relative to the element, **or** `[x, y]` canvas coords (percentages allowed, e.g. `["50%", "50%"]`) | +| `pivot` | no | `"mm"` (center) | [Anchor](#anchors) keyword relative to the element (e.g. `lt`, `mm`, `rb`), **or** `[x, y]` canvas coords (percentages allowed, e.g. `["50%", "50%"]`) | > Not to be confused with the `image`/`dlimg`-only `rotate` field, which rotates the *source image before fitting it to the box*. Use `rotation` to tilt any rendered element on the canvas. ```yaml [ - {"type": "text", "value": "Rotated", "x": 148, "y": 64, "size": 30, "anchor": "mm", "rotation": 45}, - {"type": "text", "value": "Rotated", "x": 148, "y": 64, "size": 30, "anchor": "mm", "color": "gray"}, - {"type": "circle", "x": 148, "y": 64, "radius": 3, "fill": "red"} + { + "type": "text", + "value": "Rotated", + "x": 148, + "y": 64, + "size": 30, + "anchor": "mm", + "rotation": 45 + }, + # Illustration only — the gray "ghost" of the original orientation: + { + "type": "text", + "value": "Rotated", + "x": 148, + "y": 64, + "size": 30, + "anchor": "mm", + "color": "gray" + }, + # Illustration only — a dot marking the pivot (the element's center): + { + "type": "circle", + "x": 148, + "y": 64, + "radius": 3, + "fill": "red" + } ] ``` -Only the first element matters for usage — the gray "ghost" shows the un-rotated original and the red dot marks the pivot (`pivot` defaults to the element's center; set e.g. `"pivot": "tl"` or `"pivot": [148, 64]` to move it). +Only the first element matters for usage — the gray "ghost" shows the un-rotated original and the red dot marks the pivot (`pivot` defaults to the element's center; set e.g. `"pivot": "lt"` or `"pivot": [148, 64]` to move it). ![rotation example](https://raw.githubusercontent.com/OpenDisplay/odl-renderer/main/docs/screenshots/rotation.png) @@ -210,9 +222,28 @@ Every element type accepts an optional `mirror` to flip it about its `pivot`: `" ```yaml [ - {"type": "polygon", "points": [[60, 30], [110, 30], [110, 45], [80, 45], [80, 98], [60, 98]], "fill": "red", "mirror": "h"}, - {"type": "polygon", "points": [[60, 30], [110, 30], [110, 45], [80, 45], [80, 98], [60, 98]], "outline": "gray", "width": 1}, - {"type": "line", "x_start": 85, "y_start": 20, "x_end": 85, "y_end": 108, "fill": "red", "width": 1} + { + "type": "polygon", + "points": [[60, 30], [110, 30], [110, 45], [80, 45], [80, 98], [60, 98]], + "fill": "red", + "mirror": "h" + }, + # Illustration only — the original outline and the flip axis: + { + "type": "polygon", + "points": [[60, 30], [110, 30], [110, 45], [80, 45], [80, 98], [60, 98]], + "outline": "gray", + "width": 1 + }, + { + "type": "line", + "x_start": 85, + "y_start": 20, + "x_end": 85, + "y_end": 108, + "fill": "red", + "width": 1 + } ] ``` diff --git a/src/odl_renderer/transforms.py b/src/odl_renderer/transforms.py index a70639b..ef76801 100644 --- a/src/odl_renderer/transforms.py +++ b/src/odl_renderer/transforms.py @@ -19,10 +19,6 @@ if TYPE_CHECKING: from .coordinates import CoordinateParser -# Horizontal/vertical pivot components resolved against a bbox (left, top, right, bottom). -_H_COMPONENTS = frozenset("lmr") -_V_COMPONENTS = frozenset("tmb") - def has_transform(element: dict[str, Any]) -> bool: """Return True if an element requests a rotation or mirror transform.""" @@ -121,17 +117,18 @@ def _resolve_pivot( def _anchor_point(key: str, bbox: tuple[int, int, int, int]) -> tuple[float, float]: - """Resolve an anchor keyword (e.g. "tl", "mm", "br") to a bbox point. + """Resolve a two-letter anchor keyword to a bbox point. - Order-independent and lenient: the first horizontal char in {l, m, r} and - the first vertical char in {t, m, b} are used; missing components default to - the middle (center). + Uses the same Pillow anchor format as text: the first character selects the + horizontal edge (``l``/``m``/``r``) and the second the vertical edge + (``t``/``m``/``b``). Missing or unrecognized characters default to the + middle, so ``"mm"`` (or ``""``) is the center. """ left, top, right, bottom = bbox x_by = {"l": left, "m": (left + right) / 2, "r": right} y_by = {"t": top, "m": (top + bottom) / 2, "b": bottom} lowered = key.strip().lower() - h = next((ch for ch in lowered if ch in _H_COMPONENTS), "m") - v = next((ch for ch in lowered if ch in _V_COMPONENTS), "m") - return float(x_by[h]), float(y_by[v]) + h = lowered[0] if len(lowered) > 0 else "m" + v = lowered[1] if len(lowered) > 1 else "m" + return float(x_by.get(h, (left + right) / 2)), float(y_by.get(v, (top + bottom) / 2)) diff --git a/tests/integration/test_transforms.py b/tests/integration/test_transforms.py index d38d72a..3a2be01 100644 --- a/tests/integration/test_transforms.py +++ b/tests/integration/test_transforms.py @@ -48,7 +48,7 @@ async def test_rotation_zero_is_noop(self): async def test_pivot_changes_result(self): base = {"type": "text", "value": "Fg", "x": 40, "y": 45, "size": 40, "rotation": 90} center = await _render(base) - corner = await _render({**base, "pivot": "tl"}) + corner = await _render({**base, "pivot": "lt"}) assert ImageChops.difference(center.convert("RGB"), corner.convert("RGB")).getbbox() is not None async def test_coordinate_pivot_renders(self): diff --git a/tests/unit/test_transforms.py b/tests/unit/test_transforms.py index 379fb6d..a92533a 100644 --- a/tests/unit/test_transforms.py +++ b/tests/unit/test_transforms.py @@ -35,19 +35,28 @@ def test_mirror(self): class TestAnchorPoint: + # Pillow text format: first char horizontal (l/m/r), second vertical (t/m/b). def test_center_default(self): assert _anchor_point("mm", (10, 20, 30, 40)) == (20.0, 30.0) def test_top_left(self): - assert _anchor_point("tl", (10, 20, 30, 40)) == (10.0, 20.0) + assert _anchor_point("lt", (10, 20, 30, 40)) == (10.0, 20.0) def test_bottom_right(self): - assert _anchor_point("br", (10, 20, 30, 40)) == (30.0, 40.0) + assert _anchor_point("rb", (10, 20, 30, 40)) == (30.0, 40.0) - def test_order_independent(self): - assert _anchor_point("lt", (10, 20, 30, 40)) == _anchor_point("tl", (10, 20, 30, 40)) + def test_middle_top(self): + # Regression: the 'm' in "mt" must not collapse to center. + assert _anchor_point("mt", (10, 20, 30, 40)) == (20.0, 20.0) - def test_invalid_falls_back_to_center(self): + def test_middle_bottom(self): + assert _anchor_point("mb", (10, 20, 30, 40)) == (20.0, 40.0) + + def test_left_middle(self): + assert _anchor_point("lm", (10, 20, 30, 40)) == (10.0, 30.0) + + def test_empty_and_invalid_fall_back_to_center(self): + assert _anchor_point("", (10, 20, 30, 40)) == (20.0, 30.0) assert _anchor_point("??", (10, 20, 30, 40)) == (20.0, 30.0) @@ -56,7 +65,7 @@ def test_default_is_bbox_center(self): assert _resolve_pivot(None, (0, 0, 100, 50), None) == (50.0, 25.0) def test_anchor_keyword(self): - assert _resolve_pivot("tl", (0, 0, 100, 50), None) == (0.0, 0.0) + assert _resolve_pivot("lt", (0, 0, 100, 50), None) == (0.0, 0.0) def test_coord_pair_pixels(self): coords = CoordinateParser(200, 100) diff --git a/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text_pivot_top_left.png b/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text_pivot_left_top.png similarity index 100% rename from tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text_pivot_top_left.png rename to tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text_pivot_left_top.png diff --git a/tests/visual/test_transform_rendering.py b/tests/visual/test_transform_rendering.py index a35a061..1d0cbdb 100644 --- a/tests/visual/test_transform_rendering.py +++ b/tests/visual/test_transform_rendering.py @@ -31,7 +31,7 @@ async def test_rotated_text(self, snapshot_png, ppb_font): ) assert _png_bytes(image) == snapshot_png - async def test_rotated_text_pivot_top_left(self, snapshot_png, ppb_font): + async def test_rotated_text_pivot_left_top(self, snapshot_png, ppb_font): image = await generate_image( width=120, height=120, @@ -45,7 +45,7 @@ async def test_rotated_text_pivot_top_left(self, snapshot_png, ppb_font): "font": ppb_font, "size": 28, "rotation": 45, - "pivot": "tl", + "pivot": "lt", }, ], ) diff --git a/uv.lock b/uv.lock index 03b1662..63b3511 100644 --- a/uv.lock +++ b/uv.lock @@ -807,7 +807,7 @@ wheels = [ [[package]] name = "odl-renderer" -version = "0.5.7" +version = "0.5.9" source = { editable = "." } dependencies = [ { name = "aiohttp" },