M10.5: Integrate main and adopt the validated craft-print spooler
This commit is contained in:
commit
981a2e57c8
|
|
@ -27,13 +27,13 @@ jobs:
|
|||
# and release.yml builds macOS (a failing release is fixed and rebuilt).
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# The optional craft-fonts build input (Japanese fonts), so the Japanese font tests run on
|
||||
# Linux; Windows tests the build without it. See craftrules standards/fonts.md.
|
||||
- name: Check out craft-fonts (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
|
|
@ -47,8 +47,9 @@ jobs:
|
|||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
|
||||
libgl1-mesa-dev libgtk-3-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Require Windows executable resources
|
||||
|
|
@ -56,7 +57,7 @@ jobs:
|
|||
run: Add-Content -Path $env:GITHUB_ENV -Value 'PDFCRAFT_REQUIRE_WINRES=1'
|
||||
shell: pwsh
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
|
||||
- name: Format
|
||||
if: runner.os == 'Linux'
|
||||
|
|
@ -91,11 +92,12 @@ jobs:
|
|||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: wasm32
|
||||
- name: Check library crates for wasm32
|
||||
|
|
@ -105,7 +107,7 @@ jobs:
|
|||
name: dependency licences and advisories
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||
with:
|
||||
command: check
|
||||
|
|
@ -115,9 +117,11 @@ jobs:
|
|||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: fuzz
|
||||
- name: Fetch the pdf.js corpus (seeds)
|
||||
|
|
@ -126,7 +130,7 @@ jobs:
|
|||
run: cargo xtask fuzz --time 900 --seed ${{ github.run_number }}
|
||||
- name: Upload findings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: fuzz-findings
|
||||
path: fuzz-out/findings
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 '<Icon Id="[^"]*"' packaging/windows/pdfcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error file=packaging/windows/pdfcraft.wxs::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.pdfcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.pdfcraft.metainfo.xml.in >"$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
|
||||
|
|
|
|||
|
|
@ -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<version>". 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: <bin>/../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: <bin>/../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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
|
||||
|
||||
## In this repository (264)
|
||||
## In this repository (265)
|
||||
|
||||
| Asset | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
@ -199,7 +199,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
|||
| `assets/icons/x.svg` | Lucide icon "x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/zoom-in.svg` | Lucide icon "zoom-in" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-in.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/zoom-out.svg` | Lucide icon "zoom-out" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-out.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/fonts/Inter-Regular.ttf` | Inter Regular | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels |
|
||||
| `assets/fonts/Inter-Regular.ttf` | Inter Regular | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths |
|
||||
| `assets/fonts/Inter-Medium.ttf` | Inter Medium | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels |
|
||||
| `assets/fonts/Inter-SemiBold.ttf` | Inter SemiBold | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono Regular | The JetBrains Mono Project Authors | OFL-1.1 | https://github.com/JetBrains/JetBrainsMono | UI monospace font; demo PDF code |
|
||||
|
|
@ -272,6 +272,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
|||
| `packaging/macos/dmg/background.svg` | PdfCraft macOS DMG window background | @XusBadia | MIT OR Apache-2.0 | Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied) | Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py |
|
||||
| `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile |
|
||||
| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
|
||||
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
|
||||
## Compiled in through dependencies (6)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = "1863437c886142b2bccce3f44a538ae80d0ceea81074b84dfdfe8d9a4a4227ab"
|
||||
|
||||
[[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 = "6c8604d5cd71412c6a673b75001c7ef629727839eca1449147c7d6ec4bf0be99"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/cs.tsv"
|
||||
|
|
@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
|
|||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "abd8d0c409be846fcf48cbc1338def7f77f2d301bd84d863e4ad66434e5f821f"
|
||||
sha256 = "5ae2e315a9b99b4c0dfaf7be019c4d430ace17b4fbbfea5a2779b3e972846a21"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pt-br.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 = "8b9e22fa25339e4aedb0542b8bf6f68dfd97bc3634105199e1951475e30eed53"
|
||||
|
||||
[[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 = "6d81592677f2b4eb3b97ba5810de9752da969545d91897e27033f37310696ffc"
|
||||
|
||||
[[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 = "6d99c6a85e69b5448609ae54847700c908919be27d904bd0c73bf2c220a2da4e"
|
||||
sha256 = "fb9c91a98c6975bc24249e8ae750fd46b9245ac5d2d7381f1e21319b9adbcaac"
|
||||
|
||||
[[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 = "f36aefae1a1ba6d576a4647fbfdd65b9b5086bfb266ea260e513244adc7af05a"
|
||||
|
||||
[[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 = "656940b456a6c2401f8bd3107a71cedcae5e52cd446e86e806285b6322d8ed8e"
|
||||
|
||||
[[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 = "8a7a2bfba1a2f9858ad62a74bd8229a1615a5bc6e51023b11b9768e7ea21d79c"
|
||||
sha256 = "14884c08a550600d310a794ceb0b2bb864987a074178ee49b657578b66625bef"
|
||||
|
||||
[[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 = "be960bdf9b6eeac2d95db91325076ee79c9b2efdcc4e459c7400e7ddb05fab3a"
|
||||
|
||||
[[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 = "dc0af15b819b9059bc23a73a1e794d1ae5291594b2ff0fae2595274fccc9c281"
|
||||
sha256 = "bada9b6f0b3775e4dab1213d9759070f7551d8a6142de10159268c88a04d3f0c"
|
||||
|
||||
[[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 = "efb75d8575df9e1dd4610602f20545b3a14a3a0814060efc27eb7cab591ccccb"
|
||||
|
||||
[[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 = "ca305b188abc8d9c261901dde10f7cd8518520983fdf88d2358374b352ad3d5a"
|
||||
|
||||
[[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 = "f004b06f4e7eb8f4a99e22e1abfd97c95795b2215080e4008574522e54eda74c"
|
||||
|
||||
[[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 = "cd9ecaf661533cbd3b055cfd3994602f6739c83061d1e225ba43ee6f229068cf"
|
||||
|
||||
[[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 = "a8a66390b2d80b4708c4f854318b923eef7f0f5932439fb5ef25f2651980f24e"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
|
|
@ -2119,7 +2119,7 @@ source = "https://github.com/rsms/inter"
|
|||
licence = "OFL-1.1"
|
||||
licence_file = "assets/fonts/OFL-Inter.txt"
|
||||
kind = "font"
|
||||
usage = "UI font, embedded in the app; demo PDF labels"
|
||||
usage = "UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths"
|
||||
sha256 = "40d692fce188e4471e2b3cba937be967878f631ad3ebbbdcd587687c7ebe0c82"
|
||||
|
||||
[[asset]]
|
||||
|
|
@ -3285,3 +3285,14 @@ licence_file = "LICENSE-MIT"
|
|||
kind = "document"
|
||||
usage = "Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format"
|
||||
sha256 = "b77bfb21166f4bd043597e45a1ea28b9d9f4ba6df0142b3a0fc8b7f8e0fc111b"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/monitor.svg"
|
||||
title = "Lucide icon \"monitor\""
|
||||
author = "Lucide Contributors (portions Cole Bemis, Feather)"
|
||||
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg)"
|
||||
licence = "ISC"
|
||||
licence_file = "assets/icons/LICENSE-lucide.txt"
|
||||
kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
|
||||
sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428"
|
||||
|
|
|
|||
|
|
@ -1093,7 +1093,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=da752825c70574a2423ee55427f8f7aa5b5f8ec8#da752825c70574a2423ee55427f8f7aa5b5f8ec8"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=32bcac12db98113d18d8f56bc47fb6ddabe4d54f#32bcac12db98113d18d8f56bc47fb6ddabe4d54f"
|
||||
dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
|
@ -1749,7 +1749,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3572,6 +3572,8 @@ dependencies = [
|
|||
"serde_json",
|
||||
"uds_windows",
|
||||
"ureq",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
]
|
||||
|
|
@ -3598,6 +3600,17 @@ dependencies = [
|
|||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-audit"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-edit",
|
||||
"pdfcraft-model",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-automation"
|
||||
version = "0.6.0"
|
||||
|
|
@ -3620,6 +3633,7 @@ name = "pdfcraft-cli"
|
|||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"pdfcraft-audit",
|
||||
"pdfcraft-automation",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-engine",
|
||||
|
|
@ -3703,6 +3717,7 @@ dependencies = [
|
|||
"image",
|
||||
"pdfcraft-a11y",
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-audit",
|
||||
"pdfcraft-compare",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-create",
|
||||
|
|
@ -3710,6 +3725,7 @@ dependencies = [
|
|||
"pdfcraft-export",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-forms",
|
||||
"pdfcraft-geom",
|
||||
"pdfcraft-js",
|
||||
"pdfcraft-measure",
|
||||
"pdfcraft-model",
|
||||
|
|
@ -3833,6 +3849,8 @@ dependencies = [
|
|||
name = "pdfcraft-platform"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
|
|
@ -4889,7 +4907,7 @@ dependencies = [
|
|||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5512,10 +5530,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5783,7 +5801,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6475,7 +6493,7 @@ version = "0.1.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ repository = "https://github.com/storytold/pdfcraft"
|
|||
# Shared frontend components; engines remain independent of egui.
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# Shared system spooler; document composition remains in pdfcraft-print.
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "da752825c70574a2423ee55427f8f7aa5b5f8ec8" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "32bcac12db98113d18d8f56bc47fb6ddabe4d54f" }
|
||||
# internal
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.6.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.6.0" }
|
||||
|
|
@ -24,6 +24,7 @@ pdfcraft-crypt = { path = "crates/crypt", version = "0.6.0" }
|
|||
pdfcraft-organize = { path = "crates/organize", version = "0.6.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.6.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.6.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.6.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.6.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.6.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.6.0" }
|
||||
|
|
@ -91,6 +92,11 @@ debug = "line-tables-only"
|
|||
[profile.dev]
|
||||
opt-level = 1
|
||||
|
||||
[profile.low-disk]
|
||||
inherits = "dev"
|
||||
debug = "line-tables-only"
|
||||
incremental = false
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
|
|
|||
|
|
@ -372,6 +372,7 @@ PdfCraft is a clean-room implementation. Its behaviour comes from the ISO 32000
|
|||
```sh
|
||||
git clone https://github.com/storytold/pdfcraft
|
||||
cd pdfcraft
|
||||
cargo xtask models # fetch the OCR models Scan & OCR needs (release builds include them)
|
||||
cargo run --release -p pdfcraft -- some.pdf # desktop app
|
||||
cargo xtask demo-pdf # build the showcase PDF used in these screenshots
|
||||
cargo xtask screenshots # regenerate every screenshot in this README
|
||||
|
|
@ -412,14 +413,14 @@ Logs, environment variables and other development notes are in [docs/development
|
|||
|
||||
PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, at parity with Acrobat Pro.
|
||||
|
||||
**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most of Acrobat Pro's offline features exist in some form (57% of all tracked features, 94% of the must-haves). For everyday work (viewing, commenting, filling and signing forms, organizing pages) it is about 55% of the way to replacing Acrobat; against all of Acrobat Pro, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: password-protected files Acrobat can't open, no Office-to-PDF, and Word export that loses layout.
|
||||
**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most of Acrobat Pro's offline features exist in some form (57% of all tracked features, 94% of the must-haves). For everyday work (viewing, commenting, filling and signing forms, organizing pages) it is about 55% of the way to replacing Acrobat; against all of Acrobat Pro, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no Office-to-PDF, and Word export that loses layout.
|
||||
|
||||
- **Good today:** viewing and search; organizing, combining and splitting; most kinds of comment; filling and authoring forms (with sandboxed JavaScript); passwords, redaction and sanitizing; basic digital signatures; printing; the Accessibility Checker; agent control through the CLI and MCP.
|
||||
- **Still borrowed:** pages are drawn by the `hayro` crate while our own renderer is built.
|
||||
- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, physical printer fidelity, and full XFA forms.
|
||||
- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms.
|
||||
- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been compared with Acrobat side by side.
|
||||
|
||||
**Next, in order:** encrypted files that Acrobat opens, the reported bugs and physical printer validation, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish.
|
||||
**Next, in order:** Office-to-PDF and a Word export that keeps layout, the reported bugs, web printing and the Chinese interface font, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish.
|
||||
|
||||
The summary is in **[ROADMAP.md](ROADMAP.md)**; the full assessment by dimension and area is in [docs/target-app-parity.md](docs/target-app-parity.md), the ranked gaps in [docs/gaps.md](docs/gaps.md), and the milestones in [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
|
|
|
|||
46
ROADMAP.md
46
ROADMAP.md
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI enabled) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared craft-print spooler with passing Windows CI; architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → 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 (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
|
||||
PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what
|
||||
comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI
|
||||
|
|
@ -40,17 +42,17 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i
|
|||
**Why alpha:** all six of Acrobat Pro's everyday workflows can be completed end to end on macOS
|
||||
(the [alpha gate](docs/roadmap.md#alpha-gate)), and users praise them (7.6k stars, ≈ 217k downloads
|
||||
of 0.4–0.5, switching reports such as #179). Two are only partial: editing (no CJK/RTL) and convert
|
||||
and share (password-protected files we write don't open in Acrobat or Reader, #774; no in-app
|
||||
Office→PDF; Word export loses layout, #773). Those, unmeasured quality against Acrobat, the borrowed
|
||||
`hayro` renderer, unvalidated physical printer output (#756) and 245 open issues keep it far from beta.
|
||||
and share (no in-app Office→PDF; Word export loses layout, #773; password-protected files open in
|
||||
Acrobat since #836, #774). Those, unmeasured quality against Acrobat, the borrowed `hayro` renderer,
|
||||
no printing on the web and 245 open issues keep it far from beta.
|
||||
|
||||
### Readiness by audience
|
||||
|
||||
| Audience | Ready | Opus 5.5 agent-hours to ≈ 95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| Full Acrobat Pro (ready for real work) | ≈ 49% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions |
|
||||
| Mainstream practitioner | ≈ 55% | ≈ 500–900 h (≈ 200–400 h wall clock; ≈ 60% parallelizes) | Rendering fidelity, editing existing text (CJK, RTL, subsets), conversion (Word layout, Excel, PowerPoint, Office→PDF), OCR languages, Windows print fidelity, the bug backlog |
|
||||
| Essentials user | ≈ 66% | ≈ 180–330 h (≈ 90–180 h wall clock; ≈ 50% parallelizes) | Rendering fidelity on everyday files, Windows print fidelity, launch and GPU stability, encrypted output Acrobat opens (#774), simple text fixes, Word export layout, Chinese UI glyphs |
|
||||
| Mainstream practitioner | ≈ 55% | ≈ 500–900 h (≈ 200–400 h wall clock; ≈ 60% parallelizes) | Rendering fidelity, editing existing text (CJK, RTL, subsets), conversion (Word layout, Excel, PowerPoint, Office→PDF), OCR languages, the bug backlog |
|
||||
| Essentials user | ≈ 66% | ≈ 180–330 h (≈ 90–180 h wall clock; ≈ 50% parallelizes) | Rendering fidelity on everyday files, launch and GPU stability, simple text fixes, Word export layout, Chinese UI glyphs |
|
||||
|
||||
Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#effort-and-calibration) (≈ 1k kept lines per agent-hour for new features, 3–10× slower for hardening and fidelity). The audiences are nested subsets, so essentials ≤ mainstream ≤ full.
|
||||
|
||||
|
|
@ -121,8 +123,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
|||
|
||||
| Rank | Milestone | Estimate (h) |
|
||||
|---|---|---|
|
||||
| 0 | Convert and share: encrypted output opens in Acrobat (#774), Create PDF from Office, Word export that keeps layout (#773) | 40–70 |
|
||||
| 1 | Backlog and platform: crash and wrong-result reports, physical printer validation (#756), Chinese UI face (#826) | 65–120 |
|
||||
| 0 | Convert and share: Create PDF from Office, Word export that keeps layout (#773) | 32–55 |
|
||||
| 1 | Backlog and platform: crash and wrong-result reports, web printing, Chinese UI face (#826) | 65–120 |
|
||||
| 2 | Fidelity harness against Acrobat and the renderer decision (M2; owner decision on #841) | 110–190 |
|
||||
| 3 | Editing existing content (M7) | 100–180 |
|
||||
| 4 | OCR languages and Office export/import (M10) | 95–175 |
|
||||
|
|
@ -133,9 +135,32 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
|||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-11 (M10.5, focused follow-up to #599):** Carry the architectural/ANSI/large ISO paper presets, current-page matching at 100%, and previewed PDF scale into the current print dialog and `doc_print`. Retains the #847/#1007 spooler and background discovery. Driver substitution, physical calibration, vector Windows printing and managed-endpoint restrictions remain separate gaps. Overall estimates unchanged.
|
||||
|
||||
Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
- **2026-10-11 (M10.5):** Extracted printer discovery, options and PDF submission into craft-libs/craft-print, shared with DesignCraft. Windows preserves composed sheet size and orientation without fitting into default margins; duplex binding edges are corrected. PdfCraft retains permission checks and imposition. Shared transport and local composition, engine, automation and UI coverage validate the boundary; native Windows regression execution is pending; physical printer fidelity remains a hardware check. Rust 1.95 is required by the shared crate. No readiness percentage change.
|
||||
- **2026-10-11 (M10.5):** Extracted printer discovery, options and PDF submission into craft-libs/craft-print, shared with DesignCraft. Windows preserves composed sheet size and orientation without fitting into default margins; duplex binding edges are corrected. PdfCraft retains permission checks and imposition. Shared transport and local composition, engine, automation and UI coverage validate the boundary; native Windows CI passes, including paused-queue submission and cleanup; physical printer fidelity remains a hardware check. Rust 1.95 is required by the shared crate. No readiness percentage change.
|
||||
- **2026-10-11 (#995, #1077, permissions password after opening):** A PDF with only a permissions password opens without asking, restricted, and nothing let the user enter that password afterwards: the notice and the hint pointed at reopening with it. Now the security notice and Properties ▸ Security offer Unlock…, and Change settings / Remove security on a restricted document ask for the permissions password first and then carry on (as Acrobat does). The open password is refused there. `Session::unlock` upgrades the open document in place (owner and user passwords unlock the same file key, so nothing is re-read), unlocks the undo and redo states and reopens saves as owner; it is not an edit and doesn't mark the document unsaved. New command `protect.unlock`; the new strings are in all 15 full catalogs. Tests cover crypt, cos, engine (undo, redo, save, separate open password) and the UI. Still missing: an automation tool for it. Estimates unchanged.
|
||||
- **2026-10-11 (M14, #307 Plan 3):** Engine open and full refresh use one page/content traversal to populate marks and added content. The independent public scans remain available; the combined scanner borrows direct objects and holds referenced objects through `Arc<Object>` instead of cloning scan-only `/Contents` arrays or `/PCAdded` dictionaries. Differential tests cover an indirect `/PCAdded` dictionary as well as ordering, malformed/direct/indirect/repeated entries and `/UserUnit`. Exact `bbbe600`→final release CLI measurements used 10 alternating pairs: wall-time medians were 183.645→174.440 ms on 10,000 marker-free pages (10/10 wins), 33.587→32.752 ms on 1,500 mixed pages (8/10), and 24.331→20.133 ms on the 100,000-entry array stress fixture (10/10). The stress fixture's maximum-RSS median fell 28,655,616→19,038,208 B; peak-footprint medians were 14,747,136→14,673,408 B. From P0, the follow-up stress run was 20.886→20.201 ms (10/10), with RSS 23,953,408→19,087,360 B and peak footprint 14,706,144→14,697,952 B. `doc_open` stdout hashes matched every pair. The ancillary `organize` binding preserves the prior `prune_legacy` update condition. Estimates unchanged.
|
||||
- **2026-10-11 (M4, Organize overview):** Opening Organize pages from All tools, Home, the tool palette or `ui.set tool=organize` opens the thumbnail grid directly. Checkboxes toggle individual pages without modifier keys; page controls rotate either way or delete one page, while the toolbar acts on the selection. Portrait and landscape previews retain their aspect ratio. UI and control regressions cover entry, selection, single-page versus batch actions, undo, permissions and retaining the last page. Milestone and effort estimates unchanged.
|
||||
|
||||
- **2026-10-10 (M9, signature graphics):** A visible digital signature can show a graphic where the signer's name is large, as Acrobat's Configure Signature Appearance does: the saved Fill & Sign signature (drawn strokes smoothed as ink, typed outlines in the script font, or an image) or an imported PNG/JPEG. Sign as previews it and remembers the choice for the next signing; `sign_document` takes `graphic` (`image`, `text` or `strokes`). Graphics are checked and capped before drawing (non-finite or unbounded points, mismatched pixel buffers, oversized images are refused with an error the caller can act on); images keep their alpha as a soft mask. `sign.signature-appearance` stays partial (no named saved appearances or PDF-page graphics). Overall estimates unchanged.
|
||||
- **2026-10-11 (M14, absolute interface sizes):** A chosen Interface size is now absolute, as PhotoCraft's UI Scale is: "100%" is one screen pixel per point whatever scale the display reports, so on a 1080p screen whose system reports 200 % it draws at a true 100 % (#456, reported after the first change). Auto still keeps the display's scale and adds GNOME's text scaling, and its menu entry shows the size it draws at. The menu offers PhotoCraft's sizes (75–300 %).
|
||||
- **2026-10-11 (automation, #971):** `text_find` reads pages in growing batches (8, doubling to 128) and stops once `limit` is reached, so a bounded search on a long document no longer extracts and caches every page first. Results say `complete`, and a capped one gives `next` (`start_page`, `skip`) to continue. Still open from #971: a byte budget for the automation text cache and per-page warnings for pages whose text can't be extracted.
|
||||
- **2026-10-11 (docs, closed issues):** The progress docs caught up with issues closed on 2026-10-10: encrypted output opens in Acrobat (#774, #836; gap 1 closed, rank 0 is 32–55 h), Windows prints (#756, #847), column select (#740), 1:1 touchpad scrolling (#759), same-page multi-object move (#844), and the #799–#821 batch of wrong-result fixes including the #816 panic. Scores and readiness not re-judged; overall estimate unchanged.
|
||||
- **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-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-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-11 (M14, #307, Plan 2):** Forms now return terminal fields and the page-widget adoption count from one enumeration, and engine open reuses that result while retaining XFA script-button marking. The 12,000-page/no-form PDF (1,442,052 bytes) and 2,500-orphan-widget PDF (673,432 bytes) produce byte-identical `doc_open`/`doc_info` outputs against upstream `33c14e07cfd742a82bf03baa6b103c01008fa68c`, including the orphan repair note. After one warm-up per binary and three interleaved base/candidate rounds, median open times were 0.16/0.15 s and 0.08/0.07 s, with median peak RSS 62.69/59.98 MiB and 33.28/32.20 MiB. Workspace tests, Clippy, formatting, focused forms/engine/organize tests, and the WASM check pass. A behavior-preserving local binding in `organize` also lets the workspace Clippy gate pass. No estimate change.
|
||||
- **2026-10-11 (M14, Traditional Chinese semantics):** Corrected accessibility pass/fail labels, the inverted script-accessibility rule, Taiwan row/column terminology and form choice/read-only labels; added the missing table-row explanation. Catalog regressions cover the rule metadata and preserve field names. 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.
|
||||
|
|
@ -145,6 +170,8 @@ Newest first: the date, what moved, and the new overall estimate.
|
|||
- **2026-10-10 (M12, XFA rows and areas):** Dynamic XFA forms lay out a repeating subform inside a top-to-bottom container in positioned content (a table in an area) with all its instances, drawn and measured, instead of one; and areas no longer appear in data paths (`pdfcraft_xfa::data_path`), since they are not data scopes. Before, a form whose fields sit in an area read none of the values Adobe's viewers saved, wrote what was filled here under a new element named after the area, which Adobe's viewers would most likely not read, and a FormCalc total looping over the table's rows failed on the rows that were missing. Layout, the script tree, reading and writing the data and the engine's value and row operations share the one path rule. Synthetic tests cover the layout, the path rule and the whole round trip (rows, Adobe-written values, a FormCalc total over all rows, the written data, reopening). Still open for such forms: XFA script objects (`<variables><script>`), which aren't supported. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
- **2026-10-11 (M10, Windows printing follow-up to #847):** Windows print jobs print each sheet at 100% on the driver's paper closest to the sheet's size, landscape for wide sheets; they had been shrunk into .NET's default 1-inch margins (about 76% on Letter) and not rotated. Sheets are capped at about 64 megapixels so a poster-sized sheet can't exhaust memory. PowerShell runs without a console window, and the Print dialog lists printers and spools jobs off the UI thread (the desktop app's `printer_source`), so neither Ctrl+P nor a long job freezes the app. Kittest regression for the background printer list; the Windows rendering path is still checked only by the Windows CI job. Estimates unchanged.
|
||||
- **2026-10-10 (M3, System appearance completion, #446/#881):** The Linux portal introduced by #881 now uses the standard numeric appearance setting and event-driven updates, with idle-window wakeups for initial and changed results. When that setting states no preference it falls back to the GNOME schema's `color-scheme`, which XFCE's portal answers instead. Native windows retain inherited appearance and both egui style branches use the selected palette. Manual choices and the Light default remain; unknown System appearance resolves to Light. Controlled protocol and UI checks cover the remaining gaps; the supplied EndeavourOS/GNOME report has not been reproduced on that desktop. Estimates unchanged.
|
||||
|
||||
- **2026-10-10 (M9, #179 follow-up):** A Windows store certificate that can't sign is no longer left out of the Sign dialog silently: `windows::list` names it with the reason and Windows error code (no private key, a P-521/Brainpool/Ed25519 key, a key CNG can't open), the picker shows it greyed and unselectable, and `sign_windows_ids` returns it under `unusable`. A key that needs a permission prompt to open is listed instead of skipped. Reported by a user whose CA-issued identity showed in Acrobat but not here; a plain .p12 import into the CNG and legacy Microsoft providers was verified to list, so the next report will carry the reason. Store test adds a key-less certificate; overall estimate unchanged (about 30–35%).
|
||||
- **2026-10-10 (M10.2, JPEG scanning follow-up to #729):** Skip marker fill bytes and keep reading JFIF, APP14 and ICC metadata after the frame header, stopping before scan data or at EOI. Invalid segment lengths return errors; damaged or mismatched ICC profiles retain #729's device-space fallback. Synthetic regression cases added but not run at the contributor's request. Estimates unchanged.
|
||||
- **2026-10-10 (M13, desktop action outputs):** Desktop Action Wizard keeps every result when input basenames repeat, or when the destination already has a result from an earlier run. It preserves the original filename and extension for a free name and adds numbered suffixes for occupied names; the bounded search returns an error rather than reusing an occupied path. A real-shell regression drives Start twice and reopens the three-page and one-page results, checking that the original inputs and earlier output remain intact. This covers the desktop writer separately from the CLI/MCP action_run repair in #877. Milestone and effort estimates unchanged.
|
||||
|
|
@ -319,6 +346,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | Recorded the remaining System appearance repair after #881; estimates unchanged |
|
||||
| 2026-10-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; method aligned with the standard, no new evidence) |
|
||||
| 2026-10-10 | minor | Essentials-user score added (≈ 66%) |
|
||||
| 2026-10-10 | minor | Gate consistency check: convert and share re-judged partial, not blocking; stage pre-alpha → alpha |
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ workspace = true
|
|||
pdfcraft-render = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
pdfcraft-engine = { workspace = true }
|
||||
pdfcraft-audit = { workspace = true }
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-automation = { workspace = true }
|
||||
base64 = "0.22"
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ pdfcraft-cli combine <a.pdf> <b.pdf> … --out combined.pdf
|
|||
pdfcraft-cli extract <in.pdf> --pages 1,3,5 --out out.pdf
|
||||
pdfcraft-cli split <in.pdf> (--every N | --before 3,7) [--out-dir DIR]
|
||||
pdfcraft-cli check <files or dirs…> [--timeout 20] [--dpi 36] [--json out.json]
|
||||
pdfcraft-cli audit <file.pdf> [--password PW] find faux redactions (fails when found)
|
||||
pdfcraft-cli tools automation tools and their JSON Schemas
|
||||
pdfcraft-cli run <tool> [key=value …] [--root DIR] [--out image.png]
|
||||
pdfcraft-cli run --script steps.json [--root DIR] [{\"tool\": \"doc_open\", \"args\": {…}}, …]
|
||||
|
|
@ -85,6 +86,7 @@ fn main() -> ExitCode {
|
|||
Some("split") => split(&args[1..]),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("check-one") => check_one(&args[1..]),
|
||||
Some("audit") => audit(&args[1..]),
|
||||
Some("tools") => tools(),
|
||||
Some("run") => run(&args[1..]),
|
||||
Some("ui") => ui(&args[1..]),
|
||||
|
|
@ -146,6 +148,28 @@ fn version() -> Result<(), CliError> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are
|
||||
/// found so scripts and CI can gate on it, and when the audit itself cannot run.
|
||||
fn audit(args: &[String]) -> Result<(), CliError> {
|
||||
let path = *positional(args).first().ok_or("audit: missing file")?;
|
||||
let bytes = read(path)?;
|
||||
let doc = pdfcraft_cos::Document::open_with_password(bytes, flag(args, "--password")).map_err(|e| format!("{path}: {e}"))?;
|
||||
let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?;
|
||||
let items: Vec<serde_json::Value> = findings
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
serde_json::json!({
|
||||
"page": f.page + 1,
|
||||
"kind": f.kind.id(),
|
||||
"rect": f.rect,
|
||||
"covered_text": f.covered_text,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!("{}", serde_json::json!({ "file": path, "findings": items, "count": items.len() }));
|
||||
if items.is_empty() { Ok(()) } else { Err(format!("audit: {} faux redaction(s) found", items.len()).into()) }
|
||||
}
|
||||
|
||||
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
|
||||
}
|
||||
|
|
@ -156,15 +180,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 {
|
||||
|
|
|
|||
|
|
@ -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::<serde_json::Value>(&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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<u64, String> {
|
||||
let conn = Connection::connect_to_env().map_err(|e| format!("no Wayland compositor: {e}"))?;
|
||||
let (globals, mut queue) = registry_queue_init::<Feedback>(&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<u8>| 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<Vec<u8>>,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Dispatch<WlRegistry, GlobalListContents> for Feedback {
|
||||
fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle<Self>) {}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Dispatch<ZwpLinuxDmabufV1, ()> 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<Self>) {}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Dispatch<ZwpLinuxDmabufFeedbackV1, ()> for Feedback {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &ZwpLinuxDmabufFeedbackV1,
|
||||
event: zwp_linux_dmabuf_feedback_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
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
|
||||
/// `<sys_dev_char>/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<u32> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,10 +4,10 @@
|
|||
//! `--create-images [images…]` stages the images in one PDF and asks for the page DPI.
|
||||
//!
|
||||
//! View options (applied after the files open; also the seed of the UI control channel):
|
||||
//! `--page N --zoom 150 --layout continuous|two-up|single --panel comments|bookmarks|pages|fields|layers|attachments|none
|
||||
//! `--page N --zoom 150 --layout single|continuous|two-up|two-page --continuous on|off --panel comments|bookmarks|pages|fields|layers|attachments|none
|
||||
//! --theme light|dark|system --language auto|<code> --mode all|read|edit|convert|sign --tool <catalogue id> --left open|closed
|
||||
//! --organize on --fields on --dialog properties|shortcuts|about --palette <query> --home on
|
||||
//! --cover on|off --default-layout continuous|two-up|single --default-zoom fit-width|fit-page|<percent>
|
||||
//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page|<percent>
|
||||
//! --combine-view grid|list --combine-zoom 60..200`
|
||||
//!
|
||||
//! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files
|
||||
|
|
@ -31,7 +31,9 @@ use pdfcraft_ui_egui::PdfCraftApp;
|
|||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod compositor_gpu;
|
||||
mod logging;
|
||||
mod reopen;
|
||||
mod single_instance;
|
||||
mod text_scale;
|
||||
mod updates;
|
||||
|
|
@ -299,6 +301,12 @@ fn app_creator<'a>(
|
|||
Box::new(move |cc| {
|
||||
started.set(true);
|
||||
let mut app = PdfCraftApp::new();
|
||||
// First start (or settings from before Units existed): the region's unit.
|
||||
app.units = pdfcraft_ui_egui::units::Unit::system();
|
||||
#[cfg(target_os = "linux")]
|
||||
app.enable_desktop_theme();
|
||||
// The system's reopen-windows choice (macOS) starts the preference; a saved choice replaces it.
|
||||
app.reopen_last_session = reopen::system_default();
|
||||
if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) {
|
||||
app.restore(&json);
|
||||
}
|
||||
|
|
@ -458,6 +466,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 +489,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 +500,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 +633,12 @@ fn linux_display_gpus(drm: &std::path::Path) -> Vec<DisplayGpu> {
|
|||
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<usize> {
|
||||
/// 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<usize> {
|
||||
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 +659,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)
|
||||
}
|
||||
|
|
@ -928,6 +941,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
|
||||
|
|
@ -967,17 +1010,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 +1052,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 +1064,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]
|
||||
|
|
@ -1152,4 +1210,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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
//! The system's choice for Preferences ▸ "Reopen the files that were open when PdfCraft last closed".
|
||||
//!
|
||||
//! On macOS, System Settings ▸ Desktop & Dock ▸ Windows ▸ "Close windows when quitting an
|
||||
//! application" is on by default; turning it off stores `NSQuitAlwaysKeepsWindows = 1`, and apps
|
||||
//! then reopen their windows on the next launch. A user who turned it off expects PdfCraft to
|
||||
//! bring its documents back too (#871), so that setting is the preference's starting value. It
|
||||
//! is read once at launch through `defaults`; the user's own choice in Preferences, once saved,
|
||||
//! wins over it. Other systems have no such setting, and the preference starts off there.
|
||||
|
||||
/// How long `defaults` may take before launch goes on without it.
|
||||
#[cfg(target_os = "macos")]
|
||||
const TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
|
||||
/// Whether reopening the last session starts on, before the saved preferences are applied.
|
||||
pub fn system_default() -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(output) = defaults(&["read", "-g", "NSQuitAlwaysKeepsWindows"]) {
|
||||
return keeps_windows(&output);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `defaults read` output for a boolean (`1`, `0`, `true`, `false`): true only when it says so.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn keeps_windows(output: &str) -> bool {
|
||||
matches!(output.trim(), "1" | "true" | "yes" | "YES")
|
||||
}
|
||||
|
||||
/// Run `defaults` and return what it printed, or `None` if it fails or hangs. Fails when the key
|
||||
/// was never set, which means the system default: close windows when quitting.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn defaults(args: &[&str]) -> Option<String> {
|
||||
use std::io::Read;
|
||||
use std::process::{Command, Stdio};
|
||||
// The absolute path keeps a `defaults` earlier on PATH from running.
|
||||
let mut child = Command::new("/usr/bin/defaults").args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?;
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) if status.success() => break,
|
||||
Ok(None) if start.elapsed() < TIMEOUT => std::thread::sleep(std::time::Duration::from_millis(5)),
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = String::new();
|
||||
// A boolean fits in far less; a misbehaving `defaults` can't make launch read without end.
|
||||
child.stdout.take()?.take(256).read_to_string(&mut out).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn only_a_set_flag_keeps_windows() {
|
||||
for on in ["1\n", "1", "true", "YES"] {
|
||||
assert!(super::keeps_windows(on), "{on:?}");
|
||||
}
|
||||
for off in ["0\n", "0", "false", "NO", "", "garbage"] {
|
||||
assert!(!super::keeps_windows(off), "{off:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_the_system_default_never_fails() {
|
||||
// Whatever this machine is set to, the read returns a value within the timeout.
|
||||
let _ = super::system_default();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 346 B |
|
|
@ -74,3 +74,32 @@ sync_github_name = true
|
|||
|
||||
[people.jay-tau]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Piohrun]
|
||||
real_name = "Grzegorz Martens"
|
||||
sync_github_name = true
|
||||
|
||||
[people.krinry]
|
||||
sync_github_name = true
|
||||
|
||||
|
||||
[people.arcanii]
|
||||
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
|
||||
|
||||
[people.mhadidi06]
|
||||
display_name = "Mustafa Hadidi"
|
||||
|
||||
[people.LucasT-dev]
|
||||
real_name = "Lucas"
|
||||
display_name = "Lucas"
|
||||
sync_github_name = true # use the name on your public GitHub profile as display name
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Layer L4: accessibility (execution plan M12.3). Today: the Accessibility Checker
|
|||
|
||||
```rust
|
||||
let report = check(&doc, &Options::default()); // 31 of 32 rules (colour contrast off)
|
||||
let html = report_html(&report, "file.pdf", "2026-10-02");
|
||||
let html = report_html(&report, "file.pdf", "2026-10-02", "en", &in_english); // or an interface's words
|
||||
```
|
||||
|
||||
- **32 rules in 7 categories**, as in Acrobat's full check: Document (permission flag,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
//! The Document, Page Content and Forms rules: catalog settings, page content (tagged or
|
||||
//! artifact, text, images, fonts) and annotations.
|
||||
//!
|
||||
//! Every sentence a rule here can say is listed in [`crate::WORDINGS`], so interfaces can say it in
|
||||
//! their own language; a test in this crate keeps that list complete.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
use pdfcraft_model::Page;
|
||||
|
||||
use crate::structure::Tree;
|
||||
use crate::{Finding, Rule};
|
||||
|
||||
/// What the page content holds, per checked page.
|
||||
|
|
@ -145,8 +147,8 @@ fn text_of(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
|
|||
d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn doc_finding(message: impl Into<String>) -> Vec<Finding> {
|
||||
vec![Finding { page: None, message: message.into() }]
|
||||
fn doc_finding(sentence: &'static str) -> Vec<Finding> {
|
||||
vec![Finding::plain(None, sentence)]
|
||||
}
|
||||
|
||||
pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &Scan) -> Vec<Finding> {
|
||||
|
|
@ -168,10 +170,10 @@ pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &S
|
|||
let marked = cat.get(b"MarkInfo").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().and_then(|d| d.get(b"Marked").cloned()));
|
||||
let mut out = Vec::new();
|
||||
if !cat.contains(b"StructTreeRoot") {
|
||||
out.push(Finding { page: None, message: "The document has no tags (structure tree)".into() });
|
||||
out.push(Finding::plain(None, "The document has no tags (structure tree)"));
|
||||
}
|
||||
if !matches!(marked, Some(Object::Bool(true))) {
|
||||
out.push(Finding { page: None, message: "The document isn't marked as tagged (MarkInfo Marked)".into() });
|
||||
out.push(Finding::plain(None, "The document isn't marked as tagged (MarkInfo Marked)"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -186,7 +188,7 @@ pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &S
|
|||
let mut out = Vec::new();
|
||||
let info = doc.trailer().get(b"Info").map(|i| doc.resolve(i)).and_then(|i| i.as_dict().cloned()).unwrap_or_default();
|
||||
if text_of(doc, &info, b"Title").is_none() {
|
||||
out.push(Finding { page: None, message: "The document has no title".into() });
|
||||
out.push(Finding::plain(None, "The document has no title"));
|
||||
}
|
||||
let shows = cat
|
||||
.get(b"ViewerPreferences")
|
||||
|
|
@ -194,13 +196,17 @@ pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &S
|
|||
.and_then(|v| v.as_dict().and_then(|d| d.get(b"DisplayDocTitle").map(|x| matches!(&*doc.resolve(x), Object::Bool(true)))))
|
||||
.unwrap_or(false);
|
||||
if !shows {
|
||||
out.push(Finding { page: None, message: "The window shows the file name, not the title (Initial View > Show)".into() });
|
||||
out.push(Finding::plain(None, "The window shows the file name, not the title (Initial View > Show)"));
|
||||
}
|
||||
out
|
||||
}
|
||||
Rule::Bookmarks => {
|
||||
let has = cat.get(b"Outlines").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().map(|d| d.contains(b"First"))).unwrap_or(false);
|
||||
if pages.len() > 20 && !has { doc_finding(format!("{} pages and no bookmarks", pages.len())) } else { Vec::new() }
|
||||
if pages.len() > 20 && !has {
|
||||
vec![Finding::worded(None, "{n} pages and no bookmarks", vec![("{n}", pages.len().to_string())])]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
|
|
@ -212,9 +218,8 @@ fn subtype_name(d: &Dict) -> String {
|
|||
String::from_utf8_lossy(d.name(b"Subtype").unwrap_or(b"Unknown")).into_owned()
|
||||
}
|
||||
|
||||
pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usize], scan: &Scan, tree: &Tree) -> Vec<Finding> {
|
||||
pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usize], scan: &Scan, tagged: bool) -> Vec<Finding> {
|
||||
let mut out = Vec::new();
|
||||
let tagged = tree.exists;
|
||||
let annots = |p: &Page| -> Vec<(ObjRef, Dict)> {
|
||||
let a = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
|
||||
a.iter()
|
||||
|
|
@ -228,7 +233,10 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
|
|||
return doc_finding("The document isn't tagged, so none of its content is");
|
||||
}
|
||||
for (p, n) in &scan.untagged {
|
||||
out.push(Finding { page: Some(*p), message: format!("Page {}: {n} untagged content item{}", p + 1, if *n == 1 { "" } else { "s" }) });
|
||||
// One item and several items are separate wordings, so a language can agree the
|
||||
// noun with the number its own way.
|
||||
let pattern = if *n == 1 { "Page {p}: {n} untagged content item" } else { "Page {p}: {n} untagged content items" };
|
||||
out.push(Finding::worded(Some(*p), pattern, vec![("{p}", (p + 1).to_string()), ("{n}", n.to_string())]));
|
||||
}
|
||||
}
|
||||
Rule::TaggedAnnotations | Rule::TaggedMultimedia | Rule::TaggedFormFields => {
|
||||
|
|
@ -242,8 +250,17 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
|
|||
_ => st != b"Widget" && !MULTIMEDIA.contains(&st) && st != b"PrinterMark",
|
||||
};
|
||||
if applies && !(tagged && a.contains(b"StructParent")) {
|
||||
let what = if st == b"Widget" { "Form field".to_string() } else { format!("{} annotation", subtype_name(&a)) };
|
||||
out.push(Finding { page: Some(p), message: format!("{what} on page {} isn't tagged", p + 1) });
|
||||
// A whole sentence each way, rather than a noun put in front of one: a form
|
||||
// field and an annotation of some subtype read differently in other
|
||||
// languages. The subtype itself is the PDF's own name and goes in as it is.
|
||||
let mut values = vec![("{p}", (p + 1).to_string())];
|
||||
let pattern = if st == b"Widget" {
|
||||
"Form field on page {p} isn't tagged"
|
||||
} else {
|
||||
values.push(("{}", subtype_name(&a)));
|
||||
"{} annotation on page {p} isn't tagged"
|
||||
};
|
||||
out.push(Finding::worded(Some(p), pattern, values));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -252,7 +269,7 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
|
|||
for &p in list {
|
||||
let Some(page) = pages.get(p) else { continue };
|
||||
if !annots(page).is_empty() && page.dict.name(b"Tabs") != Some(b"S") {
|
||||
out.push(Finding { page: Some(p), message: format!("Page {} doesn't tab in structure order", p + 1) });
|
||||
out.push(Finding::worded(Some(p), "Page {p} doesn't tab in structure order", vec![("{p}", (p + 1).to_string())]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -264,8 +281,18 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
|
|||
&& let Some(f) = doc.get(*r).as_dict()
|
||||
&& !maps_to_unicode(doc, f)
|
||||
{
|
||||
let name = f.name(b"BaseFont").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "(unnamed)".into());
|
||||
out.push(Finding { page: Some(*p), message: format!("Font {name} on page {} doesn't map its characters to Unicode", p + 1) });
|
||||
// A font with no name gets its own whole sentence, so another language can
|
||||
// word that rather than translate a parenthesis. The name itself is the
|
||||
// document's own and goes in as it stands.
|
||||
let page = ("{p}", (p + 1).to_string());
|
||||
out.push(match f.name(b"BaseFont") {
|
||||
Some(n) => Finding::worded(
|
||||
Some(*p),
|
||||
"Font {} on page {p} doesn't map its characters to Unicode",
|
||||
vec![("{}", String::from_utf8_lossy(n).into_owned()), page],
|
||||
),
|
||||
None => Finding::worded(Some(*p), "Font (unnamed) on page {p} doesn't map its characters to Unicode", vec![page]),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -286,10 +313,11 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
|
|||
}
|
||||
let fd = doc.get(field).as_dict().cloned().unwrap_or_default();
|
||||
if text_of(doc, &fd, b"TU").is_none() {
|
||||
out.push(Finding {
|
||||
page: Some(p),
|
||||
message: format!("Field \"{}\" on page {} has no description (tooltip)", full_name(doc, field), p + 1),
|
||||
});
|
||||
out.push(Finding::worded(
|
||||
Some(p),
|
||||
"Field \"{}\" on page {p} has no description (tooltip)",
|
||||
vec![("{}", full_name(doc, field)), ("{p}", (p + 1).to_string())],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ mod structure;
|
|||
|
||||
pub use alt::{AltError, Figure, figures, mark_decorative, set_alt};
|
||||
pub use headings::{Heading, headings};
|
||||
pub use report::report_html;
|
||||
pub use report::{REPORT_WORDS, report_html};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Category {
|
||||
|
|
@ -407,13 +407,154 @@ impl Status {
|
|||
}
|
||||
|
||||
/// One problem: where it is and what is wrong.
|
||||
///
|
||||
/// The sentence comes in two forms. [`message`](Finding::message) is it, in English, ready to read.
|
||||
/// [`pattern`](Finding::pattern) is the same sentence with a named hole where each value went, and
|
||||
/// [`values`](Finding::values) is what filled them, so an interface can say it in another language
|
||||
/// instead of showing the English one: it translates the pattern and puts the same values back.
|
||||
/// `message` is always `pattern` with `values` in it, because [`Finding::worded`] builds it.
|
||||
///
|
||||
/// Every hole holds the document's own data — an element type, a font name, a count — and goes in
|
||||
/// as it stands, with one exception: a hole named `{what}` holds another wording of ours, still with
|
||||
/// its own holes in it. An interface translates that wording too and puts it in the frame first, and
|
||||
/// then fills every hole in what it has; [`Finding::nested`] builds such a finding.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Finding {
|
||||
/// 0-based page, when the problem is on a page.
|
||||
pub page: Option<usize>,
|
||||
pub message: String,
|
||||
/// The English sentence with a hole, such as `{p}` or `{what}`, where each value went.
|
||||
pub pattern: &'static str,
|
||||
/// What goes in each of `pattern`'s holes, by the hole's name (`"{p}"`, braces included).
|
||||
pub values: Vec<(&'static str, String)>,
|
||||
}
|
||||
|
||||
impl Finding {
|
||||
/// A finding whose sentence says the same thing however the document is put together.
|
||||
pub fn plain(page: Option<usize>, sentence: &'static str) -> Self {
|
||||
Finding { page, message: sentence.to_owned(), pattern: sentence, values: Vec::new() }
|
||||
}
|
||||
|
||||
/// A finding built from a wording and what goes in its holes. The English `message` is filled
|
||||
/// in here, so it and `pattern` can never drift apart.
|
||||
pub fn worded(page: Option<usize>, pattern: &'static str, values: Vec<(&'static str, String)>) -> Self {
|
||||
Finding { page, message: say(pattern, &values), pattern, values }
|
||||
}
|
||||
|
||||
/// A finding that says where the problem is and, in `{what}`, what is wrong, each worded
|
||||
/// separately so the two read in whichever order the language reads them.
|
||||
///
|
||||
/// `what` is one of our own wordings and may have holes of its own; they are filled from
|
||||
/// `values` along with the frame's. It goes in before the values do — it is ours, not the
|
||||
/// document's — which is what lets its holes be filled at all.
|
||||
pub fn nested(page: Option<usize>, pattern: &'static str, what: &'static str, mut values: Vec<(&'static str, String)>) -> Self {
|
||||
let message = say(&pattern.replace("{what}", what), &values);
|
||||
values.push(("{what}", what.to_owned()));
|
||||
Finding { page, message, pattern, values }
|
||||
}
|
||||
|
||||
/// This finding as one sentence, said in the language `words` translates into.
|
||||
///
|
||||
/// The frame is translated, the wording in `{what}` — if the frame has one — is translated and
|
||||
/// goes in, and only then does the document's own data, in a single pass, so nothing the
|
||||
/// document itself says can be read as a hole. With `&|s: &str| s.to_owned()` this is
|
||||
/// [`message`](Finding::message).
|
||||
pub fn said(&self, words: Words) -> String {
|
||||
let mut frame = words(self.pattern);
|
||||
if let Some((_, what)) = self.values.iter().find(|(name, _)| *name == "{what}") {
|
||||
frame = frame.replace("{what}", &words(what));
|
||||
}
|
||||
say(&frame, &self.values)
|
||||
}
|
||||
}
|
||||
|
||||
/// How an interface says one of our wordings in its own language, holes and all: given the English
|
||||
/// in [`WORDINGS`], the same wording in that language. [`in_english`] keeps the English.
|
||||
pub type Words<'a> = &'a dyn Fn(&str) -> String;
|
||||
|
||||
/// The [`Words`] that translate nothing: the wording as it is written here.
|
||||
pub fn in_english(wording: &str) -> String {
|
||||
wording.to_owned()
|
||||
}
|
||||
|
||||
/// A wording with its holes filled.
|
||||
///
|
||||
/// A hole is a name in braces, such as `{p}`; what is not in `values` is left as it is written.
|
||||
/// Values go in during a single pass over the wording, so a value that itself contains braces is
|
||||
/// never read as a hole: the document's own words reach the screen as they stand.
|
||||
///
|
||||
/// An interface says a [`Finding`] in its own language with this, after translating the pattern
|
||||
/// and, if there is one, the wording in `{what}`.
|
||||
pub fn say(pattern: &str, values: &[(&str, String)]) -> String {
|
||||
let mut out = String::with_capacity(pattern.len() + 16);
|
||||
let mut rest = pattern;
|
||||
while let Some(open) = rest.find('{') {
|
||||
let (before, from_brace) = rest.split_at(open);
|
||||
out.push_str(before);
|
||||
// `find` counts from the brace, and `}` is one byte, so this is a char boundary; `get`
|
||||
// rather than slicing in case it ever isn't.
|
||||
let Some(close) = from_brace.find('}') else {
|
||||
// A brace with nothing closing it is just text.
|
||||
out.push_str(from_brace);
|
||||
return out;
|
||||
};
|
||||
let Some(hole) = from_brace.get(..=close) else { return out + from_brace };
|
||||
match values.iter().find(|(name, _)| *name == hole) {
|
||||
Some((_, value)) => out.push_str(value),
|
||||
None => out.push_str(hole),
|
||||
}
|
||||
rest = from_brace.get(close + 1..).unwrap_or_default();
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Every sentence the checker can say: each [`Finding::pattern`] and each wording that goes in a
|
||||
/// `{what}` hole, in English, with its holes still in it.
|
||||
///
|
||||
/// An interface that shows the report in another language translates these, which is the whole
|
||||
/// reason they are gathered here; `wordings_are_complete` fails if a rule says something that is
|
||||
/// not on the list.
|
||||
pub const WORDINGS: &[&str] = &[
|
||||
// Document rules.
|
||||
"The security settings don't allow screen readers to read the text",
|
||||
"No page has text: the document appears to be images of pages",
|
||||
"The document has no tags (structure tree)",
|
||||
"The document isn't marked as tagged (MarkInfo Marked)",
|
||||
"No document language is set",
|
||||
"The document has no title",
|
||||
"The window shows the file name, not the title (Initial View > Show)",
|
||||
"{n} pages and no bookmarks",
|
||||
// Page content and forms.
|
||||
"The document isn't tagged, so none of its content is",
|
||||
"Page {p}: {n} untagged content item",
|
||||
"Page {p}: {n} untagged content items",
|
||||
"Form field on page {p} isn't tagged",
|
||||
"{} annotation on page {p} isn't tagged",
|
||||
"Page {p} doesn't tab in structure order",
|
||||
"Font {} on page {p} doesn't map its characters to Unicode",
|
||||
"Font (unnamed) on page {p} doesn't map its characters to Unicode",
|
||||
"Field \"{}\" on page {p} has no description (tooltip)",
|
||||
// The structure rules: the two frames, then what goes in their `{what}`.
|
||||
"{ty} element: {what}",
|
||||
"{ty} element on page {p}: {what}",
|
||||
"no alternate text",
|
||||
"its alternate text is inside another element's and will never be read",
|
||||
"alternate text on an element with no page content",
|
||||
"alternate text hides an annotation inside it",
|
||||
"not in a Table, THead, TBody or TFoot",
|
||||
"not in a TR",
|
||||
"no header cells (TH)",
|
||||
"rows have between {min} and {max} columns",
|
||||
"no summary",
|
||||
"not in an L (list)",
|
||||
"not in an LI (list item)",
|
||||
"the first heading is an H{n}; headings should not skip levels",
|
||||
"H{n} follows an H{d}; headings should not skip levels",
|
||||
// The summary at the end of a long list.
|
||||
AND_MORE,
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuleResult {
|
||||
pub rule: Rule,
|
||||
|
|
@ -458,6 +599,9 @@ impl Report {
|
|||
/// Findings beyond this many per rule are summarised ("… and n more").
|
||||
const MAX_FINDINGS: usize = 200;
|
||||
|
||||
/// What the summary of the findings beyond [`MAX_FINDINGS`] says.
|
||||
const AND_MORE: &str = "… and {n} more";
|
||||
|
||||
/// Run the full check.
|
||||
pub fn check(doc: &Document, options: &Options) -> Report {
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
|
|
@ -465,8 +609,9 @@ pub fn check(doc: &Document, options: &Options) -> Report {
|
|||
Some(p) => p.iter().copied().filter(|p| *p < pages.len()).collect(),
|
||||
None => (0..pages.len()).collect(),
|
||||
};
|
||||
let tree = structure::Tree::read(doc, &pages);
|
||||
let tagged = structure::has_struct_tree(doc);
|
||||
let scan = content::Scan::run(doc, &pages, &page_list);
|
||||
let mut tree_rules = structure::run(doc, &pages, &options.rules);
|
||||
let mut results = Vec::with_capacity(32);
|
||||
for rule in Rule::ALL {
|
||||
if !options.rules.contains(&rule) {
|
||||
|
|
@ -477,15 +622,24 @@ pub fn check(doc: &Document, options: &Options) -> Report {
|
|||
results.push(RuleResult { rule, status: Status::Manual, findings: Vec::new() });
|
||||
continue;
|
||||
}
|
||||
let mut findings = match rule.category() {
|
||||
Category::Document => content::document_rule(doc, rule, &pages, &scan),
|
||||
Category::PageContent | Category::Forms => content::page_rule(doc, rule, &pages, &page_list, &scan, &tree),
|
||||
_ => tree.rule(rule),
|
||||
// `total` counts every finding; `findings` holds the first MAX_FINDINGS of them.
|
||||
let (mut findings, total) = match rule.category() {
|
||||
Category::Document => {
|
||||
let found = content::document_rule(doc, rule, &pages, &scan);
|
||||
let n = found.len();
|
||||
(found, n)
|
||||
}
|
||||
Category::PageContent | Category::Forms => {
|
||||
let found = content::page_rule(doc, rule, &pages, &page_list, &scan, tagged);
|
||||
let n = found.len();
|
||||
(found, n)
|
||||
}
|
||||
_ => tree_rules.remove(&rule).unwrap_or_default(),
|
||||
};
|
||||
if findings.len() > MAX_FINDINGS {
|
||||
let more = findings.len() - MAX_FINDINGS;
|
||||
if total > MAX_FINDINGS {
|
||||
let more = total - MAX_FINDINGS;
|
||||
findings.truncate(MAX_FINDINGS);
|
||||
findings.push(Finding { page: None, message: format!("… and {more} more") });
|
||||
findings.push(Finding::worded(None, AND_MORE, vec![("{n}", more.to_string())]));
|
||||
}
|
||||
let status = if findings.is_empty() { Status::Passed } else { Status::Failed };
|
||||
results.push(RuleResult { rule, status, findings });
|
||||
|
|
|
|||
|
|
@ -1,48 +1,76 @@
|
|||
//! The accessibility report: a self-contained HTML page with the summary and, per category,
|
||||
//! each rule's status, description and problems.
|
||||
//!
|
||||
//! The page is written in whichever language `words` translates into, down to its `lang` attribute,
|
||||
//! so it reads the way the interface that saved it reads. Only the file name, the date and what the
|
||||
//! findings say about the document itself come from elsewhere.
|
||||
|
||||
use crate::{Category, Report, Status};
|
||||
use crate::{Category, Report, Status, Words};
|
||||
|
||||
fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
/// The report for `file_name`, dated `date` (as the caller formats it).
|
||||
pub fn report_html(report: &Report, file_name: &str, date: &str) -> String {
|
||||
/// The page's own words, in the order it says them: its heading, what it calls the file and the
|
||||
/// date, the two things the summary can say, and the headers of the table.
|
||||
///
|
||||
/// The rest of what the page says it gets from elsewhere — the categories, the rules and the
|
||||
/// findings name themselves — so an interface has these to translate and those to translate; a test
|
||||
/// in this crate keeps the list in step with the page.
|
||||
pub const REPORT_WORDS: &[&str] = &[
|
||||
"Accessibility Report",
|
||||
"Filename",
|
||||
"Report created",
|
||||
"Summary",
|
||||
"The checker found problems which may prevent the document from being fully accessible.",
|
||||
"The checker found no problems in this document.",
|
||||
"Detailed Report",
|
||||
"Rule Name",
|
||||
"Status",
|
||||
"Description",
|
||||
];
|
||||
|
||||
/// The report for `file_name`, dated `date` (as the caller formats it), written in the language
|
||||
/// `words` translates into and marked `lang` (`"en"` and `&|s: &str| s.to_owned()` for English).
|
||||
pub fn report_html(report: &Report, file_name: &str, date: &str, lang: &str, words: Words) -> String {
|
||||
let title = esc(&words("Accessibility Report"));
|
||||
let mut h = String::new();
|
||||
h.push_str("<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Accessibility Report</title>\n<style>\n");
|
||||
h.push_str(&format!("<!doctype html>\n<html lang=\"{}\"><head><meta charset=\"utf-8\"><title>{title}</title>\n<style>\n", esc(lang)));
|
||||
h.push_str(
|
||||
"body{font:14px/1.45 system-ui,sans-serif;margin:24px;color:#1d1d1f}h1{font-size:22px}h2{font-size:17px;margin-top:28px}\
|
||||
table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:6px 8px;text-align:left;vertical-align:top}\
|
||||
th{background:#f2f2f2}.Failed{color:#b3261e;font-weight:600}.Passed{color:#1e7b34}ul{margin:4px 0 0;padding-left:18px}\n",
|
||||
);
|
||||
h.push_str("</style></head><body>\n<h1>Accessibility Report</h1>\n");
|
||||
h.push_str(&format!("<p>Filename: <b>{}</b><br>Report created: {}</p>\n", esc(file_name), esc(date)));
|
||||
h.push_str("<h2>Summary</h2>\n<p>");
|
||||
h.push_str(if report.count(Status::Failed) > 0 {
|
||||
"The checker found problems which may prevent the document from being fully accessible."
|
||||
h.push_str(&format!("</style></head><body>\n<h1>{title}</h1>\n"));
|
||||
h.push_str(&format!("<p>{}: <b>{}</b><br>{}: {}</p>\n", esc(&words("Filename")), esc(file_name), esc(&words("Report created")), esc(date)));
|
||||
h.push_str(&format!("<h2>{}</h2>\n<p>", esc(&words("Summary"))));
|
||||
// Each outcome is a whole sentence, and each is handed over on its own, so the list in
|
||||
// `REPORT_WORDS` can be read off this file.
|
||||
h.push_str(&if report.count(Status::Failed) > 0 {
|
||||
esc(&words("The checker found problems which may prevent the document from being fully accessible."))
|
||||
} else {
|
||||
"The checker found no problems in this document."
|
||||
esc(&words("The checker found no problems in this document."))
|
||||
});
|
||||
h.push_str("</p>\n<ul>\n");
|
||||
for s in [Status::Manual, Status::Skipped, Status::Passed, Status::Failed] {
|
||||
h.push_str(&format!("<li>{}: {}</li>\n", s.label(), report.count(s)));
|
||||
h.push_str(&format!("<li>{}: {}</li>\n", esc(&words(s.label())), report.count(s)));
|
||||
}
|
||||
h.push_str("</ul>\n<h2>Detailed Report</h2>\n");
|
||||
h.push_str(&format!("</ul>\n<h2>{}</h2>\n", esc(&words("Detailed Report"))));
|
||||
let (name, status, description) = (esc(&words("Rule Name")), esc(&words("Status")), esc(&words("Description")));
|
||||
for c in Category::ALL {
|
||||
h.push_str(&format!("<h3>{}</h3>\n<table><tr><th>Rule Name</th><th>Status</th><th>Description</th></tr>\n", c.label()));
|
||||
h.push_str(&format!("<h3>{}</h3>\n<table><tr><th>{name}</th><th>{status}</th><th>{description}</th></tr>\n", esc(&words(c.label()))));
|
||||
for r in report.in_category(c) {
|
||||
h.push_str(&format!(
|
||||
"<tr><td>{}</td><td class=\"{:?}\">{}</td><td>{}",
|
||||
esc(r.rule.name()),
|
||||
esc(&words(r.rule.name())),
|
||||
r.status,
|
||||
r.status.label(),
|
||||
esc(r.rule.description())
|
||||
esc(&words(r.status.label())),
|
||||
esc(&words(r.rule.description()))
|
||||
));
|
||||
if !r.findings.is_empty() {
|
||||
h.push_str("<ul>");
|
||||
for f in &r.findings {
|
||||
h.push_str(&format!("<li>{}</li>", esc(&f.message)));
|
||||
h.push_str(&format!("<li>{}</li>", esc(&f.said(words))));
|
||||
}
|
||||
h.push_str("</ul>");
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -157,6 +157,135 @@ fn row_and_column_spans_keep_a_table_regular() {
|
|||
assert_eq!(status(&r, Rule::TableRegularity), Status::Passed, "{:?}", r.result(Rule::TableRegularity));
|
||||
}
|
||||
|
||||
/// A tagged document whose structure tree is the Document element `document` (object 3).
|
||||
fn structure_doc(objects: &[String]) -> Document {
|
||||
let mut objs = vec!["<< /Type /Catalog /StructTreeRoot 2 0 R >>".to_string(), "<< /Type /StructTreeRoot /K 3 0 R >>".to_string()];
|
||||
objs.extend_from_slice(objects);
|
||||
pdf(&objs, "")
|
||||
}
|
||||
|
||||
fn checked(doc: &Document, rules: &[Rule]) -> Report {
|
||||
check(doc, &Options { rules: rules.iter().copied().collect(), pages: None })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn figures_and_tables_past_the_old_element_cut_off_are_checked() {
|
||||
// Five thousand paragraphs, then a figure without alternate text and a table whose second row
|
||||
// is narrower than its first.
|
||||
let mut kids = vec!["<< /S /P >>".to_string(); 5_000];
|
||||
kids.push("<< /S /Figure >>".into());
|
||||
kids.push(
|
||||
"<< /S /Table /A << /O /Table /Summary (s) >> /K [ << /S /TR /K [ << /S /TD >> << /S /TD >> ] >> << /S /TR /K [ << /S /TD >> ] >> ] >>"
|
||||
.into(),
|
||||
);
|
||||
let doc = structure_doc(&[format!("<< /S /Document /K [{}] >>", kids.join(" "))]);
|
||||
let r = checked(&doc, &[Rule::FiguresAltText, Rule::TableRegularity]);
|
||||
assert_eq!(r.result(Rule::FiguresAltText).unwrap().findings.len(), 1);
|
||||
assert!(r.result(Rule::TableRegularity).unwrap().findings[0].message.contains("between 1 and 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn findings_are_listed_in_document_order_not_in_the_order_they_close() {
|
||||
// Each element has alternate text but no content. The outer ones close last.
|
||||
let doc = structure_doc(&["<< /S /Document /K [ << /S /Sect /Alt (a) /K [ << /S /P /Alt (b) /K [ << /S /Div /Alt (c) >> ] >> ] >> ] >>".into()]);
|
||||
let r = checked(&doc, &[Rule::AltTextAssociated]);
|
||||
let kinds: Vec<&str> = r.result(Rule::AltTextAssociated).unwrap().findings.iter().map(|f| f.message.split(' ').next().unwrap_or("")).collect();
|
||||
assert_eq!(kinds, ["Sect", "P", "Div"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rule_lists_its_first_findings_and_counts_the_rest() {
|
||||
let figures = vec!["<< /S /Figure >>".to_string(); 300].join(" ");
|
||||
let doc = structure_doc(&[format!("<< /S /Document /K [{figures}] >>")]);
|
||||
let r = checked(&doc, &[Rule::FiguresAltText]);
|
||||
let found = &r.result(Rule::FiguresAltText).unwrap().findings;
|
||||
assert_eq!(found.len(), 201);
|
||||
assert_eq!(found[200].message, "… and 100 more");
|
||||
assert!(found[..200].iter().all(|f| f.message == "Figure element: no alternate text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycles_shared_kids_and_missing_kids_are_read_once() {
|
||||
// Object 4 holds itself, a figure twice, and a missing object is listed too.
|
||||
let doc = structure_doc(&[
|
||||
"<< /S /Document /K [4 0 R 4 0 R 99 0 R] >>".into(),
|
||||
"<< /S /Sect /K [4 0 R 5 0 R 5 0 R] >>".into(),
|
||||
"<< /S /Figure >>".into(),
|
||||
]);
|
||||
let r = checked(&doc, &[Rule::FiguresAltText]);
|
||||
assert_eq!(r.result(Rule::FiguresAltText).unwrap().findings.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_is_read_to_the_depth_limit_and_no_further() {
|
||||
// Sections, each the kid of the one before, ending in a figure without alternate text. The
|
||||
// figure is at depth `sections`; the walk reads depths up to 256.
|
||||
let figure_within = |sections: usize| {
|
||||
let mut objs: Vec<String> = (0..sections).map(|i| format!("<< /S /Sect /K {} 0 R >>", 4 + i)).collect();
|
||||
objs.push("<< /S /Figure >>".into());
|
||||
let r = checked(&structure_doc(&objs), &[Rule::FiguresAltText]);
|
||||
r.result(Rule::FiguresAltText).unwrap().findings.len()
|
||||
};
|
||||
assert_eq!(figure_within(250), 1, "within the depth limit");
|
||||
assert_eq!(figure_within(300), 0, "past the depth limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrays_nested_deeper_than_the_limit_are_not_read() {
|
||||
let nested = |n: usize| format!("<< /S /Document /K {}<< /S /Figure >>{} >>", "[".repeat(n), "]".repeat(n));
|
||||
let shallow = checked(&structure_doc(&[nested(100)]), &[Rule::FiguresAltText]);
|
||||
assert_eq!(shallow.result(Rule::FiguresAltText).unwrap().findings.len(), 1);
|
||||
let deep = checked(&structure_doc(&[nested(300)]), &[Rule::FiguresAltText]);
|
||||
assert_eq!(deep.result(Rule::FiguresAltText).unwrap().findings.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_list_of_marked_content_counts_as_content() {
|
||||
// The first paragraph holds 20,000 marked-content items, so its alternate text has content.
|
||||
let items = (0..20_000).map(|i| i.to_string()).collect::<Vec<_>>().join(" ");
|
||||
let doc = structure_doc(&[format!("<< /S /Document /K [ << /S /P /Alt (a) /K [{items}] >> << /S /P /Alt (b) >> ] >>")]);
|
||||
let r = checked(&doc, &[Rule::AltTextAssociated]);
|
||||
assert_eq!(r.result(Rule::AltTextAssociated).unwrap().findings.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_spans_do_not_size_the_table() {
|
||||
// A row of 2,000 cells, each spanning 10,000 columns, then a row of one cell.
|
||||
let wide = vec!["<< /S /TD /A << /O /Table /ColSpan 10000 >> >>".to_string(); 2_000].join(" ");
|
||||
let doc = structure_doc(&[format!(
|
||||
"<< /S /Document /K [ << /S /Table /A << /O /Table /Summary (s) >> /K [ << /S /TR /K [{wide}] >> << /S /TR /K [ << /S /TD >> ] >> ] >> ] >>"
|
||||
)]);
|
||||
let r = checked(&doc, &[Rule::TableRegularity]);
|
||||
let found = &r.result(Rule::TableRegularity).unwrap().findings;
|
||||
assert_eq!(found[0].message, "Table element: rows have between 1 and 20000000 columns");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_spans_carry_past_their_row() {
|
||||
// Row 1: 3,000 cells, each spanning 10,000 rows. Row 2: 3,000 cells, which start after them.
|
||||
let row1 = vec!["<< /S /TD /A << /O /Table /RowSpan 10000 >> >>".to_string(); 3_000].join(" ");
|
||||
let row2 = vec!["<< /S /TD >>".to_string(); 3_000].join(" ");
|
||||
let doc = structure_doc(&[format!(
|
||||
"<< /S /Document /K [ << /S /Table /A << /O /Table /Summary (s) >> /K [ << /S /TR /K [{row1}] >> << /S /TR /K [{row2}] >> ] >> ] >>"
|
||||
)]);
|
||||
let r = checked(&doc, &[Rule::TableRegularity]);
|
||||
let found = &r.result(Rule::TableRegularity).unwrap().findings;
|
||||
assert_eq!(found[0].message, "Table element: rows have between 3000 and 6000 columns");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spans_that_are_not_positive_integers_read_as_one() {
|
||||
// ColSpan -3 and 0 read as 1, and 2.0 reads as 2; the first row is 4 columns wide, the second 5.
|
||||
let doc = structure_doc(&[
|
||||
"<< /S /Document /K [ << /S /Table /A << /O /Table /Summary (s) >> /K [ \
|
||||
<< /S /TR /K [ << /S /TD /A << /O /Table /ColSpan -3 >> >> << /S /TD /A << /O /Table /ColSpan 0 >> >> << /S /TD /A << /O /Table /ColSpan 2.0 >> >> ] >> \
|
||||
<< /S /TR /K [ << /S /TD >> << /S /TD >> << /S /TD >> << /S /TD >> << /S /TD >> ] >> ] >> ] >>"
|
||||
.into(),
|
||||
]);
|
||||
let r = checked(&doc, &[Rule::TableRegularity]);
|
||||
assert_eq!(r.result(Rule::TableRegularity).unwrap().findings[0].message, "Table element: rows have between 4 and 5 columns");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_content_and_annotation_problems_are_found() {
|
||||
let mut objs = good();
|
||||
|
|
@ -218,9 +347,15 @@ fn rules_have_ids_and_the_report_lists_them() {
|
|||
let per: Vec<usize> = Category::ALL.iter().map(|c| Rule::ALL.iter().filter(|r| r.category() == *c).count()).collect();
|
||||
assert_eq!(per, [8, 9, 2, 5, 5, 2, 1]);
|
||||
let doc = pdf(&good(), "/Info 8 0 R");
|
||||
let html = report_html(&check(&doc, &Options::default()), "a<b>.pdf", "2026-10-02");
|
||||
let report = check(&doc, &Options::default());
|
||||
let html = report_html(&report, "a<b>.pdf", "2026-10-02", "en", &in_english);
|
||||
assert!(html.contains("a<b>.pdf") && html.contains("Accessibility permission flag") && html.contains("Needs manual check: 5"));
|
||||
assert!(html.contains("<h3>Headings</h3>"));
|
||||
// In another language every word of ours is said in it, down to the `lang`; the file name is the
|
||||
// file's own and stays as it is.
|
||||
let other = report_html(&report, "a<b>.pdf", "2026-10-02", "es", &|w| format!("«{w}»"));
|
||||
assert!(other.contains("<html lang=\"es\">") && other.contains("<h3>«Headings»</h3>") && other.contains("a<b>.pdf"));
|
||||
assert!(!other.contains(">Headings<"), "an English heading is left in the report: {other}");
|
||||
}
|
||||
|
||||
fn figures_doc() -> Document {
|
||||
|
|
@ -335,3 +470,93 @@ fn an_untagged_document_has_no_headings() {
|
|||
objs[0] = "<< /Type /Catalog /Pages 2 0 R >>".into();
|
||||
assert!(headings(&pdf(&objs, "")).is_empty());
|
||||
}
|
||||
|
||||
/// The text of every `"…"` literal with a space in it, outside comments and byte strings.
|
||||
fn sentences(source: &str) -> Vec<String> {
|
||||
let b = source.as_bytes();
|
||||
let (mut out, mut i) = (Vec::new(), 0);
|
||||
while i < b.len() {
|
||||
if b[i] == b'/' && b.get(i + 1) == Some(&b'/') {
|
||||
while i < b.len() && b[i] != b'\n' {
|
||||
i += 1;
|
||||
}
|
||||
} else if b[i] == b'"' {
|
||||
let byte_string = i > 0 && b[i - 1] == b'b';
|
||||
let start = i + 1;
|
||||
i = start;
|
||||
while i < b.len() && b[i] != b'"' {
|
||||
i += if b[i] == b'\\' { 2 } else { 1 };
|
||||
}
|
||||
// Quotes and backslashes are ASCII, so this never cuts a character in two.
|
||||
let text = source.get(start..i.min(b.len())).unwrap_or_default();
|
||||
if !byte_string && text.contains(' ') {
|
||||
out.push(text.replace("\\\"", "\""));
|
||||
}
|
||||
i += 1;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every sentence the rules can say is listed in `WORDINGS`, so an interface showing the report in
|
||||
/// another language has something to translate. The two rule files hold no prose of their own, so a
|
||||
/// literal with a space in it is a sentence a person reads.
|
||||
#[test]
|
||||
fn wordings_are_complete() {
|
||||
let mut missing = Vec::new();
|
||||
for source in [include_str!("content.rs"), include_str!("structure.rs")] {
|
||||
for sentence in sentences(source) {
|
||||
if !WORDINGS.contains(&sentence.as_str()) {
|
||||
missing.push(sentence);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(missing.is_empty(), "add these to WORDINGS, and a translation of each to every interface: {missing:#?}");
|
||||
}
|
||||
|
||||
/// Every word the report page says of its own is listed in `REPORT_WORDS`, and every word on that
|
||||
/// list is one the page says: the page hands each over to `words` as a literal, so the two can be
|
||||
/// read off one another.
|
||||
#[test]
|
||||
fn report_words_are_complete() {
|
||||
let source = include_str!("report.rs");
|
||||
let mut said = Vec::new();
|
||||
let mut rest = source;
|
||||
while let Some(at) = rest.find("words(\"") {
|
||||
rest = &rest[at + 7..];
|
||||
let Some(end) = rest.find('"') else { break };
|
||||
said.push(&rest[..end]);
|
||||
rest = &rest[end..];
|
||||
}
|
||||
let missing: Vec<&&str> = said.iter().filter(|word| !REPORT_WORDS.contains(word)).collect();
|
||||
assert!(missing.is_empty(), "add these to REPORT_WORDS, and a translation of each to every interface: {missing:#?}");
|
||||
let unsaid: Vec<&&str> = REPORT_WORDS.iter().filter(|word| !said.contains(word)).collect();
|
||||
assert!(unsaid.is_empty(), "REPORT_WORDS lists words the page no longer says: {unsaid:#?}");
|
||||
}
|
||||
|
||||
/// `say` puts the values in, and what it puts in is never read as a hole of its own: a document that
|
||||
/// names a font `{p}` cannot make a finding say which page it is on.
|
||||
#[test]
|
||||
fn say_fills_holes_once() {
|
||||
let values = [("{p}", "7".to_string()), ("{}", "{p} {n}".to_string())];
|
||||
assert_eq!(
|
||||
say("Font {} on page {p} doesn't map its characters to Unicode", &values),
|
||||
"Font {p} {n} on page 7 doesn't map its characters to Unicode"
|
||||
);
|
||||
// A hole with nothing for it, and a brace with nothing closing it, are left as they stand.
|
||||
assert_eq!(say("{a} {p} {", &values), "{a} 7 {");
|
||||
}
|
||||
|
||||
/// A finding whose frame holds another wording says the whole thing in English, holes and all
|
||||
/// filled, and still hands the interface the two wordings to translate.
|
||||
#[test]
|
||||
fn a_nested_finding_says_itself() {
|
||||
let values = vec![("{min}", "1".to_string()), ("{max}", "2".to_string()), ("{ty}", "Table".to_string()), ("{p}", "3".to_string())];
|
||||
let f = Finding::nested(Some(2), "{ty} element on page {p}: {what}", "rows have between {min} and {max} columns", values);
|
||||
assert_eq!(f.message, "Table element on page 3: rows have between 1 and 2 columns");
|
||||
assert_eq!(f.said(&in_english), f.message, "saying it in English is the message");
|
||||
// Both wordings are translated, each on its own, and the document's words are left alone.
|
||||
assert_eq!(f.said(&|w: &str| format!("«{w}»")), "«Table element on page 3: «rows have between 1 and 2 columns»»");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ fn line_end(c: &mut String, kind: &[u8], tip: (f64, f64), from: (f64, f64), w: f
|
|||
/// point parallel to the chord between its neighbours (an end uses itself as the missing
|
||||
/// neighbour). Ink is drawn so, as Acrobat draws it, rather than as straight segments. The curve
|
||||
/// stays within its points and these control points.
|
||||
pub(crate) fn smooth_segments(pts: &[(f64, f64)]) -> Vec<[(f64, f64); 3]> {
|
||||
pub fn smooth_segments(pts: &[(f64, f64)]) -> Vec<[(f64, f64); 3]> {
|
||||
let Some(&last) = pts.last() else { return Vec::new() };
|
||||
let at = |i: usize| pts.get(i).copied().unwrap_or(last);
|
||||
(0..pts.len().saturating_sub(1))
|
||||
|
|
|
|||
|
|
@ -1931,7 +1931,9 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
|
|||
index,
|
||||
subtype,
|
||||
author: text_value(doc, d, b"T"),
|
||||
contents: text_value(doc, d, b"Contents"),
|
||||
// Acrobat ends lines with CR, which the interface would draw as nothing (keep in
|
||||
// step with `pdfcraft_render::inspect`).
|
||||
contents: text_value(doc, d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")),
|
||||
modified: text_value(doc, d, b"M"),
|
||||
name: text_value(doc, d, b"NM"),
|
||||
in_reply_to,
|
||||
|
|
|
|||
|
|
@ -202,13 +202,24 @@ fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), Anno
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Check link geometry before writing PDF objects: a user or automation call can supply
|
||||
/// non-finite coordinates or a finite pair whose difference overflows to infinity.
|
||||
fn checked_rect(rect: [f64; 4]) -> Result<[f64; 4], AnnotError> {
|
||||
if !rect.iter().all(|v| v.is_finite()) {
|
||||
return Err(AnnotError::Invalid("the link area must have finite coordinates".into()));
|
||||
}
|
||||
let r = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
|
||||
let (width, height) = (r[2] - r[0], r[3] - r[1]);
|
||||
if !width.is_finite() || !height.is_finite() || width < 2.0 || height < 2.0 {
|
||||
return Err(AnnotError::Invalid("the link area is too small or too large".into()));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Add a link over `rect` (user space). Returns its index in the page's `/Annots`.
|
||||
pub fn add(doc: &mut Document, page: usize, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result<usize, AnnotError> {
|
||||
let p = page_ref(doc, page)?;
|
||||
let r = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
|
||||
if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < 2.0 || r[3] - r[1] < 2.0 {
|
||||
return Err(AnnotError::Invalid("the link area is too small".into()));
|
||||
}
|
||||
let r = checked_rect(rect)?;
|
||||
if matches!(action, LinkAction::Other(_)) {
|
||||
return Err(AnnotError::Invalid("choose a page or a web address".into()));
|
||||
}
|
||||
|
|
@ -250,10 +261,7 @@ pub fn set(
|
|||
let (_, r) = link_at(doc, page, index)?;
|
||||
let mut d = doc.get(r).as_dict().cloned().unwrap_or_default();
|
||||
if let Some(rect) = rect {
|
||||
let rr = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
|
||||
if rr[2] - rr[0] < 2.0 || rr[3] - rr[1] < 2.0 {
|
||||
return Err(AnnotError::Invalid("the link area is too small".into()));
|
||||
}
|
||||
let rr = checked_rect(rect)?;
|
||||
d.set(b"Rect".to_vec(), Object::Array(rr.iter().map(|v| Object::Real(*v)).collect()));
|
||||
}
|
||||
if let Some(s) = style {
|
||||
|
|
@ -296,6 +304,59 @@ pub fn remove_all(doc: &mut Document, pages: Option<&[usize]>) -> Result<usize,
|
|||
Ok(n)
|
||||
}
|
||||
|
||||
/// Recognise common ASCII mailbox addresses without mistaking domains, partial words or
|
||||
/// invalid labels for e-mail links. The returned end is in source-character indices.
|
||||
fn email_at(chars: &[char], start: usize) -> Option<(usize, String)> {
|
||||
let local_char = |c: char| c.is_ascii_alphanumeric() || "._+%-".contains(c);
|
||||
let domain_char = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '.';
|
||||
// Don't create a link starting in the middle of an address.
|
||||
if start > 0 && chars.get(start - 1).is_some_and(|c| local_char(*c) || *c == '@') {
|
||||
return None;
|
||||
}
|
||||
let mut at = start;
|
||||
while chars.get(at).is_some_and(|c| local_char(*c)) {
|
||||
at += 1;
|
||||
if at - start > 64 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let local = chars.get(start..at)?;
|
||||
if local.is_empty() || local.first() == Some(&'.') || local.last() == Some(&'.') || local.windows(2).any(|w| w == ['.', '.']) {
|
||||
return None;
|
||||
}
|
||||
if chars.get(at) != Some(&'@') {
|
||||
return None;
|
||||
}
|
||||
let mut end = at + 1;
|
||||
while chars.get(end).is_some_and(|c| domain_char(*c)) {
|
||||
end += 1;
|
||||
if end - at - 1 > 253 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
while end > at + 1 && chars.get(end - 1) == Some(&'.') {
|
||||
end -= 1;
|
||||
}
|
||||
if chars.get(end).is_some_and(|c| c.is_ascii_alphanumeric() || *c == '@' || *c == '_') {
|
||||
return None;
|
||||
}
|
||||
let domain = chars.get(at + 1..end)?;
|
||||
let mut labels = 0;
|
||||
let mut tld_length = 0;
|
||||
for label in domain.split(|c| *c == '.') {
|
||||
if label.is_empty() || label.len() > 63 || !label.first()?.is_ascii_alphanumeric() || !label.last()?.is_ascii_alphanumeric() {
|
||||
return None;
|
||||
}
|
||||
labels += 1;
|
||||
tld_length = label.len();
|
||||
}
|
||||
if labels < 2 || tld_length < 2 {
|
||||
return None;
|
||||
}
|
||||
let address: String = chars.get(start..end)?.iter().collect();
|
||||
Some((end, format!("mailto:{address}")))
|
||||
}
|
||||
|
||||
/// Find web addresses in text: `http(s)://…`, `www.…` and bare e-mail addresses become
|
||||
/// `mailto:`. Returns character ranges and the URI for each.
|
||||
pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range<usize>, String)> {
|
||||
|
|
@ -327,6 +388,11 @@ pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range<usize>, String)> {
|
|||
continue;
|
||||
}
|
||||
}
|
||||
if boundary && let Some((end, uri)) = email_at(chars, i) {
|
||||
out.push((i..end, uri));
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
|
|
|
|||
|
|
@ -432,6 +432,19 @@ fn real_annotation_flags_do_not_lock_comments() {
|
|||
assert!(set_info(&mut doc, 0, 0, Some("Ada"), None, None, &meta("")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn carriage_returns_in_comment_text_become_line_breaks() {
|
||||
let mut doc = fixture();
|
||||
let page = page_refs(&doc).unwrap()[0];
|
||||
let mut entries = annots(&doc, page);
|
||||
let Object::Dict(annotation) = &mut entries[0] else { panic!("fixture annotation is inline") };
|
||||
// Acrobat ends lines in /Contents with CR; the interface only breaks lines at LF.
|
||||
annotation.set(b"Contents".to_vec(), PdfString::text("one\r\rtwo\r\nthree"));
|
||||
set_annots(&mut doc, page, entries).unwrap();
|
||||
|
||||
assert_eq!(summaries(&doc)[0].contents.as_deref(), Some("one\n\ntwo\nthree"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_text_box_still_refits_its_text() {
|
||||
let mut doc = fixture();
|
||||
|
|
@ -574,6 +587,31 @@ fn links_are_added_edited_listed_and_removed() {
|
|||
assert!(links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page(0), &LinkStyle::default()).is_err(), "too small");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_edit_rejects_non_finite_and_overflowed_rectangles_without_mutating_the_pdf() {
|
||||
use crate::links::{self, LinkAction, LinkStyle};
|
||||
let mut doc = fixture();
|
||||
let original_rect = [10.0, 20.0, 110.0, 70.0];
|
||||
let index = links::add(&mut doc, 0, original_rect, &LinkAction::Uri("https://example.org".into()), &LinkStyle::default()).unwrap();
|
||||
let before = links::list(&doc);
|
||||
let changed_action = LinkAction::Uri("https://other.example".into());
|
||||
for rect in [
|
||||
[f64::NAN, 0.0, 100.0, 30.0],
|
||||
[0.0, 0.0, f64::INFINITY, 30.0],
|
||||
[f64::NEG_INFINITY, 0.0, 100.0, 30.0],
|
||||
[-1e308, 0.0, 1e308, 30.0],
|
||||
[0.0, 0.0, 1.5, 30.0],
|
||||
] {
|
||||
assert!(links::set(&mut doc, 0, index, Some(rect), Some(&changed_action), None).is_err(), "edit accepted {rect:?}");
|
||||
assert_eq!(links::list(&doc), before, "invalid edit changed an existing link");
|
||||
assert!(links::add(&mut doc, 0, rect, &changed_action, &LinkStyle::default()).is_err(), "add accepted {rect:?}");
|
||||
}
|
||||
// A reversed but otherwise valid rectangle is normalized on edit.
|
||||
links::set(&mut doc, 0, index, Some([110.0, 70.0, 10.0, 20.0]), None, None).unwrap();
|
||||
let edited = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == index).map(|l| l.rect);
|
||||
assert_eq!(edited, Some(original_rect));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_are_found_in_text() {
|
||||
let t: Vec<char> = "See https://example.org/a?b=1, or www.rust-lang.org. Not a.b or http:/x.".chars().collect();
|
||||
|
|
@ -581,6 +619,30 @@ fn urls_are_found_in_text() {
|
|||
assert_eq!(found, ["https://example.org/a?b=1", "http://www.rust-lang.org"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_email_addresses_become_mailto_links_with_source_character_ranges() {
|
||||
let source = "İ Contact ada.lovelace+forms@example.co.uk, browse HTTPS://EXAMPLE.ORG; or ask admin@example.org.";
|
||||
let chars: Vec<char> = source.chars().collect();
|
||||
let links = crate::links::find_urls(&chars);
|
||||
let found: Vec<(String, String)> = links.into_iter().map(|(range, url)| (chars.get(range).unwrap().iter().collect(), url)).collect();
|
||||
assert_eq!(
|
||||
found,
|
||||
[
|
||||
("ada.lovelace+forms@example.co.uk".into(), "mailto:ada.lovelace+forms@example.co.uk".into()),
|
||||
("HTTPS://EXAMPLE.ORG".into(), "HTTPS://EXAMPLE.ORG".into()),
|
||||
("admin@example.org".into(), "mailto:admin@example.org".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_email_addresses_are_not_autolinked() {
|
||||
for invalid in ["a@b", "broken@localhost", "a..b@example.org", "foo@.example.org", "foo@example..org", "foo@example.com@x"] {
|
||||
let input: Vec<char> = invalid.chars().collect();
|
||||
assert!(crate::links::find_urls(&input).is_empty(), "{invalid:?} produced a link");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_are_found_after_length_changing_lowercase() {
|
||||
// U+0130 (İ) lowercases to "i" plus a combining dot above, so a case-folded copy is longer
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "pdfcraft-audit"
|
||||
description = "Verification tooling: audit documents for faux redactions (opaque covers over extractable text)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-content = { workspace = true }
|
||||
pdfcraft-edit = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# pdfcraft-audit
|
||||
|
||||
Layer L4 (may use `edit`): verification tooling, the faux-redaction audit.
|
||||
|
||||
```rust
|
||||
let findings: Vec<Finding> = audit_redactions(&doc)?; // page, kind, rect, covered_text
|
||||
```
|
||||
|
||||
It reports two kinds of finding:
|
||||
|
||||
- **faux redaction**: an opaque filled rectangle painted *after* text it overlaps, dark enough
|
||||
against the text's colour (WCAG contrast below 3) to hide it, while the text is still
|
||||
extractable underneath;
|
||||
- **unapplied mark**: a Redact annotation that was marked but never applied.
|
||||
|
||||
A properly applied redaction removes the text, so it never flags. Page backgrounds, table
|
||||
shading and highlights don't either: they're painted before the text, or light against it.
|
||||
|
||||
Limits, by design and documented rather than hidden:
|
||||
|
||||
- only `re` rectangles closed by a fill count as covers (not arbitrary paths, shadings or
|
||||
patterns), and a translucent one (`/ca` below 1) is not flagged;
|
||||
- covers inside form XObjects are found, but only text drawn directly in the page content is
|
||||
checked under them;
|
||||
- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign.
|
||||
|
||||
It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper
|
||||
than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings".
|
||||
|
||||
Surfaces: the `redact_audit` automation tool and `pdfcraft-cli`.
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
//! Walk page content streams and collect the opaque filled rectangles they draw.
|
||||
//!
|
||||
//! This is the geometric half of the faux-redaction audit: a rectangle filled with an opaque
|
||||
//! color (`re` + `f`) is the classic way a "redaction" is faked. Graphics state tracked here is
|
||||
//! deliberately small: the CTM stack (`q`/`Q`/`cm`), the non-stroking color (`g`/`rg`/`k`) and
|
||||
//! the non-stroking alpha from ExtGState (`gs` reading `/ca`). Form XObjects are followed
|
||||
//! read-only with a depth cap. Anything fancier (arbitrary filled paths, shadings, patterns) is
|
||||
//! out of scope and documented as such.
|
||||
//!
|
||||
//! Paint order: a page's content streams are one stream in pieces (ISO 32000-2 §7.8.2), so they
|
||||
//! are parsed joined and walked once with a single graphics state — state set in an earlier
|
||||
//! stream (a `cm`, a fill color) is still in force in the later ones. Each rectangle records the
|
||||
//! position of the fill operator that painted it, as `(content-stream index, operator index in
|
||||
//! the joined stream)`; rectangles inside a form XObject carry the `Do` operator's position.
|
||||
//! Positions compare lexicographically in paint order.
|
||||
|
||||
use pdfcraft_content::{Matrix, Op, Pieces, parse};
|
||||
use pdfcraft_cos::{Dict, Document, Object};
|
||||
|
||||
use crate::AuditError;
|
||||
|
||||
/// Maximum form-XObject nesting followed. Deeper nesting (including a cycle) fails the audit: a
|
||||
/// cover drawn below the cut could otherwise go unseen and the page be reported clean.
|
||||
const MAX_DEPTH: usize = 32;
|
||||
/// Bound on total operators walked per page, so a hostile stream cannot spin forever.
|
||||
/// Hitting it fails the audit: a truncated walk must never report "clean".
|
||||
const MAX_OPS: usize = 2_000_000;
|
||||
|
||||
/// One opaque filled rectangle, in page user space.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FilledRect {
|
||||
/// `[x0, y0, x1, y1]`, normalized so `x0 <= x1` and `y0 <= y1`.
|
||||
pub rect: [f64; 4],
|
||||
/// The fill color as sRGB-ish `[r, g, b]` (CMYK is converted approximately).
|
||||
pub rgb: [f64; 3],
|
||||
/// Paint position: `(content-stream index, operator index in the joined stream)`.
|
||||
/// Rectangles inside a form XObject carry the `Do` operator's position.
|
||||
pub pos: (usize, usize),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Gs {
|
||||
ctm: Matrix,
|
||||
fill: [f64; 3],
|
||||
alpha: f64,
|
||||
}
|
||||
|
||||
impl Default for Gs {
|
||||
fn default() -> Self {
|
||||
Gs { ctm: Matrix::IDENTITY, fill: [0.0, 0.0, 0.0], alpha: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
struct Walker<'a> {
|
||||
doc: &'a Document,
|
||||
pieces: &'a Pieces,
|
||||
out: Vec<FilledRect>,
|
||||
ops_seen: usize,
|
||||
}
|
||||
|
||||
impl<'a> Walker<'a> {
|
||||
fn bump(&mut self) -> Result<(), AuditError> {
|
||||
self.ops_seen += 1;
|
||||
if self.ops_seen > MAX_OPS {
|
||||
return Err(AuditError::TooManyOps);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk one operator slice with the given graphics state. `at_do` is the `Do` position to
|
||||
/// stamp on rectangles found inside a form XObject, or `None` at page level (where each
|
||||
/// fill operator's own position is used).
|
||||
fn walk_ops(
|
||||
&mut self,
|
||||
ops: &[Op],
|
||||
gs: &mut Gs,
|
||||
stack: &mut Vec<Gs>,
|
||||
resources: &Dict,
|
||||
depth: usize,
|
||||
at_do: Option<(usize, usize)>,
|
||||
) -> Result<(), AuditError> {
|
||||
// A pending `re` rectangle, in the user space active when it was built.
|
||||
let mut pending: Option<[f64; 4]> = None;
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
self.bump()?;
|
||||
// Paint position of this operator: its own `(piece, joined index)` at page level,
|
||||
// the enclosing `Do`'s position inside a form XObject.
|
||||
let pos = match at_do {
|
||||
Some(p) => p,
|
||||
None => (self.pieces.pieces_of(op).0, i),
|
||||
};
|
||||
match op.op.as_slice() {
|
||||
b"q" => stack.push(gs.clone()),
|
||||
b"Q" => {
|
||||
if let Some(g) = stack.pop() {
|
||||
*gs = g;
|
||||
}
|
||||
pending = None;
|
||||
}
|
||||
b"cm" => {
|
||||
if let Some(m) = Matrix::from_operands(&op.operands)
|
||||
&& m.0.iter().all(|v| v.is_finite())
|
||||
{
|
||||
gs.ctm = m.then(&gs.ctm);
|
||||
}
|
||||
pending = None;
|
||||
}
|
||||
b"g" => {
|
||||
if let Some(v) = op.num(0).filter(|v| v.is_finite()) {
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
gs.fill = [v, v, v];
|
||||
}
|
||||
}
|
||||
b"rg" => {
|
||||
if let Some([r, g, b]) = op.nums::<3>()
|
||||
&& [r, g, b].iter().all(|v| v.is_finite())
|
||||
{
|
||||
gs.fill = [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)];
|
||||
}
|
||||
}
|
||||
b"k" => {
|
||||
if let Some([c, m, y, k]) = op.nums::<4>()
|
||||
&& [c, m, y, k].iter().all(|v| v.is_finite())
|
||||
{
|
||||
// Naive CMYK -> RGB; good enough to tell black from white.
|
||||
let (c, m, y, k) = (c.clamp(0.0, 1.0), m.clamp(0.0, 1.0), y.clamp(0.0, 1.0), k.clamp(0.0, 1.0));
|
||||
gs.fill = [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)];
|
||||
}
|
||||
}
|
||||
b"gs" => {
|
||||
if let Some(name) = op.name(0) {
|
||||
gs.alpha = ext_alpha(self.doc, resources, name);
|
||||
}
|
||||
}
|
||||
b"re" => {
|
||||
pending = op.nums::<4>().and_then(|[x, y, w, h]| {
|
||||
if [x, y, w, h].iter().all(|v| v.is_finite()) && w != 0.0 && h != 0.0 {
|
||||
let r = gs.ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]);
|
||||
(r[2] > r[0] && r[3] > r[1]).then_some(r)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
}
|
||||
b"f" | b"F" | b"f*" | b"B" | b"B*" => {
|
||||
if let Some(r) = pending.take()
|
||||
&& gs.alpha >= 1.0
|
||||
{
|
||||
self.out.push(FilledRect { rect: r, rgb: gs.fill, pos });
|
||||
}
|
||||
// Any other path construction clears the pending rectangle.
|
||||
}
|
||||
b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"S" | b"s" | b"n" | b"W" | b"W*" => {
|
||||
pending = None;
|
||||
}
|
||||
b"Do" => {
|
||||
if let Some(name) = op.name(0) {
|
||||
if depth >= MAX_DEPTH {
|
||||
return Err(AuditError::TooDeep);
|
||||
}
|
||||
self.walk_xobject(name, gs, resources, depth, pos)?;
|
||||
}
|
||||
pending = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Follow a form XObject read-only, composing its matrix onto the current CTM. State
|
||||
/// changes inside never leak out; every rectangle found is stamped with the `Do`
|
||||
/// position `pos`.
|
||||
fn walk_xobject(&mut self, name: &[u8], gs: &Gs, resources: &Dict, depth: usize, pos: (usize, usize)) -> Result<(), AuditError> {
|
||||
let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
|
||||
let Some(entry) = xobjects.get(name) else { return Ok(()) };
|
||||
let resolved = self.doc.resolve(entry);
|
||||
let Object::Stream(stream) = &*resolved else { return Ok(()) };
|
||||
let dict = &stream.dict;
|
||||
if dict.name(b"Subtype") != Some(b"Form") {
|
||||
return Ok(());
|
||||
}
|
||||
let matrix = dict
|
||||
.get(b"Matrix")
|
||||
.map(|o| self.doc.resolve(o))
|
||||
.and_then(|o| o.as_array().map(|a| a.to_vec()))
|
||||
.and_then(|a| {
|
||||
let v: Vec<f64> = a.iter().filter_map(|o| self.doc.resolve(o).as_f64()).collect();
|
||||
(v.len() == 6 && v.iter().all(|x| x.is_finite())).then(|| Matrix([v[0], v[1], v[2], v[3], v[4], v[5]]))
|
||||
})
|
||||
.unwrap_or(Matrix::IDENTITY);
|
||||
let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
|
||||
// A form stream that cannot be decoded fails the audit: it might hide a cover.
|
||||
let data = stream.decoded_strict()?;
|
||||
let ops = parse(&data).ops;
|
||||
// The XObject matrix maps form space into the user space active at `Do`.
|
||||
let mut sub_gs = gs.clone();
|
||||
sub_gs.ctm = matrix.then(&gs.ctm);
|
||||
let mut sub_stack = Vec::new();
|
||||
self.walk_ops(&ops, &mut sub_gs, &mut sub_stack, &sub_resources, depth + 1, Some(pos))
|
||||
}
|
||||
}
|
||||
|
||||
/// The non-stroking alpha (`/ca`) of a named ExtGState, defaulting to opaque.
|
||||
fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 {
|
||||
let alpha = resources
|
||||
.get(b"ExtGState")
|
||||
.map(|o| doc.resolve(o))
|
||||
.and_then(|o| o.as_dict().cloned())
|
||||
.and_then(|d| d.get(name).map(|o| doc.resolve(o)))
|
||||
.and_then(|o| o.as_dict().cloned())
|
||||
.and_then(|d| d.get(b"ca").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()));
|
||||
alpha.filter(|a| a.is_finite()).map(|a| a.clamp(0.0, 1.0)).unwrap_or(1.0)
|
||||
}
|
||||
|
||||
/// Every opaque filled rectangle drawn by a page's content (and its form XObjects), with paint
|
||||
/// positions. A page's content streams are walked once, joined, with one graphics state, so
|
||||
/// state set in an earlier stream is still in force in the later ones. Any content stream that
|
||||
/// cannot be decoded is an audit failure, never an empty result.
|
||||
pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, AuditError> {
|
||||
let resources = page.get(b"Resources").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
|
||||
let contents: Vec<Object> = match page.get(b"Contents") {
|
||||
None => Vec::new(),
|
||||
Some(c) => match &*doc.resolve(c) {
|
||||
Object::Array(a) => a.clone(),
|
||||
_ => vec![c.clone()],
|
||||
},
|
||||
};
|
||||
let mut streams = Vec::with_capacity(contents.len());
|
||||
for c in &contents {
|
||||
if let Object::Stream(s) = &*doc.resolve(c) {
|
||||
streams.push(s.decoded_strict()?);
|
||||
}
|
||||
}
|
||||
let pieces = Pieces::join(&streams);
|
||||
let parsed = pieces.parse();
|
||||
let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0 };
|
||||
let mut gs = Gs::default();
|
||||
let mut stack = Vec::new();
|
||||
w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?;
|
||||
Ok(w.out)
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
//! Verification tooling: audit a document for faux redactions.
|
||||
//!
|
||||
//! [`audit_redactions`] finds *faux redactions* — opaque filled rectangles drawn over text
|
||||
//! that is still extractable underneath — and redaction annotations that were marked but
|
||||
//! never applied. A properly applied redaction removes the text, so it never flags.
|
||||
//!
|
||||
//! A rectangle counts as a cover only when it was painted strictly *after* the text it
|
||||
//! overlaps, and only when it is dark enough against the text's color to make the text
|
||||
//! unreadable (WCAG contrast below 3.0). Page backgrounds, table shading and highlight
|
||||
//! marks are therefore not flagged.
|
||||
//!
|
||||
//! Detection limits (documented, not hidden): only `re` rectangles closed by a fill operator
|
||||
//! are considered — arbitrary filled paths, shadings and patterns are not; a rectangle drawn
|
||||
//! with a translucent ExtGState (`/ca < 1`) is not flagged; annotation rectangles are compared
|
||||
//! in unrotated user space, so pages with `/Rotate` may misalign; covers inside form XObjects
|
||||
//! are found, but only text drawn directly in the page content is checked under them, so text
|
||||
//! inside a form XObject is not audited.
|
||||
//!
|
||||
//! The audit fails closed: any content that cannot be read (an undecodable content stream,
|
||||
//! malformed annotations, form XObjects nested deeper than 32 or in a cycle) is an error, never
|
||||
//! an empty finding list.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use pdfcraft_cos::{Dict, Document};
|
||||
use pdfcraft_edit::TextLine;
|
||||
|
||||
mod cover;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// What the audit found.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum FindingKind {
|
||||
/// An opaque filled rectangle covers text that is still extractable.
|
||||
CoveredText,
|
||||
/// A `/Redact` annotation was placed but never applied: the text is untouched.
|
||||
UnappliedMark,
|
||||
}
|
||||
|
||||
impl FindingKind {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
FindingKind::CoveredText => "covered-text",
|
||||
FindingKind::UnappliedMark => "unapplied-mark",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One audit finding.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AuditFinding {
|
||||
/// 0-based page index.
|
||||
pub page: usize,
|
||||
pub kind: FindingKind,
|
||||
/// The covering rectangle (or the mark's rectangle), in page user space.
|
||||
pub rect: [f64; 4],
|
||||
/// The extractable text the cover hides (possibly empty when nothing decodable remains).
|
||||
pub covered_text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuditError {
|
||||
#[error("page {0}: {1}")]
|
||||
Text(usize, String),
|
||||
#[error("content walk exceeded the operator bound")]
|
||||
TooManyOps,
|
||||
#[error("form XObjects nest deeper than the audit follows")]
|
||||
TooDeep,
|
||||
#[error("page has a malformed /Annots entry")]
|
||||
BadAnnots,
|
||||
#[error(transparent)]
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
#[error(transparent)]
|
||||
Edit(#[from] pdfcraft_edit::EditError),
|
||||
}
|
||||
|
||||
/// Fraction of a text line's area that must lie under a cover to count as hidden.
|
||||
const COVER_FRACTION: f64 = 0.4;
|
||||
/// WCAG contrast ratio below which the text is unreadable against the cover.
|
||||
const COVER_CONTRAST: f64 = 3.0;
|
||||
|
||||
/// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any
|
||||
/// content that cannot be decoded is returned as an error, never as an empty finding list.
|
||||
pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError> {
|
||||
let mut out = Vec::new();
|
||||
for (n, page) in pdfcraft_model::pages(doc).iter().enumerate() {
|
||||
let lines = pdfcraft_edit::text_lines(doc, n).map_err(|e| AuditError::Text(n, e.to_string()))?;
|
||||
// 1. Opaque filled rectangles over extractable text.
|
||||
for r in cover::filled_rects(doc, &page.dict)? {
|
||||
let mut covered = String::new();
|
||||
for line in &lines {
|
||||
if is_cover(&r, line) {
|
||||
if !covered.is_empty() {
|
||||
covered.push(' ');
|
||||
}
|
||||
covered.push_str(line.text.trim());
|
||||
}
|
||||
}
|
||||
if !covered.trim().is_empty() {
|
||||
out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text: covered });
|
||||
}
|
||||
}
|
||||
// 2. Redact annotations that were never applied.
|
||||
for annot in annots(doc, &page.dict)? {
|
||||
let mut covered = String::new();
|
||||
for line in &lines {
|
||||
if covers(&annot, &line.rect) {
|
||||
if !covered.is_empty() {
|
||||
covered.push(' ');
|
||||
}
|
||||
covered.push_str(line.text.trim());
|
||||
}
|
||||
}
|
||||
if !covered.trim().is_empty() {
|
||||
out.push(AuditFinding { page: n, kind: FindingKind::UnappliedMark, rect: annot, covered_text: covered });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A filled rectangle is a faux-redaction cover for a text line iff it was painted strictly
|
||||
/// after the line, overlaps it substantially, and is dark enough against the line's color to
|
||||
/// make the text unreadable. Light covers (table shading, highlights) over dark text fail the
|
||||
/// contrast check and are not flagged.
|
||||
fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool {
|
||||
rect.pos > line.paint_pos() && covers(&rect.rect, &line.rect) && contrast(line.color, rect.rgb) < COVER_CONTRAST
|
||||
}
|
||||
|
||||
/// Does `cover` hide a substantial part of `line`?
|
||||
fn covers(cover: &[f64; 4], line: &[f64; 4]) -> bool {
|
||||
let ix0 = cover[0].max(line[0]);
|
||||
let iy0 = cover[1].max(line[1]);
|
||||
let ix1 = cover[2].min(line[2]);
|
||||
let iy1 = cover[3].min(line[3]);
|
||||
if ix1 <= ix0 || iy1 <= iy0 {
|
||||
return false;
|
||||
}
|
||||
let line_area = (line[2] - line[0]).max(0.0) * (line[3] - line[1]).max(0.0);
|
||||
if line_area <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
(ix1 - ix0) * (iy1 - iy0) / line_area >= COVER_FRACTION
|
||||
}
|
||||
|
||||
/// WCAG 2.x relative luminance of an sRGB triple.
|
||||
fn luminance(rgb: [f64; 3]) -> f64 {
|
||||
let lin = rgb.map(|c| {
|
||||
let c = c.clamp(0.0, 1.0);
|
||||
if c <= 0.03928 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
|
||||
});
|
||||
0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
|
||||
}
|
||||
|
||||
/// WCAG contrast ratio between two sRGB colors: 1.0 (identical) to 21.0 (black on white).
|
||||
fn contrast(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
let (l1, l2) = (luminance(a), luminance(b));
|
||||
let (hi, lo) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
|
||||
(hi + 0.05) / (lo + 0.05)
|
||||
}
|
||||
|
||||
/// Rectangles of `/Redact` annotations on a page (unrotated user space). A missing `/Annots`
|
||||
/// means no marks; a malformed one fails the audit rather than silently passing.
|
||||
fn annots(doc: &Document, page: &Dict) -> Result<Vec<[f64; 4]>, AuditError> {
|
||||
let Some(a) = page.get(b"Annots") else { return Ok(Vec::new()) };
|
||||
let resolved = doc.resolve(a);
|
||||
let items: Vec<pdfcraft_cos::Object> = match &*resolved {
|
||||
pdfcraft_cos::Object::Array(items) => items.clone(),
|
||||
// A missing /Annots means no marks; a malformed one fails the audit.
|
||||
_ => return Err(AuditError::BadAnnots),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for a in &items {
|
||||
let dict = match &*doc.resolve(a) {
|
||||
pdfcraft_cos::Object::Dict(d) => d.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
if dict.name(b"Subtype") != Some(b"Redact") {
|
||||
continue;
|
||||
}
|
||||
let v: Vec<f64> = match dict.get(b"Rect") {
|
||||
None => continue,
|
||||
Some(r) => doc.resolve(r).as_array().map(|arr| arr.iter().filter_map(|o| doc.resolve(o).as_f64()).collect()).unwrap_or_default(),
|
||||
};
|
||||
if v.len() == 4 && v.iter().all(|x| x.is_finite()) {
|
||||
out.push([v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
//! Tests build their own tiny PDFs in code (contributor-original fixtures): no binary files,
|
||||
//! no Adobe material, exact xref offsets computed by the builder below.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::Document;
|
||||
|
||||
use crate::{FindingKind, audit_redactions};
|
||||
|
||||
/// Minimal PDF assembler: objects are numbered 1..=n in order.
|
||||
struct Builder {
|
||||
objects: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
fn new() -> Self {
|
||||
Builder { objects: Vec::new() }
|
||||
}
|
||||
|
||||
fn add(&mut self, body: &str) -> usize {
|
||||
self.objects.push(body.as_bytes().to_vec());
|
||||
self.objects.len()
|
||||
}
|
||||
|
||||
fn add_stream(&mut self, data: &[u8]) -> usize {
|
||||
self.add_stream_with(data, "")
|
||||
}
|
||||
|
||||
fn add_stream_with(&mut self, data: &[u8], dict_extra: &str) -> usize {
|
||||
let mut body = format!("<< /Length {} {dict_extra} >>\nstream\n", data.len()).into_bytes();
|
||||
body.extend_from_slice(data);
|
||||
body.extend_from_slice(b"\nendstream");
|
||||
self.objects.push(body);
|
||||
self.objects.len()
|
||||
}
|
||||
|
||||
fn build(self) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, obj) in self.objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(obj);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref_at = out.len();
|
||||
let n = self.objects.len() + 1;
|
||||
out.extend_from_slice(format!("xref\n0 {n}\n").as_bytes());
|
||||
out.extend_from_slice(b"0000000000 65535 f \n");
|
||||
for off in offsets {
|
||||
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(b"trailer\n<< ");
|
||||
out.extend_from_slice(format!("/Size {n} /Root 1 0 R").as_bytes());
|
||||
out.extend_from_slice(b" >>\nstartxref\n");
|
||||
out.extend_from_slice(format!("{xref_at}\n").as_bytes());
|
||||
out.extend_from_slice(b"%%EOF\n");
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
const FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>";
|
||||
|
||||
/// One page with `content` as its stream; `page_extra` is appended inside the page dict.
|
||||
fn one_page(content: &[u8], page_extra: &str) -> Vec<u8> {
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
let stream_num = 4;
|
||||
b.add(&format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {stream_num} 0 R /Resources << /Font << /F1 5 0 R >> >> {page_extra} >>"
|
||||
));
|
||||
b.add_stream(content);
|
||||
b.add(FONT);
|
||||
b.build()
|
||||
}
|
||||
|
||||
/// One page with two content streams (`/Contents [4 0 R 5 0 R]`).
|
||||
fn two_streams(s1: &[u8], s2: &[u8]) -> Vec<u8> {
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents [4 0 R 5 0 R] /Resources << /Font << /F1 6 0 R >> >> >>");
|
||||
b.add_stream(s1);
|
||||
b.add_stream(s2);
|
||||
b.add(FONT);
|
||||
b.build()
|
||||
}
|
||||
|
||||
/// One page whose content stream claims `/FlateDecode` but holds garbage: decoding must fail.
|
||||
fn bad_filter_page() -> Vec<u8> {
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>");
|
||||
b.add_stream_with(b"this is not deflated data", "/Filter /FlateDecode");
|
||||
b.add(FONT);
|
||||
b.build()
|
||||
}
|
||||
|
||||
fn open(bytes: Vec<u8>) -> Document {
|
||||
Document::open(Arc::new(bytes)).expect("test pdf must open")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faux_redaction_is_found() {
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\n100 688 220 32 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
let f = &findings[0];
|
||||
assert_eq!(f.page, 0);
|
||||
assert_eq!(f.kind, FindingKind::CoveredText);
|
||||
assert!(f.covered_text.contains("SECRET"), "covered_text was {:?}", f.covered_text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_document_has_no_findings() {
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (nothing hidden here) Tj ET\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_box_without_text_is_not_a_finding() {
|
||||
// A filled rectangle with no text under it is decoration, not a faux redaction.
|
||||
let content = b"BT /F1 24 Tf 100 100 Td (far away) Tj ET\n0 g\n400 600 100 40 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translucent_cover_is_not_flagged() {
|
||||
// A rectangle drawn with a translucent ExtGState does not hide the text.
|
||||
let content = b"/GS1 gs\nBT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\n100 688 220 32 re f\n";
|
||||
let page_extra = "/Resources << /Font << /F1 5 0 R >> /ExtGState << /GS1 6 0 R >> >>";
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
b.add(&format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R {page_extra} >>"));
|
||||
b.add_stream(content);
|
||||
b.add(FONT);
|
||||
b.add("<< /Type /ExtGState /ca 0.5 >>");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unapplied_redact_mark_is_found() {
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (DO NOT SHARE) Tj ET\n";
|
||||
let page_extra = "/Annots [6 0 R]";
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
b.add(&format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> {page_extra} >>"));
|
||||
b.add_stream(content);
|
||||
b.add(FONT);
|
||||
b.add("<< /Type /Annot /Subtype /Redact /Rect [95 685 305 725] /C [1 0 0] >>");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].kind, FindingKind::UnappliedMark);
|
||||
assert!(findings[0].covered_text.contains("DO NOT SHARE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_before_text_is_not_flagged() {
|
||||
// An opaque dark rectangle painted *before* the text is a background, not a cover.
|
||||
let content = b"0 g\n100 688 220 32 re f\nBT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_shading_after_text_is_not_flagged() {
|
||||
// Light table-style shading painted after the text leaves it readable: not a cover.
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0.9 g\n100 688 220 32 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_state_carries_across_streams() {
|
||||
// The first stream sets a white fill after the text; the second stream's rectangle over
|
||||
// the text is therefore white, not the default black: no finding. Without the carry fix
|
||||
// the rectangle would default to black and flag.
|
||||
let s1 = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n1 g\n";
|
||||
let s2 = b"100 688 220 32 re f\n";
|
||||
let findings = audit_redactions(&open(two_streams(s1, s2))).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_fails_closed_on_undecodable_stream() {
|
||||
// A content stream that cannot be decoded is an audit error, never an empty finding list.
|
||||
let err = audit_redactions(&open(bad_filter_page())).expect_err("audit must fail closed");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("decoded"), "unexpected error: {msg}");
|
||||
}
|
||||
|
||||
/// A form XObject that draws itself nests without end. The walk can't follow it to the bottom, so
|
||||
/// the audit fails rather than report the page clean with a possible cover left unseen.
|
||||
#[test]
|
||||
fn self_drawing_form_fails_the_audit() {
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 6 0 R >> /XObject << /X 5 0 R >> >> >>");
|
||||
b.add_stream(b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n/X Do\n");
|
||||
b.add_stream_with(
|
||||
b"0 g\n100 688 220 32 re f\n/X Do\n",
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /XObject << /X 5 0 R >> >>",
|
||||
);
|
||||
b.add(FONT);
|
||||
let result = audit_redactions(&open(b.build()));
|
||||
assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}");
|
||||
}
|
||||
|
|
@ -52,6 +52,23 @@ or fill colour. Names, rectangles and rotations require the single-field form. R
|
|||
|
||||
Implemented: `initialize` (protocol 2025-06-18, 2025-03-26, 2024-11-05), `ping`, `tools/list` (with `readOnlyHint`/`destructiveHint` annotations), `tools/call` (JSON results also returned as `structuredContent`; images as `image/png`), and `resources/list`, `resources/templates/list` and `resources/read`. The resources expose the open documents read-only: `pdfcraft://doc/{doc}/info` (JSON), `…/text`, `…/page/{page}/text` and `…/page/{page}/image{?dpi}` (PNG, 1–600 dpi). Tool failures come back as `isError: true` results, so the agent can read and recover from them.
|
||||
|
||||
### From an installed release
|
||||
|
||||
The release packages ship `pdfcraft-cli` alongside the desktop app, so no build is needed:
|
||||
|
||||
| Install | CLI |
|
||||
|---|---|
|
||||
| Windows (MSI) | `C:\Program Files\PdfCraft\pdfcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` |
|
||||
| Linux (deb, rpm) | `/usr/bin/pdfcraft-cli` |
|
||||
| macOS | the separate `pdfcraft-cli-<version>-macos-<arch>.zip` release asset (the `.app` holds only the desktop app) |
|
||||
|
||||
```sh
|
||||
# Windows, default install folder
|
||||
claude mcp add pdfcraft -- "C:\Program Files\PdfCraft\pdfcraft-cli.exe" mcp --root <dir>
|
||||
# Linux, or macOS with the CLI unzipped onto PATH
|
||||
claude mcp add pdfcraft -- pdfcraft-cli mcp --root <dir>
|
||||
```
|
||||
|
||||
## Adding a tool
|
||||
|
||||
1. Add the engine capability first, with its tests (the tool is a thin adapter).
|
||||
|
|
|
|||
|
|
@ -84,7 +84,9 @@ impl Automation {
|
|||
let r = doc.accessibility_check(&o).ok_or_else(|| failed("the document can't be read for checking"))?;
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
let (y, m, d) = self.session.today();
|
||||
let html = pdfcraft_engine::a11y::report_html(&r, &doc.name, &format!("{y}-{m:02}-{d:02}"));
|
||||
// A script's report is in English, like the rest of what the tools say and answer with.
|
||||
let words = pdfcraft_engine::a11y::in_english;
|
||||
let html = pdfcraft_engine::a11y::report_html(&r, &doc.name, &format!("{y}-{m:02}-{d:02}"), "en", &words);
|
||||
write_atomic(&path, html.as_bytes())?;
|
||||
let mut out = report_json(&r);
|
||||
out["path"] = json!(path.to_string_lossy());
|
||||
|
|
@ -271,7 +273,7 @@ impl Automation {
|
|||
let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, html or rtf)")))?;
|
||||
let bytes = self.doc(a)?.export_office(format);
|
||||
let bytes = self.doc_to_copy(a)?.export_office(format);
|
||||
write_atomic(&path, &bytes)?;
|
||||
Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len(), "format": format.extension() }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,14 +73,27 @@ fn failed(e: impl std::fmt::Display) -> ToolError {
|
|||
ToolError::Failed(e.to_string())
|
||||
}
|
||||
|
||||
impl From<pdfcraft_engine::EditError> for ToolError {
|
||||
fn from(e: pdfcraft_engine::EditError) -> Self {
|
||||
failed(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
const MAX_BOOKMARK_PAGE: usize = 1000;
|
||||
|
||||
/// `text_find` extracts this many pages first, doubling each batch up to [`FIND_MAX_BATCH`], so a
|
||||
/// match near the start returns without touching the rest of a long document (#971).
|
||||
const FIND_FIRST_BATCH: usize = 8;
|
||||
const FIND_MAX_BATCH: usize = 128;
|
||||
|
||||
/// Page texts of one document version: (the working bytes, one slot per page).
|
||||
type TextCache = (Arc<Vec<u8>>, Vec<Option<Arc<PageText>>>);
|
||||
|
||||
|
|
@ -194,6 +207,38 @@ impl Automation {
|
|||
out["rotated"] = json!(n);
|
||||
out
|
||||
}
|
||||
"page_enhance_camera" => {
|
||||
let page = self.page(&a)?;
|
||||
let given = a.nums::<8>("corners")?;
|
||||
let doc = self.doc(&a)?;
|
||||
let (id, info) = (doc.id, doc.info.pages.get(page).cloned().ok_or_else(|| failed("no such page"))?);
|
||||
let corners = match given {
|
||||
Some(c) => [0, 1, 2, 3].map(|i| {
|
||||
let [x, y] = info.view_to_user(c[2 * i] as f32, c[2 * i + 1] as f32);
|
||||
[x as f64, y as f64]
|
||||
}),
|
||||
None => self.session.detect_camera_corners(id, page).ok_or_else(|| {
|
||||
failed("the document's edges could not be found on this page: pass `corners` [x1, y1, x2, y2, x3, y3, x4, y4]")
|
||||
})?,
|
||||
};
|
||||
let level = match a.opt_int("level")? {
|
||||
None => 0,
|
||||
Some(l @ 0..=100) => l as u8,
|
||||
Some(l) => return Err(ToolError::InvalidArgs(format!("level must be between 0 and 100, not {l}"))),
|
||||
};
|
||||
let look = match a.opt_str("mode")? {
|
||||
None => pdfcraft_engine::camera_clean::Look::Colour,
|
||||
Some(m) => pdfcraft_engine::camera_clean::Look::from_name(m)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("unknown mode {m:?} (original, colour, greyscale, bw)")))?,
|
||||
};
|
||||
self.session.enhance_camera_page_at(id, page, corners, look, level).map_err(failed)?;
|
||||
let r = |v: f32| (v * 100.0).round() / 100.0;
|
||||
let used: Vec<f32> = corners.iter().flat_map(|&[x, y]| info.user_to_view(x as f32, y as f32)).map(r).collect();
|
||||
let mut out = summary(self.doc(&a)?);
|
||||
out["corners"] = json!(used);
|
||||
out["detected"] = json!(given.is_none());
|
||||
out
|
||||
}
|
||||
"page_delete" => {
|
||||
let pages = self.pages(&a, "pages")?;
|
||||
self.apply(&a, Edit::DeletePages { pages })?
|
||||
|
|
@ -211,12 +256,12 @@ impl Automation {
|
|||
"doc_split" => self.doc_split(&a)?,
|
||||
"edit_undo" => {
|
||||
let id = self.doc(&a)?.id;
|
||||
let label = self.session.undo(id).map_err(failed)?;
|
||||
let label = self.session.undo(id).map_err(failed)?.to_string();
|
||||
json!({ "undone": label, "document": summary(self.doc(&a)?) })
|
||||
}
|
||||
"edit_redo" => {
|
||||
let id = self.doc(&a)?.id;
|
||||
let label = self.session.redo(id).map_err(failed)?;
|
||||
let label = self.session.redo(id).map_err(failed)?.to_string();
|
||||
json!({ "redone": label, "document": summary(self.doc(&a)?) })
|
||||
}
|
||||
"command_list" => self.command_list(&a)?,
|
||||
|
|
@ -409,7 +454,7 @@ impl Automation {
|
|||
}
|
||||
"text_paragraphs" => {
|
||||
let page = self.page(&a)?;
|
||||
let doc = self.doc(&a)?;
|
||||
let doc = self.doc_to_copy(&a)?;
|
||||
let info = &doc.info.pages[page];
|
||||
let r = |x: f32| (x as f64 * 100.0).round() / 100.0;
|
||||
let blocks: Vec<Value> = doc
|
||||
|
|
@ -552,6 +597,7 @@ impl Automation {
|
|||
"content_delete" => self.content_delete(&a)?,
|
||||
"doc_print" => self.doc_print(&a)?,
|
||||
"doc_remove_hidden" => self.doc_remove_hidden(&a)?,
|
||||
"doc_audit_redactions" => self.doc_audit_redactions(&a)?,
|
||||
"fill_sign_add" => self.fill_sign_add(&a)?,
|
||||
"fill_sign_date_format" => self.fill_sign_date_format(&a)?,
|
||||
"measure_distance" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Distance)?,
|
||||
|
|
@ -664,6 +710,13 @@ impl Automation {
|
|||
self.session.get(DocId(id)).ok_or_else(|| ToolError::Failed(format!("no open document with id {id} (see doc_list)")))
|
||||
}
|
||||
|
||||
/// The document, if its security allows copying text and images out of it (Table 22, bit 5).
|
||||
fn doc_to_copy(&self, a: &Args) -> Result<&Document> {
|
||||
let doc = self.doc(a)?;
|
||||
doc.check_copying().map_err(failed)?;
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
fn doc_open(&mut self, a: &Args) -> Result<Value> {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
|
|
@ -960,7 +1013,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 }
|
||||
}
|
||||
|
|
@ -989,7 +1045,7 @@ impl Automation {
|
|||
}
|
||||
|
||||
fn export(&mut self, tool: &str, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let doc = self.doc_to_copy(a)?;
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..doc.info.pages.len()).collect(),
|
||||
|
|
@ -1076,6 +1132,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:?}"))),
|
||||
|
|
@ -1212,8 +1272,8 @@ impl Automation {
|
|||
|
||||
fn doc_create_multiple(&mut self, a: &Args) -> Result<Value> {
|
||||
let paths = a.strs("paths")?;
|
||||
if paths.is_empty() || paths.len() > pdfcraft_engine::MAX_CREATE_FILES {
|
||||
return Err(ToolError::InvalidArgs(format!("paths must list 1 to {} files", pdfcraft_engine::MAX_CREATE_FILES)));
|
||||
if paths.is_empty() {
|
||||
return Err(ToolError::InvalidArgs("paths must list at least one file".into()));
|
||||
}
|
||||
let separate = match a.opt_str("mode")? {
|
||||
None | Some("combine") => false,
|
||||
|
|
@ -1228,16 +1288,16 @@ impl Automation {
|
|||
Some(Value::Array(v)) if v.len() == paths.len() => v.iter().map(|x| x.as_str().map(str::to_owned)).collect(),
|
||||
Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())),
|
||||
};
|
||||
let mut sources = Vec::new();
|
||||
for (p, range) in paths.into_iter().zip(ranges) {
|
||||
// Each file is read, converted and copied in turn, so a long list holds one file at a time.
|
||||
let sources = paths.into_iter().zip(ranges).map(|(p, range)| -> std::result::Result<pdfcraft_engine::CombineSource, ToolError> {
|
||||
let path = self.resolve(p, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let (_, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(failed)?;
|
||||
let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
sources.push((title, pdf, range));
|
||||
}
|
||||
let bytes = self.session.combine_ranges(&sources).map_err(failed)?;
|
||||
Ok((title, pdf, range))
|
||||
});
|
||||
let bytes = self.session.combine_each(sources)?.ok_or_else(|| ToolError::InvalidArgs("paths must list at least one file".into()))?;
|
||||
self.deliver(a, "Combined", bytes)
|
||||
}
|
||||
|
||||
|
|
@ -1345,7 +1405,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();
|
||||
|
|
@ -1516,7 +1579,7 @@ impl Automation {
|
|||
}
|
||||
|
||||
fn text_extract(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let doc = self.doc_to_copy(a)?;
|
||||
let id = doc.id;
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
|
|
@ -1533,22 +1596,46 @@ impl Automation {
|
|||
Ok(json!({ "pages": out }))
|
||||
}
|
||||
|
||||
/// Matches in document order, extracting page text in growing batches and stopping as soon as
|
||||
/// `limit` is reached, so a bounded search doesn't extract (and cache) the whole document
|
||||
/// (#971). One match past the limit is looked for, so `complete` is true only when no more
|
||||
/// exist; otherwise `next` holds the arguments that continue the search.
|
||||
fn text_find(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let (id, n) = (doc.id, doc.info.pages.len());
|
||||
let query = a.str("query")?;
|
||||
let limit = a.opt_int("limit")?.unwrap_or(500).max(1) as usize;
|
||||
let texts = self.page_texts(id, &(0..n).collect::<Vec<_>>())?;
|
||||
let limit = usize::try_from(a.opt_int("limit")?.unwrap_or(500).max(1)).unwrap_or(usize::MAX);
|
||||
let start = match a.opt_int("start_page")? {
|
||||
None => 0,
|
||||
Some(p) if p >= 1 && usize::try_from(p).is_ok_and(|p| p <= n) => usize::try_from(p - 1).unwrap_or(0),
|
||||
Some(p) => return Err(ToolError::InvalidArgs(format!("start_page {p} is out of range: the document has {n} pages"))),
|
||||
};
|
||||
let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX);
|
||||
let mut matches = Vec::new();
|
||||
'pages: for (p, text) in texts.iter().enumerate() {
|
||||
for r in text.find(query) {
|
||||
if matches.len() == limit {
|
||||
break 'pages;
|
||||
let mut next = None;
|
||||
let (mut first, mut batch) = (start, FIND_FIRST_BATCH);
|
||||
'search: while first < n {
|
||||
let end = first.saturating_add(batch).min(n);
|
||||
let pages: Vec<usize> = (first..end).collect();
|
||||
let texts = self.page_texts(id, &pages)?;
|
||||
for (p, text) in pages.iter().zip(&texts) {
|
||||
let skip_here = if *p == start { skip } else { 0 };
|
||||
for (k, r) in text.find(query).into_iter().enumerate().skip(skip_here) {
|
||||
if matches.len() == limit {
|
||||
next = Some(json!({ "start_page": p + 1, "skip": k }));
|
||||
break 'search;
|
||||
}
|
||||
matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) }));
|
||||
}
|
||||
matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) }));
|
||||
}
|
||||
first = end;
|
||||
batch = batch.saturating_mul(2).min(FIND_MAX_BATCH);
|
||||
}
|
||||
Ok(json!({ "query": query, "count": matches.len(), "matches": matches }))
|
||||
let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none(), "matches": matches });
|
||||
if let (Some(next), Some(o)) = (next, out.as_object_mut()) {
|
||||
o.insert("next".into(), next);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn command_list(&self, a: &Args) -> Result<Value> {
|
||||
|
|
@ -1996,6 +2083,85 @@ mod tests {
|
|||
use super::*;
|
||||
use pdfcraft_platform::staging::STAGING_ATTEMPTS;
|
||||
|
||||
/// `n` pages, page i showing "Page i".
|
||||
fn numbered_pages(n: usize) -> Vec<u8> {
|
||||
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
|
||||
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")));
|
||||
objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into());
|
||||
for i in 0..n {
|
||||
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i));
|
||||
let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1);
|
||||
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
|
||||
}
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn json(a: &mut Automation, tool: &str, args: Value) -> Value {
|
||||
match a.call(tool, &args).map(|mut c| c.remove(0)) {
|
||||
Ok(Content::Json(v)) => v,
|
||||
other => panic!("{tool} {args}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bounded_text_find_reads_only_the_pages_it_needs_and_says_it_stopped() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-automation-find-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("long.pdf"), numbered_pages(200)).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let doc = json(&mut a, "doc_open", json!({ "path": "long.pdf" }))["doc"].clone();
|
||||
let extracted = |a: &Automation| a.texts.values().map(|(_, pages)| pages.iter().filter(|t| t.is_some()).count()).sum::<usize>();
|
||||
|
||||
// #971: the first match is on page 1 and the next on page 10, so the rest of the 200 pages
|
||||
// aren't extracted or cached.
|
||||
let first = json(&mut a, "text_find", json!({ "doc": doc, "query": "Page 1", "limit": 1 }));
|
||||
assert_eq!(first["count"], 1);
|
||||
assert_eq!(first["matches"][0]["page"], 1);
|
||||
assert_eq!(first["complete"], false, "{first}");
|
||||
assert_eq!(first["next"], json!({ "start_page": 10, "skip": 0 }));
|
||||
assert!(extracted(&a) <= 3 * FIND_FIRST_BATCH, "{} pages extracted for one match", extracted(&a));
|
||||
|
||||
// Following `next` returns every match exactly once, in order, and then says it's complete.
|
||||
// "1" appears twice on pages like 11 and 101, so continuing has to skip within a page.
|
||||
let all = json(&mut a, "text_find", json!({ "doc": doc, "query": "1", "limit": 10_000 }));
|
||||
assert_eq!(all["complete"], true);
|
||||
assert!(all.get("next").is_none());
|
||||
let mut pieced = Vec::new();
|
||||
let mut args = json!({ "doc": doc, "query": "1", "limit": 3 });
|
||||
loop {
|
||||
let found = json(&mut a, "text_find", args.clone());
|
||||
pieced.extend(found["matches"].as_array().unwrap().iter().map(|m| (m["page"].clone(), m["rects"].clone())));
|
||||
if found["complete"] == true {
|
||||
break;
|
||||
}
|
||||
args["start_page"] = found["next"]["start_page"].clone();
|
||||
args["skip"] = found["next"]["skip"].clone();
|
||||
}
|
||||
let expected: Vec<_> = all["matches"].as_array().unwrap().iter().map(|m| (m["page"].clone(), m["rects"].clone())).collect();
|
||||
assert_eq!(all["count"], expected.len());
|
||||
assert_eq!(pieced, expected);
|
||||
|
||||
// A search that runs to the end below its limit is complete; a start page past the end is refused.
|
||||
let few = json(&mut a, "text_find", json!({ "doc": doc, "query": "Page 200", "start_page": 150 }));
|
||||
assert_eq!((few["count"].clone(), few["complete"].clone()), (json!(1), json!(true)));
|
||||
assert!(a.call("text_find", &json!({ "doc": doc, "query": "x", "start_page": 201 })).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_dot_resolves_by_name_and_never_climbs_above_the_start() {
|
||||
let base = std::env::temp_dir();
|
||||
|
|
|
|||
|
|
@ -105,9 +105,15 @@ impl Automation {
|
|||
None => PAPERS[0].1,
|
||||
Some(p) => PAPERS
|
||||
.iter()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case(p) || n.replace("US ", "").eq_ignore_ascii_case(p))
|
||||
.find(|(n, _)| {
|
||||
n.eq_ignore_ascii_case(p)
|
||||
|| n.replace("US ", "").eq_ignore_ascii_case(p)
|
||||
|| n.split(" (").next().is_some_and(|name| name.eq_ignore_ascii_case(p))
|
||||
})
|
||||
.map(|x| x.1)
|
||||
.ok_or_else(|| bad(format!("unknown paper {p:?} (Letter, Legal, Tabloid, A3, A4, A5)")))?,
|
||||
.ok_or_else(|| {
|
||||
bad(format!("unknown paper {p:?}; choose one of {}", PAPERS.iter().map(|(name, _)| *name).collect::<Vec<_>>().join(", ")))
|
||||
})?,
|
||||
};
|
||||
let settings = print::Settings { pages, paper, orientation, layout, content };
|
||||
let sizes: Vec<(f64, f64)> = self.doc(a)?.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect();
|
||||
|
|
|
|||
|
|
@ -163,6 +163,22 @@ impl Automation {
|
|||
Ok(json!({ "total": total, "categories": items }))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_audit_redactions(&self, a: &Args) -> Result<Value> {
|
||||
let findings = self.doc(a)?.audit_redactions().map_err(ToolError::Failed)?;
|
||||
let items: Vec<Value> = findings
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
json!({
|
||||
"page": f.page + 1,
|
||||
"kind": f.kind.id(),
|
||||
"rect": f.rect,
|
||||
"covered_text": f.covered_text,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "findings": items, "count": items.len() }))
|
||||
}
|
||||
|
||||
pub(crate) fn doc_remove_hidden(&mut self, a: &Args) -> Result<Value> {
|
||||
let edit = match a.get("categories") {
|
||||
None => Edit::Sanitize,
|
||||
|
|
|
|||
|
|
@ -70,7 +70,8 @@ impl Automation {
|
|||
"timestamp_time": s.timestamp_time.map(|t| t.to_string()),
|
||||
"modification": modification,
|
||||
"changes": changes,
|
||||
"details": s.details,
|
||||
// Scripts read this, so the sentences stay in English, whatever the interface reads in.
|
||||
"details": s.details.iter().map(|d| d.message.clone()).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -171,6 +172,11 @@ impl Automation {
|
|||
}),
|
||||
Some(v) => Some(v.as_u64().filter(|n| (1..=3).contains(n)).ok_or_else(|| bad("certify must be 1, 2 or 3"))? as u8),
|
||||
};
|
||||
let graphic = match a.get("graphic") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(g) if rect.is_none() && field.is_none() => return Err(bad(format!("graphic {g} needs a visible signature: pass rect or field"))),
|
||||
Some(g) => Some(self.signature_graphic(g)?),
|
||||
};
|
||||
let out = self.resolve(a.str("out")?, true)?;
|
||||
let opts = SignOptions {
|
||||
field,
|
||||
|
|
@ -180,9 +186,14 @@ impl Automation {
|
|||
location: a.opt_str("location")?.map(str::to_string),
|
||||
contact: a.opt_str("contact")?.map(str::to_string),
|
||||
certify,
|
||||
graphic,
|
||||
..SignOptions::default()
|
||||
};
|
||||
let signed = self.session.sign(doc_id, &id, opts).map_err(failed)?;
|
||||
let signed = self.session.sign(doc_id, &id, opts).map_err(|e| match e {
|
||||
// A graphic the signer refuses is the caller's to fix.
|
||||
pdfcraft_engine::EditError::Sign(m) if m.starts_with("the signature graphic") => bad(m),
|
||||
e => failed(e),
|
||||
})?;
|
||||
write_atomic(&out, &signed)?;
|
||||
let path = out.to_string_lossy().into_owned();
|
||||
self.session.mark_signed(doc_id, signed.clone(), Some(path.clone())).map_err(failed)?;
|
||||
|
|
@ -191,6 +202,33 @@ impl Automation {
|
|||
Ok(json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? }))
|
||||
}
|
||||
|
||||
/// `{"image": path}` (PNG or JPEG), `{"text": name}` (typed in the script font) or
|
||||
/// `{"strokes": [[[x, y], …], …]}` (y up; the drawing pad is 1 wide).
|
||||
fn signature_graphic(&self, g: &Value) -> Result<sign::Graphic> {
|
||||
let kinds = "graphic must be an object with exactly one of image (a PNG or JPEG path), text, or strokes";
|
||||
let Some(o) = g.as_object().filter(|o| o.len() == 1) else { return Err(bad(kinds)) };
|
||||
if let Some(path) = o.get("image") {
|
||||
let path = self.resolve(path.as_str().ok_or_else(|| bad("graphic.image must be a path"))?, false)?;
|
||||
let file = std::fs::File::open(&path).map_err(|e| bad(format!("graphic.image {}: {e}", path.display())))?;
|
||||
return Ok(pdfcraft_engine::SignatureImage::read(file).map_err(|e| bad(e.to_string()))?.graphic());
|
||||
}
|
||||
if let Some(text) = o.get("text") {
|
||||
let text = text.as_str().ok_or_else(|| bad("graphic.text must be a string"))?;
|
||||
return pdfcraft_engine::typed_signature_graphic(text).ok_or_else(|| {
|
||||
bad(format!(
|
||||
"graphic.text {text:?} has nothing the script font can draw (at most {} characters)",
|
||||
pdfcraft_engine::MAX_SIGNATURE_CHARS
|
||||
))
|
||||
});
|
||||
}
|
||||
if let Some(strokes) = o.get("strokes") {
|
||||
let strokes: Vec<Vec<[f64; 2]>> =
|
||||
serde_json::from_value(strokes.clone()).map_err(|e| bad(format!("graphic.strokes must be strokes of [x, y] points: {e}")))?;
|
||||
return Ok(sign::Graphic::Strokes(strokes));
|
||||
}
|
||||
Err(bad(kinds))
|
||||
}
|
||||
|
||||
pub(crate) fn sign_trust(&mut self, a: &Args) -> Result<Value> {
|
||||
let mut certs: Vec<Certificate> =
|
||||
if a.opt_bool("clear")?.unwrap_or(false) { Vec::new() } else { self.session.trusted_certificates().to_vec() };
|
||||
|
|
|
|||
|
|
@ -168,10 +168,12 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return each match with its page and line rectangles in points (origin top-left).")
|
||||
t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return each match, in page order, with its page and line rectangles in points (origin top-left). Pages are read only until the limit is reached.")
|
||||
.ro()
|
||||
.cmd("edit.find")
|
||||
.with(schema(json!({ "doc": doc(), "query": { "type": "string", "minLength": 1 }, "limit": { "type": "integer", "minimum": 1, "description": "Maximum matches (default 500)." } }), &["doc", "query"])),
|
||||
.with(schema(json!({ "doc": doc(), "query": { "type": "string", "minLength": 1 }, "limit": { "type": "integer", "minimum": 1, "description": "Maximum matches (default 500). The search stops once it has this many; `complete` says whether every match was returned, and when it is false `next` holds the start_page and skip that continue the search." },
|
||||
"start_page": { "type": "integer", "minimum": 1, "description": "Page to start searching from (default 1)." },
|
||||
"skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." } }), &["doc", "query"])),
|
||||
t("page_rotate", "Rotate pages", "Rotate pages by a multiple of 90 degrees (positive is clockwise): the listed pages (default all), filtered like Acrobat's Rotate Pages by subset (all, even, odd page numbers) and orientation (all, landscape, portrait). Undoable.")
|
||||
.cmd("page.rotate")
|
||||
.with(schema(
|
||||
|
|
@ -182,6 +184,18 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
}),
|
||||
&["doc", "degrees"],
|
||||
)),
|
||||
t("page_enhance_camera", "Enhance camera image", "Straighten a photographed document (Scan & OCR ▸ Enhance ▸ Camera image): the part of the page inside the four corners (x1, y1, … x4, y4 in top-left-origin points, any order, a convex quadrilateral; when omitted, the document's edges are found automatically, or an error says they could not be) is warped to an upright rectangle that replaces what the page shows, at the page picture's own resolution (at most 600 dpi). mode is how it looks: original, colour (default), greyscale or bw (black and white, stored losslessly). level (0–100, default 0) then cleans the photo up: even lighting so the paper turns white (casts, shadows, dark edges), more contrast, and from 60 no isolated specks; colour ink stays colour. For bw the level is the threshold (higher keeps fainter strokes black). The page's comments, links and bookmarks stay. Undoable.")
|
||||
.cmd("ocr.enhance_camera")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"corners": { "type": "array", "items": { "type": "number" }, "minItems": 8, "maxItems": 8 },
|
||||
"level": { "type": "integer", "minimum": 0, "maximum": 100 },
|
||||
"mode": { "type": "string", "enum": ["original", "colour", "greyscale", "bw"] },
|
||||
}),
|
||||
&["doc", "page"],
|
||||
)),
|
||||
t("page_delete", "Delete pages", "Delete pages. Undoable until saved.")
|
||||
.destructive()
|
||||
.cmd("page.delete")
|
||||
|
|
@ -205,7 +219,7 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
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!({
|
||||
|
|
@ -222,7 +236,7 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
.cmd("create.multiple")
|
||||
.with(schema(
|
||||
json!({
|
||||
"paths": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": pdfcraft_engine::MAX_CREATE_FILES },
|
||||
"paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
|
||||
"mode": { "type": "string", "enum": ["combine", "separate"] },
|
||||
"pages": { "type": "array", "items": { "type": ["string", "null"] } },
|
||||
"out": save_out.clone(),
|
||||
|
|
@ -435,6 +449,13 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t(
|
||||
"doc_audit_redactions",
|
||||
"Audit faux redactions",
|
||||
"Find faux redactions: opaque filled rectangles painted over text that is still extractable, and /Redact annotations that were marked but never applied. Paint-order and colour-aware: a rectangle counts only when painted after the text and dark enough to hide it (page backgrounds and light shading are not flagged). Each finding reports the 1-based page, the kind (covered-text or unapplied-mark), the rectangle, and the hidden text. Fails closed: unreadable content is an error, never a clean bill. A properly applied redaction removes the text and never flags.",
|
||||
)
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("printers", "List printers", "The printers the system's print spooler knows (CUPS on macOS and Linux), with the default marked.").ro().with(schema(json!({}), &[])),
|
||||
t("printer_options", "List printer options", "A printer driver's own job options (CUPS: from its PPD), such as the paper tray, paper type or finishing: key, label, group, default and choices. Pass chosen values to doc_print as options. Empty on Windows, where the driver's preferences window holds them.")
|
||||
.ro()
|
||||
|
|
@ -442,7 +463,7 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
t(
|
||||
"doc_print",
|
||||
"Print",
|
||||
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid/A3/A4/A5. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
|
||||
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, or A0/A1/A2/A3/A4/A5 (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
|
|
@ -479,7 +500,7 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
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(
|
||||
|
|
@ -644,7 +665,7 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
.ro()
|
||||
.cmd("sign.digital")
|
||||
.with(schema(json!({}), &[])),
|
||||
t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:<common name or fingerprint>\" from sign_keychain_ids, or on Windows a store identity: \"windows:<common name or fingerprint>\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).")
|
||||
t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:<common name or fingerprint>\" from sign_keychain_ids, or on Windows a store identity: \"windows:<common name or fingerprint>\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).")
|
||||
.cmd("sign.digital")
|
||||
.with(schema(
|
||||
json!({
|
||||
|
|
@ -658,6 +679,17 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
"location": { "type": "string" },
|
||||
"contact": { "type": "string" },
|
||||
"certify": { "type": "string", "enum": ["no_changes", "form_fill", "comments"] },
|
||||
"graphic": {
|
||||
"type": "object",
|
||||
"description": "Exactly one of image, text or strokes.",
|
||||
"properties": {
|
||||
"image": { "type": "string", "description": "A PNG or JPEG file." },
|
||||
"text": { "type": "string", "description": "Typed in the script font." },
|
||||
"strokes": { "type": "array", "items": { "type": "array", "items": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 } } },
|
||||
},
|
||||
"minProperties": 1,
|
||||
"maxProperties": 1,
|
||||
},
|
||||
"out": { "type": "string", "description": "Where to save the signed document." },
|
||||
}),
|
||||
&["doc", "id", "out"],
|
||||
|
|
@ -764,7 +796,7 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
}),
|
||||
&["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 +811,8 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
"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"],
|
||||
|
|
@ -961,7 +995,7 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
t(
|
||||
"fill_sign_add",
|
||||
"Fill & Sign: type text or place a mark",
|
||||
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 4 megapixels, preserving transparency). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations.",
|
||||
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations.",
|
||||
)
|
||||
.cmd("sign.fill.text")
|
||||
.with(schema(
|
||||
|
|
@ -983,7 +1017,7 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
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!({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -1154,8 +1184,25 @@ fn protecting_through_tools() {
|
|||
assert!(matches!(b.call("page_delete", &json!({ "doc": re, "pages": [1] })), Err(ToolError::Failed(_))));
|
||||
assert!(matches!(b.call("doc_unprotect", &json!({ "doc": re })), Err(ToolError::Failed(_))));
|
||||
ok(&mut b, "comment_add", json!({ "doc": re, "page": 1, "type": "note", "at": [10, 10], "contents": "allowed" }));
|
||||
// Copying wasn't allowed either (#568): no text or images come out, though search works.
|
||||
for (tool, args) in [
|
||||
("text_extract", json!({ "doc": re })),
|
||||
("text_paragraphs", json!({ "doc": re, "page": 1 })),
|
||||
("doc_export_text", json!({ "doc": re, "path": "out.txt" })),
|
||||
("doc_export_images", json!({ "doc": re, "folder": "pages" })),
|
||||
("doc_export_all_images", json!({ "doc": re, "folder": "images" })),
|
||||
("doc_export_office", json!({ "doc": re, "path": "out.docx" })),
|
||||
] {
|
||||
match b.call(tool, &args) {
|
||||
Err(ToolError::Failed(m)) => assert!(m.contains("don't allow copying"), "{tool}: {m}"),
|
||||
other => panic!("{tool} is refused: {other:?}"),
|
||||
}
|
||||
}
|
||||
assert!(!dir.join("out.txt").exists() && !dir.join("pages").exists() && !dir.join("out.docx").exists());
|
||||
assert_eq!(ok(&mut b, "text_find", json!({ "doc": re, "query": "Page" }))["count"], 3);
|
||||
// With the permissions password everything is possible, including removing security.
|
||||
let owner = ok(&mut b, "doc_open", json!({ "path": "locked.pdf", "password": "boss" }))["doc"].as_u64().unwrap();
|
||||
assert!(ok(&mut b, "text_extract", json!({ "doc": owner }))["pages"][0]["text"].as_str().is_some_and(|t| t.contains("Page")));
|
||||
assert_eq!(ok(&mut b, "doc_unprotect", json!({ "doc": owner }))["security"]["protected"], false);
|
||||
ok(&mut b, "doc_save", json!({ "doc": owner, "path": "open.pdf" }));
|
||||
let mut c = auto(&dir);
|
||||
|
|
@ -1396,6 +1443,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");
|
||||
|
|
@ -1599,6 +1680,66 @@ fn editing_page_images_through_tools() {
|
|||
assert!(matches!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "delete" })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enhance_camera_image_through_tools() {
|
||||
let dir = workdir("enhance-camera");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 72, "pages": [1] }));
|
||||
let photo = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/a_page_1.png"] }))["doc"].as_u64().unwrap();
|
||||
// A 200 × 300 pt page at one pixel per point; the corners, in any order, keep a 160 × 240 part.
|
||||
let r = ok(&mut a, "page_enhance_camera", json!({ "doc": photo, "page": 1, "corners": [180, 270, 20, 30, 20, 270, 180, 30] }));
|
||||
assert_eq!(r["undo"], "Enhance camera image");
|
||||
assert_eq!(r["pages"], 1);
|
||||
let images = ok(&mut a, "page_images", json!({ "doc": photo, "page": 1 }));
|
||||
assert_eq!(images["count"], 1);
|
||||
assert_eq!(images["images"][0]["pixels"], json!([160, 240]));
|
||||
assert_eq!(images["images"][0]["rect"], json!([0.0, 0.0, 160.0, 240.0]));
|
||||
for bad in [json!([0, 0, 10, 10, 20, 20, 30, 30]), json!([0, 0, 10]), json!("corners")] {
|
||||
let e = a.call("page_enhance_camera", &json!({ "doc": photo, "page": 1, "corners": bad }));
|
||||
assert!(matches!(e, Err(ToolError::InvalidArgs(_) | ToolError::Failed(_))), "{bad}: {e:?}");
|
||||
}
|
||||
// Without corners on a page with no photo, the edges can't be found: an error that says so.
|
||||
let e = a.call("page_enhance_camera", &json!({ "doc": photo, "page": 1 }));
|
||||
assert!(matches!(&e, Err(ToolError::Failed(m)) if m.contains("corners")), "{e:?}");
|
||||
assert!(matches!(
|
||||
a.call("page_enhance_camera", &json!({ "doc": photo, "page": 9, "corners": [0, 0, 9, 0, 9, 9, 0, 9] })),
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enhance_camera_image_finds_the_edges_by_itself() {
|
||||
let dir = workdir("enhance-camera-auto");
|
||||
// A 600 × 450 photo: a white sheet, tilted, on a dark table.
|
||||
let sheet = [[90.0, 60.0], [500.0, 30.0], [525.0, 405.0], [60.0, 375.0]];
|
||||
let inside = |x: f64, y: f64| {
|
||||
(0..4).all(|i| {
|
||||
let (a, b): ([f64; 2], [f64; 2]) = (sheet[i], sheet[(i + 1) % 4]);
|
||||
(b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]) >= 0.0
|
||||
})
|
||||
};
|
||||
let img = image::RgbImage::from_fn(600, 450, |x, y| {
|
||||
if inside(x as f64 + 0.5, y as f64 + 0.5) { image::Rgb([240, 238, 232]) } else { image::Rgb([70, 60, 50]) }
|
||||
});
|
||||
img.save(dir.join("photo.jpg")).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let photo = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["photo.jpg"] }))["doc"].as_u64().unwrap();
|
||||
let r = ok(&mut a, "page_enhance_camera", json!({ "doc": photo, "page": 1 }));
|
||||
assert_eq!(r["detected"], true);
|
||||
assert_eq!(r["undo"], "Enhance camera image");
|
||||
// A clean-up level outside 0–100 is refused before anything changes.
|
||||
let e = a.call("page_enhance_camera", &json!({ "doc": photo, "page": 1, "corners": [0, 0, 100, 0, 100, 100, 0, 100], "level": 101 }));
|
||||
assert!(matches!(&e, Err(ToolError::InvalidArgs(m)) if m.contains("level")), "{e:?}");
|
||||
ok(&mut a, "page_enhance_camera", json!({ "doc": photo, "page": 1, "corners": [0, 0, 100, 0, 100, 100, 0, 100], "level": 80 }));
|
||||
// One point per pixel: the corners it used are the sheet's.
|
||||
let used: Vec<f64> = r["corners"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
for (k, p) in sheet.iter().enumerate() {
|
||||
let (x, y) = (used[2 * k], used[2 * k + 1]);
|
||||
assert!((x - p[0]).hypot(y - p[1]) < 4.0, "{used:?} vs {sheet:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_form_artwork_through_tools() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental};
|
||||
|
|
@ -1881,6 +2022,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<Vec<Content>, 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");
|
||||
|
|
@ -1937,6 +2098,17 @@ fn creating_from_multiple_files_through_tools() {
|
|||
assert!(!dir.join("out/a.pdf").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_multiple_has_no_file_count_limit() {
|
||||
let dir = workdir("create-many");
|
||||
let mut a = auto(&dir);
|
||||
// The same 3-page PDF listed 1,001 times: every listed file is a source, however many there are.
|
||||
let paths = vec!["a.pdf"; 1001];
|
||||
let made = ok(&mut a, "doc_create_multiple", json!({ "paths": paths, "out": "many.pdf", "open": true }));
|
||||
assert_eq!(made["document"]["pages"], 3 * 1001, "{made}");
|
||||
assert!(dir.join("many.pdf").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creating_images_with_dpi_through_tools() {
|
||||
let dir = workdir("image-dpi");
|
||||
|
|
@ -2198,6 +2370,50 @@ fn removing_hidden_information_through_tools() {
|
|||
assert!(matches!(a.call("doc_remove_hidden", &json!({ "doc": doc, "categories": ["nonsense"] })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
/// A one-page PDF whose text "TOP SECRET" sits under an opaque black rectangle.
|
||||
fn faux_redacted_fixture() -> Vec<u8> {
|
||||
let body = "BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f";
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [4 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_string().into_bytes(),
|
||||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auditing_faux_redactions_through_tools() {
|
||||
let dir = workdir("audit-redactions");
|
||||
std::fs::write(dir.join("redacted.pdf"), faux_redacted_fixture()).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "redacted.pdf" }))["doc"].as_u64().unwrap();
|
||||
let r = ok(&mut a, "doc_audit_redactions", json!({ "doc": doc }));
|
||||
assert_eq!(r["count"], 1, "{r}");
|
||||
let f = &r["findings"][0];
|
||||
assert_eq!(f["page"], 1);
|
||||
assert_eq!(f["kind"], "covered-text");
|
||||
assert!(f["covered_text"].as_str().unwrap().contains("TOP SECRET"), "{f}");
|
||||
// A clean document audits clean.
|
||||
let clean = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut a, "doc_audit_redactions", json!({ "doc": clean }))["count"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printing_through_tools() {
|
||||
let dir = workdir("print");
|
||||
|
|
@ -2815,6 +3031,74 @@ fn drawing_comments_through_tools() {
|
|||
assert!(matches!(png[0], Content::Png { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_with_an_image_typed_or_drawn_graphic_through_tools() {
|
||||
let dir = workdir("signing-graphic");
|
||||
// A solid red 120 x 40 picture.
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut png, 120, 40);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let rgba: Vec<u8> = (0..120 * 40).flat_map(|_| [220, 0, 0, 255]).collect();
|
||||
encoder.write_header().unwrap().write_image_data(&rgba).unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("logo.png"), png).unwrap();
|
||||
std::fs::write(dir.join("broken.png"), b"broken").unwrap();
|
||||
let mut a = auto(&dir);
|
||||
ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret1", "path": "ada.p12" }));
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let sign = |a: &mut Automation, rect: [f64; 4], graphic: Value, out: &str| {
|
||||
a.call(
|
||||
"sign_document",
|
||||
&json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "page": 1, "rect": rect, "graphic": graphic, "out": out }),
|
||||
)
|
||||
};
|
||||
for (graphic, why) in [
|
||||
(json!({ "image": "logo.png", "text": "Ada" }), "two kinds"),
|
||||
(json!({}), "no kind"),
|
||||
(json!({ "picture": "logo.png" }), "unknown key"),
|
||||
(json!("logo.png"), "not an object"),
|
||||
(json!({ "image": "broken.png" }), "not an image"),
|
||||
(json!({ "text": " " }), "nothing to draw"),
|
||||
(json!({ "strokes": [[[0, 0], ["x", 1]]] }), "malformed strokes"),
|
||||
(json!({ "strokes": [] }), "empty strokes"),
|
||||
] {
|
||||
let r = sign(&mut a, [20.0, 200.0, 180.0, 250.0], graphic, "bad.pdf");
|
||||
assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "{why}: {r:?}");
|
||||
}
|
||||
// A missing file fails as it does for every tool that reads one.
|
||||
assert!(matches!(sign(&mut a, [20.0, 200.0, 180.0, 250.0], json!({ "image": "missing.png" }), "bad.pdf"), Err(ToolError::Failed(_))));
|
||||
// An invisible signature shows nothing, so a graphic for one is a mistake.
|
||||
let r = a.call("sign_document", &json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "graphic": { "text": "Ada" }, "out": "bad.pdf" }));
|
||||
assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "{r:?}");
|
||||
assert!(!dir.join("bad.pdf").exists());
|
||||
|
||||
// Image, typed and drawn graphics, each a signature of its own, all intact.
|
||||
sign(&mut a, [20.0, 200.0, 180.0, 250.0], json!({ "image": "logo.png" }), "s1.pdf").unwrap();
|
||||
sign(&mut a, [20.0, 130.0, 180.0, 180.0], json!({ "text": "Ada Lovelace" }), "s2.pdf").unwrap();
|
||||
sign(&mut a, [20.0, 60.0, 180.0, 110.0], json!({ "strokes": [[[0, 0], [0.3, 0.2], [0.6, 0], [1, 0.2]]] }), "s3.pdf").unwrap();
|
||||
let list = ok(&mut a, "sign_list", json!({ "doc": doc }));
|
||||
assert_eq!(list["count"], 3);
|
||||
for s in list["signatures"].as_array().unwrap() {
|
||||
assert_eq!((s["status"].as_str(), s["visible"].as_bool()), (Some("unknown"), Some(true)), "{s}");
|
||||
}
|
||||
// At 72 dpi a pixel is a point. Each graphic is in the left half of its box (x 23–97), the
|
||||
// details on the right.
|
||||
let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||||
let Content::Png { data, width, .. } = &png[0] else { panic!("expected an image") };
|
||||
let mut reader = png::Decoder::new(std::io::Cursor::new(data.as_slice())).read_info().unwrap();
|
||||
let mut buf = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let frame = reader.next_frame(&mut buf).unwrap();
|
||||
let channels = frame.line_size / *width as usize;
|
||||
let px = |x: usize, y: usize| buf[y * frame.line_size + x * channels..][..3].to_vec();
|
||||
assert_eq!(px(60, 225), [220, 0, 0], "the image fills the left half's middle");
|
||||
assert_ne!(px(150, 225), [220, 0, 0], "and not the details");
|
||||
let inked = |y0: usize, y1: usize| (y0..y1).any(|y| (23..97).any(|x| px(x, y).iter().all(|c| *c < 100)));
|
||||
assert!(inked(130, 180), "the typed name is drawn");
|
||||
assert!(inked(60, 110), "the strokes are drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_ids_signing_and_validation_through_tools() {
|
||||
let dir = workdir("signing");
|
||||
|
|
@ -3917,3 +4201,51 @@ fn text_extract_with_a_rect_takes_one_column() {
|
|||
assert!(matches!(e, ToolError::InvalidArgs(_)), "{bad}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Independent inch/mm dimensions, checked in the saved file through the tool path.
|
||||
#[test]
|
||||
fn architectural_and_iso_papers_through_tools() {
|
||||
let dir = workdir("plotter-papers");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let mm = 72.0 / 25.4;
|
||||
for (name, w, h) in [
|
||||
("ARCH A", 9.0 * 72.0, 12.0 * 72.0),
|
||||
("arch b", 12.0 * 72.0, 18.0 * 72.0),
|
||||
("ARCH C", 18.0 * 72.0, 24.0 * 72.0),
|
||||
("ARCH D", 24.0 * 72.0, 36.0 * 72.0),
|
||||
("ARCH E1", 30.0 * 72.0, 42.0 * 72.0),
|
||||
("ARCH E (36 x 48 in)", 36.0 * 72.0, 48.0 * 72.0),
|
||||
("ANSI C", 17.0 * 72.0, 22.0 * 72.0),
|
||||
("ANSI D", 22.0 * 72.0, 34.0 * 72.0),
|
||||
("ANSI E", 34.0 * 72.0, 44.0 * 72.0),
|
||||
("A2", 420.0 * mm, 594.0 * mm),
|
||||
("A1", 594.0 * mm, 841.0 * mm),
|
||||
("A0", 841.0 * mm, 1189.0 * mm),
|
||||
("Letter", 612.0, 792.0),
|
||||
("US Legal", 612.0, 1008.0),
|
||||
("Tabloid", 792.0, 1224.0),
|
||||
] {
|
||||
for orientation in ["portrait", "landscape"] {
|
||||
let out = ok(
|
||||
&mut a,
|
||||
"doc_print",
|
||||
json!({"doc": doc, "pages": "1", "paper": name,
|
||||
"layout": "actual", "orientation": orientation, "path": "plot.pdf"}),
|
||||
);
|
||||
assert_eq!(out["sheets"], 1);
|
||||
let bytes = std::fs::read(dir.join("plot.pdf")).unwrap();
|
||||
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap();
|
||||
let pages = pdfcraft_model::pages(&saved);
|
||||
assert_eq!(pages.len(), 1);
|
||||
let (ew, eh) = if orientation == "landscape" { (h, w) } else { (w, h) };
|
||||
let (aw, ah) = pages[0].display_size(&saved);
|
||||
assert!((aw - ew).abs() < 0.02 && (ah - eh).abs() < 0.02, "{name} {orientation}: {aw} x {ah}");
|
||||
}
|
||||
}
|
||||
let before = std::fs::read(dir.join("plot.pdf")).unwrap();
|
||||
let bad = a.call("doc_print", &json!({"doc": doc, "paper": "ARCH Z", "path": "plot.pdf"}));
|
||||
assert!(matches!(bad, Err(ToolError::InvalidArgs(m)) if m.contains("ARCH E1") && m.contains("A0")));
|
||||
assert_eq!(std::fs::read(dir.join("plot.pdf")).unwrap(), before);
|
||||
assert!(a.call("doc_print", &json!({"doc": doc, "paper": "ARCH D", "path": "../escape.pdf"})).is_err());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,8 +81,9 @@ fn zoo() -> Vec<Value> {
|
|||
|
||||
/// Paths that must never resolve inside the root. Absolute ones point into the test's own temp
|
||||
/// `base` (the root's parent), never at real system files or network shares, so a confinement
|
||||
/// bug can only touch what the test made.
|
||||
fn escapes(base: &std::path::Path) -> Vec<String> {
|
||||
/// bug can only touch what the test made. `linked`: the root holds `link-out`, a symbolic link
|
||||
/// to `base`.
|
||||
fn escapes(base: &std::path::Path, linked: bool) -> Vec<String> {
|
||||
let mut out: Vec<String> = vec![
|
||||
"../outside.pdf".into(),
|
||||
"../../outside.pdf".into(),
|
||||
|
|
@ -92,8 +93,10 @@ fn escapes(base: &std::path::Path) -> Vec<String> {
|
|||
base.join("outside.pdf").to_string_lossy().into_owned(),
|
||||
base.join("new-outside.pdf").to_string_lossy().into_owned(),
|
||||
base.join("sub").join("new-outside.pdf").to_string_lossy().into_owned(),
|
||||
"link-out/outside.pdf".into(), // through a planted symbolic link, when the system allows one
|
||||
];
|
||||
if linked {
|
||||
out.push("link-out/outside.pdf".into());
|
||||
}
|
||||
// Backslashes separate only on Windows; elsewhere this is an ordinary file name in the root.
|
||||
if cfg!(windows) {
|
||||
out.push(r"..\..\outside.pdf".into());
|
||||
|
|
@ -101,6 +104,23 @@ fn escapes(base: &std::path::Path) -> Vec<String> {
|
|||
out
|
||||
}
|
||||
|
||||
/// A symbolic link to a folder, where the system allows one (Windows needs Developer Mode or an
|
||||
/// administrator for it, #948).
|
||||
fn dir_symlink(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(target, link)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::os::windows::fs::symlink_dir(target, link)
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
Err(std::io::Error::other(format!("no symbolic links here: {} {}", target.display(), link.display())))
|
||||
}
|
||||
}
|
||||
|
||||
/// The properties of `def` whose values are paths, by name and whether they are a list.
|
||||
fn path_props(def: &pdfcraft_automation::ToolDef) -> Vec<(String, bool)> {
|
||||
let Some(props) = def.input_schema["properties"].as_object() else { return Vec::new() };
|
||||
|
|
@ -223,11 +243,15 @@ fn no_tool_escapes_the_root() {
|
|||
.expect("outside file");
|
||||
let before = listing(&base);
|
||||
|
||||
// A link inside the root pointing out of it, where the system allows one to be made.
|
||||
#[cfg(unix)]
|
||||
let _ = std::os::unix::fs::symlink(&base, root.join("link-out"));
|
||||
#[cfg(windows)]
|
||||
let _ = std::os::windows::fs::symlink_dir(&base, root.join("link-out"));
|
||||
// A link inside the root pointing out of it, where the system allows one to be made. Without
|
||||
// it `link-out/…` is an ordinary path inside the root, which tools that write rightly accept.
|
||||
let linked = match dir_symlink(&base, &root.join("link-out")) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
eprintln!("escapes through a symbolic link not checked: {e}");
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let mut a = Automation::new().with_root(&root).expect("root");
|
||||
|
||||
|
|
@ -247,7 +271,7 @@ fn no_tool_escapes_the_root() {
|
|||
|
||||
for def in tools() {
|
||||
for (prop, is_list) in path_props(&def) {
|
||||
for escape in escapes(&base) {
|
||||
for escape in escapes(&base, linked) {
|
||||
let mut obj = plausible_with(&def, Some(doc));
|
||||
obj.insert(prop.clone(), if is_list { json!([escape]) } else { json!(escape) });
|
||||
let args = Value::Object(obj);
|
||||
|
|
|
|||
|
|
@ -145,6 +145,8 @@ impl ObjectReader {
|
|||
|
||||
pub(crate) fn try_get(&mut self, reference: ObjRef) -> Result<Arc<Object>, CosError> {
|
||||
let object = self.document.try_get(reference.num);
|
||||
// A missed eviction here only costs a re-parse, so never fail the read on a poisoned
|
||||
// lock. The editing-cache evictors propagate instead; they guard user edits.
|
||||
if let Ok(mut streams) = self.document.objstms.lock() {
|
||||
// Only insertion changes this private cache between reads. Most neighboring
|
||||
// objects reuse a decoded stream: do not sum the whole store for every object.
|
||||
|
|
@ -355,10 +357,7 @@ impl Document {
|
|||
return Ok(());
|
||||
};
|
||||
let params = crate::security::encrypt_dict(self, &dict);
|
||||
let id0 = match self.trailer.get(b"ID") {
|
||||
Some(Object::Array(a)) => a.first().and_then(|s| s.as_string()).map(|s| s.bytes.clone()).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let id0 = self.first_id();
|
||||
let handler = pdfcraft_crypt::SecurityHandler::open(params, &id0, password).map_err(|e| match e {
|
||||
pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword,
|
||||
pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword,
|
||||
|
|
@ -372,6 +371,31 @@ impl Document {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// The first file identifier, part of the encryption key (empty when there is none).
|
||||
fn first_id(&self) -> Vec<u8> {
|
||||
match self.trailer.get(b"ID") {
|
||||
Some(Object::Array(a)) => a.first().and_then(|s| s.as_string()).map(|s| s.bytes.clone()).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lift the restrictions of a document opened with its user password by entering the
|
||||
/// owner (permissions) password (§7.6.4). Fails with `WrongPassword` for any other
|
||||
/// password. Does nothing when the document is unencrypted or already opened as owner.
|
||||
/// Both passwords unlock the same file key, so nothing read so far is decoded again.
|
||||
pub fn unlock(&mut self, password: &str) -> Result<(), CosError> {
|
||||
let Some(current) = self.security.as_deref() else { return Ok(()) };
|
||||
if current.auth() == pdfcraft_crypt::Auth::Owner {
|
||||
return Ok(());
|
||||
}
|
||||
let owner = current.authenticate_owner(&self.first_id(), password).map_err(|e| match e {
|
||||
pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword,
|
||||
other => CosError::Security(other.to_string()),
|
||||
})?;
|
||||
self.security = Some(Arc::new(owner));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The security handler, when the document is encrypted.
|
||||
pub fn security(&self) -> Option<&pdfcraft_crypt::SecurityHandler> {
|
||||
self.security.as_deref()
|
||||
|
|
@ -607,10 +631,81 @@ impl Document {
|
|||
LoadGuard::enter(num).ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?;
|
||||
let obj = self.load(num, 0)?;
|
||||
let obj = Arc::new(obj);
|
||||
// The editing cache is bounded like the scan reader's: re-parsing a file-backed view
|
||||
// is cheap, and returned objects outlive eviction.
|
||||
self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone());
|
||||
self.evict_editor_cache_if_over_budget(num, &obj)?;
|
||||
Ok(obj)
|
||||
}
|
||||
|
||||
/// Bounds for the editing cache: roomier than the scan reader's (128 objects / 8 MB)
|
||||
/// so interactive editing does not thrash, but still capped so touching a large
|
||||
/// document cannot pin its whole object graph. Only owned (decrypted/copied) bytes
|
||||
/// count; file-backed views re-parse for free.
|
||||
///
|
||||
/// Worst case per document: input bytes + up to `EDITOR_CACHE_OBJECTS` ×
|
||||
/// `EDITOR_LARGE_OBJECT` (1024 × 256 KB = 256 MB) of small objects between full
|
||||
/// accounting passes + `EDITOR_OBJSTM_BYTES` (32 MB) + unsaved `overlay` edits.
|
||||
/// The count cap always holds; byte accounting between passes is approximate.
|
||||
/// Clones share these caches, so one clone's eviction drops another's hot set;
|
||||
/// that only costs re-parsing, never correctness. Eviction keeps just the newcomer:
|
||||
/// file-backed views re-parse cheaply and editor working sets stay far below 1024.
|
||||
const EDITOR_CACHE_OBJECTS: usize = 1024;
|
||||
const EDITOR_CACHE_BYTES: usize = 32 * 1024 * 1024;
|
||||
/// Object streams decode to megabytes each, so fewer are kept than plain objects.
|
||||
const EDITOR_OBJSTM_OBJECTS: usize = 256;
|
||||
const EDITOR_OBJSTM_BYTES: usize = 32 * 1024 * 1024;
|
||||
/// Newcomers above this force a full accounting pass even below the count cap:
|
||||
/// `EDITOR_CACHE_BYTES / 128`, so 128 large newcomers always trigger a pass before the
|
||||
/// byte budget can double.
|
||||
const EDITOR_LARGE_OBJECT: usize = 256 * 1024;
|
||||
|
||||
fn evict_editor_cache_if_over_budget(&self, fresh_num: u32, fresh: &Object) -> Result<(), CosError> {
|
||||
// Fast path: the full fold is O(cache) per insert, so skip it for a small newcomer
|
||||
// below the count cap. `len` is copied out first; nothing here holds the lock.
|
||||
let len = self.cache.lock().map_err(|_| CosError::Poisoned)?.len();
|
||||
if len <= Self::EDITOR_CACHE_OBJECTS && owned_bytes(fresh, &self.data, 0) <= Self::EDITOR_LARGE_OBJECT {
|
||||
return Ok(());
|
||||
}
|
||||
// Walk a snapshot off-lock; re-lock only to evict.
|
||||
let live: Vec<(u32, Arc<Object>)> = self.cache.lock().map_err(|_| CosError::Poisoned)?.iter().map(|(n, o)| (*n, o.clone())).collect();
|
||||
let data = &self.data;
|
||||
let owned: usize = live.iter().fold(0usize, |n, (_, o)| n.saturating_add(owned_bytes(o, data, 0)));
|
||||
if live.len() <= Self::EDITOR_CACHE_OBJECTS && owned <= Self::EDITOR_CACHE_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
// Re-check under the final lock: another thread may have inserted or evicted since
|
||||
// the snapshot. Keep its newcomers, and skip the eviction if back within budget.
|
||||
let snapshot: std::collections::HashSet<u32> = live.into_iter().map(|(n, _)| n).collect();
|
||||
let mut cache = self.cache.lock().map_err(|_| CosError::Poisoned)?;
|
||||
if cache.len() <= Self::EDITOR_CACHE_OBJECTS {
|
||||
let data = &self.data;
|
||||
let owned: usize = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0)));
|
||||
if owned <= Self::EDITOR_CACHE_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
cache.retain(|n, _| *n == fresh_num || !snapshot.contains(n));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn evict_editor_streams_if_over_budget(&self, current: u32) -> Result<(), CosError> {
|
||||
// The caller inserts `current` first, so retaining it keeps a live entry.
|
||||
let over = {
|
||||
let streams = self.objstms.lock().map_err(|_| CosError::Poisoned)?;
|
||||
let bytes = streams.values().fold(0usize, |bytes, stream| {
|
||||
bytes
|
||||
.saturating_add(stream.data.capacity())
|
||||
.saturating_add(stream.index.capacity().saturating_mul(std::mem::size_of::<(u32, usize)>()))
|
||||
});
|
||||
bytes > Self::EDITOR_OBJSTM_BYTES || streams.len() > Self::EDITOR_OBJSTM_OBJECTS
|
||||
};
|
||||
if over {
|
||||
self.objstms.lock().map_err(|_| CosError::Poisoned)?.retain(|n, _| *n == current);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Visit every current object without filling this document's editing cache.
|
||||
///
|
||||
/// Full-document searches (for example, standalone timestamps) need to inspect objects
|
||||
|
|
@ -720,7 +815,10 @@ impl Document {
|
|||
index.push((onum.max(0) as u32, first + off.max(0) as usize));
|
||||
}
|
||||
let stm = Arc::new(ObjStm { index, data: Arc::new(data) });
|
||||
// Same bound as the object cache: decoded object streams can be megabytes each.
|
||||
// Insert first so the retained `num` is actually in the map.
|
||||
self.objstms.lock().map_err(|_| CosError::Poisoned)?.insert(num, stm.clone());
|
||||
self.evict_editor_streams_if_over_budget(num)?;
|
||||
Ok(stm)
|
||||
}
|
||||
|
||||
|
|
@ -1361,6 +1459,74 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_cache_stays_bounded() {
|
||||
let mut source = Document::new_empty();
|
||||
let objects: Vec<_> = (0..(Document::EDITOR_CACHE_OBJECTS + 64))
|
||||
.map(|i| {
|
||||
let mut value = vec![b'x'; 1024];
|
||||
value[..4].copy_from_slice(&(i as u32).to_le_bytes());
|
||||
Object::Ref(source.add(Object::String(crate::PdfString::literal(value))))
|
||||
})
|
||||
.collect();
|
||||
source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap();
|
||||
let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap());
|
||||
let doc = Document::open(bytes).unwrap();
|
||||
let root = doc.get(doc.root().unwrap());
|
||||
let references = root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap().clone();
|
||||
let mut held = None;
|
||||
for (i, reference) in references.iter().enumerate() {
|
||||
let Object::Ref(reference) = reference else { panic!("indirect object") };
|
||||
let object = doc.get(*reference);
|
||||
assert_eq!(&object.as_string().unwrap().bytes[..4], &(i as u32).to_le_bytes());
|
||||
if i == 0 {
|
||||
held = Some(object);
|
||||
}
|
||||
assert!(doc.cache.lock().unwrap().len() <= Document::EDITOR_CACHE_OBJECTS, "editing must not pin the whole object graph");
|
||||
// Eviction keeps the newcomer.
|
||||
assert!(doc.cache.lock().unwrap().contains_key(&reference.num), "eviction must keep the newcomer");
|
||||
}
|
||||
// Returned objects survive eviction, through both handles of the shared cache.
|
||||
assert_eq!(&held.unwrap().as_string().unwrap().bytes[..4], &0u32.to_le_bytes());
|
||||
let snapshot = doc.clone();
|
||||
let first = references[0].as_ref().unwrap();
|
||||
assert_eq!(&snapshot.get(first).as_string().unwrap().bytes[..4], &0u32.to_le_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_cache_byte_bound_fires() {
|
||||
// 40 × 1 MB strings: far below the count cap, above the 32 MB byte budget. Each
|
||||
// newcomer exceeds EDITOR_LARGE_OBJECT, so every insert takes a full accounting pass.
|
||||
let mut source = Document::new_empty();
|
||||
let objects: Vec<_> = (0..40)
|
||||
.map(|i| {
|
||||
let mut value = vec![b'y'; 1024 * 1024];
|
||||
value[..4].copy_from_slice(&(i as u32).to_le_bytes());
|
||||
Object::Ref(source.add(Object::String(crate::PdfString::literal(value))))
|
||||
})
|
||||
.collect();
|
||||
source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap();
|
||||
let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap());
|
||||
let doc = Document::open(bytes).unwrap();
|
||||
let root = doc.get(doc.root().unwrap());
|
||||
let references = root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap().clone();
|
||||
let mut held = None;
|
||||
for (i, reference) in references.iter().enumerate() {
|
||||
let Object::Ref(reference) = reference else { panic!("indirect object") };
|
||||
let object = doc.get(*reference);
|
||||
assert_eq!(&object.as_string().unwrap().bytes[..4], &(i as u32).to_le_bytes());
|
||||
if i == 0 {
|
||||
held = Some(object);
|
||||
}
|
||||
// Eviction keeps the newcomer.
|
||||
assert!(doc.cache.lock().unwrap().contains_key(&reference.num), "eviction must keep the newcomer");
|
||||
}
|
||||
assert!(doc.cache.lock().unwrap().len() < references.len(), "the byte budget must evict even below the count cap");
|
||||
let last = references[references.len() - 1].as_ref().unwrap();
|
||||
assert!(doc.cache.lock().unwrap().contains_key(&last.num), "eviction must keep the newcomer");
|
||||
assert_eq!(&held.unwrap().as_string().unwrap().bytes[..4], &0u32.to_le_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stream_limited_document_cannot_be_saved() {
|
||||
let bytes = Arc::new(crate::write_full(&Document::new_empty(), &crate::SaveOptions::default()).unwrap());
|
||||
|
|
|
|||
|
|
@ -230,6 +230,17 @@ impl Stream {
|
|||
pdfcraft_filters::decode(&chain, &self.raw, MAX_DECODED).map_err(|e| CosError::Filter(e.to_string()))
|
||||
}
|
||||
|
||||
/// Data after the first filter alone, bounded by `max`: the rest of the chain stays encoded.
|
||||
/// Any corruption in that first filter is an error.
|
||||
pub fn decoded_first_filter_strict(&self, max: usize) -> Result<Vec<u8>, CosError> {
|
||||
let mut chain = self.filters();
|
||||
chain.truncate(1);
|
||||
if chain.is_empty() {
|
||||
return self.raw_within(max);
|
||||
}
|
||||
pdfcraft_filters::decode(&chain, &self.raw, max.min(MAX_DECODED)).map_err(|e| CosError::Filter(e.to_string()))
|
||||
}
|
||||
|
||||
fn raw_within(&self, max: usize) -> Result<Vec<u8>, CosError> {
|
||||
let max = max.min(MAX_DECODED);
|
||||
// An empty decoder chain still produces an owned output buffer: check its limit
|
||||
|
|
@ -470,4 +481,20 @@ mod tests {
|
|||
assert_eq!(stream.decoded_within(3).unwrap(), b"abc");
|
||||
assert_eq!(stream.decoded_strict().unwrap(), b"abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_filter_decodes_only_the_outer_wrapper() {
|
||||
let data = "0 0 m 100 100 l S\n".repeat(64).into_bytes();
|
||||
let inner = Stream::flate(Dict::new(), &data).raw.to_vec();
|
||||
for (outer, name, corrupt) in [(Filter::Ascii85, "ASCII85Decode", &b"{~>"[..]), (Filter::AsciiHex, "ASCIIHexDecode", &b"G0>"[..])] {
|
||||
let encoded = pdfcraft_filters::encode(&outer, &Params::default(), &inner).unwrap();
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name(name), Object::name("FlateDecode")]));
|
||||
let stream = Stream::from_raw(dict.clone(), encoded);
|
||||
assert_eq!(stream.decoded_first_filter_strict(usize::MAX).unwrap(), inner);
|
||||
assert_eq!(stream.decoded_strict().unwrap(), data);
|
||||
assert!(matches!(stream.decoded_first_filter_strict(inner.len() - 1), Err(CosError::Filter(_))));
|
||||
assert!(matches!(Stream::from_raw(dict, corrupt.to_vec()).decoded_first_filter_strict(usize::MAX), Err(CosError::Filter(_))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,36 @@ fn empty_user_password_opens_but_reports_restrictions() {
|
|||
assert!(Document::open(Arc::new(fixture())).unwrap().permissions().is_none(), "unencrypted: no restrictions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_permissions_password_unlocks_an_open_document() {
|
||||
for alg in ALL {
|
||||
// Only a permissions password: opens without asking, restricted (#995).
|
||||
let mut doc = Document::open(Arc::new(protect(alg, "", "perms", 0b0100))).unwrap();
|
||||
// Read something before unlocking: it must stay readable afterwards.
|
||||
assert_eq!(content(&doc), SECRET, "{alg:?}");
|
||||
assert_eq!(doc.unlock("").unwrap_err(), CosError::WrongPassword, "{alg:?}");
|
||||
assert_eq!(doc.unlock("nope").unwrap_err(), CosError::WrongPassword, "{alg:?}");
|
||||
assert!(!doc.permissions().unwrap().modify(), "{alg:?}: still restricted");
|
||||
doc.unlock("perms").unwrap();
|
||||
assert_eq!(doc.security().unwrap().auth(), Auth::Owner, "{alg:?}");
|
||||
assert!(doc.permissions().unwrap().modify(), "{alg:?}");
|
||||
assert_eq!(content(&doc), SECRET, "{alg:?}");
|
||||
assert_eq!(title(&doc).as_deref(), Some("Quarterly numbers"), "{alg:?}");
|
||||
// Unlocked, security can be removed.
|
||||
doc.remove_encryption();
|
||||
let plain = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
assert!(Document::open(Arc::new(plain)).unwrap().security().is_none(), "{alg:?}");
|
||||
}
|
||||
// Opened with a separate open password: that password doesn't unlock.
|
||||
let mut doc = Document::open_with_password(Arc::new(protect(Algorithm::Aes256, "open", "perms", 0b0100)), Some("open")).unwrap();
|
||||
assert_eq!(doc.unlock("open").unwrap_err(), CosError::WrongPassword);
|
||||
doc.unlock("perms").unwrap();
|
||||
assert!(doc.permissions().unwrap().modify());
|
||||
// Nothing to unlock: unencrypted, or already the owner.
|
||||
Document::open(Arc::new(fixture())).unwrap().unlock("anything").unwrap();
|
||||
doc.unlock("anything").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_edits_of_encrypted_documents_stay_encrypted() {
|
||||
for alg in ALL {
|
||||
|
|
|
|||
|
|
@ -314,7 +314,13 @@ fn png_image(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
|
|||
_ => (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 decoded size comes from the header alone, so a file of a few bytes can claim any
|
||||
// size: keep the ceiling, and ask for the memory fallibly (a failed allocation would abort
|
||||
// the whole app, and no `catch_unwind` stops that).
|
||||
let size = reader.output_buffer_size().filter(|n| *n <= MAX_DECODED_BYTES).ok_or_else(|| bad("image too large".into()))?;
|
||||
let mut buf = Vec::new();
|
||||
buf.try_reserve_exact(size).map_err(|_| bad("not enough memory for this image".into()))?;
|
||||
buf.resize(size, 0);
|
||||
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()];
|
||||
|
|
@ -327,8 +333,14 @@ fn png_image(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
|
|||
};
|
||||
let has_alpha = channels == 2 || channels == 4;
|
||||
let colour_n = if has_alpha { channels - 1 } else { channels };
|
||||
let mut colour = Vec::with_capacity(w as usize * h as usize * colour_n);
|
||||
let mut colour = Vec::new();
|
||||
colour
|
||||
.try_reserve_exact((w as usize).saturating_mul(h as usize).saturating_mul(colour_n))
|
||||
.map_err(|_| bad("not enough memory for this image".into()))?;
|
||||
let mut alpha = Vec::new();
|
||||
if has_alpha {
|
||||
alpha.try_reserve_exact((w as usize).saturating_mul(h as usize)).map_err(|_| bad("not enough memory for this image".into()))?;
|
||||
}
|
||||
for px in data.chunks_exact(channels) {
|
||||
colour.extend_from_slice(&px[..colour_n]);
|
||||
if has_alpha {
|
||||
|
|
@ -385,8 +397,9 @@ fn decoded(name: &str, bytes: &[u8], format: image::ImageFormat) -> Result<Embed
|
|||
Ok(rgba_image(rgba.as_raw(), rgba.dimensions(), (72.0, 72.0)))
|
||||
}
|
||||
|
||||
/// Most decoded bytes one TIFF page may take: room for the 768 MiB CMYK poster in #665, twice over.
|
||||
const MAX_TIFF_DECODED_BYTES: usize = 2 << 30;
|
||||
/// Most decoded bytes one TIFF page or PNG may take: room for the 768 MiB CMYK poster in #665,
|
||||
/// twice over.
|
||||
const MAX_DECODED_BYTES: usize = 2 << 30;
|
||||
|
||||
/// Every page of a TIFF (multi-page scans become multi-page PDFs).
|
||||
fn tiff_pages(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
|
||||
|
|
@ -398,7 +411,7 @@ fn tiff_pages(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, 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 {
|
||||
|
|
|
|||
|
|
@ -198,6 +198,64 @@ fn a_tiff_claiming_enormous_dimensions_is_refused() {
|
|||
assert!(matches!(from_images(&[("huge.tif".into(), tif)]), Err(CreateError::Image(..))));
|
||||
}
|
||||
|
||||
/// A PNG of a few bytes whose header claims `w`×`h` 8-bit RGBA pixels, with no pixel data.
|
||||
fn png_claiming(w: u32, h: u32) -> Vec<u8> {
|
||||
fn crc32(bytes: &[u8]) -> u32 {
|
||||
let mut c = !0u32;
|
||||
for &b in bytes {
|
||||
c ^= u32::from(b);
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
let mut chunk = |kind: &[u8], data: &[u8]| {
|
||||
png.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
let body = [kind, data].concat();
|
||||
png.extend_from_slice(&body);
|
||||
png.extend_from_slice(&crc32(&body).to_be_bytes());
|
||||
};
|
||||
let ihdr = [&w.to_be_bytes()[..], &h.to_be_bytes(), &[8, 6, 0, 0, 0]].concat();
|
||||
chunk(b"IHDR", &ihdr);
|
||||
// An empty zlib stream: one stored block of no bytes.
|
||||
chunk(b"IDAT", &[0x78, 0x01, 0x01, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x01]);
|
||||
chunk(b"IEND", &[]);
|
||||
png
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tiny_png_claiming_huge_dimensions_is_refused_before_allocating() {
|
||||
// 68 bytes claiming 13 GiB (and 1 TiB) of pixels: refused before anything is allocated,
|
||||
// where it used to abort the process when the allocation failed.
|
||||
for (w, h) in [(60_000, 60_000), (1 << 20, 1 << 18)] {
|
||||
let png = png_claiming(w, h);
|
||||
assert_eq!(png.len(), 68);
|
||||
let r = from_images(&[("huge.png".into(), png)]);
|
||||
assert!(matches!(&r, Err(CreateError::Image(_, m)) if m.contains("too large")), "{w}x{h}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
|
|
|
|||
|
|
@ -180,6 +180,18 @@ impl SecurityHandler {
|
|||
Ok(Self { dict, key, auth, recovered_user })
|
||||
}
|
||||
|
||||
/// Upgrade to owner access with the owner (permissions) password, as when a restricted
|
||||
/// document is unlocked after opening. The user password, or any other, is `WrongPassword`.
|
||||
/// Both passwords unlock the same file key, so what was decrypted so far stays valid; a
|
||||
/// different key is refused too.
|
||||
pub fn authenticate_owner(&self, id0: &[u8], password: &str) -> Result<Self, CryptError> {
|
||||
let owner = Self::open(self.dict.clone(), id0, Some(password))?;
|
||||
if owner.auth != Auth::Owner || owner.key != self.key {
|
||||
return Err(CryptError::WrongPassword);
|
||||
}
|
||||
Ok(owner)
|
||||
}
|
||||
|
||||
pub fn auth(&self) -> Auth {
|
||||
self.auth
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,29 @@ fn created_encryption_authenticates_owner_and_user() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_owner_password_upgrades_a_user_session() {
|
||||
for alg in ALL {
|
||||
let d = new(alg, "", "secret", 0b0100).dict().clone();
|
||||
let user = SecurityHandler::open(d, ID, None).unwrap();
|
||||
assert!(!user.permissions().modify(), "{alg:?}");
|
||||
// The (empty) user password and wrong ones don't unlock anything.
|
||||
assert_eq!(user.authenticate_owner(ID, "").unwrap_err(), CryptError::WrongPassword, "{alg:?}");
|
||||
assert_eq!(user.authenticate_owner(ID, "nope").unwrap_err(), CryptError::WrongPassword, "{alg:?}");
|
||||
let owner = user.authenticate_owner(ID, "secret").unwrap();
|
||||
assert_eq!(owner.auth(), Auth::Owner, "{alg:?}");
|
||||
assert_eq!(owner.file_key(), user.file_key(), "{alg:?}");
|
||||
assert!(owner.permissions().modify(), "{alg:?}");
|
||||
}
|
||||
// A separate open password is not the permissions password.
|
||||
for alg in ALL {
|
||||
let d = new(alg, "open", "perms", 0b0100).dict().clone();
|
||||
let user = SecurityHandler::open(d, ID, Some("open")).unwrap();
|
||||
assert_eq!(user.authenticate_owner(ID, "open").unwrap_err(), CryptError::WrongPassword, "{alg:?}");
|
||||
assert_eq!(user.authenticate_owner(ID, "perms").unwrap().auth(), Auth::Owner, "{alg:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_user_password_opens_without_asking() {
|
||||
for alg in ALL {
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@
|
|||
//! after `/UserUnit` and `/Rotate`), so items stay upright on rotated pages; an item records the
|
||||
//! `/UserUnit` it was written for.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedCluster, arabic_glyph, helvetica_width, literal, shape_arabic, win_ansi};
|
||||
use unicode_bidi::{Level, ParagraphBidiInfo};
|
||||
|
||||
use crate::{EditError, check, contents, n, page_list, place_tagged};
|
||||
use crate::{EditError, MarkKind, check, contents, n, page_list, place_tagged};
|
||||
|
||||
const TAG: &str = "Added";
|
||||
|
||||
|
|
@ -198,6 +198,13 @@ pub struct Added {
|
|||
pub content: Content,
|
||||
}
|
||||
|
||||
/// PdfCraft-owned page content discovered in one page/content traversal.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct MarkerScan {
|
||||
pub marks: Vec<MarkKind>,
|
||||
pub added: Vec<Added>,
|
||||
}
|
||||
|
||||
fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
|
||||
o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default()
|
||||
}
|
||||
|
|
@ -380,24 +387,43 @@ fn font_name(base: &str) -> String {
|
|||
format!("PCF{}", base.replace('-', ""))
|
||||
}
|
||||
|
||||
/// Most glyphs in one Type 3 font: its codes are single bytes, 1–240.
|
||||
/// Most glyphs in one Type 3 font: its codes are single bytes, 1–240. An item gets one such font
|
||||
/// per 240 distinct glyphs it needs.
|
||||
const GLYPHS_PER_FONT: usize = 240;
|
||||
/// Most Type 3 fonts one item may use, which bounds the objects it adds (ordinary text needs one
|
||||
/// or two).
|
||||
const MAX_ARABIC_FONTS: usize = 16;
|
||||
|
||||
/// A line of an Arabic item after layout: its text, whether its paragraph runs right to left, its
|
||||
/// pieces in drawing order and its width at the item's size.
|
||||
type LaidLine = (String, bool, Vec<Piece>, f64);
|
||||
|
||||
/// Draw a text item that has Arabic in it. Each line is laid out in display order; Arabic glyphs
|
||||
/// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded),
|
||||
/// with a ToUnicode map for copy and search. Other text uses the item's standard font `latin`.
|
||||
fn draw_arabic(doc: &mut Document, t: &AddedText, latin: &str, taken: &Dict, fonts: &mut Dict, out: &mut Vec<u8>) -> Result<(), EditError> {
|
||||
/// with a ToUnicode map for copy and search. Other text uses the item's standard font.
|
||||
fn draw_arabic(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec<u8>) -> Result<(), EditError> {
|
||||
let laid = wrapped(t)
|
||||
.into_iter()
|
||||
.map(|(line, rtl)| arabic_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, pieces, w)))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
draw_laid_arabic(doc, t, &laid, taken, fonts, out, arabic_glyph)
|
||||
}
|
||||
|
||||
/// [`draw_arabic`] for lines that are already laid out. `outline` is the source of glyph outlines
|
||||
/// (the craft-fonts face, or a stand-in in tests).
|
||||
fn draw_laid_arabic(
|
||||
doc: &mut Document,
|
||||
t: &AddedText,
|
||||
laid: &[LaidLine],
|
||||
taken: &Dict,
|
||||
fonts: &mut Dict,
|
||||
out: &mut Vec<u8>,
|
||||
outline: impl Fn(u32) -> Result<GlyphOutline, GlyphError>,
|
||||
) -> Result<(), EditError> {
|
||||
let r = text_rect(t);
|
||||
let lines = wrapped(t);
|
||||
let laid = lines.iter().map(|(line, rtl)| arabic_layout(t, line, *rtl)).collect::<Result<Vec<_>, _>>()?;
|
||||
let latin = font_name(t.family.base_font(t.bold, t.italic));
|
||||
// Every distinct cluster (a letter with its dots and the text it stands for) is one glyph of
|
||||
// one of the fonts, so copy and search get each character exactly once.
|
||||
let mut keys: Vec<&ShapedCluster> = Vec::new();
|
||||
let mut index: HashMap<ClusterKey, usize> = HashMap::new();
|
||||
for c in laid.iter().flat_map(|(pieces, _)| pieces).flat_map(|p| match p {
|
||||
for c in laid.iter().flat_map(|(_, _, pieces, _)| pieces).flat_map(|p| match p {
|
||||
Piece::Arabic(clusters) => clusters.as_slice(),
|
||||
Piece::Latin(_) => &[],
|
||||
}) {
|
||||
|
|
@ -406,18 +432,17 @@ fn draw_arabic(doc: &mut Document, t: &AddedText, latin: &str, taken: &Dict, fon
|
|||
keys.len() - 1
|
||||
});
|
||||
}
|
||||
if keys.len() > GLYPHS_PER_FONT * MAX_ARABIC_FONTS {
|
||||
return Err(EditError::Invalid("the text uses too many different Arabic glyphs; split it into several boxes".into()));
|
||||
}
|
||||
// Every outline is read before anything is written, so a failure leaves the document as it was.
|
||||
let mut outlines = HashMap::new();
|
||||
for c in &keys {
|
||||
for (id, _) in &c.glyphs {
|
||||
if !outlines.contains_key(id) {
|
||||
outlines.insert(*id, arabic_glyph(*id).map_err(|e| arabic_error(e, &c.text))?);
|
||||
outlines.insert(*id, outline(*id).map_err(|e| arabic_error(e, &c.text))?);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The names the page and this item already use: a new font never takes one of them.
|
||||
let mut taken_names: HashSet<Vec<u8>> = taken.iter().chain(fonts.iter()).map(|(name, _)| name.clone()).collect();
|
||||
let mut names = Vec::new();
|
||||
for chunk in keys.chunks(GLYPHS_PER_FONT) {
|
||||
let font = type3_arabic(doc, chunk, &outlines);
|
||||
|
|
@ -425,19 +450,20 @@ fn draw_arabic(doc: &mut Document, t: &AddedText, latin: &str, taken: &Dict, fon
|
|||
// numbers alone don't do: a full save renumbers objects but keeps resource names).
|
||||
let mut name = format!("PCAr{}", font.num);
|
||||
let mut suffix = 0usize;
|
||||
while taken.contains(name.as_bytes()) || fonts.contains(name.as_bytes()) {
|
||||
while taken_names.contains(name.as_bytes()) {
|
||||
suffix = suffix.saturating_add(1);
|
||||
name = format!("PCAr{}_{suffix}", font.num);
|
||||
}
|
||||
taken_names.insert(name.clone().into_bytes());
|
||||
fonts.set(name.clone().into_bytes(), Object::Ref(font));
|
||||
names.push(name);
|
||||
}
|
||||
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
|
||||
out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes());
|
||||
for (i, ((line, rtl), (pieces, w))) in lines.iter().zip(&laid).enumerate() {
|
||||
for (i, (line, rtl, pieces, w)) in laid.iter().enumerate() {
|
||||
let spaces = line.matches(' ').count();
|
||||
// Justified: the spaces of every line but the last stretch to fill the box.
|
||||
let tw = if t.align == Align::Justify && i + 1 < lines.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
|
||||
let tw = if t.align == Align::Justify && i + 1 < laid.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
|
||||
let x = match t.align {
|
||||
// A right-to-left paragraph's unstretched last line ends at the right edge.
|
||||
Align::Justify if *rtl => r[2] - w - tw * spaces as f64,
|
||||
|
|
@ -454,7 +480,7 @@ fn draw_arabic(doc: &mut Document, t: &AddedText, latin: &str, taken: &Dict, fon
|
|||
out.extend(format!("/{latin} {} Tf {} Tw 1 0 0 1 {} {} Tm ", n(t.size), n(tw), n(pen), n(y)).bytes());
|
||||
out.extend(literal(&win_ansi(s)));
|
||||
out.extend_from_slice(b" Tj\n");
|
||||
current = Some(latin);
|
||||
current = Some(latin.as_str());
|
||||
pen += t.family.width(s, t.size, t.bold) + tw * s.matches(' ').count() as f64;
|
||||
}
|
||||
Piece::Arabic(clusters) => {
|
||||
|
|
@ -586,7 +612,7 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing:
|
|||
let mut fonts = Dict::new();
|
||||
fonts.set(name.clone().into_bytes(), Object::Dict(font));
|
||||
if t.text.chars().any(is_arabic) && (pdfcraft_fonts::document_arabic_font().is_some() || !existing) {
|
||||
draw_arabic(doc, t, &name, taken, &mut fonts, &mut out)?;
|
||||
draw_arabic(doc, t, taken, &mut fonts, &mut out)?;
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
out.extend_from_slice(b"Q\n");
|
||||
return Ok((out, res));
|
||||
|
|
@ -728,6 +754,76 @@ fn parse(doc: &Document, d: &Dict) -> Option<Content> {
|
|||
}
|
||||
}
|
||||
|
||||
fn with_resolved<T>(doc: &Document, object: &Object, f: impl FnOnce(&Object) -> T) -> T {
|
||||
match object {
|
||||
Object::Ref(reference) => {
|
||||
let resolved = doc.get(*reference);
|
||||
f(&resolved)
|
||||
}
|
||||
direct => f(direct),
|
||||
}
|
||||
}
|
||||
|
||||
fn for_each_content_entry(doc: &Document, page: &pdfcraft_model::Page, mut visit: impl FnMut(&Object)) {
|
||||
let Some(contents) = page.dict.get(b"Contents") else { return };
|
||||
with_resolved(doc, contents, |resolved| match resolved {
|
||||
Object::Array(entries) => {
|
||||
for entry in entries {
|
||||
visit(entry);
|
||||
}
|
||||
}
|
||||
_ => visit(contents),
|
||||
});
|
||||
}
|
||||
|
||||
/// Finds page marks and editable added content in one pass over the page content entries.
|
||||
pub fn scan_markers(doc: &Document) -> MarkerScan {
|
||||
let mut scan = MarkerScan::default();
|
||||
for (pi, page) in page_list(doc).iter().enumerate() {
|
||||
for_each_content_entry(doc, page, |entry| {
|
||||
with_resolved(doc, entry, |resolved| {
|
||||
let Some(dict) = resolved.as_dict() else { return };
|
||||
let tag = dict.name(b"PCMark");
|
||||
let kind = if tag == Some(&b"HeaderFooter"[..]) {
|
||||
Some(MarkKind::HeaderFooter)
|
||||
} else if tag == Some(&b"Watermark"[..]) {
|
||||
Some(MarkKind::Watermark)
|
||||
} else if tag == Some(&b"Background"[..]) {
|
||||
Some(MarkKind::Background)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(kind) = kind
|
||||
&& !scan.marks.contains(&kind)
|
||||
{
|
||||
scan.marks.push(kind);
|
||||
}
|
||||
|
||||
let Some(obj) = Object::as_ref(entry) else { return };
|
||||
if tag != Some(TAG.as_bytes()) {
|
||||
return;
|
||||
}
|
||||
let Some(params_object) = dict.get(b"PCAdded") else { return };
|
||||
with_resolved(doc, params_object, |resolved_params| {
|
||||
let Some(params) = resolved_params.as_dict() else { return };
|
||||
if let Some(content) = parse(doc, params) {
|
||||
// Display points depend on the page's /UserUnit. An item records the one it was
|
||||
// written for; none means 1, as for items written before display space followed
|
||||
// /UserUnit.
|
||||
let written = params
|
||||
.get(b"UserUnit")
|
||||
.and_then(Object::as_f64)
|
||||
.filter(|u| u.is_finite() && *u >= 1.0)
|
||||
.map_or(1.0, |u| u.min(pdfcraft_model::MAX_USER_UNIT));
|
||||
scan.added.push(Added { page: pi, obj, content: content.scaled(page.user_unit(doc) / written) });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
scan
|
||||
}
|
||||
|
||||
fn validate(c: &Content) -> Result<(), EditError> {
|
||||
let r = c.rect();
|
||||
if !r.iter().all(|v| v.is_finite()) {
|
||||
|
|
@ -816,37 +912,7 @@ fn write(doc: &mut Document, page: usize, c: &Content, obj: Option<ObjRef>) -> R
|
|||
|
||||
/// Every added item, page by page, in drawing order.
|
||||
pub fn list_added(doc: &Document) -> Vec<Added> {
|
||||
let mut out = Vec::new();
|
||||
for (pi, p) in page_list(doc).iter().enumerate() {
|
||||
let list: Vec<Object> = match p.dict.get(b"Contents") {
|
||||
Some(c) => match &*doc.resolve(c) {
|
||||
Object::Array(a) => a.clone(),
|
||||
_ => vec![c.clone()],
|
||||
},
|
||||
None => Vec::new(),
|
||||
};
|
||||
for o in list {
|
||||
let Some(r) = o.as_ref() else { continue };
|
||||
let obj = doc.get(r);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
if d.name(b"PCMark") != Some(TAG.as_bytes()) {
|
||||
continue;
|
||||
}
|
||||
let Some(params) = d.get(b"PCAdded").and_then(|o| doc.resolve(o).as_dict().cloned()) else { continue };
|
||||
if let Some(c) = parse(doc, ¶ms) {
|
||||
// Display points depend on the page's /UserUnit. An item records the one it was
|
||||
// written for; none means 1, as for items written before display space followed
|
||||
// /UserUnit.
|
||||
let written = params
|
||||
.get(b"UserUnit")
|
||||
.and_then(Object::as_f64)
|
||||
.filter(|u| u.is_finite() && *u >= 1.0)
|
||||
.map_or(1.0, |u| u.min(pdfcraft_model::MAX_USER_UNIT));
|
||||
out.push(Added { page: pi, obj: r, content: c.scaled(p.user_unit(doc) / written) });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
scan_markers(doc).added
|
||||
}
|
||||
|
||||
/// Add an item to a page (0-based). Returns its index among the page's added items.
|
||||
|
|
@ -915,3 +981,6 @@ fn drop_fonts(doc: &Document, res: &mut Dict, names: &[Vec<u8>]) {
|
|||
}
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod arabic_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
//! Arabic items too big for one Type 3 font. Shaped clusters and glyph outlines are injected here,
|
||||
//! so these run without the craft-fonts Arabic face. Shaping itself is covered by the face tests in
|
||||
//! `tests.rs`, which run where the face is built in.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The fingerprint of an item below the old limit, from the code before the change: its content
|
||||
/// stream, its new fonts and every object of the document.
|
||||
const SMALL_ITEM_FINGERPRINT: u64 = 14_135_892_979_695_233_112;
|
||||
|
||||
/// A cluster that shows one character with the single glyph `id`, at `advance` em.
|
||||
fn cluster(id: u32, advance: f64) -> ShapedCluster {
|
||||
ShapedCluster { glyphs: vec![(id, [0.0, 0.0])], advance, text: "ب".into() }
|
||||
}
|
||||
|
||||
/// One run of `clusters` as a line of an item at `item()`'s size.
|
||||
fn line(clusters: Vec<ShapedCluster>, rtl: bool) -> LaidLine {
|
||||
let text = clusters.iter().map(|c| c.text.as_str()).collect();
|
||||
let width = clusters.iter().map(|c| c.advance).sum::<f64>() * item().size;
|
||||
(text, rtl, vec![Piece::Arabic(clusters)], width)
|
||||
}
|
||||
|
||||
/// A glyph outline: a box on the baseline, `advance` em wide.
|
||||
fn boxed(advance: f64) -> GlyphOutline {
|
||||
let contours = vec![vec![[0.0, 0.0], [advance, 0.0], [advance, 0.5], [0.0, 0.5]]];
|
||||
GlyphOutline { contours, width: advance, bbox: [0.0, 0.0, advance, 0.5] }
|
||||
}
|
||||
|
||||
fn item() -> AddedText {
|
||||
AddedText { rect: [72.0, 600.0, 540.0, 700.0], ..AddedText::default() }
|
||||
}
|
||||
|
||||
/// Every object of `doc`, serialised: equal snapshots mean the document did not change.
|
||||
fn snapshot(doc: &Document) -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
for num in doc.object_numbers() {
|
||||
let obj = doc.get(ObjRef { num, generation: doc.generation(num) });
|
||||
pdfcraft_cos::serialize(&obj, &mut bytes);
|
||||
bytes.push(b'\n');
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// The fonts an item drew, in creation order, with their glyph counts.
|
||||
fn fonts_of(doc: &Document, fonts: &Dict) -> Vec<(Vec<u8>, usize)> {
|
||||
fonts
|
||||
.iter()
|
||||
.map(|(name, font)| {
|
||||
let glyphs = match doc.dict(font).as_ref().and_then(|f| f.get(b"CharProcs")) {
|
||||
Some(Object::Dict(procs)) => procs.len(),
|
||||
_ => 0,
|
||||
};
|
||||
(name.clone(), glyphs)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The font and code of every glyph the content stream shows, in drawing order.
|
||||
fn glyphs_shown(out: &[u8]) -> Vec<(Vec<u8>, u8)> {
|
||||
let mut font = Vec::new();
|
||||
let mut shown = Vec::new();
|
||||
for op in pdfcraft_content::parse(out).ops {
|
||||
if op.is("Tf") {
|
||||
font = op.name(0).unwrap_or_default().to_vec();
|
||||
} else if op.is("Tj")
|
||||
&& let Some(Object::String(s)) = op.operands.first()
|
||||
{
|
||||
shown.push((font.clone(), s.bytes.first().copied().unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
shown
|
||||
}
|
||||
|
||||
/// 64-bit FNV-1a: a stable fingerprint for the golden check.
|
||||
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_past_the_old_glyph_limit_gets_a_font_per_240_glyphs() {
|
||||
// Sixteen fonts of 240 glyphs was the most an item could use. This one needs 3,841 glyphs.
|
||||
let n = 16 * GLYPHS_PER_FONT + 1;
|
||||
let laid = vec![line((0..n).map(|i| cluster(i as u32 + 1, 0.5)).collect(), false)];
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut fonts, mut out) = (Dict::new(), Vec::new());
|
||||
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_| Ok(boxed(0.5)));
|
||||
assert!(drawn.is_ok(), "{drawn:?}");
|
||||
let made = fonts_of(&doc, &fonts);
|
||||
let mut expected = vec![GLYPHS_PER_FONT; 16];
|
||||
expected.push(1);
|
||||
assert_eq!(made.iter().map(|(_, glyphs)| *glyphs).collect::<Vec<_>>(), expected);
|
||||
let shown = glyphs_shown(&out);
|
||||
assert_eq!(shown.len(), n);
|
||||
for (k, (font, code)) in shown.iter().enumerate() {
|
||||
assert_eq!(font, &made[k / GLYPHS_PER_FONT].0, "glyph {k} is drawn from the wrong font");
|
||||
assert_eq!(usize::from(*code), k % GLYPHS_PER_FONT + 1, "glyph {k} has the wrong code");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_outline_missing_from_a_later_font_leaves_the_document_unchanged() {
|
||||
// Two fonts' worth of glyphs; the glyph in the second font has no outline.
|
||||
let n = 2 * GLYPHS_PER_FONT;
|
||||
let laid = vec![line((0..n).map(|i| cluster(i as u32 + 1, 0.5)).collect(), false)];
|
||||
let mut doc = Document::new_empty();
|
||||
let before = snapshot(&doc);
|
||||
let missing = n as u32;
|
||||
let (mut fonts, mut out) = (Dict::new(), Vec::new());
|
||||
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |id| {
|
||||
if id == missing { Err(GlyphError::Missing) } else { Ok(boxed(0.5)) }
|
||||
});
|
||||
assert!(matches!(&drawn, Err(EditError::Invalid(msg)) if msg.contains("can't show")), "{drawn:?}");
|
||||
assert_eq!(snapshot(&doc), before);
|
||||
assert!(fonts.is_empty() && out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_below_the_old_limit_is_drawn_exactly_as_before() {
|
||||
// A left-to-right line with a Latin run, a space and a two-glyph cluster, then a right-to-left
|
||||
// line. Glyphs repeat across the two lines and share one font.
|
||||
let space = ShapedCluster { glyphs: vec![(6, [0.0, 0.0])], advance: 0.25, text: " ".into() };
|
||||
let ligature = ShapedCluster { glyphs: vec![(4, [0.0, 0.0]), (5, [-0.1, 0.2])], advance: 0.6, text: "لا".into() };
|
||||
let mixed: LaidLine =
|
||||
("Hi ب".into(), false, vec![Piece::Latin("Hi ".into()), Piece::Arabic(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0);
|
||||
let rtl = line(vec![cluster(2, 0.5), cluster(9, 0.75)], true);
|
||||
let laid = vec![mixed, rtl];
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut fonts, mut out) = (Dict::new(), Vec::new());
|
||||
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |id| Ok(boxed(0.5 + f64::from(id) * 0.01)));
|
||||
assert!(drawn.is_ok(), "{drawn:?}");
|
||||
assert_eq!(fonts.len(), 1);
|
||||
let mut bytes = out.clone();
|
||||
pdfcraft_cos::serialize(&Object::Dict(fonts.clone()), &mut bytes);
|
||||
bytes.extend(snapshot(&doc));
|
||||
assert_eq!(fnv1a(&bytes), SMALL_ITEM_FINGERPRINT, "output changed; the golden is the code before the sharding change");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_text_with_few_distinct_glyphs_needs_one_font() {
|
||||
// 5,000 clusters that repeat three glyphs: one font, and one outline read per glyph.
|
||||
let clusters: Vec<ShapedCluster> = (0..5_000u32).map(|i| cluster(1 + i % 3, 0.5)).collect();
|
||||
let laid = vec![line(clusters, false)];
|
||||
let reads = Cell::new(0usize);
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut fonts, mut out) = (Dict::new(), Vec::new());
|
||||
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_| {
|
||||
reads.set(reads.get() + 1);
|
||||
Ok(boxed(0.5))
|
||||
});
|
||||
assert!(drawn.is_ok(), "{drawn:?}");
|
||||
assert_eq!(fonts_of(&doc, &fonts).iter().map(|(_, glyphs)| *glyphs).collect::<Vec<_>>(), [3]);
|
||||
assert_eq!(reads.get(), 3);
|
||||
assert_eq!(glyphs_shown(&out).len(), 5_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_clusters_past_the_old_limit_share_their_glyph_outlines() {
|
||||
// 3,841 different clusters (their advances differ) built from four glyphs: four outlines are read.
|
||||
let clusters: Vec<ShapedCluster> = (0..3_841u32)
|
||||
.map(|i| ShapedCluster { glyphs: vec![(1 + i % 4, [0.0, 0.0])], advance: 0.5 + f64::from(i) * 1e-6, text: "ب".into() })
|
||||
.collect();
|
||||
let laid = vec![line(clusters, false)];
|
||||
let reads = Cell::new(0usize);
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut fonts, mut out) = (Dict::new(), Vec::new());
|
||||
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_| {
|
||||
reads.set(reads.get() + 1);
|
||||
Ok(boxed(0.5))
|
||||
});
|
||||
assert!(drawn.is_ok(), "{drawn:?}");
|
||||
assert_eq!(fonts.len(), 17);
|
||||
assert_eq!(reads.get(), 4);
|
||||
assert_eq!(glyphs_shown(&out).len(), 3_841);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_clusters_draw_empty_or_fail_without_changing_the_document() {
|
||||
// A cluster with no glyphs and no text draws nothing, but it is still one glyph of a font.
|
||||
let empty = ShapedCluster { glyphs: Vec::new(), advance: 0.0, text: String::new() };
|
||||
let laid = vec![line(vec![empty, cluster(1, 0.5)], false)];
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut fonts, mut out) = (Dict::new(), Vec::new());
|
||||
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_| Ok(boxed(0.5)));
|
||||
assert!(drawn.is_ok(), "{drawn:?}");
|
||||
assert_eq!(glyphs_shown(&out).len(), 2);
|
||||
// A glyph id the face doesn't have fails the item before anything is written.
|
||||
let before = snapshot(&doc);
|
||||
let laid = vec![line(vec![cluster(1, 0.5), cluster(u32::MAX, 0.5)], false)];
|
||||
let (mut fonts, mut out) = (Dict::new(), Vec::new());
|
||||
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |id| {
|
||||
if id == u32::MAX { Err(GlyphError::Missing) } else { Ok(boxed(0.5)) }
|
||||
});
|
||||
assert!(drawn.is_err());
|
||||
assert_eq!(snapshot(&doc), before);
|
||||
assert!(fonts.is_empty() && out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_name_the_page_already_uses_is_not_reused() {
|
||||
let laid = vec![line(vec![cluster(1, 0.5)], false)];
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut fonts, mut out) = (Dict::new(), Vec::new());
|
||||
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_| Ok(boxed(0.5)));
|
||||
assert!(drawn.is_ok(), "{drawn:?}");
|
||||
let first = fonts_of(&doc, &fonts)[0].0.clone();
|
||||
// The same item on a page whose resources already use that name.
|
||||
let mut taken = Dict::new();
|
||||
taken.set(first.clone(), Object::Null);
|
||||
let mut doc = Document::new_empty();
|
||||
let (mut fonts, mut out) = (Dict::new(), Vec::new());
|
||||
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &taken, &mut fonts, &mut out, |_| Ok(boxed(0.5)));
|
||||
assert!(drawn.is_ok(), "{drawn:?}");
|
||||
let second = fonts_of(&doc, &fonts)[0].0.clone();
|
||||
assert_ne!(second, first);
|
||||
assert!(second.starts_with(b"PCAr"), "{}", String::from_utf8_lossy(&second));
|
||||
}
|
||||
|
|
@ -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<Matrix> {
|
||||
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<Op> = 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.
|
||||
|
|
|
|||
|
|
@ -387,10 +387,18 @@ pub(crate) fn drawable<'a>(lines: impl IntoIterator<Item = &'a str>) -> 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<char> {
|
||||
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<u8> {
|
||||
|
|
@ -491,7 +499,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 {
|
||||
|
|
@ -606,24 +619,7 @@ pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Resu
|
|||
|
||||
/// Which kinds of marks the document has (for enabling Update/Remove).
|
||||
pub fn marks_present(doc: &Document) -> Vec<MarkKind> {
|
||||
let mut found = Vec::new();
|
||||
for page in page_list(doc) {
|
||||
let obj = doc.get(page.obj);
|
||||
let Some(c) = obj.as_dict().and_then(|d| d.get(b"Contents").cloned()) else { continue };
|
||||
let list = match &*doc.resolve(&c) {
|
||||
Object::Array(a) => a.clone(),
|
||||
_ => vec![c.clone()],
|
||||
};
|
||||
for o in list {
|
||||
if let Some(t) = stream_tag(doc, &o)
|
||||
&& let Some(kind) = [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background].into_iter().find(|m| m.tag() == t)
|
||||
&& !found.contains(&kind)
|
||||
{
|
||||
found.push(kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
added::scan_markers(doc).marks
|
||||
}
|
||||
|
||||
pub mod objects;
|
||||
|
|
@ -636,7 +632,10 @@ pub use images::{ImageChange, PageImage, change_image, page_images, reading_imag
|
|||
pub mod text;
|
||||
pub use text::{BlockStyle, LineEdit, TextBlock, TextLine, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks, text_lines};
|
||||
pub mod added;
|
||||
pub use added::{Added, AddedImage, AddedText, Align, Content, Family, add_content, delete_content, first_undrawable, list_added, update_content};
|
||||
pub use added::{
|
||||
Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, list_added, scan_markers,
|
||||
update_content,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -247,6 +267,170 @@ fn added_items_follow_the_page_user_unit() {
|
|||
assert_eq!((old.rect[0], old.rect[2], old.rect[3], old.size), (144.0, 600.0, 1400.0, 28.0), "scaled to points");
|
||||
}
|
||||
|
||||
fn tagged_dictionary(tag: &str, added: Option<Object>) -> Dict {
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"PCMark".to_vec(), Object::name(tag));
|
||||
if let Some(added) = added {
|
||||
dict.set(b"PCAdded".to_vec(), added);
|
||||
}
|
||||
dict
|
||||
}
|
||||
|
||||
fn tagged_stream(doc: &mut Document, tag: &str, added: Option<Object>) -> Object {
|
||||
Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged_dictionary(tag, added), Vec::new()))))
|
||||
}
|
||||
|
||||
fn added_text_params() -> Dict {
|
||||
let mut params = Dict::new();
|
||||
params.set(b"Rect".to_vec(), Object::Array([10, 20, 110, 40].into_iter().map(Object::Int).collect()));
|
||||
params.set(b"Kind".to_vec(), Object::name("Text"));
|
||||
params.set(b"Text".to_vec(), Object::String(pdfcraft_cos::PdfString::text("Added text")));
|
||||
params.set(b"Size".to_vec(), Object::Int(12));
|
||||
params
|
||||
}
|
||||
|
||||
fn added_image_params(image: pdfcraft_cos::ObjRef) -> Dict {
|
||||
let mut params = Dict::new();
|
||||
params.set(b"Rect".to_vec(), Object::Array([10, 20, 110, 70].into_iter().map(Object::Int).collect()));
|
||||
params.set(b"Kind".to_vec(), Object::name("Image"));
|
||||
params.set(b"Image".to_vec(), Object::Ref(image));
|
||||
params
|
||||
}
|
||||
|
||||
fn set_page_contents(doc: &mut Document, contents: Object, user_unit: Option<Object>) {
|
||||
let page = pdfcraft_model::pages(doc)[0].obj;
|
||||
doc.update_dict(page, |dict| {
|
||||
dict.set(b"Contents".to_vec(), contents);
|
||||
if let Some(user_unit) = user_unit {
|
||||
dict.set(b"UserUnit".to_vec(), user_unit);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn assert_marker_scan_matches_legacy(doc: &Document, label: &str, marks: &[MarkKind], added: usize) -> MarkerScan {
|
||||
let expected_marks = marks_present(doc);
|
||||
let expected_added = list_added(doc);
|
||||
let scan = scan_markers(doc);
|
||||
assert_eq!(scan.marks, expected_marks, "{label}: marks differ from the independent scan");
|
||||
assert_eq!(scan.added, expected_added, "{label}: added content differs from the independent scan");
|
||||
assert_eq!(scan.marks, marks, "{label}: fixture's mark order or deduplication is wrong");
|
||||
assert_eq!(scan.added.len(), added, "{label}: fixture's added-item count is wrong");
|
||||
scan
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_marker_scan_matches_the_independent_scans() {
|
||||
let empty = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R >>"]);
|
||||
assert_marker_scan_matches_legacy(&empty, "empty page", &[], 0);
|
||||
assert_marker_scan_matches_legacy(&fixture(), "ordinary streams and a page without contents", &[], 0);
|
||||
|
||||
for (tag, kind) in [("HeaderFooter", MarkKind::HeaderFooter), ("Watermark", MarkKind::Watermark), ("Background", MarkKind::Background)] {
|
||||
let mut doc = fixture();
|
||||
let marker = tagged_stream(&mut doc, tag, None);
|
||||
set_page_contents(&mut doc, marker, None);
|
||||
assert_marker_scan_matches_legacy(&doc, tag, &[kind], 0);
|
||||
}
|
||||
|
||||
let mut all_marks = fixture();
|
||||
let background = tagged_stream(&mut all_marks, "Background", None);
|
||||
let watermark = tagged_stream(&mut all_marks, "Watermark", None);
|
||||
let header = tagged_stream(&mut all_marks, "HeaderFooter", None);
|
||||
set_page_contents(
|
||||
&mut all_marks,
|
||||
Object::Array(vec![background.clone(), watermark.clone(), header.clone(), watermark, header, background]),
|
||||
None,
|
||||
);
|
||||
assert_marker_scan_matches_legacy(&all_marks, "all mark kinds repeated", &[MarkKind::Background, MarkKind::Watermark, MarkKind::HeaderFooter], 0);
|
||||
|
||||
let mut single_reference = fixture();
|
||||
let marker = tagged_stream(&mut single_reference, "Watermark", None);
|
||||
set_page_contents(&mut single_reference, marker, None);
|
||||
assert_marker_scan_matches_legacy(&single_reference, "contents as one reference", &[MarkKind::Watermark], 0);
|
||||
|
||||
let mut text_only = fixture();
|
||||
let text = tagged_stream(&mut text_only, "Added", Some(Object::Dict(added_text_params())));
|
||||
set_page_contents(&mut text_only, text, None);
|
||||
let text_scan = assert_marker_scan_matches_legacy(&text_only, "one added text item", &[], 1);
|
||||
assert!(matches!(&text_scan.added[0].content, Content::Text(text) if text.text == "Added text"));
|
||||
|
||||
let mut indirect_params = fixture();
|
||||
let params = indirect_params.add(Object::Dict(added_text_params()));
|
||||
let text = tagged_stream(&mut indirect_params, "Added", Some(Object::Ref(params)));
|
||||
set_page_contents(&mut indirect_params, text, None);
|
||||
let indirect_scan = assert_marker_scan_matches_legacy(&indirect_params, "indirect PCAdded dictionary", &[], 1);
|
||||
assert!(matches!(&indirect_scan.added[0].content, Content::Text(text) if text.text == "Added text"));
|
||||
|
||||
let mut image_only = fixture();
|
||||
let image = image_only.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0])));
|
||||
let image_item = tagged_stream(&mut image_only, "Added", Some(Object::Dict(added_image_params(image))));
|
||||
set_page_contents(&mut image_only, image_item, None);
|
||||
let image_scan = assert_marker_scan_matches_legacy(&image_only, "one added image item", &[], 1);
|
||||
assert!(matches!(&image_scan.added[0].content, Content::Image(item) if item.image == image));
|
||||
|
||||
let mut interleaved = fixture();
|
||||
let text = tagged_stream(&mut interleaved, "Added", Some(Object::Dict(added_text_params())));
|
||||
let watermark = tagged_stream(&mut interleaved, "Watermark", None);
|
||||
let image = interleaved.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0])));
|
||||
let image_item = tagged_stream(&mut interleaved, "Added", Some(Object::Dict(added_image_params(image))));
|
||||
let header = tagged_stream(&mut interleaved, "HeaderFooter", None);
|
||||
set_page_contents(&mut interleaved, Object::Array(vec![text, watermark, image_item, header]), None);
|
||||
let interleaved_scan =
|
||||
assert_marker_scan_matches_legacy(&interleaved, "marks and added content interleaved", &[MarkKind::Watermark, MarkKind::HeaderFooter], 2);
|
||||
assert!(matches!(&interleaved_scan.added[0].content, Content::Text(_)));
|
||||
assert!(matches!(&interleaved_scan.added[1].content, Content::Image(_)));
|
||||
|
||||
let mut array_contents = fixture();
|
||||
let background = tagged_stream(&mut array_contents, "Background", None);
|
||||
let array_ref = array_contents.add(Object::Array(vec![background]));
|
||||
set_page_contents(&mut array_contents, Object::Ref(array_ref), None);
|
||||
assert_marker_scan_matches_legacy(&array_contents, "contents as an indirect array", &[MarkKind::Background], 0);
|
||||
|
||||
let mut malformed = fixture();
|
||||
let indirect_mark = malformed.add(Object::Dict(tagged_dictionary("HeaderFooter", None)));
|
||||
let missing_added = tagged_stream(&mut malformed, "Added", None);
|
||||
let invalid_added = tagged_stream(&mut malformed, "Added", Some(Object::String(pdfcraft_cos::PdfString::text("bad params"))));
|
||||
let unknown = tagged_stream(&mut malformed, "FutureKind", None);
|
||||
let direct_watermark = Object::Dict(tagged_dictionary("Watermark", None));
|
||||
let direct_added = Object::Dict(tagged_dictionary("Added", Some(Object::Dict(added_text_params()))));
|
||||
set_page_contents(
|
||||
&mut malformed,
|
||||
Object::Array(vec![
|
||||
Object::Null,
|
||||
Object::Int(7),
|
||||
Object::name("not-a-stream"),
|
||||
direct_watermark,
|
||||
Object::Ref(indirect_mark),
|
||||
missing_added,
|
||||
invalid_added,
|
||||
unknown,
|
||||
direct_added,
|
||||
]),
|
||||
None,
|
||||
);
|
||||
assert_marker_scan_matches_legacy(
|
||||
&malformed,
|
||||
"malformed, non-stream, unknown and direct entries",
|
||||
&[MarkKind::Watermark, MarkKind::HeaderFooter],
|
||||
0,
|
||||
);
|
||||
|
||||
let mut shared = fixture();
|
||||
let added = tagged_stream(&mut shared, "Added", Some(Object::Dict(added_text_params())));
|
||||
let mark = tagged_stream(&mut shared, "Background", None);
|
||||
set_page_contents(&mut shared, Object::Array(vec![added.clone(), mark.clone(), added.clone(), mark]), None);
|
||||
let shared_scan = assert_marker_scan_matches_legacy(&shared, "repeated shared stream references", &[MarkKind::Background], 2);
|
||||
assert_eq!(shared_scan.added[0].obj, shared_scan.added[1].obj);
|
||||
|
||||
let mut user_unit = fixture();
|
||||
let added = tagged_stream(&mut user_unit, "Added", Some(Object::Dict(added_text_params())));
|
||||
set_page_contents(&mut user_unit, added, Some(Object::Int(2)));
|
||||
let unit_scan = assert_marker_scan_matches_legacy(&user_unit, "non-1.0 UserUnit", &[], 1);
|
||||
let Content::Text(text) = &unit_scan.added[0].content else { panic!() };
|
||||
assert_eq!(text.rect, [20.0, 40.0, 220.0, 80.0]);
|
||||
assert_eq!(text.size, 24.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_text_and_images_are_page_content_that_stays_editable() {
|
||||
let mut doc = fixture();
|
||||
|
|
@ -656,6 +840,180 @@ fn japanese_paragraph_uses_unicode_type3_fallback() {
|
|||
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement);
|
||||
}
|
||||
|
||||
/// The first `n` Japanese characters, in code point order, that every craft-fonts face can draw, so
|
||||
/// whichever face an edit picks has them all. Without craft-fonts there are no faces, every character
|
||||
/// passes, and the text is still long.
|
||||
fn covered_japanese_chars(n: usize) -> String {
|
||||
let faces = pdfcraft_fonts::document_japanese_fonts_for_style(false, false);
|
||||
(0x4E00..0xA000)
|
||||
.filter_map(char::from_u32)
|
||||
.filter(|ch| faces.iter().all(|face| pdfcraft_fonts::japanese_glyph_from(face, *ch).is_ok()))
|
||||
.take(n)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The page's Type 3 fallback fonts (`PCJp`, `PCJp1`, ...) by resource name.
|
||||
fn fallback_fonts(doc: &Document) -> Vec<(Vec<u8>, Dict)> {
|
||||
let p = pdfcraft_model::pages(doc).swap_remove(0);
|
||||
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
|
||||
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
|
||||
fonts
|
||||
.as_dict()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(name, _)| name.starts_with(b"PCJp"))
|
||||
.map(|(name, font)| (name.clone(), doc.resolve(font).as_dict().unwrap().clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// More unique fallback glyphs than one Type 3 font holds (240) are split across several generated
|
||||
/// fonts, each within that limit, and the text still reads back as one line.
|
||||
#[test]
|
||||
fn japanese_replacement_over_240_unique_glyphs_is_split_across_fallback_fonts() {
|
||||
let replacement = covered_japanese_chars(300);
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
if without_craft_fonts("japanese_replacement_over_240_unique_glyphs_is_split_across_fallback_fonts") {
|
||||
// Without craft-fonts the long text gets the clear error, not the old limit message.
|
||||
let err = text::replace_line(&mut doc, 0, 0, &replacement).unwrap_err().to_string();
|
||||
assert!(err.contains("CRAFT_FONTS_DIR"), "{err}");
|
||||
return;
|
||||
}
|
||||
text::replace_line(&mut doc, 0, 0, &replacement).unwrap();
|
||||
let reopened = reopen(&doc);
|
||||
let lines = text::text_lines(&reopened, 0).unwrap();
|
||||
assert_eq!(lines.len(), 1, "one line: {:?}", lines.iter().map(|l| &l.text).collect::<Vec<_>>());
|
||||
assert_eq!(lines[0].text, replacement);
|
||||
let fonts = fallback_fonts(&reopened);
|
||||
assert_eq!(fonts.len(), 2, "300 glyphs take a 240-glyph font and a 60-glyph font");
|
||||
let mut glyphs = 0;
|
||||
for (name, font) in &fonts {
|
||||
let procs = reopened.resolve(font.get(b"CharProcs").unwrap());
|
||||
let count = procs.as_dict().unwrap().len();
|
||||
let last = font.get(b"LastChar").and_then(|o| o.as_int()).unwrap_or(i64::MAX);
|
||||
assert!(count <= 240 && last <= 240, "{name:?}: {count} glyphs, LastChar {last}");
|
||||
glyphs += count;
|
||||
}
|
||||
assert_eq!(glyphs, 300);
|
||||
}
|
||||
|
||||
/// A replacement whose last glyph no face draws (in a later font) changes nothing: neither the
|
||||
/// document nor a snapshot taken before the edit, which is what undo restores.
|
||||
#[test]
|
||||
fn japanese_replacement_missing_a_glyph_after_240_changes_nothing() {
|
||||
if without_craft_fonts("japanese_replacement_missing_a_glyph_after_240_changes_nothing") {
|
||||
return;
|
||||
}
|
||||
// The first CJK character that no face draws: the 242nd unique character, in a second font.
|
||||
let faces = pdfcraft_fonts::document_japanese_fonts_for_style(false, false);
|
||||
let missing = (0x4E00u32..0x9FFF)
|
||||
.filter_map(char::from_u32)
|
||||
.find(|ch| faces.iter().all(|face| pdfcraft_fonts::japanese_glyph_from(face, *ch).is_err()))
|
||||
.expect("some CJK character is outside every face");
|
||||
let replacement = format!("{}{missing}", covered_japanese_chars(241));
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let snapshot = doc.clone();
|
||||
let err = text::replace_line(&mut doc, 0, 0, &replacement).unwrap_err().to_string();
|
||||
assert!(err.contains(&format!("U+{:04X}", missing as u32)), "{err}");
|
||||
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), saved);
|
||||
assert_eq!(write_incremental(&snapshot, &SaveOptions::default()).unwrap(), saved);
|
||||
}
|
||||
|
||||
/// Runs that change font move nothing: with two glyphs per font the text reads back the same, in the
|
||||
/// same box as with one font, and the font is switched only where the text moves to another font.
|
||||
#[test]
|
||||
fn japanese_fallback_split_across_fonts_keeps_order_and_positions() {
|
||||
if without_craft_fonts("japanese_fallback_split_across_fonts_keeps_order_and_positions") {
|
||||
return;
|
||||
}
|
||||
// 見 and 商 are in the first font, 御 in the second: runs [見商], [御], [見], three switches.
|
||||
let replacement = "見商御見";
|
||||
let mut whole = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
let mut split = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
text::replace_line_with_limit(&mut whole, 0, 0, replacement, 240).unwrap();
|
||||
text::replace_line_with_limit(&mut split, 0, 0, replacement, 2).unwrap();
|
||||
let (whole, split) = (reopen(&whole), reopen(&split));
|
||||
assert_eq!(fallback_fonts(&whole).len(), 1);
|
||||
assert_eq!(fallback_fonts(&split).len(), 2);
|
||||
let (one, many) = (text::text_lines(&whole, 0).unwrap(), text::text_lines(&split, 0).unwrap());
|
||||
assert_eq!(many.len(), 1, "one line: {:?}", many.iter().map(|l| &l.text).collect::<Vec<_>>());
|
||||
assert_eq!(many[0].text, replacement);
|
||||
for (a, b) in one[0].rect.iter().zip(many[0].rect.iter()) {
|
||||
assert!((a - b).abs() < 1e-6, "box {:?} vs {:?}", one[0].rect, many[0].rect);
|
||||
}
|
||||
// One `Tf` per run plus the page's own switch to F2 and the switch back after the text.
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&split, 0)).into_owned();
|
||||
assert_eq!(content.matches(" Tf").count(), 5, "{content}");
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&whole, 0)).into_owned();
|
||||
assert_eq!(content.matches(" Tf").count(), 3, "{content}");
|
||||
}
|
||||
|
||||
/// Only a change of font gets a `Tf`: alternating characters with one glyph per font switch at every
|
||||
/// character, and a run of one font's characters switches nowhere.
|
||||
#[test]
|
||||
fn japanese_fallback_switches_font_only_between_runs() {
|
||||
if without_craft_fonts("japanese_fallback_switches_font_only_between_runs") {
|
||||
return;
|
||||
}
|
||||
let alternating = "見商".repeat(500);
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
text::replace_line_with_limit(&mut doc, 0, 0, &alternating, 1).unwrap();
|
||||
let reopened = reopen(&doc);
|
||||
assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, alternating);
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&reopened, 0)).into_owned();
|
||||
assert_eq!(content.matches(" Tf").count(), 1000 + 2, "1000 runs, the F2 switch before and after");
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
text::replace_line_with_limit(&mut doc, 0, 0, "見見見", 1).unwrap();
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned();
|
||||
assert_eq!(content.matches(" Tf").count(), 1 + 1 + 1, "{content}");
|
||||
}
|
||||
|
||||
/// A paragraph set in several fallback fonts reads back as one block with the same text, and it
|
||||
/// wraps across lines.
|
||||
#[test]
|
||||
fn japanese_paragraph_split_across_fonts_reads_back_as_one_block() {
|
||||
if without_craft_fonts("japanese_paragraph_split_across_fonts_reads_back_as_one_block") {
|
||||
return;
|
||||
}
|
||||
// Spaces are glyphs too, so two glyphs per font needs several fonts; the narrow box wraps the text.
|
||||
let replacement = "見 商 御 見 商";
|
||||
let style = text::BlockStyle { width: Some(40.0), ..Default::default() };
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
text::rewrite_block_with_limit(&mut doc, 0, 0, Some(replacement), &style, 2).unwrap();
|
||||
let reopened = reopen(&doc);
|
||||
assert!(fallback_fonts(&reopened).len() >= 2);
|
||||
let blocks = text::text_blocks(&reopened, 0).unwrap();
|
||||
assert_eq!(blocks.len(), 1, "one paragraph: {:?}", blocks.iter().map(|b| &b.text).collect::<Vec<_>>());
|
||||
assert_eq!(blocks[0].text, replacement);
|
||||
assert!(blocks[0].lines.len() > 1, "wrapped to {} line(s)", blocks[0].lines.len());
|
||||
}
|
||||
|
||||
/// A paragraph whose move or width is refused (not finite, or drawn in a space it cannot be moved in)
|
||||
/// leaves the document as it was: the fallback fonts are written only once the edit is accepted.
|
||||
#[test]
|
||||
fn japanese_paragraph_refused_before_its_fallback_fonts_are_written() {
|
||||
if without_craft_fonts("japanese_paragraph_refused_before_its_fallback_fonts_are_written") {
|
||||
return;
|
||||
}
|
||||
let plain = "BT /F2 12 Tf 72 700 Td (ab) Tj ET";
|
||||
let cases = [
|
||||
(plain, text::BlockStyle { offset: Some([f64::NAN, 0.0]), ..Default::default() }, "can't be moved that far"),
|
||||
(plain, text::BlockStyle { width: Some(f64::INFINITY), ..Default::default() }, "width must be a number"),
|
||||
(
|
||||
"0 0 0 0 0 0 cm BT /F2 12 Tf 72 700 Td (ab) Tj ET",
|
||||
text::BlockStyle { offset: Some([5.0, 0.0]), ..Default::default() },
|
||||
"can't be moved in",
|
||||
),
|
||||
];
|
||||
for (content, style, message) in cases {
|
||||
let mut doc = text_page(content);
|
||||
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let err = text::rewrite_block(&mut doc, 0, 0, Some("見本商会 御中"), &style).unwrap_err().to_string();
|
||||
assert!(err.contains(message), "{err}");
|
||||
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), saved, "{message}: the document changed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every Type 3 fallback glyph starts with a well-formed `d1`: six operands, the second 0, and a
|
||||
/// box that encloses every point of the glyph. Acrobat shows a bullet for a glyph whose `d1`
|
||||
/// has the wrong operand count.
|
||||
|
|
@ -1003,7 +1361,10 @@ fn page_content_bytes(doc: &Document, page: usize) -> Vec<u8> {
|
|||
|
||||
/// 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<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
|
|
@ -1027,6 +1388,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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ pub struct TextLine {
|
|||
/// Whether new text in this font can only be shown by substituting another font (no
|
||||
/// Unicode mapping for the line, so nothing could be reused).
|
||||
pub decodable: bool,
|
||||
/// The Japanese fallback face the font subsets, when the line is in one (see [`same_font`]).
|
||||
face: Option<String>,
|
||||
/// The content stream piece the line starts in, and its operators (indices into the page's
|
||||
/// streams parsed joined).
|
||||
stream: usize,
|
||||
|
|
@ -127,6 +129,14 @@ impl TextLine {
|
|||
pub fn origin_baseline(&self) -> f64 {
|
||||
self.origin.baseline
|
||||
}
|
||||
|
||||
/// Paint position of the line's last text-showing operator: `(content-stream index,
|
||||
/// operator index in the page's joined stream)`. Lines paint in order of this tuple.
|
||||
/// (Lines from `text_lines` never come from form XObjects, so `stream` is always a
|
||||
/// page content-stream index and `ops` are indices into the joined operator list.)
|
||||
pub fn paint_pos(&self) -> (usize, usize) {
|
||||
(self.stream, self.ops.iter().max().copied().unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
/// What replacing a line did.
|
||||
|
|
@ -142,6 +152,8 @@ struct Ts {
|
|||
/// The operators that set the current fill colour (`g`, `rg`, `k`, or `cs` + `sc`/`scn`).
|
||||
fill: Vec<Op>,
|
||||
font: Option<(Vec<u8>, Rc<Metrics>)>,
|
||||
/// The Japanese fallback face the font subsets, when the font is one (see [`is_japanese_fallback`]).
|
||||
face: Option<String>,
|
||||
size: f64,
|
||||
char_spacing: f64,
|
||||
word_spacing: f64,
|
||||
|
|
@ -265,6 +277,7 @@ struct Shown {
|
|||
bt: usize,
|
||||
font: Vec<u8>,
|
||||
base_font: String,
|
||||
face: Option<String>,
|
||||
size: f64,
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
|
|
@ -285,6 +298,7 @@ impl Carry {
|
|||
ctm: Matrix::IDENTITY,
|
||||
fill: Vec::new(),
|
||||
font: None,
|
||||
face: None,
|
||||
size: 0.0,
|
||||
char_spacing: 0.0,
|
||||
word_spacing: 0.0,
|
||||
|
|
@ -304,7 +318,7 @@ fn interpret(
|
|||
doc: &Document,
|
||||
ops: &[Op],
|
||||
fonts_res: &Dict,
|
||||
cache: &mut HashMap<Vec<u8>, Rc<Metrics>>,
|
||||
cache: &mut HashMap<Vec<u8>, (Rc<Metrics>, Option<String>)>,
|
||||
carry: &mut Carry,
|
||||
mut invoked: Option<&mut Vec<Invocation>>,
|
||||
) -> Vec<Shown> {
|
||||
|
|
@ -341,16 +355,14 @@ fn interpret(
|
|||
b"Tf" => {
|
||||
ts.size = op.num(1).unwrap_or(ts.size);
|
||||
if let Some(name) = op.name(0) {
|
||||
let m = cache.entry(name.to_vec()).or_insert_with(|| {
|
||||
Rc::new(
|
||||
fonts_res
|
||||
.get(name)
|
||||
.and_then(|f| doc.resolve(f).as_dict().cloned())
|
||||
.map(|d| Metrics::from_dict(doc, &d))
|
||||
.unwrap_or_else(Metrics::fallback),
|
||||
)
|
||||
let (m, face) = cache.entry(name.to_vec()).or_insert_with(|| {
|
||||
let dict = fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned());
|
||||
let metrics = dict.as_ref().map_or_else(Metrics::fallback, |d| Metrics::from_dict(doc, d));
|
||||
let face = dict.filter(is_japanese_fallback).map(|_| metrics.base_font.clone());
|
||||
(Rc::new(metrics), face)
|
||||
});
|
||||
ts.font = Some((name.to_vec(), m.clone()));
|
||||
ts.face = face.clone();
|
||||
}
|
||||
}
|
||||
b"Tc" => ts.char_spacing = op.num(0).unwrap_or(0.0),
|
||||
|
|
@ -460,6 +472,7 @@ fn interpret(
|
|||
bt,
|
||||
font: name,
|
||||
base_font: m.base_font.clone(),
|
||||
face: ts.face.clone(),
|
||||
size: if size_user > 0.0 { size_user } else { ts.size },
|
||||
bold: m.bold,
|
||||
italic: m.italic,
|
||||
|
|
@ -495,7 +508,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
|||
let joins = last.is_some_and(|(piece, bt, base, end, size)| {
|
||||
piece == si
|
||||
&& bt == s.bt
|
||||
&& lines.last().is_some_and(|l| l.font.as_bytes() == s.font.as_slice())
|
||||
&& lines.last().is_some_and(|l| same_font(l, &s.font, s.face.as_deref()))
|
||||
&& lines.last().is_some_and(|l| {
|
||||
(s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color
|
||||
})
|
||||
|
|
@ -524,6 +537,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
|||
italic: s.italic,
|
||||
color: fill_color(&s.state.fill),
|
||||
decodable: s.decodable,
|
||||
face: s.face.clone(),
|
||||
stream: si,
|
||||
ops: vec![s.op],
|
||||
origin: Origin {
|
||||
|
|
@ -551,7 +565,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
|||
fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<(usize, f64, f64, f64)>, s: Shown, stream: usize) {
|
||||
let joins = last.is_some_and(|(bt, base, end, size)| {
|
||||
bt == s.bt
|
||||
&& lines.last().is_some_and(|l| l.font.as_bytes() == s.font.as_slice())
|
||||
&& lines.last().is_some_and(|l| same_font(l, &s.font, s.face.as_deref()))
|
||||
&& lines
|
||||
.last()
|
||||
.is_some_and(|l| (s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color)
|
||||
|
|
@ -580,6 +594,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<(usize, f64, f64, f64)
|
|||
italic: s.italic,
|
||||
color: fill_color(&s.state.fill),
|
||||
decodable: s.decodable,
|
||||
face: s.face.clone(),
|
||||
stream,
|
||||
ops: vec![s.op],
|
||||
origin: Origin {
|
||||
|
|
@ -713,8 +728,8 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
|
|||
Ok(lines)
|
||||
}
|
||||
|
||||
/// Text → the bytes that show it in the chosen font.
|
||||
type Encoder = Box<dyn Fn(&str) -> Option<Vec<u8>>>;
|
||||
/// Text → the runs that show it: each run's font resource name and the bytes shown in that font.
|
||||
type Encoder = Box<dyn Fn(&str) -> Option<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
fn is_win_ansi_char(c: char) -> bool {
|
||||
c == '\t' || pdfcraft_fonts::win_ansi_byte(c).is_some()
|
||||
|
|
@ -724,6 +739,21 @@ fn needs_type3(text: &str) -> bool {
|
|||
text.chars().any(|c| !is_win_ansi_char(c))
|
||||
}
|
||||
|
||||
/// The `/Name` of the Type 3 fonts generated for Japanese text.
|
||||
const JAPANESE_FONT_NAME: &str = "PCJapanese";
|
||||
|
||||
/// Whether `font` is one of those generated fonts: a replacement longer than one of them is split
|
||||
/// across several, which read back as one line (see [`same_font`]).
|
||||
fn is_japanese_fallback(font: &Dict) -> bool {
|
||||
font.name(b"Subtype") == Some(b"Type3") && font.name(b"Name") == Some(JAPANESE_FONT_NAME.as_bytes())
|
||||
}
|
||||
|
||||
/// Whether a run shown in `font` (a resource name, and the Japanese fallback face it subsets, if
|
||||
/// any) continues `line`: the same font resource, or another subset of the same face.
|
||||
fn same_font(line: &TextLine, font: &[u8], face: Option<&str>) -> bool {
|
||||
line.font.as_bytes() == font || (line.face.is_some() && line.face.as_deref() == face)
|
||||
}
|
||||
|
||||
fn source_family(base_font: &str) -> crate::added::Family {
|
||||
let name = base_font.to_ascii_lowercase();
|
||||
if ["courier", "mono", "consolas", "menlo", "monaco", "lucida console"].iter().any(|s| name.contains(s)) {
|
||||
|
|
@ -741,12 +771,26 @@ const SUBSTITUTE_NAME: &str = "PCEdHelv";
|
|||
const SUBSTITUTE: &[u8] = SUBSTITUTE_NAME.as_bytes();
|
||||
const MAX_TYPE3_GLYPHS: usize = 240;
|
||||
|
||||
/// The generated fonts for one replacement: one per run of at most `max_glyphs` unique characters,
|
||||
/// in order of first appearance.
|
||||
#[derive(Clone)]
|
||||
struct Type3Fallback {
|
||||
name: String,
|
||||
/// The family its glyphs come from ("Shippori Mincho").
|
||||
family: &'static str,
|
||||
codes: Vec<(char, u8, f64)>,
|
||||
/// The fonts' resource names in order; the first is `PCJp`.
|
||||
names: Vec<String>,
|
||||
/// Every unique character with its advance (em), in order of first appearance.
|
||||
widths: Vec<(char, f64)>,
|
||||
/// Where each character is shown: the index of its font in `names`, and its code there.
|
||||
codes: HashMap<char, (usize, u8)>,
|
||||
}
|
||||
|
||||
/// One glyph of a generated font: its code there, its character, its glyph procedure and advance.
|
||||
struct Type3Glyph {
|
||||
code: u8,
|
||||
ch: char,
|
||||
path: Vec<u8>,
|
||||
width: f64,
|
||||
}
|
||||
|
||||
fn pdf_num(v: f64) -> String {
|
||||
|
|
@ -821,14 +865,20 @@ fn fallback_face<'a>(faces: &[&'a CraftFont], chars: &[char], has_glyph: impl Fn
|
|||
faces.iter().copied().find(|face| chars.iter().all(|ch| has_glyph(face, *ch))).or_else(|| faces.first().copied())
|
||||
}
|
||||
|
||||
fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str, family: crate::added::Family, bold: bool) -> Result<Type3Fallback, EditError> {
|
||||
fn type3_font(
|
||||
doc: &mut Document,
|
||||
fonts_res: &mut Dict,
|
||||
text: &str,
|
||||
family: crate::added::Family,
|
||||
bold: bool,
|
||||
max_glyphs: usize,
|
||||
) -> Result<Type3Fallback, EditError> {
|
||||
let faces = pdfcraft_fonts::document_japanese_fonts_for_style(family == crate::added::Family::Times, bold);
|
||||
// Each unique character once, in order of first appearance.
|
||||
let mut chars = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for ch in text.chars() {
|
||||
if !chars.contains(&ch) {
|
||||
if chars.len() >= MAX_TYPE3_GLYPHS {
|
||||
return Err(EditError::Invalid("Japanese replacement has too many unique characters".into()));
|
||||
}
|
||||
if seen.insert(ch) {
|
||||
chars.push(ch);
|
||||
}
|
||||
}
|
||||
|
|
@ -838,27 +888,54 @@ fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str, family: crat
|
|||
// The faces differ in coverage (e.g. of Cyrillic), so use the best face that has every
|
||||
// character. When none has them all, the best face reports the character it lacks.
|
||||
let face = fallback_face(&faces, &chars, |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(no_japanese_font)?;
|
||||
let family = face.family;
|
||||
let mut codes = Vec::with_capacity(chars.len());
|
||||
// Every outline is read before the document is touched, so a character that fails leaves it
|
||||
// as it was. Each font takes the next `per_font` characters; codes restart at 1 in each.
|
||||
let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS);
|
||||
let mut glyphs = Vec::with_capacity(chars.len());
|
||||
for (i, ch) in chars.into_iter().enumerate() {
|
||||
let (path, width) = type3_path(face, ch)?;
|
||||
let code = u8::try_from((i % per_font).saturating_add(1))
|
||||
.map_err(|_| EditError::Invalid("Japanese replacement has too many unique characters".into()))?;
|
||||
glyphs.push(Type3Glyph { code, ch, path, width });
|
||||
}
|
||||
let mut codes = HashMap::with_capacity(glyphs.len());
|
||||
let mut widths = Vec::with_capacity(glyphs.len());
|
||||
let mut names = Vec::new();
|
||||
let mut glyphs = glyphs.into_iter();
|
||||
loop {
|
||||
let group: Vec<Type3Glyph> = glyphs.by_ref().take(per_font).collect();
|
||||
if group.is_empty() {
|
||||
break;
|
||||
}
|
||||
for glyph in &group {
|
||||
codes.insert(glyph.ch, (names.len(), glyph.code));
|
||||
widths.push((glyph.ch, glyph.width));
|
||||
}
|
||||
names.push(add_type3_font(doc, fonts_res, face, group));
|
||||
}
|
||||
Ok(Type3Fallback { family: face.family, names, widths, codes })
|
||||
}
|
||||
|
||||
/// Adds one generated font whose codes 1 to `glyphs.len()` show `glyphs`, and returns its resource
|
||||
/// name (`PCJp`, then `PCJp1`, ...). Nothing here can fail: the outlines were read already.
|
||||
fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &CraftFont, glyphs: Vec<Type3Glyph>) -> String {
|
||||
let last_char = glyphs.len();
|
||||
let mut charprocs = Dict::new();
|
||||
let mut widths = Vec::with_capacity(chars.len());
|
||||
let mut widths = Vec::with_capacity(last_char);
|
||||
let mut differences = vec![Object::Int(1)];
|
||||
let mut cmap = String::from(
|
||||
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> <FF>\nendcodespacerange\n",
|
||||
);
|
||||
cmap.push_str(&format!("{} beginbfchar\n", chars.len()));
|
||||
for (i, ch) in chars.into_iter().enumerate() {
|
||||
let code = u8::try_from(i + 1).map_err(|_| EditError::Invalid("Japanese replacement has too many unique characters".into()))?;
|
||||
let glyph_name = format!("g{code:02X}");
|
||||
let (path, width) = type3_path(face, ch)?;
|
||||
cmap.push_str(&format!("{last_char} beginbfchar\n"));
|
||||
for glyph in glyphs {
|
||||
let glyph_name = format!("g{:02X}", glyph.code);
|
||||
let mut pd = Dict::new();
|
||||
pd.set(b"Length".to_vec(), path.len() as i64);
|
||||
let proc_ref = doc.add(Object::Stream(Stream::from_raw(pd, path)));
|
||||
pd.set(b"Length".to_vec(), glyph.path.len() as i64);
|
||||
let proc_ref = doc.add(Object::Stream(Stream::from_raw(pd, glyph.path)));
|
||||
charprocs.set(glyph_name.as_bytes().to_vec(), Object::Ref(proc_ref));
|
||||
differences.push(Object::name(&glyph_name));
|
||||
widths.push(Object::Real((width * 1000.0).round()));
|
||||
cmap.push_str(&format!("<{code:02X}> <{}>\n", unicode_hex(ch)));
|
||||
codes.push((ch, code, width));
|
||||
widths.push(Object::Real((glyph.width * 1000.0).round()));
|
||||
cmap.push_str(&format!("<{:02X}> <{}>\n", glyph.code, unicode_hex(glyph.ch)));
|
||||
}
|
||||
cmap.push_str("endbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
|
||||
let mut cmap_dict = Dict::new();
|
||||
|
|
@ -870,7 +947,7 @@ fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str, family: crat
|
|||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type3"));
|
||||
font.set(b"Name".to_vec(), Object::name("PCJapanese"));
|
||||
font.set(b"Name".to_vec(), Object::name(JAPANESE_FONT_NAME));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
|
||||
descriptor.set(b"FontName".to_vec(), Object::name(&format!("{}-{}", face.family, face.style).replace(' ', "")));
|
||||
|
|
@ -885,7 +962,7 @@ fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str, family: crat
|
|||
Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]),
|
||||
);
|
||||
font.set(b"FirstChar".to_vec(), Object::Int(1));
|
||||
font.set(b"LastChar".to_vec(), Object::Int(codes.len() as i64));
|
||||
font.set(b"LastChar".to_vec(), Object::Int(last_char as i64));
|
||||
font.set(b"Widths".to_vec(), Object::Array(widths));
|
||||
font.set(b"Encoding".to_vec(), Object::Dict(encoding));
|
||||
font.set(b"CharProcs".to_vec(), Object::Dict(charprocs));
|
||||
|
|
@ -897,15 +974,30 @@ fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str, family: crat
|
|||
name = format!("PCJp{suffix}");
|
||||
}
|
||||
fonts_res.set(name.as_bytes().to_vec(), Object::Dict(font));
|
||||
Ok(Type3Fallback { name, family, codes })
|
||||
name
|
||||
}
|
||||
|
||||
fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option<Vec<u8>> {
|
||||
text.chars().map(|ch| fallback.codes.iter().find(|(c, _, _)| *c == ch).map(|(_, code, _)| *code)).collect()
|
||||
/// The runs that show `text` in the fallback: consecutive characters in one font form one run, so
|
||||
/// a font is switched only where the text moves to another font. `None` if a character has no code.
|
||||
fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option<Vec<(String, Vec<u8>)>> {
|
||||
let mut runs: Vec<(usize, Vec<u8>)> = Vec::new();
|
||||
for ch in text.chars() {
|
||||
let (font, code) = *fallback.codes.get(&ch)?;
|
||||
match runs.last_mut() {
|
||||
Some((last, bytes)) if *last == font => bytes.push(code),
|
||||
_ => runs.push((font, vec![code])),
|
||||
}
|
||||
}
|
||||
runs.into_iter().map(|(font, bytes)| Some((fallback.names.get(font)?.clone(), bytes))).collect()
|
||||
}
|
||||
|
||||
/// Replace the text of line `line` (an index into [`text_lines`]) on `page` with `text`.
|
||||
pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) -> Result<LineEdit, EditError> {
|
||||
replace_line_with_limit(doc, page, line, text, MAX_TYPE3_GLYPHS)
|
||||
}
|
||||
|
||||
/// [`replace_line`] with the most unique characters one generated font may hold (tests lower it).
|
||||
pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usize, text: &str, max_glyphs: usize) -> Result<LineEdit, EditError> {
|
||||
let text = text.replace(['\n', '\r'], " ");
|
||||
let lines = text_lines(doc, page)?;
|
||||
let target = lines.get(line).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no line {}", page + 1, line + 1)))?;
|
||||
|
|
@ -935,12 +1027,14 @@ pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) ->
|
|||
Some(bytes) => replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])),
|
||||
None => {
|
||||
if needs_type3(&text) {
|
||||
let fallback = type3_font(doc, &mut fonts_res, &text, source_family(&target.base_font), target.bold)?;
|
||||
let bytes = type3_encode(&fallback, &text)
|
||||
let fallback = type3_font(doc, &mut fonts_res, &text, source_family(&target.base_font), target.bold, max_glyphs)?;
|
||||
let runs = type3_encode(&fallback, &text)
|
||||
.ok_or_else(|| EditError::Invalid(format!("\"{text}\" can't be shown by the Japanese fallback")))?;
|
||||
let size = font_size_before(&ops, first).unwrap_or(target.size);
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&fallback.name), pdfcraft_content::num(size)]));
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
|
||||
for (name, bytes) in runs {
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&name), pdfcraft_content::num(size)]));
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
|
||||
}
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
|
||||
substituted = Some(format!("{} Type3", fallback.family));
|
||||
} else {
|
||||
|
|
@ -1043,7 +1137,7 @@ fn group_blocks(lines: &[TextLine]) -> Vec<TextBlock> {
|
|||
let prev = &lines[p];
|
||||
let g = prev.origin.baseline - l.origin.baseline;
|
||||
prev.stream == l.stream
|
||||
&& prev.font == l.font
|
||||
&& same_font(prev, l.font.as_bytes(), l.face.as_deref())
|
||||
&& prev.base_font == l.base_font
|
||||
&& prev.bold == l.bold
|
||||
&& prev.italic == l.italic
|
||||
|
|
@ -1144,6 +1238,18 @@ pub struct BlockStyle {
|
|||
|
||||
/// Rewrite paragraph `block` with new text (or its own) and formatting, rewrapped to its width.
|
||||
pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option<&str>, style: &BlockStyle) -> Result<LineEdit, EditError> {
|
||||
rewrite_block_with_limit(doc, page, block, text, style, MAX_TYPE3_GLYPHS)
|
||||
}
|
||||
|
||||
/// [`rewrite_block`] with the most unique characters one generated font may hold (tests lower it).
|
||||
pub(crate) fn rewrite_block_with_limit(
|
||||
doc: &mut Document,
|
||||
page: usize,
|
||||
block: usize,
|
||||
text: Option<&str>,
|
||||
style: &BlockStyle,
|
||||
max_glyphs: usize,
|
||||
) -> Result<LineEdit, EditError> {
|
||||
let lines = text_lines(doc, page)?;
|
||||
let blocks = group_blocks(&lines);
|
||||
let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?;
|
||||
|
|
@ -1175,7 +1281,6 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
|
|||
// The chosen style also determines the real Japanese fallback outlines and advances.
|
||||
let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic));
|
||||
let bold = style.bold.unwrap_or(bold);
|
||||
let type3 = if !reuse && needs_type3(&text) { Some(type3_font(doc, &mut fonts_res, &text, family, bold)?) } else { None };
|
||||
let std_width = move |s: &str, size: f64| -> f64 {
|
||||
match family {
|
||||
crate::added::Family::Courier => s.chars().count() as f64 * 0.6 * size,
|
||||
|
|
@ -1196,6 +1301,19 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
|
|||
None if members.len() == 1 => (b.rect[2] - b.rect[0]).max(size * k).max(p.crop(doc)[2] - 36.0 - (b.rect[0] + dx)),
|
||||
None => (b.rect[2] - b.rect[0]).max(size * k),
|
||||
};
|
||||
// The move and the width are checked here, before the fallback fonts are written: a refused
|
||||
// paragraph leaves the document as it was. The move is in page space, so it is taken back through
|
||||
// the CTM's linear part into the space the paragraph is drawn in.
|
||||
let movement = if dx != 0.0 || dy != 0.0 {
|
||||
let c = o.ctm;
|
||||
let back = Matrix([c[0], c[1], c[2], c[3], 0.0, 0.0])
|
||||
.invert()
|
||||
.ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?;
|
||||
Some(back.apply(dx, dy))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let type3 = if !reuse && needs_type3(&text) { Some(type3_font(doc, &mut fonts_res, &text, family, bold, max_glyphs)?) } else { None };
|
||||
let advance = |s: &str| -> f64 {
|
||||
let t = match (&metrics, reuse) {
|
||||
(Some(m), true) => m
|
||||
|
|
@ -1208,7 +1326,13 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
|
|||
})
|
||||
.unwrap_or(0.0),
|
||||
_ if let Some(fallback) = &type3 => {
|
||||
fallback.codes.iter().map(|(ch, _, width)| s.chars().filter(|c| c == ch).count() as f64 * width * size).sum::<f64>()
|
||||
// Each character's count in `s`, from one pass over `s`.
|
||||
let mut counts: HashMap<char, usize> = HashMap::new();
|
||||
for c in s.chars() {
|
||||
let count = counts.entry(c).or_insert(0);
|
||||
*count = count.saturating_add(1);
|
||||
}
|
||||
fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * size).sum::<f64>()
|
||||
+ s.chars().count() as f64 * o_state.char_spacing
|
||||
}
|
||||
_ => std_width(s, size) + s.chars().count() as f64 * o_state.char_spacing,
|
||||
|
|
@ -1219,12 +1343,12 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
|
|||
let mut substituted = None;
|
||||
let new_font = !reuse;
|
||||
let (show_font, encode): (String, Encoder) = if let Some(m) = metrics.clone().filter(|_| reuse) {
|
||||
(font_name.clone(), Box::new(move |s: &str| m.encode(s)))
|
||||
let name = font_name.clone();
|
||||
(font_name.clone(), Box::new(move |s: &str| m.encode(s).map(|bytes| vec![(name.clone(), bytes)])))
|
||||
} else if let Some(fallback) = type3.clone() {
|
||||
let name = fallback.name.clone();
|
||||
let encoder = fallback.clone();
|
||||
substituted = Some(format!("{} Type3", fallback.family));
|
||||
(name, Box::new(move |s: &str| type3_encode(&encoder, s)))
|
||||
let first = fallback.names.first().cloned().ok_or_else(no_japanese_font)?;
|
||||
(first, Box::new(move |s: &str| type3_encode(&fallback, s)))
|
||||
} else {
|
||||
let win = pdfcraft_fonts::win_ansi(&text);
|
||||
let back: String = win.iter().map(|c| char::from_u32(u32::from(*c)).unwrap_or('?')).collect();
|
||||
|
|
@ -1242,7 +1366,8 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
|
|||
if style.family.is_none() {
|
||||
substituted = Some(base.to_string());
|
||||
}
|
||||
(name, Box::new(|s: &str| Some(pdfcraft_fonts::win_ansi(s))))
|
||||
let shown = name.clone();
|
||||
(name, Box::new(move |s: &str| Some(vec![(shown.clone(), pdfcraft_fonts::win_ansi(s))])))
|
||||
};
|
||||
// Line spacing in text space: the paragraph's own (scaled with the size), or 1.2 × the size.
|
||||
let lead = match style.line_spacing {
|
||||
|
|
@ -1254,19 +1379,15 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
|
|||
let n = pdfcraft_content::num;
|
||||
// In its own graphics state, so a new colour (or anything else) stops at the paragraph.
|
||||
let mut block_ops = vec![Op::new("q", vec![])];
|
||||
// Moved: a translation inside that state. The move is in page space, so it is taken back
|
||||
// through the CTM's linear part into the space the paragraph is drawn in.
|
||||
if dx != 0.0 || dy != 0.0 {
|
||||
let c = o.ctm;
|
||||
let back = Matrix([c[0], c[1], c[2], c[3], 0.0, 0.0])
|
||||
.invert()
|
||||
.ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?;
|
||||
let (ax, ay) = back.apply(dx, dy);
|
||||
// Moved: a translation inside that state.
|
||||
if let Some((ax, ay)) = movement {
|
||||
block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)]));
|
||||
}
|
||||
block_ops.push(Op::new("BT", vec![]));
|
||||
let mut state = o_state.clone();
|
||||
state.word_spacing = 0.0;
|
||||
// The font the next run is shown in: the one the BT sets, until a run of another fallback subset.
|
||||
let mut shown_font = show_font.clone();
|
||||
state.font = Some((show_font, size));
|
||||
if let Some([r, g, bl]) = style.color {
|
||||
state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])];
|
||||
|
|
@ -1301,8 +1422,13 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
|
|||
block_ops.push(Op::new("Tw", vec![n(tw)]));
|
||||
tw_set = tw;
|
||||
}
|
||||
let bytes = encode(line).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))?;
|
||||
block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
|
||||
for (font, bytes) in encode(line).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? {
|
||||
if font != shown_font {
|
||||
block_ops.push(Op::new("Tf", vec![Object::name(&font), n(size)]));
|
||||
shown_font = font;
|
||||
}
|
||||
block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
|
||||
}
|
||||
let w = (advance(line) + tw * spaces as f64 * o_state.scale * k) / k;
|
||||
underlines.push((dx, dx + w, -(i as f64) * lead - size * 0.12));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ pdfcraft-annot = { workspace = true }
|
|||
pdfcraft-forms = { workspace = true }
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
pdfcraft-edit = { workspace = true }
|
||||
pdfcraft-audit = { workspace = true }
|
||||
pdfcraft-redact = { workspace = true }
|
||||
pdfcraft-print = { workspace = true }
|
||||
pdfcraft-xfdf = { workspace = true }
|
||||
|
|
@ -32,6 +33,7 @@ pdfcraft-export = { workspace = true }
|
|||
pdfcraft-fonts = { workspace = true }
|
||||
pdfcraft-create = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-geom = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
png = "0.18"
|
||||
# Already in the graph (url and idna through webbrowser, icu_properties through boa): they show
|
||||
|
|
|
|||
|
|
@ -0,0 +1,280 @@
|
|||
//! Scan & OCR ▸ Enhance ▸ Camera image: straighten a photographed document. Four corners on the
|
||||
//! page (PDF user space) are warped to a rectangle, and the page's content becomes that picture
|
||||
//! (one undoable step, through Replace Pages, so the page's comments, links and bookmarks stay).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_geom::perspective::{Homography, Point, order_quad};
|
||||
use pdfcraft_render::{RenderConfig, RenderRequest};
|
||||
|
||||
use crate::{DocId, Edit, EditError, ImageResolution, Session};
|
||||
|
||||
/// The densest the page is read at: 600 dpi, as for OCR.
|
||||
const MAX_SCALE: f32 = 600.0 / 72.0;
|
||||
/// The resolution a page without a picture is read at.
|
||||
const DEFAULT_SCALE: f32 = 300.0 / 72.0;
|
||||
/// The most pixels the page is rendered with, and the straightened picture has.
|
||||
const MAX_PIXELS: f64 = 40_000_000.0;
|
||||
/// JPEG quality of the straightened picture.
|
||||
const QUALITY: u8 = 90;
|
||||
|
||||
/// A straightened picture: RGB8, row-major.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Warped {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgb: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Warp the quadrilateral `quad` (raster pixels, any corner order) of a premultiplied RGBA
|
||||
/// raster to an upright rectangle as large as the quadrilateral's longer sides, composited over
|
||||
/// white. Errors say what was wrong with the corners or the raster.
|
||||
pub fn warp(rgba: &[u8], width: u32, height: u32, quad: [Point; 4]) -> Result<Warped, String> {
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
if w == 0 || h == 0 || w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(rgba.len()) {
|
||||
return Err("the page could not be read as a picture".into());
|
||||
}
|
||||
let [tl, tr, br, bl] = order_quad(quad).ok_or("the four corners must enclose an area (a convex quadrilateral)")?;
|
||||
let dist = |a: Point, b: Point| (a[0] - b[0]).hypot(a[1] - b[1]);
|
||||
let mut ow = dist(tl, tr).max(dist(bl, br));
|
||||
let mut oh = dist(tl, bl).max(dist(tr, br));
|
||||
let k = (MAX_PIXELS / (ow * oh).max(1.0)).sqrt().min(1.0);
|
||||
ow = (ow * k).floor().max(1.0);
|
||||
oh = (oh * k).floor().max(1.0);
|
||||
if !(ow.is_finite() && oh.is_finite()) {
|
||||
return Err("the corners are out of range".into());
|
||||
}
|
||||
// Both are at least 1 and their product is at most MAX_PIXELS, so the casts are exact.
|
||||
let (ow, oh) = (ow as u32, oh as u32);
|
||||
let rect = [[0.0, 0.0], [ow as f64, 0.0], [ow as f64, oh as f64], [0.0, oh as f64]];
|
||||
let to_src = Homography::from_points(rect, [tl, tr, br, bl]).ok_or("the four corners must not be in a line")?;
|
||||
|
||||
let px = |x: usize, y: usize| -> [f64; 4] {
|
||||
let i = (y * w + x) * 4;
|
||||
match rgba.get(i..i + 4) {
|
||||
Some(p) => [p[0] as f64, p[1] as f64, p[2] as f64, p[3] as f64],
|
||||
None => [0.0; 4],
|
||||
}
|
||||
};
|
||||
let mut rgb = Vec::with_capacity(ow as usize * oh as usize * 3);
|
||||
for y in 0..oh {
|
||||
for x in 0..ow {
|
||||
// Sample at the pixel's centre; outside the page is white.
|
||||
let mut c = [0.0f64; 4];
|
||||
if let Some([sx, sy]) = to_src.apply([x as f64 + 0.5, y as f64 + 0.5]) {
|
||||
let (fx, fy) = (sx - 0.5, sy - 0.5);
|
||||
if fx > -1.0 && fy > -1.0 && fx < w as f64 && fy < h as f64 {
|
||||
let (x0, y0) = (fx.floor(), fy.floor());
|
||||
let (ax, ay) = (fx - x0, fy - y0);
|
||||
// Clamped to the raster, so every index below is in range.
|
||||
let clamp = |v: f64, n: usize| v.clamp(0.0, (n - 1) as f64) as usize;
|
||||
let (xa, xb) = (clamp(x0, w), clamp(x0 + 1.0, w));
|
||||
let (ya, yb) = (clamp(y0, h), clamp(y0 + 1.0, h));
|
||||
let (p00, p10, p01, p11) = (px(xa, ya), px(xb, ya), px(xa, yb), px(xb, yb));
|
||||
for (i, v) in c.iter_mut().enumerate() {
|
||||
let top = p00[i] * (1.0 - ax) + p10[i] * ax;
|
||||
let bottom = p01[i] * (1.0 - ax) + p11[i] * ax;
|
||||
*v = top * (1.0 - ay) + bottom * ay;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Premultiplied over white: colour + (1 - alpha) × white.
|
||||
for v in &c[..3] {
|
||||
rgb.push((v + 255.0 - c[3]).round().clamp(0.0, 255.0) as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Warped { width: ow, height: oh, rgb })
|
||||
}
|
||||
|
||||
/// The long side, in pixels, a page is rendered at to look for the document's edges.
|
||||
const DETECT_SIDE: f32 = 1200.0;
|
||||
|
||||
impl Session {
|
||||
/// Enhance ▸ Camera image: the corners of the photographed document on page `page`
|
||||
/// (0-based) of document `id`, in PDF user space (top-left, top-right, bottom-right,
|
||||
/// bottom-left as displayed), or `None` when its edges can't be found with confidence.
|
||||
pub fn detect_camera_corners(&self, id: DocId, page: usize) -> Option<[Point; 4]> {
|
||||
let doc = self.get(id)?;
|
||||
let info = doc.info.pages.get(page)?;
|
||||
let scale = (DETECT_SIDE / info.width.max(info.height).max(1.0)).clamp(0.05, MAX_SCALE);
|
||||
let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
|
||||
let shot = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config).render(RenderRequest { page, scale, ..Default::default() });
|
||||
if shot.error.is_some() {
|
||||
return None;
|
||||
}
|
||||
let quad = crate::camera_edges::detect_document(&shot.rgba, shot.width, shot.height)?;
|
||||
let k = shot.width as f64 / (info.width as f64).max(1e-3);
|
||||
Some(quad.map(|[x, y]| {
|
||||
let [u, v] = info.view_to_user((x / k) as f32, (y / k) as f32);
|
||||
[u as f64, v as f64]
|
||||
}))
|
||||
}
|
||||
|
||||
/// Enhance ▸ Camera image: replace page `page` (0-based) of document `id` with the part of it
|
||||
/// inside `corners` (PDF user space, any order), straightened to a rectangle. The page is
|
||||
/// read at its picture's own resolution (at most 600 dpi) and keeps that physical size.
|
||||
pub fn enhance_camera_page(&mut self, id: DocId, page: usize, corners: [Point; 4]) -> Result<(), EditError> {
|
||||
self.enhance_camera_page_at(id, page, corners, crate::camera_clean::Look::Original, 0)
|
||||
}
|
||||
|
||||
/// [`Session::enhance_camera_page`], then give the straightened photo a `look` at `level`
|
||||
/// (0 = as photographed … 100 = white paper, strong contrast, no specks; for black and white
|
||||
/// the threshold; see [`crate::camera_clean`]). Still one undoable step.
|
||||
pub fn enhance_camera_page_at(
|
||||
&mut self,
|
||||
id: DocId,
|
||||
page: usize,
|
||||
corners: [Point; 4],
|
||||
look: crate::camera_clean::Look,
|
||||
level: u8,
|
||||
) -> Result<(), EditError> {
|
||||
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
||||
if let Some(why) = &doc.read_only_reason {
|
||||
return Err(EditError::ReadOnly(why.clone()));
|
||||
}
|
||||
let info = doc.info.pages.get(page).ok_or_else(|| EditError::Invalid(format!("there is no page {}", page.saturating_add(1))))?;
|
||||
let native = doc
|
||||
.page_images(page)
|
||||
.iter()
|
||||
.filter(|i| i.width > 1 && i.height > 1)
|
||||
.map(|i| {
|
||||
let m = i.matrix;
|
||||
let (w, h) = (m[0].hypot(m[1]), m[2].hypot(m[3]));
|
||||
(i.width as f64 / w.max(1e-6)).max(i.height as f64 / h.max(1e-6)) as f32
|
||||
})
|
||||
.filter(|s| s.is_finite())
|
||||
.reduce(f32::max);
|
||||
let area = (info.width.max(1.0) as f64) * (info.height.max(1.0) as f64);
|
||||
let cap = (MAX_PIXELS / area).sqrt() as f32;
|
||||
let scale = native.unwrap_or(DEFAULT_SCALE).clamp(1.0, MAX_SCALE).min(cap).max(0.01);
|
||||
|
||||
let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
|
||||
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config);
|
||||
let shot = r.render(RenderRequest { page, scale, ..Default::default() });
|
||||
if let Some(e) = shot.error {
|
||||
return Err(EditError::Invalid(format!("page {} could not be rendered: {e}", page + 1)));
|
||||
}
|
||||
let k = shot.width as f64 / (info.width as f64).max(1e-3);
|
||||
let quad = corners.map(|[x, y]| {
|
||||
let [vx, vy] = info.user_to_view(x as f32, y as f32);
|
||||
[vx as f64 * k, vy as f64 * k]
|
||||
});
|
||||
let out = warp(&shot.rgba, shot.width, shot.height, quad).map_err(EditError::Invalid)?;
|
||||
let pixels = crate::camera_clean::apply(&out.rgb, out.width, out.height, look, level)
|
||||
.ok_or_else(|| EditError::Invalid("the straightened page could not be cleaned up".into()))?;
|
||||
// Black and white is lossless (PNG): JPEG would smear grey around every letter.
|
||||
let mut encoded = Vec::new();
|
||||
let (name, written) = match &pixels {
|
||||
crate::camera_clean::Pixels::Rgb(rgb) => (
|
||||
"camera.jpg",
|
||||
image::ImageEncoder::write_image(
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, QUALITY),
|
||||
rgb,
|
||||
out.width,
|
||||
out.height,
|
||||
image::ExtendedColorType::Rgb8,
|
||||
),
|
||||
),
|
||||
crate::camera_clean::Pixels::Grey(grey) if look == crate::camera_clean::Look::BlackWhite => (
|
||||
"camera.png",
|
||||
image::ImageEncoder::write_image(
|
||||
image::codecs::png::PngEncoder::new(&mut encoded),
|
||||
grey,
|
||||
out.width,
|
||||
out.height,
|
||||
image::ExtendedColorType::L8,
|
||||
),
|
||||
),
|
||||
crate::camera_clean::Pixels::Grey(grey) => (
|
||||
"camera.jpg",
|
||||
image::ImageEncoder::write_image(
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, QUALITY),
|
||||
grey,
|
||||
out.width,
|
||||
out.height,
|
||||
image::ExtendedColorType::L8,
|
||||
),
|
||||
),
|
||||
};
|
||||
written.map_err(|e| EditError::Invalid(format!("the straightened page could not be encoded: {e}")))?;
|
||||
// One device pixel per `k` points keeps the page's physical size.
|
||||
let dpi = (k * 72.0).clamp(1.0, 1200.0);
|
||||
let bytes = self.create_from_images_with_resolution(&[(name.into(), encoded)], ImageResolution::Dpi(dpi))?;
|
||||
let replace = Edit::ReplacePages { pages: vec![page], name: "camera.pdf".into(), bytes, src_pages: vec![0] };
|
||||
self.apply(id, Edit::Batch { label: "Enhance camera image".into(), edits: vec![replace] })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// An opaque premultiplied raster filled by `f(x, y) -> [r, g, b]`.
|
||||
fn raster(w: u32, h: u32, f: impl Fn(u32, u32) -> [u8; 3]) -> Vec<u8> {
|
||||
(0..h)
|
||||
.flat_map(|y| (0..w).map(move |x| (x, y)))
|
||||
.flat_map(|(x, y)| {
|
||||
let [r, g, b] = f(x, y);
|
||||
[r, g, b, 255]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tilted_quad_comes_out_upright() {
|
||||
// A dark quadrilateral on white; warping exactly its corners leaves a dark picture.
|
||||
let quad = [[20.0, 30.0], [170.0, 10.0], [180.0, 150.0], [10.0, 160.0]];
|
||||
let inside = |x: f64, y: f64| {
|
||||
let q = order_quad(quad).unwrap();
|
||||
(0..4).all(|i| {
|
||||
let (a, b) = (q[i], q[(i + 1) % 4]);
|
||||
(b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]) >= 0.0
|
||||
})
|
||||
};
|
||||
let src = raster(200, 180, |x, y| if inside(x as f64 + 0.5, y as f64 + 0.5) { [20, 20, 20] } else { [255, 255, 255] });
|
||||
let out = warp(&src, 200, 180, quad).unwrap();
|
||||
// The longer of each pair of opposite sides: 170 × 140.
|
||||
assert_eq!((out.width, out.height), (170, 140));
|
||||
// Away from the anti-aliased edges, everything is the dark fill.
|
||||
let at = |x: u32, y: u32| out.rgb[((y * out.width + x) * 3) as usize];
|
||||
for (x, y) in [(5, 5), (85, 70), (164, 134), (5, 134), (164, 5)] {
|
||||
assert!(at(x, y) < 60, "({x}, {y}) = {}", at(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_corner_order_does_not_matter() {
|
||||
let src = raster(64, 48, |x, y| [(x * 4) as u8, (y * 5) as u8, 128]);
|
||||
let q = [[4.0, 3.0], [60.0, 6.0], [58.0, 44.0], [2.0, 40.0]];
|
||||
let a = warp(&src, 64, 48, q).unwrap();
|
||||
let b = warp(&src, 64, 48, [q[2], q[0], q[3], q[1]]).unwrap();
|
||||
assert_eq!(a, b);
|
||||
// Left to right the red channel grows, top to bottom the green one does.
|
||||
let px = |x: u32, y: u32| &a.rgb[((y * a.width + x) * 3) as usize..][..3];
|
||||
assert!(px(a.width - 3, 10)[0] > px(2, 10)[0] + 100);
|
||||
assert!(px(10, a.height - 3)[1] > px(10, 2)[1] + 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_becomes_white() {
|
||||
let src = vec![0u8; 8 * 8 * 4];
|
||||
let out = warp(&src, 8, 8, [[0.0, 0.0], [8.0, 0.0], [8.0, 8.0], [0.0, 8.0]]).unwrap();
|
||||
assert!(out.rgb.iter().all(|&v| v == 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_corners_and_rasters_are_errors_not_panics() {
|
||||
let src = raster(10, 10, |_, _| [0, 0, 0]);
|
||||
let line = [[0.0, 0.0], [5.0, 5.0], [10.0, 10.0], [2.0, 2.0]];
|
||||
assert!(warp(&src, 10, 10, line).is_err());
|
||||
assert!(warp(&src, 10, 10, [[f64::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]).is_err());
|
||||
let ok = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]];
|
||||
assert!(warp(&src, 11, 10, ok).is_err(), "size doesn't match the buffer");
|
||||
assert!(warp(&[], 0, 0, ok).is_err());
|
||||
// Corners far outside the raster: a capped, white picture, not a huge allocation.
|
||||
let far = [[-1e9, -1e9], [1e9, -1e9], [1e9, 1e9], [-1e9, 1e9]];
|
||||
let out = warp(&src, 10, 10, far).unwrap();
|
||||
assert!((out.width as f64) * (out.height as f64) <= MAX_PIXELS);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,392 @@
|
|||
//! Clean up a straightened camera photo (Enhance ▸ Camera image ▸ enhancement level): even out
|
||||
//! the lighting so the paper turns white (room-light colour casts, a hand's shadow, darker
|
||||
//! edges go with it), then raise the contrast and drop isolated specks. Colour ink stays colour.
|
||||
//!
|
||||
//! The paper's illumination is estimated per channel on a coarse grid of blocks, from each
|
||||
//! block's bright end (the paper, not the ink), smoothed into a continuous map and divided out.
|
||||
//! Pure Rust and deterministic.
|
||||
|
||||
/// The strongest a level can be.
|
||||
pub const MAX_LEVEL: u8 = 100;
|
||||
|
||||
/// How an enhanced camera page looks.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Look {
|
||||
/// The straightened photo as it was taken.
|
||||
Original,
|
||||
/// Cleaned up, colour ink kept.
|
||||
#[default]
|
||||
Colour,
|
||||
/// Cleaned up, in shades of grey.
|
||||
Greyscale,
|
||||
/// Black ink on white paper only, each pixel one or the other.
|
||||
BlackWhite,
|
||||
}
|
||||
|
||||
impl Look {
|
||||
pub const ALL: [Look; 4] = [Look::Original, Look::Colour, Look::Greyscale, Look::BlackWhite];
|
||||
|
||||
/// The name tools use.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Look::Original => "original",
|
||||
Look::Colour => "colour",
|
||||
Look::Greyscale => "greyscale",
|
||||
Look::BlackWhite => "bw",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Look> {
|
||||
Look::ALL.into_iter().find(|l| l.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// A page's pixels after [`apply`]: RGB8, or one grey byte per pixel.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Pixels {
|
||||
Rgb(Vec<u8>),
|
||||
Grey(Vec<u8>),
|
||||
}
|
||||
|
||||
/// Give a straightened photo (`rgb`, RGB8) the `look` at `level`. For black and white the level
|
||||
/// sets the threshold: higher keeps fainter strokes black. `None` when the buffer doesn't match
|
||||
/// the size.
|
||||
pub fn apply(rgb: &[u8], width: u32, height: u32, look: Look, level: u8) -> Option<Pixels> {
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
if w == 0 || h == 0 || w.checked_mul(h)?.checked_mul(3)? != rgb.len() {
|
||||
return None;
|
||||
}
|
||||
let lum = |p: &[u8; 3]| ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000) as u8;
|
||||
Some(match look {
|
||||
Look::Original => Pixels::Rgb(rgb.to_vec()),
|
||||
Look::Colour => Pixels::Rgb(clean(rgb, width, height, level)?),
|
||||
Look::Greyscale => Pixels::Grey(clean(rgb, width, height, level)?.as_chunks::<3>().0.iter().map(lum).collect()),
|
||||
Look::BlackWhite => {
|
||||
// The lighting evened out fully, then one threshold for the whole page.
|
||||
let even = clean(rgb, width, height, 50)?;
|
||||
let t = 0.45 + 0.4 * level.min(MAX_LEVEL) as f32 / MAX_LEVEL as f32;
|
||||
let mut grey: Vec<u8> = even.as_chunks::<3>().0.iter().map(|p| if (lum(p) as f32 / 255.0) < t { 0 } else { 255 }).collect();
|
||||
despeckle_grey(&mut grey, w, h);
|
||||
Pixels::Grey(grey)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Clean `rgb` (RGB8, `width` × `height`) at `level` (0 = unchanged … 100 = strongest).
|
||||
/// Returns `None` when the buffer doesn't match the size.
|
||||
pub fn clean(rgb: &[u8], width: u32, height: u32, level: u8) -> Option<Vec<u8>> {
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
if w == 0 || h == 0 || w.checked_mul(h)?.checked_mul(3)? != rgb.len() {
|
||||
return None;
|
||||
}
|
||||
let level = level.min(MAX_LEVEL);
|
||||
if level == 0 {
|
||||
return Some(rgb.to_vec());
|
||||
}
|
||||
let k = level as f32 / MAX_LEVEL as f32;
|
||||
let background = background(rgb, w, h);
|
||||
// Lighting is evened out fully from half strength on; contrast keeps growing to the top.
|
||||
let flatten = (2.0 * k).min(1.0);
|
||||
let white = 1.0 - 0.12 * k; // what reaches white
|
||||
let black = 0.30 * k; // what reaches black
|
||||
let mut out = Vec::with_capacity(rgb.len());
|
||||
for (i, px) in rgb.as_chunks::<3>().0.iter().enumerate() {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let bg = background.at(x, y);
|
||||
for (c, &v) in px.iter().enumerate() {
|
||||
let v = v as f32 / 255.0;
|
||||
let b = bg.get(c).copied().unwrap_or(1.0).max(0.05);
|
||||
let even = (v / b).min(1.0);
|
||||
let flat = v + (even - v) * flatten;
|
||||
let stretched = ((flat - black) / (white - black).max(0.05)).clamp(0.0, 1.0);
|
||||
out.push((stretched * 255.0).round() as u8);
|
||||
}
|
||||
}
|
||||
if level >= 60 {
|
||||
despeckle(&mut out, w, h);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The paper's illumination: a grid of per-channel values, read back with bilinear
|
||||
/// interpolation.
|
||||
struct Background {
|
||||
cols: usize,
|
||||
rows: usize,
|
||||
block: f32,
|
||||
cells: Vec<[f32; 3]>,
|
||||
}
|
||||
|
||||
impl Background {
|
||||
fn cell(&self, cx: isize, cy: isize) -> [f32; 3] {
|
||||
let cx = cx.clamp(0, self.cols as isize - 1) as usize;
|
||||
let cy = cy.clamp(0, self.rows as isize - 1) as usize;
|
||||
self.cells.get(cy * self.cols + cx).copied().unwrap_or([1.0; 3])
|
||||
}
|
||||
|
||||
fn at(&self, x: usize, y: usize) -> [f32; 3] {
|
||||
// Cell centres sit at (i + 0.5) × block.
|
||||
let fx = (x as f32 + 0.5) / self.block - 0.5;
|
||||
let fy = (y as f32 + 0.5) / self.block - 0.5;
|
||||
let (x0, y0) = (fx.floor(), fy.floor());
|
||||
let (ax, ay) = (fx - x0, fy - y0);
|
||||
let (x0, y0) = (x0 as isize, y0 as isize);
|
||||
let (a, b, c, d) = (self.cell(x0, y0), self.cell(x0 + 1, y0), self.cell(x0, y0 + 1), self.cell(x0 + 1, y0 + 1));
|
||||
std::array::from_fn(|i| {
|
||||
let top = a[i] * (1.0 - ax) + b[i] * ax;
|
||||
let bottom = c[i] * (1.0 - ax) + d[i] * ax;
|
||||
top * (1.0 - ay) + bottom * ay
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate the paper's illumination: per block, the 90th percentile of each channel (bright
|
||||
/// paper, not dark ink); blocks that are mostly ink borrow from their neighbours; then smoothed.
|
||||
fn background(rgb: &[u8], w: usize, h: usize) -> Background {
|
||||
let block = (w.min(h) / 16).clamp(8, 128);
|
||||
let (cols, rows) = (w.div_ceil(block), h.div_ceil(block));
|
||||
let mut cells = Vec::with_capacity(cols * rows);
|
||||
let mut hist = [[0u32; 256]; 3];
|
||||
for by in 0..rows {
|
||||
for bx in 0..cols {
|
||||
hist.iter_mut().for_each(|hh| hh.fill(0));
|
||||
let mut n = 0u32;
|
||||
for y in by * block..((by + 1) * block).min(h) {
|
||||
for x in bx * block..((bx + 1) * block).min(w) {
|
||||
if let Some(p) = rgb.get((y * w + x) * 3..(y * w + x) * 3 + 3) {
|
||||
for (c, &v) in p.iter().enumerate() {
|
||||
if let Some(slot) = hist.get_mut(c).and_then(|hh| hh.get_mut(v as usize)) {
|
||||
*slot += 1;
|
||||
}
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let want = (n as f32 * 0.9) as u32;
|
||||
cells.push(std::array::from_fn(|c| {
|
||||
let mut seen = 0;
|
||||
let v = hist.get(c).and_then(|hh| {
|
||||
hh.iter().position(|&count| {
|
||||
seen += count;
|
||||
seen > want
|
||||
})
|
||||
});
|
||||
v.unwrap_or(255) as f32 / 255.0
|
||||
}));
|
||||
}
|
||||
}
|
||||
// A block darker than its neighbourhood's paper is mostly ink, a stamp or a picture: take
|
||||
// the brighter of itself and the 75th percentile of the 5 × 5 blocks around it (so a
|
||||
// coloured area up to about half that neighbourhood isn't taken for paper), then smooth.
|
||||
let get = |cells: &Vec<[f32; 3]>, x: isize, y: isize| -> [f32; 3] {
|
||||
let x = x.clamp(0, cols as isize - 1) as usize;
|
||||
let y = y.clamp(0, rows as isize - 1) as usize;
|
||||
cells.get(y * cols + x).copied().unwrap_or([1.0; 3])
|
||||
};
|
||||
let mut lifted = Vec::with_capacity(cells.len());
|
||||
for y in 0..rows as isize {
|
||||
for x in 0..cols as isize {
|
||||
let own = get(&cells, x, y);
|
||||
lifted.push(std::array::from_fn(|c| {
|
||||
let mut around: Vec<f32> =
|
||||
(-2..=2).flat_map(|dy| (-2..=2).map(move |dx| (dx, dy))).map(|(dx, dy)| get(&cells, x + dx, y + dy)[c]).collect();
|
||||
around.sort_by(f32::total_cmp);
|
||||
let paper = around.get(18).copied().unwrap_or(1.0);
|
||||
own[c].max(paper)
|
||||
}));
|
||||
}
|
||||
}
|
||||
let mut cells = lifted;
|
||||
for _ in 0..2 {
|
||||
let mut next = Vec::with_capacity(cells.len());
|
||||
for y in 0..rows as isize {
|
||||
for x in 0..cols as isize {
|
||||
let mut sum = [0.0f32; 3];
|
||||
for dy in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let v = get(&cells, x + dx, y + dy);
|
||||
for c in 0..3 {
|
||||
sum[c] += v[c] / 9.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
next.push(sum);
|
||||
}
|
||||
}
|
||||
cells = next;
|
||||
}
|
||||
Background { cols, rows, block: block as f32, cells }
|
||||
}
|
||||
|
||||
/// [`despeckle`] for one grey byte per pixel.
|
||||
fn despeckle_grey(grey: &mut [u8], w: usize, h: usize) {
|
||||
let mut rgb: Vec<u8> = grey.iter().flat_map(|&v| [v, v, v]).collect();
|
||||
despeckle(&mut rgb, w, h);
|
||||
for (g, p) in grey.iter_mut().zip(rgb.as_chunks::<3>().0.iter()) {
|
||||
*g = p[0];
|
||||
}
|
||||
}
|
||||
|
||||
/// Lighten dark pixels with at most one dark neighbour (specks), in place.
|
||||
fn despeckle(rgb: &mut [u8], w: usize, h: usize) {
|
||||
let lum = |p: &[u8; 3]| (p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000;
|
||||
let dark: Vec<bool> = rgb.as_chunks::<3>().0.iter().map(|p| lum(p) < 128).collect();
|
||||
let is_dark = |x: isize, y: isize| {
|
||||
x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && dark.get(y as usize * w + x as usize).copied().unwrap_or(false)
|
||||
};
|
||||
for y in 0..h as isize {
|
||||
for x in 0..w as isize {
|
||||
if !is_dark(x, y) {
|
||||
continue;
|
||||
}
|
||||
let neighbours =
|
||||
(-1..=1).flat_map(|dy| (-1..=1).map(move |dx| (dx, dy))).filter(|&(dx, dy)| (dx, dy) != (0, 0) && is_dark(x + dx, y + dy)).count();
|
||||
if neighbours <= 1
|
||||
&& let Some(p) = rgb.get_mut((y as usize * w + x as usize) * 3..(y as usize * w + x as usize) * 3 + 3)
|
||||
{
|
||||
p.fill(255);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A synthetic photo of a page: paper lit by `light(x, y)` (per channel, 0–1), with dark
|
||||
/// text bars, a blue stamp and a few specks.
|
||||
fn photo(w: usize, h: usize, light: impl Fn(usize, usize) -> [f32; 3]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(w * h * 3);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let text = (y / 12) % 3 == 1 && x > w / 10 && x < 9 * w / 10 && (x / 9) % 6 != 0;
|
||||
let stamp = (x as isize - (3 * w / 4) as isize).pow(2) + (y as isize - (3 * h / 4) as isize).pow(2) < (h as isize / 12).pow(2);
|
||||
let speck = (x * 7919 + y * 104729) % 997 == 0;
|
||||
let ink: [f32; 3] = if text || speck {
|
||||
[0.12, 0.12, 0.12]
|
||||
} else if stamp {
|
||||
[0.15, 0.25, 0.75]
|
||||
} else {
|
||||
[0.97, 0.97, 0.95]
|
||||
};
|
||||
let l = light(x, y);
|
||||
for c in 0..3 {
|
||||
out.push((ink[c] * l[c] * 255.0).round().clamp(0.0, 255.0) as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn px(img: &[u8], w: usize, x: usize, y: usize) -> [u8; 3] {
|
||||
let i = (y * w + x) * 3;
|
||||
[img[i], img[i + 1], img[i + 2]]
|
||||
}
|
||||
|
||||
/// A paper point (between text rows, away from the stamp).
|
||||
fn paper_points(w: usize, h: usize) -> Vec<(usize, usize)> {
|
||||
[(0.05, 0.03), (0.5, 0.03), (0.95, 0.03), (0.05, 0.53), (0.5, 0.53), (0.97, 0.98), (0.03, 0.98)]
|
||||
.iter()
|
||||
.map(|&(fx, fy)| ((fx * w as f64) as usize, (fy * h as f64) as usize))
|
||||
.map(|(x, y)| (x.min(w - 1), (y - y % 36 + 4).min(h - 1)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shadow_and_a_yellow_cast_become_white_paper() {
|
||||
let (w, h) = (480, 640);
|
||||
// Warm room light, falling off to a third towards the bottom right (a hand's shadow).
|
||||
let light = |x: usize, y: usize| {
|
||||
let f = 1.0 - 0.66 * ((x + y) as f32 / (w + h) as f32);
|
||||
[0.95 * f, 0.85 * f, 0.6 * f]
|
||||
};
|
||||
let img = photo(w, h, light);
|
||||
let out = clean(&img, w as u32, h as u32, 60).unwrap();
|
||||
for (x, y) in paper_points(w, h) {
|
||||
let before = px(&img, w, x, y);
|
||||
let after = px(&out, w, x, y);
|
||||
assert!(after.iter().all(|&v| v >= 235), "({x}, {y}) {before:?} → {after:?}");
|
||||
}
|
||||
// The text stays dark everywhere, also in the shadow.
|
||||
for (x, y) in [(w / 2, 13), (w / 2, 3 * 12 + 13), (w * 8 / 10, h - 40 - (h - 40) % 36 + 13)] {
|
||||
let after = px(&out, w, x, y);
|
||||
if (x / 9) % 6 != 0 {
|
||||
assert!(after.iter().all(|&v| v <= 90), "text at ({x}, {y}): {after:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blue_stamp_stays_blue() {
|
||||
let (w, h) = (480, 640);
|
||||
let img = photo(w, h, |_, _| [0.9, 0.85, 0.7]);
|
||||
let out = clean(&img, w as u32, h as u32, 100).unwrap();
|
||||
// The stamp's centre, on a row without text.
|
||||
let [r, g, b] = px(&out, w, 3 * w / 4, 3 * h / 4 + 12);
|
||||
assert!(b as i32 - r as i32 > 60 && b as i32 - g as i32 > 40, "{:?}", [r, g, b]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_zero_changes_nothing_and_strength_grows_with_the_level() {
|
||||
let (w, h) = (240, 320);
|
||||
let img = photo(w, h, |x, _| [0.6 + 0.3 * x as f32 / w as f32; 3]);
|
||||
assert_eq!(clean(&img, w as u32, h as u32, 0).unwrap(), img);
|
||||
let mean = |v: &[u8]| v.iter().map(|&c| c as f64).sum::<f64>() / v.len() as f64;
|
||||
let (a, b, c) = (mean(&img), mean(&clean(&img, w as u32, h as u32, 30).unwrap()), mean(&clean(&img, w as u32, h as u32, 100).unwrap()));
|
||||
assert!(a < b && b < c, "{a} {b} {c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn specks_go_at_high_levels_but_text_stays() {
|
||||
let (w, h) = (240, 320);
|
||||
let img = photo(w, h, |_, _| [1.0; 3]);
|
||||
let out = clean(&img, w as u32, h as u32, 100).unwrap();
|
||||
let specks: Vec<(usize, usize)> = (0..h)
|
||||
.flat_map(|y| (0..w).map(move |x| (x, y)))
|
||||
// Specks away from the text rows (one touching a letter isn't isolated).
|
||||
.filter(|&(x, y)| (x * 7919 + y * 104729) % 997 == 0 && (y / 12) % 3 == 0 && (2..10).contains(&(y % 12)))
|
||||
.collect();
|
||||
assert!(!specks.is_empty());
|
||||
for (x, y) in specks {
|
||||
assert!(px(&out, w, x, y).iter().all(|&v| v > 200), "speck at ({x}, {y})");
|
||||
}
|
||||
assert!(px(&out, w, w / 2 + 1, 13).iter().all(|&v| v < 60), "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_looks() {
|
||||
let (w, h) = (240, 320);
|
||||
let img = photo(w, h, |x, _| [0.95 - 0.4 * x as f32 / w as f32, 0.85 - 0.4 * x as f32 / w as f32, 0.65]);
|
||||
assert_eq!(apply(&img, w as u32, h as u32, Look::Original, 80), Some(Pixels::Rgb(img.clone())));
|
||||
let Some(Pixels::Rgb(colour)) = apply(&img, w as u32, h as u32, Look::Colour, 80) else { panic!("colour") };
|
||||
assert_eq!(Some(colour), clean(&img, w as u32, h as u32, 80));
|
||||
let Some(Pixels::Grey(grey)) = apply(&img, w as u32, h as u32, Look::Greyscale, 80) else { panic!("grey") };
|
||||
assert_eq!(grey.len(), w * h);
|
||||
let Some(Pixels::Grey(bw)) = apply(&img, w as u32, h as u32, Look::BlackWhite, 50) else { panic!("bw") };
|
||||
assert!(bw.iter().all(|&v| v == 0 || v == 255), "only black and white");
|
||||
// Paper (between rows, across the shadow) is white; text is black.
|
||||
for x in [5, w / 2, w - 6] {
|
||||
assert_eq!(bw[3 * w + x], 255, "paper at x {x}");
|
||||
}
|
||||
assert_eq!(bw[13 * w + w / 2 + 1], 0, "text");
|
||||
// The level moves the threshold: more black at the top than at the bottom.
|
||||
let black = |level| match apply(&img, w as u32, h as u32, Look::BlackWhite, level) {
|
||||
Some(Pixels::Grey(g)) => g.iter().filter(|&&v| v == 0).count(),
|
||||
_ => 0,
|
||||
};
|
||||
assert!(black(0) <= black(50) && black(50) <= black(100), "{} {} {}", black(0), black(50), black(100));
|
||||
for look in Look::ALL {
|
||||
assert_eq!(Look::from_name(look.name()), Some(look));
|
||||
}
|
||||
assert_eq!(apply(&[0; 5], 2, 1, Look::BlackWhite, 50), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_sizes_are_none_not_a_panic() {
|
||||
assert_eq!(clean(&[], 0, 0, 50), None);
|
||||
assert_eq!(clean(&[0; 12], 2, 3, 50), None);
|
||||
assert_eq!(clean(&[9; 3], 1, 1, 255).map(|v| v.len()), Some(3));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,501 @@
|
|||
//! Find a photographed document's corners (Enhance ▸ Camera image): the edges of the sheet are
|
||||
//! the four strongest straight lines that enclose a large convex area, each backed by an edge
|
||||
//! along most of its length. Pure Rust and deterministic; `None` when nothing convinces, so the
|
||||
//! caller falls back to corners the user places.
|
||||
//!
|
||||
//! Steps: shrink the picture to [`WORK`] pixels on its long side, blur it, take Sobel gradients
|
||||
//! and keep thin, strong edges; vote for lines (a Hough transform that only votes along each
|
||||
//! edge's own gradient direction, give or take a few degrees); pair two near-horizontal with two near-vertical lines; keep
|
||||
//! the quadrilaterals whose four sides lie on edges, and choose the largest.
|
||||
|
||||
use pdfcraft_geom::perspective::{Point, order_quad};
|
||||
|
||||
/// The working size: the long side of the shrunken picture, in pixels.
|
||||
const WORK: usize = 400;
|
||||
/// The most lines tried (strongest first).
|
||||
const MAX_LINES: usize = 24;
|
||||
/// A side counts when at least this share of it lies on a matching edge.
|
||||
const MIN_SUPPORT: f64 = 0.55;
|
||||
/// A document fills at least this share of the picture.
|
||||
const MIN_AREA: f64 = 0.12;
|
||||
/// How far (share of the picture's size) a corner may lie outside the picture.
|
||||
const MARGIN: f64 = 0.02;
|
||||
|
||||
/// A grey picture, row-major, read with bounds checks.
|
||||
struct Grey {
|
||||
w: usize,
|
||||
h: usize,
|
||||
v: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Grey {
|
||||
fn at(&self, x: isize, y: isize) -> f32 {
|
||||
// Outside the picture repeats its border.
|
||||
let x = x.clamp(0, self.w as isize - 1) as usize;
|
||||
let y = y.clamp(0, self.h as isize - 1) as usize;
|
||||
self.v.get(y * self.w + x).copied().unwrap_or(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// An edge pixel: its gradient's strength and direction (radians, the normal of the edge).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Edge {
|
||||
mag: f32,
|
||||
dir: f32,
|
||||
}
|
||||
|
||||
/// The document's corners in `rgba` (premultiplied RGBA8, `width` × `height`), in pixels:
|
||||
/// top-left, top-right, bottom-right, bottom-left. `None` when no quadrilateral convinces.
|
||||
pub fn detect_document(rgba: &[u8], width: u32, height: u32) -> Option<[Point; 4]> {
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
if w < 16 || h < 16 || w.checked_mul(h)?.checked_mul(4)? != rgba.len() {
|
||||
return None;
|
||||
}
|
||||
let grey = shrink(rgba, w, h);
|
||||
let k = w.max(h) as f64 / grey.w.max(grey.h) as f64;
|
||||
let edges = edges(&blur(&blur(&grey)));
|
||||
let lines = hough(&edges, grey.w, grey.h);
|
||||
let quad = best_quad(&lines, &edges, grey.w, grey.h)?;
|
||||
let quad = refine(quad, &edges, grey.w, grey.h).unwrap_or(quad);
|
||||
// Back to the picture's pixels (pixel centres line up at k × (x + 0.5)).
|
||||
Some(quad.map(|[x, y]| [(x + 0.5) * k, (y + 0.5) * k]))
|
||||
}
|
||||
|
||||
/// Box-filter `rgba` (premultiplied, over white) down to at most [`WORK`] pixels on its long
|
||||
/// side, as luminance 0–255.
|
||||
fn shrink(rgba: &[u8], w: usize, h: usize) -> Grey {
|
||||
let step = w.max(h).div_ceil(WORK).max(1);
|
||||
let (sw, sh) = (w.div_ceil(step), h.div_ceil(step));
|
||||
let mut v = Vec::with_capacity(sw * sh);
|
||||
for sy in 0..sh {
|
||||
for sx in 0..sw {
|
||||
let (mut sum, mut n) = (0.0f32, 0.0f32);
|
||||
for y in sy * step..((sy + 1) * step).min(h) {
|
||||
for x in sx * step..((sx + 1) * step).min(w) {
|
||||
if let Some(p) = rgba.get((y * w + x) * 4..(y * w + x) * 4 + 4) {
|
||||
let white = 255.0 - p[3] as f32;
|
||||
let (r, g, b) = (p[0] as f32 + white, p[1] as f32 + white, p[2] as f32 + white);
|
||||
sum += 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
v.push(if n > 0.0 { sum / n } else { 255.0 });
|
||||
}
|
||||
}
|
||||
Grey { w: sw, h: sh, v }
|
||||
}
|
||||
|
||||
/// A 3×3 binomial blur.
|
||||
fn blur(g: &Grey) -> Grey {
|
||||
let mut v = Vec::with_capacity(g.v.len());
|
||||
for y in 0..g.h as isize {
|
||||
for x in 0..g.w as isize {
|
||||
let mut s = 0.0;
|
||||
for (dy, wy) in [(-1, 1.0), (0, 2.0), (1, 1.0)] {
|
||||
for (dx, wx) in [(-1, 1.0), (0, 2.0), (1, 1.0)] {
|
||||
s += wy * wx * g.at(x + dx, y + dy);
|
||||
}
|
||||
}
|
||||
v.push(s / 16.0);
|
||||
}
|
||||
}
|
||||
Grey { w: g.w, h: g.h, v }
|
||||
}
|
||||
|
||||
/// Thin, strong edges: Sobel gradients, kept where they are a local maximum across the edge
|
||||
/// and above a threshold relative to the picture's strongest edges.
|
||||
fn edges(g: &Grey) -> Vec<Option<Edge>> {
|
||||
let mut grad = Vec::with_capacity(g.v.len());
|
||||
for y in 0..g.h as isize {
|
||||
for x in 0..g.w as isize {
|
||||
let p = |dx, dy| g.at(x + dx, y + dy);
|
||||
let gx = (p(1, -1) + 2.0 * p(1, 0) + p(1, 1)) - (p(-1, -1) + 2.0 * p(-1, 0) + p(-1, 1));
|
||||
let gy = (p(-1, 1) + 2.0 * p(0, 1) + p(1, 1)) - (p(-1, -1) + 2.0 * p(0, -1) + p(1, -1));
|
||||
grad.push(Edge { mag: gx.hypot(gy), dir: gy.atan2(gx) });
|
||||
}
|
||||
}
|
||||
// The threshold: a share of the 98th percentile, never below a faint-edge floor.
|
||||
let mut mags: Vec<f32> = grad.iter().map(|e| e.mag).filter(|m| m.is_finite()).collect();
|
||||
mags.sort_by(f32::total_cmp);
|
||||
let p98 = mags.get(mags.len() * 98 / 100).copied().unwrap_or(0.0);
|
||||
let threshold = (0.25 * p98).max(24.0);
|
||||
let mag = |x: isize, y: isize| -> f32 {
|
||||
if x < 0 || y < 0 || x >= g.w as isize || y >= g.h as isize {
|
||||
return 0.0;
|
||||
}
|
||||
grad.get(y as usize * g.w + x as usize).map_or(0.0, |e| e.mag)
|
||||
};
|
||||
let mut out = Vec::with_capacity(grad.len());
|
||||
for (i, e) in grad.iter().enumerate() {
|
||||
let (x, y) = ((i % g.w) as isize, (i / g.w) as isize);
|
||||
// Edges touching the picture's border are where blurring repeats it: no information.
|
||||
let inside = x > 1 && y > 1 && x < g.w as isize - 2 && y < g.h as isize - 2;
|
||||
let keep = inside && e.mag >= threshold && {
|
||||
let (dx, dy) = (e.dir.cos().round() as isize, e.dir.sin().round() as isize);
|
||||
e.mag >= mag(x + dx, y + dy) && e.mag >= mag(x - dx, y - dy)
|
||||
};
|
||||
out.push(keep.then_some(*e));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A line `x cos θ + y sin θ = ρ` and its votes.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Line {
|
||||
theta: f64,
|
||||
rho: f64,
|
||||
}
|
||||
|
||||
/// The strongest lines through the edges: each edge votes for the lines within 6° of its own
|
||||
/// normal, weighted by its strength; peaks are taken strongest first, clearing their
|
||||
/// neighbourhood.
|
||||
fn hough(edges: &[Option<Edge>], w: usize, h: usize) -> Vec<Line> {
|
||||
const STEPS: usize = 180;
|
||||
let diag = ((w * w + h * h) as f64).sqrt().ceil() as usize;
|
||||
let rows = 2 * diag + 1;
|
||||
let mut acc = vec![0.0f32; STEPS * rows];
|
||||
let trig: Vec<(f64, f64)> = (0..STEPS).map(|t| (t as f64).to_radians()).map(|a| (a.cos(), a.sin())).collect();
|
||||
for (i, e) in edges.iter().enumerate() {
|
||||
let Some(e) = e else { continue };
|
||||
let (x, y) = ((i % w) as f64, (i / w) as f64);
|
||||
let normal = (e.dir as f64).to_degrees().rem_euclid(180.0).round() as isize;
|
||||
// ±6°: a jagged (aliased) edge's local gradient leans towards the axes.
|
||||
for t in normal - 6..=normal + 6 {
|
||||
let t = t.rem_euclid(STEPS as isize) as usize;
|
||||
let Some(&(c, s)) = trig.get(t) else { continue };
|
||||
let r = (x * c + y * s).round() as isize + diag as isize;
|
||||
if let Some(cell) = usize::try_from(r).ok().and_then(|r| acc.get_mut(t * rows + r)) {
|
||||
*cell += e.mag;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut lines = Vec::new();
|
||||
let first = acc.iter().copied().fold(0.0f32, f32::max);
|
||||
while lines.len() < MAX_LINES {
|
||||
let Some((best, &v)) = acc.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)) else { break };
|
||||
if v <= 0.0 || v < 0.08 * first {
|
||||
break;
|
||||
}
|
||||
let (t, r) = (best / rows, best % rows);
|
||||
lines.push(Line { theta: (t as f64).to_radians(), rho: r as f64 - diag as f64 });
|
||||
// Clear ±6° and ±6 px around the peak (θ wraps, flipping ρ's sign).
|
||||
for dt in -6isize..=6 {
|
||||
let tt = t as isize + dt;
|
||||
let (tt, flip) = if tt < 0 {
|
||||
(tt + STEPS as isize, true)
|
||||
} else if tt >= STEPS as isize {
|
||||
(tt - STEPS as isize, true)
|
||||
} else {
|
||||
(tt, false)
|
||||
};
|
||||
let rr = if flip { 2 * diag as isize - r as isize } else { r as isize };
|
||||
for dr in -6isize..=6 {
|
||||
if let Some(cell) = usize::try_from(rr + dr).ok().filter(|&q| q < rows).and_then(|q| acc.get_mut(tt as usize * rows + q)) {
|
||||
*cell = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn intersect(a: Line, b: Line) -> Option<Point> {
|
||||
let (c1, s1, c2, s2) = (a.theta.cos(), a.theta.sin(), b.theta.cos(), b.theta.sin());
|
||||
let det = c1 * s2 - s1 * c2;
|
||||
if det.abs() < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let p = [(a.rho * s2 - b.rho * s1) / det, (c1 * b.rho - c2 * a.rho) / det];
|
||||
p.iter().all(|v| v.is_finite()).then_some(p)
|
||||
}
|
||||
|
||||
/// The share of the side from `a` to `b` that lies on an edge facing across it.
|
||||
fn support(a: Point, b: Point, edges: &[Option<Edge>], w: usize, h: usize) -> f64 {
|
||||
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
|
||||
if len < 1.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let (ux, uy) = ((b[0] - a[0]) / len, (b[1] - a[1]) / len);
|
||||
// The side's normal, (-uy, ux), as an angle.
|
||||
let normal = ux.atan2(-uy) as f32;
|
||||
let n = (len / 2.0).clamp(8.0, 200.0) as usize;
|
||||
let mut hits = 0;
|
||||
for i in 0..n {
|
||||
let t = 0.05 + 0.9 * (i as f64 + 0.5) / n as f64;
|
||||
let (px, py) = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t);
|
||||
let hit = (-2i32..=2).any(|o| {
|
||||
let (x, y) = ((px - uy * o as f64).round(), (py + ux * o as f64).round());
|
||||
if x < 0.0 || y < 0.0 || x >= w as f64 || y >= h as f64 {
|
||||
return false;
|
||||
}
|
||||
edges.get(y as usize * w + x as usize).copied().flatten().is_some_and(|e| {
|
||||
// Either way across: dark-to-light or light-to-dark.
|
||||
let d = (e.dir - normal).rem_euclid(std::f32::consts::PI);
|
||||
d < 0.35 || d > std::f32::consts::PI - 0.35
|
||||
})
|
||||
});
|
||||
hits += hit as usize;
|
||||
}
|
||||
hits as f64 / n as f64
|
||||
}
|
||||
|
||||
/// Sharpen the quadrilateral found on the coarse line grid: fit each side's line through the
|
||||
/// edge points along it (total least squares), then intersect neighbouring sides again.
|
||||
/// `None` when a side has too few points or the result is no longer convex.
|
||||
fn refine(q: [Point; 4], edges: &[Option<Edge>], w: usize, h: usize) -> Option<[Point; 4]> {
|
||||
// Each side as (a point on it, its unit direction).
|
||||
let mut sides = Vec::with_capacity(4);
|
||||
for k in 0..4 {
|
||||
let (a, b) = (q[k], q[(k + 1) % 4]);
|
||||
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
|
||||
if len < 4.0 {
|
||||
return None;
|
||||
}
|
||||
let (ux, uy) = ((b[0] - a[0]) / len, (b[1] - a[1]) / len);
|
||||
let normal = ux.atan2(-uy) as f32;
|
||||
let mut pts = Vec::new();
|
||||
// Pixels near the side, between 5% and 95% of its length (away from the corners).
|
||||
let (x0, x1) = (a[0].min(b[0]).floor() as isize - 3, a[0].max(b[0]).ceil() as isize + 3);
|
||||
let (y0, y1) = (a[1].min(b[1]).floor() as isize - 3, a[1].max(b[1]).ceil() as isize + 3);
|
||||
for y in y0.max(0)..=y1.min(h as isize - 1) {
|
||||
for x in x0.max(0)..=x1.min(w as isize - 1) {
|
||||
let Some(e) = edges.get(y as usize * w + x as usize).copied().flatten() else { continue };
|
||||
let d = (e.dir - normal).rem_euclid(std::f32::consts::PI);
|
||||
if d > 0.35 && d < std::f32::consts::PI - 0.35 {
|
||||
continue;
|
||||
}
|
||||
let (dx, dy) = (x as f64 - a[0], y as f64 - a[1]);
|
||||
let t = (dx * ux + dy * uy) / len;
|
||||
let off = -dx * uy + dy * ux;
|
||||
if (0.05..=0.95).contains(&t) && off.abs() <= 3.0 {
|
||||
pts.push([x as f64, y as f64]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if pts.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
let n = pts.len() as f64;
|
||||
let (cx, cy) = (pts.iter().map(|p| p[0]).sum::<f64>() / n, pts.iter().map(|p| p[1]).sum::<f64>() / n);
|
||||
let (mut sxx, mut sxy, mut syy) = (0.0, 0.0, 0.0);
|
||||
for p in &pts {
|
||||
let (dx, dy) = (p[0] - cx, p[1] - cy);
|
||||
sxx += dx * dx;
|
||||
sxy += dx * dy;
|
||||
syy += dy * dy;
|
||||
}
|
||||
// The principal direction of the points.
|
||||
let angle = 0.5 * (2.0 * sxy).atan2(sxx - syy);
|
||||
sides.push(([cx, cy], [angle.cos(), angle.sin()]));
|
||||
}
|
||||
let meet = |(p, u): (Point, Point), (r, v): (Point, Point)| -> Option<Point> {
|
||||
let det = u[0] * v[1] - u[1] * v[0];
|
||||
if det.abs() < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let t = ((r[0] - p[0]) * v[1] - (r[1] - p[1]) * v[0]) / det;
|
||||
let out = [p[0] + t * u[0], p[1] + t * u[1]];
|
||||
out.iter().all(|c| c.is_finite()).then_some(out)
|
||||
};
|
||||
let side = |k: usize| sides.get(k).copied();
|
||||
// Corner k is where side k-1 (ending there) meets side k (starting there).
|
||||
let corner = |k: usize| meet(side((k + 3) % 4)?, side(k)?);
|
||||
let refined = [corner(0)?, corner(1)?, corner(2)?, corner(3)?];
|
||||
// A fit that moved a corner far is not a refinement.
|
||||
let moved = refined.iter().zip(q.iter()).any(|(r, o)| (r[0] - o[0]).hypot(r[1] - o[1]) > 6.0);
|
||||
if moved {
|
||||
return None;
|
||||
}
|
||||
order_quad(refined)
|
||||
}
|
||||
|
||||
/// The convex quadrilateral of two near-horizontal and two near-vertical lines whose four sides
|
||||
/// all lie on edges, scored by its area times the square of its sides' mean support.
|
||||
fn best_quad(lines: &[Line], edges: &[Option<Edge>], w: usize, h: usize) -> Option<[Point; 4]> {
|
||||
// A normal within 45° of vertical makes a near-horizontal line.
|
||||
let flat = |l: &&Line| (l.theta.to_degrees() - 90.0).abs() <= 45.0;
|
||||
let horizontal: Vec<&Line> = lines.iter().filter(flat).collect();
|
||||
let vertical: Vec<&Line> = lines.iter().filter(|l| !flat(l)).collect();
|
||||
let (fw, fh) = (w as f64, h as f64);
|
||||
let (mx, my) = (MARGIN * fw, MARGIN * fh);
|
||||
let mut best: Option<(f64, [Point; 4])> = None;
|
||||
for (i, &h1) in horizontal.iter().enumerate() {
|
||||
for &h2 in horizontal.iter().skip(i + 1) {
|
||||
for (j, &v1) in vertical.iter().enumerate() {
|
||||
for &v2 in vertical.iter().skip(j + 1) {
|
||||
let corners = [intersect(*h1, *v1), intersect(*h1, *v2), intersect(*h2, *v2), intersect(*h2, *v1)];
|
||||
let [Some(a), Some(b), Some(c), Some(d)] = corners else { continue };
|
||||
let Some(q) = order_quad([a, b, c, d]) else { continue };
|
||||
if q.iter().any(|p| p[0] < -mx || p[1] < -my || p[0] > fw + mx || p[1] > fh + my) {
|
||||
continue;
|
||||
}
|
||||
let area = (0..4)
|
||||
.map(|k| {
|
||||
let (p, n) = (q[k], q[(k + 1) % 4]);
|
||||
p[0] * n[1] - n[0] * p[1]
|
||||
})
|
||||
.sum::<f64>()
|
||||
.abs()
|
||||
/ 2.0;
|
||||
if area < MIN_AREA * fw * fh {
|
||||
continue;
|
||||
}
|
||||
let sides: Vec<f64> = (0..4).map(|k| support(q[k], q[(k + 1) % 4], edges, w, h)).collect();
|
||||
if sides.iter().any(|&s| s < MIN_SUPPORT) {
|
||||
continue;
|
||||
}
|
||||
// Large, and with every side on its edge: a slightly larger quad with a side
|
||||
// off the sheet loses to the sheet itself.
|
||||
let mean = sides.iter().sum::<f64>() / 4.0;
|
||||
let score = area * mean * mean;
|
||||
if best.is_none_or(|(b, _)| score > b) {
|
||||
best = Some((score, q));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, q)| q)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A deterministic pseudo-random generator for noise.
|
||||
struct Lcg(u64);
|
||||
impl Lcg {
|
||||
fn next(&mut self) -> f32 {
|
||||
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
((self.0 >> 33) as f32) / (u32::MAX >> 1) as f32
|
||||
}
|
||||
}
|
||||
|
||||
fn inside(q: &[Point; 4], x: f64, y: f64) -> bool {
|
||||
(0..4).all(|i| {
|
||||
let (a, b) = (q[i], q[(i + 1) % 4]);
|
||||
(b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]) >= 0.0
|
||||
})
|
||||
}
|
||||
|
||||
/// A synthetic photo: `sheet` (corners in reading order) in `paper` over `table(x, y)`, with
|
||||
/// 2×2 supersampled edges, optional text lines on the sheet, and noise of ±`noise`.
|
||||
fn photo(w: u32, h: u32, sheet: [Point; 4], paper: f32, table: impl Fn(f64, f64) -> f32, text: bool, noise: f32) -> Vec<u8> {
|
||||
let mut rng = Lcg(7);
|
||||
let at = |u: f64, v: f64| -> Point {
|
||||
let top = [sheet[0][0] + (sheet[1][0] - sheet[0][0]) * u, sheet[0][1] + (sheet[1][1] - sheet[0][1]) * u];
|
||||
let bot = [sheet[3][0] + (sheet[2][0] - sheet[3][0]) * u, sheet[3][1] + (sheet[2][1] - sheet[3][1]) * u];
|
||||
[top[0] + (bot[0] - top[0]) * v, top[1] + (bot[1] - top[1]) * v]
|
||||
};
|
||||
let bars: Vec<[Point; 4]> = if text {
|
||||
(0..12)
|
||||
.map(|k| {
|
||||
let v = 0.12 + k as f64 * 0.065;
|
||||
let u1 = if k % 4 == 3 { 0.5 } else { 0.88 };
|
||||
[at(0.1, v), at(u1, v), at(u1, v + 0.022), at(0.1, v + 0.022)]
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut out = Vec::with_capacity((w * h * 4) as usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut s = 0.0;
|
||||
for (ox, oy) in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)] {
|
||||
let (fx, fy) = (x as f64 + ox, y as f64 + oy);
|
||||
s += if bars.iter().any(|b| inside(b, fx, fy)) {
|
||||
40.0
|
||||
} else if inside(&sheet, fx, fy) {
|
||||
paper
|
||||
} else {
|
||||
table(fx, fy)
|
||||
};
|
||||
}
|
||||
let v = (s / 4.0 + (rng.next() - 0.5) * 2.0 * noise).clamp(0.0, 255.0) as u8;
|
||||
out.extend_from_slice(&[v, v, v, 255]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn assert_near(found: Option<[Point; 4]>, want: [Point; 4], tol: f64) {
|
||||
let found = found.expect("the sheet should be found");
|
||||
for (f, w) in found.iter().zip(want.iter()) {
|
||||
assert!((f[0] - w[0]).hypot(f[1] - w[1]) <= tol, "found {found:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
const SHEET: [Point; 4] = [[150.0, 170.0], [760.0, 110.0], [820.0, 1050.0], [100.0, 1090.0]];
|
||||
|
||||
#[test]
|
||||
fn finds_a_tilted_sheet_on_a_plain_table() {
|
||||
let img = photo(900, 1200, SHEET, 245.0, |_, _| 95.0, false, 0.0);
|
||||
assert_near(detect_document(&img, 900, 1200), SHEET, 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_on_the_sheet_does_not_shrink_the_quad() {
|
||||
let img = photo(900, 1200, SHEET, 245.0, |_, _| 95.0, true, 6.0);
|
||||
assert_near(detect_document(&img, 900, 1200), SHEET, 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wood_grain_and_a_shadow_do_not_fool_it() {
|
||||
// Horizontal grain stripes, darker towards the bottom right.
|
||||
let table = |x: f64, y: f64| 110.0 + 18.0 * ((y / 23.0).sin() as f32) - (0.03 * (x + y)) as f32;
|
||||
let img = photo(900, 1200, SHEET, 235.0, table, true, 8.0);
|
||||
assert_near(detect_document(&img, 900, 1200), SHEET, 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_card_photographed_from_an_angle() {
|
||||
// Strong perspective: the far edge much shorter than the near one.
|
||||
let card = [[260.0, 300.0], [660.0, 290.0], [800.0, 700.0], [120.0, 720.0]];
|
||||
let img = photo(900, 1000, card, 225.0, |_, _| 60.0, false, 5.0);
|
||||
assert_near(detect_document(&img, 900, 1000), card, 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheet_turned_by_thirty_degrees() {
|
||||
// A 500 × 650 sheet turned 30° about (450, 500), corners in reading order.
|
||||
let (c, s) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
|
||||
let turn = |x: f64, y: f64| [450.0 + x * c - y * s, 500.0 + x * s + y * c];
|
||||
let sheet = [turn(-250.0, -325.0), turn(250.0, -325.0), turn(250.0, 325.0), turn(-250.0, 325.0)];
|
||||
let img = photo(900, 1000, sheet, 240.0, |_, _| 80.0, true, 5.0);
|
||||
// Turned 30° clockwise on screen, the top-left corner is the one turned from (-250, -325).
|
||||
assert_near(detect_document(&img, 900, 1000), sheet, 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheet_running_off_the_picture_is_not_invented() {
|
||||
// The bottom edge is outside the picture: either nothing, or corners on the visible
|
||||
// edges — never a quadrilateral that cuts the sheet in half.
|
||||
let sheet = [[150.0, 170.0], [760.0, 110.0], [830.0, 1400.0], [90.0, 1450.0]];
|
||||
let img = photo(900, 1200, sheet, 245.0, |_, _| 95.0, true, 4.0);
|
||||
if let Some(q) = detect_document(&img, 900, 1200) {
|
||||
let top = (q[0][1] + q[1][1]) / 2.0;
|
||||
let bottom = (q[2][1] + q[3][1]) / 2.0;
|
||||
assert!(top < 200.0 && bottom > 1150.0, "{q:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gives_up_without_a_document() {
|
||||
let table = |x: f64, y: f64| 110.0 + 18.0 * ((y / 23.0).sin() as f32) - (0.03 * (x + y)) as f32;
|
||||
let img = photo(900, 1200, [[0.0; 2]; 4], 0.0, table, false, 8.0);
|
||||
assert_eq!(detect_document(&img, 900, 1200), None);
|
||||
// White paper on a white desk: no edge to find.
|
||||
let img = photo(900, 1200, SHEET, 246.0, |_, _| 244.0, false, 2.0);
|
||||
assert_eq!(detect_document(&img, 900, 1200), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_input_is_none_not_a_panic() {
|
||||
assert_eq!(detect_document(&[], 0, 0), None);
|
||||
assert_eq!(detect_document(&[0; 40], 10, 1), None);
|
||||
assert_eq!(detect_document(&[0; 64 * 64 * 4], 64, 63), None);
|
||||
assert_eq!(detect_document(&vec![255; 64 * 64 * 4], 64, 64), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -250,6 +250,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
|||
item("In this file", "scan-text", "ocr.recognize", Ready),
|
||||
item("In multiple files", "files", "ocr.recognize_batch", Ready),
|
||||
item("Enhance scanned file", "sparkles", "ocr.enhance", Planned("M10")),
|
||||
item("Enhance camera image", "camera", "ocr.enhance_camera", Ready),
|
||||
item("Correct recognized text", "text-select", "ocr.correct", Planned("M10")),
|
||||
],
|
||||
}],
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ pub enum Needs {
|
|||
Modification,
|
||||
/// The open document's security allows adding and changing comments.
|
||||
Annotate,
|
||||
/// The open document's security allows copying text and images out of it.
|
||||
Copying,
|
||||
/// The document has form fields and its security allows filling them in.
|
||||
FillForms,
|
||||
/// The document has comments and allows changes.
|
||||
|
|
@ -34,10 +36,14 @@ pub enum Needs {
|
|||
HasRedactions,
|
||||
/// The document has page marks of this kind and allows changes.
|
||||
Marks(crate::MarkKind),
|
||||
/// The document's security may be changed (owner, or nothing restricted).
|
||||
/// The document's security may be changed (owner, or nothing restricted), or would be
|
||||
/// once its permissions password is entered.
|
||||
Security,
|
||||
/// The document is protected and its security may be removed.
|
||||
/// The document is protected and its security may be removed (perhaps after entering
|
||||
/// its permissions password).
|
||||
ProtectedSecurity,
|
||||
/// The document's restrictions can be lifted with its permissions password.
|
||||
Unlock,
|
||||
/// There is something to undo.
|
||||
Undo,
|
||||
/// There is something to redo.
|
||||
|
|
@ -188,11 +194,12 @@ pub const COMMANDS: &[CommandSpec] = &[
|
|||
c("edit.find", "Find…", EDIT, Some(Shortcut::cmd("F")), Document, "search"),
|
||||
c("edit.advanced_search", "Advanced search…", EDIT, Some(Shortcut::cmd_shift("F")), Document, "search"),
|
||||
c("view.palette", "Find tools and commands…", VIEW, Some(Shortcut::cmd("K")), Nothing, "search"),
|
||||
// Page display: View ▸ Page display and the rail's button list these as radios, so
|
||||
// `menu = None` keeps them out of the generated menus. The palette still runs them.
|
||||
c("view.layout.continuous", "Continuous scrolling", None, None, Document, "arrow-up-down"),
|
||||
// Page display: View ▸ Page display and the rail's button list the base layouts as radios and
|
||||
// continuous scrolling as a checkbox, so `menu = None` keeps them out of the generated menus.
|
||||
// The palette still runs them.
|
||||
c("view.layout.single", "Single page", None, None, Document, "file-text"),
|
||||
c("view.layout.two_up", "Two-page view", None, None, Document, "columns-2"),
|
||||
c("view.continuous", "Continuous scrolling", None, None, Document, "arrow-up-down"),
|
||||
c("view.layout.cover", "Show cover page in two-page view", None, None, TwoPageView, "bookmark"),
|
||||
// Acrobat's view modes, a page display and a zoom at once (the rail's Page display menu).
|
||||
c("view.fit_width_scrolling", "Fit to width scrolling", None, None, Document, "arrow-left-right"),
|
||||
|
|
@ -205,7 +212,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
|||
// drag with the Select tool does the same; this is the way in where the desktop takes
|
||||
// Alt-drag for moving windows (several Linux window managers).
|
||||
c("edit.column_select", "Column select", EDIT, None, Document, "text-select"),
|
||||
c("edit.snapshot", "Take a snapshot", EDIT, None, Document, "camera"),
|
||||
c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"),
|
||||
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),
|
||||
c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"),
|
||||
c("view.focus_page_input", "Go to page…", VIEW, Some(Shortcut::cmd_shift("N")), Document, "text-cursor-input"),
|
||||
|
|
@ -298,23 +305,24 @@ pub const COMMANDS: &[CommandSpec] = &[
|
|||
c("sign.fill.signature.remove", "Fill & Sign: remove saved signature", None, None, Nothing, "x"),
|
||||
c("sign.fill.initials.remove", "Fill & Sign: remove saved initials", None, None, Nothing, "x"),
|
||||
c("sign.fill.initials.change", "Fill & Sign: change initials", None, None, Nothing, "signature"),
|
||||
c("export.image", "Export to image…", FILE, None, Document, "image"),
|
||||
c("export.image", "Export to image…", FILE, None, Copying, "image"),
|
||||
c("optimize.reduce", "Reduce file size…", FILE, None, Document, "file-down"),
|
||||
c("optimize.advanced", "Optimize PDF…", FILE, None, Document, "settings-2"),
|
||||
c("export.text", "Export to text…", FILE, None, Document, "type"),
|
||||
c("export.docx", "Export to Word…", FILE, None, Document, "file-text"),
|
||||
c("export.html", "Export to HTML…", FILE, None, Document, "file-symlink"),
|
||||
c("export.rtf", "Export to RTF…", FILE, None, Document, "file-text"),
|
||||
c("export.text", "Export to text…", FILE, None, Copying, "type"),
|
||||
c("export.docx", "Export to Word…", FILE, None, Copying, "file-text"),
|
||||
c("export.html", "Export to HTML…", FILE, None, Copying, "file-symlink"),
|
||||
c("export.rtf", "Export to RTF…", FILE, None, Copying, "file-text"),
|
||||
c("app.preferences", "Preferences…", EDIT, Some(Shortcut::cmd(",")), Nothing, "settings"),
|
||||
c("tools.js_console", "JavaScript console…", None, Some(Shortcut::cmd("J")), Document, "square-terminal"),
|
||||
c("tools.document_js", "Document JavaScripts…", None, None, Modification, "file-code"),
|
||||
c("ocr.recognize", "Recognize text…", None, None, Modification, "scan-text"),
|
||||
c("ocr.enhance_camera", "Enhance camera image", None, None, Modification, "camera"),
|
||||
c("ocr.recognize_batch", "Recognize text in multiple files…", None, None, Nothing, "files"),
|
||||
c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"),
|
||||
c("a11y.report", "Open accessibility report", None, None, Document, "file-text"),
|
||||
c("a11y.reading_options", "Change reading options…", None, None, Document, "book-open"),
|
||||
c("a11y.alt_text", "Add alternate text…", None, None, Modification, "image"),
|
||||
c("export.all_images", "Export all images…", FILE, None, Document, "image"),
|
||||
c("export.all_images", "Export all images…", FILE, None, Copying, "image"),
|
||||
c("edit.header_footer", "Add header & footer…", None, None, Modification, "heading"),
|
||||
c("edit.header_footer.update", "Update header & footer…", None, None, Marks(crate::MarkKind::HeaderFooter), "heading"),
|
||||
c("edit.header_footer.remove", "Remove header & footer", None, None, Marks(crate::MarkKind::HeaderFooter), "heading"),
|
||||
|
|
@ -326,6 +334,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
|||
c("edit.background.remove", "Remove background", None, None, Marks(crate::MarkKind::Background), "palette"),
|
||||
c("protect.password", "Protect using password…", FILE, None, Security, "lock"),
|
||||
c("protect.remove", "Remove security", FILE, None, ProtectedSecurity, "lock-open"),
|
||||
c("protect.unlock", "Enter permissions password…", None, None, Unlock, "lock-open"),
|
||||
c("protect.properties", "Security properties…", FILE, None, Document, "shield-check"),
|
||||
c("page.organize", "Organize pages", PAGES, None, Document, "layout-grid"),
|
||||
c("bookmark.add", "New bookmark", PAGES, Some(Shortcut::cmd("B")), Assembly, "bookmark-plus"),
|
||||
|
|
@ -370,25 +379,36 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option<DocId>)
|
|||
Assembly => doc.is_some_and(|d| d.allows_assembly()),
|
||||
Modification => doc.is_some_and(|d| d.allows_modification()),
|
||||
Annotate => doc.is_some_and(|d| d.allows_annotation()),
|
||||
Copying => doc.is_some_and(|d| d.allows_copying()),
|
||||
FillForms => doc.is_some_and(|d| d.allows_form_filling() && !d.form.is_empty()),
|
||||
Marks(k) => doc.is_some_and(|d| d.allows_modification() && d.marks.contains(&k)),
|
||||
HasComments => doc.is_some_and(|d| d.allows_modification() && !d.info.annotations.is_empty()),
|
||||
HasFields => doc.is_some_and(|d| d.allows_modification() && !d.form.is_empty()),
|
||||
HasRedactions => doc.is_some_and(|d| d.allows_modification() && d.redaction_marks() > 0),
|
||||
Security => doc.is_some_and(|d| d.allows_security_change()),
|
||||
ProtectedSecurity => doc.is_some_and(|d| d.allows_security_change() && d.security_summary().is_some()),
|
||||
Security => doc.is_some_and(|d| d.allows_security_change() || d.can_unlock()),
|
||||
ProtectedSecurity => doc.is_some_and(|d| (d.allows_security_change() || d.can_unlock()) && d.security_summary().is_some()),
|
||||
Unlock => doc.is_some_and(|d| d.can_unlock()),
|
||||
Undo => doc.is_some_and(|d| d.can_undo().is_some()),
|
||||
Redo => doc.is_some_and(|d| d.can_redo().is_some()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The edit `spec` names now, with the wording that names it ("Undo {}"), so an interface can say
|
||||
/// each part in its own language. `None` for every other command, and when there is nothing to undo.
|
||||
pub fn current_edit<'a>(spec: &CommandSpec, session: &'a Session, active: Option<DocId>) -> Option<(&'static str, &'a crate::Label)> {
|
||||
let doc = active.and_then(|id| session.get(id))?;
|
||||
match spec.id {
|
||||
"edit.undo" => doc.undo_label().map(|l| ("Undo {}", l)),
|
||||
"edit.redo" => doc.redo_label().map(|l| ("Redo {}", l)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The label to show for `spec` now ("Undo Rotate page" rather than "Undo").
|
||||
pub fn current_label(spec: &CommandSpec, session: &Session, active: Option<DocId>) -> String {
|
||||
let doc = active.and_then(|id| session.get(id));
|
||||
match (spec.id, doc) {
|
||||
("edit.undo", Some(d)) => d.can_undo().map(|l| format!("Undo {l}")).unwrap_or_else(|| spec.label.into()),
|
||||
("edit.redo", Some(d)) => d.can_redo().map(|l| format!("Redo {l}")).unwrap_or_else(|| spec.label.into()),
|
||||
_ => spec.label.into(),
|
||||
match current_edit(spec, session, active) {
|
||||
Some((wording, label)) => wording.replacen("{}", label.as_str(), 1),
|
||||
None => spec.label.into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@
|
|||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod actions;
|
||||
pub mod camera;
|
||||
pub mod camera_clean;
|
||||
pub mod camera_edges;
|
||||
pub mod catalog;
|
||||
pub mod commands;
|
||||
pub mod compare;
|
||||
|
|
@ -46,7 +49,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;
|
||||
|
||||
|
|
@ -94,6 +97,13 @@ pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i6
|
|||
let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect();
|
||||
Some(Shape::TypedSignature { rect, contours })
|
||||
}
|
||||
/// `text` in the script font as the graphic of a visible digital signature, as Fill & Sign types
|
||||
/// it. `None` for text with no outlines.
|
||||
pub fn typed_signature_graphic(text: &str) -> Option<pdfcraft_sign::Graphic> {
|
||||
let o = script_outline(text);
|
||||
o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours))
|
||||
}
|
||||
|
||||
/// Comment geometry helpers (text-box line breaking) for frontends.
|
||||
pub use pdfcraft_annot::appearance as annot_text;
|
||||
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
|
||||
|
|
@ -199,7 +209,7 @@ fn scope_of(edit: &Edit) -> Scope {
|
|||
}
|
||||
|
||||
/// One undo/redo step: its label, the document state, its passwords, and what it changed.
|
||||
type Snapshot = (String, pdfcraft_cos::Document, Keys, Scope);
|
||||
type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope);
|
||||
|
||||
/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted).
|
||||
#[derive(Clone)]
|
||||
|
|
@ -303,6 +313,7 @@ impl Document {
|
|||
|
||||
/// Save image as: image `index` on `page` as a file (extension, bytes).
|
||||
pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec<u8>), String> {
|
||||
self.check_copying().map_err(|e| e.to_string())?;
|
||||
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?;
|
||||
if img.is_form {
|
||||
|
|
@ -326,6 +337,12 @@ impl Document {
|
|||
let e = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
measure::scale_at(&e.cos, page, at).map_err(|e| e.to_string())
|
||||
}
|
||||
/// [`Self::measurement_scale`], measuring true size in `unit` (`per_point` of them in a
|
||||
/// point) where the page sets no scale.
|
||||
pub fn measurement_scale_or(&self, page: usize, at: measure::Point, per_point: f64, unit: &str) -> Result<measure::Scale, String> {
|
||||
let e = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
measure::scale_at_or(&e.cos, page, at, per_point, unit).map_err(|e| e.to_string())
|
||||
}
|
||||
pub fn measurement_to_user(&self, page: usize, point: measure::Point) -> Result<measure::Point, String> {
|
||||
let e = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
measure::view_to_user(&e.cos, page, point).map_err(|e| e.to_string())
|
||||
|
|
@ -397,11 +414,21 @@ impl Document {
|
|||
}
|
||||
|
||||
pub fn can_undo(&self) -> Option<&str> {
|
||||
self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l.as_str())
|
||||
self.undo_label().map(Label::as_str)
|
||||
}
|
||||
|
||||
pub fn can_redo(&self) -> Option<&str> {
|
||||
self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l.as_str())
|
||||
self.redo_label().map(Label::as_str)
|
||||
}
|
||||
|
||||
/// What Undo would undo, in parts, for an interface that says it in another language.
|
||||
pub fn undo_label(&self) -> Option<&Label> {
|
||||
self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l)
|
||||
}
|
||||
|
||||
/// What Redo would redo, in parts.
|
||||
pub fn redo_label(&self) -> Option<&Label> {
|
||||
self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l)
|
||||
}
|
||||
|
||||
pub fn editable(&self) -> bool {
|
||||
|
|
@ -418,6 +445,18 @@ impl Document {
|
|||
self.permissions().is_none_or(|p| p.print())
|
||||
}
|
||||
|
||||
/// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying
|
||||
/// selected text, snapshots, Save Image As, exporting. Assistive technology may read the
|
||||
/// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask.
|
||||
pub fn allows_copying(&self) -> bool {
|
||||
self.permissions().is_none_or(|p| p.copy())
|
||||
}
|
||||
|
||||
/// [`Self::allows_copying`], as the error to report when it isn't.
|
||||
pub fn check_copying(&self) -> Result<(), EditError> {
|
||||
if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) }
|
||||
}
|
||||
|
||||
/// Page changes (insert, delete, rotate, move, extract) are allowed.
|
||||
pub fn allows_assembly(&self) -> bool {
|
||||
self.editable() && self.permissions().is_none_or(|p| p.assemble())
|
||||
|
|
@ -449,6 +488,12 @@ impl Document {
|
|||
self.editable() && self.permissions().is_none_or(|p| unrestricted(&p))
|
||||
}
|
||||
|
||||
/// A password restricts the document and it wasn't opened with the owner password: entering
|
||||
/// that password ([`Session::unlock`]) lifts the restrictions.
|
||||
pub fn can_unlock(&self) -> bool {
|
||||
self.editable() && self.permissions().is_some_and(|p| !unrestricted(&p))
|
||||
}
|
||||
|
||||
/// A summary of the document's security for Document Properties ▸ Security, including
|
||||
/// protection applied in this session (written by the next save).
|
||||
pub fn security_summary(&self) -> Option<SecuritySummary> {
|
||||
|
|
@ -492,6 +537,14 @@ impl Document {
|
|||
self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Verification: faux redactions (opaque covers over extractable text) and redaction
|
||||
/// marks that were never applied. Fails closed: any read error is returned, never an
|
||||
/// empty list.
|
||||
pub fn audit_redactions(&self) -> Result<Vec<pdfcraft_audit::AuditFinding>, String> {
|
||||
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
pdfcraft_audit::audit_redactions(&editor.cos).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes.
|
||||
pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> {
|
||||
self.editor.as_ref().and_then(|e| pdfcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default()
|
||||
|
|
@ -1206,134 +1259,354 @@ pub enum Edit {
|
|||
},
|
||||
}
|
||||
|
||||
/// What an edit is called in the Edit menu and in the history ("Undo Rotate pages"), in two parts:
|
||||
/// a whole English wording, and the one value the document itself supplies for the wording's hole
|
||||
/// (a file name, a field name). An interface in another language looks the wording up and says the
|
||||
/// label with [`said`](Self::said); [`as_str`](Self::as_str) is the English, said the same way, so
|
||||
/// the two cannot drift apart.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Label {
|
||||
/// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`].
|
||||
pub wording: &'static str,
|
||||
/// What the document supplies, when the wording has a hole for it.
|
||||
pub value: Option<String>,
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl Label {
|
||||
/// A wording with nothing to fill in.
|
||||
pub fn of(wording: &'static str) -> Self {
|
||||
Self { wording, value: None, text: wording.to_string() }
|
||||
}
|
||||
|
||||
/// A wording whose hole the document fills in.
|
||||
pub fn with(wording: &'static str, value: impl Into<String>) -> Self {
|
||||
let mut label = Self { wording, value: Some(value.into()), text: String::new() };
|
||||
label.text = label.said(wording);
|
||||
label
|
||||
}
|
||||
|
||||
/// The label read with this wording: the interface's translation of [`wording`](Self::wording),
|
||||
/// or the English wording itself. What the document supplies goes in as it stands and is not
|
||||
/// read again, so a name with braces in it stays whole.
|
||||
pub fn said(&self, wording: &str) -> String {
|
||||
let Some(value) = &self.value else { return wording.to_owned() };
|
||||
// `{` and `}` are ASCII, so these are char boundaries.
|
||||
match (wording.find('{'), wording.find('}')) {
|
||||
(Some(open), Some(close)) if open < close => {
|
||||
let mut said = String::with_capacity(wording.len() + value.len());
|
||||
said.push_str(&wording[..open]);
|
||||
said.push_str(value);
|
||||
said.push_str(&wording[close + 1..]);
|
||||
said
|
||||
}
|
||||
// A translation that lost its hole is said as it stands rather than losing the value.
|
||||
_ => wording.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The label as English reads it.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Label {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not
|
||||
/// here: its wording is only the hole, because a batch carries its name as text (see
|
||||
/// [`BATCH_LABELS`]).
|
||||
pub const EDIT_LABELS: &[&str] = &[
|
||||
"Rotate page",
|
||||
"Rotate pages",
|
||||
"Delete page",
|
||||
"Delete pages",
|
||||
"Move page",
|
||||
"Move pages",
|
||||
"Insert blank page",
|
||||
"Duplicate page",
|
||||
"Duplicate pages",
|
||||
"Replace page",
|
||||
"Replace pages",
|
||||
"Crop page",
|
||||
"Crop pages",
|
||||
"Set page boxes",
|
||||
"Change Title",
|
||||
"Change Author",
|
||||
"Change Subject",
|
||||
"Change Keywords",
|
||||
"Change {key}",
|
||||
"Insert pages from {name}",
|
||||
"Add bookmark",
|
||||
"Rename bookmark",
|
||||
"Delete bookmark",
|
||||
"Move bookmark",
|
||||
"Set bookmark destination",
|
||||
"New bookmarks from structure",
|
||||
"Number pages",
|
||||
"Measure distance",
|
||||
"Measure perimeter",
|
||||
"Measure area",
|
||||
"Set measurement scale",
|
||||
"Add sticky note",
|
||||
"Add highlight",
|
||||
"Add underline",
|
||||
"Add strikethrough",
|
||||
"Add squiggly underline",
|
||||
"Add rectangle",
|
||||
"Add oval",
|
||||
"Add arrow",
|
||||
"Add line",
|
||||
"Add drawing",
|
||||
"Add text box",
|
||||
"Add checkmark",
|
||||
"Add cross",
|
||||
"Add dot",
|
||||
"Add signature",
|
||||
"Add redaction mark",
|
||||
"Add cloud",
|
||||
"Add polygon",
|
||||
"Add connected lines",
|
||||
"Add callout",
|
||||
"Add inserted text",
|
||||
"Add file attachment",
|
||||
"Add stamp",
|
||||
"Delete comment",
|
||||
"Edit comment",
|
||||
"Reply",
|
||||
"Set status None",
|
||||
"Set status Accepted",
|
||||
"Set status Rejected",
|
||||
"Set status Cancelled",
|
||||
"Set status Completed",
|
||||
"Replace text",
|
||||
"Erase",
|
||||
"Mark with checkmark",
|
||||
"Remove checkmark",
|
||||
"Lock comment",
|
||||
"Unlock comment",
|
||||
"Move comment",
|
||||
"Resize comment",
|
||||
"Change comment properties",
|
||||
"Fill in {name}",
|
||||
"Set the image of {name}",
|
||||
"Clear form",
|
||||
"Add field",
|
||||
"Change field properties",
|
||||
"Delete field",
|
||||
"Duplicate field",
|
||||
"Set tab order",
|
||||
"Change initial view",
|
||||
"Set alternate text",
|
||||
"Mark figure as decorative",
|
||||
"Recognize text",
|
||||
"Run JavaScript",
|
||||
"Run form script",
|
||||
"Save as {level}",
|
||||
"Edit script of {name}",
|
||||
"Delete document JavaScript",
|
||||
"Edit document JavaScript",
|
||||
"Edit text",
|
||||
"Move object",
|
||||
"Move objects",
|
||||
"Move image",
|
||||
"Rotate image",
|
||||
"Flip image",
|
||||
"Replace image",
|
||||
"Delete image",
|
||||
"Add header & footer",
|
||||
"Update header & footer",
|
||||
"Add watermark",
|
||||
"Update watermark",
|
||||
"Add background",
|
||||
"Update background",
|
||||
"Remove header & footer",
|
||||
"Remove watermark",
|
||||
"Remove background",
|
||||
"Add text",
|
||||
"Add image",
|
||||
"Edit content",
|
||||
"Delete content",
|
||||
"Apply redactions",
|
||||
"Remove redaction marks",
|
||||
"Remove hidden information",
|
||||
"Import {name}",
|
||||
"Add link",
|
||||
"Change link properties",
|
||||
"Delete link",
|
||||
"Remove all links",
|
||||
"Create link",
|
||||
"Create links",
|
||||
"Sanitize document",
|
||||
"Flatten comments",
|
||||
"Flatten form fields",
|
||||
"Flatten",
|
||||
"Flatten Fill & Sign",
|
||||
"Protect with password",
|
||||
"Remove security",
|
||||
];
|
||||
|
||||
/// The names of the batches built below L7, which an interface shows in the same places as
|
||||
/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives
|
||||
/// already worded in the interface's language.
|
||||
pub const BATCH_LABELS: &[&str] = &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text"];
|
||||
|
||||
impl Edit {
|
||||
/// Label for the Edit menu and history ("Undo Rotate pages").
|
||||
pub fn label(&self) -> String {
|
||||
pub fn label(&self) -> Label {
|
||||
match self {
|
||||
Edit::RotatePages { pages, .. } => plural("Rotate page", pages.len()),
|
||||
Edit::DeletePages { pages } => plural("Delete page", pages.len()),
|
||||
Edit::MovePages { pages, .. } => plural("Move page", pages.len()),
|
||||
Edit::InsertBlankPage { .. } => "Insert blank page".into(),
|
||||
Edit::DuplicatePages { pages } => plural("Duplicate page", pages.len()),
|
||||
Edit::ReplacePages { pages, .. } => plural("Replace page", pages.len()),
|
||||
Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", pages.len()),
|
||||
Edit::SetPageBox { .. } => "Set page boxes".into(),
|
||||
Edit::SetInfo { key, .. } => format!("Change {key}"),
|
||||
Edit::InsertPagesFrom { name, .. } => format!("Insert pages from {name}"),
|
||||
Edit::AddBookmark { .. } => "Add bookmark".into(),
|
||||
Edit::RenameBookmark { .. } => "Rename bookmark".into(),
|
||||
Edit::DeleteBookmark { .. } => "Delete bookmark".into(),
|
||||
Edit::MoveBookmark { .. } => "Move bookmark".into(),
|
||||
Edit::SetBookmarkPage { .. } => "Set bookmark destination".into(),
|
||||
Edit::BookmarksFromStructure => "New bookmarks from structure".into(),
|
||||
Edit::NumberPages { .. } => "Number pages".into(),
|
||||
Edit::AddMeasurement(m) => format!("Measure {}", m.kind.name()),
|
||||
Edit::SetMeasurementScale { .. } => "Set measurement scale".into(),
|
||||
Edit::AddAnnotation(a) => format!("Add {}", annotation_noun(&a.shape)),
|
||||
Edit::AddCustomStamp { .. } => "Add stamp".into(),
|
||||
Edit::DeleteAnnotation { .. } => "Delete comment".into(),
|
||||
Edit::SetAnnotationContents { .. } => "Edit comment".into(),
|
||||
Edit::ReplyToAnnotation { .. } => "Reply".into(),
|
||||
Edit::SetAnnotationStatus { state, .. } => format!("Set status {}", state.name()),
|
||||
Edit::ReplaceText { .. } => "Replace text".into(),
|
||||
Edit::EraseInk { .. } => "Erase".into(),
|
||||
Edit::MarkAnnotation { marked: true, .. } => "Mark with checkmark".into(),
|
||||
Edit::MarkAnnotation { .. } => "Remove checkmark".into(),
|
||||
Edit::LockAnnotation { locked: true, .. } => "Lock comment".into(),
|
||||
Edit::LockAnnotation { .. } => "Unlock comment".into(),
|
||||
Edit::MoveAnnotation { .. } => "Move comment".into(),
|
||||
Edit::ResizeAnnotation { .. } => "Resize comment".into(),
|
||||
Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::SetAnnotationInfo { .. } => "Change comment properties".into(),
|
||||
Edit::SetFieldValue { name, .. } => format!("Fill in {name}"),
|
||||
Edit::SetFieldImage { name, .. } => format!("Set the image of {name}"),
|
||||
Edit::ResetForm { .. } => "Clear form".into(),
|
||||
Edit::AddField { .. } => "Add field".into(),
|
||||
Edit::SetFieldProps { .. } => "Change field properties".into(),
|
||||
Edit::DeleteField { .. } => "Delete field".into(),
|
||||
Edit::DuplicateField { .. } => "Duplicate field".into(),
|
||||
Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => "Set tab order".into(),
|
||||
Edit::SetInitialView(_) => "Change initial view".into(),
|
||||
Edit::SetAltText { .. } => "Set alternate text".into(),
|
||||
Edit::MarkDecorative { .. } => "Mark figure as decorative".into(),
|
||||
Edit::AddOcrText { .. } => "Recognize text".into(),
|
||||
Edit::ApplyScriptChanges { .. } => "Run JavaScript".into(),
|
||||
Edit::XfaEvent { .. } => "Run form script".into(),
|
||||
Edit::ConvertPdfA { level } => format!("Save as {}", level.label()),
|
||||
Edit::SetFieldScript { name, .. } => format!("Edit script of {name}"),
|
||||
Edit::SetDocumentScript { script: None, .. } => "Delete document JavaScript".into(),
|
||||
Edit::SetDocumentScript { .. } => "Edit document JavaScript".into(),
|
||||
Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => "Edit text".into(),
|
||||
Edit::MoveObjects { objects, .. } => plural("Move object", objects.len()),
|
||||
Edit::EditPageImage { change, .. } => match change {
|
||||
ImageEdit::Move(_) => "Move image".into(),
|
||||
ImageEdit::Rotate(_) => "Rotate image".into(),
|
||||
ImageEdit::Flip { .. } => "Flip image".into(),
|
||||
ImageEdit::Replace { .. } => "Replace image".into(),
|
||||
ImageEdit::Delete => "Delete image".into(),
|
||||
Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()),
|
||||
Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()),
|
||||
Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()),
|
||||
Edit::InsertBlankPage { .. } => Label::of("Insert blank page"),
|
||||
Edit::DuplicatePages { pages } => plural("Duplicate page", "Duplicate pages", pages.len()),
|
||||
Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()),
|
||||
Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()),
|
||||
Edit::SetPageBox { .. } => Label::of("Set page boxes"),
|
||||
// The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can
|
||||
// be said in another language; any other key is the document's and goes in as it stands.
|
||||
Edit::SetInfo { key, .. } => match key.as_str() {
|
||||
"Title" => Label::of("Change Title"),
|
||||
"Author" => Label::of("Change Author"),
|
||||
"Subject" => Label::of("Change Subject"),
|
||||
"Keywords" => Label::of("Change Keywords"),
|
||||
_ => Label::with("Change {key}", key.as_str()),
|
||||
},
|
||||
Edit::AddHeaderFooter { replace: false, .. } => "Add header & footer".into(),
|
||||
Edit::AddHeaderFooter { .. } => "Update header & footer".into(),
|
||||
Edit::AddWatermark { replace: false, .. } => "Add watermark".into(),
|
||||
Edit::AddWatermark { .. } => "Update watermark".into(),
|
||||
Edit::AddBackground { replace: false, .. } => "Add background".into(),
|
||||
Edit::AddBackground { .. } => "Update background".into(),
|
||||
Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => "Remove header & footer".into(),
|
||||
Edit::RemoveMarks { kind: MarkKind::Watermark } => "Remove watermark".into(),
|
||||
Edit::RemoveMarks { kind: MarkKind::Background } => "Remove background".into(),
|
||||
Edit::AddText { .. } => "Add text".into(),
|
||||
Edit::AddImage { .. } => "Add image".into(),
|
||||
Edit::UpdateContent { .. } => "Edit content".into(),
|
||||
Edit::DeleteContent { .. } => "Delete content".into(),
|
||||
Edit::ReplaceImage { .. } => "Replace image".into(),
|
||||
Edit::ApplyRedactions { .. } => "Apply redactions".into(),
|
||||
Edit::ClearRedactions => "Remove redaction marks".into(),
|
||||
Edit::RemoveHidden { .. } => "Remove hidden information".into(),
|
||||
Edit::ImportData { name, .. } => format!("Import {name}"),
|
||||
Edit::AddLink { .. } => "Add link".into(),
|
||||
Edit::SetLink { .. } => "Change link properties".into(),
|
||||
Edit::DeleteLink { .. } => "Delete link".into(),
|
||||
Edit::RemoveLinks { .. } => "Remove all links".into(),
|
||||
Edit::AddLinks { links, .. } => plural("Create link", links.len()),
|
||||
Edit::Sanitize => "Sanitize document".into(),
|
||||
Edit::Flatten { comments: true, fields: false } => "Flatten comments".into(),
|
||||
Edit::Flatten { comments: false, fields: true } => "Flatten form fields".into(),
|
||||
Edit::Flatten { .. } => "Flatten".into(),
|
||||
Edit::FlattenFillSign => "Flatten Fill & Sign".into(),
|
||||
Edit::Protect(_) => "Protect with password".into(),
|
||||
Edit::RemoveProtection => "Remove security".into(),
|
||||
Edit::Batch { label, .. } => label.clone(),
|
||||
Edit::InsertPagesFrom { name, .. } => Label::with("Insert pages from {name}", name.as_str()),
|
||||
Edit::AddBookmark { .. } => Label::of("Add bookmark"),
|
||||
Edit::RenameBookmark { .. } => Label::of("Rename bookmark"),
|
||||
Edit::DeleteBookmark { .. } => Label::of("Delete bookmark"),
|
||||
Edit::MoveBookmark { .. } => Label::of("Move bookmark"),
|
||||
Edit::SetBookmarkPage { .. } => Label::of("Set bookmark destination"),
|
||||
Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"),
|
||||
Edit::NumberPages { .. } => Label::of("Number pages"),
|
||||
// What was measured is part of the wording too, for the same reason.
|
||||
Edit::AddMeasurement(m) => Label::of(match m.kind {
|
||||
pdfcraft_measure::Kind::Distance => "Measure distance",
|
||||
pdfcraft_measure::Kind::Perimeter => "Measure perimeter",
|
||||
pdfcraft_measure::Kind::Area => "Measure area",
|
||||
}),
|
||||
Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"),
|
||||
Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)),
|
||||
Edit::AddCustomStamp { .. } => Label::of("Add stamp"),
|
||||
Edit::DeleteAnnotation { .. } => Label::of("Delete comment"),
|
||||
Edit::SetAnnotationContents { .. } => Label::of("Edit comment"),
|
||||
Edit::ReplyToAnnotation { .. } => Label::of("Reply"),
|
||||
// The state's name is part of the wording: a language may need to agree it with the rest.
|
||||
Edit::SetAnnotationStatus { state, .. } => Label::of(match state {
|
||||
ReviewState::None => "Set status None",
|
||||
ReviewState::Accepted => "Set status Accepted",
|
||||
ReviewState::Rejected => "Set status Rejected",
|
||||
ReviewState::Cancelled => "Set status Cancelled",
|
||||
ReviewState::Completed => "Set status Completed",
|
||||
}),
|
||||
Edit::ReplaceText { .. } => Label::of("Replace text"),
|
||||
Edit::EraseInk { .. } => Label::of("Erase"),
|
||||
Edit::MarkAnnotation { marked: true, .. } => Label::of("Mark with checkmark"),
|
||||
Edit::MarkAnnotation { .. } => Label::of("Remove checkmark"),
|
||||
Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"),
|
||||
Edit::LockAnnotation { .. } => Label::of("Unlock comment"),
|
||||
Edit::MoveAnnotation { .. } => Label::of("Move comment"),
|
||||
Edit::ResizeAnnotation { .. } => Label::of("Resize comment"),
|
||||
Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Label::of("Change comment properties"),
|
||||
Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()),
|
||||
Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()),
|
||||
Edit::ResetForm { .. } => Label::of("Clear form"),
|
||||
Edit::AddField { .. } => Label::of("Add field"),
|
||||
Edit::SetFieldProps { .. } => Label::of("Change field properties"),
|
||||
Edit::DeleteField { .. } => Label::of("Delete field"),
|
||||
Edit::DuplicateField { .. } => Label::of("Duplicate field"),
|
||||
Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"),
|
||||
Edit::SetInitialView(_) => Label::of("Change initial view"),
|
||||
Edit::SetAltText { .. } => Label::of("Set alternate text"),
|
||||
Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"),
|
||||
Edit::AddOcrText { .. } => Label::of("Recognize text"),
|
||||
Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"),
|
||||
Edit::XfaEvent { .. } => Label::of("Run form script"),
|
||||
// The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands.
|
||||
Edit::ConvertPdfA { level } => Label::with("Save as {level}", level.label()),
|
||||
Edit::SetFieldScript { name, .. } => Label::with("Edit script of {name}", name.as_str()),
|
||||
Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"),
|
||||
Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"),
|
||||
Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"),
|
||||
Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()),
|
||||
Edit::EditPageImage { change, .. } => Label::of(match change {
|
||||
ImageEdit::Move(_) => "Move image",
|
||||
ImageEdit::Rotate(_) => "Rotate image",
|
||||
ImageEdit::Flip { .. } => "Flip image",
|
||||
ImageEdit::Replace { .. } => "Replace image",
|
||||
ImageEdit::Delete => "Delete image",
|
||||
}),
|
||||
Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"),
|
||||
Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"),
|
||||
Edit::AddWatermark { replace: false, .. } => Label::of("Add watermark"),
|
||||
Edit::AddWatermark { .. } => Label::of("Update watermark"),
|
||||
Edit::AddBackground { replace: false, .. } => Label::of("Add background"),
|
||||
Edit::AddBackground { .. } => Label::of("Update background"),
|
||||
Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"),
|
||||
Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"),
|
||||
Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"),
|
||||
Edit::AddText { .. } => Label::of("Add text"),
|
||||
Edit::AddImage { .. } => Label::of("Add image"),
|
||||
Edit::UpdateContent { .. } => Label::of("Edit content"),
|
||||
Edit::DeleteContent { .. } => Label::of("Delete content"),
|
||||
Edit::ReplaceImage { .. } => Label::of("Replace image"),
|
||||
Edit::ApplyRedactions { .. } => Label::of("Apply redactions"),
|
||||
Edit::ClearRedactions => Label::of("Remove redaction marks"),
|
||||
Edit::RemoveHidden { .. } => Label::of("Remove hidden information"),
|
||||
Edit::ImportData { name, .. } => Label::with("Import {name}", name.as_str()),
|
||||
Edit::AddLink { .. } => Label::of("Add link"),
|
||||
Edit::SetLink { .. } => Label::of("Change link properties"),
|
||||
Edit::DeleteLink { .. } => Label::of("Delete link"),
|
||||
Edit::RemoveLinks { .. } => Label::of("Remove all links"),
|
||||
Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()),
|
||||
Edit::Sanitize => Label::of("Sanitize document"),
|
||||
Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"),
|
||||
Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"),
|
||||
Edit::Flatten { .. } => Label::of("Flatten"),
|
||||
Edit::FlattenFillSign => Label::of("Flatten Fill & Sign"),
|
||||
Edit::Protect(_) => Label::of("Protect with password"),
|
||||
Edit::RemoveProtection => Label::of("Remove security"),
|
||||
// A batch carries its own name, already worded by whoever built it.
|
||||
// A batch carries its name as text, so the wording is only the hole (see `BATCH_LABELS`).
|
||||
Edit::Batch { label, .. } => Label::with("{batch}", label.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the Edit menu calls a new comment ("Undo Add highlight").
|
||||
fn annotation_noun(s: &Shape) -> &'static str {
|
||||
/// What the Edit menu calls a new comment ("Undo Add highlight"). A whole wording for each kind,
|
||||
/// so a language can give the noun the article and the gender its own grammar asks for.
|
||||
fn add_annotation(s: &Shape) -> &'static str {
|
||||
match s {
|
||||
Shape::Note { .. } => "sticky note",
|
||||
Shape::TextMarkup { kind: Markup::Highlight, .. } => "highlight",
|
||||
Shape::TextMarkup { kind: Markup::Underline, .. } => "underline",
|
||||
Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "strikethrough",
|
||||
Shape::TextMarkup { kind: Markup::Squiggly, .. } => "squiggly underline",
|
||||
Shape::Rectangle { .. } => "rectangle",
|
||||
Shape::Oval { .. } => "oval",
|
||||
Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "arrow",
|
||||
Shape::Line { .. } => "line",
|
||||
Shape::Ink { .. } => "drawing",
|
||||
Shape::TextBox { .. } => "text box",
|
||||
Shape::Typewriter { .. } => "text",
|
||||
Shape::Mark { mark: FillMark::Check, .. } => "checkmark",
|
||||
Shape::Mark { mark: FillMark::Cross, .. } => "cross",
|
||||
Shape::Mark { mark: FillMark::Dot, .. } => "dot",
|
||||
Shape::Mark { mark: FillMark::Line, .. } => "line",
|
||||
Shape::Signature { .. } | Shape::TypedSignature { .. } => "signature",
|
||||
Shape::Redact { .. } => "redaction mark",
|
||||
Shape::Stamp { .. } | Shape::CustomStamp { .. } => "stamp",
|
||||
Shape::Polygon { cloud: true, .. } => "cloud",
|
||||
Shape::Polygon { .. } => "polygon",
|
||||
Shape::PolyLine { .. } => "connected lines",
|
||||
Shape::Callout { .. } => "callout",
|
||||
Shape::Caret { .. } => "inserted text",
|
||||
Shape::Attachment { .. } => "file attachment",
|
||||
Shape::Note { .. } => "Add sticky note",
|
||||
Shape::TextMarkup { kind: Markup::Highlight, .. } => "Add highlight",
|
||||
Shape::TextMarkup { kind: Markup::Underline, .. } => "Add underline",
|
||||
Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Add strikethrough",
|
||||
Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Add squiggly underline",
|
||||
Shape::Rectangle { .. } => "Add rectangle",
|
||||
Shape::Oval { .. } => "Add oval",
|
||||
Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow",
|
||||
Shape::Line { .. } => "Add line",
|
||||
Shape::Ink { .. } => "Add drawing",
|
||||
Shape::TextBox { .. } => "Add text box",
|
||||
Shape::Typewriter { .. } => "Add text",
|
||||
Shape::Mark { mark: FillMark::Check, .. } => "Add checkmark",
|
||||
Shape::Mark { mark: FillMark::Cross, .. } => "Add cross",
|
||||
Shape::Mark { mark: FillMark::Dot, .. } => "Add dot",
|
||||
Shape::Mark { mark: FillMark::Line, .. } => "Add line",
|
||||
Shape::Signature { .. } | Shape::TypedSignature { .. } => "Add signature",
|
||||
Shape::Redact { .. } => "Add redaction mark",
|
||||
Shape::Stamp { .. } | Shape::CustomStamp { .. } => "Add stamp",
|
||||
Shape::Polygon { cloud: true, .. } => "Add cloud",
|
||||
Shape::Polygon { .. } => "Add polygon",
|
||||
Shape::PolyLine { .. } => "Add connected lines",
|
||||
Shape::Callout { .. } => "Add callout",
|
||||
Shape::Caret { .. } => "Add inserted text",
|
||||
Shape::Attachment { .. } => "Add file attachment",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1915,9 +2188,6 @@ pub fn guard<T>(f: impl FnOnce() -> T) -> Result<T, String> {
|
|||
})
|
||||
}
|
||||
|
||||
/// The most files Create ▸ Multiple files takes in one run.
|
||||
pub const MAX_CREATE_FILES: usize = 1000;
|
||||
|
||||
/// Parse another PDF to copy pages from.
|
||||
fn open_source(name: &str, bytes: &Arc<Vec<u8>>) -> Result<pdfcraft_cos::Document, EditError> {
|
||||
open_source_with(name, bytes, None)
|
||||
|
|
@ -1966,8 +2236,10 @@ pub fn source_page_count(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Option
|
|||
pdfcraft_organize::page_count(&doc).ok()
|
||||
}
|
||||
|
||||
fn plural(s: &str, n: usize) -> String {
|
||||
if n == 1 { s.to_string() } else { format!("{s}s") }
|
||||
/// The singular or the plural wording, by count. Both are written out: a language forms its plural
|
||||
/// its own way, and `one` and `many` may differ in more than an "s".
|
||||
fn plural(one: &'static str, many: &'static str, n: usize) -> Label {
|
||||
Label::of(if n == 1 { one } else { many })
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
|
|
@ -1988,6 +2260,8 @@ pub enum EditError {
|
|||
Measure(#[from] measure::MeasureError),
|
||||
#[error("{0}")]
|
||||
Protection(String),
|
||||
#[error("the password is incorrect")]
|
||||
WrongPassword,
|
||||
#[error("{0}")]
|
||||
Form(#[from] pdfcraft_forms::FormError),
|
||||
#[error(transparent)]
|
||||
|
|
@ -2051,10 +2325,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<XfaLayout, String> {
|
||||
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.
|
||||
|
|
@ -2380,12 +2664,14 @@ impl Session {
|
|||
};
|
||||
let display = display_bytes(editor.as_ref(), &bytes);
|
||||
let renderer = RenderPool::new(display.clone(), render_threads(), config.clone());
|
||||
let mut form = editor.as_ref().map(|e| pdfcraft_forms::fields(&e.cos)).unwrap_or_default();
|
||||
let mut adopted = 0;
|
||||
if let Some(e) = editor.as_ref() {
|
||||
xfa::mark_script_buttons(&e.cos, &mut form);
|
||||
adopted = pdfcraft_forms::adopted_page_fields(&e.cos);
|
||||
}
|
||||
let (form, adopted) = editor
|
||||
.as_ref()
|
||||
.map(|e| {
|
||||
let mut fields = pdfcraft_forms::fields_with_adopted(&e.cos);
|
||||
xfa::mark_script_buttons(&e.cos, &mut fields.items);
|
||||
(fields.items, fields.adopted_page_fields)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// Fields the file lists only as page widgets are adopted leniently; the file says so.
|
||||
if adopted > 0
|
||||
&& let Some(e) = &mut editor
|
||||
|
|
@ -2397,8 +2683,13 @@ impl Session {
|
|||
};
|
||||
e.cos.note_repair(line);
|
||||
}
|
||||
let marks = editor.as_ref().map(|e| pdfcraft_edit::marks_present(&e.cos)).unwrap_or_default();
|
||||
let added = editor.as_ref().map(|e| pdfcraft_edit::list_added(&e.cos)).unwrap_or_default();
|
||||
let (marks, added) = editor
|
||||
.as_ref()
|
||||
.map(|e| {
|
||||
let scan = pdfcraft_edit::scan_markers(&e.cos);
|
||||
(scan.marks, scan.added)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let links = editor.as_ref().map(|e| pdfcraft_annot::links::list(&e.cos)).unwrap_or_default();
|
||||
let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default());
|
||||
let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, &self.trust, &sig_cache)).unwrap_or_default();
|
||||
|
|
@ -2516,7 +2807,7 @@ impl Session {
|
|||
// (applying redactions, changing security, sanitizing) would invalidate the signatures.
|
||||
let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed();
|
||||
if signed && rewrites(&next) && !rewrites(&editor.cos) {
|
||||
return Err(EditError::SignedRewrite(edit.label()));
|
||||
return Err(EditError::SignedRewrite(edit.label().to_string()));
|
||||
}
|
||||
let previous = std::mem::replace(&mut editor.cos, next);
|
||||
let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone());
|
||||
|
|
@ -2557,7 +2848,7 @@ impl Session {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn undo(&mut self, id: DocId) -> Result<String, EditError> {
|
||||
pub fn undo(&mut self, id: DocId) -> Result<Label, EditError> {
|
||||
let doc = self.doc_mut(id)?;
|
||||
let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?;
|
||||
let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?;
|
||||
|
|
@ -2571,7 +2862,7 @@ impl Session {
|
|||
Ok(label)
|
||||
}
|
||||
|
||||
pub fn redo(&mut self, id: DocId) -> Result<String, EditError> {
|
||||
pub fn redo(&mut self, id: DocId) -> Result<Label, EditError> {
|
||||
let doc = self.doc_mut(id)?;
|
||||
let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?;
|
||||
let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?;
|
||||
|
|
@ -2631,8 +2922,15 @@ impl Session {
|
|||
Scope::Metadata => return Ok(()),
|
||||
Scope::Comments => doc.info.annotations = comment_list(&editor.cos),
|
||||
Scope::Form => {
|
||||
// PERF: keystroke path, index once. A linear find per field is O(F^2);
|
||||
// forms reach 10k fields.
|
||||
let mut by_name = std::collections::HashMap::with_capacity(form.len());
|
||||
for x in form.iter() {
|
||||
// First match wins (do not simplify to collect: it keeps the last).
|
||||
by_name.entry(x.name.as_str()).or_insert(x);
|
||||
}
|
||||
for f in &mut doc.info.fields {
|
||||
if let Some(ff) = form.iter().find(|x| x.name == f.name) {
|
||||
if let Some(ff) = by_name.get(f.name.as_str()) {
|
||||
f.value = match ff.kind {
|
||||
pdfcraft_forms::FieldKind::CheckBox | pdfcraft_forms::FieldKind::Radio => {
|
||||
Some(ff.value.first().cloned().unwrap_or_else(|| "Off".into()))
|
||||
|
|
@ -2684,8 +2982,9 @@ impl Session {
|
|||
let mut form = pdfcraft_forms::fields(&editor.cos);
|
||||
xfa::mark_script_buttons(&editor.cos, &mut form);
|
||||
doc.form = Arc::new(form);
|
||||
doc.marks = pdfcraft_edit::marks_present(&editor.cos);
|
||||
doc.added = pdfcraft_edit::list_added(&editor.cos);
|
||||
let marker_scan = pdfcraft_edit::scan_markers(&editor.cos);
|
||||
doc.marks = marker_scan.marks;
|
||||
doc.added = marker_scan.added;
|
||||
doc.links = pdfcraft_annot::links::list(&editor.cos);
|
||||
doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
|
||||
doc.display = display_bytes(Some(editor), &bytes);
|
||||
|
|
@ -2736,6 +3035,39 @@ impl Session {
|
|||
Self::refresh(doc)
|
||||
}
|
||||
|
||||
/// Enter the permissions (owner) password of a document opened with its user password,
|
||||
/// lifting its restrictions for this session (Acrobat asks for it before changing security).
|
||||
/// Not an edit: nothing is marked unsaved and there is no undo step. Undo and redo states
|
||||
/// are unlocked as well, and saving keeps the rights. Unencrypted documents and documents
|
||||
/// opened by the owner need nothing.
|
||||
pub fn unlock(&mut self, id: DocId, password: &str) -> Result<(), EditError> {
|
||||
let doc = self.doc_mut(id)?;
|
||||
let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
|
||||
if editor.cos.permissions().is_none_or(|p| p.owner) {
|
||||
return Ok(());
|
||||
}
|
||||
editor.cos.unlock(password).map_err(|e| match e {
|
||||
pdfcraft_cos::CosError::WrongPassword => EditError::WrongPassword,
|
||||
other => EditError::Protection(other.to_string()),
|
||||
})?;
|
||||
// Saving a state that keeps the security it was opened with reopens it with the same
|
||||
// passwords, so it reopens as owner.
|
||||
let reopen_as_owner = |cos: &pdfcraft_cos::Document, keys: &mut Keys| {
|
||||
if !cos.encryption_changed() {
|
||||
keys.reopen = Some(password.to_owned());
|
||||
}
|
||||
};
|
||||
reopen_as_owner(&editor.cos, &mut editor.keys);
|
||||
// Other states with the same restricted access are unlocked too; states opened with
|
||||
// other passwords (protection changed and saved since) stay as they are.
|
||||
for (_, cos, keys, _) in editor.undo.iter_mut().chain(editor.redo.iter_mut()) {
|
||||
if cos.permissions().is_some_and(|p| !p.owner) && cos.unlock(password).is_ok() {
|
||||
reopen_as_owner(cos, keys);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is
|
||||
/// cleared, as in Acrobat.
|
||||
pub fn revert(&mut self, id: DocId) -> Result<(), EditError> {
|
||||
|
|
@ -2761,6 +3093,29 @@ impl Session {
|
|||
Self::refresh(doc)
|
||||
}
|
||||
|
||||
/// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open
|
||||
/// `bytes`, the file as it is now, in its place. Everything opening does runs again (XFA
|
||||
/// layout and scripts, signatures, repairs), but the document keeps its id, name and path,
|
||||
/// so its tab and view stay with it. Undo history goes, as with Revert. The password it was
|
||||
/// opened with is tried; on any failure the document is left as it was.
|
||||
pub fn reload(&mut self, id: DocId, bytes: Arc<Vec<u8>>) -> Result<(), OpenError> {
|
||||
let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?;
|
||||
let doc = &self.docs[old];
|
||||
let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation);
|
||||
let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone());
|
||||
let new_id = self.open(name, path, bytes, password.as_deref())?;
|
||||
let at = self.docs.iter().position(|d| d.id == new_id).ok_or_else(|| OpenError::Invalid("the new version was not kept".into()))?;
|
||||
let mut doc = self.docs.remove(at);
|
||||
doc.id = id;
|
||||
// Caches keyed by the edit generation (edit lines, images, measurements) must see a new
|
||||
// one; there is nothing new to autosave.
|
||||
doc.generation = generation + 1;
|
||||
doc.snapshot_generation = doc.generation;
|
||||
// `open` pushed the new version last, so the old one is still at `old`.
|
||||
*self.docs.get_mut(old).ok_or_else(|| OpenError::Invalid("the document is not open".into()))? = doc;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The page count of another PDF (Replace Pages, Insert Pages dialogs).
|
||||
pub fn page_count_of(&self, name: &str, bytes: &Arc<Vec<u8>>) -> Result<usize, EditError> {
|
||||
Ok(pdfcraft_organize::page_count(&open_source(name, bytes)?)?)
|
||||
|
|
@ -2803,6 +3158,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<Vec<u8>>) -> Result<Arc<Vec<u8>>, 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
|
||||
|
|
@ -2866,6 +3233,43 @@ impl Session {
|
|||
self.combine_unlocked(sources, &[])
|
||||
}
|
||||
|
||||
/// Combine sources one at a time, in order (Create ▸ Multiple files), to the same result as
|
||||
/// [`Self::combine_ranges`]. Each item is a source's bookmark title, its PDF bytes (see
|
||||
/// [`Self::convert_to_pdf`]) and its page range (`None` for all pages). Items are pulled one at a
|
||||
/// time: a source is opened, its pages are copied into the result, and its bytes are released
|
||||
/// before the next item is read. So the number of sources is not limited, and one source's
|
||||
/// document is held at a time. An item's error stops the combine and is returned as it is.
|
||||
/// `Ok(None)` when there are no sources.
|
||||
pub fn combine_each<I, E>(&self, sources: I) -> Result<Option<Arc<Vec<u8>>>, E>
|
||||
where
|
||||
I: IntoIterator<Item = Result<CombineSource, E>>,
|
||||
E: From<EditError>,
|
||||
{
|
||||
let mut combiner = pdfcraft_organize::Combiner::new();
|
||||
let mut added = false;
|
||||
for source in sources {
|
||||
let (title, bytes, range) = source?;
|
||||
let doc = open_source(&title, &bytes)?;
|
||||
let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty());
|
||||
let pages = match range {
|
||||
Some(r) => {
|
||||
let n = pdfcraft_organize::page_count(&doc).map_err(EditError::from)?;
|
||||
let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false)
|
||||
.map_err(|e| EditError::Print(format!("{title}: {e}")))?;
|
||||
Some(p)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
combiner.add(&title, &doc, pages.as_deref()).map_err(EditError::from)?;
|
||||
added = true;
|
||||
}
|
||||
if !added {
|
||||
return Ok(None);
|
||||
}
|
||||
let out = combiner.finish().map_err(EditError::from)?;
|
||||
Ok(Some(self.write_new(&out)?))
|
||||
}
|
||||
|
||||
/// [`Self::combine_ranges`] with the password each encrypted source is opened with, by
|
||||
/// position (missing or `None`: no password). The result is not encrypted.
|
||||
pub fn combine_unlocked(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Result<Arc<Vec<u8>>, EditError> {
|
||||
|
|
|
|||
|
|
@ -52,8 +52,8 @@ impl Document {
|
|||
let obj = editor.cos.get(picture);
|
||||
let pdfcraft_cos::Object::Stream(s) = &*obj else { return Err("the signature image is damaged".into()) };
|
||||
let (w, h) = (s.dict.int(b"Width").unwrap_or(0), s.dict.int(b"Height").unwrap_or(0));
|
||||
if w <= 0 || h <= 0 || w > 4096 || h > 4096 || w.saturating_mul(h) > MAX_PIXELS as i64 {
|
||||
return Err(SignatureImageError::Size.to_string());
|
||||
if w <= 0 || h <= 0 || w > i64::from(MAX_SIDE) || h > i64::from(MAX_SIDE) || w.saturating_mul(h) > MAX_PIXELS as i64 {
|
||||
return Err(SignatureImageError::PlacedSize.to_string());
|
||||
}
|
||||
let (_, bytes) = pdfcraft_create::image_file(&editor.cos, picture)?;
|
||||
let image = SignatureImage::from_bytes(&bytes).map_err(|e| e.to_string())?;
|
||||
|
|
@ -90,13 +90,20 @@ pub fn upright_box(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Opti
|
|||
/// Saved signatures live in settings, so both source and normalized PNG are bounded.
|
||||
pub const MAX_SIGNATURE_IMAGE_BYTES: usize = 4 << 20;
|
||||
const MAX_PIXELS: u64 = 4 << 20;
|
||||
const MAX_SIDE: u32 = 4096;
|
||||
/// Larger sources (scans, phone photos) are decoded within these bounds, then scaled down.
|
||||
const MAX_SOURCE_SIDE: u32 = 8192;
|
||||
const MAX_SOURCE_PIXELS: u64 = 32 << 20;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SignatureImageError {
|
||||
#[error("Choose a PNG or JPEG image.")]
|
||||
Format,
|
||||
#[error("Choose an image smaller than 4 MiB and 4 megapixels (at most 4096 pixels per side).")]
|
||||
#[error("Choose an image smaller than 4 MiB and 32 megapixels (at most 8192 pixels per side).")]
|
||||
Size,
|
||||
/// An image already in the document, which was stored at 4 megapixels or less when placed.
|
||||
#[error("This signature image is larger than 4 megapixels (or 4096 pixels per side), so it can't be previewed.")]
|
||||
PlacedSize,
|
||||
#[error("Couldn't read the signature image: {0}")]
|
||||
Read(#[from] std::io::Error),
|
||||
#[error("Couldn't decode the signature image: {0}")]
|
||||
|
|
@ -129,18 +136,27 @@ impl SignatureImage {
|
|||
}
|
||||
let mut reader = ImageReader::with_format(Cursor::new(bytes), format);
|
||||
let mut limits = Limits::default();
|
||||
limits.max_image_width = Some(4096);
|
||||
limits.max_image_height = Some(4096);
|
||||
limits.max_alloc = Some(32 << 20);
|
||||
limits.max_image_width = Some(MAX_SOURCE_SIDE);
|
||||
limits.max_image_height = Some(MAX_SOURCE_SIDE);
|
||||
// 8-bit RGBA at the pixel cap; 16-bit sources that large hit this and report Size.
|
||||
limits.max_alloc = Some(MAX_SOURCE_PIXELS * 4);
|
||||
reader.limits(limits);
|
||||
let mut decoder = reader.into_decoder()?;
|
||||
let too_big = |e: image::ImageError| if matches!(e, image::ImageError::Limits(_)) { SignatureImageError::Size } else { e.into() };
|
||||
let mut decoder = reader.into_decoder().map_err(too_big)?;
|
||||
let (w, h) = decoder.dimensions();
|
||||
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS {
|
||||
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_SOURCE_PIXELS {
|
||||
return Err(SignatureImageError::Size);
|
||||
}
|
||||
let orientation = decoder.orientation()?;
|
||||
let mut image = DynamicImage::from_decoder(decoder)?;
|
||||
let mut image = DynamicImage::from_decoder(decoder).map_err(too_big)?;
|
||||
image.apply_orientation(orientation);
|
||||
let (w, h) = (f64::from(image.width()), f64::from(image.height()));
|
||||
let scale = (f64::from(MAX_SIDE) / w.max(h)).min((MAX_PIXELS as f64 / (w * h)).sqrt());
|
||||
if scale < 1.0 {
|
||||
let (nw, nh) = (((w * scale) as u32).max(1), ((h * scale) as u32).max(1));
|
||||
// `thumbnail` resamples in one integer pass, without the large float buffers `resize` keeps.
|
||||
image = image.thumbnail(nw, nh);
|
||||
}
|
||||
let rgba = image.into_rgba8();
|
||||
let size = [rgba.width() as usize, rgba.height() as usize];
|
||||
let mut png = Cursor::new(Vec::new());
|
||||
|
|
@ -164,6 +180,13 @@ impl SignatureImage {
|
|||
self.size
|
||||
}
|
||||
|
||||
/// This image as the graphic of a visible digital signature.
|
||||
pub fn graphic(&self) -> pdfcraft_sign::Graphic {
|
||||
// Sides are at most 4096 pixels; 0 would be refused by the signer, never drawn.
|
||||
let side = |v: usize| u32::try_from(v).unwrap_or(0);
|
||||
pdfcraft_sign::Graphic::Image { width: side(self.size[0]), height: side(self.size[1]), rgba: self.rgba.clone() }
|
||||
}
|
||||
|
||||
/// Make near-white paper transparent, keeping darker ink.
|
||||
pub fn remove_white_background(&self, cutoff: u8, feather: u8) -> Result<Self, SignatureImageError> {
|
||||
let [w, h] = self.size;
|
||||
|
|
@ -243,12 +266,23 @@ mod tests {
|
|||
assert!(SignatureImage::from_bytes(b"not an image").is_err());
|
||||
assert!(SignatureImage::from_bytes(b"\x89PNG\r\n\x1a\ntruncated").is_err());
|
||||
assert!(SignatureImage::from_bytes(&vec![0; MAX_SIGNATURE_IMAGE_BYTES + 1]).is_err());
|
||||
assert!(SignatureImage::from_bytes(&png(4097, 1)).is_err());
|
||||
assert!(SignatureImage::from_bytes(&png(4096, 1025)).is_err());
|
||||
assert!(matches!(SignatureImage::from_bytes(&png(8193, 1)), Err(SignatureImageError::Size)));
|
||||
assert!(matches!(SignatureImage::from_bytes(&png(8192, 4097)), Err(SignatureImageError::Size)));
|
||||
assert!(SignatureImage::read(std::io::repeat(0)).is_err());
|
||||
assert!(matches!(SignatureImage::from_bytes(b"GIF89a\x02\0\x02\0"), Err(SignatureImageError::Format)));
|
||||
}
|
||||
|
||||
/// #1075: a 6025 x 2506 scan is scaled down to fit 4096 per side and 4 megapixels.
|
||||
#[test]
|
||||
fn large_signature_images_are_scaled_down() {
|
||||
let image = SignatureImage::from_bytes(&png(6025, 2506)).unwrap();
|
||||
let [w, h] = image.size();
|
||||
assert!(w <= 4096 && (w * h) as u64 <= MAX_PIXELS, "{w} x {h}");
|
||||
assert!((w as f64 / h as f64 - 6025.0 / 2506.0).abs() < 0.01, "{w} x {h}");
|
||||
assert_eq!(SignatureImage::from_bytes(&png(4096, 1)).unwrap().size(), [4096, 1]);
|
||||
assert_eq!(SignatureImage::from_bytes(&png(8192, 1)).unwrap().size(), [4096, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_signatures_are_normalized_to_png() {
|
||||
let mut jpeg = Cursor::new(Vec::new());
|
||||
|
|
@ -323,7 +357,7 @@ mod tests {
|
|||
stream.dict.set(b"Width".to_vec(), pdfcraft_cos::Object::Int(i64::MAX));
|
||||
cos.set(picture, pdfcraft_cos::Object::Stream(stream));
|
||||
let error = doc.image_signature_preview(0, 0).err().unwrap();
|
||||
assert!(error.contains("4 megapixels"), "{error}");
|
||||
assert_eq!(error, SignatureImageError::PlacedSize.to_string());
|
||||
}
|
||||
|
||||
/// "Left edge at the point, centered vertically" is as displayed: on a turned page the box
|
||||
|
|
|
|||
|
|
@ -218,12 +218,12 @@ fn undo_and_redo_restore_exact_states() {
|
|||
s.apply(id, Edit::MovePages { pages: vec![2], to: 0 }).unwrap();
|
||||
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90);
|
||||
assert_eq!(s.undo(id).unwrap(), "Rotate page");
|
||||
assert_eq!(s.undo(id).unwrap().as_str(), "Rotate page");
|
||||
assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 0);
|
||||
assert_eq!(s.undo(id).unwrap(), "Move page");
|
||||
assert_eq!(s.undo(id).unwrap().as_str(), "Move page");
|
||||
assert_eq!(s.get(id).unwrap().bytes, original, "undoing everything returns the original bytes");
|
||||
assert_eq!(s.undo(id), Err(EditError::NothingToUndo));
|
||||
assert_eq!(s.redo(id).unwrap(), "Move page");
|
||||
assert_eq!(s.redo(id).unwrap().as_str(), "Move page");
|
||||
assert_eq!(page_texts(&s, id), ["Page 3", "Page 1", "Page 2"]);
|
||||
// A new edit clears the redo stack.
|
||||
s.apply(id, Edit::InsertBlankPage { at: 0, width: 612.0, height: 792.0 }).unwrap();
|
||||
|
|
@ -396,7 +396,7 @@ fn batch_is_one_undo_step_and_all_or_nothing() {
|
|||
};
|
||||
s.apply(id, batch).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().info_value("Author").as_deref(), Some("A"));
|
||||
assert_eq!(s.undo(id).unwrap(), "Change properties");
|
||||
assert_eq!(s.undo(id).unwrap().as_str(), "Change properties");
|
||||
assert_eq!(s.get(id).unwrap().info_value("Title"), None);
|
||||
assert_eq!(s.get(id).unwrap().can_undo(), None, "one step");
|
||||
// A failing member rolls back the whole batch.
|
||||
|
|
@ -432,7 +432,7 @@ fn combine_extract_split_and_insert_from_file() {
|
|||
// Insert pages from a file, undoably, into the open document.
|
||||
s.apply(id, Edit::InsertPagesFrom { name: "b.pdf".into(), bytes: other, pages: Some(vec![1]), at: 1 }).unwrap();
|
||||
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2", "Page 3"]);
|
||||
assert_eq!(s.undo(id).unwrap(), "Insert pages from b.pdf");
|
||||
assert_eq!(s.undo(id).unwrap().as_str(), "Insert pages from b.pdf");
|
||||
assert_eq!(page_texts(&s, id).len(), 3);
|
||||
// Garbage sources fail cleanly.
|
||||
let bad = s.apply(id, Edit::InsertPagesFrom { name: "junk.pdf".into(), bytes: Arc::new(b"nope".to_vec()), pages: None, at: 0 });
|
||||
|
|
@ -504,6 +504,43 @@ fn a_file_split_around_another_combines_in_order_with_one_bookmark() {
|
|||
assert!(matches!(s.combine_grouped(&bad, &[1], &[]), Err(EditError::Print(e)) if e.starts_with("a:")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_multiple_reads_each_source_only_after_the_previous_one_is_released() {
|
||||
let mut s = Session::new();
|
||||
let mut earlier: Vec<std::sync::Weak<Vec<u8>>> = Vec::new();
|
||||
let texts = ["Alpha", "Beta", "Gamma"];
|
||||
let sources = texts.iter().map(|text| {
|
||||
// The combine has finished with every earlier source, so their bytes are gone by now.
|
||||
assert!(earlier.iter().all(|w| w.upgrade().is_none()), "a source is released before the next is read");
|
||||
let (_, pdf) = s.convert_to_pdf(&format!("{text}.txt"), &Arc::new(text.as_bytes().to_vec())).unwrap();
|
||||
earlier.push(Arc::downgrade(&pdf));
|
||||
Ok::<CombineSource, EditError>((text.to_string(), pdf, None))
|
||||
});
|
||||
let combined = s.combine_each(sources).unwrap().unwrap();
|
||||
assert!(earlier.iter().all(|w| w.upgrade().is_none()));
|
||||
let id = s.open_new("Combined.pdf", combined).unwrap();
|
||||
assert_eq!(page_texts(&s, id), ["Alpha", "Beta", "Gamma"]);
|
||||
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Alpha→1", "Beta→2", "Gamma→3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_multiple_gives_the_bytes_of_combine_ranges_and_refuses_a_bad_source() {
|
||||
let s = Session::new();
|
||||
let (a, b) = (Arc::new(fixture(3)), Arc::new(fixture(2)));
|
||||
let sources: Vec<CombineSource> =
|
||||
vec![("a".into(), a.clone(), Some("1-2".into())), ("b".into(), b, None), ("a".into(), a.clone(), Some("3".into()))];
|
||||
let by_ranges = s.combine_ranges(&sources).unwrap();
|
||||
let streamed = s.combine_each(sources.into_iter().map(Ok::<_, EditError>)).unwrap().unwrap();
|
||||
assert_eq!(*streamed, *by_ranges);
|
||||
// A bad range, or a file that isn't a PDF, stops the combine with an error naming that source.
|
||||
let bad_range: Vec<Result<CombineSource, EditError>> = vec![Ok(("a".into(), a.clone(), None)), Ok(("c".into(), a.clone(), Some("9".into())))];
|
||||
assert!(matches!(s.combine_each(bad_range), Err(EditError::Print(e)) if e.starts_with("c:")));
|
||||
let junk: Vec<Result<CombineSource, EditError>> = vec![Ok(("junk".into(), Arc::new(b"not a pdf".to_vec()), None))];
|
||||
assert!(matches!(s.combine_each(junk), Err(EditError::Source(m)) if m.starts_with("junk:")));
|
||||
// Nothing to combine is `None`, not an empty document.
|
||||
assert!(matches!(s.combine_each(Vec::<Result<CombineSource, EditError>>::new()), Ok(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_with_a_form_field_shown_twice_keeps_one_field() {
|
||||
// Regression (found by review): the repeated page copied the whole field again, so the
|
||||
|
|
@ -578,6 +615,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<Vec<u8>> {
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(2))).unwrap();
|
||||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
|
|
@ -629,6 +681,78 @@ fn restricted_documents_refuse_changes_unless_opened_by_the_owner() {
|
|||
s.apply(oid, Edit::DeletePages { pages: vec![0] }).unwrap();
|
||||
}
|
||||
|
||||
/// Table 22, bit 5: "Copy or otherwise extract text and graphics" (#568). Snapshots, exports and
|
||||
/// Save Image As are refused, other permissions are untouched, and the owner password lifts it.
|
||||
#[test]
|
||||
fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() {
|
||||
let bytes = protected("", "owner", -1 ^ 0b1_0000); // everything but copying, no password to open
|
||||
let mut s = Session::new();
|
||||
let id = s.open("r.pdf", None, bytes.clone(), None).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
assert!(!d.allows_copying());
|
||||
assert!(d.allows_printing() && d.allows_modification() && d.allows_assembly(), "only copying is withheld");
|
||||
assert_eq!(d.check_copying(), Err(EditError::NotPermitted("copying text and images")));
|
||||
assert!(d.page_image_file(0, 0).unwrap_err().contains("don't allow copying"));
|
||||
let extracting = ["edit.snapshot", "export.image", "export.text", "export.docx", "export.html", "export.rtf", "export.all_images"];
|
||||
let enabled = |s: &Session, id, c| commands::is_enabled(commands::command(c).unwrap(), s, Some(id));
|
||||
for c in extracting {
|
||||
assert!(!enabled(&s, id, c), "{c} is disabled");
|
||||
}
|
||||
assert!(enabled(&s, id, "edit.find") && enabled(&s, id, "a11y.check"), "searching and accessibility don't copy");
|
||||
let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap();
|
||||
assert!(s.get(oid).unwrap().allows_copying());
|
||||
assert!(extracting.iter().all(|c| enabled(&s, oid, c)));
|
||||
// Unencrypted documents allow everything.
|
||||
let (s, plain) = session_with(1);
|
||||
assert!(s.get(plain).unwrap().check_copying().is_ok() && extracting.iter().all(|c| enabled(&s, plain, c)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_permissions_password_unlocks_an_open_document() {
|
||||
// Only a permissions password (#995, #1077): opens without asking. Changes are allowed,
|
||||
// security changes aren't.
|
||||
let bytes = protected("", "owner", 0b1100);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("r.pdf", None, bytes, None).unwrap();
|
||||
s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Before".into() }).unwrap();
|
||||
assert!(!s.get(id).unwrap().allows_security_change());
|
||||
assert!(matches!(s.apply(id, Edit::RemoveProtection), Err(EditError::NotPermitted(_))));
|
||||
for wrong in ["", "nope"] {
|
||||
assert_eq!(s.unlock(id, wrong), Err(EditError::WrongPassword), "{wrong:?}");
|
||||
}
|
||||
assert!(!s.get(id).unwrap().allows_security_change());
|
||||
s.unlock(id, "owner").unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
assert!(d.allows_security_change() && d.security_summary().unwrap().owner);
|
||||
assert!(d.dirty, "unlocking leaves the unsaved edit alone");
|
||||
assert_eq!(d.can_undo(), Some("Change Title"), "and adds no undo step");
|
||||
// Undo and redo don't bring the restrictions back.
|
||||
s.undo(id).unwrap();
|
||||
assert!(s.get(id).unwrap().allows_security_change(), "after undo");
|
||||
s.redo(id).unwrap();
|
||||
assert!(s.get(id).unwrap().allows_security_change(), "after redo");
|
||||
// Neither does saving.
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
s.mark_saved(id, saved, None).unwrap();
|
||||
assert!(s.get(id).unwrap().allows_security_change(), "after saving");
|
||||
s.apply(id, Edit::RemoveProtection).unwrap();
|
||||
let plain = s.save_bytes(id).unwrap();
|
||||
assert!(pdfcraft_cos::Document::open(plain).unwrap().security().is_none(), "security removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlocking_needs_the_permissions_password_not_the_open_password() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("r.pdf", None, protected("open", "perms", 0b0100), Some("open")).unwrap();
|
||||
assert_eq!(s.unlock(id, "open"), Err(EditError::WrongPassword));
|
||||
s.unlock(id, "perms").unwrap();
|
||||
assert!(s.get(id).unwrap().allows_security_change());
|
||||
// Nothing to unlock: opened by the owner already, or not encrypted.
|
||||
s.unlock(id, "anything").unwrap();
|
||||
let plain = s.open("p.pdf", None, Arc::new(fixture(1)), None).unwrap();
|
||||
s.unlock(plain, "anything").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combining_protected_files_is_refused_clearly() {
|
||||
let s = Session::new();
|
||||
|
|
@ -1115,6 +1239,37 @@ fn form_edits_refresh_field_values_without_a_full_inspection() {
|
|||
assert_eq!(values(&d.info.fields), [("name".to_string(), Some("Ada".to_string())), ("ok".to_string(), Some("Yes".to_string()))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_refresh_keeps_first_value_when_field_names_repeat() {
|
||||
// Two widgets share one name; the refresh reads the first.
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V (first) /Rect [20 250 180 270] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V (second) /Rect [20 220 180 240] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (other) /Rect [20 190 180 210] /P 3 0 R >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
let mut s = Session::new();
|
||||
let id = s.open("dup.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
s.apply(id, Edit::SetFieldValue { name: "other".into(), value: FieldValue::Text("x".into()) }).unwrap();
|
||||
let dup = s.get(id).unwrap().info.fields.iter().find(|f| f.name == "dup").unwrap().value.clone();
|
||||
assert_eq!(dup.as_deref(), Some("first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preparing_a_form_adds_renames_and_deletes_fields() {
|
||||
let (mut s, id) = session_with(1);
|
||||
|
|
@ -1249,6 +1404,56 @@ fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() {
|
|||
assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove background"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_populates_marks_and_added_from_the_combined_marker_scan() {
|
||||
let (mut source, source_id) = session_with(1);
|
||||
let mut header = HeaderFooter::default();
|
||||
header.text[0] = "Header".into();
|
||||
source.apply(source_id, Edit::AddHeaderFooter { pages: vec![0], settings: header, replace: false }).unwrap();
|
||||
source
|
||||
.apply(
|
||||
source_id,
|
||||
Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "Draft".into(), ..Watermark::default() }, file: None, replace: false },
|
||||
)
|
||||
.unwrap();
|
||||
source
|
||||
.apply(
|
||||
source_id,
|
||||
Edit::AddBackground {
|
||||
pages: vec![0],
|
||||
settings: Background { color: [0.9, 0.9, 1.0], opacity: 1.0, ..Background::default() },
|
||||
replace: false,
|
||||
file: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
source
|
||||
.apply(
|
||||
source_id,
|
||||
Edit::AddText { page: 0, text: AddedText { rect: [20.0, 250.0, 180.0, 280.0], text: "Added note".into(), ..Default::default() } },
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let bytes = source.save_bytes(source_id).unwrap();
|
||||
let reference = pdfcraft_cos::Document::open(bytes.clone()).unwrap();
|
||||
let expected_marks = pdfcraft_edit::marks_present(&reference);
|
||||
let expected_added = pdfcraft_edit::list_added(&reference);
|
||||
source.mark_saved(source_id, bytes.clone(), None).unwrap();
|
||||
let refreshed = source.get(source_id).unwrap();
|
||||
assert_eq!(refreshed.marks, expected_marks);
|
||||
assert_eq!(refreshed.added, expected_added);
|
||||
let mut reopened = Session::new();
|
||||
let reopened_id = reopened.open("marked.pdf", None, bytes, None).unwrap();
|
||||
let opened = reopened.get(reopened_id).unwrap();
|
||||
assert_eq!(opened.marks, expected_marks);
|
||||
assert_eq!(opened.added, expected_added);
|
||||
assert_eq!(opened.marks.len(), 3);
|
||||
assert!(opened.marks.contains(&MarkKind::HeaderFooter));
|
||||
assert!(opened.marks.contains(&MarkKind::Watermark));
|
||||
assert!(opened.marks.contains(&MarkKind::Background));
|
||||
assert_eq!(opened.added.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_images_and_text_follow_the_working_file() {
|
||||
let (mut s, id) = session_with(2);
|
||||
|
|
@ -1534,6 +1739,25 @@ fn added_images_rotate_flip_and_crop_as_drawn() {
|
|||
assert!(red(colour_at(&s, 130, 150)), "the right half cropped away: red fills the box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_reads_the_new_file_into_the_same_document() {
|
||||
let (mut s, id) = session_with(2);
|
||||
let other = s.open("other.pdf", None, Arc::new(fixture(1)), None).unwrap();
|
||||
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
|
||||
let before = s.get(id).unwrap().edit_generation();
|
||||
s.reload(id, Arc::new(fixture(3))).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
assert_eq!((d.id, d.name.as_str()), (id, "fixture.pdf"), "same document, same name");
|
||||
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 3"], "the new version");
|
||||
assert!(!d.dirty && d.can_undo().is_none(), "nothing unsaved, nothing to undo");
|
||||
assert!(d.edit_generation() > before, "caches keyed by the generation see a change");
|
||||
assert_eq!(s.docs().iter().map(|d| d.id).collect::<Vec<_>>(), [id, other], "no new tab, same order");
|
||||
// A file that isn't a PDF (yet) leaves the document as it was.
|
||||
assert!(s.reload(id, Arc::new(b"%PDF-1.7 half written".to_vec())).is_err());
|
||||
assert_eq!(page_texts(&s, id).len(), 3);
|
||||
assert_eq!(s.docs().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_goes_back_to_the_saved_version() {
|
||||
let (mut s, id) = session_with(2);
|
||||
|
|
@ -1687,7 +1911,8 @@ fn signing_saving_trusting_and_commenting_afterwards() {
|
|||
assert_eq!(s.get(id).unwrap().signatures[0].status, SignatureStatus::Valid);
|
||||
s.apply(id, rect_comment(0, [80.0, 80.0, 120.0, 120.0])).unwrap();
|
||||
let sig = &s.get(id).unwrap().signatures[0];
|
||||
assert_eq!((sig.status, sig.modification.clone()), (SignatureStatus::Valid, sign::Modification::Allowed(vec!["comments".into()])));
|
||||
let comments = sign::Modification::Allowed(vec![sign::kind::COMMENTS]);
|
||||
assert_eq!((sig.status, sig.modification.clone()), (SignatureStatus::Valid, comments));
|
||||
// Saving keeps the signature (incremental).
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
s.mark_saved(id, saved, None).unwrap();
|
||||
|
|
@ -2211,6 +2436,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("<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"></xdp:xdp>"));
|
||||
|
|
@ -2366,6 +2610,8 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
|
|||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("scripted.pdf", None, bytes, None).expect("opens");
|
||||
let add_row = s.get(id).unwrap().form.iter().find(|f| f.name == "addRow").expect("the XFA button is in the form snapshot");
|
||||
assert!(matches!(add_row.button.as_ref(), Some(pdfcraft_forms::af::ButtonAction::Script(_))));
|
||||
let value = |s: &Session, n: &str| {
|
||||
s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_else(|| panic!("no field {n}"))
|
||||
};
|
||||
|
|
@ -2794,7 +3040,7 @@ fn adopting_page_only_fields_is_noted_as_a_repair() {
|
|||
let doc = s.get(id).unwrap();
|
||||
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(names, ["alpha", "beta"]);
|
||||
assert!(doc.repair_log().iter().any(|l| l.contains("2 fields") && l.contains("page annotations")), "{:?}", doc.repair_log());
|
||||
assert_eq!(doc.repair_log(), vec!["the form's /Fields list omits 2 fields; they were read from the page annotations".to_string()]);
|
||||
}
|
||||
|
||||
/// A page too large for the renderer at the asked resolution exports at the most it allows (and
|
||||
|
|
@ -2849,7 +3095,7 @@ fn mixed_object_moves_are_one_undo_step_and_survive_save_reopen() {
|
|||
let mut again = Session::new();
|
||||
let other = again.open("moved.pdf", None, moved.clone(), None).unwrap();
|
||||
assert_eq!(again.get(other).unwrap().editable_objects(0).unwrap(), after);
|
||||
assert_eq!(s.undo(id).unwrap(), "Move objects");
|
||||
assert_eq!(s.undo(id).unwrap().as_str(), "Move objects");
|
||||
assert_eq!(s.get(id).unwrap().editable_objects(0).unwrap(), before);
|
||||
assert_eq!(s.get(id).unwrap().can_undo(), Some("Add image"), "one undo restores all three objects");
|
||||
s.redo(id).unwrap();
|
||||
|
|
@ -2990,3 +3236,128 @@ fn recovered_user_passwords_reach_the_renderer_byte_for_byte() {
|
|||
assert_eq!(crate::renderer_password(&[0x9A, b'l', 0x9A, b'k', 0xA0]), "ılık€");
|
||||
assert_eq!(crate::renderer_password(&[0xC7, b'o', b'k']), "Çok");
|
||||
}
|
||||
|
||||
/// A synthetic phone photo, 400 × 300 px: a white sheet, tilted, on a grey table, with a black
|
||||
/// mark in the sheet's top-left corner. Returns the PNG and the sheet's corners in pixels.
|
||||
fn camera_photo() -> (Vec<u8>, [[f64; 2]; 4]) {
|
||||
let sheet = [[60.0, 40.0], [330.0, 20.0], [350.0, 270.0], [40.0, 250.0]];
|
||||
let inside = |x: f64, y: f64| {
|
||||
(0..4).all(|i| {
|
||||
let (a, b) = (sheet[i], sheet[(i + 1) % 4]);
|
||||
(b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]) >= 0.0
|
||||
})
|
||||
};
|
||||
let img = image::RgbImage::from_fn(400, 300, |x, y| {
|
||||
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
if (75.0..95.0).contains(&fx) && (55.0..75.0).contains(&fy) {
|
||||
image::Rgb([0, 0, 0])
|
||||
} else if inside(fx, fy) {
|
||||
image::Rgb([255, 255, 255])
|
||||
} else {
|
||||
image::Rgb([110, 110, 110])
|
||||
}
|
||||
});
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
(png, sheet)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enhance_camera_page_straightens_the_sheet() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let (png, sheet) = camera_photo();
|
||||
// 72 dpi: one point per pixel, so the page is 400 × 300 pt and y flips to user space.
|
||||
let bytes = s.create_from_images_with_resolution(&[("photo.png".into(), png)], ImageResolution::Dpi(72.0)).unwrap();
|
||||
let id = s.open("photo.pdf", None, bytes, None).unwrap();
|
||||
let corners = sheet.map(|[x, y]| [x, 300.0 - y]);
|
||||
// Corners in any order.
|
||||
s.enhance_camera_page(id, 0, [corners[2], corners[0], corners[3], corners[1]]).unwrap();
|
||||
|
||||
let doc = s.get(id).unwrap();
|
||||
assert_eq!(doc.info.pages.len(), 1);
|
||||
assert_eq!(doc.can_undo(), Some("Enhance camera image"));
|
||||
// The longer of each pair of opposite sides: 310 × 250 pt at the photo's own resolution.
|
||||
let p = &doc.info.pages[0];
|
||||
assert!((p.width - 310.0).abs() <= 1.0 && (p.height - 250.0).abs() <= 1.0, "{} × {}", p.width, p.height);
|
||||
|
||||
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
|
||||
let shot = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(shot.error.is_none(), "{:?}", shot.error);
|
||||
let at = |x: u32, y: u32| {
|
||||
let i = ((y * shot.width + x) * 4) as usize;
|
||||
shot.rgba[i..i + 3].to_vec()
|
||||
};
|
||||
// No table left in the corners: the sheet fills the page, and its mark is top left.
|
||||
for (x, y) in [(4, 4), (shot.width - 5, 4), (shot.width - 5, shot.height - 5), (4, shot.height - 5), (shot.width / 2, shot.height / 2)] {
|
||||
assert!(at(x, y).iter().all(|&v| v > 235), "({x}, {y}) = {:?}", at(x, y));
|
||||
}
|
||||
assert!(at(30, 30).iter().all(|&v| v < 40), "the mark: {:?}", at(30, 30));
|
||||
|
||||
s.undo(id).unwrap();
|
||||
let p = &s.get(id).unwrap().info.pages[0];
|
||||
assert_eq!((p.width, p.height), (400.0, 300.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_camera_corners_finds_the_sheet_in_user_space() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let (png, sheet) = camera_photo();
|
||||
let bytes = s.create_from_images_with_resolution(&[("photo.png".into(), png)], ImageResolution::Dpi(72.0)).unwrap();
|
||||
let id = s.open("photo.pdf", None, bytes, None).unwrap();
|
||||
let found = s.detect_camera_corners(id, 0).expect("the sheet's edges");
|
||||
// One point per pixel, y flipped: the sheet's corners in reading order.
|
||||
for (f, p) in found.iter().zip(sheet.iter()) {
|
||||
let want = [p[0], 300.0 - p[1]];
|
||||
assert!((f[0] - want[0]).hypot(f[1] - want[1]) < 4.0, "{found:?} vs {sheet:?}");
|
||||
}
|
||||
// A page with no photo on it: nothing to find.
|
||||
let id = s.open("doc.pdf", None, Arc::new(fixture(1)), None).unwrap();
|
||||
assert_eq!(s.detect_camera_corners(id, 0), None);
|
||||
assert_eq!(s.detect_camera_corners(id, 7), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enhance_camera_page_at_a_level_whitens_a_shadowed_sheet() {
|
||||
// A sheet lit unevenly: full light at the left, a third of it at the right.
|
||||
let img = image::RgbImage::from_fn(400, 300, |x, y| {
|
||||
let light = 1.0 - 0.66 * x as f32 / 400.0;
|
||||
let ink = if (y / 10) % 3 == 1 && (40..360).contains(&x) { 0.12 } else { 0.96 };
|
||||
let v = (ink * light * 255.0) as u8;
|
||||
image::Rgb([v, v, (v as f32 * 0.85) as u8])
|
||||
});
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let whole = [[0.0, 0.0], [400.0, 0.0], [400.0, 300.0], [0.0, 300.0]];
|
||||
let paper_at_right = |level: u8| {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let bytes = s.create_from_images_with_resolution(&[("photo.png".into(), png.clone())], ImageResolution::Dpi(72.0)).unwrap();
|
||||
let id = s.open("photo.pdf", None, bytes, None).unwrap();
|
||||
s.enhance_camera_page_at(id, 0, whole, camera_clean::Look::Colour, level).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().can_undo(), Some("Enhance camera image"), "one step at level {level}");
|
||||
let doc = s.get(id).unwrap();
|
||||
let shot = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()).render(pdfcraft_render::RenderRequest {
|
||||
page: 0,
|
||||
scale: 1.0,
|
||||
..Default::default()
|
||||
});
|
||||
// A paper pixel near the right edge, between text rows.
|
||||
let i = ((5 * shot.width + shot.width - 12) * 4) as usize;
|
||||
shot.rgba[i..i + 3].to_vec()
|
||||
};
|
||||
let (before, after) = (paper_at_right(0), paper_at_right(80));
|
||||
assert!(before.iter().all(|&v| v < 140), "the shadow: {before:?}");
|
||||
assert!(after.iter().all(|&v| v > 235), "white paper: {after:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enhance_camera_page_refuses_bad_corners_and_pages() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("doc.pdf", None, Arc::new(fixture(1)), None).unwrap();
|
||||
let line = [[0.0, 0.0], [50.0, 50.0], [100.0, 100.0], [150.0, 150.0]];
|
||||
let e = s.enhance_camera_page(id, 0, line).unwrap_err().to_string();
|
||||
assert!(e.contains("corners"), "{e}");
|
||||
let ok = [[0.0, 0.0], [200.0, 0.0], [200.0, 300.0], [0.0, 300.0]];
|
||||
assert!(s.enhance_camera_page(id, 5, ok).unwrap_err().to_string().contains("no page 6"));
|
||||
assert!(matches!(s.enhance_camera_page(DocId(999), 0, ok), Err(EditError::NoDocument)));
|
||||
assert_eq!(s.get(id).unwrap().can_undo(), None, "nothing changed");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ pub use craft::{
|
|||
CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_japanese_font, document_japanese_font_for_style,
|
||||
document_japanese_fonts_for_style, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_japanese_fonts, ui_telugu_fonts,
|
||||
};
|
||||
pub use script::{GlyphError, GlyphOutline, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_outline};
|
||||
pub use script::{GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_outline};
|
||||
|
||||
/// Approximate advance of `s` in Helvetica (or Arial) at `size` points.
|
||||
pub fn helvetica_width(s: &str, size: f64) -> f64 {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ use skrifa::{FontRef, GlyphId, MetadataProvider};
|
|||
|
||||
static FONT: &[u8] = include_bytes!("../../../assets/fonts/DancingScript.ttf");
|
||||
|
||||
/// Inter Regular, the interface font. Typed signatures fall back to it, slanted, for characters
|
||||
/// the script font lacks (Dancing Script is Latin and Vietnamese only: no Cyrillic or Greek).
|
||||
pub static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
|
||||
|
||||
/// Forward slant of fallback glyphs in a typed signature (about 12°), so they lean like the script.
|
||||
const FALLBACK_SLANT: f64 = 0.21;
|
||||
|
||||
/// Bound signature work while allowing long personal names.
|
||||
pub const MAX_SIGNATURE_CHARS: usize = 256;
|
||||
|
||||
|
|
@ -51,6 +58,8 @@ struct Flatten {
|
|||
contours: Vec<Vec<[f64; 2]>>,
|
||||
cur: Vec<[f64; 2]>,
|
||||
scale: f64,
|
||||
/// Horizontal shear applied to every point (x += slant × y), for slanted fallback glyphs.
|
||||
slant: f64,
|
||||
dx: f64,
|
||||
points: usize,
|
||||
too_complex: bool,
|
||||
|
|
@ -59,11 +68,12 @@ struct Flatten {
|
|||
|
||||
impl Flatten {
|
||||
fn new(scale: f64) -> Self {
|
||||
Self { contours: Vec::new(), cur: Vec::new(), scale, dx: 0.0, points: 0, too_complex: false, max_points: 4096 }
|
||||
Self { contours: Vec::new(), cur: Vec::new(), scale, slant: 0.0, dx: 0.0, points: 0, too_complex: false, max_points: 4096 }
|
||||
}
|
||||
|
||||
fn pt(&self, x: f32, y: f32) -> [f64; 2] {
|
||||
[self.dx + x as f64 * self.scale, y as f64 * self.scale]
|
||||
let (x, y) = (x as f64 * self.scale, y as f64 * self.scale);
|
||||
[self.dx + x + self.slant * y, y]
|
||||
}
|
||||
|
||||
fn last(&self) -> [f64; 2] {
|
||||
|
|
@ -178,8 +188,9 @@ pub(crate) fn bounded_outline(font: &FontRef, gid: GlyphId, width: f64) -> Resul
|
|||
Ok(GlyphOutline { contours: pen.contours, width, bbox })
|
||||
}
|
||||
|
||||
/// The outlines of `text` in the script font (characters it lacks are skipped).
|
||||
/// Over-limit input returns an empty outline instead of a silently truncated signature.
|
||||
/// The outlines of `text` in the script font. Characters it lacks (Cyrillic, Greek, …) are drawn
|
||||
/// from Inter, slanted and scaled to the script's capital height; characters neither font has are
|
||||
/// skipped. Over-limit input returns an empty outline instead of a silently truncated signature.
|
||||
pub fn script_outline(text: &str) -> ScriptOutline {
|
||||
if text.chars().take(MAX_SIGNATURE_CHARS + 1).count() > MAX_SIGNATURE_CHARS {
|
||||
return ScriptOutline::default();
|
||||
|
|
@ -191,17 +202,39 @@ pub fn script_outline(text: &str) -> ScriptOutline {
|
|||
let charmap = font.charmap();
|
||||
let glyphs = font.outline_glyphs();
|
||||
let advances = font.glyph_metrics(Size::unscaled(), loc);
|
||||
// The fallback face, scaled so its capitals are as tall as the script's.
|
||||
let fallback = FontRef::new(INTER_REGULAR).ok().map(|f| {
|
||||
let m = f.metrics(Size::unscaled(), loc);
|
||||
let em = 1.0 / m.units_per_em.max(1) as f64;
|
||||
let ratio = match (metrics.cap_height, m.cap_height) {
|
||||
(Some(ours), Some(theirs)) if ours > 0.0 && theirs > 0.0 => (ours as f64 * scale) / (theirs as f64 * em),
|
||||
_ => 1.0,
|
||||
};
|
||||
let ratio = if ratio.is_finite() { ratio.clamp(0.5, 2.0) } else { 1.0 };
|
||||
(f.charmap(), f.outline_glyphs(), f.glyph_metrics(Size::unscaled(), loc), em * ratio)
|
||||
});
|
||||
let mut pen = Flatten::new(scale);
|
||||
// The 4096-point budget is for one untrusted document glyph (japanese_glyph), not a
|
||||
// whole signature in our bundled font. Keep a separate bounded budget for the line.
|
||||
pen.max_points = 262_144;
|
||||
for ch in text.chars() {
|
||||
let Some(gid) = charmap.map(ch) else { continue };
|
||||
if let Some(g) = glyphs.get(gid) {
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), loc), &mut pen);
|
||||
pen.close();
|
||||
if let Some(gid) = charmap.map(ch) {
|
||||
(pen.scale, pen.slant) = (scale, 0.0);
|
||||
if let Some(g) = glyphs.get(gid) {
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), loc), &mut pen);
|
||||
pen.close();
|
||||
}
|
||||
pen.dx += advances.advance_width(gid).unwrap_or(0.0) as f64 * scale;
|
||||
} else if let Some((map, outlines, adv, em)) = &fallback
|
||||
&& let Some(gid) = map.map(ch)
|
||||
{
|
||||
(pen.scale, pen.slant) = (*em, FALLBACK_SLANT);
|
||||
if let Some(g) = outlines.get(gid) {
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), loc), &mut pen);
|
||||
pen.close();
|
||||
}
|
||||
pen.dx += adv.advance_width(gid).unwrap_or(0.0) as f64 * em;
|
||||
}
|
||||
pen.dx += advances.advance_width(gid).unwrap_or(0.0) as f64 * scale;
|
||||
}
|
||||
pen.close();
|
||||
if pen.too_complex {
|
||||
|
|
@ -224,6 +257,30 @@ mod tests {
|
|||
assert!(super::script_outline(&"W".repeat(super::MAX_SIGNATURE_CHARS + 1)).contours.is_empty());
|
||||
}
|
||||
|
||||
/// Issue #846: Cyrillic (and Greek) names come out of the fallback face instead of vanishing.
|
||||
#[test]
|
||||
fn cyrillic_and_greek_names_are_drawn_from_the_fallback_face() {
|
||||
for name in ["Саша Дермановић", "Љубица Ђорђевић", "Ђурђа Џаковић", "Νίκος"] {
|
||||
let o = super::script_outline(name);
|
||||
let letters = name.chars().filter(|c| !c.is_whitespace()).count();
|
||||
assert!(o.contours.len() >= letters, "{name}: {} contours for {letters} letters", o.contours.len());
|
||||
for ch in name.chars().filter(|c| !c.is_whitespace()) {
|
||||
let one = super::script_outline(&ch.to_string());
|
||||
assert!(!one.contours.is_empty() && one.width > 0.0, "{name}: {ch} has no outline");
|
||||
}
|
||||
let [_, bottom, right, top] = o.bounds();
|
||||
assert!(right <= o.width + 0.5 && top <= o.ascent + 0.2 && bottom >= o.descent - 0.2, "{name}: {:?}", o.bounds());
|
||||
}
|
||||
// Capitals from the fallback are about as tall as the script's.
|
||||
let cap = |c: &str| super::script_outline(c).contours.iter().flatten().map(|p| p[1]).fold(0.0, f64::max);
|
||||
let (latin, cyrillic) = (cap("H"), cap("Н"));
|
||||
assert!((cyrillic / latin - 1.0).abs() < 0.2, "Н {cyrillic} vs H {latin}");
|
||||
// Mixed names keep every letter, in order: the Cyrillic follows the Latin.
|
||||
let mixed = super::script_outline("Ana Ана");
|
||||
let latin_only = super::script_outline("Ana ");
|
||||
assert!(mixed.width > latin_only.width + 0.5, "{} vs {}", mixed.width, latin_only.width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_becomes_outlines() {
|
||||
let o = super::script_outline("Ada L.");
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ Interactive forms (AcroForm, ISO 32000-2 §12.7). Layer L3; depends on `pdfcraft
|
|||
|
||||
```rust
|
||||
let all: Vec<Field> = fields(&doc); // terminal fields with widgets, options, flags, /DA
|
||||
let snapshot = fields_with_adopted(&doc); // fields and page-only adoption count in one pass
|
||||
let all = snapshot.items;
|
||||
let adopted = snapshot.adopted_page_fields;
|
||||
set_value(&mut doc, "name", &FieldValue::Text("Ada".into()))?;
|
||||
set_value(&mut doc, "agree", &FieldValue::Check(true))?;
|
||||
set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into())))?;
|
||||
|
|
|
|||
|
|
@ -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<Metrics>),
|
||||
/// A composite font with a predefined Unicode CMap.
|
||||
Unicode(UnicodeCMap),
|
||||
}
|
||||
|
||||
impl FontText {
|
||||
fn encode(self, s: &str) -> Vec<u8> {
|
||||
fn encode(&self, s: &str) -> Vec<u8> {
|
||||
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::<Vec<_>>().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::<Vec<_>>().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 {
|
||||
|
|
|
|||
|
|
@ -487,13 +487,28 @@ struct Inherited {
|
|||
/// Every terminal field: the `/Fields` tree in order, then the fields reachable only through the
|
||||
/// page annotations (see [`adopt_page_fields`]).
|
||||
pub fn fields(doc: &Document) -> Vec<Field> {
|
||||
enumerate(doc).0
|
||||
fields_with_adopted(doc).items
|
||||
}
|
||||
|
||||
/// How many of [`fields`] were reachable only through the page annotations: the form's `/Fields`
|
||||
/// list names none or only some of them, so the leniency that adopts them is worth recording.
|
||||
pub fn adopted_page_fields(doc: &Document) -> usize {
|
||||
enumerate(doc).1
|
||||
fields_with_adopted(doc).adopted_page_fields
|
||||
}
|
||||
|
||||
/// The terminal fields and page-only fields adopted while reading a form.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Fields {
|
||||
/// Terminal fields in tree order, followed by adopted page-only fields.
|
||||
pub items: Vec<Field>,
|
||||
/// How many fields were reachable only through page annotations.
|
||||
pub adopted_page_fields: usize,
|
||||
}
|
||||
|
||||
/// Read all terminal fields and the page-only adoption count in one enumeration.
|
||||
pub fn fields_with_adopted(doc: &Document) -> Fields {
|
||||
let (items, adopted_page_fields) = enumerate(doc);
|
||||
Fields { items, adopted_page_fields }
|
||||
}
|
||||
|
||||
fn enumerate(doc: &Document) -> (Vec<Field>, usize) {
|
||||
|
|
@ -846,7 +861,7 @@ pub fn recalculate(doc: &mut Document) -> Result<usize, FormError> {
|
|||
|
||||
/// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`.
|
||||
pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result<usize, FormError> {
|
||||
let all = fields(doc);
|
||||
let mut all = fields(doc);
|
||||
let calculated = |f: &Field| f.actions.calculate != af::Calculate::None || f.actions.scripts.calculate.is_some();
|
||||
if !all.iter().any(calculated) {
|
||||
return Ok(0);
|
||||
|
|
@ -865,17 +880,18 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result
|
|||
}
|
||||
let mut changed = 0;
|
||||
for name in order {
|
||||
let now = fields(doc);
|
||||
let Some(f) = now.iter().find(|f| f.name == name) else { continue };
|
||||
let Some(f) = all.iter().find(|f| f.name == name).cloned() else { continue };
|
||||
let lookup = |n: &str| -> Vec<String> {
|
||||
let prefix = format!("{n}.");
|
||||
now.iter().filter(|x| x.name == n || x.name.starts_with(&prefix)).flat_map(|x| x.value.first().cloned()).collect()
|
||||
all.iter().filter(|x| x.name == n || x.name.starts_with(&prefix)).flat_map(|x| x.value.first().cloned()).collect()
|
||||
};
|
||||
let current = f.value.first().cloned().unwrap_or_default();
|
||||
let v = if let Some(js) = &f.actions.scripts.calculate {
|
||||
let f = f.clone();
|
||||
let r = scripts.run(FieldEvent::Calculate, js, &f, ¤t, &now);
|
||||
let r = scripts.run(FieldEvent::Calculate, js, &f, ¤t, &all);
|
||||
scripting::apply_changes(doc, &r.changes, &f.name)?;
|
||||
if !r.changes.is_empty() {
|
||||
all = fields(doc);
|
||||
}
|
||||
if !r.rc {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -884,12 +900,12 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result
|
|||
let Some(v) = af::calculate(&f.actions.calculate, &lookup) else { continue };
|
||||
v
|
||||
};
|
||||
let now = fields(doc);
|
||||
let Some(f) = now.iter().find(|f| f.name == name) else { continue };
|
||||
if current != v && matches!(f.kind, FieldKind::Text | FieldKind::Combo) {
|
||||
let f = f.clone();
|
||||
doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?;
|
||||
redraw(doc, &f, std::slice::from_ref(&v), scripts)?;
|
||||
if let Some(target) = all.iter_mut().find(|x| x.name == name) {
|
||||
target.value = vec![v];
|
||||
}
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,8 +100,11 @@ pub(crate) fn formatted(doc: &Document, f: &Field, value: &str, scripts: &mut dy
|
|||
/// read-only, required and visibility as flags). `except` is the event's own field, whose value
|
||||
/// the event sets.
|
||||
pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: &str) -> Result<(), FormError> {
|
||||
if changes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let all = fields(doc);
|
||||
for c in changes {
|
||||
let all = fields(doc);
|
||||
let Some(f) = all.iter().find(|f| f.name == c.name) else { continue };
|
||||
if let Some(v) = &c.value
|
||||
&& c.name != except
|
||||
|
|
|
|||
|
|
@ -62,6 +62,20 @@ fn reopen(doc: &Document) -> Document {
|
|||
Document::open(Arc::new(bytes)).expect("reopens")
|
||||
}
|
||||
|
||||
fn legacy_two_call_enumeration(doc: &Document) -> (Vec<Field>, usize) {
|
||||
(enumerate(doc).0, enumerate(doc).1)
|
||||
}
|
||||
|
||||
fn assert_combined_matches_legacy(doc: &Document, adopted: usize) {
|
||||
let (old_fields, old_adopted) = legacy_two_call_enumeration(doc);
|
||||
let combined = fields_with_adopted(doc);
|
||||
assert_eq!(combined.items, old_fields);
|
||||
assert_eq!(combined.adopted_page_fields, old_adopted);
|
||||
assert_eq!(combined.adopted_page_fields, adopted);
|
||||
assert_eq!(fields(doc), old_fields, "the compatibility wrapper keeps the old fields");
|
||||
assert_eq!(adopted_page_fields(doc), old_adopted, "the compatibility wrapper keeps the old count");
|
||||
}
|
||||
|
||||
fn field<'a>(all: &'a [Field], name: &str) -> &'a Field {
|
||||
all.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("no field {name}"))
|
||||
}
|
||||
|
|
@ -73,9 +87,28 @@ 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<Vec<u8>>) {
|
||||
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();
|
||||
assert_combined_matches_legacy(&doc, 0);
|
||||
let all = fields(&doc);
|
||||
let names: Vec<&str> = all.iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(names, ["name", "notes", "zip", "pin", "id", "address.city", "agree", "size", "country", "toppings", "go", "bare"]);
|
||||
|
|
@ -140,6 +173,7 @@ fn fields_listed_only_on_the_pages_are_adopted() {
|
|||
"<< /Type /Annot /Subtype /Widget /Rect [400 700 415 715] /P 3 0 R >>".into(), // 15 no /FT, /T or /Parent: not a field
|
||||
];
|
||||
let mut doc = document(&objs);
|
||||
assert_combined_matches_legacy(&doc, 3);
|
||||
let all = fields(&doc);
|
||||
let names: Vec<&str> = all.iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(names, ["listed", "alpha", "beta", "pair"]);
|
||||
|
|
@ -157,6 +191,7 @@ fn fields_listed_only_on_the_pages_are_adopted() {
|
|||
let mut objs = objs;
|
||||
objs[3] = "<< /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>".into();
|
||||
let doc = document(&objs);
|
||||
assert_combined_matches_legacy(&doc, 4);
|
||||
let all = fields(&doc);
|
||||
let names: Vec<&str> = all.iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(names, ["listed", "alpha", "beta", "pair"]);
|
||||
|
|
@ -227,6 +262,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<String> = 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<String> = 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<String> = 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
|
||||
|
|
@ -394,10 +516,44 @@ fn reset_restores_defaults() {
|
|||
#[test]
|
||||
fn documents_without_forms_say_so() {
|
||||
let mut doc = Document::new_empty();
|
||||
assert_combined_matches_legacy(&doc, 0);
|
||||
assert!(fields(&doc).is_empty());
|
||||
assert_eq!(set_value(&mut doc, "x", &FieldValue::Text("y".into())), Err(FormError::NoForm));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_enumeration_keeps_page_order_across_pages() {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 5 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [7 0 R] >>".into(),
|
||||
"<< /Fields [6 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (listed) /Rect [50 700 250 720] /P 3 0 R >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (adopted) /Rect [50 700 250 720] /P 4 0 R >>".into(),
|
||||
];
|
||||
let doc = document(&objs);
|
||||
assert_combined_matches_legacy(&doc, 1);
|
||||
|
||||
let combined = fields_with_adopted(&doc);
|
||||
assert_eq!(combined.items.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["listed", "adopted"]);
|
||||
assert_eq!(combined.items.iter().map(|f| f.widgets[0].page).collect::<Vec<_>>(), [Some(0), Some(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_enumeration_keeps_leniency_for_dangling_field_entries() {
|
||||
let objs: Vec<String> = 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 [] >>".into(),
|
||||
"<< /Fields [99 0 R null 5 0 R] >>".into(),
|
||||
"<< /FT /Tx /T (valid) >>".into(),
|
||||
];
|
||||
let doc = document(&objs);
|
||||
assert_combined_matches_legacy(&doc, 0);
|
||||
assert_eq!(fields_with_adopted(&doc).items.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["valid"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_field_type_can_be_added_named_and_drawn() {
|
||||
let mut doc = Document::new_empty();
|
||||
|
|
@ -905,6 +1061,7 @@ fn field_actions_round_trip_on_every_trigger() {
|
|||
assert_eq!(field_actions(&doc, &name).unwrap(), [(Trigger::MouseExit, FieldAction::Named("NextPage".into()))]);
|
||||
// The Mouse Up script is what the button runs.
|
||||
set_field_actions(&mut doc, &name, &[(Trigger::MouseUp, FieldAction::JavaScript("this.print();".into()))]).unwrap();
|
||||
assert_combined_matches_legacy(&doc, 0);
|
||||
assert_eq!(fields(&doc).iter().find(|f| f.name == "go").unwrap().button, Some(af::ButtonAction::Named("Print".into())));
|
||||
}
|
||||
|
||||
|
|
@ -1337,6 +1494,49 @@ fn check_box_redraw_keeps_down_states_it_still_draws() {
|
|||
assert!(!d.contains(b"Gone"), "a state the box no longer draws");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recalc_chain_where_script_changes_checkbox() {
|
||||
struct TestScripts;
|
||||
impl Scripts for TestScripts {
|
||||
fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, fields: &[Field]) -> ScriptResult {
|
||||
if script == "set_box" {
|
||||
ScriptResult {
|
||||
rc: true,
|
||||
value: "calc1".into(),
|
||||
changes: vec![FieldChange {
|
||||
name: "agree".into(),
|
||||
value: Some(vec!["Yes".into()]),
|
||||
read_only: None,
|
||||
required: None,
|
||||
display: None,
|
||||
}],
|
||||
message: None,
|
||||
}
|
||||
} else if script == "read_box" {
|
||||
let box_val = fields.iter().find(|f| f.name == "agree").and_then(|f| f.value.first()).cloned().unwrap_or_default();
|
||||
ScriptResult { rc: true, value: format!("box_was_{box_val}"), changes: Vec::new(), message: None }
|
||||
} else {
|
||||
ScriptResult { rc: true, value: String::new(), changes: Vec::new(), message: None }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut doc = one_page();
|
||||
add_field(&mut doc, 0, [10.0, 10.0, 100.0, 30.0], &NewField::Text { multiline: false }, Some("trigger")).unwrap();
|
||||
add_field(&mut doc, 0, [10.0, 40.0, 30.0, 60.0], &NewField::CheckBox, Some("agree")).unwrap();
|
||||
add_field(&mut doc, 0, [10.0, 70.0, 100.0, 90.0], &NewField::Text { multiline: false }, Some("result")).unwrap();
|
||||
|
||||
set_field_script(&mut doc, "trigger", "calculate", Some("set_box")).unwrap();
|
||||
set_field_script(&mut doc, "result", "calculate", Some("read_box")).unwrap();
|
||||
|
||||
let changed = recalculate_with(&mut doc, &mut TestScripts).unwrap();
|
||||
assert!(changed >= 2);
|
||||
|
||||
let all = fields(&doc);
|
||||
assert_eq!(field(&all, "agree").value, ["Yes"], "checkbox was set by script in recalc chain");
|
||||
assert_eq!(field(&all, "result").value, ["box_was_Yes"], "second recalc saw the normalized checkbox value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_appearance_keeps_custom_default_appearance_and_font_resources() {
|
||||
let mut doc = fixture();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod perspective;
|
||||
|
||||
/// An axis-aligned rectangle in PDF user space (points).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default)]
|
||||
pub struct PageRect {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
//! Perspective (projective) transforms between quadrilaterals: what straightening a photographed
|
||||
//! page needs. Pure maths in `f64`; every input may be hostile, so degenerate or non-finite
|
||||
//! quadrilaterals give `None` instead of NaNs.
|
||||
|
||||
/// A point `[x, y]`.
|
||||
pub type Point = [f64; 2];
|
||||
|
||||
/// A 3×3 projective transform, row-major, normalised so that the last entry is 1.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Homography(pub [f64; 9]);
|
||||
|
||||
impl Homography {
|
||||
/// The transform that takes each of `from` to the point at the same index of `to`. `None`
|
||||
/// when three of either set are collinear or a coordinate is not finite.
|
||||
pub fn from_points(from: [Point; 4], to: [Point; 4]) -> Option<Self> {
|
||||
if from.iter().chain(to.iter()).flatten().any(|v| !v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
// x' = (h0 x + h1 y + h2) / (h6 x + h7 y + 1), y' likewise with h3..h5: eight unknowns,
|
||||
// two equations per point pair.
|
||||
let mut m = [[0.0f64; 9]; 8];
|
||||
for (i, (&[x, y], &[u, v])) in from.iter().zip(to.iter()).enumerate() {
|
||||
m[2 * i] = [x, y, 1.0, 0.0, 0.0, 0.0, -u * x, -u * y, u];
|
||||
m[2 * i + 1] = [0.0, 0.0, 0.0, x, y, 1.0, -v * x, -v * y, v];
|
||||
}
|
||||
let h = solve8(m)?;
|
||||
let out = Self([h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1.0]);
|
||||
// Collinear points can leave a solvable but singular system: check that the transform
|
||||
// really maps the four points.
|
||||
let ok = from.iter().zip(to.iter()).all(|(&p, &q)| {
|
||||
out.apply(p).is_some_and(|r| {
|
||||
let scale = 1.0 + q[0].abs().max(q[1].abs());
|
||||
(r[0] - q[0]).abs() <= 1e-6 * scale && (r[1] - q[1]).abs() <= 1e-6 * scale
|
||||
})
|
||||
});
|
||||
ok.then_some(out)
|
||||
}
|
||||
|
||||
/// The image of `p`, or `None` where the transform sends it to infinity.
|
||||
pub fn apply(&self, [x, y]: Point) -> Option<Point> {
|
||||
let h = &self.0;
|
||||
let w = h[6] * x + h[7] * y + h[8];
|
||||
if !w.is_finite() || w.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let u = (h[0] * x + h[1] * y + h[2]) / w;
|
||||
let v = (h[3] * x + h[4] * y + h[5]) / w;
|
||||
(u.is_finite() && v.is_finite()).then_some([u, v])
|
||||
}
|
||||
}
|
||||
|
||||
/// Gaussian elimination with partial pivoting on an 8×8 system (the 9th column is the right-hand
|
||||
/// side). `None` when the system is singular.
|
||||
fn solve8(mut m: [[f64; 9]; 8]) -> Option<[f64; 8]> {
|
||||
for col in 0..8 {
|
||||
let pivot = (col..8).max_by(|&a, &b| m[a][col].abs().total_cmp(&m[b][col].abs()))?;
|
||||
if m[pivot][col].abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
m.swap(col, pivot);
|
||||
let p = m[col];
|
||||
for (r, row) in m.iter_mut().enumerate() {
|
||||
if r == col {
|
||||
continue;
|
||||
}
|
||||
let f = row[col] / p[col];
|
||||
if f != 0.0 {
|
||||
for (a, b) in row.iter_mut().zip(p.iter()) {
|
||||
*a -= f * b;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = [0.0; 8];
|
||||
for (i, o) in out.iter_mut().enumerate() {
|
||||
*o = m[i][8] / m[i][i];
|
||||
if !o.is_finite() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The four corners in reading order for a y-down space (view or raster pixels): top-left,
|
||||
/// top-right, bottom-right, bottom-left, whatever order they were given in. `None` unless they
|
||||
/// form a convex quadrilateral with a non-negligible area.
|
||||
pub fn order_quad(points: [Point; 4]) -> Option<[Point; 4]> {
|
||||
if points.iter().flatten().any(|v| !v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let cx = points.iter().map(|p| p[0]).sum::<f64>() / 4.0;
|
||||
let cy = points.iter().map(|p| p[1]).sum::<f64>() / 4.0;
|
||||
let mut q = points;
|
||||
// Ascending angle about the centre is clockwise on screen (y down): top, right, bottom, left.
|
||||
q.sort_by(|a, b| (a[1] - cy).atan2(a[0] - cx).total_cmp(&(b[1] - cy).atan2(b[0] - cx)));
|
||||
let first = (0..4).min_by(|&a, &b| (q[a][0] + q[a][1]).total_cmp(&(q[b][0] + q[b][1])))?;
|
||||
q.rotate_left(first);
|
||||
// Convex, with every turn the same way, and not a sliver.
|
||||
let cross = |o: Point, a: Point, b: Point| (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||
let turns: Vec<f64> = (0..4).map(|i| cross(q[i], q[(i + 1) % 4], q[(i + 2) % 4])).collect();
|
||||
let area = turns.iter().sum::<f64>() / 2.0;
|
||||
let span = q.iter().map(|p| p[0]).fold(f64::MIN, f64::max) - q.iter().map(|p| p[0]).fold(f64::MAX, f64::min);
|
||||
let rise = q.iter().map(|p| p[1]).fold(f64::MIN, f64::max) - q.iter().map(|p| p[1]).fold(f64::MAX, f64::min);
|
||||
let size = span.max(rise);
|
||||
let convex = turns.iter().all(|&t| t > 1e-9 * size * size);
|
||||
(convex && area > 1e-6 * size * size).then_some(q)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close(a: Point, b: Point) -> bool {
|
||||
(a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_each_corner_onto_its_target() {
|
||||
let from = [[12.0, 30.0], [410.0, 5.0], [430.0, 610.0], [-4.0, 590.0]];
|
||||
let to = [[0.0, 0.0], [400.0, 0.0], [400.0, 600.0], [0.0, 600.0]];
|
||||
let h = Homography::from_points(from, to).unwrap();
|
||||
for (p, q) in from.iter().zip(to.iter()) {
|
||||
assert!(close(h.apply(*p).unwrap(), *q), "{p:?} -> {q:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_onto_itself_is_the_identity() {
|
||||
let r = [[0.0, 0.0], [10.0, 0.0], [10.0, 20.0], [0.0, 20.0]];
|
||||
let h = Homography::from_points(r, r).unwrap();
|
||||
assert!(close(h.apply([3.0, 7.0]).unwrap(), [3.0, 7.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inverse_takes_points_back() {
|
||||
let from = [[50.0, 40.0], [300.0, 70.0], [320.0, 400.0], [30.0, 380.0]];
|
||||
let to = [[0.0, 0.0], [200.0, 0.0], [200.0, 300.0], [0.0, 300.0]];
|
||||
let (h, back) = (Homography::from_points(from, to).unwrap(), Homography::from_points(to, from).unwrap());
|
||||
let p = [120.0, 210.0];
|
||||
assert!(close(back.apply(h.apply(p).unwrap()).unwrap(), p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collinear_or_non_finite_points_have_no_transform() {
|
||||
let to = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
|
||||
assert!(Homography::from_points([[0.0, 0.0], [1.0, 1.0], [2.0, 2.0], [3.0, 3.0]], to).is_none());
|
||||
assert!(Homography::from_points([[0.0, 0.0]; 4], to).is_none());
|
||||
assert!(Homography::from_points([[f64::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], to).is_none());
|
||||
assert!(Homography::from_points([[f64::INFINITY, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], to).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corners_come_back_in_reading_order_whatever_the_input_order() {
|
||||
let tl = [10.0, 12.0];
|
||||
let tr = [200.0, 5.0];
|
||||
let br = [210.0, 300.0];
|
||||
let bl = [0.0, 290.0];
|
||||
for q in [[br, tl, bl, tr], [bl, br, tr, tl], [tl, tr, br, bl], [tr, bl, tl, br]] {
|
||||
assert_eq!(order_quad(q), Some([tl, tr, br, bl]), "{q:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concave_degenerate_or_non_finite_quads_are_refused() {
|
||||
// A dart: one corner inside the triangle of the other three.
|
||||
assert_eq!(order_quad([[0.0, 0.0], [100.0, 0.0], [20.0, 20.0], [0.0, 100.0]]), None);
|
||||
assert_eq!(order_quad([[0.0, 0.0], [10.0, 0.0], [20.0, 0.0], [30.0, 0.0]]), None);
|
||||
assert_eq!(order_quad([[5.0, 5.0]; 4]), None);
|
||||
assert_eq!(order_quad([[f64::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -447,7 +447,13 @@ pub fn list(doc: &Document) -> Listing {
|
|||
out
|
||||
}
|
||||
/// Last containing viewport wins, using the first point of the measurement (ISO 32000-2 §12.9).
|
||||
/// Outside every viewport the page measures true size in inches.
|
||||
pub fn scale_at(doc: &Document, page_index: usize, at: Point) -> Result<Scale> {
|
||||
scale_at_or(doc, page_index, at, 1.0 / 72.0, "in")
|
||||
}
|
||||
/// [`scale_at`], but outside every viewport the page measures true size in `unit`, of which there
|
||||
/// are `per_point` in a PDF point (25.4 / 72 for millimetres).
|
||||
pub fn scale_at_or(doc: &Document, page_index: usize, at: Point, per_point: f64, unit: &str) -> Result<Scale> {
|
||||
check_points(&[at])?;
|
||||
let p = page(doc, page_index)?;
|
||||
if let Some(o) = p.dict.get(b"VP") {
|
||||
|
|
@ -470,7 +476,7 @@ pub fn scale_at(doc: &Document, page_index: usize, at: Point) -> Result<Scale> {
|
|||
}
|
||||
}
|
||||
}
|
||||
Scale::new(p.user_unit(doc) / 72.0, "in", 2)
|
||||
Scale::new(p.user_unit(doc) * per_point, unit, 2)
|
||||
}
|
||||
/// Add a named rectangular viewport. Existing viewports are preserved in drawing order.
|
||||
pub fn set_scale(doc: &mut Document, page_index: usize, bbox: [f64; 4], name: &str, scale: &Scale) -> Result<()> {
|
||||
|
|
|
|||
|
|
@ -74,6 +74,20 @@ fn nearest(p: Point, a: Point, b: Point) -> Point {
|
|||
let t = (dot(sub(p, a), v) / length).clamp(0.0, 1.0);
|
||||
[a[0] + v[0] * t, a[1] + v[1] * t]
|
||||
}
|
||||
/// Save a subpath's implicit closing edge for fill operations. An unpainted or
|
||||
/// stroke-only open path must not gain that edge.
|
||||
fn remember_close(closures: &mut Vec<[Point; 2]>, current: Option<Point>, start: Option<Point>) -> bool {
|
||||
if let (Some(a), Some(b)) = (current, start)
|
||||
&& distance(a, b) > 1e-9
|
||||
{
|
||||
if closures.len() >= MAX_SEGMENTS {
|
||||
return false;
|
||||
}
|
||||
closures.push([a, b]);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
impl Geometry {
|
||||
/// Dense intersection searches are capped independently from path extraction.
|
||||
pub fn intersection_limited(&self, at: Point, tolerance: f64) -> bool {
|
||||
|
|
@ -233,6 +247,7 @@ impl Walker<'_> {
|
|||
let mut stack = Vec::new();
|
||||
let mut start = None;
|
||||
let mut current = None;
|
||||
let mut closures = Vec::new();
|
||||
let mut path = Geometry::default();
|
||||
let point = |m: Matrix, x: f64, y: f64| {
|
||||
let (x, y) = m.apply(x, y);
|
||||
|
|
@ -265,6 +280,10 @@ impl Walker<'_> {
|
|||
}
|
||||
"m" => {
|
||||
if let Some([x, y]) = op.nums::<2>() {
|
||||
if !remember_close(&mut closures, current, start) {
|
||||
self.geometry.truncated = true;
|
||||
break;
|
||||
}
|
||||
let p = point(matrix, x, y);
|
||||
current = Some(p);
|
||||
start = Some(p);
|
||||
|
|
@ -309,6 +328,10 @@ impl Walker<'_> {
|
|||
}
|
||||
"re" => {
|
||||
if let Some([x, y, w, h]) = op.nums::<4>() {
|
||||
if !remember_close(&mut closures, current, start) {
|
||||
self.geometry.truncated = true;
|
||||
break;
|
||||
}
|
||||
let a = point(matrix, x, y);
|
||||
let b = point(matrix, x + w, y);
|
||||
let c = point(matrix, x + w, y + h);
|
||||
|
|
@ -321,8 +344,24 @@ impl Walker<'_> {
|
|||
}
|
||||
}
|
||||
"S" | "s" | "f" | "F" | "f*" | "B" | "B*" | "b" | "b*" => {
|
||||
// Fills close open subpaths implicitly (ISO 32000-2 8.5.3.3); only a bare stroke leaves the path open.
|
||||
if String::from_utf8_lossy(&op.op).as_ref() != "S"
|
||||
let paint = String::from_utf8_lossy(&op.op);
|
||||
if matches!(paint.as_ref(), "f" | "F" | "f*" | "B" | "B*" | "b" | "b*") {
|
||||
if !remember_close(&mut closures, current, start) {
|
||||
self.geometry.truncated = true;
|
||||
break;
|
||||
}
|
||||
// Filling implicitly closes every open subpath, not just the last one.
|
||||
// Stroke-only S leaves its open subpaths unchanged.
|
||||
for [a, b] in closures.drain(..) {
|
||||
if self.geometry.segments.len().saturating_add(path.segments.len()) >= MAX_SEGMENTS
|
||||
|| self.geometry.targets().saturating_add(path.targets()) >= MAX_TARGETS
|
||||
{
|
||||
path.truncated = true;
|
||||
break;
|
||||
}
|
||||
path.edge(a, b);
|
||||
}
|
||||
} else if paint == "s"
|
||||
&& let (Some(a), Some(b)) = (current, start)
|
||||
{
|
||||
path.edge(a, b);
|
||||
|
|
@ -331,12 +370,14 @@ impl Walker<'_> {
|
|||
self.geometry.endpoints.append(&mut path.endpoints);
|
||||
self.geometry.midpoints.append(&mut path.midpoints);
|
||||
self.geometry.truncated |= path.truncated;
|
||||
closures.clear();
|
||||
current = None;
|
||||
start = None;
|
||||
}
|
||||
"n" => {
|
||||
self.geometry.truncated |= path.truncated;
|
||||
path = Geometry::default();
|
||||
closures.clear();
|
||||
current = None;
|
||||
start = None;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,6 +150,39 @@ fn snap_paths_endpoints_midpoints_and_intersections() {
|
|||
assert!(paths.snap([200.0, 200.0], 3.0, opts).unwrap().is_none());
|
||||
assert!(paths.snap([f64::NAN, 0.0], 3.0, opts).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn filled_open_subpath_offers_the_same_snap_edges_as_explicit_close() {
|
||||
let open = fixture("40 50 m 160 50 l 100 170 l f", "", &[]);
|
||||
let closed = fixture("40 50 m 160 50 l 100 170 l h f", "", &[]);
|
||||
let stroke = fixture("40 50 m 160 50 l 100 170 l S", "", &[]);
|
||||
let filled = snap::geometry(&open, 0).unwrap();
|
||||
let explicit = snap::geometry(&closed, 0).unwrap();
|
||||
let stroked = snap::geometry(&stroke, 0).unwrap();
|
||||
|
||||
assert_eq!(filled.segments, explicit.segments);
|
||||
assert_eq!(filled.segments.len(), 3);
|
||||
assert_eq!(stroked.segments.len(), 2);
|
||||
let paths_only = snap::SnapOptions { endpoints: false, midpoints: false, intersections: false, paths: true };
|
||||
let hit = filled.snap([55.0, 80.0], 5.0, paths_only).unwrap().unwrap();
|
||||
assert_eq!(hit.kind, snap::SnapKind::Path);
|
||||
assert_eq!(hit.point, [55.0, 80.0]);
|
||||
assert!(stroked.snap([55.0, 80.0], 5.0, paths_only).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fills_close_all_open_subpaths_but_strokes_do_not() {
|
||||
let triangles = "0 0 m 20 0 l 10 20 l 40 0 m 60 0 l 50 20 l";
|
||||
for paint in ["f", "F", "f*", "B", "B*", "b", "b*"] {
|
||||
let doc = fixture(&format!("{triangles} {paint}"), "", &[]);
|
||||
let geometry = snap::geometry(&doc, 0).unwrap();
|
||||
assert_eq!(geometry.segments.len(), 6, "{paint}");
|
||||
assert!(geometry.segments.contains(&[[10.0, 20.0], [0.0, 0.0]]), "{paint}");
|
||||
assert!(geometry.segments.contains(&[[50.0, 20.0], [40.0, 0.0]]), "{paint}");
|
||||
}
|
||||
let stroked = fixture(&format!("{triangles} S"), "", &[]);
|
||||
assert_eq!(snap::geometry(&stroked, 0).unwrap().segments.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_forms_transforms_curves_and_cycles_are_bounded() {
|
||||
let body = "0 0 m 10 0 l S 0 0 m 0 10 10 10 10 0 c S /Nested Do";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
//! Deterministic property checks for `snap::geometry` and `Geometry::snap`: mutated synthetic
|
||||
//! content keeps extracted geometry bounded and finite, and snap queries return finite results.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::Document;
|
||||
use pdfcraft_measure::snap::{self, Geometry, SnapOptions};
|
||||
|
||||
const CASES: usize = 1_000;
|
||||
const SEED: u64 = 1;
|
||||
const MAX_CONTENT_BYTES: usize = 16 * 1024;
|
||||
const MAX_GEOMETRY_SEGMENTS: usize = 20_000;
|
||||
const MAX_SNAP_TARGETS: usize = 2 * MAX_GEOMETRY_SEGMENTS + 12;
|
||||
|
||||
const SEEDS: &[&[u8]] = &[
|
||||
b"0 0 m 100 100 l S",
|
||||
b"q 1 0 0 1 10 20 cm 0 0 10 10 re S Q",
|
||||
b"0 0 m 20 100 80 -100 100 0 c S",
|
||||
b"1e308 1e308 m -1e308 -1e308 l S",
|
||||
b"BI /W 1 /H 1 /BPC 8 /CS /RGB ID \x00\xff\x7f EI",
|
||||
b"q q q Q Q cm m l c v y h re S n",
|
||||
];
|
||||
const TOKENS: &[&[u8]] = &[
|
||||
b" m ",
|
||||
b" l ",
|
||||
b" c ",
|
||||
b" v ",
|
||||
b" y ",
|
||||
b" re ",
|
||||
b" S ",
|
||||
b" f* ",
|
||||
b" q Q ",
|
||||
b" cm ",
|
||||
b" Do ",
|
||||
b" BI ID EI ",
|
||||
b" 1e308 ",
|
||||
b" -1e308 ",
|
||||
b" /BadName ",
|
||||
b" [1 2 (unterminated] ",
|
||||
];
|
||||
const DENSE_PATH: &[u8] = b"0 0 m 1 1 l S ";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
|
||||
}
|
||||
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
if n == 0 { 0 } else { (self.next() % n as u64) as usize }
|
||||
}
|
||||
}
|
||||
|
||||
fn mutate(rng: &mut Rng, seed: &[u8]) -> Vec<u8> {
|
||||
let mut data = seed.to_vec();
|
||||
let rounds = 1 + rng.below(8);
|
||||
for _ in 0..rounds {
|
||||
if data.is_empty() {
|
||||
data.extend_from_slice(b"0 0 m 10 10 l S");
|
||||
}
|
||||
let at = rng.below(data.len());
|
||||
match rng.below(6) {
|
||||
0 => data[at] ^= 1 << rng.below(8),
|
||||
1 => {
|
||||
let n = 1 + rng.below(64.min(data.len() - at));
|
||||
data.drain(at..at + n);
|
||||
}
|
||||
2 => {
|
||||
let token = TOKENS[rng.below(TOKENS.len())];
|
||||
data.splice(at..at, token.iter().copied());
|
||||
}
|
||||
3 => {
|
||||
let n = 1 + rng.below(128.min(data.len() - at));
|
||||
let duplicate = data[at..at + n].to_vec();
|
||||
let to = rng.below(data.len());
|
||||
data.splice(to..to, duplicate);
|
||||
}
|
||||
4 => {
|
||||
let byte = [rng.next() as u8];
|
||||
data.splice(at..at, byte);
|
||||
}
|
||||
_ => {
|
||||
let token = TOKENS[rng.below(TOKENS.len())];
|
||||
let end = at.saturating_add(1).min(data.len());
|
||||
data.splice(at..end, token.iter().copied());
|
||||
}
|
||||
}
|
||||
data.truncate(MAX_CONTENT_BYTES);
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
fn one_page_pdf(content: &[u8]) -> Vec<u8> {
|
||||
let stream = format!("<< /Length {} >>\nstream\n", content.len());
|
||||
let objects = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << >> >>".to_vec(),
|
||||
{
|
||||
let mut object = stream.into_bytes();
|
||||
object.extend_from_slice(content);
|
||||
object.extend_from_slice(b"\nendstream");
|
||||
object
|
||||
},
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::with_capacity(objects.len());
|
||||
for (index, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n", index + 1).as_bytes());
|
||||
bytes.extend_from_slice(object);
|
||||
bytes.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn finite(point: [f64; 2]) -> bool {
|
||||
point.into_iter().all(f64::is_finite)
|
||||
}
|
||||
|
||||
fn geometry_of(content: &[u8]) -> Geometry {
|
||||
let doc = Document::open(Arc::new(one_page_pdf(content))).unwrap();
|
||||
snap::geometry(&doc, 0).unwrap()
|
||||
}
|
||||
|
||||
/// Checks the bounds and finiteness that snapping relies on, and returns the extracted geometry.
|
||||
fn check_snap_invariants(content: &[u8]) -> Geometry {
|
||||
assert!(content.len() <= MAX_CONTENT_BYTES, "generated content exceeded the byte cap");
|
||||
let geometry = geometry_of(content);
|
||||
assert!(geometry.segments.len() <= MAX_GEOMETRY_SEGMENTS, "geometry exceeded the segment cap: {}", geometry.segments.len());
|
||||
assert!(geometry.endpoints.len().saturating_add(geometry.midpoints.len()) <= MAX_SNAP_TARGETS, "geometry exceeded the snap-target cap");
|
||||
assert!(geometry.segments.iter().flatten().copied().all(finite), "geometry contains a non-finite segment coordinate");
|
||||
assert!(geometry.endpoints.iter().copied().all(finite), "geometry contains a non-finite endpoint");
|
||||
assert!(geometry.midpoints.iter().copied().all(finite), "geometry contains a non-finite midpoint");
|
||||
|
||||
geometry.intersection_limited([0.0, 0.0], 10_000.0);
|
||||
let snap = geometry.snap([0.0, 0.0], 10_000.0, SnapOptions::default()).unwrap();
|
||||
if let Some(hit) = snap {
|
||||
assert!(finite(hit.point) && hit.distance.is_finite(), "snap result contains a non-finite value");
|
||||
}
|
||||
geometry
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_snap_geometry_stays_bounded_and_finite() {
|
||||
let mut seeds: Vec<Vec<u8>> = SEEDS.iter().map(|seed| seed.to_vec()).collect();
|
||||
let mut dense = Vec::with_capacity(MAX_CONTENT_BYTES);
|
||||
while dense.len().saturating_add(DENSE_PATH.len()) <= MAX_CONTENT_BYTES {
|
||||
dense.extend_from_slice(DENSE_PATH);
|
||||
}
|
||||
seeds.push(dense);
|
||||
|
||||
let mut rng = Rng(SEED);
|
||||
for _ in 0..CASES {
|
||||
let seed_index = rng.below(seeds.len());
|
||||
let content = mutate(&mut rng, &seeds[seed_index]);
|
||||
check_snap_invariants(&content);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_curvature_geometry_hits_segment_cap() {
|
||||
let stress_curve = b"0 0 m 0 100000000 100000000 100000000 100000000 0 c S ".repeat(6);
|
||||
let geometry = check_snap_invariants(&stress_curve);
|
||||
assert!(geometry.truncated, "high-curvature geometry did not report its extraction cap ({} segments)", geometry.segments.len());
|
||||
}
|
||||
|
|
@ -16,7 +16,9 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
|
|||
An image is replaced only if the result is smaller. Left alone: CMYK and other colour spaces,
|
||||
image masks, decode arrays, more than 8 bits, JPEG 2000, JBIG2 and CCITT.
|
||||
- **Discard objects:** thumbnails, alternate images, document tags, print settings.
|
||||
- **Clean up:** Flate for streams with no filter.
|
||||
- **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex
|
||||
layer, that layer is removed when the result is smaller (the inner filters keep their encoded
|
||||
bytes). Streams with `/DecodeParms`, images and object streams are left alone.
|
||||
|
||||
The engine runs Remove Hidden Information (`pdfcraft-redact`) for the user-data categories,
|
||||
then this, then merges identical objects and writes a full, compressed save
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
//! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree),
|
||||
//! print settings.
|
||||
//! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted
|
||||
//! image); Flate-compress streams that have no filter.
|
||||
//! image); Flate-compress streams that have no filter; remove a redundant outer ASCII85 or
|
||||
//! ASCIIHex layer from streams that have one.
|
||||
//!
|
||||
//! Metadata, attachments, comments, scripts, private data, hidden layers, bookmarks and form
|
||||
//! fields are discarded by `pdfcraft-redact`'s Remove Hidden Information, which the engine
|
||||
|
|
@ -25,7 +26,7 @@ mod unused;
|
|||
|
||||
pub use audit::{SpaceCategory, SpaceUse, audit_space};
|
||||
|
||||
use pdfcraft_cos::{Document, ObjRef, Object, Stream};
|
||||
use pdfcraft_cos::{Document, ObjRef, Object, Stream, serialize};
|
||||
|
||||
pub use images::effective_resolutions;
|
||||
|
||||
|
|
@ -84,7 +85,8 @@ pub struct Settings {
|
|||
pub discard_alternate_images: bool,
|
||||
pub discard_tags: bool,
|
||||
pub discard_print_settings: bool,
|
||||
/// Flate-compress streams stored without a filter.
|
||||
/// Flate-compress streams stored without a filter, and strip a redundant outer ASCII85 or
|
||||
/// ASCIIHex layer from streams that have one.
|
||||
pub flate_unencoded: bool,
|
||||
/// Clean Up: remove links (and bookmark destinations) that point nowhere.
|
||||
pub remove_invalid_links: bool,
|
||||
|
|
@ -227,17 +229,53 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress:
|
|||
let obj = doc.get(r);
|
||||
let Object::Stream(s) = &*obj else { continue };
|
||||
// XMP stays readable as plain text (PDF/A requires it); tiny streams don't gain.
|
||||
if s.dict.contains(b"Filter") || s.dict.name(b"Type") == Some(b"Metadata") || s.dict.name(b"Type") == Some(b"XRef") || s.raw.len() < 64 {
|
||||
if s.dict.name(b"Type") == Some(b"Metadata") || s.dict.name(b"Type") == Some(b"XRef") || s.raw.len() < 64 {
|
||||
continue;
|
||||
}
|
||||
let mut d = s.dict.clone();
|
||||
d.remove(b"Length");
|
||||
let packed = Stream::flate(d, &s.raw);
|
||||
if packed.raw.len() < s.raw.len() {
|
||||
doc.set(r, Object::Stream(packed));
|
||||
let replacement = if s.dict.contains(b"Filter") {
|
||||
strip_ascii_wrapper(s)
|
||||
} else {
|
||||
let mut d = s.dict.clone();
|
||||
d.remove(b"Length");
|
||||
let packed = Stream::flate(d, &s.raw);
|
||||
(packed.raw.len() < s.raw.len()).then_some(packed)
|
||||
};
|
||||
if let Some(replacement) = replacement {
|
||||
doc.set(r, Object::Stream(replacement));
|
||||
report.streams_compressed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// `s` without its outer ASCII85 or ASCIIHex layer (`[/ASCII85Decode /FlateDecode]` becomes
|
||||
/// `[/FlateDecode]`), when that is smaller. Only the outer layer is decoded: strictly, and within
|
||||
/// the stored size, which ASCII text never falls below, so the inner encoded bytes stay exact.
|
||||
fn strip_ascii_wrapper(s: &Stream) -> Option<Stream> {
|
||||
if s.dict.contains(b"DecodeParms") || s.dict.name(b"Type") == Some(b"ObjStm") || s.dict.name(b"Subtype") == Some(b"Image") {
|
||||
return None;
|
||||
}
|
||||
let Some(Object::Array(filters)) = s.dict.get(b"Filter") else { return None };
|
||||
let names = filters.iter().map(Object::as_name).collect::<Option<Vec<_>>>()?;
|
||||
let (outer, inner) = names.split_first()?;
|
||||
if !matches!(*outer, b"ASCII85Decode" | b"ASCIIHexDecode") || inner.is_empty() || inner.contains(&&b"Crypt"[..]) {
|
||||
return None;
|
||||
}
|
||||
let data = s.decoded_first_filter_strict(s.raw.len()).ok()?;
|
||||
let mut dict = s.dict.clone();
|
||||
dict.remove(b"Length");
|
||||
dict.set(b"Filter".to_vec(), Object::Array(inner.iter().map(|n| Object::Name(n.to_vec())).collect()));
|
||||
let candidate = Stream::from_raw(dict, data);
|
||||
(stored_len(&candidate)? < stored_len(s)?).then_some(candidate)
|
||||
}
|
||||
|
||||
/// Bytes the stream object takes in the file: its dictionary with `/Length` set as the writer
|
||||
/// sets it, then its data.
|
||||
fn stored_len(s: &Stream) -> Option<usize> {
|
||||
let mut dict = s.dict.clone();
|
||||
dict.set(b"Length".to_vec(), Object::Int(i64::try_from(s.raw.len()).ok()?));
|
||||
let mut out = Vec::new();
|
||||
serialize(&Object::Dict(dict), &mut out);
|
||||
out.len().checked_add(s.raw.len())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -583,3 +583,107 @@ fn nothing_is_dropped_when_a_stream_cannot_be_checked() {
|
|||
assert_eq!(build(s), kept, "{dict:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `data` as ASCII85 text with the `~>` end marker, as ASCII85Decode reads it.
|
||||
fn ascii85(data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for chunk in data.chunks(4) {
|
||||
let mut word = [0u8; 4];
|
||||
word[..chunk.len()].copy_from_slice(chunk);
|
||||
let mut v = u32::from_be_bytes(word);
|
||||
let mut digits = [0u8; 5];
|
||||
for d in digits.iter_mut().rev() {
|
||||
*d = (v % 85) as u8 + b'!';
|
||||
v /= 85;
|
||||
}
|
||||
out.extend_from_slice(&digits[..chunk.len() + 1]);
|
||||
}
|
||||
out.extend_from_slice(b"~>");
|
||||
out
|
||||
}
|
||||
|
||||
/// `data` as hex digits with the `>` end marker, as ASCIIHexDecode reads it.
|
||||
fn ascii_hex(data: &[u8]) -> Vec<u8> {
|
||||
let mut out: Vec<u8> = data.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect();
|
||||
out.push(b'>');
|
||||
out
|
||||
}
|
||||
|
||||
fn filtered(names: &[&str], raw: Vec<u8>) -> Stream {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Filter".to_vec(), Object::Array(names.iter().map(|n| Object::name(n)).collect()));
|
||||
Stream::from_raw(d, raw)
|
||||
}
|
||||
|
||||
/// A one-page document whose page content is `content`, with the content's reference.
|
||||
fn with_content(content: Stream) -> (Document, ObjRef) {
|
||||
let mut doc = Document::new_empty();
|
||||
let pg = page(&mut doc, &[], "");
|
||||
let c = doc.add(Object::Stream(content));
|
||||
doc.update_dict(pg, |d| d.set(b"Contents".to_vec(), Object::Ref(c))).unwrap();
|
||||
(doc, c)
|
||||
}
|
||||
|
||||
fn vector_ops() -> Vec<u8> {
|
||||
"0 0 m 612 792 l S\n".repeat(400).into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_wrappers_come_off_filtered_streams_and_the_inner_data_is_unchanged() {
|
||||
let ops = vector_ops();
|
||||
let inner = Stream::flate(Dict::new(), &ops).raw.to_vec();
|
||||
for (outer, encoded) in [("ASCII85Decode", ascii85(&inner)), ("ASCIIHexDecode", ascii_hex(&inner))] {
|
||||
let source = filtered(&[outer, "FlateDecode"], encoded);
|
||||
let (mut doc, c) = with_content(source.clone());
|
||||
let r = optimize(&mut doc, &Settings::default()).unwrap();
|
||||
assert_eq!(r.streams_compressed, 1, "{outer}");
|
||||
let after = stream(&doc, c);
|
||||
assert_eq!(after.dict.get(b"Filter"), Some(&Object::Array(vec![Object::name("FlateDecode")])), "{outer}");
|
||||
assert_eq!(after.raw.to_vec(), inner, "{outer}: the inner Flate bytes are kept exactly");
|
||||
assert_eq!(after.decoded_strict().unwrap(), ops, "{outer}");
|
||||
assert!(after.raw.len() < source.raw.len(), "{outer}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streams_the_wrapper_stripping_does_not_touch_stay_unchanged() {
|
||||
let inner = Stream::flate(Dict::new(), &vector_ops()).raw.to_vec();
|
||||
let wrapped = ascii85(&inner);
|
||||
let mut with_parms = filtered(&["ASCII85Decode", "FlateDecode"], wrapped.clone());
|
||||
with_parms.dict.set(b"DecodeParms".to_vec(), Object::Dict(Dict::new()));
|
||||
let mut image = filtered(&["ASCII85Decode", "FlateDecode"], wrapped.clone());
|
||||
image.dict.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
let mut objstm = filtered(&["ASCII85Decode", "FlateDecode"], wrapped.clone());
|
||||
objstm.dict.set(b"Type".to_vec(), Object::name("ObjStm"));
|
||||
let mut metadata = filtered(&["ASCII85Decode", "FlateDecode"], wrapped.clone());
|
||||
metadata.dict.set(b"Type".to_vec(), Object::name("Metadata"));
|
||||
let cases = [
|
||||
("malformed ASCII85", filtered(&["ASCII85Decode", "FlateDecode"], b"{{ not ASCII85 ~>".to_vec())),
|
||||
("malformed ASCIIHex", filtered(&["ASCIIHexDecode", "FlateDecode"], b"G0>".to_vec())),
|
||||
("DecodeParms", with_parms),
|
||||
("single ASCII filter", filtered(&["ASCII85Decode"], wrapped.clone())),
|
||||
("image", image),
|
||||
("object stream", objstm),
|
||||
("metadata", metadata),
|
||||
("crypt after the wrapper", filtered(&["ASCII85Decode", "Crypt", "FlateDecode"], wrapped.clone())),
|
||||
("tiny", filtered(&["ASCII85Decode", "FlateDecode"], ascii85(b"ab"))),
|
||||
("single Flate", Stream::flate(Dict::new(), &vector_ops())),
|
||||
];
|
||||
for (label, s) in cases {
|
||||
let (mut doc, c) = with_content(s.clone());
|
||||
let r = optimize(&mut doc, &Settings::default()).unwrap();
|
||||
assert_eq!(r.streams_compressed, 0, "{label}");
|
||||
assert_eq!(stream(&doc, c), s, "{label}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_run_leaves_the_stripped_stream_alone() {
|
||||
let inner = Stream::flate(Dict::new(), &vector_ops()).raw.to_vec();
|
||||
let (mut doc, c) = with_content(filtered(&["ASCII85Decode", "FlateDecode"], ascii85(&inner)));
|
||||
optimize(&mut doc, &Settings::default()).unwrap();
|
||||
let once = stream(&doc, c);
|
||||
let r = optimize(&mut doc, &Settings::default()).unwrap();
|
||||
assert_eq!(r.streams_compressed, 0);
|
||||
assert_eq!(stream(&doc, c), once);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -486,7 +486,7 @@ pub fn extract_pages(src: &Document, pages: &[usize]) -> Result<Document, Organi
|
|||
}
|
||||
}
|
||||
// After the title: the XMP packet repeats it.
|
||||
crate::pdfx::carry(&mut out, src, &crate::pdfx::PrintStandard::of(src), imported.objects)?;
|
||||
crate::pdfx::carry(&mut out, Some(src), &crate::pdfx::PrintStandard::of(src), imported.objects)?;
|
||||
// The pages share their source's resource dictionary, so the part would otherwise carry every
|
||||
// XObject the source lists — images and all (#204). Keep only what these pages draw.
|
||||
crate::prune::prune_unused_xobjects(&mut out)?;
|
||||
|
|
@ -540,16 +540,54 @@ pub fn combine(sources: &[(&str, &Document)]) -> Result<Document, OrganizeError>
|
|||
/// Combine Files with chosen pages: each source contributes `pages` (0-based, in that order;
|
||||
/// `None` for all of them). Bookmarks that point at pages left out lose their destination.
|
||||
pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Result<Document, OrganizeError> {
|
||||
let mut out = Document::new_empty();
|
||||
let mut marks = Vec::new();
|
||||
let mut attachments = Vec::new();
|
||||
// The first source's print standard, and what its pages brought along; it carries over
|
||||
// only when every source declares the same one.
|
||||
let mut standard: Option<(crate::pdfx::PrintStandard, &Document, HashMap<ObjRef, ObjRef>)> = None;
|
||||
let mut agreed = true;
|
||||
let mut combiner = Combiner::new();
|
||||
for (title, src, chosen) in sources {
|
||||
combiner.add(title, src, *chosen)?;
|
||||
}
|
||||
combiner.finish()
|
||||
}
|
||||
|
||||
/// Combine Files one source at a time, with the same result as [`combine_selected`]. [`Combiner::add`]
|
||||
/// copies a source's pages into the result and keeps only what the rest of the combine needs: its
|
||||
/// bookmarks (read and mapped to the copied pages), its attachments (copied), and its document only
|
||||
/// when it declares output intents. So each source can be dropped as soon as it is added, and a long
|
||||
/// list holds one source at a time rather than all of them.
|
||||
pub struct Combiner {
|
||||
out: Document,
|
||||
marks: Vec<Mark>,
|
||||
attachments: Vec<(Vec<u8>, Object)>,
|
||||
/// The first source's print standard, and what its pages brought along.
|
||||
standard: Option<Standard>,
|
||||
/// Whether every source so far declares the first source's print standard.
|
||||
agreed: bool,
|
||||
}
|
||||
|
||||
/// The first source's print standard (see [`Combiner`]).
|
||||
struct Standard {
|
||||
declared: crate::pdfx::PrintStandard,
|
||||
/// The source objects the copied pages brought along, and their copies.
|
||||
objects: HashMap<ObjRef, ObjRef>,
|
||||
/// The source itself, kept only for its output intents, which are copied from it.
|
||||
source: Option<Document>,
|
||||
}
|
||||
|
||||
impl Default for Combiner {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Combiner {
|
||||
/// A combine with no sources yet.
|
||||
pub fn new() -> Self {
|
||||
Self { out: Document::new_empty(), marks: Vec::new(), attachments: Vec::new(), standard: None, agreed: true }
|
||||
}
|
||||
|
||||
/// Add `src`'s `pages` (0-based, in that order; `None` for all of them) after the pages added so far,
|
||||
/// under a bookmark titled `title`. `src` need not outlive the call.
|
||||
pub fn add(&mut self, title: &str, src: &Document, pages: Option<&[usize]>) -> Result<(), OrganizeError> {
|
||||
let n = crate::page_count(src)?;
|
||||
let pages: Vec<usize> = match chosen {
|
||||
let pages: Vec<usize> = match pages {
|
||||
Some(p) => {
|
||||
if let Some(bad) = p.iter().find(|i| **i >= n) {
|
||||
return Err(OrganizeError::NoSuchPage(*bad));
|
||||
|
|
@ -558,27 +596,38 @@ pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Resu
|
|||
}
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let at = crate::page_count(&out)?;
|
||||
let imported = import_pages_mapped(&mut out, src, &pages, at)?;
|
||||
let at = crate::page_count(&self.out)?;
|
||||
let imported = import_pages_mapped(&mut self.out, src, &pages, at)?;
|
||||
if let Some(first) = imported.pages.first() {
|
||||
marks.push((title.to_string(), *first, *src, imported.page_map));
|
||||
let outline = source_outline(src, imported.page_map);
|
||||
self.marks.push(Mark { title: title.to_string(), page: *first, outline });
|
||||
}
|
||||
collect_attachments(&mut out, src, &mut attachments);
|
||||
collect_attachments(&mut self.out, src, &mut self.attachments);
|
||||
let declared = crate::pdfx::PrintStandard::of(src);
|
||||
match &standard {
|
||||
None => standard = Some((declared, *src, imported.objects)),
|
||||
Some((first, _, _)) => agreed &= first.same_as(&declared),
|
||||
match self.standard.as_ref().map(|first| first.declared.same_as(&declared)) {
|
||||
Some(same) => self.agreed &= same,
|
||||
None => {
|
||||
let source = declared.declares_output_intents().then(|| src.clone());
|
||||
self.standard = Some(Standard { declared, objects: imported.objects, source });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
if let Some((declared, src, objects)) = standard.filter(|_| agreed) {
|
||||
crate::pdfx::carry(&mut out, src, &declared, objects)?;
|
||||
|
||||
/// The combined document: the pages as added, the print standard of the first source when every
|
||||
/// source declares the same one, shared resources stored once, then the bookmarks and attachments.
|
||||
pub fn finish(self) -> Result<Document, OrganizeError> {
|
||||
let Combiner { mut out, marks, attachments, standard, agreed } = self;
|
||||
if let Some(first) = standard.filter(|_| agreed) {
|
||||
crate::pdfx::carry(&mut out, first.source.as_ref(), &first.declared, first.objects)?;
|
||||
}
|
||||
// Sources often share fonts, images and profiles (or are the same file): store them once.
|
||||
let all: Vec<ObjRef> = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect();
|
||||
crate::dedupe::dedupe_resources(&mut out, &all, false);
|
||||
add_outline(&mut out, &marks)?;
|
||||
set_attachments(&mut out, attachments)?;
|
||||
Ok(out)
|
||||
}
|
||||
// Sources often share fonts, images and profiles (or are the same file): store them once.
|
||||
let all: Vec<ObjRef> = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect();
|
||||
crate::dedupe::dedupe_resources(&mut out, &all, false);
|
||||
add_outline(&mut out, &marks)?;
|
||||
set_attachments(&mut out, attachments)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One run of pages for [`combine_grouped`]: the file it comes from (`group`: runs with the same
|
||||
|
|
@ -633,7 +682,7 @@ pub fn combine_grouped(runs: &[Run<'_>]) -> Result<Document, OrganizeError> {
|
|||
let imported = import_pages_mapped(&mut out, file.src, &file.pages, at)?;
|
||||
copies.push(imported.pages.clone());
|
||||
if let Some(first) = imported.pages.first() {
|
||||
marks.push((file.title.to_string(), *first, file.src, imported.page_map));
|
||||
marks.push(Mark { title: file.title.to_string(), page: *first, outline: source_outline(file.src, imported.page_map) });
|
||||
}
|
||||
collect_attachments(&mut out, file.src, &mut attachments);
|
||||
let declared = crate::pdfx::PrintStandard::of(file.src);
|
||||
|
|
@ -651,7 +700,7 @@ pub fn combine_grouped(runs: &[Run<'_>]) -> Result<Document, OrganizeError> {
|
|||
}
|
||||
rebuild(&mut out, &order)?;
|
||||
if let Some((declared, src, objects)) = standard.filter(|_| agreed) {
|
||||
crate::pdfx::carry(&mut out, src, &declared, objects)?;
|
||||
crate::pdfx::carry(&mut out, Some(src), &declared, objects)?;
|
||||
}
|
||||
let all: Vec<ObjRef> = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect();
|
||||
crate::dedupe::dedupe_resources(&mut out, &all, false);
|
||||
|
|
@ -714,36 +763,132 @@ fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec<u8>, Object)>) -> R
|
|||
Ok(())
|
||||
}
|
||||
|
||||
type Mark<'a> = (String, ObjRef, &'a Document, HashMap<ObjRef, ObjRef>);
|
||||
/// A source's entry in the combined outline (see [`add_outline`]): its title, its first page in the
|
||||
/// result, and its own bookmarks, read from the source when it was added.
|
||||
struct Mark {
|
||||
title: String,
|
||||
page: ObjRef,
|
||||
outline: Vec<OutlineNode>,
|
||||
}
|
||||
|
||||
/// Replace the outline with one bookmark per source (title → its first page), each holding a
|
||||
/// collapsed copy of that source's own bookmarks with destinations mapped to the copied pages.
|
||||
fn add_outline(doc: &mut Document, marks: &[Mark<'_>]) -> Result<(), OrganizeError> {
|
||||
/// One of a source's bookmarks, read and ready to write: its title, colour and flags, its destination
|
||||
/// in the result (`None` when the page it points at was not copied), whether it is open, and its own
|
||||
/// bookmarks.
|
||||
struct OutlineNode {
|
||||
title: Option<Object>,
|
||||
colour: Option<Object>,
|
||||
flags: Option<Object>,
|
||||
dest: Option<Object>,
|
||||
open: bool,
|
||||
children: Vec<OutlineNode>,
|
||||
}
|
||||
|
||||
/// The bookmarks of `src`, with destinations mapped through `pages` (source page to copied page). They
|
||||
/// are read while `src` is at hand, so the source itself need not be kept.
|
||||
fn source_outline(src: &Document, pages: HashMap<ObjRef, ObjRef>) -> Vec<OutlineNode> {
|
||||
let first = src
|
||||
.root()
|
||||
.and_then(|r| src.get(r).as_dict().cloned())
|
||||
.and_then(|c| c.get(b"Outlines").map(|o| src.resolve(o)))
|
||||
.and_then(|o| o.as_dict().and_then(|d| d.reference(b"First")));
|
||||
let mapper = Copier { src, map: HashMap::new(), pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() };
|
||||
let mut budget = 10_000usize;
|
||||
outline_level(&mapper, first, 0, &mut budget)
|
||||
}
|
||||
|
||||
/// One level of bookmarks from `first` (its siblings), each with its children. Cycle- and size-safe:
|
||||
/// at most `budget` bookmarks are read in all, and nesting stops at 32 levels.
|
||||
fn outline_level(m: &Copier<'_>, first: Option<ObjRef>, depth: u8, budget: &mut usize) -> Vec<OutlineNode> {
|
||||
if depth > 32 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut made = Vec::new();
|
||||
let mut cur = first;
|
||||
while let Some(r) = cur {
|
||||
if !seen.insert(r) || *budget == 0 {
|
||||
break;
|
||||
}
|
||||
*budget -= 1;
|
||||
let Some(item) = m.src.get(r).as_dict().cloned() else { break };
|
||||
cur = item.reference(b"Next");
|
||||
let title = item.get(b"Title").map(|t| m.src.resolve(t).as_ref().clone());
|
||||
let colour = item.get(b"C").map(|v| m.src.resolve(v).as_ref().clone());
|
||||
let flags = item.get(b"F").map(|v| m.src.resolve(v).as_ref().clone());
|
||||
let target = item.get(b"Dest").cloned().or_else(|| {
|
||||
let a = m.src.resolve(item.get(b"A")?);
|
||||
let a = a.as_dict()?;
|
||||
(a.name(b"S") == Some(b"GoTo")).then(|| a.get(b"D").cloned()).flatten()
|
||||
});
|
||||
let dest = target.and_then(|t| m.map_dest(&t));
|
||||
let open = item.int(b"Count").is_some_and(|c| c > 0);
|
||||
let children = outline_level(m, item.reference(b"First"), depth + 1, budget);
|
||||
made.push(OutlineNode { title, colour, flags, dest, open, children });
|
||||
}
|
||||
made
|
||||
}
|
||||
|
||||
/// Write `nodes` as one level under `parent`: each bookmark is created, then its children, depth first
|
||||
/// and in order, so the object numbers come out as they always did. Returns the level's first and last
|
||||
/// item and how many items it has (`None` when it is empty).
|
||||
fn write_outline_level(dst: &mut Document, parent: ObjRef, nodes: &[OutlineNode]) -> Result<Option<(ObjRef, ObjRef, usize)>, OrganizeError> {
|
||||
let mut made: Vec<ObjRef> = Vec::new();
|
||||
for node in nodes {
|
||||
let new = dst.add(Object::Null);
|
||||
let mut d = Dict::new();
|
||||
if let Some(t) = &node.title {
|
||||
d.set(b"Title".to_vec(), t.clone());
|
||||
}
|
||||
if let Some(c) = &node.colour {
|
||||
d.set(b"C".to_vec(), c.clone());
|
||||
}
|
||||
if let Some(f) = &node.flags {
|
||||
d.set(b"F".to_vec(), f.clone());
|
||||
}
|
||||
if let Some(dest) = &node.dest {
|
||||
d.set(b"Dest".to_vec(), dest.clone());
|
||||
}
|
||||
d.set(b"Parent".to_vec(), Object::Ref(parent));
|
||||
if let Some(prev) = made.last() {
|
||||
d.set(b"Prev".to_vec(), Object::Ref(*prev));
|
||||
dst.update_dict(*prev, |p| p.set(b"Next".to_vec(), Object::Ref(new)))?;
|
||||
}
|
||||
if let Some((f, l, n)) = write_outline_level(dst, new, &node.children)? {
|
||||
d.set(b"First".to_vec(), Object::Ref(f));
|
||||
d.set(b"Last".to_vec(), Object::Ref(l));
|
||||
// Keep the source's open/closed state.
|
||||
d.set(b"Count".to_vec(), Object::Int(if node.open { n as i64 } else { -(n as i64) }));
|
||||
}
|
||||
dst.set(new, Object::Dict(d));
|
||||
made.push(new);
|
||||
}
|
||||
Ok(match (made.first(), made.last()) {
|
||||
(Some(first), Some(last)) => Some((*first, *last, made.len())),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Replace the outline with one bookmark per source (title → its first page), each holding a collapsed
|
||||
/// copy of that source's own bookmarks with destinations mapped to the copied pages.
|
||||
fn add_outline(doc: &mut Document, marks: &[Mark]) -> Result<(), OrganizeError> {
|
||||
if marks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let outlines = doc.add(Object::Null);
|
||||
let items: Vec<ObjRef> = marks.iter().map(|_| doc.add(Object::Null)).collect();
|
||||
let (Some(&first_item), Some(&last_item)) = (items.first(), items.last()) else { return Ok(()) };
|
||||
for (i, ((title, page, src, page_map), r)) in marks.iter().zip(&items).enumerate() {
|
||||
for (i, (mark, r)) in marks.iter().zip(&items).enumerate() {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Title".to_vec(), Object::String(pdfcraft_cos::PdfString::text(title)));
|
||||
d.set(b"Title".to_vec(), Object::String(pdfcraft_cos::PdfString::text(&mark.title)));
|
||||
d.set(b"Parent".to_vec(), Object::Ref(outlines));
|
||||
d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(*page), Object::name("Fit")]));
|
||||
d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(mark.page), Object::name("Fit")]));
|
||||
if i > 0 {
|
||||
d.set(b"Prev".to_vec(), Object::Ref(items[i - 1]));
|
||||
}
|
||||
if let Some(next) = items.get(i + 1) {
|
||||
d.set(b"Next".to_vec(), Object::Ref(*next));
|
||||
}
|
||||
let first = src
|
||||
.root()
|
||||
.and_then(|r| src.get(r).as_dict().cloned())
|
||||
.and_then(|c| c.get(b"Outlines").map(|o| src.resolve(o)))
|
||||
.and_then(|o| o.as_dict().and_then(|d| d.reference(b"First")));
|
||||
let mapper = Copier { src, map: HashMap::new(), pages: page_map.clone(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() };
|
||||
let mut budget = 10_000usize;
|
||||
if let Some((f, l, n)) = copy_outline_level(doc, &mapper, first, *r, 0, &mut budget) {
|
||||
if let Some((f, l, n)) = write_outline_level(doc, *r, &mark.outline)? {
|
||||
d.set(b"First".to_vec(), Object::Ref(f));
|
||||
d.set(b"Last".to_vec(), Object::Ref(l));
|
||||
d.set(b"Count".to_vec(), Object::Int(-(n as i64))); // collapsed
|
||||
|
|
@ -764,67 +909,6 @@ fn add_outline(doc: &mut Document, marks: &[Mark<'_>]) -> Result<(), OrganizeErr
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy one level of a source outline (siblings from `first`) under `parent`, recursing into
|
||||
/// children. Returns (first, last, visible count). Cycle- and size-safe.
|
||||
fn copy_outline_level(
|
||||
dst: &mut Document,
|
||||
m: &Copier<'_>,
|
||||
first: Option<ObjRef>,
|
||||
parent: ObjRef,
|
||||
depth: u8,
|
||||
budget: &mut usize,
|
||||
) -> Option<(ObjRef, ObjRef, usize)> {
|
||||
if depth > 32 {
|
||||
return None;
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut made: Vec<ObjRef> = Vec::new();
|
||||
let mut total = 0;
|
||||
let mut cur = first;
|
||||
while let Some(r) = cur {
|
||||
if !seen.insert(r) || *budget == 0 {
|
||||
break;
|
||||
}
|
||||
*budget -= 1;
|
||||
let Some(item) = m.src.get(r).as_dict().cloned() else { break };
|
||||
cur = item.reference(b"Next");
|
||||
let new = dst.add(Object::Null);
|
||||
let mut d = Dict::new();
|
||||
if let Some(t) = item.get(b"Title").map(|t| m.src.resolve(t)) {
|
||||
d.set(b"Title".to_vec(), t.as_ref().clone());
|
||||
}
|
||||
for k in [&b"C"[..], b"F"] {
|
||||
if let Some(v) = item.get(k) {
|
||||
d.set(k.to_vec(), m.src.resolve(v).as_ref().clone());
|
||||
}
|
||||
}
|
||||
let target = item.get(b"Dest").cloned().or_else(|| {
|
||||
let a = m.src.resolve(item.get(b"A")?);
|
||||
let a = a.as_dict()?;
|
||||
(a.name(b"S") == Some(b"GoTo")).then(|| a.get(b"D").cloned()).flatten()
|
||||
});
|
||||
if let Some(dest) = target.and_then(|t| m.map_dest(&t)) {
|
||||
d.set(b"Dest".to_vec(), dest);
|
||||
}
|
||||
d.set(b"Parent".to_vec(), Object::Ref(parent));
|
||||
if let Some(prev) = made.last() {
|
||||
d.set(b"Prev".to_vec(), Object::Ref(*prev));
|
||||
dst.update_dict(*prev, |p| p.set(b"Next".to_vec(), Object::Ref(new))).ok()?;
|
||||
}
|
||||
if let Some((f, l, n)) = copy_outline_level(dst, m, item.reference(b"First"), new, depth + 1, budget) {
|
||||
d.set(b"First".to_vec(), Object::Ref(f));
|
||||
d.set(b"Last".to_vec(), Object::Ref(l));
|
||||
// Keep the source's open/closed state.
|
||||
let open = item.int(b"Count").is_some_and(|c| c > 0);
|
||||
d.set(b"Count".to_vec(), Object::Int(if open { n as i64 } else { -(n as i64) }));
|
||||
}
|
||||
dst.set(new, Object::Dict(d));
|
||||
made.push(new);
|
||||
total += 1;
|
||||
}
|
||||
Some((*made.first()?, *made.last()?, total))
|
||||
}
|
||||
|
||||
/// Page `page` of `src` as a form XObject in `dst` (its content and resources, no annotations),
|
||||
/// for backgrounds and watermarks taken from a PDF. The form's `/BBox` is the page's crop box
|
||||
/// and its `/Matrix` undoes the page rotation, so it draws upright as displayed. Returns the
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ pub mod view;
|
|||
|
||||
pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box};
|
||||
pub use dedupe::dedupe_resources;
|
||||
pub use import::{Run, SplitBy, combine, combine_grouped, combine_selected, extract_pages, import_pages, page_as_form, split, split_ranges};
|
||||
pub use import::{
|
||||
Combiner, Run, SplitBy, combine, combine_grouped, combine_selected, extract_pages, import_pages, page_as_form, split, split_ranges,
|
||||
};
|
||||
pub use labels::{LabelRange, LabelStyle, PageLabels, number_pages, page_label_ranges, page_labels, set_page_label_ranges};
|
||||
pub use outline::{
|
||||
Bookmark, BookmarkPage, OutlineEntry, OutlineError, add_bookmark, add_bookmark_tree, bookmark_page, bookmarks, delete_bookmark, move_bookmark,
|
||||
|
|
@ -274,7 +276,8 @@ fn drop_named_destinations(doc: &mut Document, gone: &[ObjRef]) -> Result<Vec<Ve
|
|||
}
|
||||
}
|
||||
Some(Object::Dict(mut d)) => {
|
||||
if prune_legacy(doc, &mut d, gone, &mut dead) {
|
||||
let changed = prune_legacy(doc, &mut d, gone, &mut dead);
|
||||
if changed {
|
||||
doc.update_dict(root, |c| c.set(b"Dests".to_vec(), Object::Dict(d)))?;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,11 @@ impl PrintStandard {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether the document declares output intents (its printing condition and profile).
|
||||
pub(crate) fn declares_output_intents(&self) -> bool {
|
||||
self.intents.is_some()
|
||||
}
|
||||
|
||||
/// Whether `other` declares the same standard: the same version and trapping, and the same
|
||||
/// output intents (type, printing condition and profile).
|
||||
pub(crate) fn same_as(&self, other: &Self) -> bool {
|
||||
|
|
@ -82,10 +87,12 @@ impl PrintStandard {
|
|||
|
||||
/// Give `dst` the print standard `src` declares: its output intents, copied with their profiles
|
||||
/// (through `map`, what the pages already brought along), and, for PDF/X, the document
|
||||
/// information entries and the XMP metadata that identify it.
|
||||
pub(crate) fn carry(dst: &mut Document, src: &Document, standard: &PrintStandard, map: HashMap<ObjRef, ObjRef>) -> Result<(), OrganizeError> {
|
||||
/// information entries and the XMP metadata that identify it. The output intents are copied from
|
||||
/// `src`, so a standard that declares them needs the source document (`None` otherwise).
|
||||
pub(crate) fn carry(dst: &mut Document, src: Option<&Document>, standard: &PrintStandard, map: HashMap<ObjRef, ObjRef>) -> Result<(), OrganizeError> {
|
||||
let root = dst.root().ok_or(OrganizeError::NoPageTree)?;
|
||||
if let Some(intents) = &standard.intents {
|
||||
let src = src.ok_or_else(|| OrganizeError::Invalid("the output intents' source document was not kept".into()))?;
|
||||
let copied = crate::import::copy_object(dst, src, intents, map);
|
||||
dst.update_dict(root, |c| c.set(b"OutputIntents".to_vec(), copied))?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -416,6 +416,41 @@ fn combine_concatenates_with_a_bookmark_per_file() {
|
|||
assert_eq!(dest_page, pages(&out).unwrap()[3].obj);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combiner_adds_each_source_then_releases_it() {
|
||||
let bytes = Arc::new(fixture());
|
||||
let src = Document::open(bytes.clone()).unwrap();
|
||||
let mut combiner = crate::Combiner::new();
|
||||
combiner.add("A", &src, None).unwrap();
|
||||
drop(src);
|
||||
// No output intents to copy, so the combine keeps nothing of the source's file.
|
||||
assert_eq!(Arc::strong_count(&bytes), 1);
|
||||
let picked: &[usize] = &[1];
|
||||
combiner.add("B", &open(fixture()), Some(picked)).unwrap();
|
||||
let streamed = combiner.finish().unwrap();
|
||||
let whole = combine_selected(&[("A", &open(fixture()), None), ("B", &open(fixture()), Some(picked))]).unwrap();
|
||||
assert_eq!(write_full(&streamed, &SaveOptions::default()).unwrap(), write_full(&whole, &SaveOptions::default()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combining_hostile_outlines_ends_their_runs_too() {
|
||||
// The bookmark cases of `hostile_outlines_end_their_runs_and_never_loop`, combined: each run ends
|
||||
// where it would repeat, and the source's bookmarks sit under its own entry.
|
||||
let cases: [(&[&str], &str); 3] = [
|
||||
(&["<< /Title (Self) /Parent 3 0 R /Next 5 0 R >>"], "H[Self]"),
|
||||
(&["<< /Title (A) /Parent 3 0 R /Next 6 0 R >>", "<< /Title (B) /Parent 3 0 R /Next 5 0 R >>"], "H[A,B]"),
|
||||
(&["<< /Title (A) /Parent 3 0 R /Next 99 0 R >>"], "H[A]"),
|
||||
];
|
||||
for (items, listed) in cases {
|
||||
let out = full_roundtrip(&combine(&[("H", &outline_with(items))]).unwrap());
|
||||
assert_eq!(titles(&crate::bookmarks(&out)), [listed]);
|
||||
}
|
||||
// A bookmark whose first child is itself: nesting is cut off, so the combine still ends.
|
||||
let looped = outline_with(&["<< /Title (Self) /Parent 3 0 R /First 5 0 R >>"]);
|
||||
let out = full_roundtrip(&combine(&[("H", &looped)]).unwrap());
|
||||
assert_eq!(crate::bookmarks(&out).first().map(|b| b.title.as_str()), Some("H"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_attributes_travel_with_copied_pages() {
|
||||
let out = full_roundtrip(&extract_pages(&doc_b(), &[1]).unwrap());
|
||||
|
|
|
|||
|
|
@ -11,7 +11,14 @@ workspace = true
|
|||
[dependencies]
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# The desktop's light/dark choice (see `desktop_theme.rs`). Already in the tree, with the features
|
||||
# this needs, through accesskit's accessibility bus; pinned to the same version so nothing is
|
||||
# built twice.
|
||||
zbus = "=5.19.0"
|
||||
# Already in the dependency graph through the accessibility integration. The portal watcher
|
||||
# uses the async API so dropping it can cancel an idle subscription without polling.
|
||||
zbus = { version = "=5.19.0", default-features = false, features = ["async-io"] }
|
||||
async-channel = "2"
|
||||
futures-lite = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
# Exercise the actual portal wire decoder and cancellable worker on every native test host.
|
||||
zbus = { version = "=5.19.0", default-features = false, features = ["async-io"] }
|
||||
async-channel = "2"
|
||||
futures-lite = "2"
|
||||
|
|
|
|||
|
|
@ -1,136 +1,550 @@
|
|||
//! The desktop's own light/dark choice, for an interface setting that follows it.
|
||||
//! Linux desktop appearance through the XDG Settings portal. Other platforms use native window
|
||||
//! events. No desktop polling or UI dependency is needed here.
|
||||
//!
|
||||
//! winit reports no system theme on Linux — `ActiveEventLoop::system_theme` is `None` on every
|
||||
//! desktop there — so "Use system setting" has to ask the desktop itself. The XDG Desktop Portal's
|
||||
//! Settings interface answers on GNOME, KDE and XFCE alike, over X11 and Wayland alike.
|
||||
//! Protocol reference: <https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Settings.html>.
|
||||
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
use std::time::Duration;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
|
||||
/// How often [`DesktopTheme::start`] re-reads the desktop's answer. The portal also publishes a
|
||||
/// change signal, but subscribing needs a connection kept alive for the life of every window; one
|
||||
/// round trip every few seconds is cheap and reacts soon enough after a theme switch.
|
||||
const POLL: Duration = Duration::from_secs(4);
|
||||
|
||||
/// Whether the desktop asks applications to draw dark. `None` when it says nothing, and when
|
||||
/// there is no session bus or no portal to ask (a bare X server, a container), so the caller
|
||||
/// keeps whatever it chose itself.
|
||||
pub fn prefers_dark() -> Option<bool> {
|
||||
read()
|
||||
/// A concrete appearance requested by the desktop. An absent value means no preference or no
|
||||
/// available appearance source; the UI owns its fallback and its saved manual choice.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DesktopAppearance {
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
/// Watches the desktop and reports its choice when it changes. [`prefers_dark`] covers the
|
||||
/// answer at start-up; this covers the user switching theme while the app runs.
|
||||
#[derive(Default)]
|
||||
struct Shared {
|
||||
appearance: AtomicU8,
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
published: AtomicBool,
|
||||
active: AtomicBool,
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
wake: Box<dyn Fn() + Send + Sync>,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
fn new(wake: impl Fn() + Send + Sync + 'static) -> Self {
|
||||
#[cfg(not(any(target_os = "linux", test)))]
|
||||
let _ = wake;
|
||||
Self {
|
||||
appearance: AtomicU8::new(0),
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
published: AtomicBool::new(false),
|
||||
active: AtomicBool::new(true),
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
wake: Box::new(wake),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self) -> Option<DesktopAppearance> {
|
||||
match self.appearance.load(Ordering::Acquire) {
|
||||
1 => Some(DesktopAppearance::Dark),
|
||||
2 => Some(DesktopAppearance::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn publish(&self, appearance: Option<DesktopAppearance>) {
|
||||
let encoded = match appearance {
|
||||
Some(DesktopAppearance::Dark) => 1,
|
||||
Some(DesktopAppearance::Light) => 2,
|
||||
None => 0,
|
||||
};
|
||||
let changed = self.appearance.swap(encoded, Ordering::AcqRel) != encoded;
|
||||
let first = !self.published.swap(true, Ordering::AcqRel);
|
||||
// Publish before waking: an otherwise idle UI must see the new answer on its next frame.
|
||||
if (first || changed) && self.active.load(Ordering::Acquire) {
|
||||
(self.wake)();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An event-driven appearance cache. Start when System is first selected and keep it alive while
|
||||
/// the window exists. Manual modes can suppress wakeups while preserving the latest desktop value.
|
||||
pub struct DesktopTheme {
|
||||
/// `None` when no watcher thread could be started; the one-shot read still works.
|
||||
changes: Option<Receiver<Option<bool>>>,
|
||||
shared: Arc<Shared>,
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
stop: Option<async_channel::Sender<()>>,
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
worker: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl DesktopTheme {
|
||||
/// Start watching. Nothing runs before this is called, and calling it once is enough. The
|
||||
/// first answer is sent straight away, so the caller does not have to ask for it separately.
|
||||
pub fn start() -> Self {
|
||||
let (tx, rx) = channel();
|
||||
let started = std::thread::Builder::new().name("pdfcraft-desktop-theme".into()).spawn(move || {
|
||||
let mut last = read();
|
||||
if tx.send(last).is_err() {
|
||||
return;
|
||||
/// Start a Linux portal subscription off the UI thread. The first answer and subsequent
|
||||
/// changes call `wake` while active. Missing services and thread-start failure yield `None`.
|
||||
/// On other platforms this is inert because the window integration owns appearance events.
|
||||
pub fn start(wake: impl Fn() + Send + Sync + 'static) -> Self {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Self::spawn(wake, portal::watch)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
Self {
|
||||
shared: Arc::new(Shared::new(wake)),
|
||||
#[cfg(test)]
|
||||
stop: None,
|
||||
#[cfg(test)]
|
||||
worker: None,
|
||||
}
|
||||
loop {
|
||||
std::thread::sleep(POLL);
|
||||
let now = read();
|
||||
if now != last {
|
||||
last = now;
|
||||
if tx.send(now).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the latest desktop answer without blocking or making a D-Bus call.
|
||||
pub fn get(&self) -> Option<DesktopAppearance> {
|
||||
self.shared.get()
|
||||
}
|
||||
|
||||
/// Allow UI wakeups only while following the desktop. Events still update the cache in
|
||||
/// manual mode, so selecting System again can immediately resolve the current appearance.
|
||||
pub fn set_active(&self, active: bool) {
|
||||
self.shared.active.store(active, Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn spawn<F, T>(wake: impl Fn() + Send + Sync + 'static, task: T) -> Self
|
||||
where
|
||||
F: std::future::Future<Output = ()> + 'static,
|
||||
T: FnOnce(Arc<Shared>) -> F + Send + 'static,
|
||||
{
|
||||
let shared = Arc::new(Shared::new(wake));
|
||||
let thread_shared = Arc::clone(&shared);
|
||||
let (stop, stopped) = async_channel::bounded::<()>(1);
|
||||
let worker = std::thread::Builder::new().name("pdfcraft-desktop-theme".into()).spawn(move || {
|
||||
futures_lite::future::block_on(futures_lite::future::race(
|
||||
async {
|
||||
let _ = stopped.recv().await;
|
||||
},
|
||||
task(thread_shared),
|
||||
));
|
||||
});
|
||||
Self { shared, stop: Some(stop), worker: worker.ok() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DesktopTheme {
|
||||
fn drop(&mut self) {
|
||||
self.set_active(false);
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
{
|
||||
if let Some(stop) = self.stop.take() {
|
||||
stop.close();
|
||||
}
|
||||
if let Some(worker) = self.worker.take() {
|
||||
// Cancellation also interrupts connection setup, the initial read, and idle waits.
|
||||
// A worker panic does not propagate into the window or its open document.
|
||||
let _ = worker.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod portal {
|
||||
use super::{DesktopAppearance, Shared};
|
||||
use futures_lite::StreamExt;
|
||||
use std::sync::Arc;
|
||||
use zbus::zvariant::{OwnedValue, Value};
|
||||
|
||||
const DESTINATION: &str = "org.freedesktop.portal.Desktop";
|
||||
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
const INTERFACE: &str = "org.freedesktop.portal.Settings";
|
||||
const NAMESPACE: &str = "org.freedesktop.appearance";
|
||||
const KEY: &str = "color-scheme";
|
||||
/// The colour scheme under its GNOME schema name, as a string. GNOME's portal answers it, and
|
||||
/// so does XFCE's, which proxies the GSettings schemas but not the freedesktop namespace. KDE
|
||||
/// answers only the freedesktop one, so that one wins when it states a preference.
|
||||
const GNOME_NAMESPACE: &str = "org.gnome.desktop.interface";
|
||||
|
||||
/// Standard uint32 semantics, including future values treated as no preference.
|
||||
fn numeric(value: &Value<'_>) -> Option<Option<DesktopAppearance>> {
|
||||
match value {
|
||||
Value::U32(1) => Some(Some(DesktopAppearance::Dark)),
|
||||
Value::U32(2) => Some(Some(DesktopAppearance::Light)),
|
||||
Value::U32(_) => Some(None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// GNOME's string values; anything but the two preferences (`default`) is no preference.
|
||||
fn gnome_scheme(value: &Value<'_>) -> Option<Option<DesktopAppearance>> {
|
||||
match value {
|
||||
Value::Str(scheme) => Some(match scheme.as_str() {
|
||||
"prefer-dark" => Some(DesktopAppearance::Dark),
|
||||
"prefer-light" => Some(DesktopAppearance::Light),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The freedesktop answer when it states a preference, otherwise the GNOME schema's.
|
||||
fn combined(freedesktop: Option<DesktopAppearance>, gnome: Option<DesktopAppearance>) -> Option<DesktopAppearance> {
|
||||
freedesktop.or(gnome)
|
||||
}
|
||||
|
||||
fn read_gnome_reply(reply: &zbus::Message) -> Option<DesktopAppearance> {
|
||||
let answer = reply.body().deserialize::<OwnedValue>().ok()?;
|
||||
// Same two variant layers as the freedesktop Read.
|
||||
let Value::Value(inner) = &*answer else { return None };
|
||||
gnome_scheme(inner).flatten()
|
||||
}
|
||||
|
||||
fn gnome_setting_changed(message: &zbus::Message) -> Option<Option<DesktopAppearance>> {
|
||||
let body = message.body();
|
||||
let (namespace, key, value) = body.deserialize::<(&str, &str, OwnedValue)>().ok()?;
|
||||
if namespace != GNOME_NAMESPACE || key != KEY {
|
||||
return None;
|
||||
}
|
||||
gnome_scheme(&value)
|
||||
}
|
||||
|
||||
fn read_reply(reply: &zbus::Message) -> Option<DesktopAppearance> {
|
||||
let answer = reply.body().deserialize::<OwnedValue>().ok()?;
|
||||
// Legacy Read is available in Settings version 1 and returns two variant layers. The
|
||||
// OwnedValue decoder removes the outer layer; unwrap exactly the documented inner one.
|
||||
let Value::Value(inner) = &*answer else { return None };
|
||||
numeric(inner).flatten()
|
||||
}
|
||||
|
||||
fn setting_changed(message: &zbus::Message) -> Option<Option<DesktopAppearance>> {
|
||||
let body = message.body();
|
||||
let (namespace, key, value) = body.deserialize::<(&str, &str, OwnedValue)>().ok()?;
|
||||
if namespace != NAMESPACE || key != KEY {
|
||||
return None;
|
||||
}
|
||||
// SettingChanged has one variant layer, unlike legacy Read. Malformed signals do not
|
||||
// discard a valid cached appearance; numeric no-preference signals do clear it.
|
||||
numeric(&value)
|
||||
}
|
||||
|
||||
pub(super) async fn watch(shared: Arc<Shared>) {
|
||||
if watch_inner(&shared).await.is_err() {
|
||||
shared.publish(None);
|
||||
}
|
||||
}
|
||||
|
||||
async fn watch_inner(shared: &Shared) -> zbus::Result<()> {
|
||||
let connection = zbus::connection::Builder::session()?.method_timeout(std::time::Duration::from_secs(5)).build().await?;
|
||||
watch_connection(shared, connection).await
|
||||
}
|
||||
|
||||
fn setting_rule(namespace: &'static str) -> zbus::Result<zbus::MatchRule<'static>> {
|
||||
Ok(zbus::MatchRule::builder()
|
||||
.msg_type(zbus::message::Type::Signal)
|
||||
.sender(DESTINATION)?
|
||||
.path(PATH)?
|
||||
.interface(INTERFACE)?
|
||||
.member("SettingChanged")?
|
||||
.arg(0, namespace)?
|
||||
.arg(1, KEY)?
|
||||
.build())
|
||||
}
|
||||
|
||||
async fn watch_connection(shared: &Shared, connection: zbus::Connection) -> zbus::Result<()> {
|
||||
// Install the matches before reading. Unlike Proxy::receive_signal this does not require
|
||||
// the portal to already own its bus name, so Read can activate an installed portal.
|
||||
let freedesktop_changes = zbus::MessageStream::for_match_rule(setting_rule(NAMESPACE)?, &connection, Some(64)).await?;
|
||||
let gnome_changes = zbus::MessageStream::for_match_rule(setting_rule(GNOME_NAMESPACE)?, &connection, Some(64)).await?;
|
||||
let mut changes = freedesktop_changes.or(gnome_changes);
|
||||
let reply = connection.call_method(Some(DESTINATION), PATH, Some(INTERFACE), "Read", &(NAMESPACE, KEY)).await;
|
||||
let mut freedesktop = reply.as_ref().ok().and_then(read_reply);
|
||||
let reply = connection.call_method(Some(DESTINATION), PATH, Some(INTERFACE), "Read", &(GNOME_NAMESPACE, KEY)).await;
|
||||
let mut gnome = reply.as_ref().ok().and_then(read_gnome_reply);
|
||||
shared.publish(combined(freedesktop, gnome));
|
||||
while let Some(message) = changes.next().await {
|
||||
let message = message?;
|
||||
// Keep every subscribed update, including those queued during Read. A Read handler
|
||||
// can capture an old value and emit a new one before returning its older snapshot.
|
||||
if let Some(appearance) = setting_changed(&message) {
|
||||
freedesktop = appearance;
|
||||
} else if let Some(appearance) = gnome_setting_changed(&message) {
|
||||
gnome = appearance;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
shared.publish(combined(freedesktop, gnome));
|
||||
}
|
||||
shared.publish(None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn reply(value: Value<'_>) -> zbus::Message {
|
||||
let call = zbus::Message::method_call(PATH, "Read").unwrap().build(&(NAMESPACE, KEY)).unwrap();
|
||||
zbus::Message::method_return(&call.header()).unwrap().build(&value).unwrap()
|
||||
}
|
||||
|
||||
fn signal(namespace: &str, key: &str, value: Value<'_>) -> zbus::Message {
|
||||
zbus::Message::signal(PATH, INTERFACE, "SettingChanged").unwrap().build(&(namespace, key, value)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_read_decodes_documented_double_variant_numeric_values() {
|
||||
// Keep the native subscription implementation typechecked on non-Linux test hosts.
|
||||
let _native_backend = watch;
|
||||
for (number, expected) in [(0, None), (1, Some(DesktopAppearance::Dark)), (2, Some(DesktopAppearance::Light)), (u32::MAX, None)] {
|
||||
assert_eq!(read_reply(&reply(Value::Value(Box::new(Value::U32(number))))), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_read_refuses_strings_wrong_types_and_extra_nesting() {
|
||||
assert_eq!(read_reply(&reply(Value::Value(Box::new(Value::from("prefer-dark"))))), None);
|
||||
assert_eq!(read_reply(&reply(Value::Value(Box::new(Value::I32(1))))), None);
|
||||
assert_eq!(read_reply(&reply(Value::Value(Box::new(Value::Value(Box::new(Value::U32(1))))))), None);
|
||||
assert_eq!(read_reply(&reply(Value::U32(1))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signals_distinguish_no_preference_from_unrelated_or_malformed_updates() {
|
||||
assert_eq!(setting_changed(&signal(NAMESPACE, KEY, Value::U32(1))), Some(Some(DesktopAppearance::Dark)));
|
||||
assert_eq!(setting_changed(&signal(NAMESPACE, KEY, Value::U32(2))), Some(Some(DesktopAppearance::Light)));
|
||||
assert_eq!(setting_changed(&signal(NAMESPACE, KEY, Value::U32(0))), Some(None));
|
||||
assert_eq!(setting_changed(&signal(NAMESPACE, KEY, Value::U32(1234))), Some(None));
|
||||
assert_eq!(setting_changed(&signal("org.gnome.desktop.interface", KEY, Value::U32(1))), None);
|
||||
assert_eq!(setting_changed(&signal(NAMESPACE, "contrast", Value::U32(1))), None);
|
||||
assert_eq!(setting_changed(&signal(NAMESPACE, KEY, Value::from("prefer-dark"))), None);
|
||||
assert_eq!(setting_changed(&signal(NAMESPACE, KEY, Value::Value(Box::new(Value::U32(1))))), None);
|
||||
let wrong_body = zbus::Message::signal(PATH, INTERFACE, "SettingChanged").unwrap().build(&(NAMESPACE, KEY)).unwrap();
|
||||
assert_eq!(setting_changed(&wrong_body), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gnome_schema_answers_when_the_freedesktop_one_states_no_preference() {
|
||||
// XFCE's portal proxies only the GSettings schemas; its answer is a string.
|
||||
assert_eq!(read_gnome_reply(&reply(Value::Value(Box::new(Value::from("prefer-dark"))))), Some(DesktopAppearance::Dark));
|
||||
assert_eq!(read_gnome_reply(&reply(Value::Value(Box::new(Value::from("prefer-light"))))), Some(DesktopAppearance::Light));
|
||||
assert_eq!(read_gnome_reply(&reply(Value::Value(Box::new(Value::from("default"))))), None);
|
||||
assert_eq!(read_gnome_reply(&reply(Value::Value(Box::new(Value::U32(1))))), None);
|
||||
assert_eq!(read_gnome_reply(&reply(Value::from("prefer-dark"))), None);
|
||||
assert_eq!(gnome_setting_changed(&signal(GNOME_NAMESPACE, KEY, Value::from("prefer-dark"))), Some(Some(DesktopAppearance::Dark)));
|
||||
assert_eq!(gnome_setting_changed(&signal(GNOME_NAMESPACE, KEY, Value::from("default"))), Some(None));
|
||||
assert_eq!(gnome_setting_changed(&signal(GNOME_NAMESPACE, "gtk-theme", Value::from("prefer-dark"))), None);
|
||||
assert_eq!(gnome_setting_changed(&signal(NAMESPACE, KEY, Value::from("prefer-dark"))), None);
|
||||
assert_eq!(gnome_setting_changed(&signal(GNOME_NAMESPACE, KEY, Value::U32(1))), None);
|
||||
// The freedesktop answer wins when it states one (KDE answers only that one).
|
||||
assert_eq!(combined(Some(DesktopAppearance::Light), Some(DesktopAppearance::Dark)), Some(DesktopAppearance::Light));
|
||||
assert_eq!(combined(None, Some(DesktopAppearance::Dark)), Some(DesktopAppearance::Dark));
|
||||
assert_eq!(combined(None, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
#[ignore = "requires dbus-daemon; uses only a private test session bus"]
|
||||
fn private_bus_delayed_read_keeps_queued_changes_and_wakes_idle_tracker() {
|
||||
use crate::desktop_theme::DesktopTheme;
|
||||
use std::io::BufRead;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{OnceLock, Weak, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
struct Daemon(std::process::Child);
|
||||
impl Drop for Daemon {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { changes: started.ok().map(|_| rx) }
|
||||
}
|
||||
|
||||
/// The desktop's new answer if it changed since the last call, to be polled once a frame.
|
||||
/// Several changes in one frame collapse to the newest, and a desktop that stops answering
|
||||
/// leaves the last answer in place.
|
||||
pub fn take_change(&mut self) -> Option<bool> {
|
||||
let Some(changes) = &self.changes else { return None };
|
||||
let mut newest = None;
|
||||
loop {
|
||||
match changes.try_recv() {
|
||||
Ok(changed) => newest = changed,
|
||||
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => return newest,
|
||||
struct Settings {
|
||||
captured: mpsc::Sender<()>,
|
||||
release: async_channel::Receiver<()>,
|
||||
}
|
||||
#[zbus::interface(name = "org.freedesktop.portal.Settings")]
|
||||
impl Settings {
|
||||
async fn read(&self, namespace: &str, key: &str) -> zbus::fdo::Result<OwnedValue> {
|
||||
if namespace != NAMESPACE || key != KEY {
|
||||
return Err(zbus::fdo::Error::InvalidArgs("unknown setting".into()));
|
||||
}
|
||||
// Capture Light before a later Dark signal, then deliberately return the
|
||||
// stale snapshot after that signal. Reply ordering cannot date this value.
|
||||
let snapshot = Value::Value(Box::new(Value::U32(2)));
|
||||
self.captured.send(()).map_err(|error| zbus::fdo::Error::Failed(error.to_string()))?;
|
||||
self.release.recv().await.map_err(|error| zbus::fdo::Error::Failed(error.to_string()))?;
|
||||
OwnedValue::try_from(snapshot).map_err(|error| zbus::fdo::Error::Failed(error.to_string()))
|
||||
}
|
||||
}
|
||||
fn wait_for(tracker: &DesktopTheme, appearance: Option<DesktopAppearance>) {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while tracker.get() != appearance {
|
||||
assert!(Instant::now() < deadline, "tracker did not consume the setting signal");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
fn emit(connection: &zbus::Connection, number: u32) {
|
||||
futures_lite::future::block_on(connection.emit_signal(
|
||||
None::<&str>,
|
||||
PATH,
|
||||
INTERFACE,
|
||||
"SettingChanged",
|
||||
&(NAMESPACE, KEY, OwnedValue::from(number)),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let child = Command::new("dbus-daemon")
|
||||
.args(["--session", "--nofork", "--print-address=1"])
|
||||
// Abstract sockets disappear with the daemon, even if a test fails.
|
||||
.arg(format!("--address=unix:abstract=pdfcraft-theme-test-{}", std::process::id()))
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut daemon = Daemon(child);
|
||||
let mut address = String::new();
|
||||
std::io::BufReader::new(daemon.0.stdout.take().unwrap()).read_line(&mut address).unwrap();
|
||||
assert!(!address.trim().is_empty());
|
||||
let (captured, capture_seen) = mpsc::channel();
|
||||
let (release, released) = async_channel::bounded(1);
|
||||
let server = futures_lite::future::block_on(
|
||||
zbus::connection::Builder::address(address.trim())
|
||||
.unwrap()
|
||||
.name(DESTINATION)
|
||||
.unwrap()
|
||||
.serve_at(PATH, Settings { captured, release: released })
|
||||
.unwrap()
|
||||
.build(),
|
||||
)
|
||||
.unwrap();
|
||||
let client = futures_lite::future::block_on(
|
||||
zbus::connection::Builder::address(address.trim()).unwrap().method_timeout(Duration::from_secs(5)).build(),
|
||||
)
|
||||
.unwrap();
|
||||
let source = Arc::new(OnceLock::<Weak<Shared>>::new());
|
||||
let observed = Arc::clone(&source);
|
||||
let (wake, woke) = mpsc::channel();
|
||||
let tracker = DesktopTheme::spawn(
|
||||
move || {
|
||||
wake.send(observed.get().unwrap().upgrade().unwrap().get()).unwrap();
|
||||
},
|
||||
move |shared| async move {
|
||||
watch_connection(&shared, client).await.unwrap();
|
||||
},
|
||||
);
|
||||
source.set(Arc::downgrade(&tracker.shared)).unwrap();
|
||||
capture_seen.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
emit(&server, 1);
|
||||
release.send_blocking(()).unwrap();
|
||||
assert_eq!(woke.recv_timeout(Duration::from_secs(5)).unwrap(), Some(DesktopAppearance::Light));
|
||||
assert_eq!(woke.recv_timeout(Duration::from_secs(5)).unwrap(), Some(DesktopAppearance::Dark));
|
||||
assert_eq!(tracker.get(), Some(DesktopAppearance::Dark));
|
||||
|
||||
emit(&server, 2);
|
||||
assert_eq!(woke.recv_timeout(Duration::from_secs(5)).unwrap(), Some(DesktopAppearance::Light));
|
||||
emit(&server, 0);
|
||||
assert_eq!(woke.recv_timeout(Duration::from_secs(5)).unwrap(), None);
|
||||
tracker.set_active(false);
|
||||
emit(&server, 1);
|
||||
wait_for(&tracker, Some(DesktopAppearance::Dark));
|
||||
emit(&server, 2);
|
||||
wait_for(&tracker, Some(DesktopAppearance::Light));
|
||||
assert!(matches!(woke.try_recv(), Err(mpsc::TryRecvError::Empty)));
|
||||
tracker.set_active(true);
|
||||
assert_eq!(tracker.get(), Some(DesktopAppearance::Light));
|
||||
emit(&server, 1);
|
||||
assert_eq!(woke.recv_timeout(Duration::from_secs(5)).unwrap(), Some(DesktopAppearance::Dark));
|
||||
// Closing the tracker must cancel an idle bus subscription and join the worker.
|
||||
drop(tracker);
|
||||
drop(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's
|
||||
/// thread is its only caller.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read() -> Option<bool> {
|
||||
const PORTAL: &str = "org.freedesktop.portal.Desktop";
|
||||
const SETTINGS: &str = "org.freedesktop.portal.Settings";
|
||||
// The colour scheme under its GNOME schema name first: GNOME's portal answers it, and so
|
||||
// does XFCE's, which proxies the GSettings schemas but not the older freedesktop one. KDE
|
||||
// answers only the second.
|
||||
const SCHEMAS: [(&str, &str); 2] = [("org.gnome.desktop.interface", "color-scheme"), ("org.freedesktop.interface.color-scheme", "color-scheme")];
|
||||
let connection = zbus::blocking::Connection::session().ok()?;
|
||||
for (schema, key) in SCHEMAS {
|
||||
let Ok(reply) = connection.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), "ReadOne", &(schema, key)) else {
|
||||
continue;
|
||||
};
|
||||
let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue };
|
||||
// The reply is one variant wrapping a string.
|
||||
if let Ok(scheme) = String::try_from(&*answer) {
|
||||
return parse(&scheme);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The portal's `color-scheme` answer. `default` means the desktop states no preference, so
|
||||
/// neither does the application.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse(scheme: &str) -> Option<bool> {
|
||||
match scheme {
|
||||
"prefer-dark" => Some(true),
|
||||
"prefer-light" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS and Windows answer through winit, which the caller already has.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn read() -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
/// The portal answers with one of three strings; only the two preferences are a choice.
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn the_portal_answer_reads_as_a_choice_or_as_none() {
|
||||
assert_eq!(parse("prefer-dark"), Some(true));
|
||||
assert_eq!(parse("prefer-light"), Some(false));
|
||||
assert_eq!(parse("default"), None);
|
||||
assert_eq!(parse(""), None);
|
||||
fn initial_and_changed_results_wake_after_publication_but_duplicates_do_not() {
|
||||
let wakes = Arc::new(AtomicUsize::new(0));
|
||||
let observed = Arc::clone(&wakes);
|
||||
let source = Arc::new(std::sync::OnceLock::<std::sync::Weak<Shared>>::new());
|
||||
let observed_source = Arc::clone(&source);
|
||||
let (snapshot, snapshots) = std::sync::mpsc::channel();
|
||||
let shared = Arc::new(Shared::new(move || {
|
||||
snapshot.send(observed_source.get().unwrap().upgrade().unwrap().get()).unwrap();
|
||||
observed.fetch_add(1, Ordering::Relaxed);
|
||||
}));
|
||||
source.set(Arc::downgrade(&shared)).unwrap();
|
||||
shared.publish(None);
|
||||
assert_eq!(snapshots.try_recv().unwrap(), None);
|
||||
assert_eq!(wakes.load(Ordering::Relaxed), 1);
|
||||
shared.publish(None);
|
||||
assert_eq!(wakes.load(Ordering::Relaxed), 1);
|
||||
shared.publish(Some(DesktopAppearance::Dark));
|
||||
assert_eq!(snapshots.try_recv().unwrap(), Some(DesktopAppearance::Dark));
|
||||
assert_eq!(shared.get(), Some(DesktopAppearance::Dark));
|
||||
assert_eq!(wakes.load(Ordering::Relaxed), 2);
|
||||
shared.publish(Some(DesktopAppearance::Dark));
|
||||
assert_eq!(wakes.load(Ordering::Relaxed), 2);
|
||||
shared.publish(Some(DesktopAppearance::Light));
|
||||
assert_eq!(snapshots.try_recv().unwrap(), Some(DesktopAppearance::Light));
|
||||
assert_eq!(shared.get(), Some(DesktopAppearance::Light));
|
||||
assert_eq!(wakes.load(Ordering::Relaxed), 3);
|
||||
shared.publish(None);
|
||||
assert_eq!(snapshots.try_recv().unwrap(), None);
|
||||
assert_eq!(shared.get(), None);
|
||||
assert_eq!(wakes.load(Ordering::Relaxed), 4);
|
||||
assert!(matches!(snapshots.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_mode_caches_changes_without_waking_and_system_resumes_latest_value() {
|
||||
let wakes = Arc::new(AtomicUsize::new(0));
|
||||
let observed = Arc::clone(&wakes);
|
||||
let tracker = DesktopTheme::spawn(
|
||||
move || {
|
||||
observed.fetch_add(1, Ordering::Relaxed);
|
||||
},
|
||||
|_| futures_lite::future::pending(),
|
||||
);
|
||||
tracker.set_active(false);
|
||||
tracker.shared.publish(Some(DesktopAppearance::Dark));
|
||||
tracker.shared.publish(Some(DesktopAppearance::Light));
|
||||
assert_eq!(tracker.get(), Some(DesktopAppearance::Light));
|
||||
assert_eq!(wakes.load(Ordering::Relaxed), 0);
|
||||
tracker.set_active(true);
|
||||
assert_eq!(tracker.get(), Some(DesktopAppearance::Light));
|
||||
tracker.shared.publish(Some(DesktopAppearance::Dark));
|
||||
assert_eq!(wakes.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_tracker_cancels_and_joins_an_idle_worker() {
|
||||
struct Finished(Arc<AtomicBool>);
|
||||
impl Drop for Finished {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
let finished = Arc::new(AtomicBool::new(false));
|
||||
let worker_finished = Arc::clone(&finished);
|
||||
let (ready, waiting) = std::sync::mpsc::channel();
|
||||
let tracker = DesktopTheme::spawn(
|
||||
|| {},
|
||||
move |_| async move {
|
||||
let _finished = Finished(worker_finished);
|
||||
ready.send(()).unwrap();
|
||||
futures_lite::future::pending::<()>().await;
|
||||
},
|
||||
);
|
||||
waiting.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
|
||||
drop(tracker);
|
||||
assert!(finished.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
/// Off Linux the caller already has winit's answer, so this adds nothing.
|
||||
#[test]
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn other_platforms_answer_through_winit() {
|
||||
assert_eq!(prefers_dark(), None);
|
||||
}
|
||||
|
||||
/// A watcher that was never started is inert rather than a panic.
|
||||
#[test]
|
||||
fn a_watcher_without_a_thread_is_inert() {
|
||||
assert_eq!(DesktopTheme::default().take_change(), None);
|
||||
fn native_non_linux_tracker_is_inert() {
|
||||
let tracker = DesktopTheme::start(|| panic!("non-Linux tracker must not wake"));
|
||||
assert_eq!(tracker.get(), None);
|
||||
tracker.set_active(false);
|
||||
tracker.set_active(true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,17 +58,90 @@ impl Level {
|
|||
}
|
||||
|
||||
/// A rule a document breaks.
|
||||
///
|
||||
/// [`message`](Issue::message) is the problem in English, ready to read. An interface that reads in
|
||||
/// another language says it from [`pattern`](Issue::pattern) and [`value`](Issue::value) instead:
|
||||
/// the pattern is wording of ours, listed in [`WORDINGS`], and the value — when there is one — is
|
||||
/// the document's own name for something, a PDF keyword that reads the same in every language.
|
||||
/// [`Issue::said`] does that.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Issue {
|
||||
/// The ISO 19005-2 clause.
|
||||
pub clause: &'static str,
|
||||
pub message: String,
|
||||
/// The problem as it is worded here, with `{}` where the document's own name goes.
|
||||
pub pattern: &'static str,
|
||||
/// What goes in the pattern's `{}`: an annotation subtype, an action, a font, a page number.
|
||||
pub value: Option<String>,
|
||||
/// 0-based page, when the problem is on a page.
|
||||
pub page: Option<usize>,
|
||||
/// Whether [`convert`] can fix it.
|
||||
pub fixable: bool,
|
||||
}
|
||||
|
||||
impl Issue {
|
||||
/// The problem as one sentence, said in the language `words` translates into.
|
||||
///
|
||||
/// The wording is translated and the document's own name goes in afterwards, in one pass, so a
|
||||
/// font called `{}` cannot turn into anything else. With [`in_english`] this is
|
||||
/// [`message`](Issue::message).
|
||||
pub fn said(&self, words: Words) -> String {
|
||||
let said = words(self.pattern);
|
||||
match &self.value {
|
||||
Some(v) => said.replacen("{}", v, 1),
|
||||
None => said,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How an interface says one of our wordings in its own language: given the English in [`WORDINGS`],
|
||||
/// the same wording in that language. [`in_english`] keeps the English.
|
||||
pub type Words<'a> = &'a dyn Fn(&str) -> String;
|
||||
|
||||
/// The [`Words`] that translate nothing: the wording as it is written here.
|
||||
pub fn in_english(wording: &str) -> String {
|
||||
wording.to_owned()
|
||||
}
|
||||
|
||||
/// Every problem [`verify`] can report, in English, with `{}` where the document's own name goes.
|
||||
///
|
||||
/// An interface that shows them in another language translates these, which is the whole reason they
|
||||
/// are gathered here; `wordings_are_complete` fails if a rule says something that is not on the
|
||||
/// list. What [`convert`] reports as fixed is not here: that is read by scripts, in English.
|
||||
pub const WORDINGS: &[&str] = &[
|
||||
// Identification and metadata.
|
||||
"The document has no XMP metadata",
|
||||
"The metadata doesn't identify the file as {}",
|
||||
"The title in the metadata and the document information differ",
|
||||
// The document as a whole.
|
||||
"The document is encrypted",
|
||||
"Device colour is used, but there is no PDF/A output intent",
|
||||
"DeviceCMYK is used: it needs a CMYK output intent (not added automatically)",
|
||||
"DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile",
|
||||
"DeviceRGB is used, but the PDF/A output intent isn't an RGB profile",
|
||||
"The document has additional actions (/AA)",
|
||||
"The document has document-level JavaScript",
|
||||
"The form asks viewers to make appearances (NeedAppearances)",
|
||||
// Pages, actions, annotations and fields.
|
||||
"Page {} has additional actions",
|
||||
"A {} action isn't allowed",
|
||||
"{} annotations aren't allowed",
|
||||
"A {} annotation isn't set to print, or is hidden",
|
||||
"A {} annotation has no appearance stream",
|
||||
"A {} annotation has additional actions",
|
||||
"A form field has additional actions",
|
||||
// Fonts, streams, images and embedded files.
|
||||
"The font {} isn't embedded",
|
||||
"A font isn't embedded",
|
||||
"A stream uses LZW compression",
|
||||
"A stream refers to an external file",
|
||||
"An image asks to be interpolated",
|
||||
"An image has alternates or OPI information",
|
||||
"PostScript XObjects aren't allowed",
|
||||
"Embedded files must be PDF/A files themselves (use PDF/A-3 for others)",
|
||||
"An embedded file has no AFRelationship",
|
||||
];
|
||||
|
||||
/// What a document declares (the Standards panel).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Declared {
|
||||
|
|
@ -230,62 +303,70 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc<Object>)])
|
|||
/// Check `doc` against `level`.
|
||||
pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
|
||||
let mut out = Vec::new();
|
||||
let mut issue = |clause: &'static str, message: String, page: Option<usize>, fixable: bool| out.push(Issue { clause, message, page, fixable });
|
||||
// Each problem is worded here and named by the document there: the wording goes on the list in
|
||||
// `WORDINGS` for an interface to translate, and the name goes in its `{}` as it stands.
|
||||
let mut issue = |clause: &'static str, pattern: &'static str, value: Option<String>, page: Option<usize>, fixable: bool| {
|
||||
let message = match &value {
|
||||
Some(v) => pattern.replacen("{}", v, 1),
|
||||
None => pattern.to_owned(),
|
||||
};
|
||||
out.push(Issue { clause, message, pattern, value, page, fixable });
|
||||
};
|
||||
let cat = catalog(doc);
|
||||
// 6.1.3 / 6.6: identification and metadata.
|
||||
match metadata(doc) {
|
||||
None => issue("6.6.2.1", "The document has no XMP metadata".into(), None, true),
|
||||
None => issue("6.6.2.1", "The document has no XMP metadata", None, None, true),
|
||||
Some(x) => {
|
||||
let part = xmp::value(&x, "pdfaid:part");
|
||||
let conf = xmp::value(&x, "pdfaid:conformance");
|
||||
if part.as_deref() != Some(&level.part().to_string()) || conf.as_deref().map(str::to_uppercase).as_deref() != Some("B") {
|
||||
issue("6.6.4", format!("The metadata doesn't identify the file as {}", level.label()), None, true);
|
||||
issue("6.6.4", "The metadata doesn't identify the file as {}", Some(level.label().to_owned()), None, true);
|
||||
}
|
||||
if let Some(title) = info(doc, "Title")
|
||||
&& xmp::value(&x, "dc:title").is_some_and(|t| t != title)
|
||||
{
|
||||
issue("6.6.3", "The title in the metadata and the document information differ".into(), None, true);
|
||||
issue("6.6.3", "The title in the metadata and the document information differ", None, None, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 6.1.3: encryption.
|
||||
if doc.trailer().get(b"Encrypt").is_some() {
|
||||
issue("6.1.3", "The document is encrypted".into(), None, true);
|
||||
issue("6.1.3", "The document is encrypted", None, None, true);
|
||||
}
|
||||
// 6.2.2: output intent when device colour is used.
|
||||
let objs = objects(doc);
|
||||
let used = uses_device_colour(doc, &objs);
|
||||
match pdfa_intent_profile(doc) {
|
||||
None if used.any() => {
|
||||
issue("6.2.3", "Device colour is used, but there is no PDF/A output intent".into(), None, !used.cmyk);
|
||||
issue("6.2.3", "Device colour is used, but there is no PDF/A output intent", None, None, !used.cmyk);
|
||||
if used.cmyk {
|
||||
issue("6.2.4.3", "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)".into(), None, false);
|
||||
issue("6.2.4.3", "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)", None, None, false);
|
||||
}
|
||||
}
|
||||
// 6.2.4.3: DeviceRGB needs an RGB output intent and DeviceCMYK a CMYK one (#667).
|
||||
Some(n) => {
|
||||
if used.cmyk && !used.default_cmyk && n != Some(4) {
|
||||
issue("6.2.4.3", "DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile".into(), None, false);
|
||||
issue("6.2.4.3", "DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile", None, None, false);
|
||||
}
|
||||
if used.rgb && !used.default_rgb && n != Some(3) {
|
||||
issue("6.2.4.3", "DeviceRGB is used, but the PDF/A output intent isn't an RGB profile".into(), None, false);
|
||||
issue("6.2.4.3", "DeviceRGB is used, but the PDF/A output intent isn't an RGB profile", None, None, false);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
// 6.1.3 / 6.6.1: catalog actions.
|
||||
if cat.get(b"AA").is_some() {
|
||||
issue("6.5.2", "The document has additional actions (/AA)".into(), None, true);
|
||||
issue("6.5.2", "The document has additional actions (/AA)", None, None, true);
|
||||
}
|
||||
if let Some(names) = cat.get(b"Names").map(|n| doc.resolve(n))
|
||||
&& names.as_dict().is_some_and(|n| n.get(b"JavaScript").is_some())
|
||||
{
|
||||
issue("6.5.1", "The document has document-level JavaScript".into(), None, true);
|
||||
issue("6.5.1", "The document has document-level JavaScript", None, None, true);
|
||||
}
|
||||
if let Some(af) = cat.get(b"AcroForm").map(|a| doc.resolve(a))
|
||||
&& af.as_dict().is_some_and(|a| matches!(a.get(b"NeedAppearances"), Some(Object::Bool(true))))
|
||||
{
|
||||
issue("6.4.1", "The form asks viewers to make appearances (NeedAppearances)".into(), None, true);
|
||||
issue("6.4.1", "The form asks viewers to make appearances (NeedAppearances)", None, None, true);
|
||||
}
|
||||
// Pages: annotations and page actions.
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
|
|
@ -302,7 +383,7 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
|
|||
};
|
||||
for (i, p) in pages.iter().enumerate() {
|
||||
if p.dict.get(b"AA").is_some() {
|
||||
issue("6.5.2", format!("Page {} has additional actions", i + 1), Some(i), true);
|
||||
issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true);
|
||||
}
|
||||
}
|
||||
for (r, o) in &objs {
|
||||
|
|
@ -314,19 +395,19 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
|
|||
&& FORBIDDEN_ACTIONS.contains(&s)
|
||||
&& is_action(d)
|
||||
{
|
||||
issue("6.5.1", format!("A {} action isn't allowed", String::from_utf8_lossy(s)), None, true);
|
||||
issue("6.5.1", "A {} action isn't allowed", Some(String::from_utf8_lossy(s).into_owned()), None, true);
|
||||
}
|
||||
// Annotations.
|
||||
if ty == Some(b"Annot") || (sub.is_some() && d.get(b"Rect").is_some() && d.get(b"P").is_some()) {
|
||||
let page = page_of(*r);
|
||||
let name = sub.map(|s| String::from_utf8_lossy(s).into_owned()).unwrap_or_default();
|
||||
if sub.is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) {
|
||||
issue("6.3.1", format!("{name} annotations aren't allowed"), page, true);
|
||||
issue("6.3.1", "{} annotations aren't allowed", Some(name), page, true);
|
||||
continue;
|
||||
}
|
||||
let f = d.int(b"F").unwrap_or(0);
|
||||
if sub != Some(b"Popup") && (f & 4 == 0 || f & (1 | 2 | 32) != 0) {
|
||||
issue("6.3.2", format!("A {name} annotation isn't set to print, or is hidden"), page, true);
|
||||
issue("6.3.2", "A {} annotation isn't set to print, or is hidden", Some(name.clone()), page, true);
|
||||
}
|
||||
let has_ap = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).is_some_and(|a| a.get(b"N").is_some());
|
||||
if !has_ap && !matches!(sub, Some(b"Popup") | Some(b"Link")) {
|
||||
|
|
@ -335,16 +416,16 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
|
|||
v.len() == 4 && (v[2] - v[0]).abs() < 1e-6 && (v[3] - v[1]).abs() < 1e-6
|
||||
});
|
||||
if !zero {
|
||||
issue("6.3.3", format!("A {name} annotation has no appearance stream"), page, false);
|
||||
issue("6.3.3", "A {} annotation has no appearance stream", Some(name.clone()), page, false);
|
||||
}
|
||||
}
|
||||
if d.get(b"AA").is_some() {
|
||||
issue("6.5.2", format!("A {name} annotation has additional actions"), page, true);
|
||||
issue("6.5.2", "A {} annotation has additional actions", Some(name), page, true);
|
||||
}
|
||||
}
|
||||
// Form fields with additional actions (non-widget fields).
|
||||
if d.get(b"FT").is_some() && d.get(b"AA").is_some() && ty != Some(b"Annot") {
|
||||
issue("6.5.2", "A form field has additional actions".into(), None, true);
|
||||
issue("6.5.2", "A form field has additional actions", None, None, true);
|
||||
}
|
||||
// Fonts.
|
||||
if ty == Some(b"Font") || d.get(b"BaseFont").is_some() && sub.is_some() {
|
||||
|
|
@ -361,30 +442,30 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
|
|||
None => Vec::new(),
|
||||
};
|
||||
if filters.iter().any(|f| f == b"LZWDecode") {
|
||||
issue("6.1.7.2", "A stream uses LZW compression".into(), None, false);
|
||||
issue("6.1.7.2", "A stream uses LZW compression", None, None, false);
|
||||
}
|
||||
if s.dict.get(b"F").is_some() || s.dict.get(b"FFilter").is_some() {
|
||||
issue("6.1.7.1", "A stream refers to an external file".into(), None, false);
|
||||
issue("6.1.7.1", "A stream refers to an external file", None, None, false);
|
||||
}
|
||||
if sub == Some(b"Image") {
|
||||
if matches!(s.dict.get(b"Interpolate"), Some(Object::Bool(true))) {
|
||||
issue("6.2.8", "An image asks to be interpolated".into(), None, true);
|
||||
issue("6.2.8", "An image asks to be interpolated", None, None, true);
|
||||
}
|
||||
if s.dict.get(b"Alternates").is_some() || s.dict.get(b"OPI").is_some() {
|
||||
issue("6.2.8", "An image has alternates or OPI information".into(), None, true);
|
||||
issue("6.2.8", "An image has alternates or OPI information", None, None, true);
|
||||
}
|
||||
}
|
||||
if sub == Some(b"PS") {
|
||||
issue("6.2.9", "PostScript XObjects aren't allowed".into(), None, false);
|
||||
issue("6.2.9", "PostScript XObjects aren't allowed", None, None, false);
|
||||
}
|
||||
}
|
||||
// Embedded files.
|
||||
if ty == Some(b"Filespec") && d.get(b"EF").is_some() {
|
||||
match level {
|
||||
Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)".into(), None, false),
|
||||
Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", None, None, false),
|
||||
Level::A3b => {
|
||||
if d.get(b"AFRelationship").is_none() {
|
||||
issue("6.8", "An embedded file has no AFRelationship".into(), None, true);
|
||||
issue("6.8", "An embedded file has no AFRelationship", None, None, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -396,12 +477,12 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
|
|||
out
|
||||
}
|
||||
|
||||
fn check_font(doc: &Document, d: &Dict, issue: &mut impl FnMut(&'static str, String, Option<usize>, bool)) {
|
||||
fn check_font(doc: &Document, d: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option<String>, Option<usize>, bool)) {
|
||||
let sub = d.name(b"Subtype");
|
||||
if sub == Some(b"Type3") {
|
||||
return;
|
||||
}
|
||||
let name = d.name(b"BaseFont").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "a font".into());
|
||||
let name = d.name(b"BaseFont").map(|n| String::from_utf8_lossy(n).into_owned());
|
||||
let descriptor = if sub == Some(b"Type0") {
|
||||
d.get(b"DescendantFonts")
|
||||
.map(|x| doc.resolve(x))
|
||||
|
|
@ -416,7 +497,12 @@ fn check_font(doc: &Document, d: &Dict, issue: &mut impl FnMut(&'static str, Str
|
|||
.and_then(|fd| fd.as_dict().cloned())
|
||||
.is_some_and(|fd| fd.get(b"FontFile").is_some() || fd.get(b"FontFile2").is_some() || fd.get(b"FontFile3").is_some());
|
||||
if !embedded {
|
||||
issue("6.2.11.4", format!("The font {name} isn't embedded"), None, false);
|
||||
// A font the document doesn't name gets a sentence of its own, rather than the word for
|
||||
// "a font" put where a name goes, which read badly even in English.
|
||||
match name {
|
||||
Some(n) => issue("6.2.11.4", "The font {} isn't embedded", Some(n), None, false),
|
||||
None => issue("6.2.11.4", "A font isn't embedded", None, None, false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,39 @@ fn verify_reports_what_breaks_pdfa() {
|
|||
}
|
||||
assert!(issues.iter().find(|i| i.message.starts_with("The font")).is_some_and(|i| !i.fixable));
|
||||
assert!(clauses(&issues).contains(&"6.6.2.1"));
|
||||
// Each problem is also worded separately from what the document calls things, so an interface
|
||||
// can say it in another language; in English the two come to the same sentence.
|
||||
for i in &issues {
|
||||
assert_eq!(i.said(&in_english), i.message);
|
||||
assert!(WORDINGS.contains(&i.pattern), "{:?} is not in WORDINGS", i.pattern);
|
||||
}
|
||||
let font = issues.iter().find(|i| i.pattern == "The font {} isn't embedded").unwrap();
|
||||
assert_eq!(font.said(&|w: &str| format!("«{w}»")), "«The font {} isn't embedded»".replace("{}", "Helvetica"));
|
||||
}
|
||||
|
||||
/// Every problem the rules can report is listed in `WORDINGS`, so an interface showing them in
|
||||
/// another language has something to translate. The wording is `verify`'s second argument, which is
|
||||
/// what this reads off the file; the sentences `convert` reports as fixed are not wordings.
|
||||
#[test]
|
||||
fn wordings_are_complete() {
|
||||
let source = include_str!("lib.rs");
|
||||
let mut said = Vec::new();
|
||||
let mut rest = source;
|
||||
while let Some(at) = rest.find("issue(\"") {
|
||||
rest = &rest[at + 7..];
|
||||
// Step over the clause, then take the wording that follows it.
|
||||
let Some(end) = rest.find('"') else { break };
|
||||
rest = &rest[end + 1..];
|
||||
let Some(text) = rest.strip_prefix(", \"") else { continue };
|
||||
let Some(end) = text.find('"') else { break };
|
||||
said.push(&text[..end]);
|
||||
rest = text;
|
||||
}
|
||||
assert!(said.len() >= 24, "the wordings are not being read off the file: {said:#?}");
|
||||
let missing: Vec<&&str> = said.iter().filter(|word| !WORDINGS.contains(word)).collect();
|
||||
assert!(missing.is_empty(), "add these to WORDINGS, and a translation of each to every interface: {missing:#?}");
|
||||
let unsaid: Vec<&&str> = WORDINGS.iter().filter(|word| !said.contains(word)).collect();
|
||||
assert!(unsaid.is_empty(), "WORDINGS lists problems the rules no longer report: {unsaid:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -49,3 +49,25 @@ still needs hardware validation.
|
|||
|
||||
Not yet: the web spooler, print as image, poster labels, PostScript output, colour conversion for
|
||||
grayscale.
|
||||
|
||||
## Architectural sheets and scale
|
||||
|
||||
The Paper menu and `doc_print` share Letter, Legal, Tabloid, A3/A4/A5 plus
|
||||
ARCH A/B/C/D/E1/E, ANSI C/D/E and ISO A0/A1/A2. ARCH D is 24 × 36 inches;
|
||||
ANSI D is 22 × 34 inches. Dimensions follow the [HP DesignJet paper-size table](https://support.hp.com/hr-en/document/bpp01676).
|
||||
Automation accepts short names such as `"ARCH D"` (case-insensitive) or the full menu label.
|
||||
For example, `doc_print` with `paper: "ARCH D"`, `layout: "actual"`,
|
||||
`orientation: "auto"` and `path: "plan.pdf"` writes a print-ready PDF at 100%.
|
||||
|
||||
**Match page at 100%** uses the currently viewed document page to choose a standard
|
||||
sheet (either orientation, within 0.5 pt for rounded metric dimensions), Actual size
|
||||
and Auto orientation. It leaves the selected page range intact. It is disabled
|
||||
for a custom page size with no matching preset. One preset applies to the whole job;
|
||||
this is not per-page paper-source selection.
|
||||
|
||||
**PDF scale** describes the source-to-sheet transform in the previewed print-ready
|
||||
PDF, not measured physical output. Fit reserves an 18 pt margin and can reduce even
|
||||
a matching page. The current Windows backend still rasterises and can substitute
|
||||
paper/shrink a job if its driver lacks the requested size. These controls do not
|
||||
add #599's strict PrintTicket validation or vector spooler. Verify the driver's
|
||||
paper/scaling settings and physical calibration before relying on drawing scale.
|
||||
|
|
|
|||
|
|
@ -27,10 +27,22 @@ mod tests;
|
|||
|
||||
pub use range::{Subset, select_listed, select_pages};
|
||||
|
||||
/// What can go wrong when printing.
|
||||
///
|
||||
/// The three things a *person* can be wrong about — there is nothing to print, a page number past
|
||||
/// the end, a range that is not a page — each have their own variant with the numbers apart from
|
||||
/// the words, so an interface can say them in its own language with [`PrintError::said`]. The rest
|
||||
/// are faults rather than mistakes and carry their text as it is.
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum PrintError {
|
||||
#[error("there are no pages to print")]
|
||||
NoPages,
|
||||
/// A page number past the end of the document: the number asked for, and the page count.
|
||||
#[error("page {0} is out of range (1–{1})")]
|
||||
OutOfRange(usize, usize),
|
||||
/// A range token that is neither a page number nor a page label.
|
||||
#[error("{0:?} is not a page number or label")]
|
||||
NotAPage(String),
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
#[error("{0}")]
|
||||
|
|
@ -39,6 +51,67 @@ pub enum PrintError {
|
|||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
}
|
||||
|
||||
/// How an interface says one of our wordings in its own language, holes and all: given the English
|
||||
/// in [`WORDINGS`], the same wording in that language. [`in_english`] keeps the English.
|
||||
pub type Words<'a> = &'a dyn Fn(&str) -> String;
|
||||
|
||||
/// The [`Words`] that translate nothing: the wording as it is written here.
|
||||
pub fn in_english(wording: &str) -> String {
|
||||
wording.to_owned()
|
||||
}
|
||||
|
||||
/// What a person can get wrong about what to print, in English, with their holes still in them.
|
||||
///
|
||||
/// An interface that reports these in another language translates these three, which is the whole
|
||||
/// reason they are gathered here; `wordings_are_said` keeps them in step with the errors.
|
||||
pub const WORDINGS: &[&str] = &["there are no pages to print", "page {n} is out of range (1–{count})", "{page} is not a page number or label"];
|
||||
|
||||
impl PrintError {
|
||||
/// This error as one sentence, said in the language `words` translates into.
|
||||
///
|
||||
/// The wording is translated and the numbers go in afterwards, in a single pass, so what the
|
||||
/// person typed cannot be read as a hole. With [`in_english`] this is the error's own
|
||||
/// `Display`, which is what the command line and scripts report.
|
||||
pub fn said(&self, words: Words) -> String {
|
||||
match self {
|
||||
PrintError::NoPages => words("there are no pages to print"),
|
||||
PrintError::OutOfRange(n, count) => {
|
||||
say(&words("page {n} is out of range (1–{count})"), &[("{n}", n.to_string()), ("{count}", count.to_string())])
|
||||
}
|
||||
// The token goes in quoted, the way it is typed, as the error itself prints it.
|
||||
PrintError::NotAPage(token) => say(&words("{page} is not a page number or label"), &[("{page}", format!("{token:?}"))]),
|
||||
fault => fault.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A wording with its holes filled.
|
||||
///
|
||||
/// A hole is a name in braces, such as `{n}`; what is not in `values` is left as it is written.
|
||||
/// Values go in during a single pass, so a value with braces in it is never read as a hole.
|
||||
fn say(wording: &str, values: &[(&str, String)]) -> String {
|
||||
let mut out = String::with_capacity(wording.len() + 8);
|
||||
let mut rest = wording;
|
||||
while let Some(open) = rest.find('{') {
|
||||
let (before, from_brace) = rest.split_at(open);
|
||||
out.push_str(before);
|
||||
// `find` counts from the brace and `}` is one byte, so this is a char boundary.
|
||||
let Some(close) = from_brace.find('}') else {
|
||||
// A brace with nothing closing it is just text.
|
||||
out.push_str(from_brace);
|
||||
return out;
|
||||
};
|
||||
let Some(hole) = from_brace.get(..=close) else { return out + from_brace };
|
||||
match values.iter().find(|(name, _)| *name == hole) {
|
||||
Some((_, value)) => out.push_str(value),
|
||||
None => out.push_str(hole),
|
||||
}
|
||||
rest = from_brace.get(close + 1..).unwrap_or_default();
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Orientation {
|
||||
#[default]
|
||||
|
|
@ -161,15 +234,37 @@ impl Default for Settings {
|
|||
}
|
||||
|
||||
/// Common paper sizes (points, portrait).
|
||||
pub const PAPERS: [(&str, (f64, f64)); 6] = [
|
||||
pub const PAPERS: [(&str, (f64, f64)); 18] = [
|
||||
("US Letter", (612.0, 792.0)),
|
||||
("US Legal", (612.0, 1008.0)),
|
||||
("Tabloid", (792.0, 1224.0)),
|
||||
("A3", (841.89, 1190.55)),
|
||||
("A4", (595.28, 841.89)),
|
||||
("A5", (419.53, 595.28)),
|
||||
("ARCH A (9 x 12 in)", (648.0, 864.0)),
|
||||
("ARCH B (12 x 18 in)", (864.0, 1296.0)),
|
||||
("ARCH C (18 x 24 in)", (1296.0, 1728.0)),
|
||||
("ARCH D (24 x 36 in)", (1728.0, 2592.0)),
|
||||
("ARCH E1 (30 x 42 in)", (2160.0, 3024.0)),
|
||||
("ARCH E (36 x 48 in)", (2592.0, 3456.0)),
|
||||
("ANSI C (17 x 22 in)", (1224.0, 1584.0)),
|
||||
("ANSI D (22 x 34 in)", (1584.0, 2448.0)),
|
||||
("ANSI E (34 x 44 in)", (2448.0, 3168.0)),
|
||||
("A2", (1190.55, 1683.78)),
|
||||
("A1", (1683.78, 2383.94)),
|
||||
("A0", (2383.94, 3370.39)),
|
||||
];
|
||||
|
||||
/// Match a displayed PDF page to a standard sheet, independent of orientation.
|
||||
/// Half a point allows rounded metric PDF dimensions without confusing distinct sizes.
|
||||
pub fn matching_paper(size: (f64, f64)) -> Option<usize> {
|
||||
if !size.0.is_finite() || !size.1.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let size = (size.0.min(size.1), size.0.max(size.1));
|
||||
PAPERS.iter().position(|(_, p)| (p.0 - size.0).abs() <= 0.5 && (p.1 - size.1).abs() <= 0.5)
|
||||
}
|
||||
|
||||
/// The unprintable margin assumed around the sheet for Fit, Multiple and Booklet.
|
||||
pub const MARGIN: f64 = 18.0;
|
||||
|
||||
|
|
|
|||
|
|
@ -19,11 +19,31 @@ fn page_of(tok: &str, count: usize, labels: &[String]) -> Result<usize, PrintErr
|
|||
}
|
||||
match t.parse::<usize>() {
|
||||
Ok(n) if n >= 1 && n <= count => Ok(n - 1),
|
||||
Ok(n) => Err(PrintError::Invalid(format!("page {n} is out of range (1–{count})"))),
|
||||
Err(_) => Err(PrintError::Invalid(format!("{t:?} is not a page number or label"))),
|
||||
Ok(n) => Err(PrintError::OutOfRange(n, count)),
|
||||
Err(_) => Err(PrintError::NotAPage(t.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a range whose endpoint is a logical page label containing a hyphen, such
|
||||
/// as "A-1-A-3" or "A-2-". The plain range parser must not mistake the first
|
||||
/// hyphen inside the label for the delimiter between endpoints.
|
||||
fn hyphenated_label_range(part: &str, count: usize, labels: &[String]) -> Option<(usize, usize)> {
|
||||
for (split, _) in part.match_indices('-') {
|
||||
let (Some(first), Some(last)) = (part.get(..split), part.get(split + 1..)) else { continue };
|
||||
let (first, last) = (first.trim(), last.trim());
|
||||
// Avoid changing the meaning of ordinary numeric and open-ended ranges.
|
||||
if !labels.iter().any(|l| l.contains('-') && (l == first || l == last)) {
|
||||
continue;
|
||||
}
|
||||
let from = if first.is_empty() { Some(0) } else { page_of(first, count, labels).ok() };
|
||||
let to = if last.is_empty() { count.checked_sub(1) } else { page_of(last, count, labels).ok() };
|
||||
if let (Some(from), Some(to)) = (from, to) {
|
||||
return Some((from, to));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The pages to print (0-based, in print order). `range` is `None` for all pages; it may list
|
||||
/// numbers and labels with `-` ranges (open-ended allowed: `5-`, `-3`). The subset counts the
|
||||
/// selected pages (the first selected is "odd"), as Acrobat does.
|
||||
|
|
@ -33,6 +53,18 @@ pub fn select_pages(count: usize, range: Option<&str>, labels: &[String], subset
|
|||
None => pages.extend(0..count),
|
||||
Some(r) => {
|
||||
for part in r.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
|
||||
// A complete label (e.g. "A-1") still wins over interpreting it as a range.
|
||||
// A range of such labels (e.g. "A-1-A-3") needs a later '-' split.
|
||||
if !labels.iter().any(|l| l == part)
|
||||
&& let Some((from, to)) = hyphenated_label_range(part, count, labels)
|
||||
{
|
||||
if from <= to {
|
||||
pages.extend(from..=to);
|
||||
} else {
|
||||
pages.extend((to..=from).rev());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match part.split_once('-') {
|
||||
// A label may itself contain a dash ("A-1"): try the whole token first.
|
||||
Some(_) if labels.iter().any(|l| l == part) => pages.push(page_of(part, count, labels)?),
|
||||
|
|
@ -58,7 +90,7 @@ pub fn select_pages(count: usize, range: Option<&str>, labels: &[String], subset
|
|||
/// labels. The subset and `reverse` apply as in [`select_pages`].
|
||||
pub fn select_listed(count: usize, pages: &[usize], subset: Subset, reverse: bool) -> Result<Vec<usize>, PrintError> {
|
||||
if let Some(p) = pages.iter().find(|p| **p >= count) {
|
||||
return Err(PrintError::Invalid(format!("page {} is out of range (1–{count})", p.saturating_add(1))));
|
||||
return Err(PrintError::OutOfRange(p.saturating_add(1), count));
|
||||
}
|
||||
narrow(pages.to_vec(), subset, reverse)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,18 +65,65 @@ fn page_selection() {
|
|||
assert_eq!(select_pages(5, Some("ii-2, A-1"), &labels, Subset::All, false).unwrap(), [1, 2, 3, 4], "labels, even with a dash");
|
||||
assert_eq!(select_pages(5, None, &[], Subset::Even, true).unwrap(), [3, 1]);
|
||||
assert_eq!(select_pages(5, None, &[], Subset::Odd, false).unwrap(), [0, 2, 4]);
|
||||
assert!(matches!(select_pages(5, Some("7"), &[], Subset::All, false), Err(PrintError::Invalid(_))));
|
||||
assert!(matches!(select_pages(5, Some("x"), &[], Subset::All, false), Err(PrintError::Invalid(_))));
|
||||
assert_eq!(select_pages(5, Some("7"), &[], Subset::All, false), Err(PrintError::OutOfRange(7, 5)));
|
||||
assert_eq!(select_pages(5, Some("x"), &[], Subset::All, false), Err(PrintError::NotAPage("x".into())));
|
||||
assert_eq!(select_pages(5, Some("7"), &[], Subset::All, false).unwrap_err().to_string(), "page 7 is out of range (1–5)");
|
||||
assert_eq!(select_pages(1, None, &[], Subset::Even, false), Err(PrintError::NoPages));
|
||||
// An explicit list (selected thumbnails): positions, never labels, in the order given.
|
||||
assert_eq!(select_listed(5, &[1, 3], Subset::All, false).unwrap(), [1, 3]);
|
||||
assert_eq!(select_listed(5, &[0, 2, 4], Subset::Even, false).unwrap(), [2]);
|
||||
assert_eq!(select_listed(5, &[0, 2, 4], Subset::Odd, true).unwrap(), [4, 0]);
|
||||
assert!(matches!(select_listed(5, &[1, 5], Subset::All, false), Err(PrintError::Invalid(_))));
|
||||
assert!(matches!(select_listed(0, &[usize::MAX], Subset::All, false), Err(PrintError::Invalid(_))));
|
||||
assert_eq!(select_listed(5, &[1, 5], Subset::All, false), Err(PrintError::OutOfRange(6, 5)));
|
||||
assert!(matches!(select_listed(0, &[usize::MAX], Subset::All, false), Err(PrintError::OutOfRange(_, 0))));
|
||||
assert_eq!(select_listed(5, &[], Subset::All, false), Err(PrintError::NoPages));
|
||||
}
|
||||
|
||||
/// Every mistake a person can make about what to print says itself out of a wording on the list
|
||||
/// and the numbers apart from it, and says itself in English exactly as the error prints itself.
|
||||
#[test]
|
||||
fn wordings_are_said() {
|
||||
let mistakes = [PrintError::NoPages, PrintError::OutOfRange(7, 5), PrintError::NotAPage("x".into())];
|
||||
for e in &mistakes {
|
||||
assert_eq!(e.said(&in_english), e.to_string(), "{e:?} says itself in English the way it prints itself");
|
||||
}
|
||||
// Each wording is one of a mistake's, and each mistake's is on the list: the hole names and
|
||||
// all, so a catalog that translates the list translates every sentence a person can be shown.
|
||||
let said: Vec<String> = WORDINGS.iter().map(|w| w.to_string()).collect();
|
||||
assert_eq!(said.len(), mistakes.len(), "one wording per mistake");
|
||||
for w in WORDINGS {
|
||||
assert!(!w.is_empty() && w.ends_with(|c: char| c.is_alphanumeric() || c == ')'), "a whole sentence, not a fragment: {w:?}");
|
||||
}
|
||||
// A fault is not a mistake: it carries its own text and is not on the list.
|
||||
let fault = PrintError::Spool("lp: no such printer".into());
|
||||
assert_eq!(fault.said(&in_english), "lp: no such printer");
|
||||
assert_eq!(fault.said(&|_| "traducido".to_string()), "lp: no such printer", "nothing to translate");
|
||||
// Said in another language, the numbers still go in, and in that language's own order.
|
||||
let other = |w: &str| match w {
|
||||
"page {n} is out of range (1–{count})" => "el documento tiene {count} páginas, no {n}".to_string(),
|
||||
w => w.to_string(),
|
||||
};
|
||||
assert_eq!(PrintError::OutOfRange(7, 5).said(&other), "el documento tiene 5 páginas, no 7");
|
||||
// What was typed goes in as it stands, braces and all, and is never read as a hole.
|
||||
assert_eq!(PrintError::NotAPage("{count}".into()).said(&other), "\"{count}\" is not a page number or label");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_selection_handles_ranges_between_hyphenated_labels() {
|
||||
let labels: Vec<String> = ["A-1", "A-2", "A-3", "B-1", "B-2"].iter().map(|s| s.to_string()).collect();
|
||||
let pages = |range| select_pages(5, Some(range), &labels, Subset::All, false).unwrap();
|
||||
assert_eq!(pages("A-1"), [0], "the complete label takes precedence over a range");
|
||||
assert_eq!(pages("A-1-A-3"), [0, 1, 2]);
|
||||
assert_eq!(pages("A-3-A-1"), [2, 1, 0], "reverse logical ranges");
|
||||
assert_eq!(pages("A-2-"), [1, 2, 3, 4], "an open-ended range starting at a hyphenated label");
|
||||
assert_eq!(pages("-A-2"), [0, 1], "an open-ended range ending at a hyphenated label");
|
||||
assert_eq!(pages("A-2-5"), [1, 2, 3, 4], "a label followed by a numeric endpoint");
|
||||
assert_eq!(pages("B-1-B-2"), [3, 4]);
|
||||
// If an earlier dash yields one known label but an unknown endpoint, continue
|
||||
// checking later dashes instead of rejecting the whole range.
|
||||
let ambiguous: Vec<String> = ["A-1", "A-1-B", "C"].iter().map(|s| s.to_string()).collect();
|
||||
assert_eq!(select_pages(3, Some("A-1-B-C"), &ambiguous, Subset::All, false).unwrap(), [1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_modes() {
|
||||
let sizes = [(200.0, 300.0), (1000.0, 1500.0)];
|
||||
|
|
@ -412,3 +459,30 @@ fn a_scaled_appearance_is_not_scaled_twice() {
|
|||
assert!(close(m.0[0], 2.0) && close(m.0[3], 2.0), "{:?}", m.0);
|
||||
assert!(close(m.0[4], 10.0) && close(m.0[5], 10.0), "{:?}", m.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architectural_sheets_preserve_full_scale() {
|
||||
let index = matching_paper((2592.0, 1728.0)).unwrap();
|
||||
assert_eq!(PAPERS[index].0, "ARCH D (24 x 36 in)");
|
||||
assert_eq!(matching_paper((1728.0, 2592.0)), Some(index));
|
||||
assert_eq!(matching_paper((f64::NAN, 2592.0)), None);
|
||||
let s = Settings { pages: vec![0], paper: PAPERS[index].1, layout: Layout::Size(SizeMode::Actual), ..Settings::default() };
|
||||
let sheets = layout(&[(2592.0, 1728.0)], &s).unwrap();
|
||||
assert_eq!(sheets[0].size, (2592.0, 1728.0));
|
||||
assert_eq!(sheets[0].placed[0].matrix.0, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
|
||||
let fit = layout(&[(2592.0, 1728.0)], &Settings { layout: Layout::Size(SizeMode::Fit), ..s }).unwrap();
|
||||
assert!(fit[0].placed[0].matrix.0[0] < 1.0, "Fit adds margins and must not masquerade as full scale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paper_matching_accepts_rounded_metric_sizes_but_not_custom_or_invalid_sizes() {
|
||||
for (i, (_, (w, h))) in PAPERS.iter().enumerate() {
|
||||
assert_eq!(matching_paper((*w, *h)), Some(i));
|
||||
assert_eq!(matching_paper((*h, *w)), Some(i));
|
||||
assert_eq!(matching_paper((w.round(), h.round())), Some(i));
|
||||
assert_eq!(matching_paper((w + 0.51, *h)), None);
|
||||
}
|
||||
for size in [(0.0, 0.0), (-1728.0, 2592.0), (f64::INFINITY, 2592.0), (1728.0, f64::NAN), (1000.0, 1000.0)] {
|
||||
assert_eq!(matching_paper(size), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ pub(crate) enum Mode {
|
|||
struct Gs {
|
||||
ctm: Matrix,
|
||||
font: Rc<Metrics>,
|
||||
/// The current font writes vertically (a Type0 font with an `-V` encoding or a CMap whose
|
||||
/// `/WMode` is 1). Glyph layout here is horizontal, so such text cannot be placed under a
|
||||
/// region; apply fails closed when it meets any (see `show`).
|
||||
vertical: bool,
|
||||
size: f64,
|
||||
char_spacing: f64,
|
||||
word_spacing: f64,
|
||||
|
|
@ -54,8 +58,15 @@ pub(crate) struct Output {
|
|||
pub streams: Vec<Option<Vec<u8>>>,
|
||||
/// New XObjects the rewritten content refers to: (resource name, object).
|
||||
pub xobjects: Vec<(Vec<u8>, ObjRef)>,
|
||||
/// Resource names whose `Do` this scope dropped or pointed at a cleaned copy, so the caller
|
||||
/// can unlink the original from the resource dictionary (a full save keeps whatever is still
|
||||
/// referenced, which would otherwise retain the removed image or form bytes).
|
||||
pub replaced: Vec<Vec<u8>>,
|
||||
/// Glyphs / inline images still overlapping a region (verify mode).
|
||||
pub residue: usize,
|
||||
/// Apply mode met vertical-writing text (which it cannot remove) while a redaction region was
|
||||
/// in force: the operation must fail closed rather than leave that text under the mark.
|
||||
pub vertical: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct Scope<'a> {
|
||||
|
|
@ -148,6 +159,20 @@ fn res_dict(doc: &Document, res: &Dict, key: &[u8]) -> Dict {
|
|||
res.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Does this font write vertically? Only a Type0 (composite) font can: its `/Encoding` is a
|
||||
/// predefined CMap name ending in `-V` (Identity-V and the like), or an embedded CMap stream
|
||||
/// whose `/WMode` is 1 (ISO 32000-2 §9.7.5).
|
||||
fn is_vertical(doc: &Document, font: &Dict) -> bool {
|
||||
if font.name(b"Subtype") != Some(b"Type0".as_slice()) {
|
||||
return false;
|
||||
}
|
||||
match font.get(b"Encoding").map(|e| doc.resolve(e)).as_deref() {
|
||||
Some(Object::Name(n)) => n.ends_with(b"-V"),
|
||||
Some(Object::Stream(s)) => s.dict.int(b"WMode") == Some(1),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn fresh_name(scope: &mut Scope<'_>, xobjects: &Dict) -> Vec<u8> {
|
||||
let mut i = scope.used_names.len() + 1;
|
||||
loop {
|
||||
|
|
@ -160,6 +185,23 @@ fn fresh_name(scope: &mut Scope<'_>, xobjects: &Dict) -> Vec<u8> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Drop every `replaced` resource name whose `Do` no longer appears in the rewritten `content`,
|
||||
/// so a full-rewrite save stops reaching the original image or form it named. A name that some
|
||||
/// surviving `Do` still draws (the same image shown again outside a mark) is kept.
|
||||
pub(crate) fn prune(xo: &mut Dict, replaced: &[Vec<u8>], content: &[Vec<u8>]) {
|
||||
if replaced.is_empty() {
|
||||
return;
|
||||
}
|
||||
let joined = Pieces::join(content);
|
||||
let ops = joined.parse().ops;
|
||||
let still_drawn = |n: &[u8]| ops.iter().any(|op| op.is("Do") && op.name(0) == Some(n));
|
||||
for n in replaced {
|
||||
if !still_drawn(n) {
|
||||
xo.remove(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The clip that hides everything inside each region: one even-odd clip per region (a big
|
||||
/// rectangle with the region cut out), in the current user space. Returns `None` when the CTM
|
||||
/// can't be inverted (nothing is painted then anyway).
|
||||
|
|
@ -195,8 +237,18 @@ struct Glyph {
|
|||
pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<u8>], resources: &Dict, ctm: Matrix) -> Output {
|
||||
let mut out = Output::default();
|
||||
let fallback = Rc::new(Metrics::fallback());
|
||||
let mut gs =
|
||||
Gs { ctm, font: fallback.clone(), size: 0.0, char_spacing: 0.0, word_spacing: 0.0, render_mode: 0, scale: 1.0, leading: 0.0, rise: 0.0 };
|
||||
let mut gs = Gs {
|
||||
ctm,
|
||||
font: fallback.clone(),
|
||||
vertical: false,
|
||||
size: 0.0,
|
||||
char_spacing: 0.0,
|
||||
word_spacing: 0.0,
|
||||
render_mode: 0,
|
||||
scale: 1.0,
|
||||
leading: 0.0,
|
||||
rise: 0.0,
|
||||
};
|
||||
let mut stack: Vec<Gs> = Vec::new();
|
||||
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
|
||||
let fonts_res = res_dict(doc, resources, b"Font");
|
||||
|
|
@ -329,14 +381,12 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
|
|||
b"Tf" => {
|
||||
gs.size = op.num(1).unwrap_or(gs.size);
|
||||
if let Some(name) = op.name(0) {
|
||||
let font_dict = fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned());
|
||||
gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d));
|
||||
gs.font = match scope.fonts.get(name) {
|
||||
Some(f) => f.clone(),
|
||||
None => {
|
||||
let m = fonts_res
|
||||
.get(name)
|
||||
.and_then(|f| doc.resolve(f).as_dict().cloned())
|
||||
.map(|d| Rc::new(Metrics::from_dict(doc, &d)))
|
||||
.unwrap_or_else(|| fallback.clone());
|
||||
let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone());
|
||||
scope.fonts.insert(name.to_vec(), m.clone());
|
||||
m
|
||||
}
|
||||
|
|
@ -381,6 +431,18 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
|
|||
b"TJ" => op.operands.first().and_then(Object::as_array).cloned().unwrap_or_default(),
|
||||
_ => op.operands.last().cloned().into_iter().collect(),
|
||||
};
|
||||
// Vertical-writing text is laid out horizontally by `show`, so its glyphs are
|
||||
// never matched against a region. Rather than ship a document whose vertical
|
||||
// text was silently left under a mark, record it so apply can fail closed.
|
||||
// (Full vertical-text removal is a future enhancement.)
|
||||
if gs.vertical
|
||||
&& scope.mode == Mode::Apply
|
||||
&& scope.hidden_text.is_none()
|
||||
&& !scope.rects.is_empty()
|
||||
&& items.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty()))
|
||||
{
|
||||
out.vertical = true;
|
||||
}
|
||||
let (new_items, removed) = show(scope, &gs, &mut tm, &items);
|
||||
if removed == 0 {
|
||||
ops.push(op);
|
||||
|
|
@ -543,6 +605,7 @@ fn xobject(
|
|||
return None;
|
||||
}
|
||||
scope.layer_blocks += 1;
|
||||
out.replaced.push(name);
|
||||
Some(None)
|
||||
}
|
||||
Some(b"Image") => {
|
||||
|
|
@ -552,6 +615,7 @@ fn xobject(
|
|||
}
|
||||
if scope.covered(b) {
|
||||
scope.report.images_removed += 1;
|
||||
out.replaced.push(name);
|
||||
return Some(None);
|
||||
}
|
||||
match image::clear(doc, s, &gs.ctm, scope.rects) {
|
||||
|
|
@ -561,11 +625,13 @@ fn xobject(
|
|||
let n = fresh_name(scope, xobjects);
|
||||
xobjects.set(n.clone(), Object::Ref(nr));
|
||||
out.xobjects.push((n.clone(), nr));
|
||||
out.replaced.push(name);
|
||||
scope.report.images_cleared += 1;
|
||||
Some(Some(Op::new("Do", vec![Object::Name(n)])))
|
||||
}
|
||||
Err(()) => {
|
||||
scope.report.images_removed += 1;
|
||||
out.replaced.push(name);
|
||||
Some(None)
|
||||
}
|
||||
}
|
||||
|
|
@ -588,12 +654,14 @@ fn xobject(
|
|||
return None;
|
||||
}
|
||||
scope.report.forms_removed += 1;
|
||||
out.replaced.push(name);
|
||||
return Some(None);
|
||||
}
|
||||
let data = match s.decoded() {
|
||||
Ok(d) => d,
|
||||
Err(_) if scope.mode == Mode::Apply => {
|
||||
scope.report.forms_removed += 1;
|
||||
out.replaced.push(name);
|
||||
return Some(None);
|
||||
}
|
||||
Err(_) => return None,
|
||||
|
|
@ -606,18 +674,24 @@ fn xobject(
|
|||
scope.depth -= 1;
|
||||
scope.fonts = saved_fonts;
|
||||
out.residue += inner.residue;
|
||||
out.vertical |= inner.vertical;
|
||||
if scope.mode == Mode::Verify {
|
||||
return None;
|
||||
}
|
||||
let new_data = inner.streams.into_iter().next().flatten();
|
||||
if new_data.is_none() && inner.xobjects.is_empty() {
|
||||
if new_data.is_none() && inner.xobjects.is_empty() && inner.replaced.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut dict = s.dict.clone();
|
||||
dict.remove(b"Length");
|
||||
if !inner.xobjects.is_empty() {
|
||||
// Rebuild the copy's /Resources whenever inner content gained or lost an XObject, so a
|
||||
// `Do` the copy no longer makes stops keeping the original image or form reachable.
|
||||
if !inner.xobjects.is_empty() || !inner.replaced.is_empty() {
|
||||
let mut res = res;
|
||||
let mut xo = res_dict(doc, &res, b"XObject");
|
||||
if let Some(d) = &new_data {
|
||||
prune(&mut xo, &inner.replaced, std::slice::from_ref(d));
|
||||
}
|
||||
for (n, r) in &inner.xobjects {
|
||||
xo.set(n.clone(), Object::Ref(*r));
|
||||
}
|
||||
|
|
@ -632,6 +706,7 @@ fn xobject(
|
|||
let n = fresh_name(scope, xobjects);
|
||||
xobjects.set(n.clone(), Object::Ref(nr));
|
||||
out.xobjects.push((n.clone(), nr));
|
||||
out.replaced.push(name);
|
||||
scope.report.forms_rewritten += 1;
|
||||
Some(Some(Op::new("Do", vec![Object::Name(n)])))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ pub enum RedactError {
|
|||
Unreadable(usize),
|
||||
#[error("redaction could not be verified: {0} item(s) were still found under the marks, so nothing was changed")]
|
||||
Residue(usize),
|
||||
#[error("page {0} has vertical-writing text under a redaction mark, which PdfCraft cannot remove yet, so nothing was changed")]
|
||||
VerticalText(usize),
|
||||
#[error(transparent)]
|
||||
Edit(#[from] pdfcraft_edit::EditError),
|
||||
#[error(transparent)]
|
||||
|
|
@ -107,6 +109,9 @@ pub struct Report {
|
|||
pub fields: usize,
|
||||
/// Structure elements that lost alternate/actual text or emptied marked content.
|
||||
pub tags: usize,
|
||||
/// The form was an AcroForm+XFA form and its `/XFA` packets (which still held redacted field
|
||||
/// values, and from which a relayout could rebuild them) were removed.
|
||||
pub xfa_removed: bool,
|
||||
}
|
||||
|
||||
fn rect_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> {
|
||||
|
|
@ -292,6 +297,13 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
|
|||
let mut scope = Scope::new(&rects, Mode::Apply, &mut report);
|
||||
process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY)
|
||||
};
|
||||
// Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot
|
||||
// place such glyphs under a mark or confirm they are gone. Rather than report success and
|
||||
// ship the text under the box, refuse the whole operation (the caller keeps the previous
|
||||
// document). Full vertical-text removal is a future enhancement.
|
||||
if out.vertical {
|
||||
return Err(RedactError::VerticalText(pi + 1));
|
||||
}
|
||||
let mut new_list = list.clone();
|
||||
let mut changed = false;
|
||||
for (i, new) in out.streams.into_iter().enumerate() {
|
||||
|
|
@ -309,10 +321,20 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
|
|||
new_list[i] = Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &bytes))));
|
||||
changed = true;
|
||||
}
|
||||
if changed || !out.xobjects.is_empty() {
|
||||
if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() {
|
||||
let mut res = resources;
|
||||
if !out.xobjects.is_empty() {
|
||||
if !out.xobjects.is_empty() || !out.replaced.is_empty() {
|
||||
let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default();
|
||||
// A full save keeps whatever is still referenced: unlink the originals this page no
|
||||
// longer draws so the removed image or form bytes cannot survive in the saved file.
|
||||
let page_content: Vec<Vec<u8>> = new_list
|
||||
.iter()
|
||||
.map(|o| match &*doc.resolve(o) {
|
||||
Object::Stream(s) => s.decoded().unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
interp::prune(&mut xo, &out.replaced, &page_content);
|
||||
for (n, r) in &out.xobjects {
|
||||
xo.set(n.clone(), Object::Ref(*r));
|
||||
}
|
||||
|
|
@ -388,6 +410,37 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
|
|||
}
|
||||
}
|
||||
|
||||
// 3b. An XFA form mirrors its field values in the /XFA datasets packet, and a relayout
|
||||
// rebuilds fields and pages from that packet, so deleting the AcroForm field alone leaves the
|
||||
// redacted value in the file and lets a later relayout put it back. Once redactions are
|
||||
// applied the form stops being XFA: drop /AcroForm /XFA (template and datasets), the catalog's
|
||||
// /NeedsRendering, and PdfCraft's own layout mark (pdfcraft_xfa::LAYOUT_KEY), leaving a plain
|
||||
// already-laid-out AcroForm that neither the saved file nor a relayout can restore from.
|
||||
if let Some(root) = doc.root() {
|
||||
let strip_xfa = |d: &mut Dict| {
|
||||
d.remove(b"XFA");
|
||||
d.remove(b"PCXfaLayout");
|
||||
};
|
||||
match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) {
|
||||
Some(Object::Ref(af)) if doc.get(af).as_dict().is_some_and(|d| d.contains(b"XFA")) => {
|
||||
doc.update_dict(af, strip_xfa)?;
|
||||
doc.update_dict(root, |d| {
|
||||
d.remove(b"NeedsRendering");
|
||||
})?;
|
||||
report.xfa_removed = true;
|
||||
}
|
||||
Some(Object::Dict(mut af)) if af.contains(b"XFA") => {
|
||||
strip_xfa(&mut af);
|
||||
doc.update_dict(root, |d| {
|
||||
d.set(b"AcroForm".to_vec(), Object::Dict(af));
|
||||
d.remove(b"NeedsRendering");
|
||||
})?;
|
||||
report.xfa_removed = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. The boxes.
|
||||
for &pi in &by_page {
|
||||
let page_marks: Vec<&Mark> = chosen.iter().copied().filter(|m| m.page == pi).collect();
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue