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.
@@ -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
+ ]