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,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
|