Sketch text: live Edit Text from the Text tool, a font list, handles, and whole-text picking (#821)

Live Edit Text from the Text tool, a font list, handles, and picking the whole text.
This commit is contained in:
phanoob authored and GitHub committed 2026-10-13 16:29:37 -04:00
1 parent 69050c81d9
commit ec5d7f9789
17 files changed
+1248 -80

No files matched your search

+17
View File
@@ -307,3 +307,20 @@ fn a_font_is_kept_in_the_design_and_old_designs_read_without_one() {
assert!(json.contains(r#""font":"C:/fonts/Some Font.ttf""#), "{json}");
assert_eq!(serde_json::from_str::<LinkSource>(&json).unwrap(), source(Some("C:/fonts/Some Font.ttf")));
}
#[test]
fn installed_families_are_listed_by_name_once_without_the_built_in_one() {
let folder = Folder::new("families");
// A second copy of the tiny font, the built-in font and the repository's second font: each family once,
// sorted; the built-in family is not among the installed ones, broken files and other files are skipped.
std::fs::write(folder.0.join("copy.ttf"), tiny_font()).unwrap();
std::fs::copy(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fonts").join("LiberationSans-Regular.ttf"), folder.0.join("lib.ttf")).unwrap();
std::fs::copy(web_fonts().join("BIZUDPGothic-Regular.ttf"), folder.0.join("biz.ttf")).unwrap();
let families = crate::families_in(std::slice::from_ref(&folder.0));
assert_eq!(families, ["BIZ UDPGothic", "Tiny Block"]);
// Each name finds its font again.
for family in &families {
assert!(find_family_in(std::slice::from_ref(&folder.0), family).unwrap().is_some(), "{family}");
}
assert!(crate::families_in(&[folder.0.join("missing")]).is_empty());
}
+83
View File
@@ -103,6 +103,89 @@ pub fn find_family_in(dirs: &[PathBuf], name: &str) -> Result<Option<FontData>>
Ok(None)
}
/// The family names of the installed fonts, for choosing one by name: sorted, without repeats
/// and without the built-in family. Only each file's name table is read.
pub fn installed_families() -> Vec<String> {
families_in(&font_dirs())
}
/// The family names of the fonts under `dirs` (see [`installed_families`]).
pub fn families_in(dirs: &[PathBuf]) -> Vec<String> {
let mut seen = std::collections::HashSet::from([normalize(DEFAULT_FONT_FAMILY)]);
let mut out = Vec::new();
for path in font_files(dirs) {
for family in file_families(&path) {
let key = normalize(&family);
// Names starting with a dot are a system's private fonts.
if !key.is_empty() && !family.starts_with('.') && seen.insert(key) {
out.push(family);
}
}
}
out.sort_by_cached_key(|f| f.to_lowercase());
out
}
/// Largest name table read when listing families.
const MAX_NAME_TABLE: u32 = 1 << 20;
/// The family names of each face of a font file, from its name tables alone: the file's table
/// directory says where they are, so a large font costs a few small reads, not its whole size.
fn file_families(path: &Path) -> Vec<String> {
use std::io::{Read, Seek, SeekFrom};
let Ok(mut file) = std::fs::File::open(path) else { return Vec::new() };
let mut read_at = |offset: u64, len: usize| -> Option<Vec<u8>> {
file.seek(SeekFrom::Start(offset)).ok()?;
let mut buf = vec![0; len];
file.read_exact(&mut buf).ok()?;
Some(buf)
};
let be16 = |b: &[u8], at: usize| b.get(at..at + 2).and_then(|s| s.try_into().ok()).map(u16::from_be_bytes);
let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes);
let Some(head) = read_at(0, 12) else { return Vec::new() };
let faces: Vec<u32> = if head.starts_with(b"ttcf") {
let count = be32(&head, 8).unwrap_or(0).min(64) as usize;
read_at(12, count * 4).map(|b| (0..count).filter_map(|i| be32(&b, i * 4)).collect()).unwrap_or_default()
} else {
vec![0]
};
let mut out = Vec::new();
for face in faces {
let Some(dir) = read_at(u64::from(face), 12) else { continue };
let tables = usize::from(be16(&dir, 4).unwrap_or(0).min(512));
let Some(records) = read_at(u64::from(face) + 12, tables * 16) else { continue };
let Some(record) = (0..tables).map(|i| i * 16).find(|&r| records.get(r..r + 4) == Some(b"name")) else { continue };
let (Some(offset), Some(len)) = (be32(&records, record + 8), be32(&records, record + 12)) else { continue };
if len == 0 || len > MAX_NAME_TABLE {
continue;
}
let Some(data) = read_at(u64::from(offset), len as usize) else { continue };
if let Some(family) = ttf_parser::name::Table::parse(&data).and_then(|table| family_name(&table)) {
out.push(family);
}
}
out
}
/// A face's family name as people know it: the English one when there is one.
fn family_name(table: &ttf_parser::name::Table) -> Option<String> {
use ttf_parser::name_id;
let mut best: Option<(u8, String)> = None;
for name in table.names {
let rank = match name.name_id {
name_id::FAMILY => 2,
name_id::TYPOGRAPHIC_FAMILY => 0,
_ => continue,
} + u8::from(name.language() == ttf_parser::Language::English_UnitedStates);
let Some(text) = name.to_string() else { continue };
let text = text.trim().to_string();
if !text.is_empty() && best.as_ref().is_none_or(|(b, _)| rank > *b) {
best = Some((rank, text));
}
}
best.map(|(_, name)| name)
}
/// The names of one face of a font file (normalised), for matching.
struct FaceNames {
index: u32,
+1 -1
View File
@@ -42,7 +42,7 @@ pub use curves::{
};
pub use decompose::Budget;
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
pub use fonts::{DEFAULT_FONT_FAMILY, FontData, font_data};
pub use fonts::{DEFAULT_FONT_FAMILY, FontData, families_in, font_data, installed_families};
pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, sketch_axis_geom};
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, MAX_CONSTRAINTS, MAX_CURVES, MAX_POINTS, SPoint, Sketch, SketchError, SketchView};