291 lines
8.1 KiB
Python
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())
|