mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
Merge upstream main a2ec27b4 into pr/attachments: translation catalogs and notes kept from both sides, ATTRIBUTION hashes recomputed (conflicted: nothing)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+69
-136
@@ -1,156 +1,89 @@
|
||||
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
|
||||
|
||||
# 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'
|
||||
persist-credentials: false
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV"
|
||||
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -54,16 +54,17 @@ 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.
|
||||
- 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
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
<!-- Generated from ATTRIBUTION.toml by `cargo xtask assets --write`. Do not edit by hand. -->
|
||||
|
||||
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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+3
-34
@@ -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 = "99f5022c2d0bb5354701fa6b3027ea8e1084f01a8419a22f78649359f8ea6452"
|
||||
|
||||
# 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 = "c26f12b3a0d534ed43d07b5f6e5cf4b41f85b466bd24fd77834472beb8ba1997"
|
||||
|
||||
[[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 = "830fb8bebed75eec6cdd6e9796a2015670898e99170c9be636d688c476e99716"
|
||||
|
||||
[[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 = "e978133833ba0667fa273455e7c2613f6ec67b10b749f1bfe8ccc5fc02d8f92d"
|
||||
|
||||
[[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 = "a677987ebd26576d3d7807006874b1b98a5b0bbcac57a296e7574247e5dd0733"
|
||||
|
||||
[[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 = "2588aefb9cea15de3a4b5cf797cae3bb319bd246291acf9dc700accdc775518c"
|
||||
|
||||
[[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 = "884ec27e1bc156a9efc3e443a7fa582f67b9069e4739303ab09c8a5c35de388e"
|
||||
|
||||
[[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 = "93c89bd7ee47eb5e67df51c4ba9ad85c28626427ded28377c43881cc1603887f"
|
||||
|
||||
[[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 = "8d80789e30b490423e3336a4b8423eeb861c8ead95ed7cdc2848712c5f6dce8a"
|
||||
|
||||
[[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 = "0fdd70e12441ebf1a730c648a45dd5d22071059f65e027d65fc73b35055bb693"
|
||||
|
||||
[[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 = "767a68f43150135a5dd6d1c0b824ff0c2d015de67c53c6adea2d08e544607387"
|
||||
|
||||
[[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 = "488e1694afe4252a6c54bf225f81c0d0ea4904cb67e64a949ec1ac41174be31e"
|
||||
|
||||
[[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 = "3f2ca7dbc614f275aa0d20b8e803e35ce44448846535bfbd675b9e441b575c17"
|
||||
|
||||
[[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 = "3fd64e50f6cb072b5d1da1ae84a5184e2a78a8cf9ad29b390af250c86eecdf8f"
|
||||
|
||||
[[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 = "a78857855b868baddc31a19e6ac1bf0633e0b8e6e5bd89086721ae98dc9e9716"
|
||||
|
||||
[[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 = "4058c4665677d645ed0bb577cd949239011dde4b36dc7611b92a33322aa6bcd4"
|
||||
|
||||
[[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 = "74c122206537812ec832b5f43bd31038b4504760cdff4a506a81f28a722c948e"
|
||||
|
||||
[[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 = "1661994e29cef0316c40871bb0ce7e000c2bc60ce6f3bf6edce4ac2c76b09120"
|
||||
|
||||
[[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 = "1bee006dd21f281035c249a17a3bc4ccf728e8f24b3e6233945e66d12c24e73b"
|
||||
|
||||
[[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 = "2ffc0436805e980e19efd804f4f405b425b8820d25bffb70044aade3abae3286"
|
||||
|
||||
[[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 = "e80548e47936ef352d36d7528855221f5d4f7e101633ac99c7edaaef7511e4f8"
|
||||
[[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 = "ee3be7369276cb11a73bbab3b8fe077e8eb51742f3f45c6a0244d91f57b77475"
|
||||
[[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 = "a7e1e7416f445d46795efb644de6c3fcac7e756265463853edfb03b341c6b708"
|
||||
|
||||
[[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 = "5104d76e4d884602cb7b66c4683bda0c16c7973ab43ab55848bf468bb20f34d0"
|
||||
|
||||
|
||||
[[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 = "df64f942d5017971b1d93afff9498c5b29c3688ca012127c5692829e121252c0"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
@@ -3393,7 +3368,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"
|
||||
@@ -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 = "29a8fc1210a31cda3000bf219239bd9d1850c8ab1e69b42fde65aad4a1d6f129"
|
||||
|
||||
[[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 = "305cad312a6f54732ab0cb572b10b35693c9d51ed07cf7a453632a44bc7b7961"
|
||||
|
||||
[[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 = "385382e858b826b76fba14886ceb23cf9f00ab556cafa4baa95c25701787189d"
|
||||
|
||||
[[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 = "f08a77efcccc52d852b8f0bc983db6557161a0fc6602af0f298f83c14839f5b3"
|
||||
|
||||
+6
-6
@@ -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]
|
||||
|
||||
@@ -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<String, Value> {
|
||||
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;
|
||||
|
||||
@@ -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<PathBuf, RemoteDocumentPathError> {
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
+10
-8
@@ -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<String, String> {
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
@@ -43,10 +42,13 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+20
-14
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
+139
-2473
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()), "Низкая");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -331,8 +331,7 @@ fn other_candidates() -> Vec<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
// Microsoft YaHei / JhengHei, then the older SimSun / MingLiU collections.
|
||||
@@ -363,8 +362,7 @@ fn han_candidates(traditional: bool) -> Vec<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<PathBuf> {
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: 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
|
||||
@@ -99,7 +99,10 @@ 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), `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`),
|
||||
@@ -110,4 +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`) |
|
||||
|
||||
+8
-5
@@ -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
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+11
-10
@@ -20,21 +20,21 @@ 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.
|
||||
|
||||
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).
|
||||
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,8 +72,9 @@ 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/<code>.tsv`, with one entry per line: `context<TAB>English<TAB>translation`. 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/<code>.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
|
||||
@@ -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.
|
||||
|
||||
@@ -92,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 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.
|
||||
|
||||
@@ -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-<name>).
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9965,8 +9965,8 @@ 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"]
|
||||
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."
|
||||
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]]
|
||||
id = "misc.prefs-reading"
|
||||
|
||||
+78
-14
@@ -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<String>,
|
||||
#[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 => {}
|
||||
@@ -230,6 +248,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<String>| {
|
||||
for id in licence_ids(licence) {
|
||||
if !ALLOWED_LICENCES.contains(&id) {
|
||||
@@ -248,16 +269,22 @@ 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") {
|
||||
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 {
|
||||
@@ -271,10 +298,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 +312,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 +324,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://") {
|
||||
@@ -375,7 +402,7 @@ pub fn render_markdown(m: &Manifest) -> String {
|
||||
s.push_str("<!-- Generated from ATTRIBUTION.toml by `cargo xtask assets --write`. Do not edit by hand. -->\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('|', "\\|");
|
||||
@@ -460,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,
|
||||
}
|
||||
}
|
||||
@@ -527,6 +554,43 @@ 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::<String>::new());
|
||||
let stale = format!("sha256 = \"{}\"\n", "0".repeat(64));
|
||||
assert_eq!(check_one(parsed(ja, "translation", &stale)), Vec::<String>::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")]);
|
||||
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() };
|
||||
@@ -544,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:?}");
|
||||
}
|
||||
|
||||
+55
-11
@@ -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,9 @@ pub fn ci(_: &[String]) -> anyhow::Result<()> {
|
||||
let steps: Vec<Step> = 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(&[]))),
|
||||
("i18n-report", Box::new(|| i18n_report(&["--workspace".to_string()]))),
|
||||
("layers", Box::new(|| layers(&[]))),
|
||||
("wasm", Box::new(|| wasm(&[]))),
|
||||
("assets", Box::new(|| crate::assets::run(&[]))),
|
||||
@@ -117,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<()> {
|
||||
|
||||
+8
-2
@@ -19,9 +19,15 @@ 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, 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),
|
||||
|
||||
Reference in New Issue
Block a user