Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/hscript/pdf_renderer.py

231 lines
8.2 KiB
Python

"""رندر امن Report Spec به HTML و PDF (بدون اجرای اسکریپت دوباره در قالب)."""
from __future__ import annotations
import html
import logging
from typing import Any, Optional
from app.services.hscript.errors import TypeErrorHS
from app.services.hscript.report_builder import ALLOWED_BLOCK_TYPES, ALLOWED_CHART_TYPES
from app.services.hscript.number_format import format_number
logger = logging.getLogger(__name__)
def _esc(value: Any) -> str:
return html.escape("" if value is None else str(value), quote=True)
def _num_opts(spec: dict[str, Any]) -> dict[str, str]:
meta = spec.get("meta") if isinstance(spec.get("meta"), dict) else {}
nf = meta.get("number_format") if isinstance(meta.get("number_format"), dict) else {}
return {
"thousands_sep": str(nf.get("thousands_sep", ",") or ","),
"decimal_sep": str(nf.get("decimal_sep", ".") or "."),
}
def _format_value(value: Any, fmt: str | None = None, *, num_opts: dict[str, str] | None = None) -> str:
if value is None:
return "—"
if fmt:
try:
opts = num_opts or {}
return html.escape(
format_number(
value,
fmt,
thousands_sep=opts.get("thousands_sep", ","),
decimal_sep=opts.get("decimal_sep", "."),
),
quote=True,
)
except Exception:
return _esc(value)
return _esc(value)
def spec_to_html(spec: dict[str, Any], *, business_name: str | None = None) -> str:
"""تبدیل Spec ساختاریافته به HTML امن (escape کامل متن کاربر)."""
if not isinstance(spec, dict):
raise TypeErrorHS("spec باید dict باشد")
blocks = spec.get("blocks") or []
if not isinstance(blocks, list):
raise TypeErrorHS("blocks نامعتبر است")
title = spec.get("title") or "گزارش HScript"
parts: list[str] = [
"<!DOCTYPE html><html lang='fa' dir='rtl'><head><meta charset='utf-8'/>",
"<style>",
"@page { size: A4; margin: 16mm; }",
"body { font-family: Vazirmatn, Tahoma, sans-serif; color: #1a1a1a; font-size: 12px; }",
"h1 { font-size: 20px; margin: 0 0 12px; }",
"h2 { font-size: 16px; margin: 18px 0 8px; }",
".meta { color: #666; font-size: 11px; margin-bottom: 16px; }",
".kpi-row { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0; }",
".kpi { border: 1px solid #ddd; border-radius: 8px; padding: 10px 14px; min-width: 140px; }",
".kpi .label { color: #666; font-size: 11px; }",
".kpi .value { font-size: 18px; font-weight: 700; margin-top: 4px; }",
"table { width: 100%; border-collapse: collapse; margin: 10px 0 16px; }",
"th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: right; }",
"th { background: #f5f5f5; }",
".chart-box { border: 1px dashed #bbb; padding: 10px; margin: 10px 0; border-radius: 8px; }",
".chart-bar { display: flex; align-items: center; gap: 8px; margin: 4px 0; }",
".chart-bar .bar { height: 10px; background: #3b82f6; border-radius: 4px; }",
".page-break { page-break-after: always; }",
"p { margin: 6px 0 10px; line-height: 1.6; }",
"</style></head><body>",
f"<h1>{_esc(title)}</h1>",
]
if business_name:
parts.append(f"<div class='meta'>{_esc(business_name)}</div>")
num_opts = _num_opts(spec)
for block in blocks:
if not isinstance(block, dict):
continue
btype = str(block.get("type") or "")
if btype not in ALLOWED_BLOCK_TYPES:
continue
parts.append(_render_block_html(block, num_opts=num_opts))
parts.append("</body></html>")
return "".join(parts)
def _render_block_html(block: dict[str, Any], *, num_opts: dict[str, str] | None = None) -> str:
btype = str(block.get("type") or "")
if btype == "title":
# already rendered as doc title; skip duplicate or render as h1 if different
return f"<h1>{_esc(block.get('text'))}</h1>"
if btype == "heading":
level = int(block.get("level") or 2)
level = min(6, max(2, level))
return f"<h{level}>{_esc(block.get('text'))}</h{level}>"
if btype in ("paragraph", "text"):
return f"<p>{_esc(block.get('text'))}</p>"
if btype == "section":
return f"<h2>{_esc(block.get('title'))}</h2>"
if btype == "page_break":
return "<div class='page-break'></div>"
if btype == "row_break":
return "<div style='clear:both;height:8px'></div>"
if btype == "kpi":
return (
"<div class='kpi-row'><div class='kpi'>"
f"<div class='label'>{_esc(block.get('label'))}</div>"
f"<div class='value'>{_format_value(block.get('value'), block.get('format'), num_opts=num_opts)}</div>"
"</div></div>"
)
if btype == "card":
return (
"<div class='kpi-row'><div class='kpi'>"
f"<div class='label'>{_esc(block.get('title'))}</div>"
f"<div class='value'>{_format_value(block.get('value'), num_opts=num_opts)}</div>"
f"<div class='label'>{_esc(block.get('subtitle') or '')}</div>"
"</div></div>"
)
if btype == "table":
return _table_html(block, num_opts=num_opts)
if btype == "chart":
return _chart_html(block, num_opts=num_opts)
if btype == "gauge":
return (
"<div class='chart-box'>"
f"<strong>{_esc(block.get('label') or 'Gauge')}</strong>: "
f"{_format_value(block.get('value'), 'number', num_opts=num_opts)} "
f"(min {_format_value(block.get('min'), 'number', num_opts=num_opts)} / max {_format_value(block.get('max'), 'number', num_opts=num_opts)})"
"</div>"
)
if btype in ("qr", "barcode"):
return (
f"<div class='chart-box'><strong>{_esc(btype.upper())}</strong>: "
f"{_esc(block.get('data'))}"
f"{(' — ' + _esc(block.get('label'))) if block.get('label') else ''}"
"</div>"
)
if btype == "image":
# عمداً URL خام رندر نمی‌کنیم (SSRF/XSS) — فقط برچسب
return f"<div class='chart-box'>[تصویر: {_esc(block.get('alt') or block.get('label') or 'image')}]</div>"
if btype == "timeline":
return f"<div class='chart-box'>Timeline: {_esc(block.get('title') or '')}</div>"
return ""
def _table_html(block: dict[str, Any], *, num_opts: dict[str, str] | None = None) -> str:
cols = [str(c) for c in (block.get("columns") or [])][:50]
rows = block.get("rows") or []
if not isinstance(rows, list):
rows = []
formats = block.get("formats") if isinstance(block.get("formats"), dict) else {}
title = block.get("title")
out = []
if title:
out.append(f"<h2>{_esc(title)}</h2>")
out.append("<table><thead><tr>")
for c in cols:
out.append(f"<th>{_esc(c)}</th>")
out.append("</tr></thead><tbody>")
for row in rows[:5000]:
if not isinstance(row, dict):
continue
out.append("<tr>")
for c in cols:
fmt = formats.get(c) if formats else None
out.append(f"<td>{_format_value(row.get(c), fmt, num_opts=num_opts)}</td>")
out.append("</tr>")
out.append("</tbody></table>")
return "".join(out)
def _chart_html(block: dict[str, Any], *, num_opts: dict[str, str] | None = None) -> str:
chart_type = str(block.get("chart_type") or "bar")
if chart_type not in ALLOWED_CHART_TYPES:
chart_type = "bar"
title = block.get("title") or chart_type
data = block.get("data") or []
if not isinstance(data, list):
data = []
# برای PDF: نمایش میله‌ای ساده از مقادیر (بدون JS)
nums: list[tuple[str, float]] = []
for item in data[:100]:
if not isinstance(item, dict):
continue
if "label" in item and "value" in item:
label = str(item.get("label") or "")
try:
val = float(item.get("value") or 0)
except Exception:
val = 0.0
else:
label = str(item.get("x") or "")
try:
val = float(item.get("y") or 0)
except Exception:
val = 0.0
nums.append((label, val))
max_v = max((v for _, v in nums), default=1.0) or 1.0
parts = [f"<div class='chart-box'><strong>{_esc(title)}</strong>"]
for label, val in nums:
width = max(2, int(180 * (val / max_v)))
parts.append(
"<div class='chart-bar'>"
f"<span style='min-width:80px'>{_esc(label)}</span>"
f"<span class='bar' style='width:{width}px'></span>"
f"<span>{_format_value(val, 'number', num_opts=num_opts)}</span>"
"</div>"
)
parts.append("</div>")
return "".join(parts)
def render_spec_pdf(spec: dict[str, Any], *, business_name: str | None = None) -> bytes:
"""HTML امن → PDF با WeasyPrint."""
from weasyprint import HTML
from weasyprint.text.fonts import FontConfiguration
html_content = spec_to_html(spec, business_name=business_name)
font_config = FontConfiguration()
return HTML(string=html_content).write_pdf(font_config=font_config)