# ============================================================ # 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"]