#!/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"\1", text) text = re.sub(r"`([^`]+)`", r'\1', 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())