From 2316a8c419079504461d39e07251d63ab06541db Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 11 Oct 2026 11:51:25 -0400 Subject: [PATCH 01/91] ci: refactor workflows to use storytold/craft-common-actions (#1073) * ci: refactor workflows to use reusable artcraft-common-actions * ci: point reusable workflows to storytold/craft-common-actions * ci: pin storytold/craft-common-actions to commit 27a978f and pass secrets explicitly * ci: use @main (unpinned) for craft-common-actions with explanation comment --- .github/workflows/freebsd.yml | 31 +- .github/workflows/packaging-lint.yml | 47 +-- .github/workflows/release.yml | 510 +-------------------------- .github/workflows/windows-arm64.yml | 136 +------ 4 files changed, 29 insertions(+), 695 deletions(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index b852d863..37e9fbde 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -23,30 +23,7 @@ permissions: jobs: freebsd: - name: FreeBSD 14 (build + test) - runs-on: ubuntu-latest - timeout-minutes: 300 - steps: - - uses: actions/checkout@v4 - - name: Build and test in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Nothing to bring back; copying the target dir out of the VM fails on large builds. - copyback: false - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal - run: | - . "$HOME/.cargo/env" - # The checkout is synced in under another owner; let tests that call git read it. - git config --global --add safe.directory "*" - rustc --version - CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked - CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked - # The release packaging, end to end (a quicker release profile than the real release). - CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 packaging/freebsd/package.sh - tar -tzf dist/release/pdfcraft-*-freebsd-*.tar.gz | grep -E '/bin/pdfcraft$|/bin/pdfcraft-cli$|applications/|/share/pdfcraft/models/.+\.rten$' + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main + with: + craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index aed79a90..f54b0538 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -19,48 +19,5 @@ concurrency: jobs: lint: - runs-on: ubuntu-latest - env: - ACTIONLINT_VERSION: 1.7.12 - steps: - - uses: actions/checkout@v4 - - name: Install tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml - curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ - | tar -xz -C "$RUNNER_TEMP" actionlint - - name: actionlint - run: | - "$RUNNER_TEMP/actionlint" -color - - name: shellcheck - run: shellcheck packaging/*.sh packaging/*/*.sh - - name: PowerShell - shell: pwsh - run: | - foreach ($f in Get-ChildItem packaging/windows/*.ps1) { - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null - if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 } - Write-Output "ok $($f.Name)" - } - - name: XML, desktop entry, AppStream, Flatpak manifest - run: | - xmllint --noout packaging/windows/*.wxs packaging/macos/*.plist* packaging/linux/*.xml* - # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon (#57). - if grep -oE '"$RUNNER_TEMP/ai.storyteller.pdfcraft.metainfo.xml" - appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.pdfcraft.metainfo.xml" - python3 - <<'EOF' - import yaml - src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.pdfcraft.yml")) - bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml")) - for m in (src, bundle): - assert m["id"] == "ai.storyteller.pdfcraft" and m["command"] == "pdfcraft", m["id"] - for key in ("runtime", "runtime-version", "sdk", "finish-args"): - assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" - print("Flatpak manifests ok") - EOF + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af34d040..b5929d06 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,3 @@ -# Release pipeline: every push to `release` builds signed installers for every platform and -# creates or updates the draft GitHub Release "PdfCraft v". See docs/releasing.md. -# -# The jobs that sign (macOS, Windows) and the one that drafts the release run in the `release` -# environment (restricted to the `release` branch), which holds the signing secrets. Every secret -# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The -# other jobs use no secrets and so no environment: a dispatch on another branch builds and checks -# them (a dry run), while the signing jobs are refused there and the draft-release job, which -# needs them, is skipped. name: Release on: @@ -20,494 +11,27 @@ on: default: "" permissions: - contents: read + contents: write concurrency: group: release-${{ github.ref }} - # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn. cancel-in-progress: false -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - PDFCRAFT_BUILD_SHA: ${{ github.sha }} - INPUT_VERSION: ${{ inputs.version }} - jobs: - version: - name: version - runs-on: ubuntu-latest - outputs: - version: ${{ steps.v.outputs.version }} - date: ${{ steps.v.outputs.date }} - prerelease: ${{ steps.v.outputs.prerelease }} - steps: - - uses: actions/checkout@v4 - - id: v - name: Resolve version - run: | - . packaging/env.sh - v="${INPUT_VERSION#v}" - v="${v:-$VERSION}" - if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then - echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1" - exit 1 - fi - case "$v" in *-*) pre=true ;; *) pre=false ;; esac - { - echo "version=$v" - echo "date=$(date -u +%Y-%m-%d)" - echo "prerelease=$pre" - } >>"$GITHUB_OUTPUT" - echo "### PdfCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" - - macos: - name: macOS universal - needs: version - runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) - environment: release - env: - PDFCRAFT_VERSION: ${{ needs.version.outputs.version }} - PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - # The craft-fonts build input (Japanese fonts): every release embeds it. Bump deliberately. - - name: Check out craft-fonts - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 - path: craft-fonts - - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@v2 - with: - key: release-macos-universal - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$PDFCRAFT_VERSION" - - name: Import signing certificate - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - run: packaging/macos/import-cert.sh - - name: Build, sign, notarize - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@v4 - with: - name: release-macos - path: dist/release/* - if-no-files-found: error - - name: Remove temporary keychain - if: always() - run: | - if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi - - windows: - name: Windows ${{ matrix.arch }} - needs: version - runs-on: windows-latest - environment: release - strategy: - fail-fast: false - matrix: - include: - - { arch: x64, target: x86_64-pc-windows-msvc } - - { arch: x86, target: i686-pc-windows-msvc } - # Windows on ARM (#61): cross-compiled on the x64 runner, so signing and WiX run as above. - # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. - - { arch: arm64, target: aarch64-pc-windows-msvc } - env: - PDFCRAFT_VERSION: ${{ needs.version.outputs.version }} - PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - # The craft-fonts build input (Japanese fonts): every release embeds it. Bump deliberately. - - name: Check out craft-fonts - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 - path: craft-fonts - - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 - with: - key: release-windows-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - shell: bash - run: cargo xtask version set "$PDFCRAFT_VERSION" - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build, sign, package - shell: pwsh - env: - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} - AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} - AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@v4 - with: - name: release-windows-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - linux: - name: Linux ${{ matrix.arch }} - needs: version - # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, - # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-22.04, deb: amd64 } - - { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 } - env: - PDFCRAFT_VERSION: ${{ needs.version.outputs.version }} - PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - NFPM_VERSION: 2.47.0 - steps: - - uses: actions/checkout@v4 - # The craft-fonts build input (Japanese fonts): every release embeds it. Bump deliberately. - - name: Check out craft-fonts - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 - path: craft-fonts - - name: Install build and packaging tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ - desktop-file-utils appstream python3-yaml zsync - curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ - "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" - sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - key: release-linux-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$PDFCRAFT_VERSION" - - name: Build and package (AppImage, deb, rpm, tar.gz, CLI tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/linux/package.sh - - name: Check the packages - run: | - dpkg-deb --info dist/release/*.deb - dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/pdfcraft$|metainfo|applications' - # The OCR models (#103), where the installed app looks: /../share/pdfcraft/models. - dpkg-deb --contents dist/release/*.deb | grep -E 'usr/share/pdfcraft/models/.+\.rten$' - # Named exactly: dist/release also holds the CLI-only tar.gz, and `tar -tzf a b` reads b as a member. - tar -tzf "dist/release/pdfcraft-$PDFCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" | grep -E '/share/pdfcraft/models/.+\.rten$' - ldd target/release/pdfcraft - # The CLI-only archive: the binary and its licences, and it runs. - cli="dist/release/pdfcraft-cli-$PDFCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - tar -tzf "$cli" | grep -E '/pdfcraft-cli$' - tar -tzf "$cli" | grep -E '/LICENSE-APACHE$' - mkdir -p "$RUNNER_TEMP/cli" && tar -xzf "$cli" -C "$RUNNER_TEMP/cli" - "$RUNNER_TEMP/cli/pdfcraft-cli-$PDFCRAFT_VERSION-linux-${{ matrix.arch }}/pdfcraft-cli" --version - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.pdfcraft.yml')); assert m['id'] == 'ai.storyteller.pdfcraft'" - for appimage in dist/release/*.AppImage; do - chmod +x "$appimage" - APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version - # Update information is embedded, and the .zsync for delta updates sits beside it. - "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' - test -s "$appimage.zsync" - done - - uses: actions/upload-artifact@v4 - with: - name: release-linux-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - linux-riscv64: - name: Linux riscv64 - needs: version - # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release - # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's - # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only (the - # app tree and the CLI-only archive); the CLI is smoke-tested under QEMU. - runs-on: ubuntu-24.04 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - PDFCRAFT_VERSION: ${{ needs.version.outputs.version }} - PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - # The craft-fonts build input (Japanese fonts): every release embeds it. Bump deliberately. - - name: Check out craft-fonts - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 - path: craft-fonts - - uses: dtolnay/rust-toolchain@stable - with: - targets: riscv64gc-unknown-linux-gnu - - uses: Swatinem/rust-cache@v2 - with: - key: release-linux-riscv64 - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$PDFCRAFT_VERSION" - - name: Install cross-compilation dependencies - run: | - . /etc/os-release - sudo dpkg --add-architecture riscv64 - # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. - if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then - sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources - fi - if [ -f /etc/apt/sources.list ]; then - sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list - sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list - fi - for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do - echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" - done | sudo tee /etc/apt/sources.list.d/riscv64.list - sudo apt-get update - # The same -dev packages as the linux job, for riscv64; the C toolchain also builds - # aws-lc-sys and ring (signing, TLS). - sudo apt-get install -y --no-install-recommends \ - gcc-riscv64-linux-gnu g++-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross \ - libc6-dev:riscv64 qemu-user \ - libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ - libxi-dev:riscv64 libgl1-mesa-dev:riscv64 - - name: Build and package (tar.gz, CLI tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc - CC_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-gcc - CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++ - AR_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-ar - PKG_CONFIG_SYSROOT_DIR: / - PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig - PKG_CONFIG_ALLOW_CROSS: "1" - CROSS_ARCH: riscv64 - CROSS_TARGET: riscv64gc-unknown-linux-gnu - CROSS_COMPILE: riscv64-linux-gnu- - EMULATOR: qemu-riscv64 - QEMU_LD_PREFIX: /usr/riscv64-linux-gnu - run: packaging/linux/package.sh --formats "tar cli" - - name: Check the packages - run: | - tar -tzf "dist/release/pdfcraft-$PDFCRAFT_VERSION-linux-riscv64.tar.gz" | grep -E '/bin/pdfcraft$' - # The OCR models (#103), where the installed app looks: /../share/pdfcraft/models. - tar -tzf "dist/release/pdfcraft-$PDFCRAFT_VERSION-linux-riscv64.tar.gz" | grep -E '/share/pdfcraft/models/.+\.rten$' - cli="dist/release/pdfcraft-cli-$PDFCRAFT_VERSION-linux-riscv64.tar.gz" - tar -tzf "$cli" | grep -E '/pdfcraft-cli$' - tar -tzf "$cli" | grep -E '/LICENSE-APACHE$' - file target/riscv64gc-unknown-linux-gnu/release/pdfcraft | grep -F 'RISC-V' - - uses: actions/upload-artifact@v4 - with: - name: release-linux-riscv64 - path: dist/release/*.tar.gz - if-no-files-found: error - - flatpak: - name: Flatpak ${{ matrix.arch }} - needs: [version, linux] - # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust - # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-24.04 } - - { arch: aarch64, runner: ubuntu-24.04-arm } - env: - PDFCRAFT_VERSION: ${{ needs.version.outputs.version }} - PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - name: release-linux-${{ matrix.arch }} - path: dist/linux - - name: Install flatpak-builder - run: | - sudo apt-get update - # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's - # `appstreamcli compose`, which renders the scalable icon and fails without it. - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common - # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - name: Build the Flatpak bundle - run: packaging/linux/flatpak-bundle.sh "dist/linux/pdfcraft-$PDFCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@v4 - with: - name: release-flatpak-${{ matrix.arch }} - path: dist/release/*.flatpak - if-no-files-found: error - - freebsd: - name: FreeBSD x86_64 - needs: version - # GitHub has no FreeBSD runners: build in a FreeBSD VM (see .github/workflows/freebsd.yml). - runs-on: ubuntu-latest - timeout-minutes: 180 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - PDFCRAFT_VERSION: ${{ needs.version.outputs.version }} - PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - VERSION_OVERRIDE: ${{ inputs.version }} - steps: - - uses: actions/checkout@v4 - # The craft-fonts build input (Japanese fonts): every release embeds it. Bump deliberately. - - name: Check out craft-fonts - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 - path: craft-fonts - - name: Build and package in a FreeBSD VM (tar.gz) - # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build). - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - envs: "PDFCRAFT_VERSION PDFCRAFT_BUILD_DATE VERSION_OVERRIDE" - # The same packages as the FreeBSD CI workflow, plus bash for the packaging script. - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable - run: | - set -e - . "$HOME/.cargo/env" - git config --global --add safe.directory "$PWD" - export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 - if [ -n "$VERSION_OVERRIDE" ]; then cargo xtask version set "$PDFCRAFT_VERSION"; fi - packaging/freebsd/package.sh - # Only dist/ comes back out of the VM: copying the target dir back fails on large builds. - rm -rf target - - uses: actions/upload-artifact@v4 - with: - name: release-freebsd-x86_64 - path: dist/release/* - if-no-files-found: error - - web: - name: Web (wasm) - needs: version - runs-on: ubuntu-latest - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - PDFCRAFT_VERSION: ${{ needs.version.outputs.version }} - PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - TRUNK_VERSION: 0.21.14 - steps: - - uses: actions/checkout@v4 - # The craft-fonts build input (Japanese fonts): every release embeds it. Bump deliberately. - - name: Check out craft-fonts - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 - path: craft-fonts - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 - with: - key: release-web - - name: Install trunk - run: | - curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ - | tar -xz -C "$HOME/.cargo/bin" trunk - trunk --version - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$PDFCRAFT_VERSION" - - name: Build and zip - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/web/package.sh - - uses: actions/upload-artifact@v4 - with: - name: release-web - path: dist/release/* - if-no-files-found: error - release: - name: Draft GitHub Release - # Needs the signing jobs, which only run on `release`: a branch dry run never drafts a release. - needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web] - runs-on: ubuntu-latest - environment: release - permissions: - contents: write - env: - VERSION: ${{ needs.version.outputs.version }} - PRERELEASE: ${{ needs.version.outputs.prerelease }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - steps: - - uses: actions/download-artifact@v4 - with: - pattern: release-* - path: artifacts - merge-multiple: true - - name: Checksums - working-directory: artifacts - run: | - sha256sum -- * | tee SHA256SUMS.txt - - name: Create or update the draft release - working-directory: artifacts - run: | - tag="v$VERSION" - title="PdfCraft v$VERSION" - pre=() - if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi - if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then - if [ "$draft" != "true" ]; then - echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release." - exit 1 - fi - echo "Updating draft $tag: replacing its assets" - gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do - gh release delete-asset "$tag" "$asset" --yes - done - gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE" - gh release upload "$tag" --clobber -- * - else - echo "Creating draft $tag" - gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- * - fi - { - echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:" - echo - sed 's/^/ /' SHA256SUMS.txt - } >>"$GITHUB_STEP_SUMMARY" + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/release.yml@main + with: + app-name: pdfcraft + app-title: PdfCraft + craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0 + version: ${{ inputs.version }} + secrets: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 2ddcf82a..d470419d 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -25,134 +25,10 @@ concurrency: permissions: contents: read -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - jobs: - package: - name: package (cross-compiled on x64) - runs-on: windows-latest - timeout-minutes: 90 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@v2 - with: - key: windows-arm64-package - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build and package (unsigned) - shell: pwsh - env: - DIST: ${{ github.workspace }}\dist\arm64 - run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@v4 - with: - name: pdfcraft-windows-arm64 - path: dist/arm64/* - if-no-files-found: error - retention-days: 7 - - install: - name: install and run on ARM64 - needs: package - runs-on: windows-11-arm - timeout-minutes: 30 - steps: - - uses: actions/download-artifact@v4 - with: - name: pdfcraft-windows-arm64 - path: dist - - name: Install the MSI, check it, run it, uninstall it, reinstall without the desktop shortcut - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName - # A command-line override must not put files in a user profile while registering - # their handlers machine-wide (#305). This runs on a disposable CI runner. - $p = Start-Process msiexec.exe -WindowStyle Hidden -ArgumentList '/i', "`"$msi`"", '/qn', 'ALLUSERS=2', 'MSIINSTALLPERUSER=1', '/l*v', 'per-user.log' -Wait -PassThru - if ($p.ExitCode -ne 1603) { Get-Content per-user.log -Tail 60; throw "per-user install should fail with 1603, got $($p.ExitCode)" } - if (-not (Select-String -Path per-user.log -SimpleMatch 'PdfCraft must be installed for all users.')) { - Get-Content per-user.log -Tail 60; throw 'per-user install did not fail on the scope guard' - } - $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru - if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } - $shortcuts = @( - (Join-Path ([Environment]::GetFolderPath('CommonPrograms')) 'PdfCraft.lnk'), - (Join-Path ([Environment]::GetFolderPath('CommonDesktopDirectory')) 'PdfCraft.lnk') - ) - # Plain shortcuts to the program (#143), not advertised ones, so TargetPath is the exe. - $exePath = Join-Path $env:ProgramFiles 'PdfCraft\pdfcraft.exe' - $wsh = New-Object -ComObject WScript.Shell - foreach ($shortcut in $shortcuts) { - if (-not (Test-Path -LiteralPath $shortcut)) { throw "installer did not create $shortcut" } - $target = $wsh.CreateShortcut($shortcut).TargetPath - if ($target -ne $exePath) { throw "$shortcut points to '$target', not $exePath" } - Write-Output "ok $shortcut -> $target" - } - $dir = Join-Path $env:ProgramFiles 'PdfCraft' - foreach ($exe in 'pdfcraft.exe', 'pdfcraft-cli.exe') { - $path = Join-Path $dir $exe - if (-not (Test-Path $path)) { Get-ChildItem -Recurse $env:ProgramFiles -Filter $exe | Select-Object -First 3; throw "$exe not installed in $dir" } - $bytes = [System.IO.File]::ReadAllBytes($path) - $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) - if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } - Write-Output "ok $path (ARM64)" - } - # The installed CLI runs natively: open, render and edit a small PDF through the engine. - $cli = Join-Path $dir 'pdfcraft-cli.exe' - & $cli tools | Out-Null - if ($LASTEXITCODE -ne 0) { throw "pdfcraft-cli tools exited $LASTEXITCODE" } - $body = 'BT /F1 24 Tf 20 150 Td (Hello ARM64) Tj ET' - $objs = @('<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', - '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>', - "<< /Length $($body.Length) >>`nstream`n$body`nendstream", '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>') - $sb = [System.Text.StringBuilder]::new("%PDF-1.7`n"); $offsets = @() - for ($i = 0; $i -lt $objs.Count; $i++) { $offsets += $sb.Length; [void]$sb.Append("$($i + 1) 0 obj`n$($objs[$i])`nendobj`n") } - $xref = $sb.Length - [void]$sb.Append("xref`n0 $($objs.Count + 1)`n0000000000 65535 f `n") - foreach ($o in $offsets) { [void]$sb.Append(('{0:D10} 00000 n `n' -f $o)) } - [void]$sb.Append("trailer`n<< /Size $($objs.Count + 1) /Root 1 0 R >>`nstartxref`n$xref`n%%EOF`n") - New-Item -ItemType Directory -Force smoke | Out-Null - [System.IO.File]::WriteAllText("$PWD\smoke\hello.pdf", $sb.ToString(), [System.Text.Encoding]::ASCII) - $steps = '[{"tool":"doc_open","args":{"path":"hello.pdf"}},{"tool":"text_paragraphs","args":{"doc":1,"page":1}},{"tool":"text_edit","args":{"doc":1,"page":1,"paragraph":1,"text":"Edited on ARM64"}},{"tool":"page_render","args":{"doc":1,"page":1,"dpi":72},"out":"page.png"},{"tool":"doc_save","args":{"doc":1,"path":"out.pdf"}}]' - [System.IO.File]::WriteAllText("$PWD\smoke\steps.json", $steps) - $out = & $cli run --script smoke\steps.json --root smoke 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "pdfcraft-cli run exited $LASTEXITCODE" } - if ($out -notmatch 'Hello ARM64') { Write-Output $out; throw 'text_paragraphs did not read the page' } - foreach ($f in 'smoke\page.png', 'smoke\out.pdf') { if (-not (Test-Path $f) -or (Get-Item $f).Length -eq 0) { Write-Output $out; throw "$f was not written" } } - Write-Output 'ok pdfcraft-cli: opened, read, edited, rendered and saved a PDF on ARM64' - $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } - if (Test-Path (Join-Path $dir 'pdfcraft.exe')) { throw 'uninstall left pdfcraft.exe behind' } - foreach ($shortcut in $shortcuts) { - if (Test-Path -LiteralPath $shortcut) { throw "uninstall left $shortcut behind" } - } - Write-Output 'ok uninstalled' - # Unattended installs can opt out of the desktop shortcut (#143); the Start Menu one stays. - $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', 'INSTALLDESKTOPSHORTCUT=0', '/l*v', 'install-nodesktop.log' -Wait -PassThru - if ($p.ExitCode -ne 0) { Get-Content install-nodesktop.log -Tail 60; throw "msiexec /i INSTALLDESKTOPSHORTCUT=0 exited $($p.ExitCode)" } - if (-not (Test-Path -LiteralPath $shortcuts[0])) { throw "INSTALLDESKTOPSHORTCUT=0 also skipped $($shortcuts[0])" } - if (Test-Path -LiteralPath $shortcuts[1]) { throw "INSTALLDESKTOPSHORTCUT=0 still created $($shortcuts[1])" } - $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } - if (Test-Path -LiteralPath $shortcuts[0]) { throw "uninstall left $($shortcuts[0]) behind" } - Write-Output 'ok INSTALLDESKTOPSHORTCUT=0: Start Menu shortcut only' - - test: - name: test (native ARM64) - runs-on: windows-11-arm - timeout-minutes: 120 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - key: windows-arm64-test - - name: Test - run: cargo test --workspace --locked + windows-arm64: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + with: + app-name: pdfcraft + app-dir: PdfCraft From 4666e74691dc41cf66b8d62eb02cdba6b88bce7a Mon Sep 17 00:00:00 2001 From: pauliusiv123-rgb <235694617+pauliusiv123-rgb@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:53:16 +0300 Subject: [PATCH 02/91] Text extraction: word gaps follow position on the line, not drawing order (#1062) (#1074) A line's glyphs are sorted left to right, but the space for a word gap was put before whichever of the two glyphs came later in the content stream. When the right-hand run was drawn first, the space landed inside the left run: 20.0 19.4 came out as "20. 019.4". The same rule put the space one letter early between right-to-left words drawn left to right. Gaps are now marked by position on the line and reversed together with right-to-left runs. --- crates/render/src/text.rs | 53 ++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 8c47af21..7f64c3f5 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -819,7 +819,23 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { for si in &blocks[b].0 { let s = &segs[*si]; let mut idx = s.idx.clone(); - // Reverse right-to-left runs (visual → logical). + // Word gaps relative to this line's typical letter gap (handles tracking). + let mut gaps: Vec = s.idx.windows(2).map(|w| glyphs[w[1]].rect[0] - glyphs[w[0]].rect[2]).collect(); + gaps.sort_by(f32::total_cmp); + let typical = gaps.get(gaps.len() / 3).copied().unwrap_or(0.0).max(0.0); + let threshold = (typical + s.h * WORD_GAP).max(s.h * WORD_GAP); + // `spaces[w]`: a word gap left of the glyph at `idx[w]`, by position on the line, not + // by drawing order (#1062). + let mut spaces = vec![false; idx.len()]; + for (space, pair) in spaces.iter_mut().skip(1).zip(s.idx.windows(2)) { + let &[p, c] = pair else { continue }; + let gap = glyphs[c].rect[0] - glyphs[p].rect[2]; + let (pt, ct) = (&glyphs[p].text, &glyphs[c].text); + let cjk = pt.chars().any(is_cjk) && ct.chars().any(is_cjk) && gap < s.h * 0.5; + let tight_cluster = (pt.chars().any(is_complex) || ct.chars().any(is_complex)) && gap < s.h * 0.6; + *space = gap > threshold && !cjk && !tight_cluster && !pt.trim().is_empty() && !ct.trim().is_empty(); + } + // Reverse right-to-left runs (visual → logical), and the gaps between their glyphs with them. let rtl = |i: usize| glyphs[i].text.chars().any(is_rtl); let mut k = 0; while k < idx.len() { @@ -831,30 +847,12 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { e += 1; } idx[k..=e].reverse(); + spaces[k + 1..=e].reverse(); k = e + 1; } else { k += 1; } } - // Word gaps relative to this line's typical letter gap (handles tracking). - let mut gaps: Vec = s.idx.windows(2).map(|w| glyphs[w[1]].rect[0] - glyphs[w[0]].rect[2]).collect(); - gaps.sort_by(f32::total_cmp); - let typical = gaps.get(gaps.len() / 3).copied().unwrap_or(0.0).max(0.0); - let threshold = (typical + s.h * WORD_GAP).max(s.h * WORD_GAP); - let mut spaces = vec![false; idx.len()]; - for w in 1..s.idx.len() { - let (p, c) = (s.idx[w - 1], s.idx[w]); - let gap = glyphs[c].rect[0] - glyphs[p].rect[2]; - let (pt, ct) = (&glyphs[p].text, &glyphs[c].text); - let cjk = pt.chars().any(is_cjk) && ct.chars().any(is_cjk) && gap < s.h * 0.5; - let tight_cluster = (pt.chars().any(is_complex) || ct.chars().any(is_complex)) && gap < s.h * 0.6; - if gap > threshold && !cjk && !tight_cluster && !pt.trim().is_empty() && !ct.trim().is_empty() { - // Mark the space before glyph `c` wherever it ended up after RTL reversal. - if let Some(pos) = idx.iter().position(|x| *x == c.max(p)) { - spaces[pos] = true; - } - } - } for (k, g) in idx.iter().enumerate() { order.push(*g); line_of.push(line); @@ -1158,6 +1156,21 @@ mod tests { assert_eq!(layout(v).plain_text(), "每个字"); } + /// #1062: the word gap stays between the runs when the right-hand run is drawn first, and + /// between the words of a right-to-left line. + #[test] + fn word_gaps_follow_position_not_drawing_order() { + let mut v = Vec::new(); + word(&mut v, "19.4", 40.0, 10.0, 6.0); + word(&mut v, "20.0", 10.0, 10.0, 6.0); + assert_eq!(layout(v).plain_text(), "20.0 19.4"); + let mut v = Vec::new(); + // "שלום עולם" drawn visually: עולם reversed on the left, שלום reversed on the right. + word(&mut v, "םלוע", 10.0, 10.0, 6.0); + word(&mut v, "םולש", 40.0, 10.0, 6.0); + assert_eq!(layout(v).plain_text(), "שלום עולם"); + } + /// Two lines, "ab cd" and "e fg": words stop at blank glyphs, word gaps and line ends. #[test] fn words_and_lines_around_a_glyph() { From b7528271627ec658710a140adb8d10b213ebe30b Mon Sep 17 00:00:00 2001 From: Alexis Date: Sun, 11 Oct 2026 17:56:06 +0200 Subject: [PATCH 03/91] M7: watermark offset from the page centre (edit.watermark-appearance) (#1076) Add Watermark / Update Watermark can now move the mark from the page centre by a horizontal and a vertical offset in points (right and up). - edit: add_watermark refuses a non-finite offset instead of writing it into the content stream; tests for the placement and the refusal. - automation: doc_watermark takes offset_x and offset_y (clamped to +-20,000 points); a test saves, reopens and measures the text moving. - ui-egui: an "Offset from centre" row with Horizontal and Vertical fields; the preview follows it for text and for picture watermarks. - i18n: "Offset from centre" in the catalogs that have a coverage test (ar, bg, de, fr, hu, it, ja, pt-br, ru, uk, zh-hans); hashes updated in ATTRIBUTION.toml. - parity and roadmap: edit.watermark-appearance stays partial (no offset from the page edge, no print/screen-only variants, no repeat); image and PDF watermarks no longer listed as missing; progress log line. Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 22 ++++++++--------- ROADMAP.md | 1 + crates/automation/src/lib.rs | 7 +++++- crates/automation/src/tools.rs | 4 ++- crates/automation/tests/automation.rs | 34 ++++++++++++++++++++++++++ crates/edit/src/lib.rs | 7 +++++- crates/edit/src/tests.rs | 20 +++++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/marks_ui.rs | 35 ++++++++++++++++++++------- crates/ui-egui/tests/marks.rs | 1 + docs/roadmap.md | 2 +- parity/acrobat-features.toml | 4 +-- 22 files changed, 122 insertions(+), 26 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index cfa2e434..180e80f4 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2c0f7bc1c225a95d2322e5f92e42ca3b07652f54976d754e9901abeddc4d0dc6" +sha256 = "b98e2e4858e635dbc6930f2ff80e7e8a109d27abe996ac29c27b3b13b70e43f2" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fe8fc8af29190950048c3e3d5108ede6cf2da2f85ed7003fb2035ddb517a600c" +sha256 = "96b0200a9b1f16cb23fb9482a8189b3fd763f31469009c54e59cb0aa1dd5d7b3" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6d99c6a85e69b5448609ae54847700c908919be27d904bd0c73bf2c220a2da4e" +sha256 = "9b4eb2a8230e856a2fd2abd672fc954eaa02164dae7be6a4299451ecb7e732bd" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "09f654d07a87878802e87fc0436f9a5426bc27bfb2156aee912463c5dc8a04ea" +sha256 = "b08667b24b7bb8801e656516064125a29e47f2a88caadda4a81c362d680ed457" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8a7a2bfba1a2f9858ad62a74bd8229a1615a5bc6e51023b11b9768e7ea21d79c" +sha256 = "a574d0ebee8184f917354377257d32312f711f4457e855b9d697d6af15ecc886" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dc0af15b819b9059bc23a73a1e794d1ae5291594b2ff0fae2595274fccc9c281" +sha256 = "d4d9a78c19215360e64d1fcf201415546cabc5f8937888f3f5c1616106b94838" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "16c6e6cf857b70f7ade009bc9c87fceb3831f5f3281ac7fabc0fd7bfd2c5d28e" +sha256 = "f5549263f28e22911e2dc8612d8f793112729cecb8d7d8ab6d0faaf3acd4c0b0" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9e8c0929c5839b2a5f5c93a2d3457bcce24b118a11fd6b37476aa3584fa5ce76" +sha256 = "c4c77b4451a0505bfe7aacea5a9ef3701a10e48060dcb98558716b28592d72a1" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "47720091895fc1a4c1ff332a660192fe4a9666f32002e5899030b3ca3421f337" +sha256 = "28d98970e3b7c11b1468f2ceec182979160681ca2719ec47246a1655ab452054" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fa2f00414a8f064f4d6e47d57702a100743c8b64b05dcbe93585330427c83f16" +sha256 = "3b732bcfe593c4619909deab926a8aa30826e5effd957866315c7969338eca94" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "271a9b34b0d13d1734de53d88a09b49d589eb556c75fe04dd1fa5172151c5b1a" +sha256 = "263567299cee16b9c2577b6de92e5d0f3bbb82ef334beebbd130c27407db40e2" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/ROADMAP.md b/ROADMAP.md index 9e528c62..c4f216b6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. +- **2026-10-11 (M7, watermark placement):** A watermark can be moved from the page centre by a horizontal and a vertical offset in points: Add/Update Watermark has an "Offset from centre" row (the preview follows it, for text and for pictures) and `doc_watermark` takes `offset_x` and `offset_y`. A non-finite offset is refused instead of written into the page. `edit.watermark-appearance` stays partial: no offset from the page edge or in page units, no print/screen-only variants, no repeat. Image and PDF watermarks, already shipped, no longer appear as missing. The overall estimate is unchanged. - **2026-10-10 (M14, interface size):** Preferences ▸ Interface size scales the whole interface (50–250 %, setting `ui_scale`, control option `ui-scale`); 50 % brings an interface the system draws at 200 % back to 100 % (#456). Auto, the default, follows the desktop's text scaling: on Linux and the BSDs the desktop app reads GNOME's `text-scaling-factor` once at launch, so with Large Text (1.25) on a 1× display the interface no longer draws 13 px labels that show their pixels next to the system's text. The size is egui's zoom factor, so `pixels_per_point` grows with it and page rasters stay one texel per screen pixel; egui's own ⌘+/⌘− interface zoom is turned off so those keys stay page zoom. Still missing: KDE's font DPI and Windows' "Make text bigger" aren't read (both scale only text there), and egui's text is still greyscale-antialiased without the system's gamma, so small text looks rougher than GTK's at 100 %. - **2026-10-10 (M14, #496):** Shortcut displays share a UI formatter across toolbar search, registry menus, command palette, Help and tooltips. Modifier and key names use a dedicated catalog context in every bundled translation; missing entries keep the English key name rather than a command translation. German shows Strg/Umschalt/Entf, macOS retains its symbols, and runtime language changes refresh the display. Regression coverage checks localized layout widths, English/German switching, platform conventions and unchanged command execution. Milestone and effort estimates unchanged. diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index eec1f436..5a7020ec 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -76,6 +76,8 @@ fn failed(e: impl std::fmt::Display) -> ToolError { /// Default and maximum resolution for `page_render`. const DEFAULT_DPI: f64 = 96.0; const MAX_DPI: f64 = 600.0; +/// How far a watermark may be moved from the page centre, in points (a page is at most 14,400). +const WATERMARK_MAX_OFFSET: f64 = 20_000.0; /// Bookmarks in one page of the bookmark list, by default and at most. const BOOKMARK_PAGE: usize = 100; @@ -960,7 +962,10 @@ impl Automation { opacity: a.opt_num("opacity")?.unwrap_or(d.opacity), rotation: a.opt_num("rotation")?.unwrap_or(d.rotation), behind: a.opt_bool("behind")?.unwrap_or(false), - offset: [0.0; 2], + offset: [ + a.opt_num("offset_x")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET), + a.opt_num("offset_y")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET), + ], }; Edit::AddWatermark { pages, settings: wm, replace, file } } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index f5771d68..6c2e03bb 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -764,7 +764,7 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("doc_watermark", "Add watermark", "Add a watermark to pages: text, or a picture from `file` (an image, or page `file_page` of a PDF) at `scale` of the page (default 0.5); rotated (degrees counter-clockwise, default 45), semi-transparent (opacity 0–1, default 0.5), text fitted to the page unless font_size is given, on top unless behind: true. replace: true swaps out existing ones. Undoable.") + t("doc_watermark", "Add watermark", "Add a watermark to pages: text, or a picture from `file` (an image, or page `file_page` of a PDF) at `scale` of the page (default 0.5); rotated (degrees counter-clockwise, default 45), semi-transparent (opacity 0–1, default 0.5), text fitted to the page unless font_size is given, on top unless behind: true; moved from the page centre by `offset_x` (right) and `offset_y` (up) points (default 0). replace: true swaps out existing ones. Undoable.") .cmd("edit.watermark") .with(schema( json!({ @@ -779,6 +779,8 @@ pub fn tools() -> Vec { "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "rotation": { "type": "number" }, "behind": { "type": "boolean" }, + "offset_x": { "type": "number", "description": "Points right of the page centre (negative: left)." }, + "offset_y": { "type": "number", "description": "Points above the page centre (negative: below)." }, "replace": { "type": "boolean" }, }), &["doc"], diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 26bc2b26..ed15f1d0 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1396,6 +1396,40 @@ fn headers_watermarks_and_backgrounds_through_tools() { ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" })); } +#[test] +fn watermarks_move_from_the_page_centre_through_tools() { + let dir = workdir("wm-offset"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + // The top-left corner of "DRAFT" on page 1, in points (y grows downwards). + // Saved and reopened, so the search reads the file as another program would. + let mut saved = 0; + let mut corner = |a: &mut Automation| -> (f64, f64) { + saved += 1; + let path = format!("moved{saved}.pdf"); + ok(a, "doc_save", json!({ "doc": doc, "path": path })); + let copy = ok(a, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap(); + let found = ok(a, "text_find", json!({ "doc": copy, "query": "DRAFT" })); + let rect = + found["matches"][0]["rects"][0].as_array().unwrap_or_else(|| panic!("DRAFT is on the page: {found}; text: {:?}", page_text(a, copy))); + (rect[0].as_f64().unwrap(), rect[1].as_f64().unwrap()) + }; + // Both sit above the fixture's "Page 1" (at the page centre), or their letters would mix. + ok(&mut a, "doc_watermark", json!({ "doc": doc, "pages": [1], "text": "DRAFT", "rotation": 0, "font_size": 20, "offset_y": 60 })); + let centred = corner(&mut a); + ok( + &mut a, + "doc_watermark", + json!({ "doc": doc, "pages": [1], "text": "DRAFT", "rotation": 0, "font_size": 20, "offset_x": 30, "offset_y": 100, "replace": true }), + ); + let moved = corner(&mut a); + assert!((moved.0 - centred.0 - 30.0).abs() < 1.0, "right: {centred:?} → {moved:?}"); + assert!((centred.1 - moved.1 - 40.0).abs() < 1.0, "up is towards the top: {centred:?} → {moved:?}"); + // Huge offsets are clamped, and a value that isn't a number is refused. + ok(&mut a, "doc_watermark", json!({ "doc": doc, "pages": [1], "text": "DRAFT", "offset_y": 1e300, "replace": true })); + assert!(matches!(a.call("doc_watermark", &json!({ "doc": doc, "text": "X", "offset_x": "left" })), Err(ToolError::InvalidArgs(_)))); +} + #[test] fn exporting_images_and_text_through_tools() { let dir = workdir("export"); diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 467f5dd2..48e83cf4 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -491,7 +491,12 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac if wm.source.is_none() { drawable(text.lines())?; } - if !(wm.opacity.is_finite() && wm.rotation.is_finite() && wm.font_size.is_finite() && wm.font_size >= 0.0) { + if !(wm.opacity.is_finite() + && wm.rotation.is_finite() + && wm.font_size.is_finite() + && wm.font_size >= 0.0 + && wm.offset.iter().all(|o| o.is_finite())) + { return Err(EditError::Invalid("invalid watermark settings".into())); } if replace { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index d9f7f548..f6b5ab7c 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -150,6 +150,26 @@ fn watermarks_rotate_fade_and_can_go_behind() { assert_eq!(s.len(), 2, "no wrapper needed for content behind"); } +#[test] +fn watermark_offset_moves_the_centre_right_and_up() { + let mut doc = fixture(); + let wm = Watermark { text: "DRAFT".into(), rotation: 0.0, offset: [-100.0, 50.0], ..Watermark::default() }; + add_watermark(&mut doc, &[0], &wm, false).unwrap(); + let s = streams(&doc, 0); + let mark = s.iter().find(|c| c.contains("/PCMark /Watermark")).expect("the watermark stream"); + assert!(mark.contains("1 0 0 1 200 450 cm"), "{mark}"); +} + +#[test] +fn a_watermark_offset_that_is_not_finite_is_refused() { + for offset in [[f64::NAN, 0.0], [0.0, f64::INFINITY], [f64::NEG_INFINITY, 0.0]] { + let mut doc = fixture(); + let wm = Watermark { text: "DRAFT".into(), offset, ..Watermark::default() }; + assert!(matches!(add_watermark(&mut doc, &[0], &wm, false), Err(EditError::Invalid(_))), "{offset:?}"); + assert!(!doc.is_modified()); + } +} + #[test] fn invalid_requests_change_nothing() { let mut doc = fixture(); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 77e350e0..09b9b9fb 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -785,6 +785,7 @@ Rotation التدوير Appear behind page الظهور خلف الصفحة Appear on top of page الظهور فوق الصفحة + Offset from centre الإزاحة عن المركز From colour من لون Page Range Options خيارات نطاق الصفحات Preview: page {p} of {n} معاينة: صفحة {p} من {n} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index c777b282..b6a899ec 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -470,6 +470,7 @@ Rotation Завъртане Appear behind page Под съдържанието на страницата Appear on top of page Над съдържанието на страницата + Offset from centre Отместване от центъра From colour От цвят Page Range Options Опции за диапазон на страниците Preview: page {p} of {n} Преглед: страница {p} от {n} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index db14c509..2ec96c3c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -758,6 +758,7 @@ Rotation Drehung Appear behind page Hinter der Seite anzeigen Appear on top of page Vor der Seite anzeigen + Offset from centre Abstand von der Mitte From colour Aus Farbe Page Range Options Optionen für Seitenbereich Preview: page {p} of {n} Vorschau: Seite {p} von {n} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 937bed59..fc5c95a9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -758,6 +758,7 @@ Rotation Rotation Appear behind page Afficher derrière la page Appear on top of page Afficher au-dessus de la page + Offset from centre Décalage par rapport au centre From colour À partir d'une couleur Page Range Options Options d'intervalle de pages Preview: page {p} of {n} Aperçu : page {p} sur {n} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3abb9ba1..38faaeb3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -755,6 +755,7 @@ Rotation Elforgatás Appear behind page Az oldal mögött Appear on top of page Az oldal előtt + Offset from centre Eltolás a középponttól From colour Színből Page Range Options Oldaltartomány beállításai Preview: page {p} of {n} Előnézet: {p}. oldal / {n} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d718ff51..d0b56fd9 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -781,6 +781,7 @@ Rotation Rotazione Appear behind page Dietro la pagina Appear on top of page Sopra la pagina + Offset from centre Scostamento dal centro From colour Da colore Page Range Options Opzioni intervallo di pagine Preview: page {p} of {n} Anteprima: pagina {p} di {n} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 36dbcaa2..924fac2d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -785,6 +785,7 @@ Rotation 回転 Appear behind page ページの背面に表示 Appear on top of page ページの前面に表示 + Offset from centre 中心からのオフセット From colour 色を指定 Page Range Options ページ範囲のオプション Preview: page {p} of {n} プレビュー: {n} ページ中 {p} ページ diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4004bd9d..2f400784 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -423,6 +423,7 @@ Rotation Rotação Appear behind page Exibir atrás da página Appear on top of page Exibir sobre a página + Offset from centre Deslocamento a partir do centro From colour A partir da cor Page Range Options Opções de intervalo de páginas Preview: page {p} of {n} Pré-visualização: página {p} de {n} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 431958f3..d173b3e3 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -779,6 +779,7 @@ Rotation Поворот Appear behind page Позади страницы Appear on top of page Поверх страницы + Offset from centre Смещение от центра From colour Из цвета Page Range Options Параметры диапазона страниц Preview: page {p} of {n} Предпросмотр: стр. {p} из {n} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5c2d8f87..bd59a47b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -783,6 +783,7 @@ Rotation Поворот Appear behind page Позаду сторінки Appear on top of page Поверх сторінки + Offset from centre Зсув від центру From colour Із кольору Page Range Options Параметри діапазону сторінок Preview: page {p} of {n} Попередній перегляд: сторінка {p} із {n} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 6645b973..3477455f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -477,6 +477,7 @@ Rotation 旋转 Appear behind page 显示在页面后面 Appear on top of page 显示在页面上层 + Offset from centre 与中心的偏移 From colour 使用颜色 Page Range Options 页面范围选项 Preview: page {p} of {n} 预览:第 {p} 页,共 {n} 页 diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index c92ae1f6..36cf4038 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -8,6 +8,9 @@ use pdfcraft_engine::{Background, Edit, HeaderFooter, MarkKind, Watermark}; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, widgets}; +/// How far a watermark may be moved from the page centre, in points. +const MAX_OFFSET: f64 = 20_000.0; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Subset { All, @@ -269,6 +272,13 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M ui.radio_value(&mut d.wm.behind, true, tl!("Appear behind page")); ui.radio_value(&mut d.wm.behind, false, tl!("Appear on top of page")); }); + ui.horizontal(|ui| { + ui.label(tl!("Offset from centre")); + ui.label(tl!("Horizontal")); + ui.add(egui::DragValue::new(&mut d.wm.offset[0]).range(-MAX_OFFSET..=MAX_OFFSET).suffix(" pt")); + ui.label(tl!("Vertical")); + ui.add(egui::DragValue::new(&mut d.wm.offset[1]).range(-MAX_OFFSET..=MAX_OFFSET).suffix(" pt")); + }); }); }); } @@ -318,19 +328,14 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M } MarkKind::Watermark if file_name.is_some() => { let side = r.width().min(r.height()) * wm.scale as f32; + let centre = r.center() + vec2(wm.offset[0] as f32 * s, -wm.offset[1] as f32 * s); p.rect_stroke( - Rect::from_center_size(r.center(), vec2(side, side)), + Rect::from_center_size(centre, vec2(side, side)), CornerRadius::ZERO, egui::Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Inside, ); - p.text( - r.center(), - egui::Align2::CENTER_CENTER, - file_name.clone().unwrap_or_default(), - egui::FontId::proportional(10.0), - Color32::GRAY, - ); + p.text(centre, egui::Align2::CENTER_CENTER, file_name.clone().unwrap_or_default(), egui::FontId::proportional(10.0), Color32::GRAY); } MarkKind::Watermark => { let text = if wm.text.trim().is_empty() { "CONFIDENTIAL" } else { wm.text.trim() }; @@ -344,7 +349,8 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M let half = galley.size() / 2.0; let (sn, cs) = angle.sin_cos(); let offset = vec2(half.x * cs - half.y * sn, half.x * sn + half.y * cs); - p.add(egui::epaint::TextShape::new(r.center() - offset, galley, Color32::BLACK).with_angle(angle)); + let centre = r.center() + vec2(wm.offset[0] as f32 * s, -wm.offset[1] as f32 * s); + p.add(egui::epaint::TextShape::new(centre - offset, galley, Color32::BLACK).with_angle(angle)); } MarkKind::Background if file_name.is_some() => { p.rect_stroke(r.shrink(6.0), CornerRadius::ZERO, egui::Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Inside); @@ -481,4 +487,15 @@ mod tests { let r = PageRange { all: true, subset: Subset::Odd, ..PageRange::default() }; assert_eq!(r.pages(5), [0, 2, 4]); } + + #[test] + fn the_watermark_edit_carries_the_offset() { + let mut d = MarksDraft::default(); + d.wm.text = "DRAFT".into(); + d.wm.offset = [-72.0, 36.0]; + match edit(&d, MarkKind::Watermark, 3) { + Edit::AddWatermark { settings, .. } => assert_eq!(settings.offset, [-72.0, 36.0]), + other => panic!("expected AddWatermark, got {other:?}"), + } + } } diff --git a/crates/ui-egui/tests/marks.rs b/crates/ui-egui/tests/marks.rs index 0a216ba5..285442f9 100644 --- a/crates/ui-egui/tests/marks.rs +++ b/crates/ui-egui/tests/marks.rs @@ -64,6 +64,7 @@ fn watermark_and_background_dialogs() { h.state_mut().execute("edit.watermark"); h.run_steps(2); assert!(h.query_by(|n| n.label().as_deref() == Some("OK") && n.is_disabled()).is_some(), "OK waits for text"); + h.get_by_label("Offset from centre"); h.state_mut().marks_draft.wm.text = "DRAFT".into(); h.run_steps(2); h.get_by_label("OK").click(); diff --git a/docs/roadmap.md b/docs/roadmap.md index aed24a3d..bdef89a3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -71,7 +71,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | -| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; Create PDF from multiple files (PDFs, images and text, edited as pages in the grid). Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, web printing | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e15ed9e3..097f123a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2430,8 +2430,8 @@ milestone = "M7" status = "partial" commands = ["edit.watermark"] tools = ["doc_watermark"] -evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind"] -notes = "rotation, opacity, behind/on top; no position offsets, scale or print/screen-only variants in the UI yet" +evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/edit/src/tests.rs::watermark_offset_moves_the_centre_right_and_up", "crates/edit/src/tests.rs::a_watermark_offset_that_is_not_finite_is_refused", "crates/automation/tests/automation.rs::watermarks_move_from_the_page_centre_through_tools", "crates/ui-egui/src/marks_ui.rs::the_watermark_edit_carries_the_offset"] +notes = "rotation, opacity, behind/on top, and a horizontal/vertical offset from the page centre in points (dialog and offset_x/offset_y); no offset from the page edge or in page units, and no print/screen-only variants yet" [[feature]] id = "edit.background-add" From a7e24b9512b429d7ffbb298dc1f9028141da3762 Mon Sep 17 00:00:00 2001 From: Bryan Mark Date: Mon, 12 Oct 2026 00:58:28 +0900 Subject: [PATCH 04/91] M12: XFA script time limits start when the engine is ready (#1081) * M12: XFA script time limits start when the engine is ready An XFA script's wall-clock limit (1 s for scripts that run unasked, such as initialize and calculate) also counted the engine's start-up: the script thread, a new boa Context, and for the first script in a process the engine's own one-time set-up (~60-170 ms when idle, shared by every script starting at the same moment). On a busy machine a trivial initialize script could pass 1 s, be abandoned, and so turn all of the form's scripts off, with the error only in the JavaScript console. That made two ui-egui xfa.rs tests flaky (xfa_buttons_run_their_scripts_in_the_app, messages_from_scripts_run_on_open_show_right_away): under full CPU load 25 of 40 runs of the test binary failed, and none of 40 with this change. - run_within: the script calls `started` once the engine is ready, and the time limit runs from then. Start-up has a cap of its own (10 s), so a caller still never waits forever. - run_here: a once-per-process warm-up script takes the engine's first use, so the first form script's timed part drops from ~52 ms to under ~11 ms. - FormCalc (no engine to start) signals at once, as before. - Tests: a slow start doesn't abandon a script; the script's own time, and start-up, are still capped. The UI tests print the script console when they fail. Runaway scripts are still abandoned at the same limits (runaway_xfa_calculations_at_open_end_quickly_with_a_report). Co-Authored-By: Claude Opus 5.5 * Add arcanii to the contributor names Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + contributors/people.toml | 5 ++ crates/js/src/formcalc.rs | 6 ++- crates/js/src/tests.rs | 31 +++++++++++ crates/js/src/xfa.rs | 102 ++++++++++++++++++++++++++++-------- crates/ui-egui/tests/xfa.rs | 6 +-- 6 files changed, 124 insertions(+), 27 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c4f216b6..87ecc1a1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -136,6 +136,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. - **2026-10-11 (M7, watermark placement):** A watermark can be moved from the page centre by a horizontal and a vertical offset in points: Add/Update Watermark has an "Offset from centre" row (the preview follows it, for text and for pictures) and `doc_watermark` takes `offset_x` and `offset_y`. A non-finite offset is refused instead of written into the page. `edit.watermark-appearance` stays partial: no offset from the page edge or in page units, no print/screen-only variants, no repeat. Image and PDF watermarks, already shipped, no longer appear as missing. The overall estimate is unchanged. +- **2026-10-11 (M12, XFA script time limits):** An XFA script's time limit (1 s for scripts that run unasked, on open and on every change) counted the engine's start-up too: the script's thread, a new context and, for the first script in a process, the engine's own set-up (≈ 60–170 ms when idle). On a busy machine a trivial initialize script could pass the limit, be abandoned and turn the form's scripts off; that made `xfa_buttons_run_their_scripts_in_the_app` and `messages_from_scripts_run_on_open_show_right_away` flaky (under full CPU load 25 of 40 runs of the test binary failed, with "ran longer than 1.0 s and was abandoned" in the console). The limit now runs from when the engine is ready; start-up has its own 10 s cap, so nothing waits forever, and a trivial warm-up script takes the first-in-process set-up. Under the same load none of 40 runs fails, and runaway scripts are abandoned as before. The two UI tests print the script console when they fail. Overall unchanged. - **2026-10-10 (M14, interface size):** Preferences ▸ Interface size scales the whole interface (50–250 %, setting `ui_scale`, control option `ui-scale`); 50 % brings an interface the system draws at 200 % back to 100 % (#456). Auto, the default, follows the desktop's text scaling: on Linux and the BSDs the desktop app reads GNOME's `text-scaling-factor` once at launch, so with Large Text (1.25) on a 1× display the interface no longer draws 13 px labels that show their pixels next to the system's text. The size is egui's zoom factor, so `pixels_per_point` grows with it and page rasters stay one texel per screen pixel; egui's own ⌘+/⌘− interface zoom is turned off so those keys stay page zoom. Still missing: KDE's font DPI and Windows' "Make text bigger" aren't read (both scale only text there), and egui's text is still greyscale-antialiased without the system's gamma, so small text looks rougher than GTK's at 100 %. - **2026-10-10 (M14, #496):** Shortcut displays share a UI formatter across toolbar search, registry menus, command palette, Help and tooltips. Modifier and key names use a dedicated catalog context in every bundled translation; missing entries keep the English key name rather than a command translation. German shows Strg/Umschalt/Entf, macOS retains its symbols, and runtime language changes refresh the display. Regression coverage checks localized layout widths, English/German switching, platform conventions and unchanged command execution. Milestone and effort estimates unchanged. diff --git a/contributors/people.toml b/contributors/people.toml index c685b584..3f346cd5 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -74,3 +74,8 @@ sync_github_name = true [people.jay-tau] sync_github_name = true + +[people.arcanii] +real_name = "Bryan Mark" +display_name = "Arcanii" +sync_github_name = false diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 0fc07164..5942391b 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -2543,7 +2543,11 @@ pub(crate) fn run_formcalc_at( /// [`run_formcalc`] on its own thread, abandoned after `timeout` (and told to stop itself then). pub fn run_formcalc_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, timeout: std::time::Duration) -> XfaOutcome { let (script, event, doc) = (script.to_string(), event.clone(), doc.clone()); - run_within("pdfcraft-formcalc", timeout, move || run_formcalc_at(&script, &event, &doc, root, limits, Some(timeout))) + // FormCalc has no engine to start: its time (and its own deadline) begins at once. + run_within("pdfcraft-formcalc", timeout, move |started| { + started(); + run_formcalc_at(&script, &event, &doc, root, limits, Some(timeout)) + }) } #[cfg(test)] diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index cb8b9974..e0c9a24e 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -544,6 +544,37 @@ mod xfa_model { let o = go("xfa.host.messageBox(new Array(100000).join('x'));", "form1[0].page1[0]", "click"); assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.chars().count() <= 4_097)); } + + #[test] + fn a_slow_engine_start_does_not_count_against_the_script() { + let ms = std::time::Duration::from_millis; + // On a busy machine the engine can take longer to start than a script may run: the + // script still runs (abandoning it would turn the form's scripts off). + let o = run_within_limits("test", ms(10_000), ms(500), move |started| { + std::thread::sleep(ms(1_500)); + started(); + XfaOutcome { result: Some("ran".into()), ..Default::default() } + }); + assert!(!o.abandoned, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("ran")); + // The script's own time counts from then. + let o = run_within_limits("test", ms(10_000), ms(200), move |started| { + started(); + std::thread::sleep(ms(2_000)); + XfaOutcome::default() + }); + assert!(o.abandoned && o.error.as_deref().is_some_and(|e| e.contains("ran longer")), "{o:?}"); + // Start-up has a limit of its own, so nobody waits forever for an engine. + let o = run_within_limits("test", ms(200), ms(10_000), move |started| { + std::thread::sleep(ms(2_000)); + started(); + XfaOutcome::default() + }); + assert!(o.abandoned && o.error.as_deref().is_some_and(|e| e.contains("to start")), "{o:?}"); + // A script that finishes without saying it started (refused at once) isn't abandoned. + let o = run_within_limits("test", ms(10_000), ms(200), |_| XfaOutcome { error: Some("refused".into()), ..Default::default() }); + assert!(!o.abandoned && o.error.as_deref() == Some("refused"), "{o:?}"); + } } mod formcalc { diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index c6995ba2..e9a11c7d 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -1345,7 +1345,7 @@ pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, lim let spawned = std::thread::Builder::new() .name("pdfcraft-xfa-js".into()) .stack_size(crate::SCRIPT_STACK) - .spawn_scoped(scope, || run_here(script, event, doc, root.clone(), limits)); + .spawn_scoped(scope, || run_here(script, event, doc, root.clone(), limits, &|| {})); match spawned { Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), Err(e) => failed(format!("the script engine could not start: {e}")), @@ -1353,56 +1353,112 @@ pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, lim }) } #[cfg(target_arch = "wasm32")] - run_here(script, event, doc, root.clone(), limits) + run_here(script, event, doc, root.clone(), limits, &|| {}) } -/// [`run_xfa`] with a time limit: a script still running after `timeout` (loops inside -/// nested function calls can run for hours within the engine's per-frame loop limit) is left -/// on its thread, which ends when the engine's own limits stop it, and an outcome with -/// `abandoned` set comes back at once. In the browser build there are no threads: the script -/// runs to its limits. +/// [`run_xfa`] with a time limit: a script still running `timeout` after the engine is ready +/// (loops inside nested function calls can run for hours within the engine's per-frame loop +/// limit) is left on its thread, which ends when the engine's own limits stop it, and an +/// outcome with `abandoned` set comes back at once. In the browser build there are no +/// threads: the script runs to its limits. pub fn run_xfa_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, timeout: std::time::Duration) -> XfaOutcome { if let Some(why) = refuse(script) { return XfaOutcome { error: Some(why), ..Default::default() }; } let (script, event, doc) = (script.to_string(), event.clone(), doc.clone()); - run_within("pdfcraft-xfa-js", timeout, move || run_here(&script, &event, &doc, root, limits)) + run_within("pdfcraft-xfa-js", timeout, move |started| run_here(&script, &event, &doc, root, limits, started)) } +/// Longest the script engine may take to be ready for a script (its thread and context, and +/// for the first script in a process the engine's own set-up) before the script is abandoned. +/// It doesn't count against the script's time: on a busy machine the start alone can take +/// longer than the tightest limit, and abandoning a script turns the form's scripts off. +const ENGINE_START_LIMIT: std::time::Duration = std::time::Duration::from_secs(10); + /// Run `script` on its own thread (with the script stack) and wait at most `timeout` for its -/// outcome; past that the thread is abandoned and the outcome says so. On wasm there are no -/// threads: the script runs here. -pub(crate) fn run_within(name: &str, timeout: std::time::Duration, script: impl FnOnce() -> XfaOutcome + Send + 'static) -> XfaOutcome { +/// outcome, counted from when it calls `started` (once the engine is ready); past that the +/// thread is abandoned and the outcome says so. On wasm there are no threads: the script runs +/// here. +pub(crate) fn run_within(name: &str, timeout: std::time::Duration, script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static) -> XfaOutcome { + run_within_limits(name, ENGINE_START_LIMIT, timeout, script) +} + +/// [`run_within`] with the engine's start-up limit given. +pub(crate) fn run_within_limits( + name: &str, + start_limit: std::time::Duration, + timeout: std::time::Duration, + script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static, +) -> XfaOutcome { #[cfg(not(target_arch = "wasm32"))] { + use std::sync::mpsc::RecvTimeoutError; + use std::time::Instant; + enum Step { + Started, + Done(XfaOutcome), + } let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; + let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() }; let (tx, rx) = std::sync::mpsc::channel(); let spawned = std::thread::Builder::new().name(name.into()).stack_size(crate::SCRIPT_STACK).spawn(move || { // The receiver is gone when the caller stopped waiting: nothing to report then. - let _ = tx.send(script()); + let started = tx.clone(); + let outcome = script(&move || { + let _ = started.send(Step::Started); + }); + let _ = tx.send(Step::Done(outcome)); }); if let Err(e) = spawned { return failed(format!("the script engine could not start: {e}")); } - match rx.recv_timeout(timeout) { - Ok(o) => o, - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => XfaOutcome { - error: Some(format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64())), - abandoned: true, - ..Default::default() - }, - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => failed("the script stopped with an internal error".into()), + // Wait for the engine within its start-up limit, then for the script within its own. + let (mut running, mut limit) = (false, start_limit); + let mut deadline = Instant::now().checked_add(limit); + loop { + let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now())); + match rx.recv_timeout(left) { + Ok(Step::Done(o)) => return o, + Ok(Step::Started) => { + // A second call changes nothing: the script's time has already begun. + if !running { + (running, limit) = (true, timeout); + deadline = Instant::now().checked_add(limit); + } + } + Err(RecvTimeoutError::Timeout) if running => { + return abandoned(format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64())); + } + Err(RecvTimeoutError::Timeout) => { + return abandoned(format!("the script engine took longer than {:.0} s to start", start_limit.as_secs_f64())); + } + Err(RecvTimeoutError::Disconnected) => return failed("the script stopped with an internal error".into()), + } } } #[cfg(target_arch = "wasm32")] { - let _ = (name, timeout); - script() + let _ = (name, start_limit, timeout); + script(&|| {}) } } -fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits) -> XfaOutcome { +/// The first script in a process sets the engine up (its built-ins, parser and compiler come +/// into use): a trivial script takes that on, so a form's script is never timed with it. +fn warm_up() { + static WARM: std::sync::Once = std::sync::Once::new(); + // `call_once_force`: a poisoned `Once` (a panic while warming up) only means warming up again. + WARM.call_once_force(|_| { + let mut ctx = Context::default(); + let _ = ctx.eval(Source::from_bytes(b"(function () { return eval('0'); }).call({})")); + }); +} + +/// `started` is called once the engine is ready: a time limit on the script runs from then. +fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome { + warm_up(); let mut ctx = Context::default(); + started(); ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); let host_state = XHost::new(root, &event.target, doc); diff --git a/crates/ui-egui/tests/xfa.rs b/crates/ui-egui/tests/xfa.rs index 587972d5..ccae2ea5 100644 --- a/crates/ui-egui/tests/xfa.rs +++ b/crates/ui-egui/tests/xfa.rs @@ -135,10 +135,10 @@ fn xfa_buttons_run_their_scripts_in_the_app() { }; assert!(!names(&h).iter().any(|n| n == "amount_2")); click_field(&mut h, "addRow"); - assert!(names(&h).iter().any(|n| n == "amount_2"), "{:?}", names(&h)); + assert!(names(&h).iter().any(|n| n == "amount_2"), "{:?}; script console: {:?}", names(&h), h.state().js_console.log); // The message box shows as a notice. click_field(&mut h, "hello"); - h.get_by_label_contains("Hello 2"); + assert!(h.query_by_label_contains("Hello 2").is_some(), "script console: {:?}", h.state().js_console.log); } #[test] @@ -147,7 +147,7 @@ fn messages_from_scripts_run_on_open_show_right_away() { .replace("if (qty.rawValue === null) qty.rawValue = 2;", r#"xfa.host.messageBox("Welcome to the form"); xfa.host.print();"#); let mut h = open(pdfcraft_xfa::fixtures::shell(&tpl)); h.run_steps(2); - h.get_by_label_contains("Welcome to the form"); + assert!(h.query_by_label_contains("Welcome to the form").is_some(), "script console: {:?}", h.state().js_console.log); assert_eq!(h.state().dialog, None, "an initialize script can't open the Print dialog"); let id = h.state().views[0].id; assert!(h.state_mut().session.take_js_output(id).is_empty(), "nothing waits for the next edit"); From 86e74ec1213e7c16480a384c9732a2c02528c61e Mon Sep 17 00:00:00 2001 From: Atos Lins <132598646+atoslins@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:59:29 -0300 Subject: [PATCH 05/91] Linux: draw on the GPU the Wayland compositor draws with (#1088) On a hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with AQ_DRM_DEVICES naming the NVIDIA card first), the built-in panel still hangs off the integrated GPU, so the panel rule (#8) picked it, and the window stayed empty: the compositor took the frames and showed nothing, with no error anywhere. Which GPU drives a display stands in for which GPU the compositor draws with. Under Wayland the compositor says so itself: the main_device of its linux-dmabuf feedback (version 4), the device it imports client buffers on, which Mesa's Vulkan device-select layer reads for the same purpose. compositor_gpu.rs asks for it on a short-lived connection, maps the dev_t to PCI ids through /sys/dev/char, and pick_adapter ranks that adapter first. No answer (X11, an older compositor, a GPU no adapter matches) leaves the existing rules in charge, and WGPU_POWER_PREF still overrides. wayland-client and wayland-protocols are already in the tree through winit; they become direct Linux-only dependencies. --- Cargo.lock | 2 + apps/pdfcraft/Cargo.toml | 6 + apps/pdfcraft/src/compositor_gpu.rs | 177 ++++++++++++++++++++++++++++ apps/pdfcraft/src/main.rs | 67 +++++++---- docs/development.md | 2 +- 5 files changed, 230 insertions(+), 24 deletions(-) create mode 100644 apps/pdfcraft/src/compositor_gpu.rs diff --git a/Cargo.lock b/Cargo.lock index d42992b9..08f95566 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3564,6 +3564,8 @@ dependencies = [ "serde_json", "uds_windows", "ureq", + "wayland-client", + "wayland-protocols", "winresource", "winsafe", ] diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 1850877a..b7028552 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -28,6 +28,12 @@ log = { workspace = true } # behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" +[target.'cfg(target_os = "linux")'.dependencies] +# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is +# drawn on that one (src/compositor_gpu.rs). Both are already in the tree through winit. +wayland-client = "0.31" +wayland-protocols = { version = "0.32", features = ["client"] } + [dev-dependencies] # Waits for wgpu's adapter, device and error-scope futures in tests (already in the tree through wgpu). pollster = "1.0" diff --git a/apps/pdfcraft/src/compositor_gpu.rs b/apps/pdfcraft/src/compositor_gpu.rs new file mode 100644 index 00000000..8edf23db --- /dev/null +++ b/apps/pdfcraft/src/compositor_gpu.rs @@ -0,0 +1,177 @@ +//! The GPU the Wayland compositor draws with. +//! +//! A window drawn on another GPU reaches the compositor as that GPU's buffers, and the compositor +//! may not show them: it can refuse them and end the window's connection (#96), or take them and +//! show nothing, with no error on either side. The second happened on a hybrid laptop whose +//! Hyprland runs on the NVIDIA GPU (`AQ_DRM_DEVICES`) while the built-in panel hangs off the Intel +//! one: the panel rule picked Intel and the window stayed empty. Which GPU drives a display is a +//! guess at the compositor's GPU; the compositor states it, as the `main_device` of its +//! linux-dmabuf feedback (protocol version 4): the device it imports client buffers on. + +#[cfg(target_os = "linux")] +use wayland_client::globals::{GlobalListContents, registry_queue_init}; +#[cfg(target_os = "linux")] +use wayland_client::protocol::wl_registry::{self, WlRegistry}; +#[cfg(target_os = "linux")] +use wayland_client::{Connection, Dispatch, QueueHandle}; +#[cfg(target_os = "linux")] +use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_feedback_v1::{self, ZwpLinuxDmabufFeedbackV1}; +#[cfg(target_os = "linux")] +use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_v1::{self, ZwpLinuxDmabufV1}; + +/// PCI `(vendor, device)` ids of the GPU the Wayland compositor draws with. `None` outside a +/// Wayland session, when the compositor doesn't say (linux-dmabuf older than version 4), when its +/// device isn't a PCI GPU, and on other systems. +#[cfg(target_os = "linux")] +pub(crate) fn compositor_gpu() -> Option<(u32, u32)> { + let device = match main_device() { + Ok(device) => device, + Err(why) => { + log::debug!("the compositor's GPU is unknown: {why}"); + return None; + } + }; + let pci = pci_ids(std::path::Path::new("/sys/dev/char"), device); + match pci { + Some((vendor, id)) => log::info!("the compositor draws with {vendor:04x}:{id:04x}"), + None => log::debug!("the compositor's device {:?} has no PCI ids", major_minor(device)), + } + pci +} + +#[cfg(not(target_os = "linux"))] +pub(crate) fn compositor_gpu() -> Option<(u32, u32)> { + None +} + +/// The `main_device` of the compositor's default linux-dmabuf feedback, asked on a connection of +/// its own that is closed again before the window opens. +#[cfg(target_os = "linux")] +fn main_device() -> Result { + let conn = Connection::connect_to_env().map_err(|e| format!("no Wayland compositor: {e}"))?; + let (globals, mut queue) = registry_queue_init::(&conn).map_err(|e| e.to_string())?; + let qh = queue.handle(); + // Version 4 brought the feedback; older compositors only list formats. + let dmabuf: ZwpLinuxDmabufV1 = globals.bind(&qh, 4..=4, ()).map_err(|e| format!("linux-dmabuf version 4: {e}"))?; + let _feedback = dmabuf.get_default_feedback(&qh, ()); + let mut state = Feedback::default(); + // The compositor sends the whole feedback, ending with `done`, in reply; the bound only keeps + // a compositor that never ends it from holding up the start. + for _ in 0..4 { + if state.done { + break; + } + queue.roundtrip(&mut state).map_err(|e| e.to_string())?; + } + // Dropping the connection releases the objects. + let device = state.main_device.ok_or("the compositor named no main device")?; + // A `dev_t`, in this machine's byte order. + let device: [u8; 8] = device.try_into().map_err(|d: Vec| format!("a {}-byte device number", d.len()))?; + Ok(u64::from_ne_bytes(device)) +} + +/// What [`main_device`] collects from the feedback events. +#[cfg(target_os = "linux")] +#[derive(Default)] +struct Feedback { + main_device: Option>, + done: bool, +} + +#[cfg(target_os = "linux")] +impl Dispatch for Feedback { + fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle) {} +} + +#[cfg(target_os = "linux")] +impl Dispatch for Feedback { + // Version 4 sends no format or modifier events: the feedback replaces them. + fn event(_: &mut Self, _: &ZwpLinuxDmabufV1, _: zwp_linux_dmabuf_v1::Event, _: &(), _: &Connection, _: &QueueHandle) {} +} + +#[cfg(target_os = "linux")] +impl Dispatch for Feedback { + fn event( + state: &mut Self, + _: &ZwpLinuxDmabufFeedbackV1, + event: zwp_linux_dmabuf_feedback_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + zwp_linux_dmabuf_feedback_v1::Event::MainDevice { device } => state.main_device = Some(device), + zwp_linux_dmabuf_feedback_v1::Event::Done => state.done = true, + _ => {} + } + } +} + +/// The major and minor numbers of a Linux `dev_t`, as glibc's and musl's `major()` and `minor()` +/// split it. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn major_minor(device: u64) -> (u64, u64) { + let major = ((device & 0x0000_0000_000f_ff00) >> 8) | ((device & 0xffff_f000_0000_0000) >> 32); + let minor = (device & 0x0000_0000_0000_00ff) | ((device & 0x0000_0fff_fff0_0000) >> 12); + (major, minor) +} + +/// The PCI ids of the GPU behind DRM device `device` (its `cardN` or `renderDN` node), from +/// `/MAJOR:MINOR/device/{vendor,device}`. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn pci_ids(sys_dev_char: &std::path::Path, device: u64) -> Option<(u32, u32)> { + let read_hex = |p: std::path::PathBuf| -> Option { + let s = std::fs::read_to_string(p).ok()?; + u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok() + }; + let (major, minor) = major_minor(device); + let gpu = sys_dev_char.join(format!("{major}:{minor}")).join("device"); + Some((read_hex(gpu.join("vendor"))?, read_hex(gpu.join("device"))?)) +} + +#[cfg(test)] +mod tests { + /// glibc's `makedev()`. + fn makedev(major: u64, minor: u64) -> u64 { + ((major & 0x0000_0fff) << 8) | ((major & 0xffff_f000) << 32) | (minor & 0x0000_00ff) | ((minor & 0xffff_ff00) << 12) + } + + #[test] + fn device_numbers_split_into_major_and_minor() { + // DRM nodes: card1 is 226:1, renderD128 is 226:128. + assert_eq!(super::major_minor(0xe201), (226, 1)); + assert_eq!(super::major_minor(0xe280), (226, 128)); + for (major, minor) in [(0, 0), (226, 129), (511, 1_048_575), (0xfff, 0xff), (0x1000, 0x100), (0xffff_ffff, 0xffff_ffff)] { + assert_eq!(super::major_minor(makedev(major, minor)), (major, minor)); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn the_gpu_behind_a_device_number_comes_from_sysfs() -> std::io::Result<()> { + let dir = std::env::temp_dir().join(format!("pdfcraft-dev-char-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + // /sys/dev/char/226:128 links to the render node, whose `device` is the PCI GPU. + std::fs::create_dir_all(dir.join("226:128/device"))?; + std::fs::write(dir.join("226:128/device/vendor"), "0x10de\n")?; + std::fs::write(dir.join("226:128/device/device"), "0x25ac\n")?; + // A device that isn't a PCI GPU (a virtual one) has no ids. + std::fs::create_dir_all(dir.join("226:0/device"))?; + let render = super::pci_ids(&dir, makedev(226, 128)); + let virtual_gpu = super::pci_ids(&dir, makedev(226, 0)); + let missing = super::pci_ids(&dir, makedev(226, 1)); + std::fs::remove_dir_all(&dir)?; + assert_eq!(render, Some((0x10de, 0x25ac))); + assert_eq!(virtual_gpu, None); + assert_eq!(missing, None); + Ok(()) + } + + #[test] + fn the_compositor_names_a_pci_gpu_or_nothing() { + // Whatever this session has: no compositor (CI), one that doesn't say, or a PCI GPU. + if let Some((vendor, _)) = super::compositor_gpu() { + assert_ne!(vendor, 0); + } + } +} diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index cbcf5d70..a1cfd4b3 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -31,6 +31,7 @@ use pdfcraft_ui_egui::PdfCraftApp; #[cfg(target_os = "macos")] mod apple_events; +mod compositor_gpu; mod logging; mod single_instance; mod text_scale; @@ -458,6 +459,10 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()> /// desktop with a monitor on each GPU, issue #445) the discrete one wins, as the integrated one /// may fail to present there. Where the system doesn't say which GPU drives a display (macOS, /// containers, remote sessions) the power preference alone decides. +/// - Under Wayland, the GPU the compositor draws with comes before all of that: the compositor +/// says which one it is ([`compositor_gpu::compositor_gpu`]), and frames from another GPU may +/// never reach the screen, even from the GPU behind the built-in panel when the compositor runs +/// on the discrete one. /// - On Windows, use Direct3D 12, falling back to OpenGL, and never load Vulkan drivers unless /// `WGPU_BACKEND` asks for them. Creating a Vulkan instance loads every installed Vulkan driver /// into the process, and a faulty one (an Intel driver in issue #37) crashed PdfCraft before @@ -477,7 +482,8 @@ fn configure_gpu(native: &mut eframe::NativeOptions) { if std::env::var_os("WGPU_POWER_PREF").is_none() { setup.power_preference = eframe::wgpu::PowerPreference::LowPower; let displays = display_gpus(); - if !displays.is_empty() { + let compositor = compositor_gpu::compositor_gpu(); + if !displays.is_empty() || compositor.is_some() { setup.native_adapter_selector = Some(std::sync::Arc::new(move |adapters, surface| { let usable: Vec<&eframe::wgpu::Adapter> = adapters.iter().filter(|a| surface.is_none_or(|s| a.is_surface_supported(s))).collect(); let infos: Vec<(u32, u32, eframe::wgpu::DeviceType)> = usable @@ -487,7 +493,7 @@ fn configure_gpu(native: &mut eframe::NativeOptions) { (info.vendor, info.device, info.device_type) }) .collect(); - let picked = pick_adapter(&infos, &displays).and_then(|i| usable.get(i)).map(|a| (*a).clone()); + let picked = pick_adapter(&infos, &displays, compositor).and_then(|i| usable.get(i)).map(|a| (*a).clone()); match &picked { // Which GPU draws is the first question when a window stays black (#8, #378, #445). Some(a) => { @@ -620,12 +626,12 @@ fn linux_display_gpus(drm: &std::path::Path) -> Vec { gpus } -/// Index of the adapter to draw with: one that drives a display (by PCI ids) first. Among several -/// of those, the one driving the primary display (a hybrid laptop's panel, #8; the primary display -/// on Windows, #378), or else, with no primary display known (a Linux desktop with a monitor on -/// each GPU, #445), the discrete one. Otherwise the most frugal kind: integrated, discrete, other, -/// virtual, software. -fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[DisplayGpu]) -> Option { +/// Index of the adapter to draw with: the GPU the Wayland compositor draws with (`compositor`, by +/// PCI ids) first, then one that drives a display. Among several of those, the one driving the +/// primary display (a hybrid laptop's panel, #8; the primary display on Windows, #378), or else, +/// with no primary display known (a Linux desktop with a monitor on each GPU, #445), the discrete +/// one. Otherwise the most frugal kind: integrated, discrete, other, virtual, software. +fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[DisplayGpu], compositor: Option<(u32, u32)>) -> Option { use eframe::wgpu::DeviceType; let display = |pci: (u32, u32)| displays.iter().find(|g| g.pci == pci); let multi_gpu_desktop = displays.len() > 1 && !displays.iter().any(|g| g.primary); @@ -646,7 +652,7 @@ fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[D DeviceType::VirtualGpu => 4, DeviceType::Cpu => 5, }; - (!drives_display, !drives_primary, rank) + (compositor != Some((*vendor, *device)), !drives_display, !drives_primary, rank) }) .map(|(i, _)| i) } @@ -967,17 +973,17 @@ mod tests { #[test] fn pick_adapter_prefers_the_gpu_driving_the_monitors() { // A desktop whose monitors are all on the discrete GPU: the integrated one shows black. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)]), Some(0)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)], None), Some(0)); // Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed // the display driver. Windows also says which display is the primary one. - assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)]), Some(0)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0)); } #[test] fn pick_adapter_prefers_the_gpu_driving_the_primary_display() { // A Windows desktop with a monitor on each GPU: the window opens on the primary display. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)]), Some(0)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)]), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); } #[test] @@ -1009,10 +1015,10 @@ mod tests { #[test] fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() { // Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)]), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)]), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)]), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)]), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), Some(1)); } #[test] @@ -1021,17 +1027,32 @@ mod tests { use eframe::wgpu::DeviceType; let desktop = [(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)]; - assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)]), Some(1)); - assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)]), Some(1)); + assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)], None), Some(1)); + assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), Some(1)); // A GPU driving no display still loses to one that does. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)]), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None), Some(1)); + } + + #[test] + fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() { + // A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with + // `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the + // compositor shows nothing it draws, and reports no error either. + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0)); + // The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8). + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1)); + // No GPU drives a display as far as sysfs says (a container), but the compositor knows. + assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0)); + // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge. + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1)); } #[test] fn pick_adapter_falls_back_to_low_power_without_a_match() { - assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))]), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))]), Some(1)); - assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)]), None); + assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1)); + assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), None); } #[test] diff --git a/docs/development.md b/docs/development.md index 168197ab..9708db4c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -67,7 +67,7 @@ OpenGL can't start either, PdfCraft exits with both errors in the log. The fallb | `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) | | `RUST_BACKTRACE` | `1` adds a backtrace to the report of an internal error | | `XDG_DATA_HOME` | Linux/FreeBSD: base of the settings folder (`pdfcraft/`), the log folder and crash recovery | -| `WGPU_POWER_PREF` | GPU choice; by default PdfCraft draws on the GPU that drives the primary display (Windows) or the built-in panel (Linux), else the low-power (integrated) GPU. The log says which one was chosen | +| `WGPU_POWER_PREF` | GPU choice; by default PdfCraft draws on the GPU the Wayland compositor draws with (it says so through linux-dmabuf feedback), else the GPU that drives the primary display (Windows) or the built-in panel (Linux), else the low-power (integrated) GPU. The log says which one was chosen | | `WGPU_BACKEND` | Graphics backend; by default Windows uses Direct3D 12, falling back to OpenGL | | `PDFCRAFT_RENDERER` | `gl` starts with OpenGL (glow) and never loads wgpu; `wgpu` reports a wgpu failure instead of retrying with OpenGL; unset, wgpu is retried with OpenGL when it can't start (see [Renderer fallback](#renderer-fallback)) | | `CRAFT_FONTS_DIR` | Build time: a [craft-fonts](https://github.com/storytold/craft-fonts) checkout to embed (Japanese fonts) | From 2fbce7028cf7170677262f8585b91e21df72621d Mon Sep 17 00:00:00 2001 From: Shukat Ibrahim <121353915+sibrash4@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:59:43 -0700 Subject: [PATCH 06/91] doc_create from text refuses characters Helvetica can't draw (#959) (#1030) doc_create with from=text wrote every character outside WinAnsiEncoding (Korean, Chinese, Japanese, emoji) as `?` with no error. It now refuses such text with the same message page_add_text already gives, naming the first undrawable character and its code point. Line breaks, page breaks and tabs are layout and still pass. create_from_text itself is unchanged, so opening .txt files, comment summaries and compare reports behave as before. --- crates/automation/src/lib.rs | 4 ++++ crates/automation/src/tools.rs | 4 ++-- crates/automation/tests/automation.rs | 20 ++++++++++++++++++++ crates/edit/src/lib.rs | 16 ++++++++++++---- crates/engine/src/lib.rs | 2 +- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 5a7020ec..3ab6b195 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1081,6 +1081,10 @@ impl Automation { } (None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())), }; + // The page font is Helvetica/WinAnsi: refuse what it would silently turn into `?`. + if let Some(c) = pdfcraft_engine::first_undrawable_plain(&text) { + return Err(ToolError::InvalidArgs(pdfcraft_engine::undrawable_message(c))); + } (format!("{title}.pdf"), self.session.create_from_text(&title, &text).map_err(failed)?) } other => return Err(ToolError::InvalidArgs(format!("unknown source {other:?}"))), diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 6c2e03bb..7733dd8f 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -479,7 +479,7 @@ pub fn tools() -> Vec { t( "page_add_text", "Add text to a page", - "Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. It stays editable with content_update. Undoable.", + "Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. Text must be Western European (WinAnsi) characters; others are refused. It stays editable with content_update. Undoable.", ) .cmd("edit.text") .with(schema( @@ -985,7 +985,7 @@ pub fn tools() -> Vec { t( "doc_create", "Create a PDF", - "Create a new, unsaved document and return it like doc_open: `blank` (pages, width, height in points; default 1 US Letter page), `images` (paths of PNG, JPEG, TIFF (every page), GIF or BMP files, one page each at the image's resolution) or `text` (a .txt path, or `text` directly). Save it with doc_save and a path.", + "Create a new, unsaved document and return it like doc_open: `blank` (pages, width, height in points; default 1 US Letter page), `images` (paths of PNG, JPEG, TIFF (every page), GIF or BMP files, one page each at the image's resolution) or `text` (a .txt path, or `text` directly; Western European (WinAnsi) characters only, others are refused). Save it with doc_save and a path.", ) .with(schema( json!({ diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index ed15f1d0..48182f58 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1915,6 +1915,26 @@ fn creating_and_reducing_through_tools() { assert!(matches!(a.call("doc_create", &json!({ "from": "images", "paths": ["notes.txt"] })), Err(ToolError::Failed(_)))); } +#[test] +fn creating_from_text_refuses_what_the_font_cannot_draw() { + let dir = workdir("create-undrawable"); + std::fs::write(dir.join("kr.txt"), "ABC 제안서 시험").unwrap(); + let mut a = auto(&dir); + let refused = |r: Result, ToolError>| match r { + Err(ToolError::InvalidArgs(m) | ToolError::Failed(m)) => assert!(m.contains("U+C81C"), "{m}"), + other => panic!("expected a refusal, got {other:?}"), + }; + assert!(matches!(a.call("doc_create", &json!({ "from": "text", "text": "ABC 제안서 시험" })), Err(ToolError::InvalidArgs(_)))); + refused(a.call("doc_create", &json!({ "from": "text", "text": "ABC 제안서 시험" }))); + refused(a.call("doc_create", &json!({ "from": "text", "path": "kr.txt" }))); + // WinAnsi characters, line breaks and tabs are still fine. + let d = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Price: 5 € at café\r\n\tnext\u{c}page two" }))["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, d).len(), 2); + // #959: page_add_text refuses it too. + let doc = ok(&mut a, "doc_create", json!({ "from": "blank" }))["doc"].as_u64().unwrap(); + refused(a.call("page_add_text", &json!({ "doc": doc, "page": 1, "text": "ABC 제안서 시험", "at": [20, 20] }))); +} + #[test] fn creating_from_multiple_files_through_tools() { let dir = workdir("create-multiple"); diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 48e83cf4..8acd7b2a 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -387,10 +387,18 @@ pub(crate) fn drawable<'a>(lines: impl IntoIterator) -> Result<( /// The refusal for page text with `c`, which nothing here can draw. pub(crate) fn undrawable(c: char) -> EditError { - EditError::Invalid(format!( - "the standard fonts can't draw \"{c}\" (U+{:04X}); only Western European characters can be added as text for now", - u32::from(c) - )) + EditError::Invalid(undrawable_message(c)) +} + +/// The wording of the refusal for text with `c`, which the standard fonts can't draw. +pub fn undrawable_message(c: char) -> String { + format!("the standard fonts can't draw \"{c}\" (U+{:04X}); only Western European characters can be added as text for now", u32::from(c)) +} + +/// The first character of plain `text` that Helvetica/WinAnsi can't draw (line and page breaks +/// and tabs are layout, not glyphs). +pub fn first_undrawable_plain(text: &str) -> Option { + text.chars().find(|c| !matches!(c, '\r' | '\n' | '\t' | '\u{c}') && !pdfcraft_fonts::win_ansi_encodable(*c)) } fn text_op(x: f64, y: f64, text: &str) -> Vec { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1c059a22..307cfb71 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -46,7 +46,7 @@ pub use pdfcraft_forms::{ }; pub use pdfcraft_a11y as a11y; -pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine, first_undrawable}; +pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine, first_undrawable, first_undrawable_plain, undrawable_message}; pub use pdfcraft_measure as measure; pub use pdfcraft_xfa::Report as XfaLayout; From ea1df6e1cb3ae0a8b7ad19bd8c19352de19705d0 Mon Sep 17 00:00:00 2001 From: Atos Lins <132598646+atoslins@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:02:18 -0300 Subject: [PATCH 07/91] Linux: StartupWMClass is the window's app id (#1087) * Linux: StartupWMClass is the window's app id The desktop entry said StartupWMClass=pdfcraft, but the window carries the app id ai.storyteller.pdfcraft (APP_ID) on Wayland and under X11 alike, so nothing that matches a window to its launcher by that key ever matched it: on X11 the launch's startup notification can't end when the window maps. VectorCraft, GridCraft and WordCraft already use the app id. A test reads the entry and compares it with APP_ID. * Contributors: add atoslins (Atos Lins) --------- Co-authored-by: Brandon Thomas --- apps/pdfcraft/src/main.rs | 9 +++++++++ contributors/people.toml | 5 +++++ packaging/linux/ai.storyteller.pdfcraft.desktop | 2 +- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index a1cfd4b3..f046399a 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -1173,4 +1173,13 @@ mod tests { } let _ = std::fs::remove_dir_all(&dir); } + + /// X11 startup notification and docks match a window to its launcher by `StartupWMClass`, + /// so it has to be the class the window carries: the app id, on Wayland and X11 alike. + #[test] + fn the_desktop_entry_names_the_window_class() { + let entry = include_str!("../../../packaging/linux/ai.storyteller.pdfcraft.desktop"); + let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass=")); + assert_eq!(class, Some(super::APP_ID)); + } } diff --git a/contributors/people.toml b/contributors/people.toml index 3f346cd5..c88e9159 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -79,3 +79,8 @@ sync_github_name = true real_name = "Bryan Mark" display_name = "Arcanii" sync_github_name = false + +[people.atoslins] +real_name = "Atos Lins" +display_name = "Atos Lins" +sync_github_name = false diff --git a/packaging/linux/ai.storyteller.pdfcraft.desktop b/packaging/linux/ai.storyteller.pdfcraft.desktop index f7928d62..1e091ba0 100644 --- a/packaging/linux/ai.storyteller.pdfcraft.desktop +++ b/packaging/linux/ai.storyteller.pdfcraft.desktop @@ -8,7 +8,7 @@ TryExec=pdfcraft Icon=ai.storyteller.pdfcraft Terminal=false StartupNotify=true -StartupWMClass=pdfcraft +StartupWMClass=ai.storyteller.pdfcraft Categories=Office;Viewer; Keywords=pdf;viewer;editor;annotate;sign;forms;merge;split; MimeType=application/pdf; From dd065b39bb2db190255d3bb9e60d0f7a6e9c748c Mon Sep 17 00:00:00 2001 From: mlkonrad Date: Sun, 11 Oct 2026 19:04:10 +0300 Subject: [PATCH 08/91] M4, #569: Combine files takes images and text files (#1032) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M10: refuse a PNG whose header claims more than 2 GiB of pixels before allocating The png crate's limits don't cover the caller's frame buffer, which png_image sized from the header alone: a few-KB PNG claiming 60000x60000 RGBA made Create allocate and fill 14.4 GB. Apply the 2 GiB ceiling TIFF pages already have. Reached from Create, Insert pages and (with #569) Combine files. Co-Authored-By: Claude Opus 5.5 * M4, #569: Combine files takes images and text files PNG, JPEG, TIFF, GIF, BMP, JPEG 2000 and .txt files become PDFs as they join the Combine files list (Add files…, drops, folder scans), so each is an ordinary row or card with its pages, thumbnails and page range. Session::as_pdf does the conversion; PDFs pass through unread (Combine reads them itself, with their passwords), as does a file named .pdf without a header, which the reader may still open. doc_combine accepts the same file types, and a converted file's bookmark drops its extension. A file that can't be read (a damaged image or PDF, a type that can't be converted) no longer is refused at the door: it joins the list with why in its Warnings (a red card in the grid). Combine then asks "Leave out files that can't be read?", naming them; Combine the rest goes on without them, Cancel changes nothing. Password-protected files still block Combine until unlocked. The picker offers "PDF, images and text", and the empty state, folder tooltips and folder notices say what is accepted; translated in every full catalog (catalog hashes updated). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 30 ++--- ROADMAP.md | 1 + crates/automation/src/lib.rs | 5 +- crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 30 +++++ crates/create/src/lib.rs | 12 +- crates/create/src/tests.rs | 19 +++ crates/engine/src/lib.rs | 12 ++ crates/engine/src/tests.rs | 15 +++ crates/ui-egui/src/combine_ui.rs | 183 ++++++++++++++++++++++---- crates/ui-egui/src/files.rs | 2 +- crates/ui-egui/src/i18n/ar.tsv | 16 ++- crates/ui-egui/src/i18n/bg.tsv | 16 ++- crates/ui-egui/src/i18n/de.tsv | 16 ++- crates/ui-egui/src/i18n/es.tsv | 16 ++- crates/ui-egui/src/i18n/fr.tsv | 16 ++- crates/ui-egui/src/i18n/hu.tsv | 16 ++- crates/ui-egui/src/i18n/it.tsv | 16 ++- crates/ui-egui/src/i18n/ja.tsv | 16 ++- crates/ui-egui/src/i18n/pl.tsv | 16 ++- crates/ui-egui/src/i18n/pt-br.tsv | 16 ++- crates/ui-egui/src/i18n/ru.tsv | 16 ++- crates/ui-egui/src/i18n/te.tsv | 16 ++- crates/ui-egui/src/i18n/uk.tsv | 16 ++- crates/ui-egui/src/i18n/zh-hans.tsv | 16 ++- crates/ui-egui/src/i18n/zh-hant.tsv | 16 ++- crates/ui-egui/tests/editing.rs | 72 +++++++++- parity/acrobat-features.toml | 4 +- 28 files changed, 479 insertions(+), 148 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 180e80f4..6957bbcf 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b98e2e4858e635dbc6930f2ff80e7e8a109d27abe996ac29c27b3b13b70e43f2" +sha256 = "737e9f61493dc977faf28df4666365c3f6b1764c416d39c40a6b17c021835dcb" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b55ec8f2794966e879334121d48b15923cebf38192bd8e1667d4c4e6bf168095" +sha256 = "278c6ba856aba62c5a561c9f1b0cc44b2724c8357d91dcdf2d8a5164789ff7db" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "96b0200a9b1f16cb23fb9482a8189b3fd763f31469009c54e59cb0aa1dd5d7b3" +sha256 = "115ec3d852731d72f38ab578f87c4720d3703aa4476333b7b712550e35e8010d" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "81ce4a9a18d10149129ecdf7eb0e0d3179cb4a66cf3f344a32de9a984612dafa" +sha256 = "86c0fe08c6b52b841147a8dffcb3e6c080f649557d979ae0f7a80e1f322ca511" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9b4eb2a8230e856a2fd2abd672fc954eaa02164dae7be6a4299451ecb7e732bd" +sha256 = "af81661d3c705efae149d77b5055980d37d5ee01c28bad41b27ef682b521a098" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b08667b24b7bb8801e656516064125a29e47f2a88caadda4a81c362d680ed457" +sha256 = "f5e62db505ba7e974eacc64fa7ac2e0ba4955bfb466b4726cc3ba80ef94b1fb6" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fd2b1b33d940ff9eef4bc067452252c39bf6cf02f3fe22e84ecd782943d2cc26" +sha256 = "361eff32e4e7ef312667c7865adb2af3f2f684ce2bbb481124ba78e8a1715f2f" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a574d0ebee8184f917354377257d32312f711f4457e855b9d697d6af15ecc886" +sha256 = "82235760fda93084ce58d3b4da992b584080ccb871f4dc8eb5acbb4dfb2a3df3" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "95e35de200c66936333a95d89561f3c4d32b1d38f9c0a8357bec5c6b0e75fe1e" +sha256 = "5aa16fca81c03f0c7e03760c057a1658a7fdbcc58fb9216b8549aab0e428902a" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d4d9a78c19215360e64d1fcf201415546cabc5f8937888f3f5c1616106b94838" +sha256 = "51441023d6960c92b799a80ff9c30acf63128e0061c945995548723802398068" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f5549263f28e22911e2dc8612d8f793112729cecb8d7d8ab6d0faaf3acd4c0b0" +sha256 = "a4e94a2d0deaf79923bf4165c31bc60ac308be5fd120c0132b10abf7fd5bf7c9" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c4c77b4451a0505bfe7aacea5a9ef3701a10e48060dcb98558716b28592d72a1" +sha256 = "13ff66c5924b9db68279f2f936d83802251ac5fc03ca11b5ce97eacfabffc5ae" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "28d98970e3b7c11b1468f2ceec182979160681ca2719ec47246a1655ab452054" +sha256 = "41db30fd76339586c5325036249733f159866ed1628410134249edeb4c3a2182" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3b732bcfe593c4619909deab926a8aa30826e5effd957866315c7969338eca94" +sha256 = "9ce62e7d757116846bbacbb6977650de79c31603d30fe8568cb08c8a85a98e38" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "263567299cee16b9c2577b6de92e5d0f3bbb82ef334beebbd130c27407db40e2" +sha256 = "cf1d78342ac5a45e03114cf9b7abf65b419a94d977bba94077c3046f0a7d8493" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/ROADMAP.md b/ROADMAP.md index 87ecc1a1..df1c764d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,6 +137,7 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-11 (M7, watermark placement):** A watermark can be moved from the page centre by a horizontal and a vertical offset in points: Add/Update Watermark has an "Offset from centre" row (the preview follows it, for text and for pictures) and `doc_watermark` takes `offset_x` and `offset_y`. A non-finite offset is refused instead of written into the page. `edit.watermark-appearance` stays partial: no offset from the page edge or in page units, no print/screen-only variants, no repeat. Image and PDF watermarks, already shipped, no longer appear as missing. The overall estimate is unchanged. - **2026-10-11 (M12, XFA script time limits):** An XFA script's time limit (1 s for scripts that run unasked, on open and on every change) counted the engine's start-up too: the script's thread, a new context and, for the first script in a process, the engine's own set-up (≈ 60–170 ms when idle). On a busy machine a trivial initialize script could pass the limit, be abandoned and turn the form's scripts off; that made `xfa_buttons_run_their_scripts_in_the_app` and `messages_from_scripts_run_on_open_show_right_away` flaky (under full CPU load 25 of 40 runs of the test binary failed, with "ran longer than 1.0 s and was abandoned" in the console). The limit now runs from when the engine is ready; start-up has its own 10 s cap, so nothing waits forever, and a trivial warm-up script takes the first-in-process set-up. Under the same load none of 40 runs fails, and runaway scripts are abandoned as before. The two UI tests print the script console when they fail. Overall unchanged. +- **2026-10-11 (M4, #569):** Combine files takes images and text files as well as PDFs. PNG, JPEG, TIFF, GIF, BMP, JPEG 2000 and .txt files become PDFs as they are added (Add files…, drops, folder scans), so each is an ordinary row with its pages, thumbnails and page range; a file that can't be read (a damaged image or PDF, a type that can't be converted) stays in the list with why in its Warnings, and Combine asks before leaving such files out (Cancel changes nothing); password-protected files still have to be unlocked first. `doc_combine` accepts the same types, and a converted file's bookmark drops its extension. A PNG whose header claims more than 2 GiB of pixels is now refused before its buffer is allocated (the png crate's limits don't cover it), as TIFF already was. Office documents and HTML are still not converted. Overall estimate unchanged. - **2026-10-10 (M14, interface size):** Preferences ▸ Interface size scales the whole interface (50–250 %, setting `ui_scale`, control option `ui-scale`); 50 % brings an interface the system draws at 200 % back to 100 % (#456). Auto, the default, follows the desktop's text scaling: on Linux and the BSDs the desktop app reads GNOME's `text-scaling-factor` once at launch, so with Large Text (1.25) on a 1× display the interface no longer draws 13 px labels that show their pixels next to the system's text. The size is egui's zoom factor, so `pixels_per_point` grows with it and page rasters stay one texel per screen pixel; egui's own ⌘+/⌘− interface zoom is turned off so those keys stay page zoom. Still missing: KDE's font DPI and Windows' "Make text bigger" aren't read (both scale only text there), and egui's text is still greyscale-antialiased without the system's gamma, so small text looks rougher than GTK's at 100 %. - **2026-10-10 (M14, #496):** Shortcut displays share a UI formatter across toolbar search, registry menus, command palette, Help and tooltips. Modifier and key names use a dedicated catalog context in every bundled translation; missing entries keep the English key name rather than a command translation. German shows Strg/Umschalt/Entf, macOS retains its symbols, and runtime language changes refresh the display. Regression coverage checks localized layout widths, English/German switching, platform conventions and unchanged command execution. Milestone and effort estimates unchanged. diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 3ab6b195..a98336f4 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1354,7 +1354,10 @@ impl Automation { if let Some(g) = group { firsts.push((g, path.clone(), sources.len())); } - Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?) + let bytes = Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?); + // Images and text files are made into PDFs first (#569), as in the Combine files tab. + let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + self.session.as_pdf(&file_name, &bytes).map_err(failed)? } }; let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 7733dd8f..c99e1d86 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -205,7 +205,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "pages": pages("to extract"), "out": save_out.clone(), "open": open.clone(), "separate": { "type": "boolean" }, "out_dir": { "type": "string" }, "delete": { "type": "boolean" } }), &["doc", "pages"], )), - t("doc_combine", "Combine files", "Combine PDFs, in order, into one (bookmarks are kept under one entry per file). pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password.") + t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password.") .cmd("page.combine") .with(schema( json!({ diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 48182f58..521d903e 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -167,6 +167,36 @@ fn edit_undo_redo_and_save_round_trip() { assert_eq!(after.windows(5).filter(|w| w == b"%%EOF").count(), 2); } +/// #569: images and text files combine with PDFs, each made into a PDF first. +#[test] +fn combine_takes_images_and_text_files() { + let dir = workdir("combine-images"); + let mut png = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut png, 4, 2); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + encoder.write_header().unwrap().write_image_data(&[90; 24]).unwrap(); + } + std::fs::write(dir.join("scan.png"), png).unwrap(); + std::fs::write(dir.join("notes.txt"), "Some notes").unwrap(); + std::fs::write(dir.join("broken.png"), b"\x89PNG\r\n\x1a\nbroken").unwrap(); + std::fs::write(dir.join("report.docx"), b"PK\x03\x04").unwrap(); + let mut a = auto(&dir); + let combined = ok(&mut a, "doc_combine", json!({ "paths": ["a.pdf", "scan.png", "notes.txt"], "pages": [null, null, "1"], "open": true })); + let doc = combined["document"]["doc"].as_u64().unwrap(); + assert_eq!(combined["document"]["pages"], 5); + assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3", "", "Some notes"]); + let marks = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + let titles: Vec<&str> = marks.as_array().unwrap().iter().map(|b| b["title"].as_str().unwrap()).collect(); + assert_eq!(titles, ["a", "scan", "notes"]); + // A file that can't be made into a PDF stops the combine, and says which. + for bad in ["broken.png", "report.docx"] { + let refused = a.call("doc_combine", &json!({ "paths": ["a.pdf", bad] })); + assert!(matches!(&refused, Err(ToolError::Failed(e)) if e.contains(bad)), "{bad}: {refused:?}"); + } +} + #[test] fn combine_extract_and_split() { let dir = workdir("organize"); diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 09b07270..10887a4b 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -314,7 +314,10 @@ fn png_image(name: &str, bytes: &[u8]) -> Result { _ => (72.0, 72.0), }; let icc = reader.info().icc_profile.as_ref().map(|p| p.to_vec()); - let mut buf = vec![0; reader.output_buffer_size().ok_or_else(|| bad("image too large".into()))?]; + // The png crate's limits don't cover the frame buffer, sized by the header alone: a small + // file claiming enormous dimensions must not make it allocate (and fill) gigabytes. + let size = reader.output_buffer_size().filter(|n| *n <= MAX_DECODED_BYTES).ok_or_else(|| bad("image too large".into()))?; + let mut buf = vec![0; size]; let frame = reader.next_frame(&mut buf).map_err(|e| bad(e.to_string()))?; let (w, h) = (frame.width, frame.height); let data = &buf[..frame.buffer_size()]; @@ -385,8 +388,9 @@ fn decoded(name: &str, bytes: &[u8], format: image::ImageFormat) -> Result Result, CreateError> { @@ -398,7 +402,7 @@ fn tiff_pages(name: &str, bytes: &[u8]) -> Result, CreateError> { // 12000x16000 CMYK is 768 MiB. Raise it, but keep a ceiling so a tiny file claiming // enormous dimensions can't ask for unbounded memory. let mut limits = Limits::default(); - limits.decoding_buffer_size = MAX_TIFF_DECODED_BYTES; + limits.decoding_buffer_size = MAX_DECODED_BYTES; let mut dec = Decoder::new(std::io::Cursor::new(bytes)).map_err(|e| bad(e.to_string()))?.with_limits(limits); let mut out = Vec::new(); loop { diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index d83cefc5..45d37622 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -198,6 +198,25 @@ fn a_tiff_claiming_enormous_dimensions_is_refused() { assert!(matches!(from_images(&[("huge.tif".into(), tif)]), Err(CreateError::Image(..)))); } +/// Likewise a PNG: the png crate's limits don't cover the frame buffer, which its header sizes +/// (60000x60000 RGBA is 14.4 GB). Refused before anything is allocated, not after. +#[test] +fn a_png_claiming_enormous_dimensions_is_refused() { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 60_000, 60_000); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + // A zlib stream holding one zero byte: far too little data, but a well-formed start. + w.write_chunk(png::chunk::IDAT, &[0x78, 0x01, 0x01, 0x01, 0x00, 0xFE, 0xFF, 0x00, 0x00, 0x01, 0x00, 0x01]).unwrap(); + } + match from_images(&[("huge.png".into(), png)]) { + Err(CreateError::Image(_, m)) => assert!(m.contains("too large"), "{m}"), + other => panic!("expected a refusal, got {:?}", other.map(|_| ())), + } +} + #[test] fn images_export_as_jpeg_unchanged_and_others_as_png() { let doc = reopen( diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 307cfb71..4242ee28 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2803,6 +2803,18 @@ impl Session { Ok((kind, created?)) } + /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as + /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf + /// without a `%PDF-` header, which the reader may still open. + pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { + let pdf = match source_kind(name, bytes) { + Some(SourceKind::Pdf) => true, + Some(SourceKind::Image | SourceKind::Text) => false, + None => name.to_ascii_lowercase().ends_with(".pdf"), + }; + if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } + } + /// Reduce File Size: Acrobat's defaults (images above 225 ppi to 150 ppi, JPEG medium /// quality; thumbnails dropped), identical resources merged, unused objects dropped, objects /// packed into compressed object streams. Returns the bytes and how many objects were diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index eee3f7ca..6e92eab9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -578,6 +578,21 @@ fn files_that_cannot_be_converted_are_refused_clearly() { assert_eq!(err, EditError::Source("locked.pdf: it is password-protected".into())); } +/// Combine files (#569): images and text become PDFs; PDFs pass through unread, even protected +/// ones (Combine asks for their password) and ones named .pdf without a header. +#[test] +fn files_for_combine_are_made_into_pdfs_and_pdfs_pass_through() { + let s = Session::new(); + let locked = protected("pw", "o", -1); + assert!(Arc::ptr_eq(&s.as_pdf("locked.pdf", &locked).unwrap(), &locked)); + let headless = Arc::new(b"1 0 obj << >> endobj".to_vec()); + assert!(Arc::ptr_eq(&s.as_pdf("old.PDF", &headless).unwrap(), &headless)); + assert!(s.as_pdf("scan.jpg", &Arc::new(jpeg_bytes())).unwrap().starts_with(b"%PDF-")); + assert!(s.as_pdf("notes.txt", &Arc::new(b"hello".to_vec())).unwrap().starts_with(b"%PDF-")); + let err = s.as_pdf("report.docx", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); + assert!(matches!(err, EditError::Source(m) if m.starts_with("report.docx: this file type can't be converted"))); +} + fn protected(user: &str, owner: &str, permissions: i32) -> Arc> { let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(2))).unwrap(); doc.set_encryption(&pdfcraft_cos::NewEncryption { diff --git a/crates/ui-egui/src/combine_ui.rs b/crates/ui-egui/src/combine_ui.rs index faefb68f..eb5a7ba7 100644 --- a/crates/ui-egui/src/combine_ui.rs +++ b/crates/ui-egui/src/combine_ui.rs @@ -213,6 +213,9 @@ pub struct CombineTab { pub(crate) selected_pages: BTreeSet<(u64, usize)>, /// The page a Shift-click selects from. pub(crate) page_anchor: Option<(u64, usize)>, + /// "Leave out files that can't be read?", asked when Combine is clicked with such files in the + /// list. + pub(crate) confirm_skip: bool, } /// The pages a range takes, and the range and page count they were worked out for. @@ -395,6 +398,13 @@ impl CombineFile { self.checked = None; } + /// Whether the file can't be read at all (damaged, or a type that can't be made into a PDF): + /// it stays in the list, with why, and Combine leaves it out once the user agrees. A + /// password-protected file can be unlocked instead, so it isn't one. + pub(crate) fn unreadable(&self) -> bool { + self.problem.is_some() && self.lock.is_none() + } + /// The password the file was unlocked with, if any. pub(crate) fn password(&self) -> Option<&str> { self.password.as_ref().map(|p| p.0.as_str()) @@ -583,7 +593,7 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { // The keyboard is the list's unless a text field, the password box or the size slider has it. let slider_focused = grid && app.combine_tab.zoom_slider.is_some_and(|id| ui.memory(|m| m.has_focus(id))); // (Nor while a file is shown large: its window may only open or close this frame.) - let covered = app.combine_tab.unlock.is_some() || app.combine_tab.preview.is_some(); + let covered = app.combine_tab.unlock.is_some() || app.combine_tab.preview.is_some() || app.combine_tab.confirm_skip; let focus = ui.memory(|m| m.focused()); let space = focus.is_none() || focus == app.combine_tab.card_focus; let any_picked = count > 0 || !app.combine_tab.selected_pages.is_empty(); @@ -793,7 +803,67 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { app.combine_apply(action); } if combine && blocker.is_none() { - app.combine_staged(); + if app.combine_draft.iter().any(CombineFile::unreadable) { + app.combine_tab.confirm_skip = true; + } else { + app.combine_staged(); + } + } + skip_prompt(app, ui.ctx()); +} + +/// Most files the prompt names; the rest are counted. +const SKIP_PROMPT_NAMES: usize = 8; + +/// "Leave out files that can't be read?": names them and why, then combines the others, or not. +fn skip_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { + if !app.combine_tab.confirm_skip { + return; + } + let t = Tokens::get(ctx); + let mut left_out: Vec<(&str, &str)> = Vec::new(); + let mut seen = BTreeSet::new(); + for f in app.combine_draft.iter().filter(|f| f.unreadable() && seen.insert(f.group)) { + left_out.push((f.name.as_str(), f.problem.as_deref().unwrap_or_default())); + } + let mut choice: Option = None; + let modal = egui::Modal::new(egui::Id::new("combine-skip")).show(ctx, |ui| { + ui.set_width(460.0); + ui.horizontal(|ui| { + ui.add(icons::image("triangle-alert", 22.0, ERROR)); + ui.add(egui::Label::new(egui::RichText::new(tl!("Leave out files that can't be read?")).font(theme::semibold(16.0))).wrap()); + }); + ui.add_space(6.0); + ui.label(egui::RichText::new(tl!("These files can't be read, so they won't be in the combined PDF:")).color(t.text_muted)); + ui.add_space(6.0); + for (name, why) in left_out.iter().take(SKIP_PROMPT_NAMES) { + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new(format!("• {}", crate::bidi::visual(name))).strong()); + ui.label(egui::RichText::new(*why).color(t.text_muted)); + }); + } + if left_out.len() > SKIP_PROMPT_NAMES { + let more = (left_out.len() - SKIP_PROMPT_NAMES).to_string(); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("and {n} more"), &[("n", &more)])).color(t.text_muted)); + } + ui.add_space(14.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("Combine the rest"), true).clicked() { + choice = Some(true); + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + choice = Some(false); + } + }); + }); + if choice.is_none() && modal.should_close() { + choice = Some(false); + } + if let Some(go) = choice { + app.combine_tab.confirm_skip = false; + if go { + app.combine_staged(); + } } } @@ -907,11 +977,20 @@ fn expandable(f: &mut CombineFile) -> bool { /// Why Combine can't run yet, if so. fn blocker(files: &[CombineFile], checks: &[Result]) -> Option { - // (A file split into parts is still one file.) - if files.iter().map(|f| f.group).collect::>().len() < 2 { - return Some(tl!("Add at least two files").to_string()); + // (A file split into parts is still one file.) Files that can't be read are left out, so + // they don't count. + if files.iter().filter(|f| !f.unreadable()).map(|f| f.group).collect::>().len() < 2 { + return Some(match files.iter().find(|f| f.unreadable()) { + Some(f) => { + crate::i18n::fmt(tl!("{name} can't be combined: {why}"), &[("name", &f.name), ("why", f.problem.as_deref().unwrap_or_default())]) + } + None => tl!("Add at least two files").to_string(), + }); } for (f, check) in files.iter().zip(checks) { + if f.unreadable() { + continue; + } if let Some(p) = &f.problem { return Some(crate::i18n::fmt(tl!("{name} can't be combined: {why}"), &[("name", &f.name), ("why", p)])); } @@ -1041,9 +1120,9 @@ fn empty_state(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, files_hover ui.add_space((ui.available_height() / 2.0 - 70.0).max(16.0)); ui.add(icons::image("files", 40.0, if files_hovering { t.accent } else { t.text_faint })); ui.add_space(10.0); - ui.label(egui::RichText::new(tl!("Add the PDFs to combine")).font(theme::medium(15.0))); + ui.label(egui::RichText::new(tl!("Add the files to combine")).font(theme::medium(15.0))); ui.add_space(4.0); - ui.label(egui::RichText::new(tl!("Drop PDFs or folders here, or choose them.")).color(t.text_faint)); + ui.label(egui::RichText::new(tl!("Drop PDFs, images, text files or folders here, or choose them.")).color(t.text_faint)); ui.add_space(12.0); let folders = cfg!(not(target_arch = "wasm32")); // Centre the buttons: measure them first. @@ -1084,12 +1163,12 @@ fn add_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { app.combine_dialog(); ui.close(); } - if item(ui, "folder", tl!("Add folder…")).on_hover_text(tl!("Every PDF in the folder")).clicked() { + if item(ui, "folder", tl!("Add folder…")).on_hover_text(tl!("Every PDF, image and text file in the folder")).clicked() { app.combine_add_folder(false); ui.close(); } if item(ui, "folder-open", tl!("Add folder and subfolders…")) - .on_hover_text(tl!("Every PDF in the folder and the folders inside it")) + .on_hover_text(tl!("Every PDF, image and text file in the folder and the folders inside it")) .clicked() { app.combine_add_folder(true); @@ -1406,10 +1485,10 @@ fn table_body( }); } -/// The PDFs in `dir` (and, if `recursive`, the folders inside it, not following links), in -/// natural order, at most [`MAX_FOLDER_FILES`]; and whether there were more. +/// The PDFs, images and text files in `dir` (and, if `recursive`, the folders inside it, not +/// following links), in natural order, at most [`MAX_FOLDER_FILES`]; and whether there were more. #[cfg(not(target_arch = "wasm32"))] -fn pdfs_in(dir: &std::path::Path, recursive: bool) -> (Vec, bool) { +fn combinable_in(dir: &std::path::Path, recursive: bool) -> (Vec, bool) { let mut found = Vec::new(); let mut more = false; let mut folders = vec![(dir.to_path_buf(), 0usize)]; @@ -1423,7 +1502,7 @@ fn pdfs_in(dir: &std::path::Path, recursive: bool) -> (Vec, if recursive && !hidden && depth < MAX_FOLDER_DEPTH { folders.push((path, depth + 1)); } - } else if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("pdf")) && path.is_file() { + } else if path.extension().is_some_and(|e| combinable(&e.to_string_lossy())) && path.is_file() { if found.len() >= MAX_FOLDER_FILES { more = true; break 'walk; @@ -1436,6 +1515,21 @@ fn pdfs_in(dir: &std::path::Path, recursive: bool) -> (Vec, (found, more) } +/// Whether a file with extension `ext` can join the list: a PDF, or an image or text file that +/// becomes one. +fn combinable(ext: &str) -> bool { + std::iter::once("pdf").chain(pdfcraft_engine::CONVERTIBLE).any(|c| ext.eq_ignore_ascii_case(c)) +} + +/// A file's bookmark in the combined PDF: its name without ".pdf", or without the extension of +/// the image or text file it was made from. +fn title_of(name: &str) -> &str { + match name.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() && combinable(ext) => stem, + _ => name, + } +} + /// File names in the order people expect: case-insensitive, with numbers by value /// ("scan2" before "scan10"). fn natural(a: &str, b: &str) -> Ordering { @@ -2191,12 +2285,35 @@ impl PdfCraftApp { pub(crate) fn stage_combine_with(&mut self, files: Vec) { let mut added = Vec::new(); for Incoming { name, bytes, modified, note } in files { - let assessed = match assess(&bytes, None) { - Ok(a) => a, - Err(AssessError::Unreadable(e)) => { - self.notify_fmt("Couldn't add {name}: {e}", &[("name", &name), ("e", &e)]); - continue; + // Images and text files become PDFs as they join the list (#569): from then on they + // are ordinary files, with pages, thumbnails and page ranges. + // A file that can't be read joins the list all the same, with why (the Warnings + // column, its card); Combine asks before leaving it out. + let unreadable = |e: &str| Assessed { + pages: 0, + sizes: Arc::from(Vec::new()), + problem: Some(crate::i18n::fmt(tl!("Can't be read: {e}"), &[("e", e)])), + lock: None, + notes: Vec::new(), + }; + let (bytes, assessed) = match self.session.as_pdf(&name, &bytes) { + Ok(pdf) => { + let assessed = assess(&pdf, None); + (pdf, assessed) } + Err(e) => { + // (The engine's message names the file, which the row already shows.) + let e = match e { + pdfcraft_engine::EditError::Source(m) => m, + other => other.to_string(), + }; + let why = e.strip_prefix(&format!("{name}: ")).unwrap_or(&e).to_string(); + (bytes, Ok(unreadable(&why))) + } + }; + let assessed = match assessed { + Ok(a) => a, + Err(AssessError::Unreadable(e)) => unreadable(&e), Err(AssessError::WrongPassword) => { self.notify_fmt("Couldn't add {name}: {e}", &[("name", &name), ("e", "the password is wrong")]); continue; @@ -2286,7 +2403,8 @@ impl PdfCraftApp { self.combine_unlock(&ids, password) } - /// Add every PDF in a folder the user picks (and its subfolders, if `recursive`). + /// Add every PDF, image and text file in a folder the user picks (and its subfolders, if + /// `recursive`). pub fn combine_add_folder(&mut self, recursive: bool) { #[cfg(not(target_arch = "wasm32"))] { @@ -2298,19 +2416,19 @@ impl PdfCraftApp { let _ = recursive; } - /// Add the PDFs in `dir` to the Combine files list, as one undo step. + /// Add the PDFs, images and text files in `dir` to the Combine files list, as one undo step. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn add_folder_to_combine(&mut self, dir: &std::path::Path, recursive: bool) { - let (paths, more) = pdfs_in(dir, recursive); + let (paths, more) = combinable_in(dir, recursive); let folder = dir.file_name().map_or_else(|| dir.to_string_lossy().into_owned(), |n| n.to_string_lossy().into_owned()); if paths.is_empty() { - self.notify_fmt("No PDFs found in {folder}", &[("folder", &folder)]); + self.notify_fmt("No PDFs, images or text files found in {folder}", &[("folder", &folder)]); self.open_combine_tab(); return; } self.use_paths(crate::files::FilePurpose::Combine, &paths); if more { - self.notify_fmt("Added the first {n} PDFs in {folder}", &[("n", &MAX_FOLDER_FILES.to_string()), ("folder", &folder)]); + self.notify_fmt("Added the first {n} files in {folder}", &[("n", &MAX_FOLDER_FILES.to_string()), ("folder", &folder)]); } } @@ -2331,18 +2449,17 @@ impl PdfCraftApp { } /// Combine the listed files into a new, unsaved document; the tab makes way for it. + /// Files that can't be read are left out (the user was asked). pub fn combine_staged(&mut self) { - let files = &self.combine_draft; + let files: Vec<&CombineFile> = self.combine_draft.iter().filter(|f| !f.unreadable()).collect(); if files.is_empty() { return; } // Each file once, however many parts it is in. let count = files.iter().map(|f| f.group).collect::>().len(); let groups: Vec = files.iter().map(|f| f.group).collect(); - let sources: Vec<(String, Arc>, Option)> = files - .iter() - .map(|f| (crate::files::strip_pdf(&f.name).to_string(), f.bytes.clone(), Some(f.range.clone()).filter(|r| !r.trim().is_empty()))) - .collect(); + let sources: Vec<(String, Arc>, Option)> = + files.iter().map(|f| (title_of(&f.name).to_string(), f.bytes.clone(), Some(f.range.clone()).filter(|r| !r.trim().is_empty()))).collect(); let passwords: Vec> = files.iter().map(|f| f.password.as_ref().map(|p| p.0.as_str())).collect(); match self.session.combine_grouped(&sources, &groups, &passwords) { Ok(bytes) => { @@ -2423,6 +2540,16 @@ mod tests { } } + #[test] + fn bookmarks_are_named_without_the_extension_of_a_converted_file() { + assert_eq!(title_of("report.pdf"), "report"); + assert_eq!(title_of("REPORT.PDF"), "REPORT"); + assert_eq!(title_of("scan.2024.JPG"), "scan.2024"); + assert_eq!(title_of("notes.txt"), "notes"); + assert_eq!(title_of("v1.2"), "v1.2", "not a known extension"); + assert_eq!(title_of(".png"), ".png", "nothing would be left"); + } + #[test] fn page_lists_become_the_shortest_range() { assert_eq!(range_of(&[0, 1, 2], 3), "", "every page in order: no range"); diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index d74233fd..381759e1 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -29,7 +29,7 @@ fn files_picker(purpose: FilePurpose) -> rfd::AsyncFileDialog { if purpose == FilePurpose::RedactWords { return dialog.add_filter(tl!("Text"), &["txt"]); } - if !matches!(purpose, FilePurpose::CreateMultiple | FilePurpose::InsertPages) { + if !matches!(purpose, FilePurpose::CreateMultiple | FilePurpose::InsertPages | FilePurpose::Combine) { return dialog.add_filter("PDF", &["pdf"]); } let all: Vec<&str> = std::iter::once("pdf").chain(pdfcraft_engine::CONVERTIBLE).collect(); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 09b9b9fb..15c9a646 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -1586,16 +1586,16 @@ Opened, but its security settings don't allow combining. Enter the permissions password. فُتح، لكن إعدادات أمانه لا تسمح بالدمج. أدخل كلمة مرور الأذونات. Unlocked: the combined file won't be password-protected أُلغي القفل: الملف المدموج لن يكون محميًا بكلمة مرور Unlocked {n} of {m} files أُلغي قفل {n} من {m} ملفات - Drop PDFs or folders here, or choose them. أفلت ملفات PDF أو مجلدات هنا، أو اخترها. + Drop PDFs, images, text files or folders here, or choose them. أفلت ملفات PDF أو الصور أو الملفات النصية أو المجلدات هنا، أو اخترها. Add folder… إضافة مجلد… Add folder and subfolders… إضافة مجلد ومجلداته الفرعية… More ways to add files طرق أخرى لإضافة الملفات - Every PDF in the folder كل ملفات PDF في المجلد - Every PDF in the folder and the folders inside it كل ملفات PDF في المجلد والمجلدات التي بداخله + Every PDF, image and text file in the folder كل ملفات PDF والصور والملفات النصية في المجلد + Every PDF, image and text file in the folder and the folders inside it كل ملفات PDF والصور والملفات النصية في المجلد والمجلدات التي بداخله Add folder إضافة مجلد Add folder and subfolders إضافة مجلد ومجلداته الفرعية - No PDFs found in {folder} لم يُعثر على ملفات PDF في {folder} - Added the first {n} PDFs in {folder} أُضيفت أول {n} ملفات PDF في {folder} + No PDFs, images or text files found in {folder} لم يُعثر على ملفات PDF أو صور أو ملفات نصية في {folder} + Added the first {n} files in {folder} أُضيفت أول {n} ملفات في {folder} Click to sort, drag to move the column. Drag its edge to resize it. انقر للفرز، واسحب لنقل العمود. اسحب حافته لتغيير حجمه. Reset columns إعادة ضبط الأعمدة Remove {n} files إزالة {n} ملفات @@ -1613,7 +1613,7 @@ {n} files need attention {n} ملفات تحتاج إلى انتباه {name} can't be combined: {why} لا يمكن دمج {name}: {why} Check the pages of {name}: {why} تحقق من صفحات {name}: {why} - Add the PDFs to combine أضف ملفات PDF المراد دمجها + Add the files to combine أضف الملفات المراد دمجها File name اسم الملف Warnings التحذيرات Drag to reorder اسحب لإعادة الترتيب @@ -1626,6 +1626,10 @@ Password-protected محمي بكلمة مرور Its security settings don't allow copying pages إعدادات أمانه لا تسمح بنسخ الصفحات Can't be read: {e} تتعذر قراءته: {e} + Leave out files that can't be read? هل تريد استبعاد الملفات التي تتعذر قراءتها؟ + These files can't be read, so they won't be in the combined PDF: تتعذر قراءة هذه الملفات، لذا لن تكون في ملف PDF المدمج: + Combine the rest دمج الباقي + and {n} more و{n} أخرى Includes unsaved changes يتضمن تغييرات غير محفوظة Pages of {name} to combine, e.g. 1-3, 6 صفحات {name} المراد دمجها، مثلًا 1-3, 6 Remove {name} إزالة {name} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index b6a899ec..7e1ed268 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1547,16 +1547,16 @@ Opened, but its security settings don't allow combining. Enter the permissions password. Файлът е отворен, но настройките му за защита не позволяват обединяване. Въведете паролата за разрешения. Unlocked: the combined file won't be password-protected Отключен: обединеният файл няма да бъде защитен с парола Unlocked {n} of {m} files Отключени са {n} от {m} файла - Drop PDFs or folders here, or choose them. Пуснете тук PDF файлове или папки или ги изберете. + Drop PDFs, images, text files or folders here, or choose them. Пуснете тук PDF файлове, изображения, текстови файлове или папки или ги изберете. Add folder… Добави папка… Add folder and subfolders… Добави папка и подпапки… More ways to add files Още начини за добавяне на файлове - Every PDF in the folder Всички PDF файлове в папката - Every PDF in the folder and the folders inside it Всички PDF файлове в папката и в папките в нея + Every PDF, image and text file in the folder Всички PDF файлове, изображения и текстови файлове в папката + Every PDF, image and text file in the folder and the folders inside it Всички PDF файлове, изображения и текстови файлове в папката и в папките в нея Add folder Добави папка Add folder and subfolders Добави папка и подпапки - No PDFs found in {folder} Не са открити PDF файлове в {folder} - Added the first {n} PDFs in {folder} Добавени са първите {n} PDF файла от {folder} + No PDFs, images or text files found in {folder} Не са открити PDF файлове, изображения или текстови файлове в {folder} + Added the first {n} files in {folder} Добавени са първите {n} файла от {folder} Click to sort, drag to move the column. Drag its edge to resize it. Щракнете за сортиране, плъзнете, за да преместите колоната. Плъзнете ръба на колоната, за да промените размера. Reset columns Нулирай колоните Remove {n} files Премахни {n} файла @@ -1574,7 +1574,7 @@ {n} files need attention {n} файла изискват внимание {name} can't be combined: {why} {name} не може да бъде обединен: {why} Check the pages of {name}: {why} Проверете страниците на {name}: {why} - Add the PDFs to combine Добавете PDF файловете за обединяване + Add the files to combine Добавете файловете за обединяване File name Име на файла Warnings Предупреждения Drag to reorder Плъзнете, за да пренаредите @@ -1587,6 +1587,10 @@ Password-protected Защитен с парола Its security settings don't allow copying pages Настройките му за защита не позволяват копиране на страници Can't be read: {e} Не може да бъде прочетен: {e} + Leave out files that can't be read? Да се пропуснат ли файловете, които не могат да бъдат прочетени? + These files can't be read, so they won't be in the combined PDF: Тези файлове не могат да бъдат прочетени, затова няма да бъдат в обединения PDF: + Combine the rest Обедини останалите + and {n} more и още {n} Includes unsaved changes Съдържа незаписани промени Pages of {name} to combine, e.g. 1-3, 6 Страници от {name} за обединяване, напр. 1-3, 6 Remove {name} Премахни {name} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2ec96c3c..f431690c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1990,18 +1990,22 @@ Add folder and subfolders… Ordner mit Unterordnern hinzufügen… Add folder… Ordner hinzufügen… Add open documents Geöffnete Dokumente hinzufügen - Add the PDFs to combine Füge die zu kombinierenden PDFs hinzu + Add the files to combine Füge die zu kombinierenden Dateien hinzu Added more than once Mehrfach hinzugefügt All open documents Alle geöffneten Dokumente Can't be read: {e} Kann nicht gelesen werden: {e} + Leave out files that can't be read? Dateien auslassen, die nicht gelesen werden können? + These files can't be read, so they won't be in the combined PDF: Diese Dateien können nicht gelesen werden und sind deshalb nicht im kombinierten PDF: + Combine the rest Die übrigen kombinieren + and {n} more und {n} weitere Check the pages of {name}: {why} Prüfe die Seiten von {name}: {why} Click to sort, drag to move the column. Drag its edge to resize it. Klicke zum Sortieren, ziehe zum Verschieben der Spalte. Ziehe ihren Rand, um die Breite zu ändern. Drag to reorder Ziehen zum Umsortieren - Drop PDFs or folders here, or choose them. Lege PDFs oder Ordner hier ab oder wähle sie aus. + Drop PDFs, images, text files or folders here, or choose them. Lege PDFs, Bilder, Textdateien oder Ordner hier ab oder wähle sie aus. Enter the password that opens it Gib das Passwort zum Öffnen ein Enter the permissions password to allow combining Gib das Berechtigungspasswort ein, um das Kombinieren zu erlauben - Every PDF in the folder Jedes PDF im Ordner - Every PDF in the folder and the folders inside it Jedes PDF im Ordner und in seinen Unterordnern + Every PDF, image and text file in the folder Jedes PDF, jedes Bild und jede Textdatei im Ordner + Every PDF, image and text file in the folder and the folders inside it Jedes PDF, jedes Bild und jede Textdatei im Ordner und in seinen Unterordnern File name Dateiname Files are combined from top to bottom. Leave Pages empty to take every page. Dateien werden von oben nach unten kombiniert. Lass „Seiten“ leer, um alle Seiten zu übernehmen. Hide password Passwort ausblenden @@ -2065,8 +2069,8 @@ Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form. Fahre nur fort, wenn du diesem Dokument vertraust. Eine Adresse kann Informationen aus dem Dokument übermitteln, etwa was du in sein Formular eingegeben hast. Print single-sided. Keep the sheets in order, cut at the marks, then stack the piles left to right, top to bottom. Einseitig drucken. Halte die Blätter in Reihenfolge, schneide an den Marken und lege die Stapel dann von links nach rechts, von oben nach unten aufeinander. Unlocked {n} of {m} files {n} von {m} Dateien entsperrt - No PDFs found in {folder} Keine PDFs in {folder} gefunden - Added the first {n} PDFs in {folder} Die ersten {n} PDFs aus {folder} hinzugefügt + No PDFs, images or text files found in {folder} Keine PDFs, Bilder oder Textdateien in {folder} gefunden + Added the first {n} files in {folder} Die ersten {n} Dateien aus {folder} hinzugefügt comment menu Edit Bearbeiten Undo {action} {action} rückgängig machen Redo {action} {action} wiederholen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 18678cde..794576f1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1588,16 +1588,16 @@ Opened, but its security settings don't allow combining. Enter the permissions password. Se abrió, pero su configuración de seguridad no permite combinarlo. Introduce la contraseña de permisos. Unlocked: the combined file won't be password-protected Desbloqueado: el archivo combinado no estará protegido con contraseña Unlocked {n} of {m} files Se desbloquearon {n} de {m} archivos - Drop PDFs or folders here, or choose them. Suelta aquí PDF o carpetas, o elígelos. + Drop PDFs, images, text files or folders here, or choose them. Suelta aquí PDF, imágenes, archivos de texto o carpetas, o elígelos. Add folder… Añadir carpeta… Add folder and subfolders… Añadir carpeta y subcarpetas… More ways to add files Más formas de añadir archivos - Every PDF in the folder Todos los PDF de la carpeta - Every PDF in the folder and the folders inside it Todos los PDF de la carpeta y de las carpetas que contiene + Every PDF, image and text file in the folder Todos los PDF, imágenes y archivos de texto de la carpeta + Every PDF, image and text file in the folder and the folders inside it Todos los PDF, imágenes y archivos de texto de la carpeta y de las carpetas que contiene Add folder Añadir carpeta Add folder and subfolders Añadir carpeta y subcarpetas - No PDFs found in {folder} No se encontraron PDF en {folder} - Added the first {n} PDFs in {folder} Se añadieron los primeros {n} PDF de {folder} + No PDFs, images or text files found in {folder} No se encontraron PDF, imágenes ni archivos de texto en {folder} + Added the first {n} files in {folder} Se añadieron los primeros {n} archivos de {folder} Click to sort, drag to move the column. Drag its edge to resize it. Haz clic para ordenar; arrastra para mover la columna. Arrastra su borde para cambiar el ancho. Reset columns Restablecer columnas Remove {n} files Quitar {n} archivos @@ -1615,7 +1615,7 @@ {n} files need attention {n} archivos necesitan atención {name} can't be combined: {why} No se puede combinar {name}: {why} Check the pages of {name}: {why} Revisa las páginas de {name}: {why} - Add the PDFs to combine Añade los PDF que quieres combinar + Add the files to combine Añade los archivos que quieres combinar File name Nombre de archivo Warnings Advertencias Drag to reorder Arrastra para reordenar @@ -1628,6 +1628,10 @@ Password-protected Protegido con contraseña Its security settings don't allow copying pages Su configuración de seguridad no permite copiar páginas Can't be read: {e} No se puede leer: {e} + Leave out files that can't be read? ¿Omitir los archivos que no se pueden leer? + These files can't be read, so they won't be in the combined PDF: Estos archivos no se pueden leer, así que no estarán en el PDF combinado: + Combine the rest Combinar el resto + and {n} more y {n} más Includes unsaved changes Incluye cambios sin guardar Pages of {name} to combine, e.g. 1-3, 6 Páginas de {name} que combinar, p. ej., 1-3, 6 Remove {name} Quitar {name} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index fc5c95a9..28cbb87f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1992,18 +1992,22 @@ Add folder and subfolders… Ajouter un dossier et ses sous-dossiers… Add folder… Ajouter un dossier… Add open documents Ajouter des documents ouverts - Add the PDFs to combine Ajoutez les PDF à combiner + Add the files to combine Ajoutez les fichiers à combiner Added more than once Ajouté plusieurs fois All open documents Tous les documents ouverts Can't be read: {e} Illisible : {e} + Leave out files that can't be read? Laisser de côté les fichiers illisibles ? + These files can't be read, so they won't be in the combined PDF: Ces fichiers sont illisibles ; ils ne figureront pas dans le PDF combiné : + Combine the rest Combiner les autres + and {n} more et {n} de plus Check the pages of {name}: {why} Vérifiez les pages de {name} : {why} Click to sort, drag to move the column. Drag its edge to resize it. Cliquer pour trier, faire glisser pour déplacer la colonne. Faire glisser son bord pour la redimensionner. Drag to reorder Faire glisser pour réordonner - Drop PDFs or folders here, or choose them. Déposez ici des PDF ou des dossiers, ou choisissez-les. + Drop PDFs, images, text files or folders here, or choose them. Déposez ici des PDF, des images, des fichiers texte ou des dossiers, ou choisissez-les. Enter the password that opens it Saisissez le mot de passe qui l'ouvre Enter the permissions password to allow combining Saisissez le mot de passe des autorisations pour permettre de le combiner - Every PDF in the folder Tous les PDF du dossier - Every PDF in the folder and the folders inside it Tous les PDF du dossier et des dossiers qu'il contient + Every PDF, image and text file in the folder Tous les PDF, images et fichiers texte du dossier + Every PDF, image and text file in the folder and the folders inside it Tous les PDF, images et fichiers texte du dossier et des dossiers qu'il contient File name Nom du fichier Files are combined from top to bottom. Leave Pages empty to take every page. Les fichiers sont combinés de haut en bas. Laissez Pages vide pour prendre toutes les pages. Hide password Masquer le mot de passe @@ -2086,7 +2090,7 @@ A script Un script About À propos Add {what} Ajouter {what} - Added the first {n} PDFs in {folder} Les {n} premiers PDF de {folder} ont été ajoutés + Added the first {n} files in {folder} Les {n} premiers fichiers de {folder} ont été ajoutés Align fields Aligner les champs Another file dialog is still open. Finish with it first. Une autre boîte de dialogue de fichier est encore ouverte. Terminez d'abord avec celle-ci. Basic constraints Contraintes de base @@ -2102,7 +2106,7 @@ Mark pages for redaction Marquer des pages pour biffure Match field sizes Uniformiser la taille des champs Models Modèles - No PDFs found in {folder} Aucun PDF trouvé dans {folder} + No PDFs, images or text files found in {folder} Aucun PDF, image ou fichier texte trouvé dans {folder} Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form. Ne continuez que si vous faites confiance à ce document. Une adresse peut transmettre des informations du document, par exemple ce que vous avez saisi dans son formulaire. Place saved {what} Placer {what} (version enregistrée) Print single-sided. Keep the sheets in order, cut at the marks, then stack the piles left to right, top to bottom. Imprimez en recto seul. Gardez les feuilles dans l'ordre, découpez aux repères, puis empilez les piles de gauche à droite et de haut en bas. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 38faaeb3..9f5edc1b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1979,18 +1979,22 @@ Add folder and subfolders… Mappa és almappák hozzáadása… Add folder… Mappa hozzáadása… Add open documents Megnyitott dokumentumok hozzáadása - Add the PDFs to combine Add hozzá az egyesítendő PDF-eket + Add the files to combine Add hozzá az egyesítendő fájlokat Added more than once Többször hozzáadva All open documents Minden megnyitott dokumentum Can't be read: {e} Nem olvasható: {e} + Leave out files that can't be read? Kihagyod az olvashatatlan fájlokat? + These files can't be read, so they won't be in the combined PDF: Ezek a fájlok nem olvashatók, ezért nem kerülnek bele az egyesített PDF-be: + Combine the rest A többi egyesítése + and {n} more és még {n} Check the pages of {name}: {why} Ellenőrizd {name} oldalait: {why} Click to sort, drag to move the column. Drag its edge to resize it. Kattints a rendezéshez, húzd az oszlop áthelyezéséhez. A szélét húzva méretezd. Drag to reorder Húzás az átrendezéshez - Drop PDFs or folders here, or choose them. Húzz ide PDF-eket vagy mappákat, vagy válaszd ki őket. + Drop PDFs, images, text files or folders here, or choose them. Húzz ide PDF-eket, képeket, szövegfájlokat vagy mappákat, vagy válaszd ki őket. Enter the password that opens it Add meg a megnyitó jelszót Enter the permissions password to allow combining Add meg a jogosultsági jelszót az egyesítés engedélyezéséhez - Every PDF in the folder A mappa minden PDF-je - Every PDF in the folder and the folders inside it A mappa és az almappái minden PDF-je + Every PDF, image and text file in the folder A mappa minden PDF-je, képe és szövegfájlja + Every PDF, image and text file in the folder and the folders inside it A mappa és az almappái minden PDF-je, képe és szövegfájlja File name Fájlnév Files are combined from top to bottom. Leave Pages empty to take every page. A fájlok fentről lefelé egyesülnek. Hagyd üresen az Oldalak mezőt, ha minden oldalt kérsz. Hide password Jelszó elrejtése @@ -2054,8 +2058,8 @@ Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form. Csak akkor folytasd, ha megbízol ebben a dokumentumban. Egy cím adatokat vihet a dokumentumból, például azt, amit az űrlapjába írtál. Print single-sided. Keep the sheets in order, cut at the marks, then stack the piles left to right, top to bottom. Nyomtass egyoldalasan. Tartsd a lapokat sorrendben, vágd el a jeleknél, majd rakd a kötegeket balról jobbra, fentről lefelé egymásra. Unlocked {n} of {m} files {n} / {m} fájl feloldva - No PDFs found in {folder} Nem található PDF itt: {folder} - Added the first {n} PDFs in {folder} {folder} első {n} PDF-je hozzáadva + No PDFs, images or text files found in {folder} Nem található PDF, kép vagy szövegfájl itt: {folder} + Added the first {n} files in {folder} {folder} első {n} fájlja hozzáadva comment menu Edit Szerkesztés Undo {action} {action} visszavonása Redo {action} {action} megismétlése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d0b56fd9..9b5f37e3 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1590,16 +1590,16 @@ Opened, but its security settings don't allow combining. Enter the permissions password. Aperto, ma le sue impostazioni di sicurezza non consentono la combinazione. Inserisci la password delle autorizzazioni. Unlocked: the combined file won't be password-protected Sbloccato: il file combinato non sarà protetto da password Unlocked {n} of {m} files Sbloccati {n} file su {m} - Drop PDFs or folders here, or choose them. Trascina qui PDF o cartelle, oppure sceglili. + Drop PDFs, images, text files or folders here, or choose them. Trascina qui PDF, immagini, file di testo o cartelle, oppure sceglili. Add folder… Aggiungi cartella… Add folder and subfolders… Aggiungi cartella e sottocartelle… More ways to add files Altri modi per aggiungere file - Every PDF in the folder Tutti i PDF nella cartella - Every PDF in the folder and the folders inside it Tutti i PDF nella cartella e nelle sue sottocartelle + Every PDF, image and text file in the folder Tutti i PDF, le immagini e i file di testo nella cartella + Every PDF, image and text file in the folder and the folders inside it Tutti i PDF, le immagini e i file di testo nella cartella e nelle sue sottocartelle Add folder Aggiungi cartella Add folder and subfolders Aggiungi cartella e sottocartelle - No PDFs found in {folder} Nessun PDF trovato in {folder} - Added the first {n} PDFs in {folder} Aggiunti i primi {n} PDF di {folder} + No PDFs, images or text files found in {folder} Nessun PDF, immagine o file di testo trovato in {folder} + Added the first {n} files in {folder} Aggiunti i primi {n} file di {folder} Click to sort, drag to move the column. Drag its edge to resize it. Fai clic per ordinare, trascina per spostare la colonna. Trascinane il bordo per ridimensionarla. Reset columns Reimposta colonne Remove {n} files Rimuovi {n} file @@ -1617,7 +1617,7 @@ {n} files need attention {n} file richiedono attenzione {name} can't be combined: {why} Impossibile combinare {name}: {why} Check the pages of {name}: {why} Controlla le pagine di {name}: {why} - Add the PDFs to combine Aggiungi i PDF da combinare + Add the files to combine Aggiungi i file da combinare File name Nome file Warnings Avvisi Drag to reorder Trascina per riordinare @@ -1630,6 +1630,10 @@ Password-protected Protetto da password Its security settings don't allow copying pages Le sue impostazioni di sicurezza non consentono di copiare le pagine Can't be read: {e} Impossibile leggerlo: {e} + Leave out files that can't be read? Escludere i file che non si possono leggere? + These files can't be read, so they won't be in the combined PDF: Questi file non si possono leggere, quindi non saranno nel PDF combinato: + Combine the rest Combina gli altri + and {n} more e altri {n} Includes unsaved changes Include modifiche non salvate Pages of {name} to combine, e.g. 1-3, 6 Pagine di {name} da combinare, es. 1-3, 6 Remove {name} Rimuovi {name} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 924fac2d..0a0a51d1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1595,16 +1595,16 @@ Opened, but its security settings don't allow combining. Enter the permissions password. 開けましたが、セキュリティ設定により結合が許可されていません。権限パスワードを入力してください。 Unlocked: the combined file won't be password-protected ロック解除済み: 結合後のファイルはパスワードで保護されません Unlocked {n} of {m} files {m} 個中 {n} 個のファイルのロックを解除しました - Drop PDFs or folders here, or choose them. ここに PDF またはフォルダーをドロップするか、選択してください。 + Drop PDFs, images, text files or folders here, or choose them. ここに PDF、画像、テキストファイル、またはフォルダーをドロップするか、選択してください。 Add folder… フォルダーを追加… Add folder and subfolders… フォルダーとサブフォルダーを追加… More ways to add files ファイルを追加するその他の方法 - Every PDF in the folder フォルダー内のすべての PDF - Every PDF in the folder and the folders inside it フォルダーとその中のフォルダーにあるすべての PDF + Every PDF, image and text file in the folder フォルダー内のすべての PDF、画像、テキストファイル + Every PDF, image and text file in the folder and the folders inside it フォルダーとその中のフォルダーにあるすべての PDF、画像、テキストファイル Add folder フォルダーを追加 Add folder and subfolders フォルダーとサブフォルダーを追加 - No PDFs found in {folder} {folder} に PDF が見つかりません - Added the first {n} PDFs in {folder} {folder} の最初の {n} 個の PDF を追加しました + No PDFs, images or text files found in {folder} {folder} に PDF、画像、テキストファイルが見つかりません + Added the first {n} files in {folder} {folder} の最初の {n} 個のファイルを追加しました Click to sort, drag to move the column. Drag its edge to resize it. クリックで並べ替え、ドラッグで列を移動します。列の端をドラッグすると幅を変更できます。 Reset columns 列をリセット Remove {n} files {n} 個のファイルを削除 @@ -1622,7 +1622,7 @@ {n} files need attention {n} 個のファイルに問題があります {name} can't be combined: {why} {name} は結合できません: {why} Check the pages of {name}: {why} {name} のページ指定を確認してください: {why} - Add the PDFs to combine 結合する PDF を追加 + Add the files to combine 結合するファイルを追加 File name ファイル名 Warnings 警告 Drag to reorder ドラッグして並べ替え @@ -1635,6 +1635,10 @@ Password-protected パスワードで保護されています Its security settings don't allow copying pages セキュリティ設定によりページのコピーが許可されていません Can't be read: {e} 読み込めません: {e} + Leave out files that can't be read? 読み込めないファイルを除外しますか? + These files can't be read, so they won't be in the combined PDF: 次のファイルは読み込めないため、結合した PDF には含まれません: + Combine the rest 残りを結合 + and {n} more ほか {n} 件 Includes unsaved changes 未保存の変更を含みます Pages of {name} to combine, e.g. 1-3, 6 結合する {name} のページ(例: 1-3, 6) Remove {name} {name} を削除 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4d621bd0..e61991eb 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1580,16 +1580,16 @@ Opened, but its security settings don't allow combining. Enter the permissions password. Otwarto, ale ustawienia zabezpieczeń nie zezwalają na łączenie. Wpisz hasło uprawnień. Unlocked: the combined file won't be password-protected Odblokowano: połączony plik nie będzie chroniony hasłem Unlocked {n} of {m} files Odblokowano {n} z {m} plików - Drop PDFs or folders here, or choose them. Upuść tutaj pliki PDF lub foldery albo wybierz je. + Drop PDFs, images, text files or folders here, or choose them. Upuść tutaj pliki PDF, obrazy, pliki tekstowe lub foldery albo wybierz je. Add folder… Dodaj folder… Add folder and subfolders… Dodaj folder i podfoldery… More ways to add files Więcej sposobów dodawania plików - Every PDF in the folder Każdy PDF w folderze - Every PDF in the folder and the folders inside it Każdy PDF w folderze i jego podfolderach + Every PDF, image and text file in the folder Każdy PDF, obraz i plik tekstowy w folderze + Every PDF, image and text file in the folder and the folders inside it Każdy PDF, obraz i plik tekstowy w folderze i jego podfolderach Add folder Dodaj folder Add folder and subfolders Dodaj folder i podfoldery - No PDFs found in {folder} Nie znaleziono plików PDF w {folder} - Added the first {n} PDFs in {folder} Dodano pierwsze {n} plików PDF z {folder} + No PDFs, images or text files found in {folder} Nie znaleziono plików PDF, obrazów ani plików tekstowych w {folder} + Added the first {n} files in {folder} Dodano pierwsze {n} plików z {folder} Click to sort, drag to move the column. Drag its edge to resize it. Kliknij, aby sortować; przeciągnij, aby przenieść kolumnę. Przeciągnij jej krawędź, aby zmienić szerokość. Reset columns Zresetuj kolumny Remove {n} files Usuń pliki w liczbie {n} @@ -1607,7 +1607,7 @@ {n} files need attention Liczba plików wymagających uwagi: {n} {name} can't be combined: {why} Nie można połączyć {name}: {why} Check the pages of {name}: {why} Sprawdź strony {name}: {why} - Add the PDFs to combine Dodaj pliki PDF do połączenia + Add the files to combine Dodaj pliki do połączenia File name Nazwa pliku Warnings Ostrzeżenia Drag to reorder Przeciągnij, aby zmienić kolejność @@ -1620,6 +1620,10 @@ Password-protected Chroniony hasłem Its security settings don't allow copying pages Ustawienia zabezpieczeń nie zezwalają na kopiowanie stron Can't be read: {e} Nie można odczytać: {e} + Leave out files that can't be read? Pominąć pliki, których nie można odczytać? + These files can't be read, so they won't be in the combined PDF: Tych plików nie można odczytać, więc nie znajdą się w połączonym pliku PDF: + Combine the rest Połącz pozostałe + and {n} more i jeszcze {n} Includes unsaved changes Zawiera niezapisane zmiany Pages of {name} to combine, e.g. 1-3, 6 Strony z {name} do połączenia, np. 1-3, 6 Remove {name} Usuń {name} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2f400784..d9409d36 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1942,14 +1942,14 @@ Add open documents Adicionar documentos abertos All open documents Todos os documentos abertos No documents are open Nenhum documento está aberto - Add the PDFs to combine Adicionar os PDFs para combinar - Drop PDFs or folders here, or choose them. Solte PDFs ou pastas aqui, ou escolha-os. + Add the files to combine Adicionar os arquivos para combinar + Drop PDFs, images, text files or folders here, or choose them. Solte PDFs, imagens, arquivos de texto ou pastas aqui, ou escolha-os. Add folder… Adicionar pasta… More ways to add files Mais formas de adicionar arquivos Add folder Adicionar pasta - Every PDF in the folder Todo PDF da pasta + Every PDF, image and text file in the folder Todo PDF, imagem e arquivo de texto da pasta Add folder and subfolders… Adicionar pasta e subpastas… - Every PDF in the folder and the folders inside it Todo PDF da pasta e das pastas dentro dela + Every PDF, image and text file in the folder and the folders inside it Todo PDF, imagem e arquivo de texto da pasta e das pastas dentro dela Add folder and subfolders Adicionar pasta e subpastas PDF, images and text PDF, imagens e texto Files are combined from top to bottom. Leave Pages empty to take every page. Os arquivos são combinados de cima para baixo. Deixe Páginas em branco para incluir todas as páginas. @@ -1968,6 +1968,10 @@ Opened, but its security settings don't allow combining. Enter the permissions password. Aberto, mas as configurações de segurança dele não permitem combinar. Digite a senha de permissões. Its security settings don't allow copying pages As configurações de segurança dele não permitem copiar páginas Can't be read: {e} Não é possível ler: {e} + Leave out files that can't be read? Deixar de fora os arquivos que não podem ser lidos? + These files can't be read, so they won't be in the combined PDF: Estes arquivos não podem ser lidos, então não estarão no PDF combinado: + Combine the rest Combinar os demais + and {n} more e mais {n} Unlocked: the combined file won't be password-protected Desbloqueado: o arquivo combinado não será protegido por senha Protected: the combined file won't keep its security settings Protegido: o arquivo combinado não manterá as configurações de segurança Unlock {name} Desbloquear {name} @@ -2041,7 +2045,7 @@ action wizard Start Iniciar A script Um script About Sobre Add {what} Adicionar {what} - Added the first {n} PDFs in {folder} Adicionados os primeiros {n} PDFs de {folder} + Added the first {n} files in {folder} Adicionados os primeiros {n} arquivos de {folder} Align fields Alinhar campos Another file dialog is still open. Finish with it first. Outra janela de arquivos ainda está aberta. Conclua-a primeiro. Basic constraints Restrições básicas @@ -2082,7 +2086,7 @@ action wizard Start Iniciar Model Modelo Models Modelos Name (A–Z) Nome (A–Z) - No PDFs found in {folder} Nenhum PDF encontrado em {folder} + No PDFs, images or text files found in {folder} Nenhum PDF, imagem ou arquivo de texto encontrado em {folder} None of those files can be made into a PDF; use PDFs, images or .txt files Nenhum desses arquivos pode virar PDF; use PDFs, imagens ou arquivos .txt Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply. Abre as preferências do driver da impressora. Elas valem para toda impressão deste computador; as cópias, frente e verso, escala de cinza e papel escolhidos aqui continuam valendo. Optimization cancelled Otimização cancelada diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d173b3e3..18a01e46 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1587,16 +1587,16 @@ Opened, but its security settings don't allow combining. Enter the permissions password. Открыто, но настройки безопасности не разрешают объединение. Введите пароль разрешений. Unlocked: the combined file won't be password-protected Разблокировано: объединённый файл не будет защищён паролем Unlocked {n} of {m} files Разблокировано {n} из {m} файлов - Drop PDFs or folders here, or choose them. Перетащите PDF или папки сюда или выберите их. + Drop PDFs, images, text files or folders here, or choose them. Перетащите PDF, изображения, текстовые файлы или папки сюда или выберите их. Add folder… Добавить папку… Add folder and subfolders… Добавить папку и вложенные папки… More ways to add files Больше способов добавить файлы - Every PDF in the folder Каждый PDF в папке - Every PDF in the folder and the folders inside it Каждый PDF в папке и вложенных папках + Every PDF, image and text file in the folder Каждый PDF, изображение и текстовый файл в папке + Every PDF, image and text file in the folder and the folders inside it Каждый PDF, изображение и текстовый файл в папке и вложенных папках Add folder Добавить папку Add folder and subfolders Добавить папку и вложенные папки - No PDFs found in {folder} В {folder} не найдено PDF - Added the first {n} PDFs in {folder} Добавлены первые {n} PDF из {folder} + No PDFs, images or text files found in {folder} В {folder} не найдено PDF, изображений или текстовых файлов + Added the first {n} files in {folder} Добавлены первые {n} файлов из {folder} Click to sort, drag to move the column. Drag its edge to resize it. Щёлкните для сортировки, перетащите, чтобы переместить столбец. Перетащите край, чтобы изменить размер. Reset columns Сбросить столбцы Remove {n} files Удалить {n} файлов @@ -1614,7 +1614,7 @@ {n} files need attention {n} файлов требуют внимания {name} can't be combined: {why} {name} нельзя объединить: {why} Check the pages of {name}: {why} Проверьте страницы {name}: {why} - Add the PDFs to combine Добавить PDF для объединения + Add the files to combine Добавить файлы для объединения File name Имя файла Warnings Предупреждения Drag to reorder Перетащить для изменения порядка @@ -1627,6 +1627,10 @@ Password-protected Защищён паролем Its security settings don't allow copying pages Его настройки безопасности не разрешают копировать страницы Can't be read: {e} Нельзя прочитать: {e} + Leave out files that can't be read? Пропустить файлы, которые не удаётся прочитать? + These files can't be read, so they won't be in the combined PDF: Эти файлы не удаётся прочитать, поэтому их не будет в объединённом PDF: + Combine the rest Объединить остальные + and {n} more и ещё {n} Includes unsaved changes Включает несохранённые изменения Pages of {name} to combine, e.g. 1-3, 6 Страницы {name} для объединения, например 1-3, 6 Remove {name} Удалить {name} diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 933f8727..4a0fc1e6 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -1987,16 +1987,16 @@ action wizard Start ప్రారంభించు Opened, but its security settings don't allow combining. Enter the permissions password. తెరవబడింది, కానీ దీని భద్రతా సెట్టింగ్‌లు కలపడాన్ని అనుమతించవు. అనుమతుల పాస్‌వర్డ్‌ను నమోదు చేయండి. Unlocked: the combined file won't be password-protected అన్‌లాక్ చేయబడింది: కలిపిన ఫైల్ పాస్‌వర్డ్‌తో రక్షించబడదు Unlocked {n} of {m} files {m} ఫైల్‌లలో అన్‌లాక్ చేయబడినవి: {n} - Drop PDFs or folders here, or choose them. PDFలను లేదా ఫోల్డర్‌లను ఇక్కడ లాగి వదలండి, లేదా వాటిని ఎంచుకోండి. + Drop PDFs, images, text files or folders here, or choose them. PDFలను, చిత్రాలను, టెక్స్ట్ ఫైల్‌లను లేదా ఫోల్డర్‌లను ఇక్కడ లాగి వదలండి, లేదా వాటిని ఎంచుకోండి. Add folder… ఫోల్డర్‌ను జోడించు… Add folder and subfolders… ఫోల్డర్‌ను, దాని ఉప ఫోల్డర్‌లను జోడించు… More ways to add files ఫైల్‌లను జోడించడానికి మరిన్ని మార్గాలు - Every PDF in the folder ఫోల్డర్‌లోని ప్రతి PDF - Every PDF in the folder and the folders inside it ఫోల్డర్‌లోని, దాని లోపలి ఫోల్డర్‌లలోని ప్రతి PDF + Every PDF, image and text file in the folder ఫోల్డర్‌లోని ప్రతి PDF, చిత్రం, టెక్స్ట్ ఫైల్ + Every PDF, image and text file in the folder and the folders inside it ఫోల్డర్‌లోని, దాని లోపలి ఫోల్డర్‌లలోని ప్రతి PDF, చిత్రం, టెక్స్ట్ ఫైల్ Add folder ఫోల్డర్‌ను జోడించు Add folder and subfolders ఫోల్డర్‌ను, దాని ఉప ఫోల్డర్‌లను జోడించు - No PDFs found in {folder} {folder}లో PDFలు ఏవీ కనుగొనబడలేదు - Added the first {n} PDFs in {folder} {folder}లోని మొదటి {n} PDFలు జోడించబడ్డాయి + No PDFs, images or text files found in {folder} {folder}లో PDFలు, చిత్రాలు లేదా టెక్స్ట్ ఫైల్‌లు ఏవీ కనుగొనబడలేదు + Added the first {n} files in {folder} {folder}లోని మొదటి {n} ఫైల్‌లు జోడించబడ్డాయి Click to sort, drag to move the column. Drag its edge to resize it. క్రమపరచడానికి క్లిక్ చేయండి, నిలువు వరుసను తరలించడానికి లాగండి. పరిమాణం మార్చడానికి దాని అంచును లాగండి. Reset columns నిలువు వరుసలను రీసెట్ చేయి Remove {n} files {n} ఫైల్‌లను తీసివేయి @@ -2014,7 +2014,7 @@ action wizard Start ప్రారంభించు {n} files need attention {n} ఫైల్‌లలో సమస్యలు ఉన్నాయి {name} can't be combined: {why} {name}ను కలపడం సాధ్యం కాదు: {why} Check the pages of {name}: {why} {name} పేజీలను తనిఖీ చేయండి: {why} - Add the PDFs to combine కలపాల్సిన PDFలను జోడించండి + Add the files to combine కలపాల్సిన ఫైల్‌లను జోడించండి File name ఫైల్ పేరు Warnings హెచ్చరికలు Drag to reorder క్రమం మార్చడానికి లాగండి @@ -2027,6 +2027,10 @@ action wizard Start ప్రారంభించు Password-protected పాస్‌వర్డ్‌తో రక్షించబడింది Its security settings don't allow copying pages దీని భద్రతా సెట్టింగ్‌లు పేజీలను కాపీ చేయడాన్ని అనుమతించవు Can't be read: {e} చదవడం సాధ్యం కాదు: {e} + Leave out files that can't be read? చదవలేని ఫైల్‌లను వదిలేయాలా? + These files can't be read, so they won't be in the combined PDF: ఈ ఫైల్‌లను చదవలేము, కాబట్టి అవి కలిపిన PDFలో ఉండవు: + Combine the rest మిగిలినవి కలుపు + and {n} more మరియు మరో {n} Includes unsaved changes సేవ్ చేయని మార్పులు ఉన్నాయి This web address mixes letters from different alphabets, a common way to imitate another site's name. ఈ వెబ్ చిరునామాలో వేర్వేరు లిపుల అక్షరాలు కలిసి ఉన్నాయి. వేరే సైట్ పేరును అనుకరించడానికి ఇది తరచూ వాడే పద్ధతి. This web address uses letters from another alphabet, which can look like familiar ones. ఈ వెబ్ చిరునామాలో వేరే లిపి అక్షరాలు ఉన్నాయి. అవి మీకు తెలిసిన అక్షరాల్లాగే కనిపించవచ్చు. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bd59a47b..3f04ab9c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1584,16 +1584,16 @@ Opened, but its security settings don't allow combining. Enter the permissions password. Відкрито, але налаштування захисту не дозволяють об’єднання. Введіть пароль дозволів. Unlocked: the combined file won't be password-protected Розблоковано: об’єднаний файл не буде захищено паролем Unlocked {n} of {m} files Розблоковано {n} із {m} файлів - Drop PDFs or folders here, or choose them. Перетягніть сюди PDF або теки чи виберіть їх. + Drop PDFs, images, text files or folders here, or choose them. Перетягніть сюди PDF, зображення, текстові файли або теки чи виберіть їх. Add folder… Додати теку… Add folder and subfolders… Додати теку й підтеки… More ways to add files Інші способи додати файли - Every PDF in the folder Усі PDF у теці - Every PDF in the folder and the folders inside it Усі PDF у теці та її підтеках + Every PDF, image and text file in the folder Усі PDF, зображення й текстові файли в теці + Every PDF, image and text file in the folder and the folders inside it Усі PDF, зображення й текстові файли в теці та її підтеках Add folder Додати теку Add folder and subfolders Додати теку й підтеки - No PDFs found in {folder} У {folder} не знайдено PDF - Added the first {n} PDFs in {folder} Додано перші {n} PDF із {folder} + No PDFs, images or text files found in {folder} У {folder} не знайдено PDF, зображень або текстових файлів + Added the first {n} files in {folder} Додано перші {n} файлів із {folder} Click to sort, drag to move the column. Drag its edge to resize it. Клацніть для сортування, перетягніть для переміщення стовпця. Тягніть за його край для зміни розміру. Reset columns Скинути стовпці Remove {n} files Видалити файли: {n} @@ -1611,7 +1611,7 @@ {n} files need attention Файли потребують уваги: {n} {name} can't be combined: {why} {name} неможливо об’єднати: {why} Check the pages of {name}: {why} Перевірте сторінки {name}: {why} - Add the PDFs to combine Додайте PDF для об’єднання + Add the files to combine Додайте файли для об’єднання File name Назва файлу Warnings Попередження Drag to reorder Перетягніть для зміни порядку @@ -1624,6 +1624,10 @@ Password-protected Захищено паролем Its security settings don't allow copying pages Налаштування захисту не дозволяють копіювати сторінки Can't be read: {e} Не вдалося прочитати: {e} + Leave out files that can't be read? Пропустити файли, які не вдається прочитати? + These files can't be read, so they won't be in the combined PDF: Ці файли не вдається прочитати, тож їх не буде в об’єднаному PDF: + Combine the rest Об’єднати решту + and {n} more і ще {n} Includes unsaved changes Містить незбережені зміни Pages of {name} to combine, e.g. 1-3, 6 Сторінки з {name} для об’єднання, наприклад 1-3, 6 Remove {name} Видалити {name} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3477455f..fb349fc3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1554,16 +1554,16 @@ Opened, but its security settings don't allow combining. Enter the permissions password. 已打开,但其安全设置不允许合并。请输入权限密码。 Unlocked: the combined file won't be password-protected 已解锁:合并后的文件将不受密码保护 Unlocked {n} of {m} files 已解锁 {m} 个文件中的 {n} 个 - Drop PDFs or folders here, or choose them. 将 PDF 或文件夹拖放到此处,或选择它们。 + Drop PDFs, images, text files or folders here, or choose them. 将 PDF、图像、文本文件或文件夹拖放到此处,或选择它们。 Add folder… 添加文件夹… Add folder and subfolders… 添加文件夹及子文件夹… More ways to add files 更多添加文件的方式 - Every PDF in the folder 文件夹中的所有 PDF - Every PDF in the folder and the folders inside it 文件夹及其子文件夹中的所有 PDF + Every PDF, image and text file in the folder 文件夹中的所有 PDF、图像和文本文件 + Every PDF, image and text file in the folder and the folders inside it 文件夹及其子文件夹中的所有 PDF、图像和文本文件 Add folder 添加文件夹 Add folder and subfolders 添加文件夹及子文件夹 - No PDFs found in {folder} 在 {folder} 中未找到 PDF - Added the first {n} PDFs in {folder} 已添加 {folder} 中的前 {n} 个 PDF + No PDFs, images or text files found in {folder} 在 {folder} 中未找到 PDF、图像或文本文件 + Added the first {n} files in {folder} 已添加 {folder} 中的前 {n} 个文件 Click to sort, drag to move the column. Drag its edge to resize it. 单击以排序,拖动以移动列。拖动列边缘可调整宽度。 Reset columns 重置列 Remove {n} files 删除 {n} 个文件 @@ -1581,7 +1581,7 @@ {n} files need attention {n} 个文件需要处理 {name} can't be combined: {why} 无法合并 {name}:{why} Check the pages of {name}: {why} 请检查 {name} 的页码:{why} - Add the PDFs to combine 添加要合并的 PDF + Add the files to combine 添加要合并的文件 File name 文件名 Warnings 警告 Drag to reorder 拖动以重新排序 @@ -1594,6 +1594,10 @@ Password-protected 受密码保护 Its security settings don't allow copying pages 其安全设置不允许复制页面 Can't be read: {e} 无法读取:{e} + Leave out files that can't be read? 要排除无法读取的文件吗? + These files can't be read, so they won't be in the combined PDF: 这些文件无法读取,因此不会包含在合并后的 PDF 中: + Combine the rest 合并其余文件 + and {n} more 以及另外 {n} 个 Includes unsaved changes 包含未保存的更改 Pages of {name} to combine, e.g. 1-3, 6 要合并的{name}的页面,例如 1-3、6 Remove {name} 删除{name} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6f4139ae..c1b1914e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1502,16 +1502,16 @@ Opened, but its security settings don't allow combining. Enter the permissions password. 已開啟,但其安全性設定不允許合併。請輸入權限密碼。 Unlocked: the combined file won't be password-protected 已解除鎖定:合併後的檔案將不受密碼保護 Unlocked {n} of {m} files 已解除鎖定 {m} 個檔案中的 {n} 個 - Drop PDFs or folders here, or choose them. 將 PDF 或資料夾拖放到此處,或選取它們。 + Drop PDFs, images, text files or folders here, or choose them. 將 PDF、圖片、文字檔案或資料夾拖放到此處,或選取它們。 Add folder… 新增資料夾… Add folder and subfolders… 新增資料夾及子資料夾… More ways to add files 更多新增檔案的方式 - Every PDF in the folder 資料夾中的所有 PDF - Every PDF in the folder and the folders inside it 資料夾及其子資料夾中的所有 PDF + Every PDF, image and text file in the folder 資料夾中的所有 PDF、圖片和文字檔案 + Every PDF, image and text file in the folder and the folders inside it 資料夾及其子資料夾中的所有 PDF、圖片和文字檔案 Add folder 新增資料夾 Add folder and subfolders 新增資料夾及子資料夾 - No PDFs found in {folder} 在 {folder} 中找不到 PDF - Added the first {n} PDFs in {folder} 已新增 {folder} 中的前 {n} 個 PDF + No PDFs, images or text files found in {folder} 在 {folder} 中找不到 PDF、圖片或文字檔案 + Added the first {n} files in {folder} 已新增 {folder} 中的前 {n} 個檔案 Click to sort, drag to move the column. Drag its edge to resize it. 按一下以排序,拖曳以移動欄。拖曳欄邊緣可調整寬度。 Reset columns 重設欄 Remove {n} files 刪除 {n} 個檔案 @@ -1529,7 +1529,7 @@ {n} files need attention {n} 個檔案需要處理 {name} can't be combined: {why} 無法合併 {name}:{why} Check the pages of {name}: {why} 請檢查 {name} 的頁碼:{why} - Add the PDFs to combine 新增要合併的 PDF + Add the files to combine 新增要合併的檔案 File name 檔案名稱 Warnings 警告 Drag to reorder 拖曳以重新排序 @@ -1542,6 +1542,10 @@ Password-protected 受密碼保護 Its security settings don't allow copying pages 其安全性設定不允許複製頁面 Can't be read: {e} 無法讀取:{e} + Leave out files that can't be read? 要排除無法讀取的檔案嗎? + These files can't be read, so they won't be in the combined PDF: 這些檔案無法讀取,因此不會包含在合併後的 PDF 中: + Combine the rest 合併其餘檔案 + and {n} more 以及另外 {n} 個 Includes unsaved changes 包含未儲存的變更 Pages of {name} to combine, e.g. 1-3, 6 要合併的{name}的頁面,例如 1-3、6 Remove {name} 刪除{name} diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 518399f9..62753e84 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -735,7 +735,7 @@ fn combine_files_opens_in_its_own_tab() { h.state_mut().execute("page.combine"); h.run_steps(3); assert!(h.state().combine_showing() && h.state().active.is_none()); - h.get_by_label("Add the PDFs to combine"); + h.get_by_label("Add the files to combine"); // Back to the document, then to Combine through its tab. h.get_by_label("doc.pdf").click(); h.run_steps(3); @@ -991,33 +991,93 @@ fn columns_resize_and_the_layout_is_kept_in_the_settings() { } #[test] -fn a_folder_adds_its_pdfs_in_order_and_optionally_its_subfolders() { +fn a_folder_adds_its_pdfs_images_and_text_in_order_and_optionally_its_subfolders() { let dir = std::env::temp_dir().join(format!("pdfcraft-combine-folder-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(dir.join("sub")).unwrap(); for (name, pages) in [("page10.pdf", 1), ("page2.PDF", 2), ("sub/inner.pdf", 3)] { std::fs::write(dir.join(name), fixture(pages)).unwrap(); } - std::fs::write(dir.join("notes.txt"), b"not a pdf").unwrap(); + // #569: images and text files join too; other files are left out. + std::fs::write(dir.join("notes.txt"), b"Some notes").unwrap(); + std::fs::write(dir.join("readme.md"), b"# not combinable").unwrap(); let mut h = harness(1, |_| {}); h.state_mut().execute("page.combine"); h.run_steps(2); h.state_mut().pick_override = Some(vec![dir.to_string_lossy().into_owned()]); h.get_by_label("Add folder…").click(); h.run_steps(4); - assert_eq!(combine_names(&h), ["page2.PDF", "page10.pdf"], "PDFs only, numbers by value"); + assert_eq!(combine_names(&h), ["notes.txt", "page2.PDF", "page10.pdf"], "numbers by value"); // The toolbar's menu: the folder and its subfolders, as one undo step. h.get_by_label("More ways to add files").click(); h.run_steps(2); h.get_by_label("Add folder and subfolders…").click(); h.run_steps(4); - assert_eq!(combine_names(&h), ["page2.PDF", "page10.pdf", "page2.PDF", "page10.pdf", "inner.pdf"]); + assert_eq!(combine_names(&h), ["notes.txt", "page2.PDF", "page10.pdf", "notes.txt", "page2.PDF", "page10.pdf", "inner.pdf"]); h.key_press_modifiers(Modifiers::COMMAND, Key::Z); h.run_steps(2); - assert_eq!(combine_names(&h).len(), 2); + assert_eq!(combine_names(&h).len(), 3); let _ = std::fs::remove_dir_all(&dir); } +/// #569: an image or a text file added to Combine files becomes a PDF as it joins the list, an +/// ordinary row with its pages. +#[test] +fn images_and_text_files_join_the_combine_list_as_pdfs() { + let mut png = std::io::Cursor::new(Vec::new()); + image::RgbImage::from_pixel(40, 20, image::Rgb([90, 120, 200])).write_to(&mut png, image::ImageFormat::Png).unwrap(); + let mut h = combine_of(&[("one.pdf", 2)]); + h.state_mut() + .use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("scan.png".into(), png.into_inner()), ("notes.txt".into(), b"Some notes".to_vec())]); + h.run_steps(3); + assert_eq!(combine_names(&h), ["one.pdf", "scan.png", "notes.txt"]); + let draft = &h.state().combine_draft; + assert!(draft.iter().all(|f| f.bytes.starts_with(b"%PDF-") && f.problem.is_none()), "made into PDFs"); + assert_eq!(draft.iter().map(|f| f.pages).collect::>(), [2, 1, 1]); + h.get_by_label("Combine").click(); + h.run_steps(3); + assert_eq!(texts_of(h.state(), 1), ["Page 1", "Page 2", "", "Some notes"]); +} + +/// A file that can't be read (a damaged image, a type that can't be made into a PDF) joins the +/// list all the same, with why; Combine asks before leaving it out, and Cancel changes nothing. +#[test] +fn files_that_cannot_be_read_are_listed_and_left_out_once_the_user_agrees() { + let mut h = combine_of(&[("one.pdf", 2)]); + let bad = vec![("broken.jpg".to_string(), b"\xff\xd8\xff\xe0 not a JPEG".to_vec()), ("report.docx".to_string(), b"PK\x03\x04".to_vec())]; + h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::Combine, bad); + h.run_steps(3); + assert_eq!(combine_names(&h), ["one.pdf", "broken.jpg", "report.docx"]); + for f in &h.state().combine_draft[1..] { + let why = f.problem.as_deref().expect("why it can't be combined"); + assert!(why.starts_with("Can't be read: ") && !why.contains(&f.name), "{why}"); + assert!(f.lock.is_none() && f.pages == 0); + } + h.get_by_label_contains("this file type can't be converted"); + // One readable file is not enough to combine. + assert!(h.get_by_label("Combine").accesskit_node().is_disabled()); + h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("two.pdf".into(), fixture(1))]); + h.run_steps(3); + assert!(!h.get_by_label("Combine").accesskit_node().is_disabled()); + // Combine asks first, naming the files left out; Cancel leaves everything as it was. + h.get_by_label("Combine").click(); + h.run_steps(3); + h.get_by_label("Leave out files that can't be read?"); + h.get_by_label("• broken.jpg"); + h.get_by_label("• report.docx"); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(h.query_by_label("Leave out files that can't be read?").is_none()); + assert_eq!(h.state().views.len(), 1, "nothing combined"); + assert_eq!(combine_names(&h).len(), 4); + // Asked again: Combine the rest. + h.get_by_label("Combine").click(); + h.run_steps(3); + h.get_by_label("Combine the rest").click(); + h.run_steps(3); + assert_eq!(texts_of(h.state(), 1), ["Page 1", "Page 2", "Page 1"]); +} + fn type_password(h: &mut Harness<'static, PdfCraftApp>, password: &str) { let field = h.get_by_role(Role::PasswordInput); field.focus(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 097f123a..f4799655 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2914,8 +2914,8 @@ milestone = "M4" status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] -evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/editing.rs::combine_files_opens_in_its_own_tab", "crates/ui-egui/tests/editing.rs::combine_lists_size_and_warns_before_combining", "crates/ui-egui/tests/editing.rs::a_password_protected_file_is_unlocked_with_its_password_and_combined", "crates/ui-egui/tests/editing.rs::a_folder_adds_its_pdfs_in_order_and_optionally_its_subfolders", "crates/ui-egui/tests/ui.rs::pdfs_dropped_on_the_combine_tab_join_its_list", "crates/automation/tests/automation.rs::combine_extract_and_split", "crates/automation/tests/automation.rs::combine_opens_protected_files_with_their_passwords", "crates/organize/src/tests.rs::combine_keeps_pdfx_when_every_source_agrees", "crates/organize/src/tests.rs::combine_claims_no_pdfx_when_the_sources_disagree"] -notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab. Problems show before Combine (password, security settings that withhold page assembly, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step. A PDF/X result (output intent, GTS_PDFXVersion, Trapped, XMP pdfxid) only when every source declares the same version and output intent (#263). When they differ the result claims none, and nothing reports that yet; Acrobat behaviour not observed." +evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/editing.rs::combine_files_opens_in_its_own_tab", "crates/ui-egui/tests/editing.rs::combine_lists_size_and_warns_before_combining", "crates/ui-egui/tests/editing.rs::a_password_protected_file_is_unlocked_with_its_password_and_combined", "crates/ui-egui/tests/editing.rs::a_folder_adds_its_pdfs_images_and_text_in_order_and_optionally_its_subfolders", "crates/ui-egui/tests/editing.rs::images_and_text_files_join_the_combine_list_as_pdfs", "crates/ui-egui/tests/editing.rs::files_that_cannot_be_read_are_listed_and_left_out_once_the_user_agrees", "crates/ui-egui/tests/ui.rs::pdfs_dropped_on_the_combine_tab_join_its_list", "crates/automation/tests/automation.rs::combine_extract_and_split", "crates/automation/tests/automation.rs::combine_opens_protected_files_with_their_passwords", "crates/automation/tests/automation.rs::combine_takes_images_and_text_files", "crates/engine/src/tests.rs::files_for_combine_are_made_into_pdfs_and_pdfs_pass_through", "crates/organize/src/tests.rs::combine_keeps_pdfx_when_every_source_agrees", "crates/organize/src/tests.rs::combine_claims_no_pdfx_when_the_sources_disagree"] +notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab. Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files become PDFs as they are added (#569; also in doc_combine). A file that can't be read stays in the list with why, and Combine asks before leaving it out; Office documents, HTML and other formats are not converted yet. Problems show before Combine (password, security settings that withhold page assembly, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step. A PDF/X result (output intent, GTS_PDFXVersion, Trapped, XMP pdfxid) only when every source declares the same version and output intent (#263). When they differ the result claims none, and nothing reports that yet; Acrobat behaviour not observed." [[feature]] id = "organize.combine-bookmarks" From f1e3a04587c6df6abdd25a4a7f41158276c7d3aa Mon Sep 17 00:00:00 2001 From: aAffliu <164289069+huferyliu@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:05:45 +0800 Subject: [PATCH 09/91] Return renderer initialization errors to the OpenGL startup fallback (#1042) * M13: report unavailable symbolic-link fixtures in hostile-argument tests Only include the planted link escape when symlink creation succeeds. On non-elevated Windows the fixture can fail with OS error 1314; exercising its nonexistent path otherwise produces false confinement leaks. Keep all remaining traversal and absolute-path cases and report the skip. Co-authored-by: Codex (GPT-6) * M3: return renderer initialization errors to the OpenGL startup fallback Guard renderer creation with the existing scoped GPU-error handler and return CreateRenderer instead of invoking wgpu's default panic handler. This lets Auto startup reach its existing OpenGL retry before app creation. Add a real-device invalid/valid pipeline regression and Windows startup reproduction notes. The host-specific DX12 shader compiler error itself is not fixed; explicit renderer choices and the non-winit path are unchanged. Co-authored-by: Codex (GPT-6) * Leave the symlink-test fix to #949, which covers the same ground --------- Co-authored-by: Codex (GPT-6) Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + apps/pdfcraft/src/main.rs | 30 +++++++++++ docs/development.md | 6 ++- docs/roadmap.md | 2 +- docs/windows-renderer-startup.md | 89 ++++++++++++++++++++++++++++++++ vendor/README.md | 7 +++ vendor/egui-wgpu/src/lib.rs | 11 ++++ 7 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 docs/windows-renderer-startup.md diff --git a/ROADMAP.md b/ROADMAP.md index df1c764d..f669ca54 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -138,6 +138,8 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-11 (M7, watermark placement):** A watermark can be moved from the page centre by a horizontal and a vertical offset in points: Add/Update Watermark has an "Offset from centre" row (the preview follows it, for text and for pictures) and `doc_watermark` takes `offset_x` and `offset_y`. A non-finite offset is refused instead of written into the page. `edit.watermark-appearance` stays partial: no offset from the page edge or in page units, no print/screen-only variants, no repeat. Image and PDF watermarks, already shipped, no longer appear as missing. The overall estimate is unchanged. - **2026-10-11 (M12, XFA script time limits):** An XFA script's time limit (1 s for scripts that run unasked, on open and on every change) counted the engine's start-up too: the script's thread, a new context and, for the first script in a process, the engine's own set-up (≈ 60–170 ms when idle). On a busy machine a trivial initialize script could pass the limit, be abandoned and turn the form's scripts off; that made `xfa_buttons_run_their_scripts_in_the_app` and `messages_from_scripts_run_on_open_show_right_away` flaky (under full CPU load 25 of 40 runs of the test binary failed, with "ran longer than 1.0 s and was abandoned" in the console). The limit now runs from when the engine is ready; start-up has its own 10 s cap, so nothing waits forever, and a trivial warm-up script takes the first-in-process set-up. Under the same load none of 40 runs fails, and runaway scripts are abandoned as before. The two UI tests print the script console when they fail. Overall unchanged. - **2026-10-11 (M4, #569):** Combine files takes images and text files as well as PDFs. PNG, JPEG, TIFF, GIF, BMP, JPEG 2000 and .txt files become PDFs as they are added (Add files…, drops, folder scans), so each is an ordinary row with its pages, thumbnails and page range; a file that can't be read (a damaged image or PDF, a type that can't be converted) stays in the list with why in its Warnings, and Combine asks before leaving such files out (Cancel changes nothing); password-protected files still have to be unlocked first. `doc_combine` accepts the same types, and a converted file's bookmark drops its extension. A PNG whose header claims more than 2 GiB of pixels is now refused before its buffer is allocated (the png crate's limits don't cover it), as TIFF already was. Office documents and HTML are still not converted. Overall estimate unchanged. +- **2026-10-11 (M3, Windows renderer startup):** Renderer-creation GPU errors return through the existing scoped handler so Auto startup can use the existing OpenGL fallback. A real-device invalid/valid pipeline regression covers error propagation and device reuse; the independent Windows hostile-argument fixture reports unavailable symbolic-link privileges. The underlying x86 DX12 compiler error remains unresolved; see `docs/windows-renderer-startup.md`. Milestone and effort estimates unchanged. + - **2026-10-10 (M14, interface size):** Preferences ▸ Interface size scales the whole interface (50–250 %, setting `ui_scale`, control option `ui-scale`); 50 % brings an interface the system draws at 200 % back to 100 % (#456). Auto, the default, follows the desktop's text scaling: on Linux and the BSDs the desktop app reads GNOME's `text-scaling-factor` once at launch, so with Large Text (1.25) on a 1× display the interface no longer draws 13 px labels that show their pixels next to the system's text. The size is egui's zoom factor, so `pixels_per_point` grows with it and page rasters stay one texel per screen pixel; egui's own ⌘+/⌘− interface zoom is turned off so those keys stay page zoom. Still missing: KDE's font DPI and Windows' "Make text bigger" aren't read (both scale only text there), and egui's text is still greyscale-antialiased without the system's gamma, so small text looks rougher than GTK's at 100 %. - **2026-10-10 (M14, #496):** Shortcut displays share a UI formatter across toolbar search, registry menus, command palette, Help and tooltips. Modifier and key names use a dedicated catalog context in every bundled translation; missing entries keep the English key name rather than a command translation. German shows Strg/Umschalt/Entf, macOS retains its symbols, and runtime language changes refresh the display. Regression coverage checks localized layout widths, English/German switching, platform conventions and unchanged command execution. Milestone and effort estimates unchanged. diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index f046399a..be123fce 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -934,6 +934,36 @@ mod tests { } } + #[test] + fn pipeline_creation_error_is_returned_and_device_remains_usable() { + use eframe::{egui_wgpu, wgpu}; + let mut native = super::native_options(false, eframe::Renderer::Wgpu); + let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; + let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); + let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; + let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { + assert!(std::env::var_os("PDFCRAFT_TEST_REQUIRE_GPU").is_none(), "a GPU adapter is required for this run"); + eprintln!("skipping pipeline regression: no GPU adapter"); + return; + }; + let (device, queue) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) + .expect("the selected adapter must provide a test device"); + native.wgpu_options.wgpu_setup = + egui_wgpu::WgpuSetup::Existing(egui_wgpu::WgpuSetupExisting { instance: instance.clone(), adapter, device, queue }); + // Three samples is invalid on every backend, so this reproduces a pipeline error + // without depending on a particular driver. The unpatched constructor panics. + for samples in [3, 1, 3, 1] { + let options = egui_wgpu::RendererOptions { msaa_samples: samples, ..Default::default() }; + let result = pollster::block_on(egui_wgpu::RenderState::create(&native.wgpu_options, &instance, None, options)); + if samples == 3 { + let error = result.err().expect("invalid pipeline must return an error"); + assert!(error.to_string().contains("egui_pipeline"), "{error}"); + } else { + assert!(result.is_ok(), "the device must remain usable: {:?}", result.err()); + } + } + } + #[test] fn winit_carries_the_windows_11_monitor_scale_fix() { // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another diff --git a/docs/development.md b/docs/development.md index 9708db4c..c682d4f0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -58,7 +58,11 @@ panic, as upstream: an error in a later reconfigure (resizing, a lost surface); during that first configure (wgpu reports it only to a callback, so the first frame panics, after the app has started); and a window shown at zero size, which is first configured on resize. If OpenGL can't start either, PdfCraft exits with both errors in the log. The fallback is logged at -`error`, with the wgpu error. `PDFCRAFT_RENDERER` skips it or chooses OpenGL from the start. +`error`, with the wgpu error. Renderer resource creation now uses the same scopes and returns +`WgpuError::CreateRenderer` before the app is created, so a rejected `egui_pipeline` can also +reach this retry instead of the uncaptured-error panic handler. This does not fix the failing +driver's shader compilation, and explicit `PDFCRAFT_RENDERER=wgpu` still disables the retry. +`PDFCRAFT_RENDERER` skips it or chooses OpenGL from the start. ## Environment variables diff --git a/docs/roadmap.md b/docs/roadmap.md index bdef89a3..d7b4322b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -67,7 +67,7 @@ total matches the parity estimate. | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | | M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; late browser startup downloads preserve the current document and Save target (#171). | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | diff --git a/docs/windows-renderer-startup.md b/docs/windows-renderer-startup.md new file mode 100644 index 00000000..db20cfa6 --- /dev/null +++ b/docs/windows-renderer-startup.md @@ -0,0 +1,89 @@ +# Windows renderer startup errors and the OpenGL fallback + +## Reproduced failure + +On one Windows 11 Pro build 26200 host, the official PdfCraft 0.6.0 x86 installation and +portable EXE both exited with code 101 before displaying a usable window. Their EXE SHA-256 +matched: `3a3a9ba75ec9f8ecc25863e9138fac57e17f359a5de5add486efedd83e0fcf8f`. +The host has an NVIDIA GeForce RTX 3050 Laptop GPU, driver `32.0.16.1088`, and an AMD iGPU. + +Launch the official x86 EXE with `PDFCRAFT_RENDERER` and `WGPU_BACKEND` unset on that host: + +```text +drawing on NVIDIA GeForce RTX 3050 Laptop GPU (DiscreteGpu, Dx12) +Shader translation error for stage ShaderStages(VERTEX | FRAGMENT): 参数错误。 (0x80070057) +Handling wgpu errors as fatal by default +wgpu error: Validation Error +Caused by: + In Device::create_render_pipeline, label = 'egui_pipeline' + Internal error in ShaderStages(VERTEX | FRAGMENT) shader: 参数错误。 (0x80070057) +``` + +The process reaches GPU initialization and then panics. A GUI-subsystem EXE has no console +to display this message during a desktop double-click. The official x64 0.6.0 EXE selected +the same NVIDIA/DX12 adapter and displayed a synthetic two-page PDF. This is a single-host +comparison; it does not establish a defect in all x86 builds or in a particular driver. +Issue #326 describes a similar symptom without enough diagnostics to establish the same cause. + +## Error propagation + +There are two layers: DX12 shader compilation fails, then an unscoped error in `Renderer::new` +reaches wgpu's default fatal handler. The latter prevents the existing OpenGL startup fallback +from running: it needs an `eframe::Error::Wgpu`, not a panic. + +`RenderState::create` reuses the existing `winit::catch_errors` around renderer creation and +returns `WgpuError::CreateRenderer`. Auto startup can then retry with Glow before the app is +created. Explicit renderer choices retain their meaning; the non-winit constructor is unchanged. +This recovers from initialization failure; it does not repair DX12 shader compilation. + +## Regression and actual-use validation + +`pipeline_creation_error_is_returned_and_device_remains_usable` invokes the real constructor +on a real device with sample counts `[3, 1, 3, 1]`. Three is invalid; each error must return, +and each subsequent valid initialization must succeed. The unguarded constructor panicked +with code 101. This deterministic validation error tests propagation and device reuse, +not the underlying host-specific shader compiler defect. + +```powershell +$env:PDFCRAFT_TEST_REQUIRE_GPU = '1' +$env:WGPU_BACKEND = 'vulkan' +cargo test --locked -p pdfcraft pipeline_creation_error_is_returned_and_device_remains_usable -- --nocapture +``` + +An ordinary run reports an unavailable GPU branch as skipped. `PDFCRAFT_TEST_REQUIRE_GPU` +makes that condition fail. Run actual startup acceptance separately with both renderer +variables unset, so the compatibility setting cannot hide the original failure. + +The prior local 0.6.0 integration was exercised as a native x86 GUI EXE on the affected host. +It still recorded `0x80070057`, then `Starting with OpenGL instead` and `renderer: OpenGL (glow)`. +The UI and synthetic PDF contents displayed. A filename containing Chinese, spaces, `&`, +parentheses and `#`, page changes, 150% zoom, repeated opens and a nonexistent-file error +were exercised. The modified x64 app still used DX12 and displayed the PDF. After installing +the tested EXE and removing the forced-GL user setting, the user confirmed the original +desktop shortcut and two ordinary PDF double-clicks worked. That local integration also +included the separately credited Chinese font fix from #426; font changes are outside this PR. + +The startup-only contribution was then applied to main at +`bbbe60015c571ebfc39708ee07777b89e267122a` and rebuilt as a native x86 GUI EXE (PE `0x014c`, +subsystem `2`). With both renderer variables unset it reproduced the same DX12 error, returned +it, started OpenGL, and displayed the synthetic PDF. Page 2, 150% zoom, another file open and a +nonexistent-file error were exercised without page errors or losing the open documents. +This second run does not include the Chinese product fix and is not counted as font acceptance. + +On this contribution branch, workspace tests reported 2,132 passed and 7 declared ignored. +The required-adapter GPU regression, fmt, all-target workspace Clippy with warnings denied, +assets, layering (34 crates) and WASM compilation (30 crates) passed. Browser runtime was +not tested. Initial shared-cache checks hit an existing JBIG2 timeout and an outdated WASM +shading interface; rebuilding differing vendored dependencies cleared both without decoder +or shading code changes. Keep build directories separate between historical worktrees. + +## Independent Windows test-fixture correction + +The `hostile_args` fixture now records whether its planted symbolic link was created. This +non-elevated host returned OS error 1314; the old test nevertheless tried the nonexistent +link and reported false confinement leaks. The link path is tested only when the fixture +exists, and creation failure is explicitly reported. All other traversal and absolute-path +attempts remain. A skipped link case is not evidence that symbolic-link confinement passed. + +No system fonts, screenshots, private PDFs, control tokens or application binaries are included. +Other GPU drivers and non-Windows runtime behavior remain unverified. diff --git a/vendor/README.md b/vendor/README.md index 650d9296..bba6aeb7 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -20,6 +20,13 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::egui_wgpu_carries_the_surface_size_fit`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error`, which keeps it around `add_surface`'s configure | +Renderer startup errors: `egui-wgpu::RenderState::create` also wraps `Renderer::new` in +`winit::catch_errors` when the winit feature is enabled. A rejected shader or render pipeline +is returned as `WgpuError::CreateRenderer`, allowing PdfCraft's existing startup OpenGL retry. +The regression `pdfcraft` `tests::pipeline_creation_error_is_returned_and_device_remains_usable` +uses an invalid three-sample pipeline, then valid and invalid pipelines again on the same device. +Later rendering errors, device loss, and non-winit renderer construction are unchanged. + Temporary: hayro is the bootstrap renderer and lopdf the bootstrap inspector (ADR-0004); M2.6 replaces them with our own devices and `cos`/`model`. Text extraction: hayro-interpret exposes `OutlineGlyph::is_vertical` from a Type0 font’s CMap writing mode. PdfCraft follows negative glyph-space y for WMode 1 so upright vertical columns can be searched (#126). diff --git a/vendor/egui-wgpu/src/lib.rs b/vendor/egui-wgpu/src/lib.rs index 0603d786..80b09a04 100644 --- a/vendor/egui-wgpu/src/lib.rs +++ b/vendor/egui-wgpu/src/lib.rs @@ -68,6 +68,11 @@ pub enum WgpuError { #[cfg(feature = "winit")] #[error("The window's surface couldn't be configured: {0}")] ConfigureSurface(wgpu::Error), + + /// The device rejected renderer resources before the app was created. + #[cfg(feature = "winit")] + #[error("The egui renderer couldn't be created: {0}")] + CreateRenderer(wgpu::Error), } /// Runtime-mutable subset of [`WgpuConfiguration`]. @@ -283,6 +288,12 @@ impl RenderState { }; let target_format = crate::preferred_framebuffer_format(&surface_formats)?; + // Return startup pipeline errors so eframe's caller can retry with another renderer. + #[cfg(feature = "winit")] + let renderer = winit::catch_errors(&device, || Renderer::new(&device, target_format, options)) + .await + .map_err(WgpuError::CreateRenderer)?; + #[cfg(not(feature = "winit"))] let renderer = Renderer::new(&device, target_format, options); // On wasm, depending on feature flags, wgpu objects may or may not implement sync. From f117cde040ba031aafb4888ad583b5bdfe4fd9df Mon Sep 17 00:00:00 2001 From: piero-83 Date: Sun, 11 Oct 2026 18:07:47 +0200 Subject: [PATCH 10/91] Forms: encode appearances with the existing simple font (#1044) * fix(forms): encode appearances with the chosen font * docs: record form font encoding limits * docs: log verified form encoding regressions --------- Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/forms/src/appearance.rs | 56 ++++++++++++------ crates/forms/src/tests.rs | 105 +++++++++++++++++++++++++++++++++ docs/roadmap.md | 5 +- 4 files changed, 150 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f669ca54..393a69d9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,6 +140,8 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-11 (M4, #569):** Combine files takes images and text files as well as PDFs. PNG, JPEG, TIFF, GIF, BMP, JPEG 2000 and .txt files become PDFs as they are added (Add files…, drops, folder scans), so each is an ordinary row with its pages, thumbnails and page range; a file that can't be read (a damaged image or PDF, a type that can't be converted) stays in the list with why in its Warnings, and Combine asks before leaving such files out (Cancel changes nothing); password-protected files still have to be unlocked first. `doc_combine` accepts the same types, and a converted file's bookmark drops its extension. A PNG whose header claims more than 2 GiB of pixels is now refused before its buffer is allocated (the png crate's limits don't cover it), as TIFF already was. Office documents and HTML are still not converted. Overall estimate unchanged. - **2026-10-11 (M3, Windows renderer startup):** Renderer-creation GPU errors return through the existing scoped handler so Auto startup can use the existing OpenGL fallback. A real-device invalid/valid pipeline regression covers error propagation and device reuse; the independent Windows hostile-argument fixture reports unavailable symbolic-link privileges. The underlying x86 DX12 compiler error remains unresolved; see `docs/windows-renderer-startup.md`. Milestone and effort estimates unchanged. +- **2026-10-11 (M6):** Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. Estimates unchanged. + - **2026-10-10 (M14, interface size):** Preferences ▸ Interface size scales the whole interface (50–250 %, setting `ui_scale`, control option `ui-scale`); 50 % brings an interface the system draws at 200 % back to 100 % (#456). Auto, the default, follows the desktop's text scaling: on Linux and the BSDs the desktop app reads GNOME's `text-scaling-factor` once at launch, so with Large Text (1.25) on a 1× display the interface no longer draws 13 px labels that show their pixels next to the system's text. The size is egui's zoom factor, so `pixels_per_point` grows with it and page rasters stay one texel per screen pixel; egui's own ⌘+/⌘− interface zoom is turned off so those keys stay page zoom. Still missing: KDE's font DPI and Windows' "Make text bigger" aren't read (both scale only text there), and egui's text is still greyscale-antialiased without the system's gamma, so small text looks rougher than GTK's at 100 %. - **2026-10-10 (M14, #496):** Shortcut displays share a UI formatter across toolbar search, registry menus, command palette, Help and tooltips. Modifier and key names use a dedicated catalog context in every bundled translation; missing entries keep the English key name rather than a command translation. German shows Strg/Umschalt/Entf, macOS retains its symbols, and runtime language changes refresh the display. Regression coverage checks localized layout widths, English/German switching, platform conventions and unchanged command execution. Milestone and effort estimates unchanged. diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index ea88f675..8478cc26 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -5,17 +5,19 @@ //! marked-content block as viewers expect. Supported: single-line, multiline (wrapped), comb, //! password, quadding, auto font size (`0 Tf`), combo boxes and list boxes (selection shown). //! -//! The `/DA` font is used when the form's `/DR` defines it as a simple font (text encoded in -//! WinAnsi) or as a composite font with a predefined Unicode CMap such as `UniJIS-UTF16-H`, as -//! Japanese forms use (text encoded in that CMap). Otherwise (other composite fonts, missing -//! resources) Helvetica is used, so text is always visible. Widths use the approximate metrics -//! of `pdfcraft-fonts` (Helvetica, or one em per full-width character in composite fonts). +//! The `/DA` font is used when the form's `/DR` defines it as a simple font that can encode the +//! displayed text (using its own encoding and Differences), or as a composite font with a +//! predefined Unicode CMap such as `UniJIS-UTF16-H`, as Japanese forms use. Otherwise (unsupported +//! characters, other composite fonts, missing resources) Helvetica is used. Widths use the +//! approximate metrics of `pdfcraft-fonts` (Helvetica, or one em per full-width character in +//! composite fonts). //! //! `/MK /R` (0, 90, 180, 270, counterclockwise) lays the content out in the upright box and sets //! the appearance `/Matrix` so that box lands on the widget rectangle. Any other value is 0, and //! rotation 0 leaves `/Matrix` off. use pdfcraft_cos::{Dict, Document, Object, Stream}; +use pdfcraft_fonts::pdf::Metrics; use pdfcraft_fonts::{UnicodeCMap, cjk_width, helvetica_width, literal, win_ansi, wrap_with}; use crate::{Field, FieldKind, Widget, acroform, flags}; @@ -111,24 +113,35 @@ pub fn parse_da(da: &str) -> Da { } /// How a field font's text is encoded and measured. -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] enum FontText { WinAnsi, + /// A reused simple font's encoding, including indirect dictionaries and Differences. + Simple(Box), /// A composite font with a predefined Unicode CMap. Unicode(UnicodeCMap), } impl FontText { - fn encode(self, s: &str) -> Vec { + fn encode(&self, s: &str) -> Vec { match self { Self::WinAnsi => win_ansi(s), + // Selection checks every displayed string first; wrapping only removes line breaks. + Self::Simple(metrics) => metrics.encode(&s.replace('\t', " ")).unwrap_or_default(), Self::Unicode(cmap) => cmap.encode(s), } } - fn width(self, s: &str, size: f64) -> f64 { + fn can_encode(&self, s: &str) -> bool { match self { - Self::WinAnsi => helvetica_width(s, size), + Self::Simple(metrics) => metrics.encode(&s.replace('\t', " ")).is_some(), + _ => true, + } + } + + fn width(&self, s: &str, size: f64) -> f64 { + match self { + Self::WinAnsi | Self::Simple(_) => helvetica_width(s, size), Self::Unicode(_) => cjk_width(s, size), } } @@ -154,7 +167,7 @@ fn dr_font(doc: &Document, name: &str) -> Option<(Object, FontText)> { if matches!(fd.name(b"BaseFont"), Some(b"ZapfDingbats" | b"Symbol")) { return None; } - Some((entry, FontText::WinAnsi)) + Some((entry, FontText::Simple(Box::new(Metrics::from_dict(doc, fd))))) } fn helvetica() -> Object { @@ -281,7 +294,22 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri let place = placement(mk_rotation(doc, &wd), page_w, page_h); let (width, height) = (place.layout_w, place.layout_h); let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da)); - let (font_name, font_obj, enc) = match dr_font(doc, &da.font) { + let text: String = if f.kind == FieldKind::Combo { + values.iter().map(|v| f.options.iter().find(|(e, _)| e == v).map_or(v.clone(), |(_, d)| d.clone())).collect::>().join(", ") + } else { + values.first().cloned().unwrap_or_default() + }; + let text = if f.has(flags::PASSWORD) { "*".repeat(text.chars().count()) } else { text }; + let can_show = |enc: &FontText| { + if f.kind == FieldKind::List { + f.options.iter().all(|(_, display)| enc.can_encode(display)) + } else if f.kind == FieldKind::Text && f.has(flags::MULTILINE) { + text.split(['\n', '\r']).all(|line| enc.can_encode(line)) + } else { + enc.can_encode(&text) + } + }; + let (font_name, font_obj, enc) = match dr_font(doc, &da.font).filter(|(_, enc)| can_show(enc)) { Some((o, enc)) => (da.font.clone(), o, enc), None => ("Helv".to_string(), helvetica(), FontText::WinAnsi), }; @@ -326,12 +354,6 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri } } _ => { - let text: String = if f.kind == FieldKind::Combo { - values.iter().map(|v| f.options.iter().find(|(e, _)| e == v).map_or(v.clone(), |(_, d)| d.clone())).collect::>().join(", ") - } else { - values.first().cloned().unwrap_or_default() - }; - let text = if f.has(flags::PASSWORD) { "*".repeat(text.chars().count()) } else { text }; let comb = f.has(flags::COMB) && !f.has(flags::MULTILINE) && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0); if f.kind == FieldKind::Text && f.has(flags::MULTILINE) { if size == 0.0 { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 8a40c366..354bbf5b 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -73,6 +73,24 @@ fn ap(doc: &Document, w: &Widget) -> String { String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() } +/// Read the drawn strings together with the appearance's actual font after saving and reopening. +fn appearance_text(doc: &Document, w: &Widget, font_name: &[u8]) -> (Object, Vec>) { + let widget = doc.get(w.obj); + let normal = widget.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap(); + let appearance = doc.get(normal); + let Object::Stream(stream) = appearance.as_ref() else { panic!("appearance stream") }; + let resources = doc.resolve(stream.dict.get(b"Resources").unwrap()); + let fonts = doc.resolve(resources.as_dict().unwrap().get(b"Font").unwrap()); + let font = doc.resolve(fonts.as_dict().unwrap().get(font_name).unwrap()); + let shown = pdfcraft_content::parse(&stream.decoded().unwrap()) + .ops + .into_iter() + .filter(|op| op.is("Tj")) + .map(|op| op.operands[0].as_string().unwrap().bytes.clone()) + .collect(); + (font.as_ref().clone(), shown) +} + #[test] fn the_field_tree_is_read_with_inheritance() { let doc = fixture(); @@ -227,6 +245,93 @@ fn text_fields_get_new_appearances() { assert!(raw.windows(8).any(|x| x == b"(Z\xfcrich)")); } +#[test] +fn existing_field_font_encoding_keeps_italian_punctuation_visible() { + // ReportLab-style Helvetica: an indirect encoding dictionary with PDFDoc-like Differences. + // WinAnsi 0x92/0x97 would show trademark/Scaron in this font, not quoteright/emdash. + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding 7 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (localita) /Rect [50 700 450 730] /P 3 0 R >>".into(), + "<< /Type /Encoding /Differences [39 /quotesingle 96 /grave 132 /emdash 144 /quoteright 146 /trademark 151 /Scaron 224 /agrave] >>".into(), + ]; + let mut doc = document(&objs); + let original_font = doc.get(ObjRef::new(5, 0)).as_ref().clone(); + let value = "Città Sant’Angelo — qualità"; + set_value(&mut doc, "localita", &FieldValue::Text(value.into())).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let f = field(&all, "localita"); + assert_eq!(f.value, [value], "the stored value and the drawn text must agree"); + let (font, lines) = appearance_text(&doc, &f.widgets[0], b"Helv"); + assert_eq!(font, original_font, "keep the existing field's font and encoding"); + let metrics = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, font.as_dict().unwrap()); + let shown = lines.concat(); + assert_eq!(metrics.decode(&shown), value, "decode the appearance with its actual font"); + assert_eq!(shown, b"Citt\xe0 Sant\x90Angelo \x84 qualit\xe0"); +} + +#[test] +fn unavailable_glyph_uses_an_appearance_font_without_changing_the_source_font() { + // A subset font with only A cannot draw the newly entered accented text. Replacing its + // encoding would corrupt every existing use of that font, so only the appearance falls back. + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Original 12 Tf 0 g) /DR << /Font << /Original 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Custom /FirstChar 65 /LastChar 65 /Widths [600] /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (localita) /Rect [50 700 450 730] /P 3 0 R >>".into(), + ]; + let mut doc = document(&objs); + let original_font = doc.get(ObjRef::new(5, 0)).as_ref().clone(); + let value = "Città Sant’Angelo — qualità"; + set_value(&mut doc, "localita", &FieldValue::Text(value.into())).unwrap(); + let doc = reopen(&doc); + assert_eq!(doc.get(ObjRef::new(5, 0)).as_ref(), &original_font); + let all = fields(&doc); + let f = field(&all, "localita"); + assert_eq!(f.value, [value]); + let (font, lines) = appearance_text(&doc, &f.widgets[0], b"Helv"); + let font = font.as_dict().unwrap(); + assert_eq!(font.name(b"BaseFont"), Some(&b"Helvetica"[..])); + assert_eq!(font.name(b"Encoding"), Some(&b"WinAnsiEncoding"[..])); + let metrics = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, font); + let shown = lines.concat(); + assert_eq!(metrics.decode(&shown), value); + assert_eq!(shown, b"Citt\xe0 Sant\x92Angelo \x97 qualit\xe0"); +} + +#[test] +fn multiline_wrapping_keeps_encodable_text_without_requiring_ascii_space() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding 7 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (localita) /Ff 4096 /Rect [50 500 130 550] /P 3 0 R >>".into(), + "<< /Type /Encoding /Differences [32 /.notdef 144 /quoteright 160 /nbspace 224 /agrave] >>".into(), + ]; + let mut doc = document(&objs); + let original_font = doc.get(ObjRef::new(5, 0)).as_ref().clone(); + // NBSP remains part of the word; wrapping splits it by character without adding spaces. + let value = "Città\u{a0}Sant’Angelo\nqualità"; + set_value(&mut doc, "localita", &FieldValue::Text(value.into())).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let f = field(&all, "localita"); + assert_eq!(f.value, [value]); + let (font, lines) = appearance_text(&doc, &f.widgets[0], b"Helv"); + assert_eq!(font, original_font); + assert!(lines.len() >= 3 && lines.iter().all(|line| !line.is_empty())); + let metrics = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, font.as_dict().unwrap()); + assert_eq!(metrics.decode(&lines.concat()), value.replace('\n', "")); +} + #[test] fn japanese_choices_use_the_unicode_cid_font() { // A choice field whose /DA names a non-embedded CID font with a predefined Unicode CMap diff --git a/docs/roadmap.md b/docs/roadmap.md index d7b4322b..3785300d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M6: respect existing form-font encodings; retain source fonts on appearance fallback; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Forward-looking: the milestones, the current focus and what comes next. The parity numbers behind it are in [target-app-parity.md](target-app-parity.md), the ranked work list in [gaps.md](gaps.md), @@ -72,6 +72,8 @@ total matches the parity estimate. | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; Create PDF from multiple files (PDFs, images and text, edited as pages in the grid). Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, web printing | @@ -121,6 +123,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | M6: respect existing form-font encodings; retain source fonts on appearance fallback. Estimates unchanged. | | 2026-10-10 | minor | M14: localized shortcut key names (#496), including runtime switching and native layout checks. Estimates unchanged. | | 2026-10-10 | minor | Re-judged convert and share with the cross-app gate rule (only a workflow that can't be completed at all blocks): partial, not blocking → stage alpha | | 2026-10-10 | minor | Added the alpha gate (six core workflows; convert and share fails → pre-alpha, 40–70 h to alpha) and rank 0 in the upcoming milestones | From ee1ee704639738edaade7fdb71609b77f87ecaac Mon Sep 17 00:00:00 2001 From: piero-83 Date: Sun, 11 Oct 2026 18:10:23 +0200 Subject: [PATCH 11/91] Forms: keep the accepted value when Tab wraps to the same field (#1045) * fix(forms): refresh the editor after accepted Tab edits * test(forms): keep values through same-field Tab and Save * docs: record accepted-value Tab and Save behavior * docs: log verified Tab save regression --------- Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/ui-egui/src/editing.rs | 13 ++++++ crates/ui-egui/tests/forms.rs | 82 +++++++++++++++++++++++++++++++++-- docs/roadmap.md | 3 ++ 4 files changed, 96 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 393a69d9..0c074c78 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -142,6 +142,8 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-11 (M6):** Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. Estimates unchanged. +- **2026-10-11 (M6):** Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Estimates unchanged. + - **2026-10-10 (M14, interface size):** Preferences ▸ Interface size scales the whole interface (50–250 %, setting `ui_scale`, control option `ui-scale`); 50 % brings an interface the system draws at 200 % back to 100 % (#456). Auto, the default, follows the desktop's text scaling: on Linux and the BSDs the desktop app reads GNOME's `text-scaling-factor` once at launch, so with Large Text (1.25) on a 1× display the interface no longer draws 13 px labels that show their pixels next to the system's text. The size is egui's zoom factor, so `pixels_per_point` grows with it and page rasters stay one texel per screen pixel; egui's own ⌘+/⌘− interface zoom is turned off so those keys stay page zoom. Still missing: KDE's font DPI and Windows' "Make text bigger" aren't read (both scale only text there), and egui's text is still greyscale-antialiased without the system's gamma, so small text looks rougher than GTK's at 100 %. - **2026-10-10 (M14, #496):** Shortcut displays share a UI formatter across toolbar search, registry menus, command palette, Help and tooltips. Modifier and key names use a dedicated catalog context in every bundled translation; missing entries keep the English key name rather than a command translation. German shows Strg/Umschalt/Entf, macOS retains its symbols, and runtime language changes refresh the display. Regression coverage checks localized layout widths, English/German switching, platform conventions and unchanged command execution. Milestone and effort estimates unchanged. diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 2cf6041b..dedb5584 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -238,6 +238,19 @@ impl PdfCraftApp { _ => false, }; if self.apply_edit(edit) { + // Tab can wrap to the same field (including another widget of that field). + // Its new editor was opened before this edit ran, so it still holds the old + // value. Refresh from the accepted form before Save can commit that stale draft. + if typed + && let Some(view) = self.views.get_mut(i) + && let Some(focus) = view.forms.focus.as_mut() + && focus.request_focus + && committed.as_ref().is_some_and(|draft| draft.name == focus.name) + && let Some(field) = self.session.get(view.id).and_then(|doc| doc.form.iter().find(|field| field.name == focus.name)) + { + focus.text = field.value.first().cloned().unwrap_or_default(); + focus.picked = field.value.clone(); + } if let Some(page) = signature_page && let Some(view) = self.views.get_mut(i) { diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 57a80dff..0c7fb32a 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -181,6 +181,68 @@ fn saving_while_still_typing_in_a_field_saves_the_text() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn tabbing_back_to_the_same_field_then_saving_keeps_the_committed_value() { + // A form with one text field wraps Tab back to that field. Multiple widgets of one + // field share its value too: opening the next widget must see the accepted edit. + for (case, shared_widgets, backwards, same_frame) in [ + ("single-forward", false, false, false), + ("single-backward", false, true, false), + ("shared-forward", true, false, false), + ("shared-backward", true, true, false), + ("same-frame", false, false, true), + ] { + let dir = scratch(case); + let out = dir.join("saved.pdf"); + let bytes = if shared_widgets { + pdf_from_objects(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>", + "<< /FT /Tx /T (qty) /V (4) /Kids [6 0 R 7 0 R] >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /Annot /Subtype /Widget /Parent 4 0 R /Rect [20 350 280 372] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /Parent 4 0 R /Rect [20 250 280 272] /P 3 0 R /F 4 >>", + ]) + } else { + validated_form() + }; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("tab.pdf", None, bytes).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + h.run_steps(6); + click_field(&mut h, "qty", 0); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("42".into())); + h.run_steps(2); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + let modifiers = if backwards { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; + if same_frame { + one_frame(&mut h, vec![key(egui::Key::Tab, modifiers), key(egui::Key::S, egui::Modifiers::COMMAND)]); + } else { + h.key_press_modifiers(modifiers, egui::Key::Tab); + h.run_steps(3); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S); + } + h.run_steps(4); + assert_eq!(saved_value(&out, "qty"), ["42"], "{case}: saving cannot restore the old value"); + assert_eq!(value(&h, "qty"), ["42"], "{case}"); + // One typing action is one history entry, even after saving. Escape closes the + // untouched editor before undoing the document rather than the TextEdit buffer. + h.key_press(egui::Key::Escape); + h.state_mut().undo(); + assert_eq!(value(&h, "qty"), ["4"], "{case}: a single undo restores the original"); + h.state_mut().redo(); + assert_eq!(value(&h, "qty"), ["42"], "{case}: redo restores the committed value"); + let _ = std::fs::remove_dir_all(&dir); + } +} + #[test] fn closing_while_still_typing_in_a_field_asks_to_save() { let mut h = harness(); @@ -284,13 +346,16 @@ fn exporting_form_data_while_typing_includes_the_text() { /// One text field, `qty`, whose validation script refuses values over 100. fn validated_form() -> Vec { - let objs = [ + pdf_from_objects(&[ "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", "<< /Type /Annot /Subtype /Widget /FT /Tx /T (qty) /V (4) /Rect [20 350 280 372] /P 3 0 R /F 4 /AA << /V << /S /JavaScript /JS (if (event.value > 100) { app.alert('At most 100'); event.rc = false; }) >> >> >>", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", - ]; + ]) +} + +fn pdf_from_objects(objs: &[&str]) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { @@ -355,7 +420,16 @@ fn a_save_shortcut_pressed_while_typing_saves_only_the_document_it_was_pressed_i #[test] fn a_value_refused_on_enter_keeps_save_in_the_same_frame_from_running() { - let dir = scratch("enter-refused"); + refused_value_stops_save(egui::Key::Enter, "enter-refused"); +} + +#[test] +fn a_value_refused_on_tab_keeps_save_in_the_same_frame_from_running() { + refused_value_stops_save(egui::Key::Tab, "tab-refused"); +} + +fn refused_value_stops_save(commit_key: egui::Key, case: &str) { + let dir = scratch(case); let out = dir.join("saved.pdf"); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); @@ -370,7 +444,7 @@ fn a_value_refused_on_enter_keeps_save_in_the_same_frame_from_running() { h.event(egui::Event::Text("500".into())); h.run_steps(2); h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); - one_frame(&mut h, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::S, egui::Modifiers::COMMAND)]); + one_frame(&mut h, vec![key(commit_key, egui::Modifiers::NONE), key(egui::Key::S, egui::Modifiers::COMMAND)]); h.run_steps(3); assert_eq!(value(&h, "qty"), ["4"]); assert!(!out.exists(), "nothing is saved right after the field refused its value"); diff --git a/docs/roadmap.md b/docs/roadmap.md index 3785300d..c5086b64 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,7 @@ # PdfCraft roadmap: milestones and what's next > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M6: respect existing form-font encodings; retain source fonts on appearance fallback; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M6: keep accepted form values when Tab reopens the same field before Save; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Forward-looking: the milestones, the current focus and what comes next. The parity numbers behind it are in [target-app-parity.md](target-app-parity.md), the ranked work list in [gaps.md](gaps.md), @@ -73,6 +74,7 @@ total matches the parity estimate. | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | @@ -124,6 +126,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| | 2026-10-11 | minor | M6: respect existing form-font encodings; retain source fonts on appearance fallback. Estimates unchanged. | +| 2026-10-11 | minor | M6: keep accepted form values when Tab reopens the same field before Save. Estimates unchanged. | | 2026-10-10 | minor | M14: localized shortcut key names (#496), including runtime switching and native layout checks. Estimates unchanged. | | 2026-10-10 | minor | Re-judged convert and share with the cross-app gate rule (only a workflow that can't be completed at all blocks): partial, not blocking → stage alpha | | 2026-10-10 | minor | Added the alpha gate (six core workflows; convert and share fails → pre-alpha, 40–70 h to alpha) and rank 0 in the upcoming milestones | From afb586e2497e7754c8cefc031d524432d14124f5 Mon Sep 17 00:00:00 2001 From: piero-83 Date: Sun, 11 Oct 2026 18:12:23 +0200 Subject: [PATCH 12/91] CLI: reject malformed --root options before dispatch (#1048) * fix(cli): reject malformed root options before dispatch * test(cli): reject missing empty and repeated roots * docs: explain strict root arguments and validation * docs: log malformed root argument regression --------- Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + apps/pdfcraft-cli/src/main.rs | 18 +++- apps/pdfcraft-cli/tests/root_arguments.rs | 121 ++++++++++++++++++++++ docs/mcp.md | 4 + docs/roadmap.md | 4 +- 5 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 apps/pdfcraft-cli/tests/root_arguments.rs diff --git a/ROADMAP.md b/ROADMAP.md index 0c074c78..faa2d955 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -144,6 +144,8 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-11 (M6):** Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Estimates unchanged. +- **2026-10-11 (M13):** CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. Estimates unchanged. + - **2026-10-10 (M14, interface size):** Preferences ▸ Interface size scales the whole interface (50–250 %, setting `ui_scale`, control option `ui-scale`); 50 % brings an interface the system draws at 200 % back to 100 % (#456). Auto, the default, follows the desktop's text scaling: on Linux and the BSDs the desktop app reads GNOME's `text-scaling-factor` once at launch, so with Large Text (1.25) on a 1× display the interface no longer draws 13 px labels that show their pixels next to the system's text. The size is egui's zoom factor, so `pixels_per_point` grows with it and page rasters stay one texel per screen pixel; egui's own ⌘+/⌘− interface zoom is turned off so those keys stay page zoom. Still missing: KDE's font DPI and Windows' "Make text bigger" aren't read (both scale only text there), and egui's text is still greyscale-antialiased without the system's gamma, so small text looks rougher than GTK's at 100 %. - **2026-10-10 (M14, #496):** Shortcut displays share a UI formatter across toolbar search, registry menus, command palette, Help and tooltips. Modifier and key names use a dedicated catalog context in every bundled translation; missing entries keep the English key name rather than a command translation. German shows Strg/Umschalt/Entf, macOS retains its symbols, and runtime language changes refresh the display. Regression coverage checks localized layout widths, English/German switching, platform conventions and unchanged command execution. Milestone and effort estimates unchanged. diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index db61d46e..d8f0874b 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -156,15 +156,29 @@ struct OptionSpec { takes_value: bool, } -/// Refuse a long option `command` doesn't take (a misspelling) before any file is read. +/// Refuse unknown options and malformed confinement flags before any file is read. fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> { + if specs.iter().any(|spec| spec.name == "--root") { + // `flag` searches every token, including another option's value. Match that lookup so + // a malformed confinement flag cannot be hidden there and fall back to no root. + let mut root_seen = false; + for (i, _) in args.iter().enumerate().filter(|(_, arg)| arg.as_str() == "--root") { + if root_seen { + return Err(format!("{command}: --root may only be supplied once").into()); + } + root_seen = true; + if args.get(i + 1).is_none_or(|value| value.is_empty() || value.starts_with("--")) { + return Err(format!("{command}: --root needs a directory value").into()); + } + } + } let mut i = 0; while let Some(arg) = args.get(i) { if let Some(name) = arg.strip_prefix("--") { let Some(spec) = specs.iter().find(|spec| spec.name == arg) else { return Err(format!("{command}: unknown option --{name}").into()); }; - // A missing or malformed value is left to the command, which reports it in its own + // Other missing or malformed values are left to the command, which reports them in its own // terms (`text` and `extract` explain what a page value must be). i += if spec.takes_value { 2 } else { 1 }; } else { diff --git a/apps/pdfcraft-cli/tests/root_arguments.rs b/apps/pdfcraft-cli/tests/root_arguments.rs new file mode 100644 index 00000000..2cf08ce2 --- /dev/null +++ b/apps/pdfcraft-cli/tests/root_arguments.rs @@ -0,0 +1,121 @@ +//! A malformed `--root` must never fall back to an unconfined automation session. + +use std::path::PathBuf; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); +const CANARY: &[u8] = b"KEEP EXISTING DESTINATION\n"; + +struct TestDir(PathBuf); + +impl TestDir { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + for _ in 0..128 { + let path = std::env::temp_dir().join(format!("pdfcraft-root-arguments-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + match std::fs::create_dir(&path) { + Ok(()) => return Self(path), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => panic!("creating argument test directory: {error}"), + } + } + panic!("no unused argument test directory after 128 attempts"); + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn malformed_root_fails_before_a_script_can_overwrite_a_file() { + let dir = TestDir::new(); + let destination = dir.0.join("existing.pdf"); + let script = dir.0.join("steps.json"); + let steps = serde_json::json!([ + { "tool": "doc_create", "args": { "from": "blank", "width": 100, "height": 100 } }, + { "tool": "doc_save", "args": { "doc": 1, "path": destination } }, + ]); + std::fs::write(&script, steps.to_string()).unwrap(); + std::fs::write(&destination, CANARY).unwrap(); + + for (flags, diagnostic) in [ + (vec!["--root"], "--root needs a directory value"), + (vec!["--root", ""], "--root needs a directory value"), + (vec!["--root", "--out", "escaped.png"], "--root needs a directory value"), + (vec!["--out", "--root"], "--root needs a directory value"), + (vec!["--root", ".", "--root"], "--root may only be supplied once"), + (vec!["--root", ".", "--root", "."], "--root may only be supplied once"), + (vec!["--root", ".", "--out", "--root"], "--root may only be supplied once"), + ] { + // Both a valid script and a missing one: argument validation precedes script I/O. + for script_name in ["steps.json", "missing.json"] { + let out = Command::new(BIN).current_dir(&dir.0).args(["run", "--script", script_name]).args(&flags).output().unwrap(); + assert_eq!(out.status.code(), Some(1), "{script_name} {flags:?} unexpectedly succeeded"); + assert_eq!(String::from_utf8(out.stderr).unwrap().trim(), format!("pdfcraft-cli: run: {diagnostic}")); + assert!(out.stdout.is_empty(), "a refused command must not execute or report tool results"); + assert_eq!(std::fs::read(&destination).unwrap(), CANARY); + assert!(!dir.0.join("escaped.png").exists()); + } + } + assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 2); +} + +#[test] +fn malformed_root_fails_before_direct_tool_argument_validation() { + let out = Command::new(BIN).args(["run", "doc_open", "not-key-value", "--root"]).output().unwrap(); + assert_eq!(out.status.code(), Some(1)); + assert_eq!(String::from_utf8(out.stderr).unwrap().trim(), "pdfcraft-cli: run: --root needs a directory value"); + assert!(out.stdout.is_empty()); +} + +#[test] +fn a_directory_name_starting_with_a_single_hyphen_remains_valid() { + let dir = TestDir::new(); + std::fs::create_dir(dir.0.join("-documents")).unwrap(); + let out = Command::new(BIN) + .current_dir(&dir.0) + .args(["run", "doc_create", "from=blank", "width=100", "height=100", "--root", "-documents"]) + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!(serde_json::from_slice::(&out.stdout).is_ok()); +} + +#[cfg(feature = "mcp")] +#[test] +fn malformed_root_never_starts_or_initializes_an_mcp_server() { + use std::io::Write as _; + use std::process::Stdio; + + let dir = TestDir::new(); + // Even if a directory matches the next option's name, it is not a root operand. + std::fs::create_dir(dir.0.join("--compact")).unwrap(); + for (flags, diagnostic) in [ + (vec!["--root"], "--root needs a directory value"), + (vec!["--root", ""], "--root needs a directory value"), + (vec!["--root", "--compact"], "--root needs a directory value"), + (vec!["--compact", "--root"], "--root needs a directory value"), + (vec!["--root", ".", "--root"], "--root may only be supplied once"), + (vec!["--root", ".", "--root", "./--compact"], "--root may only be supplied once"), + ] { + let mut child = Command::new(BIN) + .current_dir(&dir.0) + .arg("mcp") + .args(&flags) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + // Early rejection may close stdin before this write; that is the intended behavior. + let _ = child.stdin.take().unwrap().write_all(b"{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"initialize\",\"params\":{}}\n"); + let out = child.wait_with_output().unwrap(); + assert_eq!(out.status.code(), Some(1), "{flags:?} unexpectedly started the server"); + assert_eq!(String::from_utf8(out.stderr).unwrap().trim(), format!("pdfcraft-cli: mcp: {diagnostic}")); + assert!(out.stdout.is_empty(), "a refused server must not answer initialize"); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 5465c5b9..830a6c31 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4,6 +4,10 @@ Start explicitly with `pdfcraft-cli mcp [--root DIR] [--compact]`. The server us stdin/stdout, opens no port, and stops on EOF. `--root` confines all reads and writes, including commands invoked through the common tools. Logs stay on stderr. +Supply `--root` at most once, followed by a nonempty directory path. Missing operands, +repeated roots and another long option in place of the directory are refused before any +script is read or server is started. Prefix a directory name beginning with `--` with `./`. + | Tool | Arguments | |---|---| | `command_list` | `doc?`, `filter?`, `enabled_only?`; returns `{commands: [...]}` with mapped tool schemas | diff --git a/docs/roadmap.md b/docs/roadmap.md index c5086b64..27bc888d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,6 +2,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M6: respect existing form-font encodings; retain source fonts on appearance fallback; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M6: keep accepted form values when Tab reopens the same field before Save; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M13: reject malformed CLI --root options before dispatch; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Forward-looking: the milestones, the current focus and what comes next. The parity numbers behind it are in [target-app-parity.md](target-app-parity.md), the ranked work list in [gaps.md](gaps.md), @@ -81,7 +82,7 @@ total matches the parity estimate. | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; Create PDF from multiple files (PDFs, images and text, edited as pages in the grid). Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, web printing | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools).
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA | -| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | +| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | | | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) | @@ -127,6 +128,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st |---|---|---| | 2026-10-11 | minor | M6: respect existing form-font encodings; retain source fonts on appearance fallback. Estimates unchanged. | | 2026-10-11 | minor | M6: keep accepted form values when Tab reopens the same field before Save. Estimates unchanged. | +| 2026-10-11 | minor | M13: reject malformed CLI --root options before dispatch. Estimates unchanged. | | 2026-10-10 | minor | M14: localized shortcut key names (#496), including runtime switching and native layout checks. Estimates unchanged. | | 2026-10-10 | minor | Re-judged convert and share with the cross-app gate rule (only a workflow that can't be completed at all blocks): partial, not blocking → stage alpha | | 2026-10-10 | minor | Added the alpha gate (six core workflows; convert and share fails → pre-alpha, 40–70 h to alpha) and rank 0 in the upcoming milestones | From 1c18510b4a7f340f9388b1bb310cb318b4a27b4c Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:16:57 +0200 Subject: [PATCH 13/91] Edit images: moving or resizing a clipped image moves its own clip too (#1022) (#1049) --- crates/edit/src/images.rs | 63 +++++++++++++++++++++++++++++++++++++-- crates/edit/src/tests.rs | 48 ++++++++++++++++++++++++++++- docs/editing-images.md | 4 ++- 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index c6c0cb5d..134211bd 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -248,6 +248,34 @@ pub fn turn_about_centre(rect: [f64; 4], quarters: i32, flip_h: bool, flip_v: bo [a, b, c, d, cx - a * cx - c * cy, cy - b * cx - d * cy] } +/// For a `Do` at `op` that sits alone in `q re W n cm Do Q`: the index of the `re` and the `cm` matrix. +/// That clip belongs to this image only, so it may move with it. A `cm` this move put before the +/// `re` earlier (`q cm re W n cm Do Q`) keeps the pattern, so the image can be moved again. +fn exclusive_clip(ops: &[Op], op: usize) -> Option<(usize, Matrix)> { + let at = op.checked_sub(4)?; + let mut q = at.checked_sub(1)?; + while ops.get(q)?.is("cm") { + q = q.checked_sub(1)?; + } + let ok = ops.get(q)?.is("q") + && ops.get(at)?.is("re") + && (ops.get(at + 1)?.is("W") || ops.get(at + 1)?.is("W*")) + && ops.get(at + 2)?.is("n") + && ops.get(at + 3)?.is("cm") + && ops.get(op + 1)?.is("Q"); + if !ok { + return None; + } + Some((at, Matrix(ops.get(at + 3)?.nums::<6>()?))) +} + +/// The `cm` to put before the clip so that user-space transform `t` also moves the clip: +/// the clip is drawn in the CTM before the image's own `cm` (`cm` · that = `draw`). +fn clip_matrix(draw: Matrix, cm: Matrix, t: Matrix) -> Option { + let outer = cm.invert()?.then(&draw); + Some(outer.then(&t).then(&outer.invert()?)) +} + /// Change image `index` (from [`page_images`]) on `page`. pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &ImageChange) -> Result<(), EditError> { let images = page_images(doc, page)?; @@ -258,6 +286,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag let mut ops = parse(&data).ops; let original = ops.get(img.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; let mut resources = None; + let mut clip_move = None; let replacement: Vec = match change { ImageChange::Delete => Vec::new(), ImageChange::Transform(t) => { @@ -271,7 +300,17 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag if !m.0.iter().all(|v| v.is_finite()) { return Err(EditError::Invalid("the image transform is too large".into())); } - vec![Op::new("q", vec![]), Op::new("cm", m.0.iter().map(|v| pdfcraft_content::num(*v)).collect()), original, Op::new("Q", vec![])] + // `q re W n cm Do Q` clips only this image: move the clip with it instead of leaving it behind. + if let Some((at, cm)) = exclusive_clip(&ops, img.op) { + if let Some(c) = clip_matrix(Matrix(img.draw_matrix), cm, Matrix(*t)).filter(|c| c.0.iter().all(|v| v.is_finite())) { + clip_move = Some((at, Op::new("cm", c.0.iter().map(|v| pdfcraft_content::num(*v)).collect()))); + vec![original] + } else { + return Err(EditError::Invalid("the image transform is too large".into())); + } + } else { + vec![Op::new("q", vec![]), Op::new("cm", m.0.iter().map(|v| pdfcraft_content::num(*v)).collect()), original, Op::new("Q", vec![])] + } } ImageChange::Replace(r) => { if img.is_form { @@ -289,6 +328,9 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag } }; ops.splice(img.op..=img.op, replacement); + if let Some((at, cm)) = clip_move { + ops.insert(at, cm); + } let mut dict = match &*doc.resolve(&obj) { Object::Stream(s) => s.dict.clone(), _ => Dict::new(), @@ -339,7 +381,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize if !movement.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { return Err(EditError::Invalid("the image move is too large".into())); } - plans.insert((image.stream, image.op), movement); + plans.insert((image.stream, image.op), (movement, placement, offset)); } let mut data = Vec::with_capacity(all.len()); let mut inside_text = false; @@ -355,8 +397,23 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into())); } } + // Images alone in their own `q re W n cm Do Q` take the clip with them. + let mut clips = std::collections::BTreeMap::new(); + for index in 0..ops.len() { + let Some(&(_, placement, offset)) = plans.get(&(stream, index)) else { continue }; + let Some((at, cm)) = exclusive_clip(&ops, index) else { continue }; + let translation = Matrix([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]]); + let Some(c) = clip_matrix(placement, cm, translation).filter(|c| c.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) else { + return Err(EditError::Invalid("the image move is too large".into())); + }; + plans.remove(&(stream, index)); + clips.insert((stream, at), c); + } let changed = crate::text::splice(&pieces, &ops, |index| { - let Some(matrix) = plans.remove(&(stream, index)) else { return (Vec::new(), true) }; + if let Some(c) = clips.remove(&(stream, index)) { + return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], true); + } + let Some((matrix, _, _)) = plans.remove(&(stream, index)) else { return (Vec::new(), true) }; let original = ops.get(index).cloned().into_iter(); // Local translations can be tiny under a large CTM. Preserve their precision // rather than rounding them to zero or amplifying an error on the page. diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f6b5ab7c..616929e7 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1023,7 +1023,10 @@ fn page_content_bytes(doc: &Document, page: usize) -> Vec { /// A page drawing image XObject /Im0 at 100,100 size 200 × 100 (pixels 4 × 2). fn image_page() -> Document { - let content = "q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET"; + image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET") +} + +fn image_page_with(content: &str) -> Document { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), @@ -1047,6 +1050,49 @@ fn image_page() -> Document { Document::open(Arc::new(out)).unwrap() } +/// The box of the first `re` on the page in user space, after the `cm`s before it. +fn first_rect_in_user_space(doc: &Document) -> [f64; 4] { + let mut ctm = pdfcraft_content::Matrix::IDENTITY; + for op in pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops { + if op.is("cm") { + ctm = pdfcraft_content::Matrix(op.nums::<6>().unwrap()).then(&ctm); + } else if op.is("re") { + let [x, y, w, h] = op.nums::<4>().unwrap(); + return ctm.bbox([x, y, x + w, y + h]); + } + } + panic!("no clip rectangle"); +} + +#[test] +fn moving_an_image_moves_its_own_clip_too() { + let content = "q 100 100 200 100 re W* n 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET"; + let target = [50.0, 400.0, 350.0, 600.0]; + let mut doc = image_page_with(content); + let before = images::page_images(&doc, 0).unwrap()[0].rect; + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(before, target))).unwrap(); + let mut doc = reopen(&doc); + assert!(close(images::page_images(&doc, 0).unwrap()[0].rect, target)); + assert!(close(first_rect_in_user_space(&doc), target), "{:?}", first_rect_in_user_space(&doc)); + // A second edit still finds the clip, now behind the `cm` the first one put before it. + let moved = [80.0, 420.0, 380.0, 620.0]; + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(target, moved))).unwrap(); + assert!(close(first_rect_in_user_space(&doc), moved)); + images::translate_images(&mut doc, 0, &[0], [10.0, 0.0]).unwrap(); + assert!(close(images::page_images(&doc, 0).unwrap()[0].rect, [90.0, 420.0, 390.0, 620.0])); + assert!(close(first_rect_in_user_space(&doc), [90.0, 420.0, 390.0, 620.0])); + // Moving it with the group move helper takes the clip along as well. + let mut doc = image_page_with(content); + images::translate_images(&mut doc, 0, &[0], [30.0, -20.0]).unwrap(); + assert!(close(images::page_images(&doc, 0).unwrap()[0].rect, [130.0, 80.0, 330.0, 180.0])); + assert!(close(first_rect_in_user_space(&doc), [130.0, 80.0, 330.0, 180.0])); + // A clip shared with other drawing is left where it is. + let shared = "q 100 100 200 100 re W n q 200 0 0 100 100 100 cm /Im0 Do Q 0 0 10 10 re f Q"; + let mut doc = image_page_with(shared); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(before, target))).unwrap(); + assert!(close(first_rect_in_user_space(&doc), [100.0, 100.0, 300.0, 200.0])); +} + fn close(a: [f64; 4], b: [f64; 4]) -> bool { a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) } diff --git a/docs/editing-images.md b/docs/editing-images.md index 86a01c72..fde18518 100644 --- a/docs/editing-images.md +++ b/docs/editing-images.md @@ -3,7 +3,9 @@ In **Edit a PDF → Edit text and images**, click a figure to select it. Drag the selected figure to move it, drag a corner to resize it proportionally, or press Delete to remove that placement. The context menu also rotates or flips it. Changes support undo and -survive saving/reopening. +survive saving/reopening. An image painted inside its own rectangular clip +(`q … re W n … cm Do Q`, as PowerPoint exports it) moves and resizes together with that +clip; a clip shared with other content stays where it is. PDF figures are not always bitmap images. Imported diagrams often use a **Form XObject**, which packages text, vector paths, and nested images into one object. PdfCraft selects From dd046a25b25180fdbd10722492ebb7a6ed44f809 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:17:09 +0900 Subject: [PATCH 14/91] M2: stream page content past the 256 MiB cap (item 1 of storytold/pdfcraft#623) (#797) * M2: stream page content past the 256 MiB cap (item 1 of storytold/pdfcraft#623) Page content was decoded whole, and the decode stopped at MAX_DECODED_STREAM (256 MiB), so the last ~10 MB of the #623 sample was dropped without a warning. Top-level page content is now read a window at a time, so the decoded content is never all in memory: - the content tokenizer reads windows and holds back an instruction that runs past one; Reader records reads that touch the end of the data, including reads made through clones, so an instruction is only decided once it has been seen whole; - the Flate decoder is resumable and keeps its own 32 KiB history, so it pauses at any output size and back-references do not depend on what the caller has kept; - page content may decode to max(256 MiB, 64 x its compressed size), counting each distinct stream once; content that reaches that budget is cut and the page reports ContentTruncated, as it did not before; - forms, tiling patterns, Type 3 glyph procedures and generic streams keep their existing budgets and caps. Co-Authored-By: Claude Haiku 5.5 * M2: Avoid duplicate page content raw reads * M2: Document page content byte reuse --------- Co-authored-by: Claude Haiku 5.5 --- crates/render/src/raster.rs | 146 +++++++ vendor/README.md | 2 +- vendor/hayro-interpret/src/interpret/mod.rs | 95 +++- vendor/hayro-syntax/src/byte_reader.rs | 55 ++- vendor/hayro-syntax/src/content/mod.rs | 297 ++++++++++++- vendor/hayro-syntax/src/filter/lzw_flate.rs | 307 ++++++++++--- vendor/hayro-syntax/src/filter/mod.rs | 3 + vendor/hayro-syntax/src/object/mod.rs | 8 +- vendor/hayro-syntax/src/object/stream.rs | 46 +- vendor/hayro-syntax/src/page.rs | 456 +++++++++++++++++++- 10 files changed, 1313 insertions(+), 102 deletions(-) diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index e6b635ef..4ee9cce9 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -3842,6 +3842,152 @@ trailer << /Root 1 0 R >> } } + /// Item 1 of storytold/pdfcraft#623: a page's content used to be cut at `MAX_DECODED_STREAM`, + /// so everything after the cut was lost (the text and the title block of the A1 sample). Content + /// that expands by a few times over is read to its end, however large it is. + #[test] + fn page_content_past_the_old_cap_renders() { + let limit = hayro::hayro_syntax::MAX_DECODED_STREAM; + // Blocks of a comment and a run of blanks. Each compresses about thirty times over, well + // within the budget that `ContentReader` allows. + let mut content = Vec::with_capacity(limit + (8 << 20)); + let mut state = 0x2545_f491_4f6c_dd1d_u64; + while content.len() < limit + (8 << 20) { + content.push(b'%'); + for _ in 0..3 { + state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + content.push(b'a' + ((state >> 33) % 26) as u8); + } + content.push(b'\n'); + content.resize(content.len() + 128, b' '); + } + content.extend_from_slice(b"1 0 0 rg 0 0 4 4 re f"); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut z, &content).unwrap(); + let stream = z.finish().unwrap(); + assert!(content.len() < 64 * stream.len(), "the content expands by less than the budget allows"); + + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Filter /FlateDecode /Length {} >> stream\n", stream.len()).as_bytes()); + pdf.extend_from_slice(&stream); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })); + }); + let page = rx.recv_timeout(std::time::Duration::from_secs(120)).expect("a large page must render"); + assert!(page.error.is_none(), "{:?}", page.error); + assert!(!page.warnings.contains(&RenderWarning::ContentTruncated), "nothing was cut"); + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the square after the old cap draws"); + } + + /// The content stream is interpreted a window at a time. An inline image with more data than + /// a window is read whole, and what follows it is still drawn. + #[test] + fn inline_images_longer_than_a_window_are_read_whole() { + let mut content = b"q 300 0 0 300 0 0 cm BI /W 300 /H 300 /BPC 8 /CS /G ID ".to_vec(); + content.extend(std::iter::repeat_n(0x80_u8, 300 * 300)); + content.extend_from_slice(b" EI Q 1 0 0 rg 0 0 4 4 re f"); + let pdf = one_page_pdf(&content, ""); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })); + }); + let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("an inline image must not stall the renderer"); + assert!(page.error.is_none(), "{:?}", page.error); + let pixel = |x: u32, y: u32| { + let offset = ((y * page.width + x) * 4) as usize; + [page.rgba[offset], page.rgba[offset + 1], page.rgba[offset + 2], page.rgba[offset + 3]] + }; + assert_eq!(pixel(1, 38), [255, 0, 0, 255], "the square after the image draws"); + let grey = pixel(30, 5); + assert!(grey[0] == grey[1] && grey[1] == grey[2] && grey[0] < 255, "the image covers the page: {grey:?}"); + } + + /// A one-page PDF whose content is the stream `stream`, with the entries `dict` in its + /// dictionary. + fn one_page_pdf(stream: &[u8], dict: &str) -> Vec { + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << {dict} /Length {} >> stream\n", stream.len()).as_bytes()); + pdf.extend_from_slice(stream); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + pdf + } + + /// A page whose content expands far beyond its compressed stream stops at the expansion budget, + /// which is 64 times the compressed size and at least `MAX_DECODED_STREAM`, and the page says so. + #[test] + fn content_bombs_stop_at_the_budget_and_say_so() { + let bomb = zlib_zeros(hayro::hayro_syntax::MAX_DECODED_STREAM + (32 << 20)); + let pdf = one_page_pdf(&bomb, "/Filter /FlateDecode"); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })); + }); + let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a content bomb must not stall the renderer"); + assert!(page.error.is_none(), "{:?}", page.error); + assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "the cut content is observable"); + } + + /// A stream read in pieces of any size gives the same bytes: stored and dynamic blocks, and in + /// a `/Contents` array a stream that is not Flate-coded, between two that are. + #[test] + fn content_reader_returns_the_same_bytes_in_any_pieces() { + use hayro::hayro_syntax::Pdf; + use std::io::Write; + let mut plain = Vec::new(); + for i in 0..40_000_u32 { + plain.extend_from_slice(format!("{} {} l\n", i % 977, i % 613).as_bytes()); + } + let zlib = |data: &[u8], level| { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), level); + z.write_all(data).unwrap(); + z.finish().unwrap() + }; + let read = |pdf: Vec, piece: usize| { + let doc = Pdf::new(pdf).expect("parses"); + let page = doc.pages().first().expect("one page"); + let mut content = page.content_reader(); + let mut out = Vec::new(); + while content.read_into(&mut out, piece) > 0 {} + out + }; + let pieces = [1, 3, 64, 4096, 65_536, 1_000_000]; + + for level in [flate2::Compression::none(), flate2::Compression::best()] { + let pdf = one_page_pdf(&zlib(&plain, level), "/Filter /FlateDecode"); + for piece in pieces { + assert!(read(pdf.clone(), piece) == plain, "piece {piece}"); + } + } + + // A `/Contents` array: Flate, then ASCIIHex (decoded whole), then Flate. As before, each + // stream is followed by a space. + let hex: String = plain[..100].iter().map(|b| format!("{b:02x}")).collect::() + ">"; + let first = zlib(&plain, flate2::Compression::default()); + let last = zlib(&plain[..50], flate2::Compression::default()); + let mut expected = plain.clone(); + expected.push(b' '); + expected.extend_from_slice(&plain[..100]); + expected.push(b' '); + expected.extend_from_slice(&plain[..50]); + expected.push(b' '); + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents [4 0 R 5 0 R 6 0 R] >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Filter /FlateDecode /Length {} >> stream\n", first.len()).as_bytes()); + pdf.extend_from_slice(&first); + pdf.extend_from_slice(format!("\nendstream endobj\n5 0 obj << /Filter /ASCIIHexDecode /Length {} >> stream\n", hex.len()).as_bytes()); + pdf.extend_from_slice(hex.as_bytes()); + pdf.extend_from_slice(format!("\nendstream endobj\n6 0 obj << /Filter /FlateDecode /Length {} >> stream\n", last.len()).as_bytes()); + pdf.extend_from_slice(&last); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + for piece in pieces { + assert!(read(pdf.clone(), piece) == expected, "the array: piece {piece}"); + } + } + /// Zlib data that inflates to `len` zero bytes. fn zlib_zeros(len: usize) -> Vec { use std::io::Write; diff --git a/vendor/README.md b/vendor/README.md index bba6aeb7..e3bbc23e 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -12,7 +12,7 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | -| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open` | +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice. | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams` | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 8f5e7ce7..5ede4d44 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -21,7 +21,8 @@ use hayro_syntax::object::dict::keys::{ ANNOTS, AP, AS, C, CA, F, MCID, N, OC, QUADPOINTS, RECT, SUBTYPE, }; use hayro_syntax::object::{Array, Dict, Object, Rect, Stream, dict_or_stream}; -use hayro_syntax::page::{Page, Resources}; +use hayro_syntax::page::{ContentReader, Page, Resources}; +use hayro_syntax::MAX_DECODED_STREAM; use kurbo::{Affine, BezPath, Point, Shape}; use smallvec::smallvec; use std::sync::Arc; @@ -159,9 +160,11 @@ pub fn interpret_page<'a>( device: &mut impl Device<'a>, ) { let resources = page.resources(); - // PdfCraft patch: the page's own contents count against its content budget too. - crate::context::charge_content(page.page_stream().map_or(0, <[u8]>::len)); - interpret(page.typed_operations(), resources, context, device); + let mut content = page.content_reader(); + // PdfCraft patch: the page's own contents count against its content budget too, where they + // are decoded whole. Streamed content is only ever held a window at a time. + crate::context::charge_content(content.materialized_len()); + interpret_content(&mut content, resources, context, device); if context.settings.render_annotations && let Some(annot_arr) = page.raw().get::>(ANNOTS) @@ -323,17 +326,15 @@ fn draw_highlight_without_appearance<'a>( context.restore_state(device); } -/// Interpret the instructions from `ops` and render them into the device. -pub fn interpret<'a>( - mut ops: TypedIter<'_>, +/// Interprets the instructions that `ops` yields, until they run out or the caller cancels. It runs +/// once per window of a content stream, between one `save_state` and its restore (see +/// `interpret_content`). +fn interpret_window<'a>( + ops: &mut TypedIter<'_>, resources: &Resources<'a>, context: &mut Context<'a>, device: &mut impl Device<'a>, ) { - let num_states = context.num_states(); - - context.save_state(); - while let Some(op) = ops.next() { // PdfCraft patch: stop when the caller cancelled (see `InterpreterSettings::cancelled`). if context.settings.cancelled.as_ref().is_some_and(|c| c.load(Ordering::Relaxed)) { @@ -842,6 +843,20 @@ pub fn interpret<'a>( } } } +} + +/// Interpret the instructions from `ops` and render them into the device. +pub fn interpret<'a>( + mut ops: TypedIter<'_>, + resources: &Resources<'a>, + context: &mut Context<'a>, + device: &mut impl Device<'a>, +) { + let num_states = context.num_states(); + + context.save_state(); + + interpret_window(&mut ops, resources, context, device); while context.num_states() > num_states { context.restore_state(device); @@ -851,3 +866,61 @@ pub fn interpret<'a>( (context.settings.warning_sink)(InterpreterWarning::ContentTruncated); } } + +/// PdfCraft patch: the bytes of a window of page content. A window grows only while an instruction +/// at its end is incomplete. +const CONTENT_WINDOW: usize = 64 << 10; + +/// PdfCraft patch: interprets the content of a page a window at a time, so the decoded content is +/// never all in memory (see `ContentReader`). A window ends at an instruction that is not complete +/// yet: its bytes are kept, and the next window starts with them. +fn interpret_content<'a>( + content: &mut ContentReader<'_>, + resources: &Resources<'a>, + context: &mut Context<'a>, + device: &mut impl Device<'a>, +) { + let num_states = context.num_states(); + + context.save_state(); + + let mut window = Vec::with_capacity(CONTENT_WINDOW); + let mut exhausted = false; + let mut cut = false; + loop { + let (committed, needs_more) = { + let mut ops = TypedIter::new_window(&window, exhausted); + interpret_window(&mut ops, resources, context, device); + (ops.committed(), ops.needs_more()) + }; + window.drain(..committed); + + let cancelled = context + .settings + .cancelled + .as_ref() + .is_some_and(|c| c.load(Ordering::Relaxed)); + if !needs_more || cancelled || exhausted { + break; + } + // A window that is still incomplete at its end waits for more of the stream. It doubles + // while it waits, so one long instruction costs time in proportion to its length, and it + // stops growing at `MAX_DECODED_STREAM`, the most any one stream may decode to. + if window.len() >= MAX_DECODED_STREAM { + cut = true; + break; + } + let want = window.len().max(CONTENT_WINDOW); + if content.read_into(&mut window, want) == 0 { + exhausted = true; + } + } + + while context.num_states() > num_states { + context.restore_state(device); + } + // PdfCraft patch (18): say that content was skipped, so a truncated page isn't a clean render. + if cut || content.truncated() || crate::context::content_was_truncated() { + (context.settings.warning_sink)(InterpreterWarning::ContentTruncated); + } +} diff --git a/vendor/hayro-syntax/src/byte_reader.rs b/vendor/hayro-syntax/src/byte_reader.rs index f3c7a20e..78b282c8 100644 --- a/vendor/hayro-syntax/src/byte_reader.rs +++ b/vendor/hayro-syntax/src/byte_reader.rs @@ -1,5 +1,6 @@ //! A byte reader. +use core::cell::Cell; use core::ops::Range; /// PdfCraft patch: how deeply direct arrays and dictionaries may nest (the same cap as `pdfcraft-cos`; a @@ -13,6 +14,9 @@ pub struct Reader<'a> { pub data: &'a [u8], /// The current byte-offset. pub offset: usize, + /// PdfCraft patch: set when a read needed a byte past the end of `data`. A decision made from + /// such a read could change if more data followed (see `content::UntypedIter`). + past_end: Cell, // PdfCraft patch: bound direct-object nesting while skipping arrays and dictionaries // and while parsing dictionaries (`Dict::skip`, `Array::skip`, `parse_dict_with`). skip_depth: usize, @@ -22,13 +26,26 @@ impl<'a> Reader<'a> { /// Create a new reader. #[inline] pub fn new(data: &'a [u8]) -> Self { - Self { data, offset: 0, skip_depth: 0 } + Self::new_with(data, 0) } /// Create a new reader at the given offset. #[inline] pub fn new_with(data: &'a [u8], offset: usize) -> Self { - Self { data, offset, skip_depth: 0 } + Self { data, offset, past_end: Cell::new(false), skip_depth: 0 } + } + + /// PdfCraft patch: whether a read needed bytes past the end of the data since the flag was + /// last cleared. + #[inline] + pub(crate) fn looked_past_end(&self) -> bool { + self.past_end.get() + } + + /// PdfCraft patch: clears the flag set by [`Reader::looked_past_end`]. + #[inline] + pub(crate) fn clear_looked_past_end(&self) { + self.past_end.set(false); } pub(crate) fn enter_container(&mut self) -> Option<()> { @@ -46,7 +63,21 @@ impl<'a> Reader<'a> { /// Returns `true` if the reader has reached the end of the data. #[inline] pub fn at_end(&self) -> bool { - self.offset >= self.data.len() + let at_end = self.offset >= self.data.len(); + if at_end { + // PdfCraft patch: a parser that stops at the end decides from it (see `past_end`). + self.past_end.set(true); + } + + at_end + } + + /// PdfCraft patch: carries over whether `other`, a clone of this reader, looked past the end. + #[inline] + pub(crate) fn absorb_past_end(&self, other: &Reader<'_>) { + if other.past_end.get() { + self.past_end.set(true); + } } /// Moves the reader offset to the end of the data. @@ -118,15 +149,26 @@ impl<'a> Reader<'a> { /// Peeks the specified number of bytes. #[inline] pub fn peek_bytes(&self, len: usize) -> Option<&'a [u8]> { - self.offset + let bytes = self + .offset .checked_add(len) - .and_then(|end| self.data.get(self.offset..end)) + .and_then(|end| self.data.get(self.offset..end)); + if bytes.is_none() { + self.past_end.set(true); + } + + bytes } /// Peeks a single byte. #[inline] pub fn peek_byte(&self) -> Option { - self.data.get(self.offset).copied() + let byte = self.data.get(self.offset).copied(); + if byte.is_none() { + self.past_end.set(true); + } + + byte } /// Eat the next byte if it satisfies the condition. @@ -197,6 +239,7 @@ impl<'a> Reader<'a> { if cloned.peek_byte() == Some(b) { cloned.forward(); } else { + self.past_end.set(cloned.past_end.get()); return None; } } diff --git a/vendor/hayro-syntax/src/content/mod.rs b/vendor/hayro-syntax/src/content/mod.rs index ce0a4b56..8e47324d 100644 --- a/vendor/hayro-syntax/src/content/mod.rs +++ b/vendor/hayro-syntax/src/content/mod.rs @@ -40,7 +40,7 @@ use crate::object::name::{Name, skip_name_like}; use crate::object::{Array, Null, Number, Object, Stream}; use crate::reader::Reader; use crate::reader::{Readable, ReaderContext, ReaderExt, Skippable}; -use crate::trivia::is_white_space_character; +use crate::trivia::{Comment, is_white_space_character}; use crate::util::find_needle; use core::array; use core::fmt::{Debug, Formatter}; @@ -120,27 +120,40 @@ pub struct UntypedIter<'a> { /// PdfCraft patch: this iterator only checks whether data reads as content (the /// end-of-data heuristic below); inline images it meets end at their first `EI`. lookahead: bool, + /// PdfCraft patch: whether `reader` holds the whole content stream. The end of a window of a + /// longer stream can fall inside an instruction, so nothing that reads up to it is decided + /// (see `needs_more`). + complete: bool, + /// PdfCraft patch: set when `next` stopped because the window ran out before the next + /// instruction was decided. The caller reads more data and resumes at `committed`. + needs_more: bool, + /// PdfCraft patch: the offset just after the instruction `next` returned last. + committed: usize, } impl<'a> UntypedIter<'a> { /// Create a new untyped iterator. pub fn new(data: &'a [u8]) -> Self { + Self::new_window(data, true) + } + + /// PdfCraft patch: an iterator over `data`, which is the whole stream when `complete`, and + /// otherwise the start of a longer one. + pub(crate) fn new_window(data: &'a [u8], complete: bool) -> Self { Self { reader: Reader::new(data), stack: Stack::new(), operator: None, lookahead: false, + complete, + needs_more: false, + committed: 0, } } /// Create a new empty untyped iterator. pub fn empty() -> Self { - Self { - reader: Reader::new(&[]), - stack: Stack::new(), - operator: None, - lookahead: false, - } + Self::new_window(&[], true) } /// Return the next instruction. @@ -148,8 +161,16 @@ impl<'a> UntypedIter<'a> { pub fn next(&mut self) -> Option> { self.stack.clear(); self.operator = None; + self.needs_more = false; + self.reader.clear_looked_past_end(); - self.reader.skip_white_spaces_and_comments(); + if self.complete { + self.reader.skip_white_spaces_and_comments(); + } else { + // A window may drop what comes before the next instruction, blanks included, but not a + // comment that the window ends in: that comment may go on in the next window. + self.committed = self.skip_blanks_and_comments(); + } while !self.reader.at_end() { // I believe booleans/null never appear as an operator? @@ -165,35 +186,61 @@ impl<'a> UntypedIter<'a> { // similar behavior to Acrobat and Chromium, we try to consume // such an operator and then simply skip it. if let Some(object) = self.reader.read_without_context::>() { + if self.undecided() { + return None; + } self.stack.push(object)?; } else if self.reader.read_without_context::>().is_some() { + if self.undecided() { + return None; + } self.stack.clear(); } else { + self.undecided(); return None; } } else { let operator = match self.reader.read_without_context::>() { Some(o) => o, None => { + if self.undecided() { + return None; + } + warn!("failed to read operator in content stream"); self.reader.jump_to_end(); return None; } }; + if self.undecided() { + return None; + } // Inline images need special casing... if operator.as_ref() == b"BI" { // The ID operator will already be consumed by this. - let inline_dict = self.reader.read_without_context::>()?; + let Some(inline_dict) = self + .reader + .read_without_context::>() + else { + self.undecided(); + return None; + }; let dict = inline_dict.get_dict().clone(); // One whitespace after "ID". - self.reader.read_white_space()?; + if self.reader.read_white_space().is_none() { + self.undecided(); + return None; + } let stream_data = self.reader.tail()?; let start_offset = self.reader.offset(); let mut candidates = 0; + // PdfCraft patch: set when a look-ahead below ran into the end of the window. + let mut lookahead_touched = false; + let mut accepted = false; 'outer: while let Some(pos) = find_needle(self.reader.tail()?, b"EI") { self.reader.read_bytes(pos)?; @@ -228,7 +275,11 @@ impl<'a> UntypedIter<'a> { let mut steps = 0; let heuristic = !self.lookahead && candidates <= MAX_EI_CANDIDATES; - while heuristic && !find_reader.at_end() && steps < MAX_EI_LOOKAHEAD_STEPS { + while heuristic && steps < MAX_EI_LOOKAHEAD_STEPS { + if find_reader.at_end() { + lookahead_touched = true; + break; + } steps += 1; let remaining = find_reader.tail()?; let next_ei = find_needle(remaining, b"EI"); @@ -239,7 +290,10 @@ impl<'a> UntypedIter<'a> { (Some(_), Some(bi)) => (bi, false), (Some(ei), None) => (ei, true), (None, Some(bi)) => (bi, false), - (None, None) => break, + (None, None) => { + lookahead_touched = true; + break; + } }; find_reader.read_bytes(next_pos)?; @@ -262,7 +316,10 @@ impl<'a> UntypedIter<'a> { // stream and there should be at least one text-related // operator that can be parsed correctly. - let mut iter = TypedIter::from_untyped(UntypedIter { lookahead: true, ..UntypedIter::new(tail) }); + let mut iter = TypedIter::from_untyped(UntypedIter { + lookahead: true, + ..UntypedIter::new_window(tail, self.complete) + }); let mut found = false; let mut counter = 0; @@ -292,6 +349,8 @@ impl<'a> UntypedIter<'a> { counter += 1; } + lookahead_touched |= + iter.needs_more() || iter.untyped.reader.looked_past_end(); if !found { // Seems like the data in-between is not a valid content @@ -304,10 +363,11 @@ impl<'a> UntypedIter<'a> { // is indeed the end of data. let mut cloned = find_reader.clone(); cloned.read_bytes(2)?; - if cloned + let is_inline_image_dict = cloned .read_without_context::>() - .is_some() - { + .is_some(); + lookahead_touched |= cloned.looked_past_end(); + if is_inline_image_dict { break; } } @@ -315,17 +375,35 @@ impl<'a> UntypedIter<'a> { find_reader.read_byte()?; } + // PdfCraft patch: the end is only certain if no look-ahead needed bytes + // past the window. Otherwise read more and decide again. + if !self.complete + && (lookahead_touched || self.reader.looked_past_end()) + { + self.needs_more = true; + return None; + } + self.stack.push(Object::Stream(stream))?; self.reader.read_bytes(2)?; self.reader.skip_white_spaces(); + accepted = true; + break; } } + + // PdfCraft patch: without an accepted "EI" the image may still end in later data. + if !accepted && !self.complete { + self.needs_more = true; + return None; + } } self.operator = Some(operator); + self.committed = self.reader.offset(); return Some(Instruction { operands: &self.stack, operator: self.operator.as_ref().unwrap(), @@ -335,8 +413,43 @@ impl<'a> UntypedIter<'a> { self.reader.skip_white_spaces_and_comments(); } + // PdfCraft patch: the window ended between instructions, and another may follow. + if !self.complete { + self.needs_more = true; + } + None } + + /// PdfCraft patch: skips the blanks and comments before an instruction, as + /// `skip_white_spaces_and_comments` does, and returns where a window may be cut: the start of a + /// comment that runs into the end of the window, or else where the skipping stopped. + fn skip_blanks_and_comments(&mut self) -> usize { + let mut open_comment = None; + while let Some(b) = self.reader.peek_byte() { + if is_white_space_character(b) { + self.reader.skip_white_spaces(); + } else if b == b'%' { + let start = self.reader.offset(); + Comment::skip(&mut self.reader, true); + open_comment = self.reader.at_end().then_some(start); + } else { + break; + } + } + + open_comment.unwrap_or(self.reader.offset()) + } + + /// PdfCraft patch: whether the instruction being read ran into the end of a window, so it + /// cannot be decided yet. Records that more data is needed. + fn undecided(&mut self) -> bool { + if !self.complete && self.reader.looked_past_end() { + self.needs_more = true; + } + + self.needs_more + } } impl<'a> ContentTokenizer<'a> for UntypedIter<'a> { @@ -345,6 +458,7 @@ impl<'a> ContentTokenizer<'a> for UntypedIter<'a> { } } + /// An iterator over PDF content streams that provide access to the instructions /// in a typed fashion. #[derive(Clone)] @@ -362,6 +476,27 @@ impl<'a> TypedIter<'a, UntypedIter<'a>> { } } + /// PdfCraft patch: an iterator over a window of a content stream: the whole stream when + /// `complete`, and otherwise the start of a longer one (see [`TypedIter::needs_more`]). + pub fn new_window(data: &'a [u8], complete: bool) -> Self { + Self { + untyped: UntypedIter::new_window(data, complete), + marker: PhantomData, + } + } + + /// PdfCraft patch: whether the last `next` returned `None` only because the window ran out + /// before the next instruction was decided. Read more of the stream, then resume at + /// [`TypedIter::committed`] in a window that starts there. + pub fn needs_more(&self) -> bool { + self.untyped.needs_more + } + + /// PdfCraft patch: the offset in the window just after the last instruction `next` returned. + pub fn committed(&self) -> usize { + self.untyped.committed + } + pub(crate) fn from_untyped(untyped: UntypedIter<'a>) -> Self { Self { untyped, marker: PhantomData } } @@ -749,4 +884,134 @@ mod tests { assert_eq!(operators(&joined), expected, "split at byte {split}"); } } + + /// The instructions of `data`, read from the whole of it. + fn slice_instructions(data: &[u8]) -> Vec { + let mut tokenizer = UntypedIter::new(data); + let mut result = Vec::new(); + while let Some(instruction) = tokenizer.next_instruction() { + result.push(format!("{:?} {:?}", instruction.operator, instruction.operands)); + } + result + } + + /// The instructions of `data` as a window driver reads them: the stream arrives in pieces of + /// the lengths `next_piece` gives. Each window holds the bytes from the first instruction + /// that is not yet decided, and another piece is appended when a window runs out. + fn windowed_instructions(data: &[u8], mut next_piece: impl FnMut() -> usize) -> Vec { + let mut result = Vec::new(); + let mut window: Vec = Vec::new(); + let mut fed = 0; + let mut exhausted = false; + loop { + let (committed, needs_more) = { + let mut tokenizer = UntypedIter::new_window(&window, exhausted); + while let Some(instruction) = tokenizer.next_instruction() { + result.push(format!("{:?} {:?}", instruction.operator, instruction.operands)); + } + (tokenizer.committed, tokenizer.needs_more) + }; + window.drain(..committed); + if !needs_more || exhausted { + break; + } + if fed == data.len() { + exhausted = true; + continue; + } + let end = fed.saturating_add(next_piece().max(1)).min(data.len()); + window.extend_from_slice(&data[fed..end]); + fed = end; + } + + result + } + + /// Content whose instructions are hard to read in pieces: inline images with `EI` in their + /// data or right after it, unterminated tokens, and operands that are too many or invalid. + const WINDOWED_CORPUS: [&[u8]; 14] = [ + b"% comment\n1 -2.5 /Name /A#20B (a\\(b\\) \\101) <4869> [1 2] << /Key (value) >> [(a) 1] TJ BI /W 1 /H 1 /BPC 8 /CS /DeviceGray ID \x00 EI q Q", + b"BT /F1 12 Tf 10 20 Td [(Hel) -250 (lo)] TJ (x) Tj ET", + b"q 1 0 0 1 0 0 cm BI /W 2 /H 2 /BPC 8 /CS /G ID \x00\x01EI\x00 EI Q 0 0 m 1 1 l S", + b"BI /W 1 /H 1 /BPC 8 /CS /G ID EI x EI EI BT (a) Tj ET", + b"0.5 .5 -.5 +5 12.3.4 f", + b"/A B C /D 1 2 3 4 5 6 7 8 9 10 11 12 re", + b"BI /W 1 /H 1 /BPC 8 /CS /G ID\n\x00\nEI BI /W 1 /H 1 /BPC 8 /CS /G ID \x01 EI Q", + b"(unterminated string 1 0 0 1 0 0 cm", + b"1 0 0 1 0 0 cm\n%comment at the end", + b"BI /W 1 /H 1 /BPC 8 /CS /G ID \x00 EIxy q", + b"BI /W 2 /H 1 /BPC 8 /CS /G ID \x00\x01 EI \x00 EI \x01 q", + b"[1 [2 [3]]] 0 d q Q", + b"/Lbl BDC EMC 1 w", + b"<> BDC BI /W 1 /H 1 /BPC 8 /CS /G ID \x07 EI (end) Tj", + ]; + + #[test] + fn windows_keep_every_instruction_at_every_split() { + for data in WINDOWED_CORPUS { + let expected = slice_instructions(data); + for split in 1..=data.len() { + let mut lengths = [split, data.len()].into_iter(); + let windowed = windowed_instructions(data, || lengths.next().unwrap_or(data.len())); + assert_eq!(windowed, expected, "{:?} split at byte {split}", String::from_utf8_lossy(data)); + } + } + } + + #[test] + fn windows_keep_every_instruction_in_pieces_of_any_size() { + for data in WINDOWED_CORPUS { + let expected = slice_instructions(data); + for piece in 1..=data.len() { + let windowed = windowed_instructions(data, || piece); + assert_eq!(windowed, expected, "{:?} in pieces of {piece}", String::from_utf8_lossy(data)); + } + } + } + + #[test] + fn windows_drop_blanks_and_keep_a_comment_that_may_go_on() { + // Blanks alone decide nothing, so a window of them can be dropped whole. + let blanks = vec![b' '; 4096]; + let mut tokenizer = UntypedIter::new_window(&blanks, false); + assert!(tokenizer.next().is_none()); + assert!(tokenizer.needs_more); + assert_eq!(tokenizer.committed, blanks.len()); + + // The window ends in a comment: it is kept, from its `%`, for the next window. + let data = b"1 0 0 1 0 0 cm % a comment that goes on"; + let mut tokenizer = UntypedIter::new_window(data, false); + assert!(tokenizer.next().is_some()); + assert!(tokenizer.next().is_none()); + assert!(tokenizer.needs_more); + assert_eq!(tokenizer.committed, data.iter().position(|&b| b == b'%').expect("a comment")); + } + + #[test] + fn windows_match_the_slice_on_random_content() { + const PARTS: [&[u8]; 24] = [ + b"BI", b" ID ", b"EI", b" EI ", b"/W", b" 1 ", b"-2.5", b".5", b"(", b")", b"\\", b"<", + b">", b"[", b"]", b"TJ", b"Tj", b"q", b"Q", b"re", b"f", b"% c\n", b"\n", b"\x00\xff", + ]; + // A fixed xorshift sequence, so that a failure can be reproduced. + let mut state = 0x9e37_79b9_7f4a_7c15_u64; + let mut next = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + + for _ in 0..2000 { + let mut data = Vec::new(); + for _ in 0..next() % 40 { + let part = PARTS[(next() % PARTS.len() as u64) as usize]; + data.extend_from_slice(part); + } + + let expected = slice_instructions(&data); + let windowed = windowed_instructions(&data, || 1 + (next() % 12) as usize); + assert_eq!(windowed, expected, "{:?}", String::from_utf8_lossy(&data)); + } + } } diff --git a/vendor/hayro-syntax/src/filter/lzw_flate.rs b/vendor/hayro-syntax/src/filter/lzw_flate.rs index c82bc033..ac1c19d6 100644 --- a/vendor/hayro-syntax/src/filter/lzw_flate.rs +++ b/vendor/hayro-syntax/src/filter/lzw_flate.rs @@ -7,6 +7,14 @@ use alloc::vec::Vec; pub(crate) mod flate { use super::*; use crate::filter::lzw_flate::{PredictorParams, apply_predictor}; + + pub(crate) use fallback::FlateState; + + /// PdfCraft patch: whether a Flate stream with `params` has no predictor, so that + /// [`FlateState`] can produce it a piece at a time. + pub(crate) fn is_plain(params: &Dict<'_>) -> bool { + PredictorParams::from_params(params).predictor == 1 + } use crate::object::Dict; /// PdfCraft patch: `limit` is the most bytes inflated (see `filter::decode_limit`). @@ -69,53 +77,148 @@ pub(crate) mod flate { } fn flate_decode(data: &[u8], limit: usize) -> Option> { - if data.len() >= 2 { - let cmf = data[0]; - let flg = data[1]; + let mut state = FlateState::new(data); + let mut output = Vec::new(); + state.decode_into(data, &mut output, limit); - if (cmf & 0x0f) == 0x08 - && ((cmf as u16) << 8 | flg as u16).is_multiple_of(31) - && (flg & 0x20) == 0 - { - let mut stream = FlateStream::new(&data[2..], limit); - return stream.decode(); + Some(output) + } + + /// PdfCraft patch: where a Flate stream is between two calls of [`FlateState::decode_into`]. + /// Decoding pauses at any output size and resumes where it stopped, so a stream can be + /// produced a piece at a time without holding all of it. + #[derive(Default)] + pub(crate) struct FlateState { + pos: usize, + code_buf: u32, + code_size: u8, + eof: bool, + block: Block, + /// The last `WINDOW` bytes decoded: back-references copy from them, whatever the caller + /// has done with the bytes it was given. + history: Vec, + /// How many bytes have been decoded in all. + total: usize, + } + + /// The furthest a Flate back-reference reaches: the largest distance in `DIST_DECODE`. + const WINDOW: usize = 32 << 10; + + impl FlateState { + /// The state at the start of `data`, past a zlib header if it has one. + pub(crate) fn new(data: &[u8]) -> Self { + let pos = match data { + [cmf, flg, ..] + if (cmf & 0x0f) == 0x08 + && (u16::from(*cmf) << 8 | u16::from(*flg)).is_multiple_of(31) + && (flg & 0x20) == 0 => + { + 2 + } + _ => 0, + }; + + Self { + pos, + ..Self::default() } } - let mut stream = FlateStream::new(data, limit); - stream.decode() + /// Whether nothing more can be decoded from `data`. + pub(crate) fn finished(&self, data: &[u8]) -> bool { + self.eof || (matches!(self.block, Block::Header) && self.pos >= data.len()) + } + + /// Appends decoded bytes to `out` until it holds `until` bytes or the stream ends. + /// `data` is the whole encoded stream, the same on every call. + pub(crate) fn decode_into(&mut self, data: &[u8], out: &mut Vec, until: usize) { + // The working buffer is the history followed by the bytes decoded now. Only the + // latter go to `out`, which the caller may have drained since. + let base = self.history.len(); + let mut stream = FlateStream { + data, + pos: self.pos, + code_buf: self.code_buf, + code_size: self.code_size, + output: core::mem::take(&mut self.history), + base, + total: self.total, + eof: self.eof, + limit: base.saturating_add(until.saturating_sub(out.len())), + block: core::mem::take(&mut self.block), + }; + stream.run(); + + out.extend_from_slice(stream.output.get(base..).unwrap_or(&[])); + self.total = self.total.saturating_add(stream.output.len() - base); + let keep = stream.output.len().min(WINDOW); + stream.output.drain(..stream.output.len() - keep); + self.history = stream.output; + + self.pos = stream.pos; + self.code_buf = stream.code_buf; + self.code_size = stream.code_size; + self.eof = stream.eof; + self.block = stream.block; + } } + /// PdfCraft patch: the working state of one [`FlateState::decode_into`] call. struct FlateStream<'a> { data: &'a [u8], pos: usize, code_buf: u32, code_size: u8, + /// The history, then the bytes decoded in this call. output: Vec, + /// How many bytes of `output` are history. + base: usize, + /// How many bytes were decoded before this call. + total: usize, eof: bool, - /// PdfCraft patch: the most bytes inflated (see `filter::decode_limit`). + /// PdfCraft patch: the most bytes in `output` (see `filter::decode_limit`). limit: usize, + block: Block, } - impl<'a> FlateStream<'a> { - fn new(data: &'a [u8], limit: usize) -> Self { - FlateStream { - data, - pos: 0, - code_buf: 0, - code_size: 0, - output: Vec::new(), - eof: false, - limit, - } - } + /// PdfCraft patch: the block a Flate stream is in. + #[derive(Default)] + enum Block { + /// Between blocks, at a block header (or at the end of the data). + #[default] + Header, + /// Inside a stored block, with `left` bytes still to copy. + Stored { left: usize, last: bool }, + /// Inside a block coded with Huffman tables. `copy` is a back-reference that the limit + /// cut off, as (whether its source exists, distance, bytes left to copy). + Coded { + lit: HuffmanTable, + dist: HuffmanTable, + last: bool, + copy: Option<(bool, usize, usize)>, + }, + } - fn decode(&mut self) -> Option> { - while !self.eof && self.pos < self.data.len() && self.output.len() < self.limit { - self.read_block(); - } + impl FlateStream<'_> { + /// Decodes until `output` holds `limit` bytes or the stream ends. + fn run(&mut self) { + loop { + if self.eof || self.output.len() >= self.limit { + return; + } - Some(core::mem::take(&mut self.output)) + match self.block { + Block::Header => { + if self.pos >= self.data.len() { + return; + } + + self.read_block(); + } + Block::Stored { .. } => self.copy_stored(), + Block::Coded { .. } => self.read_codes(), + } + } } fn get_byte(&mut self) -> Option { @@ -136,13 +239,6 @@ pub(crate) mod flate { } } - fn get_bytes(&mut self, n: usize) -> Vec { - let end = (self.pos + n).min(self.data.len()); - let bytes = self.data[self.pos..end].to_vec(); - self.pos = end; - bytes - } - fn get_bits(&mut self, bits: u8) -> Option { while self.code_size < bits { let b = self.get_byte()?; @@ -196,16 +292,16 @@ pub(crate) mod flate { } }; - if (hdr & 1) != 0 { - self.eof = true; - } + // PdfCraft patch: the final block ends the stream once it is decoded, not when its + // header is read: a stored block still has its bytes to copy. + let last = (hdr & 1) != 0; let hdr = hdr >> 1; match hdr { - 0 => self.read_uncompressed_block(), - 1 => self.read_compressed_block(true), - 2 => self.read_compressed_block(false), + 0 => self.start_uncompressed_block(last), + 1 => self.start_compressed_block(true, last), + 2 => self.start_compressed_block(false, last), _ => { warn!("unknown block type in flate stream"); self.eof = true; @@ -213,7 +309,7 @@ pub(crate) mod flate { } } - fn read_uncompressed_block(&mut self) { + fn start_uncompressed_block(&mut self, last: bool) { // Skip any remaining bits in current byte self.code_buf = 0; self.code_size = 0; @@ -264,22 +360,49 @@ pub(crate) mod flate { } if block_len == 0 { - if self.peek_byte().is_none() { + if last || self.peek_byte().is_none() { self.eof = true; } } else { - let block = self.get_bytes(block_len as usize); - // PdfCraft patch: never past `limit`. - let left = self.limit.saturating_sub(self.output.len()); + self.block = Block::Stored { + left: usize::from(block_len), + last, + }; + } + } + + /// Copies the stored block's bytes, up to the limit or the end of the data. + fn copy_stored(&mut self) { + let Block::Stored { mut left, last } = self.block else { + return; + }; + + while left > 0 && self.output.len() < self.limit { + let rest = self.data.get(self.pos..).unwrap_or(&[]); + if rest.is_empty() { + // Premature end of stream: the rest of the block is missing. + self.eof = true; + return; + } + + let take = left.min(rest.len()).min(self.limit - self.output.len()); self.output - .extend_from_slice(block.get(..block.len().min(left)).unwrap_or(&[])); - if block.len() < block_len as usize { + .extend_from_slice(rest.get(..take).unwrap_or(&[])); + self.pos += take; + left -= take; + } + + if left > 0 { + self.block = Block::Stored { left, last }; + } else { + self.block = Block::Header; + if last { self.eof = true; } } } - fn read_compressed_block(&mut self, fixed: bool) { + fn start_compressed_block(&mut self, fixed: bool, last: bool) { let (lit_code_table, dist_code_table) = if fixed { (get_fixed_lit_table(), get_fixed_dist_table()) } else { @@ -292,11 +415,54 @@ pub(crate) mod flate { } }; + self.block = Block::Coded { + lit: lit_code_table, + dist: dist_code_table, + last, + copy: None, + }; + } + + /// Decodes the codes of a Huffman-coded block until its end, the limit or the end of + /// the data. + fn read_codes(&mut self) { + let Block::Coded { + lit: lit_code_table, + dist: dist_code_table, + last, + mut copy, + } = core::mem::take(&mut self.block) + else { + return; + }; + loop { + if let Some((valid, distance, left)) = copy { + let left = self.copy_back(valid, distance, left); + if left > 0 { + // The limit cut the copy short: it resumes on the next call. + self.block = Block::Coded { + lit: lit_code_table, + dist: dist_code_table, + last, + copy: Some((valid, distance, left)), + }; + return; + } + copy = None; + } + // PdfCraft patch: one code adds at most 258 bytes; stop at the limit. if self.output.len() >= self.limit { + self.block = Block::Coded { + lit: lit_code_table, + dist: dist_code_table, + last, + copy, + }; return; } + let code1 = match self.get_code(&lit_code_table) { Some(c) => c, None => { @@ -308,6 +474,9 @@ pub(crate) mod flate { if code1 < 256 { self.output.push(code1 as u8); } else if code1 == 256 { + if last { + self.eof = true; + } return; } else { let code1 = code1 - 257; @@ -354,22 +523,36 @@ pub(crate) mod flate { } } - // Copy from previous output - let start = self.output.len().wrapping_sub(distance); - for _ in 0..length { - // PdfCraft patch: never past `limit`. - if self.output.len() >= self.limit { - return; - } - if start < self.output.len() { - let byte = self.output[self.output.len() - distance]; - self.output.push(byte); - } - } + // Copy from previous output. As before, a distance reaching before the first + // byte decoded copies nothing. + let decoded = self.total.saturating_add(self.output.len().saturating_sub(self.base)); + let valid = distance > 0 && distance <= decoded; + copy = Some((valid, distance, length)); } } } + /// Copies `left` bytes from `distance` back in the output, up to the limit. Returns how + /// many are still to copy. A copy whose source does not exist writes nothing. + fn copy_back(&mut self, valid: bool, distance: usize, mut left: usize) -> usize { + while left > 0 && self.output.len() < self.limit { + if valid { + let byte = self + .output + .len() + .checked_sub(distance) + .and_then(|index| self.output.get(index)) + .copied(); + if let Some(byte) = byte { + self.output.push(byte); + } + } + left -= 1; + } + + left + } + fn read_dynamic_tables(&mut self) -> Option<(HuffmanTable, HuffmanTable)> { let num_lit_codes = self.get_bits(5)? as usize + 257; let num_dist_codes = self.get_bits(5)? as usize + 1; diff --git a/vendor/hayro-syntax/src/filter/mod.rs b/vendor/hayro-syntax/src/filter/mod.rs index e5353f35..22bebf6d 100644 --- a/vendor/hayro-syntax/src/filter/mod.rs +++ b/vendor/hayro-syntax/src/filter/mod.rs @@ -13,6 +13,9 @@ mod jpx; mod lzw_flate; mod run_length; +// PdfCraft patch: the incremental Flate decoder that `page::ContentReader` drives. +pub(crate) use lzw_flate::flate::{FlateState, is_plain as flate_is_plain}; + use crate::object::Dict; use crate::object::Name; use crate::object::dict::keys::*; diff --git a/vendor/hayro-syntax/src/object/mod.rs b/vendor/hayro-syntax/src/object/mod.rs index d9b4816d..d692db8b 100644 --- a/vendor/hayro-syntax/src/object/mod.rs +++ b/vendor/hayro-syntax/src/object/mod.rs @@ -189,10 +189,14 @@ impl<'a> Readable<'a> for Object<'a> { b'<' => match r.peek_bytes(2)? { b"<<" => { let mut cloned = r.clone(); - let dict = Dict::read(&mut cloned, ctx)?; + let dict = Dict::read(&mut cloned, ctx); cloned.skip_white_spaces_and_comments(); + let is_stream = cloned.forward_tag(b"stream").is_some(); + // PdfCraft patch: what the clone looked at past the end counts for `r` too. + r.absorb_past_end(&cloned); + let dict = dict?; - if cloned.forward_tag(b"stream").is_some() { + if is_stream { Object::Stream(Stream::read(r, ctx)?) } else { r.jump(cloned.offset()); diff --git a/vendor/hayro-syntax/src/object/stream.rs b/vendor/hayro-syntax/src/object/stream.rs index f4856682..83361081 100644 --- a/vendor/hayro-syntax/src/object/stream.rs +++ b/vendor/hayro-syntax/src/object/stream.rs @@ -22,6 +22,16 @@ struct FiltersAndParams<'a> { params: SmallVec<[Dict<'a>; 2]>, } +/// PdfCraft patch: how the decoded data of a stream can be produced (see `Stream::incremental`). +pub(crate) enum Incremental { + /// The stream has no filter, so its data is its own bytes. + Plain, + /// The stream is Flate-compressed without a predictor, so it decodes a piece at a time. + Flate, + /// Anything else decodes whole. + Whole, +} + /// A stream of arbitrary data. #[derive(Clone)] pub struct Stream<'a> { @@ -152,6 +162,22 @@ impl<'a> Stream<'a> { self.filters_and_params().filters } + /// PdfCraft patch: how the decoded data of the stream can be produced a piece at a time. + pub(crate) fn incremental(&self) -> Incremental { + let filters_and_params = self.filters_and_params(); + + match ( + filters_and_params.filters.as_slice(), + filters_and_params.params.first(), + ) { + ([], _) => Incremental::Plain, + ([Filter::FlateDecode], Some(params)) if crate::filter::flate_is_plain(params) => { + Incremental::Flate + } + _ => Incremental::Whole, + } + } + /// Return the decoded data of the stream. /// /// Note that the result of this method will not be cached, so calling it multiple @@ -185,7 +211,25 @@ impl<'a> Stream<'a> { image_params: &ImageDecodeParams, max: usize, ) -> Result, DecodeFailure> { - let data = self.raw_data(); + self.decode_raw_within(self.raw_data(), image_params, max) + } + + /// PdfCraft patch: decode caller-supplied raw/decrypted bytes with the same filters and limits. + pub(crate) fn decoded_raw_within( + &self, + data: Cow<'a, [u8]>, + max: usize, + ) -> Result, DecodeFailure> { + self.decode_raw_within(data, &ImageDecodeParams::default(), max) + .map(|r| r.data) + } + + fn decode_raw_within( + &self, + data: Cow<'a, [u8]>, + image_params: &ImageDecodeParams, + max: usize, + ) -> Result, DecodeFailure> { let filters_and_params = self.filters_and_params(); let count = filters_and_params.filters.len(); diff --git a/vendor/hayro-syntax/src/page.rs b/vendor/hayro-syntax/src/page.rs index 2aed8963..7d63931a 100644 --- a/vendor/hayro-syntax/src/page.rs +++ b/vendor/hayro-syntax/src/page.rs @@ -13,8 +13,11 @@ use crate::sync::OnceLock; use crate::transform::Transform; use crate::util::FloatExt; use crate::xref::XRef; +use crate::filter::{FlateState, MAX_DECODED_STREAM}; +use crate::object::stream::Incremental; +use alloc::borrow::Cow; use alloc::boxed::Box; -use alloc::collections::BTreeSet; +use alloc::collections::{BTreeSet, VecDeque}; use crate::object::ObjectIdentifier; use alloc::vec; use alloc::vec::Vec; @@ -183,6 +186,198 @@ pub enum Rotation { FlippedHorizontal, } +/// PdfCraft patch: the cut-off for content that expands from its compressed streams. The decoded +/// content of a page may be `MAX_CONTENT_EXPANSION` times the size of those streams, and never less +/// than `MAX_DECODED_STREAM`. Real content expands a few times, and a stream that inflates a +/// thousandfold from a few kilobytes is a decompression bomb. +const MAX_CONTENT_EXPANSION: usize = 64; + +/// PdfCraft patch: the decoded content of a page, produced a piece at a time, so that the whole of +/// it need not be in memory at once (see `interpret_page`). Plain streams and Flate streams without +/// a predictor are decoded as they are read. Any other stream is decoded whole when the reader is +/// made, as `page_stream` does, and the decoded streams share one `MAX_DECODED_STREAM`. +/// +/// The decoded bytes of the page's streams are also cut off at a budget: `MAX_CONTENT_EXPANSION` +/// times their compressed size, counting each distinct stream once however many times `/Contents` +/// names it. Content that reaches the budget is cut short, and [`ContentReader::truncated`] says so. +pub struct ContentReader<'a> { + pieces: VecDeque>, + budget: usize, + produced: usize, + materialized: usize, + truncated: bool, +} + +/// One part of the content of a page, in order. +enum Piece<'a> { + /// A stream's own bytes, which are not decoded. + Plain { data: Cow<'a, [u8]>, pos: usize }, + /// A Flate stream, decoded as it is read. + Flate { data: Cow<'a, [u8]>, state: FlateState }, + /// A stream that was decoded whole. + Whole { data: Vec, pos: usize }, + /// The space that separates the streams of a `/Contents` array. + Separator { written: bool }, +} + +impl Piece<'_> { + fn exhausted(&self) -> bool { + match self { + Piece::Plain { data, pos } => *pos >= data.len(), + Piece::Flate { data, state } => state.finished(data), + Piece::Whole { data, pos } => *pos >= data.len(), + Piece::Separator { written } => *written, + } + } + + /// Appends the next bytes of the piece to `out`, which may end at `limit` bytes. + fn append_to(&mut self, out: &mut Vec, limit: usize) { + match self { + Piece::Plain { data, pos } => copy_from(data, pos, out, limit), + Piece::Flate { data, state } => state.decode_into(data, out, limit), + Piece::Whole { data, pos } => copy_from(data, pos, out, limit), + Piece::Separator { written } => { + out.push(b' '); + *written = true; + } + } + } +} + +/// Copies the bytes of `data` from `pos` to `out`, up to `limit` bytes in all. +fn copy_from(data: &[u8], pos: &mut usize, out: &mut Vec, limit: usize) { + let rest = data.get(*pos..).unwrap_or(&[]); + let take = rest.len().min(limit.saturating_sub(out.len())); + out.extend_from_slice(rest.get(..take).unwrap_or(&[])); + *pos += take; +} + +impl<'a> ContentReader<'a> { + /// A reader of no content. + fn empty() -> Self { + Self { + pieces: VecDeque::new(), + budget: 0, + produced: 0, + materialized: 0, + truncated: false, + } + } + + /// A reader of `streams`, the content of a page in order. `separated` says whether they come + /// from an array, whose streams are separated by spaces. `cap` is the most bytes that streams + /// decoded whole may make in all, and at least the budget's floor. + fn new(streams: Vec>, separated: bool, cap: usize) -> Self { + let mut counted = BTreeSet::new(); + let mut compressed = 0_usize; + let mut pieces = VecDeque::new(); + let mut materialized = 0_usize; + // The bytes that the streams decoded whole take up, separators included, as `page_stream` + // counted them against `MAX_DECODED_STREAM`. + let mut whole_used = 0_usize; + let mut accepting_pieces = true; + for stream in streams { + let newly_counted = counted.insert(stream.obj_id()); + if !accepting_pieces { + if newly_counted { + compressed = compressed.saturating_add(stream.raw_data().len()); + } + continue; + } + + let incremental = stream.incremental(); + let left = cap.saturating_sub(whole_used); + if matches!(&incremental, Incremental::Whole) && left == 0 { + accepting_pieces = false; + if newly_counted { + compressed = compressed.saturating_add(stream.raw_data().len()); + } + continue; + } + + let raw = stream.raw_data(); + if newly_counted { + compressed = compressed.saturating_add(raw.len()); + } + + match incremental { + Incremental::Plain => pieces.push_back(Piece::Plain { + data: raw, + pos: 0, + }), + Incremental::Flate => { + let state = FlateState::new(&raw); + pieces.push_back(Piece::Flate { data: raw, state }); + } + Incremental::Whole => { + let Ok(data) = stream.decoded_raw_within(raw, left) else { + continue; + }; + let data = data.into_owned(); + whole_used = whole_used.saturating_add(data.len()); + materialized = materialized.saturating_add(data.len()); + pieces.push_back(Piece::Whole { data, pos: 0 }); + } + } + + if separated { + whole_used = whole_used.saturating_add(1); + pieces.push_back(Piece::Separator { written: false }); + } + } + let budget = cap.max(MAX_CONTENT_EXPANSION.saturating_mul(compressed)); + + Self { + pieces, + budget, + produced: 0, + materialized, + truncated: false, + } + } + + /// Appends up to `max` bytes of the decoded content to `out`, and returns how many it appended. + /// That is fewer than `max` only at the end of the content, or where it is cut short. + pub fn read_into(&mut self, out: &mut Vec, max: usize) -> usize { + let start = out.len(); + let end = start.saturating_add(max); + + while out.len() < end { + let Some(piece) = self.pieces.front_mut() else { + break; + }; + if piece.exhausted() { + self.pieces.pop_front(); + continue; + } + + let allowed = self.budget.saturating_sub(self.produced); + if allowed == 0 { + self.truncated = true; + self.pieces.clear(); + break; + } + + let before = out.len(); + piece.append_to(out, end.min(before.saturating_add(allowed))); + self.produced += out.len() - before; + } + + out.len() - start + } + + /// The bytes of the content that were decoded whole when the reader was made. The caller + /// charges these against its own budget, as it did for the whole content. + pub fn materialized_len(&self) -> usize { + self.materialized + } + + /// Whether the content was cut short at its budget (see [`ContentReader`]). + pub fn truncated(&self) -> bool { + self.truncated + } +} + /// A PDF page. pub struct Page<'a> { inner: Dict<'a>, @@ -260,6 +455,25 @@ impl<'a> Page<'a> { Some(iter) } + /// PdfCraft patch: the content of the page, to be read a piece at a time (see + /// [`ContentReader`]). + pub fn content_reader(&self) -> ContentReader<'a> { + self.content_reader_within(MAX_DECODED_STREAM) + } + + /// PdfCraft patch: [`Page::content_reader`], with `cap` in place of `MAX_DECODED_STREAM`. + fn content_reader_within(&self, cap: usize) -> ContentReader<'a> { + if let Some(stream) = self.inner.get::>(CONTENTS) { + ContentReader::new(vec![stream], false, cap) + } else if let Some(array) = self.inner.get::>(CONTENTS) { + ContentReader::new(array.iter::>().collect(), true, cap) + } else { + warn!("contents entry of page was neither stream nor array of streams"); + + ContentReader::empty() + } + } + /// Return the decoded content stream of the page. pub fn page_stream(&self) -> Option<&[u8]> { let convert_single = |s: Stream<'_>| { @@ -279,8 +493,7 @@ impl<'a> Page<'a> { for stream in array.iter::>() { // PdfCraft patch: the streams share one `MAX_DECODED_STREAM` (an array // naming one inflating stream twenty times decoded 5 GB). - let left = - crate::filter::MAX_DECODED_STREAM.saturating_sub(collected.len()); + let left = MAX_DECODED_STREAM.saturating_sub(collected.len()); if left == 0 { break; } @@ -606,3 +819,240 @@ pub(crate) mod cached { } } } + +#[cfg(test)] +mod content_reader_tests { + use crate::Pdf; + + /// A zlib stream of `data` in stored blocks, which expands by nothing. + fn stored_zlib(data: &[u8]) -> Vec { + let mut out = vec![0x78, 0x01]; + let mut chunks = data.chunks(0xffff).peekable(); + if chunks.peek().is_none() { + out.extend_from_slice(&[1, 0, 0, 0xff, 0xff]); + } + while let Some(chunk) = chunks.next() { + out.push(u8::from(chunks.peek().is_none())); + let len = u16::try_from(chunk.len()).expect("a stored block holds at most 65535 bytes"); + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&(!len).to_le_bytes()); + out.extend_from_slice(chunk); + } + out.extend_from_slice(&[0; 4]); + out + } + + /// A zlib stream of one literal byte and then `count` copies of a 258-byte match from it, in + /// fixed Huffman codes: about 160 bytes of output for each byte of input. + fn fixed_run_bomb(count: usize) -> Vec { + struct Bits { + out: Vec, + buf: u8, + len: u32, + } + impl Bits { + /// Bits are sent least significant first. + fn put(&mut self, value: u32, bits: u32) { + for i in 0..bits { + self.buf |= (((value >> i) & 1) as u8) << self.len; + self.len += 1; + if self.len == 8 { + self.out.push(self.buf); + self.buf = 0; + self.len = 0; + } + } + } + + /// Huffman codes are sent most significant bit first. + fn code(&mut self, code: u32, bits: u32) { + for i in (0..bits).rev() { + self.put((code >> i) & 1, 1); + } + } + } + + let mut bits = Bits { + out: vec![0x78, 0x01], + buf: 0, + len: 0, + }; + bits.put(1, 1); // The final block. + bits.put(1, 2); // Fixed Huffman codes. + bits.code(0x30 + u32::from(b'a'), 8); // The literal 'a'. + for _ in 0..count { + bits.code(0xc5, 8); // Length 258 (code 285), no extra bits. + bits.code(0, 5); // Distance 1 (code 0), no extra bits. + } + bits.code(0, 7); // End of block (code 256). + if bits.len > 0 { + bits.out.push(bits.buf); + } + bits.out.extend_from_slice(&[0; 4]); + bits.out + } + + /// A one-page PDF whose content is `stream`, with the stream dictionary entries `dict`. + fn page_pdf(stream: &[u8], dict: &str) -> Vec { + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R >> endobj\n4 0 obj << {dict} /Length {} >> stream\n", + stream.len() + ) + .into_bytes(); + pdf.extend_from_slice(stream); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + pdf + } + + /// A one-page PDF with an array of content references to `streams`. + fn page_pdf_array(streams: &[(&[u8], &str)], refs: &[usize]) -> Vec { + let contents = refs + .iter() + .map(|index| format!("{} 0 R", 4 + index)) + .collect::>() + .join(" "); + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents [{contents}] >> endobj\n" + ) + .into_bytes(); + for (index, (stream, dict)) in streams.iter().enumerate() { + let object = 4 + index; + pdf.extend_from_slice( + format!("{object} 0 obj << {dict} /Length {} >> stream\n", stream.len()) + .as_bytes(), + ); + pdf.extend_from_slice(stream); + pdf.extend_from_slice(b"\nendstream endobj\n"); + } + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n"); + pdf + } + + /// The content of the first page, read `piece` bytes at a time, with a budget of `cap`. + fn read_content(pdf: Vec, cap: usize, piece: usize) -> (Vec, bool) { + let doc = Pdf::new(pdf).expect("parses"); + let page = doc.pages().first().expect("one page"); + let mut content = page.content_reader_within(cap); + let mut out = Vec::new(); + while content.read_into(&mut out, piece) > 0 {} + + (out, content.truncated()) + } + + #[test] + fn plain_and_whole_pieces_keep_their_bytes() { + let plain = b"q 1 0 0 1 0 0 cm Q\n"; + let whole = b"q 0 0 m Q\n"; + let encoded: String = whole.iter().map(|byte| format!("{byte:02x}")).collect::() + ">"; + + for piece in [1, 7, 4096, 65_536] { + let (plain_out, plain_truncated) = read_content(page_pdf(plain, ""), 1 << 20, piece); + assert_eq!(plain_out, plain, "plain piece {piece}"); + assert!(!plain_truncated, "plain piece {piece}"); + + let (whole_out, whole_truncated) = read_content( + page_pdf(encoded.as_bytes(), "/Filter /ASCIIHexDecode"), + 1 << 20, + piece, + ); + assert_eq!(whole_out, whole, "whole piece {piece}"); + assert!(!whole_truncated, "whole piece {piece}"); + } + } + + #[test] + fn repeated_content_reference_counts_compressed_bytes_once() { + let data = vec![b'q'; 1 << 16]; + let pdf = page_pdf_array(&[(data.as_slice(), "")], &[0, 0]); + let doc = Pdf::new(pdf).expect("parses"); + let page = doc.pages().first().expect("one page"); + let mut content = page.content_reader_within(1 << 20); + let expected_budget = 64 * data.len(); + assert_eq!(content.budget, expected_budget); + + let mut out = Vec::new(); + while content.read_into(&mut out, 4096) > 0 {} + let mut expected = data.clone(); + expected.push(b' '); + expected.extend_from_slice(&data); + expected.push(b' '); + assert!( + out == expected, + "expected {} bytes, got {}", + expected.len(), + out.len() + ); + assert!(!content.truncated()); + } + + #[test] + fn whole_cap_still_counts_later_unique_streams() { + let first = vec![b'q'; 1 << 20]; + let later = vec![b'Q'; 12_000]; + let hex = |data: &[u8]| { + data.iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + + ">" + }; + let first_raw = hex(&first); + let later_raw = hex(&later); + let pdf = page_pdf_array( + &[ + (first_raw.as_bytes(), "/Filter /ASCIIHexDecode"), + (later_raw.as_bytes(), "/Filter /ASCIIHexDecode"), + ], + &[0, 1], + ); + let cap = 1 << 20; + let doc = Pdf::new(pdf).expect("parses"); + let page = doc.pages().first().expect("one page"); + let mut content = page.content_reader_within(cap); + let expected_budget = 64 * (first_raw.len() + later_raw.len()); + assert!(expected_budget > cap); + assert_eq!(content.budget, expected_budget); + assert_eq!(content.materialized_len(), cap); + assert_eq!(content.pieces.len(), 2, "only the first whole stream and separator are kept"); + + let mut out = Vec::new(); + while content.read_into(&mut out, 65_536) > 0 {} + let mut expected = first; + expected.push(b' '); + assert_eq!(out, expected); + assert!(!content.truncated()); + } + + #[test] + fn content_past_the_cap_is_read_to_the_end() { + let content: Vec = b"q 1 0 0 1 0 0 cm Q\n".iter().copied().cycle().take(3 << 20).collect(); + let pdf = page_pdf(&stored_zlib(&content), "/Filter /FlateDecode"); + // The cap is far below the content, but stored data expands by nothing, so nothing is cut. + for piece in [7, 4096, 1 << 16] { + let (out, truncated) = read_content(pdf.clone(), 1 << 20, piece); + assert_eq!(out.len(), content.len(), "piece {piece}"); + assert!(out == content, "piece {piece}: the bytes are the content's"); + assert!(!truncated, "piece {piece}"); + } + } + + #[test] + fn a_stream_that_expands_past_the_budget_is_cut_there() { + let cap = 1 << 20; + let bomb = fixed_run_bomb(400_000); + let (out, truncated) = read_content(page_pdf(&bomb, "/Filter /FlateDecode"), cap, 1 << 16); + + assert!(truncated, "the expansion is past the budget"); + // The budget is 64 times the compressed stream, which is more than the cap here. + assert_eq!(out.len(), 64 * bomb.len()); + assert!(out.iter().all(|&b| b == b'a')); + } + + #[test] + fn a_small_expansion_under_the_cap_is_not_cut() { + let bomb = fixed_run_bomb(1000); + let (out, truncated) = read_content(page_pdf(&bomb, "/Filter /FlateDecode"), 1 << 20, 4096); + + assert!(!truncated); + assert_eq!(out.len(), 1 + 258 * 1000); + } +} From 2912b21d33bd09766f302ca39302b5c69d3d53cb Mon Sep 17 00:00:00 2001 From: EULA <90616501+EULArule@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:17:21 -0400 Subject: [PATCH 15/91] XFA: single-block forms paginate, open in their saved state, and show their signatures (#1098) * XFA: forms in one wrapped block lay out across pages, with their rows and growing fields A dynamic form whose body is a single block that can't be flowed (left-to-right wrapped, positioned or fixed-height) laid out to one page, the rest drawn past the bottom edge with no warning. - A block taller than a content area is spread over the following ones. Breaks fall between fields, draws and lines of text; fills and lines are cut at the break; a field or draw moves whole, border and all. - Repeating subforms in a left-to-right wrapped container get the instances the data holds, as in the other layouts (only the first was drawn). - A multi-line text field without a fixed height grows to hold its text. Past the height of a content area it is drawn as text that continues across pages (not editable; reported as a warning) instead of one widget cut off. - Text too long for the fixed height it was given is drawn smaller, down to 70%, rather than over what lies below (the standard faces are wider than the template's). - Nested measurements carry their SOM path, so instance counts and field values are found below the first level. Synthetic tests cover each. Run: fmt, clippy and tests for pdfcraft-xfa, and the pdfcraft-engine tests; the workspace-wide gates were not run. Co-Authored-By: Claude Opus 5.5 * XFA: a form opens in the state it was saved in, and wrapped sections are as tall as their content The form packet's saved presence, access and captions are applied to the template, so rows a script showed before saving appear, locked fields stay locked and a button keeps the label it was saved with. A flowed subform no longer stretches to a leftover minH: a public form whose own form packet records 3 pages laid out as 4 and now lays out as 3. Also: a text field without a border of its own has no box, and a CR LF pair is one line break. Co-Authored-By: Claude Opus 5.5 * XFA: a signed signature box shows the signature the file holds A laid-out signature box takes the value and the appearance of the signed field of the same SOM name that the signing application left in the AcroForm, and is read-only. The engine no longer redraws a signed box as an empty one, on open or on a script's re-layout. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/lib.rs | 12 +- crates/engine/src/tests.rs | 19 ++ crates/engine/src/xfa.rs | 4 +- crates/xfa/src/fixtures.rs | 33 ++++ crates/xfa/src/layout.rs | 366 ++++++++++++++++++++++++++++++++----- crates/xfa/src/parse.rs | 84 +++++++++ crates/xfa/src/pdf.rs | 67 +++++++ crates/xfa/src/tests.rs | 364 ++++++++++++++++++++++++++++++++++++ crates/xfa/src/text.rs | 4 +- 9 files changed, 906 insertions(+), 47 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4242ee28..26f14fe6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2051,10 +2051,20 @@ pub struct Session { /// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { let report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; + xfa_redraw(doc)?; + Ok(report) +} + +/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the +/// picture its signer's application drew. +pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> { for f in pdfcraft_forms::fields(doc) { + if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) { + continue; + } pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; } - Ok(report) + Ok(()) } /// The form's fields as the XFA data layer wants them. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6e92eab9..9babc4fb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2226,6 +2226,25 @@ fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() { assert_eq!(d2.form.iter().find(|f| f.name == "answer").unwrap().value, vec!["N".to_string()]); } +#[test] +fn a_signed_xfa_signature_box_shows_the_picture_the_file_holds() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("signed.pdf", None, Arc::new(pdfcraft_xfa::fixtures::signed_shell()), None).expect("opens"); + let doc = s.get(id).unwrap(); + assert_eq!(doc.xfa.as_ref().map(|x| (x.pages, x.fields)), Some((1, 2))); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + let at = |x: u32, y: u32| -> [u8; 4] { + let i = ((y * out.width + x) * 4) as usize; + out.rgba[i..i + 4].try_into().unwrap() + }; + // The signed box (3in by 1in from half an inch in) is filled with the signature's blue + // picture; the unsigned one below it is not. + assert_eq!(at(36 + 108, 36 + 36), [0, 0, 255, 255]); + assert_ne!(at(36 + 108, 36 + 72 + 36), [0, 0, 255, 255]); +} + #[test] fn an_xfa_form_without_a_template_opens_with_its_placeholder_and_a_warning() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell("")); diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 029fcb70..b0f4c0b3 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -452,9 +452,7 @@ impl<'a, 'b> Runner<'a, 'b> { self.flush()?; let report = pdfcraft_xfa::rerender(self.doc, self.tpl).map_err(|e| e.to_string())?; self.console(report.warnings); - for f in pdfcraft_forms::fields(self.doc) { - pdfcraft_forms::redraw_field(self.doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; - } + crate::xfa_redraw(self.doc)?; self.by_som = None; self.fields = None; self.cached = None; diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index f91cc7c5..a6c79a2a 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -324,3 +324,36 @@ pub fn shell(xdp: &str) -> Vec { ]; assemble(objs) } + +/// A dynamic form with two signature boxes of which `sign` has been signed: the file holds the +/// field a signing application leaves (named by its SOM path, its value a signature dictionary) +/// and the picture drawn in the box, a solid blue rectangle. +pub fn signed_shell() -> Vec { + let xdp = r##" + + + +"##; + let picture = "0 0 1 rg 0 0 100 40 re f"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [6 0 R] /SigFlags 3 >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".to_vec(), + b"<< /Length 44 >>\nstream\nBT /F1 12 Tf 72 700 Td (Please wait...) Tj ET\nendstream".to_vec(), + format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(), + b"<< /T (form[0]) /Kids [7 0 R] >>".to_vec(), + b"<< /T (body[0]) /Parent 6 0 R /Kids [8 0 R] >>".to_vec(), + b"<< /T (sign[0]) /Parent 7 0 R /FT /Sig /V 9 0 R /Type /Annot /Subtype /Widget /Rect [0 0 0 0] /AP << /N 10 0 R >> >>".to_vec(), + b"<< /Type /Sig /Name (A Signer) >>".to_vec(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 40] /Length {} >>\nstream\n{picture}\nendstream", picture.len()).into_bytes(), + ]; + assemble(objs) +} diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 615afbb0..13712e91 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -200,6 +200,9 @@ struct Layouter<'t> { measured: RefCell>, /// Measurements made so far (see [`MAX_MEASURES`]). measures: Cell, + /// The fields and draws of the block being placed: (first item, one past the last, top, + /// bottom) on the last page. A page break keeps each whole (see `split_block`). + leaves: Vec<(usize, usize, f64, f64)>, } /// How many instances of a repeating subform to lay out: what the data holds once it holds @@ -248,6 +251,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result items: 0, measured: RefCell::new(HashMap::new()), measures: Cell::new(0), + leaves: Vec::new(), }; let ctx = Ctx { font: Font::default(), para: Para::default(), som: String::new() }; l.new_page()?; @@ -312,6 +316,75 @@ fn splittable(node: &Node) -> bool { matches!(node, Node::Subform(s) if matches!(s.layout, Layout::Tb | Layout::Table) && s.common.h.is_none()) } +/// Top and bottom of an item a page break must not cut (text, a picture, a field); `None` +/// for fills and lines, which are cut at the break. +fn solid_extent(item: &Item) -> Option<(f64, f64)> { + match item { + Item::Text(s) => Some((s.baseline - s.face.size * 0.8, s.baseline + s.face.size * 0.2)), + Item::Image { rect, .. } => Some((rect.y, rect.bottom())), + Item::Widget(w) => Some((w.rect.y, w.rect.bottom())), + Item::Fill { .. } | Item::Line { .. } => None, + } +} + +/// `item` as the part above `cut` and the part below: solid items go whole to the side their +/// top is on, fills and lines crossing the cut are cut in two. +fn divide(item: Item, cut: f64) -> (Option, Option) { + if let Some((top, _)) = solid_extent(&item) { + return if top < cut - 0.01 { (Some(item), None) } else { (None, Some(item)) }; + } + match item { + Item::Fill { rect, color } => { + if rect.bottom() <= cut + 0.01 { + (Some(Item::Fill { rect, color }), None) + } else if rect.y >= cut - 0.01 { + (None, Some(Item::Fill { rect, color })) + } else { + let above = Rect::new(rect.x, rect.y, rect.w, cut - rect.y); + let below = Rect::new(rect.x, cut, rect.w, rect.bottom() - cut); + (Some(Item::Fill { rect: above, color }), Some(Item::Fill { rect: below, color })) + } + } + Item::Line { from, to, width, color, dashed } => { + let (lo, hi) = if from.1 <= to.1 { (from, to) } else { (to, from) }; + if lo.1 >= cut { + (None, Some(Item::Line { from, to, width, color, dashed })) + } else if hi.1 <= cut { + (Some(Item::Line { from, to, width, color, dashed }), None) + } else { + // lo.1 < cut < hi.1, so the divisor is positive. + let t = (cut - lo.1) / (hi.1 - lo.1); + let mid = (lo.0 + (hi.0 - lo.0) * t, cut); + (Some(Item::Line { from: lo, to: mid, width, color, dashed }), Some(Item::Line { from: mid, to: hi, width, color, dashed })) + } + } + other => (Some(other), None), + } +} + +/// `item` moved by (`dx`, `dy`). +fn moved(mut item: Item, dx: f64, dy: f64) -> Item { + match &mut item { + Item::Fill { rect, .. } | Item::Image { rect, .. } => { + rect.x += dx; + rect.y += dy; + } + Item::Line { from, to, .. } => { + *from = (from.0 + dx, from.1 + dy); + *to = (to.0 + dx, to.1 + dy); + } + Item::Text(s) => { + s.x += dx; + s.baseline += dy; + } + Item::Widget(w) => { + w.rect.x += dx; + w.rect.y += dy; + } + } + item +} + fn is_row(node: &Node) -> bool { matches!(node, Node::Subform(s) if s.layout == Layout::Row) } @@ -491,6 +564,143 @@ impl Layouter<'_> { Ok(true) } + /// Place `node` as one block of size (`w`, `h`) at the cursor: in this content area when it fits, + /// in the next when it fits there, and across as many as it takes when it is taller than a + /// content area (a positioned or fixed-height subform holding more than a page). + fn flow_block(&mut self, node: &Node, x: f64, (w, h): (f64, f64), ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + let (rect, y, _) = self.cursor(); + if y + h > rect.bottom() + 0.01 && h <= rect.h + 0.01 { + self.make_room(h)?; + } + let (rect, y, _) = self.cursor(); + let first = self.pages.last().map_or(0, |p| p.items.len()); + self.leaves.clear(); + self.draw_node(node, Rect::new(x, y, w, h), ctx, depth, sib)?; + let page_h = self.pages.last().map_or(f64::INFINITY, |p| p.height); + // What was drawn, when it is lower than the block's own height says (content that + // outgrows a fixed height) and ends off the page. + let drawn = self + .pages + .last() + .and_then(|p| p.items.get(first..)) + .unwrap_or_default() + .iter() + .filter_map(solid_extent) + .map(|e| e.1) + .fold(y + h, f64::max); + if y + h <= rect.bottom() + 0.01 && drawn <= page_h + 0.01 { + self.leaves.clear(); + self.place(h); + return Ok(()); + } + self.split_block(first, y, drawn) + } + + /// Remember that the items the last page got since `start` are one field or draw at + /// `rect`, to be kept together by a page break. + fn keep_whole(&mut self, start: usize, rect: Rect) { + let end = self.pages.last().map_or(0, |p| p.items.len()); + if end > start { + self.leaves.push((start, end, rect.y, rect.bottom())); + } + } + + fn item_count(&self) -> usize { + self.pages.last().map_or(0, |p| p.items.len()) + } + + /// Spread the items the last page holds from index `first` on (a block drawn from `top` to + /// `end`, past the bottom of its content area) over this and the following content areas. + /// Breaks fall between text, pictures and fields, never through one that fits in an area; + /// fills and lines are cut at the break. + fn split_block(&mut self, first: usize, top: f64, end: f64) -> Result<(), XfaError> { + let items: Vec = match self.pages.last_mut() { + Some(p) if first <= p.items.len() => p.items.split_off(first), + _ => return Ok(()), + }; + // Each item with the extent of the field or draw it belongs to: such a leaf moves + // whole (its border with its text), unless it is taller than a content area. + let area_h = self.cursor().0.h; + let mut extents: Vec> = vec![None; items.len()]; + for (start, end, top, bottom) in std::mem::take(&mut self.leaves) { + if bottom - top <= area_h + 0.01 + && let Some(slots) = start.checked_sub(first).and_then(|s| extents.get_mut(s..end.saturating_sub(first))) + { + slots.fill(Some((top, bottom))); + } + } + let mut rest: Vec<(Item, Option<(f64, f64)>)> = items.into_iter().zip(extents).collect(); + let origin_x = self.cursor().0.x; + // An item's place on the current page is its drawn place moved by (dx, -shift). + let (mut dx, mut shift) = (0.0, 0.0); + let mut seg_top = top; + let mut first_segment = true; + loop { + let (rect, _, placed) = self.cursor(); + let limit = rect.bottom() + shift; + let mut cut = limit; + if rect.h < 1.0 { + // No room to split into: leave the rest here. + cut = f64::INFINITY; + } else { + // The lowest break at or above the bottom that crosses no solid item: going + // through them from the lowest top up, a break moved to an item's top can't + // cross one already passed. + let mut solid: Vec<(f64, f64)> = + rest.iter().filter_map(|(item, kept)| kept.or_else(|| solid_extent(item))).filter(|e| e.0 < limit - 0.01).collect(); + solid.sort_by(|a, b| b.0.total_cmp(&a.0)); + for (t, b) in solid { + if t < cut - 0.01 && b > cut + 0.01 { + cut = t; + } + } + if cut <= seg_top + 0.01 { + // An item taller than what is left. Start it in a fresh area if this one + // already holds something; otherwise cut through it. + cut = if first_segment && placed && seg_top > rect.y + 0.01 { seg_top } else { limit }; + } + } + let mut here = Vec::new(); + let mut below = Vec::new(); + for (item, kept) in rest { + if let Some((top, _)) = kept { + if top < cut - 0.01 { + here.push(item) + } else { + below.push((item, kept)) + } + continue; + } + match divide(item, cut) { + (Some(a), None) => here.push(a), + (None, Some(b)) => below.push((b, None)), + (Some(a), Some(b)) => { + here.push(a); + below.push((b, None)); + } + (None, None) => {} + } + } + if let Some(p) = self.pages.last_mut() { + p.items.extend(here.into_iter().map(|it| moved(it, dx, -shift))); + } + if below.is_empty() { + if let Some(c) = &mut self.cur { + c.y = (end - shift).min(c.rect.bottom()).max(c.y); + c.placed = true; + } + return Ok(()); + } + rest = below; + seg_top = cut; + first_segment = false; + self.next_area()?; + let (rect, _, _) = self.cursor(); + shift = cut - rect.y; + dx = rect.x - origin_x; + } + } + fn page_has_flow(&self) -> bool { self.cur.as_ref().is_some_and(|c| c.placed || c.content > 0) } @@ -518,10 +728,7 @@ impl Layouter<'_> { if !matches!(sf.layout, Layout::Tb | Layout::Table) || sf.common.h.is_some() { // Not splittable: one block. let (_, h) = self.size_of(node, w, ctx_parent, depth); - self.make_room(h)?; - let (_, y, _) = self.cursor(); - self.draw_node(node, Rect::new(x, y, w, h), ctx_parent, depth, sib + i)?; - self.place(h); + self.flow_block(node, x, (w, h), ctx_parent, depth, sib + i)?; continue; } let m = sf.common.margin; @@ -543,10 +750,7 @@ impl Layouter<'_> { continue; } let (cw, ch) = self.size_of(child, inner_w, &ctx, depth + 1); - self.make_room(ch)?; - let (_, y, _) = self.cursor(); - self.draw_node(child, Rect::new(inner_x, y, cw, ch), &ctx, depth + 1, csib)?; - self.place(ch); + self.flow_block(child, inner_x, (cw, ch), &ctx, depth + 1, csib)?; } } } @@ -605,10 +809,7 @@ impl Layouter<'_> { continue; } let (cw, ch) = self.size_of(child, inner_w, ctx, depth + 1); - self.make_room(ch)?; - let (_, y, _) = self.cursor(); - self.draw_node(child, Rect::new(x, y, cw, ch), ctx, depth + 1, csib)?; - self.place(ch); + self.flow_block(child, x, (cw, ch), ctx, depth + 1, csib)?; } Ok(()) } @@ -662,10 +863,11 @@ impl Layouter<'_> { fn measure(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { let c = node.common(); - // The container's own SOM, under which its repeating children count their instances. - // Measurements are cached per template node, so instance 0 stands for all of them. - let som = ctx.with_som(c, 0).som; - let ctx = ctx.child(c); + // The node's own SOM: its repeating children count their instances under it, and a + // growing text field finds its value at it. Measurements are cached per template node, + // so instance 0 stands for all of them. + let ctx = ctx.with_som(c, 0); + let som = ctx.som.clone(); // A container without a width is as wide as its content (a radio group beside its // question, say); a leaf without one takes what is available. let w = match c.w { @@ -697,7 +899,7 @@ impl Layouter<'_> { .filter(|k| k.common().presence.occupies()) .map(|k| self.size_of(k, inner_w, &ctx, depth + 1).1 * self.instances(k, &som) as f64) .sum(), - Layout::LrTb | Layout::RlTb => self.packed_height(&s.children, inner_w, &ctx, depth), + Layout::LrTb | Layout::RlTb => self.pack_instances(&s.children, inner_w, &ctx, depth, &som).1, Layout::Table => { let cols = self.columns(s, inner_w); s.children @@ -715,7 +917,13 @@ impl Layouter<'_> { content + c.margin.vertical() } }; - (w, clamp_opt(h, c.min_h, c.max_h)) + // A flowed subform is as tall as what it holds: other viewers do not stretch one to + // the `minH` a designer left on it. + let min_h = match node { + Node::Subform(s) if c.h.is_none() && s.layout != Layout::Positioned => None, + _ => c.min_h, + }; + (w, clamp_opt(h, min_h, c.max_h)) } /// The natural width of a container's content within `avail_w`. @@ -787,7 +995,7 @@ impl Layouter<'_> { let lines = if f.multiline { 3.0 } else { 1.0 }; let ui = match f.ui { Ui::CheckButton => f.check_size.unwrap_or(10.0).max(text::line_height(font.size)), - _ => text::line_height(font.size) * lines + 4.0 + f.ui_margin.vertical(), + _ => (text::line_height(font.size) * lines).max(self.grown_text_height(f, inner_w, ctx)) + 4.0 + f.ui_margin.vertical(), }; match self.caption_reserve(f, inner_w, ctx) { Some((Placement::Top | Placement::Bottom, r)) => ui + r, @@ -807,6 +1015,25 @@ impl Layouter<'_> { } } + /// The height the text of a multi-line text field takes (its value in the data, else the + /// template's), for a field without a fixed height, which grows to hold its text. `ctx` + /// is the field's own (its SOM ends in the field). Zero for anything else. + fn grown_text_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 { + if f.ui != Ui::TextEdit || !f.multiline || f.common.h.is_some() || f.common.name.is_none() { + return 0.0; + } + let value = self.data.and_then(|d| d.value_at(&data_path(self.tpl, &ctx.som))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain()); + let Some(value) = value.filter(|t| !t.trim().is_empty()) else { return 0.0 }; + // Beside a caption the text has what the caption leaves; 2pt of padding on each side. + let beside = match self.caption_reserve(f, inner_w, ctx) { + Some((Placement::Top | Placement::Bottom, _)) | None => 0.0, + Some((_, r)) => r, + }; + let w = (inner_w - beside - f.ui_margin.horizontal() - 4.0).max(1.0); + let font = ctx.font.apply(&f.common.font); + text::measure_height(&text::plain(value.trim_end()), w, &ctx.para, &Face::of(&font), &|_| String::new()) + } + fn group_height(&self, g: &ExclGroup, inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); match g.layout { @@ -828,8 +1055,10 @@ impl Layouter<'_> { .fold(0.0, f64::max) } - /// Left-to-right packing: rows of children, wrapped at the available width. - fn pack(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) { + /// Left-to-right packing: rows of children, wrapped at the available width, as (child, + /// instance, place). A repeating subform is packed once per instance it has under the + /// container at `som`. + fn pack_instances(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize, som: &str) -> (Vec<(usize, usize, Rect)>, f64) { let mut out = Vec::new(); let (mut x, mut y, mut line_h) = (0.0, 0.0, 0.0); for (i, k) in children.iter().enumerate() { @@ -837,18 +1066,26 @@ impl Layouter<'_> { continue; } let (w, h) = self.size_of(k, inner_w, ctx, depth + 1); - if x > 0.0 && x + w > inner_w + 0.05 { - y += line_h; - x = 0.0; - line_h = 0.0; + for instance in 0..self.instances(k, som) { + if x > 0.0 && x + w > inner_w + 0.05 { + y += line_h; + x = 0.0; + line_h = 0.0; + } + out.push((i, instance, Rect::new(x, y, w, h))); + x += w; + line_h = line_h.max(h); } - out.push((i, Rect::new(x, y, w, h))); - x += w; - line_h = line_h.max(h); } (out, y + line_h) } + /// [`Self::pack_instances`] for children that don't repeat (the fields of a group). + fn pack(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) { + let (placed, h) = self.pack_instances(children, inner_w, ctx, depth, ""); + (placed.into_iter().map(|(i, _, r)| (i, r)).collect(), h) + } + fn packed_height(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { self.pack(children, inner_w, ctx, depth).1 } @@ -923,8 +1160,21 @@ impl Layouter<'_> { return Ok(()); } match node { - Node::Draw(d) => self.draw_draw(d, rect, ctx), - Node::Field(f) => self.draw_field(f, rect, ctx, None, sib), + Node::Draw(d) => { + let start = self.item_count(); + self.draw_draw(d, rect, ctx)?; + // Text and pictures stay with their border; a bare line or rectangle is cut. + if matches!(d.value, Value::Text(_) | Value::Rich(_) | Value::Image { .. }) { + self.keep_whole(start, rect); + } + Ok(()) + } + Node::Field(f) => { + let start = self.item_count(); + self.draw_field(f, rect, ctx, None, sib)?; + self.keep_whole(start, rect); + Ok(()) + } Node::ExclGroup(g) => self.draw_group(g, rect, ctx, depth, sib), Node::Area(a) => { let ctx = ctx.with_som(&a.common, sib); @@ -962,11 +1212,13 @@ impl Layouter<'_> { } } Layout::LrTb | Layout::RlTb => { - let (placed, _) = self.pack(&s.children, inner.w, &ctx, depth); - for (i, r) in placed { + // A repeating subform here (rows of fields under a heading) gets its + // instances, as in the other layouts. + let (placed, _) = self.pack_instances(&s.children, inner.w, &ctx, depth, &ctx.som); + for (i, instance, r) in placed { if let Some(k) = s.children.get(i) { let x = if s.layout == Layout::RlTb { inner.right() - r.x - r.w } else { inner.x + r.x }; - self.draw_node(k, Rect::new(x, inner.y + r.y, r.w, r.h), &ctx, depth + 1, sib_index(&s.children, i))?; + self.draw_node(k, Rect::new(x, inner.y + r.y, r.w, r.h), &ctx, depth + 1, sib_index(&s.children, i) + instance)?; } } } @@ -1039,7 +1291,20 @@ impl Layouter<'_> { Value::Rich(r) => r.clone(), _ => return Ok(()), }; - let block = text::layout(&rich, rect, para, &Face::of(font), &|id| self.embed(id)); + let face = Face::of(font); + // Text that doesn't fit the height it was given (the template's face is narrower than + // the standard one drawn in its place, so it wraps to more lines) is drawn smaller, + // down to 70%, rather than over what lies below. + let fits = |k: f64| text::measure_height(&rich, rect.w / k, para, &face, &|id| self.embed(id)) <= rect.h / k + 1.0; + let scale = if rect.h < 4.0 || fits(1.0) { 1.0 } else { [0.95, 0.9, 0.85, 0.8, 0.75].into_iter().find(|k| fits(*k)).unwrap_or(0.7) }; + let mut block = text::layout(&rich, Rect::new(rect.x, rect.y, rect.w / scale, rect.h / scale), para, &face, &|id| self.embed(id)); + if scale < 1.0 { + for s in &mut block.spans { + s.x = rect.x + (s.x - rect.x) * scale; + s.baseline = rect.y + (s.baseline - rect.y) * scale; + s.face.size *= scale; + } + } self.push_block(block) } @@ -1248,6 +1513,25 @@ impl Layouter<'_> { // Shown as text, not as a field. return self.draw_value_text(&f.value, ui.inset(&f.ui_margin), &fctx.font, &fctx.para); } + // A text field that grew past what a content area holds can't be one widget: its text + // is drawn as lines the page breaks fall between, and stays what the data holds. + if f.ui == Ui::TextEdit && f.multiline && f.common.h.is_none() && rect.h > self.cursor().0.h + 0.01 { + let value = + self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som_path()))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain()); + if let Some(value) = value.filter(|t| !t.trim().is_empty()) { + let name = f.common.name.clone().unwrap_or_else(|| "a text field".into()); + self.warn_once(format!("{name}: its text is longer than a page; it is shown across pages and can't be edited here")); + let m = Margin { + top: f.ui_margin.top + 2.0, + bottom: f.ui_margin.bottom + 2.0, + left: f.ui_margin.left + 2.0, + right: f.ui_margin.right + 2.0, + }; + let para = Para { v_align: VAlign::Top, ..fctx.para.clone() }; + let block = text::layout(&text::plain(value.trim_end()), ui.inset(&m), ¶, &Face::of(&fctx.font), &|_| String::new()); + return self.push_block(block); + } + } let face = match (&f.ui, &f.caption) { // Buttons show their caption in the caption's font. (Ui::Button, Some(cap)) => Face::of(&ctx.font.apply(&cap.font)), @@ -1354,16 +1638,14 @@ impl Layouter<'_> { } } -/// The widget's own border from the `` border element. Without one, text fields and check -/// boxes get a solid box, as Designer draws them. +/// The widget's own border from the `` border element. Without one, a check box gets a +/// solid box, as Designer draws it, and any other field has no box of its own. fn widget_border(b: Option<&Border>, check: bool) -> WidgetBorder { let Some(b) = b else { - return WidgetBorder { - color: Some([0.0, 0.0, 0.0]), - fill: if check { Some([1.0, 1.0, 1.0]) } else { None }, - shape: BorderShape::Full, - width: 0.5, - }; + if !check { + return WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 }; + } + return WidgetBorder { color: Some([0.0, 0.0, 0.0]), fill: Some([1.0, 1.0, 1.0]), shape: BorderShape::Full, width: 0.5 }; }; let vis = b.visible_edges(); let shape = match vis { diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 0bf90081..d3111f37 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -662,7 +662,91 @@ pub fn parse(xml: &str) -> Result<(Template, Vec), XfaError> { // A page set may sit beside the root subform. root_sf.page_set = child(template, "pageSet").map(|ps| parse_page_set(ps, &mut warnings, 0)); } + // The form packet beside the template holds the state the form was saved in. + let saved = root + .descendants() + .find(|n| n.is_element() && n.tag_name().name() == "form" && n.tag_name().namespace().is_some_and(|ns| ns.contains("xfa-form"))); + if let Some(saved_root) = saved.and_then(|f| children(f, "subform").next()) { + if let Some(p) = saved_root.attribute("presence") { + root_sf.common.presence = Presence::parse(Some(p)); + } + apply_saved_state(&mut root_sf.children, saved_root, 0); + } let mut page_roles = Vec::new(); collect_page_roles(&[Node::Subform(Box::new(root_sf.clone()))], &mut page_roles, 0); Ok((Template { root: root_sf, page_roles }, warnings)) } + +fn parse_access(a: Option<&str>) -> Option { + Some(match a? { + "readOnly" => Access::ReadOnly, + "protected" => Access::Protected, + "nonInteractive" => Access::NonInteractive, + "open" => Access::Open, + _ => return None, + }) +} + +/// Apply the saved state of a form packet (`saved`: the element matching the container whose +/// children `nodes` are) to the template: the `presence` and `access` a script gave an object +/// before the form was saved, which is restored on opening. Objects are matched by +/// name and position among the same-named; of the instances of a repeating subform only the +/// first is read, as the template has it once. +fn apply_saved_state(nodes: &mut [Node], saved: XmlNode, depth: usize) { + if depth > MAX_DEPTH { + return; + } + let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + for c in saved.children().filter(|c| c.is_element()) { + if !matches!(c.tag_name().name(), "subform" | "subformSet" | "area" | "field" | "draw" | "exclGroup") { + continue; + } + let Some(name) = c.attribute("name").filter(|n| !n.is_empty()) else { continue }; + let nth = { + let k = seen.entry(name).or_insert(0); + *k += 1; + *k - 1 + }; + let same: Vec = nodes.iter().enumerate().filter(|(_, n)| n.common().name.as_deref() == Some(name)).map(|(i, _)| i).collect(); + // More saved objects of the name than the template has: further instances. + let Some(target) = same.get(nth).copied().and_then(|i| nodes.get_mut(i)) else { continue }; + let presence = c.attribute("presence").map(|p| Presence::parse(Some(p))); + match target { + Node::Subform(s) => { + if let Some(p) = presence { + s.common.presence = p; + } + apply_saved_state(&mut s.children, c, depth + 1); + } + Node::Area(a) => { + if let Some(p) = presence { + a.common.presence = p; + } + apply_saved_state(&mut a.children, c, depth + 1); + } + Node::Field(f) => { + if let Some(p) = presence { + f.common.presence = p; + } + if let Some(a) = parse_access(c.attribute("access")) { + f.access = a; + } + // A caption a script changed (a button's label, say) is saved as plain text. + let text = child(c, "caption").and_then(|k| child(k, "value")).and_then(|v| child(v, "text")).and_then(|t| t.text()); + if let (Some(caption), Some(text)) = (f.caption.as_mut(), text) { + caption.value = Value::Text(text.to_string()); + } + } + Node::Draw(d) => { + if let Some(p) = presence { + d.common.presence = p; + } + } + Node::ExclGroup(g) => { + if let Some(p) = presence { + g.common.presence = p; + } + } + } + } +} diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 811ac326..44896d40 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -176,6 +176,63 @@ struct Emitter<'a> { radio_selected: HashMap, field_count: usize, warnings: Vec, + /// The signatures the file holds, by the SOM path of the field signed. + signed: HashMap, +} + +/// A signature field of the file that has been signed. +struct Signed { + /// Its `V`: the signature dictionary (a reference, as the file has it). + value: Object, + /// Its widget's `AP`: what the signer's application drew in the box. + appearance: Option, +} + +/// Most fields looked at for signatures. +const MAX_FIELDS_SEARCHED: usize = 20_000; +/// Deepest field nesting followed. +const MAX_FIELD_DEPTH: usize = 32; + +/// The signed signature fields the document came with, by full name. Signing applications name +/// a dynamic form's fields by their SOM path (`form1[0].page1[0].sign[0]`), which is how a +/// laid-out signature box finds its signature. Fields of an earlier layout pass are not read. +fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap { + let mut out = HashMap::new(); + let Some(acro) = catalog.get(b"AcroForm").map(|a| doc.resolve(a)) else { return out }; + let Some(fields) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)) else { return out }; + let mut stack: Vec<(Object, String, bool, usize)> = + fields.as_array().into_iter().flatten().map(|f| (f.clone(), String::new(), false, 0)).collect(); + let mut seen = 0usize; + while let Some((field, prefix, parent_is_sig, depth)) = stack.pop() { + seen += 1; + if seen > MAX_FIELDS_SEARCHED { + break; + } + let field = doc.resolve(&field); + let Some(d) = field.as_dict() else { continue }; + if d.contains(SOM_KEY) { + continue; + } + let name = match d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|t| t.to_text())) { + Some(t) if prefix.is_empty() => t, + Some(t) => format!("{prefix}.{t}"), + None => prefix, + }; + let is_sig = match d.name(b"FT") { + Some(ft) => ft == b"Sig", + None => parent_is_sig, + }; + let kids: Vec = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + if is_sig && let Some(value) = d.get(b"V").filter(|v| doc.resolve(v).as_dict().is_some()) { + // The appearance is on the field when it is its own widget, or on its one widget. + let appearance = d.get(b"AP").cloned().or_else(|| kids.iter().find_map(|k| doc.resolve(k).as_dict().and_then(|k| k.get(b"AP").cloned()))); + out.entry(name.clone()).or_insert(Signed { value: value.clone(), appearance }); + } + if depth < MAX_FIELD_DEPTH { + stack.extend(kids.into_iter().map(|k| (k, name.clone(), is_sig, depth + 1))); + } + } + out } impl Emitter<'_> { @@ -304,6 +361,14 @@ impl Emitter<'_> { } WidgetKind::Signature => { d.set(b"FT".to_vec(), Object::name("Sig")); + // Signed already: the box shows the signature the file holds and is done with. + if let Some(signed) = self.signed.get(&w.som) { + d.set(b"V".to_vec(), signed.value.clone()); + if let Some(ap) = &signed.appearance { + d.set(b"AP".to_vec(), ap.clone()); + } + ff |= 1; + } } WidgetKind::CheckBox { on, .. } => { d.set(b"FT".to_vec(), Object::name("Btn")); @@ -610,6 +675,7 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result }; // Pictures embedded by earlier passes, from the pages this pass replaces. let images = earlier_images(doc, pages_ref); + let signed = signed_fields(doc, &catalog); let mut em = Emitter { doc, images, @@ -618,6 +684,7 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result radio_selected: HashMap::new(), field_count: 0, warnings: Vec::new(), + signed, }; let mut kids = Vec::new(); for page in &form.pages { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index a690e7f3..1ef3216e 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1109,3 +1109,367 @@ fn data_paths_leave_areas_out() { assert_eq!(path("other[0].box[0].rest[0]"), "other[0].box[0].rest[0]"); assert_eq!(data_path(&tpl, ""), Vec::new()); } + +/// A form of one page area (7.5in × 10in of content from 0.5in) around `body`. +fn one_area_form(root_attrs: &str, body: &str) -> String { + format!( + r##" + + + +"## + ) +} + +/// Every field is inside the content area of its page, and the fields come in template order. +fn assert_paginated(form: &Form, fields: usize, min_pages: usize) { + assert!(form.pages.len() >= min_pages, "{} pages", form.pages.len()); + let mut names = Vec::new(); + for (i, p) in form.pages.iter().enumerate() { + for w in widgets(p) { + assert!( + w.rect.y >= 36.0 - 0.01 && w.rect.bottom() <= 36.0 + 720.0 + 0.01, + "page {}: {} is outside the content area: {:?}", + i + 1, + w.name, + w.rect + ); + names.push(w.som.clone()); + } + } + assert_eq!(names.len(), fields, "{names:?}"); + let expected: Vec = (0..fields).map(|i| format!("f{i}[0]")).collect(); + for (n, e) in names.iter().zip(&expected) { + assert!(n.ends_with(e), "{n} where {e} belongs: {names:?}"); + } +} + +#[test] +fn a_positioned_subform_taller_than_a_page_continues_on_the_next_pages() { + // 30 fields 0.9in apart: 27in of positioned content, where a content area holds 10in. + let fields: String = + (0..30).map(|i| format!(r#""#, i as f64 * 0.9)).collect(); + // As the only child of a flowed root … + let form = layout_xml(&one_area_form( + r#"layout="tb""#, + &format!(r#"{fields}"#), + )) + .unwrap(); + assert_paginated(&form, 30, 3); + // … its fill is cut at the breaks, a piece on every page, none past the content area. + for (i, p) in form.pages.iter().enumerate() { + let fills: Vec<&Rect> = p.items.iter().filter_map(|it| if let Item::Fill { rect, .. } = it { Some(rect) } else { None }).collect(); + assert!(!fills.is_empty(), "page {}: no fill", i + 1); + for r in fills { + assert!(r.bottom() <= 36.0 + 720.0 + 0.01, "page {}: fill {r:?}", i + 1); + } + } + // … after other content on the page … + let form = layout_xml(&one_area_form( + r#"layout="tb""#, + &format!(r#"Head{fields}"#), + )) + .unwrap(); + assert_paginated(&form, 30, 3); + assert!(!widgets(&form.pages[0]).is_empty(), "the block starts on the page the heading is on"); + // … and as the root itself (positioned is the default layout). + let form = layout_xml(&one_area_form("", &fields)).unwrap(); + assert_paginated(&form, 30, 3); +} + +#[test] +fn wrapped_and_fixed_height_blocks_taller_than_a_page_are_split_too() { + let fields: String = (0..60).map(|i| format!(r#""#)).collect(); + // Left-to-right wrapping: 30 lines of 0.8in. + let form = layout_xml(&one_area_form(r#"layout="tb""#, &format!(r#"{fields}"#))).unwrap(); + assert_paginated(&form, 60, 3); + // Top-to-bottom with a fixed height the content outgrows. + let form = + layout_xml(&one_area_form(r#"layout="tb""#, &format!(r#"{fields}"#))).unwrap(); + assert_paginated(&form, 60, 5); +} + +#[test] +fn a_block_that_fits_a_page_is_still_moved_whole() { + let block = |n: usize| { + format!(r#""#) + }; + let form = layout_xml(&one_area_form(r#"layout="tb""#, &format!("{}{}{}", block(0), block(1), block(2)))).unwrap(); + assert_eq!(form.pages.len(), 3); + for p in &form.pages { + let w = widgets(p); + assert_eq!(w.len(), 1); + assert!((w[0].rect.y - (36.0 + 360.0)).abs() < 0.01, "{:?}", w[0].rect); + } +} + +#[test] +fn a_field_taller_than_a_page_is_cut_through_without_looping() { + let body = r#""#; + let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap(); + let all: Vec<&Widget> = form.pages.iter().flat_map(widgets).collect(); + assert_eq!(all.len(), 2); + assert!(form.pages.len() <= 4, "{} pages", form.pages.len()); +} + +/// A form built the way Designer builds a flowed one: everything in one left-to-right wrapped +/// body, sections of a heading over repeating rows of fields, and a text field that grows. +fn wrapped_form(data: &str) -> String { + let body = r#" + + Entries + + + + + + + + + "#; + let form = one_area_form(r#"layout="tb""#, body); + let packet = + format!("{data}\n"); + form.replace("", &packet) +} + +#[test] +fn repeating_rows_in_a_wrapped_container_get_the_instances_the_data_holds() { + let rows: String = (0..4).map(|i| format!("what {i}{i}why {i}")).collect(); + let form = layout_xml(&wrapped_form(&format!("
{rows}
n
"))).unwrap(); + let all: Vec<&Widget> = form.pages.iter().flat_map(widgets).collect(); + let whats: Vec<(&str, Option<&str>, f64)> = + all.iter().filter(|w| w.som.ends_with(".what[0]")).map(|w| (w.som.as_str(), w.value.as_deref(), w.rect.y)).collect(); + assert_eq!(whats.len(), 4, "{whats:?}"); + for (i, (som, value, _)) in whats.iter().enumerate() { + assert_eq!(*som, format!("form[0].body[0].section[0].row[{i}].what[0]")); + assert_eq!(*value, Some(format!("what {i}").as_str())); + } + // One under another, the section growing with them: what follows comes after the last. + assert!(whats.windows(2).all(|p| (p[1].2 - p[0].2 - 72.0).abs() < 0.01), "{whats:?}"); + let notes = all.iter().find(|w| w.name == "notes").unwrap(); + assert!(notes.rect.y >= whats[3].2 + 72.0 - 0.01, "{:?} after {whats:?}", notes.rect); + // Without data a row is there once. + let form = layout_xml(&wrapped_form("
")).unwrap(); + assert_eq!(form.pages.iter().flat_map(widgets).filter(|w| w.name.starts_with("what")).count(), 1); +} + +#[test] +fn a_multi_line_field_without_a_height_grows_to_hold_its_text() { + let place = |data: &str| { + let form = layout_xml(&wrapped_form(data)).unwrap(); + let on = |name: &str| { + form.pages.iter().enumerate().find_map(|(i, p)| widgets(p).into_iter().find(|w| w.name == name).map(|w| (i, w.rect))).unwrap() + }; + (on("notes"), on("after")) + }; + // Little text: the minimum height. + let ((_, notes), (_, after)) = place("Short.
"); + assert!((notes.h - 108.0).abs() < 0.01, "{notes:?}"); + assert!((after.y - notes.bottom()).abs() < 0.01); + // Forty lines: the field holds them, and what follows moves down. + let long: String = (0..40).map(|i| format!("Line {i} of the notes.\n")).collect(); + let ((notes_page, grown), (after_page, after)) = place(&format!("
{long}
")); + assert!(grown.h > 40.0 * 11.0 && grown.h < 720.0, "{grown:?}"); + assert_eq!(notes_page, after_page); + assert!((after.y - grown.bottom()).abs() < 0.01, "{after:?} after {grown:?}"); + // A fixed height stays what it is. + let fixed = wrapped_form(&format!("
{long}
")) + .replace(r#"minH="1.5in"> + Head + + + "#; + let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap(); + assert_paginated(&form, 2, 2); + let below_head = |it: &&Item| match it { + Item::Fill { rect, .. } => rect.bottom() > 36.0 + 432.0 + 1.0, + Item::Line { from, to, .. } => from.1.max(to.1) > 36.0 + 432.0 + 1.0, + _ => false, + }; + assert_eq!(form.pages[0].items.iter().filter(below_head).count(), 0, "{:?}", form.pages[0].items); + assert!( + form.pages[1].items.iter().any(|it| matches!(it, Item::Fill { rect, .. } if (rect.y - 36.0).abs() < 0.01 && (rect.h - 360.0).abs() < 0.01)) + ); +} + +#[test] +fn text_too_long_for_its_fixed_height_is_drawn_smaller_instead_of_over_what_follows() { + let words = "certification statement wording ".repeat(12); + let body = format!( + r#"{words} + Short"# + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let spans: Vec<&crate::text::Span> = form.pages[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s) } else { None }).collect(); + let (short, note): (Vec<_>, Vec<_>) = spans.into_iter().partition(|s| s.text == "Short"); + // Three lines at 8pt need 27.6pt; the box is 21.6pt high. + assert!(note.iter().all(|s| s.face.size < 8.0 && s.face.size >= 5.6), "{:?}", note.iter().map(|s| s.face.size).collect::>()); + assert!(note.iter().all(|s| s.baseline <= 36.0 + 21.6 + 0.5), "{:?}", note.iter().map(|s| s.baseline).collect::>()); + assert!(note.iter().all(|s| s.x + s.face.width(&s.text) <= 36.0 + 540.0 + 0.5)); + // Text that fits keeps its size. + assert!((short[0].face.size - 8.0).abs() < 1e-9); +} + +#[test] +fn a_text_field_that_outgrows_a_page_continues_on_the_next_as_text() { + let long: String = (0..150).map(|i| format!("Line {i} of the notes.\n")).collect(); + let form = layout_xml(&wrapped_form(&format!("
{long}end
"))).unwrap(); + assert!(form.pages.len() >= 3, "{} pages", form.pages.len()); + assert!(form.warnings.iter().any(|w| w.starts_with("notes: its text is longer than a page")), "{:?}", form.warnings); + // Every line is there, in order, inside the content area of its page; no widget holds it. + let mut lines = Vec::new(); + for (i, p) in form.pages.iter().enumerate() { + for it in &p.items { + if let Item::Text(s) = it + && s.text.starts_with("Line ") + { + assert!(s.baseline > 36.0 && s.baseline <= 36.0 + 720.0 + 0.01, "page {}: {:?} at {}", i + 1, s.text, s.baseline); + lines.push(s.text.clone()); + } + } + assert!(widgets(p).iter().all(|w| w.name != "notes")); + } + assert_eq!(lines, (0..150).map(|i| format!("Line {i} of the notes.")).collect::>()); + // What follows comes after the last line, on the last page. + let last = form.pages.last().unwrap(); + let after = widgets(last).into_iter().find(|w| w.name == "after").unwrap(); + let last_line = last.items.iter().filter_map(|it| if let Item::Text(s) = it { Some(s.baseline) } else { None }).fold(0.0, f64::max); + assert!(after.rect.y >= last_line - 0.01 && after.rect.bottom() <= 36.0 + 720.0 + 0.01, "{:?} after {last_line}", after.rect); +} + +#[test] +fn the_form_packet_restores_the_presence_and_access_the_form_was_saved_with() { + // Three rows hidden in the template; a script showed the first two before the form was + // saved, and locked a field. + let row = |n: usize, presence: &str| { + format!( + r#""# + ) + }; + let body = format!( + r#"{}{}{}{}"#, + row(0, ""), + row(1, r#" presence="hidden""#), + row(2, r#" presence="hidden""#), + row(3, r#" presence="hidden""#) + ); + let template = one_area_form(r#"layout="tb""#, &body); + let visible = |xdp: &str| -> Vec { layout_xml(xdp).unwrap().pages.iter().flat_map(widgets).map(|w| w.som.clone()).collect() }; + assert_eq!(visible(&template), ["form[0].body[0].row0[0].who[0]", "form[0].body[0].rep[0].x[0]"]); + let saved = r#"
+ + + + + + + + + + + + +"#; + let xdp = template.replace("", saved); + assert_eq!( + visible(&xdp), + ["form[0].body[0].row0[0].who[0]", "form[0].body[0].row1[0].who[0]", "form[0].body[0].row2[0].who[0]", "form[0].body[0].rep[0].x[0]"], + "a later instance of a repeating subform says nothing about the template's one" + ); + let form = layout_xml(&xdp).unwrap(); + let locked: Vec = form.pages.iter().flat_map(widgets).map(|w| w.read_only).collect(); + assert_eq!(locked, [true, true, false, false]); +} + +#[test] +fn a_flowed_subform_is_as_tall_as_its_content_whatever_its_minimum_height() { + // A bar with a stale 3in `minH` holds one 0.2in line; the row after it follows directly. + let body = |bar: &str| { + format!( + r#"Attachments"# + ) + }; + let top = |bar: &str| { + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body(bar))).unwrap(); + let w = widgets(&form.pages[0]); + w[0].rect.y + }; + let flowed = top(r#"layout="lr-tb""#); + assert!((top(r#"layout="tb""#) - flowed).abs() < 0.01); + // A positioned subform keeps its minimum: nothing else says how tall it is. + assert!((top("") - flowed - (216.0 - 14.4)).abs() < 0.01, "{} against {flowed}", top("")); +} + +#[test] +fn the_form_packet_restores_a_caption_a_script_changed() { + let body = r#"