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.
- decktile/__init__.py +5 -0
- decktile/__main__.py +3 -0
- decktile/assets/lucide/VENDORED.md +21 -0
- decktile/assets/lucide/lucide.css +2193 -0
- decktile/assets/lucide/lucide.ttf +3 -0
- decktile/cli.py +269 -0
- decktile/parser/__init__.py +0 -0
- decktile/parser/ast.py +191 -0
- decktile/parser/markdown.py +289 -0
- decktile/plugins/cards.py +25 -0
- decktile/plugins/directives.py +56 -0
- decktile/plugins/fenced.py +228 -0
- decktile/plugins/layouts.py +69 -0
- decktile/plugins/lucide.py +65 -0
- decktile/plugins/lucide_font.py +26 -0
- decktile/render/__init__.py +0 -0
- decktile/render/html.py +287 -0
- decktile/render/ir.py +536 -0
- decktile/render/odf/__init__.py +4 -0
- decktile/render/odf/geometry.py +510 -0
- decktile/render/odf/shapes.py +719 -0
- decktile/render/odf/styles.py +828 -0
- decktile/render/odp.py +380 -0
- decktile/render/pdf.py +17 -0
- decktile/render/pdg.py +72 -0
- decktile/render/seaborn.py +155 -0
- decktile/render/templates/base.css +741 -0
- decktile/render/templates/decktile.js +371 -0
- decktile/render/templates/shell.html +106 -0
- decktile/seaborn_styles/__init__.py +5 -0
- decktile/seaborn_styles/ossummit_europe.py +30 -0
- decktile/theme/__init__.py +0 -0
- decktile/theme/colors.py +53 -0
- decktile/theme/loader.py +22 -0
- decktile/theme/parser.py +264 -0
- decktile/themes/default.css +146 -0
- decktile/themes/kcd_vietnam.css +159 -0
- decktile/themes/kubecon_china.css +213 -0
- decktile/themes/kubecon_japan.css +156 -0
- decktile/themes/ossummit_europe.css +196 -0
- decktile/themes/ossummit_korea.css +196 -0
- decktile-0.2.0.dist-info/METADATA +625 -0
- decktile-0.2.0.dist-info/RECORD +46 -0
- decktile-0.2.0.dist-info/WHEEL +4 -0
- decktile-0.2.0.dist-info/entry_points.txt +6 -0
- 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
|