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,4386 @@
1
+ """
2
+ engrapha_notes.helpers -- Content helper functions for ReportLab notes PDFs.
3
+
4
+ These functions append styled Flowables to the module-level `story` list.
5
+ Call `set_story(my_list)` to redirect them to a custom story list.
6
+
7
+ All styles and colors are dynamically looked up from the active NotesTheme
8
+ at render time.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, TYPE_CHECKING, Literal
14
+
15
+ if TYPE_CHECKING:
16
+ from reportlab.pdfgen.canvas import Canvas
17
+
18
+ from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT
19
+ from reportlab.lib.styles import ParagraphStyle
20
+ from reportlab.platypus import (
21
+ Flowable,
22
+ HRFlowable,
23
+ PageBreak,
24
+ Paragraph,
25
+ Spacer,
26
+ Table,
27
+ TableStyle,
28
+ )
29
+
30
+ from .theme import get_theme, NotesTheme
31
+ import re
32
+ from reportlab.platypus.tableofcontents import TableOfContents
33
+
34
+ math_latex_registry: dict[str, str] = {}
35
+
36
+
37
+ class CodeBlockTable(Table):
38
+ _is_code_block: bool = True
39
+
40
+
41
+ class Bookmark(Flowable): # type: ignore[misc]
42
+ def __init__(self, key: str, title: str, level: int = 0) -> None:
43
+ Flowable.__init__(self)
44
+ self.key = key
45
+ self.title = title
46
+ self.level = level
47
+
48
+ def draw(self) -> None:
49
+ if not bookmarks_enabled:
50
+ return
51
+ last_level = getattr(self.canv, "_last_outline_level", -1)
52
+ self.canv.bookmarkPage(self.key)
53
+
54
+ level = self.level
55
+ if level > last_level + 1:
56
+ level = last_level + 1
57
+
58
+ try:
59
+ self.canv.addOutlineEntry(self.title, self.key, level=level, closed=False)
60
+ setattr(self.canv, "_last_outline_level", level)
61
+ except Exception:
62
+ pass
63
+
64
+
65
+ _bookmark_counter = 0
66
+ bookmarks_enabled = True
67
+
68
+
69
+ def _get_bookmark_key() -> str:
70
+ global _bookmark_counter
71
+ _bookmark_counter += 1
72
+ return f"bm_key_{_bookmark_counter}"
73
+
74
+
75
+ def _clean_title(text: str) -> str:
76
+ # Strip HTML tags
77
+ cleaned = re.sub(r"<[^>]*>", "", text)
78
+ return " ".join(cleaned.split())
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # The global story list -- redirect with set_story() for custom contexts.
83
+ # ---------------------------------------------------------------------------
84
+ story: list[Flowable] = []
85
+
86
+
87
+ def set_story(new_story: list[Flowable]) -> None:
88
+ """Replace the module-level story with a different list (e.g. per-document) in-place."""
89
+ global story
90
+ story.clear()
91
+ story.extend(new_story)
92
+
93
+
94
+ def get_story() -> list[Flowable]:
95
+ """Return the current story list."""
96
+ return story
97
+
98
+
99
+ _document_metadata: dict[str, Any] = {
100
+ "title": None,
101
+ "subtitle": None,
102
+ "author": None,
103
+ "date": None,
104
+ "version": None,
105
+ }
106
+
107
+
108
+ def init_document(
109
+ title: str,
110
+ subtitle: str | None = None,
111
+ author: str | None = None,
112
+ date: str | None = None,
113
+ version: str | None = None,
114
+ theme: Any = None,
115
+ ) -> None:
116
+ """
117
+ Initialize global document metadata and optional theme configuration.
118
+ """
119
+ global _document_metadata
120
+ _document_metadata = {
121
+ "title": title,
122
+ "subtitle": subtitle,
123
+ "author": author,
124
+ "date": date,
125
+ "version": version,
126
+ }
127
+ if theme is not None:
128
+ set_theme(theme)
129
+
130
+
131
+ def add_cover(
132
+ title: str | None = None,
133
+ subtitle: str | None = None,
134
+ style: str = "modern",
135
+ author: str | None = None,
136
+ date: str | None = None,
137
+ ornament: str | None = None,
138
+ tags: list[str] | None = None,
139
+ icon: str | None = None,
140
+ cover_theme: str | None = None,
141
+ ) -> None:
142
+ """
143
+ Convenient one-line cover page generator that automates page layout setup
144
+ (bookmark, header/footer suppression) and appends a page break.
145
+ """
146
+ meta_title = title or _document_metadata.get("title") or "Documentation"
147
+ meta_subtitle = subtitle or _document_metadata.get("subtitle")
148
+ meta_author = author or _document_metadata.get("author")
149
+ meta_date = date or _document_metadata.get("date")
150
+
151
+ bookmark("Cover Page")
152
+ suppress_header(page_only=True)
153
+ suppress_footer(page_only=True)
154
+ sp(120)
155
+ cover_card(
156
+ title=meta_title,
157
+ subtitle=meta_subtitle,
158
+ style=style,
159
+ author=meta_author,
160
+ date=meta_date,
161
+ ornament=ornament,
162
+ tags=tags,
163
+ icon=icon,
164
+ cover_theme=cover_theme,
165
+ )
166
+ br()
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Core primitives and style management
171
+ # ---------------------------------------------------------------------------
172
+
173
+ _style_counter = 0
174
+
175
+
176
+ def _uid() -> str:
177
+ """Generate a unique style name to prevent ReportLab stylesheet collisions."""
178
+ global _style_counter
179
+ _style_counter += 1
180
+ return f"PN_DY_ST_{_style_counter:04d}"
181
+
182
+
183
+ def _to_color(val: Any, default_hex: str) -> Any:
184
+ """Resolve a theme color (Hex string) or passed color object/string."""
185
+ t_theme = get_theme()
186
+ if val is None:
187
+ return t_theme.rl(default_hex)
188
+ if isinstance(val, str) and val.startswith("#"):
189
+ return t_theme.rl(val)
190
+ return val
191
+
192
+
193
+ def _is_light_color(color: Any) -> bool:
194
+ """Determine if a color (hex string or ReportLab Color) is light/bright."""
195
+ if isinstance(color, str):
196
+ hex_str = color.lstrip("#")
197
+ if len(hex_str) == 3:
198
+ hex_str = "".join(c * 2 for c in hex_str)
199
+ if len(hex_str) != 6:
200
+ return False
201
+ r = int(hex_str[0:2], 16)
202
+ g = int(hex_str[2:4], 16)
203
+ b = int(hex_str[4:6], 16)
204
+ luma = 0.299 * r + 0.587 * g + 0.114 * b
205
+ return luma > 180
206
+ # ReportLab Color objects have red/green/blue properties in range [0, 1]
207
+ try:
208
+ r, g, b = color.red, color.green, color.blue
209
+ luma = 0.299 * r * 255 + 0.587 * g * 255 + 0.114 * b * 255
210
+ return bool(luma > 180)
211
+ except AttributeError:
212
+ return False
213
+
214
+
215
+ def add(x: Flowable | list[Flowable] | tuple[Flowable, ...]) -> None:
216
+ """Append a Flowable or list/tuple of Flowables to the story."""
217
+ if isinstance(x, (list, tuple)):
218
+ for item in x:
219
+ story.append(item)
220
+ else:
221
+ story.append(x)
222
+
223
+
224
+ def sp(h: float = 8) -> None:
225
+ """Add vertical whitespace."""
226
+ add(Spacer(1, h))
227
+
228
+
229
+ def rule(c: Any = None, t: float = 0.6, keepWithNext: bool = False) -> None:
230
+ """Add a horizontal rule (defaults to active theme's accent color)."""
231
+ t_theme = get_theme()
232
+ actual_color = _to_color(c, t_theme.accent)
233
+ r = HRFlowable(
234
+ width="100%", thickness=t, color=actual_color, spaceAfter=6, spaceBefore=2
235
+ )
236
+ if keepWithNext:
237
+ r.keepWithNext = True
238
+ add(r)
239
+
240
+
241
+ def br() -> None:
242
+ """Insert an explicit page break."""
243
+ add(PageBreak())
244
+
245
+
246
+ class SlideBreak(Flowable): # type: ignore[misc]
247
+ """Dummy flowable that triggers a slide partition during PPTX generation."""
248
+
249
+ def __init__(self) -> None:
250
+ super().__init__()
251
+ self.width = 0
252
+ self.height = 0
253
+
254
+ def draw(self) -> None:
255
+ pass
256
+
257
+
258
+ class InlineSVG(Flowable):
259
+ """A flowable that loads an SVG and scales it to a target width."""
260
+
261
+ def __init__(self, path: str, target_width: float = 200) -> None:
262
+ Flowable.__init__(self)
263
+ self.path = path
264
+ self.target_width = target_width
265
+ self.drawing = None
266
+ self.width = target_width
267
+ self.height = 0
268
+
269
+ import os
270
+
271
+ if os.path.exists(path):
272
+ try:
273
+ from svglib.svglib import svg2rlg
274
+ except ModuleNotFoundError as e:
275
+ raise RuntimeError(
276
+ "SVG rendering requires the optional 'svglib' package.\n"
277
+ "Install it with:\n\n"
278
+ "pip install engrapha-notes[svg]\n"
279
+ "or\n"
280
+ "pip install engrapha-notes[all]"
281
+ ) from e
282
+
283
+ self.drawing = svg2rlg(path)
284
+
285
+ if self.drawing and self.drawing.width > 0:
286
+ self.scale = self.target_width / self.drawing.width
287
+ self.height = self.drawing.height * self.scale
288
+ else:
289
+ self.scale = 1.0
290
+
291
+ def draw(self) -> None:
292
+ if self.drawing:
293
+ self.canv.saveState()
294
+ self.canv.scale(self.scale, self.scale)
295
+ from reportlab.graphics import renderPDF
296
+
297
+ renderPDF.draw(self.drawing, self.canv, 0, 0)
298
+ self.canv.restoreState()
299
+
300
+
301
+ def slide_break() -> None:
302
+ """Insert an explicit slide break for PPTX generation. Dummy in PDF."""
303
+ add(SlideBreak())
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Layout blocks
308
+ # ---------------------------------------------------------------------------
309
+
310
+
311
+ def part_box(
312
+ text: str,
313
+ subtitle: str | None = None,
314
+ topics: list[str] | None = None,
315
+ ) -> None:
316
+ """
317
+ Large unit / part divider card. Renders a premium section hero page
318
+ supporting subtitle and bulleted/listed topics.
319
+ """
320
+ from .document import CW
321
+
322
+ t_theme = get_theme()
323
+
324
+ if t_theme.name == "Academic":
325
+ sp(20)
326
+ rule(c=t_theme.text, t=1.5)
327
+ sp(12)
328
+ st_title = ParagraphStyle(
329
+ _uid(),
330
+ fontSize=22,
331
+ textColor=t_theme.rl(t_theme.text),
332
+ fontName=t_theme.heading_font,
333
+ alignment=TA_CENTER,
334
+ leading=28,
335
+ )
336
+ add(Paragraph(text.upper(), st_title))
337
+ if subtitle:
338
+ sp(8)
339
+ st_sub = ParagraphStyle(
340
+ _uid(),
341
+ fontSize=14,
342
+ textColor=t_theme.rl(t_theme.text),
343
+ fontName=t_theme.body_font,
344
+ alignment=TA_CENTER,
345
+ leading=18,
346
+ )
347
+ add(Paragraph(subtitle, st_sub))
348
+ if topics:
349
+ sp(10)
350
+ st_topic = ParagraphStyle(
351
+ _uid(),
352
+ fontSize=10,
353
+ textColor=t_theme.rl(t_theme.text_dim),
354
+ fontName=t_theme.body_font,
355
+ alignment=TA_CENTER,
356
+ leading=15,
357
+ )
358
+ for topic in topics:
359
+ add(Paragraph(f"&bull; {topic}", st_topic))
360
+ sp(12)
361
+ rule(c=t_theme.text, t=1.5)
362
+ sp(24)
363
+ return
364
+
365
+ elif t_theme.name == "Notion":
366
+ sp(12)
367
+ st_title = ParagraphStyle(
368
+ _uid(),
369
+ fontSize=20,
370
+ textColor=t_theme.rl(t_theme.text),
371
+ fontName=t_theme.heading_font,
372
+ alignment=TA_LEFT,
373
+ leading=26,
374
+ )
375
+ add(Paragraph(text, st_title))
376
+ if subtitle:
377
+ sp(4)
378
+ st_sub = ParagraphStyle(
379
+ _uid(),
380
+ fontSize=13,
381
+ textColor=t_theme.rl(t_theme.text_dim),
382
+ fontName=t_theme.body_font,
383
+ alignment=TA_LEFT,
384
+ leading=17,
385
+ )
386
+ add(Paragraph(subtitle, st_sub))
387
+ if topics:
388
+ sp(6)
389
+ st_topic = ParagraphStyle(
390
+ _uid(),
391
+ fontSize=10,
392
+ textColor=t_theme.rl(t_theme.text_dim),
393
+ fontName=t_theme.body_font,
394
+ alignment=TA_LEFT,
395
+ leading=15,
396
+ )
397
+ for topic in topics:
398
+ add(Paragraph(f"&bull; {topic}", st_topic))
399
+ sp(6)
400
+ rule(c=t_theme.table_bdr, t=1.0)
401
+ sp(14)
402
+ return
403
+
404
+ # For Textbook and standard/custom themes:
405
+ # Build a premium card with accent block borders or backgrounds
406
+ st_title = ParagraphStyle(
407
+ _uid(),
408
+ fontSize=20,
409
+ textColor=t_theme.rl(
410
+ t_theme.text if t_theme.name == "Textbook" else t_theme.accent
411
+ ),
412
+ fontName=t_theme.heading_font,
413
+ alignment=TA_LEFT,
414
+ leading=26,
415
+ )
416
+
417
+ rows: list[list[Any]] = [[Paragraph(text, st_title)]]
418
+ if subtitle:
419
+ st_sub = ParagraphStyle(
420
+ _uid(),
421
+ fontSize=13,
422
+ textColor=t_theme.rl(t_theme.text),
423
+ fontName=t_theme.body_font,
424
+ alignment=TA_LEFT,
425
+ leading=17,
426
+ )
427
+ rows.append([Paragraph(subtitle, st_sub)])
428
+ if topics:
429
+ st_topic = ParagraphStyle(
430
+ _uid(),
431
+ fontSize=10,
432
+ textColor=t_theme.rl(t_theme.text_dim),
433
+ fontName=t_theme.body_font,
434
+ alignment=TA_LEFT,
435
+ leading=15,
436
+ )
437
+ for topic in topics:
438
+ rows.append([Paragraph(f"&bull; {topic}", st_topic)])
439
+
440
+ # Add space before table
441
+ sp(16)
442
+
443
+ col_w = CW - 30 if isinstance(CW, (int, float)) else CW
444
+ if t_theme.name == "Textbook":
445
+ inner_table = Table(rows, colWidths=[col_w - 20])
446
+ inner_table.setStyle(
447
+ TableStyle(
448
+ [
449
+ ("BACKGROUND", (0, 0), (-1, -1), t_theme.rl(t_theme.surface)),
450
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
451
+ ("LEFTPADDING", (0, 0), (-1, -1), 15),
452
+ ("RIGHTPADDING", (0, 0), (-1, -1), 15),
453
+ ("TOPPADDING", (0, 0), (-1, -1), 16),
454
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 16),
455
+ ]
456
+ )
457
+ )
458
+ wrapper_table = Table([["", inner_table]], colWidths=[6, col_w - 6])
459
+ wrapper_table.setStyle(
460
+ TableStyle(
461
+ [
462
+ ("BACKGROUND", (0, 0), (0, -1), t_theme.rl(t_theme.accent)),
463
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
464
+ ("LEFTPADDING", (0, 0), (-1, -1), 0),
465
+ ("RIGHTPADDING", (0, 0), (-1, -1), 0),
466
+ ("TOPPADDING", (0, 0), (-1, -1), 0),
467
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 0),
468
+ ]
469
+ )
470
+ )
471
+ setattr(wrapper_table, "_is_part_box", True)
472
+ setattr(wrapper_table, "_box_title", text)
473
+ add(wrapper_table)
474
+ else:
475
+ # Default card layout
476
+ card_table = Table(rows, colWidths=[col_w])
477
+ thickness = 2.5 * t_theme.border_thickness
478
+ bdr_color = t_theme.rl(t_theme.border_color or t_theme.accent)
479
+ card_table.setStyle(
480
+ TableStyle(
481
+ [
482
+ ("BACKGROUND", (0, 0), (-1, -1), t_theme.rl(t_theme.surface)),
483
+ ("TOPPADDING", (0, 0), (-1, -1), 22),
484
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 22),
485
+ ("LEFTPADDING", (0, 0), (-1, -1), 20),
486
+ ("RIGHTPADDING", (0, 0), (-1, -1), 20),
487
+ ("BOX", (0, 0), (-1, -1), thickness, bdr_color),
488
+ ]
489
+ )
490
+ )
491
+ setattr(card_table, "_is_part_box", True)
492
+ setattr(card_table, "_box_title", text)
493
+ add(card_table)
494
+
495
+ sp(16)
496
+
497
+
498
+ # ============================================================================
499
+ # REPLACE the existing `CoverBackgroundFlowable` class AND the existing
500
+ # `cover_card()` function in helpers.py with everything below.
501
+ # Nothing else in the file needs to change.
502
+ # ============================================================================
503
+
504
+
505
+ def _contrast_text_color(theme: Any) -> str:
506
+ """Pick a title color that's always readable against theme.bg.
507
+ Replaces the old pattern of hardcoding t_theme.white, which is wrong
508
+ on light themes (Notion, Academic Modern, Catppuccin Latte, GitHub)."""
509
+ return theme.white if _is_light_color(theme.bg) is False else theme.text
510
+
511
+
512
+ def _decor_color(theme: Any) -> Any:
513
+ """Decorative shape color with guaranteed contrast against bg,
514
+ regardless of whether the active theme is light or dark."""
515
+ if _is_light_color(theme.bg):
516
+ # Light background -> use a darker, muted version: surface_alt is too close to bg
517
+ return theme.rl(theme.accent)
518
+ return theme.rl(theme.surface_alt)
519
+
520
+
521
+ class CoverBackgroundFlowable(Flowable):
522
+ def __init__(self, style: str, theme: Any, bg_svg: str | None = None) -> None:
523
+ Flowable.__init__(self)
524
+ self.style = style
525
+ self.theme = theme
526
+ self.bg_svg = bg_svg
527
+ self.width = 0
528
+ self.height = 0
529
+
530
+ def drawOn(
531
+ self, canvas: Any, x: float, y: float, *args: Any, **kwargs: Any
532
+ ) -> None:
533
+ canvas.saveState()
534
+ self.canv = canvas
535
+ self.draw()
536
+ canvas.restoreState()
537
+
538
+ def draw(self) -> None:
539
+ canvas = self.canv
540
+ canvas.saveState()
541
+
542
+ from .document import PAGE_W, PAGE_H
543
+
544
+ theme = self.theme
545
+ acc_c = theme.rl(theme.accent)
546
+ acc2_c = theme.rl(theme.accent2 or theme.accent)
547
+ surf_c = _decor_color(theme)
548
+
549
+ arch = self.style
550
+ if arch in ("linear", "gradient", "hero"):
551
+ arch = "hero"
552
+ elif arch in ("textbook", "corporate", "standard", "modern", "book"):
553
+ arch = "book"
554
+ elif arch in ("academic", "minimal", "notion", "catppuccin", "academic_modern"):
555
+ arch = "academic_modern"
556
+ elif arch == "diagram":
557
+ arch = "diagram"
558
+
559
+ if arch == "hero":
560
+ canvas.setFillColor(theme.rl(theme.bg))
561
+ canvas.rect(0, 0, PAGE_W, PAGE_H, stroke=0, fill=1)
562
+ canvas.setFillColor(acc_c)
563
+ canvas.rect(0, PAGE_H - 140, 16, 80, stroke=0, fill=1)
564
+ elif arch == "book":
565
+ canvas.setFillColor(surf_c)
566
+ canvas.rect(0, PAGE_H * 0.55, PAGE_W, PAGE_H * 0.45, stroke=0, fill=1)
567
+ canvas.setFillColor(acc_c)
568
+ canvas.rect(0, PAGE_H * 0.55 - 6, PAGE_W, 6, stroke=0, fill=1)
569
+ elif arch == "academic_modern":
570
+ canvas.setFillColor(acc_c)
571
+ canvas.rect(0, 0, 16, PAGE_H, stroke=0, fill=1)
572
+ canvas.setFillColor(acc2_c)
573
+ canvas.rect(16, 0, 4, PAGE_H, stroke=0, fill=1)
574
+ elif arch == "diagram":
575
+ if self.bg_svg:
576
+ import os
577
+
578
+ if os.path.exists(self.bg_svg):
579
+ try:
580
+ from svglib.svglib import svg2rlg
581
+ from reportlab.graphics import renderPDF
582
+ from typing import Any # noqa: F401
583
+
584
+ drawing = svg2rlg(self.bg_svg)
585
+ if drawing is not None:
586
+ from .helpers import _is_light_color
587
+
588
+ color_to_apply = (
589
+ theme.rl(theme.surface_alt)
590
+ if _is_light_color(theme.bg)
591
+ else theme.rl(theme.text_dim)
592
+ )
593
+
594
+ def recolor(node: Any, color: Any) -> None:
595
+ if hasattr(node, "fillColor"):
596
+ node.fillColor = color
597
+ if hasattr(node, "strokeColor"):
598
+ node.strokeColor = color
599
+ if hasattr(node, "contents"):
600
+ for child in node.contents:
601
+ recolor(child, color)
602
+
603
+ recolor(drawing, color_to_apply)
604
+
605
+ canvas.saveState()
606
+ if drawing.width > 0 and drawing.height > 0:
607
+ # Scale to fit the page (contain)
608
+ scale = min(
609
+ PAGE_W / drawing.width, PAGE_H / drawing.height
610
+ )
611
+
612
+ # Center vertically and horizontally
613
+ scaled_w = drawing.width * scale
614
+ scaled_h = drawing.height * scale
615
+ dx = (PAGE_W - scaled_w) / 2.0
616
+ dy = (PAGE_H - scaled_h) / 2.0
617
+
618
+ canvas.translate(dx, dy)
619
+ canvas.scale(scale, scale)
620
+
621
+ canvas.setFillAlpha(0.4)
622
+ canvas.setStrokeAlpha(0.4)
623
+ renderPDF.draw(drawing, canvas, 0, 0)
624
+ canvas.restoreState()
625
+ except Exception:
626
+ pass
627
+
628
+ canvas.restoreState()
629
+
630
+
631
+ def cover_card(
632
+ title: str,
633
+ subtitle: str | None = None,
634
+ width: float | str | None = None,
635
+ style: str = "standard",
636
+ author: str | None = None,
637
+ date: str | None = None,
638
+ ornament: str | None = None,
639
+ tags: list[str] | None = None,
640
+ icon: str | None = None,
641
+ cover_theme: str | None = None,
642
+ bg_svg: str | None = None,
643
+ banner_svg: str | None = None,
644
+ banner_width: float = 400.0,
645
+ banner_align: str = "left",
646
+ logo_svg: str | None = None,
647
+ logo_width: float = 120.0,
648
+ ) -> None:
649
+ """
650
+ Add a styled cover-title card to the story.
651
+ """
652
+ from . import styles as s
653
+ from .document import CW
654
+
655
+ t_theme = get_theme()
656
+ w_val = width if width is not None else CW
657
+ tags_max_w: float = float(w_val) if isinstance(w_val, (int, float)) else float(CW)
658
+ effective_style = cover_theme if cover_theme is not None else style
659
+
660
+ add(CoverBackgroundFlowable(effective_style, t_theme, bg_svg=bg_svg))
661
+
662
+ if logo_svg:
663
+ add(InlineSVG(logo_svg, target_width=logo_width))
664
+ sp(16)
665
+
666
+ on_dark_color = t_theme.rl(_contrast_text_color(t_theme))
667
+
668
+ def get_ornament_flowable(color_val: Any, alignment: Any = 1) -> Any:
669
+ if ornament == "dots":
670
+ st_orn = ParagraphStyle(
671
+ _uid(),
672
+ parent=s.BODY_ST,
673
+ textColor=color_val,
674
+ alignment=alignment,
675
+ fontSize=14,
676
+ leading=16,
677
+ )
678
+ return Paragraph("&bull; &bull; &bull;", st_orn)
679
+ elif ornament == "diamond":
680
+ st_orn = ParagraphStyle(
681
+ _uid(),
682
+ parent=s.BODY_ST,
683
+ textColor=color_val,
684
+ alignment=alignment,
685
+ fontSize=14,
686
+ leading=16,
687
+ )
688
+ return Paragraph("&#9670; &compfn; &#9670;", st_orn)
689
+ else:
690
+ from reportlab.platypus import HRFlowable
691
+
692
+ return HRFlowable(
693
+ width="35%",
694
+ thickness=1.5,
695
+ color=color_val,
696
+ spaceBefore=8,
697
+ spaceAfter=8,
698
+ hAlign="CENTER" if alignment == 1 else "LEFT",
699
+ )
700
+
701
+ def add_tags_row(
702
+ tags_list: list[str],
703
+ accent_color: Any,
704
+ max_width: float,
705
+ align: "Literal['LEFT', 'CENTER', 'RIGHT']" = "LEFT",
706
+ ) -> None:
707
+ """Lay tags out horizontally as wrapping pill chips using a single Paragraph."""
708
+ if not tags_list:
709
+ return
710
+
711
+ from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT
712
+
713
+ _alignment = TA_LEFT
714
+ if align == "CENTER":
715
+ _alignment = TA_CENTER
716
+ elif align == "RIGHT":
717
+ _alignment = TA_RIGHT
718
+
719
+ st_tags = ParagraphStyle(
720
+ _uid(),
721
+ parent=s.BODY_ST,
722
+ textColor=accent_color,
723
+ fontSize=12,
724
+ leading=20,
725
+ alignment=_alignment,
726
+ )
727
+
728
+ # Build a single inline string with non-breaking spaces
729
+ # `[ DOCUMENTATION ] [ REFERENCE ] [ 2026 EDITION ]`
730
+ formatted_tags = []
731
+ for t in tags_list:
732
+ formatted_tags.append(f"<b>[&nbsp;{t.upper()}&nbsp;]</b>")
733
+
734
+ tags_str = "&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;".join(formatted_tags)
735
+ add(Paragraph(tags_str, st_tags))
736
+
737
+ def render_icon(icon_text: str, size: float = 48.0) -> Any:
738
+ import re as _re
739
+
740
+ # Resolve numeric HTML entities (&#128187;) to their actual codepoint
741
+ # so we can check font coverage before attempting to draw anything.
742
+ def _entity_to_codepoint(match: "_re.Match[str]") -> int:
743
+ return int(match.group(1))
744
+
745
+ codepoints = [
746
+ _entity_to_codepoint(m) for m in _re.finditer(r"&#(\d+);", icon_text)
747
+ ]
748
+ # Also check any literal (non-entity) characters in the string.
749
+ literal_chars = _re.sub(r"&#\d+;|&\w+;", "", icon_text)
750
+ codepoints.extend(ord(c) for c in literal_chars if not c.isspace())
751
+
752
+ # Helvetica (base-14 PDF font) only reliably covers Latin-1: 0x20-0xFF.
753
+ has_unsupported_glyph = any(cp > 0xFF for cp in codepoints)
754
+
755
+ if has_unsupported_glyph or not codepoints:
756
+ # Skip drawing entirely rather than render a black notdef box.
757
+ # Returning an empty zero-height Spacer keeps call sites unchanged.
758
+ return Spacer(1, 0)
759
+
760
+ st_icon = ParagraphStyle(
761
+ _uid(),
762
+ fontSize=size,
763
+ leading=size,
764
+ alignment=TA_CENTER,
765
+ )
766
+ return Paragraph(icon_text, st_icon)
767
+
768
+ # Map legacy styles to internal architecture
769
+ arch = effective_style
770
+ if arch in ("linear", "gradient", "hero"):
771
+ arch = "hero"
772
+ elif arch in ("textbook", "corporate", "standard", "modern", "book"):
773
+ arch = "book"
774
+ elif arch in ("academic", "minimal", "notion", "catppuccin", "academic_modern"):
775
+ arch = "academic_modern"
776
+ elif arch == "diagram":
777
+ arch = "diagram"
778
+
779
+ COVER_BUDGET = 460.0
780
+
781
+ def flexible_spacer(consumed: float) -> None:
782
+ remaining = max(40.0, COVER_BUDGET - consumed)
783
+ sp(remaining)
784
+
785
+ # Calculate global left indent if needed
786
+ base_indent = 40.0 if arch in ("academic_modern", "diagram") else 0.0
787
+ consumed = 0.0
788
+
789
+ if arch == "hero":
790
+ # Massive typography, huge whitespace, left aligned
791
+ sp(80)
792
+ if icon:
793
+ add(render_icon(icon, size=56.0))
794
+ sp(24)
795
+
796
+ st_title = ParagraphStyle(
797
+ _uid(),
798
+ parent=s.COVER_H1,
799
+ textColor=t_theme.rl(t_theme.text),
800
+ fontSize=64,
801
+ leading=72,
802
+ alignment=TA_LEFT,
803
+ )
804
+ add(Paragraph(title, st_title))
805
+ consumed = 72.0 * (1 + len(title) // 18) + 104.0
806
+
807
+ if subtitle:
808
+ sp(16)
809
+ st_sub = ParagraphStyle(
810
+ _uid(),
811
+ parent=s.COVER_SUB,
812
+ textColor=t_theme.rl(t_theme.text_dim),
813
+ fontSize=20,
814
+ leading=28,
815
+ alignment=TA_LEFT,
816
+ )
817
+ add(Paragraph(subtitle, st_sub))
818
+ consumed += 28.0 * (1 + len(subtitle) // 40) + 16.0
819
+
820
+ elif arch == "book":
821
+ # Classic structured textbook aesthetic
822
+ sp(120)
823
+ st_title = ParagraphStyle(
824
+ _uid(),
825
+ parent=s.COVER_H1,
826
+ textColor=(
827
+ on_dark_color
828
+ if not _is_light_color(t_theme.surface)
829
+ else t_theme.rl(t_theme.text)
830
+ ),
831
+ fontSize=48,
832
+ leading=56,
833
+ alignment=TA_LEFT,
834
+ )
835
+ add(Paragraph(title, st_title))
836
+ consumed = 56.0 * (1 + len(title) // 20) + 120.0
837
+
838
+ if subtitle:
839
+ sp(16)
840
+ st_sub = ParagraphStyle(
841
+ _uid(),
842
+ parent=s.COVER_SUB,
843
+ textColor=(
844
+ on_dark_color
845
+ if not _is_light_color(t_theme.surface)
846
+ else t_theme.rl(t_theme.text_dim)
847
+ ),
848
+ fontSize=16,
849
+ leading=24,
850
+ alignment=TA_LEFT,
851
+ )
852
+ add(Paragraph(subtitle, st_sub))
853
+ consumed += 24.0 * (1 + len(subtitle) // 50) + 16.0
854
+
855
+ elif arch in ("academic_modern", "diagram"):
856
+ # Clean university layout (diagram uses same text layout but relies on SVG background)
857
+ sp(60)
858
+ if icon:
859
+ add(render_icon(icon, size=48.0))
860
+ sp(20)
861
+
862
+ st_title = ParagraphStyle(
863
+ _uid(),
864
+ parent=s.COVER_H1,
865
+ textColor=t_theme.rl(t_theme.text),
866
+ fontSize=42,
867
+ leading=50,
868
+ alignment=TA_LEFT,
869
+ leftIndent=base_indent,
870
+ )
871
+ add(Paragraph(title, st_title))
872
+ consumed = 50.0 * (1 + len(title) // 20) + 80.0
873
+
874
+ if subtitle:
875
+ sp(12)
876
+ st_sub = ParagraphStyle(
877
+ _uid(),
878
+ parent=s.COVER_SUB,
879
+ textColor=t_theme.rl(t_theme.text_dim),
880
+ fontSize=16,
881
+ leading=22,
882
+ alignment=TA_LEFT,
883
+ leftIndent=base_indent,
884
+ )
885
+ add(Paragraph(subtitle, st_sub))
886
+ consumed += 22.0 * (1 + len(subtitle) // 50) + 12.0
887
+
888
+ if banner_svg:
889
+ sp(24)
890
+ from reportlab.platypus import Table, TableStyle, Indenter
891
+
892
+ svg_flowable = InlineSVG(banner_svg, target_width=banner_width)
893
+
894
+ if banner_align == "center":
895
+ center_table = Table([[svg_flowable]], colWidths=["100%"])
896
+ center_table.setStyle(
897
+ TableStyle(
898
+ [
899
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
900
+ ("TOPPADDING", (0, 0), (-1, -1), 0),
901
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 0),
902
+ ("LEFTPADDING", (0, 0), (-1, -1), 0),
903
+ ("RIGHTPADDING", (0, 0), (-1, -1), 0),
904
+ ]
905
+ )
906
+ )
907
+ add(center_table)
908
+ else:
909
+ if base_indent > 0:
910
+ add(Indenter(left=base_indent))
911
+ add(svg_flowable)
912
+ if base_indent > 0:
913
+ add(Indenter(left=-base_indent))
914
+
915
+ consumed += (banner_width * (640.0 / 1280.0)) + 24.0
916
+
917
+ flexible_spacer(consumed)
918
+
919
+ if arch == "book" and icon:
920
+ add(render_icon(icon, size=40.0))
921
+ sp(16)
922
+
923
+ if tags:
924
+ # Wrap tags table in an Indenter to push it right
925
+ from reportlab.platypus import Indenter
926
+
927
+ if base_indent > 0:
928
+ add(Indenter(left=base_indent))
929
+
930
+ add_tags_row(
931
+ tags, t_theme.rl(t_theme.accent), tags_max_w - base_indent, align="LEFT"
932
+ )
933
+
934
+ if base_indent > 0:
935
+ add(Indenter(left=-base_indent))
936
+ sp(16)
937
+
938
+ if author or date:
939
+ meta_parts = []
940
+ if author:
941
+ meta_parts.append(author)
942
+ if date:
943
+ meta_parts.append(date)
944
+ st_meta = ParagraphStyle(
945
+ _uid(),
946
+ parent=s.BODY_ST,
947
+ textColor=t_theme.rl(t_theme.text_dim),
948
+ fontSize=12,
949
+ leading=16,
950
+ alignment=TA_LEFT,
951
+ leftIndent=base_indent,
952
+ )
953
+ add(Paragraph(" &nbsp;&bull;&nbsp; ".join(meta_parts), st_meta))
954
+
955
+ sp(16)
956
+
957
+
958
+ class CoverImageFlowable(Flowable):
959
+ """A background or positioned cover illustration (SVG-first, fallback to raster)."""
960
+
961
+ def __init__(
962
+ self,
963
+ source: str,
964
+ opacity: float = 0.06,
965
+ placement: str = "bottom-right",
966
+ width: float | None = None,
967
+ height: float | None = None,
968
+ ) -> None:
969
+ Flowable.__init__(self)
970
+ self.source = source
971
+ self.opacity = max(0.0, min(1.0, opacity))
972
+ self.placement = placement
973
+ self._custom_width = width
974
+ self._custom_height = height
975
+ self._image_path: str | None = None
976
+ self._svg_available = False
977
+ self.width = 0
978
+ self.height = 0
979
+
980
+ def _resolve_source(self) -> str | None:
981
+ import os
982
+
983
+ if os.path.exists(self.source):
984
+ return self.source
985
+
986
+ cache_dir = os.path.join(os.getcwd(), ".engrapha_cache", "images")
987
+ if not os.path.exists(cache_dir):
988
+ try:
989
+ os.makedirs(cache_dir, exist_ok=True)
990
+ except Exception:
991
+ return None
992
+
993
+ basename = os.path.basename(self.source)
994
+ cached_path = os.path.join(cache_dir, basename)
995
+
996
+ if os.path.exists(cached_path):
997
+ return cached_path
998
+
999
+ return None
1000
+
1001
+ def _render_svg(
1002
+ self, svg_path: str, canvas: Any, x: float, y: float, w: float, h: float
1003
+ ) -> bool:
1004
+ try:
1005
+ from svglib.svglib import svg2rlg # type: ignore[import, import-untyped, import-not-found]
1006
+
1007
+ drawing = svg2rlg(svg_path)
1008
+ if drawing is not None:
1009
+ from reportlab.graphics import renderPDF
1010
+
1011
+ scale_x = w / drawing.width if drawing.width > 0 else 1.0
1012
+ scale_y = h / drawing.height if drawing.height > 0 else 1.0
1013
+ scale = min(scale_x, scale_y)
1014
+
1015
+ canvas.saveState()
1016
+ canvas.translate(x, y)
1017
+ canvas.scale(scale, scale)
1018
+ canvas.setFillAlpha(self.opacity)
1019
+ renderPDF.draw(drawing, canvas, 0, 0)
1020
+ canvas.restoreState()
1021
+ return True
1022
+ except Exception:
1023
+ pass
1024
+ return False
1025
+
1026
+ def _render_raster(
1027
+ self, img_path: str, canvas: Any, x: float, y: float, w: float, h: float
1028
+ ) -> None:
1029
+ try:
1030
+ from reportlab.platypus import Image as RLImage
1031
+
1032
+ img = RLImage(img_path, width=w, height=h)
1033
+ img.drawOn(canvas, x, y)
1034
+ except Exception:
1035
+ pass
1036
+
1037
+ def wrap(self, aW: float, aH: float) -> tuple[float, float]:
1038
+ resolved = self._resolve_source()
1039
+ if resolved is None:
1040
+ self.width = int(max(self._custom_width or 100.0, 100.0))
1041
+ self.height = int(max(self._custom_height or 100.0, 100.0))
1042
+ return self.width, self.height
1043
+
1044
+ self._image_path = resolved
1045
+ is_svg = resolved.lower().endswith(".svg")
1046
+ self._svg_available = is_svg
1047
+
1048
+ if self.placement in ("background", "full"):
1049
+ self.width = 0
1050
+ self.height = 0
1051
+ return self.width, self.height
1052
+
1053
+ if is_svg:
1054
+ self.width = int(self._custom_width or 200.0)
1055
+ self.height = int(self._custom_height or 200.0)
1056
+ else:
1057
+ try:
1058
+ from reportlab.lib.utils import ImageReader
1059
+
1060
+ img = ImageReader(resolved)
1061
+ iw, ih = img.getSize()
1062
+ aspect = ih / iw if iw > 0 else 1.0
1063
+ if self._custom_width is not None and self._custom_height is not None:
1064
+ self.width = int(self._custom_width)
1065
+ self.height = int(self._custom_height)
1066
+ elif self._custom_width is not None:
1067
+ self.width = int(self._custom_width)
1068
+ self.height = int(self._custom_width * aspect)
1069
+ elif self._custom_height is not None:
1070
+ self.height = int(self._custom_height)
1071
+ self.width = int(self._custom_height / aspect)
1072
+ else:
1073
+ self.width = int(iw)
1074
+ self.height = int(ih)
1075
+ except Exception:
1076
+ self.width = int(max(self._custom_width or 100.0, 100.0))
1077
+ self.height = int(max(self._custom_height or 100.0, 100.0))
1078
+
1079
+ return self.width, self.height
1080
+
1081
+ def draw(self) -> None:
1082
+ if self._image_path is None:
1083
+ return
1084
+
1085
+ from .document import PAGE_W, PAGE_H
1086
+
1087
+ canvas = self.canv
1088
+
1089
+ if self.placement == "full":
1090
+ x, y = 0, 0
1091
+ w, h = PAGE_W, PAGE_H
1092
+ elif self.placement == "top-right":
1093
+ x = PAGE_W - self.width - 20
1094
+ y = PAGE_H - self.height - 20
1095
+ w, h = self.width, self.height
1096
+ elif self.placement == "top-left":
1097
+ x = 20
1098
+ y = PAGE_H - self.height - 20
1099
+ w, h = self.width, self.height
1100
+ elif self.placement == "bottom-right":
1101
+ x = PAGE_W - self.width - 20
1102
+ y = 20
1103
+ w, h = self.width, self.height
1104
+ elif self.placement == "bottom-left":
1105
+ x = 20
1106
+ y = 20
1107
+ w, h = self.width, self.height
1108
+ elif self.placement == "center":
1109
+ x = (PAGE_W - self.width) / 2.0
1110
+ y = (PAGE_H - self.height) / 2.0
1111
+ w, h = self.width, self.height
1112
+ elif self.placement == "background":
1113
+ x = 0
1114
+ y = 0
1115
+ w, h = PAGE_W, PAGE_H
1116
+ else:
1117
+ x = PAGE_W - self.width - 20
1118
+ y = 20
1119
+ w, h = self.width, self.height
1120
+
1121
+ if self._svg_available and self._render_svg(
1122
+ self._image_path, canvas, x, y, w, h
1123
+ ):
1124
+ return
1125
+
1126
+ self._render_raster(self._image_path, canvas, x, y, w, h)
1127
+
1128
+
1129
+ def cover_image(
1130
+ source: str,
1131
+ opacity: float = 0.06,
1132
+ placement: str = "bottom-right",
1133
+ width: float | None = None,
1134
+ height: float | None = None,
1135
+ fallbacks: str | list[str] | None = None,
1136
+ ) -> None:
1137
+ """
1138
+ Add a background or positioned cover illustration.
1139
+ SVG-first: uses svglib if available for native vector rendering.
1140
+ Falls back to raster image handling otherwise.
1141
+
1142
+ Args:
1143
+ source: Path or URL to SVG/image file
1144
+ opacity: Opacity level (0.0 to 1.0), default 0.06 for subtle backgrounds
1145
+ placement: One of 'full', 'background', 'top-right', 'top-left',
1146
+ 'bottom-right', 'bottom-left', 'center'
1147
+ width: Optional explicit width in points
1148
+ height: Optional explicit height in points
1149
+ fallbacks: Optional fallback source(s) if primary fails
1150
+
1151
+ Examples:
1152
+ >>> en.cover_image("network.svg", opacity=0.08)
1153
+ >>> en.cover_image("hero.png", placement="full", opacity=0.15)
1154
+ """
1155
+ import os
1156
+
1157
+ sources = [source]
1158
+ if fallbacks is not None:
1159
+ if isinstance(fallbacks, str):
1160
+ sources.append(fallbacks)
1161
+ else:
1162
+ sources.extend(fallbacks)
1163
+
1164
+ resolved = None
1165
+ for src in sources:
1166
+ if os.path.exists(src):
1167
+ resolved = src
1168
+ break
1169
+ cache_dir = os.path.join(os.getcwd(), ".engrapha_cache", "images")
1170
+ cached = os.path.join(cache_dir, os.path.basename(src))
1171
+ if os.path.exists(cached):
1172
+ resolved = cached
1173
+ break
1174
+
1175
+ if resolved is None:
1176
+ return
1177
+
1178
+ flowable = CoverImageFlowable(
1179
+ source=resolved,
1180
+ opacity=opacity,
1181
+ placement=placement,
1182
+ width=width,
1183
+ height=height,
1184
+ )
1185
+ add(flowable)
1186
+
1187
+
1188
+ _COVER_PRESETS: dict[str, dict[str, Any]] = {
1189
+ "engineering": {
1190
+ "cover_theme": "linear",
1191
+ "icon": "&#128187;",
1192
+ "tags": ["Engineering", "Technical Reference", "2026 Edition"],
1193
+ },
1194
+ "research-paper": {
1195
+ "cover_theme": "academic_modern",
1196
+ "icon": "&#128190;",
1197
+ "tags": ["Research", "Academic Paper", "Peer Review"],
1198
+ },
1199
+ "course-notes": {
1200
+ "cover_theme": "catppuccin",
1201
+ "icon": "&#128218;",
1202
+ "tags": ["Course Notes", "Study Guide", "Exam Prep"],
1203
+ },
1204
+ "networking": {
1205
+ "cover_theme": "notion",
1206
+ "icon": "&#128279;",
1207
+ "tags": ["Networking", "Semester IV", "CCNA"],
1208
+ },
1209
+ "database": {
1210
+ "cover_theme": "academic_modern",
1211
+ "icon": "&#128451;",
1212
+ "tags": ["Database Systems", "SQL", "Data Engineering"],
1213
+ },
1214
+ "programming": {
1215
+ "cover_theme": "linear",
1216
+ "icon": "&#128009;",
1217
+ "tags": ["Programming", "Software Engineering", "Clean Code"],
1218
+ },
1219
+ }
1220
+
1221
+
1222
+ def cover_preset(preset_name: str, **kwargs: Any) -> None:
1223
+ """
1224
+ Apply a predefined cover preset configuration.
1225
+
1226
+ Built-in presets: 'engineering', 'research-paper', 'course-notes',
1227
+ 'networking', 'database', 'programming'
1228
+
1229
+ Each preset bundles cover_theme, icon, and tags for instant professional covers.
1230
+
1231
+ Example:
1232
+ >>> en.cover_preset("engineering")
1233
+ >>> en.cover_preset("networking", title="Advanced Networking")
1234
+ """
1235
+ preset = _COVER_PRESETS.get(preset_name)
1236
+ if preset is None:
1237
+ return
1238
+
1239
+ merged = dict(preset)
1240
+ merged.update(kwargs)
1241
+
1242
+ cover_theme = merged.pop("cover_theme", None)
1243
+ tags = merged.pop("tags", None)
1244
+ icon = merged.pop("icon", None)
1245
+
1246
+ cover_card(
1247
+ title=merged.pop("title", "Untitled"),
1248
+ subtitle=merged.pop("subtitle", None),
1249
+ style=merged.pop("style", "standard"),
1250
+ author=merged.pop("author", None),
1251
+ date=merged.pop("date", None),
1252
+ ornament=merged.pop("ornament", None),
1253
+ tags=tags,
1254
+ icon=icon,
1255
+ cover_theme=cover_theme,
1256
+ )
1257
+
1258
+
1259
+ def chap_box(text: str, bookmark: bool = True) -> None:
1260
+ """Chapter / topic heading card."""
1261
+ t_theme = get_theme()
1262
+ if t_theme.name == "Print Light":
1263
+ has_printable = False
1264
+ for item in reversed(story):
1265
+ if isinstance(item, PageBreak):
1266
+ break
1267
+ if item.__class__.__name__ not in (
1268
+ "Bookmark",
1269
+ "ThemeSetterFlowable",
1270
+ "Footer",
1271
+ "Header",
1272
+ "PageBorder",
1273
+ "PageMargins",
1274
+ "PageNumbering",
1275
+ ):
1276
+ has_printable = True
1277
+ break
1278
+ if has_printable:
1279
+ br()
1280
+
1281
+ if bookmark:
1282
+ add(Bookmark(_get_bookmark_key(), _clean_title(text), level=0))
1283
+
1284
+ if t_theme.name == "Academic":
1285
+ sp(10)
1286
+ st = ParagraphStyle(
1287
+ _uid(),
1288
+ fontSize=16,
1289
+ textColor=t_theme.rl(t_theme.text),
1290
+ fontName=t_theme.heading_font,
1291
+ alignment=TA_CENTER,
1292
+ leading=22,
1293
+ )
1294
+ t = Table([[Paragraph(text, st)]], colWidths=["100%"])
1295
+ t.keepWithNext = True
1296
+ setattr(t, "_is_chap_box", True)
1297
+ setattr(t, "_box_title", text)
1298
+ t.setStyle(
1299
+ TableStyle(
1300
+ [
1301
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
1302
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
1303
+ ("LINEBELOW", (0, 0), (-1, -1), 1.0, t_theme.rl(t_theme.text)),
1304
+ ]
1305
+ )
1306
+ )
1307
+ add(t)
1308
+ sp(12)
1309
+ return
1310
+
1311
+ elif t_theme.name == "Notion":
1312
+ sp(8)
1313
+ st = ParagraphStyle(
1314
+ _uid(),
1315
+ fontSize=15,
1316
+ textColor=t_theme.rl(t_theme.text),
1317
+ fontName=t_theme.heading_font,
1318
+ alignment=TA_LEFT,
1319
+ leading=20,
1320
+ )
1321
+ t = Table([[Paragraph(text, st)]], colWidths=["100%"])
1322
+ t.keepWithNext = True
1323
+ setattr(t, "_is_chap_box", True)
1324
+ setattr(t, "_box_title", text)
1325
+ t.setStyle(
1326
+ TableStyle(
1327
+ [
1328
+ ("TOPPADDING", (0, 0), (-1, -1), 4),
1329
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
1330
+ ("LINEBELOW", (0, 0), (-1, -1), 1.0, t_theme.rl(t_theme.table_bdr)),
1331
+ ]
1332
+ )
1333
+ )
1334
+ add(t)
1335
+ sp(10)
1336
+ return
1337
+
1338
+ elif t_theme.name == "Textbook":
1339
+ st = ParagraphStyle(
1340
+ _uid(),
1341
+ fontSize=15,
1342
+ textColor=t_theme.rl(t_theme.text),
1343
+ fontName=t_theme.heading_font,
1344
+ alignment=TA_LEFT,
1345
+ leading=20,
1346
+ )
1347
+ t = Table([[Paragraph(text, st)]], colWidths=["100%"])
1348
+ t.keepWithNext = True
1349
+ setattr(t, "_is_chap_box", True)
1350
+ setattr(t, "_box_title", text)
1351
+ t.setStyle(
1352
+ TableStyle(
1353
+ [
1354
+ ("BACKGROUND", (0, 0), (-1, -1), t_theme.rl(t_theme.surface_alt)),
1355
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
1356
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
1357
+ ("LEFTPADDING", (0, 0), (-1, -1), 10),
1358
+ ("RIGHTPADDING", (0, 0), (-1, -1), 10),
1359
+ ("LINELEFT", (0, 0), (0, -1), 4.0, t_theme.rl(t_theme.accent)),
1360
+ ]
1361
+ )
1362
+ )
1363
+ add(t)
1364
+ sp(10)
1365
+ return
1366
+
1367
+ st = ParagraphStyle(
1368
+ _uid(),
1369
+ fontSize=17,
1370
+ textColor=t_theme.rl(t_theme.text),
1371
+ fontName=t_theme.heading_font,
1372
+ alignment=TA_LEFT,
1373
+ leading=26,
1374
+ )
1375
+ t = Table([[Paragraph(text, st)]], colWidths=["100%"])
1376
+ t.keepWithNext = True
1377
+ setattr(t, "_is_chap_box", True)
1378
+ setattr(t, "_box_title", text)
1379
+
1380
+ thickness = 1.5 * t_theme.border_thickness
1381
+ bdr_color = t_theme.rl(t_theme.border_color or t_theme.accent)
1382
+ t.setStyle(
1383
+ TableStyle(
1384
+ [
1385
+ ("BACKGROUND", (0, 0), (-1, -1), t_theme.rl(t_theme.surface_alt)),
1386
+ ("TOPPADDING", (0, 0), (-1, -1), 10),
1387
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 10),
1388
+ ("LEFTPADDING", (0, 0), (-1, -1), 14),
1389
+ ("RIGHTPADDING", (0, 0), (-1, -1), 14),
1390
+ ("BOX", (0, 0), (-1, -1), thickness, bdr_color),
1391
+ ]
1392
+ )
1393
+ )
1394
+ add(t)
1395
+ sp(8)
1396
+
1397
+
1398
+ def section(text: str, bookmark: bool = True, keep_with_next: bool = True) -> None:
1399
+ """Section heading followed by an accent rule."""
1400
+ if bookmark:
1401
+ add(Bookmark(_get_bookmark_key(), _clean_title(text), level=1))
1402
+ t_theme = get_theme()
1403
+ st = ParagraphStyle(
1404
+ _uid(),
1405
+ fontSize=13,
1406
+ textColor=t_theme.rl(t_theme.accent),
1407
+ fontName=t_theme.heading_font,
1408
+ spaceBefore=12,
1409
+ spaceAfter=5,
1410
+ leading=20,
1411
+ keepWithNext=keep_with_next,
1412
+ )
1413
+ p = Paragraph(text, st)
1414
+ p.keepWithNext = keep_with_next
1415
+ setattr(p, "_is_section", True)
1416
+ setattr(p, "_section_title", text)
1417
+ add(p)
1418
+ rule(t_theme.rl(t_theme.accent), 0.5, keepWithNext=keep_with_next)
1419
+
1420
+
1421
+ def subsection(text: str, bookmark: bool = True) -> None:
1422
+ """Smaller sub-topic heading."""
1423
+ if bookmark:
1424
+ add(Bookmark(_get_bookmark_key(), _clean_title(text), level=2))
1425
+ t_theme = get_theme()
1426
+ st = ParagraphStyle(
1427
+ _uid(),
1428
+ fontSize=11,
1429
+ textColor=t_theme.rl(t_theme.accent),
1430
+ fontName=t_theme.heading_font,
1431
+ spaceBefore=8,
1432
+ spaceAfter=4,
1433
+ leading=17,
1434
+ keepWithNext=True,
1435
+ )
1436
+ p = Paragraph(text, st)
1437
+ p.keepWithNext = True
1438
+ setattr(p, "_is_subsection", True)
1439
+ setattr(p, "_subsection_title", text)
1440
+ add(p)
1441
+
1442
+
1443
+ def body(
1444
+ text: str,
1445
+ font_name: str | None = None,
1446
+ font_size: float | None = None,
1447
+ text_color: str | Any | None = None,
1448
+ leading: float | None = None,
1449
+ ) -> None:
1450
+ """Justified body paragraph. Supports ReportLab HTML tags."""
1451
+ t_theme = get_theme()
1452
+ f_size = font_size if font_size is not None else t_theme.size_body
1453
+ f_name = font_name if font_name is not None else t_theme.body_font
1454
+ color = (
1455
+ t_theme.rl(text_color) if text_color is not None else t_theme.rl(t_theme.text)
1456
+ )
1457
+ lead = leading if leading is not None else f_size * 1.7
1458
+ st = ParagraphStyle(
1459
+ _uid(),
1460
+ fontSize=f_size,
1461
+ textColor=color,
1462
+ fontName=f_name,
1463
+ leading=lead,
1464
+ spaceAfter=5,
1465
+ alignment=TA_JUSTIFY,
1466
+ )
1467
+ add(Paragraph(text, st))
1468
+
1469
+
1470
+ def definition(text: str, bg: Any = None, border: Any = None) -> None:
1471
+ """Highlighted definition block."""
1472
+ t_theme = get_theme()
1473
+ f_name = t_theme.body_font
1474
+ f_size = t_theme.size_body
1475
+
1476
+ st = ParagraphStyle(
1477
+ _uid(),
1478
+ fontSize=f_size,
1479
+ textColor=t_theme.rl(t_theme.text),
1480
+ fontName=f_name,
1481
+ leading=f_size * 1.6,
1482
+ spaceBefore=4,
1483
+ spaceAfter=6,
1484
+ alignment=TA_JUSTIFY,
1485
+ )
1486
+
1487
+ if getattr(t_theme, "plain_questions", False):
1488
+ p = Paragraph(text, st)
1489
+ add(p)
1490
+ sp(4)
1491
+ return
1492
+
1493
+ bg_color = _to_color(bg, t_theme.surface_alt)
1494
+ border_color = _to_color(border, t_theme.accent)
1495
+ st.leftIndent = 10
1496
+
1497
+ t = Table([[Paragraph(text, st)]], colWidths=["100%"])
1498
+ t.setStyle(
1499
+ TableStyle(
1500
+ [
1501
+ ("BACKGROUND", (0, 0), (-1, -1), bg_color),
1502
+ ("TOPPADDING", (0, 0), (-1, -1), 9),
1503
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 9),
1504
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
1505
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
1506
+ ("BOX", (0, 0), (-1, -1), 1.2, border_color),
1507
+ ]
1508
+ )
1509
+ )
1510
+ setattr(t, "_is_definition", True)
1511
+ add(t)
1512
+ sp(7)
1513
+
1514
+
1515
+ def highlight(text: str, bg: Any = None, border: Any = None) -> None:
1516
+ """Exam-important highlight block."""
1517
+ t_theme = get_theme()
1518
+ bg_color = _to_color(bg, t_theme.surface_alt)
1519
+ border_color = _to_color(border, t_theme.yellow)
1520
+
1521
+ st = ParagraphStyle(
1522
+ _uid(),
1523
+ fontSize=10,
1524
+ textColor=t_theme.rl(t_theme.text),
1525
+ fontName="Helvetica",
1526
+ leading=16,
1527
+ )
1528
+ t = Table([[Paragraph(text, st)]], colWidths=["100%"])
1529
+ t.setStyle(
1530
+ TableStyle(
1531
+ [
1532
+ ("BACKGROUND", (0, 0), (-1, -1), bg_color),
1533
+ ("TOPPADDING", (0, 0), (-1, -1), 9),
1534
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 9),
1535
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
1536
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
1537
+ ("BOX", (0, 0), (-1, -1), 1.4, border_color),
1538
+ ]
1539
+ )
1540
+ )
1541
+ setattr(t, "_is_highlight", True)
1542
+ add(t)
1543
+ sp(7)
1544
+
1545
+
1546
+ def tip(text: str) -> None:
1547
+ """Green exam-tip callout box."""
1548
+ t_theme = get_theme()
1549
+ if t_theme.name == "Print Light":
1550
+ body(f"<b>Exam Tip:</b> {text}")
1551
+ return
1552
+ st = ParagraphStyle(
1553
+ _uid(),
1554
+ fontSize=9.5,
1555
+ textColor=t_theme.rl(t_theme.green),
1556
+ fontName="Helvetica-Bold",
1557
+ leading=15,
1558
+ leftIndent=6,
1559
+ spaceAfter=5,
1560
+ )
1561
+ p = Paragraph(f"<b>EXAM TIP:</b> {text}", st)
1562
+ t = Table([[p]], colWidths=["100%"])
1563
+ t.setStyle(
1564
+ TableStyle(
1565
+ [
1566
+ ("BACKGROUND", (0, 0), (-1, -1), t_theme.rl(t_theme.green_bg)),
1567
+ ("BOX", (0, 0), (-1, -1), 1.2, t_theme.rl(t_theme.green)),
1568
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
1569
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
1570
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
1571
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
1572
+ ]
1573
+ )
1574
+ )
1575
+ setattr(t, "_is_tip", True)
1576
+ add(t)
1577
+ sp(6)
1578
+
1579
+
1580
+ def note(text: str) -> None:
1581
+ """Yellow note callout box."""
1582
+ t_theme = get_theme()
1583
+ if getattr(t_theme, "plain_questions", False) or t_theme.name == "Print Light":
1584
+ body(f"<b>Note:</b> {text}")
1585
+ return
1586
+ st = ParagraphStyle(
1587
+ _uid(),
1588
+ fontSize=9.5,
1589
+ textColor=t_theme.rl(t_theme.yellow),
1590
+ fontName="Helvetica-Oblique",
1591
+ leading=15,
1592
+ leftIndent=6,
1593
+ spaceAfter=4,
1594
+ )
1595
+ p = Paragraph(f"<b>NOTE:</b> {text}", st)
1596
+ t = Table([[p]], colWidths=["100%"])
1597
+ t.setStyle(
1598
+ TableStyle(
1599
+ [
1600
+ ("BACKGROUND", (0, 0), (-1, -1), t_theme.rl(t_theme.yellow_bg)),
1601
+ ("BOX", (0, 0), (-1, -1), 1.2, t_theme.rl(t_theme.yellow)),
1602
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
1603
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
1604
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
1605
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
1606
+ ]
1607
+ )
1608
+ )
1609
+ setattr(t, "_is_note", True)
1610
+ add(t)
1611
+
1612
+
1613
+ def formula(
1614
+ latex_str: str,
1615
+ color: str | None = None,
1616
+ fontsize: float | None = None,
1617
+ ) -> str:
1618
+ """Inline formula returning markup string using a cached PNG rendering."""
1619
+ import os
1620
+ import hashlib
1621
+ from .theme import get_theme
1622
+ from matplotlib.mathtext import MathTextParser, math_to_image
1623
+
1624
+ t_theme = get_theme()
1625
+ math_str = latex_str if latex_str.startswith("$") else f"${latex_str}$"
1626
+
1627
+ formula_color = color if color is not None else t_theme.text
1628
+ formula_size = fontsize if fontsize is not None else t_theme.size_body
1629
+
1630
+ # Ensure cache directory exists in the workspace
1631
+ cache_dir = os.path.join(os.getcwd(), ".engrapha_cache")
1632
+ if not os.path.exists(cache_dir):
1633
+ try:
1634
+ os.makedirs(cache_dir, exist_ok=True)
1635
+ except Exception:
1636
+ pass
1637
+
1638
+ # Unique hash based on text, color, and background to support dark/light modes
1639
+ # Added :trans_v3 to invalidate older cached formulas and force redraw with 10pt
1640
+ hash_val = hashlib.md5(
1641
+ f"{math_str}:{formula_color}:{t_theme.bg}:{formula_size}:trans_v3".encode(
1642
+ "utf-8"
1643
+ )
1644
+ ).hexdigest()
1645
+ filename = os.path.join(cache_dir, f"math_{hash_val}.png")
1646
+
1647
+ # Generate PNG if not exists
1648
+ if not os.path.exists(filename):
1649
+ try:
1650
+ from matplotlib import figure
1651
+
1652
+ parser_img = MathTextParser("path")
1653
+ w_img, h_img, d_img, _, _ = parser_img.parse(math_str, dpi=72)
1654
+ fig = figure.Figure(figsize=(w_img / 72.0, h_img / 72.0))
1655
+ fig.text(0, d_img / h_img, math_str, color=formula_color, fontsize=10)
1656
+ fig.savefig(filename, dpi=300, format="png", transparent=True)
1657
+ except Exception:
1658
+ try:
1659
+ math_to_image(math_str, filename, color=formula_color, dpi=300)
1660
+ except Exception:
1661
+ return f"[Math: {latex_str}]"
1662
+
1663
+ # Parse bounds in points at 72 DPI
1664
+ try:
1665
+ parser = MathTextParser("path")
1666
+ width, height, depth, _, _ = parser.parse(math_str, dpi=72)
1667
+ except Exception:
1668
+ width, height, depth = 50.0, 12.0, 3.0
1669
+
1670
+ # Standard scale matching active theme size
1671
+ scale = formula_size / 10.0
1672
+ w_points = width * scale
1673
+ h_points = (height + depth) * scale
1674
+ valign_offset = -depth * scale
1675
+
1676
+ # Format image path for ReportLab
1677
+ rl_path = filename.replace("\\", "/")
1678
+
1679
+ # Register the LaTeX formula mapping
1680
+ math_latex_registry[rl_path] = latex_str
1681
+
1682
+ return f'<img src="{rl_path}" width="{w_points:.2f}" height="{h_points:.2f}" valign="{valign_offset:.2f}" />'
1683
+
1684
+
1685
+ def formula_block(
1686
+ latex_str: str,
1687
+ color: Any = None,
1688
+ fontsize: float | None = None,
1689
+ ) -> None:
1690
+ """Centred standalone LaTeX math formula block."""
1691
+ from .document import LaTeXFlowable
1692
+ from .theme import get_theme
1693
+
1694
+ t_theme = get_theme()
1695
+ f_size = fontsize if fontsize is not None else t_theme.size_body
1696
+ flow = LaTeXFlowable(latex_str, fontsize=f_size, color=color)
1697
+
1698
+ # Wrap in a Table to center it
1699
+ from reportlab.platypus import Table, TableStyle
1700
+
1701
+ t = Table([[flow]], colWidths=["100%"])
1702
+ t.setStyle(
1703
+ TableStyle(
1704
+ [
1705
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
1706
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
1707
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
1708
+ ]
1709
+ )
1710
+ )
1711
+ add(t)
1712
+ sp(4)
1713
+
1714
+
1715
+ def warning(text: str) -> None:
1716
+ """Muted red warning callout box."""
1717
+ t_theme = get_theme()
1718
+ if t_theme.name == "Print Light":
1719
+ body(f"<b>Warning:</b> {text}")
1720
+ return
1721
+ st = ParagraphStyle(
1722
+ _uid(),
1723
+ fontSize=9.5,
1724
+ textColor=t_theme.rl(t_theme.red),
1725
+ fontName=t_theme.heading_font,
1726
+ leading=15,
1727
+ leftIndent=6,
1728
+ spaceAfter=5,
1729
+ )
1730
+ p = Paragraph(f"<b>WARNING:</b> {text}", st)
1731
+ t = Table([[p]], colWidths=["100%"])
1732
+ t.setStyle(
1733
+ TableStyle(
1734
+ [
1735
+ ("BACKGROUND", (0, 0), (-1, -1), t_theme.rl(t_theme.red_bg)),
1736
+ ("BOX", (0, 0), (-1, -1), 1.2, t_theme.rl(t_theme.red)),
1737
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
1738
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
1739
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
1740
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
1741
+ ]
1742
+ )
1743
+ )
1744
+ setattr(t, "_is_warning", True)
1745
+ add(t)
1746
+ sp(6)
1747
+
1748
+
1749
+ def important(text: str) -> None:
1750
+ """Purple/indigo important callout box."""
1751
+ t_theme = get_theme()
1752
+ if t_theme.name == "Print Light":
1753
+ body(f"<b>Important:</b> {text}")
1754
+ return
1755
+ st = ParagraphStyle(
1756
+ _uid(),
1757
+ fontSize=9.5,
1758
+ textColor=t_theme.rl(t_theme.purple),
1759
+ fontName=t_theme.heading_font,
1760
+ leading=15,
1761
+ leftIndent=6,
1762
+ spaceAfter=5,
1763
+ )
1764
+ p = Paragraph(f"<b>IMPORTANT:</b> {text}", st)
1765
+ t = Table([[p]], colWidths=["100%"])
1766
+ t.setStyle(
1767
+ TableStyle(
1768
+ [
1769
+ ("BACKGROUND", (0, 0), (-1, -1), t_theme.rl(t_theme.purple_bg)),
1770
+ ("BOX", (0, 0), (-1, -1), 1.2, t_theme.rl(t_theme.purple)),
1771
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
1772
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
1773
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
1774
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
1775
+ ]
1776
+ )
1777
+ )
1778
+ setattr(t, "_is_important", True)
1779
+ add(t)
1780
+ sp(6)
1781
+
1782
+
1783
+ def exam(text: str) -> None:
1784
+ """Yellow exam/revision callout box."""
1785
+ t_theme = get_theme()
1786
+ if t_theme.name == "Print Light":
1787
+ body(f"<b>Exam:</b> {text}")
1788
+ return
1789
+ st = ParagraphStyle(
1790
+ _uid(),
1791
+ fontSize=9.5,
1792
+ textColor=t_theme.rl(t_theme.yellow),
1793
+ fontName=t_theme.heading_font,
1794
+ leading=15,
1795
+ leftIndent=6,
1796
+ spaceAfter=5,
1797
+ )
1798
+ p = Paragraph(f"<b>EXAM:</b> {text}", st)
1799
+ t = Table([[p]], colWidths=["100%"])
1800
+ t.setStyle(
1801
+ TableStyle(
1802
+ [
1803
+ ("BACKGROUND", (0, 0), (-1, -1), t_theme.rl(t_theme.yellow_bg)),
1804
+ ("BOX", (0, 0), (-1, -1), 1.2, t_theme.rl(t_theme.yellow)),
1805
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
1806
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
1807
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
1808
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
1809
+ ]
1810
+ )
1811
+ )
1812
+ setattr(t, "_is_exam", True)
1813
+ add(t)
1814
+ sp(6)
1815
+
1816
+
1817
+ def theorem(text: str) -> None:
1818
+ """Purple bordered box for academic definitions."""
1819
+ t_theme = get_theme()
1820
+ st = ParagraphStyle(
1821
+ _uid(),
1822
+ fontSize=9.5,
1823
+ textColor=t_theme.rl(t_theme.text),
1824
+ fontName=t_theme.body_font,
1825
+ leading=15,
1826
+ leftIndent=6,
1827
+ spaceAfter=5,
1828
+ )
1829
+ p = Paragraph(f"<b>THEOREM:</b> {text}", st)
1830
+ t = Table([[p]], colWidths=["100%"])
1831
+ t.setStyle(
1832
+ TableStyle(
1833
+ [
1834
+ ("BACKGROUND", (0, 0), (-1, -1), t_theme.rl(t_theme.purple_bg)),
1835
+ ("BOX", (0, 0), (-1, -1), 1.5, t_theme.rl(t_theme.purple)),
1836
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
1837
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
1838
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
1839
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
1840
+ ]
1841
+ )
1842
+ )
1843
+ setattr(t, "_is_theorem", True)
1844
+ add(t)
1845
+ sp(6)
1846
+
1847
+
1848
+ def proof(text: str) -> None:
1849
+ """Left-indented italicized block ending with [Q.E.D.]."""
1850
+ t_theme = get_theme()
1851
+ italic_font = t_theme.body_font
1852
+ if t_theme.body_font == "Helvetica":
1853
+ italic_font = "Helvetica-Oblique"
1854
+ elif t_theme.body_font == "Times-Roman":
1855
+ italic_font = "Times-Italic"
1856
+ elif t_theme.body_font == "Courier":
1857
+ italic_font = "Courier-Oblique"
1858
+
1859
+ st = ParagraphStyle(
1860
+ _uid(),
1861
+ fontSize=t_theme.size_body,
1862
+ textColor=t_theme.rl(t_theme.text),
1863
+ fontName=italic_font,
1864
+ leading=t_theme.size_body * 1.6,
1865
+ leftIndent=20,
1866
+ spaceBefore=6,
1867
+ spaceAfter=6,
1868
+ alignment=TA_JUSTIFY,
1869
+ )
1870
+ p = Paragraph(f"<i>Proof.</i> {text} [Q.E.D.]", st)
1871
+ add(p)
1872
+ sp(6)
1873
+
1874
+
1875
+ def question(
1876
+ text: str,
1877
+ font_name: str | None = None,
1878
+ font_size: float | None = None,
1879
+ text_color: str | Any | None = None,
1880
+ leading: float | None = None,
1881
+ bg_color: str | Any | None = None,
1882
+ border_color: str | Any | None = None,
1883
+ ) -> None:
1884
+ """Question block with a left border."""
1885
+ t_theme = get_theme()
1886
+ f_size = font_size if font_size is not None else t_theme.size_question
1887
+ f_name = font_name if font_name is not None else t_theme.heading_font
1888
+ color = (
1889
+ t_theme.rl(text_color) if text_color is not None else t_theme.rl(t_theme.text)
1890
+ )
1891
+ lead = leading if leading is not None else f_size * 1.5
1892
+ st = ParagraphStyle(
1893
+ _uid(),
1894
+ fontSize=f_size,
1895
+ textColor=color,
1896
+ fontName=f_name,
1897
+ leading=lead,
1898
+ )
1899
+ if getattr(t_theme, "plain_questions", False):
1900
+ st.spaceBefore = 8
1901
+ st.spaceAfter = 8
1902
+ st.keepWithNext = True
1903
+ if not (re.match(r"^Q\d*[\d\.\(\)a-e]*\s*:", text) or text.startswith("Q:")):
1904
+ p = Paragraph(f"<b>Q:</b> {text}", st)
1905
+ else:
1906
+ p = Paragraph(text, st)
1907
+ setattr(p, "_is_question", True)
1908
+ add(p)
1909
+ sp(4)
1910
+ return
1911
+
1912
+ bg = (
1913
+ t_theme.rl(bg_color)
1914
+ if bg_color is not None
1915
+ else t_theme.rl(t_theme.surface_alt)
1916
+ )
1917
+ border = (
1918
+ t_theme.rl(border_color)
1919
+ if border_color is not None
1920
+ else t_theme.rl(t_theme.accent)
1921
+ )
1922
+ if not (re.match(r"^Q\d*[\d\.\(\)a-e]*\s*:", text) or text.startswith("Q:")):
1923
+ p = Paragraph(f"<b>Q:</b> {text}", st)
1924
+ else:
1925
+ p = Paragraph(text, st)
1926
+ t = Table([[p]], colWidths=["100%"])
1927
+ t.setStyle(
1928
+ TableStyle(
1929
+ [
1930
+ ("BACKGROUND", (0, 0), (-1, -1), bg),
1931
+ ("LINEBEFORE", (0, 0), (0, -1), 3.0, border),
1932
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
1933
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
1934
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
1935
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
1936
+ ]
1937
+ )
1938
+ )
1939
+ setattr(t, "_is_question", True)
1940
+ add(t)
1941
+ sp(6)
1942
+
1943
+
1944
+ def qbox(
1945
+ text: str,
1946
+ font_name: str | None = None,
1947
+ font_size: float | None = None,
1948
+ text_color: str | Any | None = None,
1949
+ leading: float | None = None,
1950
+ bg_color: str | Any | None = None,
1951
+ border_color: str | Any | None = None,
1952
+ ) -> None:
1953
+ """Question block with a full border, styled according to the current theme."""
1954
+ t_theme = get_theme()
1955
+ f_size = font_size if font_size is not None else t_theme.size_question
1956
+ f_name = font_name if font_name is not None else t_theme.heading_font
1957
+ color = (
1958
+ t_theme.rl(text_color) if text_color is not None else t_theme.rl(t_theme.text)
1959
+ )
1960
+ lead = leading if leading is not None else f_size * 1.5
1961
+ st = ParagraphStyle(
1962
+ _uid(),
1963
+ fontSize=f_size,
1964
+ textColor=color,
1965
+ fontName=f_name,
1966
+ leading=lead,
1967
+ )
1968
+ if getattr(t_theme, "plain_questions", False):
1969
+ st.spaceBefore = 8
1970
+ st.spaceAfter = 8
1971
+ st.keepWithNext = True
1972
+ if not (re.match(r"^Q\d*[\d\.\(\)a-e]*\s*:", text) or text.startswith("Q:")):
1973
+ p = Paragraph(f"<b>Q:</b> {text}", st)
1974
+ else:
1975
+ p = Paragraph(text, st)
1976
+ setattr(p, "_is_question", True)
1977
+ add(p)
1978
+ sp(4)
1979
+ return
1980
+
1981
+ bg = (
1982
+ t_theme.rl(bg_color)
1983
+ if bg_color is not None
1984
+ else t_theme.rl(t_theme.surface_alt)
1985
+ )
1986
+ border = (
1987
+ t_theme.rl(border_color)
1988
+ if border_color is not None
1989
+ else t_theme.rl(t_theme.accent)
1990
+ )
1991
+ if re.match(r"^Q\d*[\d\.\(\)a-e]*\s*:", text) or text.startswith("Q:"):
1992
+ p = Paragraph(text, st)
1993
+ else:
1994
+ p = Paragraph(f"<b>Q:</b> {text}", st)
1995
+ t = Table([[p]], colWidths=["100%"])
1996
+ t.setStyle(
1997
+ TableStyle(
1998
+ [
1999
+ ("BACKGROUND", (0, 0), (-1, -1), bg),
2000
+ ("BOX", (0, 0), (-1, -1), 1.5, border),
2001
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
2002
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
2003
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
2004
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
2005
+ ]
2006
+ )
2007
+ )
2008
+ setattr(t, "_is_question", True)
2009
+ add(t)
2010
+ sp(6)
2011
+
2012
+
2013
+ def answer(
2014
+ text: str,
2015
+ font_name: str | None = None,
2016
+ font_size: float | None = None,
2017
+ text_color: str | Any | None = None,
2018
+ leading: float | None = None,
2019
+ ) -> None:
2020
+ """Answer block with a green left border."""
2021
+ t_theme = get_theme()
2022
+ f_size = font_size if font_size is not None else t_theme.size_body
2023
+ f_name = font_name if font_name is not None else t_theme.body_font
2024
+ color = (
2025
+ t_theme.rl(text_color) if text_color is not None else t_theme.rl(t_theme.text)
2026
+ )
2027
+ lead = leading if leading is not None else f_size * 1.6
2028
+ st = ParagraphStyle(
2029
+ _uid(),
2030
+ fontSize=f_size,
2031
+ textColor=color,
2032
+ fontName=f_name,
2033
+ leading=lead,
2034
+ )
2035
+ p = Paragraph(f"<b>A:</b> {text}", st)
2036
+ t = Table([[p]], colWidths=["100%"])
2037
+ t.setStyle(
2038
+ TableStyle(
2039
+ [
2040
+ ("BACKGROUND", (0, 0), (-1, -1), t_theme.rl(t_theme.surface)),
2041
+ ("LINEBEFORE", (0, 0), (0, -1), 3.0, t_theme.rl(t_theme.green)),
2042
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
2043
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
2044
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
2045
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
2046
+ ]
2047
+ )
2048
+ )
2049
+ setattr(t, "_is_answer", True)
2050
+ add(t)
2051
+ sp(6)
2052
+
2053
+
2054
+ def mcq(
2055
+ question_text: str, options: list[str], correct_index: int | None = None
2056
+ ) -> None:
2057
+ """Multiple choice question block."""
2058
+ t_theme = get_theme()
2059
+ st_q = ParagraphStyle(
2060
+ _uid(),
2061
+ fontSize=10,
2062
+ textColor=t_theme.rl(t_theme.text),
2063
+ fontName=t_theme.heading_font,
2064
+ leading=16,
2065
+ )
2066
+ story_items = [Paragraph(f"<b>Q:</b> {question_text}", st_q), Spacer(1, 4)]
2067
+
2068
+ letters = ["A", "B", "C", "D", "E", "F", "G", "H"]
2069
+ for idx, opt in enumerate(options):
2070
+ prefix = letters[idx % len(letters)]
2071
+ is_correct = correct_index is not None and idx == correct_index
2072
+
2073
+ opt_color = t_theme.green if is_correct else t_theme.text
2074
+ opt_font = t_theme.heading_font if is_correct else t_theme.body_font
2075
+
2076
+ st_opt = ParagraphStyle(
2077
+ _uid(),
2078
+ fontSize=9.5,
2079
+ textColor=t_theme.rl(opt_color),
2080
+ fontName=opt_font,
2081
+ leading=15,
2082
+ leftIndent=15,
2083
+ )
2084
+ if is_correct:
2085
+ story_items.append(Paragraph(f"<b>({prefix}) {opt} [Correct]</b>", st_opt))
2086
+ else:
2087
+ story_items.append(Paragraph(f"({prefix}) {opt}", st_opt))
2088
+
2089
+ container = Table([[item] for item in story_items], colWidths=["100%"])
2090
+ container.setStyle(
2091
+ TableStyle(
2092
+ [
2093
+ ("BACKGROUND", (0, 0), (-1, -1), t_theme.rl(t_theme.surface_alt)),
2094
+ ("LINEBEFORE", (0, 0), (0, -1), 3.0, t_theme.rl(t_theme.accent2)),
2095
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
2096
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
2097
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
2098
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
2099
+ ]
2100
+ )
2101
+ )
2102
+ setattr(container, "_is_mcq", True)
2103
+ add(container)
2104
+ sp(6)
2105
+
2106
+
2107
+ def revision_card(title: str, points: list[str]) -> None:
2108
+ """Create a structured revision card with a title and bullet points."""
2109
+ t_theme = get_theme()
2110
+ from .document import CW
2111
+ from reportlab.platypus import ListFlowable, ListItem
2112
+
2113
+ title_style = ParagraphStyle(
2114
+ _uid(),
2115
+ fontSize=11,
2116
+ textColor=t_theme.rl(t_theme.accent),
2117
+ fontName=t_theme.heading_font,
2118
+ leading=16,
2119
+ )
2120
+ point_style = ParagraphStyle(
2121
+ _uid(),
2122
+ fontSize=t_theme.size_body,
2123
+ textColor=t_theme.rl(t_theme.text),
2124
+ fontName=t_theme.body_font,
2125
+ leading=t_theme.size_body * 1.5,
2126
+ )
2127
+
2128
+ content = []
2129
+
2130
+ p_title = Paragraph(f"<b>{title}</b>", title_style)
2131
+ content.append(p_title)
2132
+ content.append(Spacer(1, 6))
2133
+
2134
+ list_items = []
2135
+ for pt in points:
2136
+ list_items.append(ListItem(Paragraph(pt, point_style)))
2137
+
2138
+ lf = ListFlowable(
2139
+ list_items,
2140
+ bulletType="bullet",
2141
+ start="circle",
2142
+ bulletColor=t_theme.rl(t_theme.cyan),
2143
+ bulletFontName="Helvetica-Bold",
2144
+ leftIndent=15,
2145
+ )
2146
+ content.append(lf)
2147
+
2148
+ container = Table(
2149
+ [[content]],
2150
+ colWidths=[CW],
2151
+ style=[
2152
+ ("BACKGROUND", (0, 0), (-1, -1), t_theme.rl(t_theme.card_mid)),
2153
+ ("BOX", (0, 0), (-1, -1), 1.5, t_theme.rl(t_theme.cyan)),
2154
+ ("TOPPADDING", (0, 0), (-1, -1), 12),
2155
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 12),
2156
+ ("LEFTPADDING", (0, 0), (-1, -1), 16),
2157
+ ("RIGHTPADDING", (0, 0), (-1, -1), 16),
2158
+ ],
2159
+ )
2160
+ container.keepWithNext = False
2161
+ setattr(container, "_is_revision_card", True)
2162
+ add(container)
2163
+ sp(6)
2164
+
2165
+
2166
+ def flashcard(question: str, answer: str) -> None:
2167
+ """Register a study flashcard, appending it to the global list and drawing it as an index card."""
2168
+ global _flashcards
2169
+ _flashcards.append((question, answer))
2170
+
2171
+ t_theme = get_theme()
2172
+
2173
+ st = ParagraphStyle(
2174
+ _uid(),
2175
+ fontSize=9.5,
2176
+ textColor=t_theme.rl(t_theme.text),
2177
+ fontName=t_theme.body_font,
2178
+ leading=15,
2179
+ )
2180
+
2181
+ # Visual double-bordered physical index-card design
2182
+ p = Paragraph(
2183
+ f"<b>FLASHCARD</b><br/><b>Q:</b> {question}<br/><b>A:</b> {answer}", st
2184
+ )
2185
+
2186
+ border_color = t_theme.rl(t_theme.accent)
2187
+ bg_color = t_theme.rl(t_theme.surface_alt)
2188
+
2189
+ inner_table = Table([[p]], colWidths=["100%"])
2190
+ inner_table.setStyle(
2191
+ TableStyle(
2192
+ [
2193
+ ("BACKGROUND", (0, 0), (-1, -1), bg_color),
2194
+ ("BOX", (0, 0), (-1, -1), 1.0, border_color),
2195
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
2196
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
2197
+ ("LEFTPADDING", (0, 0), (-1, -1), 10),
2198
+ ("RIGHTPADDING", (0, 0), (-1, -1), 10),
2199
+ ]
2200
+ )
2201
+ )
2202
+
2203
+ outer_table = Table([[inner_table]], colWidths=["100%"])
2204
+ outer_table.setStyle(
2205
+ TableStyle(
2206
+ [
2207
+ ("BOX", (0, 0), (-1, -1), 1.0, border_color),
2208
+ ("TOPPADDING", (0, 0), (-1, -1), 2),
2209
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 2),
2210
+ ("LEFTPADDING", (0, 0), (-1, -1), 2),
2211
+ ("RIGHTPADDING", (0, 0), (-1, -1), 2),
2212
+ ]
2213
+ )
2214
+ )
2215
+ setattr(outer_table, "_is_flashcard", True)
2216
+ add(outer_table)
2217
+ sp(6)
2218
+
2219
+
2220
+ def bullet(items: list[str]) -> None:
2221
+ """Bulleted list of items, bullet matches active theme's accent color."""
2222
+ t_theme = get_theme()
2223
+ st = ParagraphStyle(
2224
+ _uid(),
2225
+ fontSize=10,
2226
+ textColor=t_theme.rl(t_theme.text),
2227
+ fontName="Helvetica",
2228
+ leading=16,
2229
+ leftIndent=16,
2230
+ spaceAfter=3,
2231
+ )
2232
+ accent_color = t_theme.accent
2233
+ for item in items:
2234
+ add(Paragraph(f'<font color="{accent_color}">&#8226;</font> {item}', st))
2235
+ sp(4)
2236
+
2237
+
2238
+ def code_block(text: str, lang: str | None = None, theme: str | None = None) -> None:
2239
+ """Monospace code / algorithm block with optional syntax highlighting and proper padding."""
2240
+ t_theme = get_theme()
2241
+
2242
+ pygments_style = None
2243
+ if theme:
2244
+ try:
2245
+ from pygments.styles import get_style_by_name
2246
+
2247
+ pygments_style = get_style_by_name(theme)
2248
+ except Exception:
2249
+ pass
2250
+
2251
+ # Resolve text color and background color
2252
+ from pygments.token import Token
2253
+
2254
+ if pygments_style:
2255
+ theme_text_color_hex = pygments_style.style_for_token(Token.Text).get(
2256
+ "color"
2257
+ ) or pygments_style.style_for_token(Token).get("color")
2258
+ text_color = t_theme.rl(
2259
+ f"#{theme_text_color_hex}" if theme_text_color_hex else t_theme.text
2260
+ )
2261
+ bg_color = t_theme.rl(pygments_style.background_color)
2262
+ else:
2263
+ text_color = t_theme.rl(t_theme.text)
2264
+ bg_color = t_theme.rl(t_theme.code_bg)
2265
+
2266
+ st = ParagraphStyle(
2267
+ _uid(),
2268
+ fontSize=8,
2269
+ textColor=text_color,
2270
+ fontName="Courier",
2271
+ leading=12,
2272
+ )
2273
+
2274
+ formatted_text = text.strip()
2275
+ lines: list[str] = []
2276
+
2277
+ if lang:
2278
+ try:
2279
+ import xml.sax.saxutils as saxutils
2280
+ from pygments import lex
2281
+ from pygments.lexers import get_lexer_by_name
2282
+
2283
+ lexer = get_lexer_by_name(lang)
2284
+ tokens = lex(formatted_text, lexer)
2285
+
2286
+ line_parts: list[list[str]] = [[]]
2287
+ bracket_stack: list[str] = []
2288
+ rainbow_colors = [
2289
+ t_theme.accent,
2290
+ t_theme.yellow,
2291
+ t_theme.accent2,
2292
+ t_theme.green,
2293
+ ]
2294
+ for ttype, tval in tokens:
2295
+ tval_lines = tval.split("\n")
2296
+ for idx, tval_line in enumerate(tval_lines):
2297
+ if idx > 0:
2298
+ # Only inherit leading space indentation if splitting inside a non-whitespace/text token
2299
+ from pygments.token import Token
2300
+
2301
+ num_leading = 0
2302
+ if ttype not in Token.Text:
2303
+ import re
2304
+
2305
+ current_line_text = "".join(line_parts[-1])
2306
+ plain = re.sub(r"<[^>]+>", "", current_line_text)
2307
+ num_leading = len(plain) - len(plain.lstrip(" "))
2308
+
2309
+ line_parts.append([])
2310
+ if num_leading > 0 and not tval_line.startswith(" "):
2311
+ line_parts[-1].append(" " * num_leading)
2312
+
2313
+ if not tval_line:
2314
+ continue
2315
+
2316
+ escaped_val = saxutils.escape(tval_line)
2317
+ color = None
2318
+ bold = False
2319
+ italic = False
2320
+
2321
+ if pygments_style:
2322
+ # Pygments style mode
2323
+ if tval_line in ("(", "{", "["):
2324
+ bracket_stack.append(tval_line)
2325
+ elif tval_line in (")", "}", "]"):
2326
+ if bracket_stack:
2327
+ bracket_stack.pop()
2328
+
2329
+ tok_style = pygments_style.style_for_token(ttype)
2330
+ color_hex = tok_style.get("color")
2331
+ if color_hex:
2332
+ color = f"#{color_hex}"
2333
+ bold = tok_style.get("bold")
2334
+ italic = tok_style.get("italic")
2335
+ else:
2336
+ # Original theme-based highlighting mode
2337
+ if tval_line in ("(", "{", "["):
2338
+ depth = len(bracket_stack)
2339
+ color = rainbow_colors[depth % len(rainbow_colors)]
2340
+ bracket_stack.append(tval_line)
2341
+ bold = True
2342
+ elif tval_line in (")", "}", "]"):
2343
+ matching = {"}": "{", ")": "(", "]": "["}
2344
+ if bracket_stack and bracket_stack[-1] == matching.get(
2345
+ tval_line
2346
+ ):
2347
+ bracket_stack.pop()
2348
+ elif bracket_stack:
2349
+ bracket_stack.pop()
2350
+ depth = len(bracket_stack)
2351
+ color = rainbow_colors[depth % len(rainbow_colors)]
2352
+ bold = True
2353
+ elif ttype in Token.Comment:
2354
+ color = t_theme.text_dim
2355
+ italic = True
2356
+ elif ttype in Token.Name and (
2357
+ tval_line.endswith("Exception")
2358
+ or tval_line in ("Throwable", "Error")
2359
+ ):
2360
+ color = t_theme.red
2361
+ bold = True
2362
+ elif ttype in Token.Name.Class or ttype in Token.Name.Namespace:
2363
+ color = t_theme.accent2
2364
+ bold = True
2365
+ elif ttype in Token.Keyword.Type:
2366
+ color = t_theme.accent
2367
+ bold = True
2368
+ elif ttype in Token.Keyword.Constant:
2369
+ color = t_theme.yellow
2370
+ bold = True
2371
+ elif ttype in Token.Keyword:
2372
+ color = t_theme.accent
2373
+ bold = True
2374
+ elif ttype in Token.Literal.String:
2375
+ color = t_theme.green
2376
+ elif ttype in Token.Literal.Number:
2377
+ color = t_theme.yellow
2378
+ elif ttype in Token.Name.Decorator:
2379
+ color = t_theme.yellow
2380
+ bold = True
2381
+ elif ttype in Token.Name.Function:
2382
+ color = t_theme.accent2
2383
+ elif ttype in Token.Name.Builtin:
2384
+ color = t_theme.accent
2385
+ elif ttype in Token.Name.Attribute:
2386
+ color = t_theme.accent
2387
+ elif ttype in Token.Operator:
2388
+ color = t_theme.accent
2389
+ elif ttype in Token.Punctuation:
2390
+ color = t_theme.text_dim
2391
+ elif ttype in Token.Name:
2392
+ # Treat class/type names dynamically based on casing
2393
+ if tval_line[0].isupper() if tval_line else False:
2394
+ if tval_line.isupper() and len(tval_line) > 1:
2395
+ # Constants like PI, MAX_PRIORITY
2396
+ color = t_theme.yellow
2397
+ else:
2398
+ # Class/Type names like BankAccount, String, System, Object
2399
+ color = t_theme.accent2
2400
+ bold = True
2401
+
2402
+ styled_part = escaped_val
2403
+ if color:
2404
+ styled_part = f'<font color="{color}">{styled_part}</font>'
2405
+ if bold:
2406
+ styled_part = f"<b>{styled_part}</b>"
2407
+ if italic:
2408
+ styled_part = f"<i>{styled_part}</i>"
2409
+ line_parts[-1].append(styled_part)
2410
+
2411
+ lines = ["".join(parts) for parts in line_parts]
2412
+ except Exception:
2413
+ # Fallback if pygments lexer lookup or lexing fails
2414
+ import xml.sax.saxutils as saxutils
2415
+
2416
+ lines = [saxutils.escape(line) for line in formatted_text.splitlines()]
2417
+ else:
2418
+ import xml.sax.saxutils as saxutils
2419
+
2420
+ lines = [saxutils.escape(line) for line in formatted_text.splitlines()]
2421
+
2422
+ # Make sure we have at least one line
2423
+ if not lines:
2424
+ lines = [""]
2425
+
2426
+ from reportlab.pdfbase.pdfmetrics import stringWidth
2427
+
2428
+ char_w = stringWidth(" ", "Courier", 8)
2429
+
2430
+ import re
2431
+
2432
+ def get_leading_spaces(html_str: str) -> int:
2433
+ plain = re.sub(r"<[^>]+>", "", html_str)
2434
+ return len(plain) - len(plain.lstrip(" "))
2435
+
2436
+ def strip_leading_spaces(html_str: str) -> str:
2437
+ def repl(match):
2438
+ return match.group(1) or ""
2439
+
2440
+ return re.sub(r"^((?:<[^>]+>)*)( +)", repl, html_str)
2441
+
2442
+ def preserve_consecutive_spaces(html_str: str) -> str:
2443
+ parts = re.split(r"(<[^>]+>)", html_str)
2444
+ for i in range(len(parts)):
2445
+ if i % 2 == 0:
2446
+ parts[i] = re.sub(
2447
+ r" +", lambda m: "&nbsp;" * len(m.group(0)), parts[i]
2448
+ )
2449
+ return "".join(parts)
2450
+
2451
+ data = []
2452
+ for line in lines:
2453
+ num_spaces = get_leading_spaces(line)
2454
+ stripped = strip_leading_spaces(line)
2455
+ formatted = preserve_consecutive_spaces(stripped)
2456
+
2457
+ # Create a custom style per line to align wrapped lines with their parent indentation
2458
+ line_style = ParagraphStyle(
2459
+ _uid(),
2460
+ parent=st,
2461
+ leftIndent=(num_spaces + 4) * char_w,
2462
+ firstLineIndent=-4 * char_w,
2463
+ )
2464
+ data.append([Paragraph(formatted, line_style)])
2465
+
2466
+ from .document import CW
2467
+
2468
+ t = CodeBlockTable(data, colWidths=[CW], repeatRows=0)
2469
+
2470
+ style: list[tuple[Any, ...]] = [
2471
+ ("LINEBEFORE", (0, 0), (0, -1), 1.0, t_theme.rl(t_theme.accent)),
2472
+ ("LINEAFTER", (0, 0), (0, -1), 1.0, t_theme.rl(t_theme.accent)),
2473
+ ("TOPPADDING", (0, 0), (-1, -1), 0.5),
2474
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 0.5),
2475
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
2476
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
2477
+ ]
2478
+
2479
+ # Specific padding for the top and bottom of the entire code block
2480
+ style.append(("TOPPADDING", (0, 0), (-1, 0), 8))
2481
+ style.append(("BOTTOMPADDING", (0, -1), (-1, -1), 8))
2482
+
2483
+ # Top border on the first row, bottom border on the last row
2484
+ style.append(("LINEABOVE", (0, 0), (-1, 0), 1.0, t_theme.rl(t_theme.accent)))
2485
+ style.append(("LINEBELOW", (0, -1), (-1, -1), 1.0, t_theme.rl(t_theme.accent)))
2486
+
2487
+ # Set background color for each row
2488
+ for i in range(len(lines)):
2489
+ style.append(("BACKGROUND", (0, i), (-1, i), bg_color))
2490
+
2491
+ t.setStyle(TableStyle(style))
2492
+
2493
+ sp(4)
2494
+ add(t)
2495
+ sp(6)
2496
+
2497
+
2498
+ def _wrap_long_words(text: str, max_len: int = 24) -> str:
2499
+ if not isinstance(text, str):
2500
+ text = str(text)
2501
+ # Split by HTML tags (e.g. <...>) to preserve formatting tags intact
2502
+ tokens = re.split(r"(<[^>]+>)", text)
2503
+ processed = []
2504
+ for token in tokens:
2505
+ if token.startswith("<") and token.endswith(">"):
2506
+ processed.append(token)
2507
+ else:
2508
+ words = token.split(" ")
2509
+ new_words = []
2510
+ for word in words:
2511
+ if len(word) > max_len:
2512
+ parts = [
2513
+ word[i : i + max_len] for i in range(0, len(word), max_len)
2514
+ ]
2515
+ new_words.append("<font size='0'> </font>".join(parts))
2516
+ else:
2517
+ new_words.append(word)
2518
+ processed.append(" ".join(new_words))
2519
+ return "".join(processed)
2520
+
2521
+
2522
+ _TABLE_FONT_NAME: str | None = None
2523
+
2524
+
2525
+ def _get_table_font() -> str:
2526
+ """Return a font name that renders emoji and wide Unicode on this system."""
2527
+ global _TABLE_FONT_NAME
2528
+ if _TABLE_FONT_NAME is not None:
2529
+ return _TABLE_FONT_NAME
2530
+
2531
+ import os
2532
+
2533
+ from reportlab.pdfbase import pdfmetrics
2534
+ from reportlab.pdfbase.ttfonts import TTFont
2535
+
2536
+ candidates = [
2537
+ ("C:\\Windows\\Fonts\\seguiemj.ttf", "SegoeUIEmoji"),
2538
+ ("C:\\Windows\\Fonts\\seguisym.ttf", "SegoeUISymbol"),
2539
+ ("C:\\Windows\\Fonts\\arialuni.ttf", "ArialUnicodeMS"),
2540
+ ("C:\\Windows\\Fonts\\arial.ttf", "ArialUnicode"),
2541
+ ("C:\\Windows\\Fonts\\segoeui.ttf", "SegoeUI"),
2542
+ ("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "DejaVuSans"),
2543
+ ("/System/Library/Fonts/Helvetica.ttc", "Helvetica"),
2544
+ ]
2545
+
2546
+ for path, name in candidates:
2547
+ if os.path.exists(path):
2548
+ try:
2549
+ pdfmetrics.registerFont(TTFont(name, path))
2550
+ _TABLE_FONT_NAME = name
2551
+ return name
2552
+ except Exception:
2553
+ pass
2554
+
2555
+ _TABLE_FONT_NAME = "Helvetica"
2556
+ return "Helvetica"
2557
+
2558
+
2559
+ def info_table(
2560
+ headers: list[str],
2561
+ rows: list[list[str]],
2562
+ hdr_color: Any = None,
2563
+ col_widths: list[float | str] | None = None,
2564
+ hdr_text_color: Any = None,
2565
+ ) -> None:
2566
+ """
2567
+ Comparison / reference table.
2568
+ """
2569
+ t_theme = get_theme()
2570
+ table_font = _get_table_font()
2571
+
2572
+ # Resolve header background
2573
+ actual_hdr_bg = _to_color(hdr_color, t_theme.table_hdr)
2574
+
2575
+ # Determine contrast header text color
2576
+ if hdr_text_color is not None:
2577
+ hdr_text_color = t_theme.rl(hdr_text_color)
2578
+ else:
2579
+ test_color = hdr_color if hdr_color is not None else t_theme.table_hdr
2580
+ if _is_light_color(test_color):
2581
+ hdr_text_color = t_theme.rl(t_theme.text)
2582
+ else:
2583
+ hdr_text_color = t_theme.rl("#ffffff")
2584
+
2585
+ th = ParagraphStyle(
2586
+ _uid(),
2587
+ fontSize=9,
2588
+ textColor=hdr_text_color,
2589
+ fontName=table_font,
2590
+ alignment=TA_CENTER,
2591
+ leading=14,
2592
+ )
2593
+ td = ParagraphStyle(
2594
+ _uid(),
2595
+ fontSize=9,
2596
+ textColor=t_theme.rl(t_theme.text),
2597
+ fontName=table_font,
2598
+ leading=14,
2599
+ )
2600
+
2601
+ data: list[list[Any]] = [[Paragraph(_wrap_long_words(str(h)), th) for h in headers]]
2602
+ for row in rows:
2603
+ data.append([Paragraph(_wrap_long_words(str(c)), td) for c in row])
2604
+
2605
+ if col_widths is None:
2606
+ equal_pct = f"{100 / len(headers):.2f}%"
2607
+ cw_arg: Any = [equal_pct] * len(headers)
2608
+ else:
2609
+ cw_arg = col_widths
2610
+
2611
+ t = Table(data, colWidths=cw_arg, repeatRows=1)
2612
+ style: list[tuple[Any, ...]] = [
2613
+ ("BACKGROUND", (0, 0), (-1, 0), actual_hdr_bg),
2614
+ ("GRID", (0, 0), (-1, -1), 0.4, t_theme.rl(t_theme.table_bdr)),
2615
+ ("TOPPADDING", (0, 0), (-1, -1), 5),
2616
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
2617
+ ("LEFTPADDING", (0, 0), (-1, -1), 6),
2618
+ ("RIGHTPADDING", (0, 0), (-1, -1), 6),
2619
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
2620
+ ]
2621
+
2622
+ for i in range(1, len(rows) + 1):
2623
+ row_bg = t_theme.surface if i % 2 == 1 else t_theme.surface_alt
2624
+ style.append(("BACKGROUND", (0, i), (-1, i), t_theme.rl(row_bg)))
2625
+
2626
+ t.setStyle(TableStyle(style))
2627
+ add(t)
2628
+ sp(10)
2629
+
2630
+
2631
+ class ThemeSetterFlowable(Flowable): # type: ignore[misc]
2632
+ """Special flowable that updates the canvas theme and draws the page background."""
2633
+
2634
+ def __init__(self, theme: NotesTheme) -> None:
2635
+ super().__init__()
2636
+ self.theme = theme
2637
+ self.width = 0
2638
+ self.height = 0
2639
+
2640
+ def drawOn(
2641
+ self, canvas: Canvas, x: float, y: float, *args: Any, **kwargs: Any
2642
+ ) -> None:
2643
+ # Update canvas theme
2644
+ setattr(canvas, "_current_theme", self.theme)
2645
+
2646
+ # Redraw background with the new theme in absolute page coordinates
2647
+ canvas.saveState()
2648
+ canvas.setFillColor(self.theme.rl(self.theme.bg))
2649
+ from .document import PAGE_W, PAGE_H
2650
+
2651
+ canvas.rect(0, 0, PAGE_W, PAGE_H, stroke=0, fill=1)
2652
+ canvas.restoreState()
2653
+
2654
+
2655
+ def _apply_theme_state(t: NotesTheme) -> None:
2656
+ """Set the active theme state and update layout margins and styles in-place."""
2657
+ from .theme import set_theme as _set_theme
2658
+
2659
+ _set_theme(t)
2660
+
2661
+ # Update dynamic layout margins and CW
2662
+ from . import document
2663
+
2664
+ document.CW = document.PAGE_W - (t.left_margin + t.right_margin)
2665
+
2666
+ # Update the global styles in styles.py in-place
2667
+ from . import styles as s
2668
+
2669
+ s.COVER_H1.textColor = t.rl(t.text)
2670
+ s.COVER_H2.textColor = t.rl(t.accent)
2671
+ s.COVER_SUB.textColor = t.rl(t.text_dim)
2672
+ s.PART_ST.textColor = t.rl(t.text)
2673
+ s.CHAP_ST.textColor = t.rl(t.text)
2674
+ s.SECT_ST.textColor = t.rl(t.accent)
2675
+ s.SUB_ST.textColor = t.rl(t.accent)
2676
+ s.BODY_ST.textColor = t.rl(t.text)
2677
+ s.BULLET_ST.textColor = t.rl(t.text)
2678
+ s.DEF_ST.textColor = t.rl(t.text)
2679
+ s.TIP_ST.textColor = t.rl(t.green)
2680
+ s.NOTE_ST.textColor = t.rl(t.yellow)
2681
+ s.CODE_ST.textColor = t.rl(t.text_code)
2682
+ s.CODE_ST.backColor = t.rl(t.code_bg)
2683
+ s.CODE_ST.borderColor = t.rl(t.accent)
2684
+ s.CAP_ST.textColor = t.rl(t.text_dim)
2685
+
2686
+ # Font name configurations
2687
+ s.COVER_H1.fontName = t.heading_font
2688
+ s.COVER_H2.fontName = t.body_font
2689
+ s.COVER_SUB.fontName = t.body_font
2690
+ s.PART_ST.fontName = t.heading_font
2691
+ s.CHAP_ST.fontName = t.heading_font
2692
+ s.SECT_ST.fontName = t.heading_font
2693
+ s.SUB_ST.fontName = t.heading_font
2694
+ s.BODY_ST.fontName = t.body_font
2695
+ s.BULLET_ST.fontName = t.body_font
2696
+ s.DEF_ST.fontName = t.body_font
2697
+ s.TIP_ST.fontName = t.heading_font
2698
+
2699
+ # Deriving italic fonts
2700
+ italic_font = t.body_font
2701
+ if t.body_font == "Helvetica":
2702
+ italic_font = "Helvetica-Oblique"
2703
+ elif t.body_font == "Times-Roman":
2704
+ italic_font = "Times-Italic"
2705
+ elif t.body_font == "Courier":
2706
+ italic_font = "Courier-Oblique"
2707
+
2708
+ s.NOTE_ST.fontName = italic_font
2709
+ s.CAP_ST.fontName = italic_font
2710
+
2711
+ # Font size configurations
2712
+ s.BODY_ST.fontSize = t.size_body
2713
+ s.BODY_ST.leading = t.size_body * 1.7
2714
+ s.BULLET_ST.fontSize = t.size_body
2715
+ s.BULLET_ST.leading = t.size_body * 1.6
2716
+ s.DEF_ST.fontSize = t.size_body
2717
+ s.DEF_ST.leading = t.size_body * 1.6
2718
+
2719
+
2720
+ def set_theme(t: NotesTheme) -> None:
2721
+ """Set the active theme and add a ThemeSetterFlowable to the story."""
2722
+ _apply_theme_state(t)
2723
+ add(ThemeSetterFlowable(t))
2724
+
2725
+
2726
+ class FlexibleGridTOC(TableOfContents): # type: ignore[misc]
2727
+ def __init__(
2728
+ self,
2729
+ *,
2730
+ cols: list[str] | None = None,
2731
+ headers: list[str] | None = None,
2732
+ col_widths: list[Any] | None = None,
2733
+ defaults: dict[str, str] | None = None,
2734
+ **kwargs: Any,
2735
+ ) -> None:
2736
+ self.hdr_text_color = kwargs.pop("hdr_text_color", None)
2737
+ self.hdr_bg_color = kwargs.pop("hdr_bg_color", None)
2738
+ super().__init__(**kwargs)
2739
+ self.cols = cols if cols is not None else ["topic", "page"]
2740
+ self.headers = headers
2741
+ self.col_widths = col_widths
2742
+ self.defaults = defaults or {}
2743
+
2744
+ def beforeBuild(self) -> None:
2745
+ self._table = None
2746
+ super().beforeBuild()
2747
+
2748
+ def wrap(self, aW: float, aH: float) -> tuple[float, float]:
2749
+ if not hasattr(self, "_table") or self._table is None:
2750
+ self._table = self._build_table(aW)
2751
+ assert self._table is not None
2752
+ self.width, self.height = self._table.wrap(aW, aH)
2753
+ return self.width, self.height
2754
+
2755
+ def split(self, aW: float, aH: float) -> list[Flowable]:
2756
+ if not hasattr(self, "_table") or self._table is None:
2757
+ self._table = self._build_table(aW)
2758
+ assert self._table is not None
2759
+ split_tables = self._table.split(aW, aH)
2760
+ result: list[Flowable] = []
2761
+ for t in split_tables:
2762
+ f = FlexibleGridTOC(
2763
+ cols=self.cols,
2764
+ headers=self.headers,
2765
+ col_widths=self.col_widths,
2766
+ defaults=self.defaults,
2767
+ hdr_text_color=self.hdr_text_color,
2768
+ hdr_bg_color=self.hdr_bg_color,
2769
+ )
2770
+ f._table = t
2771
+ setattr(f, "_entries", getattr(self, "_entries", []))
2772
+ setattr(f, "_lastEntries", getattr(self, "_lastEntries", []))
2773
+ result.append(f)
2774
+ return result
2775
+
2776
+ def drawOn(
2777
+ self, canvas: Any, x: float, y: float, *args: Any, **kwargs: Any
2778
+ ) -> None:
2779
+ page_num = canvas.getPageNumber()
2780
+ if not hasattr(canvas, "_page_headers"):
2781
+ setattr(canvas, "_page_headers", {})
2782
+ if not hasattr(canvas, "_page_footers"):
2783
+ setattr(canvas, "_page_footers", {})
2784
+ getattr(canvas, "_page_headers")[page_num] = {"visible": False}
2785
+ getattr(canvas, "_page_footers")[page_num] = {"visible": False}
2786
+ if hasattr(self, "_table") and self._table is not None:
2787
+ self._table.drawOn(canvas, x, y, *args, **kwargs)
2788
+
2789
+ def _build_table(self, aW: float) -> Table:
2790
+ t_theme = get_theme()
2791
+ default_headers = {
2792
+ "qno": "Q.no",
2793
+ "topic": "Question Topic",
2794
+ "co": "CO",
2795
+ "doa": "DOA",
2796
+ "dos": "DOS",
2797
+ "page": "Page no",
2798
+ "signature": "Signature",
2799
+ "sig": "Signature",
2800
+ "marks": "Marks",
2801
+ "qno_topic": "Q.no / Topic",
2802
+ }
2803
+ headers_to_use = []
2804
+ if self.headers:
2805
+ headers_to_use = list(self.headers)
2806
+ else:
2807
+ for c in self.cols:
2808
+ headers_to_use.append(default_headers.get(c, c.capitalize()))
2809
+
2810
+ table_data: list[list[Any]] = [headers_to_use]
2811
+
2812
+ entries = getattr(self, "_lastEntries", [])
2813
+ if not entries:
2814
+ entries = getattr(self, "_entries", [])
2815
+ for level, text, page, key in entries:
2816
+ if level != 0:
2817
+ continue
2818
+ cleaned = text.strip().replace("\n", " ").replace("\r", " ")
2819
+ match = re.match(
2820
+ r"^(Q\d[\d\.\(\)a-e]*)\s*(?:\[([^\]]+)\])?\s*--\s*(.*)$", cleaned
2821
+ )
2822
+ if match:
2823
+ q_no = match.group(1)
2824
+ marks_str = match.group(2) or ""
2825
+ topic = match.group(3)
2826
+ else:
2827
+ match2 = re.match(r"^(Q\d[\d\.\(\)a-e]*)\s*--\s*(.*)$", cleaned)
2828
+ if match2:
2829
+ q_no = match2.group(1)
2830
+ marks_str = ""
2831
+ topic = match2.group(2)
2832
+ else:
2833
+ q_no = ""
2834
+ marks_str = ""
2835
+ topic = cleaned
2836
+ co_match = re.search(r"Q(\d)", q_no)
2837
+ co = f"CO{co_match.group(1)}" if co_match else "CO1"
2838
+
2839
+ row = []
2840
+ for c in self.cols:
2841
+ f_name = t_theme.body_font
2842
+ f_bold = t_theme.heading_font
2843
+ if c == "qno":
2844
+ cell = Paragraph(
2845
+ q_no,
2846
+ ParagraphStyle(
2847
+ _uid(),
2848
+ fontName=f_bold,
2849
+ fontSize=9,
2850
+ textColor=t_theme.rl(t_theme.text),
2851
+ ),
2852
+ )
2853
+ elif c == "topic":
2854
+ cell = Paragraph(
2855
+ topic,
2856
+ ParagraphStyle(
2857
+ _uid(),
2858
+ fontName=f_name,
2859
+ fontSize=9,
2860
+ textColor=t_theme.rl(t_theme.text),
2861
+ ),
2862
+ )
2863
+ elif c == "qno_topic":
2864
+ display_text = f"{q_no} - {topic}" if q_no else topic
2865
+ cell = Paragraph(
2866
+ display_text,
2867
+ ParagraphStyle(
2868
+ _uid(),
2869
+ fontName=f_name,
2870
+ fontSize=9,
2871
+ textColor=t_theme.rl(t_theme.text),
2872
+ ),
2873
+ )
2874
+ elif c == "co":
2875
+ cell = Paragraph(
2876
+ co,
2877
+ ParagraphStyle(
2878
+ _uid(),
2879
+ fontName=f_name,
2880
+ fontSize=9,
2881
+ alignment=1,
2882
+ textColor=t_theme.rl(t_theme.text),
2883
+ ),
2884
+ )
2885
+ elif c == "doa":
2886
+ val = self.defaults.get("doa") or "/ / 2026"
2887
+ cell = Paragraph(
2888
+ val,
2889
+ ParagraphStyle(
2890
+ _uid(),
2891
+ fontName=f_name,
2892
+ fontSize=9,
2893
+ alignment=1,
2894
+ textColor=t_theme.rl(t_theme.text),
2895
+ ),
2896
+ )
2897
+ elif c == "dos":
2898
+ val = self.defaults.get("dos") or "18/06/2026"
2899
+ cell = Paragraph(
2900
+ val,
2901
+ ParagraphStyle(
2902
+ _uid(),
2903
+ fontName=f_name,
2904
+ fontSize=9,
2905
+ alignment=1,
2906
+ textColor=t_theme.rl(t_theme.text),
2907
+ ),
2908
+ )
2909
+ elif c == "page":
2910
+ cell = Paragraph(
2911
+ str(page),
2912
+ ParagraphStyle(
2913
+ _uid(),
2914
+ fontName=f_name,
2915
+ fontSize=9,
2916
+ alignment=1,
2917
+ textColor=t_theme.rl(t_theme.text),
2918
+ ),
2919
+ )
2920
+ elif c in ("signature", "sig"):
2921
+ val = (
2922
+ self.defaults.get("signature") or self.defaults.get("sig") or ""
2923
+ )
2924
+ cell = Paragraph(
2925
+ val,
2926
+ ParagraphStyle(
2927
+ _uid(),
2928
+ fontName=f_name,
2929
+ fontSize=9,
2930
+ alignment=1,
2931
+ textColor=t_theme.rl(t_theme.text_dim),
2932
+ ),
2933
+ )
2934
+ elif c == "marks":
2935
+ val = marks_str if marks_str else "--"
2936
+ cell = Paragraph(
2937
+ val,
2938
+ ParagraphStyle(
2939
+ _uid(),
2940
+ fontName=f_name,
2941
+ fontSize=9,
2942
+ alignment=1,
2943
+ textColor=t_theme.rl(t_theme.text),
2944
+ ),
2945
+ )
2946
+ else:
2947
+ val = self.defaults.get(c) or ""
2948
+ cell = Paragraph(
2949
+ val,
2950
+ ParagraphStyle(
2951
+ _uid(),
2952
+ fontName=f_name,
2953
+ fontSize=9,
2954
+ textColor=t_theme.rl(t_theme.text),
2955
+ ),
2956
+ )
2957
+ row.append(cell)
2958
+ table_data.append(row)
2959
+
2960
+ if len(table_data) == 1:
2961
+ table_data.append(
2962
+ ["(Table will generate here)"] + [""] * (len(self.cols) - 1)
2963
+ )
2964
+
2965
+ widths = []
2966
+ if self.col_widths:
2967
+ for w in self.col_widths:
2968
+ if isinstance(w, str) and w.endswith("%"):
2969
+ widths.append(aW * float(w[:-1]) / 100.0)
2970
+ else:
2971
+ widths.append(float(w))
2972
+ else:
2973
+ widths = [aW / len(self.cols)] * len(self.cols)
2974
+
2975
+ # Determine contrast/custom header background & text color
2976
+ if hasattr(self, "hdr_bg_color") and self.hdr_bg_color is not None:
2977
+ header_bg = t_theme.rl(self.hdr_bg_color)
2978
+ else:
2979
+ header_bg = t_theme.rl(t_theme.accent)
2980
+
2981
+ if hasattr(self, "hdr_text_color") and self.hdr_text_color is not None:
2982
+ header_text_color = t_theme.rl(self.hdr_text_color)
2983
+ else:
2984
+ if _is_light_color(header_bg):
2985
+ header_text_color = t_theme.rl(t_theme.text)
2986
+ else:
2987
+ header_text_color = t_theme.rl("#ffffff")
2988
+
2989
+ table = Table(table_data, colWidths=widths)
2990
+ table.setStyle(
2991
+ TableStyle(
2992
+ [
2993
+ ("BACKGROUND", (0, 0), (-1, -1), t_theme.rl(t_theme.surface_alt)),
2994
+ ("GRID", (0, 0), (-1, -1), 0.5, t_theme.rl(t_theme.border)),
2995
+ ("BACKGROUND", (0, 0), (-1, 0), header_bg),
2996
+ ("TEXTCOLOR", (0, 0), (-1, 0), header_text_color),
2997
+ ("FONTNAME", (0, 0), (-1, 0), t_theme.heading_font),
2998
+ ("FONTSIZE", (0, 0), (-1, 0), 9),
2999
+ ("ALIGN", (0, 0), (-1, 0), "CENTER"),
3000
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
3001
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
3002
+ ("LEFTPADDING", (0, 0), (-1, -1), 6),
3003
+ ("RIGHTPADDING", (0, 0), (-1, -1), 6),
3004
+ ]
3005
+ )
3006
+ )
3007
+ return table
3008
+
3009
+
3010
+ class GridTableOfContents(FlexibleGridTOC): # type: ignore[misc]
3011
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
3012
+ cols = kwargs.pop("cols", None) or ["qno_topic", "page"]
3013
+ col_widths = kwargs.pop("col_widths", None) or ["85%", "15%"]
3014
+ super().__init__(cols=cols, col_widths=col_widths, **kwargs)
3015
+
3016
+
3017
+ class IndexTableOfContents(FlexibleGridTOC): # type: ignore[misc]
3018
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
3019
+ cols = kwargs.pop("cols", None) or [
3020
+ "qno",
3021
+ "topic",
3022
+ "co",
3023
+ "doa",
3024
+ "dos",
3025
+ "page",
3026
+ "signature",
3027
+ ]
3028
+ col_widths = kwargs.pop("col_widths", None) or [
3029
+ "7%", # Q.no
3030
+ "29%", # Topic (increased Topic width)
3031
+ "10%", # CO (increased CO width as requested)
3032
+ "16%", # DOA (decreased to rebalance)
3033
+ "14%", # DOS (decreased to rebalance)
3034
+ "7%", # Page
3035
+ "17%", # Signature (blank – student fills)
3036
+ ]
3037
+ super().__init__(cols=cols, col_widths=col_widths, **kwargs)
3038
+
3039
+
3040
+ def toc(
3041
+ bookmark: bool = True,
3042
+ style: str = "standard",
3043
+ columns: list[str] | None = None,
3044
+ headers: list[str] | None = None,
3045
+ col_widths: list[Any] | None = None,
3046
+ defaults: dict[str, str] | None = None,
3047
+ hdr_text_color: Any = None,
3048
+ hdr_bg_color: Any = None,
3049
+ ) -> None:
3050
+ """
3051
+ Add a Table of Contents page with clickable links and theme-based styling.
3052
+ Supported styles: 'standard', 'minimal', 'detailed', 'grid', 'index', 'flexible_grid'.
3053
+ """
3054
+ start_idx = len(story)
3055
+
3056
+ if bookmark:
3057
+ toc_title = (
3058
+ "INDEX" if style in ("index", "flexible_grid") else "Table of Contents"
3059
+ )
3060
+ add(Bookmark(_get_bookmark_key(), toc_title, level=0))
3061
+ t_theme = get_theme()
3062
+
3063
+ # Resolve fonts from theme
3064
+ italic_font = t_theme.body_font
3065
+ if t_theme.body_font == "Helvetica":
3066
+ italic_font = "Helvetica-Oblique"
3067
+ elif t_theme.body_font == "Times-Roman":
3068
+ italic_font = "Times-Italic"
3069
+ elif t_theme.body_font == "Courier":
3070
+ italic_font = "Courier-Oblique"
3071
+
3072
+ font_head = t_theme.heading_font
3073
+ font_l0 = t_theme.heading_font
3074
+ font_l1 = t_theme.body_font
3075
+ font_l2 = italic_font
3076
+
3077
+ # Set parameters based on style
3078
+ if style == "minimal":
3079
+ size_head = 11
3080
+ size_l0 = 9.5
3081
+ size_l1 = 8.5
3082
+ size_l2 = 7.5
3083
+ indent_l1 = 10
3084
+ indent_l2 = 20
3085
+ space_before_l0 = 5
3086
+ space_after_l0 = 2
3087
+ space_before_l1 = 2
3088
+ space_after_l1 = 1
3089
+ space_before_l2 = 1
3090
+ space_after_l2 = 1
3091
+ elif style == "detailed":
3092
+ size_head = 15
3093
+ size_l0 = 12
3094
+ size_l1 = 10.5
3095
+ size_l2 = 9.5
3096
+ indent_l1 = 20
3097
+ indent_l2 = 40
3098
+ space_before_l0 = 12
3099
+ space_after_l0 = 4
3100
+ space_before_l1 = 5
3101
+ space_after_l1 = 3
3102
+ space_before_l2 = 3
3103
+ space_after_l2 = 2
3104
+ else: # standard / grid / index / flexible_grid
3105
+ size_head = 13
3106
+ size_l0 = 11
3107
+ size_l1 = 9.5
3108
+ size_l2 = 8.5
3109
+ indent_l1 = 15
3110
+ indent_l2 = 30
3111
+ space_before_l0 = 8
3112
+ space_after_l0 = 3
3113
+ space_before_l1 = 3
3114
+ space_after_l1 = 2
3115
+ space_before_l2 = 1
3116
+ space_after_l2 = 1
3117
+
3118
+ toc_title = "INDEX" if style in ("index", "flexible_grid") else "Table of Contents"
3119
+ st_head = ParagraphStyle(
3120
+ _uid(),
3121
+ fontSize=size_head,
3122
+ textColor=t_theme.rl(t_theme.accent),
3123
+ fontName=font_head,
3124
+ spaceBefore=12,
3125
+ spaceAfter=5,
3126
+ leading=size_head * 1.5,
3127
+ )
3128
+ add(Paragraph(toc_title, st_head))
3129
+ rule(t_theme.rl(t_theme.accent), 0.5)
3130
+ sp(8)
3131
+
3132
+ style_l0 = ParagraphStyle(
3133
+ _uid(),
3134
+ fontName=font_l0,
3135
+ fontSize=size_l0,
3136
+ textColor=t_theme.rl(t_theme.text),
3137
+ leading=size_l0 * 1.5,
3138
+ spaceBefore=space_before_l0,
3139
+ spaceAfter=space_after_l0,
3140
+ )
3141
+ style_l1 = ParagraphStyle(
3142
+ _uid(),
3143
+ fontName=font_l1,
3144
+ fontSize=size_l1,
3145
+ textColor=t_theme.rl(t_theme.text),
3146
+ leading=size_l1 * 1.5,
3147
+ leftIndent=indent_l1,
3148
+ spaceBefore=space_before_l1,
3149
+ spaceAfter=space_after_l1,
3150
+ )
3151
+ style_l2 = ParagraphStyle(
3152
+ _uid(),
3153
+ fontName=font_l2,
3154
+ fontSize=size_l2,
3155
+ textColor=t_theme.rl(t_theme.text_dim),
3156
+ leading=size_l2 * 1.5,
3157
+ leftIndent=indent_l2,
3158
+ spaceBefore=space_before_l2,
3159
+ spaceAfter=space_after_l2,
3160
+ )
3161
+
3162
+ from reportlab.platypus.tableofcontents import TableOfContents
3163
+
3164
+ if style == "grid":
3165
+ toc_flowable = GridTableOfContents(
3166
+ cols=columns,
3167
+ col_widths=col_widths,
3168
+ headers=headers,
3169
+ defaults=defaults,
3170
+ hdr_text_color=hdr_text_color,
3171
+ hdr_bg_color=hdr_bg_color,
3172
+ )
3173
+ elif style == "index":
3174
+ toc_flowable = IndexTableOfContents(
3175
+ cols=columns,
3176
+ col_widths=col_widths,
3177
+ headers=headers,
3178
+ defaults=defaults,
3179
+ hdr_text_color=hdr_text_color,
3180
+ hdr_bg_color=hdr_bg_color,
3181
+ )
3182
+ elif style == "flexible_grid":
3183
+ toc_flowable = FlexibleGridTOC(
3184
+ cols=columns,
3185
+ col_widths=col_widths,
3186
+ headers=headers,
3187
+ defaults=defaults,
3188
+ hdr_text_color=hdr_text_color,
3189
+ hdr_bg_color=hdr_bg_color,
3190
+ )
3191
+ else:
3192
+ toc_flowable = TableOfContents()
3193
+ toc_flowable.levelStyles = [style_l0, style_l1, style_l2]
3194
+
3195
+ add(toc_flowable)
3196
+ br()
3197
+
3198
+ for item in story[start_idx:]:
3199
+ setattr(item, "_is_toc_element", True)
3200
+
3201
+ for item in story[start_idx:]:
3202
+ setattr(item, "_is_toc_element", True)
3203
+
3204
+
3205
+ def bookmark(title: str, level: int = 0) -> None:
3206
+ """Manually add an outline bookmark entry for the current page."""
3207
+ add(Bookmark(_get_bookmark_key(), _clean_title(title), level=level))
3208
+
3209
+
3210
+ # Default global header config
3211
+ global_header: dict[str, Any] = {
3212
+ "left": None,
3213
+ "center": None,
3214
+ "right": None,
3215
+ "visible": True,
3216
+ "char_limit": 120,
3217
+ "font_name": None,
3218
+ "font_size": None,
3219
+ "text_color": None,
3220
+ "line_color": None,
3221
+ "line_width": None,
3222
+ "y_offset": None,
3223
+ "line_y_offset": None,
3224
+ }
3225
+
3226
+
3227
+ def set_global_header(
3228
+ left: str | None = None,
3229
+ center: str | None = None,
3230
+ right: str | None = None,
3231
+ visible: bool = True,
3232
+ char_limit: int | None = 120,
3233
+ font_name: str | None = None,
3234
+ font_size: float | None = None,
3235
+ text_color: str | None = None,
3236
+ line_color: str | None = None,
3237
+ line_width: float | None = None,
3238
+ y_offset: float | None = None,
3239
+ line_y_offset: float | None = None,
3240
+ ) -> None:
3241
+ """
3242
+ Set the default header configuration for all pages globally.
3243
+ Individual page headers can override this configuration.
3244
+ """
3245
+ import warnings
3246
+
3247
+ if char_limit is not None and char_limit > 0:
3248
+ if left and len(left) > char_limit:
3249
+ warnings.warn(f"Header left text truncated to {char_limit} characters.")
3250
+ left = left[: char_limit - 3] + "..."
3251
+ if center and len(center) > char_limit:
3252
+ warnings.warn(f"Header center text truncated to {char_limit} characters.")
3253
+ center = center[: char_limit - 3] + "..."
3254
+ if right and len(right) > char_limit:
3255
+ warnings.warn(f"Header right text truncated to {char_limit} characters.")
3256
+ right = right[: char_limit - 3] + "..."
3257
+
3258
+ global global_header
3259
+ global_header = {
3260
+ "left": left,
3261
+ "center": center,
3262
+ "right": right,
3263
+ "visible": visible,
3264
+ "char_limit": char_limit,
3265
+ "font_name": font_name,
3266
+ "font_size": font_size,
3267
+ "text_color": text_color,
3268
+ "line_color": line_color,
3269
+ "line_width": line_width,
3270
+ "y_offset": y_offset,
3271
+ "line_y_offset": line_y_offset,
3272
+ }
3273
+
3274
+
3275
+ # Default global footer config
3276
+ global_footer: dict[str, Any] = {
3277
+ "left": None,
3278
+ "center": None,
3279
+ "right": None,
3280
+ "show_page_num": True,
3281
+ "visible": True,
3282
+ "char_limit": 120,
3283
+ "font_name": None,
3284
+ "font_size": None,
3285
+ "text_color": None,
3286
+ "y_offset": None,
3287
+ }
3288
+
3289
+
3290
+ def set_global_footer(
3291
+ left: str | None = None,
3292
+ center: str | None = None,
3293
+ right: str | None = None,
3294
+ show_page_num: bool = True,
3295
+ visible: bool = True,
3296
+ char_limit: int | None = 120,
3297
+ font_name: str | None = None,
3298
+ font_size: float | None = None,
3299
+ text_color: str | None = None,
3300
+ y_offset: float | None = None,
3301
+ ) -> None:
3302
+ """
3303
+ Set the default footer configuration for all pages globally.
3304
+ Individual page footers can override this configuration.
3305
+ """
3306
+ import warnings
3307
+
3308
+ if char_limit is not None and char_limit > 0:
3309
+ if left and len(left) > char_limit:
3310
+ warnings.warn(f"Footer left text truncated to {char_limit} characters.")
3311
+ left = left[: char_limit - 3] + "..."
3312
+ if center and len(center) > char_limit:
3313
+ warnings.warn(f"Footer center text truncated to {char_limit} characters.")
3314
+ center = center[: char_limit - 3] + "..."
3315
+ if right and len(right) > char_limit:
3316
+ warnings.warn(f"Footer right text truncated to {char_limit} characters.")
3317
+ right = right[: char_limit - 3] + "..."
3318
+
3319
+ global global_footer
3320
+ global_footer = {
3321
+ "left": left,
3322
+ "center": center,
3323
+ "right": right,
3324
+ "show_page_num": show_page_num,
3325
+ "visible": visible,
3326
+ "char_limit": char_limit,
3327
+ "font_name": font_name,
3328
+ "font_size": font_size,
3329
+ "text_color": text_color,
3330
+ "y_offset": y_offset,
3331
+ }
3332
+
3333
+
3334
+ class Footer(Flowable): # type: ignore[misc]
3335
+ """
3336
+ Flowable that configures the footer dynamically for the current page
3337
+ (and subsequent pages, unless page_only=True).
3338
+ """
3339
+
3340
+ def __init__(
3341
+ self,
3342
+ left: str | None = None,
3343
+ center: str | None = None,
3344
+ right: str | None = None,
3345
+ show_page_num: bool = True,
3346
+ visible: bool = True,
3347
+ page_only: bool = False,
3348
+ char_limit: int | None = 120,
3349
+ font_name: str | None = None,
3350
+ font_size: float | None = None,
3351
+ text_color: str | None = None,
3352
+ y_offset: float | None = None,
3353
+ ) -> None:
3354
+ super().__init__()
3355
+ self.left = left
3356
+ self.center = center
3357
+ self.right = right
3358
+ self.show_page_num = show_page_num
3359
+ self.visible = visible
3360
+ self.page_only = page_only
3361
+ self.char_limit = char_limit
3362
+ self.font_name = font_name
3363
+ self.font_size = font_size
3364
+ self.text_color = text_color
3365
+ self.y_offset = y_offset
3366
+ self.width = 0
3367
+ self.height = 0
3368
+
3369
+ def draw(self) -> None:
3370
+ limit = self.char_limit
3371
+ left_text = self.left
3372
+ center_text = self.center
3373
+ right_text = self.right
3374
+
3375
+ import warnings
3376
+
3377
+ if limit is not None and limit > 0:
3378
+ if left_text and len(left_text) > limit:
3379
+ warnings.warn(f"Footer left text truncated to {limit} characters.")
3380
+ left_text = left_text[: limit - 3] + "..."
3381
+ if center_text and len(center_text) > limit:
3382
+ warnings.warn(f"Footer center text truncated to {limit} characters.")
3383
+ center_text = center_text[: limit - 3] + "..."
3384
+ if right_text and len(right_text) > limit:
3385
+ warnings.warn(f"Footer right text truncated to {limit} characters.")
3386
+ right_text = right_text[: limit - 3] + "..."
3387
+
3388
+ canvas: Any = self.canv
3389
+ page_num = canvas.getPageNumber()
3390
+
3391
+ config = {
3392
+ "left": left_text,
3393
+ "center": center_text,
3394
+ "right": right_text,
3395
+ "show_page_num": self.show_page_num,
3396
+ "visible": self.visible,
3397
+ "page_only": self.page_only,
3398
+ "font_name": self.font_name,
3399
+ "font_size": self.font_size,
3400
+ "text_color": self.text_color,
3401
+ "y_offset": self.y_offset,
3402
+ }
3403
+
3404
+ if not hasattr(canvas, "_page_footers"):
3405
+ canvas._page_footers = {}
3406
+
3407
+ if self.page_only:
3408
+ canvas._page_footers[page_num] = config
3409
+ else:
3410
+ canvas._active_footer = config
3411
+ canvas._page_footers[page_num] = config
3412
+
3413
+
3414
+ def footer(
3415
+ left: str | None = None,
3416
+ center: str | None = None,
3417
+ right: str | None = None,
3418
+ show_page_num: bool = True,
3419
+ visible: bool = True,
3420
+ page_only: bool = False,
3421
+ char_limit: int | None = 120,
3422
+ font_name: str | None = None,
3423
+ font_size: float | None = None,
3424
+ text_color: str | None = None,
3425
+ y_offset: float | None = None,
3426
+ ) -> None:
3427
+ """
3428
+ Add a Footer configuration flowable to the story.
3429
+ Applies from the page it is placed on.
3430
+ """
3431
+ add(
3432
+ Footer(
3433
+ left=left,
3434
+ center=center,
3435
+ right=right,
3436
+ show_page_num=show_page_num,
3437
+ visible=visible,
3438
+ page_only=page_only,
3439
+ char_limit=char_limit,
3440
+ font_name=font_name,
3441
+ font_size=font_size,
3442
+ text_color=text_color,
3443
+ y_offset=y_offset,
3444
+ )
3445
+ )
3446
+
3447
+
3448
+ def suppress_footer(page_only: bool = True) -> None:
3449
+ """
3450
+ Add a flowable to suppress the footer on the current page (default)
3451
+ or subsequent pages.
3452
+ """
3453
+ add(Footer(visible=False, page_only=page_only))
3454
+
3455
+
3456
+ def suppress_header(page_only: bool = True) -> None:
3457
+ """
3458
+ Add a flowable to suppress the header on the current page (default)
3459
+ or subsequent pages.
3460
+ """
3461
+ add(Header(visible=False, page_only=page_only))
3462
+
3463
+
3464
+ class Header(Flowable): # type: ignore[misc]
3465
+ """
3466
+ Flowable that configures the header dynamically for the current page
3467
+ (and subsequent pages, unless page_only=True).
3468
+ """
3469
+
3470
+ def __init__(
3471
+ self,
3472
+ left: str | None = None,
3473
+ center: str | None = None,
3474
+ right: str | None = None,
3475
+ visible: bool = True,
3476
+ page_only: bool = False,
3477
+ char_limit: int | None = 120,
3478
+ font_name: str | None = None,
3479
+ font_size: float | None = None,
3480
+ text_color: str | None = None,
3481
+ line_color: str | None = None,
3482
+ line_width: float | None = None,
3483
+ y_offset: float | None = None,
3484
+ line_y_offset: float | None = None,
3485
+ ) -> None:
3486
+ super().__init__()
3487
+ self.left = left
3488
+ self.center = center
3489
+ self.right = right
3490
+ self.visible = visible
3491
+ self.page_only = page_only
3492
+ self.char_limit = char_limit
3493
+ self.font_name = font_name
3494
+ self.font_size = font_size
3495
+ self.text_color = text_color
3496
+ self.line_color = line_color
3497
+ self.line_width = line_width
3498
+ self.y_offset = y_offset
3499
+ self.line_y_offset = line_y_offset
3500
+ self.width = 0
3501
+ self.height = 0
3502
+
3503
+ def draw(self) -> None:
3504
+ limit = self.char_limit
3505
+ left_text = self.left
3506
+ center_text = self.center
3507
+ right_text = self.right
3508
+
3509
+ import warnings
3510
+
3511
+ if limit is not None and limit > 0:
3512
+ if left_text and len(left_text) > limit:
3513
+ warnings.warn(f"Header left text truncated to {limit} characters.")
3514
+ left_text = left_text[: limit - 3] + "..."
3515
+ if center_text and len(center_text) > limit:
3516
+ warnings.warn(f"Header center text truncated to {limit} characters.")
3517
+ center_text = center_text[: limit - 3] + "..."
3518
+ if right_text and len(right_text) > limit:
3519
+ warnings.warn(f"Header right text truncated to {limit} characters.")
3520
+ right_text = right_text[: limit - 3] + "..."
3521
+
3522
+ canvas: Any = self.canv
3523
+ page_num = canvas.getPageNumber()
3524
+
3525
+ config = {
3526
+ "left": left_text,
3527
+ "center": center_text,
3528
+ "right": right_text,
3529
+ "visible": self.visible,
3530
+ "page_only": self.page_only,
3531
+ "font_name": self.font_name,
3532
+ "font_size": self.font_size,
3533
+ "text_color": self.text_color,
3534
+ "line_color": self.line_color,
3535
+ "line_width": self.line_width,
3536
+ "y_offset": self.y_offset,
3537
+ "line_y_offset": self.line_y_offset,
3538
+ }
3539
+
3540
+ if not hasattr(canvas, "_page_headers"):
3541
+ canvas._page_headers = {}
3542
+
3543
+ if self.page_only:
3544
+ canvas._page_headers[page_num] = config
3545
+ else:
3546
+ canvas._active_header = config
3547
+ canvas._page_headers[page_num] = config
3548
+
3549
+
3550
+ def header(
3551
+ left: str | None = None,
3552
+ center: str | None = None,
3553
+ right: str | None = None,
3554
+ visible: bool = True,
3555
+ page_only: bool = False,
3556
+ char_limit: int | None = 120,
3557
+ font_name: str | None = None,
3558
+ font_size: float | None = None,
3559
+ text_color: str | None = None,
3560
+ line_color: str | None = None,
3561
+ line_width: float | None = None,
3562
+ y_offset: float | None = None,
3563
+ line_y_offset: float | None = None,
3564
+ ) -> None:
3565
+ """
3566
+ Add a Header configuration flowable to the story.
3567
+ Applies from the page it is placed on.
3568
+ """
3569
+ add(
3570
+ Header(
3571
+ left=left,
3572
+ center=center,
3573
+ right=right,
3574
+ visible=visible,
3575
+ page_only=page_only,
3576
+ char_limit=char_limit,
3577
+ font_name=font_name,
3578
+ font_size=font_size,
3579
+ text_color=text_color,
3580
+ line_color=line_color,
3581
+ line_width=line_width,
3582
+ y_offset=y_offset,
3583
+ line_y_offset=line_y_offset,
3584
+ )
3585
+ )
3586
+
3587
+
3588
+ class PageBorder(Flowable): # type: ignore[misc]
3589
+ def __init__(
3590
+ self,
3591
+ enabled: bool,
3592
+ margin: float | None = None,
3593
+ gap: float | None = None,
3594
+ color: str | None = None,
3595
+ page_only: bool = False,
3596
+ ) -> None:
3597
+ super().__init__()
3598
+ self.enabled = enabled
3599
+ self.margin = margin
3600
+ self.gap = gap
3601
+ self.color = color
3602
+ self.page_only = page_only
3603
+ self.width = 0
3604
+ self.height = 0
3605
+
3606
+ def draw(self) -> None:
3607
+ canvas: Any = self.canv
3608
+ page_num = canvas.getPageNumber()
3609
+ config = {
3610
+ "enabled": self.enabled,
3611
+ "margin": self.margin,
3612
+ "gap": self.gap,
3613
+ "color": self.color,
3614
+ "page_only": self.page_only,
3615
+ }
3616
+ if not hasattr(canvas, "_page_double_borders"):
3617
+ canvas._page_double_borders = {}
3618
+ if self.page_only:
3619
+ canvas._page_double_borders[page_num] = config
3620
+ else:
3621
+ canvas._active_double_border = config
3622
+ canvas._page_double_borders[page_num] = config
3623
+
3624
+
3625
+ def page_border(
3626
+ enabled: bool,
3627
+ margin: float | None = None,
3628
+ gap: float | None = None,
3629
+ color: str | None = None,
3630
+ page_only: bool = False,
3631
+ ) -> None:
3632
+ """Configure double page border dynamically starting from the current page."""
3633
+ add(
3634
+ PageBorder(
3635
+ enabled=enabled,
3636
+ margin=margin,
3637
+ gap=gap,
3638
+ color=color,
3639
+ page_only=page_only,
3640
+ )
3641
+ )
3642
+
3643
+
3644
+ class PageNumbering(Flowable): # type: ignore[misc]
3645
+ def __init__(
3646
+ self,
3647
+ style: str = "arabic",
3648
+ reset_to: int | None = None,
3649
+ page_only: bool = False,
3650
+ ) -> None:
3651
+ super().__init__()
3652
+ self.style = style
3653
+ self.reset_to = reset_to
3654
+ self.page_only = page_only
3655
+ self.width = 0
3656
+ self.height = 0
3657
+
3658
+ def draw(self) -> None:
3659
+ canvas: Any = self.canv
3660
+ page_num = canvas.getPageNumber()
3661
+ if not hasattr(canvas, "_page_number_formats"):
3662
+ canvas._page_number_formats = {}
3663
+ if not hasattr(canvas, "_page_number_resets"):
3664
+ canvas._page_number_resets = {}
3665
+
3666
+ canvas._page_number_formats[page_num] = self.style
3667
+ if self.reset_to is not None:
3668
+ canvas._page_number_resets[page_num] = self.reset_to
3669
+
3670
+
3671
+ def page_numbering(
3672
+ style: str = "arabic",
3673
+ reset_to: int | None = None,
3674
+ ) -> None:
3675
+ """Configure page numbering style (arabic, roman, none) and/or reset page counter."""
3676
+ add(PageNumbering(style=style, reset_to=reset_to))
3677
+
3678
+
3679
+ _labels: dict[str, int] = {}
3680
+ _index_entries: dict[int, list[str]] = {}
3681
+ _flashcards: list[tuple[str, str]] = []
3682
+ _requires_multibuild = False
3683
+ _footnote_counter = 0
3684
+
3685
+
3686
+ class FootnoteRegisterFlowable(Flowable): # type: ignore[misc]
3687
+ """Invisible flowable that registers a footnote on the page it is drawn."""
3688
+
3689
+ def __init__(self, num: int, text: str) -> None:
3690
+ super().__init__()
3691
+ self.num = num
3692
+ self.text = text
3693
+ self.width = 0
3694
+ self.height = 0
3695
+
3696
+ def draw(self) -> None:
3697
+ canvas = self.canv
3698
+ page_num = canvas.getPageNumber()
3699
+ page_footnotes = getattr(canvas, "_page_footnotes", None)
3700
+ if page_footnotes is None:
3701
+ page_footnotes = {}
3702
+ setattr(canvas, "_page_footnotes", page_footnotes)
3703
+ if page_num not in page_footnotes:
3704
+ page_footnotes[page_num] = []
3705
+ # Prevent duplicate footnotes during multi-pass rendering
3706
+ existing = page_footnotes[page_num]
3707
+ if not any(item[0] == self.num for item in existing):
3708
+ existing.append((self.num, self.text))
3709
+
3710
+
3711
+ class LabelFlowable(Flowable): # type: ignore[misc]
3712
+ """Invisible flowable that registers a label on the page it is drawn."""
3713
+
3714
+ def __init__(self, ref_id: str) -> None:
3715
+ super().__init__()
3716
+ self.ref_id = ref_id
3717
+ self.width = 0
3718
+ self.height = 0
3719
+
3720
+ def draw(self) -> None:
3721
+ page_num = self.canv.getPageNumber()
3722
+ _labels[self.ref_id] = page_num
3723
+
3724
+
3725
+ class IndexEntryFlowable(Flowable): # type: ignore[misc]
3726
+ """Invisible flowable that tags a keyword for the index on the page it is drawn."""
3727
+
3728
+ def __init__(self, keyword: str) -> None:
3729
+ super().__init__()
3730
+ self.keyword = keyword
3731
+ self.width = 0
3732
+ self.height = 0
3733
+
3734
+ def draw(self) -> None:
3735
+ page_num = self.canv.getPageNumber()
3736
+ if page_num not in _index_entries:
3737
+ _index_entries[page_num] = []
3738
+ if self.keyword not in _index_entries[page_num]:
3739
+ _index_entries[page_num].append(self.keyword)
3740
+
3741
+
3742
+ class IndexPrinterFlowable(Flowable): # type: ignore[misc]
3743
+ """Flowable that dynamically compiles and renders the index table."""
3744
+
3745
+ def __init__(self) -> None:
3746
+ super().__init__()
3747
+ self.width = 0
3748
+ self.height = 0
3749
+ self._table = None
3750
+
3751
+ def wrap(self, aW: float, aH: float) -> tuple[float, float]:
3752
+ # Group pages by keyword
3753
+ keyword_map: dict[str, list[int]] = {}
3754
+ for page, keywords in _index_entries.items():
3755
+ for kw in keywords:
3756
+ if kw not in keyword_map:
3757
+ keyword_map[kw] = []
3758
+ if page not in keyword_map[kw]:
3759
+ keyword_map[kw].append(page)
3760
+
3761
+ # Build rows
3762
+ sorted_kws = sorted(keyword_map.keys())
3763
+ rows = []
3764
+ for kw in sorted_kws:
3765
+ pages_str = ", ".join(map(str, sorted(keyword_map[kw])))
3766
+ rows.append([kw, pages_str])
3767
+
3768
+ if not rows:
3769
+ rows = [["No index entries", ""]]
3770
+
3771
+ # Build a Table flowable to delegate layout
3772
+ from reportlab.platypus import Table, TableStyle
3773
+ from reportlab.lib.styles import ParagraphStyle
3774
+ from reportlab.platypus import Paragraph
3775
+
3776
+ t_theme = get_theme()
3777
+ td_key = ParagraphStyle(
3778
+ _uid(),
3779
+ fontName=t_theme.body_font,
3780
+ fontSize=9,
3781
+ textColor=t_theme.rl(t_theme.text),
3782
+ )
3783
+ td_val = ParagraphStyle(
3784
+ _uid(),
3785
+ fontName=t_theme.body_font,
3786
+ fontSize=9,
3787
+ textColor=t_theme.rl(t_theme.accent),
3788
+ )
3789
+
3790
+ table_data = []
3791
+ for kw, pg in rows:
3792
+ table_data.append([Paragraph(kw, td_key), Paragraph(pg, td_val)])
3793
+
3794
+ table = Table(table_data, colWidths=[aW * 0.7, aW * 0.3])
3795
+ table.setStyle(
3796
+ TableStyle(
3797
+ [
3798
+ (
3799
+ "LINEBELOW",
3800
+ (0, 0),
3801
+ (-1, -1),
3802
+ 0.5,
3803
+ t_theme.rl(t_theme.surface_alt),
3804
+ ),
3805
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
3806
+ ("TOPPADDING", (0, 0), (-1, -1), 4),
3807
+ ]
3808
+ )
3809
+ )
3810
+
3811
+ self._table = table
3812
+ self.width, self.height = table.wrap(aW, aH)
3813
+ return self.width, self.height
3814
+
3815
+ def split(self, aW: float, aH: float) -> list[Flowable]:
3816
+ if not self._table:
3817
+ self.wrap(aW, aH)
3818
+ if self._table:
3819
+ return self._table.split(aW, aH)
3820
+ return []
3821
+
3822
+ def draw(self) -> None:
3823
+ if self._table:
3824
+ self._table.drawOn(self.canv, 0, 0)
3825
+
3826
+
3827
+ def footnote(text: str) -> str:
3828
+ """Register a footnote and return its superscript inline tag."""
3829
+ global _footnote_counter
3830
+ _footnote_counter += 1
3831
+ add(FootnoteRegisterFlowable(_footnote_counter, text))
3832
+ return f"<sup>{_footnote_counter}</sup>"
3833
+
3834
+
3835
+ def label(ref_id: str) -> str:
3836
+ """Register a cross-reference label and return an empty string."""
3837
+ global _requires_multibuild
3838
+ _requires_multibuild = True
3839
+ add(LabelFlowable(ref_id))
3840
+ return ""
3841
+
3842
+
3843
+ def ref(ref_id: str) -> str:
3844
+ """Return a placeholder string for a cross-reference page number."""
3845
+ global _requires_multibuild
3846
+ _requires_multibuild = True
3847
+ return f"__REF_{ref_id}__"
3848
+
3849
+
3850
+ def index_entry(keyword: str) -> str:
3851
+ """Tag a keyword for the index and return an empty string."""
3852
+ global _requires_multibuild
3853
+ _requires_multibuild = True
3854
+ add(IndexEntryFlowable(keyword))
3855
+ return ""
3856
+
3857
+
3858
+ def print_index() -> None:
3859
+ """Print the sorted index table flowable."""
3860
+ global _requires_multibuild
3861
+ _requires_multibuild = True
3862
+ add(IndexPrinterFlowable())
3863
+
3864
+
3865
+ def include_chapter(py_file_path: str) -> None:
3866
+ """Load and execute a sub-Python file, appending its elements to the active story."""
3867
+ import runpy
3868
+ import os
3869
+
3870
+ if not os.path.exists(py_file_path):
3871
+ raise FileNotFoundError(f"Chapter file not found: {py_file_path}")
3872
+
3873
+ runpy.run_path(py_file_path)
3874
+
3875
+
3876
+ def include_markdown(md_file_path: str) -> None:
3877
+ """Parse a markdown file and append its flowables to the active story."""
3878
+ import os
3879
+ from .cli import parse_metadata, parse_markdown_lines
3880
+
3881
+ if not os.path.exists(md_file_path):
3882
+ raise FileNotFoundError(f"Markdown file not found: {md_file_path}")
3883
+
3884
+ with open(md_file_path, "r", encoding="utf-8") as f:
3885
+ lines = f.readlines()
3886
+
3887
+ _, content_lines = parse_metadata(lines)
3888
+ parse_markdown_lines(content_lines)
3889
+
3890
+
3891
+ def build_split_doc(
3892
+ filename: str,
3893
+ split_by: str = "chapter",
3894
+ page_interval: int | None = None,
3895
+ reset_page_numbers: bool = False,
3896
+ ) -> None:
3897
+ """Build the document and split it either by page interval or by section boundaries."""
3898
+ import os
3899
+ from .document import build_doc
3900
+ from . import helpers
3901
+ import engrapha_notes.document as doc_mod
3902
+
3903
+ # Reset any page number offsets
3904
+ doc_mod._page_number_offset = 0
3905
+
3906
+ if page_interval is not None:
3907
+ # 1. Splitting by Page Range (Post-Processing)
3908
+ temp_full = filename.replace(".pdf", "_temp_full.pdf")
3909
+ # Build the full document first
3910
+ build_doc(temp_full)
3911
+
3912
+ try:
3913
+ import fitz # PyMuPDF
3914
+ except ModuleNotFoundError as e:
3915
+ raise RuntimeError(
3916
+ "PDF splitting requires the optional 'pymupdf' package.\n"
3917
+ "Install it with:\n\n"
3918
+ "pip install engrapha-notes[split]\n"
3919
+ "or\n"
3920
+ "pip install engrapha-notes[all]"
3921
+ ) from e
3922
+
3923
+ doc = fitz.open(temp_full)
3924
+ num_pages = len(doc)
3925
+ base, ext = os.path.splitext(filename)
3926
+
3927
+ for start in range(0, num_pages, page_interval):
3928
+ end = min(start + page_interval, num_pages)
3929
+ out_doc = fitz.open()
3930
+ out_doc.insert_pdf(doc, from_page=start, to_page=end - 1)
3931
+ segment_filename = f"{base}_pages_{start+1}_{end}{ext}"
3932
+ out_doc.save(segment_filename)
3933
+ out_doc.close()
3934
+
3935
+ doc.close()
3936
+ # Clean up temporary full PDF
3937
+ if os.path.exists(temp_full):
3938
+ try:
3939
+ os.remove(temp_full)
3940
+ except Exception:
3941
+ pass
3942
+
3943
+ else:
3944
+ # 2. Splitting by Section (Chapter/Part)
3945
+ # Get active story
3946
+ active_story = helpers.get_story()
3947
+
3948
+ boundary_indices = []
3949
+ for idx, item in enumerate(active_story):
3950
+ if split_by == "part" and getattr(item, "_is_part_box", False):
3951
+ boundary_indices.append(idx)
3952
+ elif split_by == "chapter" and (
3953
+ getattr(item, "_is_chap_box", False)
3954
+ or getattr(item, "_is_part_box", False)
3955
+ ):
3956
+ boundary_indices.append(idx)
3957
+
3958
+ # Look back to group bookmarks, spacers, page breaks, etc. with their following section
3959
+ section_starts = []
3960
+ for idx in boundary_indices:
3961
+ start = idx
3962
+ while start > 0:
3963
+ prev_item = active_story[start - 1]
3964
+ prev_class = prev_item.__class__.__name__
3965
+ if prev_class in (
3966
+ "Bookmark",
3967
+ "PageBreak",
3968
+ "Spacer",
3969
+ "ThemeSetterFlowable",
3970
+ ):
3971
+ start -= 1
3972
+ else:
3973
+ break
3974
+ section_starts.append(start)
3975
+
3976
+ split_points = sorted(list(set(section_starts)))
3977
+ if not split_points:
3978
+ split_points = [0]
3979
+
3980
+ sub_stories = []
3981
+ for i in range(len(split_points)):
3982
+ start_idx = split_points[i]
3983
+ end_idx = (
3984
+ split_points[i + 1] if i + 1 < len(split_points) else len(active_story)
3985
+ )
3986
+ sub_stories.append(active_story[start_idx:end_idx])
3987
+
3988
+ base, ext = os.path.splitext(filename)
3989
+
3990
+ for idx, sub_story in enumerate(sub_stories):
3991
+ # Clean leading PageBreak flowables that occur before actual content
3992
+ cleaned_story = []
3993
+ seen_content = False
3994
+ for item in sub_story:
3995
+ if item.__class__.__name__ == "PageBreak" and not seen_content:
3996
+ continue
3997
+ if item.__class__.__name__ not in (
3998
+ "Bookmark",
3999
+ "ThemeSetterFlowable",
4000
+ "PageBreak",
4001
+ "Spacer",
4002
+ "Footer",
4003
+ "FootnoteRegisterFlowable",
4004
+ "LabelFlowable",
4005
+ "IndexEntryFlowable",
4006
+ ):
4007
+ seen_content = True
4008
+ cleaned_story.append(item)
4009
+ sub_story = cleaned_story
4010
+
4011
+ # Resolve section name
4012
+ sec_name = "section"
4013
+ # Look for the first boundary item in this sub_story to get the title
4014
+ boundary_item = None
4015
+ for item in sub_story:
4016
+ if getattr(item, "_is_chap_box", False) or getattr(
4017
+ item, "_is_part_box", False
4018
+ ):
4019
+ boundary_item = item
4020
+ break
4021
+
4022
+ if boundary_item and hasattr(boundary_item, "_box_title"):
4023
+ raw_title = getattr(boundary_item, "_box_title", "")
4024
+ # Remove HTML tags if any
4025
+ import re
4026
+
4027
+ cleaned_title = re.sub(r"<[^>]*>", "", raw_title)
4028
+ cleaned_title = "".join(
4029
+ c if c.isalnum() or c in " _-" else "_" for c in cleaned_title
4030
+ )
4031
+ sec_name = cleaned_title.strip().replace(" ", "_").lower()
4032
+
4033
+ sub_filename = f"{base}_{idx+1}_{sec_name}{ext}"
4034
+
4035
+ # Build the sub-document
4036
+ build_doc(sub_filename, story=sub_story)
4037
+
4038
+ if not reset_page_numbers:
4039
+ try:
4040
+ import fitz
4041
+ except ModuleNotFoundError as e:
4042
+ raise RuntimeError(
4043
+ "PDF splitting requires the optional 'pymupdf' package.\n"
4044
+ "Install it with:\n\n"
4045
+ "pip install engrapha-notes[split]\n"
4046
+ "or\n"
4047
+ "pip install engrapha-notes[all]"
4048
+ ) from e
4049
+
4050
+ pdf = fitz.open(sub_filename)
4051
+ num_pages = len(pdf)
4052
+ pdf.close()
4053
+ doc_mod._page_number_offset += num_pages
4054
+
4055
+
4056
+ class ImageFlowable(Flowable): # type: ignore[misc]
4057
+ def __init__(
4058
+ self,
4059
+ src: str,
4060
+ width: float | None = None,
4061
+ height: float | None = None,
4062
+ caption: str | None = None,
4063
+ link: str | None = None,
4064
+ fallbacks: str | list[str] | None = None,
4065
+ ) -> None:
4066
+ Flowable.__init__(self)
4067
+ self.src = src
4068
+ self.caption = caption
4069
+ self.link = link
4070
+ self.fallbacks = fallbacks
4071
+ self.local_path = ""
4072
+ self.resolved_src = src
4073
+
4074
+ # 1. Resolve candidates list (primary + fallbacks)
4075
+ candidates = [src]
4076
+ if fallbacks is not None:
4077
+ if isinstance(fallbacks, str):
4078
+ candidates.append(fallbacks)
4079
+ else:
4080
+ candidates.extend(fallbacks)
4081
+
4082
+ # 2. Iterate through candidates and find the first working one
4083
+ import os
4084
+
4085
+ for candidate in candidates:
4086
+ if not candidate:
4087
+ continue
4088
+
4089
+ local_cand_path = candidate
4090
+
4091
+ # Resolve remote URLs
4092
+ if candidate.startswith("http://") or candidate.startswith("https://"):
4093
+ import hashlib
4094
+ import urllib.request
4095
+ import warnings
4096
+
4097
+ cache_dir = os.path.join(os.getcwd(), ".engrapha_cache", "images")
4098
+ os.makedirs(cache_dir, exist_ok=True)
4099
+
4100
+ url_hash = hashlib.md5(candidate.encode("utf-8")).hexdigest()
4101
+ ext = candidate.split(".")[-1].split("?")[0].lower()
4102
+ if ext not in {"png", "jpg", "jpeg", "gif", "svg", "bmp"}:
4103
+ ext = "png"
4104
+ cache_path = os.path.join(cache_dir, f"{url_hash}.{ext}")
4105
+
4106
+ if not os.path.exists(cache_path):
4107
+ try:
4108
+ urllib.request.urlretrieve(candidate, cache_path)
4109
+ except Exception as e:
4110
+ warnings.warn(
4111
+ f"Failed to download image fallback from URL {candidate}: {e}"
4112
+ )
4113
+ cache_path = ""
4114
+
4115
+ if cache_path and os.path.exists(cache_path):
4116
+ local_cand_path = cache_path
4117
+
4118
+ # Check if this candidate resolved successfully
4119
+ if os.path.exists(local_cand_path):
4120
+ self.local_path = local_cand_path
4121
+ self.resolved_src = candidate
4122
+ break
4123
+
4124
+ # 3. Retrieve dimensions using ReportLab's ImageReader if possible
4125
+ w_resolved: float | None = width
4126
+ h_resolved: float | None = height
4127
+
4128
+ from reportlab.lib.utils import ImageReader
4129
+ from reportlab.platypus import Image as RLImage
4130
+ import warnings
4131
+
4132
+ self.rl_image = None
4133
+ if self.local_path and os.path.exists(self.local_path):
4134
+ try:
4135
+ reader = ImageReader(self.local_path)
4136
+ orig_w, orig_h = reader.getSize()
4137
+
4138
+ # Calculate page layout width (CW)
4139
+ from reportlab.lib.pagesizes import A4
4140
+ from reportlab.lib.units import cm
4141
+
4142
+ PAGE_W, PAGE_H = A4
4143
+ CW = PAGE_W - 2 * 1.8 * cm # default 1.8cm margin
4144
+
4145
+ if w_resolved is None and h_resolved is None:
4146
+ # Scale to fit CW preserving aspect ratio
4147
+ if orig_w > CW:
4148
+ w_resolved = CW
4149
+ h_resolved = orig_h * (CW / orig_w)
4150
+ else:
4151
+ w_resolved = float(orig_w)
4152
+ h_resolved = float(orig_h)
4153
+ elif w_resolved is not None and h_resolved is None:
4154
+ h_resolved = orig_h * (w_resolved / orig_w)
4155
+ elif h_resolved is not None and w_resolved is None:
4156
+ w_resolved = orig_w * (h_resolved / orig_h)
4157
+
4158
+ if w_resolved is not None and h_resolved is not None:
4159
+ w_val = float(w_resolved)
4160
+ h_val = float(h_resolved)
4161
+ self.rl_image = RLImage(self.local_path, width=w_val, height=h_val)
4162
+ except Exception as e:
4163
+ warnings.warn(f"Failed to load image at {self.local_path}: {e}")
4164
+
4165
+ # If still none, set default sizes to prevent errors
4166
+ self.w: float = float(w_resolved) if w_resolved is not None else 120.0
4167
+ self.h: float = float(h_resolved) if h_resolved is not None else 90.0
4168
+
4169
+ # Create RLImage if it wasn't initialized but file exists
4170
+ if (
4171
+ self.rl_image is None
4172
+ and self.local_path
4173
+ and os.path.exists(self.local_path)
4174
+ ):
4175
+ try:
4176
+ self.rl_image = RLImage(self.local_path, width=self.w, height=self.h)
4177
+ except Exception:
4178
+ pass
4179
+
4180
+ self.width = self.w
4181
+ self.height = self.h
4182
+ self.orig_w = self.w
4183
+ self.orig_h = self.h
4184
+
4185
+ def wrap(self, aW: float, aH: float) -> tuple[float, float]:
4186
+ self.w = self.orig_w
4187
+ self.h = self.orig_h
4188
+ if self.w > aW:
4189
+ scale = aW / self.w
4190
+ self.w = aW
4191
+ self.h = self.h * scale
4192
+ if self.rl_image:
4193
+ self.rl_image.drawWidth = self.w
4194
+ self.rl_image.drawHeight = self.h
4195
+ caption_h = 20.0 if self.caption else 0.0
4196
+ return self.w, self.h + caption_h
4197
+
4198
+ def draw(self) -> None:
4199
+ caption_h = 20.0 if self.caption else 0.0
4200
+ from .theme import get_theme
4201
+
4202
+ t_theme = get_theme()
4203
+
4204
+ if self.caption:
4205
+ from reportlab.platypus import Paragraph
4206
+ from reportlab.lib.styles import ParagraphStyle
4207
+
4208
+ caption_style = ParagraphStyle(
4209
+ name="ImageCaption",
4210
+ fontName=t_theme.body_font,
4211
+ fontSize=9,
4212
+ leading=11,
4213
+ alignment=1, # centered
4214
+ textColor=t_theme.rl(t_theme.text_dim),
4215
+ )
4216
+ p = Paragraph(f"<i>{self.caption}</i>", caption_style)
4217
+ p.wrap(self.w, caption_h)
4218
+ p.drawOn(self.canv, 0, 0)
4219
+
4220
+ if self.rl_image:
4221
+ self.rl_image.drawOn(self.canv, 0, caption_h)
4222
+ else:
4223
+ # Draw styled warning fallback placeholder box
4224
+ self.canv.saveState()
4225
+ self.canv.setStrokeColor(t_theme.rl(t_theme.table_bdr))
4226
+ self.canv.setLineWidth(1)
4227
+ self.canv.setDash([4, 4])
4228
+ self.canv.setFillColor(t_theme.rl(t_theme.surface))
4229
+ self.canv.rect(0, caption_h, self.w, self.h, stroke=1, fill=1)
4230
+
4231
+ from reportlab.platypus import Paragraph
4232
+ from reportlab.lib.styles import ParagraphStyle
4233
+
4234
+ fallback_style = ParagraphStyle(
4235
+ name="ImageFallback",
4236
+ fontName=t_theme.body_font,
4237
+ fontSize=9,
4238
+ leading=11,
4239
+ alignment=1, # centered
4240
+ textColor=t_theme.rl(t_theme.text_dim),
4241
+ )
4242
+ # Limit URL text length to display cleanly
4243
+ display_src = self.src
4244
+ if len(display_src) > 50:
4245
+ display_src = display_src[:47] + "..."
4246
+ text_p = Paragraph(
4247
+ f"<b>[Image Not Available]</b><br/><font size='7'>{display_src}</font>",
4248
+ fallback_style,
4249
+ )
4250
+ p_w, p_h = text_p.wrap(self.w - 10, self.h)
4251
+
4252
+ # Center the triangle and text block vertically
4253
+ block_h = 18.0 + 6.0 + p_h
4254
+ block_y = caption_h + (self.h - block_h) / 2.0
4255
+
4256
+ cx = self.w / 2.0
4257
+ tri_y = block_y + p_h + 6.0
4258
+ tri_w = 20.0
4259
+ tri_h = 18.0
4260
+
4261
+ x1 = cx
4262
+ y1 = tri_y + tri_h
4263
+ x2 = cx - tri_w / 2.0
4264
+ y2 = tri_y
4265
+ x3 = cx + tri_w / 2.0
4266
+ y3 = tri_y
4267
+
4268
+ # Draw yellow warning triangle
4269
+ self.canv.setStrokeColorRGB(0.85, 0.55, 0.0) # Orange/Dark Yellow border
4270
+ self.canv.setFillColorRGB(1.0, 0.75, 0.0) # Warning Yellow fill
4271
+ self.canv.setLineWidth(1.5)
4272
+ self.canv.setLineJoin(1)
4273
+
4274
+ p = self.canv.beginPath()
4275
+ p.moveTo(x2, y2)
4276
+ p.lineTo(x3, y3)
4277
+ p.lineTo(x1, y1)
4278
+ p.close()
4279
+ self.canv.drawPath(p, fill=1, stroke=1)
4280
+
4281
+ # Draw exclamation mark inside triangle
4282
+ self.canv.setFillColorRGB(0.0, 0.0, 0.0)
4283
+ self.canv.setStrokeColorRGB(0.0, 0.0, 0.0)
4284
+ self.canv.setLineCap(0)
4285
+ self.canv.setLineWidth(1.5)
4286
+ # Stem
4287
+ self.canv.line(cx, tri_y + 6.5, cx, tri_y + 12.0)
4288
+ # Dot
4289
+ self.canv.circle(cx, tri_y + 3.5, 0.8, stroke=0, fill=1)
4290
+
4291
+ self.canv.restoreState()
4292
+
4293
+ # Draw text
4294
+ text_p.drawOn(self.canv, 5, block_y)
4295
+
4296
+ def drawOn(
4297
+ self, canvas: Any, x: float, y: float, *args: Any, **kwargs: Any
4298
+ ) -> None:
4299
+ super().drawOn(canvas, x, y, *args, **kwargs)
4300
+ if hasattr(self, "link") and self.link:
4301
+ caption_h = 20.0 if self.caption else 0.0
4302
+ rect = (x, y + caption_h, x + self.w, y + caption_h + self.h)
4303
+ canvas.linkURL(self.link, rect, relative=0)
4304
+
4305
+
4306
+ def image(
4307
+ src: str,
4308
+ width: float | None = None,
4309
+ height: float | None = None,
4310
+ caption: str | None = None,
4311
+ link: str | None = None,
4312
+ fallbacks: str | list[str] | None = None,
4313
+ h_align: Any = "CENTER",
4314
+ ) -> None:
4315
+ """
4316
+ Add an image from a local path or remote URL to the document with fallback options.
4317
+
4318
+ Args:
4319
+ src: Local path or remote URL.
4320
+ width: Optional display width. If omitted, scales to fit page width.
4321
+ height: Optional display height. If omitted, scales proportionally.
4322
+ caption: Optional text caption displayed below the image.
4323
+ link: Optional hyperlink URL. If provided, clicking the image in PDF/HTML opens this link.
4324
+ fallbacks: Optional fallback image source path/URL or list of paths/URLs to try if src fails.
4325
+ h_align: Horizontal alignment, default is 'CENTER'.
4326
+ """
4327
+ img = ImageFlowable(
4328
+ src, width=width, height=height, caption=caption, link=link, fallbacks=fallbacks
4329
+ )
4330
+ img.hAlign = h_align
4331
+ add(img)
4332
+
4333
+
4334
+ __all__ = [
4335
+ "story",
4336
+ "set_story",
4337
+ "get_story",
4338
+ "add",
4339
+ "image",
4340
+ "ImageFlowable",
4341
+ "sp",
4342
+ "rule",
4343
+ "br",
4344
+ "slide_break",
4345
+ "part_box",
4346
+ "cover_card",
4347
+ "chap_box",
4348
+ "section",
4349
+ "subsection",
4350
+ "body",
4351
+ "definition",
4352
+ "highlight",
4353
+ "tip",
4354
+ "note",
4355
+ "warning",
4356
+ "important",
4357
+ "exam",
4358
+ "theorem",
4359
+ "proof",
4360
+ "question",
4361
+ "answer",
4362
+ "mcq",
4363
+ "flashcard",
4364
+ "bullet",
4365
+ "code_block",
4366
+ "info_table",
4367
+ "formula",
4368
+ "formula_block",
4369
+ "set_theme",
4370
+ "toc",
4371
+ "bookmark",
4372
+ "bookmarks_enabled",
4373
+ "global_footer",
4374
+ "set_global_footer",
4375
+ "Footer",
4376
+ "footer",
4377
+ "suppress_footer",
4378
+ "footnote",
4379
+ "label",
4380
+ "ref",
4381
+ "index_entry",
4382
+ "print_index",
4383
+ "include_chapter",
4384
+ "include_markdown",
4385
+ "build_split_doc",
4386
+ ]