diff --git a/.agents/skills/build-cadmus-native/SKILL.md b/.agents/skills/build-cadmus-native/SKILL.md index 58845f4a..9cba1d9e 100644 --- a/.agents/skills/build-cadmus-native/SKILL.md +++ b/.agents/skills/build-cadmus-native/SKILL.md @@ -29,14 +29,17 @@ it when documentation sources change. ## One-time setup -### Download runtime assets (optional) +### Runtime assets + +**Required for Kobo builds** — run both before `cargo xtask build-kobo`: ```bash cargo xtask download-assets +cargo xtask download-fonts ``` -Pulls static assets (fonts, icons, etc.) from the latest GitHub release. Not -strictly required for compilation, but the emulator and some tests expect them. +`download-assets` pulls Plato runtime directories (`bin/`, `resources/`, +`hyphenation-patterns/`). `download-fonts` assembles the `fonts/`. Native dependencies (MuPDF, libwebp, and the C wrapper) are now built automatically by `build.rs` when you run any Cargo command that compiles diff --git a/.github/actions/build-kobo/action.yml b/.github/actions/build-kobo/action.yml index a2bea6fd..a3b108d1 100644 --- a/.github/actions/build-kobo/action.yml +++ b/.github/actions/build-kobo/action.yml @@ -49,6 +49,11 @@ runs: - name: Cache Plato static assets uses: ./.github/actions/cache-assets + - name: Cache bundled fonts + uses: ./.github/actions/cache-fonts + with: + gh-token: ${{ inputs.gh-token }} + - name: Install system packages uses: ./.github/actions/apt diff --git a/.github/actions/cache-fonts/action.yml b/.github/actions/cache-fonts/action.yml new file mode 100644 index 00000000..225870f5 --- /dev/null +++ b/.github/actions/cache-fonts/action.yml @@ -0,0 +1,56 @@ +name: Cache bundled fonts +description: > + Restore or save the assembled fonts/ directory. The cache key incorporates + pinned release versions from download_fonts.rs and the gitlink SHAs of the + Noto and Google font submodules so the key changes when any font source + moves. On cache miss, downloads fonts via cargo xtask download-fonts. + + Defaults to restore-only. Pass `mode: save` in the cache-warmup job to + write a fresh cache. + +inputs: + gh-token: + description: "GitHub token for downloading release assets" + required: true + mode: + description: "`restore` (default) or `save` (read-write)" + required: false + default: restore + +outputs: + cache-hit: + description: "Whether a matching cache entry was found" + value: ${{ steps.save.outputs.cache-hit || steps.restore.outputs.cache-hit }} + +runs: + using: composite + steps: + - name: Compute font submodule hash + id: font-submodules + shell: bash + run: | + sha=$(git ls-tree HEAD thirdparty/noto-fonts thirdparty/google-fonts | sha256sum | cut -c1-12) + echo "hash=$sha" >> "$GITHUB_OUTPUT" + + - name: Restore bundled fonts cache + if: inputs.mode == 'restore' + id: restore + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: fonts/ + key: fonts-${{ hashFiles('xtask/src/tasks/download_fonts/**') }}-${{ steps.font-submodules.outputs.hash }} + + - name: Save bundled fonts cache + if: inputs.mode == 'save' + id: save + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: fonts/ + key: fonts-${{ hashFiles('xtask/src/tasks/download_fonts/**') }}-${{ steps.font-submodules.outputs.hash }} + + - name: Download bundled fonts + if: steps.restore.outputs.cache-hit != 'true' && steps.save.outputs.cache-hit != 'true' + shell: bash + env: + GH_TOKEN: ${{ inputs.gh-token }} + run: cargo xtask download-fonts diff --git a/.github/workflows/cargo.yml b/.github/workflows/cargo.yml index f8f77eab..e902e56b 100644 --- a/.github/workflows/cargo.yml +++ b/.github/workflows/cargo.yml @@ -43,6 +43,7 @@ jobs: - '.github/actions/build-kobo/**' - '.github/actions/build-docs-epub/**' - '.github/actions/cargo-cache/**' + - '.github/actions/cache-fonts/**' - '.github/actions/install-doc-tools/**' - 'build-scripts/**' - '.gitmodules' @@ -186,6 +187,11 @@ jobs: if: steps.cargo-llvm-cov-cache.outputs.cache-hit != 'true' uses: taiki-e/install-action@9458fed45835344deb5e69e06e831be047c96abf # cargo-llvm-cov + - name: Cache bundled fonts + uses: ./.github/actions/cache-fonts + with: + mode: save + gh-token: ${{ secrets.GITHUB_TOKEN }} - name: Download documentation EPUB if: steps.cache.outputs.cache-hit != 'true' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 @@ -361,6 +367,11 @@ jobs: if: runner.os == 'macOS' run: brew install sdl2 freetype harfbuzz openjpeg jpeg-turbo libpng jbig2dec gumbo-parser djvulibre pkg-config + - name: Restore bundled fonts + uses: ./.github/actions/cache-fonts + with: + gh-token: ${{ secrets.GITHUB_TOKEN }} + - name: Restore shared cargo cache uses: ./.github/actions/cargo-cache with: diff --git a/.gitignore b/.gitignore index ef27cb96..27727aab 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ /css/*-user.css /hyphenation-patterns /dictionaries +/fonts /dist /bundle docs/book/ diff --git a/.gitmodules b/.gitmodules index f82768a8..ba675a3c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -54,3 +54,13 @@ path = thirdparty/mdbook-i18n-helpers url = https://github.com/OGKevin/mdbook-i18n-helpers.git branch = ogkevin/push/qsonrqyvotux +[submodule "thirdparty/noto-fonts"] + path = thirdparty/noto-fonts + url = https://github.com/notofonts/noto-fonts + branch = v20201206-phase3 + shallow = true +[submodule "thirdparty/google-fonts"] + path = thirdparty/google-fonts + url = https://github.com/google/fonts + branch = main + shallow = true diff --git a/contrib/Settings-sample.toml b/contrib/Settings-sample.toml index 7567c248..1513a424 100644 --- a/contrib/Settings-sample.toml +++ b/contrib/Settings-sample.toml @@ -166,7 +166,7 @@ corner-width = 0.4 # Note: this does not exist by default. font-path = "/mnt/onboard/fonts" # The default serif font. -font-family = "Libertinus Serif" +font-family = "Libron" # The default, minimum and maximum font sizes, in points. font-size = 11.0 min-font-size = 5.5 diff --git a/crates/build-deps/src/markers.rs b/crates/build-deps/src/markers.rs index 6074fae6..7acff102 100644 --- a/crates/build-deps/src/markers.rs +++ b/crates/build-deps/src/markers.rs @@ -90,9 +90,27 @@ pub fn is_built(root: &Path, dir: &Path, submodule_path: &str) -> bool { pub fn mark_built(root: &Path, dir: &Path, name: &str, submodule_path: &str) -> Result<()> { let hash = submodule_commit(root, submodule_path) .with_context(|| format!("failed to resolve submodule commit for {submodule_path}"))?; - let marker_path = dir.join(BUILT_MARKER); - std::fs::write(&marker_path, hash.as_bytes()) - .with_context(|| format!("failed to write build marker for {name}"))?; + mark_version(dir, name, &hash) +} + +/// Returns `true` when `dir` has a `.built` marker whose content matches +/// `expected`. +/// +/// Use this for pinned release tags or other version strings when there is no +/// submodule gitlink to compare against. +pub fn is_version_current(dir: &Path, expected: &str) -> bool { + std::fs::read_to_string(built_marker_path(dir)).is_ok_and(|stored| stored.trim() == expected) +} + +/// Write a `.built` marker in `dir` with `version`, recording that `name` +/// is up to date at that revision. +/// +/// # Errors +/// +/// Returns an error if the marker file cannot be written. +pub fn mark_version(dir: &Path, name: &str, version: &str) -> Result<()> { + std::fs::write(built_marker_path(dir), version.as_bytes()) + .with_context(|| format!("failed to write version marker for {name}"))?; Ok(()) } diff --git a/crates/core/src/document/html/engine.rs b/crates/core/src/document/html/engine.rs index 1ddda9ce..5c1281dd 100644 --- a/crates/core/src/document/html/engine.rs +++ b/crates/core/src/document/html/engine.rs @@ -34,7 +34,7 @@ use paragraph_breaker::{standard_fit, total_fit}; use percent_encoding::percent_decode_str; use septem::Roman; use std::convert::TryFrom; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use xi_unicode::LineBreakIterator; @@ -102,16 +102,18 @@ impl Engine { self.hyphenation_patterns.clone().unwrap() } + /// Lazily loads [`build_default_reader_fonts`] from the install `fonts/` directory. #[inline] pub fn load_fonts(&mut self) { if self.fonts.is_none() { - self.fonts = build_fonts(&self.install_dir).ok(); + self.fonts = build_default_reader_fonts(&self.install_dir).ok(); } } + /// Lazily loads [`build_default_reader_fonts`] from `root_dir/fonts/`. pub fn load_fonts_from(&mut self, root_dir: std::path::PathBuf) { if self.fonts.is_none() { - self.fonts = build_fonts(&root_dir).ok(); + self.fonts = build_default_reader_fonts(&root_dir).ok(); } } @@ -142,8 +144,14 @@ impl Engine { self.text_align = text_align; } + /// Sets the reading font from `family_name`, searching bundled install fonts + /// before `search_path`. pub fn set_font_family(&mut self, family_name: &str, search_path: &str) { - if let Ok(serif_family) = FontFamily::from_name(family_name, search_path) { + let install_fonts = self.install_dir.join("fonts"); + if let Ok(serif_family) = FontFamily::from_name_in_paths( + family_name, + &[install_fonts.as_path(), Path::new(search_path)], + ) { self.load_fonts(); if let Some(fonts) = self.fonts.as_mut() { fonts.serif = serif_family; @@ -2461,21 +2469,26 @@ fn format_list_prefix(kind: ListStyleType, index: usize) -> Option { } } -fn build_fonts(install_dir: &std::path::Path) -> Result { +/// Loads bundled default stacks for CSS generic font families (`serif`, +/// `sans-serif`, `monospace`, `cursive`, `fantasy`) used by the HTML reader. +/// +/// User-selected reading fonts replace only the `serif` stack via +/// [`Engine::set_font_family`]. +fn build_default_reader_fonts(install_dir: &std::path::Path) -> Result { let opener = FontOpener::new()?; let font_path = |name: &str| -> std::path::PathBuf { install_dir.join("fonts").join(name) }; let mut fonts = Fonts { serif: FontFamily { - regular: opener.open(font_path("LibertinusSerif-Regular.otf").as_path())?, - italic: opener.open(font_path("LibertinusSerif-Italic.otf").as_path())?, - bold: opener.open(font_path("LibertinusSerif-Bold.otf").as_path())?, - bold_italic: opener.open(font_path("LibertinusSerif-BoldItalic.otf").as_path())?, + regular: opener.open(font_path("Libron-Regular.ttf").as_path())?, + italic: opener.open(font_path("Libron-Italic.ttf").as_path())?, + bold: opener.open(font_path("Libron-Bold.ttf").as_path())?, + bold_italic: opener.open(font_path("Libron-BoldItalic.ttf").as_path())?, }, sans_serif: FontFamily { - regular: opener.open(font_path("NotoSans-Regular.ttf").as_path())?, - italic: opener.open(font_path("NotoSans-Italic.ttf").as_path())?, - bold: opener.open(font_path("NotoSans-Bold.ttf").as_path())?, - bold_italic: opener.open(font_path("NotoSans-BoldItalic.ttf").as_path())?, + regular: opener.open(font_path("NV_Jost-Regular.ttf").as_path())?, + italic: opener.open(font_path("NV_Jost-Italic.ttf").as_path())?, + bold: opener.open(font_path("NV_Jost-Bold.ttf").as_path())?, + bold_italic: opener.open(font_path("NV_Jost-BoldItalic.ttf").as_path())?, }, monospace: FontFamily { regular: opener.open(font_path("SourceCodeVariable-Roman.otf").as_path())?, diff --git a/crates/core/src/font/mod.rs b/crates/core/src/font/mod.rs index 75733e82..c6cc356d 100644 --- a/crates/core/src/font/mod.rs +++ b/crates/core/src/font/mod.rs @@ -596,15 +596,39 @@ pub fn family_names>(search_path: P) -> Result, } impl FontFamily { + /// Resolves `family_name` by searching `search_paths` in order. + /// + /// # Errors + /// + /// Returns an error when `search_paths` is empty or the family cannot be + /// found in any path. + pub fn from_name_in_paths( + family_name: &str, + search_paths: &[impl AsRef], + ) -> Result { + let mut last_err = None; + for path in search_paths { + match Self::from_name_in_directory(family_name, path.as_ref()) { + Ok(family) => return Ok(family), + Err(err) => last_err = Some(err), + } + } + Err(last_err.unwrap_or_else(|| format_err!("no font search paths provided"))) + } + pub fn from_name>( family_name: &str, search_path: P, ) -> Result { + Self::from_name_in_paths(family_name, &[search_path.as_ref()]) + } + + fn from_name_in_directory(family_name: &str, search_path: &Path) -> Result { let opener = FontOpener::new()?; let glob = Glob::new("**/*.[ot]tf")?.compile_matcher(); let mut styles = FxHashMap::default(); - for entry in WalkDir::new(search_path.as_ref()) + for entry in WalkDir::new(search_path) .min_depth(1) .into_iter() .filter_entry(|e| !e.is_hidden()) @@ -2777,3 +2801,38 @@ impl From for FreetypeError { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn workspace_fonts_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fonts") + } + + #[test] + fn from_name_in_paths_finds_family_in_later_path() { + let fonts = workspace_fonts_dir(); + if !fonts.join("Libron-Regular.ttf").exists() { + return; + } + let empty = tempfile::tempdir().unwrap(); + FontFamily::from_name_in_paths("Libron", &[empty.path(), fonts.as_path()]).unwrap(); + } + + #[test] + fn from_name_in_paths_errors_when_paths_empty() { + let result = FontFamily::from_name_in_paths("Libron", &[] as &[&Path]); + assert!(result.is_err()); + } + + #[test] + fn from_name_delegates_to_from_name_in_paths() { + let fonts = workspace_fonts_dir(); + if !fonts.join("Libron-Regular.ttf").exists() { + return; + } + FontFamily::from_name("Libron", &fonts).unwrap(); + } +} diff --git a/crates/core/src/ota/cleanup.rs b/crates/core/src/ota/cleanup.rs index 8029d4a2..1d2ff871 100644 --- a/crates/core/src/ota/cleanup.rs +++ b/crates/core/src/ota/cleanup.rs @@ -83,7 +83,7 @@ mod tests { std::fs::create_dir_all(install_dir.join("icons")).unwrap(); std::fs::create_dir_all(install_dir.join("libs")).unwrap(); - std::fs::write(install_dir.join("fonts/NotoSans-Regular.ttf"), b"owned").unwrap(); + std::fs::write(install_dir.join("fonts/Libron-Regular.ttf"), b"owned").unwrap(); std::fs::write(install_dir.join("fonts/custom.ttf"), b"user").unwrap(); std::fs::write(install_dir.join("icons/home.svg"), b"owned").unwrap(); std::fs::write(install_dir.join("libs/libfoo.so.1"), b"owned").unwrap(); @@ -91,7 +91,7 @@ mod tests { clean_bundled_files(&install_dir).unwrap(); - assert!(!install_dir.join("fonts/NotoSans-Regular.ttf").exists()); + assert!(!install_dir.join("fonts/Libron-Regular.ttf").exists()); assert!(install_dir.join("fonts/custom.ttf").exists()); assert!(!install_dir.join("icons/home.svg").exists()); assert!(!install_dir.join("libs/libfoo.so.1").exists()); diff --git a/crates/core/src/settings/mod.rs b/crates/core/src/settings/mod.rs index bb566bf3..79d71fcc 100644 --- a/crates/core/src/settings/mod.rs +++ b/crates/core/src/settings/mod.rs @@ -120,7 +120,7 @@ pub const DEFAULT_MARGIN_WIDTH: i32 = 8; // Default line height in ems. pub const DEFAULT_LINE_HEIGHT: f32 = 1.2; // Default font family name. -pub const DEFAULT_FONT_FAMILY: &str = "Libertinus Serif"; +pub const DEFAULT_FONT_FAMILY: &str = "Libron"; // Default text alignment. pub const DEFAULT_TEXT_ALIGN: TextAlign = TextAlign::Left; pub const HYPHEN_PENALTY: i32 = 50; diff --git a/crates/core/src/view/reader/mod.rs b/crates/core/src/view/reader/mod.rs index bd26cea6..57e9e23d 100644 --- a/crates/core/src/view/reader/mod.rs +++ b/crates/core/src/view/reader/mod.rs @@ -2708,6 +2708,9 @@ impl Reader { let mut families = family_names(&context.settings.reader.font_path) .map_err(|e| error!("Can't get family names: {:#}.", e)) .unwrap_or_default(); + if let Ok(install_families) = family_names(context.device.install_path("fonts")) { + families.extend(install_families); + } let current_family = self .info .reader @@ -3364,13 +3367,7 @@ impl Reader { { let mut doc = self.doc.lock().unwrap(); - let font_path = if font_family == DEFAULT_FONT_FAMILY { - "fonts" - } else { - &context.settings.reader.font_path - }; - - doc.set_font_family(font_family, font_path); + doc.set_font_family(font_family, &context.settings.reader.font_path); if self.synthetic { let current_page = self.current_page.min(doc.pages_count() - 1); diff --git a/devenv.nix b/devenv.nix index 649503f4..96ef0f0c 100644 --- a/devenv.nix +++ b/devenv.nix @@ -402,8 +402,7 @@ in dotenv.enable = true; env = { - # override this in devenv.local.nix to the right place for your test cadmus root dir - # TEST_ROOT_DIR = "$DEVENV_ROOT" ; + TEST_ROOT_DIR = config.devenv.root; RUST_LOG = "cadmus_core=trace,cadmus=trace"; RUST_BACKTRACE = "1"; diff --git a/docs/po/messages.pot b/docs/po/messages.pot index d860a803..e6ec4448 100644 --- a/docs/po/messages.pot +++ b/docs/po/messages.pot @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Cadmus Documentation\n" -"POT-Creation-Date: 2026-07-09T12:27:48+02:00\n" +"POT-Creation-Date: 2026-07-14T20:06:37+02:00\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" @@ -24,7 +24,7 @@ msgstr "" msgid "Test builds" msgstr "" -#: src/SUMMARY.md:7 src/installation/ota.md:1 +#: src/SUMMARY.md:7 src/installation/ota.md:1 src/fonts.md:108 msgid "OTA updates" msgstr "" @@ -44,133 +44,137 @@ msgstr "" msgid "Uninstalling Cadmus" msgstr "" -#: src/SUMMARY.md:12 src/SUMMARY.md:17 src/settings/index.md:1 +#: src/SUMMARY.md:12 src/SUMMARY.md:18 src/settings/index.md:1 src/fonts.md:102 #: src/ui/settings/index.md:1 msgid "Settings" msgstr "" -#: src/SUMMARY.md:13 src/ui/index.md:1 +#: src/SUMMARY.md:13 src/fonts.md:1 +msgid "Fonts" +msgstr "" + +#: src/SUMMARY.md:14 src/ui/index.md:1 msgid "User Interface" msgstr "" -#: src/SUMMARY.md:14 src/ui/filechooser.md:1 +#: src/SUMMARY.md:15 src/ui/filechooser.md:1 msgid "File Chooser" msgstr "" -#: src/SUMMARY.md:15 src/ui/keyboard.md:1 +#: src/SUMMARY.md:16 src/ui/keyboard.md:1 msgid "Keyboard" msgstr "" -#: src/SUMMARY.md:16 src/ui/dictionary.md:1 +#: src/SUMMARY.md:17 src/ui/dictionary.md:1 msgid "Dictionary" msgstr "" -#: src/SUMMARY.md:18 src/installation/migration.md:112 +#: src/SUMMARY.md:19 src/installation/migration.md:112 #: src/ui/settings/dictionaries.md:1 msgid "Dictionaries" msgstr "" -#: src/SUMMARY.md:19 src/library/index.md:1 +#: src/SUMMARY.md:20 src/library/index.md:1 msgid "Library" msgstr "" -#: src/SUMMARY.md:20 +#: src/SUMMARY.md:21 msgid "Importing" msgstr "" -#: src/SUMMARY.md:21 src/time-sync.md:1 +#: src/SUMMARY.md:22 src/time-sync.md:1 msgid "Automatic Time Syncing" msgstr "" -#: src/SUMMARY.md:22 src/auto-frontlight.md:1 +#: src/SUMMARY.md:23 src/auto-frontlight.md:1 msgid "Automatic Frontlight" msgstr "" -#: src/SUMMARY.md:23 src/database-backup.md:1 +#: src/SUMMARY.md:24 src/database-backup.md:1 msgid "Database Backup" msgstr "" -#: src/SUMMARY.md:24 src/installation/ota.md:86 src/troubleshooting/index.md:1 +#: src/SUMMARY.md:25 src/installation/ota.md:86 src/troubleshooting/index.md:1 msgid "Troubleshooting" msgstr "" -#: src/SUMMARY.md:25 +#: src/SUMMARY.md:26 msgid "CHANGELOG" msgstr "" -#: src/SUMMARY.md:29 +#: src/SUMMARY.md:30 msgid "Contributor Guide" msgstr "" -#: src/SUMMARY.md:31 +#: src/SUMMARY.md:32 msgid "Development Environment" msgstr "" -#: src/SUMMARY.md:32 +#: src/SUMMARY.md:33 msgid "SSH" msgstr "" -#: src/SUMMARY.md:33 +#: src/SUMMARY.md:34 msgid "Code Style and Linting" msgstr "" -#: src/SUMMARY.md:34 +#: src/SUMMARY.md:35 msgid "Event System" msgstr "" -#: src/SUMMARY.md:35 src/settings/index.md:723 +#: src/SUMMARY.md:36 src/settings/index.md:778 msgid "Telemetry" msgstr "" -#: src/SUMMARY.md:36 +#: src/SUMMARY.md:37 msgid "Logging" msgstr "" -#: src/SUMMARY.md:37 +#: src/SUMMARY.md:38 msgid "Tracing" msgstr "" -#: src/SUMMARY.md:38 +#: src/SUMMARY.md:39 msgid "Profiling" msgstr "" -#: src/SUMMARY.md:39 +#: src/SUMMARY.md:40 msgid "Website" msgstr "" -#: src/SUMMARY.md:40 +#: src/SUMMARY.md:41 msgid "User guide" msgstr "" -#: src/SUMMARY.md:41 +#: src/SUMMARY.md:42 msgid "Translations" msgstr "" -#: src/SUMMARY.md:42 +#: src/SUMMARY.md:43 msgid "For developers" msgstr "" -#: src/SUMMARY.md:43 +#: src/SUMMARY.md:44 msgid "SQLite & SQLx" msgstr "" -#: src/SUMMARY.md:44 +#: src/SUMMARY.md:45 msgid "Library Database" msgstr "" -#: src/SUMMARY.md:45 +#: src/SUMMARY.md:46 msgid "Runtime Migrations" msgstr "" -#: src/SUMMARY.md:49 +#: src/SUMMARY.md:50 msgid "Investigations" msgstr "" -#: src/SUMMARY.md:51 +#: src/SUMMARY.md:52 msgid "Intro" msgstr "" -#: src/SUMMARY.md:52 +#: src/SUMMARY.md:53 msgid "DHCP IP Address Changes on WiFi Toggle" msgstr "" @@ -1071,18 +1075,18 @@ msgstr "" #: src/settings/index.md:299 src/settings/index.md:315 #: src/settings/index.md:329 src/settings/index.md:348 -#: src/settings/index.md:362 src/settings/index.md:382 -#: src/settings/index.md:398 src/settings/index.md:428 -#: src/settings/index.md:444 src/settings/index.md:460 -#: src/settings/index.md:476 src/settings/index.md:492 -#: src/settings/index.md:514 src/settings/index.md:535 -#: src/settings/index.md:550 src/settings/index.md:567 -#: src/settings/index.md:573 src/settings/index.md:579 -#: src/settings/index.md:587 src/settings/index.md:612 -#: src/settings/index.md:629 src/settings/index.md:641 -#: src/settings/index.md:649 src/settings/index.md:666 -#: src/settings/index.md:694 src/settings/index.md:751 -#: src/settings/index.md:766 +#: src/settings/index.md:363 src/settings/index.md:385 +#: src/settings/index.md:401 src/settings/index.md:433 +#: src/settings/index.md:449 src/settings/index.md:465 +#: src/settings/index.md:481 src/settings/index.md:497 +#: src/settings/index.md:519 src/settings/index.md:540 +#: src/settings/index.md:602 src/settings/index.md:619 +#: src/settings/index.md:625 src/settings/index.md:631 +#: src/settings/index.md:639 src/settings/index.md:664 +#: src/settings/index.md:681 src/settings/index.md:694 +#: src/settings/index.md:702 src/settings/index.md:721 +#: src/settings/index.md:749 src/settings/index.md:806 +#: src/settings/index.md:821 msgid "✏️" msgstr "" @@ -1128,359 +1132,393 @@ msgid "" "will also set the correct timezone. Uses time.cloudflare.com and ipapi.co." msgstr "" -#: src/settings/index.md:360 +#: src/settings/index.md:361 msgid "`auto-frontlight`" msgstr "" -#: src/settings/index.md:364 +#: src/settings/index.md:365 msgid "" "Automatically adjust the frontlight warmth and brightness based on the sun's " "position at the device's location." msgstr "" -#: src/settings/index.md:366 +#: src/settings/index.md:367 msgid "During the day warmth is at its minimum." msgstr "" -#: src/settings/index.md:367 +#: src/settings/index.md:368 msgid "Around sunrise and sunset warmth ramps gradually between zero and full." msgstr "" -#: src/settings/index.md:368 +#: src/settings/index.md:369 msgid "" "After sunset brightness is reduced to `auto-frontlight-night-brightness` and " "warmth stays at its maximum until sunrise." msgstr "" -#: src/settings/index.md:370 +#: src/settings/index.md:372 msgid "" "Coordinates are auto-detected during each time sync (via ipapi.co) and " "stored in `auto-frontlight-last-coordinates`. Set " "`auto-frontlight-manual-coordinates` to override the detected location." msgstr "" -#: src/settings/index.md:380 +#: src/settings/index.md:383 msgid "`auto-frontlight-night-brightness`" msgstr "" -#: src/settings/index.md:384 +#: src/settings/index.md:387 msgid "" "Frontlight brightness level (0.0–100.0) applied when the sun is below the " "horizon." msgstr "" -#: src/settings/index.md:386 +#: src/settings/index.md:389 msgid "This setting is optional. When not set, a default of `1.0` is used." msgstr "" -#: src/settings/index.md:396 +#: src/settings/index.md:399 msgid "`auto-frontlight-manual-coordinates`" msgstr "" -#: src/settings/index.md:400 +#: src/settings/index.md:403 msgid "" "GPS coordinates `[latitude, longitude]` to use for sun-position calculations " "instead of the auto-detected location. Takes priority over " "`auto-frontlight-last-coordinates`." msgstr "" -#: src/settings/index.md:402 +#: src/settings/index.md:406 msgid "This setting is optional." msgstr "" -#: src/settings/index.md:412 +#: src/settings/index.md:416 msgid "`auto-frontlight-last-coordinates`" msgstr "" -#: src/settings/index.md:414 +#: src/settings/index.md:418 msgid "" "GPS coordinates `[latitude, longitude]` last detected during a time sync. " "Written automatically — do not edit this by hand; set " "`auto-frontlight-manual-coordinates` to override the location instead." msgstr "" -#: src/settings/index.md:416 +#: src/settings/index.md:421 msgid "This setting is optional and managed automatically." msgstr "" -#: src/settings/index.md:426 +#: src/settings/index.md:431 msgid "`auto-suspend`" msgstr "" -#: src/settings/index.md:430 +#: src/settings/index.md:435 msgid "" "Number of minutes of inactivity after which the device will automatically go " "to sleep." msgstr "" -#: src/settings/index.md:432 src/settings/index.md:448 +#: src/settings/index.md:437 src/settings/index.md:453 msgid "Zero means never." msgstr "" -#: src/settings/index.md:442 +#: src/settings/index.md:447 msgid "`auto-power-off`" msgstr "" -#: src/settings/index.md:446 +#: src/settings/index.md:451 msgid "Delay in days after which a suspended device will power off." msgstr "" -#: src/settings/index.md:458 +#: src/settings/index.md:463 msgid "`button-scheme`" msgstr "" -#: src/settings/index.md:462 +#: src/settings/index.md:467 msgid "" "Defines how the back and forward buttons are mapped to page forward and page " "backward actions." msgstr "" -#: src/settings/index.md:464 +#: src/settings/index.md:469 msgid "Possible values: `\"natural\"`, `\"inverted\"`." msgstr "" -#: src/settings/index.md:474 +#: src/settings/index.md:479 msgid "`locale`" msgstr "" -#: src/settings/index.md:478 +#: src/settings/index.md:483 msgid "" "The preferred language for the user interface, using BCP 47 format (e.g., " "`\"en-US\"`, `\"de-DE\"`)." msgstr "" -#: src/settings/index.md:480 +#: src/settings/index.md:485 msgid "This setting is optional. When not set, `en-GB` is used." msgstr "" -#: src/settings/index.md:490 +#: src/settings/index.md:495 msgid "`startup-mode`" msgstr "" -#: src/settings/index.md:494 +#: src/settings/index.md:499 msgid "What to show when Cadmus starts." msgstr "" -#: src/settings/index.md:496 +#: src/settings/index.md:501 msgid "`\"home\"` — open the home screen (default)." msgstr "" -#: src/settings/index.md:497 +#: src/settings/index.md:502 msgid "" "`\"last-file\"` — re-open the last book you were reading. If there is no " "unfinished book in the selected library, the home screen is shown instead." msgstr "" -#: src/settings/index.md:508 +#: src/settings/index.md:513 msgid "Reader" msgstr "" -#: src/settings/index.md:510 +#: src/settings/index.md:515 msgid "Settings that control the reading experience." msgstr "" -#: src/settings/index.md:512 +#: src/settings/index.md:517 msgid "`reader.finished`" msgstr "" -#: src/settings/index.md:516 +#: src/settings/index.md:521 msgid "What to do when you finish reading a book." msgstr "" -#: src/settings/index.md:518 src/settings/index.md:592 +#: src/settings/index.md:523 src/settings/index.md:644 msgid "Possible values:" msgstr "" -#: src/settings/index.md:520 +#: src/settings/index.md:525 msgid "`\"notify\"` (show a notification)" msgstr "" -#: src/settings/index.md:521 +#: src/settings/index.md:526 msgid "`\"close\"` (close the book and go back)" msgstr "" -#: src/settings/index.md:522 +#: src/settings/index.md:527 msgid "`\"go-to-next\"` (open the next book in the library)." msgstr "" -#: src/settings/index.md:533 +#: src/settings/index.md:538 msgid "`reader.dithered-kinds`" msgstr "" -#: src/settings/index.md:537 +#: src/settings/index.md:542 msgid "File extensions rendered with dithering by default." msgstr "" -#: src/settings/index.md:548 +#: src/settings/index.md:553 +msgid "`reader.font-path`" +msgstr "" + +#: src/settings/index.md:555 +msgid "" +"The directory Cadmus scans for additional reading fonts. Bundled Cadmus " +"fonts are always available regardless of this setting. See " +"[Fonts](../fonts.md) for details on installing custom fonts." +msgstr "" + +#: src/settings/index.md:568 +msgid "`reader.font-family`" +msgstr "" + +#: src/settings/index.md:570 +msgid "" +"The default reading font family name. New installs default to `Libron`. " +"Existing configurations using `Libertinus Serif` continue to work because " +"Libertinus remains bundled." +msgstr "" + +#: src/settings/index.md:583 +msgid "`reader.font-size`" +msgstr "" + +#: src/settings/index.md:585 +msgid "The default font size in points." +msgstr "" + +#: src/settings/index.md:598 +msgid "See [Fonts](../fonts.md) for more info." +msgstr "" + +#: src/settings/index.md:600 msgid "Libraries" msgstr "" -#: src/settings/index.md:552 +#: src/settings/index.md:604 msgid "Document library configuration. Each library has a name, path, and mode." msgstr "" -#: src/settings/index.md:565 +#: src/settings/index.md:617 msgid "`libraries.name`" msgstr "" -#: src/settings/index.md:569 +#: src/settings/index.md:621 msgid "Display name for the library." msgstr "" -#: src/settings/index.md:571 +#: src/settings/index.md:623 msgid "`libraries.path`" msgstr "" -#: src/settings/index.md:575 +#: src/settings/index.md:627 msgid "Directory path containing documents." msgstr "" -#: src/settings/index.md:577 +#: src/settings/index.md:629 msgid "`libraries.mode`" msgstr "" -#: src/settings/index.md:581 +#: src/settings/index.md:633 msgid "Library indexing mode." msgstr "" -#: src/settings/index.md:583 +#: src/settings/index.md:635 msgid "Possible values: `\"database\"`, `\"filesystem\"`." msgstr "" -#: src/settings/index.md:585 +#: src/settings/index.md:637 msgid "`libraries.finished`" msgstr "" -#: src/settings/index.md:589 +#: src/settings/index.md:641 msgid "" "Override the `reader.finished` setting for this specific library. When set, " "this takes precedence over the global reader setting." msgstr "" -#: src/settings/index.md:594 +#: src/settings/index.md:646 msgid "`\"notify\"`" msgstr "" -#: src/settings/index.md:595 +#: src/settings/index.md:647 msgid "`\"close\"`" msgstr "" -#: src/settings/index.md:596 +#: src/settings/index.md:648 msgid "`\"go-to-next\"`." msgstr "" -#: src/settings/index.md:597 +#: src/settings/index.md:649 msgid "Leave unset to inherit the global `reader.finished` setting." msgstr "" -#: src/settings/index.md:610 +#: src/settings/index.md:662 msgid "Intermissions" msgstr "" -#: src/settings/index.md:614 +#: src/settings/index.md:666 msgid "Defines the images displayed when entering an intermission state." msgstr "" -#: src/settings/index.md:627 +#: src/settings/index.md:679 msgid "`intermissions.suspend`" msgstr "" -#: src/settings/index.md:631 +#: src/settings/index.md:683 msgid "Image displayed when the device enters sleep mode." msgstr "" -#: src/settings/index.md:633 +#: src/settings/index.md:685 msgid "" "Setting this to `\"calendar:\"` also enables the calendar refresh: every 5 " "minutes, the device wakes, shows the calendar, and then goes back to sleep " "automatically." msgstr "" -#: src/settings/index.md:637 +#: src/settings/index.md:689 msgid "" "Possible values: `\"logo:\"` (built-in logo), `\"cover:\"` (current book " "cover), `\"calendar:\"` (built-in calendar), or a path to a custom image " "file." msgstr "" -#: src/settings/index.md:639 +#: src/settings/index.md:692 msgid "`intermissions.power-off`" msgstr "" -#: src/settings/index.md:643 +#: src/settings/index.md:696 msgid "Image displayed when the device powers off." msgstr "" -#: src/settings/index.md:645 src/settings/index.md:653 +#: src/settings/index.md:698 src/settings/index.md:706 msgid "" "Possible values: `\"logo:\"` (built-in logo), `\"cover:\"` (current book " "cover), or a path to a custom image file." msgstr "" -#: src/settings/index.md:647 +#: src/settings/index.md:700 msgid "`intermissions.share`" msgstr "" -#: src/settings/index.md:651 +#: src/settings/index.md:704 msgid "Image displayed when entering USB sharing mode." msgstr "" -#: src/settings/index.md:655 +#: src/settings/index.md:708 msgid "Import" msgstr "" -#: src/settings/index.md:657 +#: src/settings/index.md:710 msgid "" "These settings control how Cadmus imports documents from your device. They " "are available in the **Settings → Import** menu." msgstr "" -#: src/settings/index.md:660 +#: src/settings/index.md:713 msgid "" "Import scanning happens automatically on startup using incremental file " "checking — files are only re-scanned if their modification time or size has " "changed since the last import." msgstr "" -#: src/settings/index.md:662 +#: src/settings/index.md:716 msgid "" "To trigger a full re-scan of all files regardless of cached values, use the " "**Force Full Import** action button in the Import settings category." msgstr "" -#: src/settings/index.md:664 +#: src/settings/index.md:719 msgid "`import.sync-metadata`" msgstr "" -#: src/settings/index.md:668 +#: src/settings/index.md:723 msgid "Re-extract metadata (title, author, etc.) whenever a document changes." msgstr "" -#: src/settings/index.md:679 +#: src/settings/index.md:734 msgid "`import.metadata-kinds`" msgstr "" -#: src/settings/index.md:681 +#: src/settings/index.md:736 msgid "File extensions of documents whose metadata is extracted during import." msgstr "" -#: src/settings/index.md:692 +#: src/settings/index.md:747 msgid "`import.allowed-kinds`" msgstr "" -#: src/settings/index.md:696 +#: src/settings/index.md:751 msgid "File extensions of documents considered during the import process." msgstr "" -#: src/settings/index.md:707 +#: src/settings/index.md:762 msgid "OTA" msgstr "" -#: src/settings/index.md:709 +#: src/settings/index.md:764 msgid "The OTA feature downloads builds from GitHub." msgstr "" -#: src/settings/index.md:711 +#: src/settings/index.md:766 msgid "" "Authentication for main branch and PR builds uses **GitHub device auth " "flow**. When you select a build that requires authentication, Cadmus will " @@ -1489,191 +1527,492 @@ msgid "" "once you authorize." msgstr "" -#: src/settings/index.md:717 +#: src/settings/index.md:772 msgid "" "The token is saved to disk after the first authorization so you will not be " "prompted again on subsequent downloads." msgstr "" -#: src/settings/index.md:720 +#: src/settings/index.md:775 msgid "" "For step-by-step instructions with screenshots, see the [OTA " "updates](../installation/ota.md) guide." msgstr "" -#: src/settings/index.md:725 +#: src/settings/index.md:780 msgid "" "Cadmus writes JSON logs to disk. When the build enables the `tracing` " "feature, it can also export logs to an OpenTelemetry endpoint." msgstr "" -#: src/settings/index.md:728 +#: src/settings/index.md:783 msgid "These settings are available in the **Settings → Telemetry** menu." msgstr "" -#: src/settings/index.md:731 +#: src/settings/index.md:786 msgid "" "Changes to these settings only take effect after restarting Cadmus. The " "application initializes telemetry on startup." msgstr "" -#: src/settings/index.md:734 +#: src/settings/index.md:789 msgid "`logging`" msgstr "" -#: src/settings/index.md:749 +#: src/settings/index.md:804 msgid "`logging.enabled`" msgstr "" -#: src/settings/index.md:753 +#: src/settings/index.md:808 msgid "Enable or disable structured JSON logging." msgstr "" -#: src/settings/index.md:764 +#: src/settings/index.md:819 msgid "`logging.level`" msgstr "" -#: src/settings/index.md:768 +#: src/settings/index.md:823 msgid "Minimum log level to record." msgstr "" -#: src/settings/index.md:770 +#: src/settings/index.md:825 msgid "" "Possible values: `\"trace\"`, `\"debug\"`, `\"info\"`, `\"warn\"`, " "`\"error\"`." msgstr "" -#: src/settings/index.md:781 +#: src/settings/index.md:836 msgid "`logging.max-files`" msgstr "" -#: src/settings/index.md:783 +#: src/settings/index.md:838 msgid "" "Number of log files to keep. Only the most recent N files are kept — older " "ones are deleted automatically when Cadmus starts." msgstr "" -#: src/settings/index.md:786 src/settings/index.md:879 +#: src/settings/index.md:841 src/settings/index.md:936 msgid "Default: `3`" msgstr "" -#: src/settings/index.md:787 +#: src/settings/index.md:842 msgid "Set to `0` to keep all log files." msgstr "" -#: src/settings/index.md:798 +#: src/settings/index.md:853 msgid "`logging.otlp-endpoint`" msgstr "" -#: src/settings/index.md:800 +#: src/settings/index.md:855 msgid "✏️ (only when the `tracing` feature is enabled)" msgstr "" -#: src/settings/index.md:802 +#: src/settings/index.md:857 msgid "Optional OTLP endpoint for exporting logs to an OpenTelemetry collector." msgstr "" -#: src/settings/index.md:813 src/settings/index.md:834 +#: src/settings/index.md:868 src/settings/index.md:889 msgid "Environment override:" msgstr "" -#: src/settings/index.md:815 +#: src/settings/index.md:870 msgid "" "`OTEL_EXPORTER_OTLP_ENDPOINT` takes precedence over `logging.otlp-endpoint`." msgstr "" -#: src/settings/index.md:817 +#: src/settings/index.md:872 msgid "`logging.pyroscope-endpoint`" msgstr "" -#: src/settings/index.md:819 +#: src/settings/index.md:874 msgid "✏️ (only when the `profiling` feature is enabled)" msgstr "" -#: src/settings/index.md:821 +#: src/settings/index.md:876 msgid "" "Optional Pyroscope server URL for continuous profiling. When set, Cadmus " "starts both a heap profiling agent (via jemalloc) and a CPU profiling agent " "(via pprof) that push profiles to this endpoint." msgstr "" -#: src/settings/index.md:836 +#: src/settings/index.md:891 msgid "" "`PYROSCOPE_SERVER_URL` takes precedence over `logging.pyroscope-endpoint`." msgstr "" -#: src/settings/index.md:838 +#: src/settings/index.md:893 msgid "`logging.enable-kern-log`" msgstr "" -#: src/settings/index.md:840 src/settings/index.md:856 +#: src/settings/index.md:895 src/settings/index.md:911 msgid "🧪 📱 ✏️" msgstr "" -#: src/settings/index.md:842 +#: src/settings/index.md:897 msgid "" "Captures kernel logs via `logread -F` and forwards them to structured " "logging with the target `cadmus_core::logging:kern`." msgstr "" -#: src/settings/index.md:854 +#: src/settings/index.md:909 msgid "`logging.enable-dbus-log`" msgstr "" -#: src/settings/index.md:858 +#: src/settings/index.md:913 msgid "" "Captures D-Bus signals via the built-in zbus-based DbusMonitorTask and " "forwards them to structured logging." msgstr "" -#: src/settings/index.md:870 +#: src/settings/index.md:925 msgid "Settings Retention" msgstr "" -#: src/settings/index.md:872 +#: src/settings/index.md:927 msgid "" "Cadmus stores each version's settings in a separate file in the `Settings/` " "directory (for example, `Settings-v1.2.3.toml`). This ensures backward and " "forward compatibility when you upgrade." msgstr "" -#: src/settings/index.md:875 +#: src/settings/index.md:931 msgid "`settings-retention`" msgstr "" -#: src/settings/index.md:877 +#: src/settings/index.md:933 msgid "" "Number of recent version settings files to keep. Only the most recent N " "version files are kept. When a new version is saved, older versions beyond " "this limit are deleted automatically." msgstr "" -#: src/settings/index.md:880 +#: src/settings/index.md:937 msgid "Set to `0` to keep all version files" msgstr "" -#: src/settings/index.md:890 +#: src/settings/index.md:947 msgid "`db-backup-retention`" msgstr "" -#: src/settings/index.md:892 +#: src/settings/index.md:949 msgid "" "Number of database backups to keep. When a new backup is created and the " "total would exceed this limit, the oldest backups are deleted automatically." msgstr "" -#: src/settings/index.md:895 src/database-backup.md:76 +#: src/settings/index.md:952 src/database-backup.md:76 msgid "Default: `2`" msgstr "" -#: src/settings/index.md:896 src/database-backup.md:77 +#: src/settings/index.md:953 src/database-backup.md:77 msgid "Set to `0` to disable backups entirely." msgstr "" -#: src/settings/index.md:898 +#: src/settings/index.md:955 msgid "See [Database Backup](../database-backup.md) for more details." msgstr "" +#: src/fonts.md:3 +msgid "" +"Cadmus ships with reading fonts optimized for e-ink displays, plus " +"additional fonts for the app interface and in-book HTML fallbacks. You can " +"switch fonts while reading or install your own." +msgstr "" + +#: src/fonts.md:7 +msgid "Pre-installed reading fonts" +msgstr "" + +#: src/fonts.md:9 +msgid "Cadmus's reading fonts come from two open-source projects:" +msgstr "" + +#: src/fonts.md:11 +msgid "" +"[ebook-fonts](https://github.com/nicoverbruggen/ebook-fonts) by Nico " +"Verbruggen — reading fonts tuned for e-ink displays. See the [interactive " +"showcase](https://ebook-fonts.nicoverbruggen.be/) to preview them." +msgstr "" + +#: src/fonts.md:14 +msgid "" +"[Libertinus](https://github.com/alerque/libertinus) — classic serif, sans, " +"and mono families, plus Nico's metric-tweaked **NV Libertinus** from the " +"ebook-fonts extra collection." +msgstr "" + +#: src/fonts.md:17 +msgid "ebook-fonts (Core Collection)" +msgstr "" + +#: src/fonts.md:19 src/fonts.md:33 src/fonts.md:47 +msgid "Font" +msgstr "" + +#: src/fonts.md:19 src/fonts.md:33 +msgid "Style" +msgstr "" + +#: src/fonts.md:21 +msgid "Libron" +msgstr "" + +#: src/fonts.md:21 +msgid "Serif (default)" +msgstr "" + +#: src/fonts.md:22 +msgid "Sourcerer" +msgstr "" + +#: src/fonts.md:22 src/fonts.md:23 src/fonts.md:24 src/fonts.md:25 +#: src/fonts.md:27 src/fonts.md:35 src/fonts.md:36 +msgid "Serif" +msgstr "" + +#: src/fonts.md:23 +msgid "Cartisse" +msgstr "" + +#: src/fonts.md:24 +msgid "NV Charis" +msgstr "" + +#: src/fonts.md:25 +msgid "NV Garamond" +msgstr "" + +#: src/fonts.md:26 +msgid "NV Bitter" +msgstr "" + +#: src/fonts.md:26 +msgid "Slab serif" +msgstr "" + +#: src/fonts.md:27 +msgid "NV Palatium" +msgstr "" + +#: src/fonts.md:28 +msgid "NV Jost" +msgstr "" + +#: src/fonts.md:28 src/fonts.md:37 +msgid "Sans" +msgstr "" + +#: src/fonts.md:29 +msgid "NV Legible Next" +msgstr "" + +#: src/fonts.md:29 +msgid "Sans (accessibility)" +msgstr "" + +#: src/fonts.md:31 +msgid "Libertinus" +msgstr "" + +#: src/fonts.md:35 +msgid "NV Libertinus" +msgstr "" + +#: src/fonts.md:36 +msgid "Libertinus Serif" +msgstr "" + +#: src/fonts.md:37 +msgid "Libertinus Sans" +msgstr "" + +#: src/fonts.md:38 +msgid "Libertinus Mono" +msgstr "" + +#: src/fonts.md:38 +msgid "Monospace" +msgstr "" + +#: src/fonts.md:40 +msgid "Other packaged fonts" +msgstr "" + +#: src/fonts.md:42 +msgid "" +"Cadmus also ships fonts from " +"[Noto](https://github.com/notofonts/noto-fonts), [Source Code " +"Pro](https://github.com/adobe-fonts/source-code-pro), and [Google " +"Fonts](https://github.com/googlefonts/google-fonts) for the app interface " +"and HTML rendering fallbacks:" +msgstr "" + +#: src/fonts.md:47 +msgid "Used for" +msgstr "" + +#: src/fonts.md:49 +msgid "Noto Sans" +msgstr "" + +#: src/fonts.md:49 +msgid "Menus, settings, and other app text" +msgstr "" + +#: src/fonts.md:50 +msgid "Noto Serif" +msgstr "" + +#: src/fonts.md:50 +msgid "Serif text in the app interface" +msgstr "" + +#: src/fonts.md:51 +msgid "Source Code Pro" +msgstr "" + +#: src/fonts.md:51 +msgid "Monospace in the app and code blocks in books" +msgstr "" + +#: src/fonts.md:52 +msgid "Varela Round" +msgstr "" + +#: src/fonts.md:52 +msgid "On-screen keyboard" +msgstr "" + +#: src/fonts.md:53 +msgid "Cormorant" +msgstr "" + +#: src/fonts.md:53 +msgid "Startup and intermission screens" +msgstr "" + +#: src/fonts.md:54 +msgid "Parisienne" +msgstr "" + +#: src/fonts.md:54 +msgid "Decorative cursive text in EPUB/HTML content" +msgstr "" + +#: src/fonts.md:55 +msgid "Delius" +msgstr "" + +#: src/fonts.md:55 +msgid "Decorative fantasy text in EPUB/HTML content" +msgstr "" + +#: src/fonts.md:57 +msgid "Changing the font while reading" +msgstr "" + +#: src/fonts.md:59 +msgid "Open a book in the reader." +msgstr "" + +#: src/fonts.md:60 +msgid "Tap the **font family** button in the reader toolbar." +msgstr "" + +#: src/fonts.md:61 +msgid "Select a font from the list." +msgstr "" + +#: src/fonts.md:63 +msgid "" +"The menu shows fonts from Cadmus's packaged collection and any fonts in your " +"custom font directory (see below). When you select a font, Cadmus resolves " +"the family name using the order described in [How Cadmus resolves " +"fonts](#how-cadmus-resolves-fonts)." +msgstr "" + +#: src/fonts.md:67 +msgid "How Cadmus resolves fonts" +msgstr "" + +#: src/fonts.md:69 +msgid "" +"When you pick a font in the reader or reopen a book with a saved " +"[`font-family`](settings/index.md#readerfont-family), Cadmus looks for that " +"family name in two places, **in this order**:" +msgstr "" + +#: src/fonts.md:73 +msgid "" +"**Packaged fonts** — shipped with Cadmus and updated via " +"[OTA](installation/ota.md)" +msgstr "" + +#: src/fonts.md:74 +msgid "" +"**Your custom font directory** — " +"[`reader.font-path`](settings/index.md#readerfont-path) (default " +"`/mnt/onboard/fonts/`)" +msgstr "" + +#: src/fonts.md:77 +msgid "" +"If the same family name exists in both places, the **packaged** copy is used." +msgstr "" + +#: src/fonts.md:79 +msgid "" +"Your chosen family replaces Cadmus's default **serif** stack for reflowable " +"books. Paragraphs the book styles as sans-serif, monospace, cursive, or " +"fantasy keep those respective packaged fallbacks. If Cadmus cannot find the " +"family name, the previous font is kept." +msgstr "" + +#: src/fonts.md:84 +msgid "Installing additional fonts" +msgstr "" + +#: src/fonts.md:86 +msgid "" +"To add your own fonts, copy `.ttf` or `.otf` files into your custom font " +"directory (by default at the root of your Kobo device):" +msgstr "" + +#: src/fonts.md:97 +msgid "Cadmus scans that directory recursively, so fonts can live in subfolders." +msgstr "" + +#: src/fonts.md:99 +msgid "" +"Point Cadmus at your custom directory with " +"[`reader.font-path`](settings/index.md#readerfont-path) if you use a " +"different location." +msgstr "" + +#: src/fonts.md:104 +msgid "" +"Font options can also be set in your settings file. See the [Reader " +"settings](settings/index.md#reader) section for `font-family`, `font-path`, " +"`font-size`, and related entries." +msgstr "" + +#: src/fonts.md:110 +msgid "" +"[OTA updates](installation/ota.md) replace Cadmus-packaged fonts before " +"installing the new release. Cadmus removes **each shipped font file " +"individually** — it does not delete the whole `fonts/` directory — so any " +"extra files you placed in Cadmus's install `fonts/` folder survive the " +"update if they are not part of the release." +msgstr "" + +#: src/fonts.md:116 +msgid "" +"Under normal use, your custom fonts live in a **separate directory** " +"([`reader.font-path`](settings/index.md#readerfont-path), default " +"`/mnt/onboard/fonts/`) and are never touched by OTA. Packaged fonts stay in " +"Cadmus's install directory." +msgstr "" + #: src/ui/index.md:3 msgid "" "This section explains the different parts of Cadmus you interact with while " diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 53625215..758a60a3 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -10,6 +10,7 @@ - [Using with KFMon](installation/kfmon.md) - [Uninstalling Cadmus](installation/uninstall.md) - [Settings](settings/index.md) +- [Fonts](fonts.md) - [User Interface](ui/index.md) - [File Chooser](ui/filechooser.md) - [Keyboard](ui/keyboard.md) diff --git a/docs/src/contributing/devenv-setup.md b/docs/src/contributing/devenv-setup.md index 20d867f8..dab70399 100644 --- a/docs/src/contributing/devenv-setup.md +++ b/docs/src/contributing/devenv-setup.md @@ -24,13 +24,14 @@ This guide covers setup on both Linux and macOS. ```bash cargo xtask download-assets + cargo xtask download-fonts ``` > [!NOTE] > `cadmus-core` generates some compile-time metadata from the bundled asset - > directories. For Kobo builds, make sure `bin/`, `resources/`, and - > `hyphenation-patterns/` are present before `cargo xtask build-kobo` so the - > generated asset list is complete. + > directories. For Kobo builds, make sure `bin/`, `resources/`, + > `hyphenation-patterns/`, and `fonts/` are present before + > `cargo xtask build-kobo` so the generated asset list is complete. > > Thirdparty C/C++ dependencies (MuPDF, libwebp, zlib, etc.) are tracked as git > submodules and built automatically by `build.rs` when you run `cargo build` or @@ -49,6 +50,7 @@ Once inside the devenv shell, these commands are available: | Command | Description | | ----------------------------- | ----------------------------------------------------------- | | `cargo xtask download-assets` | Download packaged Plato runtime assets | +| `cargo xtask download-fonts` | Download bundled font files into `fonts/` | | `cargo xtask test` | Run the test suite across the feature matrix | | `cargo xtask run-emulator` | Run the emulator | | `cargo xtask build-kobo` | Cross-compile for Kobo device | @@ -95,7 +97,7 @@ The `docs:build` task uses `execIfModified` to only rebuild when documentation f ## Kobo Build Notes -- `cargo xtask download-assets` must run before `cargo xtask build-kobo`. +- `cargo xtask download-assets` and `cargo xtask download-fonts` must run before `cargo xtask build-kobo`. - OTA updates delete Cadmus-owned bundled files before reboot, then Kobo extracts the new `KoboRoot.tgz` over the install directory. - User files outside the generated Cadmus-owned asset list must be preserved. diff --git a/docs/src/fonts.md b/docs/src/fonts.md new file mode 100644 index 00000000..ec252705 --- /dev/null +++ b/docs/src/fonts.md @@ -0,0 +1,119 @@ +# Fonts + +Cadmus ships with reading fonts optimized for e-ink displays, plus +additional fonts for the app interface and in-book HTML fallbacks. You can switch +fonts while reading or install your own. + +## Pre-installed reading fonts + +Cadmus's reading fonts come from two open-source projects: + +- [ebook-fonts](https://github.com/nicoverbruggen/ebook-fonts) by Nico Verbruggen — + reading fonts tuned for e-ink displays. See the [interactive showcase](https://ebook-fonts.nicoverbruggen.be/) + to preview them. +- [Libertinus](https://github.com/alerque/libertinus) — classic serif, sans, and mono families, + plus Nico's metric-tweaked **NV Libertinus** from the ebook-fonts extra collection. + +### ebook-fonts (Core Collection) + +| Font | Style | +| --------------- | -------------------- | +| Libron | Serif (default) | +| Sourcerer | Serif | +| Cartisse | Serif | +| NV Charis | Serif | +| NV Garamond | Serif | +| NV Bitter | Slab serif | +| NV Palatium | Serif | +| NV Jost | Sans | +| NV Legible Next | Sans (accessibility) | + +### Libertinus + +| Font | Style | +| ---------------- | --------- | +| NV Libertinus | Serif | +| Libertinus Serif | Serif | +| Libertinus Sans | Sans | +| Libertinus Mono | Monospace | + +## Other packaged fonts + +Cadmus also ships fonts from [Noto](https://github.com/notofonts/noto-fonts), +[Source Code Pro](https://github.com/adobe-fonts/source-code-pro), and +[Google Fonts](https://github.com/googlefonts/google-fonts) for the app interface +and HTML rendering fallbacks: + +| Font | Used for | +| --------------- | --------------------------------------------- | +| Noto Sans | Menus, settings, and other app text | +| Noto Serif | Serif text in the app interface | +| Source Code Pro | Monospace in the app and code blocks in books | +| Varela Round | On-screen keyboard | +| Cormorant | Startup and intermission screens | +| Parisienne | Decorative cursive text in EPUB/HTML content | +| Delius | Decorative fantasy text in EPUB/HTML content | + +## Changing the font while reading + +1. Open a book in the reader. +2. Tap the **font family** button in the reader toolbar. +3. Select a font from the list. + +The menu shows fonts from Cadmus's packaged collection and any fonts in your +custom font directory (see below). When you select a font, Cadmus resolves the +family name using the order described in [How Cadmus resolves fonts](#how-cadmus-resolves-fonts). + +## How Cadmus resolves fonts + +When you pick a font in the reader or reopen a book with a saved +[`font-family`](settings/index.md#readerfont-family), Cadmus looks for that family +name in two places, **in this order**: + +1. **Packaged fonts** — shipped with Cadmus and updated via [OTA](installation/ota.md) +2. **Your custom font directory** — [`reader.font-path`](settings/index.md#readerfont-path) + (default `/mnt/onboard/fonts/`) + +If the same family name exists in both places, the **packaged** copy is used. + +Your chosen family replaces Cadmus's default **serif** stack for reflowable +books. Paragraphs the book styles as sans-serif, monospace, cursive, or fantasy +keep those respective packaged fallbacks. If Cadmus cannot find the family name, +the previous font is kept. + +## Installing additional fonts + +To add your own fonts, copy `.ttf` or `.otf` files into your custom font +directory (by default at the root of your Kobo device): + + + +```text +/mnt/onboard/fonts/ +``` + + + +Cadmus scans that directory recursively, so fonts can live in subfolders. + +Point Cadmus at your custom directory with [`reader.font-path`](settings/index.md#readerfont-path) +if you use a different location. + +## Settings + +Font options can also be set in your settings file. See the +[Reader settings](settings/index.md#reader) section for `font-family`, +`font-path`, `font-size`, and related entries. + +## OTA updates + +[OTA updates](installation/ota.md) replace Cadmus-packaged fonts before +installing the new release. Cadmus removes **each shipped font file +individually** — it does not delete the whole `fonts/` directory — so any +extra files you placed in Cadmus's install `fonts/` folder survive the update +if they are not part of the release. + +Under normal use, your custom fonts live in a **separate directory** +([`reader.font-path`](settings/index.md#readerfont-path), default +`/mnt/onboard/fonts/`) and are never touched by OTA. Packaged fonts stay in +Cadmus's install directory. diff --git a/docs/src/settings/index.md b/docs/src/settings/index.md index 92dcbd45..ba480798 100644 --- a/docs/src/settings/index.md +++ b/docs/src/settings/index.md @@ -18,7 +18,7 @@ Settings can be changed via **Main Menu → Settings**, which opens the built-in ```toml -{{#include ../../../contrib/Settings-sample.toml}} +{{ #include ../../../contrib/Settings-sample.toml}} ``` @@ -80,7 +80,8 @@ auto-share = false ✏️ -Automatically synchronize the device time via NTP when WiFi connects. This will also set the correct timezone. Uses time.cloudflare.com and ipapi.co. +Automatically synchronize the device time via NTP when WiFi connects. This will also set the correct timezone. Uses +time.cloudflare.com and ipapi.co. @@ -98,9 +99,11 @@ Automatically adjust the frontlight warmth and brightness based on the sun's pos - During the day warmth is at its minimum. - Around sunrise and sunset warmth ramps gradually between zero and full. -- After sunset brightness is reduced to `auto-frontlight-night-brightness` and warmth stays at its maximum until sunrise. +- After sunset brightness is reduced to `auto-frontlight-night-brightness` and warmth stays at its maximum until + sunrise. -Coordinates are auto-detected during each time sync (via ipapi.co) and stored in `auto-frontlight-last-coordinates`. Set `auto-frontlight-manual-coordinates` to override the detected location. +Coordinates are auto-detected during each time sync (via ipapi.co) and stored in `auto-frontlight-last-coordinates`. Set +`auto-frontlight-manual-coordinates` to override the detected location. @@ -130,7 +133,8 @@ auto-frontlight-night-brightness = 10.0 ✏️ -GPS coordinates `[latitude, longitude]` to use for sun-position calculations instead of the auto-detected location. Takes priority over `auto-frontlight-last-coordinates`. +GPS coordinates `[latitude, longitude]` to use for sun-position calculations instead of the auto-detected location. +Takes priority over `auto-frontlight-last-coordinates`. This setting is optional. @@ -144,7 +148,8 @@ auto-frontlight-manual-coordinates = [51.5074, -0.1278] ### `auto-frontlight-last-coordinates` -GPS coordinates `[latitude, longitude]` last detected during a time sync. Written automatically — do not edit this by hand; set `auto-frontlight-manual-coordinates` to override the location instead. +GPS coordinates `[latitude, longitude]` last detected during a time sync. Written automatically — do not edit this by +hand; set `auto-frontlight-manual-coordinates` to override the location instead. This setting is optional and managed automatically. @@ -278,6 +283,53 @@ dithered-kinds = ["cbz", "png", "jpg", "jpeg", "webp"] +### `reader.font-path` + +The directory Cadmus scans for additional reading fonts. Bundled Cadmus fonts +are always available regardless of this setting. See [Fonts](../fonts.md) for +details on installing custom fonts. + + + +```toml +[reader] +font-path = "/mnt/onboard/fonts" +``` + + + +### `reader.font-family` + +The default reading font family name. New installs default to `Libron`. +Existing configurations using `Libertinus Serif` continue to work because +Libertinus remains bundled. + + + +```toml +[reader] +font-family = "Libron" +``` + + + +### `reader.font-size` + +The default font size in points. + + + +```toml +[reader] +font-size = 11.0 +min-font-size = 5.5 +max-font-size = 16.5 +``` + + + +See [Fonts](../fonts.md) for more info. + ## Libraries ✏️ @@ -367,7 +419,8 @@ Setting this to `"calendar:"` also enables the calendar refresh: every 5 minutes, the device wakes, shows the calendar, and then goes back to sleep automatically. -- Possible values: `"logo:"` (built-in logo), `"cover:"` (current book cover), `"calendar:"` (built-in calendar), or a path to a custom image file. +- Possible values: `"logo:"` (built-in logo), `"cover:"` (current book cover), `"calendar:"` (built-in calendar), or a + path to a custom image file. ### `intermissions.power-off` @@ -390,9 +443,11 @@ Image displayed when entering USB sharing mode. These settings control how Cadmus imports documents from your device. They are available in the **Settings → Import** menu. -Import scanning happens automatically on startup using incremental file checking — files are only re-scanned if their modification time or size has changed since the last import. +Import scanning happens automatically on startup using incremental file checking — files are only re-scanned if their +modification time or size has changed since the last import. -To trigger a full re-scan of all files regardless of cached values, use the **Force Full Import** action button in the Import settings category. +To trigger a full re-scan of all files regardless of cached values, use the **Force Full Import** action button in the +Import settings category. ### `import.sync-metadata` @@ -602,12 +657,14 @@ enable-dbus-log = false ## Settings Retention -Cadmus stores each version's settings in a separate file in the `Settings/` directory (for example, `Settings-v1.2.3.toml`). +Cadmus stores each version's settings in a separate file in the `Settings/` directory (for example, +`Settings-v1.2.3.toml`). This ensures backward and forward compatibility when you upgrade. ### `settings-retention` -Number of recent version settings files to keep. Only the most recent N version files are kept. When a new version is saved, older versions beyond this limit are deleted automatically. +Number of recent version settings files to keep. Only the most recent N version files are kept. When a new version is +saved, older versions beyond this limit are deleted automatically. - Default: `3` - Set to `0` to keep all version files diff --git a/fonts/Cormorant-Regular.ttf b/fonts/Cormorant-Regular.ttf deleted file mode 100644 index 5e9df1b2..00000000 Binary files a/fonts/Cormorant-Regular.ttf and /dev/null differ diff --git a/fonts/Delius-Regular.ttf b/fonts/Delius-Regular.ttf deleted file mode 100644 index 1564fde4..00000000 Binary files a/fonts/Delius-Regular.ttf and /dev/null differ diff --git a/fonts/LibertinusSerif-Bold.otf b/fonts/LibertinusSerif-Bold.otf deleted file mode 100644 index 39a8ee5e..00000000 Binary files a/fonts/LibertinusSerif-Bold.otf and /dev/null differ diff --git a/fonts/LibertinusSerif-BoldItalic.otf b/fonts/LibertinusSerif-BoldItalic.otf deleted file mode 100644 index d63f36e2..00000000 Binary files a/fonts/LibertinusSerif-BoldItalic.otf and /dev/null differ diff --git a/fonts/LibertinusSerif-Italic.otf b/fonts/LibertinusSerif-Italic.otf deleted file mode 100644 index ddf1996b..00000000 Binary files a/fonts/LibertinusSerif-Italic.otf and /dev/null differ diff --git a/fonts/LibertinusSerif-Regular.otf b/fonts/LibertinusSerif-Regular.otf deleted file mode 100644 index e42f25c5..00000000 Binary files a/fonts/LibertinusSerif-Regular.otf and /dev/null differ diff --git a/fonts/NotoSans-Bold.ttf b/fonts/NotoSans-Bold.ttf deleted file mode 100644 index 1db7886e..00000000 Binary files a/fonts/NotoSans-Bold.ttf and /dev/null differ diff --git a/fonts/NotoSans-BoldItalic.ttf b/fonts/NotoSans-BoldItalic.ttf deleted file mode 100644 index 385e6acb..00000000 Binary files a/fonts/NotoSans-BoldItalic.ttf and /dev/null differ diff --git a/fonts/NotoSans-Italic.ttf b/fonts/NotoSans-Italic.ttf deleted file mode 100644 index 6d2c71c8..00000000 Binary files a/fonts/NotoSans-Italic.ttf and /dev/null differ diff --git a/fonts/NotoSans-Regular.ttf b/fonts/NotoSans-Regular.ttf deleted file mode 100644 index 0a01a062..00000000 Binary files a/fonts/NotoSans-Regular.ttf and /dev/null differ diff --git a/fonts/NotoSerif-Bold.ttf b/fonts/NotoSerif-Bold.ttf deleted file mode 100644 index dadca30b..00000000 Binary files a/fonts/NotoSerif-Bold.ttf and /dev/null differ diff --git a/fonts/NotoSerif-BoldItalic.ttf b/fonts/NotoSerif-BoldItalic.ttf deleted file mode 100644 index bd0d6b4b..00000000 Binary files a/fonts/NotoSerif-BoldItalic.ttf and /dev/null differ diff --git a/fonts/NotoSerif-Italic.ttf b/fonts/NotoSerif-Italic.ttf deleted file mode 100644 index c9be7abc..00000000 Binary files a/fonts/NotoSerif-Italic.ttf and /dev/null differ diff --git a/fonts/NotoSerif-Regular.ttf b/fonts/NotoSerif-Regular.ttf deleted file mode 100644 index 5a89b2f7..00000000 Binary files a/fonts/NotoSerif-Regular.ttf and /dev/null differ diff --git a/fonts/Parisienne-Regular.ttf b/fonts/Parisienne-Regular.ttf deleted file mode 100644 index ef2db744..00000000 Binary files a/fonts/Parisienne-Regular.ttf and /dev/null differ diff --git a/fonts/SourceCodeVariable-Italic.otf b/fonts/SourceCodeVariable-Italic.otf deleted file mode 100644 index ed2a3366..00000000 Binary files a/fonts/SourceCodeVariable-Italic.otf and /dev/null differ diff --git a/fonts/SourceCodeVariable-Roman.otf b/fonts/SourceCodeVariable-Roman.otf deleted file mode 100644 index 30046987..00000000 Binary files a/fonts/SourceCodeVariable-Roman.otf and /dev/null differ diff --git a/fonts/VarelaRound-Regular.ttf b/fonts/VarelaRound-Regular.ttf deleted file mode 100644 index 54fa9f94..00000000 Binary files a/fonts/VarelaRound-Regular.ttf and /dev/null differ diff --git a/renovate.json b/renovate.json index 5f2db716..37b9e06b 100644 --- a/renovate.json +++ b/renovate.json @@ -65,6 +65,19 @@ "groupName": "mupdf", "groupSlug": "mupdf", "addLabels": ["thirdparty"] + }, + { + "description": "Group bundled font release pins and font git submodules", + "matchManagers": ["custom.regex", "git-submodules"], + "matchPackageNames": [ + "nicoverbruggen/ebook-fonts", + "alerque/libertinus", + "adobe-fonts/source-code-pro", + "thirdparty/noto-fonts", + "thirdparty/google-fonts" + ], + "groupName": "bundled fonts", + "groupSlug": "bundled-fonts" } ], "customManagers": [ @@ -186,6 +199,42 @@ "depNameTemplate": "baskerville/plato", "versioningTemplate": "semver" }, + { + "customType": "regex", + "managerFilePatterns": [ + "/^xtask\\/src\\/tasks\\/download_fonts\\/.*\\.rs$/" + ], + "matchStrings": [ + "EBOOK_FONTS_VERSION: &str = \\\"(?[^\\\"]+)\\\"" + ], + "datasourceTemplate": "github-releases", + "depNameTemplate": "nicoverbruggen/ebook-fonts", + "versioningTemplate": "semver-coerced" + }, + { + "customType": "regex", + "managerFilePatterns": [ + "/^xtask\\/src\\/tasks\\/download_fonts\\/.*\\.rs$/" + ], + "matchStrings": [ + "LIBERTINUS_VERSION: &str = \\\"(?[^\\\"]+)\\\"" + ], + "datasourceTemplate": "github-releases", + "depNameTemplate": "alerque/libertinus", + "versioningTemplate": "semver-coerced" + }, + { + "customType": "regex", + "managerFilePatterns": [ + "/^xtask\\/src\\/tasks\\/download_fonts\\/.*\\.rs$/" + ], + "matchStrings": [ + "SOURCE_CODE_RELEASE: &str = \\\"(?[^\\\"]+)\\\"" + ], + "datasourceTemplate": "github-releases", + "depNameTemplate": "adobe-fonts/source-code-pro", + "versioningTemplate": "semver-coerced" + }, { "customType": "regex", "managerFilePatterns": ["/^crates\\/build-deps\\/src\\/versions\\.rs$/"], diff --git a/thirdparty/google-fonts b/thirdparty/google-fonts new file mode 160000 index 00000000..ec0464b9 --- /dev/null +++ b/thirdparty/google-fonts @@ -0,0 +1 @@ +Subproject commit ec0464b978de222073645d6d3366f3fdf03376d8 diff --git a/thirdparty/noto-fonts b/thirdparty/noto-fonts new file mode 160000 index 00000000..20bc5918 --- /dev/null +++ b/thirdparty/noto-fonts @@ -0,0 +1 @@ +Subproject commit 20bc5918912503bc1537a407a694738c33c048aa diff --git a/xtask/src/lib.rs b/xtask/src/lib.rs index 66132c6f..209aea49 100644 --- a/xtask/src/lib.rs +++ b/xtask/src/lib.rs @@ -26,6 +26,7 @@ //! | [`install-importer`](tasks::install_importer) | Install the Cadmus importer crate | //! | [`docs`](tasks::docs) | Build the full documentation website | //! | [`download-assets`](tasks::download_assets) | Download static asset dirs from the latest release | +//! | [`download-fonts`](tasks::download_fonts) | Download bundled font files into `fonts/` | //! | [`dist`](tasks::dist) | Assemble the Kobo distribution directory | //! | [`bundle`](tasks::bundle) | Package a `KoboRoot.tgz` ready for device installation | //! | [`setup`](tasks::setup) | Build thirdparty deps (SQLite) needed before `cargo build` | @@ -81,6 +82,8 @@ pub enum Command { Docs(DocsArgs), /// Download static asset directories from the latest GitHub release. DownloadAssets, + /// Download bundled font files into the workspace `fonts/` directory. + DownloadFonts, /// Assemble the Kobo distribution directory from build outputs. Dist(DistArgs), /// Package a KoboRoot.tgz ready for device installation. @@ -108,6 +111,7 @@ pub fn run() -> Result<()> { Command::InstallImporter(args) => tasks::install_importer::run(args), Command::Docs(args) => tasks::docs::run(args), Command::DownloadAssets => tasks::download_assets::run(), + Command::DownloadFonts => tasks::download_fonts::run(), Command::Dist(args) => tasks::dist::run(args), Command::Bundle(args) => tasks::bundle::run(args), Command::Setup(args) => tasks::setup::run(args), diff --git a/xtask/src/tasks/dist.rs b/xtask/src/tasks/dist.rs index 0ddb9136..7fc8b7a5 100644 --- a/xtask/src/tasks/dist.rs +++ b/xtask/src/tasks/dist.rs @@ -126,9 +126,14 @@ fn copy_assets(root: &Path, dist_dir: &Path) -> Result<()> { for dir in dirs { let src = root.join(dir); if !src.exists() { + let hint = if dir == "fonts" { + "Run `cargo xtask download-fonts` to download it." + } else { + "Run `cargo xtask download-assets` to download it." + }; bail!( "Required asset directory '{}' not found.\n\ - Run `cargo xtask download-assets` to download it.", + {hint}", src.display() ); } diff --git a/xtask/src/tasks/download_fonts/font/ebook.rs b/xtask/src/tasks/download_fonts/font/ebook.rs new file mode 100644 index 00000000..9c66b03f --- /dev/null +++ b/xtask/src/tasks/download_fonts/font/ebook.rs @@ -0,0 +1,213 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use build_deps::markers; + +use crate::tasks::download_fonts::util; +use crate::tasks::util::{fs, github}; + +const REPO: &str = "nicoverbruggen/ebook-fonts"; +/// Tracked by Renovate via a regex manager in `renovate.json`. +pub const EBOOK_FONTS_VERSION: &str = "v2026.07.02"; +const CORE_ASSET: &str = "other-core-fonts.zip"; +const EXTRA_ASSET: &str = "other-extra-fonts.zip"; + +const CORE_FILES: &[&str] = &[ + "Cartisse-Bold.ttf", + "Cartisse-BoldItalic.ttf", + "Cartisse-Italic.ttf", + "Cartisse-Regular.ttf", + "Libron-Bold.ttf", + "Libron-BoldItalic.ttf", + "Libron-Italic.ttf", + "Libron-Regular.ttf", + "NV_Bitter-Bold.ttf", + "NV_Bitter-BoldItalic.ttf", + "NV_Bitter-Italic.ttf", + "NV_Bitter-Regular.ttf", + "NV_Charis-Bold.ttf", + "NV_Charis-BoldItalic.ttf", + "NV_Charis-Italic.ttf", + "NV_Charis-Regular.ttf", + "NV_Garamond-Bold.ttf", + "NV_Garamond-BoldItalic.ttf", + "NV_Garamond-Italic.ttf", + "NV_Garamond-Regular.ttf", + "NV_Jost-Bold.ttf", + "NV_Jost-BoldItalic.ttf", + "NV_Jost-Italic.ttf", + "NV_Jost-Regular.ttf", + "NV_Legible_Next-Bold.ttf", + "NV_Legible_Next-BoldItalic.ttf", + "NV_Legible_Next-Italic.ttf", + "NV_Legible_Next-Regular.ttf", + "NV_Palatium-Bold.ttf", + "NV_Palatium-BoldItalic.ttf", + "NV_Palatium-Italic.ttf", + "NV_Palatium-Regular.ttf", + "Sourcerer-Bold.ttf", + "Sourcerer-BoldItalic.ttf", + "Sourcerer-Italic.ttf", + "Sourcerer-Regular.ttf", +]; + +const EXTRA_FILES: &[&str] = &[ + "NV_Libertinus-Bold.ttf", + "NV_Libertinus-BoldItalic.ttf", + "NV_Libertinus-Italic.ttf", + "NV_Libertinus-Regular.ttf", +]; + +pub fn install(root: &Path, fonts_dir: &Path) -> Result<()> { + if is_complete(fonts_dir) { + return Ok(()); + } + + if !markers::is_version_current(fonts_dir, EBOOK_FONTS_VERSION) { + remove_managed_files(fonts_dir)?; + } + + let cache_dir = root.join(format!(".cache/ebook-fonts/{EBOOK_FONTS_VERSION}")); + + let core_archive = download_release_asset(&cache_dir, CORE_ASSET)?; + util::extract_cached_archive( + &core_archive, + || download_release_asset(&cache_dir, CORE_ASSET).map(|_| ()), + |archive| { + fs::extract_zip_matching_flat(archive, fonts_dir, "", ".ttf") + .context("failed to extract core fonts from ebook-fonts archive") + }, + )?; + + let extra_archive = download_release_asset(&cache_dir, EXTRA_ASSET)?; + util::extract_cached_archive( + &extra_archive, + || download_release_asset(&cache_dir, EXTRA_ASSET).map(|_| ()), + |archive| { + fs::extract_zip_matching_flat(archive, fonts_dir, "NV_Libertinus", ".ttf") + .context("failed to extract extra fonts from ebook-fonts extra archive") + }, + )?; + + markers::mark_version(fonts_dir, "ebook-fonts", EBOOK_FONTS_VERSION)?; + Ok(()) +} + +pub fn is_complete(fonts_dir: &Path) -> bool { + managed_files().all(|name| fonts_dir.join(name).exists()) + && markers::is_version_current(fonts_dir, EBOOK_FONTS_VERSION) +} + +fn managed_files() -> impl Iterator { + CORE_FILES.iter().chain(EXTRA_FILES.iter()).copied() +} + +fn remove_managed_files(fonts_dir: &Path) -> Result<()> { + for name in managed_files() { + let path = fonts_dir.join(name); + if path.exists() { + std::fs::remove_file(&path) + .with_context(|| format!("failed to remove stale font file {name}"))?; + } + } + Ok(()) +} + +fn download_release_asset(cache_dir: &Path, asset: &str) -> Result { + util::ensure_cached_release_asset(cache_dir, asset, || { + println!("Fetching {asset} from {REPO} {EBOOK_FONTS_VERSION}…"); + github::fetch_release_asset(REPO, EBOOK_FONTS_VERSION, asset) + }) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use super::*; + + fn write_test_zip(path: &Path, entries: &[(&str, &[u8])]) { + let file = fs::File::create(path).unwrap(); + let mut writer = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + + for (name, contents) in entries { + writer.start_file(*name, options).unwrap(); + std::io::Write::write_all(&mut writer, contents).unwrap(); + } + + writer.finish().unwrap(); + } + + fn touch_managed_files(fonts_dir: &Path, contents: &[u8]) { + for name in managed_files() { + fs::write(fonts_dir.join(name), contents).unwrap(); + } + } + + fn seed_ebook_cache(root: &Path, libron_contents: &[u8]) { + let cache_dir = root.join(format!(".cache/ebook-fonts/{EBOOK_FONTS_VERSION}")); + fs::create_dir_all(&cache_dir).unwrap(); + + let core_entries: Vec<(&str, &[u8])> = CORE_FILES + .iter() + .map(|name| { + let content = if *name == "Libron-Regular.ttf" { + libron_contents + } else { + b"core".as_slice() + }; + (*name, content) + }) + .collect(); + write_test_zip(&cache_dir.join(CORE_ASSET), &core_entries); + + let extra_entries: Vec<(&str, &[u8])> = EXTRA_FILES + .iter() + .map(|name| (*name, b"extra".as_slice())) + .collect(); + write_test_zip(&cache_dir.join(EXTRA_ASSET), &extra_entries); + } + + #[test] + fn is_complete_false_when_version_stale() { + let fonts_dir = tempfile::tempdir().unwrap(); + touch_managed_files(fonts_dir.path(), b"font"); + markers::mark_version(fonts_dir.path(), "ebook-fonts", "v2020.01.01").unwrap(); + + assert!(!is_complete(fonts_dir.path())); + } + + #[test] + fn is_complete_true_when_up_to_date() { + let fonts_dir = tempfile::tempdir().unwrap(); + touch_managed_files(fonts_dir.path(), b"font"); + markers::mark_version(fonts_dir.path(), "ebook-fonts", EBOOK_FONTS_VERSION).unwrap(); + + assert!(is_complete(fonts_dir.path())); + } + + #[test] + fn install_refreshes_on_stale_version() { + let root = tempfile::tempdir().unwrap(); + let fonts_dir = tempfile::tempdir().unwrap(); + seed_ebook_cache(root.path(), b"fresh"); + + fs::write(fonts_dir.path().join("Libron-Regular.ttf"), b"stale").unwrap(); + markers::mark_version(fonts_dir.path(), "ebook-fonts", "v2020.01.01").unwrap(); + + install(root.path(), fonts_dir.path()).unwrap(); + + assert_eq!( + fs::read(fonts_dir.path().join("Libron-Regular.ttf")).unwrap(), + b"fresh" + ); + assert!(markers::is_version_current( + fonts_dir.path(), + EBOOK_FONTS_VERSION + )); + assert!(is_complete(fonts_dir.path())); + } +} diff --git a/xtask/src/tasks/download_fonts/font/google.rs b/xtask/src/tasks/download_fonts/font/google.rs new file mode 100644 index 00000000..9690fa0c --- /dev/null +++ b/xtask/src/tasks/download_fonts/font/google.rs @@ -0,0 +1,29 @@ +use std::path::Path; + +use anyhow::Result; + +use super::super::util; + +const SUBMODULE: &str = "thirdparty/google-fonts"; +const MARKER_NAME: &str = "google-fonts"; + +const FILES: &[(&str, &str)] = &[ + ( + "VarelaRound-Regular.ttf", + "ofl/varelaround/VarelaRound-Regular.ttf", + ), + ("Cormorant-Regular.ttf", "ofl/cormorant/Cormorant[wght].ttf"), + ( + "Parisienne-Regular.ttf", + "ofl/parisienne/Parisienne-Regular.ttf", + ), + ("Delius-Regular.ttf", "ofl/delius/Delius-Regular.ttf"), +]; + +pub(crate) fn is_complete(root: &Path, fonts_dir: &Path) -> bool { + util::is_submodule_install_current(root, fonts_dir, SUBMODULE, MARKER_NAME, FILES) +} + +pub(crate) fn install(root: &Path, fonts_dir: &Path) -> Result<()> { + util::install_from_submodule(root, SUBMODULE, fonts_dir, MARKER_NAME, FILES) +} diff --git a/xtask/src/tasks/download_fonts/font/libertinus.rs b/xtask/src/tasks/download_fonts/font/libertinus.rs new file mode 100644 index 00000000..bd72dc5b --- /dev/null +++ b/xtask/src/tasks/download_fonts/font/libertinus.rs @@ -0,0 +1,66 @@ +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::tasks::download_fonts::util; +use crate::tasks::util::{fs, github}; + +const REPO: &str = "alerque/libertinus"; +/// Tracked by Renovate via a regex manager in `renovate.json`. +/// +/// GitHub release tags use a `v` prefix; archive names and in-zip paths omit it +/// (e.g. tag `v7.051` → asset `Libertinus-7.051.zip`). +pub const LIBERTINUS_VERSION: &str = "v7.051"; + +const FILES: &[&str] = &[ + "LibertinusSerif-Regular.otf", + "LibertinusSerif-Italic.otf", + "LibertinusSerif-Bold.otf", + "LibertinusSerif-BoldItalic.otf", + "LibertinusSans-Regular.otf", + "LibertinusSans-Italic.otf", + "LibertinusSans-Bold.otf", + "LibertinusMono-Regular.otf", +]; + +pub fn is_complete(fonts_dir: &Path) -> bool { + FILES.iter().all(|name| fonts_dir.join(name).exists()) +} + +pub fn install(root: &Path, fonts_dir: &Path) -> Result<()> { + if is_complete(fonts_dir) { + return Ok(()); + } + + let cache_dir = root.join(format!(".cache/libertinus/{LIBERTINUS_VERSION}")); + let archive_version = LIBERTINUS_VERSION + .strip_prefix('v') + .unwrap_or(LIBERTINUS_VERSION); + let asset_name = format!("Libertinus-{archive_version}.zip"); + + let archive = util::ensure_cached_release_asset(&cache_dir, &asset_name, || { + println!("Fetching {asset_name} from {REPO} {LIBERTINUS_VERSION}…"); + github::fetch_release_asset(REPO, LIBERTINUS_VERSION, &asset_name) + })?; + + let otf_prefix = format!("Libertinus-{archive_version}/static/OTF/"); + let entries: Vec<(String, String)> = FILES + .iter() + .map(|name| (format!("{otf_prefix}{name}"), (*name).to_string())) + .collect(); + + util::extract_cached_archive( + &archive, + || { + util::ensure_cached_release_asset(&cache_dir, &asset_name, || { + println!("Re-fetching {asset_name} from {REPO} {LIBERTINUS_VERSION}…"); + github::fetch_release_asset(REPO, LIBERTINUS_VERSION, &asset_name) + }) + .map(|_| ()) + }, + |archive| { + fs::extract_zip_entries_flat(archive, fonts_dir, &entries) + .context("failed to extract Libertinus fonts") + }, + ) +} diff --git a/xtask/src/tasks/download_fonts/font/mod.rs b/xtask/src/tasks/download_fonts/font/mod.rs new file mode 100644 index 00000000..05d7ebc9 --- /dev/null +++ b/xtask/src/tasks/download_fonts/font/mod.rs @@ -0,0 +1,46 @@ +//! Bundled font sources for [`super::run`]. +//! +//! Each submodule corresponds to one upstream (ebook-fonts, Libertinus, Noto, +//! Source Code, Google Fonts) and exposes its own [`install`](ebook::install) +//! and [`is_complete`](ebook::is_complete). [`install`] and [`is_complete`] +//! at this level orchestrate every source. + +use std::path::Path; + +use anyhow::{Context, Result}; + +use build_deps::ensure_submodules; + +pub mod ebook; +pub mod google; +pub mod libertinus; +pub mod noto; +pub mod source_code; + +/// Installs every bundled font source into `fonts_dir`. +/// +/// Downloads are cached under `root/.cache/`. Submodule-backed sources rely on +/// [`ensure_submodules`] from `build-deps`. Existing destination files are left +/// untouched. +/// +/// # Errors +/// +/// Returns an error if any source fails to download or extract. +pub fn install(root: &Path, fonts_dir: &Path) -> Result<()> { + ensure_submodules(root).context("failed to initialise git submodules")?; + ebook::install(root, fonts_dir)?; + libertinus::install(root, fonts_dir)?; + noto::install(root, fonts_dir)?; + source_code::install(root, fonts_dir)?; + google::install(root, fonts_dir)?; + Ok(()) +} + +/// Returns `true` when every bundled font source is already present in `fonts_dir`. +pub fn is_complete(root: &Path, fonts_dir: &Path) -> bool { + ebook::is_complete(fonts_dir) + && libertinus::is_complete(fonts_dir) + && noto::is_complete(root, fonts_dir) + && source_code::is_complete(fonts_dir) + && google::is_complete(root, fonts_dir) +} diff --git a/xtask/src/tasks/download_fonts/font/noto.rs b/xtask/src/tasks/download_fonts/font/noto.rs new file mode 100644 index 00000000..ffb64433 --- /dev/null +++ b/xtask/src/tasks/download_fonts/font/noto.rs @@ -0,0 +1,48 @@ +use std::path::Path; + +use anyhow::Result; + +use super::super::util; + +const SUBMODULE: &str = "thirdparty/noto-fonts"; +const MARKER_NAME: &str = "noto-fonts"; + +const FILES: &[(&str, &str)] = &[ + ( + "NotoSans-Regular.ttf", + "hinted/ttf/NotoSans/NotoSans-Regular.ttf", + ), + ( + "NotoSans-Italic.ttf", + "hinted/ttf/NotoSans/NotoSans-Italic.ttf", + ), + ("NotoSans-Bold.ttf", "hinted/ttf/NotoSans/NotoSans-Bold.ttf"), + ( + "NotoSans-BoldItalic.ttf", + "hinted/ttf/NotoSans/NotoSans-BoldItalic.ttf", + ), + ( + "NotoSerif-Regular.ttf", + "hinted/ttf/NotoSerif/NotoSerif-Regular.ttf", + ), + ( + "NotoSerif-Italic.ttf", + "hinted/ttf/NotoSerif/NotoSerif-Italic.ttf", + ), + ( + "NotoSerif-Bold.ttf", + "hinted/ttf/NotoSerif/NotoSerif-Bold.ttf", + ), + ( + "NotoSerif-BoldItalic.ttf", + "hinted/ttf/NotoSerif/NotoSerif-BoldItalic.ttf", + ), +]; + +pub(crate) fn is_complete(root: &Path, fonts_dir: &Path) -> bool { + util::is_submodule_install_current(root, fonts_dir, SUBMODULE, MARKER_NAME, FILES) +} + +pub(crate) fn install(root: &Path, fonts_dir: &Path) -> Result<()> { + util::install_from_submodule(root, SUBMODULE, fonts_dir, MARKER_NAME, FILES) +} diff --git a/xtask/src/tasks/download_fonts/font/source_code.rs b/xtask/src/tasks/download_fonts/font/source_code.rs new file mode 100644 index 00000000..ba0abba0 --- /dev/null +++ b/xtask/src/tasks/download_fonts/font/source_code.rs @@ -0,0 +1,60 @@ +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::tasks::download_fonts::util; +use crate::tasks::util::{fs, github}; + +const REPO: &str = "adobe-fonts/source-code-pro"; +/// Tracked by Renovate via a regex manager in `renovate.json`. +pub const SOURCE_CODE_RELEASE: &str = "2.042R-u/1.062R-i/1.026R-vf"; +/// Asset name bundled in [`SOURCE_CODE_RELEASE`]; update manually when the +/// upstream release layout changes. +const VF_ASSET: &str = "VF-source-code-VF-1.026R.zip"; + +const FILES: &[&str] = &[ + "SourceCodeVariable-Roman.otf", + "SourceCodeVariable-Italic.otf", +]; + +const ZIP_ENTRIES: &[(&str, &str)] = &[ + ( + "VF/SourceCodeVF-Upright.otf", + "SourceCodeVariable-Roman.otf", + ), + ( + "VF/SourceCodeVF-Italic.otf", + "SourceCodeVariable-Italic.otf", + ), +]; + +pub fn is_complete(fonts_dir: &Path) -> bool { + FILES.iter().all(|name| fonts_dir.join(name).exists()) +} + +pub fn install(root: &Path, fonts_dir: &Path) -> Result<()> { + if is_complete(fonts_dir) { + return Ok(()); + } + + let cache_dir = root.join(format!(".cache/source-code-pro/{SOURCE_CODE_RELEASE}")); + let archive = util::ensure_cached_release_asset(&cache_dir, VF_ASSET, || { + println!("Fetching {VF_ASSET} from {REPO} {SOURCE_CODE_RELEASE}…"); + github::fetch_release_asset(REPO, SOURCE_CODE_RELEASE, VF_ASSET) + })?; + + util::extract_cached_archive( + &archive, + || { + util::ensure_cached_release_asset(&cache_dir, VF_ASSET, || { + println!("Re-fetching {VF_ASSET} from {REPO} {SOURCE_CODE_RELEASE}…"); + github::fetch_release_asset(REPO, SOURCE_CODE_RELEASE, VF_ASSET) + }) + .map(|_| ()) + }, + |archive| { + fs::extract_zip_entries_flat(archive, fonts_dir, ZIP_ENTRIES) + .context("failed to extract Source Code VF fonts") + }, + ) +} diff --git a/xtask/src/tasks/download_fonts/mod.rs b/xtask/src/tasks/download_fonts/mod.rs new file mode 100644 index 00000000..8ee494db --- /dev/null +++ b/xtask/src/tasks/download_fonts/mod.rs @@ -0,0 +1,59 @@ +//! `cargo xtask download-fonts` — download bundled font files for Cadmus. +//! +//! Assembles the `fonts/` directory from reader sources and pinned UI +//! upstreams. Each source lives under [`font`] and exposes an `install` +//! function. +//! +//! ## Caching +//! +//! Release archives are cached locally under `.cache/` during download. +//! CI restores the assembled `fonts/` directory via the `cache-fonts` action. +//! Noto and Google UI fonts are copied from git submodules under `thirdparty/`. + +mod font; +mod util; + +use std::path::Path; + +use anyhow::{Context, Result, bail}; + +use crate::tasks::util::workspace; + +pub use font::ebook::EBOOK_FONTS_VERSION; +pub use font::libertinus::LIBERTINUS_VERSION; +pub use font::source_code::SOURCE_CODE_RELEASE; + +/// Downloads and assembles the workspace `fonts/` directory. +/// +/// Skips network I/O when every expected file is already present. +/// +/// # Errors +/// +/// Returns an error if any download or extraction step fails. +pub fn run() -> Result<()> { + let root = workspace::root()?; + let fonts_dir = root.join("fonts"); + + if all_fonts_present(&root, &fonts_dir) { + println!("All font files already present in fonts/, skipping download."); + return Ok(()); + } + + std::fs::create_dir_all(&fonts_dir).context("failed to create fonts/ directory")?; + + font::install(&root, &fonts_dir)?; + + if !all_fonts_present(&root, &fonts_dir) { + bail!("fonts/ is still incomplete after download-fonts"); + } + + let file_count = std::fs::read_dir(&fonts_dir) + .map(|entries| entries.filter_map(Result::ok).count()) + .unwrap_or(0); + println!("fonts/ is ready ({file_count} files)."); + Ok(()) +} + +fn all_fonts_present(root: &Path, fonts_dir: &Path) -> bool { + fonts_dir.exists() && font::is_complete(root, fonts_dir) +} diff --git a/xtask/src/tasks/download_fonts/util.rs b/xtask/src/tasks/download_fonts/util.rs new file mode 100644 index 00000000..4db3200c --- /dev/null +++ b/xtask/src/tasks/download_fonts/util.rs @@ -0,0 +1,417 @@ +//! Shared helpers for font sources. +//! +//! Font submodules under [`super::font`] copy files from `thirdparty/` via +//! [`install_from_submodule`]. Release archives are extracted with helpers in +//! [`crate::tasks::util::fs`]. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use build_deps::markers; + +use crate::tasks::util::github::{self, Asset}; + +fn submodule_marker_dir(fonts_dir: &Path, name: &str) -> PathBuf { + fonts_dir.join(format!(".markers/{name}")) +} + +/// Returns `true` when `files` are present and the submodule gitlink matches +/// the recorded marker under `fonts_dir/.markers/{name}/`. +pub(crate) fn is_submodule_install_current( + root: &Path, + fonts_dir: &Path, + submodule: &str, + name: &str, + files: &[(&str, &str)], +) -> bool { + let marker_dir = submodule_marker_dir(fonts_dir, name); + files.iter().all(|(dest, _)| fonts_dir.join(dest).exists()) + && markers::is_built(root, &marker_dir, submodule) +} + +/// Copies `files` from a submodule into `fonts_dir`. +/// +/// Each entry is `(dest_filename, path_relative_to_submodule_root)`. When the +/// submodule gitlink changes, managed destination files are removed and +/// recopied. +/// +/// Call [`build_deps::ensure_submodules`] before invoking this function. +/// +/// # Errors +/// +/// Returns an error if the submodule is missing a source file or a copy fails. +pub(crate) fn install_from_submodule( + root: &Path, + submodule: &str, + fonts_dir: &Path, + name: &str, + files: &[(&str, &str)], +) -> Result<()> { + let marker_dir = submodule_marker_dir(fonts_dir, name); + if is_submodule_install_current(root, fonts_dir, submodule, name, files) { + return Ok(()); + } + + if !markers::is_built(root, &marker_dir, submodule) { + for (dest_name, _) in files { + let dest = fonts_dir.join(dest_name); + if dest.exists() { + std::fs::remove_file(&dest) + .with_context(|| format!("failed to remove stale font file {dest_name}"))?; + } + } + } + + let submodule_root = root.join(submodule); + if !submodule_root.is_dir() { + anyhow::bail!( + "{submodule} not found — run `git submodule update --init --recursive` first" + ); + } + + for &(dest_name, rel_path) in files { + let dest = fonts_dir.join(dest_name); + let src = submodule_root.join(rel_path); + if !src.is_file() { + anyhow::bail!( + "missing font file {} in submodule {submodule}", + src.display() + ); + } + println!("Copying {dest_name} from {submodule}/{rel_path}…"); + std::fs::copy(&src, &dest) + .with_context(|| format!("failed to copy {dest_name} from {submodule}"))?; + } + + std::fs::create_dir_all(&marker_dir).with_context(|| { + format!( + "failed to create submodule marker directory {}", + marker_dir.display() + ) + })?; + markers::mark_built(root, &marker_dir, name, submodule)?; + Ok(()) +} + +/// Returns the path to a cached release archive, downloading it when missing. +/// +/// # Errors +/// +/// Returns an error if the cache directory cannot be created or the download +/// fails. +pub(crate) fn ensure_cached_release_asset( + cache_dir: &Path, + asset_name: &str, + fetch: impl FnOnce() -> Result, +) -> Result { + ensure_cached_release_asset_with(cache_dir, asset_name, fetch, github::download_asset) +} + +pub(crate) fn ensure_cached_release_asset_with( + cache_dir: &Path, + asset_name: &str, + fetch: impl FnOnce() -> Result, + download: impl FnOnce(&Asset, &Path) -> Result<()>, +) -> Result { + std::fs::create_dir_all(cache_dir) + .with_context(|| format!("failed to create cache dir {}", cache_dir.display()))?; + + let archive = cache_dir.join(asset_name); + if !archive.exists() { + let asset = fetch()?; + println!("Downloading {asset_name}…"); + download(&asset, &archive).with_context(|| format!("failed to download {asset_name}"))?; + } else { + println!("Using cached {asset_name}"); + } + + Ok(archive) +} + +/// Extracts a cached archive, re-downloading once when extraction fails. +/// +/// # Errors +/// +/// Returns an error if download or extraction fails after one retry. +pub(crate) fn extract_cached_archive( + archive: &Path, + redownload: impl FnOnce() -> Result<()>, + extract: F, +) -> Result<()> +where + F: Fn(&Path) -> Result<()>, +{ + match extract(archive) { + Ok(()) => Ok(()), + Err(first) => { + std::fs::remove_file(archive).ok(); + redownload().with_context(|| { + format!( + "failed to re-download {} after corrupt cache", + archive.display() + ) + })?; + extract(archive).with_context(|| { + format!( + "failed to extract {} after re-download: {first:#}", + archive.display() + ) + }) + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::fs; + use std::path::Path; + + use super::*; + use crate::tasks::util::fs::extract_zip_matching_flat; + use crate::tasks::util::github::Asset; + use crate::tasks::util::workspace; + + const GOOGLE_SUBMODULE: &str = "thirdparty/google-fonts"; + const GOOGLE_MARKER: &str = "google-fonts"; + const TEST_FILE: (&str, &str) = ( + "VarelaRound-Regular.ttf", + "ofl/varelaround/VarelaRound-Regular.ttf", + ); + + fn write_test_zip(path: &Path, entries: &[(&str, &[u8])]) { + let file = std::fs::File::create(path).unwrap(); + let mut writer = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + + for (name, contents) in entries { + writer.start_file(*name, options).unwrap(); + std::io::Write::write_all(&mut writer, contents).unwrap(); + } + + writer.finish().unwrap(); + } + + fn dummy_asset() -> Asset { + Asset { + browser_download_url: "https://example.com/asset.zip".to_owned(), + name: "asset.zip".to_owned(), + digest: None, + } + } + + fn google_marker_dir(fonts_dir: &Path) -> PathBuf { + fonts_dir.join(format!(".markers/{GOOGLE_MARKER}")) + } + + fn write_google_marker(fonts_dir: &Path, sha: &str) { + let marker_dir = google_marker_dir(fonts_dir); + fs::create_dir_all(&marker_dir).unwrap(); + markers::mark_version(&marker_dir, GOOGLE_MARKER, sha).unwrap(); + } + + #[test] + fn extract_succeeds_on_valid_archive() { + let tmp = tempfile::tempdir().unwrap(); + let archive = tmp.path().join("fonts.zip"); + write_test_zip(&archive, &[("Libron-Regular.ttf", b"font")]); + + let extract_dir = tmp.path().join("out"); + extract_cached_archive( + &archive, + || Ok(()), + |path| extract_zip_matching_flat(path, &extract_dir, "", ".ttf"), + ) + .unwrap(); + + assert!(archive.exists()); + assert_eq!( + fs::read(extract_dir.join("Libron-Regular.ttf")).unwrap(), + b"font" + ); + } + + #[test] + fn extract_retries_after_corrupt_archive() { + let tmp = tempfile::tempdir().unwrap(); + let archive = tmp.path().join("fonts.zip"); + fs::write(&archive, b"not a zip").unwrap(); + + let extract_dir = tmp.path().join("out"); + extract_cached_archive( + &archive, + || { + write_test_zip(&archive, &[("Libron-Regular.ttf", b"fresh")]); + Ok(()) + }, + |path| extract_zip_matching_flat(path, &extract_dir, "", ".ttf"), + ) + .unwrap(); + + assert_eq!( + fs::read(extract_dir.join("Libron-Regular.ttf")).unwrap(), + b"fresh" + ); + } + + #[test] + fn ensure_cached_uses_existing_file() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = tmp.path().join("cache"); + let asset_name = "other-core-fonts.zip"; + let archive = cache_dir.join(asset_name); + fs::create_dir_all(&cache_dir).unwrap(); + fs::write(&archive, b"cached").unwrap(); + + let fetch_called = Cell::new(false); + let path = ensure_cached_release_asset_with( + &cache_dir, + asset_name, + || { + fetch_called.set(true); + Ok(dummy_asset()) + }, + |_, _| Ok(()), + ) + .unwrap(); + + assert_eq!(path, archive); + assert!(!fetch_called.get()); + assert_eq!(fs::read(&archive).unwrap(), b"cached"); + } + + #[test] + fn ensure_cached_downloads_when_missing() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = tmp.path().join("cache"); + let asset_name = "other-core-fonts.zip"; + let fixture = tmp.path().join("fixture.zip"); + write_test_zip(&fixture, &[("Libron-Regular.ttf", b"downloaded")]); + + let fetch_called = Cell::new(false); + let path = ensure_cached_release_asset_with( + &cache_dir, + asset_name, + || { + fetch_called.set(true); + Ok(dummy_asset()) + }, + |_, dest| { + fs::copy(&fixture, dest)?; + Ok(()) + }, + ) + .unwrap(); + + assert!(fetch_called.get()); + assert_eq!(path, cache_dir.join(asset_name)); + assert!(path.exists()); + } + + #[test] + fn is_submodule_install_current_false_without_files() { + let root = workspace::root().unwrap(); + let sha = markers::submodule_commit(&root, GOOGLE_SUBMODULE).unwrap(); + let fonts_dir = tempfile::tempdir().unwrap(); + write_google_marker(fonts_dir.path(), &sha); + + assert!(!is_submodule_install_current( + &root, + fonts_dir.path(), + GOOGLE_SUBMODULE, + GOOGLE_MARKER, + &[TEST_FILE], + )); + } + + #[test] + fn is_submodule_install_current_false_with_stale_marker() { + let root = workspace::root().unwrap(); + let fonts_dir = tempfile::tempdir().unwrap(); + fs::write(fonts_dir.path().join(TEST_FILE.0), b"font").unwrap(); + write_google_marker(fonts_dir.path(), "0".repeat(40).as_str()); + + assert!(!is_submodule_install_current( + &root, + fonts_dir.path(), + GOOGLE_SUBMODULE, + GOOGLE_MARKER, + &[TEST_FILE], + )); + } + + #[test] + #[ignore = "requires initialized thirdparty/google-fonts submodule"] + fn install_from_submodule_copies_and_skips() { + let root = workspace::root().unwrap(); + let fonts_dir = tempfile::tempdir().unwrap(); + let files = &[TEST_FILE]; + + install_from_submodule( + &root, + GOOGLE_SUBMODULE, + fonts_dir.path(), + GOOGLE_MARKER, + files, + ) + .unwrap(); + assert!(fonts_dir.path().join(TEST_FILE.0).exists()); + + fs::write(fonts_dir.path().join(TEST_FILE.0), b"junk").unwrap(); + install_from_submodule( + &root, + GOOGLE_SUBMODULE, + fonts_dir.path(), + GOOGLE_MARKER, + files, + ) + .unwrap(); + assert_eq!( + fs::read(fonts_dir.path().join(TEST_FILE.0)).unwrap(), + b"junk" + ); + assert!(is_submodule_install_current( + &root, + fonts_dir.path(), + GOOGLE_SUBMODULE, + GOOGLE_MARKER, + files, + )); + } + + #[test] + #[ignore = "requires initialized thirdparty/google-fonts submodule"] + fn install_from_submodule_removes_stale_dest_on_revision_change() { + let root = workspace::root().unwrap(); + let fonts_dir = tempfile::tempdir().unwrap(); + let files = &[TEST_FILE]; + let expected = fs::read(root.join(GOOGLE_SUBMODULE).join(TEST_FILE.1)).unwrap(); + + install_from_submodule( + &root, + GOOGLE_SUBMODULE, + fonts_dir.path(), + GOOGLE_MARKER, + files, + ) + .unwrap(); + + write_google_marker(fonts_dir.path(), "0".repeat(40).as_str()); + fs::write(fonts_dir.path().join(TEST_FILE.0), b"junk").unwrap(); + + install_from_submodule( + &root, + GOOGLE_SUBMODULE, + fonts_dir.path(), + GOOGLE_MARKER, + files, + ) + .unwrap(); + + assert_eq!( + fs::read(fonts_dir.path().join(TEST_FILE.0)).unwrap(), + expected + ); + } +} diff --git a/xtask/src/tasks/mod.rs b/xtask/src/tasks/mod.rs index 6d5d3e34..5c482519 100644 --- a/xtask/src/tasks/mod.rs +++ b/xtask/src/tasks/mod.rs @@ -11,6 +11,7 @@ pub mod clippy; pub mod dist; pub mod docs; pub mod download_assets; +pub mod download_fonts; pub mod fmt; pub mod install_importer; pub mod run_emulator; diff --git a/xtask/src/tasks/util/fs.rs b/xtask/src/tasks/util/fs.rs index 54b4eeb1..baa287de 100644 --- a/xtask/src/tasks/util/fs.rs +++ b/xtask/src/tasks/util/fs.rs @@ -289,6 +289,105 @@ pub fn extract_zip_paths(src: &Path, dest_dir: &Path, prefixes: &[&str]) -> Resu Ok(()) } +/// Extracts zip entries whose basename matches `prefix` and `suffix` into +/// `dest_dir` using only the filename (no nested directories). +/// +/// Existing destination files are skipped. +/// +/// # Errors +/// +/// Returns an error if the archive cannot be read or a matched entry fails to +/// extract. +pub fn extract_zip_matching_flat( + src: &Path, + dest_dir: &Path, + prefix: &str, + suffix: &str, +) -> Result<()> { + std::fs::create_dir_all(dest_dir).with_context(|| { + format!( + "failed to create destination directory {}", + dest_dir.display() + ) + })?; + + let file = std::fs::File::open(src) + .with_context(|| format!("failed to open archive {}", src.display()))?; + let mut archive = zip::ZipArchive::new(file) + .with_context(|| format!("failed to read zip {}", src.display()))?; + + for i in 0..archive.len() { + let mut entry = archive + .by_index(i) + .with_context(|| format!("failed to read entry {i} from {}", src.display()))?; + let name = entry.name().to_owned(); + if entry.is_dir() { + continue; + } + let Some(filename) = Path::new(&name).file_name().and_then(|n| n.to_str()) else { + continue; + }; + if !filename.starts_with(prefix) || !filename.ends_with(suffix) { + continue; + } + let dest = dest_dir.join(filename); + if dest.exists() { + continue; + } + let mut out = std::fs::File::create(&dest) + .with_context(|| format!("failed to create file {}", dest.display()))?; + std::io::copy(&mut entry, &mut out) + .with_context(|| format!("failed to write {}", dest.display()))?; + } + + Ok(()) +} + +/// Extracts named zip entries into `dest_dir`, optionally renaming on extract. +/// +/// Each `entries` pair is `(path_inside_zip, dest_filename)`. Existing +/// destination files are skipped. +/// +/// # Errors +/// +/// Returns an error if the archive cannot be read, an entry is missing, or +/// extraction fails. +pub fn extract_zip_entries_flat( + src: &Path, + dest_dir: &Path, + entries: &[(impl AsRef, impl AsRef)], +) -> Result<()> { + std::fs::create_dir_all(dest_dir).with_context(|| { + format!( + "failed to create destination directory {}", + dest_dir.display() + ) + })?; + + let file = std::fs::File::open(src) + .with_context(|| format!("failed to open archive {}", src.display()))?; + let mut archive = zip::ZipArchive::new(file) + .with_context(|| format!("failed to read zip {}", src.display()))?; + + for (zip_path, dest_name) in entries { + let zip_path = zip_path.as_ref(); + let dest_name = dest_name.as_ref(); + let dest = dest_dir.join(dest_name); + if dest.exists() { + continue; + } + let mut entry = archive + .by_name(zip_path) + .with_context(|| format!("entry '{zip_path}' not found in {}", src.display()))?; + let mut out = std::fs::File::create(&dest) + .with_context(|| format!("failed to create file {}", dest.display()))?; + std::io::copy(&mut entry, &mut out) + .with_context(|| format!("failed to write {}", dest.display()))?; + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -407,4 +506,140 @@ mod tests { assert!(extract_dir.join("libs/libfoo.so").exists()); assert!(!extract_dir.join("other/skip.txt").exists()); } + + fn write_test_zip(path: &Path, entries: &[(&str, &[u8])]) { + let file = std::fs::File::create(path).unwrap(); + let mut writer = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + + for (name, contents) in entries { + writer.start_file(*name, options).unwrap(); + std::io::Write::write_all(&mut writer, contents).unwrap(); + } + + writer.finish().unwrap(); + } + + #[test] + fn extract_zip_matching_flat_extracts_matching_basenames() { + let tmp = tempfile::tempdir().unwrap(); + + let zip_path = tmp.path().join("fonts.zip"); + write_test_zip( + &zip_path, + &[ + ("nested/KF-Libron-Regular.otf", b"libron"), + ("nested/KF-Libron-Bold.otf", b"bold"), + ("nested/Other-Regular.otf", b"other"), + ("readme.txt", b"skip"), + ], + ); + + let extract_dir = tmp.path().join("fonts"); + extract_zip_matching_flat(&zip_path, &extract_dir, "KF-Libron", ".otf").unwrap(); + + assert_eq!( + fs::read(extract_dir.join("KF-Libron-Regular.otf")).unwrap(), + b"libron" + ); + assert_eq!( + fs::read(extract_dir.join("KF-Libron-Bold.otf")).unwrap(), + b"bold" + ); + assert!(!extract_dir.join("Other-Regular.otf").exists()); + assert!(!extract_dir.join("readme.txt").exists()); + } + + #[test] + fn extract_zip_matching_flat_skips_existing_files() { + let tmp = tempfile::tempdir().unwrap(); + + let zip_path = tmp.path().join("fonts.zip"); + write_test_zip(&zip_path, &[("nested/KF-Libron-Regular.otf", b"new")]); + + let extract_dir = tmp.path().join("fonts"); + fs::create_dir_all(&extract_dir).unwrap(); + fs::write(extract_dir.join("KF-Libron-Regular.otf"), b"existing").unwrap(); + + extract_zip_matching_flat(&zip_path, &extract_dir, "KF-Libron", ".otf").unwrap(); + + assert_eq!( + fs::read(extract_dir.join("KF-Libron-Regular.otf")).unwrap(), + b"existing" + ); + } + + #[test] + fn extract_zip_entries_flat_extracts_and_renames_entries() { + let tmp = tempfile::tempdir().unwrap(); + + let zip_path = tmp.path().join("release.zip"); + write_test_zip( + &zip_path, + &[ + ("SourceCodePro-Regular.otf", b"regular"), + ("SourceCodePro-Bold.otf", b"bold"), + ], + ); + + let extract_dir = tmp.path().join("fonts"); + extract_zip_entries_flat( + &zip_path, + &extract_dir, + &[ + ("SourceCodePro-Regular.otf", "Source Code Pro Regular.otf"), + ("SourceCodePro-Bold.otf", "Source Code Pro Bold.otf"), + ], + ) + .unwrap(); + + assert_eq!( + fs::read(extract_dir.join("Source Code Pro Regular.otf")).unwrap(), + b"regular" + ); + assert_eq!( + fs::read(extract_dir.join("Source Code Pro Bold.otf")).unwrap(), + b"bold" + ); + } + + #[test] + fn extract_zip_entries_flat_skips_existing_files() { + let tmp = tempfile::tempdir().unwrap(); + + let zip_path = tmp.path().join("release.zip"); + write_test_zip(&zip_path, &[("SourceCodePro-Regular.otf", b"new")]); + + let extract_dir = tmp.path().join("fonts"); + fs::create_dir_all(&extract_dir).unwrap(); + fs::write(extract_dir.join("Source Code Pro Regular.otf"), b"existing").unwrap(); + + extract_zip_entries_flat( + &zip_path, + &extract_dir, + &[("SourceCodePro-Regular.otf", "Source Code Pro Regular.otf")], + ) + .unwrap(); + + assert_eq!( + fs::read(extract_dir.join("Source Code Pro Regular.otf")).unwrap(), + b"existing" + ); + } + + #[test] + fn extract_zip_entries_flat_errors_on_missing_entry() { + let tmp = tempfile::tempdir().unwrap(); + + let zip_path = tmp.path().join("release.zip"); + write_test_zip(&zip_path, &[("SourceCodePro-Regular.otf", b"regular")]); + + let extract_dir = tmp.path().join("fonts"); + let err = + extract_zip_entries_flat(&zip_path, &extract_dir, &[("missing.otf", "missing.otf")]) + .unwrap_err(); + + assert!(err.to_string().contains("entry 'missing.otf' not found")); + } } diff --git a/xtask/src/tasks/util/github.rs b/xtask/src/tasks/util/github.rs index dfc280f5..8a95c0e2 100644 --- a/xtask/src/tasks/util/github.rs +++ b/xtask/src/tasks/util/github.rs @@ -15,7 +15,7 @@ //! avoids 403 rate-limit errors in GitHub Actions where the token is always //! available. -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::Deserialize; @@ -24,7 +24,7 @@ use serde::Deserialize; /// when available, an `Authorization: Bearer` token from the environment. /// /// Checks `GH_TOKEN` first, then `GITHUB_TOKEN`. -fn client() -> Result { +pub(crate) fn client() -> Result { let mut builder = reqwest::blocking::Client::builder().user_agent("cargo-xtask/cadmus"); if let Some(token) = std::env::var("GH_TOKEN") @@ -157,6 +157,12 @@ pub fn download_asset(asset: &Asset, dest: &Path) -> Result<()> { .with_context(|| format!("failed to create parent directory for {}", dest.display()))?; } + let temp = { + let mut path = dest.as_os_str().to_os_string(); + path.push(".part"); + PathBuf::from(path) + }; + let bytes = client()? .get(&asset.browser_download_url) .send() @@ -176,8 +182,15 @@ pub fn download_asset(asset: &Asset, dest: &Path) -> Result<()> { ) })?; - std::fs::write(dest, &bytes) - .with_context(|| format!("failed to write downloaded file to {}", dest.display())) + std::fs::write(&temp, &bytes) + .with_context(|| format!("failed to write downloaded file to {}", temp.display()))?; + std::fs::rename(&temp, dest).with_context(|| { + format!( + "failed to move downloaded file from {} to {}", + temp.display(), + dest.display() + ) + }) } fn fetch_release_from_url(url: &str) -> Result {