"""把投标文件稿(spec.json)渲染成 PDF,并回填响应清单里的位置(evals/check/GUIDELINES.md §5、§6)。 cd server && ../.venv/bin/python ../evals/check/render.py <投标文件编号> [...] # 需要 Gotenberg(:3109) - 每个分册一份 PDF:evals/out/check//<分册编号>.pdf; - `scan` 块单独成页,渲染后把那一页换成图片(没有文字层),用来检验扫描件的处理(C10); - 页眉写 volume.header(默认是本项目名称;注入 P06 时改成别的项目名称); - 渲染后用读标同一套切句找到每个响应块所在的页,写进 manifest.json 的 where(页码 + 片段)。 """ from __future__ import annotations import html import io import json import pathlib import sys from typing import Any import httpx import pypdfium2 as pdfium from bid_doctor.document.pipeline import text_layer ROOT = pathlib.Path(__file__).resolve().parent OUT = ROOT.parent / "out" / "check" GOTENBERG = "http://127.0.0.1:3109/forms/chromium/convert/html" SNIPPET = 24 # 回填位置时取块文字的前多少个字 CSS = """ @page { size: A4; margin: 22mm 18mm 20mm; } body { font-family: "Noto Serif CJK SC", "Noto Sans CJK SC", serif; font-size: 11pt; line-height: 1.75; margin: 0; } h1 { font-size: 17pt; text-align: center; margin: 0 0 12pt; } h2 { font-size: 14pt; margin: 14pt 0 6pt; } h3 { font-size: 12pt; margin: 10pt 0 4pt; } p { margin: 0 0 6pt; text-align: justify; } table { width: 100%; border-collapse: collapse; margin: 6pt 0 10pt; font-size: 10pt; } th, td { border: 0.6pt solid #333; padding: 3pt 4pt; vertical-align: top; } th { background: #f2f2f2; } .page { page-break-before: always; } .scan { page-break-before: always; page-break-after: always; padding: 24mm 10mm; border: 1pt solid #999; min-height: 150mm; } .scan h3 { text-align: center; } .sign { margin-top: 18pt; text-align: right; } """ def esc(s: Any) -> str: return html.escape(str(s if s is not None else "")) def block_html(b: dict[str, Any]) -> str: t = b.get("type") if t == "title": return f"

{esc(b['text'])}

" if t == "heading": level = int(b.get("level", 2)) cls = ' class="page"' if b.get("new_page") else "" return f"{esc(b['text'])}" if t == "para": return f"

{esc(b['text'])}

" if t == "sign": return "
" + "".join(f"

{esc(x)}

" for x in b["lines"]) + "
" if t == "table": head = "".join(f"{esc(h)}" for h in b.get("header") or []) rows = "".join("" + "".join(f"{esc(c)}" for c in r["cells"]) + "" for r in b["rows"]) title = f"

{esc(b['title'])}

" if b.get("title") else "" return f"{title}{head}{rows}
" if t == "scan": lines = "".join(f"

{esc(x)}

" for x in b.get("lines") or []) return f"

{esc(b['text'])}

{lines}
" raise ValueError(f"未知的块类型:{t}") def volume_html(spec: dict[str, Any], vol: dict[str, Any]) -> tuple[str, str]: body = "".join(block_html(b) for b in vol["blocks"]) header = esc(vol.get("header") or spec.get("project_name") or "") # 页眉用 Gotenberg 的 header.html 传,每页都有;读标切句会把各页重复的行认成页眉页脚带, # 查标的「别的项目名称」规则要专门扫这里(m3-plan §2.4) return ( f"{body}", header, ) def render_pdf(page_html: str, header: str) -> bytes: head = ( "" + esc(header) + "" ) files = [ ("files", ("index.html", page_html.encode(), "text/html")), ("files", ("header.html", head.encode(), "text/html")), ] data = {"marginTop": "0.9", "marginBottom": "0.8", "marginLeft": "0.7", "marginRight": "0.7"} r = httpx.post(GOTENBERG, files=files, data=data, timeout=120) r.raise_for_status() return r.content def rasterize(pdf: bytes, pages: set[int]) -> bytes: """把指定页(从 1 起)换成图片页:没有文字层,模拟扫描件。""" if not pages: return pdf src = pdfium.PdfDocument(pdf) out = pdfium.PdfDocument.new() for i in range(len(src)): if i + 1 in pages: img = src[i].render(scale=1.6).to_pil().convert("L") buf = io.BytesIO() img.save(buf, format="PDF", resolution=115) out.import_pages(pdfium.PdfDocument(buf.getvalue())) else: out.import_pages(src, [i]) buf = io.BytesIO() out.save(buf) return buf.getvalue() def page_texts(pdf: bytes) -> dict[int, str]: pages = text_layer.extract("B", pdf) return {p.page_no: text_layer.norm("".join(s.text for s in p.sentences)) for p in pages} def block_text(b: dict[str, Any], row: dict[str, Any] | None = None) -> str: if row is not None: # 定位用投标人写的那一格(anchor 指定;默认第 3 格「投标文件中的内容」),不用照抄的要求原文 cells = [str(c) for c in row["cells"]] at = row.get("anchor", 2 if len(cells) >= 3 else len(cells) - 1) return cells[at] if b.get("type") == "scan": return str(b["text"]) return str(b.get("text") or " ".join(b.get("lines") or [])) def locate(texts: dict[int, str], snippet: str, last: bool = False) -> int | None: """last:取最后一次出现的页(附件在文件末尾,名称常和前面格式文件的标题相同)。""" q = text_layer.norm(snippet)[:SNIPPET] hits = [p for p, t in texts.items() if q and q in t] return (hits[-1] if last else hits[0]) if hits else None def render(bid: str) -> None: d = OUT / bid spec = json.loads((d / "spec.json").read_text()) manifest_path = d / "manifest.json" manifest = json.loads(manifest_path.read_text()) if manifest_path.exists() else None where: dict[str, list[dict[str, Any]]] = {} problems: list[str] = [] for vol in spec["volumes"]: page_html, header = volume_html(spec, vol) pdf = render_pdf(page_html, header) texts = page_texts(pdf) scans = {p for b in vol["blocks"] if b.get("type") == "scan" and (p := locate(texts, b["text"], last=True))} located: list[tuple[list[str], str]] = [] for b in vol["blocks"]: if b.get("type") == "table": located += [(r.get("responds") or [], block_text(b, r)) for r in b["rows"]] else: located.append((b.get("responds") or [], block_text(b))) for units, snippet in located: if not units: continue page = locate(texts, snippet) if page is None: problems.append(f"{vol['id']}:找不到「{snippet[:SNIPPET]}」") continue for u in units: where.setdefault(u, []).append( {"volume": vol["id"], "p": page, "t": text_layer.norm(snippet)[:SNIPPET]} ) (d / f"{vol['id']}.pdf").write_bytes(rasterize(pdf, scans)) print(f"{bid}/{vol['id']}.pdf {len(texts)} 页,扫描页 {sorted(scans)}") if manifest is not None: for uid, entry in manifest["units"].items(): entry["where"] = where.get(uid, []) manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=1)) for p in problems: print(" ! " + p) if __name__ == "__main__": for b in sys.argv[1:]: render(b)