markobinario commited on
Commit
88ff397
·
verified ·
1 Parent(s): 20369a1

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +121 -307
app.py CHANGED
@@ -1,319 +1,133 @@
1
- import base64
2
  import io
3
  import json
4
- import os
5
- from typing import Dict, List, Tuple, Any, Optional
6
- import time
7
- import requests
8
  from PIL import Image
9
  import gradio as gr
10
- # =========================
11
- # Config
12
- # =========================
13
- DEFAULT_API_URL = os.environ.get("API_URL")
14
- TOKEN = os.environ.get("TOKEN")
15
- LOGO_IMAGE_PATH = './assets/logo.jpg'
16
- GOOGLE_FONTS_URL = "<link href='https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&display=swap' rel='stylesheet'>"
17
- LATEX_DELIMS = [
18
- {"left": "$$", "right": "$$", "display": True},
19
- {"left": "$", "right": "$", "display": False},
20
- {"left": "\\(", "right": "\\)", "display": False},
21
- {"left": "\\[", "right": "\\]", "display": True},
22
- ]
23
- AUTH_HEADER = {"Authorization": f"bearer {TOKEN}"}
24
- JSON_HEADERS = {**AUTH_HEADER, "Content-Type": "application/json"}
25
- # =========================
26
- # Base64 and Example Loading Logic
27
- # =========================
28
- def image_to_base64_data_url(filepath: str) -> str:
29
- """Reads a local image file and encodes it into a Base64 Data URL."""
30
- try:
31
- ext = os.path.splitext(filepath)[1].lower()
32
- mime_types = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif'}
33
- mime_type = mime_types.get(ext, 'image/jpeg')
34
- with open(filepath, "rb") as image_file:
35
- encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
36
- return f"data:{mime_type};base64,{encoded_string}"
37
- except Exception as e:
38
- print(f"Error encoding image to Base64: {e}")
39
- return ""
40
-
41
-
42
- def _get_examples_from_dir(dir_path: str) -> List[List[str]]:
43
- supported_exts = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
44
- examples = []
45
- if not os.path.exists(dir_path): return []
46
- for filename in sorted(os.listdir(dir_path)):
47
- if os.path.splitext(filename)[1].lower() in supported_exts:
48
- examples.append([os.path.join(dir_path, filename)])
49
- return examples
50
-
51
- TARGETED_EXAMPLES_DIR = "examples/targeted"
52
- COMPLEX_EXAMPLES_DIR = "examples/complex"
53
- targeted_recognition_examples = _get_examples_from_dir(TARGETED_EXAMPLES_DIR)
54
- complex_document_examples = _get_examples_from_dir(COMPLEX_EXAMPLES_DIR)
55
-
56
- # =========================
57
- # UI Helpers
58
- # =========================
59
- def render_uploaded_image_div(file_path: str) -> str:
60
- data_url = image_to_base64_data_url(file_path)
61
- return f"""
62
- <div class="uploaded-image">
63
- <img src="{data_url}" alt="Uploaded image" style="width:100%;height:100%;object-fit:contain;"/>
64
- </div>
65
- """
66
 
67
- def update_preview_visibility(file_path: Optional[str]) -> Dict:
68
- if file_path:
69
- html_content = render_uploaded_image_div(file_path)
70
- return gr.update(value=html_content, visible=True)
71
- else:
72
- return gr.update(value="", visible=False)
73
 
74
- def _on_gallery_select(example_paths: List[str], evt: gr.SelectData):
75
- try:
76
- idx = evt.index
77
- return example_paths[idx]
78
- except Exception:
79
- return None
80
-
81
- # =========================
82
- # API Call Logic
83
- # =========================
84
- def _file_to_b64_image_only(file_path: str) -> Tuple[str, int]:
85
- if not file_path: raise ValueError("Please upload an image first.")
86
- ext = os.path.splitext(file_path)[1].lower()
87
- if ext not in {".png", ".jpg", ".jpeg", ".bmp", ".webp"}: raise ValueError("Only image files are supported.")
88
- with open(file_path, "rb") as f:
89
- return base64.b64encode(f.read()).decode("utf-8"), 1
90
-
91
- def _call_api(api_url: str, file_path: str, use_layout_detection: bool,
92
- prompt_label: Optional[str], use_chart_recognition: bool = False) -> Dict[str, Any]:
93
- b64, file_type = _file_to_b64_image_only(file_path)
94
- payload = {
95
- "file": b64,
96
- "useLayoutDetection": bool(use_layout_detection),
97
- "fileType": file_type,
98
- "layoutMergeBboxesMode": "union",
99
- }
100
- if not use_layout_detection:
101
- if not prompt_label:
102
- raise ValueError("Please select a recognition type.")
103
- payload["promptLabel"] = prompt_label.strip().lower()
104
- if use_layout_detection and use_chart_recognition:
105
- payload["useChartRecognition"] = True
106
 
107
- try:
108
- print(f"Sending API request to {api_url}...")
109
- start_time = time.time()
110
- resp = requests.post(api_url, json=payload, headers=JSON_HEADERS, timeout=600)
111
- end_time = time.time()
112
- duration = end_time - start_time
113
- print(f"Received API response in {duration:.2f} seconds.")
114
-
115
- resp.raise_for_status()
116
- data = resp.json()
117
- except requests.exceptions.RequestException as e:
118
- raise gr.Error(f"API request failed:{e}")
119
- except json.JSONDecodeError:
120
- raise gr.Error(f"Invalid JSON response from server:\n{getattr(resp, 'text', '')}")
121
-
122
- if data.get("errorCode", -1) != 0:
123
- raise gr.Error("API returned an error:")
124
- return data
125
-
126
-
127
- # =========================
128
- # API Response Processing
129
- # =========================
130
-
131
- # 【改动点】: 这个函数现在不再需要,因为我们不再将URL下载为PIL Image对象。
132
- # def url_to_pil_image(url: str) -> Optional[Image.Image]:
133
- # """Downloads an image from a URL and returns it as a PIL Image object for the Gradio Image component."""
134
- # if not url or not url.startswith(('http://', 'https://')):
135
- # print(f"Warning: Invalid URL provided for visualization image: {url}")
136
- # return None
137
- # try:
138
- # start_time = time.time()
139
- # response = requests.get(url, timeout=600)
140
- # end_time = time.time()
141
- # print(f"Fetched visualization image from {url} in {end_time - start_time:.2f} seconds.")
142
- #
143
- # response.raise_for_status()
144
- # image_bytes = response.content
145
- # pil_image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
146
- # return pil_image
147
- # except requests.exceptions.RequestException as e:
148
- # print(f"Error fetching visualization image from URL {url}: {e}")
149
- # return None
150
- # except Exception as e:
151
- # print(f"Error processing visualization image from URL {url}: {e}")
152
- # return None
153
-
154
- def _process_api_response_page(result: Dict[str, Any]) -> Tuple[str, str, str]:
 
155
  """
156
- Processes the API response.
157
- 1. Replaces markdown image placeholders with their direct URLs.
158
- 2. Constructs an HTML <img> tag string for the visualization image URL.
159
  """
160
- layout_results = (result or {}).get("layoutParsingResults", [])
161
- if not layout_results:
162
- return "No content was recognized.", "<p>No visualization available.</p>", ""
163
-
164
- page0 = layout_results[0] or {}
165
-
166
- # Step 1: Process Markdown content (unchanged from previous optimization)
167
- md_data = page0.get("markdown") or {}
168
- md_text = md_data.get("text", "") or ""
169
- md_images_map = md_data.get("images", {})
170
- if md_images_map:
171
- for placeholder_path, image_url in md_images_map.items():
172
- md_text = md_text.replace(f'src="{placeholder_path}"', f'src="{image_url}"') \
173
- .replace(f']({placeholder_path})', f']({image_url})')
174
-
175
- # 【核心改动点】 Step 2: Process Visualization images by creating an HTML string
176
- output_html = "<p style='text-align:center; color:#888;'>No visualization image available.</p>"
177
- out_imgs = page0.get("outputImages") or {}
178
-
179
- # Get all image URLs and sort them
180
- sorted_urls = [img_url for _, img_url in sorted(out_imgs.items()) if img_url]
181
-
182
- # Logic to select the final visualization image URL
183
- output_image_url: Optional[str] = None
184
- if len(sorted_urls) >= 2:
185
- output_image_url = sorted_urls[1]
186
- elif sorted_urls:
187
- output_image_url = sorted_urls[0]
188
-
189
- # If a URL was found, create the <img> tag
190
- if output_image_url:
191
- print(f"Found visualization image URL: {output_image_url}")
192
- # The CSS will style this `img` tag because of the `#vis_image_doc img` selector
193
- output_html = f'<img src="{output_image_url}" alt="Detection Visualization">'
194
  else:
195
- print("Warning: No visualization image URL found in the API response.")
196
-
197
- return md_text or "(Empty result)", output_html, md_text
198
-
199
- # =========================
200
- # Handlers
201
- # =========================
202
- def handle_complex_doc(file_path: str, use_chart_recognition: bool) -> Tuple[str, str, str]:
203
- if not file_path: raise gr.Error("Please upload an image first.")
204
- data = _call_api(DEFAULT_API_URL, file_path, use_layout_detection=True, prompt_label=None, use_chart_recognition=use_chart_recognition)
205
- result = data.get("result", {})
206
- # Note the return types now align with the new function signature
207
- return _process_api_response_page(result)
208
-
209
- def handle_targeted_recognition(file_path: str, prompt_choice: str) -> Tuple[str, str]:
210
- if not file_path: raise gr.Error("Please upload an image first.")
211
- mapping = {"Text Recognition": "ocr", "Formula Recognition": "formula", "Table Recognition": "table", "Chart Recognition": "chart"}
212
- label = mapping.get(prompt_choice, "ocr")
213
- data = _call_api(DEFAULT_API_URL, file_path, use_layout_detection=False, prompt_label=label)
214
- result = data.get("result", {})
215
- md_preview, _, md_raw = _process_api_response_page(result)
216
- return md_preview, md_raw
217
-
218
- # =========================
219
- # CSS & UI
220
- # =========================
221
- custom_css = """
222
- /* 全局字体 */
223
- body, .gradio-container {
224
- font-family: "Noto Sans SC", "Microsoft YaHei", "PingFang SC", sans-serif;
225
- }
226
- /* ... (rest of the CSS is unchanged) ... */
227
- .app-header { text-align: center; max-width: 900px; margin: 0 auto 8px !important; }
228
- .gradio-container { padding: 4px 0 !important; }
229
- .gradio-container [data-testid="tabs"], .gradio-container .tabs { margin-top: 0 !important; }
230
- .gradio-container [data-testid="tabitem"], .gradio-container .tabitem { padding-top: 4px !important; }
231
- .quick-links { text-align: center; padding: 8px 0; border: 1px solid #e5e7eb; border-radius: 8px; margin: 8px auto; max-width: 900px; }
232
- .quick-links a { margin: 0 12px; font-size: 14px; font-weight: 600; color: #3b82f6; text-decoration: none; }
233
- .quick-links a:hover { text-decoration: underline; }
234
- .prompt-grid { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; }
235
- .prompt-grid button { height: 40px !important; padding: 0 12px !important; border-radius: 8px !important; font-weight: 600 !important; font-size: 13px !important; letter-spacing: 0.2px; }
236
- #image_preview_vl, #image_preview_doc { height: 400px !important; overflow: auto; }
237
- #image_preview_vl img, #image_preview_doc img, #vis_image_doc img { width: 100% !important; height: auto !important; object-fit: contain !important; display: block; }
238
- #md_preview_vl, #md_preview_doc { max-height: 540px; min-height: 180px; overflow: auto; scrollbar-gutter: stable both-edges; }
239
- #md_preview_vl .prose, #md_preview_doc .prose { line-height: 1.7 !important; }
240
- #md_preview_vl .prose img, #md_preview_doc .prose img { display: block; margin: 0 auto; max-width: 100%; height: auto; }
241
- .notice { margin: 8px auto 0; max-width: 900px; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 8px; background: #f8fafc; font-size: 14px; line-height: 1.6; }
242
- .notice strong { font-weight: 700; }
243
- .notice a { color: #3b82f6; text-decoration: none; }
244
- .notice a:hover { text-decoration: underline; }
245
- """
246
-
247
- with gr.Blocks(head=GOOGLE_FONTS_URL, css=custom_css, theme=gr.themes.Soft()) as demo:
248
- logo_data_url = image_to_base64_data_url(LOGO_IMAGE_PATH) if os.path.exists(LOGO_IMAGE_PATH) else ""
249
- gr.HTML(f"""<div class="app-header"><img src="{logo_data_url}" alt="App Logo" style="max-height:10%; width: auto; margin: 10px auto; display: block;"></div>""")
250
- gr.HTML("""<div class="notice"><strong>Heads up:</strong> The Hugging Face demo can be slow at times. For a faster experience, please try <a href="https://aistudio.baidu.com/application/detail/98365" target="_blank" rel="noopener noreferrer">Baidu AI Studio</a> or <a href="https://modelscope.cn/studios/PaddlePaddle/PaddleOCR-VL_Online_Demo/summary" target="_blank" rel="noopener noreferrer">ModelScope</a>.</div>""")
251
- gr.HTML("""<div class="quick-links"><a href="https://github.com/PaddlePaddle/PaddleOCR" target="_blank">GitHub</a> | <a href="https://ernie.baidu.com/blog/publication/PaddleOCR-VL_Technical_Report.pdf" target="_blank">Technical Report</a> | <a href="https://huggingface.co/PaddlePaddle/PaddleOCR-VL" target="_blank">Model</a></div>""")
252
-
253
- with gr.Tabs():
254
- with gr.Tab("Document Parsing"):
255
- with gr.Row():
256
- with gr.Column(scale=5):
257
- file_doc = gr.File(label="Upload Image", file_count="single", type="filepath", file_types=["image"])
258
- preview_doc_html = gr.HTML(value="", elem_id="image_preview_doc", visible=False)
259
- gr.Markdown("_( Use this mode for recognizing full-page documents with structured layouts, such as reports, papers, or magazines.)_")
260
- gr.Markdown("💡 *To recognize a single, pre-cropped element (e.g., a table or formula), switch to the 'Element-level Recognition' tab for better results.*")
261
- with gr.Row(variant="panel"):
262
- chart_parsing_switch = gr.Checkbox(label="Enable chart parsing", value=False, scale=1)
263
- btn_parse = gr.Button("Parse Document", variant="primary", scale=2)
264
- if complex_document_examples:
265
- complex_paths = [e[0] for e in complex_document_examples]
266
- complex_state = gr.State(complex_paths)
267
- gr.Markdown("**Document Examples (Click an image to load)**")
268
- gallery_complex = gr.Gallery(value=complex_paths, columns=4, height=400, preview=False, label=None, allow_preview=False)
269
- gallery_complex.select(fn=_on_gallery_select, inputs=[complex_state], outputs=[file_doc])
270
-
271
- with gr.Column(scale=7):
272
- with gr.Tabs():
273
- with gr.Tab("Markdown Preview"):
274
- md_preview_doc = gr.Markdown("Please upload an image and click 'Parse Document'.", latex_delimiters=LATEX_DELIMS, elem_id="md_preview_doc")
275
- with gr.Tab("Visualization"):
276
- # 【核心改动点】: 将 gr.Image 替换为 gr.HTML
277
- vis_image_doc = gr.HTML(label="Detection Visualization", elem_id="vis_image_doc")
278
- with gr.Tab("Markdown Source"):
279
- md_raw_doc = gr.Code(label="Markdown Source Code", language="markdown")
280
-
281
- file_doc.change(fn=update_preview_visibility, inputs=[file_doc], outputs=[preview_doc_html])
282
- btn_parse.click(fn=handle_complex_doc, inputs=[file_doc, chart_parsing_switch], outputs=[md_preview_doc, vis_image_doc, md_raw_doc])
283
-
284
- with gr.Tab("Element-level Recognition"):
285
- with gr.Row():
286
- with gr.Column(scale=5):
287
- file_vl = gr.File(label="Upload Image", file_count="single", type="filepath", file_types=["image"])
288
- preview_vl_html = gr.HTML(value="", elem_id="image_preview_vl", visible=False)
289
- gr.Markdown("_(Best for images with a **simple, single-column layout** (e.g., pure text), or for a **pre-cropped single element** like a table, formula, or chart.)_")
290
- gr.Markdown("Choose a recognition type:")
291
- with gr.Row(elem_classes=["prompt-grid"]):
292
- btn_ocr = gr.Button("Text Recognition", variant="secondary")
293
- btn_formula = gr.Button("Formula Recognition", "secondary")
294
- with gr.Row(elem_classes=["prompt-grid"]):
295
- btn_table = gr.Button("Table Recognition", variant="secondary")
296
- btn_chart = gr.Button("Chart Recognition", variant="secondary")
297
- if targeted_recognition_examples:
298
- targeted_paths = [e[0] for e in targeted_recognition_examples]
299
- targeted_state = gr.State(targeted_paths)
300
- gr.Markdown("**Element-level Recognition Examples (Click an image to load)**")
301
- gallery_targeted = gr.Gallery(value=targeted_paths, columns=4, height=400, preview=False, label=None, allow_preview=False)
302
- gallery_targeted.select(fn=_on_gallery_select, inputs=[targeted_state], outputs=[file_vl])
303
-
304
- with gr.Column(scale=7):
305
- with gr.Tabs():
306
- with gr.Tab("Recognition Result"):
307
- md_preview_vl = gr.Markdown("Please upload an image and click a recognition type.", latex_delimiters=LATEX_DELIMS, elem_id="md_preview_vl")
308
- with gr.Tab("Raw Output"):
309
- md_raw_vl = gr.Code(label="Raw Output", language="markdown")
310
-
311
- file_vl.change(fn=update_preview_visibility, inputs=[file_vl], outputs=[preview_vl_html])
312
- btn_ocr.click(fn=handle_targeted_recognition, inputs=[file_vl, gr.State("Text Recognition")], outputs=[md_preview_vl, md_raw_vl])
313
- btn_formula.click(fn=handle_targeted_recognition, inputs=[file_vl, gr.State("Formula Recognition")], outputs=[md_preview_vl, md_raw_vl])
314
- btn_table.click(fn=handle_targeted_recognition, inputs=[file_vl, gr.State("Table Recognition")], outputs=[md_preview_vl, md_raw_vl])
315
- btn_chart.click(fn=handle_targeted_recognition, inputs=[file_vl, gr.State("Chart Recognition")], outputs=[md_preview_vl, md_raw_vl])
316
 
317
  if __name__ == "__main__":
318
- port = int(os.getenv("PORT", "7860"))
319
- demo.queue(max_size=6).launch(server_name="0.0.0.0", server_port=port,share=False)
 
1
+ import os
2
  import io
3
  import json
4
+ from typing import List, Tuple, Dict, Any
5
+
6
+ import fitz # PyMuPDF
 
7
  from PIL import Image
8
  import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
 
 
 
 
 
 
10
 
11
+ # Lazy-load the OCR model to reduce startup time and memory
12
+ _ocr_model = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+
15
+ def get_ocr_model(lang: str = "en"):
16
+ global _ocr_model
17
+ if _ocr_model is not None:
18
+ return _ocr_model
19
+
20
+ # PaddleOCR supports language packs like 'en', 'ch', 'fr', 'german', etc.
21
+ # The Spaces container will download the model weights on first run and cache them.
22
+ from paddleocr import PaddleOCR # import here to avoid heavy import at startup
23
+
24
+ _ocr_model = PaddleOCR(use_angle_cls=True, lang=lang, show_log=False)
25
+ return _ocr_model
26
+
27
+
28
+ def pdf_page_to_image(pdf_doc: fitz.Document, page_index: int, dpi: int = 170) -> Image.Image:
29
+ page = pdf_doc.load_page(page_index)
30
+ zoom = dpi / 72.0 # 72 dpi is PDF default
31
+ mat = fitz.Matrix(zoom, zoom)
32
+ pix = page.get_pixmap(matrix=mat, alpha=False)
33
+ img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
34
+ return img
35
+
36
+
37
+ def run_paddle_ocr_on_image(image: Image.Image, lang: str = "en") -> Tuple[str, List[Dict[str, Any]]]:
38
+ ocr = get_ocr_model(lang=lang)
39
+ # Convert PIL image to numpy array for PaddleOCR
40
+ import numpy as np
41
+
42
+ img_np = np.array(image)
43
+ result = ocr.ocr(img_np, cls=True)
44
+
45
+ lines: List[str] = []
46
+ items: List[Dict[str, Any]] = []
47
+
48
+ # PaddleOCR returns list per image: [[(box, (text, conf)), ...]]
49
+ for page_result in result:
50
+ if page_result is None:
51
+ continue
52
+ for det in page_result:
53
+ box = det[0]
54
+ text = det[1][0]
55
+ conf = float(det[1][1])
56
+ lines.append(text)
57
+ items.append({"bbox": box, "text": text, "confidence": conf})
58
+
59
+ return "\n".join(lines), items
60
+
61
+
62
+ def extract_text_from_pdf(file_obj, dpi: int = 170, max_pages: int | None = None, lang: str = "en") -> Tuple[str, str]:
63
  """
64
+ Returns combined text and a JSON string with per-page OCR results.
 
 
65
  """
66
+ if file_obj is None:
67
+ return "", json.dumps({"pages": []}, ensure_ascii=False)
68
+
69
+ # Gradio may pass a path or a tempfile.NamedTemporaryFile-like with .name
70
+ pdf_path = file_obj if isinstance(file_obj, str) else getattr(file_obj, "name", None)
71
+ if pdf_path is None or not os.path.exists(pdf_path):
72
+ # If bytes were passed, fall back to reading from buffer
73
+ file_bytes = file_obj.read() if hasattr(file_obj, "read") else None
74
+ if not file_bytes:
75
+ return "", json.dumps({"pages": []}, ensure_ascii=False)
76
+ pdf_doc = fitz.open(stream=file_bytes, filetype="pdf")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  else:
78
+ pdf_doc = fitz.open(pdf_path)
79
+
80
+ try:
81
+ num_pages = pdf_doc.page_count
82
+ if max_pages is not None:
83
+ num_pages = min(num_pages, max_pages)
84
+
85
+ all_text_lines: List[str] = []
86
+ pages_payload: List[Dict[str, Any]] = []
87
+
88
+ for page_index in range(num_pages):
89
+ image = pdf_page_to_image(pdf_doc, page_index, dpi=dpi)
90
+ page_text, page_items = run_paddle_ocr_on_image(image, lang=lang)
91
+
92
+ all_text_lines.append(page_text)
93
+ pages_payload.append({
94
+ "page": page_index + 1,
95
+ "items": page_items,
96
+ })
97
+
98
+ combined_text = "\n\n".join([t for t in all_text_lines if t])
99
+ json_payload = json.dumps({"pages": pages_payload}, ensure_ascii=False)
100
+
101
+ return combined_text, json_payload
102
+ finally:
103
+ pdf_doc.close()
104
+
105
+
106
+ def gradio_predict(pdf_file):
107
+ # Always render at a high DPI for accuracy and use English OCR by default
108
+ text, _ = extract_text_from_pdf(pdf_file, dpi=300, max_pages=None, lang="en")
109
+ return text
110
+
111
+
112
+ with gr.Blocks(title="PDF OCR with PaddleOCR + PyMuPDF") as demo:
113
+ gr.Markdown("""
114
+ # PDF OCR (PaddleOCR + PyMuPDF)
115
+ Upload a PDF to extract text using OCR. The app renders pages with PyMuPDF at a high DPI and uses PaddleOCR for recognition.
116
+ """)
117
+
118
+ pdf_input = gr.File(label="PDF", file_types=[".pdf"], file_count="single")
119
+ text_output = gr.Textbox(label="Extracted Text", lines=20)
120
+
121
+ # Auto-run OCR when a PDF is uploaded
122
+ pdf_input.change(fn=gradio_predict, inputs=[pdf_input], outputs=[text_output], api_name="predict")
123
+
124
+ # Simple API note
125
+ gr.Markdown("""
126
+ ## API usage
127
+ - Use `gradio_client` to call this Space. Function signature: `gradio_predict(pdf_file)` → `text`.
128
+ """)
129
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
  if __name__ == "__main__":
132
+ # On Spaces, the host/port are managed by the platform. Locally, this runs on 7860 by default.
133
+ demo.launch()