diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ae037d..4bd8537 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev + # Runtime library only (no -dev): proves the build needs neither the + # fontconfig dev package nor pkg-config (font-kit uses dlopen on Linux), + # while still exercising the runtime font-discovery path. + - run: sudo apt-get update && sudo apt-get install -y libfontconfig1 - uses: dtolnay/rust-toolchain@stable with: components: clippy @@ -44,7 +47,19 @@ jobs: - uses: actions/checkout@v6 - name: Install system dependencies (Linux) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev + # Runtime library only (no -dev): the build loads fontconfig via dlopen, + # so only the shared library is needed at run time. + # + # font-kit pulls freetype-sys unconditionally on Linux, and it links the + # host's libfreetype whenever pkg-config finds a freetype2.pc. The runner + # image ships that .pc but not the matching libfreetype.so dev symlink, so + # the link fails. Point pkg-config at an empty dir to hide the .pc: this + # makes freetype-sys compile its bundled copy (static) instead — the same + # path `cargo install` takes on a bare server with no dev packages. + run: | + sudo apt-get update && sudo apt-get install -y libfontconfig1 + mkdir -p "$RUNNER_TEMP/empty-pkgconfig" + echo "PKG_CONFIG_LIBDIR=$RUNNER_TEMP/empty-pkgconfig" >> "$GITHUB_ENV" - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: make build diff --git a/CHANGELOG.md b/CHANGELOG.md index 5469c30..f356ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- **`cargo install termdown` no longer fails on Linux servers without the + fontconfig dev package.** font-kit now links fontconfig via `dlopen` on + Linux, so building requires neither `libfontconfig1-dev`/`fontconfig-devel` + nor `pkg-config`. font-kit still pulls `freetype-sys` on Linux, but when no + system freetype is found it compiles its bundled copy statically (only a C + toolchain is needed — already required for any native Rust build). If + `libfontconfig` is also missing at run time, termdown degrades to its bundled + font instead of panicking. System font discovery (including CJK headings) + still works whenever fontconfig is present. +- **Bundled fallback font was a corrupt HTML file, not a font** (shipped this + way since v0.1.0). The fallback only ever runs when no system font resolves, + so on machines with fonts it stayed invisible; on a font-less server it meant + headings silently degraded to plain text. Replaced with the real + OFL-licensed Source Serif 4 SemiBold, and added a test that parses the + bundled font so a bad asset can't regress. + ## [0.6.0] - 2026-05-31 ### Added diff --git a/Cargo.toml b/Cargo.toml index 92769ce..cc32f54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,14 @@ unicode-width = "0.2" [target.'cfg(unix)'.dependencies] libc = "0.2" +# On Linux, font-kit talks to fontconfig. Use the dlopen variant so building +# termdown does NOT require the fontconfig dev package or pkg-config — the +# shared library is loaded lazily at runtime instead. This keeps +# `cargo install termdown` working on bare servers. macOS/Windows use +# CoreText/DirectWrite and never pull fontconfig. +[target.'cfg(target_os = "linux")'.dependencies] +font-kit = { version = "0.14", features = ["source-fontconfig-dlopen"] } + [profile.release] strip = true lto = true diff --git a/README.md b/README.md index bb61a10..a054a1f 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,13 @@ cargo install termdown Installs into `~/.cargo/bin/`. Requires Rust 1.95+. +> **Linux:** no `-dev` packages or `pkg-config` are required to build — only a +> C toolchain (freetype is compiled from source when the system one isn't +> found), and fontconfig is loaded lazily at run time. For system font +> discovery (including CJK headings), install `fontconfig` plus the fonts you +> want (e.g. `apt install fontconfig fonts-noto-cjk`). Without it, termdown +> falls back to its bundled font. + ### Prebuilt binary (no Rust toolchain needed) ```sh diff --git a/fonts/LICENSE-SourceSerif4.md b/fonts/LICENSE-SourceSerif4.md new file mode 100644 index 0000000..ebe298c --- /dev/null +++ b/fonts/LICENSE-SourceSerif4.md @@ -0,0 +1,93 @@ +Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/fonts/SourceSerif4-SemiBold.ttf b/fonts/SourceSerif4-SemiBold.ttf index f23b590..151e9a9 100644 Binary files a/fonts/SourceSerif4-SemiBold.ttf and b/fonts/SourceSerif4-SemiBold.ttf differ diff --git a/src/font.rs b/src/font.rs index a4056da..1ed5ed4 100644 --- a/src/font.rs +++ b/src/font.rs @@ -281,30 +281,35 @@ fn try_font(source: &SystemSource, family: &str, props: &Properties) -> Option, props: &Properties, user_choice: Option<&str>, platform_defaults: &[&str], ) -> Option> { - if let Some(family) = user_choice { - if let Some(font) = try_font(source, family, props) { - return Some(font); + if let Some(source) = source { + if let Some(family) = user_choice { + if let Some(font) = try_font(source, family, props) { + return Some(font); + } } - } - for family in platform_defaults { - if let Some(font) = try_font(source, family, props) { - return Some(font); + for family in platform_defaults { + if let Some(font) = try_font(source, family, props) { + return Some(font); + } } } + // No system source (e.g. fontconfig unavailable on a bare server), or no + // family matched — fall back to the bundled font. FontRef::try_from_slice(FALLBACK_FONT).ok() } fn resolve_optional_font( - source: &SystemSource, + source: Option<&SystemSource>, props: &Properties, user_choice: Option<&str>, platform_defaults: &[&str], ) -> Option> { + let source = source?; if let Some(family) = user_choice { if let Some(font) = try_font(source, family, props) { return Some(font); @@ -348,14 +353,44 @@ pub fn get_fonts(level: u8, config: &Config) -> Option<&'static FontSet> { // the OS font registry (CoreText / fontconfig / DirectWrite), ~20-30ms per // call, and we'd otherwise pay it once per heading level. Thread-local // instead of `static` because on Linux `SystemSource` wraps a raw -// `*mut FcConfig` pointer and is neither `Send` nor `Sync`. +// `*mut FcConfig` pointer and is neither `Send` nor `Sync`. The value is +// `Option` because on Linux fontconfig may be unavailable at runtime (see +// `new_system_source`), in which case we render with the bundled font only. thread_local! { - static SYSTEM_SOURCE: OnceCell = const { OnceCell::new() }; + static SYSTEM_SOURCE: OnceCell> = const { OnceCell::new() }; +} + +/// Build the system font source, returning `None` when it can't be used. +/// +/// On Linux, fontconfig is loaded lazily via dlopen (see `Cargo.toml`). If +/// `libfontconfig` isn't installed at runtime, calling into font-kit would +/// panic deep inside the FFI layer, so we probe for the shared library first +/// and degrade to the bundled font instead of crashing. +fn new_system_source() -> Option { + #[cfg(target_os = "linux")] + if !fontconfig_available() { + return None; + } + Some(SystemSource::new()) +} + +#[cfg(target_os = "linux")] +fn fontconfig_available() -> bool { + for name in [c"libfontconfig.so.1", c"libfontconfig.so"] { + // SAFETY: `name` is a valid NUL-terminated C string; we release the + // handle immediately. This only checks that the library can be loaded. + let handle = unsafe { libc::dlopen(name.as_ptr(), libc::RTLD_LAZY) }; + if !handle.is_null() { + unsafe { libc::dlclose(handle) }; + return true; + } + } + false } fn resolve_font_set(level: u8, config: &Config) -> Option { SYSTEM_SOURCE.with(|cell| { - let source = cell.get_or_init(SystemSource::new); + let source = cell.get_or_init(new_system_source).as_ref(); let props = Properties { style: Style::Normal, weight: weight_for_level(level), @@ -396,6 +431,23 @@ fn resolve_font_set(level: u8, config: &Config) -> Option { mod tests { use super::*; + #[test] + fn bundled_fallback_font_is_a_valid_font() { + // Regression guard: the bundled fallback is the only font available when + // no system font resolves (e.g. a Linux server with no fontconfig). That + // path never runs on a dev machine, so a corrupt asset stays invisible — + // which is exactly how a non-font HTML file shipped as the fallback from + // v0.1.0 until it surfaced. Parsing it here keeps that from regressing. + let font = FontRef::try_from_slice(FALLBACK_FONT) + .expect("bundled fallback font must parse as a valid font"); + let glyph_id = font.glyph_id('A'); + assert_ne!(glyph_id.0, 0, "fallback font should map basic Latin 'A'"); + assert!( + font.outline(glyph_id).is_some(), + "fallback font should provide a renderable outline for 'A'" + ); + } + #[test] fn emoji_font_prefers_renderable_emoji_glyph() { let config = Config::default();