feat(review): 医生核对包入口;CEUS 运行目录的读取收进 runs
- 新模块 review(组织层,只依赖 library、answers、runs):把一次构建和它装入的 CEUS 运行整理成医生可读的文件夹(CSV 带 BOM、说明与报告转 PDF、图片视频逐字节拷贝), 入口 scripts/review_pack.py + config/review_pack.yaml。任务书 03。 - 读 CEUS 运行目录(failed.csv、结果目录、关键帧文件)从 build 挪进 runs,build 与 review 都经它读;failed.csv 读取在边界上收严(步号须为整数、视频号不重复、列数固定)。 - library、answers 补只读函数;import-linter 增 review 契约。 - 文档:guides/医生核对包.md(过一轮硕士生阅读),结构.md 第三至八部分同步。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
"""医生核对包:目录结构、CSV 规格、问题清单的每种来源、逐字节拷贝、TIC结果、没装 CEUS 的构建、PDF。
|
||||
|
||||
预期值照任务书 03 写:目录和文件名、列名、问题清单的「环节」与「问题」原文、关键帧文件名的秒数
|
||||
(`video_time_s` 保留一位小数)都按任务书的文字手写,不从实现里推。
|
||||
|
||||
假数据(6 例):
|
||||
- P001:进库;定位到 D1、D2、F1,其余 4 个槽位未取到;病灶去暗区曲线拟合失败
|
||||
(parameter_on_boundary);紫圈不可用。
|
||||
- P002:CEUS 第 2 步失败(C15 跳过)。P003:CEUS 运行里没有结果目录(C15 跳过)。
|
||||
- P004:进库;7 个槽位都取到,D2、F1 的时间改成 5.04、12.46 秒(测一位小数);拟合都成功;紫圈可用。
|
||||
- P005:MR 表诊断与另两张表不同(C10 跳过);CEUS 运行里却有它完整的结果目录(被跳过但定量成功)。
|
||||
- P006:进库;7 个槽位都取到;病灶整体曲线拟合失败(credible_peak_not_found);紫圈可用。
|
||||
"""
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from cadx.answers import read_aliases
|
||||
from cadx.build import BuildSummary, run_build
|
||||
from cadx.library import read_ultrasound
|
||||
from cadx.review import run_review_pack
|
||||
from cadx.review._pdf import markdown_to_pdf, report_markdown
|
||||
from fake_ceus_run import UNLOCATED_REASON, summary, write_run
|
||||
from fake_delivery import DIAGNOSIS_COLUMN, REPO_ROOT, make_delivery, write_config
|
||||
|
||||
BUILD_NOW = datetime(2026, 10, 17, 9, 0, 0).astimezone()
|
||||
PACK_NOW = datetime(2026, 10, 17, 10, 0, 0).astimezone()
|
||||
RUN_ID = "20261017-080000-abcdef0"
|
||||
ALL_SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
|
||||
|
||||
SUMMARY_HEADER = [
|
||||
"病例号",
|
||||
"视频号",
|
||||
"化名",
|
||||
"是否进入资料库",
|
||||
"没进的原因",
|
||||
"CEUS 定量是否成功",
|
||||
"CEUS 失败在第几步",
|
||||
*ALL_SLOTS,
|
||||
]
|
||||
PROBLEM_HEADER = ["病例号", "视频号", "环节", "问题"]
|
||||
ANSWER_HEADERS = {
|
||||
"化名对照.csv": ["化名", "病例号", "视频号"],
|
||||
"诊断.csv": ["化名", "病例号", "临床表诊断编码", "超声表诊断编码", "MR表诊断编码", "诊断名"],
|
||||
"跳过病例.csv": ["病例", "检查编号", "检查说明", "文件", "列", "涉及值"],
|
||||
"报告复核句.csv": ["化名", "路", "命中的词", "所在句子"],
|
||||
}
|
||||
CASE_FILES = {"临床信息.csv", "超声报告.pdf", "MR报告.pdf", "关键图.png"}
|
||||
CEUS_FILES = {"关键帧", "TIC曲线.png", "TIC结果.csv", "回放视频.mp4"}
|
||||
|
||||
|
||||
def _rewrite_summary(run: Path, video_id: str, located: tuple[str, ...], edit: Any) -> None:
|
||||
content = summary(video_id, located)
|
||||
edit(content)
|
||||
(run / video_id / "summary.json").write_text(json.dumps(content), encoding="utf-8")
|
||||
|
||||
|
||||
def _fail_fit(curve: str, code: str) -> Any:
|
||||
def edit(content: dict[str, Any]) -> None:
|
||||
content["fits"][curve].update(success=False, failure_reason=code)
|
||||
|
||||
return edit
|
||||
|
||||
|
||||
def _p004(content: dict[str, Any]) -> None:
|
||||
content["reference_valid"] = True
|
||||
content["anchor_frames"]["D2"]["frame_time_video_s"] = 5.04
|
||||
content["anchor_frames"]["F1"]["frame_time_video_s"] = 12.46
|
||||
|
||||
|
||||
def _p006(content: dict[str, Any]) -> None:
|
||||
_fail_fit("roi_all", "credible_peak_not_found")(content)
|
||||
content["reference_valid"] = True
|
||||
|
||||
|
||||
def _build_with_ceus(root: Path) -> tuple[BuildSummary, Path]:
|
||||
delivery = make_delivery(6)
|
||||
delivery.case(5).mr[DIAGNOSIS_COLUMN] = 2
|
||||
delivery_dir = delivery.write(root / "delivery")
|
||||
run = write_run(
|
||||
root / "runs" / RUN_ID,
|
||||
delivery_dir,
|
||||
{"U001": ("D1", "D2", "F1"), "U004": ALL_SLOTS, "U005": ALL_SLOTS, "U006": ALL_SLOTS},
|
||||
[("U002", 2, "两半都没有绿框")],
|
||||
)
|
||||
_rewrite_summary(
|
||||
run, "U001", ("D1", "D2", "F1"), _fail_fit("roi_perf", "parameter_on_boundary")
|
||||
)
|
||||
_rewrite_summary(run, "U004", ALL_SLOTS, _p004)
|
||||
_rewrite_summary(run, "U006", ALL_SLOTS, _p006)
|
||||
return run_build(write_config(root, delivery_dir, 1, None, run), REPO_ROOT, BUILD_NOW), run
|
||||
|
||||
|
||||
def _pack_config(root: Path, library_dir: Path, answers_dir: Path) -> Path:
|
||||
config = {
|
||||
"library_dir": str(library_dir),
|
||||
"answers_dir": str(answers_dir),
|
||||
"output_root": str(root / "review_pack"),
|
||||
}
|
||||
path = root / "review_pack.yaml"
|
||||
path.write_text(yaml.safe_dump(config, allow_unicode=True), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _tree_hashes(folder: Path) -> dict[str, str]:
|
||||
return {
|
||||
p.relative_to(folder).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
for p in sorted(folder.rglob("*"))
|
||||
if p.is_file()
|
||||
}
|
||||
|
||||
|
||||
class Packed:
|
||||
"""构建一次、出一次包,各条测试共用。"""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.build, self.run = _build_with_ceus(root)
|
||||
self.library_before = _tree_hashes(self.build.library_dir)
|
||||
self.answers_before = _tree_hashes(self.build.answers_dir)
|
||||
config = _pack_config(root, self.build.library_dir, self.build.answers_dir)
|
||||
self.config = config
|
||||
self.result = run_review_pack(config, REPO_ROOT, PACK_NOW)
|
||||
self.pack = self.result.pack_dir
|
||||
self.alias = {r.case_no: r.alias for r in read_aliases(self.build.answers_dir)}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def packed(tmp_path_factory: pytest.TempPathFactory) -> Packed:
|
||||
return Packed(tmp_path_factory.mktemp("review"))
|
||||
|
||||
|
||||
def _csv(path: Path) -> list[list[str]]:
|
||||
raw = path.read_bytes()
|
||||
assert raw.startswith(b"\xef\xbb\xbf"), path
|
||||
with path.open(encoding="utf-8-sig", newline="") as handle:
|
||||
return list(csv.reader(handle))
|
||||
|
||||
|
||||
def test_directory_layout(packed: Packed) -> None:
|
||||
pack = packed.pack
|
||||
assert pack.name == f"医生核对包_{packed.build.build_id}"
|
||||
assert sorted(p.name for p in packed.result.run_dir.iterdir()) == sorted(
|
||||
["snapshot", pack.name]
|
||||
)
|
||||
assert {p.name for p in pack.iterdir()} == {
|
||||
"说明.pdf",
|
||||
"问题清单.csv",
|
||||
"病例总表.csv",
|
||||
"标准答案",
|
||||
"病例",
|
||||
}
|
||||
assert {p.name for p in (pack / "标准答案").iterdir()} == set(ANSWER_HEADERS)
|
||||
assert {p.name for p in (pack / "病例").iterdir()} == {"P001", "P004", "P006"}
|
||||
for case in ("P001", "P004", "P006"):
|
||||
assert {p.name for p in (pack / "病例" / case).iterdir()} == CASE_FILES | CEUS_FILES
|
||||
assert {p.name for p in (pack / "病例" / "P001" / "关键帧").iterdir()} == {
|
||||
"D1_第2.5秒.png",
|
||||
"D2_第5.0秒.png",
|
||||
"F1_第12.5秒.png",
|
||||
}
|
||||
assert {p.name for p in (pack / "病例" / "P004" / "关键帧").iterdir()} == {
|
||||
"B0_第0.0秒.png",
|
||||
"D1_第2.5秒.png",
|
||||
"D2_第5.0秒.png",
|
||||
"D3_第7.5秒.png",
|
||||
"D4_第10.0秒.png",
|
||||
"F1_第12.5秒.png",
|
||||
"F2_第15.0秒.png",
|
||||
}
|
||||
|
||||
|
||||
def test_every_csv_has_bom_and_no_placeholder_for_missing(packed: Packed) -> None:
|
||||
csvs = sorted(packed.pack.rglob("*.csv"))
|
||||
assert len(csvs) == 2 + 4 + 3 * 2
|
||||
for path in csvs:
|
||||
for row in _csv(path):
|
||||
assert not {"null", "None", "NaN", "nan"} & set(row), path
|
||||
assert _csv(packed.pack / "病例总表.csv")[0] == SUMMARY_HEADER
|
||||
assert _csv(packed.pack / "问题清单.csv")[0] == PROBLEM_HEADER
|
||||
for name, header in ANSWER_HEADERS.items():
|
||||
assert _csv(packed.pack / "标准答案" / name)[0] == header
|
||||
clinical = _csv(packed.pack / "病例" / "P001" / "临床信息.csv")
|
||||
assert len(clinical) == 2 and "Case_ID" not in clinical[0] and "Diagnosis" not in clinical[0]
|
||||
assert clinical[0][:3] == ["Sex", "Age", "Symptom_Pain"]
|
||||
# 假交付包里没测的化验是 None:留空。
|
||||
assert clinical[1][clinical[0].index("Amylase")] == ""
|
||||
|
||||
|
||||
def test_case_summary(packed: Packed) -> None:
|
||||
rows = _csv(packed.pack / "病例总表.csv")[1:]
|
||||
blank = [""] * 7
|
||||
a = packed.alias
|
||||
assert rows == [
|
||||
[
|
||||
"P001",
|
||||
"U001",
|
||||
a["001"],
|
||||
"是",
|
||||
"",
|
||||
"是",
|
||||
"",
|
||||
"未取到",
|
||||
"2.5",
|
||||
"5.0",
|
||||
"未取到",
|
||||
"未取到",
|
||||
"12.5",
|
||||
"未取到",
|
||||
],
|
||||
["P002", "U002", "", "否", "C15 CEUS 定量结果齐全", "否", "2", *blank],
|
||||
["P003", "U003", "", "否", "C15 CEUS 定量结果齐全", "", "", *blank],
|
||||
[
|
||||
"P004",
|
||||
"U004",
|
||||
a["004"],
|
||||
"是",
|
||||
"",
|
||||
"是",
|
||||
"",
|
||||
"0.0",
|
||||
"2.5",
|
||||
"5.04",
|
||||
"7.5",
|
||||
"10.0",
|
||||
"12.46",
|
||||
"15.0",
|
||||
],
|
||||
["P005", "U005", "", "否", "C10 跨表一致(诊断、年龄、性别)", "是", "", *blank],
|
||||
[
|
||||
"P006",
|
||||
"U006",
|
||||
a["006"],
|
||||
"是",
|
||||
"",
|
||||
"是",
|
||||
"",
|
||||
"0.0",
|
||||
"2.5",
|
||||
"5.0",
|
||||
"7.5",
|
||||
"10.0",
|
||||
"12.5",
|
||||
"15.0",
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def test_problem_list_has_every_source(packed: Packed) -> None:
|
||||
rows = _csv(packed.pack / "问题清单.csv")[1:]
|
||||
skipped = {row[0]: row for row in _csv(packed.pack / "标准答案" / "跳过病例.csv")[1:]}
|
||||
unlocated = [
|
||||
["P001", "U001", "关键帧", f"{slot} 未取到:{UNLOCATED_REASON}"]
|
||||
for slot in ("B0", "D3", "D4", "F2")
|
||||
]
|
||||
assert rows == [
|
||||
["P001", "U001", "TIC 拟合", "病灶去暗区曲线未完成拟合(parameter_on_boundary)"],
|
||||
*unlocated,
|
||||
["P001", "U001", "紫圈", "紫圈曲线不可用"],
|
||||
[
|
||||
"P002",
|
||||
"U002",
|
||||
"装配资料库",
|
||||
f"C15 CEUS 定量结果齐全;文件:{RUN_ID}/failed.csv;"
|
||||
'涉及值:"第 2 步失败:两半都没有绿框"',
|
||||
],
|
||||
["P002", "U002", "CEUS 第 2 步", "第 2 步失败:两半都没有绿框"],
|
||||
[
|
||||
"P003",
|
||||
"U003",
|
||||
"装配资料库",
|
||||
f"C15 CEUS 定量结果齐全;文件:{RUN_ID}/U003;"
|
||||
'涉及值:"CEUS 运行目录里没有这一例的结果目录"',
|
||||
],
|
||||
[
|
||||
"P005",
|
||||
"U005",
|
||||
"装配资料库",
|
||||
f"C10 跨表一致(诊断、年龄、性别);文件:{skipped['005'][3]};"
|
||||
f"列:{skipped['005'][4]};涉及值:{skipped['005'][5]}",
|
||||
],
|
||||
["P006", "U006", "TIC 拟合", "病灶整体曲线未完成拟合(credible_peak_not_found)"],
|
||||
]
|
||||
counts = {"装配资料库": 3, "CEUS 第 2 步": 1, "TIC 拟合": 2, "关键帧": 4, "紫圈": 1}
|
||||
assert packed.result.problems_by_stage == dict(sorted(counts.items()))
|
||||
|
||||
|
||||
def test_copies_are_byte_identical(packed: Packed) -> None:
|
||||
for case_no, video_id in (("001", "U001"), ("004", "U004")):
|
||||
case_dir = packed.pack / "病例" / f"P{case_no}"
|
||||
route = read_ultrasound(packed.build.library_dir, packed.alias[case_no])
|
||||
assert (case_dir / "关键图.png").read_bytes() == route.key_image.read_bytes()
|
||||
assert route.tic is not None
|
||||
copied = {p.name.split("_")[0]: p for p in (case_dir / "关键帧").iterdir()}
|
||||
assert sorted(copied) == sorted(route.keyframes)
|
||||
for slot, source in route.keyframes.items():
|
||||
assert copied[slot].read_bytes() == source.read_bytes()
|
||||
seconds = route.tic["keyframes"][slot]["video_time_s"]
|
||||
assert copied[slot].name == f"{slot}_第{round(seconds, 1)}秒.png"
|
||||
run_case = packed.run / video_id
|
||||
assert (case_dir / "TIC曲线.png").read_bytes() == (run_case / "tic.png").read_bytes()
|
||||
assert (case_dir / "回放视频.mp4").read_bytes() == (run_case / "overlay.mp4").read_bytes()
|
||||
answers = packed.build.answers_dir
|
||||
for source, name in (
|
||||
("aliases.csv", "化名对照.csv"),
|
||||
("diagnoses.csv", "诊断.csv"),
|
||||
("skipped.csv", "跳过病例.csv"),
|
||||
("review.csv", "报告复核句.csv"),
|
||||
):
|
||||
assert (packed.pack / "标准答案" / name).read_bytes() == (answers / source).read_bytes()
|
||||
|
||||
|
||||
def _tool_output(tool: str, *args: str) -> str:
|
||||
path = shutil.which(tool)
|
||||
assert path is not None, f"找不到 {tool}"
|
||||
return subprocess.run([path, *args], check=True, capture_output=True, text=True).stdout # noqa: S603
|
||||
|
||||
|
||||
def _expected_cell(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return value if isinstance(value, str) else repr(value)
|
||||
|
||||
|
||||
def test_tic_results_match_tic_json(packed: Packed) -> None:
|
||||
for case_no in ("001", "004", "006"):
|
||||
tic = read_ultrasound(packed.build.library_dir, packed.alias[case_no]).tic
|
||||
assert tic is not None
|
||||
rows = _csv(packed.pack / "病例" / f"P{case_no}" / "TIC结果.csv")
|
||||
assert rows[0] == ["项目", "值"]
|
||||
# 4 条曲线 × 2 + 病灶 2 条 ×(3 个口径 + 11 个指标 + 3 个辅助时刻)+ 紫圈 1 + 7 槽位 × 3。
|
||||
assert len(rows) - 1 == 8 + 34 + 1 + 21
|
||||
for item, value in rows[1:]:
|
||||
path = item[item.rindex("(") + 1 : -1]
|
||||
node: Any = tic
|
||||
for key in path.split("."):
|
||||
node = None if node is None else node[key]
|
||||
assert value == _expected_cell(node), (case_no, item)
|
||||
rows = dict(_csv(packed.pack / "病例" / "P001" / "TIC结果.csv")[1:])
|
||||
assert rows["病灶整体曲线 拟合成功(fits.roi_all.success)"] == "true"
|
||||
assert rows["病灶去暗区曲线 拟合成功(fits.roi_perf.success)"] == "false"
|
||||
assert rows["紫圈曲线可用(pan_curve_usable)"] == "false"
|
||||
assert rows["关键帧 D4 未取到的原因(keyframes.D4.reason)"] == UNLOCATED_REASON
|
||||
|
||||
|
||||
def test_pdfs_use_noto_cjk_and_carry_ids(packed: Packed) -> None:
|
||||
pdfs = sorted(packed.pack.rglob("*.pdf"))
|
||||
assert len(pdfs) == packed.result.pdfs == 1 + 3 * 2
|
||||
for pdf in pdfs:
|
||||
assert pdf.read_bytes().startswith(b"%PDF") and pdf.stat().st_size > 1000
|
||||
readme = packed.pack / "说明.pdf"
|
||||
fonts = _tool_output("pdffonts", str(readme))
|
||||
assert "NotoSerifCJK" in fonts and "NotoSansCJK" in fonts
|
||||
text = _tool_output("pdftotext", str(readme), "-")
|
||||
assert packed.build.build_id in text and RUN_ID in text and "parameter_on_boundary" in text
|
||||
report = _tool_output("pdftotext", str(packed.pack / "病例" / "P001" / "超声报告.pdf"), "-")
|
||||
assert report.startswith("病例号 P001 超声描述")
|
||||
|
||||
|
||||
def test_snapshot_and_no_writes_to_libraries(packed: Packed) -> None:
|
||||
snapshot = packed.result.run_dir / "snapshot"
|
||||
assert (snapshot / "review_pack.yaml").read_bytes() == packed.config.read_bytes()
|
||||
record = json.loads((snapshot / "review.json").read_text(encoding="utf-8"))
|
||||
assert record["build_id"] == packed.build.build_id and record["ceus_run_id"] == RUN_ID
|
||||
assert record["pandoc"].startswith("pandoc ") and "LibreOffice" in record["soffice"]
|
||||
assert (snapshot / "finished.json").is_file()
|
||||
assert _tree_hashes(packed.build.library_dir) == packed.library_before
|
||||
assert _tree_hashes(packed.build.answers_dir) == packed.answers_before
|
||||
|
||||
|
||||
def test_existing_run_dir_stops(packed: Packed) -> None:
|
||||
with pytest.raises(FileExistsError):
|
||||
run_review_pack(packed.config, REPO_ROOT, PACK_NOW)
|
||||
|
||||
|
||||
def test_build_without_ceus(tmp_path: Path) -> None:
|
||||
delivery_dir = make_delivery(2).write(tmp_path / "delivery")
|
||||
build = run_build(write_config(tmp_path, delivery_dir, 1, None, None), REPO_ROOT, BUILD_NOW)
|
||||
result = run_review_pack(
|
||||
_pack_config(tmp_path, build.library_dir, build.answers_dir), REPO_ROOT, PACK_NOW
|
||||
)
|
||||
for case in ("P001", "P002"):
|
||||
assert {p.name for p in (result.pack_dir / "病例" / case).iterdir()} == CASE_FILES
|
||||
alias = {r.case_no: r.alias for r in read_aliases(build.answers_dir)}
|
||||
rows = _csv(result.pack_dir / "病例总表.csv")[1:]
|
||||
assert [row[:5] for row in rows] == [
|
||||
["P001", "U001", alias["001"], "是", ""],
|
||||
["P002", "U002", alias["002"], "是", ""],
|
||||
]
|
||||
assert all(row[5:] == [""] * 9 for row in rows)
|
||||
assert _csv(result.pack_dir / "问题清单.csv") == [PROBLEM_HEADER]
|
||||
assert result.problems_by_stage == {} and result.video_bytes == 0
|
||||
|
||||
|
||||
def test_build_ids_differ_stops(tmp_path: Path) -> None:
|
||||
config = _pack_config(tmp_path, tmp_path / "library" / "a", tmp_path / "answers" / "b")
|
||||
with pytest.raises(ValueError, match="不是同一次构建"):
|
||||
run_review_pack(config, REPO_ROOT, PACK_NOW)
|
||||
assert not (tmp_path / "review_pack").exists()
|
||||
|
||||
|
||||
def test_report_pdf_keeps_markdown_characters(tmp_path: Path) -> None:
|
||||
"""报告正文里的 Markdown 记号、引号、连字符、换行照抄,不被 pandoc 当成格式。
|
||||
|
||||
行首空格抽回文字时只剩一个(pdftotext 按版面估空格),所以每行比较去掉首尾空白后的内容。
|
||||
"""
|
||||
text = (
|
||||
'1. 囊壁*增厚* #2 a_b_c <b>x</b> [链接](y) -- "引号" ... `码` | 竖线\n 第二行行首两个空格'
|
||||
)
|
||||
target = tmp_path / "报告.pdf"
|
||||
markdown_to_pdf({target: report_markdown("病例号 P001 超声描述", text)})
|
||||
lines = _tool_output("pdftotext", "-layout", str(target), "-").splitlines()
|
||||
assert [line.strip() for line in lines if line.strip()][1:] == [
|
||||
line.strip() for line in text.splitlines()
|
||||
]
|
||||
|
||||
|
||||
def _gap_after_ab(pdf: Path) -> float:
|
||||
"""pdftotext -bbox 里词「ab」右边界到下一个词左边界的距离(点)。"""
|
||||
boxes = re.findall(
|
||||
r'xMin="([0-9.]+)"[^>]*xMax="([0-9.]+)"[^>]*>([^<]*)</word>',
|
||||
_tool_output("pdftotext", "-bbox", str(pdf), "-"),
|
||||
)
|
||||
words = [(float(x0), float(x1), w) for x0, x1, w in boxes]
|
||||
index = next(i for i, (_, _, w) in enumerate(words) if w == "ab")
|
||||
return words[index + 1][0] - words[index][1]
|
||||
|
||||
|
||||
def test_report_pdf_keeps_repeated_spaces(tmp_path: Path) -> None:
|
||||
"""一行里连续三个空格不被 pandoc 并成一个。
|
||||
|
||||
pdftotext 抽文字时按间距估空格,三个空格也只吐出一个,所以量版面:「ab」与「cd」之间的间距是
|
||||
单个空格那份 PDF 的约三倍。
|
||||
"""
|
||||
one, three = tmp_path / "一个空格.pdf", tmp_path / "三个空格.pdf"
|
||||
markdown_to_pdf(
|
||||
{
|
||||
one: report_markdown("病例号 P001 超声描述", "ab cd"),
|
||||
three: report_markdown("病例号 P001 超声描述", "ab cd"),
|
||||
}
|
||||
)
|
||||
assert _gap_after_ab(three) / _gap_after_ab(one) == pytest.approx(3.0, abs=0.3)
|
||||
|
||||
|
||||
def test_report_with_tab_stops() -> None:
|
||||
with pytest.raises(ValueError, match="病例号 P001 超声描述:报告原文里有 Tab"):
|
||||
report_markdown("病例号 P001 超声描述", "囊壁厚\t约2mm")
|
||||
Reference in New Issue
Block a user