diff --git a/.github/workflows/macos-artifacts.yml b/.github/workflows/macos-artifacts.yml
new file mode 100644
index 0000000..61b5f89
--- /dev/null
+++ b/.github/workflows/macos-artifacts.yml
@@ -0,0 +1,48 @@
+name: Build macOS Artifacts
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - codex/macos-build
+ paths:
+ - ".github/workflows/macos-artifacts.yml"
+ - "app/**"
+ - "pdf2zh/**"
+ - "scripts/**"
+ - "app-macos.spec"
+ - "build-macos.sh"
+ - "requirements*.txt"
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: macOS (${{ matrix.arch }})
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: apple-silicon
+ runner: macos-15
+ - arch: intel
+ runner: macos-15-intel
+ runs-on: ${{ matrix.runner }}
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+
+ - name: Build app and DMG
+ shell: bash
+ run: bash build-macos.sh
+
+ - uses: actions/upload-artifact@v6
+ with:
+ name: PDFTranslate-macos-${{ matrix.arch }}
+ path: dist/PDFTranslate-macos-${{ matrix.arch }}.dmg
+ if-no-files-found: error
+ retention-days: 7
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8292f6a..ec891d4 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -5,6 +5,7 @@ name: Release
# right every time - which is exactly the kind of thing manual uploads get
# wrong once and then nobody notices for a month.
on:
+ workflow_dispatch:
push:
tags:
- "v*"
@@ -16,13 +17,14 @@ jobs:
windows:
runs-on: windows-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- - uses: actions/setup-python@v5
+ - uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Check the tag matches APP_VERSION
+ if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
run: |
$tag = "${{ github.ref_name }}".TrimStart("v")
@@ -41,7 +43,63 @@ jobs:
- name: Build
run: .\build.ps1
- - uses: softprops/action-gh-release@v2
+ - uses: actions/upload-artifact@v6
with:
- files: dist/PDFTranslate-windows.zip
+ name: PDFTranslate-windows
+ path: dist/PDFTranslate-windows.zip
+ if-no-files-found: error
+
+ macos:
+ name: macOS (${{ matrix.arch }})
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: apple-silicon
+ runner: macos-15
+ - arch: intel
+ runner: macos-15-intel
+ runs-on: ${{ matrix.runner }}
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+
+ - name: Check the tag matches APP_VERSION
+ if: startsWith(github.ref, 'refs/tags/v')
+ shell: bash
+ run: |
+ tag="${GITHUB_REF_NAME#v}"
+ version="$(sed -n 's/^APP_VERSION = "\([^"]*\)"$/\1/p' app/update.py)"
+ if [[ "$tag" != "$version" ]]; then
+ echo "Tag $tag does not match APP_VERSION $version in app/update.py" >&2
+ exit 1
+ fi
+ echo "Releasing $version"
+
+ - name: Build app and DMG
+ shell: bash
+ run: bash build-macos.sh
+
+ - uses: actions/upload-artifact@v6
+ with:
+ name: PDFTranslate-macos-${{ matrix.arch }}
+ path: dist/PDFTranslate-macos-${{ matrix.arch }}.dmg
+ if-no-files-found: error
+
+ publish:
+ if: startsWith(github.ref, 'refs/tags/v')
+ needs: [windows, macos]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/download-artifact@v7
+ with:
+ path: release-assets
+ merge-multiple: true
+
+ - uses: softprops/action-gh-release@v3
+ with:
+ files: release-assets/*
generate_release_notes: true
diff --git a/README.md b/README.md
index 64f5482..36d9a2d 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,12 @@
+
+
+
+
+
+
@@ -35,7 +41,7 @@ --- -PDF Translate là ứng dụng desktop mã nguồn mở dành cho Windows. Công cụ phân tích bố cục từng trang, bảo vệ công thức và code, dịch phần văn xuôi rồi đặt nội dung trở lại đúng vị trí trong tài liệu gốc — không biến PDF của bạn thành một trang chữ trắng đơn giản. +PDF Translate là ứng dụng desktop mã nguồn mở dành cho Windows và macOS. Công cụ phân tích bố cục từng trang, bảo vệ công thức và code, dịch phần văn xuôi rồi đặt nội dung trở lại đúng vị trí trong tài liệu gốc — không biến PDF của bạn thành một trang chữ trắng đơn giản. ## Điểm nổi bật @@ -49,16 +55,27 @@ PDF Translate là ứng dụng desktop mã nguồn mở dành cho Windows. Công ## Bắt đầu trong 1 phút +### Windows + 1. **[Tải PDF Translate cho Windows](https://github.com/breslee1707/VI-Translate/releases/latest/download/PDFTranslate-windows.zip)** (`.zip`, khoảng 199 MB). 2. Giải nén toàn bộ file vừa tải. 3. Mở `PDFTranslate.exe`. +### macOS + +1. Tải bản phù hợp: **[Apple Silicon](https://github.com/breslee1707/VI-Translate/releases/latest/download/PDFTranslate-macos-apple-silicon.dmg)** cho Mac M1/M2/M3/M4/M5 hoặc **[Intel](https://github.com/breslee1707/VI-Translate/releases/latest/download/PDFTranslate-macos-intel.dmg)** cho Mac Intel. +2. Mở file `.dmg`, kéo **PDF Translate** vào thư mục **Applications**. +3. Trong lần chạy đầu, bấm chuột phải vào ứng dụng → **Open** → **Open**. + > [!NOTE] -> Ứng dụng hiện chỉ hỗ trợ **Windows 64-bit**. Bản desktop không cần cài Python và không phải tải thêm model ở lần chạy đầu. Quá trình dịch bằng Google vẫn cần kết nối Internet. +> Bản macOS yêu cầu **macOS 14 Sonoma trở lên**. Tất cả bản desktop đều không cần cài Python và không phải tải thêm model ở lần chạy đầu. Quá trình dịch bằng Google vẫn cần kết nối Internet. > [!WARNING] > Windows SmartScreen có thể cảnh báo vì ứng dụng chưa được ký số. Chọn **More info** → **Run anyway** nếu bạn tải file từ trang Releases chính thức của repo này. +> [!WARNING] +> Bản macOS hiện dùng chữ ký ad-hoc, chưa được Apple notarize. Gatekeeper có thể chặn thao tác mở thông thường; hãy dùng cách bấm chuột phải → **Open** ở trên nếu bạn tải từ trang Releases chính thức. + Bạn cũng có thể mở [trang Releases](https://github.com/breslee1707/VI-Translate/releases/latest) để xem ghi chú thay đổi và các tệp của phiên bản mới nhất. Ứng dụng cũng tự kiểm tra phiên bản mới mỗi lần mở. Có bản mới thì góc trên bên phải hiện dòng **● Có bản mới vX.Y.Z**, bấm vào là mở trang tải. Ứng dụng không tự tải và không tự cài đè — bạn vẫn tự giải nén như lần đầu. Máy không có mạng thì bỏ qua, không báo lỗi. @@ -71,7 +88,7 @@ Chọn một trong ba cách: - Kéo thả file PDF hoặc cả thư mục vào cửa sổ ứng dụng. - Bấm **Chọn file** hoặc **Chọn thư mục**. -- Thả file trực tiếp lên `PDFTranslate.exe`. +- Thả file trực tiếp lên biểu tượng ứng dụng. ### 2. Chọn ngôn ngữ @@ -174,6 +191,16 @@ python -m venv .venv Gói phát hành được tạo tại `dist\PDFTranslate-windows.zip`. +### Build ứng dụng macOS + +Chạy trên máy Mac dùng đúng kiến trúc cần phát hành: + +```bash +bash build-macos.sh +``` + +Gói phát hành được tạo tại `dist/PDFTranslate-macos-apple-silicon.dmg` hoặc `dist/PDFTranslate-macos-intel.dmg`. Từ máy Windows, bạn có thể chạy thủ công workflow **Release** trên GitHub Actions để lấy cả hai DMG trong phần Artifacts; khi push tag `v*`, workflow tự đính kèm chúng vào GitHub Release. + ## Giới hạn hiện tại - **Chưa có OCR:** PDF scan chỉ chứa hình ảnh cần được OCR trước khi dịch. diff --git a/app-macos.spec b/app-macos.spec new file mode 100644 index 0000000..2fcf292 --- /dev/null +++ b/app-macos.spec @@ -0,0 +1,114 @@ +# -*- mode: python ; coding: utf-8 -*- +"""PyInstaller spec for the macOS desktop app. + +Build on the target architecture. PyInstaller is not a cross-compiler, and the +large native dependencies make separate arm64 and x86_64 DMGs more reliable +than trying to assemble a universal2 bundle from thin wheels. +""" + +import os +import runpy +from pathlib import Path + +from PyInstaller.utils.hooks import collect_data_files + +ROOT = Path(SPECPATH) +APP_VERSION = runpy.run_path(str(ROOT / "app" / "update.py"))["APP_VERSION"] +TARGET_ARCH = os.environ.get("MACOS_TARGET_ARCH") or None +SIGNING_IDENTITY = os.environ.get("MACOS_SIGNING_IDENTITY") or None +ICON = ROOT / "build" / "macos" / "PDFTranslate.icns" + +if not ICON.is_file(): + raise SystemExit("Missing macOS icon. Run ./build-macos.sh instead of PyInstaller directly.") + +datas = [] +for optional in ("app/fonts", "app/assets"): + directory = ROOT / optional + if not directory.is_dir(): + continue + for item in sorted(directory.iterdir()): + if item.is_file() and item.suffix != ".optimized": + datas.append((str(item), optional)) + +datas += collect_data_files("customtkinter") +datas += collect_data_files("tkinterdnd2") +datas += collect_data_files("babeldoc") + +hiddenimports = [ + "peewee", + "pdf2zh.doclayout", + "pdf2zh.high_level", + "pdf2zh.converter", + "pdf2zh.translator", +] + +analysis = Analysis( + [str(ROOT / "app" / "gui.py")], + pathex=[str(ROOT)], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[ + "matplotlib", + "PyQt5", + "PyQt6", + "PySide2", + "PySide6", + "IPython", + "pytest", + "scipy", + "pandas", + "onnxruntime.transformers", + "onnxruntime.tools", + "onnxruntime.quantization", + ], + noarchive=False, +) + +pyz = PYZ(analysis.pure) + +exe = EXE( + pyz, + analysis.scripts, + [], + exclude_binaries=True, + name="PDFTranslate", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=TARGET_ARCH, + codesign_identity=SIGNING_IDENTITY, + entitlements_file=None, +) + +collect = COLLECT( + exe, + analysis.binaries, + analysis.datas, + strip=False, + upx=False, + name="PDFTranslate", +) + +app = BUNDLE( + collect, + name="PDFTranslate.app", + icon=str(ICON), + bundle_identifier="ai.huyg.pdftranslate", + version=APP_VERSION, + info_plist={ + "CFBundleDisplayName": "PDF Translate", + "LSMinimumSystemVersion": "14.0", + "NSHighResolutionCapable": True, + }, + target_arch=TARGET_ARCH, + codesign_identity=SIGNING_IDENTITY, + entitlements_file=None, +) diff --git a/app/gui.py b/app/gui.py index a3b4acf..ac88c16 100644 --- a/app/gui.py +++ b/app/gui.py @@ -10,6 +10,7 @@ import ctypes import os import queue +import subprocess import sys import threading import tkinter @@ -39,8 +40,12 @@ ASSET_DIRECTORY = APP_ROOT / "app" / "assets" UI_FONT = "Be Vietnam Pro" MONO_FONT = "JetBrains Mono" -FALLBACK_UI_FONT = "Segoe UI" -FALLBACK_MONO_FONT = "Consolas" +if sys.platform == "darwin": + FALLBACK_UI_FONT = "Helvetica Neue" + FALLBACK_MONO_FONT = "Menlo" +else: + FALLBACK_UI_FONT = "Segoe UI" + FALLBACK_MONO_FONT = "Consolas" # One 8px rhythm for the whole window, so nothing is spaced by feel. PAD, GAP, EDGE = 8, 16, 24 @@ -464,7 +469,16 @@ def _open(target: Path | None) -> None: if target is None or not Path(target).exists(): return try: - os.startfile(target) # noqa: S606 - Windows shell open, the app is Windows only + if sys.platform == "win32": + os.startfile(target) # type: ignore[attr-defined] # noqa: S606 + else: + command = "open" if sys.platform == "darwin" else "xdg-open" + subprocess.Popen( # noqa: S603 - fixed OS command, no shell + [command, str(target)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) except OSError: pass # nothing useful to do if the shell refuses @@ -702,9 +716,17 @@ def main() -> None: ctk.set_appearance_mode("system") ctk.set_default_color_theme("blue") app = App() - # Windows passes anything dropped on the executable icon as arguments. - if sys.argv[1:]: - app._add([Path(argument) for argument in sys.argv[1:]]) + arguments = [argument for argument in sys.argv[1:] if argument != "--smoke-test"] + if "--smoke-test" in sys.argv[1:]: + # CI uses this to prove the frozen executable can load Tk, TkDND and all + # native libraries on the Mac architecture that produced the bundle. + app.withdraw() + app.update_idletasks() + app.destroy() + return + # Desktop shells can pass files dropped on the executable icon as arguments. + if arguments: + app._add([Path(argument) for argument in arguments]) app.mainloop() diff --git a/app/update.py b/app/update.py index f52065f..f4671b7 100644 --- a/app/update.py +++ b/app/update.py @@ -8,7 +8,7 @@ from __future__ import annotations -APP_VERSION = "0.2.2" +APP_VERSION = "0.2.3" REPOSITORY = "breslee1707/VI-Translate" RELEASES_API = f"https://api.github.com/repos/{REPOSITORY}/releases/latest" RELEASES_PAGE = f"https://github.com/{REPOSITORY}/releases/latest" diff --git a/build-macos.sh b/build-macos.sh new file mode 100644 index 0000000..aae8b91 --- /dev/null +++ b/build-macos.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +VENV="$ROOT/.venv-macos" +PYTHON_BIN="$VENV/bin/python3" +SKIP_ASSETS=0 + +if [[ "${1:-}" == "--skip-assets" ]]; then + SKIP_ASSETS=1 +elif [[ $# -gt 0 ]]; then + echo "Usage: ./build-macos.sh [--skip-assets]" >&2 + exit 2 +fi + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "This script must run on macOS. Use the GitHub Actions workflow from Windows." >&2 + exit 1 +fi + +if [[ ! -x "$PYTHON_BIN" ]]; then + echo "==> Creating the macOS virtual environment" + python3 -m venv "$VENV" +fi + +echo "==> Installing app and packaging dependencies" +"$PYTHON_BIN" -m pip install --upgrade pip +"$PYTHON_BIN" -m pip install -r "$ROOT/requirements-app.txt" + +if [[ $SKIP_ASSETS -eq 0 ]]; then + echo "==> Fetching the layout model and font" + "$PYTHON_BIN" "$ROOT/scripts/fetch_assets.py" +fi + +echo "==> Creating the macOS icon" +ICON_ROOT="$ROOT/build/macos" +ICONSET="$ICON_ROOT/PDFTranslate.iconset" +rm -rf "$ICONSET" +mkdir -p "$ICONSET" +for size in 16 32 128 256 512; do + double=$((size * 2)) + sips -z "$size" "$size" "$ROOT/app/assets/icon.png" \ + --out "$ICONSET/icon_${size}x${size}.png" >/dev/null + sips -z "$double" "$double" "$ROOT/app/assets/icon.png" \ + --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null +done +iconutil -c icns "$ICONSET" -o "$ICON_ROOT/PDFTranslate.icns" + +case "$(uname -m)" in + arm64) ARCH_LABEL="apple-silicon"; export MACOS_TARGET_ARCH="arm64" ;; + x86_64) ARCH_LABEL="intel"; export MACOS_TARGET_ARCH="x86_64" ;; + *) echo "Unsupported Mac architecture: $(uname -m)" >&2; exit 1 ;; +esac + +echo "==> Running PyInstaller for $ARCH_LABEL" +"$PYTHON_BIN" -m PyInstaller --noconfirm --clean "$ROOT/app-macos.spec" + +APP="$ROOT/dist/PDFTranslate.app" +EXECUTABLE="$APP/Contents/MacOS/PDFTranslate" +if [[ ! -x "$EXECUTABLE" || ! -f "$APP/Contents/Info.plist" ]]; then + echo "PyInstaller did not produce a complete application bundle at $APP" >&2 + exit 1 +fi + +echo "==> Smoke-testing the packaged executable" +file "$EXECUTABLE" | grep -q "$(uname -m)" +"$EXECUTABLE" --smoke-test + +if [[ -n "${MACOS_SIGNING_IDENTITY:-}" ]]; then + echo "==> Signing with the configured Developer ID" + codesign --force --deep --options runtime --timestamp \ + --sign "$MACOS_SIGNING_IDENTITY" "$APP" +else + echo "==> Applying an ad-hoc signature (Apple Developer ID not configured)" + codesign --force --deep --sign - "$APP" +fi +codesign --verify --deep --strict --verbose=2 "$APP" + +echo "==> Creating the DMG" +DMG_STAGE="$ROOT/build/macos/dmg" +DMG="$ROOT/dist/PDFTranslate-macos-$ARCH_LABEL.dmg" +rm -rf "$DMG_STAGE" +mkdir -p "$DMG_STAGE" +cp -R "$APP" "$DMG_STAGE/" +ln -s /Applications "$DMG_STAGE/Applications" + +for attempt in 1 2 3; do + rm -f "$DMG" + if hdiutil create -volname "PDF Translate" -srcfolder "$DMG_STAGE" \ + -ov -format UDZO "$DMG"; then + break + fi + + if [[ "$attempt" -eq 3 ]]; then + echo "hdiutil could not create the DMG after $attempt attempts" >&2 + exit 1 + fi + + echo "hdiutil was busy; retrying ($attempt/3)" + sleep $((attempt * 2)) +done +hdiutil verify "$DMG" + +echo "==> Done: $DMG" +if [[ -z "${MACOS_SIGNING_IDENTITY:-}" ]]; then + echo "Unsigned distribution: users may need to right-click the app and choose Open." +fi diff --git a/pdf2zh/converter.py b/pdf2zh/converter.py index 2af579f..e014783 100644 --- a/pdf2zh/converter.py +++ b/pdf2zh/converter.py @@ -17,7 +17,7 @@ from pymupdf import Font from tenacity import retry, stop_after_attempt, wait_exponential -from pdf2zh.rules import BULLET_CHARACTERS, is_formula_font, line_height_for_language +from pdf2zh.rules import is_bullet_character, is_formula_font, line_height_for_language from pdf2zh.translator import ( ENGINES, BaseTranslator, @@ -55,37 +55,55 @@ def text_style_from_font(font_name: str | bytes) -> TextStyle: def text_orientation(matrix) -> tuple[float, float, float, float] | None: - """Return the nearest quarter-turn text orientation, or None if arbitrary.""" + """Return the nearest quarter-turn from the glyph baseline direction. + + Some PDF producers use a negative font size together with a reflected text + matrix (for example ``1 0 0 -1``) to draw ordinary upright text. Looking at + all four matrix components mistakes that implementation detail for an + unsupported orientation and causes the glyphs to be replayed upside down. + The first matrix column is the logical baseline, so it is sufficient for + classifying the four supported reading directions. + """ a, b, c, d = (float(value) for value in matrix[:4]) x_scale = math.hypot(a, b) - y_scale = math.hypot(c, d) - if x_scale <= 1e-6 or y_scale <= 1e-6: + if x_scale <= 1e-6 or math.hypot(c, d) <= 1e-6: return None - normalised = (a / x_scale, b / x_scale, c / y_scale, d / y_scale) + baseline = (a / x_scale, b / x_scale) candidates = ( IDENTITY_ORIENTATION, (0.0, 1.0, -1.0, 0.0), (-1.0, 0.0, 0.0, -1.0), (0.0, -1.0, 1.0, 0.0), ) - return min( - candidates, - key=lambda candidate: sum( - (normalised[index] - candidate[index]) ** 2 for index in range(4) - ), - ) if min( - sum((normalised[index] - candidate[index]) ** 2 for index in range(4)) + distances = [ + (baseline[0] - candidate[0]) ** 2 + + (baseline[1] - candidate[1]) ** 2 for candidate in candidates - ) <= 0.08 else None + ] + best = min(range(len(candidates)), key=distances.__getitem__) + return candidates[best] if distances[best] <= 0.04 else None def normalised_text_matrix(matrix) -> tuple[float, float, float, float]: a, b, c, d = (float(value) for value in matrix[:4]) x_scale = max(math.hypot(a, b), 1e-6) y_scale = max(math.hypot(c, d), 1e-6) + if a * d - b * c < 0: + # A reflected text matrix is normally paired with a negative font size. + # Preserve its baseline rotation but remove the technical reflection. + ux, uy = a / x_scale, b / x_scale + return (ux, uy, -uy, ux) return (a / x_scale, b / x_scale, c / y_scale, d / y_scale) +def paragraph_width_budget(x: float, x0: float, x1: float, lines: int) -> float: + """Return usable width while accounting for a first-line indentation.""" + if lines <= 0 or x1 <= x0: + return 0.0 + first_line = max(0.0, x1 - max(x, x0)) + return first_line + max(0, lines - 1) * (x1 - x0) + + def styled_text_matrix( orientation: tuple[float, float, float, float], style: int, @@ -469,7 +487,7 @@ def new_paragraph(child: LTChar, cls: int) -> None: h, w = layout.shape cx, cy = np.clip(int(child.x0), 0, w - 1), np.clip(int(child.y0), 0, h - 1) cls = layout[cy, cx] - if child.get_text() in BULLET_CHARACTERS: + if is_bullet_character(child.get_text(), child.fontname): cls = 0 orientation = text_orientation(child.matrix) if ( @@ -936,14 +954,13 @@ def _cell_measure(character: str, candidate_size: float) -> float: # also required for a single-line title: without it a longer target # string ignores x1 completely and runs into the neighbouring column. if new != sstk[id]: - line_width = x1 - x0 # Count how many lines the original text occupied orig_lines = ( max(1, round(height / (pstk[id].size * default_line_height))) if brk else 1 ) - total_avail = line_width * orig_lines + total_avail = paragraph_width_budget(x, x0, x1, orig_lines) # Measure actual width of translated text (excluding formula tags) total_new_width = 0 tmp_ptr = 0 @@ -1212,20 +1229,6 @@ def _measure_char(c, style): "style": cstyle, }) - # An inline formula keeps the vertical offsets it had in the source, - # so a fraction reaches far below its baseline while the prose around - # it does not. Uniform leading therefore let the next line print - # straight through the denominator. Measure what each line actually - # occupies above and below its own baseline, and open up only the - # gaps that need it. - ink: dict[int, tuple[float, float]] = {} - for vals in ops_vals: - s_ = vals["size"] if vals["type"] == OpType.TEXT else 0.0 - lo = vals["dy"] + min(0.0, vals.get("ylen", 0.0)) - 0.22 * s_ - hi = vals["dy"] + max(0.0, vals.get("ylen", 0.0)) + 0.78 * s_ - plo, phi = ink.get(vals["lidx"], (lo, hi)) - ink[vals["lidx"]] = (min(plo, lo), max(phi, hi)) - line_height = default_line_height # Fit the prose to the box on its own. Charging the formula's extra @@ -1243,12 +1246,54 @@ def _measure_char(c, style): if vals["type"] == OpType.TEXT: vals["size"] *= shrink + # Measure ink only after the final font-size adjustment. Measuring + # before shrinking left the old line gaps in place, so dense table + # cells used smaller glyphs but still crossed the row below. + ink = operation_ink(ops_vals) + + if pstk[id].layout_bound is not None and ink: + # Preserved codes and formula placeholders can be larger than + # the surrounding translated prose. The prose-only line count + # above cannot see that, so fit the union of the actual glyph + # extents to the cell as a final guard. + for _attempt in range(3): + preview_offsets = line_offsets( + ink, + lidx, + size, + line_height, + budget=height - (lidx + 1) * size * line_height, + ) + occupied = vertical_ink_extent(ink, preview_offsets) + available_height = max(0.0, height - 1.0) + if occupied <= available_height + 0.01 or occupied <= 0: + break + minimum_size = pstk[id].size * 0.5 + scale = max(minimum_size / max(size, 1e-6), available_height / occupied) + scale = min(1.0, scale) + if scale >= 0.999: + break + size *= scale + for vals in ops_vals: + if vals["type"] == OpType.TEXT: + vals["size"] *= scale + ink = operation_ink(ops_vals) + # ponytail: the paragraph's own box is the whole budget, so a # formula in an already tight paragraph stays somewhat cramped. # Measuring the gap down to the next paragraph would buy the rest. offsets = line_offsets(ink, lidx, size, line_height, budget=height - (lidx + 1) * size * line_height) + if pstk[id].layout_bound is not None: + y += vertical_shift_to_bounds( + y, + ink, + offsets, + pstk[id].y0 + 0.5, + pstk[id].y1 - 0.5, + ) + for vals in ops_vals: if vals["type"] == OpType.TEXT: ops_list.append( @@ -1309,3 +1354,68 @@ def line_offsets( class OpType(Enum): TEXT = "text" LINE = "line" + + +def operation_ink( + operations: list[dict], +) -> dict[int, tuple[float, float]]: + """Measure each rendered line using its final glyph sizes and offsets.""" + ink: dict[int, tuple[float, float]] = {} + for values in operations: + size = values["size"] if values["type"] == OpType.TEXT else 0.0 + low = ( + values["dy"] + + min(0.0, values.get("ylen", 0.0)) + - 0.22 * size + ) + high = ( + values["dy"] + + max(0.0, values.get("ylen", 0.0)) + + 0.78 * size + ) + previous_low, previous_high = ink.get(values["lidx"], (low, high)) + ink[values["lidx"]] = ( + min(previous_low, low), + max(previous_high, high), + ) + return ink + + +def vertical_ink_extent( + ink: dict[int, tuple[float, float]], offsets: list[float] +) -> float: + """Return total vertical glyph span after applying per-line offsets.""" + extents = [ + (low - offsets[index], high - offsets[index]) + for index, (low, high) in ink.items() + if index < len(offsets) + ] + if not extents: + return 0.0 + return max(high for _low, high in extents) - min(low for low, _high in extents) + + +def vertical_shift_to_bounds( + baseline: float, + ink: dict[int, tuple[float, float]], + offsets: list[float], + lower: float, + upper: float, +) -> float: + """Move a fitted paragraph back inside its cell without changing layout.""" + extents = [ + (baseline + low - offsets[index], baseline + high - offsets[index]) + for index, (low, high) in ink.items() + if index < len(offsets) + ] + if not extents or upper <= lower: + return 0.0 + minimum = min(low for low, _high in extents) + maximum = max(high for _low, high in extents) + if maximum - minimum > upper - lower + 0.01: + return 0.0 + if minimum < lower: + return lower - minimum + if maximum > upper: + return upper - maximum + return 0.0 diff --git a/pdf2zh/high_level.py b/pdf2zh/high_level.py index 14e0635..5414adb 100644 --- a/pdf2zh/high_level.py +++ b/pdf2zh/high_level.py @@ -45,6 +45,34 @@ BASE14_STYLE_FONTS = {0: "tiro", 1: "tibo", 2: "tiit", 3: "tibi"} logger = logging.getLogger(__name__) +LARGE_DOCUMENT_SUBSET_PAGE_LIMIT = 200 +LARGE_DOCUMENT_BYTE_LIMIT = 50 * 1024 * 1024 + + +def is_large_document(page_count: int, source_size: int = 0) -> bool: + return ( + page_count >= LARGE_DOCUMENT_SUBSET_PAGE_LIMIT + or source_size >= LARGE_DOCUMENT_BYTE_LIMIT + ) + + +def should_subset_fonts( + page_count: int, skip_subset_fonts: bool, source_size: int = 0 +) -> bool: + """Avoid the blocking whole-document font scan on large PDFs.""" + return not skip_subset_fonts and not is_large_document(page_count, source_size) + + +def pdf_write_options(page_count: int, source_size: int = 0) -> dict[str, int | bool]: + """Choose fast, low-memory serialization for large documents. + + Recompressing and garbage-collecting every object in a long textbook can + hold the CPython GIL for tens of seconds. A light cleanup is almost the same + size for image-heavy books and lets the GUI finish promptly. + """ + if is_large_document(page_count, source_size): + return {"deflate": False, "garbage": 1, "use_objstms": 0} + return {"deflate": True, "garbage": 3, "use_objstms": 1} def output_style_font_paths(language: str, regular_path: str) -> dict[int, str]: @@ -441,9 +469,11 @@ def translate_stream( envs: Dict = None, prompt: Template = None, skip_subset_fonts: bool = False, + create_dual: bool = True, ignore_cache: bool = False, **kwarg: Any, ): + source_size = len(stream) font_path = download_remote_fonts(lang_out.lower()) style_paths = output_style_font_paths(lang_out.lower(), font_path) style_font_names = dict(STYLE_FONT_NAMES) @@ -465,6 +495,8 @@ def translate_stream( stream = io.BytesIO() doc_en.save(stream) doc_zh = Document(stream=stream) + if not create_dual: + doc_en.close() page_count = doc_zh.page_count # font_list = [("GoNotoKurrent-Regular.ttf", font_path), ("tiro", None)] font_id = {} @@ -508,15 +540,30 @@ def translate_stream( # 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: + if create_dual: + doc_en.insert_file(doc_zh) + for id in range(page_count): + doc_en.move_page(page_count + id, id * 2 + 1) + + # PyMuPDF's whole-document font scan is disproportionately expensive for + # textbooks and holds the GIL while it runs. The output fonts are already + # embedded and valid without subsetting, so favour a responsive, reliable + # export for large documents over shaving a few megabytes from the result. + subset_fonts = should_subset_fonts(page_count, skip_subset_fonts, source_size) + if subset_fonts: doc_zh.subset_fonts(fallback=True) - doc_en.subset_fonts(fallback=True) + if create_dual: + doc_en.subset_fonts(fallback=True) + write_options = pdf_write_options(page_count, source_size) + mono = doc_zh.write(**write_options) + dual = ( + doc_en.write(**write_options) + if create_dual + else None + ) return ( - doc_zh.write(deflate=True, garbage=3, use_objstms=1), - doc_en.write(deflate=True, garbage=3, use_objstms=1), + mono, + dual, translation_failures, ) @@ -642,6 +689,7 @@ def translate( try: s_mono, _s_dual, translation_failures = translate_stream( s_raw, + create_dual=False, **locals(), ) if translation_failures: diff --git a/pdf2zh/rules.py b/pdf2zh/rules.py index e3655f0..9cbd542 100644 --- a/pdf2zh/rules.py +++ b/pdf2zh/rules.py @@ -31,6 +31,8 @@ ("•", "■", "□", "▪", "▸", "▹", "►", "▶", "●", "○", "◆", "◇", "★", "☆", "‣", "⬤") ) +PRIVATE_USE_BULLETS = frozenset(("\uf0b7", "\uf0d8", "\uf0fc")) + LANGUAGE_LINE_HEIGHT = { "zh-cn": 1.4, "zh-tw": 1.4, @@ -70,6 +72,21 @@ def is_formula_font(font_name: str) -> bool: return FORMULA_FONT_PATTERN.match(font_name) is not None +def is_bullet_character(text: str, font_name: str | bytes = "") -> bool: + """Recognize Unicode bullets and common Symbol/Wingdings PUA bullets.""" + if text in BULLET_CHARACTERS: + return True + if isinstance(font_name, bytes): + font_name = font_name.decode(errors="ignore") + return ( + text in PRIVATE_USE_BULLETS + and re.search( + r"wingdings|webdings|symbol|dingbats", font_name, re.IGNORECASE + ) + is not None + ) + + def line_height_for_language(language: str) -> float: """Return the translation line-height multiplier for a target language.""" return LANGUAGE_LINE_HEIGHT.get(language.lower(), 1.1) diff --git a/references/preservation-rules.md b/references/preservation-rules.md index 71f36bf..274453e 100644 --- a/references/preservation-rules.md +++ b/references/preservation-rules.md @@ -15,6 +15,7 @@ The bundled core translates ordinary text while retaining document structures wh - A layout-model table is translated cell by cell only when PyMuPDF can match a cell grid to at least half of the detected table region. - Visually merged cells are split into x-position clusters so natural-language labels can be translated without sending adjacent abbreviations, identifiers, numbers, or units to the translation service. - Each reliable cell is reflowed within its own bounds while the source grid, fills, and borders remain unchanged. +- When a dense cell is shrunk vertically, its line spacing is recomputed from the final font size so the last line cannot spill into the next row. - Cell translations may shrink to half the source font size. If text still cannot fit, that cell remains in the source language and the result is reported as partial. - Tables without a reliable cell grid remain fully protected. @@ -32,8 +33,11 @@ These classifications preserve the complete page layout instead of reflowing num - Vietnamese text uses a `1.2` line-height multiplier. - Windows uses Times New Roman when available; other environments use the downloaded Unicode font fallback. - Long translations scale down before rendering, wrap at word boundaries, reduce line height when necessary, and shrink again only when the paragraph still exceeds its original box. +- Width fitting deducts first-line indentation from the available line budget so justified or indented translations cannot cross the source right edge. - Extended bullets remain anchored, and vertically separated list items start new paragraphs. +- Symbol and Wingdings private-use bullets stay in their original embedded dingbat font instead of being emitted as missing glyphs by the macOS or Windows prose font. - Quarter-turn text keeps its source orientation. Rotated table headings are translated and fitted along their logical baseline instead of wrapping one glyph per line. +- Reflected text matrices paired with negative font sizes are normalized from their baseline direction; this prevents technically mirrored but visually upright source text from being replayed upside down. - Bold, italic, and bold-italic runs travel through the translator as validated style markers and use the matching Times New Roman face on Windows. Missing variants use synthetic weight/slant without discarding the style. ## Scan and source safety @@ -42,6 +46,7 @@ These classifications preserve the complete page layout instead of reflowing num - The core does not perform OCR. A scan without an extractable text layer remains untranslated. - Structural PDF repair uses a temporary copy. The source file is never overwritten. - The translated PDF retains the source page canvas and page count; a requested page subset limits translation rather than removing pages. +- The app emits only the mono translation. Documents of 200 pages or 50 MB and larger skip whole-document font subsetting and use light PDF serialization to avoid a long, UI-blocking finalization pass. ## Known limits diff --git a/requirements-app.txt b/requirements-app.txt index 5905f1f..26bdcf6 100644 --- a/requirements-app.txt +++ b/requirements-app.txt @@ -1,5 +1,8 @@ # Desktop app and packaging, on top of requirements.txt -r requirements.txt customtkinter==5.2.2 -tkinterdnd2==0.4.3 +# 0.6.2 carries native TkDND binaries for Apple Silicon. Keep the already +# verified Windows build on 0.4.3 until its release bundle is retested. +tkinterdnd2==0.6.2; sys_platform == "darwin" +tkinterdnd2==0.4.3; sys_platform != "darwin" pyinstaller==6.11.1 diff --git a/tests/test_app_gui.py b/tests/test_app_gui.py index cb6f5f8..44309d0 100644 --- a/tests/test_app_gui.py +++ b/tests/test_app_gui.py @@ -1,15 +1,19 @@ from __future__ import annotations import io +import subprocess import sys import tempfile import unittest from pathlib import Path +from unittest import mock try: - from app.gui import LANGUAGE_NAMES, collect_pdfs, ensure_writable_streams + from app.gui import App, LANGUAGE_NAMES, collect_pdfs, ensure_writable_streams, main except ImportError: # customtkinter and tkinterdnd2 are app-only dependencies + App = None collect_pdfs = None + main = None from scripts.translate_pdf import TARGET_LANGUAGES @@ -89,5 +93,44 @@ def test_leaves_a_working_stream_alone(self): self.assertIs(sys.stdout, marker) +@unittest.skipIf(App is None, "desktop app dependencies are not installed") +class OpenResultTests(unittest.TestCase): + def test_macos_uses_the_native_open_command(self): + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) + with ( + mock.patch("app.gui.sys.platform", "darwin"), + mock.patch("app.gui.subprocess.Popen") as launch, + ): + App._open(target) + + launch.assert_called_once_with( + ["open", str(target)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + + +@unittest.skipIf(main is None, "desktop app dependencies are not installed") +class PackagedSmokeTestTests(unittest.TestCase): + def test_smoke_test_loads_and_closes_the_app_without_entering_mainloop(self): + fake_app = mock.Mock() + with ( + mock.patch("app.gui.sys.argv", ["PDFTranslate", "--smoke-test"]), + mock.patch("app.gui.ensure_writable_streams"), + mock.patch("app.gui.use_bundled_assets"), + mock.patch("app.gui.ctk.set_appearance_mode"), + mock.patch("app.gui.ctk.set_default_color_theme"), + mock.patch("app.gui.App", return_value=fake_app), + ): + main() + + fake_app.withdraw.assert_called_once_with() + fake_app.update_idletasks.assert_called_once_with() + fake_app.destroy.assert_called_once_with() + fake_app.mainloop.assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_inline_formula_layout.py b/tests/test_inline_formula_layout.py index db75baf..35a0e1a 100644 --- a/tests/test_inline_formula_layout.py +++ b/tests/test_inline_formula_layout.py @@ -13,9 +13,13 @@ from pdf2zh.converter import ( IDENTITY_ORIENTATION, + OpType, TextStyle, line_offsets, matrix_font_size, + normalised_text_matrix, + operation_ink, + paragraph_width_budget, preferred_translation, should_translate_rotated_text, styled_text_matrix, @@ -24,6 +28,8 @@ text_orientation, text_style_from_font, uses_synthetic_bold, + vertical_ink_extent, + vertical_shift_to_bounds, ) from pdf2zh.high_level import output_style_font_paths from pdf2zh.pdfinterp import PDFPageInterpreterEx @@ -155,6 +161,33 @@ def test_negative_cell_slack_with_no_formula_extra_keeps_plain_leading(self): def test_lines_without_recorded_ink_fall_back_to_the_usual_leading(self): self.assertEqual([round(o, 4) for o in self._offsets({}, 2)], [0.0, 11.0, 22.0]) + def test_ink_uses_the_final_shrunk_font_size(self): + operations = [ + {"type": OpType.TEXT, "size": 5.0, "dy": 0.0, "lidx": 0}, + {"type": OpType.TEXT, "size": 5.0, "dy": 0.0, "lidx": 1}, + ] + ink = operation_ink(operations) + self.assertEqual(tuple(round(value, 1) for value in ink[0]), (-1.1, 3.9)) + self.assertEqual(tuple(round(value, 1) for value in ink[1]), (-1.1, 3.9)) + + def test_vertical_extent_includes_large_preserved_text_on_later_line(self): + ink = {0: (-1.1, 3.9), 1: (-2.4, 8.6)} + self.assertAlmostEqual(vertical_ink_extent(ink, [0.0, 6.0]), 12.3) + + def test_fitted_cell_text_is_shifted_inside_the_lower_border(self): + ink = {0: (-1.0, 4.0), 1: (-1.0, 4.0)} + self.assertEqual( + vertical_shift_to_bounds(20.0, ink, [0.0, 8.0], 13.0, 26.0), + 2.0, + ) + + def test_fitted_cell_text_is_shifted_inside_the_upper_border(self): + ink = {0: (-1.0, 4.0)} + self.assertEqual( + vertical_shift_to_bounds(20.0, ink, [0.0], 10.0, 22.0), + -2.0, + ) + class TableCellFitTests(unittest.TestCase): @staticmethod @@ -187,6 +220,13 @@ def test_quarter_turn_matrices_are_classified(self): self.assertEqual(text_orientation((0, -8, 8, 0, 0, 0)), (0, -1, 1, 0)) self.assertIsNone(text_orientation((6, 4, -4, 6, 0, 0))) + def test_reflected_upright_text_is_not_mistaken_for_rotation(self): + self.assertEqual(text_orientation((1, 0, 0, -1, 0, 0)), IDENTITY_ORIENTATION) + self.assertEqual( + normalised_text_matrix((1, 0, 0, -1, 0, 0)), + IDENTITY_ORIENTATION, + ) + def test_rotated_font_size_comes_from_matrix_not_glyph_advance(self): self.assertEqual(matrix_font_size((0, 8, -8, 0, 0, 0)), 8) @@ -231,6 +271,10 @@ def test_missing_style_faces_fall_back_to_the_regular_font(self): paths = output_style_font_paths("vi", "C:/missing/regular.ttf") self.assertEqual(set(paths.values()), {"C:\\missing\\regular.ttf"}) + def test_first_line_indent_is_deducted_from_width_budget(self): + self.assertEqual(paragraph_width_budget(20, 10, 110, 1), 90) + self.assertEqual(paragraph_width_budget(20, 10, 110, 3), 290) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_large_document_finalization.py b/tests/test_large_document_finalization.py new file mode 100644 index 0000000..da58466 --- /dev/null +++ b/tests/test_large_document_finalization.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from pdf2zh import high_level + + +class LargeDocumentFinalizationTests(unittest.TestCase): + def test_app_translation_requests_only_the_mono_document(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "book.pdf" + source.write_bytes(b"%PDF-1.7\n") + fake_pdf = mock.Mock() + with ( + mock.patch.object(high_level.pikepdf, "open", return_value=fake_pdf), + mock.patch.object( + high_level, + "translate_stream", + return_value=(b"%PDF-1.7\ntranslated", None, []), + ) as stream, + ): + high_level.translate([str(source)], output=str(root)) + + self.assertFalse(stream.call_args.kwargs["create_dual"]) + self.assertTrue((root / "book-mono.pdf").is_file()) + + def test_large_document_skips_the_blocking_subset_scan(self): + limit = high_level.LARGE_DOCUMENT_SUBSET_PAGE_LIMIT + self.assertTrue(high_level.should_subset_fonts(limit - 1, False)) + self.assertFalse(high_level.should_subset_fonts(limit, False)) + self.assertFalse( + high_level.should_subset_fonts( + 1, False, high_level.LARGE_DOCUMENT_BYTE_LIMIT + ) + ) + self.assertFalse(high_level.should_subset_fonts(1, True)) + + def test_large_document_uses_fast_serialization(self): + limit = high_level.LARGE_DOCUMENT_SUBSET_PAGE_LIMIT + self.assertEqual( + high_level.pdf_write_options(limit), + {"deflate": False, "garbage": 1, "use_objstms": 0}, + ) + self.assertEqual( + high_level.pdf_write_options(limit - 1), + {"deflate": True, "garbage": 3, "use_objstms": 1}, + ) + self.assertEqual( + high_level.pdf_write_options( + 1, high_level.LARGE_DOCUMENT_BYTE_LIMIT + ), + {"deflate": False, "garbage": 1, "use_objstms": 0}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_preservation_rules.py b/tests/test_preservation_rules.py index 42a72a5..86e1932 100644 --- a/tests/test_preservation_rules.py +++ b/tests/test_preservation_rules.py @@ -11,6 +11,7 @@ classify_preserved_page, cluster_table_words, formula_regions, + is_bullet_character, is_formula_font, is_scanned_page, line_height_for_language, @@ -123,6 +124,16 @@ def test_vietnamese_line_height_and_extended_bullets_are_preserved(self): self.assertEqual(line_height_for_language("vi"), 1.2) self.assertTrue({"•", "■", "▸", "◆", "⬤"}.issubset(BULLET_CHARACTERS)) + def test_office_private_use_bullets_keep_their_dingbat_font(self): + for character, font in ( + ("\uf0d8", "Wingdings"), + ("\uf0b7", "Symbol"), + ("\uf0fc", "Wingdings"), + ): + with self.subTest(character=hex(ord(character)), font=font): + self.assertTrue(is_bullet_character(character, font)) + self.assertFalse(is_bullet_character("\uf0d8", "Times New Roman")) + def test_full_page_image_is_classified_as_scanned(self): self.assertTrue( is_scanned_page([{"type": 1, "bbox": (0, 0, 80, 80)}], 10_000)