corpdoc 0.4.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.
- corpdoc/__init__.py +19 -0
- corpdoc/api.py +480 -0
- corpdoc/canvas.py +230 -0
- corpdoc/cli.py +284 -0
- corpdoc/colors.py +173 -0
- corpdoc/flowables.py +407 -0
- corpdoc/i18n.py +59 -0
- corpdoc/logo.py +89 -0
- corpdoc/parser.py +214 -0
- corpdoc/styles.py +260 -0
- corpdoc-0.4.0.dist-info/METADATA +260 -0
- corpdoc-0.4.0.dist-info/RECORD +16 -0
- corpdoc-0.4.0.dist-info/WHEEL +5 -0
- corpdoc-0.4.0.dist-info/entry_points.txt +2 -0
- corpdoc-0.4.0.dist-info/licenses/LICENSE +21 -0
- corpdoc-0.4.0.dist-info/top_level.txt +1 -0
corpdoc/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CorpDoc — Professional Corporate PDF Generator from Markdown + Logo.
|
|
3
|
+
|
|
4
|
+
Turn a Markdown file and a corporate logo into a branded PDF with cover page,
|
|
5
|
+
version history, table of contents, headers, footers, and auto-styled tables.
|
|
6
|
+
|
|
7
|
+
Basic usage:
|
|
8
|
+
from corpdoc import CorpDoc
|
|
9
|
+
|
|
10
|
+
doc = CorpDoc(config='corpdoc.yml')
|
|
11
|
+
doc.render('report.md', output='report.pdf')
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__version__ = "0.4.0"
|
|
15
|
+
|
|
16
|
+
from corpdoc.api import CorpDoc
|
|
17
|
+
from corpdoc.colors import extract_colors_from_svg, assign_roles
|
|
18
|
+
|
|
19
|
+
__all__ = ["CorpDoc", "extract_colors_from_svg", "assign_roles", "__version__"]
|
corpdoc/api.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main CorpDoc API.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from corpdoc import CorpDoc
|
|
6
|
+
|
|
7
|
+
# From config file
|
|
8
|
+
doc = CorpDoc(config='corpdoc.yml')
|
|
9
|
+
doc.render('input.md', output='output.pdf')
|
|
10
|
+
|
|
11
|
+
# From explicit config
|
|
12
|
+
doc = CorpDoc(config={
|
|
13
|
+
'company': {'legal_name': 'Acme Inc.', 'tagline': 'acme | engineering'},
|
|
14
|
+
'logo': {'svg': 'logo.svg', 'png': 'logo.png'},
|
|
15
|
+
'footer': {'address': '...', 'fields': [...]},
|
|
16
|
+
})
|
|
17
|
+
doc.render('input.md', output='output.pdf')
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
from datetime import date
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from reportlab.lib.colors import HexColor, white
|
|
27
|
+
from reportlab.lib.pagesizes import A4, landscape
|
|
28
|
+
from reportlab.lib.styles import ParagraphStyle
|
|
29
|
+
from reportlab.lib.units import cm, mm
|
|
30
|
+
from reportlab.platypus import (
|
|
31
|
+
BaseDocTemplate,
|
|
32
|
+
Frame,
|
|
33
|
+
NextPageTemplate,
|
|
34
|
+
PageBreak,
|
|
35
|
+
PageTemplate,
|
|
36
|
+
Paragraph,
|
|
37
|
+
Spacer,
|
|
38
|
+
Table,
|
|
39
|
+
TableStyle,
|
|
40
|
+
)
|
|
41
|
+
from reportlab.platypus.tableofcontents import TableOfContents
|
|
42
|
+
|
|
43
|
+
from corpdoc.colors import extract_colors_from_svg, assign_roles
|
|
44
|
+
from corpdoc.parser import parse_frontmatter, parse_blocks, detect_language
|
|
45
|
+
from corpdoc.styles import build_styles
|
|
46
|
+
from corpdoc.flowables import HRLine, CoverPageFlowable
|
|
47
|
+
from corpdoc.canvas import CorpCanvas
|
|
48
|
+
from corpdoc.i18n import t
|
|
49
|
+
from corpdoc.logo import Logo
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CorpDocTemplate(BaseDocTemplate):
|
|
53
|
+
"""
|
|
54
|
+
BaseDocTemplate subclass with two page templates (portrait A4 and
|
|
55
|
+
landscape A4) so wide tables can auto-switch orientation mid-document.
|
|
56
|
+
Heading paragraphs are fed into the real TableOfContents flowable via
|
|
57
|
+
ReportLab's notify mechanism.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def afterFlowable(self, flowable):
|
|
61
|
+
if not isinstance(flowable, Paragraph):
|
|
62
|
+
return
|
|
63
|
+
sname = flowable.style.name
|
|
64
|
+
if sname == "CH1":
|
|
65
|
+
self.notify("TOCEntry", (0, flowable.getPlainText(), self.page))
|
|
66
|
+
elif sname == "CH2":
|
|
67
|
+
self.notify("TOCEntry", (1, flowable.getPlainText(), self.page))
|
|
68
|
+
elif sname == "CH3":
|
|
69
|
+
self.notify("TOCEntry", (2, flowable.getPlainText(), self.page))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CorpDoc:
|
|
73
|
+
"""
|
|
74
|
+
Main entry point for rendering Markdown to corporate PDF.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
config: Either a path to a YAML config file, or a dict with the
|
|
78
|
+
same structure.
|
|
79
|
+
|
|
80
|
+
The config must contain (at minimum):
|
|
81
|
+
company:
|
|
82
|
+
legal_name: str
|
|
83
|
+
tagline: str # shown in header
|
|
84
|
+
brand_split: int # chars to color in accent (default: 3)
|
|
85
|
+
logo:
|
|
86
|
+
svg: path # used for color extraction
|
|
87
|
+
png: path # used for PDF embedding
|
|
88
|
+
cover_scale: float # default: 1.0
|
|
89
|
+
header_scale: float # default: 0.45
|
|
90
|
+
footer:
|
|
91
|
+
address: str
|
|
92
|
+
fields: # list of {label, value} dicts
|
|
93
|
+
- label: Director
|
|
94
|
+
value: Name
|
|
95
|
+
defaults:
|
|
96
|
+
language: "auto"|en|es|de|fr # default: auto
|
|
97
|
+
version: "1.0"
|
|
98
|
+
author: str
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, config):
|
|
102
|
+
if isinstance(config, (str, Path)):
|
|
103
|
+
import yaml
|
|
104
|
+
|
|
105
|
+
with open(config, "r", encoding="utf-8") as f:
|
|
106
|
+
self.cfg = yaml.safe_load(f)
|
|
107
|
+
elif isinstance(config, dict):
|
|
108
|
+
self.cfg = config
|
|
109
|
+
else:
|
|
110
|
+
raise ValueError("config must be a path or a dict")
|
|
111
|
+
|
|
112
|
+
# Extract colors from SVG logo, then let any explicit `colors:` block
|
|
113
|
+
# in the config override the auto-derived values on a per-key basis.
|
|
114
|
+
svg_path = self.cfg.get("logo", {}).get("svg")
|
|
115
|
+
if svg_path and os.path.exists(svg_path):
|
|
116
|
+
raw = extract_colors_from_svg(svg_path)
|
|
117
|
+
self.colors = assign_roles(raw)
|
|
118
|
+
else:
|
|
119
|
+
self.colors = assign_roles(["#333333"])
|
|
120
|
+
|
|
121
|
+
explicit = self.cfg.get("colors") or {}
|
|
122
|
+
if explicit:
|
|
123
|
+
self.colors = {**self.colors, **explicit}
|
|
124
|
+
|
|
125
|
+
self.logo = Logo(self.cfg)
|
|
126
|
+
|
|
127
|
+
# Warn early if a logo was configured but nothing resolved
|
|
128
|
+
logo_cfg = self.cfg.get("logo", {}) or {}
|
|
129
|
+
if (logo_cfg.get("png") or logo_cfg.get("svg")) and not self.logo:
|
|
130
|
+
print(
|
|
131
|
+
"Warning: logo was configured but neither PNG nor SVG could be "
|
|
132
|
+
"loaded — PDF will render without logo.",
|
|
133
|
+
file=sys.stderr,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
self.styles = build_styles(self.colors)
|
|
137
|
+
|
|
138
|
+
def render(self, md_input, output="output.pdf"):
|
|
139
|
+
"""
|
|
140
|
+
Render a Markdown file or string to PDF.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
md_input: Path to .md file, or markdown text as string.
|
|
144
|
+
output: Output PDF path.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Path to the generated PDF.
|
|
148
|
+
"""
|
|
149
|
+
if os.path.exists(str(md_input)):
|
|
150
|
+
md_text = Path(md_input).read_text(encoding="utf-8")
|
|
151
|
+
else:
|
|
152
|
+
md_text = str(md_input)
|
|
153
|
+
|
|
154
|
+
meta, body = parse_frontmatter(md_text)
|
|
155
|
+
blocks = parse_blocks(body)
|
|
156
|
+
|
|
157
|
+
lang_cfg = self.cfg.get("defaults", {}).get("language", "auto")
|
|
158
|
+
lang = detect_language(body) if lang_cfg == "auto" else lang_cfg
|
|
159
|
+
|
|
160
|
+
title = self._extract_title(meta, blocks)
|
|
161
|
+
subtitle = self._extract_subtitle(meta, blocks)
|
|
162
|
+
|
|
163
|
+
left_m, right_m, top_m, bottom_m = 2 * cm, 2 * cm, 2.2 * cm, 3.2 * cm
|
|
164
|
+
portrait_size = A4
|
|
165
|
+
landscape_size = landscape(A4)
|
|
166
|
+
|
|
167
|
+
doc = CorpDocTemplate(
|
|
168
|
+
output,
|
|
169
|
+
pagesize=portrait_size,
|
|
170
|
+
leftMargin=left_m,
|
|
171
|
+
rightMargin=right_m,
|
|
172
|
+
topMargin=top_m,
|
|
173
|
+
bottomMargin=bottom_m,
|
|
174
|
+
title=title,
|
|
175
|
+
author=meta.get("autor", meta.get("author", "")),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def _make_frame(pw, ph, frame_id):
|
|
179
|
+
return Frame(
|
|
180
|
+
left_m,
|
|
181
|
+
bottom_m,
|
|
182
|
+
pw - left_m - right_m,
|
|
183
|
+
ph - top_m - bottom_m,
|
|
184
|
+
id=frame_id,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
doc.addPageTemplates(
|
|
188
|
+
[
|
|
189
|
+
PageTemplate(
|
|
190
|
+
id="portrait",
|
|
191
|
+
frames=[_make_frame(*portrait_size, "portrait_frame")],
|
|
192
|
+
pagesize=portrait_size,
|
|
193
|
+
),
|
|
194
|
+
PageTemplate(
|
|
195
|
+
id="landscape",
|
|
196
|
+
frames=[_make_frame(*landscape_size, "landscape_frame")],
|
|
197
|
+
pagesize=landscape_size,
|
|
198
|
+
),
|
|
199
|
+
]
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
elements = []
|
|
203
|
+
elements.extend(self._build_cover(title, subtitle, meta, lang))
|
|
204
|
+
elements.extend(self._build_version_history(meta, lang))
|
|
205
|
+
elements.extend(self._build_toc(lang))
|
|
206
|
+
elements.extend(self._build_body(blocks))
|
|
207
|
+
|
|
208
|
+
corp_data = self._build_corp_data(lang)
|
|
209
|
+
|
|
210
|
+
def canvas_maker(filename, pagesize=A4, **kw):
|
|
211
|
+
return CorpCanvas(filename, pagesize=pagesize, _corp=corp_data, **kw)
|
|
212
|
+
|
|
213
|
+
doc.build(elements, canvasmaker=canvas_maker)
|
|
214
|
+
return output
|
|
215
|
+
|
|
216
|
+
# ─────────────────────────────────────────────────
|
|
217
|
+
# Helpers
|
|
218
|
+
# ─────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _plain(xml_text):
|
|
222
|
+
"""Strip ReportLab XML tags to get plain text (for PDF metadata, TOC)."""
|
|
223
|
+
return re.sub(r"<[^>]+>", "", xml_text)
|
|
224
|
+
|
|
225
|
+
def _extract_title(self, meta, blocks):
|
|
226
|
+
t = meta.get("titulo", meta.get("title", ""))
|
|
227
|
+
if t:
|
|
228
|
+
return t
|
|
229
|
+
for b in blocks:
|
|
230
|
+
if b["type"] == "h" and b["level"] == 1:
|
|
231
|
+
return self._plain(b["text"])
|
|
232
|
+
for b in blocks:
|
|
233
|
+
if b["type"] == "h":
|
|
234
|
+
return self._plain(b["text"])
|
|
235
|
+
return "Document"
|
|
236
|
+
|
|
237
|
+
def _extract_subtitle(self, meta, blocks):
|
|
238
|
+
s = meta.get("subtitulo", meta.get("subtitle", ""))
|
|
239
|
+
if s:
|
|
240
|
+
return s
|
|
241
|
+
if len(blocks) >= 2:
|
|
242
|
+
if blocks[0]["type"] == "h" and blocks[0]["level"] == 1:
|
|
243
|
+
if blocks[1]["type"] == "h" and blocks[1]["level"] == 2:
|
|
244
|
+
return self._plain(blocks[1]["text"])
|
|
245
|
+
return ""
|
|
246
|
+
|
|
247
|
+
def _build_corp_data(self, lang):
|
|
248
|
+
company = self.cfg.get("company", {})
|
|
249
|
+
footer = self.cfg.get("footer", {})
|
|
250
|
+
logo = self.cfg.get("logo", {})
|
|
251
|
+
|
|
252
|
+
data = dict(self.colors)
|
|
253
|
+
data["logo"] = self.logo
|
|
254
|
+
data["lang"] = lang
|
|
255
|
+
data["brand_split"] = company.get("brand_split", 3)
|
|
256
|
+
data["tagline"] = company.get("tagline", "")
|
|
257
|
+
data["tagline_prefix"] = company.get("tagline_prefix", "")
|
|
258
|
+
data["company_legal"] = company.get("legal_name", "")
|
|
259
|
+
data["address"] = footer.get("address", "")
|
|
260
|
+
data["fields"] = footer.get("fields", [])
|
|
261
|
+
data["header_scale"] = logo.get("header_scale", 0.45)
|
|
262
|
+
return data
|
|
263
|
+
|
|
264
|
+
# ─────────────────────────────────────────────────
|
|
265
|
+
# Page builders
|
|
266
|
+
# ─────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
def _build_cover(self, title, subtitle, meta, lang):
|
|
269
|
+
cover_cfg = self.cfg.get("cover", {}) or {}
|
|
270
|
+
style = cover_cfg.get("style", "classic")
|
|
271
|
+
if style not in CoverPageFlowable.STYLES:
|
|
272
|
+
import sys
|
|
273
|
+
|
|
274
|
+
print(
|
|
275
|
+
f"Warning: unknown cover.style '{style}'. "
|
|
276
|
+
f"Falling back to 'classic'. Valid: {', '.join(CoverPageFlowable.STYLES)}",
|
|
277
|
+
file=sys.stderr,
|
|
278
|
+
)
|
|
279
|
+
style = "classic"
|
|
280
|
+
|
|
281
|
+
return [
|
|
282
|
+
CoverPageFlowable(
|
|
283
|
+
title=title,
|
|
284
|
+
subtitle=subtitle,
|
|
285
|
+
meta=meta,
|
|
286
|
+
lang=lang,
|
|
287
|
+
logo=self.logo,
|
|
288
|
+
cover_scale=self.cfg.get("logo", {}).get("cover_scale", 1.0),
|
|
289
|
+
colors=self.colors,
|
|
290
|
+
company=self.cfg.get("company", {}),
|
|
291
|
+
defaults=self.cfg.get("defaults", {}),
|
|
292
|
+
style=style,
|
|
293
|
+
cover_cfg=cover_cfg,
|
|
294
|
+
),
|
|
295
|
+
PageBreak(),
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
def _build_version_history(self, meta, lang):
|
|
299
|
+
els = []
|
|
300
|
+
c = self.colors
|
|
301
|
+
S = self.styles
|
|
302
|
+
|
|
303
|
+
h = t(lang, "version_history_headers")
|
|
304
|
+
els.append(Paragraph(t(lang, "version_history_title"), S["VerH"]))
|
|
305
|
+
|
|
306
|
+
defaults = self.cfg.get("defaults", {})
|
|
307
|
+
ver = meta.get("version", defaults.get("version", "1.0"))
|
|
308
|
+
fecha = meta.get("fecha", meta.get("date", str(date.today())))
|
|
309
|
+
author = meta.get("autor", meta.get("author", defaults.get("author", "")))
|
|
310
|
+
|
|
311
|
+
data = [
|
|
312
|
+
[
|
|
313
|
+
Paragraph(h[0], S["THead"]),
|
|
314
|
+
Paragraph(h[1], S["THead"]),
|
|
315
|
+
Paragraph(h[2], S["THead"]),
|
|
316
|
+
Paragraph(h[3], S["THead"]),
|
|
317
|
+
],
|
|
318
|
+
[
|
|
319
|
+
Paragraph(ver, S["TCell"]),
|
|
320
|
+
Paragraph(str(fecha), S["TCell"]),
|
|
321
|
+
Paragraph(author or "—", S["TCell"]),
|
|
322
|
+
Paragraph(t(lang, "initial_version"), S["TCell"]),
|
|
323
|
+
],
|
|
324
|
+
]
|
|
325
|
+
for _ in range(3):
|
|
326
|
+
data.append([Paragraph("", S["TCell"])] * 4)
|
|
327
|
+
|
|
328
|
+
avail = A4[0] - 4 * cm
|
|
329
|
+
tbl = Table(
|
|
330
|
+
data, colWidths=[avail * 0.12, avail * 0.18, avail * 0.25, avail * 0.45], repeatRows=1
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
tbl.setStyle(
|
|
334
|
+
TableStyle(
|
|
335
|
+
[
|
|
336
|
+
("BACKGROUND", (0, 0), (-1, 0), HexColor(c["primary"])),
|
|
337
|
+
("TEXTCOLOR", (0, 0), (-1, 0), white),
|
|
338
|
+
("GRID", (0, 0), (-1, -1), 0.5, HexColor("#cccccc")),
|
|
339
|
+
("BOX", (0, 0), (-1, -1), 1, HexColor(c["secondary"])),
|
|
340
|
+
("TOPPADDING", (0, 0), (-1, -1), 4),
|
|
341
|
+
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
|
342
|
+
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
|
343
|
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
|
344
|
+
]
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
els.append(tbl)
|
|
348
|
+
els.append(PageBreak())
|
|
349
|
+
return els
|
|
350
|
+
|
|
351
|
+
def _build_toc(self, lang):
|
|
352
|
+
c = self.colors
|
|
353
|
+
S = self.styles
|
|
354
|
+
|
|
355
|
+
toc = TableOfContents()
|
|
356
|
+
toc.levelStyles = [
|
|
357
|
+
ParagraphStyle(
|
|
358
|
+
"TOCEntry1",
|
|
359
|
+
fontName="Helvetica-Bold",
|
|
360
|
+
fontSize=11,
|
|
361
|
+
textColor=HexColor(c["primary"]),
|
|
362
|
+
leftIndent=0,
|
|
363
|
+
firstLineIndent=0,
|
|
364
|
+
spaceBefore=2 * mm,
|
|
365
|
+
spaceAfter=1 * mm,
|
|
366
|
+
),
|
|
367
|
+
ParagraphStyle(
|
|
368
|
+
"TOCEntry2",
|
|
369
|
+
fontName="Helvetica",
|
|
370
|
+
fontSize=10,
|
|
371
|
+
textColor=HexColor(c["text"]),
|
|
372
|
+
leftIndent=8 * mm,
|
|
373
|
+
firstLineIndent=0,
|
|
374
|
+
spaceAfter=0.5 * mm,
|
|
375
|
+
),
|
|
376
|
+
ParagraphStyle(
|
|
377
|
+
"TOCEntry3",
|
|
378
|
+
fontName="Helvetica",
|
|
379
|
+
fontSize=9,
|
|
380
|
+
textColor=HexColor("#666666"),
|
|
381
|
+
leftIndent=16 * mm,
|
|
382
|
+
firstLineIndent=0,
|
|
383
|
+
spaceAfter=0.5 * mm,
|
|
384
|
+
),
|
|
385
|
+
]
|
|
386
|
+
|
|
387
|
+
return [
|
|
388
|
+
Paragraph(t(lang, "toc_title"), S["CH1"]),
|
|
389
|
+
Spacer(1, 4 * mm),
|
|
390
|
+
toc,
|
|
391
|
+
Spacer(1, 4 * mm),
|
|
392
|
+
HRLine(100, 0.5, HexColor(c["secondary"]), 2 * mm, 4 * mm),
|
|
393
|
+
PageBreak(),
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
def _build_body(self, blocks):
|
|
397
|
+
els = []
|
|
398
|
+
S = self.styles
|
|
399
|
+
c = self.colors
|
|
400
|
+
|
|
401
|
+
for b in blocks:
|
|
402
|
+
tp = b["type"]
|
|
403
|
+
if tp == "h":
|
|
404
|
+
lvl = min(b["level"], 4)
|
|
405
|
+
els.append(Paragraph(b["text"], S[f"CH{lvl}"]))
|
|
406
|
+
if lvl == 1:
|
|
407
|
+
els.append(HRLine(35, 2, HexColor(c["accent"]), 0, 3 * mm))
|
|
408
|
+
elif tp == "p":
|
|
409
|
+
els.append(Paragraph(b["text"], S["CBody"]))
|
|
410
|
+
elif tp == "list":
|
|
411
|
+
for item in b["items"]:
|
|
412
|
+
text = item["text"] if isinstance(item, dict) else item
|
|
413
|
+
level = item.get("level", 0) if isinstance(item, dict) else 0
|
|
414
|
+
style = S["CBullet2"] if level > 0 else S["CBullet"]
|
|
415
|
+
els.append(Paragraph(f"• {text}", style))
|
|
416
|
+
els.append(Spacer(1, 2 * mm))
|
|
417
|
+
elif tp == "table":
|
|
418
|
+
if self._needs_landscape(b["rows"]):
|
|
419
|
+
els.append(NextPageTemplate("landscape"))
|
|
420
|
+
els.append(PageBreak())
|
|
421
|
+
els.extend(self._build_table(b["rows"], page_width=landscape(A4)[0]))
|
|
422
|
+
els.append(NextPageTemplate("portrait"))
|
|
423
|
+
els.append(PageBreak())
|
|
424
|
+
else:
|
|
425
|
+
els.extend(self._build_table(b["rows"]))
|
|
426
|
+
elif tp == "mermaid":
|
|
427
|
+
content = b["content"].replace("\n", "<br/>")
|
|
428
|
+
els.append(Paragraph(f"<b>[ Diagram ]</b><br/><br/>{content}", S["CMermaid"]))
|
|
429
|
+
elif tp == "code":
|
|
430
|
+
content = (
|
|
431
|
+
b["content"].replace("&", "&").replace("<", "<").replace(">", ">")
|
|
432
|
+
)
|
|
433
|
+
content = content.replace("\n", "<br/>").replace(" ", " ")
|
|
434
|
+
els.append(Paragraph(content, S["CCode"]))
|
|
435
|
+
elif tp == "hr":
|
|
436
|
+
els.append(HRLine(100, 0.5, HexColor(c["secondary"]), 4 * mm, 4 * mm))
|
|
437
|
+
return els
|
|
438
|
+
|
|
439
|
+
def _needs_landscape(self, rows):
|
|
440
|
+
"""
|
|
441
|
+
A table is rendered on a landscape page when its column count
|
|
442
|
+
exceeds the configured threshold (default: 8).
|
|
443
|
+
"""
|
|
444
|
+
threshold = self.cfg.get("tables", {}).get("landscape_threshold", 8)
|
|
445
|
+
return bool(rows) and len(rows[0]) >= threshold
|
|
446
|
+
|
|
447
|
+
def _build_table(self, rows, page_width=None):
|
|
448
|
+
if not rows:
|
|
449
|
+
return []
|
|
450
|
+
S = self.styles
|
|
451
|
+
c = self.colors
|
|
452
|
+
n = len(rows[0])
|
|
453
|
+
wide = n > 6
|
|
454
|
+
cs = S["TCellSm" if wide else "TCell"]
|
|
455
|
+
hs = S["THeadSm" if wide else "THead"]
|
|
456
|
+
|
|
457
|
+
data = [
|
|
458
|
+
[Paragraph(cell, hs if ri == 0 else cs) for cell in row] for ri, row in enumerate(rows)
|
|
459
|
+
]
|
|
460
|
+
|
|
461
|
+
pw = page_width if page_width is not None else A4[0]
|
|
462
|
+
avail = pw - 4 * cm
|
|
463
|
+
tbl = Table(data, colWidths=[avail / n] * n, repeatRows=1)
|
|
464
|
+
|
|
465
|
+
style = [
|
|
466
|
+
("BACKGROUND", (0, 0), (-1, 0), HexColor(c["primary"])),
|
|
467
|
+
("TEXTCOLOR", (0, 0), (-1, 0), white),
|
|
468
|
+
("GRID", (0, 0), (-1, -1), 0.5, HexColor("#cccccc")),
|
|
469
|
+
("BOX", (0, 0), (-1, -1), 0.8, HexColor(c["secondary"])),
|
|
470
|
+
("TOPPADDING", (0, 0), (-1, -1), 3),
|
|
471
|
+
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
|
|
472
|
+
("LEFTPADDING", (0, 0), (-1, -1), 4),
|
|
473
|
+
("RIGHTPADDING", (0, 0), (-1, -1), 4),
|
|
474
|
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
|
475
|
+
]
|
|
476
|
+
for i in range(1, len(data)):
|
|
477
|
+
if i % 2 == 0:
|
|
478
|
+
style.append(("BACKGROUND", (0, i), (-1, i), HexColor(c["lighter"])))
|
|
479
|
+
tbl.setStyle(TableStyle(style))
|
|
480
|
+
return [Spacer(1, 2 * mm), tbl, Spacer(1, 3 * mm)]
|