mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-10 04:11:40 +00:00
Merge PR branch history before publishing local fixes.
Generated with Codebuff 🤖 Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Copies of the vendored krilla source diff, one per patch (see vendor/krilla/README.craft.md).
|
||||
vendor/krilla/patches/*.patch linguist-generated=true
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
@@ -20,10 +20,10 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- run: cargo fmt --check
|
||||
- run: cargo clippy -p craft-i18n --all-targets --locked -- -D warnings
|
||||
- run: cargo test -p craft-i18n --locked -- --nocapture
|
||||
- run: cargo clippy -p craft-i18n --all-targets --features collate --locked -- -D warnings
|
||||
- run: cargo test -p craft-i18n --features collate --locked -- --nocapture
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Shared JPEG
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["crates/jpeg/**", "Cargo.toml", "Cargo.lock", ".github/workflows/jpeg.yml"]
|
||||
pull_request:
|
||||
paths: ["crates/jpeg/**", "Cargo.toml", "Cargo.lock", ".github/workflows/jpeg.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: shared-jpeg-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
timeout-minutes: 10
|
||||
name: JPEG lint, tests, WASM
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: craft-jpeg
|
||||
- run: cargo fmt -p craft-jpeg --check
|
||||
- run: cargo clippy -p craft-jpeg --all-targets --locked -- -D warnings
|
||||
- run: cargo test -p craft-jpeg --locked
|
||||
- run: cargo check -p craft-jpeg --locked --target wasm32-unknown-unknown
|
||||
@@ -0,0 +1,92 @@
|
||||
name: Keymap
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["crates/keymap/**", "crates/settings/**", "crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"]
|
||||
pull_request:
|
||||
paths: ["crates/keymap/**", "crates/settings/**", "crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: keymap-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
timeout-minutes: 15
|
||||
name: Keymap (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/.test-inputs/craft-fonts
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Checkout test fonts
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
path: .test-inputs/craft-fonts
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: craft-keymap-linux
|
||||
- run: cargo fmt -p craft-keymap --check
|
||||
- run: cargo clippy -p craft-keymap --all-targets --no-default-features --locked -- -D warnings
|
||||
# `snapshot` covers `ui` and `egui` and compiles the dialog screenshots, which need a GPU
|
||||
# to run, so the test steps leave them out.
|
||||
- run: cargo clippy -p craft-keymap --all-targets --features snapshot --locked -- -D warnings
|
||||
# Doctests run once, in the `ui` step.
|
||||
- name: Test core
|
||||
run: cargo test -p craft-keymap --no-default-features --lib --tests --locked
|
||||
- name: Test egui dispatch and the shortcuts dialog
|
||||
run: cargo test -p craft-keymap --features ui --locked
|
||||
native:
|
||||
timeout-minutes: 15
|
||||
name: Keymap (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/.test-inputs/craft-fonts
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Checkout test fonts
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
path: .test-inputs/craft-fonts
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: craft-keymap-${{ matrix.os }}
|
||||
- run: cargo test -p craft-keymap --features ui --locked
|
||||
wasm:
|
||||
timeout-minutes: 10
|
||||
name: Keymap WASM
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: craft-keymap-wasm
|
||||
- run: cargo check -p craft-keymap --features ui --locked --target wasm32-unknown-unknown
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
name: Media lint, feature sets, WASM
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
name: Media tests with ffmpeg oracles
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
|
||||
@@ -3,9 +3,9 @@ name: Shared PDF
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["crates/pdf/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/*fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"]
|
||||
paths: ["crates/pdf/**", "crates/plate-test/**", "crates/prepress/**", "crates/cms/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/*fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"]
|
||||
pull_request:
|
||||
paths: ["crates/pdf/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/*fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"]
|
||||
paths: ["crates/pdf/**", "crates/plate-test/**", "crates/prepress/**", "crates/cms/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/*fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -26,14 +26,14 @@ jobs:
|
||||
env:
|
||||
RUSTFLAGS: --cfg craft_pdf_unicode_fixtures
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: craft-pdf
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Provision licensed PDF test fonts
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
name: PDF vendored contracts
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
@@ -63,14 +63,14 @@ jobs:
|
||||
RUSTFLAGS: --cfg craft_pdf_unicode_fixtures
|
||||
CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: craft-pdf-wasm
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Provision licensed PDF test fonts
|
||||
@@ -86,3 +86,25 @@ jobs:
|
||||
cargo install wasm-bindgen-cli --version "$runner_version" --locked
|
||||
- run: cargo test -p craft-pdf --all-features --locked --target wasm32-unknown-unknown
|
||||
- run: cargo test --manifest-path crates/pdf/read-only-contract/Cargo.toml --locked --target wasm32-unknown-unknown
|
||||
plate-test:
|
||||
name: Plate tests and craft-prepress
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: craft-plate-test
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Provision licensed PDF test fonts
|
||||
shell: bash
|
||||
run: |
|
||||
python scripts/test-fetch-pdf-test-fonts.py
|
||||
python scripts/fetch-pdf-test-fonts.py "$RUNNER_TEMP/craft-pdf-test-fonts"
|
||||
echo "CRAFT_PDF_TEST_FONT_DIR=$RUNNER_TEMP/craft-pdf-test-fonts" >> "$GITHUB_ENV"
|
||||
- run: cargo clippy -p craft-plate-test -p craft-prepress --all-targets --all-features --locked -- -D warnings
|
||||
- run: cargo test -p craft-plate-test -p craft-prepress --all-features --locked
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
@@ -16,6 +16,8 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
# Track shared CI improvements on main.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
with:
|
||||
pdf-test-fonts: true
|
||||
secrets: inherit
|
||||
|
||||
@@ -3,9 +3,9 @@ name: Shared UI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["crates/ui/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"]
|
||||
paths: ["crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"]
|
||||
pull_request:
|
||||
paths: ["crates/ui/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"]
|
||||
paths: ["crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/ui.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Install software Vulkan renderer
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
name: UI WASM
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: App updates
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['crates/update/**', 'Cargo.toml', 'Cargo.lock', 'rustfmt.toml', 'clippy.toml', '.github/workflows/update.yml']
|
||||
pull_request:
|
||||
paths: ['crates/update/**', 'Cargo.toml', 'Cargo.lock', 'rustfmt.toml', 'clippy.toml', '.github/workflows/update.yml']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update:
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The token isn't needed after checkout; cargo runs build scripts and proc macros from
|
||||
# dependencies, so don't leave it in .git/config for that third-party code to read.
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- run: cargo fmt --check
|
||||
# Default (no egui) and the egui feature both build and lint cleanly.
|
||||
- run: cargo clippy -p craft-update --all-targets --locked -- -D warnings
|
||||
- run: cargo clippy -p craft-update --all-targets --locked --features egui -- -D warnings
|
||||
- run: cargo test -p craft-update --locked -- --nocapture
|
||||
- run: cargo test -p craft-update --locked --features egui -- --nocapture
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# The token isn't needed after checkout; cargo runs build scripts and proc macros from
|
||||
# dependencies, so don't leave it in .git/config for that third-party code to read.
|
||||
|
||||
+19
-5
@@ -8,7 +8,20 @@ author, source and license. Library code and original assets are under the repos
|
||||
|---|---|---|---|
|
||||
| `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft contributors; generated by `craft_cms::synth::coated_cmyk` | [PhotoCraft `crates/cms`](https://github.com/storytold/photocraft/tree/b3222ee8e7e6bcb4799c37f904c595f20f9baf5e/crates/cms) | CC0-1.0 |
|
||||
| `crates/cms/profiles/craft-coated-fogra39.icc` | craft-libs contributors; generated by `crates/cms/tests/fogra39.rs` from the FOGRA39 data below | this repository | CC0-1.0 |
|
||||
| `crates/font-store/index/sources.json` (unmodified) | craft-font-index contributors | [aiongg/craft-font-index@5a9a7f8](https://github.com/aiongg/craft-font-index/tree/5a9a7f8038c8f3e2e1583a79c92a816e8e71b34f) | Apache-2.0 |
|
||||
| `crates/font-store/index/indexes/google-fonts.json.gz` (unmodified) | craft-font-index contributors; generated from the METADATA.pb files of [google/fonts](https://github.com/google/fonts) at `45e3d5269e4a17d823f6d84470bc084dc5ae46f6` | [aiongg/craft-font-index@5a9a7f8](https://github.com/aiongg/craft-font-index/tree/5a9a7f8038c8f3e2e1583a79c92a816e8e71b34f) | Apache-2.0 |
|
||||
| `crates/font-store/index/indexes/noto.json.gz` (unmodified) | craft-font-index contributors; generated from [notofonts/notofonts.github.io](https://github.com/notofonts/notofonts.github.io) at `93fac8abebe6c7fed0c5b7d2456d8fa484fa146c` and [noto-cjk](https://github.com/notofonts/noto-cjk) releases | [aiongg/craft-font-index@5a9a7f8](https://github.com/aiongg/craft-font-index/tree/5a9a7f8038c8f3e2e1583a79c92a816e8e71b34f) | Apache-2.0 |
|
||||
| `crates/cms/tests/data/FOGRA39.txt` (unmodified) | Fogra | [Fogra characterisation data](https://fogra.org/en/downloads/work-tools/characterisation-data), `FOGRA39_to_FOGRA60_v2.zip` | Fogra terms: free use and redistribution, unmodified, with Fogra identified as the source; "FOGRA39" identifies the reference data only and implies no certification, approval or endorsement by Fogra (full text in [NOTICE](NOTICE)) |
|
||||
| `crates/i18n/data/collation.postcard` | Unicode, Inc. (ICU 78.1 collation data, CLDR 48.2.1); generated by `crates/i18n/data/generate-collation.sh` | [ICU](https://github.com/unicode-org/icu/releases/tag/release-78.1rc) | Unicode-3.0 |
|
||||
| `crates/text/assets/hyphen/en-us.dic` | Grady Ward (Moby Hyphenator II), converted by DesignCraft | [Project Gutenberg eBook #3204](https://www.gutenberg.org/ebooks/3204) | Public domain |
|
||||
| `crates/text/assets/hyphen/en-us.pat` | DesignCraft contributors, trained from the Moby Hyphenator II list | `crates/text/examples/hyphgen.rs` | CC0-1.0 |
|
||||
| Patterns embedded through `hypher` (optional `craft-text` language features) | hyph-utf8 authors, per language | [tex-hyphen](https://github.com/hyphenation/tex-hyphen), as packaged in [hypher](https://github.com/typst/hypher/tree/main/patterns) | Per file header: LPPL, MIT, BSD-3-Clause, Unlicense or a permit-any-use notice (or a choice including one); Slovak (`hyph-sk.tex`, embedded whenever `hypher/slovak` is on: feature `slovak-gpl`, or another dependency through feature unification) is GPL-2.0-or-later; Spanish (`hyph-es.tex` 5.0, Javier Bezos, CervanTeX) is MIT |
|
||||
| Word-break dictionaries and LSTM models embedded through `icu_segmenter_data` (optional `craft-text` features `thai`, `lao`, `khmer`, `burmese`) | Unicode, Inc. and ICU4X contributors | [ICU4X](https://github.com/unicode-org/icu4x) | Unicode-3.0 |
|
||||
| `crates/ui/tests/fonts/NotoSansArabic-test.ttf` (subset to the test strings, default weight set to 100, width axis dropped) | The Noto Project Authors | [google/fonts@7085eb8 `ofl/notosansarabic`](https://github.com/google/fonts/tree/7085eb89a950e85db5b166b7a58d414544b4140c/ofl/notosansarabic), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansArabic.txt`) |
|
||||
| `crates/ui/tests/fonts/NotoSansCJKsc-test.otf` (subset to the test strings) | The Noto Project Authors (Adobe, Google) | [notofonts/noto-cjk@f8d1575 `Sans/OTF/SimplifiedChinese`](https://github.com/notofonts/noto-cjk/tree/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/SimplifiedChinese), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansCJK.txt`) |
|
||||
| `crates/ui/tests/fonts/NotoSansArabic-screens.ttf` (instance at weight 400 and width 100, subset to the screen test strings) | The Noto Project Authors | [google/fonts@7085eb8 `ofl/notosansarabic`](https://github.com/google/fonts/tree/7085eb89a950e85db5b166b7a58d414544b4140c/ofl/notosansarabic), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansArabic.txt`) |
|
||||
| `crates/ui/tests/fonts/NotoSansCJKsc-screens.otf` (subset to printable ASCII and the screen test strings) | The Noto Project Authors (Adobe, Google) | [notofonts/noto-cjk@f8d1575 `Sans/OTF/SimplifiedChinese`](https://github.com/notofonts/noto-cjk/tree/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/SimplifiedChinese), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansCJK.txt`) |
|
||||
| `crates/ui/tests/fonts/NotoSansThai-screens.ttf` (instance at weight 400 and width 100, subset to the screen test strings) | The Noto Project Authors | [google/fonts@8b0a1d0 `ofl/notosansthai`](https://github.com/google/fonts/tree/8b0a1d0f5983c89bc2b93f1b5fb55f9e252744b5/ofl/notosansthai), via craft-fonts | OFL-1.1 (`licenses/ui-test-fonts/OFL-NotoSansThai.txt`) |
|
||||
|
||||
Original assets are covered by the repository licence.
|
||||
|
||||
@@ -28,14 +41,15 @@ The profile licence is retained in `licenses/pdf-writer/LICENSE-ICC.txt`.
|
||||
| `krilla 0.8.2 / icc/sRGB-v4.icc` | Clinton Ingram | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v4.icc | CC0-1.0 | `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353` | PDF writer colour profiles |
|
||||
|
||||
The four Krilla ICC entries above are also stored unchanged in `vendor/krilla/icc`.
|
||||
The vendored source patch and per-file hashes are recorded in `vendor/krilla/README.craft.md` and `vendor/krilla/SOURCE.json`.
|
||||
The vendored source patches are in `vendor/krilla/patches`, described in `vendor/krilla/README.craft.md`; per-file hashes are in `vendor/krilla/SOURCE.json`.
|
||||
|
||||
## Assets retained with the optional PDF reader
|
||||
|
||||
The shared reader centralizes the four existing patched Hayro source trees from PdfCraft.
|
||||
The shared reader centralizes the five patched Hayro source trees from PdfCraft and the
|
||||
patched crates.io `hayro-postscript` 0.1.0 scanner, which bundles no assets.
|
||||
The standard-14 fonts, CMYK profile and generated metrics listed below remain byte-identical
|
||||
in `vendor/hayro-interpret`; no application document fonts are added. CMaps remain a pinned
|
||||
registry dependency. The removed upstream LAB.icc profile is not restored. Source hashes
|
||||
in `vendor/hayro-interpret`, and the CMaps in `vendor/hayro-cmap/assets`; no application
|
||||
document fonts are added. The removed upstream LAB.icc profile is not restored. Source hashes
|
||||
and the exact transfer revision are recorded in each vendored crate's SOURCE.json.
|
||||
|
||||
| Crate / file | Author | Source | Licence | SHA-256 | Use |
|
||||
@@ -55,5 +69,5 @@ and the exact transfer revision are recorded in each vendored crate's SOURCE.jso
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) |
|
||||
| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://crates.io/crates/hayro-cmap/0.1.0 | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged |
|
||||
| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://github.com/storytold/pdfcraft/blob/a2ec27b4775f53c40cad3456936d9406611e3a8d/vendor/hayro-cmap/assets/cmaps.brotli | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged |
|
||||
| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged |
|
||||
|
||||
Generated
+1064
-70
File diff suppressed because it is too large
Load Diff
+33
-1
@@ -1,7 +1,7 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*"]
|
||||
exclude = ["vendor/krilla", "vendor/hayro", "vendor/hayro-interpret", "vendor/hayro-syntax", "vendor/hayro-jbig2"]
|
||||
exclude = ["vendor/krilla", "vendor/hayro", "vendor/hayro-interpret", "vendor/hayro-syntax", "vendor/hayro-jbig2", "vendor/hayro-cmap", "vendor/hayro-postscript"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
@@ -15,21 +15,33 @@ repository = "https://github.com/storytold/craft-libs"
|
||||
craft-platform = { path = "crates/platform" }
|
||||
craft-ai-core = { path = "crates/ai-core" }
|
||||
craft-pdf = { path = "crates/pdf", version = "=0.1.0" }
|
||||
craft-plate-test = { path = "crates/plate-test" }
|
||||
krilla = { version = "=0.8.2", default-features = false, features = ["raster-images"] }
|
||||
hayro = { version = "=0.7.1", default-features = false }
|
||||
hayro-interpret = { version = "=0.7.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] }
|
||||
hayro-syntax = { version = "=0.7.2", default-features = false, features = ["std"] }
|
||||
bytemuck = "1"
|
||||
kurbo = "0.13"
|
||||
thiserror = "2"
|
||||
craft-atomic = { path = "crates/atomic" }
|
||||
craft-cms = { path = "crates/cms" }
|
||||
craft-docpath = { path = "crates/docpath" }
|
||||
craft-font-catalog = { path = "crates/font-catalog" }
|
||||
craft-font-manager = { path = "crates/font-manager" }
|
||||
craft-font-store = { path = "crates/font-store" }
|
||||
craft-fonts-build = { path = "crates/fonts-build" }
|
||||
craft-i18n = { path = "crates/i18n" }
|
||||
craft-jpeg = { path = "crates/jpeg" }
|
||||
craft-keymap = { path = "crates/keymap" }
|
||||
craft-media = { path = "crates/media" }
|
||||
craft-menu = { path = "crates/menu" }
|
||||
craft-prepress = { path = "crates/prepress" }
|
||||
craft-print = { path = "crates/print" }
|
||||
craft-quit = { path = "crates/quit" }
|
||||
craft-settings = { path = "crates/settings" }
|
||||
craft-text = { path = "crates/text" }
|
||||
craft-ui = { path = "crates/ui" }
|
||||
craft-update = { path = "crates/update" }
|
||||
craft-window = { path = "crates/window" }
|
||||
egui = { version = "0.37", default-features = false }
|
||||
rayon = "1"
|
||||
@@ -44,6 +56,9 @@ unsafe_code = "forbid"
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(craft_pdf_unicode_fixtures)"] }
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
too_many_arguments = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
|
||||
@@ -54,3 +69,20 @@ hayro = { path = "vendor/hayro" }
|
||||
hayro-interpret = { path = "vendor/hayro-interpret" }
|
||||
hayro-syntax = { path = "vendor/hayro-syntax" }
|
||||
hayro-jbig2 = { path = "vendor/hayro-jbig2" }
|
||||
hayro-cmap = { path = "vendor/hayro-cmap" }
|
||||
hayro-postscript = { path = "vendor/hayro-postscript" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
@@ -19,6 +19,40 @@ crates/cms/tests/data/FOGRA39.txt: FOGRA39 characterisation data, distributed un
|
||||
the data are distributed unmodified and Fogra is identified as the source. The designation
|
||||
FOGRAxx may be used solely to identify the respective reference data. Such use does not
|
||||
imply certification, approval or endorsement by Fogra."
|
||||
crates/text/assets/hyphen/en-us.dic: public domain, derived from the Moby Hyphenator II list by
|
||||
Grady Ward (Project Gutenberg eBook #3204).
|
||||
crates/text/assets/hyphen/en-us.pat: CC0-1.0, trained from en-us.dic by DesignCraft's trainer.
|
||||
Hyphenation patterns for other languages, embedded through the hypher crate only when the
|
||||
matching craft-text feature is enabled (none are by default). They come from hyph-utf8,
|
||||
https://github.com/hyphenation/tex-hyphen, as packaged in https://github.com/typst/hypher
|
||||
(patterns/). Each file is under the license stated in its header: LPPL, MIT, BSD-3-Clause, the
|
||||
Unlicense, a notice permitting any use, or a choice of licenses that includes one of these. An
|
||||
application that enables a language ships that file's copyright and license notice. The
|
||||
exception is Slovak (hyph-sk.tex, Copyright (C) 1992 Jana Chlebíková), under the GNU General
|
||||
Public License, version 2 or later (SPDX: GPL-2.0-or-later). craft-text asks for it through the
|
||||
feature "slovak-gpl", which "all-languages" does not include; an application that enables it
|
||||
ships GPL-licensed data. Cargo unifies features, so another dependency that enables hypher's
|
||||
"slovak" feature (hypher's default features do) embeds the same data; a build that must exclude
|
||||
it checks its dependency graph. The Spanish patterns (feature "spanish") are hyph-es.tex 5.0.
|
||||
Copyright (C) 1993, 1997 Javier Bezos, 2001-2019 Javier Bezos, CervanTeX. MIT License:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
and associated documentation files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions: The above copyright notice
|
||||
and this permission notice shall be included in all copies or substantial portions of the
|
||||
Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
|
||||
AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
- Word-break dictionaries and LSTM models for Thai, Lao, Khmer and Burmese, embedded through the
|
||||
icu_segmenter_data crate only when craft-text's "thai", "lao", "khmer" or "burmese" feature is
|
||||
enabled (none are by default). From ICU4X, https://github.com/unicode-org/icu4x.
|
||||
Copyright (c) 2020-2024 Unicode, Inc. Unicode License v3 (SPDX: Unicode-3.0); portions adapted
|
||||
from ICU4C, (c) 1995-2016 International Business Machines Corporation and others. An
|
||||
application that enables one of these features ships the Unicode License v3 copyright and
|
||||
permission notice, https://www.unicode.org/license.txt.
|
||||
|
||||
The optional PDF writer bundles four CC0 ICC profiles by Clinton Ingram. See ATTRIBUTION.md for per-file sources and hashes.
|
||||
|
||||
@@ -53,6 +87,13 @@ The transactional panel model and drop-zone semantics in crates/ui/src/docking d
|
||||
FilmCraft/EffectCraft split, tab and accordion layouts and VectorCraft floating groups listed
|
||||
above. The generic transaction validation, optional renderer and workspace-location helpers
|
||||
are implemented here without dependencies on application engines or native window helpers.
|
||||
The hyphenation code, data and generator in crates/text (src/hyphen/, assets/hyphen/ and
|
||||
examples/hyphgen.rs) are adapted from:
|
||||
- DesignCraft, crates/compose/src/hyphen/, crates/compose/examples/hyphgen.rs and
|
||||
assets/hyphenation/, revision e161878e4c7744fa06f9a2eede8103cb4c296355 (above).
|
||||
- WordCraft, crates/proof/src/hyphen.rs, revision e4b7d155f15a7784197ba34eeea0b54490d70eb2.
|
||||
https://github.com/storytold/wordcraft
|
||||
Copyright (c) 2026 ArtCraft Team and the WordCraft contributors.
|
||||
All sources listed above are licensed MIT OR Apache-2.0. No icon artwork is copied here.
|
||||
|
||||
The PDF spooler in crates/print is extracted from:
|
||||
@@ -75,6 +116,31 @@ PrintTicket and its driver-paper preference):
|
||||
Copyright (c) 2026 ArtCraft Team and the PdfCraft contributors.
|
||||
Licensed MIT OR Apache-2.0.
|
||||
|
||||
The menu layout and native-menu driver in crates/menu are extracted from:
|
||||
- PhotoCraft, crates/ui-egui/src/native_menu.rs, revision 6f85054288a564f9fbbccb99268ffdb34e581584.
|
||||
https://github.com/storytold/photocraft
|
||||
Copyright (c) 2026 ArtCraft Team and the PhotoCraft contributors.
|
||||
Licensed MIT OR Apache-2.0. PhotoCraft's menus, translations and command handling are not copied.
|
||||
The macOS backend in crates/menu/src/mac.rs is adapted from:
|
||||
- PhotoCraft, apps/photocraft/src/mac_menu.rs, revision 6f85054288a564f9fbbccb99268ffdb34e581584.
|
||||
https://github.com/storytold/photocraft
|
||||
Copyright (c) 2026 ArtCraft Team and the PhotoCraft contributors.
|
||||
- VectorCraft, apps/vectorcraft/src/mac_menu.rs, revision 0d0ea352df99044589a7ce0efc7004f7a5b6b7c0
|
||||
(closing an open menu on a click or Escape, and its tests, in crates/menu/src/driver.rs).
|
||||
https://github.com/storytold/vectorcraft
|
||||
Copyright (c) 2026 ArtCraft Team and the VectorCraft contributors.
|
||||
Both licensed MIT OR Apache-2.0.
|
||||
The egui menu bar in crates/menu/src/bar (its menu navigation, scrolling, submenu hover
|
||||
intent, keyboard access and mnemonics, and their tests) is ported from:
|
||||
- PhotoCraft, crates/ui-egui/src/menu_nav.rs and menu_nav_tests.rs, menu_keys.rs and
|
||||
menu_keys_tests.rs, and the menu bar in crates/ui-egui/src/menus.rs, revision
|
||||
c36e46c3873ee39f299cc50a3dcd28494c78366d.
|
||||
https://github.com/storytold/photocraft
|
||||
Copyright (c) 2026 ArtCraft Team and the PhotoCraft contributors, among them aiongg, artman67,
|
||||
BFlat, Brandon Thomas, Brennan Stehling, Daniel Lošák, David Mann, Fabio Lissi, FredApps,
|
||||
Higgs32584, itsF4LCON, jason4short, Korg1ch, MarekSpodBiedry and Nigel Ihza Firdausy.
|
||||
Licensed MIT OR Apache-2.0. PhotoCraft's menus, theme and Help search are not copied.
|
||||
|
||||
The colour management module in crates/cms is extracted from:
|
||||
- PhotoCraft, crates/cms, revision b3222ee8e7e6bcb4799c37f904c595f20f9baf5e.
|
||||
https://github.com/storytold/photocraft
|
||||
@@ -91,20 +157,31 @@ The optional writer embeds Compact ICC Profiles by Clinton Ingram
|
||||
See licenses/pdf-writer/LICENSE-ICC.txt and ATTRIBUTION.md for sources and hashes.
|
||||
The writer adds no font files. Its four existing ICC profiles are retained under vendor/krilla/icc.
|
||||
|
||||
Krilla is vendored with a native unwind-cleanup patch; see vendor/krilla/README.craft.md
|
||||
Krilla is vendored with the local patches in vendor/krilla/patches; see vendor/krilla/README.craft.md
|
||||
and vendor/krilla/SOURCE.json. Its upstream MIT/Apache-2.0 licences and CC0 profiles are unchanged.
|
||||
|
||||
Optional PDF reader
|
||||
|
||||
Hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0
|
||||
(https://github.com/LaurenzV/hayro), MIT OR Apache-2.0, retain their upstream licence files.
|
||||
These patched sources are transferred from PdfCraft revision
|
||||
7437bde378f43856eada40af25b983812e615e8a (https://github.com/storytold/pdfcraft).
|
||||
Hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0, hayro-cmap 0.1.0
|
||||
and hayro-postscript 0.1.0 (https://github.com/LaurenzV/hayro), MIT OR Apache-2.0, retain their
|
||||
upstream licence files. The first five are patched sources transferred from PdfCraft
|
||||
(https://github.com/storytold/pdfcraft): hayro-cmap from revision
|
||||
a2ec27b4775f53c40cad3456936d9406611e3a8d, the others from revision
|
||||
ec0a965074f376a5bf610435948e4e5184bf3299. hayro-postscript is the crates.io 0.1.0 package
|
||||
(upstream revision a2354aebaf86f5ab13e5fd6b6b0521306488bf9d) with one local patch.
|
||||
See vendor/README.craft-reader.md and each crate's SOURCE.json for provenance and local changes.
|
||||
The hayro-interpret plate mode (separations, overprint and output-intent simulation) is ported
|
||||
from PdfCraft pull request #2770 by Guillem Hermida, commit
|
||||
6fcf2a4aca29e943e28cb69017ff8f87d00656c6 (https://github.com/storytold/pdfcraft/pull/2770),
|
||||
MIT OR Apache-2.0; crates/pdf/tests/plates.rs and tests/support/synthetic_icc.rs adapt its tests.
|
||||
Knockout transparency groups in hayro-interpret and hayro are ported from PdfCraft pull request
|
||||
#2974 by mtlcl410, commit bae69819594a6385a027fec1f67e6b3c36c259b4
|
||||
(https://github.com/storytold/pdfcraft/pull/2974), MIT OR Apache-2.0;
|
||||
crates/pdf/tests/knockout.rs adapts its test.
|
||||
The reader retains the existing Foxit/PDFium standard-14 font assets (BSD-3-Clause),
|
||||
Compact ICC Profiles by Clinton Ingram (CC0-1.0), and dependency CMaps (BSD-3-Clause).
|
||||
Their licences and per-file hashes are retained in vendor/hayro-interpret/assets,
|
||||
licenses/pdf-backend and ATTRIBUTION.md. These are existing reader dependency assets;
|
||||
vendor/hayro-cmap/assets, licenses/pdf-backend and ATTRIBUTION.md. These are existing reader dependency assets;
|
||||
applications retain their own document-font selection and licensing policy.
|
||||
|
||||
Platform lifecycle source provenance
|
||||
|
||||
@@ -37,13 +37,40 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
- [`craft-docpath`](crates/docpath/src/lib.rs): decides whether a path or `file:` URL stored in a
|
||||
document may be opened. Paths stored in documents are followed only when they are local or on the
|
||||
document's own network share; other network paths, device paths and non-file URLs are refused.
|
||||
- [`craft-font-catalog`](crates/font-catalog/README.md): the installed fonts on every platform
|
||||
(font folders, fontconfig and Flatpak host folders, CoreText, DirectWrite, the font store),
|
||||
lookup by family, style, localized, legacy, full and PostScript names, family loads shared
|
||||
between threads, and rescans when fonts are installed or removed.
|
||||
- [`craft-font-manager`](crates/font-manager/README.md): Craft Font Manager as a library: search
|
||||
and filters over the Google Fonts index, installed fonts by family, the font store interface,
|
||||
and (`egui` feature) `FontsPanel`, the whole interface as one panel for the launcher's Fonts tab
|
||||
and the standalone app.
|
||||
- [`craft-font-store`](crates/font-store/README.md): the suite's font store: the pinned Google
|
||||
Fonts and Noto indexes plus indexes the user adds (never curated), exact lookup by family and
|
||||
style or PostScript name, and installs from each source's URLs (jsDelivr, then raw GitHub)
|
||||
checked by size, sha256 and parsing, with the licence, a manifest, and removal. Off until the
|
||||
user turns downloading on; agents can't.
|
||||
- [`craft-fonts-build`](crates/fonts-build/README.md): embeds the optional craft-fonts faces
|
||||
from an app's `build.rs` (`CRAFT_FONTS_DIR`, `CRAFT_FONTS_REQUIRED`, script filters) as a table
|
||||
of `EmbeddedFace`s. No dependencies; the app picks the faces.
|
||||
- [`craft-i18n`](crates/i18n/README.md): translations over the embedded craft-i18n catalogs:
|
||||
lookup with fallback (`pt-br` → `pt` → English), plurals, language names, the system locale and
|
||||
a pseudo locale. The catalogs stay a fully optional build input; apps keep `tl!` and their
|
||||
language setting.
|
||||
- [`craft-jpeg`](crates/jpeg/README.md): clean-room decoder for 12-bit and arithmetic-coded
|
||||
JPEG (T.81 SOF0/SOF1 at 12 bits, SOF9, SOF10), from PhotoCraft, and a frame probe for routing.
|
||||
Metadata stays in each app.
|
||||
- [`craft-keymap`](crates/keymap/README.md): keyboard bindings: chords and their text forms,
|
||||
scopes, conflicts, user overrides stored through craft-settings and import of other
|
||||
applications' shortcut sets. Command catalogs, file readers and the shortcut editor UI stay
|
||||
in each app.
|
||||
- [`craft-media`](crates/media/README.md): bit-level I/O (`bitstream`), clean-room MP4/MOV
|
||||
(`isobmff`) and Matroska/WebM (`matroska`) demuxers and muxers, from FilmCraft, and an ISO BMFF
|
||||
box reader. Containers only; codecs stay in each app.
|
||||
- [`craft-menu`](crates/menu/README.md): an app's menus as a macOS menu bar: the Mac layout
|
||||
(app menu, Window, Help, system-key clashes), a platform backend trait, and key equivalents
|
||||
fed back to egui as the input egui-winit would have made. Commands and their enabled state
|
||||
stay in each app.
|
||||
- [`craft-print`](crates/print/README.md): PDF submission to CUPS and Windows, printer discovery,
|
||||
duplex/copies/colour and driver preferences. PDF generation stays in each app.
|
||||
|
||||
@@ -51,6 +78,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
app menu. Unsaved-document decisions and save prompts stay in each app.
|
||||
|
||||
- [`craft-settings`](crates/settings/README.md): `config.toml` settings storage for every app: platform folders, portable mode and overrides, atomic saves while running, backup, damaged-file recovery and schema migrations. Policy in [docs/settings-storage.md](docs/settings-storage.md).
|
||||
- [`craft-text`](crates/text/README.md): text processing below the composer. Its `hyphen` module finds
|
||||
hyphenation points, with a bundled US English dictionary and patterns and optional hypher patterns
|
||||
for 47 more languages (one cargo feature each).
|
||||
- [`craft-ui`](crates/ui/README.md): SVG catalogs, icon buttons, tabs, pane controls, transactional docking, print settings and theme helpers for egui frontends;
|
||||
assets, themes, localization and command handling stay in each app. Requires Rust 1.95 or later.
|
||||
- [Shared UI component audit](docs/ui-components-audit.md): source inventory, extraction priorities
|
||||
|
||||
@@ -2,3 +2,4 @@ allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
cognitive-complexity-threshold = 30
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "craft-font-catalog"
|
||||
description = "Installed fonts for the Craft apps: discovery on every platform, family, style and PostScript lookup, and loading shared between threads."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["system"]
|
||||
# The platform's font service through fontique: CoreText on macOS, DirectWrite on Windows,
|
||||
# fontconfig on Linux and FreeBSD. The same version and features craft-ui's system fonts use, so
|
||||
# the apps build fontique once.
|
||||
system = ["dep:fontique"]
|
||||
|
||||
[dependencies]
|
||||
skrifa = { version = "0.47", default-features = false, features = ["std"] }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
fontique = { version = "0.11.1", default-features = false, features = ["system", "fontconfig-dlopen"], optional = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-registry = "0.6"
|
||||
|
||||
[dev-dependencies]
|
||||
write-fonts = "0.54"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,83 @@
|
||||
# craft-font-catalog
|
||||
|
||||
The installed fonts for the Craft apps: discovery on every platform, lookup by family and style or by any other name, and loading shared between threads.
|
||||
|
||||
## What it finds
|
||||
|
||||
A `Catalog` reads, in this order:
|
||||
|
||||
| Where | Source | Platforms |
|
||||
|---|---|---|
|
||||
| The platform's font folders (`font_dirs`) | `System` | all |
|
||||
| The suite's font store (`font_store_dir`) | `Store` | all |
|
||||
| The app's own folders (`Catalog::set_app_dirs`, for example `office_font_dirs`) | `App` | all |
|
||||
| Files the platform's font service knows (`system` feature, through fontique) | `System` | macOS, Windows, Linux, FreeBSD |
|
||||
| The registry's font list | `System` | Windows |
|
||||
| Files from injected listers (`Catalog::with_lister`) | `System` | all |
|
||||
|
||||
The font folders:
|
||||
|
||||
- macOS: `/System/Library/Fonts`, `/Library/Fonts`, `/Network/Library/Fonts`, `~/Library/Fonts`, `/System/Library/Assets` and the font assets macOS downloads on demand (`AssetsV2/com_apple_MobileAsset_Font*`).
|
||||
- Windows: `%WINDIR%\Fonts` and `%LOCALAPPDATA%\Microsoft\Windows\Fonts`.
|
||||
- Linux and the BSDs: `/usr/share/fonts`, `/usr/local/share/fonts`, the Flatpak host folders (`/run/host/fonts`, `/run/host/local-fonts`, `/run/host/user-fonts`), `~/.local/share/fonts`, `~/.fonts`, `$XDG_DATA_HOME/fonts`, `$XDG_DATA_DIRS/*/fonts`, and the `<dir>` and `<remap-dir>` entries of the fontconfig configuration with its includes and the user's own. Inside a Flatpak sandbox, also the host's folders from `/run/host/font-dirs.xml` and from the host's configuration under `/run/host/etc/fonts`.
|
||||
- Haiku: the system's and the user's `data/fonts` folders.
|
||||
|
||||
The font service covers fonts outside those folders:
|
||||
|
||||
- macOS: CoreText's available fonts, which include fonts that Adobe Fonts, RightFont and other font managers activate from their own folders.
|
||||
- Windows: DirectWrite's system font collection, which includes per-user fonts and the fonts that font services such as Adobe Fonts load at run time. The registry adds fonts that programs register by full path.
|
||||
- Linux and FreeBSD: fontconfig, loaded at run time (`fontconfig-dlopen`), so a system without it still runs.
|
||||
|
||||
`FontEnv` holds the environment all of this depends on (`HOME`, the XDG variables, `FONTCONFIG_FILE`, `WINDIR`, `LOCALAPPDATA`, `CRAFT_FONT_STORE`, a file system root), so tests can describe any system without touching the process environment.
|
||||
|
||||
## The font store
|
||||
|
||||
`font_store_dir` is the one per-user folder that the suite's font store installs fonts into and every app's catalog reads. No other program reads it.
|
||||
|
||||
| Platform | Folder |
|
||||
|---|---|
|
||||
| macOS | `~/Library/Application Support/Craft Fonts` |
|
||||
| Windows | `%LOCALAPPDATA%\Craft Fonts` |
|
||||
| Linux, BSDs, Haiku | `$XDG_DATA_HOME/craft-fonts` (`~/.local/share/craft-fonts`) |
|
||||
|
||||
`CRAFT_FONT_STORE` replaces it, for portable installs and tests.
|
||||
|
||||
## Scanning
|
||||
|
||||
The scan reads each file's table directory and its `name`, `OS/2` and `fvar` tables, at most 1 MiB per table, never the whole file. Faces without outline tables (`glyf`, `CFF `, `CFF2`, `VARC`) are left out. Files in folders count when they have a font extension (`.ttf`, `.otf`, `.ttc`, `.otc`); files a font service or lister names count whatever their name. Mac suitcase fonts are read from their resource fork. Damaged files give no faces, never a panic.
|
||||
|
||||
Each face (`FaceInfo`) has its family and style, legacy family and style, family names in other languages, full name, PostScript name, weight, width, italic, a named instance's axis settings, `fsType`, the OS/2 code page bits, its file and index, and its source. Each named instance of a variable font is a face of its own.
|
||||
|
||||
## Lookup
|
||||
|
||||
- `families()` and `styles(family)`.
|
||||
- `family(name)` and `resolve(name, style)` accept any name of a family: its own name ignoring ASCII case, its names in other languages and its legacy names ignoring case and anything but letters and digits. `resolve` also accepts a legacy family with its legacy style (`Roboto Light` / `Regular`), a full name, and a PostScript name.
|
||||
- Within a family, `resolve` matches the style by name (any of its names). Otherwise it picks the nearest weight, with italic differing counting as 1000, then the nearest width. It returns `Match::Exact` or `Match::NearestStyle`, so the app decides about substitutes.
|
||||
- `by_postscript_name(name)`.
|
||||
|
||||
## Loading and threads
|
||||
|
||||
- The first scan runs on first use, or on a background thread after `scan_in_background`. Lookups wait for it.
|
||||
- Lookups read an immutable snapshot, so a rescan never blocks them.
|
||||
- `load_family` reads a family's files once. Other threads asking for the same family wait and share the result; other families load at the same time. Faces of one file share its bytes (`Arc<[u8]>`).
|
||||
- `add_bytes` and `add_file` add fonts from documents, downloads or the app's bundle, within `Limits`. Added faces win over installed faces of the same family and style. `remove_added` removes a source's faces, for example when a document closes.
|
||||
|
||||
## Changes
|
||||
|
||||
- `rescan` scans again, reading only new and changed files.
|
||||
- `installed_fonts_changed` compares the modification times of every folder the scan read (and the folders of files a font service named) and, on Windows, the registry's font list. It costs a few hundred `stat` calls, not a scan.
|
||||
- `rescan_if_changed` does both; apps call it when their window is activated.
|
||||
- `watch(interval, on_change)` polls on a background thread.
|
||||
- `generation` counts the catalog's changes.
|
||||
|
||||
## One enumeration for the UI and documents
|
||||
|
||||
With the `system` feature, `system::collection()` returns the process's fontique `Collection`: the system's fonts and the font store's. The catalog lists its files, and craft-ui's font fallback can query the same collection instead of building its own, so the system's fonts are enumerated once per process. A rescan builds a new collection; `system::generation()` tells holders of an older clone to take a new one.
|
||||
|
||||
## Try it
|
||||
|
||||
```sh
|
||||
cargo run -p craft-font-catalog --example fonts # counts, folders, faces outside them
|
||||
cargo run -p craft-font-catalog --example fonts -- Arial # a family's faces
|
||||
cargo run -p craft-font-catalog --example fonts -- --watch # report fonts installed or removed
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Lists what the catalog finds on this computer, to check discovery by hand.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p craft-font-catalog --example fonts # counts, folders, font store
|
||||
//! cargo run -p craft-font-catalog --example fonts -- Arial # a family's faces
|
||||
//! cargo run -p craft-font-catalog --example fonts -- --watch # report fonts installed or removed
|
||||
//! ```
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use craft_font_catalog::{Catalog, font_dirs, font_store_dir};
|
||||
|
||||
fn main() {
|
||||
let catalog = Catalog::global();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let start = Instant::now();
|
||||
let families = catalog.families();
|
||||
let faces = catalog.faces();
|
||||
println!("{} families, {} faces, first scan {:?}", families.len(), faces.len(), start.elapsed());
|
||||
println!("font store: {:?}", font_store_dir(catalog.env()));
|
||||
for dir in font_dirs(catalog.env()).iter().filter(|d| d.is_dir()) {
|
||||
println!("folder: {}", dir.display());
|
||||
}
|
||||
let outside: Vec<_> =
|
||||
faces.iter().filter(|f| f.path.as_ref().is_some_and(|p| !font_dirs(catalog.env()).iter().any(|d| p.starts_with(d)))).collect();
|
||||
println!("{} faces outside the font folders (font service, store, registry):", outside.len());
|
||||
for f in outside.iter().take(40) {
|
||||
println!(" {} {} ({:?}) {}", f.family, f.style, f.source, f.path.as_deref().map(|p| p.display().to_string()).unwrap_or_default());
|
||||
}
|
||||
match args.first().map(String::as_str) {
|
||||
Some("--watch") => {
|
||||
let _watch = catalog.watch(Duration::from_secs(2), |c| println!("changed: {} families", c.families().len()));
|
||||
println!("watching; install or remove a font, Ctrl+C to stop");
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(3600));
|
||||
}
|
||||
}
|
||||
Some(name) => {
|
||||
for f in catalog.styles(name) {
|
||||
println!(
|
||||
"{} / {}: weight {} width {} italic {} ps {:?} {:?} #{} {:?}",
|
||||
f.family, f.style, f.weight, f.width, f.italic, f.postscript_name, f.path, f.index, f.source
|
||||
);
|
||||
}
|
||||
if let Some((f, m)) = catalog.resolve(name, args.get(1).map_or("Regular", String::as_str)) {
|
||||
println!("resolve: {} {} ({m:?})", f.family, f.style);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,778 @@
|
||||
//! The catalog: every installed face, found by any of its names, loaded once.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, RwLock, Weak};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use crate::scan::{self, Face, FaceInfo, Source, norm, style_italic, style_weight, style_width};
|
||||
use crate::{FontEnv, Os, font_dirs, font_store_dir};
|
||||
|
||||
/// Lists font files that the platform's font service knows outside the font folders. Called by
|
||||
/// every scan.
|
||||
pub type Lister = Arc<dyn Fn() -> Vec<PathBuf> + Send + Sync>;
|
||||
|
||||
/// How deep the scan goes below a font folder.
|
||||
const MAX_DEPTH: usize = 12;
|
||||
/// The most folder entries one scan looks at.
|
||||
const MAX_ENTRIES: usize = 200_000;
|
||||
/// The most bytes the scan reads from one file (its tables, or a suitcase's resource fork).
|
||||
const MAX_SCAN_READ: u64 = 256 << 20;
|
||||
/// The largest font file loaded.
|
||||
const MAX_LOAD: u64 = 512 << 20;
|
||||
|
||||
/// Why a font file or font data wasn't added or loaded.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FontFileError {
|
||||
/// Reading the file failed.
|
||||
#[error("the font file can't be read: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// The file is larger than the limit.
|
||||
#[error("the font file is {size} bytes, more than the {max} allowed")]
|
||||
TooLarge {
|
||||
/// The file's size.
|
||||
size: u64,
|
||||
/// The limit.
|
||||
max: u64,
|
||||
},
|
||||
/// No face in it could be read.
|
||||
#[error("not a font file, or a damaged one")]
|
||||
NotAFont,
|
||||
/// Adding it would pass the limit on added faces.
|
||||
#[error("more than {max} added font faces")]
|
||||
TooManyFaces {
|
||||
/// The limit.
|
||||
max: usize,
|
||||
},
|
||||
/// The face is no longer in the catalog (its data was removed).
|
||||
#[error("the font is no longer available")]
|
||||
Gone,
|
||||
}
|
||||
|
||||
/// Limits on fonts added from files or bytes ([`Catalog::add_bytes`], [`Catalog::add_file`]),
|
||||
/// which may come from documents or downloads.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// The largest file accepted.
|
||||
pub max_file_bytes: u64,
|
||||
/// The most faces read from one file (a collection).
|
||||
pub max_faces_per_file: u32,
|
||||
/// The most added faces the catalog holds in all.
|
||||
pub max_added_faces: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self { max_file_bytes: 256 << 20, max_faces_per_file: 256, max_added_faces: 1000 }
|
||||
}
|
||||
}
|
||||
|
||||
/// How [`Catalog::resolve`] matched.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Match {
|
||||
/// The family and style asked for, by any of their names.
|
||||
Exact,
|
||||
/// The family, in the style nearest to the one asked for.
|
||||
NearestStyle,
|
||||
}
|
||||
|
||||
/// A face's data: the whole file (or a suitcase font's resource) and the face's index in it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FontData {
|
||||
/// The file's bytes, shared by every face loaded from it.
|
||||
pub bytes: Arc<[u8]>,
|
||||
/// The index to pass to a font parser.
|
||||
pub index: u32,
|
||||
}
|
||||
|
||||
/// A loaded face ([`Catalog::load_family`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LoadedFace {
|
||||
/// The face.
|
||||
pub info: FaceInfo,
|
||||
/// Its data.
|
||||
pub data: FontData,
|
||||
}
|
||||
|
||||
/// A face the scan can't find again, as an alias records it.
|
||||
#[derive(Clone, Debug)]
|
||||
enum Alias {
|
||||
/// The family of this face.
|
||||
Family(usize),
|
||||
/// A legacy family name: this face, under this legacy style.
|
||||
Legacy(usize, String),
|
||||
/// A full or PostScript name: this face.
|
||||
Face(usize),
|
||||
}
|
||||
|
||||
/// One state of the catalog: what lookups read while a scan builds the next one.
|
||||
#[derive(Default)]
|
||||
struct Index {
|
||||
faces: Vec<Face>,
|
||||
infos: Arc<[FaceInfo]>,
|
||||
families: Arc<[String]>,
|
||||
by_family: HashMap<String, Vec<usize>>,
|
||||
aliases: HashMap<String, Vec<Alias>>,
|
||||
postscript: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
/// Added faces first: a document's or the app's own face wins over an installed face of the
|
||||
/// same family and style.
|
||||
fn build(added: &[Face], scanned: &[Face]) -> Self {
|
||||
let mut faces: Vec<Face> = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for f in added.iter().chain(scanned) {
|
||||
let variations: Vec<(u32, u32)> = f.info.variations.iter().map(|(t, v)| (u32::from_be_bytes(*t), v.to_bits())).collect();
|
||||
let key = (f.info.family.to_ascii_lowercase(), norm(&f.info.style), f.keys.postscript.first().cloned(), variations);
|
||||
if seen.insert(key) {
|
||||
faces.push(f.clone());
|
||||
}
|
||||
}
|
||||
let mut by_family: HashMap<String, Vec<usize>> = HashMap::new();
|
||||
let mut aliases: HashMap<String, Vec<Alias>> = HashMap::new();
|
||||
let mut postscript: HashMap<String, usize> = HashMap::new();
|
||||
let mut families: Vec<String> = Vec::new();
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
let entry = by_family.entry(f.info.family.to_ascii_lowercase()).or_default();
|
||||
if entry.is_empty() {
|
||||
families.push(f.info.family.clone());
|
||||
}
|
||||
entry.push(i);
|
||||
for k in &f.keys.families {
|
||||
let list = aliases.entry(k.clone()).or_default();
|
||||
if !list
|
||||
.iter()
|
||||
.any(|a| matches!(a, Alias::Family(j) if faces.get(*j).is_some_and(|g| g.info.family.eq_ignore_ascii_case(&f.info.family))))
|
||||
{
|
||||
list.push(Alias::Family(i));
|
||||
}
|
||||
}
|
||||
for (family, style) in &f.keys.legacy {
|
||||
aliases.entry(family.clone()).or_default().push(Alias::Legacy(i, style.clone()));
|
||||
}
|
||||
for k in f.keys.full_names.iter().chain(&f.keys.postscript) {
|
||||
aliases.entry(k.clone()).or_default().push(Alias::Face(i));
|
||||
}
|
||||
for k in &f.keys.postscript {
|
||||
postscript.entry(k.clone()).or_insert(i);
|
||||
}
|
||||
}
|
||||
// Narrow to wide, light to heavy, upright first. The sort is stable, so of two faces alike
|
||||
// the added one stays first and wins a tie in `pick_style`.
|
||||
for list in by_family.values_mut() {
|
||||
list.sort_by(|a, b| {
|
||||
let (Some(a), Some(b)) = (faces.get(*a), faces.get(*b)) else { return std::cmp::Ordering::Equal };
|
||||
(a.info.width.total_cmp(&b.info.width)).then(a.info.weight.total_cmp(&b.info.weight)).then(a.info.italic.cmp(&b.info.italic))
|
||||
});
|
||||
}
|
||||
families.sort_by_cached_key(|f| (f.to_lowercase(), f.clone()));
|
||||
let infos: Arc<[FaceInfo]> = faces.iter().map(|f| f.info.clone()).collect();
|
||||
Self { faces, infos, families: families.into(), by_family, aliases, postscript }
|
||||
}
|
||||
|
||||
fn face(&self, i: usize) -> Option<&Face> {
|
||||
self.faces.get(i)
|
||||
}
|
||||
|
||||
/// The family `name` names: a family name ignoring ASCII case, else any family name of a face
|
||||
/// (another language, legacy) ignoring case and everything but letters and digits.
|
||||
fn family_of(&self, name: &str) -> Option<&[usize]> {
|
||||
if let Some(faces) = self.by_family.get(&name.to_ascii_lowercase()) {
|
||||
return Some(faces);
|
||||
}
|
||||
let aliases = self.aliases.get(&norm(name))?;
|
||||
let i = aliases.iter().find_map(|a| match a {
|
||||
Alias::Family(i) => Some(*i),
|
||||
_ => None,
|
||||
})?;
|
||||
self.by_family.get(&self.face(i)?.info.family.to_ascii_lowercase()).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
fn resolve(&self, name: &str, style: &str) -> Option<(usize, Match)> {
|
||||
if let Some(faces) = self.family_of(name) {
|
||||
return self.pick_style(faces, style);
|
||||
}
|
||||
let aliases = self.aliases.get(&norm(name))?;
|
||||
let legacy: Vec<(usize, &str)> = aliases
|
||||
.iter()
|
||||
.filter_map(|a| match a {
|
||||
Alias::Legacy(i, s) => Some((*i, s.as_str())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if !legacy.is_empty() {
|
||||
let ns = style_key(style);
|
||||
if let Some((i, _)) = legacy.iter().find(|(_, s)| *s == ns) {
|
||||
return Some((*i, Match::Exact));
|
||||
}
|
||||
let faces: Vec<usize> = legacy.iter().map(|(i, _)| *i).collect();
|
||||
return self.pick_style(&faces, style).map(|(i, _)| (i, Match::NearestStyle));
|
||||
}
|
||||
aliases.iter().find_map(|a| match a {
|
||||
Alias::Face(i) => Some((*i, Match::Exact)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The face of `faces` (one family) in `style`: by its style name, then by any other name of
|
||||
/// its style, else the nearest weight with italic differing costing 1000, then the nearest
|
||||
/// width; the first face wins a tie.
|
||||
fn pick_style(&self, faces: &[usize], style: &str) -> Option<(usize, Match)> {
|
||||
let ns = style_key(style);
|
||||
let cands: Vec<(usize, &Face)> = faces.iter().filter_map(|i| Some((*i, self.face(*i)?))).collect();
|
||||
if let Some((i, _)) = cands.iter().find(|(_, f)| norm(&f.info.style) == ns) {
|
||||
return Some((*i, Match::Exact));
|
||||
}
|
||||
if let Some((i, _)) = cands.iter().find(|(_, f)| f.keys.styles.contains(&ns)) {
|
||||
return Some((*i, Match::Exact));
|
||||
}
|
||||
let (tw, ti, twd) = (style_weight(style), style_italic(style), style_width(style));
|
||||
let score = |f: &Face| ((f.info.weight - tw).abs() + if f.info.italic != ti { 1000.0 } else { 0.0 }, (f.info.width - twd).abs());
|
||||
cands
|
||||
.iter()
|
||||
.min_by(|(_, a), (_, b)| {
|
||||
let (sa, sb) = (score(a), score(b));
|
||||
sa.0.total_cmp(&sb.0).then(sa.1.total_cmp(&sb.1))
|
||||
})
|
||||
.map(|(i, _)| (*i, Match::NearestStyle))
|
||||
}
|
||||
}
|
||||
|
||||
/// A style name as compared: normalized, with no style meaning `Regular`.
|
||||
fn style_key(style: &str) -> String {
|
||||
let ns = norm(style);
|
||||
if ns.is_empty() { "regular".into() } else { ns }
|
||||
}
|
||||
|
||||
/// What the catalog reads besides the platform's font folders.
|
||||
struct Config {
|
||||
app_dirs: Vec<PathBuf>,
|
||||
listers: Vec<Lister>,
|
||||
font_service: bool,
|
||||
}
|
||||
|
||||
/// A file found by a scan, remembered so a rescan reads only files that changed.
|
||||
struct Cached {
|
||||
len: u64,
|
||||
modified: Option<SystemTime>,
|
||||
faces: Vec<Face>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ScanState {
|
||||
files: HashMap<PathBuf, Cached>,
|
||||
}
|
||||
|
||||
/// What a change check compares with.
|
||||
#[derive(Default)]
|
||||
struct Stamp {
|
||||
app_dirs: Vec<PathBuf>,
|
||||
/// Every folder the scan listed, and the folders holding the files listers named, with their
|
||||
/// modification times (`None`: missing).
|
||||
dirs: Vec<(PathBuf, Option<SystemTime>)>,
|
||||
/// The fonts registered with Windows.
|
||||
registered: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Added {
|
||||
faces: Vec<Face>,
|
||||
blobs: HashMap<u64, Arc<[u8]>>,
|
||||
next_blob: u64,
|
||||
}
|
||||
|
||||
/// A family's faces, loaded once ([`Catalog::load_family`]).
|
||||
type FamilyLoad = Arc<OnceLock<Arc<[LoadedFace]>>>;
|
||||
|
||||
/// The installed fonts: the platform's font folders, the files its font service knows, the font
|
||||
/// store and the app's own folders, plus faces added from files or bytes.
|
||||
///
|
||||
/// The first scan runs on first use or on a background thread ([`Catalog::scan_in_background`]);
|
||||
/// lookups wait for it. Lookups read an immutable snapshot, so they never wait for a rescan.
|
||||
/// Each family's files load once ([`Catalog::load_family`]): other threads asking for the same
|
||||
/// family wait for that load, other families load at the same time.
|
||||
pub struct Catalog {
|
||||
env: FontEnv,
|
||||
config: RwLock<Config>,
|
||||
index: RwLock<Arc<Index>>,
|
||||
ready: OnceLock<()>,
|
||||
scan: Mutex<ScanState>,
|
||||
stamp: Mutex<Stamp>,
|
||||
generation: AtomicU64,
|
||||
added: Mutex<Added>,
|
||||
loaded: Mutex<HashMap<String, FamilyLoad>>,
|
||||
files: Mutex<HashMap<PathBuf, Weak<[u8]>>>,
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn modified(path: &Path) -> Option<SystemTime> {
|
||||
std::fs::metadata(path).and_then(|m| m.modified()).ok()
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// A catalog of the fonts installed on the system `env` describes, the font store, and the
|
||||
/// files the platform's font service lists (with the `system` feature).
|
||||
pub fn new(env: FontEnv) -> Self {
|
||||
Self {
|
||||
env,
|
||||
config: RwLock::new(Config { app_dirs: Vec::new(), listers: Vec::new(), font_service: cfg!(feature = "system") }),
|
||||
index: RwLock::new(Arc::new(Index::default())),
|
||||
ready: OnceLock::new(),
|
||||
scan: Mutex::new(ScanState::default()),
|
||||
stamp: Mutex::new(Stamp::default()),
|
||||
generation: AtomicU64::new(0),
|
||||
added: Mutex::new(Added::default()),
|
||||
loaded: Mutex::new(HashMap::new()),
|
||||
files: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The process's catalog, for [`FontEnv::from_process`].
|
||||
pub fn global() -> &'static Arc<Catalog> {
|
||||
static GLOBAL: OnceLock<Arc<Catalog>> = OnceLock::new();
|
||||
GLOBAL.get_or_init(|| Arc::new(Catalog::new(FontEnv::from_process())))
|
||||
}
|
||||
|
||||
/// Whether scans ask the platform's font service (CoreText, DirectWrite, fontconfig) for the
|
||||
/// font files it knows: on by default with the `system` feature, which provides it.
|
||||
#[must_use]
|
||||
pub fn with_font_service(self, on: bool) -> Self {
|
||||
self.write_config().font_service = on && cfg!(feature = "system");
|
||||
self
|
||||
}
|
||||
|
||||
/// Have every scan also read the files `lister` names (any file name; a folder is read with
|
||||
/// its subfolders), as fonts of the system.
|
||||
#[must_use]
|
||||
pub fn with_lister(self, lister: Lister) -> Self {
|
||||
self.write_config().listers.push(lister);
|
||||
self
|
||||
}
|
||||
|
||||
/// The app's own font folders (its Fonts folder, Office's, folders the user picked), read
|
||||
/// with their subfolders from the next scan on. A change shows in
|
||||
/// [`Catalog::installed_fonts_changed`].
|
||||
pub fn set_app_dirs(&self, dirs: Vec<PathBuf>) {
|
||||
self.write_config().app_dirs = dirs;
|
||||
}
|
||||
|
||||
fn write_config(&self) -> std::sync::RwLockWriteGuard<'_, Config> {
|
||||
self.config.write().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn read_config(&self) -> std::sync::RwLockReadGuard<'_, Config> {
|
||||
self.config.read().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The environment the catalog was made for.
|
||||
pub fn env(&self) -> &FontEnv {
|
||||
&self.env
|
||||
}
|
||||
|
||||
/// Start the first scan on a background thread, so the first lookup doesn't wait for all of
|
||||
/// it. Does nothing once it has run, or when no thread can be started (the first lookup
|
||||
/// then scans).
|
||||
pub fn scan_in_background(self: &Arc<Self>) {
|
||||
if self.ready.get().is_none() {
|
||||
let me = Arc::clone(self);
|
||||
let _ = std::thread::Builder::new().name("font-catalog-scan".into()).spawn(move || me.ensure());
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure(&self) {
|
||||
self.ready.get_or_init(|| {
|
||||
self.scan_now(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the first scan has finished: lookups won't wait.
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.ready.get().is_some()
|
||||
}
|
||||
|
||||
/// Scan again, for fonts installed or removed since; only new and changed files are read.
|
||||
/// Families loaded before load again on their next [`Catalog::load_family`]. Returns the
|
||||
/// number of faces cataloged.
|
||||
pub fn rescan(&self) -> usize {
|
||||
let mut first = false;
|
||||
self.ready.get_or_init(|| {
|
||||
first = true;
|
||||
self.scan_now(false);
|
||||
});
|
||||
if !first {
|
||||
self.scan_now(true);
|
||||
}
|
||||
self.snapshot().faces.len()
|
||||
}
|
||||
|
||||
/// [`Catalog::rescan`] when [`Catalog::installed_fonts_changed`] says so, for an app to call
|
||||
/// when its window is activated. Returns whether it rescanned.
|
||||
pub fn rescan_if_changed(&self) -> bool {
|
||||
let changed = self.installed_fonts_changed();
|
||||
if changed {
|
||||
self.rescan();
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Whether fonts were installed or removed since the last scan: a folder the scan read (or
|
||||
/// the folder of a file the font service named) changed, a missing one appeared, the app's
|
||||
/// folders changed, or on Windows the registry's font list changed. It looks at folders,
|
||||
/// not files, so it costs far less than a scan. `false` before the first scan.
|
||||
///
|
||||
/// Fonts a font service registers from a folder no scan has seen are found by the next
|
||||
/// [`Catalog::rescan`] only.
|
||||
pub fn installed_fonts_changed(&self) -> bool {
|
||||
if !self.is_ready() {
|
||||
return false;
|
||||
}
|
||||
let stamp = lock(&self.stamp);
|
||||
if stamp.app_dirs != self.read_config().app_dirs {
|
||||
return true;
|
||||
}
|
||||
if stamp.dirs.iter().any(|(p, t)| modified(p) != *t) {
|
||||
return true;
|
||||
}
|
||||
self.read_config().font_service && self.env.os == Os::Windows && stamp.registered != crate::registry::registered_font_files()
|
||||
}
|
||||
|
||||
/// Poll for installed and removed fonts every `interval` on a background thread: when
|
||||
/// [`Catalog::installed_fonts_changed`], rescan and call `on_change`. Stops when the returned
|
||||
/// [`Watch`] or the catalog is dropped.
|
||||
pub fn watch(self: &Arc<Self>, interval: Duration, on_change: impl Fn(&Catalog) + Send + 'static) -> Watch {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (me, flag) = (Arc::downgrade(self), Arc::clone(&stop));
|
||||
let _ = std::thread::Builder::new().name("font-catalog-watch".into()).spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(interval);
|
||||
if flag.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let Some(catalog) = me.upgrade() else { return };
|
||||
if catalog.rescan_if_changed() {
|
||||
on_change(&catalog);
|
||||
}
|
||||
}
|
||||
});
|
||||
Watch { stop }
|
||||
}
|
||||
|
||||
/// Counts the catalog's changes: each scan, and each face added or removed.
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Arc<Index> {
|
||||
self.ensure();
|
||||
Arc::clone(&self.index.read().unwrap_or_else(PoisonError::into_inner))
|
||||
}
|
||||
|
||||
fn publish(&self, index: Index) {
|
||||
*self.index.write().unwrap_or_else(PoisonError::into_inner) = Arc::new(index);
|
||||
lock(&self.loaded).clear();
|
||||
self.generation.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
/// The family names, sorted ignoring case.
|
||||
pub fn families(&self) -> Arc<[String]> {
|
||||
Arc::clone(&self.snapshot().families)
|
||||
}
|
||||
|
||||
/// Every face, added faces first.
|
||||
pub fn faces(&self) -> Arc<[FaceInfo]> {
|
||||
Arc::clone(&self.snapshot().infos)
|
||||
}
|
||||
|
||||
/// The family `name` names: its own name ignoring ASCII case, or any other of its names
|
||||
/// (another language, a legacy name) ignoring case and everything but letters and digits.
|
||||
pub fn family(&self, name: &str) -> Option<String> {
|
||||
let index = self.snapshot();
|
||||
let i = *index.family_of(name)?.first()?;
|
||||
index.face(i).map(|f| f.info.family.clone())
|
||||
}
|
||||
|
||||
/// The faces of the family `name` names ([`Catalog::family`]).
|
||||
pub fn styles(&self, family: &str) -> Vec<FaceInfo> {
|
||||
let index = self.snapshot();
|
||||
index.family_of(family).unwrap_or_default().iter().filter_map(|i| index.face(*i)).map(|f| f.info.clone()).collect()
|
||||
}
|
||||
|
||||
/// The face for `name` in `style`, and how it matched. `name` is a family name (any of its
|
||||
/// names, as [`Catalog::family`]), a legacy family name (`Roboto Light`, matched with its
|
||||
/// legacy style), a full name or a PostScript name (which name one face; `style` is then
|
||||
/// ignored). Within the family, `style` matches by name; otherwise the nearest weight wins,
|
||||
/// italic differing counting as 1000, then the nearest width. An empty style is `Regular`.
|
||||
/// `None` when nothing has that name.
|
||||
pub fn resolve(&self, name: &str, style: &str) -> Option<(FaceInfo, Match)> {
|
||||
let index = self.snapshot();
|
||||
let (i, matched) = index.resolve(name, style)?;
|
||||
Some((index.face(i)?.info.clone(), matched))
|
||||
}
|
||||
|
||||
/// The face with this PostScript name (a named instance's too), ignoring case and everything
|
||||
/// but letters and digits.
|
||||
pub fn by_postscript_name(&self, name: &str) -> Option<FaceInfo> {
|
||||
let index = self.snapshot();
|
||||
let i = *index.postscript.get(&norm(name))?;
|
||||
index.face(i).map(|f| f.info.clone())
|
||||
}
|
||||
|
||||
/// The data of `face`, read from its file (shared with other faces of the file still loaded).
|
||||
pub fn load(&self, face: &FaceInfo) -> Result<FontData, FontFileError> {
|
||||
if let Some(blob) = face.blob {
|
||||
let bytes = lock(&self.added).blobs.get(&blob).cloned().ok_or(FontFileError::Gone)?;
|
||||
return Ok(FontData { bytes, index: face.index });
|
||||
}
|
||||
let path = face.path.as_deref().ok_or(FontFileError::Gone)?;
|
||||
if crate::suitcase::is_suitcase(path) {
|
||||
let font = crate::suitcase::read_fonts(path).into_iter().nth(face.index as usize).ok_or(FontFileError::NotAFont)?;
|
||||
return Ok(FontData { bytes: font.into(), index: 0 });
|
||||
}
|
||||
Ok(FontData { bytes: self.file_bytes(path)?, index: face.index })
|
||||
}
|
||||
|
||||
fn file_bytes(&self, path: &Path) -> Result<Arc<[u8]>, FontFileError> {
|
||||
if let Some(bytes) = lock(&self.files).get(path).and_then(Weak::upgrade) {
|
||||
return Ok(bytes);
|
||||
}
|
||||
let size = std::fs::metadata(path)?.len();
|
||||
if size > MAX_LOAD {
|
||||
return Err(FontFileError::TooLarge { size, max: MAX_LOAD });
|
||||
}
|
||||
let bytes: Arc<[u8]> = std::fs::read(path)?.into();
|
||||
let mut files = lock(&self.files);
|
||||
files.retain(|_, w| w.strong_count() > 0);
|
||||
files.insert(path.to_path_buf(), Arc::downgrade(&bytes));
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Every face of the family `family` names ([`Catalog::family`]) with its data. The first
|
||||
/// call reads the family's files; calls for the same family on other threads wait for it and
|
||||
/// share the result, while other families load at the same time. Faces whose files can't be
|
||||
/// read are left out. After a rescan, the next call reads the files again.
|
||||
pub fn load_family(&self, family: &str) -> Arc<[LoadedFace]> {
|
||||
let Some(name) = self.family(family) else { return Arc::from([]) };
|
||||
let cell = Arc::clone(lock(&self.loaded).entry(name.to_ascii_lowercase()).or_default());
|
||||
Arc::clone(
|
||||
cell.get_or_init(|| self.styles(&name).into_iter().filter_map(|info| Some(LoadedFace { data: self.load(&info).ok()?, info })).collect()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Add the faces of a font file's `bytes` (a font or a collection) for this session, as
|
||||
/// `source`. Returns the faces added.
|
||||
pub fn add_bytes(&self, bytes: Vec<u8>, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, FontFileError> {
|
||||
let size = bytes.len() as u64;
|
||||
if size > limits.max_file_bytes {
|
||||
return Err(FontFileError::TooLarge { size, max: limits.max_file_bytes });
|
||||
}
|
||||
let mut faces = scan::data_faces(&bytes, limits.max_faces_per_file, None, source);
|
||||
if faces.is_empty() {
|
||||
return Err(FontFileError::NotAFont);
|
||||
}
|
||||
self.ensure();
|
||||
let mut added = lock(&self.added);
|
||||
if added.faces.len() + faces.len() > limits.max_added_faces {
|
||||
return Err(FontFileError::TooManyFaces { max: limits.max_added_faces });
|
||||
}
|
||||
let blob = added.next_blob;
|
||||
added.next_blob += 1;
|
||||
added.blobs.insert(blob, bytes.into());
|
||||
for f in &mut faces {
|
||||
f.info.blob = Some(blob);
|
||||
}
|
||||
Ok(self.push_added(added, faces))
|
||||
}
|
||||
|
||||
/// Add the faces of the font file at `path` for this session, as `source`, read in place.
|
||||
/// Returns the faces added.
|
||||
pub fn add_file(&self, path: &Path, source: Source, limits: &Limits) -> Result<Vec<FaceInfo>, FontFileError> {
|
||||
let size = std::fs::metadata(path)?.len();
|
||||
if size > limits.max_file_bytes {
|
||||
return Err(FontFileError::TooLarge { size, max: limits.max_file_bytes });
|
||||
}
|
||||
let mut faces = scan::file_faces(path, limits.max_file_bytes, source);
|
||||
let mut fonts: Vec<u32> = faces.iter().map(|f| f.info.index).collect();
|
||||
fonts.dedup();
|
||||
if let Some(&keep) = fonts.get(limits.max_faces_per_file as usize) {
|
||||
faces.retain(|f| f.info.index < keep);
|
||||
}
|
||||
if faces.is_empty() {
|
||||
return Err(FontFileError::NotAFont);
|
||||
}
|
||||
self.ensure();
|
||||
let added = lock(&self.added);
|
||||
if added.faces.len() + faces.len() > limits.max_added_faces {
|
||||
return Err(FontFileError::TooManyFaces { max: limits.max_added_faces });
|
||||
}
|
||||
Ok(self.push_added(added, faces))
|
||||
}
|
||||
|
||||
fn push_added(&self, mut added: MutexGuard<'_, Added>, faces: Vec<Face>) -> Vec<FaceInfo> {
|
||||
let infos = faces.iter().map(|f| f.info.clone()).collect();
|
||||
added.faces.extend(faces);
|
||||
let scanned = self.scanned();
|
||||
self.publish(Index::build(&added.faces, &scanned));
|
||||
infos
|
||||
}
|
||||
|
||||
/// Remove every added face of `source` (a closed document's fonts). Returns how many.
|
||||
pub fn remove_added(&self, source: Source) -> usize {
|
||||
let mut added = lock(&self.added);
|
||||
let before = added.faces.len();
|
||||
added.faces.retain(|f| f.info.source != source);
|
||||
let blobs: HashSet<u64> = added.faces.iter().filter_map(|f| f.info.blob).collect();
|
||||
added.blobs.retain(|b, _| blobs.contains(b));
|
||||
let removed = before - added.faces.len();
|
||||
if removed > 0 {
|
||||
let scanned = self.scanned();
|
||||
self.publish(Index::build(&added.faces, &scanned));
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// The faces the last scan found.
|
||||
fn scanned(&self) -> Vec<Face> {
|
||||
let scan = lock(&self.scan);
|
||||
let mut faces: Vec<(&PathBuf, &Cached)> = scan.files.iter().collect();
|
||||
faces.sort_by(|a, b| a.0.cmp(b.0));
|
||||
faces.into_iter().flat_map(|(_, c)| c.faces.iter().cloned()).collect()
|
||||
}
|
||||
|
||||
fn scan_now(&self, refresh: bool) {
|
||||
let mut state = lock(&self.scan);
|
||||
let (app_dirs, listers, service) = {
|
||||
let c = self.read_config();
|
||||
(c.app_dirs.clone(), c.listers.clone(), c.font_service)
|
||||
};
|
||||
let ignore_case = self.env.os == Os::Windows;
|
||||
let mut roots: Vec<(PathBuf, Source)> = font_dirs(&self.env).into_iter().map(|d| (d, Source::System)).collect();
|
||||
roots.extend(font_store_dir(&self.env).map(|d| (d, Source::Store)));
|
||||
roots.extend(app_dirs.iter().map(|d| (d.clone(), Source::App)));
|
||||
|
||||
let mut named: Vec<PathBuf> = Vec::new();
|
||||
let mut registered = Vec::new();
|
||||
if service {
|
||||
named.extend(crate::system::font_files(refresh));
|
||||
if self.env.os == Os::Windows {
|
||||
registered = crate::registry::registered_font_files();
|
||||
named.extend(registered.iter().cloned());
|
||||
}
|
||||
}
|
||||
for lister in &listers {
|
||||
named.extend(lister());
|
||||
}
|
||||
|
||||
let mut walk = Walk { ignore_case, seen_dirs: HashSet::new(), seen_files: HashSet::new(), entries: 0, dirs: Vec::new(), files: Vec::new() };
|
||||
for (root, source) in &roots {
|
||||
walk.dir(root, *source, 0);
|
||||
}
|
||||
let mut parents = HashSet::new();
|
||||
for file in named {
|
||||
if file.is_dir() {
|
||||
walk.dir(&file, Source::System, 0);
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = file.parent()
|
||||
&& parents.insert(parent.to_path_buf())
|
||||
{
|
||||
walk.dirs.push((parent.to_path_buf(), modified(parent)));
|
||||
}
|
||||
walk.file(file, Source::System, true);
|
||||
}
|
||||
|
||||
let mut files = HashMap::new();
|
||||
for (path, source, by_name) in walk.files {
|
||||
let Ok(meta) = std::fs::metadata(&path) else { continue };
|
||||
let (len, mtime) = (meta.len(), meta.modified().ok());
|
||||
let cached = state.files.remove(&path).filter(|c| c.len == len && c.modified == mtime);
|
||||
let mut faces = match cached {
|
||||
Some(c) => c.faces,
|
||||
None if by_name || scan::is_font_file(&path) || crate::suitcase::is_suitcase(&path) => scan::file_faces(&path, MAX_SCAN_READ, source),
|
||||
None => continue,
|
||||
};
|
||||
for f in &mut faces {
|
||||
f.info.source = source;
|
||||
}
|
||||
files.insert(path, Cached { len, modified: mtime, faces });
|
||||
}
|
||||
state.files = files;
|
||||
drop(state);
|
||||
|
||||
*lock(&self.stamp) = Stamp { app_dirs, dirs: walk.dirs, registered };
|
||||
let added = lock(&self.added);
|
||||
let scanned = self.scanned();
|
||||
self.publish(Index::build(&added.faces, &scanned));
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops a [`Catalog::watch`] when dropped.
|
||||
#[must_use = "dropping a Watch stops it"]
|
||||
pub struct Watch {
|
||||
stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Drop for Watch {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The files of one scan: each folder and file once, however links lead back to it.
|
||||
struct Walk {
|
||||
ignore_case: bool,
|
||||
seen_dirs: HashSet<PathBuf>,
|
||||
seen_files: HashSet<PathBuf>,
|
||||
entries: usize,
|
||||
dirs: Vec<(PathBuf, Option<SystemTime>)>,
|
||||
/// Each file with where it was found and whether a lister named it.
|
||||
files: Vec<(PathBuf, Source, bool)>,
|
||||
}
|
||||
|
||||
impl Walk {
|
||||
fn key(&self, path: &Path) -> PathBuf {
|
||||
let real = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
if self.ignore_case { PathBuf::from(real.to_string_lossy().to_lowercase()) } else { real }
|
||||
}
|
||||
|
||||
fn dir(&mut self, dir: &Path, source: Source, depth: usize) {
|
||||
let key = self.key(dir);
|
||||
if !self.seen_dirs.insert(key) {
|
||||
return;
|
||||
}
|
||||
// Stamped before it is listed: a font installed meanwhile changes it after this.
|
||||
self.dirs.push((dir.to_path_buf(), modified(dir)));
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let mut paths: Vec<(PathBuf, bool)> = Vec::new();
|
||||
for e in entries.flatten() {
|
||||
self.entries += 1;
|
||||
if self.entries > MAX_ENTRIES {
|
||||
break;
|
||||
}
|
||||
let path = e.path();
|
||||
let is_dir = e.file_type().is_ok_and(|t| t.is_dir() || (t.is_symlink() && path.is_dir()));
|
||||
paths.push((path, is_dir));
|
||||
}
|
||||
paths.sort();
|
||||
for (path, is_dir) in paths {
|
||||
if is_dir {
|
||||
if depth < MAX_DEPTH {
|
||||
self.dir(&path, source, depth + 1);
|
||||
}
|
||||
} else {
|
||||
self.file(path, source, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn file(&mut self, path: PathBuf, source: Source, by_name: bool) {
|
||||
let key = self.key(&path);
|
||||
if self.seen_files.insert(key) {
|
||||
self.files.push((path, source, by_name));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Where fonts are installed: the platform's font folders and the suite's font store, worked out
|
||||
//! from a [`FontEnv`] value rather than the process environment, so tests can describe any system.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The platform family whose font folders apply.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum Os {
|
||||
/// macOS.
|
||||
MacOs,
|
||||
/// Windows.
|
||||
Windows,
|
||||
/// Linux and the BSDs: XDG folders, fontconfig and Flatpak.
|
||||
#[default]
|
||||
Unix,
|
||||
/// Haiku.
|
||||
Haiku,
|
||||
/// The web, Android, iOS and anything else without font folders to scan.
|
||||
None,
|
||||
}
|
||||
|
||||
impl Os {
|
||||
/// The platform this build runs on.
|
||||
pub fn current() -> Self {
|
||||
if cfg!(target_arch = "wasm32") || cfg!(target_os = "android") || cfg!(target_os = "ios") {
|
||||
Self::None
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Self::MacOs
|
||||
} else if cfg!(windows) {
|
||||
Self::Windows
|
||||
} else if cfg!(target_os = "haiku") {
|
||||
Self::Haiku
|
||||
} else {
|
||||
Self::Unix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The environment the font folders depend on.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FontEnv {
|
||||
/// Which platform's folders to list.
|
||||
pub os: Os,
|
||||
/// The file system root that fixed system folders (`/usr/share/fonts`, `/run/host/fonts`,
|
||||
/// `/System/Library/Fonts`, `/etc/fonts/fonts.conf`) and absolute fontconfig `<dir>` entries
|
||||
/// are under: `/` on a real system, a temporary folder in tests.
|
||||
pub root: PathBuf,
|
||||
/// `HOME` (`USERPROFILE` on Windows).
|
||||
pub home: Option<PathBuf>,
|
||||
/// `XDG_DATA_HOME`.
|
||||
pub xdg_data_home: Option<PathBuf>,
|
||||
/// `XDG_DATA_DIRS`.
|
||||
pub xdg_data_dirs: Option<OsString>,
|
||||
/// `XDG_CONFIG_HOME`.
|
||||
pub xdg_config_home: Option<PathBuf>,
|
||||
/// `FONTCONFIG_FILE`: the main fontconfig file instead of `/etc/fonts/fonts.conf`.
|
||||
pub fontconfig_file: Option<PathBuf>,
|
||||
/// Inside a Flatpak sandbox (`/.flatpak-info` exists): the host's fontconfig folders are read
|
||||
/// too, under `/run/host`.
|
||||
pub flatpak: bool,
|
||||
/// `WINDIR`.
|
||||
pub windir: Option<PathBuf>,
|
||||
/// `LOCALAPPDATA`.
|
||||
pub local_app_data: Option<PathBuf>,
|
||||
/// `CRAFT_FONT_STORE`: the font store folder instead of the platform's ([`font_store_dir`]).
|
||||
pub font_store: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl FontEnv {
|
||||
/// The environment of this process. Empty variables count as unset.
|
||||
pub fn from_process() -> Self {
|
||||
let var = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty());
|
||||
let path = |k: &str| var(k).map(PathBuf::from);
|
||||
let os = Os::current();
|
||||
let home = if os == Os::Windows { path("USERPROFILE").or_else(|| path("HOME")) } else { path("HOME") };
|
||||
Self {
|
||||
os,
|
||||
root: PathBuf::from("/"),
|
||||
home,
|
||||
xdg_data_home: path("XDG_DATA_HOME"),
|
||||
xdg_data_dirs: var("XDG_DATA_DIRS"),
|
||||
xdg_config_home: path("XDG_CONFIG_HOME"),
|
||||
fontconfig_file: path("FONTCONFIG_FILE"),
|
||||
flatpak: os == Os::Unix && Path::new("/.flatpak-info").exists(),
|
||||
windir: path("WINDIR"),
|
||||
local_app_data: path("LOCALAPPDATA"),
|
||||
font_store: path("CRAFT_FONT_STORE"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` (absolute, as on the real system) under [`FontEnv::root`].
|
||||
pub(crate) fn rooted(&self, path: impl AsRef<Path>) -> PathBuf {
|
||||
let path = path.as_ref();
|
||||
let relative: PathBuf = path.components().filter(|c| matches!(c, std::path::Component::Normal(_))).collect();
|
||||
if self.root.as_os_str().is_empty() { Path::new("/").join(relative) } else { self.root.join(relative) }
|
||||
}
|
||||
|
||||
/// `$XDG_DATA_HOME`, else `~/.local/share` (a relative `XDG_DATA_HOME` is ignored, as the XDG
|
||||
/// specification says).
|
||||
pub(crate) fn data_home(&self) -> Option<PathBuf> {
|
||||
self.xdg_data_home.clone().filter(|p| p.is_absolute()).or_else(|| self.home.as_ref().map(|h| h.join(".local/share")))
|
||||
}
|
||||
|
||||
/// `$XDG_CONFIG_HOME`, else `~/.config`.
|
||||
pub(crate) fn config_home(&self) -> Option<PathBuf> {
|
||||
self.xdg_config_home.clone().filter(|p| p.is_absolute()).or_else(|| self.home.as_ref().map(|h| h.join(".config")))
|
||||
}
|
||||
|
||||
fn windows_fonts(&self) -> PathBuf {
|
||||
self.windir.clone().unwrap_or_else(|| PathBuf::from("C:\\Windows")).join("Fonts")
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder name of the font store under the platform's per-user data folder, spelled as the
|
||||
/// suite's other per-user folders are (`ArtCraft Launcher`, `artcraft-launcher`).
|
||||
fn store_name(os: Os) -> &'static str {
|
||||
if matches!(os, Os::MacOs | Os::Windows) { "Craft Fonts" } else { "craft-fonts" }
|
||||
}
|
||||
|
||||
/// The suite's font store: one per-user folder of fonts installed by Craft Font Manager or an
|
||||
/// app's "install missing font", read by every app's catalog and by no other program.
|
||||
///
|
||||
/// - macOS: `~/Library/Application Support/Craft Fonts`
|
||||
/// - Windows: `%LOCALAPPDATA%\Craft Fonts`
|
||||
/// - Linux, the BSDs and Haiku: `$XDG_DATA_HOME/craft-fonts` (`~/.local/share/craft-fonts`)
|
||||
///
|
||||
/// `CRAFT_FONT_STORE` ([`FontEnv::font_store`]) replaces it. `None` where there is no per-user
|
||||
/// folder (the web), or when the variables it is built from are unset.
|
||||
pub fn font_store_dir(env: &FontEnv) -> Option<PathBuf> {
|
||||
if let Some(dir) = &env.font_store {
|
||||
return Some(dir.clone());
|
||||
}
|
||||
let base = match env.os {
|
||||
Os::MacOs => env.home.as_ref()?.join("Library/Application Support"),
|
||||
Os::Windows => env.local_app_data.clone()?,
|
||||
Os::Unix | Os::Haiku => env.data_home()?,
|
||||
Os::None => return None,
|
||||
};
|
||||
Some(base.join(store_name(env.os)))
|
||||
}
|
||||
|
||||
/// The platform's font folders, the system's and the user's, without duplicates. Folders that
|
||||
/// don't exist are kept: a scan skips them, and a change check notices when one appears.
|
||||
///
|
||||
/// - macOS: `/System/Library/Fonts`, `/Library/Fonts`, `/Network/Library/Fonts`,
|
||||
/// `~/Library/Fonts`, `/System/Library/Assets`, and the font assets macOS downloads on demand
|
||||
/// (`/System/Library/AssetsV2/com_apple_MobileAsset_Font*`, also under
|
||||
/// `PreinstalledAssetsV2/InstallWithOs`).
|
||||
/// - Windows: `%WINDIR%\Fonts` and the current user's `%LOCALAPPDATA%\Microsoft\Windows\Fonts`.
|
||||
/// The registry's font list is read by the catalog itself.
|
||||
/// - Linux and the BSDs: `/usr/share/fonts`, `/usr/local/share/fonts`, the Flatpak host folders
|
||||
/// (`/run/host/fonts`, `/run/host/local-fonts`, `/run/host/user-fonts`), `~/.local/share/fonts`,
|
||||
/// `~/.fonts`, `$XDG_DATA_HOME/fonts`, `$XDG_DATA_DIRS/*/fonts`, and the `<dir>` and
|
||||
/// `<remap-dir>` entries of the fontconfig configuration ([`crate::fontconfig_dirs`]).
|
||||
/// - Haiku: the system's and the user's `data/fonts` folders, packaged and non-packaged.
|
||||
pub fn font_dirs(env: &FontEnv) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
match env.os {
|
||||
Os::MacOs => {
|
||||
dirs.extend(["/System/Library/Fonts", "/Library/Fonts", "/Network/Library/Fonts"].map(|d| env.rooted(d)));
|
||||
dirs.extend(env.home.as_ref().map(|h| h.join("Library/Fonts")));
|
||||
dirs.push(env.rooted("/System/Library/Assets"));
|
||||
for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] {
|
||||
let mut assets: Vec<PathBuf> = std::fs::read_dir(env.rooted(parent))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font"))
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
assets.sort();
|
||||
dirs.extend(assets);
|
||||
}
|
||||
}
|
||||
Os::Windows => {
|
||||
dirs.push(env.windows_fonts());
|
||||
dirs.extend(env.local_app_data.as_ref().map(|l| l.join("Microsoft\\Windows\\Fonts")));
|
||||
}
|
||||
Os::Unix => {
|
||||
dirs.extend(
|
||||
["/usr/share/fonts", "/usr/local/share/fonts", "/run/host/fonts", "/run/host/local-fonts", "/run/host/user-fonts"]
|
||||
.map(|d| env.rooted(d)),
|
||||
);
|
||||
if let Some(h) = &env.home {
|
||||
dirs.push(h.join(".local/share/fonts"));
|
||||
dirs.push(h.join(".fonts"));
|
||||
}
|
||||
dirs.extend(env.data_home().map(|d| d.join("fonts")));
|
||||
let data_dirs = env.xdg_data_dirs.clone().unwrap_or_else(|| "/usr/local/share:/usr/share".into());
|
||||
dirs.extend(std::env::split_paths(&data_dirs).filter(|p| p.is_absolute()).map(|p| env.rooted(p).join("fonts")));
|
||||
dirs.extend(crate::fontconfig::fontconfig_dirs(env));
|
||||
}
|
||||
Os::Haiku => {
|
||||
dirs.extend(["/boot/system/data/fonts", "/boot/system/non-packaged/data/fonts"].map(|d| env.rooted(d)));
|
||||
if let Some(h) = &env.home {
|
||||
dirs.push(h.join("config/data/fonts"));
|
||||
dirs.push(h.join("config/non-packaged/data/fonts"));
|
||||
}
|
||||
}
|
||||
Os::None => {}
|
||||
}
|
||||
dedup_dirs(dirs, env.os == Os::Windows)
|
||||
}
|
||||
|
||||
/// The folders Microsoft Office keeps its own fonts in (Calibri, Cambria, Aptos, and the cloud
|
||||
/// fonts it downloads), which other apps don't see. Apps that open Office documents add them to
|
||||
/// their catalog ([`crate::Catalog::set_app_dirs`]).
|
||||
pub fn office_font_dirs(env: &FontEnv) -> Vec<PathBuf> {
|
||||
match env.os {
|
||||
Os::MacOs => {
|
||||
let mut dirs: Vec<PathBuf> =
|
||||
env.home.iter().map(|h| h.join("Library/Group Containers/UBF8T346G9.Office/FontCache/4/CloudFonts")).collect();
|
||||
dirs.extend(
|
||||
["Microsoft Word", "Microsoft Excel", "Microsoft PowerPoint", "Microsoft Outlook"]
|
||||
.map(|app| env.rooted(format!("/Applications/{app}.app/Contents/Resources/DFonts"))),
|
||||
);
|
||||
dirs
|
||||
}
|
||||
Os::Windows => env.local_app_data.iter().map(|l| l.join("Microsoft\\FontCache\\4\\CloudFonts")).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `dirs` without repeats (also one folder reached through a link), keeping the first. Windows
|
||||
/// paths compare ignoring case.
|
||||
pub(crate) fn dedup_dirs(dirs: Vec<PathBuf>, ignore_case: bool) -> Vec<PathBuf> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
dirs.into_iter()
|
||||
.filter(|d| {
|
||||
let key = std::fs::canonicalize(d).unwrap_or_else(|_| d.clone());
|
||||
seen.insert(if ignore_case { PathBuf::from(key.to_string_lossy().to_lowercase()) } else { key })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! The font folders named by the fontconfig configuration, read with a small tag scanner (no
|
||||
//! fontconfig library, no XML crate): only `<dir>`, `<remap-dir>` and `<include>` matter here.
|
||||
//! The work is bounded: include depth, number and size of the files read, and every file is read
|
||||
//! at most once.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::FontEnv;
|
||||
|
||||
const MAX_INCLUDE_DEPTH: u32 = 8;
|
||||
const MAX_FILES: usize = 512;
|
||||
const MAX_FILE_BYTES: u64 = 1 << 20;
|
||||
|
||||
/// The folders the fontconfig configuration for `env` names, in the order it names them.
|
||||
///
|
||||
/// The main file is `FONTCONFIG_FILE`, else the first of `/etc/fonts/fonts.conf` and
|
||||
/// `/usr/local/etc/fonts/fonts.conf` (FreeBSD) that exists. The user's own configuration
|
||||
/// (`$XDG_CONFIG_HOME/fontconfig/fonts.conf` and `conf.d`) is read too, in case the main file
|
||||
/// doesn't include it. In a Flatpak sandbox ([`FontEnv::flatpak`]) the host's folders are added:
|
||||
/// those `/run/host/font-dirs.xml` maps, and those of the host's own configuration when the
|
||||
/// sandbox can see it (`/run/host/etc/fonts/fonts.conf`), under `/run/host`.
|
||||
pub fn fontconfig_dirs(env: &FontEnv) -> Vec<PathBuf> {
|
||||
let mut reader = Reader { env, out: Vec::new(), seen: HashSet::new(), host: None };
|
||||
let root = match &env.fontconfig_file {
|
||||
Some(f) => Some(f.clone()),
|
||||
None => ["/etc/fonts/fonts.conf", "/usr/local/etc/fonts/fonts.conf"].map(|p| env.rooted(p)).into_iter().find(|p| p.is_file()),
|
||||
};
|
||||
if let Some(root) = root {
|
||||
reader.file(&root, 0);
|
||||
}
|
||||
if let Some(c) = env.config_home() {
|
||||
reader.include(&c.join("fontconfig/fonts.conf"), 0);
|
||||
reader.include(&c.join("fontconfig/conf.d"), 0);
|
||||
}
|
||||
if env.flatpak {
|
||||
reader.file(&env.rooted("/run/host/font-dirs.xml"), 0);
|
||||
reader.host = Some(env.rooted("/run/host"));
|
||||
reader.file(&env.rooted("/run/host/etc/fonts/fonts.conf"), 0);
|
||||
}
|
||||
reader.out
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
env: &'a FontEnv,
|
||||
out: Vec<PathBuf>,
|
||||
seen: HashSet<PathBuf>,
|
||||
/// While reading the host's configuration from a sandbox: where the host's root is.
|
||||
host: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Read a configuration file or, for a folder, its `NN-name.conf` files in name order.
|
||||
fn include(&mut self, path: &Path, depth: u32) {
|
||||
if path.is_dir() {
|
||||
let Ok(rd) = std::fs::read_dir(path) else { return };
|
||||
let mut files: Vec<PathBuf> = rd
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.ends_with(".conf") && n.starts_with(|c: char| c.is_ascii_digit()))
|
||||
})
|
||||
.collect();
|
||||
files.sort();
|
||||
for f in files {
|
||||
self.file(&f, depth);
|
||||
}
|
||||
} else {
|
||||
self.file(path, depth);
|
||||
}
|
||||
}
|
||||
|
||||
fn file(&mut self, path: &Path, depth: u32) {
|
||||
if depth > MAX_INCLUDE_DEPTH || self.seen.len() >= MAX_FILES || !path.is_file() {
|
||||
return;
|
||||
}
|
||||
let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
if !self.seen.insert(key) {
|
||||
return;
|
||||
}
|
||||
if std::fs::metadata(path).map(|m| m.len() > MAX_FILE_BYTES).unwrap_or(true) {
|
||||
return;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(path) else { return };
|
||||
let base = path.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
for el in elements(&text) {
|
||||
match el.name {
|
||||
"dir" | "remap-dir" => {
|
||||
if let Some(p) = self.resolve(&el, &base, false) {
|
||||
self.out.push(p);
|
||||
}
|
||||
}
|
||||
"include" => {
|
||||
if let Some(p) = self.resolve(&el, &base, true) {
|
||||
self.include(&p, depth.saturating_add(1));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The path an element names: `~` is the home folder, `prefix="xdg"` the XDG data (folders)
|
||||
/// or config (includes) home, `prefix="relative"` and plain relative paths the including
|
||||
/// file's folder, `prefix="default"`/`"cwd"` the working folder. Absolute paths are under the
|
||||
/// root, or under the host's root while reading the host's configuration.
|
||||
fn resolve(&self, el: &Element, base: &Path, include: bool) -> Option<PathBuf> {
|
||||
let v = el.text.trim();
|
||||
if v.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(rest) = v.strip_prefix('~') {
|
||||
return Some(self.env.home.as_ref()?.join(rest.trim_start_matches('/')));
|
||||
}
|
||||
let p = Path::new(v);
|
||||
if p.is_absolute() {
|
||||
return Some(match &self.host {
|
||||
Some(host) => host.join(p.strip_prefix("/").unwrap_or(p)),
|
||||
None => self.env.rooted(p),
|
||||
});
|
||||
}
|
||||
match el.attr("prefix") {
|
||||
Some("xdg") => Some(if include { self.env.config_home()? } else { self.env.data_home()? }.join(p)),
|
||||
Some("default" | "cwd") => std::env::current_dir().ok().map(|c| c.join(p)),
|
||||
_ => Some(base.join(p)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Element<'a> {
|
||||
name: &'a str,
|
||||
attrs: &'a str,
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl Element<'_> {
|
||||
fn attr(&self, key: &str) -> Option<&str> {
|
||||
let mut rest = self.attrs;
|
||||
while let Some(eq) = rest.find('=') {
|
||||
let k = rest.get(..eq)?.trim();
|
||||
let after = rest.get(eq + 1..)?.trim_start();
|
||||
let q = after.chars().next()?;
|
||||
if q != '"' && q != '\'' {
|
||||
return None;
|
||||
}
|
||||
let body = after.get(1..)?;
|
||||
let end = body.find(q)?;
|
||||
if k == key {
|
||||
return body.get(..end);
|
||||
}
|
||||
rest = body.get(end + 1..)?;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The `<dir>`, `<remap-dir>` and `<include>` elements of a fontconfig file, comments skipped.
|
||||
fn elements(src: &str) -> Vec<Element<'_>> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(off) = src.get(i..).and_then(|s| s.find('<')) {
|
||||
let start = i + off;
|
||||
let Some(tail) = src.get(start..) else { break };
|
||||
if tail.starts_with("<!--") {
|
||||
match tail.find("-->") {
|
||||
Some(e) => {
|
||||
i = start + e + 3;
|
||||
continue;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let Some(close) = tail.find('>') else { break };
|
||||
let inner = tail.get(1..close).unwrap_or_default();
|
||||
i = start + close + 1;
|
||||
let name_len = inner.find(|c: char| c.is_whitespace() || c == '/').unwrap_or(inner.len());
|
||||
let name = inner.get(..name_len).unwrap_or_default();
|
||||
if !matches!(name, "dir" | "remap-dir" | "include") || inner.ends_with('/') {
|
||||
continue;
|
||||
}
|
||||
let attrs = inner.get(name_len..).unwrap_or_default();
|
||||
let end_tag = format!("</{name}>");
|
||||
let Some(body_len) = src.get(i..).and_then(|s| s.find(&end_tag)) else { break };
|
||||
let body = src.get(i..i + body_len).unwrap_or_default();
|
||||
i += body_len + end_tag.len();
|
||||
out.push(Element { name, attrs, text: decode_entities(body) });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_entities(s: &str) -> String {
|
||||
s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
//! The installed fonts, for every Craft app: discovery on every platform, lookup by family and
|
||||
//! style or by any other name, and loading shared between threads.
|
||||
//!
|
||||
//! A [`Catalog`] scans the platform's font folders ([`font_dirs`]), the suite's font store
|
||||
//! ([`font_store_dir`]), the app's own folders ([`Catalog::set_app_dirs`]) and, with the `system`
|
||||
//! feature, the files the platform's font service knows ([`system`]): CoreText on macOS,
|
||||
//! DirectWrite on Windows, fontconfig on Linux and FreeBSD. On Windows it also reads the
|
||||
//! registry's font list. The scan reads each file's table directory and its `name`, `OS/2` and
|
||||
//! `fvar` tables, never whole files, and a rescan reads only files that changed.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use craft_font_catalog::{Catalog, Match};
|
||||
//!
|
||||
//! let catalog = Catalog::global();
|
||||
//! catalog.scan_in_background();
|
||||
//! if let Some((face, Match::Exact)) = catalog.resolve("Source Serif 4", "Bold Italic") {
|
||||
//! let data = catalog.load(&face);
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
mod catalog;
|
||||
mod env;
|
||||
mod fontconfig;
|
||||
mod registry;
|
||||
mod scan;
|
||||
mod suitcase;
|
||||
pub mod system;
|
||||
|
||||
pub use catalog::{Catalog, FontData, FontFileError, Limits, Lister, LoadedFace, Match, Watch};
|
||||
pub use env::{FontEnv, Os, font_dirs, font_store_dir, office_font_dirs};
|
||||
pub use fontconfig::fontconfig_dirs;
|
||||
pub use scan::{FaceInfo, LocalizedName, Source, is_font_file, style_italic, style_weight, style_width};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,30 @@
|
||||
//! The fonts registered with Windows in the registry: one value per face, holding a file name in
|
||||
//! the Windows font folder or a full path. Programs that install fonts into their own folders
|
||||
//! register them with full paths, which only the registry lists.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The full paths the registry lists for the machine's and the current user's fonts, sorted and
|
||||
/// without repeats. Bare file names (files in the Windows font folder) are left out: the scan
|
||||
/// reads that folder. Empty elsewhere than on Windows.
|
||||
pub(crate) fn registered_font_files() -> Vec<PathBuf> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
const MAX_REGISTERED: usize = 1 << 16;
|
||||
const KEY: &str = r"Software\Microsoft\Windows NT\CurrentVersion\Fonts";
|
||||
use windows_registry::{CURRENT_USER, LOCAL_MACHINE};
|
||||
let mut files: Vec<PathBuf> = [LOCAL_MACHINE, CURRENT_USER]
|
||||
.into_iter()
|
||||
.filter_map(|root| root.open(KEY).ok())
|
||||
.flat_map(|key| key.values().map(|vs| vs.filter_map(|(_, v)| String::try_from(v).ok()).collect::<Vec<_>>()).unwrap_or_default())
|
||||
.take(MAX_REGISTERED)
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_absolute())
|
||||
.collect();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
files
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
Vec::new()
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
//! Reading face records from font files: the table directory and the `name`, `OS/2` and `fvar`
|
||||
//! tables, never the whole file.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use skrifa::raw::{FileRef, TableProvider};
|
||||
use skrifa::{MetadataProvider, string::StringId};
|
||||
|
||||
/// Where a face comes from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Source {
|
||||
/// Installed on the system: a platform font folder or the platform's font service.
|
||||
System,
|
||||
/// The suite's font store ([`crate::font_store_dir`]).
|
||||
Store,
|
||||
/// A folder the app added ([`crate::Catalog::set_app_dirs`]).
|
||||
App,
|
||||
/// Bundled with the app and added as bytes.
|
||||
Bundled,
|
||||
/// Added by the user to this app.
|
||||
User,
|
||||
/// Embedded in, or shipped beside, an open document.
|
||||
Document,
|
||||
}
|
||||
|
||||
/// A name in one language, from the font's `name` table.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LocalizedName {
|
||||
/// The record's BCP 47 language tag (`ja`, `zh-Hant`), when the font says.
|
||||
pub language: Option<String>,
|
||||
/// The name.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// One face of an installed or added font: one face of a file, or one named instance of a
|
||||
/// variable font.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FaceInfo {
|
||||
/// The family: the typographic family name (name ID 16), else the family name (ID 1), in
|
||||
/// English when the font has an English record.
|
||||
pub family: String,
|
||||
/// The style: the typographic subfamily (ID 17), else the subfamily (ID 2), else `Regular`.
|
||||
/// A named instance's subfamily name.
|
||||
pub style: String,
|
||||
/// The legacy family name (ID 1) when it differs from [`FaceInfo::family`] (`Roboto Light`
|
||||
/// in the family `Roboto`).
|
||||
pub legacy_family: Option<String>,
|
||||
/// The legacy style (ID 2) that goes with [`FaceInfo::legacy_family`].
|
||||
pub legacy_style: Option<String>,
|
||||
/// The family's names in other languages (`ヒラギノ角ゴシック`).
|
||||
pub localized_families: Vec<LocalizedName>,
|
||||
/// The full name (ID 4) of the face itself; `None` for a named instance.
|
||||
pub full_name: Option<String>,
|
||||
/// The PostScript name (ID 6, or a named instance's own), as PDF and other documents name fonts.
|
||||
pub postscript_name: Option<String>,
|
||||
/// The weight, 1 to 1000 (400 regular, 700 bold): `usWeightClass`, a named instance's `wght`,
|
||||
/// else the weight the style name implies.
|
||||
pub weight: f32,
|
||||
/// The width as a percentage of normal (100; 75 condensed, 125 expanded): `usWidthClass`, a
|
||||
/// named instance's `wdth`, else the width the style name implies.
|
||||
pub width: f32,
|
||||
/// Italic or oblique.
|
||||
pub italic: bool,
|
||||
/// A named instance's axis settings (tag, user units); empty for the face itself.
|
||||
pub variations: Vec<([u8; 4], f32)>,
|
||||
/// The face has variation axes.
|
||||
pub variable: bool,
|
||||
/// The OS/2 `fsType` embedding permissions (0 without an OS/2 table).
|
||||
pub fs_type: u16,
|
||||
/// The OS/2 code page bits (`ulCodePageRange1`, `ulCodePageRange2`).
|
||||
pub code_pages: [u32; 2],
|
||||
/// The file the face is in; `None` for a face added as bytes.
|
||||
pub path: Option<PathBuf>,
|
||||
/// The face's number in its file: its index in a collection (0 in a single font), or for a
|
||||
/// Mac suitcase font the number of its `sfnt` resource.
|
||||
pub index: u32,
|
||||
/// Where it comes from.
|
||||
pub source: Source,
|
||||
/// For a face added as bytes: which of the catalog's added blobs holds it.
|
||||
pub(crate) blob: Option<u64>,
|
||||
}
|
||||
|
||||
/// A face record with the normalized names it answers to.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Face {
|
||||
pub info: FaceInfo,
|
||||
pub keys: Keys,
|
||||
}
|
||||
|
||||
/// Every name a face answers to besides its family and style, normalized ([`norm`]).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Keys {
|
||||
/// Its family in every language, typographic else legacy.
|
||||
pub families: Vec<String>,
|
||||
/// Its style in every language, and a variable font's default instance's name.
|
||||
pub styles: Vec<String>,
|
||||
/// Legacy family and style pairs (`Hiragino Sans W3` / `Regular`), per language record.
|
||||
pub legacy: Vec<(String, String)>,
|
||||
pub full_names: Vec<String>,
|
||||
pub postscript: Vec<String>,
|
||||
}
|
||||
|
||||
const MAX_AXES: usize = 64;
|
||||
const MAX_INSTANCES: usize = 512;
|
||||
/// The most faces read from a (possibly damaged) collection.
|
||||
pub(crate) const MAX_FACES: u32 = 256;
|
||||
/// The largest table the scan reads.
|
||||
const MAX_TABLE: u32 = 1 << 20;
|
||||
/// Tables holding outlines a renderer draws. A face with none of them (outlines in Apple's `hvgl`
|
||||
/// table, bitmaps only) draws nothing and is left out.
|
||||
const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"];
|
||||
|
||||
pub(crate) fn norm(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
/// The weight a style name implies (400 when it names none).
|
||||
pub fn style_weight(style: &str) -> f32 {
|
||||
const TABLE: &[(&str, f32)] = &[
|
||||
("extralight", 200.0),
|
||||
("ultralight", 200.0),
|
||||
("semibold", 600.0),
|
||||
("demibold", 600.0),
|
||||
("extrabold", 800.0),
|
||||
("ultrabold", 800.0),
|
||||
("hairline", 100.0),
|
||||
("thin", 100.0),
|
||||
("light", 300.0),
|
||||
("medium", 500.0),
|
||||
("bold", 700.0),
|
||||
("black", 900.0),
|
||||
("heavy", 900.0),
|
||||
];
|
||||
let s = norm(style);
|
||||
TABLE.iter().find(|(k, _)| s.contains(k)).map_or(400.0, |(_, w)| *w)
|
||||
}
|
||||
|
||||
/// Whether a style name says italic or oblique.
|
||||
pub fn style_italic(style: &str) -> bool {
|
||||
let s = norm(style);
|
||||
s.contains("italic") || s.contains("oblique") || s == "it"
|
||||
}
|
||||
|
||||
/// The width a style name implies, in percent of normal (100 when it names none).
|
||||
pub fn style_width(style: &str) -> f32 {
|
||||
const TABLE: &[(&str, f32)] = &[
|
||||
("ultracondensed", 50.0),
|
||||
("extracondensed", 62.5),
|
||||
("semicondensed", 87.5),
|
||||
("condensed", 75.0),
|
||||
("narrow", 75.0),
|
||||
("ultraexpanded", 200.0),
|
||||
("extraexpanded", 150.0),
|
||||
("semiexpanded", 112.5),
|
||||
("expanded", 125.0),
|
||||
("extended", 125.0),
|
||||
("wide", 125.0),
|
||||
];
|
||||
let s = norm(style);
|
||||
TABLE.iter().find(|(k, _)| s.contains(k)).map_or(100.0, |(_, w)| *w)
|
||||
}
|
||||
|
||||
/// `usWidthClass` (1–9) as a percentage of normal.
|
||||
fn width_class_percent(class: u16) -> Option<f32> {
|
||||
const PERCENT: [f32; 9] = [50.0, 62.5, 75.0, 87.5, 100.0, 112.5, 125.0, 150.0, 200.0];
|
||||
PERCENT.get(usize::from(class).checked_sub(1)?).copied()
|
||||
}
|
||||
|
||||
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
|
||||
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
|
||||
}
|
||||
|
||||
/// Every string of `id`, with its language.
|
||||
fn localized(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<(Option<String>, String)> {
|
||||
font.localized_strings(id).map(|s| (s.language().map(str::to_owned), s.to_string())).filter(|(_, s)| !s.is_empty()).collect()
|
||||
}
|
||||
|
||||
fn all_names(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<String> {
|
||||
localized(font, id).into_iter().map(|(_, s)| s).collect()
|
||||
}
|
||||
|
||||
fn sort_dedup(v: &mut Vec<String>) {
|
||||
v.retain(|k| !k.is_empty());
|
||||
v.sort();
|
||||
v.dedup();
|
||||
}
|
||||
|
||||
/// The faces `font` (face `index` of its file) offers: the face itself, then the named instances
|
||||
/// of a variable font. Empty when it has no family name.
|
||||
pub(crate) fn font_faces(font: &skrifa::FontRef<'_>, index: u32, path: Option<&Path>, source: Source) -> Vec<Face> {
|
||||
let Some(family) = name(font, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]) else { return vec![] };
|
||||
let style = name(font, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into());
|
||||
let typo_family = localized(font, StringId::TYPOGRAPHIC_FAMILY_NAME);
|
||||
let typo_style = all_names(font, StringId::TYPOGRAPHIC_SUBFAMILY_NAME);
|
||||
let legacy_family = localized(font, StringId::FAMILY_NAME);
|
||||
let legacy_style = localized(font, StringId::SUBFAMILY_NAME);
|
||||
|
||||
let family_records = if typo_family.is_empty() { &legacy_family } else { &typo_family };
|
||||
let mut localized_families: Vec<LocalizedName> = Vec::new();
|
||||
for (language, n) in family_records {
|
||||
if *n != family && !localized_families.iter().any(|l| l.name == *n) {
|
||||
localized_families.push(LocalizedName { language: language.clone(), name: n.clone() });
|
||||
}
|
||||
}
|
||||
let mut families: Vec<String> = std::iter::once(family.as_str()).chain(family_records.iter().map(|(_, f)| f.as_str())).map(norm).collect();
|
||||
// A legacy style names the face beside its legacy family only when the font has a
|
||||
// typographic style: every weight of `Hiragino Sans` is `Regular` in its own legacy family.
|
||||
let style_names: Vec<String> = if typo_style.is_empty() { legacy_style.iter().map(|(_, s)| s.clone()).collect() } else { typo_style };
|
||||
let mut styles: Vec<String> = std::iter::once(style.as_str()).chain(style_names.iter().map(String::as_str)).map(norm).collect();
|
||||
let mut legacy: Vec<(String, String)> = if typo_family.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
legacy_family
|
||||
.iter()
|
||||
.flat_map(|(lf, f)| legacy_style.iter().filter(move |(ls, _)| ls == lf).map(move |(_, s)| (norm(f), norm(s))))
|
||||
.filter(|(f, s)| !f.is_empty() && !s.is_empty())
|
||||
.collect()
|
||||
};
|
||||
legacy.sort();
|
||||
legacy.dedup();
|
||||
let mut postscript: Vec<String> = all_names(font, StringId::POSTSCRIPT_NAME).iter().map(|n| norm(n)).collect();
|
||||
let mut full_names: Vec<String> = all_names(font, StringId::FULL_NAME).iter().map(|n| norm(n)).collect();
|
||||
for v in [&mut families, &mut styles, &mut full_names, &mut postscript] {
|
||||
sort_dedup(v);
|
||||
}
|
||||
|
||||
let os2 = font.os2().ok();
|
||||
let weight = os2
|
||||
.as_ref()
|
||||
.map(|t| t.us_weight_class())
|
||||
.map(|w| if (1..10).contains(&w) { w * 100 } else { w })
|
||||
.filter(|w| (1..=1000).contains(w))
|
||||
.map_or_else(|| style_weight(&style), f32::from);
|
||||
let width = os2.as_ref().and_then(|t| width_class_percent(t.us_width_class())).unwrap_or_else(|| style_width(&style));
|
||||
let selection_italic = os2.as_ref().is_some_and(|t| {
|
||||
let f = t.fs_selection();
|
||||
f.contains(skrifa::raw::tables::os2::SelectionFlags::ITALIC) || f.contains(skrifa::raw::tables::os2::SelectionFlags::OBLIQUE)
|
||||
});
|
||||
let fs_type = os2.as_ref().map_or(0, |t| t.fs_type());
|
||||
let code_pages = os2.as_ref().map_or([0, 0], |t| [t.ul_code_page_range_1().unwrap_or(0), t.ul_code_page_range_2().unwrap_or(0)]);
|
||||
let axes = font.axes();
|
||||
let legacy_pair = (!typo_family.is_empty())
|
||||
.then(|| name(font, &[StringId::FAMILY_NAME]))
|
||||
.flatten()
|
||||
.filter(|f| *f != family)
|
||||
.map(|f| (f, name(font, &[StringId::SUBFAMILY_NAME])));
|
||||
|
||||
let info = FaceInfo {
|
||||
family: family.clone(),
|
||||
style: style.clone(),
|
||||
legacy_family: legacy_pair.as_ref().map(|(f, _)| f.clone()),
|
||||
legacy_style: legacy_pair.and_then(|(_, s)| s),
|
||||
localized_families,
|
||||
full_name: name(font, &[StringId::FULL_NAME]),
|
||||
postscript_name: name(font, &[StringId::POSTSCRIPT_NAME]),
|
||||
weight,
|
||||
width,
|
||||
italic: selection_italic || style_italic(&style),
|
||||
variations: vec![],
|
||||
variable: !axes.is_empty(),
|
||||
fs_type,
|
||||
code_pages,
|
||||
path: path.map(Path::to_path_buf),
|
||||
index,
|
||||
source,
|
||||
blob: None,
|
||||
};
|
||||
let mut keys = Keys { families, styles, legacy, full_names, postscript };
|
||||
let instances = named_instances(font, &info, &mut keys);
|
||||
std::iter::once(Face { info, keys }).chain(instances).collect()
|
||||
}
|
||||
|
||||
/// The named instances of a variable face (its `fvar` table) as faces of its family, without the
|
||||
/// default instance (the face itself) and without names already taken. The default instance's
|
||||
/// name, when it isn't the face's style (a face `Regular` whose default instance is `Book`), names
|
||||
/// the face too. Damaged tables give fewer instances or none.
|
||||
fn named_instances(font: &skrifa::FontRef<'_>, face: &FaceInfo, keys: &mut Keys) -> Vec<Face> {
|
||||
let axes = font.axes();
|
||||
if axes.is_empty() || axes.len() > MAX_AXES {
|
||||
return vec![];
|
||||
}
|
||||
let tags: Vec<[u8; 4]> = axes.iter().map(|a| a.tag().to_be_bytes()).collect();
|
||||
let defaults: Vec<f32> = axes.iter().map(|a| a.default_value()).collect();
|
||||
// Instances without a PostScript name of their own are named `<prefix>-<style>` (Adobe
|
||||
// Technical Note #5902).
|
||||
let prefix = name(font, &[StringId::VARIATIONS_POSTSCRIPT_NAME_PREFIX]).unwrap_or_else(|| face.family.replace(' ', ""));
|
||||
let mut taken = vec![norm(&face.style)];
|
||||
let mut out = Vec::new();
|
||||
for inst in font.named_instances().iter().take(MAX_INSTANCES) {
|
||||
let coords: Vec<f32> = inst.user_coords().take(MAX_AXES).collect();
|
||||
if coords.len() != tags.len() || coords.iter().any(|c| !c.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let Some(style) = name(font, &[inst.subfamily_name_id()]) else { continue };
|
||||
let ns = norm(&style);
|
||||
if ns.is_empty() || taken.contains(&ns) {
|
||||
continue;
|
||||
}
|
||||
let ps_default = format!("{prefix}-{}", style.replace(' ', ""));
|
||||
taken.push(ns.clone());
|
||||
if coords.iter().zip(&defaults).all(|(c, d)| (c - d).abs() < 1e-3) {
|
||||
keys.styles.push(ns);
|
||||
keys.postscript.push(norm(&ps_default));
|
||||
continue;
|
||||
}
|
||||
let axis = |tag: &[u8; 4]| tags.iter().zip(&coords).find(|(t, _)| *t == tag).map(|(_, v)| *v);
|
||||
let weight = axis(b"wght").map_or_else(|| style_weight(&style), |w| w.clamp(1.0, 1000.0));
|
||||
let width = axis(b"wdth").map_or_else(|| style_width(&style), |w| w.clamp(25.0, 400.0));
|
||||
let italic = style_italic(&style) || axis(b"ital").is_some_and(|v| v >= 0.5) || axis(b"slnt").is_some_and(|v| v != 0.0);
|
||||
let own_ps = inst.postscript_name_id().and_then(|id| name(font, &[id]));
|
||||
let mut styles: Vec<String> = std::iter::once(style.clone()).chain(all_names(font, inst.subfamily_name_id())).map(|s| norm(&s)).collect();
|
||||
let mut postscript: Vec<String> =
|
||||
inst.postscript_name_id().map(|id| all_names(font, id)).unwrap_or_default().iter().map(|n| norm(n)).collect();
|
||||
postscript.push(norm(&ps_default));
|
||||
sort_dedup(&mut styles);
|
||||
sort_dedup(&mut postscript);
|
||||
let info = FaceInfo {
|
||||
style,
|
||||
legacy_family: None,
|
||||
legacy_style: None,
|
||||
full_name: None,
|
||||
postscript_name: Some(own_ps.unwrap_or(ps_default)),
|
||||
weight,
|
||||
width,
|
||||
italic,
|
||||
variations: tags.iter().copied().zip(coords.iter().copied()).collect(),
|
||||
..face.clone()
|
||||
};
|
||||
let keys = Keys { families: keys.families.clone(), styles, legacy: vec![], full_names: vec![], postscript };
|
||||
out.push(Face { info, keys });
|
||||
}
|
||||
sort_dedup(&mut keys.styles);
|
||||
sort_dedup(&mut keys.postscript);
|
||||
out
|
||||
}
|
||||
|
||||
/// How many faces `data` (a font file or collection) holds; 0 when it isn't one.
|
||||
pub(crate) fn face_count(data: &[u8]) -> u32 {
|
||||
match FileRef::new(data) {
|
||||
Ok(FileRef::Font(_)) => 1,
|
||||
Ok(FileRef::Collection(c)) => c.len(),
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces of every font in `data`, at most `max_faces` font faces read.
|
||||
pub(crate) fn data_faces(data: &[u8], max_faces: u32, path: Option<&Path>, source: Source) -> Vec<Face> {
|
||||
(0..face_count(data).min(max_faces))
|
||||
.filter_map(|i| Some((i, skrifa::FontRef::from_index(data, i).ok()?)))
|
||||
.filter(|(_, f)| OUTLINE_TABLES.iter().any(|t| f.table_data(skrifa::Tag::new(t)).is_some()))
|
||||
.flat_map(|(i, f)| font_faces(&f, i, path, source))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The faces of the font file at `path`, reading only its table directories and `name`, `fvar`
|
||||
/// and `OS/2` tables, at most `max_read` bytes in all: a scan opens thousands of files, many of
|
||||
/// them megabytes long. A Mac suitcase font's `sfnt` resources are read instead.
|
||||
pub(crate) fn file_faces(path: &Path, max_read: u64, source: Source) -> Vec<Face> {
|
||||
if crate::suitcase::is_suitcase(path) {
|
||||
if crate::suitcase::fork_len(path) > max_read {
|
||||
return vec![];
|
||||
}
|
||||
let mut faces = Vec::new();
|
||||
for (resource, font) in (0u32..).zip(crate::suitcase::read_fonts(path)) {
|
||||
for mut face in data_faces(&font, 1, Some(path), source) {
|
||||
face.info.index = resource;
|
||||
faces.push(face);
|
||||
}
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
match std::fs::File::open(path) {
|
||||
Ok(file) => reader_faces(file, MAX_FACES, max_read, path, source),
|
||||
Err(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn reader_faces(mut file: impl std::io::Read + std::io::Seek, max_faces: u32, max_read: u64, path: &Path, source: Source) -> Vec<Face> {
|
||||
use std::io::SeekFrom;
|
||||
let mut read = 0u64;
|
||||
let file_len = file.seek(SeekFrom::End(0)).unwrap_or(0);
|
||||
let mut read_at = |offset: u64, len: usize| -> Option<Vec<u8>> {
|
||||
read = read.checked_add(u64::try_from(len).ok()?).filter(|r| *r <= max_read)?;
|
||||
let mut buf = vec![0; len];
|
||||
file.seek(SeekFrom::Start(offset)).ok()?;
|
||||
file.read_exact(&mut buf).ok()?;
|
||||
Some(buf)
|
||||
};
|
||||
let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes);
|
||||
let Some(head) = read_at(0, 12) else { return vec![] };
|
||||
let starts: Vec<u32> = if head.starts_with(b"ttcf") {
|
||||
// A damaged count is capped by what the file can hold.
|
||||
let fits = u32::try_from(file_len.saturating_sub(12) / 4).unwrap_or(u32::MAX);
|
||||
let n = be32(&head, 8).unwrap_or(0).min(max_faces).min(fits) as usize;
|
||||
read_at(12, n * 4).map(|b| b.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect()).unwrap_or_default()
|
||||
} else {
|
||||
vec![0]
|
||||
};
|
||||
(0u32..)
|
||||
.zip(starts)
|
||||
.filter_map(|(index, start)| {
|
||||
let dir = read_at(start.into(), 12)?;
|
||||
let tables = usize::from(u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?));
|
||||
let records = read_at(u64::from(start) + 12, tables * 16)?;
|
||||
let records: Vec<&[u8]> = records.as_chunks::<16>().0.iter().map(|r| r.as_slice()).collect();
|
||||
if !records.iter().any(|r| OUTLINE_TABLES.iter().any(|t| r.starts_with(*t))) {
|
||||
return None;
|
||||
}
|
||||
let mut table = |tag: &[u8; 4]| -> Option<Vec<u8>> {
|
||||
let rec = records.iter().find(|r| r.starts_with(tag))?;
|
||||
let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?);
|
||||
if len > MAX_TABLE {
|
||||
return None;
|
||||
}
|
||||
read_at(offset.into(), len as usize)
|
||||
};
|
||||
let name = table(b"name")?;
|
||||
let mut tables: Vec<(&[u8; 4], Vec<u8>)> = vec![(b"name", name)];
|
||||
for tag in [b"fvar", b"OS/2"] {
|
||||
if let Some(t) = table(tag) {
|
||||
tables.push((tag, t));
|
||||
}
|
||||
}
|
||||
let refs: Vec<(&[u8; 4], &[u8])> = tables.iter().map(|(t, d)| (*t, d.as_slice())).collect();
|
||||
let font = sfnt_of(&refs)?;
|
||||
Some(font_faces(&skrifa::FontRef::new(&font).ok()?, index, Some(path), source))
|
||||
})
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A font file holding `tables` (tag, data), sorted by tag as table directories are.
|
||||
pub(crate) fn sfnt_of(tables: &[(&[u8; 4], &[u8])]) -> Option<Vec<u8>> {
|
||||
let mut tables = tables.to_vec();
|
||||
tables.sort_by_key(|t| *t.0);
|
||||
let n = u16::try_from(tables.len()).ok()?;
|
||||
let mut font = Vec::new();
|
||||
font.extend_from_slice(&0x0001_0000_u32.to_be_bytes());
|
||||
for v in [n, 0, 0, 0] {
|
||||
font.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let mut offset = 12 + 16 * tables.len();
|
||||
for (tag, data) in &tables {
|
||||
font.extend_from_slice(*tag);
|
||||
for v in [0, u32::try_from(offset).ok()?, u32::try_from(data.len()).ok()?] {
|
||||
font.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
offset += data.len().next_multiple_of(4);
|
||||
}
|
||||
for (_, data) in &tables {
|
||||
font.extend_from_slice(data);
|
||||
font.resize(font.len().next_multiple_of(4), 0);
|
||||
}
|
||||
Some(font)
|
||||
}
|
||||
|
||||
/// Whether `path` has a font file's extension (`.ttf`, `.otf`, `.ttc`, `.otc`, in any case), as
|
||||
/// the scan picks files in a folder.
|
||||
pub fn is_font_file(path: &Path) -> bool {
|
||||
path.extension().and_then(|e| e.to_str()).is_some_and(|e| ["ttf", "otf", "ttc", "otc"].iter().any(|x| e.eq_ignore_ascii_case(x)))
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Suitcase fonts: classic Mac OS font files whose data fork is empty and whose fonts are `sfnt`
|
||||
//! resources in the resource fork (Microsoft Office installs its fonts this way in
|
||||
//! `/Library/Fonts/Microsoft`). macOS lists them as installed; a reader of the file's data finds
|
||||
//! nothing, so the scan reads the resource fork. The fork's layout is Apple's published resource
|
||||
//! file format (Inside Macintosh: More Macintosh Toolbox, "Resource Manager").
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Caps on what a (possibly damaged) fork can make the scan read.
|
||||
const MAX_FORK: u64 = 256 << 20;
|
||||
const MAX_TYPES: usize = 1 << 12;
|
||||
const MAX_FONTS: usize = 256;
|
||||
|
||||
fn be16(b: &[u8], at: usize) -> Option<usize> {
|
||||
b.get(at..at.checked_add(2)?).and_then(|s| s.try_into().ok()).map(|s| u16::from_be_bytes(s) as usize)
|
||||
}
|
||||
|
||||
fn be32(b: &[u8], at: usize) -> Option<usize> {
|
||||
b.get(at..at.checked_add(4)?).and_then(|s| s.try_into().ok()).map(|s| u32::from_be_bytes(s) as usize)
|
||||
}
|
||||
|
||||
/// The font files (`sfnt` resources) of a resource fork, in the order its map lists them. A
|
||||
/// fork that isn't well formed gives the fonts read before the damage, or none.
|
||||
pub(crate) fn sfnt_resources(fork: &[u8]) -> Vec<&[u8]> {
|
||||
let mut fonts = Vec::new();
|
||||
let _ = collect(fork, &mut fonts);
|
||||
fonts
|
||||
}
|
||||
|
||||
fn collect<'a>(fork: &'a [u8], fonts: &mut Vec<&'a [u8]>) -> Option<()> {
|
||||
// The header: where the data and the map start.
|
||||
let data = be32(fork, 0)?;
|
||||
let map = fork.get(be32(fork, 4)?..)?;
|
||||
// The map: its type list starts at the offset kept 24 bytes in, and counts from the map.
|
||||
let types = be16(map, 24)?;
|
||||
// Counts are stored less one, so a list of none is 0xFFFF.
|
||||
let n_types = (be16(map, types)? + 1) & 0xFFFF;
|
||||
let mut total = 0usize;
|
||||
for t in 0..n_types.min(MAX_TYPES) {
|
||||
let at = types + 2 + t * 8;
|
||||
if map.get(at..at.checked_add(4)?)? != b"sfnt" {
|
||||
continue;
|
||||
}
|
||||
let count = (be16(map, at + 4)? + 1) & 0xFFFF;
|
||||
// Each reference: its data's offset is the low three bytes of the word 4 bytes in.
|
||||
let refs = types + be16(map, at + 6)?;
|
||||
for r in 0..count {
|
||||
let start = data.checked_add(be32(map, refs + r * 12 + 4)? & 0x00FF_FFFF)?;
|
||||
let len = be32(fork, start)?;
|
||||
// References can share data: cap the total too, as every font is copied out.
|
||||
total = total.checked_add(len)?;
|
||||
if fonts.len() >= MAX_FONTS || total as u64 > MAX_FORK {
|
||||
return Some(());
|
||||
}
|
||||
fonts.push(fork.get(start.checked_add(4)?..start.checked_add(4)?.checked_add(len)?)?);
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The fonts of the suitcase font at `path`, read from its resource fork; none for a file
|
||||
/// without one, and off macOS, the only place such a fork is a part of a file.
|
||||
pub(crate) fn read_fonts(path: &Path) -> Vec<Vec<u8>> {
|
||||
let Some(fork) = read_fork(path) else { return vec![] };
|
||||
sfnt_resources(&fork).into_iter().map(<[u8]>::to_vec).collect()
|
||||
}
|
||||
|
||||
/// Whether the file at `path` is a suitcase font: nothing in its data fork and something in its
|
||||
/// resource fork.
|
||||
pub(crate) fn is_suitcase(path: &Path) -> bool {
|
||||
// Off macOS no file has a fork: don't look at every font file twice.
|
||||
cfg!(target_os = "macos") && std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() == 0) && fork_len(path) > 0
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn fork_path(path: &Path) -> std::path::PathBuf {
|
||||
path.join("..namedfork/rsrc")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn fork_len(path: &Path) -> u64 {
|
||||
std::fs::metadata(fork_path(path)).map_or(0, |m| m.len())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn read_fork(path: &Path) -> Option<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let mut fork = Vec::new();
|
||||
std::fs::File::open(fork_path(path)).ok()?.take(MAX_FORK).read_to_end(&mut fork).ok()?;
|
||||
Some(fork)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(crate) fn fork_len(_: &Path) -> u64 {
|
||||
0
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn read_fork(_: &Path) -> Option<Vec<u8>> {
|
||||
let _ = MAX_FORK;
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! The platform's font service through fontique: CoreText's available fonts on macOS (fonts that
|
||||
//! Adobe Fonts, RightFont and other managers activate from their own folders included),
|
||||
//! DirectWrite's system font collection on Windows (per-user fonts and font-service fonts
|
||||
//! included), and fontconfig on Linux and FreeBSD (Flatpak's host fonts included).
|
||||
//!
|
||||
//! One fontique collection serves the whole process: the catalog lists its files, and the UI's
|
||||
//! font fallback ([`collection`]) queries the same collection, so the system's fonts are
|
||||
//! enumerated once.
|
||||
|
||||
#[cfg(all(feature = "system", not(target_arch = "wasm32")))]
|
||||
mod imp {
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
use fontique::{Collection, CollectionOptions, SourceKind};
|
||||
|
||||
use crate::{FontEnv, font_store_dir};
|
||||
|
||||
struct Shared {
|
||||
generation: u64,
|
||||
collection: Collection,
|
||||
}
|
||||
|
||||
static SHARED: Mutex<Option<Shared>> = Mutex::new(None);
|
||||
|
||||
/// A new collection of the system's fonts and the font store's. `None` when fontique panics
|
||||
/// (its DirectWrite backend unwraps a failed factory).
|
||||
fn build() -> Option<Collection> {
|
||||
std::panic::catch_unwind(|| {
|
||||
let mut collection = Collection::new(CollectionOptions { shared: false, system_fonts: true });
|
||||
if let Some(store) = font_store_dir(&FontEnv::from_process()).filter(|d| d.is_dir()) {
|
||||
collection.load_fonts_from_paths([store]);
|
||||
}
|
||||
collection
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn with<R>(refresh: bool, f: impl FnOnce(&mut Shared) -> R) -> R {
|
||||
let mut guard = SHARED.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let generation = guard.as_ref().map_or(0, |s| s.generation);
|
||||
let shared = match &mut *guard {
|
||||
Some(shared) if !refresh => shared,
|
||||
slot => {
|
||||
let collection = build().unwrap_or_else(|| Collection::new(CollectionOptions { shared: false, system_fonts: false }));
|
||||
slot.insert(Shared { generation: generation + 1, collection })
|
||||
}
|
||||
};
|
||||
f(shared)
|
||||
}
|
||||
|
||||
pub fn collection() -> Collection {
|
||||
with(false, |s| s.collection.clone())
|
||||
}
|
||||
|
||||
pub fn generation() -> u64 {
|
||||
SHARED.lock().unwrap_or_else(PoisonError::into_inner).as_ref().map_or(0, |s| s.generation)
|
||||
}
|
||||
|
||||
pub fn refresh() {
|
||||
with(true, |_| ());
|
||||
}
|
||||
|
||||
pub fn font_files(refresh: bool) -> Vec<PathBuf> {
|
||||
with(refresh, |s| {
|
||||
let names: Vec<String> = s.collection.family_names().map(str::to_owned).collect();
|
||||
let mut files: Vec<PathBuf> = names
|
||||
.iter()
|
||||
.filter_map(|name| s.collection.family_by_name(name))
|
||||
.flat_map(|family| {
|
||||
family
|
||||
.fonts()
|
||||
.iter()
|
||||
.filter_map(|font| match font.source().kind() {
|
||||
SourceKind::Path(path) => Some(path.to_path_buf()),
|
||||
SourceKind::Memory(_) => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
files
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "system", not(target_arch = "wasm32"))))]
|
||||
mod imp {
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn font_files(_refresh: bool) -> Vec<PathBuf> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "system", not(target_arch = "wasm32")))]
|
||||
/// The process's fontique collection of the system's fonts and the font store's, built on the
|
||||
/// first call (which waits for the enumeration; later calls are cheap clones that share it).
|
||||
/// Clones taken before a rescan keep the fonts as they were: compare [`generation`] to know when
|
||||
/// to take a new one.
|
||||
pub fn collection() -> fontique::Collection {
|
||||
imp::collection()
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "system", not(target_arch = "wasm32")))]
|
||||
/// Counts the builds of [`collection`]: 0 before the first, one more after each rescan that asked
|
||||
/// the font service again.
|
||||
pub fn generation() -> u64 {
|
||||
imp::generation()
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "system", not(target_arch = "wasm32")))]
|
||||
/// Enumerate the system's fonts again (a catalog's [`crate::Catalog::rescan`] does this).
|
||||
pub fn refresh() {
|
||||
imp::refresh();
|
||||
}
|
||||
|
||||
/// The font files the platform's font service lists, enumerating again first with `refresh`.
|
||||
pub(crate) fn font_files(refresh: bool) -> Vec<std::path::PathBuf> {
|
||||
imp::font_files(refresh)
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
use write_fonts::FontBuilder;
|
||||
use write_fonts::tables::fvar::{AxisInstanceArrays, Fvar, InstanceRecord, VariationAxisRecord};
|
||||
use write_fonts::tables::name::{Name, NameRecord};
|
||||
use write_fonts::tables::os2::{Os2, SelectionFlags};
|
||||
use write_fonts::types::{Fixed, NameId, Tag};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// A test font: only the tables the catalog reads, and an empty `glyf` so it counts as drawable.
|
||||
#[derive(Clone)]
|
||||
struct Spec {
|
||||
family: &'static str,
|
||||
style: &'static str,
|
||||
/// Legacy family and style (name IDs 1 and 2); the names above are then typographic.
|
||||
legacy: Option<(&'static str, &'static str)>,
|
||||
/// Japanese family name.
|
||||
ja: Option<&'static str>,
|
||||
weight: u16,
|
||||
width: u16,
|
||||
italic: bool,
|
||||
fs_type: u16,
|
||||
/// Named instances: style and `wght`; the font gets a `wght` axis from 100 to 900, default 400.
|
||||
instances: Vec<(&'static str, f64)>,
|
||||
outlines: bool,
|
||||
}
|
||||
|
||||
fn spec(family: &'static str, style: &'static str, weight: u16) -> Spec {
|
||||
Spec { family, style, legacy: None, ja: None, weight, width: 5, italic: false, fs_type: 0, instances: vec![], outlines: true }
|
||||
}
|
||||
|
||||
fn font(s: &Spec) -> Vec<u8> {
|
||||
let mut records = Vec::new();
|
||||
let mut add = |lang: u16, id: u16, text: String| records.push(NameRecord::new(3, 1, lang, NameId::new(id), text.into()));
|
||||
let ps = format!("{}-{}", s.family.replace(' ', ""), s.style.replace(' ', ""));
|
||||
match s.legacy {
|
||||
Some((family, style)) => {
|
||||
add(0x409, 1, family.into());
|
||||
add(0x409, 2, style.into());
|
||||
add(0x409, 16, s.family.into());
|
||||
add(0x409, 17, s.style.into());
|
||||
}
|
||||
None => {
|
||||
add(0x409, 1, s.family.into());
|
||||
add(0x409, 2, s.style.into());
|
||||
}
|
||||
}
|
||||
add(0x409, 4, format!("{} {}", s.family, s.style));
|
||||
add(0x409, 6, ps);
|
||||
if let Some(ja) = s.ja {
|
||||
add(0x411, if s.legacy.is_some() { 16 } else { 1 }, ja.into());
|
||||
}
|
||||
let mut instances = Vec::new();
|
||||
for (next_id, (style, wght)) in (256..).zip(&s.instances) {
|
||||
add(0x409, next_id, (*style).into());
|
||||
instances.push(InstanceRecord {
|
||||
subfamily_name_id: NameId::new(next_id),
|
||||
flags: 0,
|
||||
coordinates: vec![Fixed::from_f64(*wght)],
|
||||
post_script_name_id: None,
|
||||
});
|
||||
}
|
||||
records.sort_by_key(|r| (r.platform_id, r.encoding_id, r.language_id, r.name_id));
|
||||
let mut builder = FontBuilder::new();
|
||||
builder.add_table(&Name::new(records)).unwrap();
|
||||
let os2 = Os2 {
|
||||
us_weight_class: s.weight,
|
||||
us_width_class: s.width,
|
||||
fs_type: s.fs_type,
|
||||
fs_selection: if s.italic { SelectionFlags::ITALIC } else { SelectionFlags::REGULAR },
|
||||
ul_code_page_range_1: Some(1),
|
||||
ul_code_page_range_2: Some(0),
|
||||
..Default::default()
|
||||
};
|
||||
builder.add_table(&os2).unwrap();
|
||||
if !instances.is_empty() {
|
||||
let axis = VariationAxisRecord {
|
||||
axis_tag: Tag::new(b"wght"),
|
||||
min_value: Fixed::from_f64(100.0),
|
||||
default_value: Fixed::from_f64(400.0),
|
||||
max_value: Fixed::from_f64(900.0),
|
||||
flags: 0,
|
||||
axis_name_id: NameId::new(255),
|
||||
};
|
||||
builder.add_table(&Fvar::new(AxisInstanceArrays::new(vec![axis], instances))).unwrap();
|
||||
}
|
||||
if s.outlines {
|
||||
builder.add_raw(Tag::new(b"glyf"), vec![0u8; 4]);
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
|
||||
/// A font collection of `fonts`: their table directories, with offsets moved to the new file.
|
||||
fn collection(fonts: &[Vec<u8>]) -> Vec<u8> {
|
||||
let header = 12 + 4 * fonts.len();
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(b"ttcf");
|
||||
out.extend_from_slice(&0x0001_0000u32.to_be_bytes());
|
||||
out.extend_from_slice(&(fonts.len() as u32).to_be_bytes());
|
||||
let mut base = header;
|
||||
let mut bodies = Vec::new();
|
||||
for f in fonts {
|
||||
out.extend_from_slice(&(base as u32).to_be_bytes());
|
||||
let mut f = f.clone();
|
||||
let tables = u16::from_be_bytes([f[4], f[5]]) as usize;
|
||||
for t in 0..tables {
|
||||
let at = 12 + 16 * t + 8;
|
||||
let offset = u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) + base as u32;
|
||||
f[at..at + 4].copy_from_slice(&offset.to_be_bytes());
|
||||
}
|
||||
base += f.len();
|
||||
bodies.push(f);
|
||||
}
|
||||
for b in bodies {
|
||||
out.extend(b);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("craft-font-catalog-{name}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn write(path: &Path, data: impl AsRef<[u8]>) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, data).unwrap();
|
||||
}
|
||||
|
||||
/// A Linux system under `root` with no fontconfig configuration and no other XDG folders.
|
||||
fn linux_env(root: &Path) -> FontEnv {
|
||||
FontEnv {
|
||||
os: Os::Unix,
|
||||
root: root.to_path_buf(),
|
||||
home: Some(root.join("home")),
|
||||
xdg_data_dirs: Some(root.join("share").into_os_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_font_dirs_cover_xdg_flatpak_and_fontconfig() {
|
||||
let root = temp("dirs");
|
||||
write(&root.join("etc/fonts/fonts.conf"), "<fontconfig><dir>/opt/texmf/fonts</dir><dir>/usr/share/fonts</dir></fontconfig>");
|
||||
let mut env = linux_env(&root);
|
||||
env.xdg_data_home = Some(root.join("data"));
|
||||
let dirs = font_dirs(&env);
|
||||
let want: Vec<PathBuf> = vec![
|
||||
root.join("usr/share/fonts"),
|
||||
root.join("usr/local/share/fonts"),
|
||||
root.join("run/host/fonts"),
|
||||
root.join("run/host/local-fonts"),
|
||||
root.join("run/host/user-fonts"),
|
||||
root.join("home/.local/share/fonts"),
|
||||
root.join("home/.fonts"),
|
||||
root.join("data/fonts"),
|
||||
root.join(root.join("share").strip_prefix("/").unwrap()).join("fonts"),
|
||||
root.join("opt/texmf/fonts"),
|
||||
];
|
||||
assert_eq!(dirs, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fontconfig_dirs_follow_includes_prefixes_and_skip_the_rest() {
|
||||
let root = temp("conf");
|
||||
let etc = root.join("etc/fonts");
|
||||
write(
|
||||
&etc.join("fonts.conf"),
|
||||
r#"<?xml version="1.0"?>
|
||||
<fontconfig>
|
||||
<!-- <dir>/commented/out</dir> -->
|
||||
<dir>/sys/fonts</dir>
|
||||
<dir prefix="xdg">fonts</dir>
|
||||
<dir>~/.fonts</dir>
|
||||
<cachedir>/var/cache/fontconfig</cachedir>
|
||||
<dir prefix="relative">local-fonts</dir>
|
||||
<dir salt="x">/a&b/fonts</dir>
|
||||
<include ignore_missing="yes">conf.d</include>
|
||||
<include ignore_missing="yes">missing.conf</include>
|
||||
<include>fonts.conf</include>
|
||||
</fontconfig>"#,
|
||||
);
|
||||
write(&etc.join("conf.d/60-remap.conf"), "<fontconfig><remap-dir as-path=\"/usr/share/fonts\">/remapped/fonts</remap-dir></fontconfig>");
|
||||
write(&etc.join("conf.d/README"), "<dir>/not/a/conf/file</dir>");
|
||||
write(&etc.join("conf.d/nodigit.conf"), "<fontconfig><dir>/no/digit</dir></fontconfig>");
|
||||
write(&etc.join("b.conf"), "<fontconfig><include>fonts.conf</include><dir>/unclosed");
|
||||
write(
|
||||
&root.join("home/.config/fontconfig/fonts.conf"),
|
||||
"<fontconfig><dir>/user/conf/fonts</dir><include>../../../etc/fonts/b.conf</include></fontconfig>",
|
||||
);
|
||||
let home = root.join("home");
|
||||
assert_eq!(
|
||||
fontconfig_dirs(&linux_env(&root)),
|
||||
vec![
|
||||
root.join("sys/fonts"),
|
||||
home.join(".local/share/fonts"),
|
||||
home.join(".fonts"),
|
||||
etc.join("local-fonts"),
|
||||
root.join("a&b/fonts"),
|
||||
root.join("remapped/fonts"),
|
||||
root.join("user/conf/fonts"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatpak_adds_the_hosts_font_folders() {
|
||||
let root = temp("flatpak");
|
||||
write(&root.join("run/host/font-dirs.xml"), "<fontconfig><remap-dir as-path=\"/usr/share/fonts\">/run/host/fonts</remap-dir></fontconfig>");
|
||||
write(&root.join("run/host/etc/fonts/fonts.conf"), "<fontconfig><dir>/usr/share/texmf/fonts</dir><dir>~/MyFonts</dir></fontconfig>");
|
||||
let mut env = linux_env(&root);
|
||||
assert!(fontconfig_dirs(&env).is_empty());
|
||||
env.flatpak = true;
|
||||
assert_eq!(fontconfig_dirs(&env), vec![root.join("run/host/fonts"), root.join("run/host/usr/share/texmf/fonts"), root.join("home/MyFonts")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_and_windows_folders() {
|
||||
let root = temp("mac");
|
||||
for asset in ["com_apple_MobileAsset_Font7/abc.asset", "com_apple_MobileAsset_Other/x"] {
|
||||
std::fs::create_dir_all(root.join("System/Library/AssetsV2").join(asset)).unwrap();
|
||||
}
|
||||
let env = FontEnv { os: Os::MacOs, root: root.clone(), home: Some("/Users/me".into()), ..Default::default() };
|
||||
let dirs = font_dirs(&env);
|
||||
assert!(dirs.contains(&root.join("System/Library/Fonts")));
|
||||
assert!(dirs.contains(&PathBuf::from("/Users/me/Library/Fonts")));
|
||||
assert!(dirs.contains(&root.join("System/Library/AssetsV2/com_apple_MobileAsset_Font7")));
|
||||
assert!(!dirs.iter().any(|d| d.ends_with("com_apple_MobileAsset_Other")));
|
||||
assert_eq!(font_store_dir(&env), Some(PathBuf::from("/Users/me/Library/Application Support/Craft Fonts")));
|
||||
|
||||
let env = FontEnv {
|
||||
os: Os::Windows,
|
||||
windir: Some("C:\\Windows".into()),
|
||||
local_app_data: Some("C:\\Users\\me\\AppData\\Local".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
font_dirs(&env),
|
||||
vec![PathBuf::from("C:\\Windows").join("Fonts"), PathBuf::from("C:\\Users\\me\\AppData\\Local").join("Microsoft\\Windows\\Fonts")]
|
||||
);
|
||||
assert_eq!(font_store_dir(&env), Some(PathBuf::from("C:\\Users\\me\\AppData\\Local").join("Craft Fonts")));
|
||||
assert_eq!(office_font_dirs(&env).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_store_folder() {
|
||||
let env = FontEnv { os: Os::Unix, home: Some("/home/me".into()), ..Default::default() };
|
||||
assert_eq!(font_store_dir(&env), Some(PathBuf::from("/home/me/.local/share/craft-fonts")));
|
||||
let env = FontEnv { xdg_data_home: Some("/data".into()), ..env };
|
||||
assert_eq!(font_store_dir(&env), Some(PathBuf::from("/data/craft-fonts")));
|
||||
let env = FontEnv { font_store: Some("/portable/fonts".into()), ..env };
|
||||
assert_eq!(font_store_dir(&env), Some(PathBuf::from("/portable/fonts")));
|
||||
assert_eq!(font_store_dir(&FontEnv { os: Os::None, home: Some("/h".into()), ..Default::default() }), None);
|
||||
}
|
||||
|
||||
/// A Linux system with fonts in a system folder, the Flatpak host folder, the user's folder and
|
||||
/// the font store, plus a collection, a lister's file without an extension, and files that aren't
|
||||
/// fonts.
|
||||
fn system(root: &Path) -> Arc<Catalog> {
|
||||
let sys = root.join("usr/share/fonts/test");
|
||||
write(&sys.join("TestSans-Regular.ttf"), font(&spec("Test Sans", "Regular", 400)));
|
||||
write(&sys.join("TestSans-Bold.ttf"), font(&spec("Test Sans", "Bold", 700)));
|
||||
write(&sys.join("TestSans-Italic.TTF"), font(&Spec { italic: true, ..spec("Test Sans", "Italic", 400) }));
|
||||
write(&sys.join("TestSans-Light.ttf"), font(&Spec { legacy: Some(("Test Sans Light", "Regular")), ..spec("Test Sans", "Light", 300) }));
|
||||
write(&sys.join("TestSans-Condensed.ttf"), font(&Spec { width: 3, ..spec("Test Sans", "Condensed", 400) }));
|
||||
write(&sys.join("Bitmap.ttf"), font(&Spec { outlines: false, ..spec("Bitmap Only", "Regular", 400) }));
|
||||
write(&sys.join("damaged.otf"), b"OTTO\0\x10garbage");
|
||||
write(&sys.join("notes.txt"), font(&spec("Not Scanned", "Regular", 400)));
|
||||
write(
|
||||
&root.join("run/host/fonts/HostSerif.ttc"),
|
||||
collection(&[font(&spec("Host Serif", "Regular", 400)), font(&spec("Host Serif", "Bold", 700))]),
|
||||
);
|
||||
write(
|
||||
&root.join("home/.local/share/fonts/Gothic.otf"),
|
||||
font(&Spec { legacy: Some(("Test Gothic W3", "Regular")), ja: Some("テストゴシック"), ..spec("Test Gothic", "W3", 300) }),
|
||||
);
|
||||
write(
|
||||
&root.join("home/.local/share/fonts/Variable.ttf"),
|
||||
font(&Spec { instances: vec![("Regular", 400.0), ("Thin", 100.0), ("Black", 900.0)], ..spec("Test Variable", "Regular", 400) }),
|
||||
);
|
||||
write(&root.join("home/.local/share/craft-fonts/Store/StoreFace.ttf"), font(&spec("Store Face", "Regular", 400)));
|
||||
let service = root.join("service/.hidden/f1");
|
||||
write(&service, font(&Spec { fs_type: 2, ..spec("Service Font", "Regular", 400) }));
|
||||
Arc::new(Catalog::new(linux_env(root)).with_font_service(false).with_lister(Arc::new(move || vec![service.clone()])))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scan_finds_folders_store_lister_files_and_collections() {
|
||||
let root = temp("scan");
|
||||
let cat = system(&root);
|
||||
cat.scan_in_background();
|
||||
let families: Vec<String> = cat.families().to_vec();
|
||||
assert_eq!(families, ["Host Serif", "Service Font", "Store Face", "Test Gothic", "Test Sans", "Test Variable"]);
|
||||
assert!(cat.is_ready());
|
||||
let host = cat.styles("host serif");
|
||||
assert_eq!(host.iter().map(|f| (f.style.as_str(), f.index)).collect::<Vec<_>>(), [("Regular", 0), ("Bold", 1)]);
|
||||
assert_eq!(cat.styles("Store Face")[0].source, Source::Store);
|
||||
assert_eq!(host[0].source, Source::System);
|
||||
let service = &cat.styles("Service Font")[0];
|
||||
assert_eq!((service.source, service.fs_type), (Source::System, 2));
|
||||
assert_eq!(cat.styles("Test Sans").len(), 5);
|
||||
let light = cat.resolve("Test Sans", "Light").unwrap().0;
|
||||
assert_eq!((light.legacy_family.as_deref(), light.legacy_style.as_deref()), (Some("Test Sans Light"), Some("Regular")));
|
||||
assert_eq!(light.code_pages, [1, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_by_every_name_kind() {
|
||||
let root = temp("lookup");
|
||||
let cat = system(&root);
|
||||
let found = |name: &str, style: &str| cat.resolve(name, style).map(|(f, m)| (f.family, f.style, m));
|
||||
let exact = |family: &str, style: &str| Some((family.to_string(), style.to_string(), Match::Exact));
|
||||
let nearest = |family: &str, style: &str| Some((family.to_string(), style.to_string(), Match::NearestStyle));
|
||||
assert_eq!(found("TEST SANS", "bold"), exact("Test Sans", "Bold"));
|
||||
assert_eq!(found("Test Sans", ""), exact("Test Sans", "Regular"));
|
||||
// Nearest weight, italic differing costing more than any weight.
|
||||
assert_eq!(found("Test Sans", "Semibold"), nearest("Test Sans", "Bold"));
|
||||
assert_eq!(found("Test Sans", "Bold Italic"), nearest("Test Sans", "Italic"));
|
||||
assert_eq!(found("Test Sans", "Thin"), nearest("Test Sans", "Light"));
|
||||
// Equal weights: the nearest width.
|
||||
assert_eq!(found("Test Sans", "Book Condensed"), nearest("Test Sans", "Condensed"));
|
||||
// A legacy family with its legacy style, a localized family, full and PostScript names.
|
||||
assert_eq!(found("Test Sans Light", "Regular"), exact("Test Sans", "Light"));
|
||||
assert_eq!(found("Test Gothic W3", "Regular"), exact("Test Gothic", "W3"));
|
||||
assert_eq!(found("テストゴシック", "W3"), exact("Test Gothic", "W3"));
|
||||
assert_eq!(cat.family("testgothic"), Some("Test Gothic".into()));
|
||||
assert_eq!(found("Test Sans Bold", ""), exact("Test Sans", "Bold"));
|
||||
assert_eq!(found("TestSans-Bold", "Regular"), exact("Test Sans", "Bold"));
|
||||
assert_eq!(cat.by_postscript_name("TestSans-Italic").map(|f| f.style), Some("Italic".into()));
|
||||
// Named instances: by style, by their PostScript names, and the default instance names the face.
|
||||
let thin = cat.resolve("Test Variable", "Thin").unwrap().0;
|
||||
assert_eq!((thin.weight, thin.variations.clone()), (100.0, vec![(*b"wght", 100.0)]));
|
||||
assert_eq!(cat.by_postscript_name("TestVariable-Black").map(|f| f.weight), Some(900.0));
|
||||
assert_eq!(cat.styles("Test Variable").len(), 3);
|
||||
assert_eq!(found("Missing Family", "Regular"), None);
|
||||
assert_eq!(cat.by_postscript_name("Missing-Regular"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_and_removed_fonts_are_seen_without_a_restart() {
|
||||
let root = temp("rescan");
|
||||
let cat = system(&root);
|
||||
assert!(!cat.installed_fonts_changed());
|
||||
assert!(cat.family("Test Sans").is_some());
|
||||
let generation = cat.generation();
|
||||
assert!(!cat.installed_fonts_changed());
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let _watch = cat.watch(Duration::from_millis(20), move |c| {
|
||||
let _ = tx.send((c.family("New Face"), c.family("Later")));
|
||||
});
|
||||
write(&root.join("usr/share/fonts/test/new/NewFace.otf"), font(&spec("New Face", "Regular", 400)));
|
||||
assert_eq!(rx.recv_timeout(Duration::from_secs(10)).unwrap(), (Some("New Face".into()), None));
|
||||
assert!(cat.generation() > generation);
|
||||
// A font store install, then a removal.
|
||||
write(&root.join("home/.local/share/craft-fonts/Later.ttf"), font(&spec("Later", "Regular", 400)));
|
||||
assert_eq!(rx.recv_timeout(Duration::from_secs(10)).unwrap(), (Some("New Face".into()), Some("Later".into())));
|
||||
assert_eq!(cat.styles("Later")[0].source, Source::Store);
|
||||
std::fs::remove_file(root.join("usr/share/fonts/test/new/NewFace.otf")).unwrap();
|
||||
assert_eq!(rx.recv_timeout(Duration::from_secs(10)).unwrap(), (None, Some("Later".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_folders_are_scanned_after_a_change() {
|
||||
let root = temp("app");
|
||||
let cat = Catalog::new(linux_env(&root)).with_font_service(false);
|
||||
assert!(cat.families().is_empty());
|
||||
write(&root.join("app-fonts/AppFace.ttf"), font(&spec("App Face", "Regular", 400)));
|
||||
cat.set_app_dirs(vec![root.join("app-fonts")]);
|
||||
assert!(cat.rescan_if_changed());
|
||||
assert_eq!(cat.styles("App Face")[0].source, Source::App);
|
||||
assert!(!cat.rescan_if_changed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_family_loads_once_for_every_thread() {
|
||||
let root = temp("load");
|
||||
let cat = system(&root);
|
||||
let loads: Vec<Arc<[LoadedFace]>> = std::thread::scope(|s| {
|
||||
let threads: Vec<_> = (0..8).map(|_| s.spawn(|| cat.load_family("host serif"))).collect();
|
||||
threads.into_iter().map(|t| t.join().unwrap()).collect()
|
||||
});
|
||||
assert!(loads.iter().all(|l| Arc::ptr_eq(l, &loads[0])));
|
||||
let host = &loads[0];
|
||||
assert_eq!(host.len(), 2);
|
||||
// Both faces of the collection share its bytes.
|
||||
assert!(Arc::ptr_eq(&host[0].data.bytes, &host[1].data.bytes));
|
||||
assert_eq!(host[1].data.index, 1);
|
||||
assert!(skrifa::FontRef::from_index(&host[1].data.bytes, 1).is_ok());
|
||||
// Another family loads separately, and a rescan makes the next load read again.
|
||||
assert_eq!(cat.load_family("Test Sans").len(), 5);
|
||||
cat.rescan();
|
||||
assert!(!Arc::ptr_eq(&cat.load_family("Host Serif"), &loads[0]));
|
||||
assert!(cat.load_family("Missing").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_fonts_come_first_and_respect_the_limits() {
|
||||
let root = temp("add");
|
||||
let cat = system(&root);
|
||||
let limits = Limits::default();
|
||||
let doc = font(&spec("Test Sans", "Bold", 700));
|
||||
let added = cat.add_bytes(doc.clone(), Source::Document, &limits).unwrap();
|
||||
assert_eq!(added.len(), 1);
|
||||
let (bold, m) = cat.resolve("Test Sans", "Bold").unwrap();
|
||||
assert_eq!((bold.source, bold.path.clone(), m), (Source::Document, None, Match::Exact));
|
||||
assert_eq!(&*cat.load(&bold).unwrap().bytes, doc.as_slice());
|
||||
assert!(matches!(cat.add_bytes(b"not a font".to_vec(), Source::User, &limits), Err(FontFileError::NotAFont)));
|
||||
let small = Limits { max_file_bytes: 10, ..limits };
|
||||
assert!(matches!(cat.add_bytes(doc.clone(), Source::User, &small), Err(FontFileError::TooLarge { .. })));
|
||||
let few = Limits { max_added_faces: 1, ..limits };
|
||||
assert!(matches!(cat.add_bytes(font(&spec("User Face", "Regular", 400)), Source::User, &few), Err(FontFileError::TooManyFaces { max: 1 })));
|
||||
let file = root.join("elsewhere/UserFace.ttf");
|
||||
write(&file, font(&spec("User Face", "Regular", 400)));
|
||||
let user = cat.add_file(&file, Source::User, &limits).unwrap();
|
||||
assert_eq!(user[0].path.as_deref(), Some(file.as_path()));
|
||||
// A rescan keeps added faces; removing a source's faces brings back the installed face.
|
||||
cat.rescan();
|
||||
assert_eq!(cat.styles("User Face").len(), 1);
|
||||
assert_eq!(cat.remove_added(Source::Document), 1);
|
||||
assert_eq!(cat.resolve("Test Sans", "Bold").unwrap().0.source, Source::System);
|
||||
assert!(matches!(cat.load(&bold), Err(FontFileError::Gone)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scan_reads_only_what_it_may() {
|
||||
let root = temp("bounded");
|
||||
let path = root.join("Big.ttf");
|
||||
let mut data = font(&spec("Big Face", "Regular", 400));
|
||||
data.resize(data.len() + (1 << 20), 0);
|
||||
write(&path, &data);
|
||||
assert_eq!(crate::scan::file_faces(&path, 1 << 16, Source::System).len(), 1);
|
||||
assert!(crate::scan::file_faces(&path, 64, Source::System).is_empty());
|
||||
// A collection claiming more faces than it has gives the faces it has.
|
||||
let mut ttc = collection(&[font(&spec("A Face", "Regular", 400))]);
|
||||
ttc[8..12].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
write(&root.join("Claims.ttc"), &ttc);
|
||||
assert_eq!(crate::scan::file_faces(&root.join("Claims.ttc"), u64::MAX, Source::System).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suitcase_resources_are_read() {
|
||||
let fonts = vec![font(&spec("Suit A", "Regular", 400)), font(&spec("Suit B", "Bold", 700))];
|
||||
let fork = fork_of(&fonts);
|
||||
let found = crate::suitcase::sfnt_resources(&fork);
|
||||
assert_eq!(found, fonts.iter().map(Vec::as_slice).collect::<Vec<_>>());
|
||||
assert!(crate::suitcase::sfnt_resources(&fork[..300]).is_empty());
|
||||
}
|
||||
|
||||
fn fork_of(fonts: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut data = Vec::new();
|
||||
let mut offsets = Vec::new();
|
||||
for body in fonts.iter().map(Vec::as_slice).chain([&b"1.0"[..]]) {
|
||||
offsets.push(data.len() as u32);
|
||||
data.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
data.extend_from_slice(body);
|
||||
}
|
||||
let n = fonts.len();
|
||||
// The type list: `vers` (1 resource) then `sfnt`, each entry pointing at its references.
|
||||
let list = 2 + 2 * 8;
|
||||
let mut types = Vec::new();
|
||||
types.extend_from_slice(&1u16.to_be_bytes());
|
||||
types.extend_from_slice(b"vers");
|
||||
types.extend_from_slice(&0u16.to_be_bytes());
|
||||
types.extend_from_slice(&(list as u16).to_be_bytes());
|
||||
types.extend_from_slice(b"sfnt");
|
||||
types.extend_from_slice(&((n - 1) as u16).to_be_bytes());
|
||||
types.extend_from_slice(&((list + 12) as u16).to_be_bytes());
|
||||
let reference = |id: i16, offset: u32| {
|
||||
let mut r = Vec::new();
|
||||
r.extend_from_slice(&id.to_be_bytes());
|
||||
r.extend_from_slice(&0xFFFFu16.to_be_bytes());
|
||||
r.extend_from_slice(&offset.to_be_bytes());
|
||||
r.extend_from_slice(&[0; 4]);
|
||||
r
|
||||
};
|
||||
types.extend(reference(1, offsets[n]));
|
||||
for (i, o) in offsets[..n].iter().enumerate() {
|
||||
types.extend(reference(5000 + i as i16, *o));
|
||||
}
|
||||
// The map: a copy of the header, a handle, attributes, then the offsets of the lists.
|
||||
let mut map = vec![0; 24];
|
||||
map.extend_from_slice(&28u16.to_be_bytes());
|
||||
map.extend_from_slice(&((28 + types.len()) as u16).to_be_bytes());
|
||||
map.extend(types);
|
||||
let mut fork = Vec::new();
|
||||
for v in [256u32, 256 + data.len() as u32, data.len() as u32, map.len() as u32] {
|
||||
fork.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
fork.resize(256, 0);
|
||||
fork.extend(data);
|
||||
fork.extend(map);
|
||||
fork
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "craft-font-manager"
|
||||
description = "Craft Font Manager as a library: search and filters over the font indexes (Google Fonts, Noto, user-added sources), installed fonts by family, the font store interface, and the egui panel the launcher and the standalone app show."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# `FontsPanel`: the whole interface as one egui panel.
|
||||
egui = ["dep:egui", "dep:craft-ui", "dep:serde", "dep:log"]
|
||||
|
||||
[dependencies]
|
||||
craft-font-catalog = { workspace = true }
|
||||
craft-i18n = { workspace = true }
|
||||
# The pinned built-in indexes compiled in, for browsing offline.
|
||||
craft-font-store = { workspace = true, features = ["bundled-index"] }
|
||||
# egui's default faces lead the interface font stack.
|
||||
egui = { workspace = true, features = ["default_fonts"], optional = true }
|
||||
# A host that wants installed fonts for characters its interface faces lack (language names in
|
||||
# their own scripts in the language filter) enables craft-ui's `system_fonts` itself.
|
||||
craft-ui = { workspace = true, optional = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
log = { version = "0.4", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
egui_kittest = { version = "0.37", features = ["wgpu", "snapshot"] }
|
||||
# Real fonts for the fake font store to install: egui's own Hack and Ubuntu Light.
|
||||
epaint_default_fonts = "0.37"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
# The system's fonts for the Japanese and Arabic screenshots.
|
||||
egui_system_font_provider = "0.37"
|
||||
|
||||
[[test]]
|
||||
name = "flows"
|
||||
required-features = ["egui"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,95 @@
|
||||
# craft-font-manager
|
||||
|
||||
Craft Font Manager as a library: every font the suite's apps can use, and the font collections to
|
||||
add to them: Google Fonts, Noto, and index URLs the user adds. ArtCraft Launcher shows it as its
|
||||
Fonts tab; the standalone app
|
||||
([aiongg/craft-font-manager](https://github.com/aiongg/craft-font-manager)) shows it as its window.
|
||||
|
||||
| Part | Feature | What it does |
|
||||
|---|---|---|
|
||||
| `filter` | always | Search over the index: family names ignoring case and accents, category, language (English name, autonym or id) and script |
|
||||
| `index` | always | The pinned built-in indexes (Google Fonts and Noto) compiled into [`craft-font-store`](../font-store/README.md) (`bundled`), each family's source (`source_label`: its name and whether it is curated), and the categories |
|
||||
| `installed` | always | The installed fonts [`craft-font-catalog`](../font-catalog/README.md) sees, grouped by family and source |
|
||||
| `store` | always | `Installer`, what the interface needs from the font store: the download consent, install, remove, downloads for a preview, the indexes (`indexes`, `fetch_indexes`) and the sources the user added (`user_sources`, `add_user_source`, `remove_user_source`). `SuiteStore` is craft-font-store's store, `FakeStore` a stand-in for tests, `NoStore` one for when the store folder is unknown |
|
||||
| `FontsPanel` | `egui` | The whole interface as one egui panel |
|
||||
|
||||
## The panel
|
||||
|
||||
The layout follows Adobe Fonts' and Google Fonts' browse pages.
|
||||
|
||||
- **Across the top**: Browse and Installed, with their counts, and one search field.
|
||||
- **Filters** at the reading start, each under its label: category, language (searchable by its
|
||||
English name or its own, "Japanese · 日本語"), script, source, and installed or not; each option
|
||||
with its count.
|
||||
- **Family cards**, one per row: the name, style count, category and source on one line with the
|
||||
card's state at its end (Install and Preview, Installed, a progress bar while downloading,
|
||||
Couldn't install with Retry, "Not curated" for a source the user added), and a large sample in
|
||||
the face below. The list is virtual: only the cards on screen are laid out, and their faces load
|
||||
as they come into view. The arrow keys step through the cards and Enter opens one.
|
||||
- **Sample toolbar**: a preset (a sentence in the language filtered by, or in the family's own
|
||||
script when it has no Latin; the alphabet; numerals), the sample text, and the size.
|
||||
- **Details sheet** over the list: every style in its own face with Install or Remove for each,
|
||||
Install all and Remove all, the source, designer, licence, scripts and languages, and what a
|
||||
preview downloads.
|
||||
- **Installed**: system and font store fonts as the same cards, filtered by source. Only font
|
||||
store families can be removed; system fonts are removed with the system's own tools.
|
||||
- **Sources** (`sources_ui`, for a host's settings page): the built-in sources, the index URLs the
|
||||
user added, marked not curated and flagged when the file at their address changed since it was
|
||||
added, adding one, and Update sources.
|
||||
|
||||
`FontsPanel` fills the `Ui` it is given and has no window, menu or interface fonts of its own; the
|
||||
host's `Look` gives it the host's colours and font families. The faces it draws samples in are egui
|
||||
font families of its own, added a few per frame and taken out again past 64, without touching the
|
||||
host's fonts. Right-to-left interfaces mirror through `craft_ui::direction`; sample text keeps its
|
||||
own direction.
|
||||
|
||||
```rust
|
||||
let catalog = Arc::clone(Catalog::global());
|
||||
catalog.scan_in_background();
|
||||
let installer: Arc<dyn Installer> = match SuiteStore::for_user(Arc::clone(&catalog)) {
|
||||
Some(store) => Arc::new(store),
|
||||
None => Arc::new(NoStore),
|
||||
};
|
||||
let index = installer.indexes().map_err(|e| e.to_string());
|
||||
let options = Options { catalog, installer, index, runner: Runner::Thread };
|
||||
let mut panel = FontsPanel::new(options, Settings::default());
|
||||
// each frame
|
||||
panel.ui(ui);
|
||||
```
|
||||
|
||||
| Call | Use |
|
||||
|---|---|
|
||||
| `set_look(look)` | The host's palettes (dark and light) and font families, and whether labels are set in capitals |
|
||||
| `settings()`, `settings_mut()` | What the host saves: the sample text, preset and size, and whether the downloads note was closed (`serde`) |
|
||||
| `downloads_allowed()`, `set_downloads_allowed(on)` | The download consent, for a host's settings page. It is the font store's own setting, shared by every Craft app |
|
||||
| `show_downloads_switch(false)` | For a host with the switch in its own settings |
|
||||
| `sources_ui(ui)` | The font sources, for a host's settings page |
|
||||
| `set_index(index)` | The indexes, after the host fetched them |
|
||||
| `set_language(tag)` | The interface language, the host's own; English when its catalog isn't embedded |
|
||||
| `install_interface_fonts(ctx, craft_faces)` | For a host without interface fonts of its own (the standalone app): craft-ui's font stack for the panel's language, craft-fonts' faces the app embeds with craft-fonts-build, and the interface direction |
|
||||
|
||||
## Network use
|
||||
|
||||
Nothing is downloaded until the user allows it. Downloads are off by default; the first Install or
|
||||
Preview asks first. Files come from each source's repositories at the commit or tag its index
|
||||
records, through jsDelivr (raw GitHub as the fallback), and each is checked against the index's
|
||||
size and sha256 before use. Previews stay in memory and are never installed. A source the user
|
||||
adds is an index at an https URL; its families are never curated.
|
||||
|
||||
## Strings
|
||||
|
||||
Every interface string goes through craft-i18n with the English text as the key. The panel embeds
|
||||
its own Japanese and Arabic catalogs (`src/panel/catalogs/`) until its rows are part of
|
||||
craft-i18n; style and weight names, licence names and product names stay in English.
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
cargo test -p craft-font-manager --all-features
|
||||
CRAFT_FONT_MANAGER_SNAPSHOTS=<dir> cargo test -p craft-font-manager --features egui --test flows screenshots -- --test-threads=1
|
||||
```
|
||||
|
||||
`tests/flows.rs` drives the panel with `egui_kittest` against a small index, a font folder made per
|
||||
test and `FakeStore`. They install egui's Hack and Ubuntu Light faces into temporary folders. With
|
||||
`CRAFT_FONT_MANAGER_GOOGLE_FONTS=<google/fonts checkout>`, the fake store also "downloads" the
|
||||
index's files from the checkout, so screenshots show real previews.
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Searching the index: family names, category, language and script.
|
||||
|
||||
use craft_i18n::Folder;
|
||||
|
||||
use crate::index::{Category, Index, categories};
|
||||
|
||||
/// What the catalogue shows.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Query {
|
||||
/// Part of a family name, ignoring case and accents.
|
||||
pub text: String,
|
||||
pub category: Option<Category>,
|
||||
/// A position in [`Index::languages`].
|
||||
pub language: Option<usize>,
|
||||
/// An ISO 15924 code.
|
||||
pub script: Option<String>,
|
||||
}
|
||||
|
||||
/// Search keys for one index, built once.
|
||||
pub struct Search {
|
||||
folder: Folder,
|
||||
families: Vec<String>,
|
||||
/// Per language: its name, autonym and id, folded.
|
||||
languages: Vec<String>,
|
||||
/// Language positions sorted by name.
|
||||
languages_by_name: Vec<usize>,
|
||||
/// Script positions sorted by name.
|
||||
scripts_by_name: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Search {
|
||||
pub fn new(index: &Index) -> Search {
|
||||
let folder = Folder::new("en");
|
||||
let families = index.families().iter().map(|f| folder.key(&f.family)).collect();
|
||||
let languages =
|
||||
index.languages().iter().map(|l| folder.key(&format!("{}\n{}\n{}", l.name, l.autonym.as_deref().unwrap_or(""), l.id))).collect();
|
||||
let mut languages_by_name: Vec<usize> = (0..index.languages().len()).collect();
|
||||
languages_by_name.sort_by_cached_key(|&i| index.languages().get(i).map(|l| folder.key(&l.name)));
|
||||
let mut scripts_by_name: Vec<usize> = (0..index.scripts().len()).collect();
|
||||
scripts_by_name.sort_by_cached_key(|&i| index.scripts().get(i).map(|s| folder.key(&s.name)));
|
||||
Search { folder, families, languages, languages_by_name, scripts_by_name }
|
||||
}
|
||||
|
||||
/// The families `query` selects, as positions in [`Index::families`], in index order.
|
||||
pub fn families(&self, index: &Index, query: &Query) -> Vec<usize> {
|
||||
let text = self.folder.key(query.text.trim());
|
||||
index
|
||||
.families()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, f)| {
|
||||
(text.is_empty() || self.families.get(*i).is_some_and(|key| key.contains(&text)))
|
||||
&& query.category.is_none_or(|c| categories(f).any(|fc| fc == c))
|
||||
&& query.language.is_none_or(|l| f.languages.contains(&l))
|
||||
&& query.script.as_ref().is_none_or(|s| f.scripts.contains(s))
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Languages whose English name, autonym or id contains `text`, sorted by English name.
|
||||
pub fn languages(&self, text: &str) -> Vec<usize> {
|
||||
let text = self.folder.key(text.trim());
|
||||
self.languages_by_name.iter().copied().filter(|&i| self.languages.get(i).is_some_and(|key| key.contains(&text))).collect()
|
||||
}
|
||||
|
||||
/// Every script, sorted by English name.
|
||||
pub fn scripts(&self) -> &[usize] {
|
||||
&self.scripts_by_name
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filters_combine() {
|
||||
let index = crate::index::bundled().unwrap();
|
||||
let search = Search::new(&index);
|
||||
let names = |q: &Query| search.families(&index, q).into_iter().map(|i| index.families()[i].family.clone()).collect::<Vec<_>>();
|
||||
|
||||
let noto_jp = names(&Query { text: "noto sans jp".into(), ..Query::default() });
|
||||
assert_eq!(noto_jp, ["Noto Sans JP"]);
|
||||
|
||||
let japanese = search.languages("日本語");
|
||||
let ja = *japanese.first().unwrap();
|
||||
assert_eq!(index.languages()[ja].id, "ja_Jpan");
|
||||
let serif_ja = names(&Query { category: Some(Category::Serif), language: Some(ja), ..Query::default() });
|
||||
assert!(serif_ja.contains(&"Noto Serif JP".to_owned()));
|
||||
assert!(!serif_ja.contains(&"Noto Sans JP".to_owned()));
|
||||
|
||||
let arabic = names(&Query { script: Some("Arab".into()), ..Query::default() });
|
||||
assert!(arabic.contains(&"Amiri".to_owned()));
|
||||
assert!(!arabic.contains(&"Roboto".to_owned()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! The font indexes (aiongg/craft-font-index: Google Fonts, Noto, and sources the user adds), as
|
||||
//! craft-font-store reads them, and the categories the catalogue filters by.
|
||||
//!
|
||||
//! [`Index::families`] holds every source's families, sorted by name; a family's source is
|
||||
//! [`Index::source_of`] (its name, and whether it is curated: [`Family::is_curated`] is false for
|
||||
//! a source the user added). [`Index::problems`] lists the sources that couldn't be loaded.
|
||||
|
||||
pub use craft_font_store::{Family, FontFile, FontSource, GOOGLE_FONTS, Index, IndexError, Language, Script, SourceProblem};
|
||||
|
||||
/// The pinned built-in indexes compiled into craft-font-store, read without a request.
|
||||
pub fn bundled() -> Result<Index, IndexError> {
|
||||
Index::bundled()
|
||||
}
|
||||
|
||||
/// The display name of a family's source (`Google Fonts`, `Noto`), and whether it is curated.
|
||||
pub fn source_label<'a>(index: &'a Index, family: &'a Family) -> (&'a str, bool) {
|
||||
index.source_of(family).map_or((family.source.as_str(), family.is_curated()), |s| (s.name.as_str(), s.curated))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Category {
|
||||
SansSerif,
|
||||
Serif,
|
||||
Display,
|
||||
Handwriting,
|
||||
Monospace,
|
||||
}
|
||||
|
||||
impl Category {
|
||||
/// The categories the filter offers, in Google Fonts' order.
|
||||
pub const ALL: [Category; 5] = [Category::SansSerif, Category::Serif, Category::Display, Category::Handwriting, Category::Monospace];
|
||||
|
||||
/// The index's name for it (`SANS_SERIF`).
|
||||
pub fn of(name: &str) -> Option<Category> {
|
||||
match name {
|
||||
"SANS_SERIF" => Some(Category::SansSerif),
|
||||
"SERIF" => Some(Category::Serif),
|
||||
"DISPLAY" => Some(Category::Display),
|
||||
"HANDWRITING" => Some(Category::Handwriting),
|
||||
"MONOSPACE" => Some(Category::Monospace),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A family's categories that this version knows.
|
||||
pub fn categories(family: &Family) -> impl Iterator<Item = Category> + '_ {
|
||||
family.categories.iter().filter_map(|c| Category::of(c))
|
||||
}
|
||||
|
||||
pub fn script_name<'a>(index: &'a Index, id: &str) -> Option<&'a str> {
|
||||
index.scripts().iter().find(|s| s.id == id).map(|s| s.name.as_str())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bundled_index_reads() {
|
||||
let index = bundled().unwrap();
|
||||
assert!(index.families().len() > 1000);
|
||||
let roboto = index.family("roboto").unwrap();
|
||||
assert_eq!(categories(roboto).collect::<Vec<_>>(), [Category::SansSerif]);
|
||||
assert_eq!(script_name(&index, "Arab"), Some("Arabic"));
|
||||
assert_eq!(source_label(&index, roboto), ("Google Fonts", true));
|
||||
let cjk = index.family("Noto Sans CJK JP").unwrap();
|
||||
assert_eq!(source_label(&index, cjk), ("Noto", true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! The installed fonts the catalog sees, grouped by family.
|
||||
|
||||
use craft_font_catalog::{FaceInfo, Source};
|
||||
|
||||
/// One family from one source: a family installed on the system and in the font store is two
|
||||
/// entries, since only the store's can be removed.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InstalledFamily {
|
||||
pub name: String,
|
||||
pub source: Source,
|
||||
/// Upright before italic, then by weight and width.
|
||||
pub faces: Vec<FaceInfo>,
|
||||
}
|
||||
|
||||
impl InstalledFamily {
|
||||
pub fn removable(&self) -> bool {
|
||||
self.source == Source::Store
|
||||
}
|
||||
}
|
||||
|
||||
/// System and font store faces by family, sorted by name ignoring case. Fonts other sources
|
||||
/// add (documents, the app's own) are left out.
|
||||
pub fn group(faces: &[FaceInfo]) -> Vec<InstalledFamily> {
|
||||
let mut families: Vec<InstalledFamily> = Vec::new();
|
||||
let mut sorted: Vec<&FaceInfo> = faces.iter().filter(|f| matches!(f.source, Source::System | Source::Store)).collect();
|
||||
sorted.sort_by_cached_key(|f| (f.family.to_lowercase(), f.source == Source::Store));
|
||||
for face in sorted {
|
||||
match families.last_mut() {
|
||||
Some(last) if last.source == face.source && last.name.eq_ignore_ascii_case(&face.family) => last.faces.push(face.clone()),
|
||||
_ => families.push(InstalledFamily { name: face.family.clone(), source: face.source, faces: vec![face.clone()] }),
|
||||
}
|
||||
}
|
||||
for family in &mut families {
|
||||
family.faces.sort_by(|a, b| (a.italic, a.weight, a.width).partial_cmp(&(b.italic, b.weight, b.width)).unwrap_or(std::cmp::Ordering::Equal));
|
||||
family.faces.dedup_by(|a, b| a.path == b.path && a.index == b.index && a.variations == b.variations);
|
||||
}
|
||||
families
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
//! Craft Font Manager as a library: the bundled font indexes and searching them, the installed
|
||||
//! fonts by family, and the font store interface installs and removals go through.
|
||||
//!
|
||||
//! With the `egui` feature, [`FontsPanel`] is the whole interface as one panel that fills the `Ui`
|
||||
//! it is given, with no window, menu or eframe of its own, so ArtCraft Launcher can show it as its
|
||||
//! Fonts tab and the standalone app as its window. The host passes in the catalog, the store and
|
||||
//! the index ([`Options`]), saves [`Settings`], and reads or sets the download consent through the
|
||||
//! panel.
|
||||
|
||||
pub mod filter;
|
||||
pub mod index;
|
||||
pub mod installed;
|
||||
pub mod store;
|
||||
|
||||
#[cfg(feature = "egui")]
|
||||
mod panel;
|
||||
|
||||
pub use craft_font_catalog;
|
||||
pub use craft_font_catalog::{Catalog, FaceInfo, FontEnv, Source};
|
||||
#[cfg(feature = "egui")]
|
||||
pub use panel::{FontsPanel, Look, Options, Palette, Preset, Runner, Settings, Tab, install_interface_fonts};
|
||||
@@ -0,0 +1,731 @@
|
||||
//! The panel's state, what the host calls, and the frame: the tabs and search across the top,
|
||||
//! the filters at the reading start, the family cards, and the details sheet over them.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::craft_font_catalog::{FontEnv, Limits, Os};
|
||||
use crate::filter::{Query, Search};
|
||||
use crate::index::{Category, Family, Index, categories, source_label};
|
||||
use crate::installed::{InstalledFamily, group};
|
||||
use crate::store::{Installer, RemoveRequest, StoreError};
|
||||
use crate::{Catalog, FaceInfo, Source};
|
||||
use craft_i18n::Folder;
|
||||
use craft_ui::direction;
|
||||
use craft_ui::fonts::{CraftFace, I18nScripts, UiFonts};
|
||||
use egui::{CentralPanel, Context, Frame, Margin, Panel, Ui};
|
||||
|
||||
use crate::panel::faces::FaceFonts;
|
||||
use crate::panel::i18n::{self, I18N, LANGUAGES, fmt, tl};
|
||||
use crate::panel::jobs::{Finished, Jobs, Kind, Request, Runner};
|
||||
use crate::panel::look::{Look, Palette};
|
||||
use crate::panel::samples;
|
||||
use crate::panel::widgets::{self, Skin};
|
||||
|
||||
/// Installs the standalone app's interface fonts in `ctx` and sets the interface direction: craft-ui's
|
||||
/// font stack for the panel's interface language, with `craft_faces` (craft-fonts' faces the app
|
||||
/// embeds with craft-fonts-build, or none) and the system's fonts for every other character. A host
|
||||
/// with its own fonts doesn't call it.
|
||||
pub fn install_interface_fonts(ctx: &Context, craft_faces: &[CraftFace]) {
|
||||
let language = I18N.code(i18n::current()).to_owned();
|
||||
UiFonts::new(&I18nScripts).languages(LANGUAGES).ui_language(&language).craft_faces(craft_faces).install(ctx);
|
||||
direction::apply(ctx, direction::resolve(&language, direction::Setting::Language));
|
||||
}
|
||||
|
||||
/// Preview downloads: at most this much per file, and this many faces in a session.
|
||||
const PREVIEW_LIMITS: Limits = Limits { max_file_bytes: 64 << 20, max_faces_per_file: 512, max_added_faces: 20_000 };
|
||||
|
||||
/// How often the download consent is read again from the store's settings file, which another
|
||||
/// Craft app can change.
|
||||
const CONSENT_RECHECK_SECS: f64 = 3.0;
|
||||
|
||||
/// What a sample shows when the user hasn't typed one.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Preset {
|
||||
/// A sentence in the language filtered by, or in the family's own script.
|
||||
#[default]
|
||||
Sentence,
|
||||
Alphabet,
|
||||
Numerals,
|
||||
}
|
||||
|
||||
/// What the app keeps between runs.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
/// The sample text; empty for the preset.
|
||||
pub sample: String,
|
||||
pub preset: Preset,
|
||||
pub size: f32,
|
||||
/// The user closed the note that downloads are off.
|
||||
pub hide_download_note: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings { sample: String::new(), preset: Preset::Sentence, size: 36.0, hide_download_note: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Tab {
|
||||
Installed,
|
||||
Catalogue,
|
||||
}
|
||||
|
||||
/// Which catalogue families show by install state.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum Show {
|
||||
#[default]
|
||||
All,
|
||||
Installed,
|
||||
NotInstalled,
|
||||
}
|
||||
|
||||
/// Which installed families show by where they come from.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) enum Origin {
|
||||
#[default]
|
||||
All,
|
||||
System,
|
||||
Store,
|
||||
}
|
||||
|
||||
/// What the Installed tab's list was worked out for: the search, the source and the catalog's
|
||||
/// generation.
|
||||
type InstalledFilter = (String, Origin, Option<u64>);
|
||||
|
||||
/// What the app is built from; tests pass their own catalog, store and index.
|
||||
pub struct Options {
|
||||
pub catalog: Arc<Catalog>,
|
||||
pub installer: Arc<dyn Installer>,
|
||||
/// The Google Fonts index, or why it couldn't be read.
|
||||
pub index: Result<Index, String>,
|
||||
pub runner: Runner,
|
||||
}
|
||||
|
||||
/// A download, waiting for the user's consent or to be tried again.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Pending {
|
||||
Install(usize),
|
||||
/// One file of a family, by its path in the index.
|
||||
InstallFile(usize, String),
|
||||
Preview(usize),
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
pub(crate) fn family(&self) -> usize {
|
||||
match self {
|
||||
Pending::Install(i) | Pending::InstallFile(i, _) | Pending::Preview(i) => *i,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The family the details sheet shows.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Detail {
|
||||
/// A family of the index, by position.
|
||||
Family(usize),
|
||||
/// An installed family, by name and source.
|
||||
Installed(String, Source),
|
||||
}
|
||||
|
||||
/// Why the last job for a family failed, and what Retry runs.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Failure {
|
||||
pub kind: Kind,
|
||||
pub error: String,
|
||||
pub retry: Option<Pending>,
|
||||
}
|
||||
|
||||
pub(crate) struct Catalogue {
|
||||
pub index: Arc<Index>,
|
||||
pub search: Search,
|
||||
}
|
||||
|
||||
pub struct FontsPanel {
|
||||
pub(crate) settings: Settings,
|
||||
pub(crate) tab: Tab,
|
||||
pub(crate) look: Look,
|
||||
pub(crate) skin: Skin,
|
||||
/// The panel shows its own download switch; off when the host has one in its settings.
|
||||
pub(crate) downloads_switch: bool,
|
||||
pub(crate) catalog: Arc<Catalog>,
|
||||
pub(crate) installer: Arc<dyn Installer>,
|
||||
pub(crate) catalogue: Result<Catalogue, String>,
|
||||
pub(crate) query: Query,
|
||||
pub(crate) show: Show,
|
||||
pub(crate) results: Vec<usize>,
|
||||
results_for: Option<(Query, Show, Option<String>, Option<u64>)>,
|
||||
/// Only this source's families (a source ID).
|
||||
pub(crate) source: Option<String>,
|
||||
/// Families per source, in [`Index::sources`] order, for the other filters as set.
|
||||
pub(crate) source_counts: Vec<usize>,
|
||||
/// The indexes being downloaded again (the sources' Update).
|
||||
sources_job: Option<std::sync::mpsc::Receiver<Result<Index, StoreError>>>,
|
||||
/// What the Sources settings show: the URL being typed, and the last problem.
|
||||
pub(crate) source_url: String,
|
||||
pub(crate) source_message: Option<(bool, String)>,
|
||||
/// Families per category (all first) and per install state, for the other filters as set.
|
||||
pub(crate) category_counts: [usize; 6],
|
||||
pub(crate) show_counts: [usize; 3],
|
||||
pub(crate) language_text: String,
|
||||
pub(crate) installed: Vec<InstalledFamily>,
|
||||
/// Installed faces by lower-case family name, every source together.
|
||||
pub(crate) installed_by_name: HashMap<String, Vec<FaceInfo>>,
|
||||
installed_generation: Option<u64>,
|
||||
pub(crate) installed_text: String,
|
||||
pub(crate) origin: Origin,
|
||||
/// [`FontsPanel::installed_shown`] and what it was worked out for.
|
||||
installed_shown: (Vec<usize>, Option<InstalledFilter>),
|
||||
pub(crate) detail: Option<Detail>,
|
||||
/// Downloaded preview files, never installed.
|
||||
pub(crate) previews: Arc<Catalog>,
|
||||
pub(crate) previewed: HashMap<String, Vec<FaceInfo>>,
|
||||
pub(crate) faces: FaceFonts,
|
||||
pub(crate) jobs: Jobs,
|
||||
pub(crate) asking: Option<Pending>,
|
||||
/// By lower-case family name.
|
||||
pub(crate) failed: HashMap<String, Failure>,
|
||||
/// A problem that belongs to no family (the download setting couldn't be saved).
|
||||
pub(crate) notice: Option<String>,
|
||||
focused: bool,
|
||||
pub(crate) folder: Folder,
|
||||
/// The download consent as last read, and when.
|
||||
consent: Option<(bool, f64)>,
|
||||
/// The card that has (or is to get) the keyboard focus, by row.
|
||||
pub(crate) focus_row: Option<usize>,
|
||||
/// Bring the focused row into view on the next frame.
|
||||
pub(crate) scroll_to_focus: bool,
|
||||
/// Give the focus row the keyboard focus on the next frame.
|
||||
pub(crate) move_focus: bool,
|
||||
/// Focus the search field on the next frame.
|
||||
pub(crate) focus_search: bool,
|
||||
}
|
||||
|
||||
/// A family's key in the panel's state: families of the same name can come from two sources.
|
||||
/// A removal goes by name alone (`source` empty).
|
||||
pub(crate) fn family_key(source: &str, family: &str) -> String {
|
||||
format!("{source}\n{}", family.to_lowercase())
|
||||
}
|
||||
|
||||
/// The host of an https URL.
|
||||
fn url_host(url: &str) -> Option<&str> {
|
||||
url.strip_prefix("https://")?.split(['/', '?', '#']).next().filter(|h| !h.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn category_name(c: Category) -> &'static str {
|
||||
match c {
|
||||
Category::SansSerif => tl!("Sans serif"),
|
||||
Category::Serif => tl!("Serif"),
|
||||
Category::Display => tl!("Display"),
|
||||
Category::Handwriting => tl!("Handwriting"),
|
||||
Category::Monospace => tl!("Monospace"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_name(s: Source) -> &'static str {
|
||||
match s {
|
||||
Source::Store => tl!("Font store"),
|
||||
_ => tl!("System"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The face closest to an upright regular.
|
||||
pub(crate) fn regular(faces: &[FaceInfo]) -> Option<&FaceInfo> {
|
||||
faces.iter().min_by_key(|f| (f.italic, ((f.weight - 400.0).abs() * 10.0) as i64, ((f.width - 100.0).abs() * 10.0) as i64))
|
||||
}
|
||||
|
||||
pub(crate) fn style_name(face: &FaceInfo) -> &str {
|
||||
if face.style.is_empty() { tl!("Regular") } else { &face.style }
|
||||
}
|
||||
|
||||
/// How many styles a family of the index has: each static file one, a variable file one per
|
||||
/// hundred of weight its `wght` axis covers.
|
||||
pub(crate) fn style_count(family: &Family) -> usize {
|
||||
family
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| match f.axes.iter().find(|a| a.tag == "wght") {
|
||||
Some(a) => ((a.min / 100.0).ceil() as usize..=(a.max / 100.0).floor() as usize).count().max(1),
|
||||
None => 1,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub(crate) fn category_label(family: &Family) -> String {
|
||||
categories(family).map(category_name).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
impl FontsPanel {
|
||||
pub fn new(options: Options, settings: Settings) -> FontsPanel {
|
||||
let catalogue = options.index.map(|index| Catalogue { search: Search::new(&index), index: Arc::new(index) });
|
||||
let previews = Arc::new(Catalog::new(FontEnv { os: Os::None, ..FontEnv::default() }).with_font_service(false));
|
||||
let look = Look::default();
|
||||
FontsPanel {
|
||||
settings,
|
||||
tab: Tab::Catalogue,
|
||||
skin: Skin { p: Palette::from_visuals(&egui::Visuals::dark()), look: look.clone() },
|
||||
look,
|
||||
downloads_switch: true,
|
||||
catalog: options.catalog,
|
||||
installer: options.installer,
|
||||
catalogue,
|
||||
query: Query::default(),
|
||||
show: Show::All,
|
||||
results: Vec::new(),
|
||||
results_for: None,
|
||||
source: None,
|
||||
source_counts: Vec::new(),
|
||||
sources_job: None,
|
||||
source_url: String::new(),
|
||||
source_message: None,
|
||||
category_counts: [0; 6],
|
||||
show_counts: [0; 3],
|
||||
language_text: String::new(),
|
||||
installed: Vec::new(),
|
||||
installed_by_name: HashMap::new(),
|
||||
installed_generation: None,
|
||||
installed_text: String::new(),
|
||||
origin: Origin::All,
|
||||
installed_shown: (Vec::new(), None),
|
||||
detail: None,
|
||||
previews,
|
||||
previewed: HashMap::new(),
|
||||
faces: FaceFonts::default(),
|
||||
jobs: Jobs::new(options.runner),
|
||||
asking: None,
|
||||
failed: HashMap::new(),
|
||||
notice: None,
|
||||
focused: true,
|
||||
folder: Folder::new("en"),
|
||||
consent: None,
|
||||
focus_row: None,
|
||||
scroll_to_focus: false,
|
||||
move_focus: false,
|
||||
focus_search: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows `index` (after the host fetched the sources, say). The details sheet closes, as
|
||||
/// family positions change.
|
||||
pub fn set_index(&mut self, index: Result<Index, String>) {
|
||||
self.catalogue = index.map(|index| Catalogue { search: Search::new(&index), index: Arc::new(index) });
|
||||
self.results_for = None;
|
||||
self.detail = None;
|
||||
self.asking = None;
|
||||
self.focus_row = None;
|
||||
if let Ok(c) = &self.catalogue
|
||||
&& self.source.as_ref().is_some_and(|s| c.index.source(s).is_none())
|
||||
{
|
||||
self.source = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the indexes again without a request: after a source was added or removed.
|
||||
pub(crate) fn reload_index(&mut self) {
|
||||
let index = self.installer.indexes().map_err(|e| e.0);
|
||||
self.set_index(index);
|
||||
}
|
||||
|
||||
/// Downloads every source's index again on a worker thread, when downloads are allowed.
|
||||
pub(crate) fn fetch_sources(&mut self, ctx: &Context) {
|
||||
if self.sources_job.is_some() || !self.downloads_allowed() {
|
||||
return;
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let installer = Arc::clone(&self.installer);
|
||||
let ctx = ctx.clone();
|
||||
let spawned = std::thread::Builder::new().name("font-sources".into()).spawn(move || {
|
||||
let _ = tx.send(installer.fetch_indexes());
|
||||
ctx.request_repaint();
|
||||
});
|
||||
match spawned {
|
||||
Ok(_) => self.sources_job = Some(rx),
|
||||
Err(e) => self.source_message = Some((true, e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the sources' indexes are being downloaded again.
|
||||
pub fn fetching_sources(&self) -> bool {
|
||||
self.sources_job.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn poll_sources(&mut self) {
|
||||
let Some(rx) = &self.sources_job else { return };
|
||||
match rx.try_recv() {
|
||||
Ok(Ok(index)) => {
|
||||
self.sources_job = None;
|
||||
self.source_message = None;
|
||||
self.set_index(Ok(index));
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
self.sources_job = None;
|
||||
self.source_message = Some((true, e.0));
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => self.sources_job = None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws with the host's colours and font families.
|
||||
pub fn set_look(&mut self, look: Look) {
|
||||
self.look = look;
|
||||
}
|
||||
|
||||
/// Whether the panel shows its own "Allow downloads" switch. A host with the switch in its
|
||||
/// own settings turns it off; the panel still offers to allow downloads where one is needed.
|
||||
pub fn show_downloads_switch(&mut self, show: bool) {
|
||||
self.downloads_switch = show;
|
||||
}
|
||||
|
||||
/// The panel's own settings, for the host to save.
|
||||
pub fn settings(&self) -> &Settings {
|
||||
&self.settings
|
||||
}
|
||||
|
||||
pub fn settings_mut(&mut self) -> &mut Settings {
|
||||
&mut self.settings
|
||||
}
|
||||
|
||||
pub fn tab(&self) -> Tab {
|
||||
self.tab
|
||||
}
|
||||
|
||||
pub fn set_tab(&mut self, tab: Tab) {
|
||||
self.tab = tab;
|
||||
self.focus_row = None;
|
||||
}
|
||||
|
||||
/// The installed families, as the Installed tab lists them.
|
||||
pub fn installed(&self) -> &[InstalledFamily] {
|
||||
&self.installed
|
||||
}
|
||||
|
||||
/// The catalogue's families for the current search and filters.
|
||||
pub fn results(&self) -> Vec<String> {
|
||||
let Ok(c) = &self.catalogue else { return Vec::new() };
|
||||
self.results.iter().filter_map(|&i| c.index.families().get(i)).map(|f| f.family.clone()).collect()
|
||||
}
|
||||
|
||||
/// The family the details sheet shows.
|
||||
pub fn details_family(&self) -> Option<String> {
|
||||
match &self.detail {
|
||||
Some(Detail::Family(i)) => self.catalogue.as_ref().ok()?.index.families().get(*i).map(|f| f.family.clone()),
|
||||
Some(Detail::Installed(name, _)) => Some(name.clone()),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the interface in `language` (a tag such as `de` or `pt-BR`), the host's interface
|
||||
/// language, when its catalog is embedded; English otherwise.
|
||||
pub fn set_language(&mut self, language: &str) {
|
||||
i18n::set_current(I18N.match_tag(language).unwrap_or(craft_i18n::Lang::EN));
|
||||
}
|
||||
|
||||
/// Has the user allowed downloads from Google Fonts? A suite-wide setting of the font store,
|
||||
/// off until they turn it on or confirm an install or preview.
|
||||
pub fn downloads_allowed(&self) -> bool {
|
||||
self.installer.downloads_allowed()
|
||||
}
|
||||
|
||||
/// Turns downloads on or off, as the user's own action (a host's settings page, say). A
|
||||
/// failure also shows in the panel.
|
||||
pub fn set_downloads_allowed(&mut self, allowed: bool) -> Result<(), StoreError> {
|
||||
let result = self.installer.allow_downloads(allowed);
|
||||
self.consent = None;
|
||||
self.notice = result.as_ref().err().map(|e| fmt("The download setting couldn't be saved: {error}", &[("error", &e.0)]));
|
||||
result
|
||||
}
|
||||
|
||||
/// The consent, read from the store at most every few seconds.
|
||||
pub(crate) fn allowed(&mut self, now: f64) -> bool {
|
||||
match self.consent {
|
||||
Some((allowed, at)) if (0.0..CONSENT_RECHECK_SECS).contains(&(now - at)) => allowed,
|
||||
_ => {
|
||||
let allowed = self.installer.downloads_allowed();
|
||||
self.consent = Some((allowed, now));
|
||||
allowed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the panel into `ui`, which it fills.
|
||||
pub fn ui(&mut self, ui: &mut Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
self.skin = Skin { p: self.look.palette(&ctx), look: self.look.clone() };
|
||||
self.faces.begin_frame(&ctx);
|
||||
self.poll_jobs();
|
||||
let focused = ctx.input(|i| i.focused);
|
||||
if focused && !self.focused {
|
||||
if self.catalog.is_ready() {
|
||||
self.catalog.rescan_if_changed();
|
||||
}
|
||||
self.consent = None;
|
||||
}
|
||||
self.focused = focused;
|
||||
self.refresh_installed();
|
||||
self.refresh_results();
|
||||
self.keys(&ctx);
|
||||
|
||||
let area = ui.max_rect();
|
||||
let p = self.skin.p;
|
||||
let rtl = direction::is_rtl(ui);
|
||||
let sides = |start: i8, end: i8| if rtl { (end, start) } else { (start, end) };
|
||||
let top = Frame::new().fill(p.bg).inner_margin(Margin { left: 20, right: 20, top: 16, bottom: 14 });
|
||||
Panel::top("craft-fonts.top").frame(top).resizable(false).show(ui, |ui| self.top_bar(ui));
|
||||
let width = if area.width() < 860.0 { 212.0 } else { 248.0 };
|
||||
let (left, right) = sides(20, 18);
|
||||
let filters = Frame::new().fill(p.bg).inner_margin(Margin { left, right, top: 18, bottom: 12 });
|
||||
direction::start_panel(ui, "craft-fonts.filters").frame(filters).resizable(false).exact_size(width).show(ui, |ui| self.sidebar(ui));
|
||||
let main = Frame::new().fill(p.bg).inner_margin(Margin { left: 20, right: 20, top: 16, bottom: 0 });
|
||||
CentralPanel::default().frame(main).show(ui, |ui| self.main(ui));
|
||||
self.details(&ctx, area);
|
||||
self.consent(&ctx);
|
||||
}
|
||||
|
||||
fn keys(&mut self, ctx: &Context) {
|
||||
let (find, escape) = ctx.input_mut(|i| (i.consume_key(egui::Modifiers::COMMAND, egui::Key::F), i.key_pressed(egui::Key::Escape)));
|
||||
if find {
|
||||
self.focus_search = true;
|
||||
}
|
||||
if escape && self.asking.is_none() && self.detail.is_some() {
|
||||
self.detail = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_jobs(&mut self) {
|
||||
for (job, result) in self.jobs.poll() {
|
||||
let key = family_key(&job.source, &job.family);
|
||||
match result {
|
||||
Ok(Finished::Installed | Finished::Removed) => {
|
||||
self.failed.remove(&key);
|
||||
}
|
||||
Ok(Finished::Previewed(files)) => {
|
||||
self.failed.remove(&key);
|
||||
let mut faces = Vec::new();
|
||||
for file in files {
|
||||
match self.previews.add_bytes(file.bytes, Source::User, &PREVIEW_LIMITS) {
|
||||
Ok(added) => faces.extend(added),
|
||||
Err(e) => log::warn!("preview of {}: {e}", file.path),
|
||||
}
|
||||
}
|
||||
self.previewed.insert(key, faces);
|
||||
}
|
||||
Err(e) => {
|
||||
let position =
|
||||
self.catalogue.as_ref().ok().and_then(|c| {
|
||||
c.index.families().iter().position(|f| f.source == job.source && f.family.eq_ignore_ascii_case(&job.family))
|
||||
});
|
||||
let retry = position.and_then(|i| match job.kind {
|
||||
Kind::Install => Some(job.file.clone().map_or(Pending::Install(i), |path| Pending::InstallFile(i, path))),
|
||||
Kind::Preview => Some(Pending::Preview(i)),
|
||||
Kind::Remove => None,
|
||||
});
|
||||
log::warn!("{} ({:?}): {e}", job.family, job.kind);
|
||||
self.failed.insert(key, Failure { kind: job.kind, error: e.0, retry });
|
||||
}
|
||||
}
|
||||
}
|
||||
self.poll_sources();
|
||||
}
|
||||
|
||||
fn refresh_installed(&mut self) {
|
||||
if !self.catalog.is_ready() {
|
||||
return;
|
||||
}
|
||||
let generation = self.catalog.generation();
|
||||
if self.installed_generation == Some(generation) {
|
||||
return;
|
||||
}
|
||||
self.installed_generation = Some(generation);
|
||||
let faces = self.catalog.faces();
|
||||
self.installed = group(&faces);
|
||||
self.installed_by_name.clear();
|
||||
for family in &self.installed {
|
||||
self.installed_by_name.entry(family.name.to_lowercase()).or_default().extend(family.faces.iter().cloned());
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_results(&mut self) {
|
||||
let Ok(c) = &self.catalogue else { return };
|
||||
let key = (self.query.clone(), self.show, self.source.clone(), self.installed_generation);
|
||||
if self.results_for.as_ref() == Some(&key) {
|
||||
return;
|
||||
}
|
||||
let from = |i: usize, source: &Option<String>| source.as_ref().is_none_or(|s| c.index.families().get(i).is_some_and(|f| &f.source == s));
|
||||
let installed = |i: usize| c.index.families().get(i).is_some_and(|f| self.installed_by_name.contains_key(&f.family.to_lowercase()));
|
||||
let shows = |show: Show, i: usize| match show {
|
||||
Show::All => true,
|
||||
Show::Installed => installed(i),
|
||||
Show::NotInstalled => !installed(i),
|
||||
};
|
||||
let any_category = Query { category: None, ..self.query.clone() };
|
||||
let mut by_category = [0usize; 6];
|
||||
let mut by_state = [0usize; 3];
|
||||
let mut by_source: Vec<usize> = vec![0; c.index.sources().len()];
|
||||
for i in c.search.families(&c.index, &any_category) {
|
||||
let Some(f) = c.index.families().get(i) else { continue };
|
||||
let in_category = self.query.category.is_none_or(|q| categories(f).any(|fc| fc == q));
|
||||
if in_category
|
||||
&& shows(self.show, i)
|
||||
&& let Some(n) = c.index.sources().iter().position(|s| s.id == f.source).and_then(|n| by_source.get_mut(n))
|
||||
{
|
||||
*n += 1;
|
||||
}
|
||||
if !from(i, &self.source) {
|
||||
continue;
|
||||
}
|
||||
if shows(self.show, i) {
|
||||
by_category[0] += 1;
|
||||
for fc in categories(f) {
|
||||
if let Some(pos) = Category::ALL.iter().position(|x| *x == fc) {
|
||||
by_category[pos + 1] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if in_category {
|
||||
by_state[0] += 1;
|
||||
by_state[if installed(i) { 1 } else { 2 }] += 1;
|
||||
}
|
||||
}
|
||||
self.category_counts = by_category;
|
||||
self.show_counts = by_state;
|
||||
self.source_counts = by_source;
|
||||
self.results = c.search.families(&c.index, &self.query).into_iter().filter(|&i| shows(self.show, i) && from(i, &self.source)).collect();
|
||||
self.results_for = Some(key);
|
||||
}
|
||||
|
||||
/// The installed families the Installed tab shows, by position in [`FontsPanel::installed`].
|
||||
pub(crate) fn installed_shown(&mut self) -> Vec<usize> {
|
||||
let key = (self.installed_text.clone(), self.origin, self.installed_generation);
|
||||
if self.installed_shown.1.as_ref() != Some(&key) {
|
||||
let text = self.folder.key(self.installed_text.trim());
|
||||
let shown = (0..self.installed.len())
|
||||
.filter(|&i| {
|
||||
let Some(f) = self.installed.get(i) else { return false };
|
||||
let origin = match self.origin {
|
||||
Origin::All => true,
|
||||
Origin::System => f.source != Source::Store,
|
||||
Origin::Store => f.source == Source::Store,
|
||||
};
|
||||
origin && (text.is_empty() || self.folder.key(&f.name).contains(&text))
|
||||
})
|
||||
.collect();
|
||||
self.installed_shown = (shown, Some(key));
|
||||
}
|
||||
self.installed_shown.0.clone()
|
||||
}
|
||||
|
||||
/// The sample for a family with `scripts`: what the user typed, else the preset.
|
||||
pub(crate) fn sample_for<'a>(&self, scripts: impl IntoIterator<Item = &'a str>) -> String {
|
||||
if !self.settings.sample.trim().is_empty() {
|
||||
return self.settings.sample.clone();
|
||||
}
|
||||
match self.settings.preset {
|
||||
Preset::Alphabet => samples::ALPHABET.to_owned(),
|
||||
Preset::Numerals => samples::NUMERALS.to_owned(),
|
||||
Preset::Sentence => {
|
||||
let language = self.catalogue.as_ref().ok().zip(self.query.language).and_then(|(c, l)| c.index.languages().get(l));
|
||||
language.and_then(|l| samples::for_language(&l.id)).unwrap_or_else(|| samples::for_scripts(scripts)).to_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `pending` if downloads are allowed, or asks first.
|
||||
pub(crate) fn ask(&mut self, ctx: &Context, pending: Pending) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
if self.allowed(now) {
|
||||
self.download(ctx, pending);
|
||||
} else {
|
||||
self.asking = Some(pending);
|
||||
}
|
||||
}
|
||||
|
||||
fn download(&mut self, ctx: &Context, pending: Pending) {
|
||||
if !self.downloads_allowed() {
|
||||
return;
|
||||
}
|
||||
let Ok(c) = &self.catalogue else { return };
|
||||
let index = Arc::clone(&c.index);
|
||||
if let Some(f) = index.families().get(pending.family()) {
|
||||
self.failed.remove(&family_key(&f.source, &f.family));
|
||||
}
|
||||
let request = match pending {
|
||||
Pending::Install(i) => Request::Install(index, i),
|
||||
Pending::InstallFile(i, path) => Request::InstallFile(index, i, path),
|
||||
Pending::Preview(i) => Request::Preview(index, i),
|
||||
};
|
||||
self.jobs.start(ctx, Arc::clone(&self.installer), Arc::clone(&self.catalog), request);
|
||||
}
|
||||
|
||||
/// Removes store files: a whole family, or the files of some of its styles.
|
||||
pub(crate) fn remove(&mut self, ctx: &Context, family: &str, faces: &[FaceInfo], whole: bool) {
|
||||
let mut files: Vec<_> = faces.iter().filter(|f| f.source == Source::Store).filter_map(|f| f.path.clone()).collect();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.failed.remove(&family_key("", family));
|
||||
let request = RemoveRequest { family: family.to_owned(), files, whole };
|
||||
self.jobs.start(ctx, Arc::clone(&self.installer), Arc::clone(&self.catalog), Request::Remove(request));
|
||||
}
|
||||
|
||||
fn consent(&mut self, ctx: &Context) {
|
||||
let Some(pending) = self.asking.clone() else { return };
|
||||
let Ok(c) = &self.catalogue else { return };
|
||||
let Some(family) = c.index.families().get(pending.family()) else { return };
|
||||
let file = match &pending {
|
||||
Pending::InstallFile(_, path) => family.files.iter().find(|f| &f.path == path),
|
||||
_ => family.files.first(),
|
||||
};
|
||||
let size = match &pending {
|
||||
Pending::InstallFile(..) => file.map_or(0, |f| f.size),
|
||||
_ => family.size(),
|
||||
};
|
||||
let (source, curated) = source_label(&c.index, family);
|
||||
// A curated source's files come through its repository's CDN; a source the user added
|
||||
// can point anywhere, so the dialog names the server.
|
||||
let host = file.filter(|_| !curated).and_then(|f| c.index.file_urls(family, f).first().and_then(|u| url_host(u)).map(str::to_owned));
|
||||
let args = [("family", family.family.as_str()), ("size", &i18n::size(size)), ("source", source), ("host", host.as_deref().unwrap_or(""))];
|
||||
let text = match &host {
|
||||
None => fmt("Craft Font Manager downloads {family} ({size}) from {source}, and checks every file before using it.", &args),
|
||||
Some(_) => fmt(
|
||||
"Craft Font Manager downloads {family} ({size}) from {host}, the server of {source}, a source you added, and checks every file before using it.",
|
||||
&args,
|
||||
),
|
||||
};
|
||||
let title = fmt("Download from {source}?", &[("source", source)]);
|
||||
let s = self.skin.clone();
|
||||
let mut allow = false;
|
||||
let mut cancel = false;
|
||||
let frame =
|
||||
Frame::new().fill(s.p.bg).stroke(egui::Stroke::new(1.0, s.p.line_strong)).corner_radius(s.look.corner).inner_margin(Margin::same(24));
|
||||
let response = egui::Modal::new(egui::Id::unique("craft-font-manager.consent")).frame(frame).show(ctx, |ui| {
|
||||
ui.set_max_width(440.0);
|
||||
ui.spacing_mut().item_spacing.y = 10.0;
|
||||
let title = egui::RichText::new(&title).size(22.0).family(s.look.display.clone()).color(s.p.ink_strong);
|
||||
ui.add(egui::Label::new(title).wrap());
|
||||
ui.add(egui::Label::new(egui::RichText::new(text).color(s.p.ink)).wrap());
|
||||
ui.add(egui::Label::new(egui::RichText::new(tl!("Downloads stay allowed until you turn them off.")).color(s.p.muted)).wrap());
|
||||
ui.add_space(8.0);
|
||||
direction::horizontal(ui, |ui| {
|
||||
allow = widgets::button(ui, &s, tl!("Download"), widgets::Kind::Primary, s.h(), 0.0).clicked();
|
||||
cancel = widgets::button(ui, &s, tl!("Cancel"), widgets::Kind::Secondary, s.h(), 0.0).clicked();
|
||||
});
|
||||
});
|
||||
if allow {
|
||||
self.asking = None;
|
||||
if self.set_downloads_allowed(true).is_ok() {
|
||||
self.download(ctx, pending);
|
||||
}
|
||||
} else if cancel || response.should_close() {
|
||||
self.asking = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn index(&self) -> Option<Arc<Index>> {
|
||||
self.catalogue.as_ref().ok().map(|c| Arc::clone(&c.index))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
//! The tabs and search, the filters, the sample toolbar and the list of family cards.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_ui::direction;
|
||||
use egui::{CornerRadius, FontFamily, FontId, Id, Rect, Role, Sense, StrokeKind, Ui, UiBuilder, WidgetInfo, pos2, vec2};
|
||||
|
||||
use crate::index::{Category, Index, script_name, source_label};
|
||||
use crate::panel::app::{Detail, FontsPanel, Origin, Pending, Preset, Show, Tab, category_label, family_key, regular, source_name, style_count};
|
||||
use crate::panel::faces::{Owner, Shown};
|
||||
use crate::panel::i18n::{self, fmt, tl, tn};
|
||||
use crate::panel::jobs::Kind;
|
||||
use crate::panel::widgets::{self, Cell, Glyph, Skin};
|
||||
use crate::{FaceInfo, Source};
|
||||
|
||||
/// Space round a card's contents.
|
||||
const CARD_PAD: f32 = 20.0;
|
||||
const CARD_TOP: f32 = 14.0;
|
||||
const CARD_GAP: f32 = 8.0;
|
||||
const CARD_BOTTOM: f32 = 18.0;
|
||||
|
||||
/// What a card shows: `key` sets it apart from a family of the same name from another source.
|
||||
struct Card<'a> {
|
||||
key: &'a str,
|
||||
name: &'a str,
|
||||
meta: &'a str,
|
||||
sample: &'a str,
|
||||
curated: bool,
|
||||
index_position: Option<usize>,
|
||||
}
|
||||
|
||||
/// What a card shows at its reading end.
|
||||
enum CardState {
|
||||
Running(Kind, Option<f32>),
|
||||
Failed(Kind, String, Option<Pending>),
|
||||
Installed,
|
||||
Removable(Vec<FaceInfo>),
|
||||
System,
|
||||
Offer { preview: bool },
|
||||
}
|
||||
|
||||
impl FontsPanel {
|
||||
pub(crate) fn top_bar(&mut self, ui: &mut Ui) {
|
||||
let s = self.skin.clone();
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), s.h()), direction::row(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 12.0;
|
||||
let mut tab = self.tab;
|
||||
let browse = self.catalogue.as_ref().map_or(0, |c| c.index.families().len());
|
||||
let options = [
|
||||
Cell { value: Tab::Catalogue, label: tl!("Browse"), count: Some(browse) },
|
||||
Cell { value: Tab::Installed, label: tl!("Installed"), count: Some(self.installed.len()) },
|
||||
];
|
||||
if widgets::cells_row(ui, &s, &mut tab, &options) {
|
||||
self.set_tab(tab);
|
||||
}
|
||||
let (text, hint) = match self.tab {
|
||||
Tab::Catalogue => (&mut self.query.text, tl!("Search fonts by name")),
|
||||
Tab::Installed => (&mut self.installed_text, tl!("Search installed fonts")),
|
||||
};
|
||||
let field = widgets::field(ui, &s, text, hint, tl!("Search"), ui.available_width(), true);
|
||||
if std::mem::take(&mut self.focus_search) {
|
||||
field.request_focus();
|
||||
}
|
||||
if field.changed() {
|
||||
self.focus_row = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn sidebar(&mut self, ui: &mut Ui) {
|
||||
let s = self.skin.clone();
|
||||
egui::ScrollArea::vertical().id_salt("craft-fonts.filters").auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.with_layout(direction::column(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
match self.tab {
|
||||
Tab::Catalogue => self.catalogue_filters(ui, &s),
|
||||
Tab::Installed => self.installed_filters(ui, &s),
|
||||
}
|
||||
if self.downloads_switch {
|
||||
ui.add_space(14.0);
|
||||
ui.painter().hline(ui.max_rect().x_range(), ui.cursor().top(), s.hairline());
|
||||
ui.add_space(14.0);
|
||||
widgets::label(ui, &s, tl!("Downloads"), s.p.faint);
|
||||
let now = ui.input(|i| i.time);
|
||||
let mut allowed = self.allowed(now);
|
||||
if ui.checkbox(&mut allowed, tl!("Allow downloads")).changed() {
|
||||
let _ = self.set_downloads_allowed(allowed);
|
||||
}
|
||||
let note = tl!("From each font's source, only when you install or preview a font.");
|
||||
ui.add(egui::Label::new(egui::RichText::new(note).size(12.0).color(s.p.muted)).wrap());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn catalogue_filters(&mut self, ui: &mut Ui, s: &Skin) {
|
||||
let Ok(c) = &self.catalogue else { return };
|
||||
let index = Arc::clone(&c.index);
|
||||
let width = ui.available_width();
|
||||
widgets::label(ui, s, tl!("Category"), s.p.faint);
|
||||
let mut options = vec![Cell { value: None, label: tl!("All categories"), count: Some(self.category_counts[0]) }];
|
||||
for (n, c) in Category::ALL.iter().enumerate() {
|
||||
options.push(Cell { value: Some(*c), label: crate::panel::app::category_name(*c), count: self.category_counts.get(n + 1).copied() });
|
||||
}
|
||||
if widgets::cells_column(ui, s, &mut self.query.category, &options) {
|
||||
self.focus_row = None;
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
widgets::label(ui, s, tl!("Language"), s.p.faint);
|
||||
let language_label = |i: usize| {
|
||||
index.languages().get(i).map_or_else(String::new, |l| match l.autonym.as_deref() {
|
||||
Some(autonym) if autonym != l.name => format!("{} · {autonym}", l.name),
|
||||
_ => l.name.clone(),
|
||||
})
|
||||
};
|
||||
let selected = self.query.language.map_or_else(|| tl!("Any language").to_owned(), language_label);
|
||||
let before = self.query.language;
|
||||
let search = &c.search;
|
||||
let language_text = &mut self.language_text;
|
||||
let query = &mut self.query;
|
||||
widgets::select(ui, "craft-fonts.language", tl!("Language"), &selected, width, |ui| {
|
||||
let field =
|
||||
widgets::field(ui, s, language_text, tl!("Name in English or in the language"), tl!("Find a language"), ui.available_width(), true);
|
||||
if field.gained_focus() || ui.memory(|m| m.focused().is_none()) {
|
||||
field.request_focus();
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let matches = search.languages(language_text);
|
||||
let row = ui.spacing().interact_size.y;
|
||||
egui::ScrollArea::vertical().max_height(300.0).auto_shrink([false, true]).show_rows(ui, row, matches.len() + 1, |ui, range| {
|
||||
for n in range {
|
||||
if n == 0 {
|
||||
ui.selectable_value(&mut query.language, None, tl!("Any language"));
|
||||
} else if let Some(&i) = matches.get(n - 1) {
|
||||
ui.selectable_value(&mut query.language, Some(i), language_label(i));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if self.query.language != before {
|
||||
self.focus_row = None;
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
widgets::label(ui, s, tl!("Script"), s.p.faint);
|
||||
let any = tl!("Any script");
|
||||
let selected = self.query.script.as_deref().and_then(|id| script_name(&index, id)).unwrap_or(any).to_owned();
|
||||
let query = &mut self.query;
|
||||
widgets::select(ui, "craft-fonts.script", tl!("Script"), &selected, width, |ui| {
|
||||
ui.selectable_value(&mut query.script, None, any);
|
||||
for &i in c.search.scripts() {
|
||||
if let Some(script) = index.scripts().get(i) {
|
||||
ui.selectable_value(&mut query.script, Some(script.id.clone()), &script.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if index.sources().len() > 1 {
|
||||
ui.add_space(12.0);
|
||||
widgets::label(ui, s, tl!("Source"), s.p.faint);
|
||||
let all: usize = self.source_counts.iter().sum();
|
||||
let mut options = vec![Cell { value: None, label: tl!("All sources"), count: Some(all) }];
|
||||
for (n, source) in index.sources().iter().enumerate() {
|
||||
options.push(Cell { value: Some(source.id.clone()), label: &source.name, count: self.source_counts.get(n).copied() });
|
||||
}
|
||||
let mut chosen = self.source.clone();
|
||||
if widgets::cells_column(ui, s, &mut chosen, &options) {
|
||||
self.source = chosen;
|
||||
self.focus_row = None;
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
widgets::label(ui, s, tl!("Show"), s.p.faint);
|
||||
let options = [
|
||||
Cell { value: Show::All, label: tl!("All fonts"), count: Some(self.show_counts[0]) },
|
||||
Cell { value: Show::Installed, label: tl!("Installed"), count: Some(self.show_counts[1]) },
|
||||
Cell { value: Show::NotInstalled, label: tl!("Not installed"), count: Some(self.show_counts[2]) },
|
||||
];
|
||||
widgets::cells_column(ui, s, &mut self.show, &options);
|
||||
|
||||
let filtered = self.query.category.is_some()
|
||||
|| self.query.language.is_some()
|
||||
|| self.query.script.is_some()
|
||||
|| self.show != Show::All
|
||||
|| self.source.is_some();
|
||||
if filtered {
|
||||
ui.add_space(6.0);
|
||||
if widgets::link(ui, s, tl!("Clear filters")).clicked() {
|
||||
self.clear_filters();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_filters(&mut self) {
|
||||
self.query.category = None;
|
||||
self.query.language = None;
|
||||
self.query.script = None;
|
||||
self.source = None;
|
||||
self.show = Show::All;
|
||||
self.focus_row = None;
|
||||
}
|
||||
|
||||
fn installed_filters(&mut self, ui: &mut Ui, s: &Skin) {
|
||||
widgets::label(ui, s, tl!("Source"), s.p.faint);
|
||||
let store = self.installed.iter().filter(|f| f.source == Source::Store).count();
|
||||
let options = [
|
||||
Cell { value: Origin::All, label: tl!("All sources"), count: Some(self.installed.len()) },
|
||||
Cell { value: Origin::Store, label: tl!("Font store"), count: Some(store) },
|
||||
Cell { value: Origin::System, label: tl!("System"), count: Some(self.installed.len() - store) },
|
||||
];
|
||||
widgets::cells_column(ui, s, &mut self.origin, &options);
|
||||
ui.add_space(8.0);
|
||||
let note = tl!("Every Craft app uses the fonts in the font store. System fonts are added and removed with your system's own font tools.");
|
||||
ui.add(egui::Label::new(egui::RichText::new(note).size(12.0).color(s.p.muted)).wrap());
|
||||
}
|
||||
|
||||
pub(crate) fn main(&mut self, ui: &mut Ui) {
|
||||
let s = self.skin.clone();
|
||||
self.toolbar(ui, &s);
|
||||
ui.add_space(12.0);
|
||||
if let Some(notice) = self.notice.clone() {
|
||||
widgets::note(ui, &s, |ui| {
|
||||
ui.add(egui::Label::new(egui::RichText::new(notice).color(s.p.bad)).wrap());
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
match self.tab {
|
||||
Tab::Catalogue => {
|
||||
self.download_note(ui, &s);
|
||||
self.catalogue_list(ui, &s);
|
||||
}
|
||||
Tab::Installed => self.installed_list(ui, &s),
|
||||
}
|
||||
}
|
||||
|
||||
fn toolbar(&mut self, ui: &mut Ui, s: &Skin) {
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), s.h()), direction::row(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 10.0;
|
||||
let preset = self.settings.preset;
|
||||
let preset_name = |p: Preset| match p {
|
||||
Preset::Sentence => tl!("Sentence"),
|
||||
Preset::Alphabet => tl!("Alphabet"),
|
||||
Preset::Numerals => tl!("Numerals"),
|
||||
};
|
||||
let settings = &mut self.settings;
|
||||
widgets::select(ui, "craft-fonts.preset", tl!("Sample"), preset_name(preset), 128.0, |ui| {
|
||||
for p in [Preset::Sentence, Preset::Alphabet, Preset::Numerals] {
|
||||
if ui.selectable_label(preset == p && settings.sample.is_empty(), preset_name(p)).clicked() {
|
||||
settings.preset = p;
|
||||
settings.sample.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
// The size control at the reading end, the sample text filling the room between.
|
||||
let size_w = 176.0;
|
||||
let hint = self.sample_for(["Latn"]);
|
||||
let field_w = (ui.available_width() - size_w - 10.0).max(120.0);
|
||||
widgets::field(ui, s, &mut self.settings.sample, &hint, tl!("Sample text"), field_w, false);
|
||||
ui.allocate_ui_with_layout(vec2(size_w, s.h()), direction::row(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 10.0;
|
||||
widgets::slider(ui, s, &mut self.settings.size, 12.0..=120.0, 112.0, tl!("Size"));
|
||||
let value = fmt("{size} px", &[("size", &i18n::number(self.settings.size.round() as u64))]);
|
||||
widgets::label(ui, s, &value, s.p.muted);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn download_note(&mut self, ui: &mut Ui, s: &Skin) {
|
||||
let now = ui.input(|i| i.time);
|
||||
if self.allowed(now) || self.settings.hide_download_note {
|
||||
return;
|
||||
}
|
||||
let mut allow = false;
|
||||
let mut hide = false;
|
||||
let text = tl!("Fonts that aren't installed show a sample once you preview them. Nothing is downloaded until you allow it.");
|
||||
widgets::note_with_actions(
|
||||
ui,
|
||||
s,
|
||||
|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Downloads are off")).color(s.p.ink_strong));
|
||||
ui.add(egui::Label::new(egui::RichText::new(text).size(12.5).color(s.p.muted)).wrap());
|
||||
},
|
||||
&[tl!("Allow downloads")],
|
||||
|ui, n| match n {
|
||||
0 => allow = widgets::button(ui, s, tl!("Allow downloads"), widgets::Kind::Secondary, 28.0, 0.0).clicked(),
|
||||
_ => hide = widgets::glyph_button(ui, s, Glyph::Close, tl!("Hide this note"), 28.0).clicked(),
|
||||
},
|
||||
true,
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
if allow {
|
||||
let _ = self.set_downloads_allowed(true);
|
||||
}
|
||||
if hide {
|
||||
self.settings.hide_download_note = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn row_height(&self) -> f32 {
|
||||
CARD_TOP + self.skin.h() + CARD_GAP + self.settings.size * 1.32 + CARD_BOTTOM
|
||||
}
|
||||
|
||||
/// The scroll area for `count` cards; `card` draws row `n` into the rect it is given.
|
||||
fn cards(&mut self, ui: &mut Ui, count: usize, mut card: impl FnMut(&mut FontsPanel, &mut Ui, usize, Rect) -> egui::Response) {
|
||||
let row = self.row_height();
|
||||
let mut area = egui::ScrollArea::vertical().id_salt(("craft-fonts.cards", self.tab == Tab::Catalogue)).auto_shrink([false, false]);
|
||||
let viewport_h = ui.available_height();
|
||||
if std::mem::take(&mut self.scroll_to_focus)
|
||||
&& let Some(focus) = self.focus_row
|
||||
{
|
||||
let offset = ui.ctx().data(|d| d.get_temp::<f32>(Id::unique(("craft-fonts.offset", self.tab == Tab::Catalogue)))).unwrap_or(0.0);
|
||||
let top = focus as f32 * row;
|
||||
if top < offset {
|
||||
area = area.vertical_scroll_offset(top);
|
||||
} else if top + row > offset + viewport_h {
|
||||
area = area.vertical_scroll_offset(top + row - viewport_h);
|
||||
}
|
||||
}
|
||||
let output = area.show_rows(ui, row, count, |ui, range| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
let width = ui.available_width();
|
||||
let mut stepped = false;
|
||||
for n in range {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, row), Sense::hover());
|
||||
// Neighbouring cards share their hairline.
|
||||
let rect = if n == 0 { rect } else { rect.with_min_y(rect.top() - 1.0) };
|
||||
let resp = card(self, ui, n, rect);
|
||||
if self.focus_row == Some(n) && self.move_focus {
|
||||
self.move_focus = false;
|
||||
resp.request_focus();
|
||||
}
|
||||
if resp.has_focus() && !stepped {
|
||||
// The arrow keys step through the cards here, not egui's spatial focus moves.
|
||||
let filter = egui::EventFilter { vertical_arrows: true, ..Default::default() };
|
||||
ui.memory_mut(|m| m.set_focus_lock_filter(resp.id, filter));
|
||||
self.focus_row = Some(n);
|
||||
let step = ui.input(|i| {
|
||||
if i.key_pressed(egui::Key::ArrowDown) {
|
||||
1
|
||||
} else if i.key_pressed(egui::Key::ArrowUp) {
|
||||
-1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
let next = n as i64 + step;
|
||||
if step != 0 && (0..count as i64).contains(&next) {
|
||||
self.focus_row = Some(next as usize);
|
||||
self.scroll_to_focus = true;
|
||||
self.move_focus = true;
|
||||
stepped = true;
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.ctx().data_mut(|d| d.insert_temp(Id::unique(("craft-fonts.offset", self.tab == Tab::Catalogue)), output.state.clamped_offset().y));
|
||||
}
|
||||
|
||||
fn empty(&mut self, ui: &mut Ui, s: &Skin, title: &str, body: &str, clear: bool) {
|
||||
ui.add_space(48.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 10.0;
|
||||
widgets::label(ui, s, title, s.p.muted);
|
||||
ui.add(egui::Label::new(egui::RichText::new(body).color(s.p.faint)).wrap());
|
||||
if clear {
|
||||
ui.add_space(4.0);
|
||||
if widgets::button(ui, s, tl!("Clear search and filters"), widgets::Kind::Secondary, s.h(), 0.0).clicked() {
|
||||
self.query.text.clear();
|
||||
self.clear_filters();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn catalogue_list(&mut self, ui: &mut Ui, s: &Skin) {
|
||||
let index = match &self.catalogue {
|
||||
Ok(c) => Arc::clone(&c.index),
|
||||
Err(e) => {
|
||||
let e = e.clone();
|
||||
widgets::note(ui, s, |ui| {
|
||||
let text = fmt("The font indexes can't be read: {error}", &[("error", &e)]);
|
||||
ui.add(egui::Label::new(egui::RichText::new(text).color(s.p.bad)).wrap());
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.source_problems(ui, s, &index);
|
||||
let count = tn(self.results.len() as u64, "{n} family", "{n} families");
|
||||
widgets::label(ui, s, &count, s.p.faint);
|
||||
ui.add_space(8.0);
|
||||
if self.results.is_empty() {
|
||||
self.empty(ui, s, tl!("No fonts match"), tl!("Try another name, or fewer filters."), true);
|
||||
return;
|
||||
}
|
||||
let results = self.results.clone();
|
||||
self.cards(ui, results.len(), |me, ui, n, rect| {
|
||||
let i = results.get(n).copied().unwrap_or_default();
|
||||
me.catalogue_card(ui, &index, i, rect)
|
||||
});
|
||||
}
|
||||
|
||||
/// Sources that couldn't be loaded, in a note above the list; the others show as usual.
|
||||
fn source_problems(&mut self, ui: &mut Ui, s: &Skin, index: &Index) {
|
||||
let problems = index.problems();
|
||||
if problems.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now = ui.input(|i| i.time);
|
||||
let allowed = self.allowed(now);
|
||||
let fetching = self.fetching_sources();
|
||||
let buttons: &[&str] = if allowed && !fetching { &[tl!("Try again")] } else { &[] };
|
||||
let mut retry = false;
|
||||
widgets::note_with_actions(
|
||||
ui,
|
||||
s,
|
||||
|ui| {
|
||||
let title = tn(problems.len() as u64, "{n} font source couldn't be loaded", "{n} font sources couldn't be loaded");
|
||||
ui.label(egui::RichText::new(title).color(s.p.ink_strong));
|
||||
for p in problems.iter().take(4) {
|
||||
let line = fmt("{source}: {error}", &[("source", &p.name), ("error", &p.error)]);
|
||||
ui.add(egui::Label::new(egui::RichText::new(line).size(12.5).color(s.p.muted)).wrap());
|
||||
}
|
||||
},
|
||||
buttons,
|
||||
|ui, _| retry = widgets::button(ui, s, tl!("Try again"), widgets::Kind::Secondary, 28.0, 0.0).clicked(),
|
||||
false,
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
if retry {
|
||||
let ctx = ui.ctx().clone();
|
||||
self.fetch_sources(&ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_list(&mut self, ui: &mut Ui, s: &Skin) {
|
||||
if !self.catalog.is_ready() {
|
||||
direction::horizontal(ui, |ui| {
|
||||
ui.spinner();
|
||||
ui.label(egui::RichText::new(tl!("Looking for installed fonts…")).color(s.p.muted));
|
||||
});
|
||||
ui.ctx().request_repaint();
|
||||
return;
|
||||
}
|
||||
let shown = self.installed_shown();
|
||||
let count = tn(shown.len() as u64, "{n} family", "{n} families");
|
||||
widgets::label(ui, s, &count, s.p.faint);
|
||||
ui.add_space(8.0);
|
||||
if shown.is_empty() {
|
||||
let body = if self.origin == Origin::Store && self.installed_text.trim().is_empty() {
|
||||
tl!("Fonts you install from Browse go here, for every Craft app.")
|
||||
} else {
|
||||
tl!("Try another name.")
|
||||
};
|
||||
self.empty(ui, s, tl!("No installed fonts match"), body, false);
|
||||
return;
|
||||
}
|
||||
self.cards(ui, shown.len(), |me, ui, n, rect| {
|
||||
let i = shown.get(n).copied().unwrap_or_default();
|
||||
me.installed_card(ui, i, rect)
|
||||
});
|
||||
}
|
||||
|
||||
fn catalogue_state(&self, source: &str, family: &str, faces_installed: bool) -> CardState {
|
||||
let key = family_key(source, family);
|
||||
if let Some(job) = self.jobs.of(source, family) {
|
||||
return CardState::Running(job.kind, job.fraction());
|
||||
}
|
||||
if let Some(f) = self.failed.get(&key) {
|
||||
return CardState::Failed(f.kind, f.error.clone(), f.retry.clone());
|
||||
}
|
||||
if faces_installed {
|
||||
return CardState::Installed;
|
||||
}
|
||||
CardState::Offer { preview: !self.previewed.contains_key(&key) }
|
||||
}
|
||||
|
||||
fn catalogue_card(&mut self, ui: &mut Ui, index: &Index, i: usize, rect: Rect) -> egui::Response {
|
||||
let s = self.skin.clone();
|
||||
let Some(family) = index.families().get(i) else { return ui.interact(rect, Id::unique(("craft-fonts.card", i)), Sense::hover()) };
|
||||
let key = family_key(&family.source, &family.family);
|
||||
let installed = self.installed_by_name.get(&family.family.to_lowercase()).and_then(|f| regular(f)).cloned();
|
||||
let previewed = self.previewed.get(&key).and_then(|f| regular(f)).cloned();
|
||||
let state = self.catalogue_state(&family.source, &family.family, installed.is_some());
|
||||
let (source, curated) = source_label(index, family);
|
||||
let meta = [tn(style_count(family) as u64, "{n} style", "{n} styles"), category_label(family), source.to_owned()].join(" · ");
|
||||
let sample = self.sample_for(family.scripts.iter().map(String::as_str));
|
||||
let face = match (installed, previewed) {
|
||||
(Some(face), _) => Some((Owner::Installed, face)),
|
||||
(None, Some(face)) => Some((Owner::Preview, face)),
|
||||
(None, None) => None,
|
||||
};
|
||||
let card = Card { key: &key, name: &family.family, meta: &meta, sample: &sample, curated, index_position: Some(i) };
|
||||
let resp = self.card(ui, &s, rect, card, face, state);
|
||||
if resp.clicked() {
|
||||
self.detail = Some(Detail::Family(i));
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
fn installed_card(&mut self, ui: &mut Ui, i: usize, rect: Rect) -> egui::Response {
|
||||
let s = self.skin.clone();
|
||||
let Some(family) = self.installed.get(i).cloned() else { return ui.interact(rect, Id::unique(("craft-fonts.installed", i)), Sense::hover()) };
|
||||
let meta = [tn(family.faces.len() as u64, "{n} style", "{n} styles"), source_name(family.source).to_owned()].join(" · ");
|
||||
let state = match self.jobs.of("", &family.name) {
|
||||
Some(job) => CardState::Running(job.kind, job.fraction()),
|
||||
None => match self.failed.get(&family_key("", &family.name)) {
|
||||
Some(f) => CardState::Failed(f.kind, f.error.clone(), None),
|
||||
None if family.removable() => CardState::Removable(family.faces.clone()),
|
||||
None => CardState::System,
|
||||
},
|
||||
};
|
||||
let face = regular(&family.faces).cloned().map(|f| (Owner::Installed, f));
|
||||
let scripts = self.index().and_then(|index| index.family(&family.name).map(|f| f.scripts.clone())).unwrap_or_default();
|
||||
let sample = self.sample_for(scripts.iter().map(String::as_str));
|
||||
let key = format!("{:?}\n{}", family.source, family.name);
|
||||
let card = Card { key: &key, name: &family.name, meta: &meta, sample: &sample, curated: true, index_position: None };
|
||||
let resp = self.card(ui, &s, rect, card, face, state);
|
||||
if resp.clicked() {
|
||||
let in_index = self.index().and_then(|index| index.families().iter().position(|f| f.family.eq_ignore_ascii_case(&family.name)));
|
||||
self.detail = Some(match in_index {
|
||||
Some(i) if family.source == Source::Store => Detail::Family(i),
|
||||
_ => Detail::Installed(family.name.clone(), family.source),
|
||||
});
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// One family card: its name, styles and source on one line with its state at the end, and
|
||||
/// a sample in the face below.
|
||||
fn card(&mut self, ui: &mut Ui, s: &Skin, rect: Rect, card: Card<'_>, face: Option<(Owner, FaceInfo)>, state: CardState) -> egui::Response {
|
||||
let Card { key, name, meta, sample, curated, index_position } = card;
|
||||
let p = s.p;
|
||||
let rtl = direction::is_rtl(ui);
|
||||
let id = Id::unique(("craft-fonts.card", key));
|
||||
// The whole card opens the details; added first, so the buttons on it keep their clicks.
|
||||
let resp = ui.interact(rect, id, Sense::click());
|
||||
resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, name));
|
||||
let hovered = ui.rect_contains_pointer(rect);
|
||||
let lift = ui.ctx().animate_bool_with_time(id.with("hover"), hovered || resp.has_focus(), ui.style().animation_time * 2.0);
|
||||
let painter = ui.painter_at(rect.expand(1.0));
|
||||
painter.rect(rect, CornerRadius::ZERO, p.bg.lerp_to_gamma(p.sunken, 0.55 * lift), s.hairline(), StrokeKind::Inside);
|
||||
if lift > 0.0 {
|
||||
let inside = rect.shrink(1.0);
|
||||
let w = inside.width() * lift;
|
||||
let x = if rtl { inside.right() - w } else { inside.left() };
|
||||
painter.rect_filled(Rect::from_min_size(pos2(x, inside.top()), vec2(w, 2.0)), CornerRadius::ZERO, p.accent);
|
||||
}
|
||||
widgets::focus_ring(ui, s, rect.shrink(1.0), &resp);
|
||||
|
||||
let inner = rect.shrink2(vec2(CARD_PAD, 0.0));
|
||||
let header = Rect::from_min_size(pos2(inner.left(), rect.top() + CARD_TOP), vec2(inner.width(), s.h()));
|
||||
let mut end = ui.new_child(UiBuilder::new().max_rect(header).layout(direction::trailing(ui)));
|
||||
end.spacing_mut().item_spacing.x = 10.0;
|
||||
self.card_state(&mut end, s, name, index_position, state);
|
||||
if !curated {
|
||||
widgets::badge(&mut end, s, tl!("Not curated"), s.p.warn, false)
|
||||
.on_hover_text(tl!("This family comes from a source you added. Craft doesn't review it."));
|
||||
}
|
||||
let end_w = end.min_rect().width();
|
||||
|
||||
let name_galley = ui.painter().layout_no_wrap(name.to_owned(), FontId::new(16.0, s.look.strong.clone()), p.ink_strong);
|
||||
let name_w = name_galley.size().x.min((header.width() - end_w - 24.0).max(0.0));
|
||||
let start = direction::start_x(ui, header);
|
||||
let f = direction::forward(ui);
|
||||
let name_x = if rtl { start - name_w } else { start };
|
||||
ui.painter().with_clip_rect(header).galley(pos2(name_x, header.center().y - name_galley.size().y / 2.0), name_galley, p.ink_strong);
|
||||
let room = header.width() - name_w - end_w - 40.0;
|
||||
if room > 24.0 {
|
||||
let meta_galley = s.caps_galley(ui, meta, if s.look.caps { 10.5 } else { 12.0 }, s.look.label.clone(), p.faint, Some(room));
|
||||
let x = start + f * (name_w + 14.0);
|
||||
let x = if rtl { x - meta_galley.size().x } else { x };
|
||||
ui.painter().galley(pos2(x, header.center().y - meta_galley.size().y / 2.0 + 1.0), meta_galley, p.faint);
|
||||
}
|
||||
|
||||
let sample_rect = Rect::from_min_max(pos2(inner.left(), header.bottom() + CARD_GAP), pos2(inner.right(), rect.bottom() - CARD_BOTTOM + 4.0));
|
||||
self.paint_sample(ui, s, sample_rect, face.as_ref(), sample);
|
||||
resp
|
||||
}
|
||||
|
||||
fn card_state(&mut self, ui: &mut Ui, s: &Skin, name: &str, index_position: Option<usize>, state: CardState) {
|
||||
let p = s.p;
|
||||
let h = s.h() - 4.0;
|
||||
let ctx = ui.ctx().clone();
|
||||
match state {
|
||||
CardState::Running(kind, fraction) => {
|
||||
let what = match kind {
|
||||
Kind::Install => tl!("Installing"),
|
||||
Kind::Remove => tl!("Removing"),
|
||||
Kind::Preview => tl!("Downloading"),
|
||||
};
|
||||
let text = match fraction {
|
||||
Some(f) => format!("{what} {}", fmt("{percent} %", &[("percent", &i18n::number((f * 100.0).round() as u64))])),
|
||||
None => format!("{what}…"),
|
||||
};
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(150.0, h), Sense::hover());
|
||||
let galley = s.label_galley(ui, &text, p.muted);
|
||||
widgets::paint_end(ui, rect.with_max_y(rect.bottom() - 8.0), galley, p.muted);
|
||||
let bar = Rect::from_min_max(pos2(rect.left(), rect.bottom() - 3.0), rect.max);
|
||||
widgets::progress_bar(ui, s, bar, fraction);
|
||||
resp.widget_info(|| WidgetInfo::labeled(Role::ProgressIndicator, true, &text));
|
||||
}
|
||||
CardState::Failed(kind, error, retry) => {
|
||||
if let Some(retry) = retry
|
||||
&& widgets::button(ui, s, tl!("Retry"), widgets::Kind::Secondary, h, 0.0).clicked()
|
||||
{
|
||||
self.ask(&ctx, retry);
|
||||
}
|
||||
let what = match kind {
|
||||
Kind::Install => tl!("Couldn't install"),
|
||||
Kind::Remove => tl!("Couldn't remove"),
|
||||
Kind::Preview => tl!("Couldn't download"),
|
||||
};
|
||||
widgets::label(ui, s, what, p.bad).on_hover_text(error);
|
||||
}
|
||||
CardState::Installed => {
|
||||
widgets::badge(ui, s, tl!("Installed"), p.good, true);
|
||||
}
|
||||
CardState::System => {}
|
||||
CardState::Removable(faces) => {
|
||||
let remove = widgets::button(ui, s, tl!("Remove"), widgets::Kind::Danger, h, 0.0);
|
||||
widgets::name(ui, &remove, &fmt("Remove {family}", &[("family", name)]));
|
||||
if remove.clicked() {
|
||||
self.remove(&ctx, name, &faces, true);
|
||||
}
|
||||
}
|
||||
CardState::Offer { preview } => {
|
||||
let Some(i) = index_position else { return };
|
||||
let install = widgets::button(ui, s, tl!("Install"), widgets::Kind::Secondary, h, 0.0);
|
||||
widgets::name(ui, &install, &fmt("Install {family}", &[("family", name)]));
|
||||
if install.clicked() {
|
||||
self.ask(&ctx, Pending::Install(i));
|
||||
}
|
||||
if preview {
|
||||
let button = widgets::link(ui, s, tl!("Preview"));
|
||||
widgets::name(ui, &button, &fmt("Preview {family}", &[("family", name)]));
|
||||
if button.clicked() {
|
||||
self.ask(&ctx, Pending::Preview(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` in the face, on one line; the interface font in a faint ink where the face isn't
|
||||
/// here (not installed and not previewed).
|
||||
pub(crate) fn paint_sample(&mut self, ui: &mut Ui, s: &Skin, rect: Rect, face: Option<&(Owner, FaceInfo)>, text: &str) {
|
||||
let size = self.settings.size;
|
||||
let (family, color) = match face {
|
||||
Some((owner, face)) => {
|
||||
let catalog = match owner {
|
||||
Owner::Installed => Arc::clone(&self.catalog),
|
||||
Owner::Preview => Arc::clone(&self.previews),
|
||||
};
|
||||
match self.faces.family(ui.ctx(), &catalog, *owner, face) {
|
||||
Shown::Ready(family) => (family, s.p.ink),
|
||||
Shown::Loading => return,
|
||||
Shown::Unavailable => (FontFamily::Proportional, s.p.faint),
|
||||
}
|
||||
}
|
||||
None => (FontFamily::Proportional, s.p.faint.gamma_multiply(0.8)),
|
||||
};
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(text.to_owned(), FontId::new(size, family), color);
|
||||
job.wrap = egui::text::TextWrapping::truncate_at_width(rect.width());
|
||||
let galley = ui.painter().layout_job(job);
|
||||
// Sample text keeps its own direction whatever the interface's.
|
||||
let x = if craft_i18n::base_direction(text) == Some(craft_i18n::Direction::Rtl) { rect.right() - galley.size().x } else { rect.left() };
|
||||
let y = rect.center().y - galley.size().y / 2.0;
|
||||
ui.painter().with_clip_rect(rect.expand2(vec2(0.0, 6.0))).galley(pos2(x, y), galley, color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
# Arabic (ar) catalog for Craft Font Manager's panel.
|
||||
# Format: context<TAB>source<TAB>translation; @plural rows list the locale's plural forms.
|
||||
# Style and weight names (Regular, Bold, Italic), licence names and product names stay in English.
|
||||
All categories كل الفئات
|
||||
All fonts كل الخطوط
|
||||
All sources كل المصادر
|
||||
Allow downloads السماح بالتنزيل
|
||||
Alphabet الأبجدية
|
||||
Any language أي لغة
|
||||
Any script أي نظام كتابة
|
||||
Browse تصفّح
|
||||
Cancel إلغاء
|
||||
Category الفئة
|
||||
Clear filters مسح عوامل التصفية
|
||||
Clear search and filters مسح البحث وعوامل التصفية
|
||||
Close إغلاق
|
||||
Couldn't download تعذّر التنزيل
|
||||
Couldn't install تعذّر التثبيت
|
||||
Couldn't remove تعذّرت الإزالة
|
||||
Designer المصمم
|
||||
Display عرض
|
||||
Download from Google Fonts? هل تريد التنزيل من Google Fonts؟
|
||||
Download size حجم التنزيل
|
||||
Download تنزيل
|
||||
Downloading جارٍ التنزيل
|
||||
Downloads are off التنزيل متوقف
|
||||
Downloads stay allowed until you turn them off. يبقى التنزيل مسموحًا حتى توقفه.
|
||||
Downloads التنزيلات
|
||||
Every Craft app uses the fonts in the font store. System fonts are added and removed with your system's own font tools. تستخدم كل تطبيقات Craft الخطوط الموجودة في متجر الخطوط. تُضاف خطوط النظام وتُزال بأدوات الخطوط في نظامك.
|
||||
Files الملفات
|
||||
Find a language ابحث عن لغة
|
||||
Font store متجر الخطوط
|
||||
Fonts that aren't installed show a sample once you preview them. Nothing is downloaded until you allow it. تظهر عينة الخط غير المثبّت بعد معاينته. لا يُنزَّل شيء حتى تسمح بذلك.
|
||||
Fonts you install from Browse go here, for every Craft app. تظهر هنا الخطوط التي تثبّتها من «تصفّح»، لكل تطبيقات Craft.
|
||||
From Google Fonts' GitHub repository, only when you install or preview a font. من مستودع Google Fonts على GitHub، فقط عند تثبيت خط أو معاينته.
|
||||
Handwriting خط اليد
|
||||
Hide this note إخفاء هذه الملاحظة
|
||||
Install all تثبيت الكل
|
||||
Install تثبيت
|
||||
Installed on this system مثبّت على هذا النظام
|
||||
Installed مثبّت
|
||||
Installing جارٍ التثبيت
|
||||
Language اللغة
|
||||
Looking for installed fonts… جارٍ البحث عن الخطوط المثبّتة…
|
||||
Monospace أحادي المسافة
|
||||
Name in English or in the language الاسم بالإنجليزية أو باللغة نفسها
|
||||
No fonts match لا توجد خطوط مطابقة
|
||||
No installed fonts match لا توجد خطوط مثبّتة مطابقة
|
||||
Not installed غير مثبّت
|
||||
Numerals الأرقام والرموز
|
||||
Preview معاينة
|
||||
Remove all إزالة الكل
|
||||
Remove إزالة
|
||||
Removed with your system's font tools يُزال بأدوات الخطوط في نظامك
|
||||
Removing جارٍ الإزالة
|
||||
Retry إعادة المحاولة
|
||||
Sample text نص العينة
|
||||
Sample العينة
|
||||
Sans serif بلا زوائد
|
||||
Script نظام الكتابة
|
||||
Scripts أنظمة الكتابة
|
||||
Search fonts by name ابحث في الخطوط بالاسم
|
||||
Search installed fonts ابحث في الخطوط المثبّتة
|
||||
Search بحث
|
||||
Sentence جملة
|
||||
Serif بزوائد
|
||||
Show إظهار
|
||||
Size الحجم
|
||||
Source المصدر
|
||||
Styles الأنماط
|
||||
System fonts are removed with your system's own font tools. تُزال خطوط النظام بأدوات الخطوط في نظامك.
|
||||
System النظام
|
||||
Try another name, or fewer filters. جرّب اسمًا آخر أو عوامل تصفية أقل.
|
||||
Try another name. جرّب اسمًا آخر.
|
||||
Designed by {designer} تصميم {designer}
|
||||
Install {family} تثبيت {family}
|
||||
Install {style} تثبيت {style}
|
||||
Languages · {n} اللغات · {n}
|
||||
Preview downloads this font ({size}) for this session without installing it. تنزّل المعاينة هذا الخط ({size}) لهذه الجلسة فقط دون تثبيته.
|
||||
Preview {family} معاينة {family}
|
||||
Remove {family} إزالة {family}
|
||||
Remove {style} إزالة {style}
|
||||
The Google Fonts index can't be read: {error} تتعذّر قراءة فهرس Google Fonts: {error}
|
||||
The download setting couldn't be saved: {error} تعذّر حفظ إعداد التنزيل: {error}
|
||||
{list} and {n} more {list} و{n} غيرها
|
||||
{percent} % {percent}٪
|
||||
{size} download تنزيل {size}
|
||||
{size} px {size} بكسل
|
||||
Craft Font Manager downloads {family} ({size}) from Google Fonts' GitHub repository through jsDelivr, and checks every file before using it. ينزّل Craft Font Manager الخط {family} ({size}) من مستودع Google Fonts على GitHub عبر jsDelivr، ويتحقق من كل ملف قبل استخدامه.
|
||||
@plural {n} style|{n} styles {n} نمط|{n} نمط|{n} نمط|{n} أنماط|{n} نمطًا|{n} نمط
|
||||
@plural {n} style installed|{n} styles installed {n} نمط مثبّت|{n} نمط مثبّت|{n} نمط مثبّت|{n} أنماط مثبّتة|{n} نمطًا مثبّتًا|{n} نمط مثبّت
|
||||
@plural {n} family|{n} families {n} عائلة|{n} عائلة|{n} عائلة|{n} عائلات|{n} عائلة|{n} عائلة
|
||||
Licence الترخيص
|
||||
Try again إعادة المحاولة
|
||||
Not curated غير مراجَع
|
||||
This family comes from a source you added. Craft doesn't review it. تأتي هذه العائلة من مصدر أضفته. لا يراجعها Craft.
|
||||
The font indexes can't be read: {error} تتعذّر قراءة فهارس الخطوط: {error}
|
||||
{source}: {error} {source}: {error}
|
||||
This family comes from a source you added ({url}). Craft doesn't review its fonts or their licences. تأتي هذه العائلة من مصدر أضفته ({url}). لا يراجع Craft خطوطه ولا تراخيصها.
|
||||
Changed since added تغيّر منذ إضافته
|
||||
The index at this address isn't the one first downloaded from it. Check the source before installing from it. الفهرس على هذا العنوان ليس الذي نُزِّل منه أول مرة. تحقّق من المصدر قبل التثبيت منه.
|
||||
Add a source إضافة مصدر
|
||||
Add إضافة
|
||||
Index address عنوان الفهرس
|
||||
A source you add isn't reviewed: add only indexes you trust. Every font is still checked against its index before it is used. المصدر الذي تضيفه لا يُراجَع: أضف فقط الفهارس التي تثق بها. يُتحقَّق من كل خط مقابل فهرسه قبل استخدامه.
|
||||
Downloading the sources… جارٍ تنزيل المصادر…
|
||||
Update sources تحديث المصادر
|
||||
Allow downloads to update اسمح بالتنزيل للتحديث
|
||||
Added. It downloads once downloads are allowed. أُضيف. يُنزَّل عند السماح بالتنزيل.
|
||||
Remove {source} إزالة {source}
|
||||
The source couldn't be added: {error} تعذّرت إضافة المصدر: {error}
|
||||
The source couldn't be removed: {error} تعذّرت إزالة المصدر: {error}
|
||||
@plural {n} font source couldn't be loaded|{n} font sources couldn't be loaded تعذّر تحميل {n} مصدر خطوط|تعذّر تحميل {n} مصدر خطوط|تعذّر تحميل {n} مصدر خطوط|تعذّر تحميل {n} مصادر خطوط|تعذّر تحميل {n} مصدرًا للخطوط|تعذّر تحميل {n} مصدر خطوط
|
||||
|
@@ -0,0 +1,113 @@
|
||||
# Japanese (ja) catalog for Craft Font Manager's panel.
|
||||
# Format: context<TAB>source<TAB>translation; @plural rows list the locale's plural forms.
|
||||
# Style and weight names (Regular, Bold, Italic), licence names and product names stay in English.
|
||||
All categories すべてのカテゴリ
|
||||
All fonts すべてのフォント
|
||||
All sources すべての入手元
|
||||
Allow downloads ダウンロードを許可
|
||||
Alphabet アルファベット
|
||||
Any language すべての言語
|
||||
Any script すべての文字体系
|
||||
Browse 探す
|
||||
Cancel キャンセル
|
||||
Category カテゴリ
|
||||
Clear filters 絞り込みを解除
|
||||
Clear search and filters 検索と絞り込みを解除
|
||||
Close 閉じる
|
||||
Couldn't download ダウンロードできませんでした
|
||||
Couldn't install インストールできませんでした
|
||||
Couldn't remove 削除できませんでした
|
||||
Designer デザイナー
|
||||
Display ディスプレイ
|
||||
Download from Google Fonts? Google Fonts からダウンロードしますか?
|
||||
Download size ダウンロードサイズ
|
||||
Download ダウンロード
|
||||
Downloading ダウンロード中
|
||||
Downloads are off ダウンロードはオフです
|
||||
Downloads stay allowed until you turn them off. ダウンロードは、オフにするまで許可されたままです。
|
||||
Downloads ダウンロード
|
||||
Every Craft app uses the fonts in the font store. System fonts are added and removed with your system's own font tools. フォントストアのフォントはすべての Craft アプリで使えます。システムフォントの追加と削除は、システムのフォント管理ツールで行います。
|
||||
Files ファイル
|
||||
Find a language 言語を検索
|
||||
Font store フォントストア
|
||||
Fonts that aren't installed show a sample once you preview them. Nothing is downloaded until you allow it. インストールしていないフォントは、プレビューするとサンプルが表示されます。許可するまで何もダウンロードされません。
|
||||
Fonts you install from Browse go here, for every Craft app. 「探す」からインストールしたフォントがここに表示され、すべての Craft アプリで使えます。
|
||||
From Google Fonts' GitHub repository, only when you install or preview a font. フォントをインストールまたはプレビューするときにだけ、Google Fonts の GitHub リポジトリからダウンロードします。
|
||||
Handwriting 手書き
|
||||
Hide this note このお知らせを隠す
|
||||
Install all すべてインストール
|
||||
Install インストール
|
||||
Installed on this system このシステムにインストール済み
|
||||
Installed インストール済み
|
||||
Installing インストール中
|
||||
Language 言語
|
||||
Looking for installed fonts… インストール済みのフォントを探しています…
|
||||
Monospace 等幅
|
||||
Name in English or in the language 英語名またはその言語での名前
|
||||
No fonts match 一致するフォントはありません
|
||||
No installed fonts match 一致するインストール済みフォントはありません
|
||||
Not installed 未インストール
|
||||
Numerals 数字と記号
|
||||
Preview プレビュー
|
||||
Remove all すべて削除
|
||||
Remove 削除
|
||||
Removed with your system's font tools システムのフォントツールで削除します
|
||||
Removing 削除中
|
||||
Retry 再試行
|
||||
Sample text サンプルテキスト
|
||||
Sample サンプル
|
||||
Sans serif サンセリフ
|
||||
Script 文字体系
|
||||
Scripts 文字体系
|
||||
Search fonts by name フォントを名前で検索
|
||||
Search installed fonts インストール済みのフォントを検索
|
||||
Search 検索
|
||||
Sentence 文
|
||||
Serif セリフ
|
||||
Show 表示
|
||||
Size サイズ
|
||||
Source 入手元
|
||||
Styles スタイル
|
||||
System fonts are removed with your system's own font tools. システムフォントは、システムのフォント管理ツールで削除します。
|
||||
System システム
|
||||
Try another name, or fewer filters. 別の名前を試すか、絞り込みを減らしてください。
|
||||
Try another name. 別の名前を試してください。
|
||||
Designed by {designer} デザイン: {designer}
|
||||
Install {family} {family} をインストール
|
||||
Install {style} {style} をインストール
|
||||
Languages · {n} 言語 · {n}
|
||||
Preview downloads this font ({size}) for this session without installing it. プレビューでは、このフォント ({size}) をこのセッションの間だけダウンロードし、インストールはしません。
|
||||
Preview {family} {family} をプレビュー
|
||||
Remove {family} {family} を削除
|
||||
Remove {style} {style} を削除
|
||||
The Google Fonts index can't be read: {error} Google Fonts の一覧を読み込めません: {error}
|
||||
The download setting couldn't be saved: {error} ダウンロードの設定を保存できませんでした: {error}
|
||||
{list} and {n} more {list} ほか {n} 言語
|
||||
{percent} % {percent}%
|
||||
{size} download ダウンロード {size}
|
||||
{size} px {size}px
|
||||
Craft Font Manager downloads {family} ({size}) from Google Fonts' GitHub repository through jsDelivr, and checks every file before using it. Craft Font Manager は {family} ({size}) を Google Fonts の GitHub リポジトリから jsDelivr 経由でダウンロードし、使う前にすべてのファイルを確認します。
|
||||
@plural {n} style|{n} styles {n} スタイル
|
||||
@plural {n} style installed|{n} styles installed インストール済み {n} スタイル
|
||||
@plural {n} family|{n} families {n} ファミリー
|
||||
Licence ライセンス
|
||||
Try again もう一度試す
|
||||
Not curated 未審査
|
||||
This family comes from a source you added. Craft doesn't review it. このファミリーは追加したソースのものです。Craft は審査していません。
|
||||
The font indexes can't be read: {error} フォントの一覧を読み込めません: {error}
|
||||
{source}: {error} {source}: {error}
|
||||
This family comes from a source you added ({url}). Craft doesn't review its fonts or their licences. このファミリーは追加したソース ({url}) のものです。Craft はフォントもライセンスも審査していません。
|
||||
Changed since added 追加後に変更あり
|
||||
The index at this address isn't the one first downloaded from it. Check the source before installing from it. このアドレスの一覧は、最初にダウンロードしたものと異なります。インストールする前にソースを確認してください。
|
||||
Add a source ソースを追加
|
||||
Add 追加
|
||||
Index address 一覧のアドレス
|
||||
A source you add isn't reviewed: add only indexes you trust. Every font is still checked against its index before it is used. 追加したソースは審査されません。信頼できる一覧だけを追加してください。どのフォントも、使う前に一覧と照合されます。
|
||||
Downloading the sources… ソースをダウンロードしています…
|
||||
Update sources ソースを更新
|
||||
Allow downloads to update 更新するにはダウンロードを許可してください
|
||||
Added. It downloads once downloads are allowed. 追加しました。ダウンロードを許可するとダウンロードされます。
|
||||
Remove {source} {source} を削除
|
||||
The source couldn't be added: {error} ソースを追加できませんでした: {error}
|
||||
The source couldn't be removed: {error} ソースを削除できませんでした: {error}
|
||||
@plural {n} font source couldn't be loaded|{n} font sources couldn't be loaded {n} 件のフォントソースを読み込めませんでした
|
||||
|
@@ -0,0 +1,532 @@
|
||||
//! The details sheet: a family's every style in its own face, its facts and languages, and
|
||||
//! installing or removing it whole or a style at a time.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_ui::direction;
|
||||
use egui::{
|
||||
Align, Color32, Context, CornerRadius, FontFamily, FontId, Id, Layout, Margin, Order, Rect, RichText, Sense, Stroke, StrokeKind, Ui, UiBuilder,
|
||||
vec2,
|
||||
};
|
||||
|
||||
use crate::index::{Family, FontFile, Index, script_name, source_label};
|
||||
use crate::panel::app::{Detail, FontsPanel, Pending, category_label, family_key, regular, source_name, style_count, style_name};
|
||||
use crate::panel::faces::Owner;
|
||||
use crate::panel::i18n::{self, fmt, tl, tn};
|
||||
use crate::panel::jobs::Kind;
|
||||
use crate::panel::widgets::{self, Glyph, Skin};
|
||||
use crate::{FaceInfo, Source};
|
||||
|
||||
/// From this width the sheet reads in two columns.
|
||||
const COLUMNS_FROM: f32 = 760.0;
|
||||
|
||||
/// What a style row offers at its end.
|
||||
enum RowAction {
|
||||
None,
|
||||
Install(String),
|
||||
Remove(Box<FaceInfo>),
|
||||
Running(Option<f32>),
|
||||
}
|
||||
|
||||
struct StyleRow {
|
||||
label: String,
|
||||
face: Option<(Owner, FaceInfo)>,
|
||||
action: RowAction,
|
||||
}
|
||||
|
||||
fn weight_name(weight: u16) -> &'static str {
|
||||
match weight {
|
||||
0..=149 => tl!("Thin"),
|
||||
150..=249 => tl!("ExtraLight"),
|
||||
250..=349 => tl!("Light"),
|
||||
350..=449 => tl!("Regular"),
|
||||
450..=549 => tl!("Medium"),
|
||||
550..=649 => tl!("SemiBold"),
|
||||
650..=749 => tl!("Bold"),
|
||||
750..=849 => tl!("ExtraBold"),
|
||||
_ => tl!("Black"),
|
||||
}
|
||||
}
|
||||
|
||||
/// "Bold Italic", or "Variable 100–900" for a variable file.
|
||||
fn file_label(file: &FontFile) -> String {
|
||||
let base = match file.axes.iter().find(|a| a.tag == "wght") {
|
||||
Some(a) => fmt("Variable {from}–{to}", &[("from", &(a.min.round() as u32).to_string()), ("to", &(a.max.round() as u32).to_string())]),
|
||||
None => weight_name(file.weight).to_owned(),
|
||||
};
|
||||
match (file.is_italic(), file.weight) {
|
||||
(true, 350..=449) if file.axes.is_empty() => tl!("Italic").to_owned(),
|
||||
(true, _) => format!("{base} {}", tl!("Italic")),
|
||||
(false, _) => base,
|
||||
}
|
||||
}
|
||||
|
||||
fn licence_name(id: &str) -> &str {
|
||||
match id {
|
||||
"OFL" => tl!("SIL Open Font License"),
|
||||
"APACHE2" => tl!("Apache License 2.0"),
|
||||
"UFL" => tl!("Ubuntu Font Licence"),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_name(face: &FaceInfo) -> Option<String> {
|
||||
face.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// The index file a face comes from: by PostScript name, else by weight and slant.
|
||||
fn file_of<'a>(family: &'a Family, face: &FaceInfo) -> Option<&'a FontFile> {
|
||||
let by_name = file_name(face).and_then(|n| family.files.iter().find(|f| f.file_name() == n));
|
||||
let by_postscript = || face.postscript_name.as_ref().and_then(|ps| family.files.iter().find(|f| f.postscript.contains(ps)));
|
||||
by_name.or_else(by_postscript).or_else(|| family.files.iter().find(|f| f.has_weight(face.weight.round() as u16, face.italic)))
|
||||
}
|
||||
|
||||
impl FontsPanel {
|
||||
pub(crate) fn details(&mut self, ctx: &Context, area: Rect) {
|
||||
let open = self.detail.is_some();
|
||||
let k = ctx.animate_bool_with_time(Id::unique("craft-fonts.sheet"), open, ctx.global_style().animation_time * 1.5);
|
||||
let Some(detail) = self.detail.clone() else { return };
|
||||
let s = self.skin.clone();
|
||||
let dark = ctx.theme() == egui::Theme::Dark;
|
||||
let width = (area.width() - 48.0).clamp(300.0, 1040.0).min(area.width());
|
||||
let height = (area.height() - 32.0).max(200.0).min(area.height());
|
||||
let sheet = Rect::from_center_size(area.center() + vec2(0.0, (1.0 - k) * 16.0), vec2(width, height));
|
||||
let mut close = false;
|
||||
egui::Area::new(Id::unique("craft-fonts.details")).order(Order::Foreground).fixed_pos(area.min).constrain(false).show(ctx, |ui| {
|
||||
let (rect, scrim) = ui.allocate_exact_size(area.size(), Sense::click());
|
||||
let alpha = if dark { 165.0 } else { 100.0 } * k;
|
||||
ui.painter().rect_filled(rect, CornerRadius::ZERO, Color32::from_black_alpha(alpha.round() as u8));
|
||||
close |= scrim.clicked();
|
||||
// Clicks on the sheet's own empty space stay on the sheet.
|
||||
let _ = ui.interact(sheet, Id::unique("craft-fonts.sheet-bg"), Sense::click());
|
||||
let painter = ui.painter();
|
||||
painter.add(
|
||||
egui::epaint::Shadow { offset: [0, 8], blur: 24, spread: 0, color: Color32::from_black_alpha(if dark { 140 } else { 36 }) }
|
||||
.as_shape(sheet, s.look.corner),
|
||||
);
|
||||
painter.rect(sheet, s.look.corner, s.p.bg, Stroke::new(1.0, s.p.line_strong), StrokeKind::Inside);
|
||||
let mut child = ui.new_child(UiBuilder::new().max_rect(sheet).layout(direction::column(ui)));
|
||||
child.set_opacity(k.clamp(0.0, 1.0));
|
||||
child.set_clip_rect(sheet);
|
||||
egui::ScrollArea::vertical().id_salt(("craft-fonts.sheet-scroll", format!("{detail:?}"))).auto_shrink([false, false]).show(
|
||||
&mut child,
|
||||
|ui| {
|
||||
egui::Frame::new().inner_margin(Margin::same(24)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.with_layout(direction::column(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
close |= match &detail {
|
||||
Detail::Family(i) => self.family_sheet(ui, &s, *i),
|
||||
Detail::Installed(name, source) => self.installed_sheet(ui, &s, name, *source),
|
||||
};
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
if close {
|
||||
self.detail = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The eyebrow, the close button and the title; returns whether Close was clicked.
|
||||
fn sheet_header(&mut self, ui: &mut Ui, s: &Skin, eyebrow: &str, title: &str, byline: Option<String>, face: Option<(Owner, FaceInfo)>) -> bool {
|
||||
let mut close = false;
|
||||
// "Aa" in the face, large, at the header's reading end under the close button.
|
||||
if let Some((owner, face)) = face {
|
||||
let catalog = match owner {
|
||||
Owner::Installed => Arc::clone(&self.catalog),
|
||||
Owner::Preview => Arc::clone(&self.previews),
|
||||
};
|
||||
if let crate::panel::faces::Shown::Ready(family) = self.faces.family(ui.ctx(), &catalog, owner, &face) {
|
||||
let top = ui.cursor().top();
|
||||
let end = direction::end_x(ui, ui.max_rect()) - direction::forward(ui) * (s.h() + 24.0);
|
||||
let anchor = if direction::is_rtl(ui) { egui::Align2::LEFT_TOP } else { egui::Align2::RIGHT_TOP };
|
||||
ui.painter().text(egui::pos2(end, top - 14.0), anchor, "Aa", FontId::new(96.0, family), s.p.ink_strong);
|
||||
}
|
||||
}
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), s.h()), direction::row(ui), |ui| {
|
||||
widgets::label(ui, s, eyebrow, s.p.faint);
|
||||
ui.with_layout(direction::trailing(ui), |ui| {
|
||||
close = widgets::glyph_button(ui, s, Glyph::Close, tl!("Close"), s.h()).clicked();
|
||||
});
|
||||
});
|
||||
let title = RichText::new(title).size(30.0).family(s.look.display.clone()).color(s.p.ink_strong).extra_letter_spacing(-0.4);
|
||||
ui.add(egui::Label::new(title).wrap());
|
||||
if let Some(by) = byline {
|
||||
ui.label(RichText::new(by).color(s.p.muted));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
close
|
||||
}
|
||||
|
||||
fn family_sheet(&mut self, ui: &mut Ui, s: &Skin, i: usize) -> bool {
|
||||
let ctx = ui.ctx().clone();
|
||||
let Some(index) = self.index() else { return true };
|
||||
let Some(family) = index.families().get(i).cloned() else { return true };
|
||||
let key = family_key(&family.source, &family.family);
|
||||
let installed: Vec<FaceInfo> = self.installed_by_name.get(&family.family.to_lowercase()).cloned().unwrap_or_default();
|
||||
let store: Vec<FaceInfo> = installed.iter().filter(|f| f.source == Source::Store).cloned().collect();
|
||||
let on_system = !installed.is_empty() && store.is_empty();
|
||||
let previewed = self.previewed.get(&key).cloned();
|
||||
let job = self.jobs.of(&family.source, &family.family).map(|j| (j.kind, j.file.clone(), j.fraction()));
|
||||
let (source, curated) = source_label(&index, &family);
|
||||
let eyebrow = [source.to_owned(), category_label(&family)].join(" · ");
|
||||
let by = (!family.designer.is_empty()).then(|| fmt("Designed by {designer}", &[("designer", &family.designer)]));
|
||||
let hero = regular(&installed).cloned().map(|f| (Owner::Installed, f));
|
||||
let hero = hero.or_else(|| previewed.as_deref().and_then(regular).cloned().map(|f| (Owner::Preview, f)));
|
||||
let close = self.sheet_header(ui, s, &eyebrow, &family.family, by, hero);
|
||||
|
||||
// Install or remove the whole family.
|
||||
let size = i18n::size(family.size());
|
||||
let all_installed =
|
||||
!installed.is_empty() && (on_system || family.files.iter().all(|f| store.iter().any(|x| file_name(x).as_deref() == Some(f.file_name()))));
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), s.h()), direction::row(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 10.0;
|
||||
match &job {
|
||||
Some((kind, None, fraction)) => {
|
||||
let what = match kind {
|
||||
Kind::Install => tl!("Installing"),
|
||||
Kind::Remove => tl!("Removing"),
|
||||
Kind::Preview => tl!("Downloading"),
|
||||
};
|
||||
self.progress(ui, s, what, *fraction, 220.0);
|
||||
}
|
||||
_ => {
|
||||
if all_installed {
|
||||
widgets::badge(ui, s, if on_system { tl!("Installed on this system") } else { tl!("Installed") }, s.p.good, true);
|
||||
} else if widgets::button(ui, s, tl!("Install all"), widgets::Kind::Primary, s.h(), 0.0).clicked() {
|
||||
self.ask(&ctx, Pending::Install(i));
|
||||
}
|
||||
if !store.is_empty() && widgets::button(ui, s, tl!("Remove all"), widgets::Kind::Danger, s.h(), 0.0).clicked() {
|
||||
self.remove(&ctx, &family.family, &store, true);
|
||||
}
|
||||
if installed.is_empty()
|
||||
&& previewed.is_none()
|
||||
&& widgets::button(ui, s, tl!("Preview"), widgets::Kind::Secondary, s.h(), 0.0).clicked()
|
||||
{
|
||||
self.ask(&ctx, Pending::Preview(i));
|
||||
}
|
||||
widgets::label(ui, s, &fmt("{size} download", &[("size", &size)]), s.p.faint);
|
||||
}
|
||||
}
|
||||
});
|
||||
if installed.is_empty() && previewed.is_none() {
|
||||
let note = fmt("Preview downloads this font ({size}) for this session without installing it.", &[("size", &size)]);
|
||||
ui.label(RichText::new(note).size(12.5).color(s.p.muted));
|
||||
}
|
||||
if !curated {
|
||||
ui.add_space(4.0);
|
||||
let added = index.source_of(&family).and_then(|f| f.user_url.clone()).unwrap_or_default();
|
||||
widgets::note(ui, s, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
ui.label(RichText::new(tl!("Not curated")).color(s.p.warn));
|
||||
let text = fmt(
|
||||
"This family comes from a source you added ({url}). Craft doesn't review its fonts or their licences.",
|
||||
&[("url", &added)],
|
||||
);
|
||||
ui.add(egui::Label::new(RichText::new(text).size(12.5).color(s.p.muted)).wrap());
|
||||
});
|
||||
});
|
||||
}
|
||||
if let Some(failure) = self.failed.get(&key).cloned() {
|
||||
ui.add_space(4.0);
|
||||
let what = match failure.kind {
|
||||
Kind::Install => tl!("Couldn't install"),
|
||||
Kind::Remove => tl!("Couldn't remove"),
|
||||
Kind::Preview => tl!("Couldn't download"),
|
||||
};
|
||||
let buttons: &[&str] = if failure.retry.is_some() { &[tl!("Retry")] } else { &[] };
|
||||
let mut retry = false;
|
||||
widgets::note_with_actions(
|
||||
ui,
|
||||
s,
|
||||
|ui| {
|
||||
ui.label(RichText::new(what).color(s.p.bad));
|
||||
ui.add(egui::Label::new(RichText::new(&failure.error).size(12.5).color(s.p.muted)).wrap());
|
||||
},
|
||||
buttons,
|
||||
|ui, _| retry = widgets::button(ui, s, tl!("Retry"), widgets::Kind::Secondary, 28.0, 0.0).clicked(),
|
||||
false,
|
||||
);
|
||||
if retry && let Some(pending) = failure.retry.clone() {
|
||||
self.ask(&ctx, pending);
|
||||
}
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
|
||||
let rows = self.family_rows(&family, &installed, &store, on_system, previewed.as_deref(), &job);
|
||||
let mut facts = family_facts(&index, &family, &size);
|
||||
let source_fact = if curated { source.to_owned() } else { format!("{source} · {}", tl!("Not curated")) };
|
||||
facts.insert(0, (tl!("Source").to_owned(), source_fact));
|
||||
// Most speakers first: the list is cut short.
|
||||
let mut languages: Vec<_> = family.languages(&index).filter(|l| !l.historical).collect();
|
||||
languages.sort_by(|a, b| b.population.cmp(&a.population).then_with(|| a.name.cmp(&b.name)));
|
||||
let languages: Vec<String> = languages.into_iter().map(|l| l.name.clone()).collect();
|
||||
self.sheet_body(ui, s, i, &family.family, &family.scripts, rows, facts, languages);
|
||||
close
|
||||
}
|
||||
|
||||
fn family_rows(
|
||||
&self,
|
||||
family: &Family,
|
||||
installed: &[FaceInfo],
|
||||
store: &[FaceInfo],
|
||||
on_system: bool,
|
||||
previewed: Option<&[FaceInfo]>,
|
||||
job: &Option<(Kind, Option<String>, Option<f32>)>,
|
||||
) -> Vec<StyleRow> {
|
||||
let running = |path: &str| match job {
|
||||
Some((Kind::Install, Some(p), fraction)) if p == path => Some(*fraction),
|
||||
_ => None,
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
for face in installed {
|
||||
let action = match job {
|
||||
Some((Kind::Remove, Some(p), _)) if face.path.as_ref().is_some_and(|x| x.display().to_string() == *p) => RowAction::Running(None),
|
||||
_ if face.source == Source::Store => RowAction::Remove(Box::new(face.clone())),
|
||||
_ => RowAction::None,
|
||||
};
|
||||
rows.push(StyleRow { label: style_name(face).to_owned(), face: Some((Owner::Installed, face.clone())), action });
|
||||
}
|
||||
if on_system {
|
||||
return rows;
|
||||
}
|
||||
for file in &family.files {
|
||||
if store.iter().any(|x| file_name(x).as_deref() == Some(file.file_name())) {
|
||||
continue;
|
||||
}
|
||||
let preview_faces: Vec<&FaceInfo> =
|
||||
previewed.unwrap_or_default().iter().filter(|f| file_of(family, f).is_some_and(|x| x.path == file.path)).collect();
|
||||
let action = match running(&file.path) {
|
||||
Some(fraction) => RowAction::Running(fraction),
|
||||
None => RowAction::Install(file.path.clone()),
|
||||
};
|
||||
if preview_faces.is_empty() {
|
||||
rows.push(StyleRow { label: file_label(file), face: None, action });
|
||||
} else {
|
||||
for (n, face) in preview_faces.iter().enumerate() {
|
||||
// A variable file's instances install together: the file's action on its first.
|
||||
let action = if n == 0 { action_clone(&action) } else { RowAction::None };
|
||||
rows.push(StyleRow { label: style_name(face).to_owned(), face: Some((Owner::Preview, (*face).clone())), action });
|
||||
}
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn installed_sheet(&mut self, ui: &mut Ui, s: &Skin, name: &str, source: Source) -> bool {
|
||||
let ctx = ui.ctx().clone();
|
||||
let Some(family) = self.installed.iter().find(|f| f.name == name && f.source == source).cloned() else { return true };
|
||||
let hero = regular(&family.faces).cloned().map(|f| (Owner::Installed, f));
|
||||
let close = self.sheet_header(ui, s, source_name(source), &family.name, None, hero);
|
||||
let job = self.jobs.of("", &family.name).map(|j| (j.kind, j.file.clone(), j.fraction()));
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), s.h()), direction::row(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 10.0;
|
||||
if let Some((Kind::Remove, None, _)) = &job {
|
||||
self.progress(ui, s, tl!("Removing"), None, 220.0);
|
||||
return;
|
||||
}
|
||||
let remove =
|
||||
ui.add_enabled_ui(family.removable(), |ui| widgets::button(ui, s, tl!("Remove all"), widgets::Kind::Danger, s.h(), 0.0)).inner;
|
||||
if !family.removable() {
|
||||
let _ = remove.on_disabled_hover_text(tl!("System fonts are removed with your system's own font tools."));
|
||||
widgets::label(ui, s, tl!("Removed with your system's font tools"), s.p.faint);
|
||||
} else if remove.clicked() {
|
||||
self.remove(&ctx, &family.name, &family.faces, true);
|
||||
}
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
let rows = family
|
||||
.faces
|
||||
.iter()
|
||||
.map(|face| StyleRow {
|
||||
label: style_name(face).to_owned(),
|
||||
face: Some((Owner::Installed, face.clone())),
|
||||
action: if face.source == Source::Store { RowAction::Remove(Box::new(face.clone())) } else { RowAction::None },
|
||||
})
|
||||
.collect();
|
||||
let mut files: Vec<_> = family.faces.iter().filter_map(|f| f.path.clone()).collect();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
let facts = vec![
|
||||
(tl!("Source").to_owned(), source_name(source).to_owned()),
|
||||
(tl!("Styles").to_owned(), i18n::number(family.faces.len() as u64)),
|
||||
(tl!("Files").to_owned(), i18n::number(files.len() as u64)),
|
||||
];
|
||||
let scripts = self.index().and_then(|index| index.family(&family.name).map(|f| f.scripts.clone())).unwrap_or_default();
|
||||
self.sheet_body(ui, s, usize::MAX, &family.name, &scripts, rows, facts, Vec::new());
|
||||
close
|
||||
}
|
||||
|
||||
fn progress(&self, ui: &mut Ui, s: &Skin, what: &str, fraction: Option<f32>, width: f32) {
|
||||
let text = match fraction {
|
||||
Some(f) => format!("{what} {}", fmt("{percent} %", &[("percent", &i18n::number((f * 100.0).round() as u64))])),
|
||||
None => format!("{what}…"),
|
||||
};
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, s.h()), Sense::hover());
|
||||
let galley = s.label_galley(ui, &text, s.p.muted);
|
||||
widgets::paint_start(ui, rect.with_max_y(rect.bottom() - 10.0), galley, s.p.muted);
|
||||
widgets::progress_bar(ui, s, egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - 4.0), rect.max), fraction);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ProgressIndicator, true, &text));
|
||||
}
|
||||
|
||||
/// The styles, and beside them (or below, when narrow) the facts and languages.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sheet_body(
|
||||
&mut self,
|
||||
ui: &mut Ui,
|
||||
s: &Skin,
|
||||
family_index: usize,
|
||||
family: &str,
|
||||
scripts: &[String],
|
||||
rows: Vec<StyleRow>,
|
||||
facts: Vec<(String, String)>,
|
||||
languages: Vec<String>,
|
||||
) {
|
||||
let total = ui.available_width();
|
||||
let wide = total >= COLUMNS_FROM;
|
||||
let gap = 32.0;
|
||||
let side_w = if wide { (total * 0.32).clamp(240.0, 320.0) } else { total };
|
||||
let styles_w = if wide { total - side_w - gap } else { total };
|
||||
let rtl = direction::is_rtl(ui);
|
||||
let layout =
|
||||
if wide { if rtl { Layout::right_to_left(Align::Min) } else { Layout::left_to_right(Align::Min) } } else { direction::column(ui) };
|
||||
ui.allocate_ui_with_layout(vec2(total, 0.0), layout, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(gap, 14.0);
|
||||
ui.allocate_ui_with_layout(vec2(styles_w, 0.0), direction::column(ui), |ui| {
|
||||
ui.set_width(styles_w);
|
||||
self.style_rows(ui, s, family_index, family, scripts, rows);
|
||||
});
|
||||
ui.allocate_ui_with_layout(vec2(side_w, 0.0), direction::column(ui), |ui| {
|
||||
ui.set_width(side_w);
|
||||
facts_box(ui, s, &facts);
|
||||
if !languages.is_empty() {
|
||||
ui.add_space(8.0);
|
||||
let title = fmt("Languages · {n}", &[("n", &i18n::number(languages.len() as u64))]);
|
||||
widgets::label(ui, s, &title, s.p.faint);
|
||||
const SHOWN: usize = 60;
|
||||
let mut text = languages.iter().take(SHOWN).cloned().collect::<Vec<_>>().join(", ");
|
||||
if languages.len() > SHOWN {
|
||||
text = fmt("{list} and {n} more", &[("list", &text), ("n", &i18n::number((languages.len() - SHOWN) as u64))]);
|
||||
}
|
||||
ui.add(egui::Label::new(RichText::new(text).size(13.0).color(s.p.muted)).wrap());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn style_rows(&mut self, ui: &mut Ui, s: &Skin, family_index: usize, family: &str, scripts: &[String], rows: Vec<StyleRow>) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let installed = rows.iter().filter(|r| matches!(r.face, Some((Owner::Installed, _)))).count();
|
||||
let title = if installed == rows.len() {
|
||||
tn(rows.len() as u64, "{n} style installed", "{n} styles installed")
|
||||
} else {
|
||||
tn(rows.len() as u64, "{n} style", "{n} styles")
|
||||
};
|
||||
widgets::label(ui, s, &title, s.p.faint);
|
||||
let sample = self.sample_for(scripts.iter().map(String::as_str));
|
||||
let size = self.settings.size.max(24.0);
|
||||
let width = ui.available_width();
|
||||
for row in rows {
|
||||
let top = ui.cursor().top();
|
||||
ui.painter().hline(ui.max_rect().x_range(), top, s.hairline());
|
||||
ui.add_space(10.0);
|
||||
ui.allocate_ui_with_layout(vec2(width, 28.0), direction::row(ui), |ui| {
|
||||
widgets::label(ui, s, &row.label, s.p.muted);
|
||||
ui.with_layout(direction::trailing(ui), |ui| match &row.action {
|
||||
RowAction::None => {}
|
||||
RowAction::Running(fraction) => self.progress(ui, s, tl!("Installing"), *fraction, 140.0),
|
||||
RowAction::Install(path) => {
|
||||
let install = widgets::button(ui, s, tl!("Install"), widgets::Kind::Secondary, 28.0, 0.0);
|
||||
widgets::name(ui, &install, &fmt("Install {style}", &[("style", &row.label)]));
|
||||
if install.clicked() && family_index != usize::MAX {
|
||||
self.ask(&ctx, Pending::InstallFile(family_index, path.clone()));
|
||||
}
|
||||
}
|
||||
RowAction::Remove(face) => {
|
||||
let remove = widgets::button(ui, s, tl!("Remove"), widgets::Kind::Danger, 28.0, 0.0);
|
||||
widgets::name(ui, &remove, &fmt("Remove {style}", &[("style", &row.label)]));
|
||||
if remove.clicked() {
|
||||
self.remove(&ctx, family, std::slice::from_ref(face), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
self.wrapped_sample(ui, s, row.face.as_ref(), &sample, size, width);
|
||||
ui.add_space(12.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` in the face, wrapped to `width`; faint in the interface font when the face isn't here.
|
||||
fn wrapped_sample(&mut self, ui: &mut Ui, s: &Skin, face: Option<&(Owner, FaceInfo)>, text: &str, size: f32, width: f32) {
|
||||
let (family, color) = match face {
|
||||
Some((owner, face)) => {
|
||||
let catalog = match owner {
|
||||
Owner::Installed => Arc::clone(&self.catalog),
|
||||
Owner::Preview => Arc::clone(&self.previews),
|
||||
};
|
||||
match self.faces.family(ui.ctx(), &catalog, *owner, face) {
|
||||
crate::panel::faces::Shown::Ready(family) => (family, s.p.ink),
|
||||
crate::panel::faces::Shown::Loading => {
|
||||
ui.allocate_space(vec2(width, size * 1.3));
|
||||
return;
|
||||
}
|
||||
crate::panel::faces::Shown::Unavailable => (FontFamily::Proportional, s.p.faint),
|
||||
}
|
||||
}
|
||||
None => (FontFamily::Proportional, s.p.faint),
|
||||
};
|
||||
let size = if face.is_some() { size } else { size.min(20.0) };
|
||||
// Sample text keeps its own direction whatever the interface's.
|
||||
let align = if craft_i18n::base_direction(text) == Some(craft_i18n::Direction::Rtl) { Align::Max } else { Align::Min };
|
||||
ui.allocate_ui_with_layout(vec2(width, 0.0), Layout::top_down(align), |ui| {
|
||||
ui.add(egui::Label::new(RichText::new(text).font(FontId::new(size, family)).color(color)).wrap());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn action_clone(action: &RowAction) -> RowAction {
|
||||
match action {
|
||||
RowAction::None => RowAction::None,
|
||||
RowAction::Install(p) => RowAction::Install(p.clone()),
|
||||
RowAction::Remove(f) => RowAction::Remove(f.clone()),
|
||||
RowAction::Running(f) => RowAction::Running(*f),
|
||||
}
|
||||
}
|
||||
|
||||
fn family_facts(index: &Index, family: &Family, size: &str) -> Vec<(String, String)> {
|
||||
let scripts: Vec<&str> = family.scripts.iter().map(|id| script_name(index, id).unwrap_or(id)).collect();
|
||||
let mut facts = Vec::new();
|
||||
if !family.designer.is_empty() {
|
||||
facts.push((tl!("Designer").to_owned(), family.designer.clone()));
|
||||
}
|
||||
facts.push((tl!("Licence").to_owned(), licence_name(&family.license).to_owned()));
|
||||
facts.push((tl!("Styles").to_owned(), i18n::number(style_count(family) as u64)));
|
||||
facts.push((tl!("Files").to_owned(), i18n::number(family.files.len() as u64)));
|
||||
facts.push((tl!("Download size").to_owned(), size.to_owned()));
|
||||
if !scripts.is_empty() {
|
||||
facts.push((tl!("Scripts").to_owned(), scripts.join(", ")));
|
||||
}
|
||||
facts
|
||||
}
|
||||
|
||||
/// Facts in a hairline box, each a small label over its value.
|
||||
fn facts_box(ui: &mut Ui, s: &Skin, facts: &[(String, String)]) {
|
||||
egui::Frame::new().stroke(s.hairline()).corner_radius(s.look.corner).inner_margin(Margin::symmetric(14, 12)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.spacing_mut().item_spacing.y = 3.0;
|
||||
for (n, (label, value)) in facts.iter().enumerate() {
|
||||
if n > 0 {
|
||||
ui.add_space(6.0);
|
||||
let y = ui.cursor().top();
|
||||
ui.painter().hline(ui.max_rect().x_range(), y, s.hairline());
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
widgets::label(ui, s, label, s.p.faint);
|
||||
ui.add(egui::Label::new(RichText::new(value).color(s.p.ink)).wrap());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Drawing text in a font face itself: each face shown becomes an egui font family of its own.
|
||||
//!
|
||||
//! Adding a font makes egui rebuild its fonts, so faces are added a few per frame, and once more
|
||||
//! than [`MAX_REGISTERED`] are loaded the ones not drawn in the last frame are taken out again,
|
||||
//! which keeps memory to the faces on screen. Faces still on screen stay, however many there are,
|
||||
//! so a full screen doesn't load its faces again every frame. Only the panel's own faces are
|
||||
//! touched: the host's fonts stay as they are.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{Catalog, FaceInfo};
|
||||
use egui::epaint::text::{Blob, FontInsert, FontPriority, InsertFontFamily, VariationCoords};
|
||||
use egui::{Context, FontData, FontFamily, FontTweak};
|
||||
|
||||
const MAX_REGISTERED: usize = 64;
|
||||
const PER_FRAME: usize = 6;
|
||||
/// Larger files are not drawn: a whole CJK family can be hundreds of megabytes.
|
||||
const MAX_FILE_BYTES: u64 = 48 << 20;
|
||||
|
||||
/// Whose catalog a face belongs to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Owner {
|
||||
/// The installed fonts.
|
||||
Installed,
|
||||
/// Files downloaded for a preview.
|
||||
Preview,
|
||||
}
|
||||
|
||||
pub enum Shown {
|
||||
Ready(FontFamily),
|
||||
Loading,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FaceFonts {
|
||||
/// Each face's family and the frame it was last drawn in.
|
||||
registered: HashMap<String, (FontFamily, u64)>,
|
||||
failed: HashSet<String>,
|
||||
budget: usize,
|
||||
frame: u64,
|
||||
}
|
||||
|
||||
fn key(owner: Owner, face: &FaceInfo) -> String {
|
||||
let file = match &face.path {
|
||||
Some(path) => path.display().to_string(),
|
||||
None => face.postscript_name.clone().or_else(|| face.full_name.clone()).unwrap_or_else(|| face.family.clone()),
|
||||
};
|
||||
let coords: String = face.variations.iter().map(|(tag, v)| format!(" {}={v}", String::from_utf8_lossy(tag))).collect();
|
||||
format!("face:{owner:?}:{file}#{}{coords}", face.index)
|
||||
}
|
||||
|
||||
impl FaceFonts {
|
||||
pub fn begin_frame(&mut self, ctx: &Context) {
|
||||
self.budget = PER_FRAME;
|
||||
let stale = self.next_frame();
|
||||
if !stale.is_empty() {
|
||||
let mut definitions = ctx.fonts(|f| f.definitions().clone());
|
||||
definitions.font_data.retain(|name, _| !stale.contains_key(name));
|
||||
definitions.families.retain(|family, _| !stale.values().any(|f| f == family));
|
||||
ctx.set_fonts(definitions);
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a frame, and once more than [`MAX_REGISTERED`] faces are registered takes out the
|
||||
/// ones the last frame didn't draw, returning them.
|
||||
fn next_frame(&mut self) -> HashMap<String, FontFamily> {
|
||||
self.frame += 1;
|
||||
if self.registered.len() <= MAX_REGISTERED {
|
||||
return HashMap::new();
|
||||
}
|
||||
let last = self.frame - 1;
|
||||
let stale: Vec<String> = self.registered.iter().filter(|(_, (_, used))| *used < last).map(|(k, _)| k.clone()).collect();
|
||||
stale.into_iter().filter_map(|k| self.registered.remove(&k).map(|(family, _)| (k, family))).collect()
|
||||
}
|
||||
|
||||
/// The family that draws `face`, once egui has loaded it.
|
||||
pub fn family(&mut self, ctx: &Context, catalog: &Catalog, owner: Owner, face: &FaceInfo) -> Shown {
|
||||
let key = key(owner, face);
|
||||
if self.failed.contains(&key) {
|
||||
return Shown::Unavailable;
|
||||
}
|
||||
if let Some((family, used)) = self.registered.get_mut(&key) {
|
||||
*used = self.frame;
|
||||
let loaded = ctx.fonts(|f| f.definitions().families.contains_key(family));
|
||||
if loaded {
|
||||
return Shown::Ready(family.clone());
|
||||
}
|
||||
ctx.request_repaint();
|
||||
return Shown::Loading;
|
||||
}
|
||||
if self.budget == 0 {
|
||||
ctx.request_repaint();
|
||||
return Shown::Loading;
|
||||
}
|
||||
self.budget -= 1;
|
||||
let too_large = face.path.as_ref().and_then(|p| std::fs::metadata(p).ok()).is_some_and(|m| m.len() > MAX_FILE_BYTES);
|
||||
let data = if too_large { None } else { catalog.load(face).ok() };
|
||||
let Some(data) = data else {
|
||||
self.failed.insert(key);
|
||||
return Shown::Unavailable;
|
||||
};
|
||||
let blob: Blob = Arc::new(data.bytes);
|
||||
let tweak = FontTweak { coords: VariationCoords::new(face.variations.iter().map(|(tag, v)| (*tag, *v))), ..Default::default() };
|
||||
let family = FontFamily::Name(key.as_str().into());
|
||||
let insert = InsertFontFamily { family: family.clone(), priority: FontPriority::Highest };
|
||||
ctx.add_font(FontInsert::new(&key, FontData::from_blob(blob, data.index).tweak(tweak), vec![insert]));
|
||||
self.registered.insert(key, (family, self.frame));
|
||||
ctx.request_repaint();
|
||||
Shown::Loading
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_faces_off_screen_are_taken_out() {
|
||||
let mut faces = FaceFonts::default();
|
||||
let register = |faces: &mut FaceFonts, range: std::ops::Range<usize>| {
|
||||
for n in range {
|
||||
faces.registered.insert(format!("face:{n}"), (FontFamily::Name(format!("face:{n}").into()), faces.frame));
|
||||
}
|
||||
};
|
||||
faces.next_frame();
|
||||
register(&mut faces, 0..MAX_REGISTERED);
|
||||
assert!(faces.next_frame().is_empty());
|
||||
// A screen of more faces than the limit keeps them all, frame after frame.
|
||||
register(&mut faces, 0..MAX_REGISTERED + 10);
|
||||
assert!(faces.next_frame().is_empty());
|
||||
register(&mut faces, 0..MAX_REGISTERED + 10);
|
||||
assert!(faces.next_frame().is_empty());
|
||||
// Scrolled on: the faces the last frame didn't draw go, and only those.
|
||||
register(&mut faces, 20..MAX_REGISTERED + 20);
|
||||
let stale = faces.next_frame();
|
||||
assert_eq!(stale.len(), 20);
|
||||
assert!(stale.keys().all(|k| k[5..].parse::<usize>().unwrap() < 20));
|
||||
assert_eq!(faces.registered.len(), MAX_REGISTERED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Interface strings through craft-i18n: the English text is the lookup key.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use craft_i18n::{Embedded, FileSize, I18n, Lang};
|
||||
|
||||
/// The embedded translation catalogs: the panel's own Japanese and Arabic rows, until its rows
|
||||
/// are part of craft-i18n.
|
||||
static CATALOGS: &Embedded = &[("ja", "", include_str!("catalogs/ja.tsv")), ("ar", "", include_str!("catalogs/ar.tsv"))];
|
||||
|
||||
/// The interface languages offered, in menu order.
|
||||
pub const LANGUAGES: &[&str] = &["en", "ja", "ar"];
|
||||
|
||||
pub static I18N: LazyLock<I18n> = LazyLock::new(|| {
|
||||
let (i18n, problems) = I18n::new(CATALOGS, LANGUAGES);
|
||||
for problem in problems {
|
||||
log::warn!("translation catalog: {problem}; that string shows in English");
|
||||
}
|
||||
i18n
|
||||
});
|
||||
|
||||
thread_local! {
|
||||
static CURRENT: Cell<Lang> = const { Cell::new(Lang::EN) };
|
||||
}
|
||||
|
||||
/// The interface language.
|
||||
pub fn current() -> Lang {
|
||||
CURRENT.get()
|
||||
}
|
||||
|
||||
pub fn set_current(lang: Lang) {
|
||||
CURRENT.set(lang);
|
||||
}
|
||||
|
||||
/// Translates a literal.
|
||||
macro_rules! tl {
|
||||
($s:expr) => {
|
||||
$crate::panel::i18n::I18N.t($crate::panel::i18n::current(), $s)
|
||||
};
|
||||
}
|
||||
pub(crate) use tl;
|
||||
|
||||
/// Translates `template` and fills its `{name}` placeholders.
|
||||
pub fn fmt(template: &str, args: &[(&str, &str)]) -> String {
|
||||
craft_i18n::fmt(I18N.t(current(), template), args)
|
||||
}
|
||||
|
||||
/// A count with its noun, `{n}` in the English forms.
|
||||
pub fn tn(n: u64, one: &str, other: &str) -> String {
|
||||
I18N.trn(current(), n, one, other)
|
||||
}
|
||||
|
||||
/// A download size, `1.5 MB`.
|
||||
pub fn size(bytes: u64) -> String {
|
||||
I18N.file_size(current(), bytes, FileSize::DECIMAL)
|
||||
}
|
||||
|
||||
/// A count on its own, in the region's digits and grouping.
|
||||
pub fn number(n: u64) -> String {
|
||||
I18N.format().integer(n)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every row reads. The strings the panel shows without a row are listed (run with
|
||||
/// `--nocapture`); they show in English, so a missing row never fails.
|
||||
#[test]
|
||||
fn catalogs_load_and_missing_rows_are_listed() {
|
||||
let (_, problems) = I18n::new(CATALOGS, LANGUAGES);
|
||||
assert!(problems.is_empty(), "{problems:#?}");
|
||||
// English-only by design: style and weight names, licence and product names.
|
||||
let english = [
|
||||
"Thin",
|
||||
"ExtraLight",
|
||||
"Light",
|
||||
"Regular",
|
||||
"Medium",
|
||||
"SemiBold",
|
||||
"Bold",
|
||||
"ExtraBold",
|
||||
"Black",
|
||||
"Italic",
|
||||
"Variable {from}–{to}",
|
||||
"SIL Open Font License",
|
||||
"Apache License 2.0",
|
||||
"Ubuntu Font Licence",
|
||||
"Google Fonts",
|
||||
"{source}: {error}",
|
||||
];
|
||||
let sources = [include_str!("app.rs"), include_str!("browse.rs"), include_str!("details.rs"), include_str!("sources.rs")];
|
||||
let mut keys = Vec::new();
|
||||
for source in sources {
|
||||
for marker in ["tl!(", "fmt("] {
|
||||
for part in source.split(marker).skip(1) {
|
||||
if let Some(key) = part.trim_start().strip_prefix('"').and_then(|p| p.find('"').map(|end| &p[..end])) {
|
||||
keys.push(key.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
keys.retain(|k| !english.contains(&k.as_str()));
|
||||
assert!(keys.len() > 60, "{}", keys.len());
|
||||
for lang in ["ja", "ar"] {
|
||||
let lang = I18N.match_tag(lang).unwrap_or(Lang::EN);
|
||||
let missing: Vec<_> = keys.iter().filter(|k| I18N.t(lang, k) == k.as_str()).collect();
|
||||
if !missing.is_empty() {
|
||||
println!("{} is missing {} strings: {missing:#?}", I18N.code(lang), missing.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Installs, removals and preview downloads, one at a time on a worker thread.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
|
||||
use crate::Catalog;
|
||||
use crate::index::Index;
|
||||
use crate::store::{Downloaded, Installer, Progress, RemoveRequest, StoreError};
|
||||
|
||||
type Task = Box<dyn FnOnce() + Send>;
|
||||
|
||||
/// Where jobs run.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Runner {
|
||||
/// On a worker thread.
|
||||
Thread,
|
||||
/// Right away, on the calling thread (tests).
|
||||
Inline,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Install,
|
||||
Remove,
|
||||
Preview,
|
||||
}
|
||||
|
||||
pub struct Running {
|
||||
/// The family's source; empty for a removal, which goes by the installed files.
|
||||
pub source: String,
|
||||
pub family: String,
|
||||
pub kind: Kind,
|
||||
/// One file's index path or store path, when the job is for one style.
|
||||
pub file: Option<String>,
|
||||
pub progress: Progress,
|
||||
}
|
||||
|
||||
impl Running {
|
||||
/// How far along, when the size is known.
|
||||
pub fn fraction(&self) -> Option<f32> {
|
||||
(self.progress.total > 0).then(|| (self.progress.done as f32 / self.progress.total as f32).clamp(0.0, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Finished {
|
||||
Installed,
|
||||
Removed,
|
||||
Previewed(Vec<Downloaded>),
|
||||
}
|
||||
|
||||
enum Msg {
|
||||
Progress(u64, Progress),
|
||||
Done(u64, Result<Finished, StoreError>),
|
||||
}
|
||||
|
||||
pub enum Request {
|
||||
/// A family of the index, by position.
|
||||
Install(Arc<Index>, usize),
|
||||
/// One file of a family of the index, by its path in the index.
|
||||
InstallFile(Arc<Index>, usize, String),
|
||||
Remove(RemoveRequest),
|
||||
/// A family of the index, by position.
|
||||
Preview(Arc<Index>, usize),
|
||||
}
|
||||
|
||||
pub struct Jobs {
|
||||
worker: Option<Sender<Task>>,
|
||||
tx: Sender<Msg>,
|
||||
rx: Receiver<Msg>,
|
||||
next: u64,
|
||||
running: BTreeMap<u64, Running>,
|
||||
}
|
||||
|
||||
impl Jobs {
|
||||
pub fn new(runner: Runner) -> Jobs {
|
||||
let (tx, rx) = channel();
|
||||
let worker = match runner {
|
||||
Runner::Inline => None,
|
||||
Runner::Thread => {
|
||||
let (worker, tasks) = channel::<Task>();
|
||||
let spawned = std::thread::Builder::new().name("font-jobs".into()).spawn(move || {
|
||||
for task in tasks {
|
||||
task();
|
||||
}
|
||||
});
|
||||
if let Err(e) = &spawned {
|
||||
log::warn!("no worker thread ({e}); font jobs run on the interface thread");
|
||||
}
|
||||
spawned.ok().map(|_| worker)
|
||||
}
|
||||
};
|
||||
Jobs { worker, tx, rx, next: 0, running: BTreeMap::new() }
|
||||
}
|
||||
|
||||
/// The job running for `family` of `source` (any source when empty), if any.
|
||||
pub fn of(&self, source: &str, family: &str) -> Option<&Running> {
|
||||
self.running.values().find(|r| r.family.eq_ignore_ascii_case(family) && (source.is_empty() || r.source.is_empty() || r.source == source))
|
||||
}
|
||||
|
||||
/// Runs `request` through `installer`, then rescans `catalog` after an install or removal.
|
||||
pub fn start(&mut self, ctx: &egui::Context, installer: Arc<dyn Installer>, catalog: Arc<Catalog>, request: Request) {
|
||||
let id = self.next;
|
||||
self.next += 1;
|
||||
let (source, family, kind, file, total) = match &request {
|
||||
Request::Install(index, i) | Request::Preview(index, i) => {
|
||||
let Some(f) = index.families().get(*i) else { return };
|
||||
let kind = if matches!(request, Request::Install(..)) { Kind::Install } else { Kind::Preview };
|
||||
(f.source.clone(), f.family.clone(), kind, None, f.size())
|
||||
}
|
||||
Request::InstallFile(index, i, path) => {
|
||||
let Some(f) = index.families().get(*i) else { return };
|
||||
let size = f.files.iter().find(|x| &x.path == path).map_or(0, |x| x.size);
|
||||
(f.source.clone(), f.family.clone(), Kind::Install, Some(path.clone()), size)
|
||||
}
|
||||
Request::Remove(r) => {
|
||||
let file = if r.whole { None } else { r.files.first().map(|p| p.display().to_string()) };
|
||||
(String::new(), r.family.clone(), Kind::Remove, file, 0)
|
||||
}
|
||||
};
|
||||
self.running.insert(id, Running { source, family, kind, file, progress: Progress { done: 0, total } });
|
||||
let tx = self.tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let task: Task = Box::new(move || {
|
||||
let mut progress = |p: Progress| {
|
||||
let _ = tx.send(Msg::Progress(id, p));
|
||||
ctx.request_repaint();
|
||||
};
|
||||
let family = |index: &Index, i: usize| index.families().get(i).cloned().ok_or_else(|| StoreError("no such family".into()));
|
||||
let result = match request {
|
||||
Request::Install(index, i) => {
|
||||
family(&index, i).and_then(|f| installer.install(&index, &f, &mut progress)).map(|()| Finished::Installed)
|
||||
}
|
||||
Request::InstallFile(index, i, path) => {
|
||||
family(&index, i).and_then(|f| installer.install_file(&index, &f, &path, &mut progress)).map(|()| Finished::Installed)
|
||||
}
|
||||
Request::Remove(r) => installer.remove(&r).map(|()| Finished::Removed),
|
||||
Request::Preview(index, i) => family(&index, i).and_then(|f| installer.download(&index, &f, &mut progress)).map(Finished::Previewed),
|
||||
};
|
||||
if matches!(result, Ok(Finished::Installed | Finished::Removed)) {
|
||||
catalog.rescan();
|
||||
}
|
||||
let _ = tx.send(Msg::Done(id, result));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
match &self.worker {
|
||||
Some(worker) => {
|
||||
if let Err(e) = worker.send(task) {
|
||||
(e.0)();
|
||||
}
|
||||
}
|
||||
None => task(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the progress reports, and returns the jobs that ended.
|
||||
pub fn poll(&mut self) -> Vec<(Running, Result<Finished, StoreError>)> {
|
||||
let mut done = Vec::new();
|
||||
while let Ok(msg) = self.rx.try_recv() {
|
||||
match msg {
|
||||
Msg::Progress(id, p) => {
|
||||
if let Some(job) = self.running.get_mut(&id) {
|
||||
job.progress = p;
|
||||
}
|
||||
}
|
||||
Msg::Done(id, result) => {
|
||||
if let Some(job) = self.running.remove(&id) {
|
||||
done.push((job, result));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
done
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! The colours and type the panel draws with, so it reads as part of the app that shows it.
|
||||
|
||||
use egui::{Color32, Context, CornerRadius, FontFamily, Stroke, Visuals};
|
||||
|
||||
/// Colours for one appearance.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Palette {
|
||||
/// Page and card surface.
|
||||
pub bg: Color32,
|
||||
/// Fields, wells, hovered rows.
|
||||
pub sunken: Color32,
|
||||
/// Body text.
|
||||
pub ink: Color32,
|
||||
/// Headings and family names.
|
||||
pub ink_strong: Color32,
|
||||
/// Secondary text.
|
||||
pub muted: Color32,
|
||||
/// Labels and tertiary text.
|
||||
pub faint: Color32,
|
||||
/// Hairlines.
|
||||
pub line: Color32,
|
||||
/// Outlined buttons and stronger rules.
|
||||
pub line_strong: Color32,
|
||||
/// Selection and progress.
|
||||
pub accent: Color32,
|
||||
/// The accent for text and focus rings.
|
||||
pub accent_ink: Color32,
|
||||
/// Primary buttons and the chosen option.
|
||||
pub invert_bg: Color32,
|
||||
pub invert_fg: Color32,
|
||||
pub good: Color32,
|
||||
pub warn: Color32,
|
||||
pub bad: Color32,
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
/// A palette from egui's own visuals, for a host that sets none.
|
||||
pub fn from_visuals(v: &Visuals) -> Palette {
|
||||
let ink = v.text_color();
|
||||
Palette {
|
||||
bg: v.panel_fill,
|
||||
sunken: v.extreme_bg_color,
|
||||
ink,
|
||||
ink_strong: v.strong_text_color(),
|
||||
muted: ink.gamma_multiply(0.75),
|
||||
faint: ink.gamma_multiply(0.5),
|
||||
line: v.widgets.noninteractive.bg_stroke.color,
|
||||
line_strong: v.widgets.inactive.fg_stroke.color.gamma_multiply(0.6),
|
||||
accent: v.selection.bg_fill,
|
||||
accent_ink: v.hyperlink_color,
|
||||
invert_bg: v.strong_text_color(),
|
||||
invert_fg: v.panel_fill,
|
||||
good: if v.dark_mode { Color32::from_rgb(0x3f, 0xd6, 0xb8) } else { Color32::from_rgb(0x0a, 0x75, 0x63) },
|
||||
warn: v.warn_fg_color,
|
||||
bad: v.error_fg_color,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hairline(&self) -> Stroke {
|
||||
Stroke::new(1.0, self.line)
|
||||
}
|
||||
}
|
||||
|
||||
/// How the panel looks: the host's colours for both appearances and the font families it
|
||||
/// registered. [`Look::default`] follows egui's visuals and default families.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Look {
|
||||
/// `None`: from the context's visuals.
|
||||
pub dark: Option<Palette>,
|
||||
pub light: Option<Palette>,
|
||||
/// Page titles.
|
||||
pub display: FontFamily,
|
||||
/// Family names on cards.
|
||||
pub strong: FontFamily,
|
||||
/// Labels, counts and badges.
|
||||
pub label: FontFamily,
|
||||
/// Button text.
|
||||
pub button: FontFamily,
|
||||
/// Labels and buttons set upper case and spaced out.
|
||||
pub caps: bool,
|
||||
pub corner: CornerRadius,
|
||||
/// Buttons, fields and option cells.
|
||||
pub control_height: f32,
|
||||
}
|
||||
|
||||
impl Default for Look {
|
||||
fn default() -> Self {
|
||||
Look {
|
||||
dark: None,
|
||||
light: None,
|
||||
display: FontFamily::Proportional,
|
||||
strong: FontFamily::Proportional,
|
||||
label: FontFamily::Proportional,
|
||||
button: FontFamily::Proportional,
|
||||
caps: false,
|
||||
corner: CornerRadius::same(3),
|
||||
control_height: 30.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Look {
|
||||
/// The palette for the context's current appearance.
|
||||
pub fn palette(&self, ctx: &Context) -> Palette {
|
||||
let dark = ctx.theme() == egui::Theme::Dark;
|
||||
match if dark { self.dark } else { self.light } {
|
||||
Some(p) => p,
|
||||
None => Palette::from_visuals(&ctx.global_style().visuals),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! The egui interface: the installed fonts, the Google Fonts catalogue, and installing and
|
||||
//! removing through the font store.
|
||||
|
||||
mod i18n;
|
||||
|
||||
mod app;
|
||||
mod browse;
|
||||
mod details;
|
||||
mod faces;
|
||||
mod jobs;
|
||||
mod look;
|
||||
mod samples;
|
||||
mod sources;
|
||||
mod widgets;
|
||||
|
||||
pub use app::{FontsPanel, Options, Preset, Settings, Tab, install_interface_fonts};
|
||||
pub use jobs::Runner;
|
||||
pub use look::{Look, Palette};
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Sample text: a sentence in the language the catalogue is filtered by, or in a family's own
|
||||
//! script when it has no Latin, so a sample never comes out as boxes.
|
||||
|
||||
/// The English pangram, the sample for Latin.
|
||||
pub const PANGRAM: &str = "The quick brown fox jumps over the lazy dog";
|
||||
pub const ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz";
|
||||
pub const NUMERALS: &str = "0123456789 & ? ! @ # % ( ) [ ] { } $ € £ ¥";
|
||||
|
||||
/// Sentences by language code (gflanguages' code before the script).
|
||||
const BY_LANGUAGE: &[(&str, &str)] = &[
|
||||
("ar", "صف خلق خود كمثل الشمس إذ بزغت يحظى الضجيع بها نجلاء معطار"),
|
||||
("bn", "সমস্ত মানুষ স্বাধীনভাবে সমান মর্যাদা এবং অধিকার নিয়ে জন্মগ্রহণ করে"),
|
||||
("cs", "Příliš žluťoučký kůň úpěl ďábelské ódy"),
|
||||
("de", "Victor jagt zwölf Boxkämpfer quer über den großen Sylter Deich"),
|
||||
("el", "Ξεσκεπάζω την ψυχοφθόρα βδελυγμία"),
|
||||
("en", PANGRAM),
|
||||
("es", "El veloz murciélago hindú comía feliz cardillo y kiwi"),
|
||||
("fa", "همه افراد بشر آزاد به دنیا میآیند"),
|
||||
("fr", "Portez ce vieux whisky au juge blond qui fume"),
|
||||
("he", "דג סקרן שט בים מאוכזב ולפתע מצא חברה"),
|
||||
("hi", "सभी मनुष्यों को गौरव और अधिकारों के मामले में जन्मजात स्वतन्त्रता प्राप्त है"),
|
||||
("hu", "Árvíztűrő tükörfúrógép"),
|
||||
("it", "Quel vituperabile xenofobo zelante assaggia il whisky ed esclama: alleluja!"),
|
||||
("ja", "いろはにほへと ちりぬるを 色は匂へど 散りぬるを"),
|
||||
("ko", "다람쥐 헌 쳇바퀴에 타고파"),
|
||||
("pl", "Pchnąć w tę łódź jeża lub ośm skrzyń fig"),
|
||||
("pt", "Luís argüia à Júlia que «brações, fé, chá, óxido, pôr, zângão» eram palavras do português"),
|
||||
("ru", "Съешь же ещё этих мягких французских булок, да выпей чаю"),
|
||||
("ta", "மனிதப் பிறவியினர் சகலரும் சுதந்திரமாகவே பிறக்கின்றனர்"),
|
||||
("th", "เป็นมนุษย์สุดประเสริฐเลิศคุณค่า"),
|
||||
("tr", "Pijamalı hasta yağız şoföre çabucak güvendi"),
|
||||
("uk", "Чуєш їх, доцю, га? Кумедна ж ти, прощайся без ґольфів!"),
|
||||
("ur", "تمام انسان آزاد اور حقوق و عزت کے اعتبار سے برابر پیدا ہوئے ہیں"),
|
||||
("vi", "Tôi có thể ăn thủy tinh mà không hại gì"),
|
||||
];
|
||||
|
||||
const SIMPLIFIED: &str = "天地玄黄,宇宙洪荒。日月盈昃,辰宿列张。";
|
||||
const TRADITIONAL: &str = "天地玄黃,宇宙洪荒。日月盈昃,辰宿列張。";
|
||||
|
||||
/// Sentences by ISO 15924 script.
|
||||
const BY_SCRIPT: &[(&str, &str)] = &[
|
||||
("Arab", "ar"),
|
||||
("Beng", "bn"),
|
||||
("Cyrl", "ru"),
|
||||
("Deva", "hi"),
|
||||
("Grek", "el"),
|
||||
("Hang", "ko"),
|
||||
("Hebr", "he"),
|
||||
("Jpan", "ja"),
|
||||
("Kana", "ja"),
|
||||
("Hira", "ja"),
|
||||
("Kore", "ko"),
|
||||
("Latn", "en"),
|
||||
("Taml", "ta"),
|
||||
("Thai", "th"),
|
||||
];
|
||||
|
||||
fn language(code: &str) -> Option<&'static str> {
|
||||
BY_LANGUAGE.iter().find(|(c, _)| *c == code).map(|(_, s)| *s)
|
||||
}
|
||||
|
||||
/// A sentence in the language with gflanguages id `id` (`ja_Jpan`, `zh_Hant`), or in its script.
|
||||
pub fn for_language(id: &str) -> Option<&'static str> {
|
||||
let mut parts = id.split('_');
|
||||
let code = parts.next().unwrap_or(id);
|
||||
let script = parts.next().unwrap_or("");
|
||||
if code == "zh" {
|
||||
return Some(if script == "Hant" { TRADITIONAL } else { SIMPLIFIED });
|
||||
}
|
||||
language(code).or_else(|| for_script(script))
|
||||
}
|
||||
|
||||
/// A sentence in the ISO 15924 script `script`.
|
||||
pub fn for_script(script: &str) -> Option<&'static str> {
|
||||
match script {
|
||||
"Hans" | "Hani" => Some(SIMPLIFIED),
|
||||
"Hant" => Some(TRADITIONAL),
|
||||
_ => BY_SCRIPT.iter().find(|(s, _)| *s == script).and_then(|(_, code)| language(code)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sample for a family with `scripts`: the pangram when it has Latin, else a sentence in the
|
||||
/// first of its scripts there is one for.
|
||||
pub fn for_scripts<'a>(scripts: impl IntoIterator<Item = &'a str>) -> &'static str {
|
||||
let mut first = None;
|
||||
for script in scripts {
|
||||
if script == "Latn" {
|
||||
return PANGRAM;
|
||||
}
|
||||
first = first.or_else(|| for_script(script));
|
||||
}
|
||||
first.unwrap_or(PANGRAM)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn samples_follow_language_then_script() {
|
||||
assert_eq!(for_language("ja_Jpan"), language("ja"));
|
||||
assert_eq!(for_language("zh_Hant"), Some(TRADITIONAL));
|
||||
assert_eq!(for_language("xx_Arab"), language("ar"));
|
||||
assert_eq!(for_language("xx_Zzzz"), None);
|
||||
assert_eq!(for_scripts(["Arab", "Latn"]), PANGRAM);
|
||||
assert_eq!(for_scripts(["Zzzz", "Hebr"]), language("he").unwrap_or_default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! The font sources, for a host's settings page: the built-in ones, the index URLs the user
|
||||
//! added (never curated, and flagged when their file changed since it was added), adding one,
|
||||
//! and downloading them all again.
|
||||
|
||||
use craft_ui::direction;
|
||||
use egui::{RichText, Ui, vec2};
|
||||
|
||||
use crate::panel::app::FontsPanel;
|
||||
use crate::panel::i18n::{fmt, tl, tn};
|
||||
use crate::panel::widgets::{self, Skin};
|
||||
|
||||
impl FontsPanel {
|
||||
/// Draws the font sources into `ui`, in the panel's look: a host shows it in its settings.
|
||||
pub fn sources_ui(&mut self, ui: &mut Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let s = Skin { p: self.look.palette(&ctx), look: self.look.clone() };
|
||||
self.poll_sources();
|
||||
let index = self.index();
|
||||
let user = self.installer.user_sources();
|
||||
let now = ui.input(|i| i.time);
|
||||
let allowed = self.allowed(now);
|
||||
let mut remove = None;
|
||||
|
||||
ui.spacing_mut().item_spacing.y = 6.0;
|
||||
let width = ui.available_width();
|
||||
egui::Frame::new().stroke(s.hairline()).corner_radius(s.look.corner).show(ui, |ui| {
|
||||
ui.set_width(width);
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
let builtin = index.iter().flat_map(|i| i.sources().iter()).filter(|src| src.user_url.is_none());
|
||||
let mut first = true;
|
||||
for src in builtin {
|
||||
row(ui, &s, &mut first, |ui| {
|
||||
ui.label(RichText::new(&src.name).color(s.p.ink_strong));
|
||||
widgets::label(ui, &s, &tn(src.families as u64, "{n} family", "{n} families"), s.p.faint);
|
||||
});
|
||||
}
|
||||
for added in &user {
|
||||
let source = index.iter().flat_map(|i| i.sources().iter()).find(|src| src.user_url.as_deref() == Some(added.url.as_str()));
|
||||
let problem = index.iter().flat_map(|i| i.problems().iter()).find(|p| p.source == added.url);
|
||||
row(ui, &s, &mut first, |ui| {
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), s.h() - 6.0), direction::row(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
ui.label(RichText::new(source.map_or(added.url.as_str(), |src| src.name.as_str())).color(s.p.ink_strong));
|
||||
ui.with_layout(direction::trailing(ui), |ui| {
|
||||
let button = widgets::button(ui, &s, tl!("Remove"), widgets::Kind::Danger, 28.0, 0.0);
|
||||
widgets::name(ui, &button, &fmt("Remove {source}", &[("source", &added.url)]));
|
||||
if button.clicked() {
|
||||
remove = Some(added.url.clone());
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), 24.0), direction::row(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
widgets::badge(ui, &s, tl!("Not curated"), s.p.warn, false);
|
||||
if source.is_some_and(|src| src.changed) {
|
||||
widgets::badge(ui, &s, tl!("Changed since added"), s.p.bad, true).on_hover_text(tl!(
|
||||
"The index at this address isn't the one first downloaded from it. Check the source before installing from it."
|
||||
));
|
||||
}
|
||||
});
|
||||
ui.label(RichText::new(&added.url).size(12.0).color(s.p.muted));
|
||||
match (source, problem) {
|
||||
(_, Some(p)) => {
|
||||
ui.label(RichText::new(&p.error).size(12.0).color(if p.error == "not downloaded yet" { s.p.faint } else { s.p.bad }));
|
||||
}
|
||||
(Some(src), None) => {
|
||||
widgets::label(ui, &s, &tn(src.families as u64, "{n} family", "{n} families"), s.p.faint);
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
widgets::label(ui, &s, tl!("Add a source"), s.p.faint);
|
||||
let mut add = false;
|
||||
ui.allocate_ui_with_layout(vec2(width, s.h()), direction::row(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
let button_w = s.button_galley(ui, tl!("Add"), s.p.ink).size().x + 28.0;
|
||||
let field = widgets::field(
|
||||
ui,
|
||||
&s,
|
||||
&mut self.source_url,
|
||||
"https://example.org/fonts/index.json",
|
||||
tl!("Index address"),
|
||||
ui.available_width() - button_w - 8.0,
|
||||
false,
|
||||
);
|
||||
let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let enabled = !self.source_url.trim().is_empty();
|
||||
let clicked = ui.add_enabled_ui(enabled, |ui| widgets::button(ui, &s, tl!("Add"), widgets::Kind::Secondary, s.h(), 0.0)).inner.clicked();
|
||||
add = enabled && (clicked || enter);
|
||||
});
|
||||
let warning =
|
||||
tl!("A source you add isn't reviewed: add only indexes you trust. Every font is still checked against its index before it is used.");
|
||||
ui.add(egui::Label::new(RichText::new(warning).size(12.0).color(s.p.muted)).wrap());
|
||||
|
||||
ui.add_space(4.0);
|
||||
ui.allocate_ui_with_layout(vec2(width, s.h()), direction::row(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 10.0;
|
||||
if self.fetching_sources() {
|
||||
ui.add(egui::Spinner::new().size(16.0).color(s.p.muted));
|
||||
ui.label(RichText::new(tl!("Downloading the sources…")).color(s.p.muted));
|
||||
} else {
|
||||
let update =
|
||||
ui.add_enabled_ui(allowed, |ui| widgets::button(ui, &s, tl!("Update sources"), widgets::Kind::Secondary, s.h(), 0.0)).inner;
|
||||
if !allowed {
|
||||
widgets::label(ui, &s, tl!("Allow downloads to update"), s.p.faint);
|
||||
}
|
||||
if update.clicked() {
|
||||
self.fetch_sources(&ctx);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some((error, text)) = &self.source_message {
|
||||
ui.add(egui::Label::new(RichText::new(text).size(12.0).color(if *error { s.p.bad } else { s.p.muted })).wrap());
|
||||
}
|
||||
|
||||
if add {
|
||||
let url = self.source_url.trim().to_owned();
|
||||
match self.installer.add_user_source(&url) {
|
||||
Ok(()) => {
|
||||
self.source_url.clear();
|
||||
self.source_message = (!allowed).then(|| (false, tl!("Added. It downloads once downloads are allowed.").to_owned()));
|
||||
self.reload_index();
|
||||
self.fetch_sources(&ctx);
|
||||
}
|
||||
Err(e) => self.source_message = Some((true, fmt("The source couldn't be added: {error}", &[("error", &e.0)]))),
|
||||
}
|
||||
}
|
||||
if let Some(url) = remove {
|
||||
match self.installer.remove_user_source(&url) {
|
||||
Ok(()) => {
|
||||
self.source_message = None;
|
||||
self.reload_index();
|
||||
}
|
||||
Err(e) => self.source_message = Some((true, fmt("The source couldn't be removed: {error}", &[("error", &e.0)]))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One source in the list: a hairline above every one but the first.
|
||||
fn row(ui: &mut Ui, s: &Skin, first: &mut bool, add: impl FnOnce(&mut Ui)) {
|
||||
if !std::mem::take(first) {
|
||||
let y = ui.cursor().top();
|
||||
ui.painter().hline(ui.max_rect().x_range(), y, s.hairline());
|
||||
}
|
||||
egui::Frame::new().inner_margin(egui::Margin::symmetric(14, 10)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.spacing_mut().item_spacing.y = 3.0;
|
||||
ui.with_layout(direction::column(ui), add);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
//! The panel's controls, drawn in the host's [`Look`]: labels, buttons, option cells, badges,
|
||||
//! fields, progress and the few glyphs it needs (drawn, so no font has to carry them).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::text::{LayoutJob, TextWrapping};
|
||||
use egui::{
|
||||
Align2, Color32, ComboBox, CursorIcon, FontFamily, FontId, Galley, Painter, Pos2, Rect, Response, Role, Sense, Stroke, StrokeKind, TextEdit,
|
||||
TextFormat, Ui, Vec2, WidgetInfo, pos2, vec2,
|
||||
};
|
||||
|
||||
use crate::panel::look::{Look, Palette};
|
||||
|
||||
/// The look and this frame's palette.
|
||||
#[derive(Clone)]
|
||||
pub struct Skin {
|
||||
pub look: Look,
|
||||
pub p: Palette,
|
||||
}
|
||||
|
||||
/// Letter spacing suits Latin, Greek, Cyrillic and CJK; it breaks up joined scripts.
|
||||
fn spaceable(text: &str) -> bool {
|
||||
text.chars().all(|c| (c as u32) < 0x0590 || (c as u32) >= 0x2E80)
|
||||
}
|
||||
|
||||
impl Skin {
|
||||
pub fn h(&self) -> f32 {
|
||||
self.look.control_height
|
||||
}
|
||||
|
||||
/// `text` on one line, upper case and spaced out when the look says so.
|
||||
pub fn caps_galley(&self, ui: &Ui, text: &str, size: f32, family: FontFamily, color: Color32, max_width: Option<f32>) -> Arc<Galley> {
|
||||
// Small capitals and letter spacing suit Latin, Greek, Cyrillic and CJK; joined scripts
|
||||
// get neither, and a size step up to read as well at label size.
|
||||
let joined = !spaceable(text);
|
||||
let size = if joined { size * 1.15 } else { size };
|
||||
let (text, spacing) = if self.look.caps { (text.to_uppercase(), if joined { 0.0 } else { size * 0.14 }) } else { (text.to_owned(), 0.0) };
|
||||
let format = TextFormat { font_id: FontId::new(size, family), color, extra_letter_spacing: spacing, ..Default::default() };
|
||||
let mut job = LayoutJob::single_section(text, format);
|
||||
if let Some(w) = max_width {
|
||||
job.wrap = TextWrapping::truncate_at_width(w.max(0.0));
|
||||
}
|
||||
ui.painter().layout_job(job)
|
||||
}
|
||||
|
||||
/// A small label: a section title, a count, a fact.
|
||||
pub fn label_galley(&self, ui: &Ui, text: &str, color: Color32) -> Arc<Galley> {
|
||||
self.caps_galley(ui, text, if self.look.caps { 11.0 } else { 12.0 }, self.look.label.clone(), color, None)
|
||||
}
|
||||
|
||||
pub fn button_galley(&self, ui: &Ui, text: &str, color: Color32) -> Arc<Galley> {
|
||||
self.caps_galley(ui, text, if self.look.caps { 11.0 } else { 13.0 }, self.look.button.clone(), color, None)
|
||||
}
|
||||
|
||||
pub fn hairline(&self) -> Stroke {
|
||||
self.p.hairline()
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints `galley` at the reading start of `rect`, centred vertically.
|
||||
pub fn paint_start(ui: &Ui, rect: Rect, galley: Arc<Galley>, color: Color32) {
|
||||
let x = if craft_ui::direction::is_rtl(ui) { rect.right() - galley.size().x } else { rect.left() };
|
||||
ui.painter().galley_with_override_text_color(pos2(x, rect.center().y - galley.size().y / 2.0), galley, color);
|
||||
}
|
||||
|
||||
/// Paints `galley` at the reading end of `rect`, centred vertically.
|
||||
pub fn paint_end(ui: &Ui, rect: Rect, galley: Arc<Galley>, color: Color32) {
|
||||
let x = if craft_ui::direction::is_rtl(ui) { rect.left() } else { rect.right() - galley.size().x };
|
||||
ui.painter().galley_with_override_text_color(pos2(x, rect.center().y - galley.size().y / 2.0), galley, color);
|
||||
}
|
||||
|
||||
pub fn paint_centred(ui: &Ui, rect: Rect, galley: Arc<Galley>, color: Color32) {
|
||||
ui.painter().galley_with_override_text_color(rect.center() - galley.size() / 2.0, galley, color);
|
||||
}
|
||||
|
||||
/// A small label.
|
||||
pub fn label(ui: &mut Ui, s: &Skin, text: &str, color: Color32) -> Response {
|
||||
let galley = s.label_galley(ui, text, color);
|
||||
// Joined scripts can draw a pixel or two past their measured width: a little room each side.
|
||||
let (rect, resp) = ui.allocate_exact_size(galley.size() + vec2(4.0, 0.0), Sense::hover());
|
||||
paint_start(ui, rect.shrink2(vec2(2.0, 0.0)), galley, color);
|
||||
resp.widget_info(|| WidgetInfo::labeled(Role::Label, true, text));
|
||||
resp
|
||||
}
|
||||
|
||||
/// A focus ring inside `rect`, while `resp` has the keyboard focus.
|
||||
pub fn focus_ring(ui: &Ui, s: &Skin, rect: Rect, resp: &Response) {
|
||||
if resp.has_focus() {
|
||||
ui.painter().rect_stroke(rect, s.look.corner, Stroke::new(2.0, s.p.accent_ink), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
/// Solid, in the inverted colours.
|
||||
Primary,
|
||||
/// Outlined.
|
||||
Secondary,
|
||||
/// Outlined in the error colour.
|
||||
Danger,
|
||||
}
|
||||
|
||||
/// A button `height` tall and at least `min_width` wide.
|
||||
pub fn button(ui: &mut Ui, s: &Skin, text: &str, kind: Kind, height: f32, min_width: f32) -> Response {
|
||||
let p = s.p;
|
||||
let probe = s.button_galley(ui, text, p.ink);
|
||||
let size = vec2((probe.size().x + 28.0).max(min_width), height);
|
||||
let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
|
||||
let enabled = ui.is_enabled();
|
||||
let hovered = resp.hovered() && enabled;
|
||||
let (fill, stroke, color) = match kind {
|
||||
Kind::Primary => (p.invert_bg.gamma_multiply(if hovered { 0.82 } else { 1.0 }), Stroke::NONE, p.invert_fg),
|
||||
Kind::Secondary if hovered => (p.invert_bg, Stroke::NONE, p.invert_fg),
|
||||
Kind::Secondary => (Color32::TRANSPARENT, Stroke::new(1.0, p.line_strong), p.ink),
|
||||
Kind::Danger if hovered => (p.bad, Stroke::NONE, Color32::WHITE),
|
||||
Kind::Danger => (Color32::TRANSPARENT, Stroke::new(1.0, p.bad.gamma_multiply(0.7)), p.bad),
|
||||
};
|
||||
let fade = |c: Color32| if enabled { c } else { c.gamma_multiply(0.45) };
|
||||
ui.painter().rect(rect, s.look.corner, fade(fill), Stroke::new(stroke.width, fade(stroke.color)), StrokeKind::Inside);
|
||||
let galley = s.button_galley(ui, text, fade(color));
|
||||
paint_centred(ui, rect, galley, fade(color));
|
||||
focus_ring(ui, s, rect.expand(2.0), &resp);
|
||||
resp.widget_info(|| WidgetInfo::labeled(Role::Button, enabled, text));
|
||||
if enabled { resp.on_hover_cursor(CursorIcon::PointingHand) } else { resp }
|
||||
}
|
||||
|
||||
/// A text-only action in the accent colour: "Clear filters", "Retry".
|
||||
pub fn link(ui: &mut Ui, s: &Skin, text: &str) -> Response {
|
||||
let galley = s.label_galley(ui, text, s.p.accent_ink);
|
||||
let (rect, resp) = ui.allocate_exact_size(galley.size() + vec2(4.0, 6.0), Sense::click());
|
||||
let color = if resp.hovered() { s.p.ink_strong } else { s.p.accent_ink };
|
||||
paint_start(ui, rect.shrink2(vec2(2.0, 0.0)), galley, color);
|
||||
if resp.hovered() {
|
||||
let y = rect.bottom() - 1.5;
|
||||
ui.painter().hline(rect.x_range(), y, Stroke::new(1.0, color));
|
||||
}
|
||||
focus_ring(ui, s, rect.expand(3.0), &resp);
|
||||
resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, text));
|
||||
resp.on_hover_cursor(CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// One option in a group of cells: its value, its label and an optional count.
|
||||
pub struct Cell<'a, T> {
|
||||
pub value: T,
|
||||
pub label: &'a str,
|
||||
pub count: Option<usize>,
|
||||
}
|
||||
|
||||
fn count_text(n: usize) -> String {
|
||||
crate::panel::i18n::number(n as u64)
|
||||
}
|
||||
|
||||
/// Options stacked in a column of hairline cells the width of `ui`; the chosen one inverted.
|
||||
/// Returns whether the choice changed.
|
||||
pub fn cells_column<T: PartialEq + Clone>(ui: &mut Ui, s: &Skin, value: &mut T, options: &[Cell<'_, T>]) -> bool {
|
||||
let p = s.p;
|
||||
let mut changed = false;
|
||||
let width = ui.available_width();
|
||||
let h = s.h() - 2.0;
|
||||
let mut rects = Vec::with_capacity(options.len());
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
for cell in options {
|
||||
let on = *value == cell.value;
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, h), Sense::click());
|
||||
let (fill, ink, faint) = match (on, resp.hovered()) {
|
||||
(true, _) => (p.invert_bg, p.invert_fg, p.invert_fg.gamma_multiply(0.6)),
|
||||
(false, true) => (p.sunken, p.ink_strong, p.muted),
|
||||
(false, false) => (Color32::TRANSPARENT, p.ink, p.faint),
|
||||
};
|
||||
ui.painter().rect_filled(rect, egui::CornerRadius::ZERO, fill);
|
||||
let inner = rect.shrink2(vec2(12.0, 0.0));
|
||||
let galley = s.label_galley(ui, cell.label, ink);
|
||||
paint_start(ui, inner, galley, ink);
|
||||
if let Some(n) = cell.count {
|
||||
let g = s.label_galley(ui, &count_text(n), faint);
|
||||
paint_end(ui, inner, g, faint);
|
||||
}
|
||||
focus_ring(ui, s, rect.shrink(1.0), &resp);
|
||||
resp.widget_info(|| {
|
||||
let mut info = WidgetInfo::selected(Role::RadioButton, true, on, cell.label);
|
||||
info.value = cell.count.map(|n| n as f64);
|
||||
info
|
||||
});
|
||||
if resp.on_hover_cursor(CursorIcon::PointingHand).clicked() && !on {
|
||||
*value = cell.value.clone();
|
||||
changed = true;
|
||||
}
|
||||
rects.push(rect);
|
||||
}
|
||||
});
|
||||
if let (Some(first), Some(last)) = (rects.first(), rects.last()) {
|
||||
let painter = ui.painter();
|
||||
painter.rect_stroke(first.union(*last), s.look.corner, s.hairline(), StrokeKind::Inside);
|
||||
for r in rects.iter().skip(1) {
|
||||
painter.hline(r.x_range(), r.top(), s.hairline());
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Options side by side in hairline cells, in reading order; the chosen one inverted.
|
||||
pub fn cells_row<T: PartialEq + Clone>(ui: &mut Ui, s: &Skin, value: &mut T, options: &[Cell<'_, T>]) -> bool {
|
||||
let p = s.p;
|
||||
let mut changed = false;
|
||||
let rtl = craft_ui::direction::is_rtl(ui) || ui.layout().prefer_right_to_left();
|
||||
let mut rects = Vec::with_capacity(options.len());
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
for cell in options {
|
||||
let on = *value == cell.value;
|
||||
let (ink, faint) = if on { (p.invert_fg, p.invert_fg.gamma_multiply(0.6)) } else { (p.muted, p.faint) };
|
||||
let label = s.label_galley(ui, cell.label, ink);
|
||||
let count = cell.count.map(|n| s.label_galley(ui, &count_text(n), faint));
|
||||
let w = label.size().x + count.as_ref().map_or(0.0, |g| g.size().x + 8.0) + 28.0;
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, s.h()), Sense::click());
|
||||
let fill = match (on, resp.hovered()) {
|
||||
(true, _) => p.invert_bg,
|
||||
(false, true) => p.sunken,
|
||||
(false, false) => Color32::TRANSPARENT,
|
||||
};
|
||||
let ink = if !on && resp.hovered() { p.ink_strong } else { ink };
|
||||
ui.painter().rect_filled(rect, egui::CornerRadius::ZERO, fill);
|
||||
let inner = rect.shrink2(vec2(14.0, 0.0));
|
||||
let label_w = label.size().x;
|
||||
paint_start(ui, inner, label, ink);
|
||||
if let Some(g) = count {
|
||||
let rest = if rtl { inner.with_max_x(inner.right() - label_w - 8.0) } else { inner.with_min_x(inner.left() + label_w + 8.0) };
|
||||
paint_start(ui, rest, g, faint);
|
||||
}
|
||||
focus_ring(ui, s, rect.shrink(1.0), &resp);
|
||||
resp.widget_info(|| {
|
||||
let mut info = WidgetInfo::selected(Role::Tab, true, on, cell.label);
|
||||
info.value = cell.count.map(|n| n as f64);
|
||||
info
|
||||
});
|
||||
if resp.on_hover_cursor(CursorIcon::PointingHand).clicked() && !on {
|
||||
*value = cell.value.clone();
|
||||
changed = true;
|
||||
}
|
||||
rects.push(rect);
|
||||
}
|
||||
});
|
||||
if let (Some(first), Some(last)) = (rects.first(), rects.last()) {
|
||||
let painter = ui.painter();
|
||||
painter.rect_stroke(first.union(*last), s.look.corner, s.hairline(), StrokeKind::Inside);
|
||||
for r in rects.iter().skip(1) {
|
||||
let x = if rtl { r.right() } else { r.left() };
|
||||
painter.vline(x, first.y_range(), s.hairline());
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// A small square status label on the sunken surface, with an optional dot before it.
|
||||
pub fn badge(ui: &mut Ui, s: &Skin, text: &str, color: Color32, dot: bool) -> Response {
|
||||
let galley = s.caps_galley(ui, text, 10.0, s.look.label.clone(), color, None);
|
||||
let dot_w = if dot { 12.0 } else { 0.0 };
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 16.0 + dot_w, 22.0), Sense::hover());
|
||||
ui.painter().rect(rect, s.look.corner, s.p.sunken, s.hairline(), StrokeKind::Inside);
|
||||
let rtl = craft_ui::direction::is_rtl(ui);
|
||||
let inner = rect.shrink2(vec2(8.0, 0.0));
|
||||
if dot {
|
||||
let x = if rtl { inner.right() - 3.0 } else { inner.left() + 3.0 };
|
||||
ui.painter().circle_filled(pos2(x, rect.center().y), 3.0, color);
|
||||
}
|
||||
let text_rect = if rtl { inner.with_max_x(inner.right() - dot_w) } else { inner.with_min_x(inner.left() + dot_w) };
|
||||
paint_start(ui, text_rect, galley, color);
|
||||
resp.widget_info(|| WidgetInfo::labeled(Role::Label, true, text));
|
||||
resp
|
||||
}
|
||||
|
||||
/// A slim bar along the bottom of `rect`: `fraction` done, or a moving segment when unknown.
|
||||
pub fn progress_bar(ui: &Ui, s: &Skin, rect: Rect, fraction: Option<f32>) {
|
||||
let painter = ui.painter();
|
||||
painter.rect_filled(rect, egui::CornerRadius::ZERO, s.p.line);
|
||||
let rtl = craft_ui::direction::is_rtl(ui);
|
||||
match fraction {
|
||||
Some(f) => {
|
||||
let w = rect.width() * f.clamp(0.0, 1.0);
|
||||
let bar = if rtl {
|
||||
Rect::from_min_max(pos2(rect.right() - w, rect.top()), rect.max)
|
||||
} else {
|
||||
Rect::from_min_size(rect.min, vec2(w, rect.height()))
|
||||
};
|
||||
painter.rect_filled(bar, egui::CornerRadius::ZERO, s.p.accent);
|
||||
}
|
||||
None => {
|
||||
let w = rect.width();
|
||||
let x = (ui.input(|i| i.time) * 0.8).fract() as f32 * w;
|
||||
for left in [x, x - w] {
|
||||
let seg = Rect::from_min_size(pos2(rect.left() + left, rect.top()), vec2(w * 0.3, rect.height())).intersect(rect);
|
||||
if seg.is_positive() {
|
||||
painter.rect_filled(seg, egui::CornerRadius::ZERO, s.p.accent);
|
||||
}
|
||||
}
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Glyph {
|
||||
Search,
|
||||
Close,
|
||||
Info,
|
||||
}
|
||||
|
||||
/// Draws `glyph` centred on `c`, `size` across.
|
||||
pub fn glyph(painter: &Painter, glyph: Glyph, c: Pos2, size: f32, color: Color32) {
|
||||
let stroke = Stroke::new(1.5, color);
|
||||
let r = size / 2.0;
|
||||
match glyph {
|
||||
Glyph::Search => {
|
||||
let centre = c + vec2(-r * 0.2, -r * 0.2);
|
||||
painter.circle_stroke(centre, r * 0.6, stroke);
|
||||
painter.line_segment([centre + vec2(r * 0.45, r * 0.45), c + vec2(r, r)], stroke);
|
||||
}
|
||||
Glyph::Close => {
|
||||
let k = r * 0.75;
|
||||
painter.line_segment([c + vec2(-k, -k), c + vec2(k, k)], stroke);
|
||||
painter.line_segment([c + vec2(-k, k), c + vec2(k, -k)], stroke);
|
||||
}
|
||||
Glyph::Info => {
|
||||
painter.circle_stroke(c, r * 0.85, Stroke::new(1.2, color));
|
||||
painter.line_segment([c + vec2(0.0, -r * 0.05), c + vec2(0.0, r * 0.45)], Stroke::new(1.4, color));
|
||||
painter.circle_filled(c + vec2(0.0, -r * 0.38), 1.0, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A square button with a drawn glyph, outlined, inverting on hover.
|
||||
pub fn glyph_button(ui: &mut Ui, s: &Skin, g: Glyph, tip: &str, size: f32) -> Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
||||
let hovered = resp.hovered();
|
||||
let painter = ui.painter();
|
||||
if hovered {
|
||||
painter.rect_filled(rect, s.look.corner, s.p.invert_bg);
|
||||
} else {
|
||||
painter.rect_stroke(rect, s.look.corner, s.hairline(), StrokeKind::Inside);
|
||||
}
|
||||
let color = if hovered { s.p.invert_fg } else { s.p.muted };
|
||||
glyph(painter, g, rect.center(), 12.0, color);
|
||||
focus_ring(ui, s, rect, &resp);
|
||||
resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, tip));
|
||||
resp.on_hover_text(tip).on_hover_cursor(CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// Names a widget for screen readers (and kittest) where it has no label of its own.
|
||||
pub fn name(ui: &Ui, resp: &Response, label: &str) {
|
||||
ui.ctx().accesskit_node_builder(resp.id, |node| node.set_label(label));
|
||||
}
|
||||
|
||||
/// A single-line field `width` wide, named `label`, with a search glyph at its start when
|
||||
/// `search`.
|
||||
pub fn field(ui: &mut Ui, s: &Skin, text: &mut String, hint: &str, label: &str, width: f32, search: bool) -> Response {
|
||||
let rtl = craft_ui::direction::is_rtl(ui);
|
||||
let pad = if search { 34.0 } else { 10.0 };
|
||||
let margin = if rtl {
|
||||
egui::Margin { left: 10, right: pad as i8, top: 0, bottom: 0 }
|
||||
} else {
|
||||
egui::Margin { left: pad as i8, right: 10, top: 0, bottom: 0 }
|
||||
};
|
||||
let edit = TextEdit::singleline(text)
|
||||
.hint_text(hint)
|
||||
.desired_width(width)
|
||||
.min_size(vec2(0.0, s.h()))
|
||||
.align(if rtl { Align2::RIGHT_CENTER } else { Align2::LEFT_CENTER })
|
||||
.margin(margin);
|
||||
let resp = ui.add(edit);
|
||||
if search {
|
||||
let x = if rtl { resp.rect.right() - 18.0 } else { resp.rect.left() + 18.0 };
|
||||
glyph(ui.painter(), Glyph::Search, pos2(x, resp.rect.center().y), 13.0, s.p.faint);
|
||||
}
|
||||
name(ui, &resp, label);
|
||||
resp
|
||||
}
|
||||
|
||||
/// A drop-down `width` wide showing `selected`, named `label`.
|
||||
pub fn select<R>(ui: &mut Ui, id_salt: &str, label: &str, selected: &str, width: f32, add: impl FnOnce(&mut Ui) -> R) -> Option<R> {
|
||||
let inner = ComboBox::from_id_salt(id_salt).selected_text(selected).width(width).height(380.0).truncate().show_ui(ui, add);
|
||||
name(ui, &inner.response, label);
|
||||
inner.inner
|
||||
}
|
||||
|
||||
/// An inline note in a sunken, hairline box: an info glyph, then `add`.
|
||||
pub fn note<R>(ui: &mut Ui, s: &Skin, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
egui::Frame::new()
|
||||
.fill(s.p.sunken)
|
||||
.stroke(s.hairline())
|
||||
.corner_radius(s.look.corner)
|
||||
.inner_margin(egui::Margin { left: 14, right: 12, top: 10, bottom: 10 })
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
craft_ui::direction::horizontal(ui, |ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(16.0, s.h().min(28.0)), Sense::hover());
|
||||
glyph(ui.painter(), Glyph::Info, rect.center(), 14.0, s.p.muted);
|
||||
ui.add_space(4.0);
|
||||
add(ui)
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
/// A [`note`] with `add` beside buttons at its reading end: one per label in `buttons`, then a
|
||||
/// close glyph when `closable`. `button(ui, n)` draws the `n`th, the close glyph last.
|
||||
pub fn note_with_actions(ui: &mut Ui, s: &Skin, add: impl FnOnce(&mut Ui), buttons: &[&str], mut button: impl FnMut(&mut Ui, usize), closable: bool) {
|
||||
let gap = 10.0;
|
||||
let actions_w: f32 =
|
||||
buttons.iter().map(|b| s.button_galley(ui, b, s.p.ink).size().x + 28.0 + gap).sum::<f32>() + if closable { 28.0 + gap } else { 0.0 };
|
||||
note(ui, s, |ui| {
|
||||
let text_w = (ui.available_width() - actions_w - 8.0).max(120.0);
|
||||
ui.allocate_ui_with_layout(vec2(text_w, 0.0), craft_ui::direction::column(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
add(ui);
|
||||
});
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), 28.0), craft_ui::direction::trailing(ui), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = gap;
|
||||
if closable {
|
||||
button(ui, buttons.len());
|
||||
}
|
||||
for n in (0..buttons.len()).rev() {
|
||||
button(ui, n);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A slim slider `width` wide for whole numbers in `range`, named `label`: drag or click the
|
||||
/// track, or step with the arrow keys (Shift for ten). It reads left to right in every interface
|
||||
/// direction, as sliders do.
|
||||
pub fn slider(ui: &mut Ui, s: &Skin, value: &mut f32, range: std::ops::RangeInclusive<f32>, width: f32, label: &str) -> Response {
|
||||
let (lo, hi) = (*range.start(), *range.end());
|
||||
let (rect, mut resp) = ui.allocate_exact_size(vec2(width, s.h()), Sense::click_and_drag());
|
||||
let track = Rect::from_center_size(rect.center(), vec2(width - 14.0, 2.0));
|
||||
let before = *value;
|
||||
if let Some(pos) = resp.interact_pointer_pos() {
|
||||
let t = ((pos.x - track.left()) / track.width()).clamp(0.0, 1.0);
|
||||
*value = (lo + t * (hi - lo)).round();
|
||||
}
|
||||
if resp.has_focus() {
|
||||
ui.memory_mut(|m| {
|
||||
m.set_focus_lock_filter(resp.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() })
|
||||
});
|
||||
let step = ui.input(|i| {
|
||||
let n = if i.modifiers.shift { 10.0 } else { 1.0 };
|
||||
let up = i.key_pressed(egui::Key::ArrowRight) || i.key_pressed(egui::Key::ArrowUp);
|
||||
let down = i.key_pressed(egui::Key::ArrowLeft) || i.key_pressed(egui::Key::ArrowDown);
|
||||
if up {
|
||||
n
|
||||
} else if down {
|
||||
-n
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
});
|
||||
*value = (*value + step).clamp(lo, hi);
|
||||
}
|
||||
if *value != before {
|
||||
resp.mark_changed();
|
||||
}
|
||||
let t = ((*value - lo) / (hi - lo)).clamp(0.0, 1.0);
|
||||
let x = track.left() + t * track.width();
|
||||
let painter = ui.painter();
|
||||
painter.rect_filled(track, egui::CornerRadius::ZERO, s.p.line_strong);
|
||||
painter.rect_filled(Rect::from_min_max(track.min, pos2(x, track.bottom())), egui::CornerRadius::ZERO, s.p.ink);
|
||||
let handle = Rect::from_center_size(pos2(x, rect.center().y), vec2(12.0, 12.0));
|
||||
let hovered = resp.hovered() || resp.dragged();
|
||||
painter.rect(
|
||||
handle,
|
||||
egui::CornerRadius::ZERO,
|
||||
if hovered { s.p.ink_strong } else { s.p.invert_bg },
|
||||
Stroke::new(2.0, s.p.bg),
|
||||
StrokeKind::Outside,
|
||||
);
|
||||
if resp.has_focus() {
|
||||
painter.rect_stroke(handle.expand(4.0), egui::CornerRadius::ZERO, Stroke::new(2.0, s.p.accent_ink), StrokeKind::Inside);
|
||||
}
|
||||
resp.widget_info(|| WidgetInfo::slider(true, f64::from(*value), label));
|
||||
resp.on_hover_cursor(CursorIcon::ResizeHorizontal)
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
//! What Craft Font Manager needs from the suite's font store: the download consent, installing a
|
||||
//! family from the index, removing one, and downloading a family's files for a preview without
|
||||
//! installing them.
|
||||
//!
|
||||
//! [`SuiteStore`] is craft-font-store's store; [`FakeStore`] stands in for it in tests.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Condvar, Mutex};
|
||||
|
||||
use craft_font_catalog::Catalog;
|
||||
pub use craft_font_store::UserSource;
|
||||
use craft_font_store::{Cancel, Client, Face, FontStore, Network, PINNED_INDEX, verify_font};
|
||||
|
||||
use crate::index::{Family, Index};
|
||||
|
||||
/// Store files to remove: a whole family, or some of its styles.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RemoveRequest {
|
||||
pub family: String,
|
||||
/// The files the catalog found for it in the font store.
|
||||
pub files: Vec<PathBuf>,
|
||||
/// The whole family, its licence included; otherwise only `files`.
|
||||
pub whole: bool,
|
||||
}
|
||||
|
||||
/// Bytes downloaded so far, of `total`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Progress {
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// A downloaded and verified file.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Downloaded {
|
||||
pub path: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// An error to show the user, in English.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StoreError(pub String);
|
||||
|
||||
impl std::fmt::Display for StoreError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StoreError {}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> StoreError {
|
||||
StoreError(e.to_string())
|
||||
}
|
||||
|
||||
/// The font store. Calls block; the app makes the downloading ones on a worker thread.
|
||||
pub trait Installer: Send + Sync {
|
||||
/// Has the user allowed downloads? The setting is the whole suite's.
|
||||
fn downloads_allowed(&self) -> bool;
|
||||
|
||||
/// Turns downloads on or off, as the user's own action.
|
||||
fn allow_downloads(&self, allowed: bool) -> Result<(), StoreError>;
|
||||
|
||||
/// Downloads, verifies and installs every file of `family`, with its licence.
|
||||
fn install(&self, index: &Index, family: &Family, progress: &mut dyn FnMut(Progress)) -> Result<(), StoreError>;
|
||||
|
||||
/// Installs one file of `family` (`path` as the index has it), with the family's licence.
|
||||
fn install_file(&self, index: &Index, family: &Family, path: &str, progress: &mut dyn FnMut(Progress)) -> Result<(), StoreError>;
|
||||
|
||||
/// Deletes a family's files from the store.
|
||||
fn remove(&self, request: &RemoveRequest) -> Result<(), StoreError>;
|
||||
|
||||
/// Downloads and verifies `family`'s files without installing them.
|
||||
fn download(&self, index: &Index, family: &Family, progress: &mut dyn FnMut(Progress)) -> Result<Vec<Downloaded>, StoreError>;
|
||||
|
||||
/// The indexes without a request: the built-in ones compiled in, and the sources the user
|
||||
/// added as last fetched.
|
||||
fn indexes(&self) -> Result<Index, StoreError> {
|
||||
crate::index::bundled().map_err(err)
|
||||
}
|
||||
|
||||
/// Downloads the indexes: the built-in ones at the pinned commit and the user's sources. A
|
||||
/// source that fails is in [`Index::problems`]; the others load.
|
||||
fn fetch_indexes(&self) -> Result<Index, StoreError> {
|
||||
Err(StoreError(NO_FOLDER.into()))
|
||||
}
|
||||
|
||||
/// The index URLs the user added as sources. Their families are never curated.
|
||||
fn user_sources(&self) -> Vec<UserSource> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Adds an index URL (https, `.json` or `.json.gz`) as a source, as the user's own action.
|
||||
/// The next [`Installer::fetch_indexes`] downloads it.
|
||||
fn add_user_source(&self, _url: &str) -> Result<(), StoreError> {
|
||||
Err(StoreError(NO_FOLDER.into()))
|
||||
}
|
||||
|
||||
/// Removes a source the user added. Families installed from it stay installed.
|
||||
fn remove_user_source(&self, _url: &str) -> Result<(), StoreError> {
|
||||
Err(StoreError(NO_FOLDER.into()))
|
||||
}
|
||||
}
|
||||
|
||||
const NO_FOLDER: &str = "there is no folder for the font store";
|
||||
|
||||
/// craft-font-store's store, in the user's font store folder.
|
||||
pub struct SuiteStore {
|
||||
store: FontStore,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl SuiteStore {
|
||||
/// The store the catalog's environment names, rescanning `catalog` after each change.
|
||||
pub fn for_user(catalog: Arc<Catalog>) -> Option<SuiteStore> {
|
||||
let store = FontStore::for_user(catalog.env())?.with_catalog(catalog);
|
||||
Some(SuiteStore { store, client: Client::default() })
|
||||
}
|
||||
|
||||
/// The store's source and name for the family holding `files`, which can differ from the
|
||||
/// name in the fonts themselves.
|
||||
fn installed_name(&self, request: &RemoveRequest) -> Option<(String, String)> {
|
||||
let dir = self.store.dir();
|
||||
let installed = self.store.installed();
|
||||
let lower = request.family.to_lowercase();
|
||||
installed
|
||||
.iter()
|
||||
.find(|f| request.files.iter().any(|path| path.starts_with(dir.join(&f.dir))))
|
||||
.or_else(|| installed.iter().find(|f| f.family.to_lowercase() == lower))
|
||||
.map(|f| (f.source.clone(), f.family.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Installer for SuiteStore {
|
||||
fn downloads_allowed(&self) -> bool {
|
||||
self.store.network() == Network::On
|
||||
}
|
||||
|
||||
fn allow_downloads(&self, allowed: bool) -> Result<(), StoreError> {
|
||||
if allowed { self.store.enable_network() } else { self.store.disable_network() }.map_err(err)
|
||||
}
|
||||
|
||||
fn install(&self, index: &Index, family: &Family, progress: &mut dyn FnMut(Progress)) -> Result<(), StoreError> {
|
||||
let mut report = |p: craft_font_store::Progress| progress(Progress { done: p.done, total: p.total });
|
||||
self.store.install_family(index, family, &Cancel::new(), &mut report).map(drop).map_err(err)
|
||||
}
|
||||
|
||||
fn install_file(&self, index: &Index, family: &Family, path: &str, progress: &mut dyn FnMut(Progress)) -> Result<(), StoreError> {
|
||||
let file = family.files.iter().find(|f| f.path == path).ok_or_else(|| StoreError(format!("{path} isn't in the index")))?;
|
||||
let mut report = |p: craft_font_store::Progress| progress(Progress { done: p.done, total: p.total });
|
||||
self.store.install_face(index, Face { family, file }, &Cancel::new(), &mut report).map(drop).map_err(err)
|
||||
}
|
||||
|
||||
fn remove(&self, request: &RemoveRequest) -> Result<(), StoreError> {
|
||||
let (source, name) = self.installed_name(request).ok_or_else(|| err(craft_font_store::Error::NotInstalled(request.family.clone())))?;
|
||||
if request.whole {
|
||||
return self.store.remove_family(&source, &name).map_err(err);
|
||||
}
|
||||
let files: Vec<String> = request.files.iter().filter_map(|f| f.file_name()).map(|f| f.to_string_lossy().into_owned()).collect();
|
||||
self.store.remove_files(&source, &name, &files).map_err(err)
|
||||
}
|
||||
|
||||
fn download(&self, index: &Index, family: &Family, progress: &mut dyn FnMut(Progress)) -> Result<Vec<Downloaded>, StoreError> {
|
||||
if !self.downloads_allowed() {
|
||||
return Err(err(craft_font_store::Error::NetworkOff));
|
||||
}
|
||||
let total = family.size();
|
||||
let cancel = Cancel::new();
|
||||
let mut before = 0u64;
|
||||
let mut out = Vec::new();
|
||||
for file in &family.files {
|
||||
let mut reasons = Vec::new();
|
||||
for url in index.file_urls(family, file) {
|
||||
let mut on_bytes = |n: u64| progress(Progress { done: before.saturating_add(n), total });
|
||||
match self.client.get(&url, file.size, &cancel, &mut on_bytes) {
|
||||
Ok(bytes) => match verify_font(file, &bytes) {
|
||||
Ok(()) => {
|
||||
out.push(Downloaded { path: file.path.clone(), bytes });
|
||||
reasons.clear();
|
||||
break;
|
||||
}
|
||||
Err(e) => reasons.push(format!("{url}: {e}")),
|
||||
},
|
||||
Err(e) => reasons.push(format!("{url}: {e}")),
|
||||
}
|
||||
}
|
||||
if !reasons.is_empty() {
|
||||
return Err(StoreError(format!("downloading {} failed: {}", file.file_name(), reasons.join("; "))));
|
||||
}
|
||||
before = before.saturating_add(file.size);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn indexes(&self) -> Result<Index, StoreError> {
|
||||
Ok(self.store.offline_indexes())
|
||||
}
|
||||
|
||||
fn fetch_indexes(&self) -> Result<Index, StoreError> {
|
||||
self.store.fetch_indexes(&PINNED_INDEX, &Cancel::new()).map_err(err)
|
||||
}
|
||||
|
||||
fn user_sources(&self) -> Vec<UserSource> {
|
||||
self.store.user_sources()
|
||||
}
|
||||
|
||||
fn add_user_source(&self, url: &str) -> Result<(), StoreError> {
|
||||
self.store.add_user_source(url).map_err(err)
|
||||
}
|
||||
|
||||
fn remove_user_source(&self, url: &str) -> Result<(), StoreError> {
|
||||
self.store.remove_user_source(url).map_err(err)
|
||||
}
|
||||
}
|
||||
|
||||
/// The store when the system names no folder for it (no home folder): nothing installs.
|
||||
pub struct NoStore;
|
||||
|
||||
impl Installer for NoStore {
|
||||
fn downloads_allowed(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn allow_downloads(&self, _: bool) -> Result<(), StoreError> {
|
||||
Err(StoreError(NO_FOLDER.into()))
|
||||
}
|
||||
|
||||
fn install(&self, _: &Index, _: &Family, _: &mut dyn FnMut(Progress)) -> Result<(), StoreError> {
|
||||
Err(StoreError(NO_FOLDER.into()))
|
||||
}
|
||||
|
||||
fn install_file(&self, _: &Index, _: &Family, _: &str, _: &mut dyn FnMut(Progress)) -> Result<(), StoreError> {
|
||||
Err(StoreError(NO_FOLDER.into()))
|
||||
}
|
||||
|
||||
fn remove(&self, _: &RemoveRequest) -> Result<(), StoreError> {
|
||||
Err(StoreError(NO_FOLDER.into()))
|
||||
}
|
||||
|
||||
fn download(&self, _: &Index, _: &Family, _: &mut dyn FnMut(Progress)) -> Result<Vec<Downloaded>, StoreError> {
|
||||
Err(StoreError(NO_FOLDER.into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a [`FakeStore`] was asked to do.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Call {
|
||||
Install(String),
|
||||
/// The family and the file's path in the index.
|
||||
InstallFile(String, String),
|
||||
Remove(String),
|
||||
Download(String),
|
||||
}
|
||||
|
||||
/// A font store without a network: "downloads" come from a table of file bytes by index path,
|
||||
/// and installs write them into a folder as the real store does, `<dir>/<family>/<file>`. It
|
||||
/// refuses to download while downloads are off, like the real one.
|
||||
pub struct FakeStore {
|
||||
dir: PathBuf,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
allowed: AtomicBool,
|
||||
calls: Mutex<Vec<Call>>,
|
||||
failure: Mutex<Option<String>>,
|
||||
/// A google/fonts checkout to read files missing from `files` from.
|
||||
folder: Option<PathBuf>,
|
||||
/// The built-in indexes; the bundled ones when not given.
|
||||
builtin: Option<Index>,
|
||||
/// Index files user sources "download", by URL.
|
||||
served_indexes: HashMap<String, Vec<u8>>,
|
||||
user: Mutex<Vec<UserSource>>,
|
||||
/// User sources downloaded so far, and those whose file changed since.
|
||||
fetched: Mutex<Vec<String>>,
|
||||
changed: Mutex<Vec<String>>,
|
||||
/// Downloads stop halfway while this is true.
|
||||
held: (Mutex<bool>, Condvar),
|
||||
}
|
||||
|
||||
impl FakeStore {
|
||||
pub fn new(dir: impl Into<PathBuf>, files: HashMap<String, Vec<u8>>) -> FakeStore {
|
||||
FakeStore {
|
||||
dir: dir.into(),
|
||||
files,
|
||||
allowed: AtomicBool::new(false),
|
||||
calls: Mutex::new(Vec::new()),
|
||||
failure: Mutex::new(None),
|
||||
folder: None,
|
||||
builtin: None,
|
||||
served_indexes: HashMap::new(),
|
||||
user: Mutex::new(Vec::new()),
|
||||
fetched: Mutex::new(Vec::new()),
|
||||
changed: Mutex::new(Vec::new()),
|
||||
held: (Mutex::new(false), Condvar::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Uses `index` as the built-in indexes.
|
||||
pub fn with_index(mut self, index: Index) -> FakeStore {
|
||||
self.builtin = Some(index);
|
||||
self
|
||||
}
|
||||
|
||||
/// Has the index at `url` "download" as `bytes` once added as a source.
|
||||
pub fn serving_index(mut self, url: &str, bytes: Vec<u8>) -> FakeStore {
|
||||
self.served_indexes.insert(url.to_owned(), bytes);
|
||||
self
|
||||
}
|
||||
|
||||
/// The index at `url` now differs from the one first downloaded.
|
||||
pub fn change_index(&self, url: &str) {
|
||||
if let Ok(mut changed) = self.changed.lock() {
|
||||
changed.push(url.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
/// Also "downloads" files from a google/fonts checkout, by their path in the index.
|
||||
pub fn serving(mut self, checkout: impl Into<PathBuf>) -> FakeStore {
|
||||
self.folder = Some(checkout.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// The next download fails with `error`, as when the network is down.
|
||||
pub fn fail_next(&self, error: &str) {
|
||||
if let Ok(mut failure) = self.failure.lock() {
|
||||
*failure = Some(error.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads report half their bytes, then wait until [`FakeStore::release`] (with
|
||||
/// `Runner::Thread`).
|
||||
pub fn hold(&self) {
|
||||
if let Ok(mut held) = self.held.0.lock() {
|
||||
*held = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn release(&self) {
|
||||
if let Ok(mut held) = self.held.0.lock() {
|
||||
*held = false;
|
||||
}
|
||||
self.held.1.notify_all();
|
||||
}
|
||||
|
||||
fn wait_while_held(&self) {
|
||||
let Ok(mut held) = self.held.0.lock() else { return };
|
||||
while *held {
|
||||
match self.held.1.wait(held) {
|
||||
Ok(h) => held = h,
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything asked of it, downloads refused for lack of consent included.
|
||||
pub fn calls(&self) -> Vec<Call> {
|
||||
self.calls.lock().map(|c| c.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn record(&self, call: Call) {
|
||||
if let Ok(mut calls) = self.calls.lock() {
|
||||
calls.push(call);
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch(&self, family: &Family, only: Option<&str>, progress: &mut dyn FnMut(Progress)) -> Result<Vec<Downloaded>, StoreError> {
|
||||
if !self.downloads_allowed() {
|
||||
return Err(StoreError("downloading fonts is turned off".into()));
|
||||
}
|
||||
if let Some(error) = self.failure.lock().ok().and_then(|mut f| f.take()) {
|
||||
return Err(StoreError(error));
|
||||
}
|
||||
let wanted: Vec<_> = family.files.iter().filter(|f| only.is_none_or(|p| p == f.path)).collect();
|
||||
let total = wanted.iter().map(|f| f.size).sum();
|
||||
progress(Progress { done: total / 2, total });
|
||||
self.wait_while_held();
|
||||
let mut done = 0u64;
|
||||
let mut out = Vec::new();
|
||||
for file in wanted {
|
||||
let from_folder = || self.folder.as_ref().and_then(|dir| std::fs::read(dir.join(&file.path)).ok());
|
||||
let bytes =
|
||||
self.files.get(&file.path).cloned().or_else(from_folder).ok_or_else(|| StoreError(format!("{} isn't available", file.path)))?;
|
||||
done = done.saturating_add(file.size);
|
||||
progress(Progress { done, total });
|
||||
out.push(Downloaded { path: file.path.clone(), bytes });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn write(&self, family: &Family, files: Vec<Downloaded>) -> Result<(), StoreError> {
|
||||
let dir = self.dir.join(&family.family);
|
||||
std::fs::create_dir_all(&dir).map_err(err)?;
|
||||
for file in files {
|
||||
let name = file.path.rsplit('/').next().unwrap_or(&file.path);
|
||||
std::fs::write(dir.join(name), &file.bytes).map_err(err)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn in_store(&self, path: &Path) -> bool {
|
||||
path.starts_with(&self.dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Installer for FakeStore {
|
||||
fn downloads_allowed(&self) -> bool {
|
||||
self.allowed.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn allow_downloads(&self, allowed: bool) -> Result<(), StoreError> {
|
||||
self.allowed.store(allowed, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install(&self, _: &Index, family: &Family, progress: &mut dyn FnMut(Progress)) -> Result<(), StoreError> {
|
||||
self.record(Call::Install(family.family.clone()));
|
||||
let files = self.fetch(family, None, progress)?;
|
||||
self.write(family, files)
|
||||
}
|
||||
|
||||
fn install_file(&self, _: &Index, family: &Family, path: &str, progress: &mut dyn FnMut(Progress)) -> Result<(), StoreError> {
|
||||
self.record(Call::InstallFile(family.family.clone(), path.to_owned()));
|
||||
let files = self.fetch(family, Some(path), progress)?;
|
||||
self.write(family, files)
|
||||
}
|
||||
|
||||
fn remove(&self, request: &RemoveRequest) -> Result<(), StoreError> {
|
||||
self.record(Call::Remove(request.family.clone()));
|
||||
if let Some(outside) = request.files.iter().find(|f| !self.in_store(f)) {
|
||||
return Err(StoreError(format!("{} isn't in the font store", outside.display())));
|
||||
}
|
||||
for file in &request.files {
|
||||
std::fs::remove_file(file).map_err(err)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn download(&self, _: &Index, family: &Family, progress: &mut dyn FnMut(Progress)) -> Result<Vec<Downloaded>, StoreError> {
|
||||
self.record(Call::Download(family.family.clone()));
|
||||
self.fetch(family, None, progress)
|
||||
}
|
||||
|
||||
fn indexes(&self) -> Result<Index, StoreError> {
|
||||
let builtin = match &self.builtin {
|
||||
Some(index) => index.clone(),
|
||||
None => crate::index::bundled().map_err(err)?,
|
||||
};
|
||||
let fetched = self.fetched.lock().map(|f| f.clone()).unwrap_or_default();
|
||||
let changed = self.changed.lock().map(|c| c.clone()).unwrap_or_default();
|
||||
let mut users = Vec::new();
|
||||
let mut problems = Vec::new();
|
||||
for source in self.user_sources() {
|
||||
let problem = |error: String| craft_font_store::SourceProblem { source: source.url.clone(), name: source.url.clone(), error };
|
||||
if !fetched.contains(&source.url) {
|
||||
problems.push(problem("not downloaded yet".into()));
|
||||
continue;
|
||||
}
|
||||
match self.served_indexes.get(&source.url).map(|bytes| Index::from_user_bytes(bytes, &source.url)) {
|
||||
Some(Ok(mut index)) => {
|
||||
if changed.contains(&source.url) {
|
||||
index.mark_changed();
|
||||
}
|
||||
users.push(index);
|
||||
}
|
||||
Some(Err(e)) => problems.push(problem(e.to_string())),
|
||||
None => problems.push(problem("nothing at this address".into())),
|
||||
}
|
||||
}
|
||||
Ok(Index::merge(std::iter::once(builtin).chain(users)).with_problems(problems))
|
||||
}
|
||||
|
||||
fn fetch_indexes(&self) -> Result<Index, StoreError> {
|
||||
if !self.downloads_allowed() {
|
||||
return Err(StoreError("downloading fonts is turned off".into()));
|
||||
}
|
||||
if let Ok(mut fetched) = self.fetched.lock() {
|
||||
*fetched = self.user_sources().into_iter().map(|s| s.url).collect();
|
||||
}
|
||||
self.indexes()
|
||||
}
|
||||
|
||||
fn user_sources(&self) -> Vec<UserSource> {
|
||||
self.user.lock().map(|u| u.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn add_user_source(&self, url: &str) -> Result<(), StoreError> {
|
||||
if !url.starts_with("https://") {
|
||||
return Err(StoreError("an index address starts with https://".into()));
|
||||
}
|
||||
let mut user = self.user.lock().map_err(err)?;
|
||||
if !user.iter().any(|s| s.url == url) {
|
||||
user.push(UserSource { url: url.to_owned(), added_at: 0, last_sha256: None });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_user_source(&self, url: &str) -> Result<(), StoreError> {
|
||||
self.user.lock().map_err(err)?.retain(|s| s.url != url);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
//! The main flows against a small index, a font folder made per test and a fake font store.
|
||||
//! `CRAFT_FONT_MANAGER_SNAPSHOTS=<dir>` also saves screenshots; run that with
|
||||
//! `-- --test-threads=1`, as rendering on several threads at once can crash older Vulkan loaders.
|
||||
//! With `CRAFT_FONT_MANAGER_GOOGLE_FONTS=<google/fonts checkout>` the fake store also "downloads"
|
||||
//! the index's files from it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_font_manager::craft_font_catalog::Os;
|
||||
use craft_font_manager::index::Index;
|
||||
use craft_font_manager::store::{Call, FakeStore, Installer};
|
||||
use craft_font_manager::{Catalog, FontEnv, Source};
|
||||
use craft_font_manager::{FontsPanel, Look, Options, Palette, Runner, Settings, Tab, install_interface_fonts};
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{By, NodeT, Queryable};
|
||||
|
||||
const HACK: &str = "ofl/hack/Hack-Regular.ttf";
|
||||
const UBUNTU: &str = "ufl/ubuntu/Ubuntu-Light.ttf";
|
||||
|
||||
fn index() -> Index {
|
||||
let file = |path: &str, size: usize| {
|
||||
format!(r#"{{"path":"{path}","size":{size},"sha256":"{}","style":"normal","weight":400,"axes":[],"postscript":[]}}"#, "0a".repeat(32))
|
||||
};
|
||||
let family = |name: &str, category: &str, languages: &str, scripts: &str, files: String| {
|
||||
format!(
|
||||
r#"{{"family":"{name}","designer":"","categories":["{category}"],"languages":[{languages}],"scripts":[{scripts}],"license":"OFL","license_path":null,"files":[{files}]}}"#
|
||||
)
|
||||
};
|
||||
let json = format!(
|
||||
r#"{{"schema":1,"version":"1.0","source":"google/fonts","commit":"45e3d5269e4a17d823f6d84470bc084dc5ae46f6","base_url":"https://cdn.jsdelivr.net/gh/google/fonts@45e3d5269e4a17d823f6d84470bc084dc5ae46f6/","fallback_base_url":"https://raw.githubusercontent.com/google/fonts/45e3d5269e4a17d823f6d84470bc084dc5ae46f6/",
|
||||
"languages":[
|
||||
{{"id":"ar_Arab","name":"Arabic","autonym":"العربية","script":"Arab","population":1,"historical":false}},
|
||||
{{"id":"en_Latn","name":"English","autonym":"English","script":"Latn","population":1,"historical":false}},
|
||||
{{"id":"ja_Jpan","name":"Japanese","autonym":"日本語","script":"Jpan","population":1,"historical":false}}],
|
||||
"scripts":[{{"id":"Arab","name":"Arabic"}},{{"id":"Jpan","name":"Japanese"}},{{"id":"Latn","name":"Latin"}}],
|
||||
"families":[{},{},{},{}]}}"#,
|
||||
family("Amiri", "SERIF", "0,1", r#""Arab","Latn""#, file("ofl/amiri/Amiri-Regular.ttf", 1000)),
|
||||
family("Hack", "MONOSPACE", "1", r#""Latn""#, file(HACK, epaint_default_fonts::HACK_REGULAR.len())),
|
||||
family("Noto Sans JP", "SANS_SERIF", "1,2", r#""Jpan","Latn""#, file("ofl/notosansjp/NotoSansJP[wght].ttf", 1000)),
|
||||
family("Ubuntu", "SANS_SERIF", "1", r#""Latn""#, file(UBUNTU, epaint_default_fonts::UBUNTU_LIGHT.len())),
|
||||
);
|
||||
Index::from_json(json.as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
/// A system with Ubuntu Light installed, an empty font store, and a fake store that can "download"
|
||||
/// Hack and Ubuntu Light.
|
||||
fn setup(name: &str, settings: Settings) -> (Harness<'static, FontsPanel>, Arc<FakeStore>) {
|
||||
setup_with(name, settings, Runner::Inline, |_| {})
|
||||
}
|
||||
|
||||
/// [`setup`] with a job runner and the context set up by `fonts` (the interface fonts and
|
||||
/// direction), at the launcher's window size less its header and footer.
|
||||
fn setup_with(
|
||||
name: &str,
|
||||
settings: Settings,
|
||||
runner: Runner,
|
||||
fonts: impl Fn(&egui::Context) + 'static,
|
||||
) -> (Harness<'static, FontsPanel>, Arc<FakeStore>) {
|
||||
let root = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("flows").join(name);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let system = root.join("usr/share/fonts");
|
||||
std::fs::create_dir_all(&system).unwrap();
|
||||
std::fs::write(system.join("Ubuntu-Light.ttf"), epaint_default_fonts::UBUNTU_LIGHT).unwrap();
|
||||
let store_dir = root.join("store");
|
||||
std::fs::create_dir_all(&store_dir).unwrap();
|
||||
let env = FontEnv { os: Os::Unix, root: root.clone(), font_store: Some(store_dir.clone()), ..FontEnv::default() };
|
||||
let catalog = Arc::new(Catalog::new(env).with_font_service(false));
|
||||
catalog.rescan();
|
||||
let files = HashMap::from([
|
||||
(HACK.to_owned(), epaint_default_fonts::HACK_REGULAR.to_vec()),
|
||||
(UBUNTU.to_owned(), epaint_default_fonts::UBUNTU_LIGHT.to_vec()),
|
||||
]);
|
||||
let store = FakeStore::new(store_dir, files);
|
||||
let store = Arc::new(match std::env::var_os("CRAFT_FONT_MANAGER_GOOGLE_FONTS") {
|
||||
Some(checkout) => store.serving(checkout),
|
||||
None => store,
|
||||
});
|
||||
let options = Options { catalog, installer: store.clone(), index: Ok(index()), runner };
|
||||
let app = FontsPanel::new(options, settings);
|
||||
let mut harness = Harness::builder()
|
||||
.with_size([1120.0, 678.0])
|
||||
// Autonyms such as العربية need fonts beyond egui's; the tests don't depend on the host's.
|
||||
.allow_missing_glyphs()
|
||||
.with_context_setup(move |ctx| {
|
||||
install_interface_fonts(ctx, &[]);
|
||||
fonts(ctx);
|
||||
})
|
||||
.build_ui_state(|ui, app: &mut FontsPanel| app.ui(ui), app);
|
||||
harness.run_steps(4);
|
||||
(harness, store)
|
||||
}
|
||||
|
||||
fn settle(h: &mut Harness<'_, FontsPanel>) {
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, FontsPanel>, label: &str) {
|
||||
h.get_all_by_label(label).next().unwrap_or_else(|| panic!("no {label:?}")).click();
|
||||
settle(h);
|
||||
}
|
||||
|
||||
/// Clicks the first node whose name has every one of `parts` (names with a placeholder are
|
||||
/// isolated for right-to-left text).
|
||||
fn click_parts(h: &mut Harness<'_, FontsPanel>, parts: &[&str]) {
|
||||
let wanted: Vec<String> = parts.iter().map(|p| (*p).to_owned()).collect();
|
||||
let by = By::new().predicate(move |n| n.label().is_some_and(|l| wanted.iter().all(|p| l.contains(p.as_str()))));
|
||||
h.get_all(by).next().unwrap_or_else(|| panic!("no {parts:?}")).click();
|
||||
settle(h);
|
||||
}
|
||||
|
||||
fn click_role(h: &mut Harness<'_, FontsPanel>, role: Role, label: &str) {
|
||||
h.get_by_role_and_label(role, label).click();
|
||||
settle(h);
|
||||
}
|
||||
|
||||
fn type_into(h: &mut Harness<'_, FontsPanel>, label: &str, text: &str) {
|
||||
h.get_by_role_and_label(Role::TextInput, label).focus();
|
||||
h.step();
|
||||
h.get_by_role_and_label(Role::TextInput, label).type_text(text);
|
||||
settle(h);
|
||||
}
|
||||
|
||||
fn allow(h: &mut Harness<'_, FontsPanel>) {
|
||||
h.state_mut().set_downloads_allowed(true).unwrap();
|
||||
settle(h);
|
||||
}
|
||||
|
||||
fn installed_tab(h: &mut Harness<'_, FontsPanel>) {
|
||||
click_role(h, Role::Tab, "Installed");
|
||||
assert_eq!(h.state().tab(), Tab::Installed);
|
||||
}
|
||||
|
||||
fn has_installed(h: &Harness<'_, FontsPanel>, family: &str) -> bool {
|
||||
h.state().installed().iter().any(|f| f.name == family)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_and_filters_narrow_the_catalogue() {
|
||||
let (mut h, _) = setup("search", Settings::default());
|
||||
assert_eq!(h.state().tab(), Tab::Catalogue);
|
||||
assert_eq!(h.state().results(), ["Amiri", "Hack", "Noto Sans JP", "Ubuntu"]);
|
||||
|
||||
type_into(&mut h, "Search", "noto");
|
||||
assert_eq!(h.state().results(), ["Noto Sans JP"]);
|
||||
|
||||
type_into(&mut h, "Search", "XYZ");
|
||||
assert!(h.state().results().is_empty());
|
||||
assert!(h.query_by_label("No fonts match").is_some());
|
||||
click(&mut h, "Clear search and filters");
|
||||
assert_eq!(h.state().results().len(), 4);
|
||||
|
||||
click_role(&mut h, Role::RadioButton, "Serif");
|
||||
assert_eq!(h.state().results(), ["Amiri"]);
|
||||
click_role(&mut h, Role::RadioButton, "All categories");
|
||||
|
||||
// Ubuntu Light is installed on the system.
|
||||
click_role(&mut h, Role::RadioButton, "Installed");
|
||||
assert_eq!(h.state().results(), ["Ubuntu"]);
|
||||
click_role(&mut h, Role::RadioButton, "Not installed");
|
||||
assert_eq!(h.state().results(), ["Amiri", "Hack", "Noto Sans JP"]);
|
||||
click(&mut h, "Clear filters");
|
||||
assert_eq!(h.state().results().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_filter_finds_languages_by_their_own_name() {
|
||||
let (mut h, _) = setup("language", Settings::default());
|
||||
click_role(&mut h, Role::ComboBox, "Language");
|
||||
type_into(&mut h, "Find a language", "日本");
|
||||
assert!(h.query_by_label("Arabic · العربية").is_none());
|
||||
click(&mut h, "Japanese · 日本語");
|
||||
assert_eq!(h.state().results(), ["Noto Sans JP"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_a_family_then_remove_it_from_its_card() {
|
||||
let (mut h, store) = setup("install", Settings::default());
|
||||
assert!(!has_installed(&h, "Hack"));
|
||||
|
||||
click(&mut h, "Hack");
|
||||
assert_eq!(h.state().details_family().as_deref(), Some("Hack"));
|
||||
click(&mut h, "Install all");
|
||||
click(&mut h, "Download");
|
||||
assert_eq!(store.calls(), [Call::Install("Hack".into())]);
|
||||
assert!(h.state().downloads_allowed());
|
||||
let hack = h.state().installed().iter().find(|f| f.name == "Hack").cloned().expect("Hack is listed");
|
||||
assert_eq!(hack.source, Source::Store);
|
||||
assert!(h.query_by_label("Remove all").is_some());
|
||||
|
||||
h.key_press(egui::Key::Escape);
|
||||
settle(&mut h);
|
||||
assert!(h.state().details_family().is_none());
|
||||
installed_tab(&mut h);
|
||||
click(&mut h, "Remove Hack");
|
||||
assert_eq!(store.calls().last(), Some(&Call::Remove("Hack".into())));
|
||||
assert!(!has_installed(&h, "Hack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_and_remove_one_style() {
|
||||
let (mut h, store) = setup("style", Settings::default());
|
||||
allow(&mut h);
|
||||
click(&mut h, "Hack");
|
||||
click(&mut h, "Install Regular");
|
||||
assert_eq!(store.calls(), [Call::InstallFile("Hack".into(), HACK.into())]);
|
||||
assert!(has_installed(&h, "Hack"));
|
||||
|
||||
click(&mut h, "Remove Regular");
|
||||
assert_eq!(store.calls().last(), Some(&Call::Remove("Hack".into())));
|
||||
assert!(!has_installed(&h, "Hack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_fonts_cannot_be_removed() {
|
||||
let (mut h, store) = setup("system", Settings::default());
|
||||
installed_tab(&mut h);
|
||||
let system = h.state().installed().first().cloned().expect("the system font is listed");
|
||||
assert_eq!(system.source, Source::System);
|
||||
assert!(h.query_by_label(&format!("Remove {}", system.name)).is_none());
|
||||
click(&mut h, &system.name);
|
||||
let remove = h.get_by_label("Remove all");
|
||||
assert!(remove.accesskit_node().is_disabled());
|
||||
remove.click();
|
||||
settle(&mut h);
|
||||
assert!(store.calls().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_downloads_without_consent() {
|
||||
let (mut h, store) = setup("consent", Settings::default());
|
||||
assert!(h.query_by_label("Downloads are off").is_some());
|
||||
click(&mut h, "Hack");
|
||||
|
||||
click(&mut h, "Preview");
|
||||
assert!(h.query_by_label("Download from Google Fonts?").is_some());
|
||||
click(&mut h, "Cancel");
|
||||
click(&mut h, "Install all");
|
||||
click(&mut h, "Cancel");
|
||||
assert!(store.calls().is_empty());
|
||||
assert!(!h.state().downloads_allowed());
|
||||
assert!(h.query_by_label("Download from Google Fonts?").is_none());
|
||||
h.key_press(egui::Key::Escape);
|
||||
settle(&mut h);
|
||||
|
||||
// Allowed from the note, a preview downloads without asking and installs nothing.
|
||||
click_role(&mut h, Role::Button, "Allow downloads");
|
||||
assert!(h.state().downloads_allowed());
|
||||
assert!(h.query_by_label("Downloads are off").is_none());
|
||||
click(&mut h, "Preview Hack");
|
||||
assert_eq!(store.calls(), [Call::Download("Hack".into())]);
|
||||
assert!(!has_installed(&h, "Hack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_install_offers_retry() {
|
||||
let (mut h, store) = setup("retry", Settings::default());
|
||||
allow(&mut h);
|
||||
store.fail_next("network unreachable");
|
||||
click(&mut h, "Install Hack");
|
||||
assert!(h.query_by_label("Couldn't install").is_some());
|
||||
assert!(!has_installed(&h, "Hack"));
|
||||
click(&mut h, "Retry");
|
||||
assert!(has_installed(&h, "Hack"));
|
||||
assert!(h.query_by_label("Couldn't install").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_move_between_cards_and_enter_opens_one() {
|
||||
let (mut h, _) = setup("keys", Settings::default());
|
||||
h.get_by_role_and_label(Role::Button, "Amiri").focus();
|
||||
settle(&mut h);
|
||||
h.key_press(egui::Key::ArrowDown);
|
||||
settle(&mut h);
|
||||
h.key_press(egui::Key::Enter);
|
||||
settle(&mut h);
|
||||
assert_eq!(h.state().details_family().as_deref(), Some("Hack"));
|
||||
}
|
||||
|
||||
const USER_INDEX: &str = "https://fonts.example.org/index.json";
|
||||
|
||||
/// A source a user could add: another "Hack", from an index of its own.
|
||||
fn user_index() -> Vec<u8> {
|
||||
format!(
|
||||
r#"{{"schema":1,"version":"1.1","source":"examplefonts","commit":"{commit}",
|
||||
"source_info":{{"id":"examplefonts","name":"Example Fonts","curated":true,"file_urls":["https://fonts.example.org/files/{{path}}"]}},
|
||||
"languages":[{{"id":"en_Latn","name":"English","script":"Latn","population":1}}],
|
||||
"scripts":[{{"id":"Latn","name":"Latin"}}],
|
||||
"families":[{{"family":"Hack","source":"examplefonts","categories":["MONOSPACE"],"license":"OFL","license_path":null,"languages":[0],"scripts":["Latn"],
|
||||
"files":[{{"path":"{HACK}","size":{size},"sha256":"{sha}","style":"normal","weight":400,"axes":[],"postscript":[]}}]}}]}}"#,
|
||||
commit = "1".repeat(40),
|
||||
size = epaint_default_fonts::HACK_REGULAR.len(),
|
||||
sha = "0a".repeat(32),
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// The panel with its sources beside it, as a host's settings show them.
|
||||
fn sources_setup(name: &str) -> (Harness<'static, FontsPanel>, Arc<FakeStore>) {
|
||||
let root = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("flows").join(name);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let store_dir = root.join("store");
|
||||
std::fs::create_dir_all(&store_dir).unwrap();
|
||||
let env = FontEnv { os: Os::Unix, root: root.clone(), font_store: Some(store_dir.clone()), ..FontEnv::default() };
|
||||
let catalog = Arc::new(Catalog::new(env).with_font_service(false));
|
||||
catalog.rescan();
|
||||
let store = Arc::new(FakeStore::new(store_dir, HashMap::new()).with_index(index()).serving_index(USER_INDEX, user_index()));
|
||||
store.allow_downloads(true).unwrap();
|
||||
let options = Options { catalog, installer: store.clone(), index: store.indexes().map_err(|e| e.0), runner: Runner::Inline };
|
||||
let mut harness = Harness::builder()
|
||||
.with_size([1400.0, 760.0])
|
||||
.allow_missing_glyphs()
|
||||
.with_context_setup(|ctx| install_interface_fonts(ctx, &[]))
|
||||
.build_ui_state(
|
||||
|ui, app: &mut FontsPanel| {
|
||||
egui::Panel::left("sources").exact_size(420.0).show(ui, |ui| app.sources_ui(ui));
|
||||
egui::CentralPanel::default().show(ui, |ui| app.ui(ui));
|
||||
},
|
||||
FontsPanel::new(options, Settings::default()),
|
||||
);
|
||||
harness.run_steps(4);
|
||||
(harness, store)
|
||||
}
|
||||
|
||||
/// Steps until `done`, for work on the panel's own threads.
|
||||
fn wait(h: &mut Harness<'_, FontsPanel>, done: impl Fn(&FontsPanel) -> bool) {
|
||||
for _ in 0..200 {
|
||||
if done(h.state()) {
|
||||
settle(h);
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
h.step();
|
||||
}
|
||||
panic!("timed out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_added_source_is_not_curated_and_can_be_removed() {
|
||||
let (mut h, store) = sources_setup("sources");
|
||||
assert_eq!(h.state().results().len(), 4);
|
||||
type_into(&mut h, "Index address", USER_INDEX);
|
||||
click_role(&mut h, Role::Button, "Add");
|
||||
wait(&mut h, |p| p.results().len() == 5);
|
||||
// Two families named Hack, one from each source, each with its own card.
|
||||
assert_eq!(h.state().results().iter().filter(|f| *f == "Hack").count(), 2);
|
||||
assert!(h.query_all_by_label("Not curated").count() >= 2, "a badge on the card and one in the sources");
|
||||
assert!(h.query_by_label("Changed since added").is_none());
|
||||
|
||||
store.change_index(USER_INDEX);
|
||||
click(&mut h, "Update sources");
|
||||
wait(&mut h, |p| !p.fetching_sources());
|
||||
assert!(h.query_by_label("Changed since added").is_some());
|
||||
|
||||
click_parts(&mut h, &["Remove", USER_INDEX]);
|
||||
assert_eq!(h.state().results().len(), 4);
|
||||
assert!(store.user_sources().is_empty());
|
||||
}
|
||||
|
||||
/// The launcher's colours, for screenshots that look as the panel does there.
|
||||
fn launcher_look() -> Look {
|
||||
let hex = |rgb: u32| egui::Color32::from_rgb((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8);
|
||||
let ink = |rgb: u32, a: u8| egui::Color32::from_rgba_unmultiplied((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, a);
|
||||
let dark = Palette {
|
||||
bg: hex(0x121316),
|
||||
sunken: hex(0x060607),
|
||||
ink: hex(0xf2f1ee),
|
||||
ink_strong: hex(0xffffff),
|
||||
muted: ink(0xf2f1ee, 153),
|
||||
faint: ink(0xf2f1ee, 97),
|
||||
line: ink(0xf2f1ee, 38),
|
||||
line_strong: ink(0xf2f1ee, 102),
|
||||
accent: hex(0x2d81ff),
|
||||
accent_ink: hex(0x74aaff),
|
||||
invert_bg: hex(0xffffff),
|
||||
invert_fg: hex(0x0b0b0c),
|
||||
good: hex(0x3fd6b8),
|
||||
warn: hex(0xffc14d),
|
||||
bad: hex(0xff6259),
|
||||
};
|
||||
Look { dark: Some(dark), caps: true, corner: egui::CornerRadius::ZERO, control_height: 34.0, ..Look::default() }
|
||||
}
|
||||
|
||||
fn save(h: &mut Harness<'_, FontsPanel>, dir: &std::ffi::OsStr, name: &str) {
|
||||
h.run_steps(8);
|
||||
let image = h.render().unwrap();
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
image.save(PathBuf::from(dir).join(format!("{name}.png"))).unwrap();
|
||||
}
|
||||
|
||||
/// The interface fonts with the system's for the scripts egui's own faces lack.
|
||||
fn system_fonts(language: &'static str) -> impl Fn(&egui::Context) + 'static {
|
||||
move |ctx| {
|
||||
use craft_ui::fonts::{I18nScripts, UiFonts};
|
||||
let provider = Arc::new(egui_system_font_provider::SystemFontProvider::new().with_locale(Some(language)));
|
||||
UiFonts::new(&I18nScripts).languages(&["en", language]).ui_language(language).system_provider(provider).install(ctx);
|
||||
craft_ui::direction::apply(ctx, craft_ui::direction::resolve(language, craft_ui::direction::Setting::Language));
|
||||
}
|
||||
}
|
||||
|
||||
/// `CRAFT_FONT_MANAGER_SNAPSHOTS=<dir>`: the panel's main states.
|
||||
#[test]
|
||||
fn screenshots() {
|
||||
let Some(dir) = std::env::var_os("CRAFT_FONT_MANAGER_SNAPSHOTS") else { return };
|
||||
let shot = |name: &str, language: &'static str, f: &dyn Fn(&mut Harness<'_, FontsPanel>, &FakeStore)| {
|
||||
let (mut h, store) = setup_with(&format!("shot-{name}"), Settings::default(), Runner::Thread, system_fonts(language));
|
||||
h.state_mut().set_look(launcher_look());
|
||||
h.state_mut().set_language(language);
|
||||
settle(&mut h);
|
||||
f(&mut h, &store);
|
||||
save(&mut h, &dir, name);
|
||||
store.release();
|
||||
};
|
||||
shot("catalogue-downloads-off", "en", &|_, _| {});
|
||||
shot("catalogue", "en", &|h, _| {
|
||||
allow(h);
|
||||
click(h, "Preview Hack");
|
||||
h.run_steps(20);
|
||||
});
|
||||
shot("downloading", "en", &|h, store| {
|
||||
allow(h);
|
||||
store.hold();
|
||||
click(h, "Install Hack");
|
||||
h.run_steps(10);
|
||||
});
|
||||
shot("failed", "en", &|h, store| {
|
||||
allow(h);
|
||||
store.fail_next("couldn't reach cdn.jsdelivr.net or raw.githubusercontent.com");
|
||||
click(h, "Install Hack");
|
||||
h.run_steps(20);
|
||||
});
|
||||
shot("empty-search", "en", &|h, _| type_into(h, "Search", "Garamondish"));
|
||||
shot("installed", "en", &|h, _| {
|
||||
allow(h);
|
||||
click(h, "Install Hack");
|
||||
h.run_steps(20);
|
||||
installed_tab(h);
|
||||
});
|
||||
shot("details", "en", &|h, _| {
|
||||
allow(h);
|
||||
click(h, "Preview Hack");
|
||||
h.run_steps(20);
|
||||
click(h, "Hack");
|
||||
});
|
||||
shot("details-not-installed", "en", &|h, _| click(h, "Noto Sans JP"));
|
||||
shot("arabic", "ar", &|h, _| {
|
||||
allow(h);
|
||||
click_parts(h, &["معاينة", "Amiri"]);
|
||||
h.run_steps(20);
|
||||
click_role(h, Role::ComboBox, "اللغة");
|
||||
type_into(h, "ابحث عن لغة", "Arabic");
|
||||
click(h, "Arabic · العربية");
|
||||
});
|
||||
shot("arabic-details", "ar", &|h, _| {
|
||||
allow(h);
|
||||
click_parts(h, &["معاينة", "Amiri"]);
|
||||
h.run_steps(20);
|
||||
click(h, "Amiri");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "craft-font-store"
|
||||
description = "The Craft apps' font store: the font indexes (Google Fonts, Noto, sources the user adds), exact font lookup, and verified installs into the suite's font folder."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# The pinned built-in indexes compiled in (`Index::bundled`), about 620 KB gzipped, so an app can
|
||||
# look fonts up with no request at all.
|
||||
bundled-index = []
|
||||
|
||||
[dependencies]
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = "0.10"
|
||||
skrifa = { version = "0.47", default-features = false, features = ["std"] }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# The store and the network are native only; the web keeps fonts elsewhere.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-atomic = { workspace = true }
|
||||
craft-font-catalog = { workspace = true }
|
||||
# Blocking HTTPS with rustls and webpki roots: no async runtime, no OpenSSL.
|
||||
ureq = { version = "3", default-features = false, features = ["rustls", "gzip"] }
|
||||
|
||||
[dev-dependencies]
|
||||
write-fonts = "0.54"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,122 @@
|
||||
# craft-font-store
|
||||
|
||||
The suite's font store: the font indexes of every source, exact lookup of a missing font, and verified installs into the one folder every app's font catalog reads ([`craft-font-catalog`](../font-catalog/README.md)'s `font_store_dir`).
|
||||
|
||||
The indexes are [aiongg/craft-font-index](https://github.com/aiongg/craft-font-index) (format 1.x), designed by @kntrttm for photocraft#1135 and #2453. Every font file and licence in an index has a size and a sha256, and only families under a licence that allows redistribution (OFL, Apache 2.0, Ubuntu Font Licence) are listed.
|
||||
|
||||
## Sources
|
||||
|
||||
| Source | ID | Curated | Fonts come from |
|
||||
|---|---|---|---|
|
||||
| Google Fonts | `google/fonts` | yes | [google/fonts](https://github.com/google/fonts) at the commit the index records |
|
||||
| Noto | `notofonts` | yes | [notofonts](https://github.com/notofonts) repositories at the commits and release tags the index records (the full CJK fonts from noto-cjk, and script families, widths and UI variants Google Fonts lacks) |
|
||||
| An index the user adds | `user:<host>/<id>` | never | wherever its URL templates point (https only) |
|
||||
|
||||
The built-in sources are the indexes `sources.json` lists. `PINNED_INDEX` names a commit of the index repository and the sha256 of its `sources.json`; each index must have the size and sha256 `sources.json` gives it.
|
||||
|
||||
A source the user adds is an index file at an https URL (`.json` or `.json.gz`). It is checked by the same rules as a built-in one, but:
|
||||
|
||||
- it is never curated, whatever its file says, so the interface can mark its families "not curated";
|
||||
- its ID is prefixed with `user:<host>/`, so it can't stand in for a built-in source;
|
||||
- the sha256 of the file first fetched is kept, and a different file later shows as `FontSource::changed`;
|
||||
- only an app's own interface can add one (`add_user_source`); an agent gets `Error::NotAllowed`.
|
||||
|
||||
## Network access
|
||||
|
||||
| Rule | How |
|
||||
|---|---|
|
||||
| Off until the user turns it on | `FontStore::network()` is `Network::Off` until `enable_network()`. The setting is `settings.json` in the store folder, so it holds for every app. |
|
||||
| Only the user turns it on | `FontStore::for_user` is for the app's own interface; call `enable_network()` from a setting or from the user's Install click. `FontStore::for_agent` (MCP, CLI, scripting) gets `Error::NotAllowed`. An agent can install while the user has the network on. |
|
||||
| No requests at startup | Nothing in the crate makes a request on its own. Only `fetch_indexes` and the installs do, and they return `Error::NetworkOff` without one while the network is off. |
|
||||
| One HTTP client | `Client`: ureq with rustls and the webpki roots, HTTPS only, no cookies, `User-Agent: craft-font-store/<version>`. Timeouts: 15 s to connect, 30 s to the response headers, 10 minutes per file. |
|
||||
|
||||
A file's URLs come from its source's URL templates, best first. Both built-in sources use `https://cdn.jsdelivr.net/gh/{repo}@{ref}/{path}`, then `https://raw.githubusercontent.com/{repo}/{ref}/{path}`. The store tries the next URL after any failure: a network error, an HTTP error (jsDelivr answers 403 for files over 20 MB, such as the Noto Serif CJK fonts), or bytes that don't verify.
|
||||
|
||||
## The index
|
||||
|
||||
`Index` holds the families of one or more sources. Families are sorted by name. Two sources can have a family of the same name (Google Fonts and Noto both have `Noto Serif Myanmar`, Noto's with more styles); lookups by name prefer the first source: the built-in ones in `sources.json` order, then the user's.
|
||||
|
||||
| Call | What it does |
|
||||
|---|---|
|
||||
| `Index::bundled()` (feature `bundled-index`) | The pinned built-in indexes compiled in: about 620 KB gzipped, no request. |
|
||||
| `FontStore::offline_indexes()` (feature `bundled-index`) | The bundled indexes and the user's sources as last fetched. |
|
||||
| `FontStore::cached_indexes(&PINNED_INDEX)` | The indexes the last `fetch_indexes` saved, when its `sources.json` is the pin's and every built-in index matches it, with the user's sources. |
|
||||
| `FontStore::fetch_indexes(&PINNED_INDEX, &cancel)` | Downloads `sources.json`, every gzipped index it lists and every user source, checks them and saves them. An index that fails is reported in `Index::problems()`; the others load. Needs the network on. |
|
||||
| `Index::from_bytes(bytes)`, `Index::from_user_bytes(bytes, url)` | One index file, JSON or gzipped JSON. |
|
||||
| `Index::merge(indexes)` | One index of several. |
|
||||
|
||||
Reading checks every entry: the version (a major version other than 1 is refused; unknown fields are ignored), https URL templates, safe relative paths (no empty, `.` or `..` segment, no `\`), safe and unique file names, `owner/name` repositories and refs, sha256s, sizes, weights and widths. A format 1.0 index is Google Fonts, with URLs from its `base_url` and `fallback_base_url`.
|
||||
|
||||
| Call | What it gives |
|
||||
|---|---|
|
||||
| `index.sources()`, `index.source_of(family)` | Each source's name, homepage, curated flag, commit, and families left out with why. |
|
||||
| `family.source`, `family.is_curated()` | The family's source ID, and whether that source is curated. |
|
||||
| `index.file_urls(family, file)`, `index.license_urls(family)` | The URLs to try, best first. |
|
||||
|
||||
## Exact matches only
|
||||
|
||||
| Call | Matches |
|
||||
|---|---|
|
||||
| `index.family(name)` | The whole family name, ignoring case, from the first source that has it. `families_named(name)` gives every source's; `family_from(source, name)` one source's. |
|
||||
| `index.find_face(family, style)` | A style name (`Bold Italic`, `SemiBold`), a CSS weight (`700`) or a PostScript style (`BoldItalic`, `CondensedBold`). A variable file has every weight its `wght` axis reaches. A file of normal width is preferred. |
|
||||
| `index.find_postscript(name)` | The PostScript name, case included. A name that families of different names share is `Unavailable::Ambiguous`; look it up in the document's family with `Family::face_by_postscript`. |
|
||||
|
||||
A family without the style asked for is `Unavailable::NoStyle`: the store never offers a nearer weight or a similar family. Substitution stays a separate choice in each app.
|
||||
|
||||
## Installing
|
||||
|
||||
```rust
|
||||
use craft_font_catalog::{Catalog, FontEnv};
|
||||
use craft_font_store::{Cancel, FontStore, Index};
|
||||
|
||||
let store = FontStore::for_user(&FontEnv::from_process()).unwrap().with_catalog(Catalog::global().clone());
|
||||
let index = store.offline_indexes();
|
||||
let face = index.find_face("Lato", "Bold Italic")?;
|
||||
store.enable_network()?; // the user clicked Install
|
||||
let cancel = Cancel::new(); // cancel.cancel() from the UI thread stops the download
|
||||
store.install_face(&index, face, &cancel, &mut |p| println!("{} of {} bytes", p.done, p.total))?;
|
||||
```
|
||||
|
||||
`install_family` installs every file of a family; `install_face` one file and the licence. Each file is:
|
||||
|
||||
1. downloaded (at most its index size), with progress and cancellation;
|
||||
2. checked: its size, its sha256, then that every face in it parses and has outlines (`verify_font`);
|
||||
3. saved under `.staging/` as `<name>.part`, which no font scan picks up.
|
||||
|
||||
The licence file (`OFL.txt`, `LICENSE`) is checked against its size and sha256 and goes beside the fonts. For a format 1.0 index, which has no sha256 for it, only its size is checked (1 MiB at most).
|
||||
|
||||
When every file has passed, the store takes `.lock`, moves the files into the family's folder and writes `manifest.json` (written atomically through `craft-atomic`). If a move or the manifest fails, the files moved so far are taken back out and replaced files are restored. A failed or cancelled install leaves nothing behind. Files already installed with the same sha256 aren't downloaded again.
|
||||
|
||||
## The store folder
|
||||
|
||||
```text
|
||||
settings.json the network setting and the sources the user added
|
||||
manifest.json what is installed
|
||||
indexes/ the last indexes fetched: sources.json, <source>.json.gz, user-<hash>
|
||||
<family folder>/ the font files and the licence
|
||||
.staging/ downloads being installed
|
||||
.lock held while the store changes
|
||||
```
|
||||
|
||||
A Google Fonts family's folder is google/fonts' own folder name (`lato/`); another source's is `<source>/<family>` (`notofonts/noto-sans-cjk-jp/`, `user-fonts-example-org-myfonts-bfeb06e2/some-family/`).
|
||||
|
||||
`manifest.json` lists each installed family: its name, source, folder, licence and licence file, and per file its name, path in its repository, size, sha256, the index commit it came from, and its PostScript names. An entry is one family of one source, so the same name from two sources is two entries. A manifest that can't be read is moved aside to `manifest.json.bad` and counts as empty.
|
||||
|
||||
| Call | What it does |
|
||||
|---|---|
|
||||
| `installed()`, `installed_family(source, name)`, `is_installed(family, file)` | Read the manifest. |
|
||||
| `remove_family(source, name)` | Moves the family folder out at once, updates the manifest, then deletes the files and the licence. |
|
||||
| `verify()` | Reads every installed file and reports the missing, damaged and unparseable ones. |
|
||||
| `user_sources()`, `add_user_source(url)`, `remove_user_source(url)` | The sources the user added. Removing one leaves its installed families installed. |
|
||||
|
||||
## Telling the catalog
|
||||
|
||||
The catalog scans the store folder. With `with_catalog(catalog)`, every install and removal ends with `catalog.rescan()`, which reads only new and changed files, so the app sees the fonts at once. Other running apps notice through `Catalog::rescan_if_changed` (when their window is activated) or `Catalog::watch`.
|
||||
|
||||
## Web
|
||||
|
||||
The index, the lookups, `verify_font` and `IndexPin` build for `wasm32-unknown-unknown`. The store folder and the HTTP client are native only; the web keeps fonts elsewhere.
|
||||
|
||||
## Tests
|
||||
|
||||
`cargo test -p craft-font-store --all-features`. The store tests run a small HTTP server on 127.0.0.1 that stands in for jsDelivr, raw GitHub and a user's host; no test uses the network. One ignored test fetches the pinned indexes and installs a Noto family over the network: `cargo test -p craft-font-store --all-features -- --ignored`.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
{"schema":1,"version":"1.1","indexes":[
|
||||
{"source":"google/fonts","name":"Google Fonts","curated":true,"path":"indexes/google-fonts.json","size":4724581,"sha256":"72ea418e43785d572e73350ad08e9e6601a5a12f93315805f92bd3e092618a63","gz_path":"indexes/google-fonts.json.gz","gz_size":596893,"gz_sha256":"62d1d5645417a691e4710f6d1b895f925284ea9c804de268a97432bd67e9c1f6","commit":"45e3d5269e4a17d823f6d84470bc084dc5ae46f6","families":2040,"files":3781},
|
||||
{"source":"notofonts","name":"Noto","curated":true,"path":"indexes/noto.json","size":117706,"sha256":"8243d697c407776ec4fb19fa892c089279a4a528eb4e04cd970ac6ec14ce142a","gz_path":"indexes/noto.json.gz","gz_size":18192,"gz_sha256":"0d467fb79bcd3afe10350ed3caba2ff57af02aedbc413292e06bc78c02994999","commit":"93fac8abebe6c7fed0c5b7d2456d8fa484fa146c","families":30,"files":130}
|
||||
]}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! The one HTTP client: blocking HTTPS GET through ureq with rustls, timeouts, a size cap,
|
||||
//! progress and cancellation.
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{self, RecvTimeoutError};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Cancels a download from another thread. Clones share the flag.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Cancel(Arc<AtomicBool>);
|
||||
|
||||
impl Cancel {
|
||||
/// A flag that isn't set.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Asks the download to stop. It stops within a few tens of milliseconds, even while a
|
||||
/// server sends nothing.
|
||||
pub fn cancel(&self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether [`Cancel::cancel`] was called.
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.0.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a request may take.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Timeouts {
|
||||
/// Connecting: DNS, TCP and TLS.
|
||||
pub connect: Duration,
|
||||
/// From sending the request to the response's headers.
|
||||
pub response: Duration,
|
||||
/// The whole request, body included.
|
||||
pub overall: Duration,
|
||||
}
|
||||
|
||||
impl Default for Timeouts {
|
||||
fn default() -> Self {
|
||||
// Ten minutes moves the largest Google Fonts file (about 60 MB) over a 1 Mbit/s line.
|
||||
Self { connect: Duration::from_secs(15), response: Duration::from_secs(30), overall: Duration::from_secs(600) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a request failed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum FetchError {
|
||||
/// [`Cancel::cancel`] was called.
|
||||
#[error("cancelled")]
|
||||
Cancelled,
|
||||
/// The body is larger than allowed.
|
||||
#[error("the response is larger than {max} bytes")]
|
||||
TooLarge {
|
||||
/// The limit.
|
||||
max: u64,
|
||||
},
|
||||
/// Anything else: connection, TLS, timeout, an HTTP status other than 2xx.
|
||||
#[error("{0}")]
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// The User-Agent every request sends: the crate and its version, nothing about the user.
|
||||
pub const USER_AGENT: &str = concat!("craft-font-store/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// The HTTP client. HTTPS only; no cookies, no credentials.
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
agent: ureq::Agent,
|
||||
/// URL prefixes the tests serve from a local server.
|
||||
#[cfg(test)]
|
||||
pub(crate) rewrite: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Default for Client {
|
||||
fn default() -> Self {
|
||||
Self::new(Timeouts::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Client {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Client")
|
||||
}
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// A client with these timeouts.
|
||||
pub fn new(timeouts: Timeouts) -> Self {
|
||||
Self::build(timeouts, true)
|
||||
}
|
||||
|
||||
fn build(t: Timeouts, https_only: bool) -> Self {
|
||||
let config = ureq::Agent::config_builder()
|
||||
.https_only(https_only)
|
||||
.user_agent(USER_AGENT)
|
||||
.timeout_connect(Some(t.connect))
|
||||
.timeout_recv_response(Some(t.response))
|
||||
.timeout_global(Some(t.overall))
|
||||
.max_redirects(3)
|
||||
.http_status_as_error(true)
|
||||
.build();
|
||||
Self {
|
||||
agent: ureq::Agent::new_with_config(config),
|
||||
#[cfg(test)]
|
||||
rewrite: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain `http://` to a local test server, with `https://` prefixes mapped onto it.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_tests(t: Timeouts, rewrite: Vec<(String, String)>) -> Self {
|
||||
Self { rewrite, ..Self::build(t, false) }
|
||||
}
|
||||
|
||||
fn url(&self, url: &str) -> String {
|
||||
#[cfg(test)]
|
||||
for (from, to) in &self.rewrite {
|
||||
if let Some(rest) = url.strip_prefix(from.as_str()) {
|
||||
return format!("{to}{rest}");
|
||||
}
|
||||
}
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
/// GETs `url`, at most `max` bytes, calling `on_bytes` with the bytes read so far. The
|
||||
/// request runs on a helper thread, so `cancel` stops it even while the server sends nothing.
|
||||
pub fn get(&self, url: &str, max: u64, cancel: &Cancel, on_bytes: &mut dyn FnMut(u64)) -> Result<Vec<u8>, FetchError> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(FetchError::Cancelled);
|
||||
}
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let (agent, url, flag) = (self.agent.clone(), self.url(url), Arc::clone(&stop));
|
||||
std::thread::Builder::new()
|
||||
.name("font-store-fetch".into())
|
||||
.spawn(move || {
|
||||
let progress = tx.clone();
|
||||
let result = download(&agent, &url, max, &flag, &mut |n| {
|
||||
let _ = progress.send(Msg::Bytes(n));
|
||||
});
|
||||
// After a cancel nobody receives this.
|
||||
let _ = tx.send(Msg::Done(result));
|
||||
})
|
||||
.map_err(|e| FetchError::Failed(format!("can't start the download: {e}")))?;
|
||||
loop {
|
||||
match rx.recv_timeout(Duration::from_millis(20)) {
|
||||
Ok(Msg::Bytes(n)) => on_bytes(n),
|
||||
Ok(Msg::Done(r)) => return r,
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
Err(RecvTimeoutError::Disconnected) => return Err(FetchError::Failed("the download stopped unexpectedly".into())),
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
return Err(FetchError::Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Msg {
|
||||
Bytes(u64),
|
||||
Done(Result<Vec<u8>, FetchError>),
|
||||
}
|
||||
|
||||
fn describe(e: &ureq::Error) -> String {
|
||||
match e {
|
||||
ureq::Error::StatusCode(code) => format!("HTTP {code}"),
|
||||
ureq::Error::Timeout(_) => "timed out".into(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn download(agent: &ureq::Agent, url: &str, max: u64, stop: &AtomicBool, on_bytes: &mut dyn FnMut(u64)) -> Result<Vec<u8>, FetchError> {
|
||||
let mut resp = agent.get(url).call().map_err(|e| FetchError::Failed(describe(&e)))?;
|
||||
if resp.body().content_length().is_some_and(|n| n > max) {
|
||||
return Err(FetchError::TooLarge { max });
|
||||
}
|
||||
// One byte over the cap is an error, not a truncated file.
|
||||
let mut reader = resp.body_mut().with_config().limit(max.saturating_add(1)).reader();
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let mut buf = vec![0u8; 64 * 1024];
|
||||
loop {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
return Err(FetchError::Cancelled);
|
||||
}
|
||||
let n = reader.read(&mut buf).map_err(|e| FetchError::Failed(format!("reading the response failed: {e}")))?;
|
||||
if n == 0 {
|
||||
return Ok(out);
|
||||
}
|
||||
if (out.len() as u64).saturating_add(n as u64) > max {
|
||||
return Err(FetchError::TooLarge { max });
|
||||
}
|
||||
out.extend_from_slice(buf.get(..n).unwrap_or_default());
|
||||
on_bytes(out.len() as u64);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
//! Font indexes (aiongg/craft-font-index, format 1.x): the families of every source in one
|
||||
//! [`Index`], and exact lookup.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::source::{Origin, fill, parse};
|
||||
|
||||
/// The largest index read.
|
||||
pub const MAX_INDEX_BYTES: usize = 32 << 20;
|
||||
/// The largest font file an index may list.
|
||||
pub const MAX_FILE_BYTES: u64 = 256 << 20;
|
||||
/// The largest licence file an index may list.
|
||||
pub const MAX_LICENSE_BYTES: u64 = 1 << 20;
|
||||
/// The index format's major version this crate reads.
|
||||
pub const MAJOR_VERSION: u32 = 1;
|
||||
/// The source ID of Google Fonts, and of every format 1.0 index.
|
||||
pub const GOOGLE_FONTS: &str = "google/fonts";
|
||||
/// The licences that allow redistribution. Families under any other are left out.
|
||||
pub const LICENSES: [&str; 3] = ["OFL", "APACHE2", "UFL"];
|
||||
|
||||
/// Why an index can't be used.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum IndexError {
|
||||
/// More than [`MAX_INDEX_BYTES`].
|
||||
#[error("the font index is larger than {max} bytes")]
|
||||
TooLarge {
|
||||
/// The limit.
|
||||
max: usize,
|
||||
},
|
||||
/// Not JSON, or a field has the wrong type.
|
||||
#[error("the font index can't be read: {0}")]
|
||||
Json(String),
|
||||
/// A major version this crate doesn't know.
|
||||
#[error("the font index has version {0}; this build reads version {MAJOR_VERSION}.x")]
|
||||
Version(String),
|
||||
/// An entry breaks the format's rules.
|
||||
#[error("the font index has an invalid entry: {0}")]
|
||||
Invalid(String),
|
||||
/// A gzipped index that doesn't decompress.
|
||||
#[error("the gzipped font index can't be read: {0}")]
|
||||
Gzip(String),
|
||||
/// The bytes aren't the pinned ones.
|
||||
#[error("the font index doesn't have the sha256 this build expects")]
|
||||
Checksum,
|
||||
}
|
||||
|
||||
/// A language some family supports.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize)]
|
||||
pub struct Language {
|
||||
/// gflanguages ID: `<ISO 639>_<ISO 15924>`, sometimes with `_<region>` (`vi_Latn`).
|
||||
pub id: String,
|
||||
/// English name.
|
||||
pub name: String,
|
||||
/// The name in the language itself.
|
||||
#[serde(default)]
|
||||
pub autonym: Option<String>,
|
||||
/// ISO 15924 code.
|
||||
#[serde(default)]
|
||||
pub script: String,
|
||||
/// Speakers; 0 when unknown.
|
||||
#[serde(default)]
|
||||
pub population: u64,
|
||||
/// A historical language.
|
||||
#[serde(default)]
|
||||
pub historical: bool,
|
||||
}
|
||||
|
||||
/// A script some family uses.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize)]
|
||||
pub struct Script {
|
||||
/// ISO 15924 code (`Zmth`, `Zsym` and `Zsye` for maths, symbols and emoji).
|
||||
pub id: String,
|
||||
/// English name.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// A variation axis of a font file.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize)]
|
||||
pub struct Axis {
|
||||
/// The axis tag (`wght`).
|
||||
pub tag: String,
|
||||
/// The smallest value.
|
||||
pub min: f32,
|
||||
/// The largest value.
|
||||
pub max: f32,
|
||||
}
|
||||
|
||||
/// One font file of a family.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize)]
|
||||
pub struct FontFile {
|
||||
/// Path in the family's repository: the `{path}` of the source's URL templates.
|
||||
pub path: String,
|
||||
/// Bytes.
|
||||
pub size: u64,
|
||||
/// 64 lowercase hex characters.
|
||||
pub sha256: String,
|
||||
/// `normal` or `italic`.
|
||||
#[serde(default = "normal")]
|
||||
pub style: String,
|
||||
/// CSS weight; for a variable font, the default instance's.
|
||||
pub weight: u16,
|
||||
/// CSS font-stretch percentage; for a variable font, the default instance's.
|
||||
#[serde(default = "normal_width")]
|
||||
pub width: f32,
|
||||
/// Variation axes; empty for a static font.
|
||||
#[serde(default)]
|
||||
pub axes: Vec<Axis>,
|
||||
/// PostScript names: the face's and, for a variable font, each named instance's.
|
||||
#[serde(default)]
|
||||
pub postscript: Vec<String>,
|
||||
/// The repository holding the file, when not the family's.
|
||||
#[serde(default)]
|
||||
pub repo: Option<String>,
|
||||
/// The commit or tag of `repo`, when not the family's.
|
||||
#[serde(default, rename = "ref")]
|
||||
pub git_ref: Option<String>,
|
||||
}
|
||||
|
||||
fn normal() -> String {
|
||||
"normal".into()
|
||||
}
|
||||
|
||||
fn normal_width() -> f32 {
|
||||
100.0
|
||||
}
|
||||
|
||||
impl FontFile {
|
||||
/// The file's name, the last part of its path.
|
||||
pub fn file_name(&self) -> &str {
|
||||
self.path.rsplit('/').next().unwrap_or(&self.path)
|
||||
}
|
||||
|
||||
/// Whether the file's faces are italic.
|
||||
pub fn is_italic(&self) -> bool {
|
||||
self.style.eq_ignore_ascii_case("italic")
|
||||
}
|
||||
|
||||
/// Whether it is a variable font.
|
||||
pub fn is_variable(&self) -> bool {
|
||||
!self.axes.is_empty()
|
||||
}
|
||||
|
||||
fn axis(&self, tag: &str) -> Option<&Axis> {
|
||||
self.axes.iter().find(|a| a.tag == tag)
|
||||
}
|
||||
|
||||
/// Whether the file has the face of CSS `weight`, italic or not: its own weight, or a weight
|
||||
/// its `wght` axis reaches.
|
||||
pub fn has_weight(&self, weight: u16, italic: bool) -> bool {
|
||||
let italic_ok = self.is_italic() == italic || self.axis("ital").is_some_and(|a| a.min <= 0.0 && a.max >= 1.0);
|
||||
let w = f32::from(weight);
|
||||
italic_ok && (self.weight == weight || self.axis("wght").is_some_and(|a| a.min <= w && w <= a.max))
|
||||
}
|
||||
|
||||
/// Whether the file has the normal width (100%), as its own or on its `wdth` axis.
|
||||
pub fn has_normal_width(&self) -> bool {
|
||||
self.width == 100.0 || self.axis("wdth").is_some_and(|a| a.min <= 100.0 && 100.0 <= a.max)
|
||||
}
|
||||
}
|
||||
|
||||
/// A family's licence file.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
|
||||
pub struct LicenseFile {
|
||||
/// Path in its repository.
|
||||
pub path: String,
|
||||
/// Bytes.
|
||||
pub size: u64,
|
||||
/// 64 lowercase hex characters.
|
||||
pub sha256: String,
|
||||
/// The repository holding it, when not the family's.
|
||||
#[serde(default)]
|
||||
pub repo: Option<String>,
|
||||
/// The commit or tag of `repo`, when not the family's.
|
||||
#[serde(default, rename = "ref")]
|
||||
pub git_ref: Option<String>,
|
||||
}
|
||||
|
||||
impl LicenseFile {
|
||||
/// The file's name (`OFL.txt`, `LICENSE`).
|
||||
pub fn file_name(&self) -> &str {
|
||||
self.path.rsplit('/').next().unwrap_or(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
/// A font family of an index.
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize)]
|
||||
pub struct Family {
|
||||
/// The family name, unique in its source ignoring case. Two sources can have the same name.
|
||||
pub family: String,
|
||||
/// The ID of the family's source ([`FontSource::id`]).
|
||||
#[serde(default)]
|
||||
pub source: String,
|
||||
/// The designer; empty when not given.
|
||||
#[serde(default)]
|
||||
pub designer: String,
|
||||
/// `SANS_SERIF`, `SERIF`, `DISPLAY`, `HANDWRITING` or `MONOSPACE`.
|
||||
#[serde(default)]
|
||||
pub categories: Vec<String>,
|
||||
/// google/fonts subsets (`latin`, `japanese`, ...); empty for other sources.
|
||||
#[serde(default)]
|
||||
pub subsets: Vec<String>,
|
||||
/// Positions in [`Index::languages`].
|
||||
#[serde(default)]
|
||||
pub languages: Vec<usize>,
|
||||
/// ISO 15924 codes.
|
||||
#[serde(default)]
|
||||
pub scripts: Vec<String>,
|
||||
/// `OFL`, `APACHE2` or `UFL`.
|
||||
#[serde(default)]
|
||||
pub license: String,
|
||||
/// Format 1.0: the licence file in google/fonts, beside the font files.
|
||||
#[serde(default)]
|
||||
pub license_path: Option<String>,
|
||||
/// The licence file, with its size and sha256.
|
||||
#[serde(default)]
|
||||
pub license_file: Option<LicenseFile>,
|
||||
/// The repository holding the files, when not the source's.
|
||||
#[serde(default)]
|
||||
pub repo: Option<String>,
|
||||
/// The commit or tag of `repo`, when not the source's.
|
||||
#[serde(default, rename = "ref")]
|
||||
pub git_ref: Option<String>,
|
||||
/// The font files, sorted by path.
|
||||
pub files: Vec<FontFile>,
|
||||
#[serde(skip)]
|
||||
pub(crate) dir: String,
|
||||
#[serde(skip)]
|
||||
pub(crate) curated: bool,
|
||||
}
|
||||
|
||||
impl Family {
|
||||
/// The family's folder in the font store: google/fonts' folder name (`notosansjp`), or
|
||||
/// `<source>/<family>` (`notofonts/noto-sans-cjk-jp`) for other sources.
|
||||
pub fn dir(&self) -> &str {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Whether the family's source is curated: a built-in source, not one the user added.
|
||||
pub fn is_curated(&self) -> bool {
|
||||
self.curated
|
||||
}
|
||||
|
||||
/// The licence file's name (`OFL.txt`, `LICENSE`).
|
||||
pub fn license_file_name(&self) -> Option<&str> {
|
||||
match &self.license_file {
|
||||
Some(f) => Some(f.file_name()),
|
||||
None => self.license_path.as_deref().and_then(|p| p.rsplit('/').next()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file with exactly this style: a style name (`Bold Italic`, `SemiBold`, `Regular`), a
|
||||
/// CSS weight (`700`), or the part of a PostScript name after its `-` (`BoldItalic`,
|
||||
/// `CondensedBold`). A variable font has every weight its `wght` axis reaches. A static file
|
||||
/// of normal width is preferred. No nearer style is offered: a style the family doesn't have
|
||||
/// is [`Unavailable::NoStyle`].
|
||||
pub fn face(&self, style: &str) -> Result<&FontFile, Unavailable> {
|
||||
let no_style = || Unavailable::NoStyle { family: self.family.clone(), style: style.to_string() };
|
||||
if let Some((weight, italic)) = parse_style(style) {
|
||||
let found: Vec<&FontFile> = self.files.iter().filter(|f| f.has_weight(weight, italic) && f.has_normal_width()).collect();
|
||||
let exact = found.iter().find(|f| !f.is_variable() && f.weight == weight && f.is_italic() == italic && f.width == 100.0);
|
||||
if let Some(f) = exact.or(found.first()) {
|
||||
return Ok(f);
|
||||
}
|
||||
}
|
||||
let wanted = squash(style);
|
||||
if wanted.is_empty() {
|
||||
return Err(no_style());
|
||||
}
|
||||
self.files.iter().find(|f| f.postscript.iter().any(|p| p.split_once('-').is_some_and(|(_, s)| squash(s) == wanted))).ok_or_else(no_style)
|
||||
}
|
||||
|
||||
/// The file with this PostScript name, matched exactly.
|
||||
pub fn face_by_postscript(&self, name: &str) -> Option<&FontFile> {
|
||||
self.files.iter().find(|f| f.postscript.iter().any(|p| p == name))
|
||||
}
|
||||
|
||||
/// The family's languages.
|
||||
pub fn languages<'a>(&'a self, index: &'a Index) -> impl Iterator<Item = &'a Language> + 'a {
|
||||
self.languages.iter().filter_map(|&i| index.languages.get(i))
|
||||
}
|
||||
|
||||
/// The total size of the family's files.
|
||||
pub fn size(&self) -> u64 {
|
||||
self.files.iter().map(|f| f.size).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Letters and digits only, lowercased: `Bold Italic`, `bold-italic` and `BoldItalic` are equal.
|
||||
fn squash(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
/// A style name's CSS weight and whether it is italic: `Bold Italic` is (700, true), `Regular`
|
||||
/// and the empty style are (400, false), `300` is (300, false). `None` for names that aren't a
|
||||
/// weight (`Condensed Bold`).
|
||||
pub fn parse_style(style: &str) -> Option<(u16, bool)> {
|
||||
let s = squash(style);
|
||||
let (base, italic) = match s.strip_suffix("italic").or_else(|| s.strip_suffix("oblique")) {
|
||||
Some(b) => (b, true),
|
||||
None => (s.as_str(), false),
|
||||
};
|
||||
let weight = match base {
|
||||
"" | "regular" | "normal" | "book" | "roman" => 400,
|
||||
"thin" | "hairline" => 100,
|
||||
"extralight" | "ultralight" => 200,
|
||||
"light" => 300,
|
||||
"medium" => 500,
|
||||
"semibold" | "demibold" => 600,
|
||||
"bold" => 700,
|
||||
"extrabold" | "ultrabold" => 800,
|
||||
"black" | "heavy" => 900,
|
||||
n => n.parse::<u16>().ok().filter(|w| (1..=1000).contains(w))?,
|
||||
};
|
||||
Some((weight, italic))
|
||||
}
|
||||
|
||||
/// Why the index has no exact match. Near matches are never offered as installable.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Unavailable {
|
||||
/// No family of this name.
|
||||
#[error("{0} is not in the font index")]
|
||||
NoFamily(String),
|
||||
/// The family exists, without this style.
|
||||
#[error("the font index has {family} but not its {style} style")]
|
||||
NoStyle {
|
||||
/// The family's name in the index.
|
||||
family: String,
|
||||
/// The style asked for.
|
||||
style: String,
|
||||
},
|
||||
/// No file has this PostScript name.
|
||||
#[error("no font in the index has the PostScript name {0}")]
|
||||
NoPostScript(String),
|
||||
/// Files of several families have this PostScript name.
|
||||
#[error("the PostScript name {name} belongs to several families: {}", families.join(", "))]
|
||||
Ambiguous {
|
||||
/// The PostScript name.
|
||||
name: String,
|
||||
/// The families that have it.
|
||||
families: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// An exact match: a family and the file that has the face.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Face<'a> {
|
||||
/// The family.
|
||||
pub family: &'a Family,
|
||||
/// The file.
|
||||
pub file: &'a FontFile,
|
||||
}
|
||||
|
||||
/// A source of fonts: one index file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FontSource {
|
||||
/// The ID its families carry: `google/fonts`, `notofonts`, or `user:<host>/<id>` for a
|
||||
/// source the user added.
|
||||
pub id: String,
|
||||
/// Display name (`Google Fonts`, `Noto`).
|
||||
pub name: String,
|
||||
/// The source's web page.
|
||||
pub homepage: Option<String>,
|
||||
/// A built-in source. A source the user added is never curated, whatever its file says.
|
||||
pub curated: bool,
|
||||
/// The index format version, `"MAJOR.MINOR"`.
|
||||
pub version: String,
|
||||
/// The commit the index was generated from.
|
||||
pub commit: String,
|
||||
/// The URL a user-added source was added from.
|
||||
pub user_url: Option<String>,
|
||||
/// A user-added source whose file differs from the one first fetched.
|
||||
pub changed: bool,
|
||||
/// Families left out of the index and why (a licence that doesn't allow redistribution).
|
||||
pub warnings: Vec<String>,
|
||||
/// How many families it has.
|
||||
pub families: usize,
|
||||
pub(crate) templates: Vec<String>,
|
||||
pub(crate) repo: Option<String>,
|
||||
pub(crate) git_ref: Option<String>,
|
||||
}
|
||||
|
||||
impl FontSource {
|
||||
/// The URL templates of its files, best first.
|
||||
pub fn file_url_templates(&self) -> &[String] {
|
||||
&self.templates
|
||||
}
|
||||
}
|
||||
|
||||
/// A source that couldn't be loaded. The other sources still are.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SourceProblem {
|
||||
/// The source's ID, or its URL for a user-added source.
|
||||
pub source: String,
|
||||
/// Its display name, or its URL.
|
||||
pub name: String,
|
||||
/// What went wrong.
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
pub(crate) type Part = (FontSource, Vec<Language>, Vec<Script>, Vec<Family>);
|
||||
|
||||
/// The checked indexes of one or more sources. Families are sorted by name; families of the same
|
||||
/// name are in source order (built-in sources in `sources.json` order, then the user's), and
|
||||
/// lookups by name prefer the first.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Index {
|
||||
sources: Vec<FontSource>,
|
||||
problems: Vec<SourceProblem>,
|
||||
languages: Vec<Language>,
|
||||
scripts: Vec<Script>,
|
||||
families: Vec<Family>,
|
||||
by_name: HashMap<String, Vec<usize>>,
|
||||
by_postscript: HashMap<String, Vec<(usize, usize)>>,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
/// Reads one source's index from its JSON, checking every entry. Its `curated` flag is
|
||||
/// kept; a format 1.0 index is Google Fonts and curated.
|
||||
pub fn from_json(bytes: &[u8]) -> Result<Self, IndexError> {
|
||||
parse(bytes, Origin::Trusted)
|
||||
}
|
||||
|
||||
/// Reads an index from gzipped JSON.
|
||||
pub fn from_gzip(bytes: &[u8]) -> Result<Self, IndexError> {
|
||||
Self::from_json(&gunzip(bytes, MAX_INDEX_BYTES)?)
|
||||
}
|
||||
|
||||
/// Reads an index from JSON or gzipped JSON.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, IndexError> {
|
||||
if is_gzip(bytes) { Self::from_gzip(bytes) } else { Self::from_json(bytes) }
|
||||
}
|
||||
|
||||
/// Reads an index the user added from `url` (JSON or gzipped JSON): not curated, its source
|
||||
/// ID `user:<host>/<id>`, so it can't stand in for a built-in source.
|
||||
pub fn from_user_bytes(bytes: &[u8], url: &str) -> Result<Self, IndexError> {
|
||||
let json = if is_gzip(bytes) { gunzip(bytes, MAX_INDEX_BYTES)? } else { bytes.to_vec() };
|
||||
parse(&json, Origin::User { url })
|
||||
}
|
||||
|
||||
/// The built-in sources compiled into this build ([`crate::PINNED_INDEX`]): `sources.json`
|
||||
/// and every index it lists, each checked against its sha256.
|
||||
#[cfg(feature = "bundled-index")]
|
||||
pub fn bundled() -> Result<Self, IndexError> {
|
||||
crate::sources::bundled()
|
||||
}
|
||||
|
||||
/// One index of every source in `parts`, in order. A source whose ID came earlier is left
|
||||
/// out and reported in [`Index::problems`].
|
||||
pub fn merge(parts: impl IntoIterator<Item = Index>) -> Self {
|
||||
let mut list = Vec::new();
|
||||
let mut problems = Vec::new();
|
||||
for part in parts {
|
||||
problems.extend(part.problems);
|
||||
let mut families = part.families;
|
||||
for source in part.sources {
|
||||
let (mine, rest): (Vec<Family>, Vec<Family>) = families.into_iter().partition(|f| f.source == source.id);
|
||||
families = rest;
|
||||
list.push((source, part.languages.clone(), part.scripts.clone(), mine));
|
||||
}
|
||||
}
|
||||
Self::assemble(list, problems)
|
||||
}
|
||||
|
||||
/// The index with `problems` added, for sources that couldn't be loaded.
|
||||
pub fn with_problems(mut self, problems: impl IntoIterator<Item = SourceProblem>) -> Self {
|
||||
self.problems.extend(problems);
|
||||
self
|
||||
}
|
||||
|
||||
/// Marks every source as changed since the user added it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn mark_changed(&mut self) {
|
||||
for s in &mut self.sources {
|
||||
s.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn assemble(parts: Vec<Part>, mut problems: Vec<SourceProblem>) -> Self {
|
||||
let mut sources: Vec<FontSource> = Vec::new();
|
||||
let mut languages: BTreeMap<String, Language> = BTreeMap::new();
|
||||
let mut scripts: BTreeMap<String, Script> = BTreeMap::new();
|
||||
let mut families: Vec<(Family, Vec<String>)> = Vec::new();
|
||||
for (source, langs, scrs, fams) in parts {
|
||||
if sources.iter().any(|s| s.id == source.id) {
|
||||
problems.push(SourceProblem {
|
||||
source: source.user_url.clone().unwrap_or_else(|| source.id.clone()),
|
||||
name: source.name.clone(),
|
||||
error: format!("another source already has the ID {}", source.id),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for f in fams {
|
||||
let ids = f.languages.iter().filter_map(|&i| langs.get(i)).map(|l| l.id.clone()).collect();
|
||||
families.push((f, ids));
|
||||
}
|
||||
for l in langs {
|
||||
languages.entry(l.id.clone()).or_insert(l);
|
||||
}
|
||||
for s in scrs {
|
||||
scripts.entry(s.id.clone()).or_insert(s);
|
||||
}
|
||||
sources.push(source);
|
||||
}
|
||||
let position: HashMap<&str, usize> = languages.keys().enumerate().map(|(i, id)| (id.as_str(), i)).collect();
|
||||
let mut merged: Vec<Family> = families
|
||||
.into_iter()
|
||||
.map(|(mut f, ids)| {
|
||||
f.languages = ids.iter().filter_map(|id| position.get(id.as_str()).copied()).collect();
|
||||
f
|
||||
})
|
||||
.collect();
|
||||
let order: HashMap<&str, usize> = sources.iter().enumerate().map(|(i, s)| (s.id.as_str(), i)).collect();
|
||||
merged.sort_by_cached_key(|f| (f.family.to_lowercase(), order.get(f.source.as_str()).copied().unwrap_or(usize::MAX)));
|
||||
let mut by_name: HashMap<String, Vec<usize>> = HashMap::new();
|
||||
let mut by_postscript: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
|
||||
for (fi, fam) in merged.iter().enumerate() {
|
||||
by_name.entry(fam.family.to_lowercase()).or_default().push(fi);
|
||||
for (xi, file) in fam.files.iter().enumerate() {
|
||||
for ps in &file.postscript {
|
||||
let hits = by_postscript.entry(ps.clone()).or_default();
|
||||
if !hits.contains(&(fi, xi)) {
|
||||
hits.push((fi, xi));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Index {
|
||||
sources,
|
||||
problems,
|
||||
languages: languages.into_values().collect(),
|
||||
scripts: scripts.into_values().collect(),
|
||||
families: merged,
|
||||
by_name,
|
||||
by_postscript,
|
||||
}
|
||||
}
|
||||
|
||||
/// The sources, built-in ones first.
|
||||
pub fn sources(&self) -> &[FontSource] {
|
||||
&self.sources
|
||||
}
|
||||
|
||||
/// The source with this ID.
|
||||
pub fn source(&self, id: &str) -> Option<&FontSource> {
|
||||
self.sources.iter().find(|s| s.id == id)
|
||||
}
|
||||
|
||||
/// The source a family comes from.
|
||||
pub fn source_of(&self, family: &Family) -> Option<&FontSource> {
|
||||
self.source(&family.source)
|
||||
}
|
||||
|
||||
/// The sources that couldn't be loaded, and why.
|
||||
pub fn problems(&self) -> &[SourceProblem] {
|
||||
&self.problems
|
||||
}
|
||||
|
||||
/// Every family, sorted by name, then by source.
|
||||
pub fn families(&self) -> &[Family] {
|
||||
&self.families
|
||||
}
|
||||
|
||||
/// Every language a family supports, sorted by ID.
|
||||
pub fn languages(&self) -> &[Language] {
|
||||
&self.languages
|
||||
}
|
||||
|
||||
/// Every script a family uses, sorted by code.
|
||||
pub fn scripts(&self) -> &[Script] {
|
||||
&self.scripts
|
||||
}
|
||||
|
||||
/// The family of this name, ignoring case, from the first source that has it. Only the whole
|
||||
/// name matches.
|
||||
pub fn family(&self, name: &str) -> Option<&Family> {
|
||||
self.families_named(name).next()
|
||||
}
|
||||
|
||||
/// Every family of this name, ignoring case, in source order.
|
||||
pub fn families_named(&self, name: &str) -> impl Iterator<Item = &Family> + '_ {
|
||||
self.by_name.get(&name.to_lowercase()).into_iter().flatten().filter_map(|&i| self.families.get(i))
|
||||
}
|
||||
|
||||
/// The family of this name from one source.
|
||||
pub fn family_from(&self, source: &str, name: &str) -> Option<&Family> {
|
||||
self.families_named(name).find(|f| f.source == source)
|
||||
}
|
||||
|
||||
/// The exact face for a family and style ([`Family::face`]), from the first source whose
|
||||
/// family of that name has the style.
|
||||
pub fn find_face(&self, family: &str, style: &str) -> Result<Face<'_>, Unavailable> {
|
||||
let mut first_error = None;
|
||||
for fam in self.families_named(family) {
|
||||
match fam.face(style) {
|
||||
Ok(file) => return Ok(Face { family: fam, file }),
|
||||
Err(e) => {
|
||||
first_error.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(first_error.unwrap_or_else(|| Unavailable::NoFamily(family.to_string())))
|
||||
}
|
||||
|
||||
/// The face with this PostScript name, matched exactly (case included), from the first
|
||||
/// source that has it. When families of different names have it, it is
|
||||
/// [`Unavailable::Ambiguous`]: look it up in the document's family with
|
||||
/// [`Family::face_by_postscript`].
|
||||
pub fn find_postscript(&self, name: &str) -> Result<Face<'_>, Unavailable> {
|
||||
let hits = self.by_postscript.get(name).map(Vec::as_slice).unwrap_or_default();
|
||||
let mut names: Vec<&str> = Vec::new();
|
||||
for &(fi, _) in hits {
|
||||
if let Some(f) = self.families.get(fi)
|
||||
&& !names.iter().any(|n| n.to_lowercase() == f.family.to_lowercase())
|
||||
{
|
||||
names.push(&f.family);
|
||||
}
|
||||
}
|
||||
match (names.len(), hits.first()) {
|
||||
(1, Some(&(fi, xi))) => {
|
||||
let family = self.families.get(fi).ok_or_else(|| Unavailable::NoPostScript(name.into()))?;
|
||||
let file = family.files.get(xi).ok_or_else(|| Unavailable::NoPostScript(name.into()))?;
|
||||
Ok(Face { family, file })
|
||||
}
|
||||
(0, _) | (_, None) => Err(Unavailable::NoPostScript(name.into())),
|
||||
_ => Err(Unavailable::Ambiguous { name: name.into(), families: names.into_iter().map(str::to_string).collect() }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The URLs of a font file of `family`, best first: the source's templates filled with the
|
||||
/// file's path, repository and ref.
|
||||
pub fn file_urls(&self, family: &Family, file: &FontFile) -> Vec<String> {
|
||||
self.urls(family, &file.path, file.repo.as_deref(), file.git_ref.as_deref())
|
||||
}
|
||||
|
||||
/// The URLs of `family`'s licence file, best first; empty when it has none.
|
||||
pub fn license_urls(&self, family: &Family) -> Vec<String> {
|
||||
match (&family.license_file, &family.license_path) {
|
||||
(Some(f), _) => self.urls(family, &f.path, f.repo.as_deref(), f.git_ref.as_deref()),
|
||||
(None, Some(path)) => self.urls(family, path, None, None),
|
||||
(None, None) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn urls(&self, family: &Family, path: &str, repo: Option<&str>, git_ref: Option<&str>) -> Vec<String> {
|
||||
let Some(source) = self.source_of(family) else { return Vec::new() };
|
||||
let repo = repo.or(family.repo.as_deref()).or(source.repo.as_deref());
|
||||
let git_ref = git_ref.or(family.git_ref.as_deref()).or(source.git_ref.as_deref());
|
||||
fill(&source.templates, path, repo, git_ref).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_gzip(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(&[0x1f, 0x8b])
|
||||
}
|
||||
|
||||
/// Decompresses at most `max` bytes; more is an error, not a truncated index.
|
||||
pub(crate) fn gunzip(bytes: &[u8], max: usize) -> Result<Vec<u8>, IndexError> {
|
||||
use std::io::Read;
|
||||
let mut out = Vec::new();
|
||||
flate2::read::GzDecoder::new(bytes).take((max as u64).saturating_add(1)).read_to_end(&mut out).map_err(|e| IndexError::Gzip(e.to_string()))?;
|
||||
if out.len() > max {
|
||||
return Err(IndexError::TooLarge { max });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
//! The suite's font store: the font indexes of every source (Google Fonts, Noto, indexes the user
|
||||
//! adds), exact lookup of a missing font, and verified installs into the one folder every app's
|
||||
//! font catalog reads.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # #[cfg(not(target_arch = "wasm32"))] {
|
||||
//! use craft_font_store::{Cancel, FontStore, Index, PINNED_INDEX};
|
||||
//! use craft_font_catalog::{Catalog, FontEnv};
|
||||
//!
|
||||
//! let store = FontStore::for_user(&FontEnv::from_process()).unwrap().with_catalog(Catalog::global().clone());
|
||||
//! let index = store.cached_indexes(&PINNED_INDEX).unwrap();
|
||||
//! if let Ok(face) = index.find_face("Lato", "Bold Italic") {
|
||||
//! store.enable_network().unwrap(); // the user clicked Install
|
||||
//! store.install_face(&index, face, &Cancel::new(), &mut |p| println!("{}/{}", p.done, p.total)).unwrap();
|
||||
//! }
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! The index and its lookups, [`verify_font`] and [`IndexPin`] build on every target. The store
|
||||
//! and the HTTP client are native only.
|
||||
|
||||
mod index;
|
||||
mod pin;
|
||||
mod source;
|
||||
mod sources;
|
||||
mod verify;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod http;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod store;
|
||||
|
||||
pub use index::{
|
||||
Axis, Face, Family, FontFile, FontSource, GOOGLE_FONTS, Index, IndexError, LICENSES, Language, LicenseFile, MAJOR_VERSION, MAX_FILE_BYTES,
|
||||
MAX_INDEX_BYTES, MAX_LICENSE_BYTES, Script, SourceProblem, Unavailable, parse_style,
|
||||
};
|
||||
pub use pin::{IndexPin, PINNED_INDEX};
|
||||
pub use sources::{SourceEntry, SourcesFile};
|
||||
pub use verify::{VerifyError, is_font, sha256_hex, verify_font};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use http::{Cancel, Client, FetchError, Timeouts, USER_AGENT};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use store::{Caller, Error, FontStore, InstalledFamily, InstalledFile, Network, Problem, Progress, UserSource};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,38 @@
|
||||
//! The built-in sources this build trusts: a commit of the index repository and the sha256 of its
|
||||
//! `sources.json`.
|
||||
|
||||
use crate::source::encode_path;
|
||||
|
||||
/// `sources.json` pinned by commit and sha256. The indexes it lists are checked against the
|
||||
/// sha256s in it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct IndexPin {
|
||||
/// The GitHub repository holding the indexes (`owner/name`).
|
||||
pub repo: &'static str,
|
||||
/// The repository's commit.
|
||||
pub commit: &'static str,
|
||||
/// The sha256 of `sources.json` at that commit, lowercase hex.
|
||||
pub sha256: &'static str,
|
||||
}
|
||||
|
||||
impl IndexPin {
|
||||
/// Where `sources.json` is downloaded from: jsDelivr, then raw GitHub.
|
||||
pub fn urls(&self) -> Vec<String> {
|
||||
self.file_urls("sources.json")
|
||||
}
|
||||
|
||||
/// Where a file of the repository at the pinned commit is downloaded from (an index's
|
||||
/// `path` or `gz_path`): jsDelivr, then raw GitHub.
|
||||
pub fn file_urls(&self, path: &str) -> Vec<String> {
|
||||
let (repo, commit, path) = (self.repo, self.commit, encode_path(path));
|
||||
vec![format!("https://cdn.jsdelivr.net/gh/{repo}@{commit}/{path}"), format!("https://raw.githubusercontent.com/{repo}/{commit}/{path}")]
|
||||
}
|
||||
}
|
||||
|
||||
/// The built-in sources of this build: aiongg/craft-font-index at `5a9a7f8` (Google Fonts at
|
||||
/// google/fonts `45e3d52`, and Noto), the copy `Index::bundled` compiles in.
|
||||
pub const PINNED_INDEX: IndexPin = IndexPin {
|
||||
repo: "aiongg/craft-font-index",
|
||||
commit: "5a9a7f8038c8f3e2e1583a79c92a816e8e71b34f",
|
||||
sha256: "4b0c2b9cdec1885027dc3316cdc013ccfbc0d63b6ed9950138d762afaff72e23",
|
||||
};
|
||||
@@ -0,0 +1,497 @@
|
||||
//! Reading and checking one source's index file, and its URL templates.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::index::{
|
||||
Family, FontSource, GOOGLE_FONTS, Index, IndexError, LICENSES, Language, MAJOR_VERSION, MAX_FILE_BYTES, MAX_INDEX_BYTES, MAX_LICENSE_BYTES,
|
||||
Script,
|
||||
};
|
||||
|
||||
pub(crate) const MAX_STR: usize = 1024;
|
||||
const MAX_LIST: usize = 4096;
|
||||
const MAX_FAMILIES: usize = 50_000;
|
||||
const MAX_FILES: usize = 200_000;
|
||||
const MAX_SEGMENT: usize = 64;
|
||||
const MAX_FILE_NAME: usize = 200;
|
||||
const MAX_TEMPLATES: usize = 8;
|
||||
|
||||
/// Where an index file comes from.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum Origin<'a> {
|
||||
/// A built-in source, or any file the app itself trusts: its `curated` flag is kept.
|
||||
Trusted,
|
||||
/// A source the user added by URL: never curated, its ID prefixed with `user:<host>/`.
|
||||
User { url: &'a str },
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawSourceInfo {
|
||||
#[serde(default)]
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
homepage: Option<String>,
|
||||
#[serde(default)]
|
||||
curated: bool,
|
||||
#[serde(default)]
|
||||
file_urls: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Raw {
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
source: String,
|
||||
commit: String,
|
||||
#[serde(default)]
|
||||
base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
fallback_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
source_info: Option<RawSourceInfo>,
|
||||
#[serde(default)]
|
||||
repo: Option<String>,
|
||||
#[serde(default, rename = "ref")]
|
||||
git_ref: Option<String>,
|
||||
#[serde(default)]
|
||||
languages: Vec<Language>,
|
||||
#[serde(default)]
|
||||
scripts: Vec<Script>,
|
||||
families: Vec<Family>,
|
||||
}
|
||||
|
||||
pub(crate) fn invalid<T>(msg: impl Into<String>) -> Result<T, IndexError> {
|
||||
Err(IndexError::Invalid(msg.into()))
|
||||
}
|
||||
|
||||
fn short(s: &str) -> String {
|
||||
s.chars().take(120).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn check_version(version: &str) -> Result<(), IndexError> {
|
||||
let major = version.split_once('.').and_then(|(major, minor)| {
|
||||
let major: u32 = major.parse().ok()?;
|
||||
minor.parse::<u32>().ok()?;
|
||||
Some(major)
|
||||
});
|
||||
if major == Some(MAJOR_VERSION) { Ok(()) } else { Err(IndexError::Version(version.chars().take(40).collect())) }
|
||||
}
|
||||
|
||||
pub(crate) fn check_str(what: &str, s: &str) -> Result<(), IndexError> {
|
||||
if s.len() > MAX_STR || s.chars().any(char::is_control) {
|
||||
return invalid(format!("{what} is too long or has control characters"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn is_sha256(s: &str) -> bool {
|
||||
s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
}
|
||||
|
||||
/// A git ref or a repository part: `[A-Za-z0-9._-]+`, not starting with `.`.
|
||||
pub(crate) fn is_ref(s: &str) -> bool {
|
||||
!s.is_empty() && s.len() <= 128 && !s.starts_with('.') && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
|
||||
}
|
||||
|
||||
/// `owner/name` on GitHub.
|
||||
pub(crate) fn is_repo(s: &str) -> bool {
|
||||
s.split_once('/').is_some_and(|(o, n)| is_ref(o) && is_ref(n))
|
||||
}
|
||||
|
||||
fn check_repo_ref(what: &str, repo: Option<&str>, git_ref: Option<&str>) -> Result<(), IndexError> {
|
||||
if let Some(r) = repo.filter(|r| !is_repo(r)) {
|
||||
return invalid(format!("{what} has repo `{}`, not owner/name", short(r)));
|
||||
}
|
||||
if let Some(r) = git_ref.filter(|r| !is_ref(r)) {
|
||||
return invalid(format!("{what} has ref `{}`", short(r)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A file name the store can write on every platform.
|
||||
pub(crate) fn safe_file_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= MAX_FILE_NAME
|
||||
&& !name.starts_with('.')
|
||||
&& !name.ends_with('.')
|
||||
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || b"_-[],.+".contains(&b))
|
||||
}
|
||||
|
||||
fn safe_segment(s: &str) -> bool {
|
||||
!s.is_empty() && s.len() <= MAX_SEGMENT && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
|
||||
}
|
||||
|
||||
/// A family folder in the store: google/fonts' `[a-z0-9_-]+`, or `<source>/<family>` of the same
|
||||
/// characters.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn safe_dir(dir: &str) -> bool {
|
||||
let mut parts = dir.split('/');
|
||||
match (parts.next(), parts.next(), parts.next()) {
|
||||
(Some(a), None, None) => safe_segment(a),
|
||||
(Some(a), Some(b), None) => safe_segment(a) && safe_segment(b),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercase letters and digits, other runs as one `-`: `notofonts`, `google-fonts`,
|
||||
/// `noto-sans-cjk-jp`. A name with no ASCII letters or digits gets a hash of itself.
|
||||
pub(crate) fn slug(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
out.push(c.to_ascii_lowercase());
|
||||
} else if !out.is_empty() && !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
out.truncate(MAX_SEGMENT);
|
||||
let out = out.trim_end_matches('-').to_string();
|
||||
if out.is_empty() { crate::verify::sha256_hex(s.as_bytes()).chars().take(16).collect() } else { out }
|
||||
}
|
||||
|
||||
/// A user source's folder name: its ID's slug and a hash of the whole ID, since IDs that differ
|
||||
/// only in punctuation, case or past the slug's length share a slug.
|
||||
fn user_slug(id: &str) -> String {
|
||||
let hash: String = crate::verify::sha256_hex(id.as_bytes()).chars().take(8).collect();
|
||||
let mut s = slug(id);
|
||||
s.truncate(MAX_SEGMENT - hash.len() - 1);
|
||||
format!("{}-{hash}", s.trim_end_matches('-'))
|
||||
}
|
||||
|
||||
/// A relative path in a repository: `/`-separated, no empty, `.` or `..` segment, no `\`, and a
|
||||
/// file name the store can write.
|
||||
pub(crate) fn check_path(path: &str) -> Result<&str, IndexError> {
|
||||
let bad = path.is_empty()
|
||||
|| path.len() > MAX_STR
|
||||
|| path.contains('\\')
|
||||
|| path.chars().any(char::is_control)
|
||||
|| path.split('/').any(|s| s.is_empty() || s == "." || s == "..");
|
||||
let name = path.rsplit('/').next().unwrap_or(path);
|
||||
if bad || !safe_file_name(name) { invalid(format!("`{}` is not a safe relative path", short(path))) } else { Ok(name) }
|
||||
}
|
||||
|
||||
/// Percent-encodes each `/`-separated segment, keeping the unreserved characters.
|
||||
pub(crate) fn encode_path(p: &str) -> String {
|
||||
let mut out = String::with_capacity(p.len() + 8);
|
||||
for b in p.bytes() {
|
||||
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/') {
|
||||
out.push(char::from(b));
|
||||
} else {
|
||||
out.push_str(&format!("%{b:02X}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn check_template(t: &str) -> Result<(), IndexError> {
|
||||
let rest = t.replace("{path}", "").replace("{repo}", "").replace("{ref}", "");
|
||||
let ok = t.len() <= MAX_STR
|
||||
&& t.starts_with("https://")
|
||||
&& t.len() > "https://".len()
|
||||
&& t.contains("{path}")
|
||||
&& !rest.contains(['{', '}', '\\'])
|
||||
&& !t.chars().any(|c| c.is_control() || c.is_whitespace());
|
||||
if ok { Ok(()) } else { invalid(format!("file URL template `{}` is not an https URL with {{path}}", short(t))) }
|
||||
}
|
||||
|
||||
fn check_base_url(u: &str) -> Result<(), IndexError> {
|
||||
let ok = u.len() <= MAX_STR
|
||||
&& u.len() > "https://".len()
|
||||
&& u.starts_with("https://")
|
||||
&& u.ends_with('/')
|
||||
&& !u.chars().any(|c| c.is_control() || c.is_whitespace() || matches!(c, '\\' | '{' | '}'));
|
||||
if ok { Ok(()) } else { invalid(format!("base URL `{}` is not an https folder URL", short(u))) }
|
||||
}
|
||||
|
||||
/// The URLs of `path`, filling each template in turn. `repo` and `ref` are needed only when a
|
||||
/// template names them.
|
||||
pub(crate) fn fill(templates: &[String], path: &str, repo: Option<&str>, git_ref: Option<&str>) -> Result<Vec<String>, IndexError> {
|
||||
check_path(path)?;
|
||||
let encoded = encode_path(path);
|
||||
let mut urls = Vec::with_capacity(templates.len());
|
||||
for t in templates {
|
||||
let mut url = t.replace("{path}", &encoded);
|
||||
for (key, value, ok) in [("{repo}", repo, is_repo as fn(&str) -> bool), ("{ref}", git_ref, is_ref)] {
|
||||
if url.contains(key) {
|
||||
match value {
|
||||
Some(v) if ok(v) => url = url.replace(key, v),
|
||||
_ => return invalid(format!("`{}` has no valid {key} for its URL", short(path))),
|
||||
}
|
||||
}
|
||||
}
|
||||
if !url.starts_with("https://") || url.contains('{') {
|
||||
return invalid(format!("`{}` doesn't resolve to an https URL", short(path)));
|
||||
}
|
||||
urls.push(url);
|
||||
}
|
||||
Ok(urls)
|
||||
}
|
||||
|
||||
/// The host of an https URL, lowercased: `fonts.example.org`.
|
||||
pub(crate) fn https_host(url: &str) -> Option<String> {
|
||||
let rest = url.strip_prefix("https://")?;
|
||||
let host = rest.split(['/', '?', '#']).next()?.to_ascii_lowercase();
|
||||
let ok = !host.is_empty() && host.len() <= 253 && host.bytes().all(|b| b.is_ascii_alphanumeric() || b".-:[]".contains(&b));
|
||||
ok.then_some(host)
|
||||
}
|
||||
|
||||
/// Whether `url` is an https URL a user may add as a source.
|
||||
pub(crate) fn is_source_url(url: &str) -> bool {
|
||||
url.len() <= MAX_STR && https_host(url).is_some() && !url.chars().any(|c| c.is_control() || c.is_whitespace() || c == '\\')
|
||||
}
|
||||
|
||||
/// What [`check_family`] needs to know about the source.
|
||||
struct Ctx<'a> {
|
||||
/// The ID the file declares.
|
||||
declared: &'a str,
|
||||
/// The ID the app uses (prefixed for a user source).
|
||||
id: &'a str,
|
||||
slug: &'a str,
|
||||
google: bool,
|
||||
curated: bool,
|
||||
templates: &'a [String],
|
||||
repo: Option<&'a str>,
|
||||
git_ref: Option<&'a str>,
|
||||
languages: usize,
|
||||
}
|
||||
|
||||
/// Reads one source's index file.
|
||||
pub(crate) fn parse(bytes: &[u8], origin: Origin<'_>) -> Result<Index, IndexError> {
|
||||
if bytes.len() > MAX_INDEX_BYTES {
|
||||
return Err(IndexError::TooLarge { max: MAX_INDEX_BYTES });
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| IndexError::Json(e.to_string()))?;
|
||||
// The version first: a newer major version may change any other field.
|
||||
let version = value.get("version").and_then(|v| v.as_str()).ok_or_else(|| IndexError::Version("(none)".into()))?;
|
||||
check_version(version)?;
|
||||
let raw: Raw = serde_json::from_value(value).map_err(|e| IndexError::Json(e.to_string()))?;
|
||||
check(raw, origin)
|
||||
}
|
||||
|
||||
fn check(raw: Raw, origin: Origin<'_>) -> Result<Index, IndexError> {
|
||||
check_str("commit", &raw.commit)?;
|
||||
if raw.commit.len() != 40 || !raw.commit.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return invalid(format!("commit `{}` is not 40 hex characters", short(&raw.commit)));
|
||||
}
|
||||
let info_id = raw.source_info.as_ref().map(|i| i.id.as_str()).unwrap_or_default();
|
||||
let declared = match (raw.source.as_str(), info_id) {
|
||||
("", "") => GOOGLE_FONTS,
|
||||
(s, "") | ("", s) => s,
|
||||
(s, i) if s == i => s,
|
||||
_ => return invalid("source and source_info.id differ"),
|
||||
}
|
||||
.to_string();
|
||||
if !declared.split('/').all(is_ref) || declared.len() > 128 {
|
||||
return invalid(format!("source `{}` is not a valid ID", short(&declared)));
|
||||
}
|
||||
let host = match origin {
|
||||
Origin::Trusted => None,
|
||||
Origin::User { url } => Some(https_host(url).ok_or_else(|| IndexError::Invalid("the source's URL is not https".into()))?),
|
||||
};
|
||||
let id = host.as_ref().map_or_else(|| declared.clone(), |h| format!("user:{h}/{declared}"));
|
||||
for u in raw.base_url.iter().chain(&raw.fallback_base_url) {
|
||||
check_base_url(u)?;
|
||||
}
|
||||
check_repo_ref("the index", raw.repo.as_deref(), raw.git_ref.as_deref())?;
|
||||
let (name, homepage, curated, templates) = match &raw.source_info {
|
||||
Some(info) => {
|
||||
check_str("source name", &info.name)?;
|
||||
if info.file_urls.is_empty() || info.file_urls.len() > MAX_TEMPLATES {
|
||||
return invalid("source_info.file_urls must list 1 to 8 templates");
|
||||
}
|
||||
for t in &info.file_urls {
|
||||
check_template(t)?;
|
||||
}
|
||||
let homepage = info.homepage.clone().filter(|h| is_source_url(h));
|
||||
let name = if info.name.trim().is_empty() { declared.clone() } else { info.name.clone() };
|
||||
(name, homepage, info.curated, info.file_urls.clone())
|
||||
}
|
||||
None => {
|
||||
let Some(base) = &raw.base_url else { return invalid("an index without source_info needs base_url") };
|
||||
let templates = std::iter::once(base).chain(&raw.fallback_base_url).map(|b| format!("{b}{{path}}")).collect();
|
||||
let name = host.clone().unwrap_or_else(|| "Google Fonts".into());
|
||||
(name, None, true, templates)
|
||||
}
|
||||
};
|
||||
let curated = curated && host.is_none();
|
||||
if raw.families.len() > MAX_FAMILIES || raw.languages.len() > MAX_FAMILIES * 4 || raw.scripts.len() > MAX_LIST {
|
||||
return invalid("too many families, languages or scripts");
|
||||
}
|
||||
for l in &raw.languages {
|
||||
check_str("language", &l.id)?;
|
||||
}
|
||||
for s in &raw.scripts {
|
||||
check_str("script", &s.id)?;
|
||||
}
|
||||
let slug = if host.is_some() { user_slug(&id) } else { slug(&id) };
|
||||
let ctx = Ctx {
|
||||
declared: &declared,
|
||||
id: &id,
|
||||
slug: &slug,
|
||||
google: host.is_none() && declared == GOOGLE_FONTS,
|
||||
curated,
|
||||
templates: &templates,
|
||||
repo: raw.repo.as_deref(),
|
||||
git_ref: raw.git_ref.as_deref(),
|
||||
languages: raw.languages.len(),
|
||||
};
|
||||
let mut warnings = Vec::new();
|
||||
let mut families = Vec::with_capacity(raw.families.len());
|
||||
let mut names = HashSet::new();
|
||||
let mut dirs = HashSet::new();
|
||||
let mut total = 0usize;
|
||||
for mut fam in raw.families {
|
||||
check_str("license", &fam.license)?;
|
||||
if !LICENSES.contains(&fam.license.as_str()) {
|
||||
warnings.push(format!("{} is left out: its licence `{}` doesn't allow redistribution", short(&fam.family), short(&fam.license)));
|
||||
continue;
|
||||
}
|
||||
check_family(&mut fam, &ctx)?;
|
||||
total = total.saturating_add(fam.files.len());
|
||||
if total > MAX_FILES {
|
||||
return invalid("too many files");
|
||||
}
|
||||
if !names.insert(fam.family.to_lowercase()) {
|
||||
return invalid(format!("family `{}` is listed twice", fam.family));
|
||||
}
|
||||
if !dirs.insert(fam.dir.clone()) {
|
||||
return invalid(format!("folder `{}` holds more than one family", fam.dir));
|
||||
}
|
||||
families.push(fam);
|
||||
}
|
||||
let source = FontSource {
|
||||
id,
|
||||
name,
|
||||
homepage,
|
||||
curated,
|
||||
version: raw.version,
|
||||
commit: raw.commit,
|
||||
user_url: match origin {
|
||||
Origin::Trusted => None,
|
||||
Origin::User { url } => Some(url.to_string()),
|
||||
},
|
||||
changed: false,
|
||||
warnings,
|
||||
families: families.len(),
|
||||
templates,
|
||||
repo: raw.repo,
|
||||
git_ref: raw.git_ref,
|
||||
};
|
||||
Ok(Index::assemble(vec![(source, raw.languages, raw.scripts, families)], Vec::new()))
|
||||
}
|
||||
|
||||
/// The family folder in google/fonts: every file at `<ofl|apache|ufl>/<dir>/<file>`.
|
||||
fn google_dir(fam: &Family) -> Result<String, IndexError> {
|
||||
let dir_of = |path: &str| -> Option<String> {
|
||||
let mut parts = path.split('/');
|
||||
match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
||||
(Some(lic), Some(dir), Some(_), None) if ["ofl", "apache", "ufl"].contains(&lic) && safe_segment(dir) => Some(dir.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let mut dir: Option<String> = None;
|
||||
for path in fam.files.iter().map(|f| f.path.as_str()).chain(fam.license_path.as_deref()) {
|
||||
let d = dir_of(path).ok_or_else(|| IndexError::Invalid(format!("`{}` is not <ofl|apache|ufl>/<folder>/<file>", short(path))))?;
|
||||
match &dir {
|
||||
None => dir = Some(d),
|
||||
Some(x) if *x == d => {}
|
||||
Some(_) => return invalid(format!("the files of `{}` are in different folders", fam.family)),
|
||||
}
|
||||
}
|
||||
dir.ok_or_else(|| IndexError::Invalid(format!("`{}` has no files", fam.family)))
|
||||
}
|
||||
|
||||
fn check_family(fam: &mut Family, ctx: &Ctx<'_>) -> Result<(), IndexError> {
|
||||
check_str("family name", &fam.family)?;
|
||||
if fam.family.trim().is_empty() {
|
||||
return invalid("a family has no name");
|
||||
}
|
||||
let name = fam.family.clone();
|
||||
if !fam.source.is_empty() && fam.source != ctx.declared {
|
||||
return invalid(format!("`{name}` is from source `{}`, not the index's", short(&fam.source)));
|
||||
}
|
||||
fam.source = ctx.id.to_string();
|
||||
fam.curated = ctx.curated;
|
||||
check_str("designer", &fam.designer)?;
|
||||
if fam.files.is_empty() {
|
||||
return invalid(format!("`{name}` has no files"));
|
||||
}
|
||||
let lists = [fam.categories.len(), fam.subsets.len(), fam.languages.len(), fam.scripts.len(), fam.files.len()];
|
||||
if lists.iter().any(|&n| n > MAX_LIST) {
|
||||
return invalid(format!("`{name}` has too many entries"));
|
||||
}
|
||||
for s in fam.categories.iter().chain(&fam.subsets).chain(&fam.scripts) {
|
||||
check_str("category, subset or script", s)?;
|
||||
}
|
||||
if fam.languages.iter().any(|&i| i >= ctx.languages) {
|
||||
return invalid(format!("`{name}` names a language the index doesn't have"));
|
||||
}
|
||||
check_repo_ref(&name, fam.repo.as_deref(), fam.git_ref.as_deref())?;
|
||||
let (fam_repo, fam_ref) = (fam.repo.clone(), fam.git_ref.clone());
|
||||
let repo = |own: Option<String>| own.or_else(|| fam_repo.clone()).or_else(|| ctx.repo.map(str::to_string));
|
||||
let git_ref = |own: Option<String>| own.or_else(|| fam_ref.clone()).or_else(|| ctx.git_ref.map(str::to_string));
|
||||
let mut names = HashSet::new();
|
||||
for f in &fam.files {
|
||||
let file = check_path(&f.path)?.to_ascii_lowercase();
|
||||
if ![".ttf", ".otf", ".ttc", ".otc"].iter().any(|ext| file.ends_with(ext)) {
|
||||
return invalid(format!("`{}` is not a font file", short(&f.path)));
|
||||
}
|
||||
if !names.insert(file) {
|
||||
return invalid(format!("`{}` is listed twice", short(&f.path)));
|
||||
}
|
||||
if !is_sha256(&f.sha256) {
|
||||
return invalid(format!("`{}` has a bad sha256", short(&f.path)));
|
||||
}
|
||||
if f.size == 0 || f.size > MAX_FILE_BYTES {
|
||||
return invalid(format!("`{}` has size {}", short(&f.path), f.size));
|
||||
}
|
||||
if !(1..=1000).contains(&f.weight) {
|
||||
return invalid(format!("`{}` has weight {}", short(&f.path), f.weight));
|
||||
}
|
||||
if !(f.width.is_finite() && f.width > 0.0 && f.width <= 1000.0) {
|
||||
return invalid(format!("`{}` has width {}", short(&f.path), f.width));
|
||||
}
|
||||
check_str("style", &f.style)?;
|
||||
if f.axes.len() > 64 || f.postscript.len() > MAX_LIST {
|
||||
return invalid(format!("`{}` has too many axes or PostScript names", short(&f.path)));
|
||||
}
|
||||
for a in &f.axes {
|
||||
if a.tag.len() != 4 || !a.tag.is_ascii() || !a.min.is_finite() || !a.max.is_finite() || a.min > a.max {
|
||||
return invalid(format!("`{}` has a bad axis", short(&f.path)));
|
||||
}
|
||||
}
|
||||
for ps in &f.postscript {
|
||||
check_str("PostScript name", ps)?;
|
||||
if ps.is_empty() {
|
||||
return invalid(format!("`{}` has an empty PostScript name", short(&f.path)));
|
||||
}
|
||||
}
|
||||
check_repo_ref(&f.path, f.repo.as_deref(), f.git_ref.as_deref())?;
|
||||
fill(ctx.templates, &f.path, repo(f.repo.clone()).as_deref(), git_ref(f.git_ref.clone()).as_deref())?;
|
||||
}
|
||||
let beside = |path: &str| -> Result<(), IndexError> {
|
||||
if names.contains(&check_path(path)?.to_ascii_lowercase()) {
|
||||
return invalid(format!("the licence of `{name}` has a font file's name"));
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
if let Some(lf) = &fam.license_file {
|
||||
beside(&lf.path)?;
|
||||
if !is_sha256(&lf.sha256) || lf.size == 0 || lf.size > MAX_LICENSE_BYTES {
|
||||
return invalid(format!("the licence of `{name}` has a bad sha256 or size"));
|
||||
}
|
||||
check_repo_ref(&lf.path, lf.repo.as_deref(), lf.git_ref.as_deref())?;
|
||||
fill(ctx.templates, &lf.path, repo(lf.repo.clone()).as_deref(), git_ref(lf.git_ref.clone()).as_deref())?;
|
||||
}
|
||||
if let Some(lp) = &fam.license_path {
|
||||
beside(lp)?;
|
||||
if fam.license_file.is_none() {
|
||||
fill(ctx.templates, lp, repo(None).as_deref(), git_ref(None).as_deref())?;
|
||||
}
|
||||
}
|
||||
fam.dir = if ctx.google { google_dir(fam)? } else { format!("{}/{}", ctx.slug, slug(&fam.family)) };
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! `sources.json`: the built-in indexes, each with its size and sha256.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::index::{Index, IndexError, MAX_INDEX_BYTES, gunzip};
|
||||
use crate::source::{MAX_STR, check_path, check_str, check_version, invalid, is_sha256};
|
||||
use crate::verify::sha256_hex;
|
||||
|
||||
/// The largest `sources.json` read.
|
||||
pub(crate) const MAX_SOURCES_BYTES: usize = 1 << 20;
|
||||
const MAX_SOURCES: usize = 64;
|
||||
|
||||
/// One built-in index of `sources.json`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
|
||||
pub struct SourceEntry {
|
||||
/// The index's source ID.
|
||||
pub source: String,
|
||||
/// The source's display name.
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// Whether the source is curated.
|
||||
#[serde(default)]
|
||||
pub curated: bool,
|
||||
/// The index, relative to `sources.json`.
|
||||
pub path: String,
|
||||
/// Bytes of the index.
|
||||
pub size: u64,
|
||||
/// sha256 of the index.
|
||||
pub sha256: String,
|
||||
/// The gzipped index, relative to `sources.json`.
|
||||
#[serde(default)]
|
||||
pub gz_path: Option<String>,
|
||||
/// Bytes of the gzipped index.
|
||||
#[serde(default)]
|
||||
pub gz_size: Option<u64>,
|
||||
/// sha256 of the gzipped index.
|
||||
#[serde(default)]
|
||||
pub gz_sha256: Option<String>,
|
||||
/// The commit the index was generated from.
|
||||
#[serde(default)]
|
||||
pub commit: String,
|
||||
}
|
||||
|
||||
impl SourceEntry {
|
||||
/// The gzipped copy's path, size and sha256, when `sources.json` lists one.
|
||||
pub fn gz(&self) -> Option<(&str, u64, &str)> {
|
||||
match (&self.gz_path, self.gz_size, &self.gz_sha256) {
|
||||
(Some(p), Some(n), Some(h)) => Some((p, n, h)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The index from the bytes of its gzipped copy (when `gz`) or of the index itself, checked
|
||||
/// against the sizes and sha256s listed here, and that it is this entry's source.
|
||||
pub fn read(&self, bytes: &[u8], gz: bool) -> Result<Index, IndexError> {
|
||||
let json = if gz {
|
||||
let (_, size, sha256) = self.gz().ok_or(IndexError::Checksum)?;
|
||||
if bytes.len() as u64 != size || sha256_hex(bytes) != sha256 {
|
||||
return Err(IndexError::Checksum);
|
||||
}
|
||||
gunzip(bytes, MAX_INDEX_BYTES)?
|
||||
} else {
|
||||
bytes.to_vec()
|
||||
};
|
||||
if json.len() as u64 != self.size || sha256_hex(&json) != self.sha256 {
|
||||
return Err(IndexError::Checksum);
|
||||
}
|
||||
let index = Index::from_json(&json)?;
|
||||
if index.sources().iter().any(|s| s.id != self.source) {
|
||||
return invalid(format!("the index at {} is not of source {}", self.path, self.source));
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
}
|
||||
|
||||
/// `sources.json`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
|
||||
pub struct SourcesFile {
|
||||
/// `"MAJOR.MINOR"`.
|
||||
pub version: String,
|
||||
/// The built-in indexes, in the order a client lists them.
|
||||
pub indexes: Vec<SourceEntry>,
|
||||
}
|
||||
|
||||
impl SourcesFile {
|
||||
/// Reads and checks `sources.json`.
|
||||
pub fn from_json(bytes: &[u8]) -> Result<Self, IndexError> {
|
||||
if bytes.len() > MAX_SOURCES_BYTES {
|
||||
return Err(IndexError::TooLarge { max: MAX_SOURCES_BYTES });
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| IndexError::Json(e.to_string()))?;
|
||||
let version = value.get("version").and_then(|v| v.as_str()).ok_or_else(|| IndexError::Version("(none)".into()))?;
|
||||
check_version(version)?;
|
||||
let file: SourcesFile = serde_json::from_value(value).map_err(|e| IndexError::Json(e.to_string()))?;
|
||||
if file.indexes.len() > MAX_SOURCES {
|
||||
return invalid("sources.json lists too many indexes");
|
||||
}
|
||||
let mut ids = HashSet::new();
|
||||
for e in &file.indexes {
|
||||
check_str("source", &e.source)?;
|
||||
check_str("source name", &e.name)?;
|
||||
check_str("commit", &e.commit)?;
|
||||
if e.source.is_empty() || e.source.len() > MAX_STR || !ids.insert(e.source.as_str()) {
|
||||
return invalid(format!("source `{}` is empty or listed twice", e.source));
|
||||
}
|
||||
check_path(&e.path)?;
|
||||
if !is_sha256(&e.sha256) || e.size == 0 || e.size > MAX_INDEX_BYTES as u64 {
|
||||
return invalid(format!("the index of `{}` has a bad sha256 or size", e.source));
|
||||
}
|
||||
if e.gz_path.is_some() || e.gz_size.is_some() || e.gz_sha256.is_some() {
|
||||
let Some((p, n, h)) = e.gz() else { return invalid(format!("the gzipped index of `{}` is incomplete", e.source)) };
|
||||
check_path(p)?;
|
||||
if !is_sha256(h) || n == 0 || n > MAX_INDEX_BYTES as u64 {
|
||||
return invalid(format!("the gzipped index of `{}` has a bad sha256 or size", e.source));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "bundled-index")]
|
||||
const BUNDLED_SOURCES: &[u8] = include_bytes!("../index/sources.json");
|
||||
|
||||
/// The gzipped indexes compiled in, by their path in `sources.json`.
|
||||
#[cfg(feature = "bundled-index")]
|
||||
const BUNDLED: [(&str, &[u8]); 2] = [
|
||||
("indexes/google-fonts.json.gz", include_bytes!("../index/indexes/google-fonts.json.gz")),
|
||||
("indexes/noto.json.gz", include_bytes!("../index/indexes/noto.json.gz")),
|
||||
];
|
||||
|
||||
#[cfg(feature = "bundled-index")]
|
||||
pub(crate) fn bundled() -> Result<Index, IndexError> {
|
||||
if sha256_hex(BUNDLED_SOURCES) != crate::PINNED_INDEX.sha256 {
|
||||
return Err(IndexError::Checksum);
|
||||
}
|
||||
let sources = SourcesFile::from_json(BUNDLED_SOURCES)?;
|
||||
let mut parts = Vec::new();
|
||||
for entry in &sources.indexes {
|
||||
let gz_path = entry.gz().map(|(p, _, _)| p);
|
||||
let bytes = BUNDLED.iter().find(|(p, _)| Some(*p) == gz_path).map(|(_, b)| *b);
|
||||
let bytes = bytes.ok_or_else(|| IndexError::Invalid(format!("{} is not compiled in", entry.source)))?;
|
||||
parts.push(entry.read(bytes, true)?);
|
||||
}
|
||||
Ok(Index::merge(parts))
|
||||
}
|
||||
@@ -0,0 +1,920 @@
|
||||
//! The font store folder: consent, installs, the manifest, removal and checks.
|
||||
//!
|
||||
//! Layout of the folder ([`craft_font_catalog::font_store_dir`]):
|
||||
//!
|
||||
//! ```text
|
||||
//! settings.json the consent setting and the sources the user added
|
||||
//! manifest.json what is installed
|
||||
//! indexes/ the last indexes fetched: sources.json, one file per source
|
||||
//! <family dir>/ google/fonts' folder name, or <source>/<family>: the fonts and the licence
|
||||
//! .staging/ downloads being installed; nothing a font scan picks up
|
||||
//! .lock held while the store changes
|
||||
//! ```
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use craft_font_catalog::{Catalog, FontEnv, font_store_dir};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::http::{Cancel, Client, FetchError};
|
||||
use crate::index::{Face, Family, FontFile, GOOGLE_FONTS, Index, IndexError, MAX_INDEX_BYTES, MAX_LICENSE_BYTES, SourceProblem, Unavailable};
|
||||
use crate::pin::IndexPin;
|
||||
use crate::source::{is_source_url, safe_dir, safe_file_name, slug};
|
||||
use crate::sources::{MAX_SOURCES_BYTES, SourceEntry, SourcesFile};
|
||||
use crate::verify::{VerifyError, is_font, sha256_hex, verify_font};
|
||||
|
||||
const SETTINGS: &str = "settings.json";
|
||||
const MANIFEST: &str = "manifest.json";
|
||||
const INDEX_CACHE: &str = "indexes";
|
||||
const SOURCES_CACHE: &str = "sources.json";
|
||||
const STAGING: &str = ".staging";
|
||||
const LOCK: &str = ".lock";
|
||||
const MAX_MANIFEST_BYTES: u64 = 16 << 20;
|
||||
const MAX_SETTINGS_BYTES: u64 = 1 << 20;
|
||||
const MAX_USER_SOURCES: usize = 64;
|
||||
/// Staging folders older than this belong to an install that crashed.
|
||||
const STALE: Duration = Duration::from_secs(3600);
|
||||
|
||||
/// Whether the store may download.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Network {
|
||||
/// No requests. The default.
|
||||
#[default]
|
||||
Off,
|
||||
/// The user allowed downloads from jsDelivr and GitHub.
|
||||
On,
|
||||
}
|
||||
|
||||
/// Who drives the store. Only a person in the app can turn the network on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Caller {
|
||||
/// The app's own interface, acting on the user's click.
|
||||
User,
|
||||
/// An agent (MCP, the CLI, scripting): it can install when the user has turned the network
|
||||
/// on, and can never turn it on.
|
||||
Agent,
|
||||
}
|
||||
|
||||
/// Why a store operation failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// Downloading is off; the user turns it on in the app.
|
||||
#[error("downloading fonts is turned off")]
|
||||
NetworkOff,
|
||||
/// An agent tried to turn the network on.
|
||||
#[error("only the user can turn on downloading fonts")]
|
||||
NotAllowed,
|
||||
/// Not an https URL a source can be added from.
|
||||
#[error("{0} is not an https URL")]
|
||||
BadUrl(String),
|
||||
/// No exact match in the index.
|
||||
#[error(transparent)]
|
||||
Unavailable(#[from] Unavailable),
|
||||
/// The index can't be used.
|
||||
#[error(transparent)]
|
||||
Index(#[from] IndexError),
|
||||
/// Every URL failed.
|
||||
#[error("downloading {what} failed: {}", reasons.join("; "))]
|
||||
Download {
|
||||
/// The file.
|
||||
what: String,
|
||||
/// Each URL's failure.
|
||||
reasons: Vec<String>,
|
||||
},
|
||||
/// The downloaded file isn't the one the index describes.
|
||||
#[error("{file} was rejected: {error}")]
|
||||
Verify {
|
||||
/// The file.
|
||||
file: String,
|
||||
/// What was wrong.
|
||||
error: VerifyError,
|
||||
},
|
||||
/// The family isn't installed.
|
||||
#[error("{0} is not installed")]
|
||||
NotInstalled(String),
|
||||
/// The operation was cancelled.
|
||||
#[error("cancelled")]
|
||||
Cancelled,
|
||||
/// Reading or writing the store failed.
|
||||
#[error("{what}: {source}")]
|
||||
Io {
|
||||
/// What was being done.
|
||||
what: String,
|
||||
/// The error.
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
fn io(what: impl Into<String>) -> impl FnOnce(std::io::Error) -> Error {
|
||||
let what = what.into();
|
||||
move |source| Error::Io { what, source }
|
||||
}
|
||||
|
||||
/// Download progress of an install.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Progress {
|
||||
/// The file being downloaded, from 0.
|
||||
pub file: usize,
|
||||
/// Files to download.
|
||||
pub files: usize,
|
||||
/// Bytes downloaded, of all files.
|
||||
pub done: u64,
|
||||
/// Bytes of all files.
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// An installed font file.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InstalledFile {
|
||||
/// The file's name in the family folder.
|
||||
pub name: String,
|
||||
/// Its path in its source's repository.
|
||||
pub path: String,
|
||||
/// Bytes.
|
||||
pub size: u64,
|
||||
/// sha256, lowercase hex.
|
||||
pub sha256: String,
|
||||
/// The commit of the index it was installed from.
|
||||
pub commit: String,
|
||||
/// Its PostScript names.
|
||||
#[serde(default)]
|
||||
pub postscript: Vec<String>,
|
||||
}
|
||||
|
||||
/// An installed family.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InstalledFamily {
|
||||
/// The family name.
|
||||
pub family: String,
|
||||
/// The ID of the source it was installed from.
|
||||
#[serde(default = "google_fonts")]
|
||||
pub source: String,
|
||||
/// The folder in the store holding its files.
|
||||
pub dir: String,
|
||||
/// `OFL`, `APACHE2` or `UFL`.
|
||||
pub license: String,
|
||||
/// The licence file's name in the folder.
|
||||
pub license_file: Option<String>,
|
||||
/// The installed files: all of the family's, or some faces.
|
||||
pub files: Vec<InstalledFile>,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct Manifest {
|
||||
version: u32,
|
||||
families: Vec<InstalledFamily>,
|
||||
}
|
||||
|
||||
fn google_fonts() -> String {
|
||||
GOOGLE_FONTS.into()
|
||||
}
|
||||
|
||||
/// A source the user added: an index file at an https URL, never curated.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UserSource {
|
||||
/// The index file (format 1.x, JSON or gzipped JSON).
|
||||
pub url: String,
|
||||
/// When it was added, in seconds since 1970.
|
||||
pub added_at: u64,
|
||||
/// The sha256 of the file first fetched from it. A different file later shows as
|
||||
/// [`crate::FontSource::changed`].
|
||||
#[serde(default)]
|
||||
pub last_sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
struct Settings {
|
||||
#[serde(default)]
|
||||
network: Network,
|
||||
#[serde(default)]
|
||||
user_sources: Vec<UserSource>,
|
||||
}
|
||||
|
||||
/// Something [`FontStore::verify`] found wrong with an installed file.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Problem {
|
||||
/// The family.
|
||||
pub family: String,
|
||||
/// The file.
|
||||
pub file: String,
|
||||
/// What is wrong; `None` when the file is missing.
|
||||
pub error: Option<VerifyError>,
|
||||
}
|
||||
|
||||
/// The suite's font store.
|
||||
///
|
||||
/// Nothing here touches the network on its own: only [`FontStore::fetch_index`] and the installs
|
||||
/// do, and only while the user has turned the network on.
|
||||
pub struct FontStore {
|
||||
dir: PathBuf,
|
||||
caller: Caller,
|
||||
client: Client,
|
||||
catalog: Option<Arc<Catalog>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FontStore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("FontStore").field("dir", &self.dir).field("caller", &self.caller).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl FontStore {
|
||||
/// The store for the app's own interface, in the folder every app reads.
|
||||
/// `None` where there is no per-user folder.
|
||||
pub fn for_user(env: &FontEnv) -> Option<Self> {
|
||||
Some(Self::new(font_store_dir(env)?, Caller::User))
|
||||
}
|
||||
|
||||
/// The store for an agent (MCP, CLI): it can never turn the network on.
|
||||
pub fn for_agent(env: &FontEnv) -> Option<Self> {
|
||||
Some(Self::new(font_store_dir(env)?, Caller::Agent))
|
||||
}
|
||||
|
||||
/// A store in `dir`.
|
||||
pub fn new(dir: PathBuf, caller: Caller) -> Self {
|
||||
Self { dir, caller, client: Client::default(), catalog: None }
|
||||
}
|
||||
|
||||
/// Uses `client` for downloads (other timeouts).
|
||||
pub fn with_client(mut self, client: Client) -> Self {
|
||||
self.client = client;
|
||||
self
|
||||
}
|
||||
|
||||
/// Rescans `catalog` after each install and removal, so the app sees the change at once.
|
||||
/// Apps pass `Catalog::global().clone()`.
|
||||
pub fn with_catalog(mut self, catalog: Arc<Catalog>) -> Self {
|
||||
self.catalog = Some(catalog);
|
||||
self
|
||||
}
|
||||
|
||||
/// The store's folder.
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Who drives this store.
|
||||
pub fn caller(&self) -> Caller {
|
||||
self.caller
|
||||
}
|
||||
|
||||
// ----- consent
|
||||
|
||||
/// The user's setting; [`Network::Off`] when it was never set or can't be read.
|
||||
pub fn network(&self) -> Network {
|
||||
self.settings().network
|
||||
}
|
||||
|
||||
fn settings(&self) -> Settings {
|
||||
read_limited(&self.dir.join(SETTINGS), MAX_SETTINGS_BYTES).and_then(|b| serde_json::from_slice::<Settings>(&b).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Turns downloading on: call it only from the user's own action (a setting, an Install
|
||||
/// click). [`Error::NotAllowed`] for an agent.
|
||||
pub fn enable_network(&self) -> Result<(), Error> {
|
||||
if self.caller != Caller::User {
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
self.change_settings(|s| s.network = Network::On)
|
||||
}
|
||||
|
||||
/// Turns downloading off. Anyone may.
|
||||
pub fn disable_network(&self) -> Result<(), Error> {
|
||||
self.change_settings(|s| s.network = Network::Off)
|
||||
}
|
||||
|
||||
fn change_settings(&self, change: impl FnOnce(&mut Settings)) -> Result<(), Error> {
|
||||
let _lock = self.lock()?;
|
||||
let mut settings = self.settings();
|
||||
change(&mut settings);
|
||||
let bytes = serde_json::to_vec_pretty(&settings).map_err(|e| io("saving the setting")(std::io::Error::other(e)))?;
|
||||
self.write_file(SETTINGS, &bytes)
|
||||
}
|
||||
|
||||
// ----- sources the user added
|
||||
|
||||
/// The sources the user added, in the order added.
|
||||
pub fn user_sources(&self) -> Vec<UserSource> {
|
||||
self.settings().user_sources
|
||||
}
|
||||
|
||||
/// Adds an index file at `url` as a source, from the user's own action; the next
|
||||
/// [`FontStore::fetch_indexes`] downloads it. Its families are never curated.
|
||||
/// [`Error::NotAllowed`] for an agent.
|
||||
pub fn add_user_source(&self, url: &str) -> Result<(), Error> {
|
||||
if self.caller != Caller::User {
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
let url = url.trim();
|
||||
if !is_source_url(url) {
|
||||
return Err(Error::BadUrl(url.chars().take(200).collect()));
|
||||
}
|
||||
let added_at = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map_or(0, |d| d.as_secs());
|
||||
self.change_settings(|s| {
|
||||
if !s.user_sources.iter().any(|u| u.url == url) && s.user_sources.len() < MAX_USER_SOURCES {
|
||||
s.user_sources.push(UserSource { url: url.to_string(), added_at, last_sha256: None });
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Removes a source the user added, and its cached index. Families installed from it stay
|
||||
/// installed.
|
||||
pub fn remove_user_source(&self, url: &str) -> Result<(), Error> {
|
||||
self.change_settings(|s| s.user_sources.retain(|u| u.url != url))?;
|
||||
let _ = std::fs::remove_file(self.user_cache(url));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn user_cache(&self, url: &str) -> PathBuf {
|
||||
let key: String = sha256_hex(url.as_bytes()).chars().take(16).collect();
|
||||
self.dir.join(INDEX_CACHE).join(format!("user-{key}"))
|
||||
}
|
||||
|
||||
fn require_network(&self) -> Result<(), Error> {
|
||||
if self.network() == Network::On { Ok(()) } else { Err(Error::NetworkOff) }
|
||||
}
|
||||
|
||||
// ----- index
|
||||
|
||||
/// The indexes saved by the last [`FontStore::fetch_indexes`], when its `sources.json` is
|
||||
/// `pin`'s and every built-in index matches it, with the sources the user added.
|
||||
pub fn cached_indexes(&self, pin: &IndexPin) -> Option<Index> {
|
||||
let cache = self.dir.join(INDEX_CACHE);
|
||||
let bytes = read_limited(&cache.join(SOURCES_CACHE), MAX_SOURCES_BYTES as u64)?;
|
||||
if sha256_hex(&bytes) != pin.sha256 {
|
||||
return None;
|
||||
}
|
||||
let sources = SourcesFile::from_json(&bytes).ok()?;
|
||||
let mut parts = Vec::new();
|
||||
for entry in &sources.indexes {
|
||||
let bytes = read_limited(&cache.join(builtin_cache_name(entry)), MAX_INDEX_BYTES as u64)?;
|
||||
parts.push(entry.read(&bytes, entry.gz().is_some()).ok()?);
|
||||
}
|
||||
let (users, problems) = self.cached_user_indexes();
|
||||
Some(Index::merge(parts.into_iter().chain(users)).with_problems(problems))
|
||||
}
|
||||
|
||||
/// The indexes without a request: the built-in ones compiled in, with the sources the user
|
||||
/// added as last fetched.
|
||||
#[cfg(feature = "bundled-index")]
|
||||
pub fn offline_indexes(&self) -> Index {
|
||||
let (users, mut problems) = self.cached_user_indexes();
|
||||
let builtin = Index::bundled().unwrap_or_else(|e| {
|
||||
problems.insert(0, SourceProblem { source: "sources.json".into(), name: "Built-in sources".into(), error: e.to_string() });
|
||||
Index::default()
|
||||
});
|
||||
Index::merge(std::iter::once(builtin).chain(users)).with_problems(problems)
|
||||
}
|
||||
|
||||
fn cached_user_indexes(&self) -> (Vec<Index>, Vec<SourceProblem>) {
|
||||
let mut indexes = Vec::new();
|
||||
let mut problems = Vec::new();
|
||||
for user in self.user_sources() {
|
||||
let problem = |error: String| SourceProblem { source: user.url.clone(), name: user.url.clone(), error };
|
||||
let Some(bytes) = read_limited(&self.user_cache(&user.url), MAX_INDEX_BYTES as u64) else {
|
||||
problems.push(problem("not downloaded yet".into()));
|
||||
continue;
|
||||
};
|
||||
match user_index(&bytes, &user) {
|
||||
Ok(index) => indexes.push(index),
|
||||
Err(e) => problems.push(problem(e.to_string())),
|
||||
}
|
||||
}
|
||||
(indexes, problems)
|
||||
}
|
||||
|
||||
/// Downloads `sources.json` as `pin` names it, every index it lists and every source the user
|
||||
/// added, checks them and saves them for [`FontStore::cached_indexes`]. Needs the network on.
|
||||
///
|
||||
/// An index that fails leaves the others usable: it is reported in [`Index::problems`]. Only
|
||||
/// a `sources.json` that can't be had fails the whole call.
|
||||
pub fn fetch_indexes(&self, pin: &IndexPin, cancel: &Cancel) -> Result<Index, Error> {
|
||||
self.require_network()?;
|
||||
let check = |bytes: &[u8]| if sha256_hex(bytes) == pin.sha256 { Ok(()) } else { Err(VerifyError::Sha256) };
|
||||
let bytes = self.fetch(&pin.urls(), MAX_SOURCES_BYTES as u64, "sources.json", cancel, &mut |_| {}, &check)?;
|
||||
let sources = SourcesFile::from_json(&bytes)?;
|
||||
let cache = self.dir.join(INDEX_CACHE);
|
||||
std::fs::create_dir_all(&cache).map_err(io("creating the index folder"))?;
|
||||
let mut parts = Vec::new();
|
||||
let mut problems = Vec::new();
|
||||
for entry in &sources.indexes {
|
||||
match self.fetch_builtin(pin, entry, cancel) {
|
||||
Ok(index) => parts.push(index),
|
||||
Err(Error::Cancelled) => return Err(Error::Cancelled),
|
||||
Err(e) => problems.push(SourceProblem { source: entry.source.clone(), name: entry.name.clone(), error: e.to_string() }),
|
||||
}
|
||||
}
|
||||
self.write_file(&format!("{INDEX_CACHE}/{SOURCES_CACHE}"), &bytes)?;
|
||||
for user in self.user_sources() {
|
||||
match self.fetch_user(&user, cancel) {
|
||||
Ok(index) => parts.push(index),
|
||||
Err(Error::Cancelled) => return Err(Error::Cancelled),
|
||||
Err(e) => problems.push(SourceProblem { source: user.url.clone(), name: user.url.clone(), error: e.to_string() }),
|
||||
}
|
||||
}
|
||||
Ok(Index::merge(parts).with_problems(problems))
|
||||
}
|
||||
|
||||
fn fetch_builtin(&self, pin: &IndexPin, entry: &SourceEntry, cancel: &Cancel) -> Result<Index, Error> {
|
||||
let (path, size, sha256) = entry.gz().unwrap_or((&entry.path, entry.size, &entry.sha256));
|
||||
let check = |bytes: &[u8]| {
|
||||
if bytes.len() as u64 != size {
|
||||
Err(VerifyError::Size { expected: size, actual: bytes.len() as u64 })
|
||||
} else if sha256_hex(bytes) != sha256 {
|
||||
Err(VerifyError::Sha256)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
let bytes = self.fetch(&pin.file_urls(path), size, &entry.name, cancel, &mut |_| {}, &check)?;
|
||||
let index = entry.read(&bytes, entry.gz().is_some())?;
|
||||
self.write_file(&format!("{INDEX_CACHE}/{}", builtin_cache_name(entry)), &bytes)?;
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
fn fetch_user(&self, user: &UserSource, cancel: &Cancel) -> Result<Index, Error> {
|
||||
let bytes = self.fetch(std::slice::from_ref(&user.url), MAX_INDEX_BYTES as u64, &user.url, cancel, &mut |_| {}, &|_| Ok(()))?;
|
||||
let index = user_index(&bytes, user)?;
|
||||
if let Some(name) = self.user_cache(&user.url).file_name().and_then(|n| n.to_str()) {
|
||||
self.write_file(&format!("{INDEX_CACHE}/{name}"), &bytes)?;
|
||||
}
|
||||
if user.last_sha256.is_none() {
|
||||
let sha = sha256_hex(&bytes);
|
||||
self.change_settings(|s| {
|
||||
if let Some(u) = s.user_sources.iter_mut().find(|u| u.url == user.url && u.last_sha256.is_none()) {
|
||||
u.last_sha256 = Some(sha);
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
// ----- install
|
||||
|
||||
/// Installs every file of `family`. Files already installed with the same sha256 aren't
|
||||
/// downloaded again.
|
||||
pub fn install_family(
|
||||
&self,
|
||||
index: &Index,
|
||||
family: &Family,
|
||||
cancel: &Cancel,
|
||||
progress: &mut dyn FnMut(Progress),
|
||||
) -> Result<InstalledFamily, Error> {
|
||||
let files: Vec<&FontFile> = family.files.iter().collect();
|
||||
self.install(index, family, &files, cancel, progress)
|
||||
}
|
||||
|
||||
/// Installs the file of one face (from [`Index::find_face`] or [`Index::find_postscript`]),
|
||||
/// with the family's licence.
|
||||
pub fn install_face(&self, index: &Index, face: Face<'_>, cancel: &Cancel, progress: &mut dyn FnMut(Progress)) -> Result<InstalledFamily, Error> {
|
||||
self.install(index, face.family, &[face.file], cancel, progress)
|
||||
}
|
||||
|
||||
fn install(
|
||||
&self,
|
||||
index: &Index,
|
||||
family: &Family,
|
||||
files: &[&FontFile],
|
||||
cancel: &Cancel,
|
||||
progress: &mut dyn FnMut(Progress),
|
||||
) -> Result<InstalledFamily, Error> {
|
||||
let installed = self.installed_family(&family.source, &family.family);
|
||||
let folder = self.dir.join(family.dir());
|
||||
let have = |f: &FontFile| installed.as_ref().is_some_and(|i| i.files.iter().any(|x| x.name == f.file_name() && x.sha256 == f.sha256));
|
||||
let wanted: Vec<&FontFile> = files.iter().copied().filter(|f| !have(f) || !folder.join(f.file_name()).is_file()).collect();
|
||||
let license_missing = family.license_file_name().is_some_and(|name| !folder.join(name).is_file());
|
||||
if wanted.is_empty() && !license_missing {
|
||||
return installed.ok_or_else(|| Error::NotInstalled(family.family.clone()));
|
||||
}
|
||||
self.require_network()?;
|
||||
std::fs::create_dir_all(&self.dir).map_err(io("creating the font store folder"))?;
|
||||
self.sweep_staging();
|
||||
let stage = Stage::new(&self.dir)?;
|
||||
|
||||
let total: u64 = wanted.iter().map(|f| f.size).sum();
|
||||
let mut done_before = 0u64;
|
||||
let mut staged: Vec<(String, PathBuf)> = Vec::new();
|
||||
for (i, file) in wanted.iter().enumerate() {
|
||||
let mut on_bytes = |n: u64| progress(Progress { file: i, files: wanted.len(), done: done_before + n, total });
|
||||
let check = |bytes: &[u8]| verify_font(file, bytes);
|
||||
let bytes = self.fetch(&index.file_urls(family, file), file.size, file.file_name(), cancel, &mut on_bytes, &check)?;
|
||||
done_before += file.size;
|
||||
staged.push((file.file_name().to_string(), stage.write(file.file_name(), &bytes)?));
|
||||
}
|
||||
if license_missing && let Some(name) = family.license_file_name() {
|
||||
// A format 1.0 index has no size or sha256 for the licence: the size cap is the only
|
||||
// check.
|
||||
let max = family.license_file.as_ref().map_or(MAX_LICENSE_BYTES, |f| f.size);
|
||||
let check = |bytes: &[u8]| match &family.license_file {
|
||||
Some(f) if bytes.len() as u64 != f.size => Err(VerifyError::Size { expected: f.size, actual: bytes.len() as u64 }),
|
||||
Some(f) if sha256_hex(bytes) != f.sha256 => Err(VerifyError::Sha256),
|
||||
_ => Ok(()),
|
||||
};
|
||||
let bytes = self.fetch(&index.license_urls(family), max, name, cancel, &mut |_| {}, &check)?;
|
||||
staged.push((name.to_string(), stage.write(name, &bytes)?));
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
|
||||
let result = {
|
||||
let _lock = self.lock()?;
|
||||
let mut manifest = self.read_manifest();
|
||||
let commit = Commit::apply(&folder, &stage.dir, &staged)?;
|
||||
let entry = merge_entry(&mut manifest, index, family, &wanted);
|
||||
match self.write_manifest(&manifest) {
|
||||
Ok(()) => {
|
||||
commit.keep();
|
||||
Ok(entry)
|
||||
}
|
||||
Err(e) => {
|
||||
commit.undo();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
};
|
||||
drop(stage);
|
||||
self.rescan();
|
||||
result
|
||||
}
|
||||
|
||||
/// Downloads from each URL in turn until one gives bytes that pass `check`.
|
||||
fn fetch(
|
||||
&self,
|
||||
urls: &[String],
|
||||
max: u64,
|
||||
what: &str,
|
||||
cancel: &Cancel,
|
||||
on_bytes: &mut dyn FnMut(u64),
|
||||
check: &dyn Fn(&[u8]) -> Result<(), VerifyError>,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let mut reasons = Vec::new();
|
||||
let mut rejected = None;
|
||||
for url in urls {
|
||||
match self.client.get(url, max, cancel, on_bytes) {
|
||||
Ok(bytes) => match check(&bytes) {
|
||||
Ok(()) => return Ok(bytes),
|
||||
Err(e) => {
|
||||
reasons.push(format!("{}: {e}", host(url)));
|
||||
rejected.get_or_insert(e);
|
||||
}
|
||||
},
|
||||
Err(FetchError::Cancelled) => return Err(Error::Cancelled),
|
||||
Err(e) => reasons.push(format!("{}: {e}", host(url))),
|
||||
}
|
||||
}
|
||||
match rejected {
|
||||
Some(error) => Err(Error::Verify { file: what.to_string(), error }),
|
||||
None => Err(Error::Download { what: what.to_string(), reasons }),
|
||||
}
|
||||
}
|
||||
|
||||
// ----- installed fonts
|
||||
|
||||
/// The installed families, sorted by name.
|
||||
pub fn installed(&self) -> Vec<InstalledFamily> {
|
||||
self.read_manifest().families
|
||||
}
|
||||
|
||||
/// The family of this name installed from `source`, ignoring case.
|
||||
pub fn installed_family(&self, source: &str, name: &str) -> Option<InstalledFamily> {
|
||||
self.installed().into_iter().find(|f| f.is(source, name))
|
||||
}
|
||||
|
||||
/// Whether `file` is installed, with the index's sha256.
|
||||
pub fn is_installed(&self, family: &Family, file: &FontFile) -> bool {
|
||||
self.installed_family(&family.source, &family.family)
|
||||
.is_some_and(|i| i.files.iter().any(|x| x.name == file.file_name() && x.sha256 == file.sha256))
|
||||
}
|
||||
|
||||
/// Removes the family of this name installed from `source`: its files, its licence and its
|
||||
/// manifest entry. Its folder disappears at once and is deleted after.
|
||||
pub fn remove_family(&self, source: &str, name: &str) -> Result<(), Error> {
|
||||
let result = {
|
||||
let _lock = self.lock()?;
|
||||
let mut manifest = self.read_manifest();
|
||||
let pos = manifest.families.iter().position(|f| f.is(source, name)).ok_or_else(|| Error::NotInstalled(name.to_string()))?;
|
||||
let entry = manifest.families.remove(pos);
|
||||
let folder = self.dir.join(&entry.dir);
|
||||
let stage = Stage::new(&self.dir)?;
|
||||
let moved = stage.dir.join("removed");
|
||||
let had_folder = folder.exists();
|
||||
if had_folder {
|
||||
std::fs::rename(&folder, &moved).map_err(io(format!("removing {}", entry.family)))?;
|
||||
}
|
||||
match self.write_manifest(&manifest) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
if had_folder {
|
||||
let _ = std::fs::rename(&moved, &folder);
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
};
|
||||
self.rescan();
|
||||
result
|
||||
}
|
||||
|
||||
/// Removes some files of an installed family (one style, say), by their names in its folder.
|
||||
/// Taking out the last one removes the family. Names the family's manifest entry doesn't list
|
||||
/// are left alone.
|
||||
pub fn remove_files(&self, source: &str, name: &str, files: &[String]) -> Result<(), Error> {
|
||||
let result = {
|
||||
let _lock = self.lock()?;
|
||||
let mut manifest = self.read_manifest();
|
||||
let pos = manifest.families.iter().position(|f| f.is(source, name)).ok_or_else(|| Error::NotInstalled(name.to_string()))?;
|
||||
let Some(entry) = manifest.families.get_mut(pos) else { return Err(Error::NotInstalled(name.to_string())) };
|
||||
// Only listed, safe names become paths, so a request can't reach outside the folder.
|
||||
let removing: Vec<String> = entry.files.iter().map(|f| f.name.clone()).filter(|n| safe_file_name(n) && files.contains(n)).collect();
|
||||
if removing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
entry.files.retain(|f| !removing.contains(&f.name));
|
||||
let folder = self.dir.join(&entry.dir);
|
||||
let stage = Stage::new(&self.dir)?;
|
||||
let mut moving: Vec<(PathBuf, PathBuf)> = removing.iter().map(|f| (folder.join(f), stage.dir.join(f))).collect();
|
||||
let last = entry.files.is_empty();
|
||||
if last {
|
||||
// The licence goes with the last style. Files the store didn't install stay, and
|
||||
// keep the folder.
|
||||
if let Some(licence) = entry.license_file.clone().filter(|n| safe_file_name(n) && !removing.contains(n)) {
|
||||
moving.push((folder.join(&licence), stage.dir.join(&licence)));
|
||||
}
|
||||
manifest.families.remove(pos);
|
||||
}
|
||||
let mut moved = Vec::new();
|
||||
for (from, to) in &moving {
|
||||
if from.exists() {
|
||||
std::fs::rename(from, to).map_err(io(format!("removing {}", from.display())))?;
|
||||
moved.push((from, to));
|
||||
}
|
||||
}
|
||||
match self.write_manifest(&manifest) {
|
||||
Ok(()) => {
|
||||
if last {
|
||||
let _ = std::fs::remove_dir(&folder);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
for (from, to) in moved {
|
||||
let _ = std::fs::rename(to, from);
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
};
|
||||
self.rescan();
|
||||
result
|
||||
}
|
||||
|
||||
/// Checks every installed file against the manifest: present, the right size and sha256,
|
||||
/// and a font.
|
||||
pub fn verify(&self) -> Vec<Problem> {
|
||||
let mut problems = Vec::new();
|
||||
for fam in self.installed() {
|
||||
for f in &fam.files {
|
||||
let path = self.dir.join(&fam.dir).join(&f.name);
|
||||
let problem = |error| Problem { family: fam.family.clone(), file: f.name.clone(), error };
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
problems.push(problem(None));
|
||||
continue;
|
||||
};
|
||||
let error = if bytes.len() as u64 != f.size {
|
||||
Some(VerifyError::Size { expected: f.size, actual: bytes.len() as u64 })
|
||||
} else if sha256_hex(&bytes) != f.sha256 {
|
||||
Some(VerifyError::Sha256)
|
||||
} else if !is_font(&bytes) {
|
||||
Some(VerifyError::NotAFont)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if error.is_some() {
|
||||
problems.push(problem(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
problems
|
||||
}
|
||||
|
||||
// ----- folder
|
||||
|
||||
fn rescan(&self) {
|
||||
if let Some(catalog) = &self.catalog {
|
||||
catalog.rescan();
|
||||
}
|
||||
}
|
||||
|
||||
fn lock(&self) -> Result<File, Error> {
|
||||
std::fs::create_dir_all(&self.dir).map_err(io("creating the font store folder"))?;
|
||||
let file = File::options().create(true).truncate(false).write(true).open(self.dir.join(LOCK)).map_err(io("locking the font store"))?;
|
||||
file.lock().map_err(io("locking the font store"))?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// The manifest, without entries that aren't safe to act on. A manifest that can't be read
|
||||
/// is kept aside as `manifest.json.bad` and counts as empty.
|
||||
fn read_manifest(&self) -> Manifest {
|
||||
let path = self.dir.join(MANIFEST);
|
||||
let Some(bytes) = read_limited(&path, MAX_MANIFEST_BYTES) else { return Manifest::default() };
|
||||
let Ok(mut manifest) = serde_json::from_slice::<Manifest>(&bytes) else {
|
||||
let _ = std::fs::rename(&path, self.dir.join("manifest.json.bad"));
|
||||
return Manifest::default();
|
||||
};
|
||||
manifest
|
||||
.families
|
||||
.retain(|f| safe_dir(&f.dir) && f.files.iter().all(|x| safe_file_name(&x.name)) && f.license_file.as_deref().is_none_or(safe_file_name));
|
||||
manifest
|
||||
}
|
||||
|
||||
fn write_manifest(&self, manifest: &Manifest) -> Result<(), Error> {
|
||||
let manifest = Manifest { version: 1, families: manifest.families.clone() };
|
||||
let bytes = serde_json::to_vec_pretty(&manifest).map_err(|e| io("saving the manifest")(std::io::Error::other(e)))?;
|
||||
self.write_file(MANIFEST, &bytes)
|
||||
}
|
||||
|
||||
fn write_file(&self, name: &str, bytes: &[u8]) -> Result<(), Error> {
|
||||
std::fs::create_dir_all(&self.dir).map_err(io("creating the font store folder"))?;
|
||||
craft_atomic::atomic_write(&self.dir.join(name), bytes).map_err(io(format!("saving {name}")))
|
||||
}
|
||||
|
||||
fn sweep_staging(&self) {
|
||||
for e in std::fs::read_dir(self.dir.join(STAGING)).into_iter().flatten().flatten() {
|
||||
let old = e.metadata().and_then(|m| m.modified()).is_ok_and(|t| SystemTime::now().duration_since(t).is_ok_and(|d| d > STALE));
|
||||
if old {
|
||||
let _ = std::fs::remove_dir_all(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The family's manifest entry after installing `files`, merged into `manifest`.
|
||||
fn merge_entry(manifest: &mut Manifest, index: &Index, family: &Family, files: &[&FontFile]) -> InstalledFamily {
|
||||
let pos = match manifest.families.iter().position(|f| f.is(&family.source, &family.family)) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
manifest.families.push(InstalledFamily {
|
||||
family: family.family.clone(),
|
||||
source: family.source.clone(),
|
||||
dir: family.dir().to_string(),
|
||||
license: family.license.clone(),
|
||||
license_file: None,
|
||||
files: Vec::new(),
|
||||
});
|
||||
manifest.families.len() - 1
|
||||
}
|
||||
};
|
||||
let Some(entry) = manifest.families.get_mut(pos) else { return InstalledFamily::empty() };
|
||||
entry.license.clone_from(&family.license);
|
||||
let commit = index.source_of(family).map(|s| s.commit.clone()).unwrap_or_default();
|
||||
if let Some(name) = family.license_file_name() {
|
||||
entry.license_file = Some(name.to_string());
|
||||
}
|
||||
for f in files {
|
||||
entry.files.retain(|x| x.name != f.file_name());
|
||||
entry.files.push(InstalledFile {
|
||||
name: f.file_name().to_string(),
|
||||
path: f.path.clone(),
|
||||
size: f.size,
|
||||
sha256: f.sha256.clone(),
|
||||
commit: commit.clone(),
|
||||
postscript: f.postscript.clone(),
|
||||
});
|
||||
}
|
||||
entry.files.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
let entry = entry.clone();
|
||||
manifest.families.sort_by_key(|f| (f.family.to_lowercase(), f.source.clone()));
|
||||
entry
|
||||
}
|
||||
|
||||
impl InstalledFamily {
|
||||
fn empty() -> Self {
|
||||
Self { family: String::new(), source: String::new(), dir: String::new(), license: String::new(), license_file: None, files: Vec::new() }
|
||||
}
|
||||
|
||||
/// Whether this is the family `name` (ignoring case) from `source`.
|
||||
pub fn is(&self, source: &str, name: &str) -> bool {
|
||||
self.source == source && self.family.to_lowercase() == name.to_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_cache_name(entry: &SourceEntry) -> String {
|
||||
format!("{}{}", slug(&entry.source), if entry.gz().is_some() { ".json.gz" } else { ".json" })
|
||||
}
|
||||
|
||||
/// A user-added source's index from the bytes fetched from it.
|
||||
fn user_index(bytes: &[u8], user: &UserSource) -> Result<Index, IndexError> {
|
||||
let mut index = Index::from_user_bytes(bytes, &user.url)?;
|
||||
if user.last_sha256.as_ref().is_some_and(|h| *h != sha256_hex(bytes)) {
|
||||
index.mark_changed();
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
fn read_limited(path: &Path, max: u64) -> Option<Vec<u8>> {
|
||||
if std::fs::metadata(path).ok()?.len() > max {
|
||||
return None;
|
||||
}
|
||||
std::fs::read(path).ok()
|
||||
}
|
||||
|
||||
fn host(url: &str) -> &str {
|
||||
url.split("://").nth(1).and_then(|r| r.split('/').next()).unwrap_or(url)
|
||||
}
|
||||
|
||||
/// A folder of downloads under `.staging`, deleted when dropped.
|
||||
struct Stage {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Stage {
|
||||
fn new(store: &Path) -> Result<Self, Error> {
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
let dir = store.join(STAGING).join(format!("{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir).map_err(io("creating a download folder"))?;
|
||||
Ok(Self { dir })
|
||||
}
|
||||
|
||||
/// Writes a verified download as `<name>.part`, which no font scan picks up.
|
||||
fn write(&self, name: &str, bytes: &[u8]) -> Result<PathBuf, Error> {
|
||||
let path = self.dir.join(format!("{name}.part"));
|
||||
let write = || -> std::io::Result<()> {
|
||||
let mut f = File::options().write(true).create_new(true).open(&path)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()
|
||||
};
|
||||
write().map_err(io(format!("saving {name}")))?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Stage {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Staged files moved into a family folder, undone unless kept.
|
||||
struct Commit {
|
||||
folder: PathBuf,
|
||||
created_folder: bool,
|
||||
/// Each file moved in, and where the file it replaced went.
|
||||
moved: Vec<(PathBuf, Option<PathBuf>)>,
|
||||
kept: bool,
|
||||
}
|
||||
|
||||
impl Commit {
|
||||
fn apply(folder: &Path, stage: &Path, staged: &[(String, PathBuf)]) -> Result<Self, Error> {
|
||||
let created_folder = !folder.is_dir();
|
||||
let mut commit = Self { folder: folder.to_path_buf(), created_folder, moved: Vec::new(), kept: false };
|
||||
std::fs::create_dir_all(folder).map_err(io("creating the family folder"))?;
|
||||
for (name, from) in staged {
|
||||
let dest = folder.join(name);
|
||||
let backup = if dest.exists() {
|
||||
let b = stage.join(format!("{name}.old"));
|
||||
std::fs::rename(&dest, &b).map_err(io(format!("replacing {name}")))?;
|
||||
Some(b)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Err(e) = std::fs::rename(from, &dest) {
|
||||
if let Some(b) = &backup {
|
||||
let _ = std::fs::rename(b, &dest);
|
||||
}
|
||||
return Err(io(format!("installing {name}"))(e));
|
||||
}
|
||||
commit.moved.push((dest, backup));
|
||||
}
|
||||
Ok(commit)
|
||||
}
|
||||
|
||||
fn keep(mut self) {
|
||||
self.kept = true;
|
||||
}
|
||||
|
||||
fn undo(self) {}
|
||||
}
|
||||
|
||||
impl Drop for Commit {
|
||||
fn drop(&mut self) {
|
||||
if self.kept {
|
||||
return;
|
||||
}
|
||||
for (dest, backup) in self.moved.iter().rev() {
|
||||
let _ = std::fs::remove_file(dest);
|
||||
if let Some(b) = backup {
|
||||
let _ = std::fs::rename(b, dest);
|
||||
}
|
||||
}
|
||||
if self.created_folder {
|
||||
let _ = std::fs::remove_dir(&self.folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
//! Checks a downloaded or installed file against its index entry.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use skrifa::raw::FileRef;
|
||||
|
||||
use crate::index::FontFile;
|
||||
|
||||
/// Tables of which a drawable face has at least one.
|
||||
const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"];
|
||||
|
||||
/// Why a file doesn't match its index entry.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum VerifyError {
|
||||
/// The size differs.
|
||||
#[error("the file is {actual} bytes, not {expected}")]
|
||||
Size {
|
||||
/// The index's size.
|
||||
expected: u64,
|
||||
/// The file's.
|
||||
actual: u64,
|
||||
},
|
||||
/// The sha256 differs.
|
||||
#[error("the file's sha256 doesn't match the font index")]
|
||||
Sha256,
|
||||
/// It doesn't parse as a font with outlines.
|
||||
#[error("the file is not a font, or a damaged one")]
|
||||
NotAFont,
|
||||
}
|
||||
|
||||
/// The sha256 of `bytes`, in lowercase hex.
|
||||
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Checks `bytes` against `file`: its size, then its sha256, then that every face in it parses
|
||||
/// and has outlines.
|
||||
pub fn verify_font(file: &FontFile, bytes: &[u8]) -> Result<(), VerifyError> {
|
||||
let actual = bytes.len() as u64;
|
||||
if actual != file.size {
|
||||
return Err(VerifyError::Size { expected: file.size, actual });
|
||||
}
|
||||
if sha256_hex(bytes) != file.sha256 {
|
||||
return Err(VerifyError::Sha256);
|
||||
}
|
||||
if !is_font(bytes) {
|
||||
return Err(VerifyError::NotAFont);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `bytes` is a font file or collection whose every face has a `name` table and outlines.
|
||||
pub fn is_font(bytes: &[u8]) -> bool {
|
||||
let Ok(file) = FileRef::new(bytes) else { return false };
|
||||
let mut faces = 0usize;
|
||||
for font in file.fonts() {
|
||||
let Ok(font) = font else { return false };
|
||||
let named = font.table_data(skrifa::Tag::new(b"name")).is_some();
|
||||
let drawable = OUTLINE_TABLES.iter().any(|t| font.table_data(skrifa::Tag::new(t)).is_some());
|
||||
if !named || !drawable {
|
||||
return false;
|
||||
}
|
||||
faces += 1;
|
||||
}
|
||||
faces > 0
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "craft-fonts-build"
|
||||
description = "Embeds the optional craft-fonts faces from an app's build.rs, plus the EmbeddedFace type the generated table uses."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
# Cut the `role ui` faces to the catalogs' characters (`Embed::subset_to`). Enable it only in an
|
||||
# app's `[build-dependencies]`, so the subsetter stays out of the app itself.
|
||||
subset = ["dep:skera", "dep:skrifa"]
|
||||
|
||||
[dependencies]
|
||||
skera = { version = "0.8", optional = true }
|
||||
skrifa = { version = "0.48", default-features = false, features = ["std"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
craft-fonts-build-consumer = { path = "tests/consumer" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,202 @@
|
||||
# craft-fonts-build
|
||||
|
||||
Embeds the optional [craft-fonts](https://github.com/storytold/craft-fonts) faces from an app's
|
||||
`build.rs`, and defines `EmbeddedFace`, the plain data type of the generated table. No
|
||||
dependencies without the `subset` feature, and no egui, so crates below the UI layer (text engines,
|
||||
exporters) can use it.
|
||||
|
||||
Apps never list craft-fonts in `Cargo.toml`: Cargo would fetch the font repository for every
|
||||
build. The build reads a local checkout named by `CRAFT_FONTS_DIR` instead.
|
||||
|
||||
| Environment | Result |
|
||||
|---|---|
|
||||
| `CRAFT_FONTS_DIR` unset or empty | Empty table. |
|
||||
| `CRAFT_FONTS_DIR` = a craft-fonts checkout | Every selected face of `fonts/manifest.txt`, embedded with `include_bytes!`. |
|
||||
| `CRAFT_FONTS_DIR` set, but no manifest or a missing font file | `cargo::warning`, empty table. |
|
||||
| `CRAFT_FONTS_REQUIRED=1` (exactly `1`; other values are ignored) | Error instead when the directory is unset, unreadable, has no selected face, or has no face for a script passed to `scripts`. |
|
||||
|
||||
Give `CRAFT_FONTS_DIR` as an absolute path: Cargo runs build scripts in the package's directory.
|
||||
|
||||
## Using it
|
||||
|
||||
`Cargo.toml` (same `rev` as the app's other craft-libs crates). The `subset` feature goes on the
|
||||
build-dependency only: it brings in the subsetter ([skera](https://crates.io/crates/skera) and the
|
||||
fontations crates), which the app itself never needs. Cargo's resolver 2 and later (edition 2021
|
||||
and later) keep build-dependency features apart from the app's, so the dependency stays without
|
||||
it.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
craft-fonts-build = { git = "https://github.com/storytold/craft-libs", rev = "…" }
|
||||
|
||||
[build-dependencies]
|
||||
craft-fonts-build = { git = "https://github.com/storytold/craft-libs", rev = "…", features = ["subset"] }
|
||||
```
|
||||
|
||||
`build.rs` (FilmCraft's `crates/text/build.rs`, in full):
|
||||
|
||||
```rust
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-changed=build.rs");
|
||||
let wasm = craft_fonts_build::target_is_wasm();
|
||||
let embedded = craft_fonts_build::Embed::new()
|
||||
// CJK faces only as the interface faces, cut to the characters FilmCraft's catalogs use.
|
||||
.subset_to(catalogs("filmcraft"))
|
||||
// Web builds have a size budget: embed only the Japanese interface face there.
|
||||
.filter(move |face| !wasm || face.has_script("Jpan"))
|
||||
.write("craft_fonts.rs");
|
||||
if let Err(e) = embedded {
|
||||
println!("cargo::error={e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every locale's shared and app catalog in the craft-i18n checkout `CRAFT_I18N_DIR` names, the
|
||||
/// files the app's translations embed; none when it is unset.
|
||||
fn catalogs(app: &str) -> Vec<PathBuf> {
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_I18N_DIR");
|
||||
let Some(dir) = std::env::var_os("CRAFT_I18N_DIR").filter(|dir| !dir.is_empty()) else { return Vec::new() };
|
||||
let locales = PathBuf::from(dir).join("locales");
|
||||
println!("cargo::rerun-if-changed={}", locales.display());
|
||||
let mut paths: Vec<PathBuf> = std::fs::read_dir(&locales)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.flat_map(|locale| ["common.tsv".to_owned(), format!("{app}.tsv")].map(|file| locale.path().join(file)))
|
||||
.filter(|path| path.is_file())
|
||||
.collect();
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
```
|
||||
|
||||
The crate:
|
||||
|
||||
```rust
|
||||
use craft_fonts_build::EmbeddedFace;
|
||||
|
||||
/// craft-fonts faces; empty unless built with `CRAFT_FONTS_DIR`.
|
||||
pub static CRAFT_FONTS: &[EmbeddedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
||||
```
|
||||
|
||||
`Embed::new()` selects every face except the sources of the interface faces (`role` `ui`, see
|
||||
below), and `Embed::documents(&["Noto Sans", …])` only the families it names (see
|
||||
[Document fonts](#document-fonts)). The builder narrows the set:
|
||||
|
||||
- `scripts(&["Jpan", "Arab", "Hant"])` keeps faces with any of these script tags. A tag matches
|
||||
itself and its region forms (`Hant` matches `Hant-HK`). With `CRAFT_FONTS_REQUIRED=1`, each tag
|
||||
must match at least one face.
|
||||
- `filter(|entry| …)` keeps faces by any manifest field (family, style, licence, index).
|
||||
- `for_ui()` keeps CJK faces only as the interface faces (see below).
|
||||
- `subset_to(catalogs)` (feature `subset`) cuts the interface faces to the catalogs' characters;
|
||||
it implies `for_ui()`.
|
||||
- `check_text(paths)` warns about characters in these files that no embedded face covers.
|
||||
- `embedded_elsewhere(&["Noto Sans Math", …])` names families another table of the app embeds,
|
||||
so `check_text` and the catalog check count their characters as covered.
|
||||
- `dir(path)` and `required(bool)` override the two environment variables.
|
||||
|
||||
`write` returns the embedded `ManifestEntry`s, for build-time checks such as "the interface face
|
||||
is present". The generated file is a slice expression of `EmbeddedFace { family, style, scripts,
|
||||
index, bytes }` literals; any struct with those fields and that name in scope works too.
|
||||
`EmbeddedFace` itself has `name()`, `has_script(tag)`, `is_cjk()` and `weight()`.
|
||||
|
||||
## Document fonts
|
||||
|
||||
An app that draws document text (titles, layouts, vector text) embeds the families its documents
|
||||
use, and only those, in a table of its own:
|
||||
|
||||
```rust
|
||||
let embedded = craft_fonts_build::Embed::documents(&["Noto Sans", "Shippori Mincho"])
|
||||
// The interface table holds the CJK cuts and the symbol faces.
|
||||
.embedded_elsewhere(&["Noto Sans Math", "Noto Sans Symbols 2"])
|
||||
.write("document_fonts.rs");
|
||||
```
|
||||
|
||||
Each named family brings all its styles, as whole files: a CJK document face takes 5–17 MB, so a
|
||||
release embeds exactly the faces the app's documents need. A family named here is embedded even
|
||||
when it is also an interface face's source (`BIZ UDPGothic` brings its Regular too). A name the
|
||||
manifest lacks is an `Error::UnknownFamilies` listing the manifest's families: a build error with
|
||||
`CRAFT_FONTS_REQUIRED=1`, and a `cargo::warning` with an empty table otherwise, as for any
|
||||
checkout that can't be used. `scripts` and `filter` narrow the families further, for example to
|
||||
their Regular faces.
|
||||
|
||||
## Serving the faces (web builds)
|
||||
|
||||
`Embed::serve(dir, url_prefix)` copies the selected faces into `dir` instead of embedding them,
|
||||
and writes a table of `ServedFace { family, style, scripts, index, url, size, integrity, coverage }`
|
||||
literals for the app to fetch at run time (craft-ui's `fonts::UiFonts::served_faces` does). Each
|
||||
font file and its licence keep their path under the checkout's `fonts/` directory, and `url` is
|
||||
`url_prefix` plus that path: `serve("web/fonts", "fonts/")` copies
|
||||
`fonts/noto-sans-arabic/NotoSansArabic.ttf` to `web/fonts/noto-sans-arabic/NotoSansArabic.ttf`, with
|
||||
the URL `fonts/noto-sans-arabic/NotoSansArabic.ttf`. Faces of one collection share a file and a
|
||||
URL. `integrity` is the manifest's SHA-256 as a Subresource Integrity value. `coverage` holds the
|
||||
code points the face's `cmap` maps, as the `coverage` module encodes them (sorted ranges as LEB128
|
||||
gaps and lengths; `coverage::contains` reads them), so an app fetches a face only for characters
|
||||
it has: about 3 KB for a CJK interface face cut to an app's catalogs, under 1 KB for a full one.
|
||||
|
||||
An interface face cut by `subset_to` is served from its cut in `OUT_DIR`, with the start of the
|
||||
cut's SHA-256 in its name (`web/fonts/ui/NotoSansTCUI-Regular.1234abcd5678ef90.ttf`) and the cut's
|
||||
own `integrity`, so changed catalogs give a new URL rather than a cached copy that fails its check.
|
||||
A relative `dir` is under the package directory. Files already there with the same bytes are not
|
||||
copied again, a deleted one is copied again on the next build, and files of faces no longer
|
||||
selected, and earlier cuts, stay.
|
||||
|
||||
```rust
|
||||
// CJK faces only as the interface faces, cut to the app's catalogs (see below).
|
||||
let fonts = craft_fonts_build::Embed::new().subset_to(catalogs("filmcraft"));
|
||||
let written = if craft_fonts_build::target_is_wasm() {
|
||||
fonts.serve("web/fonts", "fonts/").write("craft_fonts.rs")
|
||||
} else {
|
||||
fonts.write("craft_fonts.rs")
|
||||
};
|
||||
```
|
||||
|
||||
The crate includes the table as `&[ServedFace]` on wasm and `&[EmbeddedFace]` elsewhere. craft-ui's
|
||||
README shows a whole eframe web app, Trunk copy included.
|
||||
|
||||
## Interface faces
|
||||
|
||||
The full CJK faces take 5–17 MB each. For the interface, craft-fonts marks one Regular TrueType
|
||||
face per CJK script with the role `ui`: `Noto Sans SC UI` (`Hans`), `Noto Sans TC UI` (`Hant`),
|
||||
`Noto Sans HK UI` (`Hant-HK`), `Noto Sans KR UI` (`Kore`) and BIZ UDPGothic Regular (`Jpan`). The
|
||||
app's build cuts them to the characters its translation catalogs use; for FilmCraft's catalogs
|
||||
the five cuts take about 3 MB together.
|
||||
|
||||
- `for_ui()` drops every face with a CJK script tag (`Hans`, `Hant`, `Hant-HK`, `Jpan`, `Kore`,
|
||||
`Hani`) that is not an interface face, and keeps the rest. Document text embeds the full faces
|
||||
it names with `Embed::documents` instead. Without `for_ui()` or `subset_to`, the interface
|
||||
faces' sources are left out unless `Embed::documents` names their family.
|
||||
- `subset_to(catalogs)` cuts each interface face to the characters the catalogs use from U+2E80 up
|
||||
(CJK and later blocks, without emoji, variation selectors and tags), plus CJK punctuation
|
||||
(U+3000–303F), kana (U+3040–30FF, U+31F0–31FF) and full-width forms (U+FF00–FFEF), and keeps
|
||||
every layout feature, so vertical forms (`vert`) still work. Every face gets the characters of
|
||||
every catalog, so a Japanese label in a Chinese catalog still draws. The cuts are written to
|
||||
`OUT_DIR/craft-fonts-ui/` and embedded from there; the same inputs give the same bytes, for
|
||||
reproducible releases. Cutting all five faces takes well under a second.
|
||||
- Without `subset_to`, or with an empty catalog list, `for_ui()` leaves the interface faces out
|
||||
with a `cargo::warning`: a full CJK face is never embedded for the interface by accident. A
|
||||
checkout whose manifest has no interface face is a `cargo::warning` too. With `CRAFT_FONTS_REQUIRED=1` that
|
||||
makes `scripts(&["Hant"])` an error; with catalogs, an interface face satisfies it.
|
||||
- An interface face with CFF outlines is an error (`Error::NotTrueType`), even when not required:
|
||||
the subsetter cuts TrueType (`glyf`) faces correctly, but not CFF ones.
|
||||
|
||||
The catalogs come from craft-i18n. An app's build reads them from the checkout `CRAFT_I18N_DIR`
|
||||
names (craft-i18n's `docs/integration.md`); the fonts build script reads the same variable, as in
|
||||
the example above. Catalogs kept in the app's own repository are passed by path, relative to the
|
||||
package directory.
|
||||
|
||||
A character the faces lack still shows: craft-ui's system font provider draws it with an installed
|
||||
font. The build says so: for each catalog, and each file passed to `check_text`, it prints one
|
||||
`cargo::warning` with the count and the first few characters from U+2E80 up that no embedded face
|
||||
covers. Since the cuts hold every catalog character their sources have, these are the characters
|
||||
the sources lack. Faces the app embeds in another table count as covering their characters when
|
||||
named with `embedded_elsewhere`; they are read whole from the checkout, so a face cut elsewhere is
|
||||
exact only for the catalogs it was cut to. It also reruns the build script when a file changes.
|
||||
|
||||
## The manifest
|
||||
|
||||
One face per line, eight or nine fields separated by `" | "`; craft-fonts' `docs/integration.md`
|
||||
defines them. Lines starting with `#` are comments. A file field ending in `#<n>` names face `n` of
|
||||
a font collection (`fonts/noto-sans-cjk/NotoSansCJK.ttc#2`); without it the index is 0. The ninth
|
||||
field, `role`, is `ui` on interface faces and absent elsewhere. Fields after the ninth are ignored.
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Just enough of an OpenType reader to ask which characters a face maps: the `cmap` table's
|
||||
//! Unicode subtables in formats 4 and 12, in a single font or one face of a collection.
|
||||
|
||||
/// The Unicode character maps of one face.
|
||||
pub(crate) struct Cmap<'a> {
|
||||
subtables: Vec<&'a [u8]>,
|
||||
}
|
||||
|
||||
impl<'a> Cmap<'a> {
|
||||
/// The cmap of face `index` of `font` (a `.ttf`/`.otf`, or a `.ttc` collection); `None` when the
|
||||
/// file isn't a font or has no Unicode subtable in format 4 or 12.
|
||||
pub(crate) fn read(font: &'a [u8], index: u32) -> Option<Self> {
|
||||
let face = if font.get(0..4)? == b"ttcf" {
|
||||
if index >= u32_at(font, 8)? {
|
||||
return None;
|
||||
}
|
||||
usize::try_from(u32_at(font, 12 + 4 * usize::try_from(index).ok()?)?).ok()?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let tables = usize::from(u16_at(font, face + 4)?);
|
||||
let record = (0..tables).map(|i| face + 12 + 16 * i).find(|&r| font.get(r..r + 4) == Some(b"cmap"))?;
|
||||
let cmap = slice(font, u32_at(font, record + 8)?, u32_at(font, record + 12)?)?;
|
||||
let mut subtables = Vec::new();
|
||||
for i in 0..usize::from(u16_at(cmap, 2)?) {
|
||||
let (platform, encoding) = (u16_at(cmap, 4 + 8 * i)?, u16_at(cmap, 6 + 8 * i)?);
|
||||
// Unicode platform, or Windows Unicode BMP (1) and full repertoire (10).
|
||||
if platform != 0 && !(platform == 3 && matches!(encoding, 1 | 10)) {
|
||||
continue;
|
||||
}
|
||||
let start = usize::try_from(u32_at(cmap, 8 + 8 * i)?).ok()?;
|
||||
let rest = cmap.get(start..)?;
|
||||
// Bound each subtable by its own length so that reads can't stray into the next one.
|
||||
// Some large CJK fonts declare a format 4 length past the end of the table, so that
|
||||
// one is clamped; a format 12 length that doesn't fit is skipped.
|
||||
let len = match u16_at(rest, 0) {
|
||||
Some(4) => u16_at(rest, 2).map(|len| usize::from(len).min(rest.len())),
|
||||
Some(12) => u32_at(rest, 4).and_then(|len| usize::try_from(len).ok()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(subtable) = len.and_then(|len| rest.get(..len)) {
|
||||
subtables.push(subtable);
|
||||
}
|
||||
}
|
||||
(!subtables.is_empty()).then_some(Self { subtables })
|
||||
}
|
||||
|
||||
/// Whether any Unicode subtable maps `c` to a glyph other than `.notdef`.
|
||||
pub(crate) fn covers(&self, c: char) -> bool {
|
||||
self.subtables.iter().any(|t| glyph(t, u32::from(c)).is_some_and(|g| g != 0))
|
||||
}
|
||||
|
||||
/// Every character [`Self::covers`], as sorted, disjoint, non-adjacent inclusive ranges.
|
||||
pub(crate) fn ranges(&self) -> Vec<(u32, u32)> {
|
||||
let mut spans: Vec<(u32, u32)> = self.subtables.iter().flat_map(|t| spans(t)).collect();
|
||||
spans.sort_unstable();
|
||||
let mut ranges: Vec<(u32, u32)> = Vec::new();
|
||||
let mut next = 0;
|
||||
for (start, end) in spans {
|
||||
for c in start.max(next)..=end {
|
||||
if !char::from_u32(c).is_some_and(|c| self.covers(c)) {
|
||||
continue;
|
||||
}
|
||||
match ranges.last_mut() {
|
||||
Some(last) if last.1 + 1 == c => last.1 = c,
|
||||
_ => ranges.push((c, c)),
|
||||
}
|
||||
}
|
||||
next = next.max(end.saturating_add(1));
|
||||
}
|
||||
ranges
|
||||
}
|
||||
}
|
||||
|
||||
/// The code point spans a subtable lists, mapped or not.
|
||||
fn spans(t: &[u8]) -> Vec<(u32, u32)> {
|
||||
let mut spans = Vec::new();
|
||||
match u16_at(t, 0) {
|
||||
Some(4) => {
|
||||
let seg_x2 = u16_at(t, 6).map_or(0, usize::from);
|
||||
for i in 0..seg_x2 / 2 {
|
||||
let (Some(end), Some(start)) = (u16_at(t, 14 + 2 * i), u16_at(t, 16 + seg_x2 + 2 * i)) else { break };
|
||||
if start <= end {
|
||||
spans.push((u32::from(start), u32::from(end)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(12) => {
|
||||
for i in 0..u32_at(t, 12).and_then(|n| usize::try_from(n).ok()).unwrap_or(0) {
|
||||
let (Some(start), Some(end)) = (u32_at(t, 16 + 12 * i), u32_at(t, 20 + 12 * i)) else { break };
|
||||
if start <= end {
|
||||
spans.push((start, end.min(u32::from(char::MAX))));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
fn glyph(subtable: &[u8], c: u32) -> Option<u32> {
|
||||
match u16_at(subtable, 0)? {
|
||||
4 => format4(subtable, u16::try_from(c).ok()?),
|
||||
12 => format12(subtable, c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn format4(t: &[u8], c: u16) -> Option<u32> {
|
||||
let seg_x2 = usize::from(u16_at(t, 6)?);
|
||||
let (ends, starts, deltas, range_offsets) = (14, 16 + seg_x2, 16 + 2 * seg_x2, 16 + 3 * seg_x2);
|
||||
// End codes are sorted: the first segment ending at or after `c` is the only candidate.
|
||||
let segments = seg_x2 / 2;
|
||||
let i = partition_point(segments, |i| u16_at(t, ends + 2 * i).is_some_and(|end| end < c));
|
||||
if i == segments {
|
||||
return None;
|
||||
}
|
||||
let start = u16_at(t, starts + 2 * i)?;
|
||||
if c < start {
|
||||
return None;
|
||||
}
|
||||
let delta = u16_at(t, deltas + 2 * i)?;
|
||||
let range_offset_at = range_offsets + 2 * i;
|
||||
let range_offset = usize::from(u16_at(t, range_offset_at)?);
|
||||
if range_offset == 0 {
|
||||
return Some(u32::from(c.wrapping_add(delta)));
|
||||
}
|
||||
let g = u16_at(t, range_offset_at + range_offset + 2 * usize::from(c - start))?;
|
||||
Some(if g == 0 { 0 } else { u32::from(g.wrapping_add(delta)) })
|
||||
}
|
||||
|
||||
fn format12(t: &[u8], c: u32) -> Option<u32> {
|
||||
let groups = usize::try_from(u32_at(t, 12)?).ok()?;
|
||||
// Groups are sorted by start code and don't overlap.
|
||||
let i = partition_point(groups, |i| u32_at(t, 16 + 12 * i + 4).is_some_and(|end| end < c));
|
||||
if i == groups {
|
||||
return None;
|
||||
}
|
||||
let g = 16 + 12 * i;
|
||||
let (start, end, glyph) = (u32_at(t, g)?, u32_at(t, g + 4)?, u32_at(t, g + 8)?);
|
||||
(start..=end).contains(&c).then(|| glyph.wrapping_add(c - start))
|
||||
}
|
||||
|
||||
/// The first index in `0..len` for which `before` is false, given that it is true for a prefix.
|
||||
fn partition_point(len: usize, before: impl Fn(usize) -> bool) -> usize {
|
||||
let (mut lo, mut hi) = (0, len);
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if before(mid) { lo = mid + 1 } else { hi = mid }
|
||||
}
|
||||
lo
|
||||
}
|
||||
|
||||
fn slice(data: &[u8], offset: u32, len: u32) -> Option<&[u8]> {
|
||||
let offset = usize::try_from(offset).ok()?;
|
||||
data.get(offset..offset.checked_add(usize::try_from(len).ok()?)?)
|
||||
}
|
||||
|
||||
fn u16_at(data: &[u8], at: usize) -> Option<u16> {
|
||||
Some(u16::from_be_bytes(data.get(at..at.checked_add(2)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn u32_at(data: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_be_bytes(data.get(at..at.checked_add(4)?)?.try_into().ok()?))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! The characters a face maps, as [`ServedFace::coverage`](crate::ServedFace::coverage) stores
|
||||
//! them: its sorted, disjoint code point ranges, each as two unsigned LEB128 numbers, the gap from
|
||||
//! the end of the previous range (from 0 for the first) and the range's length minus one. A
|
||||
//! Chinese interface face cut to an app's catalogs takes about 3 KB.
|
||||
|
||||
use crate::cmap::Cmap;
|
||||
|
||||
/// The coverage of face `index` of `font` (a `.ttf`/`.otf`, or a `.ttc` collection); `None` when
|
||||
/// the file isn't a font or has no Unicode `cmap` subtable in format 4 or 12.
|
||||
pub fn of_face(font: &[u8], index: u32) -> Option<Vec<u8>> {
|
||||
Some(encode(&Cmap::read(font, index)?.ranges()))
|
||||
}
|
||||
|
||||
/// The coverage of inclusive code point ranges, sorted by start. Overlaps are dropped.
|
||||
pub fn encode(ranges: &[(u32, u32)]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut next = 0;
|
||||
for &(start, end) in ranges {
|
||||
let start = start.max(next);
|
||||
if end < start {
|
||||
continue;
|
||||
}
|
||||
leb128(&mut out, start - next);
|
||||
leb128(&mut out, end - start);
|
||||
let Some(after) = end.checked_add(1) else { break };
|
||||
next = after;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `coverage` holds `c`.
|
||||
pub fn contains(coverage: &[u8], c: char) -> bool {
|
||||
let c = u32::from(c);
|
||||
let mut bytes = coverage.iter().copied();
|
||||
let mut next = 0u32;
|
||||
while let (Some(gap), Some(len)) = (read(&mut bytes), read(&mut bytes)) {
|
||||
let Some(start) = next.checked_add(gap) else { return false };
|
||||
let Some(end) = start.checked_add(len) else { return false };
|
||||
if c < start {
|
||||
return false;
|
||||
}
|
||||
if c <= end {
|
||||
return true;
|
||||
}
|
||||
let Some(after) = end.checked_add(1) else { return false };
|
||||
next = after;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn leb128(out: &mut Vec<u8>, mut n: u32) {
|
||||
while n >= 0x80 {
|
||||
out.push((n & 0x7F) as u8 | 0x80);
|
||||
n >>= 7;
|
||||
}
|
||||
out.push(n as u8);
|
||||
}
|
||||
|
||||
fn read(bytes: &mut impl Iterator<Item = u8>) -> Option<u32> {
|
||||
let mut n = 0u32;
|
||||
for shift in (0..35).step_by(7) {
|
||||
let byte = bytes.next()?;
|
||||
n |= u32::from(byte & 0x7F).checked_shl(shift)?;
|
||||
if byte & 0x80 == 0 {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
//! The build-script side: read `CRAFT_FONTS_DIR`, select faces, write the generated table.
|
||||
|
||||
#[cfg(feature = "subset")]
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::OsString;
|
||||
use std::fmt::{self, Write as _};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::cmap::Cmap;
|
||||
use crate::manifest::{ManifestEntry, ParseError, parse_manifest, script_matches};
|
||||
|
||||
const DIR_VAR: &str = "CRAFT_FONTS_DIR";
|
||||
const REQUIRED_VAR: &str = "CRAFT_FONTS_REQUIRED";
|
||||
const EMPTY_TABLE: &str = "&[]\n";
|
||||
|
||||
/// Whether the build script is building for wasm32 (web builds usually embed fewer faces).
|
||||
pub fn target_is_wasm() -> bool {
|
||||
std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|arch| arch == "wasm32")
|
||||
}
|
||||
|
||||
type Filter = Box<dyn Fn(&ManifestEntry) -> bool>;
|
||||
|
||||
/// Which faces to embed, and from where. [`Embed::write`] does the work.
|
||||
#[derive(Default)]
|
||||
pub struct Embed {
|
||||
scripts: Option<Vec<String>>,
|
||||
filter: Option<Filter>,
|
||||
dir: Option<PathBuf>,
|
||||
required: Option<bool>,
|
||||
ui: bool,
|
||||
catalogs: Option<Vec<PathBuf>>,
|
||||
texts: Vec<PathBuf>,
|
||||
families: Option<Vec<String>>,
|
||||
elsewhere: Vec<String>,
|
||||
serve: Option<Serve>,
|
||||
}
|
||||
|
||||
/// Where [`Embed::serve`] copies the faces, and the URL prefix they are served under.
|
||||
struct Serve {
|
||||
dir: PathBuf,
|
||||
url_prefix: String,
|
||||
}
|
||||
|
||||
/// A selected face and the file the table embeds it from.
|
||||
struct Face {
|
||||
entry: ManifestEntry,
|
||||
path: PathBuf,
|
||||
index: u32,
|
||||
/// An interface face to cut to the catalogs' characters before it is embedded.
|
||||
cut: bool,
|
||||
/// Hex SHA-256 of the file: the manifest's, or the cut's.
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
impl Embed {
|
||||
/// Every face except the interface faces' sources (`role` `ui`), from `CRAFT_FONTS_DIR`,
|
||||
/// required when `CRAFT_FONTS_REQUIRED=1`. Narrow it with [`Embed::scripts`] and
|
||||
/// [`Embed::filter`]; a table of document fonts starts from [`Embed::documents`] instead.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Every face of these families, whole, and nothing else: an app's document fonts. A family
|
||||
/// is named as the manifest spells it (`"Noto Sans"`, `"Shippori Mincho"`) and contributes all
|
||||
/// its styles, interface faces included (`"BIZ UDPGothic"` brings its Regular as well).
|
||||
/// A name the manifest lacks is an [`Error::UnknownFamilies`], so the build fails with
|
||||
/// `CRAFT_FONTS_REQUIRED=1` and warns and embeds nothing otherwise. Narrow it further with
|
||||
/// [`Embed::scripts`] and [`Embed::filter`].
|
||||
pub fn documents(families: &[&str]) -> Self {
|
||||
Self { families: Some(families.iter().map(|f| (*f).to_owned()).collect()), ..Self::default() }
|
||||
}
|
||||
|
||||
/// Embed only faces with at least one of these script tags (see
|
||||
/// [`ManifestEntry::has_script`]). With `CRAFT_FONTS_REQUIRED=1`, each tag must match a face.
|
||||
pub fn scripts(mut self, tags: &[&str]) -> Self {
|
||||
self.scripts = Some(tags.iter().map(|t| (*t).to_owned()).collect());
|
||||
self
|
||||
}
|
||||
|
||||
/// Embed only faces for which `keep` returns true (applied after [`Embed::scripts`]).
|
||||
pub fn filter(mut self, keep: impl Fn(&ManifestEntry) -> bool + 'static) -> Self {
|
||||
self.filter = Some(Box::new(keep));
|
||||
self
|
||||
}
|
||||
|
||||
/// Embed faces for an app's interface: CJK faces only as the interface faces (`role` `ui` in
|
||||
/// the manifest), cut to the characters of the catalogs given to `Embed::subset_to`. Other
|
||||
/// CJK faces are dropped and the rest are kept. Without catalogs the interface faces are left
|
||||
/// out too, with a `cargo::warning`, so a full CJK face is never embedded for the interface.
|
||||
/// A manifest with no interface face is also a `cargo::warning` when that drops a CJK face.
|
||||
/// Combines with [`Embed::scripts`], [`Embed::filter`] and [`Embed::serve`]; with
|
||||
/// `CRAFT_FONTS_REQUIRED=1`, an interface face satisfies its script tags.
|
||||
pub fn for_ui(mut self) -> Self {
|
||||
self.ui = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Cut the interface faces to the characters these translation catalogs use, and embed the
|
||||
/// cut faces, written to `OUT_DIR`. Implies [`Embed::for_ui`]. An empty list counts as no
|
||||
/// catalogs: the interface faces are left out, with a `cargo::warning`.
|
||||
///
|
||||
/// The cut keeps the catalogs' characters from U+2E80 up (CJK and later blocks, without emoji,
|
||||
/// variation selectors and tags), plus CJK punctuation (U+3000–303F), kana (U+3040–30FF,
|
||||
/// U+31F0–31FF) and full-width forms (U+FF00–FFEF), with every layout feature. The same inputs
|
||||
/// give the same bytes. A face with CFF outlines is an [`Error::NotTrueType`].
|
||||
///
|
||||
/// The catalogs are also checked as by [`Embed::check_text`]. Relative paths are under the
|
||||
/// package directory; an unreadable catalog is a `cargo::warning`.
|
||||
#[cfg(feature = "subset")]
|
||||
pub fn subset_to<P: Into<PathBuf>>(mut self, catalogs: impl IntoIterator<Item = P>) -> Self {
|
||||
self.ui = true;
|
||||
self.catalogs.get_or_insert_default().extend(catalogs.into_iter().map(Into::into));
|
||||
self
|
||||
}
|
||||
|
||||
/// Check that the embedded faces cover these text files, such as translation catalogs: one
|
||||
/// `cargo::warning` per file that has characters from U+2E80 up (CJK and later blocks, without
|
||||
/// emoji and variation selectors) that no embedded face maps. Those characters fall back to
|
||||
/// system fonts at run time. Relative paths are under the package directory. The check runs
|
||||
/// only when a checkout was read.
|
||||
pub fn check_text<P: Into<PathBuf>>(mut self, paths: impl IntoIterator<Item = P>) -> Self {
|
||||
self.texts.extend(paths.into_iter().map(Into::into));
|
||||
self
|
||||
}
|
||||
|
||||
/// Families the app embeds in another table, such as its interface faces next to its document
|
||||
/// fonts: [`Embed::check_text`] and the catalog check count their characters as covered. Each
|
||||
/// is read from the checkout whole, so for a face cut elsewhere this is exact only for the
|
||||
/// catalogs it was cut to. A name the manifest lacks is a `cargo::warning`.
|
||||
pub fn embedded_elsewhere(mut self, families: &[&str]) -> Self {
|
||||
self.elsewhere.extend(families.iter().map(|f| (*f).to_owned()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Read this checkout instead of `CRAFT_FONTS_DIR`.
|
||||
pub fn dir(mut self, dir: impl Into<PathBuf>) -> Self {
|
||||
self.dir = Some(dir.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Override `CRAFT_FONTS_REQUIRED` (required only when it is exactly `1`).
|
||||
pub fn required(mut self, required: bool) -> Self {
|
||||
self.required = Some(required);
|
||||
self
|
||||
}
|
||||
|
||||
/// Copy the selected faces into `dir` instead of embedding them, for a web build that serves
|
||||
/// them beside its wasm, and write a table of [`ServedFace`](crate::ServedFace)s.
|
||||
///
|
||||
/// Each font file and its licence file keep their path under the checkout's `fonts/`
|
||||
/// directory (`noto-sans-arabic/NotoSansArabic.ttf`), and a face's `url` is `url_prefix`
|
||||
/// followed by that path, so `serve("web/fonts", "fonts/")` suits a page that serves
|
||||
/// `web/fonts` as `fonts/`. An interface face cut by `Embed::subset_to` is served from its cut,
|
||||
/// with the start of the cut's SHA-256 in the file name (`ui/NotoSansTCUI-Regular.<hash>.ttf`)
|
||||
/// and the cut's own integrity value. A relative `dir` is under the package directory, where
|
||||
/// Cargo runs build scripts. Files already there with the same bytes are left as they are;
|
||||
/// files of faces no longer selected, and earlier cuts, are not removed.
|
||||
pub fn serve(mut self, dir: impl Into<PathBuf>, url_prefix: &str) -> Self {
|
||||
self.serve = Some(Serve { dir: dir.into(), url_prefix: url_prefix.to_owned() });
|
||||
self
|
||||
}
|
||||
|
||||
/// Write the face table to `out_file` and print the `cargo::` lines; returns the faces
|
||||
/// embedded.
|
||||
///
|
||||
/// Without a checkout the table is empty. A checkout that can't be read is a
|
||||
/// `cargo::warning` and an empty table, or an [`Error`] when required. The table is written
|
||||
/// in every case, so on an error the build fails only on the message the caller prints.
|
||||
pub fn write(&self, out_file: impl AsRef<Path>) -> Result<Vec<ManifestEntry>, Error> {
|
||||
let env = Env { dir: std::env::var_os(DIR_VAR), required: std::env::var_os(REQUIRED_VAR), out_dir: std::env::var_os("OUT_DIR") };
|
||||
let mut cargo = Vec::new();
|
||||
let result = self.run(&env, out_file.as_ref(), &mut cargo);
|
||||
for line in cargo {
|
||||
println!("{line}");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn run(&self, env: &Env, out_file: &Path, cargo: &mut Vec<String>) -> Result<Vec<ManifestEntry>, Error> {
|
||||
cargo.push(format!("cargo::rerun-if-env-changed={DIR_VAR}"));
|
||||
cargo.push(format!("cargo::rerun-if-env-changed={REQUIRED_VAR}"));
|
||||
for text in self.checked_files() {
|
||||
cargo.push(format!("cargo::rerun-if-changed={}", text.display()));
|
||||
}
|
||||
let out = match (out_file.is_absolute(), &env.out_dir) {
|
||||
(true, _) => out_file.to_path_buf(),
|
||||
(false, Some(out_dir)) => Path::new(out_dir).join(out_file),
|
||||
(false, None) => return Err(Error::NoOutDir),
|
||||
};
|
||||
let required = self.required.unwrap_or_else(|| env.required.as_ref().is_some_and(|v| v == "1"));
|
||||
let dir = self.dir.clone().or_else(|| env.dir.clone().filter(|d| !d.is_empty()).map(PathBuf::from));
|
||||
|
||||
let selected = match &dir {
|
||||
None => Err(Error::NotSet),
|
||||
Some(dir) => self.select(dir, cargo).and_then(|faces| if required { self.check_required(dir, faces) } else { Ok(faces) }),
|
||||
};
|
||||
let mut selected = selected;
|
||||
if let Ok(faces) = &mut selected {
|
||||
// A face that can't be cut is a broken manifest line: an error even when not required.
|
||||
if let Err(e) = self.cut(faces, out.parent().unwrap_or(Path::new(""))) {
|
||||
write(&out, EMPTY_TABLE)?;
|
||||
return Err(e);
|
||||
}
|
||||
if let Some(dir) = &dir {
|
||||
self.check_coverage(faces, dir, cargo);
|
||||
}
|
||||
}
|
||||
let selected = match (selected, &self.serve, &dir) {
|
||||
(Ok(faces), Some(serve), Some(dir)) => match serve.copy(dir, &faces, cargo) {
|
||||
Ok(table) => Ok((table, faces)),
|
||||
Err(e) => {
|
||||
write(&out, EMPTY_TABLE)?;
|
||||
return Err(e);
|
||||
}
|
||||
},
|
||||
(selected, ..) => selected.and_then(|faces| Ok((table(&faces)?, faces))),
|
||||
};
|
||||
let (table, faces) = match selected {
|
||||
Ok((table, faces)) => (table, faces.into_iter().map(|face| face.entry).collect()),
|
||||
Err(e) if required => {
|
||||
write(&out, EMPTY_TABLE)?;
|
||||
return Err(e);
|
||||
}
|
||||
Err(Error::NotSet) => (EMPTY_TABLE.to_owned(), Vec::new()),
|
||||
Err(e) => {
|
||||
cargo.push(format!("cargo::warning=building without craft-fonts: {e}"));
|
||||
(EMPTY_TABLE.to_owned(), Vec::new())
|
||||
}
|
||||
};
|
||||
write(&out, &table)?;
|
||||
Ok(faces)
|
||||
}
|
||||
|
||||
/// The wanted manifest entries with their canonical file paths.
|
||||
fn select(&self, dir: &Path, cargo: &mut Vec<String>) -> Result<Vec<Face>, Error> {
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|source| Error::Read { path: manifest.clone(), source })?;
|
||||
// Cargo treats a missing watched file as changed, which would rerun the build every time.
|
||||
cargo.push(format!("cargo::rerun-if-changed={}", manifest.display()));
|
||||
let entries = parse_manifest(&text).map_err(|error| Error::Parse { manifest: manifest.clone(), error })?;
|
||||
if let Some(families) = &self.families {
|
||||
let unknown: Vec<String> = families.iter().filter(|f| !entries.iter().any(|e| &e.family == *f)).cloned().collect();
|
||||
if !unknown.is_empty() {
|
||||
let mut known: Vec<String> = Vec::new();
|
||||
for entry in &entries {
|
||||
if !known.contains(&entry.family) {
|
||||
known.push(entry.family.clone());
|
||||
}
|
||||
}
|
||||
return Err(Error::UnknownFamilies { manifest, families: unknown, known });
|
||||
}
|
||||
}
|
||||
let has_ui = entries.iter().any(ManifestEntry::is_ui);
|
||||
let no_catalogs = self.catalogs.as_ref().is_none_or(Vec::is_empty);
|
||||
let mut faces = Vec::new();
|
||||
let mut uncut = Vec::new();
|
||||
let mut dropped_cjk = false;
|
||||
for entry in entries {
|
||||
// Interface faces are the sources of the cuts: embedded whole only when named.
|
||||
let named = match &self.families {
|
||||
Some(families) => families.contains(&entry.family),
|
||||
None => self.ui || !entry.is_ui(),
|
||||
};
|
||||
if !named {
|
||||
continue;
|
||||
}
|
||||
let wanted = self.scripts.as_ref().is_none_or(|tags| tags.iter().any(|t| entry.has_script(t)));
|
||||
let ui_dropped = self.ui && entry.is_cjk() && !entry.is_ui();
|
||||
if !wanted || ui_dropped || self.filter.as_ref().is_some_and(|keep| !keep(&entry)) {
|
||||
dropped_cjk |= wanted && ui_dropped;
|
||||
continue;
|
||||
}
|
||||
let cut = self.ui && entry.is_ui();
|
||||
// An empty list would cut the faces to kana and punctuation, so it counts as none.
|
||||
if cut && no_catalogs {
|
||||
uncut.push(format!("{} {}", entry.family, entry.style));
|
||||
continue;
|
||||
}
|
||||
let file = dir.join(&entry.file);
|
||||
// `include_bytes!` resolves relative paths from the including source file.
|
||||
let path = file.canonicalize().map_err(|source| Error::Read { path: file, source })?;
|
||||
cargo.push(format!("cargo::rerun-if-changed={}", path.display()));
|
||||
let (index, sha256) = (entry.index, entry.sha256.clone());
|
||||
faces.push(Face { entry, path, index, cut, sha256 });
|
||||
}
|
||||
if !uncut.is_empty() {
|
||||
let why = if self.catalogs.is_some() { "Embed::subset_to was given no translation catalogs" } else { "no translation catalogs" };
|
||||
cargo.push(format!(
|
||||
"cargo::warning=craft-fonts: not embedding the interface faces {}: {why}; pass the catalogs to \
|
||||
Embed::subset_to (feature `subset`) to embed them cut to the catalogs' characters",
|
||||
uncut.join(", ")
|
||||
));
|
||||
}
|
||||
if dropped_cjk && !has_ui {
|
||||
cargo.push(format!(
|
||||
"cargo::warning=craft-fonts: not embedding any CJK face for the interface: {} has no interface face \
|
||||
(role `ui`); use a craft-fonts checkout that has them",
|
||||
manifest.display()
|
||||
));
|
||||
}
|
||||
Ok(faces)
|
||||
}
|
||||
|
||||
/// Replace each interface face with its cut, written to `out_dir`.
|
||||
#[cfg(feature = "subset")]
|
||||
fn cut(&self, faces: &mut [Face], out_dir: &Path) -> Result<(), Error> {
|
||||
let Some(catalogs) = &self.catalogs else { return Ok(()) };
|
||||
// `select` leaves the faces to cut out when there are no catalogs.
|
||||
if !faces.iter().any(|face| face.cut) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut chars = BTreeSet::new();
|
||||
// An unreadable catalog adds nothing here; `check_coverage` reports it.
|
||||
for text in catalogs.iter().filter_map(|path| std::fs::read_to_string(path).ok()) {
|
||||
chars.extend(text.chars().filter(|&c| needs_glyph(c)));
|
||||
}
|
||||
for (start, end) in UI_RANGES {
|
||||
chars.extend(start..=end);
|
||||
}
|
||||
let dir = out_dir.join("craft-fonts-ui");
|
||||
std::fs::create_dir_all(&dir).map_err(|source| Error::Write { path: dir.clone(), source })?;
|
||||
for face in faces.iter_mut().filter(|face| face.cut) {
|
||||
let source = std::fs::read(&face.path).map_err(|source| Error::Read { path: face.path.clone(), source })?;
|
||||
let bytes = crate::subset::subset(&source, face.index, &chars).map_err(|failure| match failure {
|
||||
crate::subset::Failure::NotTrueType => Error::NotTrueType { path: face.path.clone() },
|
||||
crate::subset::Failure::Other(reason) => Error::Subset { path: face.path.clone(), reason },
|
||||
})?;
|
||||
let name: String =
|
||||
format!("{}-{}", face.entry.family, face.entry.style).chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect();
|
||||
let out = dir.join(format!("{name}.ttf"));
|
||||
// Rewriting identical bytes would make Cargo rebuild the crate that embeds them.
|
||||
if std::fs::read(&out).ok().as_deref() != Some(&bytes[..]) {
|
||||
std::fs::write(&out, &bytes).map_err(|source| Error::Write { path: out.clone(), source })?;
|
||||
}
|
||||
face.path = out;
|
||||
face.index = 0;
|
||||
face.sha256 = crate::sha256::hex(&bytes);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Without the subsetter there are no catalogs, so [`Embed::select`] left out every face to cut.
|
||||
#[cfg(not(feature = "subset"))]
|
||||
fn cut(&self, _faces: &mut [Face], _out_dir: &Path) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The catalogs, then the other [`Embed::check_text`] files.
|
||||
fn checked_files(&self) -> Vec<&PathBuf> {
|
||||
let mut files: Vec<&PathBuf> = self.catalogs.iter().flatten().collect();
|
||||
for text in &self.texts {
|
||||
if !files.contains(&text) {
|
||||
files.push(text);
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
/// The manifest entries of the [`Embed::embedded_elsewhere`] families, with a
|
||||
/// `cargo::warning` for a family the manifest lacks.
|
||||
fn elsewhere(&self, dir: &Path, cargo: &mut Vec<String>) -> Vec<ManifestEntry> {
|
||||
if self.elsewhere.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
// `select` read and parsed this manifest already.
|
||||
let entries = std::fs::read_to_string(&manifest).ok().and_then(|text| parse_manifest(&text).ok()).unwrap_or_default();
|
||||
for family in self.elsewhere.iter().filter(|f| !entries.iter().any(|e| &e.family == *f)) {
|
||||
cargo.push(format!("cargo::warning=craft-fonts: embedded_elsewhere names {family}, which {} lacks", manifest.display()));
|
||||
}
|
||||
entries.into_iter().filter(|e| self.elsewhere.contains(&e.family)).collect()
|
||||
}
|
||||
|
||||
/// Warn about each catalog's and [`Embed::check_text`] file's characters that neither an
|
||||
/// embedded face nor an [`Embed::embedded_elsewhere`] face covers.
|
||||
fn check_coverage(&self, faces: &[Face], dir: &Path, cargo: &mut Vec<String>) {
|
||||
let files = self.checked_files();
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let elsewhere = self.elsewhere(dir, cargo);
|
||||
let fonts: Vec<(&ManifestEntry, u32, Option<Vec<u8>>)> = faces
|
||||
.iter()
|
||||
.map(|face| (&face.entry, face.index, std::fs::read(&face.path).ok()))
|
||||
.chain(elsewhere.iter().map(|entry| (entry, entry.index, std::fs::read(dir.join(&entry.file)).ok())))
|
||||
.collect();
|
||||
let mut cmaps = Vec::new();
|
||||
for (entry, index, bytes) in &fonts {
|
||||
match bytes.as_deref().and_then(|bytes| Cmap::read(bytes, *index)) {
|
||||
Some(cmap) => cmaps.push(cmap),
|
||||
None => cargo
|
||||
.push(format!("cargo::warning=craft-fonts: cannot read the character map of {} {} ({})", entry.family, entry.style, entry.file)),
|
||||
}
|
||||
}
|
||||
for path in files {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
cargo.push(format!("cargo::warning=craft-fonts: cannot check {}: {e}", path.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut seen = HashSet::new();
|
||||
let missing: Vec<char> = text.chars().filter(|&c| needs_glyph(c) && seen.insert(c) && !cmaps.iter().any(|cmap| cmap.covers(c))).collect();
|
||||
if missing.is_empty() {
|
||||
continue;
|
||||
}
|
||||
const SHOWN: usize = 8;
|
||||
let shown: String = missing.iter().take(SHOWN).collect();
|
||||
let more = if missing.len() > SHOWN { " …" } else { "" };
|
||||
cargo.push(format!(
|
||||
"cargo::warning=craft-fonts: {} has {} character{} no embedded face covers ({shown}{more}); system fonts will draw them.",
|
||||
path.display(),
|
||||
missing.len(),
|
||||
if missing.len() == 1 { "" } else { "s" },
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn check_required(&self, dir: &Path, faces: Vec<Face>) -> Result<Vec<Face>, Error> {
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
if faces.is_empty() {
|
||||
return Err(Error::NoFaces { manifest });
|
||||
}
|
||||
let missing: Vec<String> = self
|
||||
.scripts
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|tag| !faces.iter().any(|face| face.entry.scripts.iter().any(|s| script_matches(s, tag))))
|
||||
.cloned()
|
||||
.collect();
|
||||
if missing.is_empty() { Ok(faces) } else { Err(Error::MissingScripts { manifest, scripts: missing }) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the interface faces need `c`: CJK and later blocks, except emoji and other pictographs
|
||||
/// (the emoji faces draw them), variation selectors, tags and a byte order mark.
|
||||
fn needs_glyph(c: char) -> bool {
|
||||
c >= '\u{2E80}' && !matches!(c, '\u{FE00}'..='\u{FE0F}' | '\u{FEFF}' | '\u{1F000}'..='\u{1FFFF}' | '\u{E0000}'..)
|
||||
}
|
||||
|
||||
/// Kept in every cut, whatever the catalogs use: CJK punctuation, kana and full-width forms.
|
||||
#[cfg(feature = "subset")]
|
||||
const UI_RANGES: [(char, char); 4] = [('\u{3000}', '\u{303F}'), ('\u{3040}', '\u{30FF}'), ('\u{31F0}', '\u{31FF}'), ('\u{FF00}', '\u{FFEF}')];
|
||||
|
||||
/// The environment a build script sees, separated out for tests.
|
||||
pub(crate) struct Env {
|
||||
pub dir: Option<OsString>,
|
||||
pub required: Option<OsString>,
|
||||
pub out_dir: Option<OsString>,
|
||||
}
|
||||
|
||||
/// The generated source; `include_bytes!` takes a string, so a face under a path that isn't UTF-8
|
||||
/// is an error rather than a lossy path the consumer's build can't find.
|
||||
fn table(faces: &[Face]) -> Result<String, Error> {
|
||||
let mut src = String::from("// Generated by craft-fonts-build. Do not edit.\n&[\n");
|
||||
for Face { entry, path, index, .. } in faces {
|
||||
let file = path.to_str().ok_or_else(|| Error::NonUtf8Path { path: path.clone() })?;
|
||||
let scripts: Vec<String> = entry.scripts.iter().map(|s| format!("{s:?}")).collect();
|
||||
let _ = writeln!(
|
||||
src,
|
||||
" EmbeddedFace {{ family: {:?}, style: {:?}, scripts: &[{}], index: {}, bytes: include_bytes!({:?}) }},",
|
||||
entry.family,
|
||||
entry.style,
|
||||
scripts.join(", "),
|
||||
index,
|
||||
file,
|
||||
);
|
||||
}
|
||||
src.push_str("]\n");
|
||||
Ok(src)
|
||||
}
|
||||
|
||||
impl Serve {
|
||||
/// Copy the faces' files and licences into the served directory; returns the table.
|
||||
fn copy(&self, checkout: &Path, faces: &[Face], cargo: &mut Vec<String>) -> Result<String, Error> {
|
||||
let mut copied: Vec<String> = Vec::new();
|
||||
let mut src = String::from("// Generated by craft-fonts-build. Do not edit.\n&[\n");
|
||||
for face in faces {
|
||||
let (entry, path) = (&face.entry, &face.path);
|
||||
let served = served_file(face);
|
||||
let font = std::fs::read(path).map_err(|source| Error::Read { path: path.clone(), source })?;
|
||||
let size = font.len();
|
||||
if !copied.contains(&served) {
|
||||
copied.push(served.clone());
|
||||
copy_file(path, &self.dir.join(&served), cargo)?;
|
||||
let licence = checkout.join(&entry.licence_file);
|
||||
if entry.licence_file.is_empty() || !licence.is_file() {
|
||||
cargo.push(format!("cargo::warning=craft-fonts: no licence file beside {}", entry.file));
|
||||
} else {
|
||||
copy_file(&licence, &self.dir.join(served_path(&entry.licence_file)), cargo)?;
|
||||
}
|
||||
}
|
||||
let coverage = crate::coverage::of_face(&font, face.index).unwrap_or_else(|| {
|
||||
cargo.push(format!("cargo::warning=craft-fonts: no character map in {}; the face is never fetched for a character", entry.file));
|
||||
Vec::new()
|
||||
});
|
||||
let scripts: Vec<String> = entry.scripts.iter().map(|s| format!("{s:?}")).collect();
|
||||
let _ = writeln!(
|
||||
src,
|
||||
" ServedFace {{ family: {:?}, style: {:?}, scripts: &[{}], index: {}, url: {:?}, size: {size}, integrity: {:?}, coverage: {} }},",
|
||||
entry.family,
|
||||
entry.style,
|
||||
scripts.join(", "),
|
||||
face.index,
|
||||
format!("{}{served}", self.url_prefix),
|
||||
integrity(&face.sha256),
|
||||
byte_string(&coverage),
|
||||
);
|
||||
}
|
||||
src.push_str("]\n");
|
||||
Ok(src)
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte string literal, printable ASCII as itself.
|
||||
fn byte_string(bytes: &[u8]) -> String {
|
||||
let mut out = String::from("b\"");
|
||||
for &b in bytes {
|
||||
match b {
|
||||
b'"' | b'\\' => {
|
||||
out.push('\\');
|
||||
out.push(char::from(b));
|
||||
}
|
||||
0x20..=0x7E => out.push(char::from(b)),
|
||||
_ => {
|
||||
let _ = write!(out, "\\x{b:02x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a face's file is served, under the served directory: its checkout path under `fonts/`.
|
||||
/// A cut adds the start of its SHA-256 before the extension (`ui/A.1234abcd5678ef90.ttf`), so a
|
||||
/// cut from changed catalogs gets a new URL instead of meeting a cached copy that fails its
|
||||
/// integrity check.
|
||||
fn served_file(face: &Face) -> String {
|
||||
let file = served_path(&face.entry.file);
|
||||
if !face.cut {
|
||||
return file.to_owned();
|
||||
}
|
||||
let hash = face.sha256.get(..16).unwrap_or(&face.sha256);
|
||||
match file.rsplit_once('.') {
|
||||
Some((stem, extension)) if !extension.contains('/') => format!("{stem}.{hash}.{extension}"),
|
||||
_ => format!("{file}.{hash}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A checkout path relative to the served directory: `fonts/a/A.ttf` → `a/A.ttf`.
|
||||
fn served_path(file: &str) -> &str {
|
||||
file.strip_prefix("fonts/").unwrap_or(file)
|
||||
}
|
||||
|
||||
/// Copy `from` unless `to` already holds the same bytes. The copy keeps `from`'s modification time
|
||||
/// and is watched, so the build copies it again once it is deleted, without running every build.
|
||||
fn copy_file(from: &Path, to: &Path, cargo: &mut Vec<String>) -> Result<(), Error> {
|
||||
cargo.push(format!("cargo::rerun-if-changed={}", to.display()));
|
||||
let write_error = |source| Error::Write { path: to.to_path_buf(), source };
|
||||
let read_error = |source| Error::Read { path: from.to_path_buf(), source };
|
||||
let bytes = std::fs::read(from).map_err(read_error)?;
|
||||
if std::fs::read(to).is_ok_and(|old| old == bytes) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = to.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(write_error)?;
|
||||
}
|
||||
std::fs::write(to, &bytes).map_err(write_error)?;
|
||||
// Cargo reruns a build script whose watched files are newer than its last run.
|
||||
let modified = std::fs::metadata(from).and_then(|m| m.modified()).map_err(read_error)?;
|
||||
std::fs::File::options().write(true).open(to).and_then(|f| f.set_modified(modified)).map_err(write_error)
|
||||
}
|
||||
|
||||
/// The Subresource Integrity value (`sha256-<base64>`) of a hex SHA-256, or `""` for a value that
|
||||
/// isn't one.
|
||||
pub(crate) fn integrity(sha256: &str) -> String {
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let digest: Option<Vec<u8>> =
|
||||
(sha256.len() == 64).then(|| (0..32).map(|i| u8::from_str_radix(sha256.get(2 * i..2 * i + 2)?, 16).ok()).collect()).flatten();
|
||||
let Some(digest) = digest else { return String::new() };
|
||||
let mut out = String::from("sha256-");
|
||||
for chunk in digest.chunks(3) {
|
||||
let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | (u32::from(*b) << (16 - 8 * i)));
|
||||
for i in 0..4 {
|
||||
let c = if i <= chunk.len() { ALPHABET[((n >> (18 - 6 * i)) & 63) as usize] } else { b'=' };
|
||||
out.push(char::from(c));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn write(path: &Path, contents: &str) -> Result<(), Error> {
|
||||
std::fs::write(path, contents).map_err(|source| Error::Write { path: path.to_path_buf(), source })
|
||||
}
|
||||
|
||||
/// Why the faces could not be embedded. Print it as `cargo::error={error}`.
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
/// `CRAFT_FONTS_REQUIRED=1` but `CRAFT_FONTS_DIR` is unset or empty.
|
||||
NotSet,
|
||||
/// The manifest or a font file it lists can't be read.
|
||||
Read { path: PathBuf, source: io::Error },
|
||||
/// The manifest has a malformed line.
|
||||
Parse { manifest: PathBuf, error: ParseError },
|
||||
/// Required, but no face in the manifest was selected.
|
||||
NoFaces { manifest: PathBuf },
|
||||
/// Required, but no face in the manifest has these requested script tags.
|
||||
MissingScripts { manifest: PathBuf, scripts: Vec<String> },
|
||||
/// The generated table can't be written.
|
||||
Write { path: PathBuf, source: io::Error },
|
||||
/// `out_file` is relative and `OUT_DIR` is unset (not running as a build script).
|
||||
NoOutDir,
|
||||
/// An interface face has CFF outlines, which can't be cut correctly; it must be TrueType.
|
||||
NotTrueType { path: PathBuf },
|
||||
/// An interface face can't be cut.
|
||||
Subset { path: PathBuf, reason: String },
|
||||
/// [`Embed::documents`] names families the manifest lacks; `known` lists the manifest's.
|
||||
UnknownFamilies { manifest: PathBuf, families: Vec<String>, known: Vec<String> },
|
||||
/// A face to embed has a path that isn't UTF-8, which `include_bytes!` can't name.
|
||||
NonUtf8Path { path: PathBuf },
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::NotSet => {
|
||||
write!(f, "{REQUIRED_VAR}=1 but {DIR_VAR} is not: set {DIR_VAR} to the absolute path of a storytold/craft-fonts checkout")
|
||||
}
|
||||
Self::Read { path, source } => {
|
||||
write!(f, "cannot read {} ({DIR_VAR} must be the absolute path of a storytold/craft-fonts checkout): {source}", path.display())
|
||||
}
|
||||
Self::Parse { manifest, error } => write!(f, "{}: {error}", manifest.display()),
|
||||
Self::NoFaces { manifest } => write!(f, "{REQUIRED_VAR}=1 but no face in {} was selected", manifest.display()),
|
||||
Self::MissingScripts { manifest, scripts } => {
|
||||
write!(f, "{REQUIRED_VAR}=1 but {} has no face for {}", manifest.display(), scripts.join(", "))
|
||||
}
|
||||
Self::Write { path, source } => write!(f, "cannot write {}: {source}", path.display()),
|
||||
Self::NoOutDir => write!(f, "OUT_DIR is not set: call craft-fonts-build from a build script, or pass an absolute out_file"),
|
||||
Self::NotTrueType { path } => write!(
|
||||
f,
|
||||
"{} has CFF outlines, which craft-fonts-build can't subset correctly: an interface face (role ui) must be a TrueType (glyf) font",
|
||||
path.display()
|
||||
),
|
||||
Self::Subset { path, reason } => write!(f, "cannot subset {}: {reason}", path.display()),
|
||||
Self::UnknownFamilies { manifest, families, known } => write!(
|
||||
f,
|
||||
"{} has no family {}; Embed::documents names families as the manifest spells them: {}",
|
||||
manifest.display(),
|
||||
families.join(", "),
|
||||
known.join(", ")
|
||||
),
|
||||
Self::NonUtf8Path { path } => write!(f, "cannot embed {}: include_bytes! needs a UTF-8 path", path.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Read { source, .. } | Self::Write { source, .. } => Some(source),
|
||||
Self::Parse { error, .. } => Some(error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
//! Embed the optional [craft-fonts](https://github.com/storytold/craft-fonts) faces from an app's
|
||||
//! `build.rs`.
|
||||
//!
|
||||
//! Apps never depend on craft-fonts in `Cargo.toml` (Cargo would fetch the font repository for
|
||||
//! every build). The build reads a local checkout named by `CRAFT_FONTS_DIR` instead:
|
||||
//!
|
||||
//! - **Unset or empty:** the table is empty and the app uses its own and system fonts.
|
||||
//! - **Set:** every selected face in `fonts/manifest.txt` is embedded with `include_bytes!`.
|
||||
//! - **Set, but not readable as a checkout:** a `cargo::warning` and an empty table.
|
||||
//! - **`CRAFT_FONTS_REQUIRED=1`** (release builds; any other value is ignored): a missing or
|
||||
//! unreadable checkout, no selected face, or a requested script without a face is an [`Error`].
|
||||
//!
|
||||
//! Add this crate both as a build-dependency (for [`Embed`]) and as a dependency (for
|
||||
//! [`EmbeddedFace`]). The build-dependency enables the `subset` feature for `Embed::subset_to`,
|
||||
//! which cuts the CJK interface faces to the translation catalogs' characters; the dependency
|
||||
//! leaves it off, so the subsetter is not part of the app. In `build.rs`'s `main`:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! println!("cargo::rerun-if-changed=build.rs");
|
||||
//! let wasm = craft_fonts_build::target_is_wasm();
|
||||
//! let catalogs: Vec<std::path::PathBuf> = /* the app's translation catalogs */;
|
||||
//! let embedded = craft_fonts_build::Embed::new()
|
||||
//! // CJK faces only as the interface faces, cut to the characters the catalogs use.
|
||||
//! .subset_to(catalogs)
|
||||
//! // Web builds have a size budget: embed only the Japanese interface face there.
|
||||
//! .filter(move |face| !wasm || face.has_script("Jpan"))
|
||||
//! .write("craft_fonts.rs");
|
||||
//! if let Err(e) = embedded {
|
||||
//! println!("cargo::error={e}");
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! and in the crate:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use craft_fonts_build::EmbeddedFace;
|
||||
//!
|
||||
//! pub static CRAFT_FONTS: &[EmbeddedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
||||
//! ```
|
||||
//!
|
||||
//! The generated file is a slice expression of `EmbeddedFace { .. }` literals, so any struct with
|
||||
//! the same fields and that name in scope works too.
|
||||
//!
|
||||
//! An app's document fonts go in a table of their own that names its families,
|
||||
//! `Embed::documents(&["Noto Sans", "Shippori Mincho"])`: nothing is embedded for documents
|
||||
//! unless the app names it.
|
||||
//!
|
||||
//! Web builds can serve the faces beside the wasm instead: [`Embed::serve`] copies the selected
|
||||
//! files into a directory and writes a table of [`ServedFace`]s, which carry a URL in place of the
|
||||
//! bytes, for the app to fetch the faces it needs at run time.
|
||||
|
||||
mod cmap;
|
||||
pub mod coverage;
|
||||
mod embed;
|
||||
mod manifest;
|
||||
#[cfg(feature = "subset")]
|
||||
mod sha256;
|
||||
#[cfg(feature = "subset")]
|
||||
mod subset;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use embed::{Embed, Error, target_is_wasm};
|
||||
pub use manifest::{CJK_SCRIPTS, ManifestEntry, ParseError, UI_ROLE, parse_manifest};
|
||||
|
||||
/// A craft-fonts face embedded by [`Embed`]: one line of `fonts/manifest.txt`.
|
||||
///
|
||||
/// [`Embed::serve`] writes [`ServedFace`]s instead.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct EmbeddedFace {
|
||||
/// Family name, e.g. `"Noto Sans Arabic"`.
|
||||
pub family: &'static str,
|
||||
/// Style, e.g. `"Regular"` or `"Bold"`.
|
||||
pub style: &'static str,
|
||||
/// Script tags: ISO 15924 codes such as `Arab`, `Jpan`, `Hans`, `Hant`, optionally with a
|
||||
/// region subtag (`Hant-HK` for Hong Kong forms, which have no ISO 15924 code).
|
||||
pub scripts: &'static [&'static str],
|
||||
/// Face index in a font collection (`.ttc`); `0` for a single font.
|
||||
pub index: u32,
|
||||
pub bytes: &'static [u8],
|
||||
}
|
||||
|
||||
impl EmbeddedFace {
|
||||
/// `"<family> <style>"`, e.g. `"Noto Sans Arabic Regular"`.
|
||||
pub fn name(&self) -> String {
|
||||
format!("{} {}", self.family, self.style)
|
||||
}
|
||||
|
||||
/// Whether any script tag matches `tag`: `Hant` matches `Hant` and `Hant-HK`.
|
||||
pub fn has_script(&self, tag: &str) -> bool {
|
||||
self.scripts.iter().any(|s| manifest::script_matches(s, tag))
|
||||
}
|
||||
|
||||
/// Whether the face has a CJK script tag ([`CJK_SCRIPTS`]).
|
||||
pub fn is_cjk(&self) -> bool {
|
||||
CJK_SCRIPTS.iter().any(|tag| self.has_script(tag))
|
||||
}
|
||||
|
||||
/// CSS/OpenType weight implied by the style name (`Bold` → 700); 400 when it names none.
|
||||
pub fn weight(&self) -> u16 {
|
||||
let style = self.style.to_ascii_lowercase().replace([' ', '-'], "");
|
||||
const WEIGHTS: [(&str, u16); 14] = [
|
||||
("extralight", 200),
|
||||
("ultralight", 200),
|
||||
("semibold", 600),
|
||||
("demibold", 600),
|
||||
("extrabold", 800),
|
||||
("ultrabold", 800),
|
||||
("thin", 100),
|
||||
("light", 300),
|
||||
("medium", 500),
|
||||
("bold", 700),
|
||||
("black", 900),
|
||||
("heavy", 900),
|
||||
("regular", 400),
|
||||
("book", 400),
|
||||
];
|
||||
WEIGHTS.iter().find(|(name, _)| style.contains(name)).map_or(400, |(_, weight)| *weight)
|
||||
}
|
||||
}
|
||||
|
||||
/// A craft-fonts face that [`Embed::serve`] copied beside the app, to fetch at run time: one line
|
||||
/// of `fonts/manifest.txt`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ServedFace {
|
||||
/// Family name, e.g. `"Noto Sans Arabic"`.
|
||||
pub family: &'static str,
|
||||
/// Style, e.g. `"Regular"` or `"Bold"`.
|
||||
pub style: &'static str,
|
||||
/// Script tags, as in [`EmbeddedFace::scripts`].
|
||||
pub scripts: &'static [&'static str],
|
||||
/// Face index in a font collection (`.ttc`); `0` for a single font. Faces of one collection
|
||||
/// share a URL.
|
||||
pub index: u32,
|
||||
/// Where the file is served, relative to the page unless the prefix given to
|
||||
/// [`Embed::serve`] makes it absolute.
|
||||
pub url: &'static str,
|
||||
/// File size in bytes.
|
||||
pub size: u64,
|
||||
/// Subresource Integrity value (`sha256-…`) from the manifest's hash; empty when the manifest
|
||||
/// has none.
|
||||
pub integrity: &'static str,
|
||||
/// The characters the face maps, read from its `cmap` (see [`coverage`]).
|
||||
pub coverage: &'static [u8],
|
||||
}
|
||||
|
||||
impl ServedFace {
|
||||
/// `"<family> <style>"`, e.g. `"Noto Sans Arabic Regular"`.
|
||||
pub fn name(&self) -> String {
|
||||
format!("{} {}", self.family, self.style)
|
||||
}
|
||||
|
||||
/// Whether any script tag matches `tag`: `Hant` matches `Hant` and `Hant-HK`.
|
||||
pub fn has_script(&self, tag: &str) -> bool {
|
||||
self.scripts.iter().any(|s| manifest::script_matches(s, tag))
|
||||
}
|
||||
|
||||
/// Whether the face has a CJK script tag ([`CJK_SCRIPTS`]).
|
||||
pub fn is_cjk(&self) -> bool {
|
||||
CJK_SCRIPTS.iter().any(|tag| self.has_script(tag))
|
||||
}
|
||||
|
||||
/// Whether the face maps `c`.
|
||||
pub fn covers(&self, c: char) -> bool {
|
||||
coverage::contains(self.coverage, c)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for EmbeddedFace {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("EmbeddedFace")
|
||||
.field("family", &self.family)
|
||||
.field("style", &self.style)
|
||||
.field("scripts", &self.scripts)
|
||||
.field("index", &self.index)
|
||||
.field("bytes", &format_args!("[{} bytes]", self.bytes.len()))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! craft-fonts' `fonts/manifest.txt`: one font face per line, eight or nine fields separated by
|
||||
//! `" | "`:
|
||||
//!
|
||||
//! ```text
|
||||
//! family | style | file | scripts | licence | licence file | sha256 | source [| role]
|
||||
//! ```
|
||||
//!
|
||||
//! Blank lines and lines starting with `#` are skipped. `file` is relative to the checkout and may
|
||||
//! end in `#<index>` to pick one face of a font collection (`.ttc`). The optional `role` is `ui` for
|
||||
//! an interface face: a full Regular TrueType face that an app's build cuts to the characters of its
|
||||
//! translation catalogs. Fields after the ninth are ignored, so a manifest that gains a field still
|
||||
//! embeds.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// One face listed in the manifest.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ManifestEntry {
|
||||
/// Family name, as the font's `name` table spells it (e.g. `Noto Sans Arabic`).
|
||||
pub family: String,
|
||||
/// Style within the family (e.g. `Regular`, `Bold`).
|
||||
pub style: String,
|
||||
/// Font file, relative to the checkout, without the `#<index>` suffix.
|
||||
pub file: String,
|
||||
/// Face index in a font collection; `0` when the file field has no `#<index>`.
|
||||
pub index: u32,
|
||||
/// Script tags: ISO 15924 codes (`Jpan`, `Arab`), optionally with a region (`Hant-HK`).
|
||||
pub scripts: Vec<String>,
|
||||
/// SPDX licence identifier.
|
||||
pub licence: String,
|
||||
/// Licence text, relative to the checkout.
|
||||
pub licence_file: String,
|
||||
/// Hex SHA-256 of the font file.
|
||||
pub sha256: String,
|
||||
/// Where the file came from.
|
||||
pub source: String,
|
||||
/// The optional ninth field: `Some("ui")` for an interface face, `None` when the line has no
|
||||
/// role.
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
impl ManifestEntry {
|
||||
/// Whether any of the face's script tags matches `tag`. `Hant` matches `Hant` and `Hant-HK`;
|
||||
/// `Hant-HK` matches only `Hant-HK`.
|
||||
pub fn has_script(&self, tag: &str) -> bool {
|
||||
self.scripts.iter().any(|s| script_matches(s, tag))
|
||||
}
|
||||
|
||||
/// Whether the face is an interface face (`role` is `ui`).
|
||||
pub fn is_ui(&self) -> bool {
|
||||
self.role.as_deref() == Some(UI_ROLE)
|
||||
}
|
||||
|
||||
/// Whether the face has a CJK script tag ([`CJK_SCRIPTS`]).
|
||||
pub fn is_cjk(&self) -> bool {
|
||||
CJK_SCRIPTS.iter().any(|tag| self.has_script(tag))
|
||||
}
|
||||
}
|
||||
|
||||
/// The `role` of an interface face.
|
||||
pub const UI_ROLE: &str = "ui";
|
||||
|
||||
/// Script tags of the CJK faces, which craft-fonts also ships as interface faces. `Hant` covers
|
||||
/// `Hant-HK`.
|
||||
pub const CJK_SCRIPTS: &[&str] = &["Hans", "Hant", "Jpan", "Kore", "Hani"];
|
||||
|
||||
/// `face_tag` is `wanted` or `wanted` plus a `-` subtag.
|
||||
pub(crate) fn script_matches(face_tag: &str, wanted: &str) -> bool {
|
||||
face_tag.strip_prefix(wanted).is_some_and(|rest| rest.is_empty() || rest.starts_with('-'))
|
||||
}
|
||||
|
||||
/// A manifest line that could not be read.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ParseError {
|
||||
/// 1-based line number.
|
||||
pub line: usize,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "manifest line {}: {}", self.line, self.reason)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ParseError {}
|
||||
|
||||
/// Parse a whole manifest.
|
||||
pub fn parse_manifest(text: &str) -> Result<Vec<ManifestEntry>, ParseError> {
|
||||
let mut entries = Vec::new();
|
||||
for (i, raw) in text.lines().enumerate() {
|
||||
let line = i + 1;
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let fields: Vec<&str> = trimmed.split(" | ").map(str::trim).collect();
|
||||
let [family, style, file, scripts, licence, licence_file, sha256, source, rest @ ..] = fields.as_slice() else {
|
||||
return Err(ParseError { line, reason: format!("expected 8 fields separated by \" | \", found {}", fields.len()) });
|
||||
};
|
||||
for (name, value) in [("family", family), ("style", style), ("file", file), ("scripts", scripts)] {
|
||||
if value.is_empty() {
|
||||
return Err(ParseError { line, reason: format!("the {name} field is empty") });
|
||||
}
|
||||
}
|
||||
let (file, index) = match file.rsplit_once('#') {
|
||||
Some((path, index)) => {
|
||||
let index = index.parse().map_err(|_| ParseError { line, reason: format!("bad face index in {file:?}") })?;
|
||||
(path, index)
|
||||
}
|
||||
None => (*file, 0),
|
||||
};
|
||||
entries.push(ManifestEntry {
|
||||
family: (*family).to_owned(),
|
||||
style: (*style).to_owned(),
|
||||
file: file.to_owned(),
|
||||
index,
|
||||
scripts: scripts.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect(),
|
||||
licence: (*licence).to_owned(),
|
||||
licence_file: (*licence_file).to_owned(),
|
||||
sha256: (*sha256).to_owned(),
|
||||
source: (*source).to_owned(),
|
||||
role: rest.first().filter(|role| !role.is_empty()).map(|role| (*role).to_owned()),
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! SHA-256 (FIPS 180-4) of a cut face, for its served file name and Subresource Integrity value.
|
||||
|
||||
const K: [u32; 64] = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
||||
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||||
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
];
|
||||
|
||||
/// The lowercase hex SHA-256 of `data`, as craft-fonts' manifest writes it.
|
||||
pub(crate) fn hex(data: &[u8]) -> String {
|
||||
let mut h: [u32; 8] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
|
||||
let mut message = data.to_vec();
|
||||
message.push(0x80);
|
||||
while message.len() % 64 != 56 {
|
||||
message.push(0);
|
||||
}
|
||||
message.extend_from_slice(&(u64::try_from(data.len()).unwrap_or(u64::MAX).wrapping_mul(8)).to_be_bytes());
|
||||
for block in message.as_chunks::<64>().0 {
|
||||
let mut w = [0u32; 64];
|
||||
for (i, word) in block.as_chunks::<4>().0.iter().enumerate() {
|
||||
w[i] = u32::from_be_bytes(*word);
|
||||
}
|
||||
for i in 16..64 {
|
||||
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
|
||||
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
|
||||
w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1);
|
||||
}
|
||||
let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h;
|
||||
for (k, w) in K.iter().zip(w) {
|
||||
let t1 = hh
|
||||
.wrapping_add(e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25))
|
||||
.wrapping_add((e & f) ^ (!e & g))
|
||||
.wrapping_add(*k)
|
||||
.wrapping_add(w);
|
||||
let t2 = (a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22)).wrapping_add((a & b) ^ (a & c) ^ (b & c));
|
||||
(hh, g, f, e, d, c, b, a) = (g, f, e, d.wrapping_add(t1), c, b, a, t1.wrapping_add(t2));
|
||||
}
|
||||
for (h, v) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) {
|
||||
*h = h.wrapping_add(v);
|
||||
}
|
||||
}
|
||||
h.iter().map(|v| format!("{v:08x}")).collect()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Cutting an interface face down to the characters the translation catalogs use, with skera.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use skera::{Plan, SubsetFlags, subset_font};
|
||||
use skrifa::raw::collections::IntSet;
|
||||
use skrifa::raw::types::NameId;
|
||||
use skrifa::{FontRef, GlyphId, Tag};
|
||||
|
||||
/// Why a face could not be cut.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum Failure {
|
||||
/// The face has CFF outlines: skera's CFF output draws the wrong glyphs.
|
||||
NotTrueType,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Face `index` of `font` with the glyphs of those `chars` it maps and the glyphs its layout tables
|
||||
/// reach from them (vertical forms, ligatures). Every layout feature and script is kept; the name
|
||||
/// table keeps IDs 0–6 in US English, as fonttools' subsetter does. The same input gives the same
|
||||
/// bytes.
|
||||
pub(crate) fn subset(font: &[u8], index: u32, chars: &BTreeSet<char>) -> Result<Vec<u8>, Failure> {
|
||||
let font = FontRef::from_index(font, index).map_err(|e| Failure::Other(e.to_string()))?;
|
||||
if font.table_data(Tag::new(b"glyf")).is_none() {
|
||||
return Err(Failure::NotTrueType);
|
||||
}
|
||||
let mut unicodes = IntSet::<u32>::empty();
|
||||
unicodes.extend(chars.iter().map(|&c| u32::from(c)));
|
||||
let mut name_ids = IntSet::<NameId>::empty();
|
||||
name_ids.insert_range(NameId::new(0)..=NameId::new(6));
|
||||
let mut languages = IntSet::<u16>::empty();
|
||||
languages.insert(0x0409);
|
||||
let plan = Plan::new(
|
||||
&IntSet::<GlyphId>::empty(),
|
||||
&unicodes,
|
||||
&font,
|
||||
SubsetFlags::default(),
|
||||
&IntSet::<Tag>::empty(),
|
||||
&IntSet::<Tag>::all(),
|
||||
&IntSet::<Tag>::all(),
|
||||
&name_ids,
|
||||
&languages,
|
||||
);
|
||||
subset_font(&font, &plan).map_err(|e| Failure::Other(e.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,694 @@
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use crate::cmap::Cmap;
|
||||
use crate::embed::Env;
|
||||
use crate::{Embed, EmbeddedFace, Error, parse_manifest};
|
||||
|
||||
fn fixture() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixture/craft-fonts")
|
||||
}
|
||||
|
||||
/// A fresh scratch directory under the system temp dir.
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(name: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("craft-fonts-build-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
struct Run {
|
||||
result: Result<Vec<String>, Error>,
|
||||
cargo: Vec<String>,
|
||||
table: String,
|
||||
/// `OUT_DIR`, which holds the table and the cut faces.
|
||||
#[cfg_attr(not(feature = "subset"), expect(dead_code))]
|
||||
out: Scratch,
|
||||
}
|
||||
|
||||
/// Run `embed` with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUIRED` as given; returns the embedded
|
||||
/// face names, the cargo lines and the generated table.
|
||||
fn run(embed: Embed, dir: Option<&Path>, required: Option<&str>) -> Run {
|
||||
static RUNS: AtomicUsize = AtomicUsize::new(0);
|
||||
let scratch = Scratch::new(&format!("run-{}", RUNS.fetch_add(1, Ordering::Relaxed)));
|
||||
let env = Env { dir: dir.map(OsString::from), required: required.map(OsString::from), out_dir: Some(scratch.0.clone().into()) };
|
||||
let mut cargo = Vec::new();
|
||||
let result = embed.run(&env, Path::new("craft_fonts.rs"), &mut cargo);
|
||||
let table = std::fs::read_to_string(scratch.0.join("craft_fonts.rs")).unwrap();
|
||||
Run { result: result.map(|faces| faces.iter().map(|f| format!("{} {}", f.family, f.style)).collect()), cargo, table, out: scratch }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_manifest_with_collection_indices() {
|
||||
let text = std::fs::read_to_string(fixture().join("fonts/manifest.txt")).unwrap();
|
||||
let entries = parse_manifest(&text).unwrap();
|
||||
assert_eq!(entries.len(), 6);
|
||||
assert_eq!((entries[0].family.as_str(), entries[0].style.as_str()), ("Sans JP", "Regular"));
|
||||
assert_eq!(entries[0].scripts, ["Jpan", "Latn"]);
|
||||
assert_eq!((entries[0].file.as_str(), entries[0].index), ("fonts/sans-jp/SansJP-Regular.ttf", 0));
|
||||
assert_eq!((entries[4].file.as_str(), entries[4].index), ("fonts/sans-cjk/SansCJK.ttc", 3));
|
||||
assert_eq!(entries[4].scripts, ["Hant-HK", "Latn"]);
|
||||
assert_eq!(entries[4].licence, "OFL-1.1");
|
||||
|
||||
let short = "# comment\n\nA | Regular | a.ttf | Latn\n";
|
||||
let err = parse_manifest(short).unwrap_err();
|
||||
assert_eq!(err.line, 3);
|
||||
assert!(err.to_string().contains("expected 8 fields"), "{err}");
|
||||
let bad_index = "A | Regular | a.ttc#x | Latn | OFL-1.1 | OFL.txt | 00 | src\n";
|
||||
assert!(parse_manifest(bad_index).unwrap_err().to_string().contains("face index"));
|
||||
let ui = "A UI | Regular | a.ttf | Hant | OFL-1.1 | OFL.txt | 00 | src | ui | future field\nA | Regular | a.ttf | Hant | OFL-1.1 | OFL.txt | 00 | src |\n";
|
||||
let entries = parse_manifest(ui).unwrap();
|
||||
assert_eq!(entries[0].role.as_deref(), Some("ui"));
|
||||
assert!(entries[0].is_ui() && entries[0].is_cjk());
|
||||
assert_eq!(entries[1].role, None);
|
||||
assert!(!entries[1].is_ui());
|
||||
assert!(entries.iter().all(|e| e.role.is_none() || e.is_ui()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_dir_writes_an_empty_table() {
|
||||
let r = run(Embed::new(), None, None);
|
||||
assert_eq!(r.result.unwrap(), Vec::<String>::new());
|
||||
assert_eq!(r.table.trim(), "&[]");
|
||||
assert!(r.cargo.contains(&"cargo::rerun-if-env-changed=CRAFT_FONTS_DIR".to_owned()));
|
||||
assert!(r.cargo.contains(&"cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED".to_owned()));
|
||||
assert!(!r.cargo.iter().any(|l| l.starts_with("cargo::warning")));
|
||||
// Only `1` means required.
|
||||
for value in ["0", "true", "yes", " 1"] {
|
||||
assert!(run(Embed::new(), None, Some(value)).result.is_ok(), "{value:?}");
|
||||
}
|
||||
assert!(run(Embed::new(), Some(Path::new("")), Some("")).result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_fails_without_a_usable_checkout() {
|
||||
for dir in [None, Some(Path::new(""))] {
|
||||
let r = run(Embed::new(), dir, Some("1"));
|
||||
assert!(matches!(r.result, Err(Error::NotSet)), "{dir:?}");
|
||||
assert_eq!(r.table.trim(), "&[]");
|
||||
}
|
||||
let err = run(Embed::new(), None, Some("1")).result.unwrap_err().to_string();
|
||||
assert!(err.contains("CRAFT_FONTS_DIR") && err.contains("CRAFT_FONTS_REQUIRED"), "{err}");
|
||||
|
||||
let missing = Path::new("/nonexistent/craft-fonts");
|
||||
assert!(matches!(run(Embed::new(), Some(missing), Some("1")).result, Err(Error::Read { .. })));
|
||||
let r = run(Embed::new(), Some(missing), None);
|
||||
assert!(r.result.unwrap().is_empty());
|
||||
// Watching a missing manifest would rerun the build script on every build.
|
||||
assert!(!r.cargo.iter().any(|l| l.starts_with("cargo::rerun-if-changed")), "{:?}", r.cargo);
|
||||
assert!(r.cargo.iter().any(|l| l.starts_with("cargo::warning=building without craft-fonts")), "{:?}", r.cargo);
|
||||
|
||||
let scratch = Scratch::new("missing-file");
|
||||
std::fs::create_dir_all(scratch.0.join("fonts")).unwrap();
|
||||
std::fs::write(scratch.0.join("fonts/manifest.txt"), "A | Regular | fonts/a.ttf | Latn | OFL-1.1 | OFL.txt | 00 | src\n").unwrap();
|
||||
assert!(matches!(run(Embed::new(), Some(&scratch.0), Some("1")).result, Err(Error::Read { .. })));
|
||||
std::fs::write(scratch.0.join("fonts/manifest.txt"), "# no faces\n").unwrap();
|
||||
assert!(matches!(run(Embed::new(), Some(&scratch.0), Some("1")).result, Err(Error::NoFaces { .. })));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_a_checkout_under_a_non_utf8_path() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let scratch = Scratch::new("non-utf8");
|
||||
let dir = scratch.0.join(std::ffi::OsStr::from_bytes(b"craft-fonts-\xff"));
|
||||
std::fs::create_dir_all(dir.join("fonts")).unwrap();
|
||||
std::fs::copy(fixture().join("fonts/sans-arabic/SansArabic.ttf"), dir.join("fonts/a.ttf")).unwrap();
|
||||
std::fs::write(dir.join("fonts/manifest.txt"), "A | Regular | fonts/a.ttf | Arab | OFL-1.1 | OFL.txt | 00 | src\n").unwrap();
|
||||
let r = run(Embed::new(), Some(&dir), Some("1"));
|
||||
assert!(matches!(r.result, Err(Error::NonUtf8Path { .. })), "{:?}", r.result);
|
||||
assert_eq!(r.table.trim(), "&[]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embeds_the_selected_scripts() {
|
||||
let r = run(Embed::new(), Some(&fixture()), Some("1"));
|
||||
assert_eq!(r.result.unwrap().len(), 6);
|
||||
|
||||
let r = run(Embed::new().scripts(&["Jpan", "Hant"]), Some(&fixture()), Some("1"));
|
||||
assert_eq!(r.result.unwrap(), ["Sans JP Regular", "Sans JP Bold", "Sans CJK TC Regular", "Sans CJK HK Regular"]);
|
||||
let manifest = fixture().join("fonts/manifest.txt");
|
||||
assert!(r.cargo.contains(&format!("cargo::rerun-if-changed={}", manifest.display())));
|
||||
let jp = fixture().join("fonts/sans-jp/SansJP-Regular.ttf").canonicalize().unwrap();
|
||||
assert!(r.cargo.contains(&format!("cargo::rerun-if-changed={}", jp.display())));
|
||||
assert!(r.table.contains(&format!("include_bytes!({:?})", jp.display().to_string())), "{}", r.table);
|
||||
assert!(r.table.contains("family: \"Sans CJK HK\", style: \"Regular\", scripts: &[\"Hant-HK\", \"Latn\"], index: 3"), "{}", r.table);
|
||||
|
||||
let r = run(Embed::new().scripts(&["Hant-HK"]).filter(|f| f.index != 0), Some(&fixture()), None);
|
||||
assert_eq!(r.result.unwrap(), ["Sans CJK HK Regular"]);
|
||||
|
||||
let r = run(Embed::new().scripts(&["Arab", "Kore", "Geor"]), Some(&fixture()), Some("1"));
|
||||
let Err(Error::MissingScripts { scripts, .. }) = r.result else { panic!("{:?}", r.result) };
|
||||
assert_eq!(scripts, ["Kore", "Geor"]);
|
||||
// Not required: whatever matched is embedded.
|
||||
let r = run(Embed::new().scripts(&["Arab", "Kore"]), Some(&fixture()), None);
|
||||
assert_eq!(r.result.unwrap(), ["Sans Arabic Regular"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_face_helpers() {
|
||||
let face = |style: &'static str| EmbeddedFace { family: "Sans", style, scripts: &["Hant-HK", "Latn"], index: 0, bytes: b"" };
|
||||
assert_eq!(face("Regular").name(), "Sans Regular");
|
||||
assert!(face("Regular").has_script("Hant") && face("Regular").has_script("Hant-HK") && !face("Regular").has_script("Han"));
|
||||
let weights: Vec<u16> = ["Regular", "Bold", "SemiBold", "Extra Light", "Black Italic", "Italic"].map(|s| face(s).weight()).into();
|
||||
assert_eq!(weights, [400, 700, 600, 200, 900, 400]);
|
||||
assert!(face("Regular").is_cjk());
|
||||
assert!(!EmbeddedFace { scripts: &["Arab"], ..face("Regular") }.is_cjk());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_embeds_only_the_named_families() {
|
||||
let r = run(Embed::documents(&["Sans JP", "Sans Thai"]), Some(&fixture()), Some("1"));
|
||||
assert_eq!(r.result.unwrap(), ["Sans JP Regular", "Sans JP Bold", "Sans Thai Regular"]);
|
||||
let r = run(Embed::documents(&["Sans JP", "Sans Thai"]).filter(|f| f.style == "Bold"), Some(&fixture()), Some("1"));
|
||||
assert_eq!(r.result.unwrap(), ["Sans JP Bold"]);
|
||||
let r = run(Embed::documents(&[]), Some(&fixture()), None);
|
||||
assert_eq!(r.result.unwrap(), Vec::<String>::new());
|
||||
|
||||
let r = run(Embed::documents(&["Sans JP", "Sans Jp", "Serif"]), Some(&fixture()), Some("1"));
|
||||
let Err(Error::UnknownFamilies { families, .. }) = &r.result else { panic!("{:?}", r.result) };
|
||||
assert_eq!(families, &["Sans Jp", "Serif"]);
|
||||
let message = r.result.unwrap_err().to_string();
|
||||
assert!(
|
||||
message.contains("has no family Sans Jp, Serif") && message.ends_with("Sans JP, Sans Arabic, Sans CJK TC, Sans CJK HK, Sans Thai"),
|
||||
"{message}"
|
||||
);
|
||||
assert_eq!(r.table.trim(), "&[]");
|
||||
// Not required: a warning and an empty table, as for any checkout that can't be used.
|
||||
let r = run(Embed::documents(&["Serif"]), Some(&fixture()), None);
|
||||
assert!(r.result.as_ref().unwrap().is_empty());
|
||||
assert!(warnings(&r).iter().any(|w| w.contains("building without craft-fonts") && w.contains("no family Serif")), "{:?}", r.cargo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_copies_the_selected_faces_and_writes_urls() {
|
||||
let scratch = Scratch::new("serve");
|
||||
let served = scratch.0.join("dist/fonts");
|
||||
let env = Env { dir: Some(fixture().into()), required: Some("1".into()), out_dir: Some(scratch.0.clone().into()) };
|
||||
let embed = Embed::new().scripts(&["Jpan", "Hant"]).filter(|f| f.style == "Regular").serve(&served, "fonts/");
|
||||
let mut cargo = Vec::new();
|
||||
let names: Vec<String> =
|
||||
embed.run(&env, Path::new("craft_fonts.rs"), &mut cargo).unwrap().iter().map(|f| format!("{} {}", f.family, f.style)).collect();
|
||||
assert_eq!(names, ["Sans JP Regular", "Sans CJK TC Regular", "Sans CJK HK Regular"]);
|
||||
|
||||
let read = |path: &str| std::fs::read(served.join(path)).unwrap();
|
||||
assert_eq!(read("sans-jp/SansJP-Regular.ttf"), std::fs::read(fixture().join("fonts/sans-jp/SansJP-Regular.ttf")).unwrap());
|
||||
assert_eq!(read("sans-cjk/SansCJK.ttc").len(), 28);
|
||||
assert_eq!(read("sans-jp/OFL.txt"), b"Fixture licence text.\n");
|
||||
assert!(!served.join("sans-jp/SansJP-Bold.ttf").exists() && !served.join("sans-arabic").exists());
|
||||
// Cargo copies a deleted file again, and doesn't rerun for files it just copied.
|
||||
assert!(cargo.contains(&format!("cargo::rerun-if-changed={}", served.join("sans-jp/SansJP-Regular.ttf").display())), "{cargo:?}");
|
||||
assert!(cargo.contains(&format!("cargo::rerun-if-changed={}", served.join("sans-jp/OFL.txt").display())), "{cargo:?}");
|
||||
let modified = |path: &Path| std::fs::metadata(path).unwrap().modified().unwrap();
|
||||
assert_eq!(modified(&served.join("sans-jp/SansJP-Regular.ttf")), modified(&fixture().join("fonts/sans-jp/SansJP-Regular.ttf")));
|
||||
|
||||
let table = std::fs::read_to_string(scratch.0.join("craft_fonts.rs")).unwrap();
|
||||
assert!(!table.contains("include_bytes!"), "{table}");
|
||||
assert!(
|
||||
table.contains(
|
||||
"ServedFace { family: \"Sans JP\", style: \"Regular\", scripts: &[\"Jpan\", \"Latn\"], index: 0, url: \"fonts/sans-jp/SansJP-Regular.ttf\", size: 24, integrity: \"sha256-ydCO8fxp8V6HzlK9qKnCRxTkEl2vflCkDtsaor4Q3/E=\", coverage: b\"\" }"
|
||||
),
|
||||
"{table}"
|
||||
);
|
||||
// Both faces of the collection name the one served file.
|
||||
assert_eq!(table.matches("url: \"fonts/sans-cjk/SansCJK.ttc\"").count(), 2, "{table}");
|
||||
|
||||
// Without a checkout the table is empty and nothing is copied.
|
||||
let env = Env { dir: None, required: None, out_dir: Some(scratch.0.clone().into()) };
|
||||
let empty = scratch.0.join("empty");
|
||||
assert!(Embed::new().serve(&empty, "fonts/").run(&env, Path::new("craft_fonts.rs"), &mut cargo).unwrap().is_empty());
|
||||
assert_eq!(std::fs::read_to_string(scratch.0.join("craft_fonts.rs")).unwrap().trim(), "&[]");
|
||||
assert!(!empty.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_replaces_a_stale_copy_of_the_same_size() {
|
||||
let scratch = Scratch::new("serve-stale");
|
||||
let served = scratch.0.join("served");
|
||||
let source = std::fs::read(fixture().join("fonts/sans-jp/SansJP-Regular.ttf")).unwrap();
|
||||
std::fs::create_dir_all(served.join("sans-jp")).unwrap();
|
||||
std::fs::write(served.join("sans-jp/SansJP-Regular.ttf"), vec![0; source.len()]).unwrap();
|
||||
let r = run(Embed::new().scripts(&["Jpan"]).filter(|f| f.style == "Regular").serve(&served, "fonts/"), Some(&fixture()), Some("1"));
|
||||
r.result.unwrap();
|
||||
assert_eq!(std::fs::read(served.join("sans-jp/SansJP-Regular.ttf")).unwrap(), source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrity_is_base64_of_the_hex_hash() {
|
||||
use crate::embed::integrity;
|
||||
assert_eq!(integrity("c9d08ef1fc69f15e87ce52bda8a9c24714e4125daf7e50a40edb1aa2be10dff1"), "sha256-ydCO8fxp8V6HzlK9qKnCRxTkEl2vflCkDtsaor4Q3/E=");
|
||||
for bad in ["", "00", "zz08ef1fc69f15e87ce52bda8a9c24714e4125daf7e50a40edb1aa2be10dff1"] {
|
||||
assert_eq!(integrity(bad), "", "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A font with only a `cmap` table: a Windows format 4 subtable with `bmp` segments `(first,
|
||||
/// last, glyphs)`, mapped to glyphs 1.. when `glyphs` is `None` and through the glyph array
|
||||
/// otherwise, and a format 12 subtable with `groups` `(first, last)` when there are any.
|
||||
fn tiny_font(bmp: &[(u16, u16, Option<&[u16]>)], groups: &[(u32, u32)]) -> Vec<u8> {
|
||||
let be16 = |v: &mut Vec<u8>, x: u16| v.extend_from_slice(&x.to_be_bytes());
|
||||
let be32 = |v: &mut Vec<u8>, x: u32| v.extend_from_slice(&x.to_be_bytes());
|
||||
|
||||
let mut segments = bmp.to_vec();
|
||||
segments.push((0xFFFF, 0xFFFF, None));
|
||||
let n = u16::try_from(segments.len()).unwrap();
|
||||
let (mut ends, mut starts, mut deltas, mut offsets, mut glyphs) = (vec![], vec![], vec![], vec![], vec![]);
|
||||
for (i, &(first, last, ids)) in segments.iter().enumerate() {
|
||||
be16(&mut ends, last);
|
||||
be16(&mut starts, first);
|
||||
match ids {
|
||||
None => {
|
||||
// The last segment maps 0xFFFF to glyph 0, as fonts do.
|
||||
be16(&mut deltas, 1u16.wrapping_sub(if first == 0xFFFF { 0 } else { first }));
|
||||
be16(&mut offsets, 0);
|
||||
}
|
||||
Some(ids) => {
|
||||
be16(&mut deltas, 0);
|
||||
be16(&mut offsets, 2 * (n - u16::try_from(i).unwrap()) + u16::try_from(glyphs.len()).unwrap());
|
||||
ids.iter().for_each(|&g| be16(&mut glyphs, g));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut format4 = vec![];
|
||||
for x in [4, 0, 0, 2 * n, 0, 0, 0] {
|
||||
be16(&mut format4, x);
|
||||
}
|
||||
format4.extend(ends.iter().chain(&[0, 0]).chain(&starts).chain(&deltas).chain(&offsets).chain(&glyphs));
|
||||
let len = u16::try_from(format4.len()).unwrap();
|
||||
format4[2..4].copy_from_slice(&len.to_be_bytes());
|
||||
|
||||
let mut format12 = vec![];
|
||||
be16(&mut format12, 12);
|
||||
be16(&mut format12, 0);
|
||||
be32(&mut format12, 16 + 12 * u32::try_from(groups.len()).unwrap());
|
||||
be32(&mut format12, 0);
|
||||
be32(&mut format12, u32::try_from(groups.len()).unwrap());
|
||||
for &(first, last) in groups {
|
||||
[first, last, 1].into_iter().for_each(|x| be32(&mut format12, x));
|
||||
}
|
||||
|
||||
let subtables: Vec<(u16, Vec<u8>)> = [(1, format4), (10, format12)].into_iter().filter(|(enc, _)| *enc == 1 || !groups.is_empty()).collect();
|
||||
let mut cmap = vec![];
|
||||
be16(&mut cmap, 0);
|
||||
be16(&mut cmap, u16::try_from(subtables.len()).unwrap());
|
||||
let mut at = 4 + 8 * u32::try_from(subtables.len()).unwrap();
|
||||
for (encoding, table) in &subtables {
|
||||
[3, *encoding].into_iter().for_each(|x| be16(&mut cmap, x));
|
||||
be32(&mut cmap, at);
|
||||
at += u32::try_from(table.len()).unwrap();
|
||||
}
|
||||
subtables.iter().for_each(|(_, table)| cmap.extend(table));
|
||||
|
||||
let mut font = vec![];
|
||||
be32(&mut font, 0x0001_0000);
|
||||
for x in [1, 0, 0, 0] {
|
||||
be16(&mut font, x);
|
||||
}
|
||||
font.extend_from_slice(b"cmap");
|
||||
for x in [0, 28, u32::try_from(cmap.len()).unwrap()] {
|
||||
be32(&mut font, x);
|
||||
}
|
||||
font.extend(cmap);
|
||||
font
|
||||
}
|
||||
|
||||
/// A `.ttc` of fonts made by [`tiny_font`].
|
||||
fn tiny_collection(fonts: &[Vec<u8>]) -> Vec<u8> {
|
||||
let header = 12 + 4 * fonts.len();
|
||||
let mut ttc = b"ttcf\0\x01\0\0".to_vec();
|
||||
ttc.extend_from_slice(&u32::try_from(fonts.len()).unwrap().to_be_bytes());
|
||||
let mut body = vec![];
|
||||
for font in fonts {
|
||||
let start = u32::try_from(header + body.len()).unwrap();
|
||||
ttc.extend_from_slice(&start.to_be_bytes());
|
||||
let mut font = font.clone();
|
||||
// The table offsets of a collection's faces count from the start of the file.
|
||||
let offset = u32::from_be_bytes(font[20..24].try_into().unwrap()) + start;
|
||||
font[20..24].copy_from_slice(&offset.to_be_bytes());
|
||||
body.extend(font);
|
||||
}
|
||||
ttc.extend(body);
|
||||
ttc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmap_reads_formats_4_and_12_and_collections() {
|
||||
let font = tiny_font(&[(0x41, 0x5A, None), (0x4E00, 0x4E02, Some(&[5, 0, 7]))], &[(0x2_0000, 0x2_0001)]);
|
||||
let cmap = Cmap::read(&font, 0).unwrap();
|
||||
let covered: Vec<char> = "AZa一丁丂七\u{FFFF}\u{20000}\u{20001}\u{20002}".chars().filter(|&c| cmap.covers(c)).collect();
|
||||
assert_eq!(covered, ['A', 'Z', '一', '丂', '\u{20000}', '\u{20001}']);
|
||||
|
||||
let bmp_in_format12 = tiny_font(&[], &[(0x4E00, 0x4E0F)]);
|
||||
assert!(Cmap::read(&bmp_in_format12, 0).unwrap().covers('丁'));
|
||||
|
||||
let ttc = tiny_collection(&[tiny_font(&[(0x41, 0x41, None)], &[]), tiny_font(&[(0x4E00, 0x4E00, None)], &[])]);
|
||||
let second = Cmap::read(&ttc, 1).unwrap();
|
||||
assert!(second.covers('一') && !second.covers('A'));
|
||||
assert!(Cmap::read(&ttc, 0).unwrap().covers('A'));
|
||||
assert!(Cmap::read(&ttc, 2).is_none());
|
||||
assert!(Cmap::read(b"fixture: Sans JP Regular", 0).is_none());
|
||||
assert!(Cmap::read(&font[..40], 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmap_reads_each_subtable_within_its_declared_length() {
|
||||
// 'A' takes its glyph from an empty glyph array, so the read lands on the format 12 subtable
|
||||
// that follows the format 4 one.
|
||||
let font = tiny_font(&[(0x41, 0x41, Some(&[]))], &[(0x2_0000, 0x2_0000)]);
|
||||
let cmap = Cmap::read(&font, 0).unwrap();
|
||||
assert!(!cmap.covers('A') && cmap.covers('\u{20000}'));
|
||||
|
||||
// A format 4 length past the end of the table is clamped rather than rejected. The format 4
|
||||
// subtable starts after the 28-byte font header, the 4-byte cmap header and two 8-byte encoding
|
||||
// records.
|
||||
let mut font = tiny_font(&[(0x41, 0x5A, None)], &[(0x2_0000, 0x2_0000)]);
|
||||
let format4 = 28 + 4 + 2 * 8;
|
||||
let format12 = format4 + usize::from(u16::from_be_bytes(font[format4 + 2..format4 + 4].try_into().unwrap()));
|
||||
font[format4 + 2..format4 + 4].copy_from_slice(&u16::MAX.to_be_bytes());
|
||||
assert!(Cmap::read(&font, 0).unwrap().covers('A'));
|
||||
|
||||
// A format 12 group past the declared length doesn't count.
|
||||
font[format12 + 4..format12 + 8].copy_from_slice(&16u32.to_be_bytes());
|
||||
let cmap = Cmap::read(&font, 0).unwrap();
|
||||
assert!(cmap.covers('A') && !cmap.covers('\u{20000}'));
|
||||
}
|
||||
|
||||
/// A craft-fonts checkout in `scratch` with these `(family, scripts, role, font)` faces, all
|
||||
/// Regular.
|
||||
fn checkout(scratch: &Scratch, faces: &[(&str, &str, &str, Vec<u8>)]) -> PathBuf {
|
||||
let dir = scratch.0.join("craft-fonts");
|
||||
std::fs::create_dir_all(dir.join("fonts")).unwrap();
|
||||
let mut manifest = String::new();
|
||||
for (i, (family, scripts, role, font)) in faces.iter().enumerate() {
|
||||
std::fs::write(dir.join(format!("fonts/{i}.ttf")), font).unwrap();
|
||||
manifest += &format!("{family} | Regular | fonts/{i}.ttf | {scripts} | OFL-1.1 | fonts/OFL.txt | 00 | src | {role}\n");
|
||||
}
|
||||
std::fs::write(dir.join("fonts/manifest.txt"), manifest).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn warnings(r: &Run) -> Vec<String> {
|
||||
r.cargo.iter().filter(|l| l.starts_with("cargo::warning")).cloned().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_ui_embeds_cjk_faces_only_as_cut_interface_faces() {
|
||||
let scratch = Scratch::new("for-ui");
|
||||
let latin = || tiny_font(&[(0x20, 0x7E, None)], &[]);
|
||||
let dir = checkout(
|
||||
&scratch,
|
||||
&[
|
||||
("Noto Sans TC", "Hant,Latn", "", latin()),
|
||||
("Noto Sans TC UI", "Hant,Latn", "ui", latin()),
|
||||
("Noto Sans HK UI", "Hant-HK,Latn", "ui", latin()),
|
||||
("Noto Sans Arabic", "Arab", "", latin()),
|
||||
],
|
||||
);
|
||||
// Without catalogs to cut them to, the interface faces are left out too.
|
||||
let r = run(Embed::new().for_ui(), Some(&dir), None);
|
||||
assert_eq!(r.result.as_ref().unwrap(), &["Noto Sans Arabic Regular"]);
|
||||
let w = warnings(&r);
|
||||
assert_eq!(w.len(), 1, "{w:#?}");
|
||||
assert!(w[0].contains("not embedding the interface faces Noto Sans TC UI Regular, Noto Sans HK UI Regular"), "{}", w[0]);
|
||||
assert!(w[0].contains("Embed::subset_to"), "{}", w[0]);
|
||||
// A manifest without interface faces loses every CJK face, which is worth a warning.
|
||||
let r = run(Embed::new().for_ui(), Some(&fixture()), None);
|
||||
assert!(r.cargo.contains(&format!("cargo::rerun-if-changed={}", fixture().join("fonts/manifest.txt").display())), "{:?}", r.cargo);
|
||||
let w = warnings(&r);
|
||||
assert_eq!(w.len(), 1, "{w:#?}");
|
||||
assert!(w[0].contains("not embedding any CJK face for the interface") && w[0].contains("role `ui`"), "{}", w[0]);
|
||||
let r = run(Embed::new().for_ui().scripts(&["Hant", "Arab"]), Some(&dir), Some("1"));
|
||||
let Err(Error::MissingScripts { scripts, .. }) = r.result else { panic!("{:?}", r.result) };
|
||||
assert_eq!(scripts, ["Hant"]);
|
||||
assert_eq!(r.table.trim(), "&[]");
|
||||
// Without `for_ui` the interface faces' sources are left out, unless a document table names them.
|
||||
let r = run(Embed::new().scripts(&["Hant"]), Some(&dir), Some("1"));
|
||||
assert_eq!(r.result.as_ref().unwrap(), &["Noto Sans TC Regular"]);
|
||||
assert_eq!(warnings(&r), Vec::<String>::new());
|
||||
let r = run(Embed::documents(&["Noto Sans TC UI", "Noto Sans TC"]), Some(&dir), Some("1"));
|
||||
assert_eq!(r.result.as_ref().unwrap(), &["Noto Sans TC Regular", "Noto Sans TC UI Regular"]);
|
||||
assert!(r.table.contains("fonts/1.ttf"), "{}", r.table);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_text_warns_about_characters_no_face_covers() {
|
||||
let scratch = Scratch::new("check-text");
|
||||
let dir = checkout(
|
||||
&scratch,
|
||||
&[
|
||||
("Noto Sans TC", "Hant,Latn", "", tiny_font(&[(0x20, 0x7E, None), (0x4E00, 0x4E03, None)], &[])),
|
||||
("Noto Sans Arabic", "Arab", "", tiny_font(&[(0x600, 0x6FF, None)], &[])),
|
||||
("Noto Sans SC", "Hans,Latn", "", tiny_font(&[(0x20, 0x7E, None), (0x3000, 0x9FFF, None)], &[])),
|
||||
],
|
||||
);
|
||||
let catalogs = scratch.0.join("i18n");
|
||||
std::fs::create_dir_all(&catalogs).unwrap();
|
||||
std::fs::write(catalogs.join("zh-TW.ftl"), "\u{FEFF}open = 一丁万。\nclose = 丁万\u{FE0F} \u{1F600}\n").unwrap();
|
||||
std::fs::write(catalogs.join("ar.ftl"), "open = فتح\nok = 一\n").unwrap();
|
||||
let long: String = (0x4E10..0x4E20).filter_map(char::from_u32).collect();
|
||||
std::fs::write(catalogs.join("long.ftl"), format!("x = {long}\n")).unwrap();
|
||||
let paths = ["zh-TW.ftl", "ar.ftl", "long.ftl", "missing.ftl"].map(|f| catalogs.join(f));
|
||||
|
||||
let r = run(Embed::new().scripts(&["Hant", "Arab"]).check_text(paths.clone()), Some(&dir), None);
|
||||
assert_eq!(r.result.as_ref().unwrap().len(), 2);
|
||||
for path in &paths {
|
||||
assert!(r.cargo.contains(&format!("cargo::rerun-if-changed={}", path.display())), "{:?}", r.cargo);
|
||||
}
|
||||
let w = warnings(&r);
|
||||
assert_eq!(w.len(), 3, "{w:#?}");
|
||||
assert!(w[0].contains("zh-TW.ftl has 2 characters no embedded face covers (万。); system fonts"), "{}", w[0]);
|
||||
assert!(w[1].contains("long.ftl has 16 characters") && w[1].contains("(丐丑丒专且丕世丗 …)"), "{}", w[1]);
|
||||
assert!(w[2].contains("cannot check") && w[2].contains("missing.ftl"), "{}", w[2]);
|
||||
|
||||
// The full face covers everything; without a checkout there is nothing to check.
|
||||
let r = run(Embed::new().check_text(paths[..3].to_vec()), Some(&dir), None);
|
||||
assert_eq!(warnings(&r), Vec::<String>::new());
|
||||
let r = run(Embed::new().check_text(paths.clone()), None, None);
|
||||
assert_eq!(warnings(&r), Vec::<String>::new());
|
||||
assert!(r.cargo.contains(&format!("cargo::rerun-if-changed={}", paths[0].display())));
|
||||
|
||||
// Faces another table embeds count as covering their characters.
|
||||
let r = run(Embed::new().scripts(&["Arab"]).embedded_elsewhere(&["Noto Sans SC"]).check_text(paths[..3].to_vec()), Some(&dir), None);
|
||||
assert_eq!(warnings(&r), Vec::<String>::new());
|
||||
let r = run(
|
||||
Embed::new().scripts(&["Arab"]).embedded_elsewhere(&["Noto Sans TC", "Noto Sans Math"]).check_text(paths[..2].to_vec()),
|
||||
Some(&dir),
|
||||
None,
|
||||
);
|
||||
let w = warnings(&r);
|
||||
assert_eq!(w.len(), 2, "{w:#?}");
|
||||
assert!(w[0].contains("embedded_elsewhere names Noto Sans Math, which") && w[0].ends_with("manifest.txt lacks"), "{}", w[0]);
|
||||
assert!(w[1].contains("zh-TW.ftl has 2 characters no embedded face covers (万。)"), "{}", w[1]);
|
||||
|
||||
// The fixture's "fonts" are text, so they cover nothing.
|
||||
let r = run(Embed::new().scripts(&["Arab"]).check_text([&paths[1]]), Some(&fixture()), None);
|
||||
let w = warnings(&r);
|
||||
assert!(w[0].contains("cannot read the character map of Sans Arabic Regular"), "{w:#?}");
|
||||
assert!(w[1].contains("ar.ftl has 1 character no embedded face covers (一)"), "{w:#?}");
|
||||
}
|
||||
|
||||
#[cfg(feature = "subset")]
|
||||
mod subset {
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{Run, Scratch, checkout, run, tiny_font, warnings};
|
||||
use crate::cmap::Cmap;
|
||||
use crate::{Embed, Error};
|
||||
|
||||
/// `fixture/subset/make-fonts.py` writes these.
|
||||
fn tiny(file: &str) -> Vec<u8> {
|
||||
std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixture/subset").join(file)).unwrap()
|
||||
}
|
||||
|
||||
/// The BMP characters `font` maps.
|
||||
fn mapped(font: &[u8]) -> Vec<u32> {
|
||||
let cmap = Cmap::read(font, 0).unwrap();
|
||||
('\0'..='\u{FFFF}').filter(|&c| cmap.covers(c)).map(u32::from).collect()
|
||||
}
|
||||
|
||||
fn table<'a>(font: &'a [u8], tag: &[u8; 4]) -> Option<&'a [u8]> {
|
||||
let u16_at = |at: usize| usize::from(u16::from_be_bytes([font[at], font[at + 1]]));
|
||||
let u32_at = |at: usize| usize::try_from(u32::from_be_bytes(font[at..at + 4].try_into().unwrap())).unwrap();
|
||||
let record = (0..u16_at(4)).map(|i| 12 + 16 * i).find(|&r| &font[r..r + 4] == tag)?;
|
||||
Some(&font[u32_at(record + 8)..][..u32_at(record + 12)])
|
||||
}
|
||||
|
||||
/// The feature tags of a GSUB or GPOS table.
|
||||
fn features(table: &[u8]) -> BTreeSet<String> {
|
||||
let list = usize::from(u16::from_be_bytes([table[6], table[7]]));
|
||||
let count = usize::from(u16::from_be_bytes([table[list], table[list + 1]]));
|
||||
(0..count).map(|i| String::from_utf8_lossy(&table[list + 2 + 6 * i..][..4]).into_owned()).collect()
|
||||
}
|
||||
|
||||
fn glyph_count(font: &[u8]) -> u16 {
|
||||
let maxp = table(font, b"maxp").unwrap();
|
||||
u16::from_be_bytes([maxp[4], maxp[5]])
|
||||
}
|
||||
|
||||
fn cut_file(r: &Run, family: &str) -> PathBuf {
|
||||
r.out.0.join("craft-fonts-ui").join(format!("{}-Regular.ttf", family.replace(' ', "-")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_to_cuts_interface_faces_to_the_catalogs() {
|
||||
let scratch = Scratch::new("subset");
|
||||
let latin = || tiny_font(&[(0x20, 0x7E, None)], &[]);
|
||||
let dir = checkout(
|
||||
&scratch,
|
||||
&[
|
||||
("Noto Sans TC", "Hant,Latn", "", latin()),
|
||||
("Noto Sans TC UI", "Hant,Latn", "ui", tiny("Tiny-glyf.ttf")),
|
||||
("Noto Sans HK UI", "Hant-HK,Latn", "ui", tiny("Tiny-glyf.ttf")),
|
||||
("Noto Sans Arabic", "Arab", "", latin()),
|
||||
],
|
||||
);
|
||||
let tw = scratch.0.join("zh-TW.tsv");
|
||||
std::fs::write(&tw, "Open\t一万\n").unwrap();
|
||||
let ko = scratch.0.join("ko.tsv");
|
||||
std::fs::write(&ko, "Open\t丁\u{FE0F} \u{1F600}\n").unwrap();
|
||||
|
||||
let r = run(Embed::new().subset_to([&tw]).scripts(&["Hant"]), Some(&dir), Some("1"));
|
||||
assert_eq!(r.result.as_ref().unwrap(), &["Noto Sans TC UI Regular", "Noto Sans HK UI Regular"]);
|
||||
let tc = cut_file(&r, "Noto Sans TC UI");
|
||||
assert!(r.table.contains(&format!("index: 0, bytes: include_bytes!({:?})", tc.display().to_string())), "{}", r.table);
|
||||
assert!(r.cargo.contains(&format!("cargo::rerun-if-changed={}", tw.display())), "{:?}", r.cargo);
|
||||
let cut = std::fs::read(&tc).unwrap();
|
||||
// The catalog's 一, plus the punctuation, kana and full-width forms the source has; not
|
||||
// its Latin, nor the 丁 and 七 no catalog uses.
|
||||
assert_eq!(mapped(&cut), [0x3001, 0x300C, 0x3042, 0x4E00, 0xFF01]);
|
||||
assert!(table(&cut, b"glyf").is_some());
|
||||
assert_eq!(features(table(&cut, b"GSUB").unwrap()), BTreeSet::from(["vert".to_owned()]));
|
||||
// .notdef, five characters and the vertical form of U+300C.
|
||||
assert_eq!(glyph_count(&cut), 7);
|
||||
// The source lacks 万, so system fonts draw it.
|
||||
let w = warnings(&r);
|
||||
assert_eq!(w.len(), 1, "{w:#?}");
|
||||
assert!(w[0].contains("zh-TW.tsv has 1 character no embedded face covers (万)"), "{}", w[0]);
|
||||
|
||||
// The same bytes on every run. Another catalog adds its characters, but no emoji or
|
||||
// variation selector, and a `check_text` file is only checked.
|
||||
let again = run(Embed::new().for_ui().subset_to([&tw]), Some(&dir), None);
|
||||
assert_eq!(std::fs::read(cut_file(&again, "Noto Sans TC UI")).unwrap(), cut);
|
||||
let r = run(Embed::new().subset_to([&tw, &ko]).check_text([scratch.0.join("zh-TW.tsv")]), Some(&dir), None);
|
||||
assert_eq!(mapped(&std::fs::read(cut_file(&r, "Noto Sans TC UI")).unwrap()), [0x3001, 0x300C, 0x3042, 0x4E00, 0x4E01, 0xFF01]);
|
||||
assert_eq!(warnings(&r).len(), 1, "{:#?}", warnings(&r));
|
||||
|
||||
// No catalogs (an app whose CRAFT_I18N_DIR is unset) leaves the faces out, not cut to kana.
|
||||
let r = run(Embed::new().subset_to(Vec::<PathBuf>::new()), Some(&dir), None);
|
||||
assert_eq!(r.result.as_ref().unwrap(), &["Noto Sans Arabic Regular"]);
|
||||
let w = warnings(&r);
|
||||
assert_eq!(w.len(), 1, "{w:#?}");
|
||||
assert!(w[0].contains("Noto Sans TC UI Regular") && w[0].contains("given no translation catalogs"), "{}", w[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_to_rejects_cff_interface_faces() {
|
||||
let scratch = Scratch::new("subset-cff");
|
||||
let dir = checkout(&scratch, &[("Noto Sans SC UI", "Hans", "ui", tiny("Tiny-cff.otf"))]);
|
||||
let catalog = scratch.0.join("zh-CN.tsv");
|
||||
std::fs::write(&catalog, "Open\t一\n").unwrap();
|
||||
for required in [None, Some("1")] {
|
||||
let r = run(Embed::new().subset_to([&catalog]), Some(&dir), required);
|
||||
let Err(e @ Error::NotTrueType { .. }) = r.result else { panic!("{:?}", r.result) };
|
||||
assert!(e.to_string().contains("0.ttf has CFF outlines"), "{e}");
|
||||
assert_eq!(r.table.trim(), "&[]");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_to_serves_the_cut_under_its_hash() {
|
||||
let scratch = Scratch::new("subset-serve");
|
||||
let dir = checkout(&scratch, &[("Noto Sans TC UI", "Hant,Latn", "ui", tiny("Tiny-glyf.ttf"))]);
|
||||
let catalog = scratch.0.join("zh-TW.tsv");
|
||||
std::fs::write(&catalog, "Open\t一\n").unwrap();
|
||||
let served = scratch.0.join("served");
|
||||
let r = run(Embed::new().subset_to([&catalog]).serve(&served, "fonts/"), Some(&dir), Some("1"));
|
||||
assert_eq!(r.result.as_ref().unwrap(), &["Noto Sans TC UI Regular"]);
|
||||
let cut = std::fs::read(cut_file(&r, "Noto Sans TC UI")).unwrap();
|
||||
let sha256 = crate::sha256::hex(&cut);
|
||||
let file = format!("{}.{}.ttf", 0, &sha256[..16]);
|
||||
assert_eq!(std::fs::read(served.join(&file)).unwrap(), cut);
|
||||
assert!(!served.join("0.ttf").exists());
|
||||
let line = format!("url: \"fonts/{file}\", size: {}, integrity: {:?}, coverage: ", cut.len(), crate::embed::integrity(&sha256));
|
||||
assert!(r.table.contains(&line), "{}\n{line}", r.table);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_matches_the_standard_vectors() {
|
||||
use crate::sha256::hex;
|
||||
assert_eq!(hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
||||
assert_eq!(hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
assert_eq!(
|
||||
hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
|
||||
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
|
||||
);
|
||||
assert_eq!(hex(&vec![b'a'; 1_000_000]), "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0");
|
||||
}
|
||||
|
||||
/// With `CRAFT_FONTS_DIR` set to a craft-fonts checkout, cut its BIZ UDPGothic Regular.
|
||||
#[test]
|
||||
fn cuts_biz_udpgothic() {
|
||||
let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").filter(|dir| !dir.is_empty()) else { return };
|
||||
let Ok(source) = std::fs::read(Path::new(&dir).join("fonts/biz-ud-pgothic/BIZUDPGothic-Regular.ttf")) else { return };
|
||||
let chars: BTreeSet<char> = "ファイルを開く「新規」、保存。編集表示".chars().collect();
|
||||
let cut = crate::subset::subset(&source, 0, &chars).unwrap();
|
||||
let (source_cmap, cmap) = (Cmap::read(&source, 0).unwrap(), Cmap::read(&cut, 0).unwrap());
|
||||
assert!(chars.iter().all(|&c| cmap.covers(c) == source_cmap.covers(c)));
|
||||
assert!(!cmap.covers('A') && !cmap.covers('漢'));
|
||||
assert!(features(table(&cut, b"GSUB").unwrap()).contains("vert"));
|
||||
assert!(cut.len() < source.len() / 20, "{} of {} bytes", cut.len(), source.len());
|
||||
assert_eq!(crate::subset::subset(&source, 0, &chars).unwrap(), cut);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_ui_serves_no_whole_cjk_face() {
|
||||
let scratch = Scratch::new("for-ui-serve");
|
||||
let dir = checkout(
|
||||
&scratch,
|
||||
&[
|
||||
("Noto Sans TC", "Hant,Latn", "", b"full".to_vec()),
|
||||
("Noto Sans TC UI", "Hant,Latn", "ui", b"source".to_vec()),
|
||||
("Noto Sans Arabic", "Arab", "", b"arabic".to_vec()),
|
||||
],
|
||||
);
|
||||
let served = scratch.0.join("served");
|
||||
let r = run(Embed::new().for_ui().serve(&served, "fonts/"), Some(&dir), None);
|
||||
assert_eq!(r.result.unwrap(), ["Noto Sans Arabic Regular"]);
|
||||
assert_eq!(std::fs::read(served.join("2.ttf")).unwrap(), b"arabic");
|
||||
assert!(!served.join("0.ttf").exists() && !served.join("1.ttf").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn served_faces_carry_the_characters_their_cmap_maps() {
|
||||
let font = tiny_font(&[(0x41, 0x5A, None), (0x4E00, 0x4E02, Some(&[5, 0, 7]))], &[(0x4E03, 0x4E03), (0x2_0000, 0x2_0001)]);
|
||||
let coverage = crate::coverage::of_face(&font, 0).unwrap();
|
||||
assert_eq!(Cmap::read(&font, 0).unwrap().ranges(), [(0x41, 0x5A), (0x4E00, 0x4E00), (0x4E02, 0x4E03), (0x2_0000, 0x2_0001)]);
|
||||
let covered: Vec<char> =
|
||||
"@AZ[一丁丂七丄\u{FFFF}\u{20000}\u{20001}\u{20002}".chars().filter(|&c| crate::coverage::contains(&coverage, c)).collect();
|
||||
assert_eq!(covered, ['A', 'Z', '一', '丂', '七', '\u{20000}', '\u{20001}']);
|
||||
assert!(!crate::coverage::contains(&[], 'A') && !crate::coverage::contains(&[0x80], 'A'));
|
||||
assert_eq!(crate::coverage::encode(&[(0, 0x10_FFFF)]), [0, 0xFF, 0xFF, 0x43]);
|
||||
|
||||
let scratch = Scratch::new("serve-coverage");
|
||||
let dir = checkout(&scratch, &[("Noto Sans", "Latn", "", font), ("Broken", "Latn", "", b"not a font".to_vec())]);
|
||||
let r = run(Embed::new().serve(scratch.0.join("served"), "fonts/"), Some(&dir), None);
|
||||
assert_eq!(r.result.as_ref().unwrap().len(), 2);
|
||||
let table = &r.table;
|
||||
// Gaps and lengths 0x41 0x19, 0x4DA5 0, 1 1, 0x1_B1FC 1 as LEB128.
|
||||
assert!(table.contains(r#"coverage: b"A\x19\xa5\x9b\x01\x00\x01\x01\xfc\xe3\x06\x01" }"#), "{table}");
|
||||
assert!(table.contains(r#"family: "Broken""#) && table.ends_with("coverage: b\"\" },\n]\n"), "{table}");
|
||||
assert!(warnings(&r).iter().any(|w| w.contains("no character map in fonts/1.ttf")), "{:?}", warnings(&r));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "craft-fonts-build-consumer"
|
||||
description = "Test crate: a build.rs that embeds the craft-fonts-build fixture the way an app does."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
craft-fonts-build = { path = "../.." }
|
||||
|
||||
[build-dependencies]
|
||||
craft-fonts-build = { path = "../.." }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Embeds the fixture checkout in `../fixture/craft-fonts` the way an app's build.rs embeds
|
||||
//! `CRAFT_FONTS_DIR`, so the generated tables are compiled by a real `include!`: once embedded and
|
||||
//! once served from `OUT_DIR/served`.
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default());
|
||||
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default());
|
||||
let fonts = || {
|
||||
craft_fonts_build::Embed::new()
|
||||
.dir(root.join("../fixture/craft-fonts"))
|
||||
.required(true)
|
||||
.scripts(&["Jpan", "Hant"])
|
||||
.filter(|face| face.style == "Regular")
|
||||
};
|
||||
let embedded = fonts().write("craft_fonts.rs");
|
||||
let served = fonts().serve(out.join("served"), "fonts/").write("craft_fonts_served.rs");
|
||||
if let Err(e) = embedded.and(served) {
|
||||
println!("cargo::error={e}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
//! The fixture faces as an app's crate sees them; read by `craft-fonts-build`'s tests.
|
||||
|
||||
use craft_fonts_build::{EmbeddedFace, ServedFace};
|
||||
|
||||
pub static CRAFT_FONTS: &[EmbeddedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
||||
|
||||
pub static CRAFT_FONTS_SERVED: &[ServedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts_served.rs"));
|
||||
@@ -0,0 +1,10 @@
|
||||
# Test fixture in the craft-fonts manifest format. The "font files" are short text files:
|
||||
# embedding copies bytes and never parses them.
|
||||
# family | style | file | scripts | licence | licence file | sha256 | source
|
||||
|
||||
Sans JP | Regular | fonts/sans-jp/SansJP-Regular.ttf | Jpan,Latn | OFL-1.1 | fonts/sans-jp/OFL.txt | c9d08ef1fc69f15e87ce52bda8a9c24714e4125daf7e50a40edb1aa2be10dff1 | https://example.invalid/sans-jp
|
||||
Sans JP | Bold | fonts/sans-jp/SansJP-Bold.ttf | Jpan,Latn | OFL-1.1 | fonts/sans-jp/OFL.txt | a135adabc1facd7a49fa4ef04771f74ffe69e67418e0e0f0b81a4cebba776e48 | https://example.invalid/sans-jp
|
||||
Sans Arabic | Regular | fonts/sans-arabic/SansArabic.ttf | Arab | OFL-1.1 | fonts/sans-arabic/OFL.txt | 318d7469dd372ff3ad458d46ea714c0ea969696675794d6100396f8a11103514 | https://example.invalid/sans-arabic
|
||||
Sans CJK TC | Regular | fonts/sans-cjk/SansCJK.ttc#2 | Hant,Latn | OFL-1.1 | fonts/sans-cjk/OFL.txt | 2fecde232d5dcda26a2299664dae1a2c50ad92b694575c5cfa70207010b339be | https://example.invalid/sans-cjk
|
||||
Sans CJK HK | Regular | fonts/sans-cjk/SansCJK.ttc#3 | Hant-HK,Latn | OFL-1.1 | fonts/sans-cjk/OFL.txt | 2fecde232d5dcda26a2299664dae1a2c50ad92b694575c5cfa70207010b339be | https://example.invalid/sans-cjk
|
||||
Sans Thai | Regular | fonts/sans-thai/SansThai.ttf | Thai | OFL-1.1 | fonts/sans-thai/OFL.txt | 5960276bcbad8e7949804d35651ebf42e17648f4d128c71ccdb76167a5337944 | https://example.invalid/sans-thai
|
||||
@@ -0,0 +1 @@
|
||||
Fixture licence text.
|
||||
@@ -0,0 +1 @@
|
||||
fixture: Sans Arabic
|
||||
@@ -0,0 +1 @@
|
||||
Fixture licence text.
|
||||
@@ -0,0 +1 @@
|
||||
fixture: Sans CJK collection
|
||||
@@ -0,0 +1 @@
|
||||
Fixture licence text.
|
||||
@@ -0,0 +1 @@
|
||||
fixture: Sans JP Bold
|
||||
@@ -0,0 +1 @@
|
||||
fixture: Sans JP Regular
|
||||
@@ -0,0 +1 @@
|
||||
Fixture licence text.
|
||||
@@ -0,0 +1 @@
|
||||
fixture: Sans Thai
|
||||
Binary file not shown.
Binary file not shown.
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["fonttools==4.67.0"]
|
||||
# ///
|
||||
"""Write the subsetting fixtures next to this script: Tiny-glyf.ttf (TrueType outlines) and
|
||||
Tiny-cff.otf (CFF outlines). Both map the same characters, have a `vert` feature that swaps
|
||||
U+300C for a vertical form, and draw every glyph as a square.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fontTools.feaLib.builder import addOpenTypeFeaturesFromString
|
||||
from fontTools.fontBuilder import FontBuilder
|
||||
from fontTools.pens.t2CharStringPen import T2CharStringPen
|
||||
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
CMAP = {
|
||||
0x20: "space",
|
||||
0x41: "A",
|
||||
0x3001: "uni3001",
|
||||
0x300C: "uni300C",
|
||||
0x3042: "uni3042",
|
||||
0x4E00: "uni4E00",
|
||||
0x4E01: "uni4E01",
|
||||
0x4E03: "uni4E03",
|
||||
0xFF01: "uniFF01",
|
||||
}
|
||||
GLYPHS = [".notdef", *CMAP.values(), "uni300C.vert"]
|
||||
FEATURES = "languagesystem DFLT dflt;\nfeature vert { sub uni300C by uni300C.vert; } vert;\n"
|
||||
# 2026-01-01, so the files are the same on every run.
|
||||
TIMESTAMP = 3_850_000_000
|
||||
|
||||
|
||||
def square(pen, size):
|
||||
pen.moveTo((100, 0))
|
||||
pen.lineTo((100, size))
|
||||
pen.lineTo((100 + size, size))
|
||||
pen.lineTo((100 + size, 0))
|
||||
pen.closePath()
|
||||
|
||||
|
||||
def build(cff: bool) -> FontBuilder:
|
||||
fb = FontBuilder(1000, isTTF=not cff)
|
||||
fb.setupGlyphOrder(GLYPHS)
|
||||
fb.setupCharacterMap(CMAP)
|
||||
if cff:
|
||||
charstrings = {}
|
||||
for i, name in enumerate(GLYPHS):
|
||||
pen = T2CharStringPen(1000, None)
|
||||
square(pen, 100 + 10 * i)
|
||||
charstrings[name] = pen.getCharString()
|
||||
fb.setupCFF("Tiny-Regular", {"FullName": "Tiny Regular"}, charstrings, {})
|
||||
else:
|
||||
glyphs = {}
|
||||
for i, name in enumerate(GLYPHS):
|
||||
pen = TTGlyphPen(None)
|
||||
square(pen, 100 + 10 * i)
|
||||
glyphs[name] = pen.glyph()
|
||||
fb.setupGlyf(glyphs)
|
||||
fb.setupHorizontalMetrics({name: (1000, 100) for name in GLYPHS})
|
||||
fb.setupHorizontalHeader(ascent=880, descent=-120)
|
||||
fb.setupNameTable({"familyName": "Tiny", "styleName": "Regular"})
|
||||
fb.setupOS2(sTypoAscender=880, sTypoDescender=-120, usWinAscent=880, usWinDescent=120)
|
||||
fb.setupPost()
|
||||
addOpenTypeFeaturesFromString(fb.font, FEATURES)
|
||||
fb.font["head"].created = TIMESTAMP
|
||||
fb.font["head"].modified = TIMESTAMP
|
||||
fb.font.recalcTimestamp = False
|
||||
return fb
|
||||
|
||||
|
||||
def main() -> None:
|
||||
build(cff=False).save(HERE / "Tiny-glyf.ttf")
|
||||
build(cff=True).save(HERE / "Tiny-cff.otf")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,33 @@
|
||||
//! The tables `craft-fonts-build-consumer`'s build.rs generated from the fixture compile and hold
|
||||
//! the selected faces' bytes.
|
||||
|
||||
use craft_fonts_build_consumer::CRAFT_FONTS;
|
||||
|
||||
#[test]
|
||||
fn generated_table_embeds_the_selected_faces() {
|
||||
let names: Vec<String> = CRAFT_FONTS.iter().map(|f| f.name()).collect();
|
||||
assert_eq!(names, ["Sans JP Regular", "Sans CJK TC Regular", "Sans CJK HK Regular"]);
|
||||
assert_eq!(CRAFT_FONTS[0].bytes, include_bytes!("fixture/craft-fonts/fonts/sans-jp/SansJP-Regular.ttf"));
|
||||
assert_eq!(CRAFT_FONTS[0].scripts, ["Jpan", "Latn"]);
|
||||
let indices: Vec<u32> = CRAFT_FONTS.iter().map(|f| f.index).collect();
|
||||
assert_eq!(indices, [0, 2, 3]);
|
||||
assert_eq!(CRAFT_FONTS[2].bytes, include_bytes!("fixture/craft-fonts/fonts/sans-cjk/SansCJK.ttc"));
|
||||
assert!(CRAFT_FONTS[2].has_script("Hant"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn served_table_names_urls_instead_of_bytes() {
|
||||
use craft_fonts_build_consumer::CRAFT_FONTS_SERVED;
|
||||
let faces: Vec<(String, &str, u64)> = CRAFT_FONTS_SERVED.iter().map(|f| (f.name(), f.url, f.size)).collect();
|
||||
assert_eq!(
|
||||
faces,
|
||||
[
|
||||
("Sans JP Regular".to_owned(), "fonts/sans-jp/SansJP-Regular.ttf", 24),
|
||||
("Sans CJK TC Regular".to_owned(), "fonts/sans-cjk/SansCJK.ttc", 28),
|
||||
("Sans CJK HK Regular".to_owned(), "fonts/sans-cjk/SansCJK.ttc", 28),
|
||||
]
|
||||
);
|
||||
assert_eq!(CRAFT_FONTS_SERVED[2].index, 3);
|
||||
assert!(CRAFT_FONTS_SERVED[2].has_script("Hant") && CRAFT_FONTS_SERVED[2].is_cjk());
|
||||
assert!(CRAFT_FONTS_SERVED[0].integrity.starts_with("sha256-"));
|
||||
}
|
||||
+28
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "craft-i18n"
|
||||
description = "Translations for the Craft apps: the embedded craft-i18n catalogs, lookup with fallback, plurals, language names and the system locale."
|
||||
description = "Translations for the Craft apps: the embedded craft-i18n catalogs, lookup with fallback, plurals, numbers for a region, language names, the system locale, search folding and locale sorting."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -8,14 +8,39 @@ rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
intl_pluralrules = "=7.0.2"
|
||||
unic-langid = "0.9"
|
||||
# Numbers and plural rules, on CLDR data (data/numbers.postcard) generated for one ICU4X minor
|
||||
# version, hence `~2.3`.
|
||||
fixed_decimal = { version = "0.7.2", default-features = false }
|
||||
icu_decimal = { version = "~2.3", default-features = false, features = ["serde"] }
|
||||
icu_locale_core = { version = "~2.3", default-features = false }
|
||||
icu_locale_fallback = { version = "~2.3", default-features = false, features = ["compiled_data"] }
|
||||
icu_plurals = { version = "~2.3", default-features = false, features = ["serde"] }
|
||||
icu_provider = { version = "~2.3", default-features = false, features = ["sync"] }
|
||||
icu_provider_adapters = { version = "~2.3", default-features = false }
|
||||
icu_provider_blob = { version = "~2.3", default-features = false }
|
||||
writeable = "0.6"
|
||||
log = "0.4"
|
||||
sys-locale = "0.3"
|
||||
icu_normalizer = "~2.3"
|
||||
icu_properties = "~2.3"
|
||||
# Generated data fits one ICU4X minor version; data/generate-collation.sh names its datagen.
|
||||
icu_collator = { version = "~2.3.1", default-features = false, features = ["serde"], optional = true }
|
||||
unicode-bidi = "0.3"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
sys-locale = { version = "0.3", features = ["js"] }
|
||||
|
||||
# The regional format (Region::system); safe registry reads, MIT OR Apache-2.0.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-registry = "0.6"
|
||||
|
||||
# The current locale (Region::system); already in the apps' Cargo.lock through eframe.
|
||||
[target.'cfg(target_vendor = "apple")'.dependencies]
|
||||
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSDateFormatter", "NSFormatter", "NSLocale", "NSString"] }
|
||||
|
||||
[features]
|
||||
# Locale sorting (`collate`), with ICU4X collation data for the suite's locales embedded.
|
||||
collate = ["dep:icu_collator", "icu_provider/serde", "icu_provider_blob/alloc"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+78
-6
@@ -45,10 +45,21 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l
|
||||
→ `pt`, `zh-hant-hk` → `zh-hant`); `sr-latn` never falls back to Cyrillic `sr`. A missing
|
||||
translation never errors.
|
||||
- **Lookups:** `t` (plain), `tc` (with a context such as `menu` or `@msg`, else plain), `tid` (an
|
||||
`@id` command id, else the English label), `trn` (a count, `@plural` rows, `{n}` filled), `get`
|
||||
(one context along the locale chain, so `pt-br` can return a `pt` row, but never the plain row
|
||||
`@id` command id, else the English label), `trn` and `trn_decimal` (a count, `@plural` rows,
|
||||
`{n}` filled), `get` (one context along the locale chain, so `pt-br` can return a `pt` row, but never the plain row
|
||||
or English: a key cap's name under `key`, or "is there a translation?"), and `fmt` for `{name}`
|
||||
placeholders, filled in one pass.
|
||||
- **Isolation:** when a message or a value inserted into it has a right-to-left letter (bidi
|
||||
class R or AL), or a value has a bidi control (LRE, RLE, PDF, LRO, RLO, LRI, RLI, FSI, PDI,
|
||||
LRM, RLM, ALM), `fmt` wraps each inserted value in a first-strong isolate (FSI…PDI), and `trn`,
|
||||
`file_size` and `measure` wrap `{n}` in a left-to-right one (LRI…PDI), so a Latin file name in an Arabic sentence, a
|
||||
Hebrew name in an English one, or an ASCII file name with a right-to-left override can't
|
||||
reorder the words around it. Embeddings, overrides and isolates a value opens are closed inside
|
||||
its isolate, and a stray PDF or PDI in it is dropped. Wrap numbers, paths and codes in `ltr`
|
||||
before passing them; a value that already is one isolate keeps it. Other text comes out
|
||||
without marks, as if `fmt` didn't isolate at all. egui draws the marks as nothing. For text
|
||||
that leaves the interface (file names, logs, terminals, text parsed later), use `fmt_plain` or
|
||||
`strip_isolates`.
|
||||
- **Bad rows** (malformed, an unknown `@` context, a wrong plural form count, a row defined twice,
|
||||
of which the first is kept) and bad locales (`en`, `pseudo`, a code embedded twice in any case)
|
||||
are skipped; craft-i18n's CI rejects them before they ship. `I18n::new` parses nothing: it
|
||||
@@ -56,10 +67,13 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l
|
||||
`log::warn!`) on the first lookup that reaches it, including as a fallback. `I18n::check_all()`
|
||||
parses every locale and returns all problems (`<locale>/<common|app>: line N: …`), for a test.
|
||||
- **Plurals:** forms are listed in CLDR order (zero, one, two, few, many, other) for the categories
|
||||
whole numbers 0 to 1000 reach, from `intl_pluralrules`; a count in any other category takes the
|
||||
last form. CLDR's `pt` is Brazilian, so `pt` uses `pt-PT`'s rules (0 is plural) and `pt-br`
|
||||
`pt`'s. `intl_pluralrules` is pinned to the version craft-i18n's checker uses, and
|
||||
`plural_form_counts` holds the same counts.
|
||||
whole numbers 0 to 1000 reach, from ICU4X's CLDR rules; `plural_form_counts` holds the counts
|
||||
craft-i18n's checker expects. `trn_decimal` picks the form from the digits shown (ICU4X plural
|
||||
operands), so English `1.0` is plural and French `1,5` singular. A count in a category integers
|
||||
never reach takes the `few` form if there is one (Russian and Ukrainian decimals, `1,5 файла`),
|
||||
else the last. Rules are per language, so `sr-latn` and `zh-hant` take `sr`'s and `zh`'s. CLDR's
|
||||
`pt` is Brazilian, so `pt` uses `pt-PT`'s rules (0 is plural) and `pt-br` `pt`'s. A language
|
||||
CLDR has no plural rules for shows English forms.
|
||||
- **Names:** a language's name comes from the app catalog's `@id language.name` row, else the
|
||||
shared catalog's, else its code; English is "English".
|
||||
- **Scripts and direction:** for any language tag, `scripts` lists the ISO 15924 scripts its
|
||||
@@ -68,13 +82,71 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l
|
||||
gives the regional forms of its Han characters for picking a CJK face: `zh-CN`/`zh-SG`/`zh-Hans` →
|
||||
SC, `zh-TW`/`zh-US`/`zh-Hant` → TC, `zh-HK`/`zh-MO` → HK, `ja` → JP, `ko` → KR. `I18n::rtl` is
|
||||
`direction` of the language's code; putting text into display order is the app's job.
|
||||
- **Reading numbers:** `Format::parse(text)` reads what the format writes (its digits, decimal
|
||||
and grouping separators, the system's included) and what people type or paste: any Unicode
|
||||
decimal digits (ASCII, Arabic-Indic, Persian, Devanagari, Bengali, Thai, full-width, …) from one
|
||||
set; `٫` as a decimal mark everywhere; grouping by the locale's other mark of `.` and `,`, spaces
|
||||
(NBSP, narrow NBSP), `'`, `’` or `٬`, in threes or the Indian 2-2-3; a leading `-`, `−` or `+`;
|
||||
bidi marks ignored. A lone `.` in a comma locale is decimal when it can't be grouping (`1.5`,
|
||||
`0.250`, `1234.5`), while `1.234` stays 1234 in German; the same holds for `,` in a point
|
||||
locale. Percent signs and units (`50 %`, `12 px`) are not read: strip them first. Other input
|
||||
that doesn't fit is an error, never a guess. `parse_number(text, locale)` is the same for
|
||||
`Format::from_tag(locale)`.
|
||||
- **System locale:** `system_lang` matches the system's list once and caches it: on desktop Unix
|
||||
`LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG`, read raw; elsewhere `sys-locale`'s (macOS and
|
||||
Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → `zh-hant`,
|
||||
`pt-PT` → `pt-br` when that is the only Portuguese, `C`/`POSIX` → English. A script is never
|
||||
crossed: `sr_RS@latin` and `sr-Latn-RS` pick `sr-latn`, never `sr`, while `sr-Cyrl-RS` picks `sr`.
|
||||
A region that implies a script counts as that script: `pa-PK` picks `pa-arab`, not Gurmukhi `pa`.
|
||||
- **Sizes and units:** `file_size(lang, bytes, style)` writes `1.5 MB` or `512 bytes`; `FileSize`
|
||||
picks steps of 1024 named KB (`BINARY`), 1024 named KiB (`IEC`) or 1000 named KB (`DECIMAL`),
|
||||
the decimals per unit, no decimals from a given value up, the smallest and largest unit (below
|
||||
the smallest, sizes round up), and `B` or words for bytes. `measure(lang, value, decimals,
|
||||
"{n} px")` writes any other measure. Unit names are rows in context `unit`, keyed by the English
|
||||
template (`{n} MB` → `{n} Mo`, `{n}%` → `{n} %`) and read with `get`, so a plain `B` row (bold)
|
||||
is never used; bytes in words are the `@plural` row `{n} byte|{n} bytes`. Numbers are written
|
||||
by `I18n::format`.
|
||||
- **Numbers:** `Format` writes numbers for a `Region` on ICU4X's decimal formatter: `integer`
|
||||
(`1.234.567`, Indian `12,34,567`), `number(value, decimals)` and `percent(ratio, decimals)`
|
||||
(`50 %`, Turkish `%50`). It follows the region, not the interface language, with the locale's
|
||||
digits (`١٬٢٣٤٫٥` in Egypt, `۱٬۲۳۴` in Iran) or, with `Digits::Latin`, `0`–`9`. With Latin
|
||||
digits, a decimal or grouping separator the system sets (Windows' and macOS's own settings)
|
||||
replaces the locale's. `I18n::format` is the format `trn`, `file_size` and `measure` use: the
|
||||
system region's until `I18n::set_format` (the user's region setting or digit choice), and again
|
||||
after `I18n::reset_format`. A count in `trn` is therefore grouped and in the region's digits
|
||||
(`1.234 Dateien`, `١٬٢٣٤ ملفًا`); for names written into documents or files (`Copy 1234`), use
|
||||
`trn_plain`, which writes `0`–`9` without grouping or isolate marks.
|
||||
- **CLDR data:** decimal symbols, digits and plural rules (`data/numbers.postcard`, about 10 KB) and
|
||||
percent patterns (`data/locales.rs`) cover every CLDR locale; `data/generate.sh` rebuilds both
|
||||
from CLDR. The ICU4X crates are pinned to one minor version (`~2.3`), the one the data is
|
||||
generated for. The ICU4X code and data add about 90 KB to a stripped release build and 80 KB to
|
||||
wasm.
|
||||
- **Formatting region:** `Region` holds how numbers, times and measurements are written, apart
|
||||
from the interface language: the number and time locales (BCP 47), metric or US measurement, A4
|
||||
or Letter, and the decimal and grouping separators (or no grouping) and clock when the system
|
||||
sets them; otherwise the number locale's CLDR data decides. `Region::system()` reads it once:
|
||||
Windows' regional format with the user's changes (`HKCU\Control Panel\International`), Apple's
|
||||
current locale (`en_PL`, `en_GB@rg=uszzzz`), `LC_ALL` or else `LC_NUMERIC`, `LC_TIME`,
|
||||
`LC_MEASUREMENT` and `LC_PAPER` each on its own over `LANG` on Unix (`C` there is English on A4,
|
||||
metric, with a `.` decimal and no grouping; `LANG=C` gives way to the preferred language), the
|
||||
preferred language on the web and Android. `Region::from_setting` takes the app's saved choice
|
||||
(`auto` or a tag like `pl-PL`). Paper and measurement follow CLDR's territory lists; tags may
|
||||
carry `-u-ms-`, `-u-hc-`, `-u-rg-` and `-u-nu-` (Apple's `@numbers=`), and a chosen digit
|
||||
system stays in the number locale.
|
||||
Ported from WordCraft's `Region`.
|
||||
- **Pseudo locale** (`pseudo`, debug builds only, never in `languages()`): every lookup, `get`
|
||||
included, comes back as `⟦…⟧`, line by line, so text drawn without the marks never went through
|
||||
translation. `looked_up()` lists the source texts looked up so far, for an audit against a
|
||||
complete catalog.
|
||||
- **Search** (`Folder`): `Folder::new(locale).fold(text)` gives a key for case- and
|
||||
accent-insensitive matching (`keep_accents()` makes accents count) and a map back to the original
|
||||
text, so `Folded::find` returns the byte range to highlight. Compatibility forms fold to their
|
||||
plain letters (`fi`, full-width `A`), `ß` to `ss`, `ς` to `σ`, digits of every script to ASCII
|
||||
(`Layer ١٢` finds `layer 12`); ignoring accents, Persian and Arabic letter forms match (`ک` and
|
||||
`ك`, `ی` and `ي`, `ة` and `ه`, `أ` and `ا`); invisible format characters (soft hyphens, joiners,
|
||||
bidi isolates) are dropped. Turkish and Azerbaijani fold `I` to `ı` when accents count; ignoring
|
||||
accents, `I`, `i`, `İ` and `ı` all match.
|
||||
- **Sorting** (`Collator`, feature `collate`): ICU4X collation for the locales craft-i18n ships,
|
||||
Persian and Hindi, with digits compared by value (`Layer 2` before `Layer 10`) and `-u-co-pinyin`
|
||||
/ `-u-co-stroke` for Chinese. The data (754 KB) is regenerated by `data/generate-collation.sh`;
|
||||
the feature adds about 1 MB to a release binary or wasm module (320 KB gzipped).
|
||||
|
||||
Binary file not shown.
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# Regenerates the collation data `collate` embeds, a postcard blob for the locales craft-i18n
|
||||
# ships (its `locales/` directory) plus English, Persian and Hindi: each locale's default order,
|
||||
# and Chinese pinyin and stroke orders.
|
||||
# Needs icu4x-datagen at the version of the icu_collator dependency:
|
||||
# cargo install icu4x-datagen --version 2.3.0 --locked
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
icu4x-datagen --format blob --overwrite --out collation.postcard --deduplication maximal \
|
||||
--cldr-tag 48.2.1 --icuexport-tag release-78.1rc \
|
||||
--attribute-filter 'collator=/^(stroke|pinyin)?$/' \
|
||||
--markers CollationRootV1 CollationTailoringV1 CollationDiacriticsV1 CollationJamoV1 \
|
||||
CollationMetadataV1 CollationReorderingV1 CollationSpecialPrimariesV1 \
|
||||
--locales en ar bg cs da de el es et fa fi fr hi hr hu id it ja ka kk ko lt nl pl pt-BR ru sl \
|
||||
sr sr-Latn sv te tr uk vi zh-Hans zh-Hant
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates the number and plural data in this directory from Unicode CLDR, for every CLDR
|
||||
# locale: 10 KB, of which CLDR's modern-coverage locales alone would be 7 KB. Lookups fall back
|
||||
# at run time, so a locale whose data matches its parent's is left out.
|
||||
#
|
||||
# Needs `cargo install icu4x-datagen --version 2.3.0` (the ICU4X minor version in Cargo.toml)
|
||||
# and python3. Downloads CLDR's JSON release once into $CACHE.
|
||||
set -euo pipefail
|
||||
|
||||
CLDR=48.2.1
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
CACHE=${CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/craft-i18n-data}
|
||||
mkdir -p "$CACHE"
|
||||
|
||||
TMPDIR=$CACHE icu4x-datagen --format blob --overwrite \
|
||||
--cldr-tag "$CLDR" \
|
||||
--markers DecimalSymbolsV1 DecimalDigitsV1 PluralsCardinalV1 \
|
||||
--locales full --deduplication maximal \
|
||||
--out "$HERE/numbers.postcard"
|
||||
|
||||
ZIP=$CACHE/icu4x-source-cache/github.com/unicode-org/cldr-json/releases/download/$CLDR/cldr-$CLDR-json-full.zip
|
||||
python3 -I "$HERE/locales.py" "$ZIP" "$CLDR" > "$HERE/locales.rs.tmp"
|
||||
mv "$HERE/locales.rs.tmp" "$HERE/locales.rs"
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Writes locales.rs from CLDR's JSON release: the languages with plural rules and every locale's
|
||||
percent affixes.
|
||||
|
||||
Usage: locales.py <cldr-json-full.zip> <cldr version>
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
|
||||
def rust(s):
|
||||
return '"' + "".join(c if " " <= c <= "~" and c not in '"\\' else f"\\u{{{ord(c):x}}}" for c in s) + '"'
|
||||
|
||||
|
||||
def affixes(numbers, numsys):
|
||||
pattern = numbers[f"percentFormats-numberSystem-{numsys}"]["standard"].split(";")[0].replace("'", "")
|
||||
sign = numbers[f"symbols-numberSystem-{numsys}"]["percentSign"]
|
||||
number = re.search(r"[#0,.]+", pattern)
|
||||
return pattern[: number.start()].replace("%", sign), pattern[number.end() :].replace("%", sign)
|
||||
|
||||
|
||||
def main():
|
||||
path, version = sys.argv[1], sys.argv[2]
|
||||
found = {}
|
||||
with zipfile.ZipFile(path) as z:
|
||||
plurals = json.loads(z.read("cldr-core/supplemental/plurals.json"))["supplemental"]["plurals-type-cardinal"]
|
||||
languages = sorted(l for l in plurals if l != "root" and "-" not in l)
|
||||
for name in z.namelist():
|
||||
m = re.fullmatch(r"cldr-numbers-full/main/([^/]+)/numbers\.json", name)
|
||||
if not m or m.group(1) == "root":
|
||||
continue
|
||||
locale = m.group(1)
|
||||
numbers = json.loads(z.read(name))["main"][locale]["numbers"]
|
||||
found[(locale, True)] = affixes(numbers, "latn")
|
||||
default = numbers["defaultNumberingSystem"]
|
||||
if default != "latn":
|
||||
found[(locale, False)] = affixes(numbers, default)
|
||||
|
||||
# Kept only where the lookup by dropping trailing subtags would find something else.
|
||||
kept = {}
|
||||
for (locale, latin), value in sorted(found.items(), key=lambda e: (e[0][0].count("-"), e[0])):
|
||||
parts = locale.split("-")
|
||||
inherited = ("", "%")
|
||||
for n in range(len(parts) - 1, 0, -1):
|
||||
if ("-".join(parts[:n]), latin) in kept:
|
||||
inherited = kept[("-".join(parts[:n]), latin)]
|
||||
break
|
||||
if value != inherited:
|
||||
kept[(locale, latin)] = value
|
||||
|
||||
print(f"// @generated by generate.sh from Unicode CLDR {version} (Unicode License).")
|
||||
print()
|
||||
print("/// The languages CLDR has plural rules for; the baked data has them all.")
|
||||
print("pub(crate) const PLURAL_LANGUAGES: &[&str] = &[")
|
||||
for i in range(0, len(languages), 16):
|
||||
print(" " + ", ".join(rust(l) for l in languages[i : i + 16]) + ",")
|
||||
print("];")
|
||||
print()
|
||||
print("/// Percent signs around the number (CLDR's standard percent pattern and sign): locale, Latin")
|
||||
print("/// digits or the locale's own, text before the number, text after it. A locale missing here")
|
||||
print("/// takes its parent's by dropping trailing subtags, then `{n}%`.")
|
||||
print("pub(crate) const PERCENT: &[(&str, bool, &str, &str)] = &[")
|
||||
for (locale, latin), (before, after) in sorted(kept.items()):
|
||||
print(f" ({rust(locale)}, {str(latin).lower()}, {rust(before)}, {rust(after)}),")
|
||||
print("];")
|
||||
|
||||
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user