支持直接审查 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 丢图框块,离线侧暂无等价手段),留待后续打磨。
286 lines
10 KiB
Python
286 lines
10 KiB
Python
"""`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"]
|