Merge pull request #821 from attisvalentin/fix/rescan-and-load-fonts

M4: fonts installed while the app runs: text.rescanFonts, text.loadFont, text.refit
This commit is contained in:
BFlat authored and GitHub committed 2026-10-20 03:04:29 +07:00
commit 1abb9083c7
11 files changed
+360 -15

No files matched your search

+35
View File
@@ -481,5 +481,40 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
spec!(
"text.rescanFonts",
"Rescan installed fonts",
"{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in the families that gained faces (new ones, or styles installed since) fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}",
true,
|s, _| {
let rescan = figcraft_text::rescan_system_fonts();
let refitted = crate::fonts::refit_open(s, None, Some(&rescan.changed));
Ok(json!({"added": rescan.added, "refitted": refitted}))
}
),
spec!(
"text.loadFont",
"Load font file",
"{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it; the open document's auto-sizing text in its families fits it (text.refit) → {families, refitted: [ids]}",
true,
|s, p| {
let cmd = "text.loadFont";
let bytes = if let Some(b64) = string(p, "data") {
use base64::Engine as _;
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))?
} else if let Some(path) = string(p, "path") {
super::file::read_capped(&path, figcraft_text::fontdb::MAX_FONT_FILE)?
} else {
return Err(bad(cmd, "pass `path` or `data` (base64)"));
};
let families = figcraft_text::load_font_bytes(bytes);
if families.is_empty() {
return Err(bad(cmd, "not a font file"));
}
let refitted = crate::fonts::refit_open(s, None, Some(&families));
Ok(json!({"families": families, "refitted": refitted}))
}
),
]
}
+70 -10
View File
@@ -1,13 +1,15 @@
//! Fonts a document names that this machine doesn't have (#752): text layers, their styled
//! ranges, text styles and the string variables bound to a font family. Text in such a family
//! is drawn in a fallback font, so the user is told which ones are missing.
//! is drawn in a fallback font, so the user is told which ones are missing. Once such a font is
//! there, [`refit`] lets auto-sizing text measure its box in it again.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables};
use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableValue, Variables};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::params::{get, req_targets};
use crate::{Result, Session, spec};
/// A family the document names that no installed or built-in font provides.
@@ -79,20 +81,78 @@ fn string_values(vars: &Variables, id: &str) -> Vec<String> {
out
}
/// Auto-sizing text layers measure their boxes again: a box measured while its family was missing
/// (in a fallback font) follows the real face once that's installed or loaded. Only layers under
/// `under` (every layer: `None`) that use one of `families` (any family: `None`). Returns the
/// layers whose size changed.
pub fn refit(doc: &mut Document, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec<NodeId> {
let want: Option<BTreeSet<String>> = families.map(|f| f.iter().map(|x| x.trim().to_lowercase()).collect());
let scope: Option<BTreeSet<NodeId>> = under.map(|ids| ids.iter().flat_map(|i| doc.subtree(*i)).collect());
let ids: Vec<NodeId> = doc
.nodes()
.filter(|n| scope.as_ref().is_none_or(|s| s.contains(&n.id)))
.filter(|n| {
let NodeKind::Text(t) = &n.kind else { return false };
let uses = |w: &BTreeSet<String>| {
w.contains(&t.style.font_family.trim().to_lowercase())
|| t.spans().iter().any(|(_, _, st)| w.contains(&st.font_family.trim().to_lowercase()))
};
want.as_ref().is_none_or(uses)
&& figcraft_text::auto_size(n).is_some_and(|(w, h)| (w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
})
.map(|n| n.id)
.collect();
if !ids.is_empty() {
let before = doc.clone();
doc.mark_dirty(ids.iter().copied());
crate::normalize(doc, &before);
}
ids
}
/// [`refit`] in the open document, if there is one.
pub(crate) fn refit_open(s: &mut Session, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec<NodeId> {
match s.doc_mut() {
Ok(doc) => refit(doc, under, families),
Err(_) => Vec::new(),
}
}
/// `text.refit`: the layers `ids` names (an `ids` that isn't a list of layers is refused), else the
/// whole document.
fn refit_text(s: &mut Session, p: &Value) -> Result<Value> {
let under = match get(p, "ids") {
None | Some(Value::Null) => None,
Some(_) => Some(req_targets("text.refit", s, p)?),
};
s.doc()?;
Ok(json!({"refitted": refit_open(s, under.as_deref(), None)}))
}
fn missing_fonts(s: &mut Session, _: &Value) -> Result<Value> {
let families: Vec<Value> = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect();
s.request_ui("dialog.missingFonts", json!({}));
Ok(json!({"families": families}))
}
/// The font commands: `text.missingFonts` and `text.refit`.
pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"text.missingFonts",
"Missing fonts",
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}",
false,
missing_fonts
)]
vec![
spec!(
"text.missingFonts",
"Missing fonts",
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}",
false,
missing_fonts
),
spec!(
"text.refit",
"Fit text to fonts",
"{ids? (these layers and the text in them; default: the whole document)} auto-sizing text measures its box again, e.g. a box measured in a fallback font while its family was missing → {refitted: [ids whose size changed]}",
true,
refit_text
),
]
}
#[cfg(test)]
+89
View File
@@ -124,3 +124,92 @@ fn needs_an_open_document() {
let mut s = Session::empty();
assert!(s.execute("text.missingFonts", &json!({})).is_err());
}
/// Make `id`'s box stale, as if it was measured in another font: `dw` wider than its content.
fn stale(s: &mut Session, id: figcraft_doc::NodeId, dw: f64) -> f64 {
let doc = s.doc_mut().unwrap();
let n = doc.get_mut(id).unwrap();
let want = figcraft_text::auto_size(n).unwrap().0;
n.width = want + dw;
doc.take_dirty();
want
}
/// The width of layer `id`.
fn width(s: &Session, id: figcraft_doc::NodeId) -> f64 {
s.doc().unwrap().node(id).unwrap().width
}
/// The ids a refit reports.
fn refitted(r: &Value) -> Vec<u64> {
r["refitted"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
}
/// Stale boxes fit their text again, by family, by layer or everywhere; fixed boxes keep theirs.
#[test]
fn refit_measures_stale_text_boxes_again() {
let mut s = Session::new();
let a = text(&mut s, "FigCraft Refit A");
let b = text(&mut s, "FigCraft Refit B");
let fixed = text(&mut s, "FigCraft Refit A");
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(fixed).unwrap().kind {
t.auto_resize = figcraft_doc::TextAutoResize::None;
}
s.execute("select.none", &json!({})).unwrap();
let want_a = stale(&mut s, a, -30.0);
let want_b = stale(&mut s, b, 25.0);
s.doc_mut().unwrap().get_mut(fixed).unwrap().width = 7.0;
s.doc_mut().unwrap().take_dirty();
// Only the families asked for.
let only_a = super::refit(s.doc_mut().unwrap(), None, Some(&["figcraft refit a".to_string()]));
assert_eq!(only_a, vec![a]);
assert_eq!(width(&s, a), want_a);
assert_eq!(width(&s, b), want_b + 25.0);
// Only the layers asked for, then everything; a fixed box keeps its size.
stale(&mut s, a, 12.0);
assert!(refitted(&s.execute("text.refit", &json!({"ids": [b]})).unwrap()) == vec![b.0]);
assert_eq!((width(&s, a), width(&s, b)), (want_a + 12.0, want_b));
assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()) == vec![a.0]);
assert_eq!(width(&s, a), want_a);
assert_eq!(width(&s, fixed), 7.0);
// Nothing stale: nothing changes.
assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()).is_empty());
assert!(s.execute("text.refit", &json!({"ids": ["nope"]})).is_err());
// An `ids` that isn't a list is refused, not taken as the whole document.
stale(&mut s, a, 9.0);
assert!(s.execute("text.refit", &json!({"ids": a.0.to_string()})).is_err());
assert_eq!(width(&s, a), want_a + 9.0);
assert!(Session::empty().execute("text.refit", &json!({})).is_err());
}
/// Text inside an instance is refitted too.
#[test]
fn refit_reaches_text_in_instances() {
let mut s = Session::new();
let t = text(&mut s, "FigCraft Refit C");
let comp = id_of(&s.execute("component.create", &json!({"ids": [t]})).unwrap()["id"]).unwrap();
let inst = id_of(&s.execute("instance.create", &json!({"component": comp, "x": 300, "y": 0})).unwrap()["id"]).unwrap();
let copy = s.doc().unwrap().node(inst).unwrap().children[0];
let want = stale(&mut s, copy, -20.0);
let r = s.execute("text.refit", &json!({"ids": [inst]})).unwrap();
assert_eq!(refitted(&r), vec![copy.0]);
assert_eq!(width(&s, copy), want);
}
/// `text.loadFont` refuses what isn't a font file; `text.rescanFonts` reports what it added.
#[test]
fn fonts_load_from_files_and_rescans_report_new_families() {
let mut s = Session::new();
assert!(s.execute("text.loadFont", &json!({})).is_err());
assert!(s.execute("text.loadFont", &json!({"data": "bm90IGEgZm9udA=="})).is_err(), "not a font file");
assert!(s.execute("text.loadFont", &json!({"data": "%%%"})).is_err());
assert!(s.execute("text.loadFont", &json!({"path": "no-such-dir/x.ttf"})).is_err());
if let Some(f) = figcraft_text::CRAFT_FONTS.first() {
use base64::Engine as _;
let data = base64::engine::general_purpose::STANDARD.encode(f.bytes);
let r = s.execute("text.loadFont", &json!({"data": data})).unwrap();
assert_eq!(r["families"].as_array().map(Vec::len), Some(1));
}
let r = s.execute("text.rescanFonts", &json!({})).unwrap();
assert!(r["added"].is_array());
}
+3
View File
@@ -376,6 +376,7 @@ fn pointer_draw_move_resize() {
assert_eq!(s.selection(), vec![r]);
}
/// No command panics on junk parameters.
#[test]
fn every_command_survives_junk_params() {
// A junk-parameter sweep: no command may panic (guard turns panics into Internal errors,
@@ -437,6 +438,8 @@ fn every_command_survives_junk_params() {
| "file.placeImage"
| "image.place"
| "file.revert"
// Takes no params and walks the font folders: one walk per junk is slow.
| "text.rescanFonts"
) {
continue;
}
+3 -1
View File
@@ -782,6 +782,7 @@ mod tests {
assert!(r.is_null() || r["axes"].is_array());
}
/// No text command panics on junk parameters while a text is being edited.
#[test]
fn text_commands_survive_junk_while_editing() {
let junk = [
@@ -793,7 +794,8 @@ mod tests {
json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}),
json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}),
];
for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.")) {
// `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow.
for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") {
for j in &junk {
let (mut s, id) = setup("Hi there\nyou");
begin(&mut s, id, Some(2), false).unwrap();
+99 -1
View File
@@ -444,9 +444,22 @@ pub struct FontDb {
families: BTreeMap<String, (String, Vec<usize>)>,
/// System faces known by name but not loaded (lower-case family → entries).
pending: BTreeMap<String, (String, Vec<Pending>)>,
/// System faces read from disk (file, face index), so a rescan only offers new ones.
loaded_files: std::collections::BTreeSet<(std::path::PathBuf, u32)>,
/// Font files added as bytes (hash and length → families), so loading one again adds nothing.
loaded_bytes: std::collections::HashMap<(u64, usize), Vec<String>>,
system_scanned: bool,
}
/// What a later walk of the font folders found ([`rescan_system_fonts`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Rescan {
/// Families that were unknown before.
pub added: Vec<String>,
/// Every family that gained faces: the new ones and those with styles installed since.
pub changed: Vec<String>,
}
static DB: OnceLock<RwLock<FontDb>> = OnceLock::new();
/// Set by [`scan_system_fonts_on_demand`].
static ON_DEMAND: AtomicBool = AtomicBool::new(false);
@@ -580,6 +593,7 @@ impl FontDb {
};
if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, None, Some(&e.style)) {
self.add(face);
self.loaded_files.insert((e.path, e.index));
}
}
}
@@ -670,6 +684,35 @@ impl FontDb {
self.faces.len()
}
/// Offer the families of a later walk of the font folders: new families become pending, a
/// family not loaded yet takes the walk's files, and a loaded family keeps its faces and gets
/// the files it hasn't read (styles installed since) as pending, read when it is next used.
fn merge_rescan(&mut self, found: BTreeMap<String, (String, Vec<Pending>)>) -> Rescan {
let mut out = Rescan::default();
for (k, (display, entries)) in found {
if self.families.contains_key(&k) {
let waiting = self.pending.get(&k).map(|(_, v)| v.clone()).unwrap_or_default();
let new: Vec<Pending> = entries
.into_iter()
.filter(|e| {
!self.loaded_files.contains(&(e.path.clone(), e.index)) && !waiting.iter().any(|w| w.path == e.path && w.index == e.index)
})
.collect();
if !new.is_empty() {
out.changed.push(display.clone());
self.pending.entry(k).or_insert_with(|| (display, Vec::new())).1.extend(new);
}
continue;
}
if !self.pending.contains_key(&k) {
out.added.push(display.clone());
}
out.changed.push(display.clone());
self.pending.insert(k, (display, entries));
}
out
}
/// Index the system font directories (desktop only): family names are read now, faces loaded
/// when first used. Safe to call more than once.
pub fn scan_system(&mut self) {
@@ -776,13 +819,19 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap<String, (Str
// Very large files are indexed all the same (Apple Color Emoji is ~180 MB and is where the
// system's colour emoji live): `index_bytes` reads headers and name tables out of a zeroed
// buffer, so only those parts are ever touched.
let Some(bytes) = usize::try_from(meta.len()).ok().filter(|l| *l <= 256 * 1024 * 1024).and_then(|l| index_bytes(&path, l)) else { continue };
if meta.len() > MAX_FONT_FILE {
continue;
}
let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(&path, l)) else { continue };
for (index, family, style) in faces_in(&bytes) {
out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { path: path.clone(), index, style });
}
}
}
/// The largest font file read (CJK collections run to ~120 MB, Apple Color Emoji ~180 MB).
pub const MAX_FONT_FILE: u64 = 256 * 1024 * 1024;
/// The parts of a font file the scan reads: headers, table directories and `name` tables, at their
/// offsets in a zeroed buffer of the file's length (reading whole files was most of the scan).
fn index_bytes(path: &std::path::Path, len: usize) -> Option<Vec<u8>> {
@@ -887,6 +936,55 @@ pub fn scan_system_fonts() {
});
}
/// Walk the system font folders again, for fonts installed while the app runs (the first scan is
/// once per process): the families that were unknown before, and every family that gained faces.
/// Before the first scan this is that scan; a no-op on the web or with `FIGCRAFT_NO_SYSTEM_FONTS`.
pub fn rescan_system_fonts() -> Rescan {
#[cfg(not(target_arch = "wasm32"))]
if !system_fonts_opted_out() {
if SCANNED.get().is_none() {
let before: std::collections::BTreeSet<String> =
raw_db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families().into_iter().collect();
scan_system_fonts();
let added: Vec<String> = family_names().into_iter().filter(|f| !before.contains(f)).collect();
return Rescan { changed: added.clone(), added };
}
let mut found: BTreeMap<String, (String, Vec<Pending>)> = BTreeMap::new();
for dir in system_font_dirs() {
scan_dir(&dir, 0, &mut found);
}
let rescan = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner).merge_rescan(found);
if !rescan.changed.is_empty() {
crate::clear_layout_cache();
}
return rescan;
}
Rescan::default()
}
/// Load font file bytes (every face of a collection) into the global db, for a font that isn't
/// installed. Returns the families of its faces (none: not a font file); the same file again
/// returns them without adding its faces twice.
pub fn load_font_bytes(bytes: Vec<u8>) -> Vec<String> {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h);
let key = (h.finish(), bytes.len());
let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(families) = db.loaded_bytes.get(&key) {
return families.clone();
}
let faces = db.add_bytes(bytes);
let mut families: Vec<String> = faces.iter().map(|f| f.family.clone()).collect();
families.dedup();
if !families.is_empty() {
db.loaded_bytes.insert(key, families.clone());
drop(db);
crate::clear_layout_cache();
}
families
}
/// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`): renders use only the embedded fonts.
#[cfg(not(target_arch = "wasm32"))]
fn system_fonts_opted_out() -> bool {
+46
View File
@@ -217,3 +217,49 @@ fn user_font_dirs_preserve_non_utf8_paths() {
assert_eq!(dirs[0].as_os_str().as_bytes(), b"/home/\xff/.fonts");
assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts");
}
/// A rescan offers new families and the styles installed since, and never a file twice.
#[test]
fn a_rescan_offers_fonts_installed_since() {
let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into() };
let mut db = pending(&["Regular"]);
let found = |fams: &[(&str, &[&str])]| -> BTreeMap<String, (String, Vec<Pending>)> {
fams.iter().map(|(f, styles)| (f.to_lowercase(), ((*f).to_string(), styles.iter().map(|s| entry(s)).collect()))).collect()
};
// A new family is added; a pending one takes the walk's files (a new style); both changed.
let r = db.merge_rescan(found(&[("CJK UI", &["Regular", "Bold"]), ("Brand Sans", &["Regular"])]));
assert_eq!(r.added, ["Brand Sans"]);
assert_eq!(r.changed, ["Brand Sans", "CJK UI"]);
assert!(db.has_family("brand sans"));
assert_eq!(left(&db), ["Regular", "Bold"]);
// A loaded family keeps its faces and gets only the files it hasn't read, once.
db.families.insert("brand sans".into(), ("Brand Sans".into(), Vec::new()));
db.pending.remove("brand sans");
db.loaded_files.insert(("no-such-dir/Regular.ttf".into(), 0));
let same = db.merge_rescan(found(&[("Brand Sans", &["Regular"])]));
assert_eq!(same, Rescan::default());
assert!(!db.pending.contains_key("brand sans"));
let bold = db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])]));
assert_eq!((bold.added.len(), bold.changed), (0, vec!["Brand Sans".to_string()]));
let waiting = |db: &FontDb| db.pending.get("brand sans").map(|(_, v)| v.iter().map(|e| e.style.clone()).collect::<Vec<_>>()).unwrap_or_default();
assert_eq!(waiting(&db), ["Black"]);
assert_eq!(db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])])), Rescan::default());
assert_eq!(waiting(&db), ["Black"]);
}
/// Font file bytes add their faces once; junk adds nothing.
#[test]
fn font_bytes_load_into_the_db() {
assert!(load_font_bytes(b"not a font".to_vec()).is_empty());
assert!(load_font_bytes(Vec::new()).is_empty());
// With the embedded fonts (absent on CI builds without craft-fonts): a file's family is added,
// and loading the same file again returns it without adding its faces twice.
if let Some(f) = CRAFT_FONTS.first() {
let families = load_font_bytes(f.bytes.to_vec());
assert_eq!(families.len(), 1);
assert!(families.iter().all(|f| has_family(f)));
let faces = db().read().unwrap().face_count();
assert_eq!(load_font_bytes(f.bytes.to_vec()), families);
assert_eq!(db().read().unwrap().face_count(), faces);
}
}
+3 -2
View File
@@ -16,8 +16,9 @@ mod onpath;
pub use breaks::grapheme_step;
pub use color::{ColorBitmap, ColorInk, ColorLayer};
pub use fontdb::{
Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family,
installed_fonts_known, no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned,
Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face,
has_family, installed_fonts_known, load_font_bytes, no_coords, rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand,
system_fonts_scanned,
};
pub use layout::{Decoration, Glyph, Line, TextLayout, layout};
pub use onpath::OnPath;
+3
View File
@@ -2157,6 +2157,7 @@ status = "draft — needs native review"
"text.listIndent" = "Aumentar sangría de la lista"
"text.listOutdent" = "Reducir sangría de la lista"
"text.listStyle" = "Estilo de lista"
"text.loadFont" = "Cargar archivo de fuente"
"text.maxLines" = "Máximo de líneas"
"text.missingFonts" = "Fuentes que faltan"
"text.numberList" = "Lista numerada"
@@ -2165,7 +2166,9 @@ status = "draft — needs native review"
"text.paragraphSpacing" = "Espacio entre párrafos"
"text.pathOptions" = "Opciones de texto en trazado"
"text.preview" = "Previsualizar cambio de texto"
"text.refit" = "Ajustar texto a las fuentes"
"text.removeFromPath" = "Quitar texto del trazado"
"text.rescanFonts" = "Volver a buscar fuentes instaladas"
"text.set" = "Establecer texto"
"text.setFeatures" = "Funciones OpenType"
"text.setVariations" = "Ejes de fuente variable"
+3
View File
@@ -2145,6 +2145,7 @@ status = "draft — needs native review"
"text.listIndent" = "Sisennä luettelo"
"text.listOutdent" = "Vähennä luettelon sisennystä"
"text.listStyle" = "Luettelotyyli"
"text.loadFont" = "Lataa fonttitiedosto"
"text.maxLines" = "Rivien enimmäismäärä"
"text.missingFonts" = "Puuttuvat fontit"
"text.numberList" = "Numeroitu luettelo"
@@ -2153,7 +2154,9 @@ status = "draft — needs native review"
"text.paragraphSpacing" = "Kappaleväli"
"text.pathOptions" = "Tekstipolun asetukset"
"text.preview" = "Esikatsele tekstimuutosta"
"text.refit" = "Sovita teksti fontteihin"
"text.removeFromPath" = "Poista teksti polulta"
"text.rescanFonts" = "Etsi asennetut fontit uudelleen"
"text.set" = "Aseta teksti"
"text.setFeatures" = "OpenType-ominaisuudet"
"text.setVariations" = "Muuttuvan fontin akselit"
+6 -1
View File
@@ -234,7 +234,12 @@ Or in a project's `.mcp.json`:
`text.setVariations {variations, replace?}` merges instead. `text.fonts {family, style?, axes: true}`
lists a font's axes and named instances (named instances are also valid `fontStyle` values).
`text.missingFonts` lists the families the document names that aren't installed
(`{families: [{family, layers}]}`); their text draws in a fallback font.
(`{families: [{family, layers}]}`); their text draws in a fallback font. Fonts are indexed once
per process: after installing one, `text.rescanFonts` picks it up (`{added: [families]}`), and
`text.loadFont {path | data (base64)}` adds a font file without installing it (`{families}`).
Text boxes keep the size they were measured at: both refit the open document's auto-sizing text
in the new families (`refitted: [ids]`), and `text.refit {ids?}` does it for any text, e.g. a
file saved while its fonts were missing.
OpenType features: `features {"liga": false, "tnum": true}`.
Decorations: `textDecorationStyle` (`solid`, `dotted`, `wavy`), `textDecorationThickness` and
`textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline