#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Генерация PDF сметы с корректной кириллицей. Читает JSON со структурой сметы из stdin, пишет PDF в stdout. Использование: echo '' | python generate_estimate_pdf.py > smeta.pdf или вызов из Node.js с pipe. """ import json import sys import os import html from io import BytesIO from reportlab.lib import colors from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import mm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak def find_cyrillic_font(): """Найти TTF с поддержкой кириллицы: fonts/ рядом со скриптом или Windows Arial.""" script_dir = os.path.dirname(os.path.abspath(__file__)) candidates_regular = [ os.path.join(script_dir, "fonts", "PTSans-Regular.ttf"), os.path.join(script_dir, "fonts", "pt-sans.regular.ttf"), os.path.join(script_dir, "fonts", "arial.ttf"), "C:\\Windows\\Fonts\\arial.ttf", "C:\\Windows\\Fonts\\times.ttf", ] candidates_bold = [ os.path.join(script_dir, "fonts", "PTSans-Bold.ttf"), os.path.join(script_dir, "fonts", "arialbd.ttf"), "C:\\Windows\\Fonts\\arialbd.ttf", ] regular = None bold = None for p in candidates_regular: if os.path.isfile(p): regular = p break for p in candidates_bold: if os.path.isfile(p): bold = p break fonts_dir = os.path.join(script_dir, "fonts") if not regular and os.path.isdir(fonts_dir): for f in os.listdir(fonts_dir): if not f.lower().endswith(".ttf"): continue p = os.path.join(fonts_dir, f) if "bold" in f.lower() or "700" in f or "bold" in p.lower(): bold = bold or p else: regular = regular or p return regular, bold def register_fonts(): regular, bold = find_cyrillic_font() if not regular: raise FileNotFoundError( "Шрифт с кириллицей не найден. Положите TTF (например PTSans-Regular.ttf, Arial) в папку pdf_generator/fonts/" ) pdfmetrics.registerFont(TTFont("Cyrillic", regular)) if bold and bold != regular: pdfmetrics.registerFont(TTFont("CyrillicBold", bold)) else: pdfmetrics.registerFont(TTFont("CyrillicBold", regular)) return "Cyrillic", "CyrillicBold" def num_fmt(value): try: return f"{float(value):,.2f}".replace(",", " ").replace(".", ",") except (TypeError, ValueError): return str(value) def currency_fmt(value): try: return f"{float(value):,.2f} руб.".replace(",", " ").replace(".", ",") except (TypeError, ValueError): return str(value) def build_pdf(data, font_name, font_bold): buffer = BytesIO() doc = SimpleDocTemplate( buffer, pagesize=A4, leftMargin=15 * mm, rightMargin=15 * mm, topMargin=15 * mm, bottomMargin=15 * mm, ) story = [] # Стили style_center = ParagraphStyle( name="Center", fontName=font_bold, fontSize=14, alignment=1, spaceAfter=6, ) style_center_small = ParagraphStyle( name="CenterSmall", fontName=font_name, fontSize=11, alignment=1, spaceAfter=4, ) style_normal = ParagraphStyle( name="Normal", fontName=font_name, fontSize=10, spaceAfter=4, ) style_cell = ParagraphStyle( name="TableCell", fontName=font_name, fontSize=8, leading=9, leftIndent=0, rightIndent=0, wordWrap="CJK", ) number = data.get("number", "") direction_name = (data.get("direction") or {}).get("name", "") object_name = data.get("objectName", "") customer = data.get("customer", "") executor = data.get("executor", "") story.append(Paragraph(f"Исполнительная смета №{number}", style_center)) story.append(Paragraph(f"{direction_name} на объекте:", style_center_small)) story.append(Paragraph(f"«{object_name}»", style_center_small)) story.append(Spacer(1, 6 * mm)) story.append(Paragraph(f"Наименование организации Заказчика: «{customer}»", style_normal)) story.append(Paragraph(f"Наименование организации Исполнителя: «{executor}»", style_normal)) story.append(Spacer(1, 6 * mm)) # Таблица: позиции по разделам col_widths = [25, 180, 90, 45, 45, 75] headers = ["№", "Наименование работ", "Обоснование", "Цена", "Объем", "Стоимость"] section_names = { "field": "Полевые работы", "office": "Камеральные работы", "laboratory": "Лабораторные работы", "other": "", } items = data.get("items") or [] by_section = {} for it in items: sec = it.get("sectionType") or "other" by_section.setdefault(sec, []).append(it) for sec_key in ("field", "office", "laboratory", "other"): sec_items = by_section.get(sec_key, []) if not sec_items: continue title = section_names.get(sec_key) if title: story.append(Paragraph(title, ParagraphStyle(name="Sec", fontName=font_bold, fontSize=9, spaceAfter=2))) table_data = [headers] for it in sec_items: raw_name = (it.get("workName") or "").strip() safe_name = html.escape(raw_name) wname_para = Paragraph(safe_name, style_cell) just_text = (it.get("justification") or "")[:25] table_data.append([ str(it.get("orderNumber", "")), wname_para, just_text, num_fmt(it.get("basePrice")), num_fmt(it.get("quantity")), num_fmt(it.get("totalPrice")), ]) t = Table(table_data, colWidths=col_widths) t.setStyle(TableStyle([ ("FONTNAME", (0, 0), (-1, 0), font_bold), ("FONTSIZE", (0, 0), (-1, -1), 8), ("FONTNAME", (0, 1), (-1, -1), font_name), ("ALIGN", (0, 0), (0, -1), "CENTER"), ("ALIGN", (3, 0), (-1, -1), "RIGHT"), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("GRID", (0, 0), (-1, -1), 0.5, colors.black), ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey), ])) story.append(t) story.append(Spacer(1, 4 * mm)) # Итоги totals = data.get("totals") or [] if totals: story.append(Spacer(1, 4 * mm)) for tot in totals: label = tot.get("label", "") desc = tot.get("description") if desc: label = f"{label} ({desc})" val = currency_fmt(tot.get("resultValue")) story.append(Paragraph(f"{label}: {val}", style_normal)) story.append(Spacer(1, 10 * mm)) story.append(Paragraph("Выполнил: _____________________", style_normal)) doc.build(story) return buffer.getvalue() def main(): try: font_name, font_bold = register_fonts() except FileNotFoundError as e: sys.stderr.write(str(e) + "\n") sys.exit(1) try: if len(sys.argv) >= 2: with open(sys.argv[1], "r", encoding="utf-8") as f: data = json.load(f) else: raw = sys.stdin.buffer.read() if hasattr(raw, "decode"): raw = raw.decode("utf-8") data = json.loads(raw) except Exception as e: sys.stderr.write(f"JSON error: {e}\n") sys.exit(2) try: pdf_bytes = build_pdf(data, font_name, font_bold) except Exception as e: sys.stderr.write(f"PDF error: {e}\n") sys.exit(3) sys.stdout.buffer.write(pdf_bytes) if __name__ == "__main__": main()