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,828 @@
1
+ """ODF Style registries built from resolved (weasyprint-derived) styles.
2
+
3
+ Every registry dedups by a hashable key derived from ResolvedStyle so two
4
+ elements with identical computed style share one odfdo Style object. Each
5
+ ``*_style``/``*_name`` method inserts into the document immediately (no
6
+ deferred batch-insert pass) -- odfdo dedups by name internally and this
7
+ keeps the registration/use sites next to each other.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ import functools
14
+ import os
15
+ import tempfile
16
+
17
+ from dataclasses import dataclass
18
+ from itertools import count
19
+ from typing import Callable
20
+
21
+ from odfdo import Document, DrawFillImage, Style, StyleMasterPage, StylePageLayout
22
+ from odfdo.element import Element
23
+
24
+ from decktile.render.ir import ResolvedStyle
25
+
26
+ PX_TO_PT = 0.75 # CSS px (96dpi) -> pt (72dpi)
27
+ CM_PER_PX = 2.54 / 96.0
28
+
29
+
30
+ def css_angle_to_odf(css_deg: float, width_cm: float, height_cm: float) -> float:
31
+ """CSS gradient angle -> ODF draw:angle (plain degrees).
32
+
33
+ ODF measures 0deg = top-to-bottom, rotating counter-clockwise (opposite
34
+ sense from CSS) -- nominal angle is ``(180 - css) % 360``. But ODF's
35
+ linear gradient line is defined relative to the shape's own bounding
36
+ box, so a non-square shape stretches the visual angle; compensate via
37
+ atan2(sin(t)*W, cos(t)*H). Verified empirically against weasyprint's
38
+ render at a real 16:9 slide (css 135deg -> odf ~60.7deg, confirmed
39
+ pixel-for-pixel via corner-color sampling through soffice).
40
+ """
41
+ if width_cm <= 0 or height_cm <= 0:
42
+ width_cm = height_cm = 1.0
43
+ nominal = math.radians((180 - css_deg) % 360)
44
+ odf_deg = math.degrees(math.atan2(math.sin(nominal) * width_cm, math.cos(nominal) * height_cm))
45
+ return odf_deg % 360
46
+
47
+
48
+ def px_to_pt(px: float) -> str:
49
+ return f"{px * PX_TO_PT:.2f}pt"
50
+
51
+
52
+ def px_to_cm(px: float) -> str:
53
+ return f"{px * CM_PER_PX:.3f}cm"
54
+
55
+
56
+ def downscale_image(path: str, width_cm: float, height_cm: float) -> str:
57
+ """Downscale an oversized bitmap before embedding, to 2x the target's
58
+ own pixel size -- used for both per-page master backgrounds (one
59
+ style, but Impress recomposites it fresh for every page using it --
60
+ see ``StyleRegistry._prepare_bg_image``) and per-slide logo/watermark
61
+ frames (one frame per slide, each independently decoded/resampled by
62
+ the same mechanism; a wide source watermark photo turned a one-time
63
+ decode into a cost repeated on every slide carrying it). Preserves
64
+ alpha -- a logo/watermark PNG's transparency must survive, unlike an
65
+ opaque full-bleed background photo. Skipped entirely if already
66
+ smaller than the target. Memoized: the same source+box recurring on
67
+ every slide (a deck-wide logo/watermark) would otherwise re-decode
68
+ and re-embed as a distinct Pictures/ entry on every call -- add_file
69
+ dedups by path, and a fresh mkstemp path every call defeats that.
70
+ """
71
+ return _downscale_image_cached(path, round(width_cm, 2), round(height_cm, 2))
72
+
73
+
74
+ @functools.lru_cache(maxsize=256)
75
+ def _downscale_image_cached(path: str, width_cm: float, height_cm: float) -> str:
76
+ try:
77
+ from PIL import Image
78
+ except ImportError:
79
+ return path
80
+ target_w = max(1, round(width_cm / CM_PER_PX * 2))
81
+ target_h = max(1, round(height_cm / CM_PER_PX * 2))
82
+ try:
83
+ with Image.open(path) as im:
84
+ if im.width <= target_w and im.height <= target_h:
85
+ return path
86
+ scale = min(target_w / im.width, target_h / im.height)
87
+ size = (max(1, round(im.width * scale)), max(1, round(im.height * scale)))
88
+ mode = "RGBA" if im.mode in ("RGBA", "LA", "P") else "RGB"
89
+ resized = im.convert(mode).resize(size, Image.LANCZOS)
90
+ fd, out_path = tempfile.mkstemp(suffix=".png")
91
+ os.close(fd)
92
+ resized.save(out_path)
93
+ return out_path
94
+ except Exception:
95
+ return path
96
+
97
+
98
+ def round_image_corners(path: str, radius_cm: float, frame_w_cm: float) -> str:
99
+ """Bakes rounded corners into a copy of the image via an alpha mask.
100
+
101
+ ``draw:corner-radius`` has no visual effect on a ``draw:image``'s
102
+ embedded picture content in LibreOffice -- verified empirically: it
103
+ rounds a plain filled frame's own shape correctly, but a picture
104
+ frame renders perfectly square regardless of the attribute. CSS's
105
+ ``img { border-radius: var(--img-radius) }`` treatment has to be
106
+ applied to the pixels themselves. Radius is scaled by the ratio of
107
+ the frame's own box width to the source image's native pixel width,
108
+ so the rounding proportion matches regardless of source resolution.
109
+ Memoized like ``downscale_image``, for the same reason (a deck photo
110
+ recurring across slides).
111
+ """
112
+ if radius_cm <= 0 or frame_w_cm <= 0:
113
+ return path
114
+ return _round_image_corners_cached(path, round(radius_cm, 3), round(frame_w_cm, 2))
115
+
116
+
117
+ @functools.lru_cache(maxsize=256)
118
+ def _round_image_corners_cached(path: str, radius_cm: float, frame_w_cm: float) -> str:
119
+ try:
120
+ from PIL import Image, ImageChops, ImageDraw
121
+ except ImportError:
122
+ return path
123
+ try:
124
+ with Image.open(path) as im:
125
+ had_alpha = im.mode in ("RGBA", "LA")
126
+ im = im.convert("RGBA")
127
+ radius_px = max(1, round(radius_cm / frame_w_cm * im.width))
128
+ mask = Image.new("L", im.size, 0)
129
+ ImageDraw.Draw(mask).rounded_rectangle(
130
+ [0, 0, im.width - 1, im.height - 1], radius=radius_px, fill=255,
131
+ )
132
+ alpha = ImageChops.multiply(mask, im.getchannel("A")) if had_alpha else mask
133
+ im.putalpha(alpha)
134
+ fd, out_path = tempfile.mkstemp(suffix=".png")
135
+ os.close(fd)
136
+ im.save(out_path)
137
+ return out_path
138
+ except Exception:
139
+ return path
140
+
141
+
142
+ def is_uniform_tint(gradient: list[tuple[float, str, float]] | None) -> bool:
143
+ """A CSS ``background-image`` gradient layer that is the *same* color
144
+ and alpha at every stop (``linear-gradient(var(--bg-overlay), var(--bg-overlay))``,
145
+ used by every non-title section in this deck's themes) is really a flat
146
+ tint, not a gradient -- it can be pre-composited into one flattened
147
+ master-page fill per (image, tint) combination and switched per slide
148
+ via ``master_page``, instead of a per-slide alpha-composited overlay
149
+ shape on top of one shared bitmap master. A true multi-stop/angled
150
+ gradient (``.layout-title``'s directional fade) still needs the
151
+ per-slide overlay -- it can't be expressed as a single flat color.
152
+ """
153
+ if not gradient:
154
+ return False
155
+ first_hex, first_alpha = gradient[0][1], gradient[0][2]
156
+ return all(g[1] == first_hex and abs(g[2] - first_alpha) < 0.01 for g in gradient)
157
+
158
+
159
+ def _bitmap_tint_key(style: ResolvedStyle) -> tuple | None:
160
+ """The (image, tint) master key, or None if this style's background
161
+ doesn't qualify for a baked-tint master (no image, or a true multi-
162
+ stop/angled gradient like ``.layout-title``'s that can't be
163
+ flattened to one tint). Single source shared by ``master_for`` and
164
+ ``overlay_needed``/``footer_bar_baked`` so they can't drift."""
165
+ if not (style.bg_image_url and os.path.isfile(style.bg_image_url)):
166
+ return None
167
+ if not is_uniform_tint(style.bg_gradient):
168
+ return None
169
+ tint_hex, tint_alpha = style.bg_gradient[0][1], round(style.bg_gradient[0][2], 3)
170
+ return ("bitmap-tinted", style.bg_image_url, tint_hex, tint_alpha)
171
+
172
+
173
+ def _master_label(key: tuple, tint_hex: str | None) -> str:
174
+ """A descriptive master-page name (shows in Impress's layout panel as
175
+ e.g. "DecktileLight1") instead of a bare counter -- the whole point of
176
+ baking tints into distinct masters is a layout switched per slide,
177
+ which should read as one in the UI, not just in the XML."""
178
+ if key[0] == "bitmap-tinted" and tint_hex:
179
+ r, g, b = (int(tint_hex[i:i + 2], 16) for i in (1, 3, 5))
180
+ luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
181
+ return "Light" if luminance > 0.5 else "Dark"
182
+ if key[0] in ("bitmap", "gradient"):
183
+ return "Gradient"
184
+ return "Solid"
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class _TextKey:
189
+ fg: str
190
+ font_family: str
191
+ font_size_px: float
192
+ weight: str
193
+ italic: bool
194
+ text_align: str
195
+ margin_bottom_px: float = 0.0
196
+ scope: str = "content"
197
+
198
+ @dataclass(frozen=True)
199
+ class _GraphicKey:
200
+ bg: str
201
+ border_color: str
202
+ border_width_px: float
203
+ border_radius_px: float
204
+ text_align: str
205
+ vertical_align: str
206
+ padding: tuple[float, float, float, float]
207
+ scope: str = "content"
208
+
209
+
210
+ class StyleRegistry:
211
+ """Owns every ODF style created for one ODP document."""
212
+
213
+ def __init__(self, document: Document, pygments_style: str = "default") -> None:
214
+ self.pygments_style = pygments_style
215
+ self._doc = document
216
+ self._fonts: set[str] = set()
217
+ self._paragraph: dict[_TextKey, str] = {}
218
+ self._run: dict[_TextKey, str] = {}
219
+ self._graphic: dict[_GraphicKey, str] = {}
220
+ self._table_cells: dict[tuple, str] = {}
221
+ self._table_dims: dict[tuple, str] = {}
222
+ self._list_styles: dict[tuple, str] = {}
223
+ self._overlay: dict[tuple, tuple[str, str | None]] = {}
224
+ self._gradients: dict[tuple, str] = {}
225
+ self._opacities: dict[tuple, str] = {}
226
+ self._masters: dict[tuple, str] = {}
227
+ self._master_objects: dict[str, StyleMasterPage] = {}
228
+ self._tint_baked_keys: set[tuple] = set()
229
+ self._footer_baked: dict[tuple, bool] = {}
230
+ self._label_counters: dict[str, int] = {}
231
+ self._pil_ok: bool | None = None
232
+ self._page_layout_name: str | None = None
233
+ self._notes_style_name: str | None = None
234
+ self._page_w: float = 0.0
235
+ self._page_h: float = 0.0
236
+ self._counter = count(1)
237
+
238
+ # ---- fonts ----
239
+
240
+ def ensure_font(self, family: str, mono: bool = False) -> None:
241
+ if not family or family in self._fonts:
242
+ return
243
+ self._fonts.add(family)
244
+ face = Style(
245
+ "font-face", font_name=family, font_family=family,
246
+ font_family_generic="modern" if mono else "swiss",
247
+ font_pitch="fixed" if mono else "variable",
248
+ )
249
+ self._doc.insert_style(face)
250
+ self._doc.insert_style(face.clone, default=True)
251
+
252
+ def ensure_lucide_font(self) -> str:
253
+ """Embeds the vendored Lucide icon font into the ODP package (once)
254
+ and declares it with its own ``style:font-face-src`` -- unlike a
255
+ system font referenced by name, this one must travel with the
256
+ document or every icon falls back to tofu on a machine that
257
+ doesn't have it installed (verified: not present on this one).
258
+ Returns the font family name to use in run styles."""
259
+ name = "lucide"
260
+ if name in self._fonts:
261
+ return name
262
+ self._fonts.add(name)
263
+ from decktile.plugins.lucide_font import FONT_PATH
264
+
265
+ manifest = self._doc.manifest
266
+ if manifest.get_media_type("Fonts/") is None:
267
+ manifest.add_full_path("Fonts/")
268
+ font_bytes = FONT_PATH.read_bytes()
269
+ self._doc.container.set_part("Fonts/lucide.ttf", font_bytes)
270
+ manifest.add_full_path("Fonts/lucide.ttf", "application/x-font-ttf")
271
+
272
+ font_face = Element.from_tag("style:font-face")
273
+ font_face.set_attribute("style:name", name)
274
+ font_face.set_attribute("svg:font-family", name)
275
+ src = Element.from_tag("svg:font-face-src")
276
+ uri = Element.from_tag("svg:font-face-uri")
277
+ uri.set_attribute("xlink:href", "Fonts/lucide.ttf")
278
+ uri.set_attribute("xlink:type", "simple")
279
+ fmt = Element.from_tag("svg:font-face-format")
280
+ fmt.set_attribute("svg:string", "truetype")
281
+ uri.append(fmt)
282
+ src.append(uri)
283
+ font_face.append(src)
284
+ self._doc.styles.get_element("office:font-face-decls").append(font_face)
285
+ return name
286
+
287
+ def notes_style(self) -> str:
288
+ """A minimal ``style:family="presentation"`` style for the speaker
289
+ notes placeholder frame. Required, not cosmetic: verified
290
+ empirically via an LO import/export roundtrip that a notes-page
291
+ ``draw:frame`` with no ``presentation:style-name`` is silently
292
+ discarded on import (replaced with LO's own blank placeholder,
293
+ i.e. the speaker notes vanish the moment anyone opens the file
294
+ in Impress) -- an otherwise-identical frame with this style-name
295
+ set survives the roundtrip with its text intact."""
296
+ if self._notes_style_name is not None:
297
+ return self._notes_style_name
298
+ name = "DecktileNotesPr"
299
+ s = Element.from_tag("style:style")
300
+ s.set_attribute("style:name", name)
301
+ s.set_attribute("style:family", "presentation")
302
+ self._doc.insert_style(s, automatic=True)
303
+ self._notes_style_name = name
304
+ return name
305
+
306
+
307
+ # ---- text (paragraph default + inline run) styles ----
308
+
309
+ def paragraph_style(self, style: ResolvedStyle, *, mono: bool = False,
310
+ margin_bottom_px: float = 0.0, scope: str = "content") -> str:
311
+ """``scope="content"`` (default): an automatic style in content.xml
312
+ -- verified empirically: Impress's draw:text-box only resolves
313
+ text:p/text:span against automatic styles there, silently
314
+ ignoring an identical common (styles.xml) style (same family of
315
+ pitfall as the table-cell/master-background ones). ``scope=
316
+ "styles"``: the inverse pitfall, for a shape placed directly on a
317
+ ``style:master-page`` (styles.xml) -- content.xml's automatic
318
+ styles aren't in scope there either, and resolve to nothing
319
+ (verified: LibreOffice silently falls back to a default serif
320
+ font/black text instead of erroring) -- register into styles.xml's
321
+ own automatic-styles section instead.
322
+ """
323
+ key = _TextKey(style.fg, style.font_family, style.font_size_px,
324
+ style.font_weight, style.font_italic, style.text_align,
325
+ round(margin_bottom_px, 1), scope)
326
+ cached = self._paragraph.get(key)
327
+ if cached:
328
+ return cached
329
+ self.ensure_font(style.font_family, mono=mono)
330
+ name = f"DecktileP{next(self._counter)}"
331
+ s = Style("paragraph", name=name)
332
+ s.set_properties(area="paragraph", align=style.text_align)
333
+ if margin_bottom_px:
334
+ s.set_properties({"fo:margin-bottom": px_to_cm(margin_bottom_px)}, area="paragraph")
335
+ s.set_properties(
336
+ area="text", color=style.fg, font=style.font_family,
337
+ font_family=style.font_family, family_generic="modern" if mono else "swiss",
338
+ size=px_to_pt(style.font_size_px), weight=style.font_weight,
339
+ font_style="italic" if style.font_italic else "normal",
340
+ )
341
+ if scope == "styles":
342
+ self._doc.styles.get_element("office:automatic-styles").append(s)
343
+ else:
344
+ self._doc.insert_style(s, automatic=True)
345
+ self._paragraph[key] = name
346
+ return name
347
+
348
+ def run_style(self, fg: str, font_family: str, font_size_px: float,
349
+ bold: bool, italic: bool, *, mono: bool = False, scope: str = "content") -> str:
350
+ key = _TextKey(fg, font_family, font_size_px,
351
+ "bold" if bold else "normal", italic, "", scope=scope)
352
+ cached = self._run.get(key)
353
+ if cached:
354
+ return cached
355
+ self.ensure_font(font_family, mono=mono)
356
+ name = f"DecktileT{next(self._counter)}"
357
+ s = Style("text", name=name, bold=bold, italic=italic)
358
+ s.set_properties(
359
+ area="text", color=fg, font=font_family, font_family=font_family,
360
+ family_generic="modern" if mono else "swiss", size=px_to_pt(font_size_px),
361
+ )
362
+ if scope == "styles":
363
+ self._doc.styles.get_element("office:automatic-styles").append(s)
364
+ else:
365
+ self._doc.insert_style(s, automatic=True)
366
+ self._run[key] = name
367
+ return name
368
+
369
+ def list_style(self, color: str, font_family: str) -> str:
370
+ """Bullet list style matching base.css's ``ul { list-style: var(--list-style) }``
371
+ (bullet char U+25B8) and ``li::marker { color: var(--color-accent) }``.
372
+ Impress ignores ``text:list-level-style-bullet/@text:style-name`` for
373
+ the bullet glyph's color (verified empirically against real output);
374
+ an inline ``style:text-properties`` child is what it actually honors.
375
+ """
376
+ key = (color, font_family)
377
+ cached = self._list_styles.get(key)
378
+ if cached:
379
+ return cached
380
+ self.ensure_font(font_family)
381
+ name = f"DecktileL{next(self._counter)}"
382
+ s = Style("list", name=name)
383
+ level = s.set_level_style(1, bullet_char="\u25b8")
384
+ if level is not None:
385
+ props = Element.from_tag("style:list-level-properties")
386
+ props.set_attribute("text:space-before", "0cm")
387
+ props.set_attribute("text:min-label-width", "0.5cm")
388
+ level.append(props)
389
+ tprops = Element.from_tag("style:text-properties")
390
+ tprops.set_attribute("fo:color", color)
391
+ tprops.set_attribute("style:font-name", font_family)
392
+ level.append(tprops)
393
+ self._doc.insert_style(s, automatic=True)
394
+ self._list_styles[key] = name
395
+ return name
396
+
397
+ def table_cell_style(self, bg: str, border_color: str, border_width_px: float) -> str:
398
+ """Impress tables are *draw* tables, not spreadsheet tables: a cell's
399
+ fill is read from style:graphic-properties (draw:fill/draw:fill-color),
400
+ not style:table-cell-properties (fo:background-color) -- verified
401
+ against real LibreOffice output; the Writer/Calc-style property is
402
+ silently ignored by Impress and the default blue banding shows
403
+ through instead. Must be an automatic (content.xml) style; odfdo's
404
+ get_style('table-cell', ...) only resolves there for Impress tables.
405
+ """
406
+ key = (bg, border_color, round(border_width_px, 1))
407
+ cached = self._table_cells.get(key)
408
+ if cached:
409
+ return cached
410
+ name = f"DecktileTC{next(self._counter)}"
411
+ s = Style("table-cell", name=name)
412
+ s.set_properties(
413
+ {"draw:fill": "solid" if bg else "none", "draw:fill-color": bg or "#ffffff",
414
+ "draw:textarea-vertical-align": "middle"},
415
+ area="graphic",
416
+ )
417
+ s.set_properties(
418
+ {"fo:border": f"{px_to_pt(border_width_px) if border_width_px else '0.5pt'} "
419
+ f"solid {border_color or '#cccccc'}"},
420
+ area="paragraph",
421
+ )
422
+ self._doc.insert_style(s, automatic=True)
423
+ self._table_cells[key] = name
424
+ return name
425
+
426
+ def table_column_style(self, width_cm: float) -> str:
427
+ """Explicit column width -- without it Impress tables fall back to
428
+ a fixed per-column default, ignoring the frame's own measured
429
+ width/font sizes and rendering noticeably larger than the HTML.
430
+ ``width``/``height`` for table-column/table-row only map to
431
+ ``style:column-width``/``style:row-height`` as ``Style()``
432
+ constructor kwargs (``Style.__init__``), not via a later
433
+ ``set_properties()`` call -- verified against odfdo source.
434
+ """
435
+ key = round(width_cm, 3)
436
+ cached = self._table_dims.get(("col", key))
437
+ if cached:
438
+ return cached
439
+ name = f"DecktileTCW{next(self._counter)}"
440
+ s = Style("table-column", name=name, width=f"{width_cm:.3f}cm")
441
+ self._doc.insert_style(s, automatic=True)
442
+ self._table_dims[("col", key)] = name
443
+ return name
444
+
445
+ def table_row_style(self, height_cm: float) -> str:
446
+ key = round(height_cm, 3)
447
+ cached = self._table_dims.get(("row", key))
448
+ if cached:
449
+ return cached
450
+ name = f"DecktileTRH{next(self._counter)}"
451
+ s = Style("table-row", name=name, height=f"{height_cm:.3f}cm")
452
+ self._doc.insert_style(s, automatic=True)
453
+ self._table_dims[("row", key)] = name
454
+ return name
455
+
456
+ # ---- graphic (frame fill/border/padding) ----
457
+
458
+ def graphic_style(self, style: ResolvedStyle, *, fill: bool = True,
459
+ vertical_align: str = "top", border: bool = True) -> str:
460
+ padding = (style.padding_top, style.padding_right, style.padding_bottom, style.padding_left)
461
+ key = _GraphicKey(
462
+ style.bg if fill else "", style.border_color if border else "",
463
+ style.border_width if border else 0.0,
464
+ style.border_radius, style.text_align, vertical_align, padding,
465
+ )
466
+ cached = self._graphic.get(key)
467
+ if cached:
468
+ return cached
469
+ name = f"DecktileG{next(self._counter)}"
470
+ s = Style("graphic", name=name)
471
+ props: dict[str, str] = {
472
+ "draw:textarea-vertical-align": vertical_align,
473
+ # LibreOffice's text engine can wrap a few percent wider than
474
+ # weasyprint measured (font substitution/metrics differences);
475
+ # auto-grow-height let that push the frame past its measured
476
+ # box and collide with the next sibling (fixed-position frames
477
+ # don't reflow). shrink-to-fit keeps every frame at its
478
+ # weasyprint-measured size and scales text down instead,
479
+ # verified empirically to eliminate the overlap.
480
+ "draw:auto-grow-height": "false",
481
+ "style:shrink-to-fit": "true",
482
+ "fo:padding-top": px_to_cm(style.padding_top),
483
+ "fo:padding-right": px_to_cm(style.padding_right),
484
+ "fo:padding-bottom": px_to_cm(style.padding_bottom),
485
+ "fo:padding-left": px_to_cm(style.padding_left),
486
+ }
487
+ if fill and style.bg:
488
+ props["draw:fill"] = "solid"
489
+ props["draw:fill-color"] = style.bg
490
+ else:
491
+ props["draw:fill"] = "none"
492
+ if border and style.border_color and style.border_width > 0:
493
+ props["draw:stroke"] = "solid"
494
+ props["svg:stroke-color"] = style.border_color
495
+ props["svg:stroke-width"] = px_to_cm(style.border_width)
496
+ else:
497
+ props["draw:stroke"] = "none"
498
+ # draw:corner-radius is a draw:rect/frame element attribute in ODF,
499
+ # not a style:graphic-properties one (odfdo's whitelist agrees --
500
+ # confirmed it silently drops it here); shapes.py sets it directly
501
+ # on the Frame/RectangleShape it builds, keyed off style.border_radius.
502
+ s.set_properties(props, area="graphic")
503
+ s.set_properties(area="paragraph", align=style.text_align)
504
+ self._doc.insert_style(s)
505
+ self._graphic[key] = name
506
+ return name
507
+
508
+ # ---- family-less raw styles: draw:gradient / draw:opacity ----
509
+ # odfdo has no Style class for either family (Style("gradient"/"opacity", ...)
510
+ # raises "Unknown family value") -- both are built as raw XML and appended
511
+ # directly to office:styles, same escape hatch insert_style(str) documents.
512
+
513
+ def gradient_name(self, stops: list[tuple[float, str, float]], angle_deg: float) -> str:
514
+ key = (tuple(stops), round(angle_deg))
515
+ cached = self._gradients.get(key)
516
+ if cached:
517
+ return cached
518
+ name = f"DecktileGrad{next(self._counter)}"
519
+ start_hex, end_hex = stops[0][1], stops[-1][1]
520
+ odf_deg = css_angle_to_odf(angle_deg, self._page_w, self._page_h)
521
+ # draw:start-color/end-color are the base-ODF 2-stop fallback;
522
+ # loext:gradient-stop children (a LibreOffice extension, verified
523
+ # rendering every stop) carry the full N-stop gradient so a middle
524
+ # stop's hue (e.g. kcd_vietnam's #7a0504 red) isn't lost.
525
+ stop_xml = "".join(
526
+ f'<loext:gradient-stop svg:offset="{offset:.4f}" '
527
+ f'loext:color-type="rgb" loext:color-value="{hex_color}"/>'
528
+ for offset, hex_color, _alpha in stops
529
+ )
530
+ el = Element.from_tag(
531
+ f'<draw:gradient draw:name="{name}" draw:style="linear" '
532
+ f'draw:angle="{odf_deg:.0f}deg" draw:start-color="{start_hex}" '
533
+ f'draw:end-color="{end_hex}" draw:start-intensity="100%" '
534
+ f'draw:end-intensity="100%" draw:border="0%">{stop_xml}</draw:gradient>'
535
+ )
536
+ self._doc.styles.get_element("office:styles").append(el)
537
+ self._gradients[key] = name
538
+ return name
539
+
540
+ def opacity_name(self, stops: list[tuple[float, str, float]], angle_deg: float) -> str | None:
541
+ """Per-axis opacity gradient name, or None if fully opaque throughout.
542
+
543
+ ``draw:opacity``'s ``draw:start``/``draw:end`` are plain opacity
544
+ percentages (0%=fully transparent, 100%=fully opaque) -- confirmed
545
+ empirically against the installed LibreOffice via a solid-color
546
+ corner-sample test (0/25/75/100% inputs produced exactly that much
547
+ of the foreground color blended in): the direct CSS-alpha sense,
548
+ not inverted. A flat run of equal alphas (common: kubecon_japan's
549
+ gradient is 0.85 throughout) is approximated with ``draw:border``
550
+ holding the start value before the real fade.
551
+ """
552
+ alphas = [a for _, _, a in stops]
553
+ if all(a >= 0.999 for a in alphas):
554
+ return None
555
+ key = (tuple(stops), round(angle_deg))
556
+ cached = self._opacities.get(key)
557
+ if cached:
558
+ return cached
559
+ start_alpha, end_alpha = alphas[0], alphas[-1]
560
+ border_pct = 0.0
561
+ for offset, _, a in stops[1:-1]:
562
+ if abs(a - start_alpha) < 0.05:
563
+ border_pct = offset * 100
564
+ name = f"DecktileOpac{next(self._counter)}"
565
+ odf_deg = css_angle_to_odf(angle_deg, self._page_w, self._page_h)
566
+ el = Element.from_tag(
567
+ f'<draw:opacity draw:name="{name}" draw:style="linear" '
568
+ f'draw:angle="{odf_deg:.0f}deg" '
569
+ f'draw:start="{start_alpha * 100:.0f}%" '
570
+ f'draw:end="{end_alpha * 100:.0f}%" '
571
+ f'draw:border="{border_pct:.0f}%"/>'
572
+ )
573
+ self._doc.styles.get_element("office:styles").append(el)
574
+ self._opacities[key] = name
575
+ return name
576
+
577
+ def overlay_style(self, style: ResolvedStyle) -> tuple[str, str | None]:
578
+ """Graphic style for a full-slide overlay rect (gradient-over-image case)."""
579
+ if style.bg_gradient:
580
+ grad_name = self.gradient_name(style.bg_gradient, style.bg_angle_deg)
581
+ fill_props = {"draw:fill": "gradient", "draw:fill-gradient-name": grad_name}
582
+ opacity = self.opacity_name(style.bg_gradient, style.bg_angle_deg)
583
+ else:
584
+ fill_props = {"draw:fill": "solid", "draw:fill-color": style.bg}
585
+ opacity = None
586
+ key = ("overlay", tuple(sorted(fill_props.items())), opacity)
587
+ cached = self._overlay.get(key)
588
+ if cached:
589
+ return cached
590
+ name = f"DecktileOverlay{next(self._counter)}"
591
+ s = Style("graphic", name=name)
592
+ props = dict(fill_props)
593
+ props["draw:stroke"] = "none"
594
+ if opacity:
595
+ props["draw:opacity-name"] = opacity
596
+ s.set_properties(props, area="graphic")
597
+ self._doc.insert_style(s)
598
+ self._overlay[key] = (name, opacity)
599
+ return name, opacity
600
+
601
+ # ---- page layout + master page (slide/master background) ----
602
+
603
+ def ensure_page_layout(self, width_cm: float, height_cm: float) -> str:
604
+ self._page_w, self._page_h = width_cm, height_cm
605
+ if self._page_layout_name:
606
+ return self._page_layout_name
607
+ name = "DecktilePageLayout"
608
+ pl = StylePageLayout(name=name)
609
+ pl.set_properties(
610
+ page_width=f"{width_cm:.3f}cm", page_height=f"{height_cm:.3f}cm",
611
+ margin_top="0cm", margin_bottom="0cm", margin_left="0cm", margin_right="0cm",
612
+ print_orientation="landscape" if width_cm >= height_cm else "portrait",
613
+ )
614
+ self._doc.insert_style(pl)
615
+ self._page_layout_name = name
616
+ return name
617
+
618
+ def _prepare_bg_image(self, path: str, width_cm: float, height_cm: float) -> str:
619
+ """Downscale an oversized background bitmap before embedding.
620
+
621
+ A bitmap master-page fill is one *style* (deduped), but Impress
622
+ recomposites it fresh for every page that uses it, not once --
623
+ verified empirically: a 10-page subset with a single 4096x2304
624
+ source photo took 24.8s to export, 2.3s with that same master
625
+ painted as a flat color (everything else identical). A multi-
626
+ megapixel source photo turns a one-time decode into a per-slide
627
+ cost multiplied by every slide sharing that master. Cap to 2x the
628
+ slide's own pixel size -- already more than enough for a full-
629
+ bleed background -- and skip entirely if already smaller.
630
+ """
631
+ return downscale_image(path, width_cm, height_cm)
632
+
633
+ def _composite_tint_image(
634
+ self, path: str, hex_color: str, alpha: float, width_cm: float, height_cm: float,
635
+ ) -> tuple[str, bool]:
636
+ """Bakes a uniform overlay tint directly into a downscaled copy of
637
+ the background image -- replaces a per-slide alpha-composited
638
+ overlay shape on top of one shared bitmap master with ONE
639
+ flattened fill per (image, tint) combination, selected per slide
640
+ via ``master_page`` -- a layout switched per slide (light/dark/
641
+ etc.) instead of one image with a universal runtime overlay.
642
+ Returns ``(path, baked)``: ``baked`` is False if compositing
643
+ failed (corrupt/unreadable image), so the caller (``master_for``)
644
+ can record the fallback and ``overlay_needed`` stays the single
645
+ source of truth -- otherwise a page would get neither the baked
646
+ tint nor the per-slide overlay shape.
647
+ """
648
+ downscaled = self._prepare_bg_image(path, width_cm, height_cm)
649
+ try:
650
+ from PIL import Image
651
+ rgb = tuple(int(hex_color[i:i + 2], 16) for i in (1, 3, 5))
652
+ with Image.open(downscaled) as im:
653
+ tint_layer = Image.new("RGB", im.size, rgb)
654
+ composited = Image.blend(im.convert("RGB"), tint_layer, max(0.0, min(1.0, alpha)))
655
+ fd, out_path = tempfile.mkstemp(suffix=".png")
656
+ os.close(fd)
657
+ composited.save(out_path)
658
+ return out_path, True
659
+ except Exception:
660
+ return downscaled, False
661
+
662
+ def _bake_footer_bar(
663
+ self, img_path: str, footer: BoxGeometry, width_cm: float, height_cm: float,
664
+ ) -> str | None:
665
+ """Paints the footer bar directly onto a *copy* of the composited
666
+ master image at its exact weasyprint-measured position, returning
667
+ the new file's path -- never mutates ``img_path`` in place. On the
668
+ ``_composite_tint_image`` failure path (or a source already
669
+ smaller than the downscale target), ``img_path`` can be the
670
+ theme's own original asset file on disk; writing into it would
671
+ permanently corrupt the user's source image.
672
+ ``style:repeat="stretch"`` maps the image linearly across the
673
+ page regardless of its own aspect, so cm -> px uses the image's
674
+ own size, not the original photo's.
675
+ """
676
+ try:
677
+ from PIL import Image, ImageDraw
678
+ rgb = tuple(int(footer.style.bg[i:i + 2], 16) for i in (1, 3, 5))
679
+ alpha = max(0.0, min(1.0, footer.style.bg_alpha))
680
+ with Image.open(img_path) as im:
681
+ im = im.convert("RGB")
682
+ sx, sy = im.width / width_cm, im.height / height_cm
683
+ box = (footer.x * sx, footer.y * sy, (footer.x + footer.w) * sx, (footer.y + footer.h) * sy)
684
+ if alpha >= 0.999:
685
+ ImageDraw.Draw(im).rectangle(box, fill=rgb)
686
+ else:
687
+ overlay = Image.new("RGB", im.size, rgb)
688
+ mask = Image.new("L", im.size, 0)
689
+ ImageDraw.Draw(mask).rectangle(box, fill=round(alpha * 255))
690
+ im = Image.composite(overlay, im, mask)
691
+ fd, out_path = tempfile.mkstemp(suffix=".png")
692
+ os.close(fd)
693
+ im.save(out_path)
694
+ return out_path
695
+ except Exception:
696
+ return None
697
+
698
+ def _pil_available(self) -> bool:
699
+ if self._pil_ok is None:
700
+ try:
701
+ import PIL # noqa: F401
702
+ self._pil_ok = True
703
+ except ImportError:
704
+ self._pil_ok = False
705
+ return self._pil_ok
706
+
707
+ def _next_master_name(self, label: str) -> str:
708
+ n = self._label_counters.get(label, 0) + 1
709
+ self._label_counters[label] = n
710
+ return label if n == 1 else f"{label} {n}"
711
+
712
+ def overlay_needed(self, style: ResolvedStyle) -> bool:
713
+ """Single source of truth for whether a separate per-slide overlay
714
+ shape is still needed -- false whenever ``master_for`` already
715
+ baked this exact (image, tint) into the master's own bitmap fill.
716
+ Checked against the *recorded* outcome, not re-derived independently
717
+ (a failed composite would otherwise disagree with itself: no
718
+ overlay AND an untinted master)."""
719
+ bg_image_ok = bool(style.bg_image_url) and os.path.isfile(style.bg_image_url)
720
+ key = _bitmap_tint_key(style)
721
+ if key is None:
722
+ return bg_image_ok
723
+ return key not in self._tint_baked_keys
724
+
725
+ def footer_bar_baked(self, style: ResolvedStyle) -> bool:
726
+ """Single source of truth for whether the footer bar is already
727
+ painted into this style's master -- the per-slide footer frame
728
+ must then render text only (no fill), or it double-paints."""
729
+ key = _bitmap_tint_key(style)
730
+ return key is not None and self._footer_baked.get(key, False)
731
+
732
+ def master_page_element(self, name: str) -> StyleMasterPage:
733
+ return self._master_objects[name]
734
+
735
+ def master_for(
736
+ self, style: ResolvedStyle, width_cm: float, height_cm: float,
737
+ add_file: Callable[[str], str], footer: BoxGeometry | None = None,
738
+ ) -> tuple[str, bool]:
739
+ """Dedup'd master page for a slide's base background (solid/gradient/bitmap).
740
+
741
+ A uniform overlay tint (every theme's light/dark section
742
+ background -- ``is_uniform_tint``) is baked directly into the
743
+ master's own bitmap fill: one master per (image, tint) pair,
744
+ switched per slide via ``master_page`` -- "light" and "dark" are
745
+ distinct master-page layouts, not one shared image recomposited
746
+ with a universal per-slide overlay shape. Only a true multi-stop/
747
+ angled gradient (``.layout-title``'s directional fade) still
748
+ needs a separate per-slide overlay -- ``overlay_needed`` is the
749
+ single source of truth for that. An opaque ``footer`` bar (the
750
+ section's resolved footer box, if any) is baked in the same way.
751
+
752
+ Returns ``(name, is_new)``: ``is_new`` is True only the first
753
+ time this exact key is built -- the caller uses it to append
754
+ logo/watermark/footer-text/page-number shapes to the master
755
+ element (``master_page_element``) exactly once per master,
756
+ instead of as a separate shape drawn on every single slide.
757
+ """
758
+ pl_name = self.ensure_page_layout(width_cm, height_cm)
759
+ bg_image_ok = bool(style.bg_image_url) and os.path.isfile(style.bg_image_url)
760
+ tint_key = _bitmap_tint_key(style) if self._pil_available() else None
761
+ tint_baked = tint_key is not None
762
+ if tint_baked:
763
+ key = tint_key
764
+ self._tint_baked_keys.add(key)
765
+ elif bg_image_ok:
766
+ key = ("bitmap", style.bg_image_url)
767
+ elif style.bg_gradient:
768
+ key = ("gradient", tuple(style.bg_gradient), round(style.bg_angle_deg))
769
+ else:
770
+ key = ("solid", style.bg)
771
+ cached = self._masters.get(key)
772
+ if cached:
773
+ return cached, False
774
+
775
+ dp_name = f"DecktileBgDp{next(self._counter)}"
776
+ dp = Style("drawing-page", name=dp_name)
777
+ if key[0] in ("bitmap", "bitmap-tinted"):
778
+ if tint_baked:
779
+ img_path, _ = self._composite_tint_image(
780
+ style.bg_image_url, tint_key[2], tint_key[3], width_cm, height_cm,
781
+ )
782
+ footer_baked = False
783
+ if footer is not None and footer.style.bg_alpha > 0:
784
+ baked_path = self._bake_footer_bar(img_path, footer, width_cm, height_cm)
785
+ if baked_path is not None:
786
+ img_path, footer_baked = baked_path, True
787
+ self._footer_baked[key] = footer_baked
788
+ else:
789
+ img_path = self._prepare_bg_image(style.bg_image_url, width_cm, height_cm)
790
+ uri = add_file(img_path)
791
+ img_name = f"DecktileBgImg{next(self._counter)}"
792
+ # draw:fill-image is family-less, same as draw:gradient/
793
+ # draw:opacity -- append to styles.xml office:styles (same
794
+ # container, same resolution scope as the gradient path,
795
+ # confirmed working) rather than insert_style(automatic=True),
796
+ # which would place it in content.xml where this master's
797
+ # styles.xml drawing-page style couldn't resolve it.
798
+ img = DrawFillImage(name=img_name, url=uri)
799
+ self._doc.styles.get_element("office:styles").append(img)
800
+ dp.set_properties(
801
+ {"draw:fill": "bitmap", "draw:fill-image-name": img_name,
802
+ "style:repeat": "stretch"},
803
+ area="drawing-page",
804
+ )
805
+ elif key[0] == "gradient":
806
+ grad_name = self.gradient_name(style.bg_gradient, style.bg_angle_deg)
807
+ dp.set_properties(
808
+ {"draw:fill": "gradient", "draw:fill-gradient-name": grad_name},
809
+ area="drawing-page",
810
+ )
811
+ else:
812
+ dp.set_properties(
813
+ {"draw:fill": "solid", "draw:fill-color": key[1] or "#ffffff"},
814
+ area="drawing-page",
815
+ )
816
+ # Master backgrounds must live in styles.xml automatic-styles directly:
817
+ # insert_style(automatic=True) targets content.xml, but a master-page's
818
+ # draw_style_name only resolves drawing-page styles from styles.xml
819
+ # automatic-styles (odfdo pitfall, verified against document.py/styles.py).
820
+ self._doc.styles.get_element("office:automatic-styles").append(dp)
821
+
822
+ label = _master_label(key, tint_key[2] if tint_baked else None)
823
+ master_name = self._next_master_name(label)
824
+ mp = StyleMasterPage(name=master_name, page_layout=pl_name, draw_style_name=dp_name)
825
+ self._doc.insert_style(mp)
826
+ self._masters[key] = master_name
827
+ self._master_objects[master_name] = mp
828
+ return master_name, True