"""装箱单数据装配(纯逻辑,可单测)。 把 run_query 返回的行(list[dict])转换成 ReportBro 模板所需的数据结构。 排版策略:弃用 ReportBro 表格元素(其行高不自适应、行不自动堆叠), 改为「Python 端逐行计算高度 + 预展开为标量参数」,模板用纯文本元素 在精确坐标渲染。本模块负责: - 计算 box 头信息、合计件数 - 估算每行明细的高度(依据型号/位号等长字段的字符数),用于模板定位 模板与展示逻辑严格分离:合计/日期/行高在此算好,模板只渲染。 """ from __future__ import annotations import math import os import re from datetime import date, datetime from typing import Any MAX_ROWS = 8 # 模板预留的最大明细行数 # 1mm = 2.834645669pt(72/25.4),与 _build_template 的 PT 保持一致 PT = 2.834645669 # ---- 明细行高度估算:用 reportbro 同款 fpdf2 引擎实测字形宽度算换行数(方案B)---- # 对照 R04398_box1.pdf 真实渲染逆推出的关键事实: # * ReportBro 的有效换行宽度 = 整列宽 + ~1mm 容差(padding 不参与换行宽度计算)。 # 实证:model 行实测宽 127.5pt = 45mm = 列宽44 + 1mm;weihao 行 76.5pt = 27mm = 列宽26 + 1mm。 # * 行被贪心填满到该宽度,故行数 = ceil( 字形总宽 / (列宽 + 1mm) )。 # * 用 fpdf2 的 get_string_width 取真实轮廓宽度(与 reportbro 渲染同引擎、同字体 simhei), # 替代原先 fitz 测宽(fitz 比 reportbro 宽约 4~6%,会高估行数 → band 虚高 → 下边距异常)。 # * ceil 是行数的下界估计:绝不低估(→ 绝不裁切丢失数据);仅在极边界可能多估 1 行(仅留白,安全)。 _CJK_FONT_PATH = os.environ.get("REPORT_CJK_FONT", r"C:/Windows/Fonts/simhei.ttf") _SLACK_MM = 1.0 # ReportBro 允许行略微超出列宽约 1mm 不裁切 _FP = None # 惰性创建的 fpdf2 引擎单例(同款 simhei 字体,c_margin=0) def _fpdf_engine(font_size: float): """返回复用了 simhei 字体的 fpdf2 引擎;字体不可用则返回 None(走字符数回退)。""" global _FP if _FP is None: try: from fpdf import FPDF pdf = FPDF(unit="pt", format="A5", orientation="L") pdf.add_font("simhei", fname=_CJK_FONT_PATH) pdf.c_margin = 0 # 与 reportbro 一致:强制 c_margin=0,使换行宽度公式对齐 _FP = pdf except Exception: _FP = None return None _FP.set_font("simhei", size=font_size) return _FP def _text_width_pt(text: str, font_size: float) -> float: """字符串真实轮廓宽度(点)。引擎不可用时回退到字符数粗估。""" eng = _fpdf_engine(font_size) if eng is not None: return eng.get_string_width(text) cjk = sum(1 for c in text if ord(c) > 0x2E80) asc = len(text) - cjk return (cjk * 3.0 + asc * 1.6) / PT # mm → pt # 列宽(mm) 与左右 padding 合计(mm) 与字号(pt);须与 _build_template.COLS 对齐。 # 注意:换行宽度用「整列宽 + 1mm 容差」(见 _SLACK_MM),padding 不参与换行宽度计算。 _COL_METRICS = { "product_name": (30, 0.0, 8.5), "model": (44, 0.0, 8.5), "range_": (30, 0.0, 8.5), "weihao": (26, 0.0, 8.5), "remark": (30, 0.0, 8.5), } _LINE_MM = 2.82 # 单行实际行高(8.5pt @ lineSpacing=1.0,实测字形 2.82mm/行) def _wrap_lines(text: str, col_w_mm: float, pad_mm: float, font_size: float) -> int: """估算某字段在给定列宽下换行后的行数(与 reportbro 实际渲染行数一致)。 行数 = ceil( 字形总宽 / (列宽 + 1mm 容差) )。 pad_mm 当前不参与计算(reportbro 实测 padding 不进入换行宽度),保留形参以兼容调用点。 """ if not text: return 1 max_pt = (col_w_mm + _SLACK_MM) * PT w = _text_width_pt(text, font_size) return max(1, math.ceil(w / max_pt)) class EmptyBoxError(ValueError): """查询结果为空 —— 该排产号 + 箱号不存在或无明细。""" def _to_date(value: Any) -> str: """把 created_at(datetime/iso str)格式化为 YYYY-MM-DD;失败则原样返回。""" if value is None: return "" if isinstance(value, datetime): return value.strftime("%Y-%m-%d") if isinstance(value, date): return value.strftime("%Y-%m-%d") if isinstance(value, str): try: return datetime.fromisoformat(value.replace("Z", "+00:00")).strftime("%Y-%m-%d") except ValueError: return value[:10] return str(value) _CTRL_RE = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]") def _clean(value: Any) -> str: """None → 空串;其余去除首尾空白与控制字符。 含换行/制表符的脏数据(库里偶发)会让 ReportBro 在渲染时找不到 simhei 字形而抛 ``Character "\\n" is not included`` 错误。这里把 \\r\\n\\t 换成空格、 剔除其余 ASCII 控制字符,保证模板渲染稳定。 """ if value is None: return "" s = str(value) s = s.replace("\r", " ").replace("\n", " ").replace("\t", " ") s = _CTRL_RE.sub("", s) return s.strip() def _estimate_row_height(row: dict[str, Any]) -> float: """估算单行明细的高度(mm)。 各字段按真实字体宽度算换行数(见 _wrap_lines),取最大行数作为本行高度。 行高 = 行数 × 单行实际行高(2.82mm) + 1.0mm 底部留白。 配合 _build_template 中分隔线「画在行底、行距 1mm」的布局, 分隔线到上行文本底约 1mm、到下行文本顶约 1mm,间距均匀。 字体/列宽/padding 均与 _build_template 对齐,避免漂移。 """ max_lines = 1 for field, (cw, pad, fs) in _COL_METRICS.items(): max_lines = max(max_lines, _wrap_lines(_clean(row.get(field)), cw, pad, fs)) return 1.0 + max_lines * _LINE_MM def build_context(rows: list[dict[str, Any]]) -> dict[str, Any]: """把查询行装配成 ReportBro 模板数据。 :param rows: query.sql 的结果。 :return: dict,含箱头标量参数 + 预展开的明细行标量参数(r0_*..r7_*)+ 每行 y 坐标(row_y0..)+ 合计。 :raises EmptyBoxError: rows 为空。 """ if not rows: raise EmptyBoxError("找不到该箱:排产号与箱号无装箱明细,请核对参数。") first = rows[0] total_qty = sum(int(r.get("box_qty") or 0) for r in rows) ctx: dict[str, Any] = { "paichan_no": _clean(first.get("paichan_no")), "box_no": int(first.get("box_no") or 0), "pack_date": _to_date(first.get("created_at")).replace("-", "/"), "order_no": _clean(first.get("order_no")), "total_qty": total_qty, } # 预展开明细行:r{i}_{field},并在模板列出的行范围内填值(超出则留空)。 # 每行的 y 偏移由模板侧按预估行高累加;此处也输出每行高度供模板使用。 row_heights = [] for i in range(MAX_ROWS): if i < len(rows): r = rows[i] row_heights.append(_estimate_row_height(r)) else: row_heights.append(0.0) src = rows[i] if i < len(rows) else {} ctx[f"r{i}_seq"] = str(i + 1) if i < len(rows) else "" ctx[f"r{i}_product_name"] = _clean(src.get("product_name")) ctx[f"r{i}_model"] = _clean(src.get("model")) ctx[f"r{i}_range_"] = _clean(src.get("range_")) ctx[f"r{i}_qty"] = str(int(src.get("box_qty") or 0)) if i < len(rows) else "" ctx[f"r{i}_weihao"] = _clean(src.get("weihao")) ctx[f"r{i}_remark"] = _clean(src.get("remark")) # 该行是否有数据(模板用 printIf 控制空行不渲染) ctx[f"r{i}_show"] = "1" if i < len(rows) else "" ctx["row_heights"] = row_heights return ctx