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 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
432
+ )
433
+ content = content.replace("\n", "<br/>").replace(" ", "&nbsp;")
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)]