md2textbook 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ from .parser import convert
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["convert", "__version__"]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
md2textbook/builder.py ADDED
@@ -0,0 +1,530 @@
1
+ from contextlib import contextmanager
2
+ from datetime import date
3
+ from pathlib import Path
4
+
5
+ from pygments import lex
6
+ from pygments.lexers import TextLexer, get_lexer_by_name
7
+ from pygments.token import Token
8
+ from pygments.util import ClassNotFound
9
+ from reportlab.lib.colors import HexColor, white
10
+ from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
11
+ from reportlab.lib.styles import ParagraphStyle
12
+ from reportlab.lib.utils import ImageReader
13
+ from reportlab.pdfbase.pdfmetrics import stringWidth
14
+ from reportlab.platypus import (BaseDocTemplate, CondPageBreak, Flowable, Frame, HRFlowable, Image, KeepTogether,
15
+ NextPageTemplate, PageBreak, PageTemplate, Paragraph, Spacer, Table, TableStyle)
16
+ from reportlab.platypus.tableofcontents import TableOfContents
17
+
18
+ from . import mathkit, theme
19
+ from .theme import (BOXES, INDIGO, INDIGO_DARK, INDIGO_SOFT, INK, MB, ML, MR, MT, MUTED, PAGE_H, PAGE_W, PURPLE, RULE,
20
+ TEXT_W, ZEBRA, code_span, inline, para, plain, xml_escape)
21
+
22
+ FRAME_H = PAGE_H - MT - MB
23
+
24
+ CODE_BG = HexColor("#282C34")
25
+ CODE_FG = "#ABB2BF"
26
+ CODE_STYLES = {
27
+ Token.Keyword: "#C678DD",
28
+ Token.Keyword.Constant: "#D19A66",
29
+ Token.Name.Function: "#61AFEF",
30
+ Token.Name.Class: "#E5C07B",
31
+ Token.Name.Builtin: "#56B6C2",
32
+ Token.Name.Decorator: "#61AFEF",
33
+ Token.Name.Tag: "#E06C75",
34
+ Token.Name.Attribute: "#D19A66",
35
+ Token.Name.Exception: "#E5C07B",
36
+ Token.Literal.String: "#98C379",
37
+ Token.Literal.Number: "#D19A66",
38
+ Token.Comment: "#7F848E",
39
+ Token.Operator: "#56B6C2",
40
+ Token.Generic.Heading: "#E5C07B",
41
+ Token.Generic.Deleted: "#E06C75",
42
+ Token.Generic.Inserted: "#98C379",
43
+ }
44
+ PLAIN_LANGS = {"", "text", "txt", "bash", "sh", "shell", "console", "powershell", "ps1", "cmd", "bat"}
45
+ MONO_SIZE = 7.8
46
+ MONO_W = 0.602 * MONO_SIZE
47
+ BOX_INSET = 26
48
+
49
+
50
+ class Heading(Paragraph):
51
+ toc = None
52
+
53
+
54
+ class Equation(Flowable):
55
+ PAD = 7
56
+
57
+ def __init__(self, rendered: mathkit.Rendered, label: str | None):
58
+ super().__init__()
59
+ self.rendered, self.label = rendered, label
60
+
61
+ def wrap(self, aw, ah):
62
+ side = 40 if self.label else 0
63
+ scale = min(1.0, (aw - 2 * side) / self.rendered.width)
64
+ self.w, self.h = self.rendered.width * scale, self.rendered.height * scale
65
+ self.aw = aw
66
+ return aw, self.h + 2 * self.PAD
67
+
68
+ def draw(self):
69
+ c = self.canv
70
+ c.drawImage(self.rendered.path, (self.aw - self.w) / 2, self.PAD, self.w, self.h, mask="auto")
71
+ if self.label:
72
+ c.setFont("Body", 10)
73
+ c.setFillColor(MUTED)
74
+ c.drawRightString(self.aw, self.PAD + self.h / 2 - 3, self.label)
75
+
76
+
77
+ def _code_color(ttype) -> str | None:
78
+ while ttype is not Token:
79
+ if ttype in CODE_STYLES:
80
+ return CODE_STYLES[ttype]
81
+ ttype = ttype.parent
82
+ return None
83
+
84
+
85
+ def _highlight_lines(code: str, lang: str) -> list[list[tuple[str | None, str, bool]]]:
86
+ try:
87
+ lexer = get_lexer_by_name(lang or "text", stripnl=False, ensurenl=False)
88
+ except ClassNotFound:
89
+ lexer = TextLexer(stripnl=False, ensurenl=False)
90
+ lines: list[list[tuple[str | None, str, bool]]] = [[]]
91
+ for ttype, value in lex(code, lexer):
92
+ italic = ttype in Token.Comment
93
+ color = _code_color(ttype)
94
+ for i, chunk in enumerate(value.split("\n")):
95
+ if i:
96
+ lines.append([])
97
+ if chunk:
98
+ lines[-1].append((color, chunk, italic))
99
+ return lines
100
+
101
+
102
+ def _wrap_segments(segments, width: int) -> list[list[tuple[str | None, str, bool]]]:
103
+ rows, current, used = [], [], 0
104
+ for color, text, italic in segments:
105
+ while text:
106
+ room = width - used
107
+ if room <= 0:
108
+ rows.append(current)
109
+ current, used = [], 2
110
+ room = width - used
111
+ piece, text = text[:room], text[room:]
112
+ current.append((color, piece, italic))
113
+ used += len(piece)
114
+ rows.append(current)
115
+ return rows
116
+
117
+
118
+ def _markup(segments, continuation: bool) -> str:
119
+ out = ['<font name="Sym" color="#5C6370">↪</font>&nbsp;'] if continuation else []
120
+ for color, text, italic in segments:
121
+ body = xml_escape(text).replace(" ", "&nbsp;")
122
+ body = f"<i>{body}</i>" if italic else body
123
+ out.append(f'<font color="{color or CODE_FG}">{body}</font>')
124
+ return "".join(out) or "&nbsp;"
125
+
126
+
127
+ class Book:
128
+ def __init__(self, path: str | Path, title: str, author: str = ""):
129
+ theme.register_fonts()
130
+ self.path, self.title, self.author = str(path), title, author
131
+ self.S = theme.make_styles()
132
+ self._story: list = []
133
+ self._sink = self._story
134
+ self.depth = 0
135
+ self._cover: dict | None = None
136
+ self._want_toc = False
137
+ self._counters = {"ch": 0, "sec": 0, "sub": 0, "eq": 0, "fig": 0, "lst": 0, "tbl": 0}
138
+ self._headings = 0
139
+ self._last_level = -1
140
+ self._list_styles: dict[tuple, ParagraphStyle] = {}
141
+
142
+ @property
143
+ def avail(self) -> float:
144
+ return TEXT_W - self.depth * BOX_INSET
145
+
146
+ def _add(self, *flowables) -> "Book":
147
+ self._sink.extend(flowables)
148
+ return self
149
+
150
+ def _add_kept(self, parts: list, keep: bool = True) -> "Book":
151
+ if keep and not self.depth:
152
+ return self._add(KeepTogether(parts))
153
+ return self._add(*parts)
154
+
155
+ @contextmanager
156
+ def boxed(self):
157
+ saved, items = self._sink, []
158
+ self._sink = items
159
+ self.depth += 1
160
+ try:
161
+ yield items
162
+ finally:
163
+ self._sink = saved
164
+ self.depth -= 1
165
+
166
+ def _tag(self) -> str:
167
+ ch = self._counters["ch"]
168
+ return f"{ch}." if ch else ""
169
+
170
+ def cover(self, title: str, subtitle: str = "", lines: tuple[str, ...] = ()) -> "Book":
171
+ self._cover = {"title": title, "subtitle": subtitle, "lines": [ln for ln in lines if ln]}
172
+ return self
173
+
174
+ def toc(self) -> "Book":
175
+ self._want_toc = True
176
+ return self
177
+
178
+ def _heading(self, level: int, title: str, number: str, style_name: str) -> Heading:
179
+ level = min(level, self._last_level + 1)
180
+ self._last_level = level
181
+ self._headings += 1
182
+ label = f"{number} {plain(title)}" if number else plain(title)
183
+ style = self.S[style_name]
184
+ prefix = f'<font color="#4F46E5">{number}</font>&nbsp;&nbsp;' if number else ""
185
+ heading = Heading(prefix + inline(title, style.fontName, style.fontSize), style)
186
+ heading.toc = (level, label, f"h{self._headings}")
187
+ return heading
188
+
189
+ def chapter(self, title: str, kicker: str | None = None) -> "Book":
190
+ c = self._counters
191
+ c["ch"] += 1
192
+ c["sec"] = c["sub"] = c["eq"] = c["fig"] = c["lst"] = c["tbl"] = 0
193
+ self._headings += 1
194
+ self._last_level = 0
195
+ number = f"{c['ch']}"
196
+ kick = kicker or f"CHAPTER {c['ch']}"
197
+ rows = [["", para(kick, self.S["kicker"])], ["", para(title, self.S["chapter"])]]
198
+ banner = Table(rows, colWidths=[7, TEXT_W - 7], spaceAfter=16)
199
+ banner.setStyle(TableStyle([
200
+ ("BACKGROUND", (0, 0), (0, -1), HexColor("#818CF8")),
201
+ ("BACKGROUND", (1, 0), (1, -1), INDIGO_DARK),
202
+ ("LEFTPADDING", (0, 0), (0, -1), 0), ("RIGHTPADDING", (0, 0), (0, -1), 0),
203
+ ("LEFTPADDING", (1, 0), (1, -1), 17), ("RIGHTPADDING", (1, 0), (1, -1), 20),
204
+ ("TOPPADDING", (0, 0), (-1, 0), 20), ("BOTTOMPADDING", (0, 0), (-1, 0), 2),
205
+ ("TOPPADDING", (0, 1), (-1, 1), 0), ("BOTTOMPADDING", (0, 1), (-1, 1), 22),
206
+ ]))
207
+ banner.toc = (0, f"{number} {plain(title)}", f"h{self._headings}")
208
+ banner.is_chapter = True
209
+ banner.chapter_title = plain(title)
210
+ return self._add(CondPageBreak(FRAME_H - 1), banner)
211
+
212
+ def section(self, title: str) -> "Book":
213
+ c = self._counters
214
+ c["sec"] += 1
215
+ c["sub"] = 0
216
+ number = f"{c['ch']}.{c['sec']}" if c["ch"] else f"{c['sec']}"
217
+ return self._add(self._heading(1, title, number, "sec"))
218
+
219
+ def sub(self, title: str) -> "Book":
220
+ c = self._counters
221
+ c["sub"] += 1
222
+ base = f"{c['ch']}.{c['sec']}" if c["ch"] else f"{c['sec']}"
223
+ return self._add(self._heading(2, title, f"{base}.{c['sub']}", "sub"))
224
+
225
+ def subsub(self, title: str) -> "Book":
226
+ return self._add(self._heading(3, title, "", "subsub"))
227
+
228
+ def para(self, text: str, lead: bool = False) -> "Book":
229
+ style = self.S["lead"] if lead else self.S["box_body" if self.depth else "body"]
230
+ return self._add(para(text, style))
231
+
232
+ def _item_style(self, level: int, ordered: bool) -> ParagraphStyle:
233
+ key = (level, ordered, bool(self.depth))
234
+ if key not in self._list_styles:
235
+ base = self.S["box_bullet" if self.depth else "bullet"]
236
+ indent = 18 + level * 16 + (4 if ordered else 0)
237
+ self._list_styles[key] = ParagraphStyle(f"item{key}", parent=base, leftIndent=indent,
238
+ bulletIndent=indent - (18 if ordered else 12))
239
+ return self._list_styles[key]
240
+
241
+ def item(self, text: str, level: int = 0, marker: str | None = None) -> "Book":
242
+ ordered = bool(marker and marker[0].isdigit())
243
+ marker = marker or ("•", "–", "▪")[min(level, 2)]
244
+ st = self._item_style(level, ordered)
245
+ font = st.bulletFontName
246
+ flowable = Paragraph(inline(text, st.fontName, st.fontSize), st, bulletText=theme.wrap_missing_glyphs(marker, font))
247
+ return self._add(flowable)
248
+
249
+ def bullets(self, items: list[str]) -> "Book":
250
+ for text in items:
251
+ self.item(text)
252
+ return self
253
+
254
+ def numbered(self, items: list[str]) -> "Book":
255
+ for n, text in enumerate(items, 1):
256
+ self.item(text, marker=f"{n}.")
257
+ return self
258
+
259
+ def rule(self) -> "Book":
260
+ return self._add(HRFlowable(width="100%", thickness=0.8, color=RULE, spaceBefore=8, spaceAfter=10))
261
+
262
+ def space(self, points: float = 8) -> "Book":
263
+ return self._add(Spacer(1, points))
264
+
265
+ def condbreak(self, height: float = 140) -> "Book":
266
+ return self._add(CondPageBreak(height))
267
+
268
+ def box(self, kind: str, title: str = "", items=(), label: str | None = None) -> "Book":
269
+ name, bar, tint = BOXES[kind]
270
+ label = label or name
271
+ head: list = []
272
+ if label:
273
+ head.append(Paragraph(label, ParagraphStyle("lbl", parent=self.S["box_label"], textColor=HexColor(bar))))
274
+ if title:
275
+ head.append(para(title, self.S["box_title"]))
276
+ rows = [["", head]] if head else []
277
+ for entry in items:
278
+ if isinstance(entry, str):
279
+ if entry.startswith("- "):
280
+ with self.boxed() as made:
281
+ self.item(entry[2:])
282
+ rows.extend([["", f] for f in made])
283
+ else:
284
+ rows.append(["", para(entry, self.S["box_body"])])
285
+ else:
286
+ rows.append(["", entry])
287
+ if not rows:
288
+ return self
289
+ table = Table(rows, colWidths=[3.2, self.avail - 3.2], splitByRow=1, spaceBefore=4, spaceAfter=10)
290
+ table.setStyle(TableStyle([
291
+ ("BACKGROUND", (0, 0), (0, -1), HexColor(bar)),
292
+ ("BACKGROUND", (1, 0), (1, -1), HexColor(tint)),
293
+ ("LEFTPADDING", (0, 0), (0, -1), 0), ("RIGHTPADDING", (0, 0), (0, -1), 0),
294
+ ("LEFTPADDING", (1, 0), (1, -1), 12), ("RIGHTPADDING", (1, 0), (1, -1), 12),
295
+ ("TOPPADDING", (0, 0), (-1, -1), 1), ("BOTTOMPADDING", (0, 0), (-1, -1), 1),
296
+ ("TOPPADDING", (0, 0), (-1, 0), 9), ("BOTTOMPADDING", (0, -1), (-1, -1), 8),
297
+ ]))
298
+ return self._add_kept([table])
299
+
300
+ def define(self, term: str, text: str) -> "Book":
301
+ return self.box("definition", term, [text])
302
+
303
+ def eq(self, tex: str, numbered: bool = True) -> "Book":
304
+ numbered = numbered and not self.depth
305
+ label = None
306
+ if numbered:
307
+ self._counters["eq"] += 1
308
+ label = f"({self._tag()}{self._counters['eq']})"
309
+ rendered = mathkit.render(tex, 12.5)
310
+ if rendered is None:
311
+ style = ParagraphStyle("eqfail", parent=self.S["body"], alignment=TA_CENTER)
312
+ return self._add(Paragraph(code_span(tex, 9), style))
313
+ return self._add(Equation(rendered, label))
314
+
315
+ def eq_flow(self, lines: list[str], numbered: bool = True) -> "Book":
316
+ for tex in lines:
317
+ self.eq(tex, numbered)
318
+ return self
319
+
320
+ def code(self, source: str, lang: str = "", label: str | None = None, numbers: bool | None = None,
321
+ start_line: int = 1) -> "Book":
322
+ source = source.expandtabs(4).rstrip("\n")
323
+ if numbers is None:
324
+ numbers = lang.lower() not in PLAIN_LANGS
325
+ num_w = 30 if numbers else 0
326
+ width = max(30, int((self.avail - num_w - 26) / MONO_W))
327
+ base = ParagraphStyle("code", fontName="Mono", fontSize=MONO_SIZE, leading=10.8, textColor=HexColor(CODE_FG))
328
+ num_style = ParagraphStyle("codenum", parent=base, alignment=TA_RIGHT, textColor=HexColor("#636D83"))
329
+ rows = []
330
+ for offset, segments in enumerate(_highlight_lines(source, lang)):
331
+ for i, chunk in enumerate(_wrap_segments(segments, width)):
332
+ number = str(start_line + offset) if numbers and i == 0 else ""
333
+ cells = [Paragraph(_markup(chunk, i > 0), base)]
334
+ rows.append([Paragraph(number or "&nbsp;", num_style), *cells] if numbers else cells)
335
+ widths = [num_w, self.avail - num_w] if numbers else [self.avail]
336
+ table = Table(rows, colWidths=widths, splitByRow=1, spaceAfter=10)
337
+ pad_left = 8 if numbers else 12
338
+ style = [
339
+ ("BACKGROUND", (0, 0), (-1, -1), CODE_BG),
340
+ ("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (-1, -1), 12),
341
+ ("TOPPADDING", (0, 0), (-1, -1), 0), ("BOTTOMPADDING", (0, 0), (-1, -1), 0),
342
+ ("TOPPADDING", (0, 0), (-1, 0), 9), ("BOTTOMPADDING", (0, -1), (-1, -1), 9),
343
+ ("LEFTPADDING", (-1, 0), (-1, -1), pad_left + (4 if numbers else 0)),
344
+ ("ROUNDEDCORNERS", [5, 5, 5, 5]),
345
+ ]
346
+ if numbers:
347
+ style.append(("RIGHTPADDING", (0, 0), (0, -1), 0))
348
+ table.setStyle(TableStyle(style))
349
+ parts: list = []
350
+ if label:
351
+ self._counters["lst"] += 1
352
+ tag = f"Listing {self._tag()}{self._counters['lst']}"
353
+ parts.append(Paragraph(f'<font color="#4F46E5">{tag}</font>&nbsp;&nbsp;{inline(label, "HeadM", 8.4)}',
354
+ self.S["code_label"]))
355
+ parts.append(table)
356
+ return self._add_kept(parts, len(rows) <= 28)
357
+
358
+ def table(self, header: list[str], rows: list[list[str]], aligns: list[str] | None = None,
359
+ caption: str | None = None) -> "Book":
360
+ cols = len(header)
361
+ aligns = (aligns or []) + ["left"] * cols
362
+ amap = {"left": TA_LEFT, "center": TA_CENTER, "right": TA_RIGHT}
363
+ th = [ParagraphStyle(f"th{i}", parent=self.S["th"], alignment=amap[aligns[i]]) for i in range(cols)]
364
+ td = [ParagraphStyle(f"td{i}", parent=self.S["td"], alignment=amap[aligns[i]]) for i in range(cols)]
365
+ data = [[para(h, th[i]) for i, h in enumerate(header)]]
366
+ for row in rows:
367
+ row = (row + [""] * cols)[:cols]
368
+ data.append([para(cell, td[i]) for i, cell in enumerate(row)])
369
+ lengths = [max([len(header[i])] + [len(r[i]) if i < len(r) else 0 for r in rows]) for i in range(cols)]
370
+ weights = [max(6, min(n, 60)) ** 0.8 for n in lengths]
371
+ widths = [self.avail * w / sum(weights) for w in weights]
372
+ table = Table(data, colWidths=widths, repeatRows=1, splitByRow=1, spaceAfter=10, spaceBefore=4)
373
+ table.setStyle(TableStyle([
374
+ ("BACKGROUND", (0, 0), (-1, 0), INDIGO_DARK),
375
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [white, ZEBRA]),
376
+ ("LINEBELOW", (0, 1), (-1, -1), 0.4, RULE),
377
+ ("BOX", (0, 0), (-1, -1), 0.6, RULE),
378
+ ("VALIGN", (0, 0), (-1, 0), "MIDDLE"), ("VALIGN", (0, 1), (-1, -1), "TOP"),
379
+ ("LEFTPADDING", (0, 0), (-1, -1), 7), ("RIGHTPADDING", (0, 0), (-1, -1), 7),
380
+ ("TOPPADDING", (0, 0), (-1, -1), 4.5), ("BOTTOMPADDING", (0, 0), (-1, -1), 4.5),
381
+ ]))
382
+ parts: list = []
383
+ if caption:
384
+ self._counters["tbl"] += 1
385
+ tag = f"Table {self._tag()}{self._counters['tbl']}"
386
+ cap = ParagraphStyle("tcap", parent=self.S["code_label"], fontSize=9, spaceBefore=8)
387
+ parts.append(Paragraph(f'<font color="#4F46E5">{tag}</font>&nbsp;&nbsp;{inline(caption, "HeadM", 9)}', cap))
388
+ parts.append(table)
389
+ return self._add_kept(parts, len(data) <= 18)
390
+
391
+ def figure(self, path: str | Path, caption: str = "") -> "Book":
392
+ try:
393
+ px_w, px_h = ImageReader(str(path)).getSize()
394
+ except Exception:
395
+ return self.para(f"*[image not found: {Path(path).name}]*")
396
+ width = min(self.avail, px_w * 0.75)
397
+ height = width * px_h / px_w
398
+ limit = FRAME_H * 0.62
399
+ if height > limit:
400
+ width, height = width * limit / height, limit
401
+ parts: list = [Image(str(path), width, height, hAlign="CENTER")]
402
+ self._counters["fig"] += 1
403
+ if caption:
404
+ tag = f"Figure {self._tag()}{self._counters['fig']}"
405
+ parts.append(Paragraph(f'<font name="HeadM" color="#4F46E5">{tag}</font>&nbsp;&nbsp;'
406
+ f"{inline(caption, 'Body-I', 9)}", self.S["caption"]))
407
+ return self._add_kept([Spacer(1, 4), *parts])
408
+
409
+ def _draw_cover(self, c, doc):
410
+ data = self._cover or {"title": self.title, "subtitle": "", "lines": []}
411
+ c.saveState()
412
+ clip = c.beginPath()
413
+ clip.rect(0, 0, PAGE_W, PAGE_H)
414
+ c.clipPath(clip, stroke=0, fill=0)
415
+ c.linearGradient(0, PAGE_H, PAGE_W, 0, (INDIGO_DARK, INDIGO, PURPLE), (0, 0.55, 1))
416
+ accents = [HexColor(h) for h in ("#A5B4FC", "#C4B5FD", "#F0ABFC", "#67E8F9", "#FDE68A")]
417
+ for row in range(12):
418
+ for col in range(22):
419
+ c.setFillColor(accents[(row * 3 + col) % len(accents)])
420
+ c.setFillAlpha(max(0.0, 0.5 - row * 0.045))
421
+ c.circle(26 + col * 27, 26 + row * 27, 2.1, stroke=0, fill=1)
422
+ c.setFillAlpha(1)
423
+ width = TEXT_W - 30
424
+ title = Paragraph(inline(data["title"], "HeadB", 36), self.S["cover_title"])
425
+ _, th = title.wrap(width, 400)
426
+ top = PAGE_H * 0.62
427
+ title.drawOn(c, (PAGE_W - width) / 2, top)
428
+ y = top - 18
429
+ c.setFillColor(HexColor("#F0ABFC"))
430
+ c.roundRect((PAGE_W - 64) / 2, y, 64, 4, 2, stroke=0, fill=1)
431
+ y -= 18
432
+ if data["subtitle"]:
433
+ sub = Paragraph(inline(data["subtitle"], "HeadL", 16), self.S["cover_sub"])
434
+ _, sh = sub.wrap(width, 300)
435
+ y -= sh
436
+ sub.drawOn(c, (PAGE_W - width) / 2, y)
437
+ meta_y = PAGE_H * 0.2 + 15 * len(data["lines"])
438
+ for line in data["lines"]:
439
+ meta = Paragraph(inline(line, "HeadM", 10), self.S["cover_meta"])
440
+ meta.wrap(width, 40)
441
+ meta.drawOn(c, (PAGE_W - width) / 2, meta_y)
442
+ meta_y -= 16
443
+ c.restoreState()
444
+
445
+ def _footer(self, c, doc):
446
+ text = str(doc.page)
447
+ w = max(28, stringWidth(text, "HeadM", 8.5) + 16)
448
+ c.saveState()
449
+ c.setFillColor(INDIGO_SOFT)
450
+ c.roundRect((PAGE_W - w) / 2, 30, w, 15, 7.5, stroke=0, fill=1)
451
+ c.setFillColor(INDIGO_DARK)
452
+ c.setFont("HeadM", 8.5)
453
+ c.drawCentredString(PAGE_W / 2, 34.6, text)
454
+ c.restoreState()
455
+
456
+ def _header(self, c, doc):
457
+ if doc.page in doc.opener_pages:
458
+ return
459
+ c.saveState()
460
+ y = PAGE_H - 44
461
+ c.setFont("HeadL", 8)
462
+ c.setFillColor(MUTED)
463
+ title = self.title
464
+ limit = TEXT_W * 0.55
465
+ while stringWidth(title, "HeadL", 8) > limit and len(title) > 4:
466
+ title = title[:-2].rstrip() + "…"
467
+ c.drawString(ML, y, title)
468
+ chapter = doc.chapter_name
469
+ if chapter:
470
+ while stringWidth(chapter, "HeadM", 8) > TEXT_W * 0.4 and len(chapter) > 4:
471
+ chapter = chapter[:-2].rstrip() + "…"
472
+ c.setFont("HeadM", 8)
473
+ c.setFillColor(INDIGO)
474
+ c.drawRightString(PAGE_W - MR, y, chapter)
475
+ c.setStrokeColor(RULE)
476
+ c.setLineWidth(0.8)
477
+ c.line(ML, y - 7, PAGE_W - MR, y - 7)
478
+ c.restoreState()
479
+
480
+ def _document(self) -> BaseDocTemplate:
481
+ book = self
482
+
483
+ class Doc(BaseDocTemplate):
484
+ def afterFlowable(self, flowable):
485
+ for f in _walk(flowable):
486
+ info = getattr(f, "toc", None)
487
+ if info is None:
488
+ continue
489
+ level, text, key = info
490
+ self.canv.bookmarkPage(key)
491
+ self.canv.addOutlineEntry(text, key, level, closed=0)
492
+ if level < 2:
493
+ self.notify("TOCEntry", (level, inline(text, "Body", 10), self.page, key))
494
+ if getattr(f, "is_chapter", False):
495
+ self.chapter_name = f.chapter_title
496
+ self.opener_pages.add(self.page)
497
+
498
+ doc = Doc(self.path, pagesize=(PAGE_W, PAGE_H), title=self.title, author=self.author,
499
+ leftMargin=ML, rightMargin=MR, topMargin=MT, bottomMargin=MB, creator="md2textbook")
500
+ doc.chapter_name = ""
501
+ doc.opener_pages = set()
502
+ frame = lambda: Frame(ML, MB, TEXT_W, FRAME_H, 0, 0, 0, 0, id="body")
503
+ doc.addPageTemplates([
504
+ PageTemplate("cover", [Frame(0, 0, PAGE_W, PAGE_H, id="cover")], onPage=book._draw_cover),
505
+ PageTemplate("front", [frame()], onPageEnd=book._footer),
506
+ PageTemplate("main", [frame()], onPageEnd=lambda c, d: (book._header(c, d), book._footer(c, d))),
507
+ ])
508
+ return doc
509
+
510
+ def build(self) -> str:
511
+ front: list = [NextPageTemplate("front"), PageBreak()]
512
+ if self._want_toc and self._headings:
513
+ toc = TableOfContents()
514
+ toc.levelStyles = [self.S["toc0"], self.S["toc1"]]
515
+ toc.dotsMinLevel = 0
516
+ front += [Paragraph("Contents", self.S["toc_title"]), toc]
517
+ front += [NextPageTemplate("main"), PageBreak()]
518
+ story = front + self._story
519
+ self._document().multiBuild(story)
520
+ return self.path
521
+
522
+
523
+ def _walk(flowable):
524
+ yield flowable
525
+ for child in getattr(flowable, "_content", None) or ():
526
+ yield from _walk(child)
527
+
528
+
529
+ def today() -> str:
530
+ return date.today().strftime("%B %d, %Y").replace(" 0", " ")