Skip to content

Commit 9018907

Browse files
committed
fix: honor Gradio output directory setting
1 parent 79d6d8d commit 9018907

2 files changed

Lines changed: 236 additions & 4 deletions

File tree

mineru/cli/gradio_app.py

Lines changed: 137 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import os
77
import re
88
import sys
9+
import tempfile
910
import threading
1011
import time
1112
import uuid
@@ -231,6 +232,9 @@ async def acquire(
231232
STATUS_QUEUED_ON_SERVER = "Queued on server"
232233
STATUS_PROCESSING_ON_SERVER = "Processing on server"
233234
STATUS_QUEUED_LOCALLY_PREFIX = "Queued locally:"
235+
DEFAULT_GRADIO_OUTPUT_ROOT = "./output"
236+
GRADIO_OUTPUT_ROOT_STORAGE_KEY = "mineru.gradioOutputRoot"
237+
GRADIO_OUTPUT_ROOT_STORAGE_SECRET = "mineru-gradio-output-root-v1"
234238

235239
BACKEND_CHOICE_DEFINITIONS = list(LOCAL_BACKEND_CHOICES)
236240
HTTP_CLIENT_BACKEND_CHOICE_DEFINITIONS = list(HTTP_CLIENT_BACKEND_CHOICES)
@@ -882,15 +886,44 @@ def should_use_client_side_output_generation(client_side_output_generation):
882886
return client_side_output_generation
883887

884888

885-
def create_gradio_run_paths(file_path, output_root="./output"):
889+
def normalize_gradio_output_root(output_root=DEFAULT_GRADIO_OUTPUT_ROOT):
890+
"""创建并校验 Gradio 输出根目录,成功后返回规范化绝对路径。"""
891+
raw_output_root = str(output_root or "").strip()
892+
if not raw_output_root:
893+
raise ValueError("output directory cannot be empty")
894+
895+
normalized_root = Path(raw_output_root).expanduser().resolve()
896+
try:
897+
normalized_root.mkdir(parents=True, exist_ok=True)
898+
if not normalized_root.is_dir():
899+
raise NotADirectoryError(f"{normalized_root} is not a directory")
900+
with tempfile.NamedTemporaryFile(
901+
dir=normalized_root,
902+
prefix=".mineru-write-test-",
903+
):
904+
pass
905+
except OSError as exc:
906+
raise ValueError(f"output directory is not writable: {normalized_root}") from exc
907+
return str(normalized_root)
908+
909+
910+
def register_gradio_allowed_path(allowed_paths, output_root):
911+
"""把已校验输出目录加入 Gradio 可访问路径,保证结果下载与预览可用。"""
912+
normalized_root = str(Path(output_root).expanduser().resolve())
913+
if normalized_root not in allowed_paths:
914+
allowed_paths.append(normalized_root)
915+
return normalized_root
916+
917+
918+
def create_gradio_run_paths(file_path, output_root=DEFAULT_GRADIO_OUTPUT_ROOT):
886919
run_id = f"{time.strftime('%y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}_{safe_stem(Path(file_path).stem)}"
887920
run_root = Path(output_root) / "gradio" / run_id
888921
extract_root = run_root / "result"
889922
archive_zip_path = run_root / f"{safe_stem(Path(file_path).stem)}.zip"
890923
return run_root, extract_root, archive_zip_path
891924

892925

893-
def build_gradio_allowed_paths(output_root="./output"):
926+
def build_gradio_allowed_paths(output_root=DEFAULT_GRADIO_OUTPUT_ROOT):
894927
"""生成 Gradio 可公开访问目录,确保预览 HTTP 图片链接能被 /gradio_api/file= 读取。"""
895928
allowed_paths = []
896929
for item in os.environ.get("GRADIO_ALLOWED_PATHS", "").split(","):
@@ -1017,6 +1050,7 @@ async def _run_to_markdown_job(
10171050
api_url=None,
10181051
client_side_output_generation=False,
10191052
status_callback: Callable[[str], None] | None = None,
1053+
output_root=DEFAULT_GRADIO_OUTPUT_ROOT,
10201054
):
10211055
if file_path is None:
10221056
return "", "", "", None, None
@@ -1032,7 +1066,10 @@ def emit_status(message: str) -> None:
10321066
client_side_output_generation
10331067
)
10341068
parse_method = resolve_parse_method(file_path, is_ocr, backend)
1035-
run_root, extract_root, archive_zip_path = create_gradio_run_paths(file_path)
1069+
run_root, extract_root, archive_zip_path = create_gradio_run_paths(
1070+
file_path,
1071+
output_root=output_root,
1072+
)
10361073
run_root.mkdir(parents=True, exist_ok=True)
10371074

10381075
form_data = _api_client.build_parse_request_form_data(
@@ -1168,6 +1205,7 @@ async def stream_to_markdown(
11681205
url=None,
11691206
api_url=None,
11701207
client_side_output_generation=False,
1208+
output_root=DEFAULT_GRADIO_OUTPUT_ROOT,
11711209
):
11721210
status_state = StatusPanelState()
11731211
job_task: asyncio.Task | None = None
@@ -1200,6 +1238,7 @@ def enqueue_status(message: str) -> None:
12001238
api_url=api_url,
12011239
client_side_output_generation=client_side_output_generation,
12021240
status_callback=enqueue_status,
1241+
output_root=output_root,
12031242
)
12041243
)
12051244

@@ -1594,6 +1633,11 @@ def main(ctx,
15941633
"server_url_info": "OpenAI-compatible server URL for http-client backend.",
15951634
"recognition_options": "**Recognition Options:**",
15961635
"advanced_options": "Advanced options",
1636+
"output_root": "File save location",
1637+
"output_root_info": "Click Save to apply this location to subsequent conversions.",
1638+
"save_output_root": "Save",
1639+
"output_root_saved": "Save location updated: {path}",
1640+
"output_root_save_failed": "Unable to update save location: {error}",
15971641
"table_enable": "Enable table recognition",
15981642
"table_info": "If disabled, tables will be shown as images.",
15991643
"image_analysis_enable": "Enable image analysis",
@@ -1670,6 +1714,11 @@ def main(ctx,
16701714
"server_url_info": "http-client 后端的 OpenAI 兼容服务器地址。",
16711715
"recognition_options": "**识别选项:**",
16721716
"advanced_options": "高级选项",
1717+
"output_root": "文件保存位置",
1718+
"output_root_info": "点击保存后,该位置将用于后续转换。",
1719+
"save_output_root": "保存",
1720+
"output_root_saved": "保存位置已更新:{path}",
1721+
"output_root_save_failed": "无法更新保存位置:{error}",
16731722
"table_enable": "启用表格识别",
16741723
"table_info": "禁用后,表格将显示为图片。",
16751724
"image_analysis_enable": "启用图片分析",
@@ -1795,9 +1844,11 @@ async def convert_to_markdown_stream(
17951844
language="ch",
17961845
backend="pipeline",
17971846
url=None,
1847+
output_root=DEFAULT_GRADIO_OUTPUT_ROOT,
17981848
request: gr.Request = None,
17991849
):
18001850
request_locale = resolve_request_locale(request)
1851+
normalized_output_root = normalize_gradio_output_root(output_root)
18011852
async for update in stream_to_markdown(
18021853
file_path=file_path,
18031854
end_pages=end_pages,
@@ -1811,6 +1862,7 @@ async def convert_to_markdown_stream(
18111862
url=url,
18121863
api_url=api_url,
18131864
client_side_output_generation=client_side_output_generation,
1865+
output_root=normalized_output_root,
18141866
):
18151867
update = (
18161868
render_status_steps_html(update[0], i18n, locale=request_locale),
@@ -1823,6 +1875,12 @@ async def convert_to_markdown_stream(
18231875
suffixes = [f".{suffix}" for suffix in pdf_suffixes + image_suffixes + office_suffixes]
18241876
_blocks_kwargs = {} if IS_GRADIO_6 else {"css": APP_CSS, "js": APP_JS}
18251877
with gr.Blocks(**_blocks_kwargs) as demo:
1878+
output_root_state = gr.State(DEFAULT_GRADIO_OUTPUT_ROOT)
1879+
output_root_browser_state = gr.BrowserState(
1880+
default_value=DEFAULT_GRADIO_OUTPUT_ROOT,
1881+
storage_key=GRADIO_OUTPUT_ROOT_STORAGE_KEY,
1882+
secret=GRADIO_OUTPUT_ROOT_STORAGE_SECRET,
1883+
)
18261884
gr.HTML(render_header_html(i18n), elem_classes=["mineru-header-html"])
18271885
with gr.Row(elem_classes=["mineru-workspace-row"]):
18281886
with gr.Column(variant='panel', scale=2, min_width=280, elem_classes=["mineru-control-column"]):
@@ -1976,6 +2034,16 @@ async def convert_to_markdown_stream(
19762034
value=False,
19772035
info=i18n(select_force_ocr_info_key(preferred_option)),
19782036
)
2037+
with gr.Group():
2038+
output_root_input = gr.Textbox(
2039+
label=i18n("output_root"),
2040+
value=DEFAULT_GRADIO_OUTPUT_ROOT,
2041+
info=i18n("output_root_info"),
2042+
)
2043+
save_output_root_button = gr.Button(
2044+
i18n("save_output_root"),
2045+
size="sm",
2046+
)
19792047

19802048
# 添加事件处理
19812049
_private_api_kwargs = (
@@ -1996,6 +2064,59 @@ async def convert_to_markdown_stream(
19962064
outputs=[is_ocr, formula_enable, backend],
19972065
**_private_api_kwargs
19982066
)
2067+
2068+
def restore_output_root_for_ui(saved_output_root):
2069+
"""恢复浏览器保存的路径;失效时静默回退默认目录。"""
2070+
try:
2071+
normalized_root = normalize_gradio_output_root(saved_output_root)
2072+
except ValueError:
2073+
normalized_root = normalize_gradio_output_root()
2074+
register_gradio_allowed_path(demo.allowed_paths, normalized_root)
2075+
return normalized_root, normalized_root, normalized_root
2076+
2077+
def save_output_root_for_ui(output_root, request: gr.Request):
2078+
"""校验并提交新的输出目录,失败时不覆盖现有有效状态。"""
2079+
request_locale = resolve_request_locale(request)
2080+
try:
2081+
normalized_root = normalize_gradio_output_root(output_root)
2082+
except ValueError as exc:
2083+
message = translate_ui(
2084+
i18n,
2085+
"output_root_save_failed",
2086+
request_locale,
2087+
).format(error=str(exc))
2088+
raise gr.Error(message) from exc
2089+
2090+
register_gradio_allowed_path(demo.allowed_paths, normalized_root)
2091+
gr.Info(
2092+
translate_ui(
2093+
i18n,
2094+
"output_root_saved",
2095+
request_locale,
2096+
).format(path=normalized_root)
2097+
)
2098+
return normalized_root, normalized_root, normalized_root
2099+
2100+
demo.load(
2101+
fn=restore_output_root_for_ui,
2102+
inputs=[output_root_browser_state],
2103+
outputs=[
2104+
output_root_input,
2105+
output_root_state,
2106+
output_root_browser_state,
2107+
],
2108+
**_private_api_kwargs
2109+
)
2110+
save_output_root_button.click(
2111+
fn=save_output_root_for_ui,
2112+
inputs=[output_root_input],
2113+
outputs=[
2114+
output_root_input,
2115+
output_root_state,
2116+
output_root_browser_state,
2117+
],
2118+
**_private_api_kwargs
2119+
)
19992120
clear_bu.add([input_file, md, doc_show, md_text, content_list_json, output_file, is_ocr, office_html, status_panel])
20002121

20012122
def reset_primary_ui():
@@ -2049,7 +2170,19 @@ def update_file_options_html_for_ui(file_path, request: gr.Request):
20492170
)
20502171
change_bu.click(
20512172
fn=convert_to_markdown_stream,
2052-
inputs=[input_file, max_pages, is_ocr, formula_enable, table_enable, image_analysis, hybrid_effort, language, backend, url],
2173+
inputs=[
2174+
input_file,
2175+
max_pages,
2176+
is_ocr,
2177+
formula_enable,
2178+
table_enable,
2179+
image_analysis,
2180+
hybrid_effort,
2181+
language,
2182+
backend,
2183+
url,
2184+
output_root_state,
2185+
],
20532186
outputs=[status_panel, output_file, md, md_text, content_list_json, doc_show],
20542187
**_to_md_api_kwargs
20552188
)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import asyncio
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
from mineru.cli import gradio_app
7+
from mineru.cli.gradio_app import (
8+
create_gradio_run_paths,
9+
normalize_gradio_output_root,
10+
register_gradio_allowed_path,
11+
)
12+
13+
14+
def test_normalize_gradio_output_root_creates_directory(tmp_path):
15+
output_root = tmp_path / "nested" / "results"
16+
17+
normalized_root = normalize_gradio_output_root(output_root)
18+
19+
assert normalized_root == str(output_root.resolve())
20+
assert output_root.is_dir()
21+
assert not list(output_root.glob(".mineru-write-test-*"))
22+
23+
24+
@pytest.mark.parametrize("output_root", ["", " ", None])
25+
def test_normalize_gradio_output_root_rejects_empty_value(output_root):
26+
with pytest.raises(ValueError, match="cannot be empty"):
27+
normalize_gradio_output_root(output_root)
28+
29+
30+
def test_normalize_gradio_output_root_rejects_file(tmp_path):
31+
output_file = tmp_path / "result.txt"
32+
output_file.write_text("not a directory", encoding="utf-8")
33+
34+
with pytest.raises(ValueError, match="not writable"):
35+
normalize_gradio_output_root(output_file)
36+
37+
38+
def test_register_gradio_allowed_path_normalizes_and_deduplicates(tmp_path):
39+
allowed_paths = []
40+
41+
normalized_root = register_gradio_allowed_path(allowed_paths, tmp_path)
42+
register_gradio_allowed_path(allowed_paths, tmp_path / ".")
43+
44+
assert normalized_root == str(tmp_path.resolve())
45+
assert allowed_paths == [normalized_root]
46+
47+
48+
def test_create_gradio_run_paths_uses_custom_output_root(tmp_path):
49+
run_root, extract_root, archive_zip_path = create_gradio_run_paths(
50+
"sample.pdf",
51+
output_root=tmp_path,
52+
)
53+
54+
assert run_root.parent == tmp_path / "gradio"
55+
assert extract_root == run_root / "result"
56+
assert archive_zip_path.parent == run_root
57+
assert archive_zip_path.name == "sample.zip"
58+
59+
60+
def test_create_gradio_run_paths_keeps_default_output_root(monkeypatch, tmp_path):
61+
monkeypatch.chdir(tmp_path)
62+
63+
run_root, _, _ = create_gradio_run_paths("sample.pdf")
64+
65+
assert run_root.parent == Path("./output/gradio")
66+
67+
68+
def test_stream_to_markdown_forwards_output_root(monkeypatch, tmp_path):
69+
captured = {}
70+
71+
async def fake_run_to_markdown_job(**kwargs):
72+
captured.update(kwargs)
73+
return "rendered", "source", "{}", "result.zip", "preview.pdf"
74+
75+
monkeypatch.setattr(
76+
gradio_app,
77+
"_run_to_markdown_job",
78+
fake_run_to_markdown_job,
79+
)
80+
81+
async def collect_updates():
82+
return [
83+
update
84+
async for update in gradio_app.stream_to_markdown(
85+
file_path="sample.pdf",
86+
output_root=str(tmp_path),
87+
)
88+
]
89+
90+
updates = asyncio.run(collect_updates())
91+
92+
assert captured["output_root"] == str(tmp_path)
93+
assert updates[-1][1:] == (
94+
"result.zip",
95+
"rendered",
96+
"source",
97+
"{}",
98+
"preview.pdf",
99+
)

0 commit comments

Comments
 (0)