Merge pull request #781 from dotCooCoo/feat/macos-registered-fonts

Part of #579: macOS fonts registered from other folders are listed too
This commit is contained in:
BFlat authored and GitHub committed 2026-10-10 01:42:54 +07:00
commit 783540a34a
10 files changed
+155 -7

No files matched your search

Generated
+13
View File
@@ -2553,6 +2553,15 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -4323,6 +4332,8 @@ dependencies = [
"muda",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-core-text",
"objc2-foundation 0.3.2",
"rfd",
"serde_json",
@@ -4379,6 +4390,8 @@ name = "vectorcraft-cli"
version = "0.7.0"
dependencies = [
"log",
"objc2-core-foundation",
"objc2-core-text",
"serde_json",
"vectorcraft-color",
"vectorcraft-doc",
+1 -1
View File
@@ -163,7 +163,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs).
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579).
- **Files (M4):**
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
+6
View File
@@ -31,6 +31,12 @@ log = { workspace = true }
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
# CoreText's font manager (the desktop app's src/mac_fonts.rs, shared): fonts that apps and font
# managers register from their own folders (#579). Zlib OR Apache-2.0 OR MIT.
[target.'cfg(target_os = "macos")'.dependencies]
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] }
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
serde_json = { workspace = true }
+8 -2
View File
@@ -9,8 +9,8 @@
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
//! vectorcraft-cli perf [--paths 50000]
//! ```
// Denied, not forbidden: the DirectWrite font lister shared with the desktop app (Windows) allows
// it for its COM calls, and nothing else may.
// Denied, not forbidden: the font listers shared with the desktop app allow it for their
// DirectWrite (Windows) and CoreText (macOS) calls, and nothing else may.
#![deny(unsafe_code)]
use std::io::Write;
@@ -36,6 +36,10 @@ macro_rules! out {
}};
}
/// The desktop app's CoreText font lister, shared: exports and MCP find the same fonts.
#[cfg(target_os = "macos")]
#[path = "../../vectorcraft/src/mac_fonts.rs"]
mod mac_fonts;
mod perf;
/// The desktop app's DirectWrite font lister, shared: exports and MCP find the same fonts.
#[cfg(all(windows, not(target_vendor = "win7")))]
@@ -104,6 +108,8 @@ fn main() -> ExitCode {
// Before any font lookup: the fonts font services load (#579).
#[cfg(all(windows, not(target_vendor = "win7")))]
system_fonts::install();
#[cfg(target_os = "macos")]
mac_fonts::install();
let args: Vec<String> = std::env::args().skip(1).collect();
let r = match args.first().map(String::as_str) {
Some("mcp") => mcp(&args[1..]),
+5
View File
@@ -51,6 +51,11 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["std",
# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSNotification", "NSOperation", "NSString", "NSURL"] }
block2 = "0.6"
# CoreText's font manager (src/mac_fonts.rs): fonts that apps and font managers register from their
# own folders (#579). Zlib OR Apache-2.0 OR MIT; objc2-core-foundation already comes with
# objc2-foundation.
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] }
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
[build-dependencies]
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
+84
View File
@@ -0,0 +1,84 @@
//! macOS: the font files CoreText's font manager lists, scanned with the font folders
//! (`vectorcraft_text::set_platform_font_files`). The desktop app and `vectorcraft-cli` (exports,
//! MCP) both install it; the CLI shares this file.
//!
//! Apps and font managers can register fonts with the font manager from files in any folder,
//! outside the font folders
//! (<https://developer.apple.com/documentation/coretext/ctfontmanagerregisterfontsforurl(_:_:_:)>).
//! The font manager's list of available fonts holds them
//! (<https://developer.apple.com/documentation/coretext/ctfontmanagercopyavailablefonturls()>), so
//! VectorCraft lists them as it does on Windows (`system_fonts.rs`), reading the files in place
//! (#579).
//!
//! CoreText is a C API with no safe binding, hence the scoped `unsafe_code` allowance.
#![allow(unsafe_code)]
use std::collections::BTreeSet;
use objc2_core_foundation::{CFType, CFURL};
use objc2_core_text::CTFontManagerCopyAvailableFontURLs;
/// The most fonts read from the list (a system has a few thousand faces).
const MAX_FONTS: usize = 1 << 16;
/// Have the font scans read the font files CoreText lists.
pub fn install() {
vectorcraft_text::set_platform_font_files(font_files);
}
/// The files of the fonts available to the app as CoreText lists them now (fonts registered since
/// the last call included).
fn font_files() -> Vec<String> {
// SAFETY: a CoreText call without arguments; it returns a new array.
let list = unsafe { CTFontManagerCopyAvailableFontURLs() };
// SAFETY: the array holds CF objects (CFURLs, as CoreText documents); each one is checked to be
// a CFURL before it is read.
let fonts = unsafe { list.cast_unchecked::<CFType>() };
// Faces of one file (a collection's) name it once.
let files: BTreeSet<String> = fonts
.iter()
.take(MAX_FONTS)
.filter_map(|f| f.downcast::<CFURL>().ok())
// Only file URLs, as in `open_documents.rs` (#433).
.filter(|u| u.scheme().is_some_and(|s| s.to_string().eq_ignore_ascii_case("file")))
.filter_map(|u| u.to_file_path()?.into_os_string().into_string().ok())
.collect();
files.into_iter().collect()
}
#[cfg(test)]
mod tests {
use objc2_core_foundation::CFURL;
use objc2_core_text::{CTFontManagerRegisterFontsForURL, CTFontManagerScope, CTFontManagerUnregisterFontsForURL};
/// CoreText lists the installed fonts, by their files' full paths.
#[test]
fn coretext_lists_the_installed_font_files() {
let files = super::font_files();
assert!(files.iter().all(|f| std::path::Path::new(f).is_absolute()), "{:?}", files.first());
assert!(files.iter().any(|f| f.starts_with("/System/Library/Fonts/")), "{} files, {:?}", files.len(), files.first());
}
/// A font registered with CoreText from another folder, as apps and font managers register
/// fonts, is listed while it is registered.
#[test]
fn fonts_registered_from_other_folders_are_listed() {
let dir = std::env::temp_dir().join(format!("vectorcraft-mac-fonts-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// A space in the name: the URL's escapes are decoded. The bundled serif isn't a font macOS
// installs.
let file = dir.join("Registered Font.ttf");
std::fs::copy(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSerif4-Regular.ttf"), &file).unwrap();
let want = std::fs::canonicalize(&file).unwrap();
let listed = || super::font_files().iter().any(|f| f.ends_with("/Registered Font.ttf") && std::fs::canonicalize(f).is_ok_and(|p| p == want));
assert!(!listed());
let url = CFURL::from_file_path(&file).unwrap();
// SAFETY: a file URL, a scope constant and no error out-parameter.
assert!(unsafe { CTFontManagerRegisterFontsForURL(&url, CTFontManagerScope::Process, std::ptr::null_mut()) });
let registered = listed();
// SAFETY: as above.
unsafe { CTFontManagerUnregisterFontsForURL(&url, CTFontManagerScope::Process, std::ptr::null_mut()) };
std::fs::remove_dir_all(&dir).unwrap();
assert!(registered);
}
}
+4
View File
@@ -23,6 +23,8 @@ mod control_server;
mod gpu;
mod logging;
#[cfg(target_os = "macos")]
mod mac_fonts;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
mod open_documents;
@@ -393,6 +395,8 @@ fn main() -> std::process::ExitCode {
// Before the first font scan (the app's start): the fonts font services load (#579).
#[cfg(all(windows, not(target_vendor = "win7")))]
system_fonts::install();
#[cfg(target_os = "macos")]
mac_fonts::install();
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
+13 -2
View File
@@ -783,9 +783,15 @@ fn font_files(path: &Path) -> Vec<Vec<u8>> {
std::fs::read(path).into_iter().collect()
}
/// The tables holding outlines the font engine draws: TrueType, CFF, CFF2 and VARC. A face with
/// none of them (outlines in Apple's `hvgl` table, bitmaps only) draws nothing.
#[cfg(not(target_arch = "wasm32"))]
const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"];
/// The styles of every face in the font file at `path` ([`face_styles`]), reading only its table
/// directories and `name` and `fvar` tables: a scan opens hundreds of font files, many of them
/// megabytes long.
/// megabytes long. Faces without outlines the font engine draws (no [`OUTLINE_TABLES`] table) are
/// left out.
#[cfg(not(target_arch = "wasm32"))]
fn file_face_names(path: &Path) -> Vec<FaceStyle> {
if crate::suitcase::is_suitcase(path) {
@@ -825,6 +831,9 @@ fn reader_face_names(mut file: impl std::io::Read + std::io::Seek) -> Vec<FaceSt
let dir = read_at(start.into(), 12)?;
let tables = u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?) as usize;
let records = read_at(u64::from(start) + 12, tables * 16)?;
if !records.as_chunks::<16>().0.iter().any(|r| OUTLINE_TABLES.iter().any(|t| r.starts_with(*t))) {
return None;
}
let mut table = |tag: &[u8; 4]| -> Option<Vec<u8>> {
let rec: &[u8] = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(tag))?;
let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?);
@@ -890,7 +899,9 @@ static PLATFORM_FONT_FILES: std::sync::OnceLock<PlatformFontFiles> = std::sync::
/// folders, asked again by each scan and each check for installed fonts
/// ([`FontDb::installed_fonts_changed`]). The desktop app lists DirectWrite's system font
/// collection on Windows: fonts a font service such as Adobe Fonts loads in place, from files
/// outside the font folders and unknown to the registry (#579). Only the first call counts.
/// outside the font folders and unknown to the registry (#579). On macOS it lists the fonts
/// CoreText's font manager has available, which apps and font managers can register from any
/// folder. Only the first call counts.
pub fn set_platform_font_files(list: PlatformFontFiles) {
#[cfg(not(target_arch = "wasm32"))]
// A second call keeps the first lister, as documented.
+16
View File
@@ -104,6 +104,22 @@ fn the_scan_reads_collections_and_loads_fonts_only_when_used() {
assert_eq!(db.find(FAMILY, "Regular").unwrap().face_index(), 0, "the collection's faces load together");
}
#[test]
fn fonts_without_outlines_the_engine_draws_are_left_out() {
let dir = font_dir("outlines");
// The Bold's TrueType outlines under another tag, as in fonts that keep their outlines in
// Apple's `hvgl` table or hold bitmaps only. The tag keeps the table directory sorted.
let bold = dir.join("Sub/Sysfont-Bold.TTF");
let mut data = std::fs::read(&bold).unwrap();
let tables = u16::from_be_bytes([data[4], data[5]]) as usize;
let rec = (0..tables).map(|i| 12 + 16 * i).find(|&r| &data[r..r + 4] == b"glyf").unwrap();
data[rec..rec + 4].copy_from_slice(b"gxyz");
std::fs::write(&bold, data).unwrap();
let db = FontDb::with_font_dirs(vec![dir]);
assert_eq!(db.styles(FAMILY), ["Regular"]);
assert_eq!(db.load_system_fonts(), 1);
}
#[test]
fn rescanning_finds_fonts_installed_since() {
let dir = font_dir("rescan");
+5 -2
View File
@@ -2534,9 +2534,12 @@ Type can use the bundled fonts, fonts added to the session and the fonts install
the system's and the user's font folders (Windows: `Fonts` and `%LOCALAPPDATA%\Microsoft\Windows\Fonts`, plus fonts
registered outside them, such as fonts installed as shortcuts, and in the desktop app and `vectorcraft-cli` (MCP
included) the fonts in DirectWrite's system font collection, such as those Adobe Fonts activates while Creative Cloud runs; macOS: `/System/Library/Fonts`, `/Library/Fonts`,
`/Network/Library/Fonts`, `~/Library/Fonts` and downloaded system fonts; Linux and BSD: `/usr/share/fonts`,
`/Network/Library/Fonts`, `~/Library/Fonts` and downloaded system fonts, plus in the desktop app and `vectorcraft-cli`
the fonts CoreText's font manager lists outside them, such as those apps and font managers register from their own
folders; Linux and BSD: `/usr/share/fonts`,
`/usr/local/share/fonts`, `~/.fonts` and the XDG data folders' `fonts`, `~/.local/share/fonts` among them, and in a
Flatpak sandbox the host's fonts). The installed fonts are cataloged once per session (in the background when the app starts, else on the first lookup
Flatpak sandbox the host's fonts). Faces without outlines VectorCraft draws (no `glyf`, `CFF`, `CFF2` or `VARC` table, such
as bitmap-only fonts) are left out. The installed fonts are cataloged once per session (in the background when the app starts, else on the first lookup
by family name), so opening, placing, pasting and importing files find them whatever ran before. `text.fontList`
lists every family available, the installed ones included, as the font menus do: without the system's hidden
families, whose names start with "." (macOS's ".SF NS", ".LastResort"), which still resolve when a document names