From 2cf91e3c096b3414373cacfe645b80c2bd82ac61 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:00:09 -0400 Subject: [PATCH 01/15] Packaging: pin the signing client and appimagetool, and check their hashes (#3731) * Packaging: pin the Windows signing client and check its hash * Packaging: pin appimagetool and check its hash * Packaging: fail clearly when no pinned appimagetool exists for the architecture riscv64 has no pinned appimagetool, so building its AppImage without APPIMAGETOOL or a tool on PATH stopped on an unbound APPIMAGETOOL_SHA256 after a download attempt. * Packaging: pin the AppImage runtime and check its hash appimagetool otherwise downloads the type2-runtime continuous build and embeds it unchecked. The script fetches release 20251108 by version, checks its SHA-256 on download and on every run, and passes it with --runtime-file. An architecture with no pinned runtime exits with a clear message. --- packaging/linux/package.sh | 46 +++++++++++++++++++++++++++++++++----- packaging/windows/sign.ps1 | 13 ++++++++--- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index f87dccd616..595ca7b189 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -128,6 +128,23 @@ fi # ---- AppImage ----------------------------------------------------------------------------------- if has appimage; then + APPIMAGETOOL_VERSION=1.9.1 + case "$ARCH" in + x86_64) APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 ;; + aarch64) APPIMAGETOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 ;; + *) APPIMAGETOOL_SHA256= ;; + esac + # appimagetool otherwise fetches the unpinned type2-runtime `continuous` build and embeds it. + APPIMAGE_RUNTIME_VERSION=20251108 + case "$ARCH" in + x86_64) APPIMAGE_RUNTIME_SHA256=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d ;; + aarch64) APPIMAGE_RUNTIME_SHA256=00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 ;; + *) APPIMAGE_RUNTIME_SHA256= ;; + esac + if [[ -z "$APPIMAGE_RUNTIME_SHA256" ]]; then + echo "no pinned AppImage runtime for $ARCH; build the AppImage on x86_64 or aarch64" >&2 + exit 2 + fi APPDIR="$WORK/PdfCraft.AppDir" cp -R "$STAGE" "$APPDIR" mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" @@ -138,17 +155,36 @@ if has appimage; then TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" if [[ -z "$TOOL" ]]; then - TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" - if [[ ! -x "$TOOL" ]]; then - curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" - chmod +x "$TOOL" + if [[ -z "$APPIMAGETOOL_SHA256" ]]; then + echo "no pinned appimagetool for $ARCH; set APPIMAGETOOL or put appimagetool on PATH" >&2 + exit 2 fi + TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage" + if [[ ! -x "$TOOL" ]]; then + download="$TOOL.download" + curl --proto '=https' -fsSL -o "$download" \ + "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" + printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$download" | sha256sum -c - + chmod 755 "$download" + mv "$download" "$TOOL" + fi + printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$TOOL" | sha256sum -c - fi + RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$APPIMAGE_RUNTIME_VERSION-$ARCH" + if [[ ! -f "$RUNTIME" ]]; then + download="$RUNTIME.download" + curl --proto '=https' -fsSL -o "$download" \ + "https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-$ARCH" + printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$download" | sha256sum -c - + mv "$download" "$RUNTIME" + fi + printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$RUNTIME" | sha256sum -c - # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be # relative, e.g. target/agent-). OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" APPDIR="$(cd "$APPDIR" && pwd)" TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")" + RUNTIME="$(cd "$(dirname "$RUNTIME")" && pwd)/$(basename "$RUNTIME")" # A .zsync left by an earlier run would hide a missing zsyncmake and describe another file. rm -f "$OUT.zsync" # Update information: AppImageUpdate, AppImageLauncher and the like read it from the file and @@ -160,7 +196,7 @@ if has appimage; then # Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime, # so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync # package) appimagetool also writes the .zsync, into its working directory, hence the cd. - (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") + (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream --runtime-file "$RUNTIME" -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" if [[ -s "$OUT.zsync" ]]; then echo "wrote $OUT.zsync" diff --git a/packaging/windows/sign.ps1 b/packaging/windows/sign.ps1 index 5dd206b459..d56e0e65ea 100644 --- a/packaging/windows/sign.ps1 +++ b/packaging/windows/sign.ps1 @@ -56,8 +56,8 @@ if (-not $haveCert -and -not $haveAzure) { $script:SignTool = Find-SignTool $common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'PdfCraft', '/du', 'https://github.com/storytold/pdfcraft') -$tmp = Join-Path ([IO.Path]::GetTempPath()) "pdfcraft-sign-$PID" -New-Item -ItemType Directory -Force -Path $tmp | Out-Null +$tmp = Join-Path ([IO.Path]::GetTempPath()) "pdfcraft-sign-$([IO.Path]::GetRandomFileName())" +New-Item -ItemType Directory -Path $tmp | Out-Null try { if ($haveCert) { @@ -73,8 +73,15 @@ try { Write-Output "Signing with Azure Trusted Signing ($env:AZURE_SIGNING_ACCOUNT / $env:AZURE_CERT_PROFILE): $($Files -join ', ')" # The dlib authenticates with DefaultAzureCredential, which reads AZURE_TENANT_ID, # AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment. + $trustedSigningVersion = '1.0.95' + $trustedSigningSha256 = '3bfcf1e0a3cb42af1692f0a8ed45c15de070c2de86f28a59b2795d904d8a920f' $pkg = Join-Path $tmp 'trusted-signing.zip' - Invoke-WebRequest -Uri 'https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client' -OutFile $pkg + $uri = "https://api.nuget.org/v3-flatcontainer/microsoft.trusted.signing.client/$trustedSigningVersion/microsoft.trusted.signing.client.$trustedSigningVersion.nupkg" + Invoke-WebRequest -Uri $uri -OutFile $pkg + $actualHash = (Get-FileHash -Path $pkg -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $trustedSigningSha256) { + throw "Microsoft.Trusted.Signing.Client $trustedSigningVersion checksum mismatch" + } Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force $dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll | Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 From 487907adfb1369cfe0796a8836a235adf4c64081 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:02:41 -0400 Subject: [PATCH 02/15] fix(links): resolve remote document paths with craft-docpath's resolve_absolute (#3730) * fix(links): use resolve_absolute for remote document paths * fix(ui): refuse output file names that end in a dot or a space * ci: check that git dependencies come from storytold * ci: release only with git dependencies from storytold * ci: check git dependency sources on a locked, parsed Cargo.lock The git-sources step in ci.yml and release.yml fails when Cargo.lock is missing, when .cargo/config.toml replaces or patches a source, when Cargo.lock is out of date with the manifests (cargo metadata --locked), and when any package source parsed from Cargo.lock as TOML is a git source outside github.com/storytold/. A grep on Cargo's usual spelling missed single-quoted strings and stale lockfiles. * fix(links): a Windows path on Unix is refused, not reported as needing a source resolve_remote_document_path reported MissingSourcePath for any path that resolved but not to an absolute one. With an absolute source, such a path is one this platform cannot open (C:\x.pdf on Unix): it is now NotLocal(Invalid). A relative target from a missing or relative source still gives MissingSourcePath. * ci: check the legacy .cargo/config for source replacement too Cargo reads .cargo/config as well as .cargo/config.toml, so the git-sources step greps both; grep -s keeps a missing file from failing the step. * ci: parse cargo config for source replacement, patches and path overrides --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++ .github/workflows/release.yml | 40 +++++++++++++++++++++++++++++++++++ crates/engine/src/links.rs | 20 ++++++++++++------ crates/ui-egui/src/files.rs | 21 +++++++++++++----- 4 files changed, 102 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f94d7dbe40..53ed10344b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,39 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Git dependencies come from storytold + if: runner.os == 'Linux' + run: | + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY + # 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) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6ee0e1c31..6c9994ae36 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,47 @@ concurrency: cancel-in-progress: false jobs: + git-sources: + name: Git dependencies come from storytold + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Git dependencies come from storytold + run: | + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY + release: + needs: git-sources # 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: diff --git a/crates/engine/src/links.rs b/crates/engine/src/links.rs index 4902d72f53..9b1c49729f 100644 --- a/crates/engine/src/links.rs +++ b/crates/engine/src/links.rs @@ -176,7 +176,7 @@ pub fn remote_document_path_is_absolute(raw: &str) -> bool { /// Resolve a PDF remote-Go-To file specification to a local path without opening it. /// Relative paths are based on the source document, which should be an absolute path; an /// in-memory source has no fallback to CWD. URLs (including `file:`), network and device paths are -/// refused ([`craft_docpath::resolve`]). +/// refused ([`craft_docpath::resolve_absolute`]). pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result { if raw.is_empty() || raw.trim().is_empty() { return Err(RemoteDocumentPathError::Empty); @@ -197,12 +197,14 @@ pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result< return Err(RemoteDocumentPathError::NotLocal(craft_docpath::Refused::Url)); } } - let path = craft_docpath::resolve(raw, source).map_err(RemoteDocumentPathError::NotLocal)?; - // A relative result would be read against the working directory. - if !path.is_absolute() { - return Err(RemoteDocumentPathError::MissingSourcePath); - } - Ok(path) + craft_docpath::resolve_absolute(raw, source).map_err(|r| match r { + // A path that resolves but not to an absolute one needed a source with an absolute path. With + // such a source, it is a path this platform cannot open (`C:\x.pdf` on Unix). + craft_docpath::Refused::Invalid if !source.is_some_and(Path::is_absolute) && craft_docpath::resolve(raw, source).is_ok() => { + RemoteDocumentPathError::MissingSourcePath + } + r => RemoteDocumentPathError::NotLocal(r), + }) } /// Largest linked PDF a GoToR link may open (1 GiB, the largest size the large-file work measures). @@ -512,6 +514,8 @@ mod tests { assert_eq!(resolve(Some(source), "/manual/target.pdf").as_deref(), Ok(Path::new("/manual/target.pdf"))); assert_eq!(resolve(None, "target.pdf"), Err(E::MissingSourcePath)); assert_eq!(resolve(Some(Path::new("source.pdf")), "target.pdf"), Err(E::MissingSourcePath), "a relative source is not used"); + #[cfg(unix)] + assert_eq!(resolve(Some(source), "C:\\x.pdf"), Err(E::NotLocal(craft_docpath::Refused::Invalid)), "a Windows path on Unix needs no source"); assert_eq!(resolve(Some(source), ""), Err(E::Empty)); assert_eq!(resolve(Some(source), " "), Err(E::Empty)); for url in @@ -520,6 +524,8 @@ mod tests { assert!(matches!(resolve(Some(source), url), Err(E::NotLocal(_))), "{url}"); } assert_eq!(resolve(Some(source), "folder/hidden\u{200B}.pdf"), Err(E::Hidden)); + #[cfg(windows)] + assert_eq!(resolve(Some(source), "folder/a:b.pdf"), Err(E::NotLocal(craft_docpath::Refused::Invalid))); assert_eq!(resolve(Some(source), &"a".repeat(super::MAX_REMOTE_DOCUMENT_PATH_BYTES + 1)), Err(E::TooLong)); } diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index d9334d84c8..4a4f5333a8 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -217,12 +217,13 @@ pub(crate) fn attachment_file_name(name: &str) -> String { if reserved { format!("_{name}") } else { name.to_string() } } -/// Whether `name` names a file directly inside a folder: not empty, `.` or `..`, with no `/` or -/// `\\`, and no drive (`C:`). On Windows any `:` is refused, which also rules out alternate data -/// streams. +/// Whether `name` names a file directly inside a folder: not empty, with no `/` or `\\`, no drive +/// (`C:`), and not ending in `.` or a space (which also rules out `.` and `..`): Windows drops a +/// trailing dot or space, so `report.` would write `report`. On Windows any `:` is refused, which +/// also rules out alternate data streams. pub(crate) fn is_plain_file_name(name: &str) -> bool { let drive = matches!(name.as_bytes(), [letter, b':', ..] if letter.is_ascii_alphabetic()); - !(name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\']) || drive || (cfg!(windows) && name.contains(':'))) + !(name.is_empty() || name.ends_with(['.', ' ']) || name.contains(['/', '\\']) || drive || (cfg!(windows) && name.contains(':'))) } /// Pages ▸ Rotate Pages. @@ -1084,6 +1085,16 @@ mod stem_tests { } } + #[test] + fn plain_file_names_do_not_end_in_a_dot_or_a_space() { + for good in ["report.pdf", "report", ".hidden", "a b.pdf", "資料.pdf"] { + assert!(is_plain_file_name(good), "{good}"); + } + for bad in ["report.", "report.pdf.", "report.pdf ", "report. . ", " ", "...", ". "] { + assert!(!is_plain_file_name(bad), "{bad:?}"); + } + } + #[test] fn write_files_refuses_names_that_leave_the_folder() { let dir = std::env::temp_dir().join(format!("pdfcraft-write-files-{}", std::process::id())); @@ -1094,7 +1105,7 @@ mod stem_tests { let mut app = crate::PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.export_dir_override = Some(out.to_string_lossy().into_owned()); - for bad in ["../x.pdf", "..", ".", "", "sub/x.pdf", "sub\\x.pdf", "C:x.pdf", absolute.as_str()] { + for bad in ["../x.pdf", "..", ".", "", "sub/x.pdf", "sub\\x.pdf", "C:x.pdf", "x.pdf.", absolute.as_str()] { assert!(!is_plain_file_name(bad), "{bad}"); // A plain name first: nothing is written when any name in the batch is refused. app.write_files(&[("ok.pdf".into(), std::sync::Arc::new(b"%PDF".to_vec())), (bad.into(), std::sync::Arc::new(b"%PDF".to_vec()))], "t"); From cf0f80604ff1c27356c68ebe9b6c0aa1cfaca152 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:02:56 -0400 Subject: [PATCH 03/15] =?UTF-8?q?Allow=20OFL=20typefaces,=20Noto=20CJK=20a?= =?UTF-8?q?nd=20Source=20Han=20included=20(AGENTS.md=20=C2=A71.1)=20(#3908?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Allow OFL typefaces whoever designed them (AGENTS.md §1.1) Noto CJK and Source Han from their open-source releases are allowed like any other OFL font. The fonts build no longer skips them, the asset check accepts an OFL font credited to Adobe, and the docs describe the Chinese faces that result. The ban on Adobe icons, artwork, UI graphics and the fonts Adobe products install stays. * Leave out whole CJK faces other than the Japanese ones for size A Chinese or Korean face is embedded whole only under 4 MiB, so the 16.4 MB Noto Sans CJK SC in craft-fonts stays out of the binary and the embedded total against the pinned craft-fonts is unchanged (25,204,828 bytes). * docs: replacement boxes for Chinese without craft-fonts only on the web or without an installed Chinese face * xtask assets: the OFL font exemption needs at least one licence ID * fonts build: count a CJK script tag with a region (Hant-HK) as CJK, shared with the embedding test * AGENTS.md: the asset gate exempts OFL fonts from the Adobe-author check * fonts build: count a Hangul-only face (Hang) as CJK for the size rule * fonts: the CJK embedding test checks the size rule, sharing its constants with build.rs * docs: the missing Hans face is a property of the pinned craft-fonts, not of every build --- AGENTS.md | 6 +++--- ATTRIBUTION.md | 2 +- ATTRIBUTION.toml | 2 +- crates/fonts/build.rs | 18 +++++++++------- crates/fonts/src/cjk_scripts.rs | 19 +++++++++++++++++ crates/fonts/src/craft.rs | 34 ++++++++++++++++++------------ crates/fonts/src/fallback.rs | 3 +-- crates/fonts/src/lib.rs | 2 ++ crates/render/src/raster.rs | 4 ++-- crates/ui-egui/src/system_fonts.rs | 20 +++--------------- crates/ui-egui/tests/it/fonts.rs | 8 +++---- docs/gaps.md | 7 +++--- docs/localization.md | 6 +++--- parity/acrobat-features.toml | 2 +- xtask/src/assets.rs | 28 +++++++++++++++++++----- 15 files changed, 96 insertions(+), 65 deletions(-) create mode 100644 crates/fonts/src/cjk_scripts.rs diff --git a/AGENTS.md b/AGENTS.md index 0665ad1d3b..f6123cc5c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,10 +20,10 @@ An **asset** is any non-code material that the project includes, ships, embeds, - **Never** use icons, images, artwork, logos, UI graphics, fonts, stamps, cursors, sample documents or any other asset taken from an Adobe product, installer, website, document or package. - "Adobe product" includes Acrobat, Reader, Photoshop, Illustrator, Premiere, Lightroom and Creative Cloud, including the fonts they install (for example Minion, Myriad, Kozuka and the Adobe CJK families). - - This holds even when Adobe publishes the material under an open licence, if it is **visual design**. For example, **do not** use Source Han, Source Serif or Source Sans, or Noto CJK, which is Source Han rebranded. + - Typefaces released under the SIL Open Font License are allowed like any other OFL font, with an `ATTRIBUTION.toml` entry, whoever designed them: for example Noto CJK and Source Han, taken from their open-source releases. The copies an Adobe product installs stay banned. - **Never** recreate, trace, redraw or closely imitate an Adobe icon or graphic. Our icons come from Lucide, and our visual design is our own. - Screenshots of Adobe software exist only as private, local observation notes under `plan/acrobat/`, which is gitignored. They must **never** be committed, published, linked, shown in docs, used as a design source to trace, or placed anywhere else in the repository or its outputs. -- The only Adobe-authored material allowed is **non-visual technical data** that is openly licensed and required for PDF compatibility. Each item needs its own entry in `ATTRIBUTION.toml` with `adobe_data = true`, and the list below is closed: +- Apart from those OFL typefaces, the only Adobe-authored material allowed is **non-visual technical data** that is openly licensed and required for PDF compatibility. Each item needs its own entry in `ATTRIBUTION.toml` with `adobe_data = true`, and the list below is closed: - The CMap resources (BSD-3-Clause), bundled inside the `hayro-cmap` dependency. - The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces. They are embedded as code in hayro, and as our own generated table in `crates/fonts/src/std14_metrics.rs` (approved by the owner, 2026-10-08; that file holds the data alone, so the code reading it can change without re-attesting the table), which is what generated appearances measure with and what survives hayro being retired. @@ -63,7 +63,7 @@ Anything else is forbidden, including assets whose licence you cannot prove from - an entry's hash does not match; - a licence is not on the allowlist; - a licence file is missing; - - an image, icon or font names Adobe as author or source. + - an image, icon or font names Adobe as author or source, except a font licensed under OFL-1.1 alone. - **`cargo xtask demo-pdf`** downloads its fonts only from `ATTRIBUTION.toml` entries, pinned by SHA-256. Afterwards it **fails if the PDF embeds any font not in its allowlist**, which catches the browser silently falling back to a system font. ### 1.4 Fonts live in craft-fonts diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 593ae6e685..19c81bf001 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -342,4 +342,4 @@ Not in this repository and never downloaded by it: compiled in only when the bui | Input | Option | Title | Author | Licence | Source | Attribution | Used for | |---|---|---|---|---|---|---|---| -| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs never embeds (AGENTS.md §1.1) | +| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs leaves out for size | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 4be205f6ed..106c1febff 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -3393,7 +3393,7 @@ source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/ licence = "OFL-1.1" attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md" kind = "font" -usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs never embeds (AGENTS.md §1.1)" +usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs leaves out for size" [[asset]] path = "crates/sign/tests/data/x509-rsa-sha1.pdf" diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index 397f6fc384..bc87a16634 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -7,6 +7,9 @@ use std::fmt::Write as _; use std::path::PathBuf; +#[path = "src/cjk_scripts.rs"] +mod cjk_scripts; + fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); @@ -27,10 +30,6 @@ fn main() { } } -/// Family-name prefixes of the faces AGENTS.md §1.1 forbids: Adobe's Source families and Noto -/// CJK, which is Source Han under another name. The test in `src/craft.rs` has the same list. -const BARRED_FAMILIES: [&str; 5] = ["Source Han", "Source Serif", "Source Sans", "Noto Sans CJK", "Noto Serif CJK"]; - /// One `CraftFont { .. }` initialiser per manifest line. fn craft_fonts(dir: &std::path::Path) -> Result { let manifest = dir.join("fonts/manifest.txt"); @@ -43,10 +42,13 @@ fn craft_fonts(dir: &std::path::Path) -> Result { let [family, style, file, scripts, ..] = f.as_slice() else { return Err(format!("malformed manifest line: {line}")); }; - // AGENTS.md §1.1 bars Adobe's type designs whatever their licence, so a checkout that - // carries one (craft-fonts has Noto Sans CJK, which is Source Han) never embeds it. - if BARRED_FAMILIES.iter().any(|barred| family.starts_with(barred)) { - println!("cargo::warning=craft-fonts: not embedding {family} {style} (AGENTS.md §1.1)"); + // Whole CJK faces aren't embedded for size; interface CJK comes from cut faces once the app + // moves to craft-fonts-build (storytold/craft-libs#213). + let cjk = scripts.split(',').any(cjk_scripts::is_cjk_script); + if cjk + && !cjk_scripts::WHOLE_CJK_FAMILIES.contains(family) + && std::fs::metadata(dir.join(file)).is_ok_and(|m| m.len() > cjk_scripts::MAX_OTHER_CJK_BYTES) + { continue; } // Arabic interface faces are small, so the web build keeps them too. diff --git a/crates/fonts/src/cjk_scripts.rs b/crates/fonts/src/cjk_scripts.rs new file mode 100644 index 0000000000..16a863c683 --- /dev/null +++ b/crates/fonts/src/cjk_scripts.rs @@ -0,0 +1,19 @@ +//! The rule `build.rs` follows for CJK faces: which manifest script tags are CJK, which families +//! are embedded whole, and the size cap for the others. The build script includes this file with +//! `#[path]` and the crate's tests use it as a module, so the two can't drift. + +/// The CJK families embedded whole: the Japanese faces documents and the renderer use. +pub const WHOLE_CJK_FAMILIES: [&str; 3] = ["Shippori Mincho", "BIZ UDPGothic", "BIZ UDMincho"]; + +/// Any other CJK face over this size is left out. +pub const MAX_OTHER_CJK_BYTES: u64 = 4 << 20; + +/// ISO 15924 codes of the Han, Japanese and Korean scripts, Hangul on its own included. +const CJK_SCRIPTS: [&str; 6] = ["Hani", "Hans", "Hant", "Jpan", "Kore", "Hang"]; + +/// Whether a craft-fonts manifest script tag is CJK. A tag can carry a region after the code +/// (craft-fonts tags Noto Sans HK UI `Hant-HK`), so only the part before `-` is compared. +pub fn is_cjk_script(tag: &str) -> bool { + let code = tag.trim().split('-').next().unwrap_or_default(); + CJK_SCRIPTS.contains(&code) +} diff --git a/crates/fonts/src/craft.rs b/crates/fonts/src/craft.rs index e6103a5edc..e778a42ad7 100644 --- a/crates/fonts/src/craft.rs +++ b/crates/fonts/src/craft.rs @@ -297,20 +297,6 @@ mod tests { assert_eq!(ui_arabic_fonts().len(), CRAFT_FONTS.iter().filter(|f| f.covers("Arab")).count()); } - /// Family-name prefixes `build.rs` never embeds (its own `BARRED_FAMILIES`): Adobe's type - /// designs, which includes Noto CJK (Source Han under another name). - const BARRED_FAMILIES: [&str; 5] = ["Source Han", "Source Serif", "Source Sans", "Noto Sans CJK", "Noto Serif CJK"]; - - /// Whatever the craft-fonts checkout holds, no Adobe type design is embedded (AGENTS.md §1.1). - #[test] - fn barred_families_are_never_embedded() { - for face in CRAFT_FONTS { - assert!(!BARRED_FAMILIES.iter().any(|barred| face.family.starts_with(barred)), "{} is embedded", face.name()); - } - assert!(BARRED_FAMILIES.iter().any(|barred| "Noto Sans CJK SC".starts_with(barred))); - assert!(!BARRED_FAMILIES.iter().any(|barred| "Noto Sans Arabic".starts_with(barred))); - } - #[test] fn hebrew_document_faces_follow_the_weight() { static HEBR_LATN: &[&str] = &["Hebr", "Latn"]; @@ -328,6 +314,26 @@ mod tests { assert_eq!(document_hebrew_font(false).is_some(), CRAFT_FONTS.iter().any(|f| f.covers("Hebr"))); } + /// `build.rs` embeds the Japanese CJK faces whole and any other CJK face only up to + /// `MAX_OTHER_CJK_BYTES`, so a checkout with a large Chinese or Korean face (craft-fonts has the + /// 16.4 MB Noto Sans CJK SC) leaves it out. The four Japanese faces of the pinned craft-fonts + /// are 24.1 MB. + #[test] + fn cjk_faces_follow_the_size_rule() { + use crate::cjk_scripts::{MAX_OTHER_CJK_BYTES, WHOLE_CJK_FAMILIES, is_cjk_script}; + // A tag can carry a region after the code: craft-fonts tags Noto Sans HK UI `Hant-HK`. + assert!(["Hant-HK", "Hans", "Jpan", "Hang"].iter().all(|s| is_cjk_script(s))); + assert!(!["Latn", "Arab", "Hebr"].iter().any(|s| is_cjk_script(s))); + let cjk = CRAFT_FONTS.iter().filter(|f| f.scripts.iter().any(|s| is_cjk_script(s))); + let (whole, other): (Vec<_>, Vec<_>) = cjk.partition(|f| WHOLE_CJK_FAMILIES.contains(&f.family)); + for face in &other { + let len = face.bytes.len(); + assert!(u64::try_from(len).is_ok_and(|n| n <= MAX_OTHER_CJK_BYTES), "{} is embedded at {len} bytes", face.name()); + } + let bytes: usize = whole.iter().map(|f| f.bytes.len()).sum(); + assert!(bytes < 25_000_000, "{bytes} bytes of Japanese faces"); + } + /// The bundled Inter faces carry the Latin, Greek and Cyrillic letters the craft-fonts faces /// lack: Uzbek қ ғ ҳ, Kazakh ә ө ү ұ һ, Ukrainian ї є ґ, Greek. This one runs with or without /// CRAFT_FONTS_DIR (#2593). diff --git a/crates/fonts/src/fallback.rs b/crates/fonts/src/fallback.rs index 64069a3de1..d309c6ba8f 100644 --- a/crates/fonts/src/fallback.rs +++ b/crates/fonts/src/fallback.rs @@ -156,8 +156,7 @@ impl Substitution { fn script_for(ordering: &str) -> Option<&'static str> { match ordering { "Japan1" => Some("Jpan"), - // Registered orderings we have no openly licensed face for. AGENTS.md 1.1 rules out - // Source Han and Noto CJK (Source Han rebranded), which is why these have none. + // Registered orderings the chain maps to no craft-fonts face. "GB1" | "CNS1" | "Korea1" | "KR" => Some(""), _ => None, } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 37a2ea74d8..61061f5509 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -7,6 +7,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(test)] +mod cjk_scripts; mod craft; pub mod embedding; mod encodings; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 1f8d2f19ef..0f43b06361 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -88,8 +88,8 @@ impl RenderConfig { /// A Japanese face from craft-fonts for a CID font of the Adobe-Japan1 collection that the PDF /// doesn't embed (`HeiseiMin-W3`, `KozGoPro-Medium`, …). hayro's own substitutes for fonts that /// aren't embedded are the Latin standard 14, so such text drew nothing. `None` for every other -/// font, and when PdfCraft was built without craft-fonts. Only Japanese: the pinned craft-fonts -/// has no other CJK faces, and its later Chinese face is Noto CJK, which AGENTS.md §1.1 rules out. +/// font, and when PdfCraft was built without craft-fonts. Only Japanese: no other CJK face is +/// embedded whole. fn japanese_fallback(query: &FontQuery) -> Option<(FontData, u32)> { let FontQuery::Fallback(f) = query else { return None }; if f.character_collection.as_ref()?.family != CidFamily::AdobeJapan1 { diff --git a/crates/ui-egui/src/system_fonts.rs b/crates/ui-egui/src/system_fonts.rs index e2c6a0dbd7..383dc1e7a9 100644 --- a/crates/ui-egui/src/system_fonts.rs +++ b/crates/ui-egui/src/system_fonts.rs @@ -331,8 +331,7 @@ fn other_candidates() -> Vec { } } -/// Chinese sans faces the operating system ships, the UI language's own region first. Noto CJK -/// and Source Han are left out on purpose (AGENTS.md §1.1). +/// Chinese sans faces the operating system ships, the UI language's own region first. fn han_candidates(traditional: bool) -> Vec { if cfg!(windows) { // Microsoft YaHei / JhengHei, then the older SimSun / MingLiU collections. @@ -363,8 +362,7 @@ fn han_candidates(traditional: bool) -> Vec { } } -/// Well-known locations of Korean faces, best first. Noto CJK and Source Han are left out, as for -/// Chinese (AGENTS.md §1.1). +/// Well-known locations of Korean faces, best first. fn hangul_candidates() -> Vec { if cfg!(windows) { ["malgun.ttf", "gulim.ttc", "batang.ttc"].iter().map(|f| windows_fonts().join(f)).collect() @@ -554,29 +552,17 @@ mod tests { } #[test] - fn hangul_candidates_are_font_files_without_adobe_designs() { + fn hangul_candidates_are_font_files() { let list = hangul_candidates(); assert!(!list.is_empty()); for path in list { assert!(path.extension().is_some_and(|e| e == "ttf" || e == "ttc"), "{path:?}"); - let name = path.to_string_lossy().to_lowercase(); - assert!(!["notosanscjk", "notoserifcjk", "sourcehan", "source-han"].iter().any(|b| name.contains(b)), "{name}"); } // Inter has no Hangul. let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); assert_eq!(face_with(inter, HANGUL_PROBE), None); } - #[test] - fn han_candidates_skip_adobe_designs() { - for need in [Need::Hans, Need::Hant] { - for path in candidates(need) { - let name = path.to_string_lossy().to_lowercase(); - assert!(!["notosanscjk", "notoserifcjk", "sourcehan", "source-han"].iter().any(|b| name.contains(b)), "{name}"); - } - } - } - /// Retune tripwires, deliberately separate from `the_macos_devanagari_list_is_apples_layout` below: these assert that three /// constants still hold the values this module's contract depends on. They cannot fail on their /// own — they exist so that editing one is a decision someone has to make twice. Each was diff --git a/crates/ui-egui/tests/it/fonts.rs b/crates/ui-egui/tests/it/fonts.rs index b2e9ffeb24..1e569230d8 100644 --- a/crates/ui-egui/tests/it/fonts.rs +++ b/crates/ui-egui/tests/it/fonts.rs @@ -48,14 +48,12 @@ fn japanese_ui_text_uses_craft_fonts() { } /// Chinese mode orders the CJK fallback with the `Hans` group first, so one line never -/// mixes faces with different vertical metrics. Without an allowed `Hans` face in the -/// build input the order part still holds; glyph coverage follows the craft-fonts checkout. +/// mixes faces with different vertical metrics. Without a `Hans` face in the build input the +/// order part still holds; glyph coverage follows the craft-fonts checkout. #[test] fn chinese_ui_text_prefers_the_chinese_face() { if pdfcraft_fonts::ui_chinese_fonts().is_empty() { - eprintln!( - "skipping chinese_ui_text_prefers_the_chinese_face: no Hans face bundled (set CRAFT_FONTS_DIR with an allowed Chinese face to run it)" - ); + eprintln!("skipping chinese_ui_text_prefers_the_chinese_face: no Hans face bundled (set CRAFT_FONTS_DIR with a Chinese face to run it)"); return; } let defs = theme::font_definitions_for(true); diff --git a/docs/gaps.md b/docs/gaps.md index 54935c77be..4349df5922 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -124,11 +124,12 @@ update the numbers. [#688](https://github.com/storytold/pdfcraft/issues/688), [#728](https://github.com/storytold/pdfcraft/issues/728), [#758](https://github.com/storytold/pdfcraft/issues/758), - [#689](https://github.com/storytold/pdfcraft/issues/689) (Flatpak). `AGENTS.md` §1.1 bars Noto - CJK (Source Han rebranded), so craft-fonts needs another openly licensed Hans face. + [#689](https://github.com/storytold/pdfcraft/issues/689) (Flatpak). craft-fonts has Noto Sans + CJK SC, but at 16.4 MB it is too large to embed whole; the interface needs a cut of it + (storytold/craft-libs#213). - **Impact:** the second-largest language group sees a broken interface although the catalog is 97% translated. -- **Estimate:** 6–12 h (font sourcing in craft-fonts plus the fallback chain). **Doc:** +- **Estimate:** 6–12 h (cut CJK faces through craft-fonts-build plus the fallback chain). **Doc:** [localization-parity.md](localization-parity.md). ### 6. Open-issue backlog of wrong results in shipped features diff --git a/docs/localization.md b/docs/localization.md index ccacccd09b..2d9aff7f5b 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -34,7 +34,7 @@ The Norwegian Bokmål catalog covers every key the other bundled catalogs have, Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet in Edit text. Add text (#128) draws Hebrew with the same face through the shaped path Arabic and Thai use: right to left with numbers and Latin in place, points kept on their letter, and typing order for copy and search. The craft-fonts revision pinned for CI and releases has no `Hebr` face yet, so until one is added releases report that error instead of drawing the missing letters, and refuse Hebrew in Add text. -Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Until craft-fonts has an allowed `Hans` face, the desktop app draws a Chinese interface with a Chinese face already installed on the machine (Microsoft YaHei or JhengHei on Windows, PingFang, Hiragino Sans GB or STHeiti on macOS, WenQuanYi or Droid Sans Fallback on Linux; never Noto CJK or Source Han), placed before the Japanese faces so a line keeps one face (#826). The web build has no installed fonts to fall back on. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Slovenian, Turkish, Lithuanian, Norwegian, Finnish, Albanian, Catalan and Romanian need only the bundled Latin faces (tested in `tests/it/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. +Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and in a build without craft-fonts every Chinese character does, on the web and on a desktop machine with no installed Chinese face). Until the interface embeds a `Hans` face, the desktop app draws a Chinese interface with a Chinese face already installed on the machine (Microsoft YaHei or JhengHei on Windows, PingFang, Hiragino Sans GB or STHeiti on macOS, WenQuanYi or Droid Sans Fallback on Linux), placed before the Japanese faces so a line keeps one face (#826). The web build has no installed fonts to fall back on. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Slovenian, Turkish, Lithuanian, Norwegian, Finnish, Albanian, Catalan and Romanian need only the bundled Latin faces (tested in `tests/it/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. The Brazilian Portuguese catalog has 2,674 entries and covers the current union of catalog keys. It began with the ~1,826-entry gist André Cardoso posted in [#288](https://github.com/storytold/pdfcraft/issues/288), with labels added since and the existing wording reviewed. Portuguese, like French, treats 0 and 1 as singular (`1 página`, `2 páginas`). @@ -57,11 +57,11 @@ These need bidirectional support in the text layout itself, not in the catalog. Central Kurdish (Sorani, `ckb`) takes the same path: its catalog is written in logical order and `rtl: true` puts every entry into display order at load (`bidi::display_rtl`), so it behaves as Arabic does above and has the same four limits. Counts use the two [CLDR ckb forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ckb) (`one`, `other`); Sorani keeps the noun singular after a numeral, so the two `@plural` forms usually read the same. Dates use the Kurdish names of the Gregorian months. The `Arab` face (Noto Sans Arabic) covers the Kurdish-only letters (`ێ ڵ ڕ ۆ ە ڤ ژ چ گ پ`). -Arabic glyphs come from the craft-fonts `Arab` face (Noto Sans Arabic, kept in the web build too). A build without craft-fonts shows Arabic as replacement boxes on the web; on desktop it falls back to one installed font (below). The craft-fonts revision pinned for CI and releases (`.github/workflows/`) has the Arabic face. That revision also holds Noto Sans CJK SC, which `crates/fonts/build.rs` never embeds: `AGENTS.md` §1.1 bars it, so Chinese still has no embedded face of its own (desktop builds fall back to an installed Chinese face, above). +Arabic glyphs come from the craft-fonts `Arab` face (Noto Sans Arabic, kept in the web build too). A build without craft-fonts shows Arabic as replacement boxes on the web; on desktop it falls back to one installed font (below). The craft-fonts revision pinned for CI and releases (`.github/workflows/`) has the Arabic face. That revision also holds Noto Sans CJK SC, which `crates/fonts/build.rs` leaves out for size (16.4 MB whole), so Chinese still has no embedded face of its own (desktop builds fall back to an installed Chinese face, above). File names and document titles in Arabic or Hebrew are drawn with a craft-fonts `Arab` face when the build input has one; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). The tab strip, the recent-files list and Properties show such names in display order; other places (bookmarks, comments, search results) have the glyphs but may order mixed-direction text wrongly. -Japanese, Simplified Chinese, French, German and Brazilian Portuguese coverage tests, following PhotoCraft's complete-catalog approach, check every registered command, All tools label and `tl!("literal")` in the UI source. Brazilian Portuguese also checks every key in the union of the bundled catalogs. Simplified Chinese additionally checks direct `i18n::t` calls and multiline literals, plus the About tabs, contributor name modes, sort options, table headers and model columns. Generated history labels, contributor summaries and diagnostics are tested with user-supplied names and error details. Catalog coverage does not guarantee glyph coverage: the release-pinned craft-fonts input currently has no `Hans` face, and the web build embeds only BIZ UDPGothic Regular. Chinese font delivery remains separate work, as discussed in [#112](https://github.com/storytold/pdfcraft/pull/112) and [#140](https://github.com/storytold/pdfcraft/pull/140). +Japanese, Simplified Chinese, French, German and Brazilian Portuguese coverage tests, following PhotoCraft's complete-catalog approach, check every registered command, All tools label and `tl!("literal")` in the UI source. Brazilian Portuguese also checks every key in the union of the bundled catalogs. Simplified Chinese additionally checks direct `i18n::t` calls and multiline literals, plus the About tabs, contributor name modes, sort options, table headers and model columns. Generated history labels, contributor summaries and diagnostics are tested with user-supplied names and error details. Catalog coverage does not guarantee glyph coverage: the craft-fonts revision pinned for CI and releases has no `Hans` face small enough to embed (desktop builds embed a `Hans` face of at most 4 MiB from `CRAFT_FONTS_DIR`), and the web build embeds only BIZ UDPGothic Regular. Chinese font delivery remains separate work, as discussed in [#112](https://github.com/storytold/pdfcraft/pull/112) and [#140](https://github.com/storytold/pdfcraft/pull/140). German labels keep nouns capitalized, including tool names inserted into notices. The certificate viewer uses the `certificate` context for `Subject` (the certificate holder), while document properties keep the plain translation (the topic). Search hints reserve room for the shortcut, and Home cards limit their title and description to the available rows; hovering shows the full text when it is shortened. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4af0cd98c2..6d950a71ca 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9966,7 +9966,7 @@ status = "partial" commands = ["app.preferences"] tools = [] evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/it/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/it/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/it/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/it/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_what_the_engine_says", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said", "crates/ui-egui/src/i18n/mod.rs::norwegian_bokmal_is_registered_and_persists"] -notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic/Norwegian Bokmål and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar|nb and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Brazilian Portuguese covers the current union of catalog keys, including signature and accessibility messages; Czech remains limited to menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one, and Norwegian locales (nb, nb_NO, and no/nn until a Nynorsk catalog exists) the Bokmål one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish, Italian and Brazilian Portuguese translate them, Polish the signature details and the changes after signing; strings absent from other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is never embedded, AGENTS.md §1.1), the engine's sentences in the other catalogs, a full localization audit." +notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic/Norwegian Bokmål and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar|nb and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Brazilian Portuguese covers the current union of catalog keys, including signature and accessibility messages; Czech remains limited to menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one, and Norwegian locales (nb, nb_NO, and no/nn until a Nynorsk catalog exists) the Bokmål one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish, Italian and Brazilian Portuguese translate them, Polish the signature details and the changes after signing; strings absent from other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is too large to embed whole), the engine's sentences in the other catalogs, a full localization audit." [[feature]] id = "misc.prefs-reading" diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index abd5f070cc..a34a9be4bf 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -230,6 +230,9 @@ pub fn check(root: &Path, m: &Manifest, repo_files: &[String], lock: &BTreeSet<( // Kinds that may never come from Adobe (AGENTS.md §1.1): visual design, and UI translations // (which must be clean-room, never taken from a product's string tables). let visual = |kind: &str| matches!(kind, "icon" | "image" | "font" | "logo" | "cursor" | "video" | "translation"); + // Typefaces under the SIL Open Font License are allowed whoever designed them (AGENTS.md §1.1). + let ofl_font = + |kind: &str, licence: &str| kind == "font" && licence_ids(licence).next().is_some() && licence_ids(licence).all(|id| id == "OFL-1.1"); let licence_ok = |what: &str, licence: &str, problems: &mut Vec| { for id in licence_ids(licence) { if !ALLOWED_LICENCES.contains(&id) { @@ -248,7 +251,7 @@ pub fn check(root: &Path, m: &Manifest, repo_files: &[String], lock: &BTreeSet<( if !root.join(&a.licence_file).is_file() { problems.push(format!("{}: licence file {} is missing", a.path, a.licence_file)); } - if visual(&a.kind) && (mentions_adobe(&a.author) || mentions_adobe(&a.source) || mentions_adobe(&a.title)) { + if visual(&a.kind) && !ofl_font(&a.kind, &a.licence) && (mentions_adobe(&a.author) || mentions_adobe(&a.source) || mentions_adobe(&a.title)) { problems.push(format!("{}: {} from Adobe is forbidden (AGENTS.md §1.1)", a.path, a.kind)); } if a.adobe_data && !(ADOBE_DATA_FILES.contains(&a.path.as_str()) && a.kind == "data") { @@ -271,10 +274,10 @@ pub fn check(root: &Path, m: &Manifest, repo_files: &[String], lock: &BTreeSet<( if !lock.contains(&(b.krate.clone(), b.version.clone())) { problems.push(format!("{what}: crate version not in Cargo.lock (dependency changed: re-audit its bundled assets)")); } - if visual(&b.kind) && (mentions_adobe(&b.author) || mentions_adobe(&b.source)) { + if visual(&b.kind) && !ofl_font(&b.kind, &b.licence) && (mentions_adobe(&b.author) || mentions_adobe(&b.source)) { problems.push(format!("{what}: {} from Adobe is forbidden (AGENTS.md §1.1)", b.kind)); } - let adobe = mentions_adobe(&b.author); + let adobe = mentions_adobe(&b.author) && !ofl_font(&b.kind, &b.licence); let approved = ADOBE_DATA_ALLOWED.contains(&(b.krate.as_str(), b.path.as_str())) && b.kind == "data"; if (adobe || b.adobe_data) && !(approved && b.adobe_data) { problems.push(format!("{what}: Adobe-authored material outside the closed list in AGENTS.md §1.1")); @@ -285,7 +288,7 @@ pub fn check(root: &Path, m: &Manifest, repo_files: &[String], lock: &BTreeSet<( } for f in &m.fetched { licence_ok(&f.file, &f.licence, &mut problems); - if visual(&f.kind) && (mentions_adobe(&f.author) || mentions_adobe(&f.source) || mentions_adobe(&f.title)) { + if visual(&f.kind) && !ofl_font(&f.kind, &f.licence) && (mentions_adobe(&f.author) || mentions_adobe(&f.source) || mentions_adobe(&f.title)) { problems.push(format!("{}: {} from Adobe is forbidden (AGENTS.md §1.1)", f.file, f.kind)); } if f.sha256.len() != 64 || f.licence_sha256.len() != 64 { @@ -297,7 +300,7 @@ pub fn check(root: &Path, m: &Manifest, repo_files: &[String], lock: &BTreeSet<( } for b in &m.build_input { licence_ok(&b.name, &b.licence, &mut problems); - if visual(&b.kind) && (mentions_adobe(&b.author) || mentions_adobe(&b.source) || mentions_adobe(&b.title)) { + if visual(&b.kind) && !ofl_font(&b.kind, &b.licence) && (mentions_adobe(&b.author) || mentions_adobe(&b.source) || mentions_adobe(&b.title)) { problems.push(format!("{}: {} from Adobe is forbidden (AGENTS.md §1.1)", b.name, b.kind)); } if !b.source.starts_with("https://") || !b.attribution.starts_with("https://") { @@ -527,6 +530,21 @@ mod tests { assert!(check(&root(), &m, &[], &lock(&[])).iter().any(|p| p.contains("from Adobe is forbidden"))); } + #[test] + fn adobe_ofl_fonts_are_allowed_and_other_adobe_fonts_rejected() { + let l = lock(&[("noto", "1.0.0")]); + let noto = |licence: &str| { + let mut b = bundled("noto", "fonts/NotoSansCJKsc-Regular.otf", "Adobe", "font", false); + b.licence = licence.into(); + Manifest { bundled: vec![b], ..Default::default() } + }; + assert!(check(&root(), ¬o("OFL-1.1"), &[], &l).is_empty()); + assert!(check(&root(), ¬o("MIT"), &[], &l).iter().any(|p| p.contains("forbidden"))); + assert!(check(&root(), ¬o(""), &[], &l).iter().any(|p| p.contains("forbidden")), "an empty licence is not OFL"); + let icon = Manifest { asset: vec![asset("assets/icons/x.svg", "Adobe Inc.", "icon", "OFL-1.1")], ..Default::default() }; + assert!(check(&root(), &icon, &[], &l).iter().any(|p| p.contains("from Adobe is forbidden"))); + } + #[test] fn adobe_icons_are_rejected() { let m = Manifest { asset: vec![asset("assets/icons/x.svg", "Adobe Inc.", "icon", "MIT")], ..Default::default() }; From 045c8b39a712f30a72209aa479651949e555f7a0 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:34:21 -0400 Subject: [PATCH 04/15] CI: nextest, line tables only, one wasm check (#4269) * CI speed: line tables for workspace crates, no debug info for dependencies * CI speed: xtask ci runs nextest and doctests, one wasm check, the CLI without default features --- Cargo.toml | 12 ++++++------ docs/architecture.md | 7 +++++-- docs/development.md | 13 ++++++++----- xtask/src/gates.rs | 35 ++++++++++++++++++++++++----------- xtask/src/main.rs | 9 +++++++-- 5 files changed, 50 insertions(+), 26 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 92f51290d4..2714debb62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,20 +89,20 @@ dbg_macro = "warn" todo = "warn" # `"*"` matches every non-workspace package: crates.io dependencies and the -# vendored crates under `vendor/` (excluded from the workspace). Our own crates -# keep full debug info. Dependencies keep only line tables, so backtraces still -# show file and line, but debug info for the whole dependency graph stays out of -# every test binary (each UI test binary had a 500-600 MB PDB on Windows, #454). +# vendored crates under `vendor/` (excluded from the workspace). Dependencies +# carry no debug info and our own crates only line tables: backtraces and +# panics still show file and line, while test binaries and the target dir stay +# small (each UI test binary had a 500-600 MB PDB on Windows, #454). [profile.dev.package."*"] opt-level = 2 -debug = "line-tables-only" +debug = false [profile.dev] opt-level = 1 +debug = "line-tables-only" [profile.low-disk] inherits = "dev" -debug = "line-tables-only" incremental = false [profile.release] diff --git a/docs/architecture.md b/docs/architecture.md index 6b44817e15..c6b64da48d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # PdfCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (xtask ci: nextest, one wasm check) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How PdfCraft is built today. The design rationale and the planned crates live in `plan/architecture.md` and `plan/adr/` (local-only); this file describes what is on `main`. Each @@ -99,7 +99,9 @@ Every user-facing feature is reachable headlessly (`AGENTS.md` §3): ## Build and quality gates -`cargo xtask ci` runs fmt, clippy (`-D warnings`), tests, the layering check, the WASM check, +`cargo xtask ci` runs fmt, clippy (`-D warnings`), a check of `pdfcraft-cli` without default +features, the tests (`xtask test`: cargo-nextest when installed, else `cargo test`, then the +doctests), the layering check, the WASM check (one `cargo check` over every crate below L8), `xtask assets` (asset licences and hashes), `xtask parity` (checklist claims must cite real commands, tools and tests) and `xtask deny` (dependency licences). Other tasks: `xtask fuzz`, `xtask corpus` and `xtask check` (robustness sweep over the pinned pdf.js corpus), `xtask text-oracle` (word F1 and reading order against `pdftotext`), @@ -110,4 +112,5 @@ built by `.github/workflows/release.yml` ([releasing.md](releasing.md)). | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | `xtask ci` runs nextest and doctests, checks the CLI without default features, and checks wasm in one call | | 2026-10-10 | major | Created from the code on main (crate list, `xtask/src/layers.rs`, crate READMEs, `vendor/README.md`) | diff --git a/docs/development.md b/docs/development.md index 1c6cce6a95..1142fed333 100644 --- a/docs/development.md +++ b/docs/development.md @@ -12,11 +12,14 @@ cargo build --profile low-disk -p pdfcraft-cli cargo test --profile low-disk -p pdfcraft-cos --lib ``` -The profile inherits the normal development settings, including debug assertions and overflow -checks. It uses line-table-only debug information for workspace code, which keeps file and line -numbers in backtraces but omits local-variable and parameter details, and it disables incremental -compilation for this profile only. Ordinary `cargo build`, `cargo test`, and release builds keep -their existing profiles. +The profile inherits the normal development settings, including debug assertions, overflow +checks and line-table-only debug information, and disables incremental compilation for this +profile only. Ordinary `cargo build` and `cargo test` keep incremental compilation. + +The development profile itself keeps only line tables for workspace code and no debug information +for dependencies. Backtraces show file and line numbers but no local variables or parameters; for +full debug information in workspace code (for a debugger), build with +`CARGO_PROFILE_DEV_DEBUG=true`. Cargo stores custom-profile artifacts separately under `target/low-disk`. Switching between the normal and low-disk profiles can therefore leave both sets of artifacts on disk. To remove only diff --git a/xtask/src/gates.rs b/xtask/src/gates.rs index 8e7db8cf08..f64ded0998 100644 --- a/xtask/src/gates.rs +++ b/xtask/src/gates.rs @@ -78,20 +78,32 @@ pub fn wasm(_: &[String]) -> anyhow::Result<()> { .filter(|c| matches!(layers::classify(&c.name), Some(layers::Class::Layer(_) | layers::Class::Standalone(_)))) .map(|c| c.name) .collect(); - let mut failed = Vec::new(); + // One call over every crate: they share one dependency build, and `--keep-going` reports + // each failing crate instead of stopping at the first. + let mut c = cargo(); + c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]); for pkg in &set { - let mut c = cargo(); - c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]); - if run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_err() { - failed.push(pkg.clone()); - } + c.args(["-p", pkg]); } - if failed.is_empty() { - println!("wasm32: {} crates ok", set.len()); - Ok(()) + run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going ({} crates)", set.len()))?; + println!("wasm32: {} crates ok", set.len()); + Ok(()) +} + +/// The workspace tests. Unit and integration tests run under cargo-nextest when it is installed +/// (one process per test, so the run is not held up by its slowest binary), else under +/// `cargo test`; doctests run separately because nextest does not run them. Examples are left +/// out of the test build: clippy `--all-targets` already checks that they compile. +pub fn test(_: &[String]) -> anyhow::Result<()> { + let targets = ["--workspace", "--lib", "--bins", "--tests"]; + let nextest = cargo().args(["nextest", "--version"]).output().is_ok_and(|o| o.status.success()); + if nextest { + run_args(&[&["nextest", "run", "--no-fail-fast"][..], &targets].concat())?; } else { - bail!("wasm32 check failed for: {}", failed.join(", ")) + eprintln!("test: cargo-nextest is not installed, using cargo test (cargo install cargo-nextest --locked)"); + run_args(&[&["test", "--no-fail-fast"][..], &targets].concat())?; } + run_args(&["test", "--workspace", "--doc"]) } pub fn ci(_: &[String]) -> anyhow::Result<()> { @@ -99,7 +111,8 @@ pub fn ci(_: &[String]) -> anyhow::Result<()> { let steps: Vec = vec![ ("fmt", Box::new(|| run_args(&["fmt", "--all", "--", "--check"]))), ("clippy", Box::new(|| run_args(&["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))), - ("test", Box::new(|| run_args(&["test", "--workspace"]))), + ("cli-no-default-features", Box::new(|| run_args(&["check", "-p", "pdfcraft-cli", "--no-default-features"]))), + ("test", Box::new(|| test(&[]))), ("layers", Box::new(|| layers(&[]))), ("wasm", Box::new(|| wasm(&[]))), ("assets", Box::new(|| crate::assets::run(&[]))), diff --git a/xtask/src/main.rs b/xtask/src/main.rs index f06c099441..442fcf8a4e 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -19,9 +19,14 @@ type Command = fn(&[String]) -> anyhow::Result<()>; const COMMANDS: &[(&str, &str, Command)] = &[ ("version", "Print the workspace version, or `version set X.Y.Z[-pre]` to change it and refresh Cargo.lock", version_cmd), ("layers", "Enforce the crate dependency layering (plan/architecture.md §3)", gates::layers), - ("wasm", "cargo check --target wasm32-unknown-unknown for every crate below L8", gates::wasm), + ("wasm", "cargo check --target wasm32-unknown-unknown for every crate below L8, in one call", gates::wasm), ("deny", "Dependency licences, bans, sources and advisories (deny.toml; needs cargo-deny)", gates::deny), - ("ci", "fmt --check, clippy -D warnings, test, layers, wasm, assets, deny, parity (stops at first failure)", gates::ci), + ( + "ci", + "fmt --check, clippy -D warnings, the CLI without default features, test, layers, wasm, assets, deny, parity (stops at first failure)", + gates::ci, + ), + ("test", "Workspace tests: cargo nextest (cargo test when it is missing), then doctests", gates::test), ("assets", "Enforce the asset policy (AGENTS.md §1) against ATTRIBUTION.toml; --write regenerates ATTRIBUTION.md", assets::run), ("corpus", "Fetch the pinned pdf.js test corpus into corpus/ (git-ignored) and verify it; --update-pin re-records the pin", gates::corpus), ("check", "Robustness sweep over corpus/ with pdfcraft-cli; fails on crashes or regressions vs xtask/baselines", gates::check), From d1756e487d91837fafbfc553b1427e0f0ea39f30 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:02:43 -0400 Subject: [PATCH 05/15] CI: shared gate workflow; nightly fuzzing in its own workflow (#4270) * CI: the shared gate workflow; nightly fuzzing in fuzz.yml * CI: run the gate now that the shared workflow is on craft-common-actions main * CI: adding full-ci to an open PR runs the gate; fuzz checkout keeps no token --- .github/workflows/ci.yml | 180 +++++++++---------------------------- .github/workflows/fuzz.yml | 46 ++++++++++ 2 files changed, 86 insertions(+), 140 deletions(-) create mode 100644 .github/workflows/fuzz.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53ed10344b..ac276f13c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,43 +1,38 @@ name: CI +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. Nightly fuzzing is in fuzz.yml. on: push: branches: [main] pull_request: + # `labeled` so that adding `full-ci` to an open PR starts a run with Windows in it. + types: [opened, synchronize, reopened, labeled] workflow_dispatch: - schedule: - - cron: "17 3 * * *" # nightly fuzzing # The token is only used to check out the code: no job writes to the repository. permissions: contents: read -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 - +# A newer push to a PR branch cancels its older run; every push to main runs to the end. Adding +# any label but `full-ci` gets a group of its own, so it neither runs the gate nor cancels a run. concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true + group: ci-${{ github.event_name == 'pull_request' && (github.event.action != 'labeled' || github.event.label.name == 'full-ci') && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - test: - name: test (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - env: - CARGO_PROFILE_DEV_DEBUG: line-tables-only - CARGO_PROFILE_TEST_DEBUG: line-tables-only - strategy: - fail-fast: false - matrix: - # No macOS on PRs, main or nightly: the org's 5 macOS slots are kept for release builds, - # and release.yml builds macOS (a failing release is fixed and rebuilt). - os: [ubuntu-latest, windows-latest] + # The shared gate doesn't check where git dependencies come from; release.yml runs the same job. + git-sources: + if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' + name: Git dependencies come from storytold + runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - + with: + persist-credentials: false - name: Git dependencies come from storytold - if: runner.os == 'Linux' run: | set -euo pipefail test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } @@ -69,121 +64,26 @@ jobs: sys.exit(1) PY - # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 - path: craft-fonts - - - name: Configure craft-fonts (Linux) - if: runner.os == 'Linux' - run: | - echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV" - echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV" - - - name: Install Linux system dependencies (egui/winit/rfd) and the oracles - if: runner.os == 'Linux' - 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 libgtk-3-dev \ - qpdf poppler-utils - - # The independent oracles (crates/oracle) are only worth having when they run. With this - # set, a missing one fails the job instead of printing a skip notice that reads as a pass. - # Only the tools the job actually uses are installed above. - - name: Require the oracles (Linux) - if: runner.os == 'Linux' - run: echo "PDFCRAFT_ORACLES_REQUIRED=1" >> "$GITHUB_ENV" - - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - toolchain: stable - components: clippy, rustfmt - - - name: Require Windows executable resources - if: runner.os == 'Windows' - run: Add-Content -Path $env:GITHUB_ENV -Value 'PDFCRAFT_REQUIRE_WINRES=1' - shell: pwsh - - - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - - - name: Format - if: runner.os == 'Linux' - run: cargo fmt --all -- --check - - - name: Clippy - run: cargo clippy --workspace --all-targets -- -D warnings - - - name: Test - if: runner.os != 'Linux' - run: cargo test --workspace - - - name: Test (with craft-fonts) - if: runner.os == 'Linux' - run: cargo test --workspace - - - name: Layering - if: runner.os == 'Linux' - run: cargo xtask layers - - - name: Assets (licences and attribution) - if: runner.os == 'Linux' - run: cargo xtask assets - - - name: CLI without the MCP server still builds - if: runner.os == 'Linux' - run: cargo check -p pdfcraft-cli --no-default-features - - wasm: - name: wasm32 check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - toolchain: stable - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - with: - key: wasm32 - - name: Check library crates for wasm32 - run: cargo xtask wasm - - deny: - name: dependency licences and advisories - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 - with: - command: check - - fuzz: - name: fuzz (mutation, 15 min) - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - toolchain: stable - - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - with: - key: fuzz - - name: Fetch the pdf.js corpus (seeds) - run: cargo xtask corpus - - name: Fuzz - run: cargo xtask fuzz --time 900 --seed ${{ github.run_number }} - - name: Upload findings - if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: fuzz-findings - path: fuzz-out/findings - if-no-files-found: ignore + gate: + if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + # Windows on main, manual runs and pull requests labelled `full-ci`. No macOS on PRs or main: + # the org's 5 macOS slots are kept for release builds, and release.yml builds macOS. + os: ${{ (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-ci')) && '["ubuntu-24.04","windows-latest"]' || '["ubuntu-24.04"]' }} + # Same craft-fonts commit as release.yml and freebsd.yml. + craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + # egui/winit/rfd, and the independent oracles (crates/oracle). + packages: >- + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev + libgtk-3-dev qpdf poppler-utils + # cargo-deny for the deny step of `cargo xtask ci`, which requires it under CI. + tools: cargo-nextest@0.9.149,cargo-deny@0.20.2 + # A missing oracle fails the job instead of printing a skip notice that reads as a pass. + linux-env: PDFCRAFT_ORACLES_REQUIRED=1 + windows-env: PDFCRAFT_REQUIRE_WINRES=1 + # Windows checks what differs by platform: the build (with resources) and the tests. + windows-command: | + cargo clippy --workspace --all-targets --locked -- -D warnings + cargo test --workspace --locked diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000000..b7b1d409c8 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,46 @@ +name: Fuzz + +# Mutation fuzzing of open/render/edit/save (`cargo xtask fuzz`), nightly and on demand. Findings +# are uploaded as an artifact; each real one becomes a synthetic regression test. +on: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +# The token is only used to check out the code: no job writes to the repository. +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +concurrency: + group: fuzz-${{ github.ref }} + cancel-in-progress: true + +jobs: + fuzz: + name: fuzz (mutation, 15 min) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + with: + toolchain: stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: fuzz + - name: Fetch the pdf.js corpus (seeds) + run: cargo xtask corpus + - name: Fuzz + run: cargo xtask fuzz --time 900 --seed ${{ github.run_number }} + - name: Upload findings + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: fuzz-findings + path: fuzz-out/findings + if-no-files-found: ignore From c9ac28a04843141f2261ade4b9a19d51aed6f182 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:22:19 -0400 Subject: [PATCH 06/15] test(automation): give sign_document a real digital ID and document in the hostile-arguments sweep (#4277) * test(automation): give sign_document a real digital ID and document in the hostile-arguments sweep sign_id_create, earlier in the sweep, wrote a digital ID to the in.pdf placeholder that sign_document then took as its id. Almost every sign_document call failed to decrypt it and never reached the hostile value under test, and the decrypts took most of the test's time. Each tool now starts from an empty root, and sign_document gets a test ID with its password and a fresh blank document per call. 370 of its 440 calls get past the ID (1 before) and 340 past the document (0 before); the sweep takes 7 s instead of 89 s. * test(automation): empty the sweep's root through its canonical path form_merge_data, given the hostile name " ", writes a file of that name into the root through the canonical \\?\ path, where Windows takes it literally. Emptying the root through the plain path dropped the trailing space, so the entry named the root itself and removing it deleted the root. Entries are now reached through the canonical path, their type is read from the directory entry, and a failed removal is reported. * test(automation): fail the sweep when the root can't be emptied Listing, typing or removing an entry left in the root now fails the test with the entry's path, so no tool runs against an earlier tool's files. --- crates/automation/tests/it/hostile_args.rs | 69 +++++++++++++++++++--- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/crates/automation/tests/it/hostile_args.rs b/crates/automation/tests/it/hostile_args.rs index d393e37268..c017ff78d3 100644 --- a/crates/automation/tests/it/hostile_args.rs +++ b/crates/automation/tests/it/hostile_args.rs @@ -190,6 +190,54 @@ fn guarded(a: &mut Automation, tool: &str, args: &Value) -> std::result::Result< } } +/// A new one-page blank document, by id. +fn blank_doc(a: &mut Automation) -> u64 { + let created = a.call("doc_create", &json!({ "from": "blank", "pages": 1 })).expect("create a blank document"); + created + .into_iter() + .find_map(|c| match c { + pdfcraft_automation::Content::Json(v) => v["doc"].as_u64(), + pdfcraft_automation::Content::Png { .. } => None, + }) + .expect("the new document has an id") +} + +/// Delete everything inside `dir`, so one tool never sees files an earlier tool wrote: a digital ID +/// that `sign_id_create` wrote to the `in.pdf` placeholder would stop every later `sign_document` +/// call at its decrypt, before the argument under test. +/// +/// The entries are reached through the canonical path, as the tools reach them: on Windows that +/// is a `\\?\` path, whose names are taken literally. Through the plain path a file the tools +/// named `" "` would lose its trailing space and name `dir` itself, which would then be deleted. +fn empty_dir(dir: &Path) { + let dir = dir.canonicalize().expect("the root exists"); + for entry in std::fs::read_dir(&dir).expect("read the root") { + let entry = entry.unwrap_or_else(|e| panic!("list {}: {e}", dir.display())); + let path = entry.path(); + let is_dir = entry.file_type().unwrap_or_else(|e| panic!("file type of {}: {e}", path.display())).is_dir(); + let removed = if is_dir { std::fs::remove_dir_all(&path) } else { std::fs::remove_file(&path) }; + removed.unwrap_or_else(|e| panic!("remove {} before the next tool: {e}", path.display())); + } +} + +/// Real values for the arguments a tool checks before the others, so a hostile value reaches the +/// argument it is meant for instead of failing on a placeholder. `sign_document` checks its +/// digital ID first and its document second: this ID opens with its password, and each call gets +/// a fresh document (which the caller closes), since a successful call signs it. The ID is a test +/// key with a low key derivation count, so the hundreds of calls that open it stay fast. +fn fixture(a: &mut Automation, root: &Path, tool: &str) -> serde_json::Map { + match tool { + "sign_document" => { + let id = Path::new(env!("CARGO_MANIFEST_DIR")).join("../sign/tests/data/ec-p256.p12"); + std::fs::copy(&id, root.join("id.p12")).expect("copy the test digital ID"); + let doc = blank_doc(a); + let args = json!({ "doc": doc, "id": "id.p12", "password": "test", "out": "signed.pdf" }); + args.as_object().cloned().unwrap_or_default() + } + _ => serde_json::Map::new(), + } +} + #[test] fn no_tool_panics_on_hostile_arguments() { let root = tmp("args"); @@ -199,6 +247,7 @@ fn no_tool_panics_on_hostile_arguments() { let mut calls = 0usize; for def in tools() { + empty_dir(&root); // The whole arguments object, not just its properties. for bad in [json!(null), json!([]), json!("string"), json!(7), json!(true), json!({})] { calls += 1; @@ -210,13 +259,24 @@ fn no_tool_panics_on_hostile_arguments() { for prop in &props { for value in &values { let mut obj = plausible(&def); + let fixed = fixture(&mut a, &root, def.name); + let fixture_doc = fixed.get("doc").cloned(); + obj.extend(fixed); obj.insert(prop.clone(), value.clone()); calls += 1; if let Err(e) = guarded(&mut a, def.name, &Value::Object(obj)) { failures.push(e); } + // Later tools pass placeholder document ids, which must not find this one open. + if let Some(doc) = fixture_doc { + a.call("doc_close", &json!({ "doc": doc, "discard_changes": true })).expect("close the fixture's document"); + } } } + // A signed file shows the fixture got calls past the ID and document checks. + if def.name == "sign_document" { + assert!(root.join("signed.pdf").is_file(), "no sign_document call signed: the hostile values stop at the ID or document"); + } } eprintln!("hostile arguments: {calls} calls over {} tools, {} panics", tools().len(), failures.len()); @@ -256,14 +316,7 @@ fn no_tool_escapes_the_root() { let mut a = Automation::new().with_root(&root).expect("root"); // A real document, so tools reach their path arguments instead of failing on `doc` first. - let created = a.call("doc_create", &json!({ "from": "blank", "pages": 1 })).expect("create a blank document"); - let doc = created - .into_iter() - .find_map(|c| match c { - pdfcraft_automation::Content::Json(v) => v["doc"].as_u64(), - pdfcraft_automation::Content::Png { .. } => None, - }) - .expect("the new document has an id"); + let doc = blank_doc(&mut a); let mut leaked = Vec::new(); let mut tried = 0usize; From aea5e78e6600d12678c3c96f29f2206ef1b1c48b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 14:41:20 -0400 Subject: [PATCH 07/15] release: the git-sources gate moves into craft-common-actions (storytold/craft-common-actions#78) (#4292) The check is shared at release.yml@main now; keep only the reusable call. --- .github/workflows/release.yml | 40 ----------------------------------- 1 file changed, 40 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c9994ae36..c6ee0e1c31 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,47 +18,7 @@ concurrency: cancel-in-progress: false jobs: - git-sources: - name: Git dependencies come from storytold - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Git dependencies come from storytold - run: | - set -euo pipefail - test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } - # Source replacement, patches and path overrides in cargo config never show up - # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. - # Cargo still reads the legacy .cargo/config. - python3 - <<'PY' - import os, sys, tomllib - for path in (".cargo/config", ".cargo/config.toml"): - if not os.path.exists(path): - continue - config = tomllib.load(open(path, "rb")) - replaced = [name for name, source in config.get("source", {}).items() - if isinstance(source, dict) and "replace-with" in source] - if replaced or "patch" in config or "paths" in config: - print(f"{path} replaces, patches or overrides a source") - sys.exit(1) - PY - # Fails when Cargo.lock is out of date with the manifests. - cargo metadata --locked --format-version 1 > /dev/null - python3 - <<'PY' - import sys, tomllib - lock = tomllib.load(open("Cargo.lock", "rb")) - bad = sorted({p["source"] for p in lock.get("package", []) - if p.get("source", "").startswith("git+") - and not p["source"].startswith("git+https://github.com/storytold/")}) - if bad: - print("Git dependencies outside github.com/storytold:", *bad, sep="\n") - sys.exit(1) - PY - release: - needs: git-sources # 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: From 8d99a5ce1f909c1ffad9e2a866d62644be0d00cf Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:42:18 -0400 Subject: [PATCH 08/15] Translations: a missing string never fails a test; xtask ci prints the gaps (#4302) Delete the translation coverage tests and the tests of one language's wording. Keep catalog validation (format, duplicates, placeholders, plural forms, command ids) and the tests that use a language (registration, plural rules, RTL order, glyphs, casing, font fallback). cargo xtask i18n-report runs i18n::tests::translation_gaps_report, which prints each language's missing count and first few keys and always passes; xtask ci runs it after the tests. --- crates/ui-egui/src/docking.rs | 8 - crates/ui-egui/src/i18n/mod.rs | 2612 ++-------------------------- crates/ui-egui/src/motion.rs | 16 - crates/ui-egui/src/object_ui.rs | 25 - crates/ui-egui/src/scroll_speed.rs | 3 - crates/ui-egui/src/sign_ui.rs | 9 +- docs/architecture.md | 6 +- docs/localization.md | 13 +- parity/acrobat-features.toml | 2 +- xtask/src/gates.rs | 31 + xtask/src/main.rs | 3 +- 11 files changed, 190 insertions(+), 2538 deletions(-) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 4531709046..11eb732be3 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -459,14 +459,6 @@ fn panel_limits(panel: &Panel) -> PanelLimits { mod tests { use super::*; - #[test] - fn lithuanian_translates_every_dynamic_panel_title() { - let lang = crate::i18n::Lang::from_code("lt").expect("Lithuanian is registered"); - for panel in Panel::ALL { - assert_ne!(crate::i18n::tr(lang, panel.label()), panel.label(), "{} remains untranslated", panel.id()); - } - } - #[test] fn tools_side_moves_to_both_edges_and_survives_closed_restore() { let mut workspace = Workspace::default(); diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index dff8efc3de..8f17a8acaf 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -686,19 +686,19 @@ mod tests { use super::*; const JA: fn() -> Lang = || Lang::from_code("ja").expect("ja registered"); - // These physical key-cap names remain English in several keyboard layouts. Only their - // dedicated key context may use them unchanged; ordinary command labels still translate. - const KEY_CAP_NAMES: &[&str] = &["Ctrl", "Shift", "Delete", "Esc", "Home", "End"]; #[test] fn keyboard_key_names_have_a_separate_context_and_english_fallback() { - for lang in Lang::all().filter(|l| *l != Lang::EN) { - for key in ["Ctrl", "Shift", "Delete", "Esc", "Home", "End", "Space"] { - assert!(lang.catalog().contextual("key", key).is_some(), "{}: {key}", lang.code()); - } - } + static KEYS: LangInfo = LangInfo { + code: "keys", + name: "Key name test catalog", + source: "key\tDelete\tEntf\n\tDelete\tLöschen\n\tOpen\tÖffnen\n", + plural: plural_one_other, + rtl: false, + catalog: OnceLock::new(), + }; let previous = current(); - set_current(Lang::from_code("de").unwrap()); + set_current(Lang(&KEYS)); assert_eq!(key_name("Delete"), "Entf"); assert_eq!(t("Delete"), "Löschen"); assert_eq!(key_name("Open"), "Open", "a missing key name must not borrow a command translation"); @@ -936,89 +936,9 @@ mod tests { assert_eq!(fmt("unchanged", &[]), "unchanged"); } - /// #103: the missing-OCR-models messages are translated under their current English text - /// (catalogs once keyed an older `PRINTCRAFT_MODELS` wording, which never matched). - #[test] - fn missing_ocr_models_messages_are_translated() { - let error = pdfcraft_engine::ocr::OcrError::NoModels.to_string(); - let notice = "Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them."; - assert!(include_str!("../ocr_ui.rs").contains(notice), "keep in step with ocr_ui.rs"); - for code in ["bg", "cs", "de", "es", "fr", "hu", "ja", "ru", "te", "uk", "zh-hans", "zh-hant"] { - let lang = Lang::from_code(code).expect("registered"); - assert!(has(lang, notice) && has(lang, &error), "{code}"); - } - for lang in &LANGUAGES { - assert!(!lang.source.contains("PRINTCRAFT"), "{}", lang.code); - } - } - - /// Japanese translates every registered command and every All tools group, section and item. - #[test] - fn japanese_covers_commands_and_catalogue() { - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(JA(), command.label), "missing command: {}", command.label); - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(JA(), group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(JA(), section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(JA(), item.label), "missing item: {}", item.label); - } - } - } - } - - /// Every `tl!("…")` literal in the UI source (test modules aside), unescaped. - /// Every label Shared Field Properties shows, including those it passes through a variable - /// (flag labels, errors, the font label), exists in each full catalog. - #[test] - fn bulk_field_properties_labels_are_in_every_full_catalog() { - let source = include_str!("../bulk_fields.rs"); - // The string literal starting right after `at`, unescaped. - fn literal(rest: &str) -> Option { - let mut out = String::new(); - let mut chars = rest.chars(); - while let Some(c) = chars.next() { - match c { - '"' => return Some(out), - '\\' => out.push(chars.next()?), - c => out.push(c), - } - } - None - } - let mut labels = std::collections::BTreeSet::new(); - for marker in ["tl!(\"", "Err(\"", "ok_or(\"", "|| \"", "MISSING: &str = \"", "i18n::t(\"", "_ => \""] { - for (at, _) in source.match_indices(marker) { - labels.extend(source.get(at + marker.len()..).and_then(literal)); - } - } - // Flag labels: `(field_flags::NAME, "Label", inverted)`. - for (at, _) in source.match_indices("(field_flags::") { - let line = source.get(at..).and_then(|s| s.lines().next()).unwrap_or_default(); - if let Some(q) = line.find(", \"") { - labels.extend(line.get(q + 3..).and_then(literal)); - } - } - labels.retain(|l| l.chars().any(char::is_alphabetic) && !l.contains("credits") && !l.starts_with("bulk")); - for label in ["Mixed", "Custom font", "The document is no longer open."] { - labels.insert(label.to_string()); - } - assert!(labels.len() > 20 && labels.contains("Comb of characters"), "the scan found the dialog's labels: {labels:?}"); - for code in ["bg", "cs", "de", "es", "fr", "hu", "it", "ja", "pt-br", "ru", "te", "tr", "uk", "zh-hans", "zh-hant"] { - let lang = Lang::from_code(code).expect("registered"); - let missing: Vec<_> = labels.iter().filter(|l| !has(lang, l)).collect(); - assert!(missing.is_empty(), "{code} lacks Shared Field Properties labels: {missing:#?}"); - } - } - + /// Every literal passed to `tl!(…)` or `crate::i18n::t(…)` in the UI source (test modules + /// aside), unescaped, including literals on a line of their own. fn ui_literals() -> std::collections::BTreeSet { - scan_ui_literals(false) - } - - // Lithuanian also checks direct lookup calls and multiline literal arguments. - fn scan_ui_literals(expanded: bool) -> std::collections::BTreeSet { let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; let mut literals = std::collections::BTreeSet::new(); while let Some(dir) = stack.pop() { @@ -1030,18 +950,13 @@ mod tests { } } else if path.extension().is_some_and(|ext| ext == "rs") { let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let source = if expanded { source.replace("crate::i18n::t(", "tl!(") } else { source }; + let source = source.replace("crate::i18n::t(", "tl!("); let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once(if expanded { "tl!(" } else { "tl!(\"" }) { - let after = if expanded { - let after = after.trim_start(); - let Some(after) = after.strip_prefix('"') else { - rest = after; - continue; - }; - after - } else { - after + while let Some((_, after)) = rest.split_once("tl!(") { + let after = after.trim_start(); + let Some(after) = after.strip_prefix('"') else { + rest = after; + continue; }; let mut escaped = false; let end = after @@ -1058,7 +973,7 @@ mod tests { let argument_end = tail.strip_prefix('"').expect("closing quote").trim_start(); let literal_call = argument_end.starts_with(')') || argument_end.strip_prefix(',').is_some_and(|rest| rest.trim_start().starts_with(')')); - if tail.starts_with("\")") || (expanded && literal_call) { + if literal_call { let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); literals.insert(label); } @@ -1070,50 +985,14 @@ mod tests { literals } - /// Partial catalogs also translate every label of the absolute local GoToR consent prompt. + /// Each translated label of a visible signature that runs into its value keeps a separator + /// at its end. #[test] - fn local_gotor_confirmation_is_translated_in_all_supported_languages() { - for language in LANGUAGES.iter().filter(|language| !language.source.is_empty()) { - let lang = Lang::from_code(language.code).expect("registered language"); - for label in [ - "Open this local PDF?", - "This document is trying to open a local file:", - "Only allow this if you trust the source document.", - "Allow", - "Cancel", - ] { - assert!(has(lang, label), "{} lacks GoToR confirmation label: {label}", language.code); - assert_ne!(tr(lang, label), label, "{} falls back to English for {label}", language.code); - } - } - } - - /// Both page-gap labels must be translated, even in catalogs without full UI coverage. - #[test] - fn page_gap_labels_are_translated_in_every_catalog() { - for language in LANGUAGES.iter().filter(|language| !language.source.is_empty()) { - let lang = Lang::from_code(language.code).expect("registered language"); - for label in ["Show gaps between pages", "Hide gaps to join page edges. Margins inside the PDF remain unchanged."] { - assert!(has(lang, label), "{} lacks {label:?}", language.code); - let translated = tr(lang, label); - assert!(!translated.trim().is_empty(), "{} has an empty translation for {label:?}", language.code); - assert_ne!(translated, label, "{} falls back to English for {label:?}", language.code); - } - } - } - - /// Every catalog says the labels of a visible signature, which are looked up by the sign - /// crate's own English words (so the `tl!` scan can't see them), and the note shown when they - /// fall back to English. Each label that runs into its value keeps a separator at its end. - #[test] - fn signature_appearance_labels_are_in_every_catalog() { + fn signature_appearance_labels_keep_their_separator() { let english = pdfcraft_engine::sign::AppearanceLabels::default(); - let note = "The labels are in English: the signature's font can't draw them in this language."; for language in LANGUAGES.iter().filter(|language| !language.source.is_empty()) { let lang = Lang::from_code(language.code).expect("registered language"); - assert!(has(lang, note), "{} lacks the labels note", language.code); for (field, label) in english.fields() { - assert!(has(lang, label), "{} lacks the signature label {field}: {label:?}", language.code); let said = tr(lang, label); if field != "signed_by" && !lang.rtl() { assert!(said.ends_with(' ') || said.ends_with(':'), "{}: the {field} label runs into its value: {said:?}", language.code); @@ -1122,67 +1001,6 @@ mod tests { } } - /// Polish translates every `tl!("…")` literal in the UI source, as Japanese does. - #[test] - fn polish_covers_ui_literals() { - let pl = Lang::from_code("pl").expect("pl registered"); - let missing: Vec<_> = ui_literals().into_iter().filter(|label| !has(pl, label)).collect(); - assert!(missing.is_empty(), "untranslated Polish UI literals: {missing:#?}"); - } - - /// Polish says everything a signature's validation says, and the changes after signing it - /// names, keeping each sentence's holes: a Polish signature panel listed these in English. - #[test] - fn polish_says_what_a_signature_says() { - let pl = Lang::from_code("pl").expect("pl registered"); - for wording in pdfcraft_engine::sign::pdf::WORDINGS.iter().chain(pdfcraft_engine::sign::pdf::kind::ALL) { - assert!(has(pl, wording), "missing signature wording: {wording}"); - let said = tr(pl, wording); - assert_eq!(wording.matches("{}").count(), said.matches("{}").count(), "the Polish of {wording:?} loses a hole: {said:?}"); - } - } - - /// Japanese translates every `tl!("…")` literal in the UI source, so a new string can't ship in - /// English by accident (the same check French, German, Russian and Simplified Chinese have). - #[test] - fn japanese_covers_ui_literals() { - let literals = ui_literals(); - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(JA(), label)).collect(); - assert!(missing.is_empty(), "untranslated Japanese UI literals: {missing:#?}"); - } - - /// Italian translates every registered command and every All tools group, section and item. - #[test] - fn italian_covers_commands_and_catalogue() { - let it = Lang::from_code("it").expect("it registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(it, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(it, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(it, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(it, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(it, item.label), "missing item: {}", item.label); - } - } - } - } - - /// New tl!("literal") labels must not silently fall back to English in Italian. - #[test] - fn italian_covers_ui_literals() { - let it = Lang::from_code("it").expect("it registered"); - let literals = ui_literals(); - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(it, label)).collect(); - assert!(missing.is_empty(), "untranslated Italian UI literals: {missing:#?}"); - } - /// With craft-fonts, every Japanese translation has glyphs: with all interface faces (desktop) /// and with BIZ UDPGothic Regular alone (the web build's only Japanese face). #[test] @@ -1233,8 +1051,8 @@ mod tests { assert_eq!(trn(Lang::EN, 1, "{n} item", "{n} items"), "1 item"); assert_eq!(trn(Lang::EN, 0, "{n} item", "{n} items"), "0 items"); assert_eq!(trn(Lang::EN, 7, "{n} item", "{n} items"), "7 items"); - // No `@plural` entry yet: Japanese falls back to the English forms. - assert_eq!(trn(JA(), 7, "{n} item", "{n} items"), "7 items"); + // No catalog has this `@plural` entry: Japanese falls back to the English forms. + assert_eq!(trn(JA(), 7, "{n} test item", "{n} test items"), "7 test items"); assert_eq!(fmt("{b} before {a}", &[("a", "x"), ("b", "y"), ("c", "z")]), "y before x"); assert_eq!(fmt("{missing}", &[]), "{missing}"); assert_eq!(placeholders("a {x} b {y} {"), ["x", "y"]); @@ -1270,8 +1088,6 @@ mod tests { assert_eq!(normalize_pref("pt"), None, "only exact codes are preferences"); assert_eq!(lang_from_tag("pt_BR.UTF-8"), Some(pt)); assert_eq!(lang_from_tag("pt_PT"), Some(pt)); - assert_eq!(tr(pt, "File"), "Arquivo"); - assert_eq!(tr(pt, "Save as…"), "Salvar como…"); assert_eq!(tr(pt, "Arquivo do usuário.pdf"), "Arquivo do usuário.pdf"); assert_eq!((0..=3).map(|n| (pt.0.plural)(n)).collect::>(), [0, 0, 1, 1]); let mut app = crate::PdfCraftApp::default(); @@ -1282,100 +1098,6 @@ mod tests { assert_eq!(restored.language, "pt-br"); } - /// Brazilian Portuguese translates every registered command and every All tools group, section and item. - #[test] - fn brazilian_portuguese_covers_commands_and_catalogue() { - let pt = Lang::from_code("pt-br").expect("pt-br registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(pt, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(pt, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(pt, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(pt, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(pt, item.label), "missing item: {}", item.label); - } - } - } - } - - #[test] - fn brazilian_portuguese_covers_catalog_union() { - let pt = Lang::from_code("pt-br").expect("pt-br registered"); - let pt_catalog = pt.catalog(); - for language in LANGUAGES.iter().filter(|language| !language.source.is_empty()) { - let (entries, errors) = catalog::parse_entries(language.source, language.plural_forms()); - assert!(errors.is_empty(), "{} catalog: {errors:?}", language.code); - for entry in entries { - let covered = match entry.context.as_str() { - "" => pt_catalog.plain(&entry.source).is_some(), - "@id" => pt_catalog.id(&entry.source).is_some(), - "@plural" => { - let (one, other) = entry.source.split_once('|').expect("validated plural source"); - pt_catalog.plural(one, other, 0).is_some() - } - context => pt_catalog.contextual(context, &entry.source).is_some(), - }; - assert!(covered, "Brazilian Portuguese lacks {} / {:?}", entry.context, entry.source); - } - } - } - - /// Check direct lookups and multiline literals as well as ordinary tl!("literal") calls, - /// following the complete-catalog check for Simplified Chinese. - #[test] - fn brazilian_portuguese_covers_ui_literals() { - let pt = Lang::from_code("pt-br").expect("pt-br registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n").replace("crate::i18n::t(", "tl!("); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(") { - let after = after.trim_start(); - let Some(after) = after.strip_prefix('"') else { - rest = after; - continue; - }; - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - let closing = tail.strip_prefix('"').expect("closing quote").trim_start(); - let closing = closing.strip_prefix(',').unwrap_or(closing).trim_start(); - if closing.starts_with(')') { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(pt, label)).collect(); - assert!(missing.is_empty(), "untranslated Brazilian Portuguese UI literals: {missing:#?}"); - } - #[test] fn spanish_is_registered() { let es = Lang::from_code("es").expect("es registered"); @@ -1384,13 +1106,7 @@ mod tests { assert_eq!(lang_from_tag("es_ES.UTF-8"), Some(es)); assert_eq!(lang_from_tag("es-MX"), Some(es)); assert_eq!(first_supported("es-ES\r\nen-US"), Some(es)); - assert_eq!(tr(es, "File"), "Archivo"); - assert_eq!(tr(es, "Save as…"), "Guardar como…"); assert_eq!(tr(es, "Informe del usuario.pdf"), "Informe del usuario.pdf"); - assert_eq!(trn(es, 1, "{n} page", "{n} pages"), "1 página"); - assert_eq!(trn(es, 3, "{n} page", "{n} pages"), "3 páginas"); - // The signature pad's Type is the verb (type your name), not the noun "Tipo". - assert_eq!(tr_ctx(es, "signature pad", "Type"), "Escribir"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "es").unwrap(); assert_eq!(app.language, "es"); @@ -1399,70 +1115,9 @@ mod tests { assert_eq!(restored.language, "es"); } - /// Spanish translates every registered command and every All tools group, section and item. - #[test] - fn spanish_covers_commands_and_catalogue() { - let es = Lang::from_code("es").expect("es registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(es, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(es, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(es, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(es, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(es, item.label), "missing item: {}", item.label); - } - } - } - } - - /// New tl!("literal") labels must not silently fall back to English in Spanish. - #[test] - fn spanish_covers_ui_literals() { - let es = Lang::from_code("es").expect("es registered"); - let literals = ui_literals(); - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(es, label)).collect(); - assert!(missing.is_empty(), "untranslated Spanish UI literals: {missing:#?}"); - } - - /// Spanish says everything the layers below word for themselves: what the accessibility - /// checker finds and what its saved report calls things, what the PDF/A verifier reports, what - /// a signature's details say and the changes they name, what can be wrong with the pages to - /// print or with a table of text fields, and what an edit is called in the history. - /// - /// These lists are the whole point of the engine handing the wording over apart from the - /// document's own words: a catalog that covers them says those sentences in its own language. - /// A sentence missing here is a sentence in English on a Spanish screen. - #[test] - fn spanish_covers_what_the_engine_says() { - let es = Lang::from_code("es").expect("es registered"); - let lists: [(&str, &[&str]); 9] = [ - ("accessibility finding", pdfcraft_engine::a11y::WORDINGS), - ("accessibility report", pdfcraft_engine::a11y::REPORT_WORDS), - ("PDF/A problem", pdfcraft_engine::pdfa::WORDINGS), - ("signature detail", pdfcraft_engine::sign::pdf::WORDINGS), - ("change after signing", pdfcraft_engine::sign::pdf::kind::ALL), - ("pages to print", pdfcraft_engine::print::WORDINGS), - ("edit label", pdfcraft_engine::EDIT_LABELS), - ("batch label", pdfcraft_engine::BATCH_LABELS), - ("text field table mistake", pdfcraft_engine::field_table::WORDINGS), - ]; - for (what, list) in lists { - assert!(!list.is_empty(), "no {what} wordings to cover"); - for wording in list { - assert!(has(es, wording), "missing {what}: {wording}"); - } - } - } - - /// Each of those sentences keeps its holes when it is said in Spanish, so the document's own - /// words still have somewhere to go: a translation that drops `{p}` or `{}` would read as a - /// sentence about nothing in particular. + /// Each sentence the engine words for itself keeps its holes when it is said in Spanish, so + /// the document's own words still have somewhere to go: a translation that drops `{p}` or + /// `{}` would read as a sentence about nothing in particular. #[test] fn spanish_keeps_the_holes_in_what_the_engine_says() { let es = Lang::from_code("es").expect("es registered"); @@ -1490,11 +1145,7 @@ mod tests { assert_eq!(normalize_pref("TE"), Some("te")); assert_eq!(lang_from_tag("te_IN.UTF-8"), Some(te)); assert_eq!(first_supported("te-IN\r\nen-US"), Some(te)); - assert_eq!(tr(te, "File"), "ఫైల్"); - assert_eq!(tr(te, "Save as…"), "వేరే పేరుతో సేవ్ చేయి…"); assert_eq!(tr(te, "నివేదిక.pdf"), "నివేదిక.pdf"); - assert_eq!(trn(te, 1, "{n} page", "{n} pages"), "1 పేజీ"); - assert_eq!(trn(te, 3, "{n} page", "{n} pages"), "3 పేజీలు"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "te").unwrap(); assert_eq!(app.language, "te"); @@ -1531,24 +1182,6 @@ mod tests { assert_eq!(tr_ctx(es, "no such context", "Type"), tr(es, "Type")); } - /// Telugu translates every registered command and every All tools group, section and item. - #[test] - fn telugu_covers_commands_and_catalogue() { - let te = Lang::from_code("te").expect("te registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(te, command.label), "missing command: {}", command.label); - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(te, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(te, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(te, item.label), "missing item: {}", item.label); - } - } - } - } - /// With a craft-fonts Telugu face, every Telugu translation has glyphs: with all interface /// faces (desktop) and with the Telugu face as the only craft-fonts face (the web build keeps /// it; Telugu labels must not need a Japanese or Arabic face). @@ -1575,56 +1208,12 @@ mod tests { } } - /// Czech covers every registered menu title and menu command label (#68), keeps ellipses, and - /// only leaves untranslated what is deliberately the same in Czech. #[test] - fn czech_covers_every_menu_label() { + fn czech_is_registered_and_persists() { let cs = Lang::from_code("cs").expect("cs registered"); assert_eq!(cs.name(), "Čeština"); - assert_eq!(tr(cs, "File"), "Soubor"); assert_eq!(tr(cs, "Žluťoučký kůň.pdf"), "Žluťoučký kůň.pdf"); assert_eq!((0..=6).map(|n| (cs.0.plural)(n)).collect::>(), [2, 0, 1, 1, 1, 2, 2]); - for spec in pdfcraft_engine::commands::COMMANDS { - let Some(menu) = spec.menu else { continue }; - assert!(has(cs, menu), "Czech lacks the menu title {menu:?}"); - assert!(has(cs, spec.label), "Czech lacks the {menu} menu label {:?} ({})", spec.label, spec.id); - } - // Spelled the same in Czech: product and format names, abbreviations, key caps, words - // Czech shares with English, and templates with no English words. - const KEEP_AS_IS: &[&str] = &[ - "OK", - "PDF/A…", - "Microsoft Word (.docx)", - "PostScript / EPS", - "Home / End", - "JavaScript", - "ZIP", - "Text", - "Index", - "Metadata", - "metadata", - "Model", - "{field}: {e}", - "{0}: {1}", - "{month} {y}", - "+LOC", - "−LOC", - "ΔLOC", - "+Bin", - "−Bin", - // The distinguished name of a certificate, abbreviated as in X.500. - "DN: ", - ]; - let (entries, errors) = parse_entries(cs.0.source, cs.0.plural_forms()); - assert!(errors.is_empty(), "{errors:?}"); - // The code joins some labels with a space at either end: keep exactly the source's. - let edges = |s: &str| (s.len() - s.trim_start().len(), s.len() - s.trim_end().len()); - for e in &entries { - let key_cap = e.context == "key" && KEY_CAP_NAMES.contains(&e.source.as_str()); - assert!(e.source != e.translation || KEEP_AS_IS.contains(&e.source.as_str()) || key_cap, "{:?} is untranslated", e.source); - assert!(!e.translation.contains("..."), "use … rather than three dots: {:?}", e.translation); - assert_eq!(edges(&e.translation), edges(&e.source), "stray whitespace: {:?}", e.translation); - } let mut app = crate::PdfCraftApp::default(); app.set_option("language", "cs").unwrap(); assert!(app.set_option("language", "cz").is_err()); @@ -1634,178 +1223,12 @@ mod tests { assert_eq!(restored.language, "cs"); } - /// Czech translates every registered command, menu and All tools label. #[test] - fn czech_covers_commands_and_catalogue() { - let cs = Lang::from_code("cs").expect("cs registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(cs, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(cs, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(cs, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(cs, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(cs, item.label), "missing item: {}", item.label); - } - } - } - } - - /// Czech translates every `tl!("…")` literal in the UI source. - #[test] - fn czech_covers_ui_literals() { - let cs = Lang::from_code("cs").expect("cs registered"); - let literals = ui_literals(); - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(cs, label)).collect(); - assert!(missing.is_empty(), "untranslated Czech UI literals: {missing:#?}"); - } - - /// Czech counts take three forms (1; 2–4; 0 and 5+), dates use the genitive month after the - /// day, and history labels keep captured names. - #[test] - fn czech_plurals_dates_and_history_labels() { - let cs = Lang::from_code("cs").expect("cs registered"); - assert_eq!(trn(cs, 1, "{n} page", "{n} pages"), "1 stránka"); - assert_eq!(trn(cs, 3, "{n} page", "{n} pages"), "3 stránky"); - assert_eq!(trn(cs, 5, "{n} page", "{n} pages"), "5 stránek"); - assert_eq!(trn(cs, 0, "{n} field", "{n} fields"), "0 polí"); - assert_eq!(trn(cs, 1, "{n} byte", "{n} bytes"), "1 bajt"); - assert_eq!(trn(cs, 512, "{n} byte", "{n} bytes"), "512 bajtů"); - assert_eq!(trn(cs, 3, "{n} image optimized", "{n} images optimized"), "3 obrázky optimalizovány"); - assert_eq!(tr(cs, "October"), "Říjen"); - let month = tr_ctx(cs, "calendar date", "October"); - assert_eq!(fmt(tr(cs, "{month} {day}, {y}"), &[("month", month), ("day", "9"), ("y", "2026")]), "9. října 2026"); - set_current(cs); - assert_eq!(command_label("Undo Insert pages from Zpráva {n}.pdf"), "Zpět: Vložit stránky ze souboru Zpráva {n}.pdf"); - assert_eq!(command_label("Redo Fill in Kontakt {key}"), "Znovu: Vyplnit Kontakt {key}"); - assert_eq!(action_label("Change Title"), "Změnit: Název"); - assert_eq!(action_label("Delete 3 fields"), "Odstranit 3 pole"); - assert_eq!(action_label("Delete 12 fields"), "Odstranit 12 polí"); - assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "Vybráno stránek: 3"); - set_current(Lang::EN); - } - - /// Czech says everything the layers below word for themselves (see - /// `spanish_covers_what_the_engine_says`), and keeps each sentence's holes. - #[test] - fn czech_covers_what_the_engine_says() { - let cs = Lang::from_code("cs").expect("cs registered"); - let lists: [(&str, &[&str]); 8] = [ - ("accessibility finding", pdfcraft_engine::a11y::WORDINGS), - ("accessibility report", pdfcraft_engine::a11y::REPORT_WORDS), - ("PDF/A problem", pdfcraft_engine::pdfa::WORDINGS), - ("signature detail", pdfcraft_engine::sign::pdf::WORDINGS), - ("change after signing", pdfcraft_engine::sign::pdf::kind::ALL), - ("pages to print", pdfcraft_engine::print::WORDINGS), - ("edit label", pdfcraft_engine::EDIT_LABELS), - ("batch label", pdfcraft_engine::BATCH_LABELS), - ]; - for (what, list) in lists { - assert!(!list.is_empty(), "no {what} wordings to cover"); - for wording in list { - assert!(has(cs, wording), "missing {what}: {wording}"); - let said = tr(cs, wording); - assert_eq!(wording.matches("{}").count(), said.matches("{}").count(), "the Czech of {wording:?} loses a hole: {said:?}"); - for hole in wording.split('{').skip(1).filter_map(|rest| rest.split_once('}')).map(|(name, _)| name).filter(|n| !n.is_empty()) { - assert!(said.contains(&format!("{{{hole}}}")), "the Czech of {wording:?} loses {{{hole}}}: {said:?}"); - } - } - } - assert_eq!(tr(cs, "Signature is valid"), "Podpis je platný"); - } - - /// The date picker's weekday heads and the certificate viewer's fields are looked up in their - /// own contexts, which the `tl!` scan doesn't see: none of them may fall back to English. - #[test] - fn czech_covers_weekdays_and_certificate_fields() { - let cs = Lang::from_code("cs").expect("cs registered"); - assert_eq!( - ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(|day| tr_ctx(cs, "calendar weekday", day)), - ["Ne", "Po", "Út", "St", "Čt", "Pá", "So"] - ); - assert_eq!(tr_ctx(cs, "certificate", "Subject"), "Subjekt"); - for field in [ - "Issued to", - "Issued by", - "Valid from", - "Valid to", - "Intended usage", - "Serial number", - "Issuer", - "Validity starts", - "Validity ends", - "Public key", - "Basic constraints", - "Key usage", - "Self-signed", - "SHA-1 digest", - "SHA-256 digest", - "Version", - ] { - assert_ne!(tr_ctx(cs, "certificate", field), field, "certificate field remains untranslated: {field}"); - } - assert_eq!(trn(cs, 3, "{n} file", "{n} files"), "3 soubory"); - assert_eq!(fmt(tr(cs, "{n} tags"), &[("n", "1")]), "Tagy: 1"); - } - - /// Turkish covers every registered menu title and menu command label, keeps ellipses, and - /// persists in preferences. - #[test] - fn turkish_covers_every_menu_label() { + fn turkish_is_registered_and_persists() { let tr_lang = Lang::from_code("tr").expect("tr registered"); assert_eq!(tr_lang.name(), "Türkçe"); - assert_eq!(tr(tr_lang, "File"), "Dosya"); assert_eq!(tr(tr_lang, "Türkçe Belge.pdf"), "Türkçe Belge.pdf"); assert_eq!((0..=6).map(|n| (tr_lang.0.plural)(n)).collect::>(), [0; 7]); - for spec in pdfcraft_engine::commands::COMMANDS { - let Some(menu) = spec.menu else { continue }; - assert!(has(tr_lang, menu), "Turkish lacks the menu title {menu:?}"); - assert!(has(tr_lang, spec.label), "Turkish lacks the {menu} menu label {:?} ({})", spec.label, spec.id); - } - const KEEP_AS_IS: &[&str] = &[ - "OK", - "PDF/A…", - "Microsoft Word (.docx)", - "PostScript / EPS", - "Preflight", - "JavaScript", - "ZIP", - "{n} {kind}", - "{rule} - {status}", - "Poster", - "{field}: {e}", - "`{command}` {when}", - "{0}: {1}", - "{month} {y}", - "+LOC", - "−LOC", - "ΔLOC", - "+Bin", - "−Bin", - "Model", - "Home / End", - // The distinguished name of a certificate, abbreviated as in X.500. - "DN: ", - ]; - let (entries, errors) = parse_entries(tr_lang.0.source, tr_lang.0.plural_forms()); - assert!(errors.is_empty(), "Turkish catalog errors: {errors:?}"); - for e in &entries { - assert!( - e.source != e.translation - || KEEP_AS_IS.contains(&e.source.as_str()) - || (e.context == "key" && KEY_CAP_NAMES.contains(&e.source.as_str())), - "{:?} is untranslated", - e.source - ); - assert!(!e.translation.contains("..."), "use … rather than three dots: {:?}", e.translation); - assert_eq!(e.source.starts_with(' '), e.translation.starts_with(' '), "leading space mismatch: {:?}", e.translation); - assert_eq!(e.source.ends_with(' '), e.translation.ends_with(' '), "trailing space mismatch: {:?}", e.translation); - } let mut app = crate::PdfCraftApp::default(); app.set_option("language", "tr").unwrap(); assert!(app.set_option("language", "tu").is_err()); @@ -1815,35 +1238,20 @@ mod tests { assert_eq!(restored.language, "tr"); } - /// Turkish translates every registered command, menu and All tools label. + /// The code joins some labels with a space at either end, so these catalogs keep exactly the + /// source's, and write … rather than three dots. #[test] - fn turkish_covers_commands_and_catalogue() { - let tr_lang = Lang::from_code("tr").expect("tr registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(tr_lang, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(tr_lang, menu), "missing menu: {menu}"); + fn catalog_rows_keep_the_source_spacing_and_ellipsis() { + let edges = |s: &str| (s.len() - s.trim_start().len(), s.len() - s.trim_end().len()); + for code in ["cs", "tr", "vi"] { + let lang = Lang::from_code(code).expect("registered"); + let (entries, errors) = parse_entries(lang.0.source, lang.0.plural_forms()); + assert!(errors.is_empty(), "{code}: {errors:?}"); + for e in &entries { + assert!(!e.translation.contains("..."), "{code}: use … rather than three dots: {:?}", e.translation); + assert_eq!(edges(&e.translation), edges(&e.source), "{code}: stray whitespace: {:?}", e.translation); } } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(tr_lang, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(tr_lang, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(tr_lang, item.label), "missing item: {}", item.label); - } - } - } - } - - /// Turkish translates every `tl!("…")` literal in the UI source. - #[test] - fn turkish_covers_ui_literals() { - let literals = ui_literals(); - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let tr_lang = Lang::from_code("tr").expect("tr registered"); - let missing: Vec<_> = literals.iter().filter(|label| !has(tr_lang, label)).collect(); - assert!(missing.is_empty(), "untranslated Turkish UI literals: {missing:#?}"); } #[test] @@ -1852,138 +1260,7 @@ mod tests { assert_eq!(zh.name(), "简体中文"); assert_eq!(normalize_pref("zh-Hans"), Some("zh-hans")); assert_eq!(normalize_pref("zh"), None, "only exact codes are preferences"); - assert_eq!(tr(zh, "File"), "文件"); assert_eq!(tr(zh, "报告.pdf"), "报告.pdf"); - for menu in ["File", "Edit", "Pages", "View", "Help", "Preferences…", "Interface language"] { - assert!(has(zh, menu), "missing {menu}"); - } - } - - /// Like PhotoCraft's complete-catalog gates, derive coverage from the UI, not another language. - #[test] - fn simplified_chinese_covers_commands_and_catalogue() { - let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(zh, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(zh, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(zh, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(zh, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(zh, item.label), "missing item: {}", item.label); - } - } - } - } - - #[test] - fn simplified_chinese_covers_about_credit_labels() { - let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); - for label in ["About", "Contributors", "Models"].into_iter().chain(crate::credits::MODEL_COLUMNS) { - assert!(has(zh, label), "missing About label: {label}"); - } - for mode in crate::credits::NameMode::ALL { - assert!(has(zh, mode.label()), "missing name mode: {}", mode.label()); - } - for key in crate::credits::SortKey::ALL { - let (label, header) = key.label(); - for text in [label, header] { - assert!(has(zh, text), "missing contributor sort/header: {text}"); - } - } - } - - /// Direct tl! and i18n::t labels must not silently fall back to English in the complete catalog. - #[test] - fn simplified_chinese_covers_ui_literals() { - let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - // Catalog implementation and its test-only lookup examples are not UI labels. - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n").replace("crate::i18n::t(", "tl!("); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(") { - let after = after.trim_start(); - let Some(after) = after.strip_prefix('"') else { - rest = after; - continue; - }; - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - let closing = tail.strip_prefix('"').expect("closing quote").trim_start(); - let closing = closing.strip_prefix(',').unwrap_or(closing).trim_start(); - if closing.starts_with(')') { - // Current UI literals use the shared Rust/JSON string escapes. - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(zh, label)).collect(); - assert!(missing.is_empty(), "untranslated Simplified Chinese UI literals: {missing:#?}"); - } - - #[test] - fn simplified_chinese_history_and_diagnostics_preserve_user_values() { - let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); - set_current(zh); - assert_eq!(command_label("Undo Insert pages from 报告 {n}.pdf"), "撤销 从 报告 {n}.pdf 插入页面"); - assert_eq!(command_label("Redo Fill in 联系人 {key}"), "重做 填写 联系人 {key}"); - assert_eq!(action_label("Change Title"), "更改标题"); - assert_eq!(action_label("Custom action {n}"), "Custom action {n}"); - assert_eq!(fmt(t("This page couldn't be displayed.\n{e}"), &[("e", "OS error {n}")]), "无法显示此页面。\nOS error {n}"); - assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "已选择 3 页"); - assert_eq!(tr(zh, "CheckBox"), "复选框"); - assert_eq!(tr(zh, "pages"), "页"); - assert_eq!(trn(zh, 1, "{n} page", "{n} pages"), "1 页"); - assert_eq!(trn(zh, 0, "{n} field", "{n} fields"), "0 个字段"); - assert_eq!(trn(zh, 2, "{n} field", "{n} fields"), "2 个字段"); - assert_eq!(fmt(t("{n} contributors · {c} commits"), &[("n", "2"), ("c", "1,234")]), "2 位贡献者 · 1,234 次提交"); - let contributor = crate::credits::Contributor { - login: "reader{n}", - display_name: Some("Save"), - real_name: None, - prs: 2, - commits: 3, - lines_added: 10, - lines_deleted: 4, - binary_added: 1, - binary_deleted: 0, - first_commit: "2026-10-01T00:00:00Z", - last_commit: "2026-10-09T00:00:00Z", - }; - assert_eq!( - contributor.summary(), - "@reader{n}:2 个 PR,3 次提交,新增 10 行 / 删除 4 行(净增 +6 行),新增 1 个 / 删除 0 个二进制资源,2026-10-01 – 2026-10-09" - ); - assert_eq!(contributor.name(crate::credits::NameMode::DisplayName), "Save"); - set_current(Lang::EN); } #[test] @@ -1991,37 +1268,7 @@ mod tests { let zh = Lang::from_code("zh-hant").expect("zh-hant registered"); assert_eq!(zh.name(), "繁體中文"); assert_eq!(normalize_pref("zh-Hant"), Some("zh-hant")); - assert_eq!(tr(zh, "File"), "檔案"); assert_eq!(tr(zh, "報告.pdf"), "報告.pdf"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(zh, command.label), "missing command: {}", command.label); - } - } - - /// Report statuses and rule descriptions must retain their meaning in Taiwan Chinese. - #[test] - fn traditional_chinese_accessibility_meaning() { - use pdfcraft_engine::a11y::{Rule, Status}; - let zh = Lang::from_code("zh-hant").expect("zh-hant registered"); - assert_eq!(tr(zh, Status::Passed.label()), "通過"); - assert_eq!(tr(zh, Status::Failed.label()), "未通過"); - assert_eq!(tr(zh, Rule::Scripts.description()), "沒有妨礙無障礙操作的指令碼"); - assert_eq!(tr(zh, Rule::TableRows.name()), "列"); - assert_eq!(tr(zh, Rule::TableRows.explanation()), "表格列應直接位於表格或其表頭、主體、表尾內。請修正表格標記。"); - assert_eq!(tr(zh, Rule::TableRegularity.description()), "表格每列的欄數必須相同,每欄的列數也必須相同"); - assert_eq!(tr(zh, Rule::TableCells.explanation()), "表格儲存格應位於列內。請修正表格標記。"); - assert_eq!(tr(zh, Rule::TableHeaders.explanation()), "表頭儲存格(TH)讓聽者知道每欄或每列的內容。請標記表頭儲存格。"); - } - - /// A choice in a form is an item, not a project; field notices preserve user-supplied names. - #[test] - fn traditional_chinese_form_vocabulary() { - let zh = Lang::from_code("zh-hant").expect("zh-hant registered"); - assert_eq!(tr(zh, "Item:"), "項目:"); - assert_eq!(tr(zh, "Item List"), "項目清單"); - assert_eq!(tr(zh, "Sort items"), "排序項目"); - assert_eq!(tr(zh, "Read Only"), "唯讀"); - assert_eq!(fmt(tr(zh, "{name} is read-only"), &[("name", "Field{n}")]), "Field{n} 為唯讀"); } #[test] @@ -2032,17 +1279,8 @@ mod tests { assert_eq!(lang_from_tag("fr_FR.UTF-8"), Some(fr)); assert_eq!(lang_from_tag("fr-CA"), Some(fr)); assert_eq!(first_supported("fr-FR\r\nen-US"), Some(fr)); - assert_eq!(tr(fr, "File"), "Fichier"); - assert_eq!(tr(fr, "Save as…"), "Enregistrer sous…"); - assert_eq!(tr(fr, "Bookmarks"), "Signets"); - assert_eq!(tr(fr, "Layers"), "Calques"); assert_eq!(tr(fr, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf"); assert_eq!((0..=3).map(|n| (fr.0.plural)(n)).collect::>(), [0, 0, 1, 1]); - assert_eq!(trn(fr, 0, "{n} page", "{n} pages"), "0 page"); - assert_eq!(trn(fr, 1, "{n} page", "{n} pages"), "1 page"); - assert_eq!(trn(fr, 2, "{n} page", "{n} pages"), "2 pages"); - assert_eq!(trn(fr, 1, "{n} field", "{n} fields"), "1 champ"); - assert_eq!(trn(fr, 2, "{n} field", "{n} fields"), "2 champs"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "fr").unwrap(); assert_eq!(app.language, "fr"); @@ -2051,84 +1289,6 @@ mod tests { assert_eq!(restored.language, "fr"); } - /// French translates every registered command and every All tools group, section and item. - #[test] - fn french_covers_commands_and_catalogue() { - let fr = Lang::from_code("fr").expect("fr registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(fr, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(fr, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(fr, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(fr, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(fr, item.label), "missing item: {}", item.label); - } - } - } - } - - /// New tl!("literal") labels must not silently fall back to English. - #[test] - fn french_covers_ui_literals() { - let fr = Lang::from_code("fr").expect("fr registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(\"") { - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - if tail.starts_with("\")") { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(fr, label)).collect(); - assert!(missing.is_empty(), "untranslated French UI literals: {missing:#?}"); - } - - #[test] - fn french_history_and_diagnostics_preserve_user_values() { - let fr = Lang::from_code("fr").expect("fr registered"); - set_current(fr); - assert_eq!(command_label("Undo Insert pages from Rapport {n}.pdf"), "Annuler Insérer des pages depuis Rapport {n}.pdf"); - assert_eq!(command_label("Redo Fill in Contact {key}"), "Rétablir Remplir Contact {key}"); - assert_eq!(action_label("Change Title"), "Modifier Titre"); - assert_eq!(action_label("Custom action {n}"), "Custom action {n}"); - assert_eq!(fmt(t("This page couldn't be displayed.\n{e}"), &[("e", "OS error {n}")]), "Impossible d'afficher cette page.\nOS error {n}"); - assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "3 pages sélectionnées"); - assert_eq!(tr(fr, "CheckBox"), "Case à cocher"); - set_current(Lang::EN); - } - #[test] fn german_is_registered() { let de = Lang::from_code("de").expect("de registered"); @@ -2138,17 +1298,8 @@ mod tests { assert_eq!(lang_from_tag("de-AT"), Some(de)); assert_eq!(lang_from_tag("de_CH"), Some(de)); assert_eq!(first_supported("de-DE\r\nen-US"), Some(de)); - assert_eq!(tr(de, "File"), "Datei"); - assert_eq!(tr(de, "Save as…"), "Speichern unter…"); - assert_eq!(tr(de, "Bookmarks"), "Lesezeichen"); - assert_eq!(tr(de, "Layers"), "Ebenen"); assert_eq!(tr(de, "Bericht des Nutzers.pdf"), "Bericht des Nutzers.pdf"); assert_eq!((0..=3).map(|n| (de.0.plural)(n)).collect::>(), [1, 0, 1, 1]); - assert_eq!(trn(de, 0, "{n} page", "{n} pages"), "0 Seiten"); - assert_eq!(trn(de, 1, "{n} page", "{n} pages"), "1 Seite"); - assert_eq!(trn(de, 2, "{n} page", "{n} pages"), "2 Seiten"); - assert_eq!(trn(de, 1, "{n} field", "{n} fields"), "1 Feld"); - assert_eq!(trn(de, 2, "{n} field", "{n} fields"), "2 Felder"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "de").unwrap(); assert_eq!(app.language, "de"); @@ -2157,70 +1308,6 @@ mod tests { assert_eq!(restored.language, "de"); } - /// German translates every registered command and every All tools group, section and item. - #[test] - fn german_covers_commands_and_catalogue() { - let de = Lang::from_code("de").expect("de registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(de, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(de, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(de, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(de, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(de, item.label), "missing item: {}", item.label); - } - } - } - } - - /// New tl!("literal") labels must not silently fall back to English. - #[test] - fn german_covers_ui_literals() { - let de = Lang::from_code("de").expect("de registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(\"") { - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - if tail.starts_with("\")") { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(de, label)).collect(); - assert!(missing.is_empty(), "untranslated German UI literals: {missing:#?}"); - } - #[test] fn tool_names_inside_sentences_are_lowercased_except_in_german() { set_current(Lang::EN); @@ -2244,32 +1331,6 @@ mod tests { assert_eq!(in_sentence("Image"), "image"); } - #[test] - fn german_certificate_subject_has_a_distinct_context() { - let de = Lang::from_code("de").expect("de registered"); - assert_eq!(tr(de, "Subject"), "Thema"); - assert_eq!(tr_ctx(de, "certificate", "Subject"), "Zertifikatsinhaber"); - assert_eq!(tr_ctx(de, "certificate", "Issuer"), tr(de, "Issuer")); - assert_eq!(tr_ctx(Lang::EN, "certificate", "Subject"), "Subject"); - } - - #[test] - fn german_history_and_diagnostics_preserve_user_values() { - let de = Lang::from_code("de").expect("de registered"); - set_current(de); - assert_eq!(command_label("Undo Insert pages from Rapport {n}.pdf"), "Seiten aus Rapport {n}.pdf einfügen rückgängig machen"); - assert_eq!(command_label("Redo Fill in Contact {key}"), "Contact {key} ausfüllen wiederholen"); - assert_eq!(action_label("Change Title"), "Titel ändern"); - assert_eq!(action_label("Custom action {n}"), "Custom action {n}"); - assert_eq!( - fmt(t("This page couldn't be displayed.\n{e}"), &[("e", "OS error {n}")]), - "Diese Seite konnte nicht angezeigt werden.\nOS error {n}" - ); - assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "3 Seiten ausgewählt"); - assert_eq!(tr(de, "CheckBox"), "Kontrollkästchen"); - set_current(Lang::EN); - } - #[test] fn russian_is_registered() { let ru = Lang::from_code("ru").expect("ru registered"); @@ -2278,23 +1339,9 @@ mod tests { assert_eq!(lang_from_tag("ru_RU.UTF-8"), Some(ru)); assert_eq!(lang_from_tag("ru-BY"), Some(ru)); assert_eq!(first_supported("ru-RU\r\nen-US"), Some(ru)); - assert_eq!(tr(ru, "File"), "Файл"); - assert_eq!(tr(ru, "Save as…"), "Сохранить как…"); - assert_eq!(tr(ru, "Bookmarks"), "Закладки"); - assert_eq!(tr(ru, "Layers"), "Слои"); assert_eq!(tr(ru, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf"); // one (1), few (2-4), many (0, 5+), with 11-14 exception assert_eq!((0..=3).map(|n| (ru.0.plural)(n)).collect::>(), [2, 0, 1, 1]); - assert_eq!(trn(ru, 0, "{n} page", "{n} pages"), "0 страниц"); - assert_eq!(trn(ru, 1, "{n} page", "{n} pages"), "1 страница"); - assert_eq!(trn(ru, 2, "{n} page", "{n} pages"), "2 страницы"); - assert_eq!(trn(ru, 5, "{n} page", "{n} pages"), "5 страниц"); - assert_eq!(trn(ru, 11, "{n} page", "{n} pages"), "11 страниц"); - assert_eq!(trn(ru, 21, "{n} page", "{n} pages"), "21 страница"); - assert_eq!(trn(ru, 22, "{n} page", "{n} pages"), "22 страницы"); - assert_eq!(trn(ru, 1, "{n} field", "{n} fields"), "1 поле"); - assert_eq!(trn(ru, 2, "{n} field", "{n} fields"), "2 поля"); - assert_eq!(trn(ru, 5, "{n} field", "{n} fields"), "5 полей"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "ru").unwrap(); assert_eq!(app.language, "ru"); @@ -2303,84 +1350,6 @@ mod tests { assert_eq!(restored.language, "ru"); } - /// Russian translates every registered command and every All tools group, section and item. - #[test] - fn russian_covers_commands_and_catalogue() { - let ru = Lang::from_code("ru").expect("ru registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(ru, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(ru, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(ru, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(ru, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(ru, item.label), "missing item: {}", item.label); - } - } - } - } - - /// New tl!("literal") labels must not silently fall back to English. - #[test] - fn russian_covers_ui_literals() { - let ru = Lang::from_code("ru").expect("ru registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(\"") { - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - if tail.starts_with("\")") { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(ru, label)).collect(); - assert!(missing.is_empty(), "untranslated Russian UI literals: {missing:#?}"); - } - - #[test] - fn russian_history_and_diagnostics_preserve_user_values() { - let ru = Lang::from_code("ru").expect("ru registered"); - set_current(ru); - assert_eq!(command_label("Undo Insert pages from Rapport {n}.pdf"), "Отменить Вставить страницы из Rapport {n}.pdf"); - assert_eq!(command_label("Redo Fill in Contact {key}"), "Повторить Заполнить Contact {key}"); - assert_eq!(action_label("Change Title"), "Изменить заголовок"); - assert_eq!(action_label("Custom action {n}"), "Custom action {n}"); - assert_eq!(fmt(t("This page couldn't be displayed.\n{e}"), &[("e", "OS error {n}")]), "Не удалось отобразить эту страницу.\nOS error {n}"); - assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "Выбрано 3 страницы"); - assert_eq!(tr(ru, "CheckBox"), "Флажок"); - set_current(Lang::EN); - } - #[test] fn finnish_is_registered_and_persists() { let fi = Lang::from_code("fi").expect("fi registered"); @@ -2392,21 +1361,6 @@ mod tests { // Filipino (`fil`) and Swedish in Finland (`sv-FI`) don't pick Finnish. assert_ne!(lang_from_tag("fil"), Some(fi)); assert_ne!(lang_from_tag("sv-FI"), Some(fi)); - assert_eq!(tr(fi, "File"), "Tiedosto"); - assert_eq!(tr(fi, "Edit"), "Muokkaa"); - assert_eq!(tr(fi, "Save"), "Tallenna"); - assert_eq!(tr(fi, "Interface language"), "Käyttöliittymän kieli"); - // The partitive singular follows any number but 1. - assert_eq!(trn(fi, 0, "{n} page", "{n} pages"), "0 sivua"); - assert_eq!(trn(fi, 1, "{n} page", "{n} pages"), "1 sivu"); - assert_eq!(trn(fi, 2, "{n} page", "{n} pages"), "2 sivua"); - assert_eq!(trn(fi, 21, "{n} page", "{n} pages"), "21 sivua"); - // Calendar headings use the nominative, dates the partitive. - assert_eq!(fmt(tr(fi, "{month} {y}"), &[("month", tr(fi, "October")), ("y", "2026")]), "Lokakuu 2026"); - assert_eq!( - fmt(tr(fi, "{month} {day}, {y}"), &[("month", tr_ctx(fi, "calendar date", "October")), ("day", "14"), ("y", "2026")]), - "14. lokakuuta 2026" - ); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "FI").unwrap(); assert_eq!(app.language, "fi"); @@ -2415,68 +1369,6 @@ mod tests { assert_eq!(restored.language, "fi"); } - #[test] - fn finnish_covers_commands_and_tools() { - let fi = Lang::from_code("fi").expect("fi registered"); - let mut missing = std::collections::BTreeSet::new(); - for command in pdfcraft_engine::commands::COMMANDS { - if !has(fi, command.label) { - missing.insert(command.label); - } - if let Some(menu) = command.menu - && !has(fi, menu) - { - missing.insert(menu); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - if !has(fi, group.label) { - missing.insert(group.label); - } - for section in group.sections { - if !has(fi, section.title) { - missing.insert(section.title); - } - for item in section.items { - if !has(fi, item.label) { - missing.insert(item.label); - } - } - } - } - assert!(missing.is_empty(), "Finnish misses commands/tools: {missing:#?}"); - } - - #[test] - fn finnish_covers_ui_literals() { - let fi = Lang::from_code("fi").expect("fi registered"); - let missing: Vec<_> = scan_ui_literals(true).into_iter().filter(|label| !has(fi, label)).collect(); - assert!(missing.is_empty(), "Untranslated Finnish UI labels: {missing:#?}"); - } - - #[test] - fn finnish_covers_bundled_catalog_keys() { - // Compare English keys, not other languages' translations. - let fi = Lang::from_code("fi").expect("fi registered"); - let mut missing = std::collections::BTreeSet::new(); - for reference in LANGUAGES.iter().filter(|language| language.code != "fi") { - let (entries, errors) = catalog::parse_entries(reference.source, reference.plural_forms()); - assert!(errors.is_empty(), "{}: invalid reference catalog: {errors:?}", reference.code); - for entry in entries { - let translated = match entry.context.as_str() { - "" => fi.catalog().plain(&entry.source), - "@id" => fi.catalog().id(&entry.source), - "@plural" => entry.source.split_once('|').and_then(|(one, other)| fi.catalog().plural(one, other, 0)), - context => fi.catalog().contextual(context, &entry.source), - }; - if translated.is_none() { - missing.insert((entry.context, entry.source)); - } - } - } - assert!(missing.is_empty(), "Finnish misses bundled catalog keys: {missing:#?}"); - } - #[test] fn lithuanian_is_registered_and_persists() { let lt = Lang::from_code("lt").expect("lt registered"); @@ -2485,18 +1377,6 @@ mod tests { for tag in ["lt", "lt-LT", "lt_LT.UTF-8", "LT-lt"] { assert_eq!(lang_from_tag(tag), Some(lt), "{tag}"); } - assert_eq!(tr(lt, "File"), "Failas"); - assert_eq!(tr(lt, "Edit"), "Redaguoti"); - assert_eq!(tr(lt, "Save"), "Įrašyti"); - assert_eq!(tr(lt, "Interface language"), "Sąsajos kalba"); - assert_eq!(trn(lt, 1, "{n} page", "{n} pages"), "1 puslapis"); - assert_eq!(trn(lt, 2, "{n} page", "{n} pages"), "2 puslapiai"); - assert_eq!(trn(lt, 10, "{n} page", "{n} pages"), "10 puslapių"); - assert_eq!(trn(lt, 11, "{n} page", "{n} pages"), "11 puslapių"); - assert_eq!(trn(lt, 21, "{n} page", "{n} pages"), "21 puslapis"); - assert_eq!(trn(lt, 22, "{n} page", "{n} pages"), "22 puslapiai"); - assert_eq!(trn(lt, 12, "{n} object selected", "{n} objects selected"), "Pažymėta 12 objektų"); - assert_eq!(trn(lt, 22, "{n} object selected", "{n} objects selected"), "Pažymėti 22 objektai"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "LT").unwrap(); assert_eq!(app.language, "lt"); @@ -2519,8 +1399,6 @@ mod tests { assert_eq!(first_supported("(\n \"nb-NO\",\n \"en-US\"\n)\n"), Some(nb)); assert_eq!(first_supported("nn-NO\r\nen-US\r\n"), Some(nb)); assert_eq!((0..=3).map(|n| (nb.0.plural)(n)).collect::>(), [1, 0, 1, 1]); - assert_eq!(tr(nb, "Allow"), "Tillat"); - assert_eq!(tr_ctx(nb, "key", "Space"), "Mellomrom"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "NB").unwrap(); assert_eq!(app.language, "nb"); @@ -2529,181 +1407,6 @@ mod tests { assert_eq!(restored.language, "nb"); } - #[test] - fn lithuanian_covers_commands_and_catalogue() { - let lt = Lang::from_code("lt").expect("lt registered"); - let mut missing = std::collections::BTreeSet::new(); - for command in pdfcraft_engine::commands::COMMANDS { - if !has(lt, command.label) { - missing.insert(command.label); - } - if let Some(menu) = command.menu - && !has(lt, menu) - { - missing.insert(menu); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - if !has(lt, group.label) { - missing.insert(group.label); - } - for section in group.sections { - if !has(lt, section.title) { - missing.insert(section.title); - } - for item in section.items { - if !has(lt, item.label) { - missing.insert(item.label); - } - } - } - } - assert!(missing.is_empty(), "Lithuanian misses commands/tools: {missing:#?}"); - } - - #[test] - fn lithuanian_covers_ui_literals() { - let lt = Lang::from_code("lt").expect("lt registered"); - let literals = scan_ui_literals(true); - assert!(literals.contains("Float panel"), "direct lookup literals are included"); - assert!(literals.contains("@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}"), "multiline literal arguments are included"); - let missing: Vec<_> = literals.iter().filter(|label| !has(lt, label)).collect(); - assert!(missing.is_empty(), "Untranslated Lithuanian UI labels: {missing:#?}"); - } - - #[test] - fn lithuanian_covers_contextual_calendar_certificate_and_preference_labels() { - let lt = Lang::from_code("lt").expect("lt registered"); - assert_eq!(tr_ctx(lt, "signature pad", "Type"), "Tekstu"); - for label in ["All tools", "Read", "E-Sign", "Tools", "View more", "View less"] { - assert!(has(lt, label), "shell label remains untranslated: {label}"); - } - assert_eq!(["Slow", "Normal", "Fast"].map(|label| tr_ctx(lt, "scroll speed", label)), ["Lėtas", "Įprastas", "Greitas"]); - let months = [ - ("January", "sausis", "sausio"), - ("February", "vasaris", "vasario"), - ("March", "kovas", "kovo"), - ("April", "balandis", "balandžio"), - ("May", "gegužė", "gegužės"), - ("June", "birželis", "birželio"), - ("July", "liepa", "liepos"), - ("August", "rugpjūtis", "rugpjūčio"), - ("September", "rugsėjis", "rugsėjo"), - ("October", "spalis", "spalio"), - ("November", "lapkritis", "lapkričio"), - ("December", "gruodis", "gruodžio"), - ]; - for (source, standalone, in_date) in months { - assert_eq!(tr(lt, source), standalone); - assert_eq!(tr_ctx(lt, "calendar date", source), in_date); - } - assert_eq!( - ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(|day| tr_ctx(lt, "calendar weekday", day)), - ["Sk", "Pr", "An", "Tr", "Kt", "Pn", "Št"] - ); - assert_eq!( - fmt(tr(lt, "{month} {day}, {y}"), &[("month", tr_ctx(lt, "calendar date", "March")), ("day", "9"), ("y", "2026")]), - "2026 m. kovo 9 d." - ); - assert_eq!(tr_ctx(lt, "certificate", "Subject"), "Sertifikato subjektas"); - for field in [ - "Issued to", - "Issued by", - "Valid from", - "Valid to", - "Intended usage", - "Serial number", - "Issuer", - "Validity starts", - "Validity ends", - "Public key", - "Basic constraints", - "Key usage", - "Self-signed", - "SHA-1 digest", - "SHA-256 digest", - ] { - assert_ne!(tr_ctx(lt, "certificate", field), field, "certificate field remains untranslated: {field}"); - } - } - - #[test] - fn lithuanian_dialog_wording_preserves_resolution_and_selection_counts() { - let lt = Lang::from_code("lt").expect("lt registered"); - assert_eq!(tr(lt, "for images above"), "kai raiška viršija"); - assert_eq!( - fmt(tr(lt, "Checking Options ({on} of 32 in all categories)"), &[("on", "31")]), - "Tikrinimo parinktys (visose kategorijose: 31 iš 32)" - ); - } - - #[test] - fn lithuanian_covers_bundled_catalog_keys_including_dynamic_dialog_labels() { - // Compare English keys, not other languages' translations. Literal scans alone - // miss labels passed through helpers, enum methods and conditional expressions. - let lt = Lang::from_code("lt").expect("lt registered"); - let mut missing = std::collections::BTreeSet::new(); - for reference in LANGUAGES.iter().filter(|language| language.code != "lt") { - let (entries, errors) = catalog::parse_entries(reference.source, reference.plural_forms()); - assert!(errors.is_empty(), "{}: invalid reference catalog: {errors:?}", reference.code); - for entry in entries { - let translated = match entry.context.as_str() { - "" => lt.catalog().plain(&entry.source), - "@id" => lt.catalog().id(&entry.source), - "@plural" => entry.source.split_once('|').and_then(|(one, other)| lt.catalog().plural(one, other, 0)), - context => lt.catalog().contextual(context, &entry.source), - }; - if translated.is_none() { - missing.insert((entry.context, entry.source)); - } - } - } - assert!(missing.is_empty(), "Lithuanian misses bundled catalog keys: {missing:#?}"); - } - - #[test] - fn lithuanian_covers_credits_labels() { - let lt = Lang::from_code("lt").expect("lt registered"); - let labels = crate::credits::NameMode::ALL - .iter() - .map(|mode| mode.label()) - .chain(crate::credits::SortKey::ALL.iter().flat_map(|key| { - let (menu, heading) = key.label(); - [menu, heading] - })) - .chain(crate::credits::MODEL_COLUMNS.iter().copied()); - let missing: Vec<_> = labels.filter(|label| !has(lt, label)).collect(); - assert!(missing.is_empty(), "Lithuanian misses credits labels: {missing:#?}"); - } - - #[test] - fn lithuanian_wording_fixes_keep_context_and_meaning() { - let lt = Lang::from_code("lt").expect("lt registered"); - assert_eq!(tr(lt, "of {name}"), "iš {name}"); - assert_eq!(tr(lt, "Active"), "Aktyvi", "the adjective describes a theme (tema)"); - for n in [1, 2, 12] { - let renamed = trn( - lt, - n, - "{n} field name collides with earlier files and will be renamed: {list}", - "{n} field names collide with earlier files and will be renamed: {list}", - ); - assert!(renamed.contains("ankstesniuose failuose esančių laukų pavadinimais")); - assert!(renamed.contains("bus pervadint")); - assert!(renamed.contains("{list}")); - } - assert_eq!(tr(lt, "Two-Up (Facing)"), "Du atgręžti puslapiai"); - assert_eq!(tr(lt, "Windows store · "), "„Windows“ sertifikatų saugykla · "); - assert_eq!( - tr(lt, "Adds invisible text over each word; the page image is not changed"), - "Virš kiekvieno žodžio prideda nematomą tekstą; puslapio vaizdas nesikeičia" - ); - assert!( - tr(lt, "The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved.") - .contains("visose PDF žiūryklėse") - ); - } - #[test] fn vietnamese_is_registered_and_persists() { let vi = Lang::from_code("vi").expect("vi registered"); @@ -2713,18 +1416,7 @@ mod tests { assert_eq!(lang_from_tag(tag), Some(vi), "{tag}"); } assert_eq!(first_supported("vi-VN\r\nen-US"), Some(vi)); - assert_eq!(tr(vi, "File"), "Tệp"); - assert_eq!(tr(vi, "Edit"), "Chỉnh sửa"); - assert_eq!(tr(vi, "Save"), "Lưu"); - assert_eq!(tr(vi, "Save as…"), "Lưu thành…"); - assert_eq!(tr(vi, "Bookmarks"), "Dấu trang"); - assert_eq!(tr(vi, "Fill & Sign"), "Điền & Ký"); - assert_eq!(tr(vi, "Interface language"), "Ngôn ngữ giao diện"); assert_eq!((0..=3).map(|n| (vi.0.plural)(n)).collect::>(), [0, 0, 0, 0]); - for n in [0, 1, 2, 5] { - assert_eq!(trn(vi, n, "{n} page", "{n} pages"), format!("{n} trang")); - assert_eq!(trn(vi, n, "{n} field", "{n} fields"), format!("{n} trường")); - } let mut app = crate::PdfCraftApp::default(); app.set_option("language", "vi").unwrap(); assert_eq!(app.language, "vi"); @@ -2735,123 +1427,6 @@ mod tests { assert_eq!(restored.language, "vi"); } - #[test] - fn vietnamese_covers_commands_and_catalogue() { - let vi = Lang::from_code("vi").expect("vi registered"); - let mut missing = std::collections::BTreeSet::new(); - for command in pdfcraft_engine::commands::COMMANDS { - if !has(vi, command.label) { - missing.insert(command.label); - } - if let Some(menu) = command.menu - && !has(vi, menu) - { - missing.insert(menu); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - if !has(vi, group.label) { - missing.insert(group.label); - } - for section in group.sections { - if !has(vi, section.title) { - missing.insert(section.title); - } - for item in section.items { - if !has(vi, item.label) { - missing.insert(item.label); - } - } - } - } - assert!(missing.is_empty(), "Vietnamese misses commands/tools: {missing:#?}"); - } - - #[test] - fn vietnamese_covers_ui_literals() { - let vi = Lang::from_code("vi").expect("vi registered"); - let literals = scan_ui_literals(true); - let missing: Vec<_> = literals.iter().filter(|label| !has(vi, label)).collect(); - assert!(missing.is_empty(), "Untranslated Vietnamese UI labels: {missing:#?}"); - } - - #[test] - fn vietnamese_covers_contextual_calendar_certificate_and_preference_labels() { - let vi = Lang::from_code("vi").expect("vi registered"); - assert_eq!(tr_ctx(vi, "comment menu", "Edit"), "Sửa"); - assert_eq!(tr_ctx(vi, "signature pad", "Type"), "Nhập chữ"); - assert_eq!(tr_ctx(vi, "action wizard", "Start"), "Bắt đầu"); - assert_eq!(["Slow", "Normal", "Fast"].map(|label| tr_ctx(vi, "scroll speed", label)), ["Chậm", "Bình thường", "Nhanh"]); - let months = [ - ("January", "Tháng 1", "tháng 1"), - ("February", "Tháng 2", "tháng 2"), - ("March", "Tháng 3", "tháng 3"), - ("April", "Tháng 4", "tháng 4"), - ("May", "Tháng 5", "tháng 5"), - ("June", "Tháng 6", "tháng 6"), - ("July", "Tháng 7", "tháng 7"), - ("August", "Tháng 8", "tháng 8"), - ("September", "Tháng 9", "tháng 9"), - ("October", "Tháng 10", "tháng 10"), - ("November", "Tháng 11", "tháng 11"), - ("December", "Tháng 12", "tháng 12"), - ]; - for (source, standalone, in_date) in months { - assert_eq!(tr(vi, source), standalone); - assert_eq!(tr_ctx(vi, "calendar date", source), in_date); - } - assert_eq!( - ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(|day| tr_ctx(vi, "calendar weekday", day)), - ["CN", "T2", "T3", "T4", "T5", "T6", "T7"] - ); - assert_eq!(tr_ctx(vi, "certificate", "Subject"), "Chủ thể chứng chỉ"); - assert_eq!(tr(vi, "Subject"), "Chủ đề"); - // Appearance: object/comment/signature look vs Preferences theme section. - assert_eq!(tr(vi, "Appearance"), "Diện mạo"); - assert_eq!(tr_ctx(vi, "theme", "Appearance"), "Giao diện"); - // PDF form Field — never the visual-box sense "ô". - assert_eq!(tr(vi, "Field"), "Trường"); - assert_eq!(tr(vi, "Fields"), "Các trường"); - assert_eq!(tr(vi, "Form fields"), "Các trường biểu mẫu"); - assert_eq!(tr(vi, "Text field"), "Trường văn bản"); - assert_eq!(tr(vi, "Date field"), "Trường ngày"); - assert_eq!(tr(vi, "Image field"), "Trường hình ảnh"); - assert_eq!(tr(vi, "Field properties"), "Thuộc tính trường"); - assert_eq!(tr(vi, "Field list"), "Danh sách trường"); - assert_eq!(tr(vi, "{kind} field"), "Trường {kind}"); - // Redaction: permanent removal, not temporary cover. - assert_eq!(tr(vi, "Mark for redaction"), "Đánh dấu để xóa"); - assert_eq!(tr(vi, "Apply redactions"), "Xóa vĩnh viễn nội dung đã đánh dấu"); - assert_eq!(tr(vi, "Redact text and images"), "Xóa nội dung nhạy cảm trong văn bản và hình ảnh"); - assert_eq!(tr(vi, "Clear form"), "Xóa dữ liệu biểu mẫu"); - // Fill & Sign tool section title — fill-in, not paint fill. - assert_eq!(tr(vi, "Fill"), "Điền"); - assert_eq!(tr(vi, "Fill & Sign"), "Điền & Ký"); - assert_eq!( - tr(vi, "Remove \"{name}\" from this list? The file itself is not deleted."), - "Gỡ \"{name}\" khỏi danh sách này? Bản thân tệp không bị xóa." - ); - for field in [ - "Issued to", - "Issued by", - "Valid from", - "Valid to", - "Intended usage", - "Serial number", - "Issuer", - "Validity starts", - "Validity ends", - "Public key", - "Basic constraints", - "Key usage", - "Self-signed", - "SHA-1 digest", - "SHA-256 digest", - ] { - assert_ne!(tr_ctx(vi, "certificate", field), field, "certificate field remains untranslated: {field}"); - } - } - #[test] fn romanian_is_registered_with_three_plural_forms() { let ro = Lang::from_code("ro").expect("ro registered"); @@ -2865,120 +1440,6 @@ mod tests { for (n, form) in [(1, 0), (0, 1), (2, 1), (19, 1), (101, 1), (119, 1), (20, 2), (100, 2), (120, 2), (1000, 2)] { assert_eq!(plural_ro(n), form, "{n}"); } - assert_eq!(trn(ro, 1, "{n} page", "{n} pages"), "1 pagină"); - assert_eq!(trn(ro, 5, "{n} page", "{n} pages"), "5 pagini"); - assert_eq!(trn(ro, 25, "{n} page", "{n} pages"), "25 de pagini"); - } - - #[test] - fn romanian_covers_bundled_catalog_keys() { - let ro = Lang::from_code("ro").expect("ro registered"); - let mut missing = std::collections::BTreeSet::new(); - for reference in LANGUAGES.iter().filter(|language| language.code != "ro") { - let (entries, errors) = catalog::parse_entries(reference.source, reference.plural_forms()); - assert!(errors.is_empty(), "{}: invalid reference catalog: {errors:?}", reference.code); - for entry in entries { - let translated = match entry.context.as_str() { - "" => ro.catalog().plain(&entry.source), - "@id" => ro.catalog().id(&entry.source), - "@plural" => entry.source.split_once('|').and_then(|(one, other)| ro.catalog().plural(one, other, 0)), - context => ro.catalog().contextual(context, &entry.source), - }; - if translated.is_none() { - missing.insert((entry.context, entry.source)); - } - } - } - assert!(missing.is_empty(), "Romanian misses bundled catalog keys: {missing:#?}"); - } - - #[test] - fn vietnamese_covers_bundled_catalog_keys() { - let vi = Lang::from_code("vi").expect("vi registered"); - let mut missing = std::collections::BTreeSet::new(); - for reference in LANGUAGES.iter().filter(|language| language.code != "vi") { - let (entries, errors) = catalog::parse_entries(reference.source, reference.plural_forms()); - assert!(errors.is_empty(), "{}: invalid reference catalog: {errors:?}", reference.code); - for entry in entries { - let translated = match entry.context.as_str() { - "" => vi.catalog().plain(&entry.source), - "@id" => vi.catalog().id(&entry.source), - "@plural" => entry.source.split_once('|').and_then(|(one, other)| vi.catalog().plural(one, other, 0)), - context => vi.catalog().contextual(context, &entry.source), - }; - if translated.is_none() { - missing.insert((entry.context, entry.source)); - } - } - } - assert!(missing.is_empty(), "Vietnamese misses bundled catalog keys: {missing:#?}"); - } - - #[test] - fn vietnamese_covers_credits_labels() { - let vi = Lang::from_code("vi").expect("vi registered"); - let labels = crate::credits::NameMode::ALL - .iter() - .map(|mode| mode.label()) - .chain(crate::credits::SortKey::ALL.iter().flat_map(|key| { - let (menu, heading) = key.label(); - [menu, heading] - })) - .chain(crate::credits::MODEL_COLUMNS.iter().copied()); - let missing: Vec<_> = labels.filter(|label| !has(vi, label)).collect(); - assert!(missing.is_empty(), "Vietnamese misses credits labels: {missing:#?}"); - } - - #[test] - fn vietnamese_leaves_no_unapproved_english_fallbacks() { - let vi = Lang::from_code("vi").expect("vi registered"); - // Authoritative allowlist for intentional English-identical Vietnamese rows. - const KEEP_AS_IS: &[&str] = &[ - "OK", - "Digital ID", - "ZIP", - "JavaScript", - "Microsoft Word (.docx)", - "PDF/A…", - "PostScript / EPS", - "Rich Text Format (.rtf)", - "CheckBox", - "Combo", - "Home / End", - "PushButton", - "Radio", - "Standard Business", - "Keychain · ", - "{0}: {1}", - "{field}: {e}", - "{month} {y}", - "{n} {kind}", - "{rule} - {status}", - "+LOC", - "−LOC", - "ΔLOC", - "+Bin", - "−Bin", - "`{command}` {when}", - "English", - ]; - const KEY_CAPS: &[&str] = &["Ctrl", "Shift", "Delete", "Esc", "Home", "End", "Space"]; - let (entries, errors) = catalog::parse_entries(vi.0.source, vi.0.plural_forms()); - assert!(errors.is_empty(), "{errors:?}"); - let edges = |s: &str| (s.len() - s.trim_start().len(), s.len() - s.trim_end().len()); - let mut unchanged = Vec::new(); - for e in &entries { - let key_cap = e.context == "key" && KEY_CAPS.contains(&e.source.as_str()); - if e.source == e.translation && !KEEP_AS_IS.contains(&e.source.as_str()) && !key_cap { - unchanged.push((e.context.clone(), e.source.clone())); - } - assert!(!e.translation.contains("..."), "use … rather than three dots: {:?}", e.translation); - assert_eq!(edges(&e.translation), edges(&e.source), "stray whitespace: {:?}", e.translation); - if e.context == "@plural" { - assert!(!e.translation.contains('|'), "Vietnamese @plural is one form: {:?}", e.translation); - } - } - assert!(unchanged.is_empty(), "unapproved English Vietnamese catalog entries: {unchanged:#?}"); } #[test] @@ -2990,15 +1451,8 @@ mod tests { assert_eq!(lang_from_tag(tag), Some(ca), "{tag}"); } assert_eq!(first_supported("ca-ES\r\nen-US"), Some(ca)); - assert_eq!(tr(ca, "File"), "Fitxer"); - assert_eq!(tr(ca, "Save as…"), "Anomena i desa…"); - assert_eq!(tr(ca, "Cancel"), "Cancel·la"); - assert_eq!(tr_ctx(ca, "comment menu", "Edit"), "Edita"); - assert_eq!(tr_ctx(ca, "signature pad", "Type"), "Escriu"); // one (1), other (0, 2+), as in English assert_eq!((0..=3).map(|n| (ca.0.plural)(n)).collect::>(), [1, 0, 1, 1]); - assert_eq!(trn(ca, 1, "{n} page", "{n} pages"), "1 pàgina"); - assert_eq!(trn(ca, 3, "{n} page", "{n} pages"), "3 pàgines"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "CA").unwrap(); assert_eq!(app.language, "ca"); @@ -3009,61 +1463,6 @@ mod tests { assert_eq!(restored.language, "ca"); } - /// Catalan translates every registered command, menu, All tools group, section and item. - #[test] - fn catalan_covers_commands_and_catalogue() { - let ca = Lang::from_code("ca").expect("ca registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(ca, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(ca, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(ca, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(ca, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(ca, item.label), "missing item: {}", item.label); - } - } - } - } - - /// Catalan translates every `tl!("…")` literal in the UI source, so a new string can't ship in - /// English by accident. - #[test] - fn catalan_covers_ui_literals() { - let ca = Lang::from_code("ca").expect("ca registered"); - let literals = ui_literals(); - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(ca, label)).collect(); - assert!(missing.is_empty(), "untranslated Catalan UI literals: {missing:#?}"); - } - - /// Catalan has every key the other bundled catalogs have (English keys, not their translations). - #[test] - fn catalan_covers_bundled_catalog_keys() { - let ca = Lang::from_code("ca").expect("ca registered"); - let mut missing = std::collections::BTreeSet::new(); - for reference in LANGUAGES.iter().filter(|language| language.code != "ca") { - let (entries, errors) = catalog::parse_entries(reference.source, reference.plural_forms()); - assert!(errors.is_empty(), "{}: invalid reference catalog: {errors:?}", reference.code); - for entry in entries { - let translated = match entry.context.as_str() { - "" => ca.catalog().plain(&entry.source), - "@id" => ca.catalog().id(&entry.source), - "@plural" => entry.source.split_once('|').and_then(|(one, other)| ca.catalog().plural(one, other, 0)), - context => ca.catalog().contextual(context, &entry.source), - }; - if translated.is_none() { - missing.insert((entry.context, entry.source)); - } - } - } - assert!(missing.is_empty(), "Catalan misses bundled catalog keys: {missing:#?}"); - } - #[test] fn slovene_is_registered_and_persists() { let sl = Lang::from_code("sl").expect("sl registered"); @@ -3073,14 +1472,7 @@ mod tests { assert_eq!(lang_from_tag(tag), Some(sl), "{tag}"); } assert_eq!(first_supported("sl-SI\r\nen-US"), Some(sl)); - assert_eq!(tr(sl, "File"), "Datoteka"); - assert_eq!(tr(sl, "Save as…"), "Shrani kot…"); - assert_eq!(tr(sl, "Bookmarks"), "Zaznamki"); - assert_eq!(tr(sl, "Layers"), "Plasti"); assert_eq!(tr(sl, "Poročilo {n}.pdf"), "Poročilo {n}.pdf"); - assert_eq!(tr_ctx(sl, "comment menu", "Edit"), "Uredi"); - assert_eq!(tr_ctx(sl, "signature pad", "Type"), "Vtipkaj"); - assert_eq!(tr_ctx(sl, "action wizard", "Start"), "Začni"); // one (1), two (2), few (3, 4), other (0, 5+) assert_eq!((0..=5).map(|n| (sl.0.plural)(n)).collect::>(), [3, 0, 1, 2, 2, 3]); let mut app = crate::PdfCraftApp::default(); @@ -3102,12 +1494,8 @@ mod tests { assert_eq!(lang_from_tag(tag), Some(sq), "{tag}"); } assert_eq!(first_supported("sq-AL\r\nen-US"), Some(sq)); - assert_eq!(tr(sq, "File"), "Skedari"); // one (1), other (0, 2+) assert_eq!((0..=3).map(|n| (sq.0.plural)(n)).collect::>(), [1, 0, 1, 1]); - assert_eq!(trn(sq, 1, "{n} page", "{n} pages"), "1 faqe"); - assert_eq!(trn(sq, 0, "{n} page", "{n} pages"), "0 faqe"); - assert_eq!(trn(sq, 5, "{n} page", "{n} pages"), "5 faqe"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "SQ").unwrap(); assert_eq!(app.language, "sq"); @@ -3118,125 +1506,22 @@ mod tests { assert_eq!(restored.language, "sq"); } - /// Albanian translates every registered command and every All tools group, section and item. - #[test] - fn albanian_covers_commands_and_catalogue() { - let sq = Lang::from_code("sq").expect("sq registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(sq, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(sq, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(sq, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(sq, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(sq, item.label), "missing item: {}", item.label); - } - } - } - } - - /// New tl!("literal") labels must not silently fall back to English. - #[test] - fn albanian_covers_ui_literals() { - let sq = Lang::from_code("sq").expect("sq registered"); - let missing: Vec<_> = scan_ui_literals(false).into_iter().filter(|label| !has(sq, label)).collect(); - assert!(missing.is_empty(), "untranslated Albanian UI literals: {missing:#?}"); - } - #[test] fn slovene_plurals_cover_dual_few_teens_and_large_counts() { let sl = Lang::from_code("sl").expect("sl registered"); - for (counts, page, field) in [ - (vec![1, 101, 201, 1001, 1101], "stran", "polje"), - (vec![2, 102, 202, 1002], "strani", "polji"), - (vec![3, 4, 103, 104, 1003], "strani", "polja"), - (vec![0, 5, 10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, 31, 100, 111, 112, 121, 122, 1000, u64::MAX], "strani", "polj"), + assert_eq!(sl.0.plural_forms(), 4); + for (counts, form) in [ + (vec![1, 101, 201, 1001, 1101], 0), + (vec![2, 102, 202, 1002], 1), + (vec![3, 4, 103, 104, 1003], 2), + (vec![0, 5, 10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, 31, 100, 111, 112, 121, 122, 1000, u64::MAX], 3), ] { for n in counts { - assert_eq!(trn(sl, n, "{n} page", "{n} pages"), format!("{n} {page}")); - assert_eq!(trn(sl, n, "{n} field", "{n} fields"), format!("{n} {field}")); + assert_eq!((sl.0.plural)(n), form, "{n}"); } } } - #[test] - fn slovene_diagnostics_preserve_user_values() { - let sl = Lang::from_code("sl").expect("sl registered"); - set_current(sl); - assert_eq!(action_label("Custom action {n}"), "Custom action {n}"); - assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "Izbranih strani: 3"); - assert_eq!(tr(sl, "CheckBox"), "Potrditveno polje"); - set_current(Lang::EN); - } - - /// Slovene translates every registered command and every All tools group, section and item. - #[test] - fn slovene_covers_commands_and_catalogue() { - let sl = Lang::from_code("sl").expect("sl registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(sl, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(sl, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(sl, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(sl, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(sl, item.label), "missing item: {}", item.label); - } - } - } - } - - /// New tl!("literal") labels must not silently fall back to English. - #[test] - fn slovene_covers_ui_literals() { - let sl = Lang::from_code("sl").expect("sl registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(\"") { - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - if tail.starts_with("\")") { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(sl, label)).collect(); - assert!(missing.is_empty(), "untranslated Slovene UI literals: {missing:#?}"); - } - #[test] fn indonesian_and_vietnamese_are_registered_and_persist() { let id = Lang::from_code("id").expect("id registered"); @@ -3253,25 +1538,9 @@ mod tests { } assert_eq!(first_supported("id-ID\r\nen-US"), Some(id)); assert_eq!(first_supported("vi-VN\r\nen-US"), Some(vi)); - assert_eq!(tr(id, "Save"), "Simpan"); - assert_eq!(tr(id, "Save as…"), "Simpan sebagai…"); - assert_eq!(tr(id, "Bookmarks"), "Bookmark"); - assert_eq!(tr(id, "Fill & Sign"), "Isi & Tanda Tangani"); - assert_eq!(tr(vi, "Save"), "Lưu"); - assert_eq!(tr(vi, "Save as…"), "Lưu thành…"); - assert_eq!(tr(vi, "Bookmarks"), "Dấu trang"); - assert_eq!(tr(vi, "Fill & Sign"), "Điền & Ký"); - assert_eq!(tr(vi, "Interface language"), "Ngôn ngữ giao diện"); - assert_eq!(tr(id, "Interface language"), "Bahasa antarmuka"); // One CLDR plural form (CLDR `id`, `vi`): the message reads the same for every count. assert_eq!((0..=3).map(|n| (id.0.plural)(n)).collect::>(), [0, 0, 0, 0]); assert_eq!((0..=3).map(|n| (vi.0.plural)(n)).collect::>(), [0, 0, 0, 0]); - for n in [0, 1, 2, 5] { - assert_eq!(trn(id, n, "{n} page", "{n} pages"), format!("{n} halaman")); - assert_eq!(trn(vi, n, "{n} page", "{n} pages"), format!("{n} trang")); - assert_eq!(trn(id, n, "{n} field", "{n} fields"), format!("{n} kolom")); - assert_eq!(trn(vi, n, "{n} field", "{n} fields"), format!("{n} trường")); - } let mut app = crate::PdfCraftApp::default(); app.set_option("language", "ID").unwrap(); assert_eq!(app.language, "id"); @@ -3288,74 +1557,6 @@ mod tests { assert_eq!(restored.language, "vi"); } - /// Indonesian and Vietnamese translate every registered command and every All tools group, - /// section and item. - #[test] - fn indonesian_and_vietnamese_cover_commands_and_catalogue() { - for lang in [Lang::from_code("id").expect("id registered"), Lang::from_code("vi").expect("vi registered")] { - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(lang, command.label), "{} missing command: {}", lang.code(), command.label); - if let Some(menu) = command.menu { - assert!(has(lang, menu), "{} missing menu: {menu}", lang.code()); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(lang, group.label), "{} missing group: {}", lang.code(), group.label); - for section in group.sections { - assert!(has(lang, section.title), "{} missing section: {}", lang.code(), section.title); - for item in section.items { - assert!(has(lang, item.label), "{} missing item: {}", lang.code(), item.label); - } - } - } - } - } - - /// Every `tl!("…")` literal in the UI source is translated in Indonesian and Vietnamese, so a - /// new label cannot silently fall back to English in either. - #[test] - fn indonesian_and_vietnamese_cover_ui_literals() { - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(\"") { - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - if tail.starts_with("\")") { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - for lang in [Lang::from_code("id").expect("id registered"), Lang::from_code("vi").expect("vi registered")] { - let missing: Vec<_> = literals.iter().filter(|label| !has(lang, label)).collect(); - assert!(missing.is_empty(), "untranslated {} UI literals: {missing:#?}", lang.code()); - } - } - #[test] fn hindi_and_korean_are_registered_and_persist() { let hi = Lang::from_code("hi").expect("hi registered"); @@ -3372,26 +1573,9 @@ mod tests { } assert_eq!(first_supported("hi-IN\r\nen-US"), Some(hi)); assert_eq!(first_supported("ko-KR\r\nen-US"), Some(ko)); - assert_eq!(tr(hi, "Save"), "सहेजें"); - assert_eq!(tr(hi, "Save as…"), "इस रूप में सहेजें…"); - assert_eq!(tr(hi, "Bookmarks"), "बुकमार्क"); - assert_eq!(tr(hi, "Home"), "होम"); - assert_eq!(tr(hi, "Interface language"), "इंटरफ़ेस भाषा"); - assert_eq!(tr(ko, "Save"), "저장"); - assert_eq!(tr(ko, "Save as…"), "다른 이름으로 저장…"); - assert_eq!(tr(ko, "Bookmarks"), "책갈피"); - assert_eq!(tr(ko, "Home"), "홈"); - assert_eq!(tr(ko, "Interface language"), "인터페이스 언어"); // One plural form per catalog: the message reads the same for every count. assert_eq!((0..=3).map(|n| (hi.0.plural)(n)).collect::>(), [0, 0, 0, 0]); assert_eq!((0..=3).map(|n| (ko.0.plural)(n)).collect::>(), [0, 0, 0, 0]); - for n in [0, 1, 2, 5] { - assert_eq!(trn(hi, n, "{n} page", "{n} pages"), format!("{n} पृष्ठ")); - assert_eq!(trn(ko, n, "{n} page", "{n} pages"), format!("{n}페이지")); - assert_eq!(trn(ko, n, "{n} field", "{n} fields"), format!("필드 {n}개")); - } - // The broken-line regression: a `\n` escape stays one catalog line. - assert_eq!(tr(ko, "This page couldn't be displayed.\n{e}"), "이 페이지를 표시할 수 없습니다.\n{e}"); for code in ["hi", "ko"] { let mut app = crate::PdfCraftApp::default(); app.set_option("language", &code.to_uppercase()).unwrap(); @@ -3404,104 +1588,6 @@ mod tests { } } - /// Redaction first marks a region; applying it irreversibly removes marked content. - /// Korean must not present either stage as censorship ("검열") or imply that - /// clearing un-applied marks deletes PDF content. - #[test] - fn korean_redaction_labels_distinguish_marks_and_permanent_removal() { - let ko = Lang::from_code("ko").expect("ko registered"); - for english in [ - "Redact", - "Redact a PDF", - "Redact text and images", - "Redact pages…", - "Mark for redaction", - "Apply redactions", - "Apply redactions…", - "Clear redaction marks", - "Redact all", - ] { - assert!(!tr(ko, english).contains("검열"), "Korean label misstates redaction: {english}"); - } - assert_eq!(tr(ko, "Mark for redaction"), "제거할 영역 표시"); - assert_eq!(tr(ko, "Apply redactions"), "표시된 정보 영구 제거"); - assert_eq!(tr(ko, "Clear redaction marks"), "제거 대상 표시 지우기"); - let warning = concat!( - "You are about to apply 1 redaction mark. ", - "Text, images and drawings under the marks, and comments ", - "and form fields that overlap them, are removed permanently." - ); - assert!(tr(ko, warning).contains("영구적으로 제거됩니다")); - } - - /// Hindi and Korean translate every registered command and every All tools group, section - /// and item. - #[test] - fn hindi_and_korean_cover_commands_and_catalogue() { - for lang in [Lang::from_code("hi").expect("hi registered"), Lang::from_code("ko").expect("ko registered")] { - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(lang, command.label), "{} missing command: {}", lang.code(), command.label); - if let Some(menu) = command.menu { - assert!(has(lang, menu), "{} missing menu: {menu}", lang.code()); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(lang, group.label), "{} missing group: {}", lang.code(), group.label); - for section in group.sections { - assert!(has(lang, section.title), "{} missing section: {}", lang.code(), section.title); - for item in section.items { - assert!(has(lang, item.label), "{} missing item: {}", lang.code(), item.label); - } - } - } - } - } - - /// Every `tl!("…")` literal in the UI source is translated in Hindi and Korean, so a new - /// label cannot silently fall back to English in either. - #[test] - fn hindi_and_korean_cover_ui_literals() { - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(\"") { - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - if tail.starts_with("\")") { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - for lang in [Lang::from_code("hi").expect("hi registered"), Lang::from_code("ko").expect("ko registered")] { - let missing: Vec<_> = literals.iter().filter(|label| !has(lang, label)).collect(); - assert!(missing.is_empty(), "untranslated {} UI literals: {missing:#?}", lang.code()); - } - } - #[test] fn bulgarian_is_registered() { let bg = Lang::from_code("bg").expect("bg registered"); @@ -3510,19 +1596,9 @@ mod tests { assert_eq!(lang_from_tag("bg_BG.UTF-8"), Some(bg)); assert_eq!(lang_from_tag("bg-BG"), Some(bg)); assert_eq!(first_supported("bg-BG\r\nen-US"), Some(bg)); - assert_eq!(tr(bg, "File"), "Файл"); - assert_eq!(tr(bg, "Save as…"), "Запиши като…"); - assert_eq!(tr(bg, "Bookmarks"), "Отметки"); - assert_eq!(tr(bg, "Layers"), "Слоеве"); assert_eq!(tr(bg, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf"); // one (1), other (0, 2+) assert_eq!((0..=3).map(|n| (bg.0.plural)(n)).collect::>(), [1, 0, 1, 1]); - assert_eq!(trn(bg, 0, "{n} page", "{n} pages"), "0 страници"); - assert_eq!(trn(bg, 1, "{n} page", "{n} pages"), "1 страница"); - assert_eq!(trn(bg, 2, "{n} page", "{n} pages"), "2 страници"); - assert_eq!(trn(bg, 21, "{n} page", "{n} pages"), "21 страници"); - assert_eq!(trn(bg, 1, "{n} field", "{n} fields"), "1 поле"); - assert_eq!(trn(bg, 5, "{n} field", "{n} fields"), "5 полета"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "bg").unwrap(); assert_eq!(app.language, "bg"); @@ -3531,80 +1607,6 @@ mod tests { assert_eq!(restored.language, "bg"); } - /// Bulgarian translates every registered command and every All tools group, section and item. - #[test] - fn bulgarian_covers_commands_and_catalogue() { - let bg = Lang::from_code("bg").expect("bg registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(bg, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(bg, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(bg, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(bg, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(bg, item.label), "missing item: {}", item.label); - } - } - } - } - - /// New tl!("literal") labels must not silently fall back to English. - #[test] - fn bulgarian_covers_ui_literals() { - let bg = Lang::from_code("bg").expect("bg registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(\"") { - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - if tail.starts_with("\")") { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(bg, label)).collect(); - assert!(missing.is_empty(), "untranslated Bulgarian UI literals: {missing:#?}"); - } - - #[test] - fn bulgarian_diagnostics_preserve_user_values() { - let bg = Lang::from_code("bg").expect("bg registered"); - set_current(bg); - assert_eq!(action_label("Custom action {n}"), "Custom action {n}"); - assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "Избрани страници: 3"); - assert_eq!(tr(bg, "CheckBox"), "Квадратче за отметка"); - set_current(Lang::EN); - } - #[test] fn hungarian_is_registered() { let hu = Lang::from_code("hu").expect("hu registered"); @@ -3613,24 +1615,9 @@ mod tests { assert_eq!(lang_from_tag("hu_HU.UTF-8"), Some(hu)); assert_eq!(lang_from_tag("hu-HU"), Some(hu)); assert_eq!(first_supported("hu-HU\r\nen-US"), Some(hu)); - assert_eq!(tr(hu, "File"), "Fájl"); - assert_eq!(tr(hu, "Save as…"), "Mentés másként…"); - assert_eq!(tr(hu, "Bookmarks"), "Könyvjelzők"); - assert_eq!(tr(hu, "Layers"), "Rétegek"); assert_eq!(tr(hu, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf"); - assert_eq!(tr_ctx(hu, "signature pad", "Type"), "Gépelés"); - assert_eq!(tr_ctx(hu, "action wizard", "Start"), "Indítás"); - assert_eq!(tr(hu, "Windows store · "), "Windows-tároló · "); - assert_eq!(tr(hu, "Place saved {what}"), "Mentett {what} elhelyezése"); - assert_eq!(tr(hu, "Remove saved {what}"), "Mentett {what} eltávolítása"); - assert_eq!(tr(hu, "Squiggly"), "Hullámos aláhúzás"); // one (1), other (0, 2+) assert_eq!((0..=3).map(|n| (hu.0.plural)(n)).collect::>(), [1, 0, 1, 1]); - assert_eq!(trn(hu, 0, "{n} page", "{n} pages"), "0 oldal"); - assert_eq!(trn(hu, 1, "{n} page", "{n} pages"), "1 oldal"); - assert_eq!(trn(hu, 2, "{n} page", "{n} pages"), "2 oldal"); - assert_eq!(trn(hu, 1, "{n} field", "{n} fields"), "1 mező"); - assert_eq!(trn(hu, 5, "{n} field", "{n} fields"), "5 mező"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "hu").unwrap(); assert_eq!(app.language, "hu"); @@ -3639,87 +1626,6 @@ mod tests { assert_eq!(restored.language, "hu"); } - /// Hungarian translates every registered command and every All tools group, section and item. - #[test] - fn hungarian_covers_commands_and_catalogue() { - let hu = Lang::from_code("hu").expect("hu registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(hu, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(hu, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(hu, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(hu, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(hu, item.label), "missing item: {}", item.label); - } - } - } - } - - /// New tl!("literal") labels must not silently fall back to English. - #[test] - fn hungarian_covers_ui_literals() { - let hu = Lang::from_code("hu").expect("hu registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(\"") { - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - if tail.starts_with("\")") { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(hu, label)).collect(); - assert!(missing.is_empty(), "untranslated Hungarian UI literals: {missing:#?}"); - } - - #[test] - fn hungarian_history_and_diagnostics_preserve_user_values() { - let hu = Lang::from_code("hu").expect("hu registered"); - set_current(hu); - assert_eq!(command_label("Undo Insert pages from Rapport {n}.pdf"), "Oldalak beszúrása ebből: Rapport {n}.pdf visszavonása"); - assert_eq!(command_label("Redo Fill in Contact {key}"), "Contact {key} kitöltése megismétlése"); - assert_eq!(action_label("Change Title"), "Cím módosítása"); - assert_eq!(action_label("Custom action {n}"), "Custom action {n}"); - assert_eq!( - fmt(t("This page couldn't be displayed.\n{e}"), &[("e", "OS error {n}")]), - "Ezt az oldalt nem sikerült megjeleníteni.\nOS error {n}" - ); - assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "3 oldal kijelölve"); - assert_eq!(tr(hu, "CheckBox"), "Jelölőnégyzet"); - set_current(Lang::EN); - } - #[test] fn ukrainian_is_registered_and_persists() { let uk = Lang::from_code("uk").expect("uk registered"); @@ -3730,16 +1636,7 @@ mod tests { } assert_eq!(first_supported("uk-UA\r\nen-US"), Some(uk)); assert_eq!(first_supported("(\n \"uk-UA\",\n \"en-US\"\n)\n"), Some(uk)); - assert_eq!(tr(uk, "File"), "Файл"); - assert_eq!(tr(uk, "Save as…"), "Зберегти як…"); - assert_eq!(tr(uk, "Bookmarks"), "Закладки"); - assert_eq!(tr(uk, "Layers"), "Шари"); assert_eq!(tr(uk, "Звіт {n}.pdf"), "Звіт {n}.pdf"); - assert_eq!(tr_ctx(uk, "comment menu", "Edit"), "Редагувати"); - assert_eq!(tr_ctx(uk, "signature pad", "Type"), "Ввести"); - assert_eq!(tr_ctx(uk, "action wizard", "Start"), "Почати"); - assert_eq!(tr(uk, "Subject"), "Тема"); - assert_eq!(tr_ctx(uk, "certificate", "Subject"), "Власник сертифіката"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "UK").unwrap(); assert_eq!(app.language, "uk"); @@ -3753,297 +1650,18 @@ mod tests { #[test] fn ukrainian_integer_plurals_cover_teens_and_large_counts() { let uk = Lang::from_code("uk").expect("uk registered"); - for (counts, page, field) in [ - (vec![1, 21, 31, 101, 121, 1001], "сторінка", "поле"), - (vec![2, 3, 4, 22, 23, 24, 102, 104, 122, 1002], "сторінки", "поля"), - (vec![0, 5, 10, 11, 12, 13, 14, 19, 20, 25, 100, 110, 111, 112, 114, 1000, u64::MAX], "сторінок", "полів"), + assert_eq!(uk.0.plural_forms(), 3); + for (counts, form) in [ + (vec![1, 21, 31, 101, 121, 1001], 0), + (vec![2, 3, 4, 22, 23, 24, 102, 104, 122, 1002], 1), + (vec![0, 5, 10, 11, 12, 13, 14, 19, 20, 25, 100, 110, 111, 112, 114, 1000, u64::MAX], 2), ] { for n in counts { - assert_eq!(trn(uk, n, "{n} page", "{n} pages"), format!("{n} {page}")); - assert_eq!(trn(uk, n, "{n} field", "{n} fields"), format!("{n} {field}")); + assert_eq!((uk.0.plural)(n), form, "{n}"); } } } - #[test] - fn ukrainian_calendar_uses_month_case_for_dates() { - let uk = Lang::from_code("uk").expect("uk registered"); - for (source, standalone, date) in [ - ("January", "Січень", "січня"), - ("February", "Лютий", "лютого"), - ("March", "Березень", "березня"), - ("April", "Квітень", "квітня"), - ("May", "Травень", "травня"), - ("June", "Червень", "червня"), - ("July", "Липень", "липня"), - ("August", "Серпень", "серпня"), - ("September", "Вересень", "вересня"), - ("October", "Жовтень", "жовтня"), - ("November", "Листопад", "листопада"), - ("December", "Грудень", "грудня"), - ] { - assert_eq!(tr(uk, source), standalone); - let month = tr_ctx(uk, "calendar date", source); - assert_eq!(month, date); - assert_eq!(fmt(tr(uk, "{month} {day}, {y}"), &[("month", month), ("day", "9"), ("y", "2026")]), format!("9 {date} 2026")); - // Catalogs without this context retain their existing month translation. - assert_eq!(tr_ctx(Lang::EN, "calendar date", source), source); - assert_eq!(tr_ctx(JA(), "calendar date", source), tr(JA(), source)); - } - } - - /// Preserve only product/format names, abbreviations and templates with no English words. - #[test] - fn ukrainian_catalog_does_not_leave_english_ui_text() { - let uk = Lang::from_code("uk").expect("uk registered"); - let (entries, errors) = parse_entries(uk.0.source, uk.0.plural_forms()); - assert!(errors.is_empty(), "{errors:?}"); - let keep = [ - "OK", - "PDF/A…", - "Microsoft Word (.docx)", - "PostScript / EPS", - // Key-cap names, printed in English on Ukrainian keyboards. - "Home / End", - "JavaScript", - "ZIP", - "{n} {kind}", - "{rule} - {status}", - "{field}: {e}", - "`{command}` {when}", - "{0}: {1}", - "{month} {y}", - "+LOC", - "−LOC", - "ΔLOC", - "+Bin", - "−Bin", - // The distinguished name of a certificate, abbreviated as in X.500. - "DN: ", - ]; - let unchanged: Vec<_> = entries - .iter() - .filter(|e| { - let key_cap = e.context == "key" && KEY_CAP_NAMES.contains(&e.source.as_str()); - e.source == e.translation && !keep.contains(&e.source.as_str()) && !key_cap - }) - .collect(); - assert!(unchanged.is_empty(), "untranslated Ukrainian catalog entries: {unchanged:#?}"); - } - - /// Ukrainian translates every registered command and every All tools label. - #[test] - fn ukrainian_covers_commands_and_catalogue() { - let uk = Lang::from_code("uk").expect("uk registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(uk, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(uk, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(uk, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(uk, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(uk, item.label), "missing item: {}", item.label); - } - } - } - } - - /// Kazakh translates every registered command and every All tools label. - #[test] - fn kazakh_covers_commands_and_catalogue() { - let kk = Lang::from_code("kk").expect("kk registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(kk, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(kk, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(kk, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(kk, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(kk, item.label), "missing item: {}", item.label); - } - } - } - } - /// Preserve only product/format names, abbreviations and templates with no English words. - #[test] - fn kazakh_catalog_does_not_leave_english_ui_text() { - let kk = Lang::from_code("kk").expect("kk registered"); - let (entries, errors) = parse_entries(kk.0.source, kk.0.plural_forms()); - assert!(errors.is_empty(), "{errors:?}"); - let keep = [ - "OK", - "PDF/A…", - "Microsoft Word (.docx)", - "PostScript / EPS", - // Key-cap names, printed in English on Kazakh keyboards. - "Home / End", - "JavaScript", - "ZIP", - "{n} {kind}", - "{rule} - {status}", - "{field}: {e}", - "`{command}` {when}", - "{0}: {1}", - "{month} {y}", - "+LOC", - "−LOC", - "ΔLOC", - "+Bin", - "−Bin", - // The distinguished name of a certificate, abbreviated as in X.500. - "DN: ", - ]; - let unchanged: Vec<_> = entries - .iter() - .filter(|e| { - let key_cap = e.context == "key" && KEY_CAP_NAMES.contains(&e.source.as_str()); - e.source == e.translation && !keep.contains(&e.source.as_str()) && !key_cap - }) - .collect(); - assert!(unchanged.is_empty(), "untranslated Kazakh catalog entries: {unchanged:#?}"); - } - /// Kazakh translates every tl!("literal") in the UI source, including direct lookups and - /// multiline literals, following the complete-catalog check for Simplified Chinese. - #[test] - fn kazakh_covers_ui_literals() { - let kk = Lang::from_code("kk").expect("kk registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n").replace("crate::i18n::t(", "tl!("); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(") { - let after = after.trim_start(); - let Some(after) = after.strip_prefix('"') else { - rest = after; - continue; - }; - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - let closing = tail.strip_prefix('"').expect("closing quote").trim_start(); - let closing = closing.strip_prefix(',').unwrap_or(closing).trim_start(); - if closing.starts_with(')') { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(kk, label)).collect(); - assert!(missing.is_empty(), "untranslated Kazakh UI literals: {missing:#?}"); - } - /// Check direct lookups and multiline literals as well as ordinary tl!("literal") calls, - /// following the complete-catalog check for Simplified Chinese. - #[test] - fn ukrainian_covers_ui_literals() { - let uk = Lang::from_code("uk").expect("uk registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n").replace("crate::i18n::t(", "tl!("); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(") { - let after = after.trim_start(); - let Some(after) = after.strip_prefix('"') else { - rest = after; - continue; - }; - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - let closing = tail.strip_prefix('"').expect("closing quote").trim_start(); - let closing = closing.strip_prefix(',').unwrap_or(closing).trim_start(); - if closing.starts_with(')') { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(uk, label)).collect(); - assert!(missing.is_empty(), "untranslated Ukrainian UI literals: {missing:#?}"); - } - - #[test] - fn ukrainian_history_and_diagnostics_preserve_user_values() { - let uk = Lang::from_code("uk").expect("uk registered"); - set_current(uk); - assert_eq!(command_label("Undo Insert pages from Звіт {n}.pdf"), "Скасувати: Вставити сторінки з Звіт {n}.pdf"); - assert_eq!(command_label("Redo Fill in Контакт {key}"), "Повторити: Заповнити Контакт {key}"); - assert_eq!(action_label("Change Title"), "Змінити: Заголовок"); - assert_eq!(action_label("Custom action {n}"), "Custom action {n}"); - assert_eq!(fmt(t("This page couldn't be displayed.\n{e}"), &[("e", "OS error {n}")]), "Не вдалося відобразити цю сторінку.\nOS error {n}"); - assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "Вибрано сторінок: 3"); - assert_eq!(fmt(t("{n} contributors · {c} commits"), &[("n", "2"), ("c", "1,234")]), "Учасників: 2 · Комітів: 1,234"); - assert_eq!(tr(uk, "CheckBox"), "Прапорець"); - let contributor = crate::credits::Contributor { - login: "reader{n}", - display_name: Some("Save"), - real_name: None, - prs: 2, - commits: 3, - lines_added: 10, - lines_deleted: 4, - binary_added: 1, - binary_deleted: 0, - first_commit: "2026-10-01T00:00:00Z", - last_commit: "2026-10-09T00:00:00Z", - }; - assert_eq!( - contributor.summary(), - "@reader{n}: PR: 2, комітів: 3, +10 / −4 рядків (Δ +6), +1 / −0 бінарних ресурсів, 2026-10-01 – 2026-10-09" - ); - assert_eq!(contributor.name(crate::credits::NameMode::DisplayName), "Save"); - set_current(Lang::EN); - } - fn arabic() -> Lang { Lang::from_code("ar").expect("ar registered") } @@ -4060,10 +1678,6 @@ mod tests { assert_eq!(lang_from_tag(tag), Some(ar), "{tag}"); } assert_eq!(first_supported("ar-MA\r\nen-US"), Some(ar)); - assert_eq!(tr(ar, "File"), "ملف"); - assert_eq!(tr(ar, "Bookmarks"), "المرجعية الإشارات"); - assert_eq!(tr_ctx(ar, "comment menu", "Edit"), "تحرير"); - assert_eq!(tr_ctx(ar, "certificate", "Subject"), "الشهادة صاحب"); // Unknown strings and user text are never reordered by a lookup. assert_eq!(tr(ar, "تقرير نهائي {n}.pdf"), "تقرير نهائي {n}.pdf"); let mut app = crate::PdfCraftApp::default(); @@ -4107,41 +1721,6 @@ mod tests { assert_eq!([3, 10, 103, 110, 1003].map(form), [3; 5]); assert_eq!([11, 99, 111, 199, 1011].map(form), [4; 5]); assert_eq!([100, 101, 102, 200, 1000, u64::MAX - 13].map(form), [5; 6]); - assert_eq!(trn(ar, 1, "{n} page", "{n} pages"), "صفحة 1"); - assert_eq!(trn(ar, 2, "{n} page", "{n} pages"), "صفحتان 2"); - assert_eq!(trn(ar, 5, "{n} page", "{n} pages"), "صفحات 5"); - assert_eq!(trn(ar, 11, "{n} field", "{n} fields"), "حقلًا 11"); - assert_eq!(trn(ar, 100, "{n} field", "{n} fields"), "حقل 100"); - } - - /// Arabic translates every registered command, menu and All tools label. - #[test] - fn arabic_covers_commands_and_catalogue() { - let ar = arabic(); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(ar, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(ar, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(ar, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(ar, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(ar, item.label), "missing item: {}", item.label); - } - } - } - } - - /// Arabic translates every `tl!("…")` literal in the UI source. - #[test] - fn arabic_covers_ui_literals() { - let literals = ui_literals(); - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(arabic(), label)).collect(); - assert!(missing.is_empty(), "untranslated Arabic UI literals: {missing:#?}"); } /// With a craft-fonts Arabic face, every Arabic translation has glyphs: with all interface @@ -4189,6 +1768,93 @@ mod tests { } } + /// The translation gaps report (`cargo xtask i18n-report`, a step of `cargo xtask ci`): for + /// each language, how many English strings it has no translation for, and the first few. + /// It never fails: a missing string shows in English. + /// + /// The English strings are the UI source's literals, the commands, All tools, the engine's + /// own wordings, and every key of every bundled catalog (which covers contextual, plural and + /// dynamically looked-up strings the scan can't see). + #[test] + fn translation_gaps_report() { + use std::collections::BTreeSet; + const SHOWN: usize = 3; + let mut plain: BTreeSet = ui_literals(); + let mut add = |s: &str| { + plain.insert(s.to_string()); + }; + for command in pdfcraft_engine::commands::COMMANDS { + add(command.label); + command.menu.into_iter().for_each(&mut add); + } + for group in pdfcraft_engine::catalog::TOOL_GROUPS { + add(group.label); + for section in group.sections { + add(section.title); + section.items.iter().for_each(|item| add(item.label)); + } + } + let engine: [&[&str]; 9] = [ + pdfcraft_engine::a11y::WORDINGS, + pdfcraft_engine::a11y::REPORT_WORDS, + pdfcraft_engine::pdfa::WORDINGS, + pdfcraft_engine::sign::pdf::WORDINGS, + pdfcraft_engine::sign::pdf::kind::ALL, + pdfcraft_engine::print::WORDINGS, + pdfcraft_engine::EDIT_LABELS, + pdfcraft_engine::BATCH_LABELS, + pdfcraft_engine::field_table::WORDINGS, + ]; + engine.into_iter().flatten().for_each(|s| add(s)); + pdfcraft_engine::sign::AppearanceLabels::default().fields().into_iter().for_each(|(_, label)| add(label)); + crate::credits::NameMode::ALL.iter().for_each(|mode| add(mode.label())); + for key in crate::credits::SortKey::ALL { + let (label, header) = key.label(); + add(label); + add(header); + } + crate::credits::MODEL_COLUMNS.iter().for_each(|s| add(s)); + // (context, source) keys; `@id` rows only reword a command label, which is in `plain`. + let mut keys: BTreeSet<(String, String)> = plain.into_iter().map(|s| (String::new(), s)).collect(); + for language in &LANGUAGES { + let (entries, _) = parse_entries(language.source, language.plural_forms()); + keys.extend(entries.into_iter().filter(|e| e.context != "@id").map(|e| (e.context, e.source))); + } + let mut report = format!("Translation gaps: {} English strings\n", keys.len()); + for lang in Lang::all().filter(|l| *l != Lang::EN) { + let catalog = lang.catalog(); + let missing: Vec<&(String, String)> = keys + .iter() + .filter(|(context, source)| match context.as_str() { + "" => catalog.plain(source).is_none(), + "@plural" => source.split_once('|').is_none_or(|(one, other)| catalog.plural(one, other, 0).is_none()), + // A contextual lookup falls back to the plain translation. + context => catalog.contextual(context, source).is_none() && catalog.plain(source).is_none(), + }) + .collect(); + let mut line = format!(" {:<8} {:>5} missing", lang.code(), missing.len()); + for (i, (context, source)) in missing.iter().take(SHOWN).enumerate() { + let mut shown: String = source.chars().take(48).collect(); + if shown.len() < source.len() { + shown.push('…'); + } + let key = if context.is_empty() { format!("{shown:?}") } else { format!("{context} / {shown:?}") }; + line.push_str(if i == 0 { ": " } else { ", " }); + line.push_str(&key); + } + if missing.len() > SHOWN { + line.push_str(", …"); + } + report.push_str(&line); + report.push('\n'); + } + println!("{REPORT_START}\n{report}{REPORT_END}"); + } + + /// The lines `cargo xtask i18n-report` prints from the test output lie between these. + const REPORT_START: &str = "=== translation gaps ==="; + const REPORT_END: &str = "=== end of translation gaps ==="; + /// Preferences ▸ Date format ▸ Language offers exactly the interface languages, in order. #[test] fn date_languages_are_the_interface_languages() { diff --git a/crates/ui-egui/src/motion.rs b/crates/ui-egui/src/motion.rs index c091cbd97e..934fc06188 100644 --- a/crates/ui-egui/src/motion.rs +++ b/crates/ui-egui/src/motion.rs @@ -463,22 +463,6 @@ mod tests { let style = ctx.global_style(); assert_eq!((style.animation_time, style.scroll_animation), times, "{effects:?}"); } - // The row's labels are translated: the heading, and On and Off as they suit it. - let de = crate::i18n::Lang::from_code("de").expect("German ships"); - assert_eq!(crate::i18n::tr(de, "Animation effects"), "Animationseffekte"); - let labels: Vec<&str> = - AnimationEffects::ALL.into_iter().map(|choice| crate::i18n::tr_ctx(de, "animation effects", choice.label())).collect(); - assert_eq!(labels, ["Systemeinstellung verwenden", "Ein", "Aus"]); - for lang in crate::i18n::Lang::all() { - for english in ["Animation effects", "On", "Off"] { - let shown = if english == "Animation effects" { - crate::i18n::tr(lang, english) - } else { - crate::i18n::tr_ctx(lang, "animation effects", english) - }; - assert!(lang.code() == "en" || shown != english, "{}: {english} is not translated", lang.code()); - } - } } /// The chrome's hover wash has a duration of its own (`theme::Press`): it eases in with diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index 029dc5bc91..1b090b3731 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -415,28 +415,3 @@ pub(crate) fn move_error(error: &pdfcraft_engine::EditError) -> String { _ => tl!("These objects could not be moved. Select them again and try a smaller move.").into(), } } - -#[cfg(test)] -mod tests { - #[test] - fn selection_labels_and_refusals_are_translated_in_every_catalog() { - for language in crate::i18n::LANGUAGES.iter().filter(|l| !l.source.is_empty()) { - let lang = crate::i18n::Lang::from_code(language.code).unwrap(); - for label in [ - "Drag the selection to move it. Esc clears the selection.", - "Shift-click to select several objects. Drag empty space to select an area.", - "Select at most 1000 objects at a time.", - "Finish or discard the text being edited before selecting several objects.", - "Move object", - "Move objects", - "Vertical text cannot be moved with this selection.", - "Text used as a clipping path cannot be moved with this selection.", - "These objects could not be moved. Select them again and try a smaller move.", - "This page contains objects that cannot be selected together.", - ] { - assert_ne!(crate::i18n::tr(lang, label), label, "{}: {label}", language.code); - } - assert_ne!(crate::i18n::trn(lang, 2, "{n} object selected", "{n} objects selected"), "2 objects selected", "{}", language.code); - } - } -} diff --git a/crates/ui-egui/src/scroll_speed.rs b/crates/ui-egui/src/scroll_speed.rs index cc3a7e86e5..e5a2b453f4 100644 --- a/crates/ui-egui/src/scroll_speed.rs +++ b/crates/ui-egui/src/scroll_speed.rs @@ -116,8 +116,5 @@ mod tests { } assert_eq!(ScrollSpeed::parse("FAST"), Some(ScrollSpeed::Fast)); assert_eq!(ScrollSpeed::parse("warp"), None); - // The labels are translated in their own context: they qualify "speed". - let ru = crate::i18n::Lang::from_code("ru").expect("Russian ships"); - assert_eq!(crate::i18n::tr_ctx(ru, "scroll speed", ScrollSpeed::Slow.label()), "Низкая"); } } diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index f771987a85..25f92530b2 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -1633,19 +1633,22 @@ mod tests { /// left): never a mix, and never a label signing would refuse. #[test] fn appearance_labels_are_translated_or_all_english() { - use crate::i18n::{Lang, tr}; + use crate::i18n::{Lang, has, tr}; let english = AppearanceLabels::default(); assert_eq!(appearance_labels(Lang::EN), (english.clone(), false)); + // Latin-script catalogs the signature's font can draw: stamped in the language once every + // label is translated there. for code in ["de", "es", "fr", "it", "pt-br", "pl", "tr", "sl", "hu"] { let lang = Lang::from_code(code).unwrap(); + if !english.fields().into_iter().all(|(_, source)| has(lang, source)) { + continue; + } let (labels, in_english) = appearance_labels(lang); assert!(!in_english, "{code}: {labels:?}"); for ((field, ours), (_, source)) in labels.fields().into_iter().zip(english.fields()) { assert_eq!(ours, tr(lang, source), "{code}: {field}"); } } - let (de, _) = appearance_labels(Lang::from_code("de").unwrap()); - assert_eq!((de.signed_by.as_str(), de.reason.as_str()), ("Digital signiert von", "Grund: ")); for code in ["zh-hans", "zh-hant", "ja", "ru", "uk", "bg", "kk", "el", "te", "cs", "lt", "ar"] { assert_eq!(appearance_labels(Lang::from_code(code).unwrap()), (english.clone(), true), "{code}"); } diff --git a/docs/architecture.md b/docs/architecture.md index c6b64da48d..40084794b4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # PdfCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (xtask ci: nextest, one wasm check) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (xtask ci: translation gaps report) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How PdfCraft is built today. The design rationale and the planned crates live in `plan/architecture.md` and `plan/adr/` (local-only); this file describes what is on `main`. Each @@ -101,7 +101,8 @@ Every user-facing feature is reachable headlessly (`AGENTS.md` §3): `cargo xtask ci` runs fmt, clippy (`-D warnings`), a check of `pdfcraft-cli` without default features, the tests (`xtask test`: cargo-nextest when installed, else `cargo test`, then the -doctests), the layering check, the WASM check (one `cargo check` over every crate below L8), +doctests), `xtask i18n-report` (the strings each interface language has no translation for; it +never fails), the layering check, the WASM check (one `cargo check` over every crate below L8), `xtask assets` (asset licences and hashes), `xtask parity` (checklist claims must cite real commands, tools and tests) and `xtask deny` (dependency licences). Other tasks: `xtask fuzz`, `xtask corpus` and `xtask check` (robustness sweep over the pinned pdf.js corpus), `xtask text-oracle` (word F1 and reading order against `pdftotext`), @@ -112,5 +113,6 @@ built by `.github/workflows/release.yml` ([releasing.md](releasing.md)). | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | `xtask ci` prints the translation gaps (`xtask i18n-report`); a missing translation never fails it | | 2026-10-23 | minor | `xtask ci` runs nextest and doctests, checks the CLI without default features, and checks wasm in one call | | 2026-10-10 | major | Created from the code on main (crate list, `xtask/src/layers.rs`, crate READMEs, `vendor/README.md`) | diff --git a/docs/localization.md b/docs/localization.md index 2d9aff7f5b..fc161e99a3 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -20,15 +20,15 @@ Whole sentences that the layers below word — what the accessibility checker fo The Ukrainian catalog has 2,047 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover. -The Lithuanian catalog has 2,585 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Coverage tests check commands/tools, scanned literal and direct lookups, and source/context keys in the other bundled catalogs. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation. +The Lithuanian catalog has 2,585 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation. -The Vietnamese catalog has 2,674 entries and one plural form (`plural_none`), covering the current catalog-key union. Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). Coverage tests require every current command, UI literal, bundled key and credit label. A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. +The Vietnamese catalog has 2,674 entries and one plural form (`plural_none`), covering the current catalog-key union. Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. The Finnish catalog has 2,674 entries: every key of the other bundled catalogs, written from the English labels in ordinary Finnish computing vocabulary and sentence case. Finnish has the English one/other plural rule, with the partitive singular after any number but 1 (`1 sivu`, `2 sivua`, `0 sivua`). Calendar headings use the nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`), and Finnish is also a date language for month and weekday names. Any `fi-*` locale selects it; `sv-FI` keeps English until PdfCraft has a Swedish catalog. The bundled Latin faces cover `ä ö å š ž`. The translation is AI-assisted (Claude Code) and has not yet been proofread line by line by a native speaker. The Catalan catalog has 2,592 entries, written from the meaning of the English labels in standard Catalan computing vocabulary. Commands use the second-person imperative (`Desa`, `Obre`, `Cancel·la`) and notices use impersonal or informal wording. Catalan has two plural forms like English (1 is singular; 0 and every other count take the plural: `1 pàgina`, `3 pàgines`). Every `ca-*` locale, including Valencian (`ca_ES@valencia`), uses this catalog. The interpunct of `l·l` is U+00B7, which the bundled Latin faces cover (tested in `tests/it/fonts.rs`). The date picker has Catalan month and weekday names; dates are day-first like French (`dddd d mmmm yyyy`), but after a day the month carries its own preposition, elided before a vowel (`7 de març`, `7 d'abril`, `8 d'octubre`), as Czech and Russian change theirs through `months_with_day`; the year takes `\de` from the pattern (`d mmmm \de yyyy`). A native Catalan speaker (the contributor) has reviewed the translation. -The Romanian catalog (`ro`, Română) has 2,673 entries and three plural forms (`plural_ro`, CLDR `ro`): `one` for 1, `few` for 0 and counts whose last two digits are 1–19 ("2 pagini", "101 pagini"), `other` for the rest, written with "de" ("20 de pagini"). Commands use the noun form common in Romanian software ("Salvare", "Deschidere…"); messages address the user formally. Redaction is "Anonimizare" ("redactare" means editing in Romanian), a watermark "Filigran". Calendar month names are lowercase inside dates (`{day} {month} {y}`). The bundled Inter faces cover ă, â, î, ș, ț (comma below). Coverage tests require every bundled key. Not yet reviewed by a second native speaker; status `partial`: no translated help, and engine/OS details stay English inside a translated frame. +The Romanian catalog (`ro`, Română) has 2,673 entries and three plural forms (`plural_ro`, CLDR `ro`): `one` for 1, `few` for 0 and counts whose last two digits are 1–19 ("2 pagini", "101 pagini"), `other` for the rest, written with "de" ("20 de pagini"). Commands use the noun form common in Romanian software ("Salvare", "Deschidere…"); messages address the user formally. Redaction is "Anonimizare" ("redactare" means editing in Romanian), a watermark "Filigran". Calendar month names are lowercase inside dates (`{day} {month} {y}`). The bundled Inter faces cover ă, â, î, ș, ț (comma below). Not yet reviewed by a second native speaker; status `partial`: no translated help, and engine/OS details stay English inside a translated frame. The Norwegian Bokmål catalog covers every key the other bundled catalogs have, with the English one/other plural rule, Norwegian key names (`Mellomrom`; Shift keeps its name) and month and weekday names for Fill & Sign dates. It was written from the meaning of the English labels; a native-speaking contributor reviewed its terminology (key names, Opacity, Redact, Highlight, Fill & Sign), not yet every line. @@ -61,7 +61,7 @@ Arabic glyphs come from the craft-fonts `Arab` face (Noto Sans Arabic, kept in t File names and document titles in Arabic or Hebrew are drawn with a craft-fonts `Arab` face when the build input has one; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). The tab strip, the recent-files list and Properties show such names in display order; other places (bookmarks, comments, search results) have the glyphs but may order mixed-direction text wrongly. -Japanese, Simplified Chinese, French, German and Brazilian Portuguese coverage tests, following PhotoCraft's complete-catalog approach, check every registered command, All tools label and `tl!("literal")` in the UI source. Brazilian Portuguese also checks every key in the union of the bundled catalogs. Simplified Chinese additionally checks direct `i18n::t` calls and multiline literals, plus the About tabs, contributor name modes, sort options, table headers and model columns. Generated history labels, contributor summaries and diagnostics are tested with user-supplied names and error details. Catalog coverage does not guarantee glyph coverage: the craft-fonts revision pinned for CI and releases has no `Hans` face small enough to embed (desktop builds embed a `Hans` face of at most 4 MiB from `CRAFT_FONTS_DIR`), and the web build embeds only BIZ UDPGothic Regular. Chinese font delivery remains separate work, as discussed in [#112](https://github.com/storytold/pdfcraft/pull/112) and [#140](https://github.com/storytold/pdfcraft/pull/140). +Catalog coverage does not guarantee glyph coverage: the craft-fonts revision pinned for CI and releases has no `Hans` face small enough to embed (desktop builds embed a `Hans` face of at most 4 MiB from `CRAFT_FONTS_DIR`), and the web build embeds only BIZ UDPGothic Regular. Chinese font delivery remains separate work, as discussed in [#112](https://github.com/storytold/pdfcraft/pull/112) and [#140](https://github.com/storytold/pdfcraft/pull/140). German labels keep nouns capitalized, including tool names inserted into notices. The certificate viewer uses the `certificate` context for `Subject` (the certificate holder), while document properties keep the plain translation (the topic). Search hints reserve room for the shortcut, and Home cards limit their title and description to the available rows; hovering shows the full text when it is shortened. @@ -72,6 +72,7 @@ PdfCraft uses the same system as PhotoCraft (`crates/ui-egui/src/i18n/`): - Strings in the code stay in English and serve as lookup keys. At draw time, `tl!("Save")` returns the text in the current language, and falls back to English when there is no entry. - Each language has a catalog, `crates/ui-egui/src/i18n/.tsv`, with one entry per line: `contextEnglishtranslation`. An empty context marks a plain string. `@id` keys an entry by command id (for example `file.saveAs`), so one menu item can read differently from another with the same English label. `@plural` holds plural forms. Any other context disambiguates an English word with several meanings (`tl_ctx!` in UI code, for example the signature pad's "Type" button against the "Type" column). The header of `ja.tsv` documents the format and escapes. - Placeholders (`{name}`) must appear in both columns. A trailing `…` must be kept. `i18n::fmt` fills placeholders in one pass, so a value such as a file name containing `{n}` is inserted as written. +- A missing entry never fails a build or a test: that string shows in English. `cargo xtask i18n-report`, a step of `cargo xtask ci`, prints for each language how many strings it has no translation for and the first few. It counts every command, All tools label, literal passed to `tl!` or `i18n::t` in the UI source, engine wording (below) and key of the bundled catalogs, so a new English string shows up there until each catalog has it. - Catalogs are validated strictly: unknown escapes, unknown `@` contexts, placeholder or ellipsis mismatches, wrong plural form counts and duplicates are errors. The tests require the bundled catalogs to have none; at run time a bad line is skipped (and logged) and that string shows in English. - Catalogs are assets: each `.tsv` has an `ATTRIBUTION.toml` entry with `kind = "translation"` and its SHA-256, checked by `cargo xtask assets`. After editing a catalog, put the new hash into its entry by hand (`sha256sum crates/ui-egui/src/i18n/.tsv`), because `cargo xtask assets --write` does not touch `ATTRIBUTION.toml`; the hash check is deliberate, so a changed asset gets its provenance re-checked. Without it `cargo xtask assets` and the test `assets::tests::the_real_manifest_passes` fail. - Translations are clean-room: write them from the meaning of the English text, never from another product's string tables. @@ -84,7 +85,7 @@ Some sentences are not the interface's own. The accessibility checker, the PDF/A - `said(&words)` says one of them: the layer looks its own wording up through the closure it is given, then fills the holes with the document's values — a page number, a font name, what the person typed — in one pass, so a value containing `{n}` is inserted as written. - The UI passes `&crate::i18n::words`, which is a catalog lookup, so the sentence reads in the interface language. `in_english` is the same thing in English, and a wording with no entry comes back as it stands, which is the English — a catalog missing one shows an English sentence rather than a gap. - The lists are `a11y::{WORDINGS, REPORT_WORDS}`, `preflight::WORDINGS`, `sign::pdf::{WORDINGS, kind::ALL}`, `print::WORDINGS` and the engine's `{EDIT_LABELS, BATCH_LABELS}`. `a11y::report_html` takes a language tag and a `words` closure, so a saved report reads the way the panel read, down to its `lang` attribute. -- `spanish_covers_what_the_engine_says` requires an entry for every wording in those lists, and `spanish_keeps_the_holes_in_what_the_engine_says` requires each translation to keep the same holes, so the catalog cannot fall behind the engine without a test saying so. Other languages are free to add the entries when they are translated; until then those sentences show in English as before. +- The translation gaps report (`cargo xtask i18n-report`) counts every wording in those lists, so a catalog that lacks one shows there; until it is added, that sentence shows in English. Catalog validation requires each translation to keep the same holes, and `spanish_keeps_the_holes_in_what_the_engine_says` checks the Spanish ones against the lists themselves. Error details that come from the operating system, and faults rather than mistakes (a file that cannot be read, a lock that was poisoned), still carry their own text and are shown as they are. @@ -94,4 +95,4 @@ Error details that come from the operating system, and faults rather than mistak 2. Add one row to `LANGUAGES` in `crates/ui-egui/src/i18n/mod.rs`: code, native name, catalog and plural rule. 3. Add an `ATTRIBUTION.toml` entry for the catalog (`kind = "translation"`), with the catalog's `sha256sum` as `sha256`, then run `cargo xtask assets --write` (it regenerates `ATTRIBUTION.md` only) and `cargo xtask assets`. -The Preferences dropdown, the system-language match and the catalog tests (format, duplicates, placeholders, plural forms, command ids) then pick it up. +The Preferences dropdown, the system-language match, the catalog tests (format, duplicates, placeholders, plural forms, command ids) and the translation gaps report then pick it up. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6d950a71ca..4ec39848c5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9965,7 +9965,7 @@ milestone = "M14" status = "partial" commands = ["app.preferences"] tools = [] -evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/it/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/it/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/it/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/it/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_what_the_engine_says", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said", "crates/ui-egui/src/i18n/mod.rs::norwegian_bokmal_is_registered_and_persists"] +evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/it/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/tests/it/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/it/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/it/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said", "crates/ui-egui/src/i18n/mod.rs::norwegian_bokmal_is_registered_and_persists"] notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic/Norwegian Bokmål and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar|nb and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Brazilian Portuguese covers the current union of catalog keys, including signature and accessibility messages; Czech remains limited to menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one, and Norwegian locales (nb, nb_NO, and no/nn until a Nynorsk catalog exists) the Bokmål one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish, Italian and Brazilian Portuguese translate them, Polish the signature details and the changes after signing; strings absent from other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is too large to embed whole), the engine's sentences in the other catalogs, a full localization audit." [[feature]] diff --git a/xtask/src/gates.rs b/xtask/src/gates.rs index f64ded0998..0212e165a5 100644 --- a/xtask/src/gates.rs +++ b/xtask/src/gates.rs @@ -113,6 +113,7 @@ pub fn ci(_: &[String]) -> anyhow::Result<()> { ("clippy", Box::new(|| run_args(&["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))), ("cli-no-default-features", Box::new(|| run_args(&["check", "-p", "pdfcraft-cli", "--no-default-features"]))), ("test", Box::new(|| test(&[]))), + ("i18n-report", Box::new(|| i18n_report(&["--workspace".to_string()]))), ("layers", Box::new(|| layers(&[]))), ("wasm", Box::new(|| wasm(&[]))), ("assets", Box::new(|| crate::assets::run(&[]))), @@ -130,6 +131,36 @@ pub fn ci(_: &[String]) -> anyhow::Result<()> { Ok(()) } +/// `cargo xtask i18n-report`: the strings each interface language has no translation for, as +/// counts and the first few keys, from the UI crate's `translation_gaps_report` test. It always +/// passes: a missing string shows in English. `--workspace` runs the test from the workspace +/// test build, which `xtask ci` has just made; without it only the UI crate is built. +pub fn i18n_report(args: &[String]) -> anyhow::Result<()> { + const TEST: &str = "i18n::tests::translation_gaps_report"; + const START: &str = "=== translation gaps ==="; + const END: &str = "=== end of translation gaps ==="; + let scope: &[&str] = if args.iter().any(|a| a == "--workspace") { &["--workspace"] } else { &["-p", "pdfcraft-ui-egui"] }; + let mut c = cargo(); + c.arg("test").args(scope).args(["--lib", "--quiet", "--", TEST, "--exact", "--nocapture"]).stderr(std::process::Stdio::inherit()); + eprintln!("$ cargo test {} --lib --quiet -- {TEST} --exact --nocapture", scope.join(" ")); + let out = match c.output() { + Ok(out) => out, + Err(e) => { + println!("i18n-report: no report, cargo test did not start: {e}"); + return Ok(()); + } + }; + let stdout = String::from_utf8_lossy(&out.stdout); + let report: Vec<&str> = stdout.lines().skip_while(|l| *l != START).skip(1).take_while(|l| *l != END).collect(); + if report.is_empty() { + println!("i18n-report: no report (cargo test exited with {})", out.status); + } + for line in report { + println!("{line}"); + } + Ok(()) +} + /// Dependency licences, bans, sources and advisories (`deny.toml`). Skipped with a hint when /// cargo-deny is not installed, except under CI (`CI` set), where it is required. pub fn deny(_: &[String]) -> anyhow::Result<()> { diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 442fcf8a4e..24ddd16d16 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -23,10 +23,11 @@ const COMMANDS: &[(&str, &str, Command)] = &[ ("deny", "Dependency licences, bans, sources and advisories (deny.toml; needs cargo-deny)", gates::deny), ( "ci", - "fmt --check, clippy -D warnings, the CLI without default features, test, layers, wasm, assets, deny, parity (stops at first failure)", + "fmt --check, clippy -D warnings, the CLI without default features, test, i18n-report, layers, wasm, assets, deny, parity (stops at first failure)", gates::ci, ), ("test", "Workspace tests: cargo nextest (cargo test when it is missing), then doctests", gates::test), + ("i18n-report", "Print the strings each interface language lacks (counts and a few keys); never fails", gates::i18n_report), ("assets", "Enforce the asset policy (AGENTS.md §1) against ATTRIBUTION.toml; --write regenerates ATTRIBUTION.md", assets::run), ("corpus", "Fetch the pinned pdf.js test corpus into corpus/ (git-ignored) and verify it; --update-pin re-records the pin", gates::corpus), ("check", "Robustness sweep over corpus/ with pdfcraft-cli; fails on crashes or regressions vs xtask/baselines", gates::check), From c4a6dd6be373df4a68af51ba6af15a4b4261245f Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:41:52 -0400 Subject: [PATCH 09/15] Assets: don't hash translation catalogs in ATTRIBUTION.toml (#4284) * Assets: translation catalogs aren't hashed The 33 UI translation catalogs are project files under the project licence, and git history records their provenance. Their ATTRIBUTION.toml entries keep title, author, source and licence but carry no sha256, so a PR that changes UI text no longer has to edit ATTRIBUTION.toml. cargo xtask assets accepts a kind = "translation" entry for a .tsv under crates/ui-egui/src/i18n/ without a hash, rejects one that has a hash, and still requires a hash for every other entry. * Assets: a leftover hash on a translation catalog is a note, not an error --- AGENTS.md | 5 ++-- ATTRIBUTION.md | 2 +- ATTRIBUTION.toml | 35 ++---------------------- docs/localization.md | 4 +-- xtask/src/assets.rs | 64 +++++++++++++++++++++++++++++++++++++------- 5 files changed, 63 insertions(+), 47 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f6123cc5c2..2ecfe99a02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,13 +54,14 @@ Anything else is forbidden, including assets whose licence you cannot prove from - path or URL, title, author; - source (URL, with version or commit); - SPDX licence, and the licence file in the repo; - - SHA-256; + - SHA-256, except for translation catalogs (see below); - how it is used. +- **Translation catalogs** (`crates/ui-egui/src/i18n/*.tsv`, `kind = "translation"`) are project files under the project licence. Their entries carry no `sha256`: git history records every change and who made it. Every other kind (fonts, images, data) is hashed. - **`ATTRIBUTION.md`** is generated from it (`cargo xtask assets --write`). Never edit it by hand. - **`NOTICE`** keeps the licence notices that the licences require. - **`cargo xtask assets`** runs in `cargo xtask ci`. It fails if: - an asset file in the repository has no entry; - - an entry's hash does not match; + - an entry's hash is missing or does not match (translation catalogs carry none); - a licence is not on the allowlist; - a licence file is missing; - an image, icon or font names Adobe as author or source, except a font licensed under OFL-1.1 alone. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 19c81bf001..deb37ceb9f 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -2,7 +2,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). +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 the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). ## In this repository (288) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 106c1febff..c0bfada2d1 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,12 +7,13 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1902f1c31698e6adfdaf65b35b1fe1628f5cd2bd0182dd3b99bcf5e21720f394" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). # # [[asset]] a file in this repository (committed or vendored); `sha256` must match the file. +# Translation catalogs (kind = "translation", crates/ui-egui/src/i18n/*.tsv) are project files +# under the project licence and carry no `sha256`: git history records their provenance. # [[bundled]] a file compiled into PdfCraft by a Cargo dependency (its crate version must match Cargo.lock). # [[fetched]] downloaded at build time by xtask (never committed), pinned by `sha256`. # [[build_input]] an optional build input outside this repository (e.g. craft-fonts via CRAFT_FONTS_DIR); @@ -30,7 +31,6 @@ 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 = "2e23c41fcd5b9118cfde03b3a45aa2f988f7eeba650677b6f1f6664e543c7a59" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,6 @@ 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 = "105703f2fbc07c0663308ebd6852199de12a5851a37507d7e51e477eb5e57218" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +51,6 @@ 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 = "b08f8282923d2faaf1f658d2aff8f71fe5f2d3a33e792eeb50fb0e2b396ef24f" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +61,6 @@ 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 = "df17dada3df736e75bd9b03e5ecceec164cce63767e33a1efdb98c56a121c890" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +71,6 @@ 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 = "8e8c0cfda0886c584e5286370ec00814206c3af3699c76665e636fa6fdb6310b" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +81,6 @@ 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 = "3f31e5c9abdb0208d48d8c68aa81437d9d1e10aeb615dedf811fefa539d93279" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +91,6 @@ 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 = "815a3a672421cc3590fb1f267755316659cdb2046e30bbb3ad8b31e96f08b20a" [[asset]] path = "crates/ui-egui/src/i18n/lo.tsv" @@ -107,7 +101,6 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6bd67286795ed487eedf2ce549249c8a83286cb4273490c1349996050b540fed" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -118,7 +111,6 @@ 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 = "9cd63425705fb9a3aa029ff3a5110d0877a34f9038f1d64a2102ccd6c3cb35a6" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -129,7 +121,6 @@ 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 = "76f138293fc12ea3584568a81f6f90a1cedfff6256e78b4f60cb21b1b66bbba4" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -140,7 +131,6 @@ 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 = "0bd75a93f8d18deb832223bd0642cc4f4abde04a322b684a143a9d4b4687a344" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -151,7 +141,6 @@ 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 = "3d539a28afcf8c2e36b7087ac23334b50bac7971e03da20a62b6e7e400763a87" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -162,7 +151,6 @@ 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 = "71b7e8180fc759aebad4660953ab8b55383def0572c1ace2288cd8bfeb1b82dc" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -173,7 +161,6 @@ 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 = "e689fb24a8acfa7e85f0f60644f7e2da1225595e6cd6a94109fbe86082926244" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -184,7 +171,6 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c601ea117ec2a3cdba606fdc9a62220fcffbd0c20c1133bacb653a979cf046a5" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -195,7 +181,6 @@ 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 = "006867b891ae2f995c7a711e55b6401db29d20ab493889c6284dc3671f9faa2d" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -206,7 +191,6 @@ 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 = "9e5b18a09c209525cf08ef53a242ed457cfa346295eef7facde482a75cf72f52" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -217,7 +201,6 @@ 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 = "2306dbbe6a73671fd20df116adb159a7f66c85fbbd1bae5ecef5f6e882d6b7f1" [[asset]] path = "crates/ui-egui/src/i18n/ckb.tsv" @@ -228,7 +211,6 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "864db8aa0a5e443c92e7e388f89349eb0f0e7beda35a0b81695d864dcec51b72" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -239,7 +221,6 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "463a2d28bd5c5ac8df31a5b5c68400d7a61ce51237a58d9337078c973e6f9e3d" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -250,7 +231,6 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bbef7f4e16f83e9e0116278e7a0a175bad79757f96a18dc639516572533f6d5b" [[asset]] path = "crates/ui-egui/src/i18n/hi.tsv" @@ -261,7 +241,6 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "09d2840c02b40a6049b86c2cb68151d863913e8c91c22226f25d07496c6366aa" [[asset]] path = "crates/ui-egui/src/i18n/id.tsv" title = "Indonesian interface translation catalog" @@ -271,7 +250,6 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0dc1abdb262e4568f19ffb2233bfeb3686ae42df8a8d8bec2951351a50e6a886" [[asset]] path = "crates/ui-egui/src/i18n/ko.tsv" title = "Korean interface translation catalog" @@ -281,7 +259,6 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a3e67cee4f3fff776e816523f810b2bb6353235dbdf85181c3f12896604a3339" [[asset]] path = "crates/ui-egui/src/i18n/ro.tsv" @@ -292,7 +269,6 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8e76922ebf2c707bf4a67a2df0940287b560ce012db367dd9c39fbe8e409cc1b" [[asset]] @@ -304,7 +280,6 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "609efa92054aa76de4bb6062c0fcc0a8b450519f4b253b4c0f395b3cbb34d2ba" [[asset]] path = "assets/icons/accessibility.svg" @@ -3426,7 +3401,6 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "57f0dd007ad16518fbc87d8aeac840ef6d86412bb460aff9fb17b176408fb7a8" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3437,7 +3411,6 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a3b8351f0da80c2d394221dbe5d62e794bb019c74c2adfc5d933d999f7d3236f" [[asset]] @@ -3460,7 +3433,6 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "03477c4ab2d0d52543cb91b3aec6f27b2689e6b9bb7c95bbb8463664c75a9dc0" [[asset]] path = "assets/icons/superscript.svg" @@ -3515,7 +3487,6 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ebd177262d8fe20f960f06df084f8a03c9504bcd9990dded3a006e35bd8ef7c9" [[asset]] path = "crates/ui-egui/src/i18n/th.tsv" @@ -3526,7 +3497,6 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1dbb66c05af0aec06d5a6fa154e89f1684203dde32c8e8aedd9aeec6c2d5ae02" [[asset]] path = "crates/ui-egui/src/i18n/ca.tsv" @@ -3537,4 +3507,3 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5879ee3540ffb630fc83d0f2cb293cc6cbf2e081ea4577161a770794e8cbd22c" diff --git a/docs/localization.md b/docs/localization.md index fc161e99a3..8800362378 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -74,7 +74,7 @@ PdfCraft uses the same system as PhotoCraft (`crates/ui-egui/src/i18n/`): - Placeholders (`{name}`) must appear in both columns. A trailing `…` must be kept. `i18n::fmt` fills placeholders in one pass, so a value such as a file name containing `{n}` is inserted as written. - A missing entry never fails a build or a test: that string shows in English. `cargo xtask i18n-report`, a step of `cargo xtask ci`, prints for each language how many strings it has no translation for and the first few. It counts every command, All tools label, literal passed to `tl!` or `i18n::t` in the UI source, engine wording (below) and key of the bundled catalogs, so a new English string shows up there until each catalog has it. - Catalogs are validated strictly: unknown escapes, unknown `@` contexts, placeholder or ellipsis mismatches, wrong plural form counts and duplicates are errors. The tests require the bundled catalogs to have none; at run time a bad line is skipped (and logged) and that string shows in English. -- Catalogs are assets: each `.tsv` has an `ATTRIBUTION.toml` entry with `kind = "translation"` and its SHA-256, checked by `cargo xtask assets`. After editing a catalog, put the new hash into its entry by hand (`sha256sum crates/ui-egui/src/i18n/.tsv`), because `cargo xtask assets --write` does not touch `ATTRIBUTION.toml`; the hash check is deliberate, so a changed asset gets its provenance re-checked. Without it `cargo xtask assets` and the test `assets::tests::the_real_manifest_passes` fail. +- Each `.tsv` has an `ATTRIBUTION.toml` entry with `kind = "translation"` (title, author, source, licence), checked by `cargo xtask assets`. Catalogs are project files under the project licence, so the entry has no `sha256` and editing a catalog doesn't touch `ATTRIBUTION.toml`; git history records their provenance. - Translations are clean-room: write them from the meaning of the English text, never from another product's string tables. ## Sentences the engine words @@ -93,6 +93,6 @@ Error details that come from the operating system, and faults rather than mistak 1. Copy the header of `ja.tsv` into `xx.tsv` and translate entries. 2. Add one row to `LANGUAGES` in `crates/ui-egui/src/i18n/mod.rs`: code, native name, catalog and plural rule. -3. Add an `ATTRIBUTION.toml` entry for the catalog (`kind = "translation"`), with the catalog's `sha256sum` as `sha256`, then run `cargo xtask assets --write` (it regenerates `ATTRIBUTION.md` only) and `cargo xtask assets`. +3. Add an `ATTRIBUTION.toml` entry for the catalog (`kind = "translation"`, no `sha256`), then run `cargo xtask assets --write` (it regenerates `ATTRIBUTION.md` only) and `cargo xtask assets`. The Preferences dropdown, the system-language match, the catalog tests (format, duplicates, placeholders, plural forms, command ids) and the translation gaps report then pick it up. diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index a34a9be4bf..b3393d770a 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -3,7 +3,8 @@ //! `ATTRIBUTION.toml` lists every asset: committed or vendored files (`[[asset]]`), files that //! Cargo dependencies compile into PdfCraft (`[[bundled]]`), and files xtask downloads at build //! time (`[[fetched]]`). This gate fails when: -//! - an asset-like file in the repository has no entry, or its SHA-256 differs; +//! - an asset-like file in the repository has no entry, or its SHA-256 is missing or differs +//! (translation catalogs are project files and carry none); //! - a licence is not on the allowlist, or a declared licence file is missing; //! - an icon, image or font names Adobe as author or source, or an `adobe_data` entry is not on //! the closed list approved in AGENTS.md §1.1; @@ -49,6 +50,11 @@ pub const ADOBE_DATA_ALLOWED: &[(&str, &str)] = &[("hayro-cmap", "assets/cmaps.b /// table has to be re-attested deliberately. pub const ADOBE_DATA_FILES: &[&str] = &["crates/fonts/src/std14_metrics.rs", "vendor/hayro-cmap/assets/cmaps.brotli"]; +/// Where the UI translation catalogs live. `kind = "translation"` entries for `.tsv` files here +/// are project files under the project licence: they carry no `sha256`, because git history +/// already records their provenance (AGENTS.md §1.3). +pub const TRANSLATION_DIR: &str = "crates/ui-egui/src/i18n/"; + /// File extensions that count as assets wherever they appear in the repository. const ASSET_EXTENSIONS: &[&str] = &[ "png", "jpg", "jpeg", "gif", "svg", "ico", "icns", "webp", "bmp", "tif", "tiff", "avif", "heic", "ttf", "otf", "ttc", "woff", "woff2", "pfb", @@ -97,11 +103,20 @@ pub struct Asset { pub licence_file: String, pub kind: String, pub usage: String, - pub sha256: String, + /// Required for every entry except a translation catalog ([`Asset::is_translation_catalog`]). + #[serde(default)] + pub sha256: Option, #[serde(default)] pub adobe_data: bool, } +impl Asset { + /// A UI translation catalog: a project file whose entry carries no hash. + pub fn is_translation_catalog(&self) -> bool { + self.kind == "translation" && self.path.starts_with(TRANSLATION_DIR) && self.path.ends_with(".tsv") + } +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub struct Bundled { @@ -162,6 +177,9 @@ pub fn run(args: &[String]) -> Result<()> { std::fs::write(root.join("ATTRIBUTION.md"), &markdown)?; println!("assets: wrote ATTRIBUTION.md"); } + for a in manifest.asset.iter().filter(|a| a.is_translation_catalog() && a.sha256.is_some()) { + println!("assets: note: {}: translation catalogs aren't hashed; its sha256 line can be deleted", a.path); + } let mut problems = check(&root, &manifest, &repo_files(&root)?, &cargo_lock_versions(&root)?); match std::fs::read_to_string(root.join("ATTRIBUTION.md")) { Ok(existing) if existing == markdown => {} @@ -257,10 +275,16 @@ pub fn check(root: &Path, m: &Manifest, repo_files: &[String], lock: &BTreeSet<( if a.adobe_data && !(ADOBE_DATA_FILES.contains(&a.path.as_str()) && a.kind == "data") { problems.push(format!("{}: adobe_data is only allowed for the closed list in AGENTS.md §1.1", a.path)); } - match std::fs::read(root.join(&a.path)) { - Ok(bytes) if sha256_hex(&bytes) == a.sha256 => {} - Ok(_) => problems.push(format!("{}: SHA-256 does not match ATTRIBUTION.toml (re-verify provenance, then update the entry)", a.path)), - Err(_) => problems.push(format!("{}: listed but missing", a.path)), + let catalog = a.is_translation_catalog(); + match (std::fs::read(root.join(&a.path)), &a.sha256) { + (Err(_), _) => problems.push(format!("{}: listed but missing", a.path)), + // A leftover hash line (e.g. kept while resolving a rebase) is not checked; `run` notes it. + (Ok(_), _) if catalog => {} + (Ok(_), None) => problems.push(format!("{}: sha256 is missing (only translation catalogs go without one)", a.path)), + (Ok(bytes), Some(hash)) if sha256_hex(&bytes) == *hash => {} + (Ok(_), Some(_)) => { + problems.push(format!("{}: SHA-256 does not match ATTRIBUTION.toml (re-verify provenance, then update the entry)", a.path)) + } } } for f in repo_files { @@ -378,7 +402,7 @@ pub fn render_markdown(m: &Manifest) -> String { s.push_str("\n\n"); s.push_str("Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. "); s.push_str( - "The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). ", + "The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). ", ); s.push_str("Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).\n\n"); let esc = |t: &str| t.replace('|', "\\|"); @@ -463,7 +487,7 @@ mod tests { licence_file: "Cargo.toml".into(), kind: kind.into(), usage: "u".into(), - sha256: sha256_hex(&std::fs::read(root().join(path)).unwrap_or_default()), + sha256: Some(sha256_hex(&std::fs::read(root().join(path)).unwrap_or_default())), adobe_data: false, } } @@ -530,6 +554,28 @@ mod tests { assert!(check(&root(), &m, &[], &lock(&[])).iter().any(|p| p.contains("from Adobe is forbidden"))); } + /// One `[[asset]]` entry as ATTRIBUTION.toml spells it, with `extra` lines appended. + fn parsed(path: &str, kind: &str, extra: &str) -> Manifest { + let text = format!( + "[[asset]]\npath = \"{path}\"\ntitle = \"t\"\nauthor = \"a\"\nsource = \"s\"\nlicence = \"MIT\"\nlicence_file = \"Cargo.toml\"\nkind = \"{kind}\"\nusage = \"u\"\n{extra}" + ); + toml::from_str(&text).unwrap() + } + + /// Catalogs change in most UI PRs; a pinned hash would make every pair of them conflict. + #[test] + fn translation_catalogs_carry_no_hash_and_everything_else_does() { + let check_one = |m: Manifest| check(&root(), &m, &[], &lock(&[])); + let ja = "crates/ui-egui/src/i18n/ja.tsv"; + assert_eq!(check_one(parsed(ja, "translation", "")), Vec::::new()); + let stale = format!("sha256 = \"{}\"\n", "0".repeat(64)); + assert_eq!(check_one(parsed(ja, "translation", &stale)), Vec::::new(), "a leftover hash is not checked"); + for (path, kind) in [("assets/fonts/Inter-Regular.ttf", "font"), (ja, "data"), ("Cargo.toml", "translation")] { + let p = check_one(parsed(path, kind, "")); + assert!(p.iter().any(|p| p.contains("sha256 is missing")), "{path} {kind}: {p:?}"); + } + } + #[test] fn adobe_ofl_fonts_are_allowed_and_other_adobe_fonts_rejected() { let l = lock(&[("noto", "1.0.0")]); @@ -562,7 +608,7 @@ mod tests { #[test] fn changed_file_fails_hash_check() { let mut a = asset("assets/icons/x.svg", "Lucide", "icon", "ISC"); - a.sha256 = "0".repeat(64); + a.sha256 = Some("0".repeat(64)); let p = check(&root(), &Manifest { asset: vec![a], ..Default::default() }, &[], &lock(&[])); assert!(p.iter().any(|p| p.contains("SHA-256 does not match")), "{p:?}"); } From a2ec27b4775f53c40cad3456936d9406611e3a8d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 17:06:26 -0400 Subject: [PATCH 10/15] ci(sonar): don't cancel superseded runs on main, so coverage is published (#4321) --- .github/workflows/sonarcloud.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index ecee45a6d8..e78fe1ea3b 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -11,7 +11,8 @@ permissions: concurrency: group: sonarcloud-${{ github.ref }} - cancel-in-progress: true + # A push to main is the run that publishes coverage; superseded runs there would never finish. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: sonarcloud: From 0c748e34a8eee539a2308a1f2f98d465003179c1 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:17:12 -0400 Subject: [PATCH 11/15] fix(render): bound xref streams, JBIG2 referred-to counts, saved graphics states and /W2 runs (#4350) * fix(render): bound xref streams, JBIG2 referred-to counts, saved graphics states and /W2 runs Input bounds and crash fixes in the vendored hayro crates, ported from the snapshot craft-libs carries (storytold/craft-libs#176, #239, #241): - hayro-syntax: an xref stream subsection must be backed by its decoded data. All-zero /W widths and a count longer than the data are refused before any entry is inserted; a tiny stream with a large /Size or /Index count inserted that many entries. - hayro-syntax: a subsection running past object number u32::MAX is refused; start + i overflowed. - hayro-syntax: the xref stream type field is read big-endian at its full /W[0] width, and /W[0] over 8 bytes is refused like /W[1]. - hayro-jbig2: the long-form referred-to segment count is capped at u16::MAX before its retention bytes are read; the 29-bit count went straight to Vec::with_capacity (about 2 GiB) and aborted the process. - hayro-interpret: at most 4096 saved graphics states holding at most 64 MiB together. A q past either limit is counted but not copied, and its Q still removes the clips added since. - hayro-interpret: a /W2 run whose first CID is past MAX_CID stops there, as /W does; a first CID of 4294967295 discarded the whole /W2 table. Each patch is marked in the source, has a regression test in its crate, and is listed in vendor/README.md. * fix(render): a binary usecmap counts against the CMap nesting limit; /W2 checks the CID first - hayro-cmap: a binary CMap's usecmap passed its own depth to the nested parse, so a self-referencing binary CMap recursed until the stack overflowed. It now counts like a text usecmap (ledger hayro-cmap 2). - hayro-interpret: the /W2 first-CID check runs before the run's metrics are read, so an incomplete run past MAX_CID no longer discards the rest of the table (ledger 52). * fix(render): refuse xref-stream object numbers past i32::MAX ObjectIdentifier stores an i32; a number between i32::MAX and u32::MAX was cast to a negative object number (ledger hayro-syntax 26). --- vendor/README.md | 8 +- vendor/hayro-cmap/src/bcmap/mod.rs | 5 +- vendor/hayro-cmap/src/lib.rs | 11 ++ vendor/hayro-interpret/src/context.rs | 153 ++++++++++++++++++++++++- vendor/hayro-interpret/src/font/cid.rs | 27 +++++ vendor/hayro-jbig2/src/segment.rs | 42 +++++++ vendor/hayro-syntax/src/xref.rs | 109 ++++++++++++++++-- 7 files changed, 336 insertions(+), 19 deletions(-) diff --git a/vendor/README.md b/vendor/README.md index c5660972f0..cc6c29b4b4 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -17,15 +17,15 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it; (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours` |; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name` | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it; (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours` |; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret) | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path` |; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients` | -| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` | +| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | -| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references` | +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`) | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::egui_wgpu_carries_the_surface_size_fit`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error`, which keeps it around `add_surface`'s configure | | vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | -| hayro-cmap | 0.1.0 | Apache-2.0 OR MIT | malformed parts of a CMap (bad bfchar/bfrange entries, unresolvable `usecmap`, unparseable tail) are skipped instead of discarding the whole map, so search and redaction find text the edit layer can read (#3177) | `hayro-cmap::tests::bad_bfchar_entry_is_skipped`, `unresolvable_usecmap_is_skipped` | +| hayro-cmap | 0.1.0 | Apache-2.0 OR MIT | (1) malformed parts of a CMap (bad bfchar/bfrange entries, unresolvable `usecmap`, unparseable tail) are skipped instead of discarding the whole map, so search and redaction find text the edit layer can read (#3177); (2) a binary CMap's `usecmap` counts against the 16-level nesting limit as a text one does, where it passed its own depth and a self-referencing binary CMap recursed until the stack overflowed (from storytold/craft-libs#254) | (1) `hayro-cmap::tests::bad_bfchar_entry_is_skipped`, `unresolvable_usecmap_is_skipped`; (2) `tests::self_referencing_binary_usecmap_stops_at_the_nesting_limit` | Renderer startup errors: `egui-wgpu::RenderState::create` also wraps `Renderer::new` in `winit::catch_errors` when the winit feature is enabled. A rejected shader or render pipeline diff --git a/vendor/hayro-cmap/src/bcmap/mod.rs b/vendor/hayro-cmap/src/bcmap/mod.rs index 997bc00d85..e20c45903f 100644 --- a/vendor/hayro-cmap/src/bcmap/mod.rs +++ b/vendor/hayro-cmap/src/bcmap/mod.rs @@ -109,10 +109,13 @@ pub(crate) fn parse<'a>( SEGMENT_USECMAP => { let base_data = get_cmap(CMapName::from_bytes(payload))?; + // PdfCraft patch: a binary `usecmap` counts against the nesting limit as a text + // one does; passing the same depth let a self-referencing CMap recurse until the + // stack overflowed. base = Some(Box::new(parse::parse_inner( base_data, get_cmap.clone(), - depth, + depth + 1, )?)); } SEGMENT_WMODE => { diff --git a/vendor/hayro-cmap/src/lib.rs b/vendor/hayro-cmap/src/lib.rs index fb42ff37ec..2ef3c6d190 100644 --- a/vendor/hayro-cmap/src/lib.rs +++ b/vendor/hayro-cmap/src/lib.rs @@ -737,6 +737,17 @@ endcodespacerange CMap::parse(&data, |_| None).unwrap() } + /// PdfCraft patch test: a binary CMap whose `usecmap` names itself stops at the nesting limit. + #[cfg(feature = "embed-cmaps")] + #[test] + fn self_referencing_binary_usecmap_stops_at_the_nesting_limit() { + // Header (magic, version, file length 19), then one usecmap segment (length 9) naming "Self". + const SELF_REF: &[u8] = &[ + b'b', b'c', b'm', b'a', b'p', 1, 0, 0, 0, 19, 0x09, 0, 0, 0, 9, b'S', b'e', b'l', b'f', + ]; + assert!(CMap::parse(SELF_REF, |_| Some(SELF_REF)).is_none()); + } + #[test] fn metadata_parsing() { let data = br#" diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index 39806fcd80..3670f46fec 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -169,6 +169,30 @@ pub struct Context<'a> { pub(crate) xref: &'a XRef, pub(crate) ocg_state: OcgState, nesting_depth: u32, + /// PdfCraft patch: estimated bytes held by saved states, and for each `q` not saved because + /// a limit was reached, the clip count to return to at its `Q`. + saved_bytes: usize, + skipped_saves: Vec, +} + +/// PdfCraft patch: most graphics states saved at once. Every `q` copies the current state, dash +/// array and clip list included, so without a limit short operators multiply one large state. +const MAX_SAVED_STATES: usize = 4096; +/// PdfCraft patch: most bytes all saved states may hold together. +const MAX_SAVED_STATE_BYTES: usize = 64 << 20; + +/// Roughly what a saved copy of `state` holds. +fn state_bytes(state: &State<'_>) -> usize { + size_of::>() + .saturating_add( + state + .graphics_state + .stroke_props + .dash_array + .len() + .saturating_mul(size_of::()), + ) + .saturating_add(state.clips.len().saturating_mul(size_of::())) } impl<'a> Context<'a> { @@ -223,15 +247,33 @@ impl<'a> Context<'a> { interpreter_cache: cache.clone(), ocg_state, nesting_depth, + saved_bytes: 0, + skipped_saves: Vec::new(), } } pub(crate) fn save_state(&mut self) { - let Some(cur) = self.states.last().cloned() else { + let Some(cur) = self.states.last() else { warn!("attempted to save state without existing state"); return; }; + // PdfCraft patch: past either limit the `q` is recorded but nothing is copied. Its `Q` + // still removes the clips added since; other changes made in between are kept. + let bytes = state_bytes(cur); + if !self.skipped_saves.is_empty() + || self.states.len() >= MAX_SAVED_STATES + || self.saved_bytes.saturating_add(bytes) > MAX_SAVED_STATE_BYTES + { + if self.skipped_saves.is_empty() { + warn!("too many saved graphics states; further saves are not kept"); + } + self.skipped_saves.push(cur.clips.len()); + return; + } + + let cur = cur.clone(); + self.saved_bytes = self.saved_bytes.saturating_add(bytes); self.states.push(cur); } @@ -308,6 +350,14 @@ impl<'a> Context<'a> { } pub(crate) fn restore_state(&mut self, device: &mut impl Device<'a>) { + // PdfCraft patch: a save that was not kept is the most recent one. + if let Some(target_clips) = self.skipped_saves.pop() { + while self.get().clips.len() > target_clips { + self.pop_clip_path(device); + } + return; + } + let Some(target_clips) = self .states .get(self.states.len().saturating_sub(2)) @@ -324,6 +374,10 @@ impl<'a> Context<'a> { // The first state should never be popped. if self.states.len() > 1 { self.states.pop(); + // PdfCraft patch: the state now on top is the copy that was charged when saved. + if let Some(top) = self.states.last() { + self.saved_bytes = self.saved_bytes.saturating_sub(state_bytes(top)); + } } device.set_soft_mask( @@ -395,7 +449,8 @@ impl<'a> Context<'a> { } pub(crate) fn num_states(&self) -> usize { - self.states.len() + // PdfCraft patch: saves not kept still count, so callers restore back past them. + self.states.len().saturating_add(self.skipped_saves.len()) } pub(crate) fn nesting_depth(&self) -> u32 { @@ -517,3 +572,97 @@ pub(crate) fn path_as_rect(path: &BezPath) -> Option { Some(path.fast_bounding_box()) } + +/// PdfCraft patch tests (ported from craft-libs): the graphics-state stack is bounded by count +/// and by bytes, and every `q` still pairs with its `Q`. +#[cfg(test)] +mod saved_state_tests { + use super::*; + use crate::device::DummyDevice; + use hayro_syntax::Pdf; + + fn pdf() -> Pdf { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Count 0 /Kids [] >>", + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(b"xref\n0 3\n0000000000 65535 f \n"); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice( + format!("trailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes(), + ); + Pdf::new(out).expect("synthetic pdf") + } + + fn triangle() -> BezPath { + let mut path = BezPath::new(); + path.move_to((10.0, 10.0)); + path.line_to((90.0, 10.0)); + path.line_to((50.0, 90.0)); + path.close_path(); + path + } + + /// Saves `saves` more times, checks a clip added after the last save goes at its `Q`, and unwinds. + fn save_and_unwind(ctx: &mut Context<'_>, saves: usize) { + let before = ctx.num_states(); + for _ in 0..saves { + ctx.save_state(); + } + assert!(ctx.states.len() <= MAX_SAVED_STATES); + assert!(ctx.saved_bytes <= MAX_SAVED_STATE_BYTES); + assert_eq!(ctx.num_states(), before + saves, "every q still counts"); + + let mut device = DummyDevice; + let clips = ctx.get().clips.len(); + ctx.push_clip_path(triangle(), FillRule::NonZero, &mut device); + assert_eq!(ctx.get().clips.len(), clips + 1); + ctx.restore_state(&mut device); + assert_eq!(ctx.get().clips.len(), clips); + + while ctx.num_states() > 1 { + ctx.restore_state(&mut device); + } + assert_eq!(ctx.states.len(), 1); + assert!(ctx.skipped_saves.is_empty()); + assert_eq!(ctx.saved_bytes, 0); + } + + #[test] + fn repeated_saves_stay_bounded_and_balanced() { + let pdf = pdf(); + let cache = InterpreterCache::new(); + let bbox = Rect::new(0.0, 0.0, 100.0, 100.0); + let settings = InterpreterSettings::default(); + let mut ctx = Context::new(Affine::IDENTITY, bbox, &cache, pdf.xref(), settings); + + // A small state reaches the count limit. + save_and_unwind(&mut ctx, 20_000); + + // A large one (a 64 Ki-entry dash array, 256 KiB a copy) reaches the byte limit first. + ctx.get_mut().graphics_state.stroke_props.dash_array = (0..1 << 16).map(|_| 2.0).collect(); + ctx.save_state(); + assert_eq!(ctx.states.len(), 2); + while ctx.skipped_saves.is_empty() { + ctx.save_state(); + } + assert!(ctx.states.len() < MAX_SAVED_STATES); + save_and_unwind(&mut ctx, 1000); + + // Once unwound, a kept save restores what changed after it. + let mut device = DummyDevice; + ctx.save_state(); + ctx.pre_concat_affine(Affine::scale(2.0)); + ctx.restore_state(&mut device); + assert_eq!(ctx.get().ctm, Affine::IDENTITY); + } +} diff --git a/vendor/hayro-interpret/src/font/cid.rs b/vendor/hayro-interpret/src/font/cid.rs index e23b2433d3..ee4cc2412f 100644 --- a/vendor/hayro-interpret/src/font/cid.rs +++ b/vendor/hayro-interpret/src/font/cid.rs @@ -618,6 +618,12 @@ fn read_widths2(arr: &Array<'_>) -> Option> { let mut iter = range.iter::(); while let Some(w) = iter.next() { + // PdfCraft patch: as in `read_widths`, checked before the run's metrics are + // read. Without it, a run starting at 4294967295 overflowed `first` and + // discarded the whole /W2 table. + if first > MAX_CID { + break; + } let v1 = iter.next()?; let v2 = iter.next()?; map.insert(first, [w, v1, v2]); @@ -669,3 +675,24 @@ fn read_encoding(object: &Object<'_>, cmap_resolver: &CMapResolverFn) -> Option< _ => None, } } + +/// PdfCraft patch test (ported from craft-libs): a /W2 run whose first CID is past `MAX_CID` +/// is skipped and the rest of the table kept. +#[cfg(test)] +mod tests { + use super::read_widths2; + use hayro_syntax::object::{Array, FromBytes}; + + #[test] + fn w2_run_past_the_largest_cid_keeps_the_rest_of_the_table() { + let arr = + Array::from_bytes(b"[4294967295 [1000 500] 10 [900 450 860] 20 30 800 400 870]") + .unwrap(); + let map = read_widths2(&arr).unwrap(); + + assert_eq!(map.get(&4_294_967_295), None); + assert_eq!(map.get(&10), Some(&[900.0, 450.0, 860.0])); + assert_eq!(map.get(&25), Some(&[800.0, 400.0, 870.0])); + assert_eq!(map.len(), 12); + } +} diff --git a/vendor/hayro-jbig2/src/segment.rs b/vendor/hayro-jbig2/src/segment.rs index 35c17c38af..db720df320 100644 --- a/vendor/hayro-jbig2/src/segment.rs +++ b/vendor/hayro-jbig2/src/segment.rs @@ -8,6 +8,12 @@ use alloc::vec::Vec; use crate::error::{ParseError, Result, SegmentError, bail, err}; use crate::reader::Reader; +/// PdfCraft patch: the most segments one segment may refer to. A long-form header can claim a +/// 29-bit count, which `Vec::with_capacity` below would turn into a multi-gigabyte reservation +/// before a single referred-to number is read; the allocation failure aborts the process. Real +/// files refer to a handful of segments. +pub(crate) const MAX_REFERRED_TO_SEGMENTS: u32 = u16::MAX as u32; + /// "The segment type is a number between 0 and 63, inclusive. Not all values /// are allowed." (7.3) #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -183,6 +189,12 @@ pub(crate) fn parse_segment_header(reader: &mut Reader<'_>) -> Result MAX_REFERRED_TO_SEGMENTS { + bail!(SegmentError::InvalidReferredCount); + } + // Skip retention flag bytes in long form. // "The first one-byte field following the initial four-byte field is formatted // as follows: Bit 0: Retain bit for this segment. Bit 1-7: Retain bits for @@ -416,4 +428,34 @@ mod tests { assert_eq!(header.data_length, Some(32)); } + + /// PdfCraft patch test (ported from craft-libs): a long-form referred-to count over + /// `MAX_REFERRED_TO_SEGMENTS` is refused before its retention bytes are read. + #[test] + fn test_referred_to_count_is_capped() { + use crate::error::DecodeError; + + let header = |count: u32| { + let mut data = vec![ + 0x00, 0x01, 0x00, 0x00, // Segment number = 65536 + 0x86, // Flags: type 6, page assoc 1 byte, deferred non-retain + ]; + // Long form: bits 29-31 are 7, bits 0-28 the count. + data.extend_from_slice(&(0xE000_0000 | count).to_be_bytes()); + parse_segment_header(&mut Reader::new(&data)).unwrap_err() + }; + + // The 29-bit maximum, 536,870,911, would reserve about 2 GiB. + for count in [0x1FFF_FFFF, MAX_REFERRED_TO_SEGMENTS + 1] { + assert!(matches!( + header(count), + DecodeError::Segment(SegmentError::InvalidReferredCount) + )); + } + // At the cap the header is read on and ends at its missing retention bytes. + assert!(matches!( + header(MAX_REFERRED_TO_SEGMENTS), + DecodeError::Parse(ParseError::UnexpectedEof) + )); + } } diff --git a/vendor/hayro-syntax/src/xref.rs b/vendor/hayro-syntax/src/xref.rs index 2d9df169bd..745af95556 100644 --- a/vendor/hayro-syntax/src/xref.rs +++ b/vendor/hayro-syntax/src/xref.rs @@ -965,17 +965,13 @@ fn populate_from_xref_stream<'a>( let [f1_len, f2_len, f3_len] = stream.dict().get::<[u8; 3]>(W)?; - if f2_len > size_of::() as u8 { - error!("xref offset length is larger than the allowed limit"); + // PdfCraft patch: the type field is read at its full width, so it is bounded like the offset. + if f1_len > size_of::() as u8 || f2_len > size_of::() as u8 { + error!("xref field length is larger than the allowed limit"); return None; } - // Do such files exist? - if f1_len != 1 { - warn!("first field in xref stream was longer than 1"); - } - let xref_data = stream.decoded().ok()?; // PdfCraft patch: (#2366) this section's entries are read first, for `prev_offset`. let mut own = XrefMap::default(); @@ -1042,15 +1038,38 @@ fn xref_stream_subsection<'a>( f3_len: u8, insert_map: &mut XrefMap, ) -> Option<()> { + // PdfCraft patch: every entry must be backed by stream bytes. With all widths zero an entry + // reads nothing, so a tiny stream could declare billions of entries; and a count past the + // decoded data is refused before any of it is inserted. + let width = f1_len as usize + f2_len as usize + f3_len as usize; + if width == 0 { + warn!("xref stream entries have no width"); + + return None; + } + let available = xref_reader.len().saturating_sub(xref_reader.offset()); + if (num_elements as usize) + .checked_mul(width) + .is_none_or(|need| need > available) + { + warn!("xref stream subsection declares more entries than its data holds"); + + return None; + } + for i in 0..num_elements { + // A zero-width type field defaults to 1 (ISO 32000-2 §7.5.8.2, `/W`). let f_type = if f1_len == 0 { 1 } else { - // We assume a length of 1. - xref_reader.read_bytes(1)?[0] + // PdfCraft patch: the type field is read big-endian at its full `/W[0]` width. + let bytes = xref_reader.read_bytes(f1_len as usize)?; + bytes.iter().fold(0u64, |acc, b| (acc << 8) | u64::from(*b)) }; - let obj_number = start + i; + // PdfCraft patch: object numbers are stored as `i32`; a subsection running past + // `i32::MAX` is refused instead of overflowing or turning negative. + let obj_number = i32::try_from(start.checked_add(i)?).ok()?; match f_type { // We don't care about free objects. @@ -1073,7 +1092,7 @@ fn xref_stream_subsection<'a>( }; insert_map.insert( - ObjectIdentifier::new(obj_number as i32, gen_number as i32), + ObjectIdentifier::new(obj_number, gen_number as i32), EntryType::Normal(offset as usize), ); } @@ -1091,7 +1110,7 @@ fn xref_stream_subsection<'a>( }; insert_map.insert( - ObjectIdentifier::new(obj_number as i32, gen_number), + ObjectIdentifier::new(obj_number, gen_number), EntryType::ObjStream(obj_stream_number, index), ); } @@ -1248,6 +1267,72 @@ fn parse_metadata(info_dict: &Dict<'_>) -> Metadata { mod tests { use super::*; + // PdfCraft patch tests (ported from craft-libs): xref stream entries are bounded by the + // decoded data, and the type field is read at its full width. + #[test] + fn xref_stream_entries_need_backing_bytes() { + let mut map = XrefMap::default(); + // All widths zero: an entry reads nothing, so none may be declared. + let mut empty = Reader::new(&[]); + assert!(xref_stream_subsection(&mut empty, 0, u32::MAX, 0, 0, 0, &mut map).is_none()); + assert!(map.is_empty()); + // Three entries of width 3 need nine bytes; eight are refused before any insert. + let data = [1, 0, 9, 1, 0, 9, 1, 0]; + let mut short = Reader::new(&data); + assert!(xref_stream_subsection(&mut short, 0, 3, 1, 1, 1, &mut map).is_none()); + assert!(map.is_empty()); + let mut whole = Reader::new(&data[..6]); + assert!(xref_stream_subsection(&mut whole, 0, 2, 1, 1, 1, &mut map).is_some()); + assert_eq!(map.len(), 2); + // A subsection running past object number i32::MAX is refused. + for start in [i32::MAX as u32, u32::MAX] { + let mut past_end = XrefMap::default(); + let mut wrap = Reader::new(&data[..6]); + assert!(xref_stream_subsection(&mut wrap, start, 2, 1, 1, 1, &mut past_end).is_none()); + assert!(past_end.keys().all(|id| id.obj_number >= 0)); + } + } + + #[test] + fn xref_stream_type_field_uses_the_full_w0_width() { + // /W [2 1 1]: two-byte type 0x0001 (normal), offset 7, gen 0. + let mut map = XrefMap::default(); + let mut reader = Reader::new(&[0u8, 1, 7, 0]); + assert!(xref_stream_subsection(&mut reader, 5, 1, 2, 1, 1, &mut map).is_some()); + assert!(matches!( + map.get(&ObjectIdentifier::new(5, 0)), + Some(EntryType::Normal(7)) + )); + } + + #[test] + fn xref_stream_loader_rejects_oversized_w0() { + // One normal entry (offset 7, gen 0) whose type field is `type_len` bytes wide, so only + // the width of /W[0] differs between the two streams. + let load = |type_len: usize| { + let mut entry = vec![0u8; type_len]; + entry[type_len - 1] = 1; + entry.extend_from_slice(&[7, 0]); + let mut data = format!( + "1 0 obj\n<< /Type /XRef /Size 6 /Index [5 1] /W [{type_len} 1 1] /Length {} >>\nstream\n", + entry.len() + ) + .into_bytes(); + data.extend_from_slice(&entry); + data.extend_from_slice(b"\nendstream\nendobj\n"); + let mut map = XrefMap::default(); + let got = populate_from_xref_stream( + &data, + &mut Reader::new(&data), + &mut map, + &mut BTreeSet::new(), + ); + got.is_some() + }; + assert!(load(8)); + assert!(!load(9)); + } + // Contributor-original, MIT OR Apache-2.0. Matches the bounded, uncompressed // synthetic regression used to reproduce the cold-publication race. A second // stream optionally checks that racing registration never aliases two ids. From a047c6ba0983464647953169e9a3e34237dc8ff2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 23:28:18 -0400 Subject: [PATCH 12/15] chore: resolve SonarCloud maintainability issues (#4356) * chore: resolve SonarCloud maintainability issues * fix(packaging): use verify_sha256 helper function * style: cargo fmt * fix: restore wildcard imports and fix method-reference type errors Revert the glob-import expansions (they missed SomIndices and left unused imports), revert char::is_ascii_digit where the iterator yields char by value, and pass the content slice to prune_doomed_xobjects as &[Vec]. Co-Authored-By: Claude Opus 5.5 * Keep XObject decodability check without binding content in interp --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Dylan Taylor --- apps/pdfcraft-cli/src/main.rs | 4 ++-- crates/a11y/src/content.rs | 21 ++++++++-------- crates/a11y/src/tests.rs | 2 +- crates/annot/src/lib.rs | 4 ++-- crates/automation/src/batch.rs | 4 ++-- crates/automation/src/lib.rs | 2 +- crates/create/src/lib.rs | 2 +- crates/edit/src/text_regions.rs | 6 ++--- crates/engine/src/lib.rs | 6 ++--- crates/engine/src/ocr.rs | 2 +- crates/engine/src/text_fonts.rs | 2 +- crates/engine/src/xfa.rs | 2 +- crates/export/src/lib.rs | 4 ++-- crates/fonts/src/std14.rs | 2 +- crates/forms/src/author.rs | 2 +- crates/forms/src/calc_order.rs | 18 ++++++++------ crates/forms/src/detect.rs | 2 +- crates/forms/src/table.rs | 11 ++++++--- crates/js/src/formcalc.rs | 4 ++-- crates/js/src/xfa.rs | 2 +- crates/organize/src/tests.rs | 2 +- crates/platform/src/staging.rs | 2 +- crates/preflight/src/xmp.rs | 4 ++-- crates/redact/src/interp.rs | 25 +++++++++++-------- crates/redact/src/patterns.rs | 2 +- crates/redact/src/tags.rs | 2 +- crates/render/examples/page_timing.rs | 2 +- crates/render/src/raster.rs | 6 ++--- crates/render/src/raster/work_queue.rs | 2 +- crates/render/src/text.rs | 10 ++++---- crates/sign/src/cms.rs | 33 +++++++++++++++----------- crates/ui-egui/src/files.rs | 31 ++++++++++++------------ crates/ui-egui/src/protect.rs | 4 ++-- crates/ui-egui/src/recovery.rs | 6 ++--- crates/ui-egui/tests/it/animate.rs | 2 +- crates/ui-egui/tests/it/recovery.rs | 4 +++- crates/ui-egui/tests/it/xfa.rs | 2 +- crates/ui-egui/tests/perf.rs | 2 +- crates/xfa/src/data.rs | 7 ++---- crates/xfa/src/text.rs | 2 +- packaging/linux/package.sh | 14 +++++++---- xtask/src/assets.rs | 2 +- xtask/src/layers.rs | 2 +- 43 files changed, 146 insertions(+), 124 deletions(-) diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 14500a3847..d0f23242d6 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -644,7 +644,7 @@ fn render(args: &[String]) -> Result<(), CliError> { let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("96"))?; let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?; // The file is what its name says (#248): a `.png` used to get a netpbm PAM stream. - let format = match Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() { + let format = match Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).as_deref() { Some("png") => Some(ImageFormat::Png), Some("jpg" | "jpeg") => Some(ImageFormat::Jpeg { quality: 90 }), Some("tif" | "tiff") => Some(ImageFormat::Tiff), @@ -779,7 +779,7 @@ fn collect(paths: &[&str]) -> Vec { if let Ok(rd) = std::fs::read_dir(&p) { stack.extend(rd.flatten().map(|e| e.path())); } - } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) { + } else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) { out.push(p); } } diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index fd5f0d305f..d548c57f9d 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -53,6 +53,15 @@ struct Walk<'a> { budget: DecodeBudget, } +fn bdc_marks(doc: &Document, op: &pdfcraft_content::Op, props: &Dict) -> bool { + op.name(0) == Some(b"Artifact") + || match op.operands.get(1) { + Some(Object::Dict(d)) => d.contains(b"MCID"), + Some(Object::Name(n)) => props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().map(|d| d.contains(b"MCID"))).unwrap_or(false), + _ => false, + } +} + impl Walk<'_> { fn sub(&self, res: &Dict, key: &[u8]) -> Dict { res.get(key).map(|x| self.doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default() @@ -68,17 +77,7 @@ impl Walk<'_> { let inside = covered || stack.last().copied().unwrap_or(false); match op.op.as_slice() { b"BMC" => stack.push(inside || op.name(0) == Some(b"Artifact")), - b"BDC" => { - let marks = op.name(0) == Some(b"Artifact") - || match op.operands.get(1) { - Some(Object::Dict(d)) => d.contains(b"MCID"), - Some(Object::Name(n)) => { - props.get(n).map(|x| self.doc.resolve(x)).and_then(|x| x.as_dict().map(|d| d.contains(b"MCID"))).unwrap_or(false) - } - _ => false, - }; - stack.push(inside || marks); - } + b"BDC" => stack.push(inside || bdc_marks(self.doc, &op, &props)), b"EMC" => { stack.pop(); } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index be46ef61c1..a6dcdba199 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -380,7 +380,7 @@ fn table_doc(th: &str, extra: &[&str], kids: &str) -> Document { th.to_string(), "<< /S /TD >>".to_string(), ]; - objs.extend(extra.iter().map(|s| s.to_string())); + objs.extend(extra.iter().map(ToString::to_string)); pdf(&objs, "") } diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 720d39cd80..09a081c438 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1010,7 +1010,7 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> { grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0) } Shape::Ink { strokes } | Shape::Signature { strokes } => { - if strokes.iter().all(|s| s.is_empty()) || !strokes.iter().flatten().all(|p| finite(p)) { + if strokes.iter().all(Vec::is_empty) || !strokes.iter().flatten().all(|p| finite(p)) { return Err(bad("drawing (no points)")); } // Strokes of three or more points are drawn as curves, which stay within their @@ -1221,7 +1221,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R d.set(b"PCOutline".to_vec(), Object::Array(list)); } Shape::CustomStamp { name, picture, image, size, .. } => { - let clean: String = name.chars().filter(|c| c.is_ascii_alphanumeric()).collect(); + let clean: String = name.chars().filter(char::is_ascii_alphanumeric).collect(); d.set(b"Name".to_vec(), Object::name(&format!("PCCustom{clean}"))); d.set(b"PCPicture".to_vec(), Object::Ref(*picture)); d.set(b"PCPictureImage".to_vec(), Object::Bool(*image)); diff --git a/crates/automation/src/batch.rs b/crates/automation/src/batch.rs index 99310832fb..47cf714ec9 100644 --- a/crates/automation/src/batch.rs +++ b/crates/automation/src/batch.rs @@ -58,7 +58,7 @@ impl<'a> Recipe<'a> { } fn accepts(&self, path: &Path) -> bool { - let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("").to_ascii_lowercase(); + let extension = path.extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase(); extension == "pdf" || (self.steps.contains(&"convert") && pdfcraft_engine::CONVERTIBLE.contains(&extension.as_str())) } @@ -70,7 +70,7 @@ impl<'a> Recipe<'a> { if input.len() as u64 > MAX_INPUT { return Err(failed("input exceeds 512 MiB")); } - let name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| failed("input name is not UTF-8"))?; + let name = path.file_name().and_then(std::ffi::OsStr::to_str).ok_or_else(|| failed("input name is not UTF-8"))?; let mut bytes = Arc::new(input); let mut log = Vec::new(); for step in &self.steps { diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 1dae412e41..d2114ac7bf 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -2700,7 +2700,7 @@ fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[u } }; let config = RenderConfig { password, ..Default::default() }; - let workers = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).min(8) }; + let workers = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get).min(8) }; // Small jobs aren't worth a second parse of the document. if workers == 1 || pages.len() < 8 { let mut r = PageRenderer::new(bytes.clone(), config); diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 7d1fdfc366..02bccd5fa8 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -269,7 +269,7 @@ fn jpeg(name: &str, bytes: &[u8]) -> Result { 0xEE if seg.starts_with(b"Adobe") => adobe = true, // APP2 ICC profile, possibly split over several chunks. 0xE2 => { - if let Some(([seq, count], data)) = seg.strip_prefix(b"ICC_PROFILE\0").and_then(|r| r.split_first_chunk::<2>()) { + if let Some(([seq, count], data)) = seg.strip_prefix(b"ICC_PROFILE\0").and_then(<[u8]>::split_first_chunk::<2>) { icc_chunks.push((*seq, *count, data)); } } diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index a775594944..71ccbc683f 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -166,8 +166,8 @@ fn opens_with_marker(text: &str) -> bool { if !first.is_ascii_digit() && !first.is_ascii_alphabetic() { return false; } - let digits = text.chars().take_while(|c| c.is_ascii_digit()).count(); - let letters = text.chars().take_while(|c| c.is_ascii_alphabetic()).count(); + let digits = text.chars().take_while(char::is_ascii_digit).count(); + let letters = text.chars().take_while(char::is_ascii_alphabetic).count(); // Both runs are ASCII characters, so their length in characters is their length in bytes: // the slice below starts on a character boundary. let rest = if digits > 0 { @@ -178,7 +178,7 @@ fn opens_with_marker(text: &str) -> bool { return false; }; let mut after = rest.chars(); - matches!(after.next(), Some('.' | ')')) && after.next().is_none_or(|c| c.is_whitespace()) + matches!(after.next(), Some('.' | ')')) && after.next().is_none_or(char::is_whitespace) } /// A row that opens with a list marker opens its own item: joining it to the row above is what diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 68fc076b97..86fc9dce4f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1050,7 +1050,7 @@ fn shown_under_redactions( } fn render_threads() -> usize { - std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1 + std::thread::available_parallelism().map(std::num::NonZeroUsize::get).unwrap_or(4).clamp(2, 8) - 1 } /// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy @@ -1311,7 +1311,7 @@ impl Protection { (Some(a), Some(b)) if a == b => return bad("the open password and the permissions password must be different"), _ => {} } - if [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.is_empty()) { + if [&self.open_password, &self.permissions_password].into_iter().flatten().any(String::is_empty) { return bad("passwords can't be empty"); } if self.algorithm != pdfcraft_cos::Algorithm::Aes256 @@ -4692,7 +4692,7 @@ impl Session { pub fn combine_field_conflicts(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Vec> { let docs: Vec> = sources.iter().enumerate().map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()).ok()).collect(); - let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(|d| d.as_ref()).collect(); + let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(Option::as_ref).collect(); let mut opened = pdfcraft_organize::combine_field_conflicts(&refs).into_iter(); // One preview per source, in step with it: a file that cannot be opened has none. docs.iter().map(|d| if d.is_some() { opened.next().unwrap_or_default() } else { Default::default() }).collect() diff --git a/crates/engine/src/ocr.rs b/crates/engine/src/ocr.rs index dcd4142d75..c737889023 100644 --- a/crates/engine/src/ocr.rs +++ b/crates/engine/src/ocr.rs @@ -75,7 +75,7 @@ impl OcrPage { /// The recogniser, loaded once (it takes a moment) and shared. pub fn engine() -> Result, String> { static OCR: Mutex>> = Mutex::new(None); - let mut slot = OCR.lock().unwrap_or_else(|e| e.into_inner()); + let mut slot = OCR.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(o) = slot.as_ref() { return Ok(o.clone()); } diff --git a/crates/engine/src/text_fonts.rs b/crates/engine/src/text_fonts.rs index f4945f7563..859bc1a6f8 100644 --- a/crates/engine/src/text_fonts.rs +++ b/crates/engine/src/text_fonts.rs @@ -74,5 +74,5 @@ pub fn discover(roots: &[PathBuf]) -> Vec { files } fn font_extension(path: &Path) -> bool { - path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf")) + path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf")) } diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 6ce76548f5..6166426bbf 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -532,7 +532,7 @@ impl<'a, 'b> Runner<'a, 'b> { } else { Some(by_som.iter().filter(|(som, _)| names.iter().any(|n| som_matches(som, n))).map(|(_, f)| f.clone()).collect()) }; - if listed.as_ref().is_some_and(|l| l.is_empty()) { + if listed.as_ref().is_some_and(Vec::is_empty) { return Ok(()); } pdfcraft_forms::reset(self.doc, listed.as_deref()).map_err(|e| e.to_string())?; diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 8be472e98a..c8dc65951c 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -246,7 +246,7 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec, Vec) { used.sort_unstable(); used.dedup(); let mut edges: Vec = used.iter().filter_map(|&i| col_of.get(i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); - edges.sort_by(|a, b| a.total_cmp(b)); + edges.sort_by(f64::total_cmp); edges.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); let ncols = edges.len(); let ok = (ncols >= 3 || (ncols == 2 && run.len() >= 3)) && ncols <= MAX_COLS; @@ -313,7 +313,7 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec
, Vec) { let run_edges = |run: &[Vec<(usize, usize)>]| -> Vec { let mut es: Vec = run.iter().flatten().filter_map(|(_, i)| col_of.get(*i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); - es.sort_by(|a, b| a.total_cmp(b)); + es.sort_by(f64::total_cmp); es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); es }; diff --git a/crates/fonts/src/std14.rs b/crates/fonts/src/std14.rs index aae6575018..13bb5b7711 100644 --- a/crates/fonts/src/std14.rs +++ b/crates/fonts/src/std14.rs @@ -59,7 +59,7 @@ impl Std14 { /// report that called it a standard-14 name would be telling the reader something untrue. pub fn recognise(base: &str) -> Option<(Self, Match)> { let base = base.rsplit('+').next().unwrap_or(base); - let flat: String = base.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect(); + let flat: String = base.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect(); let has = |w: &str| flat.contains(w); let (bold, italic) = (has("bold"), has("italic") || has("oblique")); // Two bools index a four-element array, so the subscript is provably 0..=3. diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index c3424050d8..95cd9d9a8c 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -980,7 +980,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result]) -> Vec { continue; } if low[v] == index[v] { - while let Some(w) = stack.pop() { - on_stack[w] = false; - component[w] = count; - if w == v { - break; - } - } + pop_component(&mut stack, &mut on_stack, &mut component, v, count); count += 1; } if let Some(&(parent, _)) = work.last() { @@ -260,6 +254,16 @@ fn components(links: &[Vec]) -> Vec { component } +fn pop_component(stack: &mut Vec, on_stack: &mut [bool], component: &mut [usize], v: usize, count: usize) { + while let Some(w) = stack.pop() { + on_stack[w] = false; + component[w] = count; + if w == v { + break; + } + } +} + /// Fix order: every node after the nodes it reads in other components, the earliest ready node /// first, so an order that is already right stays as it is and otherwise only what must move /// moves. Reads within a component (a loop) are left out: no order satisfies them. diff --git a/crates/forms/src/detect.rs b/crates/forms/src/detect.rs index 8c980c09f2..386671f433 100644 --- a/crates/forms/src/detect.rs +++ b/crates/forms/src/detect.rs @@ -307,7 +307,7 @@ fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String { .iter() .filter(|w| w.rect[1] >= rect[3] - 1.0 && w.rect[1] - rect[3] < 24.0 && w.rect[0] < rect[2] && w.rect[2] > rect[0] - 4.0) .collect(); - let Some(line) = above.iter().map(|w| mid(w.rect)).min_by(|a, b| a.total_cmp(b)) else { return String::new() }; + let Some(line) = above.iter().map(|w| mid(w.rect)).min_by(f64::total_cmp) else { return String::new() }; let mut on: Vec<&&Word> = above.iter().filter(|w| (mid(w.rect) - line).abs() < 3.0).collect(); on.sort_by(|a, b| a.rect[0].total_cmp(&b.rect[0])); clean(&on.iter().map(|w| w.text.as_str()).collect::>().join(" ")) diff --git a/crates/forms/src/table.rs b/crates/forms/src/table.rs index 23ad18c8b4..ae565052fc 100644 --- a/crates/forms/src/table.rs +++ b/crates/forms/src/table.rs @@ -269,23 +269,28 @@ pub fn cell_name(pattern: &str, row: usize, column: usize, seq: usize) -> String if numbered { return out; } + substitute_isolated_n(&out, &row_number) +} + +fn substitute_isolated_n(out: &str, row_number: &str) -> String { if out == "N" || out == "n" { - return row_number; + return row_number.to_string(); } let chars: Vec = out.chars().collect(); let mut named = String::with_capacity(out.len() + row_number.len()); + let mut numbered = false; for (i, &ch) in chars.iter().enumerate() { let after_separator = i.checked_sub(1).and_then(|p| chars.get(p)).is_some_and(|c| SEPARATORS.contains(c)); let before_separator = chars.get(i + 1).is_none_or(|c| SEPARATORS.contains(c)); if matches!(ch, 'N' | 'n') && after_separator && before_separator { - named.push_str(&row_number); + named.push_str(row_number); numbered = true; } else { named.push(ch); } } if !numbered { - named.push_str(&row_number); + named.push_str(row_number); } named } diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 2348ce2206..4f31d04d34 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -123,7 +123,7 @@ fn lex(src: &str) -> Result, String> { } out.push(Token { tok: Tok::Str(s), line }); } - c if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|d| d.is_ascii_digit())) => { + c if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(char::is_ascii_digit)) => { let start = i; while i < chars.len() && (chars[i].is_ascii_digit() || (chars[i] == '.' && chars.get(i + 1) != Some(&'.'))) { i += 1; @@ -760,7 +760,7 @@ fn parse_num(s: &str) -> Option { } fn is_numeric_text(s: &str) -> bool { - s.trim().parse::().is_ok_and(|v| v.is_finite()) + s.trim().parse::().is_ok_and(f64::is_finite) } // ── the evaluator ─────────────────────────────────────────────────────────────────────────── diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 8a60e8ba9c..45abeeb026 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -1416,7 +1416,7 @@ fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> "DataView", "Intl", ]; - let mut defined: Vec = RESERVED.iter().map(|s| s.to_string()).collect(); + let mut defined: Vec = RESERVED.iter().map(ToString::to_string).collect(); for scope in scopes { let kids: Vec<(usize, String, bool)> = { let h = host(ctx)?.borrow(); diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index f0bb3738d7..b5c4488821 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -1692,7 +1692,7 @@ fn outline_with(items: &[&str]) -> Document { format!("<< /Type /Outlines /First 5 0 R /Count {} >>", items.len()), "<< /Type /Page /Parent 2 0 R >>".into(), ]; - b.extend(items.iter().map(|s| s.to_string())); + b.extend(items.iter().map(ToString::to_string)); open(build(&b, "/Root 1 0 R")) } diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index f6a98c4fe4..c0e8f990bc 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -145,7 +145,7 @@ pub fn write_atomically_with( return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "the file is read-only; save a copy with Save As")); } let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("save"); + let name = path.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("save"); // A file that is replaced keeps its permissions (#1790) and group. Unix only: Windows is as before. #[cfg(unix)] let keep = std::fs::metadata(path).ok(); diff --git a/crates/preflight/src/xmp.rs b/crates/preflight/src/xmp.rs index 0da4869cc6..0aaab12b23 100644 --- a/crates/preflight/src/xmp.rs +++ b/crates/preflight/src/xmp.rs @@ -151,7 +151,7 @@ fn esc(s: &str) -> String { /// A PDF date (`D:YYYYMMDDHHmmSSOHH'mm'`) as an XMP (ISO 8601) date. pub fn iso_date(pdf: &str) -> Option { let d = pdf.trim().trim_start_matches("D:"); - let digits: String = d.chars().take_while(|c| c.is_ascii_digit()).collect(); + let digits: String = d.chars().take_while(char::is_ascii_digit).collect(); if digits.len() < 4 { return None; } @@ -161,7 +161,7 @@ pub fn iso_date(pdf: &str) -> Option { let tz = match rest.chars().next() { Some('Z') | None => "Z".to_string(), Some(sign @ ('+' | '-')) => { - let t: String = rest[1..].chars().filter(|c| c.is_ascii_digit()).collect(); + let t: String = rest[1..].chars().filter(char::is_ascii_digit).collect(); format!("{sign}{}:{}", t.get(0..2).unwrap_or("00"), t.get(2..4).unwrap_or("00")) } _ => "Z".to_string(), diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index fcd2ebe835..5ac6c24ea9 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -443,8 +443,8 @@ fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], de if depth >= MAX_DEPTH { return None; } - let Ok(data) = s.decoded() else { return None }; - match content_draws(doc, std::slice::from_ref(&data), xo, name, depth + 1, seen) { + let Ok(_data) = s.decoded() else { return None }; + match content_draws(doc, content, xo, name, depth + 1, seen) { Some(false) => {} other => return other, } @@ -616,14 +616,7 @@ fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet {} - Some(false) => pruned |= xo.remove(&name).is_some(), - None => return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }), - } - } + let mut pruned = prune_doomed_xobjects(doc, std::slice::from_ref(&data), &mut xo, &doomed_names)?; if pruned { res.set(b"XObject".to_vec(), Object::Dict(xo)); } @@ -637,6 +630,18 @@ fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet], xo: &mut Dict, doomed_names: &[Vec]) -> Result { + let mut pruned = false; + for name in doomed_names { + match content_draws(doc, content, xo, name, 0, &mut HashSet::new()) { + Some(true) => {} + Some(false) => pruned |= xo.remove(name).is_some(), + None => return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }), + } + } + Ok(pruned) +} + /// 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). diff --git a/crates/redact/src/patterns.rs b/crates/redact/src/patterns.rs index f707f179a4..88e85fb463 100644 --- a/crates/redact/src/patterns.rs +++ b/crates/redact/src/patterns.rs @@ -346,7 +346,7 @@ fn date(s: &[char], i: usize) -> Option { // Numeric: m/d/yy(yy), m-d-yyyy, d.m.yyyy, yyyy-mm-dd. if let Some(a) = number(s, i, 1, 4) { let sep = s.get(i + a).copied(); - if matches!(sep, Some('/' | '-' | '.')) + if matches!(sep, Some('-'..='/')) && let Some(b) = number(s, i + a + 1, 1, 2) && s.get(i + a + 1 + b).copied() == sep && let Some(c) = number(s, i + a + b + 2, if a == 4 { 1 } else { 2 }, if a == 4 { 2 } else { 4 }) diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index 60c158b180..961f016e94 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -108,7 +108,7 @@ pub(crate) fn touched(doc: &Document, resources: &Dict, before: &[u8], after: &[ let mut changed = HashSet::new(); let mut empty = HashSet::new(); let mut budget = MAX_COMPARED; - let len = |ranges: &[Range]| ranges.iter().map(|r| r.len()).fold(0usize, usize::saturating_add); + let len = |ranges: &[Range]| ranges.iter().map(Range::len).fold(0usize, usize::saturating_add); for (id, (old, _)) in &b.ids { match a.ids.get(id) { Some((new, draws)) => { diff --git a/crates/render/examples/page_timing.rs b/crates/render/examples/page_timing.rs index f1d143a210..5fc60c6abb 100644 --- a/crates/render/examples/page_timing.rs +++ b/crates/render/examples/page_timing.rs @@ -51,7 +51,7 @@ fn main() -> Result<(), Box> { }; let threads: usize = match args.get(4) { Some(s) => s.parse::()?.max(1), - None => std::thread::available_parallelism().map_or(4, |n| n.get()).clamp(2, 8) - 1, + None => std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get).clamp(2, 8) - 1, }; let bytes = Arc::new(std::fs::read(path)?); diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 0f43b06361..64e6c55969 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -488,7 +488,7 @@ fn parse(bytes: &Arc>, password: Option<&str>) -> Option { } pub(crate) fn panic_message(p: &Box) -> String { - p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "unknown panic".into()) + p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "unknown panic".into()) } /// How long one page may render before the pool gives up on it (the watchdog). @@ -587,7 +587,7 @@ struct Shared { } fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { - m.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } #[cfg(not(target_arch = "wasm32"))] @@ -666,7 +666,7 @@ impl PoolParser { // Drop never joins a parser. A waiting worker must still notice // its pool's disconnected wake channel while another thread is // stuck in initialization and cannot signal this condition. - state = self.ready.wait_timeout(state, std::time::Duration::from_millis(50)).unwrap_or_else(|p| p.into_inner()).0; + state = self.ready.wait_timeout(state, std::time::Duration::from_millis(50)).unwrap_or_else(std::sync::PoisonError::into_inner).0; } ParserState::Empty => { *state = ParserState::Loading; diff --git a/crates/render/src/raster/work_queue.rs b/crates/render/src/raster/work_queue.rs index eb1d05b452..ff20bbf1f1 100644 --- a/crates/render/src/raster/work_queue.rs +++ b/crates/render/src/raster/work_queue.rs @@ -220,7 +220,7 @@ impl WorkQueue { } // The parser validity token can change independently of this queue. Bound the // wait so failed generations are released even while the consumer is paused. - state = self.changed.wait_timeout(state, Duration::from_millis(25)).unwrap_or_else(|p| p.into_inner()).0; + state = self.changed.wait_timeout(state, Duration::from_millis(25)).unwrap_or_else(std::sync::PoisonError::into_inner).0; } } diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index c9e89248f1..1e522b83f1 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -499,11 +499,11 @@ impl TextDevice { return; } let Some(glyph) = merge(&drawn, text.clone()) else { - self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(|c| c.is_control()))); + self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(char::is_control))); return; }; - let originals = drawn.iter().filter(|d| !d.2.text.chars().all(|c| c.is_control())).map(|d| (d.2.text.clone(), d.2.rect)).collect(); - let at = if text.chars().all(|c| c.is_control()) { + let originals = drawn.iter().filter(|d| !d.2.text.chars().all(char::is_control)).map(|d| (d.2.text.clone(), d.2.rect)).collect(); + let at = if text.chars().all(char::is_control) { None } else { self.glyphs.push(glyph); @@ -594,7 +594,7 @@ impl TextDevice { let text = replacement.to_owned(); #[cfg(test)] REDRAW_TEXT_BYTES.with(|c| c.set(c.get() + text.len())); - if !text.chars().all(|c| c.is_control()) { + if !text.chars().all(char::is_control) { match merge(run, text) { Some(glyph) => self.glyphs.push(glyph), None => self.glyphs.extend_from_slice(run), @@ -640,7 +640,7 @@ impl<'a> Device<'a> for TextDevice { return; } }; - if !replaced && text.chars().all(|c| c.is_control()) { + if !replaced && text.chars().all(char::is_control) { return; } // Glyph space → view space. Glyph space uses 1000 units per em for outline glyphs; the em diff --git a/crates/sign/src/cms.rs b/crates/sign/src/cms.rs index 3568cbf454..b148ea4d19 100644 --- a/crates/sign/src/cms.rs +++ b/crates/sign/src/cms.rs @@ -294,6 +294,24 @@ fn parse_signer(si: &Tlv<'_>) -> Result { } let (scheme, scheme_digest) = signature_algorithm(&next)?; let signature = it.next().ok_or_else(|| bad("signature"))?.octets("signature")?.into_owned(); + let (timestamp, timestamp_token) = parse_timestamp_token(it)?; + Ok(SignerInfo { + sid, + digest, + signed_attrs, + message_digest, + content_type, + signing_time, + signing_certificate, + scheme, + scheme_digest, + signature, + timestamp, + timestamp_token, + }) +} + +fn parse_timestamp_token<'a>(it: impl Iterator>) -> Result<(bool, Option>), SignError> { let mut timestamp = false; let mut timestamp_token = None; for t in it { @@ -309,20 +327,7 @@ fn parse_signer(si: &Tlv<'_>) -> Result { } } } - Ok(SignerInfo { - sid, - digest, - signed_attrs, - message_digest, - content_type, - signing_time, - signing_certificate, - scheme, - scheme_digest, - signature, - timestamp, - timestamp_token, - }) + Ok((timestamp, timestamp_token)) } /// Attach an RFC 3161 `signatureTimeStampToken` unsigned attribute to a detached CMS object. diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 4a4f5333a8..aa2aad02d1 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -657,22 +657,21 @@ impl PdfCraftApp { } }); return; - } else { - match self.session.extract(id, &pages) { - Ok(bytes) => { - let message = if pages.len() == 1 { - tl!("Extracted 1 page").to_string() - } else { - crate::i18n::fmt(tl!("Extracted {n} pages"), &[("n", &pages.len().to_string())]) - }; - // Save As offers the tab's name, so a name typed in the dialog is kept. - let name = if renamed { format!("{base}.pdf") } else { format!("{stem} (extract).pdf") }; - self.open_created(&name, bytes, &message) - } - Err(e) => { - self.notify_fmt("Couldn't extract pages: {e}", &[("e", &e.to_string())]); - return; - } + } + match self.session.extract(id, &pages) { + Ok(bytes) => { + let message = if pages.len() == 1 { + tl!("Extracted 1 page").to_string() + } else { + crate::i18n::fmt(tl!("Extracted {n} pages"), &[("n", &pages.len().to_string())]) + }; + // Save As offers the tab's name, so a name typed in the dialog is kept. + let name = if renamed { format!("{base}.pdf") } else { format!("{stem} (extract).pdf") }; + self.open_created(&name, bytes, &message) + } + Err(e) => { + self.notify_fmt("Couldn't extract pages: {e}", &[("e", &e.to_string())]); + return; } } if opts.delete { diff --git a/crates/ui-egui/src/protect.rs b/crates/ui-egui/src/protect.rs index a4336d546c..fb48c369de 100644 --- a/crates/ui-egui/src/protect.rs +++ b/crates/ui-egui/src/protect.rs @@ -56,8 +56,8 @@ impl ProtectDraft { /// A rough password strength, as Acrobat's meter shows (Weak / Medium / Strong). pub fn strength(pw: &str) -> (&'static str, Color32) { let classes = [ - pw.chars().any(|c| c.is_lowercase()), - pw.chars().any(|c| c.is_uppercase()), + pw.chars().any(char::is_lowercase), + pw.chars().any(char::is_uppercase), pw.chars().any(|c| c.is_ascii_digit()), pw.chars().any(|c| !c.is_alphanumeric()), ] diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 17a3410d12..68d82922c3 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -206,10 +206,10 @@ impl RecoveryStore { let mut out = Vec::new(); for entry in dir.flatten() { let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("pdf") || !entry.file_type().is_ok_and(|t| t.is_file()) { + if path.extension().and_then(std::ffi::OsStr::to_str) != Some("pdf") || !entry.file_type().is_ok_and(|t| t.is_file()) { continue; } - let Some(key) = path.file_stem().and_then(|s| s.to_str()) else { continue }; + let Some(key) = path.file_stem().and_then(std::ffi::OsStr::to_str) else { continue }; let meta = std::fs::read(path.with_extension("json")) .and_then(|b| serde_json::from_slice::(&b).map_err(std::io::Error::other)) .and_then(|m| if m.key == key { Ok(m) } else { Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "snapshot key mismatch")) }); @@ -266,7 +266,7 @@ impl RecoveryStore { for entry in dir.flatten() { let path = entry.path(); // Only sidecars whose PDF is gone: they hold no document content. - if path.extension().and_then(|e| e.to_str()) == Some("json") + if path.extension().and_then(std::ffi::OsStr::to_str) == Some("json") && entry.file_type().is_ok_and(|t| t.is_file()) && !path.with_extension("pdf").exists() { diff --git a/crates/ui-egui/tests/it/animate.rs b/crates/ui-egui/tests/it/animate.rs index d01bf6de19..c2ab58e92c 100644 --- a/crates/ui-egui/tests/it/animate.rs +++ b/crates/ui-egui/tests/it/animate.rs @@ -18,7 +18,7 @@ fn pdf(annots: &[&str]) -> Vec { "<< /Length 0 >>\nstream\n\nendstream".into(), "<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] /Length 0 >>\nstream\n\nendstream".into(), ]; - objs.extend(annots.iter().map(|o| o.to_string())); + objs.extend(annots.iter().map(ToString::to_string)); let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { diff --git a/crates/ui-egui/tests/it/recovery.rs b/crates/ui-egui/tests/it/recovery.rs index 5e2ffaad7d..3e3f3a593c 100644 --- a/crates/ui-egui/tests/it/recovery.rs +++ b/crates/ui-egui/tests/it/recovery.rs @@ -43,7 +43,9 @@ fn store(tag: &str) -> RecoveryStore { /// Snapshot files (the PDF and its sidecar), not the running instances' lock files. fn files_in(s: &RecoveryStore) -> usize { - std::fs::read_dir(s.dir()).map(|d| d.flatten().filter(|e| e.path().extension().and_then(|x| x.to_str()) != Some("lock")).count()).unwrap_or(0) + std::fs::read_dir(s.dir()) + .map(|d| d.flatten().filter(|e| e.path().extension().and_then(std::ffi::OsStr::to_str) != Some("lock")).count()) + .unwrap_or(0) } /// A session that edits a document, autosaves, and then "crashes" (is dropped). diff --git a/crates/ui-egui/tests/it/xfa.rs b/crates/ui-egui/tests/it/xfa.rs index e66658b0e1..cf0fc193b1 100644 --- a/crates/ui-egui/tests/it/xfa.rs +++ b/crates/ui-egui/tests/it/xfa.rs @@ -15,7 +15,7 @@ fn pdf(acroform: &str, extra: &str, objects: &[&str]) -> Vec { "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R >>".into(), "<< /Length 0 >>\nstream\n\nendstream".into(), ]; - objs.extend(objects.iter().map(|o| o.to_string())); + objs.extend(objects.iter().map(ToString::to_string)); let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { diff --git a/crates/ui-egui/tests/perf.rs b/crates/ui-egui/tests/perf.rs index 319edeffc8..61d3965bdc 100644 --- a/crates/ui-egui/tests/perf.rs +++ b/crates/ui-egui/tests/perf.rs @@ -18,7 +18,7 @@ fn load_average() -> Option { /// Frame budgets only mean something on a machine that isn't overcommitted: with the load /// average above twice the core count (other builds running), skip and say so. fn overloaded() -> bool { - let cores = std::thread::available_parallelism().map_or(4, |n| n.get()) as f64; + let cores = std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get) as f64; match load_average() { Some(l) if l > 2.0 * cores => { eprintln!("PERF skipped: load average {l:.0} on {cores} cores; frame budgets need a quiet machine"); diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 2b80d8b96a..95f59a44b9 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -441,11 +441,8 @@ pub fn pattern_to_iso(text: &str, pattern: &str) -> Option { "yyyy" | "yy" | "mm" | "m" | "dd" | "d" => { // Digits up to the next literal (or a fixed width). let fixed = matches!(t.as_str(), "yyyy" | "yy" | "mm" | "dd"); - let width = if fixed { - t.len() - } else { - rest.chars().take_while(|c| c.is_ascii_digit()).count().min(if t == "d" || t == "m" { 2 } else { 4 }) - }; + let width = + if fixed { t.len() } else { rest.chars().take_while(char::is_ascii_digit).count().min(if t == "d" || t == "m" { 2 } else { 4 }) }; let digits: String = rest.chars().take(width).collect(); if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) { return None; diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs index 7e83212680..e1f4c548b0 100644 --- a/crates/xfa/src/text.rs +++ b/crates/xfa/src/text.rs @@ -213,7 +213,7 @@ fn words(runs: &[Run], base: &Face, embed: &dyn Fn(&str, usize) -> String, embed .filter_map(|c| match c { '\r' | '\n' | '\t' | '\u{2028}' | '\u{2029}' | '\u{2009}' | '\u{202F}' | '\u{3000}' => Some(' '), '\u{00AD}' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' => None, - '\u{2011}' | '\u{2010}' | '\u{2012}' | '\u{2013}' => Some('-'), + '\u{2010}'..='\u{2013}' => Some('-'), '\u{2018}' | '\u{2019}' => Some('\''), _ => Some(c), }) diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 595ca7b189..b7813920e7 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -92,6 +92,12 @@ has() { case " $FORMATS " in *" $format "*) return 0 ;; *) return 1 ;; esac } +verify_sha256() { + local expected="$1" + local target="$2" + printf '%s %s\n' "$expected" "$target" | sha256sum -c - +} + # ---- .tar.gz ------------------------------------------------------------------------------------ if has tar; then mkdir -p "$WORK/tar" @@ -164,21 +170,21 @@ if has appimage; then download="$TOOL.download" curl --proto '=https' -fsSL -o "$download" \ "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" - printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$download" | sha256sum -c - + verify_sha256 "$APPIMAGETOOL_SHA256" "$download" chmod 755 "$download" mv "$download" "$TOOL" fi - printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$TOOL" | sha256sum -c - + verify_sha256 "$APPIMAGETOOL_SHA256" "$TOOL" fi RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$APPIMAGE_RUNTIME_VERSION-$ARCH" if [[ ! -f "$RUNTIME" ]]; then download="$RUNTIME.download" curl --proto '=https' -fsSL -o "$download" \ "https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-$ARCH" - printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$download" | sha256sum -c - + verify_sha256 "$APPIMAGE_RUNTIME_SHA256" "$download" mv "$download" "$RUNTIME" fi - printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$RUNTIME" | sha256sum -c - + verify_sha256 "$APPIMAGE_RUNTIME_SHA256" "$RUNTIME" # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be # relative, e.g. target/agent-). OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index b3393d770a..ac836eae6d 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -229,7 +229,7 @@ fn cargo_lock_versions(root: &Path) -> Result> { } pub fn is_asset_path(path: &str) -> bool { - Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| ASSET_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str())) + Path::new(path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| ASSET_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str())) } /// Every licence id in an SPDX expression (`MIT OR Apache-2.0`, `MIT AND Bitstream-Vera`). diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index a0907cb1a6..8ad15733a2 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -228,7 +228,7 @@ pub fn check(crates: &[Crate]) -> Vec { } } } - out.sort_by_key(|v| v.to_string()); + out.sort_by_key(ToString::to_string); out.dedup(); out } From 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:03:16 -0400 Subject: [PATCH 13/15] fix(deps): bump crypto-bigint to 0.7.6 (RUSTSEC-2026-0387) (#4366) cargo deny refuses crypto-bigint 0.7.5, which pdfcraft-sign uses through rsa and crypto-primes. 0.7.6 is a semver-compatible patch release with the fix. --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 496eeab0a4..93c047eef3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1243,9 +1243,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +checksum = "6bb08299a6046833a879ace115697f12fd2bd6c8d3e93c909ffc9e0738e7cae0" dependencies = [ "cpubits", "ctutils", From 86730ed14466c5388173e32ef5a3ec015e523b9a Mon Sep 17 00:00:00 2001 From: Jayesh Prajapati <156836276+Jayesh-Prajapati9@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:35:33 +0530 Subject: [PATCH 14/15] Tests: UI tests wait for background work by time, not frame count (#4186) (#4242) * Tests: UI tests wait for background work by time, not frame count (#4186) A frame count is not a time: on a busy runner the worker had not finished the open, or the page text had not arrived, within 30 frames or 50 hover rounds. support::step_until (#[must_use]) steps frames until a condition holds or 30 s pass, sleeping briefly between frames, and says whether it held; each test asserts on that with its own message. - control_open_replies_pending_while_the_worker_opens_and_focuses_once_it_has and mode_tabs_keep_the_hand_over_page_text_scrolled_under_them wait with it, and so does control.rs call() for its reply. - support::settle waits for every pending render and fails if the pages are not rendered within 30 s. It replaces all the render-pending settle loops (40 to 3000 frames) and the local settle/wait_for_pages wrappers, except text_select's double-click wait, which is held to 8 frames on purpose to stay inside egui's multi-click window, and view.rs's loops that already wait by a deadline. - wait_until is built on step_until, keeping its diagnostic message. - tabbing_back_to_the_same_field_then_saving_keeps_the_committed_value already waited 30 s for its file. It failed because its "same-frame" case shared its scratch directory with typing_and_saving_in_the_same_frame_saves_the_last_keystrokes, which runs in the same test process (since ee1ee704, #1045) and deleted the directory mid-save. forms' and pickers' scratch() now give every call a directory of its own. Fixes #4186 * M14: update ctutils to 0.4.5 for RUSTSEC-2026-0385 The dependency-advisory gate rejects ctutils 0.4.2 (non-constant-time mask generation and u128 comparisons, fixed in 0.4.4). It comes in through crypto-bigint for the signing crates. Only its version and checksum change in Cargo.lock. (cherry picked from commit 1f8811761a08d853be4af0c36d52a551bd274054) --- crates/ui-egui/tests/it/bookmark_position.rs | 21 ++---- crates/ui-egui/tests/it/comments.rs | 24 ++----- crates/ui-egui/tests/it/content.rs | 34 ++------- crates/ui-egui/tests/it/control.rs | 27 ++++---- crates/ui-egui/tests/it/editing.rs | 72 ++++++-------------- crates/ui-egui/tests/it/forms.rs | 55 ++++----------- crates/ui-egui/tests/it/object_selection.rs | 8 +-- crates/ui-egui/tests/it/ocr.rs | 8 +-- crates/ui-egui/tests/it/pickers.rs | 7 +- crates/ui-egui/tests/it/prepare.rs | 8 +-- crates/ui-egui/tests/it/redact.rs | 8 +-- crates/ui-egui/tests/it/support/mod.rs | 44 +++++++++--- crates/ui-egui/tests/it/ui.rs | 30 +++----- crates/ui-egui/tests/it/view.rs | 48 ++----------- crates/ui-egui/tests/it/viewer.rs | 16 +---- crates/ui-egui/tests/it/xfa.rs | 15 +--- 16 files changed, 127 insertions(+), 298 deletions(-) diff --git a/crates/ui-egui/tests/it/bookmark_position.rs b/crates/ui-egui/tests/it/bookmark_position.rs index 03fd8b50a9..09fd1d4be8 100644 --- a/crates/ui-egui/tests/it/bookmark_position.rs +++ b/crates/ui-egui/tests/it/bookmark_position.rs @@ -55,21 +55,10 @@ fn harness(rotate: u16) -> Harness<'static, PdfCraftApp> { app }); h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); h } -/// Run frames until rendering has finished and the layout holds still. -fn settle(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..200 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } -} - /// Make a bookmark with ⌘B, as the Bookmarks button does, and say what it goes to. fn new_bookmark(h: &mut Harness<'static, PdfCraftApp>) -> (Option, DestView) { h.key_press_modifiers(Modifiers::COMMAND, Key::B); @@ -103,7 +92,7 @@ fn a_new_bookmark_goes_to_the_top_of_the_window_not_the_top_of_the_page() { h.state_mut().apply_edit(Edit::AddBookmark { parent: vec![], index: 0, title: "Mid".into(), page: 1, left: None, top: Some(250.0) }); h.run_steps(3); h.get_by_label("Mid").click(); - settle(&mut h); + crate::support::settle(&mut h); let (page, view) = new_bookmark(&mut h); assert_eq!(page, Some(1), "the page at the top of the window"); let (left, top) = xyz(view); @@ -151,7 +140,7 @@ fn a_bookmark_made_on_a_turned_page_pins_the_axis_the_window_scrolls_along() { fn rotate_view(h: &mut Harness<'static, PdfCraftApp>, degrees: u16) { h.state_mut().views[0].rotation = degrees; h.run_steps(3); - settle(h); + crate::support::settle(h); } #[test] @@ -216,12 +205,12 @@ fn a_bookmark_in_a_spread_of_unequal_pages_goes_to_the_page_crossing_the_top_of_ }); h.state_mut().views[0].layout = PageLayout::TwoUp; h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); // 20 pt below page 2's top is at the window's top, where page 1 has not started. h.state_mut().apply_edit(Edit::AddBookmark { parent: vec![], index: 0, title: "Right".into(), page: 1, left: None, top: Some(380.0) }); h.run_steps(3); h.get_by_label("Right").click(); - settle(&mut h); + crate::support::settle(&mut h); let (page, view) = new_bookmark(&mut h); assert_eq!(page, Some(1), "page 2 crosses the top of the window; page 1 starts below it"); close(xyz(view).1, 380.0, "top"); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 6194c75163..68b41ac98b 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -40,20 +40,10 @@ fn harness_with_bytes(bytes: Vec, setup: impl FnOnce(&mut PdfCraftApp) + 'st app }); h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); h } -fn settle(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..200 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } -} - /// A point on page 1 in PDF user space → screen (no rotation; MediaBox 300×200). fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 { let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); @@ -146,7 +136,7 @@ fn sidebar_comment_centres_the_annotation_at_high_zoom() { && let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { let _gpu = crate::gpu(); - settle(&mut h); + crate::support::settle(&mut h); h.render().unwrap().save(format!("{dir}/comment-centred.png")).unwrap(); } } @@ -754,7 +744,7 @@ fn a_dragged_text_box_follows_a_rotated_view() { } h.state_mut().set_option("zoom", "150").unwrap(); h.run_steps(3); - settle(&mut h); + crate::support::settle(&mut h); let screen = |h: &Harness<'static, PdfCraftApp>, x: f32, y: f32| { let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); let (u, v) = (x / 300.0, (200.0 - y) / 200.0); @@ -1726,7 +1716,7 @@ fn attachment_harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness app }); h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); h } @@ -1915,7 +1905,7 @@ fn hovering_a_comment_shows_its_author_and_text() { app }); h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); assert!(h.query_by_label_contains("Check this figure").is_none(), "no popup before hovering"); let p = at(&h, 100.0, 85.0); h.hover_at(p); @@ -1945,7 +1935,7 @@ fn preference_hides_author_only_highlight_hover_previews() { app }); h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); let p = at(&h, 100.0, 85.0); // By default, author-only hover is shown. @@ -1992,7 +1982,7 @@ fn markup_hover_harness(pdf: Vec, view_rotation: u16) -> Harness<'static, Pd app }); h.run_steps(4); - settle(&mut h); + crate::support::settle(&mut h); h } diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 515e3e04f1..dc13bdb615 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -28,13 +28,7 @@ fn harness_bytes_with_step_dt(bytes: Vec, step_dt: f32) -> Harness<'static, app.set_option("zoom", "150").unwrap(); app }); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h } @@ -161,13 +155,7 @@ fn text_tool_deleting_a_draft_hides_the_old_added_glyphs_and_cancel_restores_the click(&mut h, point); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); h.key_press(egui::Key::Backspace); - for _ in 0..100 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert!(h.state().views[0].content.draft.as_ref().unwrap().text.is_empty()); let shot = h.render().unwrap(); for x in 105..140 { @@ -795,18 +783,6 @@ fn red(pixels: &image::RgbaImage, r: egui::Rect) -> usize { .count() } -/// Steps until no render is pending. -fn settle(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..200 { - h.run_steps(1); - if !h.state().render_pending() { - return; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - panic!("the page did not finish rendering"); -} - /// An added picture resized by a corner is drawn at its new size while dragged and after the /// release until the page is rendered again. It used to stay at its old size under the outline for /// the whole drag, and to show there again after the release. @@ -826,7 +802,7 @@ fn a_resized_added_picture_shows_only_at_its_new_size() { h.state_mut().add_image("red.png".into(), png); h.run_steps(3); assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the picture is selected"); - settle(&mut h); + crate::support::settle(&mut h); let rect = added(&h)[0].content.rect(); let r = item_on_screen(&h, rect); // The lower right corner to the middle: half the size, the upper left corner kept. @@ -841,7 +817,7 @@ fn a_resized_added_picture_shows_only_at_its_new_size() { h.hover_at(grip + (to - grip) * (k as f32 / 4.0)); h.run_steps(1); } - settle(&mut h); + crate::support::settle(&mut h); let pixels = h.render().unwrap(); assert!(red(&pixels, kept) > 100, "while dragged, the picture is drawn at its new size"); assert_eq!(red(&pixels, gone), 0, "while dragged, the picture shows at its old size"); @@ -856,7 +832,7 @@ fn a_resized_added_picture_shows_only_at_its_new_size() { } assert_ne!(added(&h)[0].content.rect(), rect, "the resize is applied"); h.state_mut().views[0].hold_rasters = false; - settle(&mut h); + crate::support::settle(&mut h); h.run_steps(2); let pixels = h.render().unwrap(); assert!(red(&pixels, kept) > 100 && red(&pixels, gone) == 0, "the page raster has the picture at its new size"); diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 54bfe2fa3b..ec29ecc973 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -73,13 +73,15 @@ fn harness_pages_at(pages: usize, width: f32, scale: f32) -> (Harness<'static, P /// Send a request and run frames until it is answered. fn call(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Reply { let rx = c.send(method, params); - for _ in 0..30 { - h.step(); - if let Ok(r) = rx.try_recv() { - return r; - } + let mut reply = None; + let answered = crate::support::step_until(h, |_| { + reply = rx.try_recv().ok(); + reply.is_some() + }); + match reply { + Some(r) if answered => r, + _ => panic!("{method}: no reply in 30 s"), } - panic!("{method}: no reply after 30 frames"); } fn ok(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Value { @@ -1674,15 +1676,12 @@ fn control_open_replies_pending_while_the_worker_opens_and_focuses_once_it_has() // The worker completes the open on later frames; the tab shows, and only then does a // repeated open focus it instead of reporting another pending open. let mut documents = 0; - for _ in 0..30 { - h.run_steps(1); - let state = ok(&mut h, &c, "ui.state", json!({})); + let opened = crate::support::step_until(&mut h, |h| { + let state = ok(h, &c, "ui.state", json!({})); documents = state["documents"].as_array().map(|d| d.len()).unwrap_or_default(); - if documents == 1 { - break; - } - } - assert_eq!(documents, 1, "the pending open completed into a tab"); + documents == 1 + }); + assert!(opened, "the pending open completed into a tab: {documents} tabs"); let again = ok(&mut h, &c, "ui.open", json!({ "path": a })); assert_eq!(again["focused"], true, "{again}"); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 73464d33e7..54b99d619d 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -2826,13 +2826,7 @@ fn deleting_a_draft_does_not_show_the_original_text_underneath() { h.drop_at(at); h.run_steps(3); h.state_mut().views[0].line_editor.as_mut().unwrap().text.clear(); - for _ in 0..100 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); let shot = h.render().unwrap(); if let Ok(path) = std::env::var("PDFCRAFT_TEXT_PREVIEW_SHOT") { shot.save(path).unwrap(); @@ -2931,13 +2925,7 @@ fn opening_a_paragraph_leaves_the_page_as_it_is() { }); h.run_steps(4); assert!(h.state_mut().execute("edit.edit_text")); - for _ in 0..100 { - h.run_steps(1); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); let page = h.state().views[0].page_screen_rect(0).expect("on screen"); let k = page.width() / 300.0; let block = h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].rect; @@ -3008,13 +2996,7 @@ fn a_quick_click_into_another_paragraph_keeps_both_in_place() { }; let (top, bottom) = (screen(&blocks[0]), screen(&blocks[1])); click_at(&mut h, top.center()); - for _ in 0..100 { - h.run_steps(1); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("Top")); let ink = |pixels: &image::RgbaImage, r: egui::Rect| { (r.top() as u32..r.bottom() as u32) @@ -4013,14 +3995,13 @@ fn save_paragraph_drag(h: &mut Harness<'static, PdfCraftApp>, name: &str, origin fn paragraph_drag_shot(h: &mut Harness<'static, PdfCraftApp>, name: &str) { if let Ok(directory) = std::env::var("PDFCRAFT_PARAGRAPH_DRAG_SHOTS") { - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - assert!(!h.state().render_pending(), "the screenshot must show the current document generation"); + assert!( + crate::support::step_until(h, |h| { + h.run_steps(1); + !h.state().render_pending() + }), + "the screenshot must show the current document generation" + ); let directory = std::path::Path::new(&directory); std::fs::create_dir_all(directory).unwrap(); h.render().unwrap().save(directory.join(name)).unwrap(); @@ -5170,14 +5151,13 @@ fn image_pixels_follow_the_frame_while_a_corner_is_dragged() { h.run_steps(4); // The dragged pixels are the page raster's: wait for it, as a slow runner may not have it yet // (the drag then drew no picture at all). - for _ in 0..400 { - h.run_steps(1); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - assert!(!h.state().render_pending(), "the page raster arrives"); + assert!( + crate::support::step_until(&mut h, |h| { + h.run_steps(1); + !h.state().render_pending() + }), + "the page raster arrives" + ); let page = h.state().views[0].page_screen_rect(0).unwrap(); let at = |x: f32, y: f32| egui::pos2(page.left() + x / 200.0 * page.width(), page.top() + (300.0 - y) / 300.0 * page.height()); let p = at(95.0, 195.0); @@ -5876,13 +5856,7 @@ fn a_paragraph_running_off_its_page_edits_without_covering_the_next_page() { h.drag_at(p); h.run_steps(1); h.drop_at(p); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert!( h.state().views[0].line_editor.as_ref().is_some_and(|e| e.page == 0 && e.text.contains("line 50")), "the paragraph's off-page lines are in it" @@ -5971,15 +5945,13 @@ fn mode_tabs_keep_the_hand_over_page_text_scrolled_under_them() { h.run_steps(4); let top = h.state().views[0].page_screen_rect(0).expect("on screen").top(); let mut cursor = egui::CursorIcon::Default; - for _ in 0..50 { + let text_cursor = crate::support::step_until(&mut h, |h| { h.hover_at(text.center() + egui::vec2(0.0, top)); h.run_steps(2); cursor = h.output().platform_output.cursor_icon; - if cursor == egui::CursorIcon::Text { - break; - } - } - assert_eq!(cursor, egui::CursorIcon::Text); + cursor == egui::CursorIcon::Text + }); + assert!(text_cursor, "the I-beam over page text, not {cursor:?}"); assert_eq!(mode_tab_cursors(&mut h, text), HAND_ON_EVERY_TAB); } diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 230addd656..676a16db4a 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -14,13 +14,7 @@ fn harness() -> Harness<'static, PdfCraftApp> { app.set_option("zoom", "150").unwrap(); app }); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h } @@ -500,8 +494,13 @@ fn wait_for_file(h: &mut Harness<'static, PdfCraftApp>, path: &std::path::Path) h.run_steps(1); } +/// A fresh directory of its own for each call. The tests share one process and run in parallel, +/// so a name alone is not enough: two tests that both used "same-frame" deleted each other's +/// directory mid-save, and the save then never wrote its file (#4186). fn scratch(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("pdfcraft-forms-{name}-{}", std::process::id())); + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("pdfcraft-forms-{name}-{}-{n}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir @@ -1029,13 +1028,7 @@ fn calculated_field_and_script_side_effect_on_another_page_re_render() { event: "calculate".into(), script: Some("event.value = 'from_' + getField('name').value;".into()), })); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); click_field(&mut h, "name", 0); h.event(egui::Event::Text("hello".into())); h.run_steps(2); @@ -1043,13 +1036,7 @@ fn calculated_field_and_script_side_effect_on_another_page_re_render() { h.run_steps(2); assert_eq!(value(&h, "page2_field"), ["from_hello"]); // Wait for all re-renders to complete. - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert_eq!(value(&h, "name"), ["hello"]); assert_eq!(value(&h, "page2_field"), ["from_hello"]); } @@ -1065,13 +1052,7 @@ fn overlay_clears_after_fresh_tiles_zoom_then_edit_and_rejected_edit() { h.key_press(egui::Key::Tab); h.run_steps(2); assert_eq!(value(&h, "name"), ["GoodVal"]); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert!(!h.state().views[0].forms.unbaked.contains(&("name".to_string(), 0))); // 2. Rejected edit: typing rejected by validate script leaves unbaked clean. @@ -1091,13 +1072,7 @@ fn overlay_clears_after_fresh_tiles_zoom_then_edit_and_rejected_edit() { // 3. Zoom then edit: zoom to another scale, let tiles settle, then edit. h.state_mut().views[0].zoom_step(true); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); click_field(&mut h, "name", 0); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); h.event(egui::Event::Text("ZoomedVal".into())); @@ -1105,13 +1080,7 @@ fn overlay_clears_after_fresh_tiles_zoom_then_edit_and_rejected_edit() { h.key_press(egui::Key::Tab); h.run_steps(2); assert_eq!(value(&h, "name"), ["ZoomedVal"]); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert!(!h.state().views[0].forms.unbaked.contains(&("name".to_string(), 0))); } diff --git a/crates/ui-egui/tests/it/object_selection.rs b/crates/ui-egui/tests/it/object_selection.rs index 6e428ad749..fc2a8e4fe7 100644 --- a/crates/ui-egui/tests/it/object_selection.rs +++ b/crates/ui-egui/tests/it/object_selection.rs @@ -73,13 +73,7 @@ fn harness_document(clipping: bool, background: bool) -> Harness<'static, PdfCra assert!(app.execute("edit.edit_text")); app }); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h } diff --git a/crates/ui-egui/tests/it/ocr.rs b/crates/ui-egui/tests/it/ocr.rs index 20fb73344d..7f2e3bdc53 100644 --- a/crates/ui-egui/tests/it/ocr.rs +++ b/crates/ui-egui/tests/it/ocr.rs @@ -133,13 +133,7 @@ fn an_ocr_word_opens_for_editing_and_stays_open() { assert!(app.execute("edit.edit_text")); app }); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); let (r, k) = { let app = h.state(); let d = app.session.get(app.views[0].id).unwrap(); diff --git a/crates/ui-egui/tests/it/pickers.rs b/crates/ui-egui/tests/it/pickers.rs index e4ce56d7c5..bbab087d9a 100644 --- a/crates/ui-egui/tests/it/pickers.rs +++ b/crates/ui-egui/tests/it/pickers.rs @@ -115,9 +115,12 @@ fn a_pick_for_a_document_that_is_no_longer_active_is_dropped() { let _ = std::fs::remove_dir_all(&dir); } -/// A scratch folder for one test. +/// A scratch folder for one test, of its own for each call: the tests share one process and run +/// in parallel, so two that picked the same name would delete each other's folder (#4186). fn scratch(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("pdfcraft-pickers-{name}-{}", std::process::id())); + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("pdfcraft-pickers-{name}-{}-{n}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 106d2d5391..bf0f6c68da 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -17,13 +17,7 @@ fn harness() -> Harness<'static, PdfCraftApp> { app.set_option("zoom", "150").unwrap(); app }); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h } diff --git a/crates/ui-egui/tests/it/redact.rs b/crates/ui-egui/tests/it/redact.rs index c2d150e110..3e11861cbc 100644 --- a/crates/ui-egui/tests/it/redact.rs +++ b/crates/ui-egui/tests/it/redact.rs @@ -14,13 +14,7 @@ fn harness() -> Harness<'static, PdfCraftApp> { app.set_option("zoom", "150").unwrap(); app }); - for _ in 0..60 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h } diff --git a/crates/ui-egui/tests/it/support/mod.rs b/crates/ui-egui/tests/it/support/mod.rs index c4c0ee060e..2e1e11ffb2 100644 --- a/crates/ui-egui/tests/it/support/mod.rs +++ b/crates/ui-egui/tests/it/support/mod.rs @@ -2,21 +2,43 @@ use egui_kittest::Harness; use pdfcraft_ui_egui::PdfCraftApp; -pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl Fn(&PdfCraftApp) -> bool) { +/// Step frames until `ready` holds or 30 seconds pass, and say whether it held. For background work +/// (a worker-thread open, page text arriving) that a test then asserts on itself: a frame count is +/// not a time, and a busy runner may not have finished the work within one. Sleeps briefly between +/// frames so the worker threads get the CPU. +#[must_use] +pub fn step_until(h: &mut Harness<'static, PdfCraftApp>, mut ready: impl FnMut(&mut Harness<'static, PdfCraftApp>) -> bool) -> bool { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); loop { h.step(); - if ready(h.state()) { - return; + if ready(h) { + return true; + } + if std::time::Instant::now() >= deadline { + return false; } - assert!( - std::time::Instant::now() < deadline, - "timed out waiting for {what}: views={}, active={:?}, password={:?}, toast={:?}", - h.state().views.len(), - h.state().active, - h.state().password_prompt.as_ref().map(|p| (&p.name, &p.error)), - h.state().toast - ); std::thread::sleep(std::time::Duration::from_millis(5)); } } + +/// Step frames until nothing is waiting to be rendered, and fail the test if that takes over 30 +/// seconds: what a test checks next is on the rendered page. +pub fn settle(h: &mut Harness<'static, PdfCraftApp>) { + let settled = step_until(h, |h| { + h.run_steps(1); + !h.state().render_pending() + }); + assert!(settled, "pages rendered"); +} + +pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl Fn(&PdfCraftApp) -> bool) { + let held = step_until(h, |h| ready(h.state())); + assert!( + held, + "timed out waiting for {what}: views={}, active={:?}, password={:?}, toast={:?}", + h.state().views.len(), + h.state().active, + h.state().password_prompt.as_ref().map(|p| (&p.name, &p.error)), + h.state().toast + ); +} diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 6472b7c66d..275cdab76e 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -451,7 +451,7 @@ fn overflowing_tabs_can_be_reached_by_scrolling() { assert!(h.state().active.is_some_and(|i| i < 19)); if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") { let _gpu = crate::gpu(); - settle(&mut h); + crate::support::settle(&mut h); h.render().unwrap().save(format!("{dir}/overflow-tabs.png")).unwrap(); } } @@ -565,7 +565,7 @@ fn narrow_tab_strips_accept_vertical_wheel_scrolling() { assert!(h.get_by_label_contains("Display theme:").rect().right() <= width); if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") { let _gpu = crate::gpu(); - settle(&mut h); + crate::support::settle(&mut h); h.render().unwrap().save(format!("{dir}/tabs-{width}.png")).unwrap(); } } @@ -612,23 +612,13 @@ endstream endobj trailer << /Root 1 0 R >> %%EOF"; -fn settle(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..200 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } -} - #[test] fn find_counts_matches_across_pages() { let mut h = harness(|app| { app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); app.set_option("find", "brown").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); h.get_by_label_contains("of 2"); let v = &h.state().views[0]; let f = v.find.as_ref().expect("find open"); @@ -641,7 +631,7 @@ fn find_reports_no_matches() { app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); app.set_option("find", "zebra").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); h.get_by_label("No matches"); } @@ -651,7 +641,7 @@ fn drag_selects_text_and_copy_returns_it() { app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); app.set_option("left", "closed").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); // "The quick brown fox": glyph 4 = 'q', glyph 14 = 'n' of "brown". let (a, b) = { let v = &h.state().views[0]; @@ -738,7 +728,7 @@ fn zoom_keeps_the_point_under_the_cursor_still() { app.set_option("left", "closed").unwrap(); app.set_option("panel", "none").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); let before = h.state().views[0].glyph_screen_pos(0, 4).expect("glyph on screen"); let z = h.state().views[0].zoom; h.state_mut().views[0].zoom_at(z * 2.0, before); @@ -754,7 +744,7 @@ fn trackpad_pinch_zooms_the_pdf_under_the_pointer() { app.set_option("left", "closed").unwrap(); app.set_option("panel", "none").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); let before = h.state().views[0].glyph_screen_pos(0, 4).expect("glyph on screen"); let zoom = h.state().views[0].zoom; h.hover_at(before); @@ -781,7 +771,7 @@ fn selection_works_in_every_view_rotation() { // Keep the whole rotated page on screen. app.set_option("zoom", "60").unwrap(); }); - settle(&mut h); + crate::support::settle(&mut h); let (a, b) = { let v = &h.state().views[0]; (v.glyph_screen_pos(0, 4).expect("glyph"), v.glyph_screen_pos(0, 14).expect("glyph")) @@ -1321,7 +1311,7 @@ fn document_tabs_expose_selection_and_activate_from_keyboard() { } if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") { let _gpu = crate::gpu(); - settle(&mut h); + crate::support::settle(&mut h); h.render().unwrap().save(format!("{dir}/focused-tab-{key:?}.png")).unwrap(); } } @@ -1547,7 +1537,7 @@ fn short_window_rail_buttons_scroll_clear_of_page_navigation() { app.open_bytes("short.pdf", None, FIXTURE.to_vec()).unwrap(); app }); - settle(&mut h); + crate::support::settle(&mut h); let rail = |h: &Harness<'static, PdfCraftApp>, label: &str| { h.query_all_by_label(label).map(|n| n.rect()).find(|r| r.left() >= width - 48.0).unwrap_or_else(|| panic!("{label} in the rail")) }; diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index dc1d5585d9..3520f0a988 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -1266,13 +1266,7 @@ fn highlight_fields_tints_the_field_area() { h.state_mut().set_option("panel", "none").unwrap(); // Highlighting is on by default; start from plain fields. h.state_mut().set_option("fields", "off").unwrap(); - for _ in 0..100 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); let r = rect(&h, 0).expect("page 1"); // The field spans x 50..250, y 300..330 (PDF space, y up) on a 300×400 page; sample inside it. let at = egui::pos2(r.min.x + r.width() * (60.0 / 300.0), r.min.y + r.height() * (1.0 - 305.0 / 400.0)); @@ -1409,13 +1403,7 @@ fn required_radio_buttons_get_a_round_red_border() { app.set_option("fields", "on").unwrap(); app }); - for _ in 0..100 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); let r = rect(&h, 0).expect("page 1"); let ppp = h.ctx.pixels_per_point(); let img = h.render().expect("renders"); @@ -1560,13 +1548,7 @@ fn required_fields_get_a_red_border_when_highlighting() { h.state_mut().set_option("fields", "on").unwrap(); let props = pdfcraft_engine::FieldProps { required: Some(true), ..Default::default() }; h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { name: "fullname".into(), props: Box::new(props) }); - for _ in 0..100 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); let r = rect(&h, 0).expect("page 1"); // Just inside the left edge of the field (x 50, y 300..330 on a 300×400 page). let ppp = h.ctx.pixels_per_point(); @@ -1622,13 +1604,7 @@ trailer << /Root 1 0 R >> /// Mean and largest difference between page 1 on screen and the page rendered directly at the /// view's exact device scale, over the lined area of `page_raster_maps_one_to_one_onto_screen_pixels`. fn compare_page_with_raster(h: &mut Harness<'static, PdfCraftApp>, pdf: &[u8], zoom: f32) -> (f64, u8) { - for _ in 0..100 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(h); let ppp = h.ctx.pixels_per_point(); let r = rect(h, 0).expect("page 1"); let img = h.render().expect("renders"); @@ -1693,16 +1669,6 @@ fn grid_harness(pdf: Vec, zoom: &str) -> Harness<'static, PdfCraftApp> { }) } -fn settle(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..400 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } -} - /// Detail in the middle of the window: the summed difference between neighbouring pixels. fn centre_detail(h: &mut Harness<'static, PdfCraftApp>) -> u64 { let img = h.render().expect("renders"); @@ -1720,7 +1686,7 @@ fn centre_detail(h: &mut Harness<'static, PdfCraftApp>) -> u64 { fn zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive() { let _gpu = gpu(); let mut h = grid_harness(fine_grid_pdf(), "300"); - settle(&mut h); + crate::support::settle(&mut h); let sharp = centre_detail(&mut h); // The first frame at a new zoom has no tiles for it yet (they are requested at its end). h.state_mut().set_option("zoom", "310").unwrap(); @@ -1736,13 +1702,13 @@ fn zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive() { fn returning_to_a_visited_zoom_reuses_its_cached_tiles() { let _gpu = gpu(); let mut h = grid_harness(fine_grid_pdf(), "300"); - settle(&mut h); + crate::support::settle(&mut h); let before = h.state().views[0].tiles_debug(0); assert!(!before.is_empty(), "the 300 % page should be tiled, but it cached no tiles"); // Zoom right out, where the 1200 pt page fits in one raster and is not tiled at all, so // none of its tiles is demanded... h.state_mut().set_option("zoom", "50").unwrap(); - settle(&mut h); + crate::support::settle(&mut h); // ...then back to the first zoom, which was already rendered sharp. h.state_mut().set_option("zoom", "300").unwrap(); h.step(); diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index a3c86fc52a..8052e6a316 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -40,13 +40,7 @@ fn harness() -> Harness<'static, PdfCraftApp> { app }); // Background rendering and text extraction can take seconds on a loaded CI runner (FreeBSD VM). - for _ in 0..3000 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h } @@ -1732,13 +1726,7 @@ fn a_picture_inside_form_artwork_under_text_is_selected_and_copied() { app.open_bytes("scan.pdf", None, pdf).unwrap(); app }); - for _ in 0..40 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); h.state_mut().system_clipboard = false; let i = h.state().active.unwrap(); let r = h.state().views[i].page_screen_rect(0).expect("on screen"); diff --git a/crates/ui-egui/tests/it/xfa.rs b/crates/ui-egui/tests/it/xfa.rs index cf0fc193b1..6db98a52b0 100644 --- a/crates/ui-egui/tests/it/xfa.rs +++ b/crates/ui-egui/tests/it/xfa.rs @@ -103,17 +103,6 @@ fn ordinary_forms_and_documents_have_no_xfa_notice() { assert_eq!(xfa(&h), Some(pdfcraft_render::Xfa::Dynamic)); } -/// Run frames until the pages are drawn (fields can be clicked then). -fn wait_for_pages(h: &mut Harness<'static, PdfCraftApp>) { - for _ in 0..40 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } -} - /// Click the centre of a field's widget. fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) { let p = { @@ -133,7 +122,7 @@ fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) { #[test] fn xfa_buttons_run_their_scripts_in_the_app() { let mut h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); - wait_for_pages(&mut h); + crate::support::settle(&mut h); let names = |h: &Harness<'static, PdfCraftApp>| { let s = h.state(); s.session.get(s.views[0].id).unwrap().form.iter().map(|f| f.name.clone()).collect::>() @@ -189,7 +178,7 @@ fn a_form_whose_scripts_were_turned_off_at_open_says_so() { fn scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back() { let tpl = pdfcraft_xfa::fixtures::scripted_template().replace(r#"xfa.host.messageBox("Hello " + qty.rawValue);"#, RUNAWAY); let mut h = open(pdfcraft_xfa::fixtures::shell(&tpl)); - wait_for_pages(&mut h); + crate::support::settle(&mut h); // The form opens with its scripts on, and the user closes the notice bar. let id = h.state().views[0].id; assert!(!h.state().session.get(id).unwrap().xfa_scripts_off()); From c894928e6fdfd3861795e43b1446c6cfc23cc9ab Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:37:33 -0400 Subject: [PATCH 15/15] fix(demo): showcase font-display, distinct link text, SVG accessible names (Sonar) (#4402) Co-authored-by: Claude Sonnet 5.5 --- assets/demo/showcase.html | 78 +++++++++++++++++++-------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/assets/demo/showcase.html b/assets/demo/showcase.html index 22e0619eae..b26f24565a 100644 --- a/assets/demo/showcase.html +++ b/assets/demo/showcase.html @@ -14,27 +14,27 @@ (highlight, sticky note, stamp, ...), so annotation geometry follows the layout. -->