250 lines
8.4 KiB
Python
250 lines
8.4 KiB
Python
|
|
#!/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()
|