213 lines
8.3 KiB
Python
213 lines
8.3 KiB
Python
# ============================================================
|
||
# PDF 图纸解析与预览(moduleId: domain-drawing-pdf, 可重生 ✅)
|
||
# R71.1 扩展:PDF 与 DXF 共用 drawing-master-candidates.v1 候选契约。
|
||
# 诚实边界:只提取文本/表格/页数,不做几何推断;数量/工时/资源保持 None。
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from collections import Counter
|
||
from datetime import UTC, datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from server.aps_domain.drawing_dxf import (
|
||
_ITEM_REF_RE,
|
||
_clean_text,
|
||
_field_candidate,
|
||
_file_digest,
|
||
_filename_metadata,
|
||
_stable_id,
|
||
)
|
||
|
||
MAX_PDF_BYTES = 50 * 1024 * 1024
|
||
MAX_PDF_PAGES = 200
|
||
MAX_PDF_TEXT_BLOCKS = 2_000
|
||
|
||
_TITLE_FIELD_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
||
("drawingTitle", re.compile(r"(?:TITLE|图名|图纸名称)\s*[::]?\s*(.+)", re.IGNORECASE)),
|
||
("material", re.compile(r"(?:MATERIAL|材料|材质)\s*[::]?\s*(.+)", re.IGNORECASE)),
|
||
("scale", re.compile(r"(?:SCALE|比例)\s*[::]?\s*(\d+\s*[::]\s*\d+)", re.IGNORECASE)),
|
||
("drawnBy", re.compile(r"(?:DRAWN BY|DESIGNED BY|设计|制图)\s*[::]?\s*(.+)", re.IGNORECASE)),
|
||
("date", re.compile(r"(?:DATE|日期)\s*[::]?\s*(\d{4}[-/.]\d{1,2}[-/.]\d{1,2})", re.IGNORECASE)),
|
||
)
|
||
|
||
|
||
def _utc_now() -> str:
|
||
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||
|
||
|
||
def _safe_path(path: str | Path) -> Path:
|
||
p = Path(path).expanduser().resolve(strict=True)
|
||
if not p.is_file() or p.suffix.lower() != ".pdf":
|
||
raise ValueError("Only an existing .pdf file can be inspected")
|
||
size = p.stat().st_size
|
||
if size <= 0:
|
||
raise ValueError("PDF file is empty")
|
||
if size > MAX_PDF_BYTES:
|
||
raise ValueError(f"PDF file exceeds {MAX_PDF_BYTES} bytes")
|
||
return p
|
||
|
||
|
||
def inspect_pdf(path: str | Path) -> dict[str, Any]:
|
||
"""Parse a PDF into the same stable review-only evidence contract as DXF."""
|
||
p = _safe_path(path)
|
||
digest = _file_digest(p)
|
||
try:
|
||
import pdfplumber
|
||
except ImportError as exc: # pragma: no cover - environment guard
|
||
raise RuntimeError("PDF parsing requires the 'pdfplumber' package") from exc
|
||
|
||
try:
|
||
with pdfplumber.open(p) as pdf:
|
||
page_count = len(pdf.pages)
|
||
if page_count > MAX_PDF_PAGES:
|
||
raise ValueError(f"PDF 页数超过 {MAX_PDF_PAGES} 页上限")
|
||
texts: list[dict[str, Any]] = []
|
||
tables: list[dict[str, Any]] = []
|
||
item_refs: list[dict[str, Any]] = []
|
||
stats: Counter[str] = Counter()
|
||
for page_no, page in enumerate(pdf.pages, start=1):
|
||
if page_no > MAX_PDF_PAGES:
|
||
break
|
||
raw = page.extract_text() or ""
|
||
for line_no, line in enumerate(_clean_text(raw).splitlines()):
|
||
if len(texts) >= MAX_PDF_TEXT_BLOCKS:
|
||
break
|
||
texts.append({
|
||
"text": line,
|
||
"page": page_no,
|
||
"line": line_no + 1,
|
||
"evidence": {"page": page_no, "line": line_no + 1, "source": "text"},
|
||
})
|
||
for match in _ITEM_REF_RE.finditer(line):
|
||
item_refs.append({
|
||
"reference": match.group(0),
|
||
"page": page_no,
|
||
"line": line_no + 1,
|
||
"confidence": 0.62,
|
||
"evidence": {"page": page_no, "line": line_no + 1, "text": line},
|
||
})
|
||
try:
|
||
extracted_tables = page.extract_tables() or []
|
||
except Exception: # noqa: BLE001 - 表结构不稳定的 PDF 不阻断文本解析
|
||
extracted_tables = []
|
||
for table in extracted_tables:
|
||
tables.append({
|
||
"page": page_no,
|
||
"rows": len(table),
|
||
"columns": max((len(row) for row in table), default=0),
|
||
})
|
||
stats["page"] = page_count
|
||
stats["text_block"] = len(texts)
|
||
stats["table"] = len(tables)
|
||
except ValueError:
|
||
raise
|
||
except Exception as exc:
|
||
raise ValueError(f"Invalid or unsupported PDF: {exc}") from exc
|
||
|
||
name_meta = _filename_metadata(p)
|
||
drawing_no = str(name_meta.get("drawingNumber") or p.stem)
|
||
revision = name_meta.get("revision")
|
||
text_blob = "\n".join(str(row.get("text") or "") for row in texts)
|
||
in_text = drawing_no in text_blob
|
||
field_candidates = [
|
||
_field_candidate(
|
||
"drawingNumber", drawing_no, 0.93 if in_text else 0.62,
|
||
{"filename": p.name, "source": "filename+text" if in_text else "filename", "textFound": in_text},
|
||
review_required=not in_text,
|
||
),
|
||
_field_candidate(
|
||
"revision", revision, 0.90 if revision else 0.40,
|
||
{"filename": p.name, "source": "filename"},
|
||
review_required=not revision,
|
||
),
|
||
]
|
||
for field, pattern in _TITLE_FIELD_PATTERNS:
|
||
match = pattern.search(text_blob)
|
||
if not match:
|
||
continue
|
||
value = match.group(1).strip() if match.lastindex else match.group(0).strip()
|
||
field_candidates.append(
|
||
_field_candidate(field, value, 0.70, {"source": "text", "text": match.group(0)},
|
||
review_required=True)
|
||
)
|
||
|
||
asset_id = _stable_id("drawing", digest)
|
||
parsed = {
|
||
"asset": {
|
||
"id": asset_id,
|
||
"kind": "PDF",
|
||
"filename": p.name,
|
||
"sourcePath": str(p),
|
||
"sha256": digest,
|
||
"sizeBytes": p.stat().st_size,
|
||
"mimeType": "application/pdf",
|
||
},
|
||
"drawing": {
|
||
"drawingNumber": drawing_no,
|
||
"revision": revision,
|
||
"pageCount": page_count,
|
||
"modelspaceEntityCount": None,
|
||
"paperSpaceEntityCount": None,
|
||
"metadata": {"source": "pdf", "pageCount": page_count},
|
||
},
|
||
"fieldCandidates": field_candidates,
|
||
"texts": texts,
|
||
"itemReferences": item_refs,
|
||
"dimensions": [],
|
||
"layers": [],
|
||
"entityStatistics": dict(stats),
|
||
"tables": tables,
|
||
"warnings": [
|
||
"PDF 文本与表格仅为候选证据,数量/单位/工时/资源不得从表格自动落库。",
|
||
"扫描件/图片型 PDF 无文本层时,需要真实 OCR(R71.6)接入。",
|
||
],
|
||
"parsedAt": _utc_now(),
|
||
}
|
||
from server.aps_domain.drawing_process import recognize_process_candidates
|
||
process = recognize_process_candidates(parsed)
|
||
for detail in process.get("details") or []:
|
||
parsed["fieldCandidates"].append(_field_candidate(
|
||
detail["field"], detail["value"], float(detail["confidence"]),
|
||
detail["evidence"], review_required=True,
|
||
))
|
||
return parsed
|
||
|
||
|
||
def render_pdf_preview(path: str | Path, *, scale: float = 1.5, page_index: int = 0) -> dict[str, Any]:
|
||
"""Render the first PDF page as a PNG data URL for the drawing canvas."""
|
||
p = _safe_path(path)
|
||
try:
|
||
import base64
|
||
import io
|
||
|
||
import pypdfium2 as pdfium
|
||
except ImportError as exc: # pragma: no cover - environment guard
|
||
raise RuntimeError("PDF preview requires the 'pypdfium2' package") from exc
|
||
try:
|
||
document = pdfium.PdfDocument(str(p))
|
||
if len(document) == 0:
|
||
raise ValueError("PDF 没有可渲染页面")
|
||
index = max(0, min(int(page_index), len(document) - 1))
|
||
bitmap = document[index].render(scale=max(0.25, float(scale)))
|
||
image = bitmap.to_pil().convert("RGB")
|
||
buffer = io.BytesIO()
|
||
image.save(buffer, format="PNG")
|
||
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
|
||
return {
|
||
"mimeType": "image/png",
|
||
"dataUrl": f"data:image/png;base64,{encoded}",
|
||
"pageCount": len(document),
|
||
"pageIndex": index,
|
||
"width": image.width,
|
||
"height": image.height,
|
||
}
|
||
except ValueError:
|
||
raise
|
||
except Exception as exc:
|
||
raise ValueError(f"PDF 预览渲染失败:{exc}") from exc
|
||
|
||
|
||
__all__ = ["inspect_pdf", "render_pdf_preview"]
|