Spaces:
Paused
Paused
File size: 9,202 Bytes
f66643d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 | """End-to-end orchestration: OCR (Phase 1) -> Translate (Phase 2) -> Render (Phase 3).
This module wires the three existing phases into a single callable used by the
Gradio app (``app.py``). It reuses the public APIs of each phase and adds:
- a process-wide lazy singleton for ``StageAParser`` (its 3-5GB models load once),
- language-name + font handling shared across the run,
- intermediate JSON artifacts written to a per-request work dir.
"""
from __future__ import annotations
import json
import logging
import os
import time
import uuid
from pathlib import Path
from typing import Callable, Optional
from pdf2zh.config import get_settings
from pdf2zh.parser import PDFTypeDetector, StageAParser
from pdf2zh.render import RenderConfig, render_document
from pdf2zh.translation import TranslatorConfig, translate_document
logger = logging.getLogger(__name__)
# Full language names — Phase 2 prompts interpolate these directly (prompts.py).
SUPPORTED_LANGUAGES = [
"English",
"Vietnamese",
"Simplified Chinese",
"Japanese",
"Korean",
"French",
"German",
"Spanish",
]
# Directories searched by Typst for fonts (populated in the Docker image).
FONT_DIRS = [os.environ.get("PDF2ZH_FONT_DIR", "/app/fonts")]
# Fonts pre-installed in the image (see Dockerfile). The UI exposes these.
# Family names must match what Typst sees (apt fonts-noto-* + bundled Be Vietnam Pro).
BUNDLED_FONTS = ["Noto Sans", "Noto Serif", "Be Vietnam Pro", "Noto Sans CJK SC"]
DEFAULT_FONT = "Noto Sans" # neutral, full Vietnamese coverage
# Appended after the user's choice so missing glyphs fall back gracefully.
FALLBACK_TAIL = ["Noto Sans", "Noto Serif", "Noto Sans CJK SC"]
def font_chain(selected: str) -> list[str]:
"""User-selected font first, then multilingual fallbacks (deduped, ordered)."""
chain = [selected, *FALLBACK_TAIL]
return list(dict.fromkeys(c for c in chain if c))
# --------------------------------------------------------------------------- #
# Phase-1 model singleton
# --------------------------------------------------------------------------- #
_parser: Optional[StageAParser] = None
def get_parser() -> StageAParser:
"""Process-wide lazy singleton. The Surya/Paddle models load exactly once."""
global _parser
settings = get_settings()
if _parser is None:
logger.info("Loading StageAParser models (one-time)...")
_parser = StageAParser(**settings.model_dump())
logger.info("StageAParser ready.")
return _parser
def warmup() -> None:
"""Load models at app startup so the first request isn't penalized."""
get_parser()
# --------------------------------------------------------------------------- #
# Config builders
# --------------------------------------------------------------------------- #
def build_translator_config(
src_lang: str,
tgt_lang: str,
provider: str,
api_key: str,
model: str | None,
) -> TranslatorConfig:
"""Build Phase-2 config. Languages are set on the config directly (the
pipeline reads ``cfg.source_language`` before the doc dict), and the API key
is passed through so ``resolve_provider`` never needs an env var."""
return TranslatorConfig(
source_language=src_lang,
target_language=tgt_lang,
provider=provider,
model=(model.strip() or None) if model else None,
api_key=api_key.strip(),
)
def build_render_config(font: str, pages: list[int] | None) -> RenderConfig:
"""Build Phase-3 config. The chosen font heads a fallback chain; the default
Helvetica lacks Vietnamese glyphs so we always override it."""
cfg = RenderConfig()
cfg.font_family = font_chain(font)
cfg.typst_font_paths = FONT_DIRS
cfg.typst_binary = os.environ.get("TYPST_BIN", "typst")
cfg.pages = pages
cfg.redact_native_text = True
cfg.min_font_size_pt = 7.0
return cfg
# --------------------------------------------------------------------------- #
# Orchestration — split into per-phase steps so the stepped UI can checkpoint
# (review/edit) between phases and re-run only what changed.
# --------------------------------------------------------------------------- #
def _progress_fn(
progress: Callable[[float, str], None] | None,
) -> Callable[[float, str], None]:
def _p(frac: float, msg: str) -> None:
logger.info(msg)
if progress is not None:
progress(frac, msg)
return _p
def run_parse(
pdf_path: str,
pages: list[int] | None,
work_dir: str | Path,
progress: Callable[[float, str], None] | None = None,
) -> dict:
"""Phase 1 — OCR / layout parse (slowest step, loads heavy models).
Returns the parsed doc as a dict and writes ``phase1_parsed.json``.
"""
work = Path(work_dir)
work.mkdir(parents=True, exist_ok=True)
if not pdf_path:
raise ValueError("Vui lòng tải lên một file PDF.")
_p = _progress_fn(progress)
# Detect type (informational only — the Surya path handles all types).
_p(0.05, "Đang nhận diện loại PDF...")
try:
pdf_type = PDFTypeDetector().detect(pdf_path)
logger.info("PDF type: %s", pdf_type)
except Exception as exc: # detection is best-effort, never fatal
logger.warning("PDF type detection failed: %s", exc)
_p(0.1, "Phase 1/3 — OCR & phân tích bố cục (bước chậm nhất)...")
parser = get_parser()
parsed_doc = parser.parse_pdf(pdf_path, cache_path=None, pages=pages)
(work / "phase1_parsed.json").write_text(parsed_doc.to_json(), encoding="utf-8")
return parsed_doc.to_dict()
def run_translate(
parsed_dict: dict,
src_lang: str,
tgt_lang: str,
provider: str,
api_key: str,
model: str | None,
work_dir: str | Path,
progress: Callable[[float, str], None] | None = None,
) -> dict:
"""Phase 2 — translate the (possibly edited) parsed doc.
Returns the translated dict and writes ``phase2_translated.json``.
"""
work = Path(work_dir)
work.mkdir(parents=True, exist_ok=True)
if not api_key or not api_key.strip():
raise ValueError("Thiếu API key — nhập API key của provider ở thanh bên.")
if not src_lang or not tgt_lang:
raise ValueError("Chọn ngôn ngữ nguồn và ngôn ngữ đích.")
_p = _progress_fn(progress)
_p(0.55, f"Phase 2/3 — Đang dịch {src_lang} → {tgt_lang}...")
tcfg = build_translator_config(src_lang, tgt_lang, provider, api_key, model)
translated_dict = translate_document(parsed_dict, tcfg)
(work / "phase2_translated.json").write_text(
json.dumps(translated_dict, ensure_ascii=False, indent=2), encoding="utf-8"
)
return translated_dict
def run_render(
pdf_path: str,
translated_dict: dict,
pages: list[int] | None,
font: str,
work_dir: str | Path,
progress: Callable[[float, str], None] | None = None,
) -> str:
"""Phase 3 — render the (possibly edited) translated doc to a PDF.
The output contains only the translated pages. Returns the output path.
"""
work = Path(work_dir)
work.mkdir(parents=True, exist_ok=True)
_p = _progress_fn(progress)
_p(0.85, "Phase 3/3 — Đang dựng PDF bản dịch (typst)...")
out_path = str(work / f"translated_{uuid.uuid4().hex[:8]}.pdf")
rcfg = build_render_config(font, pages)
render_document(pdf_path, translated_dict, out_path, rcfg)
_p(1.0, "Hoàn tất.")
return out_path
def run_pipeline(
pdf_path: str,
src_lang: str,
tgt_lang: str,
provider: str,
api_key: str,
model: str | None,
pages: list[int] | None,
font: str,
work_dir: str | Path,
progress: Callable[[float, str], None] | None = None,
) -> str:
"""Run Phase 1 -> 2 -> 3 end-to-end and return the translated PDF path.
``pages`` is a 0-based index list (or None for all) shared by Phase 1 and 3.
Thin wrapper over run_parse/run_translate/run_render; the stepped UI calls
those directly so it can checkpoint between phases.
"""
# Fail fast on user-input errors before any GPU work.
if not pdf_path:
raise ValueError("Vui lòng tải lên một file PDF.")
if not api_key or not api_key.strip():
raise ValueError("Thiếu API key — nhập API key của provider ở thanh bên.")
if not src_lang or not tgt_lang:
raise ValueError("Chọn ngôn ngữ nguồn và ngôn ngữ đích.")
t0 = time.perf_counter()
parsed = run_parse(pdf_path, pages, work_dir, progress)
t1 = time.perf_counter()
translated = run_translate(
parsed, src_lang, tgt_lang, provider, api_key, model, work_dir, progress
)
t2 = time.perf_counter()
out_path = run_render(pdf_path, translated, pages, font, work_dir, progress)
t3 = time.perf_counter()
# End-to-end runs only (the stepped UI calls run_parse/translate/render
# directly). Logged last so the breakdown is easy to trace after a run.
logger.info(
"[latency] parse=%.2fs translate=%.2fs render=%.2fs total=%.2fs",
t1 - t0,
t2 - t1,
t3 - t2,
t3 - t0,
)
return out_path
|