287 lines
9.6 KiB
Python
287 lines
9.6 KiB
Python
"""Генерира docs/razdeljane-na-sekcii.pdf от съседния .md (кирилица, Calibri)."""
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from reportlab.lib.colors import HexColor
|
|
from reportlab.lib.enums import TA_JUSTIFY, TA_LEFT
|
|
from reportlab.lib.pagesizes import A4
|
|
from reportlab.lib.styles import ParagraphStyle
|
|
from reportlab.lib.units import mm
|
|
from reportlab.pdfbase import pdfmetrics
|
|
from reportlab.pdfbase.ttfonts import TTFont
|
|
from reportlab.platypus import (
|
|
HRFlowable,
|
|
ListFlowable,
|
|
ListItem,
|
|
Paragraph,
|
|
Preformatted,
|
|
SimpleDocTemplate,
|
|
Spacer,
|
|
Table,
|
|
TableStyle,
|
|
)
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
MD_PATH = HERE / "razdeljane-na-sekcii.md"
|
|
PDF_PATH = HERE / "razdeljane-na-sekcii.pdf"
|
|
|
|
FONT_DIR = Path(r"C:\Windows\Fonts")
|
|
BODY = "Calibri"
|
|
MONO = "ConsolasDoc"
|
|
|
|
|
|
def _register_fonts() -> None:
|
|
pairs = [
|
|
(BODY, "", "calibri.ttf"),
|
|
(BODY, "Bold", "calibrib.ttf"),
|
|
(BODY, "Italic", "calibrii.ttf"),
|
|
(BODY, "BoldItalic", "calibriz.ttf"),
|
|
]
|
|
for family, face, fname in pairs:
|
|
path = FONT_DIR / fname
|
|
if not path.is_file():
|
|
raise SystemExit(f"Липсва шрифт: {path}")
|
|
name = family if not face else f"{family}-{face}"
|
|
pdfmetrics.registerFont(TTFont(name, str(path)))
|
|
pdfmetrics.registerFontFamily(
|
|
BODY,
|
|
normal=BODY,
|
|
bold=f"{BODY}-Bold",
|
|
italic=f"{BODY}-Italic",
|
|
boldItalic=f"{BODY}-BoldItalic",
|
|
)
|
|
consola = FONT_DIR / "consola.ttf"
|
|
if consola.is_file():
|
|
pdfmetrics.registerFont(TTFont(MONO, str(consola)))
|
|
else:
|
|
pdfmetrics.registerFont(TTFont(MONO, str(FONT_DIR / "arial.ttf")))
|
|
|
|
|
|
def _inline(text: str) -> str:
|
|
parts: list[str] = []
|
|
i = 0
|
|
code_re = re.compile(r"`([^`]+)`")
|
|
bold_re = re.compile(r"\*\*([^*]+)\*\*")
|
|
while i < len(text):
|
|
m_code = code_re.search(text, i)
|
|
m_bold = bold_re.search(text, i)
|
|
candidates = [m for m in (m_code, m_bold) if m]
|
|
if not candidates:
|
|
parts.append(html.escape(text[i:]))
|
|
break
|
|
m = min(candidates, key=lambda x: x.start())
|
|
parts.append(html.escape(text[i:m.start()]))
|
|
if m is m_code:
|
|
inner = html.escape(m.group(1))
|
|
parts.append(f'<font name="{MONO}" size="8">{inner}</font>')
|
|
else:
|
|
parts.append(f"<b>{html.escape(m.group(1))}</b>")
|
|
i = m.end()
|
|
return "".join(parts)
|
|
|
|
|
|
def _styles() -> dict[str, ParagraphStyle]:
|
|
accent = HexColor("#1e4a8c")
|
|
muted = HexColor("#4a4a58")
|
|
text = HexColor("#1a1a1f")
|
|
return {
|
|
"h1": ParagraphStyle(
|
|
"H1", fontName=f"{BODY}-Bold", fontSize=16, leading=20,
|
|
textColor=accent, spaceAfter=10, spaceBefore=0,
|
|
),
|
|
"h2": ParagraphStyle(
|
|
"H2", fontName=f"{BODY}-Bold", fontSize=13, leading=17,
|
|
textColor=accent, spaceBefore=12, spaceAfter=6,
|
|
),
|
|
"h3": ParagraphStyle(
|
|
"H3", fontName=f"{BODY}-Bold", fontSize=11.5, leading=15,
|
|
textColor=HexColor("#2d5fb0"), spaceBefore=8, spaceAfter=4,
|
|
),
|
|
"body": ParagraphStyle(
|
|
"Body", fontName=BODY, fontSize=10, leading=14,
|
|
textColor=text, alignment=TA_JUSTIFY, spaceAfter=6,
|
|
),
|
|
"li": ParagraphStyle(
|
|
"Li", fontName=BODY, fontSize=10, leading=14,
|
|
textColor=text, alignment=TA_LEFT, leftIndent=4,
|
|
),
|
|
"code": ParagraphStyle(
|
|
"Code", fontName=MONO, fontSize=8, leading=11,
|
|
textColor=text, backColor=HexColor("#eef0f4"),
|
|
leftIndent=6, rightIndent=6, spaceBefore=4, spaceAfter=8,
|
|
),
|
|
"caption": ParagraphStyle(
|
|
"Cap", fontName=BODY, fontSize=8, leading=11,
|
|
textColor=muted, alignment=TA_LEFT, spaceAfter=10,
|
|
),
|
|
"th": ParagraphStyle(
|
|
"Th", fontName=f"{BODY}-Bold", fontSize=9, leading=12, textColor=text,
|
|
),
|
|
"td": ParagraphStyle(
|
|
"Td", fontName=BODY, fontSize=9, leading=12, textColor=text,
|
|
),
|
|
"footer": ParagraphStyle(
|
|
"Foot", fontName=BODY, fontSize=8, leading=10, textColor=muted,
|
|
),
|
|
}
|
|
|
|
|
|
def _split_table_row(line: str) -> list[str]:
|
|
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
return cells
|
|
|
|
|
|
def _is_table_sep(line: str) -> bool:
|
|
s = line.strip().strip("|").replace(" ", "")
|
|
return bool(s) and all(set(c) <= {"-", ":"} and "-" in c for c in s.split("|"))
|
|
|
|
|
|
def md_to_flowables(md: str, styles: dict[str, ParagraphStyle]) -> list:
|
|
lines = md.replace("\r\n", "\n").split("\n")
|
|
story: list = []
|
|
i = 0
|
|
n = len(lines)
|
|
|
|
def flush_para(buf: list[str]) -> None:
|
|
text = " ".join(x.strip() for x in buf if x.strip())
|
|
if text:
|
|
story.append(Paragraph(_inline(text), styles["body"]))
|
|
|
|
while i < n:
|
|
line = lines[i]
|
|
stripped = line.strip()
|
|
|
|
if stripped.startswith("```"):
|
|
i += 1
|
|
block: list[str] = []
|
|
while i < n and not lines[i].strip().startswith("```"):
|
|
block.append(lines[i])
|
|
i += 1
|
|
i += 1
|
|
story.append(Preformatted("\n".join(block) or " ", styles["code"]))
|
|
continue
|
|
|
|
if stripped == "---":
|
|
story.append(Spacer(1, 4))
|
|
story.append(HRFlowable(width="100%", thickness=0.4, color=HexColor("#b7c9e3")))
|
|
story.append(Spacer(1, 8))
|
|
i += 1
|
|
continue
|
|
|
|
if stripped.startswith("# "):
|
|
story.append(Paragraph(_inline(stripped[2:]), styles["h1"]))
|
|
i += 1
|
|
continue
|
|
if stripped.startswith("## "):
|
|
story.append(Paragraph(_inline(stripped[3:]), styles["h2"]))
|
|
i += 1
|
|
continue
|
|
if stripped.startswith("### "):
|
|
story.append(Paragraph(_inline(stripped[4:]), styles["h3"]))
|
|
i += 1
|
|
continue
|
|
|
|
if stripped.startswith("|") and i + 1 < n and _is_table_sep(lines[i + 1]):
|
|
headers = _split_table_row(stripped)
|
|
i += 2
|
|
rows = [[Paragraph(_inline(h), styles["th"]) for h in headers]]
|
|
while i < n and lines[i].strip().startswith("|"):
|
|
cells = _split_table_row(lines[i])
|
|
while len(cells) < len(headers):
|
|
cells.append("")
|
|
rows.append([Paragraph(_inline(c), styles["td"]) for c in cells[: len(headers)]])
|
|
i += 1
|
|
col_w = (A4[0] - 36 * mm) / max(len(headers), 1)
|
|
tbl = Table(rows, colWidths=[col_w] * len(headers), hAlign="LEFT")
|
|
tbl.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, 0), HexColor("#e8f0fa")),
|
|
("GRID", (0, 0), (-1, -1), 0.3, HexColor("#d8dce3")),
|
|
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
|
("LEFTPADDING", (0, 0), (-1, -1), 5),
|
|
("RIGHTPADDING", (0, 0), (-1, -1), 5),
|
|
("TOPPADDING", (0, 0), (-1, -1), 4),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
|
]))
|
|
story.append(tbl)
|
|
story.append(Spacer(1, 8))
|
|
continue
|
|
|
|
if stripped.startswith(("- ", "* ")):
|
|
items: list[ListItem] = []
|
|
while i < n and lines[i].strip().startswith(("- ", "* ")):
|
|
items.append(ListItem(Paragraph(_inline(lines[i].strip()[2:]), styles["li"])))
|
|
i += 1
|
|
story.append(ListFlowable(
|
|
items, bulletType="bullet", leftIndent=16, bulletFontName=BODY,
|
|
bulletFontSize=10, spaceAfter=6,
|
|
))
|
|
continue
|
|
|
|
if re.match(r"^\d+\.\s+", stripped):
|
|
items = []
|
|
while i < n and re.match(r"^\d+\.\s+", lines[i].strip()):
|
|
text = re.sub(r"^\d+\.\s+", "", lines[i].strip())
|
|
items.append(ListItem(Paragraph(_inline(text), styles["li"])))
|
|
i += 1
|
|
story.append(ListFlowable(
|
|
items, bulletType="1", leftIndent=18, bulletFontName=BODY,
|
|
bulletFontSize=10, spaceAfter=6,
|
|
))
|
|
continue
|
|
|
|
if not stripped:
|
|
i += 1
|
|
continue
|
|
|
|
buf = [line]
|
|
i += 1
|
|
while i < n:
|
|
nxt = lines[i]
|
|
ns = nxt.strip()
|
|
if (not ns or ns.startswith("#") or ns.startswith("|")
|
|
or ns.startswith("- ") or ns.startswith("* ")
|
|
or ns.startswith("```") or ns == "---"
|
|
or re.match(r"^\d+\.\s+", ns)):
|
|
break
|
|
buf.append(nxt)
|
|
i += 1
|
|
flush_para(buf)
|
|
|
|
return story
|
|
|
|
|
|
def _footer(canvas, doc) -> None:
|
|
canvas.saveState()
|
|
canvas.setFillColor(HexColor("#6a6a78"))
|
|
canvas.setFont(BODY, 8)
|
|
canvas.drawString(18 * mm, 12 * mm, "RIP Help System — разделяне на секции")
|
|
canvas.drawRightString(A4[0] - 18 * mm, 12 * mm, f"{doc.page}")
|
|
canvas.restoreState()
|
|
|
|
|
|
def build_pdf() -> Path:
|
|
_register_fonts()
|
|
md = MD_PATH.read_text(encoding="utf-8")
|
|
styles = _styles()
|
|
doc = SimpleDocTemplate(
|
|
str(PDF_PATH),
|
|
pagesize=A4,
|
|
leftMargin=18 * mm,
|
|
rightMargin=18 * mm,
|
|
topMargin=16 * mm,
|
|
bottomMargin=18 * mm,
|
|
title="Разделяне на произволен текст на секции",
|
|
author="RIP Help System",
|
|
)
|
|
story = md_to_flowables(md, styles)
|
|
doc.build(story, onFirstPage=_footer, onLaterPages=_footer)
|
|
return PDF_PATH
|
|
|
|
|
|
if __name__ == "__main__":
|
|
out = build_pdf()
|
|
print(out)
|