diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000000000000000000000000000000000000..ab89d3654b2f46304bc2518cc79ef4c399ab1b67
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,19 @@
+# Thiết bị chạy model: "cuda", "cpu", hoặc "auto" (auto -> cuda nếu có GPU)
+DEVICE=auto
+
+# Batch size cho A100 80GB / 142GB RAM — tận dụng VRAM cho Phase 1 (đỉnh ~45-55GB,
+# vẫn còn headroom an toàn trong 80GB).
+# - OCR (recognition) nặng nhất: 512 ~ 13GB VRAM.
+# - layout/detection 64: gấp đôi mặc định, tăng throughput Phase 1 nhờ thừa VRAM.
+# - page 32 + table 512: 142GB RAM & 80GB VRAM dư sức.
+# GPU nhỏ hơn (T4/16GB): để trống tất cả (None) để Surya tự chọn batch nhỏ, tránh OOM.
+PAGE_BATCH_SIZE=32
+LAYOUT_BATCH_SIZE=64
+DETECTION_BATCH_SIZE=64
+OCR_BATCH_SIZE=512
+TABLE_BATCH_SIZE=512
+
+# Cấu hình cho OCR (Lưu ý: text threshold > blank threshold) — ảnh hưởng độ chính xác,
+# không liên quan VRAM.
+DETECTOR_BLANK_THRESHOLD=0.5
+DETECTOR_TEXT_THRESHOLD=0.6
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..237ad0f0df46521cf00a815479026f7478067271
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,45 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [main, develop, feature/*]
+
+jobs:
+ lint-and-test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Python 3.12
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Cache pip dependencies
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/pip
+ key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}
+ restore-keys: |
+ ${{ runner.os }}-pip-
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[dev]"
+
+ # 🔍 Lint with Ruff
+ - name: Lint with Ruff
+ run: ruff check pdf2zh test
+
+ # 🎨 Format check with Black
+ - name: Check formatting with Black
+ run: black --check pdf2zh test
+
+ # 🧪 Run tests
+ - name: Run pytest
+ run: pytest test/ -v
+
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..4b8736649a7e870b980c7969d362e8f17c727b1a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,54 @@
+# macOS
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Python
+__pycache__/
+*.py[cod]
+*.pyo
+*.pyd
+.Python
+*.egg
+*.egg-info/
+dist/
+build/
+.eggs/
+*.whl
+
+# Virtual environments
+.venv/
+venv/
+env/
+ENV/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+
+# Jupyter
+.ipynb_checkpoints/
+
+# Logs & temp
+*.log
+*.tmp
+./claude CLAUDE.md
+.claude/
+CLAUDE.md
+claude
+.DS_Store
+
+# File
+output.json
+math.json
+*.pdf
+SURYAOCR_README.md
+test.py
+
+# Bỏ qua tất cả mọi thứ bên trong thư mục model_path
+pdf2zh/scanned/model_path/*
+test_local/
+
+.env
diff --git a/.ruffignore b/.ruffignore
new file mode 100644
index 0000000000000000000000000000000000000000..6cc9fc83ba772e779eeb24107115dc3c2ae40bb8
--- /dev/null
+++ b/.ruffignore
@@ -0,0 +1,12 @@
+# Ignore generated files and directories
+__pycache__/
+*.egg-info/
+.git/
+.venv/
+venv/
+env/
+dist/
+build/
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..eecefc9efc2bbdae1fe23140fd27cd3b2a1776d9
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,121 @@
+# syntax=docker/dockerfile:1
+# E2E PDF translator (OCR -> Translate -> Render) for a GPU Hugging Face Space.
+# Base: CUDA 13 runtime + Ubuntu 22.04 (Python 3.10) — the config that builds cleanly.
+# Only surya-ocr/paddleocr are pinned (in requirements.txt); torch/paddle/numpy stay
+# unpinned so pip resolves a mutually compatible CUDA stack. T4 = Turing sm_75 (OK on CUDA 13).
+# NOTE: if this tag 404s at build, pick an existing one from
+# https://hub.docker.com/r/nvidia/cuda/tags (e.g. 13.0.1-cudnn-runtime-ubuntu22.04).
+FROM nvidia/cuda:13.0.0-cudnn-runtime-ubuntu22.04
+
+ENV DEBIAN_FRONTEND=noninteractive \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ TORCH_DEVICE=cuda \
+ TYPST_BIN=typst \
+ PDF2ZH_FONT_DIR=/app/fonts \
+ HF_HOME=/app/.cache/huggingface \
+ TRANSFORMERS_CACHE=/app/.cache/huggingface \
+ TYPST_PACKAGE_CACHE_PATH=/app/.cache/typst \
+ MODEL_CACHE_DIR=/app/.cache/datalab/models \
+ PADDLE_PDX_CACHE_HOME=/app/.cache/paddlex
+
+WORKDIR /app
+EXPOSE 7860
+
+# ── System deps ───────────────────────────────────────────────────────────────
+# - python 3.10 + pip (Ubuntu 22.04 default)
+# - OpenCV / PyMuPDF runtime libs (libgl1, libglib2.0-0, ...)
+# - fonts: Noto Sans/Serif + Noto CJK (covers Vietnamese + CJK), fontconfig
+# - wget/xz to fetch the typst binary
+RUN apt-get update && apt-get install --no-install-recommends -y \
+ python3 python3-pip python3-dev \
+ libgl1 libglib2.0-0 libxext6 libsm6 libxrender1 \
+ fontconfig fonts-noto-core fonts-noto-cjk \
+ wget xz-utils ca-certificates && \
+ rm -rf /var/lib/apt/lists/*
+
+# ── Typst binary ────────────────────────────────────────────────────────────────
+ARG TYPST_VERSION=v0.14.2
+RUN wget -qO /tmp/typst.tar.xz \
+ "https://github.com/typst/typst/releases/download/${TYPST_VERSION}/typst-x86_64-unknown-linux-musl.tar.xz" && \
+ tar -xJf /tmp/typst.tar.xz -C /tmp && \
+ install -m 0755 /tmp/typst-x86_64-unknown-linux-musl/typst /usr/local/bin/typst && \
+ rm -rf /tmp/typst* && typst --version
+
+# ── Extra fonts (Be Vietnam Pro — open-source Google Font) ───────────────────────
+RUN mkdir -p /app/fonts && \
+ for w in Regular Bold Italic; do \
+ wget -qO "/app/fonts/BeVietnamPro-${w}.ttf" \
+ "https://github.com/google/fonts/raw/main/ofl/bevietnampro/BeVietnamPro-${w}.ttf" || true; \
+ done && \
+ # also surface the system Noto fonts to the typst --font-path dir
+ cp -n /usr/share/fonts/truetype/noto/*.ttf /app/fonts/ 2>/dev/null || true && \
+ cp -n /usr/share/fonts/opentype/noto/*.otf /app/fonts/ 2>/dev/null || true && \
+ fc-cache -f
+
+# ── Python deps ──────────────────────────────────────────────────────────────────
+COPY requirements.txt .
+RUN python3 -m pip install --upgrade pip && \
+ python3 -m pip install -r requirements.txt
+
+# ── App code ─────────────────────────────────────────────────────────────────────
+# Running from /app puts the pdf2zh package on sys.path, so no editable install is
+# needed (and it avoids pulling pyproject's heavier optional deps like babeldoc).
+COPY . .
+
+# ── Seed .env from the tracked template ────────────────────────────────────────
+# .env is gitignored (absent from the image) but Settings reads env_file=".env".
+# Copy it here as root: the Space runs the container as a non-root user that cannot
+# write to /app at runtime, so seeding only in the entrypoint fails with EACCES.
+RUN cp -n .env.example .env 2>/dev/null || true
+
+# ── Pre-cache typst packages (cmarker + mitex) so runtime needs no network ─────────
+RUN mkdir -p /app/.cache/typst && \
+ printf '#import "@preview/cmarker:0.1.8"\n#import "@preview/mitex:0.2.6": *\n#cmarker.render("ok")\n' \
+ > /tmp/warm.typ && \
+ typst compile /tmp/warm.typ /tmp/warm.pdf || echo "typst package pre-cache skipped"
+
+# OCR models (~3-5GB) are NOT baked in — they download on the first request:
+# - Surya (layout/detection/recognition) -> Datalab's servers, cached in MODEL_CACHE_DIR
+# - Paddle table-cell model -> PaddleX model server, cached in PADDLE_PDX_CACHE_HOME
+# Make caches writable in case the Space runs the container as a non-root user.
+RUN mkdir -p /app/.cache/datalab/models /app/.cache/paddlex /app/.cache/huggingface \
+ /app/.cache/typst && chmod -R 777 /app/.cache
+
+# Force UTF-8 so the app can write Vietnamese text / .typ files on a minimal locale
+# (placed after pip so the heavy install layer stays cached). Avoids
+# UnicodeEncodeError: 'ascii' codec can't encode ... at render time.
+ENV PYTHONUTF8=1 \
+ LANG=C.UTF-8 \
+ LC_ALL=C.UTF-8
+
+# Entrypoint: prefer HF Persistent Storage (/data) for the model caches so they
+# survive sleep/restart and download only once. Falls back to /app/.cache (ephemeral)
+# when persistent storage is not enabled.
+RUN cat > /usr/local/bin/entrypoint.sh <<'EOF'
+#!/usr/bin/env bash
+set -e
+if mkdir -p /data 2>/dev/null && [ -w /data ]; then
+ CACHE_ROOT=/data
+else
+ CACHE_ROOT=/app/.cache
+fi
+export MODEL_CACHE_DIR="$CACHE_ROOT/datalab/models"
+export PADDLE_PDX_CACHE_HOME="$CACHE_ROOT/paddlex"
+export HF_HOME="$CACHE_ROOT/huggingface"
+export TRANSFORMERS_CACHE="$CACHE_ROOT/huggingface"
+mkdir -p "$MODEL_CACHE_DIR" "$PADDLE_PDX_CACHE_HOME" "$HF_HOME"
+echo "[entrypoint] model cache root = $CACHE_ROOT"
+# Fallback seed for writable-/app environments (local runs). On the Space /app is
+# read-only at runtime, so .env is already baked at build time; keep this non-fatal
+# (|| true) so a failed copy never aborts the entrypoint under `set -e`.
+if [ ! -f /app/.env ] && [ -f /app/.env.example ]; then
+ cp /app/.env.example /app/.env 2>/dev/null \
+ && echo "[entrypoint] seeded /app/.env from .env.example" \
+ || echo "[entrypoint] /app not writable; using build-time .env"
+fi
+exec python3 app.py
+EOF
+RUN chmod +x /usr/local/bin/entrypoint.sh
+
+CMD ["/usr/local/bin/entrypoint.sh"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..0ad25db4bd1d86c452db3f9602ccdbe172438f52
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..ed0ee9d277a48c0c697d5b2dd73306a4ec601e51
--- /dev/null
+++ b/README.md
@@ -0,0 +1,406 @@
+---
+title: PDF Translator
+emoji: 📄
+colorFrom: indigo
+colorTo: blue
+sdk: docker
+app_port: 7860
+pinned: false
+---
+
+# PDF Translator — End-to-End (OCR → Translate → Render)
+
+A single Gradio app that runs a full document-translation pipeline and rebuilds a
+**layout-faithful PDF** in the target language. It chains three phases into one
+end-to-end flow:
+
+| Phase | Package | What it does |
+|-------|---------|--------------|
+| **1 · Parse / OCR** | [`pdf2zh/parser`](pdf2zh/parser) | Layout detection + OCR (Surya) and table cells (PaddleOCR) → a structured `ParsedDocument` (JSON). |
+| **2 · Translate** | [`pdf2zh/translation`](pdf2zh/translation) | Async, chunked LLM translation with glossary, math-fix, TOC-fix and vision passes. |
+| **3 · Render** | [`pdf2zh/render`](pdf2zh/render) | Rebuilds the PDF with **Typst**, overlaying translated text on the original layout. |
+
+- **Entry point:** [`app.py`](app.py) → warms up models, then launches the Gradio UI ([`pdf2zh/webapp/ui.py`](pdf2zh/webapp/ui.py)).
+- **Orchestration:** [`pdf2zh/e2e.py`](pdf2zh/e2e.py) — `run_pipeline()` chains Phase 1 → 2 → 3.
+
+---
+
+## Table of contents
+
+1. [How it works](#how-it-works)
+2. [Prerequisites (exact versions)](#prerequisites-exact-versions)
+3. [Quick start — Docker (recommended, closest to production)](#quick-start--docker-recommended-closest-to-production)
+4. [Run locally without Docker (personal GPU / laptop)](#run-locally-without-docker-personal-gpu--laptop)
+5. [Configuration reference (`.env`)](#configuration-reference-env)
+6. [Model downloads & caching](#model-downloads--caching)
+7. [Using the web app](#using-the-web-app)
+8. [Command-line & programmatic use](#command-line--programmatic-use)
+9. [Testing](#testing)
+10. [Deploy to a Hugging Face Space](#deploy-to-a-hugging-face-space)
+11. [Troubleshooting](#troubleshooting)
+12. [Customizing](#customizing)
+13. [Known limitations](#known-limitations)
+14. [License](#license)
+
+---
+
+## How it works
+
+```
+app.py (Gradio UI, warmup() on boot, demo.queue serializes requests)
+ │
+ ▼
+pdf2zh/e2e.py :: run_pipeline(pdf_path, src_lang, tgt_lang, provider,
+ api_key, model, pages, font, work_dir, progress)
+ ├─ Phase 1 get_parser().parse_pdf(...) # StageAParser — models loaded ONCE (singleton)
+ │ └─ writes work_dir/phase1_parsed.json
+ ├─ Phase 2 translate_document(parsed_dict, TranslatorConfig)
+ │ └─ writes work_dir/phase2_translated.json
+ └─ Phase 3 render_document(pdf_path, translated_dict, out.pdf, RenderConfig)
+ └─ shells out to the `typst` binary → translated_.pdf
+```
+
+- **Model singleton** — `StageAParser` loads ~3–5 GB of OCR weights exactly once
+ (`warmup()` at startup), not per request. See [`pdf2zh/e2e.py`](pdf2zh/e2e.py).
+- **Providers** — OpenRouter, Gemini, OpenAI, DeepSeek, MiniMax, Anthropic, LiteLLM.
+ The user supplies their **own API key** in the UI; nothing is stored server-side.
+- **Fonts** — the chosen font heads a multilingual fallback chain
+ (Noto Sans / Noto Serif / Noto CJK / Be Vietnam Pro) so missing glyphs degrade
+ gracefully. The default Helvetica lacks Vietnamese glyphs and is always overridden.
+
+---
+
+## Prerequisites (exact versions)
+
+Reproducing this project reliably means matching the following stack. Deviating
+(especially on the OCR/GPU pins) is the most common cause of a broken build.
+
+| Component | Version / constraint | Notes |
+|-----------|----------------------|-------|
+| **Python** | `>=3.10, <3.13` | 3.10 / 3.11 / 3.12 only. Set in [`pyproject.toml`](pyproject.toml). |
+| **Typst** | `v0.14.2` binary on `PATH` | Phase 3 shells out to it. Later 0.x may work but is untested. |
+| **CUDA (GPU path)** | **13.x** runtime + driver | Docker base = `nvidia/cuda:13.0.0-cudnn-runtime-ubuntu22.04`. |
+| **surya-ocr** | `==0.17.1` (pinned) | 0.18+ dropped the `settings.*_BATCH_SIZE` API used by `hardware.py`. |
+| **transformers** | `==4.56.1` (pinned) | Matches surya-ocr 0.17.1. |
+| **paddleocr** | `==3.6.0` (pinned) | Table-cell recognition. |
+| **paddlepaddle-gpu** | `==3.3.1` (cu130) | Installed from the `cu130` extra index (see `requirements.txt`). |
+| **torch / torchvision / numpy** | unpinned | Left to pip so it resolves a CUDA stack compatible with paddle. |
+| **Fonts** | Noto Sans, Noto Serif, Noto CJK, Be Vietnam Pro | Must be visible to Typst (bundled in the Docker image). |
+
+> **GPU is strongly recommended.** Phase 1 (Surya + PaddleOCR + Torch) is slow on CPU.
+> A single 16 GB T4 works for small page ranges; an A100 (80 GB) handles the full
+> batch-size settings in `.env.example`. On Apple Silicon the parser auto-selects
+> the `mps` device with reduced batch sizes.
+
+---
+
+## Quick start — Docker (recommended, closest to production)
+
+Docker is the only path that pins **every** system dependency (CUDA, Typst, fonts,
+locale). Use it for the most reproducible result.
+
+```bash
+# 1. Clone
+git clone https://github.com/HoanggNguyen/PDFTranslator.git
+cd PDFTranslator
+
+# 2. Seed the environment file (the container also does this, but do it locally too)
+cp .env.example .env
+
+# 3. Build the image (installs CUDA deps, Typst v0.14.2, fonts, Python deps)
+docker build -t pdf2zh .
+
+# 4a. Run WITH a GPU (requires the NVIDIA Container Toolkit on the host)
+docker run --gpus all -p 7860:7860 pdf2zh
+
+# 4b. Run WITHOUT a GPU (CPU only — much slower, fine for testing wiring)
+docker run -p 7860:7860 -e DEVICE=cpu pdf2zh
+
+# 5. Open the app
+# http://localhost:7860
+```
+
+**Persisting model weights across restarts** (avoid re-downloading ~3–5 GB):
+
+```bash
+docker run --gpus all -p 7860:7860 \
+ -v "$PWD/.model-cache:/data" \
+ pdf2zh
+```
+
+The container entrypoint prefers `/data` for all model caches when it is writable
+(see the `entrypoint.sh` block in the [`Dockerfile`](Dockerfile)); mounting a host
+volume there makes the weights survive container restarts.
+
+> **NVIDIA Container Toolkit** is required for `--gpus all`. Install it on the host
+> first: .
+> Verify with `docker run --rm --gpus all nvidia/cuda:13.0.0-base-ubuntu22.04 nvidia-smi`.
+
+---
+
+## Run locally without Docker (personal GPU / laptop)
+
+Use this when you want to develop against the code directly. You are responsible
+for three system dependencies that Docker would otherwise provide: **Python 3.10–3.12**,
+the **Typst binary**, and **fonts**.
+
+### 1. Clone and create an isolated environment
+
+```bash
+git clone https://github.com/HoanggNguyen/PDFTranslator.git
+cd PDFTranslator
+
+python3.12 -m venv .venv # any 3.10–3.12 interpreter
+source .venv/bin/activate # Windows: .venv\Scripts\activate
+python -m pip install --upgrade pip
+```
+
+### 2. Install the Typst binary (v0.14.2)
+
+Phase 3 calls the `typst` executable. It must be on `PATH` (or point `TYPST_BIN`
+at it).
+
+```bash
+# macOS (Homebrew)
+brew install typst # then verify the version is 0.14.x
+typst --version
+
+# Linux (x86_64) — pinned release, matches the Docker image
+wget -qO /tmp/typst.tar.xz \
+ "https://github.com/typst/typst/releases/download/v0.14.2/typst-x86_64-unknown-linux-musl.tar.xz"
+tar -xJf /tmp/typst.tar.xz -C /tmp
+sudo install -m 0755 /tmp/typst-x86_64-unknown-linux-musl/typst /usr/local/bin/typst
+typst --version
+
+# Any platform (Cargo)
+cargo install typst-cli --locked
+```
+
+### 3. Install fonts (Vietnamese + CJK coverage)
+
+Typst renders with whatever fonts it can find. Install Noto (covers Vietnamese and
+CJK) and optionally Be Vietnam Pro, then confirm Typst sees them:
+
+- **Linux:** `sudo apt-get install -y fonts-noto-core fonts-noto-cjk && fc-cache -f`
+- **macOS:** install the Noto families (e.g. via Homebrew casks or Google Fonts).
+- Alternatively, drop `.ttf/.otf` files into a directory and set `PDF2ZH_FONT_DIR`
+ to it; the app passes that directory to Typst's `--font-path`.
+
+```bash
+typst fonts | grep -i noto # should list Noto families
+```
+
+### 4. Install Python dependencies
+
+```bash
+# GPU stack (Linux + CUDA 13) — installs paddlepaddle-gpu (cu130) via the extra index
+pip install -r requirements.txt
+
+# CPU / macOS: requirements.txt targets a CUDA GPU. On a machine without a
+# CUDA GPU, edit requirements.txt to drop the `paddlepaddle-gpu` line and the
+# cu130 --extra-index-url, and install the CPU wheel instead:
+# pip install paddlepaddle==3.3.1
+# then: pip install -r requirements.txt
+```
+
+### 5. Configure and run
+
+```bash
+cp .env.example .env # then edit as needed (see Configuration reference)
+python app.py # warms up models, serves http://localhost:7860
+```
+
+The first launch downloads the OCR weights (~3–5 GB) — see the next section.
+
+---
+
+## Configuration reference (`.env`)
+
+`.env` is **gitignored**; [`.env.example`](.env.example) is the tracked template —
+always `cp .env.example .env` after cloning. Values are read by
+[`pdf2zh/config.py`](pdf2zh/config.py) (`Settings`, via `pydantic-settings`) and
+consumed by the Phase-1 parser.
+
+| Variable | Default | Meaning |
+|----------|---------|---------|
+| `DEVICE` | `auto` | `cuda`, `mps`, `cpu`, or `auto` (→ CUDA if a GPU is present, else MPS, else CPU). |
+| `PAGE_BATCH_SIZE` | *(unset)* | Pages processed per batch. Leave unset on small GPUs. |
+| `LAYOUT_BATCH_SIZE` | *(unset)* | Surya layout batch. |
+| `DETECTION_BATCH_SIZE` | *(unset)* | Surya text-detection batch. |
+| `OCR_BATCH_SIZE` | *(unset)* | Surya recognition batch (heaviest on VRAM). |
+| `TABLE_BATCH_SIZE` | *(unset)* | Paddle table-cell batch. |
+| `DETECTOR_BLANK_THRESHOLD` | `0.5` | OCR accuracy tuning (not VRAM related). |
+| `DETECTOR_TEXT_THRESHOLD` | `0.6` | Must be **>** the blank threshold. |
+
+**Batch-size guidance** (from `.env.example`):
+- **Large GPU (A100 80 GB):** the values in `.env.example` (OCR 512, layout/detection 64,
+ page 32, table 512) peak around 45–55 GB VRAM.
+- **Small GPU (T4 16 GB):** leave every batch size **unset (empty)** so Surya picks
+ safe defaults and avoids OOM.
+- Unset values fall back to per-device defaults in
+ [`pdf2zh/parser/utils/hardware.py`](pdf2zh/parser/utils/hardware.py).
+
+### Provider / API keys
+
+You do **not** put translation API keys in `.env` for normal use — they are entered
+in the web UI per request and never stored. For **headless/CLI** runs you may set the
+provider's env var instead of passing `--api-key`:
+
+| Provider (UI label) | Key (config) | Env var | Default model |
+|---------------------|--------------|---------|---------------|
+| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` | `google/gemini-3.1-flash-lite` |
+| Gemini | `gemini` | `GEMINI_API_KEY` | `gemini-2.5-flash-lite` |
+| OpenAI | `openai` | `OPENAI_API_KEY` | `gpt-4o-mini` |
+| DeepSeek | `deepseek` | `DEEPSEEK_API_KEY` | `deepseek-chat` |
+| MiniMax | `minimax` | `MINIMAX_API_KEY` | `MiniMax-Text-01` |
+| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` | `claude-haiku-4-5` |
+| LiteLLM | `litellm` | `LITELLM_API_KEY` (+ `LITELLM_BASE_URL`) | proxy-routed |
+
+Defined in [`pdf2zh/translation/config.py`](pdf2zh/translation/config.py) (`PROVIDERS`).
+
+---
+
+## Model downloads & caching
+
+The OCR weights are **not** bundled — they download on the **first request** and are
+then cached. Point these env vars at a persistent, writable directory to download
+them only once:
+
+| Env var | What it caches |
+|---------|----------------|
+| `MODEL_CACHE_DIR` | Surya layout / detection / recognition models (Datalab). |
+| `PADDLE_PDX_CACHE_HOME` | Paddle table-cell model (PaddleX). |
+| `HF_HOME` / `TRANSFORMERS_CACHE` | Hugging Face / transformers assets. |
+
+In Docker these default to `/app/.cache/*` and, when Hugging Face Persistent Storage
+(or a mounted `-v host:/data`) is available, to `/data/*` (handled by the entrypoint).
+Locally they default to the standard per-tool locations unless you export them, e.g.:
+
+```bash
+export MODEL_CACHE_DIR="$HOME/.cache/pdf2zh/datalab"
+export PADDLE_PDX_CACHE_HOME="$HOME/.cache/pdf2zh/paddlex"
+export HF_HOME="$HOME/.cache/pdf2zh/huggingface"
+```
+
+> First run also warms the Typst package cache (`cmarker`, `mitex`). In Docker this
+> is pre-baked; locally Typst fetches them once from `@preview` (needs network on
+> first render).
+
+---
+
+## Using the web app
+
+1. Upload a PDF in the main panel.
+2. In the sidebar pick a **Provider**, paste your **API key** (optionally click
+ *Load models* to fetch the model list, or type a model name).
+3. Choose **source / target language**, **output font**, and **page range**
+ (All / First page / First 5 / First N — capped at 50 pages per request to guard
+ against OOM).
+4. Click **Translate**. A modal streams per-phase progress.
+5. Preview and download the translated PDF.
+
+---
+
+## Command-line & programmatic use
+
+Useful for automation, batch jobs, and debugging a single phase in isolation.
+
+### End-to-end (all three phases)
+
+```python
+from pdf2zh.e2e import run_pipeline
+
+out = run_pipeline(
+ pdf_path="test/file/translate.cli.plain.text.pdf",
+ src_lang="English", tgt_lang="Vietnamese",
+ provider="openrouter", api_key="",
+ model=None, # None → provider default
+ pages=[0], # 0-based list, or None for all pages
+ font="Noto Sans",
+ work_dir="/tmp/e2e_test", # phase1/phase2 JSON + final PDF land here
+ progress=lambda f, m: print(f"{f:.0%} {m}"),
+)
+print("OUTPUT:", out)
+```
+
+### Phase 2 only — translate an existing parsed JSON
+
+```bash
+python test/verify_translate.py \
+ --input test_local/output_math.json \
+ --provider openrouter --api-key "$OPENROUTER_API_KEY" \
+ --src English --tgt Vietnamese
+```
+
+(The underlying CLI is [`pdf2zh/translation/cli.py`](pdf2zh/translation/cli.py):
+`--provider`, `--model`, `--api-key`, `--concurrent`, `--chunk-bytes`,
+`--no-glossary`, `--no-math-fix`, `--no-toc-fix`, …)
+
+### Phase 3 only — render translated JSON back onto the original PDF
+
+```bash
+python -m pdf2zh.render \
+ --pdf \
+ --parsed test_local/output_math.translated.json \
+ --output /tmp/render_test.pdf \
+ --font-family "Noto Sans" \
+ --typst-bin typst
+```
+
+See [`pdf2zh/render/cli.py`](pdf2zh/render/cli.py) for all flags
+(`--pages`, `--min-font`, `--no-redact`, `--keep-typst-source`, `--aggressive-compress`, …).
+
+---
+
+## Testing
+
+```bash
+# 0. Cheap import check (does NOT load models)
+python -c "import pdf2zh.e2e; print('e2e import OK')"
+
+# 1. Unit / integration tests (do not require a GPU or API key for most cases)
+pytest -q
+
+# 2. Phase-2 smoke (needs an API key)
+python test/verify_translate.py --input test_local/output_math.json \
+ --provider openrouter --api-key "$OPENROUTER_API_KEY" --src English --tgt Vietnamese
+
+# 3. Phase-3 render feasibility / smoke
+python test/verify_render.py --input \
+ --parsed test_local/output_math.translated.json --output /tmp/render_test.pdf
+```
+
+Tips: the first run downloads the OCR models (~3–5 GB); use a **single page** while
+iterating to keep API cost and latency low.
+
+---
+
+## Customizing
+
+- **Add a font** — drop a `.ttf/.otf` into the font directory (`PDF2ZH_FONT_DIR`,
+ `/app/fonts` in Docker; see [`Dockerfile`](Dockerfile)) and add its family name to
+ `BUNDLED_FONTS` in [`pdf2zh/e2e.py`](pdf2zh/e2e.py).
+- **Add a provider** — add an entry to `PROVIDERS` in
+ [`pdf2zh/translation/config.py`](pdf2zh/translation/config.py) and to `PROVIDER_KEY`
+ in [`pdf2zh/webapp/config.py`](pdf2zh/webapp/config.py).
+- **Change page limit / default language / default font** — edit the constants in
+ [`pdf2zh/webapp/config.py`](pdf2zh/webapp/config.py) (`MAX_CUSTOM_PAGES`,
+ `PAGE_PRESETS`) and [`pdf2zh/e2e.py`](pdf2zh/e2e.py) (`SUPPORTED_LANGUAGES`,
+ `DEFAULT_FONT`).
+- **Tune OCR batch sizes / device** — edit `.env` (see Configuration reference).
+
+---
+
+## Known limitations
+
+- Equation elements without `equation_words` are rendered as-is (not translated).
+- A single T4 (16 GB) can OOM on large PDFs; the UI caps custom page counts at 50 and
+ serializes requests via `demo.queue()`. Reduce the page range if needed.
+- Phase 3 depends on the external `typst` binary; a version mismatch can change layout.
+
+---
+
+## License
+
+This project builds on [PDFMathTranslate](https://github.com/Byaidu/PDFMathTranslate)
+(AGPL-3.0). See [`LICENSE`](LICENSE).
diff --git a/app.json b/app.json
new file mode 100644
index 0000000000000000000000000000000000000000..8164d42090abdb9e316ddca0c503f3a083d76e42
--- /dev/null
+++ b/app.json
@@ -0,0 +1,5 @@
+{
+ "name": "PDFMathTranslate",
+ "description": "PDF scientific paper translation and bilingual comparison.",
+ "repository": "https://github.com/Byaidu/PDFMathTranslate"
+}
\ No newline at end of file
diff --git a/app.py b/app.py
new file mode 100644
index 0000000000000000000000000000000000000000..20ad8ad9be756e693c066737ce421c793861d822
--- /dev/null
+++ b/app.py
@@ -0,0 +1,34 @@
+"""Gradio app entry point: end-to-end PDF translation (OCR -> Translate -> Render).
+
+Single entry point for the Hugging Face Space (Docker SDK). UI, styling, and the
+pipeline runner live in ``pdf2zh.webapp``; this module only warms up the heavy
+models and launches the server.
+"""
+
+from __future__ import annotations
+
+import logging
+import tempfile
+
+from pdf2zh.e2e import warmup
+from pdf2zh.webapp.ui import build_ui
+
+logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
+logger = logging.getLogger(__name__)
+
+# Load the heavy Phase-1 models at boot so the first request isn't penalized.
+try:
+ warmup()
+except Exception as exc: # noqa: BLE001 — log but still start the UI
+ logger.warning("warmup failed (models will load on first request): %s", exc)
+
+demo = build_ui()
+
+if __name__ == "__main__":
+ # The rendered PDF lives under the system temp dir (see runner.py); allow
+ # Gradio to serve it so the preview/download components can load it.
+ demo.queue(max_size=8).launch(
+ server_name="0.0.0.0",
+ server_port=7860,
+ allowed_paths=[tempfile.gettempdir()],
+ )
diff --git a/benchmark/__init__.py b/benchmark/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/benchmark/parser/.gitignore b/benchmark/parser/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..221d68ed44efd5bdc1cd971fc77672fb24dec611
--- /dev/null
+++ b/benchmark/parser/.gitignore
@@ -0,0 +1,8 @@
+data/
+parser_results/
+eval_results/
+
+myenv/
+.venv/
+__pycache__/
+cdm_work/
diff --git a/benchmark/parser/README.md b/benchmark/parser/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..20de58be2474dc5aa940d7e5dd465061f01bf980
--- /dev/null
+++ b/benchmark/parser/README.md
@@ -0,0 +1,175 @@
+# Benchmark — Đánh giá phân tích bố cục (parser vs OmniDocBench)
+
+Đo độ chính xác của `StageAParser` (giai đoạn phân tích cấu trúc của PDFTranslator) bằng cách đối chiếu trực tiếp với ground truth của **OmniDocBench**. Quy trình gồm **hai giai đoạn**:
+
+1. **Sinh prediction** — chạy parser trên toàn bộ trang OmniDocBench → cần **GPU** (nên dùng Google Colab A100).
+2. **Chấm điểm (eval)** — tính các độ đo (định vị, phân loại, OCR, thứ tự đọc, công thức, bảng) → chỉ cần **CPU**, chạy local. Riêng chỉ số **CDM** cho công thức cần thêm TeX Live + ImageMagick.
+
+---
+
+## Cấu trúc thư mục
+
+```
+benchmark/parser/
+├── run_parser/ # build_pdfs.py, run_parser.py (sinh prediction — cần GPU)
+├── evaluation/ # eval_layout / eval_formula / eval_table / eval_formula_cdm
+│ # aggregate_reports.py, compare_matchers.py, download_dataset.py
+│ # requirements-eval.txt
+├── data/ # OmniDocBench.json + images/ + pdfs/ (tải/tạo ở bước 1)
+├── parser_results/ # batch_*.json (ParsedDocument) + mapping.json (đầu ra parser)
+└── eval_results/ # *.json report + eval_summary_*.csv (đầu ra eval)
+```
+
+---
+
+## 0. Chuẩn bị
+
+```bash
+git clone https://github.com/HoanggNguyen/PDFTranslator.git
+cd PDFTranslator
+# repo OmniDocBench chỉ cần cho CDM (đánh giá công thức):
+git clone https://github.com/opendatalab/OmniDocBench.git
+cd benchmark/parser
+```
+
+---
+
+## 1. Sinh prediction bằng parser (GPU)
+
+Giai đoạn này cần GPU lớn. Nếu GPU laptop không đủ thì dùng **GoogleColab**.
+
+### 1a. Kiểm tra GPU và tạo môi trường ảo
+
+```bash
+nvidia-smi
+```
+
+Trên Colab, tạo môi trường ảo riêng để **tránh xung đột** với các gói cài sẵn:
+
+```bash
+pip install virtualenv
+virtualenv myenv
+./myenv/bin/pip install -r ../../requirements.txt
+```
+
+(Nếu chạy local có GPU đủ: dùng venv của repo và `pip install -r ../../requirements.txt`.)
+
+### 1b. Tải dữ liệu → gộp PDF → chạy parser
+
+```bash
+# (1) tải ảnh + OmniDocBench.json về data/
+./myenv/bin/python evaluation/download_dataset.py --out data
+
+# (2) gộp ảnh thành PDF 32 trang/PDF (tạo kèm data/pdfs/mapping.json)
+./myenv/bin/python run_parser/build_pdfs.py \
+ --images data/images --out data/pdfs --per-pdf 32
+
+# (3) chạy parser -> parser_results/batch_*.json + báo cáo thời gian
+./myenv/bin/python run_parser/run_parser.py \
+ --pdfs data/pdfs \
+ --out parser_results \
+ --timing eval_results/parser_timing.json \
+ --device cuda
+```
+
+Tuỳ chọn hữu ích của `run_parser.py`:
+
+- Batch size (điều chỉnh theo VRAM): `--layout-batch-size`, `--detection-batch-size`, `--ocr-batch-size`, `--table-batch-size`, `--page-batch-size` (mặc định hợp cho A100).
+- Ngưỡng detector: `--blank-threshold`, `--text-threshold`.
+- `--limit N`: chỉ chạy N PDF đầu (test nhanh); `--overwrite`: chạy lại PDF đã có JSON.
+
+> `build_pdfs.py` ghi `mapping.json` cạnh các PDF (`data/pdfs/mapping.json`). Để các
+> lệnh eval ở Giai đoạn 2 chạy nguyên trạng, sau khi chạy parser hãy copy nó vào
+> `parser_results/`: `cp data/pdfs/mapping.json parser_results/`.
+
+- Tải `parser_results/` từ Colab về máy để chấm điểm ở Giai đoạn 2.
+
+---
+
+## 2. Chấm điểm (eval — CPU, chạy local)
+
+### 2a. Môi trường eval
+
+```bash
+python3 -m venv .venv
+.venv/bin/pip install -r evaluation/requirements-eval.txt
+
+sudo apt install -y texlive-latex-base texlive-latex-extra \
+ texlive-fonts-recommended imagemagick
+```
+
+Đặt cho gọn: `PY=.venv/bin/python`.
+
+### 2b. Localization + Classification + OCR + Reading order
+
+```bash
+# fine (bung merge_list — khuyến nghị)
+$PY evaluation/eval_layout.py \
+ --gt data/OmniDocBench.json --pred parser_results \
+ --mapping parser_results/mapping.json \
+ --gt-granularity fine --out eval_results/eval_report_fine.json
+
+# merged (box top-level như OmniDocBench)
+$PY evaluation/eval_layout.py \
+ --gt data/OmniDocBench.json --pred parser_results \
+ --mapping parser_results/mapping.json \
+ --gt-granularity merged --out eval_results/eval_report_merged.json
+
+# fine + mask-math (thay công thức inline bằng token -> CER/WER text thuần)
+$PY evaluation/eval_layout.py \
+ --gt data/OmniDocBench.json --pred parser_results \
+ --mapping parser_results/mapping.json \
+ --gt-granularity fine --mask-math \
+ --out eval_results/eval_report_fine_maskmath.json
+```
+
+### 2c. Công thức (edit distance) + Bảng (nội dung)
+
+```bash
+$PY evaluation/eval_formula.py \
+ --gt data/OmniDocBench.json --pred parser_results \
+ --mapping parser_results/mapping.json \
+ --out eval_results/eval_report_formula.json
+
+$PY evaluation/eval_table.py \
+ --gt data/OmniDocBench.json --pred parser_results \
+ --mapping parser_results/mapping.json \
+ --out eval_results/eval_report_table.json
+```
+
+### 2d. Công thức CDM (chuẩn vàng — cần TeX Live + ImageMagick + pylatexenc)
+
+```bash
+$PY evaluation/eval_formula_cdm.py \
+ --gt data/OmniDocBench.json --pred parser_results \
+ --mapping parser_results/mapping.json \
+ --omnidocbench ../../OmniDocBench \
+ --out eval_results/eval_report_formula_cdm.json
+# thêm --limit 200 để test nhanh
+```
+
+### 2e. Gom kết quả thành CSV
+
+```bash
+$PY evaluation/aggregate_reports.py \
+ --layout fine=eval_results/eval_report_fine.json \
+ --layout merged=eval_results/eval_report_merged.json \
+ --layout fine_maskmath=eval_results/eval_report_fine_maskmath.json \
+ --formula eval_results/eval_report_formula.json \
+ --report table=eval_results/eval_report_table.json \
+ --out eval_results/eval_summary
+```
+
+### 2f. (tuỳ chọn) So sánh 3 cách matching localization
+
+```bash
+$PY evaluation/compare_matchers.py --iou 0.5 --granularity fine
+```
+
+---
+
+## Ghi chú
+
+- GT↔pred nối theo **tên ảnh** (`image_path`) qua `mapping.json` — khớp 1:1.
+- Eval chạy **CPU** (trừ CDM cần TeX Live). Chỉ Giai đoạn 1 (parser) mới cần GPU.
+- Các script trong `evaluation/` import lẫn nhau theo thư mục cạnh bên; hãy gọi bằng `python evaluation/
+
+ """
+
+tech_details_string = f"""
+ Technical details
+ - GitHub: Byaidu/PDFMathTranslate
+ - BabelDOC: funstory-ai/BabelDOC
+ - GUI by: Rongxin
+ - pdf2zh Version: {__version__}
+ - BabelDOC Version: {babeldoc_version}
+ """
+cancellation_event_map = {}
+
+
+# The following code creates the GUI
+with gr.Blocks(
+ title="PDFMathTranslate - PDF Translation with preserved formats",
+ theme=gr.themes.Default(
+ primary_hue=custom_blue, spacing_size="md", radius_size="lg"
+ ),
+ css=custom_css,
+ head=demo_recaptcha if flag_demo else "",
+) as demo:
+ gr.Markdown(
+ "# [PDFMathTranslate @ GitHub](https://github.com/Byaidu/PDFMathTranslate)"
+ )
+
+ with gr.Row():
+ with gr.Column(scale=1):
+ gr.Markdown("## File | < 5 MB" if flag_demo else "## File")
+ file_type = gr.Radio(
+ choices=["File", "Link"],
+ label="Type",
+ value="File",
+ )
+ file_input = gr.File(
+ label="File",
+ file_count="single",
+ file_types=[".pdf"],
+ type="filepath",
+ elem_classes=["input-file"],
+ )
+ link_input = gr.Textbox(
+ label="Link",
+ visible=False,
+ interactive=True,
+ )
+ gr.Markdown("## Option")
+ service = gr.Dropdown(
+ label="Service",
+ choices=enabled_services,
+ value=enabled_services[0],
+ )
+ envs = []
+ for i in range(3):
+ envs.append(
+ gr.Textbox(
+ visible=False,
+ interactive=True,
+ )
+ )
+ with gr.Row():
+ lang_from = gr.Dropdown(
+ label="Translate from",
+ choices=lang_map.keys(),
+ value=ConfigManager.get("PDF2ZH_LANG_FROM", "English"),
+ )
+ lang_to = gr.Dropdown(
+ label="Translate to",
+ choices=lang_map.keys(),
+ value=ConfigManager.get("PDF2ZH_LANG_TO", "Simplified Chinese"),
+ )
+ page_range = gr.Radio(
+ choices=page_map.keys(),
+ label="Pages",
+ value=list(page_map.keys())[0],
+ )
+
+ page_input = gr.Textbox(
+ label="Page range",
+ visible=False,
+ interactive=True,
+ )
+
+ with gr.Accordion("Open for More Experimental Options!", open=False):
+ gr.Markdown("#### Experimental")
+ threads = gr.Textbox(
+ label="number of threads", interactive=True, value="4"
+ )
+ skip_subset_fonts = gr.Checkbox(
+ label="Skip font subsetting", interactive=True, value=False
+ )
+ ignore_cache = gr.Checkbox(
+ label="Ignore cache", interactive=True, value=False
+ )
+ vfont = gr.Textbox(
+ label="Custom formula font regex (vfont)",
+ interactive=True,
+ value=ConfigManager.get("PDF2ZH_VFONT", ""),
+ )
+ prompt = gr.Textbox(
+ label="Custom Prompt for llm", interactive=True, visible=False
+ )
+ use_babeldoc = gr.Checkbox(
+ label="Use BabelDOC", interactive=True, value=False
+ )
+ envs.append(prompt)
+
+ def on_select_service(service, evt: gr.EventData):
+ translator = service_map[service]
+ _envs = []
+ for i in range(4):
+ _envs.append(gr.update(visible=False, value=""))
+ for i, env in enumerate(translator.envs.items()):
+ label = env[0]
+ value = ConfigManager.get_env_by_translatername(
+ translator, env[0], env[1]
+ )
+ visible = True
+ if hidden_gradio_details:
+ if (
+ "MODEL" not in str(label).upper()
+ and value
+ and hidden_gradio_details
+ ):
+ visible = False
+ # Hidden Keys From Gradio
+ if "API_KEY" in label.upper():
+ value = "***" # We use "***" Present Real API_KEY
+ _envs[i] = gr.update(
+ visible=visible,
+ label=label,
+ value=value,
+ )
+ _envs[-1] = gr.update(visible=translator.CustomPrompt)
+ return _envs
+
+ def on_select_filetype(file_type):
+ return (
+ gr.update(visible=file_type == "File"),
+ gr.update(visible=file_type == "Link"),
+ )
+
+ def on_select_page(choice):
+ if choice == "Others":
+ return gr.update(visible=True)
+ else:
+ return gr.update(visible=False)
+
+ def on_vfont_change(value):
+ ConfigManager.set("PDF2ZH_VFONT", value)
+ return value
+
+ output_title = gr.Markdown("## Translated", visible=False)
+ output_file_mono = gr.File(
+ label="Download Translation (Mono)", visible=False
+ )
+ output_file_dual = gr.File(
+ label="Download Translation (Dual)", visible=False
+ )
+ recaptcha_response = gr.Textbox(
+ label="reCAPTCHA Response", elem_id="verify", visible=False
+ )
+ recaptcha_box = gr.HTML('')
+ translate_btn = gr.Button("Translate", variant="primary")
+ cancellation_btn = gr.Button("Cancel", variant="secondary")
+ tech_details_tog = gr.Markdown(
+ tech_details_string,
+ elem_classes=["secondary-text"],
+ )
+ page_range.select(on_select_page, page_range, page_input)
+ service.select(
+ on_select_service,
+ service,
+ envs,
+ )
+ vfont.change(on_vfont_change, inputs=vfont, outputs=None)
+ file_type.select(
+ on_select_filetype,
+ file_type,
+ [file_input, link_input],
+ js=(
+ f"""
+ (a,b)=>{{
+ try{{
+ grecaptcha.render('recaptcha-box',{{
+ 'sitekey':'{client_key}',
+ 'callback':'onVerify'
+ }});
+ }}catch(error){{}}
+ return [a];
+ }}
+ """
+ if flag_demo
+ else ""
+ ),
+ )
+
+ with gr.Column(scale=2):
+ gr.Markdown("## Preview")
+ preview = PDF(label="Document Preview", visible=True, height=2000)
+
+ # Event handlers
+ file_input.upload(
+ lambda x: x,
+ inputs=file_input,
+ outputs=preview,
+ js=(
+ f"""
+ (a,b)=>{{
+ try{{
+ grecaptcha.render('recaptcha-box',{{
+ 'sitekey':'{client_key}',
+ 'callback':'onVerify'
+ }});
+ }}catch(error){{}}
+ return [a];
+ }}
+ """
+ if flag_demo
+ else ""
+ ),
+ )
+
+ state = gr.State({"session_id": None})
+
+ translate_btn.click(
+ translate_file,
+ inputs=[
+ file_type,
+ file_input,
+ link_input,
+ service,
+ lang_from,
+ lang_to,
+ page_range,
+ page_input,
+ prompt,
+ threads,
+ skip_subset_fonts,
+ ignore_cache,
+ vfont,
+ use_babeldoc,
+ recaptcha_response,
+ state,
+ *envs,
+ ],
+ outputs=[
+ output_file_mono,
+ preview,
+ output_file_dual,
+ output_file_mono,
+ output_file_dual,
+ output_title,
+ ],
+ ).then(lambda: None, js="()=>{grecaptcha.reset()}" if flag_demo else "")
+
+ cancellation_btn.click(
+ stop_translate_file,
+ inputs=[state],
+ )
+
+
+def parse_user_passwd(file_path: str) -> tuple:
+ """
+ Parse the user name and password from the file.
+
+ Inputs:
+ - file_path: The file path to read.
+ Outputs:
+ - tuple_list: The list of tuples of user name and password.
+ - content: The content of the file
+ """
+ tuple_list = []
+ content = ""
+ if not file_path:
+ return tuple_list, content
+ if len(file_path) == 2:
+ try:
+ with open(file_path[1], "r", encoding="utf-8") as file:
+ content = file.read()
+ except FileNotFoundError:
+ print(f"Error: File '{file_path[1]}' not found.")
+ try:
+ with open(file_path[0], "r", encoding="utf-8") as file:
+ tuple_list = [
+ tuple(line.strip().split(",")) for line in file if line.strip()
+ ]
+ except FileNotFoundError:
+ print(f"Error: File '{file_path[0]}' not found.")
+ return tuple_list, content
+
+
+def setup_gui(
+ share: bool = False, auth_file: list = ["", ""], server_port=7860
+) -> None:
+ """
+ Setup the GUI with the given parameters.
+
+ Inputs:
+ - share: Whether to share the GUI.
+ - auth_file: The file path to read the user name and password.
+
+ Outputs:
+ - None
+ """
+ user_list, html = parse_user_passwd(auth_file)
+ if flag_demo:
+ demo.launch(server_name="0.0.0.0", max_file_size="5mb", inbrowser=True)
+ else:
+ if len(user_list) == 0:
+ try:
+ demo.launch(
+ server_name="0.0.0.0",
+ debug=True,
+ inbrowser=True,
+ share=share,
+ server_port=server_port,
+ )
+ except Exception:
+ print(
+ "Error launching GUI using 0.0.0.0.\nThis may be caused by global mode of proxy software."
+ )
+ try:
+ demo.launch(
+ server_name="127.0.0.1",
+ debug=True,
+ inbrowser=True,
+ share=share,
+ server_port=server_port,
+ )
+ except Exception:
+ print(
+ "Error launching GUI using 127.0.0.1.\nThis may be caused by global mode of proxy software."
+ )
+ demo.launch(
+ debug=True, inbrowser=True, share=True, server_port=server_port
+ )
+ else:
+ try:
+ demo.launch(
+ server_name="0.0.0.0",
+ debug=True,
+ inbrowser=True,
+ share=share,
+ auth=user_list,
+ auth_message=html,
+ server_port=server_port,
+ )
+ except Exception:
+ print(
+ "Error launching GUI using 0.0.0.0.\nThis may be caused by global mode of proxy software."
+ )
+ try:
+ demo.launch(
+ server_name="127.0.0.1",
+ debug=True,
+ inbrowser=True,
+ share=share,
+ auth=user_list,
+ auth_message=html,
+ server_port=server_port,
+ )
+ except Exception:
+ print(
+ "Error launching GUI using 127.0.0.1.\nThis may be caused by global mode of proxy software."
+ )
+ demo.launch(
+ debug=True,
+ inbrowser=True,
+ share=True,
+ auth=user_list,
+ auth_message=html,
+ server_port=server_port,
+ )
+
+
+# For auto-reloading while developing
+if __name__ == "__main__":
+ logging.basicConfig(level=logging.DEBUG)
+ setup_gui()
diff --git a/pdf2zh/high_level.py b/pdf2zh/high_level.py
new file mode 100644
index 0000000000000000000000000000000000000000..d92aa842e37d1221dc43a980e0708d8a0102a5c4
--- /dev/null
+++ b/pdf2zh/high_level.py
@@ -0,0 +1,449 @@
+"""Functions that can be used for the most common use-cases for pdf2zh.six"""
+
+import asyncio
+import io
+import logging
+import os
+import re
+import sys
+import tempfile
+from asyncio import CancelledError
+from pathlib import Path
+from string import Template
+from typing import Any, BinaryIO, Dict, List, Optional
+
+import numpy as np
+import requests
+import tqdm
+from babeldoc.assets.assets import get_font_and_metadata
+from pdfminer.pdfdocument import PDFDocument
+from pdfminer.pdfexceptions import PDFValueError
+from pdfminer.pdfinterp import PDFResourceManager
+from pdfminer.pdfpage import PDFPage
+from pdfminer.pdfparser import PDFParser
+from pymupdf import Document, Font
+
+from pdf2zh.config import ConfigManager
+from pdf2zh.converter import TranslateConverter
+from pdf2zh.doclayout import OnnxModel
+from pdf2zh.parser.detector import PDFTypeDetector
+from pdf2zh.parser.main import StageAParser
+from pdf2zh.pdfinterp import PDFPageInterpreterEx
+
+NOTO_NAME = "noto"
+
+logger = logging.getLogger(__name__)
+
+noto_list = [
+ "am", # Amharic
+ "ar", # Arabic
+ "bn", # Bengali
+ "bg", # Bulgarian
+ "chr", # Cherokee
+ "el", # Greek
+ "gu", # Gujarati
+ "iw", # Hebrew
+ "hi", # Hindi
+ "kn", # Kannada
+ "ml", # Malayalam
+ "mr", # Marathi
+ "ru", # Russian
+ "sr", # Serbian
+ "ta", # Tamil
+ "te", # Telugu
+ "th", # Thai
+ "ur", # Urdu
+ "uk", # Ukrainian
+]
+
+
+def check_files(files: List[str]) -> List[str]:
+ files = [
+ f for f in files if not f.startswith("http://")
+ ] # exclude online files, http
+ files = [
+ f for f in files if not f.startswith("https://")
+ ] # exclude online files, https
+ missing_files = [file for file in files if not os.path.exists(file)]
+ return missing_files
+
+
+def translate_patch(
+ inf: BinaryIO,
+ pages: Optional[list[int]] = None,
+ vfont: str = "",
+ vchar: str = "",
+ thread: int = 0,
+ doc_zh: Document = None,
+ lang_in: str = "",
+ lang_out: str = "",
+ service: str = "",
+ noto_name: str = "",
+ noto: Font = None,
+ callback: object = None,
+ cancellation_event: asyncio.Event = None,
+ model: OnnxModel = None,
+ envs: Dict = None,
+ prompt: Template = None,
+ ignore_cache: bool = False,
+ **kwarg: Any,
+) -> None:
+ rsrcmgr = PDFResourceManager()
+ layout = {}
+ device = TranslateConverter(
+ rsrcmgr,
+ vfont,
+ vchar,
+ thread,
+ layout,
+ lang_in,
+ lang_out,
+ service,
+ noto_name,
+ noto,
+ envs,
+ prompt,
+ ignore_cache,
+ )
+
+ assert device is not None
+ obj_patch = {}
+ interpreter = PDFPageInterpreterEx(rsrcmgr, device, obj_patch)
+ if pages:
+ total_pages = len(pages)
+ else:
+ total_pages = doc_zh.page_count
+
+ parser = PDFParser(inf)
+ doc = PDFDocument(parser)
+ with tqdm.tqdm(total=total_pages) as progress:
+ for pageno, page in enumerate(PDFPage.create_pages(doc)):
+ if cancellation_event and cancellation_event.is_set():
+ raise CancelledError("task cancelled")
+ if pages and (pageno not in pages):
+ continue
+ progress.update()
+ if callback:
+ callback(progress)
+ page.pageno = pageno
+ pix = doc_zh[page.pageno].get_pixmap()
+ image = np.frombuffer(pix.samples, np.uint8).reshape(
+ pix.height, pix.width, 3
+ )[:, :, ::-1]
+ page_layout = model.predict(image, imgsz=int(pix.height / 32) * 32)[0]
+ # kdtree 是不可能 kdtree 的,不如直接渲染成图片,用空间换时间
+ box = np.ones((pix.height, pix.width))
+ h, w = box.shape
+ vcls = ["abandon", "figure", "table", "isolate_formula", "formula_caption"]
+ for i, d in enumerate(page_layout.boxes):
+ if page_layout.names[int(d.cls)] not in vcls:
+ x0, y0, x1, y1 = d.xyxy.squeeze()
+ x0, y0, x1, y1 = (
+ np.clip(int(x0 - 1), 0, w - 1),
+ np.clip(int(h - y1 - 1), 0, h - 1),
+ np.clip(int(x1 + 1), 0, w - 1),
+ np.clip(int(h - y0 + 1), 0, h - 1),
+ )
+ box[y0:y1, x0:x1] = i + 2
+ for i, d in enumerate(page_layout.boxes):
+ if page_layout.names[int(d.cls)] in vcls:
+ x0, y0, x1, y1 = d.xyxy.squeeze()
+ x0, y0, x1, y1 = (
+ np.clip(int(x0 - 1), 0, w - 1),
+ np.clip(int(h - y1 - 1), 0, h - 1),
+ np.clip(int(x1 + 1), 0, w - 1),
+ np.clip(int(h - y0 + 1), 0, h - 1),
+ )
+ box[y0:y1, x0:x1] = 0
+ layout[page.pageno] = box
+ # 新建一个 xref 存放新指令流
+ page.page_xref = doc_zh.get_new_xref() # hack 插入页面的新 xref
+ doc_zh.update_object(page.page_xref, "<<>>")
+ doc_zh.update_stream(page.page_xref, b"")
+ doc_zh[page.pageno].set_contents(page.page_xref)
+ interpreter.process_page(page)
+
+ device.close()
+ return obj_patch
+
+
+def translate_stream(
+ stream: bytes,
+ pages: Optional[list[int]] = None,
+ lang_in: str = "",
+ lang_out: str = "",
+ service: str = "",
+ thread: int = 0,
+ vfont: str = "",
+ vchar: str = "",
+ callback: object = None,
+ cancellation_event: asyncio.Event = None,
+ model: OnnxModel = None,
+ envs: Dict = None,
+ prompt: Template = None,
+ skip_subset_fonts: bool = False,
+ ignore_cache: bool = False,
+ **kwarg: Any,
+):
+ font_list = [("tiro", None)]
+
+ font_path = download_remote_fonts(lang_out.lower())
+ noto_name = NOTO_NAME
+ noto = Font(noto_name, font_path)
+ font_list.append((noto_name, font_path))
+
+ doc_en = Document(stream=stream)
+ stream = io.BytesIO()
+ doc_en.save(stream)
+ doc_zh = Document(stream=stream)
+ page_count = doc_zh.page_count
+ # font_list = [("GoNotoKurrent-Regular.ttf", font_path), ("tiro", None)]
+ font_id = {}
+ for page in doc_zh:
+ for font in font_list:
+ font_id[font[0]] = page.insert_font(font[0], font[1])
+ xreflen = doc_zh.xref_length()
+ for xref in range(1, xreflen):
+ for label in ["Resources/", ""]: # 可能是基于 xobj 的 res
+ try: # xref 读写可能出错
+ font_res = doc_zh.xref_get_key(xref, f"{label}Font")
+ target_key_prefix = f"{label}Font/"
+ if font_res[0] == "xref":
+ resource_xref_id = re.search("(\\d+) 0 R", font_res[1]).group(1)
+ xref = int(resource_xref_id)
+ font_res = ("dict", doc_zh.xref_object(xref))
+ target_key_prefix = ""
+
+ if font_res[0] == "dict":
+ for font in font_list:
+ target_key = f"{target_key_prefix}{font[0]}"
+ font_exist = doc_zh.xref_get_key(xref, target_key)
+ if font_exist[0] == "null":
+ doc_zh.xref_set_key(
+ xref,
+ target_key,
+ f"{font_id[font[0]]} 0 R",
+ )
+ except Exception:
+ pass
+
+ fp = io.BytesIO()
+
+ doc_zh.save(fp)
+ obj_patch: dict = translate_patch(fp, **locals())
+
+ for obj_id, ops_new in obj_patch.items():
+ # ops_old=doc_en.xref_stream(obj_id)
+ # print(obj_id)
+ # print(ops_old)
+ # print(ops_new.encode())
+ doc_zh.update_stream(obj_id, ops_new.encode())
+
+ doc_en.insert_file(doc_zh)
+ for id in range(page_count):
+ doc_en.move_page(page_count + id, id * 2 + 1)
+ if not skip_subset_fonts:
+ doc_zh.subset_fonts(fallback=True)
+ doc_en.subset_fonts(fallback=True)
+ return (
+ doc_zh.write(deflate=True, garbage=3, use_objstms=1),
+ doc_en.write(deflate=True, garbage=3, use_objstms=1),
+ )
+
+
+def convert_to_pdfa(input_path, output_path):
+ """
+ Convert PDF to PDF/A format
+
+ Args:
+ input_path: Path to source PDF file
+ output_path: Path to save PDF/A file
+ """
+ from pikepdf import Dictionary, Name, Pdf
+
+ # Open the PDF file
+ pdf = Pdf.open(input_path)
+
+ # Add PDF/A conformance metadata
+ metadata = {
+ "pdfa_part": "2",
+ "pdfa_conformance": "B",
+ "title": pdf.docinfo.get("/Title", ""),
+ "author": pdf.docinfo.get("/Author", ""),
+ "creator": "PDF Math Translate",
+ }
+
+ with pdf.open_metadata() as meta:
+ meta.load_from_docinfo(pdf.docinfo)
+ meta["pdfaid:part"] = metadata["pdfa_part"]
+ meta["pdfaid:conformance"] = metadata["pdfa_conformance"]
+
+ # Create OutputIntent dictionary
+ output_intent = Dictionary(
+ {
+ "/Type": Name("/OutputIntent"),
+ "/S": Name("/GTS_PDFA1"),
+ "/OutputConditionIdentifier": "sRGB IEC61966-2.1",
+ "/RegistryName": "http://www.color.org",
+ "/Info": "sRGB IEC61966-2.1",
+ }
+ )
+
+ # Add output intent to PDF root
+ if "/OutputIntents" not in pdf.Root:
+ pdf.Root.OutputIntents = [output_intent]
+ else:
+ pdf.Root.OutputIntents.append(output_intent)
+
+ # Save as PDF/A
+ pdf.save(output_path, linearize=True)
+ pdf.close()
+
+
+def translate(
+ files: list[str],
+ output: str = "",
+ pages: Optional[list[int]] = None,
+ lang_in: str = "",
+ lang_out: str = "",
+ service: str = "",
+ thread: int = 0,
+ vfont: str = "",
+ vchar: str = "",
+ callback: object = None,
+ compatible: bool = False,
+ cancellation_event: asyncio.Event = None,
+ model: OnnxModel = None,
+ envs: Dict = None,
+ prompt: Template = None,
+ skip_subset_fonts: bool = False,
+ ignore_cache: bool = False,
+ **kwarg: Any,
+):
+ if not files:
+ raise PDFValueError("No files to process.")
+
+ missing_files = check_files(files)
+
+ if missing_files:
+ print("The following files do not exist:", file=sys.stderr)
+ for file in missing_files:
+ print(f" {file}", file=sys.stderr)
+ raise PDFValueError("Some files do not exist.")
+
+ result_files = []
+
+ for file in files:
+ if type(file) is str and (
+ file.startswith("http://") or file.startswith("https://")
+ ):
+ print("Online files detected, downloading...")
+ try:
+ r = requests.get(file, allow_redirects=True)
+ if r.status_code == 200:
+ with tempfile.NamedTemporaryFile(
+ suffix=".pdf", delete=False
+ ) as tmp_file:
+ print(f"Writing the file: {file}...")
+ tmp_file.write(r.content)
+ file = tmp_file.name
+ else:
+ r.raise_for_status()
+ except Exception as e:
+ raise PDFValueError(
+ f"Errors occur in downloading the PDF file. Please check the link(s).\nError:\n{e}"
+ )
+ filename = os.path.splitext(os.path.basename(file))[0]
+
+ # Stage A: Check if PDF is scanned and route to scanned pipeline
+ # NOTE: Stages B, C, D will be wired in subsequent sprints
+ try:
+ detector = PDFTypeDetector()
+ pdf_type = detector.detect(file)
+ if pdf_type == "scanned":
+ logger.info(f"Detected scanned PDF: {file}, using Stage A parser")
+ parser = StageAParser(device="auto")
+ output_dir = Path(output) if output else Path(file).parent
+ cache_path = output_dir / f"{filename}_stage_a.json"
+ parsed_doc = parser.parse_pdf(file, pages=pages)
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
+ parsed_doc.save(cache_path)
+ logger.info(f"Stage A complete: {len(parsed_doc.pages)} pages parsed")
+ # For now, return the cache path as placeholder
+ # Full translation pipeline (Stages B, C, D) will be added later
+ result_files.append((str(cache_path), str(cache_path)))
+ continue
+ except Exception as e:
+ logger.warning(
+ f"Scanned PDF detection failed, falling back to digital pipeline: {e}"
+ )
+
+ # If the commandline has specified converting to PDF/A format
+ # --compatible / -cp
+ if compatible:
+ with tempfile.NamedTemporaryFile(
+ suffix="-pdfa.pdf", delete=False
+ ) as tmp_pdfa:
+ print(f"Converting {file} to PDF/A format...")
+ convert_to_pdfa(file, tmp_pdfa.name)
+ doc_raw = open(tmp_pdfa.name, "rb")
+ os.unlink(tmp_pdfa.name)
+ else:
+ doc_raw = open(file, "rb")
+ s_raw = doc_raw.read()
+ doc_raw.close()
+
+ temp_dir = Path(tempfile.gettempdir())
+ file_path = Path(file)
+ try:
+ if file_path.exists() and file_path.resolve().is_relative_to(
+ temp_dir.resolve()
+ ):
+ file_path.unlink(missing_ok=True)
+ logger.debug(f"Cleaned temp file: {file_path}")
+ except Exception:
+ logger.warning(f"Failed to clean temp file {file_path}", exc_info=True)
+
+ s_mono, s_dual = translate_stream(
+ s_raw,
+ **locals(),
+ )
+ file_mono = Path(output) / f"{filename}-mono.pdf"
+ file_dual = Path(output) / f"{filename}-dual.pdf"
+ doc_mono = open(file_mono, "wb")
+ doc_dual = open(file_dual, "wb")
+ doc_mono.write(s_mono)
+ doc_dual.write(s_dual)
+ doc_mono.close()
+ doc_dual.close()
+ result_files.append((str(file_mono), str(file_dual)))
+
+ return result_files
+
+
+def download_remote_fonts(lang: str):
+ lang = lang.lower()
+ LANG_NAME_MAP = {
+ **{la: "GoNotoKurrent-Regular.ttf" for la in noto_list},
+ **{
+ la: f"SourceHanSerif{region}-Regular.ttf"
+ for region, langs in {
+ "CN": ["zh-cn", "zh-hans", "zh"],
+ "TW": ["zh-tw", "zh-hant"],
+ "JP": ["ja"],
+ "KR": ["ko"],
+ }.items()
+ for la in langs
+ },
+ }
+ font_name = LANG_NAME_MAP.get(lang, "GoNotoKurrent-Regular.ttf")
+
+ # docker
+ font_path = ConfigManager.get("NOTO_FONT_PATH", Path("/app", font_name).as_posix())
+ if not Path(font_path).exists():
+ font_path, _ = get_font_and_metadata(font_name)
+ font_path = font_path.as_posix()
+
+ logger.info(f"use font: {font_path}")
+
+ return font_path
diff --git a/pdf2zh/json_translator.py b/pdf2zh/json_translator.py
new file mode 100644
index 0000000000000000000000000000000000000000..fcf4bc496d95ba1937bf4d0457b782adf202fac0
--- /dev/null
+++ b/pdf2zh/json_translator.py
@@ -0,0 +1,34 @@
+"""
+How to run:
+ python -m pdf2zh.json_translator input.json --api-key $KEY
+"""
+
+import logging
+import time
+
+from pdf2zh.translation import ( # noqa: F401
+ PROVIDERS,
+ Gateway,
+ RateLimiter,
+ Task,
+ TranslatorConfig,
+ build_glossary_prompt,
+ build_translation_prompt,
+ collect_translatables,
+ extract_glossary,
+ glossary_block_for_chunk,
+ is_equation_only,
+ is_plain_text,
+ resolve_provider,
+ segments_to_chunks,
+ translate_chunks,
+ translate_document,
+)
+from pdf2zh.translation.cli import main # noqa: F401
+
+logger = logging.getLogger("json_translator")
+
+if __name__ == "__main__":
+ t0 = time.perf_counter()
+ main()
+ logger.info(f"Total time: {time.perf_counter() - t0:.1f}s")
diff --git a/pdf2zh/mcp_server.py b/pdf2zh/mcp_server.py
new file mode 100644
index 0000000000000000000000000000000000000000..4752262cc834bb0b2cc4c5d9fd484f254290a848
--- /dev/null
+++ b/pdf2zh/mcp_server.py
@@ -0,0 +1,109 @@
+import contextlib
+import io
+import os
+from pathlib import Path
+
+from mcp.server import Server
+from mcp.server.fastmcp import Context, FastMCP
+from mcp.server.sse import SseServerTransport
+from starlette.applications import Starlette
+from starlette.requests import Request
+from starlette.routing import Mount, Route
+
+from pdf2zh import translate_stream
+from pdf2zh.doclayout import ModelInstance
+
+
+def create_mcp_app() -> FastMCP:
+ mcp = FastMCP("pdf2zh")
+
+ @mcp.tool()
+ async def translate_pdf(
+ file: str, lang_in: str, lang_out: str, ctx: Context
+ ) -> str:
+ """
+ translate given pdf. Argument `file` is absolute path of input pdf,
+ `lang_in` and `lang_out` is translate from and to language, and
+ should be like google translate lang_code. `lang_in` can be `auto`
+ if you can't determine input language.
+ """
+
+ with open(file, "rb") as f:
+ file_bytes = f.read()
+ await ctx.log(level="info", message=f"start translate {file}")
+ with contextlib.redirect_stdout(io.StringIO()):
+ doc_mono_bytes, doc_dual_bytes = translate_stream(
+ file_bytes,
+ lang_in=lang_in,
+ lang_out=lang_out,
+ service="google",
+ model=ModelInstance.value,
+ thread=4,
+ )
+ await ctx.log(level="info", message="translate complete")
+ output_path = Path(os.path.dirname(file))
+ filename = os.path.splitext(os.path.basename(file))[0]
+ doc_mono = output_path / f"{filename}-mono.pdf"
+ doc_dual = output_path / f"{filename}-dual.pdf"
+ with open(doc_mono, "wb") as f:
+ f.write(doc_mono_bytes)
+ with open(doc_dual, "wb") as f:
+ f.write(doc_dual_bytes)
+ return f"""------------
+ translate complete
+ mono pdf file: {doc_mono.absolute()}
+ dual pdf file: {doc_dual.absolute()}
+ """
+
+ return mcp
+
+
+def create_starlette_app(mcp_server: Server, *, debug: bool = False) -> Starlette:
+ sse = SseServerTransport("/messages/")
+
+ async def handle_sse(request: Request) -> None:
+ async with sse.connect_sse(request.scope, request.receive, request._send) as (
+ read_stream,
+ write_stream,
+ ):
+ await mcp_server.run(
+ read_stream, write_stream, mcp_server.create_initialization_options()
+ )
+
+ return Starlette(
+ debug=debug,
+ routes=[
+ Route("/sse", endpoint=handle_sse),
+ Mount("/messages/", app=sse.handle_post_message),
+ ],
+ )
+
+
+if __name__ == "__main__":
+ import argparse
+
+ mcp = create_mcp_app()
+ mcp_server = mcp._mcp_server
+ parser = argparse.ArgumentParser(description="Run MCP SSE-based PDF2ZH server")
+
+ parser.add_argument(
+ "--sse",
+ default=False,
+ action="store_true",
+ help="Run the server with SSE transport or STDIO",
+ )
+ parser.add_argument(
+ "--host", type=str, default="127.0.0.1", required=False, help="Host to bind"
+ )
+ parser.add_argument(
+ "--port", type=int, default=3001, required=False, help="Port to bind"
+ )
+
+ args = parser.parse_args()
+ if args.sse and args.host and args.port:
+ import uvicorn
+
+ starlette_app = create_starlette_app(mcp_server, debug=True)
+ uvicorn.run(starlette_app, host=args.host, port=args.port)
+ else:
+ mcp.run()
diff --git a/pdf2zh/parser/__init__.py b/pdf2zh/parser/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..b6a6986d3a0c6ebe76a6c1b5c95c18d0678c389b
--- /dev/null
+++ b/pdf2zh/parser/__init__.py
@@ -0,0 +1,19 @@
+"""Scanned PDF translation pipeline - Stage A.
+
+This package provides parsing and analysis for scanned (image-based) PDFs
+using Surya for layout detection and OCR.
+
+Main exports:
+- PDFTypeDetector: Detect if PDF is scanned, digital, or mixed
+- StageAParser and phase result objects: Main parser for Stage A processing
+"""
+
+from pdf2zh.parser.detector import PDFTypeDetector
+from pdf2zh.parser.main import StageAParser
+
+__all__ = [
+ # Detector
+ "PDFTypeDetector",
+ # Parser
+ "StageAParser",
+]
diff --git a/pdf2zh/parser/ai_models/__init__.py b/pdf2zh/parser/ai_models/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..36739a41da9c78bb35ecbf06c2faf7740b128bd3
--- /dev/null
+++ b/pdf2zh/parser/ai_models/__init__.py
@@ -0,0 +1,13 @@
+"""AI model wrappers for Stage A parsing."""
+
+from .base import BaseImageToTextModel
+from .layout import SuryaLayoutModel
+from .ocr import SuryaOCRModel
+from .table import PaddleCellTableModule
+
+__all__ = [
+ "BaseImageToTextModel",
+ "SuryaLayoutModel",
+ "SuryaOCRModel",
+ "PaddleCellTableModule",
+]
diff --git a/pdf2zh/parser/ai_models/base.py b/pdf2zh/parser/ai_models/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..d9cd40bf6b5fab03a86919fa8c9a3892dff95b3f
--- /dev/null
+++ b/pdf2zh/parser/ai_models/base.py
@@ -0,0 +1,85 @@
+"""Base class for all AI models."""
+
+from __future__ import annotations
+
+import logging
+from abc import ABC, abstractmethod
+from typing import Any
+
+from PIL import Image
+
+logger = logging.getLogger(__name__)
+
+
+class BaseImageToTextModel(ABC):
+ """
+ Shared interface for AI models with Lazy Loading support.
+ Pipeline: Call -> [Load Model] -> Prepare -> Predict -> Postprocess.
+ """
+
+ def __init__(self) -> None:
+ """Chỉ khai báo các thuộc tính, KHÔNG tải weights vào VRAM ở đây."""
+ self.model: Any = None
+ self.device: Any = None
+
+ @abstractmethod
+ def load_model(self) -> None:
+ """
+ Khởi tạo model và đẩy vào VRAM.
+ Các class con BẮT BUỘC phải override hàm này.
+ """
+ pass
+
+ def unload_model(self) -> None:
+ """
+ Unload model from VRAM
+ """
+ if self.model is not None:
+ import torch
+
+ logger.info("Unloading model from VRAM to free memory...")
+ del self.model
+ self.model = None
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+
+ @abstractmethod
+ def prepare(self, images: list[Image.Image], *args: Any, **kwargs: Any) -> Any:
+ """Preprocess raw images."""
+ pass
+
+ @abstractmethod
+ def predict(self, *args: Any, **kwargs: Any) -> Any:
+ """Run core inference. (Model chắc chắn đã được load khi hàm này chạy)."""
+ pass
+
+ @abstractmethod
+ def postprocess(self, *args: Any, **kwargs: Any) -> Any:
+ """Format raw model outputs."""
+ pass
+
+ def __call__(
+ self,
+ images: list[Image.Image],
+ auto_unload: bool = False,
+ *args: Any,
+ **kwargs: Any,
+ ) -> Any:
+ """
+ Hàm trung tâm điều phối toàn bộ Pipeline (Template Method).
+ """
+ if self.model is None:
+ self.load_model()
+
+ try:
+ prepared_inputs = self.prepare(images, *args, **kwargs)
+
+ raw_outputs = self.predict(prepared_inputs, *args, **kwargs)
+
+ final_results = self.postprocess(raw_outputs, *args, **kwargs)
+
+ return final_results
+ finally:
+ # 5. Giải phóng VRAM ngay lập tức nếu auto_unload = True
+ if auto_unload:
+ self.unload_model()
diff --git a/pdf2zh/parser/ai_models/layout.py b/pdf2zh/parser/ai_models/layout.py
new file mode 100644
index 0000000000000000000000000000000000000000..3bb349d8c39cd60ee28bc492f8e40999df0b70c8
--- /dev/null
+++ b/pdf2zh/parser/ai_models/layout.py
@@ -0,0 +1,54 @@
+"""Layout model: page layout detection."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from PIL import Image
+
+from pdf2zh.parser.ai_models.base import BaseImageToTextModel
+
+logger = logging.getLogger(__name__)
+
+
+class SuryaLayoutModel(BaseImageToTextModel):
+ model_name = "SuryaLayout"
+
+ def __init__(self) -> None:
+ super().__init__()
+
+ def load_model(self) -> None:
+ logger.info("Initializing %s into VRAM...", self.model_name)
+ from surya.foundation import FoundationPredictor
+ from surya.layout import LayoutPredictor
+ from surya.settings import settings
+
+ # 1. Load foundation specifically for layout
+ self.layout_foundation_predictor = FoundationPredictor(
+ checkpoint=settings.LAYOUT_MODEL_CHECKPOINT,
+ )
+ logger.info("Loaded FoundationPredictor (layout backbone)")
+
+ # 2. Load layout predictor
+ self.model = LayoutPredictor(self.layout_foundation_predictor)
+ logger.info("Loaded LayoutPredictor successfully.")
+
+ def prepare(
+ self, images: list[Image.Image], *args: Any, **kwargs: Any
+ ) -> list[Image.Image]:
+ return images
+
+ def predict(
+ self,
+ prepared_inputs: list[Image.Image],
+ batch_size: int | None = None,
+ *args: Any,
+ **kwargs: Any,
+ ) -> list[Any]:
+ return self.model(prepared_inputs, batch_size=batch_size)
+
+ def postprocess(
+ self, raw_results: list[Any], *args: Any, **kwargs: Any
+ ) -> list[Any]:
+ return raw_results
diff --git a/pdf2zh/parser/ai_models/ocr.py b/pdf2zh/parser/ai_models/ocr.py
new file mode 100644
index 0000000000000000000000000000000000000000..56d9eebfb7d370f3ef89f614dbc1c05368e2b8e3
--- /dev/null
+++ b/pdf2zh/parser/ai_models/ocr.py
@@ -0,0 +1,144 @@
+"""OCR model: text detection + recognition"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Optional, Tuple
+
+from PIL import Image
+
+from pdf2zh.parser.ai_models.base import BaseImageToTextModel
+
+logger = logging.getLogger(__name__)
+
+
+class SuryaOCRModel(BaseImageToTextModel):
+ """
+ Wraps Surya's DetectionPredictor + RecognitionPredictor.
+ Models are loaded lazily upon first inference call.
+ """
+
+ model_name = "SuryaOCR"
+
+ def __init__(
+ self,
+ detector_blank_threshold: Optional[float] = None,
+ detector_text_threshold: Optional[float] = None,
+ ) -> None:
+ super().__init__()
+ self.detector_blank_threshold = detector_blank_threshold
+ self.detector_text_threshold = detector_text_threshold
+ self.foundation_predictor: Any = None
+ self.detection_predictor: Any = None
+ self.recognition_predictor: Any = None
+
+ def load_model(self) -> None:
+ logger.info(
+ "Initializing %s and loading models into memory...", self.model_name
+ )
+
+ from surya.detection import DetectionPredictor
+ from surya.foundation import FoundationPredictor
+ from surya.recognition import RecognitionPredictor
+ from surya.settings import settings
+
+ if self.detector_text_threshold is not None:
+ settings.DETECTOR_TEXT_THRESHOLD = self.detector_text_threshold
+
+ if self.detector_blank_threshold is not None:
+ settings.DETECTOR_BLANK_THRESHOLD = self.detector_blank_threshold
+
+ self.foundation_predictor = FoundationPredictor()
+ logger.info("Loaded FoundationPredictor (OCR backbone)")
+
+ self.detection_predictor = DetectionPredictor()
+ logger.info("Loaded DetectionPredictor")
+
+ self.recognition_predictor = RecognitionPredictor(self.foundation_predictor)
+ logger.info("Loaded RecognitionPredictor")
+
+ self.model = self.recognition_predictor
+
+ def unload_model(self) -> None:
+ if self.model is not None:
+ import torch
+
+ logger.info("Unloading all %s predictors from VRAM...", self.model_name)
+
+ del self.foundation_predictor
+ del self.detection_predictor
+ del self.recognition_predictor
+ del self.model
+
+ self.foundation_predictor = None
+ self.detection_predictor = None
+ self.recognition_predictor = None
+ self.model = None
+
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+
+ def prepare(
+ self,
+ images: list[Image.Image],
+ highres_images: list[Image.Image] | None = None,
+ *args: Any,
+ **kwargs: Any,
+ ) -> Tuple[list[Image.Image], list[Image.Image] | None]:
+ """
+ Preprocess raw images before inference.
+ """
+ return images, highres_images
+
+ def predict(
+ self,
+ prepared_inputs: Tuple[list[Image.Image], list[Image.Image] | None],
+ *args: Any,
+ math_mode: bool = False,
+ task_names: list[Any] | None = None,
+ bboxes: list[Any] | None = None,
+ detection_batch_size: int | None = None,
+ ocr_batch_size: int | None = None,
+ **kwargs: Any,
+ ) -> list[Any]:
+ """
+ Run full-page OCR (detection -> recognition) on prepared images.
+ """
+ images, highres_images = prepared_inputs
+
+ run_kwargs: dict[str, Any] = {"math_mode": True, "return_words": False}
+
+ if not math_mode:
+ logger.info("Running OCR with detection + recognition")
+ run_kwargs.update(
+ {
+ "det_predictor": self.detection_predictor,
+ "detection_batch_size": detection_batch_size,
+ "recognition_batch_size": ocr_batch_size,
+ "highres_images": highres_images,
+ }
+ )
+ else:
+ logger.info("Running OCR in math mode (LaTeX recognition)")
+ run_kwargs.update(
+ {
+ "recognition_batch_size": ocr_batch_size,
+ }
+ )
+
+ if task_names is not None:
+ run_kwargs["task_names"] = task_names
+ if bboxes is not None:
+ run_kwargs["bboxes"] = bboxes
+
+ raw_results = self.recognition_predictor(images, **run_kwargs)
+
+ return raw_results
+
+ def postprocess(
+ self, raw_results: list[Any], *args: Any, **kwargs: Any
+ ) -> list[Any]:
+ """
+ Format raw Surya outputs into the final desired structure.
+ """
+ return raw_results
diff --git a/pdf2zh/parser/ai_models/table.py b/pdf2zh/parser/ai_models/table.py
new file mode 100644
index 0000000000000000000000000000000000000000..310fd5772babb974d9ffe0d63d5aed773a4c2a0a
--- /dev/null
+++ b/pdf2zh/parser/ai_models/table.py
@@ -0,0 +1,217 @@
+"""Table models: table structure and cell recognition."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+import numpy as np
+from PIL import Image
+
+from pdf2zh.parser.ai_models.base import BaseImageToTextModel
+from pdf2zh.parser.utils.bbox import bbox_area, bbox_intersection
+
+logger = logging.getLogger(__name__)
+
+
+class SuryaTableModel(BaseImageToTextModel):
+ """
+ Wraps Surya's TableRecPredictor.
+
+ Identifies row/column structure and cell bounding boxes within a cropped
+ table image. Text extraction is handled separately.
+ Models are loaded lazily upon first inference call.
+ """
+
+ model_name = "SuryaTable"
+
+ def __init__(self) -> None:
+ """Initialize empty state to defer model loading."""
+ super().__init__()
+
+ def load_model(self) -> None:
+ """Load Surya model into VRAM."""
+ logger.info("Initializing %s...", self.model_name)
+
+ from surya.table_rec import TableRecPredictor
+
+ self.model = TableRecPredictor()
+ logger.info("Loaded TableRecPredictor")
+
+ def prepare(
+ self, images: list[Image.Image], *args: Any, **kwargs: Any
+ ) -> list[Image.Image]:
+ """Preprocess a batch of cropped table images."""
+ # Surya models accept raw PIL images directly
+ return images
+
+ def predict(
+ self,
+ prepared_inputs: list[Image.Image],
+ batch_size: int | None = None,
+ *args: Any,
+ **kwargs: Any,
+ ) -> list[Any]:
+ """
+ Recognize table structure for a batch of prepared table images.
+ """
+ try:
+ # self.model is guaranteed to be loaded by the Base class
+ raw_results = self.model(
+ prepared_inputs,
+ batch_size=batch_size,
+ )
+ return raw_results
+ except Exception:
+ logger.exception(
+ "Table recognition failed for batch of %d crops — returning nulls.",
+ len(prepared_inputs),
+ )
+ return [None] * len(prepared_inputs)
+
+ def postprocess(
+ self, raw_results: list[Any], *args: Any, **kwargs: Any
+ ) -> list[list[list[float]]]:
+ """Convert objects into a simple list of bounding boxes."""
+ batch_boxes = []
+ for result in raw_results:
+ if result is None:
+ batch_boxes.append([])
+ continue
+
+ # Extract only bboxes and ensure float type
+ boxes = [
+ [float(x) for x in cell.bbox] for cell in getattr(result, "cells", [])
+ ]
+ batch_boxes.append(boxes)
+ return batch_boxes
+
+
+class PaddleCellTableModule(BaseImageToTextModel):
+ """
+ Wraps Paddle's Table Cell Detection Module.
+ Models are loaded lazily upon first inference call.
+ """
+
+ model_name = "PaddleCellTableModule"
+
+ def __init__(self) -> None:
+ """Initialize empty state to defer model loading."""
+ super().__init__()
+
+ def load_model(self) -> None:
+ """Load Paddle model into memory/VRAM."""
+ logger.info("Initializing %s...", self.model_name)
+
+ from paddleocr import TableCellsDetection
+
+ self.model = TableCellsDetection(model_name="RT-DETR-L_wireless_table_cell_det")
+ logger.info("Loaded TableCellsDetection")
+
+ def prepare(
+ self, images: list[Image.Image], *args: Any, **kwargs: Any
+ ) -> list[np.ndarray]:
+ """
+ Convert PIL images to numpy arrays to satisfy PaddleOCR requirements.
+ """
+ return [np.array(img.convert("RGB")) for img in images]
+
+ def predict(
+ self,
+ prepared_inputs: list[np.ndarray],
+ batch_size: int | None = None,
+ threshold: float = 0.3,
+ *args: Any,
+ **kwargs: Any,
+ ) -> list[Any]:
+ """
+ Recognize cell detection for a batch of prepared table images.
+ """
+ try:
+ raw_results = self.model.predict(
+ prepared_inputs,
+ threshold=threshold,
+ batch_size=batch_size,
+ )
+ return raw_results
+ except Exception:
+ logger.exception(
+ "Paddle table cell detection failed for batch of %d crops — returning nulls.",
+ len(prepared_inputs),
+ )
+ return [None] * len(prepared_inputs)
+
+ def postprocess(
+ self, raw_results: list[Any], *args: Any, **kwargs: Any
+ ) -> list[list[list[float]]]:
+ """Normalize Paddle output into simple bbox lists."""
+ batch_boxes = []
+ for result in raw_results:
+ if result is None:
+ batch_boxes.append([])
+ continue
+
+ # Check both 'boxes' and 'coordinate' attributes
+ raw_cells = result.get("boxes", [])
+
+ boxes = []
+ for cell in raw_cells:
+ coords = cell.get("coordinate")
+ if coords:
+ boxes.append([float(x) for x in coords])
+
+ batch_boxes.append(self._prune_nested_cell_boxes(boxes))
+ return batch_boxes
+
+ def _prune_nested_cell_boxes(
+ self,
+ boxes: list[list[float]],
+ containment_threshold: float = 0.8,
+ ) -> list[list[float]]:
+ if len(boxes) < 2:
+ return boxes
+
+ kept_boxes: list[list[float]] = []
+ sorted_boxes = sorted(boxes, key=bbox_area)
+
+ for box in sorted_boxes:
+ box_area = max(1.0, bbox_area(box))
+ is_duplicate = False
+ for kept in kept_boxes:
+ intersection = bbox_intersection(box, kept)
+ if intersection is None:
+ continue
+
+ overlap_ratio = bbox_area(intersection) / box_area
+ if overlap_ratio >= containment_threshold:
+ is_duplicate = True
+ break
+
+ if not is_duplicate:
+ kept_boxes.append(box)
+
+ filtered_boxes: list[list[float]] = []
+ for box in kept_boxes:
+ box_area = max(1.0, bbox_area(box))
+ contains_smaller_box = False
+ for other in kept_boxes:
+ if other is box:
+ continue
+
+ other_area = bbox_area(other)
+ if other_area >= box_area:
+ continue
+
+ intersection = bbox_intersection(box, other)
+ if intersection is None:
+ continue
+
+ overlap_ratio = bbox_area(intersection) / max(1.0, other_area)
+ if overlap_ratio >= containment_threshold:
+ contains_smaller_box = True
+ break
+
+ if not contains_smaller_box:
+ filtered_boxes.append(box)
+
+ return filtered_boxes
diff --git a/pdf2zh/parser/detector.py b/pdf2zh/parser/detector.py
new file mode 100644
index 0000000000000000000000000000000000000000..fa88ff045e690b49ee3aac2d250d5f792467dfe4
--- /dev/null
+++ b/pdf2zh/parser/detector.py
@@ -0,0 +1,233 @@
+"""PDF type detection for routing to appropriate pipeline.
+
+This module provides PDFTypeDetector to classify PDFs as:
+- "scanned": Image-based PDFs requiring OCR
+- "digital": Text-based PDFs with extractable text
+- "mixed": PDFs with both scanned and digital pages
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Literal
+
+import fitz # PyMuPDF
+
+logger = logging.getLogger(__name__)
+
+PDFType = Literal["scanned", "digital", "mixed"]
+
+
+class PDFTypeDetector:
+ """Detect whether a PDF is scanned, digital, or mixed.
+
+ Detection is based on analyzing text extraction vs image coverage
+ on a sample of pages.
+
+ Attributes:
+ text_threshold: Minimum characters per page to consider it digital
+ image_coverage_threshold: Minimum image area ratio to consider scanned
+ sample_pages: Maximum pages to sample for detection
+ """
+
+ def __init__(
+ self,
+ text_threshold: int = 100,
+ image_coverage_threshold: float = 0.5,
+ sample_pages: int = 5,
+ text_block_threshold: int = 3,
+ ) -> None:
+ """Initialize detector with thresholds.
+
+ Args:
+ text_threshold: Min chars per page for digital classification
+ image_coverage_threshold: Min image/page area ratio for scanned
+ sample_pages: Max pages to analyze (evenly sampled)
+ text_block_threshold: Min text blocks for digital fallback when
+ font encoding fails (e.g. font.unknown PDFs)
+ """
+ self.text_threshold = text_threshold
+ self.image_coverage_threshold = image_coverage_threshold
+ self.sample_pages = sample_pages
+ self.text_block_threshold = text_block_threshold
+
+ def detect(self, pdf_path: str | Path) -> PDFType:
+ """Detect PDF type.
+
+ Args:
+ pdf_path: Path to PDF file
+
+ Returns:
+ "scanned", "digital", or "mixed"
+
+ Raises:
+ FileNotFoundError: If PDF doesn't exist
+ fitz.FileDataError: If file is not a valid PDF
+ """
+ pdf_path = Path(pdf_path)
+ if not pdf_path.exists():
+ raise FileNotFoundError(f"PDF not found: {pdf_path}")
+
+ doc = fitz.open(pdf_path)
+ try:
+ return self._analyze_document(doc)
+ finally:
+ doc.close()
+
+ def detect_from_bytes(self, pdf_bytes: bytes) -> PDFType:
+ """Detect PDF type from bytes.
+
+ Args:
+ pdf_bytes: PDF file contents as bytes
+
+ Returns:
+ "scanned", "digital", or "mixed"
+ """
+ doc = fitz.open(stream=pdf_bytes, filetype="pdf")
+ try:
+ return self._analyze_document(doc)
+ finally:
+ doc.close()
+
+ def _analyze_document(self, doc: fitz.Document) -> PDFType:
+ """Sample pages from the document and classify the overall PDF type.
+
+ Pages are sampled evenly up to ``self.sample_pages``. Each sampled
+ page is classified independently by :meth:`_analyze_page`. The
+ overall type is determined by majority vote with thresholds:
+
+ - 100 % scanned → ``"scanned"``
+ - 100 % digital → ``"digital"``
+ - ≥ 80 % scanned → ``"scanned"``
+ - ≤ 20 % scanned → ``"digital"``
+ - otherwise → ``"mixed"``
+
+ Args:
+ doc: Open fitz Document to analyse.
+
+ Returns:
+ ``"scanned"``, ``"digital"``, or ``"mixed"``.
+ """
+ page_count = len(doc)
+ if page_count == 0:
+ logger.warning("Empty PDF, defaulting to digital")
+ return "digital"
+
+ # Sample pages evenly
+ if page_count <= self.sample_pages:
+ sample_indices = list(range(page_count))
+ else:
+ step = page_count / self.sample_pages
+ sample_indices = [int(i * step) for i in range(self.sample_pages)]
+
+ scanned_count = 0
+ digital_count = 0
+
+ for page_idx in sample_indices:
+ page = doc[page_idx]
+ page_type = self._analyze_page(page)
+
+ if page_type == "scanned":
+ scanned_count += 1
+ else:
+ digital_count += 1
+
+ # Classify based on majority
+ total_sampled = len(sample_indices)
+
+ if scanned_count == total_sampled:
+ return "scanned"
+ elif digital_count == total_sampled:
+ return "digital"
+ else:
+ # Mixed detection
+ scanned_ratio = scanned_count / total_sampled
+ if scanned_ratio >= 0.8:
+ return "scanned"
+ elif scanned_ratio <= 0.2:
+ return "digital"
+ else:
+ return "mixed"
+
+ def _analyze_page(self, page: fitz.Page) -> Literal["scanned", "digital"]:
+ """Classify a single page as scanned or digital.
+
+ The classification uses a three-tier heuristic:
+
+ 1. **Raw text length** — if extracted text has ≥ ``text_threshold``
+ characters, the page is ``digital``.
+ 2. **Text block count fallback** — if font encoding prevents raw text
+ extraction (e.g. ``font.unknown`` PDFs), count structural text blocks
+ from ``get_text("blocks")``. ≥ ``text_block_threshold`` blocks
+ signals ``digital``.
+ 3. **Image coverage** — if images cover ≥ ``image_coverage_threshold``
+ of the page area, the page is ``scanned``.
+ 4. Otherwise defaults to ``digital``.
+
+ Args:
+ page: fitz Page object to classify.
+
+ Returns:
+ ``"scanned"`` or ``"digital"``.
+ """
+ # Extract text
+ text = page.get_text("text")
+ text_length = len(text.strip())
+
+ # Check for sufficient extractable text
+ if text_length >= self.text_threshold:
+ return "digital"
+
+ # Fallback: count text block objects even when font encoding is unknown.
+ # PDFs with non-standard fonts (e.g. font.unknown) return empty raw text
+ # but still have text block structures detectable via get_text("blocks").
+ blocks = page.get_text("blocks")
+ text_block_count = sum(1 for b in blocks if b[6] == 0) # type 0 = text
+ if text_block_count >= self.text_block_threshold:
+ logger.debug(
+ f"Font-encoding fallback: {text_block_count} text blocks found "
+ f"despite {text_length} raw chars — classifying as digital"
+ )
+ return "digital"
+
+ # Zero text by any measure → image-based page (scanned or screenshot PDF).
+ # image_coverage detection below can miss inline images and PDFs produced
+ # by screenshot tools that embed images outside the XObject registry.
+ if text_length == 0 and text_block_count == 0:
+ logger.debug("No text or text blocks found — classifying as scanned")
+ return "scanned"
+
+ # Check image coverage
+ page_rect = page.rect
+ page_area = page_rect.width * page_rect.height
+
+ if page_area == 0:
+ return "digital"
+
+ image_area = 0.0
+ image_list = page.get_images(full=True)
+
+ for img_info in image_list:
+ xref = img_info[0]
+ try:
+ # Get image bbox on page
+ for img_rect in page.get_image_rects(xref):
+ image_area += img_rect.width * img_rect.height
+ except Exception:
+ # If we can't get rect, estimate from image size
+ try:
+ pix = fitz.Pixmap(page.parent, xref)
+ # Rough estimate: image covers significant portion
+ image_area += pix.width * pix.height * 0.5
+ pix = None
+ except Exception:
+ pass
+
+ image_coverage = image_area / page_area
+
+ if image_coverage >= self.image_coverage_threshold:
+ return "scanned"
+
+ # Default to digital if unclear
+ return "digital"
diff --git a/pdf2zh/parser/enums.py b/pdf2zh/parser/enums.py
new file mode 100644
index 0000000000000000000000000000000000000000..20fc75d74738d2ec1dd55e3776e2771cdfb81241
--- /dev/null
+++ b/pdf2zh/parser/enums.py
@@ -0,0 +1,90 @@
+"""Enums and label mappings for the scanned PDF pipeline.
+
+This module contains:
+- ElementCategory: The 5 translation handling categories
+- SuryaLabel: String constants for Surya's hyphenated labels
+- SURYA_LABEL_MAP: Mapping from Surya labels to ElementCategory
+- DEFAULT_CATEGORY: Fallback for unknown labels
+"""
+
+from enum import Enum
+
+
+class ElementCategory(str, Enum):
+ """Categories determining how downstream stages handle each element.
+
+ Values:
+ BYPASS: Pixel-copy from original; never translate (Picture, Figure, Form)
+ FLOWING_TEXT: Translate full source_text; may merge adjacent blocks
+ IN_PLACE: Translate source_text; render at exact bbox position
+ TABLE: Translate each cell's source_text; render cell grid
+ EQUATION: source_text contains extracted OCR text to translate;
+ equation_words preserves word-level OCR boxes for precise placement
+ """
+
+ BYPASS = "BYPASS"
+ FLOWING_TEXT = "FLOWING_TEXT"
+ IN_PLACE = "IN_PLACE"
+ TABLE = "TABLE"
+ EQUATION = "EQUATION"
+
+
+class SuryaLabel:
+ """String constants for Surya layout labels (v0.9+ hyphenated format).
+
+ These match the exact strings returned by Surya's LayoutPredictor.
+ """
+
+ # Text elements -> FLOWING_TEXT
+ TEXT = "Text"
+ LIST_ITEM = "ListItem"
+ FOOTNOTE = "Footnote"
+
+ # Headers/footers/captions -> IN_PLACE
+ SECTION_HEADER = "SectionHeader"
+ PAGE_HEADER = "PageHeader"
+ PAGE_FOOTER = "PageFooter"
+ CAPTION = "Caption"
+ TABLE_OF_CONTENTS = "TableOfContents"
+
+ # Graphics -> BYPASS
+ PICTURE = "Picture"
+ FIGURE = "Figure"
+ FORM = "Form"
+
+ # Tables -> TABLE
+ TABLE = "Table"
+
+ # Math -> EQUATION
+ EQUATION = "Equation"
+
+ # Code
+ CODE = "Code"
+
+
+# Mapping from Surya labels to ElementCategory
+SURYA_LABEL_MAP: dict[str, ElementCategory] = {
+ # FLOWING_TEXT: regular text blocks that can be translated and reflowed
+ SuryaLabel.TEXT: ElementCategory.FLOWING_TEXT,
+ SuryaLabel.LIST_ITEM: ElementCategory.FLOWING_TEXT,
+ SuryaLabel.FOOTNOTE: ElementCategory.FLOWING_TEXT,
+ # IN_PLACE: text that must be rendered at exact position
+ SuryaLabel.SECTION_HEADER: ElementCategory.FLOWING_TEXT,
+ SuryaLabel.PAGE_HEADER: ElementCategory.FLOWING_TEXT,
+ SuryaLabel.PAGE_FOOTER: ElementCategory.FLOWING_TEXT,
+ SuryaLabel.CAPTION: ElementCategory.IN_PLACE,
+ SuryaLabel.TABLE_OF_CONTENTS: ElementCategory.IN_PLACE,
+ # BYPASS: graphics that should be copied without modification
+ SuryaLabel.PICTURE: ElementCategory.BYPASS,
+ SuryaLabel.FIGURE: ElementCategory.BYPASS,
+ SuryaLabel.FORM: ElementCategory.FLOWING_TEXT,
+ # TABLE: structured data requiring cell-level translation
+ SuryaLabel.TABLE: ElementCategory.TABLE,
+ # EQUATION: math content to be preserved as-is
+ SuryaLabel.EQUATION: ElementCategory.EQUATION,
+ # CODE: programming code blocks (treat as IN_PLACE for now)
+ SuryaLabel.CODE: ElementCategory.BYPASS,
+}
+
+# Default category for unknown Surya labels
+DEFAULT_CATEGORY = ElementCategory.FLOWING_TEXT
diff --git a/pdf2zh/parser/main.py b/pdf2zh/parser/main.py
new file mode 100644
index 0000000000000000000000000000000000000000..ce96c1317acb6bf9687162e3008cb9146ad54ee9
--- /dev/null
+++ b/pdf2zh/parser/main.py
@@ -0,0 +1,1150 @@
+"""Stage A parser with phase-based Surya workflow for scanned PDFs."""
+
+from __future__ import annotations
+
+import gc
+import logging
+from pathlib import Path
+from typing import Any, Iterable
+
+import fitz # PyMuPDF
+import torch
+from PIL import Image
+
+from pdf2zh.parser.ai_models import (
+ PaddleCellTableModule,
+ SuryaLayoutModel,
+ SuryaOCRModel,
+)
+from pdf2zh.parser.enums import (
+ DEFAULT_CATEGORY,
+ SURYA_LABEL_MAP,
+ ElementCategory,
+ SuryaLabel,
+)
+from pdf2zh.parser.models import (
+ CellData,
+ ElementData,
+ LayoutBlockResult,
+ LayoutPageResult,
+ LayoutParseResult,
+ OCRPageResult,
+ OCRParseResult,
+ PageData,
+ ParsedDocument,
+ TableBlockResult,
+ TableParseResult,
+ _DocumentContext,
+ _TableJob,
+)
+from pdf2zh.parser.utils.bbox import (
+ bbox_area,
+ bbox_intersection,
+ bbox_iou,
+ bbox_union_area,
+ clamp_bbox,
+ convert_bbox,
+ image_bbox_to_pdf,
+ is_degenerate,
+ offset_bbox,
+ polygon_to_bbox,
+)
+from pdf2zh.parser.utils.block import (
+ get_line_bbox,
+ is_sparse_text_block,
+)
+from pdf2zh.parser.utils.hardware import configure_settings
+from pdf2zh.parser.utils.image import crop_image_to_bbox, get_page_dimensions
+from pdf2zh.parser.utils.ocr_text import (
+ adjust_cell_bbox,
+ clean_ocr_text,
+ extract_text_for_region,
+ join_raw_text,
+ smart_join_text_lines,
+ sort_text_lines,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class StageAParser:
+ """Phase-based Stage A parser for scanned PDFs."""
+
+ def __init__(
+ self,
+ device: str = "auto",
+ page_batch_size: int | None = None,
+ layout_batch_size: int | None = None,
+ detection_batch_size: int | None = None,
+ ocr_batch_size: int | None = None,
+ table_batch_size: int | None = None,
+ detector_blank_threshold: float | None = None,
+ detector_text_threshold: float | None = None,
+ ) -> None:
+ """Configure settings and initialize predictors."""
+
+ self.hardware = configure_settings(
+ device=device,
+ page_batch_size=page_batch_size,
+ layout_batch_size=layout_batch_size,
+ detection_batch_size=detection_batch_size,
+ ocr_batch_size=ocr_batch_size,
+ table_batch_size=table_batch_size,
+ )
+ self.layout_model = SuryaLayoutModel()
+ self.ocr_model = SuryaOCRModel(
+ detector_blank_threshold=detector_blank_threshold,
+ detector_text_threshold=detector_text_threshold,
+ )
+ # self.table_model = SuryaTableModel(self.hardware)
+ self.table_model = PaddleCellTableModule()
+
+ def parse_layout(
+ self,
+ context: _DocumentContext,
+ ) -> LayoutParseResult:
+ """Run the layout phase only."""
+
+ parsed_pages: list[LayoutPageResult] = []
+
+ for batch_indices in self._chunked(
+ context.page_indices, self.hardware.layout_batch_size
+ ):
+ images, _ = self._load_page_images(
+ context.pdf_path,
+ batch_indices,
+ include_highres=False,
+ )
+ parsed_pages.extend(
+ self._parse_layout_batch(
+ batch_indices,
+ context.page_dims,
+ images,
+ ocr_pages=None,
+ )
+ )
+ self._release_batch(images)
+
+ return LayoutParseResult(pdf_path=str(context.pdf_path), pages=parsed_pages)
+
+ def parse_ocr(
+ self,
+ context: _DocumentContext,
+ ) -> OCRParseResult:
+ """Run the full-page OCR phase only."""
+
+ parsed_pages: list[OCRPageResult] = []
+
+ for batch_indices in self._chunked(
+ context.page_indices, self.hardware.detection_batch_size
+ ):
+ images, highres_images = self._load_page_images(
+ context.pdf_path, batch_indices, include_highres=True
+ )
+ parsed_pages.extend(
+ self._parse_ocr_batch(batch_indices, images, highres_images)
+ )
+ self._release_batch(images, highres_images)
+
+ return OCRParseResult(pdf_path=str(context.pdf_path), pages=parsed_pages)
+
+ def parse_tables(
+ self,
+ context: _DocumentContext,
+ layout_result: LayoutParseResult,
+ ) -> TableParseResult:
+ """Run table structure recognition and merge cell text from full-page OCR."""
+
+ if Path(layout_result.pdf_path) != context.pdf_path:
+ raise ValueError("layout_result does not belong to the requested PDF")
+
+ tables: dict[str, TableBlockResult] = {}
+
+ for page_batch in self._chunked(
+ layout_result.pages, self.hardware.table_batch_size
+ ):
+ batch_indices = [page.page_index for page in page_batch]
+ images, _ = self._load_page_images(
+ context.pdf_path, batch_indices, include_highres=False
+ )
+
+ batch_tables = self._parse_tables_batch(
+ page_batch,
+ images,
+ )
+ tables.update(batch_tables.tables)
+ self._release_batch(images)
+
+ return TableParseResult(pdf_path=str(context.pdf_path), tables=tables)
+
+ def parse_pdf(
+ self,
+ pdf_path: str | Path,
+ cache_path: str | Path | None = None,
+ pages: list[int] | None = None,
+ ) -> ParsedDocument:
+ """Backward-compatible wrapper that executes the phase pipeline."""
+
+ pdf_path = self._resolve_pdf_path(pdf_path)
+ if cache_path:
+ cache_path = Path(cache_path)
+ if cache_path.exists():
+ logger.info("Loading cached Stage A result from %s", cache_path)
+ return ParsedDocument.load(cache_path)
+
+ context = self._prepare_document_context(pdf_path, pages)
+
+ layout_pages: list[LayoutPageResult] = []
+ ocr_pages: list[OCRPageResult] = []
+ tables: dict[str, TableBlockResult] = {}
+
+ for batch_indices in self._chunked(
+ context.page_indices, self.hardware.page_batch_size
+ ):
+ images, highres_images = self._load_page_images(
+ context.pdf_path,
+ batch_indices,
+ include_highres=True,
+ )
+
+ batch_ocr_pages = self._parse_ocr_batch(
+ batch_indices, images, highres_images
+ )
+
+ batch_layout_pages = self._parse_layout_batch(
+ batch_indices,
+ context.page_dims,
+ images,
+ ocr_pages=batch_ocr_pages,
+ )
+
+ batch_tables = self._parse_tables_batch(
+ batch_layout_pages,
+ images,
+ )
+
+ layout_pages.extend(batch_layout_pages)
+ ocr_pages.extend(batch_ocr_pages)
+ tables.update(batch_tables.tables)
+
+ self._release_batch(images, highres_images)
+
+ parsed_doc = self.merge_results(
+ context.pdf_path,
+ LayoutParseResult(pdf_path=str(context.pdf_path), pages=layout_pages),
+ OCRParseResult(pdf_path=str(context.pdf_path), pages=ocr_pages),
+ table_result=TableParseResult(
+ pdf_path=str(context.pdf_path), tables=tables
+ ),
+ )
+
+ if cache_path:
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
+ parsed_doc.save(cache_path)
+ logger.info("Saved Stage A result to %s", cache_path)
+
+ return parsed_doc
+
+ def merge_results(
+ self,
+ pdf_path: str | Path,
+ layout_result: LayoutParseResult,
+ ocr_result: OCRParseResult,
+ table_result: TableParseResult | None = None,
+ ) -> ParsedDocument:
+ """Merge phase outputs into the final ParsedDocument."""
+
+ pdf_path = self._resolve_pdf_path(pdf_path)
+ if Path(layout_result.pdf_path) != pdf_path:
+ raise ValueError("layout_result does not belong to the requested PDF")
+ if Path(ocr_result.pdf_path) != pdf_path:
+ raise ValueError("ocr_result does not belong to the requested PDF")
+
+ table_map = table_result.tables if table_result else {}
+ ocr_page_map = ocr_result.page_map()
+
+ pages: list[PageData] = []
+ for layout_page in layout_result.pages:
+ page_ocr = ocr_page_map.get(layout_page.page_index)
+ if page_ocr is None:
+ raise ValueError(f"ocr_result is missing page {layout_page.page_index}")
+
+ elements: list[ElementData] = []
+
+ for block in layout_page.blocks:
+ source_text = ""
+ cells: list[CellData] = []
+ element_label = block.label
+ element_category = block.category
+
+ if block.category == ElementCategory.BYPASS:
+ text_line = self._single_text_line_in_figure(block, page_ocr)
+ if text_line is not None:
+ # Figure gán nhầm cho 1 dòng text -> coi như flowing text.
+ source_text = smart_join_text_lines([text_line])
+ element_label = SuryaLabel.TEXT
+ element_category = ElementCategory.FLOWING_TEXT
+ elif block.category == ElementCategory.TABLE:
+ table_block = table_map.get(block.block_id)
+ if table_block is None:
+ matching_lines = extract_text_for_region(
+ page_ocr.ocr_result, block.bbox_image
+ )
+ source_text = " ".join(line.text for line in matching_lines)
+ else:
+ crop_w, crop_h = table_block.crop_size
+ block_image_w = block.bbox_image[2] - block.bbox_image[0]
+ block_image_h = block.bbox_image[3] - block.bbox_image[1]
+ block_pdf_w = block.bbox_pdf[2] - block.bbox_pdf[0]
+ block_pdf_h = block.bbox_pdf[3] - block.bbox_pdf[1]
+
+ source_parts: list[str] = []
+ cells = []
+ cell_boxes_image: list[list[float]] = []
+
+ for cell_bbox in table_block.cells_bbox:
+ cell_bbox_image = offset_bbox(
+ convert_bbox(
+ cell_bbox,
+ crop_w,
+ crop_h,
+ block_image_w,
+ block_image_h,
+ pad_right=0,
+ pad_bottom=0,
+ ),
+ block.bbox_image[0],
+ block.bbox_image[1],
+ )
+ cell_boxes_image.append(cell_bbox_image)
+ cell_bbox_pdf = clamp_bbox(
+ offset_bbox(
+ convert_bbox(
+ cell_bbox,
+ crop_w,
+ crop_h,
+ block_pdf_w,
+ block_pdf_h,
+ pad_right=0,
+ pad_bottom=0,
+ ),
+ block.bbox_pdf[0],
+ block.bbox_pdf[1],
+ ),
+ layout_page.page_width,
+ layout_page.page_height,
+ )
+ matching_cell_lines = extract_text_for_region(
+ page_ocr.ocr_result, cell_bbox_image
+ )
+ cell_bbox_text = adjust_cell_bbox(
+ matching_cell_lines, cell_bbox_pdf, cell_bbox_image
+ )
+ cell_text = smart_join_text_lines(matching_cell_lines)
+ cells.append(
+ CellData(
+ bbox_pdf=cell_bbox_pdf,
+ bbox_text=cell_bbox_text,
+ source_text=cell_text,
+ translated_text="",
+ )
+ )
+ source_parts.append(cell_text)
+
+ for orphan_line in self._collect_orphan_table_lines(
+ page_ocr.ocr_result,
+ block.bbox_image,
+ cell_boxes_image,
+ ):
+ orphan_text = clean_ocr_text(
+ getattr(orphan_line, "text", "")
+ )
+ if not orphan_text:
+ continue
+
+ orphan_bbox_line = get_line_bbox(orphan_line)
+
+ if orphan_bbox_line is None or is_degenerate(
+ orphan_bbox_line
+ ):
+ continue
+
+ orphan_bbox_pdf = clamp_bbox(
+ image_bbox_to_pdf(
+ orphan_bbox_line,
+ page_ocr.image_bbox,
+ layout_page.page_width,
+ layout_page.page_height,
+ pad_right=0,
+ pad_bottom=0,
+ ),
+ layout_page.page_width,
+ layout_page.page_height,
+ )
+ cells.append(
+ CellData(
+ bbox_pdf=orphan_bbox_pdf,
+ bbox_text=orphan_bbox_pdf,
+ source_text=orphan_text,
+ translated_text="",
+ )
+ )
+ source_parts.append(orphan_text)
+
+ source_text = " | ".join(source_parts)
+
+ if not cells:
+ matching_lines = extract_text_for_region(
+ page_ocr.ocr_result, block.bbox_image
+ )
+ source_text = smart_join_text_lines(matching_lines)
+ else:
+ matching_lines = extract_text_for_region(
+ page_ocr.ocr_result, block.bbox_image
+ )
+ source_text = smart_join_text_lines(matching_lines)
+
+ elements.append(
+ ElementData(
+ label=element_label,
+ category=element_category,
+ bbox_pdf=block.bbox_pdf,
+ source_text=source_text,
+ translated_text="",
+ cells=cells,
+ )
+ )
+
+ orphan_elements = self._collect_orphan_ocr_data(
+ layout_page,
+ page_ocr,
+ )
+ elements = self._insert_orphan_elements(elements, orphan_elements)
+ pages.append(
+ PageData(
+ page_index=layout_page.page_index,
+ page_width=layout_page.page_width,
+ page_height=layout_page.page_height,
+ elements=elements,
+ raw_text=join_raw_text(elements),
+ chapter_id="",
+ )
+ )
+
+ return ParsedDocument(
+ pdf_path=str(pdf_path),
+ pages=pages,
+ chapters=[],
+ glossary={},
+ )
+
+ def _prepare_document_context(
+ self,
+ pdf_path: str | Path,
+ pages: list[int] | None,
+ ) -> _DocumentContext:
+ pdf_path = self._resolve_pdf_path(pdf_path)
+ doc = fitz.open(pdf_path)
+ try:
+ if len(doc) == 0:
+ raise ValueError("PDF is empty")
+ if pages is None:
+ page_indices = list(range(len(doc)))
+ else:
+ page_indices = [index for index in pages if 0 <= index < len(doc)]
+ page_dims = {
+ index: get_page_dimensions(doc[index]) for index in page_indices
+ }
+ finally:
+ doc.close()
+
+ return _DocumentContext(
+ pdf_path=pdf_path,
+ page_indices=page_indices,
+ page_dims=page_dims,
+ )
+
+ def _load_page_images(
+ self,
+ pdf_path: Path,
+ page_indices: list[int],
+ include_highres: bool,
+ ) -> tuple[list[Image.Image], list[Image.Image] | None]:
+ from surya.input.load import load_from_file
+ from surya.settings import settings
+
+ images, _ = load_from_file(str(pdf_path), page_range=page_indices)
+
+ if not include_highres:
+ return images, None
+
+ highres_images, _ = load_from_file(
+ str(pdf_path),
+ dpi=settings.IMAGE_DPI_HIGHRES,
+ page_range=page_indices,
+ )
+
+ return images, highres_images
+
+ def _parse_layout_batch(
+ self,
+ batch_indices: list[int],
+ page_dims: dict[int, tuple[float, float]],
+ images: list[Image.Image],
+ ocr_pages: list[OCRPageResult] | None = None,
+ ) -> list[LayoutPageResult]:
+ layout_predictions = self.layout_model(
+ images, batch_size=self.hardware.layout_batch_size, auto_unload=False
+ )
+
+ layout_pages: list[LayoutPageResult] = []
+ ocr_page_map = (
+ {page.page_index: page for page in ocr_pages}
+ if ocr_pages is not None
+ else {}
+ )
+
+ for seq, page_index in enumerate(batch_indices):
+ page_width, page_height = page_dims[page_index]
+ image_bbox = [0.0, 0.0, images[seq].size[0], images[seq].size[1]]
+ layout_image_bbox = list(layout_predictions[seq].image_bbox)
+ blocks: list[LayoutBlockResult] = []
+ page_ocr = ocr_page_map.get(page_index)
+
+ for position, block in enumerate(layout_predictions[seq].bboxes):
+ block_bbox = getattr(block, "bbox", None)
+ raw_bbox = list(
+ block_bbox
+ if block_bbox is not None
+ else polygon_to_bbox(block.polygon)
+ )
+ label = block.label
+ category = SURYA_LABEL_MAP.get(label, DEFAULT_CATEGORY)
+ bbox_pdf = clamp_bbox(
+ image_bbox_to_pdf(
+ raw_bbox,
+ layout_image_bbox,
+ page_width,
+ page_height,
+ pad_right=1.0,
+ pad_bottom=1.0,
+ ),
+ page_width,
+ page_height,
+ )
+ bbox_image = clamp_bbox(
+ convert_bbox(
+ raw_bbox,
+ layout_image_bbox[2],
+ layout_image_bbox[3],
+ image_bbox[2],
+ image_bbox[3],
+ pad_right=1.0,
+ pad_bottom=1.0,
+ ),
+ image_bbox[2],
+ image_bbox[3],
+ )
+
+ if is_degenerate(bbox_pdf) or is_degenerate(bbox_image):
+ logger.debug(
+ "Skipping degenerate layout bbox on page %s", page_index
+ )
+ continue
+
+ blocks.append(
+ LayoutBlockResult(
+ block_id=f"{page_index}:{getattr(block, 'position', position)}",
+ page_index=page_index,
+ position=getattr(block, "position", position),
+ label=label,
+ category=category,
+ bbox_layout=raw_bbox,
+ bbox_image=bbox_image,
+ bbox_pdf=bbox_pdf,
+ )
+ )
+
+ if page_ocr is not None:
+ blocks = self._expand_layout_blocks(
+ blocks,
+ page_ocr,
+ image_bbox,
+ page_width,
+ page_height,
+ )
+
+ blocks = self._prune_overlapping_layout_blocks(blocks)
+
+ blocks = self._refine_sparse_text_blocks(
+ blocks,
+ page_ocr,
+ image_bbox,
+ layout_image_bbox,
+ page_width,
+ page_height,
+ )
+
+ layout_pages.append(
+ LayoutPageResult(
+ page_index=page_index,
+ page_width=page_width,
+ page_height=page_height,
+ layout_image_bbox=layout_image_bbox,
+ image_bbox=image_bbox,
+ blocks=blocks,
+ )
+ )
+
+ return layout_pages
+
+ def _parse_ocr_batch(
+ self,
+ batch_indices: list[int],
+ images: list[Image.Image],
+ highres_images: list[Image.Image] | None,
+ ) -> list[OCRPageResult]:
+ ocr_predictions = self.ocr_model(
+ images,
+ highres_images=highres_images,
+ math_mode=False,
+ detection_batch_size=self.hardware.detection_batch_size,
+ ocr_batch_size=self.hardware.ocr_batch_size,
+ auto_unload=False,
+ )
+
+ return [
+ OCRPageResult(
+ page_index=page_index,
+ image_bbox=list(
+ getattr(
+ prediction,
+ "image_bbox",
+ [0, 0, images[seq].size[0], images[seq].size[1]],
+ )
+ ),
+ ocr_result=prediction,
+ )
+ for seq, (page_index, prediction) in enumerate(
+ zip(batch_indices, ocr_predictions)
+ )
+ ]
+
+ def _parse_tables_batch(
+ self,
+ layout_pages: list[LayoutPageResult],
+ images: list[Image.Image],
+ ) -> TableParseResult:
+
+ table_jobs: list[_TableJob] = []
+ table_crops: list[Image.Image] = []
+
+ for seq, page in enumerate(layout_pages):
+ for block in page.blocks:
+ if block.category != ElementCategory.TABLE:
+ continue
+ table_crop = crop_image_to_bbox(
+ images[seq],
+ block.bbox_pdf,
+ page.page_width,
+ page.page_height,
+ )
+ table_jobs.append(
+ _TableJob(
+ block=block,
+ page_width=page.page_width,
+ page_height=page.page_height,
+ table_crop=table_crop,
+ )
+ )
+ table_crops.append(table_crop)
+
+ if not table_jobs:
+ return TableParseResult(pdf_path="", tables={})
+
+ table_predictions = self.table_model(
+ table_crops, batch_size=self.hardware.table_batch_size, auto_unload=False
+ )
+
+ tables: dict[str, TableBlockResult] = {}
+
+ for job, prediction in zip(table_jobs, table_predictions):
+ table_result = TableBlockResult(
+ block_id=job.block.block_id,
+ cells_bbox=prediction,
+ crop_size=job.table_crop.size,
+ )
+ tables[job.block.block_id] = table_result
+
+ return TableParseResult(pdf_path="", tables=tables)
+
+ def _expand_layout_blocks(
+ self,
+ blocks: list[LayoutBlockResult],
+ page_ocr: OCRPageResult,
+ image_bbox: list[float],
+ page_width: float,
+ page_height: float,
+ overlap_threshold: float = 0.3,
+ ) -> list[LayoutBlockResult]:
+ text_lines = getattr(page_ocr.ocr_result, "text_lines", None) or []
+ if not text_lines:
+ return blocks
+
+ expanded_blocks: list[LayoutBlockResult] = []
+ for block in blocks:
+ if block.category == ElementCategory.BYPASS:
+ expanded_blocks.append(block)
+ continue
+
+ matched_boxes: list[list[float]] = [block.bbox_image]
+ for line in text_lines:
+ line_bbox = get_line_bbox(line)
+ if line_bbox is None or is_degenerate(line_bbox):
+ continue
+
+ intersection = bbox_intersection(line_bbox, block.bbox_image)
+ if intersection is None:
+ continue
+
+ overlap_ratio = bbox_area(intersection) / max(1.0, bbox_area(line_bbox))
+
+ if overlap_ratio >= overlap_threshold:
+ matched_boxes.append(line_bbox)
+
+ merged_bbox = self._merge_bboxes(matched_boxes)
+ if merged_bbox is None:
+ expanded_blocks.append(block)
+ continue
+
+ bbox_image = clamp_bbox(merged_bbox, image_bbox[2], image_bbox[3])
+ bbox_pdf = clamp_bbox(
+ image_bbox_to_pdf(
+ bbox_image,
+ image_bbox,
+ page_width,
+ page_height,
+ pad_right=1.0,
+ pad_bottom=1.0,
+ ),
+ page_width,
+ page_height,
+ )
+ expanded_blocks.append(
+ LayoutBlockResult(
+ block_id=block.block_id,
+ page_index=block.page_index,
+ position=block.position,
+ label=block.label,
+ category=block.category,
+ bbox_layout=block.bbox_layout,
+ bbox_image=bbox_image,
+ bbox_pdf=bbox_pdf,
+ )
+ )
+
+ return expanded_blocks
+
+ def _prune_overlapping_layout_blocks(
+ self,
+ blocks: list[LayoutBlockResult],
+ overlap_threshold: float = 0.7,
+ containment_threshold: float = 0.9,
+ ) -> list[LayoutBlockResult]:
+ if len(blocks) < 2:
+ return blocks
+
+ kept_blocks: list[LayoutBlockResult] = []
+ for block in sorted(
+ blocks,
+ key=lambda item: (-bbox_area(item.bbox_image), item.position),
+ ):
+ block_area = max(1.0, bbox_area(block.bbox_image))
+ should_drop = False
+
+ for kept in kept_blocks:
+ intersection = bbox_intersection(block.bbox_image, kept.bbox_image)
+ if intersection is None:
+ continue
+
+ overlap_ratio = bbox_area(intersection) / block_area
+ kept_area = bbox_area(kept.bbox_image)
+ if overlap_ratio >= overlap_threshold and kept_area >= block_area:
+ should_drop = True
+ break
+
+ if not should_drop:
+ kept_blocks.append(block)
+
+ filtered_blocks: list[LayoutBlockResult] = []
+ for block in kept_blocks:
+ block_area = max(1.0, bbox_area(block.bbox_image))
+ covered_by_larger = False
+ for other in kept_blocks:
+ if other.block_id == block.block_id:
+ continue
+
+ other_area = bbox_area(other.bbox_image)
+ if other_area < block_area:
+ continue
+
+ intersection = bbox_intersection(block.bbox_image, other.bbox_image)
+ if intersection is None:
+ continue
+
+ overlap_ratio = bbox_area(intersection) / block_area
+ if overlap_ratio >= containment_threshold:
+ covered_by_larger = True
+ break
+
+ if not covered_by_larger:
+ filtered_blocks.append(block)
+
+ return sorted(filtered_blocks, key=lambda item: item.position)
+
+ def _single_text_line_in_figure(
+ self,
+ block: LayoutBlockResult,
+ page_ocr: OCRPageResult,
+ iou_threshold: float = 0.75,
+ ) -> Any | None:
+ """Figure có đúng 1 textline lấp gần kín vùng -> trả về textline đó.
+
+ Surya đôi khi gán 1 dòng text lẻ thành Figure. Khi vùng figure chứa
+ ĐÚNG 1 OCR textline và textline đó gần như trùng khớp với vùng
+ (IoU >= iou_threshold), coi như text bị gán nhầm; ngược lại trả None.
+ """
+ if block.label not in [SuryaLabel.FIGURE, SuryaLabel.PICTURE]:
+ return None
+
+ lines = extract_text_for_region(page_ocr.ocr_result, block.bbox_image)
+ if len(lines) != 1:
+ return None
+
+ print("hello")
+
+ line_bbox = get_line_bbox(lines[0])
+ if line_bbox is None or is_degenerate(line_bbox):
+ return None
+ print(bbox_iou(block.bbox_image, line_bbox))
+ if bbox_iou(block.bbox_image, line_bbox) < iou_threshold:
+ return None
+
+ return lines[0]
+
+ def _refine_sparse_text_blocks(
+ self,
+ blocks: list[LayoutBlockResult],
+ page_ocr: OCRPageResult,
+ image_bbox: list[float],
+ layout_image_bbox: list[float],
+ page_width: float,
+ page_height: float,
+ ) -> list[LayoutBlockResult]:
+ refined_blocks: list[LayoutBlockResult] = []
+
+ # Labels that must always be split into per-line blocks and relabelled
+ # as plain text so downstream stages reflow/translate them like text.
+ force_text_labels = {SuryaLabel.TABLE_OF_CONTENTS, SuryaLabel.FORM}
+
+ for block in blocks:
+ force_text = block.label in force_text_labels
+
+ if not force_text and block.category not in [
+ ElementCategory.FLOWING_TEXT,
+ ElementCategory.EQUATION,
+ ]:
+ refined_blocks.append(block)
+ continue
+
+ if force_text:
+ # TableOfContents / Form -> treat as plain text, always split.
+ split_label = SuryaLabel.TEXT
+ split_category = ElementCategory.FLOWING_TEXT
+ always_convert = True
+ elif block.category == ElementCategory.EQUATION:
+ # Equations keep their label/category as before.
+ split_label = block.label
+ split_category = block.category
+ always_convert = True
+ else:
+ split_label = block.label
+ split_category = block.category
+ always_convert = False
+
+ is_sparse, text_lines = is_sparse_text_block(
+ page_ocr.ocr_result, block.bbox_image, always_convert
+ )
+
+ if not is_sparse:
+ refined_blocks.append(block)
+ continue
+
+ line_blocks = self._make_line_layout_blocks(
+ block,
+ text_lines,
+ split_label,
+ split_category,
+ image_bbox,
+ layout_image_bbox,
+ page_width,
+ page_height,
+ )
+ refined_blocks.extend(line_blocks or [block])
+
+ return refined_blocks
+
+ def _make_line_layout_blocks(
+ self,
+ block: LayoutBlockResult,
+ text_lines: list[Any],
+ label: str,
+ category: ElementCategory,
+ image_bbox: list[float],
+ layout_image_bbox: list[float],
+ page_width: float,
+ page_height: float,
+ ) -> list[LayoutBlockResult]:
+ line_blocks: list[LayoutBlockResult] = []
+
+ for index, line in enumerate(text_lines):
+ line_bbox = get_line_bbox(line)
+ if line_bbox is None or is_degenerate(line_bbox):
+ continue
+
+ bbox_image = clamp_bbox(line_bbox, image_bbox[2], image_bbox[3])
+ bbox_pdf = clamp_bbox(
+ image_bbox_to_pdf(
+ bbox_image,
+ image_bbox,
+ page_width,
+ page_height,
+ pad_right=2.5,
+ pad_bottom=1.5,
+ ),
+ page_width,
+ page_height,
+ )
+ bbox_layout = clamp_bbox(
+ convert_bbox(
+ bbox_image,
+ image_bbox[2],
+ image_bbox[3],
+ layout_image_bbox[2],
+ layout_image_bbox[3],
+ pad_right=2.5,
+ pad_bottom=1.5,
+ ),
+ layout_image_bbox[2],
+ layout_image_bbox[3],
+ )
+ line_blocks.append(
+ LayoutBlockResult(
+ block_id=f"{block.block_id}:line:{index}",
+ page_index=block.page_index,
+ position=block.position * 1000 + index,
+ label=label,
+ category=category,
+ bbox_layout=bbox_layout,
+ bbox_image=bbox_image,
+ bbox_pdf=bbox_pdf,
+ )
+ )
+
+ return line_blocks
+
+ def _create_orphan_element_from_line(
+ self,
+ line: Any,
+ line_bbox: list[float],
+ page_ocr: OCRPageResult,
+ layout_page: LayoutPageResult,
+ ) -> ElementData | None:
+ line_text = clean_ocr_text(getattr(line, "text", ""))
+ if not line_text or is_degenerate(line_bbox):
+ return None
+
+ orphan_bbox_pdf = clamp_bbox(
+ image_bbox_to_pdf(
+ line_bbox,
+ page_ocr.image_bbox,
+ layout_page.page_width,
+ layout_page.page_height,
+ pad_right=2.5,
+ pad_bottom=1.5,
+ ),
+ layout_page.page_width,
+ layout_page.page_height,
+ )
+ return ElementData(
+ label="Text",
+ category=DEFAULT_CATEGORY,
+ bbox_pdf=orphan_bbox_pdf,
+ source_text=line_text,
+ translated_text="",
+ )
+
+ def _collect_orphan_table_lines(
+ self,
+ ocr_result: Any,
+ table_bbox_image: list[float],
+ cell_bboxes_image: list[list[float]],
+ table_overlap_threshold: float = 0.5,
+ cell_overlap_threshold: float = 0.3,
+ ) -> list[Any]:
+ orphan_lines: list[Any] = []
+ for line in getattr(ocr_result, "text_lines", None) or []:
+ line_bbox = get_line_bbox(line)
+ if line_bbox is None or is_degenerate(line_bbox):
+ continue
+
+ intersection = bbox_intersection(line_bbox, table_bbox_image)
+ if intersection is None:
+ continue
+
+ if (
+ bbox_area(intersection) / max(1.0, bbox_area(line_bbox))
+ < table_overlap_threshold
+ ):
+ continue
+
+ overlaps_cell = False
+ for cell_bbox in cell_bboxes_image:
+ cell_intersection = bbox_intersection(line_bbox, cell_bbox)
+ if cell_intersection is None:
+ continue
+ if (
+ bbox_area(cell_intersection) / max(1.0, bbox_area(line_bbox))
+ >= cell_overlap_threshold
+ ):
+ overlaps_cell = True
+ break
+
+ if not overlaps_cell:
+ orphan_lines.append(line)
+
+ orphan_lines = sort_text_lines(orphan_lines)
+ return orphan_lines
+
+ def _collect_orphan_ocr_data(
+ self,
+ layout_page: LayoutPageResult,
+ page_ocr: OCRPageResult,
+ overlap_threshold: float = 0.5,
+ ) -> list[ElementData]:
+ text_lines = getattr(page_ocr.ocr_result, "text_lines", None)
+ if not text_lines:
+ return []
+
+ orphan_elements: list[ElementData] = []
+ layout_bboxes = [block.bbox_image for block in layout_page.blocks]
+
+ for line in text_lines:
+ line_bbox = get_line_bbox(line)
+ if line_bbox is None or is_degenerate(line_bbox):
+ continue
+
+ line_area = bbox_area(line_bbox)
+ if line_area <= 0:
+ continue
+
+ covered_regions: list[list[float]] = []
+ for layout_bbox in layout_bboxes:
+ intersection = bbox_intersection(line_bbox, layout_bbox)
+ if intersection is not None:
+ covered_regions.append(intersection)
+
+ covered_ratio = bbox_union_area(covered_regions) / line_area
+ # This condition ensures that lines can't duplicate with function extract_text_from_region
+ if covered_ratio >= overlap_threshold:
+ continue
+
+ orphan = self._create_orphan_element_from_line(
+ line,
+ line_bbox,
+ page_ocr,
+ layout_page,
+ )
+
+ if orphan is not None:
+ orphan_elements.append(orphan)
+
+ return orphan_elements
+
+ def _insert_orphan_elements(
+ self,
+ elements: list[ElementData],
+ orphan_elements: list[ElementData],
+ ) -> list[ElementData]:
+ """Insert orphan OCR elements without disturbing layout block order."""
+
+ if not orphan_elements:
+ return elements
+
+ merged_elements = list(elements)
+ for orphan in orphan_elements:
+ insert_at = len(merged_elements)
+ for index, element in enumerate(merged_elements):
+ if self._bbox_precedes_in_reading_order(
+ orphan.bbox_pdf,
+ element.bbox_pdf,
+ ):
+ insert_at = index
+ break
+ merged_elements.insert(insert_at, orphan)
+
+ return merged_elements
+
+ def _bbox_precedes_in_reading_order(
+ self,
+ first_bbox: list[float],
+ second_bbox: list[float],
+ row_overlap_ratio: float = 0.35,
+ ) -> bool:
+ """Return True when the first bbox should be read before the second."""
+
+ first_height = max(1.0, first_bbox[3] - first_bbox[1])
+ second_height = max(1.0, second_bbox[3] - second_bbox[1])
+ row_overlap = max(
+ 0.0,
+ min(first_bbox[3], second_bbox[3]) - max(first_bbox[1], second_bbox[1]),
+ )
+
+ same_row = row_overlap >= min(first_height, second_height) * row_overlap_ratio
+ if same_row:
+ return first_bbox[0] < second_bbox[0]
+
+ first_center_y = (first_bbox[1] + first_bbox[3]) / 2.0
+ second_center_y = (second_bbox[1] + second_bbox[3]) / 2.0
+ return first_center_y < second_center_y
+
+ def _merge_bboxes(self, boxes: list[list[float]]) -> list[float] | None:
+ if not boxes:
+ return None
+
+ return [
+ min(bbox[0] for bbox in boxes),
+ min(bbox[1] for bbox in boxes),
+ max(bbox[2] for bbox in boxes),
+ max(bbox[3] for bbox in boxes),
+ ]
+
+ def _release_batch(self, *objects: Any) -> None:
+ for obj in objects:
+ if obj is None:
+ continue
+ del obj
+
+ gc.collect()
+ if self.hardware.device == "cuda" and torch.cuda.is_available():
+ torch.cuda.empty_cache()
+
+ def _resolve_pdf_path(self, pdf_path: str | Path) -> Path:
+ pdf_path = Path(pdf_path)
+ if not pdf_path.exists():
+ raise FileNotFoundError(f"PDF not found: {pdf_path}")
+ return pdf_path
+
+ def _chunked(self, items: list[Any], size: int) -> Iterable[list[Any]]:
+ for start in range(0, len(items), size):
+ yield items[start : start + size]
diff --git a/pdf2zh/parser/models.py b/pdf2zh/parser/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..805a1962c86bf207eb29e977f7437e6dfabd26b9
--- /dev/null
+++ b/pdf2zh/parser/models.py
@@ -0,0 +1,435 @@
+"""Data models for the scanned PDF pipeline.
+
+This module defines the core dataclasses used throughout Stage A:
+- CellData: Individual table cell with bbox, row/col indices, and text
+- ElementData: A layout element (text block, figure, table, etc.)
+- PageData: A single page with dimensions, elements, and metadata
+- ChapterInfo: Chapter metadata (filled by Stage B)
+- ParsedDocument: The complete parsed document structure
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from PIL import Image
+
+from pdf2zh.parser.enums import ElementCategory
+
+
+@dataclass
+class CellData:
+ """A single cell within a TABLE element.
+
+ Attributes:
+ bbox_pdf: [x0, y0, x1, y1] in absolute PDF points (page-level coordinates)
+ row_id: 0-based row index
+ col_id: 0-based column index
+ source_text: OCR text content; empty string for empty cells
+ translated_text: Empty string after Stage A; filled by Stage C
+ """
+
+ bbox_pdf: list[float]
+ bbox_text: list[float]
+ source_text: str = ""
+ translated_text: str = ""
+
+ def to_dict(self) -> dict[str, Any]:
+ """Serialize this cell to a JSON-compatible dictionary.
+
+ Returns:
+ Dict with keys ``bbox_pdf``, ``bbox_text``, ``source_text``, and ``translated_text``.
+ """
+ return {
+ "bbox_pdf": self.bbox_pdf,
+ "bbox_text": self.bbox_text,
+ "source_text": self.source_text,
+ "translated_text": self.translated_text,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> CellData:
+ """Deserialize a :class:`CellData` from a plain dictionary.
+
+ Args:
+ data: Dictionary as produced by :meth:`to_dict`. ``translated_text``
+ defaults to ``""`` if absent (backward compatibility).
+
+ Returns:
+ New :class:`CellData` instance.
+ """
+ return cls(
+ bbox_pdf=data["bbox_pdf"],
+ bbox_text=data.get("bbox_text", data["bbox_pdf"]),
+ source_text=data["source_text"],
+ translated_text=data.get("translated_text", ""),
+ )
+
+
+@dataclass
+class ElementData:
+ """A layout element detected by Surya.
+
+ Attributes:
+ label: Raw Surya label (e.g., "Text", "Section-header", "Table")
+ category: One of the 5 ElementCategory values determining handling
+ bbox_pdf: [x0, y0, x1, y1] in PDF points; x0 < x1, y0 < y1
+ source_text: OCR text; always "" for BYPASS and optional for EQUATION
+ translated_text: Empty string after Stage A; filled by Stage C
+ cells: Non-empty only for TABLE category; empty list otherwise
+ """
+
+ label: str
+ category: ElementCategory
+ bbox_pdf: list[float]
+ source_text: str
+ translated_text: str = ""
+ cells: list[CellData] = field(default_factory=list)
+
+ def to_dict(self) -> dict[str, Any]:
+ """Serialize this element to a JSON-compatible dictionary."""
+
+ return {
+ "label": self.label,
+ "category": (
+ self.category.value
+ if isinstance(self.category, ElementCategory)
+ else self.category
+ ),
+ "bbox_pdf": self.bbox_pdf,
+ "source_text": self.source_text,
+ "translated_text": self.translated_text,
+ "cells": [c.to_dict() for c in self.cells],
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> ElementData:
+ """Deserialize an :class:`ElementData` from a plain dictionary."""
+ return cls(
+ label=data["label"],
+ category=ElementCategory(data["category"]),
+ bbox_pdf=data["bbox_pdf"],
+ source_text=data["source_text"],
+ translated_text=data.get("translated_text", ""),
+ cells=[CellData.from_dict(c) for c in data.get("cells", [])],
+ )
+
+
+@dataclass
+class PageData:
+ """Data for a single PDF page.
+
+ Attributes:
+ page_index: 0-based page number
+ page_width: Width in PDF points (from page.rect.width)
+ page_height: Height in PDF points (from page.rect.height)
+ elements: Layout elements in top-to-bottom reading order
+ raw_text: Joined source_text of FLOWING_TEXT and IN_PLACE elements
+ chapter_id: Empty string after Stage A; filled by Stage B
+ """
+
+ page_index: int
+ page_width: float
+ page_height: float
+ elements: list[ElementData] = field(default_factory=list)
+ raw_text: str = ""
+ chapter_id: str = ""
+
+ def to_dict(self) -> dict[str, Any]:
+ """Serialize this page to a JSON-compatible dictionary.
+
+ Returns:
+ Dict with keys ``page_index``, ``page_width``, ``page_height``,
+ ``elements``, ``raw_text``, and ``chapter_id``.
+ """
+ return {
+ "page_index": self.page_index,
+ "page_width": self.page_width,
+ "page_height": self.page_height,
+ "elements": [e.to_dict() for e in self.elements],
+ "raw_text": self.raw_text,
+ "chapter_id": self.chapter_id,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> PageData:
+ """Deserialize a :class:`PageData` from a plain dictionary.
+
+ Args:
+ data: Dictionary as produced by :meth:`to_dict`. Optional keys
+ ``elements``, ``raw_text``, and ``chapter_id`` default to
+ ``[]``, ``""``, and ``""`` respectively.
+
+ Returns:
+ New :class:`PageData` instance.
+ """
+ return cls(
+ page_index=data["page_index"],
+ page_width=data["page_width"],
+ page_height=data["page_height"],
+ elements=[ElementData.from_dict(e) for e in data.get("elements", [])],
+ raw_text=data.get("raw_text", ""),
+ chapter_id=data.get("chapter_id", ""),
+ )
+
+
+@dataclass
+class ChapterInfo:
+ """Chapter metadata (empty after Stage A, filled by Stage B).
+
+ Attributes:
+ chapter_id: Identifier like "ch_0", "ch_1", etc.
+ title: Chapter heading text; empty if not found
+ start_page: 0-based inclusive start page
+ end_page: 0-based inclusive end page (end_page >= start_page)
+ summary: LLM-generated summary; empty initially
+ glossary: {term: definition}; empty initially
+ """
+
+ chapter_id: str
+ title: str
+ start_page: int
+ end_page: int
+ summary: str = ""
+ glossary: dict[str, str] = field(default_factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ """Serialize this chapter info to a JSON-compatible dictionary.
+
+ Returns:
+ Dict with keys ``chapter_id``, ``title``, ``start_page``,
+ ``end_page``, ``summary``, and ``glossary``.
+ """
+ return {
+ "chapter_id": self.chapter_id,
+ "title": self.title,
+ "start_page": self.start_page,
+ "end_page": self.end_page,
+ "summary": self.summary,
+ "glossary": self.glossary,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> ChapterInfo:
+ """Deserialize a :class:`ChapterInfo` from a plain dictionary.
+
+ Args:
+ data: Dictionary as produced by :meth:`to_dict`. Optional keys
+ ``summary`` and ``glossary`` default to ``""`` and ``{}``.
+
+ Returns:
+ New :class:`ChapterInfo` instance.
+ """
+ return cls(
+ chapter_id=data["chapter_id"],
+ title=data["title"],
+ start_page=data["start_page"],
+ end_page=data["end_page"],
+ summary=data.get("summary", ""),
+ glossary=data.get("glossary", {}),
+ )
+
+
+@dataclass
+class ParsedDocument:
+ """Complete parsed document from Stage A.
+
+ Attributes:
+ pdf_path: Path to the source PDF file
+ pages: List of PageData, one per page, 0-based order
+ chapters: Empty list after Stage A; filled by Stage B
+ glossary: Empty dict after Stage A; filled by Stage B
+ """
+
+ pdf_path: str
+ pages: list[PageData] = field(default_factory=list)
+ chapters: list[ChapterInfo] = field(default_factory=list)
+ glossary: dict[str, str] = field(default_factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ """Serialize the full document to a JSON-compatible dictionary.
+
+ Returns:
+ Dict with keys ``pdf_path``, ``pages``, ``chapters``,
+ and ``glossary``.
+ """
+ return {
+ "pdf_path": self.pdf_path,
+ "pages": [p.to_dict() for p in self.pages],
+ "chapters": [c.to_dict() for c in self.chapters],
+ "glossary": self.glossary,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> ParsedDocument:
+ """Deserialize a :class:`ParsedDocument` from a plain dictionary.
+
+ Args:
+ data: Dictionary as produced by :meth:`to_dict`. Optional keys
+ ``pages``, ``chapters``, and ``glossary`` default to
+ ``[]``, ``[]``, and ``{}`` respectively.
+
+ Returns:
+ New :class:`ParsedDocument` instance.
+ """
+ return cls(
+ pdf_path=data["pdf_path"],
+ pages=[PageData.from_dict(p) for p in data.get("pages", [])],
+ chapters=[ChapterInfo.from_dict(c) for c in data.get("chapters", [])],
+ glossary=data.get("glossary", {}),
+ )
+
+ def to_json(self, indent: int = 2) -> str:
+ """Serialize the document to a JSON string.
+
+ Args:
+ indent: Number of spaces for JSON indentation (default 2).
+
+ Returns:
+ Pretty-printed JSON string with UTF-8 characters unescaped
+ (``ensure_ascii=False``).
+ """
+ return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
+
+ @classmethod
+ def from_json(cls, json_str: str) -> ParsedDocument:
+ """Deserialize a :class:`ParsedDocument` from a JSON string.
+
+ Args:
+ json_str: JSON string as produced by :meth:`to_json`.
+
+ Returns:
+ New :class:`ParsedDocument` instance.
+ """
+ return cls.from_dict(json.loads(json_str))
+
+ def save(self, path: str | Path) -> None:
+ """Save the document to a JSON file on disk.
+
+ Creates parent directories if they do not exist.
+
+ Args:
+ path: Destination file path (``str`` or :class:`~pathlib.Path`).
+ """
+ path = Path(path)
+ path.write_text(self.to_json(), encoding="utf-8")
+
+ @classmethod
+ def load(cls, path: str | Path) -> ParsedDocument:
+ """Load a :class:`ParsedDocument` from a JSON file on disk.
+
+ Args:
+ path: Source file path (``str`` or :class:`~pathlib.Path`).
+
+ Returns:
+ New :class:`ParsedDocument` instance parsed from the file.
+ """
+ path = Path(path)
+ return cls.from_json(path.read_text(encoding="utf-8"))
+
+
+@dataclass(slots=True)
+class LayoutBlockResult:
+ """A layout block with stable IDs and coordinates in all required spaces."""
+
+ block_id: str
+ page_index: int
+ position: int
+ label: str
+ category: ElementCategory
+ bbox_layout: list[float]
+ bbox_image: list[float]
+ bbox_pdf: list[float]
+
+
+@dataclass(slots=True)
+class LayoutPageResult:
+ """Layout output for one page."""
+
+ page_index: int
+ page_width: float
+ page_height: float
+ layout_image_bbox: list[float]
+ image_bbox: list[float]
+ blocks: list[LayoutBlockResult] = field(default_factory=list)
+
+
+@dataclass(slots=True)
+class LayoutParseResult:
+ """Full layout phase output."""
+
+ pdf_path: str
+ pages: list[LayoutPageResult] = field(default_factory=list)
+
+ def page_map(self) -> dict[int, LayoutPageResult]:
+ return {page.page_index: page for page in self.pages}
+
+ def block_map(self) -> dict[str, LayoutBlockResult]:
+ return {block.block_id: block for page in self.pages for block in page.blocks}
+
+
+@dataclass(slots=True)
+class OCRPageResult:
+ """OCR output for one full page."""
+
+ page_index: int
+ image_bbox: list[float]
+ ocr_result: Any
+
+ @property
+ def image_width(self) -> float:
+ return self.image_bbox[2] - self.image_bbox[0]
+
+ @property
+ def image_height(self) -> float:
+ return self.image_bbox[3] - self.image_bbox[1]
+
+
+@dataclass(slots=True)
+class OCRParseResult:
+ """Full-page OCR phase output."""
+
+ pdf_path: str
+ pages: list[OCRPageResult] = field(default_factory=list)
+
+ def page_map(self) -> dict[int, OCRPageResult]:
+ return {page.page_index: page for page in self.pages}
+
+
+@dataclass(slots=True)
+class TableBlockResult:
+ """Merged table output for one layout table block."""
+
+ block_id: str
+ cells_bbox: list[list[float]]
+ crop_size: tuple[float, float]
+
+
+@dataclass(slots=True)
+class TableParseResult:
+ """Table phase output indexed by layout block id."""
+
+ pdf_path: str
+ tables: dict[str, TableBlockResult] = field(default_factory=dict)
+
+
+@dataclass(slots=True)
+class _DocumentContext:
+ """Immutable page selection and geometry for one PDF parse request."""
+
+ pdf_path: Path
+ page_indices: list[int]
+ page_dims: dict[int, tuple[float, float]]
+
+
+@dataclass(slots=True)
+class _TableJob:
+ """Bookkeeping for one table crop inside a batch."""
+
+ block: LayoutBlockResult
+ page_width: float
+ page_height: float
+ table_crop: Image.Image
diff --git a/pdf2zh/parser/schema.json b/pdf2zh/parser/schema.json
new file mode 100644
index 0000000000000000000000000000000000000000..4c82b9dd8ba4bd87f408fe9bc0f336611ca8c28f
--- /dev/null
+++ b/pdf2zh/parser/schema.json
@@ -0,0 +1,204 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://pdf2zh/scanned/parsed_document.schema.json",
+ "title": "ParsedDocument",
+ "description": "Stage A output - intermediate JSON representation of a scanned PDF.",
+ "type": "object",
+ "required": [
+ "pdf_path",
+ "pages",
+ "chapters",
+ "glossary"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "pdf_path": {
+ "type": "string",
+ "minLength": 1
+ },
+ "pages": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/PageData"
+ }
+ },
+ "chapters": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/ChapterInfo"
+ }
+ },
+ "glossary": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "$defs": {
+ "BboxPdf": {
+ "type": "array",
+ "description": "(x0, y0, x1, y1) in PDF points, top-left origin. x0 None:
+ """Add an error and mark result as invalid."""
+ self.valid = False
+ self.errors.append(ValidationError(path=path, message=message, code=code))
+
+
+def _load_schema() -> dict[str, Any]:
+ """Load the JSON Schema definition from the bundled ``schema.json`` file.
+
+ The schema file lives alongside this module in the same package directory
+ and is used by :func:`validate_stage_output` for structural validation of
+ :class:`~pdf2zh.scanned.models.ParsedDocument` dictionaries.
+
+ Returns:
+ Parsed JSON Schema as a Python dictionary.
+ """
+ schema_path = Path(__file__).parent / "schema.json"
+ return json.loads(schema_path.read_text(encoding="utf-8"))
+
+
+def _is_finite(value: Any) -> bool:
+ """Return True if *value* is a finite number (not NaN or ±Infinity).
+
+ Non-numeric values (e.g. strings, None) are considered valid and return
+ True so that callers can use this as a lightweight numeric guard without
+ performing isinstance checks themselves.
+
+ Args:
+ value: Any Python object to check. Only ``int`` and ``float`` values
+ are tested for NaN / Infinity; all other types pass through.
+
+ Returns:
+ ``True`` if *value* is not numeric, or is a finite numeric value.
+ ``False`` if *value* is ``float('nan')``, ``float('inf')``, or
+ ``float('-inf')``.
+ """
+ if not isinstance(value, (int, float)):
+ return True
+ return not (math.isnan(value) or math.isinf(value))
+
+
+def _check_bbox_valid(bbox: list[float], path: str, result: ValidationResult) -> bool:
+ """Validate basic bbox structural invariants and record any errors.
+
+ Checks that *bbox* is a 4-element list of finite numbers where
+ ``x0 < x1`` and ``y0 < y1``. Errors are appended to *result*;
+ the function returns a boolean so callers can short-circuit further
+ checks that depend on a valid bbox.
+
+ Args:
+ bbox: Candidate bounding box ``[x0, y0, x1, y1]``.
+ path: JSON path string used as the error location (e.g.
+ ``"pages[0].elements[2].bbox_pdf"``).
+ result: Mutable :class:`ValidationResult` that errors are appended to.
+
+ Returns:
+ ``True`` if all invariants pass; ``False`` if any error was recorded.
+ """
+ if not isinstance(bbox, list) or len(bbox) != 4:
+ result.add_error(path, "bbox must be a list of 4 numbers", "BBOX_FORMAT")
+ return False
+
+ for i, v in enumerate(bbox):
+ if not _is_finite(v):
+ result.add_error(
+ path, f"bbox[{i}] contains NaN or Infinity", "BBOX_INVALID_NUMBER"
+ )
+ return False
+
+ x0, y0, x1, y1 = bbox
+ if not (x0 < x1):
+ result.add_error(path, f"bbox x0 ({x0}) must be < x1 ({x1})", "BBOX_X_ORDER")
+ return False
+ if not (y0 < y1):
+ result.add_error(path, f"bbox y0 ({y0}) must be < y1 ({y1})", "BBOX_Y_ORDER")
+ return False
+
+ return True
+
+
+def _check_bbox_within_page(
+ bbox: list[float],
+ page_width: float,
+ page_height: float,
+ path: str,
+ result: ValidationResult,
+) -> None:
+ """Verify that a bbox lies within the page boundaries and record violations.
+
+ A small floating-point tolerance (0.01 pt) is allowed on all sides to
+ accommodate rounding errors introduced by coordinate conversions.
+
+ Args:
+ bbox: Validated ``[x0, y0, x1, y1]`` in PDF points.
+ page_width: Page width in PDF points (maximum allowed x coordinate).
+ page_height: Page height in PDF points (maximum allowed y coordinate).
+ path: JSON path string used as the error location.
+ result: Mutable :class:`ValidationResult` that errors are appended to.
+ """
+ x0, y0, x1, y1 = bbox
+ # Allow small tolerance for floating point issues
+ tolerance = 0.01
+
+ if x0 < -tolerance or y0 < -tolerance:
+ result.add_error(
+ path,
+ f"bbox ({x0}, {y0}, {x1}, {y1}) has negative coordinates",
+ "BBOX_NEGATIVE",
+ )
+ if x1 > page_width + tolerance:
+ result.add_error(
+ path,
+ f"bbox x1 ({x1}) exceeds page_width ({page_width})",
+ "BBOX_EXCEEDS_WIDTH",
+ )
+ if y1 > page_height + tolerance:
+ result.add_error(
+ path,
+ f"bbox y1 ({y1}) exceeds page_height ({page_height})",
+ "BBOX_EXCEEDS_HEIGHT",
+ )
+
+
+def _check_cell_within_table(
+ cell_bbox: list[float],
+ table_bbox: list[float],
+ cell_path: str,
+ result: ValidationResult,
+) -> None:
+ """Verify that a cell bbox is contained within its parent table bbox.
+
+ A small floating-point tolerance (0.01 pt) is applied on all edges to
+ account for rounding during coordinate conversion from image to PDF space.
+
+ Args:
+ cell_bbox: Cell bounding box ``[x0, y0, x1, y1]`` in PDF points.
+ table_bbox: Parent table bounding box ``[x0, y0, x1, y1]`` in PDF points.
+ cell_path: JSON path string for the cell (used in error messages).
+ result: Mutable :class:`ValidationResult` that errors are appended to.
+ """
+ tolerance = 0.01
+ cx0, cy0, cx1, cy1 = cell_bbox
+ tx0, ty0, tx1, ty1 = table_bbox
+
+ if cx0 < tx0 - tolerance or cy0 < ty0 - tolerance:
+ result.add_error(
+ cell_path,
+ f"cell bbox ({cx0}, {cy0}, {cx1}, {cy1}) starts before table bbox ({tx0}, {ty0}, {tx1}, {ty1})",
+ "CELL_OUTSIDE_TABLE",
+ )
+ if cx1 > tx1 + tolerance or cy1 > ty1 + tolerance:
+ result.add_error(
+ cell_path,
+ f"cell bbox ({cx0}, {cy0}, {cx1}, {cy1}) ends after table bbox ({tx0}, {ty0}, {tx1}, {ty1})",
+ "CELL_OUTSIDE_TABLE",
+ )
+
+
+def validate_stage_output(
+ data: dict[str, Any], stage: str = "A", skip_json_schema: bool = False
+) -> ValidationResult:
+ """Validate ParsedDocument output against JSON Schema and runtime invariants.
+
+ Args:
+ data: Dictionary representation of ParsedDocument
+ stage: Current stage ("A", "B", "C", or "D") for stage-aware checks
+ skip_json_schema: If True, skip JSON Schema validation (useful for testing)
+
+ Returns:
+ ValidationResult with valid=True if all checks pass, otherwise errors list
+
+ Runtime invariants checked (not expressible in JSON Schema):
+ 1. bbox_pdf[0] < bbox_pdf[2] (x0 < x1)
+ 2. bbox_pdf[1] < bbox_pdf[3] (y0 < y1)
+ 3. Element bboxes fit within [0, 0, page_width, page_height]
+ 4. category == "BYPASS" -> source_text == ""
+ 5. category == "EQUATION" -> source_text may contain surrounding text
+ 6. category == "TABLE" -> len(cells) > 0
+ 7. category != "TABLE" -> cells == []
+ 8. Cell bboxes contained within parent TABLE bbox
+ 9. (Integration test only) len(pages) matches actual PDF page count
+ 10. chapter_id == "" for all pages after Stage A
+ 11. chapters == [] after Stage A
+ 12. end_page >= start_page for ChapterInfo
+ 13. No NaN or Infinity in numeric fields
+ """
+ result = ValidationResult()
+
+ # JSON Schema validation (optional)
+ if not skip_json_schema:
+ try:
+ import jsonschema
+
+ schema = _load_schema()
+ jsonschema.validate(data, schema)
+ except ImportError:
+ # jsonschema not installed, skip this validation
+ pass
+ except jsonschema.ValidationError as e:
+ result.add_error(
+ ".".join(str(p) for p in e.absolute_path), e.message, "JSON_SCHEMA"
+ )
+ # Continue to check other invariants
+
+ # Check top-level fields
+ if "pdf_path" not in data:
+ result.add_error("pdf_path", "pdf_path is required", "MISSING_FIELD")
+ return result
+
+ pages = data.get("pages", [])
+ chapters = data.get("chapters", [])
+
+ # Invariant 11: chapters == [] after Stage A
+ if stage == "A" and chapters:
+ result.add_error(
+ "chapters", "chapters must be [] after Stage A", "STAGE_A_CHAPTERS"
+ )
+
+ # Check each page
+ for page_idx, page in enumerate(pages):
+ page_path = f"pages[{page_idx}]"
+
+ # Check page dimensions
+ page_width = page.get("page_width", 0)
+ page_height = page.get("page_height", 0)
+
+ if not _is_finite(page_width):
+ result.add_error(
+ f"{page_path}.page_width", "contains NaN or Infinity", "INVALID_NUMBER"
+ )
+ if not _is_finite(page_height):
+ result.add_error(
+ f"{page_path}.page_height", "contains NaN or Infinity", "INVALID_NUMBER"
+ )
+
+ # Invariant 10: chapter_id == "" after Stage A
+ if stage == "A" and page.get("chapter_id", "") != "":
+ result.add_error(
+ f"{page_path}.chapter_id",
+ "chapter_id must be '' after Stage A",
+ "STAGE_A_CHAPTER_ID",
+ )
+
+ # Check each element
+ elements = page.get("elements", [])
+ for elem_idx, elem in enumerate(elements):
+ elem_path = f"{page_path}.elements[{elem_idx}]"
+
+ # Check bbox
+ bbox = elem.get("bbox_pdf", [])
+ bbox_valid = _check_bbox_valid(bbox, f"{elem_path}.bbox_pdf", result)
+
+ # Invariant 3: bbox within page bounds
+ if bbox_valid and page_width > 0 and page_height > 0:
+ _check_bbox_within_page(
+ bbox, page_width, page_height, f"{elem_path}.bbox_pdf", result
+ )
+
+ category = elem.get("category", "")
+ source_text = elem.get("source_text", "")
+ cells = elem.get("cells", [])
+
+ # Invariant 4: BYPASS -> source_text == ""
+ if category == "BYPASS" and source_text != "":
+ result.add_error(
+ f"{elem_path}.source_text",
+ "source_text must be '' for BYPASS category",
+ "BYPASS_TEXT",
+ )
+
+ # Invariant 6: TABLE -> len(cells) > 0
+ if category == "TABLE" and len(cells) == 0:
+ result.add_error(
+ f"{elem_path}.cells",
+ "cells must not be empty for TABLE category",
+ "TABLE_NO_CELLS",
+ )
+
+ # Invariant 7: non-TABLE -> cells == []
+ if category != "TABLE" and len(cells) > 0:
+ result.add_error(
+ f"{elem_path}.cells",
+ "cells must be [] for non-TABLE category",
+ "NON_TABLE_HAS_CELLS",
+ )
+
+ # Check cells
+ for cell_idx, cell in enumerate(cells):
+ cell_path = f"{elem_path}.cells[{cell_idx}]"
+
+ cell_bbox = cell.get("bbox_pdf", [])
+ cell_bbox_valid = _check_bbox_valid(
+ cell_bbox, f"{cell_path}.bbox_pdf", result
+ )
+
+ # Invariant 8: cell bbox within table bbox
+ if cell_bbox_valid and bbox_valid:
+ _check_cell_within_table(cell_bbox, bbox, cell_path, result)
+
+ # Check chapters (for later stages)
+ for ch_idx, chapter in enumerate(chapters):
+ ch_path = f"chapters[{ch_idx}]"
+
+ start_page = chapter.get("start_page", 0)
+ end_page = chapter.get("end_page", 0)
+
+ # Invariant 12: end_page >= start_page
+ if end_page < start_page:
+ result.add_error(
+ ch_path,
+ f"end_page ({end_page}) must be >= start_page ({start_page})",
+ "CHAPTER_PAGE_ORDER",
+ )
+
+ return result
diff --git a/pdf2zh/parser/utils/bbox.py b/pdf2zh/parser/utils/bbox.py
new file mode 100644
index 0000000000000000000000000000000000000000..9e2bf646ae8fbba4b1a9cd9867485d696f5fff76
--- /dev/null
+++ b/pdf2zh/parser/utils/bbox.py
@@ -0,0 +1,324 @@
+"""Bounding box conversion and manipulation utilities.
+
+This module handles coordinate conversion between Surya's model output
+coordinates and fitz's PDF-point coordinates.
+
+Coordinate spaces
+-----------------
+Surya produces three distinct coordinate spaces:
+
+1. **PIL image space** — the raw PIL Image rendered from the PDF (via fitz or
+ embedded image extraction). This is what you pass into the predictor as
+ ``List[Image.Image]``.
+
+2. **image_processor numpy space** — after ``processor.image_processor()``
+ resizes the PIL image to the model's ``max_size`` and converts it to a
+ float32 numpy array. ``LayoutResult.image_bbox`` and
+ ``LayoutBox.polygon`` (and therefore ``LayoutBox.bbox``) live in *this*
+ space.
+
+3. **OCR image space** — Surya's recognition predictor keeps text-line polygons
+ in the *PIL image* pixel space. ``OCRResult.image_bbox`` and
+ ``TextLine.bbox`` are in this space.
+
+4. **PDF point space** — the target coordinate system for ``ElementData.bbox_pdf``.
+ Both axes have top-left origin with Y increasing downward; only scaling is
+ needed (no Y-axis flip).
+
+Key assumptions:
+- All four spaces share top-left origin with Y increasing downward
+- No Y-axis flip is required between any two spaces, only scaling
+- The correct image dimensions to use for layout→PDF scaling come from
+ ``layout_result.image_bbox``, NOT from the PIL image size
+"""
+
+from __future__ import annotations
+
+
+def convert_bbox(
+ surya_bbox: list[float] | tuple[float, ...],
+ image_width: float,
+ image_height: float,
+ pdf_width: float,
+ pdf_height: float,
+ pad_right: float = 0.0, # Thêm padding bên phải (đơn vị: points)
+ pad_bottom: float = 0.0, # Thêm padding bên dưới (đơn vị: points)
+) -> list[float]:
+ if image_width <= 0 or image_height <= 0:
+ raise ValueError(f"Invalid image dimensions: {image_width}x{image_height}")
+
+ sx0, sy0, sx1, sy1 = surya_bbox
+
+ # Scale factors
+ scale_x = pdf_width / image_width
+ scale_y = pdf_height / image_height
+
+ # Convert coordinates và cộng padding trực tiếp vào x1, y1
+ x0 = sx0 * scale_x
+ y0 = sy0 * scale_y
+ x1 = (sx1 * scale_x) + pad_right
+ y1 = (sy1 * scale_y) + pad_bottom
+
+ # Giới hạn tọa độ không vượt quá kích thước trang PDF
+ x1 = min(x1, pdf_width)
+ y1 = min(y1, pdf_height)
+
+ return [x0, y0, x1, y1]
+
+
+def polygon_to_bbox(polygon: list[list[float]]) -> list[float]:
+ """Convert a Surya polygon to an axis-aligned bounding box.
+
+ Surya returns ``PolygonBox`` objects whose ``polygon`` field contains four
+ corners that may be slightly skewed (non-axis-aligned). For coordinate
+ conversion we need the axis-aligned envelope, which is what
+ ``PolygonBox.bbox`` also computes.
+
+ Args:
+ polygon: 4-corner polygon as [[x0,y0],[x1,y1],[x2,y2],[x3,y3]]
+
+ Returns:
+ [x_min, y_min, x_max, y_max] axis-aligned bbox
+ """
+ xs = [p[0] for p in polygon]
+ ys = [p[1] for p in polygon]
+ return [min(xs), min(ys), max(xs), max(ys)]
+
+
+def image_bbox_to_pdf(
+ surya_bbox: list[float] | tuple[float, ...],
+ image_bbox: list[float],
+ pdf_width: float,
+ pdf_height: float,
+ pad_right: float = 0.0, # Thêm padding bên phải (đơn vị: points)
+ pad_bottom: float = 0.0, # Thêm padding bên dưới (đơn vị: points)
+) -> list[float]:
+ """Scale a bbox from Surya's ``result.image_bbox`` space to PDF points.
+
+ Surya's layout model (and other foundation-model-based predictors) internally
+ resizes the input PIL image to a fixed ``max_size`` via ``image_processor``
+ before running inference. The output polygon/bbox coordinates are therefore
+ in that *resized numpy array* space, not in the original PIL image space.
+ ``LayoutResult.image_bbox`` (and ``OCRResult.image_bbox``) records the
+ actual dimensions used: ``[0, 0, W, H]``.
+
+ This function uses those recorded dimensions to compute the correct scale
+ factor, avoiding the off-by-scale bug that occurs when you use the PIL
+ image's ``.size`` instead.
+
+ Args:
+ surya_bbox: [x0, y0, x1, y1] in ``result.image_bbox`` coordinate space
+ image_bbox: Surya result ``image_bbox`` field, e.g. ``[0, 0, 768, 768]``
+ pdf_width: Target PDF page width in points
+ pdf_height: Target PDF page height in points
+
+ Returns:
+ [x0, y0, x1, y1] in PDF points
+ """
+ # image_bbox = [0, 0, image_w, image_h]
+ _, _, iw, ih = image_bbox
+ return convert_bbox(
+ surya_bbox, iw, ih, pdf_width, pdf_height, pad_right, pad_bottom
+ )
+
+
+def clamp_bbox(
+ bbox: list[float],
+ page_width: float,
+ page_height: float,
+) -> list[float]:
+ """Clamp bbox coordinates to page bounds.
+
+ Ensures the bbox fits within [0, 0, page_width, page_height].
+
+ Args:
+ bbox: [x0, y0, x1, y1] in PDF points
+ page_width: Maximum x coordinate
+ page_height: Maximum y coordinate
+
+ Returns:
+ Clamped [x0, y0, x1, y1]
+ """
+ x0, y0, x1, y1 = bbox
+
+ x0 = max(0.0, min(x0, page_width))
+ y0 = max(0.0, min(y0, page_height))
+ x1 = max(0.0, min(x1, page_width))
+ y1 = max(0.0, min(y1, page_height))
+
+ return [x0, y0, x1, y1]
+
+
+def offset_bbox(
+ bbox: list[float],
+ offset_x: float,
+ offset_y: float,
+) -> list[float]:
+ """Apply offset to bbox coordinates.
+
+ Used for converting cell coordinates from table-relative to page-absolute.
+
+ Args:
+ bbox: [x0, y0, x1, y1] in any coordinate space
+ offset_x: X offset to add
+ offset_y: Y offset to add
+
+ Returns:
+ Offset [x0, y0, x1, y1]
+ """
+ x0, y0, x1, y1 = bbox
+ return [
+ x0 + offset_x,
+ y0 + offset_y,
+ x1 + offset_x,
+ y1 + offset_y,
+ ]
+
+
+def is_degenerate(bbox: list[float], min_size: float = 0.1) -> bool:
+ """Check if bbox is degenerate (zero or negative area).
+
+ A bbox is degenerate if:
+ - x0 >= x1 (no width)
+ - y0 >= y1 (no height)
+ - Width or height is less than min_size
+
+ Args:
+ bbox: [x0, y0, x1, y1]
+ min_size: Minimum acceptable dimension
+
+ Returns:
+ True if bbox is degenerate
+ """
+ x0, y0, x1, y1 = bbox
+ width = x1 - x0
+ height = y1 - y0
+
+ return width < min_size or height < min_size
+
+
+def normalize_bbox(bbox: list[float]) -> list[float]:
+ """Ensure bbox has x0 < x1 and y0 < y1 by swapping if needed.
+
+ Args:
+ bbox: [x0, y0, x1, y1] possibly with inverted coordinates
+
+ Returns:
+ Normalized [x0, y0, x1, y1] with x0 <= x1 and y0 <= y1
+ """
+ x0, y0, x1, y1 = bbox
+
+ if x0 > x1:
+ x0, x1 = x1, x0
+ if y0 > y1:
+ y0, y1 = y1, y0
+
+ return [x0, y0, x1, y1]
+
+
+def bbox_area(bbox: list[float]) -> float:
+ """Calculate bbox area.
+
+ Args:
+ bbox: [x0, y0, x1, y1]
+
+ Returns:
+ Area (width * height), or 0 if degenerate
+ """
+ x0, y0, x1, y1 = bbox
+ width = max(0.0, x1 - x0)
+ height = max(0.0, y1 - y0)
+ return width * height
+
+
+def bbox_intersection(
+ bbox1: list[float],
+ bbox2: list[float],
+) -> list[float] | None:
+ """Calculate intersection of two bboxes.
+
+ Args:
+ bbox1: First [x0, y0, x1, y1]
+ bbox2: Second [x0, y0, x1, y1]
+
+ Returns:
+ Intersection bbox, or None if no intersection
+ """
+ x0 = max(bbox1[0], bbox2[0])
+ y0 = max(bbox1[1], bbox2[1])
+ x1 = min(bbox1[2], bbox2[2])
+ y1 = min(bbox1[3], bbox2[3])
+
+ if x0 >= x1 or y0 >= y1:
+ return None
+
+ return [x0, y0, x1, y1]
+
+
+def bbox_iou(bbox1: list[float], bbox2: list[float]) -> float:
+ """Calculate Intersection over Union (IoU) of two bboxes.
+
+ Args:
+ bbox1: First [x0, y0, x1, y1]
+ bbox2: Second [x0, y0, x1, y1]
+
+ Returns:
+ IoU value between 0 and 1
+ """
+ intersection = bbox_intersection(bbox1, bbox2)
+ if intersection is None:
+ return 0.0
+
+ inter_area = bbox_area(intersection)
+ area1 = bbox_area(bbox1)
+ area2 = bbox_area(bbox2)
+
+ union_area = area1 + area2 - inter_area
+ if union_area <= 0:
+ return 0.0
+
+ return inter_area / union_area
+
+
+def bbox_union_area(bboxes: list[list[float]]) -> float:
+ """Calculate the union area of multiple axis-aligned bboxes."""
+
+ valid_bboxes = [bbox for bbox in bboxes if bbox_area(bbox) > 0]
+ if not valid_bboxes:
+ return 0.0
+
+ x_points = sorted(
+ {bbox[0] for bbox in valid_bboxes} | {bbox[2] for bbox in valid_bboxes}
+ )
+ if len(x_points) < 2:
+ return 0.0
+
+ total_area = 0.0
+ for x0, x1 in zip(x_points, x_points[1:]):
+ if x1 <= x0:
+ continue
+
+ y_intervals: list[tuple[float, float]] = []
+ for bbox in valid_bboxes:
+ bx0, by0, bx1, by1 = bbox
+ if bx0 < x1 and bx1 > x0:
+ y_intervals.append((by0, by1))
+
+ if not y_intervals:
+ continue
+
+ y_intervals.sort()
+ covered_height = 0.0
+ current_y0, current_y1 = y_intervals[0]
+ for next_y0, next_y1 in y_intervals[1:]:
+ if next_y0 <= current_y1:
+ current_y1 = max(current_y1, next_y1)
+ continue
+
+ covered_height += current_y1 - current_y0
+ current_y0, current_y1 = next_y0, next_y1
+
+ covered_height += current_y1 - current_y0
+ total_area += (x1 - x0) * covered_height
+
+ return total_area
diff --git a/pdf2zh/parser/utils/block.py b/pdf2zh/parser/utils/block.py
new file mode 100644
index 0000000000000000000000000000000000000000..82f2e4a687c2a23f623a2f6c528f92da79f14a56
--- /dev/null
+++ b/pdf2zh/parser/utils/block.py
@@ -0,0 +1,90 @@
+from typing import Any
+
+from pdf2zh.parser.utils.bbox import polygon_to_bbox
+from pdf2zh.parser.utils.ocr_text import extract_text_for_region, sort_text_lines
+
+
+def is_sparse_text_block(
+ ocr_result: Any,
+ block_bbox: list[float],
+ always_convert: bool,
+) -> tuple[bool, list[Any]]:
+ text_lines = extract_text_for_region(ocr_result, block_bbox)
+
+ if always_convert and len(text_lines) > 1:
+ return True, text_lines
+
+ if len(text_lines) < 2:
+ return False, text_lines
+
+ rows = cluster_text_lines_into_rows(text_lines)
+
+ GAP_MULTIPLIER = 1.0
+
+ for row in rows:
+ if len(row) < 2:
+ continue
+
+ row_boxes = []
+ for line in row:
+ box = get_line_bbox(line)
+ if box is not None:
+ row_boxes.append(box)
+
+ row_boxes = sort_text_lines(row_boxes)
+
+ for i in range(len(row_boxes) - 1):
+ prev_box = row_boxes[i]
+ curr_box = row_boxes[i + 1]
+
+ gap = curr_box[0] - prev_box[2]
+
+ prev_height = prev_box[3] - prev_box[1]
+ curr_height = curr_box[3] - curr_box[1]
+ avg_height = (prev_height + curr_height) / 2.0
+
+ if gap > (avg_height * GAP_MULTIPLIER):
+ return True, text_lines
+
+ return False, text_lines
+
+
+def cluster_text_lines_into_rows(lines: list[Any]) -> list[list[Any]]:
+ _ROW_Y_OVERLAP_RATIO = 0.4
+ rows: list[dict[str, Any]] = []
+
+ for line in lines:
+ line_bbox = get_line_bbox(line)
+ if line_bbox is None:
+ continue
+
+ _, y0, _, y1 = line_bbox
+ placed = False
+ for row in rows:
+ row_y0 = row["y0"]
+ row_y1 = row["y1"]
+ overlap = max(0.0, min(y1, row_y1) - max(y0, row_y0))
+ line_height = max(1.0, y1 - y0)
+ row_height = max(1.0, row_y1 - row_y0)
+ overlap_ratio = overlap / min(line_height, row_height)
+ if overlap_ratio >= _ROW_Y_OVERLAP_RATIO:
+ row["lines"].append(line)
+ row["y0"] = min(row_y0, y0)
+ row["y1"] = max(row_y1, y1)
+ placed = True
+ break
+
+ if not placed:
+ rows.append({"y0": y0, "y1": y1, "lines": [line]})
+
+ rows.sort(key=lambda row: (row["y0"], row["y1"]))
+ return [row["lines"] for row in rows]
+
+
+def get_line_bbox(line: Any) -> list[float] | None:
+ line_bbox = getattr(line, "bbox", None)
+ if line_bbox is not None:
+ return list(line_bbox)
+ if hasattr(line, "polygon"):
+ return polygon_to_bbox(line.polygon)
+ return None
diff --git a/pdf2zh/parser/utils/hardware.py b/pdf2zh/parser/utils/hardware.py
new file mode 100644
index 0000000000000000000000000000000000000000..db1d2ad7ecfbe6e1d002788486d57b65b4c2d164
--- /dev/null
+++ b/pdf2zh/parser/utils/hardware.py
@@ -0,0 +1,180 @@
+"""Hardware-awaresettings configuration."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import logging
+import os
+from dataclasses import asdict, dataclass
+from typing import Literal
+
+logger = logging.getLogger(__name__)
+
+DeviceType = Literal["cuda", "mps", "cpu", "auto"]
+
+_DEFAULT_BATCHES = {
+ "cuda": {
+ "layout": 32,
+ "detection": 32,
+ "recognition": 128,
+ "table": 256,
+ },
+ "mps": {
+ "layout": 4,
+ "detection": 8,
+ "recognition": 64,
+ "table": 64,
+ },
+ "cpu": {
+ "layout": 4,
+ "detection": 8,
+ "recognition": 32,
+ "table": 32,
+ },
+}
+
+
+@dataclass(slots=True)
+class HardwareConfig:
+ """Resolved hardware configuration used to drive Surya settings."""
+
+ device: str
+ page_batch_size: int
+ layout_batch_size: int
+ detection_batch_size: int
+ ocr_batch_size: int
+ table_batch_size: int
+
+
+# Backward-compatible alias for older imports.
+HardwareProfile = HardwareConfig
+
+
+def _detect_device() -> str:
+ """Detect the best torch device available for Surya."""
+
+ try:
+ import torch
+
+ if torch.cuda.is_available():
+ logger.info("CUDA device detected")
+ return "cuda"
+ if torch.backends.mps.is_available():
+ logger.info("MPS device detected")
+ return "mps"
+ except ImportError:
+ logger.warning("PyTorch is unavailable, falling back to CPU")
+
+ logger.info("Using CPU device")
+ return "cpu"
+
+
+def set_torch_device_env(device: str) -> None:
+ """Set the torch device for downstream Surya imports."""
+
+ os.environ["TORCH_DEVICE"] = device
+
+
+def configure_settings(
+ device: DeviceType = "auto",
+ page_batch_size: int | None = None,
+ layout_batch_size: int | None = None,
+ detection_batch_size: int | None = None,
+ ocr_batch_size: int | None = None,
+ table_batch_size: int | None = None,
+) -> HardwareConfig:
+ """Resolve and apply settings using local hardware heuristics."""
+
+ resolved_device = _detect_device() if device == "auto" else device
+
+ resolved_layout_batch = (
+ layout_batch_size
+ if layout_batch_size
+ else _DEFAULT_BATCHES[resolved_device]["layout"]
+ )
+ resolved_detection_batch = (
+ detection_batch_size
+ if detection_batch_size
+ else _DEFAULT_BATCHES[resolved_device]["detection"]
+ )
+ resolved_table_batch = (
+ table_batch_size
+ if table_batch_size
+ else _DEFAULT_BATCHES[resolved_device]["table"]
+ )
+ resolved_ocr_batch = (
+ ocr_batch_size
+ if ocr_batch_size
+ else _DEFAULT_BATCHES[resolved_device]["recognition"]
+ )
+
+ resolved_page_batch = (
+ page_batch_size
+ if page_batch_size
+ else min(resolved_layout_batch, resolved_detection_batch)
+ )
+
+ config = HardwareConfig(
+ device=resolved_device,
+ page_batch_size=resolved_page_batch,
+ layout_batch_size=resolved_layout_batch,
+ detection_batch_size=resolved_detection_batch,
+ ocr_batch_size=resolved_ocr_batch,
+ table_batch_size=resolved_table_batch,
+ )
+
+ logger.info(
+ "Configured settings: device=%s page=%s layout=%s detection=%s "
+ "ocr=%s table=%s",
+ config.device,
+ config.page_batch_size,
+ config.layout_batch_size,
+ config.detection_batch_size,
+ config.ocr_batch_size,
+ config.table_batch_size,
+ )
+ return config
+
+
+def resolve_hardware(
+ device: DeviceType = "auto",
+ ocr_batch_size: int | None = None,
+ **kwargs,
+) -> HardwareConfig:
+ """Backward-compatible wrapper around ``configure_settings``."""
+
+ return configure_settings(
+ device=device,
+ ocr_batch_size=ocr_batch_size,
+ **kwargs,
+ )
+
+
+def main() -> None:
+ """Print a resolved Surya hardware config for local tuning."""
+
+ parser = argparse.ArgumentParser(description="Inspect resolved settings")
+ parser.add_argument(
+ "--device", default="auto", choices=["auto", "cuda", "mps", "cpu"]
+ )
+ parser.add_argument("--page-batch-size", type=int, default=None)
+ parser.add_argument("--layout-batch-size", type=int, default=None)
+ parser.add_argument("--detection-batch-size", type=int, default=None)
+ parser.add_argument("--ocr-batch-size", type=int, default=None)
+ parser.add_argument("--table-batch-size", type=int, default=None)
+ args = parser.parse_args()
+
+ config = configure_settings(
+ device=args.device,
+ page_batch_size=args.page_batch_size,
+ layout_batch_size=args.layout_batch_size,
+ detection_batch_size=args.detection_batch_size,
+ ocr_batch_size=args.ocr_batch_size,
+ table_batch_size=args.table_batch_size,
+ )
+ print(json.dumps(asdict(config), indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pdf2zh/parser/utils/image.py b/pdf2zh/parser/utils/image.py
new file mode 100644
index 0000000000000000000000000000000000000000..8b3faadb5ff1ba70a204402c0c936e0dbfacf50a
--- /dev/null
+++ b/pdf2zh/parser/utils/image.py
@@ -0,0 +1,232 @@
+"""PDF page rendering utilities.
+
+This module provides functions to render PDF pages to PIL Images
+for Surya processing.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING
+
+import fitz # PyMuPDF
+from PIL import Image
+
+if TYPE_CHECKING:
+ pass
+
+logger = logging.getLogger(__name__)
+
+
+def _extract_dominant_image(
+ page: fitz.Page,
+ coverage_threshold: float = 0.8,
+) -> Image.Image | None:
+ """Extract the dominant full-page embedded image, if one exists.
+
+ For scanned PDFs the page content is typically a single high-resolution
+ raster image embedded at 300+ DPI. Re-rendering through fitz at 150 DPI
+ halves the effective resolution and causes Surya to miss fine layout
+ structure. Extracting the raw embedded image preserves the original DPI.
+
+ Args:
+ page: fitz Page object
+ coverage_threshold: Minimum fraction of page area the image must cover
+
+ Returns:
+ PIL Image if a dominant embedded image is found, else None
+ """
+ page_rect = page.rect
+ page_area = page_rect.width * page_rect.height
+ if page_area == 0:
+ return None
+
+ image_list = page.get_images(full=True)
+ best_xref = None
+ best_coverage = 0.0
+
+ for img_info in image_list:
+ xref = img_info[0]
+ try:
+ rects = list(page.get_image_rects(xref))
+ if not rects:
+ continue
+ covered = sum(r.width * r.height for r in rects) / page_area
+ if covered > best_coverage:
+ best_coverage = covered
+ best_xref = xref
+ except Exception:
+ continue
+
+ if best_xref is None or best_coverage < coverage_threshold:
+ return None
+
+ try:
+ pix = fitz.Pixmap(page.parent, best_xref)
+ # Normalize to RGB. pix.n counts all components including alpha.
+ # Grayscale (n=1), Gray+A (n=2), CMYK (n=4 no alpha), CMYK+A (n=5)
+ # all need conversion — only pure RGB (n=3, alpha=0) is already correct.
+ n_colors = pix.n - pix.alpha
+ if n_colors != 3:
+ pix = fitz.Pixmap(fitz.csRGB, pix)
+ mode = "RGBA" if pix.alpha else "RGB"
+ img = Image.frombytes(mode, (pix.width, pix.height), pix.samples)
+ if pix.alpha:
+ img = img.convert("RGB")
+ logger.debug(
+ f"Using embedded image (xref={best_xref}, "
+ f"{pix.width}×{pix.height}px, n_colors={n_colors}, coverage={best_coverage:.0%})"
+ )
+ return img
+ except Exception as e:
+ logger.debug(f"Could not extract embedded image xref={best_xref}: {e}")
+ return None
+
+
+def render_page_to_image(
+ page: fitz.Page,
+ dpi: int = 150,
+) -> Image.Image:
+ """Render a PDF page for OCR — prefers native embedded image.
+
+ Prefers extracting the dominant full-page embedded image at its native
+ resolution. Higher resolution means clearer characters for OCR.
+ Falls back to fitz rendering when no dominant embedded image is found.
+
+ Args:
+ page: fitz Page object to render
+ dpi: Resolution used for fitz fallback rendering (default 150)
+
+ Returns:
+ PIL Image in RGB mode
+ """
+ img = _extract_dominant_image(page)
+ if img is not None:
+ return img
+ return _fitz_render(page, dpi)
+
+
+def render_page_for_layout(
+ page: fitz.Page,
+ dpi: int = 96,
+) -> Image.Image:
+ """Render a PDF page for layout detection — always uses fitz at fixed DPI.
+
+ Surya's layout model (and its sibling detection/reading-order models) is
+ calibrated to receive images rendered at IMAGE_DPI = 96. That is the DPI
+ that Surya itself uses internally (see surya/input/processing.py:
+ get_page_images). Passing images at a much higher DPI (e.g. 150-300)
+ causes the layout model to see the same document content at a larger pixel
+ scale than it was trained on, which results in collapsed or missed layout
+ bounding boxes.
+
+ This function always renders through fitz so that the image scale is
+ predictable regardless of whether embedded raster images exist.
+
+ Args:
+ page: fitz Page object to render
+ dpi: Target DPI for layout rendering (default 96, matching Surya IMAGE_DPI)
+
+ Returns:
+ PIL Image in RGB mode
+ """
+ return _fitz_render(page, dpi)
+
+
+def _fitz_render(page: fitz.Page, dpi: int) -> Image.Image:
+ """Render a PDF page to a PIL Image using fitz at the specified DPI.
+
+ Converts ``dpi`` to a scale factor relative to fitz's default 72 DPI and
+ renders the page without an alpha channel (RGB mode).
+
+ Args:
+ page: fitz Page object to render.
+ dpi: Output resolution in dots per inch. Higher values produce larger
+ images with finer detail but require more memory.
+
+ Returns:
+ PIL Image in ``RGB`` mode at the requested DPI.
+ """
+ zoom = dpi / 72.0
+ matrix = fitz.Matrix(zoom, zoom)
+ pixmap = page.get_pixmap(matrix=matrix, alpha=False)
+ return Image.frombytes("RGB", (pixmap.width, pixmap.height), pixmap.samples)
+
+
+def render_pages_batch(
+ doc: fitz.Document,
+ page_indices: list[int],
+ dpi: int = 150,
+) -> list[Image.Image]:
+ """Render multiple PDF pages to PIL Images.
+
+ Args:
+ doc: fitz Document object
+ page_indices: List of 0-based page indices to render
+ dpi: Resolution in dots per inch
+
+ Returns:
+ List of PIL Images in same order as page_indices
+ """
+ images = []
+ for page_idx in page_indices:
+ page = doc[page_idx]
+ img = render_page_to_image(page, dpi=dpi)
+ images.append(img)
+ return images
+
+
+def get_page_dimensions(page: fitz.Page) -> tuple[float, float]:
+ """Get page dimensions in PDF points.
+
+ Args:
+ page: fitz Page object
+
+ Returns:
+ (width, height) in PDF points
+ """
+ rect = page.rect
+ return rect.width, rect.height
+
+
+def crop_image_to_bbox(
+ image: Image.Image,
+ bbox: list[float],
+ pdf_width: float,
+ pdf_height: float,
+) -> Image.Image:
+ """Crop a rendered image to a bounding box.
+
+ Args:
+ image: PIL Image rendered from PDF page
+ bbox: [x0, y0, x1, y1] in PDF points
+ pdf_width: Original page width in PDF points
+ pdf_height: Original page height in PDF points
+
+ Returns:
+ Cropped PIL Image
+ """
+ img_width, img_height = image.size
+
+ # Calculate scale factors
+ scale_x = img_width / pdf_width
+ scale_y = img_height / pdf_height
+
+ # Convert bbox to image pixels
+ x0 = int(bbox[0] * scale_x)
+ y0 = int(bbox[1] * scale_y)
+ x1 = int(bbox[2] * scale_x)
+ y1 = int(bbox[3] * scale_y)
+
+ # Clamp to image bounds
+ x0 = max(0, min(x0, img_width))
+ y0 = max(0, min(y0, img_height))
+ x1 = max(0, min(x1, img_width))
+ y1 = max(0, min(y1, img_height))
+
+ # Ensure valid crop region
+ if x1 <= x0 or y1 <= y0:
+ # Return a small placeholder image
+ return Image.new("RGB", (1, 1), color=(255, 255, 255))
+
+ return image.crop((x0, y0, x1, y1))
diff --git a/pdf2zh/parser/utils/ocr_text.py b/pdf2zh/parser/utils/ocr_text.py
new file mode 100644
index 0000000000000000000000000000000000000000..f69f6f97a4bd7a019e94c5d800671afab590c8d7
--- /dev/null
+++ b/pdf2zh/parser/utils/ocr_text.py
@@ -0,0 +1,410 @@
+"""OCR text cleaning and extraction utilities.
+
+This module provides functions to clean and process OCR output from Surya,
+including handling common OCR artifacts and extracting text for specific regions.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+import unicodedata
+from typing import Any
+
+from pdf2zh.parser.utils.bbox import bbox_area, bbox_intersection, polygon_to_bbox
+
+logger = logging.getLogger(__name__)
+
+
+def adjust_cell_bbox(
+ matching_cell_lines: list[Any],
+ cell_bbox_pdf: list[float],
+ cell_bbox_image: list[float],
+ padding: float = 0.0,
+) -> list[float]:
+ """
+ Co nhỏ cell_bbox_pdf lại để ôm sát vào phân vùng chứa textlines thực tế,
+ sau đó bổ sung thêm một lượng padding.
+
+ Args:
+ matching_cell_lines: Danh sách các dòng OCR tìm thấy trong ô
+ cell_bbox_pdf: Bounding box của ô ở hệ PDF [x0, y0, x1, y1]
+ cell_bbox_image: Bounding box của ô ở hệ ảnh [x0, y0, x1, y1]
+ padding: Khoảng cách đệm thêm vào các cạnh (đơn vị: points)
+
+ Returns:
+ Bounding box mới hệ PDF [x0, y0, x1, y1] đã được điều chỉnh ôm sát text
+ """
+ if not matching_cell_lines:
+ return cell_bbox_pdf
+
+ text_x0 = float("inf")
+ text_y0 = float("inf")
+ text_x1 = float("-inf")
+ text_y1 = float("-inf")
+
+ for line in matching_cell_lines:
+ line_bbox = _get_ocr_bbox(line)
+ if line_bbox is None:
+ continue
+ text_x0 = min(text_x0, line_bbox[0])
+ text_y0 = min(text_y0, line_bbox[1])
+ text_x1 = max(text_x1, line_bbox[2])
+ text_y1 = max(text_y1, line_bbox[3])
+
+ if text_x0 == float("inf"):
+ return cell_bbox_pdf
+
+ img_w = cell_bbox_image[2] - cell_bbox_image[0]
+ img_h = cell_bbox_image[3] - cell_bbox_image[1]
+ pdf_w = cell_bbox_pdf[2] - cell_bbox_pdf[0]
+ pdf_h = cell_bbox_pdf[3] - cell_bbox_pdf[1]
+
+ scale_x = pdf_w / img_w if img_w > 0 else 1.0
+ scale_y = pdf_h / img_h if img_h > 0 else 1.0
+
+ dx0 = max(0.0, text_x0 - cell_bbox_image[0])
+ dy0 = max(0.0, text_y0 - cell_bbox_image[1])
+ dx1 = max(0.0, cell_bbox_image[2] - text_x1)
+ dy1 = max(0.0, cell_bbox_image[3] - text_y1)
+
+ new_pdf_x0 = cell_bbox_pdf[0] + max(0.0, dx0 * scale_x - padding)
+ new_pdf_y0 = cell_bbox_pdf[1] + max(0.0, dy0 * scale_y - padding)
+ new_pdf_x1 = cell_bbox_pdf[2] - max(0.0, dx1 * scale_x - padding)
+ new_pdf_y1 = cell_bbox_pdf[3] - max(0.0, dy1 * scale_y - padding)
+
+ final_x0 = max(cell_bbox_pdf[0], min(new_pdf_x0, cell_bbox_pdf[2]))
+ final_y0 = max(cell_bbox_pdf[1], min(new_pdf_y0, cell_bbox_pdf[3]))
+ final_x1 = max(final_x0, min(new_pdf_x1, cell_bbox_pdf[2]))
+ final_y1 = max(final_y0, min(new_pdf_y1, cell_bbox_pdf[3]))
+
+ return [final_x0, final_y0, final_x1, final_y1]
+
+
+def clean_ocr_text(text: str) -> str:
+ """Clean OCR text by removing artifacts and normalizing whitespace.
+
+ Processing steps:
+ 1. Normalize Unicode (NFC form)
+ 2. Remove control characters except newlines and tabs
+ 3. Fix common OCR artifacts (ligatures, smart quotes, etc.)
+ 4. Normalize whitespace (collapse multiple spaces, trim lines)
+ 5. Remove empty lines at start/end
+
+ Args:
+ text: Raw OCR text from Surya
+
+ Returns:
+ Cleaned text string
+ """
+ if not text:
+ return ""
+
+ # Step 1: Unicode normalization
+ text = unicodedata.normalize("NFC", text)
+
+ # Step 2: Remove control characters except newlines and tabs
+ cleaned_chars = []
+ for char in text:
+ if char in ("\n", "\t"):
+ cleaned_chars.append(char)
+ elif unicodedata.category(char)[0] != "C":
+ cleaned_chars.append(char)
+ text = "".join(cleaned_chars)
+
+ # # Step 3: Fix common OCR artifacts
+ # # Ligatures
+ # text = text.replace("\ufb01", "fi")
+ # text = text.replace("\ufb02", "fl")
+ # text = text.replace("\ufb00", "ff")
+ # text = text.replace("\ufb03", "ffi")
+ # text = text.replace("\ufb04", "ffl")
+
+ # # Smart quotes to straight quotes
+ # text = text.replace("\u2018", "'") # Left single quote
+ # text = text.replace("\u2019", "'") # Right single quote
+ # text = text.replace("\u201c", '"') # Left double quote
+ # text = text.replace("\u201d", '"') # Right double quote
+
+ # # Dashes
+ # text = text.replace("\u2013", "-") # En dash
+ # text = text.replace("\u2014", "-") # Em dash
+ # text = text.replace("\u2212", "-") # Minus sign
+
+ # # Other common artifacts
+ # text = text.replace("\u00a0", " ") # Non-breaking space
+ # text = text.replace("\u2026", "...") # Ellipsis
+
+ text = text.replace(" ", "\n") # Line break tags
+
+ # Step 4: Normalize whitespace
+
+ # Collapse multiple spaces into one
+ text = re.sub(r" +", " ", text)
+
+ # Trim each line
+ lines = text.split("\n")
+ lines = [line.strip() for line in lines]
+
+ # Step 5: Remove empty lines at start and end
+ while lines and not lines[0]:
+ lines.pop(0)
+ while lines and not lines[-1]:
+ lines.pop()
+
+ return "\n".join(lines)
+
+
+def collect_ocr_text(ocr_result: Any) -> str:
+ """Collect all text lines from an OCR result into a single string.
+
+ Used after crop-then-OCR: the entire OCR result belongs to one layout
+ region, so we simply concatenate all detected text lines.
+
+ Args:
+ ocr_result: Surya OCR result with ``text_lines`` attribute
+
+ Returns:
+ Cleaned concatenated text
+ """
+ if not hasattr(ocr_result, "text_lines"):
+ return ""
+
+ lines = []
+ for line in ocr_result.text_lines:
+ if hasattr(line, "text") and line.text:
+ lines.append(line.text)
+
+ return clean_ocr_text(" ".join(lines))
+
+
+def smart_join_text_lines(lines: list[Any]) -> str:
+ if not lines:
+ return ""
+
+ result = []
+ last_valid_text = "" # Lưu lại văn bản của dòng có chữ gần nhất
+
+ for line in lines:
+ current_text = getattr(line, "text", "").strip()
+
+ if not current_text:
+ continue
+
+ # Nếu là dòng chứa chữ đầu tiên, chỉ cần thêm vào kết quả
+ if not result:
+ result.append(current_text)
+ last_valid_text = current_text
+ continue
+
+ ends_with_punctuation = last_valid_text[-1] in {".", "!", "?"}
+ starts_with_upper = current_text[0].isupper()
+ ends_with_hyphen = last_valid_text.endswith("-")
+
+ if ends_with_hyphen:
+ result.append(current_text)
+ elif not ends_with_punctuation and starts_with_upper:
+ result.append("\n" + current_text)
+ else:
+ result.append(" " + current_text)
+
+ last_valid_text = current_text
+
+ return clean_ocr_text("".join(result))
+
+
+def sort_text_lines(lines: list[Any]) -> list[Any]:
+ """
+ Sort OCR text lines in reading order (top-to-bottom, left-to-right).
+ """
+ if not lines:
+ return []
+
+ first_line = lines[0]
+ if hasattr(first_line, "bbox") and first_line.bbox:
+
+ def get_full_bbox(line):
+ b = line.bbox
+ return b[0], b[1], b[2], b[3]
+
+ elif hasattr(first_line, "polygon"):
+
+ def get_full_bbox(line):
+ poly = line.polygon
+ xs = [p[0] for p in poly]
+ ys = [p[1] for p in poly]
+ return min(xs), min(ys), max(xs), max(ys)
+
+ else:
+ return lines
+
+ boxes = []
+ for line in lines:
+ x_min, y_min, x_max, y_max = get_full_bbox(line)
+ y_center = (y_min + y_max) / 2.0
+
+ boxes.append((y_min, y_center, x_min, y_max, line))
+
+ boxes.sort()
+
+ rows = []
+ current_row = []
+ anchor_y_center = None
+
+ for box in boxes:
+ y_min, y_center, x_min, y_max, line = box
+
+ if not current_row:
+ current_row.append((x_min, line))
+ anchor_y_center = y_center
+ else:
+ if y_min <= anchor_y_center <= y_max:
+ current_row.append((x_min, line))
+ else:
+ rows.append(current_row)
+ current_row = [(x_min, line)]
+ anchor_y_center = y_center
+
+ if current_row:
+ rows.append(current_row)
+
+ sorted_lines = []
+ for row in rows:
+ row.sort()
+ for _, line in row:
+ sorted_lines.append(line)
+
+ return sorted_lines
+
+
+def extract_text_for_region(
+ ocr_result: Any,
+ region_bbox: list[float],
+ overlap_threshold: float = 0.5,
+) -> list[Any]:
+ """Extract OCR text that falls within a region.
+
+ Finds all text lines from the OCR result that overlap significantly
+ with the given region and concatenates them.
+
+ Args:
+ ocr_result: Surya OCR result object with text_lines attribute
+ region_bbox: [x0, y0, x1, y1] in image pixels
+ image_width: Image width for coordinate validation
+ image_height: Image height for coordinate validation
+ overlap_threshold: Minimum overlap ratio to include a line
+
+ Returns:
+ Concatenated text from overlapping lines and estimated font size
+ """
+ if not hasattr(ocr_result, "text_lines"):
+ return []
+
+ matching_lines = _collect_region_matches(
+ getattr(ocr_result, "text_lines", []),
+ region_bbox,
+ overlap_threshold,
+ )
+ return sort_text_lines(matching_lines)
+
+
+def _collect_region_matches(
+ items: list[Any],
+ region_bbox: list[float],
+ overlap_threshold: float,
+) -> list[Any]:
+ matching_items: list[Any] = []
+
+ for item in items:
+ if not hasattr(item, "text"):
+ continue
+
+ item_bbox = _get_ocr_bbox(item)
+ if item_bbox is None:
+ continue
+
+ intersection = bbox_intersection(region_bbox, item_bbox)
+ if intersection is None:
+ continue
+
+ item_area = max(1.0, bbox_area(item_bbox))
+ overlap_ratio = bbox_area(intersection) / item_area
+ if overlap_ratio >= overlap_threshold:
+ matching_items.append(item)
+
+ return matching_items
+
+
+def _get_ocr_bbox(item: Any) -> list[float] | None:
+ item_bbox = getattr(item, "bbox", None)
+ if item_bbox is not None:
+ return list(item_bbox)
+
+ if hasattr(item, "polygon"):
+ return polygon_to_bbox(item.polygon)
+
+ return None
+
+
+def log_toc_hints(elements: list[Any], page_index: int) -> None:
+ """Log potential Table of Contents entries for debugging.
+
+ Looks for Section-header elements that might indicate chapter structure
+ and logs them for manual review during development.
+
+ Args:
+ elements: List of ElementData objects from the page
+ page_index: 0-based page number for logging context
+ """
+ toc_hints = []
+
+ for elem in elements:
+ label = getattr(elem, "label", "")
+
+ # Look for section headers and TOC elements
+ if label in ("Section-header", "Table-of-contents"):
+ text = getattr(elem, "source_text", "")
+ if text:
+ # Truncate long text
+ display_text = text[:80] + "..." if len(text) > 80 else text
+ toc_hints.append(f" [{label}] {display_text}")
+
+ if toc_hints:
+ logger.debug(f"Page {page_index} TOC hints:\n" + "\n".join(toc_hints))
+
+
+def join_raw_text(elements: list[Any]) -> str:
+ """Concatenate ``source_text`` from translatable layout elements.
+
+ Collects source text from every :class:`~pdf2zh.scanned.enums.ElementCategory`
+ that carries translatable content (``FLOWING_TEXT`` and ``IN_PLACE``) and
+ joins them with newlines to form the ``raw_text`` field of
+ :class:`~pdf2zh.scanned.models.PageData`.
+
+ BYPASS, TABLE, and EQUATION categories are intentionally excluded:
+ BYPASS has no text; TABLE text is stored per-cell; EQUATION text is a
+ placeholder handled separately.
+
+ Args:
+ elements: Ordered list of :class:`~pdf2zh.scanned.models.ElementData`
+ objects (or any object with ``category`` and ``source_text``
+ attributes) for a single page.
+
+ Returns:
+ Single string with element texts joined by ``"\n"``,
+ or an empty string if no translatable elements are present.
+ """
+ from pdf2zh.parser.enums import ElementCategory
+
+ text_parts = []
+
+ for elem in elements:
+ category = getattr(elem, "category", None)
+
+ # Only include FLOWING_TEXT and IN_PLACE categories
+ if category in (ElementCategory.FLOWING_TEXT, ElementCategory.IN_PLACE):
+ source_text = getattr(elem, "source_text", "")
+ if source_text:
+ text_parts.append(source_text)
+
+ return "\n".join(text_parts)
diff --git a/pdf2zh/pdf2zh.py b/pdf2zh/pdf2zh.py
new file mode 100644
index 0000000000000000000000000000000000000000..85b138513cad2cd87039b2de0633c2498582fbbf
--- /dev/null
+++ b/pdf2zh/pdf2zh.py
@@ -0,0 +1,476 @@
+#!/usr/bin/env python3
+"""A command line tool for extracting text and images from PDF and
+output it to plain text, html, xml or tags.
+"""
+
+from __future__ import annotations
+
+import argparse
+import logging
+import os
+import sys
+from string import Template
+from typing import List, Optional
+
+from pdf2zh import __version__, log
+
+logger = logging.getLogger(__name__)
+
+
+def create_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__, add_help=True)
+ parser.add_argument(
+ "files",
+ type=str,
+ default=None,
+ nargs="*",
+ help="One or more paths to PDF files.",
+ )
+ parser.add_argument(
+ "--version",
+ "-v",
+ action="version",
+ version=f"pdf2zh v{__version__}",
+ )
+ parser.add_argument(
+ "--debug",
+ "-d",
+ default=False,
+ action="store_true",
+ help="Use debug logging level.",
+ )
+ parse_params = parser.add_argument_group(
+ "Parser",
+ description="Used during PDF parsing",
+ )
+ parse_params.add_argument(
+ "--pages",
+ "-p",
+ type=str,
+ help="The list of page numbers to parse.",
+ )
+ parse_params.add_argument(
+ "--vfont",
+ "-f",
+ type=str,
+ default="",
+ help="The regex to math font name of formula.",
+ )
+ parse_params.add_argument(
+ "--vchar",
+ "-c",
+ type=str,
+ default="",
+ help="The regex to math character of formula.",
+ )
+ parse_params.add_argument(
+ "--lang-in",
+ "-li",
+ type=str,
+ default="en",
+ help="The code of source language.",
+ )
+ parse_params.add_argument(
+ "--lang-out",
+ "-lo",
+ type=str,
+ default="zh",
+ help="The code of target language.",
+ )
+ parse_params.add_argument(
+ "--service",
+ "-s",
+ type=str,
+ default="google",
+ help="The service to use for translation.",
+ )
+ parse_params.add_argument(
+ "--output",
+ "-o",
+ type=str,
+ default="",
+ help="Output directory for files.",
+ )
+ parse_params.add_argument(
+ "--thread",
+ "-t",
+ type=int,
+ default=4,
+ help="The number of threads to execute translation.",
+ )
+ parse_params.add_argument(
+ "--interactive",
+ "-i",
+ action="store_true",
+ help="Interact with GUI.",
+ )
+ parse_params.add_argument(
+ "--share",
+ action="store_true",
+ help="Enable Gradio Share",
+ )
+ parse_params.add_argument(
+ "--flask",
+ action="store_true",
+ help="flask",
+ )
+ parse_params.add_argument(
+ "--celery",
+ action="store_true",
+ help="celery",
+ )
+ parse_params.add_argument(
+ "--authorized",
+ type=str,
+ nargs="+",
+ help="user name and password.",
+ )
+ parse_params.add_argument(
+ "--prompt",
+ type=str,
+ help="user custom prompt.",
+ )
+
+ parse_params.add_argument(
+ "--compatible",
+ "-cp",
+ action="store_true",
+ help="Convert the PDF file into PDF/A format to improve compatibility.",
+ )
+
+ parse_params.add_argument(
+ "--onnx",
+ type=str,
+ help="custom onnx model path.",
+ )
+
+ parse_params.add_argument(
+ "--serverport",
+ type=int,
+ help="custom WebUI port.",
+ )
+
+ parse_params.add_argument(
+ "--dir",
+ action="store_true",
+ help="translate directory.",
+ )
+
+ parse_params.add_argument(
+ "--config",
+ type=str,
+ help="config file.",
+ )
+
+ parse_params.add_argument(
+ "--babeldoc",
+ default=False,
+ action="store_true",
+ help="Use experimental backend babeldoc.",
+ )
+
+ parse_params.add_argument(
+ "--skip-subset-fonts",
+ action="store_true",
+ help="Skip font subsetting. "
+ "This option can improve compatibility "
+ "but will increase the size of the output file.",
+ )
+
+ parse_params.add_argument(
+ "--ignore-cache",
+ action="store_true",
+ help="Ignore cache and force retranslation.",
+ )
+
+ parse_params.add_argument(
+ "--mcp", action="store_true", help="Launch pdf2zh MCP server in STDIO mode"
+ )
+
+ parse_params.add_argument(
+ "--sse", action="store_true", help="Launch pdf2zh MCP server in SSE mode"
+ )
+
+ return parser
+
+
+def parse_args(args: Optional[List[str]]) -> argparse.Namespace:
+ parsed_args = create_parser().parse_args(args=args)
+
+ if parsed_args.pages:
+ pages = []
+ for p in parsed_args.pages.split(","):
+ if "-" in p:
+ start, end = p.split("-")
+ pages.extend(range(int(start) - 1, int(end)))
+ else:
+ pages.append(int(p) - 1)
+ parsed_args.raw_pages = parsed_args.pages
+ parsed_args.pages = pages
+
+ return parsed_args
+
+
+def find_all_files_in_directory(directory_path):
+ """
+ Recursively search all PDF files in the given directory and return their paths as a list.
+
+ :param directory_path: str, the path to the directory to search
+ :return: list of PDF file paths
+ """
+ # Check if the provided path is a directory
+ if not os.path.isdir(directory_path):
+ raise ValueError(f"The provided path '{directory_path}' is not a directory.")
+
+ file_paths = []
+
+ # Walk through the directory recursively
+ for root, _, files in os.walk(directory_path):
+ for file in files:
+ # Check if the file is a PDF
+ if file.lower().endswith(".pdf"):
+ # Append the full file path to the list
+ file_paths.append(os.path.join(root, file))
+
+ return file_paths
+
+
+def main(args: Optional[List[str]] = None) -> int:
+ parsed_args = parse_args(args)
+
+ from rich.logging import RichHandler
+
+ logging.basicConfig(level=logging.INFO, handlers=[RichHandler()])
+
+ # disable httpx, openai, httpcore, http11 logs
+ logging.getLogger("httpx").setLevel("CRITICAL")
+ logging.getLogger("httpx").propagate = False
+ logging.getLogger("openai").setLevel("CRITICAL")
+ logging.getLogger("openai").propagate = False
+ logging.getLogger("httpcore").setLevel("CRITICAL")
+ logging.getLogger("httpcore").propagate = False
+ logging.getLogger("http11").setLevel("CRITICAL")
+ logging.getLogger("http11").propagate = False
+
+ if parsed_args.config:
+ from pdf2zh.config import ConfigManager
+
+ ConfigManager.custome_config(parsed_args.config)
+
+ if parsed_args.debug:
+ log.setLevel(logging.DEBUG)
+
+ if parsed_args.onnx:
+ from pdf2zh.doclayout import ModelInstance, OnnxModel
+
+ ModelInstance.value = OnnxModel(parsed_args.onnx)
+ else:
+ from pdf2zh.doclayout import ModelInstance, OnnxModel
+
+ ModelInstance.value = OnnxModel.load_available()
+
+ if parsed_args.interactive:
+ from pdf2zh.gui import setup_gui
+
+ if parsed_args.serverport:
+ setup_gui(
+ parsed_args.share, parsed_args.authorized, int(parsed_args.serverport)
+ )
+ else:
+ setup_gui(parsed_args.share, parsed_args.authorized)
+ return 0
+
+ if parsed_args.flask:
+ from pdf2zh.backend import flask_app
+
+ flask_app.run(port=11008)
+ return 0
+
+ if parsed_args.celery:
+ from pdf2zh.backend import celery_app
+
+ celery_app.start(argv=sys.argv[2:])
+ return 0
+
+ if parsed_args.prompt:
+ try:
+ with open(parsed_args.prompt, "r", encoding="utf-8") as file:
+ content = file.read()
+ parsed_args.prompt = Template(content)
+ except Exception:
+ raise ValueError("prompt error.")
+
+ if parsed_args.mcp:
+ logging.getLogger("mcp").setLevel(logging.ERROR)
+ from pdf2zh.mcp_server import create_mcp_app, create_starlette_app
+
+ mcp = create_mcp_app()
+ if parsed_args.sse:
+ import uvicorn
+
+ starlette_app = create_starlette_app(mcp._mcp_server)
+ uvicorn.run(starlette_app)
+ return 0
+ mcp.run()
+ return 0
+
+ print(parsed_args)
+ if parsed_args.babeldoc:
+ return yadt_main(parsed_args)
+ if parsed_args.dir:
+ untranlate_file = find_all_files_in_directory(parsed_args.files[0])
+ parsed_args.files = untranlate_file
+ from pdf2zh.high_level import translate
+
+ translate(model=ModelInstance.value, **vars(parsed_args))
+ return 0
+
+ from pdf2zh.high_level import translate
+
+ translate(model=ModelInstance.value, **vars(parsed_args))
+ return 0
+
+
+def yadt_main(parsed_args) -> int:
+ from babeldoc.high_level import async_translate as yadt_translate
+ from babeldoc.high_level import init as yadt_init
+ from babeldoc.main import create_progress_handler
+ from babeldoc.translation_config import TranslationConfig as YadtConfig
+
+ from pdf2zh.high_level import download_remote_fonts
+
+ if parsed_args.dir:
+ untranlate_file = find_all_files_in_directory(parsed_args.files[0])
+ else:
+ untranlate_file = parsed_args.files
+ lang_in = parsed_args.lang_in
+ lang_out = parsed_args.lang_out
+ ignore_cache = parsed_args.ignore_cache
+ outputdir = None
+ if parsed_args.output:
+ outputdir = parsed_args.output
+
+ # yadt require init before translate
+ yadt_init()
+ font_path = download_remote_fonts(lang_out.lower())
+
+ param = parsed_args.service.split(":", 1)
+ service_name = param[0]
+ service_model = param[1] if len(param) > 1 else None
+
+ envs = {}
+ prompt = []
+
+ if parsed_args.prompt:
+ try:
+ with open(parsed_args.prompt, "r", encoding="utf-8") as file:
+ content = file.read()
+ prompt = Template(content)
+ except Exception:
+ raise ValueError("prompt error.")
+
+ from pdf2zh.translator import (
+ AnythingLLMTranslator,
+ ArgosTranslator,
+ AzureOpenAITranslator,
+ AzureTranslator,
+ BingTranslator,
+ DeepLTranslator,
+ DeepLXTranslator,
+ DeepseekTranslator,
+ DifyTranslator,
+ GeminiTranslator,
+ GoogleTranslator,
+ GrokTranslator,
+ GroqTranslator,
+ ModelScopeTranslator,
+ OllamaTranslator,
+ OpenAIlikedTranslator,
+ OpenAITranslator,
+ QwenMtTranslator,
+ SiliconTranslator,
+ TencentTranslator,
+ X302AITranslator,
+ XinferenceTranslator,
+ ZhipuTranslator,
+ )
+
+ for translator in [
+ GoogleTranslator,
+ BingTranslator,
+ DeepLTranslator,
+ DeepLXTranslator,
+ OllamaTranslator,
+ XinferenceTranslator,
+ AzureOpenAITranslator,
+ OpenAITranslator,
+ ZhipuTranslator,
+ ModelScopeTranslator,
+ SiliconTranslator,
+ GeminiTranslator,
+ AzureTranslator,
+ TencentTranslator,
+ DifyTranslator,
+ AnythingLLMTranslator,
+ ArgosTranslator,
+ GrokTranslator,
+ GroqTranslator,
+ DeepseekTranslator,
+ OpenAIlikedTranslator,
+ QwenMtTranslator,
+ X302AITranslator,
+ ]:
+ if service_name == translator.name:
+ translator = translator(
+ lang_in,
+ lang_out,
+ service_model,
+ envs=envs,
+ prompt=prompt,
+ ignore_cache=ignore_cache,
+ )
+ break
+ else:
+ raise ValueError("Unsupported translation service")
+ import asyncio
+
+ for file in untranlate_file:
+ file = file.strip("\"'")
+ yadt_config = YadtConfig(
+ input_file=file,
+ font=font_path,
+ pages=",".join((str(x) for x in getattr(parsed_args, "raw_pages", []))),
+ output_dir=outputdir,
+ doc_layout_model=None,
+ translator=translator,
+ debug=parsed_args.debug,
+ lang_in=lang_in,
+ lang_out=lang_out,
+ no_dual=False,
+ no_mono=False,
+ qps=parsed_args.thread,
+ )
+
+ async def yadt_translate_coro(yadt_config):
+ progress_context, progress_handler = create_progress_handler(yadt_config)
+ # 开始翻译
+ with progress_context:
+ async for event in yadt_translate(yadt_config):
+ progress_handler(event)
+ if yadt_config.debug:
+ logger.debug(event)
+ if event["type"] == "finish":
+ result = event["translate_result"]
+ logger.info("Translation Result:")
+ logger.info(f" Original PDF: {result.original_pdf_path}")
+ logger.info(f" Time Cost: {result.total_seconds:.2f}s")
+ logger.info(f" Mono PDF: {result.mono_pdf_path or 'None'}")
+ logger.info(f" Dual PDF: {result.dual_pdf_path or 'None'}")
+ break
+
+ asyncio.run(yadt_translate_coro(yadt_config))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/pdf2zh/pdfinterp.py b/pdf2zh/pdfinterp.py
new file mode 100644
index 0000000000000000000000000000000000000000..b615d113e6c0965b4b9c60a297036ca8b1cb9ef6
--- /dev/null
+++ b/pdf2zh/pdfinterp.py
@@ -0,0 +1,366 @@
+import logging
+from typing import Any, Dict, Optional, Sequence, Tuple, cast
+
+import numpy as np
+from pdfminer import settings
+from pdfminer.pdfcolor import PREDEFINED_COLORSPACE, PDFColorSpace
+from pdfminer.pdfdevice import PDFDevice
+from pdfminer.pdffont import PDFFont
+from pdfminer.pdfinterp import (
+ LITERAL_FORM,
+ LITERAL_IMAGE,
+ Color,
+ PDFContentParser,
+ PDFInterpreterError,
+ PDFPageInterpreter,
+ PDFResourceManager,
+ PDFStackT,
+)
+from pdfminer.pdfpage import PDFPage
+from pdfminer.pdftypes import (
+ PDFObjRef,
+ dict_value,
+ list_value,
+ resolve1,
+ stream_value,
+)
+from pdfminer.psexceptions import PSEOF
+from pdfminer.psparser import (
+ PSKeyword,
+ keyword_name,
+ literal_name,
+)
+from pdfminer.utils import (
+ MATRIX_IDENTITY,
+ Matrix,
+ Rect,
+ apply_matrix_pt,
+ mult_matrix,
+)
+
+log = logging.getLogger(__name__)
+
+
+def safe_float(o: Any) -> Optional[float]:
+ try:
+ return float(o)
+ except (TypeError, ValueError):
+ return None
+
+
+class PDFPageInterpreterEx(PDFPageInterpreter):
+ """Processor for the content of a PDF page
+
+ Reference: PDF Reference, Appendix A, Operator Summary
+ """
+
+ def __init__(
+ self, rsrcmgr: PDFResourceManager, device: PDFDevice, obj_patch
+ ) -> None:
+ self.rsrcmgr = rsrcmgr
+ self.device = device
+ self.obj_patch = obj_patch
+
+ def dup(self) -> "PDFPageInterpreterEx":
+ return self.__class__(self.rsrcmgr, self.device, self.obj_patch)
+
+ def init_resources(self, resources: Dict[object, object]) -> None:
+ # 重载设置 fontid 和 descent
+ """Prepare the fonts and XObjects listed in the Resource attribute."""
+ self.resources = resources
+ self.fontmap: Dict[object, PDFFont] = {}
+ self.fontid: Dict[PDFFont, object] = {}
+ self.xobjmap = {}
+ self.csmap: Dict[str, PDFColorSpace] = PREDEFINED_COLORSPACE.copy()
+ if not resources:
+ return
+
+ def get_colorspace(spec: object) -> Optional[PDFColorSpace]:
+ if isinstance(spec, list):
+ name = literal_name(spec[0])
+ else:
+ name = literal_name(spec)
+ if name == "ICCBased" and isinstance(spec, list) and len(spec) >= 2:
+ return PDFColorSpace(name, stream_value(spec[1])["N"])
+ elif name == "DeviceN" and isinstance(spec, list) and len(spec) >= 2:
+ return PDFColorSpace(name, len(list_value(spec[1])))
+ else:
+ return PREDEFINED_COLORSPACE.get(name)
+
+ for k, v in dict_value(resources).items():
+ # log.debug("Resource: %r: %r", k, v)
+ if k == "Font":
+ for fontid, spec in dict_value(v).items():
+ objid = None
+ if isinstance(spec, PDFObjRef):
+ objid = spec.objid
+ spec = dict_value(spec)
+ self.fontmap[fontid] = self.rsrcmgr.get_font(objid, spec)
+ self.fontmap[fontid].descent = 0 # hack fix descent
+ self.fontid[self.fontmap[fontid]] = fontid
+ elif k == "ColorSpace":
+ for csid, spec in dict_value(v).items():
+ colorspace = get_colorspace(resolve1(spec))
+ if colorspace is not None:
+ self.csmap[csid] = colorspace
+ elif k == "ProcSet":
+ self.rsrcmgr.get_procset(list_value(v))
+ elif k == "XObject":
+ for xobjid, xobjstrm in dict_value(v).items():
+ self.xobjmap[xobjid] = xobjstrm
+
+ def do_S(self) -> None:
+ # 重载过滤非公式线条
+ """Stroke path"""
+
+ def is_black(color: Color) -> bool:
+ if isinstance(color, Tuple):
+ return sum(color) == 0
+ else:
+ return color == 0
+
+ if (
+ len(self.curpath) == 2
+ and self.curpath[0][0] == "m"
+ and self.curpath[1][0] == "l"
+ and apply_matrix_pt(self.ctm, self.curpath[0][-2:])[1]
+ == apply_matrix_pt(self.ctm, self.curpath[1][-2:])[1]
+ and is_black(self.graphicstate.scolor)
+ ): # 独立直线,水平,黑色
+ # print(apply_matrix_pt(self.ctm,self.curpath[0][-2:]),apply_matrix_pt(self.ctm,self.curpath[1][-2:]),self.graphicstate.scolor)
+ self.device.paint_path(self.graphicstate, True, False, False, self.curpath)
+ self.curpath = []
+ return "n"
+ else:
+ self.curpath = []
+
+ ############################################################
+ # 重载过滤非公式线条(F/B)
+ def do_f(self) -> None:
+ """Fill path using nonzero winding number rule"""
+ # self.device.paint_path(self.graphicstate, False, True, False, self.curpath)
+ self.curpath = []
+
+ def do_F(self) -> None:
+ """Fill path using nonzero winding number rule (obsolete)"""
+
+ def do_f_a(self) -> None:
+ """Fill path using even-odd rule"""
+ # self.device.paint_path(self.graphicstate, False, True, True, self.curpath)
+ self.curpath = []
+
+ def do_B(self) -> None:
+ """Fill and stroke path using nonzero winding number rule"""
+ # self.device.paint_path(self.graphicstate, True, True, False, self.curpath)
+ self.curpath = []
+
+ def do_B_a(self) -> None:
+ """Fill and stroke path using even-odd rule"""
+ # self.device.paint_path(self.graphicstate, True, True, True, self.curpath)
+ self.curpath = []
+
+ ############################################################
+ # 重载返回调用参数(SCN)
+ def do_SCN(self) -> None:
+ """Set color for stroking operations."""
+ if self.scs:
+ n = self.scs.ncomponents
+ else:
+ if settings.STRICT:
+ raise PDFInterpreterError("No colorspace specified!")
+ n = 1
+ args = self.pop(n)
+ self.graphicstate.scolor = cast(Color, args)
+ return args
+
+ def do_scn(self) -> None:
+ """Set color for nonstroking operations"""
+ if self.ncs:
+ n = self.ncs.ncomponents
+ else:
+ if settings.STRICT:
+ raise PDFInterpreterError("No colorspace specified!")
+ n = 1
+ args = self.pop(n)
+ self.graphicstate.ncolor = cast(Color, args)
+ return args
+
+ def do_SC(self) -> None:
+ """Set color for stroking operations"""
+ return self.do_SCN()
+
+ def do_sc(self) -> None:
+ """Set color for nonstroking operations"""
+ return self.do_scn()
+
+ def do_Do(self, xobjid_arg: PDFStackT) -> None:
+ # 重载设置 xobj 的 obj_patch
+ """Invoke named XObject"""
+ xobjid = literal_name(xobjid_arg)
+ try:
+ xobj = stream_value(self.xobjmap[xobjid])
+ except KeyError:
+ if settings.STRICT:
+ raise PDFInterpreterError("Undefined xobject id: %r" % xobjid)
+ return
+ # log.debug("Processing xobj: %r", xobj)
+ subtype = xobj.get("Subtype")
+ if subtype is LITERAL_FORM and "BBox" in xobj:
+ interpreter = self.dup()
+ bbox = cast(Rect, list_value(xobj["BBox"]))
+ matrix = cast(Matrix, list_value(xobj.get("Matrix", MATRIX_IDENTITY)))
+ # According to PDF reference 1.7 section 4.9.1, XObjects in
+ # earlier PDFs (prior to v1.2) use the page's Resources entry
+ # instead of having their own Resources entry.
+ xobjres = xobj.get("Resources")
+ if xobjres:
+ resources = dict_value(xobjres)
+ else:
+ resources = self.resources.copy()
+ self.device.begin_figure(xobjid, bbox, matrix)
+ ctm = mult_matrix(matrix, self.ctm)
+ ops_base = interpreter.render_contents(
+ resources,
+ [xobj],
+ ctm=ctm,
+ )
+ self.ncs = interpreter.ncs
+ self.scs = interpreter.scs
+ try: # 有的时候 form 字体加不上这里会烂掉
+ self.device.fontid = interpreter.fontid
+ self.device.fontmap = interpreter.fontmap
+ ops_new = self.device.end_figure(xobjid)
+ ctm_inv = np.linalg.inv(np.array(ctm[:4]).reshape(2, 2))
+ np_version = np.__version__
+ if np_version.split(".")[0] >= "2":
+ pos_inv = -np.asmatrix(ctm[4:]) * ctm_inv
+ else:
+ pos_inv = -np.mat(ctm[4:]) * ctm_inv
+ a, b, c, d = ctm_inv.reshape(4).tolist()
+ e, f = pos_inv.tolist()[0]
+ self.obj_patch[self.xobjmap[xobjid].objid] = (
+ f"q {ops_base}Q {a} {b} {c} {d} {e} {f} cm {ops_new}"
+ )
+ except Exception:
+ pass
+ elif subtype is LITERAL_IMAGE and "Width" in xobj and "Height" in xobj:
+ self.device.begin_figure(xobjid, (0, 0, 1, 1), MATRIX_IDENTITY)
+ self.device.render_image(xobjid, xobj)
+ self.device.end_figure(xobjid)
+ else:
+ # unsupported xobject type.
+ pass
+
+ def process_page(self, page: PDFPage) -> None:
+ # 重载设置 page 的 obj_patch
+ # log.debug("Processing page: %r", page)
+ # print(page.mediabox,page.cropbox)
+ # (x0, y0, x1, y1) = page.mediabox
+ x0, y0, x1, y1 = page.cropbox
+ if page.rotate == 90:
+ ctm = (0, -1, 1, 0, -y0, x1)
+ elif page.rotate == 180:
+ ctm = (-1, 0, 0, -1, x1, y1)
+ elif page.rotate == 270:
+ ctm = (0, 1, -1, 0, y1, -x0)
+ else:
+ ctm = (1, 0, 0, 1, -x0, -y0)
+ self.device.begin_page(page, ctm)
+ ops_base = self.render_contents(page.resources, page.contents, ctm=ctm)
+ self.device.fontid = self.fontid
+ self.device.fontmap = self.fontmap
+ ops_new = self.device.end_page(page)
+ # 上面渲染的时候会根据 cropbox 减掉页面偏移得到真实坐标,这里输出的时候需要用 cm 把页面偏移加回来
+ self.obj_patch[page.page_xref] = (
+ f"q {ops_base}Q 1 0 0 1 {x0} {y0} cm {ops_new}" # ops_base 里可能有图,需要让 ops_new 里的文字覆盖在上面,使用 q/Q 重置位置矩阵
+ )
+ for obj in page.contents:
+ self.obj_patch[obj.objid] = ""
+
+ def render_contents(
+ self,
+ resources: Dict[object, object],
+ streams: Sequence[object],
+ ctm: Matrix = MATRIX_IDENTITY,
+ ) -> None:
+ # 重载返回指令流
+ """Render the content streams.
+
+ This method may be called recursively.
+ """
+ # log.debug(
+ # "render_contents: resources=%r, streams=%r, ctm=%r",
+ # resources,
+ # streams,
+ # ctm,
+ # )
+ self.init_resources(resources)
+ self.init_state(ctm)
+ return self.execute(list_value(streams))
+
+ def execute(self, streams: Sequence[object]) -> None:
+ # 重载返回指令流
+ ops = ""
+ try:
+ parser = PDFContentParser(streams)
+ except PSEOF:
+ # empty page
+ return
+ while True:
+ try:
+ _, obj = parser.nextobject()
+ except PSEOF:
+ break
+ if isinstance(obj, PSKeyword):
+ name = keyword_name(obj)
+ method = "do_%s" % name.replace("*", "_a").replace('"', "_w").replace(
+ "'",
+ "_q",
+ )
+ if hasattr(self, method):
+ func = getattr(self, method)
+ nargs = func.__code__.co_argcount - 1
+ if nargs:
+ args = self.pop(nargs)
+ # log.debug("exec: %s %r", name, args)
+ if len(args) == nargs:
+ func(*args)
+ if not (
+ name[0] == "T"
+ or name in ['"', "'", "EI", "MP", "DP", "BMC", "BDC"]
+ ): # 过滤 T 系列文字指令,因为 EI 的参数是 obj 所以也需要过滤(只在少数文档中画横线时使用),过滤 marked 系列指令
+ p = " ".join(
+ [
+ (
+ f"{x:f}"
+ if isinstance(x, float)
+ else str(x).replace("'", "")
+ )
+ for x in args
+ ]
+ )
+ ops += f"{p} {name} "
+ else:
+ # log.debug("exec: %s", name)
+ targs = func()
+ if targs is None:
+ targs = []
+ if not (name[0] == "T" or name in ["BI", "ID", "EMC"]):
+ p = " ".join(
+ [
+ (
+ f"{x:f}"
+ if isinstance(x, float)
+ else str(x).replace("'", "")
+ )
+ for x in targs
+ ]
+ )
+ ops += f"{p} {name} "
+ elif settings.STRICT:
+ error_msg = "Unknown operator: %r" % name
+ raise PDFInterpreterError(error_msg)
+ else:
+ self.push(obj)
+ # print('REV DATA',ops)
+ return ops
diff --git a/pdf2zh/render/__init__.py b/pdf2zh/render/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..f03e5eefffba5381619cc8831bbd5e3eb8695380
--- /dev/null
+++ b/pdf2zh/render/__init__.py
@@ -0,0 +1,4 @@
+from .config import RenderConfig
+from .renderer import render_document
+
+__all__ = ["RenderConfig", "render_document"]
diff --git a/pdf2zh/render/__main__.py b/pdf2zh/render/__main__.py
new file mode 100644
index 0000000000000000000000000000000000000000..4e28416e104515e90fca4b69cc60d0c61fd15d61
--- /dev/null
+++ b/pdf2zh/render/__main__.py
@@ -0,0 +1,3 @@
+from .cli import main
+
+main()
diff --git a/pdf2zh/render/background.py b/pdf2zh/render/background.py
new file mode 100644
index 0000000000000000000000000000000000000000..5adfe2cda8c26b9d6a6c17e51f2142ed3d850e70
--- /dev/null
+++ b/pdf2zh/render/background.py
@@ -0,0 +1,172 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+import fitz
+import numpy as np
+
+from .config import BackgroundConfig, TextColorConfig
+
+if TYPE_CHECKING:
+ pass
+
+RGB = tuple[int, int, int]
+
+
+@dataclass
+class CoverPlan:
+ kind: str # "flat" or "strip"
+ rgb: RGB # used when kind == "flat"
+ pixmap: fitz.Pixmap | None = None # used when kind == "strip"
+
+
+# ---------------------------------------------------------------------------
+# Background sampling
+# ---------------------------------------------------------------------------
+
+
+def prepare_cover(
+ page: fitz.Page,
+ bbox_pdf: list[float],
+ page_width: float,
+ page_height: float,
+ cfg: BackgroundConfig,
+) -> CoverPlan:
+ if not cfg.enabled:
+ return CoverPlan(kind="flat", rgb=cfg.fallback_bg)
+
+ try:
+ rgb = _sample_donut_median(page, bbox_pdf, page_width, page_height, cfg)
+ return CoverPlan(kind="flat", rgb=rgb)
+ except Exception:
+ return CoverPlan(kind="flat", rgb=cfg.fallback_bg)
+
+
+def _sample_donut_median(
+ page: fitz.Page,
+ bbox_pdf: list[float],
+ page_width: float,
+ page_height: float,
+ cfg: BackgroundConfig,
+) -> RGB:
+ margin = cfg.sample_margin_pt
+ x0, y0, x1, y1 = bbox_pdf
+ outer = fitz.Rect(
+ max(0.0, x0 - margin),
+ max(0.0, y0 - margin),
+ min(page_width, x1 + margin),
+ min(page_height, y1 + margin),
+ )
+ if outer.is_empty:
+ return cfg.fallback_bg
+
+ mat = fitz.Matrix(cfg.dpi_scale, cfg.dpi_scale)
+ pm = page.get_pixmap(matrix=mat, clip=outer, colorspace=fitz.csRGB, alpha=False)
+ arr = np.frombuffer(pm.samples, dtype=np.uint8).reshape(pm.height, pm.width, 3)
+
+ # Build donut mask: True for pixels OUTSIDE the inner bbox (donut band)
+ sx = pm.width / outer.width
+ sy = pm.height / outer.height
+ inner_x0 = int((x0 - outer.x0) * sx)
+ inner_y0 = int((y0 - outer.y0) * sy)
+ inner_x1 = int((x1 - outer.x0) * sx)
+ inner_y1 = int((y1 - outer.y0) * sy)
+
+ mask = np.ones((pm.height, pm.width), dtype=bool)
+ mask[
+ max(0, inner_y0) : min(pm.height, inner_y1),
+ max(0, inner_x0) : min(pm.width, inner_x1),
+ ] = False
+
+ donut_pixels = arr[mask].reshape(-1, 3)
+ if len(donut_pixels) < cfg.min_sample_pixels:
+ return cfg.fallback_bg
+
+ if _is_text_contaminated(donut_pixels, cfg):
+ return _trimmed_robust(donut_pixels, cfg)
+
+ brightness_spread = int(donut_pixels.max()) - int(donut_pixels.min())
+ if brightness_spread > cfg.complexity_brightness_spread:
+ return _trimmed_robust(donut_pixels, cfg)
+
+ r = int(np.median(donut_pixels[:, 0]))
+ g = int(np.median(donut_pixels[:, 1]))
+ b = int(np.median(donut_pixels[:, 2]))
+ return (r, g, b)
+
+
+def _trimmed_robust(pixels: np.ndarray, cfg: BackgroundConfig) -> RGB:
+ """Drop darkest 20% (likely text bleed), then per-channel median."""
+ brightness = pixels.mean(axis=1)
+ threshold = np.percentile(brightness, 20)
+ keep = pixels[brightness >= threshold]
+ if len(keep) == 0:
+ keep = pixels
+ r = int(np.median(keep[:, 0]))
+ g = int(np.median(keep[:, 1]))
+ b = int(np.median(keep[:, 2]))
+ return (r, g, b)
+
+
+def _is_text_contaminated(pixels: np.ndarray, cfg: BackgroundConfig) -> bool:
+ """Return True if pixels look light overall but have too many dark pixels (text bleed)."""
+ median_val = float(np.median(pixels))
+ if median_val < 245:
+ return False
+ dark_ratio = float((pixels < cfg.text_contamination_dark_value).any(axis=1).mean())
+ return dark_ratio > cfg.text_contamination_dark_ratio
+
+
+# ---------------------------------------------------------------------------
+# Text color sampling
+# ---------------------------------------------------------------------------
+
+
+def sample_text_color(
+ page: fitz.Page,
+ bbox_pdf: list[float],
+ page_width: float,
+ page_height: float,
+ bg: RGB,
+ cfg: TextColorConfig,
+) -> RGB:
+ if not cfg.enabled:
+ return cfg.fallback
+
+ try:
+ x0, y0, x1, y1 = bbox_pdf
+ w = x1 - x0
+ h = y1 - y0
+ cx0 = x0 + w * (1 - cfg.center_fraction) / 2
+ cy0 = y0 + h * (1 - cfg.center_fraction) / 2
+ cx1 = x0 + w * (1 + cfg.center_fraction) / 2
+ cy1 = y0 + h * (1 + cfg.center_fraction) / 2
+ inner = fitz.Rect(cx0, cy0, cx1, cy1)
+ if inner.is_empty:
+ return cfg.fallback
+
+ pm = page.get_pixmap(
+ matrix=fitz.Matrix(2, 2), clip=inner, colorspace=fitz.csRGB, alpha=False
+ )
+ arr = np.frombuffer(pm.samples, dtype=np.uint8).reshape(-1, 3).astype(np.int32)
+ bg_arr = np.array(bg, dtype=np.int32)
+ dist = np.sqrt(((arr - bg_arr) ** 2).sum(axis=1))
+ text_mask = dist > 80
+ text_pixels = arr[text_mask]
+ text_dist = dist[text_mask]
+ if len(text_pixels) < 5 or len(text_pixels) / max(1, len(arr)) < 0.02:
+ return cfg.fallback
+ # Select pixels most different from background (core text, not antialiased edges).
+ # Distance-based selection works for any text color including teal, blue, red…
+ # "Darkest" heuristic would fail for non-dark colored text on light backgrounds.
+ dist_threshold = np.percentile(text_dist, 50)
+ core = text_pixels[text_dist >= dist_threshold]
+ if len(core) == 0:
+ core = text_pixels
+ r = int(np.median(core[:, 0]))
+ g = int(np.median(core[:, 1]))
+ b = int(np.median(core[:, 2]))
+ return (r, g, b)
+ except Exception:
+ return cfg.fallback
diff --git a/pdf2zh/render/cli.py b/pdf2zh/render/cli.py
new file mode 100644
index 0000000000000000000000000000000000000000..2ccbd6c44e30b9b32e38796cf8cf10cc0b3da2e3
--- /dev/null
+++ b/pdf2zh/render/cli.py
@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+import argparse
+import json
+import logging
+import sys
+from pathlib import Path
+
+from .config import RenderConfig
+from .renderer import render_document
+
+
+def _parse_pages(s: str) -> list[int]:
+ pages: list[int] = []
+ for part in s.split(","):
+ part = part.strip()
+ if "-" in part:
+ a, b = part.split("-", 1)
+ pages.extend(range(int(a), int(b) + 1))
+ else:
+ pages.append(int(part))
+ return pages
+
+
+def main() -> None:
+ ap = argparse.ArgumentParser(description="Phase 3: render translated scanned PDF")
+ ap.add_argument("--pdf", required=True, help="Original scanned PDF")
+ ap.add_argument("--parsed", required=True, help="Translated JSON (phase 2 output)")
+ ap.add_argument("--output", required=True, help="Output PDF path")
+ ap.add_argument("--font-config", default=None, help="JSON font/render config file")
+ ap.add_argument(
+ "--font-family",
+ default="Helvetica",
+ help="Typst font (single name or comma-separated fallback chain)",
+ )
+ ap.add_argument(
+ "--font-path",
+ action="append",
+ default=[],
+ dest="font_paths",
+ help="Typst --font-path directory (repeatable)",
+ )
+ ap.add_argument("--pages", default=None, help="Page filter e.g. 0-4,7,10")
+ ap.add_argument("--min-font", type=float, default=7.0, dest="min_font_size_pt")
+ ap.add_argument("--typst-bin", default="typst", help="Path to typst binary")
+ ap.add_argument(
+ "--keep-typst-source",
+ action="store_true",
+ help="Save intermediate .typ file alongside output",
+ )
+ ap.add_argument(
+ "--no-bg-sampling",
+ action="store_true",
+ help="Disable background color sampling (use white)",
+ )
+ ap.add_argument(
+ "--no-redact",
+ action="store_true",
+ help="Skip native text layer redaction (faster, but original text remains selectable)",
+ )
+ ap.add_argument(
+ "--aggressive-compress",
+ action="store_true",
+ help="Re-encode images via pikepdf",
+ )
+ ap.add_argument("--verbose", action="store_true")
+ args = ap.parse_args()
+
+ logging.basicConfig(
+ level=logging.DEBUG if args.verbose else logging.INFO,
+ format="%(asctime)s %(levelname)s %(message)s",
+ )
+
+ if args.font_config:
+ cfg = RenderConfig.from_json(args.font_config)
+ else:
+ cfg = RenderConfig()
+
+ # CLI args override config file
+ if args.font_family:
+ fonts = [f.strip() for f in args.font_family.split(",") if f.strip()]
+ cfg.font_family = fonts[0] if len(fonts) == 1 else fonts
+ if args.font_paths:
+ cfg.typst_font_paths = args.font_paths
+ cfg.typst_binary = args.typst_bin
+ cfg.min_font_size_pt = args.min_font_size_pt
+ cfg.keep_typst_source = args.keep_typst_source
+ cfg.compress.pikepdf_image_recompress = args.aggressive_compress
+ if args.no_bg_sampling:
+ cfg.background.enabled = False
+ cfg.text_color.enabled = False
+ if args.no_redact:
+ cfg.redact_native_text = False
+ if args.pages:
+ cfg.pages = _parse_pages(args.pages)
+
+ parsed = json.loads(Path(args.parsed).read_text(encoding="utf-8"))
+ try:
+ stats = render_document(args.pdf, parsed, args.output, cfg)
+ print(
+ f"Done: pages={stats['pages']} rendered={stats['elements_rendered']} "
+ f"skipped={stats['elements_skipped']} cells={stats['cells_rendered']}"
+ )
+ except RuntimeError as e:
+ print(f"ERROR: {e}", file=sys.stderr)
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pdf2zh/render/color.py b/pdf2zh/render/color.py
new file mode 100644
index 0000000000000000000000000000000000000000..181ed5f4158c68395feaac7be9cf4ca124724535
--- /dev/null
+++ b/pdf2zh/render/color.py
@@ -0,0 +1,87 @@
+from __future__ import annotations
+
+import fitz
+import numpy as np
+
+
+def pixmap_to_array(pm: fitz.Pixmap) -> np.ndarray:
+ arr = np.frombuffer(pm.samples, dtype=np.uint8).reshape(pm.height, pm.width, pm.n)
+ return arr[:, :, :3] if pm.n == 4 else arr
+
+
+def bbox_to_pixels(
+ bbox_pdf: list[float],
+ page_width: float,
+ page_height: float,
+ pm: fitz.Pixmap,
+) -> tuple[int, int, int, int]:
+ sx = pm.width / page_width
+ sy = pm.height / page_height
+ x0, y0, x1, y1 = bbox_pdf
+ return (
+ max(0, int(x0 * sx)),
+ max(0, int(y0 * sy)),
+ min(pm.width, int(x1 * sx + 0.5)),
+ min(pm.height, int(y1 * sy + 0.5)),
+ )
+
+
+def _mode_rgb(pixels: np.ndarray, qstep: int) -> tuple[int, int, int] | None:
+ if pixels.size == 0:
+ return None
+ q = (pixels // qstep) * qstep + qstep // 2
+ keys = (
+ q[:, 0].astype(np.int32) * 65536
+ + q[:, 1].astype(np.int32) * 256
+ + q[:, 2].astype(np.int32)
+ )
+ vals, counts = np.unique(keys, return_counts=True)
+ w = vals[counts.argmax()]
+ return (int((w >> 16) & 0xFF), int((w >> 8) & 0xFF), int(w & 0xFF))
+
+
+def detect_bg_color(
+ arr: np.ndarray,
+ bbox_px: tuple[int, int, int, int],
+ edge_band_px: int = 2,
+ qstep: int = 16,
+ fallback: tuple[int, int, int] = (255, 255, 255),
+) -> tuple[int, int, int]:
+ px0, py0, px1, py1 = bbox_px
+ if px1 - px0 < 2 * edge_band_px + 1 or py1 - py0 < 2 * edge_band_px + 1:
+ return fallback
+ top = arr[py0 : py0 + edge_band_px, px0:px1].reshape(-1, 3)
+ bot = arr[py1 - edge_band_px : py1, px0:px1].reshape(-1, 3)
+ left = arr[
+ py0 + edge_band_px : py1 - edge_band_px, px0 : px0 + edge_band_px
+ ].reshape(-1, 3)
+ right = arr[
+ py0 + edge_band_px : py1 - edge_band_px, px1 - edge_band_px : px1
+ ].reshape(-1, 3)
+ band = np.concatenate([top, bot, left, right])
+ return _mode_rgb(band, qstep) or fallback
+
+
+def detect_text_color(
+ arr: np.ndarray,
+ bbox_px: tuple[int, int, int, int],
+ bg: tuple[int, int, int],
+ edge_band_px: int = 2,
+ qstep: int = 16,
+ dist_threshold: int = 32,
+ min_ratio: float = 0.05,
+ fallback: tuple[int, int, int] = (0, 0, 0),
+) -> tuple[int, int, int]:
+ px0, py0, px1, py1 = bbox_px
+ inner = arr[
+ py0 + edge_band_px : py1 - edge_band_px, px0 + edge_band_px : px1 - edge_band_px
+ ]
+ if inner.size == 0:
+ return fallback
+ flat = inner.reshape(-1, 3).astype(np.int32)
+ bg_arr = np.array(bg, dtype=np.int32)
+ dist = np.sqrt(((flat - bg_arr) ** 2).sum(axis=1))
+ keep = flat[dist > dist_threshold]
+ if keep.size == 0 or len(keep) / max(1, len(flat)) < min_ratio:
+ return fallback
+ return _mode_rgb(keep.astype(np.uint8), qstep) or fallback
diff --git a/pdf2zh/render/compiler.py b/pdf2zh/render/compiler.py
new file mode 100644
index 0000000000000000000000000000000000000000..a992500262fcba4684092839c394f5726c3fec42
--- /dev/null
+++ b/pdf2zh/render/compiler.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+import logging
+import re
+import subprocess
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+_TYPST_LOCATION = re.compile(r"\.typ:(\d+):(\d+)")
+
+
+class TypstCompileError(RuntimeError):
+ """Typst compile failure carrying the full compiler stderr for diagnosis."""
+
+ def __init__(self, message: str, stderr: str = ""):
+ super().__init__(message)
+ self.stderr = stderr
+
+
+def _source_context(source: str, stderr: str, radius: int = 2) -> str:
+ match = _TYPST_LOCATION.search(stderr)
+ if not match:
+ return ""
+ line_number = int(match.group(1))
+ lines = source.splitlines()
+ start = max(0, line_number - radius - 1)
+ end = min(len(lines), line_number + radius)
+ return "\n".join(f"{index + 1:>5} | {lines[index]}" for index in range(start, end))
+
+
+def compile_typst(
+ source: str,
+ font_paths: list[str],
+ output_pdf: Path,
+ typst_bin: str = "typst",
+ work_dir: Path | None = None,
+) -> Path:
+ """Compile a Typst source string to PDF.
+
+ Args:
+ source: Complete Typst source code.
+ font_paths: Directories or file paths passed to --font-path.
+ output_pdf: Destination PDF path.
+ typst_bin: Path/name of the typst binary.
+ work_dir: Directory to write the intermediate .typ file (defaults to
+ output_pdf.parent).
+
+ Returns:
+ output_pdf path.
+
+ Raises:
+ TypstCompileError: If the typst process exits non-zero.
+ """
+ work_dir = work_dir or output_pdf.parent
+ work_dir.mkdir(parents=True, exist_ok=True)
+
+ typ_path = work_dir / (output_pdf.stem + ".typ")
+ typ_path.write_text(source, encoding="utf-8")
+
+ cmd = [typst_bin, "compile"]
+ for fp in font_paths:
+ cmd += ["--font-path", fp]
+ cmd += [str(typ_path), str(output_pdf)]
+
+ logger.debug("typst: %s", " ".join(cmd))
+ result = subprocess.run(cmd, capture_output=True, text=True)
+
+ if result.returncode != 0:
+ stderr = result.stderr or ""
+ tail = "\n".join(stderr.splitlines()[-50:])
+ context = _source_context(source, stderr)
+ if context:
+ logger.error("Typst source near the failing markup:\n%s", context)
+ tail = f"{tail}\n\nTypst source context:\n{context}"
+ raise TypstCompileError(
+ f"typst compile failed (exit {result.returncode}):\n{tail}", stderr=stderr
+ )
+
+ logger.debug("typst compiled → %s", output_pdf)
+ return output_pdf
diff --git a/pdf2zh/render/compress.py b/pdf2zh/render/compress.py
new file mode 100644
index 0000000000000000000000000000000000000000..7dacae78ca1ec885330cddb9211ca9d1790543e8
--- /dev/null
+++ b/pdf2zh/render/compress.py
@@ -0,0 +1,115 @@
+from __future__ import annotations
+
+import logging
+import shutil
+import tempfile
+from pathlib import Path
+
+import fitz
+
+from .config import CompressConfig
+
+logger = logging.getLogger(__name__)
+
+
+def finalize_save(
+ doc: fitz.Document,
+ output_path: Path,
+ cfg: CompressConfig,
+) -> None:
+ """Subset fonts, apply deflate/garbage optimisation, optionally re-encode images."""
+ if cfg.subset_fonts:
+ doc.subset_fonts()
+ logger.debug("subset_fonts done")
+
+ save_kwargs: dict = {"garbage": 4, "clean": True}
+ if cfg.deflate:
+ save_kwargs.update(
+ deflate=True, deflate_images=True, deflate_fonts=True, use_objstms=1
+ )
+
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ doc.save(str(output_path), **save_kwargs)
+ logger.debug("Saved %s", output_path)
+
+ if cfg.pikepdf_image_recompress:
+ _recompress_images(output_path, cfg)
+
+
+def _recompress_images(path: Path, cfg: CompressConfig) -> None:
+ """Re-encode images in PDF via pikepdf (optional, aggressive compression)."""
+ from importlib.util import find_spec
+
+ if find_spec("pikepdf") is None or find_spec("PIL") is None:
+ logger.warning("pikepdf or Pillow not installed; skipping image recompression")
+ return
+ import pikepdf
+
+ original_size = path.stat().st_size
+
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
+ tmp_path = Path(tmp.name)
+
+ try:
+ with pikepdf.open(str(path)) as pdf:
+ for page in pdf.pages:
+ for xobj_name in list(page.Resources.get("/XObject", {}).keys()):
+ xobj = page.Resources.XObject[xobj_name]
+ if xobj.get("/Subtype") != "/Image":
+ continue
+ _recompress_one(pdf, xobj, cfg)
+
+ pdf.save(str(tmp_path), compress_streams=True, recompress_flate=True)
+
+ new_size = tmp_path.stat().st_size
+ if new_size < original_size:
+ shutil.move(str(tmp_path), str(path))
+ logger.info(
+ "Image recompression: %d → %d bytes (%.1f%%)",
+ original_size,
+ new_size,
+ 100 * new_size / max(1, original_size),
+ )
+ else:
+ tmp_path.unlink(missing_ok=True)
+ logger.debug("Image recompression skipped: not smaller")
+ except Exception as exc:
+ logger.warning("Image recompression failed: %s", exc)
+ tmp_path.unlink(missing_ok=True)
+
+
+def _recompress_one(pdf, xobj, cfg: CompressConfig) -> None:
+ import io
+
+ import pikepdf
+ from PIL import Image
+
+ try:
+ # Skip non-standard images
+ cs = xobj.get("/ColorSpace")
+ if cs in ("/DeviceCMYK",):
+ return
+ bpc = xobj.get("/BitsPerComponent", 8)
+ if int(bpc) != 8:
+ return
+ if "/Mask" in xobj or "/SMask" in xobj:
+ return
+
+ w = int(xobj["/Width"])
+ h = int(xobj["/Height"])
+ if w * h == 0:
+ return
+
+ raw = xobj.read_raw_bytes()
+ img = Image.open(io.BytesIO(raw)).convert("RGB")
+
+ # Resize to target DPI if image is very large
+ # (we don't know display DPI here so skip resize — just recompress)
+ buf = io.BytesIO()
+ img.save(buf, format="JPEG", quality=cfg.jpeg_quality, optimize=True)
+ encoded = buf.getvalue()
+
+ if len(encoded) < len(raw):
+ xobj.write(encoded, filter=pikepdf.Name("/DCTDecode"))
+ except Exception:
+ pass # Leave image unchanged on any error
diff --git a/pdf2zh/render/config.py b/pdf2zh/render/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..0173e0128cbccf1f71dac79036054249112ddaa6
--- /dev/null
+++ b/pdf2zh/render/config.py
@@ -0,0 +1,150 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+
+@dataclass
+class StyleSpec:
+ weight: str = "regular" # "regular" | "bold"
+ style_: str = "normal" # "normal" | "italic"
+ align: str = "left" # "left" | "center" | "right"
+
+
+@dataclass
+class SizingConfig:
+ detect: bool = True
+ cluster_eps_pt: float = 2.0
+ # Maps group name → list of labels belonging to that group
+ cluster_groups: dict[str, list[str]] = field(
+ default_factory=lambda: {
+ "body": [
+ "Text",
+ "ListItem",
+ "Footnote",
+ "Handwriting",
+ "TextInlineMath",
+ ],
+ "toc": ["TableOfContents"],
+ "equation": ["Equation"],
+ "headings": ["SectionHeader"],
+ "header_footer": ["PageHeader", "PageFooter"],
+ "caption": ["Caption"],
+ }
+ )
+ # "document" = cluster across all pages; "page" = cluster per page
+ cluster_scope_by_group: dict[str, str] = field(
+ default_factory=lambda: {
+ "body": "page",
+ "toc": "document",
+ "equation": "page",
+ "headings": "page",
+ "header_footer": "page",
+ "caption": "page",
+ }
+ )
+ cap_height_ratio: float = 0.8
+ fallback_size: float = 11.0
+ # Used by _estimate_fit_size: avg char width / font_size and line-height / font_size
+ char_width_ratio: float = 0.55
+ leading_ratio: float = 1.25
+ # Table cell tweaks: slightly smaller font + inset to avoid border overlap
+ cell_font_scale: float = 0.88
+ cell_bbox_inset_pt: float = 2.0
+
+
+@dataclass
+class BackgroundConfig:
+ enabled: bool = True
+ sample_margin_pt: float = 6.0
+ dpi_scale: float = 2.0
+ complexity_brightness_spread: float = 72.0
+ text_contamination_dark_value: int = 220
+ text_contamination_dark_ratio: float = 0.015
+ min_sample_pixels: int = 24
+ eraser_padding_pt: float = 1.5
+ fallback_bg: tuple[int, int, int] = (255, 255, 255)
+
+
+@dataclass
+class TextColorConfig:
+ enabled: bool = True
+ center_fraction: float = 0.6
+ fallback: tuple[int, int, int] = (0, 0, 0)
+
+
+@dataclass
+class CompressConfig:
+ subset_fonts: bool = True
+ deflate: bool = True
+ pikepdf_image_recompress: bool = False
+ target_dpi: int = 200
+ jpeg_quality: int = 78
+
+
+@dataclass
+class RenderConfig:
+ # Typst font configuration
+ typst_font_paths: list[str] = field(default_factory=list)
+ # Single name or fallback chain. Typst tries each in order when a glyph
+ # is missing — useful for mixed-script content (Vietnamese, Greek, etc.).
+ font_family: str | list[str] = "Helvetica"
+ # Optional per-label style overrides
+ styles: dict[str, StyleSpec] = field(
+ default_factory=lambda: {
+ "SectionHeader": StyleSpec(weight="bold"),
+ "PageHeader": StyleSpec(align="center"),
+ "PageFooter": StyleSpec(align="center"),
+ "Caption": StyleSpec(style_="italic", align="center"),
+ "TableOfContents": StyleSpec(),
+ }
+ )
+ default_style: StyleSpec = field(default_factory=StyleSpec)
+ cell_style: StyleSpec = field(default_factory=StyleSpec)
+ sizing: SizingConfig = field(default_factory=SizingConfig)
+ background: BackgroundConfig = field(default_factory=BackgroundConfig)
+ text_color: TextColorConfig = field(default_factory=TextColorConfig)
+ compress: CompressConfig = field(default_factory=CompressConfig)
+ min_font_size_pt: float = 7.0
+ expand_downward: bool = True
+ max_expand_pt: float = 80.0
+ # Remove native text layer in translatable regions (needed for non-scanned PDFs)
+ redact_native_text: bool = True
+ pages: list[int] | None = None
+ typst_binary: str = "typst"
+ keep_typst_source: bool = False
+
+ # Legacy PyMuPDF fallback fields (kept for the fallback renderer)
+ font_path: str = ""
+ font_name: str = "Body"
+
+ @classmethod
+ def from_json(cls, path: str | Path) -> "RenderConfig":
+ import json
+
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
+ cfg = cls()
+ if "font_family" in data:
+ cfg.font_family = data["font_family"]
+ if "typst_font_paths" in data:
+ cfg.typst_font_paths = data["typst_font_paths"]
+ if "typst_binary" in data:
+ cfg.typst_binary = data["typst_binary"]
+ if "font_path" in data:
+ cfg.font_path = data["font_path"]
+ if "min_font_size_pt" in data:
+ cfg.min_font_size_pt = float(data["min_font_size_pt"])
+ if "pages" in data:
+ cfg.pages = data["pages"]
+ _load_nested(cfg.sizing, data.get("sizing", {}))
+ _load_nested(cfg.background, data.get("background", {}))
+ _load_nested(cfg.text_color, data.get("text_color", {}))
+ _load_nested(cfg.compress, data.get("compress", {}))
+ return cfg
+
+
+def _load_nested(obj: Any, d: dict) -> None:
+ for k, v in d.items():
+ if hasattr(obj, k):
+ setattr(obj, k, v)
diff --git a/pdf2zh/render/fonts.py b/pdf2zh/render/fonts.py
new file mode 100644
index 0000000000000000000000000000000000000000..c6b125b5225441337657897498f6bcade165d1ac
--- /dev/null
+++ b/pdf2zh/render/fonts.py
@@ -0,0 +1,9 @@
+from __future__ import annotations
+
+import fitz
+
+from .config import RenderConfig
+
+
+def register_font_for_page(page: fitz.Page, cfg: RenderConfig) -> None:
+ page.insert_font(fontname=cfg.font_name, fontfile=cfg.font_path)
diff --git a/pdf2zh/render/labels.py b/pdf2zh/render/labels.py
new file mode 100644
index 0000000000000000000000000000000000000000..c4e375aa69c3d169891d21eea66df7e7dee1cb8a
--- /dev/null
+++ b/pdf2zh/render/labels.py
@@ -0,0 +1,72 @@
+from __future__ import annotations
+
+from .config import SizingConfig
+
+# Normalise hyphenated Surya labels to CamelCase (matches actual JSON output)
+_NORMALISE: dict[str, str] = {
+ "Section-header": "SectionHeader",
+ "List-item": "ListItem",
+ "Page-header": "PageHeader",
+ "Page-footer": "PageFooter",
+ "Table-of-contents": "TableOfContents",
+ "Text-inline-math": "TextInlineMath",
+ "Formula": "Equation",
+ "Picture": "Figure",
+}
+
+
+def normalize_label(label: str) -> str:
+ return _NORMALISE.get(label, label)
+
+
+# Minor/structural labels whose box should never legitimately span most of a
+# page. When one does, it is almost certainly a layout mis-detection, so we keep
+# the original and do not translate/overlay it. Real content — body text
+# (Text/ListItem), tables, equations, TOC — may legitimately be large and is
+# never skipped by size. (PageHeader/PageFooter/Figure/Code are BYPASS and are
+# already skipped upstream; listed here only to document intent.)
+OVERSIZE_KEEP_ORIGINAL_LABELS: frozenset[str] = frozenset(
+ {
+ "SectionHeader",
+ "PageHeader",
+ "PageFooter",
+ "Caption",
+ "Footnote",
+ "Title",
+ }
+)
+
+
+def skip_oversize_element(
+ label: str,
+ bbox_pdf: list[float],
+ page_width: float,
+ page_height: float,
+ threshold: float = 0.5,
+) -> bool:
+ """Whether a large element should be left as the original (not translated).
+
+ Returns True only when the element's label is a minor/structural one
+ (:data:`OVERSIZE_KEEP_ORIGINAL_LABELS`) *and* its box covers at least
+ ``threshold`` of the page area. This single predicate is used by BOTH the
+ overlay builder and the native-text redaction pass so they always agree on
+ which elements to skip — a mismatch would erase content without redrawing it.
+ """
+ if normalize_label(label) not in OVERSIZE_KEEP_ORIGINAL_LABELS:
+ return False
+ x0, y0, x1, y1 = bbox_pdf
+ area = max(0.0, x1 - x0) * max(0.0, y1 - y0)
+ page_area = page_width * page_height
+ return page_area > 0 and area >= threshold * page_area
+
+
+def group_for_label(label: str, cfg: SizingConfig) -> str | None:
+ norm = normalize_label(label)
+ for group, members in cfg.cluster_groups.items():
+ if norm in members:
+ return group
+ return None
+
+
+def style_key(label: str) -> str:
+ return normalize_label(label)
diff --git a/pdf2zh/render/markup.py b/pdf2zh/render/markup.py
new file mode 100644
index 0000000000000000000000000000000000000000..b9f9650033cd2d9ea5033c41ac244bd64cff8f14
--- /dev/null
+++ b/pdf2zh/render/markup.py
@@ -0,0 +1,1033 @@
+from __future__ import annotations
+
+import re
+
+# ---------------------------------------------------------------------------
+# Regex patterns
+# ---------------------------------------------------------------------------
+
+# Display math:
+_MATH_DISPLAY = re.compile(
+ r'', re.DOTALL | re.IGNORECASE
+)
+# Inline math: (no display attribute, or display="inline")
+_MATH_INLINE = re.compile(
+ r'',
+ re.DOTALL | re.IGNORECASE,
+)
+_BOLD = re.compile(r"<(?:b|strong)>(.*?)(?:b|strong)>", re.DOTALL | re.IGNORECASE)
+_ITALIC = re.compile(r"<(?:i|em)>(.*?)(?:i|em)>", re.DOTALL | re.IGNORECASE)
+_SUP = re.compile(r"(.*?)", re.DOTALL | re.IGNORECASE)
+_SUB = re.compile(r"(.*?)", re.DOTALL | re.IGNORECASE)
+_ANY_TAG = re.compile(r"<[^>]+>")
+
+# Bare LaTeX command sequences (outside $ markers): \cmd{...} or \cmd
+_BARE_LATEX = re.compile(
+ r"(? after all tags stripped
+_LT = re.compile(r"<(?![a-zA-Z/])")
+_GT = re.compile(r'(?')
+
+
+def escape_typst_string(text: str) -> str:
+ """Escape for embedding inside a Typst double-quoted string literal."""
+ return text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
+
+
+def to_typst_markup(text: str, *, is_equation: bool = False) -> str:
+ """Convert hybrid HTML/LaTeX text to cmarker-friendly markdown with mitex math.
+
+ Args:
+ text: The translated_text field value (may contain HTML tags and LaTeX).
+ is_equation: True for EQUATION category elements — bare LaTeX gets wrapped.
+
+ Returns:
+ String safe for cmarker.render(..., math: mitex) in Typst.
+ """
+ if not text:
+ return ""
+
+ # If not in equation mode, escape literal dollar signs that are outside of