fonts: embed craft-fonts when built with CRAFT_FONTS_DIR (#321)
Squash of PR #321 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d74f7a4108
commit
b4777fa8b3
7 files changed
+211
-7
No files matched your search
@@ -24,7 +24,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
## Non-negotiables
|
||||
- **Clean-room.** AutoCAD is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic drawings, take screenshots (by window id) stored only under `plan/autocad/screenshots/` (never committed). **Never** read, disassemble or copy anything inside the AutoCAD bundle (names/listings only); never copy Autodesk fonts (SHX), hatch patterns (`.pat`), linetypes (`.lin`), templates, CUI/PGP files, icons, artwork or help text; never commit files produced by AutoCAD. File formats come from public specifications (Autodesk's published DXF Reference, the Open Design Alliance's public DWG specification) and our own synthetic tests. Never copy or link GPL/LGPL/AGPL code (LibreDWG, LibreCAD, QCAD, FreeCAD's planegcs, SolveSpace, OpenCASCADE) — don't even read their sources.
|
||||
- **Assets — absolutely essential.** CADCraft contains **no Autodesk, Adobe or Avid iconography, images, fonts, patterns or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`), every hatch pattern and linetype is our own definition (`crates/doc/src/library.rs`), the drafting font is our own (`crates/fonts/src/stroke.rs`). Any file asset must be original, public domain/CC0, OSI-licensed, or redistributable Creative Commons (or licensed open source by a contributor who made it), and **must have a row in `ATTRIBUTION.md`** with author, source and licence (`cargo xtask assets` enforces it). The only exception is the ArtCraft brand in `docs/brand/` (trademarks, `docs/brand/LICENSE-brand.txt`). Screenshots of Autodesk software are never committed. Breaking this rule is the most serious mistake you can make in this repo.
|
||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`), never a `Cargo.toml` dependency. Code and tests must work without it.
|
||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`, embedded by `crates/fonts/build.rs`), never a `Cargo.toml` dependency. Code and tests must work without it.
|
||||
- **Shared test corpora** live in separate repos (`storytold/<app>-corpus`); never commit large binary fixtures here.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (`CommandSpec`: id = AutoCAD's command name in lower case, label, menu path, shortcut, aliases, params doc, `enabled`, JSON `run`, optional `interactive` prompt machine) + tests. UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The command line, menus, toolbar, Tool Sets, scripts, CLI, control channel and MCP all reach the same commands.
|
||||
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence.
|
||||
|
||||
@@ -24,7 +24,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
## Non-negotiables
|
||||
- **Clean-room.** AutoCAD is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic drawings, take screenshots (by window id) stored only under `plan/autocad/screenshots/` (never committed). **Never** read, disassemble or copy anything inside the AutoCAD bundle (names/listings only); never copy Autodesk fonts (SHX), hatch patterns (`.pat`), linetypes (`.lin`), templates, CUI/PGP files, icons, artwork or help text; never commit files produced by AutoCAD. File formats come from public specifications (Autodesk's published DXF Reference, the Open Design Alliance's public DWG specification) and our own synthetic tests. Never copy or link GPL/LGPL/AGPL code (LibreDWG, LibreCAD, QCAD, FreeCAD's planegcs, SolveSpace, OpenCASCADE) — don't even read their sources.
|
||||
- **Assets — absolutely essential.** CADCraft contains **no Autodesk, Adobe or Avid iconography, images, fonts, patterns or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`), every hatch pattern and linetype is our own definition (`crates/doc/src/library.rs`), the drafting font is our own (`crates/fonts/src/stroke.rs`). Any file asset must be original, public domain/CC0, OSI-licensed, or redistributable Creative Commons (or licensed open source by a contributor who made it), and **must have a row in `ATTRIBUTION.md`** with author, source and licence (`cargo xtask assets` enforces it). The only exception is the ArtCraft brand in `docs/brand/` (trademarks, `docs/brand/LICENSE-brand.txt`). Screenshots of Autodesk software are never committed. Breaking this rule is the most serious mistake you can make in this repo.
|
||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`), never a `Cargo.toml` dependency. Code and tests must work without it.
|
||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`, embedded by `crates/fonts/build.rs`), never a `Cargo.toml` dependency. Code and tests must work without it.
|
||||
- **Shared test corpora** live in separate repos (`storytold/<app>-corpus`); never commit large binary fixtures here.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (`CommandSpec`: id = AutoCAD's command name in lower case, label, menu path, shortcut, aliases, params doc, `enabled`, JSON `run`, optional `interactive` prompt machine) + tests. UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The command line, menus, toolbar, Tool Sets, scripts, CLI, control channel and MCP all reach the same commands.
|
||||
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence.
|
||||
|
||||
@@ -138,7 +138,13 @@ The web build: `cd apps/cadcraft-web && trunk serve` (needs [trunk](https://trun
|
||||
|
||||
### Fonts
|
||||
|
||||
CADCraft never ships fonts; it uses the TrueType/OpenType fonts installed on your system. SHX
|
||||
CADCraft has no fonts in its repository; it uses the TrueType/OpenType fonts installed on your
|
||||
system. Builds made with the optional [craft-fonts](https://github.com/storytold/craft-fonts)
|
||||
input (`CRAFT_FONTS_DIR=<absolute path to a checkout> cargo build --release`) also embed its
|
||||
fonts: BIZ UDPGothic, BIZ UDMincho and Shippori Mincho (Japanese) and Noto Sans Arabic (the web
|
||||
build, which has no system fonts, only BIZ UDPGothic Regular and Noto Sans Arabic). A text style
|
||||
can use them by family name, and they draw characters a text's font lacks when no installed
|
||||
wide-coverage CJK font has them. Without `CRAFT_FONTS_DIR` nothing is embedded. SHX
|
||||
fonts (including big fonts such as `chineset.shx`) aren't read yet: text in an SHX style is drawn
|
||||
with our own stroke font, and any character a text's font lacks (CJK, for example) is drawn from
|
||||
an installed font that has it, preferring wide-coverage fonts such as Noto Sans CJK, Source Han
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Embeds the fonts of the optional craft-fonts build input (`CRAFT_FONTS_DIR`) as `CRAFT_FONTS`.
|
||||
//! The recipe is craft-fonts' `docs/integration.md` (as in the other Crafting Apps); unset or
|
||||
//! empty, `CRAFT_FONTS` is empty and the build is unchanged. It only reads the local checkout
|
||||
//! (no network). A bad checkout is a warning, or an error with `CRAFT_FONTS_REQUIRED=1`.
|
||||
//!
|
||||
//! On wasm32 it embeds only BIZ UDPGothic Regular and the `Arab` faces (about 5.5 MB), to keep the
|
||||
//! web build within hosting limits (Cloudflare Pages: 25 MiB per file). The web build has no
|
||||
//! system fonts, so these are its only fallback for Japanese and Arabic-script text.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
|
||||
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").filter(|d| !d.is_empty()).map(PathBuf::from) {
|
||||
match craft_fonts(&dir) {
|
||||
Ok(entries) => src.push_str(&entries),
|
||||
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
|
||||
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
|
||||
}
|
||||
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
}
|
||||
}
|
||||
src.push_str("];\n");
|
||||
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
|
||||
if let Err(e) = std::fs::write(&out, src) {
|
||||
println!("cargo::error=writing {}: {e}", out.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Family-name prefixes AGENTS.md bars (no Adobe fonts, ever): Adobe's Source families and Noto
|
||||
/// CJK, which is Source Han under another name. `src/craft_fonts.rs` tests the same list.
|
||||
const BARRED_FAMILIES: [&str; 5] = ["Source Han", "Source Serif", "Source Sans", "Noto Sans CJK", "Noto Serif CJK"];
|
||||
|
||||
/// One `CraftFont { .. }` initialiser per manifest line, in manifest order.
|
||||
fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut out = String::new();
|
||||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
|
||||
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
};
|
||||
if BARRED_FAMILIES.iter().any(|barred| family.starts_with(barred)) {
|
||||
println!("cargo::warning=craft-fonts: not embedding {family} {style} (AGENTS.md: no Adobe fonts)");
|
||||
continue;
|
||||
}
|
||||
let arabic = scripts.split(',').any(|s| s.trim() == "Arab");
|
||||
let web_face = (*family == "BIZ UDPGothic" && *style == "Regular") || arabic;
|
||||
if wasm && !web_face {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
let scripts: Vec<String> = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({:?}) }},",
|
||||
scripts.join(", "),
|
||||
path.display().to_string(),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Fonts from the optional craft-fonts build input (<https://github.com/storytold/craft-fonts>).
|
||||
//!
|
||||
//! Built with `CRAFT_FONTS_DIR=<craft-fonts checkout>`, `build.rs` embeds the fonts listed in its
|
||||
//! `fonts/manifest.txt` (web builds: only BIZ UDPGothic Regular and the Arabic faces).
|
||||
//! Without it [`CRAFT_FONTS`] is empty and nothing changes. The font table ([`crate::ttf`])
|
||||
//! lists the embedded fonts with the installed ones, under [`CraftFont::stem`]. A text style
|
||||
//! can name one (`BIZ UDPGothic`), and they are fallbacks for characters a text's font lacks.
|
||||
//! They are copied out of the binary only when first used.
|
||||
|
||||
/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`).
|
||||
pub struct CraftFont {
|
||||
pub family: &'static str,
|
||||
pub style: &'static str,
|
||||
/// ISO 15924 scripts the font is for, e.g. `"Jpan"`.
|
||||
pub scripts: &'static [&'static str],
|
||||
pub bytes: &'static [u8],
|
||||
}
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
||||
|
||||
impl CraftFont {
|
||||
/// The key the font table lists this font under, like a lower-case file stem:
|
||||
/// `BIZ UDPGothic` `Regular` → `bizudpgothic-regular`.
|
||||
pub fn stem(&self) -> String {
|
||||
format!("{}-{}", self.family, self.style).chars().filter(|c| *c != ' ').flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `stem` is the key of an embedded craft-fonts font.
|
||||
pub(crate) fn is_embedded(stem: &str) -> bool {
|
||||
CRAFT_FONTS.iter().any(|f| f.stem() == stem)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal sfnt (TrueType header, no tables), made here: no font file is committed.
|
||||
static TINY_SFNT: [u8; 12] = [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
|
||||
#[test]
|
||||
fn empty_without_craft_fonts_dir() {
|
||||
// `build.rs` and `option_env!` see the same build environment.
|
||||
if option_env!("CRAFT_FONTS_DIR").is_none_or(str::is_empty) {
|
||||
assert!(CRAFT_FONTS.is_empty());
|
||||
assert!(!is_embedded("bizudpgothic-regular"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stems_are_lower_case_without_spaces() {
|
||||
let f = CraftFont { family: "BIZ UDPGothic", style: "Bold", scripts: &["Jpan"], bytes: &[] };
|
||||
assert_eq!(f.stem(), "bizudpgothic-bold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_static_font_is_found_by_name_and_loaded_on_use() {
|
||||
crate::ttf::register_static("cadcraftstatictest-regular", &TINY_SFNT);
|
||||
let face = crate::ttf::find("CADCraft Static Test").map(|f| f.bytes.as_slice().to_vec());
|
||||
assert_eq!(face.as_deref(), Some(&TINY_SFNT[..]));
|
||||
assert!(crate::ttf::find("cadcraftstatictest-regular.ttf").is_some());
|
||||
// It has no glyphs, so it is never a fallback face, and nothing panics.
|
||||
assert!(!crate::ttf::find("CADCraft Static Test").is_some_and(|f| f.has_glyph('A')));
|
||||
let _ = crate::ttf::fallback_for('\u{65E5}');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_barred_family_is_embedded() {
|
||||
// AGENTS.md: no Adobe fonts. Noto Sans CJK is Source Han Sans; `build.rs` skips them.
|
||||
const BARRED: [&str; 5] = ["Source Han", "Source Serif", "Source Sans", "Noto Sans CJK", "Noto Serif CJK"];
|
||||
for f in CRAFT_FONTS {
|
||||
assert!(!BARRED.iter().any(|b| f.family.starts_with(b)), "{} is barred", f.family);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_fonts_load_and_cover_their_scripts() {
|
||||
// Only with CRAFT_FONTS_DIR (skips otherwise): every embedded font parses, is found by
|
||||
// its family name and has a character of its script; Japanese text gets fallback glyphs.
|
||||
for f in CRAFT_FONTS {
|
||||
let face = crate::ttf::find(&format!("{} {}", f.family, f.style));
|
||||
assert!(face.is_some(), "{} {} not found", f.family, f.style);
|
||||
let sample = match f.scripts.first().copied() {
|
||||
Some("Jpan") => '\u{3042}',
|
||||
Some("Arab") => '\u{0628}',
|
||||
_ => 'A',
|
||||
};
|
||||
assert!(face.is_some_and(|x| x.has_glyph(sample)), "{} lacks {sample}", f.family);
|
||||
}
|
||||
if CRAFT_FONTS.iter().any(|f| f.scripts.contains(&"Jpan")) {
|
||||
let sh = crate::shape_stroke("\u{65E5}\u{672C}", 1.0, 1.0, 0.0);
|
||||
assert_eq!(sh.glyphs.len(), 2, "Japanese drawn from a fallback font");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ use cadcraft_geom::{Bounds2, Vec2};
|
||||
|
||||
pub use mtext::{MTextColor, MTextLayout, MTextParams, MTextPiece, layout_mtext, layout_mtext_with, plain_mtext};
|
||||
|
||||
mod craft_fonts;
|
||||
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
|
||||
|
||||
/// Name of the built-in font.
|
||||
pub const BUILTIN_FONT: &str = "CADCraft Stroke";
|
||||
|
||||
|
||||
+35
-4
@@ -161,8 +161,8 @@ const WEIGHT_WORDS: &[&str] = &["thin", "light", "medium", "semibold", "bold", "
|
||||
#[derive(Default)]
|
||||
struct Db {
|
||||
scanned: bool,
|
||||
/// Font files found by the scan, by lower-case file stem.
|
||||
paths: HashMap<String, PathBuf>,
|
||||
/// Font files found by the scan and fonts compiled into the binary, by lower-case file stem.
|
||||
paths: HashMap<String, Source>,
|
||||
/// Loaded or registered font file bytes, by lower-case file stem.
|
||||
files: HashMap<String, Arc<Vec<u8>>>,
|
||||
/// Name lookups already answered, found or not.
|
||||
@@ -187,12 +187,24 @@ struct Fallback {
|
||||
by_char: HashMap<char, Option<usize>>,
|
||||
}
|
||||
|
||||
/// Where a font file's bytes come from.
|
||||
enum Source {
|
||||
/// An installed font file, read on first use.
|
||||
File(PathBuf),
|
||||
/// A font compiled into the binary ([`crate::CRAFT_FONTS`]), copied on first use.
|
||||
Static(&'static [u8]),
|
||||
}
|
||||
|
||||
fn lock() -> MutexGuard<'static, Db> {
|
||||
static DB: OnceLock<Mutex<Db>> = OnceLock::new();
|
||||
let mut g = DB.get_or_init(|| Mutex::new(Db::default())).lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if !g.scanned {
|
||||
g.scanned = true;
|
||||
g.paths = scan();
|
||||
g.paths = scan().into_iter().map(|(stem, p)| (stem, Source::File(p))).collect();
|
||||
// Embedded craft-fonts fonts take the place of an installed file with the same stem.
|
||||
for f in crate::CRAFT_FONTS {
|
||||
g.paths.insert(f.stem(), Source::Static(f.bytes));
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
@@ -273,7 +285,10 @@ impl Db {
|
||||
if self.loaded_bytes >= MAX_LOADED_BYTES {
|
||||
return None;
|
||||
}
|
||||
let bytes = Arc::new(read_file(self.paths.get(stem)?)?);
|
||||
let bytes = Arc::new(match self.paths.get(stem)? {
|
||||
Source::File(path) => read_file(path)?,
|
||||
Source::Static(bytes) => bytes.to_vec(),
|
||||
});
|
||||
self.loaded_bytes = self.loaded_bytes.saturating_add(bytes.len());
|
||||
self.files.insert(stem.to_string(), bytes.clone());
|
||||
Some(bytes)
|
||||
@@ -412,11 +427,15 @@ fn weight_penalty(stem: &str, want: &str) -> usize {
|
||||
/// Order font file stems for the missing-glyph search: known wide-coverage (CJK) fonts first,
|
||||
/// regular weights before bold, light and the like, then by name.
|
||||
pub(crate) fn rank_fallback(stems: &[String]) -> Vec<String> {
|
||||
let embedded_rank = PREFERRED_FALLBACK.iter().position(|p| *p == "notosans").unwrap_or(PREFERRED_FALLBACK.len());
|
||||
let mut v: Vec<(usize, usize, String)> = stems
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let n = norm(s);
|
||||
let pref = PREFERRED_FALLBACK.iter().position(|p| n.contains(p)).unwrap_or(PREFERRED_FALLBACK.len());
|
||||
// Embedded craft-fonts fonts come right after the installed wide-coverage (CJK) fonts,
|
||||
// before the general-purpose ones, so the bounded search always reaches them.
|
||||
let pref = if crate::craft_fonts::is_embedded(s) { pref.min(embedded_rank) } else { pref };
|
||||
(pref, weight_penalty(s, ""), s.clone())
|
||||
})
|
||||
.collect();
|
||||
@@ -433,6 +452,18 @@ pub fn register(name: &str, bytes: Vec<u8>) {
|
||||
g.fallback = Fallback::default();
|
||||
}
|
||||
|
||||
/// Register font bytes that live for the whole process under a name; they are copied only when
|
||||
/// the font is first used, the way the embedded craft-fonts fonts are listed.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn register_static(name: &str, bytes: &'static [u8]) {
|
||||
let mut g = lock();
|
||||
let k = key(name);
|
||||
g.files.remove(&k);
|
||||
g.paths.insert(k, Source::Static(bytes));
|
||||
g.resolved.clear();
|
||||
g.fallback = Fallback::default();
|
||||
}
|
||||
|
||||
/// Normalise a style font name ("arial.ttf", "Arial", "ARIALBD.TTF") to a lookup key.
|
||||
fn key(name: &str) -> String {
|
||||
let n = name.trim().to_ascii_lowercase();
|
||||
|
||||
Reference in new issue
Block a user