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

163 lines
4.9 KiB
Python
Executable file

from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, Tuple
import datetime
import base64
import logging
from jinja2 import Environment, FileSystemLoader, select_autoescape
_env: Environment | None = None
def _get_templates_dir() -> Path:
# .../hesabixAPI/app/services/pdf/template_renderer.py
# -> .../hesabixAPI/templates
return Path(__file__).resolve().parents[3] / "templates"
def _filter_format_amount(value: Any, decimals: int = 0) -> str:
try:
num = float(value if value is not None else 0)
fmt = f"{{:,.{decimals}f}}"
return fmt.format(num)
except Exception:
return str(value if value is not None else "")
def _filter_ltr(text: Any) -> str:
s = "" if text is None else str(text)
# Wrap numeric text to ensure LTR rendering in RTL documents
return f'<span style="direction:ltr; unicode-bidi:plaintext; font-variant-numeric: tabular-nums">{s}</span>'
def _filter_format_date(value: Any, fmt: str = "%Y/%m/%d %H:%M") -> str:
try:
if isinstance(value, (datetime.date, datetime.datetime)):
dt = value
else:
# Try parse ISO-like strings
dt = datetime.datetime.fromisoformat(str(value))
return dt.strftime(fmt)
except Exception:
return str(value if value is not None else "")
def _filter_money(value: Any, decimals: int = 0, sep: str = ",") -> str:
"""فیلتر فرمت مبلغ - مشابه ReportTemplateService"""
try:
n = float(value if value is not None else 0)
if decimals <= 0:
s = f"{int(round(n)):,}"
else:
s = f"{n:,.{decimals}f}"
return s.replace(",", sep)
except Exception:
return str(value if value is not None else "")
def _filter_smart_number(value: Any, max_decimals: int = 2) -> str:
"""نمایش عدد با جداکننده هزارگان و حذف .0 های اضافی. None => رشته خالی"""
if value is None:
return ""
try:
n = float(value)
# If it's effectively an integer, show without decimals
if abs(n - round(n)) < 1e-9:
return f"{int(round(n)):,}"
# Otherwise show up to max_decimals, trimming trailing zeros
s = f"{n:,.{max_decimals}f}"
if "." in s:
s = s.rstrip("0").rstrip(".")
return s
except Exception:
# Non-numeric => string, but never "None"
return "" if value is None else str(value)
def load_farsi_font_data_uris() -> Tuple[str | None, str | None]:
"""Load preferred Persian fonts as data:font/ttf;base64 URIs.
Order:
- YekanBakhFaNum-Regular/Bold
- Vazirmatn-Regular/Bold (fallback)
"""
logger = logging.getLogger(__name__)
try:
project_root = Path(__file__).resolve().parents[4]
fonts_dir = project_root / "hesabixUI" / "hesabix_ui" / "assets" / "fonts"
regular_candidates = [
fonts_dir / "YekanBakhFaNum-Regular.ttf",
fonts_dir / "Vazirmatn-Regular.ttf",
]
bold_candidates = [
fonts_dir / "YekanBakhFaNum-Bold.ttf",
fonts_dir / "Vazirmatn-Bold.ttf",
]
reg = next((p for p in regular_candidates if p.is_file()), None)
bold = next((p for p in bold_candidates if p.is_file()), None)
logger.debug(
"load_farsi_font_data_uris: fonts_dir=%s regular=%s bold=%s",
fonts_dir,
str(reg) if reg else None,
str(bold) if bold else None,
)
reg_uri = None
bold_uri = None
if reg:
reg_uri = f"data:font/ttf;base64,{base64.b64encode(reg.read_bytes()).decode('ascii')}"
if bold:
bold_uri = f"data:font/ttf;base64,{base64.b64encode(bold.read_bytes()).decode('ascii')}"
logger.debug(
"load_farsi_font_data_uris: uri_lengths regular=%s bold=%s",
len(reg_uri or ""),
len(bold_uri or ""),
)
return reg_uri, bold_uri
except Exception:
logger.exception("load_farsi_font_data_uris: failed")
return None, None
def _create_env() -> Environment:
env = Environment(
loader=FileSystemLoader(str(_get_templates_dir())),
autoescape=select_autoescape(["html", "xml"]),
enable_async=False,
)
# Common filters
env.filters["format_amount"] = _filter_format_amount
env.filters["money"] = _filter_money # فیلتر money برای استفاده در قالب‌ها
env.filters["smart_number"] = _filter_smart_number
env.filters["ltr"] = _filter_ltr
env.filters["format_date"] = _filter_format_date
return env
def get_env() -> Environment:
global _env
if _env is None:
_env = _create_env()
return _env
def render_template(template_path: str, context: Dict[str, Any]) -> str:
"""
Render a repository template under templates/ directory with common filters.
Example: render_template("pdf/invoices/detail.html", ctx)
"""
enriched = dict(context or {})
if "brand_primary" not in enriched:
try:
from app.services.theme_brand_colors import resolve_theme_brand_context
enriched.update(resolve_theme_brand_context())
except Exception:
enriched.setdefault("brand_primary", "#0F4C81")
enriched.setdefault("brand_positive", "#2E7D32")
enriched.setdefault("brand_negative", "#B3261E")
enriched.setdefault("brand_warning", "#F0B92A")
enriched.setdefault("brand_secondary", "#5A6A7A")
template = get_env().get_template(template_path)
return template.render(**enriched)