698 lines
27 KiB
Python
698 lines
27 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
把患者首页 PDF 转为图片,并生成图片-结构化字段对照核验清单。
|
||
|
||
依赖系统命令 pdftoppm,通常由 poppler-utils 提供。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import html
|
||
import json
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from string import Template
|
||
from typing import Any
|
||
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||
DEFAULT_INPUT_DIR = PROJECT_ROOT / "待处理-患者首页PDF"
|
||
DEFAULT_RESULT_DIR = PROJECT_ROOT / "数据处理结果区"
|
||
DEFAULT_IMAGE_REVIEW_DIR = DEFAULT_RESULT_DIR / "06_PDF图片对照"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class FieldCheck:
|
||
group: str
|
||
key: str
|
||
level: str
|
||
location_hint: str
|
||
|
||
|
||
FIELD_CHECKS = [
|
||
FieldCheck("基本信息", "医疗机构", "recommended", "首页抬头及组织机构代码附近"),
|
||
FieldCheck("基本信息", "医疗付费方式", "recommended", "首页左上付费方式"),
|
||
FieldCheck("基本信息", "健康卡号", "recommended", "首页左上健康卡号"),
|
||
FieldCheck("基本信息", "住院次数", "recommended", "首页左上住院次数"),
|
||
FieldCheck("基本信息", "病案号", "required", "首页左上病案号"),
|
||
FieldCheck("基本信息", "姓名", "required", "基本信息行"),
|
||
FieldCheck("基本信息", "性别", "required", "基本信息行"),
|
||
FieldCheck("基本信息", "出生日期", "required", "基本信息行"),
|
||
FieldCheck("基本信息", "年龄", "required", "基本信息行"),
|
||
FieldCheck("基本信息", "国籍", "recommended", "基本信息行"),
|
||
FieldCheck("基本信息", "身份证号", "required", "基本信息行"),
|
||
FieldCheck("基本信息", "职业", "recommended", "基本信息行"),
|
||
FieldCheck("基本信息", "婚姻代码", "recommended", "基本信息行"),
|
||
FieldCheck("基本信息", "出生地", "recommended", "地址信息区"),
|
||
FieldCheck("基本信息", "籍贯", "recommended", "地址信息区"),
|
||
FieldCheck("基本信息", "民族", "recommended", "地址信息区"),
|
||
FieldCheck("地址联系人", "现住址", "required", "现住址行"),
|
||
FieldCheck("地址联系人", "现住址电话", "recommended", "现住址行"),
|
||
FieldCheck("地址联系人", "现住址邮编", "recommended", "现住址行"),
|
||
FieldCheck("地址联系人", "户口地址", "recommended", "户口地址行"),
|
||
FieldCheck("地址联系人", "户口地址邮编", "recommended", "户口地址行"),
|
||
FieldCheck("地址联系人", "工作单位及地址", "recommended", "工作单位及地址行"),
|
||
FieldCheck("地址联系人", "单位电话", "recommended", "工作单位及地址行"),
|
||
FieldCheck("地址联系人", "单位邮编", "recommended", "工作单位及地址行"),
|
||
FieldCheck("地址联系人", "联系人姓名", "required", "联系人信息行"),
|
||
FieldCheck("地址联系人", "联系人关系", "recommended", "联系人信息行"),
|
||
FieldCheck("地址联系人", "联系人地址", "recommended", "联系人信息行"),
|
||
FieldCheck("地址联系人", "联系人电话", "required", "联系人信息行"),
|
||
FieldCheck("入出院", "入院途径代码", "recommended", "入院途径勾选项"),
|
||
FieldCheck("入出院", "入院时间", "required", "入院记录行"),
|
||
FieldCheck("入出院", "入院科别", "required", "入院记录行"),
|
||
FieldCheck("入出院", "入院病房", "recommended", "入院记录行"),
|
||
FieldCheck("入出院", "转科科别", "optional", "转科科别行"),
|
||
FieldCheck("入出院", "出院时间", "required", "出院记录行"),
|
||
FieldCheck("入出院", "出院科别", "required", "出院记录行"),
|
||
FieldCheck("入出院", "出院病房", "recommended", "出院记录行"),
|
||
FieldCheck("入出院", "实际住院天数", "required", "出院记录行"),
|
||
FieldCheck("入出院", "大科室", "required", "由入院/出院科别映射"),
|
||
FieldCheck("诊断手术", "门急诊诊断", "recommended", "诊断区顶部"),
|
||
FieldCheck("诊断手术", "门急诊诊断编码", "recommended", "诊断区顶部"),
|
||
FieldCheck("诊断手术", "主要诊断名称", "required", "出院诊断表第一行"),
|
||
FieldCheck("诊断手术", "主要诊断编码", "required", "出院诊断表第一行"),
|
||
FieldCheck("诊断手术", "主要诊断入院病情", "required", "出院诊断表第一行"),
|
||
FieldCheck("诊断手术", "出院诊断", "recommended", "出院诊断表全部行"),
|
||
FieldCheck("诊断手术", "手术操作", "recommended", "手术及操作表"),
|
||
FieldCheck("诊断手术", "损伤中毒外部原因", "optional", "损伤中毒外部原因行"),
|
||
FieldCheck("诊断手术", "损伤中毒疾病编码", "optional", "损伤中毒外部原因行"),
|
||
FieldCheck("诊断手术", "病理诊断", "optional", "病理诊断行"),
|
||
FieldCheck("诊断手术", "病理诊断编码", "optional", "病理诊断行"),
|
||
FieldCheck("诊断手术", "病理号", "optional", "病理号"),
|
||
FieldCheck("质控信息", "药物过敏代码", "recommended", "药物过敏勾选项"),
|
||
FieldCheck("质控信息", "过敏药物", "optional", "过敏药物填写处"),
|
||
FieldCheck("质控信息", "死亡患者尸检代码", "recommended", "死亡患者尸检勾选项"),
|
||
FieldCheck("质控信息", "血型代码", "recommended", "血型勾选项"),
|
||
FieldCheck("质控信息", "Rh代码", "recommended", "Rh勾选项"),
|
||
FieldCheck("质控信息", "科主任", "recommended", "医师签名区"),
|
||
FieldCheck("质控信息", "主任副主任医师", "recommended", "医师签名区"),
|
||
FieldCheck("质控信息", "主治医师", "recommended", "医师签名区"),
|
||
FieldCheck("质控信息", "住院医师", "recommended", "医师签名区"),
|
||
FieldCheck("质控信息", "责任护士", "recommended", "护理签名区"),
|
||
FieldCheck("质控信息", "编码员", "recommended", "编码员签名区"),
|
||
FieldCheck("质控信息", "病案质量代码", "recommended", "病案质量勾选项"),
|
||
FieldCheck("质控信息", "质控医师", "recommended", "质控签名区"),
|
||
FieldCheck("质控信息", "质控护士", "recommended", "质控签名区"),
|
||
FieldCheck("质控信息", "质控日期", "recommended", "质控日期"),
|
||
FieldCheck("离院费用", "离院方式代码", "recommended", "离院方式勾选项"),
|
||
FieldCheck("离院费用", "出院31天内再住院计划代码", "recommended", "再住院计划勾选项"),
|
||
FieldCheck("离院费用", "再住院计划目的", "optional", "再住院计划目的"),
|
||
FieldCheck("离院费用", "入院前昏迷天数", "optional", "昏迷时间区"),
|
||
FieldCheck("离院费用", "入院后昏迷天数", "optional", "昏迷时间区"),
|
||
FieldCheck("离院费用", "总费用", "optional", "费用区"),
|
||
FieldCheck("离院费用", "自付金额", "optional", "费用区"),
|
||
FieldCheck("离院费用", "费用明细", "optional", "费用明细区"),
|
||
]
|
||
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(description="患者首页 PDF 转图片并生成字段对照核验报告")
|
||
parser.add_argument("-i", "--input-dir", type=Path, default=DEFAULT_INPUT_DIR, help="PDF 输入目录")
|
||
parser.add_argument("-r", "--result-dir", type=Path, default=DEFAULT_RESULT_DIR, help="解析结果根目录")
|
||
parser.add_argument("-o", "--output-dir", type=Path, default=DEFAULT_IMAGE_REVIEW_DIR, help="图片对照输出目录")
|
||
parser.add_argument("--dpi", type=int, default=180, help="图片分辨率,默认 180")
|
||
parser.add_argument("--format", choices=["png", "jpeg"], default="png", help="图片格式,默认 png")
|
||
parser.add_argument("--force", action="store_true", help="重新生成已存在图片")
|
||
parser.add_argument("--strict-recommended", action="store_true", help="把建议字段缺项也写入对照建议")
|
||
return parser.parse_args()
|
||
|
||
|
||
def run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
|
||
return subprocess.run(cmd, check=True, text=True, capture_output=True)
|
||
|
||
|
||
def numeric_page_key(path: Path) -> tuple[int, str]:
|
||
match = re.search(r"-(\d+)\.(png|jpe?g)$", path.name, flags=re.IGNORECASE)
|
||
if not match:
|
||
return (10**9, path.name)
|
||
return (int(match.group(1)), path.name)
|
||
|
||
|
||
def convert_pdf_to_images(pdf_path: Path, output_dir: Path, dpi: int, image_format: str, force: bool) -> tuple[list[Path], str]:
|
||
pdftoppm = shutil.which("pdftoppm")
|
||
if not pdftoppm:
|
||
return [], "未找到 pdftoppm,请安装 poppler-utils。"
|
||
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
extension = "jpg" if image_format == "jpeg" else "png"
|
||
existing = sorted(output_dir.glob(f"page-*.{extension}"), key=numeric_page_key)
|
||
if existing and not force:
|
||
return existing, ""
|
||
|
||
if force:
|
||
for old_image in output_dir.glob("page-*.*"):
|
||
old_image.unlink()
|
||
|
||
prefix = output_dir / "page"
|
||
cmd = [pdftoppm, "-r", str(dpi), f"-{image_format}", str(pdf_path), str(prefix)]
|
||
try:
|
||
run(cmd)
|
||
except subprocess.CalledProcessError as exc:
|
||
detail = exc.stderr.strip() or exc.stdout.strip() or str(exc)
|
||
return [], f"图片转换失败:{detail}"
|
||
|
||
images = sorted(output_dir.glob(f"page-*.{extension}"), key=numeric_page_key)
|
||
normalized_images: list[Path] = []
|
||
for index, image_path in enumerate(images, start=1):
|
||
target = output_dir / f"page-{index:03d}.{extension}"
|
||
if image_path != target:
|
||
if target.exists():
|
||
target.unlink()
|
||
image_path.rename(target)
|
||
normalized_images.append(target)
|
||
return normalized_images, "" if normalized_images else "图片转换后未找到输出文件。"
|
||
|
||
|
||
def load_record(result_dir: Path, pdf_path: Path) -> tuple[dict[str, Any], Path | None, str]:
|
||
json_path = result_dir / "02_单份JSON" / f"{pdf_path.stem}.json"
|
||
if not json_path.exists():
|
||
return {}, None, "未找到结构化 JSON,请先运行 02_解析入库 步骤。"
|
||
try:
|
||
return json.loads(json_path.read_text(encoding="utf-8")), json_path, ""
|
||
except json.JSONDecodeError as exc:
|
||
return {}, json_path, f"结构化 JSON 读取失败:{exc}"
|
||
|
||
|
||
def is_blank(value: Any) -> bool:
|
||
if value is None:
|
||
return True
|
||
if isinstance(value, str):
|
||
text = value.strip()
|
||
return text == "" or text in {"-", "--", "null", "None", "[]", "{}"}
|
||
if isinstance(value, (list, tuple, set, dict)):
|
||
return len(value) == 0
|
||
return False
|
||
|
||
|
||
def value_preview(value: Any, max_length: int = 120) -> str:
|
||
if value is None:
|
||
return ""
|
||
if isinstance(value, (list, dict)):
|
||
text = json.dumps(value, ensure_ascii=False)
|
||
else:
|
||
text = str(value)
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
if len(text) > max_length:
|
||
return text[: max_length - 1] + "…"
|
||
return text
|
||
|
||
|
||
def build_field_rows(record: dict[str, Any]) -> tuple[list[dict[str, str]], list[str], list[str]]:
|
||
rows: list[dict[str, str]] = []
|
||
missing_required: list[str] = []
|
||
missing_recommended: list[str] = []
|
||
|
||
for check in FIELD_CHECKS:
|
||
value = record.get(check.key)
|
||
missing = is_blank(value)
|
||
if missing and check.level == "required":
|
||
missing_required.append(check.key)
|
||
elif missing and check.level == "recommended":
|
||
missing_recommended.append(check.key)
|
||
rows.append(
|
||
{
|
||
"group": check.group,
|
||
"key": check.key,
|
||
"level": check.level,
|
||
"location_hint": check.location_hint,
|
||
"value": value_preview(value),
|
||
"missing": "是" if missing else "否",
|
||
}
|
||
)
|
||
|
||
return rows, missing_required, missing_recommended
|
||
|
||
|
||
def as_csv_text(value: Any) -> str:
|
||
if value is None:
|
||
return ""
|
||
if isinstance(value, (list, dict)):
|
||
return json.dumps(value, ensure_ascii=False)
|
||
return str(value)
|
||
|
||
|
||
def relative_to(path: Path, start: Path) -> str:
|
||
try:
|
||
return path.relative_to(start).as_posix()
|
||
except ValueError:
|
||
return path.as_posix()
|
||
|
||
|
||
def html_escape(value: Any) -> str:
|
||
return html.escape(str(value), quote=True)
|
||
|
||
|
||
def make_case_summary(
|
||
pdf_path: Path,
|
||
images: list[Path],
|
||
image_error: str,
|
||
record: dict[str, Any],
|
||
json_path: Path | None,
|
||
json_error: str,
|
||
output_dir: Path,
|
||
strict_recommended: bool,
|
||
) -> dict[str, Any]:
|
||
field_rows, missing_required, missing_recommended = build_field_rows(record) if record else ([], [], [])
|
||
review_notes = record.get("复核备注", []) if record else []
|
||
if not isinstance(review_notes, list):
|
||
review_notes = [review_notes]
|
||
|
||
suggestions: list[str] = []
|
||
if image_error:
|
||
suggestions.append(image_error)
|
||
if json_error:
|
||
suggestions.append(json_error)
|
||
if missing_required:
|
||
suggestions.append("核心字段缺项,需对照图片补齐:" + "、".join(missing_required))
|
||
if strict_recommended and missing_recommended:
|
||
suggestions.append("建议字段缺项,抽样确认是否首页未填写:" + "、".join(missing_recommended))
|
||
if record.get("复核状态") and record.get("复核状态") != "auto_pass":
|
||
suggestions.append("该记录已有复核状态:" + str(record.get("复核状态")))
|
||
if review_notes:
|
||
suggestions.append("已有复核备注:" + ";".join(value_preview(note, 80) for note in review_notes))
|
||
if not suggestions:
|
||
suggestions.append("图片与结构化字段抽查一致即可。")
|
||
|
||
return {
|
||
"pdf_path": pdf_path,
|
||
"source_file": pdf_path.name,
|
||
"images": images,
|
||
"image_dir": images[0].parent if images else output_dir / "图片" / pdf_path.stem,
|
||
"first_image": images[0] if images else None,
|
||
"page_count": len(images),
|
||
"image_error": image_error,
|
||
"record": record,
|
||
"json_path": json_path,
|
||
"json_error": json_error,
|
||
"field_rows": field_rows,
|
||
"missing_required": missing_required,
|
||
"missing_recommended": missing_recommended,
|
||
"suggestions": suggestions,
|
||
}
|
||
|
||
|
||
def write_index_csv(cases: list[dict[str, Any]], output_dir: Path) -> Path:
|
||
csv_path = output_dir / "患者首页_PDF图片对照索引.csv"
|
||
fieldnames = [
|
||
"源文件",
|
||
"页数",
|
||
"图片目录",
|
||
"首页图片",
|
||
"结构化JSON",
|
||
"病案号",
|
||
"姓名",
|
||
"复核状态",
|
||
"复核备注",
|
||
"核心缺项",
|
||
"建议核对缺项",
|
||
"对照建议",
|
||
]
|
||
with csv_path.open("w", encoding="utf-8-sig", newline="") as fp:
|
||
writer = csv.DictWriter(fp, fieldnames=fieldnames)
|
||
writer.writeheader()
|
||
for case in cases:
|
||
record = case["record"]
|
||
writer.writerow(
|
||
{
|
||
"源文件": case["source_file"],
|
||
"页数": case["page_count"],
|
||
"图片目录": relative_to(case["image_dir"], output_dir),
|
||
"首页图片": relative_to(case["first_image"], output_dir) if case["first_image"] else "",
|
||
"结构化JSON": relative_to(case["json_path"], output_dir) if case["json_path"] else "",
|
||
"病案号": record.get("病案号", ""),
|
||
"姓名": record.get("姓名", ""),
|
||
"复核状态": record.get("复核状态", ""),
|
||
"复核备注": as_csv_text(record.get("复核备注", [])),
|
||
"核心缺项": "、".join(case["missing_required"]),
|
||
"建议核对缺项": "、".join(case["missing_recommended"]),
|
||
"对照建议": ";".join(case["suggestions"]),
|
||
}
|
||
)
|
||
return csv_path
|
||
|
||
|
||
def render_field_table(field_rows: list[dict[str, str]]) -> str:
|
||
if not field_rows:
|
||
return '<p class="empty">未生成字段核验表。</p>'
|
||
|
||
parts = [
|
||
'<table class="field-table">',
|
||
"<thead><tr><th>组</th><th>字段</th><th>级别</th><th>首页位置</th><th>值</th><th>缺项</th></tr></thead>",
|
||
"<tbody>",
|
||
]
|
||
for row in field_rows:
|
||
missing_class = " is-missing" if row["missing"] == "是" and row["level"] != "optional" else ""
|
||
parts.append(
|
||
"<tr class=\"{missing_class}\">"
|
||
"<td>{group}</td><td>{key}</td><td>{level}</td><td>{location}</td><td>{value}</td><td>{missing}</td>"
|
||
"</tr>".format(
|
||
missing_class=missing_class.strip(),
|
||
group=html_escape(row["group"]),
|
||
key=html_escape(row["key"]),
|
||
level=html_escape(row["level"]),
|
||
location=html_escape(row["location_hint"]),
|
||
value=html_escape(row["value"]),
|
||
missing=html_escape(row["missing"]),
|
||
)
|
||
)
|
||
parts.append("</tbody></table>")
|
||
return "\n".join(parts)
|
||
|
||
|
||
def render_html(cases: list[dict[str, Any]], output_dir: Path) -> Path:
|
||
html_path = output_dir / "患者首页_PDF图片对照.html"
|
||
total = len(cases)
|
||
with_required_missing = sum(1 for case in cases if case["missing_required"])
|
||
with_review_status = sum(1 for case in cases if case["record"].get("复核状态") not in {"", "auto_pass", None})
|
||
failed_images = sum(1 for case in cases if case["image_error"])
|
||
|
||
nav_rows = []
|
||
case_sections = []
|
||
for index, case in enumerate(cases, start=1):
|
||
anchor = f"case-{index}"
|
||
record = case["record"]
|
||
status = record.get("复核状态", "未解析")
|
||
nav_rows.append(
|
||
"<tr><td><a href=\"#{anchor}\">{source}</a></td><td>{name}</td><td>{mrn}</td><td>{status}</td><td>{required}</td></tr>".format(
|
||
anchor=anchor,
|
||
source=html_escape(case["source_file"]),
|
||
name=html_escape(record.get("姓名", "")),
|
||
mrn=html_escape(record.get("病案号", "")),
|
||
status=html_escape(status),
|
||
required=html_escape("、".join(case["missing_required"]) or "无"),
|
||
)
|
||
)
|
||
|
||
images_html = []
|
||
for image_path in case["images"]:
|
||
rel_image = relative_to(image_path, output_dir)
|
||
images_html.append(
|
||
'<figure><img loading="lazy" src="{src}" alt="{alt}"><figcaption>{caption}</figcaption></figure>'.format(
|
||
src=html_escape(rel_image),
|
||
alt=html_escape(f"{case['source_file']} {image_path.name}"),
|
||
caption=html_escape(image_path.name),
|
||
)
|
||
)
|
||
if not images_html:
|
||
images_html.append('<p class="empty">未生成图片。</p>')
|
||
|
||
suggestion_items = "\n".join(f"<li>{html_escape(note)}</li>" for note in case["suggestions"])
|
||
summary_items = [
|
||
("病案号", record.get("病案号", "")),
|
||
("姓名", record.get("姓名", "")),
|
||
("大科室", record.get("大科室", "")),
|
||
("出院科别", record.get("出院科别", "")),
|
||
("主要诊断", record.get("主要诊断名称", "")),
|
||
("主要诊断编码", record.get("主要诊断编码", "")),
|
||
("复核状态", status),
|
||
]
|
||
summary_html = "\n".join(
|
||
f"<dt>{html_escape(label)}</dt><dd>{html_escape(value_preview(value, 160))}</dd>" for label, value in summary_items
|
||
)
|
||
case_sections.append(
|
||
"""
|
||
<section class="case" id="{anchor}">
|
||
<header class="case-header">
|
||
<div>
|
||
<h2>{source}</h2>
|
||
<p>{name} · {mrn} · {status}</p>
|
||
</div>
|
||
<a class="back-link" href="#top">返回顶部</a>
|
||
</header>
|
||
<div class="case-grid">
|
||
<div class="image-pane">{images}</div>
|
||
<div class="data-pane">
|
||
<dl class="summary">{summary}</dl>
|
||
<div class="suggestions"><h3>对照建议</h3><ul>{suggestions}</ul></div>
|
||
{field_table}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
""".format(
|
||
anchor=anchor,
|
||
source=html_escape(case["source_file"]),
|
||
name=html_escape(record.get("姓名", "")),
|
||
mrn=html_escape(record.get("病案号", "")),
|
||
status=html_escape(status),
|
||
images="\n".join(images_html),
|
||
summary=summary_html,
|
||
suggestions=suggestion_items,
|
||
field_table=render_field_table(case["field_rows"]),
|
||
)
|
||
)
|
||
|
||
page_template = Template(
|
||
"""<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>患者首页 PDF 图片对照核验</title>
|
||
<style>
|
||
:root {
|
||
--ink: #15181c;
|
||
--muted: #5f6975;
|
||
--line: #d9dde3;
|
||
--paper: #f6f7f9;
|
||
--panel: #ffffff;
|
||
--bad: #b42318;
|
||
--bad-bg: #fff2ef;
|
||
--warn: #8a5a00;
|
||
--warn-bg: #fff7df;
|
||
--ok: #1f7a4d;
|
||
--ok-bg: #edf8f2;
|
||
--accent: #245b8f;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
background: var(--paper);
|
||
color: var(--ink);
|
||
font-family: "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
|
||
font-size: 14px;
|
||
line-height: 1.55;
|
||
}
|
||
header.report-header {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
padding: 14px 22px;
|
||
border-bottom: 1px solid var(--line);
|
||
background: rgba(255, 255, 255, 0.96);
|
||
}
|
||
h1, h2, h3 { margin: 0; letter-spacing: 0; }
|
||
h1 { font-size: 20px; }
|
||
h2 { font-size: 16px; }
|
||
h3 { font-size: 14px; margin-bottom: 8px; }
|
||
.meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
}
|
||
.pill {
|
||
border: 1px solid var(--line);
|
||
background: #fff;
|
||
padding: 4px 8px;
|
||
border-radius: 6px;
|
||
color: var(--muted);
|
||
}
|
||
main { padding: 18px 22px 32px; }
|
||
.index {
|
||
overflow: auto;
|
||
border: 1px solid var(--line);
|
||
background: var(--panel);
|
||
border-radius: 8px;
|
||
margin-bottom: 18px;
|
||
}
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th, td {
|
||
border-bottom: 1px solid var(--line);
|
||
padding: 7px 8px;
|
||
text-align: left;
|
||
vertical-align: top;
|
||
white-space: normal;
|
||
word-break: break-word;
|
||
}
|
||
th {
|
||
background: #eef1f5;
|
||
color: #303844;
|
||
font-weight: 700;
|
||
}
|
||
a { color: var(--accent); text-decoration: none; }
|
||
a:hover { text-decoration: underline; }
|
||
.case {
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: var(--panel);
|
||
margin: 18px 0;
|
||
overflow: hidden;
|
||
}
|
||
.case-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid var(--line);
|
||
background: #fbfcfd;
|
||
}
|
||
.case-header p {
|
||
margin: 4px 0 0;
|
||
color: var(--muted);
|
||
}
|
||
.back-link { white-space: nowrap; align-self: center; }
|
||
.case-grid {
|
||
display: grid;
|
||
grid-template-columns: minmax(420px, 55%) minmax(360px, 45%);
|
||
min-height: 520px;
|
||
}
|
||
.image-pane {
|
||
border-right: 1px solid var(--line);
|
||
background: #eceff3;
|
||
padding: 12px;
|
||
overflow: auto;
|
||
max-height: 86vh;
|
||
}
|
||
figure { margin: 0 0 14px; }
|
||
img {
|
||
display: block;
|
||
width: 100%;
|
||
height: auto;
|
||
border: 1px solid #c5cbd3;
|
||
background: #fff;
|
||
}
|
||
figcaption {
|
||
margin-top: 5px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
.data-pane {
|
||
padding: 12px;
|
||
overflow: auto;
|
||
max-height: 86vh;
|
||
}
|
||
.summary {
|
||
display: grid;
|
||
grid-template-columns: 108px 1fr;
|
||
gap: 5px 10px;
|
||
margin: 0 0 12px;
|
||
padding: 10px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: #fbfcfd;
|
||
}
|
||
dt { color: var(--muted); }
|
||
dd { margin: 0; font-weight: 600; }
|
||
.suggestions {
|
||
margin-bottom: 12px;
|
||
padding: 10px;
|
||
border: 1px solid #ead7a7;
|
||
border-radius: 8px;
|
||
background: var(--warn-bg);
|
||
color: var(--warn);
|
||
}
|
||
.suggestions ul { margin: 0; padding-left: 18px; }
|
||
.field-table td:nth-child(5) { max-width: 360px; }
|
||
.field-table .is-missing td {
|
||
background: var(--bad-bg);
|
||
color: var(--bad);
|
||
}
|
||
.empty {
|
||
color: var(--muted);
|
||
padding: 8px;
|
||
}
|
||
@media (max-width: 980px) {
|
||
.case-grid { grid-template-columns: 1fr; }
|
||
.image-pane { border-right: 0; border-bottom: 1px solid var(--line); max-height: none; }
|
||
.data-pane { max-height: none; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header class="report-header" id="top">
|
||
<h1>患者首页 PDF 图片对照核验</h1>
|
||
<div class="meta">
|
||
<span class="pill">PDF:$total</span>
|
||
<span class="pill">核心缺项:$with_required_missing</span>
|
||
<span class="pill">需复核状态:$with_review_status</span>
|
||
<span class="pill">图片失败:$failed_images</span>
|
||
</div>
|
||
</header>
|
||
<main>
|
||
<section class="index">
|
||
<table>
|
||
<thead><tr><th>源文件</th><th>姓名</th><th>病案号</th><th>复核状态</th><th>核心缺项</th></tr></thead>
|
||
<tbody>$nav_rows</tbody>
|
||
</table>
|
||
</section>
|
||
$case_sections
|
||
</main>
|
||
</body>
|
||
</html>
|
||
"""
|
||
)
|
||
html_path.write_text(
|
||
page_template.substitute(
|
||
total=total,
|
||
with_required_missing=with_required_missing,
|
||
with_review_status=with_review_status,
|
||
failed_images=failed_images,
|
||
nav_rows="\n".join(nav_rows),
|
||
case_sections="\n".join(case_sections),
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
return html_path
|
||
|
||
|
||
def main() -> int:
|
||
args = parse_args()
|
||
input_dir = args.input_dir.resolve()
|
||
result_dir = args.result_dir.resolve()
|
||
output_dir = args.output_dir.resolve()
|
||
image_root = output_dir / "图片"
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
pdf_files = sorted(input_dir.glob("*.pdf"))
|
||
if not pdf_files:
|
||
raise SystemExit(f"未找到 PDF:{input_dir}")
|
||
|
||
cases: list[dict[str, Any]] = []
|
||
for pdf_path in pdf_files:
|
||
case_image_dir = image_root / pdf_path.stem
|
||
images, image_error = convert_pdf_to_images(pdf_path, case_image_dir, args.dpi, args.format, args.force)
|
||
record, json_path, json_error = load_record(result_dir, pdf_path)
|
||
cases.append(
|
||
make_case_summary(
|
||
pdf_path=pdf_path,
|
||
images=images,
|
||
image_error=image_error,
|
||
record=record,
|
||
json_path=json_path,
|
||
json_error=json_error,
|
||
output_dir=output_dir,
|
||
strict_recommended=args.strict_recommended,
|
||
)
|
||
)
|
||
|
||
index_csv = write_index_csv(cases, output_dir)
|
||
html_path = render_html(cases, output_dir)
|
||
required_missing_count = sum(1 for case in cases if case["missing_required"])
|
||
print(f"PDF 数量:{len(cases)}")
|
||
print(f"图片输出:{image_root}")
|
||
print(f"对照索引:{index_csv}")
|
||
print(f"HTML 对照页:{html_path}")
|
||
print(f"核心缺项病例数:{required_missing_count}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|