engrapha-notes 0.1.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.
- engrapha_notes/__init__.py +236 -0
- engrapha_notes/cli.py +883 -0
- engrapha_notes/document.py +2691 -0
- engrapha_notes/helpers.py +4386 -0
- engrapha_notes/packet.py +282 -0
- engrapha_notes/palette.py +61 -0
- engrapha_notes/styles.py +28 -0
- engrapha_notes/theme.py +348 -0
- engrapha_notes-0.1.0.dist-info/METADATA +530 -0
- engrapha_notes-0.1.0.dist-info/RECORD +13 -0
- engrapha_notes-0.1.0.dist-info/WHEEL +5 -0
- engrapha_notes-0.1.0.dist-info/entry_points.txt +3 -0
- engrapha_notes-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2691 @@
|
|
|
1
|
+
"""
|
|
2
|
+
engrapha_notes.document -- Document building utilities.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
- page_decor(): backward-compatible dummy page decoration.
|
|
6
|
+
- build_doc(): build the story into a PDF with A4 layout and ThemedCanvas.
|
|
7
|
+
- ThemedCanvas: custom canvas that draws background and page number dynamically.
|
|
8
|
+
- CW, PM, PAGE_W, PAGE_H: standard layout constants.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
from html.parser import HTMLParser
|
|
15
|
+
from typing import Any, TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .theme import NotesTheme
|
|
19
|
+
|
|
20
|
+
from reportlab.lib.pagesizes import A4
|
|
21
|
+
from reportlab.lib.units import cm
|
|
22
|
+
from reportlab.pdfgen.canvas import Canvas
|
|
23
|
+
from reportlab.platypus import BaseDocTemplate, SimpleDocTemplate, Flowable, Paragraph
|
|
24
|
+
|
|
25
|
+
# Monkey-patch Paragraph.wrap to resolve cross-references dynamically during layout passes
|
|
26
|
+
original_paragraph_wrap = Paragraph.wrap
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def patched_paragraph_wrap(
|
|
30
|
+
self: Paragraph, aW: float, aH: float
|
|
31
|
+
) -> tuple[float, float]:
|
|
32
|
+
if hasattr(self, "text") and isinstance(self.text, str) and "__REF_" in self.text:
|
|
33
|
+
from .helpers import _labels
|
|
34
|
+
import re
|
|
35
|
+
|
|
36
|
+
def repl(match: re.Match) -> str:
|
|
37
|
+
ref_id = match.group(1)
|
|
38
|
+
return str(_labels.get(ref_id, "??"))
|
|
39
|
+
|
|
40
|
+
new_text = re.sub(r"__REF_([a-zA-Z0-9_\-]+)__", repl, self.text)
|
|
41
|
+
if new_text != self.text:
|
|
42
|
+
self.text = new_text
|
|
43
|
+
self.__init__(self.text, self.style)
|
|
44
|
+
return original_paragraph_wrap(self, aW, aH)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
Paragraph.wrap = patched_paragraph_wrap
|
|
48
|
+
|
|
49
|
+
# Monkey-patch Paragraph.__init__ to dynamically translate $...$ LaTeX math to inline images
|
|
50
|
+
original_paragraph_init = Paragraph.__init__
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def patched_paragraph_init(
|
|
54
|
+
self: Paragraph,
|
|
55
|
+
text: str,
|
|
56
|
+
style: Any = None,
|
|
57
|
+
bulletText: Any = None,
|
|
58
|
+
frags: Any = None,
|
|
59
|
+
caseSensitive: int = 1,
|
|
60
|
+
encoding: str = "utf8",
|
|
61
|
+
) -> None:
|
|
62
|
+
if isinstance(text, str) and "$" in text:
|
|
63
|
+
import re
|
|
64
|
+
|
|
65
|
+
def repl(match: re.Match) -> str:
|
|
66
|
+
math_content = match.group(1)
|
|
67
|
+
try:
|
|
68
|
+
from .helpers import formula
|
|
69
|
+
|
|
70
|
+
return formula(math_content)
|
|
71
|
+
except Exception:
|
|
72
|
+
return f"${math_content}$"
|
|
73
|
+
|
|
74
|
+
text = re.sub(r"(?<!\\)\$([^\$]+)\$", repl, text)
|
|
75
|
+
text = text.replace(r"\$", "$")
|
|
76
|
+
original_paragraph_init(
|
|
77
|
+
self, text, style, bulletText, frags, caseSensitive, encoding
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
Paragraph.__init__ = patched_paragraph_init
|
|
82
|
+
|
|
83
|
+
PAGE_W, PAGE_H = A4
|
|
84
|
+
PM = 1.8 * cm
|
|
85
|
+
CW = PAGE_W - 2 * PM
|
|
86
|
+
_page_number_offset = 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class PptxHtmlParser(HTMLParser):
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
paragraph,
|
|
93
|
+
default_font_name="Courier New",
|
|
94
|
+
default_font_size=10.0,
|
|
95
|
+
default_color=None,
|
|
96
|
+
):
|
|
97
|
+
super().__init__()
|
|
98
|
+
self.paragraph = paragraph
|
|
99
|
+
self.font_name = default_font_name
|
|
100
|
+
self.font_size = default_font_size
|
|
101
|
+
self.default_color = default_color
|
|
102
|
+
self.style_stack = []
|
|
103
|
+
|
|
104
|
+
def get_current_style(self):
|
|
105
|
+
style = {
|
|
106
|
+
"name": self.font_name,
|
|
107
|
+
"size": self.font_size,
|
|
108
|
+
"bold": False,
|
|
109
|
+
"italic": False,
|
|
110
|
+
"color": self.default_color,
|
|
111
|
+
}
|
|
112
|
+
for s in self.style_stack:
|
|
113
|
+
style.update(s)
|
|
114
|
+
return style
|
|
115
|
+
|
|
116
|
+
def handle_starttag(self, tag, attrs):
|
|
117
|
+
style = {}
|
|
118
|
+
if tag == "b":
|
|
119
|
+
style["bold"] = True
|
|
120
|
+
elif tag == "i":
|
|
121
|
+
style["italic"] = True
|
|
122
|
+
elif tag == "font":
|
|
123
|
+
for name, val in attrs:
|
|
124
|
+
if name == "color":
|
|
125
|
+
style["color"] = val
|
|
126
|
+
elif name == "face":
|
|
127
|
+
style["name"] = val
|
|
128
|
+
elif name == "size":
|
|
129
|
+
if val is not None:
|
|
130
|
+
try:
|
|
131
|
+
style["size"] = float(val)
|
|
132
|
+
except ValueError:
|
|
133
|
+
pass
|
|
134
|
+
elif tag == "br":
|
|
135
|
+
run = self.paragraph.add_run()
|
|
136
|
+
run.text = "\n"
|
|
137
|
+
self.style_stack.append(style)
|
|
138
|
+
|
|
139
|
+
def handle_endtag(self, tag):
|
|
140
|
+
if self.style_stack:
|
|
141
|
+
self.style_stack.pop()
|
|
142
|
+
|
|
143
|
+
def handle_startendtag(self, tag, attrs):
|
|
144
|
+
if tag == "br":
|
|
145
|
+
run = self.paragraph.add_run()
|
|
146
|
+
run.text = "\n"
|
|
147
|
+
elif tag == "img":
|
|
148
|
+
src = next((v for k, v in attrs if k == "src"), None)
|
|
149
|
+
if src:
|
|
150
|
+
from .helpers import math_latex_registry
|
|
151
|
+
|
|
152
|
+
if src in math_latex_registry:
|
|
153
|
+
latex_str = math_latex_registry[src]
|
|
154
|
+
from pptx.util import Pt
|
|
155
|
+
from pptx.dml.color import RGBColor
|
|
156
|
+
|
|
157
|
+
style = self.get_current_style()
|
|
158
|
+
run = self.paragraph.add_run()
|
|
159
|
+
run.text = f" {latex_str} "
|
|
160
|
+
run.font.name = style["name"]
|
|
161
|
+
run.font.size = Pt(style["size"])
|
|
162
|
+
run.font.bold = style["bold"]
|
|
163
|
+
run.font.italic = style["italic"]
|
|
164
|
+
|
|
165
|
+
if style["color"]:
|
|
166
|
+
if isinstance(style["color"], str):
|
|
167
|
+
h = style["color"].lstrip("#")
|
|
168
|
+
if len(h) == 6:
|
|
169
|
+
try:
|
|
170
|
+
run.font.color.rgb = RGBColor(
|
|
171
|
+
*(int(h[i : i + 2], 16) for i in (0, 2, 4))
|
|
172
|
+
)
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
else:
|
|
176
|
+
try:
|
|
177
|
+
run.font.color.rgb = style["color"]
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
def handle_data(self, data):
|
|
182
|
+
if not data:
|
|
183
|
+
return
|
|
184
|
+
from pptx.util import Pt
|
|
185
|
+
from pptx.dml.color import RGBColor
|
|
186
|
+
|
|
187
|
+
style = self.get_current_style()
|
|
188
|
+
run = self.paragraph.add_run()
|
|
189
|
+
run.text = data
|
|
190
|
+
run.font.name = style["name"]
|
|
191
|
+
run.font.size = Pt(style["size"])
|
|
192
|
+
run.font.bold = style["bold"]
|
|
193
|
+
run.font.italic = style["italic"]
|
|
194
|
+
|
|
195
|
+
if style["color"]:
|
|
196
|
+
if isinstance(style["color"], str):
|
|
197
|
+
h = style["color"].lstrip("#")
|
|
198
|
+
if len(h) == 6:
|
|
199
|
+
try:
|
|
200
|
+
run.font.color.rgb = RGBColor(
|
|
201
|
+
*(int(h[i : i + 2], 16) for i in (0, 2, 4))
|
|
202
|
+
)
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
else:
|
|
206
|
+
try:
|
|
207
|
+
run.font.color.rgb = style["color"]
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class ThemedCanvas(Canvas): # type: ignore[misc]
|
|
213
|
+
"""
|
|
214
|
+
A custom ReportLab canvas that dynamically draws the page-specific background,
|
|
215
|
+
borders, headers, and page numbers at the end of the page (in showPage).
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
_current_theme: NotesTheme
|
|
219
|
+
_pageNumber: int
|
|
220
|
+
_active_footer: dict[str, Any] | None
|
|
221
|
+
_page_footers: dict[int, dict[str, Any]]
|
|
222
|
+
_page_headers: dict[int, dict[str, Any]]
|
|
223
|
+
_page_double_borders: dict[int, dict[str, Any]]
|
|
224
|
+
_page_number_resets: dict[int, int]
|
|
225
|
+
_page_number_formats: dict[int, str]
|
|
226
|
+
_active_double_border: dict[str, Any] | None
|
|
227
|
+
_active_header: dict[str, Any] | None
|
|
228
|
+
|
|
229
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
230
|
+
super().__init__(*args, **kwargs)
|
|
231
|
+
from .theme import get_theme
|
|
232
|
+
|
|
233
|
+
self._current_theme = get_theme()
|
|
234
|
+
|
|
235
|
+
# Initialize canvas-level footer state from helper global defaults
|
|
236
|
+
from .helpers import global_footer
|
|
237
|
+
|
|
238
|
+
self._active_footer = global_footer
|
|
239
|
+
self._page_footers = {}
|
|
240
|
+
self._page_headers = {}
|
|
241
|
+
self._page_double_borders = {}
|
|
242
|
+
self._page_number_resets = {}
|
|
243
|
+
self._page_number_formats = {}
|
|
244
|
+
self._active_double_border = None
|
|
245
|
+
self._active_header = None
|
|
246
|
+
|
|
247
|
+
def _to_roman(self, val: int) -> str:
|
|
248
|
+
roman_map = [
|
|
249
|
+
(1000, "M"),
|
|
250
|
+
(900, "CM"),
|
|
251
|
+
(500, "D"),
|
|
252
|
+
(400, "CD"),
|
|
253
|
+
(100, "C"),
|
|
254
|
+
(90, "XC"),
|
|
255
|
+
(50, "L"),
|
|
256
|
+
(40, "XL"),
|
|
257
|
+
(10, "X"),
|
|
258
|
+
(9, "IX"),
|
|
259
|
+
(5, "V"),
|
|
260
|
+
(4, "IV"),
|
|
261
|
+
(1, "I"),
|
|
262
|
+
]
|
|
263
|
+
result = []
|
|
264
|
+
for integer, roman in roman_map:
|
|
265
|
+
while val >= integer:
|
|
266
|
+
result.append(roman)
|
|
267
|
+
val -= integer
|
|
268
|
+
return "".join(result).lower()
|
|
269
|
+
|
|
270
|
+
def get_displayed_page_number(self, page_num: int) -> str:
|
|
271
|
+
current_num = 1
|
|
272
|
+
current_format = "arabic"
|
|
273
|
+
for p in range(1, page_num + 1):
|
|
274
|
+
if hasattr(self, "_page_number_resets") and p in self._page_number_resets:
|
|
275
|
+
current_num = self._page_number_resets[p]
|
|
276
|
+
if hasattr(self, "_page_number_formats") and p in self._page_number_formats:
|
|
277
|
+
current_format = self._page_number_formats[p]
|
|
278
|
+
if p == page_num:
|
|
279
|
+
break
|
|
280
|
+
current_num += 1
|
|
281
|
+
|
|
282
|
+
if current_format == "roman":
|
|
283
|
+
return self._to_roman(current_num)
|
|
284
|
+
elif current_format == "none":
|
|
285
|
+
return ""
|
|
286
|
+
else:
|
|
287
|
+
return str(current_num)
|
|
288
|
+
|
|
289
|
+
def showPage(self) -> None:
|
|
290
|
+
# Draw the custom footer on top of everything before closing the page
|
|
291
|
+
self.saveState()
|
|
292
|
+
|
|
293
|
+
page_num = self.getPageNumber() + _page_number_offset
|
|
294
|
+
|
|
295
|
+
# Resolve double border
|
|
296
|
+
border_config = self._page_double_borders.get(page_num)
|
|
297
|
+
if border_config is None:
|
|
298
|
+
border_config = getattr(self, "_active_double_border", None)
|
|
299
|
+
|
|
300
|
+
# Look up footer configuration (page-specific first, then active/global)
|
|
301
|
+
config = self._page_footers.get(page_num)
|
|
302
|
+
if config is None:
|
|
303
|
+
config = getattr(self, "_active_footer", None)
|
|
304
|
+
if config is None:
|
|
305
|
+
from .helpers import global_footer
|
|
306
|
+
|
|
307
|
+
config = global_footer
|
|
308
|
+
|
|
309
|
+
# Resolve header configuration
|
|
310
|
+
header_config = self._page_headers.get(page_num)
|
|
311
|
+
if header_config is None:
|
|
312
|
+
header_config = getattr(self, "_active_header", None)
|
|
313
|
+
if header_config is None:
|
|
314
|
+
from .helpers import global_header
|
|
315
|
+
|
|
316
|
+
header_config = global_header
|
|
317
|
+
|
|
318
|
+
is_border_enabled = False
|
|
319
|
+
b_margin: float = float(self._current_theme.page_border_margin)
|
|
320
|
+
b_gap: float = float(self._current_theme.page_border_gap)
|
|
321
|
+
b_color = self._current_theme.page_border_color or self._current_theme.accent
|
|
322
|
+
|
|
323
|
+
if border_config is not None:
|
|
324
|
+
is_border_enabled = bool(border_config.get("enabled", False))
|
|
325
|
+
cfg_margin = border_config.get("margin")
|
|
326
|
+
if cfg_margin is not None:
|
|
327
|
+
b_margin = float(cfg_margin)
|
|
328
|
+
cfg_gap = border_config.get("gap")
|
|
329
|
+
if cfg_gap is not None:
|
|
330
|
+
b_gap = float(cfg_gap)
|
|
331
|
+
cfg_color = border_config.get("color")
|
|
332
|
+
if cfg_color is not None:
|
|
333
|
+
b_color = cfg_color
|
|
334
|
+
else:
|
|
335
|
+
is_border_enabled = bool(self._current_theme.double_page_border)
|
|
336
|
+
|
|
337
|
+
if is_border_enabled:
|
|
338
|
+
self.saveState()
|
|
339
|
+
self.setStrokeColor(self._current_theme.rl(b_color))
|
|
340
|
+
self.setLineWidth(0.75)
|
|
341
|
+
# Outer box
|
|
342
|
+
self.rect(
|
|
343
|
+
b_margin,
|
|
344
|
+
b_margin,
|
|
345
|
+
PAGE_W - 2 * b_margin,
|
|
346
|
+
PAGE_H - 2 * b_margin,
|
|
347
|
+
stroke=1,
|
|
348
|
+
fill=0,
|
|
349
|
+
)
|
|
350
|
+
# Inner box
|
|
351
|
+
inner_margin: float = b_margin + b_gap
|
|
352
|
+
self.rect(
|
|
353
|
+
inner_margin,
|
|
354
|
+
inner_margin,
|
|
355
|
+
PAGE_W - 2 * inner_margin,
|
|
356
|
+
PAGE_H - 2 * inner_margin,
|
|
357
|
+
stroke=1,
|
|
358
|
+
fill=0,
|
|
359
|
+
)
|
|
360
|
+
self.restoreState()
|
|
361
|
+
|
|
362
|
+
# Look up footer configuration (page-specific first, then active/global)
|
|
363
|
+
config = self._page_footers.get(page_num)
|
|
364
|
+
if config is None:
|
|
365
|
+
config = getattr(self, "_active_footer", None)
|
|
366
|
+
if config is None:
|
|
367
|
+
from .helpers import global_footer
|
|
368
|
+
|
|
369
|
+
config = global_footer
|
|
370
|
+
|
|
371
|
+
left_m = self._current_theme.left_margin
|
|
372
|
+
right_m = self._current_theme.right_margin
|
|
373
|
+
bottom_m = self._current_theme.bottom_margin
|
|
374
|
+
top_m = self._current_theme.top_margin
|
|
375
|
+
|
|
376
|
+
# Resolve header configuration
|
|
377
|
+
header_config = self._page_headers.get(page_num)
|
|
378
|
+
if header_config is None:
|
|
379
|
+
header_config = getattr(self, "_active_header", None)
|
|
380
|
+
if header_config is None:
|
|
381
|
+
from .helpers import global_header
|
|
382
|
+
|
|
383
|
+
header_config = global_header
|
|
384
|
+
|
|
385
|
+
show_header_line = self._current_theme.show_headers
|
|
386
|
+
if header_config:
|
|
387
|
+
if not header_config.get("visible", True):
|
|
388
|
+
show_header_line = False
|
|
389
|
+
elif (
|
|
390
|
+
header_config.get("left")
|
|
391
|
+
or header_config.get("center")
|
|
392
|
+
or header_config.get("right")
|
|
393
|
+
):
|
|
394
|
+
show_header_line = True
|
|
395
|
+
|
|
396
|
+
# Draw header text and line
|
|
397
|
+
if header_config and header_config.get("visible", True):
|
|
398
|
+
self.saveState()
|
|
399
|
+
h_font_name = (
|
|
400
|
+
header_config.get("font_name") or self._current_theme.body_font
|
|
401
|
+
)
|
|
402
|
+
h_font_size = header_config.get("font_size") or 9
|
|
403
|
+
h_text_color = (
|
|
404
|
+
header_config.get("text_color") or self._current_theme.text_dim
|
|
405
|
+
)
|
|
406
|
+
self.setFillColor(self._current_theme.rl(h_text_color))
|
|
407
|
+
self.setFont(h_font_name, h_font_size)
|
|
408
|
+
|
|
409
|
+
h_left = header_config.get("left")
|
|
410
|
+
h_center = header_config.get("center")
|
|
411
|
+
h_right = header_config.get("right")
|
|
412
|
+
|
|
413
|
+
y_mult_val = header_config.get("y_offset")
|
|
414
|
+
y_mult = float(y_mult_val) if y_mult_val is not None else 0.65
|
|
415
|
+
y_header = PAGE_H - top_m * y_mult
|
|
416
|
+
|
|
417
|
+
if h_left is not None:
|
|
418
|
+
self.drawString(left_m, y_header, h_left)
|
|
419
|
+
if h_center is not None:
|
|
420
|
+
self.drawCentredString(PAGE_W / 2, y_header, h_center)
|
|
421
|
+
if h_right is not None:
|
|
422
|
+
self.drawRightString(PAGE_W - right_m, y_header, h_right)
|
|
423
|
+
|
|
424
|
+
self.restoreState()
|
|
425
|
+
|
|
426
|
+
if show_header_line:
|
|
427
|
+
self.saveState()
|
|
428
|
+
hl_color = (
|
|
429
|
+
header_config.get("line_color") or self._current_theme.accent
|
|
430
|
+
if header_config
|
|
431
|
+
else self._current_theme.accent
|
|
432
|
+
)
|
|
433
|
+
hl_width = (
|
|
434
|
+
header_config.get("line_width") or self._current_theme.divider_thickness
|
|
435
|
+
if header_config
|
|
436
|
+
else self._current_theme.divider_thickness
|
|
437
|
+
)
|
|
438
|
+
self.setStrokeColor(self._current_theme.rl(hl_color))
|
|
439
|
+
self.setLineWidth(hl_width)
|
|
440
|
+
line_y_mult_val = (
|
|
441
|
+
header_config.get("line_y_offset") if header_config else None
|
|
442
|
+
)
|
|
443
|
+
line_y_mult = float(line_y_mult_val) if line_y_mult_val is not None else 0.7
|
|
444
|
+
self.line(
|
|
445
|
+
left_m,
|
|
446
|
+
PAGE_H - top_m * line_y_mult,
|
|
447
|
+
PAGE_W - right_m,
|
|
448
|
+
PAGE_H - top_m * line_y_mult,
|
|
449
|
+
)
|
|
450
|
+
self.restoreState()
|
|
451
|
+
|
|
452
|
+
# Determine footnotes presence for footer y-offset adjustment
|
|
453
|
+
footnotes = getattr(self, "_page_footnotes", {}).get(page_num, [])
|
|
454
|
+
|
|
455
|
+
if config.get("visible", True):
|
|
456
|
+
f_font_name = config.get("font_name") or self._current_theme.body_font
|
|
457
|
+
f_font_size = config.get("font_size") or 9
|
|
458
|
+
f_text_color = config.get("text_color") or self._current_theme.text_dim
|
|
459
|
+
self.setFillColor(self._current_theme.rl(f_text_color))
|
|
460
|
+
self.setFont(f_font_name, f_font_size)
|
|
461
|
+
|
|
462
|
+
left = config.get("left")
|
|
463
|
+
center = config.get("center")
|
|
464
|
+
right = config.get("right")
|
|
465
|
+
show_page_num = config.get("show_page_num", True)
|
|
466
|
+
|
|
467
|
+
f_y_mult_val = config.get("y_offset")
|
|
468
|
+
if f_y_mult_val is not None:
|
|
469
|
+
f_y_mult = float(f_y_mult_val)
|
|
470
|
+
else:
|
|
471
|
+
f_y_mult = 0.35 if footnotes else 0.5
|
|
472
|
+
y_footer = bottom_m * f_y_mult
|
|
473
|
+
|
|
474
|
+
# Left-aligned custom text
|
|
475
|
+
if left is not None:
|
|
476
|
+
self.drawString(left_m, y_footer, left)
|
|
477
|
+
|
|
478
|
+
# Centred custom text
|
|
479
|
+
if center is not None:
|
|
480
|
+
self.drawCentredString(PAGE_W / 2, y_footer, center)
|
|
481
|
+
|
|
482
|
+
# Right-aligned custom text / Page number
|
|
483
|
+
right_str = None
|
|
484
|
+
disp_num = self.get_displayed_page_number(page_num)
|
|
485
|
+
if show_page_num and disp_num:
|
|
486
|
+
if right is not None:
|
|
487
|
+
right_str = f"{right} | {disp_num}"
|
|
488
|
+
else:
|
|
489
|
+
right_str = disp_num
|
|
490
|
+
else:
|
|
491
|
+
right_str = right
|
|
492
|
+
|
|
493
|
+
if right_str is not None:
|
|
494
|
+
self.drawRightString(PAGE_W - right_m, y_footer, right_str)
|
|
495
|
+
|
|
496
|
+
# Draw accumulated footnotes if any exist
|
|
497
|
+
if footnotes:
|
|
498
|
+
self.saveState()
|
|
499
|
+
|
|
500
|
+
from reportlab.lib.styles import ParagraphStyle
|
|
501
|
+
from reportlab.platypus import Paragraph
|
|
502
|
+
|
|
503
|
+
fn_style = ParagraphStyle(
|
|
504
|
+
"_footnote_style",
|
|
505
|
+
fontName=self._current_theme.body_font,
|
|
506
|
+
fontSize=8,
|
|
507
|
+
leading=10.5,
|
|
508
|
+
textColor=self._current_theme.rl(self._current_theme.text),
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# Compute footer position to start footnote stack above it
|
|
512
|
+
f_y_mult_val = config.get("y_offset")
|
|
513
|
+
if f_y_mult_val is not None:
|
|
514
|
+
f_y_mult = float(f_y_mult_val)
|
|
515
|
+
else:
|
|
516
|
+
f_y_mult = 0.35 if footnotes else 0.5
|
|
517
|
+
y_footer_calc = bottom_m * f_y_mult
|
|
518
|
+
|
|
519
|
+
w_avail = PAGE_W - left_m - right_m
|
|
520
|
+
y = y_footer_calc + 12 # Start 12 pt above the footer
|
|
521
|
+
|
|
522
|
+
# Render footnotes in reverse order so footnote 1 is at the top of the stack
|
|
523
|
+
fn_paras = []
|
|
524
|
+
for num, text in reversed(footnotes):
|
|
525
|
+
text = self._resolve_refs(text)
|
|
526
|
+
p = Paragraph(f"<b>{num}.</b> {text}", fn_style)
|
|
527
|
+
_, h = p.wrap(w_avail, 1000)
|
|
528
|
+
fn_paras.append((p, h))
|
|
529
|
+
|
|
530
|
+
for p, h in fn_paras:
|
|
531
|
+
p.drawOn(self, left_m, y)
|
|
532
|
+
y += h + 4
|
|
533
|
+
|
|
534
|
+
# Draw divider line 2 pt above the topmost footnote
|
|
535
|
+
line_y = y - 4 + 2
|
|
536
|
+
self.setStrokeColor(self._current_theme.rl(self._current_theme.text_dim))
|
|
537
|
+
self.setLineWidth(0.5)
|
|
538
|
+
self.line(left_m, line_y, left_m + 50, line_y)
|
|
539
|
+
self.restoreState()
|
|
540
|
+
|
|
541
|
+
self.restoreState()
|
|
542
|
+
|
|
543
|
+
super().showPage()
|
|
544
|
+
|
|
545
|
+
def _resolve_refs(self, text: str) -> str:
|
|
546
|
+
if not isinstance(text, str):
|
|
547
|
+
return text
|
|
548
|
+
import re
|
|
549
|
+
from .helpers import _labels
|
|
550
|
+
|
|
551
|
+
def repl(match):
|
|
552
|
+
ref_id = match.group(1)
|
|
553
|
+
return str(_labels.get(ref_id, "??"))
|
|
554
|
+
|
|
555
|
+
return re.sub(r"__REF_([a-zA-Z0-9_\-]+)__", repl, text)
|
|
556
|
+
|
|
557
|
+
def drawString(self, x: float, y: float, text: str, *args: Any, **kwargs: Any) -> None: # type: ignore[override]
|
|
558
|
+
text = self._resolve_refs(text)
|
|
559
|
+
super().drawString(x, y, text, *args, **kwargs)
|
|
560
|
+
|
|
561
|
+
def drawCentredString(self, x: float, y: float, text: str, *args: Any, **kwargs: Any) -> None: # type: ignore[override]
|
|
562
|
+
text = self._resolve_refs(text)
|
|
563
|
+
super().drawCentredString(x, y, text, *args, **kwargs)
|
|
564
|
+
|
|
565
|
+
def drawRightString(self, x: float, y: float, text: str, *args: Any, **kwargs: Any) -> None: # type: ignore[override]
|
|
566
|
+
text = self._resolve_refs(text)
|
|
567
|
+
super().drawRightString(x, y, text, *args, **kwargs)
|
|
568
|
+
|
|
569
|
+
def drawText(self, textobj: Any, *args: Any, **kwargs: Any) -> None:
|
|
570
|
+
if hasattr(textobj, "_code"):
|
|
571
|
+
new_code = []
|
|
572
|
+
for op in textobj._code:
|
|
573
|
+
if isinstance(op, str) and "__REF_" in op:
|
|
574
|
+
op = self._resolve_refs(op)
|
|
575
|
+
new_code.append(op)
|
|
576
|
+
textobj._code = new_code
|
|
577
|
+
super().drawText(textobj, *args, **kwargs)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
class NotesDocTemplate(SimpleDocTemplate): # type: ignore[misc]
|
|
581
|
+
def afterFlowable(self, flowable: Any) -> None:
|
|
582
|
+
def process(f: Any) -> None:
|
|
583
|
+
if f.__class__.__name__ == "Bookmark":
|
|
584
|
+
self.notify("TOCEntry", (f.level, f.title, self.page, f.key))
|
|
585
|
+
elif f.__class__.__name__ == "KeepTogether":
|
|
586
|
+
for child in getattr(f, "_flowables", []):
|
|
587
|
+
process(child)
|
|
588
|
+
|
|
589
|
+
process(flowable)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def page_decor(canvas: Canvas, doc: BaseDocTemplate) -> None:
|
|
593
|
+
"""Draw the page background dynamically at the start of the page using the active theme."""
|
|
594
|
+
theme = getattr(canvas, "_current_theme", None)
|
|
595
|
+
if theme is None:
|
|
596
|
+
from .theme import get_theme
|
|
597
|
+
|
|
598
|
+
theme = get_theme()
|
|
599
|
+
setattr(canvas, "_current_theme", theme)
|
|
600
|
+
|
|
601
|
+
canvas.saveState()
|
|
602
|
+
canvas.setFillColor(theme.rl(theme.bg))
|
|
603
|
+
canvas.rect(0, 0, PAGE_W, PAGE_H, stroke=0, fill=1)
|
|
604
|
+
canvas.restoreState()
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def build_doc(
|
|
608
|
+
filename: Any,
|
|
609
|
+
story: list[Any] | None = None,
|
|
610
|
+
title: str | None = None,
|
|
611
|
+
author: str | None = None,
|
|
612
|
+
) -> None:
|
|
613
|
+
"""
|
|
614
|
+
Build the story into an A4 PDF with the dynamic ThemedCanvas.
|
|
615
|
+
|
|
616
|
+
filename: output PDF path.
|
|
617
|
+
story: Flowable list. If None, uses engrapha_notes.helpers.story.
|
|
618
|
+
title: optional document title metadata.
|
|
619
|
+
author: optional document author metadata.
|
|
620
|
+
"""
|
|
621
|
+
from .helpers import story as default_story
|
|
622
|
+
from .theme import get_theme
|
|
623
|
+
from . import helpers
|
|
624
|
+
import os
|
|
625
|
+
|
|
626
|
+
# Clear multi-pass global states in helpers to avoid pollution across runs
|
|
627
|
+
helpers._labels.clear()
|
|
628
|
+
helpers._index_entries.clear()
|
|
629
|
+
helpers._footnote_counter = 0
|
|
630
|
+
|
|
631
|
+
if story is None:
|
|
632
|
+
story = default_story
|
|
633
|
+
|
|
634
|
+
# Reset leftover ReportLab postponement states to prevent LayoutError in split/consecutive builds
|
|
635
|
+
def _clear_postponed(f_item: Any) -> None:
|
|
636
|
+
if hasattr(f_item, "_postponed"):
|
|
637
|
+
try:
|
|
638
|
+
delattr(f_item, "_postponed")
|
|
639
|
+
except Exception:
|
|
640
|
+
pass
|
|
641
|
+
# Clean children in common containers
|
|
642
|
+
if hasattr(f_item, "_flowables"):
|
|
643
|
+
for child in getattr(f_item, "_flowables", []):
|
|
644
|
+
_clear_postponed(child)
|
|
645
|
+
if hasattr(f_item, "_cellvalues"):
|
|
646
|
+
for row in getattr(f_item, "_cellvalues", []):
|
|
647
|
+
for cell in row:
|
|
648
|
+
if isinstance(cell, list):
|
|
649
|
+
for sub_f in cell:
|
|
650
|
+
_clear_postponed(sub_f)
|
|
651
|
+
elif cell:
|
|
652
|
+
_clear_postponed(cell)
|
|
653
|
+
|
|
654
|
+
for item in story:
|
|
655
|
+
_clear_postponed(item)
|
|
656
|
+
|
|
657
|
+
# Post-process story to automatically wrap headings and their next content in KeepTogether
|
|
658
|
+
from reportlab.platypus import KeepTogether, HRFlowable
|
|
659
|
+
|
|
660
|
+
processed_story = []
|
|
661
|
+
i = 0
|
|
662
|
+
n = len(story)
|
|
663
|
+
while i < n:
|
|
664
|
+
item = story[i]
|
|
665
|
+
is_heading = (
|
|
666
|
+
getattr(item, "_is_section", False)
|
|
667
|
+
or getattr(item, "_is_subsection", False)
|
|
668
|
+
or getattr(item, "_is_chap_box", False)
|
|
669
|
+
)
|
|
670
|
+
if is_heading:
|
|
671
|
+
group = [item]
|
|
672
|
+
i += 1
|
|
673
|
+
if i < n and isinstance(story[i], HRFlowable):
|
|
674
|
+
group.append(story[i])
|
|
675
|
+
i += 1
|
|
676
|
+
while i < n:
|
|
677
|
+
next_item = story[i]
|
|
678
|
+
next_class = next_item.__class__.__name__
|
|
679
|
+
if next_class in (
|
|
680
|
+
"Bookmark",
|
|
681
|
+
"Spacer",
|
|
682
|
+
"LabelFlowable",
|
|
683
|
+
"FootnoteRegisterFlowable",
|
|
684
|
+
"IndexEntryFlowable",
|
|
685
|
+
"ThemeSetterFlowable",
|
|
686
|
+
):
|
|
687
|
+
group.append(next_item)
|
|
688
|
+
i += 1
|
|
689
|
+
else:
|
|
690
|
+
group.append(next_item)
|
|
691
|
+
i += 1
|
|
692
|
+
break
|
|
693
|
+
processed_story.append(KeepTogether(group))
|
|
694
|
+
else:
|
|
695
|
+
processed_story.append(item)
|
|
696
|
+
i += 1
|
|
697
|
+
story = processed_story
|
|
698
|
+
|
|
699
|
+
# Pre-configure building styles and canvas defaults with the first theme setter from the story
|
|
700
|
+
for item in story:
|
|
701
|
+
if item.__class__.__name__ == "ThemeSetterFlowable":
|
|
702
|
+
helpers._apply_theme_state(item.theme)
|
|
703
|
+
break
|
|
704
|
+
|
|
705
|
+
if title is None:
|
|
706
|
+
if isinstance(filename, str):
|
|
707
|
+
base = os.path.basename(filename)
|
|
708
|
+
title = os.path.splitext(base)[0].replace("_", " ").title()
|
|
709
|
+
else:
|
|
710
|
+
title = "Document"
|
|
711
|
+
if author is None:
|
|
712
|
+
author = "Bharat Dangi"
|
|
713
|
+
|
|
714
|
+
t = get_theme()
|
|
715
|
+
doc = NotesDocTemplate(
|
|
716
|
+
filename,
|
|
717
|
+
pagesize=A4,
|
|
718
|
+
leftMargin=t.left_margin,
|
|
719
|
+
rightMargin=t.right_margin,
|
|
720
|
+
topMargin=t.top_margin,
|
|
721
|
+
bottomMargin=t.bottom_margin,
|
|
722
|
+
title=title,
|
|
723
|
+
author=author,
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
# Auto-detect if TableOfContents is present in the story
|
|
727
|
+
has_toc = False
|
|
728
|
+
from reportlab.platypus.tableofcontents import TableOfContents
|
|
729
|
+
|
|
730
|
+
for item in story:
|
|
731
|
+
if isinstance(item, TableOfContents):
|
|
732
|
+
has_toc = True
|
|
733
|
+
break
|
|
734
|
+
|
|
735
|
+
# Decide build method based on TOC or cross-reference requirements
|
|
736
|
+
use_multibuild = has_toc or helpers._requires_multibuild
|
|
737
|
+
|
|
738
|
+
if use_multibuild:
|
|
739
|
+
doc.multiBuild(
|
|
740
|
+
story,
|
|
741
|
+
onFirstPage=page_decor,
|
|
742
|
+
onLaterPages=page_decor,
|
|
743
|
+
canvasmaker=ThemedCanvas,
|
|
744
|
+
)
|
|
745
|
+
else:
|
|
746
|
+
doc.build(
|
|
747
|
+
story,
|
|
748
|
+
onFirstPage=page_decor,
|
|
749
|
+
onLaterPages=page_decor,
|
|
750
|
+
canvasmaker=ThemedCanvas,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
# Export study flashcards if any were registered
|
|
754
|
+
if hasattr(helpers, "_flashcards") and helpers._flashcards:
|
|
755
|
+
cards = helpers._flashcards
|
|
756
|
+
# Export CSV
|
|
757
|
+
csv_filename = filename.replace(".pdf", "_flashcards.csv")
|
|
758
|
+
import csv
|
|
759
|
+
|
|
760
|
+
try:
|
|
761
|
+
with open(csv_filename, "w", newline="", encoding="utf-8") as f:
|
|
762
|
+
writer = csv.writer(f)
|
|
763
|
+
writer.writerow(["Question", "Answer"])
|
|
764
|
+
for q, a in cards:
|
|
765
|
+
writer.writerow([q, a])
|
|
766
|
+
except Exception:
|
|
767
|
+
pass
|
|
768
|
+
|
|
769
|
+
# Export JSON
|
|
770
|
+
json_filename = filename.replace(".pdf", "_flashcards.json")
|
|
771
|
+
import json
|
|
772
|
+
|
|
773
|
+
try:
|
|
774
|
+
with open(json_filename, "w", encoding="utf-8") as f:
|
|
775
|
+
json.dump([{"question": q, "answer": a} for q, a in cards], f, indent=2)
|
|
776
|
+
except Exception:
|
|
777
|
+
pass
|
|
778
|
+
|
|
779
|
+
# Export APKG (Anki). If genanki is unavailable, write a compact
|
|
780
|
+
# JSON-backed package so local exports are still deterministic.
|
|
781
|
+
apkg_filename = filename.replace(".pdf", ".apkg")
|
|
782
|
+
try:
|
|
783
|
+
import genanki
|
|
784
|
+
import hashlib
|
|
785
|
+
|
|
786
|
+
title_hash = int(hashlib.md5(title.encode("utf-8")).hexdigest(), 16)
|
|
787
|
+
model_id = (title_hash % 1000000000) + 1000000000
|
|
788
|
+
deck_id = (title_hash % 1000000000) + 2000000000
|
|
789
|
+
|
|
790
|
+
t_theme = get_theme()
|
|
791
|
+
css_style = f"""
|
|
792
|
+
.card {{
|
|
793
|
+
font-family: Arial, Helvetica, sans-serif;
|
|
794
|
+
font-size: 18px;
|
|
795
|
+
text-align: center;
|
|
796
|
+
color: {t_theme.text};
|
|
797
|
+
background-color: {t_theme.bg};
|
|
798
|
+
padding: 20px;
|
|
799
|
+
border-radius: 8px;
|
|
800
|
+
}}
|
|
801
|
+
.front {{
|
|
802
|
+
border: 2px solid {t_theme.accent};
|
|
803
|
+
}}
|
|
804
|
+
.back {{
|
|
805
|
+
border: 2px dashed {t_theme.accent2};
|
|
806
|
+
}}
|
|
807
|
+
.question {{
|
|
808
|
+
font-size: 14px;
|
|
809
|
+
color: {t_theme.text_dim};
|
|
810
|
+
margin-bottom: 8px;
|
|
811
|
+
}}
|
|
812
|
+
hr {{
|
|
813
|
+
border: 0;
|
|
814
|
+
height: 1px;
|
|
815
|
+
background: {t_theme.table_bdr};
|
|
816
|
+
margin: 12px 0;
|
|
817
|
+
}}
|
|
818
|
+
pre, code {{
|
|
819
|
+
font-family: Courier, monospace;
|
|
820
|
+
background-color: {t_theme.code_bg};
|
|
821
|
+
color: {t_theme.text_code};
|
|
822
|
+
}}
|
|
823
|
+
"""
|
|
824
|
+
|
|
825
|
+
model = genanki.Model(
|
|
826
|
+
model_id,
|
|
827
|
+
f"engrapha Model - {title}",
|
|
828
|
+
fields=[
|
|
829
|
+
{"name": "Question"},
|
|
830
|
+
{"name": "Answer"},
|
|
831
|
+
],
|
|
832
|
+
templates=[
|
|
833
|
+
{
|
|
834
|
+
"name": "Card 1",
|
|
835
|
+
"qfmt": '<div class="card front">{{Question}}</div>',
|
|
836
|
+
"afmt": '<div class="card back"><div class="question">{{Question}}</div><hr id="answer">{{Answer}}</div>',
|
|
837
|
+
}
|
|
838
|
+
],
|
|
839
|
+
css=css_style,
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
deck = genanki.Deck(deck_id, f"engrapha Deck - {title}")
|
|
843
|
+
for q, a in cards:
|
|
844
|
+
q_html = q.replace("\n", "<br/>")
|
|
845
|
+
a_html = a.replace("\n", "<br/>")
|
|
846
|
+
|
|
847
|
+
# Format inline math using MathJax
|
|
848
|
+
import re
|
|
849
|
+
|
|
850
|
+
def repl_math(m):
|
|
851
|
+
expr = m.group(1)
|
|
852
|
+
return f"\\\\({expr}\\\\)"
|
|
853
|
+
|
|
854
|
+
q_html = re.sub(r"\$([^\$]+)\$", repl_math, q_html)
|
|
855
|
+
a_html = re.sub(r"\$([^\$]+)\$", repl_math, a_html)
|
|
856
|
+
|
|
857
|
+
note = genanki.Note(model=model, fields=[q_html, a_html])
|
|
858
|
+
deck.add_note(note)
|
|
859
|
+
|
|
860
|
+
genanki.Package(deck).write_to_file(apkg_filename)
|
|
861
|
+
except Exception:
|
|
862
|
+
import json
|
|
863
|
+
import zipfile
|
|
864
|
+
|
|
865
|
+
payload = {
|
|
866
|
+
"title": title,
|
|
867
|
+
"cards": [{"question": q, "answer": a} for q, a in cards],
|
|
868
|
+
}
|
|
869
|
+
with zipfile.ZipFile(
|
|
870
|
+
apkg_filename, "w", compression=zipfile.ZIP_DEFLATED
|
|
871
|
+
) as zf:
|
|
872
|
+
zf.writestr("engrapha_flashcards.json", json.dumps(payload, indent=2))
|
|
873
|
+
zf.writestr(
|
|
874
|
+
"README.txt",
|
|
875
|
+
"engrapha fallback flashcard package. Install genanki for native Anki APKG export.\n"
|
|
876
|
+
* 24,
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
if hasattr(helpers, "_flashcards"):
|
|
880
|
+
helpers._flashcards.clear()
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
class LaTeXFlowable(Flowable): # type: ignore[misc]
|
|
884
|
+
"""Flowable that parses a LaTeX math expression and renders it as vector paths."""
|
|
885
|
+
|
|
886
|
+
def __init__(
|
|
887
|
+
self, latex_str: str, fontsize: float | None = None, color: Any = None
|
|
888
|
+
) -> None:
|
|
889
|
+
super().__init__()
|
|
890
|
+
self.latex_str = latex_str
|
|
891
|
+
from .theme import get_theme
|
|
892
|
+
|
|
893
|
+
t_theme = get_theme()
|
|
894
|
+
self.fontsize = fontsize if fontsize is not None else t_theme.size_body
|
|
895
|
+
self.color = color
|
|
896
|
+
|
|
897
|
+
from matplotlib.mathtext import MathTextParser
|
|
898
|
+
|
|
899
|
+
parser = MathTextParser("path")
|
|
900
|
+
math_str = latex_str if latex_str.startswith("$") else f"${latex_str}$"
|
|
901
|
+
self.width, self.height, self.depth, self.glyphs, self.rects = parser.parse(
|
|
902
|
+
math_str, dpi=72
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
self.scale = self.fontsize / 10.0
|
|
906
|
+
self.width *= self.scale
|
|
907
|
+
self.height *= self.scale
|
|
908
|
+
self.depth *= self.scale
|
|
909
|
+
|
|
910
|
+
def wrap(self, aW: float, aH: float) -> tuple[float, float]:
|
|
911
|
+
return self.width, self.height + self.depth
|
|
912
|
+
|
|
913
|
+
def draw(self) -> None:
|
|
914
|
+
canvas = self.canv
|
|
915
|
+
canvas.saveState()
|
|
916
|
+
|
|
917
|
+
from .theme import get_theme
|
|
918
|
+
|
|
919
|
+
t_theme = get_theme()
|
|
920
|
+
|
|
921
|
+
fill_color = self.color if self.color is not None else t_theme.rl(t_theme.text)
|
|
922
|
+
canvas.setFillColor(fill_color)
|
|
923
|
+
canvas.setStrokeColor(fill_color)
|
|
924
|
+
canvas.setLineWidth(0.5 * self.scale)
|
|
925
|
+
|
|
926
|
+
y_baseline = self.depth
|
|
927
|
+
|
|
928
|
+
# Draw rects (like fraction bars)
|
|
929
|
+
for rx, ry, rw, rh in self.rects:
|
|
930
|
+
rx_s = rx * self.scale
|
|
931
|
+
ry_s = ry * self.scale
|
|
932
|
+
rw_s = rw * self.scale
|
|
933
|
+
rh_s = rh * self.scale
|
|
934
|
+
canvas.rect(rx_s, y_baseline + ry_s, rw_s, rh_s, stroke=0, fill=1)
|
|
935
|
+
|
|
936
|
+
# Draw glyphs
|
|
937
|
+
glyphs_list: list[Any] = self.glyphs
|
|
938
|
+
for glyph_tuple in glyphs_list:
|
|
939
|
+
if len(glyph_tuple) == 6:
|
|
940
|
+
font, fontsize, num, _, gx, gy = glyph_tuple
|
|
941
|
+
else:
|
|
942
|
+
font, fontsize, num, gx, gy = glyph_tuple
|
|
943
|
+
gx_s = gx * self.scale
|
|
944
|
+
gy_s = gy * self.scale
|
|
945
|
+
|
|
946
|
+
font.clear()
|
|
947
|
+
font.set_size(fontsize, 72)
|
|
948
|
+
font.load_char(num)
|
|
949
|
+
vertices, codes = font.get_path()
|
|
950
|
+
|
|
951
|
+
p = canvas.beginPath()
|
|
952
|
+
i = 0
|
|
953
|
+
curr_x, curr_y = 0.0, 0.0
|
|
954
|
+
while i < len(codes):
|
|
955
|
+
code = codes[i]
|
|
956
|
+
if code == 1: # MOVETO
|
|
957
|
+
vx = vertices[i][0] * self.scale
|
|
958
|
+
vy = vertices[i][1] * self.scale
|
|
959
|
+
p.moveTo(gx_s + vx, y_baseline + gy_s + vy)
|
|
960
|
+
curr_x, curr_y = gx_s + vx, y_baseline + gy_s + vy
|
|
961
|
+
i += 1
|
|
962
|
+
elif code == 2: # LINETO
|
|
963
|
+
vx = vertices[i][0] * self.scale
|
|
964
|
+
vy = vertices[i][1] * self.scale
|
|
965
|
+
p.lineTo(gx_s + vx, y_baseline + gy_s + vy)
|
|
966
|
+
curr_x, curr_y = gx_s + vx, y_baseline + gy_s + vy
|
|
967
|
+
i += 1
|
|
968
|
+
elif code == 3: # CURVE3
|
|
969
|
+
cx = vertices[i][0] * self.scale
|
|
970
|
+
cy = vertices[i][1] * self.scale
|
|
971
|
+
ex = vertices[i + 1][0] * self.scale
|
|
972
|
+
ey = vertices[i + 1][1] * self.scale
|
|
973
|
+
|
|
974
|
+
c1x = curr_x + (2.0 / 3.0) * (gx_s + cx - curr_x)
|
|
975
|
+
c1y = curr_y + (2.0 / 3.0) * (y_baseline + gy_s + cy - curr_y)
|
|
976
|
+
c2x = (gx_s + ex) + (2.0 / 3.0) * (gx_s + cx - (gx_s + ex))
|
|
977
|
+
c2y = (y_baseline + gy_s + ey) + (2.0 / 3.0) * (
|
|
978
|
+
y_baseline + gy_s + cy - (y_baseline + gy_s + ey)
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
p.curveTo(c1x, c1y, c2x, c2y, gx_s + ex, y_baseline + gy_s + ey)
|
|
982
|
+
curr_x, curr_y = gx_s + ex, y_baseline + gy_s + ey
|
|
983
|
+
i += 2
|
|
984
|
+
elif code == 4: # CURVE4
|
|
985
|
+
c1x = vertices[i][0] * self.scale
|
|
986
|
+
c1y = vertices[i][1] * self.scale
|
|
987
|
+
c2x = vertices[i + 1][0] * self.scale
|
|
988
|
+
c2y = vertices[i + 1][1] * self.scale
|
|
989
|
+
ex = vertices[i + 2][0] * self.scale
|
|
990
|
+
ey = vertices[i + 2][1] * self.scale
|
|
991
|
+
|
|
992
|
+
p.curveTo(
|
|
993
|
+
gx_s + c1x,
|
|
994
|
+
y_baseline + gy_s + c1y,
|
|
995
|
+
gx_s + c2x,
|
|
996
|
+
y_baseline + gy_s + c2y,
|
|
997
|
+
gx_s + ex,
|
|
998
|
+
y_baseline + gy_s + ey,
|
|
999
|
+
)
|
|
1000
|
+
curr_x, curr_y = gx_s + ex, y_baseline + gy_s + ey
|
|
1001
|
+
i += 3
|
|
1002
|
+
elif code == 79: # CLOSEPOLY
|
|
1003
|
+
p.close()
|
|
1004
|
+
i += 1
|
|
1005
|
+
else:
|
|
1006
|
+
i += 1
|
|
1007
|
+
canvas.drawPath(p, stroke=0, fill=1)
|
|
1008
|
+
|
|
1009
|
+
canvas.restoreState()
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _unwrap_flowable(val):
|
|
1013
|
+
if type(val).__name__ == "_ExpandedCellTuple":
|
|
1014
|
+
val = list(val)
|
|
1015
|
+
if isinstance(val, (list, tuple)):
|
|
1016
|
+
if len(val) == 1:
|
|
1017
|
+
return _unwrap_flowable(val[0])
|
|
1018
|
+
return list(val)
|
|
1019
|
+
|
|
1020
|
+
cls_name = type(val).__name__
|
|
1021
|
+
if cls_name == "KeepTogether":
|
|
1022
|
+
return getattr(val, "_content", getattr(val, "_flowables", [val]))
|
|
1023
|
+
if cls_name in ("ListItem", "LIIndenter"):
|
|
1024
|
+
if hasattr(val, "_flowables"):
|
|
1025
|
+
return val._flowables
|
|
1026
|
+
if hasattr(val, "_flowable"):
|
|
1027
|
+
return [val._flowable]
|
|
1028
|
+
if hasattr(val, "_content"):
|
|
1029
|
+
return val._content
|
|
1030
|
+
return [val]
|
|
1031
|
+
return val
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def build_pptx(
|
|
1035
|
+
filename,
|
|
1036
|
+
story=None,
|
|
1037
|
+
title=None,
|
|
1038
|
+
author=None,
|
|
1039
|
+
):
|
|
1040
|
+
from .helpers import story as default_story
|
|
1041
|
+
from .theme import get_theme
|
|
1042
|
+
|
|
1043
|
+
try:
|
|
1044
|
+
from pptx import Presentation
|
|
1045
|
+
except ModuleNotFoundError as e:
|
|
1046
|
+
raise RuntimeError(
|
|
1047
|
+
"PPTX export requires the optional 'python-pptx' package.\n"
|
|
1048
|
+
"Install it with:\n\n"
|
|
1049
|
+
"pip install engrapha-notes[pptx]\n"
|
|
1050
|
+
"or\n"
|
|
1051
|
+
"pip install engrapha-notes[all]"
|
|
1052
|
+
) from e
|
|
1053
|
+
|
|
1054
|
+
from pptx.util import Inches, Pt
|
|
1055
|
+
from pptx.dml.color import RGBColor
|
|
1056
|
+
from pptx.enum.shapes import MSO_SHAPE
|
|
1057
|
+
from pptx.enum.text import PP_ALIGN
|
|
1058
|
+
import os
|
|
1059
|
+
import re
|
|
1060
|
+
|
|
1061
|
+
if story is None:
|
|
1062
|
+
story = default_story
|
|
1063
|
+
|
|
1064
|
+
t_theme = get_theme()
|
|
1065
|
+
|
|
1066
|
+
prs = Presentation()
|
|
1067
|
+
prs.slide_width = Inches(13.33)
|
|
1068
|
+
prs.slide_height = Inches(7.5)
|
|
1069
|
+
|
|
1070
|
+
def hex_to_rgb(hex_str):
|
|
1071
|
+
h = hex_str.lstrip("#")
|
|
1072
|
+
return RGBColor(*(int(h[i : i + 2], 16) for i in (0, 2, 4)))
|
|
1073
|
+
|
|
1074
|
+
bg_rgb = hex_to_rgb(t_theme.bg)
|
|
1075
|
+
text_rgb = hex_to_rgb(t_theme.text)
|
|
1076
|
+
accent_rgb = hex_to_rgb(t_theme.accent)
|
|
1077
|
+
surface_rgb = hex_to_rgb(t_theme.surface)
|
|
1078
|
+
|
|
1079
|
+
def set_slide_background(slide):
|
|
1080
|
+
background = slide.background
|
|
1081
|
+
fill = background.fill
|
|
1082
|
+
fill.solid()
|
|
1083
|
+
fill.fore_color.rgb = bg_rgb
|
|
1084
|
+
|
|
1085
|
+
def _get_val_plain(val):
|
|
1086
|
+
val = _unwrap_flowable(val)
|
|
1087
|
+
if val is None:
|
|
1088
|
+
return ""
|
|
1089
|
+
if isinstance(val, str):
|
|
1090
|
+
return re.sub(r"<[^>]+>", "", val)
|
|
1091
|
+
cls_n = val.__class__.__name__
|
|
1092
|
+
if cls_n == "LaTeXFlowable":
|
|
1093
|
+
return getattr(val, "latex_str", "") # type: ignore
|
|
1094
|
+
if cls_n == "Paragraph":
|
|
1095
|
+
return re.sub(r"<[^>]+>", "", getattr(val, "text", ""))
|
|
1096
|
+
if isinstance(val, (list, tuple)):
|
|
1097
|
+
return "".join(_get_val_plain(x) for x in val)
|
|
1098
|
+
return re.sub(r"<[^>]+>", "", str(val))
|
|
1099
|
+
|
|
1100
|
+
slides_data = []
|
|
1101
|
+
curr_slide = {
|
|
1102
|
+
"title": title or "Presentation",
|
|
1103
|
+
"layout": "content",
|
|
1104
|
+
"items": [],
|
|
1105
|
+
"theme": t_theme,
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
def preprocess_story(story_items):
|
|
1109
|
+
import copy
|
|
1110
|
+
|
|
1111
|
+
processed = []
|
|
1112
|
+
for item in story_items:
|
|
1113
|
+
cls_name = item.__class__.__name__
|
|
1114
|
+
if cls_name == "KeepTogether" and hasattr(item, "_content"):
|
|
1115
|
+
new_item = copy.copy(item)
|
|
1116
|
+
new_item._content = preprocess_story(getattr(item, "_content", []))
|
|
1117
|
+
processed.append(new_item)
|
|
1118
|
+
elif cls_name == "IndexPrinterFlowable":
|
|
1119
|
+
from reportlab.platypus import Paragraph
|
|
1120
|
+
from reportlab.lib.styles import ParagraphStyle
|
|
1121
|
+
|
|
1122
|
+
style = ParagraphStyle(
|
|
1123
|
+
name="idx",
|
|
1124
|
+
fontName=t_theme.body_font,
|
|
1125
|
+
fontSize=14,
|
|
1126
|
+
textColor=t_theme.rl(t_theme.text_dim),
|
|
1127
|
+
)
|
|
1128
|
+
processed.append(
|
|
1129
|
+
Paragraph(
|
|
1130
|
+
"<i>Index mapping is only available in the PDF format.</i>",
|
|
1131
|
+
style,
|
|
1132
|
+
)
|
|
1133
|
+
)
|
|
1134
|
+
elif cls_name in ("Table", "CodeBlockTable") and getattr(
|
|
1135
|
+
item, "_is_code_block", False
|
|
1136
|
+
):
|
|
1137
|
+
rows = getattr(item, "_cellvalues", [])
|
|
1138
|
+
if len(rows) > 16:
|
|
1139
|
+
for i in range(0, len(rows), 16):
|
|
1140
|
+
chunk_item = copy.copy(item)
|
|
1141
|
+
chunk_item._cellvalues = rows[i : i + 16]
|
|
1142
|
+
processed.append(chunk_item)
|
|
1143
|
+
else:
|
|
1144
|
+
processed.append(item)
|
|
1145
|
+
else:
|
|
1146
|
+
processed.append(item)
|
|
1147
|
+
return processed
|
|
1148
|
+
|
|
1149
|
+
flattened_story = preprocess_story(story)
|
|
1150
|
+
|
|
1151
|
+
for item in flattened_story:
|
|
1152
|
+
cls_name = item.__class__.__name__
|
|
1153
|
+
|
|
1154
|
+
if cls_name == "ThemeSetterFlowable":
|
|
1155
|
+
t_theme = item.theme
|
|
1156
|
+
curr_slide["theme"] = t_theme
|
|
1157
|
+
continue
|
|
1158
|
+
|
|
1159
|
+
if getattr(item, "_is_part_box", False):
|
|
1160
|
+
if curr_slide["items"] or curr_slide["title"] != (title or "Presentation"):
|
|
1161
|
+
slides_data.append(curr_slide)
|
|
1162
|
+
curr_slide = {
|
|
1163
|
+
"title": getattr(item, "_box_title", ""),
|
|
1164
|
+
"layout": "content",
|
|
1165
|
+
"items": [],
|
|
1166
|
+
"theme": t_theme,
|
|
1167
|
+
}
|
|
1168
|
+
elif getattr(item, "_is_chap_box", False):
|
|
1169
|
+
if curr_slide["items"] or curr_slide["title"] != (title or "Presentation"):
|
|
1170
|
+
slides_data.append(curr_slide)
|
|
1171
|
+
curr_slide = {
|
|
1172
|
+
"title": getattr(item, "_box_title", ""),
|
|
1173
|
+
"layout": "content",
|
|
1174
|
+
"items": [],
|
|
1175
|
+
"theme": t_theme,
|
|
1176
|
+
}
|
|
1177
|
+
elif getattr(item, "_is_section", False):
|
|
1178
|
+
curr_slide["items"].append(item)
|
|
1179
|
+
elif cls_name == "SlideBreak":
|
|
1180
|
+
if curr_slide["items"]:
|
|
1181
|
+
slides_data.append(curr_slide)
|
|
1182
|
+
curr_slide = {
|
|
1183
|
+
"title": curr_slide["title"],
|
|
1184
|
+
"layout": "content",
|
|
1185
|
+
"items": [],
|
|
1186
|
+
"theme": t_theme,
|
|
1187
|
+
}
|
|
1188
|
+
elif cls_name == "PageBreak":
|
|
1189
|
+
if curr_slide["items"]:
|
|
1190
|
+
slides_data.append(curr_slide)
|
|
1191
|
+
curr_slide = {
|
|
1192
|
+
"title": curr_slide["title"],
|
|
1193
|
+
"layout": "content",
|
|
1194
|
+
"items": [],
|
|
1195
|
+
"theme": t_theme,
|
|
1196
|
+
}
|
|
1197
|
+
elif cls_name in (
|
|
1198
|
+
"Bookmark",
|
|
1199
|
+
"Footer",
|
|
1200
|
+
"FootnoteRegisterFlowable",
|
|
1201
|
+
"LabelFlowable",
|
|
1202
|
+
"IndexEntryFlowable",
|
|
1203
|
+
):
|
|
1204
|
+
continue
|
|
1205
|
+
else:
|
|
1206
|
+
curr_slide["items"].append(item)
|
|
1207
|
+
|
|
1208
|
+
if curr_slide["items"] or curr_slide["layout"] != "content":
|
|
1209
|
+
slides_data.append(curr_slide)
|
|
1210
|
+
|
|
1211
|
+
blank_layout = prs.slide_layouts[6]
|
|
1212
|
+
|
|
1213
|
+
for sd in slides_data:
|
|
1214
|
+
layout_type = sd["layout"]
|
|
1215
|
+
stitle = sd["title"]
|
|
1216
|
+
items = sd["items"]
|
|
1217
|
+
t_theme = sd["theme"]
|
|
1218
|
+
|
|
1219
|
+
bg_rgb = hex_to_rgb(t_theme.bg)
|
|
1220
|
+
text_rgb = hex_to_rgb(t_theme.text)
|
|
1221
|
+
accent_rgb = hex_to_rgb(t_theme.accent)
|
|
1222
|
+
surface_rgb = hex_to_rgb(t_theme.surface)
|
|
1223
|
+
|
|
1224
|
+
if layout_type == "title":
|
|
1225
|
+
slide = prs.slides.add_slide(blank_layout)
|
|
1226
|
+
set_slide_background(slide)
|
|
1227
|
+
txBox = slide.shapes.add_textbox(
|
|
1228
|
+
Inches(1.0), Inches(2.2), Inches(11.33), Inches(1.5)
|
|
1229
|
+
)
|
|
1230
|
+
tf = txBox.text_frame
|
|
1231
|
+
tf.word_wrap = True
|
|
1232
|
+
p = tf.paragraphs[0]
|
|
1233
|
+
p.text = stitle
|
|
1234
|
+
p.font.size = Pt(44)
|
|
1235
|
+
p.font.bold = True
|
|
1236
|
+
p.font.color.rgb = accent_rgb
|
|
1237
|
+
p.font.name = t_theme.heading_font
|
|
1238
|
+
|
|
1239
|
+
p2 = tf.add_paragraph()
|
|
1240
|
+
p2.text = author or "engrapha Compiler"
|
|
1241
|
+
p2.font.size = Pt(20)
|
|
1242
|
+
p2.font.color.rgb = text_rgb
|
|
1243
|
+
p2.font.name = t_theme.body_font
|
|
1244
|
+
|
|
1245
|
+
elif layout_type == "section":
|
|
1246
|
+
slide = prs.slides.add_slide(blank_layout)
|
|
1247
|
+
set_slide_background(slide)
|
|
1248
|
+
txBox = slide.shapes.add_textbox(
|
|
1249
|
+
Inches(1.0), Inches(3.0), Inches(11.33), Inches(1.5)
|
|
1250
|
+
)
|
|
1251
|
+
tf = txBox.text_frame
|
|
1252
|
+
tf.word_wrap = True
|
|
1253
|
+
p = tf.paragraphs[0]
|
|
1254
|
+
p.text = stitle
|
|
1255
|
+
p.font.size = Pt(36)
|
|
1256
|
+
p.font.bold = True
|
|
1257
|
+
p.font.color.rgb = accent_rgb
|
|
1258
|
+
p.font.name = t_theme.heading_font
|
|
1259
|
+
|
|
1260
|
+
else:
|
|
1261
|
+
slide_items_groups = []
|
|
1262
|
+
current_group = []
|
|
1263
|
+
accumulated_h = 0.0
|
|
1264
|
+
|
|
1265
|
+
def get_h_est(item):
|
|
1266
|
+
item_name = item.__class__.__name__
|
|
1267
|
+
if item_name == "KeepTogether" and hasattr(item, "_content"):
|
|
1268
|
+
return sum(get_h_est(c) for c in getattr(item, "_content", []))
|
|
1269
|
+
if item_name == "Paragraph":
|
|
1270
|
+
plain = re.sub(r"<[^>]+>", "", getattr(item, "text", ""))
|
|
1271
|
+
return math.ceil(len(plain) / 85.0) * 0.3 + 0.2
|
|
1272
|
+
if item_name in ("Table", "CodeBlockTable"):
|
|
1273
|
+
is_code = getattr(item, "_is_code_block", False)
|
|
1274
|
+
is_packet = getattr(item, "_is_packet_format", False)
|
|
1275
|
+
rows_cnt = len(getattr(item, "_cellvalues", []))
|
|
1276
|
+
if is_code:
|
|
1277
|
+
return rows_cnt * 0.22 + 0.3
|
|
1278
|
+
if is_packet:
|
|
1279
|
+
return rows_cnt * 0.3 + 0.3
|
|
1280
|
+
return rows_cnt * 0.4 + 0.3
|
|
1281
|
+
if item_name == "Spacer":
|
|
1282
|
+
return getattr(item, "height", 0.0) / 72.0
|
|
1283
|
+
if hasattr(item, "drawing") or item_name == "ResponsiveDrawingFlowable":
|
|
1284
|
+
drawing = (
|
|
1285
|
+
getattr(item, "drawing", None)
|
|
1286
|
+
if hasattr(item, "drawing")
|
|
1287
|
+
else item
|
|
1288
|
+
)
|
|
1289
|
+
if drawing is not None:
|
|
1290
|
+
h = getattr(drawing, "height", 0.0) / 72.0
|
|
1291
|
+
if h > 4.5:
|
|
1292
|
+
h = 4.5
|
|
1293
|
+
return h + 0.4
|
|
1294
|
+
return 0.4
|
|
1295
|
+
if item_name == "ImageFlowable":
|
|
1296
|
+
return 3.5
|
|
1297
|
+
return 1.0
|
|
1298
|
+
|
|
1299
|
+
for item in items:
|
|
1300
|
+
h_est = get_h_est(item)
|
|
1301
|
+
|
|
1302
|
+
if accumulated_h + h_est > 5.0 and current_group:
|
|
1303
|
+
slide_items_groups.append(current_group)
|
|
1304
|
+
current_group = [item]
|
|
1305
|
+
accumulated_h = h_est
|
|
1306
|
+
else:
|
|
1307
|
+
current_group.append(item)
|
|
1308
|
+
accumulated_h += h_est
|
|
1309
|
+
|
|
1310
|
+
if current_group:
|
|
1311
|
+
slide_items_groups.append(current_group)
|
|
1312
|
+
|
|
1313
|
+
if not slide_items_groups:
|
|
1314
|
+
slide_items_groups = [[]]
|
|
1315
|
+
|
|
1316
|
+
for g_idx, g_items in enumerate(slide_items_groups):
|
|
1317
|
+
slide = prs.slides.add_slide(blank_layout)
|
|
1318
|
+
set_slide_background(slide)
|
|
1319
|
+
|
|
1320
|
+
txBox = slide.shapes.add_textbox(
|
|
1321
|
+
Inches(0.8), Inches(0.4), Inches(11.73), Inches(0.8)
|
|
1322
|
+
)
|
|
1323
|
+
tf = txBox.text_frame
|
|
1324
|
+
tf.word_wrap = True
|
|
1325
|
+
p = tf.paragraphs[0]
|
|
1326
|
+
p.text = f"{stitle} (Cont.)" if g_idx > 0 else stitle
|
|
1327
|
+
p.font.size = Pt(28)
|
|
1328
|
+
p.font.bold = True
|
|
1329
|
+
p.font.color.rgb = accent_rgb
|
|
1330
|
+
p.font.name = t_theme.heading_font
|
|
1331
|
+
|
|
1332
|
+
y_cursor = 1.4
|
|
1333
|
+
|
|
1334
|
+
flattened_g_items = []
|
|
1335
|
+
for item in g_items:
|
|
1336
|
+
if item.__class__.__name__ == "KeepTogether" and hasattr(
|
|
1337
|
+
item, "_content"
|
|
1338
|
+
):
|
|
1339
|
+
flattened_g_items.extend(getattr(item, "_content", []))
|
|
1340
|
+
else:
|
|
1341
|
+
flattened_g_items.append(item)
|
|
1342
|
+
|
|
1343
|
+
for item in flattened_g_items:
|
|
1344
|
+
item_name = item.__class__.__name__
|
|
1345
|
+
if item_name == "ThemeSetterFlowable":
|
|
1346
|
+
t_theme = item.theme
|
|
1347
|
+
bg_rgb = hex_to_rgb(t_theme.bg)
|
|
1348
|
+
text_rgb = hex_to_rgb(t_theme.text)
|
|
1349
|
+
accent_rgb = hex_to_rgb(t_theme.accent)
|
|
1350
|
+
surface_rgb = hex_to_rgb(t_theme.surface)
|
|
1351
|
+
set_slide_background(slide)
|
|
1352
|
+
p.font.color.rgb = accent_rgb
|
|
1353
|
+
p.font.name = t_theme.heading_font
|
|
1354
|
+
continue
|
|
1355
|
+
if item_name == "Paragraph":
|
|
1356
|
+
plain = re.sub(r"<[^>]+>", "", getattr(item, "text", ""))
|
|
1357
|
+
num_lines = math.ceil(len(plain) / 85.0)
|
|
1358
|
+
h_box = num_lines * 0.3 + 0.2
|
|
1359
|
+
|
|
1360
|
+
tb = slide.shapes.add_textbox(
|
|
1361
|
+
Inches(0.8), Inches(y_cursor), Inches(11.73), Inches(h_box)
|
|
1362
|
+
)
|
|
1363
|
+
tb.text_frame.word_wrap = True
|
|
1364
|
+
p_para = tb.text_frame.paragraphs[0]
|
|
1365
|
+
parser = PptxHtmlParser(
|
|
1366
|
+
p_para,
|
|
1367
|
+
default_font_name=t_theme.body_font,
|
|
1368
|
+
default_font_size=14,
|
|
1369
|
+
default_color=text_rgb,
|
|
1370
|
+
)
|
|
1371
|
+
parser.feed(getattr(item, "text", ""))
|
|
1372
|
+
|
|
1373
|
+
y_cursor += h_box
|
|
1374
|
+
|
|
1375
|
+
elif item_name in ("Table", "CodeBlockTable"):
|
|
1376
|
+
is_code = getattr(item, "_is_code_block", False)
|
|
1377
|
+
is_tip = getattr(item, "_is_tip", False)
|
|
1378
|
+
is_note = getattr(item, "_is_note", False)
|
|
1379
|
+
is_warning = getattr(item, "_is_warning", False)
|
|
1380
|
+
is_important = getattr(item, "_is_important", False)
|
|
1381
|
+
is_exam = getattr(item, "_is_exam", False)
|
|
1382
|
+
is_theorem = getattr(item, "_is_theorem", False)
|
|
1383
|
+
is_definition = getattr(item, "_is_definition", False)
|
|
1384
|
+
is_highlight = getattr(item, "_is_highlight", False)
|
|
1385
|
+
is_question = getattr(item, "_is_question", False)
|
|
1386
|
+
is_answer = getattr(item, "_is_answer", False)
|
|
1387
|
+
is_flashcard = getattr(item, "_is_flashcard", False)
|
|
1388
|
+
is_mcq = getattr(item, "_is_mcq", False)
|
|
1389
|
+
|
|
1390
|
+
is_callout = (
|
|
1391
|
+
is_tip
|
|
1392
|
+
or is_note
|
|
1393
|
+
or is_warning
|
|
1394
|
+
or is_important
|
|
1395
|
+
or is_exam
|
|
1396
|
+
or is_theorem
|
|
1397
|
+
or is_definition
|
|
1398
|
+
or is_highlight
|
|
1399
|
+
or is_question
|
|
1400
|
+
or is_answer
|
|
1401
|
+
)
|
|
1402
|
+
|
|
1403
|
+
if is_code:
|
|
1404
|
+
code_lines_raw = []
|
|
1405
|
+
for r in getattr(item, "_cellvalues", []):
|
|
1406
|
+
cell = _unwrap_flowable(r[0])
|
|
1407
|
+
if isinstance(cell, Paragraph):
|
|
1408
|
+
code_lines_raw.append(getattr(cell, "text", ""))
|
|
1409
|
+
else:
|
|
1410
|
+
code_lines_raw.append(str(cell))
|
|
1411
|
+
|
|
1412
|
+
h_tbl = len(code_lines_raw) * 0.22 + 0.3
|
|
1413
|
+
tb = slide.shapes.add_textbox(
|
|
1414
|
+
Inches(0.8),
|
|
1415
|
+
Inches(y_cursor),
|
|
1416
|
+
Inches(11.73),
|
|
1417
|
+
Inches(h_tbl),
|
|
1418
|
+
)
|
|
1419
|
+
tf = tb.text_frame
|
|
1420
|
+
tf.word_wrap = True
|
|
1421
|
+
|
|
1422
|
+
fill = tb.fill
|
|
1423
|
+
fill.solid()
|
|
1424
|
+
fill.fore_color.rgb = hex_to_rgb(t_theme.code_bg)
|
|
1425
|
+
|
|
1426
|
+
line = tb.line
|
|
1427
|
+
line.color.rgb = accent_rgb
|
|
1428
|
+
line.width = Pt(1.5)
|
|
1429
|
+
|
|
1430
|
+
for r_idx, r in enumerate(getattr(item, "_cellvalues", [])):
|
|
1431
|
+
cell = _unwrap_flowable(r[0])
|
|
1432
|
+
p_line = (
|
|
1433
|
+
tf.paragraphs[0]
|
|
1434
|
+
if r_idx == 0
|
|
1435
|
+
else tf.add_paragraph()
|
|
1436
|
+
)
|
|
1437
|
+
|
|
1438
|
+
left_indent = 0.0
|
|
1439
|
+
if isinstance(cell, Paragraph):
|
|
1440
|
+
left_indent = getattr(cell.style, "leftIndent", 0.0)
|
|
1441
|
+
num_spaces = 0
|
|
1442
|
+
if left_indent > 0:
|
|
1443
|
+
num_spaces = max(
|
|
1444
|
+
0, int(round(left_indent / 4.8)) - 4
|
|
1445
|
+
)
|
|
1446
|
+
indent_str = " " * num_spaces
|
|
1447
|
+
|
|
1448
|
+
if indent_str:
|
|
1449
|
+
run_ind = p_line.add_run()
|
|
1450
|
+
run_ind.text = indent_str
|
|
1451
|
+
run_ind.font.name = "Courier New"
|
|
1452
|
+
run_ind.font.size = Pt(10)
|
|
1453
|
+
run_ind.font.color.rgb = text_rgb
|
|
1454
|
+
|
|
1455
|
+
parser = PptxHtmlParser(
|
|
1456
|
+
p_line,
|
|
1457
|
+
default_font_name="Courier New",
|
|
1458
|
+
default_font_size=10,
|
|
1459
|
+
default_color=text_rgb,
|
|
1460
|
+
)
|
|
1461
|
+
parser.feed(getattr(cell, "text", ""))
|
|
1462
|
+
else:
|
|
1463
|
+
parser = PptxHtmlParser(
|
|
1464
|
+
p_line,
|
|
1465
|
+
default_font_name="Courier New",
|
|
1466
|
+
default_font_size=10,
|
|
1467
|
+
default_color=text_rgb,
|
|
1468
|
+
)
|
|
1469
|
+
parser.feed(str(cell))
|
|
1470
|
+
|
|
1471
|
+
y_cursor += h_tbl
|
|
1472
|
+
elif is_callout:
|
|
1473
|
+
text_val = _get_val_plain(
|
|
1474
|
+
_unwrap_flowable(item._cellvalues[0][0])
|
|
1475
|
+
)
|
|
1476
|
+
num_lines = math.ceil(len(text_val) / 80.0)
|
|
1477
|
+
h_tbl = num_lines * 0.3 + 0.4
|
|
1478
|
+
tb = slide.shapes.add_textbox(
|
|
1479
|
+
Inches(0.8),
|
|
1480
|
+
Inches(y_cursor),
|
|
1481
|
+
Inches(11.73),
|
|
1482
|
+
Inches(h_tbl),
|
|
1483
|
+
)
|
|
1484
|
+
tf = tb.text_frame
|
|
1485
|
+
tf.word_wrap = True
|
|
1486
|
+
p_callout = tf.paragraphs[0]
|
|
1487
|
+
|
|
1488
|
+
parser = PptxHtmlParser(
|
|
1489
|
+
p_callout,
|
|
1490
|
+
default_font_name=t_theme.body_font,
|
|
1491
|
+
default_font_size=12,
|
|
1492
|
+
default_color=text_rgb,
|
|
1493
|
+
)
|
|
1494
|
+
cell = _unwrap_flowable(item._cellvalues[0][0])
|
|
1495
|
+
if type(cell).__name__ == "Paragraph":
|
|
1496
|
+
parser.feed(getattr(cell, "text", ""))
|
|
1497
|
+
else:
|
|
1498
|
+
parser.feed(str(cell))
|
|
1499
|
+
|
|
1500
|
+
fill = tb.fill
|
|
1501
|
+
fill.solid()
|
|
1502
|
+
if is_tip:
|
|
1503
|
+
fill.fore_color.rgb = hex_to_rgb(t_theme.green_bg)
|
|
1504
|
+
tb.line.color.rgb = hex_to_rgb(t_theme.green)
|
|
1505
|
+
elif is_note:
|
|
1506
|
+
fill.fore_color.rgb = hex_to_rgb(t_theme.yellow_bg)
|
|
1507
|
+
tb.line.color.rgb = hex_to_rgb(t_theme.yellow)
|
|
1508
|
+
elif is_warning:
|
|
1509
|
+
fill.fore_color.rgb = hex_to_rgb(t_theme.red_bg)
|
|
1510
|
+
tb.line.color.rgb = hex_to_rgb(t_theme.red)
|
|
1511
|
+
elif is_important:
|
|
1512
|
+
fill.fore_color.rgb = hex_to_rgb(t_theme.purple_bg)
|
|
1513
|
+
tb.line.color.rgb = hex_to_rgb(t_theme.purple)
|
|
1514
|
+
elif is_exam:
|
|
1515
|
+
fill.fore_color.rgb = hex_to_rgb(t_theme.yellow_bg)
|
|
1516
|
+
tb.line.color.rgb = hex_to_rgb(t_theme.yellow)
|
|
1517
|
+
elif is_theorem:
|
|
1518
|
+
fill.fore_color.rgb = hex_to_rgb(t_theme.purple_bg)
|
|
1519
|
+
tb.line.color.rgb = hex_to_rgb(t_theme.purple)
|
|
1520
|
+
elif is_definition:
|
|
1521
|
+
fill.fore_color.rgb = hex_to_rgb(t_theme.surface_alt)
|
|
1522
|
+
tb.line.color.rgb = hex_to_rgb(t_theme.accent)
|
|
1523
|
+
elif is_highlight:
|
|
1524
|
+
fill.fore_color.rgb = hex_to_rgb(t_theme.surface_alt)
|
|
1525
|
+
tb.line.color.rgb = hex_to_rgb(t_theme.yellow)
|
|
1526
|
+
elif is_question:
|
|
1527
|
+
fill.fore_color.rgb = hex_to_rgb(t_theme.surface_alt)
|
|
1528
|
+
tb.line.color.rgb = hex_to_rgb(t_theme.accent)
|
|
1529
|
+
elif is_answer:
|
|
1530
|
+
fill.fore_color.rgb = hex_to_rgb(t_theme.surface)
|
|
1531
|
+
tb.line.color.rgb = hex_to_rgb(t_theme.green)
|
|
1532
|
+
|
|
1533
|
+
tb.line.width = Pt(1.5)
|
|
1534
|
+
y_cursor += h_tbl
|
|
1535
|
+
elif is_flashcard:
|
|
1536
|
+
inner_content = _unwrap_flowable(
|
|
1537
|
+
getattr(item, "_cellvalues", [])[0][0]
|
|
1538
|
+
)
|
|
1539
|
+
inner_table = (
|
|
1540
|
+
next(
|
|
1541
|
+
(
|
|
1542
|
+
x
|
|
1543
|
+
for x in inner_content
|
|
1544
|
+
if type(x).__name__ == "Table"
|
|
1545
|
+
),
|
|
1546
|
+
None,
|
|
1547
|
+
)
|
|
1548
|
+
if isinstance(inner_content, list)
|
|
1549
|
+
else (
|
|
1550
|
+
inner_content
|
|
1551
|
+
if type(inner_content).__name__ == "Table"
|
|
1552
|
+
else None
|
|
1553
|
+
)
|
|
1554
|
+
)
|
|
1555
|
+
if inner_table is not None:
|
|
1556
|
+
inner_cell = getattr(inner_table, "_cellvalues", [])[0][
|
|
1557
|
+
0
|
|
1558
|
+
]
|
|
1559
|
+
text_val = _get_val_plain(_unwrap_flowable(inner_cell))
|
|
1560
|
+
else:
|
|
1561
|
+
text_val = ""
|
|
1562
|
+
num_lines = math.ceil(len(text_val) / 80.0)
|
|
1563
|
+
h_tbl = num_lines * 0.3 + 0.4
|
|
1564
|
+
tb = slide.shapes.add_textbox(
|
|
1565
|
+
Inches(0.8),
|
|
1566
|
+
Inches(y_cursor),
|
|
1567
|
+
Inches(11.73),
|
|
1568
|
+
Inches(h_tbl),
|
|
1569
|
+
)
|
|
1570
|
+
tf = tb.text_frame
|
|
1571
|
+
tf.word_wrap = True
|
|
1572
|
+
p_fc = tf.paragraphs[0]
|
|
1573
|
+
p_fc.text = text_val
|
|
1574
|
+
p_fc.font.size = Pt(12)
|
|
1575
|
+
p_fc.font.color.rgb = text_rgb
|
|
1576
|
+
p_fc.font.name = t_theme.body_font
|
|
1577
|
+
|
|
1578
|
+
fill = tb.fill
|
|
1579
|
+
fill.solid()
|
|
1580
|
+
fill.fore_color.rgb = hex_to_rgb(t_theme.surface_alt)
|
|
1581
|
+
tb.line.color.rgb = hex_to_rgb(t_theme.accent)
|
|
1582
|
+
tb.line.width = Pt(2.0)
|
|
1583
|
+
y_cursor += h_tbl
|
|
1584
|
+
elif is_mcq:
|
|
1585
|
+
mcq_lines = []
|
|
1586
|
+
for r in getattr(item, "_cellvalues", []):
|
|
1587
|
+
cell = _unwrap_flowable(r[0])
|
|
1588
|
+
if isinstance(cell, Paragraph):
|
|
1589
|
+
mcq_lines.append(_get_val_plain(cell))
|
|
1590
|
+
mcq_text = "\n".join(mcq_lines)
|
|
1591
|
+
num_lines = len(mcq_lines) + 2
|
|
1592
|
+
h_tbl = num_lines * 0.25 + 0.4
|
|
1593
|
+
tb = slide.shapes.add_textbox(
|
|
1594
|
+
Inches(0.8),
|
|
1595
|
+
Inches(y_cursor),
|
|
1596
|
+
Inches(11.73),
|
|
1597
|
+
Inches(h_tbl),
|
|
1598
|
+
)
|
|
1599
|
+
tf = tb.text_frame
|
|
1600
|
+
tf.word_wrap = True
|
|
1601
|
+
p_mcq = tf.paragraphs[0]
|
|
1602
|
+
p_mcq.text = mcq_text
|
|
1603
|
+
p_mcq.font.size = Pt(11)
|
|
1604
|
+
p_mcq.font.color.rgb = text_rgb
|
|
1605
|
+
p_mcq.font.name = t_theme.body_font
|
|
1606
|
+
|
|
1607
|
+
fill = tb.fill
|
|
1608
|
+
fill.solid()
|
|
1609
|
+
fill.fore_color.rgb = hex_to_rgb(t_theme.surface_alt)
|
|
1610
|
+
tb.line.color.rgb = hex_to_rgb(t_theme.accent2)
|
|
1611
|
+
tb.line.width = Pt(1.5)
|
|
1612
|
+
y_cursor += h_tbl
|
|
1613
|
+
else:
|
|
1614
|
+
is_packet = getattr(item, "_is_packet_format", False)
|
|
1615
|
+
is_frame = getattr(item, "_is_frame_format", False)
|
|
1616
|
+
rows_cnt = len(item._cellvalues)
|
|
1617
|
+
cols_cnt = len(item._cellvalues[0]) if rows_cnt > 0 else 0
|
|
1618
|
+
if rows_cnt > 0 and cols_cnt > 0:
|
|
1619
|
+
h_tbl = rows_cnt * (0.3 if is_packet else 0.4) + 0.3
|
|
1620
|
+
table_shape = slide.shapes.add_table(
|
|
1621
|
+
rows_cnt,
|
|
1622
|
+
cols_cnt,
|
|
1623
|
+
Inches(0.8),
|
|
1624
|
+
Inches(y_cursor),
|
|
1625
|
+
Inches(11.73),
|
|
1626
|
+
Inches(h_tbl),
|
|
1627
|
+
)
|
|
1628
|
+
tbl = table_shape.table
|
|
1629
|
+
|
|
1630
|
+
if (
|
|
1631
|
+
not hasattr(item, "_spanRanges")
|
|
1632
|
+
or not item._spanRanges
|
|
1633
|
+
):
|
|
1634
|
+
try:
|
|
1635
|
+
item._calcSpanRanges()
|
|
1636
|
+
except Exception:
|
|
1637
|
+
pass
|
|
1638
|
+
spans_dict = getattr(item, "_spanRanges", {})
|
|
1639
|
+
|
|
1640
|
+
if spans_dict:
|
|
1641
|
+
for coord, span_val in spans_dict.items():
|
|
1642
|
+
if span_val is not None:
|
|
1643
|
+
sc, sr, ec, er = span_val
|
|
1644
|
+
if ec < cols_cnt and er < rows_cnt:
|
|
1645
|
+
cell_start = tbl.cell(sr, sc)
|
|
1646
|
+
cell_end = tbl.cell(er, ec)
|
|
1647
|
+
cell_start.merge(cell_end)
|
|
1648
|
+
|
|
1649
|
+
for r_idx in range(rows_cnt):
|
|
1650
|
+
for c_idx in range(cols_cnt):
|
|
1651
|
+
if (
|
|
1652
|
+
spans_dict
|
|
1653
|
+
and spans_dict.get((c_idx, r_idx)) is None
|
|
1654
|
+
and (c_idx, r_idx) in spans_dict
|
|
1655
|
+
):
|
|
1656
|
+
continue
|
|
1657
|
+
|
|
1658
|
+
cell = tbl.cell(r_idx, c_idx)
|
|
1659
|
+
val = _unwrap_flowable(
|
|
1660
|
+
item._cellvalues[r_idx][c_idx]
|
|
1661
|
+
)
|
|
1662
|
+
plain_cell = _get_val_plain(val)
|
|
1663
|
+
cell.text = plain_cell
|
|
1664
|
+
cell.fill.solid()
|
|
1665
|
+
|
|
1666
|
+
if is_packet:
|
|
1667
|
+
if r_idx == 0 and len(item._cellvalues) > 1:
|
|
1668
|
+
cell.fill.fore_color.rgb = hex_to_rgb(
|
|
1669
|
+
t_theme.surface_alt
|
|
1670
|
+
)
|
|
1671
|
+
font_sz = Pt(7)
|
|
1672
|
+
else:
|
|
1673
|
+
cell.fill.fore_color.rgb = surface_rgb
|
|
1674
|
+
font_sz = Pt(8)
|
|
1675
|
+
elif is_frame:
|
|
1676
|
+
cell.fill.fore_color.rgb = surface_rgb
|
|
1677
|
+
font_sz = Pt(9)
|
|
1678
|
+
else:
|
|
1679
|
+
if r_idx == 0:
|
|
1680
|
+
cell.fill.fore_color.rgb = hex_to_rgb(
|
|
1681
|
+
t_theme.table_hdr
|
|
1682
|
+
)
|
|
1683
|
+
font_sz = Pt(11)
|
|
1684
|
+
else:
|
|
1685
|
+
bg_hex = (
|
|
1686
|
+
t_theme.surface
|
|
1687
|
+
if r_idx % 2 == 1
|
|
1688
|
+
else t_theme.surface_alt
|
|
1689
|
+
)
|
|
1690
|
+
cell.fill.fore_color.rgb = hex_to_rgb(
|
|
1691
|
+
bg_hex
|
|
1692
|
+
)
|
|
1693
|
+
font_sz = Pt(10)
|
|
1694
|
+
|
|
1695
|
+
p_cell = cell.text_frame.paragraphs[0]
|
|
1696
|
+
p_cell.font.size = font_sz
|
|
1697
|
+
p_cell.font.color.rgb = text_rgb
|
|
1698
|
+
p_cell.font.name = t_theme.body_font
|
|
1699
|
+
|
|
1700
|
+
y_cursor += h_tbl
|
|
1701
|
+
|
|
1702
|
+
elif item_name == "Spacer":
|
|
1703
|
+
y_cursor += getattr(item, "height", 0.0) / 72.0
|
|
1704
|
+
|
|
1705
|
+
elif (
|
|
1706
|
+
item.__class__.__name__ == "Drawing"
|
|
1707
|
+
or hasattr(item, "drawing")
|
|
1708
|
+
or item_name == "ResponsiveDrawingFlowable"
|
|
1709
|
+
):
|
|
1710
|
+
drawing = (
|
|
1711
|
+
getattr(item, "drawing", None)
|
|
1712
|
+
if hasattr(item, "drawing")
|
|
1713
|
+
else item
|
|
1714
|
+
)
|
|
1715
|
+
if drawing is not None:
|
|
1716
|
+
h_img = getattr(drawing, "height", 0.0) / 72.0
|
|
1717
|
+
w_img = getattr(drawing, "width", 0.0) / 72.0
|
|
1718
|
+
|
|
1719
|
+
max_h = 4.5
|
|
1720
|
+
max_w = 11.5
|
|
1721
|
+
if h_img > max_h:
|
|
1722
|
+
scale = max_h / h_img
|
|
1723
|
+
h_img *= scale
|
|
1724
|
+
w_img *= scale
|
|
1725
|
+
if w_img > max_w:
|
|
1726
|
+
scale = max_w / w_img
|
|
1727
|
+
h_img *= scale
|
|
1728
|
+
w_img *= scale
|
|
1729
|
+
|
|
1730
|
+
from reportlab.graphics import renderPM
|
|
1731
|
+
|
|
1732
|
+
temp_png = f"temp_slide_diag_{g_idx}_{y_cursor:.2f}.png"
|
|
1733
|
+
success = False
|
|
1734
|
+
try:
|
|
1735
|
+
renderPM.drawToFile(drawing, temp_png, fmt="PNG")
|
|
1736
|
+
success = True
|
|
1737
|
+
except Exception:
|
|
1738
|
+
temp_pdf = temp_png.replace(".png", ".pdf")
|
|
1739
|
+
try:
|
|
1740
|
+
from reportlab.graphics import renderPDF
|
|
1741
|
+
import fitz
|
|
1742
|
+
|
|
1743
|
+
renderPDF.drawToFile(drawing, temp_pdf)
|
|
1744
|
+
pdf_doc = fitz.open(temp_pdf)
|
|
1745
|
+
page = pdf_doc[0]
|
|
1746
|
+
pix = page.get_pixmap(dpi=300) # type: ignore
|
|
1747
|
+
pix.save(temp_png)
|
|
1748
|
+
pdf_doc.close()
|
|
1749
|
+
success = True
|
|
1750
|
+
except Exception:
|
|
1751
|
+
pass
|
|
1752
|
+
finally:
|
|
1753
|
+
if os.path.exists(temp_pdf):
|
|
1754
|
+
try:
|
|
1755
|
+
os.remove(temp_pdf)
|
|
1756
|
+
except Exception:
|
|
1757
|
+
pass
|
|
1758
|
+
|
|
1759
|
+
if success and os.path.exists(temp_png):
|
|
1760
|
+
try:
|
|
1761
|
+
x_pos = 0.8 + (11.73 - w_img) / 2.0
|
|
1762
|
+
slide.shapes.add_picture(
|
|
1763
|
+
temp_png,
|
|
1764
|
+
Inches(x_pos),
|
|
1765
|
+
Inches(y_cursor),
|
|
1766
|
+
width=Inches(w_img),
|
|
1767
|
+
)
|
|
1768
|
+
y_cursor += h_img + 0.2
|
|
1769
|
+
except Exception:
|
|
1770
|
+
pass
|
|
1771
|
+
finally:
|
|
1772
|
+
if os.path.exists(temp_png):
|
|
1773
|
+
try:
|
|
1774
|
+
os.remove(temp_png)
|
|
1775
|
+
except Exception:
|
|
1776
|
+
pass
|
|
1777
|
+
|
|
1778
|
+
elif item_name == "ImageFlowable":
|
|
1779
|
+
local_path = getattr(item, "local_path", "")
|
|
1780
|
+
caption = getattr(item, "caption", "")
|
|
1781
|
+
if local_path and os.path.exists(local_path):
|
|
1782
|
+
w_img = getattr(item, "w", 360.0) / 72.0
|
|
1783
|
+
h_img = getattr(item, "h", 270.0) / 72.0
|
|
1784
|
+
|
|
1785
|
+
max_h = 4.5
|
|
1786
|
+
max_w = 11.5
|
|
1787
|
+
if h_img > max_h:
|
|
1788
|
+
scale = max_h / h_img
|
|
1789
|
+
h_img *= scale
|
|
1790
|
+
w_img *= scale
|
|
1791
|
+
if w_img > max_w:
|
|
1792
|
+
scale = max_w / w_img
|
|
1793
|
+
h_img *= scale
|
|
1794
|
+
w_img *= scale
|
|
1795
|
+
|
|
1796
|
+
try:
|
|
1797
|
+
x_pos = 0.8 + (11.73 - w_img) / 2.0
|
|
1798
|
+
slide.shapes.add_picture(
|
|
1799
|
+
local_path,
|
|
1800
|
+
Inches(x_pos),
|
|
1801
|
+
Inches(y_cursor),
|
|
1802
|
+
width=Inches(w_img),
|
|
1803
|
+
)
|
|
1804
|
+
y_cursor += h_img + 0.2
|
|
1805
|
+
except Exception:
|
|
1806
|
+
pass
|
|
1807
|
+
else:
|
|
1808
|
+
# Render a styled missing image placeholder block in PowerPoint slides
|
|
1809
|
+
w_img = getattr(item, "w", 360.0) / 72.0
|
|
1810
|
+
h_img = getattr(item, "h", 270.0) / 72.0
|
|
1811
|
+
|
|
1812
|
+
max_h = 3.5
|
|
1813
|
+
max_w = 10.0
|
|
1814
|
+
if h_img > max_h:
|
|
1815
|
+
scale = max_h / h_img
|
|
1816
|
+
h_img *= scale
|
|
1817
|
+
w_img *= scale
|
|
1818
|
+
if w_img > max_w:
|
|
1819
|
+
scale = max_w / w_img
|
|
1820
|
+
h_img *= scale
|
|
1821
|
+
w_img *= scale
|
|
1822
|
+
|
|
1823
|
+
try:
|
|
1824
|
+
x_pos = 0.8 + (11.73 - w_img) / 2.0
|
|
1825
|
+
shape = slide.shapes.add_shape(
|
|
1826
|
+
MSO_SHAPE.RECTANGLE,
|
|
1827
|
+
Inches(x_pos),
|
|
1828
|
+
Inches(y_cursor),
|
|
1829
|
+
Inches(w_img),
|
|
1830
|
+
Inches(h_img),
|
|
1831
|
+
)
|
|
1832
|
+
shape.fill.solid()
|
|
1833
|
+
shape.fill.fore_color.rgb = surface_rgb
|
|
1834
|
+
shape.line.color.rgb = hex_to_rgb(t_theme.table_bdr)
|
|
1835
|
+
shape.line.width = Pt(1)
|
|
1836
|
+
|
|
1837
|
+
tf = shape.text_frame
|
|
1838
|
+
tf.word_wrap = True
|
|
1839
|
+
p_tf = tf.paragraphs[0]
|
|
1840
|
+
p_tf.text = f"[Image Not Available]\n{caption or ''}"
|
|
1841
|
+
p_tf.font.size = Pt(12)
|
|
1842
|
+
p_tf.font.color.rgb = text_rgb
|
|
1843
|
+
p_tf.font.name = t_theme.body_font
|
|
1844
|
+
p_tf.alignment = PP_ALIGN.CENTER
|
|
1845
|
+
|
|
1846
|
+
y_cursor += h_img + 0.2
|
|
1847
|
+
except Exception:
|
|
1848
|
+
pass
|
|
1849
|
+
|
|
1850
|
+
prs.save(filename)
|
|
1851
|
+
|
|
1852
|
+
|
|
1853
|
+
def build_html(
|
|
1854
|
+
output_dir,
|
|
1855
|
+
story=None,
|
|
1856
|
+
title=None,
|
|
1857
|
+
):
|
|
1858
|
+
from .helpers import story as default_story, math_latex_registry
|
|
1859
|
+
from .theme import get_theme
|
|
1860
|
+
from reportlab.graphics import renderSVG
|
|
1861
|
+
import os
|
|
1862
|
+
import re
|
|
1863
|
+
|
|
1864
|
+
if story is None:
|
|
1865
|
+
story = default_story
|
|
1866
|
+
|
|
1867
|
+
t_theme = get_theme()
|
|
1868
|
+
|
|
1869
|
+
def _fix_svg_scaling(svg_str):
|
|
1870
|
+
match_svg = re.search(r"<svg([^>]*)>", svg_str)
|
|
1871
|
+
if not match_svg:
|
|
1872
|
+
return svg_str
|
|
1873
|
+
attrs_str = match_svg.group(1)
|
|
1874
|
+
match_w = re.search(r'\bwidth\s*=\s*[\'"]([^\'"]+)[\'"]', attrs_str)
|
|
1875
|
+
match_h = re.search(r'\bheight\s*=\s*[\'"]([^\'"]+)[\'"]', attrs_str)
|
|
1876
|
+
if match_w and match_h:
|
|
1877
|
+
w_val = match_w.group(1).replace("pt", "").replace("px", "").strip()
|
|
1878
|
+
h_val = match_h.group(1).replace("pt", "").replace("px", "").strip()
|
|
1879
|
+
try:
|
|
1880
|
+
w = float(w_val)
|
|
1881
|
+
h = float(h_val)
|
|
1882
|
+
attrs_str = re.sub(r'\bwidth\s*=\s*[\'"][^\'"]*[\'"]', "", attrs_str)
|
|
1883
|
+
attrs_str = re.sub(r'\bheight\s*=\s*[\'"][^\'"]*[\'"]', "", attrs_str)
|
|
1884
|
+
attrs_str = re.sub(r'\bviewBox\s*=\s*[\'"][^\'"]*[\'"]', "", attrs_str)
|
|
1885
|
+
attrs_str = re.sub(r'\bstyle\s*=\s*[\'"][^\'"]*[\'"]', "", attrs_str)
|
|
1886
|
+
attrs_str = re.sub(
|
|
1887
|
+
r'\bpreserveAspectRatio\s*=\s*[\'"][^\'"]*[\'"]', "", attrs_str
|
|
1888
|
+
)
|
|
1889
|
+
attrs_str = " ".join(attrs_str.split()).strip()
|
|
1890
|
+
|
|
1891
|
+
new_tag = f'<svg {attrs_str} viewBox="0 0 {w} {h}" style="width: 100%; max-width: {w}px; height: auto; display: block; margin: 0 auto; overflow: visible;">'
|
|
1892
|
+
svg_str = (
|
|
1893
|
+
svg_str[: match_svg.start()] + new_tag + svg_str[match_svg.end() :]
|
|
1894
|
+
)
|
|
1895
|
+
except ValueError:
|
|
1896
|
+
pass
|
|
1897
|
+
return svg_str
|
|
1898
|
+
|
|
1899
|
+
def _get_val_html(val):
|
|
1900
|
+
val = _unwrap_flowable(val)
|
|
1901
|
+
if val is None:
|
|
1902
|
+
return ""
|
|
1903
|
+
if isinstance(val, str):
|
|
1904
|
+
return val
|
|
1905
|
+
cls_n = val.__class__.__name__
|
|
1906
|
+
if cls_n == "Spacer":
|
|
1907
|
+
return ""
|
|
1908
|
+
if cls_n == "Paragraph":
|
|
1909
|
+
return getattr(val, "text", "")
|
|
1910
|
+
if cls_n == "LaTeXFlowable":
|
|
1911
|
+
return f"$${getattr(val, 'latex_str', '')}$$"
|
|
1912
|
+
if cls_n == "ListFlowable":
|
|
1913
|
+
content_html = [
|
|
1914
|
+
'<ul style="padding-left: 20px; margin: 10px 0; text-align: left;">'
|
|
1915
|
+
]
|
|
1916
|
+
items = getattr(
|
|
1917
|
+
val,
|
|
1918
|
+
"_flowables",
|
|
1919
|
+
getattr(val, "_content", getattr(val, "_list_content", [])),
|
|
1920
|
+
)
|
|
1921
|
+
for li in items:
|
|
1922
|
+
content_html.append(
|
|
1923
|
+
f'<li style="margin-bottom: 6px;">{_get_val_html(li)}</li>'
|
|
1924
|
+
)
|
|
1925
|
+
content_html.append("</ul>")
|
|
1926
|
+
return "".join(content_html)
|
|
1927
|
+
if (
|
|
1928
|
+
cls_n == "Drawing"
|
|
1929
|
+
or hasattr(val, "drawing")
|
|
1930
|
+
or cls_n == "ResponsiveDrawingFlowable"
|
|
1931
|
+
):
|
|
1932
|
+
drawing = getattr(val, "drawing", None) if hasattr(val, "drawing") else val
|
|
1933
|
+
if (
|
|
1934
|
+
drawing is not None
|
|
1935
|
+
and getattr(getattr(drawing, "__class__", None), "__name__", "")
|
|
1936
|
+
== "Drawing"
|
|
1937
|
+
):
|
|
1938
|
+
try:
|
|
1939
|
+
svg_str = renderSVG.drawToString(drawing) # type: ignore
|
|
1940
|
+
svg_clean = re.sub(r"<\?xml[^>]*\?>", "", svg_str)
|
|
1941
|
+
svg_clean = re.sub(r"<!DOCTYPE[^>]*>", "", svg_clean)
|
|
1942
|
+
svg_scaled = _fix_svg_scaling(svg_clean)
|
|
1943
|
+
return f'<div style="text-align: center; width: 100%; cursor: zoom-in;" onclick="openDiagramOverlay(this)">{svg_scaled}</div>'
|
|
1944
|
+
except Exception:
|
|
1945
|
+
pass
|
|
1946
|
+
if isinstance(val, (list, tuple)):
|
|
1947
|
+
return "".join(_get_val_html(x) for x in val)
|
|
1948
|
+
return str(val)
|
|
1949
|
+
|
|
1950
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
1951
|
+
|
|
1952
|
+
sidebar_links = []
|
|
1953
|
+
content_html = []
|
|
1954
|
+
bookmark_counter = 0
|
|
1955
|
+
diagram_counter = 0
|
|
1956
|
+
|
|
1957
|
+
flattened_story = []
|
|
1958
|
+
for item in story:
|
|
1959
|
+
if item.__class__.__name__ == "KeepTogether" and hasattr(item, "_content"):
|
|
1960
|
+
flattened_story.extend(getattr(item, "_content", []))
|
|
1961
|
+
else:
|
|
1962
|
+
flattened_story.append(item)
|
|
1963
|
+
|
|
1964
|
+
in_theme_section = False
|
|
1965
|
+
|
|
1966
|
+
def start_theme_section(theme):
|
|
1967
|
+
nonlocal in_theme_section
|
|
1968
|
+
res = []
|
|
1969
|
+
if in_theme_section:
|
|
1970
|
+
res.append("</div></div>")
|
|
1971
|
+
res.append(
|
|
1972
|
+
f'<div class="theme-section" style="background-color: {theme.bg}; color: {theme.text}; width: 100%; box-sizing: border-box; padding: 40px 0; min-height: 100vh; transition: background-color 0.3s, color 0.3s;">'
|
|
1973
|
+
f'<div style="max-width: 800px; margin: 0 auto; padding: 0 40px;">'
|
|
1974
|
+
)
|
|
1975
|
+
in_theme_section = True
|
|
1976
|
+
return "".join(res)
|
|
1977
|
+
|
|
1978
|
+
for item in flattened_story:
|
|
1979
|
+
if getattr(item, "_is_toc_element", False):
|
|
1980
|
+
continue
|
|
1981
|
+
|
|
1982
|
+
cls_name = item.__class__.__name__
|
|
1983
|
+
|
|
1984
|
+
if cls_name == "ThemeSetterFlowable":
|
|
1985
|
+
t_theme = item.theme
|
|
1986
|
+
content_html.append(start_theme_section(t_theme))
|
|
1987
|
+
continue
|
|
1988
|
+
|
|
1989
|
+
if not in_theme_section:
|
|
1990
|
+
content_html.append(start_theme_section(t_theme))
|
|
1991
|
+
|
|
1992
|
+
if getattr(item, "_is_part_box", False):
|
|
1993
|
+
title_text = getattr(item, "_box_title", "")
|
|
1994
|
+
plain_title = re.sub(r"<[^>]+>", "", title_text)
|
|
1995
|
+
anchor = f"part-{bookmark_counter}"
|
|
1996
|
+
bookmark_counter += 1
|
|
1997
|
+
sidebar_links.append(
|
|
1998
|
+
f'<a href="#{anchor}" class="nav-part">{plain_title}</a>'
|
|
1999
|
+
)
|
|
2000
|
+
content_html.append(
|
|
2001
|
+
f'<div id="{anchor}" class="part-header" style="border-bottom: 2px solid {t_theme.table_bdr}; margin-top: 30px; padding-bottom: 10px;"><h1 style="color: {t_theme.accent}; margin: 0;">{title_text}</h1></div>'
|
|
2002
|
+
)
|
|
2003
|
+
|
|
2004
|
+
elif getattr(item, "_is_chap_box", False):
|
|
2005
|
+
title_text = getattr(item, "_box_title", "")
|
|
2006
|
+
plain_title = re.sub(r"<[^>]+>", "", title_text)
|
|
2007
|
+
anchor = f"chap-{bookmark_counter}"
|
|
2008
|
+
bookmark_counter += 1
|
|
2009
|
+
sidebar_links.append(
|
|
2010
|
+
f'<a href="#{anchor}" class="nav-chap">{plain_title}</a>'
|
|
2011
|
+
)
|
|
2012
|
+
content_html.append(
|
|
2013
|
+
f'<div id="{anchor}" class="chap-header" style="border-bottom: 2px solid {t_theme.table_bdr}; margin-top: 30px; padding-bottom: 10px;"><h2 style="color: {t_theme.accent}; margin: 0;">{title_text}</h2></div>'
|
|
2014
|
+
)
|
|
2015
|
+
|
|
2016
|
+
elif getattr(item, "_is_section", False):
|
|
2017
|
+
title_text = getattr(item, "_section_title", "")
|
|
2018
|
+
plain_title = re.sub(r"<[^>]+>", "", title_text)
|
|
2019
|
+
anchor = f"sect-{bookmark_counter}"
|
|
2020
|
+
bookmark_counter += 1
|
|
2021
|
+
sidebar_links.append(
|
|
2022
|
+
f'<a href="#{anchor}" class="nav-sect">{plain_title}</a>'
|
|
2023
|
+
)
|
|
2024
|
+
content_html.append(
|
|
2025
|
+
f'<h3 id="{anchor}" class="section-title" style="color: {t_theme.accent}; margin-top: 24px; margin-bottom: 12px;">{title_text}</h3><hr class="section-rule" style="border: 0; border-top: 1px solid {t_theme.accent}; margin-bottom: 15px;" />'
|
|
2026
|
+
)
|
|
2027
|
+
|
|
2028
|
+
elif getattr(item, "_is_subsection", False):
|
|
2029
|
+
title_text = getattr(item, "_subsection_title", "")
|
|
2030
|
+
content_html.append(
|
|
2031
|
+
f'<h4 class="subsection-title" style="color: {t_theme.accent}; margin-top: 24px; margin-bottom: 12px;">{title_text}</h4>'
|
|
2032
|
+
)
|
|
2033
|
+
|
|
2034
|
+
elif cls_name == "Paragraph":
|
|
2035
|
+
p_text = getattr(item, "text", "")
|
|
2036
|
+
content_html.append(f'<p class="body-paragraph">{p_text}</p>')
|
|
2037
|
+
|
|
2038
|
+
elif cls_name == "IndexPrinterFlowable":
|
|
2039
|
+
content_html.append(
|
|
2040
|
+
f'<p class="body-paragraph" style="color: {t_theme.text_dim}; font-style: italic;">Index mapping is only available in the PDF format.</p>'
|
|
2041
|
+
)
|
|
2042
|
+
|
|
2043
|
+
elif cls_name in ("Table", "CodeBlockTable"):
|
|
2044
|
+
is_code = getattr(item, "_is_code_block", False)
|
|
2045
|
+
is_tip = getattr(item, "_is_tip", False)
|
|
2046
|
+
is_note = getattr(item, "_is_note", False)
|
|
2047
|
+
is_warning = getattr(item, "_is_warning", False)
|
|
2048
|
+
is_important = getattr(item, "_is_important", False)
|
|
2049
|
+
is_exam = getattr(item, "_is_exam", False)
|
|
2050
|
+
is_theorem = getattr(item, "_is_theorem", False)
|
|
2051
|
+
is_definition = getattr(item, "_is_definition", False)
|
|
2052
|
+
is_highlight = getattr(item, "_is_highlight", False)
|
|
2053
|
+
is_question = getattr(item, "_is_question", False)
|
|
2054
|
+
is_answer = getattr(item, "_is_answer", False)
|
|
2055
|
+
is_mcq = getattr(item, "_is_mcq", False)
|
|
2056
|
+
is_flashcard = getattr(item, "_is_flashcard", False)
|
|
2057
|
+
is_frame_format = getattr(item, "_is_frame_format", False)
|
|
2058
|
+
is_packet_format = getattr(item, "_is_packet_format", False)
|
|
2059
|
+
|
|
2060
|
+
if is_code and len(item._cellvalues) > 0:
|
|
2061
|
+
code_lines = []
|
|
2062
|
+
for r in item._cellvalues:
|
|
2063
|
+
cell = _unwrap_flowable(r[0])
|
|
2064
|
+
if isinstance(cell, Paragraph):
|
|
2065
|
+
left_indent = getattr(cell.style, "leftIndent", 0.0)
|
|
2066
|
+
num_spaces = 0
|
|
2067
|
+
if left_indent > 0:
|
|
2068
|
+
num_spaces = max(0, int(round(left_indent / 4.8)) - 4)
|
|
2069
|
+
indent_str = " " * num_spaces
|
|
2070
|
+
line_text = indent_str + getattr(cell, "text", "")
|
|
2071
|
+
code_lines.append(line_text)
|
|
2072
|
+
else:
|
|
2073
|
+
code_lines.append(str(cell))
|
|
2074
|
+
code_text = "\n".join(code_lines)
|
|
2075
|
+
content_html.append(
|
|
2076
|
+
f'<div class="code-container" style="position: relative; margin: 15px 0;">'
|
|
2077
|
+
f'<button class="copy-btn" onclick="copyCode(this)" style="position: absolute; right: 10px; top: 10px; background: {t_theme.surface_alt}; border: 1px solid {t_theme.table_bdr}; color: {t_theme.text}; padding: 4px 8px; border-radius: 4px; font-size: 0.75em; cursor: pointer; opacity: 0; transition: opacity 0.2s; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">Copy</button>'
|
|
2078
|
+
f'<pre style="background-color: {t_theme.code_bg}; color: {t_theme.text_code}; padding: 16px; border-radius: 4px; border-left: 4px solid {t_theme.accent}; overflow-x: auto; font-family: Courier, monospace; margin: 0; white-space: pre;"><code>{code_text}</code></pre>'
|
|
2079
|
+
f"</div>"
|
|
2080
|
+
)
|
|
2081
|
+
elif is_tip:
|
|
2082
|
+
text = _get_val_html(_unwrap_flowable(item._cellvalues[0][0]))
|
|
2083
|
+
content_html.append(
|
|
2084
|
+
f'<div class="callout tip" style="border-left: 4px solid {t_theme.green}; background-color: {t_theme.green_bg}; color: {t_theme.text}; padding: 12px; margin: 15px 0; border-radius: 4px;">{text}</div>'
|
|
2085
|
+
)
|
|
2086
|
+
elif is_note:
|
|
2087
|
+
text = _get_val_html(_unwrap_flowable(item._cellvalues[0][0]))
|
|
2088
|
+
content_html.append(
|
|
2089
|
+
f'<div class="callout note" style="border-left: 4px solid {t_theme.yellow}; background-color: {t_theme.yellow_bg}; color: {t_theme.text}; padding: 12px; margin: 15px 0; border-radius: 4px;">{text}</div>'
|
|
2090
|
+
)
|
|
2091
|
+
elif is_warning:
|
|
2092
|
+
text = _get_val_html(_unwrap_flowable(item._cellvalues[0][0]))
|
|
2093
|
+
content_html.append(
|
|
2094
|
+
f'<div class="callout warning" style="border-left: 4px solid {t_theme.red}; background-color: {t_theme.red_bg}; color: {t_theme.text}; padding: 12px; margin: 15px 0; border-radius: 4px;">{text}</div>'
|
|
2095
|
+
)
|
|
2096
|
+
elif is_important:
|
|
2097
|
+
text = _get_val_html(_unwrap_flowable(item._cellvalues[0][0]))
|
|
2098
|
+
content_html.append(
|
|
2099
|
+
f'<div class="callout important" style="border-left: 4px solid {t_theme.purple}; background-color: {t_theme.purple_bg}; color: {t_theme.text}; padding: 12px; margin: 15px 0; border-radius: 4px;">{text}</div>'
|
|
2100
|
+
)
|
|
2101
|
+
elif is_exam:
|
|
2102
|
+
text = _get_val_html(_unwrap_flowable(item._cellvalues[0][0]))
|
|
2103
|
+
content_html.append(
|
|
2104
|
+
f'<div class="callout exam" style="border-left: 4px solid {t_theme.yellow}; background-color: {t_theme.yellow_bg}; color: {t_theme.text}; padding: 12px; margin: 15px 0; border-radius: 4px;">{text}</div>'
|
|
2105
|
+
)
|
|
2106
|
+
elif is_theorem:
|
|
2107
|
+
text = _get_val_html(_unwrap_flowable(item._cellvalues[0][0]))
|
|
2108
|
+
content_html.append(
|
|
2109
|
+
f'<div class="callout theorem" style="border: 1px solid {t_theme.purple}; background-color: {t_theme.purple_bg}; color: {t_theme.text}; padding: 12px; margin: 15px 0; border-radius: 4px;">{text}</div>'
|
|
2110
|
+
)
|
|
2111
|
+
elif is_definition:
|
|
2112
|
+
text = _get_val_html(_unwrap_flowable(item._cellvalues[0][0]))
|
|
2113
|
+
content_html.append(
|
|
2114
|
+
f'<div class="callout definition" style="border-left: 4px solid {t_theme.accent}; background-color: {t_theme.surface_alt}; color: {t_theme.text}; padding: 12px; margin: 15px 0; border-radius: 4px;">{text}</div>'
|
|
2115
|
+
)
|
|
2116
|
+
elif is_highlight:
|
|
2117
|
+
text = _get_val_html(_unwrap_flowable(item._cellvalues[0][0]))
|
|
2118
|
+
content_html.append(
|
|
2119
|
+
f'<div class="callout highlight" style="border-left: 4px solid {t_theme.yellow}; background-color: {t_theme.surface_alt}; color: {t_theme.text}; padding: 12px; margin: 15px 0; border-radius: 4px;">{text}</div>'
|
|
2120
|
+
)
|
|
2121
|
+
elif is_question:
|
|
2122
|
+
text = _get_val_html(_unwrap_flowable(item._cellvalues[0][0]))
|
|
2123
|
+
content_html.append(
|
|
2124
|
+
f'<div class="callout question" style="border-left: 4px solid {t_theme.accent}; background-color: {t_theme.surface_alt}; color: {t_theme.text}; padding: 12px; margin: 15px 0; border-radius: 4px;">{text}</div>'
|
|
2125
|
+
)
|
|
2126
|
+
elif is_answer:
|
|
2127
|
+
text = _get_val_html(_unwrap_flowable(item._cellvalues[0][0]))
|
|
2128
|
+
content_html.append(
|
|
2129
|
+
f'<div class="callout answer" style="border-left: 4px solid {t_theme.green}; background-color: {t_theme.surface}; color: {t_theme.text}; padding: 12px; margin: 15px 0; border-radius: 4px;">{text}</div>'
|
|
2130
|
+
)
|
|
2131
|
+
elif is_flashcard:
|
|
2132
|
+
inner_content = _unwrap_flowable(getattr(item, "_cellvalues", [])[0][0])
|
|
2133
|
+
inner_table = (
|
|
2134
|
+
next(
|
|
2135
|
+
(x for x in inner_content if type(x).__name__ == "Table"),
|
|
2136
|
+
None,
|
|
2137
|
+
)
|
|
2138
|
+
if isinstance(inner_content, list)
|
|
2139
|
+
else (
|
|
2140
|
+
inner_content
|
|
2141
|
+
if type(inner_content).__name__ == "Table"
|
|
2142
|
+
else None
|
|
2143
|
+
)
|
|
2144
|
+
)
|
|
2145
|
+
if inner_table is not None:
|
|
2146
|
+
inner_cell = getattr(inner_table, "_cellvalues", [])[0][0]
|
|
2147
|
+
text = _get_val_html(_unwrap_flowable(inner_cell))
|
|
2148
|
+
else:
|
|
2149
|
+
text = ""
|
|
2150
|
+
content_html.append(
|
|
2151
|
+
f'<div class="callout flashcard" style="border: 2px solid {t_theme.accent}; background-color: {t_theme.surface_alt}; color: {t_theme.text}; padding: 16px; margin: 15px 0; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">{text}</div>'
|
|
2152
|
+
)
|
|
2153
|
+
elif is_mcq:
|
|
2154
|
+
mcq_html = [
|
|
2155
|
+
f'<div class="callout mcq" style="border-left: 4px solid {t_theme.accent2}; background-color: {t_theme.surface_alt}; color: {t_theme.text}; padding: 16px; margin: 15px 0; border-radius: 4px;">'
|
|
2156
|
+
]
|
|
2157
|
+
for r in getattr(item, "_cellvalues", []):
|
|
2158
|
+
cell = _unwrap_flowable(r[0])
|
|
2159
|
+
if isinstance(cell, Paragraph):
|
|
2160
|
+
text = _get_val_html(cell)
|
|
2161
|
+
mcq_html.append(f'<p style="margin: 6px 0;">{text}</p>')
|
|
2162
|
+
mcq_html.append("</div>")
|
|
2163
|
+
content_html.append("".join(mcq_html))
|
|
2164
|
+
else:
|
|
2165
|
+
is_packet = is_packet_format
|
|
2166
|
+
is_frame = is_frame_format
|
|
2167
|
+
|
|
2168
|
+
if is_packet:
|
|
2169
|
+
tbl_style = f"width: 100%; max-width: 600px; table-layout: fixed; border-collapse: collapse; margin: 15px auto; border: 1px solid {t_theme.table_bdr}; font-size: 0.65em;"
|
|
2170
|
+
elif is_frame:
|
|
2171
|
+
tbl_style = f"width: 100%; max-width: 600px; table-layout: fixed; border-collapse: collapse; margin: 15px auto; border: 1px solid {t_theme.table_bdr}; font-size: 0.8em;"
|
|
2172
|
+
else:
|
|
2173
|
+
tbl_style = f"width: 100%; max-width: 650px; border-collapse: collapse; margin: 15px auto; border: 1px solid {t_theme.table_bdr};"
|
|
2174
|
+
|
|
2175
|
+
if not hasattr(item, "_spanRanges") or not item._spanRanges:
|
|
2176
|
+
try:
|
|
2177
|
+
item._calcSpanRanges()
|
|
2178
|
+
except Exception:
|
|
2179
|
+
pass
|
|
2180
|
+
spans_dict = getattr(item, "_spanRanges", {})
|
|
2181
|
+
|
|
2182
|
+
html_tbl = [
|
|
2183
|
+
f'<div class="table-container" style="overflow-x: auto; width: 100%; text-align: center;">'
|
|
2184
|
+
f'<table style="{tbl_style}">'
|
|
2185
|
+
]
|
|
2186
|
+
for r_idx, r in enumerate(getattr(item, "_cellvalues", [])):
|
|
2187
|
+
bg = (
|
|
2188
|
+
t_theme.table_hdr
|
|
2189
|
+
if r_idx == 0 and not is_packet and not is_frame
|
|
2190
|
+
else (
|
|
2191
|
+
t_theme.surface if r_idx % 2 == 1 else t_theme.surface_alt
|
|
2192
|
+
)
|
|
2193
|
+
)
|
|
2194
|
+
txt_color = (
|
|
2195
|
+
"#ffffff"
|
|
2196
|
+
if r_idx == 0 and not is_packet and not is_frame
|
|
2197
|
+
else t_theme.text
|
|
2198
|
+
)
|
|
2199
|
+
|
|
2200
|
+
if is_packet:
|
|
2201
|
+
if r_idx == 0 and len(getattr(item, "_cellvalues", [])) > 1:
|
|
2202
|
+
bg = t_theme.surface_alt
|
|
2203
|
+
txt_color = t_theme.text_dim
|
|
2204
|
+
|
|
2205
|
+
html_tbl.append(
|
|
2206
|
+
f'<tr style="background-color: {bg}; color: {txt_color}; border-bottom: 1px solid {t_theme.table_bdr};">'
|
|
2207
|
+
)
|
|
2208
|
+
for c_idx, val in enumerate(r):
|
|
2209
|
+
if (
|
|
2210
|
+
spans_dict
|
|
2211
|
+
and (c_idx, r_idx) in spans_dict
|
|
2212
|
+
and spans_dict[(c_idx, r_idx)] is None
|
|
2213
|
+
):
|
|
2214
|
+
continue
|
|
2215
|
+
tag = (
|
|
2216
|
+
"th"
|
|
2217
|
+
if r_idx == 0 and not is_packet and not is_frame
|
|
2218
|
+
else "td"
|
|
2219
|
+
)
|
|
2220
|
+
|
|
2221
|
+
colspan_attr = ""
|
|
2222
|
+
rowspan_attr = ""
|
|
2223
|
+
if spans_dict and (c_idx, r_idx) in spans_dict:
|
|
2224
|
+
span_val = spans_dict[(c_idx, r_idx)]
|
|
2225
|
+
if span_val:
|
|
2226
|
+
sc, sr, ec, er = span_val
|
|
2227
|
+
cs = ec - sc + 1
|
|
2228
|
+
rs = er - sr + 1
|
|
2229
|
+
if cs > 1:
|
|
2230
|
+
colspan_attr = f' colspan="{cs}"'
|
|
2231
|
+
if rs > 1:
|
|
2232
|
+
rowspan_attr = f' rowspan="{rs}"'
|
|
2233
|
+
|
|
2234
|
+
if is_packet:
|
|
2235
|
+
cell_style = f"padding: 4px 1px; text-align: center; border: 1px solid {t_theme.table_bdr}; overflow: hidden;"
|
|
2236
|
+
elif is_frame:
|
|
2237
|
+
cell_style = f"padding: 6px 4px; text-align: center; border: 1px solid {t_theme.table_bdr};"
|
|
2238
|
+
else:
|
|
2239
|
+
cell_style = f"padding: 8px 12px; text-align: left; border: 1px solid {t_theme.table_bdr};"
|
|
2240
|
+
|
|
2241
|
+
html_tbl.append(
|
|
2242
|
+
f'<{tag}{colspan_attr}{rowspan_attr} style="{cell_style}">{_get_val_html(val)}</{tag}>'
|
|
2243
|
+
)
|
|
2244
|
+
html_tbl.append("</tr>")
|
|
2245
|
+
html_tbl.append("</table></div>")
|
|
2246
|
+
content_html.append("".join(html_tbl))
|
|
2247
|
+
|
|
2248
|
+
elif (
|
|
2249
|
+
item.__class__.__name__ == "Drawing"
|
|
2250
|
+
or hasattr(item, "drawing")
|
|
2251
|
+
or cls_name == "ResponsiveDrawingFlowable"
|
|
2252
|
+
):
|
|
2253
|
+
drawing = (
|
|
2254
|
+
getattr(item, "drawing", None) if hasattr(item, "drawing") else item
|
|
2255
|
+
)
|
|
2256
|
+
if (
|
|
2257
|
+
drawing is not None
|
|
2258
|
+
and getattr(getattr(drawing, "__class__", None), "__name__", "")
|
|
2259
|
+
== "Drawing"
|
|
2260
|
+
):
|
|
2261
|
+
try:
|
|
2262
|
+
svg_str = renderSVG.drawToString(drawing) # type: ignore
|
|
2263
|
+
svg_clean = re.sub(r"<\?xml[^>]*\?>", "", svg_str)
|
|
2264
|
+
svg_clean = re.sub(r"<!DOCTYPE[^>]*>", "", svg_clean)
|
|
2265
|
+
|
|
2266
|
+
# Make clip path and group IDs unique to avoid ID collision
|
|
2267
|
+
clip_id = f"clip_{diagram_counter}"
|
|
2268
|
+
group_id = f"group_{diagram_counter}"
|
|
2269
|
+
svg_clean = svg_clean.replace('id="clip"', f'id="{clip_id}"')
|
|
2270
|
+
svg_clean = svg_clean.replace("url(#clip)", f"url(#{clip_id})")
|
|
2271
|
+
svg_clean = svg_clean.replace('id="group"', f'id="{group_id}"')
|
|
2272
|
+
diagram_counter += 1
|
|
2273
|
+
|
|
2274
|
+
svg_scaled = _fix_svg_scaling(svg_clean)
|
|
2275
|
+
|
|
2276
|
+
# Retrieve the diagram theme if attached to drawing or item
|
|
2277
|
+
diag_theme = getattr(
|
|
2278
|
+
item, "diagram_theme", getattr(drawing, "_diagram_theme", None)
|
|
2279
|
+
)
|
|
2280
|
+
|
|
2281
|
+
# Check dark/light mode and apply color inversion filter if they differ
|
|
2282
|
+
def is_dark_hex(hex_color):
|
|
2283
|
+
h = hex_color.lstrip("#")
|
|
2284
|
+
if len(h) == 3:
|
|
2285
|
+
h = "".join(c * 2 for c in h)
|
|
2286
|
+
try:
|
|
2287
|
+
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
|
2288
|
+
luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
|
|
2289
|
+
return luminance < 0.5
|
|
2290
|
+
except Exception:
|
|
2291
|
+
return True
|
|
2292
|
+
|
|
2293
|
+
page_is_dark = is_dark_hex(t_theme.bg)
|
|
2294
|
+
diag_is_dark = (
|
|
2295
|
+
is_dark_hex(diag_theme.bg) if diag_theme else page_is_dark
|
|
2296
|
+
)
|
|
2297
|
+
|
|
2298
|
+
filter_style = ""
|
|
2299
|
+
if page_is_dark != diag_is_dark:
|
|
2300
|
+
filter_style = "filter: invert(1) hue-rotate(180deg);"
|
|
2301
|
+
|
|
2302
|
+
# Always match the container background and border with the global theme to follow the whole page theme
|
|
2303
|
+
bg_color = t_theme.surface
|
|
2304
|
+
border_color = t_theme.table_bdr
|
|
2305
|
+
|
|
2306
|
+
content_html.append(
|
|
2307
|
+
f'<div class="diagram-container" style="text-align: center; margin: 20px 0; background: {bg_color}; padding: 15px; border-radius: 6px; border: 1px solid {border_color}; overflow-x: auto; overflow-y: visible; cursor: zoom-in;" onclick="openDiagramOverlay(this)">'
|
|
2308
|
+
f'<div style="{filter_style}">{svg_scaled}</div>'
|
|
2309
|
+
f"</div>"
|
|
2310
|
+
)
|
|
2311
|
+
except Exception:
|
|
2312
|
+
pass
|
|
2313
|
+
|
|
2314
|
+
elif cls_name == "ImageFlowable":
|
|
2315
|
+
src = getattr(item, "resolved_src", getattr(item, "src", ""))
|
|
2316
|
+
local_path = getattr(item, "local_path", "")
|
|
2317
|
+
caption = getattr(item, "caption", "")
|
|
2318
|
+
link = getattr(item, "link", None)
|
|
2319
|
+
|
|
2320
|
+
# Check if image resolved/downloaded successfully on disk
|
|
2321
|
+
is_valid_image = local_path and os.path.exists(local_path)
|
|
2322
|
+
|
|
2323
|
+
if is_valid_image:
|
|
2324
|
+
img_html = f'<img src="{src}" style="max-width: 100%; height: auto; display: block; margin: 0 auto; border-radius: 4px;" />'
|
|
2325
|
+
if link:
|
|
2326
|
+
img_html = f'<a href="{link}" target="_blank">{img_html}</a>'
|
|
2327
|
+
else:
|
|
2328
|
+
w_fallback = getattr(item, "w", 150)
|
|
2329
|
+
h_fallback = getattr(item, "h", 100)
|
|
2330
|
+
|
|
2331
|
+
# Limit length of display URL/path
|
|
2332
|
+
display_src = src
|
|
2333
|
+
if len(display_src) > 50:
|
|
2334
|
+
display_src = display_src[:47] + "..."
|
|
2335
|
+
|
|
2336
|
+
img_html = (
|
|
2337
|
+
f'<div class="image-fallback-box" style="margin: 0 auto; max-width: {w_fallback}px; height: {h_fallback}px; '
|
|
2338
|
+
f"border: 1px dashed {t_theme.table_bdr}; background: {t_theme.surface}; border-radius: 4px; "
|
|
2339
|
+
f"display: flex; flex-direction: column; align-items: center; justify-content: center; "
|
|
2340
|
+
f'color: {t_theme.text_dim}; font-size: 0.9em; padding: 10px; box-sizing: border-box; text-align: center; line-height: 1.2;">'
|
|
2341
|
+
f'<span style="font-size: 1.4em; margin-bottom: 4px;">⚠️</span>'
|
|
2342
|
+
f"<strong>[Image Not Available]</strong>"
|
|
2343
|
+
f'<span style="font-size: 0.75em; margin-top: 4px; word-break: break-all; opacity: 0.85;">{display_src}</span>'
|
|
2344
|
+
f"</div>"
|
|
2345
|
+
)
|
|
2346
|
+
if link:
|
|
2347
|
+
img_html = f'<a href="{link}" target="_blank" style="text-decoration: none; display: inline-block;">{img_html}</a>'
|
|
2348
|
+
|
|
2349
|
+
if caption:
|
|
2350
|
+
img_html += f'<p style="text-align: center; font-style: italic; font-size: 0.9em; margin-top: 5px; color: {t_theme.text_dim};">{caption}</p>'
|
|
2351
|
+
|
|
2352
|
+
content_html.append(
|
|
2353
|
+
f'<div class="image-container" style="text-align: center; margin: 20px 0;">'
|
|
2354
|
+
f"{img_html}"
|
|
2355
|
+
f"</div>"
|
|
2356
|
+
)
|
|
2357
|
+
|
|
2358
|
+
elif cls_name == "LaTeXFlowable":
|
|
2359
|
+
math_str = item.latex_str
|
|
2360
|
+
content_html.append(
|
|
2361
|
+
f'<div class="math-block" style="text-align: center; margin: 15px 0; font-size: 1.2em;">$${math_str}$$</div>'
|
|
2362
|
+
)
|
|
2363
|
+
|
|
2364
|
+
if in_theme_section:
|
|
2365
|
+
content_html.append("</div></div>")
|
|
2366
|
+
|
|
2367
|
+
extracted_title = None
|
|
2368
|
+
if not title:
|
|
2369
|
+
for item in flattened_story:
|
|
2370
|
+
if (
|
|
2371
|
+
type(item).__name__ == "Table"
|
|
2372
|
+
and hasattr(item, "_cellvalues")
|
|
2373
|
+
and item._cellvalues
|
|
2374
|
+
):
|
|
2375
|
+
try:
|
|
2376
|
+
cell = _unwrap_flowable(item._cellvalues[0][0])
|
|
2377
|
+
if isinstance(cell, list):
|
|
2378
|
+
cell = cell[0]
|
|
2379
|
+
if type(cell).__name__ == "Paragraph":
|
|
2380
|
+
p_style = getattr(cell, "style", None)
|
|
2381
|
+
if p_style:
|
|
2382
|
+
parent_name = getattr(
|
|
2383
|
+
getattr(p_style, "parent", None), "name", "NO_PARENT"
|
|
2384
|
+
)
|
|
2385
|
+
if parent_name in ("Cover_H1", "Chap_H1", "H1"):
|
|
2386
|
+
raw_text = getattr(cell, "text", "")
|
|
2387
|
+
clean_title = re.sub(r"<[^>]*>", "", raw_text).strip()
|
|
2388
|
+
if clean_title:
|
|
2389
|
+
extracted_title = clean_title
|
|
2390
|
+
break
|
|
2391
|
+
except Exception:
|
|
2392
|
+
pass
|
|
2393
|
+
|
|
2394
|
+
doc_title = title or extracted_title or "Documentation"
|
|
2395
|
+
html_output = f"""<!DOCTYPE html>
|
|
2396
|
+
<html lang="en">
|
|
2397
|
+
<head>
|
|
2398
|
+
<meta charset="UTF-8">
|
|
2399
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2400
|
+
<title>{doc_title}</title>
|
|
2401
|
+
<script>
|
|
2402
|
+
window.MathJax = {{
|
|
2403
|
+
tex: {{
|
|
2404
|
+
inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],
|
|
2405
|
+
displayMath: [['$$', '$$'], ['\\\\[', '\\\\]']]
|
|
2406
|
+
}},
|
|
2407
|
+
svg: {{
|
|
2408
|
+
fontCache: 'global'
|
|
2409
|
+
}}
|
|
2410
|
+
}};
|
|
2411
|
+
</script>
|
|
2412
|
+
<script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
|
|
2413
|
+
<script>
|
|
2414
|
+
function copyCode(button) {{
|
|
2415
|
+
const codeEl = button.nextElementSibling.querySelector('code');
|
|
2416
|
+
navigator.clipboard.writeText(codeEl.textContent).then(() => {{
|
|
2417
|
+
button.innerText = 'Copied!';
|
|
2418
|
+
setTimeout(() => {{
|
|
2419
|
+
button.innerText = 'Copy';
|
|
2420
|
+
}}, 2000);
|
|
2421
|
+
}});
|
|
2422
|
+
}}
|
|
2423
|
+
function openDiagramOverlay(element) {{
|
|
2424
|
+
var overlay = document.getElementById('diagram-overlay');
|
|
2425
|
+
var content = document.getElementById('diagram-overlay-content');
|
|
2426
|
+
var svgHTML = element.innerHTML;
|
|
2427
|
+
content.innerHTML = svgHTML;
|
|
2428
|
+
overlay.style.display = 'flex';
|
|
2429
|
+
}}
|
|
2430
|
+
function toggleSidebar() {{
|
|
2431
|
+
const body = document.body;
|
|
2432
|
+
const btn = document.getElementById('sidebar-toggle');
|
|
2433
|
+
const icon = document.getElementById('toggle-icon');
|
|
2434
|
+
body.classList.toggle('sidebar-collapsed');
|
|
2435
|
+
|
|
2436
|
+
if (body.classList.contains('sidebar-collapsed')) {{
|
|
2437
|
+
icon.innerText = '▶';
|
|
2438
|
+
btn.style.left = '15px';
|
|
2439
|
+
}} else {{
|
|
2440
|
+
icon.innerText = '◀';
|
|
2441
|
+
btn.style.left = '270px';
|
|
2442
|
+
}}
|
|
2443
|
+
}}
|
|
2444
|
+
window.addEventListener('DOMContentLoaded', () => {{
|
|
2445
|
+
if (window.innerWidth <= 768) {{
|
|
2446
|
+
toggleSidebar();
|
|
2447
|
+
}}
|
|
2448
|
+
}});
|
|
2449
|
+
</script>
|
|
2450
|
+
<style>
|
|
2451
|
+
:root {{
|
|
2452
|
+
--bg-color: {t_theme.bg};
|
|
2453
|
+
--text-color: {t_theme.text};
|
|
2454
|
+
--accent-color: {t_theme.accent};
|
|
2455
|
+
--sidebar-bg: {t_theme.surface};
|
|
2456
|
+
--border-color: {t_theme.table_bdr};
|
|
2457
|
+
}}
|
|
2458
|
+
body {{
|
|
2459
|
+
margin: 0;
|
|
2460
|
+
padding: 0;
|
|
2461
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
2462
|
+
background-color: var(--bg-color);
|
|
2463
|
+
color: var(--text-color);
|
|
2464
|
+
display: flex;
|
|
2465
|
+
min-height: 100vh;
|
|
2466
|
+
}}
|
|
2467
|
+
html {{
|
|
2468
|
+
scrollbar-width: thin;
|
|
2469
|
+
scrollbar-color: var(--border-color) var(--bg-color);
|
|
2470
|
+
}}
|
|
2471
|
+
pre {{
|
|
2472
|
+
scrollbar-width: thin;
|
|
2473
|
+
scrollbar-color: var(--border-color) var(--bg-color);
|
|
2474
|
+
}}
|
|
2475
|
+
::-webkit-scrollbar {{
|
|
2476
|
+
width: 8px;
|
|
2477
|
+
height: 8px;
|
|
2478
|
+
}}
|
|
2479
|
+
::-webkit-scrollbar-track {{
|
|
2480
|
+
background: var(--bg-color);
|
|
2481
|
+
}}
|
|
2482
|
+
::-webkit-scrollbar-thumb {{
|
|
2483
|
+
background: var(--border-color);
|
|
2484
|
+
border-radius: 4px;
|
|
2485
|
+
}}
|
|
2486
|
+
::-webkit-scrollbar-thumb:hover {{
|
|
2487
|
+
background: var(--accent-color);
|
|
2488
|
+
}}
|
|
2489
|
+
pre::-webkit-scrollbar-track {{
|
|
2490
|
+
background: {t_theme.code_bg};
|
|
2491
|
+
}}
|
|
2492
|
+
pre::-webkit-scrollbar-thumb {{
|
|
2493
|
+
background: {t_theme.table_bdr};
|
|
2494
|
+
}}
|
|
2495
|
+
pre::-webkit-scrollbar-thumb:hover {{
|
|
2496
|
+
background: {t_theme.accent};
|
|
2497
|
+
}}
|
|
2498
|
+
.code-container:hover .copy-btn {{
|
|
2499
|
+
opacity: 1 !important;
|
|
2500
|
+
}}
|
|
2501
|
+
.copy-btn:hover {{
|
|
2502
|
+
background-color: var(--border-color) !important;
|
|
2503
|
+
}}
|
|
2504
|
+
@media (max-width: 768px) {{
|
|
2505
|
+
.copy-btn {{
|
|
2506
|
+
opacity: 1 !important;
|
|
2507
|
+
}}
|
|
2508
|
+
}}
|
|
2509
|
+
aside {{
|
|
2510
|
+
width: 260px;
|
|
2511
|
+
background-color: var(--sidebar-bg);
|
|
2512
|
+
border-right: 1px solid var(--border-color);
|
|
2513
|
+
padding: 20px 10px;
|
|
2514
|
+
box-sizing: border-box;
|
|
2515
|
+
position: fixed;
|
|
2516
|
+
top: 0;
|
|
2517
|
+
bottom: 0;
|
|
2518
|
+
overflow-y: auto;
|
|
2519
|
+
transition: transform 0.3s ease;
|
|
2520
|
+
z-index: 900;
|
|
2521
|
+
}}
|
|
2522
|
+
aside a {{
|
|
2523
|
+
display: block;
|
|
2524
|
+
color: var(--text-color);
|
|
2525
|
+
text-decoration: none;
|
|
2526
|
+
padding: 8px 12px;
|
|
2527
|
+
border-radius: 4px;
|
|
2528
|
+
margin-bottom: 4px;
|
|
2529
|
+
font-size: 0.9em;
|
|
2530
|
+
opacity: 0.8;
|
|
2531
|
+
transition: all 0.2s;
|
|
2532
|
+
}}
|
|
2533
|
+
aside a:hover {{
|
|
2534
|
+
background-color: var(--border-color);
|
|
2535
|
+
opacity: 1.0;
|
|
2536
|
+
}}
|
|
2537
|
+
aside a.nav-part {{
|
|
2538
|
+
font-weight: bold;
|
|
2539
|
+
font-size: 1.0em;
|
|
2540
|
+
margin-top: 15px;
|
|
2541
|
+
color: var(--accent-color);
|
|
2542
|
+
}}
|
|
2543
|
+
aside a.nav-chap {{
|
|
2544
|
+
font-weight: 600;
|
|
2545
|
+
padding-left: 20px;
|
|
2546
|
+
}}
|
|
2547
|
+
aside a.nav-sect {{
|
|
2548
|
+
padding-left: 32px;
|
|
2549
|
+
font-size: 0.85em;
|
|
2550
|
+
opacity: 0.75;
|
|
2551
|
+
}}
|
|
2552
|
+
main {{
|
|
2553
|
+
margin-left: 260px;
|
|
2554
|
+
padding: 0;
|
|
2555
|
+
width: calc(100% - 260px);
|
|
2556
|
+
min-height: 100vh;
|
|
2557
|
+
box-sizing: border-box;
|
|
2558
|
+
transition: margin-left 0.3s ease, width 0.3s ease;
|
|
2559
|
+
}}
|
|
2560
|
+
#sidebar-toggle {{
|
|
2561
|
+
position: fixed;
|
|
2562
|
+
left: 270px;
|
|
2563
|
+
top: 15px;
|
|
2564
|
+
z-index: 1000;
|
|
2565
|
+
background: var(--sidebar-bg);
|
|
2566
|
+
border: 1px solid var(--border-color);
|
|
2567
|
+
color: var(--text-color);
|
|
2568
|
+
padding: 8px 12px;
|
|
2569
|
+
border-radius: 4px;
|
|
2570
|
+
cursor: pointer;
|
|
2571
|
+
transition: left 0.3s ease, background-color 0.2s, color 0.2s;
|
|
2572
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
2573
|
+
display: flex;
|
|
2574
|
+
align-items: center;
|
|
2575
|
+
justify-content: center;
|
|
2576
|
+
font-weight: bold;
|
|
2577
|
+
}}
|
|
2578
|
+
#sidebar-toggle:hover {{
|
|
2579
|
+
background: var(--border-color);
|
|
2580
|
+
color: var(--accent-color);
|
|
2581
|
+
}}
|
|
2582
|
+
body.sidebar-collapsed aside {{
|
|
2583
|
+
transform: translateX(-260px);
|
|
2584
|
+
}}
|
|
2585
|
+
body.sidebar-collapsed main {{
|
|
2586
|
+
margin-left: 0;
|
|
2587
|
+
width: 100%;
|
|
2588
|
+
}}
|
|
2589
|
+
h1, h2, h3, h4 {{
|
|
2590
|
+
color: var(--accent-color);
|
|
2591
|
+
margin-top: 24px;
|
|
2592
|
+
margin-bottom: 12px;
|
|
2593
|
+
}}
|
|
2594
|
+
.part-header, .chap-header {{
|
|
2595
|
+
border-bottom: 2px solid var(--border-color);
|
|
2596
|
+
padding-bottom: 10px;
|
|
2597
|
+
margin-top: 30px;
|
|
2598
|
+
}}
|
|
2599
|
+
.body-paragraph {{
|
|
2600
|
+
line-height: 1.6;
|
|
2601
|
+
margin-bottom: 16px;
|
|
2602
|
+
}}
|
|
2603
|
+
.diagram-container svg {{
|
|
2604
|
+
max-width: 100%;
|
|
2605
|
+
height: auto;
|
|
2606
|
+
}}
|
|
2607
|
+
@media (max-width: 768px) {{
|
|
2608
|
+
body {{
|
|
2609
|
+
flex-direction: column;
|
|
2610
|
+
}}
|
|
2611
|
+
aside {{
|
|
2612
|
+
width: 260px;
|
|
2613
|
+
position: fixed;
|
|
2614
|
+
border-right: 1px solid var(--border-color);
|
|
2615
|
+
border-bottom: none;
|
|
2616
|
+
max-height: none;
|
|
2617
|
+
}}
|
|
2618
|
+
main {{
|
|
2619
|
+
margin-left: 0 !important;
|
|
2620
|
+
width: 100% !important;
|
|
2621
|
+
padding: 0;
|
|
2622
|
+
}}
|
|
2623
|
+
}}
|
|
2624
|
+
</style>
|
|
2625
|
+
</head>
|
|
2626
|
+
<body>
|
|
2627
|
+
<button id="sidebar-toggle" onclick="toggleSidebar()">
|
|
2628
|
+
<span id="toggle-icon">◀</span>
|
|
2629
|
+
</button>
|
|
2630
|
+
<aside>
|
|
2631
|
+
<div style="font-weight: bold; font-size: 1.1em; padding: 10px 12px; color: var(--accent-color); border-bottom: 1px solid var(--border-color); margin-bottom: 15px;">
|
|
2632
|
+
{doc_title}
|
|
2633
|
+
</div>
|
|
2634
|
+
{"".join(sidebar_links)}
|
|
2635
|
+
</aside>
|
|
2636
|
+
<main>
|
|
2637
|
+
{"".join(content_html)}
|
|
2638
|
+
</main>
|
|
2639
|
+
<div id="diagram-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.85); z-index: 9999; align-items: center; justify-content: center; overflow: hidden; cursor: zoom-out;" onclick="this.style.display='none'">
|
|
2640
|
+
<div id="diagram-overlay-content" style="background: var(--bg-color); padding: 20px; border-radius: 8px; border: 1px solid var(--border-color); max-width: 95vw; max-height: 95vh; overflow: auto; display: flex; justify-content: center; align-items: center; cursor: default;" onclick="event.stopPropagation()">
|
|
2641
|
+
</div>
|
|
2642
|
+
</div>
|
|
2643
|
+
</body>
|
|
2644
|
+
</html>
|
|
2645
|
+
"""
|
|
2646
|
+
|
|
2647
|
+
# Convert math-inline images back to MathJax LaTeX strings
|
|
2648
|
+
def repl_math_inline(match):
|
|
2649
|
+
img_tag = match.group(0)
|
|
2650
|
+
src_match = re.search(r'src=["\']([^"\']+)["\']', img_tag)
|
|
2651
|
+
if src_match:
|
|
2652
|
+
src_path = src_match.group(1)
|
|
2653
|
+
# Match directly
|
|
2654
|
+
if src_path in math_latex_registry:
|
|
2655
|
+
latex = math_latex_registry[src_path]
|
|
2656
|
+
return f"${latex}$"
|
|
2657
|
+
# Match by base filename (resilient to path formatting)
|
|
2658
|
+
filename_part = os.path.basename(src_path)
|
|
2659
|
+
for k, v in math_latex_registry.items():
|
|
2660
|
+
if os.path.basename(k) == filename_part:
|
|
2661
|
+
return f"${v}$"
|
|
2662
|
+
return img_tag
|
|
2663
|
+
|
|
2664
|
+
html_output = re.sub(
|
|
2665
|
+
r"<img\b[^>]*math_[^>]*\.png[^>]*>", repl_math_inline, html_output
|
|
2666
|
+
)
|
|
2667
|
+
|
|
2668
|
+
base_name = os.path.basename(os.path.normpath(output_dir))
|
|
2669
|
+
if base_name.endswith("_html"):
|
|
2670
|
+
base_name = base_name[:-5]
|
|
2671
|
+
elif base_name.endswith(".html"):
|
|
2672
|
+
base_name = base_name[:-5]
|
|
2673
|
+
|
|
2674
|
+
html_filename = "index.html"
|
|
2675
|
+
|
|
2676
|
+
with open(os.path.join(output_dir, html_filename), "w", encoding="utf-8") as f:
|
|
2677
|
+
f.write(html_output)
|
|
2678
|
+
|
|
2679
|
+
|
|
2680
|
+
__all__ = [
|
|
2681
|
+
"PAGE_W",
|
|
2682
|
+
"PAGE_H",
|
|
2683
|
+
"PM",
|
|
2684
|
+
"CW",
|
|
2685
|
+
"page_decor",
|
|
2686
|
+
"build_doc",
|
|
2687
|
+
"ThemedCanvas",
|
|
2688
|
+
"LaTeXFlowable",
|
|
2689
|
+
"build_pptx",
|
|
2690
|
+
"build_html",
|
|
2691
|
+
]
|