250 lines
8.4 KiB
Python
Executable File
250 lines
8.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Генерация PDF сметы с корректной кириллицей.
|
||
Читает JSON со структурой сметы из stdin, пишет PDF в stdout.
|
||
|
||
Использование:
|
||
echo '<json>' | 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()
|