From c94269ff727b0c9d29850aa0d34c1df78009c684 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 02:43:50 +0700 Subject: [PATCH 1/2] =?UTF-8?q?Type:=20Composite=20Fonts=E2=80=A6=20sets?= =?UTF-8?q?=20each=20class=20of=20character=20in=20a=20font=20of=20its=20o?= =?UTF-8?q?wn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed from #6244 onto main. --- crates/doc/src/document.rs | 3 + crates/doc/src/lib.rs | 4 + crates/doc/src/mem.rs | 5 + crates/doc/src/text.rs | 5 + crates/doc/src/text/composite.rs | 344 ++++++++++++ crates/engine/src/cmd.rs | 2 + crates/engine/src/cmd/addons/cleanup/text.rs | 15 +- crates/engine/src/cmd/compositefonts.rs | 309 +++++++++++ crates/engine/src/cmd/compositefonts/tests.rs | 502 ++++++++++++++++++ crates/engine/src/cmd/create.rs | 2 +- crates/engine/src/cmd/fileio/load.rs | 3 +- crates/engine/src/cmd/fontfiles.rs | 6 +- crates/engine/src/cmd/fonts.rs | 70 ++- crates/engine/src/cmd/textedit.rs | 9 +- crates/engine/src/cmd/textstyles.rs | 2 +- crates/engine/src/cmd/typecmd.rs | 2 +- crates/engine/src/lib.rs | 3 + crates/engine/src/session/documents.rs | 32 +- crates/engine/src/session/execute.rs | 3 + crates/engine/src/tests/fontlist.rs | 6 +- crates/eps/src/legacy_ai.rs | 6 +- crates/eps/src/legacy_ai/encoding.rs | 13 +- crates/eps/src/legacy_ai/tests.rs | 8 +- crates/eps/src/legacy_ai/writer.rs | 2 +- crates/eps/src/lib.rs | 2 + crates/pdf/src/export/entry.rs | 2 + crates/render/src/tests/fontchange.rs | 19 + crates/render/src/text_geom.rs | 9 +- crates/svg/src/export/text.rs | 13 +- crates/svg/src/lib.rs | 2 + crates/text/src/fontdb.rs | 7 + crates/text/src/fontdb/composite.rs | 345 ++++++++++++ crates/text/src/fontdb/db.rs | 10 +- crates/text/src/fontdb/lookup.rs | 26 +- crates/text/src/layout.rs | 14 + crates/text/src/layout/driver.rs | 22 +- crates/text/src/layout/ruby.rs | 9 +- crates/text/src/layout/wrapping_tests.rs | 1 + crates/text/src/lib.rs | 6 +- crates/text/src/tests.rs | 1 + crates/text/src/tests/composite.rs | 196 +++++++ crates/ui-egui/src/app/commands.rs | 2 +- crates/ui-egui/src/dialogs.rs | 2 + crates/ui-egui/src/dialogs/composite_fonts.rs | 275 ++++++++++ .../src/dialogs/composite_fonts/tests.rs | 174 ++++++ crates/ui-egui/src/i18n/bg.tsv | 9 + crates/ui-egui/src/i18n/bs.tsv | 9 + crates/ui-egui/src/i18n/ca.tsv | 9 + crates/ui-egui/src/i18n/cs.tsv | 9 + crates/ui-egui/src/i18n/da.tsv | 9 + crates/ui-egui/src/i18n/de.tsv | 9 + crates/ui-egui/src/i18n/el.tsv | 9 + crates/ui-egui/src/i18n/es.tsv | 9 + crates/ui-egui/src/i18n/fi.tsv | 9 + crates/ui-egui/src/i18n/fr.tsv | 9 + crates/ui-egui/src/i18n/hi.tsv | 9 + crates/ui-egui/src/i18n/hu.tsv | 9 + crates/ui-egui/src/i18n/id.tsv | 9 + crates/ui-egui/src/i18n/it.tsv | 9 + crates/ui-egui/src/i18n/ja.tsv | 9 + crates/ui-egui/src/i18n/ka.tsv | 9 + crates/ui-egui/src/i18n/ko.tsv | 9 + crates/ui-egui/src/i18n/nl.tsv | 9 + crates/ui-egui/src/i18n/pl.tsv | 9 + crates/ui-egui/src/i18n/pt-br.tsv | 9 + crates/ui-egui/src/i18n/ru.tsv | 9 + crates/ui-egui/src/i18n/sv.tsv | 9 + crates/ui-egui/src/i18n/tr.tsv | 9 + crates/ui-egui/src/i18n/uk.tsv | 9 + crates/ui-egui/src/i18n/vi.tsv | 9 + crates/ui-egui/src/i18n/zh-hans.tsv | 9 + crates/ui-egui/src/i18n/zh-hant.tsv | 9 + crates/ui-egui/src/io/open.rs | 1 + crates/ui-egui/src/io/save.rs | 1 + crates/ui-egui/src/menus/invoke.rs | 7 + crates/ui-egui/src/menus/state.rs | 13 +- crates/ui-egui/src/menus/tree.rs | 1 + docs/mcp.md | 47 ++ docs/type-parity.md | 1 + 79 files changed, 2729 insertions(+), 78 deletions(-) create mode 100644 crates/doc/src/text/composite.rs create mode 100644 crates/engine/src/cmd/compositefonts.rs create mode 100644 crates/engine/src/cmd/compositefonts/tests.rs create mode 100644 crates/text/src/fontdb/composite.rs create mode 100644 crates/text/src/tests/composite.rs create mode 100644 crates/ui-egui/src/dialogs/composite_fonts.rs create mode 100644 crates/ui-egui/src/dialogs/composite_fonts/tests.rs diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 287a6b2aa..ebd27fd87 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -34,6 +34,7 @@ impl Document { graphic_styles, char_styles, para_styles, + composite_fonts, text_threads, symbols, graph_designs, @@ -90,6 +91,7 @@ impl Document { && *graphic_styles == other.graphic_styles && *char_styles == other.char_styles && *para_styles == other.para_styles + && *composite_fonts == other.composite_fonts && *text_threads == other.text_threads && symbols.len() == other.symbols.len() && symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) @@ -213,6 +215,7 @@ impl Document { graphic_styles: default_graphic_styles().into(), char_styles: Default::default(), para_styles: Default::default(), + composite_fonts: Default::default(), text_threads: vec![], symbols: vec![], graph_designs: vec![], diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8a866256e..5ab3b8bdf 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -121,6 +121,7 @@ pub use text::{ RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape, }; +pub use text::{CharClass, CompositeFont, CompositePart, CompositeParts}; pub use trace::TraceView; pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; pub use vectorcraft_color as color; @@ -195,6 +196,9 @@ pub struct Document { /// Paragraph styles (besides the built-in [Normal Paragraph Style]). #[serde(default, skip_serializing_if = "skip::is_empty_vec")] pub para_styles: Shared>, + /// Type › Composite Fonts…: the composite fonts defined in the document, by name. + #[serde(default, skip_serializing_if = "skip::is_empty_vec")] + pub composite_fonts: Shared>, /// Threaded text: area-type frames one story flows through, in order. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub text_threads: Vec>, diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index a93830822..e431fc9d1 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -103,6 +103,11 @@ impl Document { let styles = |s: &Vec| vec_bytes(s) + s.iter().map(|s| s.name.capacity() + map_bytes(&s.attrs)).sum::(); n += shared(&self.char_styles, &newer.char_styles, styles); n += shared(&self.para_styles, &newer.para_styles, styles); + n += shared(&self.composite_fonts, &newer.composite_fonts, |c| { + let part = |p: &crate::CompositePart| p.font.capacity() + p.style.capacity(); + vec_bytes(c) + + c.iter().map(|f| f.name.capacity() + crate::CharClass::ALL.into_iter().map(|k| part(f.parts.get(k))).sum::()).sum::() + }); n += shared(&self.saved_selections, &newer.saved_selections, |s| { vec_bytes(s) + s.iter().map(|s: &SavedSelection| s.name.capacity() + vec_bytes(&s.objects)).sum::() }); diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index a2f21e856..11be20ee2 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -8,6 +8,7 @@ use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, Li mod area; mod char_style; +mod composite; mod object; mod para_style; mod runs; @@ -17,6 +18,10 @@ mod wrap; pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape}; use char_style::yes; pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, ScriptMetrics}; +pub use composite::{ + COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CharClass, CompositeFont, CompositePart, + CompositeParts, +}; pub use para_style::{ Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, SingleWordJustify, diff --git a/crates/doc/src/text/composite.rs b/crates/doc/src/text/composite.rs new file mode 100644 index 000000000..e95428c2f --- /dev/null +++ b/crates/doc/src/text/composite.rs @@ -0,0 +1,344 @@ +//! Type › Composite Fonts…: a font made of other fonts, one for each class of character (the +//! Kanji font is the base; kana, punctuation, symbols, Roman letters and numerals each take a font +//! of their own, sized, raised and scaled against it). Type set in a composite font names it as its +//! family; the layout sets each character in its class's font. +//! +//! Behavior reference (public documentation): +//! . +//! Which characters fall in which class, the ranges and the defaults are VectorCraft's own. + +use serde::{Deserialize, Serialize}; + +/// A class of characters a composite font gives a font of its own. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CharClass { + /// The base font: ideographs, full-width letters and digits, and every character no other + /// class takes. + Kanji, + /// Hiragana and katakana, full-width and half-width. + Kana, + /// East Asian and full-width punctuation, and typographic quotes and dashes. + Punctuation, + /// Arrows, math, shapes, enclosed and other symbols. + Symbols, + /// Half-width Roman letters, their punctuation and spaces. + Roman, + /// The half-width digits 0 to 9. + Numerals, +} + +impl CharClass { + /// Every class, in the order the Composite Fonts dialog lists them. + pub const ALL: [CharClass; 6] = [Self::Kanji, Self::Kana, Self::Punctuation, Self::Symbols, Self::Roman, Self::Numerals]; + + /// The name commands and files use (`kanji`, `kana`, …). + pub fn key(self) -> &'static str { + match self { + Self::Kanji => "kanji", + Self::Kana => "kana", + Self::Punctuation => "punctuation", + Self::Symbols => "symbols", + Self::Roman => "roman", + Self::Numerals => "numerals", + } + } + + /// The class named `key` ([`Self::key`], any case). + pub fn named(key: &str) -> Option { + Self::ALL.into_iter().find(|c| c.key().eq_ignore_ascii_case(key)) + } + + /// Whether the class's characters can be scaled across and up (Kana, Roman and Numerals). + pub fn scales(self) -> bool { + matches!(self, Self::Kana | Self::Roman | Self::Numerals) + } + + /// The class of character `c`. + pub fn of(c: char) -> Self { + let u = c as u32; + match u { + 0x30..=0x39 => Self::Numerals, + // Basic Latin, Latin-1, Latin Extended, IPA, spacing and combining marks, and Latin + // Extended Additional. + 0x00..=0x036F | 0x1E00..=0x1EFF => Self::Roman, + // The other Latin blocks: combining marks extended, phonetic extensions and their + // supplement, combining marks supplement, Latin Extended-C, -D, -E, -F and -G, and the + // Latin ligatures of the alphabetic presentation forms. + 0x1AB0..=0x1AFF + | 0x1D00..=0x1DFF + | 0x2C60..=0x2C7F + | 0xA720..=0xA7FF + | 0xAB30..=0xAB6F + | 0xFB00..=0xFB06 + | 0x10780..=0x107BF + | 0x1DF00..=0x1DFFF => Self::Roman, + // ・ and ゠ sit in the katakana block but are punctuation. + 0x30A0 | 0x30FB => Self::Punctuation, + // Hiragana, katakana, katakana phonetic extensions, half-width katakana (with its + // prolonged sound and voicing marks) and the kana supplements. + 0x3041..=0x309F | 0x30A1..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F | 0x1B000..=0x1B16F => Self::Kana, + // CJK symbols: 〄 〒 〓 〠 〶; ideographic space, 々 〆 〇 and the like stay with the base. + 0x3004 | 0x3012 | 0x3013 | 0x3020 | 0x3036 => Self::Symbols, + 0x3000 | 0x3005..=0x3007 | 0x3021..=0x302F | 0x3031..=0x3035 | 0x3038..=0x303C => Self::Kanji, + 0x3001..=0x303F => Self::Punctuation, + // ※ is a symbol; the rest of General Punctuation (dashes, quotes, leaders) punctuation. + 0x203B => Self::Symbols, + 0x2010..=0x205E => Self::Punctuation, + // Letterlike symbols, number forms, arrows, math, technical, enclosed alphanumerics, box + // drawing, shapes, miscellaneous symbols and dingbats; enclosed CJK and CJK + // compatibility; full-width signs. + 0x2100..=0x2BFF | 0x3200..=0x33FF | 0xFFE0..=0xFFEE => Self::Symbols, + // Full-width forms of the ASCII signs. + 0xFF03..=0xFF06 | 0xFF0A | 0xFF0B | 0xFF1C..=0xFF1E | 0xFF20 | 0xFF3C | 0xFF3E | 0xFF5C | 0xFF5E => Self::Symbols, + // Full-width forms of the other ASCII punctuation, and the half-width CJK punctuation. + 0xFF01..=0xFF0F | 0xFF1A..=0xFF1F | 0xFF3B..=0xFF40 | 0xFF5B..=0xFF65 => Self::Punctuation, + _ => Self::Kanji, + } + } +} + +/// The font a composite font sets one class of characters in, and how. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompositePart { + /// The font family and style. + pub font: String, + #[serde(default = "regular")] + pub style: String, + /// The size, in percent of the type's size. + #[serde(default = "hundred")] + pub size: f64, + /// How far the characters are raised from the baseline, in percent of the type's size + /// (negative lowers them). + #[serde(default)] + pub baseline: f64, + /// Horizontal and vertical scale, in percent (Kana, Roman and Numerals only). + #[serde(default = "hundred")] + pub h_scale: f64, + #[serde(default = "hundred")] + pub v_scale: f64, + /// Kana only: the characters are scaled from the center of the em box rather than from the + /// baseline. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub scale_at_center: bool, +} + +fn hundred() -> f64 { + 100.0 +} + +fn regular() -> String { + "Regular".into() +} + +impl CompositePart { + /// The part a new composite font gives every class: `font` in `style` at full size, on the + /// baseline, unscaled. + pub fn new(font: impl Into, style: impl Into) -> Self { + Self { font: font.into(), style: style.into(), size: 100.0, baseline: 0.0, h_scale: 100.0, v_scale: 100.0, scale_at_center: false } + } +} + +/// The six parts of a composite font, one per [`CharClass`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CompositeParts { + pub kanji: CompositePart, + pub kana: CompositePart, + pub punctuation: CompositePart, + pub symbols: CompositePart, + pub roman: CompositePart, + pub numerals: CompositePart, +} + +impl CompositeParts { + /// Every class set in `part`. + pub fn all(part: CompositePart) -> Self { + Self { kanji: part.clone(), kana: part.clone(), punctuation: part.clone(), symbols: part.clone(), roman: part.clone(), numerals: part } + } + + pub fn get(&self, class: CharClass) -> &CompositePart { + match class { + CharClass::Kanji => &self.kanji, + CharClass::Kana => &self.kana, + CharClass::Punctuation => &self.punctuation, + CharClass::Symbols => &self.symbols, + CharClass::Roman => &self.roman, + CharClass::Numerals => &self.numerals, + } + } + + pub fn get_mut(&mut self, class: CharClass) -> &mut CompositePart { + match class { + CharClass::Kanji => &mut self.kanji, + CharClass::Kana => &mut self.kana, + CharClass::Punctuation => &mut self.punctuation, + CharClass::Symbols => &mut self.symbols, + CharClass::Roman => &mut self.roman, + CharClass::Numerals => &mut self.numerals, + } + } +} + +/// A composite font (Type › Composite Fonts…), saved with the document. Its name is the family +/// that type set in it names. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CompositeFont { + pub name: String, + pub parts: CompositeParts, +} + +/// The size a part can take, in percent of the type's size. +pub const COMPOSITE_SIZE: std::ops::RangeInclusive = 10.0..=400.0; +/// How far a part can raise or lower its characters, in percent of the type's size. +pub const COMPOSITE_BASELINE: std::ops::RangeInclusive = -100.0..=100.0; +/// The horizontal and vertical scale a part can take, in percent. +pub const COMPOSITE_SCALE: std::ops::RangeInclusive = 10.0..=400.0; +/// The longest composite font name, in characters. +pub const COMPOSITE_NAME_MAX: usize = 255; +/// The most composite fonts a document or a file keeps. +pub const COMPOSITE_FONTS_MAX: usize = 256; + +impl CompositeFont { + /// A composite font named `name` setting every class in `font` and `style`. + pub fn new(name: impl Into, font: impl Into, style: impl Into) -> Self { + Self { name: name.into(), parts: CompositeParts::all(CompositePart::new(font, style)) } + } + + /// Do `a` and `b` name the same composite font: equal in any case? + pub fn same_name(a: &str, b: &str) -> bool { + a == b || a.to_lowercase() == b.to_lowercase() + } + + /// The font of `fonts` (a document's composite fonts) named `name`, in any case. + pub fn named<'a>(fonts: &'a [CompositeFont], name: &str) -> Option<&'a CompositeFont> { + fonts.iter().find(|f| Self::same_name(&f.name, name)) + } + + /// `name` trimmed, when it can name a composite font: not empty, at most + /// [`COMPOSITE_NAME_MAX`] characters and without control characters. + pub fn clean_name(name: &str) -> Option { + let name = name.trim(); + let ok = !name.is_empty() && name.chars().count() <= COMPOSITE_NAME_MAX && !name.chars().any(char::is_control); + ok.then(|| name.to_string()) + } + + /// Why this composite font can't be used, if it can't: a name [`Self::clean_name`] refuses, a + /// part without a font, a value out of its range, or a scale or Scale at Center on a class + /// that has none. + pub fn check(&self) -> Result<(), String> { + if Self::clean_name(&self.name).as_deref() != Some(self.name.as_str()) { + return Err(format!("`{}` can't name a composite font", self.name)); + } + for class in CharClass::ALL { + let p = self.parts.get(class); + let k = class.key(); + if p.font.trim().is_empty() { + return Err(format!("{k}: give a font")); + } + let within = |v: f64, r: &std::ops::RangeInclusive| v.is_finite() && r.contains(&v); + if !within(p.size, &COMPOSITE_SIZE) { + return Err(format!("{k}: size must be {} to {}%", COMPOSITE_SIZE.start(), COMPOSITE_SIZE.end())); + } + if !within(p.baseline, &COMPOSITE_BASELINE) { + return Err(format!("{k}: baseline must be {} to {}%", COMPOSITE_BASELINE.start(), COMPOSITE_BASELINE.end())); + } + for v in [p.h_scale, p.v_scale] { + if !within(v, &COMPOSITE_SCALE) { + return Err(format!("{k}: scale must be {} to {}%", COMPOSITE_SCALE.start(), COMPOSITE_SCALE.end())); + } + if v != 100.0 && !class.scales() { + return Err(format!("{k}: only kana, roman and numerals can be scaled")); + } + } + if p.scale_at_center && class != CharClass::Kana { + return Err(format!("{k}: only kana can be scaled at the center")); + } + } + Ok(()) + } + + /// `fonts` without the ones that can't be used: those [`Self::check`] refuses, a later font + /// sharing an earlier one's name (in any case), a part set in one of the fonts, and those past + /// [`COMPOSITE_FONTS_MAX`]. `None` when every font can be used. + pub fn usable(fonts: &[CompositeFont]) -> Option> { + let mut names = std::collections::HashSet::new(); + let mut kept: Vec = + fonts.iter().filter(|f| f.check().is_ok() && names.insert(f.name.to_lowercase())).take(COMPOSITE_FONTS_MAX).cloned().collect(); + kept.retain(|f| CharClass::ALL.iter().all(|&c| !names.contains(&f.parts.get(c).font.to_lowercase()))); + (kept.len() != fonts.len()).then_some(kept) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Each class takes its characters: digits and Latin letters are half-width numerals and + /// Roman, kana both widths, and the punctuation, symbols and full-width forms their own. + #[test] + fn characters_fall_in_their_classes() { + for (s, class) in [ + ("漢字国一龍〇々", CharClass::Kanji), + ("AZa09 ", CharClass::Kanji), + ("あをアンーアンー", CharClass::Kana), + ("、。「」『』【】〜・!?():", CharClass::Punctuation), + ("“”‘’—…", CharClass::Punctuation), + ("※→★○〒+=$%①㈱", CharClass::Symbols), + ("AZaz é!? ()", CharClass::Roman), + // Latin Extended-C, -D and -E, phonetic extensions and a ligature. + ("\u{2C60}\u{A7A0}\u{AB30}\u{1D00}\u{FB01}", CharClass::Roman), + ("0123456789", CharClass::Numerals), + ] { + for c in s.chars() { + assert_eq!(CharClass::of(c), class, "{c:?} (U+{:04X})", c as u32); + } + } + } + + /// A composite font is read and written by its fields' names, and a part's omitted values + /// take the defaults. + #[test] + fn composite_fonts_read_back_with_defaults() { + let mut f = CompositeFont::new("Mixed", "Source Sans 3", "Regular"); + f.parts.kana.v_scale = 90.0; + f.parts.kana.scale_at_center = true; + f.parts.roman.baseline = -5.0; + let json = serde_json::to_value(&f).unwrap(); + assert_eq!(json["parts"]["kana"]["vScale"], 90.0); + assert_eq!(json["parts"]["kana"]["scaleAtCenter"], true); + assert!(json["parts"]["roman"].get("scaleAtCenter").is_none()); + assert_eq!(serde_json::from_value::(json).unwrap(), f); + let part: CompositePart = serde_json::from_value(serde_json::json!({"font": "Inter"})).unwrap(); + assert_eq!(part, CompositePart::new("Inter", "Regular")); + } + + /// Names are trimmed; values out of their ranges, and scales on classes that don't scale, are + /// refused. + #[test] + fn composite_fonts_check_their_values() { + assert_eq!(CompositeFont::clean_name(" Mixed ").as_deref(), Some("Mixed")); + for bad in ["", " ", "a\tb", &"x".repeat(COMPOSITE_NAME_MAX + 1)] { + assert_eq!(CompositeFont::clean_name(bad), None, "{bad:?}"); + } + let ok = CompositeFont::new("Mixed", "Inter", "Regular"); + assert_eq!(ok.check(), Ok(())); + let with = |f: fn(&mut CompositeParts)| { + let mut c = ok.clone(); + f(&mut c.parts); + c.check() + }; + assert!(with(|p| p.roman.size = 9.0).is_err()); + assert!(with(|p| p.roman.size = f64::NAN).is_err()); + assert!(with(|p| p.kana.baseline = 101.0).is_err()); + assert!(with(|p| p.kana.h_scale = 401.0).is_err()); + assert!(with(|p| p.kanji.v_scale = 90.0).is_err()); + assert!(with(|p| p.symbols.h_scale = 90.0).is_err()); + assert!(with(|p| p.roman.scale_at_center = true).is_err()); + assert!(with(|p| p.numerals.font = " ".into()).is_err()); + assert_eq!(with(|p| p.kana.scale_at_center = true), Ok(())); + assert_eq!(with(|p| p.numerals.h_scale = 50.0), Ok(())); + let mut spaced = ok.clone(); + spaced.name = " Mixed".into(); + assert!(spaced.check().is_err()); + } +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index b24386645..75835394a 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -12,6 +12,7 @@ pub mod clipboard; mod colorcmds; pub mod colormgmt; pub mod colortheme; +pub mod compositefonts; mod compoundshape; pub(crate) mod create; mod css; @@ -286,6 +287,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(inline::specs()); v.extend(textstyles::specs()); v.extend(fonts::specs()); + v.extend(compositefonts::specs()); v.extend(fontfiles::specs()); v.extend(help::specs()); v.extend(threads::specs()); diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index d8f2b0289..73b21356b 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -962,11 +962,9 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { let default = CharStyle::default().font_family; let wanted = text_param(p, "font", C)?.filter(|f| !f.trim().is_empty()).unwrap_or(&default); let db = FontDb::global(); - // Any name of the family is stored as its own name. - let family = match db.resolve(wanted, "Regular") { - Some((f, m)) if m != FontMatch::Missing => f.family.clone(), - _ => return Err(bad(C, "the replacement font is not available")), - }; + // Any name of the family is stored as its own name, and a composite font as its own name. + let composite = db.composite_font(wanted).is_some(); + let family = db.stored_family(wanted, "Regular").ok_or_else(|| bad(C, "the replacement font is not available"))?; let missing = |st: &CharStyle| db.resolve(&st.font_family, &st.font_style).is_none_or(|(_, m)| m == FontMatch::Missing); let lacks = |t: &TextObject| t.runs.iter().any(|r| missing(&r.style)); let d = &s.doc()?.doc; @@ -988,8 +986,11 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { let mut runs = 0; let mut replace = |t: &mut TextObject| { for r in t.runs.iter_mut().filter(|r| missing(&r.style)) { - // The style the font has closest to the one the character had. - r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone()); + // The style the font has closest to the one the character had (a composite font's + // parts name their own styles: the character keeps its style). + if !composite { + r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone()); + } r.style.font_family = family.clone(); r.style.font_version = None; r.style.font_axes.clear(); diff --git a/crates/engine/src/cmd/compositefonts.rs b/crates/engine/src/cmd/compositefonts.rs new file mode 100644 index 000000000..2b356ab85 --- /dev/null +++ b/crates/engine/src/cmd/compositefonts.rs @@ -0,0 +1,309 @@ +//! Type › Composite Fonts…: the document's composite fonts ([`vectorcraft_doc::CompositeFont`]), +//! which set each class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals) in a +//! font of its own. Type names a composite font as its family, and the font menus list them first. +//! The font database holds the active document's composite fonts ([`sync`]); a file exported here +//! carries them to another document. +//! +//! Behavior reference (public documentation): +//! . + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::text::{COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX}; +use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CompositeParts}; +use vectorcraft_text::FontDb; + +use super::fileio::{read_file_head, write_file}; +use super::textstyles::each_text_mut; +use super::typecmd::refresh_bounds; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, switch_or, text_param}; + +/// The extension of the files Export writes and Import reads. +pub const EXTS: &[&str] = &["vccomposite"]; + +/// The largest file Import reads, in bytes. +const MAX_FILE_BYTES: usize = 4 << 20; + +/// The keys a part may give. +const PART_KEYS: [&str; 7] = ["font", "style", "size", "baseline", "hScale", "vScale", "scaleAtCenter"]; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "type.compositeFonts.list", + "Composite Fonts", + [], + None, + "{} the active document's composite fonts → {fonts: [{name, parts: {kanji, kana, punctuation, symbols, roman, numerals: {font, style, size: % of the type size, baseline: % of the type size (positive raises), hScale: %, vScale: %, scaleAtCenter?: bool}}}]}", + has_doc, + list + ), + cmd!( + "type.compositeFonts.set", + "Composite Fonts…", + ["Type"], + None, + "{fonts: [{name, parts: {kanji: {font, style?: \"Regular\", size?: % 10..400 = 100, baseline?: % -100..100 = 0}, kana?, punctuation?, symbols?, roman?, numerals?: {font, style?, size?, baseline?, hScale?, vScale?: % 10..400 = 100, scaleAtCenter?: bool = false}}}]} replace the active document's composite fonts (Type › Composite Fonts…'s OK), as one undo step, none when nothing changes. A composite font sets each class of character in its part's font: kanji (the base: ideographs, full-width letters and digits and anything no other class takes), kana (hiragana and katakana, both widths), punctuation (East Asian and full-width punctuation, quotes and dashes), symbols (arrows, math, shapes and other symbols), roman (half-width letters, their punctuation and spaces) and numerals (the digits 0 to 9); a part left out is the kanji part. size and baseline are percent of the type's size, hScale and vScale scale kana, roman and numerals only, and scaleAtCenter (kana only) scales them from the em box's center instead of the baseline. Names are trimmed, unique in any case, at most 255 characters, and may not name an installed font; a part's font may not be a composite font; at most 256. Type set in a composite font names it as its family (text.setStyle {font: name}), and the font menus list composite fonts first. A font taken out stays named by its type, which then shows as missing → {fonts}", + has_doc, + set + ), + cmd!( + query "type.compositeFonts.export", + "Export Composite Fonts", + [], + None, + "{path?: .vccomposite file, names?: [name] (default: all), fonts?: [{name, parts}] (as type.compositeFonts.set takes them)} write the active document's composite fonts (all, or those named, any case), or the fonts given, to a file type.compositeFonts.import reads; without path the file's text comes back as data → {path, count} | {data, count}", + has_doc, + export + ), + cmd!( + "type.compositeFonts.import", + "Import Composite Fonts", + [], + None, + "{path: .vccomposite file | data: its text | dataBase64: its bytes (at most 4 MB), apply?: bool = true} read the composite fonts a file holds (type.compositeFonts.export writes them); with apply they join the active document's, a font of the same name (any case) replaced, as one undo step, none when nothing changes; apply false only reads them → {fonts, imported: [name]}", + has_doc, + import + ), + ] +} + +/// Register the active document's composite fonts in the font database, and remove the ones the +/// other open documents (or an earlier active document) defined. Run after each command and when +/// another document takes the front, so an undo, a revert, an opened or closed file or a document +/// switch draws type in the active document's own composite fonts, and type naming one it does +/// not define shows as missing. Whether a composite font was added, changed or removed. +pub(crate) fn sync(s: &mut Session) -> bool { + let db = FontDb::global(); + let mut names = Vec::new(); + let mut changed = false; + for f in s.active().map(|d| d.doc.composite_fonts.to_vec()).unwrap_or_default() { + changed |= db.set_composite_font(&f); + names.push(f.name.to_lowercase()); + } + let others = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase())); + let stale: Vec = s.composite_names.iter().cloned().chain(others).filter(|n| !names.contains(n)).collect(); + for gone in stale { + changed |= db.remove_composite_font(&gone); + } + s.composite_names = names; + changed +} + +/// Leave out a file's unusable composite fonts before registering and measuring its type. +pub(crate) fn filter_loaded(doc: &mut vectorcraft_doc::Document) { + let db = FontDb::global(); + if let Some(kept) = CompositeFont::usable(&doc.composite_fonts) { + *doc.composite_fonts = kept; + } + doc.composite_fonts.retain(|f| !db.has_installed_family(&f.name)); +} + +fn list(s: &mut Session, _: &Value) -> Result { + Ok(json!({ "fonts": *s.doc()?.doc.composite_fonts })) +} + +/// Composite font `v` from command parameters or a file: its name trimmed, each part left out a +/// copy of the Kanji part, and keys or values a part can't take refused. +fn parse_font(v: &Value, c: &str) -> Result { + let o = v.as_object().ok_or_else(|| bad(c, "each font must be {name, parts}"))?; + if let Some(k) = o.keys().find(|k| !["name", "parts"].contains(&k.as_str())) { + return Err(bad(c, format!("unknown key `{k}`; a font takes name and parts"))); + } + let raw = o.get("name").and_then(Value::as_str).ok_or_else(|| bad(c, "each font needs a name"))?; + let name = CompositeFont::clean_name(raw) + .ok_or_else(|| bad(c, format!("`{raw}` can't name a composite font (1 to {COMPOSITE_NAME_MAX} characters, no control characters)")))?; + let parts = o.get("parts").and_then(Value::as_object).ok_or_else(|| bad(c, format!("`{name}`: parts must be an object")))?; + if let Some(k) = parts.keys().find(|k| !CharClass::ALL.iter().any(|c| c.key() == k.as_str())) { + return Err(bad(c, format!("`{name}`: unknown class `{k}`; the classes are kanji, kana, punctuation, symbols, roman and numerals"))); + } + let part = |class: CharClass| -> Result> { + let Some(p) = parts.get(class.key()) else { return Ok(None) }; + let po = p.as_object().ok_or_else(|| bad(c, format!("`{name}`: {} must be an object", class.key())))?; + if let Some(k) = po.keys().find(|k| !PART_KEYS.contains(&k.as_str())) { + return Err(bad(c, format!("`{name}`: unknown key `{k}` in {}", class.key()))); + } + serde_json::from_value(p.clone()).map(Some).map_err(|e| bad(c, format!("`{name}`: {}: {e}", class.key()))) + }; + let kanji = part(CharClass::Kanji)?.ok_or_else(|| bad(c, format!("`{name}`: give the kanji part")))?; + let mut f = CompositeFont { name: name.clone(), parts: CompositeParts::all(kanji) }; + for class in CharClass::ALL.into_iter().skip(1) { + if let Some(p) = part(class)? { + *f.parts.get_mut(class) = p; + } + } + f.check().map_err(|e| bad(c, format!("`{}`: {e}", f.name)))?; + Ok(f) +} + +/// Error unless `fonts` can stand together in a document: names unique in any case, none naming +/// an installed font, no part set in a composite font, and at most [`COMPOSITE_FONTS_MAX`]. +fn check_set(fonts: &[CompositeFont], c: &str) -> Result<()> { + if fonts.len() > COMPOSITE_FONTS_MAX { + return Err(bad(c, format!("a document keeps at most {COMPOSITE_FONTS_MAX} composite fonts"))); + } + let db = FontDb::global(); + let mut names = std::collections::HashSet::new(); + for f in fonts { + if !names.insert(f.name.to_lowercase()) { + return Err(bad(c, format!("two composite fonts are named `{}`", f.name))); + } + if db.has_installed_family(&f.name) { + return Err(bad(c, format!("`{}` is the name of an installed font", f.name))); + } + } + for f in fonts { + for class in CharClass::ALL { + let font = &f.parts.get(class).font; + if names.contains(&font.to_lowercase()) || db.composite_font(font).is_some() { + return Err(bad(c, format!("`{}`: {} is set in `{font}`, a composite font", f.name, class.key()))); + } + } + } + Ok(()) +} + +/// Make `after` the active document's composite fonts, as one undo step named `label` (none when +/// they are what it has). The font database forgets the ones taken out, and type set in a font +/// that changed is measured again. +fn replace(s: &mut Session, label: &str, after: Vec) -> Result<()> { + let before = s.doc()?.doc.composite_fonts.to_vec(); + if before == after { + return Ok(()); + } + let changed: Vec = + before.iter().filter(|b| !after.contains(b)).chain(after.iter().filter(|a| !before.contains(a))).map(|f| f.name.to_lowercase()).collect(); + let db = FontDb::global(); + for f in &before { + if !after.iter().any(|a| CompositeFont::same_name(&a.name, &f.name)) { + db.remove_composite_font(&f.name); + } + } + for f in &after { + db.set_composite_font(f); + } + let r = s.edit(label, |d, _| { + *d.composite_fonts = after.clone(); + each_text_mut(d, &mut |t| { + if t.runs.iter().any(|r| changed.contains(&r.style.font_family.to_lowercase())) { + refresh_bounds(t); + } + Ok(()) + }) + }); + sync(s); + r +} + +fn set(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.compositeFonts.set"; + let list = p.get("fonts").and_then(Value::as_array).ok_or_else(|| bad(C, "give fonts: [{name, parts}…]"))?; + let fonts = list.iter().map(|v| parse_font(v, C)).collect::>>()?; + check_set(&fonts, C)?; + replace(s, "Composite Fonts", fonts.clone())?; + Ok(json!({ "fonts": fonts })) +} + +fn export(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.compositeFonts.export"; + let path = text_param(p, "path", C)?; + if path.is_some_and(|p| p.trim().is_empty()) { + return Err(bad(C, "path is empty")); + } + let all = match p.get("fonts") { + None | Some(Value::Null) => s.doc()?.doc.composite_fonts.to_vec(), + Some(Value::Array(list)) => { + let fonts = list.iter().map(|v| parse_font(v, C)).collect::>>()?; + check_set(&fonts, C)?; + fonts + } + Some(_) => return Err(bad(C, "fonts must be a list of composite fonts")), + }; + let fonts = match p.get("names") { + None | Some(Value::Null) => all, + Some(Value::Array(names)) => { + let mut out = Vec::with_capacity(names.len()); + for n in names { + let n = n.as_str().ok_or_else(|| bad(C, "names must be strings"))?; + let f = all + .iter() + .find(|f| CompositeFont::same_name(&f.name, n.trim())) + .ok_or_else(|| bad(C, format!("no composite font named `{n}`")))?; + if !out.contains(f) { + out.push(f.clone()); + } + } + out + } + Some(_) => return Err(bad(C, "names must be a list of names")), + }; + if fonts.is_empty() { + return Err(bad(C, "no composite fonts to export")); + } + let mut file = Map::new(); + file.insert("compositeFonts".into(), json!(fonts)); + let text = serde_json::to_string_pretty(&file).map_err(|e| bad(C, e.to_string()))?; + match path { + Some(path) => { + write_file(path, text.as_bytes())?; + Ok(json!({ "path": path, "count": fonts.len() })) + } + None => Ok(json!({ "data": text, "count": fonts.len() })), + } +} + +/// The composite fonts in the bytes of an exported file. +fn read_fonts(bytes: &[u8], c: &str) -> Result> { + let v: Value = serde_json::from_slice(bytes).map_err(|e| bad(c, format!("not a composite fonts file: {e}")))?; + let list = v.get("compositeFonts").and_then(Value::as_array).ok_or_else(|| bad(c, "not a composite fonts file: no compositeFonts list"))?; + if list.len() > COMPOSITE_FONTS_MAX { + return Err(bad(c, format!("the file holds more than {COMPOSITE_FONTS_MAX} composite fonts"))); + } + list.iter().map(|f| parse_font(f, c)).collect() +} + +fn import(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.compositeFonts.import"; + let apply = switch_or(p, "apply", true, C)?; + let given = (text_param(p, "path", C)?, text_param(p, "data", C)?, text_param(p, "dataBase64", C)?); + let too_large = || bad(C, "the file is larger than 4 MB"); + let bytes = match given { + (Some(path), None, None) if !path.trim().is_empty() => read_file_head(path, MAX_FILE_BYTES + 1)?, + (None, Some(text), None) => text.as_bytes().to_vec(), + (None, None, Some(b64)) => { + if b64.len() > MAX_FILE_BYTES / 3 * 4 + 4 { + return Err(too_large()); + } + vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "dataBase64 is not base64"))? + } + _ => return Err(bad(C, "give one of path, data or dataBase64")), + }; + if bytes.len() > MAX_FILE_BYTES { + return Err(too_large()); + } + let fonts = read_fonts(&bytes, C)?; + if fonts.is_empty() { + return Err(bad(C, "the file holds no composite fonts")); + } + let mut names = std::collections::HashSet::new(); + if let Some(f) = fonts.iter().find(|f| !names.insert(f.name.to_lowercase())) { + return Err(bad(C, format!("the file holds two composite fonts named `{}`", f.name))); + } + if apply { + let mut after = s.doc()?.doc.composite_fonts.to_vec(); + for f in &fonts { + match after.iter_mut().find(|a| CompositeFont::same_name(&a.name, &f.name)) { + Some(a) => *a = f.clone(), + None => after.push(f.clone()), + } + } + check_set(&after, C)?; + replace(s, "Import Composite Fonts", after)?; + } else { + check_set(&fonts, C)?; + } + let imported: Vec<&str> = fonts.iter().map(|f| f.name.as_str()).collect(); + Ok(json!({ "fonts": fonts, "imported": imported })) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs new file mode 100644 index 000000000..670a85ee2 --- /dev/null +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -0,0 +1,502 @@ +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; + +use crate::Session; + +/// A composite font named `name`: Source Sans 3 for the base, JetBrains Mono at half size for +/// Roman, Inter for numerals. +fn font(name: &str) -> Value { + json!({"name": name, "parts": { + "kanji": {"font": "Source Sans 3"}, + "roman": {"font": "JetBrains Mono", "size": 50, "baseline": 10}, + "numerals": {"font": "Inter", "hScale": 80}, + }}) +} + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn names(s: &Session) -> Vec { + s.doc().unwrap().doc.composite_fonts.iter().map(|f| f.name.clone()).collect() +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn text(s: &Session, id: u64) -> vectorcraft_doc::TextObject { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +/// A scratch folder of the test's own. +fn folder(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vectorcraft-composite-fonts-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Composite Fonts' OK replaces the document's composite fonts as one undo step (none when nothing +/// changes); parts left out copy the Kanji part; they are saved with the document and undone with +/// it. +#[test] +fn composite_fonts_are_set_as_one_undo_step_and_saved() { + let mut s = session(); + let before = steps(&s); + let r = s.execute("type.compositeFonts.set", &json!({"fonts": [font(" CF Saved A "), font("CF Saved B")]})).unwrap(); + assert_eq!(r["fonts"][0]["name"], "CF Saved A"); + assert_eq!(names(&s), ["CF Saved A", "CF Saved B"]); + assert_eq!(steps(&s), before + 1); + let f = &s.doc().unwrap().doc.composite_fonts[0]; + assert_eq!((f.parts.kana.font.as_str(), f.parts.roman.size, f.parts.numerals.h_scale), ("Source Sans 3", 50.0, 80.0)); + // The same fonts again change nothing. + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Saved A"), font("CF Saved B")]})).unwrap(); + assert_eq!(steps(&s), before + 1); + let listed = s.execute("type.compositeFonts.list", &json!({})).unwrap(); + assert_eq!(listed["fonts"].as_array().map(Vec::len), Some(2)); + // They travel with the file. + let back = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap(); + assert_eq!(*back.composite_fonts, *s.doc().unwrap().doc.composite_fonts); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(names(&s).is_empty()); +} + +/// Bad fonts are refused with an error and no undo step: a name that is empty, shared (in any +/// case) or an installed font's; a part set in a composite font; an unknown class or key; a value +/// out of range, of the wrong type, or on a class that can't take it; no Kanji part. +#[test] +fn bad_composite_fonts_are_refused() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Refused Base")]})).unwrap(); + let before = steps(&s); + let with = |f: &dyn Fn(&mut Value)| { + let mut v = font("CF Refused"); + f(&mut v); + json!({"fonts": [v]}) + }; + for bad in [ + json!({}), + json!({"fonts": "CF"}), + json!({"fonts": [font(" ")]}), + json!({"fonts": [font("CF Twice"), font("cf twice")]}), + json!({"fonts": [font("Source Sans 3")]}), + json!({"fonts": [font("Tab\tName")]}), + with(&|v| v["parts"]["roman"]["font"] = json!("CF Refused Base")), + with(&|v| v["parts"]["roman"]["font"] = json!("CF Refused")), + with(&|v| v["parts"]["hangul"] = json!({"font": "Inter"})), + with(&|v| v["parts"]["Roman"] = json!({"font": "Inter"})), + with(&|v| v["parts"]["roman"]["width"] = json!(3)), + with(&|v| v["parts"]["roman"]["size"] = json!(5)), + with(&|v| v["parts"]["roman"]["size"] = json!("50")), + with(&|v| v["parts"]["roman"]["baseline"] = json!(-120)), + with(&|v| v["parts"]["kanji"]["hScale"] = json!(90)), + with(&|v| v["parts"]["punctuation"] = json!({"font": "Inter", "vScale": 90})), + with(&|v| v["parts"]["roman"]["scaleAtCenter"] = json!(true)), + with(&|v| v["parts"]["kana"] = json!({"font": "Inter", "scaleAtCenter": "yes"})), + with(&|v| v["parts"]["roman"]["font"] = json!("")), + with(&|v| v["extra"] = json!(1)), + with(&|v| { + v["parts"].as_object_mut().unwrap().remove("kanji"); + }), + ] { + assert!(s.execute("type.compositeFonts.set", &bad).is_err(), "accepted {bad}"); + assert_eq!(names(&s), ["CF Refused Base"]); + assert_eq!(steps(&s), before); + } + // Kana scaled at the center is allowed. + let ok = with(&|v| v["parts"]["kana"] = json!({"font": "Inter", "vScale": 80, "scaleAtCenter": true})); + s.execute("type.compositeFonts.set", &ok).unwrap(); + assert_eq!(names(&s), ["CF Refused"]); +} + +/// Type set in a composite font names it and is not missing; each class is drawn in its part's +/// font; changing the font measures the type again, and taking it out leaves the type naming it, +/// shown as missing. +#[test] +fn type_set_in_a_composite_font_follows_it() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Type")]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.setStyle", &json!({"ids": [id], "font": "CF Type"})).unwrap(); + assert!(r.get("warning").is_none(), "{r}"); + let t = text(&s, id); + assert_eq!(t.runs[0].style.font_family, "CF Type"); + let db = vectorcraft_text::FontDb::global(); + let face = |family: &str| db.face(family, "Regular").unwrap().id(); + let lay = vectorcraft_text::layout(db, &t); + let fonts: Vec = lay.glyphs.iter().map(|g| g.font_id).collect(); + assert_eq!(fonts, [face("JetBrains Mono"), face("JetBrains Mono"), face("Inter"), face("Inter")]); + let missing = |s: &mut Session| s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().filter(|f| f["missing"] == true).count(); + assert_eq!(missing(&mut s), 0); + // Roman at full size makes the type wider, measured again in the same step. + let wide = t.cached_bounds.unwrap().width(); + let mut full = font("CF Type"); + full["parts"]["roman"]["size"] = json!(100); + let before = steps(&s); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + assert_eq!(steps(&s), before + 1); + assert!(text(&s, id).cached_bounds.unwrap().width() > wide + 1.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert!((text(&s, id).cached_bounds.unwrap().width() - wide).abs() < 1e-6); + assert_eq!(db.composite_font("CF Type").unwrap().parts.roman.size, 50.0); + // Taken out, the type keeps the name, now missing; undone, the font is back. + s.execute("type.compositeFonts.set", &json!({"fonts": []})).unwrap(); + assert!(db.composite_font("CF Type").is_none()); + assert_eq!(text(&s, id).runs[0].style.font_family, "CF Type"); + assert_eq!(missing(&mut s), 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(db.composite_font("CF Type").is_some()); + assert_eq!(missing(&mut s), 0); +} + +/// Type in a symbol's art is measured again too when the composite font it names changes. +#[test] +fn type_in_symbol_art_follows_its_composite_font() { + fn width(s: &Session) -> f64 { + fn find(n: &vectorcraft_doc::Node) -> Option { + if let NodeKind::Text(t) = &n.kind { + return t.cached_bounds.map(|b| b.width()); + } + n.children().into_iter().flatten().find_map(|c| find(c)) + } + s.doc().unwrap().doc.symbols.iter().find_map(|sym| find(&sym.art)).unwrap() + } + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Symbol")]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Symbol"})).unwrap()["id"].as_u64().unwrap(); + s.execute("symbol.new", &json!({"name": "CF Mark", "ids": [id]})).unwrap(); + let narrow = width(&s); + let mut full = font("CF Symbol"); + full["parts"]["roman"]["size"] = json!(100); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + assert!(width(&s) > narrow + 1.0); +} + +/// The font database forgets a composite font once no open document defines it: after the step +/// that added it is undone, and after its document closes. +#[test] +fn composite_fonts_no_document_defines_are_forgotten() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Forgotten Undo")]})).unwrap(); + assert!(db.composite_font("CF Forgotten Undo").is_some()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(db.composite_font("CF Forgotten Undo").is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(db.composite_font("CF Forgotten Undo").is_some()); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Forgotten Close")]})).unwrap(); + s.execute("file.close", &json!({})).unwrap(); + assert!(db.composite_font("CF Forgotten Close").is_none()); + assert!(db.composite_font("CF Forgotten Undo").is_some()); +} + +/// SVG and legacy `.ai` write type in a composite font as its outlines, since a reader can't find +/// the font by its name. +#[test] +fn exports_outline_type_in_a_composite_font() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Export")]})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab1", "size": 20, "font": "CF Export"})).unwrap(); + let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + assert!(!svg.contains("CF Export") && !svg.contains(">(); + let svg = vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions::default()); + assert!(!svg.contains("CF Standalone") && !svg.contains(" = missing["fonts"].as_array().unwrap().iter().filter_map(|f| f["family"].as_str()).collect(); + assert_eq!(names, ["No Such Part Font"], "{missing}"); + let fonts = s.execute("text.fonts", &json!({})).unwrap(); + let row = fonts.as_array().unwrap().iter().find(|r| r["family"] == "CF Inventory").cloned().unwrap(); + assert_eq!((&row["missing"], &row["status"]), (&json!(true), &json!("missing")), "{row}"); + assert_eq!(row["resolved"]["family"], vectorcraft_text::FALLBACK_FAMILY, "{row}"); + // A run that starts with a combining mark after a run ending in kana: the mark is Roman. + let boundary = json!({"name": "CF Boundary", "parts": {"kanji": {"font": "Source Sans 3"}, "roman": {"font": "No Such Part Font"}}}); + s.execute("type.compositeFonts.set", &json!({"fonts": [boundary]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 170, "text": "\u{304B}\u{301}", "size": 20, "font": "CF Boundary"})).unwrap()["id"] + .as_u64() + .unwrap(); + let row = + |s: &mut Session| s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().find(|r| r["family"] == "CF Boundary").cloned(); + assert_eq!(row(&mut s).unwrap()["status"], "exact"); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 5, "size": 30})).unwrap(); + assert_eq!(text(&s, id).runs.len(), 2); + assert_eq!(row(&mut s).unwrap()["status"], "missing"); + // A symbol the Kanji part's font draws but the Symbols part's font lacks. + let db = vectorcraft_text::FontDb::global(); + let (mono, sans) = (db.face("JetBrains Mono", "Regular").unwrap(), db.face("Source Sans 3", "Regular").unwrap()); + let c = ('\u{2190}'..='\u{2BFF}').find(|c| mono.covers(*c) && !sans.covers(*c)).expect("a symbol only JetBrains Mono draws"); + let glyphs = json!({"name": "CF Glyphs", "parts": {"kanji": {"font": "JetBrains Mono"}, "symbols": {"font": "Source Sans 3"}}}); + s.execute("type.compositeFonts.set", &json!({"fonts": [glyphs]})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 130, "text": c.to_string(), "size": 20, "font": "CF Glyphs"})).unwrap(); + let fonts = s.execute("text.fonts", &json!({})).unwrap(); + let row = fonts.as_array().unwrap().iter().find(|r| r["family"] == "CF Glyphs").cloned().unwrap(); + assert_eq!(row["missingGlyphs"], 1, "{row}"); +} + +/// The Glyphs panel's alternates of a character in a composite font come from the font of the +/// part its grapheme cluster falls in. +#[test] +fn glyph_alternates_of_composite_type_come_from_the_characters_part() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Glyph Part")]})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Glyph Part"})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 2})).unwrap(); + assert_eq!(r["font"], "Inter", "{r}"); + let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 0})).unwrap(); + assert_eq!(r["font"], "JetBrains Mono", "{r}"); + // A combining mark is drawn in the part of the character it follows. + let id = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "1\u{301}", "size": 20, "font": "CF Glyph Part"})).unwrap()["id"] + .as_u64() + .unwrap(); + let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 1})).unwrap(); + assert_eq!(r["font"], "Inter", "{r}"); +} + +/// With two documents defining one name differently, the type is drawn in the front document's. +#[test] +fn the_front_document_defines_a_shared_name() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Front")]})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let mut other = font("CF Front"); + other["parts"]["roman"]["size"] = json!(75); + s.execute("type.compositeFonts.set", &json!({"fonts": [other]})).unwrap(); + let db = vectorcraft_text::FontDb::global(); + assert_eq!(db.composite_font("CF Front").unwrap().parts.roman.size, 75.0); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(db.composite_font("CF Front").unwrap().parts.roman.size, 50.0); +} + +/// A composite font only a document in the back defines is not available in the front one, where +/// type naming it shows as missing; a click on the back document's tab brings it back. +#[test] +fn a_font_only_a_back_document_defines_is_missing_in_front() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Back Only")]})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + assert!(db.composite_font("CF Back Only").is_none()); + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "CF Back Only"})).unwrap(); + let fonts = s.execute("text.fonts", &json!({})).unwrap(); + assert!(fonts.as_array().unwrap().iter().any(|f| f["missing"] == true), "{fonts}"); + assert!(s.set_active(0)); + assert!(db.composite_font("CF Back Only").is_some()); +} + +/// A newly opened document measures a missing font without using the previous tab's composite +/// font of the same name. +#[test] +fn opened_document_measures_missing_composite_font_as_missing() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Previous Tab")]})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "CF Previous Tab"})).unwrap()["id"].as_u64().unwrap(); + let mut doc = (*s.doc().unwrap().doc).clone(); + doc.composite_fonts.clear(); + if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind { + t.cached_bounds = None; + } + s.add_document(doc, None); + assert!(s.set_active(0)); + assert!(s.set_active(1)); + let actual = text(&s, id); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A loaded composite font cannot replace an installed family used by existing type. +#[test] +fn loaded_composite_font_cannot_take_an_installed_family_name() { + let mut s = session(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + let mut doc = (*s.doc().unwrap().doc).clone(); + doc.composite_fonts.push(vectorcraft_doc::CompositeFont::new("Inter", "Source Sans 3", "Regular")); + if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind { + t.cached_bounds = None; + } + s.add_document(doc, None); + assert!(names(&s).is_empty()); + assert!(vectorcraft_text::FontDb::global().composite_font("Inter").is_none()); + let actual = text(&s, id); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A file's composite fonts that Composite Fonts… would refuse (a value out of range, a name used +/// twice) are left out when the file opens. +#[test] +fn unusable_composite_fonts_in_a_file_are_left_out() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + let mut doc = (*s.doc().unwrap().doc).clone(); + let mut wild = vectorcraft_doc::CompositeFont::new("CF Wild", "Source Sans 3", "Regular"); + wild.parts.roman.size = 5000.0; + let good = vectorcraft_doc::CompositeFont::new("CF Kept", "Source Sans 3", "Regular"); + let twice = vectorcraft_doc::CompositeFont::new("cf kept", "Inter", "Regular"); + *doc.composite_fonts = vec![wild, good, twice]; + s.add_document(doc, None); + assert_eq!(names(&s), ["CF Kept"]); + s.execute("document.activate", &json!({"index": 1})).unwrap(); + assert!(db.composite_font("CF Wild").is_none()); + assert_eq!(db.composite_font("CF Kept").unwrap().parts.roman.font, "Source Sans 3"); +} + +/// Export writes the document's composite fonts (all, or those named) to a file; Import reads it: +/// with apply false it only returns them, otherwise they join the document's, replacing a font of +/// the same name, as one undo step. +#[test] +fn composite_fonts_export_and_import() { + let dir = folder("io"); + let path = dir.join("Mixed fonts (日本).vccomposite"); + let path = path.to_str().unwrap(); + let mut s = session(); + assert!(s.execute("type.compositeFonts.export", &json!({"path": path})).is_err(), "nothing to export"); + let mut slash = font("CF IO a/b: \"quoted\" 日本"); + slash["parts"]["kana"] = json!({"font": "Inter", "vScale": 80, "scaleAtCenter": true}); + s.execute("type.compositeFonts.set", &json!({"fonts": [slash, font("CF IO Other")]})).unwrap(); + assert!(s.execute("type.compositeFonts.export", &json!({"path": path, "names": ["CF IO Nope"]})).is_err()); + let r = s + .execute("type.compositeFonts.export", &json!({"path": path, "names": ["cf io a/b: \"quoted\" 日本", "CF IO a/b: \"quoted\" 日本"]})) + .unwrap(); + assert_eq!(r["count"], 1); + let saved = s.doc().unwrap().doc.composite_fonts[0].clone(); + + let mut t = session(); + let mut mine = font("CF IO a/b: \"quoted\" 日本"); + mine["parts"]["roman"]["size"] = json!(90); + t.execute("type.compositeFonts.set", &json!({"fonts": [mine, font("CF IO Kept")]})).unwrap(); + let before = steps(&t); + let read = t.execute("type.compositeFonts.import", &json!({"path": path, "apply": false})).unwrap(); + assert_eq!(read["fonts"].as_array().map(Vec::len), Some(1)); + assert_eq!(steps(&t), before); + t.execute("type.compositeFonts.import", &json!({"path": path})).unwrap(); + assert_eq!(steps(&t), before + 1); + assert_eq!(names(&t), ["CF IO a/b: \"quoted\" 日本", "CF IO Kept"]); + assert_eq!(t.doc().unwrap().doc.composite_fonts[0], saved); + // The same file again changes nothing. + t.execute("type.compositeFonts.import", &json!({"path": path})).unwrap(); + assert_eq!(steps(&t), before + 1); + + // Files that aren't composite fonts, or are too large, are refused. + for (file, bytes) in [ + ("bad.vccomposite", b"not json".to_vec()), + ("empty.vccomposite", br#"{"compositeFonts": []}"#.to_vec()), + ("twice.vccomposite", format!(r#"{{"compositeFonts": [{}, {}]}}"#, font("CF Dup"), font("cf dup")).into_bytes()), + ("large.vccomposite", vec![b' '; (4 << 20) + 1]), + ] { + let p = dir.join(file); + std::fs::write(&p, bytes).unwrap(); + assert!(t.execute("type.compositeFonts.import", &json!({"path": p.to_str().unwrap()})).is_err(), "{file}"); + assert_eq!(steps(&t), before + 1); + } + assert!(t.execute("type.compositeFonts.import", &json!({"path": path, "apply": "no"})).is_err()); + assert!(t.execute("type.compositeFonts.import", &json!({"path": ""})).is_err()); + assert!(t.execute("type.compositeFonts.import", &json!({"path": path, "data": "{}"})).is_err()); + assert!(t.execute("type.compositeFonts.import", &json!({"dataBase64": "%%%"})).is_err()); + + // Without a path, Export gives the file's text, which Import takes as data. + let data = s.execute("type.compositeFonts.export", &json!({"names": ["CF IO Other"]})).unwrap(); + assert_eq!(data["count"], 1); + let mut u = session(); + let r = u.execute("type.compositeFonts.import", &json!({"data": data["data"]})).unwrap(); + assert_eq!(r["imported"], json!(["CF IO Other"])); + assert_eq!(names(&u), ["CF IO Other"]); + // Fonts given are written in place of the document's; bad ones are refused. + let given = s.execute("type.compositeFonts.export", &json!({"fonts": [font("CF IO Given")]})).unwrap(); + assert!(given["data"].as_str().unwrap().contains("CF IO Given")); + assert!(s.execute("type.compositeFonts.export", &json!({"fonts": [font("")]})).is_err()); + assert!(s.execute("type.compositeFonts.export", &json!({"fonts": []})).is_err()); + assert!(s.execute("type.compositeFonts.export", &json!({"path": " "})).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 6860df149..407b20a1a 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -743,7 +743,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result { let mut made = super::typecmd::with_overflow(s, id.as_slice(), made); if p.get("font").is_some() || p.get("style").is_some() { let runs = match id.and_then(|i| s.doc().ok()?.doc.node(i)).map(|n| &n.kind) { - Some(NodeKind::Text(t)) => t.runs.iter().map(|r| super::fonts::font_of(&r.style)).collect(), + Some(NodeKind::Text(t)) => t.runs.iter().flat_map(super::fonts::run_fonts).collect(), _ => Default::default(), }; if let Some(w) = super::fonts::set_font_warning(runs) { diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 7fe82e102..697f3c1d7 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -564,7 +564,8 @@ impl ReadFile { /// The first half of [`open_bytes_with`], which needs no session (an app may do it on another /// thread): read and decode the file, read its linked files and lay out its type → what -/// [`open_read`] opens. The linked files are looked for from `path`, where the file was read from, +/// [`open_read`] opens. Type in the file's composite fonts is measured again once the document +/// opens and its composite fonts are registered. The linked files are looked for from `path`, where the file was read from, /// and modified ones are read again when `update_links` (Preferences › File Handling › Update /// Links set to Automatically; see [`crate::cmd::links::resolve`]). pub fn read_for_open(name: &str, bytes: &[u8], path: Option, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result { diff --git a/crates/engine/src/cmd/fontfiles.rs b/crates/engine/src/cmd/fontfiles.rs index cadbd6509..309347985 100644 --- a/crates/engine/src/cmd/fontfiles.rs +++ b/crates/engine/src/cmd/fontfiles.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Missing Fonts", [], None, - "{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers and its symbols, that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)", + "{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers and its symbols (for type in a composite font, the fonts of the parts its characters are drawn in), that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)", has_doc, missing ), @@ -103,8 +103,8 @@ pub struct MissingFont { pub resolved: Option<(String, String)>, } -/// The fonts the type of `d` uses, in its layers and its symbols, that aren't available as named -/// (sorted by family and style, each once whatever version its type names; at most 1000). CSS generic families such as `sans-serif` +/// The fonts the type of `d` uses, in its layers and its symbols (a composite font's parts, those +/// its characters are drawn in, in place of its name), that aren't available as named (sorted by family and style, each once whatever version its type names; at most 1000). CSS generic families such as `sans-serif` /// ([`GENERIC_FAMILIES`]) and names longer than 256 bytes are left out. pub fn missing_fonts(d: &Document) -> Vec { let db = FontDb::global(); diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index ba91f87fa..2a6d1cb0d 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fonts in Document", [], None, - "{selectionOnly?} (the type in the art of the symbols in scope included: all of the document's, or those the selected instances show) → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)", + "{selectionOnly?} (the type in the art of the symbols in scope included: all of the document's, or those the selected instances show) → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font). For a composite font, status is the worst among the parts its characters are drawn in, and missingGlyphs counts the characters their part's font lacks", has_doc, fonts ), @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Replace Font", [], None, - "{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); the type in the art of the symbols in scope changes too (all of the document's, or those the selected instances show, so every instance of them follows); text that is hidden or locked, in a hidden or locked group or layer (or group of a symbol's art), or on a locked artboard keeps its font; when no run changes (text already set in the new font, or none to change), the document and its undo history stay as they are → {runs: the runs changed, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}", + "{from: {family, style?}, to: {family (any of its names; stored as the font's own family name, a composite font's as its own name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); the type in the art of the symbols in scope changes too (all of the document's, or those the selected instances show, so every instance of them follows); text that is hidden or locked, in a hidden or locked group or layer (or group of a symbol's art), or on a locked artboard keeps its font; when no run changes (text already set in the new font, or none to change), the document and its undo history stay as they are → {runs: the runs changed, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}", has_doc, replace ), @@ -208,7 +208,18 @@ pub(super) fn font_label((family, style, version): &UsedFont) -> String { } } -/// The fonts (family, style, version) of the type in the layers and symbols of `d`. +/// The fonts (family, style, version) run `r` is drawn in: its own, or, set in a composite font, +/// the fonts of the parts its characters fall in ([`vectorcraft_text::FontDb::composite_fonts_for`]). +pub(super) fn run_fonts(r: &TextRun) -> Vec { + let parts = if r.inline.is_some() { None } else { vectorcraft_text::FontDb::global().composite_fonts_for(&r.style.font_family, &r.text) }; + match parts { + Some(parts) => parts.into_iter().map(|(family, style)| (family, style, None)).collect(), + None => vec![font_of(&r.style)], + } +} + +/// The fonts (family, style, version) the type in the layers and symbols of `d` is drawn in, a +/// composite font's parts in place of its name ([`run_fonts`]). pub(super) fn used_fonts(d: &Document) -> BTreeSet { art_fonts(d, &[]) } @@ -219,7 +230,7 @@ pub(super) fn art_fonts(d: &Document, art: &[Node]) -> BTreeSet { let mut fonts = BTreeSet::new(); let mut add = |n: &Node| { if let NodeKind::Text(t) = &n.kind { - fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone()))); + fonts.extend(t.runs.iter().flat_map(run_fonts)); } }; d.walk(&mut add); @@ -344,7 +355,7 @@ impl Printed<'_> { self.overflow.insert(n.id); } for r in runs { - self.fonts.insert((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())); + self.fonts.extend(run_fonts(r)); // Inline graphics draw their symbols' art. if let Some(art) = &r.inline { self.symbol(&art.symbol); @@ -546,14 +557,15 @@ fn font_inventory(s: &Session, selected: Option<&[NodeId]>) -> Result { let symbols = if selected.is_some() { Some(selected_symbols(s, selected)?.0) } else { None }; let mut texts: Vec<&TextObject> = ids.iter().filter_map(|id| text(d, *id)).collect(); texts.extend(symbol_texts(d, symbols.as_ref()).into_iter().map(|(t, _)| t)); - let mut found: BTreeMap = BTreeMap::new(); + // Each font's runs, objects and the text of its runs. + let mut found: BTreeMap)> = BTreeMap::new(); for t in texts { let mut seen = BTreeSet::new(); for r in &t.runs { let font = (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone()); let e = found.entry(font.clone()).or_default(); e.0 += 1; - e.2.push_str(&r.text); + e.2.push(&r.text); if seen.insert(font) { e.1 += 1; } @@ -561,14 +573,27 @@ fn font_inventory(s: &Session, selected: Option<&[NodeId]>) -> Result { } let list: Vec = found .into_iter() - .map(|((family, style, version), (runs, objects, chars))| { + .map(|((family, style, version), (runs, objects, texts))| { // The face the type is set in: the version it names when that one is installed. - let resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m)); - let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing); - let mut lacking: Vec = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect(); - lacking.sort_unstable(); - lacking.dedup(); - lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c))); + let mut resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m)); + let (status, lacking) = match db.composite_coverage(&family, &texts) { + // A composite font: each character is drawn by the font of the part it falls in, and + // the row names the face of the first part that isn't there as named. + Some(c) => { + if c.status != vectorcraft_text::FontMatch::Exact { + resolved = c.face.map(|f| (f, c.status)); + } + (c.status, c.lacking) + } + None => { + let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing); + let mut lacking: Vec = texts.concat().chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect(); + lacking.sort_unstable(); + lacking.dedup(); + lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c))); + (status, lacking) + } + }; let mut row = json!({ "family": family, "style": style, "runs": runs, "objects": objects, "missing": status == vectorcraft_text::FontMatch::Missing, @@ -612,10 +637,11 @@ fn replace(s: &mut Session, p: &Value) -> Result { let (ff, fs) = font_param(p, "from", C)?; let (tf, ts) = font_param(p, "to", C)?; let db = vectorcraft_text::FontDb::global(); - // Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans). - let tf = match db.resolve(&tf, ts.as_deref().unwrap_or("Regular")) { - Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => f.family.clone(), - _ => return Err(bad(C, format!("font family `{tf}` is not available"))), + // Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans), and a + // composite font as its own name. + let composite = db.composite_font(&tf).is_some(); + let Some(tf) = db.stored_family(&tf, ts.as_deref().unwrap_or("Regular")) else { + return Err(bad(C, format!("font family `{tf}` is not available"))); }; let matches = |st: &vectorcraft_doc::CharStyle| { st.font_family.eq_ignore_ascii_case(&ff) && fs.as_ref().is_none_or(|x| st.font_style.eq_ignore_ascii_case(x)) @@ -626,8 +652,10 @@ fn replace(s: &mut Session, p: &Value) -> Result { if !matches(st) { return None; } - // An explicit style, else the closest the new family has to the old one. - let style = ts.clone().or_else(|| db.face(&tf, &st.font_style).map(|f| f.style.clone())).unwrap_or_else(|| st.font_style.clone()); + // An explicit style, else the closest the new family has to the old one (a composite font's + // parts name their own styles: the run keeps its style). + let closest = || if composite { None } else { db.face(&tf, &st.font_style).map(|f| f.style.clone()) }; + let style = ts.clone().or_else(closest).unwrap_or_else(|| st.font_style.clone()); (st.font_family != tf || st.font_style != style || st.font_version.is_some() || !st.font_axes.is_empty()).then_some(style) }; // Only text the Selection tool can reach changes: not hidden or locked, in a hidden or locked @@ -674,7 +702,7 @@ fn replace(s: &mut Session, p: &Value) -> Result { // Another font: none of the old one's versions or Variable Font settings. r.style.font_version = None; r.style.font_axes.clear(); - set.insert(font_of(&r.style)); + set.extend(run_fonts(r)); changed = true; n += 1; } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index d8514ef56..4274ce6d5 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1068,7 +1068,7 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result { if sets_font { // Empty text has one run, which the empty range styles. Characters that kept their font aren't the command's. let styled = if a == b { t.runs.clone() } else { unprotected_runs(&t.runs, a, b, &kept) }; - set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style))); + set.extend(styled.iter().flat_map(super::fonts::run_fonts)); } refresh_bounds(t); n = if *f == id { t.runs.len() } else { n }; @@ -1140,7 +1140,12 @@ fn glyph_target(t: &TextObject, p: &Value, c: &str) -> Result<(char, std::ops::R return Err(bad(c, "an inline graphic has no glyphs")); } let db = vectorcraft_text::FontDb::global(); - let face = db.style_face(&run.style).filter(|_| db.has_family(&run.style.font_family)); + let face = match db.composite_resolve_at(&run.style.font_family, &run.text, start - at) { + // Type in a composite font: the character is drawn in the font of the part its + // grapheme cluster falls in. + Some((face, m)) => (m != vectorcraft_text::FontMatch::Missing).then_some(face), + None => db.style_face(&run.style).filter(|_| db.has_family(&run.style.font_family)), + }; let face = face.ok_or_else(|| bad(c, format!("the font {} is not available", run.style.font_family)))?; return Ok((ch, start..start + ch.len_utf8(), face)); } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 17b2f0f0c..68394e7fd 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -327,7 +327,7 @@ fn font_change(before: &CharStyle, mut st: CharStyle, attrs: &Map /// Change text in layers, symbols, graph designs, pattern tiles, saved pattern tiles restored by /// Cancel, and brush art, including opacity masks in those trees. -fn each_text_mut(d: &mut Document, f: &mut impl FnMut(&mut vectorcraft_doc::TextObject) -> Result<()>) -> Result<()> { +pub(crate) fn each_text_mut(d: &mut Document, f: &mut impl FnMut(&mut vectorcraft_doc::TextObject) -> Result<()>) -> Result<()> { fn has_text(n: &Node) -> bool { matches!(n.kind, NodeKind::Text(_)) || n.extras().mask.as_ref().is_some_and(|mask| has_text(&mask.art)) diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index b349ef894..c5663b58a 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -919,7 +919,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result { super::textedit::unprotected_runs(&t.runs, a, b, &kept) } }; - set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style))); + set.extend(styled.iter().flat_map(super::fonts::run_fonts)); } if let Some(j) = justify { let span = para_span(range, t); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c9533158d..27972ef20 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -172,6 +172,9 @@ pub struct Session { /// has inherited the selection's appearance: the documents ([`DocState::uid`]) whose newest /// history step takes the colors the batch finally left. pub(crate) batch_paint_docs: Vec, + /// The composite fonts (lowercase names) that the open documents defined when the font + /// database was last brought in step with them ([`cmd::compositefonts::sync`]). + pub(crate) composite_names: Vec, /// While a command that changes nothing in the document runs (a query, or one of /// [`command_sets::JOURNALED_READS`]): it reads the instance of an object selected in a symbol /// instance's art. diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index d4859cdf1..b0619c992 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -83,6 +83,7 @@ impl Session { batch_stash: None, batch_steps: None, batch_paint_docs: Vec::new(), + composite_names: Vec::new(), reading: false, place_hand_off: None, queue_hand_off: None, @@ -176,10 +177,16 @@ impl Session { pub fn set_active(&mut self, index: usize) -> bool { if index < self.docs.len() { - if self.active != Some(index) { + let switched = self.active != Some(index); + if switched { self.reset_tool_for_doc_switch(); } self.active = Some(index); + // Type is drawn in the composite fonts of the document now in front, and measured again + // when they differ from those of the document before. + if switched && cmd::compositefonts::sync(self) { + self.refresh_document_text_bounds(index); + } true } else { false @@ -196,7 +203,7 @@ impl Session { format!("Untitled-{}", self.untitled_counter) } - /// Text layout bounds are a cache (not saved): compute them for a document just read, or + /// Text layout bounds are a cache (not saved): compute them after its fonts are registered, or /// selection boxes and hit testing would use the rough estimate until each text is edited, and /// what is fitted to type's bounds (an envelope warping it) would come out differently. That /// holds for the type in symbol definitions, pattern tiles, graph designs and opacity masks too, @@ -211,6 +218,14 @@ impl Session { } } + /// [`Session::refresh_text_bounds`] for open document `index`, as a change that is not an + /// edit: a saved document stays saved, and its undo history keeps its documents as they are. + fn refresh_document_text_bounds(&mut self, index: usize) { + if let Some(st) = self.docs.get_mut(index) { + st.change_unsaved(|doc| Self::refresh_text_bounds(Arc::make_mut(doc))); + } + } + /// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`, /// an undo group with the begun group's name opens in it: here outside a command, and after the /// command that adds it otherwise ([`Session::execute`]). @@ -218,7 +233,7 @@ impl Session { // Every edit is checked against the document before it, so the first one is within the cap // (importers and the native reader have seen to theirs already, with a warning). doc.limit_depth(); - Self::refresh_text_bounds(&mut doc); + cmd::compositefonts::filter_loaded(&mut doc); // Guides from files saved before guides had layers go on a layer. doc.adopt_guides(); cmd::dimension::conform(&mut doc); @@ -229,6 +244,8 @@ impl Session { self.docs.push(st); let i = self.docs.len() - 1; self.active = Some(i); + cmd::compositefonts::sync(self); + self.refresh_document_text_bounds(i); if self.depth == 0 && let Some(label) = self.explicit_undo_group.clone() { @@ -244,7 +261,7 @@ impl Session { return false; } doc.limit_depth(); - Self::refresh_text_bounds(&mut doc); + cmd::compositefonts::filter_loaded(&mut doc); doc.adopt_guides(); cmd::dimension::conform(&mut doc); if self.active == Some(index) { @@ -280,6 +297,12 @@ impl Session { if let Some(stash) = &mut self.batch_stash { stash.push(old); } + // Type in the composite fonts of a document in the back is measured again when it comes to + // the front. + if self.active == Some(index) { + cmd::compositefonts::sync(self); + } + self.refresh_document_text_bounds(index); true } @@ -296,6 +319,7 @@ impl Session { stash.push(old); } self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) }; + cmd::compositefonts::sync(self); true } diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 352a267b4..5ad84885f 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -157,6 +157,9 @@ impl Session { } if let Some(open) = open { self.open_undo_groups_in_new_documents(&open); + // Type is drawn in the composite fonts of the documents open now, an undone or opened + // document's included. + cmd::compositefonts::sync(self); } let journaled = spec.journal || (self.depth == 0 && std::mem::take(&mut self.journal_query)); if journaled && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) { diff --git a/crates/engine/src/tests/fontlist.rs b/crates/engine/src/tests/fontlist.rs index d0804b7d0..6202a8ce7 100644 --- a/crates/engine/src/tests/fontlist.rs +++ b/crates/engine/src/tests/fontlist.rs @@ -14,7 +14,11 @@ fn names(v: &Value) -> Vec { fn the_font_list_names_every_available_family_with_its_styles() { let mut s = Session::new(); let fams = names(&s.execute("text.fontList", &json!({})).unwrap()["families"]); - assert_eq!(fams, *FontDb::global().menu_family_list()); + // Other tests running at the same time add and remove composite fonts, which the lists show + // first; the installed families are compared. + let db = FontDb::global(); + let installed_only = |list: &[String]| list.iter().filter(|f| db.has_installed_family(f)).cloned().collect::>(); + assert_eq!(installed_only(&fams), installed_only(&db.menu_family_list())); for f in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] { assert!(fams.iter().any(|x| x == f), "{f}"); } diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index de2c9b384..c5c1c4c26 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -65,8 +65,8 @@ use std::sync::Arc; use vectorcraft_color::cms::Cms; use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{ - AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, Node, NodeKind, - ParaDirection, ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject, + AppearanceItem, AreaOptions, CharPosition, CharStyle, CompositeFont, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, Node, + NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject, }; use vectorcraft_effects::stroke; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, SubPath, Vec2}; @@ -180,6 +180,8 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result 0.0 && board.height() > 0.0) { return Err("the document has no artboard".into()); } + // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); // The document is y down; art space y up from the artboard's left edge, its top on a whole point. let (width, height) = (board.width(), board.height()); let top = if (height - height.round()).abs() < 1e-6 { height.round() } else { height.ceil() }; diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs index c2346cbb9..8ea787f8a 100644 --- a/crates/eps/src/legacy_ai/encoding.rs +++ b/crates/eps/src/legacy_ai/encoding.rs @@ -1,7 +1,7 @@ use super::writer::Writer; use super::{ - Affine, AreaOptions, BezPath, CharPosition, CharStyle, Gradient, GradientKind, Paint, ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, - TextObject, Write, latin, ps, stroke, + Affine, AreaOptions, BezPath, CharPosition, CharStyle, CompositeFont, Document, Gradient, GradientKind, Paint, ParaStyle, PathAlign, PathEffect, + StrokeLayer, TextKind, TextObject, Write, latin, ps, stroke, }; /// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd`, `[`, the stops @@ -78,10 +78,11 @@ pub(super) fn latin_string(name: &str) -> String { ps::bytes(&bytes) } -/// Can type `t` be written as text: 8-bit text can hold it ([`holds_as_text`]) and no character -/// is rotated ([`rotated`])? -pub(super) fn writable(t: &TextObject) -> bool { - holds_as_text(t) && !rotated(t) +/// Can type `t` of document `doc` be written as text: 8-bit text can hold it ([`holds_as_text`]), +/// no character is rotated ([`rotated`]) and no run is set in one of the document's composite +/// fonts, which a reader can't find by its name? +pub(super) fn writable(doc: &Document, t: &TextObject) -> bool { + holds_as_text(t) && !rotated(t) && !t.runs.iter().any(|r| CompositeFont::named(&doc.composite_fonts, &r.style.font_family).is_some()) } /// Has type `t` visible characters with Character Rotation? A legacy `.ai` text object's character diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index b34c127a7..c3ca85c08 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -136,9 +136,9 @@ fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document { nodes.extend(layout.glyphs.iter().filter_map(|g| g.placed_colour(t.xf)).map(Arc::new)); Node::group(vectorcraft_doc::NodeId(0), nodes) } - fn plain(nodes: &mut Vec>, o: &LegacyAiOptions, images: &mut std::collections::BTreeMap) { + fn plain(doc: &Document, nodes: &mut Vec>, o: &LegacyAiOptions, images: &mut std::collections::BTreeMap) { nodes.retain(|n| match &n.kind { - NodeKind::Text(t) => o.outline_text || !writable(t), + NodeKind::Text(t) => o.outline_text || !writable(doc, t), NodeKind::Layer { printable, .. } => *printable, _ => true, }); @@ -160,13 +160,13 @@ fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document { } } if let Some(c) = n.children_mut() { - plain(c, o, images); + plain(doc, c, o, images); } } } let mut d = doc.clone(); let mut images = std::mem::take(&mut d.images); - plain(&mut d.layers, o, &mut images); + plain(doc, &mut d.layers, o, &mut images); d.images = images; d } diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index ae8fddc70..cf601e939 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -673,7 +673,7 @@ impl<'a> Writer<'a> { let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t); let t = &*resolved; if !self.outline_text { - if writable(t) { + if writable(self.doc, t) { return self.text_object(t); } if !holds_as_text(t) { diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index bf515de66..e8fd85764 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -224,6 +224,8 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb if ![r.x0, r.y0, r.x1, r.y1, opts.origin.x, opts.origin.y].iter().all(|v| v.is_finite()) { return Err("the export region is not finite".into()); } + // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); let baked = vectorcraft_effects::bake_document(doc); let doc = baked.as_ref().unwrap_or(doc); let page = scene::Scene::new(doc, opts).run(); diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index 92607d5c5..c64d4a6f5 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -9,6 +9,8 @@ pub fn export(doc: &Document, opts: &PdfOptions) -> Result, PdfError> { /// Like [`export`], also returning warnings: options accepted but not applied yet (see /// [`crate::PdfSettings::warnings`]) and features approximated or dropped. pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { + // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); write::guard(|| leaving_out_undecodable(doc, |left_out| export_with_report_inner(doc, opts, left_out))) } diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index 2ca5b5978..5b0f60091 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -119,6 +119,25 @@ fn variable_font_settings_are_not_highlighted_as_substituted_glyphs() { assert!(g.alternate_glyphs(&t).elements().is_empty()); } +/// Type in a composite font draws each class of character in its part's face, which is not a +/// substitute: Document Setup's highlight of substituted glyphs leaves it alone. +#[test] +fn composite_font_parts_are_not_highlighted_as_substituted_glyphs() { + use vectorcraft_doc::{CompositeFont, CompositePart, CompositeParts}; + let _adding = adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); + parts.roman = CompositePart::new("JetBrains Mono", "Regular"); + parts.numerals = CompositePart::new("Inter", "Regular"); + db.set_composite_font(&CompositeFont { name: "CF Highlight".into(), parts }); + let style = CharStyle { size: 40.0, font_family: "CF Highlight".into(), ..Default::default() }; + let t = TextObject::point(Point::new(10.0, 60.0), "Ab12", style); + let g = text_geom(&t); + db.remove_composite_font("CF Highlight"); + assert!(!g.all.elements().is_empty()); + assert!(g.substituted_glyphs.elements().is_empty() && g.substituted_fonts.elements().is_empty()); +} + /// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes /// available: the glyph geometry kept for it is laid out again. #[test] diff --git a/crates/render/src/text_geom.rs b/crates/render/src/text_geom.rs index 5e3c9496a..05228eb20 100644 --- a/crates/render/src/text_geom.rs +++ b/crates/render/src/text_geom.rs @@ -191,7 +191,14 @@ pub(crate) fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeo if let Some(art) = g.placed_colour(Affine::IDENTITY) { colours.push((g.run, Arc::new(art))); } - let Some(&(missing, face)) = faces.get(g.run) else { continue }; + let Some(&(mut missing, mut face)) = faces.get(g.run) else { continue }; + // Type in a composite font: each character's face is its class's part's. + if let Some(r) = t.runs.get(g.run) + && let Some(c) = plain.get(g.byte..).and_then(|s| s.chars().next()) + && let Some((m, f)) = db.composite_face(&r.style.font_family, c) + { + (missing, face) = (m, Some(f)); + } let Some(line) = layout.lines.get(g.line) else { continue }; let mut cell = Rect::new(g.origin.x, g.origin.y - line.ascent, g.origin.x + g.advance, g.origin.y + line.descent).to_path(0.1); cell.apply_affine(Affine::rotate_about(g.angle, g.origin)); diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index dd956692d..c9d9027b8 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -84,11 +84,14 @@ impl Writer<'_> { let inline = t.runs.iter().any(|r| r.inline.is_some()); // Ruby is written as outlines with its base text: a reader's can't place it. So are // small capitals, superscript and subscript and tab leaders, which SVG text doesn't draw (#3869), - // and a glyph chosen for a character, which a reader's draws with the default glyph. - let unwritable = - t.runs.iter().any(|r| { - r.style.small_caps.is_some() || r.style.position != vectorcraft_doc::CharPosition::Normal || r.style.glyph_alternate.is_some() - }) || std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty())); + // and a glyph chosen for a character, which a reader's draws with the default glyph. So + // is type in one of the document's composite fonts, which a reader can't find by its name. + let unwritable = t.runs.iter().any(|r| { + r.style.small_caps.is_some() + || r.style.position != vectorcraft_doc::CharPosition::Normal + || r.style.glyph_alternate.is_some() + || vectorcraft_doc::CompositeFont::named(&doc.composite_fonts, &r.style.font_family).is_some() + }) || std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty())); let outlined = self.opts.outline_text || t.vertical || !t.ruby.is_empty() || (inline && matches!(t.kind, TextKind::OnPath { .. })) || unwritable; let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) }; diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 1dbc7035b..a3ad7a069 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -367,6 +367,8 @@ pub fn export_with_report(doc: &Document, opts: &ExportOptions) -> (String, Vec< /// Export a document as SVG plus the image files it links to. `native`: the document in the /// native format, embedded when [`ExportOptions::preserve_editing`] is on. pub fn export_full(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>) -> Output { + // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); export::export(doc, opts, native) } diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index d42e8f351..99975cd2b 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -20,6 +20,7 @@ use vectorcraft_doc::color::Color; mod alternates; mod build; mod cache; +mod composite; mod db; mod dirs; mod face; @@ -36,6 +37,7 @@ pub use build::style_weight; #[cfg(test)] use cache::GlyphCache; use cache::{CanonicalNames, Catalog, GlyphColours, GlyphImages, PostScriptNames}; +pub use composite::{CompositeCoverage, LentCompositeFonts}; #[cfg(all(not(target_arch = "wasm32"), test))] pub(crate) use dirs::fonts_outside; #[cfg(test)] @@ -107,6 +109,11 @@ pub struct FontDb { stamp: Mutex>, /// Faces instanced at the axis settings of type's Variable Font sliders ([`FontDb::instance`]). instances: Mutex, + /// Composite fonts (Type › Composite Fonts…) by lowercased name ([`FontDb::set_composite_font`]): + /// type naming one as its family is set in its parts' fonts. + composites: RwLock>>, + /// The composite fonts [`FontDb::lend_composite_fonts`] put in, by lowercased name. + composite_loans: Mutex>, } /// Where a database's system font scan looks: folders (read with their subfolders) and font files. diff --git a/crates/text/src/fontdb/composite.rs b/crates/text/src/fontdb/composite.rs new file mode 100644 index 000000000..faee695ca --- /dev/null +++ b/crates/text/src/fontdb/composite.rs @@ -0,0 +1,345 @@ +//! Composite fonts (Type › Composite Fonts…) in the font database: each is listed first in the +//! font menus and resolves to its base (Kanji) font, and layout sets each character of type in one +//! in the font of the character's class ([`FontDb::composite_split`]). + +use unicode_segmentation::UnicodeSegmentation; +use vectorcraft_doc::{CharClass, CompositeFont, TextObject, TextRun}; + +use super::face::FALLBACK_FAMILY; +use super::{Arc, FontDb, FontFace, FontMatch}; + +/// A face [`FontDb::resolve`] found and how it matched. +type Resolved = Option<(Arc, FontMatch)>; + +/// How a composite font draws some text ([`FontDb::composite_coverage`]). +#[derive(Clone, Debug)] +pub struct CompositeCoverage { + /// The worst match among the parts that draw it. + pub status: FontMatch, + /// The face of the first part with that match. + pub face: Option>, + /// The characters whose part's face lacks them. + pub lacking: Vec, +} + +impl FontDb { + /// Make `font` available by its name, in place of a composite font of that name (any case). + /// The font menus and anything else that lists fonts change only when the definition does. + /// Whether it did. + pub fn set_composite_font(&self, font: &CompositeFont) -> bool { + let key = font.name.to_lowercase(); + { + let mut map = self.composites.write().unwrap_or_else(|e| e.into_inner()); + if map.get(&key).is_some_and(|f| **f == *font) { + return false; + } + map.insert(key, Arc::new(font.clone())); + } + self.changed(); + true + } + + /// Forget the composite font named `name` (any case). Whether there was one. + pub fn remove_composite_font(&self, name: &str) -> bool { + let removed = self.composites.write().unwrap_or_else(|e| e.into_inner()).remove(&name.to_lowercase()).is_some(); + if removed { + self.changed(); + } + removed + } + + /// The composite font named `name` (any case). + pub fn composite_font(&self, name: &str) -> Option> { + let map = self.composites.read().unwrap_or_else(|e| e.into_inner()); + if map.is_empty() { + return None; + } + map.get(&name.to_lowercase()).cloned() + } + + /// The face composite font `name` sets character `c` in (its class's part's), and whether + /// that part's font is missing. `None` when `name` is no composite font. + pub fn composite_face(&self, name: &str, c: char) -> Option<(bool, u32)> { + self.composite_resolve(name, c).map(|(face, m)| (m == FontMatch::Missing, face.id())) + } + + /// [`FontDb::composite_resolve`] for the character at byte `at` of run text `text`: the part of + /// the grapheme cluster that holds it, by the cluster's first character, as layout sets it. + pub fn composite_resolve_at(&self, name: &str, text: &str, at: usize) -> Option<(Arc, FontMatch)> { + let first = text.grapheme_indices(true).take_while(|(i, _)| *i <= at).last().and_then(|(_, g)| g.chars().next())?; + self.composite_resolve(name, first) + } + + /// The face composite font `name` sets character `c` in (its class's part's), and how that + /// part's font matched ([`FontDb::resolve`]). `None` when `name` is no composite font, or no + /// font at all is loaded. + pub fn composite_resolve(&self, name: &str, c: char) -> Option<(Arc, FontMatch)> { + let f = self.composite_font(name)?; + let (family, style) = self.part_font(&f, CharClass::of(c)); + self.resolve(&family, &style) + } + + /// The fonts (family, style) composite font `name` sets `text` in: those of the parts its + /// grapheme clusters fall in (each by its first character, as layout splits them), each once, + /// in the order first used; the Kanji part's for text without characters. `None` when `name` is + /// no composite font. + pub fn composite_fonts_for(&self, name: &str, text: &str) -> Option> { + let f = self.composite_font(name)?; + let mut classes: Vec = Vec::new(); + for g in text.graphemes(true) { + let c = g.chars().next().map_or(CharClass::Kanji, CharClass::of); + if !classes.contains(&c) { + classes.push(c); + } + } + if classes.is_empty() { + classes.push(CharClass::Kanji); + } + let mut fonts: Vec<(String, String)> = Vec::with_capacity(classes.len()); + for c in classes { + let font = self.part_font(&f, c); + if !fonts.contains(&font) { + fonts.push(font); + } + } + Some(fonts) + } + + /// How composite font `name` draws the runs of text `texts` (each split into grapheme clusters + /// on its own, as layout splits runs): the worst match among the parts their grapheme clusters + /// fall in (missing, then another style, then exact), the face of the first part with that + /// match, and the characters (neither whitespace nor control characters, sorted, each once) + /// that their part's face lacks. `None` when `name` is no composite font. + pub fn composite_coverage(&self, name: &str, texts: &[&str]) -> Option { + let f = self.composite_font(name)?; + let rank = |m: FontMatch| match m { + FontMatch::Exact => 0, + FontMatch::Style => 1, + FontMatch::Missing => 2, + }; + let mut faces: Vec<(CharClass, Resolved)> = Vec::new(); + let mut face_of = |class: CharClass| { + if let Some((_, found)) = faces.iter().find(|(c, _)| *c == class) { + return found.clone(); + } + let (family, style) = self.part_font(&f, class); + let found = self.resolve(&family, &style); + faces.push((class, found.clone())); + found + }; + let mut worst: Option<(FontMatch, Option>)> = None; + let mut lacking: Vec = Vec::new(); + let mut graphemes: Vec<&str> = texts.iter().flat_map(|t| t.graphemes(true)).collect(); + if graphemes.is_empty() { + graphemes.push(""); + } + for g in graphemes { + let class = g.chars().next().map_or(CharClass::Kanji, CharClass::of); + let found = face_of(class); + let m = found.as_ref().map_or(FontMatch::Missing, |(_, m)| *m); + if worst.as_ref().is_none_or(|(w, _)| rank(m) > rank(*w)) { + worst = Some((m, found.as_ref().map(|(face, _)| face.clone()))); + } + lacking.extend(g.chars().filter(|c| !c.is_whitespace() && !c.is_control() && !found.as_ref().is_some_and(|(face, _)| face.covers(*c)))); + } + lacking.sort_unstable(); + lacking.dedup(); + let (status, face) = worst.unwrap_or((FontMatch::Exact, None)); + Some(CompositeCoverage { status, face, lacking }) + } + + /// The family type in `family` is stored under when it is set in that font: a composite font's + /// own name, else the family of the face it resolves to (any name of a family, ヒラギノ角ゴシック, + /// is stored as its own, Hiragino Sans). `None` when the family isn't available. + pub fn stored_family(&self, family: &str, style: &str) -> Option { + if let Some(f) = self.composite_font(family) { + return Some(f.name.clone()); + } + match self.resolve(family, style) { + Some((face, m)) if m != FontMatch::Missing => Some(face.family.clone()), + _ => None, + } + } + + /// Make each of the composite fonts `fonts` available while the returned guard lives, in place + /// of a composite font of the same name that the database holds. An app's session registers + /// the composite fonts of its front document; with the guard, a document exported without a + /// session, or one that is not in front, draws its type in its own. Dropping the guard puts + /// back the font each one replaced, or forgets it, unless another guard still lends the same + /// definition or a font of that name has been set since. While one guard lends a font, another + /// guard's different definition of that name is not lent. + pub fn lend_composite_fonts(&self, fonts: &[CompositeFont]) -> LentCompositeFonts<'_> { + let mut lent = Vec::new(); + let mut changed = false; + // A font a document can't use (its name an installed family's, or a value out of range) + // is not lent: an app leaves it out when it opens the document. + let usable: Vec<&CompositeFont> = fonts.iter().filter(|f| f.check().is_ok() && !self.has_installed_family(&f.name)).collect(); + if !usable.is_empty() { + let mut map = self.composites.write().unwrap_or_else(|e| e.into_inner()); + let mut loans = self.composite_loans.lock().unwrap_or_else(|e| e.into_inner()); + for f in usable { + let key = f.name.to_lowercase(); + // A loan still in force: lend the same definition again, and leave another as it is. + if let Some(loan) = loans.get_mut(&key) + && map.get(&key).is_some_and(|have| Arc::ptr_eq(have, &loan.font)) + { + if *loan.font == *f { + loan.guards += 1; + lent.push((key, loan.font.clone())); + } + continue; + } + if map.get(&key).is_some_and(|have| **have == *f) { + continue; + } + let font = Arc::new((*f).clone()); + let previous = map.insert(key.clone(), font.clone()); + loans.insert(key.clone(), CompositeLoan { font: font.clone(), previous, guards: 1 }); + lent.push((key, font)); + changed = true; + } + } + if changed { + self.changed(); + } + LentCompositeFonts { db: self, lent } + } + + /// The family and style composite font `f` sets characters of `class` in: its part's, or the + /// fallback font's when that part names a composite font. + fn part_font(&self, f: &CompositeFont, class: CharClass) -> (String, String) { + let p = f.parts.get(class); + if self.composite_font(&p.font).is_some() { (FALLBACK_FAMILY.to_string(), "Regular".to_string()) } else { (p.font.clone(), p.style.clone()) } + } + + /// The names of the composite fonts, sorted. + pub fn composite_font_names(&self) -> Vec { + let mut v: Vec = self.composites.read().unwrap_or_else(|e| e.into_inner()).values().map(|f| f.name.clone()).collect(); + v.sort_by_key(|n| n.to_lowercase()); + v + } + + /// The family and style that stand for composite font `name` where one font is needed (its + /// metrics, whether it is missing): its Kanji part's, or the fallback font's when that part + /// names a composite font too. `None` when `name` is no composite font. + pub(super) fn composite_base(&self, name: &str) -> Option<(String, String)> { + let f = self.composite_font(name)?; + Some(self.part_font(&f, CharClass::Kanji)) + } + + /// `t` with each run set in a composite font split into runs of one [`CharClass`] each, set in + /// that class's font: its size and baseline shift from the part's size and baseline (in + /// percent of the run's size), its scales multiplied by the part's, and Kana scaled at the + /// center raised so the em box's center stays where it was. Each grapheme cluster goes with + /// its first character. Also the index of the run of `t` each run came from. `None` when no run + /// is set in a composite font. + pub fn composite_split(&self, t: &TextObject) -> Option<(TextObject, Vec)> { + if self.composites.read().unwrap_or_else(|e| e.into_inner()).is_empty() { + return None; + } + let fonts: Vec>> = + t.runs.iter().map(|r| if r.inline.is_some() { None } else { self.composite_font(&r.style.font_family) }).collect(); + if fonts.iter().all(Option::is_none) { + return None; + } + let mut runs = Vec::with_capacity(t.runs.len()); + let mut from = Vec::with_capacity(t.runs.len()); + for (i, (r, font)) in t.runs.iter().zip(&fonts).enumerate() { + let Some(font) = font else { + runs.push(r.clone()); + from.push(i); + continue; + }; + let mut start = 0; + let mut class = None; + for (at, g) in r.text.grapheme_indices(true) { + let c = g.chars().next().map_or(CharClass::Kanji, CharClass::of); + if let Some(k) = class + && k != c + { + runs.push(self.composite_run(r.text.get(start..at).unwrap_or_default(), r, font, k)); + from.push(i); + start = at; + } + class = Some(c); + } + runs.push(self.composite_run(r.text.get(start..).unwrap_or_default(), r, font, class.unwrap_or(CharClass::Kanji))); + from.push(i); + } + Some((TextObject { runs, ..t.clone() }, from)) + } + + /// Characters `text` of run `r` (set in composite font `font`), all of class `class`, as a run + /// set in that class's part. + fn composite_run(&self, text: &str, r: &TextRun, font: &CompositeFont, class: CharClass) -> TextRun { + let p = font.parts.get(class); + let mut st = r.style.clone(); + let base = st.size; + let (family, style) = self.part_font(font, class); + st.font_family = family; + st.font_style = style; + st.font_version = None; + st.font_axes.clear(); + st.size = base * p.size / 100.0; + st.baseline_shift += base * p.baseline / 100.0; + st.h_scale *= p.h_scale / 100.0; + st.v_scale *= p.v_scale / 100.0; + if class == CharClass::Kana && p.scale_at_center && p.v_scale != 100.0 { + let centre = self.style_face(&st).map_or(0.38, |f| f.ideographic_centre()); + st.baseline_shift += st.size * centre * (1.0 - p.v_scale / 100.0); + } + TextRun { text: text.to_string(), style: st, inline: None } + } +} + +/// A composite font [`FontDb::lend_composite_fonts`] put in the database. +pub(super) struct CompositeLoan { + /// The font lent. + font: Arc, + /// The font of that name it replaced, put back when the last guard lending it drops. + previous: Option>, + /// How many guards lend it. + guards: usize, +} + +/// The composite fonts [`FontDb::lend_composite_fonts`] made available, given back when it drops. +#[must_use = "the fonts are given back when the guard drops"] +pub struct LentCompositeFonts<'a> { + db: &'a FontDb, + /// The lowercased names this guard lends, each with the font lent. + lent: Vec<(String, Arc)>, +} + +impl Drop for LentCompositeFonts<'_> { + fn drop(&mut self) { + if self.lent.is_empty() { + return; + } + let mut changed = false; + { + let mut map = self.db.composites.write().unwrap_or_else(|e| e.into_inner()); + let mut loans = self.db.composite_loans.lock().unwrap_or_else(|e| e.into_inner()); + for (key, font) in &self.lent { + // A later loan of that name is another guard's. + let Some(loan) = loans.get_mut(key).filter(|l| Arc::ptr_eq(&l.font, font)) else { continue }; + loan.guards -= 1; + if loan.guards > 0 { + continue; + } + let previous = loan.previous.take(); + loans.remove(key); + // Given back unless a font of that name has been set or removed since. + if map.get(key).is_some_and(|have| Arc::ptr_eq(have, font)) { + match previous { + Some(p) => map.insert(key.clone(), p), + None => map.remove(key), + }; + changed = true; + } + } + } + if changed { + self.db.changed(); + } + } +} diff --git a/crates/text/src/fontdb/db.rs b/crates/text/src/fontdb/db.rs index 9cc32eeee..902a3e5aa 100644 --- a/crates/text/src/fontdb/db.rs +++ b/crates/text/src/fontdb/db.rs @@ -69,6 +69,8 @@ impl FontDb { #[cfg(not(target_arch = "wasm32"))] stamp: Mutex::new(None), instances: Mutex::new(Instances::default()), + composites: RwLock::new(HashMap::new()), + composite_loans: Mutex::new(HashMap::new()), } } @@ -150,7 +152,8 @@ impl FontDb { /// The families font menus and lists show: [`FontDb::family_list`] without the system's hidden /// families, whose names start with "." (macOS keeps ".SF NS", ".LastResort" and others for its /// own interface, and Mac apps don't list them). Those still resolve by name, for documents and - /// fallbacks that name them. Cheap to call every frame. + /// fallbacks that name them. The composite fonts ([`FontDb::composite_font_names`]) come first. + /// Cheap to call every frame. pub fn menu_family_list(&self) -> Arc<[String]> { let all = self.family_list(); let mut cache = self.menu_cache.lock().unwrap_or_else(|e| e.into_inner()); @@ -159,7 +162,8 @@ impl FontDb { { return list.clone(); } - let list: Arc<[String]> = all.iter().filter(|f| !f.starts_with('.')).cloned().collect(); + let composites = self.composite_font_names(); + let list: Arc<[String]> = composites.into_iter().chain(all.iter().filter(|f| !f.starts_with('.')).cloned()).collect(); *cache = Some((all, list.clone())); list } @@ -187,7 +191,7 @@ impl FontDb { let mut v: Vec = self.read_faces().iter().map(|f| f.family.clone()).filter(|f| !f.starts_with('.')).collect(); v.sort_by_key(|a| a.to_lowercase()); v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); - v.into() + self.composite_font_names().into_iter().chain(v).collect() } /// How a font menu labels `family`: the English name when `english` is on (Preferences › Type › diff --git a/crates/text/src/fontdb/lookup.rs b/crates/text/src/fontdb/lookup.rs index 010f96ffa..71e8e7ab8 100644 --- a/crates/text/src/fontdb/lookup.rs +++ b/crates/text/src/fontdb/lookup.rs @@ -82,8 +82,11 @@ impl FontDb { /// The kind of `family` (whether it sets Japanese, its class), for font menu filters: from a /// loaded face, else from the system font scan. Variable when any of its faces, loaded or /// scanned, is: a family may install static files beside a variable one. Cheap: no font file - /// is read, and a loaded family doesn't start the scan. + /// is read, and a loaded family doesn't start the scan. A composite font takes its base font's. pub fn family_traits(&self, family: &str) -> FontTraits { + if let Some((base, _)) = self.composite_base(family) { + return self.family_traits(&base); + } let loaded = { let faces = self.read_faces(); let mut of_family = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)); @@ -101,8 +104,11 @@ impl FontDb { } /// [`face`](Self::face) by any of the family's names ([`canonical`](Self::canonical)), and how - /// it matched. + /// it matched. A composite font matches as its base font does ([`Self::composite_base`]). pub fn resolve(&self, family: &str, style: &str) -> Option<(Arc, FontMatch)> { + if let Some((base, base_style)) = self.composite_base(family) { + return self.resolve(&base, &base_style); + } let (family, style) = self.canonical(family, style); let f = self.face(&family, &style)?; let m = if !f.family.eq_ignore_ascii_case(&family) { @@ -159,8 +165,12 @@ impl FontDb { /// Resolve a family + style to a face, falling back to the closest style of the family, then to /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. `None` /// only if no font at all is loaded (the bundled fonts failed to parse, or were left out with - /// the `bundled-fonts` feature and none was added), in which case text has no glyphs. + /// the `bundled-fonts` feature and none was added), in which case text has no glyphs. A + /// composite font gives its base font's face ([`Self::composite_base`]). pub fn face(&self, family: &str, style: &str) -> Option> { + if let Some((base, base_style)) = self.composite_base(family) { + return self.face(&base, &base_style); + } let (family, style) = self.canonical(family, style); let (family, style) = (family.as_str(), style.as_str()); let found = self.find(family, style); @@ -352,8 +362,16 @@ impl FontDb { self.family_list().iter().find(|f| norm_chars(f).eq(norm_chars(name))).cloned() } - /// Is `family` available (loaded, or installed on the system)? + /// Is `family` available (loaded, installed on the system, or a composite font)? pub fn has_family(&self, family: &str) -> bool { + if self.composite_font(family).is_some() { + return true; + } + self.has_installed_family(family) + } + + /// Is `family` available as a loaded or installed font, excluding composite fonts? + pub fn has_installed_family(&self, family: &str) -> bool { let (family, _) = self.canonical(family, ""); self.is_loaded(&family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 6e9334041..053cd2981 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -115,6 +115,10 @@ struct Ctx<'a> { db: &'a FontDb, text: &'a str, runs: Vec<(Range, &'a CharStyle)>, + /// The runs as the text sets them, when `runs` are the pieces a composite font splits them + /// into ([`FontDb::composite_split`]); empty otherwise. Ruby readings take their style from + /// these, so a reading in a composite font is set in its own parts. + set_runs: Vec<(Range, &'a CharStyle)>, /// The inline graphic of each run that is one (parallel to `runs`). inlines: Vec>, /// The face of each run (parallel to `runs`), looked up once ([`run_face`]). @@ -151,6 +155,16 @@ impl Ctx<'_> { self.run_at(b).and_then(|i| self.runs.get(i)).map_or(&self.default, |(_, s)| *s) } + /// The style the text sets byte `b` in: [`Self::style_at`], or for text in a composite font + /// the run's style before it was split by class ([`Self::set_runs`]). + fn set_style_at(&self, b: usize) -> &CharStyle { + let i = self.set_runs.partition_point(|(r, _)| r.end <= b); + match self.set_runs.get(i).or_else(|| self.set_runs.last()) { + Some((_, s)) => s, + None => self.style_at(b), + } + } + /// The face of [`Self::style_at`] `b` ([`FontDb::style_face`]). fn face_at(&self, b: usize) -> Option> { match self.run_at(b).and_then(|i| self.runs.get(i).map(|(_, s)| (i, *s))) { diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index f55750e95..75796e265 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -276,8 +276,27 @@ pub(crate) fn layout_faces_per_call(db: &FontDb, t: &TextObject) -> TextLayout { l } -/// One layout pass, without fitting. +/// One layout pass, without fitting. Runs set in a composite font are laid out as the runs of one +/// class each that [`FontDb::composite_split`] makes of them; the glyphs, ruby and inline +/// graphics still name the runs of `t`. fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout { + let Some((split, from)) = db.composite_split(t) else { return layout_runs(db, t, opts, None) }; + let mut l = layout_runs(db, &split, opts, Some(t)); + let original = |run: &mut usize| *run = from.get(*run).copied().unwrap_or(*run); + l.glyphs.iter_mut().chain(l.ruby.iter_mut()).for_each(|g| original(&mut g.run)); + l.inlines.iter_mut().for_each(|i| original(&mut i.run)); + l +} + +/// [`layout_once`] for text set in no composite font: `t` itself, or with `set`, the pieces a +/// composite font splits `set` into (ruby readings take their style from `set`'s runs). +fn layout_runs(db: &FontDb, t: &TextObject, opts: &LayoutOptions, set: Option<&TextObject>) -> TextLayout { + let mut set_runs = Vec::new(); + let mut off = 0; + for r in set.map(|s| s.runs.as_slice()).unwrap_or_default() { + set_runs.push((off..off + r.text.len(), &r.style)); + off += r.text.len(); + } let text = t.plain_text(); let mut runs = Vec::with_capacity(t.runs.len()); let mut inlines = Vec::with_capacity(t.runs.len()); @@ -315,6 +334,7 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout text: &text, faces, runs, + set_runs, inlines, default: CharStyle::default(), opts, diff --git a/crates/text/src/layout/ruby.rs b/crates/text/src/layout/ruby.rs index 1cf34e24c..34cb71f09 100644 --- a/crates/text/src/layout/ruby.rs +++ b/crates/text/src/layout/ruby.rs @@ -127,7 +127,10 @@ fn ruby_piece_for_line( ) -> Option { let mut x0 = f64::INFINITY; let mut x1 = f64::NEG_INFINITY; + // The size the base is drawn at (where the reading goes), and the size the text sets it in (what + // the reading is scaled from: a composite font's part scales the reading itself). let mut base_size: f64 = 0.0; + let mut set_size: f64 = 0.0; let mut run = 0; let mut byte = start; for (index, &glyph_index) in glyph_indices.iter().enumerate() { @@ -135,6 +138,7 @@ fn ruby_piece_for_line( x0 = x0.min(glyph.origin.x); x1 = x1.max(glyph.origin.x + glyph.advance); base_size = base_size.max(cx.style_at(glyph.byte).size); + set_size = set_size.max(cx.set_style_at(glyph.byte).size); if index == 0 { run = glyph.run; byte = glyph.byte; @@ -143,8 +147,9 @@ fn ruby_piece_for_line( if !(x0.is_finite() && x1.is_finite()) || base_size <= 0.0 { return None; } - let mut style = cx.style_at(byte).clone(); - style.size = base_size * ratio; + // The base's style as the text sets it: a reading in a composite font is set in its parts. + let mut style = cx.set_style_at(byte).clone(); + style.size = set_size * ratio; style.leading = None; style.baseline_shift = 0.0; style.tracking = 0.0; diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index aec815641..e54a49144 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -114,6 +114,7 @@ fn composition_trim_stays_along_the_line_when_a_character_rotates() { db: &db, text: "A", runs: vec![(0..1, &style)], + set_runs: Vec::new(), inlines: vec![None], faces: vec![std::cell::OnceCell::new()], default: style.clone(), diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index ce315645e..2dc80a3e9 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -36,9 +36,9 @@ pub use bitmap::GlyphImage; pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont}; pub use features::{EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, MAX_STYLE_FEATURES, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; pub use fontdb::{ - FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, IcfMargins, PlatformFontFiles, StylisticSet, WantedFont, - WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, set_user_font_dirs_confined, - style_weight, system_font_dirs, user_font_dirs, + CompositeCoverage, FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, IcfMargins, LentCompositeFonts, + PlatformFontFiles, StylisticSet, WantedFont, WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, + set_user_font_dirs, set_user_font_dirs_confined, style_weight, system_font_dirs, user_font_dirs, }; pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions}; pub use layout::{envelope_ink, register_envelope_ink}; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 6183278ed..af022b847 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -9,6 +9,7 @@ mod bidi; mod bitmaps; mod colr; mod combos; +mod composite; mod devanagari; mod embed; mod fit; diff --git a/crates/text/src/tests/composite.rs b/crates/text/src/tests/composite.rs new file mode 100644 index 000000000..370fa4557 --- /dev/null +++ b/crates/text/src/tests/composite.rs @@ -0,0 +1,196 @@ +//! Composite fonts: each class of character set in its part's font. + +use crate::*; +use vectorcraft_doc::{CharStyle, CompositeFont, TextRun}; + +/// Base (Kanji) Source Sans 3; Roman in JetBrains Mono at half size, raised by a tenth of the size; +/// Numerals in Inter, scaled to 80% across; Kana scaled to 50% up from the center. +fn mixed(name: &str) -> CompositeFont { + let mut f = CompositeFont::new(name, "Source Sans 3", "Regular"); + f.parts.roman.font = "JetBrains Mono".into(); + f.parts.roman.size = 50.0; + f.parts.roman.baseline = 10.0; + f.parts.numerals.font = "Inter".into(); + f.parts.numerals.h_scale = 80.0; + f.parts.kana.v_scale = 50.0; + f.parts.kana.scale_at_center = true; + f +} + +fn style(family: &str, size: f64) -> CharStyle { + CharStyle { font_family: family.into(), size, ..CharStyle::default() } +} + +fn face_id(db: &FontDb, family: &str) -> u32 { + db.face(family, "Regular").unwrap().id() +} + +/// Each run in a composite font splits where the class changes; a grapheme cluster stays with its +/// first character, runs in other fonts are kept, and each piece remembers its run. +#[test] +fn composite_runs_split_by_class() { + let db = FontDb::with_font_dirs(vec![]); + db.set_composite_font(&mixed("Mixed")); + let mut t = TextObject::point(Point::ZERO, "Ae\u{301}12\u{304B}\u{3099}国", style("Mixed", 20.0)); + t.runs.insert(0, TextRun::new("x", style("Inter", 12.0))); + let (split, from) = db.composite_split(&t).expect("split"); + let pieces: Vec<(&str, &str)> = split.runs.iter().map(|r| (r.text.as_str(), r.style.font_family.as_str())).collect(); + assert_eq!( + pieces, + [("x", "Inter"), ("Ae\u{301}", "JetBrains Mono"), ("12", "Inter"), ("\u{304B}\u{3099}", "Source Sans 3"), ("国", "Source Sans 3")] + ); + assert_eq!(from, [0, 1, 1, 1, 1]); + assert_eq!(split.plain_text(), t.plain_text()); + let roman = &split.runs[1].style; + assert_eq!((roman.size, roman.baseline_shift, roman.h_scale, roman.v_scale), (10.0, 2.0, 100.0, 100.0)); + let numerals = &split.runs[2].style; + assert_eq!((numerals.size, numerals.h_scale), (20.0, 80.0)); + // Kana at half height, raised by half the em box's center so the center stays put. + let kana = &split.runs[3].style; + let centre = db.face("Source Sans 3", "Regular").unwrap().ideographic_centre(); + assert_eq!(kana.v_scale, 50.0); + assert!((kana.baseline_shift - 20.0 * centre * 0.5).abs() < 1e-9, "{}", kana.baseline_shift); + assert_eq!(split.runs[4].style.baseline_shift, 0.0); + // Text in no composite font is not split. + assert!(db.composite_split(&TextObject::point(Point::ZERO, "Ab", style("Inter", 12.0))).is_none()); +} + +/// Laid out, each character is drawn in its class's font, sized by its part, and the glyphs still +/// name the run of the text they belong to. +#[test] +fn composite_type_draws_each_class_in_its_font() { + let db = FontDb::with_font_dirs(vec![]); + db.set_composite_font(&mixed("Mixed")); + let mut t = TextObject::point(Point::ZERO, "x", style("Inter", 12.0)); + t.runs.push(TextRun::new("A1", style("Mixed", 20.0))); + let l = layout(&db, &t); + let fonts: Vec<(usize, u32)> = l.glyphs.iter().map(|g| (g.run, g.font_id)).collect(); + assert_eq!(fonts, [(0, face_id(&db, "Inter")), (1, face_id(&db, "JetBrains Mono")), (1, face_id(&db, "Inter"))]); + // `A` is set as JetBrains Mono at 10 pt would set it. + let alone = layout(&db, &TextObject::point(Point::ZERO, "A", style("JetBrains Mono", 10.0))); + assert!((l.glyphs[1].advance - alone.glyphs[0].advance).abs() < 1e-9); +} + +/// A ruby reading over type in a composite font is set in the parts its own characters fall in. +#[test] +fn ruby_over_composite_type_is_set_in_its_own_parts() { + let db = FontDb::with_font_dirs(vec![]); + db.set_composite_font(&mixed("Ruby Parts")); + let mut t = TextObject::point(Point::ZERO, "\u{56FD}", style("Ruby Parts", 20.0)); + t.ruby = vec![vectorcraft_doc::text::RubySpan::new(0, 3, "A")]; + let l = layout(&db, &t); + let fonts: Vec = l.ruby.iter().map(|g| g.font_id).collect(); + assert_eq!(fonts, [face_id(&db, "JetBrains Mono")]); + assert_eq!(l.ruby[0].run, 0); + // Over a Roman base (a 50% part), the reading is half the 20 pt type, scaled once by its part: + // 5 pt, not the base's 10 pt halved twice. + let mut t = TextObject::point(Point::ZERO, "A", style("Ruby Parts", 20.0)); + t.ruby = vec![vectorcraft_doc::text::RubySpan::new(0, 1, "a")]; + let l = layout(&db, &t); + let alone = layout(&db, &TextObject::point(Point::ZERO, "a", style("JetBrains Mono", 5.0))); + assert_eq!(l.ruby.len(), 1); + // The width each glyph is drawn at, in text space. + let width = |g: &crate::PositionedGlyph| { + use kurbo::Shape; + g.outline.bounding_box().width() + }; + let (reading, expected) = (width(&l.ruby[0]), width(&alone.glyphs[0])); + assert!(expected > 0.0 && (reading - expected).abs() < 1e-6, "{reading} vs {expected}"); + // It sits above the base as drawn (10 pt), not above the 20 pt the text sets it in. + let rise = l.glyphs[0].origin.y - l.ruby[0].origin.y; + assert!(rise > 8.0 && rise < 15.0, "the reading is {rise} pt above the base's baseline"); +} + +/// Lent composite fonts are available while a guard lends them: a second guard lends one again, a +/// font of the same name the database holds is replaced and put back afterwards, and a font named +/// like an installed family is not lent. +#[test] +fn lent_composite_fonts_last_as_long_as_a_guard() { + let db = FontDb::with_font_dirs(vec![]); + db.set_composite_font(&mixed("Held")); + let mut held = mixed("Held"); + held.parts.roman.size = 75.0; + let fonts = [mixed("Lent"), held, CompositeFont::new("Inter", "Source Sans 3", "Regular")]; + let first = db.lend_composite_fonts(&fonts); + assert!(db.composite_font("lent").is_some()); + assert_eq!(db.composite_font("Held").unwrap().parts.roman.size, 75.0); + assert!(db.composite_font("Inter").is_none()); + let second = db.lend_composite_fonts(&fonts); + drop(first); + assert!(db.composite_font("Lent").is_some()); + assert_eq!(db.composite_font("Held").unwrap().parts.roman.size, 75.0); + drop(second); + assert!(db.composite_font("Lent").is_none()); + assert_eq!(db.composite_font("Held").unwrap().parts.roman.size, 50.0); + // A font set in its place while lent stays when the guard drops. + let lent = db.lend_composite_fonts(&fonts[..1]); + let mut replaced = mixed("Lent"); + replaced.parts.roman.size = 60.0; + db.set_composite_font(&replaced); + drop(lent); + assert_eq!(db.composite_font("Lent").unwrap().parts.roman.size, 60.0); + // A guard whose font was removed doesn't give back a later guard's loan of that name. + assert!(db.remove_composite_font("Lent")); + let old = db.lend_composite_fonts(&fonts[..1]); + assert!(db.remove_composite_font("Lent")); + let new = db.lend_composite_fonts(&fonts[..1]); + drop(old); + assert!(db.composite_font("Lent").is_some()); + drop(new); + assert!(db.composite_font("Lent").is_none()); +} + +/// The fonts a composite font sets text in are those of the parts its characters fall in, and +/// coverage is checked against each character's part. +#[test] +fn composite_fonts_for_text_follow_each_characters_part() { + let db = FontDb::with_font_dirs(vec![]); + let mut f = mixed("Parts"); + f.parts.kana.font = "No Such Kana Font".into(); + db.set_composite_font(&f); + let fonts = |text: &str| db.composite_fonts_for("parts", text).unwrap(); + let pair = |family: &str| (family.to_string(), "Regular".to_string()); + assert_eq!(fonts("A1"), [pair("JetBrains Mono"), pair("Inter")]); + assert_eq!(fonts(""), [pair("Source Sans 3")]); + assert!(db.composite_fonts_for("Inter", "A").is_none()); + let exact = db.composite_coverage("Parts", &["A1"]).unwrap(); + assert_eq!((exact.status, exact.lacking.len()), (FontMatch::Exact, 0)); + let missing = db.composite_coverage("Parts", &["A\u{304B}"]).unwrap(); + assert_eq!(missing.status, FontMatch::Missing); + assert_eq!(missing.face.map(|f| f.family.clone()).as_deref(), Some(FALLBACK_FAMILY)); + // Each run is split on its own: a combining mark that starts a run is a Roman character. + f.parts.kana.font = "Source Sans 3".into(); + f.parts.roman.font = "No Such Roman Font".into(); + db.set_composite_font(&f); + assert_eq!(db.composite_coverage("Parts", &["\u{304B}\u{301}"]).unwrap().status, FontMatch::Exact); + assert_eq!(db.composite_coverage("Parts", &["\u{304B}", "\u{301}"]).unwrap().status, FontMatch::Missing); + assert_eq!(db.stored_family("parts", "Regular").as_deref(), Some("Parts")); + assert_eq!(db.stored_family("No Such Kana Font", "Regular"), None); +} + +/// A composite font resolves to its base font as an exact match, lists first in the font menus, +/// and is gone from them once removed. +#[test] +fn composite_fonts_resolve_to_their_base_and_list_first() { + let db = FontDb::with_font_dirs(vec![]); + let generation = db.generation(); + db.set_composite_font(&mixed("Zeta Mixed")); + assert_ne!(db.generation(), generation); + let (face, how) = db.resolve("zeta mixed", "Regular").unwrap(); + assert_eq!((face.family.as_str(), how), ("Source Sans 3", FontMatch::Exact)); + assert!(db.has_family("Zeta Mixed")); + assert_eq!(db.menu_family_list().first().map(String::as_str), Some("Zeta Mixed")); + // Registering the same definition again changes nothing. + let generation = db.generation(); + db.set_composite_font(&mixed("Zeta Mixed")); + assert_eq!(db.generation(), generation); + // A base naming a composite font falls back rather than looping. + let mut odd = mixed("Odd"); + odd.parts.kanji.font = "Zeta Mixed".into(); + db.set_composite_font(&odd); + assert_eq!(db.face("Odd", "Regular").unwrap().family, FALLBACK_FAMILY); + assert!(db.remove_composite_font("ZETA MIXED")); + assert!(!db.remove_composite_font("Zeta Mixed")); + assert!(!db.menu_family_list().iter().any(|f| f == "Zeta Mixed")); + assert!(!db.has_family("Zeta Mixed")); +} diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 985f98c34..de227b748 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -250,7 +250,7 @@ impl VectorcraftApp { let to = ¶ms["to"]; to["family"].as_str().and_then(|family| { let style = to["style"].as_str().unwrap_or("Regular"); - vectorcraft_text::FontDb::global().resolve(family, style).map(|(face, _)| face.family.clone()) + vectorcraft_text::FontDb::global().stored_family(family, style) }) } _ => None, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f2c123a56..48b9895a6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -22,6 +22,7 @@ pub mod color_balance; pub mod color_guide_options; mod color_picker; mod command; +pub mod composite_fonts; pub mod confirm; pub mod corners; pub mod dimension; @@ -348,6 +349,7 @@ registry! { Hyphenation: [hyphenation::KIND] => hyphenation::SPEC, Justification: [justification::KIND] => justification::SPEC, TateChuYokoSettings: [tate_chu_yoko::KIND] => tate_chu_yoko::SPEC, + CompositeFonts: [composite_fonts::KIND] => composite_fonts::SPEC, SavePdf: [save_pdf::KIND] => save_pdf::SPEC, SvgOptions: [svg_options::KIND] => svg_options::SPEC, JavaScriptFiles: [javascript_files::KIND] => javascript_files::SPEC, diff --git a/crates/ui-egui/src/dialogs/composite_fonts.rs b/crates/ui-egui/src/dialogs/composite_fonts.rs new file mode 100644 index 000000000..cc71cc32d --- /dev/null +++ b/crates/ui-egui/src/dialogs/composite_fonts.rs @@ -0,0 +1,275 @@ +//! Type › Composite Fonts… (with Show East Asian Options): the active document's composite fonts, +//! the one picked in Font shown as a table with a row for each class of character (Kanji, Kana, +//! Punctuation, Symbols, Roman, Numerals) and its font, style, size, baseline and scales. New adds +//! a font based on the one picked, Delete Font takes it out, Import… adds the fonts of a file and +//! Export… writes the one picked to a file. Nothing changes in the document until OK, which runs +//! `type.compositeFonts.set` with every font as one undo step; Cancel leaves them as they were. +//! +//! Fields: `fonts` (the composite fonts as `type.compositeFonts.set` takes them), `selected` (the +//! index of the one picked) and `name` (its name as typed). +//! +//! Behavior reference (public documentation): +//! . + +use serde_json::{Value, json}; +use vectorcraft_doc::text::{COMPOSITE_BASELINE, COMPOSITE_SCALE, COMPOSITE_SIZE}; +use vectorcraft_doc::{CharClass, CompositeFont, CompositePart}; +use vectorcraft_engine::cmd::compositefonts::EXTS; + +use super::{DialogSpec, form}; +use crate::picks::PickRequest; +use crate::state::Dialog; +use crate::{FilePick, VectorcraftApp, widgets}; + +/// The dialog kind of Composite Fonts. +pub const KIND: &str = "compositeFonts"; + +/// The command OK runs, and the menu item's. +pub const COMMAND: &str = "type.compositeFonts.set"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Composite Fonts").into(), body, confirm, min_width: 760.0, ..DialogSpec::FORM }; + +/// Open Composite Fonts on the active document's composite fonts, the first one picked. +pub fn open(app: &mut VectorcraftApp) -> Result { + let fonts = app.session.active().ok_or("no document open")?.doc.composite_fonts.to_vec(); + let mut d = Dialog::new(KIND, json!({ "fonts": fonts, "selected": 0 })); + show_selected(&mut d); + app.ui.dialog = Some(d); + Ok(Value::Null) +} + +/// The fonts the dialog holds. +fn fonts(d: &Dialog) -> Vec { + d.fields.get("fonts").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default() +} + +fn put_fonts(d: &mut Dialog, fonts: &[CompositeFont]) { + d.fields.insert("fonts".into(), json!(fonts)); +} + +/// The index of the font picked, within the fonts (0 when there are none). +fn selected(d: &Dialog, count: usize) -> usize { + d.fields.get("selected").and_then(Value::as_u64).map_or(0, |i| i as usize).min(count.saturating_sub(1)) +} + +/// Pick font `i`: its name goes into Name. +fn select(d: &mut Dialog, i: usize) { + d.fields.insert("selected".into(), json!(i)); + show_selected(d); +} + +fn show_selected(d: &mut Dialog) { + let fonts = fonts(d); + let name = fonts.get(selected(d, fonts.len())).map(|f| f.name.clone()).unwrap_or_default(); + d.fields.insert("name".into(), json!(name)); +} + +/// The name of a class's row. +fn class_label(c: CharClass) -> &'static str { + match c { + CharClass::Kanji => tl!("Kanji"), + CharClass::Kana => tl!("Kana"), + CharClass::Punctuation => tl!("Punctuation"), + CharClass::Symbols => tl!("Symbols"), + CharClass::Roman => tl!("Roman"), + CharClass::Numerals => tl!("Numerals"), + } +} + +/// "Composite Font N", the first N no font in `fonts` and no installed font is named. +fn new_name(fonts: &[CompositeFont]) -> String { + let db = vectorcraft_text::FontDb::global(); + (1..) + .map(|n| format!("Composite Font {n}")) + .find(|n| !fonts.iter().any(|f| CompositeFont::same_name(&f.name, n)) && (db.composite_font(n).is_some() || !db.has_family(n))) + .unwrap_or_default() +} + +/// What a button asked for, done once the table is drawn. +enum Action { + New, + Delete, + Import, + Export, +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let mut list = fonts(d); + let i = selected(d, list.len()); + let mut act = None; + let names: Vec<&str> = list.iter().map(|f| f.name.as_str()).collect(); + let pick = FilePick { filters: vec![FilePick::filter("Composite fonts", EXTS)], ..Default::default() }; + let can_import = crate::picks::can(app, &PickRequest::Open(pick)); + let mut picked = None; + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Font:")); + if names.is_empty() { + widgets::dim_label(ui, tl!("None")); + } else if let Some(k) = widgets::dropdown_names(ui, "composite-fonts-font", names.get(i).copied().unwrap_or_default(), &names, 220.0) { + picked = Some(k); + } + ui.add_space(8.0); + ui.spacing_mut().item_spacing.x = 4.0; + if widgets::flat_button(ui, tl!("New"), 48.0).clicked() { + act = Some(Action::New); + } + if ui.add_enabled_ui(!list.is_empty(), |ui| widgets::flat_button(ui, tl!("Delete Font"), 84.0)).inner.clicked() { + act = Some(Action::Delete); + } + if can_import && widgets::flat_button(ui, tl!("Import…"), 64.0).clicked() { + act = Some(Action::Import); + } + if ui.add_enabled_ui(!list.is_empty(), |ui| widgets::flat_button(ui, tl!("Export…"), 64.0)).inner.clicked() { + act = Some(Action::Export); + } + }); + if let Some(k) = picked { + select(d, k); + return false; + } + ui.add_space(8.0); + if let Some(f) = list.get_mut(i) { + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Name:")); + form::text(ui, d, "name", 260.0); + }); + f.name = d.str("name"); + ui.add_space(8.0); + parts_table(app, ui, f); + } + put_fonts(d, &list); + if let Some(a) = act { + // Import…'s file dialog, shown off the UI thread, imports into the dialog as it is then. + let r = match a { + Action::Import => crate::picks::in_dialog(app, d, |app, d| run(app, d, Action::Import)), + a => run(app, d, a), + }; + if let Err(e) = r { + app.status(e); + } + } + false +} + +/// Do what button `act` asks for on the dialog's fonts. +fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action) -> Result<(), String> { + let mut list = fonts(d); + let i = selected(d, list.len()); + match act { + Action::New => { + let mut f = list.get(i).cloned().unwrap_or_else(|| { + let st = vectorcraft_doc::CharStyle::default(); + CompositeFont::new("", st.font_family, st.font_style) + }); + f.name = new_name(&list); + list.push(f); + put_fonts(d, &list); + select(d, list.len() - 1); + } + Action::Delete => { + if i < list.len() { + list.remove(i); + } + put_fonts(d, &list); + select(d, i.min(list.len().saturating_sub(1))); + } + Action::Import => import(app, d)?, + Action::Export => { + let f = list.get(i).ok_or("no composite font")?; + crate::io::save_command_output(app, "type.compositeFonts.export", EXTS[0], json!({ "fonts": [f] }))?; + } + } + Ok(()) +} + +/// The table of `f`'s parts: a row for each class with its font, style, size, baseline, scales and +/// (Kana) Scale at Center; the values a class can't take are not shown. +fn parts_table(app: &VectorcraftApp, ui: &mut egui::Ui, f: &mut CompositeFont) { + let db = vectorcraft_text::FontDb::global(); + egui::Grid::new("composite-fonts-parts").num_columns(8).spacing([8.0, 6.0]).show(ui, |ui| { + for h in ["", tl!("Font"), tl!("Style"), tl!("Size"), tl!("Baseline"), tl!("Horizontal Scale"), tl!("Vertical Scale"), ""] { + widgets::dim_label(ui, h); + } + ui.end_row(); + for class in CharClass::ALL { + let p: &mut CompositePart = f.parts.get_mut(class); + ui.label(class_label(class)); + let id = ("composite-font", class.key()); + if let Some(crate::font_menu::FontPick::Chosen(family, style)) = + crate::font_menu::font_menu(ui, id, &p.font, 190.0, None, crate::font_menu::MenuLook::of(app)) + && db.composite_font(&family).is_none() + { + p.style = style.unwrap_or_else(|| db.styles(&family).first().cloned().unwrap_or_else(|| "Regular".into())); + p.font = family; + } + let styles = db.styles(&p.font); + let style_names: Vec<&str> = styles.iter().map(String::as_str).collect(); + if let Some(k) = widgets::dropdown_names(ui, ("composite-font-style", class.key()), &p.style, &style_names, 120.0) + && let Some(s) = styles.get(k) + { + p.style.clone_from(s); + } + let percent = |ui: &mut egui::Ui, key: &str, v: &mut f64, range: &std::ops::RangeInclusive| { + if let Some(n) = widgets::range_field(ui, ("composite-font", class.key(), key), *v, range.clone(), "%", 1, 64.0) { + *v = n; + } + }; + percent(ui, "size", &mut p.size, &COMPOSITE_SIZE); + percent(ui, "baseline", &mut p.baseline, &COMPOSITE_BASELINE); + if class.scales() { + percent(ui, "hScale", &mut p.h_scale, &COMPOSITE_SCALE); + percent(ui, "vScale", &mut p.v_scale, &COMPOSITE_SCALE); + } else { + ui.label(""); + ui.label(""); + } + if class == CharClass::Kana { + ui.checkbox(&mut p.scale_at_center, tl!("Scale at Center")); + } else { + ui.label(""); + } + ui.end_row(); + } + }); +} + +/// Import…: the fonts of a picked file join those in the dialog, a font of the same name replaced, +/// and the first of them is picked. +fn import(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { + let pick = FilePick { filters: vec![FilePick::filter("Composite fonts", EXTS)], ..Default::default() }; + let path = crate::picks::open(app, &pick).ok_or("cancelled")?; + let r = app.run("type.compositeFonts.import", json!({ "path": path, "apply": false }))?; + let read: Vec = serde_json::from_value(r["fonts"].clone()).map_err(|e| e.to_string())?; + let mut list = fonts(d); + let mut first = None; + for f in read { + let at = match list.iter_mut().enumerate().find(|(_, g)| CompositeFont::same_name(&g.name, &f.name)) { + Some((k, g)) => { + *g = f; + k + } + None => { + list.push(f); + list.len() - 1 + } + }; + first.get_or_insert(at); + } + put_fonts(d, &list); + if let Some(k) = first { + select(d, k); + } + Ok(()) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let r = app.run(COMMAND, json!({ "fonts": fonts(d) })); + // A name two fonts share, or a value out of range, keeps the dialog open to be changed. + if r.is_ok() { + app.ui.dialog = None; + } + r +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/dialogs/composite_fonts/tests.rs b/crates/ui-egui/src/dialogs/composite_fonts/tests.rs new file mode 100644 index 000000000..64a031e04 --- /dev/null +++ b/crates/ui-egui/src/dialogs/composite_fonts/tests.rs @@ -0,0 +1,174 @@ +use serde_json::{Value, json}; +use vectorcraft_engine::Session; + +use super::*; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + app +} + +fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); +} + +fn dialog(app: &mut VectorcraftApp) -> &mut Dialog { + app.ui.dialog.as_mut().expect("Composite Fonts open") +} + +fn names(app: &VectorcraftApp) -> Vec { + app.session.active().unwrap().doc.composite_fonts.iter().map(|f| f.name.clone()).collect() +} + +fn steps(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() +} + +/// Do `act` with the dialog as it is, as its button does. +fn press(app: &mut VectorcraftApp, act: Action) { + let mut d = app.ui.dialog.clone().unwrap(); + run(app, &mut d, act).unwrap(); + app.ui.dialog = Some(d); +} + +/// Type › Composite Fonts… opens on the document's fonts; New adds one, its parts edited in the +/// table; nothing changes until OK, which sets them as one undo step; Cancel leaves them. +#[test] +fn composite_fonts_change_on_ok_as_one_undo_step() { + let mut app = app(); + crate::menus::invoke(&mut app, COMMAND, json!({})); + assert_eq!(dialog(&mut app).kind, KIND); + frame(&mut app); + press(&mut app, Action::New); + frame(&mut app); + let d = dialog(&mut app); + assert_eq!(d.str("name"), "Composite Font 1"); + let mut list = fonts(d); + list[0].parts.roman.font = "JetBrains Mono".into(); + list[0].parts.roman.size = 80.0; + list[0].parts.kana.scale_at_center = true; + put_fonts(d, &list); + d.fields.insert("name".into(), json!("UI Composite A")); + frame(&mut app); + assert!(names(&app).is_empty()); + let before = steps(&app); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(names(&app), ["UI Composite A"]); + assert_eq!(steps(&app), before + 1); + let f = &app.session.active().unwrap().doc.composite_fonts[0]; + assert_eq!((f.parts.roman.font.as_str(), f.parts.roman.size, f.parts.kana.scale_at_center), ("JetBrains Mono", 80.0, true)); + // OK with nothing changed adds no step; Cancel leaves the fonts. + open(&mut app).unwrap(); + super::super::confirm(&mut app).unwrap(); + assert_eq!(steps(&app), before + 1); + open(&mut app).unwrap(); + press(&mut app, Action::Delete); + super::super::cancel(&mut app); + assert_eq!(names(&app), ["UI Composite A"]); + // Delete Font and OK takes it out. + open(&mut app).unwrap(); + press(&mut app, Action::Delete); + assert_eq!(dialog(&mut app).str("name"), ""); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert!(names(&app).is_empty()); +} + +/// New is based on the font picked and takes the next free name; a name two fonts share, or one +/// left empty, keeps the dialog open and changes nothing. +#[test] +fn shared_or_empty_names_keep_the_dialog_open() { + let mut app = app(); + open(&mut app).unwrap(); + press(&mut app, Action::New); + let d = dialog(&mut app); + let mut list = fonts(d); + list[0].parts.numerals.font = "Inter".into(); + put_fonts(d, &list); + press(&mut app, Action::New); + let list = fonts(dialog(&mut app)); + assert_eq!(list.iter().map(|f| f.name.as_str()).collect::>(), ["Composite Font 1", "Composite Font 2"]); + assert_eq!(list[1].parts.numerals.font, "Inter"); + for bad in ["composite font 1", " "] { + dialog(&mut app).fields.insert("name".into(), json!(bad)); + frame(&mut app); + assert!(super::super::confirm(&mut app).is_err(), "{bad:?}"); + assert!(app.ui.dialog.is_some()); + assert!(names(&app).is_empty()); + } + dialog(&mut app).fields.insert("name".into(), json!("UI Composite B")); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert_eq!(names(&app), ["Composite Font 1", "UI Composite B"]); +} + +/// The menu item shows only with Show East Asian Options on, and needs a document. +#[test] +fn the_menu_item_shows_with_east_asian_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.show_east_asian_options = true; + assert_eq!(crate::menus::shown_state(&app, COMMAND, &json!({})).map(|s| s.0), Some(false)); + app.run("file.new", json!({})).unwrap(); + assert_eq!(crate::menus::shown_state(&app, COMMAND, &json!({})).map(|s| s.0), Some(true)); + app.session.prefs.show_east_asian_options = false; + assert_eq!(crate::menus::shown_state(&app, COMMAND, &json!({})), None); +} + +/// Export… writes the font picked to a file; Import… adds the fonts of a file to the dialog's, +/// replacing one of the same name, and opening the file adds them to the document. +#[test] +fn composite_fonts_go_through_files() { + let dir = std::env::temp_dir().join(format!("vc-composite-fonts-ui-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("fonts.vccomposite").to_string_lossy().to_string(); + let (saved, picked) = (file.clone(), file.clone()); + let services = crate::Services { + pick_save: Some(Box::new(move |_: &FilePick| Some(saved.clone()))), + pick_open: Some(Box::new(move |_: &FilePick| Some(picked.clone()))), + write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({})).unwrap(); + open(&mut app).unwrap(); + press(&mut app, Action::New); + let d = dialog(&mut app); + let mut list = fonts(d); + list[0].name = "UI Composite IO".into(); + list[0].parts.roman.baseline = -12.0; + put_fonts(d, &list); + press(&mut app, Action::Export); + assert!(std::fs::read_to_string(&file).unwrap().contains("UI Composite IO")); + // Import into the dialog: the same name is replaced, not added. + let d = dialog(&mut app); + let mut list = fonts(d); + list[0].parts.roman.baseline = 0.0; + put_fonts(d, &list); + press(&mut app, Action::Import); + let list = fonts(dialog(&mut app)); + assert_eq!((list.len(), list[0].parts.roman.baseline), (1, -12.0)); + super::super::cancel(&mut app); + assert!(names(&app).is_empty()); + // Opening the file adds its fonts to the document. + let bytes = std::fs::read(&file).unwrap(); + crate::io::open_bytes(&mut app, &file, &bytes, Some(file.clone())).unwrap(); + assert_eq!(names(&app), ["UI Composite IO"]); + let _ = std::fs::remove_dir_all(dir); +} + +/// The dialog lists the fonts and draws its table without a document font picked too. +#[test] +fn the_dialog_draws_with_and_without_fonts() { + let mut app = app(); + open(&mut app).unwrap(); + frame(&mut app); + assert_eq!(dialog(&mut app).fields.get("fonts"), Some(&Value::Array(vec![]))); + press(&mut app, Action::New); + frame(&mut app); + assert_eq!(fonts(dialog(&mut app)).len(), 1); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 8eed280d6..75f2637a4 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3375,6 +3375,15 @@ digits Malayalam Малаялам Tate-chu-yoko Тате-чу-йоко Tate-chu-yoko Settings… Настройки на Тате-чу-йоко… Tate-chu-yoko Settings Настройки на Тате-чу-йоко + Composite Fonts… Съставни шрифтове… + Composite Fonts Съставни шрифтове + Export Composite Fonts Експортиране на съставни шрифтове + Import Composite Fonts Импортиране на съставни шрифтове + Delete Font Изтриване на шрифт + Kanji Канджи + Kana Кана + Numerals Цифри + Scale at Center Мащабиране от центъра Up/Down: Нагоре/надолу: Left/Right: Наляво/надясно: Temperature Температура diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index f533bd62c..4eadaff15 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1501,6 +1501,15 @@ digits Malayalam Malajalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Postavke za Tate-chu-yoko… Tate-chu-yoko Settings Postavke za Tate-chu-yoko + Composite Fonts… Kompozitni fontovi… + Composite Fonts Kompozitni fontovi + Export Composite Fonts Izvezi kompozitne fontove + Import Composite Fonts Uvezi kompozitne fontove + Delete Font Izbriši font + Kanji Kanji + Kana Kana + Numerals Brojevi + Scale at Center Skaliraj od centra Up/Down: Gore/dolje: Left/Right: Lijevo/desno: Underline Podvučeno diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index f30503c5e..d598de47b 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4318,6 +4318,15 @@ digits Malayalam Malaiàlam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Configuració de Tate-chu-yoko… Tate-chu-yoko Settings Configuració de Tate-chu-yoko + Composite Fonts… Tipus de lletra compostos… + Composite Fonts Tipus de lletra compostos + Export Composite Fonts Exporta tipus de lletra compostos + Import Composite Fonts Importa tipus de lletra compostos + Delete Font Suprimeix el tipus de lletra + Kanji Kanji + Kana Kana + Numerals Xifres + Scale at Center Escala des del centre Up/Down: Amunt/avall: Left/Right: Esquerra/dreta: Technical Drawing Dibuix tècnic diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 327664b70..e24e74929 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1525,6 +1525,15 @@ digits Malayalam Malajálamské Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Nastavení Tate-chu-yoko… Tate-chu-yoko Settings Nastavení Tate-chu-yoko + Composite Fonts… Složená písma… + Composite Fonts Složená písma + Export Composite Fonts Exportovat složená písma + Import Composite Fonts Importovat složená písma + Delete Font Odstranit písmo + Kanji Kandži + Kana Kana + Numerals Číslice + Scale at Center Měřítko od středu Up/Down: Nahoru/dolů: Left/Right: Vlevo/vpravo: Underline Podtržení diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index ddcf0cee0..c3fb0ffbe 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1435,6 +1435,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Indstillinger for Tate-chu-yoko… Tate-chu-yoko Settings Indstillinger for Tate-chu-yoko + Composite Fonts… Sammensatte skrifttyper… + Composite Fonts Sammensatte skrifttyper + Export Composite Fonts Eksportér sammensatte skrifttyper + Import Composite Fonts Importér sammensatte skrifttyper + Delete Font Slet skrifttype + Kanji Kanji + Kana Kana + Numerals Tal + Scale at Center Skalér fra midten Up/Down: Op/ned: Left/Right: Venstre/højre: Underline Understreget diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 609d04cd2..f83f7f74a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3407,6 +3407,15 @@ effect Points Punkte Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Tate-chu-yoko-Einstellungen… Tate-chu-yoko Settings Tate-chu-yoko-Einstellungen + Composite Fonts… Zusammengesetzte Schriften… + Composite Fonts Zusammengesetzte Schriften + Export Composite Fonts Zusammengesetzte Schriften exportieren + Import Composite Fonts Zusammengesetzte Schriften importieren + Delete Font Schrift löschen + Kanji Kanji + Kana Kana + Numerals Ziffern + Scale at Center Von der Mitte aus skalieren Up/Down: Oben/Unten: Left/Right: Links/Rechts: Temperature Temperatur diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 5c701ea4a..9e30d25f9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3809,6 +3809,15 @@ digits Malayalam Μαλαγιάλαμ Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Ρυθμίσεις Tate-chu-yoko… Tate-chu-yoko Settings Ρυθμίσεις Tate-chu-yoko + Composite Fonts… Σύνθετες γραμματοσειρές… + Composite Fonts Σύνθετες γραμματοσειρές + Export Composite Fonts Εξαγωγή σύνθετων γραμματοσειρών + Import Composite Fonts Εισαγωγή σύνθετων γραμματοσειρών + Delete Font Διαγραφή γραμματοσειράς + Kanji Κάντζι + Kana Κάνα + Numerals Αριθμοί + Scale at Center Κλιμάκωση από το κέντρο Up/Down: Πάνω/Κάτω: Left/Right: Αριστερά/Δεξιά: Temperature/Tint… Θερμοκρασία/Απόχρωση… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7ba760adb..5122c5acb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3463,6 +3463,15 @@ digits Malayalam Malayálam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Ajustes de Tate-chu-yoko… Tate-chu-yoko Settings Ajustes de Tate-chu-yoko + Composite Fonts… Fuentes compuestas… + Composite Fonts Fuentes compuestas + Export Composite Fonts Exportar fuentes compuestas + Import Composite Fonts Importar fuentes compuestas + Delete Font Eliminar fuente + Kanji Kanji + Kana Kana + Numerals Números + Scale at Center Escalar desde el centro Up/Down: Arriba/abajo: Left/Right: Izquierda/derecha: Temperature Temperatura diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 8fab4136a..4265fcbde 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1498,6 +1498,15 @@ digits Malayalam Malajalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Tate-chu-yoko-asetukset… Tate-chu-yoko Settings Tate-chu-yoko-asetukset + Composite Fonts… Yhdistelmäfontit… + Composite Fonts Yhdistelmäfontit + Export Composite Fonts Vie yhdistelmäfontit + Import Composite Fonts Tuo yhdistelmäfontit + Delete Font Poista fontti + Kanji Kanji + Kana Kana + Numerals Numerot + Scale at Center Skaalaa keskeltä Up/Down: Ylös/alas: Left/Right: Vasen/oikea: Underline Alleviivaus diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 039fe3fca..107f49a8b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3455,6 +3455,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Paramètres Tate-chu-yoko… Tate-chu-yoko Settings Paramètres Tate-chu-yoko + Composite Fonts… Polices composites… + Composite Fonts Polices composites + Export Composite Fonts Exporter des polices composites + Import Composite Fonts Importer des polices composites + Delete Font Supprimer la police + Kanji Kanji + Kana Kana + Numerals Chiffres + Scale at Center Mettre à l’échelle depuis le centre Up/Down: Haut/bas : Left/Right: Gauche/droite : Temperature Température diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 8c03e0c67..5db92a70e 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -4694,6 +4694,15 @@ Tate-chu-yoko ताते-चू-योको Tate-chu-yoko Settings ताते-चू-योको सेटिंग्स Tate-chu-yoko Settings… ताते-चू-योको सेटिंग्स… + Composite Fonts… कंपोज़िट फ़ॉन्ट… + Composite Fonts कंपोज़िट फ़ॉन्ट + Export Composite Fonts कंपोज़िट फ़ॉन्ट एक्सपोर्ट करें + Import Composite Fonts कंपोज़िट फ़ॉन्ट इम्पोर्ट करें + Delete Font फ़ॉन्ट हटाएँ + Kanji कांजी + Kana काना + Numerals अंक + Scale at Center केंद्र से आकार बदलें Technical Drawing तकनीकी ड्राइंग Telugu तेलुगु Temperature तापमान diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index eb0bf1e83..43ca6a06d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4116,6 +4116,15 @@ mark-style Japanese Japán Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Tate-chu-yoko beállításai… Tate-chu-yoko Settings Tate-chu-yoko beállításai + Composite Fonts… Összetett betűtípusok… + Composite Fonts Összetett betűtípusok + Export Composite Fonts Összetett betűtípusok exportálása + Import Composite Fonts Összetett betűtípusok importálása + Delete Font Betűtípus törlése + Kanji Kandzsi + Kana Kana + Numerals Számjegyek + Scale at Center Méretezés középről Up/Down: Fel/le: Left/Right: Balra/jobbra: Technical Drawing Műszaki rajz diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a27a83850..22b8db56b 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1435,6 +1435,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Pengaturan Tate-chu-yoko… Tate-chu-yoko Settings Pengaturan Tate-chu-yoko + Composite Fonts… Font komposit… + Composite Fonts Font komposit + Export Composite Fonts Ekspor font komposit + Import Composite Fonts Impor font komposit + Delete Font Hapus font + Kanji Kanji + Kana Kana + Numerals Angka + Scale at Center Skalakan dari tengah Up/Down: Atas/bawah: Left/Right: Kiri/kanan: Underline Garis bawah diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 62c8d5855..4f6375c4f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3499,6 +3499,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Impostazioni Tate-chu-yoko… Tate-chu-yoko Settings Impostazioni Tate-chu-yoko + Composite Fonts… Font compositi… + Composite Fonts Font compositi + Export Composite Fonts Esporta font compositi + Import Composite Fonts Importa font compositi + Delete Font Elimina font + Kanji Kanji + Kana Kana + Numerals Numeri + Scale at Center Ridimensiona dal centro Up/Down: Su/giù: Left/Right: Sinistra/destra: Temperature Temperatura diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8ea8706c5..8c73a1ae0 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3823,6 +3823,15 @@ file-filter Templates テンプレート Tate-chu-yoko 縦中横 Tate-chu-yoko Settings… 縦中横の設定… Tate-chu-yoko Settings 縦中横の設定 + Composite Fonts… 合成フォント… + Composite Fonts 合成フォント + Export Composite Fonts 合成フォントを書き出し + Import Composite Fonts 合成フォントを読み込み + Delete Font フォントを削除 + Kanji 漢字 + Kana かな + Numerals 数字 + Scale at Center 中心から拡大・縮小 Up/Down: 上下: Left/Right: 左右: Temperature 色温度 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index bb12f0353..5d40de893 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1360,6 +1360,15 @@ digits Malayalam მალაიალამური Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Tate-chu-yoko პარამეტრები… Tate-chu-yoko Settings Tate-chu-yoko პარამეტრები + Composite Fonts… კომპოზიტური შრიფტები… + Composite Fonts კომპოზიტური შრიფტები + Export Composite Fonts კომპოზიტური შრიფტების ექსპორტი + Import Composite Fonts კომპოზიტური შრიფტების იმპორტი + Delete Font შრიფტის წაშლა + Kanji კანძი + Kana კანა + Numerals ციფრები + Scale at Center ცენტრიდან მასშტაბირება Up/Down: ზემოთ/ქვემოთ: Left/Right: მარცხნივ/მარჯვნივ: Underline ხაზგასმული diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index bed786266..30a409b72 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3811,6 +3811,15 @@ file-filter Templates 템플릿 Tate-chu-yoko 다테추요코 Tate-chu-yoko Settings… 다테추요코 설정… Tate-chu-yoko Settings 다테추요코 설정 + Composite Fonts… 합성 글꼴… + Composite Fonts 합성 글꼴 + Export Composite Fonts 합성 글꼴 내보내기 + Import Composite Fonts 합성 글꼴 가져오기 + Delete Font 글꼴 삭제 + Kanji 한자 + Kana 가나 + Numerals 숫자 + Scale at Center 중심에서 크기 조정 Up/Down: 위/아래: Left/Right: 왼쪽/오른쪽: Temperature 색온도 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index bb8b5a0b0..c771d8b17 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1449,6 +1449,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Tate-chu-yoko-instellingen… Tate-chu-yoko Settings Tate-chu-yoko-instellingen + Composite Fonts… Samengestelde lettertypen… + Composite Fonts Samengestelde lettertypen + Export Composite Fonts Samengestelde lettertypen exporteren + Import Composite Fonts Samengestelde lettertypen importeren + Delete Font Lettertype verwijderen + Kanji Kanji + Kana Kana + Numerals Cijfers + Scale at Center Schalen vanuit het midden Up/Down: Omhoog/omlaag: Left/Right: Links/rechts: Underline Onderstrepen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 48bf41947..bdd887ecd 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1437,6 +1437,15 @@ digits Malayalam Malajalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Ustawienia Tate-chu-yoko… Tate-chu-yoko Settings Ustawienia Tate-chu-yoko + Composite Fonts… Fonty złożone… + Composite Fonts Fonty złożone + Export Composite Fonts Eksportuj fonty złożone + Import Composite Fonts Importuj fonty złożone + Delete Font Usuń font + Kanji Kanji + Kana Kana + Numerals Cyfry + Scale at Center Skaluj od środka Up/Down: W górę/w dół: Left/Right: W lewo/w prawo: Underline Podkreślenie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 62e251ef5..822a0d59e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4914,6 +4914,15 @@ axis Both Ambos Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Configurações de Tate-chu-yoko… Tate-chu-yoko Settings Configurações de Tate-chu-yoko + Composite Fonts… Fontes compostas… + Composite Fonts Fontes compostas + Export Composite Fonts Exportar fontes compostas + Import Composite Fonts Importar fontes compostas + Delete Font Excluir fonte + Kanji Kanji + Kana Kana + Numerals Números + Scale at Center Dimensionar a partir do centro Up/Down: Para Cima/Para Baixo: Left/Right: Esquerda/Direita: Temperature Temperatura diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c505319d9..54158fab4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3494,6 +3494,15 @@ digits Malayalam Малаялам Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Параметры Tate-chu-yoko… Tate-chu-yoko Settings Параметры Tate-chu-yoko + Composite Fonts… Составные шрифты… + Composite Fonts Составные шрифты + Export Composite Fonts Экспорт составных шрифтов + Import Composite Fonts Импорт составных шрифтов + Delete Font Удалить шрифт + Kanji Кандзи + Kana Кана + Numerals Цифры + Scale at Center Масштабировать от центра Up/Down: Вверх/вниз: Left/Right: Влево/вправо: Temperature Температура diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 67c495f17..a872e969a 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3385,6 +3385,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Inställningar för Tate-chu-yoko… Tate-chu-yoko Settings Inställningar för Tate-chu-yoko + Composite Fonts… Sammansatta teckensnitt… + Composite Fonts Sammansatta teckensnitt + Export Composite Fonts Exportera sammansatta teckensnitt + Import Composite Fonts Importera sammansatta teckensnitt + Delete Font Ta bort teckensnitt + Kanji Kanji + Kana Kana + Numerals Siffror + Scale at Center Skala från mitten Up/Down: Upp/ned: Left/Right: Vänster/höger: Temperature Temperatur diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 70ff00f3d..300167a97 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3276,6 +3276,15 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Tate-chu-yoko Ayarları… Tate-chu-yoko Settings Tate-chu-yoko Ayarları + Composite Fonts… Bileşik Fontlar… + Composite Fonts Bileşik Fontlar + Export Composite Fonts Bileşik Fontları Dışa Aktar + Import Composite Fonts Bileşik Fontları İçe Aktar + Delete Font Fontu Sil + Kanji Kanji + Kana Kana + Numerals Rakamlar + Scale at Center Merkezden Ölçekle Up/Down: Yukarı/Aşağı: Left/Right: Sol/Sağ: Temperature Sıcaklık diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c91061845..4ad4f5b4d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3492,6 +3492,15 @@ effect Points Точки Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Параметри Tate-chu-yoko… Tate-chu-yoko Settings Параметри Tate-chu-yoko + Composite Fonts… Складені шрифти… + Composite Fonts Складені шрифти + Export Composite Fonts Експорт складених шрифтів + Import Composite Fonts Імпорт складених шрифтів + Delete Font Видалити шрифт + Kanji Кандзі + Kana Кана + Numerals Цифри + Scale at Center Масштабувати від центру Up/Down: Вгору/вниз: Left/Right: Ліворуч/праворуч: Temperature Температура diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 3c97189d6..b493f9c64 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3826,6 +3826,15 @@ mark-style Japanese Kiểu Nhật Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Cài đặt Tate-chu-yoko… Tate-chu-yoko Settings Cài đặt Tate-chu-yoko + Composite Fonts… Phông chữ tổ hợp… + Composite Fonts Phông chữ tổ hợp + Export Composite Fonts Xuất phông chữ tổ hợp + Import Composite Fonts Nhập phông chữ tổ hợp + Delete Font Xóa phông chữ + Kanji Kanji + Kana Kana + Numerals Chữ số + Scale at Center Co giãn từ tâm Up/Down: Lên/Xuống: Left/Right: Trái/Phải: Temperature Nhiệt độ màu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3ac228eee..99e5223df 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3331,6 +3331,15 @@ digits Malayalam 马拉雅拉姆数字 Tate-chu-yoko 纵中横 Tate-chu-yoko Settings… 纵中横设置… Tate-chu-yoko Settings 纵中横设置 + Composite Fonts… 复合字体… + Composite Fonts 复合字体 + Export Composite Fonts 导出复合字体 + Import Composite Fonts 导入复合字体 + Delete Font 删除字体 + Kanji 汉字 + Kana 假名 + Numerals 数字 + Scale at Center 从中心缩放 Up/Down: 上/下: Left/Right: 左/右: Temperature 色温 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3a18628cb..eb0cf06b1 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4371,6 +4371,15 @@ digits Malayalam 馬來亞拉姆數字 Tate-chu-yoko 縱中橫 Tate-chu-yoko Settings… 縱中橫設定… Tate-chu-yoko Settings 縱中橫設定 + Composite Fonts… 複合字體… + Composite Fonts 複合字體 + Export Composite Fonts 匯出複合字體 + Import Composite Fonts 匯入複合字體 + Delete Font 刪除字體 + Kanji 漢字 + Kana 假名 + Numerals 數字 + Scale at Center 從中心縮放 Up/Down: 上/下: Left/Right: 左/右: Temperature 色溫 diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 0d2ab4df7..a03118aa2 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -101,6 +101,7 @@ pub fn opens_as(name: &str) -> Option { (vectorcraft_engine::cmd::printpresets::PRESET_EXTS, "print.presets.import", "print presets"), (vectorcraft_engine::cmd::perspgrid::PRESET_EXTS, "perspective.presets.import", "perspective grid presets"), (fileio::SCREEN_EXPORT_PRESET_EXTS, "document.exportPresets.import", EXPORT_PRESETS), + (vectorcraft_engine::cmd::compositefonts::EXTS, "type.compositeFonts.import", "composite fonts"), ]; if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) { Some(OpensAs::Plugin) diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index 98e52a2c3..e9860e99a 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -129,6 +129,7 @@ const FILE_TYPES: &[(&str, &[&str])] = &[ ("Print presets", vectorcraft_engine::cmd::printpresets::PRESET_EXTS), ("Action sets", &[crate::panels::actions::ACTIONS_EXT]), ("Export presets", fileio::SCREEN_EXPORT_PRESET_EXTS), + ("Composite fonts", vectorcraft_engine::cmd::compositefonts::EXTS), ("CSS", &["css"]), ("PostScript", &["ps"]), ]; diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index a29714b1a..cbe983416 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -267,6 +267,13 @@ fn open_view_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool { let _ = app.run("ui.paramDialog", json!({"command": id, "label": "New View", "params": {"name": name}})); return true; } + // Composite Fonts…: the document's composite fonts, to edit before OK sets them. + if id == crate::dialogs::composite_fonts::COMMAND && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::composite_fonts::open(app) { + app.status(e); + } + return true; + } // Edit Views…: the saved views in a list, to rename or delete. if id == "view.saved.edit" && p.as_object().is_none_or(|o| o.is_empty()) { if let Err(e) = crate::dialogs::edit_views::open(app) { diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index ff5ccda13..1c09d3899 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -135,12 +135,19 @@ fn swapped_out(app: &VectorcraftApp, id: &str) -> bool { enabled(app, "object.envelope.release") != reset } +/// Menu items shown only with Preferences › Type › Show East Asian Options on: Type › Composite +/// Fonts…. +fn east_asian_hidden(app: &VectorcraftApp, id: &str) -> bool { + id == crate::dialogs::composite_fonts::COMMAND && !app.session.prefs.show_east_asian_options +} + /// How the menus show item `id` (params `p`) now: (enabled, checked: `Some` for an item that is on -/// or off), or `None` while they leave it out: an empty slot ([`hidden_when_disabled`]) or an item -/// another stands in for ([`swapped_out`]). +/// or off), or `None` while they leave it out: an empty slot ([`hidden_when_disabled`]), an item +/// another stands in for ([`swapped_out`]) or an East Asian item with those options hidden +/// ([`east_asian_hidden`]). pub fn shown_state(app: &VectorcraftApp, id: &str, p: &Value) -> Option<(bool, Option)> { let en = enabled(app, id) && (id != crate::addons::select::link_status::COMMAND || crate::addons::select::link_status::enabled(app, p)); - if (!en && hidden_when_disabled(id)) || swapped_out(app, id) { + if (!en && hidden_when_disabled(id)) || swapped_out(app, id) || east_asian_hidden(app, id) { return None; } Some((en, checked(app, id, p))) diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 0df18da50..83f6d6db4 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -406,6 +406,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Fit Headline", "text.fitHeadline"), Sep, c("Find Font…", "type.findFont"), + c("Composite Fonts…", crate::dialogs::composite_fonts::COMMAND), sub( "Change Case", vec![ diff --git a/docs/mcp.md b/docs/mcp.md index d27e1c91a..a3f8bc00f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5833,6 +5833,53 @@ dialog open. {"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":4,"tateChuYoko":true,"tcyUpDown":2,"tcyLeftRight":-1}}} ``` +## Composite Fonts + +A composite font sets each class of character in a font of its own +([Creating composite fonts](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347)). +`type.compositeFonts.set {fonts: [{name, parts}]}` replaces the active document's composite fonts as one undo step, +and adds no step when nothing changes. `parts` holds `kanji` (the base font), `kana`, `punctuation`, `symbols`, `roman` +and `numerals`, each `{font, style?, size?, baseline?, hScale?, vScale?, scaleAtCenter?}`; a part left out copies the +`kanji` part. Which characters fall in each class is VectorCraft's own rule: Kanji takes ideographs, full-width +letters and digits and every character no other class takes; Kana takes hiragana and katakana of both widths; +Punctuation takes East Asian and full-width punctuation, quotes and dashes; Symbols takes arrows, math, shapes and +other symbols; Roman takes half-width letters, their punctuation and spaces; Numerals takes the digits 0 to 9. + +`size` (10 to 400, 100 by default) and `baseline` (-100 to 100, 0 by default; positive raises) are percentages of the +type's size, VectorCraft's own ranges. `hScale` and `vScale` (10 to 400, 100 by default) scale Kana, Roman and +Numerals only, and `scaleAtCenter` (Kana only) scales kana from the center of the em box instead of the baseline. A +value out of range, a scale on another class, an unknown class or key, a name that is empty, longer than 255 +characters, shared by two fonts (in any case) or the name of an installed font, and a part set in a composite font are +each refused with an error, and nothing changes. A document keeps at most 256. + +Type set in a composite font names it as its family (`text.setStyle {font: "My Composite"}`), and the font menus list +composite fonts first. The layout sets each character in its class's font, a letter and its combining marks together. +A composite font taken out of the document leaves its type naming it, and the type shows as missing. Composite fonts +are saved with the document. SVG and legacy `.ai` write type in one of the document's composite fonts as outlines, and +SVG, PDF, EPS and legacy `.ai` export draw it in the font's parts also when the document was read without an open app. +`type.compositeFonts.list` returns the document's composite fonts. + +`text.replaceFont` and `addon.cleanup.replaceFonts` with a composite font as the new font set the type in it by its own +name, and the type keeps its style. `text.fonts` lists a composite font by its name; its `status` is the worst among the +parts its characters are drawn in, and `missingGlyphs` counts the characters that their part's font lacks. +`text.missingFonts`, File › Package and the warnings about fonts that aren't available name the part fonts that the +characters are drawn in. `text.glyphAlternates` lists the alternates of a character from its part's font. + +`type.compositeFonts.export {path?, names?, fonts?}` writes the document's composite fonts (all, or those `names` +lists), or the `fonts` given, to a `.vccomposite` file; without `path` it returns the file's text as `data`. +`type.compositeFonts.import {path | data | dataBase64, apply?}` reads a file of at most 4 MB. With `apply` (the +default) its fonts join the document's, replacing a font of the same name, as one undo step; with `apply: false` it +only returns them. Opening a `.vccomposite` file imports it. + +Type › Composite Fonts… (shown with Show East Asian Options) opens the Composite Fonts dialog on the document's fonts: +Font picks one, and a table sets each class's font, style, size, baseline and scales. New adds a font based on the one +picked, Delete Font takes it out, Import… adds the fonts of a file and Export… writes the one picked. OK runs +`type.compositeFonts.set` with every font as one undo step; a refused font keeps the dialog open. + +```json +{"name":"run_command","arguments":{"command":"type.compositeFonts.set","params":{"fonts":[{"name":"My Composite","parts":{"kanji":{"font":"Source Han Sans"},"roman":{"font":"Source Sans 3","size":95,"baseline":-2},"numerals":{"font":"Source Sans 3"}}}]}}} +``` + ## Optical Margin Alignment `type.opticalMarginAlignment {on?: bool}` (Type › Optical Margin Alignment) turns Optical Margin Alignment on or off diff --git a/docs/type-parity.md b/docs/type-parity.md index dcf0eeaed..b7e7fffab 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -68,6 +68,7 @@ against Illustrator's public documentation; **estimated** unless noted. | CJK: East Asian Type units (Q, Ha) | done | Preferences › Units › East Asian Type (`unitsAsianType`, Points by default) offers every unit and Q and Ha, a quarter of a millimetre each ([Scripting Guide](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/): 0.709 pt per Q; [InDesign's unit notation](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html): `q`, `h`). The public pages do not say which fields follow it, so no field uses it yet and that waits for a source. Evidence: `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` | | CJK: manual tate-chu-yoko | done | Character panel menu › Tate-chu-yoko (`text.setFormat` / `text.setRangeStyle {tateChuYoko}`), with Show East Asian Options: marked characters stand side by side across one em of a vertical column, squeezed to the em when wider; two or three half-width digits still do so without the mark; horizontal type keeps the mark and does not move them. Tate-chu-yoko Settings… (`ui.tateChuYokoSettings`, `{tcyUpDown, tcyLeftRight}` in points) moves a marked block up or down and right or left as one piece. Evidence: `tests_vertical::manual_tate_chu_yoko_sets_marked_characters_across_one_em`, `tests_vertical::tate_chu_yoko_settings_move_a_marked_block_up_down_left_and_right`, `typecmd::tate_chu_yoko_is_a_character_attribute_saved_only_when_on`, `typecmd::tate_chu_yoko_settings_are_character_attributes_saved_only_when_set`, `dialogs::tate_chu_yoko::tests` | | CJK: Standard Vertical Roman Alignment | done | Character panel menu › Standard Vertical Roman Alignment (`text.setFormat` / `text.setRangeStyle {standardVerticalRoman}`), with Show East Asian Options, off by default: in vertical type the marked half-width characters (Latin letters, digits and other characters that would lie on their side) stand upright one below the other, each in its upright cell; spaces keep their width and tate-chu-yoko blocks stay across the column; horizontal type keeps the mark and does not change. Character Style Options has its own checkbox for it. Evidence: `tests_vertical::standard_vertical_roman_alignment_stands_half_width_characters_upright`, `typecmd::standard_vertical_roman_is_a_character_attribute_saved_only_when_on` | +| CJK: composite fonts | partial | Type › Composite Fonts… (`type.compositeFonts.set`), with Show East Asian Options: a font made of a font per class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals), each with its own style, size and baseline in percent of the type's size, Kana, Roman and Numerals with horizontal and vertical scale, and Kana with Scale at Center; New, Delete Font, Import… and Export… (`.vccomposite`, `type.compositeFonts.import` and `.export`) ([Creating composite fonts](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347)). Composite fonts are listed first in the font menus, saved with the document, and their type is laid out per class; SVG and legacy `.ai` write it as outlines. Not yet: custom character sets, the Units menu, Show Sample. Evidence: `text::composite::tests::characters_fall_in_their_classes`, `tests::composite::composite_type_draws_each_class_in_its_font`, `compositefonts::tests`, `dialogs::composite_fonts::tests` | | CJK: ruby | partial | ruby (ふりがな) for live text (`type.ruby`): a reading over a byte range, above (right of the column in vertical type) or below, start/centre/end alignment, size, overhang or squeeze; saved natively, laid out with its line growing, drawn on the canvas, in SVG and PDF (as outlines where ruby is present), in EPS, EMF/WMF and legacy `.ai` (legacy `.ai` writes type with ruby as outlines), and kept by Create Outlines, Expand Appearance and flattening, each glyph where the layout puts it (`typecmd::ruby_tests::create_outlines_keeps_the_ruby_where_it_is_laid_out`) | #633 | | Language per character (Character panel › Language, character styles), text shaped in it | done | `text.setFormat` / `text.setRangeStyle {language}` by name or BCP 47 tag, one of Document Setup's languages (Hindi, Marathi, Gujarati, Bengali, Punjabi, Tamil, Telugu, Kannada, Malayalam, Odia and Urdu added, #4943; Assamese and Sanskrit added, #4947; Serbian: Cyrillic, Serbian: Latin and Croatian); the shaper gets the tag, so the font's language forms apply (OpenType `locl`: Turkish keeps `fi` apart and the dot on its i, #4940); new type takes the document's language; saved as the tag only when set, carried by character styles (#4946). English and eleven Indian languages hyphenate by run language; Sanskrit automatic hyphenation awaits allowlisted patterns (#4947). Not yet: spelling by it, `.ai`/PDF import of a text's language, paragraph styles. Evidence: `features::tests::language_picks_the_fonts_forms`, `features::tests::serbian_gets_the_fonts_serbian_forms`, `typecmd::area_tests::language_is_a_character_attribute_saved_only_when_set`, `tests::typo::indic_language_runs_hyphenate_with_both_composers`, `hyphen::tests::sanskrit_without_allowlisted_patterns_uses_only_exceptions` | | Language per character (Character panel › Language, character styles), text shaped in it | done | `text.setFormat` / `text.setRangeStyle {language}` by name or BCP 47 tag, one of Document Setup's languages (Hindi, Marathi, Gujarati, Bengali, Punjabi, Tamil, Telugu, Kannada, Malayalam, Odia and Urdu added, #4943; Serbian: Cyrillic, Serbian: Latin and Croatian; Bulgarian, #5945); the shaper gets the tag, so the font's language forms apply (OpenType `locl`: Turkish keeps `fi` apart and the dot on its i, #4940); new type takes the document's language; saved as the tag only when set, carried by character styles (#4946). Not yet: hyphenation and spelling by it, `.ai`/PDF import of a text's language, paragraph styles. Evidence: `features::tests::language_picks_the_fonts_forms`, `features::tests::serbian_gets_the_fonts_serbian_forms`, `typecmd::area_tests::language_is_a_character_attribute_saved_only_when_set` | From c2f2c8ae9b55175a4d7ecc227a4f3a7dc3d77847 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 9 Nov 2026 03:14:41 +0700 Subject: [PATCH 2/2] Find Font: a composite font's row is listed by its name alone --- crates/ui-egui/src/find_font.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/find_font.rs b/crates/ui-egui/src/find_font.rs index d63905e48..0c17c6e92 100644 --- a/crates/ui-egui/src/find_font.rs +++ b/crates/ui-egui/src/find_font.rs @@ -1087,7 +1087,8 @@ mod tests { let system = replacement_rows(&app, "system", &used); assert!(system.iter().any(|r| r.family == "Source Sans 3" && r.style == "Regular"), "{system:?}"); assert!(!system.iter().any(|r| r.family.starts_with('.')), "the font menu's hidden families stay out"); - assert!(system.iter().all(|r| !r.style.is_empty()), "no nameless rows: {system:?}"); + // A composite font is a family without styles of its own, listed by its name alone. + assert!(system.iter().all(|r| !r.style.is_empty() || db.styles(&r.family).is_empty()), "no nameless rows: {system:?}"); let menu = db.menu_family_list(); let listed: std::collections::BTreeSet<&str> = system.iter().map(|r| r.family.as_str()).collect(); assert_eq!(listed.len(), menu.len(), "every family the font menu shows is listed");