refine result preview layout

This commit is contained in:
2026-05-08 22:14:08 +08:00
parent fe6fcf9826
commit 1be6cb287e
3 changed files with 214 additions and 161 deletions

View File

@@ -1,6 +1,6 @@
# HIS_Sur_Data_Deal
网页端检测数据处理工具。上传 `待处理检测数据.zip` 后,服务会自动识别 V1/V2 数据结构,调用原处理脚本生成 Excel并在网页中展示结果摘要、工作表统计和部分数据预览;用户可按需导出单个 Excel 或全部 Excel 压缩包。
网页端检测数据处理工具。上传 `待处理检测数据.zip` 后,服务会自动识别 V1/V2 数据结构,调用原处理脚本生成 Excel并在网页中展示结果摘要、工作表统计和数据预览用户可调整每个工作表的预览行数,并按需导出单个 Excel 或全部 Excel 压缩包。
## 本地运行
@@ -31,4 +31,4 @@ V1zip 解压后包含 `Patients_info.csv`、`Tests_List`、`Tests_Detail_List
V2zip 解压后包含 `Patients_info.csv`,并按患者目录分别保存检测汇总和具体检测,输出多个患者 Excel。
导出的压缩包默认只包含 Excel 结果,不包含处理日志。
导出的压缩包默认只包含 Excel 结果,不包含处理日志。系统默认输出全部检测记录,并保留“未匹配检测内容”列作为额外辅助信息。

View File

@@ -14,56 +14,59 @@ from .processor import (
create_result_zip,
find_output_file,
run_processing,
summarize_job,
)
WORK_ROOT = Path(tempfile.gettempdir()) / "his_sur_data_deal_jobs"
WORK_ROOT.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="检测数据处理")
APP_TITLE = "\u68c0\u6d4b\u6570\u636e\u5904\u7406"
UNMATCHED = "\u672a\u5339\u914d\u68c0\u6d4b\u5185\u5bb9"
app = FastAPI(title=APP_TITLE)
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return _page_shell(
"""
<section class="panel">
<form action="/process" method="post" enctype="multipart/form-data">
body = f"""
<section class="panel">
<form action="/process" method="post" enctype="multipart/form-data">
<div>
<label for="file">\u5f85\u5904\u7406\u68c0\u6d4b\u6570\u636e.zip</label>
<input id="file" name="file" type="file" accept=".zip,application/zip" required>
</div>
<div class="grid">
<div>
<label for="file">待处理检测数据.zip</label>
<input id="file" name="file" type="file" accept=".zip,application/zip" required>
<label for="mode">\u5904\u7406\u6a21\u5f0f</label>
<select id="mode" name="mode">
<option value="auto">\u81ea\u52a8\u8bc6\u522b</option>
<option value="v1">V1 \u6574\u6279\u6c47\u603b</option>
<option value="v2">V2 \u5355\u60a3\u8005\u6587\u4ef6</option>
</select>
</div>
<div class="grid">
<div>
<label for="mode">处理模式</label>
<select id="mode" name="mode">
<option value="auto">自动识别</option>
<option value="v1">V1 整批汇总</option>
<option value="v2">V2 单患者文件</option>
</select>
</div>
<div>
<label for="data_type">患者编号类型</label>
<select id="data_type" name="data_type">
<option value="pat_no">患者号 pat_no</option>
<option value="zhuyuanhao">住院号 zhuyuanhao</option>
</select>
</div>
<div>
<label for="result_name">结果文件名</label>
<input id="result_name" name="result_name" type="text" value="Result">
</div>
<div>
<label for="data_type">\u60a3\u8005\u7f16\u53f7\u7c7b\u578b</label>
<select id="data_type" name="data_type">
<option value="pat_no">\u60a3\u8005\u53f7 pat_no</option>
<option value="zhuyuanhao">\u4f4f\u9662\u53f7 zhuyuanhao</option>
</select>
</div>
<div class="checks">
<label><input type="checkbox" name="show_not_match" value="true" checked> 输出未匹配内容</label>
<label><input type="checkbox" name="show_all_infos" value="true"> 输出全部检测记录</label>
<div>
<label for="result_name">\u7ed3\u679c\u6587\u4ef6\u540d</label>
<input id="result_name" name="result_name" type="text" value="Result">
</div>
<button type="submit">开始处理</button>
</form>
<div class="note">V1 适用于含有 Patients_info.csv、Tests_List、Tests_Detail_List 的批量数据V2 适用于每个患者单独目录的数据。</div>
</section>
"""
)
<div>
<label for="preview_rows">\u6bcf\u4e2a\u5de5\u4f5c\u8868\u9884\u89c8\u884c\u6570</label>
<input id="preview_rows" name="preview_rows" type="number" min="5" max="200" value="20">
</div>
</div>
<button type="submit">\u5f00\u59cb\u5904\u7406</button>
</form>
<div class="note">\u9ed8\u8ba4\u8f93\u51fa\u5168\u90e8\u68c0\u6d4b\u8bb0\u5f55\uff0c\u5e76\u4fdd\u7559\u201c{UNMATCHED}\u201d\u5217\uff1b\u8be5\u5217\u4e3a\u989d\u5916\u8f85\u52a9\u4fe1\u606f\uff0c\u4fbf\u4e8e\u6838\u5bf9\u672a\u5f52\u7c7b\u9879\u76ee\u3002</div>
</section>
"""
return _page_shell(body)
@app.post("/process", response_class=HTMLResponse)
@@ -72,11 +75,10 @@ async def process(
mode: str = Form("auto"),
data_type: str = Form("pat_no"),
result_name: str = Form("Result"),
show_not_match: str | None = Form(None),
show_all_infos: str | None = Form(None),
preview_rows: int = Form(20),
) -> str:
if not file.filename or not file.filename.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="请上传 zip 文件。")
raise HTTPException(status_code=400, detail="\u8bf7\u4e0a\u4f20 zip \u6587\u4ef6\u3002")
job_dir = WORK_ROOT / uuid.uuid4().hex
job_dir.mkdir(parents=True, exist_ok=True)
@@ -85,21 +87,34 @@ async def process(
with upload_path.open("wb") as out:
shutil.copyfileobj(file.file, out)
rows = _clean_preview_rows(preview_rows)
result = run_processing(
zip_path=upload_path,
job_dir=job_dir,
mode=mode,
data_type=data_type,
result_name=result_name,
show_not_match=show_not_match == "true",
show_all_infos=show_all_infos == "true",
show_not_match=True,
show_all_infos=True,
preview_rows=rows,
)
except ProcessingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"处理失败:{exc}") from exc
raise HTTPException(status_code=500, detail=f"\u5904\u7406\u5931\u8d25\uff1a{exc}") from exc
return _render_result(result)
return _render_result(result, preview_rows=rows)
@app.get("/result/{job_id}", response_class=HTMLResponse)
def result_page(job_id: str, preview_rows: int = Query(20, ge=5, le=200)) -> str:
job_dir = _get_job_dir(job_id)
try:
rows = _clean_preview_rows(preview_rows)
result = summarize_job(job_dir, rows)
except ProcessingError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return _render_result(result, preview_rows=rows)
@app.get("/download/all/{job_id}")
@@ -109,11 +124,7 @@ def download_all(job_id: str) -> FileResponse:
result_zip = create_result_zip(job_dir)
except ProcessingError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return FileResponse(
result_zip,
media_type="application/zip",
filename="检测数据处理结果.zip",
)
return FileResponse(result_zip, media_type="application/zip", filename="result.zip")
@app.get("/download/file/{job_id}")
@@ -137,89 +148,96 @@ def health() -> dict[str, str]:
def _get_job_dir(job_id: str) -> Path:
if not job_id.isalnum():
raise HTTPException(status_code=404, detail="结果不存在。")
raise HTTPException(status_code=404, detail="\u7ed3\u679c\u4e0d\u5b58\u5728\u3002")
job_dir = (WORK_ROOT / job_id).resolve()
if not str(job_dir).startswith(str(WORK_ROOT.resolve())) or not job_dir.exists():
raise HTTPException(status_code=404, detail="结果不存在。")
raise HTTPException(status_code=404, detail="\u7ed3\u679c\u4e0d\u5b58\u5728\u3002")
return job_dir
def _render_result(result: ProcessingResult) -> str:
def _clean_preview_rows(preview_rows: int) -> int:
return max(5, min(int(preview_rows or 20), 200))
def _render_result(result: ProcessingResult, preview_rows: int = 20) -> str:
total_sheets = sum(len(file.sheets) for file in result.files)
total_rows = sum(max(sheet.rows - 1, 0) for file in result.files for sheet in file.sheets)
file_items = "\n".join(_render_file(file, result.job_id) for file in result.files)
body = f"""
<section class="summary">
<div class="metric"><span>处理模式</span><strong>{html.escape(result.mode.upper())}</strong></div>
<div class="metric"><span>Excel 文件</span><strong>{len(result.files)}</strong></div>
<div class="metric"><span>工作表</span><strong>{total_sheets}</strong></div>
<div class="metric"><span>数据行</span><strong>{total_rows}</strong></div>
<div class="metric"><span>\u5904\u7406\u6a21\u5f0f</span><strong>{html.escape(result.mode.upper())}</strong></div>
<div class="metric"><span>Excel \u6587\u4ef6</span><strong>{len(result.files)}</strong></div>
<div class="metric"><span>\u5de5\u4f5c\u8868</span><strong>{total_sheets}</strong></div>
<div class="metric"><span>\u6570\u636e\u884c</span><strong>{total_rows}</strong></div>
</section>
<section class="actions">
<a class="button" href="/download/all/{html.escape(result.job_id)}">导出全部 Excel</a>
<a class="ghost" href="/">继续处理新文件</a>
<a class="button" href="/download/all/{html.escape(result.job_id)}">\u5bfc\u51fa\u5168\u90e8 Excel</a>
<a class="ghost" href="/">\u7ee7\u7eed\u5904\u7406\u65b0\u6587\u4ef6</a>
</section>
<form class="row-control" method="get" action="/result/{html.escape(result.job_id)}">
<label for="preview_rows">\u6bcf\u4e2a\u5de5\u4f5c\u8868\u9884\u89c8\u884c\u6570</label>
<input id="preview_rows" name="preview_rows" type="number" min="5" max="200" value="{preview_rows}">
<button type="submit">\u5237\u65b0\u9884\u89c8</button>
</form>
<section class="hint">\u7ed3\u679c\u4e2d\u201c{UNMATCHED}\u201d\u5217\u4e3a\u989d\u5916\u8f85\u52a9\u4fe1\u606f\uff0c\u7528\u6765\u6807\u660e\u672a\u5f52\u5165\u6807\u51c6\u9879\u76ee\u7684\u68c0\u6d4b\u5185\u5bb9\u3002</section>
<section class="results">
{file_items}
</section>
"""
return _page_shell(body, subtitle="处理完成,可先查看结果摘要和部分预览,再选择导出。")
return _page_shell(body, subtitle="\u5904\u7406\u5b8c\u6210\uff0c\u53ef\u5411\u4e0b\u6eda\u52a8\u67e5\u770b\u66f4\u591a\u5de5\u4f5c\u8868\uff0c\u4e5f\u53ef\u8c03\u6574\u9884\u89c8\u884c\u6570\u540e\u5237\u65b0\u3002")
def _render_file(file, job_id: str) -> str:
sheet_items = "\n".join(_render_sheet(sheet) for sheet in file.sheets[:6])
more = ""
if len(file.sheets) > 6:
more = f'<div class="more">还有 {len(file.sheets) - 6} 个工作表未展开预览,可导出 Excel 查看完整内容。</div>'
sheet_items = "\n".join(_render_sheet(sheet) for sheet in file.sheets)
file_url = f"/download/file/{html.escape(job_id)}?path={quote(file.relpath)}"
return f"""
<article class="file-block">
<div class="file-head">
<div>
<h2>{html.escape(file.filename)}</h2>
<p>{len(file.sheets)} 个工作表</p>
<p>{len(file.sheets)} \u4e2a\u5de5\u4f5c\u8868</p>
</div>
<a class="small-button" href="{file_url}">导出此 Excel</a>
<a class="small-button" href="{file_url}">\u5bfc\u51fa\u6b64 Excel</a>
</div>
{sheet_items}
{more}
</article>
"""
def _render_sheet(sheet) -> str:
preview = sheet.preview[:6]
table = '<div class="empty">此工作表没有可预览的数据。</div>'
preview = sheet.preview
table = '<div class="empty">\u6b64\u5de5\u4f5c\u8868\u6ca1\u6709\u53ef\u9884\u89c8\u7684\u6570\u636e\u3002</div>'
if preview:
max_cols = min(max((len(row) for row in preview), default=0), 12)
max_cols = min(max((len(row) for row in preview), default=0), 18)
rows = []
for index, row in enumerate(preview):
cells = []
for value in (row + [""] * max_cols)[:max_cols]:
tag = "th" if index == 0 else "td"
cells.append(f"<{tag}>{html.escape(value)}</{tag}>")
css_class = ' class="extra-col"' if index == 0 and value == UNMATCHED else ""
cells.append(f"<{tag}{css_class}>{html.escape(value)}</{tag}>")
rows.append("<tr>" + "".join(cells) + "</tr>")
table = f"<div class=\"table-wrap\"><table>{''.join(rows)}</table></div>"
table = f'<div class="table-wrap"><table>{"".join(rows)}</table></div>'
return f"""
<details class="sheet" open>
<summary>
<span>{html.escape(sheet.name)}</span>
<small>{sheet.rows} · {sheet.columns} </small>
<small>{sheet.rows} \u884c · {sheet.columns} \u5217</small>
</summary>
{table}
</details>
"""
def _page_shell(body: str, subtitle: str = "上传“待处理检测数据.zip”处理完成后在网页中查看结果。") -> str:
def _page_shell(body: str, subtitle: str = "\u4e0a\u4f20\u201c\u5f85\u5904\u7406\u68c0\u6d4b\u6570\u636e.zip\u201d\uff0c\u5904\u7406\u5b8c\u6210\u540e\u5728\u7f51\u9875\u4e2d\u67e5\u770b\u7ed3\u679c\u3002") -> str:
return f"""
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>检测数据处理</title>
<title>{APP_TITLE}</title>
<style>
:root {{
color-scheme: light;
@@ -231,8 +249,10 @@ def _page_shell(body: str, subtitle: str = "上传“待处理检测数据.zip
--primary: #146c5c;
--primary-dark: #0f574b;
--soft: #eef7f5;
--extra: #fff7e6;
}}
* {{ box-sizing: border-box; }}
html, body {{ overflow-x: hidden; }}
body {{
margin: 0;
min-height: 100vh;
@@ -241,26 +261,21 @@ def _page_shell(body: str, subtitle: str = "上传“待处理检测数据.zip
color: var(--text);
}}
.shell {{
width: min(1120px, calc(100vw - 32px));
width: min(1600px, calc(100vw - 24px));
margin: 0 auto;
padding: 36px 0;
}}
header {{
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 24px;
padding: 24px 0;
min-width: 0;
}}
header {{ margin-bottom: 18px; }}
h1 {{
margin: 0;
font-size: 28px;
font-size: 26px;
line-height: 1.2;
font-weight: 750;
letter-spacing: 0;
}}
.sub {{
margin-top: 8px;
margin-top: 6px;
color: var(--muted);
font-size: 14px;
}}
@@ -268,54 +283,38 @@ def _page_shell(body: str, subtitle: str = "上传“待处理检测数据.zip
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 24px;
box-shadow: 0 10px 26px rgba(17, 24, 39, 0.06);
}}
form {{
display: grid;
gap: 18px;
padding: 18px;
box-shadow: 0 8px 22px rgba(17, 24, 39, 0.05);
min-width: 0;
}}
form {{ display: grid; gap: 16px; }}
label {{
display: block;
margin-bottom: 8px;
margin-bottom: 7px;
font-size: 14px;
font-weight: 650;
}}
input[type="file"], select, input[type="text"] {{
input[type="file"], select, input[type="text"], input[type="number"] {{
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
padding: 11px 12px;
padding: 10px 11px;
font: inherit;
background: #fff;
color: var(--text);
}}
.grid {{
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}}
.checks {{
display: flex;
flex-wrap: wrap;
gap: 16px;
color: var(--text);
font-size: 14px;
}}
.checks label {{
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
font-weight: 500;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}}
button, .button, .small-button, .ghost {{
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 42px;
min-height: 40px;
border-radius: 6px;
padding: 0 16px;
padding: 0 14px;
font: inherit;
font-weight: 700;
text-decoration: none;
@@ -332,25 +331,27 @@ def _page_shell(body: str, subtitle: str = "上传“待处理检测数据.zip
color: var(--text);
background: #fff;
}}
.note {{
.note, .hint {{
color: var(--muted);
font-size: 13px;
line-height: 1.6;
margin-top: 18px;
}}
.note {{
margin-top: 2px;
border-top: 1px solid var(--line);
padding-top: 16px;
padding-top: 14px;
}}
.summary {{
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 16px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}}
.metric {{
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 16px;
padding: 14px;
}}
.metric span {{
display: block;
@@ -358,33 +359,37 @@ def _page_shell(body: str, subtitle: str = "上传“待处理检测数据.zip
font-size: 13px;
margin-bottom: 8px;
}}
.metric strong {{
font-size: 24px;
line-height: 1;
}}
.actions {{
.metric strong {{ font-size: 24px; line-height: 1; }}
.actions, .row-control {{
display: flex;
gap: 12px;
margin: 0 0 18px;
align-items: end;
gap: 10px;
margin: 0 0 14px;
}}
.results {{
display: grid;
gap: 16px;
.row-control label {{
margin: 0;
color: var(--muted);
font-weight: 650;
}}
.row-control input {{ width: 120px; }}
.hint {{
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 10px 12px;
margin-bottom: 14px;
}}
.results {{ display: grid; gap: 14px; min-width: 0; }}
.file-head {{
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}}
h2 {{
margin: 0;
font-size: 18px;
letter-spacing: 0;
gap: 14px;
margin-bottom: 14px;
}}
h2 {{ margin: 0; font-size: 18px; letter-spacing: 0; }}
.file-head p {{
margin: 6px 0 0;
margin: 5px 0 0;
color: var(--muted);
font-size: 13px;
}}
@@ -393,53 +398,51 @@ def _page_shell(body: str, subtitle: str = "上传“待处理检测数据.zip
border-radius: 8px;
margin-top: 10px;
overflow: hidden;
min-width: 0;
}}
summary {{
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
padding: 11px 14px;
cursor: pointer;
background: var(--soft);
font-weight: 700;
}}
summary small {{
color: var(--muted);
font-weight: 500;
}}
summary small {{ color: var(--muted); font-weight: 500; }}
.table-wrap {{
width: 100%;
overflow: auto;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
background: #fff;
}}
table {{
width: 100%;
width: max-content;
min-width: 100%;
border-collapse: collapse;
font-size: 13px;
min-width: 720px;
}}
th, td {{
border-top: 1px solid var(--line);
border-right: 1px solid var(--line);
padding: 8px 10px;
padding: 7px 10px;
text-align: left;
vertical-align: top;
white-space: nowrap;
}}
th {{
background: #fbfcfd;
font-weight: 700;
}}
.empty, .more {{
th {{ background: #fbfcfd; font-weight: 700; }}
th.extra-col {{ background: var(--extra); }}
.empty {{
color: var(--muted);
font-size: 13px;
padding: 12px 14px;
}}
@media (max-width: 760px) {{
header {{ display: block; }}
.grid, .summary {{ grid-template-columns: 1fr; }}
.actions, .file-head {{ align-items: stretch; flex-direction: column; }}
.actions, .file-head, .row-control {{ align-items: stretch; flex-direction: column; }}
.row-control input {{ width: 100%; }}
button, .button, .small-button, .ghost {{ width: 100%; }}
}}
</style>
@@ -447,10 +450,8 @@ def _page_shell(body: str, subtitle: str = "上传“待处理检测数据.zip
<body>
<main class="shell">
<header>
<div>
<h1>检测数据处理</h1>
<div class="sub">{html.escape(subtitle)}</div>
</div>
<h1>{APP_TITLE}</h1>
<div class="sub">{html.escape(subtitle)}</div>
</header>
{body}
</main>

View File

@@ -48,6 +48,7 @@ def run_processing(
result_name: str,
show_not_match: bool,
show_all_infos: bool,
preview_rows: int = 20,
) -> ProcessingResult:
if mode not in {"auto", "v1", "v2"}:
raise ProcessingError("处理模式不正确。")
@@ -123,6 +124,9 @@ def run_processing(
if not xlsx_files:
raise ProcessingError("处理完成但没有生成 Excel 文件,请检查数据结构。")
for xlsx_file in xlsx_files:
_remove_default_empty_sheet(xlsx_file)
result_zip = job_dir / "result.zip"
_create_result_zip(output_dir, result_zip)
return ProcessingResult(
@@ -130,7 +134,7 @@ def run_processing(
mode=selected_mode,
output_dir=output_dir,
zip_path=result_zip,
files=[_summarize_workbook(path, output_dir) for path in xlsx_files],
files=[_summarize_workbook(path, output_dir, preview_rows) for path in xlsx_files],
)
@@ -143,6 +147,24 @@ def create_result_zip(job_dir: Path) -> Path:
return result_zip
def summarize_job(job_dir: Path, preview_rows: int = 20) -> ProcessingResult:
output_dir = job_dir / "output"
if not output_dir.exists():
raise ProcessingError("结果目录不存在。")
xlsx_files = sorted(output_dir.rglob("*.xlsx"))
if not xlsx_files:
raise ProcessingError("结果文件不存在。")
result_zip = job_dir / "result.zip"
mode = _read_mode(job_dir)
return ProcessingResult(
job_id=job_dir.name,
mode=mode,
output_dir=output_dir,
zip_path=result_zip,
files=[_summarize_workbook(path, output_dir, preview_rows) for path in xlsx_files],
)
def find_output_file(job_dir: Path, relpath: str) -> Path:
output_dir = (job_dir / "output").resolve()
target = (output_dir / relpath).resolve()
@@ -153,6 +175,16 @@ def find_output_file(job_dir: Path, relpath: str) -> Path:
return target
def _read_mode(job_dir: Path) -> str:
log_path = job_dir / "process.log"
if not log_path.exists():
return "unknown"
first_line = log_path.read_text(encoding="utf-8", errors="replace").splitlines()[0:1]
if first_line and first_line[0].startswith("mode="):
return first_line[0].split("=", 1)[1]
return "unknown"
def _safe_extract(zip_path: Path, target_dir: Path) -> None:
try:
with zipfile.ZipFile(zip_path) as zf:
@@ -223,13 +255,33 @@ def _create_result_zip(output_dir: Path, result_zip: Path) -> None:
zf.write(path, path.relative_to(output_dir))
def _summarize_workbook(path: Path, output_dir: Path) -> ExcelSummary:
def _remove_default_empty_sheet(path: Path) -> None:
workbook = load_workbook(path)
try:
if "Sheet" in workbook.sheetnames and len(workbook.sheetnames) > 1:
sheet = workbook["Sheet"]
if _is_empty_sheet(sheet):
workbook.remove(sheet)
workbook.save(path)
finally:
workbook.close()
def _is_empty_sheet(sheet) -> bool:
for row in sheet.iter_rows(values_only=True):
for value in row:
if value not in (None, ""):
return False
return True
def _summarize_workbook(path: Path, output_dir: Path, preview_rows: int) -> ExcelSummary:
sheets: list[SheetSummary] = []
workbook = load_workbook(path, read_only=True, data_only=True)
try:
for sheet in workbook.worksheets:
preview: list[list[str]] = []
for row in sheet.iter_rows(max_row=6, values_only=True):
for row in sheet.iter_rows(max_row=max(2, min(preview_rows, 200)), values_only=True):
preview.append([_cell_to_text(value) for value in row])
sheets.append(
SheetSummary(