@@ -0,0 +1,290 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user