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.
- md2textbook/__init__.py +4 -0
- md2textbook/__main__.py +5 -0
- md2textbook/builder.py +530 -0
- md2textbook/cli.py +179 -0
- md2textbook/fonts/Caladea-Bold.ttf +0 -0
- md2textbook/fonts/Caladea-BoldItalic.ttf +0 -0
- md2textbook/fonts/Caladea-Italic.ttf +0 -0
- md2textbook/fonts/Caladea-Regular.ttf +0 -0
- md2textbook/fonts/OFL-Caladea.txt +93 -0
- md2textbook/fonts/OFL-Poppins.txt +93 -0
- md2textbook/fonts/Poppins-Bold.ttf +0 -0
- md2textbook/fonts/Poppins-Light.ttf +0 -0
- md2textbook/fonts/Poppins-Medium.ttf +0 -0
- md2textbook/fonts/Poppins-Regular.ttf +0 -0
- md2textbook/fonts/Poppins-SemiBold.ttf +0 -0
- md2textbook/mathkit.py +99 -0
- md2textbook/parser.py +371 -0
- md2textbook/theme.py +241 -0
- md2textbook-0.1.0.dist-info/METADATA +110 -0
- md2textbook-0.1.0.dist-info/RECORD +26 -0
- md2textbook-0.1.0.dist-info/WHEEL +5 -0
- md2textbook-0.1.0.dist-info/entry_points.txt +5 -0
- md2textbook-0.1.0.dist-info/licenses/LICENSE +24 -0
- md2textbook-0.1.0.dist-info/licenses/src/md2textbook/fonts/OFL-Caladea.txt +93 -0
- md2textbook-0.1.0.dist-info/licenses/src/md2textbook/fonts/OFL-Poppins.txt +93 -0
- md2textbook-0.1.0.dist-info/top_level.txt +1 -0
md2textbook/__init__.py
ADDED
md2textbook/__main__.py
ADDED
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> '] if continuation else []
|
|
120
|
+
for color, text, italic in segments:
|
|
121
|
+
body = xml_escape(text).replace(" ", " ")
|
|
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 " "
|
|
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> ' 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 " ", 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> {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> {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> '
|
|
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", " ")
|