66import os
77import re
88import sys
9+ import tempfile
910import threading
1011import time
1112import uuid
@@ -231,6 +232,9 @@ async def acquire(
231232STATUS_QUEUED_ON_SERVER = "Queued on server"
232233STATUS_PROCESSING_ON_SERVER = "Processing on server"
233234STATUS_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
235239BACKEND_CHOICE_DEFINITIONS = list (LOCAL_BACKEND_CHOICES )
236240HTTP_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 )
0 commit comments