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 丢图框块,离线侧暂无等价手段),留待后续打磨。
This commit is contained in:
gjm committed 2026-10-16 23:07:23 +08:00
1 parent d4a9442e30
commit 6143bb9ab2
32 files changed
+2617 -77

No files matched your search

+285
View File
@@ -0,0 +1,285 @@
"""`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"]