"""Gradio UI: stepped, human-in-the-loop PDF translation. Flow: Config → Step 1 (Phase 1: review/edit extraction) → [translate + render] → Step 2 (Phase 3: review render, edit translations, re-render) → download. Both review steps let the user click a page preview to select an element and edit it (with a #-dropdown as an explicit fallback selector). The pure logic lives in ``review.py``; heavy phases run in worker threads via ``runner.py``. """ from __future__ import annotations import shutil import tempfile import time import uuid from pathlib import Path import gradio as gr from pdf2zh.e2e import BUNDLED_FONTS, DEFAULT_FONT, SUPPORTED_LANGUAGES from pdf2zh.webapp.config import ( PAGE_MODE_ALL, PAGE_MODE_RANGE, PAGE_MODES, PROVIDER_CHOICES, PROVIDER_KEY, resolve_pages, ) from pdf2zh.webapp.review import ( LABEL_CHOICES, add_element, apply_phase1_cell_edit, apply_phase1_edit, apply_phase2_cell_edit, apply_phase2_edit, hex_to_rgb, hit_test, normalize_click, output_page_position, overlay_svg, render_all_pages, render_page_plain, ) from pdf2zh.webapp.runner import ( Progress, TranslationRequest, list_models, stream_parse, stream_render, stream_translate_render, stream_translation, validate, ) REVIEW_DPI = 150 _SCALE = REVIEW_DPI / 72.0 _P1_SOURCE_LABEL = "Văn bản gốc (source_text)" def _paged_gallery(pdf_path: str, dpi: int = REVIEW_DPI): """Render every page and tag each with a "Trang i/N" caption for the gallery.""" imgs = render_all_pages(pdf_path, dpi) n = len(imgs) return [(img, f"Trang {i}/{n}") for i, img in enumerate(imgs, 1)] # Static base image + live SVG overlay: the base reloads only on page change # / re-render, the overlay (a pure innerHTML swap) updates on every edit action, so # selecting/saving/adding no longer flickers the preview. REVIEW_CSS = """ #p1-stage, #p3-stage { position:relative; padding:0 !important; gap:0 !important; } #p1-stage .image-frame img, #p1-stage .image-container img, #p3-stage .image-frame img, #p3-stage .image-container img { width:100% !important; height:auto !important; object-fit:fill !important; display:block; } #p1-stage .image-container, #p1-stage .image-frame, #p3-stage .image-container, #p3-stage .image-frame { height:auto !important; } #p1-stage .image-container, #p1-stage .image-frame, #p1-stage button, #p3-stage .image-container, #p3-stage .image-frame, #p3-stage button { padding:0 !important; border:0 !important; margin:0 !important; } #p1-stage .icon-button-wrapper, #p3-stage .icon-button-wrapper, #p1-stage button[aria-label*="ullscreen"], #p3-stage button[aria-label*="ullscreen"] { display:none !important; } #p1-overlay, #p3-overlay { position:absolute; top:0; left:0; width:100%; padding:0 !important; margin:0 !important; pointer-events:none; } #p1-overlay svg, #p3-overlay svg { display:block; width:100%; height:auto; } svg.draw-rubber { position:absolute; pointer-events:none; z-index:6; } """ # Client-side box drawing: in draw mode the user clicks two corners. Click 1 drops # a marker; moving the mouse shows a live dashed preview rectangle to the cursor; # click 2 locks it and writes the PDF-point coords into the hidden x0/y0/x1/y1 # inputs. No server round-trip happens until "Thêm box", so drawing never flickers. # Coords go display-px → natural-px (naturalWidth/rect) → PDF points (÷ _SCALE), # matching ``on_p1_img_click``'s old math. __SCALE__ is filled from REVIEW_DPI below. _DRAW_JS_TMPL = """ () => { const SCALE = __SCALE__; const CFG = { stage:'p1-stage', chk:'p1-draw-mode', x0:'p1-x0', y0:'p1-y0', x1:'p1-x1', y1:'p1-y1' }; const q = (s) => document.querySelector(s); const drawOn = () => { const c = q('#'+CFG.chk+' input[type=checkbox]'); return !!(c && c.checked); }; function setNum(id, val) { const inp = q('#'+id+' input'); if (!inp) return; const setter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value').set; setter.call(inp, String(Math.round(val * 100) / 100)); inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('change', { bubbles: true })); } function layer(stageEl, r) { const sr = stageEl.getBoundingClientRect(); let svg = stageEl.querySelector('svg.draw-rubber'); if (!svg) { svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'draw-rubber'); stageEl.appendChild(svg); } svg.style.left = (r.left - sr.left) + 'px'; svg.style.top = (r.top - sr.top) + 'px'; svg.style.width = r.width + 'px'; svg.style.height = r.height + 'px'; return svg; } function paint(stageEl, r, ax, ay, bx, by, dashed) { const rx = Math.min(ax, bx), ry = Math.min(ay, by); layer(stageEl, r).innerHTML = '' +''; } const at = (r, e) => ({ x: Math.max(0, Math.min(r.width, e.clientX - r.left)), y: Math.max(0, Math.min(r.height, e.clientY - r.top)), }); let P = null; // first corner: {stageEl, img, r, x, y} once click 1 lands const reset = () => { P = null; }; document.addEventListener('click', (e) => { if (e.target.closest && e.target.closest('#p1-add-btn')) { clearAll(); return; } if (!drawOn()) return; const stageEl = e.target.closest && e.target.closest('#'+CFG.stage); if (!stageEl) return; const img = stageEl.querySelector('img'); if (!img) return; const r = img.getBoundingClientRect(); const p = at(r, e); if (!P) { // Click 1: mark the first corner. P = { stageEl, img, r, x: p.x, y: p.y }; layer(stageEl, r).innerHTML = ''; return; } // Click 2: finalize. const x0 = Math.min(P.x, p.x), y0 = Math.min(P.y, p.y); const x1 = Math.max(P.x, p.x), y1 = Math.max(P.y, p.y); const sx = (img.naturalWidth || r.width) / r.width; const sy = (img.naturalHeight || r.height) / r.height; if (x1 - x0 >= 3 && y1 - y0 >= 3) { setNum(CFG.x0, x0 * sx / SCALE); setNum(CFG.y0, y0 * sy / SCALE); setNum(CFG.x1, x1 * sx / SCALE); setNum(CFG.y1, y1 * sy / SCALE); paint(P.stageEl, r, P.x, P.y, p.x, p.y, false); } reset(); }, true); document.addEventListener('mousemove', (e) => { if (!P || !drawOn()) return; const p = at(P.r, e); paint(P.stageEl, P.r, P.x, P.y, p.x, p.y, true); }, true); const clearAll = () => { const svg = q('#'+CFG.stage+' svg.draw-rubber'); if (svg) svg.innerHTML = ''; reset(); }; // Clear the preview when leaving draw mode and whenever the base image // (re)loads — page change, new file, re-render — so no stale rectangle lingers. document.addEventListener('change', (e) => { if (e.target.closest && e.target.closest('#'+CFG.chk) && !drawOn()) clearAll(); }, true); document.addEventListener('load', (e) => { if (e.target.tagName === 'IMG' && e.target.closest && e.target.closest('#'+CFG.stage)) clearAll(); }, true); } """ DRAW_JS = _DRAW_JS_TMPL.replace("__SCALE__", repr(_SCALE)) # --------------------------------------------------------------------------- # # Small helpers (pure-ish, operate on the session dict) # --------------------------------------------------------------------------- # def _page_choices(doc: dict) -> list[tuple[str, int]]: """Dropdown choices for pages: label = 1-based document page number.""" return [ (f"Trang {p.get('page_index', i) + 1}", i) for i, p in enumerate(doc.get("pages", [])) ] def _elem_choices(page: dict) -> list[tuple[str, int]]: out = [] for i, e in enumerate(page.get("elements", [])): cat = e.get("category", "") label = e.get("label", "") out.append((f"#{i} · {label} · {cat}", i)) return out def _base_p1(session: dict): """Rasterize the current Phase-1 page (no boxes). Reloads the base .""" page_i = session["p1_page"] page = session["parsed"]["pages"][page_i] img, size = render_page_plain( session["pdf_path"], page.get("page_index", page_i), REVIEW_DPI ) session["p1_size"] = size return img def _overlay_p1(session: dict) -> str: """Build the Phase-1 SVG overlay + refresh session box list for hit_test. Reads the current selection straight from ``session["p1_sel"]`` — a ``(elem_i, cell_i)`` tuple (``cell_i`` None for a whole-element selection) or None — so callers just set that key and never pass highlight state separately (one less thing to keep in sync). """ page = session["parsed"]["pages"][session["p1_page"]] w, h = session["p1_size"] sel = session.get("p1_sel") highlight_idx = sel[0] if sel and sel[1] is None else None highlight_cell = sel if sel and sel[1] is not None else None svg, boxes = overlay_svg( page.get("elements", []), _SCALE, w, h, highlight_idx, highlight_cell ) session["p1_boxes"] = boxes return svg def _base_p3(session: dict): """Rasterize the current Phase-3 page (no boxes), or None if not rendered.""" page_i = session["p3_page"] page = session["translated"]["pages"][page_i] out_pos = output_page_position(session["pages"], page.get("page_index", page_i)) if out_pos is None: return None img, size = render_page_plain(session["out_path"], out_pos, REVIEW_DPI) session["p3_size"] = size return img def _overlay_p3(session: dict) -> str: """Build the Phase-3 SVG overlay + refresh session box list for hit_test. Reads the current selection straight from ``session["p3_sel"]``, same convention as ``_overlay_p1``. """ size = session.get("p3_size") if size is None: return "" page = session["translated"]["pages"][session["p3_page"]] sel = session.get("p3_sel") highlight_idx = sel[0] if sel and sel[1] is None else None highlight_cell = sel if sel and sel[1] is not None else None svg, boxes = overlay_svg( page.get("elements", []), _SCALE, size[0], size[1], highlight_idx, highlight_cell, ) session["p3_boxes"] = boxes return svg # --------------------------------------------------------------------------- # # UI # --------------------------------------------------------------------------- # def build_ui() -> gr.Blocks: with gr.Blocks( title="PDF Translator", theme=gr.themes.Default(), css=REVIEW_CSS ) as demo: sess_state = gr.State({}) gr.Markdown( "# PDF Translator\n" "Dịch PDF end-to-end với 2 bước kiểm tra: **Phase 1** (sửa trích xuất) " "→ dịch & dựng → **Phase 3** (soát bản dịch trên trang đã render)." ) # ---- Config --------------------------------------------------------- with gr.Row(): with gr.Column(scale=1): provider = gr.Dropdown( PROVIDER_CHOICES, value="OpenRouter", label="Provider" ) api_key = gr.Textbox(label="API Key", type="password", value="") model = gr.Dropdown( choices=[], value=None, label="Model (trống = mặc định)", allow_custom_value=True, info="Nhập API key để tự tải danh sách, hoặc gõ tên model.", ) load_models_btn = gr.Button("🔄 Tải danh sách model", size="sm") lang_from = gr.Dropdown( SUPPORTED_LANGUAGES, value="English", label="Dịch từ" ) lang_to = gr.Dropdown( SUPPORTED_LANGUAGES, value="Vietnamese", label="Dịch sang" ) font = gr.Dropdown( BUNDLED_FONTS, value=DEFAULT_FONT, label="Font đầu ra" ) page_mode = gr.Radio( PAGE_MODES, value=PAGE_MODE_ALL, label="Trang dịch" ) with gr.Row(): page_from = gr.Number( value=1, precision=0, label="Từ trang", visible=False, minimum=1 ) page_to = gr.Number( value=1, precision=0, label="Đến trang", visible=False, minimum=1, ) parse_btn = gr.Button("① Trích xuất (Phase 1)", variant="primary") e2e_btn = gr.Button( "⚡ Chạy end-to-end (bỏ qua sửa)", variant="secondary" ) with gr.Column(scale=2): pdf_in = gr.File( label="Tải lên PDF", file_types=[".pdf"], type="filepath" ) pdf_preview = gr.Gallery( label="Xem trước (mọi trang)", height=520, columns=1, show_label=True, preview=True, visible=False, ) status = gr.Markdown("") e2e_pdf = gr.Gallery( label="Bản dịch (end-to-end)", height=520, columns=1, preview=True, visible=False, ) e2e_download = gr.File(label="Tải PDF (end-to-end)", visible=False) # ---- Step 1: Phase-1 review ---------------------------------------- with gr.Column(visible=False) as p1_group: gr.Markdown( "## ① Kiểm tra trích xuất\n" "Click vào ô trên trang để chọn element (hoặc chọn theo số). " "Sửa nhãn/nội dung, bỏ qua element thừa, hoặc thêm box cho vùng bị sót." ) with gr.Row(): with gr.Column(scale=2): p1_page_dd = gr.Dropdown(label="Trang", choices=[], value=None) with gr.Column(elem_id="p1-stage"): p1_img = gr.Image( interactive=False, show_label=False, container=False, elem_id="p1-img", ) p1_overlay = gr.HTML( elem_id="p1-overlay", container=False, padding=False ) with gr.Column(scale=1): p1_elem_dd = gr.Dropdown( label="Element đang chọn (khớp theo nhãn hiển thị trên ô)", choices=[], value=None, ) p1_label = gr.Dropdown( LABEL_CHOICES, label="Nhãn (label)", value=None ) p1_source = gr.Textbox(label=_P1_SOURCE_LABEL, lines=4) p1_bypass = gr.Checkbox(label="Bỏ qua element này (không dịch)") p1_save_btn = gr.Button("💾 Lưu element", variant="secondary") gr.Markdown("**Thêm box cho vùng bị sót**") p1_draw_mode = gr.Checkbox( label="Chế độ vẽ box (nhấn 2 điểm trên ảnh)", elem_id="p1-draw-mode", ) with gr.Row(): p1_x0 = gr.Number(label="x0", value=0, elem_id="p1-x0") p1_y0 = gr.Number(label="y0", value=0, elem_id="p1-y0") p1_x1 = gr.Number(label="x1", value=0, elem_id="p1-x1") p1_y1 = gr.Number(label="y1", value=0, elem_id="p1-y1") p1_new_label = gr.Dropdown( LABEL_CHOICES, label="Nhãn box mới", value="Text" ) p1_new_source = gr.Textbox(label="Văn bản box mới", lines=3) p1_new_auto_color = gr.Checkbox( label="Tự động lấy màu nền/chữ từ trang", value=True ) with gr.Row(): p1_new_bg = gr.ColorPicker(label="Màu nền", value="#ffffff") p1_new_text = gr.ColorPicker(label="Màu chữ", value="#000000") p1_add_btn = gr.Button("➕ Thêm box", elem_id="p1-add-btn") confirm_btn = gr.Button("② Xác nhận → Dịch & Render", variant="primary") # ---- Step 2: Phase-3 review ---------------------------------------- with gr.Column(visible=False) as p3_group: gr.Markdown( "## ② Soát bản dịch (trên trang đã render)\n" "Click vào element để sửa bản dịch, rồi **Render lại**. " "Màu nền/chữ, font, cỡ chữ được giữ nguyên." ) with gr.Row(): with gr.Column(scale=1): p3_pdf = gr.Gallery( label="Bản dịch (xem trước)", height=560, columns=1, preview=True, ) download = gr.File(label="Tải PDF bản dịch") with gr.Column(scale=1): p3_page_dd = gr.Dropdown(label="Trang", choices=[], value=None) with gr.Column(elem_id="p3-stage"): p3_img = gr.Image( interactive=False, show_label=False, container=False, elem_id="p3-img", ) p3_overlay = gr.HTML( elem_id="p3-overlay", container=False, padding=False ) p3_elem_dd = gr.Dropdown( label="Element đang chọn", choices=[], value=None ) p3_source = gr.Textbox( label="Văn bản gốc", lines=3, interactive=False ) p3_translated = gr.Textbox(label="Bản dịch (sửa được)", lines=5) p3_save_btn = gr.Button("💾 Lưu bản dịch", variant="secondary") rerender_btn = gr.Button("🔁 Render lại", variant="primary") # ================================================================== # # Handlers # ================================================================== # def on_provider_change(_provider): return (gr.update(choices=[], value=None), gr.update(value="")) def on_load_models(prov, key): models = list_models(PROVIDER_KEY[prov], key) if not models: gr.Warning("Không tải được model — kiểm tra key/provider, hoặc gõ tay.") return gr.update() gr.Info(f"Đã tải {len(models)} model.") return gr.update(choices=models) def on_page_mode(mode): vis = mode == PAGE_MODE_RANGE return gr.update(visible=vis), gr.update(visible=vis) def on_new_file(session, pdf): """Reset all derived state when the uploaded PDF changes/clears. Drops the previous file's work dir + session keys and hides every review panel / download so a new file never shows the old one's outputs. Also rasterizes page 1 server-side (PyMuPDF) for the preview so it never depends on a browser-side PDF viewer / external CDN. """ old = session.get("work_dir") if old: shutil.rmtree(old, ignore_errors=True) for k in ( "pdf_path", "work_dir", "pages", "parsed", "p1_page", "p1_sel", "p1_size", "p1_boxes", "translated", "out_path", "p3_page", "p3_sel", "p3_size", "p3_boxes", ): session.pop(k, None) preview = None if pdf: try: preview = _paged_gallery(pdf) except Exception: preview = None return { sess_state: session, status: "", pdf_preview: gr.update(value=preview, visible=bool(preview)), p1_group: gr.update(visible=False), p3_group: gr.update(visible=False), p3_pdf: gr.update(value=None), download: gr.update(value=None), e2e_pdf: gr.update(value=None, visible=False), e2e_download: gr.update(value=None, visible=False), } # ---- End-to-end (skip the per-phase review) ------------------------ def do_e2e(pdf, prov, key, mdl, lfrom, lto, fnt, mode, frm, to): req = TranslationRequest( pdf_path=pdf, provider=PROVIDER_KEY[prov], api_key=key or "", model=mdl or None, src_lang=lfrom, tgt_lang=lto, font=fnt, pages=resolve_pages(mode, frm, to), ) # Hide any PDF/download left over from a previous successful run so a # failure never displays a stale result as if it were the new output. hide = { e2e_pdf: gr.update(visible=False), e2e_download: gr.update(visible=False), } err = validate(req) if err: yield {status: f"⚠️ {err}", **hide} return t0 = time.perf_counter() yield {status: "⏳ Đang chạy end-to-end (Phase 1 → 2 → 3)…", **hide} result = None for ev in stream_translation(req): if isinstance(ev, Progress): yield {status: f"⏳ {ev.msg}"} else: result = ev if result is None or result.status != "ok": yield { status: f"❌ {result.detail if result else 'Không rõ lỗi.'}", **hide, } return elapsed = time.perf_counter() - t0 yield { status: f"✅ Xong end-to-end trong {elapsed:.1f}s (xem log để biết chi tiết từng phase).", e2e_pdf: gr.update(value=_paged_gallery(result.out_path), visible=True), e2e_download: gr.update(value=result.out_path, visible=True), } # ---- Step 1: parse ------------------------------------------------- def do_parse(session, pdf, prov, key, lfrom, lto, mode, frm, to): if not pdf: yield {status: "⚠️ Vui lòng tải lên một file PDF."} return if not key or not key.strip(): yield {status: "⚠️ Thiếu API key."} return prev = session.get("work_dir") if prev: shutil.rmtree(prev, ignore_errors=True) work_dir = Path(tempfile.gettempdir()) / f"pdf2zh_{uuid.uuid4().hex}" work_dir.mkdir(parents=True, exist_ok=True) # Copy the uploaded PDF so later steps survive Gradio cache cleanup. local_pdf = work_dir / "source.pdf" shutil.copyfile(pdf, local_pdf) pages = resolve_pages(mode, frm, to) session["pdf_path"] = str(local_pdf) session["work_dir"] = str(work_dir) session["pages"] = pages session["provider"] = PROVIDER_KEY[prov] session["src_lang"] = lfrom session["tgt_lang"] = lto yield {status: "⏳ Phase 1 — OCR & phân tích bố cục…"} result = None for ev in stream_parse(session["pdf_path"], pages, session["work_dir"]): if isinstance(ev, Progress): yield {status: f"⏳ {ev.msg}"} else: result = ev if result is None or result.status != "ok": yield {status: f"❌ {result.detail if result else 'Không rõ lỗi.'}"} return if not result.data.get("pages"): yield { status: "⚠️ Không có trang nào để dịch (khoảng trang nằm ngoài tài liệu?)." } return session["parsed"] = result.data session["p1_page"] = 0 session["p1_sel"] = None page_choices = _page_choices(session["parsed"]) img = _base_p1(session) first_page = session["parsed"]["pages"][0] yield { sess_state: session, status: "✅ Phase 1 xong — kiểm tra & sửa rồi bấm Xác nhận.", p1_group: gr.update(visible=True), p1_page_dd: gr.update(choices=page_choices, value=0), p1_img: img, p1_overlay: _overlay_p1(session), p1_elem_dd: gr.update(choices=_elem_choices(first_page), value=None), # Re-show label/bypass in case a table cell was selected in a # previous file (which hides them) before this parse ran. p1_label: gr.update(value=None, visible=True), p1_source: gr.update(value="", label=_P1_SOURCE_LABEL), p1_bypass: gr.update(value=False, visible=True), } def on_p1_page(session, page_i): if page_i is None: return {sess_state: session} session["p1_page"] = int(page_i) session["p1_sel"] = None page = session["parsed"]["pages"][int(page_i)] return { sess_state: session, p1_img: _base_p1(session), p1_overlay: _overlay_p1(session), p1_elem_dd: gr.update(choices=_elem_choices(page), value=None), p1_label: gr.update(value=None, visible=True), p1_source: gr.update(value="", label=_P1_SOURCE_LABEL), p1_bypass: gr.update(value=False, visible=True), } def _p1_select_elem(session, elem_i): """Populate the edit panel for a whole element + highlight it.""" session["p1_sel"] = (elem_i, None) page = session["parsed"]["pages"][session["p1_page"]] elem = page["elements"][elem_i] return { sess_state: session, p1_overlay: _overlay_p1(session), p1_elem_dd: gr.update(value=elem_i), p1_label: gr.update(value=elem.get("label"), visible=True), p1_source: gr.update( value=elem.get("source_text", ""), label=_P1_SOURCE_LABEL ), p1_bypass: gr.update( value=elem.get("category") == "BYPASS", visible=True ), status: "", } def _p1_select_cell(session, elem_i, cell_i): """Populate the edit panel for one TABLE cell + highlight it. Cells have no label/category of their own (translation runs per cell), so the label/bypass controls are hidden — only the cell's OCR text is editable. """ session["p1_sel"] = (elem_i, cell_i) page = session["parsed"]["pages"][session["p1_page"]] cell = page["elements"][elem_i]["cells"][cell_i] return { sess_state: session, p1_overlay: _overlay_p1(session), p1_elem_dd: gr.update(value=elem_i), p1_label: gr.update(visible=False), p1_source: gr.update( value=cell.get("source_text", ""), label=f"Văn bản gốc (Table #{elem_i}, cell #{cell_i})", ), p1_bypass: gr.update(visible=False), status: "", } def on_p1_img_click(session, draw_mode, evt: gr.SelectData): # In draw mode the box is drawn client-side (see DRAW_JS), so a stray # click here must not also select an element. if draw_mode: return {sess_state: session} pt = normalize_click(evt.index) if pt is None: return {sess_state: session} hit = hit_test(session.get("p1_boxes", []), pt[0], pt[1]) if hit is None: return {sess_state: session} elem_i, cell_i = hit if cell_i is None: return _p1_select_elem(session, elem_i) return _p1_select_cell(session, elem_i, cell_i) def on_p1_pick(session, elem_i): # Dropdown selects whole elements only — table cells are picked by # clicking them directly on the image. if elem_i is None: return {sess_state: session} return _p1_select_elem(session, int(elem_i)) def do_p1_save(session, label, source, bypass): sel = session.get("p1_sel") if sel is None: gr.Warning("Chưa chọn element nào.") return {sess_state: session, status: "⚠️ Chưa chọn element nào."} elem_i, cell_i = sel if cell_i is not None: msg = apply_phase1_cell_edit( session["parsed"], session["p1_page"], elem_i, cell_i, source ) if msg: gr.Warning(msg) else: gr.Info(f"Đã lưu cell #{cell_i} (Table #{elem_i}).") return { sess_state: session, p1_overlay: _overlay_p1(session), status: ( f"⚠️ {msg}" if msg else f"✅ Đã lưu cell #{cell_i} (Table #{elem_i})." ), } msg = apply_phase1_edit( session["parsed"], session["p1_page"], elem_i, label, source, bypass ) page = session["parsed"]["pages"][session["p1_page"]] if msg: gr.Warning(msg) else: gr.Info(f"Đã lưu element #{elem_i}.") return { sess_state: session, p1_overlay: _overlay_p1(session), p1_elem_dd: gr.update(choices=_elem_choices(page), value=elem_i), status: f"⚠️ {msg}" if msg else f"✅ Đã lưu element #{elem_i}.", } def do_p1_add( session, x0, y0, x1, y1, label, source, auto_color, bg_hex, text_hex ): if x1 <= x0 or y1 <= y0: gr.Warning("Box không hợp lệ (cần x1>x0, y1>y0).") return { sess_state: session, status: "⚠️ Box không hợp lệ (cần x1>x0, y1>y0).", } page_i = session["p1_page"] # auto_color → let the renderer sample bg/text from the page (old # default); unchecked → pin the user-picked colors. bg = None if auto_color else hex_to_rgb(bg_hex) text = None if auto_color else hex_to_rgb(text_hex) new_idx = add_element( session["parsed"], page_i, [x0, y0, x1, y1], label, source, bg_color=bg, text_color=text, ) page = session["parsed"]["pages"][page_i] gr.Info(f"Đã thêm box #{new_idx}.") # Populate the panel exactly as if the new element had been clicked # (also re-shows label/bypass in case a table cell was selected # beforehand, which hides them), then layer on the add-specific # resets — including the dropdown's choices, which _p1_select_elem # doesn't refresh since it doesn't know an element was just added. out = _p1_select_elem(session, new_idx) out.update( { p1_elem_dd: gr.update(choices=_elem_choices(page), value=new_idx), p1_new_source: gr.update(value=""), status: f"✅ Đã thêm box #{new_idx}.", # Turn draw mode back off: leaving it checked would make the # next click on the image be treated as drawing instead of # selecting an element, silently blocking further edits. p1_draw_mode: gr.update(value=False), } ) return out # ---- Confirm → translate + render ---------------------------------- def do_confirm(session, prov, key, mdl, lfrom, lto, fnt): if not key or not key.strip(): yield {status: "⚠️ Thiếu API key."} return session["src_lang"], session["tgt_lang"] = lfrom, lto yield {status: "⏳ Phase 2 — đang dịch…"} result = None for ev in stream_translate_render( session["pdf_path"], session["parsed"], lfrom, lto, PROVIDER_KEY[prov], key, mdl or None, session["pages"], fnt, session["work_dir"], ): if isinstance(ev, Progress): yield {status: f"⏳ {ev.msg}"} else: result = ev if result is None or result.status != "ok": yield {status: f"❌ {result.detail if result else 'Không rõ lỗi.'}"} return session["translated"] = result.data["translated"] session["out_path"] = result.data["out_path"] session["p3_page"] = 0 session["p3_sel"] = None page_choices = _page_choices(session["translated"]) img = _base_p3(session) first_page = session["translated"]["pages"][0] yield { sess_state: session, status: "✅ Đã dịch & dựng. Soát bản dịch rồi tải về.", p3_group: gr.update(visible=True), p3_pdf: _paged_gallery(session["out_path"]), download: session["out_path"], p3_page_dd: gr.update(choices=page_choices, value=0), p3_img: img, p3_overlay: _overlay_p3(session), p3_elem_dd: gr.update(choices=_elem_choices(first_page), value=None), } def on_p3_page(session, page_i): if page_i is None: return {sess_state: session} session["p3_page"] = int(page_i) session["p3_sel"] = None page = session["translated"]["pages"][int(page_i)] return { sess_state: session, p3_img: _base_p3(session), p3_overlay: _overlay_p3(session), p3_elem_dd: gr.update(choices=_elem_choices(page), value=None), p3_source: gr.update(value=""), p3_translated: gr.update(value=""), } def _p3_select_elem(session, elem_i): session["p3_sel"] = (elem_i, None) page = session["translated"]["pages"][session["p3_page"]] elem = page["elements"][elem_i] return { sess_state: session, p3_overlay: _overlay_p3(session), p3_elem_dd: gr.update(value=elem_i), p3_source: gr.update(value=elem.get("source_text", "")), p3_translated: gr.update(value=elem.get("translated_text", "")), status: "", } def _p3_select_cell(session, elem_i, cell_i): session["p3_sel"] = (elem_i, cell_i) page = session["translated"]["pages"][session["p3_page"]] cell = page["elements"][elem_i]["cells"][cell_i] return { sess_state: session, p3_overlay: _overlay_p3(session), p3_elem_dd: gr.update(value=elem_i), p3_source: gr.update(value=cell.get("source_text", "")), p3_translated: gr.update(value=cell.get("translated_text", "")), status: "", } def on_p3_img_click(session, evt: gr.SelectData): pt = normalize_click(evt.index) if pt is None: return {sess_state: session} hit = hit_test(session.get("p3_boxes", []), pt[0], pt[1]) if hit is None: return {sess_state: session} elem_i, cell_i = hit if cell_i is None: return _p3_select_elem(session, elem_i) return _p3_select_cell(session, elem_i, cell_i) def on_p3_pick(session, elem_i): # Dropdown selects whole elements only — table cells are picked by # clicking them directly on the image. if elem_i is None: return {sess_state: session} return _p3_select_elem(session, int(elem_i)) def do_p3_save(session, translated_text): sel = session.get("p3_sel") if sel is None: gr.Warning("Chưa chọn element nào.") return {sess_state: session, status: "⚠️ Chưa chọn element nào."} elem_i, cell_i = sel if cell_i is not None: apply_phase2_cell_edit( session["translated"], session["p3_page"], elem_i, cell_i, translated_text, ) gr.Info( f"Đã lưu bản dịch cell #{cell_i} (Table #{elem_i}, " "bấm Render lại để cập nhật)." ) else: apply_phase2_edit( session["translated"], session["p3_page"], elem_i, translated_text ) gr.Info(f"Đã lưu bản dịch #{elem_i} (bấm Render lại để cập nhật).") return { sess_state: session, status: "✅ Đã lưu bản dịch (bấm Render lại để cập nhật).", } def do_rerender(session, fnt): yield {status: "⏳ Đang render lại…"} result = None for ev in stream_render( session["pdf_path"], session["translated"], session["pages"], fnt, session["work_dir"], ): if isinstance(ev, Progress): yield {status: f"⏳ {ev.msg}"} else: result = ev if result is None or result.status != "ok": yield {status: f"❌ {result.detail if result else 'Không rõ lỗi.'}"} return session["out_path"] = result.data["out_path"] yield { sess_state: session, status: "✅ Đã render lại.", p3_pdf: _paged_gallery(session["out_path"]), download: session["out_path"], p3_img: _base_p3(session), p3_overlay: _overlay_p3(session), } # ================================================================== # # Wiring # ================================================================== # provider.change(on_provider_change, provider, [model, api_key]) api_key.blur(on_load_models, [provider, api_key], model) load_models_btn.click(on_load_models, [provider, api_key], model) page_mode.change(on_page_mode, page_mode, [page_from, page_to]) pdf_in.change( on_new_file, [sess_state, pdf_in], [ sess_state, status, pdf_preview, p1_group, p3_group, p3_pdf, download, e2e_pdf, e2e_download, ], ) parse_out = [ sess_state, status, p1_group, p1_page_dd, p1_img, p1_elem_dd, p1_label, p1_source, p1_bypass, p1_overlay, ] parse_btn.click( do_parse, [ sess_state, pdf_in, provider, api_key, lang_from, lang_to, page_mode, page_from, page_to, ], parse_out, ) e2e_btn.click( do_e2e, [ pdf_in, provider, api_key, model, lang_from, lang_to, font, page_mode, page_from, page_to, ], [status, e2e_pdf, e2e_download], ) # Page change reloads the base ; edit events must NOT touch p1_img # (its presence in outputs makes Gradio flash a loading state over the # image on every click). show_progress="hidden" suppresses the same # pulse on the other edited components. p1_page_out = [ sess_state, p1_img, p1_overlay, p1_elem_dd, p1_label, p1_source, p1_bypass, ] p1_edit_out = [ sess_state, p1_overlay, p1_elem_dd, p1_label, p1_source, p1_bypass, p1_new_source, status, ] p1_page_dd.change(on_p1_page, [sess_state, p1_page_dd], p1_page_out) p1_img.select( on_p1_img_click, [sess_state, p1_draw_mode], p1_edit_out, show_progress="hidden", ) p1_elem_dd.select( on_p1_pick, [sess_state, p1_elem_dd], p1_edit_out, show_progress="hidden" ) p1_save_btn.click( do_p1_save, [sess_state, p1_label, p1_source, p1_bypass], p1_edit_out, show_progress="hidden", ) p1_add_btn.click( do_p1_add, [ sess_state, p1_x0, p1_y0, p1_x1, p1_y1, p1_new_label, p1_new_source, p1_new_auto_color, p1_new_bg, p1_new_text, ], p1_edit_out + [p1_draw_mode], show_progress="hidden", ) confirm_out = [ sess_state, status, p3_group, p3_pdf, download, p3_page_dd, p3_img, p3_elem_dd, p3_overlay, ] confirm_btn.click( do_confirm, [sess_state, provider, api_key, model, lang_from, lang_to, font], confirm_out, ) # Same split as Phase 1: only page-change / re-render reload p3_img. p3_page_out = [ sess_state, p3_img, p3_overlay, p3_elem_dd, p3_source, p3_translated, ] p3_edit_out = [ sess_state, p3_overlay, p3_elem_dd, p3_source, p3_translated, status, ] p3_rerender_out = [ sess_state, status, p3_pdf, download, p3_img, p3_overlay, ] p3_page_dd.change(on_p3_page, [sess_state, p3_page_dd], p3_page_out) p3_img.select( on_p3_img_click, [sess_state], p3_edit_out, show_progress="hidden" ) p3_elem_dd.select( on_p3_pick, [sess_state, p3_elem_dd], p3_edit_out, show_progress="hidden" ) p3_save_btn.click( do_p3_save, [sess_state, p3_translated], p3_edit_out, show_progress="hidden" ) rerender_btn.click(do_rerender, [sess_state, font], p3_rerender_out) # fn must be explicit: Blocks.load()'s fn defaults to the sentinel string # "decorator" (for @demo.load()-style usage), not None — passing js= alone # silently never registers the trigger, so the script never runs. demo.load(fn=None, js=DRAW_JS) return demo