decktile 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. decktile/__init__.py +5 -0
  2. decktile/__main__.py +3 -0
  3. decktile/assets/lucide/VENDORED.md +21 -0
  4. decktile/assets/lucide/lucide.css +2193 -0
  5. decktile/assets/lucide/lucide.ttf +3 -0
  6. decktile/cli.py +269 -0
  7. decktile/parser/__init__.py +0 -0
  8. decktile/parser/ast.py +191 -0
  9. decktile/parser/markdown.py +289 -0
  10. decktile/plugins/cards.py +25 -0
  11. decktile/plugins/directives.py +56 -0
  12. decktile/plugins/fenced.py +228 -0
  13. decktile/plugins/layouts.py +69 -0
  14. decktile/plugins/lucide.py +65 -0
  15. decktile/plugins/lucide_font.py +26 -0
  16. decktile/render/__init__.py +0 -0
  17. decktile/render/html.py +287 -0
  18. decktile/render/ir.py +536 -0
  19. decktile/render/odf/__init__.py +4 -0
  20. decktile/render/odf/geometry.py +510 -0
  21. decktile/render/odf/shapes.py +719 -0
  22. decktile/render/odf/styles.py +828 -0
  23. decktile/render/odp.py +380 -0
  24. decktile/render/pdf.py +17 -0
  25. decktile/render/pdg.py +72 -0
  26. decktile/render/seaborn.py +155 -0
  27. decktile/render/templates/base.css +741 -0
  28. decktile/render/templates/decktile.js +371 -0
  29. decktile/render/templates/shell.html +106 -0
  30. decktile/seaborn_styles/__init__.py +5 -0
  31. decktile/seaborn_styles/ossummit_europe.py +30 -0
  32. decktile/theme/__init__.py +0 -0
  33. decktile/theme/colors.py +53 -0
  34. decktile/theme/loader.py +22 -0
  35. decktile/theme/parser.py +264 -0
  36. decktile/themes/default.css +146 -0
  37. decktile/themes/kcd_vietnam.css +159 -0
  38. decktile/themes/kubecon_china.css +213 -0
  39. decktile/themes/kubecon_japan.css +156 -0
  40. decktile/themes/ossummit_europe.css +196 -0
  41. decktile/themes/ossummit_korea.css +196 -0
  42. decktile-0.2.0.dist-info/METADATA +625 -0
  43. decktile-0.2.0.dist-info/RECORD +46 -0
  44. decktile-0.2.0.dist-info/WHEEL +4 -0
  45. decktile-0.2.0.dist-info/entry_points.txt +6 -0
  46. decktile-0.2.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,510 @@
1
+ """weasyprint layout box-tree adapter: resolves per-Elem geometry + style for ODP.
2
+
3
+ Isolated here because it reads weasyprint's private layout API
4
+ (``HTML(...).render().pages[i]._page_box``, box internals). weasyprint is
5
+ pinned (``pyproject.toml``); ``tests/test_geometry.py`` guards the
6
+ attributes this module relies on so an upgrade breaking them fails loudly
7
+ here, not as a silently-wrong ODP render.
8
+
9
+ HTML/PDF rendering never import this module; it is the ODP-only consumer
10
+ of ``Elem.box``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import dataclasses
16
+ import math
17
+ import re
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from decktile.render.ir import BoxGeometry, Elem, ResolvedStyle, SlideIR
22
+
23
+ PX_TO_CM = 2.54 / 96.0
24
+
25
+
26
+ GLOBAL_TAGS = {"code"} # inline constructs with no per-instance id: resolve once, globally
27
+
28
+
29
+ def annotate_geometry(
30
+ ir_slides: list[SlideIR],
31
+ doc: Any,
32
+ assembled_css: str,
33
+ dims: tuple[int, int],
34
+ source_dir: Path | None = None,
35
+ ) -> tuple[dict[str, BoxGeometry], dict[str, ResolvedStyle]]:
36
+ """Render ir_slides via weasyprint and fill every Elem.box in place.
37
+
38
+ Returns ``({slide.id: BoxGeometry}, {tag: ResolvedStyle})``: the first
39
+ for each slide's root ``<section>`` (slide/master background); the
40
+ second for inline constructs with no per-instance id (``code``, for
41
+ its themed mono font, plus ``icon:accent-primary``/``-secondary``/
42
+ ``-contrast`` for lucide icon glyph color) -- resolved once from the
43
+ first occurrence found anywhere (``code``) or directly from the
44
+ theme's own ``:root`` custom properties (icon accents), since the CSS
45
+ that styles them never varies per instance. Icon accents specifically
46
+ can't be resolved from weasyprint's own box tree: an inline ``<svg>``
47
+ icon gets no separate, walkable box there (verified empirically --
48
+ unlike a replaced ``<img>``, weasyprint folds it into its containing
49
+ element's own paint step with no ``element_tag == "svg"`` child to
50
+ read ``color`` from).
51
+ """
52
+ from weasyprint import HTML
53
+
54
+ from decktile.render.html import render_from_ir
55
+
56
+ html_str = render_from_ir(ir_slides, doc, assembled_css, dims)
57
+ base_url = f"{source_dir}/" if source_dir else "."
58
+ rendered = HTML(string=html_str, base_url=base_url).render()
59
+
60
+ by_id: dict[str, Elem] = {}
61
+ for slide in ir_slides:
62
+ _index(slide.elements, by_id)
63
+
64
+ section_box: dict[str, BoxGeometry] = {}
65
+ global_tags: dict[str, ResolvedStyle] = {}
66
+ for cls, hexcolor in _resolve_icon_accent_colors(assembled_css).items():
67
+ global_tags[f"icon:{cls}"] = ResolvedStyle(fg=hexcolor)
68
+ seen: set[str] = set()
69
+ white = (1.0, 1.0, 1.0)
70
+ for page in rendered.pages:
71
+ _walk(page._page_box, by_id, section_box, seen, None, white, global_tags, None)
72
+ return section_box, global_tags
73
+
74
+
75
+ _VAR_DECL_RE = re.compile(r'(--[\w-]+)\s*:\s*([^;]+);')
76
+ _VAR_REF_RE = re.compile(r'var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\)')
77
+
78
+
79
+ def _resolve_icon_accent_colors(css: str) -> dict[str, str]:
80
+ """``accent-primary``/``-secondary``/``-contrast`` -> resolved hex, read
81
+ directly from the theme's (and base.css's) own ``:root { ... }``
82
+ blocks, in source order so a theme's declaration overrides base.css's
83
+ default -- the normal CSS custom-property cascade for the default
84
+ (non-dark) case, which is what matters since every icon in this
85
+ deck's themes appears on light/default sections.
86
+
87
+ Resolves ``var(--other)`` indirection rather than returning it
88
+ verbatim: base.css's own fallback is ``--color-accent-primary: var(
89
+ --color-accent)``, and a theme (e.g. the bundled default theme) that
90
+ never overrides it with a direct hex previously leaked the literal
91
+ string "var(--color-accent)" through to odfdo's color parser, which
92
+ crashed (KeyError) trying to look it up as a CSS3 color name."""
93
+ all_vars: dict[str, str] = {}
94
+ for block in re.finditer(r':root\s*\{([^}]*)\}', css):
95
+ for m in _VAR_DECL_RE.finditer(block.group(1)):
96
+ all_vars[m.group(1)] = m.group(2).strip()
97
+
98
+ def resolve(value: str, seen: frozenset[str]) -> str:
99
+ m = _VAR_REF_RE.match(value)
100
+ if not m:
101
+ return value
102
+ name = m.group(1)
103
+ if name in seen or name not in all_vars:
104
+ return (m.group(2) or value).strip()
105
+ return resolve(all_vars[name], seen | {name})
106
+
107
+ out: dict[str, str] = {}
108
+ for key in ("accent-primary", "accent-secondary", "accent-contrast"):
109
+ full_key = f"--color-{key}"
110
+ if full_key not in all_vars:
111
+ continue
112
+ resolved = resolve(all_vars[full_key], frozenset({full_key}))
113
+ if not resolved.startswith("var("):
114
+ out[key] = resolved
115
+ return out
116
+
117
+
118
+ def _index(elems: list[Elem], by_id: dict[str, Elem]) -> None:
119
+ for e in elems:
120
+ by_id[e.id] = e
121
+ if e.children:
122
+ _index(e.children, by_id)
123
+
124
+
125
+ def _sid_of(box: Any) -> str | None:
126
+ el = getattr(box, "element", None)
127
+ if el is None:
128
+ return None
129
+ try:
130
+ return el.get("data-decktile-id")
131
+ except Exception:
132
+ return None
133
+
134
+
135
+ def _has_class(box: Any, cls: str) -> bool:
136
+ el = getattr(box, "element", None)
137
+ if el is None:
138
+ return False
139
+ try:
140
+ return cls in (el.get("class") or "").split()
141
+ except Exception:
142
+ return False
143
+
144
+
145
+ def _walk(
146
+ box: Any,
147
+ by_id: dict[str, Elem],
148
+ section_box: dict[str, BoxGeometry],
149
+ seen: set[str],
150
+ section_origin: tuple[float, float] | None,
151
+ parent_bg: tuple[float, float, float],
152
+ global_tags: dict[str, ResolvedStyle],
153
+ current_sid: str | None,
154
+ ) -> None:
155
+ tag = getattr(box, "element_tag", None) or ""
156
+ sid = _sid_of(box)
157
+ has_rect = hasattr(box, "border_box_x")
158
+ bg_rgb = parent_bg
159
+
160
+ if tag in GLOBAL_TAGS and tag not in global_tags:
161
+ global_tags[tag] = _resolve_style(box, parent_bg)
162
+
163
+ pseudo_or_injected = tag in (
164
+ "section::before", "section::after", "footer", "div::before", "div::after",
165
+ ) or (tag == "span" and _has_class(box, "slide-num"))
166
+ if has_rect and (pseudo_or_injected or (sid not in seen if sid else True)):
167
+ style = _resolve_style(box, parent_bg)
168
+ bg_rgb = style.bg_rgb01 # already composited with parent_bg in _resolve_style
169
+
170
+ if tag == "section" and sid:
171
+ section_origin = (box.border_box_x(), box.border_box_y())
172
+ section_box[sid] = BoxGeometry(
173
+ x=0.0, y=0.0,
174
+ w=box.border_width() * PX_TO_CM, h=box.border_height() * PX_TO_CM,
175
+ style=style,
176
+ )
177
+ seen.add(sid)
178
+ current_sid = sid
179
+ elif tag in ("section::before", "section::after") and sid in section_box:
180
+ # logo (::before) / watermark (::after): position+file resolved
181
+ # the same way as any other box -- weasyprint's own cascade
182
+ # already applied any `--logo: var(--logo-dark)` swap, so this
183
+ # is always the exact file/position the HTML used, no separate
184
+ # Python-side light/dark selection needed.
185
+ ox, oy = section_origin or (box.border_box_x(), box.border_box_y())
186
+ pseudo = BoxGeometry(
187
+ x=(box.border_box_x() - ox) * PX_TO_CM,
188
+ y=(box.border_box_y() - oy) * PX_TO_CM,
189
+ w=box.border_width() * PX_TO_CM,
190
+ h=box.border_height() * PX_TO_CM,
191
+ style=style,
192
+ )
193
+ if tag == "section::before":
194
+ section_box[sid].logo = pseudo
195
+ else:
196
+ section_box[sid].watermark = pseudo
197
+ elif tag in ("footer", "span") and current_sid in section_box and (
198
+ tag == "footer" or _has_class(box, "slide-num")
199
+ ):
200
+ # <footer>/.slide-num: template-injected, not AST elements --
201
+ # no data-decktile-id to match by, attach to the enclosing
202
+ # section instead (current_sid, threaded down the recursion).
203
+ # First match wins: weasyprint's own LineBox/TextBox children
204
+ # of the element also carry element_tag == "footer" (inherited
205
+ # from their block ancestor) and get walked into right after --
206
+ # taking the last match instead landed on the text's own tight
207
+ # line-box geometry (zero padding, transparent) rather than the
208
+ # footer block's own resolved box (verified empirically: every
209
+ # slide's footer reported the same narrow, unstyled geometry
210
+ # regardless of its real light/dark/title background).
211
+ ox, oy = section_origin or (box.border_box_x(), box.border_box_y())
212
+ el = getattr(box, "element", None)
213
+ pseudo = BoxGeometry(
214
+ x=(box.border_box_x() - ox) * PX_TO_CM,
215
+ y=(box.border_box_y() - oy) * PX_TO_CM,
216
+ w=box.border_width() * PX_TO_CM,
217
+ h=box.border_height() * PX_TO_CM,
218
+ style=style,
219
+ text=(el.text or "").strip() if el is not None else "",
220
+ )
221
+ if tag == "footer":
222
+ if section_box[current_sid].footer is None:
223
+ section_box[current_sid].footer = pseudo
224
+ elif section_box[current_sid].slide_num is None:
225
+ section_box[current_sid].slide_num = pseudo
226
+ elif current_sid in by_id and by_id[current_sid].kind in ("grid", "card") and (
227
+ tag in ("div::before", "div::after")
228
+ or (tag == "span" and _has_class(box, "timeline-label"))
229
+ ):
230
+ # Timeline spine (.grid::before) / dot+stub (.card::before/
231
+ # ::after) / milestone name (span.timeline-label): no AST node
232
+ # of their own. Attach to the enclosing grid/card's box so the
233
+ # ODP renderer can draw them alongside it. First match per
234
+ # label wins -- the span's own line/text child boxes inherit
235
+ # element_tag "span" and are walked right after.
236
+ host = by_id[current_sid]
237
+ is_label = tag == "span"
238
+ if host.box is not None and not (
239
+ is_label and any(d.text for d in host.box.decorations)
240
+ ):
241
+ ox, oy = section_origin or (box.border_box_x(), box.border_box_y())
242
+ dx, dy = _translate_px(box)
243
+ el = getattr(box, "element", None)
244
+ host.box.decorations.append(BoxGeometry(
245
+ x=(box.border_box_x() + dx - ox) * PX_TO_CM,
246
+ y=(box.border_box_y() + dy - oy) * PX_TO_CM,
247
+ w=box.border_width() * PX_TO_CM,
248
+ h=box.border_height() * PX_TO_CM,
249
+ style=_decoration_style(box, style, parent_bg),
250
+ text=(el.text or "").strip() if is_label and el is not None else "",
251
+ ))
252
+ elif sid and sid in by_id:
253
+ ox, oy = section_origin or (box.border_box_x(), box.border_box_y())
254
+ elem = by_id[sid]
255
+ substyles = (
256
+ _collect_substyles(box, bg_rgb) if elem.kind in ("card", "table", "list")
257
+ else {}
258
+ )
259
+ # `border-radius` is set on `img` itself (base.css's rounded
260
+ # image treatment), not on the paragraph/figure wrapping it --
261
+ # a plain Elem's own resolved style never carries it, so a
262
+ # content image rendered square regardless of the theme's
263
+ # `--img-radius`. Copy it from the first nested `img` box, if
264
+ # this elem is image content.
265
+ if elem.kind in ("text", "side-image"):
266
+ img_radius = _img_border_radius(box)
267
+ if img_radius:
268
+ style = dataclasses.replace(style, border_radius=img_radius)
269
+ elem.box = BoxGeometry(
270
+ x=(box.border_box_x() - ox) * PX_TO_CM,
271
+ y=(box.border_box_y() - oy) * PX_TO_CM,
272
+ w=box.border_width() * PX_TO_CM,
273
+ h=box.border_height() * PX_TO_CM,
274
+ style=style,
275
+ substyles=substyles,
276
+ )
277
+ seen.add(sid)
278
+ if elem.kind in ("grid", "card"):
279
+ current_sid = sid
280
+
281
+ for child in getattr(box, "children", None) or []:
282
+ _walk(child, by_id, section_box, seen, section_origin, bg_rgb, global_tags, current_sid)
283
+
284
+
285
+ _SUBTAGS = {"h3", "p", "th", "td", "tr", "li"}
286
+
287
+
288
+ def _collect_substyles(
289
+ box: Any, bg_rgb: tuple[float, float, float],
290
+ ) -> dict[str, ResolvedStyle]:
291
+ """First-seen style per descendant tag inside one Elem's subtree.
292
+
293
+ ``tr`` needs both parity classes (CSS zebra striping is
294
+ ``tr:nth-child(even)``), so the first two ``tr`` occurrences are kept
295
+ as ``tr:odd``/``tr:even`` instead of one first-match. ``li::marker``
296
+ is a real pseudo-element box (``element_tag`` suffixed accordingly),
297
+ not derived from ``li``.
298
+ """
299
+ out: dict[str, ResolvedStyle] = {}
300
+ tr_seen = 0
301
+
302
+ def walk(b: Any, parent_bg: tuple[float, float, float], in_tbody: bool) -> None:
303
+ nonlocal tr_seen
304
+ tag = getattr(b, "element_tag", None) or ""
305
+ bg_rgb_here = parent_bg
306
+ next_in_tbody = in_tbody or tag == "tbody"
307
+ if hasattr(b, "border_box_x") or tag.endswith("::marker"):
308
+ style = _resolve_style(b, parent_bg)
309
+ bg_rgb_here = style.bg_rgb01
310
+ base_tag = tag.split("::")[0]
311
+ if base_tag == "tr" and in_tbody:
312
+ # nth-child scopes to the row's parent (tbody) -- a <thead>
313
+ # row is never a zebra-stripe candidate at all.
314
+ key = "tr:odd" if tr_seen == 0 else "tr:even"
315
+ if tr_seen < 2:
316
+ out.setdefault(key, style)
317
+ tr_seen += 1
318
+ elif base_tag != "tr" and (tag in _SUBTAGS or tag.endswith("::marker")):
319
+ out.setdefault(tag, style)
320
+ for c in getattr(b, "children", None) or []:
321
+ walk(c, bg_rgb_here, next_in_tbody)
322
+
323
+ for child in getattr(box, "children", None) or []:
324
+ walk(child, bg_rgb, False)
325
+ return out
326
+
327
+
328
+ def _resolve_style(box: Any, parent_bg: tuple[float, float, float]) -> ResolvedStyle:
329
+ s = box.style
330
+
331
+ fg_rgb = _rgb01(s["color"])
332
+ bg_color = s["background_color"]
333
+ bg_rgb01 = _rgb01(bg_color)
334
+ bg_alpha = float(bg_color.alpha or 0.0)
335
+ bg_gradient, bg_angle, bg_image_url, bg_image_rgb01, bg_image_alpha = (
336
+ _resolve_background_image(s["background_image"])
337
+ )
338
+
339
+ if bg_gradient or bg_image_url:
340
+ # A box with its own background-image isn't "transparent" in the
341
+ # visual sense even when background-color alpha is 0 -- its own
342
+ # image/gradient already determines what descendants see through.
343
+ # Composite onto white (the assumed photo brightness), never onto
344
+ # an unrelated ancestor's flat color (e.g. the <body> screen-mode
345
+ # letterboxing color, which never renders behind a slide section).
346
+ composited = _composite((1.0, 1.0, 1.0), bg_image_rgb01, bg_image_alpha)
347
+ else:
348
+ composited = _composite(parent_bg, bg_rgb01, bg_alpha)
349
+
350
+ radius_dims = s["border_top_left_radius"]
351
+ border_radius = _dim_px(radius_dims[0]) if isinstance(radius_dims, tuple) else 0.0
352
+
353
+ weight = s["font_weight"]
354
+ font_weight = "bold" if (isinstance(weight, int) and weight >= 600) else "normal"
355
+ font_italic = s["font_style"] in ("italic", "oblique")
356
+
357
+ font_family_tuple = s["font_family"]
358
+ font_family = font_family_tuple[0] if font_family_tuple else ""
359
+
360
+ style = ResolvedStyle(
361
+ bg=_hex(composited),
362
+ bg_alpha=bg_alpha,
363
+ fg=_hex(fg_rgb),
364
+ border_color=_hex(_rgb01(s["border_left_color"], fg_rgb)),
365
+ border_width=float(s["border_left_width"]) if s["border_left_width"] else 0.0,
366
+ border_radius=border_radius,
367
+ font_family=font_family,
368
+ font_size_px=float(s["font_size"]),
369
+ font_weight=font_weight,
370
+ font_italic=font_italic,
371
+ text_align=s["text_align_all"] or "left",
372
+ bg_gradient=bg_gradient,
373
+ bg_angle_deg=bg_angle,
374
+ bg_image_url=bg_image_url,
375
+ bg_rgb01=composited,
376
+ padding_top=_dim_px(s["padding_top"]),
377
+ padding_right=_dim_px(s["padding_right"]),
378
+ padding_bottom=_dim_px(s["padding_bottom"]),
379
+ padding_left=_dim_px(s["padding_left"]),
380
+ )
381
+ return style
382
+
383
+
384
+ def _resolve_background_image(
385
+ layers: tuple,
386
+ ) -> tuple[
387
+ list[tuple[float, str, float]] | None, float, str,
388
+ tuple[float, float, float], float,
389
+ ]:
390
+ """Returns (gradient stops, angle, image url, representative rgb01, alpha).
391
+
392
+ The representative color/alpha is the stop-average, used only to
393
+ approximate "what this box's own background visually reads as" for
394
+ compositing child text/fills -- never for the ODF fill itself (that
395
+ uses the full gradient/bitmap via styles.py).
396
+ """
397
+ gradient = None
398
+ angle = 180.0
399
+ url = ""
400
+ rep_rgb01 = (1.0, 1.0, 1.0)
401
+ rep_alpha = 1.0
402
+ for kind, val in layers or ():
403
+ if kind == "linear-gradient":
404
+ angle = math.degrees(val.direction) if val.direction_type == "angle" else angle
405
+ stops = []
406
+ rgb01_stops = []
407
+ alphas = []
408
+ n = len(val.colors)
409
+ for idx, (color, pos) in enumerate(zip(val.colors, val.stop_positions)):
410
+ if pos is not None and getattr(pos, "unit", "") == "%":
411
+ offset = pos.value / 100.0
412
+ else:
413
+ # no stop position declared (e.g. every theme's
414
+ # `linear-gradient(var(--bg-overlay), var(--bg-overlay))`)
415
+ # -- evenly space rather than collapsing every stop to 0.
416
+ offset = idx / (n - 1) if n > 1 else 0.0
417
+ alpha = 1.0 if color.alpha is None else float(color.alpha)
418
+ rgb01 = _rgb01(color)
419
+ stops.append((offset, _hex(rgb01), alpha))
420
+ rgb01_stops.append(rgb01)
421
+ alphas.append(alpha)
422
+ gradient = stops
423
+ rep_rgb01 = tuple(sum(c[i] for c in rgb01_stops) / len(rgb01_stops) for i in range(3))
424
+ rep_alpha = sum(alphas) / len(alphas)
425
+ elif kind == "url" and val.startswith("file://"):
426
+ url = val[len("file://"):]
427
+ return gradient, angle, url, rep_rgb01, rep_alpha
428
+
429
+
430
+ def _rgb01(color: Any, currentcolor: tuple[float, float, float] = (0.0, 0.0, 0.0)) -> tuple[float, float, float]:
431
+ if color == "currentcolor":
432
+ return currentcolor
433
+ r, g, b = color.coordinates
434
+ return (_clamp01(r), _clamp01(g), _clamp01(b))
435
+
436
+
437
+ def _clamp01(v: float) -> float:
438
+ return max(0.0, min(1.0, v))
439
+
440
+
441
+ def _composite(
442
+ parent_rgb01: tuple[float, float, float],
443
+ child_rgb01: tuple[float, float, float],
444
+ alpha: float,
445
+ ) -> tuple[float, float, float]:
446
+ if alpha <= 0.0:
447
+ return parent_rgb01
448
+ if alpha >= 1.0:
449
+ return child_rgb01
450
+ return tuple(c * alpha + p * (1 - alpha) for c, p in zip(child_rgb01, parent_rgb01)) # type: ignore[return-value]
451
+
452
+
453
+ def _hex(rgb01: tuple[float, float, float]) -> str:
454
+ return "#%02x%02x%02x" % tuple(round(c * 255) for c in rgb01)
455
+
456
+
457
+ def _dim_px(dim: Any) -> float:
458
+ return float(dim.value) if getattr(dim, "unit", "px") == "px" else 0.0
459
+
460
+
461
+ def _len_px(dim: Any, ref_px: float) -> float:
462
+ unit = getattr(dim, "unit", "px")
463
+ if unit == "px":
464
+ return float(dim.value)
465
+ if unit == "%":
466
+ return float(dim.value) / 100.0 * ref_px
467
+ return 0.0
468
+
469
+
470
+ def _translate_px(box: Any) -> tuple[float, float]:
471
+ """CSS translate() offset in px. weasyprint box positions are pre-
472
+ transform (applied only at paint time), so a `left: 50%; transform:
473
+ translateX(-50%)` centred dot would land half its width off-centre
474
+ without this."""
475
+ dx = dy = 0.0
476
+ for name, args in box.style["transform"] or ():
477
+ if name == "translate":
478
+ dx += _len_px(args[0], box.border_width())
479
+ dy += _len_px(args[1], box.border_height())
480
+ return dx, dy
481
+
482
+
483
+ def _decoration_style(
484
+ box: Any, style: ResolvedStyle, parent_bg: tuple[float, float, float],
485
+ ) -> ResolvedStyle:
486
+ """A decoration's own style with what _resolve_style drops: `%`
487
+ border-radius (`50%` -> circle) and element `opacity` (folded into the
488
+ fill, composited onto the parent bg -- ODF draw:fill-color is opaque)."""
489
+ radius_dims = box.style["border_top_left_radius"]
490
+ radius = _len_px(radius_dims[0], min(box.border_width(), box.border_height()))
491
+ opacity = float(box.style["opacity"])
492
+ bg = style.bg
493
+ if opacity < 1.0 and style.bg:
494
+ bg = _hex(_composite(parent_bg, style.bg_rgb01, opacity))
495
+ return dataclasses.replace(style, border_radius=radius, bg=bg)
496
+
497
+
498
+ def _img_border_radius(box: Any) -> float:
499
+ """The first nested ``img`` box's own border-radius (px), or 0.
500
+ ``border-radius`` is set on ``img`` itself (base.css's rounded image
501
+ treatment), not on the paragraph/figure wrapping it -- the wrapping
502
+ Elem's own resolved style never carries it."""
503
+ for child in getattr(box, "children", None) or []:
504
+ if getattr(child, "element_tag", None) == "img":
505
+ dims = child.style["border_top_left_radius"]
506
+ return _dim_px(dims[0]) if isinstance(dims, tuple) else 0.0
507
+ found = _img_border_radius(child)
508
+ if found:
509
+ return found
510
+ return 0.0