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/10] 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 f87dccd61..595ca7b18 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 5dd206b45..d56e0e65e 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/10] 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 f94d7dbe4..53ed10344 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 c6ee0e1c3..6c9994ae3 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 4902d72f5..9b1c49729 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 d9334d84c..4a4f5333a 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/10] =?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 0665ad1d3..f6123cc5c 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 593ae6e68..19c81bf00 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 4be205f6e..106c1febf 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 397f6fc38..bc87a1663 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 000000000..16a863c68 --- /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 e6103a5ed..e778a42ad 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 64069a3de..d309c6ba8 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 37a2ea74d..61061f550 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 1f8d2f19e..0f43b0636 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 e2c6a0dbd..383dc1e7a 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 b2e9ffeb2..1e569230d 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 54935c77b..4349df592 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 ccacccd09..2d9aff7f5 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 4af0cd98c..6d950a71c 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 abd5f070c..a34a9be4b 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/10] 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 92f51290d..2714debb6 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 6b44817e1..c6b64da48 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 1c6cce6a9..1142fed33 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 8e7db8cf0..f64ded099 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 f06c09944..442fcf8a4 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/10] 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 53ed10344..ac276f13c 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 000000000..b7b1d409c --- /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/10] 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 d393e3726..c017ff78d 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/10] 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 6c9994ae3..c6ee0e1c3 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/10] 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 453170904..11eb732be 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 dff8efc3d..8f17a8aca 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 c091cbd97..934fc0618 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 029dc5bc9..1b090b373 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 cc3a7e86e..e5a2b453f 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 f771987a8..25f92530b 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 c6b64da48..40084794b 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 2d9aff7f5..fc161e99a 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 6d950a71c..4ec39848c 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 f64ded099..0212e165a 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 442fcf8a4..24ddd16d1 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/10] 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 f6123cc5c..2ecfe99a0 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 19c81bf00..deb37ceb9 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 106c1febf..c0bfada2d 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 fc161e99a..880036237 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 a34a9be4b..b3393d770 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/10] 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 ecee45a6d..e78fe1ea3 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: