Files
cad-agent/tests/test_sources_pdf.py
T
gjm 6143bb9ab2 feat(review): PDF/图片离线源适配(C1)—— 无需 CAD 的整条审查链路
支持直接审查 PDF 与位图图纸,全链路离线(不连 CAD、不连 8080 端口)。
渲染在内存内完成,复用既有墨迹热力图 + 自适应瓦片规划。

- 新增 dwg_review/sources.py
  - detect_source / plan_file / collect 统一入口
  - PdfReader:pypdfium2 惰性导入,页序即图框序,处理 /Rotate 与 crop 边距
  - ImageReader:世界单位 = 源像素,snap_box 整数对齐保证窗口与像素严格一致
  - ink_grid:纯 PIL 灰度阈值 → 占用网格(不引入 numpy)
- ReviewContext.create(connect_cad=False):离线时 client/tools 均为 None
- 全链路 ctx.tools is None 守卫:review 只读子循环 / inspect_region / annotator /
  checks 的窗口类检查 / 绘图区最大化
- checklist.build_system_prompt(allow_tools=False, offline=True):离线提示词
- __main__:位置参数 source,按扩展名自动判定源类型并决定是否连 CAD
- config 新增 8 个常量(PDF_MAX_PAGES / 文本最小字数 / 各源缩放上限 / 墨迹阈值等)
- tiling.content_predicate 与 heatmap.cell_box 提升为公开函数

验证:
- pytest 457 passed
- verify_pdf_source.py:PDF 23/23、图片 9/9(不需 CAD),含实测瓦片字高像素与
  旋转映射自检(文本区墨迹 0.311 vs 忽略 /Rotate 反向对照 0.145)
- 真实 LLM 端到端:20 页 HVAC 图纸前 3 页跑通,结论具体可核验且未编造 handle

已知局限:页边线在墨迹网格里会把整页连成单簇,瓦片常退化为整页象限(CAD 侧
靠 drop_oversized 丢图框块,离线侧暂无等价手段),留待后续打磨。
2026-10-16 23:07:23 +08:00

286 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""`dwg_review.sources` 的 **PDF** 部分单测。
用手工构造的最小 PDF(`_build_pdf`)驱动,不依赖任何 PDF 生成库、不依赖外部样本文件。
真实工程图纸的端到端验收见仓库根 `verify_pdf_source.py`。
"""
import base64
import pytest
from dwg_review import config, sources
from tests.fakes import make_ctx
def _build_pdf(pages):
"""构造最小可解析 PDF。pages = [{"mediabox", "rotate", "text"}, ...]。"""
objs = [
b"<< /Type /Catalog /Pages 2 0 R >>",
None, # obj 2:Pages(末尾回填,需先知道各页 obj 号)
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
]
page_ids = []
for spec in pages:
mb = spec.get("mediabox", (0, 0, 200, 100))
rot = spec.get("rotate", 0)
text = spec.get("text", "HELLO")
parts = []
if spec.get("fill"):
# 整页填黑 → 墨迹铺满整页(用于「整页一个簇」的场景)
parts.append(
f"{mb[0]} {mb[1]} {mb[2] - mb[0]} {mb[3] - mb[1]} re f"
)
if text:
parts.append(f"BT /F1 12 Tf 20 40 Td ({text}) Tj ET")
stream = "\n".join(parts).encode()
page_id = len(objs) + 1
content_id = page_id + 1
page_ids.append(page_id)
objs.append(
(
f"<< /Type /Page /Parent 2 0 R "
f"/MediaBox [{mb[0]} {mb[1]} {mb[2]} {mb[3]}] /Rotate {rot} "
f"/Resources << /Font << /F1 3 0 R >> >> /Contents {content_id} 0 R >>"
).encode()
)
objs.append(
b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n"
+ stream + b"\nendstream"
)
kids = " ".join(f"{i} 0 R" for i in page_ids)
objs[1] = f"<< /Type /Pages /Kids [{kids}] /Count {len(page_ids)} >>".encode()
out = bytearray(b"%PDF-1.4\n")
offsets = []
for i, body in enumerate(objs, 1):
offsets.append(len(out))
out += f"{i} 0 obj\n".encode() + body + b"\nendobj\n"
xref_pos = len(out)
n = len(objs) + 1
out += f"xref\n0 {n}\n".encode() + b"0000000000 65535 f \n"
for off in offsets:
out += f"{off:010d} 00000 n \n".encode()
out += (
f"trailer\n<< /Size {n} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n"
).encode()
return bytes(out)
@pytest.fixture
def pdf_path(tmp_path):
"""把 `_build_pdf(pages)` 落盘,返回路径。"""
def _make(pages):
path = tmp_path / "mini.pdf"
path.write_bytes(_build_pdf(pages))
return str(path)
return _make
# ---------------------------------------------------------------------------
# PdfReader
# ---------------------------------------------------------------------------
def test_pdf_reader_frames_basic(pdf_path):
reader = sources.PdfReader(pdf_path([{}]))
try:
frames = reader.frames()
finally:
reader.close()
assert len(frames) == 1
assert frames[0]["bbox"] == [0.0, 0.0, 200.0, 100.0]
assert frames[0]["source_type"] == "pdf"
assert frames[0]["page_index"] == 0
assert frames[0]["sheet_scale"] == 1.0
def test_pdf_reader_frames_multipage_keeps_order(pdf_path):
reader = sources.PdfReader(
pdf_path([{"mediabox": (0, 0, 100, 50)}, {"mediabox": (0, 0, 300, 400)}])
)
try:
frames = reader.frames()
finally:
reader.close()
assert [f["page_index"] for f in frames] == [0, 1]
assert frames[1]["bbox"] == [0.0, 0.0, 300.0, 400.0]
def test_pdf_reader_rot90_swaps_page_size(pdf_path):
"""带 /Rotate 90 的页:世界尺寸应是 (MediaBox 高, MediaBox 宽)。"""
reader = sources.PdfReader(pdf_path([{"mediabox": (0, 0, 200, 100), "rotate": 90}]))
try:
frames = reader.frames()
finally:
reader.close()
assert frames[0]["bbox"] == [0.0, 0.0, 100.0, 200.0]
def test_pdf_reader_render_region_size(pdf_path):
reader = sources.PdfReader(pdf_path([{}]))
try:
out = reader.render((0, 0, 100, 50), 2.0)
finally:
reader.close()
assert out.size == (200, 100)
def test_pdf_reader_render_bad_box(pdf_path):
reader = sources.PdfReader(pdf_path([{}]))
try:
assert reader.render((0, 0, 0, 0), 2.0) is None
finally:
reader.close()
def test_pdf_reader_text_items(pdf_path):
reader = sources.PdfReader(pdf_path([{"text": "HELLO"}]))
try:
items = reader.text_items(0)
finally:
reader.close()
assert len(items) == 1
assert items[0]["content"] == "HELLO"
x0, y0, x1, y1 = items[0]["bbox"]
assert 0 <= x0 < x1 <= 200 and 0 <= y0 < y1 <= 100
def test_pdf_reader_text_items_min_chars(pdf_path):
"""短于 PDF_TEXT_MIN_CHARS 的片段被丢弃(滤噪)。"""
reader = sources.PdfReader(pdf_path([{"text": "AB"}]))
try:
assert reader.text_items(0, min_chars=8) == []
assert len(reader.text_items(0, min_chars=2)) == 1
finally:
reader.close()
def test_pdf_reader_text_items_respects_box_filter(pdf_path):
reader = sources.PdfReader(pdf_path([{"text": "HELLO"}]))
try:
# 文本在 y≈40~49,给一个完全错开的窗口 → 过滤掉
assert reader.text_items(0, box=(0, 80, 200, 100)) == []
finally:
reader.close()
def test_pdf_reader_text_items_rot90_maps_into_world(pdf_path):
reader = sources.PdfReader(pdf_path([{"mediabox": (0, 0, 200, 100), "rotate": 90}]))
try:
items = reader.text_items(0)
finally:
reader.close()
assert len(items) == 1
x0, y0, x1, y1 = items[0]["bbox"]
# 世界尺寸为 (100, 200)(旋转后),bbox 必须落在其中
assert 0 <= x0 < x1 <= 100.0
assert 0 <= y0 < y1 <= 200.0
def test_pdf_reader_empty_text_layer(pdf_path):
reader = sources.PdfReader(pdf_path([{"text": ""}]))
try:
assert reader.text_items(0) == []
finally:
reader.close()
# ---------------------------------------------------------------------------
# PDF 端到端(离线,plan + collect)
# ---------------------------------------------------------------------------
def test_pdf_plan_and_collect_end_to_end(pdf_path, monkeypatch):
monkeypatch.setattr(config, "TILING_ENABLED", True)
path = pdf_path([{"text": "HELLO"}])
ctx = make_ctx(connect_cad=False, source="pdf", source_path=path)
try:
plan = sources.plan_file(ctx)
assert plan["drawing_info"]["unit"] == "pt"
assert plan["drawing_info"]["page_count"] == 1
assert len(plan["frames"]) == 1
ev = sources.collect(ctx, 1, plan["frames"][0])
finally:
sources.close_reader(ctx)
assert ev["source_type"] == "pdf"
assert ev["window"] == {"min_x": 0.0, "min_y": 0.0, "max_x": 200.0, "max_y": 100.0}
assert ev["entities"] == {"total": 0, "items": []}
assert ev["texts"]["total"] == 1
assert ev["texts"]["items"][0]["content"] == "HELLO"
assert ev["images"] and ev["images"][0]["label"] == "overall"
assert base64.b64decode(ev["images"][0]["b64"]).startswith(b"\x89PNG")
def test_pdf_pdf_max_pages_caps_frames(pdf_path, monkeypatch):
monkeypatch.setattr(config, "PDF_MAX_PAGES", 1)
path = pdf_path([{}, {}])
ctx = make_ctx(connect_cad=False, source="pdf", source_path=path)
try:
plan = sources.plan_file(ctx)
finally:
sources.close_reader(ctx)
assert len(plan["frames"]) == 1
assert plan["drawing_info"]["page_count"] == 2
def test_pdf_blank_page_has_only_overall(pdf_path, monkeypatch):
"""无墨迹的页:墨迹网格为空 → 只出整体图(不凭空造瓦片)。"""
monkeypatch.setattr(config, "TILING_ENABLED", True)
path = pdf_path([{"text": ""}])
ctx = make_ctx(connect_cad=False, source="pdf", source_path=path)
try:
plan = sources.plan_file(ctx)
ev = sources.collect(ctx, 1, plan["frames"][0])
finally:
sources.close_reader(ctx)
assert [i["label"] for i in ev["images"]] == ["overall"]
def test_pdf_collect_multi_page_uses_own_page(pdf_path):
"""多页时每页取证必须渲染**自己的页**(页序不可错位)。"""
path = pdf_path(
[
{"mediabox": (0, 0, 200, 100), "text": "FIRST"},
{"mediabox": (0, 0, 400, 200), "text": "SECOND"},
]
)
ctx = make_ctx(connect_cad=False, source="pdf", source_path=path)
try:
plan = sources.plan_file(ctx)
ev2 = sources.collect(ctx, 2, plan["frames"][1])
finally:
sources.close_reader(ctx)
assert ev2["window"]["max_x"] == 400.0
assert ev2["texts"]["items"][0]["content"] == "SECOND"
def test_pdf_dedupes_tile_equal_to_overall(pdf_path, monkeypatch):
"""整页即一个簇且瓦片分辨率不更高时,不重复出一张与整体图相同的图。"""
monkeypatch.setattr(config, "TILING_ENABLED", True)
monkeypatch.setattr(config, "PDF_OVERVIEW_MAX_ZOOM", 4.0)
monkeypatch.setattr(config, "PDF_TILE_MAX_ZOOM", 4.0)
path = pdf_path([{"fill": True}]) # 墨迹铺满整页 → 整页即一个簇
ctx = make_ctx(connect_cad=False, source="pdf", source_path=path)
try:
plan = sources.plan_file(ctx)
ev = sources.collect(ctx, 1, plan["frames"][0])
finally:
sources.close_reader(ctx)
labels = [i["label"] for i in ev["images"]]
assert labels.count("overall") == 1
assert "tile-1" not in labels # 与整体图完全重合 → 省掉
def test_pdf_keeps_tile_when_higher_zoom_than_overall(pdf_path, monkeypatch):
"""瓦片分辨率**高于**整体图时不能去重(否则丢掉细节)。"""
monkeypatch.setattr(config, "TILING_ENABLED", True)
monkeypatch.setattr(config, "PDF_OVERVIEW_MAX_ZOOM", 1.0)
monkeypatch.setattr(config, "PDF_TILE_MAX_ZOOM", 6.0)
path = pdf_path([{"fill": True}])
ctx = make_ctx(connect_cad=False, source="pdf", source_path=path)
try:
plan = sources.plan_file(ctx)
ev = sources.collect(ctx, 1, plan["frames"][0])
finally:
sources.close_reader(ctx)
assert [i["label"] for i in ev["images"]] == ["overall", "tile-1"]