Type: composite fonts take custom character sets

Type > Composite Fonts... has Custom: named character sets, each setting
the characters it lists in its own font, style, size, baseline and
scales ahead of their class. The later of two sets that list a character
sets it. Characters are added by Direct Entry or by Unicode code and
deleted one at a time, and the parts table scrolls when it grows.

- type.compositeFonts.set takes `custom`; part keys left out take the
  Kanji part's values. Sets are saved with the document and in
  .vccomposite files, and Export refuses a file over 4 MB.
- VectorCraft's own limits: 64 sets per font, 8,192 characters per set,
  each character once per set, up to U+FFFF and no control characters.
- The font database caches each font's character lookup.

Docs: docs/mcp.md, docs/type-parity.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-14 22:16:08 -08:00
parent 0c1ac3bdc5
commit 18fe65acd6
44 changed files with 1023 additions and 140 deletions
+1 -1
View File
@@ -128,7 +128,7 @@ pub use text::{
RubyPosition, RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap,
VerticalAlign, WrapShape,
};
pub use text::{CharClass, CompositeFont, CompositePart, CompositeParts};
pub use text::{CharClass, CompositeClass, CompositeFont, CompositePart, CompositeParts, CustomCharSet};
pub use trace::TraceView;
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
pub use vectorcraft_color as color;
+6 -1
View File
@@ -105,8 +105,13 @@ impl Document {
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();
let custom = |f: &crate::CompositeFont| {
vec_bytes(&f.custom) + f.custom.iter().map(|s| s.name.capacity() + s.characters.capacity() + part(&s.part)).sum::<usize>()
};
vec_bytes(c)
+ c.iter().map(|f| f.name.capacity() + crate::CharClass::ALL.into_iter().map(|k| part(f.parts.get(k))).sum::<usize>()).sum::<usize>()
+ c.iter()
.map(|f| f.name.capacity() + crate::CharClass::ALL.into_iter().map(|k| part(f.parts.get(k))).sum::<usize>() + custom(f))
.sum::<usize>()
});
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::<usize>()
+2 -2
View File
@@ -22,8 +22,8 @@ pub use char_style::{
WARICHU_LINES, WARICHU_SCALE, WarichuAlign,
};
pub use composite::{
COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CharClass, CompositeFont, CompositePart,
CompositeParts,
COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CUSTOM_CHARS_MAX, CUSTOM_SETS_MAX, CharClass,
CompositeClass, CompositeClasses, CompositeFont, CompositePart, CompositeParts, CustomCharSet,
};
pub use para_style::{
Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, KinsokuShoriType, LeadingModel,
+247 -26
View File
@@ -1,11 +1,16 @@
//! 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.
//! of their own, sized, raised and scaled against it). Custom character sets give characters the
//! user picks a font of their own too, ahead of their class; of two sets that list a character,
//! the later one sets it. Type set in a composite font names it as its family; the layout sets each
//! character in its set's or its class's font.
//!
//! Behavior reference (public documentation):
//! <https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347>.
//! Which characters fall in which class, the ranges and the defaults are VectorCraft's own.
//! <https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347>,
//! custom character sets:
//! <https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=348>.
//! Which characters fall in which class, the ranges, the limits and the defaults are VectorCraft's
//! own.
use serde::{Deserialize, Serialize};
@@ -179,12 +184,76 @@ impl CompositeParts {
}
}
/// A custom character set of a composite font: characters chosen by the user, set in a part of
/// their own instead of their class's part.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CustomCharSet {
/// The set's name, unique in its composite font in any case.
pub name: String,
/// The set's characters, each once, in the order they were added.
#[serde(default)]
pub characters: String,
/// The font, style, size, baseline and scales its characters take.
#[serde(flatten)]
pub part: CompositePart,
}
impl CustomCharSet {
/// An empty set named `name`, its characters set in `part`.
pub fn new(name: impl Into<String>, part: CompositePart) -> Self {
Self { name: name.into(), characters: String::new(), part }
}
/// Whether `c` can be in a custom character set: not a control character, and in the Basic
/// Multilingual Plane (U+0000 to U+FFFF).
pub fn takes(c: char) -> bool {
!c.is_control() && (c as u32) <= 0xFFFF
}
/// The character a Unicode code names: hexadecimal digits, optionally after `U+` or `0x`
/// (`3042`, `U+3042`). `None` for anything else or a code [`Self::takes`] refuses.
pub fn char_from_code(code: &str) -> Option<char> {
let code = code.trim();
let hex = ["U+", "u+", "0x", "0X"].iter().find_map(|p| code.strip_prefix(p)).unwrap_or(code);
if hex.is_empty() || hex.len() > 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
u32::from_str_radix(hex, 16).ok().and_then(char::from_u32).filter(|&c| Self::takes(c))
}
}
/// Where a composite font sets a character: the part of one of its six classes, or the part of
/// one of its custom character sets (an index into [`CompositeFont::custom`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CompositeClass {
Fixed(CharClass),
Custom(usize),
}
/// The custom character set each character of a composite font's sets falls in
/// ([`CompositeFont::classes`]): the last set that lists it.
#[derive(Clone, Debug, Default)]
pub struct CompositeClasses {
custom: std::collections::HashMap<char, usize>,
}
impl CompositeClasses {
/// Where the composite font sets `c`: the last custom set that lists it, else its class.
pub fn of(&self, c: char) -> CompositeClass {
self.custom.get(&c).map_or(CompositeClass::Fixed(CharClass::of(c)), |&i| CompositeClass::Custom(i))
}
}
/// 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,
/// Custom character sets, checked before the classes. A character two sets list is set in the
/// later set's part.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub custom: Vec<CustomCharSet>,
}
/// The size a part can take, in percent of the type's size.
@@ -197,11 +266,40 @@ pub const COMPOSITE_SCALE: std::ops::RangeInclusive<f64> = 10.0..=400.0;
pub const COMPOSITE_NAME_MAX: usize = 255;
/// The most composite fonts a document or a file keeps.
pub const COMPOSITE_FONTS_MAX: usize = 256;
/// The most custom character sets a composite font keeps.
pub const CUSTOM_SETS_MAX: usize = 64;
/// The most characters a custom character set keeps.
pub const CUSTOM_CHARS_MAX: usize = 8192;
impl CompositeFont {
/// A composite font named `name` setting every class in `font` and `style`.
pub fn new(name: impl Into<String>, font: impl Into<String>, style: impl Into<String>) -> Self {
Self { name: name.into(), parts: CompositeParts::all(CompositePart::new(font, style)) }
Self { name: name.into(), parts: CompositeParts::all(CompositePart::new(font, style)), custom: Vec::new() }
}
/// The part that sets the characters of `class`, `None` for a custom set the font doesn't have.
pub fn part(&self, class: CompositeClass) -> Option<&CompositePart> {
match class {
CompositeClass::Fixed(c) => Some(self.parts.get(c)),
CompositeClass::Custom(i) => self.custom.get(i).map(|s| &s.part),
}
}
/// Where this font sets character `c`: the last custom set that lists it, else its class. For
/// many characters, [`Self::classes`] looks each up faster.
pub fn class_of(&self, c: char) -> CompositeClass {
self.custom.iter().rposition(|s| s.characters.contains(c)).map_or(CompositeClass::Fixed(CharClass::of(c)), CompositeClass::Custom)
}
/// The custom set each character of this font's sets falls in, to look up many characters.
pub fn classes(&self) -> CompositeClasses {
let mut custom = std::collections::HashMap::new();
for (i, s) in self.custom.iter().enumerate() {
for c in s.characters.chars() {
custom.insert(c, i);
}
}
CompositeClasses { custom }
}
/// Do `a` and `b` name the same composite font: equal in any case?
@@ -223,36 +321,50 @@ impl CompositeFont {
}
/// 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.
/// part without a font, a value out of its range, a scale or Scale at Center on a class that
/// has none, or a custom character set [`Self::check_custom`] refuses.
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"));
check_part(self.parts.get(class), class.key(), class.scales(), class == CharClass::Kana)?;
}
self.check_custom()
}
/// Why this font's custom character sets can't be used, if they can't: more than
/// [`CUSTOM_SETS_MAX`], a name [`Self::clean_name`] refuses or two sets share (in any case), a
/// character [`CustomCharSet::takes`] refuses or a set lists twice, more than
/// [`CUSTOM_CHARS_MAX`] characters in a set, or a part [`Self::check`] would refuse. A set's
/// characters can be scaled across and up; only kana are scaled at the center.
pub fn check_custom(&self) -> Result<(), String> {
if self.custom.len() > CUSTOM_SETS_MAX {
return Err(format!("a composite font keeps at most {CUSTOM_SETS_MAX} character sets"));
}
let mut names = std::collections::HashSet::new();
for s in &self.custom {
let name = &s.name;
if Self::clean_name(name).as_deref() != Some(name.as_str()) {
return Err(format!("`{name}` can't name a character set"));
}
let within = |v: f64, r: &std::ops::RangeInclusive<f64>| 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 !names.insert(name.to_lowercase()) {
return Err(format!("two character sets are named `{name}`"));
}
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()));
let mut seen = std::collections::HashSet::new();
for (n, c) in s.characters.chars().enumerate() {
if n == CUSTOM_CHARS_MAX {
return Err(format!("`{name}`: a character set keeps at most {CUSTOM_CHARS_MAX} characters"));
}
if v != 100.0 && !class.scales() {
return Err(format!("{k}: only kana, roman and numerals can be scaled"));
let code = c as u32;
if !CustomCharSet::takes(c) {
return Err(format!("`{name}`: U+{code:04X} is a control character or past U+FFFF"));
}
if !seen.insert(c) {
return Err(format!("`{name}`: U+{code:04X} is listed twice"));
}
}
if p.scale_at_center && class != CharClass::Kana {
return Err(format!("{k}: only kana can be scaled at the center"));
}
check_part(&s.part, name, true, false)?;
}
Ok(())
}
@@ -264,9 +376,41 @@ impl CompositeFont {
let mut names = std::collections::HashSet::new();
let mut kept: Vec<CompositeFont> =
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.retain(|f| f.part_fonts().all(|font| !names.contains(&font.to_lowercase())));
(kept.len() != fonts.len()).then_some(kept)
}
/// The fonts of the parts, each class's then each custom set's.
pub fn part_fonts(&self) -> impl Iterator<Item = &str> {
CharClass::ALL.into_iter().map(|c| self.parts.get(c).font.as_str()).chain(self.custom.iter().map(|s| s.part.font.as_str()))
}
}
/// Why part `p` (of the class or set named `k`) can't be used, if it can't: no font, a value out
/// of its range, a scale when `scales` is false, or Scale at Center when `center` is false.
fn check_part(p: &CompositePart, k: &str, scales: bool, center: bool) -> Result<(), String> {
if p.font.trim().is_empty() {
return Err(format!("{k}: give a font"));
}
let within = |v: f64, r: &std::ops::RangeInclusive<f64>| 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 && !scales {
return Err(format!("{k}: only kana, roman and numerals can be scaled"));
}
}
if p.scale_at_center && !center {
return Err(format!("{k}: only kana can be scaled at the center"));
}
Ok(())
}
#[cfg(test)]
@@ -341,4 +485,81 @@ mod tests {
spaced.name = " Mixed".into();
assert!(spaced.check().is_err());
}
/// A character a custom set lists falls in that set ahead of its class, the later of two sets
/// that list it wins, and the quick lookup agrees with the one-character one.
#[test]
fn custom_sets_take_their_characters_before_the_classes() {
let mut f = CompositeFont::new("Mixed", "Inter", "Regular");
let mut a = CustomCharSet::new("Marks", CompositePart::new("Source Sans 3", "Bold"));
a.characters = "※A".into();
let mut b = CustomCharSet::new("Letters", CompositePart::new("Inter", "Italic"));
b.characters = "AB".into();
f.custom = vec![a, b];
let classes = f.classes();
for (c, want) in [
('※', CompositeClass::Custom(0)),
('A', CompositeClass::Custom(1)),
('B', CompositeClass::Custom(1)),
('C', CompositeClass::Fixed(CharClass::Roman)),
('漢', CompositeClass::Fixed(CharClass::Kanji)),
] {
assert_eq!(f.class_of(c), want, "{c:?}");
assert_eq!(classes.of(c), want, "{c:?}");
}
assert_eq!(f.part(CompositeClass::Custom(0)).map(|p| p.style.as_str()), Some("Bold"));
assert_eq!(f.part(CompositeClass::Custom(2)), None);
assert_eq!(f.check(), Ok(()));
// Read and written with the part's fields beside the set's name and characters.
let json = serde_json::to_value(&f).unwrap();
assert_eq!(json["custom"][1]["characters"], "AB");
assert_eq!(json["custom"][1]["style"], "Italic");
assert_eq!(serde_json::from_value::<CompositeFont>(json).unwrap(), f);
// A font without sets is written as before.
assert!(serde_json::to_value(CompositeFont::new("Plain", "Inter", "Regular")).unwrap().get("custom").is_none());
}
/// Set names, characters and limits are checked; sets scale like Roman letters.
#[test]
fn custom_sets_check_their_names_and_characters() {
let ok = CompositeFont::new("Mixed", "Inter", "Regular");
let with = |sets: Vec<(&str, &str)>, edit: fn(&mut CompositePart)| {
let mut f = ok.clone();
for (name, chars) in sets {
let mut s = CustomCharSet::new(name, CompositePart::new("Inter", "Regular"));
s.characters = chars.into();
edit(&mut s.part);
f.custom.push(s);
}
f.check()
};
let none = |_: &mut CompositePart| {};
assert_eq!(with(vec![("Empty", "")], none), Ok(()));
assert_eq!(with(vec![("Wide", "AB")], |p| p.h_scale = 80.0), Ok(()));
assert!(with(vec![("Wide", "AB")], |p| p.scale_at_center = true).is_err());
assert!(with(vec![("Big", "A")], |p| p.size = 401.0).is_err());
assert!(with(vec![("Same", "A"), ("same", "B")], none).unwrap_err().contains("two character sets"));
assert!(with(vec![(" Pad", "A")], none).is_err());
assert!(with(vec![("Twice", "ABA")], none).unwrap_err().contains("U+0041 is listed twice"));
assert!(with(vec![("Tab", "A\t")], none).unwrap_err().contains("U+0009"));
assert!(with(vec![("Astral", "𠀋")], none).unwrap_err().contains("U+2000B"));
let many: String = (0x4E00u32..).filter_map(char::from_u32).take(CUSTOM_CHARS_MAX + 1).collect();
let mut f = ok.clone();
f.custom.push(CustomCharSet { name: "Many".into(), characters: many, part: CompositePart::new("Inter", "Regular") });
assert!(f.check().unwrap_err().contains("at most"));
let mut f = ok.clone();
f.custom = (0..=CUSTOM_SETS_MAX).map(|i| CustomCharSet::new(format!("S{i}"), CompositePart::new("Inter", "Regular"))).collect();
assert!(f.check().is_err());
}
/// Codes are hexadecimal, with or without `U+` or `0x`, inside the Basic Multilingual Plane.
#[test]
fn custom_set_codes_name_characters() {
assert_eq!(CustomCharSet::char_from_code("3042"), Some('あ'));
assert_eq!(CustomCharSet::char_from_code(" U+203B "), Some('※'));
assert_eq!(CustomCharSet::char_from_code("0x41"), Some('A'));
for bad in ["", "U+", "zz", "20000B", "D800", "0009", "1F600", "1234567", "+41"] {
assert_eq!(CustomCharSet::char_from_code(bad), None, "{bad:?}");
}
}
}
+67 -15
View File
@@ -1,15 +1,18 @@
//! 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.
//! font of its own, and the characters of each custom character set in the set's font. 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):
//! <https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347>.
//! <https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347>,
//! custom character sets:
//! <https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=348>.
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_doc::text::{COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, CUSTOM_CHARS_MAX, CUSTOM_SETS_MAX};
use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CompositeParts, CustomCharSet};
use vectorcraft_text::FontDb;
use super::fileio::{read_file_head, write_file};
@@ -26,6 +29,10 @@ 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"];
/// The keys a custom character set may give: its name and characters, then its part's values
/// (a set is never scaled at the center).
const SET_KEYS: [&str; 8] = ["name", "characters", "font", "style", "size", "baseline", "hScale", "vScale"];
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -33,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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}}}]}",
"{} 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}}, custom?: [{name, characters, font, style, size, baseline, hScale, vScale}]}]}",
has_doc,
list
),
@@ -42,7 +49,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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}",
"{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}}, custom?: [{name, characters?: string = \"\", font?, style?, size?, baseline?, hScale?, vScale?}]}]} 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. custom lists custom character sets: each sets the characters it lists in its own font, style, size, baseline and scales (values left out are the kanji part's), ahead of their class; a character two sets list is set by the later one. A set's name is trimmed and unique in the font in any case; its characters are each listed once, at most 8192, none a control character or past U+FFFF; at most 64 sets. 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
),
@@ -51,7 +58,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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}",
"{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 (a file over 4 MB is refused); without path the file's text comes back as data → {path, count} | {data, count}",
has_doc,
export
),
@@ -121,8 +128,8 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
/// copy of the Kanji part, and keys or values a part can't take refused.
fn parse_font(v: &Value, c: &str) -> Result<CompositeFont> {
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")));
if let Some(k) = o.keys().find(|k| !["name", "parts", "custom"].contains(&k.as_str())) {
return Err(bad(c, format!("unknown key `{k}`; a font takes name, parts and custom")));
}
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)
@@ -140,7 +147,17 @@ fn parse_font(v: &Value, c: &str) -> Result<CompositeFont> {
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) };
let custom = match o.get("custom") {
None => Vec::new(),
Some(Value::Array(sets)) => {
if sets.len() > CUSTOM_SETS_MAX {
return Err(bad(c, format!("`{name}`: a composite font keeps at most {CUSTOM_SETS_MAX} character sets")));
}
sets.iter().map(|s| parse_set(s, &name, &kanji, c)).collect::<Result<Vec<_>>>()?
}
Some(_) => return Err(bad(c, format!("`{name}`: custom must be a list of character sets"))),
};
let mut f = CompositeFont { name: name.clone(), parts: CompositeParts::all(kanji), custom };
for class in CharClass::ALL.into_iter().skip(1) {
if let Some(p) = part(class)? {
*f.parts.get_mut(class) = p;
@@ -150,6 +167,38 @@ fn parse_font(v: &Value, c: &str) -> Result<CompositeFont> {
Ok(f)
}
/// Custom character set `v` of composite font `font` from command parameters or a file: its name
/// trimmed, the part values it leaves out those of `kanji`, and keys or values it can't take
/// refused. [`CompositeFont::check`] checks its characters and values.
fn parse_set(v: &Value, font: &str, kanji: &CompositePart, c: &str) -> Result<CustomCharSet> {
let o = v.as_object().ok_or_else(|| bad(c, format!("`{font}`: each character set must be {{name, characters, font…}}")))?;
if let Some(k) = o.keys().find(|k| !SET_KEYS.contains(&k.as_str())) {
return Err(bad(c, format!("`{font}`: unknown key `{k}` in a character set")));
}
let raw = o.get("name").and_then(Value::as_str).ok_or_else(|| bad(c, format!("`{font}`: each character set needs a name")))?;
let name = CompositeFont::clean_name(raw)
.ok_or_else(|| bad(c, format!("`{raw}` can't name a character set (1 to {COMPOSITE_NAME_MAX} characters, no control characters)")))?;
let characters = match o.get("characters") {
None => String::new(),
Some(Value::String(s)) => s.clone(),
Some(_) => return Err(bad(c, format!("`{font}`: `{name}`: characters must be a string"))),
};
// Checked here as well as by `check`, so a huge list is refused before it is copied again.
if characters.chars().nth(CUSTOM_CHARS_MAX).is_some() {
return Err(bad(c, format!("`{font}`: `{name}`: a character set keeps at most {CUSTOM_CHARS_MAX} characters")));
}
let mut part = serde_json::to_value(kanji).map_err(|e| bad(c, e.to_string()))?;
if let Some(p) = part.as_object_mut() {
for k in &SET_KEYS[2..] {
if let Some(given) = o.get(*k) {
p.insert(k.to_string(), given.clone());
}
}
}
let part: CompositePart = serde_json::from_value(part).map_err(|e| bad(c, format!("`{font}`: `{name}`: {e}")))?;
Ok(CustomCharSet { name, characters, part })
}
/// 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<()> {
@@ -167,10 +216,10 @@ fn check_set(fonts: &[CompositeFont], c: &str) -> Result<()> {
}
}
for f in fonts {
for class in CharClass::ALL {
let font = &f.parts.get(class).font;
let classes = CharClass::ALL.into_iter().map(|class| (class.key(), &f.parts.get(class).font));
for (part, font) in classes.chain(f.custom.iter().map(|s| (s.name.as_str(), &s.part.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())));
return Err(bad(c, format!("`{}`: {} is set in `{font}`, a composite font", f.name, part)));
}
}
}
@@ -260,6 +309,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
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()))?;
if text.len() > MAX_FILE_BYTES {
return Err(bad(C, "the composite fonts make a file larger than 4 MB, which Import can't read; export fewer of them"));
}
match path {
Some(path) => {
write_file(path, text.as_bytes())?;
@@ -856,3 +856,127 @@ fn composite_fonts_export_and_import() {
assert!(s.execute("type.compositeFonts.export", &json!({"path": " "})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
/// `font(name)` with two custom character sets: "Marks" (x and A) in Inter at 150%, and "A only"
/// (A) in Source Sans 3, which sets A since it comes later.
fn font_with_sets(name: &str) -> Value {
let mut v = font(name);
v["custom"] = json!([
{"name": " Marks ", "characters": "xA", "font": "Inter", "size": 150, "hScale": 90},
{"name": "A only", "characters": "A"},
]);
v
}
/// Custom character sets are set with the font as one undo step, take values they leave out from
/// the Kanji part, set the characters they list ahead of their class (the later set winning), are
/// saved with the document, and measure type again when they change.
#[test]
fn custom_character_sets_set_their_characters() {
let mut s = session();
let before = steps(&s);
let r = s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets")]})).unwrap();
assert_eq!(r["fonts"][0]["custom"][0]["name"], "Marks");
assert_eq!(steps(&s), before + 1);
let f = s.doc().unwrap().doc.composite_fonts[0].clone();
assert_eq!(f.custom.len(), 2);
assert_eq!((f.custom[0].part.font.as_str(), f.custom[0].part.size, f.custom[0].part.h_scale), ("Inter", 150.0, 90.0));
assert_eq!((f.custom[1].part.font.as_str(), f.custom[1].part.size), ("Source Sans 3", 100.0));
// The same sets again change nothing.
s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets")]})).unwrap();
assert_eq!(steps(&s), before + 1);
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);
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "xAB", "size": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("text.setStyle", &json!({"ids": [id], "font": "CF Sets"})).unwrap();
let db = vectorcraft_text::FontDb::global();
let face = |family: &str| db.face(family, "Regular").unwrap().id();
let lay = vectorcraft_text::layout(db, &text(&s, id));
let fonts: Vec<u32> = lay.glyphs.iter().map(|g| g.font_id).collect();
assert_eq!(fonts, [face("Inter"), face("Source Sans 3"), face("JetBrains Mono")]);
// Taking x out of Marks sets it in its class's font again, measured in the same step.
let wide = text(&s, id).cached_bounds.unwrap().width();
let mut fewer = font_with_sets("CF Sets");
fewer["custom"][0]["characters"] = json!("A");
s.execute("type.compositeFonts.set", &json!({"fonts": [fewer]})).unwrap();
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);
}
/// Bad custom character sets are refused with an error and no undo step.
#[test]
fn bad_custom_character_sets_are_refused() {
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets Base")]})).unwrap();
let before = steps(&s);
let with = |f: &dyn Fn(&mut Value)| {
let mut v = font_with_sets("CF Sets Refused");
f(&mut v);
json!({"fonts": [v]})
};
let many: String = (0x4E00u32..).filter_map(char::from_u32).take(8193).collect();
let sets: Vec<Value> = (0..65).map(|i| json!({"name": format!("S{i}")})).collect();
for bad in [
with(&|v| v["custom"] = json!("Marks")),
with(&|v| v["custom"] = Value::Null),
with(&|v| v["custom"][0]["characters"] = Value::Null),
with(&|v| v["custom"][0] = json!("Marks")),
with(&|v| v["custom"][0]["name"] = json!(" ")),
with(&|v| {
v["custom"][0].as_object_mut().unwrap().remove("name");
}),
with(&|v| v["custom"][1]["name"] = json!("marks")),
with(&|v| v["custom"][0]["characters"] = json!(["※"])),
with(&|v| v["custom"][0]["characters"] = json!("※※")),
with(&|v| v["custom"][0]["characters"] = json!("A\n")),
with(&|v| v["custom"][0]["characters"] = json!("😀")),
with(&|v| v["custom"][0]["characters"] = json!(many.clone())),
with(&|v| v["custom"] = json!(sets.clone())),
with(&|v| v["custom"][0]["size"] = json!(5)),
with(&|v| v["custom"][0]["size"] = json!("50")),
with(&|v| v["custom"][0]["scaleAtCenter"] = json!(true)),
with(&|v| v["custom"][0]["scaleAtCenter"] = json!(false)),
with(&|v| v["custom"][0]["font"] = json!("")),
with(&|v| v["custom"][0]["font"] = json!("CF Sets Base")),
with(&|v| v["custom"][0]["width"] = json!(3)),
] {
let e = s.execute("type.compositeFonts.set", &bad);
assert!(e.is_err(), "accepted {bad}");
assert_eq!(names(&s), ["CF Sets Base"]);
assert_eq!(steps(&s), before);
}
// A set without characters is kept: it sets nothing.
let ok = with(&|v| v["custom"][0]["characters"] = json!(""));
s.execute("type.compositeFonts.set", &ok).unwrap();
assert_eq!(s.doc().unwrap().doc.composite_fonts[0].custom[0].characters, "");
}
/// Export writes the custom character sets and Import reads them back; a file whose set is bad is
/// refused.
#[test]
fn custom_character_sets_export_and_import() {
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets IO")]})).unwrap();
let data = s.execute("type.compositeFonts.export", &json!({})).unwrap();
let mut t = session();
t.execute("type.compositeFonts.import", &json!({"data": data["data"]})).unwrap();
assert_eq!(*t.doc().unwrap().doc.composite_fonts, *s.doc().unwrap().doc.composite_fonts);
let text = data["data"].as_str().unwrap().replace("\"A only\"", "\"marks\"");
let mut u = session();
assert!(u.execute("type.compositeFonts.import", &json!({"data": text})).is_err());
assert!(names(&u).is_empty());
// Fonts whose file would be larger than Import reads are refused.
let full: String = (0x4E00u32..).filter_map(char::from_u32).take(8192).collect();
let sets: Vec<Value> = (0..64).map(|i| json!({"name": format!("S{i}"), "characters": full})).collect();
let big: Vec<Value> = (0..3)
.map(|i| {
let mut f = font(&format!("CF Sets Big {i}"));
f["custom"] = json!(sets);
f
})
.collect();
let e = s.execute("type.compositeFonts.export", &json!({"fonts": big})).unwrap_err();
assert!(e.to_string().contains("4 MB"), "{e}");
}
+4 -4
View File
@@ -145,7 +145,7 @@ fn composite_font_parts_are_not_highlighted_as_substituted_glyphs() {
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 });
db.set_composite_font(&CompositeFont { name: "CF Highlight".into(), parts, custom: Vec::new() });
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);
@@ -175,7 +175,7 @@ fn a_documents_own_composite_font_draws_without_being_registered() {
let (mut composite, id) = typed(NAME);
let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular"));
parts.roman = CompositePart::new("JetBrains Mono", "Regular");
composite.composite_fonts.push(CompositeFont { name: NAME.into(), parts });
composite.composite_fonts.push(CompositeFont { name: NAME.into(), parts, custom: Vec::new() });
let (plain, plain_id) = typed("JetBrains Mono");
assert!(db.composite_font(NAME).is_none());
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
@@ -236,7 +236,7 @@ fn a_placed_document_draws_its_own_definition_of_a_name_the_page_defines() {
let definition = |roman: &str| {
let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular"));
parts.roman = CompositePart::new(roman, "Regular");
CompositeFont { name: NAME.into(), parts }
CompositeFont { name: NAME.into(), parts, custom: Vec::new() }
};
let typed = |family: &str| {
let mut d = Document::new(300.0, 150.0);
@@ -319,7 +319,7 @@ fn a_document_draws_type_in_another_documents_composite_font_as_missing() {
};
let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular"));
parts.roman = CompositePart::new("JetBrains Mono", "Regular");
db.set_composite_font(&CompositeFont { name: NAME.into(), parts });
db.set_composite_font(&CompositeFont { name: NAME.into(), parts, custom: Vec::new() });
let (drawn, as_missing, in_front) = (draw(&back, id), draw(&missing, missing_id), draw(&plain, plain_id));
db.remove_composite_font(NAME);
assert!(drawn.0 != in_front.0, "not in the front document's definition");
+3
View File
@@ -119,6 +119,9 @@ pub struct FontDb {
/// type naming one as its family is set in its parts' fonts. A thread's guards can lend others
/// in their place ([`FontDb::lend_composite_fonts`]).
composites: RwLock<HashMap<String, Arc<vectorcraft_doc::CompositeFont>>>,
/// The lookups of the custom character sets of the composite fonts held or lent, each built
/// when first used.
custom_classes: Mutex<composite::CustomClasses>,
}
/// Where a database's system font scan looks: folders (read with their subfolders) and font files.
+86 -33
View File
@@ -1,15 +1,18 @@
//! 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`]).
//! in the font of the custom character set that lists it, else of its class
//! ([`FontDb::composite_split`]).
use std::cell::RefCell;
use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::marker::PhantomData;
use std::sync::Weak;
use std::sync::atomic::{AtomicU64, Ordering};
use unicode_segmentation::UnicodeSegmentation;
use vectorcraft_doc::{CharClass, CompositeFont, TextObject, TextRun};
use vectorcraft_doc::text::CompositeClasses;
use vectorcraft_doc::{CharClass, CompositeClass, CompositeFont, CompositePart, TextObject, TextRun};
use super::face::FALLBACK_FAMILY;
use super::{Arc, FontDb, FontFace, FontMatch};
@@ -17,6 +20,13 @@ use super::{Arc, FontDb, FontFace, FontMatch};
/// A face [`FontDb::resolve`] found and how it matched.
type Resolved = Option<(Arc<FontFace>, FontMatch)>;
/// The base class: type without characters and anything no part names takes its part.
const KANJI: CompositeClass = CompositeClass::Fixed(CharClass::Kanji);
/// Lookups of the custom character sets of composite fonts, by the address of the font's `Arc`,
/// each with a weak reference that tells when that font has gone and the address may be reused.
pub(super) type CustomClasses = HashMap<usize, (Weak<CompositeFont>, Arc<CompositeClasses>)>;
thread_local! {
/// The composite fonts the guards of this thread lend ([`FontDb::lend_composite_fonts`]), the
/// latest loan last. Only the latest loan of a database is looked in.
@@ -41,15 +51,25 @@ struct Loan {
print: u64,
}
/// Feed composite font `f`'s name and parts to `h`.
/// Feed composite font `f`'s name, parts and custom character sets to `h`.
fn hash_font(f: &CompositeFont, h: &mut DefaultHasher) {
f.name.hash(h);
let p = &f.parts;
for part in [&p.kanji, &p.kana, &p.punctuation, &p.symbols, &p.roman, &p.numerals] {
(&part.font, &part.style, part.scale_at_center).hash(h);
for v in [part.size, part.baseline, part.h_scale, part.v_scale] {
v.to_bits().hash(h);
}
hash_part(part, h);
}
f.custom.len().hash(h);
for s in &f.custom {
(&s.name, &s.characters).hash(h);
hash_part(&s.part, h);
}
}
/// Feed part `part`'s font and values to `h`.
fn hash_part(part: &CompositePart, h: &mut DefaultHasher) {
(&part.font, &part.style, part.scale_at_center).hash(h);
for v in [part.size, part.baseline, part.h_scale, part.v_scale] {
v.to_bits().hash(h);
}
}
@@ -136,8 +156,8 @@ impl FontDb {
.unwrap_or(g)
}
/// 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.
/// The face composite font `name` sets character `c` in (its custom set's or 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()))
}
@@ -149,30 +169,58 @@ impl FontDb {
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.
/// The face composite font `name` sets character `c` in (its custom set's or 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<FontFace>, FontMatch)> {
let f = self.composite_font(name)?;
let (family, style) = self.part_font(&f, CharClass::of(c));
let (family, style) = self.part_font(&f, self.composite_class(&f, c));
self.resolve(&family, &style)
}
/// Where composite font `f` sets character `c`: the last custom set that lists it, else its
/// class. The lookup of `f`'s sets is built once and kept while `f` lives.
fn composite_class(&self, f: &Arc<CompositeFont>, c: char) -> CompositeClass {
if f.custom.is_empty() {
return CompositeClass::Fixed(CharClass::of(c));
}
self.custom_lookup(f).of(c)
}
/// The lookup of composite font `f`'s custom character sets, built when first asked for.
fn custom_lookup(&self, f: &Arc<CompositeFont>) -> Arc<CompositeClasses> {
if f.custom.is_empty() {
return Arc::default();
}
let key = Arc::as_ptr(f) as usize;
let mut cache = self.custom_classes.lock().unwrap_or_else(|e| e.into_inner());
if let Some((font, lookup)) = cache.get(&key)
&& font.upgrade().is_some_and(|g| Arc::ptr_eq(&g, f))
{
return lookup.clone();
}
cache.retain(|_, (font, _)| font.strong_count() > 0);
let lookup = Arc::new(f.classes());
cache.insert(key, (Arc::downgrade(f), lookup.clone()));
lookup
}
/// 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<Vec<(String, String)>> {
let f = self.composite_font(name)?;
let mut classes: Vec<CharClass> = Vec::new();
let lookup = self.custom_lookup(&f);
let mut classes: Vec<CompositeClass> = Vec::new();
for g in text.graphemes(true) {
let c = g.chars().next().map_or(CharClass::Kanji, CharClass::of);
let c = g.chars().next().map_or(KANJI, |c| lookup.of(c));
if !classes.contains(&c) {
classes.push(c);
}
}
if classes.is_empty() {
classes.push(CharClass::Kanji);
classes.push(KANJI);
}
let mut fonts: Vec<(String, String)> = Vec::with_capacity(classes.len());
for c in classes {
@@ -196,8 +244,9 @@ impl FontDb {
FontMatch::Style => 1,
FontMatch::Missing => 2,
};
let mut faces: Vec<(CharClass, Resolved)> = Vec::new();
let mut face_of = |class: CharClass| {
let lookup = self.custom_lookup(&f);
let mut faces: Vec<(CompositeClass, Resolved)> = Vec::new();
let mut face_of = |class: CompositeClass| {
if let Some((_, found)) = faces.iter().find(|(c, _)| *c == class) {
return found.clone();
}
@@ -213,7 +262,7 @@ impl FontDb {
graphemes.push("");
}
for g in graphemes {
let class = g.chars().next().map_or(CharClass::Kanji, CharClass::of);
let class = g.chars().next().map_or(KANJI, |c| lookup.of(c));
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)) {
@@ -307,10 +356,11 @@ impl FontDb {
LentCompositeFonts { number: pushed.then_some(number), thread: PhantomData }
}
/// 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);
/// The family and style composite font `f` sets characters of `class` in: its part's (the
/// Kanji part's for a custom set it doesn't have), or the fallback font's when that part names
/// a composite font.
fn part_font(&self, f: &CompositeFont, class: CompositeClass) -> (String, String) {
let p = f.part(class).unwrap_or(&f.parts.kanji);
if self.composite_font(&p.font).is_some() { (FALLBACK_FAMILY.to_string(), "Regular".to_string()) } else { (p.font.clone(), p.style.clone()) }
}
@@ -326,11 +376,13 @@ impl FontDb {
/// 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))
Some(self.part_font(&f, 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
/// `t` with each run set in a composite font split into runs of one custom character set or
/// [`CharClass`] each (a set listing a character takes it ahead of its class, the font's last
/// such set when several do), set in that set's or 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
@@ -352,10 +404,11 @@ impl FontDb {
from.push(i);
continue;
};
let lookup = self.custom_lookup(font);
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);
let c = g.chars().next().map_or(KANJI, |c| lookup.of(c));
if let Some(k) = class
&& k != c
{
@@ -365,16 +418,16 @@ impl FontDb {
}
class = Some(c);
}
runs.push(self.composite_run(r.text.get(start..).unwrap_or_default(), r, font, class.unwrap_or(CharClass::Kanji)));
runs.push(self.composite_run(r.text.get(start..).unwrap_or_default(), r, font, class.unwrap_or(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);
/// Characters `text` of run `r` (set in composite font `font`), all of class or set `class`,
/// as a run set in that class's or set's part.
fn composite_run(&self, text: &str, r: &TextRun, font: &CompositeFont, class: CompositeClass) -> TextRun {
let p = font.part(class).unwrap_or(&font.parts.kanji);
let mut st = r.style.clone();
let base = st.size;
let (family, style) = self.part_font(font, class);
@@ -386,7 +439,7 @@ impl FontDb {
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 {
if class == CompositeClass::Fixed(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);
}
+1
View File
@@ -74,6 +74,7 @@ impl FontDb {
stamp: Mutex::new(None),
instances: Mutex::new(Instances::default()),
composites: RwLock::new(HashMap::new()),
custom_classes: Mutex::new(HashMap::new()),
}
}
+45
View File
@@ -55,6 +55,51 @@ fn composite_runs_split_by_class() {
assert!(db.composite_split(&TextObject::point(Point::ZERO, "Ab", style("Inter", 12.0))).is_none());
}
/// A custom character set takes the characters it lists ahead of their class, the later of two
/// sets wins a character both list, and the set's part sizes, raises and scales them. The fonts
/// used for the text, how it is covered and the face of one character follow the sets too.
#[test]
fn custom_character_sets_take_their_characters_first() {
use vectorcraft_doc::{CompositePart, CustomCharSet};
let db = FontDb::with_font_dirs(vec![]);
let mut f = mixed("Custom");
let mut part = CompositePart::new("Inter", "Regular");
part.size = 150.0;
part.baseline = -10.0;
part.h_scale = 90.0;
let mut first = CustomCharSet::new("Letters", CompositePart::new("Source Sans 3", "Regular"));
first.characters = "Bか".into();
let mut second = CustomCharSet::new("B only", part);
second.characters = "B".into();
f.custom = vec![first, second];
db.set_composite_font(&f);
let t = TextObject::point(Point::ZERO, "ABか1", style("Custom", 20.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, [("A", "JetBrains Mono"), ("B", "Inter"), ("か", "Source Sans 3"), ("1", "Inter")]);
assert_eq!(from, [0, 0, 0, 0]);
let b = &split.runs[1].style;
assert_eq!((b.size, b.baseline_shift, b.h_scale, b.v_scale), (30.0, -2.0, 90.0, 100.0));
// か is in a set, so the Kana part's Scale at Center does not apply to it.
let ka = &split.runs[2].style;
assert_eq!((ka.v_scale, ka.baseline_shift), (100.0, 0.0));
assert_eq!(db.composite_face("Custom", 'B').map(|(_, id)| id), Some(face_id(&db, "Inter")));
let fonts = db.composite_fonts_for("Custom", "BA").unwrap();
assert_eq!(fonts, [("Inter".to_string(), "Regular".to_string()), ("JetBrains Mono".to_string(), "Regular".to_string())]);
let l = layout(&db, &t);
let ids: Vec<u32> = l.glyphs.iter().map(|g| g.font_id).collect();
assert_eq!(ids[1], face_id(&db, "Inter"));
// A changed set is a changed definition: lending it lays the type out again.
let mut changed = f.clone();
changed.custom[1].characters = "A".into();
let g0 = db.generation();
let lent = db.lend_composite_fonts(&[changed]);
assert_ne!(db.generation(), g0);
let (split, _) = db.composite_split(&t).expect("split");
assert_eq!(split.runs[0].style.font_family, "Inter");
drop(lent);
}
/// 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]
+270 -52
View File
@@ -1,19 +1,27 @@
//! 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.
//! Punctuation, Symbols, Roman, Numerals), then a row for each custom character set, with 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. Custom shows
//! the custom character sets of the font picked: New adds a set based on the one picked, Delete Set
//! takes it out, and Add puts the characters typed (Direct Entry) or the character of a Unicode code
//! into it; Delete takes the character picked out. 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).
//! index of the one picked) and `name` (its name as typed); `custom` (whether the custom character
//! sets show), `set` (the index of the set picked), `setName` (its name as typed), `entryMode`
//! (`direct` or `unicode`), `entry` (the characters or code typed) and `char` (the index of the
//! character picked in the set).
//!
//! Behavior reference (public documentation):
//! <https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347>.
//! <https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347>,
//! custom character sets:
//! <https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=348>.
use serde_json::{Value, json};
use vectorcraft_doc::text::{COMPOSITE_BASELINE, COMPOSITE_SCALE, COMPOSITE_SIZE};
use vectorcraft_doc::{CharClass, CompositeFont, CompositePart};
use vectorcraft_doc::text::{COMPOSITE_BASELINE, COMPOSITE_SCALE, COMPOSITE_SIZE, CUSTOM_CHARS_MAX, CUSTOM_SETS_MAX};
use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CustomCharSet};
use vectorcraft_engine::cmd::compositefonts::EXTS;
use super::{DialogSpec, form};
@@ -27,12 +35,19 @@ pub const KIND: &str = "compositeFonts";
/// The command OK runs, and the menu item's.
pub const COMMAND: &str = "type.compositeFonts.set";
/// How many of a custom set's characters each row of its list shows.
const CHARS_PER_ROW: usize = 20;
/// The tallest the table of parts grows before it scrolls, in points: the header, the six classes
/// and a few custom sets.
const TABLE_HEIGHT: f32 = 340.0;
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<Value, String> {
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 }));
let mut d = Dialog::new(KIND, json!({ "fonts": fonts, "selected": 0, "entryMode": "direct" }));
show_selected(&mut d);
app.ui.dialog = Some(d);
Ok(Value::Null)
@@ -47,12 +62,17 @@ 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))
/// The index field `key` holds, within `count` items (0 when there are none).
fn index(d: &Dialog, key: &str, count: usize) -> usize {
d.fields.get(key).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.
/// The index of the font picked, within the fonts (0 when there are none).
fn selected(d: &Dialog, count: usize) -> usize {
index(d, "selected", count)
}
/// Pick font `i`: its name goes into Name, and its first custom set is picked.
fn select(d: &mut Dialog, i: usize) {
d.fields.insert("selected".into(), json!(i));
show_selected(d);
@@ -62,6 +82,17 @@ 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));
select_set(d, 0);
}
/// Pick custom set `i` of the font picked: its name goes into the set's Name, and no character is
/// picked.
fn select_set(d: &mut Dialog, i: usize) {
d.fields.insert("set".into(), json!(i));
d.fields.remove("char");
let fonts = fonts(d);
let name = fonts.get(selected(d, fonts.len())).and_then(|f| f.custom.get(i)).map(|s| s.name.clone()).unwrap_or_default();
d.fields.insert("setName".into(), json!(name));
}
/// The name of a class's row.
@@ -85,12 +116,25 @@ fn new_name(fonts: &[CompositeFont]) -> String {
.unwrap_or_default()
}
/// "Character Set N", the first N no set in `sets` is named.
fn new_set_name(sets: &[CustomCharSet]) -> String {
(1..).map(|n| format!("Character Set {n}")).find(|n| !sets.iter().any(|s| CompositeFont::same_name(&s.name, n))).unwrap_or_default()
}
/// What a button asked for, done once the table is drawn.
enum Action {
New,
Delete,
Import,
Export,
/// Custom: show or hide the custom character sets.
Custom,
NewSet,
DeleteSet,
/// Add the characters typed, or the character of the code typed, to the set picked.
AddChars,
/// Take the character picked out of the set picked.
DeleteChar,
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
@@ -122,6 +166,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if ui.add_enabled_ui(!list.is_empty(), |ui| widgets::flat_button(ui, tl!("Export…"), 64.0)).inner.clicked() {
act = Some(Action::Export);
}
if ui.add_enabled_ui(!list.is_empty(), |ui| widgets::flat_button(ui, tl!("Custom"), 64.0)).inner.clicked() {
act = Some(Action::Custom);
}
});
if let Some(k) = picked {
select(d, k);
@@ -136,6 +183,13 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
f.name = d.str("name");
ui.add_space(8.0);
parts_table(app, ui, f);
if d.bool("custom") {
ui.add_space(8.0);
ui.separator();
if let Some(a) = custom_sets(ui, d, f) {
act = Some(a);
}
}
}
put_fonts(d, &list);
if let Some(a) = act {
@@ -151,6 +205,80 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false
}
/// The custom character sets of `f`: Character Set with New and Delete Set, the set's Name, the
/// Character row that adds characters, and the set's characters with Delete. The button pressed.
fn custom_sets(ui: &mut egui::Ui, d: &mut Dialog, f: &mut CompositeFont) -> Option<Action> {
let mut act = None;
let k = index(d, "set", f.custom.len());
let names: Vec<&str> = f.custom.iter().map(|s| s.name.as_str()).collect();
let mut picked = None;
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Character Set:"));
if names.is_empty() {
widgets::dim_label(ui, tl!("None"));
} else if let Some(n) = widgets::dropdown_names(ui, "composite-fonts-set", names.get(k).copied().unwrap_or_default(), &names, 220.0) {
picked = Some(n);
}
ui.add_space(8.0);
ui.spacing_mut().item_spacing.x = 4.0;
if ui.add_enabled_ui(f.custom.len() < CUSTOM_SETS_MAX, |ui| widgets::flat_button(ui, tl!("New"), 48.0)).inner.clicked() {
act = Some(Action::NewSet);
}
if ui.add_enabled_ui(!f.custom.is_empty(), |ui| widgets::flat_button(ui, tl!("Delete Set"), 84.0)).inner.clicked() {
act = Some(Action::DeleteSet);
}
});
if let Some(n) = picked {
select_set(d, n);
return None;
}
let Some(set) = f.custom.get_mut(k) else { return act };
ui.add_space(6.0);
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Name:"));
form::text(ui, d, "setName", 260.0);
});
set.name = d.str("setName");
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Character:"));
let modes = [tl!("Direct Entry"), tl!("Unicode")];
let unicode = d.str("entryMode") == "unicode";
if let Some(m) = widgets::dropdown(ui, "composite-fonts-entry-mode", modes[usize::from(unicode)], &modes, 120.0) {
d.fields.insert("entryMode".into(), json!(if m == 1 { "unicode" } else { "direct" }));
}
form::text(ui, d, "entry", 160.0);
if widgets::flat_button(ui, tl!("Add"), 48.0).clicked() {
act = Some(Action::AddChars);
}
});
ui.add_space(4.0);
let chosen = d.fields.get("char").and_then(Value::as_u64).map(|c| c as usize);
let mut pick_char = None;
// Only the rows in view are built, so a set of thousands of characters stays quick to draw.
let chars: Vec<char> = set.characters.chars().collect();
let rows = chars.len().div_ceil(CHARS_PER_ROW);
egui::ScrollArea::vertical().id_salt("composite-fonts-chars").max_height(120.0).show_rows(ui, 24.0, rows, |ui, range| {
for row in range {
ui.horizontal(|ui| {
for (n, c) in chars.iter().enumerate().skip(row * CHARS_PER_ROW).take(CHARS_PER_ROW) {
let r = ui.add(egui::Button::selectable(chosen == Some(n), c.to_string()).min_size(egui::vec2(24.0, 24.0)));
if r.on_hover_text(format!("U+{:04X}", *c as u32)).clicked() {
pick_char = Some(n);
}
}
});
}
});
if let Some(n) = pick_char {
d.fields.insert("char".into(), json!(n));
}
let can_delete = chosen.is_some_and(|n| n < chars.len());
if ui.add_enabled_ui(can_delete, |ui| widgets::flat_button(ui, tl!("Delete"), 64.0)).inner.clicked() {
act = Some(Action::DeleteChar);
}
act
}
/// 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);
@@ -178,61 +306,151 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action) -> Result<(), Stri
let f = list.get(i).ok_or("no composite font")?;
crate::io::save_command_output(app, "type.compositeFonts.export", EXTS[0], json!({ "fonts": [f] }))?;
}
Action::Custom => {
let open = !d.bool("custom");
d.fields.insert("custom".into(), json!(open));
}
Action::NewSet | Action::DeleteSet | Action::AddChars | Action::DeleteChar => {
let f = list.get_mut(i).ok_or("no composite font")?;
let at = edit_sets(d, f, act)?;
put_fonts(d, &list);
if let Some(k) = at {
select_set(d, k);
}
}
}
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.
/// Do custom set action `act` on font `f`. The set to pick afterwards, when the sets changed.
fn edit_sets(d: &mut Dialog, f: &mut CompositeFont, act: Action) -> Result<Option<usize>, String> {
let k = index(d, "set", f.custom.len());
match act {
Action::NewSet => {
if f.custom.len() >= CUSTOM_SETS_MAX {
return Err(format!("a composite font keeps at most {CUSTOM_SETS_MAX} character sets"));
}
// Based on the set picked: its characters, font and values.
let mut s = f.custom.get(k).cloned().unwrap_or_else(|| CustomCharSet::new("", f.parts.kanji.clone()));
s.name = new_set_name(&f.custom);
f.custom.push(s);
Ok(Some(f.custom.len() - 1))
}
Action::DeleteSet => {
if k < f.custom.len() {
f.custom.remove(k);
}
Ok(Some(k.min(f.custom.len().saturating_sub(1))))
}
Action::AddChars => {
let set = f.custom.get_mut(k).ok_or("no character set")?;
let entry = d.str("entry");
let add: Vec<char> = if d.str("entryMode") == "unicode" {
entry
.split(|c: char| c.is_whitespace() || c == ',')
.filter(|code| !code.is_empty())
.map(|code| CustomCharSet::char_from_code(code).ok_or_else(|| format!("`{code}` is not a code from U+0000 to U+FFFF")))
.collect::<Result<_, _>>()?
} else {
entry.chars().collect()
};
if let Some(c) = add.iter().find(|&&c| !CustomCharSet::takes(c)) {
return Err(format!("`{}`: U+{:04X} is a control character or past U+FFFF", set.name, *c as u32));
}
let mut count = set.characters.chars().count();
for c in add {
if set.characters.contains(c) {
continue;
}
if count == CUSTOM_CHARS_MAX {
return Err(format!("`{}`: a character set keeps at most {CUSTOM_CHARS_MAX} characters", set.name));
}
set.characters.push(c);
count += 1;
}
d.fields.insert("entry".into(), json!(""));
Ok(None)
}
Action::DeleteChar => {
let set = f.custom.get_mut(k).ok_or("no character set")?;
let n = d.fields.get("char").and_then(Value::as_u64).map(|c| c as usize);
if let Some(n) = n {
set.characters = set.characters.chars().enumerate().filter(|(j, _)| *j != n).map(|(_, c)| c).collect();
}
d.fields.remove("char");
Ok(None)
}
Action::New | Action::Delete | Action::Import | Action::Export | Action::Custom => Ok(None),
}
}
/// The table of `f`'s parts: a row for each class, then for each custom character set, with its
/// font, style, size, baseline, scales and (Kana) Scale at Center; the values a row can't take are
/// not shown. Past [`TABLE_HEIGHT`] the table scrolls, so the controls under it stay on screen.
fn parts_table(app: &VectorcraftApp, ui: &mut egui::Ui, f: &mut CompositeFont) {
let db = vectorcraft_text::FontDb::global();
egui::ScrollArea::vertical().id_salt("composite-fonts-table").max_height(TABLE_HEIGHT).show(ui, |ui| parts_grid(app, ui, f));
}
/// The rows of [`parts_table`].
fn parts_grid(app: &VectorcraftApp, ui: &mut egui::Ui, f: &mut CompositeFont) {
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<f64>| {
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();
part_row(app, ui, class.key(), f.parts.get_mut(class), class.scales(), class == CharClass::Kana);
}
for (n, s) in f.custom.iter_mut().enumerate() {
// A set's name is the user's own, shown as it is.
ui.label(s.name.as_str());
part_row(app, ui, &format!("custom-{n}"), &mut s.part, true, false);
}
});
}
/// The cells of part `p`'s row (keyed `key`) after its label: font, style, size, baseline, the
/// scales when `scales`, and Scale at Center when `center`.
fn part_row(app: &VectorcraftApp, ui: &mut egui::Ui, key: &str, p: &mut CompositePart, scales: bool, center: bool) {
let db = vectorcraft_text::FontDb::global();
let id = ("composite-font", 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", key), &p.style, &style_names, 120.0)
&& let Some(s) = styles.get(k)
{
p.style.clone_from(s);
}
let percent = |ui: &mut egui::Ui, field: &str, v: &mut f64, range: &std::ops::RangeInclusive<f64>| {
if let Some(n) = widgets::range_field(ui, ("composite-font", key, field), *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 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 center {
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> {
@@ -172,3 +172,90 @@ fn the_dialog_draws_with_and_without_fonts() {
frame(&mut app);
assert_eq!(fonts(dialog(&mut app)).len(), 1);
}
fn try_press(app: &mut VectorcraftApp, act: Action) -> Result<(), String> {
let mut d = app.ui.dialog.clone().unwrap();
let r = run(app, &mut d, act);
app.ui.dialog = Some(d);
r
}
fn sets(app: &mut VectorcraftApp) -> Vec<CustomCharSet> {
fonts(dialog(app)).first().map(|f| f.custom.clone()).unwrap_or_default()
}
/// Custom shows the font's custom character sets: New adds a set (based on the one picked), Add
/// puts typed characters or the characters of Unicode codes into it once each, Delete takes the
/// picked character out, Delete Set takes the set out; OK sets them with the font as one step.
#[test]
fn custom_character_sets_are_edited_in_the_dialog() {
let mut app = app();
open(&mut app).unwrap();
press(&mut app, Action::New);
press(&mut app, Action::Custom);
assert!(dialog(&mut app).bool("custom"));
frame(&mut app);
press(&mut app, Action::NewSet);
frame(&mut app);
assert_eq!(dialog(&mut app).str("setName"), "Character Set 1");
let kanji = fonts(dialog(&mut app))[0].parts.kanji.clone();
assert_eq!(sets(&mut app)[0].part, kanji);
// Direct Entry adds each character once.
dialog(&mut app).fields.insert("entry".into(), json!("※A※"));
press(&mut app, Action::AddChars);
assert_eq!(sets(&mut app)[0].characters, "※A");
assert_eq!(dialog(&mut app).str("entry"), "");
// Unicode takes codes, with or without U+, and skips characters already in the set.
dialog(&mut app).fields.insert("entryMode".into(), json!("unicode"));
dialog(&mut app).fields.insert("entry".into(), json!("U+3042, 41"));
press(&mut app, Action::AddChars);
assert_eq!(sets(&mut app)[0].characters, "※Aあ");
for bad in ["zz", "1F600", "0009"] {
dialog(&mut app).fields.insert("entry".into(), json!(bad));
assert!(try_press(&mut app, Action::AddChars).is_err(), "{bad}");
assert_eq!(sets(&mut app)[0].characters, "※Aあ");
}
dialog(&mut app).fields.insert("entryMode".into(), json!("direct"));
dialog(&mut app).fields.insert("entry".into(), json!("B😀"));
assert!(try_press(&mut app, Action::AddChars).is_err());
assert_eq!(sets(&mut app)[0].characters, "※Aあ");
frame(&mut app);
// Delete takes the picked character out.
dialog(&mut app).fields.insert("char".into(), json!(0));
press(&mut app, Action::DeleteChar);
assert_eq!(sets(&mut app)[0].characters, "Aあ");
// The set's name is edited in Name; New is based on the set picked.
dialog(&mut app).fields.insert("setName".into(), json!("Letters"));
frame(&mut app);
press(&mut app, Action::NewSet);
let list = sets(&mut app);
assert_eq!(list.iter().map(|s| (s.name.as_str(), s.characters.as_str())).collect::<Vec<_>>(), [("Letters", "Aあ"), ("Character Set 1", "Aあ")]);
assert_eq!(dialog(&mut app).fields.get("set"), Some(&json!(1)));
press(&mut app, Action::DeleteSet);
assert_eq!(sets(&mut app).len(), 1);
assert_eq!(dialog(&mut app).str("setName"), "Letters");
frame(&mut app);
dialog(&mut app).fields.insert("name".into(), json!("UI Composite Sets"));
frame(&mut app);
let before = steps(&app);
super::super::confirm(&mut app).unwrap();
assert_eq!(steps(&app), before + 1);
let f = &app.session.active().unwrap().doc.composite_fonts[0];
assert_eq!((f.custom.len(), f.custom[0].name.as_str(), f.custom[0].characters.as_str()), (1, "Letters", "Aあ"));
}
/// Two sets of the same name keep the dialog open and change nothing.
#[test]
fn custom_sets_sharing_a_name_keep_the_dialog_open() {
let mut app = app();
open(&mut app).unwrap();
press(&mut app, Action::New);
press(&mut app, Action::NewSet);
press(&mut app, Action::NewSet);
dialog(&mut app).fields.insert("setName".into(), json!("character set 1"));
dialog(&mut app).fields.insert("custom".into(), json!(true));
frame(&mut app);
assert!(super::super::confirm(&mut app).is_err());
assert!(app.ui.dialog.is_some());
assert!(names(&app).is_empty());
}
+2
View File
@@ -3614,6 +3614,8 @@ digits Malayalam Малаялам
Kana Кана
Numerals Цифри
Scale at Center Мащабиране от центъра
Delete Set Изтриване на набор
Direct Entry Директно въвеждане
Up/Down: Нагоре/надолу:
Left/Right: Наляво/надясно:
Tate-chu-yoko Up/Down Тате-чу-йоко нагоре/надолу
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1640,6 +1640,8 @@ digits Malayalam Malajalam
Kana Kana
Numerals Brojevi
Scale at Center Skaliraj od centra
Delete Set Izbriši skup
Direct Entry Direktan unos
Up/Down: Gore/dolje:
Left/Right: Lijevo/desno:
Tate-chu-yoko Up/Down Tate-chu-yoko gore/dolje
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4685,6 +4685,8 @@ digits Malayalam Malaiàlam
Kana Kana
Numerals Xifres
Scale at Center Escala des del centre
Delete Set Suprimeix el joc
Direct Entry Entrada directa
Up/Down: Amunt/avall:
Left/Right: Esquerra/dreta:
Tate-chu-yoko Up/Down Tate-chu-yoko amunt/avall
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1669,6 +1669,8 @@ digits Malayalam Malajálamské
Kana Kana
Numerals Číslice
Scale at Center Měřítko od středu
Delete Set Odstranit sadu
Direct Entry Přímé zadání
Up/Down: Nahoru/dolů:
Left/Right: Vlevo/vpravo:
Tate-chu-yoko Up/Down Tate-chu-yoko nahoru/dolů
Can't render this file because it is too large.
+2
View File
@@ -2184,6 +2184,8 @@ digits Malayalam Malayalam
Kana Kana
Numerals Tal
Scale at Center Skalér fra midten
Delete Set Slet sæt
Direct Entry Direkte indtastning
Up/Down: Op/ned:
Left/Right: Venstre/højre:
Tate-chu-yoko Up/Down Tate-chu-yoko op/ned
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3657,6 +3657,8 @@ effect Points Punkte
Kana Kana
Numerals Ziffern
Scale at Center Von der Mitte aus skalieren
Delete Set Satz löschen
Direct Entry Direkteingabe
Up/Down: Oben/Unten:
Left/Right: Links/Rechts:
Tate-chu-yoko Up/Down Tate-chu-yoko oben/unten
Can't render this file because it is too large.
+2
View File
@@ -4191,6 +4191,8 @@ digits Malayalam Μαλαγιάλαμ
Kana Κάνα
Numerals Αριθμοί
Scale at Center Κλιμάκωση από το κέντρο
Delete Set Διαγραφή συνόλου
Direct Entry Άμεση καταχώριση
Up/Down: Πάνω/Κάτω:
Left/Right: Αριστερά/Δεξιά:
Tate-chu-yoko Up/Down Tate-chu-yoko πάνω/κάτω
Can't render this file because it is too large.
+2
View File
@@ -3705,6 +3705,8 @@ digits Malayalam Malayálam
Kana Kana
Numerals Números
Scale at Center Escalar desde el centro
Delete Set Eliminar juego
Direct Entry Entrada directa
Up/Down: Arriba/abajo:
Left/Right: Izquierda/derecha:
Tate-chu-yoko Up/Down Tate-chu-yoko arriba/abajo
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1642,6 +1642,8 @@ digits Malayalam Malajalam
Kana Kana
Numerals Numerot
Scale at Center Skaalaa keskeltä
Delete Set Poista merkistö
Direct Entry Suora syöttö
Up/Down: Ylös/alas:
Left/Right: Vasen/oikea:
Tate-chu-yoko Up/Down Tate-chu-yoko ylös/alas
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3697,6 +3697,8 @@ digits Malayalam Malayalam
Kana Kana
Numerals Chiffres
Scale at Center Mettre à l’échelle depuis le centre
Delete Set Supprimer le jeu
Direct Entry Saisie directe
Up/Down: Haut/bas :
Left/Right: Gauche/droite :
Tate-chu-yoko Up/Down Tate-chu-yoko haut/bas
Can't render this file because it is too large.
+2
View File
@@ -3257,6 +3257,8 @@
Scale Text Attributes ટેક્સ્ટ ગુણધર્મો સ્કેલ કરો
Scale Tool સ્કેલ ટૂલ
Scale at Center કેન્દ્રથી સ્કેલ કરો
Delete Set સેટ કાઢી નાખો
Direct Entry સીધી એન્ટ્રી
Scale in Perspective પર્સ્પેક્ટિવમાં સ્કેલ કરો
Scale to Fit Artboard આર્ટબોર્ડમાં બંધબેસતું સ્કેલ કરો
Scale: સ્કેલ:
Can't render this file because it contains an unexpected character in line 72 and column 5.
+2
View File
@@ -5070,6 +5070,8 @@ antiAlias Sharp शार्प
Kana काना
Numerals अंक
Scale at Center केंद्र से आकार बदलें
Delete Set सेट हटाएँ
Direct Entry सीधी प्रविष्टि
Technical Drawing तकनीकी ड्राइंग
Telugu तेलुगु
Temperature तापमान
Can't render this file because it is too large.
+2
View File
@@ -4373,6 +4373,8 @@ mark-style Japanese Japán
Kana Kana
Numerals Számjegyek
Scale at Center Méretezés középről
Delete Set Készlet törlése
Direct Entry Közvetlen bevitel
Up/Down: Fel/le:
Left/Right: Balra/jobbra:
Tate-chu-yoko Up/Down Tate-chu-yoko fel/le
Can't render this file because it is too large.
+2
View File
@@ -1575,6 +1575,8 @@ digits Malayalam Malayalam
Kana Kana
Numerals Angka
Scale at Center Skalakan dari tengah
Delete Set Hapus set
Direct Entry Entri langsung
Up/Down: Atas/bawah:
Left/Right: Kiri/kanan:
Tate-chu-yoko Up/Down Tate-chu-yoko atas/bawah
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1575
1576
1577
1578
1579
1580
1581
1582
+2
View File
@@ -3751,6 +3751,8 @@ digits Malayalam Malayalam
Kana Kana
Numerals Numeri
Scale at Center Ridimensiona dal centro
Delete Set Elimina set
Direct Entry Immissione diretta
Up/Down: Su/giù:
Left/Right: Sinistra/destra:
Tate-chu-yoko Up/Down Tate-chu-yoko su/giù
Can't render this file because it is too large.
+2
View File
@@ -4079,6 +4079,8 @@ file-filter Templates テンプレート
Kana かな
Numerals 数字
Scale at Center 中心から拡大・縮小
Delete Set セットを削除
Direct Entry 直接入力
Up/Down: 上下:
Left/Right: 左右:
Tate-chu-yoko Up/Down 縦中横の上下
Can't render this file because it is too large.
+2
View File
@@ -1513,6 +1513,8 @@ digits Malayalam მალაიალამური
Kana კანა
Numerals ციფრები
Scale at Center ცენტრიდან მასშტაბირება
Delete Set ნაკრების წაშლა
Direct Entry პირდაპირი შეყვანა
Up/Down: ზემოთ/ქვემოთ:
Left/Right: მარცხნივ/მარჯვნივ:
Tate-chu-yoko Up/Down Tate-chu-yoko ზემოთ/ქვემოთ
Can't render this file because it is too large.
+2
View File
@@ -4067,6 +4067,8 @@ file-filter Templates 템플릿
Kana 가나
Numerals 숫자
Scale at Center 중심에서 크기 조정
Delete Set 집합 삭제
Direct Entry 직접 입력
Up/Down: 위/아래:
Left/Right: 왼쪽/오른쪽:
Tate-chu-yoko Up/Down 다테추요코 위/아래
Can't render this file because it is too large.
+2
View File
@@ -2093,6 +2093,8 @@ digits Malayalam Malayalam
Kana Kana
Numerals Cijfers
Scale at Center Schalen vanuit het midden
Delete Set Set verwijderen
Direct Entry Directe invoer
Up/Down: Omhoog/omlaag:
Left/Right: Links/rechts:
Tate-chu-yoko Up/Down Tate-chu-yoko omhoog/omlaag
Can't render this file because it is too large.
+2
View File
@@ -1581,6 +1581,8 @@ digits Malayalam Malajalam
Kana Kana
Numerals Cyfry
Scale at Center Skaluj od środka
Delete Set Usuń zestaw
Direct Entry Wprowadzanie bezpośrednie
Up/Down: W górę/w dół:
Left/Right: W lewo/w prawo:
Tate-chu-yoko Up/Down Tate-chu-yoko w górę/w dół
Can't render this file because it is too large.
+2
View File
@@ -5267,6 +5267,8 @@ axis Both Ambos
Kana Kana
Numerals Números
Scale at Center Dimensionar a partir do centro
Delete Set Excluir conjunto
Direct Entry Entrada direta
Up/Down: Para Cima/Para Baixo:
Left/Right: Esquerda/Direita:
Tate-chu-yoko Up/Down Tate-chu-yoko Para Cima/Para Baixo
Can't render this file because it is too large.
+2
View File
@@ -3737,6 +3737,8 @@ digits Malayalam Малаялам
Kana Кана
Numerals Цифры
Scale at Center Масштабировать от центра
Delete Set Удалить набор
Direct Entry Прямой ввод
Up/Down: Вверх/вниз:
Left/Right: Влево/вправо:
Tate-chu-yoko Up/Down Tate-chu-yoko вверх/вниз
Can't render this file because it is too large.
+2
View File
@@ -3627,6 +3627,8 @@ digits Malayalam Malayalam
Kana Kana
Numerals Siffror
Scale at Center Skala från mitten
Delete Set Ta bort uppsättning
Direct Entry Direktinmatning
Up/Down: Upp/ned:
Left/Right: Vänster/höger:
Tate-chu-yoko Up/Down Tate-chu-yoko upp/ned
Can't render this file because it is too large.
+2
View File
@@ -3491,6 +3491,8 @@ digits Malayalam Malayalam
Kana Kana
Numerals Rakamlar
Scale at Center Merkezden Ölçekle
Delete Set Seti Sil
Direct Entry Doğrudan Giriş
Up/Down: Yukarı/Aşağı:
Left/Right: Sol/Sağ:
Tate-chu-yoko Up/Down Tate-chu-yoko Yukarı/Aşağı
Can't render this file because it is too large.
+2
View File
@@ -3744,6 +3744,8 @@ effect Points Точки
Kana Кана
Numerals Цифри
Scale at Center Масштабувати від центру
Delete Set Видалити набір
Direct Entry Пряме введення
Up/Down: Вгору/вниз:
Left/Right: Ліворуч/праворуч:
Tate-chu-yoko Up/Down Tate-chu-yoko вгору/вниз
Can't render this file because it is too large.
+2
View File
@@ -4090,6 +4090,8 @@ mark-style Japanese Kiểu Nhật
Kana Kana
Numerals Chữ số
Scale at Center Co giãn từ tâm
Delete Set Xóa bộ
Direct Entry Nhập trực tiếp
Up/Down: Lên/Xuống:
Left/Right: Trái/Phải:
Tate-chu-yoko Up/Down Tate-chu-yoko lên/xuống
Can't render this file because it contains an unexpected character in line 204 and column 5.
+2
View File
@@ -3565,6 +3565,8 @@ digits Malayalam 马拉雅拉姆数字
Kana 假名
Numerals 数字
Scale at Center 从中心缩放
Delete Set 删除集
Direct Entry 直接输入
Up/Down: 上/下:
Left/Right: 左/右:
Tate-chu-yoko Up/Down 纵中横上/下
Can't render this file because it is too large.
+2
View File
@@ -4824,6 +4824,8 @@ digits Malayalam 馬來亞拉姆數字
Kana 假名
Numerals 數字
Scale at Center 從中心縮放
Delete Set 刪除集
Direct Entry 直接輸入
Up/Down: 上/下:
Left/Right: 左/右:
Tate-chu-yoko Up/Down 縱中橫上/下
Can't render this file because it is too large.
+22 -5
View File
@@ -9295,8 +9295,19 @@ value out of range, a scale on another class, an unknown class or key, a name th
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.
`custom` lists a font's custom character sets
([Customize characters in a composite font](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=348)),
each `{name, characters?, font?, style?, size?, baseline?, hScale?, vScale?}`. A set sets the characters it lists in
its own font, style, size, baseline and scales, ahead of their class; a value left out is the `kanji` part's. When two
sets list a character, the later set sets it. A set's name is trimmed and unique in its font in any case. Its
characters are each listed once, none a control character or past U+FFFF. A set may list no characters, and then sets
none. A font keeps at most 64 sets and a set at most 8,192 characters, VectorCraft's own limits. A set name that is
empty or shared, a character listed twice or not allowed, `scaleAtCenter` on a set, or an unknown key is refused with
an error, and nothing changes.
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.
composite fonts first. The layout sets each character in its custom set's or 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.
@@ -9309,18 +9320,24 @@ parts its characters are drawn in, and `missingGlyphs` counts the characters tha
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`.
lists), or the `fonts` given, to a `.vccomposite` file; without `path` it returns the file's text as `data`. Fonts
whose file would be larger than 4 MB, the most Import reads, are refused with an error.
`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.
Font picks one, and a table sets each class's and each custom set'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. Custom shows the font's custom character sets: Character Set picks one, New adds a set based on the one picked,
Delete Set takes it out, and Name renames it. Character › Direct Entry adds the characters typed, and Character ›
Unicode adds the characters of hexadecimal codes (`3042`, `U+3042`, several separated by spaces or commas); Add skips
characters the set already lists. Delete takes the character picked out of the set. 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"}}}]}}}
{"name":"run_command","arguments":{"command":"type.compositeFonts.set","params":{"fonts":[{"name":"My Composite","parts":{"kanji":{"font":"Source Han Sans"}},"custom":[{"name":"Marks","characters":"※〒","font":"Source Sans 3","size":90}]}]}}}
```
## Optical Margin Alignment
+1
View File
@@ -693,6 +693,7 @@ since 2026-10-01.
- **Create Outlines keeps the characters' gradients across the type:** Type › Create Outlines and Object › Expand give every glyph, ruby glyph and underline or strikethrough bar the gradient its characters drew with: a gradient with no vector of its own stays fitted to the whole layout rather than to each glyph, and one with a vector keeps its place, both moved, scaled and rotated with the type; the characters' stroke keeps the weight it was drawn at on scaled type. `object.expand.info` measures gradient fills on the same paths. Outlined characters keep their stroke and fill (the help page, <https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=337>); the gradient fitted across the whole layout is VectorCraft's own rule. Measured by `tests::objexpand::create_outlines_keeps_a_character_gradient_across_the_type` and `tests::objexpand::expand_keeps_a_character_gradient_across_the_type`. Scores unchanged (estimated).
- **Composite fonts that two documents define differently:** when two open documents each define a composite font (Type › Composite Fonts…) by the same name with different parts, each document draws and exports its type in its own definition: in a tiled or floating window drawn while the front document draws, when it is placed in a document that defines the name differently, and in an export made while another document is in front. Evidence: `tests::composite::nested_loans_of_one_name_use_each_documents_definition` and `threads_lending_one_name_at_once_use_their_own_definitions` (`crates/text`), `tests::fontchange::a_placed_document_draws_its_own_definition_of_a_name_the_page_defines` (`crates/render`). Scores unchanged (estimated).
- **Composite fonts only another document defines:** a document draws, measures and exports its type only in the composite fonts it defines itself. Type naming a composite font that only another open document, or the document a placed document sits in, defines is set as in a missing font, also when the document defines no composite font: in a window, a thumbnail or an export of a document in the back, in a file being opened and in a placed document. A temporary document drawn while a document draws keeps that document's composite fonts. Evidence: `tests::composite::a_lent_document_does_not_use_another_documents_composite_font` and `a_document_without_composite_fonts_uses_none_and_a_preview_is_unchanged` (`crates/text`), `tests::fontchange::a_placed_document_draws_its_own_definition_of_a_name_the_page_defines` and `a_document_draws_type_in_another_documents_composite_font_as_missing` (`crates/render`), `a_document_replaced_in_the_back_measures_a_font_it_lacks_as_missing` (`crates/engine`). Scores unchanged (estimated).
- **Composite fonts' custom character sets:** Type › Composite Fonts… › Custom adds named character sets to a composite font, each setting the characters it lists in its own font, style, size, baseline and scales ahead of their class; the later of two sets that list a character sets it ([the help page](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=348)). Characters are added by Direct Entry or by Unicode code and deleted one at a time; the sets are saved with the document and in `.vccomposite` files (`custom` in `type.compositeFonts.set`). Evidence: `tests::composite::custom_character_sets_take_their_characters_first` (`crates/text`), `compositefonts::tests::custom_character_sets_set_their_characters` (`crates/engine`), `dialogs::composite_fonts::tests::custom_character_sets_are_edited_in_the_dialog` (`crates/ui-egui`). Scores unchanged (estimated).
- **Character-style Auto leading:** capture and explicit style attributes retain Auto as a reset, distinct from omitted leading. Redefine and later numeric updates preserve local leading overrides; the Character Styles marker recognizes Auto correctly. Regression assertions cover partial definitions, Undo/Redo and native save/load serialization. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Leading](https://helpx.adobe.com/illustrator/using/line-character-spacing.html).
- **Character-style numeric bounds:** `charStyle.new`, `charStyle.edit`, and `charStyle.setAttrs` accept size from 0.1 to 1296 pt, explicit leading from 0.1 to 5000 pt, horizontal and vertical scale from 1% to 10,000%, and baseline shift and Tate-chu-yoko offsets from -1296 to 1296 pt. Out-of-range input leaves the style and Undo history unchanged; Auto leading remains available. These limits match the Character panel controls.
- **Character Style Options, General and Basic Character Formats:** the dialog edits a style's name and stored character formats together. Empty fields remain unset, checkboxes show an indeterminate unset state, Basic's Reset Panel clears its explicit settings, and Preview can be canceled without changing history. OK saves one undo step; New Character Style offers the same fields from captured text or the interface language's defaults without a selection. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html).
+1 -1
View File
@@ -72,7 +72,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| 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: East Asian glyph forms | done | Glyphs panel menu › Traditional Forms, Expert Forms, JIS 04, JIS 90, JIS 78 and JIS 83 Forms, Monospaced Full-Width, Half-Width, Third-Width and Quarter-Width Forms, and Revert To Default Forms, with Show East Asian Options ([Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)): the selected characters are drawn with the font's OpenType feature for that form (`trad`, `expt`, `jp04`, `jp90`, `jp78`, `jp83`, `fwid`, `hwid`, `twid`, `qwid`; [feature list](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist)), one form at a time (`text.setFormat` / `text.setRangeStyle {glyphForm}`); a form none of the faces that draw the selected text (fallback faces included) has a feature for is disabled, and Revert To Default Forms draws the default glyphs. Saved natively only when set, carried by character styles, drawn on the canvas and in PDF, and written to SVG as `font-feature-settings`. Character Style Options has no field for it. Evidence: `tests::typo::glyph_forms_draw_the_fonts_east_asian_forms`, `typecmd::area_tests::glyph_form_is_one_character_attribute_saved_only_when_set`, `panels::glyphs::tests::east_asian_glyph_forms_in_the_panel_menu_set_the_selected_characters_form` |
| CJK: warichu | done | Character panel menu › Warichu and Warichu Settings… (`text.setFormat` / `text.setRangeStyle {warichu, warichuLines, warichuGap, warichuScale, warichuAlign}`, `ui.warichuSettings`), with Show East Asian Options ([Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)): marked characters set at a percentage of their size and stacked on 1 to 6 lines inside their line, one above the other in horizontal type and side by side in vertical type, with a line gap and eight alignments; a block stays whole on one line. Saved natively, carried by character styles, drawn on the canvas and in every export (SVG, PDF and `.ai` write such type as outlines). Type on a path sets the characters at their own size. Evidence: `tests::warichu`, `dialogs::warichu::tests`, `typecmd::area_tests::warichu_is_a_character_attribute_set_saved_and_exported` |
| 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: 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)); custom character sets (Custom: New, Delete Set, characters added by Direct Entry or Unicode code and deleted), each with its own font, style, size, baseline and scales, ahead of the classes, the later set winning a character two list (`custom` in `type.compositeFonts.set`) ([Customize characters in a composite font](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=348)). Composite fonts are listed first in the font menus, saved with the document, and their type is laid out per set and class; SVG and legacy `.ai` write it as outlines. Not yet: the Units menu, Show Sample. Evidence: `text::composite::tests::characters_fall_in_their_classes`, `text::composite::tests::custom_sets_take_their_characters_before_the_classes`, `tests::composite::composite_type_draws_each_class_in_its_font`, `tests::composite::custom_character_sets_take_their_characters_first`, `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: `.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`; spelling is checked in each run's Language (`text.spelling.check`, #4936) |
| 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 by it in languages other than English and the eleven Indian languages above, `.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`; spelling is checked in each run's Language (`text.spelling.check`, #4936) |