engrapha-notes 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- engrapha_notes/__init__.py +236 -0
- engrapha_notes/cli.py +883 -0
- engrapha_notes/document.py +2691 -0
- engrapha_notes/helpers.py +4386 -0
- engrapha_notes/packet.py +282 -0
- engrapha_notes/palette.py +61 -0
- engrapha_notes/styles.py +28 -0
- engrapha_notes/theme.py +348 -0
- engrapha_notes-0.1.0.dist-info/METADATA +530 -0
- engrapha_notes-0.1.0.dist-info/RECORD +13 -0
- engrapha_notes-0.1.0.dist-info/WHEEL +5 -0
- engrapha_notes-0.1.0.dist-info/entry_points.txt +3 -0
- engrapha_notes-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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"• {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"• {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"• {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("• • •", 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("◆ ∘ ◆", 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>[ {t.upper()} ]</b>")
|
|
733
|
+
|
|
734
|
+
tags_str = " ".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 (💻) 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(" • ".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": "💻",
|
|
1192
|
+
"tags": ["Engineering", "Technical Reference", "2026 Edition"],
|
|
1193
|
+
},
|
|
1194
|
+
"research-paper": {
|
|
1195
|
+
"cover_theme": "academic_modern",
|
|
1196
|
+
"icon": "💾",
|
|
1197
|
+
"tags": ["Research", "Academic Paper", "Peer Review"],
|
|
1198
|
+
},
|
|
1199
|
+
"course-notes": {
|
|
1200
|
+
"cover_theme": "catppuccin",
|
|
1201
|
+
"icon": "📚",
|
|
1202
|
+
"tags": ["Course Notes", "Study Guide", "Exam Prep"],
|
|
1203
|
+
},
|
|
1204
|
+
"networking": {
|
|
1205
|
+
"cover_theme": "notion",
|
|
1206
|
+
"icon": "🔗",
|
|
1207
|
+
"tags": ["Networking", "Semester IV", "CCNA"],
|
|
1208
|
+
},
|
|
1209
|
+
"database": {
|
|
1210
|
+
"cover_theme": "academic_modern",
|
|
1211
|
+
"icon": "🗃",
|
|
1212
|
+
"tags": ["Database Systems", "SQL", "Data Engineering"],
|
|
1213
|
+
},
|
|
1214
|
+
"programming": {
|
|
1215
|
+
"cover_theme": "linear",
|
|
1216
|
+
"icon": "🐉",
|
|
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}">•</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: " " * 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
|
+
]
|