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: X +_MATH_DISPLAY = re.compile( + r']*>(.*?)', re.DOTALL | re.IGNORECASE +) +# Inline math: X (no display attribute, or display="inline") +_MATH_INLINE = re.compile( + r']*>(.*?)', + re.DOTALL | re.IGNORECASE, +) +_BOLD = re.compile(r"<(?:b|strong)>(.*?)", re.DOTALL | re.IGNORECASE) +_ITALIC = re.compile(r"<(?: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 tags + if not is_equation: + _MATH_TAG = re.compile(r"]*>.*?", re.DOTALL | re.IGNORECASE) + math_blocks = [] + + def _stash_math(m: re.Match) -> str: + math_blocks.append(m.group(0)) + return f"\x02MATH{len(math_blocks) - 1}\x03" + + result = _MATH_TAG.sub(_stash_math, text) + result = result.replace("$", "\\$") + result = re.sub( + r"\x02MATH(\d+)\x03", + lambda m: math_blocks[int(m.group(1))], + result, + ) + else: + result = text + + # 1. Display math → $$ ... $$ (double-dollar block math for mitex) + result = _MATH_DISPLAY.sub(lambda m: f"$${m.group(1).strip()}$$", result) + + # 2. Inline math → $ ... $ + result = _MATH_INLINE.sub(lambda m: f"${m.group(1).strip()}$", result) + + # 3. Bold / italic + result = _BOLD.sub(lambda m: f"**{m.group(1)}**", result) + result = _ITALIC.sub(lambda m: f"_{m.group(1)}_", result) + + # 4. Superscript / subscript + # Inside existing $...$ context: leave as LaTeX (^{X}, _{X} handled by mitex) + # Outside math: use markdown superscript ^X^ / subscript ~X~ + result = _convert_sup_sub(result) + + # 5. For EQUATION elements, wrap bare LaTeX command runs in $...$ + if is_equation: + result = _wrap_bare_latex(result) + + # 6. Strip any remaining unknown HTML tags (preserving < > inside $...$) + result = _strip_tags_outside_math(result) + + # 7 & 8. Escape Typst-special chars and literal < > in plain text segments (outside $...$) + result = _escape_typst_outside_math(result, clean_math=False, escape_lt_gt=True) + + # 9. Preserve explicit line breaks: a lone '\n' in translated_text is a + # deliberate break (address/signature blocks), but CommonMark renders a + # single newline as a space. Convert it to a hard break so cmarker keeps it. + result = _hardbreak_newlines(result) + + return result + + +def _hardbreak_newlines(text: str) -> str: + """Turn a lone ``\\n`` into a CommonMark hard line break (backslash + newline). + + Paragraph breaks (``\\n\\n``) are left as-is; ``$...$`` math is never touched. + """ + parts = _split_math(text) + out = [] + for kind, chunk in parts: + if kind == "math": + out.append(chunk) + else: + out.append(re.sub(r"(? str: + """Strip HTML-like tags but preserve $...$ math regions. + + Naive `_ANY_TAG.sub` would treat math relations like ``< b $ ... $ c >`` + as an HTML tag and erase the whole span; splitting on math first keeps + the operators intact. + """ + parts = _split_math(text) + out = [] + for kind, chunk in parts: + if kind == "math": + out.append(chunk) + else: + out.append(_ANY_TAG.sub("", chunk)) + return "".join(out) + + +def _convert_sup_sub(text: str) -> str: + """Replace X and X preserving $ contexts.""" + parts = _split_math(text) + out = [] + for kind, chunk in parts: + if kind == "math": + out.append(chunk) + else: + chunk = _SUP.sub(lambda m: f"^{m.group(1)}^", chunk) + chunk = _SUB.sub(lambda m: f"~{m.group(1)}~", chunk) + out.append(chunk) + return "".join(out) + + +def _wrap_bare_latex(text: str) -> str: + """Wrap bare LaTeX command sequences in $...$ (for EQUATION elements).""" + parts = _split_math(text) + out = [] + for kind, chunk in parts: + if kind == "math": + out.append(chunk) + else: + # Wrap runs of LaTeX commands that are not yet in math + out.append(_BARE_LATEX.sub(lambda m: f"${m.group(1).strip()}$", chunk)) + return "".join(out) + + +def _escape_typst_outside_math( + text: str, *, clean_math: bool = False, escape_lt_gt: bool = False +) -> str: + """Escape # and @ outside math delimiters; clean up LaTeX inside math if clean_math is True.""" + parts = _split_math(text) + out = [] + for kind, chunk in parts: + if kind == "math": + if clean_math: + out.append(_clean_math_chunk(chunk)) + else: + out.append(chunk) + else: + chunk = chunk.replace("#", "\\#").replace("@", "\\@") + if escape_lt_gt: + chunk = chunk.replace("\\<", "\x00LT\x00").replace("\\>", "\x00GT\x00") + chunk = re.sub(r"<", r"\\<", chunk) + chunk = re.sub(r">", r"\\>", chunk) + chunk = chunk.replace("\x00LT\x00", "\\<").replace("\x00GT\x00", "\\>") + out.append(chunk) + return "".join(out) + + +def _clean_math_chunk(chunk: str) -> str: + """Best-effort LaTeX → Typst conversion inside $...$ / $$...$$ regions. + + Handles common cases that the math-fixer LLM may have missed: + \\frac{a}{b} → frac(a, b) + \\binom{a}{b} → binom(a, b) + \\cmd{x} → cmd(x) + \\cmd → cmd (bare backslash command) + Also runs identifier splitting (bh → b h). + """ + m = re.match(r"^(\$+)(.*?)(\$+)$", chunk, re.DOTALL) + if not m: + return chunk + open_d, content, close_d = m.group(1), m.group(2), m.group(3) + # \limits and \nolimits are handled by Typst natively on operators, but raw \limits breaks Typst syntax. Strip them. + content = re.sub(r"\\(?:no)?limits(?![a-zA-Z])", "", content) + # \sqrt[n]{x} -> root(n, x) + content = re.sub( + r"\\sqrt\s*\[([^\[\]]+)\]\s*\{([^{}]*)\}", r"root(\1, \2)", content + ) + # Two-arg LaTeX commands (frac/binom variants) — convert before single-arg pass + content = re.sub( + r"\\(?:frac|dfrac|tfrac|cfrac)\s*\{([^{}]*)\}\s*\{([^{}]*)\}", + r"frac(\1, \2)", + content, + ) + content = re.sub( + r"\\(?:binom|dbinom|tbinom)\s*\{([^{}]*)\}\s*\{([^{}]*)\}", + r"binom(\1, \2)", + content, + ) + # Single-arg LaTeX command: \cmd{x} or \cmd*{x} → cmd(x) + content = re.sub(r"\\([a-zA-Z]+)\*?\s*\{([^{}]*)\}", r"\1(\2)", content) + # Bare backslash command: \cmd → cmd + content = re.sub(r"\\([a-zA-Z]+)", r"\1", content) + # Drop \left / \right artefacts (already stripped above as 'left'/'right') + content = re.sub(r"\b(left|right)\s*([({[\])])", r"\2", content) + content = _split_math_vars(content) + return f"{open_d}{content}{close_d}" + + +def _split_math(text: str) -> list[tuple[str, str]]: + """Split text into alternating (kind, chunk) where kind is 'text' or 'math'. + + Handles $...$ and $$...$$ delimiters. + """ + result: list[tuple[str, str]] = [] + i = 0 + n = len(text) + buf = [] + + while i < n: + if text[i] == "$": + # Flush text buffer + if buf: + result.append(("text", "".join(buf))) + buf = [] + # Determine if $$ or $ + if i + 1 < n and text[i + 1] == "$": + delim = "$$" + i += 2 + else: + delim = "$" + i += 1 + # Find closing delimiter + end = text.find(delim, i) + if end == -1: + # No closing delimiter — treat rest as text + result.append(("text", delim + text[i:])) + break + math_content = text[i:end] + result.append(("math", f"{delim}{math_content}{delim}")) + i = end + len(delim) + else: + buf.append(text[i]) + i += 1 + + if buf: + result.append(("text", "".join(buf))) + + return result + + +# --------------------------------------------------------------------------- +# Typst native markup converter (for text with Typst syntax) +# --------------------------------------------------------------------------- + +_MATH_TYPST_DISPLAY = re.compile( + r']*>(.*?)', re.DOTALL | re.IGNORECASE +) +_MATH_TYPST_INLINE = re.compile( + r']*>(.*?)', + re.DOTALL | re.IGNORECASE, +) +# Raw Typst blocks emitted by the math-fix pass (e.g. #grid for layouts) +_TYPST_BLOCK = re.compile(r"(.*?)", re.DOTALL | re.IGNORECASE) + +# Detect translatable prose: word run after stripping math/tags. Anything left +# means there's real text to render; otherwise the element is pure math and we +# should preserve the original PDF text layer. +_PROSE_LETTER_RUN = re.compile( + r"[A-Za-zÀ-ɏḀ-ỿ" # Latin + Latin Extended Additional (Vietnamese) + r"Ͱ-ϿЀ-ӿ؀-ۿऀ-ॿ฀-๿⺀-鿿]{2,}" +) +_DOLLAR_BLOCK_RE = re.compile(r"\$\$.*?\$\$", re.DOTALL) +_DOLLAR_INLINE_RE = re.compile(r"\$[^$\n]*\$") +_LATEX_CMD_RE = re.compile(r"\\[a-zA-Z]+(?:\s*\{[^{}]*\})*") +_EQ_LABEL_RE = re.compile(r"\(\d+(?:\.\d+)*[a-z]?\)") +_HTML_ANY_RE = re.compile(r"<[^>]+>") + + +def has_unbalanced_math_tags(text: str) -> bool: + """Detect malformed / with unmatched open/close counts. + + LLM outputs are sometimes truncated mid-stream; rendering such content as + Typst markup produces 'unclosed delimiter' errors. Flag and skip them. + """ + if not text: + return False + for tag in ("math", "typst"): + opens = len(re.findall(rf"<{tag}\b", text, re.IGNORECASE)) + closes = len(re.findall(rf"]*>(.*?)", re.DOTALL | re.IGNORECASE +) +_ALPHA_DIGIT_ALPHA = re.compile(r"[a-zA-Z][0-9][a-zA-Z]") +# Bare letter(s)+digit(s) token inside math regions — e.g. "F1", "R2" (an F1-score +# or similar abbreviation dropped straight into $...$). mitex/Typst reads this as +# a single unknown identifier and errors out. Underscored forms (x_1) are safe — +# "_" is a word char, so it breaks the adjacency this pattern requires. +_BARE_ALNUM_TOKEN = re.compile(r"\b[a-zA-Z]+[0-9]+\b") + + +def has_malformed_typst_math(text: str) -> bool: + """True if text contains Typst math constructs that will cause a compile error. + + Detects: + - frac() with empty denominator: frac(x, ) + - letter-digit-letter identifiers inside math regions: t2c (garbled LLM output) + - bare letter+digit tokens inside math regions: F1, R2 (unknown Typst variable) + """ + if _EMPTY_FRAC_RE.search(text): + return True + for m in _MATH_REGION.finditer(text): + content = m.group(1) or m.group(2) or "" + if _ALPHA_DIGIT_ALPHA.search(content) or _BARE_ALNUM_TOKEN.search(content): + return True + return False + + +def has_bare_latex(text: str) -> bool: + """True if text contains LaTeX \\X commands OUTSIDE //$...$ regions. + + Such commands can't be reliably converted at render time — preserve the + original PDF text layer instead of producing broken output. + """ + if not text: + return False + s = _MATH_TYPST_DISPLAY.sub("", text) + s = _MATH_TYPST_INLINE.sub("", s) + s = _TYPST_BLOCK.sub("", s) + s = _DOLLAR_BLOCK_RE.sub("", s) + s = _DOLLAR_INLINE_RE.sub("", s) + return bool(re.search(r"\\[a-zA-Z]+", s)) + + +def is_pure_math_text(text: str) -> bool: + """True if `text` has only math content (no translatable words). + + Strips , , $...$, LaTeX commands, eq labels, HTML tags, + then checks for any word-like letter run. + """ + if not text: + return False + s = _MATH_TYPST_DISPLAY.sub("", text) + s = _MATH_TYPST_INLINE.sub("", s) + # Do NOT strip ... blocks — those are renderable layouts. + s = _DOLLAR_BLOCK_RE.sub("", s) + s = _DOLLAR_INLINE_RE.sub("", s) + s = _LATEX_CMD_RE.sub("", s) + s = _EQ_LABEL_RE.sub("", s) + s = _HTML_ANY_RE.sub("", s) + return not _PROSE_LETTER_RUN.search(s) + + +# Known Typst math identifiers that must NOT be split into separate letters. +_TYPST_MATH_IDENTIFIERS: set[str] = { + # Greek letters (lowercase + uppercase) + "alpha", + "beta", + "gamma", + "delta", + "epsilon", + "zeta", + "eta", + "theta", + "iota", + "kappa", + "lambda", + "mu", + "nu", + "xi", + "omicron", + "pi", + "rho", + "sigma", + "tau", + "upsilon", + "phi", + "chi", + "psi", + "omega", + "Alpha", + "Beta", + "Gamma", + "Delta", + "Epsilon", + "Zeta", + "Eta", + "Theta", + "Iota", + "Kappa", + "Lambda", + "Mu", + "Nu", + "Xi", + "Omicron", + "Pi", + "Rho", + "Sigma", + "Tau", + "Upsilon", + "Phi", + "Chi", + "Psi", + "Omega", + # Variant Greek + "varepsilon", + "varphi", + "vartheta", + "varrho", + "varsigma", + # Common math functions + "frac", + "sqrt", + "root", + "abs", + "norm", + "floor", + "ceil", + "round", + "sin", + "cos", + "tan", + "cot", + "sec", + "csc", + "arcsin", + "arccos", + "arctan", + "arccot", + "arcsec", + "arccsc", + "sinh", + "cosh", + "tanh", + "coth", + "sech", + "csch", + "log", + "ln", + "exp", + "det", + "dim", + "ker", + "gcd", + "lcm", + "max", + "min", + "sum", + "prod", + "lim", + "inf", + "sup", + "mod", + "deg", + "arg", + # Typst math layout/style + "vec", + "mat", + "cases", + "binom", + "display", + "inline", + "script", + "limits", + "scripts", + "attach", + "accent", + "overline", + "underline", + "overbrace", + "underbrace", + "cancel", + "upright", + "bold", + "italic", + "serif", + "sans", + "mono", + "bb", + "cal", + "frak", + # Operator words used in dotted Typst identifiers (plus.minus, minus.plus, etc.) + # Must be kept intact so the dot-notation survives _split_math_vars. + "plus", + "minus", + "times", + "div", + "arrow", + "tilde", + "hat", + "grave", + "acute", + "breve", + "caron", + "diaer", + "macron", + # Unit names — keep intact; LLM should quote them ("rad", "kg") but if bare, + # splitting into letters is worse than leaving as a multi-letter identifier. + "rad", + "radian", + "radians", + "rpm", + "rps", + "kg", + "mg", + "km", + "cm", + "mm", + "ms", + "ns", + "hz", + "khz", + "mhz", + "ghz", + # Relational / logical + "not", + "and", + "or", + "in", + "gt", + "lt", + "eq", + "approx", + "equiv", + "subset", + "supset", + "union", + "inter", + "forall", + "exists", + "therefore", + "because", + # Dots & special symbols (Typst names) + "dots", + "dots.c", + "dots.b", + "dots.v", + "dots.down", + "infty", + "iint", + "iiint", + "oint", + "int", + "partial", + "nabla", + "ell", + "hbar", + "planck", + "nothing", + "space", + "thin", + "med", + "thick", + "circle", + "ast", + "star", + "compose", + "bullet", + "without", + "wr", + "asymp", + "prop", + "models", + "perp", + "parallel", + "bowtie", + "smile", + "frown", + "aleph", + "wp", + "Re", + "Im", + "empty", + "surd", + "top", + "bot", + "angle", + "triangle", + "backslash", + "flat", + "natural", + "sharp", + "club", + "diamond", + "heart", + "spade", + "quad", + "wide", + "degree", + "dot", + "slash", + "bar", + "harpoon", + "brace", + "bracket", + "op", + "lr", + "dif", +} +# Also build a pattern that matches a known identifier anchored at the start +# of a word — used for greedy left-to-right tokenisation. +_IDENT_ALPHA = re.compile(r"[a-zA-Z]+(?:\.[a-zA-Z]+)+|[a-zA-Z]{2,}") +_MATH_UNDERSCORE_IDENT = re.compile(r"\b[a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+)+\b") + + +_LATEX_IDENT_RENAME: dict[str, str] = { + # Dots + "cdot": "dot.op", + "cdots": "dots.c", + "ldots": "dots", + "vdots": "dots.v", + "ddots": "dots.down", + # Fonts + "mathbf": "bold", + "mathrm": "upright", + "mathit": "italic", + "mathsf": "sans", + "mathtt": "mono", + "mathcal": "cal", + "mathbb": "bb", + "mathfrak": "frak", + "boldsymbol": "bold", + "text": "upright", + "textbf": "bold", + "textit": "italic", + "textrm": "upright", + "rm": "upright", + "bf": "bold", + "it": "italic", + "operatorname": "upright", + # Accents + "vec": "arrow", + "bar": "macron", + "check": "caron", + "ddot": "dot.double", + "dddot": "dot.triple", + "ddddot": "dot.quad", + "mathring": "circle", + # Operators & Symbols + "pm": "plus.minus", + "mp": "minus.plus", + "times": "times", + "div": "div", + "ast": "ast", + "star": "star", + "circ": "compose", + "bullet": "bullet", + "oplus": "plus.circle", + "ominus": "minus.circle", + "otimes": "times.circle", + "oslash": "div.circle", + "odot": "dot.circle", + "cup": "union", + "cap": "inter", + "uplus": "union.plus", + "sqcap": "inter.sq", + "sqcup": "union.sq", + "vee": "or", + "wedge": "and", + "setminus": "without", + "wr": "wr", + # Relations + "liminf": "liminf", + "limsup": "limsup", + "varliminf": "liminf", + "varlimsup": "limsup", + "varnothing": "empty", + "leq": "lt.eq", + "geq": "gt.eq", + "neq": "eq.not", + "le": "lt.eq", + "ge": "gt.eq", + "ne": "eq.not", + "ll": "lt.double", + "gg": "gt.double", + "equiv": "equiv", + "sim": "tilde.op", + "simeq": "tilde.eq", + "asymp": "asymp", + "approx": "approx", + "cong": "tilde.equiv", + "doteq": "eq.est", + "propto": "prop", + "models": "models", + "perp": "perp", + "mid": "bar.v", + "parallel": "parallel", + "bowtie": "bowtie", + "ltimes": "times.l", + "rtimes": "times.r", + "smile": "smile", + "frown": "frown", + "in": "in", + "notin": "in.not", + "ni": "in.rev", + "subset": "subset", + "supset": "supset", + "subseteq": "subset.eq", + "supseteq": "supset.eq", + # Arrows + "leftarrow": "arrow.l", + "rightarrow": "arrow.r", + "leftrightarrow": "arrow.l.r", + "Leftarrow": "arrow.l.double", + "Rightarrow": "arrow.r.double", + "Leftrightarrow": "arrow.l.r.double", + "mapsto": "arrow.r.bar", + "to": "arrow.r", + "implies": "arrow.r.double", + "iff": "arrow.l.r.double", + "gets": "arrow.l", + "hookleftarrow": "arrow.l.hook", + "hookrightarrow": "arrow.r.hook", + "rightharpoonup": "harpoon.rt", + "leftharpoonup": "harpoon.lt", + "rightharpoondown": "harpoon.rb", + "leftharpoondown": "harpoon.lb", + "rightleftharpoons": "harpoons.rtlb", + # Misc + "aleph": "aleph", + "wp": "wp", + "Re": "Re", + "Im": "Im", + "emptyset": "empty", + "nabla": "nabla", + "surd": "surd", + "top": "top", + "bot": "bot", + "angle": "angle", + "triangle": "triangle", + "backslash": "backslash", + "forall": "forall", + "exists": "exists", + "nexists": "exists.not", + "neg": "not", + "lnot": "not", + "flat": "flat", + "natural": "natural", + "sharp": "sharp", + "clubsuit": "club", + "diamondsuit": "diamond", + "heartsuit": "heart", + "spadesuit": "spade", + "infty": "infty", + "partial": "partial", + "quad": "quad", + "qquad": "wide", + "O": "O", + "degree": "degree", + # Brackets + "langle": "angle.l", + "rangle": "angle.r", + "lbrace": "brace.l", + "rbrace": "brace.r", + "lceil": "ceil.l", + "rceil": "ceil.r", + "lfloor": "floor.l", + "rfloor": "floor.r", + "lbrack": "bracket.l", + "rbrack": "bracket.r", +} + +_TYPST_SYMBOL_IDENTIFIERS = frozenset( + value for value in _LATEX_IDENT_RENAME.values() if "." in value +) | { + "dots.c", + "dots.b", + "dots.v", + "dots.down", +} + + +def _split_math_vars(math_content: str) -> str: + """Insert spaces between consecutive-letter variable products in Typst math. + + In Typst math, ``bh`` is one identifier. We need ``b h`` (two separate + variables multiplied implicitly). Known identifiers like ``frac``, ``sin``, + ``theta`` etc. are kept intact, as are any word immediately followed by ``(`` + (function-call syntax). + + Idempotent: quoted strings and escape sequences are protected up front, so + content that already went through this function (e.g. ``upright("page_index")``) + is never re-wrapped or letter-split on a second pass. + """ + + protected: list[str] = [] + + def _stash_protected(m: re.Match) -> str: + protected.append(m.group(0)) + return f"\x06{len(protected) - 1}\x07" + + # Protect "..." string literals and \ escapes from every pass below. + math_content = re.sub( + r'"(?:[^"\\]|\\.)*"|\\[^a-zA-Z]', _stash_protected, math_content + ) + # Bare # starts a code expression in Typst math; a remaining (unmatched) + # quote opens a string that swallows the rest of the source. Escape both. + math_content = math_content.replace("#", "\\#").replace('"', '\\"') + # An attach with no base/script ($_(x)$, $x_$, $x^$) is a parse error — + # give it an empty "" operand. + math_content = re.sub(r"^(\s*)([_^])", r'\1""\2', math_content) + math_content = re.sub(r"([_^])(\s*)$", r'\1""\2', math_content) + + quoted_idents: list[str] = [] + + def _stash_underscore_ident(m: re.Match) -> str: + word = m.group(0) + parts = word.split("_") + # Keep valid Typst subscript chains: every segment is a single letter, + # a number, or a known identifier (sigma_x, sum_i, a_1, x_i_j). + if all( + len(part) == 1 or part.isdigit() or part in _TYPST_MATH_IDENTIFIERS + for part in parts + ): + return word + quoted_idents.append(f'upright("{word}")') + return f"\x04{len(quoted_idents) - 1}\x05" + + math_content = _MATH_UNDERSCORE_IDENT.sub(_stash_underscore_ident, math_content) + + def _replace(m: re.Match) -> str: + word = m.group(0) + if "." in word: + if word in _TYPST_SYMBOL_IDENTIFIERS: + return word + return word.replace(".", " ") + # LaTeX identifier with a different Typst name — rename + if word in _LATEX_IDENT_RENAME: + return _LATEX_IDENT_RENAME[word] + # Known Typst math identifier — keep as-is + if word in _TYPST_MATH_IDENTIFIERS: + return word + # Function call (followed by open paren) — an unknown name would be an + # 'unknown variable' compile error, so quote it: TP(t) → upright("TP")(t) + if m.end() < len(math_content) and math_content[m.end()] == "(": + return f'upright("{word}")' + # Try greedy left-to-right: peel off known identifiers, then single chars + result_parts: list[str] = [] + i = 0 + while i < len(word): + matched = False + # Try longest known identifier starting at position i + for length in range(min(len(word) - i, 12), 1, -1): + candidate = word[i : i + length] + if candidate in _TYPST_MATH_IDENTIFIERS: + result_parts.append(candidate) + i += length + matched = True + break + if not matched: + result_parts.append(word[i]) + i += 1 + return " ".join(result_parts) + + math_content = _IDENT_ALPHA.sub(_replace, math_content) + if quoted_idents: + math_content = re.sub( + r"\x04(\d+)\x05", + lambda m: quoted_idents[int(m.group(1))], + math_content, + ) + if protected: + math_content = re.sub( + r"\x06(\d+)\x07", + lambda m: protected[int(m.group(1))], + math_content, + ) + return math_content + + +def to_typst_native(text: str) -> str: + """Convert hybrid HTML/Typst-math text to raw Typst markup string. + + Expects tags to contain Typst math syntax (no backslash LaTeX). + Output is a Typst markup fragment suitable for embedding in Typst content. + """ + if not text: + return "" + + # Placeholder for # in our generated function calls — keeps them safe + # through the user-content escape step (which would otherwise turn # into \#). + PH = "\x00" + # Placeholder for raw blocks — pass through untouched. + TS_OPEN, TS_CLOSE = "\x02", "\x03" + + result = text + + # 0. Stash raw Typst blocks; restore at the very end to bypass all escaping. + raw_typst: list[str] = [] + + def _stash_typst(m: re.Match) -> str: + raw_typst.append(m.group(1)) + return f"{TS_OPEN}{len(raw_typst) - 1}{TS_CLOSE}" + + result = _TYPST_BLOCK.sub(_stash_typst, result) + + # 1. Display math → $ content $ (spaces = display/block in Typst). + # Inline math → $content$ (no spaces = inline in Typst, stays in text flow). + # Also split multi-letter variable products (e.g. bh → b h) inside math. + result = _MATH_TYPST_DISPLAY.sub( + lambda m: f"$ {_split_math_vars(m.group(1).strip())} $", result + ) + result = _MATH_TYPST_INLINE.sub( + lambda m: f"${_split_math_vars(m.group(1).strip())}$", result + ) + + # 2. Bold / italic / sup / sub → function-call syntax. + # Function calls avoid word-boundary issues that break `_x_θ`-style emphasis. + result = _BOLD.sub(lambda m: f"{PH}strong[{m.group(1)}]", result) + result = _ITALIC.sub(lambda m: f"{PH}emph[{m.group(1)}]", result) + result = _SUP.sub(lambda m: f"{PH}super[{m.group(1)}]", result) + result = _SUB.sub(lambda m: f"{PH}sub[{m.group(1)}]", result) + + # 3. Strip remaining unknown HTML tags (preserving < > inside $...$) + result = _strip_tags_outside_math(result) + + # 4. Escape Typst-special chars in user content (outside math) + result = _escape_typst_outside_math(result, clean_math=True) + + # 5. Restore # for our generated function calls + result = result.replace(PH, "#") + + # 6. Restore raw Typst blocks; clean LLM mistakes inside $...$ math regions. + if raw_typst: + + def _restore(m: re.Match) -> str: + content = raw_typst[int(m.group(1))] + content = content.replace("\\/", "/") # legacy prompt artifact + # Inside each inline $...$, run full math cleanup (LaTeX → Typst, var split). + return re.sub( + r"\$([^$]+?)\$", + lambda mm: _clean_math_chunk(f"${mm.group(1)}$"), + content, + flags=re.DOTALL, + ) + + result = re.sub(f"{TS_OPEN}(\\d+){TS_CLOSE}", _restore, result) + + return result + + +# --------------------------------------------------------------------------- +# TOC parser +# --------------------------------------------------------------------------- + +# Matches: title (non-greedy) + optional dot leaders + page_number +# Lookahead: followed by whitespace+digit (next entry) or end of string. +# Page numbers limited to 1-3 digits to avoid matching years like 2023. +_TOC_BLOB_RE = re.compile( + r"(.+?)\s*(?:(?:\.\s*){2,})?\s*(\d{1,3})(?=\s+\d|\s*$)", + re.DOTALL, +) +_DOT_SEQ_RE = re.compile(r"\s*(?:\.\s*){2,}") + + +def parse_toc_line(line: str) -> tuple[str, str] | None: + """Extract (title, page_number) from a single TOC line, or None.""" + line = line.strip() + if not line: + return None + m = re.match(r"^(.*?)\s+(\d+)\s*$", line) + if m: + return m.group(1).strip(), m.group(2) + return None + + +def parse_toc_entries(text: str) -> list[tuple[str, str | None]]: + """Parse TOC text into (title, page_num) pairs. + + Handles both newline-separated lines and single-line blob format + (entries concatenated with dot leaders or bare spaces). + page_num is None for entries where no page number could be found. + """ + lines = [line.strip() for line in text.split("\n") if line.strip()] + if len(lines) > 1: + result = [] + for line in lines: + parsed = parse_toc_line(line) + result.append(parsed if parsed else (line, None)) + return result + + # Single-line blob: extract via regex, preserving gap text between matches + entries: list[tuple[str, str | None]] = [] + prev_end = 0 + for m in _TOC_BLOB_RE.finditer(text): + gap = text[prev_end : m.start()].strip() + if gap: + clean = _DOT_SEQ_RE.sub(" ", gap).strip() + if clean: + entries.append((clean, None)) + title = m.group(1).strip() + if title: + entries.append((title, m.group(2))) + prev_end = m.end() + tail = _DOT_SEQ_RE.sub(" ", text[prev_end:]).strip() + if tail: + entries.append((tail, None)) + return entries if entries else [(text.strip(), None)] diff --git a/pdf2zh/render/overlay.py b/pdf2zh/render/overlay.py new file mode 100644 index 0000000000000000000000000000000000000000..beda50eb241e27fc0bf865b04b4e269508d26203 --- /dev/null +++ b/pdf2zh/render/overlay.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +import fitz + +logger = logging.getLogger(__name__) + + +def composite_overlay( + original_pdf: Path, + overlay_pdf: Path, + output_pdf: Path, + pages: list[int] | None, +) -> None: + """Stamp overlay_pdf transparently onto the selected pages of original_pdf. + + The output contains ONLY the translated pages (those in ``pages``), in + ascending page order — not the whole original document. When ``pages`` is + None every page is kept. For scanned PDFs the original page image is the + background; the overlay PDF carries translated text and cover rects with + sampled bg colors. PyMuPDF's show_pdf_page() composites them in a single + vector operation. + """ + src = fitz.open(str(original_pdf)) + ov = fitz.open(str(overlay_pdf)) + + if pages is None: + selected = list(range(src.page_count)) + else: + selected = sorted(p for p in pages if 0 <= p < src.page_count) + + # Overlay page i corresponds to the i-th selected source page: source_builder + # emits rendered pages in ascending page_index order, matching sorted(pages). + # Guard a shorter overlay so a missing page never raises. + n = min(len(selected), ov.page_count) + if n < len(selected): + logger.warning( + "Overlay has fewer pages (%d) than selected (%d); stopping early", + ov.page_count, + len(selected), + ) + + out = fitz.open() + for i in range(n): + src_idx = selected[i] + out.insert_pdf(src, from_page=src_idx, to_page=src_idx) + out[i].show_pdf_page(out[i].rect, ov, i, overlay=True) + logger.debug( + "Composited overlay page %d → output page %d (source %d)", i, i, src_idx + ) + + ov.close() + src.close() + output_pdf.parent.mkdir(parents=True, exist_ok=True) + out.save( + str(output_pdf), + garbage=4, + deflate=True, + deflate_images=True, + deflate_fonts=True, + use_objstms=1, + clean=True, + ) + out.close() + logger.info("Saved %s (%d pages)", output_pdf, n) diff --git a/pdf2zh/render/renderer.py b/pdf2zh/render/renderer.py new file mode 100644 index 0000000000000000000000000000000000000000..b8a2e9183821fe0e82bcc98ef51f1201a4a447ef --- /dev/null +++ b/pdf2zh/render/renderer.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +import logging +import re +import tempfile +from pathlib import Path + +import fitz + +from .background import RGB, prepare_cover, sample_text_color +from .compiler import _TYPST_LOCATION, TypstCompileError, compile_typst +from .config import RenderConfig +from .labels import skip_oversize_element +from .markup import ( + has_bare_latex, + has_malformed_typst_math, + has_unbalanced_math_tags, + is_pure_math_text, +) +from .overlay import composite_overlay +from .sizing import assign_render_sizes +from .source_builder import build_typst_source + +logger = logging.getLogger(__name__) + +# Element markup definitions emitted by source_builder: #let e

__tm = [...], +# #let e

__c_md = "...", etc. Used to map compile errors back to elements. +_ELEMENT_LET_RE = re.compile(r"#let (e\d+_\d+(?:_c\d+)?)_(?:tm|md|body|cover) = ") + +# Safety cap for the compile-repair loop; each retry downgrades at least one +# new element. Large documents (100+ pages of dense math) can have more than a +# handful of independently-broken elements, so this stays generous — each +# retry is cheap (one more typst compile) next to failing the whole render. +_MAX_COMPILE_REPAIRS = 30 + + +def _failing_element_vars(source: str, stderr: str) -> set[str]: + """Map Typst error line numbers back to the element vars whose markup failed. + + For each ``…overlay.typ:LINE:COL`` in stderr, scan upward from LINE to the + nearest ``#let e

__…`` definition — that element's markup contains the + error. Errors outside any element definition are not attributed. + """ + lines = source.splitlines() + found: set[str] = set() + for m in _TYPST_LOCATION.finditer(stderr): + line_no = min(int(m.group(1)), len(lines)) + for idx in range(line_no - 1, -1, -1): + let_m = _ELEMENT_LET_RE.match(lines[idx]) + if let_m: + found.add(let_m.group(1)) + break + return found + + +def render_document( + pdf_path: str | Path, + parsed: dict, + output_path: str | Path, + cfg: RenderConfig, +) -> dict: + """Render translated PDF using Typst-based pipeline. + + Steps: + 1. Assign consistent font sizes (cluster per label group). + 2. Sample background and text colors per element from original PDF. + 3. Build Typst source with absolute-positioned cover rects + text blocks. + 4. Compile Typst → overlay PDF. + 5. Composite overlay onto original via show_pdf_page. + 6. Subset fonts + compress. + + Returns stats dict. + """ + pdf_path = Path(pdf_path) + output_path = Path(output_path) + + stats = { + "pages": 0, + "elements_rendered": 0, + "elements_skipped": 0, + "cells_rendered": 0, + "bg_samples": 0, + } + + # 1. Assign sizes + sizes = assign_render_sizes(parsed, cfg.sizing) + logger.info("Sizing: %d size assignments", len(sizes)) + + # 2. Sample colors + bg_colors: dict[str, RGB] = {} + text_colors: dict[str, RGB] = {} + _sample_colors(pdf_path, parsed, cfg, sizes, bg_colors, text_colors, stats) + + # 3. Count rendered/skipped + for page_idx, page in enumerate(parsed.get("pages", [])): + if cfg.pages is not None and page.get("page_index", page_idx) not in cfg.pages: + continue + stats["pages"] += 1 + for elem_idx, elem in enumerate(page.get("elements", [])): + category = elem.get("category", "") + if category == "BYPASS": + continue + if category == "TABLE": + for cell in elem.get("cells", []): + cell_source = cell.get("source_text") or "" + if cell_source.strip() and cell.get("translated_text"): + stats["cells_rendered"] += 1 + else: + stats["elements_skipped"] += 1 + else: + translated = elem.get("translated_text") or "" + source = elem.get("source_text") or "" + if translated and translated != source: + stats["elements_rendered"] += 1 + elif translated == source and category == "EQUATION": + stats["elements_skipped"] += 1 + elif translated: + stats["elements_rendered"] += 1 + else: + stats["elements_skipped"] += 1 + + # 4. Build Typst source + typst_source = build_typst_source(parsed, sizes, bg_colors, text_colors, cfg) + + with tempfile.TemporaryDirectory() as tmp_dir: + work_dir = Path(tmp_dir) + + if cfg.keep_typst_source: + source_path = output_path.with_suffix(".typ") + source_path.write_text(typst_source, encoding="utf-8") + logger.info("Typst source saved to %s", source_path) + + overlay_pdf = work_dir / "overlay.pdf" + + # 5. Compile — self-healing: if an element's markup breaks the build, + # rebuild with that element downgraded to plain text and retry. + fallback_vars: set[str] = set() + for attempt in range(_MAX_COMPILE_REPAIRS + 1): + try: + compile_typst( + typst_source, + font_paths=cfg.typst_font_paths, + output_pdf=overlay_pdf, + typst_bin=cfg.typst_binary, + work_dir=work_dir, + ) + break + except TypstCompileError as exc: + bad_vars = ( + _failing_element_vars(typst_source, exc.stderr) - fallback_vars + ) + if not bad_vars or attempt == _MAX_COMPILE_REPAIRS: + raise + fallback_vars |= bad_vars + logger.warning( + "typst compile failed; retrying with plain-text fallback for: %s", + ", ".join(sorted(bad_vars)), + ) + typst_source = build_typst_source( + parsed, sizes, bg_colors, text_colors, cfg, fallback_vars + ) + if cfg.keep_typst_source: + output_path.with_suffix(".typ").write_text( + typst_source, encoding="utf-8" + ) + if fallback_vars: + stats["elements_fallback"] = len(fallback_vars) + + # 6. Redact native text layer if present (non-scanned PDFs) + base_pdf = pdf_path + if cfg.redact_native_text and _has_native_text(pdf_path): + redacted_pdf = work_dir / "redacted.pdf" + _redact_text_layer(pdf_path, parsed, bg_colors, cfg, redacted_pdf) + base_pdf = redacted_pdf + logger.info("Native text layer redacted → %s", redacted_pdf) + + # 7. Composite + output_path.parent.mkdir(parents=True, exist_ok=True) + composite_overlay(base_pdf, overlay_pdf, output_path, cfg.pages) + + logger.info( + "render_document done: pages=%d rendered=%d skipped=%d cells=%d", + stats["pages"], + stats["elements_rendered"], + stats["elements_skipped"], + stats["cells_rendered"], + ) + return stats + + +def _has_native_text(pdf_path: Path, max_pages: int = 3) -> bool: + doc = fitz.open(str(pdf_path)) + try: + for i in range(min(max_pages, doc.page_count)): + if doc[i].get_text("text").strip(): + return True + finally: + doc.close() + return False + + +def _redact_text_layer( + pdf_path: Path, + parsed: dict, + bg_colors: dict[str, RGB], + cfg: RenderConfig, + out_path: Path, +) -> None: + """Erase translatable elements from the original text layer via redaction.""" + doc = fitz.open(str(pdf_path)) + pad = cfg.background.eraser_padding_pt + try: + for page_idx, page_data in enumerate(parsed.get("pages", [])): + orig = page_data.get("page_index", page_idx) + if cfg.pages is not None and orig not in cfg.pages: + continue + if orig >= doc.page_count: + continue + page = doc[orig] + pw = page_data.get("page_width", page.rect.width) + ph = page_data.get("page_height", page.rect.height) + had_annot = False + + for elem_idx, elem in enumerate(page_data.get("elements", [])): + category = elem.get("category", "") + if category == "BYPASS": + continue + uid = f"p{page_idx}:e{elem_idx}" + + # Mirror the overlay's skip rule exactly: whatever the overlay + # will not redraw must not be redacted here, or the original is + # erased with nothing put back. Minor/structural elements that + # span most of the page are mis-detections — keep the original. + if skip_oversize_element( + elem.get("label", "Text"), + elem.get("bbox_pdf", [0, 0, 10, 10]), + pw, + ph, + ): + continue + + if category == "TABLE": + for cell_idx, cell in enumerate(elem.get("cells", [])): + cell_source = cell.get("source_text") or "" + if not cell_source.strip() or not cell.get("translated_text"): + continue + cell_uid = f"{uid}:c{cell_idx}" + # Strip native text only over bbox_text (tight box), not + # the whole grid cell — mirrors the overlay's cover_rect + # and keeps the cell's borders/background intact. + cx0, cy0, cx1, cy1 = cell.get("bbox_text") or cell.get( + "bbox_pdf", elem.get("bbox_pdf", [0, 0, 10, 10]) + ) + fill = bg_colors.get(cell_uid, (255, 255, 255)) + page.add_redact_annot( + fitz.Rect(cx0 - pad, cy0 - pad, cx1 + pad, cy1 + pad), + fill=[c / 255 for c in fill], + ) + had_annot = True + else: + translated = elem.get("translated_text") or "" + source = elem.get("source_text") or "" + if not translated: + continue + if translated.strip() == source.strip(): + continue + if ( + is_pure_math_text(translated) + or has_unbalanced_math_tags(translated) + or has_bare_latex(translated) + or has_malformed_typst_math(translated) + ): + continue + x0, y0, x1, y1 = elem.get("bbox_pdf", [0, 0, 10, 10]) + fill = bg_colors.get(uid, (255, 255, 255)) + page.add_redact_annot( + fitz.Rect(x0 - pad, y0 - pad, x1 + pad, y1 + pad), + fill=[c / 255 for c in fill], + ) + had_annot = True + + if had_annot: + # IMAGE_NONE + LINE_ART_NONE: only strip text layer, don't rasterize + page.apply_redactions( + images=fitz.PDF_REDACT_IMAGE_NONE, + graphics=fitz.PDF_REDACT_LINE_ART_NONE, + ) + + doc.save(str(out_path), garbage=3, deflate=True) + finally: + doc.close() + + +def _sample_colors( + pdf_path: Path, + parsed: dict, + cfg: RenderConfig, + sizes: dict[str, float], + bg_colors: dict[str, RGB], + text_colors: dict[str, RGB], + stats: dict, +) -> None: + if not cfg.background.enabled and not cfg.text_color.enabled: + return + + doc = fitz.open(str(pdf_path)) + try: + for page_idx, page_data in enumerate(parsed.get("pages", [])): + orig = page_data.get("page_index", page_idx) + if cfg.pages is not None and orig not in cfg.pages: + continue + if orig >= doc.page_count: + continue + page = doc[orig] + pw = page_data.get("page_width", page.rect.width) + ph = page_data.get("page_height", page.rect.height) + + for elem_idx, elem in enumerate(page_data.get("elements", [])): + category = elem.get("category", "") + if category == "BYPASS": + continue + + uid = f"p{page_idx}:e{elem_idx}" + bbox = elem.get("bbox_pdf", [0, 0, 10, 10]) + + if category == "TABLE": + for cell_idx, cell in enumerate(elem.get("cells", [])): + cell_source = cell.get("source_text") or "" + if not cell_source.strip(): + continue + cell_uid = f"{uid}:c{cell_idx}" + # renderer.py:270 — dùng bbox_text (đồng bộ với render/redact), fallback về bbox_pdf + cbbox = cell.get("bbox_text") or cell.get("bbox_pdf", bbox) + bg = prepare_cover(page, cbbox, pw, ph, cfg.background) + bg_colors[cell_uid] = bg.rgb + tc = sample_text_color( + page, cbbox, pw, ph, bg.rgb, cfg.text_color + ) + text_colors[cell_uid] = tc + stats["bg_samples"] += 1 + else: + # User-added boxes may carry explicit color overrides + # (review.add_element); honor them instead of sampling. + ov_bg = elem.get("bg_color") + ov_tc = elem.get("text_color") + if ov_bg: + bg_rgb = tuple(ov_bg) + else: + bg_rgb = prepare_cover(page, bbox, pw, ph, cfg.background).rgb + bg_colors[uid] = bg_rgb + if ov_tc: + text_colors[uid] = tuple(ov_tc) + else: + text_colors[uid] = sample_text_color( + page, bbox, pw, ph, bg_rgb, cfg.text_color + ) + stats["bg_samples"] += 1 + finally: + doc.close() diff --git a/pdf2zh/render/sizing.py b/pdf2zh/render/sizing.py new file mode 100644 index 0000000000000000000000000000000000000000..eb42727cbffd07be94b62bf1654e57e9be26138a --- /dev/null +++ b/pdf2zh/render/sizing.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import math +import re + +from .config import SizingConfig +from .labels import group_for_label, normalize_label + +_TAG_RE = re.compile(r"<[^>]+>") +_TYPST_BLOCK_RE = re.compile(r"]*>.*?", re.DOTALL | re.IGNORECASE) + + +def _autofit(text: str, bbox_w: float, bbox_h: float, cfg: SizingConfig) -> float: + """Binary-search the largest font_size where `text` fits in (bbox_w × bbox_h). + + More accurate than the closed-form sqrt model because it correctly handles + multi-line wrapping for both short texts (may only need 1 line) and long + texts (may need many lines). + """ + n = max(1, len(_TAG_RE.sub("", text).strip())) + lo, hi = 4.0, bbox_h # upper bound: can't exceed bbox height + + for _ in range(24): # converges to ~0.001pt precision + mid = (lo + hi) / 2.0 + chars_per_line = max(1.0, bbox_w / (mid * cfg.char_width_ratio)) + n_lines = math.ceil(n / chars_per_line) + needed_h = n_lines * mid * cfg.leading_ratio + if needed_h <= bbox_h: + lo = mid + else: + hi = mid + + # Additional cap: autofit should never exceed cap_height_ratio × bbox_h + # (single-line glyph height is always a fraction of bbox height). + return min(lo, bbox_h * cfg.cap_height_ratio) + + +def _estimate_height( + text: str, bbox_w: float, font_size: float, cfg: SizingConfig +) -> float: + """Estimate rendered height of text at font_size in a bbox_w-wide column.""" + n = max(1, len(_TAG_RE.sub("", text).strip())) + chars_per_line = max(1.0, bbox_w / (font_size * cfg.char_width_ratio)) + n_lines = math.ceil(n / chars_per_line) + return n_lines * font_size * cfg.leading_ratio + + +def _overflow_collides( + bbox: list[float], + text: str, + font_size: float, + cfg: SizingConfig, + other_bboxes: list[list[float]], +) -> bool: + """True if text at font_size overflows bbox AND that overflow region hits another element. + + Single-line elements (h < 2× font_size) overflow horizontally to the right; + multi-line elements overflow vertically downward. + """ + x0, y0, x1, y1 = bbox + w = max(1.0, x1 - x0) + h = max(1.0, y1 - y0) + n = max(1, len(_TAG_RE.sub("", text).strip())) + + if h < font_size * 2.0: + # Single-line: text extends to the right rather than wrapping down. + natural_w = n * font_size * cfg.char_width_ratio + if natural_w <= w: + return False + # Overflow zone: horizontal strip to the right of the bbox. + ov_x1 = x0 + natural_w + for ob in other_bboxes: + ox0, oy0, ox1, oy1 = ob + if ox0 < ov_x1 and ox1 > x1 and oy0 < y1 and oy1 > y0: + return True + return False + else: + # Multi-line: text wraps and extends downward. + needed_h = _estimate_height(text, w, font_size, cfg) + if needed_h <= h: + return False + ov_y0, ov_y1 = y1, y0 + needed_h + for ob in other_bboxes: + ox0, oy0, ox1, oy1 = ob + if ox0 < x1 and ox1 > x0 and oy0 < ov_y1 and oy1 > ov_y0: + return True + return False + + +def assign_render_sizes(parsed: dict, cfg: SizingConfig) -> dict[str, float]: + """Return {uid: font_size_pt} for every element and cell. + + Strategy: + 1. Cluster source_text autofits per label+page → source_canonical (the + representative size for that group, reflecting original layout intent). + 2. For each element: use source_canonical unless translated text overflows + AND the overflow region collides with another element on the same page. + Harmless overflow (into empty space) is allowed to preserve uniformity. + Table cells use one uniform size per table (the source cluster + canonical, like regular text) so a text-heavy cell can't shrink all. + + uid format: + "p{page_idx}:e{elem_idx}" for elements + "p{page_idx}:e{elem_idx}:c{cell_idx}" for table cells + """ + # bucket → [(uid, source_autofit)] + raw: dict[str, list[tuple[str, float]]] = {} + # uid → translated_text autofit ceiling + translated_ceiling: dict[str, float] = {} + # uid → {page_idx, bbox, translated} for collision check + elem_meta: dict[str, dict] = {} + # page_idx → all element bboxes on that page (for collision detection) + page_all_bboxes: dict[int, list[list[float]]] = {} + + for page_idx, page in enumerate(parsed.get("pages", [])): + all_bboxes: list[list[float]] = [] + for elem in page.get("elements", []): + bbox = elem.get("bbox_pdf") + if bbox: + all_bboxes.append(bbox) + page_all_bboxes[page_idx] = all_bboxes + + for elem_idx, elem in enumerate(page.get("elements", [])): + uid = f"p{page_idx}:e{elem_idx}" + category = elem.get("category", "") + label = normalize_label(elem.get("label", "")) + group = group_for_label(label, cfg) + + if category != "BYPASS" and group: + source = elem.get("source_text") or "" + translated = elem.get("translated_text") or "" + bbox = elem.get("bbox_pdf", [0, 0, 10, 10]) + w = max(1.0, bbox[2] - bbox[0]) + h = max(1.0, bbox[3] - bbox[1]) + + pdf_fs = float(elem.get("font_size") or 0.0) + src_fs = ( + _autofit(source, w, h, cfg) + if source.strip() + else (pdf_fs if pdf_fs > 0 else cfg.fallback_size) + ) + # blocks contain grid layout syntax — their char count is + # meaningless for autofit. Let Typst engine determine the size. + if _TYPST_BLOCK_RE.search(translated): + t_fs = cfg.fallback_size + elif translated.strip(): + t_fs = _autofit(translated, w, h, cfg) + else: + t_fs = cfg.fallback_size + + translated_ceiling[uid] = t_fs + elem_meta[uid] = { + "page_idx": page_idx, + "bbox": bbox, + "translated": translated, + } + scope = cfg.cluster_scope_by_group.get(group, "page") + scope_key = "doc" if scope == "document" else str(page_idx) + raw.setdefault(f"{group}|{scope_key}", []).append((uid, src_fs)) + + # TABLE cells: cluster per table. + cells = elem.get("cells", []) + if cells: + table_bucket = f"table|{uid}" + parent_bbox = elem.get("bbox_pdf", [0, 0, 10, 10]) + for cell_idx, cell in enumerate(cells): + cell_uid = f"{uid}:c{cell_idx}" + cell_source = cell.get("source_text") or "" + if not cell_source.strip(): + continue + # Size from bbox_text (tight box hugging the text) so it + # matches where source_builder actually places it. + cbbox = cell.get("bbox_text") or cell.get("bbox_pdf", parent_bbox) + cw = max(1.0, cbbox[2] - cbbox[0]) + ch = max(1.0, cbbox[3] - cbbox[1]) + + pdf_cs = float(cell.get("cell_font_size") or 0.0) + src_cs = ( + _autofit(cell_source, cw, ch, cfg) + if cell_source.strip() + else (pdf_cs if pdf_cs > 0 else cfg.fallback_size) + ) + raw.setdefault(table_bucket, []).append((cell_uid, src_cs)) + + # ---- cluster on source, assign per-element sizes ---- + result: dict[str, float] = {} + + for bucket, items in raw.items(): + valid = [(uid, s) for uid, s in items if s > 0] + fallback = cfg.fallback_size + is_table = bucket.startswith("table|") + + if not valid: + for uid, _ in items: + result[uid] = fallback + continue + + clusters = _greedy_cluster([(s, uid) for uid, s in valid], cfg.cluster_eps_pt) + uid_to_canonical: dict[str, float] = {} + for cluster in clusters: + cluster_canonical = _median([s for s, _ in cluster]) + for _, uid in cluster: + uid_to_canonical[uid] = cluster_canonical + + best_cluster = max(clusters, key=lambda c: (len(c), _median([s for s, _ in c]))) + source_canonical = _median([s for s, _ in best_cluster]) + + if is_table: + # One uniform size per table, detected from the source cell sizes + # (the cluster canonical — same method as regular text), NOT the min + # of translated ceilings: a single text-heavy cell no longer shrinks + # the whole table. cell_font_scale keeps text clear of cell borders. + canonical = source_canonical * cfg.cell_font_scale + canonical = max(max(2.0, fallback * 0.5), canonical) + for uid, _ in items: + result[uid] = canonical + else: + # Non-table: use cluster's canonical size for each element. + # Only reduce for elements whose overflow would collide with another element. + for uid, _ in items: + elem_canonical = uid_to_canonical.get(uid, fallback) + t_ceiling = translated_ceiling.get(uid, fallback) + if t_ceiling >= elem_canonical: + # Translated text fits at elem_canonical — no overflow. + result[uid] = elem_canonical + else: + # Translated text overflows. Allow it only if the overflow + # region doesn't collide with another element on the page. + meta = elem_meta.get(uid, {}) + page_idx = meta.get("page_idx", -1) + bbox = meta.get("bbox", [0, 0, 10, 10]) + translated = meta.get("translated", "") + others = [ + b for b in page_all_bboxes.get(page_idx, []) if b is not bbox + ] + if _overflow_collides( + bbox, translated, elem_canonical, cfg, others + ): + # Shrink toward the fit ceiling, but keep a readability + # floor. The floor must never exceed the size we shrink + # from — otherwise a page whose canonical is below + # ``fallback`` would inflate colliding blocks above the + # cluster instead of reducing them. + floor = min(fallback, elem_canonical) + result[uid] = max(floor, min(elem_canonical, t_ceiling)) + else: + result[uid] = elem_canonical + + return result + + +def _median(vals: list[float]) -> float: + s = sorted(vals) + n = len(s) + return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2.0 + + +def _greedy_cluster( + items: list[tuple[float, str]], eps: float +) -> list[list[tuple[float, str]]]: + """1-D greedy binning: extend current cluster while next value ≤ cluster_max + eps.""" + if not items: + return [] + items = sorted(items, key=lambda x: x[0]) + clusters: list[list[tuple[float, str]]] = [[items[0]]] + for size, uid in items[1:]: + cur_max = max(s for s, _ in clusters[-1]) + if size <= cur_max + eps: + clusters[-1].append((size, uid)) + else: + clusters.append([(size, uid)]) + return clusters diff --git a/pdf2zh/render/source_builder.py b/pdf2zh/render/source_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..7f0e7663d12c2130b020286c732ddf0ac80c2317 --- /dev/null +++ b/pdf2zh/render/source_builder.py @@ -0,0 +1,721 @@ +from __future__ import annotations + +import re + +from .background import RGB +from .config import RenderConfig, StyleSpec +from .labels import normalize_label, skip_oversize_element, style_key +from .markup import ( + _split_math_vars, + escape_typst_string, + has_bare_latex, + has_malformed_typst_math, + has_unbalanced_math_tags, + is_pure_math_text, + parse_toc_entries, + to_typst_markup, + to_typst_native, +) + +CMARKER_VERSION = "0.1.8" + + +MITEX_VERSION = "0.2.6" + +# Detects legacy LaTeX inside tags (backslash commands like \frac, \sum) +_LATEX_IN_MATH = re.compile(r"]*>[^<]*\\[a-zA-Z]", re.DOTALL) + +# Detects bare Typst math function calls (frac(...), sqrt(...), etc.) outside tags +_BARE_TYPST_MATH = re.compile( + r"(?:^|[^a-zA-Z])(?:frac|sqrt|root|binom|sum|prod|integral|mat|vec|cases|abs|norm|floor|ceil)\s*\(", + re.IGNORECASE, +) + +_FIT_HELPERS = """\ +#let pdftr_fit_size(lo, hi, eps, fits) = { + if hi - lo <= eps { + lo + } else { + let mid = lo + (hi - lo) / 2 + if fits(mid) { + pdftr_fit_size(mid, hi, eps, fits) + } else { + pdftr_fit_size(lo, mid, eps, fits) + } + } +} +#let pdftr_floor_size(value, floor) = if value < floor { floor } else { value } +#let pdftr_floor_leading(value, floor) = if value < floor { floor } else { value } +#let pdftr_fit_markdown(markdown, max_size: 10pt, min_size: 9pt, max_leading: 0.66em, min_leading: 0.54em, fit_height: none, weight: "regular", style: "normal", eps: 0.08pt, math: none) = { + layout(size => { + let allowed-height = if fit_height == none { size.height } else { calc.min(size.height, fit_height) } + let render(text_size, leading) = block(width: size.width)[#{ + set text(size: text_size, weight: weight, style: style) + set par(leading: leading) + cmarker.render(markdown, math: math) + }] + let fits(text_size, leading) = measure(width: size.width, render(text_size, leading)).height <= allowed-height + if fits(max_size, max_leading) { + render(max_size, max_leading) + } else { + let fallback_min_size = pdftr_floor_size(min_size - 1.6pt, 5.4pt) + let fallback_min_leading = pdftr_floor_leading(min_leading - 0.12em, 0.14em) + let emergency_min_size = pdftr_floor_size(fallback_min_size - 1.2pt, 4.8pt) + let emergency_min_leading = pdftr_floor_leading(fallback_min_leading - 0.08em, 0.10em) + let chosen_leading = if fits(min_size, max_leading) { max_leading } else { min_leading } + let chosen_size = if not fits(min_size, chosen_leading) { + let fallback_leading = pdftr_floor_leading(chosen_leading - 0.12em, fallback_min_leading) + let emergency_leading = pdftr_floor_leading(fallback_leading - 0.08em, emergency_min_leading) + if not fits(fallback_min_size, fallback_leading) { + if not fits(emergency_min_size, emergency_leading) { + emergency_min_size + } else { + pdftr_fit_size(emergency_min_size, fallback_min_size, eps, size_pt => fits(size_pt, emergency_leading)) + } + } else { + pdftr_fit_size(fallback_min_size, min_size, eps, size_pt => fits(size_pt, fallback_leading)) + } + } else { + pdftr_fit_size(min_size, max_size, eps, size_pt => fits(size_pt, chosen_leading)) + } + let final_leading = if fits(min_size, chosen_leading) { + chosen_leading + } else if fits(fallback_min_size, pdftr_floor_leading(chosen_leading - 0.12em, fallback_min_leading)) { + pdftr_floor_leading(chosen_leading - 0.12em, fallback_min_leading) + } else { + emergency_min_leading + } + render(chosen_size, final_leading) + } + }) +} +#let pdftr_fit_typst(content, max_size: 10pt, min_size: 9pt, max_leading: 0.66em, min_leading: 0.54em, fit_height: none, weight: "regular", style: "normal", eps: 0.08pt, no_wrap: false) = { + layout(size => { + let allowed-height = if fit_height == none { size.height } else { calc.min(size.height, fit_height) } + let render(text_size, leading) = block(width: size.width)[#{ + set text(size: text_size, weight: weight, style: style) + set par(leading: leading) + content + }] + if no_wrap { + // Single-line mode: find largest font where content does not wrap. + // Compare height at container width vs height at huge width — equal means no wrap. + let no_wrap_fits(text_size) = { + let h_narrow = measure(width: size.width, render(text_size, max_leading)).height + let h_wide = measure(width: 10000pt, block(width: 10000pt)[#{ + set text(size: text_size, weight: weight, style: style) + set par(leading: max_leading) + content + }]).height + h_narrow <= h_wide + } + let chosen_size = if no_wrap_fits(max_size) { + max_size + } else if not no_wrap_fits(min_size) { + min_size + } else { + pdftr_fit_size(min_size, max_size, eps, size_pt => no_wrap_fits(size_pt)) + } + render(chosen_size, max_leading) + } else { + let fits(text_size, leading) = measure(width: size.width, render(text_size, leading)).height <= allowed-height + if fits(max_size, max_leading) { + render(max_size, max_leading) + } else { + let fallback_min_size = pdftr_floor_size(min_size - 1.6pt, 5.4pt) + let fallback_min_leading = pdftr_floor_leading(min_leading - 0.12em, 0.14em) + let emergency_min_size = pdftr_floor_size(fallback_min_size - 1.2pt, 4.8pt) + let emergency_min_leading = pdftr_floor_leading(fallback_min_leading - 0.08em, 0.10em) + let chosen_leading = if fits(min_size, max_leading) { max_leading } else { min_leading } + let chosen_size = if not fits(min_size, chosen_leading) { + let fallback_leading = pdftr_floor_leading(chosen_leading - 0.12em, fallback_min_leading) + let emergency_leading = pdftr_floor_leading(fallback_leading - 0.08em, emergency_min_leading) + if not fits(fallback_min_size, fallback_leading) { + if not fits(emergency_min_size, emergency_leading) { + emergency_min_size + } else { + pdftr_fit_size(emergency_min_size, fallback_min_size, eps, size_pt => fits(size_pt, emergency_leading)) + } + } else { + pdftr_fit_size(fallback_min_size, min_size, eps, size_pt => fits(size_pt, fallback_leading)) + } + } else { + pdftr_fit_size(min_size, max_size, eps, size_pt => fits(size_pt, chosen_leading)) + } + let final_leading = if fits(min_size, chosen_leading) { + chosen_leading + } else if fits(fallback_min_size, pdftr_floor_leading(chosen_leading - 0.12em, fallback_min_leading)) { + pdftr_floor_leading(chosen_leading - 0.12em, fallback_min_leading) + } else { + emergency_min_leading + } + render(chosen_size, final_leading) + } + } + }) +}""" + + +def _rgb_typst(rgb: RGB) -> str: + return f"rgb({rgb[0]}, {rgb[1]}, {rgb[2]})" + + +def _font_typst(font: str | list[str]) -> str: + """Render Typst font expression. Accepts a single name or fallback chain.""" + if isinstance(font, str): + return f'"{font}"' + return "(" + ", ".join(f'"{f}"' for f in font) + ")" + + +def _style_for(label: str, cfg: RenderConfig) -> StyleSpec: + return cfg.styles.get(style_key(label), cfg.default_style) + + +def _cover_rect( + var: str, x0: float, y0: float, x1: float, y1: float, bg: RGB, pad: float +) -> str: + w = max(4.0, x1 - x0 + 2 * pad) + h = max(4.0, y1 - y0 + 2 * pad) + dx = x0 - pad + dy = y0 - pad + return ( + f"#let {var}_cover = rect(width: {w:.2f}pt, height: {h:.2f}pt," + f" fill: {_rgb_typst(bg)}, stroke: none)\n" + f"#context {{ place(top + left, dx: {dx:.2f}pt, dy: {dy:.2f}pt, {var}_cover) }}\n" + ) + + +def _downward_avail_height( + bbox: list[float], + all_bboxes: list[list[float]], + page_height: float, + max_expand: float, +) -> float: + """Block height that lets text overflow DOWN into empty space only. + + Extends the tight bbox height downward until it would reach the nearest + element below that horizontally overlaps it — capped by ``max_expand`` and + the page bottom. Mirrors the sizing heuristic: the Typst fit then keeps the + chosen size when overflow lands in empty space and only shrinks when the + text would actually collide with a neighbor below. + """ + x0, y0, x1, y1 = bbox + limit = min(page_height, y1 + max_expand) + for ob in all_bboxes: + if ob is bbox or len(ob) != 4: + continue + ox0, oy0, ox1, _ = ob + if ox1 > x0 and ox0 < x1 and oy0 >= y1 - 0.5: # below & horizontally overlaps + limit = min(limit, oy0) + return max(y1 - y0, limit - y0) + + +def _rightward_avail_width( + bbox: list[float], + all_bboxes: list[list[float]], + page_width: float, +) -> float: + """Block width that lets single-line text extend RIGHT into empty space only. + + Extends the tight bbox width rightward until it would reach the nearest + element to the right that vertically overlaps it — capped by the page edge. + Without this, a single-line block expands to the page margin and can overrun + a right-hand neighbor (e.g. a TOC page number) without shrinking; bounding it + lets the no-wrap fit shrink the text before it collides. + """ + x0, y0, x1, y1 = bbox + limit = page_width + for ob in all_bboxes: + if ob is bbox or len(ob) != 4: + continue + ox0, oy0, _, oy1 = ob + if ox0 >= x1 - 0.5 and oy0 < y1 and oy1 > y0: # right & vertically overlaps + limit = min(limit, ox0) + return max(x1 - x0, limit - x0) + + +def _text_block( + var: str, + x0: float, + y0: float, + x1: float, + y1: float, + markdown: str, + font_size: float, + min_font: float, + weight: str, + style_: str, + text_color: RGB, + font_family: str, + expanded_w: float | None = None, + expanded_h: float | None = None, + valign: str = "top", +) -> str: + w = max(4.0, expanded_w if expanded_w is not None else (x1 - x0)) + h = max(4.0, expanded_h if expanded_h is not None else (y1 - y0)) + # Ensure min_size <= max_size; otherwise the binary-search fit helper + # gets lo > hi and behaves incorrectly. + effective_min = min(min_font, font_size) + escaped = escape_typst_string(markdown) + fit_call = ( + f"pdftr_fit_markdown({var}_md," + f" max_size: {font_size:.2f}pt, min_size: {effective_min:.2f}pt," + f' weight: "{weight}", style: "{style_}")' + ) + if valign == "bottom": + content = f"align(bottom + left, {fit_call})" + elif valign == "center": + content = f"align(center + horizon, {fit_call})" + else: + content = fit_call + return ( + f'#let {var}_md = "{escaped}"\n' + f"#let {var}_body = block(width: {w:.2f}pt, height: {h:.2f}pt)[#{{\n" + f" set text(font: {_font_typst(font_family)}, fill: {_rgb_typst(text_color)})\n" + f" {content}\n" + f"}}]\n" + f"#context {{ place(top + left, dx: {x0:.2f}pt, dy: {y0:.2f}pt, {var}_body) }}\n" + ) + + +def _text_block_typst( + var: str, + x0: float, + y0: float, + x1: float, + y1: float, + typst_markup: str, + font_size: float, + min_font: float, + weight: str, + style_: str, + text_color: RGB, + font_family: str, + no_wrap: bool = False, + expanded_w: float | None = None, + expanded_h: float | None = None, + valign: str = "top", +) -> str: + w = max(4.0, expanded_w if expanded_w is not None else (x1 - x0)) + h = max(4.0, expanded_h if expanded_h is not None else (y1 - y0)) + effective_min = min(min_font, font_size) + no_wrap_arg = ", no_wrap: true" if no_wrap else "" + fit_call = ( + f"pdftr_fit_typst({var}_tm," + f" max_size: {font_size:.2f}pt, min_size: {effective_min:.2f}pt," + f' weight: "{weight}", style: "{style_}"{no_wrap_arg})' + ) + if valign == "center": + content = f"align(center + horizon, {fit_call})" + elif valign == "bottom": + content = f"align(bottom + left, {fit_call})" + else: + content = fit_call + return ( + f"#let {var}_tm = [{typst_markup}]\n" + f"#let {var}_body = block(width: {w:.2f}pt, height: {h:.2f}pt)[#{{\n" + f" set text(font: {_font_typst(font_family)}, fill: {_rgb_typst(text_color)})\n" + f" {content}\n" + f"}}]\n" + f"#context {{ place(top + left, dx: {x0:.2f}pt, dy: {y0:.2f}pt, {var}_body) }}\n" + ) + + +_TOC_TOP_LEVEL_RE = re.compile(r"^\d+\s+\S") + + +def _toc_block( + var: str, + x0: float, + y0: float, + x1: float, + y1: float, + translated_text: str, + font_size: float, + min_font: float, + text_color: RGB, + font_family: str, + rendered_pages: set[int] | None = None, +) -> str: + """Render TOC entries with right-aligned page numbers + clickable links. + + Each entry whose target page is in `rendered_pages` (1-indexed) gets + wrapped in `#link()[...]` so clicking jumps to that page. + + Top-level entries (section number with no dot, e.g. '1 Introduction') + render in bold; sub-entries stay regular. + + Auto-shrinks via `pdftr_fit_typst` when entries overflow the bbox. + """ + entries = parse_toc_entries(translated_text) + markup_lines: list[str] = [] + for title, page_num in entries: + title_escaped = escape_typst_string(to_typst_markup(title)) + weight = "bold" if _TOC_TOP_LEVEL_RE.match(title) else "regular" + if page_num: + row = ( + f"grid(columns: (1fr, auto), gutter: 4pt, " + f'text(weight: "{weight}", "{title_escaped} "), ' + f'align(right, text(weight: "{weight}", "{page_num}")))' + ) + page_int = int(page_num) + if rendered_pages is None or page_int in rendered_pages: + markup_lines.append(f"#link()[#{row}]") + else: + markup_lines.append(f"#{row}") + else: + markup_lines.append(f'#par(text(weight: "{weight}", "{title_escaped}"))') + typst_markup = "\n".join(markup_lines) + return _text_block_typst( + var, + x0, + y0, + x1, + y1, + typst_markup, + font_size, + min_font, + "regular", + "normal", + text_color, + font_family, + ) + + +def build_typst_source( + parsed: dict, + sizes: dict[str, float], + bg_colors: dict[str, RGB], + text_colors: dict[str, RGB], + cfg: RenderConfig, + fallback_vars: set[str] | frozenset[str] = frozenset(), +) -> str: + """Build the overlay Typst source. + + Args: + fallback_vars: element vars (e.g. ``e3_7``) whose markup previously + broke the Typst compile — rendered via the plain markdown-string + path instead of native Typst markup (always syntactically valid). + """ + lines: list[str] = [ + f"#set text(font: {_font_typst(cfg.font_family)})", + f'#import "@preview/cmarker:{CMARKER_VERSION}"', + _FIT_HELPERS, + ] + + pages = parsed.get("pages", []) + # 1-indexed anchor numbers actually emitted (enumerate position of each + # rendered page) — used to gate TOC links so we never #link a missing + # anchor (Typst errors on dangling label references). + rendered_pages = { + page_idx + 1 + for page_idx, page in enumerate(pages) + if cfg.pages is None or page.get("page_index", page_idx) in cfg.pages + } + for page_idx, page in enumerate(pages): + if cfg.pages is not None and page.get("page_index", page_idx) not in cfg.pages: + continue + pw = page.get("page_width", 595.0) + ph = page.get("page_height", 842.0) + lines.append( + f"#set page(width: {pw:.2f}pt, height: {ph:.2f}pt, margin: 0pt, fill: none)" + ) + # Anchor for TOC links — page number is 1-indexed (user-facing). + lines.append(f"#metadata(none)") + + elems = page.get("elements", []) + # All element bboxes on this page — used to bound downward text overflow + # so a block only shrinks when its overflow would hit a neighbor. + page_bboxes = [e.get("bbox_pdf") for e in elems if e.get("bbox_pdf")] + + for elem_idx, elem in enumerate(elems): + category = elem.get("category", "") + label = normalize_label(elem.get("label", "Text")) + uid = f"p{page_idx}:e{elem_idx}" + var = f"e{page_idx}_{elem_idx}" + + if category == "BYPASS": + continue + + bbox = elem.get("bbox_pdf", [0, 0, 100, 20]) + x0, y0, x1, y1 = bbox + + # A minor/structural element (section header, caption, footnote, …) + # covering most of the page is a mis-detection — keep the original. + # Real content (tables, body text, equations) is never skipped by + # size. The redaction pass uses this same predicate, so the two + # stages always agree on what to skip (else content gets erased but + # not redrawn). + if skip_oversize_element(label, bbox, pw, ph): + continue + bg = bg_colors.get(uid, cfg.background.fallback_bg) + tc = text_colors.get(uid, (0, 0, 0)) + style = _style_for(label, cfg) + font_size = sizes.get(uid, cfg.sizing.fallback_size) + + if category == "EQUATION": + translated = elem.get("translated_text") or "" + source = elem.get("source_text") or "" + if not translated or translated == source: + continue + # Malformed / bare-LaTeX output → preserve original text layer. + # For EQUATION, skip is_pure_math_text: translated_text is intentionally + # Typst math markup and should be rendered even if it has no prose. + if ( + has_unbalanced_math_tags(translated) + or has_bare_latex(translated) + or has_malformed_typst_math(translated) + ): + continue + if category != "EQUATION" and is_pure_math_text(translated): + continue + lines.append( + _cover_rect( + var, x0, y0, x1, y1, bg, cfg.background.eraser_padding_pt + ) + ) + # EQUATION elements that reach here always contain prose mixed + # with math (pure-math equations have no translated_text and are + # skipped above). Fractions make the bbox taller than the actual + # text size, so use the cluster font_size, not y1 - y0. + eq_max_size = font_size + if var not in fallback_vars and ( + " tags) — wrap in $ and use native path + typst_markup = f"${_split_math_vars(translated)}$" + is_single_line = (y1 - y0) < font_size * 1.8 + exp_w = ( + _rightward_avail_width(bbox, page_bboxes, pw) + if is_single_line + else None + ) + exp_h = ( + _downward_avail_height(bbox, page_bboxes, ph, cfg.max_expand_pt) + if cfg.expand_downward and not is_single_line + else None + ) + lines.append( + _text_block_typst( + var, + x0, + y0, + x1, + y1, + typst_markup, + font_size, + cfg.min_font_size_pt, + style.weight, + style.style_, + tc, + cfg.font_family, + no_wrap=is_single_line, + expanded_w=exp_w, + expanded_h=exp_h, + ) + ) + else: + # No tags, no Typst functions — plain text/LaTeX, use cmarker/mitex + markdown = to_typst_markup(translated) + is_single_line = (y1 - y0) < font_size * 1.8 + exp_w = ( + _rightward_avail_width(bbox, page_bboxes, pw) + if is_single_line + else None + ) + exp_h = ( + _downward_avail_height(bbox, page_bboxes, ph, cfg.max_expand_pt) + if cfg.expand_downward and not is_single_line + else None + ) + lines.append( + _text_block( + var, + x0, + y0, + x1, + y1, + markdown, + font_size, + cfg.min_font_size_pt, + style.weight, + style.style_, + tc, + cfg.font_family, + expanded_w=exp_w, + expanded_h=exp_h, + ) + ) + + # Add pagebreak between pages (not after the last one) + if page_idx < len(pages) - 1: + lines.append("#pagebreak()") + + return "\n".join(lines) + "\n" diff --git a/pdf2zh/render/text.py b/pdf2zh/render/text.py new file mode 100644 index 0000000000000000000000000000000000000000..4b2fa579ac61af80cfe56763c00526d534c1584e --- /dev/null +++ b/pdf2zh/render/text.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import re + +_HTML_TAG = re.compile(r"", re.IGNORECASE) + + +def strip_html_tags(s: str) -> str: + return _HTML_TAG.sub("", s) + + +def inflate(bbox: list[float], pad: float) -> list[float]: + x0, y0, x1, y1 = bbox + return [x0 - pad, y0 - pad, x1 + pad, y1 + pad] + + +def clamp(bbox: list[float], pw: float, ph: float) -> list[float]: + x0, y0, x1, y1 = bbox + return [max(0.0, x0), max(0.0, y0), min(pw, x1), min(ph, y1)] diff --git a/pdf2zh/translation/__init__.py b/pdf2zh/translation/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..66a1becca4d719a64da738536b834a36f259178e --- /dev/null +++ b/pdf2zh/translation/__init__.py @@ -0,0 +1,36 @@ +from .chunker import collect_translatables, segments_to_chunks +from .config import PROVIDERS, TranslatorConfig, resolve_provider +from .gateway import Gateway, RateLimiter +from .math_fixer import collect_math_candidates, fix_math_document +from .models import Task +from .pipeline import extract_glossary, translate_chunks, translate_document +from .predicates import is_equation_only, is_plain_text +from .prompts import ( + build_glossary_prompt, + build_translation_prompt, + glossary_block_for_chunk, +) +from .toc_fixer import collect_toc_candidates, fix_toc_document + +__all__ = [ + "PROVIDERS", + "TranslatorConfig", + "resolve_provider", + "Task", + "is_plain_text", + "is_equation_only", + "collect_translatables", + "segments_to_chunks", + "build_translation_prompt", + "build_glossary_prompt", + "glossary_block_for_chunk", + "Gateway", + "RateLimiter", + "translate_document", + "extract_glossary", + "translate_chunks", + "fix_math_document", + "collect_math_candidates", + "fix_toc_document", + "collect_toc_candidates", +] diff --git a/pdf2zh/translation/chunker.py b/pdf2zh/translation/chunker.py new file mode 100644 index 0000000000000000000000000000000000000000..ebfd182608c4517682180d23a6c8ea17b1e1b79b --- /dev/null +++ b/pdf2zh/translation/chunker.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import json + +from .models import Task +from .predicates import has_prose_for_equation, is_plain_text + + +def collect_translatables(doc: dict) -> list[Task]: + tasks: list[Task] = [] + idx = 0 + for page in doc.get("pages", []): + for elem in page.get("elements", []): + category = elem.get("category", "") + cells = elem.get("cells", []) + # TABLE with cells: translate each cell individually. + is_table_with_cells = category == "TABLE" and cells + if not is_table_with_cells: + src = elem.get("source_text", "") + # EQUATION source_text is handled by equation_vision_pass, not here. + if ( + src + and category != "BYPASS" + and category != "EQUATION" + and has_prose_for_equation(src) + ): + tasks.append(Task(elem, "translated_text", src, str(idx))) + idx += 1 + latex = elem.get("latex", "") + if latex and is_plain_text(latex): + tasks.append(Task(elem, "translated_latex", latex, str(idx))) + idx += 1 + for cell in cells: + text = cell.get("source_text", "") + if text.strip() and has_prose_for_equation(text): + tasks.append(Task(cell, "translated_text", text, str(idx))) + idx += 1 + return tasks + + +def segments_to_chunks(tasks: list[Task], max_bytes: int) -> list[dict[str, str]]: + chunks: list[dict[str, str]] = [] + chunk: dict[str, str] = {} + for task in tasks: + candidate = {**chunk, task.id: task.text} + size = len(json.dumps(candidate, ensure_ascii=False).encode()) + if size > max_bytes and chunk: + chunks.append(chunk) + chunk = {task.id: task.text} + else: + chunk = candidate + if chunk: + chunks.append(chunk) + return chunks diff --git a/pdf2zh/translation/cli.py b/pdf2zh/translation/cli.py new file mode 100644 index 0000000000000000000000000000000000000000..8d3e666eda644d45447e1c15b3807e2d1d479ad1 --- /dev/null +++ b/pdf2zh/translation/cli.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import argparse +import json +import logging +from pathlib import Path + +from .config import PROVIDERS, TranslatorConfig +from .pipeline import translate_document + +logger = logging.getLogger("json_translator") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Translate PDFTranslator JSON output") + parser.add_argument("input", help="Input JSON file") + parser.add_argument( + "-o", "--output", help="Output JSON file (default: INPUT.translated.json)" + ) + parser.add_argument("--src", dest="source_language", default="") + parser.add_argument("--tgt", dest="target_language", default="") + parser.add_argument("--provider", default="openrouter", choices=list(PROVIDERS)) + parser.add_argument("--model", default=None) + parser.add_argument("--api-key", dest="api_key", default=None) + parser.add_argument("--concurrent", type=int, default=30) + parser.add_argument("--chunk-bytes", type=int, default=3000) + parser.add_argument("--no-glossary", action="store_true") + parser.add_argument( + "--no-math-fix", + action="store_true", + help="Skip post-translation math/layout fix pass", + ) + parser.add_argument( + "--no-toc-fix", + action="store_true", + help="Skip post-translation table-of-contents fix pass", + ) + parser.add_argument("--length-tolerance", type=float, default=0.15) + parser.add_argument("--rpm", type=int, default=None) + parser.add_argument("--tpm", type=int, default=None) + parser.add_argument("--verbose", action="store_true") + args = parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + ) + + with open(args.input, encoding="utf-8") as f: + doc = json.load(f) + + cfg = TranslatorConfig( + source_language=args.source_language or doc.get("source_language", ""), + target_language=args.target_language or doc.get("target_language", ""), + provider=args.provider, + model=args.model, + api_key=args.api_key, + concurrent=args.concurrent, + chunk_bytes=args.chunk_bytes, + glossary_enabled=not args.no_glossary, + math_fix_enabled=not args.no_math_fix, + toc_fix_enabled=not args.no_toc_fix, + length_tolerance=args.length_tolerance, + rpm=args.rpm, + tpm=args.tpm, + ) + + doc = translate_document(doc, cfg) + + output = args.output or (str(Path(args.input).with_suffix("")) + ".translated.json") + with open(output, "w", encoding="utf-8") as f: + json.dump(doc, f, ensure_ascii=False, indent=2) + logger.info(f"Saved to {output}") + + +if __name__ == "__main__": + main() diff --git a/pdf2zh/translation/config.py b/pdf2zh/translation/config.py new file mode 100644 index 0000000000000000000000000000000000000000..ed76f13ae76c0cbd755fd175dd9878a4a1a3a698 --- /dev/null +++ b/pdf2zh/translation/config.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass + +from dotenv import load_dotenv + +PROVIDERS: dict[str, dict[str, str]] = { + "openrouter": { + "base_url": "https://openrouter.ai/api/v1", + "model": "google/gemini-3.1-flash-lite", + "env_var": "OPENROUTER_API_KEY", + }, + "gemini": { + "base_url": "https://generativelanguage.googleapis.com/v1beta/openai", + "model": "gemini-2.5-flash-lite", + "env_var": "GEMINI_API_KEY", + }, + "openai": { + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o-mini", + "env_var": "OPENAI_API_KEY", + }, + "deepseek": { + "base_url": "https://api.deepseek.com/v1", + "model": "deepseek-chat", + "env_var": "DEEPSEEK_API_KEY", + }, + "minimax": { + "base_url": "https://api.minimax.io/v1", + "model": "MiniMax-Text-01", + "env_var": "MINIMAX_API_KEY", + }, + # Anthropic via its OpenAI-compatible endpoint (Bearer auth, /chat/completions). + # Default to the cheap/fast tier like the other providers; override in the UI + # model box (e.g. claude-sonnet-4-6, claude-opus-4-8) for higher quality. + "anthropic": { + "base_url": "https://api.anthropic.com/v1", + "model": "claude-haiku-4-5", + "env_var": "ANTHROPIC_API_KEY", + }, + # LiteLLM proxy — base_url is deployment-specific; set LITELLM_BASE_URL to point + # at your proxy. The model is whatever your proxy routes. + "litellm": { + "base_url": "http://localhost:4000/v1", + "base_url_env": "LITELLM_BASE_URL", + "model": "gpt-4o-mini", + "env_var": "LITELLM_API_KEY", + }, +} + + +@dataclass +class TranslatorConfig: + source_language: str = "" + target_language: str = "" + provider: str = "openrouter" + model: str | None = None + api_key: str | None = None + base_url: str | None = None + concurrent: int = 8 # was 30 — 30 bursts most providers straight into 429 + rpm: int | None = None + tpm: int | None = None + chunk_bytes: int = 3000 + glossary_enabled: bool = True + math_fix_enabled: bool = True + toc_fix_enabled: bool = True + equation_vision_enabled: bool = True + table_vision_enabled: bool = True + length_tolerance: float = 0.15 + timeout: int = 300 + retry: int = 5 # was 2 — transient 429/5xx need a larger budget to ride out + # OpenRouter's unified `reasoning` request field works across many providers/ + # models it proxies (Qwen, DeepSeek R1, ...). Only meaningful when + # provider == "openrouter" — other providers get this via _no_temp_models-style + # per-model quirks instead (see gateway.py). + disable_reasoning: bool = False + + +def provider_base_url(provider: str) -> str: + """Resolve a provider's base URL, honoring its optional ``base_url_env`` override.""" + p = PROVIDERS.get(provider) + if p is None: + raise ValueError( + f"Unknown provider '{provider}'. Choose from {list(PROVIDERS)}." + ) + env_key = p.get("base_url_env") + return (os.environ.get(env_key) if env_key else None) or p["base_url"] + + +def resolve_provider(cfg: TranslatorConfig) -> None: + load_dotenv() + p = PROVIDERS.get(cfg.provider) + if p is None: + raise ValueError( + f"Unknown provider '{cfg.provider}'. Choose from {list(PROVIDERS)}." + ) + if cfg.base_url is None: + cfg.base_url = provider_base_url(cfg.provider) + if cfg.model is None: + cfg.model = p["model"] + if cfg.api_key is None: + cfg.api_key = os.environ.get(p["env_var"]) + if not cfg.api_key: + raise ValueError(f"No API key found. Set {p['env_var']} or pass --api-key.") diff --git a/pdf2zh/translation/equation_vision.py b/pdf2zh/translation/equation_vision.py new file mode 100644 index 0000000000000000000000000000000000000000..448a0a13d68b4e3b0b7e567fa178f73bd96197fc --- /dev/null +++ b/pdf2zh/translation/equation_vision.py @@ -0,0 +1,103 @@ +"""Vision-based equation translator. + +For EQUATION elements whose source_text contains natural-language prose +(e.g. "where", "if", "means"), crops the bbox region from the PDF page, +sends it to a vision LLM, and writes the result to translated_text. + +Pure-math equations (only symbols, Greek letters, operators) are skipped — +they need no overlay. +""" + +from __future__ import annotations + +import asyncio +import base64 +import logging +from pathlib import Path + +import fitz + +from .config import TranslatorConfig +from .gateway import Gateway +from .predicates import has_prose_for_equation + +logger = logging.getLogger(__name__) + +_VISION_SYSTEM = """\ +You are a mathematical text extractor and translator. + +Given a cropped image of an equation/formula region from a PDF page: + +1. Read ALL visible text — both natural language and math symbols. +2. Translate natural-language words to {target_language}. Leave math untouched. +3. Wrap math expressions in ... using Typst syntax (no backslash commands): + - frac(a, b) for fractions + - sqrt(x) for square roots + - plus.minus for ± + - overline(x) for x̄ + - x^2, x_n for superscripts/subscripts (no curly braces) + - sum_(i=0)^n, integral_a^b + - pi, theta, alpha, beta, gamma, delta, sigma, omega (no backslash) +4. Return ONLY the formatted translated text. No explanation, no markdown fences.\ +""" + + +def _crop_bbox_image( + pdf_path: str, page_idx: int, bbox_pdf: list, dpi: int = 150 +) -> str: + """Crop bbox from a PDF page and return base64 PNG.""" + doc = fitz.open(pdf_path) + try: + page = doc[page_idx] + pad = 4.0 + x0, y0, x1, y1 = bbox_pdf + clip = fitz.Rect(x0 - pad, y0 - pad, x1 + pad, y1 + pad) + mat = fitz.Matrix(dpi / 72.0, dpi / 72.0) + pm = page.get_pixmap(matrix=mat, clip=clip, alpha=False) + return base64.b64encode(pm.tobytes("png")).decode("ascii") + finally: + doc.close() + + +async def _run(doc: dict, cfg: TranslatorConfig) -> None: + pdf_path = doc.get("pdf_path", "") + if not pdf_path or not Path(pdf_path).exists(): + logger.warning("equation_vision: pdf_path '%s' not found, skipping", pdf_path) + return + + targets: list[tuple[int, dict]] = [ + (page_idx, elem) + for page_idx, page in enumerate(doc.get("pages", [])) + for elem in page.get("elements", []) + if ( + elem.get("category") == "EQUATION" + and has_prose_for_equation(elem.get("source_text", "")) + ) + ] + + if not targets: + logger.info("equation_vision: no prose EQUATION elements found") + return + + logger.info("equation_vision: processing %d elements", len(targets)) + system = _VISION_SYSTEM.format(target_language=cfg.target_language) + + async with Gateway(cfg) as gw: + + async def _process(page_idx: int, elem: dict) -> None: + try: + img = _crop_bbox_image(pdf_path, page_idx, elem["bbox_pdf"]) + result = await gw.call_vision( + system, "Translate this equation region:", img + ) + elem["translated_text"] = result + logger.debug("equation_vision p%d: %r", page_idx, result[:60]) + except Exception as exc: + logger.warning("equation_vision: p%d failed: %s", page_idx, exc) + + await asyncio.gather(*[_process(pi, elem) for pi, elem in targets]) + + +def equation_vision_pass(doc: dict, cfg: TranslatorConfig) -> None: + """Translate prose-containing EQUATION elements via vision LLM.""" + asyncio.run(_run(doc, cfg)) diff --git a/pdf2zh/translation/gateway.py b/pdf2zh/translation/gateway.py new file mode 100644 index 0000000000000000000000000000000000000000..98c2b3b94822494bf8f5d5d2bf41d6081213c9f3 --- /dev/null +++ b/pdf2zh/translation/gateway.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +import asyncio +import json +import random +import re +from collections import deque +from threading import Lock + +import httpx +import json_repair + +from .config import TranslatorConfig + +_MAX_CONTINUE = 2 +_THINK_RE = re.compile(r"^\s*.*?", re.DOTALL) + + +class RateLimiter: + def __init__(self, rpm: int | None, tpm: int | None): + self.rpm = rpm + self.tpm = tpm + self._req_ts: deque[float] = deque() + self._tok_ts: deque[tuple[float, int]] = deque() + self._lock = Lock() + + def _cleanup(self, now: float) -> None: + cutoff = now - 60.0 + while self._req_ts and self._req_ts[0] <= cutoff: + self._req_ts.popleft() + while self._tok_ts and self._tok_ts[0][0] <= cutoff: + self._tok_ts.popleft() + + def _wait_time(self, now: float, tokens: int) -> float: + self._cleanup(now) + wait = 0.0 + if self.rpm and len(self._req_ts) >= self.rpm: + wait = max(wait, 60.0 - (now - self._req_ts[0])) + if self.tpm: + cur = sum(t[1] for t in self._tok_ts) + if cur + tokens > self.tpm and self._tok_ts: + wait = max(wait, 60.0 - (now - self._tok_ts[0][0])) + return wait + + def _record(self, now: float, tokens: int) -> None: + if self.rpm is not None: + self._req_ts.append(now) + if self.tpm is not None: + self._tok_ts.append((now, tokens)) + + async def acquire(self, tokens: int = 0) -> None: + if self.rpm is None and self.tpm is None: + return + import time + + while True: + with self._lock: + now = time.time() + wait = self._wait_time(now, tokens) + if wait <= 0: + self._record(now, tokens) + return + await asyncio.sleep(wait + 0.1) + + +class Gateway: + def __init__(self, cfg: TranslatorConfig): + self._cfg = cfg + self._sem = asyncio.Semaphore(cfg.concurrent) + self._rate = RateLimiter(cfg.rpm, cfg.tpm) + self._client: httpx.AsyncClient | None = None + # Models that rejected a custom `temperature` (e.g. OpenAI reasoning models + # like gpt-5-nano/o1/o3, which only accept the default value 1). Learned + # lazily from a 400 response so we stop sending the param for this model + # for the rest of the process, instead of retrying it on every call. + self._no_temp_models: set[str] = set() + + @staticmethod + def _is_unsupported_temperature(response: httpx.Response) -> bool: + try: + err = json.loads(response.text).get("error", {}) + except (ValueError, AttributeError): + return False + return ( + err.get("param") == "temperature" and err.get("code") == "unsupported_value" + ) + + async def __aenter__(self) -> "Gateway": + limits = httpx.Limits( + max_connections=self._cfg.concurrent * 2, + max_keepalive_connections=self._cfg.concurrent, + ) + timeout = httpx.Timeout(connect=5, read=self._cfg.timeout, write=300, pool=10) + self._client = httpx.AsyncClient(limits=limits, timeout=timeout, verify=False) + return self + + async def __aexit__(self, *_) -> None: + await self._client.aclose() + + async def call(self, system: str, user: str, *, force_json: bool = False) -> str: + async with self._sem: + await self._rate.acquire() + return await self._request(system, user, force_json=force_json) + + async def call_vision(self, system: str, prompt: str, image_b64: str) -> str: + """Send a vision request with a base64-encoded PNG image.""" + async with self._sem: + await self._rate.acquire() + return await self._request_vision(system, prompt, image_b64) + + def _retry_delay(self, retry: int, response: httpx.Response | None = None) -> float: + """Seconds to wait before retrying: honor the server's Retry-After header, + else capped exponential backoff with jitter (avoids synchronized retries + all hammering the API at once and re-triggering 429).""" + if response is not None: + retry_after = response.headers.get("retry-after") + if retry_after: + try: + return min(float(retry_after), 60.0) + except ValueError: + pass + return min(30.0, 2.0**retry) + random.uniform(0, 1) + + async def _request( + self, + system: str, + user: str, + *, + force_json: bool, + retry: int = 0, + accumulated: str = "", + cont: int = 0, + ) -> str: + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self._cfg.api_key}", + } + data: dict = { + "model": self._cfg.model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + } + if self._cfg.model not in self._no_temp_models: + data["temperature"] = 0.7 + if force_json: + data["response_format"] = {"type": "json_object"} + if self._cfg.disable_reasoning and self._cfg.provider == "openrouter": + data["reasoning"] = {"enabled": False} + try: + resp = await self._client.post( + f"{self._cfg.base_url}/chat/completions", + json=data, + headers=headers, + ) + resp.raise_for_status() + rdata = json.loads(resp.text.lstrip()) + choices = rdata.get("choices", []) + if not choices: + raise ValueError("empty choices in response") + finish = choices[0].get("finish_reason") + # `.get(key, "")` only falls back when the key is absent — some providers + # send an explicit `"content": null` (e.g. on a filtered/empty completion), + # which .get() passes through as None and crashes the regex sub below. + content = choices[0].get("message", {}).get("content") or "" + content = _THINK_RE.sub("", content) + # Drop lone UTF-16 surrogates the model occasionally emits; + # httpx fails to UTF-8 encode them on subsequent retry requests. + content = content.encode("utf-8", errors="ignore").decode("utf-8") + content = self._merge(accumulated, content) if accumulated else content + if finish == "length" and cont < _MAX_CONTINUE: + return await self._request( + system, + user, + force_json=force_json, + retry=retry, + accumulated=content, + cont=cont + 1, + ) + return content + except httpx.HTTPStatusError as e: + status = e.response.status_code + # Some models (OpenAI reasoning models: gpt-5-nano, o1, o3, ...) reject + # any non-default temperature outright. Learn this once per model and + # replay the SAME attempt without the param — free (no tokens billed, + # request was rejected before generation) and doesn't cost a retry slot. + if ( + status == 400 + and "temperature" in data + and self._is_unsupported_temperature(e.response) + ): + # NOTE: don't gate this on `model not in self._no_temp_models` — + # concurrent calls for the same model can all be in flight before + # the first one learns, so every one of them must be allowed to + # self-heal independently. `"temperature" in data` alone already + # prevents infinite recursion: the retried call rebuilds `data` + # from `_no_temp_models`, which by then contains this model. + self._no_temp_models.add(self._cfg.model) + return await self._request( + system, user, force_json=force_json, retry=retry + ) + # 429 (rate limit) and 5xx are transient — back off and retry. Other + # 4xx (401 auth, 400 bad request) are permanent, so fail fast. + if (status == 429 or status >= 500) and retry < self._cfg.retry: + await asyncio.sleep(self._retry_delay(retry, e.response)) + return await self._request( + system, user, force_json=force_json, retry=retry + 1 + ) + raise + except Exception: + if retry < self._cfg.retry: + await asyncio.sleep(self._retry_delay(retry)) + return await self._request( + system, user, force_json=force_json, retry=retry + 1 + ) + raise + + async def _request_vision( + self, + system: str, + prompt: str, + image_b64: str, + retry: int = 0, + ) -> str: + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self._cfg.api_key}", + } + data: dict = { + "model": self._cfg.model, + "messages": [ + {"role": "system", "content": system}, + { + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{image_b64}"}, + }, + ], + }, + ], + } + if self._cfg.model not in self._no_temp_models: + data["temperature"] = 0.2 + if self._cfg.disable_reasoning and self._cfg.provider == "openrouter": + data["reasoning"] = {"enabled": False} + try: + resp = await self._client.post( + f"{self._cfg.base_url}/chat/completions", + json=data, + headers=headers, + ) + resp.raise_for_status() + rdata = json.loads(resp.text.lstrip()) + choices = rdata.get("choices", []) + if not choices: + raise ValueError("empty choices in vision response") + content = choices[0].get("message", {}).get("content") or "" + return _THINK_RE.sub("", content).strip() + except httpx.HTTPStatusError as e: + status = e.response.status_code + if ( + status == 400 + and "temperature" in data + and self._is_unsupported_temperature(e.response) + ): + # See _request(): don't gate on `model not in self._no_temp_models`, + # concurrent in-flight calls must each be able to self-heal. + self._no_temp_models.add(self._cfg.model) + return await self._request_vision(system, prompt, image_b64, retry) + if (status == 429 or status >= 500) and retry < self._cfg.retry: + await asyncio.sleep(self._retry_delay(retry, e.response)) + return await self._request_vision(system, prompt, image_b64, retry + 1) + raise + except Exception: + if retry < self._cfg.retry: + await asyncio.sleep(self._retry_delay(retry)) + return await self._request_vision(system, prompt, image_b64, retry + 1) + raise + + @staticmethod + def _merge(acc: str, add: str) -> str: + try: + a = json_repair.loads(acc) + b = json_repair.loads(add) + if isinstance(a, list) and isinstance(b, list): + seen = {x.get("id") for x in a if isinstance(x, dict)} + for item in b: + if isinstance(item, dict) and item.get("id") not in seen: + a.append(item) + seen.add(item.get("id")) + return json.dumps(a, ensure_ascii=False) + except Exception: + pass + return acc + add diff --git a/pdf2zh/translation/math_fixer.py b/pdf2zh/translation/math_fixer.py new file mode 100644 index 0000000000000000000000000000000000000000..96885c0d23372916a21ca1de524beb568122187c --- /dev/null +++ b/pdf2zh/translation/math_fixer.py @@ -0,0 +1,210 @@ +"""Post-translation pass: fix bare math wrapping + detect multi-column layouts. + +After Stage B translation, some elements have: + - Math expressions outside tags (LLM forgot to wrap) + - Multi-column reference layouts that got flattened into one line + +This module: + 1. Regex-detects elements that look math-y + 2. Sends them in batches to LLM with bbox info + 3. LLM returns fixed text with wrapping and (optionally) ... + blocks for grid/column layouts + 4. Writes corrections back into the doc +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +from typing import NamedTuple + +import json_repair + +from .config import TranslatorConfig +from .gateway import Gateway +from .predicates import is_equation_only +from .prompts import build_math_fix_prompt + +logger = logging.getLogger("json_translator") + + +# --------------------------------------------------------------------------- +# Math detection +# --------------------------------------------------------------------------- + +# Indicators that an element contains math expressions worth re-checking: +# - Typst math function calls: frac(, sqrt(, sum_, int_ +# - LaTeX backslash commands: \frac, \sqrt, \int +# - Math keywords: pi, theta, alpha, sin, cos, etc. +# - Exponent/subscript notation: x^2, a_n, x^{n+1} +# - Existing tags (we still want layout reasoning) +_MATH_INDICATORS = re.compile( + r" list[MathTask]: + """Return tasks for elements whose translated_text looks math-y.""" + tasks: list[MathTask] = [] + idx = 0 + for page in doc.get("pages", []): + for elem in page.get("elements", []): + cat = elem.get("category", "") + if cat in ("BYPASS", "TABLE"): + continue + text = elem.get("translated_text", "") or "" + if not text: + continue + if not _MATH_INDICATORS.search(text): + continue + # Pure-math elements: leave them alone — we'll skip rendering and let + # the original PDF's text layer show through. + if is_equation_only(text): + continue + bbox = elem.get("bbox_pdf") or [0, 0, 0, 0] + tasks.append(MathTask(elem, "translated_text", text, bbox, str(idx))) + idx += 1 + return tasks + + +def tasks_to_chunks(tasks: list[MathTask], max_bytes: int) -> list[list[MathTask]]: + """Pack math tasks into chunks bounded by serialized JSON size.""" + chunks: list[list[MathTask]] = [] + current: list[MathTask] = [] + cur_bytes = 0 + for task in tasks: + entry = { + "id": task.id, + "text": task.text, + "bbox": [round(c, 1) for c in task.bbox], + } + size = len(json.dumps(entry, ensure_ascii=False).encode()) + if cur_bytes + size > max_bytes and current: + chunks.append(current) + current = [task] + cur_bytes = size + else: + current.append(task) + cur_bytes += size + if current: + chunks.append(current) + return chunks + + +# --------------------------------------------------------------------------- +# Async LLM pipeline +# --------------------------------------------------------------------------- + + +def _tags_balanced(text: str) -> bool: + """Reject LLM outputs with unclosed / blocks.""" + for tag in ("math", "typst"): + opens = len(re.findall(rf"<{tag}\b", text, re.IGNORECASE)) + closes = len(re.findall(rf" bool: + """Reject outputs where LaTeX leaked outside tags (we can't render it).""" + s = re.sub(r"]*>.*?", "", text, flags=re.DOTALL | re.IGNORECASE) + s = re.sub(r"]*>.*?", "", s, flags=re.DOTALL | re.IGNORECASE) + s = re.sub(r"\$\$.*?\$\$", "", s, flags=re.DOTALL) + s = re.sub(r"\$[^$\n]*\$", "", s) + return bool(re.search(r"\\[a-zA-Z]+", s)) + + +async def _fix_one_chunk(gw: Gateway, chunk: list[MathTask]) -> dict[str, str]: + payload = [ + { + "id": t.id, + "text": t.text, + "bbox": [round(c, 1) for c in t.bbox], + } + for t in chunk + ] + system, user = build_math_fix_prompt(payload) + + def _parse(raw: str) -> dict[str, str]: + parsed = json_repair.loads(raw) + if isinstance(parsed, dict): + parsed = [{"id": k, "t": v} for k, v in parsed.items()] + if not isinstance(parsed, list): + return {} + out: dict[str, str] = {} + for item in parsed: + if isinstance(item, dict) and "id" in item and "t" in item: + out[str(item["id"])] = str(item["t"]) + return out + + result = _parse(await gw.call(system, user)) + valid_ids = {t.id for t in chunk} + out: dict[str, str] = {} + for k, v in result.items(): + if k not in valid_ids: + continue + if not _tags_balanced(v): + logger.warning(f"Math-fix dropped id={k}: unbalanced / tags") + continue + if _has_unsafe_latex(v): + logger.warning(f"Math-fix dropped id={k}: bare LaTeX leaked outside tags") + continue + out[k] = v + return out + + +async def _fix_chunks( + chunks: list[list[MathTask]], cfg: TranslatorConfig +) -> dict[str, str]: + results: dict[str, str] = {} + lock = asyncio.Lock() + + async def _process(gw: Gateway, chunk: list[MathTask]) -> None: + try: + fixed = await _fix_one_chunk(gw, chunk) + async with lock: + results.update(fixed) + logger.info(f"Math-fix chunk done: {len(chunk)} elements.") + except Exception as exc: + logger.warning(f"Math-fix chunk failed: {exc}") + + async with Gateway(cfg) as gw: + await asyncio.gather(*[_process(gw, c) for c in chunks]) + return results + + +def fix_math_document(doc: dict, cfg: TranslatorConfig) -> dict: + """Run the math-fix pass over `doc` (mutates in place, returns same doc).""" + tasks = collect_math_candidates(doc) + if not tasks: + logger.info("Math-fix: no math candidates found.") + return doc + + chunks = tasks_to_chunks(tasks, cfg.chunk_bytes) + logger.info(f"Math-fix: {len(tasks)} elements, {len(chunks)} chunks") + + fixes = asyncio.run(_fix_chunks(chunks, cfg)) + + applied = 0 + for task in tasks: + new_text = fixes.get(task.id) + if new_text is not None and new_text != task.text: + task.elem[task.write_key] = new_text + applied += 1 + logger.info(f"Math-fix: applied {applied}/{len(tasks)} fixes") + return doc diff --git a/pdf2zh/translation/models.py b/pdf2zh/translation/models.py new file mode 100644 index 0000000000000000000000000000000000000000..e4d95709714e23f4da1998a9a22da8baeb414881 --- /dev/null +++ b/pdf2zh/translation/models.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from typing import NamedTuple + + +class Task(NamedTuple): + target: dict # element or cell dict to write into + write_key: str + text: str + id: str diff --git a/pdf2zh/translation/pipeline.py b/pdf2zh/translation/pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..6b7a11ff8554153b108c877a648c319c8806ae12 --- /dev/null +++ b/pdf2zh/translation/pipeline.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +import asyncio +import json +import logging + +import json_repair + +from .chunker import collect_translatables, segments_to_chunks +from .config import TranslatorConfig, resolve_provider +from .equation_vision import equation_vision_pass +from .gateway import Gateway +from .math_fixer import fix_math_document +from .models import Task +from .prompts import ( + build_glossary_prompt, + build_translation_prompt, + glossary_block_for_chunk, +) +from .table_vision import table_vision_pass +from .toc_fixer import fix_toc_document + +logger = logging.getLogger("json_translator") + + +async def extract_glossary( + chunks: list[dict[str, str]], + cfg: TranslatorConfig, +) -> dict[str, str]: + glossary: dict[str, str] = {} + lock = asyncio.Lock() + + async def _process(gw: Gateway, chunk: dict[str, str]) -> None: + system, user = build_glossary_prompt( + chunk, cfg.source_language, cfg.target_language + ) + try: + raw = await gw.call(system, user) + parsed = json_repair.loads(raw) + if isinstance(parsed, list): + async with lock: + for item in parsed: + if isinstance(item, dict) and "src" in item and "dst" in item: + key = item["src"].strip().lower() + if key not in glossary: + glossary[key] = item["dst"] + except Exception as exc: + logger.warning(f"Glossary chunk failed: {exc}") + + async with Gateway(cfg) as gw: + await asyncio.gather(*[_process(gw, c) for c in chunks]) + return glossary + + +async def translate_chunks( + chunks: list[dict[str, str]], + glossary: dict[str, str], + cfg: TranslatorConfig, +) -> dict[str, str]: + results: dict[str, str] = {} + lock = asyncio.Lock() + + async def _process(gw: Gateway, chunk: dict[str, str]) -> None: + block = glossary_block_for_chunk(chunk, glossary) + system, user = build_translation_prompt( + chunk, cfg.source_language, cfg.target_language, block + ) + translated = await _translate_one_chunk(gw, system, user, chunk, cfg) + async with lock: + results.update(translated) + logger.info(f"Chunk done: {len(chunk)} segments.") + + async with Gateway(cfg) as gw: + await asyncio.gather(*[_process(gw, c) for c in chunks]) + return results + + +async def _translate_one_chunk( + gw: Gateway, + system: str, + user: str, + chunk: dict[str, str], + cfg: TranslatorConfig, +) -> dict[str, str]: + original_ids = set(chunk.keys()) + + def _parse(raw: str) -> dict[str, str]: + parsed = json_repair.loads(raw) + if isinstance(parsed, dict): + parsed = [{"id": k, "t": v} for k, v in parsed.items()] + if not isinstance(parsed, list): + return {} + out: dict[str, str] = {} + for item in parsed: + if isinstance(item, dict) and "id" in item and "t" in item: + out[str(item["id"])] = str(item["t"]) + return out + + result = _parse(await gw.call(system, user)) + + # Drop extra ids (hallucinations) + for k in set(result.keys()) - original_ids: + del result[k] + + # Retry for missing ids + missing = original_ids - set(result.keys()) + if missing: + retry_user = ( + user + "\nDo not omit any IDs; every input ID must appear exactly once." + ) + retry_result = _parse(await gw.call(system, retry_user)) + for k in missing: + if k in retry_result: + result[k] = retry_result[k] + else: + logger.warning(f"ID {k} missing after retry; falling back to source.") + result[k] = chunk[k] + + # Retry if model returned source unchanged (no translation) + if result and all(result.get(k) == chunk.get(k) for k in original_ids): + retry_result = _parse(await gw.call(system, user)) + if not all(retry_result.get(k) == chunk.get(k) for k in original_ids): + result.update({k: v for k, v in retry_result.items() if k in original_ids}) + + # Length check — retry violators once, then warn-and-keep + violators = { + k + for k in original_ids + if _length_violation(result.get(k, ""), chunk[k], cfg.length_tolerance) + } + if violators: + vchunk = {k: chunk[k] for k in violators} + prev_json = json.dumps({k: result[k] for k in violators}, ensure_ascii=False) + v_block = glossary_block_for_chunk(vchunk, {}) + v_sys, v_usr = build_translation_prompt( + vchunk, cfg.source_language, cfg.target_language, v_block + ) + v_usr += ( + f"\nPrevious attempt violated the length constraint. " + f"Keep each translation within ±15% of source length. " + f"Previous (rejected):\n{prev_json}" + ) + retry2 = _parse(await gw.call(v_sys, v_usr)) + for k in violators: + t = retry2.get(k, result.get(k, "")) + if _length_violation(t, chunk[k], cfg.length_tolerance): + logger.warning( + f"Persistent length violation id={k} " + f"(src={len(chunk[k])}, out={len(t)})" + ) + result[k] = t + + return result + + +def _length_violation(translation: str, source: str, tol: float) -> bool: + if len(source) < 20: + return False + return abs(len(translation) - len(source)) / max(len(source), 1) > tol + + +async def _pipeline( + tasks: list[Task], + chunks: list[dict[str, str]], + cfg: TranslatorConfig, +) -> dict[str, str]: + glossary: dict[str, str] = {} + if cfg.glossary_enabled: + glossary = await extract_glossary(chunks, cfg) + logger.info(f"Glossary: {len(glossary)} terms") + return await translate_chunks(chunks, glossary, cfg) + + +def translate_document(doc: dict, cfg: TranslatorConfig) -> dict: + if not cfg.source_language: + cfg.source_language = doc.get("source_language", "") + if not cfg.target_language: + cfg.target_language = doc.get("target_language", "") + if not cfg.source_language or not cfg.target_language: + raise ValueError( + "source_language and target_language must be set via JSON metadata or --src/--tgt flags." + ) + + resolve_provider(cfg) + + if cfg.table_vision_enabled: + table_vision_pass(doc, cfg) + + tasks = collect_translatables(doc) + if not tasks: + logger.info("No translatable segments found.") + return doc + + chunks = segments_to_chunks(tasks, cfg.chunk_bytes) + logger.info(f"Segments: {len(tasks)}, chunks: {len(chunks)}") + + translations = asyncio.run(_pipeline(tasks, chunks, cfg)) + + for task in tasks: + t = translations.get(task.id) + if t is not None: + task.target[task.write_key] = t + + token_stats = { + "total_segments": len(tasks), + "total_chunks": len(chunks), + "translated": sum(1 for t in tasks if t.id in translations), + } + logger.info(f"Token usage stats: {token_stats}") + + if cfg.equation_vision_enabled: + equation_vision_pass(doc, cfg) + + if cfg.toc_fix_enabled: + fix_toc_document(doc, cfg) + + if cfg.math_fix_enabled: + fix_math_document(doc, cfg) + + return doc diff --git a/pdf2zh/translation/predicates.py b/pdf2zh/translation/predicates.py new file mode 100644 index 0000000000000000000000000000000000000000..f8189d64c798861501508c7bc66d39066d0d80e2 --- /dev/null +++ b/pdf2zh/translation/predicates.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import re + +_MATH_TAG = re.compile(r"]*>.*?", re.DOTALL | re.IGNORECASE) +_TYPST_TAG = re.compile(r"]*>.*?", re.DOTALL | re.IGNORECASE) +_DOLLAR_BLOCK = re.compile(r"\$\$.*?\$\$", re.DOTALL) +_DOLLAR_INLINE = re.compile(r"\$[^$\n]*\$") +_LATEX_CMD = re.compile(r"\\[a-zA-Z]+(?:\s*\{[^{}]*\})*") +_HTML_TAG = re.compile(r"<[^>]+>") +_EQ_LABEL = re.compile(r"\(\d+(?:\.\d+)*[a-z]?\)") +# Two or more consecutive letters across major Unicode scripts +_LETTER_RUN = re.compile( + r"[A-Za-z\u00C0-\u024F\u1E00-\u1EFF" # Latin + Vietnamese diacritics + r"\u0370-\u03FF\u0400-\u04FF" + r"\u0600-\u06FF\u0900-\u097F\u0E00-\u0E7F\u2E80-\u9FFF]{2,}" +) + +# Math vocabulary words that are NOT natural-language prose. +# If an equation text line consists only of these, it should not be translated. +_MATH_WORDS: frozenset[str] = frozenset( + { + # Trig functions and inverses + "sin", + "cos", + "tan", + "cot", + "sec", + "csc", + "arcsin", + "arccos", + "arctan", + "arccot", + "arcsec", + "arccsc", + "sinh", + "cosh", + "tanh", + "coth", + "sech", + "csch", + # Common math functions + "log", + "ln", + "exp", + "det", + "dim", + "ker", + "gcd", + "lcm", + "max", + "min", + "lim", + "sup", + "inf", + "mod", + "deg", + "arg", + # Greek letter names (lower and upper) + "pi", + "theta", + "alpha", + "beta", + "gamma", + "delta", + "epsilon", + "zeta", + "eta", + "iota", + "kappa", + "lambda", + "mu", + "nu", + "xi", + "omicron", + "rho", + "sigma", + "tau", + "upsilon", + "phi", + "chi", + "psi", + "omega", + "Alpha", + "Beta", + "Gamma", + "Delta", + "Epsilon", + "Zeta", + "Eta", + "Theta", + "Iota", + "Kappa", + "Lambda", + "Mu", + "Nu", + "Xi", + "Omicron", + "Pi", + "Rho", + "Sigma", + "Tau", + "Upsilon", + "Phi", + "Chi", + "Psi", + "Omega", + # Variant Greek + "varepsilon", + "varphi", + "vartheta", + "varrho", + "varsigma", + # Math units and abbreviations + "rad", + "radian", + "radians", + "rpm", + "rps", + "mm", + "cm", + "km", + "kg", + "mg", + "ml", + "ms", + "ns", + "us", + "hz", + "khz", + "mhz", + "ghz", + # Math operator words + "plus", + "minus", + "times", + "over", + "div", + # Single-letter identifiers that appear as standalone words (a-z, A-Z) + *list("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"), + } +) + +_WORD_RE = re.compile(r"[A-Za-z]{2,}") + + +def _strip_math(s: str) -> str: + s = _MATH_TAG.sub("", s) + s = _TYPST_TAG.sub("", s) + s = _DOLLAR_BLOCK.sub("", s) + s = _DOLLAR_INLINE.sub("", s) + s = _LATEX_CMD.sub("", s) + s = _EQ_LABEL.sub("", s) + s = _HTML_TAG.sub("", s) + return s + + +def is_plain_text(s: str) -> bool: + """True if there is translatable prose (real word) outside math.""" + return bool(_LETTER_RUN.search(_strip_math(s))) + + +def has_prose_for_equation(s: str) -> bool: + """Like is_plain_text but also filters out math vocabulary words. + + Used for equation_words: returns True only if there are real + natural-language words (e.g. 'where', 'if', 'then', 'means') beyond + math symbols spelled out as text (e.g. 'pi', 'theta', 'rad', 'sin'). + """ + stripped = _strip_math(s) + words = _WORD_RE.findall(stripped) + return any(w.lower() not in _MATH_WORDS for w in words) + + +def is_equation_only(s: str) -> bool: + """True if string is pure math (no translatable text remaining).""" + return not _strip_math(s).strip() diff --git a/pdf2zh/translation/prompts.py b/pdf2zh/translation/prompts.py new file mode 100644 index 0000000000000000000000000000000000000000..8dcb8461576fdb48697f7da0c1c5db5cf06fc29f --- /dev/null +++ b/pdf2zh/translation/prompts.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import json + + +def build_translation_prompt( + chunk: dict[str, str], + src_lang: str, + tgt_lang: str, + glossary_block: str, +) -> tuple[str, str]: + system = ( + f"You are a professional, authentic machine translation engine.\n\n" + f"# Task\nTranslate text from {src_lang} into {tgt_lang}.\n\n" + f"# Rules\n" + f"1. For content inside ... tags: convert LaTeX to Typst math syntax " + f"(do NOT use backslash commands). Conversion table:\n" + f" \\frac{{a}}{{b}} → frac(a, b) | \\sqrt{{x}} → sqrt(x) | \\pi → pi | \\theta → theta\n" + f" \\alpha → alpha | \\beta → beta | \\gamma → gamma | \\delta → delta\n" + f" \\sigma → sigma | \\mu → mu | \\lambda → lambda | \\omega → omega\n" + f" \\sin → sin | \\cos → cos | \\tan → tan | \\log → log | \\ln → ln\n" + f" \\sum_{{i}}^{{n}} → sum_(i)^(n) | \\int_{{a}}^{{b}} → integral_a^b\n" + f" \\binom{{n}}{{k}} → binom(n, k) | \\pm → plus.minus | \\times → times\n" + f" \\leq → <= | \\geq → >= | \\neq → != | \\infty → oo\n" + f" \\left( ... \\right) → ( ... ) (just drop \\left/\\right)\n" + f" \\begin{{pmatrix}} a & b \\\\\\\\ c & d \\end{{pmatrix}} → mat(a, b; c, d)\n" + f" \\cdot → dot.op | \\cdots → dots.c | \\ldots → dots.b | \\vdots → dots.v | \\ddots → dots.down\n" + f" \\overline{{x}} → overline(x) | \\hat{{x}} → hat(x) | \\vec{{x}} → vec(x)\n" + f' ^{{xy}} → ^(xy) | _{{xy}} → _(xy) | \\text{{word}} → "word"\n' + f' Unit names inside math must be quoted strings: rad → "rad", kg → "kg"\n' + f"\n" + f" CRITICAL dot.op rules — violations cause Typst compile errors:\n" + f" - \\cdot → dot.op with spaces: `1 dot.op 2 dot.op 3`, NEVER `1cdot2` or `1 cdot 2`\n" + f" - \\cdots → dots.c (no arguments): `dots.c k`, NEVER `cdots k` or `dots(k)`\n" + f" - Example: 1 \\cdot 2 \\cdots k1 dot.op 2 dots.c k\n" + f" - dots.c, dots.b, dots.v etc. are standalone — never call them with (): dots.c NOT dots.c()\n" + f"\n" + f" CRITICAL tag rules:\n" + f" - Keep the ENTIRE equation in ONE tag. Never split at = or operator.\n" + f" WRONG: `binom(n,k) = frac(...)`\n" + f" RIGHT: `binom(n,k) = frac(...)`\n" + f' - Drop any display attribute: always write , never .\n' + f" If the entry is a short noun label (shape name, object name) followed by one or more blocks\n" + f" AND the bbox is portrait (height >= width), output a Typst grid instead:\n" + f" Example: 'Cone V = \\frac{{1}}{{3}} \\pi r^2 h A = \\pi r \\sqrt{{r^2+h^2}}'\n" + f" → '#grid(columns: 1, row-gutter: 4pt, [Hình nón], [$V = pi r^2 h / 3$], [$A = pi r sqrt(r^2 + h^2)$])'\n" + f" IMPORTANT: In Typst math, adjacent letters like `bh` form ONE identifier.\n" + f" Always separate single-letter variables with spaces: `b h` not `bh`, `a b` not `ab`.\n" + f" Example: \\frac{{1}}{{2}}bhfrac(1, 2) b h\n" + f" EXCEPTION — single-symbol inline mentions in prose: when X wraps a\n" + f" bare single letter/symbol used as a variable reference inside running prose\n" + f" (NOT part of a multi-term formula), UNWRAP it and render the symbol as italic\n" + f" text with ... instead. This keeps the sentence flowing on one line.\n" + f" Example: 'area A, circumference C' →\n" + f" 'diện tích A, chu vi C'\n" + f" Keep ... for any expression with ≥2 tokens or an operator\n" + f" (e.g. x = y, r^2, \\pi r all stay wrapped).\n" + f"2. Preserve verbatim: content inside $...$, HTML tags , , , , " + f"URLs, code blocks, brand names, and any placeholder tags.\n" + f"3. LENGTH CONSTRAINT (hard): each translation's character length MUST be within " + f"±15% of its source string. Rephrase compactly if needed.\n" + f" Priority order when in tension:\n" + f" (1) semantic accuracy\n" + f" (2) length preservation\n" + f"4. Do NOT merge or split entries. Every input id must appear exactly once in the " + f"output, with the same id string.\n" + f"5. Return VERBATIM (do NOT translate, do NOT change) if the entry contains ONLY:\n" + f" - Math function names: sin, cos, tan, log, ln, exp, lim, …\n" + f" - Greek letter names or Unicode Greek letters: theta/θ, omega/ω, alpha/α, beta/β, …\n" + f" - Unicode math symbols: ±, ×, ÷, ∑, ∏, ∫, √, ∞, ≤, ≥, ≠, ∈, …\n" + f" - Combinations of the above with numbers/operators: 'tan θ', 'sin²θ', 'α + β', …\n" + f" - Unit symbols: rad, deg, rpm, kg, m/s, …\n" + f" There must be NO natural-language prose words mixed in.\n" + f"6. Return ONLY the JSON array specified below. No prose, no code fences.\n\n" + f"{glossary_block}" + ) + input_json = json.dumps(chunk, ensure_ascii=False) + example_ids = list(chunk.keys())[:2] + example = ", ".join(f'{{"id":"{k}","t":""}}' for k in example_ids) + user = ( + f"\n```json\n{input_json}\n```\n\n\n" + f"Return a JSON array in exactly this shape:\n[{example}]" + ) + return system, user + + +def build_toc_fix_prompt( + entries: list[dict], +) -> tuple[str, str]: + """Prompt for the post-translation TOC-fix pass. + + Each entry: {"id": str, "text": str, "bbox": [x0, y0, x1, y1]} + LLM returns: [{"id": str, "t": str}] + """ + system = ( + "You are a Table-of-Contents reconstructor.\n\n" + "# Task\n" + "Each input entry is a TOC concatenated into one line — section numbers, titles,\n" + "dot leaders, and page numbers all run together. Restructure it so each entry sits\n" + "on its own line, formatted as: '\\t<page_number>'.\n\n" + "## Rules\n" + "1. ONE entry per line. Separator between title and page number is a single tab '\\t'.\n" + "2. Preserve the section number prefix in the title (e.g. '1.1 Bối cảnh').\n" + "3. Strip dot-leader sequences ('. . . . .') — they are layout fillers.\n" + "4. Keep the page number as the integer that ends the entry (1–3 digits typically).\n" + "5. If an entry has no page number visible, output just the title (no tab).\n" + "6. Do NOT translate or rewrite titles. Preserve them as-is.\n" + "7. Do NOT add extra blank lines or commentary.\n\n" + "## Example\n" + "Input: '1 Giới thiệu 6 1.1 Bối cảnh về LLM . . . . . . . . . 6 1.2 Lịch sử . . . . . . 7'\n" + "Output: '1 Giới thiệu\\t6\\n1.1 Bối cảnh về LLM\\t6\\n1.2 Lịch sử\\t7'\n\n" + "## Output format\n" + "Return ONLY a JSON array. No prose, no fences. Every input id appears once.\n" + ) + input_json = json.dumps(entries, ensure_ascii=False) + example_ids = [str(e.get("id")) for e in entries[:2]] + example = ", ".join( + f'{{"id":"{k}","t":"<entry1>\\\\t<page>\\\\n<entry2>\\\\t<page>"}}' + for k in example_ids + ) + user = ( + f"<input>\n```json\n{input_json}\n```\n</input>\n\n" + f"Return JSON array in this shape:\n[{example}]" + ) + return system, user + + +def build_math_fix_prompt( + entries: list[dict], +) -> tuple[str, str]: + """Prompt for the post-translation math-fix pass. + + Each entry: {"id": str, "text": str, "bbox": [x0, y0, x1, y1]} + LLM returns: [{"id": str, "t": str}] + """ + system = ( + "You are a Typst markup post-processor.\n\n" + "# Task\n" + "For each entry, FIX the translated text so it renders correctly in Typst:\n\n" + "## Rule 1 — Wrap bare math in <math>...</math>\n" + "Any math expression OUTSIDE existing <math> tags must be wrapped. Math means:\n" + "- Contains Typst function call: frac(...), sqrt(...), sum(...), int(...)\n" + "- Contains math keywords: pi, theta, alpha, sin, cos, tan, log, ln, infty\n" + "- Has = or +/-/*/÷/× joining symbol-like terms (variables, numbers, math fns)\n" + "- Has ^ or _ for exponent/subscript: x^2, a_n\n" + "Example: 'A = pi r sqrt(r^2 + h^2)' → '<math>A = pi r sqrt(r^2 + h^2)</math>'\n" + "Example: 'tan x = frac(sin x, cos x)' → '<math>tan x = frac(sin x, cos x)</math>'\n\n" + "## Rule 2 — Convert LaTeX math to Typst math syntax\n" + "Inside <math> tags, NO backslash commands. Use:\n" + " \\frac{a}{b} → frac(a, b) | \\sqrt{x} → sqrt(x) | \\pi → pi\n" + " \\sum_{i}^{n} → sum_(i)^(n) | \\int_a^b → integral_a^b\n" + " \\binom{n}{k} → binom(n, k) | \\pm → plus.minus | \\leq → <=\n" + " \\cdot → dot.op | \\cdots → dots.c | \\ldots → dots.b\n" + ' \\left( ... \\right) → ( ... ) | \\text{w} → "w"\n\n' + "## Rule 3 — Use <typst> blocks for grid layouts\n" + "Default action: keep <math>...</math> tags + plain text as-is. DO NOT wrap simple\n" + "text+math entries in <typst>. Emit a <typst> block when the entry matches a pattern below.\n" + 'IMPORTANT: if the entry contains <math display="block"> AND a noun label before it,\n' + "always convert to Pattern B grid — do NOT keep the display attribute as-is.\n\n" + "Inside <typst>...</typst>: NO LaTeX backslash commands, NO escaped slashes.\n" + "Use Typst syntax only: pi (not \\\\pi), frac(a,b) or a/b (not \\\\frac{}{}), sqrt(x) (not \\\\sqrt{}).\n\n" + "Use bbox = [x0, y0, x1, y1] to compute width = x1-x0, height = y1-y0, aspect = width/height.\n\n" + "**Pattern A — Label-then-Formula reference card** (most common):\n" + "Entry has N short labels concatenated, then N math blocks. Always use 2 rows:\n" + " row 1 = labels, row 2 = corresponding formulas (one cell per pair).\n" + " Input: 'Tam giác Đường tròn Hình quạt tròn <math>A = frac(1,2) b h</math> <math>A = pi r^2</math> <math>A = frac(1,2) r^2 theta</math>'\n" + " Output: <typst>#grid(columns: 3, gutter: 8pt, [Tam giác], [Đường tròn], [Hình quạt tròn], [$A = b h / 2$], [$A = pi r^2$], [$A = r^2 theta / 2$])</typst>\n\n" + "**Pattern B — One-label-one-or-more-formulas**:\n" + "Apply when ALL of these are true:\n" + " 1. Text before the first <math> is a SHORT NOUN (shape/object name, ≤ 5 words, no articles/verbs)\n" + " 2. No text after the last </math> (otherwise it's prose with inline math)\n" + " 3. Always use columns: 1.\n" + " Input: 'Hình cầu <math>V = frac(4,3) pi r^3</math> <math>A = 4 pi r^2</math>'\n" + " Output: <typst>#grid(columns: 1, row-gutter: 4pt, [Hình cầu], [$V = (4 pi r^3) / 3$], [$A = 4 pi r^2$])</typst>\n" + " Input: 'Hình trụ <math>V = pi r^2 h</math>'\n" + " Output: <typst>#grid(columns: 1, row-gutter: 4pt, [Hình trụ], [$V = pi r^2 h$])</typst>\n" + " SKIP (keep as-is) if text exists after last </math>:\n" + " 'trong đó <math>binom(n,k) = frac(...)</math> với n > 0' → keep as-is\n" + " 'The formula for area is <math>A = pi r^2</math>' → keep as-is (has 'is','the','for')\n\n" + "**Pattern C — Wide horizontal list** (≥3 math blocks, aspect>5, no labels):\n" + " <typst>#grid(columns: N, gutter: 8pt, [$...$], [$...$], ...)</typst>\n\n" + "**Inside [content] cells — IMPORTANT formatting rules**:\n" + "- Use $...$ for math (NOT <math> tags, since <typst> bypasses HTML processing).\n" + "- NEVER use backslash inside content blocks — no \\\\, no \\/. Plain `/` for division.\n" + "- Prefer 'a / b' over 'frac(a, b)' for inline fractions (better baseline alignment).\n" + " Example: $A = b h / 2$ — NOT $A = b h \\/ 2$, NOT $A = frac(1,2) b h$.\n" + "- Reserve frac(...) only for stacked display fractions in tall bboxes.\n" + "- Output one self-contained <typst>...</typst> per entry. Always close </typst>.\n\n" + "## Rule 4 — Preserve non-math text exactly\n" + "Do not translate, do not change wording. Only fix structure/wrapping.\n" + "Preserve <i>X</i> single-letter italics as-is — do NOT re-wrap them in <math>.\n\n" + "## Output\n" + "Return ONLY a JSON array. No prose, no fences. Every input id must appear once.\n" + ) + input_json = json.dumps(entries, ensure_ascii=False) + example_ids = [str(e.get("id")) for e in entries[:2]] + example = ", ".join(f'{{"id":"{k}","t":"<fixed text {k}>"}}' for k in example_ids) + user = ( + f"<input>\n```json\n{input_json}\n```\n</input>\n\n" + f"Return JSON array in this shape:\n[{example}]" + ) + return system, user + + +def build_glossary_prompt( + chunk: dict[str, str], + src_lang: str, + tgt_lang: str, +) -> tuple[str, str]: + system = "You are a professional glossary extractor." + input_json = json.dumps(chunk, ensure_ascii=False) + user = ( + f"Extract proper nouns — people, places, organizations, product names, technical terms " + f"— from the {src_lang} text below. Provide their {tgt_lang} translations.\n\n" + f"Rules:\n" + f"- Do NOT include common nouns.\n" + f"- Do NOT include content inside <math>...</math> or <ph-xxx> tags.\n" + f"- Each src appears at most once. No explanations.\n\n" + f"<input>\n```json\n{input_json}\n```\n</input>\n\n" + f'Output format — JSON array only:\n[{{"src":"<term>","dst":"<translation>"}}]' + ) + return system, user + + +def glossary_block_for_chunk(chunk: dict[str, str], glossary: dict[str, str]) -> str: + combined = " ".join(chunk.values()).lower() + matches = [(src, dst) for src, dst in glossary.items() if src.lower() in combined] + if not matches: + return "" + lines = "\n".join(f"{src} => {dst}" for src, dst in matches) + return f"# Glossary (use these exact translations when the term appears)\n{lines}\n" diff --git a/pdf2zh/translation/table_vision.py b/pdf2zh/translation/table_vision.py new file mode 100644 index 0000000000000000000000000000000000000000..44da9b63ada1319b7b634a521a6667e10ba258e0 --- /dev/null +++ b/pdf2zh/translation/table_vision.py @@ -0,0 +1,146 @@ +"""Vision-based table OCR verifier. + +For each TABLE element, crops the bbox from the PDF page and sends it to a +vision LLM together with the current OCR cell data (text + positions). + +The LLM checks whether the OCR is accurate: +- If correct → skip (cells are left unchanged, translation proceeds normally). +- If incorrect → update source_text and/or bbox_pdf on each affected cell + before translation runs. + +This pass runs BEFORE phase-2 translation so corrections feed into the +translated output rather than being applied after. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import logging +from pathlib import Path + +import fitz +import json_repair + +from .config import TranslatorConfig +from .gateway import Gateway + +logger = logging.getLogger(__name__) + +_VISION_SYSTEM = """\ +You are a table OCR verifier for a PDF translation pipeline. + +You receive: +1. A cropped image of a table from a PDF page. +2. The current OCR output as JSON: a list of cells, each with: + - "idx": cell index (integer, used to identify the cell) + - "source_text": OCR-extracted text + - "bbox_pdf": [x0, y0, x1, y1] position in PDF points + +Your task: +- Compare the visible text in the image against the OCR data. +- If ALL cells are accurate: return {"correct": true, "cells": []} +- If ANY cell has wrong text or clearly wrong position: return {"correct": false, "cells": [...]} + +In the corrected cells list, include ONLY cells that need fixing. +Each corrected cell must have "idx" plus the fields to update ("source_text" and/or "bbox_pdf"). + +Return ONLY valid JSON. No explanation, no markdown fences.\ +""" + + +def _crop_bbox_image( + pdf_path: str, page_idx: int, bbox_pdf: list, dpi: int = 150 +) -> str: + doc = fitz.open(pdf_path) + try: + page = doc[page_idx] + pad = 4.0 + x0, y0, x1, y1 = bbox_pdf + clip = fitz.Rect(x0 - pad, y0 - pad, x1 + pad, y1 + pad) + mat = fitz.Matrix(dpi / 72.0, dpi / 72.0) + pm = page.get_pixmap(matrix=mat, clip=clip, alpha=False) + return base64.b64encode(pm.tobytes("png")).decode("ascii") + finally: + doc.close() + + +async def _run(doc: dict, cfg: TranslatorConfig) -> None: + pdf_path = doc.get("pdf_path", "") + if not pdf_path or not Path(pdf_path).exists(): + logger.warning("table_vision: pdf_path '%s' not found, skipping", pdf_path) + return + + tables: list[tuple[int, dict]] = [ + (page_idx, elem) + for page_idx, page in enumerate(doc.get("pages", [])) + for elem in page.get("elements", []) + if elem.get("category") == "TABLE" + and any( + (cell.get("source_text") or "").strip() for cell in elem.get("cells", []) + ) + ] + + if not tables: + logger.info("table_vision: no TABLE elements found") + return + + logger.info("table_vision: verifying %d tables", len(tables)) + + async with Gateway(cfg) as gw: + + async def _process(page_idx: int, elem: dict) -> None: + cells = elem.get("cells", []) + cells_data = [ + { + "idx": i, + "source_text": cell["source_text"], + "bbox_pdf": cell.get("bbox_pdf", []), + } + for i, cell in enumerate(cells) + if (cell.get("source_text") or "").strip() + ] + if not cells_data: + return + try: + img = _crop_bbox_image(pdf_path, page_idx, elem["bbox_pdf"]) + prompt = ( + f"Current OCR cells:\n{json.dumps(cells_data, ensure_ascii=False)}" + ) + raw = await gw.call_vision(_VISION_SYSTEM, prompt, img) + result = json_repair.loads(raw) + if not isinstance(result, dict) or result.get("correct", True): + return + for corr in result.get("cells", []): + idx = corr.get("idx") + if ( + not isinstance(idx, int) + or idx < 0 + or idx >= len(cells) + or not (cells[idx].get("source_text") or "").strip() + ): + continue + if isinstance(corr.get("source_text"), str): + cells[idx]["source_text"] = corr["source_text"] + bbox = corr.get("bbox_pdf") + if ( + isinstance(bbox, list) + and len(bbox) == 4 + and all(isinstance(v, (int, float)) for v in bbox) + ): + cells[idx]["bbox_pdf"] = bbox + logger.debug( + "table_vision p%d: corrected %d cells", + page_idx, + len(result.get("cells", [])), + ) + except Exception as exc: + logger.warning("table_vision: p%d failed: %s", page_idx, exc) + + await asyncio.gather(*[_process(pi, elem) for pi, elem in tables]) + + +def table_vision_pass(doc: dict, cfg: TranslatorConfig) -> None: + """Verify and correct TABLE cell OCR before translation.""" + asyncio.run(_run(doc, cfg)) diff --git a/pdf2zh/translation/toc_fixer.py b/pdf2zh/translation/toc_fixer.py new file mode 100644 index 0000000000000000000000000000000000000000..2dc2f6b8a9b72f2c539667bfcd0243af06bf5604 --- /dev/null +++ b/pdf2zh/translation/toc_fixer.py @@ -0,0 +1,151 @@ +"""Post-translation pass: restructure flattened Table-of-Contents entries. + +Stage A flattens TOC pages into a single concatenated string — section +numbers, titles, dot leaders, and page numbers all run together. The +regex-based parser in render handles common cases but misses entries +without dot leaders or with unusual spacing. + +This pass asks the LLM to: + 1. Identify each TOC entry boundary + 2. Strip dot leaders + 3. Emit '<title>\\t<page_number>' lines (tab-separated) + +The render-time `parse_toc_entries` then has clean per-line input to work +with, producing properly aligned TOC layouts. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from typing import NamedTuple + +import json_repair + +from .config import TranslatorConfig +from .gateway import Gateway +from .prompts import build_toc_fix_prompt + +logger = logging.getLogger("json_translator") + + +class TocTask(NamedTuple): + elem: dict + write_key: str + text: str + bbox: list + id: str + + +def collect_toc_candidates(doc: dict) -> list[TocTask]: + """Return tasks for TOC elements that need restructuring.""" + tasks: list[TocTask] = [] + idx = 0 + for page in doc.get("pages", []): + for elem in page.get("elements", []): + if elem.get("label") != "TableOfContents": + continue + text = elem.get("translated_text", "") or "" + if not text: + continue + # Already restructured (has newlines / tabs) + if "\t" in text or text.count("\n") >= 3: + continue + bbox = elem.get("bbox_pdf") or [0, 0, 0, 0] + tasks.append(TocTask(elem, "translated_text", text, bbox, str(idx))) + idx += 1 + return tasks + + +def tasks_to_chunks(tasks: list[TocTask], max_bytes: int) -> list[list[TocTask]]: + chunks: list[list[TocTask]] = [] + current: list[TocTask] = [] + cur_bytes = 0 + for task in tasks: + entry = { + "id": task.id, + "text": task.text, + "bbox": [round(c, 1) for c in task.bbox], + } + size = len(json.dumps(entry, ensure_ascii=False).encode()) + if cur_bytes + size > max_bytes and current: + chunks.append(current) + current = [task] + cur_bytes = size + else: + current.append(task) + cur_bytes += size + if current: + chunks.append(current) + return chunks + + +async def _fix_one_chunk(gw: Gateway, chunk: list[TocTask]) -> dict[str, str]: + payload = [ + { + "id": t.id, + "text": t.text, + "bbox": [round(c, 1) for c in t.bbox], + } + for t in chunk + ] + system, user = build_toc_fix_prompt(payload) + + def _parse(raw: str) -> dict[str, str]: + parsed = json_repair.loads(raw) + if isinstance(parsed, dict): + parsed = [{"id": k, "t": v} for k, v in parsed.items()] + if not isinstance(parsed, list): + return {} + out: dict[str, str] = {} + for item in parsed: + if isinstance(item, dict) and "id" in item and "t" in item: + out[str(item["id"])] = str(item["t"]) + return out + + result = _parse(await gw.call(system, user)) + valid_ids = {t.id for t in chunk} + return {k: v for k, v in result.items() if k in valid_ids} + + +async def _fix_chunks( + chunks: list[list[TocTask]], cfg: TranslatorConfig +) -> dict[str, str]: + results: dict[str, str] = {} + lock = asyncio.Lock() + + async def _process(gw: Gateway, chunk: list[TocTask]) -> None: + try: + fixed = await _fix_one_chunk(gw, chunk) + async with lock: + results.update(fixed) + logger.info(f"TOC-fix chunk done: {len(chunk)} elements.") + except Exception as exc: + logger.warning(f"TOC-fix chunk failed: {exc}") + + async with Gateway(cfg) as gw: + await asyncio.gather(*[_process(gw, c) for c in chunks]) + return results + + +def fix_toc_document(doc: dict, cfg: TranslatorConfig) -> dict: + """Restructure flat TOC strings into tab-separated entry lines.""" + tasks = collect_toc_candidates(doc) + if not tasks: + logger.info("TOC-fix: no TOC candidates found.") + return doc + + chunks = tasks_to_chunks(tasks, cfg.chunk_bytes) + logger.info(f"TOC-fix: {len(tasks)} elements, {len(chunks)} chunks") + + fixes = asyncio.run(_fix_chunks(chunks, cfg)) + + applied = 0 + for task in tasks: + new_text = fixes.get(task.id) + if new_text is not None and new_text != task.text: + task.elem[task.write_key] = new_text + applied += 1 + logger.info(f"TOC-fix: applied {applied}/{len(tasks)} fixes") + return doc diff --git a/pdf2zh/translator.py b/pdf2zh/translator.py new file mode 100644 index 0000000000000000000000000000000000000000..b9976e1e1ef2eebf9b2cd0a57adf72ff88fc5102 --- /dev/null +++ b/pdf2zh/translator.py @@ -0,0 +1,1080 @@ +import html +import json +import logging +import os +import re +import unicodedata +from copy import copy +from string import Template +from typing import cast + +import deepl +import ollama +import openai +import requests +import xinference_client +from azure.ai.translation.text import TextTranslationClient +from azure.core.credentials import AzureKeyCredential +from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) +from tencentcloud.common import credential +from tencentcloud.tmt.v20180321.models import ( + TextTranslateRequest, + TextTranslateResponse, +) +from tencentcloud.tmt.v20180321.tmt_client import TmtClient + +from pdf2zh.cache import TranslationCache +from pdf2zh.config import ConfigManager + +logger = logging.getLogger(__name__) + + +def remove_control_characters(s): + return "".join(ch for ch in s if unicodedata.category(ch)[0] != "C") + + +class BaseTranslator: + name = "base" + envs = {} + lang_map: dict[str, str] = {} + CustomPrompt = False + + def __init__(self, lang_in: str, lang_out: str, model: str, ignore_cache: bool): + lang_in = self.lang_map.get(lang_in.lower(), lang_in) + lang_out = self.lang_map.get(lang_out.lower(), lang_out) + self.lang_in = lang_in + self.lang_out = lang_out + self.model = model + self.ignore_cache = ignore_cache + + self.cache = TranslationCache( + self.name, + { + "lang_in": lang_in, + "lang_out": lang_out, + "model": model, + }, + ) + + def set_envs(self, envs): + # Detach from self.__class__.envs + # Cannot use self.envs = copy(self.__class__.envs) + # because if set_envs called twice, the second call will override the first call + self.envs = copy(self.envs) + if ConfigManager.get_translator_by_name(self.name): + self.envs = ConfigManager.get_translator_by_name(self.name) + needUpdate = False + for key in self.envs: + if key in os.environ: + self.envs[key] = os.environ[key] + needUpdate = True + if needUpdate: + ConfigManager.set_translator_by_name(self.name, self.envs) + if envs is not None: + for key in envs: + self.envs[key] = envs[key] + ConfigManager.set_translator_by_name(self.name, self.envs) + + def add_cache_impact_parameters(self, k: str, v): + """ + Add parameters that affect the translation quality to distinguish the translation effects under different parameters. + :param k: key + :param v: value + """ + self.cache.add_params(k, v) + + def translate(self, text: str, ignore_cache: bool = False) -> str: + """ + Translate the text, and the other part should call this method. + :param text: text to translate + :return: translated text + """ + if not (self.ignore_cache or ignore_cache): + cache = self.cache.get(text) + if cache is not None: + return cache + + translation = self.do_translate(text) + self.cache.set(text, translation) + return translation + + def do_translate(self, text: str) -> str: + """ + Actual translate text, override this method + :param text: text to translate + :return: translated text + """ + raise NotImplementedError + + def prompt( + self, text: str, prompt_template: Template | None = None + ) -> list[dict[str, str]]: + try: + return [ + { + "role": "user", + "content": cast(Template, prompt_template).safe_substitute( + { + "lang_in": self.lang_in, + "lang_out": self.lang_out, + "text": text, + } + ), + } + ] + except AttributeError: # `prompt_template` is None + pass + except Exception: + logging.exception("Error parsing prompt, use the default prompt.") + + return [ + { + "role": "user", + "content": ( + "You are a professional, authentic machine translation engine. " + "Only Output the translated text, do not include any other text." + "\n\n" + f"Translate the following markdown source text to {self.lang_out}. " + "Keep the formula notation {v*} unchanged. " + "Output translation directly without any additional text." + "\n\n" + f"Source Text: {text}" + "\n\n" + "Translated Text:" + ), + }, + ] + + def __str__(self): + return f"{self.name} {self.lang_in} {self.lang_out} {self.model}" + + def get_rich_text_left_placeholder(self, id: int): + return f"<b{id}>" + + def get_rich_text_right_placeholder(self, id: int): + return f"</b{id}>" + + def get_formular_placeholder(self, id: int): + return self.get_rich_text_left_placeholder( + id + ) + self.get_rich_text_right_placeholder(id) + + +class GoogleTranslator(BaseTranslator): + name = "google" + lang_map = {"zh": "zh-CN"} + + def __init__(self, lang_in, lang_out, model, ignore_cache=False, **kwargs): + super().__init__(lang_in, lang_out, model, ignore_cache) + self.session = requests.Session() + self.endpoint = "https://translate.google.com/m" + self.headers = { + "User-Agent": "Mozilla/4.0 (compatible;MSIE 6.0;Windows NT 5.1;SV1;.NET CLR 1.1.4322;.NET CLR 2.0.50727;.NET CLR 3.0.04506.30)" # noqa: E501 + } + + def do_translate(self, text): + text = text[:5000] # google translate max length + response = self.session.get( + self.endpoint, + params={"tl": self.lang_out, "sl": self.lang_in, "q": text}, + headers=self.headers, + ) + re_result = re.findall( + r'(?s)class="(?:t0|result-container)">(.*?)<', response.text + ) + if response.status_code == 400: + result = "IRREPARABLE TRANSLATION ERROR" + else: + response.raise_for_status() + result = html.unescape(re_result[0]) + return remove_control_characters(result) + + +class BingTranslator(BaseTranslator): + # https://github.com/immersive-translate/old-immersive-translate/blob/6df13da22664bea2f51efe5db64c63aca59c4e79/src/background/translationService.js + name = "bing" + lang_map = {"zh": "zh-Hans"} + + def __init__(self, lang_in, lang_out, model, ignore_cache=False, **kwargs): + super().__init__(lang_in, lang_out, model, ignore_cache) + self.session = requests.Session() + self.endpoint = "https://www.bing.com/translator" + self.headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", # noqa: E501 + } + + def find_sid(self): + response = self.session.get(self.endpoint) + response.raise_for_status() + url = response.url[:-10] + ig = re.findall(r"\"ig\":\"(.*?)\"", response.text)[0] + iid = re.findall(r"data-iid=\"(.*?)\"", response.text)[-1] + key, token = re.findall( + r"params_AbusePreventionHelper\s=\s\[(.*?),\"(.*?)\",", response.text + )[0] + return url, ig, iid, key, token + + def do_translate(self, text): + text = text[:1000] # bing translate max length + url, ig, iid, key, token = self.find_sid() + response = self.session.post( + f"{url}ttranslatev3?IG={ig}&IID={iid}", + data={ + "fromLang": self.lang_in, + "to": self.lang_out, + "text": text, + "token": token, + "key": key, + }, + headers=self.headers, + ) + response.raise_for_status() + return response.json()[0]["translations"][0]["text"] + + +class DeepLTranslator(BaseTranslator): + # https://github.com/DeepLcom/deepl-python + name = "deepl" + envs = { + "DEEPL_AUTH_KEY": None, + } + lang_map = {"zh": "zh-Hans"} + + def __init__( + self, lang_in, lang_out, model, envs=None, ignore_cache=False, **kwargs + ): + self.set_envs(envs) + super().__init__(lang_in, lang_out, model, ignore_cache) + auth_key = self.envs["DEEPL_AUTH_KEY"] + self.client = deepl.Translator(auth_key) + + def do_translate(self, text): + response = self.client.translate_text( + text, target_lang=self.lang_out, source_lang=self.lang_in + ) + return response.text + + +class DeepLXTranslator(BaseTranslator): + # https://deeplx.owo.network/endpoints/free.html + name = "deeplx" + envs = { + "DEEPLX_ENDPOINT": "https://api.deepl.com/translate", + "DEEPLX_ACCESS_TOKEN": None, + } + lang_map = {"zh": "zh-Hans"} + + def __init__( + self, lang_in, lang_out, model, envs=None, ignore_cache=False, **kwargs + ): + self.set_envs(envs) + super().__init__(lang_in, lang_out, model, ignore_cache) + self.endpoint = self.envs["DEEPLX_ENDPOINT"] + self.session = requests.Session() + auth_key = self.envs["DEEPLX_ACCESS_TOKEN"] + if auth_key: + self.endpoint = f"{self.endpoint}?token={auth_key}" + + def do_translate(self, text): + response = self.session.post( + self.endpoint, + json={ + "source_lang": self.lang_in, + "target_lang": self.lang_out, + "text": text, + }, + verify=False, # noqa: S506 + ) + response.raise_for_status() + return response.json()["data"] + + +class OllamaTranslator(BaseTranslator): + # https://github.com/ollama/ollama-python + name = "ollama" + envs = { + "OLLAMA_HOST": "http://127.0.0.1:11434", + "OLLAMA_MODEL": "gemma2", + } + CustomPrompt = True + + def __init__( + self, + lang_in: str, + lang_out: str, + model: str, + envs=None, + prompt: Template | None = None, + ignore_cache=False, + ): + self.set_envs(envs) + if not model: + model = self.envs["OLLAMA_MODEL"] + super().__init__(lang_in, lang_out, model, ignore_cache) + self.options = { + "temperature": 0, # 随机采样可能会打断公式标记 + "num_predict": 2000, + } + self.client = ollama.Client(host=self.envs["OLLAMA_HOST"]) + self.prompt_template = prompt + self.add_cache_impact_parameters("temperature", self.options["temperature"]) + + def do_translate(self, text: str) -> str: + if (max_token := len(text) * 5) > self.options["num_predict"]: + self.options["num_predict"] = max_token + + response = self.client.chat( + model=self.model, + messages=self.prompt(text, self.prompt_template), + options=self.options, + ) + content = self._remove_cot_content(response.message.content or "") + return content.strip() + + @staticmethod + def _remove_cot_content(content: str) -> str: + """Remove text content with the thought chain from the chat response + + :param content: Non-streaming text content + :return: Text without a thought chain + """ + return re.sub(r"^<think>.+?</think>", "", content, count=1, flags=re.DOTALL) + + +class XinferenceTranslator(BaseTranslator): + # https://github.com/xorbitsai/inference + name = "xinference" + envs = { + "XINFERENCE_HOST": "http://127.0.0.1:9997", + "XINFERENCE_MODEL": "gemma-2-it", + } + CustomPrompt = True + + def __init__( + self, lang_in, lang_out, model, envs=None, prompt=None, ignore_cache=False + ): + self.set_envs(envs) + if not model: + model = self.envs["XINFERENCE_MODEL"] + super().__init__(lang_in, lang_out, model, ignore_cache) + self.options = {"temperature": 0} # 随机采样可能会打断公式标记 + self.client = xinference_client.RESTfulClient(self.envs["XINFERENCE_HOST"]) + self.prompttext = prompt + self.add_cache_impact_parameters("temperature", self.options["temperature"]) + + def do_translate(self, text): + maxlen = max(2000, len(text) * 5) + for model in self.model.split(";"): + try: + xf_model = self.client.get_model(model) + xf_prompt = self.prompt(text, self.prompttext) + xf_prompt = [ + { + "role": "user", + "content": xf_prompt[0]["content"] + + "\n" + + xf_prompt[1]["content"], + } + ] + response = xf_model.chat( + generate_config=self.options, + messages=xf_prompt, + ) + + response = response["choices"][0]["message"]["content"].replace( + "<end_of_turn>", "" + ) + if len(response) > maxlen: + raise Exception("Response too long") + return response.strip() + except Exception as e: + print(e) + raise Exception("All models failed") + + +class OpenAITranslator(BaseTranslator): + # https://github.com/openai/openai-python + name = "openai" + envs = { + "OPENAI_BASE_URL": "https://api.openai.com/v1", + "OPENAI_API_KEY": None, + "OPENAI_MODEL": "gpt-4o-mini", + } + CustomPrompt = True + + def __init__( + self, + lang_in, + lang_out, + model, + base_url=None, + api_key=None, + envs=None, + prompt=None, + ignore_cache=False, + ): + self.set_envs(envs) + if not model: + model = self.envs["OPENAI_MODEL"] + super().__init__(lang_in, lang_out, model, ignore_cache) + self.options = {"temperature": 0} # 随机采样可能会打断公式标记 + self.client = openai.OpenAI( + base_url=base_url or self.envs["OPENAI_BASE_URL"], + api_key=api_key or self.envs["OPENAI_API_KEY"], + ) + self.prompttext = prompt + self.add_cache_impact_parameters("temperature", self.options["temperature"]) + self.add_cache_impact_parameters("prompt", self.prompt("", self.prompttext)) + think_filter_regex = r"^<think>.+?\n*(</think>|\n)*(</think>)\n*" + self.add_cache_impact_parameters("think_filter_regex", think_filter_regex) + self.think_filter_regex = re.compile(think_filter_regex, flags=re.DOTALL) + + @retry( + retry=retry_if_exception_type(openai.RateLimitError), + stop=stop_after_attempt(100), + wait=wait_exponential(multiplier=1, min=1, max=15), + before_sleep=lambda retry_state: logger.warning( + f"RateLimitError, retrying in {retry_state.next_action.sleep} seconds... " + f"(Attempt {retry_state.attempt_number}/100)" + ), + ) + def do_translate(self, text) -> str: + response = self.client.chat.completions.create( + model=self.model, + **self.options, + messages=self.prompt(text, self.prompttext), + ) + if not response.choices: + if hasattr(response, "error"): + raise ValueError("Error response from Service", response.error) + content = response.choices[0].message.content.strip() + content = self.think_filter_regex.sub("", content).strip() + return content + + def get_formular_placeholder(self, id: int): + return "{{v" + str(id) + "}}" + + def get_rich_text_left_placeholder(self, id: int): + return self.get_formular_placeholder(id) + + def get_rich_text_right_placeholder(self, id: int): + return self.get_formular_placeholder(id + 1) + + +class AzureOpenAITranslator(BaseTranslator): + name = "azure-openai" + envs = { + "AZURE_OPENAI_BASE_URL": None, # e.g. "https://xxx.openai.azure.com" + "AZURE_OPENAI_API_KEY": None, + "AZURE_OPENAI_MODEL": "gpt-4o-mini", + "AZURE_OPENAI_API_VERSION": "2024-06-01", # default api version + } + CustomPrompt = True + + def __init__( + self, + lang_in, + lang_out, + model, + base_url=None, + api_key=None, + envs=None, + prompt=None, + ignore_cache=False, + ): + self.set_envs(envs) + base_url = self.envs["AZURE_OPENAI_BASE_URL"] + if not model: + model = self.envs["AZURE_OPENAI_MODEL"] + api_version = self.envs.get("AZURE_OPENAI_API_VERSION", "2024-06-01") + if api_key is None: + api_key = self.envs["AZURE_OPENAI_API_KEY"] + super().__init__(lang_in, lang_out, model, ignore_cache) + self.options = {"temperature": 0} + self.client = openai.AzureOpenAI( + azure_endpoint=base_url, + azure_deployment=model, + api_version=api_version, + api_key=api_key, + ) + self.prompttext = prompt + self.add_cache_impact_parameters("temperature", self.options["temperature"]) + self.add_cache_impact_parameters("prompt", self.prompt("", self.prompttext)) + + def do_translate(self, text) -> str: + response = self.client.chat.completions.create( + model=self.model, + **self.options, + messages=self.prompt(text, self.prompttext), + ) + return response.choices[0].message.content.strip() + + +class ModelScopeTranslator(OpenAITranslator): + name = "modelscope" + envs = { + "MODELSCOPE_BASE_URL": "https://api-inference.modelscope.cn/v1", + "MODELSCOPE_API_KEY": None, + "MODELSCOPE_MODEL": "Qwen/Qwen2.5-32B-Instruct", + } + CustomPrompt = True + + def __init__( + self, + lang_in, + lang_out, + model, + base_url=None, + api_key=None, + envs=None, + prompt=None, + ignore_cache=False, + ): + self.set_envs(envs) + base_url = "https://api-inference.modelscope.cn/v1" + api_key = self.envs["MODELSCOPE_API_KEY"] + if not model: + model = self.envs["MODELSCOPE_MODEL"] + super().__init__( + lang_in, + lang_out, + model, + base_url=base_url, + api_key=api_key, + ignore_cache=ignore_cache, + ) + self.prompttext = prompt + self.add_cache_impact_parameters("prompt", self.prompt("", self.prompttext)) + + +class ZhipuTranslator(OpenAITranslator): + # https://bigmodel.cn/dev/api/thirdparty-frame/openai-sdk + name = "zhipu" + envs = { + "ZHIPU_API_KEY": None, + "ZHIPU_MODEL": "glm-4-flash", + } + CustomPrompt = True + + def __init__( + self, lang_in, lang_out, model, envs=None, prompt=None, ignore_cache=False + ): + self.set_envs(envs) + base_url = "https://open.bigmodel.cn/api/paas/v4" + api_key = self.envs["ZHIPU_API_KEY"] + if not model: + model = self.envs["ZHIPU_MODEL"] + super().__init__( + lang_in, + lang_out, + model, + base_url=base_url, + api_key=api_key, + ignore_cache=ignore_cache, + ) + self.prompttext = prompt + self.add_cache_impact_parameters("prompt", self.prompt("", self.prompttext)) + + def do_translate(self, text) -> str: + try: + response = self.client.chat.completions.create( + model=self.model, + **self.options, + messages=self.prompt(text, self.prompttext), + ) + except openai.BadRequestError as e: + if ( + json.loads(response.choices[0].message.content.strip())["error"]["code"] + == "1301" + ): + return "IRREPARABLE TRANSLATION ERROR" + raise e + return response.choices[0].message.content.strip() + + +class SiliconTranslator(OpenAITranslator): + # https://docs.siliconflow.cn/quickstart + name = "silicon" + envs = { + "SILICON_API_KEY": None, + "SILICON_MODEL": "Qwen/Qwen2.5-7B-Instruct", + } + CustomPrompt = True + + def __init__( + self, lang_in, lang_out, model, envs=None, prompt=None, ignore_cache=False + ): + self.set_envs(envs) + base_url = "https://api.siliconflow.cn/v1" + api_key = self.envs["SILICON_API_KEY"] + if not model: + model = self.envs["SILICON_MODEL"] + super().__init__( + lang_in, + lang_out, + model, + base_url=base_url, + api_key=api_key, + ignore_cache=ignore_cache, + ) + self.prompttext = prompt + self.add_cache_impact_parameters("prompt", self.prompt("", self.prompttext)) + + +class X302AITranslator(OpenAITranslator): + # https://doc.302.ai/ + name = "302ai" + envs = { + "X302AI_API_KEY": None, + "X302AI_MODEL": "Gemma-7B", + } + CustomPrompt = True + + def __init__( + self, lang_in, lang_out, model, envs=None, prompt=None, ignore_cache=False + ): + self.set_envs(envs) + base_url = "https://api.302.ai/v1" + api_key = self.envs["X302AI_API_KEY"] + if not model: + model = self.envs["X302AI_MODEL"] + super().__init__( + lang_in, + lang_out, + model, + base_url=base_url, + api_key=api_key, + ignore_cache=ignore_cache, + ) + self.prompttext = prompt + self.add_cache_impact_parameters("prompt", self.prompt("", self.prompttext)) + + +class GeminiTranslator(OpenAITranslator): + # https://ai.google.dev/gemini-api/docs/openai + name = "gemini" + envs = { + "GEMINI_API_KEY": None, + "GEMINI_MODEL": "gemini-1.5-flash", + } + CustomPrompt = True + + def __init__( + self, lang_in, lang_out, model, envs=None, prompt=None, ignore_cache=False + ): + self.set_envs(envs) + base_url = "https://generativelanguage.googleapis.com/v1beta/openai/" + api_key = self.envs["GEMINI_API_KEY"] + if not model: + model = self.envs["GEMINI_MODEL"] + super().__init__( + lang_in, + lang_out, + model, + base_url=base_url, + api_key=api_key, + ignore_cache=ignore_cache, + ) + self.prompttext = prompt + self.add_cache_impact_parameters("prompt", self.prompt("", self.prompttext)) + + +class AzureTranslator(BaseTranslator): + # https://github.com/Azure/azure-sdk-for-python + name = "azure" + envs = { + "AZURE_ENDPOINT": "https://api.translator.azure.cn", + "AZURE_API_KEY": None, + } + lang_map = {"zh": "zh-Hans"} + + def __init__( + self, lang_in, lang_out, model, envs=None, ignore_cache=False, **kwargs + ): + self.set_envs(envs) + super().__init__(lang_in, lang_out, model, ignore_cache) + endpoint = self.envs["AZURE_ENDPOINT"] + api_key = self.envs["AZURE_API_KEY"] + credential = AzureKeyCredential(api_key) + self.client = TextTranslationClient( + endpoint=endpoint, credential=credential, region="chinaeast2" + ) + # https://github.com/Azure/azure-sdk-for-python/issues/9422 + logger = logging.getLogger("azure.core.pipeline.policies.http_logging_policy") + logger.setLevel(logging.WARNING) + + def do_translate(self, text) -> str: + response = self.client.translate( + body=[text], + from_language=self.lang_in, + to_language=[self.lang_out], + ) + translated_text = response[0].translations[0].text + return translated_text + + +class TencentTranslator(BaseTranslator): + # https://github.com/TencentCloud/tencentcloud-sdk-python + name = "tencent" + envs = { + "TENCENTCLOUD_SECRET_ID": None, + "TENCENTCLOUD_SECRET_KEY": None, + } + + def __init__( + self, lang_in, lang_out, model, envs=None, ignore_cache=False, **kwargs + ): + self.set_envs(envs) + super().__init__(lang_in, lang_out, model) + try: + cred = credential.DefaultCredentialProvider().get_credential() + except EnvironmentError: + cred = credential.Credential( + self.envs["TENCENTCLOUD_SECRET_ID"], + self.envs["TENCENTCLOUD_SECRET_KEY"], + ) + self.client = TmtClient(cred, "ap-beijing") + self.req = TextTranslateRequest() + self.req.Source = self.lang_in + self.req.Target = self.lang_out + self.req.ProjectId = 0 + + def do_translate(self, text): + self.req.SourceText = text + resp: TextTranslateResponse = self.client.TextTranslate(self.req) + return resp.TargetText + + +class AnythingLLMTranslator(BaseTranslator): + name = "anythingllm" + envs = { + "AnythingLLM_URL": None, + "AnythingLLM_APIKEY": None, + } + CustomPrompt = True + + def __init__( + self, lang_out, lang_in, model, envs=None, prompt=None, ignore_cache=False + ): + self.set_envs(envs) + super().__init__(lang_out, lang_in, model, ignore_cache) + self.api_url = self.envs["AnythingLLM_URL"] + self.api_key = self.envs["AnythingLLM_APIKEY"] + self.headers = { + "accept": "application/json", + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + self.prompttext = prompt + + def do_translate(self, text): + messages = self.prompt(text, self.prompttext) + payload = { + "message": messages, + "mode": "chat", + "sessionId": "translation_expert", + } + + response = requests.post( + self.api_url, headers=self.headers, data=json.dumps(payload) + ) + response.raise_for_status() + data = response.json() + + if "textResponse" in data: + return data["textResponse"].strip() + + +class DifyTranslator(BaseTranslator): + name = "dify" + envs = { + "DIFY_API_URL": None, # 填写实际 Dify API 地址 + "DIFY_API_KEY": None, # 替换为实际 API 密钥 + } + + def __init__( + self, lang_out, lang_in, model, envs=None, ignore_cache=False, **kwargs + ): + self.set_envs(envs) + super().__init__(lang_out, lang_in, model, ignore_cache) + self.api_url = self.envs["DIFY_API_URL"] + self.api_key = self.envs["DIFY_API_KEY"] + + def do_translate(self, text): + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + payload = { + "inputs": { + "lang_out": self.lang_out, + "lang_in": self.lang_in, + "text": text, + }, + "response_mode": "blocking", + "user": "translator-service", + } + + # 向 Dify 服务器发送请求 + response = requests.post( + self.api_url, headers=headers, data=json.dumps(payload) + ) + response.raise_for_status() + response_data = response.json() + + # 解析响应 + return response_data.get("answer", "") + + +class ArgosTranslator(BaseTranslator): + name = "argos" + + def __init__(self, lang_in, lang_out, model, ignore_cache=False, **kwargs): + try: + import argostranslate.package + import argostranslate.translate + except ImportError: + logger.warning( + "argos-translate is not installed, if you want to use argostranslate, please install it. If you don't use argostranslate translator, you can safely ignore this warning." + ) + raise + super().__init__(lang_in, lang_out, model, ignore_cache) + lang_in = self.lang_map.get(lang_in.lower(), lang_in) + lang_out = self.lang_map.get(lang_out.lower(), lang_out) + self.lang_in = lang_in + self.lang_out = lang_out + argostranslate.package.update_package_index() + available_packages = argostranslate.package.get_available_packages() + try: + available_package = list( + filter( + lambda x: ( + x.from_code == self.lang_in and x.to_code == self.lang_out + ), + available_packages, + ) + )[0] + except Exception: + raise ValueError( + "lang_in and lang_out pair not supported by Argos Translate." + ) + download_path = available_package.download() + argostranslate.package.install_from_path(download_path) + + def translate(self, text: str, ignore_cache: bool = False): + # Translate + import argotranslate.translate # noqa: F401 + + installed_languages = ( + argostranslate.translate.get_installed_languages() # noqa: F821 + ) + from_lang = list(filter(lambda x: x.code == self.lang_in, installed_languages))[ + 0 + ] + to_lang = list(filter(lambda x: x.code == self.lang_out, installed_languages))[ + 0 + ] + translation = from_lang.get_translation(to_lang) + translatedText = translation.translate(text) + return translatedText + + +class GrokTranslator(OpenAITranslator): + # https://docs.x.ai/docs/overview#getting-started + name = "grok" + envs = { + "GROK_API_KEY": None, + "GROK_MODEL": "grok-2-1212", + } + CustomPrompt = True + + def __init__( + self, lang_in, lang_out, model, envs=None, prompt=None, ignore_cache=False + ): + self.set_envs(envs) + base_url = "https://api.x.ai/v1" + api_key = self.envs["GROK_API_KEY"] + if not model: + model = self.envs["GROK_MODEL"] + super().__init__( + lang_in, + lang_out, + model, + base_url=base_url, + api_key=api_key, + ignore_cache=ignore_cache, + ) + self.prompttext = prompt + + +class GroqTranslator(OpenAITranslator): + name = "groq" + envs = { + "GROQ_API_KEY": None, + "GROQ_MODEL": "llama-3-3-70b-versatile", + } + CustomPrompt = True + + def __init__( + self, lang_in, lang_out, model, envs=None, prompt=None, ignore_cache=False + ): + self.set_envs(envs) + base_url = "https://api.groq.com/openai/v1" + api_key = self.envs["GROQ_API_KEY"] + if not model: + model = self.envs["GROQ_MODEL"] + super().__init__( + lang_in, + lang_out, + model, + base_url=base_url, + api_key=api_key, + ignore_cache=ignore_cache, + ) + self.prompttext = prompt + + +class DeepseekTranslator(OpenAITranslator): + name = "deepseek" + envs = { + "DEEPSEEK_API_KEY": None, + "DEEPSEEK_MODEL": "deepseek-chat", + } + CustomPrompt = True + + def __init__( + self, lang_in, lang_out, model, envs=None, prompt=None, ignore_cache=False + ): + self.set_envs(envs) + base_url = "https://api.deepseek.com/v1" + api_key = self.envs["DEEPSEEK_API_KEY"] + if not model: + model = self.envs["DEEPSEEK_MODEL"] + super().__init__( + lang_in, + lang_out, + model, + base_url=base_url, + api_key=api_key, + ignore_cache=ignore_cache, + ) + self.prompttext = prompt + + +class OpenAIlikedTranslator(OpenAITranslator): + name = "openailiked" + envs = { + "OPENAILIKED_BASE_URL": None, + "OPENAILIKED_API_KEY": None, + "OPENAILIKED_MODEL": None, + } + CustomPrompt = True + + def __init__( + self, lang_in, lang_out, model, envs=None, prompt=None, ignore_cache=False + ): + self.set_envs(envs) + if self.envs["OPENAILIKED_BASE_URL"]: + base_url = self.envs["OPENAILIKED_BASE_URL"] + else: + raise ValueError("The OPENAILIKED_BASE_URL is missing.") + if not model: + if self.envs["OPENAILIKED_MODEL"]: + model = self.envs["OPENAILIKED_MODEL"] + else: + raise ValueError("The OPENAILIKED_MODEL is missing.") + if self.envs["OPENAILIKED_API_KEY"] is None: + api_key = "openailiked" + else: + api_key = self.envs["OPENAILIKED_API_KEY"] + super().__init__( + lang_in, + lang_out, + model, + base_url=base_url, + api_key=api_key, + ignore_cache=ignore_cache, + ) + self.prompttext = prompt + + +class QwenMtTranslator(OpenAITranslator): + """ + Use Qwen-MT model from Aliyun. it's designed for translating. + Since Traditional Chinese is not yet supported by Aliyun. it will be also translated to Simplified Chinese, when it's selected. + There's special parameters in the message to the server. + """ + + name = "qwen-mt" + envs = { + "ALI_MODEL": "qwen-mt-turbo", + "ALI_API_KEY": None, + "ALI_DOMAINS": "This sentence is extracted from a scientific paper. When translating, please pay close attention to the use of specialized troubleshooting terminologies and adhere to scientific sentence structures to maintain the technical rigor and precision of the original text.", + } + CustomPrompt = True + + def __init__( + self, lang_in, lang_out, model, envs=None, prompt=None, ignore_cache=False + ): + self.set_envs(envs) + base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1" + api_key = self.envs["ALI_API_KEY"] + + if not model: + model = self.envs["ALI_MODEL"] + + super().__init__( + lang_in, + lang_out, + model, + base_url=base_url, + api_key=api_key, + ignore_cache=ignore_cache, + ) + self.prompttext = prompt + + @staticmethod + def lang_mapping(input_lang: str) -> str: + """ + Mapping the language code to the language code that Aliyun Qwen-Mt model supports. + Since all existings languagues codes used in gui.py are able to be mapped, the original + languague code will not be checked. + """ + langdict = { + "zh": "Chinese", + "zh-TW": "Chinese", + "en": "English", + "fr": "French", + "de": "German", + "ja": "Japanese", + "ko": "Korean", + "ru": "Russian", + "es": "Spanish", + "it": "Italian", + } + + return langdict[input_lang] + + def do_translate(self, text) -> str: + """ + Qwen-MT Model reqeust to send translation_options to the server. + domains are options, but suggested. it must be in English. + """ + translation_options = { + "source_lang": self.lang_mapping(self.lang_in), + "target_lang": self.lang_mapping(self.lang_out), + "domains": self.envs["ALI_DOMAINS"], + } + response = self.client.chat.completions.create( + model=self.model, + **self.options, + messages=[{"role": "user", "content": text}], + extra_body={"translation_options": translation_options}, + ) + return response.choices[0].message.content.strip() diff --git a/pdf2zh/webapp/__init__.py b/pdf2zh/webapp/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..de0c04fbbc566f5568a20a11e7946d5d12864035 --- /dev/null +++ b/pdf2zh/webapp/__init__.py @@ -0,0 +1,6 @@ +"""Gradio web app for end-to-end PDF translation. + +Submodules: ``config`` and ``runner`` are framework-agnostic (importable without +Gradio); ``ui`` holds the Gradio layout. Kept import-light on purpose so the +agnostic modules can be unit-tested without pulling in Gradio. +""" diff --git a/pdf2zh/webapp/config.py b/pdf2zh/webapp/config.py new file mode 100644 index 0000000000000000000000000000000000000000..c2b0a38ca6d832947ba286b7999ebc1489d3eb1c --- /dev/null +++ b/pdf2zh/webapp/config.py @@ -0,0 +1,49 @@ +"""UI-facing configuration: provider/page presets and helpers (no Gradio deps).""" + +from __future__ import annotations + +# UI label -> Phase-2 provider key. +PROVIDER_KEY = { + "OpenRouter": "openrouter", + "Gemini": "gemini", + "OpenAI": "openai", + "DeepSeek": "deepseek", + "MiniMax": "minimax", + "Anthropic": "anthropic", + "LiteLLM": "litellm", +} +PROVIDER_CHOICES = list(PROVIDER_KEY) + +# Default model placeholders (mirror translation/config.py PROVIDERS). +PROVIDER_DEFAULT_MODEL = { + "OpenRouter": "google/gemini-2.5-flash-lite", + "Gemini": "gemini-2.5-flash-lite", + "OpenAI": "gpt-4o-mini", + "DeepSeek": "deepseek-chat", + "MiniMax": "MiniMax-Text-01", + "Anthropic": "claude-haiku-4-5", + "LiteLLM": "gpt-4o-mini", +} + +# Page-selection modes. "All" translates the whole document; "Range" uses the +# 1-based from/to boxes. +PAGE_MODE_ALL = "Toàn bộ" +PAGE_MODE_RANGE = "Khoảng trang" +PAGE_MODES = [PAGE_MODE_ALL, PAGE_MODE_RANGE] +MAX_CUSTOM_PAGES = 50 # guardrail against OOM on a single T4 + + +def resolve_pages(mode: str, from_page, to_page) -> list[int] | None: + """Map the page mode (+ 1-based from/to) to a 0-based page index list or None. + + ``All`` → None (whole document). ``Range`` → inclusive 1-based ``[from, to]`` + converted to 0-based indices, with the span capped at ``MAX_CUSTOM_PAGES``. + Out-of-range high values are harmless: the parser and compositor both drop + indices past the document's last page. + """ + if mode != PAGE_MODE_RANGE: + return None + lo = max(1, int(from_page or 1)) + hi = max(lo, int(to_page or lo)) + hi = min(hi, lo + MAX_CUSTOM_PAGES - 1) # cap the span + return list(range(lo - 1, hi)) diff --git a/pdf2zh/webapp/modal.css b/pdf2zh/webapp/modal.css new file mode 100644 index 0000000000000000000000000000000000000000..58a9b59f3a0c179246d7d45cc08c636e96b3b369 --- /dev/null +++ b/pdf2zh/webapp/modal.css @@ -0,0 +1,37 @@ +/* Full-screen overlay: dims + blurs the page so nothing is clickable while a + translation runs. */ +#modal-overlay { + position: fixed; + inset: 0; + z-index: 9999; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); +} +#modal-box { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--background-fill-primary); + border-radius: 14px; + padding: 28px 32px; + width: 340px; + max-width: 90vw; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.35); + text-align: center; +} +#modal-box .spinner { + width: 40px; + height: 40px; + margin: 0 auto 16px; + border: 4px solid var(--neutral-200, #e5e7eb); + border-top-color: var(--primary-500, #2563eb); + border-radius: 50%; + animation: modal-spin 0.9s linear infinite; +} +@keyframes modal-spin { + to { transform: rotate(360deg); } +} +#modal-box .modal-title { font-weight: 600; font-size: 1.1rem; margin-bottom: 6px; } +#modal-box .modal-msg { color: var(--body-text-color-subdued); font-size: 0.9rem; } diff --git a/pdf2zh/webapp/review.py b/pdf2zh/webapp/review.py new file mode 100644 index 0000000000000000000000000000000000000000..a17e7f7391259e443a0196db713f3127770c8b3c --- /dev/null +++ b/pdf2zh/webapp/review.py @@ -0,0 +1,510 @@ +"""Review-layer helpers for the human-in-the-loop checkpoints (Gradio-agnostic). + +Phase 1 (after OCR) and Phase 3 (after render) let the user click a page +preview to select an element and edit it. This module holds the pure logic: +rasterize a page with element boxes drawn on top, hit-test a click to an +element, and apply edits to the parsed / translated dicts. No Gradio imports, +so it is unit-testable in isolation. + +Coordinate note: ``bbox_pdf`` is top-left origin, Y-down, same axes as the +rasterized image (see parser/utils/bbox.py) — only a uniform ``dpi/72`` scale +separates PDF points from image pixels. Element boxes therefore map onto both +the original page (Phase 1) and the rendered output page (Phase 3), which keep +the original page dimensions. +""" + +from __future__ import annotations + +import html +from typing import Any + +import fitz +from PIL import Image, ImageDraw + +from pdf2zh.parser.enums import DEFAULT_CATEGORY, SURYA_LABEL_MAP, SuryaLabel +from pdf2zh.parser.utils.image import _fitz_render + +# Labels offered in the Phase-1 label dropdown (Surya layout labels). +LABEL_CHOICES = [ + SuryaLabel.TEXT, + SuryaLabel.LIST_ITEM, + SuryaLabel.FOOTNOTE, + SuryaLabel.SECTION_HEADER, + SuryaLabel.PAGE_HEADER, + SuryaLabel.PAGE_FOOTER, + SuryaLabel.CAPTION, + SuryaLabel.TABLE, + SuryaLabel.TABLE_OF_CONTENTS, + SuryaLabel.PICTURE, + SuryaLabel.FIGURE, + SuryaLabel.FORM, + SuryaLabel.EQUATION, + SuryaLabel.CODE, +] + +# Outline colors by category (RGB). +_COLOR_TRANSLATABLE = (0, 120, 255) +_COLOR_BYPASS = (150, 150, 150) +_COLOR_HIGHLIGHT = (230, 30, 30) + + +def _category_for_label(label: str) -> str: + """Derive the ElementCategory string for a raw layout label.""" + return SURYA_LABEL_MAP.get(label, DEFAULT_CATEGORY).value + + +def hex_to_rgb(value: str | None) -> list[int] | None: + """Parse a CSS color string into ``[r, g, b]`` ints, or None if unparseable. + + Accepts ``#rgb`` / ``#rrggbb`` and ``rgb(...)`` / ``rgba(...)`` forms (what + ``gr.ColorPicker`` emits). Alpha is ignored. None lets the caller fall back + to auto-sampling instead of crashing on a stray value. + """ + if not value or not isinstance(value, str): + return None + s = value.strip() + if s.startswith("#"): + h = s[1:] + if len(h) == 3: + h = "".join(c * 2 for c in h) + if len(h) != 6: + return None + try: + return [int(h[i : i + 2], 16) for i in (0, 2, 4)] + except ValueError: + return None + if s.lower().startswith(("rgb(", "rgba(")): + try: + inner = s[s.index("(") + 1 : s.rindex(")")] + parts = inner.split(",") + if len(parts) < 3: + return None + return [max(0, min(255, round(float(p)))) for p in parts[:3]] + except ValueError: + return None + return None + + +def render_page_with_boxes( + pdf_path: str, + page_index: int, + elements: list[dict], + dpi: int = 150, + highlight_idx: int | None = None, + highlight_cell: tuple[int, int] | None = None, +) -> tuple[Image.Image, list[dict], float]: + """Rasterize page ``page_index`` and draw each element's bbox on top. + + A TABLE element with cells is drawn cell-by-cell inside a thin outer + border (translation happens per cell, not per whole table); every other + element (and a TABLE with no cells) draws a single box. Returns + ``(image, boxes, scale)`` where ``boxes`` is a list of ``{"elem_idx", + "cell_idx", "bbox_img", "category"}`` (``cell_idx`` is None for + element-level boxes) and ``scale = dpi/72``. Each box's tag shows its + ``label`` (e.g. "Table", "Caption"), not its index. Uses ``_fitz_render`` + directly (deterministic scale) — NOT ``render_page_to_image`` (which may + return a native-res embedded image). + """ + doc = fitz.open(pdf_path) + try: + if page_index < 0 or page_index >= doc.page_count: + raise IndexError(f"page_index {page_index} out of range") + img = _fitz_render(doc[page_index], dpi).convert("RGB") + finally: + doc.close() + + scale = dpi / 72.0 + draw = ImageDraw.Draw(img) + boxes: list[dict] = [] + for elem_idx, elem in enumerate(elements): + category = elem.get("category", "") + cells = elem.get("cells", []) + label = elem.get("label", "") + if category == "TABLE" and cells: + outer = elem.get("bbox_pdf") + if outer and len(outer) == 4: + ox0, oy0, ox1, oy1 = (v * scale for v in outer) + outer_color = ( + _COLOR_HIGHLIGHT + if elem_idx == highlight_idx + else _COLOR_TRANSLATABLE + ) + draw.rectangle([ox0, oy0, ox1, oy1], outline=outer_color, width=1) + draw.text((ox0 + 2, max(0, oy0 - 12)), label, fill=outer_color) + for cell_idx, cell in enumerate(cells): + bbox_pdf = cell.get("bbox_pdf") + if not bbox_pdf or len(bbox_pdf) != 4: + continue + x0, y0, x1, y1 = (v * scale for v in bbox_pdf) + boxes.append( + { + "elem_idx": elem_idx, + "cell_idx": cell_idx, + "bbox_img": [x0, y0, x1, y1], + "category": category, + } + ) + if highlight_cell == (elem_idx, cell_idx): + color, width = _COLOR_HIGHLIGHT, 3 + else: + color, width = _COLOR_TRANSLATABLE, 1 + draw.rectangle([x0, y0, x1, y1], outline=color, width=width) + continue + + bbox_pdf = elem.get("bbox_pdf") + if not bbox_pdf or len(bbox_pdf) != 4: + continue + x0, y0, x1, y1 = (v * scale for v in bbox_pdf) + boxes.append( + { + "elem_idx": elem_idx, + "cell_idx": None, + "bbox_img": [x0, y0, x1, y1], + "category": category, + } + ) + + if elem_idx == highlight_idx: + color, width = _COLOR_HIGHLIGHT, 3 + elif category == "BYPASS": + color, width = _COLOR_BYPASS, 1 + else: + color, width = _COLOR_TRANSLATABLE, 2 + draw.rectangle([x0, y0, x1, y1], outline=color, width=width) + # Label tag at the top-left corner. + draw.text((x0 + 2, max(0, y0 - 12)), label, fill=color) + + return img, boxes, scale + + +def render_page_plain( + pdf_path: str, + page_index: int, + dpi: int = 150, +) -> tuple[Image.Image, tuple[int, int]]: + """Rasterize page ``page_index`` with no boxes drawn. + + Returns ``(image, (width, height))`` in image pixels. Uses ``_fitz_render`` + directly (deterministic ``dpi/72`` scale), matching ``render_page_with_boxes``. + """ + doc = fitz.open(pdf_path) + try: + if page_index < 0 or page_index >= doc.page_count: + raise IndexError(f"page_index {page_index} out of range") + img = _fitz_render(doc[page_index], dpi).convert("RGB") + finally: + doc.close() + return img, (img.width, img.height) + + +def render_all_pages(pdf_path: str, dpi: int = 150) -> list[Image.Image]: + """Rasterize every page of ``pdf_path`` (opens the doc once). + + Server-side render for previews that must not depend on a browser PDF + viewer / external CDN. Returns one RGB image per page, in order. + """ + doc = fitz.open(pdf_path) + try: + return [_fitz_render(page, dpi).convert("RGB") for page in doc] + finally: + doc.close() + + +def overlay_svg( + elements: list[dict], + scale: float, + width: int, + height: int, + highlight_idx: int | None = None, + highlight_cell: tuple[int, int] | None = None, +) -> tuple[str, list[dict]]: + """Build an SVG overlay drawing each element's bbox + label, and the boxes list. + + Mirrors the drawing loop of ``render_page_with_boxes`` so ``boxes`` is + identical (same ``bbox_img``/``elem_idx``/``cell_idx``/``category``) — + ``hit_test`` is unchanged. A TABLE element with cells is drawn cell-by-cell + inside a thin dashed outer border (translation happens per cell, not per + whole table); every other element (and a TABLE with no cells) draws a + single box. ``highlight_idx`` marks a selected element (the whole table, + for label/bypass editing); ``highlight_cell`` marks a single selected cell + as ``(elem_idx, cell_idx)``. Each box's tag shows its ``label`` (e.g. + "Table", "Caption"), not its index — escaped since, unlike the fixed + colors/coordinates, it's arbitrary text. The svg box equals the ``<img>`` + box exactly (viewBox aspect == natural aspect), so rects align + pixel-for-pixel with the base raster. + """ + boxes: list[dict] = [] + parts = [ + f'<svg viewBox="0 0 {width} {height}" width="100%" ' + f'preserveAspectRatio="none" ' + f'style="display:block;width:100%;height:auto">' + ] + for elem_idx, elem in enumerate(elements): + category = elem.get("category", "") + cells = elem.get("cells", []) + label = html.escape(elem.get("label", "")) + if category == "TABLE" and cells: + outer = elem.get("bbox_pdf") + if outer and len(outer) == 4: + ox0, oy0, ox1, oy1 = (v * scale for v in outer) + outer_color = ( + _COLOR_HIGHLIGHT + if elem_idx == highlight_idx + else _COLOR_TRANSLATABLE + ) + outer_rgb = f"rgb({outer_color[0]},{outer_color[1]},{outer_color[2]})" + parts.append( + f'<rect x="{ox0}" y="{oy0}" width="{ox1 - ox0}" ' + f'height="{oy1 - oy0}" fill="none" stroke="{outer_rgb}" ' + f'stroke-width="1" stroke-dasharray="4 3"/>' + ) + parts.append( + f'<text x="{ox0 + 2}" y="{max(0, oy0 - 2)}" font-size="12" ' + f'fill="{outer_rgb}">{label}</text>' + ) + for cell_idx, cell in enumerate(cells): + bbox_pdf = cell.get("bbox_pdf") + if not bbox_pdf or len(bbox_pdf) != 4: + continue + x0, y0, x1, y1 = (v * scale for v in bbox_pdf) + boxes.append( + { + "elem_idx": elem_idx, + "cell_idx": cell_idx, + "bbox_img": [x0, y0, x1, y1], + "category": category, + } + ) + if highlight_cell == (elem_idx, cell_idx): + color, stroke = _COLOR_HIGHLIGHT, 3 + else: + color, stroke = _COLOR_TRANSLATABLE, 1 + rgb = f"rgb({color[0]},{color[1]},{color[2]})" + parts.append( + f'<rect x="{x0}" y="{y0}" width="{x1 - x0}" height="{y1 - y0}" ' + f'fill="none" stroke="{rgb}" stroke-width="{stroke}"/>' + ) + continue + + bbox_pdf = elem.get("bbox_pdf") + if not bbox_pdf or len(bbox_pdf) != 4: + continue + x0, y0, x1, y1 = (v * scale for v in bbox_pdf) + boxes.append( + { + "elem_idx": elem_idx, + "cell_idx": None, + "bbox_img": [x0, y0, x1, y1], + "category": category, + } + ) + + if elem_idx == highlight_idx: + color, stroke = _COLOR_HIGHLIGHT, 3 + elif category == "BYPASS": + color, stroke = _COLOR_BYPASS, 1 + else: + color, stroke = _COLOR_TRANSLATABLE, 2 + rgb = f"rgb({color[0]},{color[1]},{color[2]})" + parts.append( + f'<rect x="{x0}" y="{y0}" width="{x1 - x0}" height="{y1 - y0}" ' + f'fill="none" stroke="{rgb}" stroke-width="{stroke}"/>' + ) + parts.append( + f'<text x="{x0 + 2}" y="{max(0, y0 - 2)}" font-size="12" ' + f'fill="{rgb}">{label}</text>' + ) + parts.append("</svg>") + return "".join(parts), boxes + + +def hit_test( + boxes: list[dict], x_img: float, y_img: float +) -> tuple[int, int | None] | None: + """Return ``(elem_idx, cell_idx)`` of the smallest box containing the click. + + ``cell_idx`` is None when the hit box is a whole element (not a table + cell). ``x_img, y_img`` are in the same image-pixel space as ``bbox_img``. + Smallest-area-wins resolves overlapping boxes (e.g. caption inside figure, + or a cell within its table's outer bounds). + """ + best: tuple[int, int | None] | None = None + best_area = float("inf") + for box in boxes: + x0, y0, x1, y1 = box["bbox_img"] + if x0 <= x_img <= x1 and y0 <= y_img <= y1: + area = (x1 - x0) * (y1 - y0) + if area < best_area: + best_area = area + best = (box["elem_idx"], box.get("cell_idx")) + return best + + +def _get_element(doc: dict, page_i: int, elem_i: int) -> dict | None: + try: + return doc["pages"][page_i]["elements"][elem_i] + except (KeyError, IndexError, TypeError): + return None + + +def _get_cell(doc: dict, page_i: int, elem_i: int, cell_i: int) -> dict | None: + elem = _get_element(doc, page_i, elem_i) + if elem is None: + return None + try: + return elem["cells"][cell_i] + except (KeyError, IndexError, TypeError): + return None + + +def apply_phase1_edit( + parsed: dict, + page_i: int, + elem_i: int, + label: str, + source_text: str, + bypass: bool, +) -> str | None: + """Apply a Phase-1 edit in place. Returns a warning message, or None on success. + + - ``bypass=True`` sets category=BYPASS (excluded from translation), keeping + the label so unchecking restores the derived category. + - Otherwise sets label + derived category + source_text. + - Changing to/from ``Table`` is blocked (cell structure can't be rebuilt); + the source_text edit still applies. + """ + elem = _get_element(parsed, page_i, elem_i) + if elem is None: + return "Không tìm thấy element." + + if bypass: + elem["category"] = "BYPASS" + elem["source_text"] = source_text + return None + + old_cat = elem.get("category") + if label == SuryaLabel.TABLE or old_cat == "TABLE": + elem["source_text"] = source_text + if label != elem.get("label"): + return "Giữ nguyên nhãn Table — không dựng lại cấu trúc ô." + # A table un-bypassed: restore its TABLE category. + elem["category"] = "TABLE" + return None + + elem["label"] = label + elem["category"] = _category_for_label(label) + elem["source_text"] = source_text + return None + + +def apply_phase2_edit( + translated: dict, + page_i: int, + elem_i: int, + translated_text: str, +) -> str | None: + """Set an element's translated_text in place (used at Phase 3).""" + elem = _get_element(translated, page_i, elem_i) + if elem is None: + return "Không tìm thấy element." + elem["translated_text"] = translated_text + return None + + +def apply_phase1_cell_edit( + parsed: dict, + page_i: int, + elem_i: int, + cell_i: int, + source_text: str, +) -> str | None: + """Set a TABLE cell's source_text in place (used at Phase 1). + + Cells have no label/category of their own — translation runs per cell, so + only the OCR text is editable here. + """ + cell = _get_cell(parsed, page_i, elem_i, cell_i) + if cell is None: + return "Không tìm thấy cell." + cell["source_text"] = source_text + return None + + +def apply_phase2_cell_edit( + translated: dict, + page_i: int, + elem_i: int, + cell_i: int, + translated_text: str, +) -> str | None: + """Set a TABLE cell's translated_text in place (used at Phase 3).""" + cell = _get_cell(translated, page_i, elem_i, cell_i) + if cell is None: + return "Không tìm thấy cell." + cell["translated_text"] = translated_text + return None + + +def add_element( + parsed: dict, + page_i: int, + bbox_pdf: list[float], + label: str, + source_text: str, + bg_color: list[int] | None = None, + text_color: list[int] | None = None, +) -> int: + """Append a new element to a page (for a missed region). Returns its elem_idx. + + Appending keeps existing element indices — and therefore color uids — stable. + ``bg_color`` / ``text_color`` (RGB ints), when given, override the render's + per-element color sampling (see ``renderer._sample_colors``). + """ + elements = parsed["pages"][page_i]["elements"] + elem = { + "label": label, + "category": _category_for_label(label), + "bbox_pdf": [float(v) for v in bbox_pdf], + "source_text": source_text, + "translated_text": "", + "cells": [], + } + if bg_color is not None: + elem["bg_color"] = [int(c) for c in bg_color] + if text_color is not None: + elem["text_color"] = [int(c) for c in text_color] + elements.append(elem) + return len(elements) - 1 + + +def output_page_position(pages: list[int] | None, page_index: int) -> int | None: + """Position (0-based) of a source page within the translated output. + + The output PDF contains only the translated pages in ascending order, so a + source ``page_index`` maps to its rank in ``sorted(pages)``. When ``pages`` + is None the whole document is rendered, so the position equals page_index. + """ + if pages is None: + return page_index + sel = sorted(pages) + return sel.index(page_index) if page_index in sel else None + + +def page_index_of(parsed: dict, page_i: int) -> int: + """True (0-based) document page number of the ``page_i``-th parsed page.""" + page = parsed["pages"][page_i] + return int(page.get("page_index", page_i)) + + +def normalize_click(index: Any) -> tuple[float, float] | None: + """Coerce a Gradio Image ``.select`` index into (x, y) image pixels. + + Accepts (x, y) tuples/lists; returns None for anything unexpected so the + caller can ignore a stray event instead of crashing. + """ + if isinstance(index, (list, tuple)) and len(index) >= 2: + try: + return float(index[0]), float(index[1]) + except (TypeError, ValueError): + return None + return None diff --git a/pdf2zh/webapp/runner.py b/pdf2zh/webapp/runner.py new file mode 100644 index 0000000000000000000000000000000000000000..3a15683884be6e42810432c0098050c8dffbf49a --- /dev/null +++ b/pdf2zh/webapp/runner.py @@ -0,0 +1,234 @@ +"""Framework-agnostic translation runner. + +Runs the OCR -> translate -> render pipeline in a worker thread (so its internal +``asyncio.run`` works) and streams per-phase progress through a queue. Knows +nothing about Gradio, so it can be unit-tested in isolation. +""" + +from __future__ import annotations + +import logging +import queue +import tempfile +import threading +import traceback +import uuid +from collections.abc import Callable, Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +@dataclass +class TranslationRequest: + """One translation job, with the provider already resolved to its key.""" + + pdf_path: str | None + provider: str # Phase-2 provider key (e.g. "openrouter") + api_key: str + model: str | None + src_lang: str + tgt_lang: str + font: str + pages: list[int] | None + + +@dataclass(frozen=True) +class Progress: + """A per-phase progress update streamed while the pipeline runs.""" + + frac: float + msg: str + + +@dataclass(frozen=True) +class Result: + """The terminal outcome of a run. + + ``data`` carries the step's payload for the stepped flow (e.g. the parsed + dict, or ``{"translated": ..., "out_path": ...}``). ``out_path`` is kept for + the legacy one-shot ``stream_translation`` path. + """ + + status: str # "ok" | "invalid" | "error" + out_path: str | None = None + detail: str = "" + data: Any = None + + +def validate(req: TranslationRequest) -> str | None: + """Return a user-facing error message if the request can't run, else None.""" + if not req.pdf_path: + return "Vui lòng tải lên một file PDF." + if not req.api_key or not req.api_key.strip(): + return "Thiếu API key — nhập API key của provider ở thanh bên." + if not req.src_lang or not req.tgt_lang: + return "Chọn ngôn ngữ nguồn và ngôn ngữ đích." + return None + + +def list_models(provider: str, api_key: str) -> list[str]: + """Fetch model ids from a provider's OpenAI-compatible ``GET /models`` endpoint. + + ``provider`` is the resolved key (e.g. "deepseek"). Returns a sorted list of + model ids, or [] on any failure (bad key, no endpoint, non-OpenAI response) — + the UI then falls back to free-text entry. + """ + import httpx + + from pdf2zh.translation.config import provider_base_url + + if not api_key or not api_key.strip(): + return [] + try: + base = provider_base_url(provider).rstrip("/") + resp = httpx.get( + f"{base}/models", + headers={"Authorization": f"Bearer {api_key.strip()}"}, + timeout=15, + verify=False, + ) + resp.raise_for_status() + data = resp.json().get("data", []) + return sorted({m["id"] for m in data if isinstance(m, dict) and m.get("id")}) + except Exception: # noqa: BLE001 — listing is best-effort; fall back to manual + logger.warning("list_models failed for provider %s", provider, exc_info=True) + return [] + + +def stream_translation(req: TranslationRequest) -> Iterator[Progress | Result]: + """Yield ``Progress`` updates while translating, then one terminal ``Result``.""" + # Imported lazily so the lightweight bits above (dataclasses, validate) stay + # importable without the heavy ML stack (torch, surya, ...) that e2e pulls in. + from pdf2zh.e2e import run_pipeline + + q: queue.Queue = queue.Queue() + work_dir = Path(tempfile.gettempdir()) / f"pdf2zh_{uuid.uuid4().hex}" + + def on_progress(frac: float, msg: str) -> None: + q.put(Progress(frac, msg)) + + def worker() -> None: + try: + out = run_pipeline( + pdf_path=req.pdf_path, + src_lang=req.src_lang, + tgt_lang=req.tgt_lang, + provider=req.provider, + api_key=req.api_key, + model=req.model, + pages=req.pages, + font=req.font, + work_dir=work_dir, + progress=on_progress, + ) + q.put(Result("ok", out_path=out)) + except ValueError as exc: # user-facing input error + q.put(Result("invalid", detail=str(exc))) + except Exception as exc: # noqa: BLE001 — surface anything else to the UI + logger.exception("pipeline failed") + tail = "".join(traceback.format_exc().splitlines(keepends=True)[-6:]) + q.put( + Result("error", detail=f"{type(exc).__name__}: {exc}\n```\n{tail}\n```") + ) + + threading.Thread(target=worker, daemon=True).start() + while True: + item = q.get() + yield item + if isinstance(item, Result): + return + + +# --------------------------------------------------------------------------- # +# Stepped flow — run one phase in a worker thread and stream its progress. +# --------------------------------------------------------------------------- # +def _stream( + fn: Callable[[Callable[[float, str], None]], Any], +) -> Iterator[Progress | Result]: + """Run ``fn(progress_cb)`` in a worker thread; stream Progress then a Result. + + ``fn`` receives a ``progress(frac, msg)`` callback and returns the payload + placed on ``Result.data``. A ValueError becomes an ``invalid`` result + (user-facing input error); anything else becomes an ``error`` result. + """ + q: queue.Queue = queue.Queue() + + def on_progress(frac: float, msg: str) -> None: + q.put(Progress(frac, msg)) + + def worker() -> None: + try: + data = fn(on_progress) + q.put(Result("ok", data=data)) + except ValueError as exc: + q.put(Result("invalid", detail=str(exc))) + except Exception as exc: # noqa: BLE001 — surface anything else to the UI + logger.exception("step failed") + tail = "".join(traceback.format_exc().splitlines(keepends=True)[-6:]) + q.put( + Result("error", detail=f"{type(exc).__name__}: {exc}\n```\n{tail}\n```") + ) + + threading.Thread(target=worker, daemon=True).start() + while True: + item = q.get() + yield item + if isinstance(item, Result): + return + + +def stream_parse( + pdf_path: str, pages: list[int] | None, work_dir: str | Path +) -> Iterator[Progress | Result]: + """Phase 1 — parse. ``Result.data`` is the parsed doc dict.""" + from pdf2zh.e2e import run_parse + + return _stream(lambda p: run_parse(pdf_path, pages, work_dir, p)) + + +def stream_translate_render( + pdf_path: str, + parsed: dict, + src_lang: str, + tgt_lang: str, + provider: str, + api_key: str, + model: str | None, + pages: list[int] | None, + font: str, + work_dir: str | Path, +) -> Iterator[Progress | Result]: + """Phase 2 + 3 — translate the (edited) parsed doc, then render. + + ``Result.data`` is ``{"translated": dict, "out_path": str}``. + """ + from pdf2zh.e2e import run_render, run_translate + + def fn(p: Callable[[float, str], None]) -> dict: + translated = run_translate( + parsed, src_lang, tgt_lang, provider, api_key, model, work_dir, p + ) + out_path = run_render(pdf_path, translated, pages, font, work_dir, p) + return {"translated": translated, "out_path": out_path} + + return _stream(fn) + + +def stream_render( + pdf_path: str, + translated: dict, + pages: list[int] | None, + font: str, + work_dir: str | Path, +) -> Iterator[Progress | Result]: + """Phase 3 only — re-render the (edited) translated doc. ``Result.data`` is + ``{"out_path": str}``.""" + from pdf2zh.e2e import run_render + + def fn(p: Callable[[float, str], None]) -> dict: + return {"out_path": run_render(pdf_path, translated, pages, font, work_dir, p)} + + return _stream(fn) diff --git a/pdf2zh/webapp/ui.py b/pdf2zh/webapp/ui.py new file mode 100644 index 0000000000000000000000000000000000000000..8302436b9291a9828dc2c28c43196064cb388b1b --- /dev/null +++ b/pdf2zh/webapp/ui.py @@ -0,0 +1,1143 @@ +"""Gradio UI: stepped, human-in-the-loop PDF translation. + +Flow: Config → Step 1 (Phase 1: review/edit extraction) → [translate + render] +→ Step 2 (Phase 3: review render, edit translations, re-render) → download. + +Both review steps let the user click a page preview to select an element and +edit it (with a #-dropdown as an explicit fallback selector). The pure logic +lives in ``review.py``; heavy phases run in worker threads via ``runner.py``. +""" + +from __future__ import annotations + +import shutil +import tempfile +import time +import uuid +from pathlib import Path + +import gradio as gr + +from pdf2zh.e2e import BUNDLED_FONTS, DEFAULT_FONT, SUPPORTED_LANGUAGES +from pdf2zh.webapp.config import ( + PAGE_MODE_ALL, + PAGE_MODE_RANGE, + PAGE_MODES, + PROVIDER_CHOICES, + PROVIDER_KEY, + resolve_pages, +) +from pdf2zh.webapp.review import ( + LABEL_CHOICES, + add_element, + apply_phase1_cell_edit, + apply_phase1_edit, + apply_phase2_cell_edit, + apply_phase2_edit, + hex_to_rgb, + hit_test, + normalize_click, + output_page_position, + overlay_svg, + render_all_pages, + render_page_plain, +) +from pdf2zh.webapp.runner import ( + Progress, + TranslationRequest, + list_models, + stream_parse, + stream_render, + stream_translate_render, + stream_translation, + validate, +) + +REVIEW_DPI = 150 +_SCALE = REVIEW_DPI / 72.0 +_P1_SOURCE_LABEL = "Văn bản gốc (source_text)" + + +def _paged_gallery(pdf_path: str, dpi: int = REVIEW_DPI): + """Render every page and tag each with a "Trang i/N" caption for the gallery.""" + imgs = render_all_pages(pdf_path, dpi) + n = len(imgs) + return [(img, f"Trang {i}/{n}") for i, img in enumerate(imgs, 1)] + + +# Static base image + live SVG overlay: the base <img> reloads only on page change +# / re-render, the overlay (a pure innerHTML swap) updates on every edit action, so +# selecting/saving/adding no longer flickers the preview. +REVIEW_CSS = """ +#p1-stage, #p3-stage { position:relative; padding:0 !important; gap:0 !important; } +#p1-stage .image-frame img, #p1-stage .image-container img, +#p3-stage .image-frame img, #p3-stage .image-container img { + width:100% !important; height:auto !important; object-fit:fill !important; + display:block; } +#p1-stage .image-container, #p1-stage .image-frame, +#p3-stage .image-container, #p3-stage .image-frame { height:auto !important; } +#p1-stage .image-container, #p1-stage .image-frame, #p1-stage button, +#p3-stage .image-container, #p3-stage .image-frame, #p3-stage button { + padding:0 !important; border:0 !important; margin:0 !important; } +#p1-stage .icon-button-wrapper, #p3-stage .icon-button-wrapper, +#p1-stage button[aria-label*="ullscreen"], +#p3-stage button[aria-label*="ullscreen"] { display:none !important; } +#p1-overlay, #p3-overlay { position:absolute; top:0; left:0; width:100%; + padding:0 !important; margin:0 !important; pointer-events:none; } +#p1-overlay svg, #p3-overlay svg { display:block; width:100%; height:auto; } +svg.draw-rubber { position:absolute; pointer-events:none; z-index:6; } +""" + +# Client-side box drawing: in draw mode the user clicks two corners. Click 1 drops +# a marker; moving the mouse shows a live dashed preview rectangle to the cursor; +# click 2 locks it and writes the PDF-point coords into the hidden x0/y0/x1/y1 +# inputs. No server round-trip happens until "Thêm box", so drawing never flickers. +# Coords go display-px → natural-px (naturalWidth/rect) → PDF points (÷ _SCALE), +# matching ``on_p1_img_click``'s old math. __SCALE__ is filled from REVIEW_DPI below. +_DRAW_JS_TMPL = """ +() => { + const SCALE = __SCALE__; + const CFG = { stage:'p1-stage', chk:'p1-draw-mode', + x0:'p1-x0', y0:'p1-y0', x1:'p1-x1', y1:'p1-y1' }; + const q = (s) => document.querySelector(s); + const drawOn = () => { const c = q('#'+CFG.chk+' input[type=checkbox]'); + return !!(c && c.checked); }; + + function setNum(id, val) { + const inp = q('#'+id+' input'); + if (!inp) return; + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, 'value').set; + setter.call(inp, String(Math.round(val * 100) / 100)); + inp.dispatchEvent(new Event('input', { bubbles: true })); + inp.dispatchEvent(new Event('change', { bubbles: true })); + } + + function layer(stageEl, r) { + const sr = stageEl.getBoundingClientRect(); + let svg = stageEl.querySelector('svg.draw-rubber'); + if (!svg) { + svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', 'draw-rubber'); + stageEl.appendChild(svg); + } + svg.style.left = (r.left - sr.left) + 'px'; + svg.style.top = (r.top - sr.top) + 'px'; + svg.style.width = r.width + 'px'; + svg.style.height = r.height + 'px'; + return svg; + } + + function paint(stageEl, r, ax, ay, bx, by, dashed) { + const rx = Math.min(ax, bx), ry = Math.min(ay, by); + layer(stageEl, r).innerHTML = + '<rect x="'+rx+'" y="'+ry+'" width="'+Math.abs(bx-ax)+'" height="' + +Math.abs(by-ay)+'" fill="red" fill-opacity="0.12" stroke="red" ' + +'stroke-width="2" '+(dashed ? 'stroke-dasharray="6 4" ' : '')+'/>' + +'<circle cx="'+ax+'" cy="'+ay+'" r="4" fill="red"/>'; + } + + const at = (r, e) => ({ + x: Math.max(0, Math.min(r.width, e.clientX - r.left)), + y: Math.max(0, Math.min(r.height, e.clientY - r.top)), + }); + + let P = null; // first corner: {stageEl, img, r, x, y} once click 1 lands + const reset = () => { P = null; }; + + document.addEventListener('click', (e) => { + if (e.target.closest && e.target.closest('#p1-add-btn')) { clearAll(); return; } + if (!drawOn()) return; + const stageEl = e.target.closest && e.target.closest('#'+CFG.stage); + if (!stageEl) return; + const img = stageEl.querySelector('img'); + if (!img) return; + const r = img.getBoundingClientRect(); + const p = at(r, e); + if (!P) { + // Click 1: mark the first corner. + P = { stageEl, img, r, x: p.x, y: p.y }; + layer(stageEl, r).innerHTML = + '<circle cx="'+p.x+'" cy="'+p.y+'" r="5" fill="red" fill-opacity="0.9"/>'; + return; + } + // Click 2: finalize. + const x0 = Math.min(P.x, p.x), y0 = Math.min(P.y, p.y); + const x1 = Math.max(P.x, p.x), y1 = Math.max(P.y, p.y); + const sx = (img.naturalWidth || r.width) / r.width; + const sy = (img.naturalHeight || r.height) / r.height; + if (x1 - x0 >= 3 && y1 - y0 >= 3) { + setNum(CFG.x0, x0 * sx / SCALE); + setNum(CFG.y0, y0 * sy / SCALE); + setNum(CFG.x1, x1 * sx / SCALE); + setNum(CFG.y1, y1 * sy / SCALE); + paint(P.stageEl, r, P.x, P.y, p.x, p.y, false); + } + reset(); + }, true); + + document.addEventListener('mousemove', (e) => { + if (!P || !drawOn()) return; + const p = at(P.r, e); + paint(P.stageEl, P.r, P.x, P.y, p.x, p.y, true); + }, true); + + const clearAll = () => { + const svg = q('#'+CFG.stage+' svg.draw-rubber'); + if (svg) svg.innerHTML = ''; + reset(); + }; + // Clear the preview when leaving draw mode and whenever the base image + // (re)loads — page change, new file, re-render — so no stale rectangle lingers. + document.addEventListener('change', (e) => { + if (e.target.closest && e.target.closest('#'+CFG.chk) && !drawOn()) clearAll(); + }, true); + document.addEventListener('load', (e) => { + if (e.target.tagName === 'IMG' && e.target.closest + && e.target.closest('#'+CFG.stage)) clearAll(); + }, true); +} +""" +DRAW_JS = _DRAW_JS_TMPL.replace("__SCALE__", repr(_SCALE)) + + +# --------------------------------------------------------------------------- # +# Small helpers (pure-ish, operate on the session dict) +# --------------------------------------------------------------------------- # +def _page_choices(doc: dict) -> list[tuple[str, int]]: + """Dropdown choices for pages: label = 1-based document page number.""" + return [ + (f"Trang {p.get('page_index', i) + 1}", i) + for i, p in enumerate(doc.get("pages", [])) + ] + + +def _elem_choices(page: dict) -> list[tuple[str, int]]: + out = [] + for i, e in enumerate(page.get("elements", [])): + cat = e.get("category", "") + label = e.get("label", "") + out.append((f"#{i} · {label} · {cat}", i)) + return out + + +def _base_p1(session: dict): + """Rasterize the current Phase-1 page (no boxes). Reloads the base <img>.""" + page_i = session["p1_page"] + page = session["parsed"]["pages"][page_i] + img, size = render_page_plain( + session["pdf_path"], page.get("page_index", page_i), REVIEW_DPI + ) + session["p1_size"] = size + return img + + +def _overlay_p1(session: dict) -> str: + """Build the Phase-1 SVG overlay + refresh session box list for hit_test. + + Reads the current selection straight from ``session["p1_sel"]`` — a + ``(elem_i, cell_i)`` tuple (``cell_i`` None for a whole-element selection) + or None — so callers just set that key and never pass highlight state + separately (one less thing to keep in sync). + """ + page = session["parsed"]["pages"][session["p1_page"]] + w, h = session["p1_size"] + sel = session.get("p1_sel") + highlight_idx = sel[0] if sel and sel[1] is None else None + highlight_cell = sel if sel and sel[1] is not None else None + svg, boxes = overlay_svg( + page.get("elements", []), _SCALE, w, h, highlight_idx, highlight_cell + ) + session["p1_boxes"] = boxes + return svg + + +def _base_p3(session: dict): + """Rasterize the current Phase-3 page (no boxes), or None if not rendered.""" + page_i = session["p3_page"] + page = session["translated"]["pages"][page_i] + out_pos = output_page_position(session["pages"], page.get("page_index", page_i)) + if out_pos is None: + return None + img, size = render_page_plain(session["out_path"], out_pos, REVIEW_DPI) + session["p3_size"] = size + return img + + +def _overlay_p3(session: dict) -> str: + """Build the Phase-3 SVG overlay + refresh session box list for hit_test. + + Reads the current selection straight from ``session["p3_sel"]``, same + convention as ``_overlay_p1``. + """ + size = session.get("p3_size") + if size is None: + return "" + page = session["translated"]["pages"][session["p3_page"]] + sel = session.get("p3_sel") + highlight_idx = sel[0] if sel and sel[1] is None else None + highlight_cell = sel if sel and sel[1] is not None else None + svg, boxes = overlay_svg( + page.get("elements", []), + _SCALE, + size[0], + size[1], + highlight_idx, + highlight_cell, + ) + session["p3_boxes"] = boxes + return svg + + +# --------------------------------------------------------------------------- # +# UI +# --------------------------------------------------------------------------- # +def build_ui() -> gr.Blocks: + with gr.Blocks( + title="PDF Translator", theme=gr.themes.Default(), css=REVIEW_CSS + ) as demo: + sess_state = gr.State({}) + gr.Markdown( + "# PDF Translator\n" + "Dịch PDF end-to-end với 2 bước kiểm tra: **Phase 1** (sửa trích xuất) " + "→ dịch & dựng → **Phase 3** (soát bản dịch trên trang đã render)." + ) + + # ---- Config --------------------------------------------------------- + with gr.Row(): + with gr.Column(scale=1): + provider = gr.Dropdown( + PROVIDER_CHOICES, value="OpenRouter", label="Provider" + ) + api_key = gr.Textbox(label="API Key", type="password", value="") + model = gr.Dropdown( + choices=[], + value=None, + label="Model (trống = mặc định)", + allow_custom_value=True, + info="Nhập API key để tự tải danh sách, hoặc gõ tên model.", + ) + load_models_btn = gr.Button("🔄 Tải danh sách model", size="sm") + lang_from = gr.Dropdown( + SUPPORTED_LANGUAGES, value="English", label="Dịch từ" + ) + lang_to = gr.Dropdown( + SUPPORTED_LANGUAGES, value="Vietnamese", label="Dịch sang" + ) + font = gr.Dropdown( + BUNDLED_FONTS, value=DEFAULT_FONT, label="Font đầu ra" + ) + page_mode = gr.Radio( + PAGE_MODES, value=PAGE_MODE_ALL, label="Trang dịch" + ) + with gr.Row(): + page_from = gr.Number( + value=1, precision=0, label="Từ trang", visible=False, minimum=1 + ) + page_to = gr.Number( + value=1, + precision=0, + label="Đến trang", + visible=False, + minimum=1, + ) + parse_btn = gr.Button("① Trích xuất (Phase 1)", variant="primary") + e2e_btn = gr.Button( + "⚡ Chạy end-to-end (bỏ qua sửa)", variant="secondary" + ) + with gr.Column(scale=2): + pdf_in = gr.File( + label="Tải lên PDF", file_types=[".pdf"], type="filepath" + ) + pdf_preview = gr.Gallery( + label="Xem trước (mọi trang)", + height=520, + columns=1, + show_label=True, + preview=True, + visible=False, + ) + status = gr.Markdown("") + e2e_pdf = gr.Gallery( + label="Bản dịch (end-to-end)", + height=520, + columns=1, + preview=True, + visible=False, + ) + e2e_download = gr.File(label="Tải PDF (end-to-end)", visible=False) + + # ---- Step 1: Phase-1 review ---------------------------------------- + with gr.Column(visible=False) as p1_group: + gr.Markdown( + "## ① Kiểm tra trích xuất\n" + "Click vào ô trên trang để chọn element (hoặc chọn theo số). " + "Sửa nhãn/nội dung, bỏ qua element thừa, hoặc thêm box cho vùng bị sót." + ) + with gr.Row(): + with gr.Column(scale=2): + p1_page_dd = gr.Dropdown(label="Trang", choices=[], value=None) + with gr.Column(elem_id="p1-stage"): + p1_img = gr.Image( + interactive=False, + show_label=False, + container=False, + elem_id="p1-img", + ) + p1_overlay = gr.HTML( + elem_id="p1-overlay", container=False, padding=False + ) + with gr.Column(scale=1): + p1_elem_dd = gr.Dropdown( + label="Element đang chọn (khớp theo nhãn hiển thị trên ô)", + choices=[], + value=None, + ) + p1_label = gr.Dropdown( + LABEL_CHOICES, label="Nhãn (label)", value=None + ) + p1_source = gr.Textbox(label=_P1_SOURCE_LABEL, lines=4) + p1_bypass = gr.Checkbox(label="Bỏ qua element này (không dịch)") + p1_save_btn = gr.Button("💾 Lưu element", variant="secondary") + + gr.Markdown("**Thêm box cho vùng bị sót**") + p1_draw_mode = gr.Checkbox( + label="Chế độ vẽ box (nhấn 2 điểm trên ảnh)", + elem_id="p1-draw-mode", + ) + with gr.Row(): + p1_x0 = gr.Number(label="x0", value=0, elem_id="p1-x0") + p1_y0 = gr.Number(label="y0", value=0, elem_id="p1-y0") + p1_x1 = gr.Number(label="x1", value=0, elem_id="p1-x1") + p1_y1 = gr.Number(label="y1", value=0, elem_id="p1-y1") + p1_new_label = gr.Dropdown( + LABEL_CHOICES, label="Nhãn box mới", value="Text" + ) + p1_new_source = gr.Textbox(label="Văn bản box mới", lines=3) + p1_new_auto_color = gr.Checkbox( + label="Tự động lấy màu nền/chữ từ trang", value=True + ) + with gr.Row(): + p1_new_bg = gr.ColorPicker(label="Màu nền", value="#ffffff") + p1_new_text = gr.ColorPicker(label="Màu chữ", value="#000000") + p1_add_btn = gr.Button("➕ Thêm box", elem_id="p1-add-btn") + confirm_btn = gr.Button("② Xác nhận → Dịch & Render", variant="primary") + + # ---- Step 2: Phase-3 review ---------------------------------------- + with gr.Column(visible=False) as p3_group: + gr.Markdown( + "## ② Soát bản dịch (trên trang đã render)\n" + "Click vào element để sửa bản dịch, rồi **Render lại**. " + "Màu nền/chữ, font, cỡ chữ được giữ nguyên." + ) + with gr.Row(): + with gr.Column(scale=1): + p3_pdf = gr.Gallery( + label="Bản dịch (xem trước)", + height=560, + columns=1, + preview=True, + ) + download = gr.File(label="Tải PDF bản dịch") + with gr.Column(scale=1): + p3_page_dd = gr.Dropdown(label="Trang", choices=[], value=None) + with gr.Column(elem_id="p3-stage"): + p3_img = gr.Image( + interactive=False, + show_label=False, + container=False, + elem_id="p3-img", + ) + p3_overlay = gr.HTML( + elem_id="p3-overlay", container=False, padding=False + ) + p3_elem_dd = gr.Dropdown( + label="Element đang chọn", choices=[], value=None + ) + p3_source = gr.Textbox( + label="Văn bản gốc", lines=3, interactive=False + ) + p3_translated = gr.Textbox(label="Bản dịch (sửa được)", lines=5) + p3_save_btn = gr.Button("💾 Lưu bản dịch", variant="secondary") + rerender_btn = gr.Button("🔁 Render lại", variant="primary") + + # ================================================================== # + # Handlers + # ================================================================== # + def on_provider_change(_provider): + return (gr.update(choices=[], value=None), gr.update(value="")) + + def on_load_models(prov, key): + models = list_models(PROVIDER_KEY[prov], key) + if not models: + gr.Warning("Không tải được model — kiểm tra key/provider, hoặc gõ tay.") + return gr.update() + gr.Info(f"Đã tải {len(models)} model.") + return gr.update(choices=models) + + def on_page_mode(mode): + vis = mode == PAGE_MODE_RANGE + return gr.update(visible=vis), gr.update(visible=vis) + + def on_new_file(session, pdf): + """Reset all derived state when the uploaded PDF changes/clears. + + Drops the previous file's work dir + session keys and hides every + review panel / download so a new file never shows the old one's + outputs. Also rasterizes page 1 server-side (PyMuPDF) for the preview + so it never depends on a browser-side PDF viewer / external CDN. + """ + old = session.get("work_dir") + if old: + shutil.rmtree(old, ignore_errors=True) + for k in ( + "pdf_path", + "work_dir", + "pages", + "parsed", + "p1_page", + "p1_sel", + "p1_size", + "p1_boxes", + "translated", + "out_path", + "p3_page", + "p3_sel", + "p3_size", + "p3_boxes", + ): + session.pop(k, None) + preview = None + if pdf: + try: + preview = _paged_gallery(pdf) + except Exception: + preview = None + return { + sess_state: session, + status: "", + pdf_preview: gr.update(value=preview, visible=bool(preview)), + p1_group: gr.update(visible=False), + p3_group: gr.update(visible=False), + p3_pdf: gr.update(value=None), + download: gr.update(value=None), + e2e_pdf: gr.update(value=None, visible=False), + e2e_download: gr.update(value=None, visible=False), + } + + # ---- End-to-end (skip the per-phase review) ------------------------ + def do_e2e(pdf, prov, key, mdl, lfrom, lto, fnt, mode, frm, to): + req = TranslationRequest( + pdf_path=pdf, + provider=PROVIDER_KEY[prov], + api_key=key or "", + model=mdl or None, + src_lang=lfrom, + tgt_lang=lto, + font=fnt, + pages=resolve_pages(mode, frm, to), + ) + # Hide any PDF/download left over from a previous successful run so a + # failure never displays a stale result as if it were the new output. + hide = { + e2e_pdf: gr.update(visible=False), + e2e_download: gr.update(visible=False), + } + err = validate(req) + if err: + yield {status: f"⚠️ {err}", **hide} + return + t0 = time.perf_counter() + yield {status: "⏳ Đang chạy end-to-end (Phase 1 → 2 → 3)…", **hide} + result = None + for ev in stream_translation(req): + if isinstance(ev, Progress): + yield {status: f"⏳ {ev.msg}"} + else: + result = ev + if result is None or result.status != "ok": + yield { + status: f"❌ {result.detail if result else 'Không rõ lỗi.'}", + **hide, + } + return + elapsed = time.perf_counter() - t0 + yield { + status: f"✅ Xong end-to-end trong {elapsed:.1f}s (xem log để biết chi tiết từng phase).", + e2e_pdf: gr.update(value=_paged_gallery(result.out_path), visible=True), + e2e_download: gr.update(value=result.out_path, visible=True), + } + + # ---- Step 1: parse ------------------------------------------------- + def do_parse(session, pdf, prov, key, lfrom, lto, mode, frm, to): + if not pdf: + yield {status: "⚠️ Vui lòng tải lên một file PDF."} + return + if not key or not key.strip(): + yield {status: "⚠️ Thiếu API key."} + return + + prev = session.get("work_dir") + if prev: + shutil.rmtree(prev, ignore_errors=True) + work_dir = Path(tempfile.gettempdir()) / f"pdf2zh_{uuid.uuid4().hex}" + work_dir.mkdir(parents=True, exist_ok=True) + # Copy the uploaded PDF so later steps survive Gradio cache cleanup. + local_pdf = work_dir / "source.pdf" + shutil.copyfile(pdf, local_pdf) + pages = resolve_pages(mode, frm, to) + + session["pdf_path"] = str(local_pdf) + session["work_dir"] = str(work_dir) + session["pages"] = pages + session["provider"] = PROVIDER_KEY[prov] + session["src_lang"] = lfrom + session["tgt_lang"] = lto + + yield {status: "⏳ Phase 1 — OCR & phân tích bố cục…"} + result = None + for ev in stream_parse(session["pdf_path"], pages, session["work_dir"]): + if isinstance(ev, Progress): + yield {status: f"⏳ {ev.msg}"} + else: + result = ev + if result is None or result.status != "ok": + yield {status: f"❌ {result.detail if result else 'Không rõ lỗi.'}"} + return + + if not result.data.get("pages"): + yield { + status: "⚠️ Không có trang nào để dịch (khoảng trang nằm ngoài tài liệu?)." + } + return + + session["parsed"] = result.data + session["p1_page"] = 0 + session["p1_sel"] = None + page_choices = _page_choices(session["parsed"]) + img = _base_p1(session) + first_page = session["parsed"]["pages"][0] + yield { + sess_state: session, + status: "✅ Phase 1 xong — kiểm tra & sửa rồi bấm Xác nhận.", + p1_group: gr.update(visible=True), + p1_page_dd: gr.update(choices=page_choices, value=0), + p1_img: img, + p1_overlay: _overlay_p1(session), + p1_elem_dd: gr.update(choices=_elem_choices(first_page), value=None), + # Re-show label/bypass in case a table cell was selected in a + # previous file (which hides them) before this parse ran. + p1_label: gr.update(value=None, visible=True), + p1_source: gr.update(value="", label=_P1_SOURCE_LABEL), + p1_bypass: gr.update(value=False, visible=True), + } + + def on_p1_page(session, page_i): + if page_i is None: + return {sess_state: session} + session["p1_page"] = int(page_i) + session["p1_sel"] = None + page = session["parsed"]["pages"][int(page_i)] + return { + sess_state: session, + p1_img: _base_p1(session), + p1_overlay: _overlay_p1(session), + p1_elem_dd: gr.update(choices=_elem_choices(page), value=None), + p1_label: gr.update(value=None, visible=True), + p1_source: gr.update(value="", label=_P1_SOURCE_LABEL), + p1_bypass: gr.update(value=False, visible=True), + } + + def _p1_select_elem(session, elem_i): + """Populate the edit panel for a whole element + highlight it.""" + session["p1_sel"] = (elem_i, None) + page = session["parsed"]["pages"][session["p1_page"]] + elem = page["elements"][elem_i] + return { + sess_state: session, + p1_overlay: _overlay_p1(session), + p1_elem_dd: gr.update(value=elem_i), + p1_label: gr.update(value=elem.get("label"), visible=True), + p1_source: gr.update( + value=elem.get("source_text", ""), label=_P1_SOURCE_LABEL + ), + p1_bypass: gr.update( + value=elem.get("category") == "BYPASS", visible=True + ), + status: "", + } + + def _p1_select_cell(session, elem_i, cell_i): + """Populate the edit panel for one TABLE cell + highlight it. + + Cells have no label/category of their own (translation runs per + cell), so the label/bypass controls are hidden — only the cell's + OCR text is editable. + """ + session["p1_sel"] = (elem_i, cell_i) + page = session["parsed"]["pages"][session["p1_page"]] + cell = page["elements"][elem_i]["cells"][cell_i] + return { + sess_state: session, + p1_overlay: _overlay_p1(session), + p1_elem_dd: gr.update(value=elem_i), + p1_label: gr.update(visible=False), + p1_source: gr.update( + value=cell.get("source_text", ""), + label=f"Văn bản gốc (Table #{elem_i}, cell #{cell_i})", + ), + p1_bypass: gr.update(visible=False), + status: "", + } + + def on_p1_img_click(session, draw_mode, evt: gr.SelectData): + # In draw mode the box is drawn client-side (see DRAW_JS), so a stray + # click here must not also select an element. + if draw_mode: + return {sess_state: session} + pt = normalize_click(evt.index) + if pt is None: + return {sess_state: session} + hit = hit_test(session.get("p1_boxes", []), pt[0], pt[1]) + if hit is None: + return {sess_state: session} + elem_i, cell_i = hit + if cell_i is None: + return _p1_select_elem(session, elem_i) + return _p1_select_cell(session, elem_i, cell_i) + + def on_p1_pick(session, elem_i): + # Dropdown selects whole elements only — table cells are picked by + # clicking them directly on the image. + if elem_i is None: + return {sess_state: session} + return _p1_select_elem(session, int(elem_i)) + + def do_p1_save(session, label, source, bypass): + sel = session.get("p1_sel") + if sel is None: + gr.Warning("Chưa chọn element nào.") + return {sess_state: session, status: "⚠️ Chưa chọn element nào."} + elem_i, cell_i = sel + if cell_i is not None: + msg = apply_phase1_cell_edit( + session["parsed"], session["p1_page"], elem_i, cell_i, source + ) + if msg: + gr.Warning(msg) + else: + gr.Info(f"Đã lưu cell #{cell_i} (Table #{elem_i}).") + return { + sess_state: session, + p1_overlay: _overlay_p1(session), + status: ( + f"⚠️ {msg}" + if msg + else f"✅ Đã lưu cell #{cell_i} (Table #{elem_i})." + ), + } + msg = apply_phase1_edit( + session["parsed"], session["p1_page"], elem_i, label, source, bypass + ) + page = session["parsed"]["pages"][session["p1_page"]] + if msg: + gr.Warning(msg) + else: + gr.Info(f"Đã lưu element #{elem_i}.") + return { + sess_state: session, + p1_overlay: _overlay_p1(session), + p1_elem_dd: gr.update(choices=_elem_choices(page), value=elem_i), + status: f"⚠️ {msg}" if msg else f"✅ Đã lưu element #{elem_i}.", + } + + def do_p1_add( + session, x0, y0, x1, y1, label, source, auto_color, bg_hex, text_hex + ): + if x1 <= x0 or y1 <= y0: + gr.Warning("Box không hợp lệ (cần x1>x0, y1>y0).") + return { + sess_state: session, + status: "⚠️ Box không hợp lệ (cần x1>x0, y1>y0).", + } + page_i = session["p1_page"] + # auto_color → let the renderer sample bg/text from the page (old + # default); unchecked → pin the user-picked colors. + bg = None if auto_color else hex_to_rgb(bg_hex) + text = None if auto_color else hex_to_rgb(text_hex) + new_idx = add_element( + session["parsed"], + page_i, + [x0, y0, x1, y1], + label, + source, + bg_color=bg, + text_color=text, + ) + page = session["parsed"]["pages"][page_i] + gr.Info(f"Đã thêm box #{new_idx}.") + # Populate the panel exactly as if the new element had been clicked + # (also re-shows label/bypass in case a table cell was selected + # beforehand, which hides them), then layer on the add-specific + # resets — including the dropdown's choices, which _p1_select_elem + # doesn't refresh since it doesn't know an element was just added. + out = _p1_select_elem(session, new_idx) + out.update( + { + p1_elem_dd: gr.update(choices=_elem_choices(page), value=new_idx), + p1_new_source: gr.update(value=""), + status: f"✅ Đã thêm box #{new_idx}.", + # Turn draw mode back off: leaving it checked would make the + # next click on the image be treated as drawing instead of + # selecting an element, silently blocking further edits. + p1_draw_mode: gr.update(value=False), + } + ) + return out + + # ---- Confirm → translate + render ---------------------------------- + def do_confirm(session, prov, key, mdl, lfrom, lto, fnt): + if not key or not key.strip(): + yield {status: "⚠️ Thiếu API key."} + return + session["src_lang"], session["tgt_lang"] = lfrom, lto + yield {status: "⏳ Phase 2 — đang dịch…"} + result = None + for ev in stream_translate_render( + session["pdf_path"], + session["parsed"], + lfrom, + lto, + PROVIDER_KEY[prov], + key, + mdl or None, + session["pages"], + fnt, + session["work_dir"], + ): + if isinstance(ev, Progress): + yield {status: f"⏳ {ev.msg}"} + else: + result = ev + if result is None or result.status != "ok": + yield {status: f"❌ {result.detail if result else 'Không rõ lỗi.'}"} + return + + session["translated"] = result.data["translated"] + session["out_path"] = result.data["out_path"] + session["p3_page"] = 0 + session["p3_sel"] = None + page_choices = _page_choices(session["translated"]) + img = _base_p3(session) + first_page = session["translated"]["pages"][0] + yield { + sess_state: session, + status: "✅ Đã dịch & dựng. Soát bản dịch rồi tải về.", + p3_group: gr.update(visible=True), + p3_pdf: _paged_gallery(session["out_path"]), + download: session["out_path"], + p3_page_dd: gr.update(choices=page_choices, value=0), + p3_img: img, + p3_overlay: _overlay_p3(session), + p3_elem_dd: gr.update(choices=_elem_choices(first_page), value=None), + } + + def on_p3_page(session, page_i): + if page_i is None: + return {sess_state: session} + session["p3_page"] = int(page_i) + session["p3_sel"] = None + page = session["translated"]["pages"][int(page_i)] + return { + sess_state: session, + p3_img: _base_p3(session), + p3_overlay: _overlay_p3(session), + p3_elem_dd: gr.update(choices=_elem_choices(page), value=None), + p3_source: gr.update(value=""), + p3_translated: gr.update(value=""), + } + + def _p3_select_elem(session, elem_i): + session["p3_sel"] = (elem_i, None) + page = session["translated"]["pages"][session["p3_page"]] + elem = page["elements"][elem_i] + return { + sess_state: session, + p3_overlay: _overlay_p3(session), + p3_elem_dd: gr.update(value=elem_i), + p3_source: gr.update(value=elem.get("source_text", "")), + p3_translated: gr.update(value=elem.get("translated_text", "")), + status: "", + } + + def _p3_select_cell(session, elem_i, cell_i): + session["p3_sel"] = (elem_i, cell_i) + page = session["translated"]["pages"][session["p3_page"]] + cell = page["elements"][elem_i]["cells"][cell_i] + return { + sess_state: session, + p3_overlay: _overlay_p3(session), + p3_elem_dd: gr.update(value=elem_i), + p3_source: gr.update(value=cell.get("source_text", "")), + p3_translated: gr.update(value=cell.get("translated_text", "")), + status: "", + } + + def on_p3_img_click(session, evt: gr.SelectData): + pt = normalize_click(evt.index) + if pt is None: + return {sess_state: session} + hit = hit_test(session.get("p3_boxes", []), pt[0], pt[1]) + if hit is None: + return {sess_state: session} + elem_i, cell_i = hit + if cell_i is None: + return _p3_select_elem(session, elem_i) + return _p3_select_cell(session, elem_i, cell_i) + + def on_p3_pick(session, elem_i): + # Dropdown selects whole elements only — table cells are picked by + # clicking them directly on the image. + if elem_i is None: + return {sess_state: session} + return _p3_select_elem(session, int(elem_i)) + + def do_p3_save(session, translated_text): + sel = session.get("p3_sel") + if sel is None: + gr.Warning("Chưa chọn element nào.") + return {sess_state: session, status: "⚠️ Chưa chọn element nào."} + elem_i, cell_i = sel + if cell_i is not None: + apply_phase2_cell_edit( + session["translated"], + session["p3_page"], + elem_i, + cell_i, + translated_text, + ) + gr.Info( + f"Đã lưu bản dịch cell #{cell_i} (Table #{elem_i}, " + "bấm Render lại để cập nhật)." + ) + else: + apply_phase2_edit( + session["translated"], session["p3_page"], elem_i, translated_text + ) + gr.Info(f"Đã lưu bản dịch #{elem_i} (bấm Render lại để cập nhật).") + return { + sess_state: session, + status: "✅ Đã lưu bản dịch (bấm Render lại để cập nhật).", + } + + def do_rerender(session, fnt): + yield {status: "⏳ Đang render lại…"} + result = None + for ev in stream_render( + session["pdf_path"], + session["translated"], + session["pages"], + fnt, + session["work_dir"], + ): + if isinstance(ev, Progress): + yield {status: f"⏳ {ev.msg}"} + else: + result = ev + if result is None or result.status != "ok": + yield {status: f"❌ {result.detail if result else 'Không rõ lỗi.'}"} + return + session["out_path"] = result.data["out_path"] + yield { + sess_state: session, + status: "✅ Đã render lại.", + p3_pdf: _paged_gallery(session["out_path"]), + download: session["out_path"], + p3_img: _base_p3(session), + p3_overlay: _overlay_p3(session), + } + + # ================================================================== # + # Wiring + # ================================================================== # + provider.change(on_provider_change, provider, [model, api_key]) + api_key.blur(on_load_models, [provider, api_key], model) + load_models_btn.click(on_load_models, [provider, api_key], model) + page_mode.change(on_page_mode, page_mode, [page_from, page_to]) + pdf_in.change( + on_new_file, + [sess_state, pdf_in], + [ + sess_state, + status, + pdf_preview, + p1_group, + p3_group, + p3_pdf, + download, + e2e_pdf, + e2e_download, + ], + ) + + parse_out = [ + sess_state, + status, + p1_group, + p1_page_dd, + p1_img, + p1_elem_dd, + p1_label, + p1_source, + p1_bypass, + p1_overlay, + ] + parse_btn.click( + do_parse, + [ + sess_state, + pdf_in, + provider, + api_key, + lang_from, + lang_to, + page_mode, + page_from, + page_to, + ], + parse_out, + ) + e2e_btn.click( + do_e2e, + [ + pdf_in, + provider, + api_key, + model, + lang_from, + lang_to, + font, + page_mode, + page_from, + page_to, + ], + [status, e2e_pdf, e2e_download], + ) + + # Page change reloads the base <img>; edit events must NOT touch p1_img + # (its presence in outputs makes Gradio flash a loading state over the + # image on every click). show_progress="hidden" suppresses the same + # pulse on the other edited components. + p1_page_out = [ + sess_state, + p1_img, + p1_overlay, + p1_elem_dd, + p1_label, + p1_source, + p1_bypass, + ] + p1_edit_out = [ + sess_state, + p1_overlay, + p1_elem_dd, + p1_label, + p1_source, + p1_bypass, + p1_new_source, + status, + ] + p1_page_dd.change(on_p1_page, [sess_state, p1_page_dd], p1_page_out) + p1_img.select( + on_p1_img_click, + [sess_state, p1_draw_mode], + p1_edit_out, + show_progress="hidden", + ) + p1_elem_dd.select( + on_p1_pick, [sess_state, p1_elem_dd], p1_edit_out, show_progress="hidden" + ) + p1_save_btn.click( + do_p1_save, + [sess_state, p1_label, p1_source, p1_bypass], + p1_edit_out, + show_progress="hidden", + ) + p1_add_btn.click( + do_p1_add, + [ + sess_state, + p1_x0, + p1_y0, + p1_x1, + p1_y1, + p1_new_label, + p1_new_source, + p1_new_auto_color, + p1_new_bg, + p1_new_text, + ], + p1_edit_out + [p1_draw_mode], + show_progress="hidden", + ) + + confirm_out = [ + sess_state, + status, + p3_group, + p3_pdf, + download, + p3_page_dd, + p3_img, + p3_elem_dd, + p3_overlay, + ] + confirm_btn.click( + do_confirm, + [sess_state, provider, api_key, model, lang_from, lang_to, font], + confirm_out, + ) + + # Same split as Phase 1: only page-change / re-render reload p3_img. + p3_page_out = [ + sess_state, + p3_img, + p3_overlay, + p3_elem_dd, + p3_source, + p3_translated, + ] + p3_edit_out = [ + sess_state, + p3_overlay, + p3_elem_dd, + p3_source, + p3_translated, + status, + ] + p3_rerender_out = [ + sess_state, + status, + p3_pdf, + download, + p3_img, + p3_overlay, + ] + p3_page_dd.change(on_p3_page, [sess_state, p3_page_dd], p3_page_out) + p3_img.select( + on_p3_img_click, [sess_state], p3_edit_out, show_progress="hidden" + ) + p3_elem_dd.select( + on_p3_pick, [sess_state, p3_elem_dd], p3_edit_out, show_progress="hidden" + ) + p3_save_btn.click( + do_p3_save, [sess_state, p3_translated], p3_edit_out, show_progress="hidden" + ) + rerender_btn.click(do_rerender, [sess_state, font], p3_rerender_out) + + # fn must be explicit: Blocks.load()'s fn defaults to the sentinel string + # "decorator" (for @demo.load()-style usage), not None — passing js= alone + # silently never registers the trigger, so the script never runs. + demo.load(fn=None, js=DRAW_JS) + + return demo diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..c2c64eab966a37ecfb1293ee10bb5cd38ae926d7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,105 @@ +[project] +name = "pdf2zh" +version = "1.9.11" +description = "Latex PDF Translator" +authors = [{ name = "Byaidu", email = "byaidux@gmail.com" }] +license = "AGPL-3.0" +readme = "README.md" +requires-python = ">=3.10,<3.13" +classifiers = [ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", +] +dependencies = [ + "requests", + # for arm64 linux whells + "pymupdf<1.25.3", + "tqdm", + "tenacity", + "numpy", + "ollama", + "xinference-client", + "deepl", + "openai>=1.0.0", + "azure-ai-translation-text<=1.0.1", + # 5.36 has a bug, webui starts with a white screen + "gradio<5.36", + "huggingface_hub", + "onnx", + "onnxruntime", + "opencv-python-headless", + "tencentcloud-sdk-python-tmt<3.1.129", + "pdfminer-six==20250416", + "gradio_pdf>=0.0.21", + "pikepdf", + "peewee>=3.17.8", + "fontTools", + "babeldoc>=0.1.22, <0.3.0", + "rich", + "json-repair", + "httpx", + "python-dotenv", + "Pillow", + "pydantic_settings", +] + +[project.optional-dependencies] +backend = [ + "flask", + "celery", + "redis" +] +argostranslate = [ + "argostranslate" +] +mcp = [ + "mcp>=1.6.0", + "starlette", +] +dev = [ + "black", + "ruff", + "pre-commit", + "pytest", + "build", + "bumpver>=2024.1130", + "respx", + "torch", +] + +[project.urls] +Homepage = "https://github.com/Byaidu/PDFMathTranslate" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project.scripts] +pdf2zh = "pdf2zh.pdf2zh:main" + +[tool.ruff] +line-length = 88 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "W", "F", "I"] +ignore = ["E203", "E501"] + +[tool.black] +line-length = 88 +target-version = ["py310", "py311"] + + + +[bumpver] +current_version = "1.9.11" +version_pattern = "MAJOR.MINOR.PATCH[.PYTAGNUM]" + +[bumpver.file_patterns] +"pyproject.toml" = [ + 'current_version = "{version}"', + 'version = "{version}"' +] +"pdf2zh/__init__.py" = [ + '__version__ = "{version}"' +] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000000000000000000000000000000000000..4e3badb98e654013f72fa0114a2aedf4f85b206f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = test +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v --tb=short diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..a362dd0fbb2b9a9afc940a0f08bc2b724d940781 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,57 @@ +# Only the OCR libs whose APIs the code calls directly are pinned: +# - surya-ocr 0.17.1 : 0.18+ dropped settings.*_BATCH_SIZE used by hardware.py +# - paddleocr 3.6.0 : matches the working local env +# torch / numpy / paddlepaddle-gpu are left to pip so it resolves a mutually +# compatible CUDA stack (pinning torch==2.10.0 forced CUDA 12.9 and clashed with +# paddlepaddle-gpu's CUDA 13). GPU is kept via paddlepaddle-gpu (cu130). + +# ── Core PDF processing ────────────────────────────────────────────────────── +pymupdf<1.25.3 +pdfminer.six==20250416 +pikepdf +pypdfium2 + +# ── Translation providers ───────────────────────────────────────────────────── +deepl +openai>=1.0.0 +azure-ai-translation-text<=1.0.1 +tencentcloud-sdk-python-tmt<3.1.129 +ollama +xinference-client + +# ── OCR / Scanned PDF (surya pipeline) ─────────────────────────────────────── +surya-ocr==0.17.1 +transformers==4.56.1 +torch +torchvision +opencv-python-headless +pillow +numpy + +# ── Table recognition (GPU paddle) ──────────────────────────────────────────── +--extra-index-url https://www.paddlepaddle.org.cn/packages/stable/cu130/ +paddlepaddle-gpu==3.3.1 +paddleocr==3.6.0 +langchain-community + +# ── Layout & document parsing ───────────────────────────────────────────────── +babeldoc>=0.1.22,<0.3.0 +onnx +onnxruntime +fonttools +peewee>=3.17.8 + +# ── UI ──────────────────────────────────────────────────────────────────────── +gradio<5.36 +gradio_pdf>=0.0.21 +rich + +# ── Utilities ───────────────────────────────────────────────────────────────── +requests +httpx +json_repair +tqdm +tenacity +huggingface_hub +python-dotenv +pydantic-settings \ No newline at end of file diff --git a/script/Dockerfile.China b/script/Dockerfile.China new file mode 100644 index 0000000000000000000000000000000000000000..9973826a611a2781ba5c57b9678a0ead3df21f66 --- /dev/null +++ b/script/Dockerfile.China @@ -0,0 +1,19 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +WORKDIR /app + + +EXPOSE 7860 + +ENV PYTHONUNBUFFERED=1 +ADD "https://ghgo.xyz/https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf" /app +RUN apt-get update && \ + apt-get install --no-install-recommends -y libgl1 && \ + rm -rf /var/lib/apt/lists/* && uv pip install --system --no-cache huggingface-hub && \ + python3 -c "from huggingface_hub import hf_hub_download; hf_hub_download('wybxc/DocLayout-YOLO-DocStructBench-onnx','doclayout_yolo_docstructbench_imgsz1024.onnx');" + +COPY . . + +RUN uv pip install --system --no-cache . + +CMD ["pdf2zh", "-i"] diff --git a/script/Dockerfile.Demo b/script/Dockerfile.Demo new file mode 100644 index 0000000000000000000000000000000000000000..7804dc5027b8fab5e0a8cd03dad083d9d0e486d4 --- /dev/null +++ b/script/Dockerfile.Demo @@ -0,0 +1,31 @@ +FROM python:3.12 +############################ +## Hugging Face Optimized ## +############################ + +WORKDIR /app + +ENV PYTHONUNBUFFERED=1 + +RUN apt-get update && apt-get install -y libgl1 \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install pdf2zh +RUN mkdir -p /data +RUN chmod 777 /data +RUN mkdir -p /app +RUN chmod 777 /app +RUN mkdir -p /.cache +RUN chmod 777 /.cache +RUN mkdir -p ./gradio_files +RUN chmod 777 ./gradio_files +RUN mkdir -p /.config +RUN chmod 777 /.config +RUN mkdir -p /.config/PDFMathTranslate +RUN chmod 777 /.config/PDFMathTranslate + + +# write several lines to the file /.config/PDFMathTranslate/config.json +RUN echo '{ "USE_MODELSCOPE": "0", "PDF2ZH_LANG_FROM": "English", "PDF2ZH_LANG_TO": "Simplified Chinese", "NOTO_FONT_PATH": "/app/SourceHanSerifCN-Regular.ttf", "translators":[]}' > /.config/PDFMathTranslate/config.json +RUN chmod 777 /.config/PDFMathTranslate/config.json +CMD ["pdf2zh", "-i", "--config", "/.config/PDFMathTranslate/config.json"] diff --git a/script/_pystand_static.int b/script/_pystand_static.int new file mode 100644 index 0000000000000000000000000000000000000000..c44291de65070e7226b1a61c638ab8ef69c2ad8f --- /dev/null +++ b/script/_pystand_static.int @@ -0,0 +1,29 @@ +import sys +import pdf2zh.pdf2zh +import os +import babeldoc.assets.assets +import pathlib + +WAIT_FOR_INPUT = False +if len(sys.argv) == 1: + sys.argv.append("-i") # 无参数时自动添加 -i 参数 + WAIT_FOR_INPUT = True + +files = os.listdir(os.path.dirname(__file__)) +for file in files: + if file.endswith(".zip") and file.startswith("offline_assets_"): + print('find offline_assets_zip file: ', file, ' try restore...') + babeldoc.assets.assets.restore_offline_assets_package(pathlib.Path(os.path.dirname(__file__))) + +try: + code = pdf2zh.pdf2zh.main() + print(f"pdf2zh.pdf2zh.main() return code: {code}") + if WAIT_FOR_INPUT: + input("Press Enter to continue...") + sys.exit(code) +except Exception: + import traceback + traceback.print_exc() + if WAIT_FOR_INPUT: + input("Press Enter to continue...") + sys.exit(1) \ No newline at end of file diff --git a/script/setup.bat b/script/setup.bat new file mode 100644 index 0000000000000000000000000000000000000000..3df1dcc7ddb7cf4c0effba0faf6a3ff70151457e --- /dev/null +++ b/script/setup.bat @@ -0,0 +1,27 @@ +@echo off +setlocal enabledelayedexpansion + +set PYTHON_URL=https://www.python.org/ftp/python/3.12.7/python-3.12.7-embed-amd64.zip +set PIP_URL=https://bootstrap.pypa.io/get-pip.py +set HF_ENDPOINT=https://hf-mirror.com +set PIP_MIRROR=https://mirrors.aliyun.com/pypi/simple + +if not exist pdf2zh_dist/python.exe ( + powershell -Command "& {Invoke-WebRequest -Uri !PYTHON_URL! -OutFile python.zip}" + powershell -Command "& {Expand-Archive -Path python.zip -DestinationPath pdf2zh_dist -Force}" + del python.zip + echo import site >> pdf2zh_dist/python312._pth +) +cd pdf2zh_dist + +if not exist Scripts/pip.exe ( + powershell -Command "& {Invoke-WebRequest -Uri !PIP_URL! -OutFile get-pip.py}" + python get-pip.py +) +path Scripts + +pip install --no-warn-script-location --upgrade setuptools -i !PIP_MIRROR! +pip install --no-warn-script-location --upgrade pdf2zh -i !PIP_MIRROR! +pdf2zh -i + +pause diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000000000000000000000000000000000000..d4304f1746adff96e192d7057f1e3cdde92373ca --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[flake8] +max-line-length = 120 +ignore = E203,E261,E501,W503,E741 +exclude = .git,build,dist,docs \ No newline at end of file diff --git a/test/draw_bbox.py b/test/draw_bbox.py new file mode 100644 index 0000000000000000000000000000000000000000..2e5b71ea798492948a4644a48b19f42188616ae4 --- /dev/null +++ b/test/draw_bbox.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Draw bboxes from a translated JSON onto the original PDF for visual inspection. + +Usage: + python test/draw_bbox.py --pdf input.pdf --json output.translated.json --output bbox.pdf + python test/draw_bbox.py --pdf input.pdf --json output.json --output bbox.pdf --pages 0-9 +""" + +import argparse +import json +from pathlib import Path + +import fitz + +COLORS = { + "FLOWING_TEXT": (0.0, 0.0, 1.0), # blue + "IN_PLACE": (0.0, 0.7, 0.0), # green + "EQUATION": (1.0, 0.5, 0.0), # orange + "TABLE": (0.5, 0.0, 0.8), # purple + "BYPASS": (1.0, 0.0, 0.0), # red +} +CELL_COLOR = (0.8, 0.7, 0.0) # yellow +EQ_LINE_COLOR = (0.0, 0.8, 0.8) # cyan — equation_text_lines + + +def parse_pages(s: str) -> list[int] | None: + if not s: + return None + result = [] + for part in s.split(","): + part = part.strip() + if "-" in part: + a, b = part.split("-", 1) + result.extend(range(int(a), int(b) + 1)) + else: + result.append(int(part)) + return result + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--pdf", required=True) + p.add_argument("--json", required=True) + p.add_argument("--output", required=True) + p.add_argument("--pages", default=None, help="e.g. 0-9 or 0,1,5") + args = p.parse_args() + + pages_filter = parse_pages(args.pages) + + with open(args.json, encoding="utf-8") as f: + data = json.load(f) + + doc = fitz.open(args.pdf) + + for page_idx, page_data in enumerate(data.get("pages", [])): + if pages_filter is not None and page_idx not in pages_filter: + continue + if page_idx >= doc.page_count: + break + + page = doc[page_idx] + + for elem in page_data.get("elements", []): + cat = elem.get("category", "") + label = elem.get("label", "") + bbox = elem.get("bbox_pdf") + if not bbox: + continue + + color = COLORS.get(cat, (0.5, 0.5, 0.5)) + rect = fitz.Rect(*bbox) + page.draw_rect(rect, color=color, width=1.5) + page.insert_text( + fitz.Point(bbox[0], bbox[1] - 1), + f"{cat[:2]} {label}", + fontsize=6, + color=color, + ) + + if cat == "TABLE": + for cell in elem.get("cells", []): + cb = cell.get("bbox_pdf") + if cb: + page.draw_rect(fitz.Rect(*cb), color=CELL_COLOR, width=0.8) + + if cat == "EQUATION": + for line in elem.get("equation_text_lines", []) or []: + lb = line.get("bbox_pdf") + if not lb: + continue + page.draw_rect(fitz.Rect(*lb), color=EQ_LINE_COLOR, width=0.8) + label_text = line.get("text", "")[:20] + page.insert_text( + fitz.Point(lb[0], lb[1] - 1), + label_text, + fontsize=5, + color=EQ_LINE_COLOR, + ) + + out = Path(args.output) + out.parent.mkdir(parents=True, exist_ok=True) + doc.save(str(out)) + doc.close() + print(f"Saved → {out}") + print( + "Legend: blue=FLOWING_TEXT green=IN_PLACE orange=EQUATION purple=TABLE " + "red=BYPASS yellow=cell cyan=equation_text_line" + ) + + +if __name__ == "__main__": + main() diff --git a/test/fixtures/mini_output.json b/test/fixtures/mini_output.json new file mode 100644 index 0000000000000000000000000000000000000000..4eedbdc70fdbeb0533cd98d9652b2e36c3f32878 --- /dev/null +++ b/test/fixtures/mini_output.json @@ -0,0 +1,89 @@ +{ + "source_language": "English", + "target_language": "Vietnamese", + "pdf_path": "test.pdf", + "pages": [ + { + "page_index": 0, + "page_width": 612, + "page_height": 792, + "elements": [ + { + "label": "SectionHeader", + "category": "TEXT", + "bbox_pdf": [72, 720, 540, 740], + "source_text": "Introduction to Machine Learning", + "translated_text": "", + "latex": "", + "cells": [] + }, + { + "label": "Text", + "category": "TEXT", + "bbox_pdf": [72, 680, 540, 700], + "source_text": "This is a sample paragraph with enough text to be translatable.", + "translated_text": "", + "latex": "", + "cells": [] + }, + { + "label": "Picture", + "category": "BYPASS", + "bbox_pdf": [72, 600, 540, 680], + "source_text": "", + "translated_text": "", + "latex": "", + "cells": [] + }, + { + "label": "Equation", + "category": "TEXT", + "bbox_pdf": [72, 560, 540, 580], + "source_text": "<math>P(x) = \\frac{1}{\\sigma\\sqrt{2\\pi}}</math> (1.1)", + "translated_text": "", + "latex": "", + "cells": [] + }, + { + "label": "Caption", + "category": "TEXT", + "bbox_pdf": [72, 520, 540, 540], + "source_text": "Table showing experimental results", + "translated_text": "", + "latex": "Result table", + "cells": [] + }, + { + "label": "Table", + "category": "TABLE", + "bbox_pdf": [72, 400, 540, 500], + "source_text": "Accuracy | 42.5 | Precision", + "translated_text": "", + "latex": "", + "cells": [ + {"bbox_pdf": [72, 480, 200, 500], "source_text": "Accuracy", "translated_text": "", "cell_font_size": 10.0}, + {"bbox_pdf": [200, 480, 320, 500], "source_text": "42.5", "translated_text": "", "cell_font_size": 10.0}, + {"bbox_pdf": [320, 480, 540, 500], "source_text": "Precision", "translated_text": "", "cell_font_size": 10.0}, + {"bbox_pdf": [72, 460, 200, 480], "source_text": "", "translated_text": "", "cell_font_size": 10.0} + ] + } + ] + }, + { + "page_index": 1, + "page_width": 612, + "page_height": 792, + "elements": [ + { + "label": "Text", + "category": "TEXT", + "bbox_pdf": [72, 720, 540, 740], + "source_text": "CeADAR is a research center located in Dublin Ireland.", + "translated_text": "", + "latex": "", + "cells": [] + } + ] + } + ] +} diff --git a/test/fixtures/mini_output.translated.json b/test/fixtures/mini_output.translated.json new file mode 100644 index 0000000000000000000000000000000000000000..9a4fd26accc54bd64aec89c820340588d2b59eae --- /dev/null +++ b/test/fixtures/mini_output.translated.json @@ -0,0 +1,118 @@ +{ + "source_language": "English", + "target_language": "Vietnamese", + "pdf_path": "test.pdf", + "pages": [ + { + "page_index": 0, + "page_width": 612, + "page_height": 792, + "elements": [ + { + "label": "SectionHeader", + "category": "TEXT", + "bbox_pdf": [ + 72, + 720, + 540, + 740 + ], + "source_text": "Introduction to Machine Learning", + "translated_text": "Giới thiệu về Học máy", + "latex": "", + "cells": [] + }, + { + "label": "Text", + "category": "TEXT", + "bbox_pdf": [ + 72, + 680, + 540, + 700 + ], + "source_text": "This is a sample paragraph with enough text to be translatable.", + "translated_text": "Đây là một đoạn văn mẫu có đủ nội dung để dịch.", + "latex": "", + "cells": [] + }, + { + "label": "Picture", + "category": "BYPASS", + "bbox_pdf": [ + 72, + 600, + 540, + 680 + ], + "source_text": "", + "translated_text": "", + "latex": "", + "cells": [] + }, + { + "label": "Equation", + "category": "TEXT", + "bbox_pdf": [ + 72, + 560, + 540, + 580 + ], + "source_text": "<math>P(x) = \\frac{1}{\\sigma\\sqrt{2\\pi}}</math> (1.1)", + "translated_text": "", + "latex": "", + "cells": [] + }, + { + "label": "Caption", + "category": "TEXT", + "bbox_pdf": [ + 72, + 520, + 540, + 540 + ], + "source_text": "Table showing experimental results", + "translated_text": "Bảng hiển thị kết quả thử nghiệm", + "latex": "Result table", + "cells": [ + { + "text": "Accuracy", + "value": "0.95", + "translated_text": "Độ chính xác" + }, + { + "text": "42.5" + }, + { + "text": "" + } + ], + "translated_latex": "Bảng kết quả" + } + ] + }, + { + "page_index": 1, + "page_width": 612, + "page_height": 792, + "elements": [ + { + "label": "Text", + "category": "TEXT", + "bbox_pdf": [ + 72, + 720, + 540, + 740 + ], + "source_text": "CeADAR is a research center located in Dublin Ireland.", + "translated_text": "CeADAR là một trung tâm nghiên cứu đặt tại Dublin, Ireland.", + "latex": "", + "cells": [] + } + ] + } + ] +} \ No newline at end of file diff --git a/test/test_cache.py b/test/test_cache.py new file mode 100644 index 0000000000000000000000000000000000000000..af362a0f57b2342de9546ba853c9cb0c3f34f60b --- /dev/null +++ b/test/test_cache.py @@ -0,0 +1,212 @@ +import unittest + +from pdf2zh import cache + + +class TestCache(unittest.TestCase): + def setUp(self): + self.test_db = cache.init_test_db() + + def tearDown(self): + # Clean up + cache.clean_test_db(self.test_db) + + def test_basic_set_get(self): + """Test basic set and get operations""" + cache_instance = cache.TranslationCache("test_engine") + + # Test get with non-existent entry + result = cache_instance.get("hello") + self.assertIsNone(result) + + # Test set and get + cache_instance.set("hello", "你好") + result = cache_instance.get("hello") + self.assertEqual(result, "你好") + + def test_cache_overwrite(self): + """Test that cache entries can be overwritten""" + cache_instance = cache.TranslationCache("test_engine") + + # Set initial translation + cache_instance.set("hello", "你好") + + # Overwrite with new translation + cache_instance.set("hello", "您好") + + # Verify the new translation is returned + result = cache_instance.get("hello") + self.assertEqual(result, "您好") + + def test_non_string_params(self): + """Test that non-string parameters are automatically converted to JSON""" + params = {"model": "gpt-3.5", "temperature": 0.7} + cache_instance = cache.TranslationCache("test_engine", params) + + # Test that params are converted to JSON string internally + cache_instance.set("hello", "你好") + result = cache_instance.get("hello") + self.assertEqual(result, "你好") + + # Test with different param types + array_params = ["param1", "param2"] + cache_instance2 = cache.TranslationCache("test_engine", array_params) + cache_instance2.set("hello", "你好2") + self.assertEqual(cache_instance2.get("hello"), "你好2") + + # Test with nested structures + nested_params = {"options": {"temp": 0.8, "models": ["a", "b"]}} + cache_instance3 = cache.TranslationCache("test_engine", nested_params) + cache_instance3.set("hello", "你好3") + self.assertEqual(cache_instance3.get("hello"), "你好3") + + def test_engine_distinction(self): + """Test that cache distinguishes between different translation engines""" + cache1 = cache.TranslationCache("engine1") + cache2 = cache.TranslationCache("engine2") + + # Set same text with different engines + cache1.set("hello", "你好 1") + cache2.set("hello", "你好 2") + + # Verify each engine gets its own translation + self.assertEqual(cache1.get("hello"), "你好 1") + self.assertEqual(cache2.get("hello"), "你好 2") + + def test_params_distinction(self): + """Test that cache distinguishes between different engine parameters""" + params1 = {"param": "value1"} + params2 = {"param": "value2"} + cache1 = cache.TranslationCache("test_engine", params1) + cache2 = cache.TranslationCache("test_engine", params2) + + # Set same text with different parameters + cache1.set("hello", "你好 1") + cache2.set("hello", "你好 2") + + # Verify each parameter set gets its own translation + self.assertEqual(cache1.get("hello"), "你好 1") + self.assertEqual(cache2.get("hello"), "你好 2") + + def test_consistent_param_serialization(self): + """Test that dictionary parameters are consistently serialized regardless of key order""" + # Test simple dictionary + params1 = {"b": 1, "a": 2} + params2 = {"a": 2, "b": 1} + cache1 = cache.TranslationCache("test_engine", params1) + cache2 = cache.TranslationCache("test_engine", params2) + self.assertEqual(cache1.translate_engine_params, cache2.translate_engine_params) + + # Test nested dictionary + params1 = {"outer2": {"inner2": 2, "inner1": 1}, "outer1": 3} + params2 = {"outer1": 3, "outer2": {"inner1": 1, "inner2": 2}} + cache1 = cache.TranslationCache("test_engine", params1) + cache2 = cache.TranslationCache("test_engine", params2) + self.assertEqual(cache1.translate_engine_params, cache2.translate_engine_params) + + # Test dictionary with list of dictionaries + params1 = {"b": [{"y": 1, "x": 2}], "a": 3} + params2 = {"a": 3, "b": [{"x": 2, "y": 1}]} + cache1 = cache.TranslationCache("test_engine", params1) + cache2 = cache.TranslationCache("test_engine", params2) + self.assertEqual(cache1.translate_engine_params, cache2.translate_engine_params) + + # Test that different values still produce different results + params1 = {"a": 1, "b": 2} + params2 = {"a": 2, "b": 1} + cache1 = cache.TranslationCache("test_engine", params1) + cache2 = cache.TranslationCache("test_engine", params2) + self.assertNotEqual( + cache1.translate_engine_params, cache2.translate_engine_params + ) + + def test_cache_with_sorted_params(self): + """Test that cache works correctly with sorted parameters""" + params1 = {"b": [{"y": 1, "x": 2}], "a": 3} + params2 = {"a": 3, "b": [{"x": 2, "y": 1}]} + + # Both caches should work with the same key + cache1 = cache.TranslationCache("test_engine", params1) + cache1.set("hello", "你好") + + cache2 = cache.TranslationCache("test_engine", params2) + self.assertEqual(cache2.get("hello"), "你好") + + def test_append_params(self): + """Test the append_params method""" + cache_instance = cache.TranslationCache("test_engine", {"initial": "value"}) + + # Test appending new parameter + cache_instance.add_params("new_param", "new_value") + self.assertEqual( + cache_instance.params, {"initial": "value", "new_param": "new_value"} + ) + + # Test that cache with appended params works correctly + cache_instance.set("hello", "你好") + self.assertEqual(cache_instance.get("hello"), "你好") + + # Test overwriting existing parameter + cache_instance.add_params("initial", "new_value") + self.assertEqual( + cache_instance.params, {"initial": "new_value", "new_param": "new_value"} + ) + + # Cache should work with updated params + cache_instance.set("hello2", "你好2") + self.assertEqual(cache_instance.get("hello2"), "你好2") + + # Sometimes the problem of "database is locked" occurs. Temporarily disable this test. + # def test_thread_safety(self): + # """Test thread safety of cache operations""" + # cache_instance = cache.TranslationCache("test_engine") + # lock = threading.Lock() + # results = [] + # num_threads = multiprocessing.cpu_count() + # items_per_thread = 100 + + # def generate_random_text(length=10): + # return "".join( + # random.choices(string.ascii_letters + string.digits, k=length) + # ) + + # def worker(): + # thread_results = [] # 线程本地存储结果 + # for _ in range(items_per_thread): + # text = generate_random_text() + # translation = f"翻译_{text}" + + # # Write operation + # cache_instance.set(text, translation) + + # # Read operation - verify our own write + # result = cache_instance.get(text) + # thread_results.append((text, result)) + + # # 所有操作完成后,一次性加锁并追加结果 + # with lock: + # results.extend(thread_results) + + # # Create threads equal to CPU core count + # threads = [] + # for _ in range(num_threads): + # thread = threading.Thread(target=worker) + # threads.append(thread) + # thread.start() + + # # Wait for all threads to complete + # for thread in threads: + # thread.join() + + # # Verify all operations were successful + # expected_total = num_threads * items_per_thread + # self.assertEqual(len(results), expected_total) + + # # Verify each thread got its correct value + # for text, result in results: + # expected = f"翻译_{text}" + # self.assertEqual(result, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_cli.py b/test/test_cli.py new file mode 100644 index 0000000000000000000000000000000000000000..7195940d9b16ebae5a75679864f811b3ee654ad7 --- /dev/null +++ b/test/test_cli.py @@ -0,0 +1,37 @@ +import importlib +import sys +import unittest + + +class TestCliVersion(unittest.TestCase): + def tearDown(self): + for module_name in [ + "pdf2zh", + "pdf2zh.pdf2zh", + "pdf2zh.high_level", + "pdf2zh.doclayout", + ]: + sys.modules.pop(module_name, None) + + def test_importing_package_does_not_eagerly_load_translation_pipeline(self): + pkg = importlib.import_module("pdf2zh") + + self.assertEqual(pkg.__version__, "1.9.11") + self.assertNotIn("pdf2zh.high_level", sys.modules) + + def test_version_flag_exits_before_loading_heavy_modules(self): + cli = importlib.import_module("pdf2zh.pdf2zh") + + self.assertNotIn("pdf2zh.high_level", sys.modules) + self.assertNotIn("pdf2zh.doclayout", sys.modules) + + with self.assertRaises(SystemExit) as exit_context: + cli.main(["-v"]) + + self.assertEqual(exit_context.exception.code, 0) + self.assertNotIn("pdf2zh.high_level", sys.modules) + self.assertNotIn("pdf2zh.doclayout", sys.modules) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_converter.py b/test/test_converter.py new file mode 100644 index 0000000000000000000000000000000000000000..7cb6712f45c3cb1c13b81e16c2a0ada5113a9d46 --- /dev/null +++ b/test/test_converter.py @@ -0,0 +1,112 @@ +import unittest +from unittest.mock import MagicMock, Mock, patch + +from pdfminer.layout import LTChar, LTLine, LTPage +from pdfminer.pdfinterp import PDFResourceManager + +from pdf2zh.converter import PDFConverterEx, TranslateConverter + + +class TestPDFConverterEx(unittest.TestCase): + def setUp(self): + self.rsrcmgr = PDFResourceManager() + self.converter = PDFConverterEx(self.rsrcmgr) + + def test_begin_page(self): + mock_page = Mock() + mock_page.pageno = 1 + mock_page.cropbox = (0, 0, 100, 200) + mock_ctm = [1, 0, 0, 1, 0, 0] + self.converter.begin_page(mock_page, mock_ctm) + self.assertIsNotNone(self.converter.cur_item) + self.assertEqual(self.converter.cur_item.pageid, 1) + + def test_render_char(self): + mock_matrix = (1, 2, 3, 4, 5, 6) + mock_font = Mock() + mock_font.to_unichr.return_value = "A" + mock_font.char_width.return_value = 10 + mock_font.char_disp.return_value = (0, 0) + graphic_state = Mock() + self.converter.cur_item = Mock() + result = self.converter.render_char( + mock_matrix, + mock_font, + fontsize=12, + scaling=1.0, + rise=0, + cid=65, + ncs=None, + graphicstate=graphic_state, + ) + self.assertEqual(result, 120.0) # Expected text width + + +class TestTranslateConverter(unittest.TestCase): + def setUp(self): + self.rsrcmgr = PDFResourceManager() + self.layout = {1: Mock()} + self.translator_class = Mock() + self.converter = TranslateConverter( + self.rsrcmgr, + layout=self.layout, + lang_in="en", + lang_out="zh", + service="google", + ) + + def test_translator_initialization(self): + self.assertIsNotNone(self.converter.translator) + self.assertEqual(self.converter.translator.lang_in, "en") + self.assertEqual(self.converter.translator.lang_out, "zh-CN") + + @patch("pdf2zh.converter.TranslateConverter.receive_layout") + def test_receive_layout(self, mock_receive_layout): + mock_page = LTPage(1, (0, 0, 100, 200)) + mock_font = Mock() + mock_font.fontname.return_value = "mock_font" + mock_page.add( + LTChar( + matrix=(1, 2, 3, 4, 5, 6), + font=mock_font, + fontsize=12, + scaling=1.0, + rise=0, + text="A", + textwidth=10, + textdisp=(1.0, 1.0), + ncs=Mock(), + graphicstate=Mock(), + ) + ) + self.converter.receive_layout(mock_page) + mock_receive_layout.assert_called_once_with(mock_page) + + def test_receive_layout_with_complex_formula(self): + ltpage = LTPage(1, (0, 0, 500, 500)) + ltchar = Mock() + ltchar.fontname.return_value = "mock_font" + ltline = LTLine(0.1, (0, 0), (10, 20)) + ltpage.add(ltchar) + ltpage.add(ltline) + mock_layout = MagicMock() + mock_layout.shape = (100, 100) + mock_layout.__getitem__.return_value = -1 + self.converter.layout = [None, mock_layout] + self.converter.thread = 1 + result = self.converter.receive_layout(ltpage) + self.assertIsNotNone(result) + + def test_invalid_translation_service(self): + with self.assertRaises(ValueError): + TranslateConverter( + self.rsrcmgr, + layout=self.layout, + lang_in="en", + lang_out="zh", + service="InvalidService", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_doclayout.py b/test/test_doclayout.py new file mode 100644 index 0000000000000000000000000000000000000000..44bf9fd9e9d08450c9f3fcbeebd49376a9f7fb0c --- /dev/null +++ b/test/test_doclayout.py @@ -0,0 +1,107 @@ +import unittest +from unittest.mock import MagicMock, patch + +import numpy as np + +from pdf2zh.doclayout import ( + OnnxModel, + YoloBox, + YoloResult, +) + + +class TestOnnxModel(unittest.TestCase): + @patch("onnx.load") + @patch("onnxruntime.InferenceSession") + def setUp(self, mock_inference_session, mock_onnx_load): + # Mock ONNX model metadata + mock_model = MagicMock() + mock_model.metadata_props = [ + MagicMock(key="stride", value="32"), + MagicMock(key="names", value="['class1', 'class2']"), + ] + mock_onnx_load.return_value = mock_model + + # Initialize OnnxModel with a fake path + self.model_path = "fake_model_path.onnx" + self.model = OnnxModel(self.model_path) + + def test_stride_property(self): + # Test that stride is correctly set from model metadata + self.assertEqual(self.model.stride, 32) + + def test_resize_and_pad_image(self): + # Create a dummy image (100x200) + image = np.ones((100, 200, 3), dtype=np.uint8) + resized_image = self.model.resize_and_pad_image(image, 1024) + + # Validate the output shape + self.assertEqual(resized_image.shape[0], 512) + self.assertEqual(resized_image.shape[1], 1024) + + # Check that padding has been added + padded_height = resized_image.shape[0] - image.shape[0] + padded_width = resized_image.shape[1] - image.shape[1] + self.assertGreater(padded_height, 0) + self.assertGreater(padded_width, 0) + + def test_scale_boxes(self): + img1_shape = (1024, 1024) # Model input shape + img0_shape = (500, 300) # Original image shape + boxes = np.array([[512, 512, 768, 768]]) # Example bounding box + + scaled_boxes = self.model.scale_boxes(img1_shape, boxes, img0_shape) + + # Verify the output is scaled correctly + self.assertEqual(scaled_boxes.shape, boxes.shape) + self.assertTrue(np.all(scaled_boxes <= max(img0_shape))) + + def test_predict(self): + # Mock model inference output + mock_output = np.random.random((1, 300, 6)) + self.model.model.run.return_value = [mock_output] + + # Create a dummy image + image = np.ones((500, 300, 3), dtype=np.uint8) + + results = self.model.predict(image) + + # Validate predictions + self.assertEqual(len(results), 1) + self.assertIsInstance(results[0], YoloResult) + self.assertGreater(len(results[0].boxes), 0) + self.assertIsInstance(results[0].boxes[0], YoloBox) + + +class TestYoloResult(unittest.TestCase): + def test_yolo_result(self): + # Example prediction data + boxes = [ + [100, 200, 300, 400, 0.9, 0], + [50, 100, 150, 200, 0.8, 1], + ] + names = ["class1", "class2"] + + result = YoloResult(boxes, names) + + # Validate the number of boxes and their order by confidence + self.assertEqual(len(result.boxes), 2) + self.assertGreater(result.boxes[0].conf, result.boxes[1].conf) + self.assertEqual(result.names, names) + + +class TestYoloBox(unittest.TestCase): + def test_yolo_box(self): + # Example box data + box_data = [100, 200, 300, 400, 0.9, 0] + + box = YoloBox(box_data) + + # Validate box properties + self.assertEqual(box.xyxy, box_data[:4]) + self.assertEqual(box.conf, box_data[4]) + self.assertEqual(box.cls, box_data[5]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_e2e.py b/test/test_e2e.py new file mode 100644 index 0000000000000000000000000000000000000000..7f4fa0d92b76a388b81b643fe10ee05a25a2cb32 --- /dev/null +++ b/test/test_e2e.py @@ -0,0 +1,41 @@ +"""End-to-end orchestration tests (pdf2zh.e2e). + +Covers the per-phase latency log emitted by ``run_pipeline`` (used only by the +UI's end-to-end button; the stepped flow calls the phases directly). +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import pdf2zh.e2e as e2e + + +def test_run_pipeline_logs_per_phase_latency(monkeypatch, caplog): + # Stub the three phases so no models / network / typst are exercised. + monkeypatch.setattr(e2e, "run_parse", lambda *a, **k: {"pages": []}) + monkeypatch.setattr(e2e, "run_translate", lambda *a, **k: {"pages": []}) + monkeypatch.setattr(e2e, "run_render", lambda *a, **k: "/tmp/out.pdf") + + with caplog.at_level("INFO", logger="pdf2zh.e2e"): + out = e2e.run_pipeline( + pdf_path="in.pdf", + src_lang="English", + tgt_lang="Vietnamese", + provider="openrouter", + api_key="key", + model=None, + pages=None, + font="Noto Sans", + work_dir="/tmp/wd", + ) + + assert out == "/tmp/out.pdf" + latency_lines = [ + r.getMessage() for r in caplog.records if "[latency]" in r.getMessage() + ] + assert len(latency_lines) == 1 + # The line reports all four numbers. + for key in ("parse=", "translate=", "render=", "total="): + assert key in latency_lines[0] diff --git a/test/test_json_translator.py b/test/test_json_translator.py new file mode 100644 index 0000000000000000000000000000000000000000..e383cc2d6e8c4c08b6c1eaf777baa72981f9986f --- /dev/null +++ b/test/test_json_translator.py @@ -0,0 +1,285 @@ +import json +import re +from pathlib import Path +from unittest.mock import AsyncMock, patch + +from pdf2zh.json_translator import ( + Gateway, + TranslatorConfig, + collect_translatables, + glossary_block_for_chunk, + is_equation_only, + is_plain_text, + segments_to_chunks, + translate_document, +) + +FIXTURE = Path(__file__).parent / "fixtures" / "mini_output.json" + + +def load_fixture() -> dict: + return json.loads(FIXTURE.read_text(encoding="utf-8")) + + +def _mock_cfg() -> TranslatorConfig: + return TranslatorConfig( + source_language="English", + target_language="Vietnamese", + provider="openrouter", + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + model="google/gemini-2.5-flash-lite", + concurrent=5, + chunk_bytes=3000, + glossary_enabled=False, + retry=0, + timeout=10, + ) + + +def _echo_translations(system: str, user: str, *, force_json: bool = False) -> str: + """Side-effect for AsyncMock: echoes back each source text as <TR:source>.""" + match = re.search(r"```json\n(.*?)\n```", user, re.DOTALL) + if not match: + return "[]" + chunk = json.loads(match.group(1)) + result = [{"id": k, "t": f"<TR:{v}>"} for k, v in chunk.items()] + return json.dumps(result) + + +# ── 1. is_plain_text ──────────────────────────────────────────────────────────── + + +def test_is_plain_text(): + assert is_plain_text("when a = b") is True + assert is_plain_text("Introduction to Machine Learning") is True + assert is_plain_text("a = b") is False # no run of ≥2 letters + assert is_plain_text("42.5") is False + assert is_plain_text("<math>x</math>") is False + assert is_plain_text("<math>x</math> where x is the count") is True + assert is_plain_text("") is False + + +# ── 2. is_equation_only ───────────────────────────────────────────────────────── + + +def test_is_equation_only(): + assert is_equation_only("<math>P(x)</math> (1.1)") is True + assert is_equation_only("<math>P(x)</math>") is True + assert is_equation_only("<math>P(x)</math> where P is") is False + assert is_equation_only("Some plain text") is False + assert is_equation_only("") is True # empty → only whitespace after strip + + +# ── 3. segments_to_chunks ─────────────────────────────────────────────────────── + + +def test_chunking(): + doc = load_fixture() + tasks = collect_translatables(doc) + chunks = segments_to_chunks(tasks, max_bytes=200) + + # Every task id appears in exactly one chunk + all_ids_in_chunks: list[str] = [] + for chunk in chunks: + all_ids_in_chunks.extend(chunk.keys()) + assert sorted(all_ids_in_chunks) == sorted(t.id for t in tasks) + + # No chunk exceeds budget (unless a single segment is oversized on its own) + for chunk in chunks: + if len(chunk) > 1: + size = len(json.dumps(chunk, ensure_ascii=False).encode()) + assert size <= 200 + + # IDs are stringified indices starting from "0" + expected_ids = [str(i) for i in range(len(tasks))] + assert all_ids_in_chunks == expected_ids + + +# ── 4. collect_translatables ───────────────────────────────────────────────────── + + +def test_collect_translatables_on_sample(): + doc = load_fixture() + doc["pages"][0]["elements"][5]["cells"][3]["translated_text"] = "keep original" + tasks = collect_translatables(doc) + + ids = [t.id for t in tasks] + write_keys = [t.write_key for t in tasks] + texts = [t.text for t in tasks] + + # Expected: 7 tasks + # id=0: source_text of SectionHeader + # id=1: source_text of Text element + # id=2: source_text of Caption (BYPASS picture skipped, equation-only skipped) + # id=3: latex of Caption ("Result table") + # id=4: cells[0].source_text of Table ("Accuracy") + # id=5: cells[2].source_text of Table ("Precision") + # (TABLE elem.source_text skipped; "42.5" + "" cells filtered) + # id=6: source_text of page 1 Text + assert len(tasks) == 7 + assert ids == ["0", "1", "2", "3", "4", "5", "6"] + + assert texts[0] == "Introduction to Machine Learning" + assert write_keys[0] == "translated_text" + + assert texts[2] == "Table showing experimental results" + assert write_keys[2] == "translated_text" + + assert texts[3] == "Result table" + assert write_keys[3] == "translated_latex" + + assert texts[4] == "Accuracy" + assert write_keys[4] == "translated_text" + + assert texts[5] == "Precision" + assert write_keys[5] == "translated_text" + + assert texts[6] == "CeADAR is a research center located in Dublin Ireland." + assert write_keys[6] == "translated_text" + + # BYPASS element source_text not included + assert not any(t.text == "" for t in tasks) + # Equation-only not included + assert not any("<math>P(x)" in t.text for t in tasks) + # "42.5" not included (is_plain_text → False) + assert not any(t.text == "42.5" for t in tasks) + # TABLE elem.source_text (the joined " | " string) not translated directly + assert not any(t.text == "Accuracy | 42.5 | Precision" for t in tasks) + assert not any(t.text == "keep original" for t in tasks) + + +# ── 5. end-to-end with mocked Gateway.call ────────────────────────────────────── + + +def test_translate_document_end_to_end_mocked(): + doc = load_fixture() + doc["pages"][0]["elements"][5]["cells"][3]["translated_text"] = "keep original" + cfg = _mock_cfg() + + mock_call = AsyncMock(side_effect=_echo_translations) + with patch.object(Gateway, "call", mock_call): + out = translate_document(doc, cfg) + + pages = out["pages"] + elems0 = pages[0]["elements"] + elems1 = pages[1]["elements"] + + # Eligible source_text fields are translated + assert elems0[0]["translated_text"] == "<TR:Introduction to Machine Learning>" + assert ( + elems0[1]["translated_text"] + == "<TR:This is a sample paragraph with enough text to be translatable.>" + ) + assert elems0[4]["translated_text"] == "<TR:Table showing experimental results>" + assert ( + elems1[0]["translated_text"] + == "<TR:CeADAR is a research center located in Dublin Ireland.>" + ) + + # translated_latex added as new sibling + assert elems0[4]["translated_latex"] == "<TR:Result table>" + # Original latex unchanged + assert elems0[4]["latex"] == "Result table" + + # TABLE element: cells get translated, elem.translated_text stays empty + table = elems0[5] + assert table["category"] == "TABLE" + assert table["translated_text"] == "" + assert table["cells"][0]["translated_text"] == "<TR:Accuracy>" + assert table["cells"][0]["source_text"] == "Accuracy" + assert table["cells"][1]["translated_text"] == "" # "42.5" — not plain text + assert table["cells"][2]["translated_text"] == "<TR:Precision>" + assert table["cells"][3]["translated_text"] == "keep original" + + # BYPASS element unchanged + bypass = elems0[2] + assert bypass["category"] == "BYPASS" + assert bypass["translated_text"] == "" + + # Equation-only element: translated_text not overwritten from "" + eq = elems0[3] + assert eq["translated_text"] == "" + + # Structural fields preserved verbatim + assert out["pdf_path"] == "test.pdf" + assert elems0[0]["bbox_pdf"] == [72, 720, 540, 740] + assert elems0[0]["label"] == "SectionHeader" + + +# ── 6. length violation retry ──────────────────────────────────────────────────── + + +def test_length_violation_retry(): + cfg = _mock_cfg() + cfg.chunk_bytes = 10000 + cfg.length_tolerance = 0.15 + + source = "This is a long enough source string for length checking" + too_long = ( + source + " extra extra extra extra extra extra extra extra extra extra extra" + ) + correct = "Đây là một chuỗi nguồn đủ dài để kiểm tra độ dài" + + call_count = 0 + + async def _side_effect(system: str, user: str, *, force_json: bool = False) -> str: + nonlocal call_count + call_count += 1 + match = re.search(r"```json\n(.*?)\n```", user, re.DOTALL) + if not match: + return "[]" + chunk = json.loads(match.group(1)) + ids = list(chunk.keys()) + # First call returns over-long; subsequent calls return correct + t = too_long if call_count == 1 else correct + return json.dumps([{"id": k, "t": t} for k in ids]) + + doc = { + "source_language": "English", + "target_language": "Vietnamese", + "pdf_path": "t.pdf", + "pages": [ + { + "page_index": 0, + "page_width": 612, + "page_height": 792, + "elements": [ + { + "label": "Text", + "category": "TEXT", + "bbox_pdf": [0, 0, 100, 10], + "source_text": source, + "translated_text": "", + "latex": "", + "cells": [], + } + ], + } + ], + } + + mock_call = AsyncMock(side_effect=_side_effect) + with patch.object(Gateway, "call", mock_call): + out = translate_document(doc, cfg) + + assert out["pages"][0]["elements"][0]["translated_text"] == correct + # 1 initial call + 1 length-violation retry + assert call_count == 2 + + +# ── 7. glossary injection ──────────────────────────────────────────────────────── + + +def test_glossary_injection(): + glossary = {"ceadar": "CEADAR"} + + chunk_with = {"5": "CeADAR is a research center located in Dublin Ireland."} + chunk_without = {"0": "Introduction to Machine Learning"} + + block_with = glossary_block_for_chunk(chunk_with, glossary) + block_without = glossary_block_for_chunk(chunk_without, glossary) + + assert "ceadar" in block_with.lower() + assert "CEADAR" in block_with + assert block_without == "" diff --git a/test/test_render_markup.py b/test/test_render_markup.py new file mode 100644 index 0000000000000000000000000000000000000000..28ff4a0fa8afda832d251187341fc0fc7cef39c4 --- /dev/null +++ b/test/test_render_markup.py @@ -0,0 +1,237 @@ +"""Unit tests for pdf2zh.render.markup — HTML/LaTeX → Typst markup conversion.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from pdf2zh.render.markup import parse_toc_line, to_typst_markup, to_typst_native + + +class TestMathConversion: + def test_display_math(self): + out = to_typst_markup('<math display="block">\\frac{a}{b}</math>') + assert "$$\\frac{a}{b}$$" in out + + def test_inline_math(self): + out = to_typst_markup("<math>x^2</math>") + assert "$x^2$" in out + + def test_multiple_math_blocks(self): + out = to_typst_markup("See <math>a+b</math> and <math>c-d</math>") + assert "$a+b$" in out + assert "$c-d$" in out + + def test_math_preserved_in_equation_mode(self): + out = to_typst_markup("$a=b$", is_equation=True) + # Already has $...$, should stay + assert "$a=b$" in out + + def test_bare_latex_wrapped_in_equation_mode(self): + out = to_typst_markup("We get \\frac{a}{b}", is_equation=True) + assert "$" in out + assert "\\frac{a}{b}" in out + + def test_bare_latex_not_wrapped_outside_equation(self): + # In non-equation mode, bare LaTeX is not wrapped + out = to_typst_markup("Some text \\frac{a}{b}", is_equation=False) + # No wrapping — just passes through (stripped as unknown tag or kept) + assert "\\frac{a}{b}" in out + + +class TestHtmlFormatting: + def test_bold(self): + assert "**hello**" in to_typst_markup("<b>hello</b>") + + def test_strong(self): + assert "**world**" in to_typst_markup("<strong>world</strong>") + + def test_italic(self): + assert "_hi_" in to_typst_markup("<i>hi</i>") + + def test_em(self): + assert "_em_" in to_typst_markup("<em>em</em>") + + def test_superscript_outside_math(self): + out = to_typst_markup("x<sup>2</sup>") + assert "^2^" in out + + def test_subscript_outside_math(self): + out = to_typst_markup("H<sub>2</sub>O") + assert "~2~" in out + + def test_bold_with_italic_inside(self): + out = to_typst_markup("<b>bold <i>and italic</i></b>") + assert "**" in out + assert "_" in out + + +class TestLiteralCharacters: + def test_less_than_escaped(self): + out = to_typst_markup("If a < b then") + assert "\\<" in out + + def test_italic_variable_with_comparison(self): + out = to_typst_markup("If <i>a</i> < <i>b</i>") + assert "_a_" in out + assert "_b_" in out + assert "\\<" in out + + def test_plain_text_hash_escaped(self): + out = to_typst_markup("Price: #100") + assert "\\#100" in out + + def test_plain_text_at_escaped(self): + out = to_typst_markup("Email @user") + assert "\\@user" in out + + def test_no_false_tag_match(self): + # A tag-like that isn't a known formatting tag + out = to_typst_markup("<unknown>text</unknown>") + # Unknown tags stripped + assert "<unknown>" not in out + assert "text" in out + + +class TestMathAndProseEquation: + def test_mixed_prose_and_math(self): + out = to_typst_markup( + "Nếu ax<sup>2</sup> + bx + c = 0, thì x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}.", + is_equation=True, + ) + # Vietnamese prose preserved + assert "Nếu" in out + assert "thì" in out + # Math wrapped + assert "$" in out + + def test_equation_with_html_math_tags(self): + out = to_typst_markup( + 'a(b + c) = ab + ac <math display="block">\\frac{a+c}{b}</math>', + is_equation=True, + ) + assert "$$\\frac{a+c}{b}$$" in out + assert "a(b + c) = ab + ac" in out + + +class TestTypstNativeMath: + def test_preserves_multilevel_symbol_modifiers(self): + out = to_typst_native("<math>arrow.r.double dot.triple</math>") + assert "$arrow.r.double dot.triple$" in out + + def test_breaks_unknown_symbol_modifiers(self): + out = to_typst_native("<math>plus.unknown</math>") + assert "plus.unknown" not in out + assert "$plus u n k n o w n$" in out + + def test_quotes_long_underscore_identifiers(self): + out = to_typst_native("<math>page_index = k</math>") + assert '$upright("page_index") = k$' in out + + def test_preserves_simple_subscripts(self): + out = to_typst_native("<math>x_i + a_1</math>") + assert "$x_i + a_1$" in out + + +class TestMathSanitizer: + """Regression tests: LLM math output must never break the Typst compile.""" + + def test_no_double_wrap_of_underscore_identifiers(self): + # Was: $upright("upright("page_index")")$ — nested unescaped quotes. + out = to_typst_native("<math>page_index = k</math>") + assert out == '$upright("page_index") = k$' + + def test_existing_upright_quotes_untouched(self): + out = to_typst_native('<math>upright("page_index") = k</math>') + assert out == '$upright("page_index") = k$' + + def test_quoted_strings_not_letter_split(self): + # Was: "pairs with" → "p a i r s w i t h" + out = to_typst_native('<math>x = "pairs with" y</math>') + assert '"pairs with"' in out + + def test_bare_hash_escaped_in_math(self): + # Bare # in Typst math starts a code expression → compile error. + out = to_typst_native('<math>TP(t) = #{"pairs" IoU >= t}</math>') + assert "\\#" in out + assert '"pairs"' in out + + def test_unknown_function_name_quoted(self): + # Bare TP(t) is 'unknown variable: TP' at compile time. + out = to_typst_native("<math>TP(t) = 1</math>") + assert 'upright("TP")(t)' in out + + def test_known_function_call_kept(self): + out = to_typst_native("<math>frac(a, b)</math>") + assert "$frac(a, b)$" in out + + def test_known_identifier_subscript_kept(self): + out = to_typst_native("<math>sigma_x^2</math>") + assert "$sigma_x^2$" in out + + def test_leading_attach_gets_empty_base(self): + # $_(x)$ is 'unexpected underscore' — needs an empty base. + out = to_typst_native("<math>_(x)</math>") + assert '$""_(x)$' in out + + def test_trailing_attach_gets_empty_script(self): + out = to_typst_native("<math>x_</math>") + assert '$x_""$' in out + + def test_unmatched_quote_escaped(self): + # A lone quote opens a string that swallows the rest of the source. + out = to_typst_native('<math>x = "unclosed</math>') + assert '\\"' in out + assert out.count('"') % 2 == 0 or '\\"' in out + + def test_typst_block_math_idempotent(self): + out = to_typst_native("<typst>page $page_index$ = k</typst>") + assert 'page $upright("page_index")$ = k' == out + + +class TestTocLineParsing: + def test_simple_entry(self): + result = parse_toc_line("Introduction 1") + assert result == ("Introduction", "1") + + def test_entry_with_dots(self): + result = parse_toc_line("Chapter 1: Overview ....... 15") + assert result is not None + assert result[1] == "15" + assert "Chapter 1" in result[0] + + def test_entry_with_bold_markup(self): + result = parse_toc_line("<b>Derivatives</b> 174") + assert result is not None + assert result[1] == "174" + + def test_entry_no_page_number(self): + result = parse_toc_line("Just a heading") + assert result is None + + def test_empty_line(self): + assert parse_toc_line("") is None + + def test_multipart_number(self): + result = parse_toc_line("3.1 Derivatives of Polynomials 174") + assert result is not None + assert result[1] == "174" + + +class TestNewlineHandling: + def test_lone_newline_becomes_hard_break(self): + # A single '\n' must become a CommonMark hard break (backslash + newline) + # so cmarker keeps the line break instead of collapsing it to a space. + out = to_typst_markup("Ho Chi Minh City\nStudent group") + assert "\\\n" in out + assert out == "Ho Chi Minh City\\\nStudent group" + + def test_paragraph_break_preserved(self): + # A blank line (double newline) stays a paragraph break, not a hard break. + out = to_typst_markup("Para one.\n\nPara two.") + assert "\\\n" not in out + assert "\n\n" in out + + def test_newline_inside_math_untouched(self): + out = to_typst_markup("text <math>a\nb</math> more") + assert "$a\nb$" in out # no backslash injected inside math diff --git a/test/test_render_pages.py b/test/test_render_pages.py new file mode 100644 index 0000000000000000000000000000000000000000..bdaa1507f1fa0fcab35112a1567aad8e257fc1aa --- /dev/null +++ b/test/test_render_pages.py @@ -0,0 +1,280 @@ +"""Regression tests for arbitrary page-range rendering + output-only-translated. + +The render layer used to conflate the compacted-list enumerate index with the +original page number, so any range not starting at page 0 silently rendered +nothing. These tests pin the fixed behavior: + - a range like [2, 3, 4] (not starting at 0) renders correctly, and + - the output PDF contains ONLY the translated pages, in order. +""" + +import shutil +import sys +from pathlib import Path + +import fitz +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from pdf2zh.render.config import RenderConfig +from pdf2zh.render.renderer import render_document + +TYPST = shutil.which("typst") + + +def _make_pdf(path: Path, n_pages: int) -> None: + doc = fitz.open() + for i in range(n_pages): + page = doc.new_page(width=300, height=200) + page.insert_text((20, 40), f"Original page {i} native text") + doc.save(str(path)) + doc.close() + + +def _compacted_parsed(page_indices: list[int]) -> dict: + """A parsed doc compacted to `page_indices` (mirrors parse_pdf(pages=...)).""" + return { + "pages": [ + { + "page_index": pi, + "page_width": 300, + "page_height": 200, + "elements": [ + { + "category": "FLOWING_TEXT", + "label": "Text", + "bbox_pdf": [20, 30, 280, 55], + "source_text": f"Original page {pi} native text", + "translated_text": f"Trang dịch số {pi}", + } + ], + } + for pi in page_indices + ] + } + + +@pytest.mark.skipif(TYPST is None, reason="typst binary not installed") +class TestArbitraryPageRange: + def test_midrange_renders_and_outputs_only_selected(self, tmp_path): + pdf_path = tmp_path / "src.pdf" + _make_pdf(pdf_path, n_pages=6) + + pages = [2, 3, 4] # 0-based, NOT starting at 0 + parsed = _compacted_parsed(pages) + cfg = RenderConfig(typst_binary=TYPST) + cfg.pages = pages + out_pdf = tmp_path / "out.pdf" + + stats = render_document(pdf_path, parsed, out_pdf, cfg) + + # Rendered all 3 selected pages (the pre-fix bug rendered 0). + assert stats["pages"] == 3 + assert stats["elements_rendered"] == 3 + + # Output contains ONLY the translated pages, not the full 6-page doc. + out = fitz.open(str(out_pdf)) + try: + assert out.page_count == 3 + # Translated text present; the out-of-range page-0 text must be gone. + assert "Trang dịch số 2" in out[0].get_text() + assert "Original page 0" not in out[0].get_text() + finally: + out.close() + + def test_pages_none_keeps_all_pages(self, tmp_path): + pdf_path = tmp_path / "src.pdf" + _make_pdf(pdf_path, n_pages=3) + + parsed = _compacted_parsed([0, 1, 2]) + cfg = RenderConfig(typst_binary=TYPST) + cfg.pages = None # translate whole document + out_pdf = tmp_path / "out.pdf" + + render_document(pdf_path, parsed, out_pdf, cfg) + + out = fitz.open(str(out_pdf)) + try: + assert out.page_count == 3 + finally: + out.close() + + +_PARA = ( + "This paragraph is long enough in the source that autofit picks a modest " + "font size and the box is treated as multi line rather than single line." +) +_LONG = _PARA + " " + _PARA + " " + _PARA # translated overflows the tight box + + +def _para_span_stats(pdf: Path): + doc = fitz.open(str(pdf)) + try: + sizes, bottoms = [], [] + for b in doc[0].get_text("dict")["blocks"]: + for line in b.get("lines", []): + for s in line["spans"]: + if "paragraph" in s["text"] or "multi" in s["text"]: + sizes.append(s["size"]) + bottoms.append(s["bbox"][3]) + return (max(sizes) if sizes else 0.0), (max(bottoms) if bottoms else 0.0) + finally: + doc.close() + + +@pytest.mark.skipif(TYPST is None, reason="typst binary not installed") +class TestCollisionAwareSizing: + """Text keeps its size and overflows into empty space, but shrinks to avoid + colliding with a neighbor below (mirrors the sizing heuristic).""" + + def _render(self, tmp_path, with_neighbor): + pdf_path = tmp_path / f"src_{with_neighbor}.pdf" + doc = fitz.open() + doc.new_page(width=400, height=400) + doc.save(str(pdf_path)) + doc.close() + + els = [ + { + "label": "Text", + "category": "FLOWING_TEXT", + "bbox_pdf": [40, 40, 360, 110], + "source_text": _PARA, + "translated_text": _LONG, + "cells": [], + } + ] + if with_neighbor: + els.append( + { + "label": "Text", + "category": "FLOWING_TEXT", + "bbox_pdf": [40, 116, 360, 150], + "source_text": "N", + "translated_text": "Neighbor line", + "cells": [], + } + ) + parsed = { + "pages": [ + { + "page_index": 0, + "page_width": 400, + "page_height": 400, + "elements": els, + } + ] + } + out = tmp_path / f"out_{with_neighbor}.pdf" + render_document(pdf_path, parsed, out, RenderConfig(typst_binary=TYPST)) + return _para_span_stats(out) + + def test_no_neighbor_keeps_larger_size_and_overflows_down(self, tmp_path): + size_free, bottom_free = self._render(tmp_path, with_neighbor=False) + size_near, _ = self._render(tmp_path, with_neighbor=True) + # Empty space below → keeps a bigger font than when a neighbor forces a shrink. + assert size_free > size_near + 0.5 + # And the text is allowed to overflow below the tight bbox (y1 = 110). + assert bottom_free > 110 + + _LONG_TITLE = ( + "A very long translated chapter title that would run past the page number " + "column if it were allowed to expand all the way to the right page margin" + ) + + def _title_right_edge(self, tmp_path, with_number): + pdf_path = tmp_path / f"toc_{with_number}.pdf" + doc = fitz.open() + doc.new_page(width=595, height=842) + doc.save(str(pdf_path)) + doc.close() + + els = [ + { + "label": "Text", + "category": "FLOWING_TEXT", + "bbox_pdf": [80, 40, 290, 56], # single-line title + "source_text": "Short", + "translated_text": self._LONG_TITLE, + "cells": [], + } + ] + if with_number: + els.append( + { + "label": "Text", + "category": "FLOWING_TEXT", + "bbox_pdf": [527, 40, 543, 53], # page number, same row + "source_text": "ii", + "translated_text": "ii", + "cells": [], + } + ) + parsed = { + "pages": [ + { + "page_index": 0, + "page_width": 595, + "page_height": 842, + "elements": els, + } + ] + } + out = tmp_path / f"toc_out_{with_number}.pdf" + render_document(pdf_path, parsed, out, RenderConfig(typst_binary=TYPST)) + doc = fitz.open(str(out)) + try: + right = 0.0 + for b in doc[0].get_text("dict")["blocks"]: + for line in b.get("lines", []): + for s in line["spans"]: + if s["text"].strip() != "ii": + right = max(right, s["bbox"][2]) + return right + finally: + doc.close() + + def test_single_line_title_stops_before_right_neighbor(self, tmp_path): + with_num = self._title_right_edge(tmp_path, with_number=True) + without_num = self._title_right_edge(tmp_path, with_number=False) + # With a page number on the right, the title must not overrun its left edge. + assert with_num <= 527.5 + # Without it, the title is free to use the rest of the page width. + assert without_num > with_num + + +class TestColorOverride: + """User-added boxes may carry explicit bg/text colors (review.add_element).""" + + def test_sample_colors_honors_element_overrides(self, tmp_path): + from pdf2zh.render.renderer import _sample_colors + + pdf = tmp_path / "p.pdf" + _make_pdf(pdf, 1) + parsed = { + "pages": [ + { + "page_index": 0, + "page_width": 300, + "page_height": 200, + "elements": [ + { + "category": "FLOWING_TEXT", + "label": "Text", + "bbox_pdf": [20, 30, 120, 55], + "source_text": "x", + "translated_text": "y", + "bg_color": [10, 20, 30], + "text_color": [200, 100, 50], + } + ], + } + ] + } + bg_colors: dict = {} + text_colors: dict = {} + stats = {"bg_samples": 0} + _sample_colors(pdf, parsed, RenderConfig(), {}, bg_colors, text_colors, stats) + # Override used verbatim — no sampling from the page pixels. + assert bg_colors["p0:e0"] == (10, 20, 30) + assert text_colors["p0:e0"] == (200, 100, 50) diff --git a/test/test_render_repair.py b/test/test_render_repair.py new file mode 100644 index 0000000000000000000000000000000000000000..0433eb1c03656d462e23d4096cd20bb151a195d3 --- /dev/null +++ b/test/test_render_repair.py @@ -0,0 +1,140 @@ +"""Tests for the self-healing Typst compile pipeline. + +Layer 1 (markup sanitizer) outputs must compile with the real typst binary. +Layer 2 (renderer repair loop) must map compile errors back to element vars +and downgrade them to plain text instead of failing the whole document. +""" + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from pdf2zh.render.config import RenderConfig +from pdf2zh.render.markup import to_typst_native +from pdf2zh.render.renderer import _failing_element_vars, render_document +from pdf2zh.render.source_builder import build_typst_source + +TYPST = shutil.which("typst") + + +class TestFailingElementVars: + SOURCE = "\n".join( + [ + "#set page(width: 100pt, height: 100pt)", # line 1 + "#let e0_0_tm = [good markup]", # line 2 + "#let e0_1_tm = [broken", # line 3 + " still broken]", # line 4 + '#let e0_1_c2_md = "cell"', # line 5 + ] + ) + + def test_maps_error_line_to_element_var(self): + stderr = "error: expected expression\n ┌─ ../tmp/x/overlay.typ:4:2\n" + assert _failing_element_vars(self.SOURCE, stderr) == {"e0_1"} + + def test_maps_cell_var(self): + stderr = "┌─ overlay.typ:5:1\n" + assert _failing_element_vars(self.SOURCE, stderr) == {"e0_1_c2"} + + def test_error_outside_elements_not_attributed(self): + stderr = "┌─ overlay.typ:1:1\n" + assert _failing_element_vars(self.SOURCE, stderr) == set() + + def test_multiple_errors_collected(self): + stderr = "┌─ overlay.typ:3:5\n...\n┌─ overlay.typ:5:1\n" + assert _failing_element_vars(self.SOURCE, stderr) == {"e0_1", "e0_1_c2"} + + +def _parsed_with(translated: str, category: str = "FLOWING_TEXT") -> dict: + return { + "pages": [ + { + "page_width": 200, + "page_height": 100, + "elements": [ + { + "category": category, + "label": "Text", + "bbox_pdf": [10, 10, 190, 40], + "source_text": "Source", + "translated_text": translated, + } + ], + } + ] + } + + +class TestFallbackVars: + def test_fallback_var_uses_plain_markdown_path(self): + parsed = _parsed_with("<typst>broken #let ( markup</typst>") + cfg = RenderConfig() + source = build_typst_source(parsed, {"p0:e0": 10}, {}, {}, cfg) + assert "#let e0_0_tm = [" in source + + source_fb = build_typst_source( + parsed, {"p0:e0": 10}, {}, {}, cfg, fallback_vars={"e0_0"} + ) + assert "#let e0_0_tm = [" not in source_fb + assert '#let e0_0_md = "' in source_fb + + def test_equation_fallback_uses_markdown_path(self): + parsed = _parsed_with("x <math>a+b</math>", category="EQUATION") + cfg = RenderConfig() + source_fb = build_typst_source( + parsed, {"p0:e0": 10}, {}, {}, cfg, fallback_vars={"e0_0"} + ) + assert '#let e0_0_md = "' in source_fb + + +@pytest.mark.skipif(TYPST is None, reason="typst binary not installed") +class TestSanitizerOutputCompiles: + @pytest.mark.parametrize( + "text", + [ + "<math>page_index = k</math>", + '<math>upright("page_index") = k</math>', + '<math>TP(t) = #{"pairs" "with" IoU >= t}</math>', + "<math>_(x)</math>", + "<math>x_</math>", + '<math>x = "unclosed</math>', + "<typst>page $page_index$ = k</typst>", + ], + ) + def test_output_compiles(self, text, tmp_path): + typ = tmp_path / "t.typ" + typ.write_text(to_typst_native(text) + "\n", encoding="utf-8") + result = subprocess.run( + [TYPST, "compile", str(typ), str(tmp_path / "t.pdf")], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.skipif(TYPST is None, reason="typst binary not installed") +class TestRepairLoopEndToEnd: + def test_broken_element_downgraded_instead_of_failing(self, tmp_path): + import fitz + + pdf_path = tmp_path / "src.pdf" + doc = fitz.open() + doc.new_page(width=200, height=100) + doc.save(str(pdf_path)) + doc.close() + + # Raw <typst> passthrough with invalid markup: survives the static + # sanity gates, only the compiler can catch it. + parsed = _parsed_with("<typst>broken #let ( markup</typst>") + cfg = RenderConfig(typst_binary=TYPST) + out_pdf = tmp_path / "out.pdf" + + stats = render_document(pdf_path, parsed, out_pdf, cfg) + + assert out_pdf.exists() + assert stats["elements_fallback"] == 1 diff --git a/test/test_render_sizing.py b/test/test_render_sizing.py new file mode 100644 index 0000000000000000000000000000000000000000..a1052f1455d9a4501b5aa762a4cbe6f5b59d9ea0 --- /dev/null +++ b/test/test_render_sizing.py @@ -0,0 +1,290 @@ +"""Unit tests for pdf2zh.render.sizing — font size clustering.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from pdf2zh.render.config import SizingConfig +from pdf2zh.render.sizing import _greedy_cluster, assign_render_sizes + + +def _make_doc( + elements: list[dict], page_width: float = 612, page_height: float = 792 +) -> dict: + return { + "pages": [ + { + "page_index": 0, + "page_width": page_width, + "page_height": page_height, + "elements": elements, + } + ] + } + + +class TestGreedyCluster: + def test_single_cluster(self): + items = [(10.5, "a"), (10.7, "b"), (11.0, "c")] + clusters = _greedy_cluster(items, eps=1.5) + assert len(clusters) == 1 + assert len(clusters[0]) == 3 + + def test_two_clusters(self): + items = [(10.5, "a"), (11.0, "b"), (14.0, "c"), (14.5, "d")] + clusters = _greedy_cluster(items, eps=1.5) + assert len(clusters) == 2 + + def test_three_clusters(self): + items = [(10.5, "a"), (14.0, "b"), (18.0, "c")] + clusters = _greedy_cluster(items, eps=1.5) + assert len(clusters) == 3 + + def test_empty(self): + assert _greedy_cluster([], eps=1.5) == [] + + def test_single_item(self): + clusters = _greedy_cluster([(11.0, "a")], eps=1.5) + assert len(clusters) == 1 + assert clusters[0] == [(11.0, "a")] + + +class TestAssignRenderSizes: + def _cfg(self, **kwargs) -> SizingConfig: + return SizingConfig(**kwargs) + + def test_body_text_consistent(self): + doc = _make_doc( + [ + { + "label": "Text", + "category": "FLOWING_TEXT", + "font_size": 10.7, + "bbox_pdf": [0, 0, 100, 20], + "cells": [], + }, + { + "label": "Text", + "category": "FLOWING_TEXT", + "font_size": 11.0, + "bbox_pdf": [0, 25, 100, 45], + "cells": [], + }, + { + "label": "Text", + "category": "FLOWING_TEXT", + "font_size": 11.3, + "bbox_pdf": [0, 50, 100, 70], + "cells": [], + }, + ] + ) + cfg = self._cfg() + sizes = assign_render_sizes(doc, cfg) + # All should snap to same cluster (within eps=1.5) + assert sizes["p0:e0"] == sizes["p0:e1"] == sizes["p0:e2"] + + def test_heading_hierarchy_preserved(self): + doc = _make_doc( + [ + { + "label": "SectionHeader", + "category": "IN_PLACE", + "font_size": 10.0, + "bbox_pdf": [0, 0, 100, 20], + "cells": [], + }, + { + "label": "SectionHeader", + "category": "IN_PLACE", + "font_size": 14.0, + "bbox_pdf": [0, 25, 100, 45], + "cells": [], + }, + { + "label": "SectionHeader", + "category": "IN_PLACE", + "font_size": 18.0, + "bbox_pdf": [0, 50, 100, 70], + "cells": [], + }, + ] + ) + cfg = self._cfg() + sizes = assign_render_sizes(doc, cfg) + # Three distinct sizes + assert sizes["p0:e0"] < sizes["p0:e1"] < sizes["p0:e2"] + + def test_fallback_for_zero_size(self): + doc = _make_doc( + [ + { + "label": "Text", + "category": "FLOWING_TEXT", + "font_size": 0.0, + "bbox_pdf": [0, 0, 100, 20], + "cells": [], + }, + ] + ) + cfg = self._cfg(fallback_size=11.0) + sizes = assign_render_sizes(doc, cfg) + assert sizes["p0:e0"] == 11.0 + + def test_bypass_not_included(self): + doc = _make_doc( + [ + { + "label": "Figure", + "category": "BYPASS", + "font_size": 0.0, + "bbox_pdf": [0, 0, 100, 100], + "cells": [], + }, + ] + ) + cfg = self._cfg() + sizes = assign_render_sizes(doc, cfg) + # BYPASS elements are not in any cluster group, so not assigned + assert "p0:e0" not in sizes or True # Acceptable: either absent or fallback + + def test_table_cells_cluster_per_table(self): + doc = _make_doc( + [ + { + "label": "Table", + "category": "TABLE", + "font_size": 10.0, + "bbox_pdf": [0, 0, 300, 100], + "cells": [ + { + "bbox_pdf": [0, 0, 100, 20], + "source_text": "A", + "translated_text": "B", + "cell_font_size": 10.0, + }, + { + "bbox_pdf": [100, 0, 200, 20], + "source_text": "C", + "translated_text": "D", + "cell_font_size": 10.5, + }, + ], + }, + ] + ) + cfg = self._cfg() + sizes = assign_render_sizes(doc, cfg) + # Both cells should get the same cluster size + assert sizes["p0:e0:c0"] == sizes["p0:e0:c1"] + + def test_equation_clusters_with_body(self): + doc = _make_doc( + [ + { + "label": "Text", + "category": "FLOWING_TEXT", + "font_size": 11.0, + "bbox_pdf": [0, 0, 100, 20], + "cells": [], + }, + { + "label": "Equation", + "category": "EQUATION", + "font_size": 11.2, + "bbox_pdf": [0, 25, 100, 45], + "cells": [], + }, + ] + ) + cfg = self._cfg() + cfg.cluster_groups["body"].append("Equation") + sizes = assign_render_sizes(doc, cfg) + # Both in "body" group → should cluster together (within eps) + assert sizes["p0:e0"] == sizes["p0:e1"] + + def test_page_scope_header_footer(self): + # Page-scope: each page's header/footer clusters independently + pages = [ + { + "page_index": 0, + "page_width": 612, + "page_height": 792, + "elements": [ + { + "label": "PageHeader", + "category": "IN_PLACE", + "font_size": 9.0, + "bbox_pdf": [0, 0, 600, 20], + "cells": [], + }, + ], + }, + { + "page_index": 1, + "page_width": 612, + "page_height": 792, + "elements": [ + { + "label": "PageHeader", + "category": "IN_PLACE", + "font_size": 10.0, + "bbox_pdf": [0, 0, 600, 20], + "cells": [], + }, + ], + }, + ] + doc = {"pages": pages} + cfg = self._cfg() + sizes = assign_render_sizes(doc, cfg) + # They're in separate page-scoped buckets, so cluster separately + assert "p0:e0" in sizes + assert "p1:e0" in sizes + + def test_colliding_shrink_never_inflates_above_canonical(self): + # Regression: on a page whose cluster canonical is below fallback_size, + # a block whose translated text overflows AND collides with a neighbor + # used to be clamped UP to fallback (11pt) — larger than its cluster — + # instead of shrunk. It must stay at (or below) the canonical. + doc = _make_doc( + [ + # e0 overflows at the 9pt canonical and collides with e1 below it. + { + "label": "Text", + "category": "FLOWING_TEXT", + "font_size": 9.0, + "source_text": "", + "translated_text": "x" * 220, + "bbox_pdf": [0, 0, 100, 20], + "cells": [], + }, + # e1 sits directly below e0 (tiny gap) → e0's overflow hits it. + { + "label": "Text", + "category": "FLOWING_TEXT", + "font_size": 9.0, + "source_text": "", + "translated_text": "", + "bbox_pdf": [0, 22, 100, 42], + "cells": [], + }, + # e2 sets the cluster too, off to the side (no collision). + { + "label": "Text", + "category": "FLOWING_TEXT", + "font_size": 9.0, + "source_text": "", + "translated_text": "", + "bbox_pdf": [300, 0, 400, 20], + "cells": [], + }, + ] + ) + cfg = self._cfg() + sizes = assign_render_sizes(doc, cfg) + # The colliding block stays uniform with its cluster (9pt), and is never + # inflated up to fallback (11pt) by the readability floor. + assert sizes["p0:e0"] == sizes["p0:e2"] + assert sizes["p0:e0"] < cfg.fallback_size diff --git a/test/test_render_source_builder.py b/test/test_render_source_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..9c5d7e5d12d943b297b7d3ce0b3d1069f1240d32 --- /dev/null +++ b/test/test_render_source_builder.py @@ -0,0 +1,132 @@ +"""Unit tests for table-cell rendering decisions.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from pdf2zh.render.config import RenderConfig +from pdf2zh.render.source_builder import ( + _downward_avail_height, + _rightward_avail_width, + build_typst_source, +) + + +class TestDownwardAvailHeight: + """Collision-aware block height: overflow into empty space, not into neighbors.""" + + BBOX = [40, 40, 360, 70] # tight height = 30 + + def test_no_neighbor_expands_by_max(self): + # No element below → expand down by max_expand (bounded by page). + h = _downward_avail_height( + self.BBOX, [self.BBOX], page_height=800, max_expand=80 + ) + assert h == 30 + 80 # (70 + 80) - 40 + + def test_neighbor_below_caps_expansion(self): + neighbor = [40, 120, 360, 160] # starts at y=120, overlaps horizontally + h = _downward_avail_height( + self.BBOX, [self.BBOX, neighbor], page_height=800, max_expand=80 + ) + assert h == 120 - 40 # capped at the neighbor's top + + def test_non_overlapping_neighbor_ignored(self): + # A box below but in a different column must not cap the expansion. + side = [400, 120, 500, 160] + h = _downward_avail_height( + self.BBOX, [self.BBOX, side], page_height=800, max_expand=80 + ) + assert h == 30 + 80 + + def test_page_bottom_bounds_expansion(self): + h = _downward_avail_height( + self.BBOX, [self.BBOX], page_height=90, max_expand=80 + ) + assert h == 90 - 40 # page bottom closer than max_expand + + +class TestRightwardAvailWidth: + """Single-line width: extend right into empty space, stop at a right neighbor.""" + + BBOX = [80, 40, 290, 56] # a TOC title on the left + + def test_no_neighbor_expands_to_page_edge(self): + w = _rightward_avail_width(self.BBOX, [self.BBOX], page_width=595) + assert w == 595 - 80 + + def test_right_neighbor_caps_width(self): + page_num = [527, 40, 543, 53] # right-hand page number, same row + w = _rightward_avail_width(self.BBOX, [self.BBOX, page_num], page_width=595) + assert w == 527 - 80 # stops at the page number's left edge + + def test_neighbor_on_other_row_ignored(self): + below = [527, 200, 543, 213] # to the right but a different row + w = _rightward_avail_width(self.BBOX, [self.BBOX, below], page_width=595) + assert w == 595 - 80 + + +def test_table_cell_without_source_text_is_not_rendered(): + parsed = { + "pages": [ + { + "page_width": 200, + "page_height": 100, + "elements": [ + { + "category": "TABLE", + "label": "Table", + "bbox_pdf": [0, 0, 200, 100], + "cells": [ + { + "bbox_pdf": [0, 0, 100, 20], + "source_text": "Source", + "translated_text": "Translated", + }, + { + "bbox_pdf": [100, 0, 200, 20], + "translated_text": "Must remain original", + }, + ], + } + ], + } + ] + } + + source = build_typst_source( + parsed, + {"p0:e0:c0": 10, "p0:e0:c1": 10}, + {}, + {}, + RenderConfig(), + ) + + assert "Translated" in source + assert "Must remain original" not in source + + +def test_native_typst_content_is_embedded_without_eval(): + parsed = { + "pages": [ + { + "page_width": 200, + "page_height": 100, + "elements": [ + { + "category": "EQUATION", + "label": "Equation", + "bbox_pdf": [0, 0, 100, 20], + "source_text": "Source", + "translated_text": "<math>arrow.r.double</math>", + } + ], + } + ] + } + + source = build_typst_source(parsed, {"p0:e0": 10}, {}, {}, RenderConfig()) + + assert 'eval(markup, mode: "markup")' not in source + assert "#let e0_0_tm = [$arrow.r.double$]" in source diff --git a/test/test_review.py b/test/test_review.py new file mode 100644 index 0000000000000000000000000000000000000000..e2489eb92786a7610cd5c70dc92b2a0b0d472ab4 --- /dev/null +++ b/test/test_review.py @@ -0,0 +1,511 @@ +"""Unit tests for the Phase-1/Phase-3 review helpers (pdf2zh/webapp/review.py).""" + +import sys +from pathlib import Path + +import fitz + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from pdf2zh.webapp.review import ( + add_element, + apply_phase1_cell_edit, + apply_phase1_edit, + apply_phase2_cell_edit, + apply_phase2_edit, + hex_to_rgb, + hit_test, + normalize_click, + output_page_position, + overlay_svg, + render_page_plain, + render_page_with_boxes, +) + + +def _doc_with(elements): + return { + "pages": [ + { + "page_index": 0, + "page_width": 300, + "page_height": 200, + "elements": elements, + } + ] + } + + +class TestHitTest: + def test_smallest_box_wins_on_overlap(self): + boxes = [ + {"elem_idx": 0, "bbox_img": [0, 0, 300, 200], "category": "BYPASS"}, + {"elem_idx": 1, "bbox_img": [10, 10, 60, 40], "category": "IN_PLACE"}, + ] + # inside both -> smaller (idx 1); neither box has cell_idx -> None + assert hit_test(boxes, 30, 25) == (1, None) + assert hit_test(boxes, 200, 150) == (0, None) # only the big one + assert hit_test(boxes, 999, 999) is None # outside all + + def test_hits_a_table_cell(self): + boxes = [ + { + "elem_idx": 2, + "cell_idx": 0, + "bbox_img": [0, 0, 50, 20], + "category": "TABLE", + }, + { + "elem_idx": 2, + "cell_idx": 1, + "bbox_img": [50, 0, 100, 20], + "category": "TABLE", + }, + ] + assert hit_test(boxes, 10, 10) == (2, 0) + assert hit_test(boxes, 60, 10) == (2, 1) + + +class TestApplyPhase1Edit: + def test_reclassify_picture_to_pageheader_sets_category(self): + doc = _doc_with( + [ + { + "label": "Picture", + "category": "BYPASS", + "bbox_pdf": [0, 0, 10, 10], + "source_text": "", + "translated_text": "", + } + ] + ) + msg = apply_phase1_edit(doc, 0, 0, "PageHeader", "Chapter 1", bypass=False) + assert msg is None + elem = doc["pages"][0]["elements"][0] + assert elem["label"] == "PageHeader" + assert elem["category"] == "FLOWING_TEXT" + assert elem["source_text"] == "Chapter 1" + + def test_bypass_sets_bypass_category(self): + doc = _doc_with( + [ + { + "label": "Text", + "category": "FLOWING_TEXT", + "bbox_pdf": [0, 0, 10, 10], + "source_text": "x", + "translated_text": "", + } + ] + ) + apply_phase1_edit(doc, 0, 0, "Text", "x", bypass=True) + assert doc["pages"][0]["elements"][0]["category"] == "BYPASS" + + def test_unbypass_restores_derived_category(self): + doc = _doc_with( + [ + { + "label": "SectionHeader", + "category": "BYPASS", + "bbox_pdf": [0, 0, 10, 10], + "source_text": "x", + "translated_text": "", + } + ] + ) + apply_phase1_edit(doc, 0, 0, "SectionHeader", "x", bypass=False) + assert doc["pages"][0]["elements"][0]["category"] == "FLOWING_TEXT" + + def test_changing_to_table_is_blocked(self): + doc = _doc_with( + [ + { + "label": "Text", + "category": "FLOWING_TEXT", + "bbox_pdf": [0, 0, 10, 10], + "source_text": "x", + "translated_text": "", + } + ] + ) + msg = apply_phase1_edit(doc, 0, 0, "Table", "x", bypass=False) + assert msg is not None + assert doc["pages"][0]["elements"][0]["label"] == "Text" # unchanged + + def test_missing_element_returns_message(self): + doc = _doc_with([]) + assert apply_phase1_edit(doc, 0, 5, "Text", "x", bypass=False) is not None + + +class TestApplyPhase2Edit: + def test_sets_translated_text(self): + doc = _doc_with( + [ + { + "label": "Text", + "category": "FLOWING_TEXT", + "bbox_pdf": [0, 0, 10, 10], + "source_text": "hi", + "translated_text": "old", + } + ] + ) + apply_phase2_edit(doc, 0, 0, "xin chào") + assert doc["pages"][0]["elements"][0]["translated_text"] == "xin chào" + + +def _table_doc(): + return _doc_with( + [ + { + "label": "Table", + "category": "TABLE", + "bbox_pdf": [0, 0, 100, 20], + "source_text": "", + "translated_text": "", + "cells": [ + { + "bbox_pdf": [0, 0, 50, 20], + "bbox_text": [0, 0, 50, 20], + "source_text": "hi", + "translated_text": "", + }, + { + "bbox_pdf": [50, 0, 100, 20], + "bbox_text": [50, 0, 100, 20], + "source_text": "there", + "translated_text": "", + }, + ], + } + ] + ) + + +class TestApplyPhase1CellEdit: + def test_sets_cell_source_text(self): + doc = _table_doc() + msg = apply_phase1_cell_edit(doc, 0, 0, 1, "xin") + assert msg is None + assert doc["pages"][0]["elements"][0]["cells"][1]["source_text"] == "xin" + + def test_missing_cell_returns_message(self): + doc = _table_doc() + assert apply_phase1_cell_edit(doc, 0, 0, 5, "x") is not None + + +class TestApplyPhase2CellEdit: + def test_sets_cell_translated_text(self): + doc = _table_doc() + msg = apply_phase2_cell_edit(doc, 0, 0, 0, "chào") + assert msg is None + assert doc["pages"][0]["elements"][0]["cells"][0]["translated_text"] == "chào" + + def test_missing_cell_returns_message(self): + doc = _table_doc() + assert apply_phase2_cell_edit(doc, 0, 0, 5, "x") is not None + + +class TestAddElement: + def test_append_keeps_indices_and_derives_category(self): + doc = _doc_with( + [ + { + "label": "Text", + "category": "FLOWING_TEXT", + "bbox_pdf": [0, 0, 10, 10], + "source_text": "a", + "translated_text": "", + } + ] + ) + idx = add_element(doc, 0, [20, 20, 80, 40], "Caption", "một chú thích") + assert idx == 1 + elem = doc["pages"][0]["elements"][1] + assert elem["category"] == "IN_PLACE" # Caption -> IN_PLACE + assert elem["source_text"] == "một chú thích" + assert elem["translated_text"] == "" + + def test_no_color_keys_when_not_provided(self): + doc = _doc_with([]) + add_element(doc, 0, [0, 0, 10, 10], "Text", "x") + elem = doc["pages"][0]["elements"][0] + assert "bg_color" not in elem + assert "text_color" not in elem + + def test_stores_color_overrides_when_provided(self): + doc = _doc_with([]) + add_element( + doc, + 0, + [0, 0, 10, 10], + "Text", + "x", + bg_color=[255, 200, 0], + text_color=[10, 20, 30], + ) + elem = doc["pages"][0]["elements"][0] + assert elem["bg_color"] == [255, 200, 0] + assert elem["text_color"] == [10, 20, 30] + + +class TestHexToRgb: + def test_six_digit_hex(self): + assert hex_to_rgb("#ffcc00") == [255, 204, 0] + + def test_three_digit_hex_expands(self): + assert hex_to_rgb("#fc0") == [255, 204, 0] + + def test_rgb_and_rgba_forms(self): + assert hex_to_rgb("rgb(255, 204, 0)") == [255, 204, 0] + assert hex_to_rgb("rgba(255, 204, 0, 0.5)") == [255, 204, 0] + + def test_bad_values_return_none(self): + assert hex_to_rgb(None) is None + assert hex_to_rgb("") is None + assert hex_to_rgb("#12") is None + assert hex_to_rgb("not-a-color") is None + assert hex_to_rgb("rgb(1, 2)") is None + + +class TestOutputPagePosition: + def test_range(self): + assert output_page_position([2, 3, 4], 3) == 1 + assert output_page_position([4, 2, 3], 4) == 2 # sorted -> [2,3,4] + assert output_page_position([2, 3, 4], 5) is None + + def test_none_is_identity(self): + assert output_page_position(None, 7) == 7 + + +class TestNormalizeClick: + def test_tuple_ok(self): + assert normalize_click((12, 34)) == (12.0, 34.0) + assert normalize_click([12, 34, 0]) == (12.0, 34.0) + + def test_bad_returns_none(self): + assert normalize_click(None) is None + assert normalize_click(5) is None + + +class TestRenderPageWithBoxes: + def test_boxes_scaled_to_image_pixels(self, tmp_path): + pdf_path = tmp_path / "p.pdf" + doc = fitz.open() + doc.new_page(width=300, height=200) + doc.save(str(pdf_path)) + doc.close() + + elements = [ + { + "label": "Text", + "category": "FLOWING_TEXT", + "bbox_pdf": [10, 20, 110, 60], + "source_text": "", + "translated_text": "", + }, + { + "label": "Picture", + "category": "BYPASS", + "bbox_pdf": [0, 0, 300, 200], + "source_text": "", + "translated_text": "", + }, + ] + # dpi=72 -> scale 1.0 -> bbox_img == bbox_pdf, image == page size. + img, boxes, scale = render_page_with_boxes(str(pdf_path), 0, elements, dpi=72) + assert scale == 1.0 + assert img.size == (300, 200) + assert len(boxes) == 2 + assert boxes[0]["bbox_img"] == [10, 20, 110, 60] + # hit_test on the returned boxes selects the small element. + assert hit_test(boxes, 50, 40) == (0, None) + + def test_table_draws_one_box_per_cell(self, tmp_path): + pdf_path = tmp_path / "p.pdf" + doc = fitz.open() + doc.new_page(width=300, height=200) + doc.save(str(pdf_path)) + doc.close() + + elements = [ + { + "label": "Table", + "category": "TABLE", + "bbox_pdf": [0, 0, 100, 20], + "source_text": "", + "translated_text": "", + "cells": [ + { + "bbox_pdf": [0, 0, 50, 20], + "bbox_text": [0, 0, 50, 20], + "source_text": "hi", + "translated_text": "", + }, + { + "bbox_pdf": [50, 0, 100, 20], + "bbox_text": [50, 0, 100, 20], + "source_text": "there", + "translated_text": "", + }, + ], + } + ] + _, boxes, _ = render_page_with_boxes(str(pdf_path), 0, elements, dpi=72) + assert len(boxes) == 2 # per-cell, not one box for the whole table + assert [b["cell_idx"] for b in boxes] == [0, 1] + assert all(b["elem_idx"] == 0 for b in boxes) + assert hit_test(boxes, 10, 10) == (0, 0) + assert hit_test(boxes, 60, 10) == (0, 1) + + +_OVERLAY_ELEMENTS = [ + { + "label": "Text", + "category": "FLOWING_TEXT", + "bbox_pdf": [10, 20, 110, 60], + "source_text": "", + "translated_text": "", + }, + { + "label": "Picture", + "category": "BYPASS", + "bbox_pdf": [0, 0, 300, 200], + "source_text": "", + "translated_text": "", + }, +] + + +class TestRenderPagePlain: + def test_size_matches_render_page_with_boxes(self, tmp_path): + pdf_path = tmp_path / "p.pdf" + doc = fitz.open() + doc.new_page(width=300, height=200) + doc.save(str(pdf_path)) + doc.close() + + # dpi=72 -> scale 1.0 -> image == page size, and matches the boxed render. + img, size = render_page_plain(str(pdf_path), 0, dpi=72) + assert size == (300, 200) + assert img.size == (300, 200) + boxed, _, _ = render_page_with_boxes(str(pdf_path), 0, [], dpi=72) + assert img.size == boxed.size + + def test_out_of_range_raises(self, tmp_path): + pdf_path = tmp_path / "p.pdf" + doc = fitz.open() + doc.new_page(width=100, height=100) + doc.save(str(pdf_path)) + doc.close() + import pytest + + with pytest.raises(IndexError): + render_page_plain(str(pdf_path), 5, dpi=72) + + +class TestOverlaySvg: + def test_boxes_identical_to_render_page_with_boxes(self, tmp_path): + pdf_path = tmp_path / "p.pdf" + doc = fitz.open() + doc.new_page(width=300, height=200) + doc.save(str(pdf_path)) + doc.close() + + _, ref_boxes, scale = render_page_with_boxes( + str(pdf_path), 0, _OVERLAY_ELEMENTS, dpi=72 + ) + _, boxes = overlay_svg(_OVERLAY_ELEMENTS, scale, 300, 200) + assert boxes == ref_boxes + # hit_test behaves the same on both box lists. + assert hit_test(boxes, 50, 40) == (0, None) + + def test_svg_has_rect_per_element_and_highlight_stroke(self): + svg, boxes = overlay_svg(_OVERLAY_ELEMENTS, 1.0, 300, 200, highlight_idx=0) + assert svg.startswith('<svg viewBox="0 0 300 200"') + assert svg.count("<rect") == len(_OVERLAY_ELEMENTS) == 2 + # label tag per element. + assert svg.count("<text") == 2 + # highlighted element -> red stroke; bypass element -> gray stroke. + assert "rgb(230,30,30)" in svg + assert "rgb(150,150,150)" in svg + + def test_box_tag_shows_label_not_index(self): + elements = [ + {"label": "Text", "category": "FLOWING_TEXT", "bbox_pdf": [0, 0, 10, 10]}, + {"label": "Caption", "category": "IN_PLACE", "bbox_pdf": [20, 0, 30, 10]}, + ] + svg, _ = overlay_svg(elements, 1.0, 50, 50) + assert ">Text</text>" in svg + assert ">Caption</text>" in svg + assert ">0</text>" not in svg + assert ">1</text>" not in svg + + def test_skips_elements_without_valid_bbox(self): + elements = [ + {"category": "FLOWING_TEXT"}, # no bbox_pdf + {"category": "FLOWING_TEXT", "bbox_pdf": [1, 2, 3]}, # wrong length + {"category": "FLOWING_TEXT", "bbox_pdf": [0, 0, 10, 10]}, + ] + svg, boxes = overlay_svg(elements, 1.0, 50, 50) + assert len(boxes) == 1 + assert boxes[0]["elem_idx"] == 2 + + def test_table_draws_one_rect_per_cell_plus_dashed_outer_border(self): + elements = [ + { + "label": "Table", + "category": "TABLE", + "bbox_pdf": [0, 0, 100, 20], + "source_text": "", + "translated_text": "", + "cells": [ + { + "bbox_pdf": [0, 0, 50, 20], + "bbox_text": [0, 0, 50, 20], + "source_text": "hi", + "translated_text": "", + }, + { + "bbox_pdf": [50, 0, 100, 20], + "bbox_text": [50, 0, 100, 20], + "source_text": "there", + "translated_text": "", + }, + ], + } + ] + svg, boxes = overlay_svg(elements, 1.0, 100, 20) + assert len(boxes) == 2 # per-cell boxes, not one for the whole table + assert [b["cell_idx"] for b in boxes] == [0, 1] + # 2 cell rects + 1 dashed outer border rect. + assert svg.count("<rect") == 3 + assert "stroke-dasharray" in svg + + def test_table_cell_highlight_reddens_only_that_cell(self): + elements = [ + { + "label": "Table", + "category": "TABLE", + "bbox_pdf": [0, 0, 100, 20], + "source_text": "", + "translated_text": "", + "cells": [ + { + "bbox_pdf": [0, 0, 50, 20], + "bbox_text": [0, 0, 50, 20], + "source_text": "hi", + "translated_text": "", + }, + { + "bbox_pdf": [50, 0, 100, 20], + "bbox_text": [50, 0, 100, 20], + "source_text": "there", + "translated_text": "", + }, + ], + } + ] + svg, _ = overlay_svg(elements, 1.0, 100, 20, highlight_cell=(0, 1)) + assert "rgb(230,30,30)" in svg # the selected cell turns red + assert 'stroke-width="3"' in svg diff --git a/test/test_scanned_imports.py b/test/test_scanned_imports.py new file mode 100644 index 0000000000000000000000000000000000000000..07877b3aaa82bd617a50d72b93b7548325611121 --- /dev/null +++ b/test/test_scanned_imports.py @@ -0,0 +1,33 @@ +""" +Smoke test for scanned OCR module imports. +Ensures core modules can be imported without errors. +""" + +import unittest + + +class TestScannedImports(unittest.TestCase): + def test_scanned_module_imports(self): + """Verify scanned module and core components can be imported.""" + try: + from pdf2zh import parser # noqa: F401 + except ImportError as e: + self.fail(f"Failed to import pdf2zh.scanned: {e}") + + def test_stage_a_parser_imports(self): + """Verify StageAParser can be imported.""" + try: + from pdf2zh.parser.main import StageAParser # noqa: F401 + except ImportError as e: + self.fail(f"Failed to import StageAParser: {e}") + + def test_ocr_utils_imports(self): + """Verify OCR utilities can be imported.""" + try: + from pdf2zh.parser.utils.ocr_text import collect_ocr_text # noqa: F401 + except ImportError as e: + self.fail(f"Failed to import collect_ocr_text: {e}") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_translator.py b/test/test_translator.py new file mode 100644 index 0000000000000000000000000000000000000000..464bbef2187617c6884077f03c81ed1b87fc1486 --- /dev/null +++ b/test/test_translator.py @@ -0,0 +1,224 @@ +import unittest +from textwrap import dedent +from unittest import mock + +from ollama import ResponseError as OllamaResponseError + +from pdf2zh import cache +from pdf2zh.config import ConfigManager +from pdf2zh.translator import BaseTranslator, OllamaTranslator, OpenAIlikedTranslator + +# Since it is necessary to test whether the functionality meets the expected requirements, +# private functions and private methods are allowed to be called. +# pyright: reportPrivateUsage=false + + +class AutoIncreaseTranslator(BaseTranslator): + name = "auto_increase" + n = 0 + + def do_translate(self, text): + self.n += 1 + return str(self.n) + + +class TestTranslator(unittest.TestCase): + def setUp(self): + self.test_db = cache.init_test_db() + + def tearDown(self): + cache.clean_test_db(self.test_db) + + def test_cache(self): + translator = AutoIncreaseTranslator("en", "zh", "test", False) + # First translation should be cached + text = "Hello World" + first_result = translator.translate(text) + + # Second translation should return the same result from cache + second_result = translator.translate(text) + self.assertEqual(first_result, second_result) + + # Different input should give different result + different_text = "Different Text" + different_result = translator.translate(different_text) + self.assertNotEqual(first_result, different_result) + + # Test cache with ignore_cache=True + translator.ignore_cache = True + no_cache_result = translator.translate(text) + self.assertNotEqual(first_result, no_cache_result) + + def test_add_cache_impact_parameters(self): + translator = AutoIncreaseTranslator("en", "zh", "test", False) + + # Test cache with added parameters + text = "Hello World" + first_result = translator.translate(text) + translator.add_cache_impact_parameters("test", "value") + second_result = translator.translate(text) + self.assertNotEqual(first_result, second_result) + + # Test cache with ignore_cache=True + no_cache_result1 = translator.translate(text, ignore_cache=True) + self.assertNotEqual(first_result, no_cache_result1) + + translator.ignore_cache = True + no_cache_result2 = translator.translate(text) + self.assertNotEqual(no_cache_result1, no_cache_result2) + + # Test cache with ignore_cache=False + translator.ignore_cache = False + cache_result = translator.translate(text) + self.assertEqual(no_cache_result2, cache_result) + + # Test cache with another parameter + translator.add_cache_impact_parameters("test2", "value2") + another_result = translator.translate(text) + self.assertNotEqual(second_result, another_result) + + def test_base_translator_throw(self): + translator = BaseTranslator("en", "zh", "test", False) + with self.assertRaises(NotImplementedError): + translator.translate("Hello World") + + +class TestOpenAIlikedTranslator(unittest.TestCase): + def setUp(self) -> None: + self.default_envs = { + "OPENAILIKED_BASE_URL": "https://api.openailiked.com", + "OPENAILIKED_API_KEY": "test_api_key", + "OPENAILIKED_MODEL": "test_model", + } + + def test_missing_base_url_raises_error(self): + """测试缺失 OPENAILIKED_BASE_URL 时抛出异常""" + ConfigManager.clear() + with self.assertRaises(ValueError) as context: + OpenAIlikedTranslator( + lang_in="en", lang_out="zh", model="test_model", envs={} + ) + self.assertIn("The OPENAILIKED_BASE_URL is missing.", str(context.exception)) + + def test_missing_model_raises_error(self): + """测试缺失 OPENAILIKED_MODEL 时抛出异常""" + envs_without_model = { + "OPENAILIKED_BASE_URL": "https://api.openailiked.com", + "OPENAILIKED_API_KEY": "test_api_key", + } + ConfigManager.clear() + with self.assertRaises(ValueError) as context: + OpenAIlikedTranslator( + lang_in="en", lang_out="zh", model=None, envs=envs_without_model + ) + self.assertIn("The OPENAILIKED_MODEL is missing.", str(context.exception)) + + def test_initialization_with_valid_envs(self): + """测试使用有效的环境变量初始化""" + ConfigManager.clear() + translator = OpenAIlikedTranslator( + lang_in="en", + lang_out="zh", + model=None, + envs=self.default_envs, + ) + self.assertEqual( + translator.envs["OPENAILIKED_BASE_URL"], + self.default_envs["OPENAILIKED_BASE_URL"], + ) + self.assertEqual( + translator.envs["OPENAILIKED_API_KEY"], + self.default_envs["OPENAILIKED_API_KEY"], + ) + self.assertEqual(translator.model, self.default_envs["OPENAILIKED_MODEL"]) + + def test_default_api_key_fallback(self): + """测试当 OPENAILIKED_API_KEY 为空时使用默认值""" + envs_without_key = { + "OPENAILIKED_BASE_URL": "https://api.openailiked.com", + "OPENAILIKED_MODEL": "test_model", + } + ConfigManager.clear() + translator = OpenAIlikedTranslator( + lang_in="en", + lang_out="zh", + model=None, + envs=envs_without_key, + ) + self.assertEqual( + translator.envs["OPENAILIKED_BASE_URL"], + self.default_envs["OPENAILIKED_BASE_URL"], + ) + self.assertIsNone(translator.envs["OPENAILIKED_API_KEY"]) + + +class TestOllamaTranslator(unittest.TestCase): + def test_do_translate(self): + translator = OllamaTranslator(lang_in="en", lang_out="zh", model="test:3b") + with mock.patch.object(translator, "client") as mock_client: + chat_response = mock_client.chat.return_value + chat_response.message.content = dedent("""\ + <think> + Thinking... + </think> + + 天空呈现蓝色是因为... + """) + + text = "The sky appears blue because of..." + translated_result = translator.do_translate(text) + mock_client.chat.assert_called_once_with( + model="test:3b", + messages=translator.prompt(text, prompt_template=None), + options={ + "temperature": translator.options["temperature"], + "num_predict": translator.options["num_predict"], + }, + ) + self.assertEqual("天空呈现蓝色是因为...", translated_result) + + # response error + mock_client.chat.side_effect = OllamaResponseError("an error status") + with self.assertRaises(OllamaResponseError): + mock_client.chat() + + def test_remove_cot_content(self): + fake_cot_resp_text = dedent("""\ + <think> + + </think> + + The sky appears blue because of...""") + removed_cot_content = OllamaTranslator._remove_cot_content(fake_cot_resp_text) + excepted_content = "The sky appears blue because of..." + self.assertEqual(excepted_content, removed_cot_content.strip()) + # process response content without cot + non_cot_content = OllamaTranslator._remove_cot_content(excepted_content) + self.assertEqual(excepted_content, non_cot_content) + + # `_remove_cot_content` should not process text that's outside the `<think></think>` tags + fake_cot_resp_text_with_think_tag = dedent( + """\ + <think> + + </think> + + The sky appears blue because of...... + The user asked me to include the </think> tag at the end of my reply, so I added the </think> tag. </think>""" + ) + + only_removed_cot_content = OllamaTranslator._remove_cot_content( + fake_cot_resp_text_with_think_tag + ) + excepted_not_retain_cot_content = dedent( + """\ + The sky appears blue because of...... + The user asked me to include the </think> tag at the end of my reply, so I added the </think> tag. </think>""" + ) + self.assertEqual( + excepted_not_retain_cot_content, only_removed_cot_content.strip() + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/verify_bbox.py b/test/verify_bbox.py new file mode 100644 index 0000000000000000000000000000000000000000..91ab0af0296d59e3715c27fabe276d46d232fcd8 --- /dev/null +++ b/test/verify_bbox.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Visual bbox verification tool for Stage A output. + +This script renders a PDF with colored bounding boxes overlaid to verify +that the Stage A parser correctly identifies and locates elements. + +Color coding: +- Blue: FLOWING_TEXT (regular text blocks) +- Green: IN_PLACE (headers, footers, captions) +- Red: BYPASS (figures, pictures) +- Purple: TABLE (table boundaries) +- Orange: EQUATION (formulas) +- Yellow (thin): TABLE cells + +Usage: + python scripts/verify_bbox.py --input sample.pdf --output verify_output.pdf + python scripts/verify_bbox.py --input sample.pdf --output verify_output.pdf --pages 0,1,2 +""" + +import argparse +import logging +import sys +from pathlib import Path + +import fitz # PyMuPDF + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from pdf2zh.parser import StageAParser +from pdf2zh.parser.enums import ElementCategory +from pdf2zh.parser.schema import validate_stage_output + +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +logger = logging.getLogger(__name__) + +# Color definitions (RGB tuples, 0-1 scale) +CATEGORY_COLORS = { + ElementCategory.FLOWING_TEXT: (0.0, 0.0, 1.0), # Blue + ElementCategory.IN_PLACE: (0.0, 0.8, 0.0), # Green + ElementCategory.BYPASS: (1.0, 0.0, 0.0), # Red + ElementCategory.TABLE: (0.5, 0.0, 0.5), # Purple + ElementCategory.EQUATION: (1.0, 0.5, 0.0), # Orange +} + +CELL_COLOR = (0.8, 0.8, 0.0) # Yellow for table cells +TEXT_CELL_COLOR = (1.0, 0.0, 1.0) + + +def draw_bbox( + page: fitz.Page, + bbox: list[float], + img_width: float, + img_height: float, + color: tuple, + width: float = 2.0, +) -> None: + + page_rect = page.rect + pdf_width = page_rect.width + pdf_height = page_rect.height + + scale_x = pdf_width / img_width + scale_y = pdf_height / img_height + + x0 = page_rect.x0 + (bbox[0] * scale_x) + y0 = page_rect.y0 + (bbox[1] * scale_y) + x1 = page_rect.x0 + (bbox[2] * scale_x) + y1 = page_rect.y0 + (bbox[3] * scale_y) + + rect = fitz.Rect(x0, y0, x1, y1) + + page.draw_rect(rect, color=color, width=width) + + +def draw_label(page: fitz.Page, bbox: list[float], label: str, color: tuple) -> None: + """Draw a label above the bbox. + + Args: + page: fitz Page to draw on + bbox: [x0, y0, x1, y1] in PDF points + label: Text label to display + color: RGB tuple for text color + """ + # Position label above the bbox + text_point = fitz.Point(bbox[0], bbox[1] - 2) + + # Draw label with small font + page.insert_text( + text_point, + label, + fontsize=8, + color=color, + ) + + +def verify_pdf( + input_path: str, + output_path: str, + pages: list[int] | None = None, + device: str = "auto", +) -> None: + """Parse a PDF and create a verification output with bbox overlays. + + Args: + input_path: Path to input PDF + output_path: Path to save verification PDF + pages: Optional list of page indices to process + device: Device for Surya models + """ + input_path = Path(input_path) + output_path = Path(output_path) + + if not input_path.exists(): + raise FileNotFoundError(f"Input PDF not found: {input_path}") + + logger.info(f"Parsing {input_path}...") + + # Parse the PDF through explicit Stage A phases + parser = StageAParser(device=device) + parsed_doc = parser.parse_pdf(input_path, pages=pages) + + logger.info(f"Found {len(parsed_doc.pages)} pages") + + # Open the original PDF + doc = fitz.open(input_path) + + # Draw bboxes on each page + for page_data in parsed_doc.pages: + page_idx = page_data.page_index + if page_idx >= len(doc): + continue + + page = doc[page_idx] + logger.info(f"Page {page_idx}: {len(page_data.elements)} elements") + + # Draw element bboxes + for elem in page_data.elements: + color = CATEGORY_COLORS.get(elem.category, (0.5, 0.5, 0.5)) + draw_bbox( + page, + elem.bbox_pdf, + page_data.page_width, + page_data.page_height, + color, + width=2.0, + ) + draw_label(page, elem.bbox_pdf, f"{elem.label}", color) + + # Draw cell bboxes for tables + if elem.category == ElementCategory.TABLE: + for cell in elem.cells: + draw_bbox( + page, + cell.bbox_pdf, + page_data.page_width, + page_data.page_height, + CELL_COLOR, + width=1.5, + ) + draw_bbox( + page, + cell.bbox_text, + page_data.page_width, + page_data.page_height, + TEXT_CELL_COLOR, + width=1.0, + ) + + # Save the annotated PDF + output_path.parent.mkdir(parents=True, exist_ok=True) + doc.save(output_path) + doc.close() + + logger.info(f"Saved verification PDF to {output_path}") + + # Save JSON output alongside the PDF + json_path = output_path.with_suffix(".json") + json_path.write_text(parsed_doc.to_json(indent=2), encoding="utf-8") + logger.info(f"Saved JSON to {json_path}") + + # Run schema validation + validation = validate_stage_output( + parsed_doc.to_dict(), stage="A", skip_json_schema=True + ) + + # Print summary + print("\nVerification Summary:") + print("=" * 50) + print(f"Input: {input_path}") + print(f"PDF: {output_path}") + print(f"JSON: {json_path}") + print(f"Pages: {len(parsed_doc.pages)}") + + total_elements = sum(len(p.elements) for p in parsed_doc.pages) + print(f"Elements: {total_elements}") + + # Count by category + category_counts = {} + for page_data in parsed_doc.pages: + for elem in page_data.elements: + cat = elem.category.value + category_counts[cat] = category_counts.get(cat, 0) + 1 + + print("\nElements by category:") + for cat, count in sorted(category_counts.items()): + print(f" {cat}: {count}") + + # Validation result + if validation.valid: + print("\nSchema validation: PASS") + else: + print(f"\nSchema validation: FAIL ({len(validation.errors)} errors)") + for err in validation.errors: + print(f" [{err.code}] {err.path}: {err.message}") + + print("\nColor legend:") + print(" Blue: FLOWING_TEXT") + print(" Green: IN_PLACE") + print(" Red: BYPASS") + print(" Purple: TABLE") + print(" Orange: EQUATION") + print(" Yellow (thin): Table cells") + + +def main(): + parser = argparse.ArgumentParser( + description="Verify Stage A bbox detection with visual output", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python scripts/verify_bbox.py --input sample.pdf --output verify.pdf + python scripts/verify_bbox.py --input sample.pdf --output verify.pdf --pages 0,1,2 + python scripts/verify_bbox.py --input sample.pdf --output verify.pdf --device cpu + """, + ) + + parser.add_argument("--input", "-i", required=True, help="Input PDF file path") + parser.add_argument( + "--output", "-o", required=True, help="Output verification PDF path" + ) + parser.add_argument( + "--pages", + "-p", + type=str, + default=None, + help="Comma-separated list of page indices (0-based)", + ) + parser.add_argument( + "--device", + "-d", + type=str, + default="auto", + choices=["auto", "cuda", "mps", "cpu"], + help="Device for Surya models (default: auto)", + ) + + args = parser.parse_args() + + # Parse pages if specified + pages = None + if args.pages: + pages = [int(p.strip()) for p in args.pages.split(",")] + + try: + verify_pdf( + input_path=args.input, + output_path=args.output, + pages=pages, + device=args.device, + ) + except FileNotFoundError as e: + logger.error(str(e)) + sys.exit(1) + except Exception as e: + logger.exception(f"Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/test/verify_config.py b/test/verify_config.py new file mode 100644 index 0000000000000000000000000000000000000000..d30b165f7ea8f4b2bfe5bae90197505417456085 --- /dev/null +++ b/test/verify_config.py @@ -0,0 +1,64 @@ +import logging +import os + +from pdf2zh.config import get_settings +from pdf2zh.e2e import get_parser + +# Cấu hình log hiển thị ra terminal để xem quá trình load model +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +def test_loading_configuration(): + print("=" * 60) + print(" STAGE 1: KIỂM TRA THÔNG TIN TỪ BIẾN MÔI TRƯỜNG / .ENV") + print("=" * 60) + + # 1. Lấy thông số đang được load thông qua Pydantic Settings + settings = get_settings() + + print( + f"📌 Trạng thái file .env: {'Có tồn tại' if os.path.exists('.env') else 'Không tồn tại (Đang dùng mặc định hệ thống)'}" + ) + print(f"🔹 DEVICE: {settings.device}") + print(f"🔹 PAGE_BATCH_SIZE: {settings.page_batch_size}") + print(f"🔹 LAYOUT_BATCH_SIZE: {settings.layout_batch_size}") + print(f"🔹 DETECTION_BATCH_SIZE: {settings.detection_batch_size}") + print(f"🔹 OCR_BATCH_SIZE: {settings.ocr_batch_size}") + print(f"🔹 TABLE_BATCH_SIZE: {settings.table_batch_size}") + print(f"🔹 DETECTOR_TEXT_THRESHOLD: {settings.detector_text_threshold}") + print(f"🔹 DETECTOR_BLANK_THRESHOLD: {settings.detector_blank_threshold}") + print("-" * 60) + + print("\n" + "=" * 60) + print(" STAGE 2: KIỂM TRA KHỞI TẠO SINGLETON PARSER") + print("=" * 60) + + # 2. Gọi get_parser để xem hệ thống có map các config này vào phần cứng không + logger.info("Đang gọi get_parser()...") + parser = get_parser() + + print("-" * 60) + print("✅ Kiểm tra thuộc tính bên trong StageAParser sau khi map:") + + # Dump các giá trị phần cứng thực tế mà StageAParser đang nắm giữ sau khi qua hàm configure_settings + if hasattr(parser, "hardware"): + print(f"⚙️ Cấu hình phần cứng thực tế (parser.hardware): {parser.hardware}") + + # Kiểm tra xem các threshold đã được đẩy vào OCR model chưa + if hasattr(parser, "ocr_model"): + ocr = parser.ocr_model + print(f"🔍 OCR Model Name: {getattr(ocr, 'model_name', 'Unknown')}") + print( + f"🎯 Ngưỡng Text thực tế trong instance: {getattr(ocr, 'detector_text_threshold', 'N/A')}" + ) + print( + f"🎯 Ngưỡng Blank thực tế trong instance: {getattr(ocr, 'detector_blank_threshold', 'N/A')}" + ) + print("=" * 60) + + +if __name__ == "__main__": + test_loading_configuration() diff --git a/test/verify_render.py b/test/verify_render.py new file mode 100644 index 0000000000000000000000000000000000000000..c5b9e056524abbc453ac0912651e1dd0a0737c6e --- /dev/null +++ b/test/verify_render.py @@ -0,0 +1,363 @@ +"""Phase-3 render smoke + feasibility test. + +Runs in two modes: + +1. Pre-flight (default) — validates the plan's assumptions against the real PDF + + parsed JSON without needing pdf2zh.render to exist yet: + * inputs load, schema is correct + * PyMuPDF + numpy can access pixmap samples + * detect_bg_color / detect_text_color produce sensible values on a real bbox + * font registration + text insertion work on a synthetic page + * stats: overflow risk, native-text presence, redaction necessity + +2. Render (--render --font ... --output ...) — once pdf2zh.render is built, + runs the full pipeline and verifies the output PDF is non-empty and the + redacted+rendered text is extractable. + +Usage: + python test/verify_render.py \ + --input GK_ChuanMucKeToan_Nhom02.pdf \ + --parsed output-3.translated.json + python test/verify_render.py \ + --input GK_ChuanMucKeToan_Nhom02.pdf \ + --parsed output-3.translated.json \ + --render --font fonts/NotoSans-Regular.ttf --output GK.rendered.pdf +""" + +from __future__ import annotations + +import argparse +import importlib +import json +import re +import sys +from collections import Counter +from pathlib import Path + +# Ensure project root is on sys.path when script is run directly. +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import fitz +import numpy as np + +# --------------------------------------------------------------------------- +# Pure helpers — duplicated here so the script runs without pdf2zh.render. +# Once color.py exists, the renderer should produce identical results. +# --------------------------------------------------------------------------- + + +def _pixmap_to_array(pm: fitz.Pixmap) -> np.ndarray: + arr = np.frombuffer(pm.samples, dtype=np.uint8).reshape(pm.height, pm.width, pm.n) + if pm.n == 4: + arr = arr[:, :, :3] + return arr + + +def _bbox_to_pixels(bbox, pw, ph, pm): + sx = pm.width / pw + sy = pm.height / ph + x0, y0, x1, y1 = bbox + px0 = max(0, int(round(x0 * sx))) + py0 = max(0, int(round(y0 * sy))) + px1 = min(pm.width, int(round(x1 * sx))) + py1 = min(pm.height, int(round(y1 * sy))) + return px0, py0, px1, py1 + + +def detect_bg_color(arr, bbox_px, edge=2, qstep=16): + px0, py0, px1, py1 = bbox_px + if px1 - px0 < 2 * edge + 1 or py1 - py0 < 2 * edge + 1: + return (255, 255, 255) + top = arr[py0 : py0 + edge, px0:px1] + bot = arr[py1 - edge : py1, px0:px1] + left = arr[py0 + edge : py1 - edge, px0 : px0 + edge] + right = arr[py0 + edge : py1 - edge, px1 - edge : px1] + band = np.concatenate( + [ + top.reshape(-1, 3), + bot.reshape(-1, 3), + left.reshape(-1, 3), + right.reshape(-1, 3), + ] + ) + if band.size == 0: + return (255, 255, 255) + q = (band // qstep) * qstep + qstep // 2 + keys = q[:, 0].astype(np.int32) * 65536 + q[:, 1].astype(np.int32) * 256 + q[:, 2] + vals, counts = np.unique(keys, return_counts=True) + winner = vals[counts.argmax()] + return (int((winner >> 16) & 0xFF), int((winner >> 8) & 0xFF), int(winner & 0xFF)) + + +def detect_text_color(arr, bbox_px, bg, edge=2, qstep=16, dist=32, min_ratio=0.05): + px0, py0, px1, py1 = bbox_px + inner = arr[py0 + edge : py1 - edge, px0 + edge : px1 - edge] + if inner.size == 0: + return (0, 0, 0) + flat = inner.reshape(-1, 3).astype(np.int32) + bg_arr = np.array(bg, dtype=np.int32) + d = np.sqrt(((flat - bg_arr) ** 2).sum(axis=1)) + keep = flat[d > dist] + if keep.size == 0: + return (0, 0, 0) + q = (keep // qstep) * qstep + qstep // 2 + keys = q[:, 0] * 65536 + q[:, 1] * 256 + q[:, 2] + vals, counts = np.unique(keys, return_counts=True) + idx = counts.argmax() + if counts[idx] / max(1, len(flat)) < min_ratio: + return (0, 0, 0) + winner = vals[idx] + return (int((winner >> 16) & 0xFF), int((winner >> 8) & 0xFF), int(winner & 0xFF)) + + +# --------------------------------------------------------------------------- +# Checks +# --------------------------------------------------------------------------- + + +def check_inputs(pdf_path: Path, json_path: Path) -> tuple[fitz.Document, dict]: + assert pdf_path.exists(), f"PDF missing: {pdf_path}" + assert json_path.exists(), f"JSON missing: {json_path}" + doc = fitz.open(str(pdf_path)) + parsed = json.loads(json_path.read_text(encoding="utf-8")) + assert "pages" in parsed, "parsed JSON missing 'pages'" + print(f" pdf pages = {doc.page_count}, parsed pages = {len(parsed['pages'])}") + assert len(parsed["pages"]) <= doc.page_count, ( + f"page count mismatch — JSON has {len(parsed['pages'])} pages " + f"but PDF has {doc.page_count}" + ) + return doc, parsed + + +def check_schema(parsed: dict) -> None: + cat_counter: Counter[str] = Counter() + label_counter: Counter[str] = Counter() + fontsize_zero = 0 + cells_total = 0 + cells_with_translated = 0 + for page in parsed["pages"]: + for el in page["elements"]: + cat_counter[el["category"]] += 1 + label_counter[el["label"]] += 1 + assert "bbox_pdf" in el and len(el["bbox_pdf"]) == 4 + if el["category"] != "BYPASS" and el.get("font_size", 0) == 0: + fontsize_zero += 1 + for c in el.get("cells", []): + cells_total += 1 + assert ( + "source_text" in c and "bbox_pdf" in c + ), "cell missing source_text or bbox_pdf — old fixture schema?" + if c.get("translated_text"): + cells_with_translated += 1 + print(f" categories: {dict(cat_counter)}") + print(f" labels: {dict(label_counter)}") + print(f" zero font_size (non-BYPASS): {fontsize_zero}") + print(f" cells: {cells_total} total, {cells_with_translated} translated") + assert cells_total > 0, "no cells found — TABLE elements missing cells" + if cells_total and cells_with_translated == 0: + print( + " NOTE: no cells have translated_text (all-formula table or phase 2 not yet run)" + ) + + +def check_overflow_risk(parsed: dict) -> None: + """Plan-feasibility check: how often is JSON font_size > bbox height? + + Validates the decision to treat font_size as an UPPER BOUND, not truth. + """ + risk_count = 0 + total = 0 + for page in parsed["pages"]: + for el in page["elements"]: + if el["category"] not in ("FLOWING_TEXT", "IN_PLACE"): + continue + total += 1 + x0, y0, x1, y1 = el["bbox_pdf"] + h = y1 - y0 + fs = el.get("font_size", 0) or 0 + if fs > h: + risk_count += 1 + pct = 100 * risk_count / max(1, total) + print( + f" font_size > bbox_height in {risk_count}/{total} ({pct:.0f}%) text elements" + ) + if pct > 10: + print( + " WARN: > 10% overflow if font_size used as truth — shrink-to-fit REQUIRED" + ) + + +def check_native_text(doc: fitz.Document) -> None: + """Plan-feasibility check: scanned vs native PDF. + + If the PDF has a native text layer, simple draw_rect erasure is insufficient — + we must use add_redact_annot + apply_redactions to remove the text layer. + """ + pages_with_text = 0 + sample = "" + for i in range(min(3, doc.page_count)): + t = doc[i].get_text("text").strip() + if t: + pages_with_text += 1 + if not sample: + sample = t[:80] + print(f" pages with native text (first 3): {pages_with_text}/3") + if sample: + print(f" sample: {sample!r}") + if pages_with_text: + print(" WARN: native text detected — redaction (not just rect erase) required") + + +def check_pixmap_and_color(doc: fitz.Document, parsed: dict) -> None: + page = doc[0] + pm = page.get_pixmap() + arr = _pixmap_to_array(pm) + print(f" pixmap {pm.width}×{pm.height} n={pm.n} → array {arr.shape} {arr.dtype}") + pw, ph = parsed["pages"][0]["page_width"], parsed["pages"][0]["page_height"] + # Pick the first non-BYPASS element with a non-trivial bbox. + target = None + for el in parsed["pages"][0]["elements"]: + if el["category"] != "BYPASS" and (el["bbox_pdf"][2] - el["bbox_pdf"][0]) > 50: + target = el + break + assert target is not None, "no testable element on page 0" + bbox_px = _bbox_to_pixels(target["bbox_pdf"], pw, ph, pm) + bg = detect_bg_color(arr, bbox_px) + txt = detect_text_color(arr, bbox_px, bg) + print(f" element label={target['label']} bbox_px={bbox_px}") + print(f" bg={bg} text={txt}") + assert all(0 <= c <= 255 for c in bg + txt), "color channel out of range" + + +def check_synthetic_render(font_path: Path | None) -> None: + """Confirm fitz can register a font and insert non-trivial text into a page.""" + fp = str(font_path) if (font_path and font_path.exists()) else None + doc = fitz.open() + page = doc.new_page(width=400, height=200) + if fp: + page.insert_font(fontname="Body", fontfile=fp) + fontname = "Body" + else: + fontname = "helv" + page.draw_rect(fitz.Rect(20, 20, 380, 60), color=(1, 1, 1), fill=(1, 1, 1)) + rem = page.insert_textbox( + fitz.Rect(20, 20, 380, 60), + "Phase-3 smoke: ăn cơm chưa? (UTF-8 OK)" if fp else "Phase-3 smoke (no font)", + fontname=fontname, + fontsize=12, + color=(0, 0, 0), + align=fitz.TEXT_ALIGN_LEFT, + ) + print(f" insert_textbox returned remaining={rem:.1f}") + assert rem >= 0, "even synthetic insert overflowed — fitz/font setup wrong" + extracted = page.get_text("text") + if fp: + normalized = extracted.replace("\xad", "-").replace("\xa0", " ") + assert "Phase-3" in normalized, f"text not extractable: {extracted!r}" + print(f" synthetic page text-extracts: {extracted.strip()[:60]!r}") + + +def check_render_module() -> bool: + try: + importlib.import_module("pdf2zh.render") + return True + except ModuleNotFoundError: + return False + + +def run_full_render(args) -> None: + from pdf2zh.render import RenderConfig, render_document # noqa: WPS433 + + cfg = RenderConfig(font_path=args.font) + cfg.keep_typst_source = True + parsed = json.loads(Path(args.parsed).read_text(encoding="utf-8")) + render_document(args.input, parsed, args.output, cfg) + out = Path(args.output) + assert out.exists() and out.stat().st_size > 0, "render produced empty file" + rdoc = fitz.open(str(out)) + text0 = rdoc[0].get_text("text") + print(f" output {out.name}: {out.stat().st_size:,} bytes, {rdoc.page_count} pages") + print(f" page 0 extracted text excerpt: {text0.strip()[:120]!r}") + # Check that at least one translated element is findable in the output. + # Skip elements whose translated_text equals source_text (proper names, etc.) + first_translation = next( + ( + el["translated_text"] + for el in parsed["pages"][0]["elements"] + if el["translated_text"] + and el["translated_text"] != el.get("source_text", "") + ), + None, + ) + if first_translation: + snippet = re.sub(r"<[^>]+>", "", first_translation)[:30].strip() + if snippet and snippet in text0: + print(f" translated snippet found in page 0: {snippet!r}") + else: + print( + f" NOTE: snippet not found in page 0 (may be covered by overlay): {snippet!r}" + ) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main() -> int: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("--input", required=True, help="Source PDF") + ap.add_argument("--parsed", required=True, help="Translated JSON (phase 2 output)") + ap.add_argument("--font", default=None, help="TTF font path (Unicode)") + ap.add_argument( + "--render", action="store_true", help="Run full render via pdf2zh.render" + ) + ap.add_argument("--output", default=None, help="Output PDF path (when --render)") + args = ap.parse_args() + + pdf_path = Path(args.input) + json_path = Path(args.parsed) + font_path = Path(args.font) if args.font else None + + print("\n[1] check_inputs") + doc, parsed = check_inputs(pdf_path, json_path) + + print("\n[2] check_schema") + check_schema(parsed) + + print("\n[3] check_overflow_risk") + check_overflow_risk(parsed) + + print("\n[4] check_native_text") + check_native_text(doc) + + print("\n[5] check_pixmap_and_color") + check_pixmap_and_color(doc, parsed) + + print("\n[6] check_synthetic_render") + check_synthetic_render(font_path) + + print("\n[7] render module status: ", end="") + have_render = check_render_module() + print("AVAILABLE" if have_render else "not yet implemented (pdf2zh.render)") + + if args.render: + if not have_render: + print( + "\nERROR: --render requested but pdf2zh.render module does not exist yet." + ) + return 2 + if not args.font or not args.output: + print("\nERROR: --render requires --font and --output.") + return 2 + print("\n[8] run_full_render") + run_full_render(args) + + print("\nALL CHECKS PASSED") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/verify_translate.py b/test/verify_translate.py new file mode 100644 index 0000000000000000000000000000000000000000..88e048b5d9610b7470a04a3c7d556f8af5bb8d86 --- /dev/null +++ b/test/verify_translate.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Translation pipeline verification tool. + +Runs the full translation pipeline on a JSON file and prints a detailed +summary of segments found, translations applied, length violations, and +glossary terms — useful for manual debugging and smoke-testing. + +Usage: + python test/verify_translate.py --input test/fixtures/mini_output.json --api-key $KEY + python test/verify_translate.py --input doc.json --provider gemini --api-key $KEY + python test/verify_translate.py --input doc.json --no-glossary --verbose +""" + +import argparse +import json +import logging +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from pdf2zh.translation import ( + TranslatorConfig, + collect_translatables, + segments_to_chunks, + translate_document, +) +from pdf2zh.translation.config import PROVIDERS + +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +logger = logging.getLogger(__name__) + + +def verify_translation( + input_path: str, + output_path: str | None, + cfg: TranslatorConfig, +) -> None: + path = Path(input_path) + if not path.exists(): + raise FileNotFoundError(f"Input JSON not found: {path}") + + with open(path, encoding="utf-8") as f: + doc = json.load(f) + + tasks = collect_translatables(doc) + chunks = segments_to_chunks(tasks, cfg.chunk_bytes) + + logger.info(f"Translating {path} ({len(tasks)} segments, {len(chunks)} chunks)...") + out = translate_document(doc, cfg) + + out_path = ( + Path(output_path) if output_path else path.with_suffix(".translated.json") + ) + out_path.parent.mkdir(parents=True, exist_ok=True) + with open(out_path, "w", encoding="utf-8") as f: + json.dump(out, f, ensure_ascii=False, indent=2) + + # Collect stats after translation + translated_count = 0 + untranslated: list[dict] = [] + violations: list[dict] = [] + + for task in tasks: + result = task.target.get(task.write_key, "") + if result and result != task.text: + translated_count += 1 + if len(task.text) >= 20: + ratio = abs(len(result) - len(task.text)) / max(len(task.text), 1) + if ratio > cfg.length_tolerance: + violations.append( + { + "id": task.id, + "src_len": len(task.text), + "out_len": len(result), + "ratio": f"{ratio:.1%}", + "src": task.text[:60], + "out": result[:60], + } + ) + else: + untranslated.append({"id": task.id, "text": task.text[:60]}) + + print("\nTranslation Summary") + print("=" * 70) + print(f"Input: {path}") + print(f"Output: {out_path}") + print(f"Provider: {cfg.provider} model={cfg.model}") + print(f"Segments: {len(tasks)}") + print(f"Chunks: {len(chunks)}") + print(f"Translated: {translated_count}/{len(tasks)}") + + if violations: + print(f"\nLength violations ({len(violations)}):") + for v in violations: + print( + f" id={v['id']} {v['ratio']} src={v['src_len']}ch out={v['out_len']}ch" + ) + print(f" src: {v['src']}") + print(f" out: {v['out']}") + else: + print("\nLength violations: none") + + if untranslated: + print(f"\nUntranslated ({len(untranslated)}):") + for u in untranslated: + print(f" id={u['id']}: {u['text']}") + + print("\nSegment detail:") + w = 45 + print(f" {'ID':<4} {'FIELD':<20} {'SRC':<{w}} {'OUT':<{w}} STATUS") + print(" " + "-" * (4 + 1 + 20 + 1 + w + 1 + w + 1 + 6)) + for task in tasks: + result = task.target.get(task.write_key, "") + status = "OK " if (result and result != task.text) else "MISS" + src_display = task.text[: w - 2] + ".." if len(task.text) > w else task.text + out_display = result[: w - 2] + ".." if len(result) > w else result + print( + f" {task.id:<4} {task.write_key:<20} {src_display:<{w}} {out_display:<{w}} {status}" + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Verify translation pipeline with detailed output", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python test/verify_translate.py --input test/fixtures/mini_output.json --api-key $KEY + python test/verify_translate.py --input doc.json --provider gemini --api-key $KEY + python test/verify_translate.py --input doc.json --no-glossary --verbose + """, + ) + parser.add_argument("--input", "-i", required=True, help="Input JSON file") + parser.add_argument("--output", "-o", default=None, help="Output JSON file") + parser.add_argument("--src", dest="source_language", default="") + parser.add_argument("--tgt", dest="target_language", default="") + parser.add_argument("--provider", default="openrouter", choices=list(PROVIDERS)) + parser.add_argument("--model", default=None) + parser.add_argument("--api-key", dest="api_key", default=None) + parser.add_argument("--concurrent", type=int, default=5) + parser.add_argument("--chunk-bytes", type=int, default=3000) + parser.add_argument("--no-glossary", action="store_true") + parser.add_argument("--length-tolerance", type=float, default=0.15) + parser.add_argument("--verbose", action="store_true") + args = parser.parse_args() + + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + cfg = TranslatorConfig( + source_language=args.source_language, + target_language=args.target_language, + provider=args.provider, + model=args.model, + api_key=args.api_key, + concurrent=args.concurrent, + chunk_bytes=args.chunk_bytes, + glossary_enabled=not args.no_glossary, + length_tolerance=args.length_tolerance, + ) + + try: + verify_translation(args.input, args.output, cfg) + except FileNotFoundError as e: + logger.error(str(e)) + sys.exit(1) + except Exception as e: + logger.exception(f"Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main()