Files
2026-07-17 12:57:18 +03:00

291 lines
8.1 KiB
Python

#!/usr/bin/env python3
"""Convert ROADMAP.md to PDF (Cyrillic-safe via reportlab)."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from xml.sax.saxutils import escape
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, Preformatted, SimpleDocTemplate, Spacer, Table, TableStyle
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.services.pdf_cyrillic_fonts import ( # noqa: E402
register_cyrillic_font,
register_cyrillic_font_bold,
)
def md_inline(text: str) -> str:
text = escape(text)
text = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", text)
text = re.sub(r"`([^`]+)`", r'<font face="Courier">\1</font>', text)
return text
def build_styles(font: str, bold_font: str):
styles = getSampleStyleSheet()
styles.add(
ParagraphStyle(
name="RoadmapTitle",
parent=styles["Title"],
fontName=bold_font,
fontSize=18,
leading=22,
spaceAfter=10,
)
)
styles.add(
ParagraphStyle(
name="RoadmapH2",
parent=styles["Heading2"],
fontName=bold_font,
fontSize=13,
leading=16,
spaceBefore=12,
spaceAfter=6,
)
)
styles.add(
ParagraphStyle(
name="RoadmapH3",
parent=styles["Heading3"],
fontName=bold_font,
fontSize=11,
leading=14,
spaceBefore=8,
spaceAfter=4,
)
)
styles.add(
ParagraphStyle(
name="RoadmapBody",
parent=styles["Normal"],
fontName=font,
fontSize=9,
leading=12,
spaceAfter=4,
)
)
styles.add(
ParagraphStyle(
name="RoadmapQuote",
parent=styles["Normal"],
fontName=font,
fontSize=9,
leading=12,
leftIndent=12,
textColor=colors.HexColor("#374151"),
spaceAfter=6,
)
)
styles.add(
ParagraphStyle(
name="RoadmapBullet",
parent=styles["Normal"],
fontName=font,
fontSize=9,
leading=12,
leftIndent=14,
bulletIndent=6,
spaceAfter=2,
)
)
styles.add(
ParagraphStyle(
name="RoadmapCode",
parent=styles["Code"],
fontName="Courier",
fontSize=7.5,
leading=9.5,
backColor=colors.HexColor("#f3f4f6"),
borderPadding=4,
spaceAfter=6,
)
)
styles.add(
ParagraphStyle(
name="RoadmapTableCell",
parent=styles["Normal"],
fontName=font,
fontSize=8,
leading=10,
)
)
styles.add(
ParagraphStyle(
name="RoadmapTableHeader",
parent=styles["Normal"],
fontName=bold_font,
fontSize=8,
leading=10,
)
)
return styles
def parse_table(lines: list[str], start: int) -> tuple[list[list[str]], int]:
rows: list[list[str]] = []
i = start
while i < len(lines):
line = lines[i].strip()
if not line.startswith("|"):
break
if re.match(r"^\|[-:\s|]+\|$", line):
i += 1
continue
cells = [c.strip() for c in line.strip("|").split("|")]
rows.append(cells)
i += 1
return rows, i
def table_flowable(rows: list[list[str]], styles, usable_width: float):
if not rows:
return None
ncols = max(len(r) for r in rows)
col_width = usable_width / ncols
data = []
for ri, row in enumerate(rows):
style = styles["RoadmapTableHeader"] if ri == 0 else styles["RoadmapTableCell"]
padded = row + [""] * (ncols - len(row))
data.append([Paragraph(md_inline(c), style) for c in padded])
tbl = Table(data, colWidths=[col_width] * ncols, repeatRows=1)
tbl.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#d1d5db")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]
)
)
return tbl
def roadmap_to_pdf(md_path: Path, pdf_path: Path) -> None:
font = register_cyrillic_font()
bold_font = register_cyrillic_font_bold()
styles = build_styles(font, bold_font)
margin = 14 * mm
page_width, _ = A4
usable_width = page_width - 2 * margin
text = md_path.read_text(encoding="utf-8")
lines = text.splitlines()
story = []
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
if not stripped:
i += 1
continue
if stripped == "---":
story.append(Spacer(1, 4))
i += 1
continue
if stripped.startswith("```"):
code_lines = []
i += 1
while i < len(lines) and not lines[i].strip().startswith("```"):
code_lines.append(lines[i])
i += 1
if i < len(lines):
i += 1
code_text = escape("\n".join(code_lines))
story.append(Preformatted(code_text, styles["RoadmapCode"]))
continue
if stripped.startswith("|"):
rows, i = parse_table(lines, i)
tbl = table_flowable(rows, styles, usable_width)
if tbl:
story.append(tbl)
story.append(Spacer(1, 6))
continue
if stripped.startswith("# "):
story.append(Paragraph(md_inline(stripped[2:]), styles["RoadmapTitle"]))
i += 1
continue
if stripped.startswith("## "):
story.append(Paragraph(md_inline(stripped[3:]), styles["RoadmapH2"]))
i += 1
continue
if stripped.startswith("### "):
story.append(Paragraph(md_inline(stripped[4:]), styles["RoadmapH3"]))
i += 1
continue
if stripped.startswith("> "):
story.append(Paragraph(md_inline(stripped[2:]), styles["RoadmapQuote"]))
i += 1
continue
if re.match(r"^[-*]\s+\[[ x]\]", stripped):
content = re.sub(r"^[-*]\s+\[[ x]\]\s*", "", stripped)
story.append(Paragraph(f"• {md_inline(content)}", styles["RoadmapBullet"]))
i += 1
continue
if stripped.startswith("- ") or stripped.startswith("* "):
story.append(Paragraph(f"• {md_inline(stripped[2:])}", styles["RoadmapBullet"]))
i += 1
continue
if re.match(r"^\d+\.\s", stripped):
story.append(Paragraph(md_inline(stripped), styles["RoadmapBullet"]))
i += 1
continue
story.append(Paragraph(md_inline(stripped), styles["RoadmapBody"]))
i += 1
pdf_path.parent.mkdir(parents=True, exist_ok=True)
doc = SimpleDocTemplate(
str(pdf_path),
pagesize=A4,
leftMargin=margin,
rightMargin=margin,
topMargin=14 * mm,
bottomMargin=14 * mm,
title="WESP — дорожная карта разработки",
)
doc.build(story)
def main() -> int:
root = Path(__file__).resolve().parents[1]
md_path = root / "docs" / "ROADMAP.md"
out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.home() / "Downloads" / "WESP-ROADMAP.pdf"
if not md_path.is_file():
print(f"Not found: {md_path}", file=sys.stderr)
return 1
roadmap_to_pdf(md_path, out)
print(out)
return 0
if __name__ == "__main__":
raise SystemExit(main())