Merge pull request #7507 from dotCooCoo/feat/cjk-tsume

Type: Tsume narrows the space around each character's ink
This commit is contained in:
BFlat
2026-11-14 19:38:02 +07:00
committed by GitHub
48 changed files with 579 additions and 35 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ mod wrap;
pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape};
use char_style::yes;
pub use char_style::{
CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES,
CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_TSUME, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES,
WARICHU_SCALE, WarichuAlign,
};
pub use composite::{
+15 -1
View File
@@ -44,6 +44,14 @@ pub struct CharStyle {
/// Insert Aki Right: space added after the character in reading order, as [`Self::aki_left`].
#[serde(default, rename = "akiRight", skip_serializing_if = "crate::skip::is_default")]
pub aki_right: f64,
/// Tsume (Character panel, East Asian options): the percentage of the blank space on each side
/// of the character's ink, inside its own width, that is taken away, 0 to [`MAX_TSUME`]. 50
/// halves that space and 100 sets the ink edge to edge; the glyph itself is not scaled. A
/// cluster takes the value of its first character. Tracking, kerning and Insert Aki are added
/// after it. Horizontal type only.
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub tsume: f64,
/// Use glyph shapes for automatic pair spacing; false uses font metrics.
#[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")]
pub optical_kerning: bool,
@@ -440,6 +448,9 @@ fn hundred() -> f64 {
/// The largest Insert Aki Left or Right ([`CharStyle::aki_left`]), in 1/1000 em: one em.
pub const MAX_AKI: f64 = 1000.0;
/// The largest Tsume ([`CharStyle::tsume`]), in percent: all the blank space beside the ink.
pub const MAX_TSUME: f64 = 100.0;
/// The number of lines a warichu block may be stacked on ([`CharStyle::warichu_lines`]).
pub const WARICHU_LINES: std::ops::RangeInclusive<u8> = 1..=6;
@@ -534,6 +545,7 @@ impl Default for CharStyle {
kerning_before: 0.0,
aki_left: 0.0,
aki_right: 0.0,
tsume: 0.0,
optical_kerning: false,
baseline_shift: 0.0,
h_scale: 100.0,
@@ -588,7 +600,8 @@ impl CharStyle {
/// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a
/// baseline shift that is not a number becomes 0. The Tate-chu-yoko Up/Down and Left/Right
/// offsets are kept as the baseline shift is. Insert Aki Left and Right are kept from 0 to
/// [`MAX_AKI`], and one that is not a number becomes 0. The Warichu Settings Lines and Scale
/// [`MAX_AKI`], and one that is not a number becomes 0; Tsume is kept from 0 to [`MAX_TSUME`] the
/// same way. The Warichu Settings Lines and Scale
/// are kept to [`WARICHU_LINES`] and [`WARICHU_SCALE`]; a gap or scale that is not a number becomes its default. The gap is kept to 0 to
/// [`crate::MAX_COORD`], as the baseline shift is, since a scaled type object holds it in its
/// own units.
@@ -606,6 +619,7 @@ impl CharStyle {
let aki = |v: f64| if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_AKI) };
self.aki_left = aki(self.aki_left);
self.aki_right = aki(self.aki_right);
self.tsume = if self.tsume.is_nan() { 0.0 } else { self.tsume.clamp(0.0, MAX_TSUME) };
self.warichu_lines = self.warichu_lines.clamp(*WARICHU_LINES.start(), *WARICHU_LINES.end());
self.warichu_gap = if self.warichu_gap.is_nan() { default.warichu_gap } else { self.warichu_gap.clamp(0.0, crate::MAX_COORD) };
self.warichu_scale =
+2
View File
@@ -507,6 +507,7 @@ impl crate::Document {
st.tcy_left_right = num("tcyLeftRight").unwrap_or(st.tcy_left_right);
st.aki_left = num("akiLeft").unwrap_or(st.aki_left);
st.aki_right = num("akiRight").unwrap_or(st.aki_right);
st.tsume = num("tsume").unwrap_or(st.tsume);
st.warichu_gap = num("warichuGap").unwrap_or(st.warichu_gap);
st.warichu_scale = num("warichuScale").unwrap_or(st.warichu_scale);
st.keep_dimensions_in_range();
@@ -532,6 +533,7 @@ impl crate::Document {
("tcyLeftRight", Some(st.tcy_left_right)),
("akiLeft", Some(st.aki_left)),
("akiRight", Some(st.aki_right)),
("tsume", Some(st.tsume)),
("leading", st.leading),
];
for (k, v) in kept {
+2 -1
View File
@@ -357,7 +357,7 @@ impl ParaList {
/// The style the marker is set in: `first` (the style of the paragraph's first character)
/// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning,
/// Insert Aki, tate-chu-yoko, warichu, underline and strikethrough.
/// Insert Aki, Tsume, tate-chu-yoko, warichu, underline and strikethrough.
pub fn marker_style(first: &CharStyle) -> CharStyle {
CharStyle {
baseline_shift: 0.0,
@@ -368,6 +368,7 @@ impl ParaList {
kerning_before: 0.0,
aki_left: 0.0,
aki_right: 0.0,
tsume: 0.0,
optical_kerning: first.optical_kerning,
tate_chu_yoko: false,
warichu: false,
+28
View File
@@ -129,6 +129,34 @@ fn insert_aki_is_saved_only_when_set() {
assert_eq!((d.char_styles[0].attrs["akiLeft"].as_f64(), d.char_styles[0].attrs["akiRight"].as_f64()), (Some(MAX_AKI), Some(0.0)));
}
/// Tsume is 0 by default and saved only when not 0; styles written before it read as 0, and a
/// value out of range from a file, in a run or a character style, is kept from 0 to 100.
#[test]
fn tsume_is_saved_only_when_set_and_kept_in_range() {
let st = CharStyle::default();
assert_eq!(st.tsume, 0.0);
let saved = serde_json::to_value(&st).unwrap();
assert!(saved.get("tsume").is_none());
let read = serde_json::from_value::<CharStyle>(saved).unwrap();
assert_eq!(read.tsume, 0.0);
let set = CharStyle { tsume: 37.5, ..CharStyle::default() };
let saved = serde_json::to_value(&set).unwrap();
assert_eq!(saved.get("tsume").and_then(serde_json::Value::as_f64), Some(37.5));
assert_eq!(serde_json::from_value::<CharStyle>(saved).unwrap(), set);
for (stored, kept) in [(-5.0, 0.0), (250.0, MAX_TSUME), (f64::NAN, 0.0), (f64::INFINITY, MAX_TSUME), (50.0, 50.0)] {
let mut damaged = CharStyle { tsume: stored, ..CharStyle::default() };
damaged.keep_dimensions_in_range();
assert_eq!(damaged.tsume, kept, "{stored}");
}
let mut d = crate::Document::new(100.0, 100.0);
for tsume in [500, -1] {
let attrs = serde_json::json!({ "tsume": tsume });
d.char_styles.push(TextStyleDef { name: format!("Tight {tsume}"), attrs: attrs.as_object().cloned().unwrap_or_default() });
}
d.keep_type_values_in_range();
assert_eq!((d.char_styles[0].attrs["tsume"].as_f64(), d.char_styles[1].attrs["tsume"].as_f64()), (Some(MAX_TSUME), Some(0.0)));
}
/// Each Indic numeral system draws 0–9 (and the Arabic-Indic sets) as the ten digits of its
/// script's block; every system saves under its id, which reads back, and other names are refused.
#[test]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -222,6 +222,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
("warichuAlign", json!(v.warichu_align)),
("akiLeft", json!(v.aki_left)),
("akiRight", json!(v.aki_right)),
("tsume", json!(v.tsume)),
("standardVerticalRoman", json!(v.standard_vertical_roman)),
("noBreak", json!(v.no_break)),
("digits", json!(v.digits)),
@@ -578,6 +579,10 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma
return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}")));
}
}
let (key, lo, hi) = ("tsume", 0.0, vectorcraft_doc::text::MAX_TSUME);
if changed(key) && !(lo..=hi).contains(&st.tsume) {
return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}")));
}
let lines = vectorcraft_doc::text::WARICHU_LINES;
if changed("warichuLines") && !lines.contains(&st.warichu_lines) {
return Err(bad(&c, format!("`warichuLines` must be a whole number from {} to {}", lines.start(), lines.end())));
@@ -1075,12 +1080,12 @@ fn unused(s: &mut Session, kind: Kind) -> Result<Value> {
/// The styles of `kind` in `src` that load, with their source names so that art in the file keeps
/// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes,
/// scales, leading, shifts, Insert Aki, Warichu Settings or stroke width are out of the range `charStyle.new` takes, whose
/// scales, leading, shifts, Insert Aki, Tsume, Warichu Settings or stroke width are out of the range `charStyle.new` takes, whose
/// indents, spacing, Hyphenation or Justification options or tab stops are out of the range
/// `paraStyle.new` takes, or whose name is empty, which are skipped. → (the styles, the names in
/// `src` of those skipped)
fn loadable(src: &Document, kind: Kind) -> (Vec<TextStyleDef>, Vec<String>) {
const RANGED: [&str; 21] = [
const RANGED: [&str; 22] = [
"size",
"h_scale",
"v_scale",
@@ -1088,6 +1093,7 @@ fn loadable(src: &Document, kind: Kind) -> (Vec<TextStyleDef>, Vec<String>) {
"baseline_shift",
"akiLeft",
"akiRight",
"tsume",
"tcyUpDown",
"tcyLeftRight",
"warichuLines",
+4 -1
View File
@@ -397,6 +397,8 @@ fn character_style_dimensions_are_checked_before_a_definition_changes() {
json!({"tcyLeftRight": -1296.1}),
json!({"akiLeft": -0.1}),
json!({"akiRight": 1000.1}),
json!({"tsume": -0.1}),
json!({"tsume": 100.1}),
] {
let key = attrs.as_object().unwrap().keys().next().unwrap();
for cmd in ["charStyle.edit", "charStyle.setAttrs", "charStyle.new"] {
@@ -419,7 +421,8 @@ fn character_style_dimensions_are_checked_before_a_definition_changes() {
"h_scale": 1.0, "v_scale": 10000.0,
"baseline_shift": -1296.0,
"tcyUpDown": 1296.0, "tcyLeftRight": -1296.0,
"akiLeft": 0.0, "akiRight": 1000.0
"akiLeft": 0.0, "akiRight": 1000.0,
"tsume": 100.0
}}),
)
.unwrap();
@@ -1821,6 +1821,61 @@ fn insert_aki_is_a_character_attribute_set_one_side_at_a_time() {
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step for a refused call");
}
/// Tsume (Character panel, East Asian options): a character attribute in percent, 0 by default
/// and saved only when not 0. Both commands set it in one undo step, a call that gives the value
/// the text already has records none, and a value that is not a number or lies outside 0 to 100 is
/// refused, naming the key, before anything changes; a character style captured from the
/// characters carries it.
#[test]
fn tsume_is_a_character_attribute() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "(日本)"})).unwrap()["id"].as_u64().unwrap();
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.runs.clone(),
_ => panic!("text"),
};
let tsume = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.tsume)).collect::<Vec<_>>();
let saved = serde_json::to_value(&runs(&s)[0].style).unwrap();
assert!(saved.get("tsume").is_none());
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.setFormat", &json!({"tsume": 50})).unwrap();
assert_eq!(tsume(&s), [("(日本)".to_string(), 50.0)]);
let undo = s.doc().unwrap().history.undo.len();
s.execute("text.setFormat", &json!({"tsume": 50})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step without a change");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(tsume(&s), [("(日本)".to_string(), 0.0)], "one undo step");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(tsume(&s), [("(日本)".to_string(), 50.0)]);
let paren = "(".len();
s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": paren, "tsume": 100})).unwrap();
assert_eq!(tsume(&s), [("(".to_string(), 100.0), ("日本)".to_string(), 50.0)]);
let saved = serde_json::to_value(&runs(&s)[0].style).unwrap();
assert_eq!(saved["tsume"], json!(100.0));
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.tsume)).collect();
assert_eq!(reopened, tsume(&s));
let attrs = super::super::textstyles::char_attrs(&runs(&s)[0].style);
assert_eq!(attrs["tsume"], json!(100.0));
let before = tsume(&s);
let undo = s.doc().unwrap().history.undo.len();
for (cmd, p, want) in [
("text.setFormat", json!({"tsume": "50"}), "`tsume` must be a number"),
("text.setFormat", json!({"tsume": -1}), "`tsume` must be from 0 to 100"),
("text.setRangeStyle", json!({"id": id, "tsume": true}), "`tsume` must be a number"),
("text.setRangeStyle", json!({"id": id, "start": 0, "end": paren, "tsume": 100.5}), "`tsume` must be from 0 to 100"),
] {
let e = s.execute(cmd, &p).unwrap_err();
assert!(matches!(&e, EngineError::BadParams { msg, .. } if msg == want), "{cmd} {p}: {e}");
}
assert_eq!(tsume(&s), before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step for a refused call");
}
/// Standard Vertical Roman Alignment (Character panel menu, East Asian options): a character
/// attribute, off by default and saved only when on. Documents written before it read as off; a
/// character style captured from the characters carries it.
+11 -5
View File
@@ -283,6 +283,11 @@ impl Writer<'_> {
self.warn("type on a path with Insert Aki is outlined to keep its look");
return self.text_outlines(n, t);
}
// Tsume does as well.
if t.runs.iter().any(|r| r.style.tsume != 0.0) {
self.warn("type on a path with Tsume is outlined to keep its look");
return self.text_outlines(n, t);
}
// A `<textPath>` holds the characters of the text, not the bullets or numbers drawn
// before them.
if !lay.markers.is_empty() {
@@ -715,13 +720,13 @@ const PLACE_TOLERANCE: f64 = 1e-6;
/// starts a new segment at each character the layout draws elsewhere than a reader continuing
/// the segment would ([`reader_advances`]): a Justify All line's letter gaps, the gaps between
/// the characters of a justified Japanese line, and the spacing Mojikumi changes. Text with Insert
/// Aki does so on every line, with `fewer` too.
/// Aki or Tsume does so on every line, with `fewer` too.
fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec<Line> {
let plain = t.plain_text();
let mut lines = Vec::with_capacity(lay.lines.len());
let (mut kerned, mut unkerned) = (None, None);
// Insert Aki moves characters from where a reader draws them, with `fewer` too.
let aki = t.runs.iter().any(|r| r.style.aki_left != 0.0 || r.style.aki_right != 0.0);
// Insert Aki and Tsume move characters from where a reader draws them, with `fewer` too.
let aki = t.runs.iter().any(|r| r.style.aki_left != 0.0 || r.style.aki_right != 0.0 || r.style.tsume != 0.0);
for line in &lay.lines {
let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count());
let style = t.para_at(para);
@@ -885,11 +890,12 @@ fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning:
if !kerning {
r.style.kerning.get_or_insert(0.0);
}
// A reader puts no space before a line's first character (`kerningBefore`) and no Insert
// Aki: the segment starts that much further along instead.
// A reader puts no space before a line's first character (`kerningBefore`), no Insert
// Aki and no Tsume: the segment starts that much further along instead.
r.style.kerning_before = 0.0;
r.style.aki_left = 0.0;
r.style.aki_right = 0.0;
r.style.tsume = 0.0;
}
let relaid;
let glyphs = if set == *t {
+29
View File
@@ -196,6 +196,35 @@ fn insert_aki_places_each_moved_character() {
assert!(!out.svg.contains("<textPath") && out.warnings.iter().any(|w| w.contains("Insert Aki")), "{:?}", out.warnings);
}
/// Tsume: each character the layout moves by its Tsume starts a `<tspan>` where the character is
/// drawn, so a reader draws the text where the canvas does, with fewer `<tspan>`s asked for too.
/// On a path the type is outlined.
#[test]
fn tsume_places_each_moved_character() {
let tight = CharStyle { tsume: 50.0, ..style(12.0) };
let runs =
vec![run("H", style(12.0)), run("O", tight.clone()), run("H ", style(12.0)), run("OHO\nHO", CharStyle { tsume: 100.0, ..style(12.0) })];
for justify in [Justify::Left, Justify::Center, Justify::Right] {
assert_drawn_in_place(&area_doc(runs.clone(), 200.0, justify));
}
let d = area_doc(runs, 200.0, Justify::Left);
let plain = tspans(&export(&area_doc(vec![run("HOH OHO\nHO", style(12.0))], 200.0, Justify::Left), &ExportOptions::default())).len();
let fewer = tspans(&export(&d, &ExportOptions { fewer_tspans: true, ..Default::default() })).len();
assert!(fewer > plain, "each moved character is placed with fewer tspans too: {plain} {fewer}");
let mut t = TextObject::point(Point::ZERO, "", style(12.0));
t.runs = vec![run("HO", tight)];
let mut line = BezPath::new();
line.move_to((20.0, 100.0));
line.line_to((280.0, 100.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
let mut d = Document::new(300.0, 200.0);
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let out = vectorcraft_svg::export_full(&d, &ExportOptions::default(), None);
assert!(!out.svg.contains("<textPath") && out.warnings.iter().any(|w| w.contains("Tsume")), "{:?}", out.warnings);
}
/// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own.
#[test]
fn proportional_metrics_write_palt() {
+1
View File
@@ -56,6 +56,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool {
kerning_before: _,
aki_left: _,
aki_right: _,
tsume: _,
optical_kerning,
baseline_shift,
h_scale,
+85 -9
View File
@@ -13,7 +13,7 @@ use kurbo::Shape;
use skrifa::MetadataProvider;
use skrifa::charmap::Charmap;
use skrifa::instance::Size;
use vectorcraft_doc::text::{INLINE_CHAR, MAX_AKI};
use vectorcraft_doc::text::{INLINE_CHAR, MAX_AKI, MAX_TSUME};
use vectorcraft_doc::{CharStyle, Digits, InlineArt};
use crate::features::{OtFeatures, ligatures_suppressed_by};
@@ -969,9 +969,9 @@ fn source_run_at(runs: &[(Range<usize>, &CharStyle)], span: &Range<usize>, byte:
/// Can one glyph draw characters in styles `a` and `b`? OpenType features already apply to their
/// own character ranges during shaping, so feature-only differences do not split a cluster.
/// Horizontal type does not read Standard Vertical Roman Alignment or the tate-chu-yoko offsets.
/// The space before a line's first character ([`space_before`]) and Insert Aki ([`aki`]) come
/// from the cluster's first character, so they do not split a ligature or a letter from its marks
/// either. Warichu Settings count only on characters marked Warichu ([`warichu_alike`]).
/// The space before a line's first character ([`space_before`]), Insert Aki ([`aki`]) and Tsume
/// ([`tighten`]) come from the cluster's first character, so they do not split a ligature or a
/// letter from its marks either. Warichu Settings count only on characters marked Warichu ([`warichu_alike`]).
fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool {
// Exhaustive, like `shapes_alike`: compare borrowed fields without copying feature lists.
let CharStyle {
@@ -986,6 +986,7 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool {
kerning_before,
aki_left,
aki_right,
tsume,
optical_kerning,
baseline_shift,
h_scale,
@@ -1034,6 +1035,7 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool {
kerning_before == &a.kerning_before
&& aki_left == &a.aki_left
&& aki_right == &a.aki_right
&& tsume == &a.tsume
&& offsets == other_offsets
&& font_family == &b.font_family
&& font_style == &b.font_style
@@ -1094,7 +1096,7 @@ pub(crate) fn warichu_alike(a: &CharStyle, b: &CharStyle) -> bool {
/// character style's name, leading, baseline shift, rotation, Character Alignment, Character
/// Direction (text is shaped apart where its bidi level changes), Kashidas, the kerning (each glyph takes its
/// run's, see [`shape_segment`]), the space before a line's first character ([`space_before`]),
/// Insert Aki ([`aki`]), the OpenType features (each run's apply to its own characters, as features
/// Insert Aki ([`aki`]), Tsume ([`tighten`]), the OpenType features (each run's apply to its own characters, as features
/// over a range of the one buffer) and the tracking, as long as the tracking keeps or drops
/// standard ligatures alike ([`crate::features::ligatures_suppressed_by`]).
fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
@@ -1111,6 +1113,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
kerning_before: _,
aki_left: _,
aki_right: _,
tsume: _,
optical_kerning: _,
baseline_shift: _,
h_scale,
@@ -1332,6 +1335,9 @@ struct Shaped {
/// from their letters and the character-level pass matches ([`crate::harakat`]); empty
/// otherwise (glyphs keep their shaping-cluster start).
chars: Vec<u32>,
/// Each glyph's x advance without the font's pair kerning, the width Tsume measures, when
/// horizontal type with Tsume sets the same glyphs without it; empty otherwise.
unkerned: Vec<i32>,
}
/// The features that turn the whole-text features `base` into `own` over the clusters `range`, put
@@ -1443,6 +1449,8 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
}
ranged
};
// Horizontal type with Tsume on any of the segment's characters.
let tsume_here = !feats.vertical && runs.get(source_runs.clone()).unwrap_or_default().iter().any(|(_, s)| s.tsume > 0.0);
// Shape the segment as if all of it were in `st`, with the features `more` on top.
let shape_in = |st: &CharStyle, more: &[Feature]| {
// A BCP 47 tag is short ASCII; anything else (a file may hold anything) is shaped without one.
@@ -1452,7 +1460,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
.filter(|l| l.len() <= 35 && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'))
.and_then(|l| l.parse::<harfrust::Language>().ok());
let mut shaped = Shaped::default();
let Shaped { raw, proportional, vpal, chars } = &mut shaped;
let Shaped { raw, proportional, vpal, chars, unkerned } = &mut shaped;
let hb_shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
// The baseline and character-level pass use exactly the same resolved features.
@@ -1531,6 +1539,16 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
*proportional = full.iter().zip(raw).map(|(a, b)| a.2 != b.2).collect();
}
}
if tsume_here {
// Tsume measures each glyph on its own width: the same glyphs set without the
// font's pair kerning (`kern` and `apkn`) give it.
let mut bare = along_features.clone();
bare.extend([b"kern", b"apkn"].map(|t| Feature::new(Tag::new(t), 0, range.start..=range.end)));
let bare = shape(&bare, along);
if same_glyphs(&bare) {
*unkerned = bare.iter().map(|g| g.2).collect();
}
}
if feats.proportional_vertical(st) {
// `vpal` adjusts heights, which only top-to-bottom shaping applies: the same glyphs set
// top to bottom with it (and the vertical kerning that goes with it, `vkrn` and `vapk`)
@@ -1577,7 +1595,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
shaped
};
let first_optical = if optical(range.start) { Some(range.start) } else { boundaries().find(|&b| optical(b)) };
let Shaped { raw, proportional, vpal, chars } = if let Some(first) = first_optical.filter(|_| !feats.vertical) {
let Shaped { raw, proportional, vpal, chars, unkerned } = if let Some(first) = first_optical.filter(|_| !feats.vertical) {
// The pair entering Optical keeps the left character's font kerning. Subsequent
// Optical pairs use outline spacing, so font pair features stop at its second character.
let optical_off = if first == range.start {
@@ -1711,11 +1729,16 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl;
let ch = first_char(cl);
let mut adv = xa as f64 * k * hs;
// The glyph's own width, without the font's pair kerning and before the tracking, manual
// kerning and Insert Aki after it: what Tsume measures.
let mut own = unkerned.get(gi).map_or(adv, |&u| f64::from(u) * k * hs);
// The chosen glyph takes its own advance.
let chosen = chosen_at(gi);
let gid = match chosen {
Some(c) => {
adv += (face.advance(c.gid) - face.advance(gid)) * k * hs;
let wider = (face.advance(c.gid) - face.advance(gid)) * k * hs;
adv += wider;
own += wider;
c.gid
}
None => gid,
@@ -1723,7 +1746,8 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
let mut kern_shift = 0.0;
let mut tracked = 0.0;
let (mut aki_shift, mut aki_in_adv) = (0.0, 0.0);
if ch == SOFT_HYPHEN || ch == '\u{2028}' {
let invisible = ch == SOFT_HYPHEN || ch == '\u{2028}';
if invisible {
adv = 0.0;
} else {
let manual_kern = gst.kerning.map(|v| v / 1000.0 * spacing_em).unwrap_or(0.0);
@@ -1785,9 +1809,61 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
cursive,
hyphen_language: crate::hyphen::Language::from_tag(gst.language.as_deref()),
});
// Tsume takes its share of the blank space beside the cluster's ink once the cluster is
// complete, in horizontal type.
if last_in_cluster && !invisible && !feats.vertical && gst.tsume > 0.0 {
let first = out.iter().rposition(|g| g.byte != cl).map_or(0, |i| i + 1);
tighten(out.get_mut(first..).unwrap_or_default(), own, gst.tsume);
}
}
}
/// Tsume ([`CharStyle::tsume`]) on one cluster: `glyphs` are its glyphs in the order they are
/// drawn along the line, the last one with `own` as its width before the tracking, kerning and
/// Insert Aki its advance holds. `tsume` percent of the blank space between the cluster's own width
/// and its ink, on each side, comes out of the cluster's advance, and its glyphs are drawn the
/// left side's share earlier. A cluster without ink, such as a space, or with a width that is not
/// positive, keeps its place and width.
fn tighten(glyphs: &mut [SGlyph], own: f64, tsume: f64) {
let Some(last) = glyphs.len().checked_sub(1) else { return };
let share = if tsume.is_nan() { 0.0 } else { tsume.clamp(0.0, MAX_TSUME) / 100.0 };
let (mut pen, mut ink): (f64, Option<(f64, f64)>) = (0.0, None);
for (i, g) in glyphs.iter().enumerate() {
if let Some((a, b)) = ink_across(&g.face, g.gid) {
let (a, b) = (pen + g.dx + a * g.sx, pen + g.dx + b * g.sx);
let (lo, hi) = (a.min(b), a.max(b));
ink = Some(ink.map_or((lo, hi), |(l, h)| (l.min(lo), h.max(hi))));
}
pen += if i == last { own } else { g.adv };
}
let Some((lo, hi)) = ink.filter(|(lo, hi)| lo.is_finite() && hi.is_finite()) else { return };
if !(pen.is_finite() && pen > 0.0) {
return;
}
let left = lo.clamp(0.0, pen) * share;
let right = (pen - hi).clamp(0.0, pen) * share;
for g in glyphs.iter_mut() {
g.dx -= left;
}
if let Some(g) = glyphs.get_mut(last) {
g.adv -= left + right;
}
}
/// The left and right edges of glyph `gid`'s outline in `face`, in font units; `None` for a glyph
/// without an outline (a space, or a bitmap or color glyph without one).
fn ink_across(face: &FontFace, gid: u32) -> Option<(f64, f64)> {
let font = face.skrifa()?;
let glyph = font.outline_glyphs().get(skrifa::GlyphId::new(gid))?;
let mut pen = crate::fontdb::FlipPen(kurbo::BezPath::new());
glyph.draw(skrifa::outline::DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen).ok()?;
if pen.0.elements().is_empty() {
return None;
}
let b = pen.0.bounding_box();
Some((b.x0, b.x1))
}
#[cfg(test)]
mod optical_tests {
use super::*;
+1
View File
@@ -30,6 +30,7 @@ mod shape_plans;
mod snap;
mod svg_glyphs;
mod sysfonts;
mod tsume;
mod typo;
mod variable;
mod versions;
+143
View File
@@ -0,0 +1,143 @@
//! Tsume (`CharStyle::tsume`): the share of the blank space on each side of a character's ink,
//! inside its own width, that horizontal type takes away, the glyph keeping its shape.
use super::{area, db, point, style};
use crate::*;
use kurbo::Shape;
use vectorcraft_doc::{CharStyle, Justify};
/// Where glyph `byte` is drawn along a horizontal line.
fn drawn_x(l: &TextLayout, byte: usize) -> f64 {
l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.xf.as_coeffs()[4]).unwrap()
}
/// The left and right edges of glyph `byte`'s ink.
fn ink(l: &TextLayout, byte: usize) -> (f64, f64) {
let b = l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.outline.bounding_box()).unwrap();
(b.x0, b.x1)
}
/// `t` with `f` applied to the style of the character at `byte`.
fn with(t: &TextObject, byte: usize, f: impl Fn(&mut CharStyle)) -> TextObject {
let mut t = t.clone();
let end = edit::next_char(&t.plain_text(), byte);
edit::style_range(&mut t.runs, byte, end, f);
t
}
/// How far each of `bytes` moved along the line from `a` to `b`.
fn moves(a: &TextObject, b: &TextObject, bytes: &[usize]) -> Vec<f64> {
let (la, lb) = (layout(db(), a), layout(db(), b));
bytes.iter().map(|&i| drawn_x(&lb, i) - drawn_x(&la, i)).collect()
}
fn near(values: &[f64], expected: &[f64]) -> bool {
values.len() == expected.len() && values.iter().zip(expected).all(|(v, e)| (v - e).abs() < 1e-6)
}
/// The blank space before and after the O of "HOH" in `t`'s layout: between the start of its
/// width and its ink, and between its ink and the next character.
fn o_bearings(t: &TextObject) -> (f64, f64) {
let l = layout(db(), t);
let (x0, x1) = ink(&l, 1);
(x0 - drawn_x(&l, 1), drawn_x(&l, 2) - x1)
}
#[test]
fn tsume_takes_its_share_of_the_space_beside_the_ink() {
let plain = point("HOH", style(100.0));
let (before, after) = o_bearings(&plain);
assert!(before > 1.0 && after > 1.0, "{before} {after}");
let plain_layout = layout(db(), &plain);
let width = ink(&plain_layout, 1).1 - ink(&plain_layout, 1).0;
for tsume in [25.0, 50.0, 100.0] {
let tight = with(&plain, 1, |s| s.tsume = tsume);
let share = tsume / 100.0;
// The O's ink moves back by its share of the space before it; the H after it also moves
// back by the share of the space after the O.
let l = layout(db(), &tight);
let (x0, x1) = ink(&l, 1);
let moved = (x0 - ink(&plain_layout, 1).0, ink(&l, 2).0 - ink(&plain_layout, 2).0);
assert!((moved.0 + before * share).abs() < 1e-6, "{tsume}: {moved:?}");
assert!((moved.1 + (before + after) * share).abs() < 1e-6, "{tsume}: {moved:?}");
// The glyph keeps its shape, and the H before it stays.
assert!((x1 - x0 - width).abs() < 1e-6, "{tsume}: {} {width}", x1 - x0);
assert!((ink(&l, 0).0 - ink(&plain_layout, 0).0).abs() < 1e-9);
}
// At 100% the O's ink starts where the H before it ends its width, and the next H starts its
// width where the O's ink ends.
let l = layout(db(), &with(&plain, 1, |s| s.tsume = 100.0));
let cell_start = drawn_x(&plain_layout, 1);
assert!((ink(&l, 1).0 - cell_start).abs() < 1e-6 && (drawn_x(&l, 2) - ink(&l, 1).1).abs() < 1e-6, "{:?} {cell_start}", ink(&l, 1));
}
#[test]
fn zero_out_of_range_and_spaces_keep_the_layout() {
let plain = point("H O", style(100.0));
let base = layout(db(), &plain);
for (tsume, at) in [(0.0, 2), (f64::NAN, 2), (-50.0, 2), (100.0, 1)] {
// 0, a value that is not a number, a negative value, and a space at 100%.
let l = layout(db(), &with(&plain, at, |s| s.tsume = tsume));
let xs: Vec<f64> = l.glyphs.iter().map(|g| g.xf.as_coeffs()[4]).collect();
let base_xs: Vec<f64> = base.glyphs.iter().map(|g| g.xf.as_coeffs()[4]).collect();
assert_eq!(xs, base_xs, "{tsume} at {at}");
}
// A value above 100 takes all the space, as 100 does.
let most = moves(&plain, &with(&plain, 2, |s| s.tsume = 500.0), &[2]);
let full = moves(&plain, &with(&plain, 2, |s| s.tsume = 100.0), &[2]);
assert!(near(&most, &full) && full[0] < -1.0, "{most:?} {full:?}");
}
#[test]
fn tracking_and_insert_aki_are_added_after_the_tsume() {
let plain = point("HOH", style(100.0));
let tight = moves(&plain, &with(&plain, 1, |s| s.tsume = 50.0), &[0, 1, 2]);
let spaced = moves(&plain, &with(&plain, 1, |s| (s.tsume, s.aki_left, s.tracking) = (50.0, 250.0, 100.0)), &[0, 1, 2]);
// 25 pt of Aki before the O, and 10 pt of tracking after it, on top of the Tsume.
assert!(tight[1] < -1.0 && tight[2] < tight[1], "{tight:?}");
assert!(near(&spaced, &[0.0, tight[1] + 25.0, tight[2] + 35.0]), "{tight:?} {spaced:?}");
}
#[test]
fn the_font_kerning_is_added_after_the_tsume() {
// An L, with blank space after its ink, before a letter the font kerns it with: its Tsume is
// the same as before an H, which the font does not kern it with.
let lh = point("LH", style(100.0));
let kerned = ["LT", "LV", "LY", "LW"]
.map(|s| point(s, style(100.0)))
.into_iter()
.find(|t| drawn_x(&layout(db(), t), 1) < drawn_x(&layout(db(), &lh), 1) - 1.0);
let Some(kerned) = kerned else { panic!("the test font kerns none of the pairs") };
let before_kerned = moves(&kerned, &with(&kerned, 0, |s| s.tsume = 100.0), &[1]);
let before_h = moves(&lh, &with(&lh, 0, |s| s.tsume = 100.0), &[1]);
assert!(near(&before_kerned, &before_h) && before_h[0] < -10.0, "{before_kerned:?} {before_h:?}");
}
#[test]
fn a_letter_with_its_mark_takes_the_tsume_of_the_letter() {
let plain = point("H\u{301}O", style(100.0));
let after = "H\u{301}".len();
let letter = moves(&plain, &with(&plain, 0, |s| s.tsume = 100.0), &[after]);
let mark = moves(&plain, &with(&plain, 1, |s| s.tsume = 100.0), &[after]);
assert!(letter[0] < -1.0 && near(&mark, &[0.0]), "{letter:?} {mark:?}");
}
#[test]
fn justification_keeps_the_tsume() {
let plain = area("HOH HOH", style(20.0), Rect::new(0.0, 0.0, 400.0, 400.0), Justify::JustifyAll);
let tight = with(&plain, 1, |s| s.tsume = 100.0);
let (lp, lt) = (layout(db(), &plain), layout(db(), &tight));
// The O stays nearer the H before it by the space Tsume took before its ink.
let gap = |l: &TextLayout| ink(l, 1).0 - ink(l, 0).1;
assert!(gap(&lt) < gap(&lp) - 1.0, "{} {}", gap(&lp), gap(&lt));
}
#[test]
fn vertical_type_keeps_its_spacing() {
for text in ["§§", "ab"] {
let mut plain = point(text, style(20.0));
plain.xf = Affine::IDENTITY;
plain.vertical = true;
let centers = |t: &TextObject| layout(db(), t).glyphs.iter().map(|g| g.outline.bounding_box().center()).collect::<Vec<_>>();
assert_eq!(centers(&with(&plain, 0, |s| s.tsume = 100.0)), centers(&plain), "{text}");
}
}
@@ -26,9 +26,10 @@ const NUMBERS: &[&str] = &["size", "leading", "kerning", "tracking"];
/// The on, off or unset checkboxes.
const SWITCHES: &[&str] = &["underline", "strikethrough", "standardVerticalRoman"];
/// The attributes of Advanced Character Formats, in the order the section shows them.
const ADVANCED: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight", "language"];
const ADVANCED: &[&str] =
&["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight", "tsume", "language"];
/// The number fields of Advanced Character Formats.
const ADVANCED_NUMBERS: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight"];
const ADVANCED_NUMBERS: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight", "tsume"];
/// The Insert Aki Left and Right fields, which take the Character panel's presets and stop at
/// [`vectorcraft_doc::text::MAX_AKI`].
const AKI: [&str; 2] = ["akiLeft", "akiRight"];
@@ -86,6 +87,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
"rotation",
"akiLeft",
"akiRight",
"tsume",
"language",
"fill",
"fill_tint",
@@ -275,6 +277,7 @@ fn optional_number(d: &Dialog, key: &str) -> Result<Option<Value>, String> {
"rotation" => tl!("Character Rotation"),
"akiLeft" => tl!("Insert Aki Left"),
"akiRight" => tl!("Insert Aki Right"),
"tsume" => tl!("Tsume"),
"fill_tint" => tl!("Fill Tint"),
"stroke_tint" => tl!("Stroke Tint"),
"stroke_width" => tl!("Weight"),
@@ -322,6 +325,8 @@ fn optional_number(d: &Dialog, key: &str) -> Result<Option<Value>, String> {
// Insert Aki stops at 0 and at the largest value the style takes, as the Character
// panel's fields do.
let n = if AKI.contains(&key) { n.clamp(0.0, vectorcraft_doc::text::MAX_AKI) } else { n };
// Tsume stops at 0% and 100% the same way.
let n = if key == "tsume" { n.clamp(0.0, vectorcraft_doc::text::MAX_TSUME) } else { n };
Ok(Some(json!(n)))
}
}
@@ -600,7 +605,7 @@ pub(crate) fn reset_basic(d: &mut Dialog) {
}
}
/// Reset Panel in Advanced Character Formats: its nine attributes become unset.
/// Reset Panel in Advanced Character Formats: its ten attributes become unset.
pub(crate) fn reset_advanced(d: &mut Dialog) {
d.fields.insert("__resetAdvanced".into(), json!(true));
for key in ADVANCED_NUMBERS {
@@ -755,8 +760,8 @@ pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) {
}
/// Advanced Character Formats: Horizontal Scale, Vertical Scale, Baseline Shift, the Tate-chu-yoko
/// Up/Down and Left/Right, Character Rotation, Insert Aki Left and Right (with the Character
/// panel's presets) and Language, each empty (unset) or set.
/// Up/Down and Left/Right, Character Rotation, Insert Aki Left and Right and Tsume (with the
/// Character panel's presets) and Language, each empty (unset) or set.
pub(crate) fn advanced_body(ui: &mut egui::Ui, d: &mut Dialog) {
egui::Grid::new("character-style-advanced").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| {
for (key, caption, unit) in [
@@ -787,6 +792,17 @@ pub(crate) fn advanced_body(ui: &mut egui::Ui, d: &mut Dialog) {
}
ui.end_row();
}
let presets: Vec<String> = crate::panels::character::TSUME_PRESETS.iter().map(f64::to_string).collect();
let presets: Vec<&str> = presets.iter().map(String::as_str).collect();
let row = ui.label(tl!("Tsume"));
let value = if d.fields.get("tsume").is_none_or(Value::is_null) { String::new() } else { d.str("tsume") };
ui.horizontal(|ui| {
if let Some(v) = ui.label_inputs_by(row.id, |ui| crate::widgets::text_presets(ui, (KIND, "tsume"), &value, &presets, 160.0)) {
d.fields.insert("tsume".into(), json!(v));
}
ui.label("%");
});
ui.end_row();
let row = ui.label(tl!("Language:"));
let names: Vec<&str> = std::iter::once(" ").chain(vectorcraft_doc::setup::LANGUAGES.iter().map(|(name, _, _)| *name)).collect();
let current = d.fields.get("language").and_then(Value::as_str).unwrap_or("").to_string();
@@ -1847,6 +1863,50 @@ mod tests {
assert!(t.runs.iter().all(|r| (r.style.aki_left, r.style.aki_right) == (250.0, 1000.0)));
}
/// Tsume in Advanced Character Formats: empty is unset, an explicit 0 stays set, a typed value
/// stops at 0 and 100, Reset Panel unsets it, and the field is drawn and commits to the style
/// and the text set in it.
#[test]
fn tsume_field_shows_sets_resets_and_commits() {
let d = dialog_for(json!({"size": 12.0}));
assert_eq!(d.fields["tsume"], json!(""));
let mut d = dialog_for(json!({"tsume": 50.0}));
assert_eq!(d.fields["tsume"], json!(50.0));
for (typed, kept) in [("0", 0.0), ("25.5", 25.5), ("150", 100.0), ("-5", 0.0)] {
d.fields.insert("tsume".into(), json!(typed));
assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({"tsume": kept})), "{typed}");
}
d.fields.insert("tsume".into(), json!(""));
assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({})));
d.fields.insert("tsume".into(), json!("tight"));
assert_eq!(edited_attrs(&d).unwrap_err(), "Tsume must be a number");
let mut d = dialog_for(json!({"tsume": 50.0, "size": 12.0}));
d.fields.insert("section".into(), json!("advanced"));
reset_section(&mut d);
assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({"size": 12.0})));
let mut app = app();
app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24.0}})).unwrap();
let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "Text"})).unwrap()["id"].as_u64().unwrap();
app.run("charStyle.apply", json!({"name": "Body", "id": id})).unwrap();
open(&mut app, "Body").unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 900.0));
let mut d = app.ui.dialog.clone().unwrap();
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| advanced_body(ui, &mut d));
let texts = texts_of(&out);
out.textures_delta.clear();
assert!(texts.iter().any(|(text, _)| text == "Tsume"), "Tsume is drawn: {texts:?}");
app.ui.dialog.as_mut().unwrap().fields.insert("tsume".into(), json!("75"));
super::super::confirm(&mut app).unwrap();
let state = app.session.doc().unwrap();
assert_eq!(state.doc.char_styles[0].attrs, attrs(json!({"size": 24.0, "tsume": 75.0})));
let vectorcraft_doc::NodeKind::Text(t) = &state.doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") };
assert!(t.runs.iter().all(|r| r.style.tsume == 75.0));
}
/// The negated tags of the twenty stylistic sets: an explicit empty Stylistic Sets choice.
fn no_sets() -> Vec<String> {
vectorcraft_text::EXTRA_FEATURES[..20].iter().map(|s| format!("-{s}")).collect()
+1
View File
@@ -2588,6 +2588,7 @@ font-technology Variable Променливи
Proportional Metrics Пропорционални метрики
Insert Aki Left Вмъкване на аки отляво
Insert Aki Right Вмъкване на аки отдясно
Tsume Цуме
Character Direction Посока на знаците
Digits Цифри
Arabic Арабски
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1573,6 +1573,7 @@ lighting Light Svjetlo
Proportional Metrics Proporcionalna metrika
Insert Aki Left Umetni aki lijevo
Insert Aki Right Umetni aki desno
Tsume Tsume
Character Direction Smjer znakova
Digits Cifre
Arabic Arapski
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3358,6 +3358,7 @@ digits Malayalam Malaiàlam
Proportional Metrics Mètriques proporcionals
Insert Aki Left Insereix aki a l'esquerra
Insert Aki Right Insereix aki a la dreta
Tsume Tsume
Proportional Lining Xifres proporcionals alineades
Position: Posició:
Default Position Posició predeterminada
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1601,6 +1601,7 @@ lighting Light Světlo
Proportional Metrics Proporcionální metrika
Insert Aki Left Vložit aki vlevo
Insert Aki Right Vložit aki vpravo
Tsume Tsume
Character Direction Směr znaků
Digits Číslice
Arabic Arabské
Can't render this file because it is too large.
+1
View File
@@ -1509,6 +1509,7 @@ lighting Light Lys
Proportional Metrics Proportionale metrikker
Insert Aki Left Indsæt aki til venstre
Insert Aki Right Indsæt aki til højre
Tsume Tsume
Character Direction Tegnretning
Digits Cifre
Arabic Arabisk
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2632,6 +2632,7 @@ mark-style Japanese Japanisch
Proportional Metrics Proportionale Metrik
Insert Aki Left Aki links einfügen
Insert Aki Right Aki rechts einfügen
Tsume Tsume
Character Direction Zeichenrichtung
Digits Ziffern
Arabic Arabisch
Can't render this file because it is too large.
+1
View File
@@ -3428,6 +3428,7 @@ theme Appearance Εμφάνιση
Proportional Metrics Αναλογικές μετρικές
Insert Aki Left Εισαγωγή aki αριστερά
Insert Aki Right Εισαγωγή aki δεξιά
Tsume Tsume
Arabic Αραβικά
Hindi Χίντι
Farsi Περσικά
Can't render this file because it is too large.
+1
View File
@@ -2666,6 +2666,7 @@ font-technology Variable Variables
Proportional Metrics Métrica proporcional
Insert Aki Left Insertar aki a la izquierda
Insert Aki Right Insertar aki a la derecha
Tsume Tsume
Character Direction Dirección de caracteres
Digits Dígitos
Arabic Árabes
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1576,6 +1576,7 @@ lighting Light Valo
Proportional Metrics Suhteelliset metriikat
Insert Aki Left Lisää aki vasemmalle
Insert Aki Right Lisää aki oikealle
Tsume Tsume
Character Direction Merkkien suunta
Digits Numerot
Arabic Arabia
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2658,6 +2658,7 @@ font-technology Variable Variables
Proportional Metrics Métrique proportionnelle
Insert Aki Left Insérer un aki à gauche
Insert Aki Right Insérer un aki à droite
Tsume Tsume
Character Direction Direction des caractères
Digits Chiffres
Arabic Arabes
Can't render this file because it is too large.
+1
View File
@@ -3698,6 +3698,7 @@ antiAlias Sharp शार्प
Proportional Metrics आनुपातिक मीट्रिक
Insert Aki Left बाईं ओर आकी जोड़ें
Insert Aki Right दाईं ओर आकी जोड़ें
Tsume त्सुमे
Proportional Oldstyle आनुपातिक ओल्डस्टाइल
Protect Corners कॉर्नर को सुरक्षित रखें
Proximity Guides निकटता गाइड
Can't render this file because it is too large.
+1
View File
@@ -3224,6 +3224,7 @@ mark-style Japanese Japán
Proportional Metrics Arányos metrika
Insert Aki Left Aki beszúrása balra
Insert Aki Right Aki beszúrása jobbra
Tsume Tsume
Proportional Lining Arányos modern
Position: Pozíció:
Default Position Alapértelmezett pozíció
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1509,6 +1509,7 @@ lighting Light Cahaya
Proportional Metrics Metrik proporsional
Insert Aki Left Sisipkan aki kiri
Insert Aki Right Sisipkan aki kanan
Tsume Tsume
Character Direction Arah karakter
Digits Digit
Arabic Arab
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1509
1510
1511
1512
1513
1514
1515
+1
View File
@@ -2708,6 +2708,7 @@ mark-style Japanese Giapponese
Proportional Metrics Metrica proporzionale
Insert Aki Left Inserisci aki a sinistra
Insert Aki Right Inserisci aki a destra
Tsume Tsume
Character Direction Direzione caratteri
Digits Cifre
Arabic Arabe
Can't render this file because it is too large.
+1
View File
@@ -52,6 +52,7 @@ font-technology Variable バリアブル
Proportional Metrics プロポーショナルメトリクス
Insert Aki Left 前にアキを挿入
Insert Aki Right 後ろにアキを挿入
Tsume 文字ツメ
Character Direction 文字の方向
Digits 数字
Arabic アラビア数字
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1446,6 +1446,7 @@ lighting Light სინათლე
Proportional Metrics პროპორციული მეტრიკა
Insert Aki Left აკის ჩასმა მარცხნივ
Insert Aki Right აკის ჩასმა მარჯვნივ
Tsume ცუმე
Character Direction სიმბოლოების მიმართულება
Digits ციფრები
Arabic არაბული
Can't render this file because it is too large.
+1
View File
@@ -46,6 +46,7 @@ font-technology Variable 가변
Proportional Metrics 비례 메트릭
Insert Aki Left 앞 아키 삽입
Insert Aki Right 뒤 아키 삽입
Tsume 츠메
Character Direction 문자 방향
Digits 숫자
Arabic 아라비아어
Can't render this file because it is too large.
+1
View File
@@ -2027,6 +2027,7 @@ lighting Light Licht
Proportional Metrics Proportionele metingen
Insert Aki Left Aki links invoegen
Insert Aki Right Aki rechts invoegen
Tsume Tsume
Character Direction Tekenrichting
Digits Cijfers
Arabic Arabisch
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1514,6 +1514,7 @@ lighting Light Światło
Proportional Metrics Metryka proporcjonalna
Insert Aki Left Wstaw aki z lewej
Insert Aki Right Wstaw aki z prawej
Tsume Tsume
Character Direction Kierunek znaków
Digits Cyfry
Arabic Arabskie
Can't render this file because it is too large.
+1
View File
@@ -2232,6 +2232,7 @@ antiAlias Sharp Nítido
Proportional Metrics Métrica Proporcional
Insert Aki Left Inserir Aki à Esquerda
Insert Aki Right Inserir Aki à Direita
Tsume Tsume
Character Direction Direção de Caracteres
Digits Dígitos
Arabic Arábicos
Can't render this file because it is too large.
+1
View File
@@ -2695,6 +2695,7 @@ mark-style Japanese Японский
Proportional Metrics Пропорциональные метрики
Insert Aki Left Вставить аки слева
Insert Aki Right Вставить аки справа
Tsume Цумэ
Character Direction Направление символов
Digits Цифры
Arabic Арабские
Can't render this file because it is too large.
+1
View File
@@ -2603,6 +2603,7 @@ font-technology Variable Variabla
Proportional Metrics Proportionella mått
Insert Aki Left Infoga aki till vänster
Insert Aki Right Infoga aki till höger
Tsume Tsume
Character Direction Teckenriktning
Digits Siffror
Arabic Arabiska
Can't render this file because it is too large.
+1
View File
@@ -2450,6 +2450,7 @@ shape Open Açık
Proportional Metrics Orantılı Metrikler
Insert Aki Left Sola Aki Ekle
Insert Aki Right Sağa Aki Ekle
Tsume Tsume
Digits Rakamlar
Arabic Arapça
Hindi Hintçe
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -2700,6 +2700,7 @@ mark-style Japanese Японський
Proportional Metrics Пропорційні метрики
Insert Aki Left Вставити акі ліворуч
Insert Aki Right Вставити акі праворуч
Tsume Цуме
Character Direction Напрямок символів
Digits Цифри
Arabic Арабські
Can't render this file because it is too large.
+1
View File
@@ -3013,6 +3013,7 @@ mark-style Japanese Kiểu Nhật
Proportional Metrics Số liệu theo tỷ lệ
Insert Aki Left Chèn aki bên trái
Insert Aki Right Chèn aki bên phải
Tsume Tsume
Proportional Lining Số tỷ lệ đứng
Position: Vị trí:
Default Position Vị trí mặc định
Can't render this file because it contains an unexpected character in line 204 and column 5.
+1
View File
@@ -2563,6 +2563,7 @@ font-script Japanese 汉字
Proportional Metrics 比例度量
Insert Aki Left 在前方插入空白
Insert Aki Right 在后方插入空白
Tsume 比例间距
Character Direction 字符方向
Digits 数字
Arabic 阿拉伯数字
Can't render this file because it is too large.
+1
View File
@@ -3779,6 +3779,7 @@ font-script Japanese 漢字
Proportional Metrics 比例度量
Insert Aki Left 在前方插入空白
Insert Aki Right 在後方插入空白
Tsume 比例間距
Character Direction 字元方向
Digits 數字
Arabic 阿拉伯數字
Can't render this file because it is too large.
+13 -3
View File
@@ -26,6 +26,8 @@ pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0
/// Insert Aki Left and Right presets, in thousandths of an em: none, a quarter, half and a whole
/// full-width space.
pub const AKI_PRESETS: [f64; 4] = [0.0, 250.0, 500.0, 1000.0];
/// Tsume presets, in percent.
pub const TSUME_PRESETS: [f64; 5] = [0.0, 25.0, 50.0, 75.0, 100.0];
/// The stored character style: the selected range's (or the caret's) while the Type
/// tool edits text, else the first selected text object's first run (including groups). With it, the paragraph
@@ -846,7 +848,7 @@ fn cell(ui: &mut Ui, label: &str, tip: &str, add: impl FnOnce(&mut Ui)) {
/// The Character panel's grid of fields, `w` wide: Font Size, Leading, Kerning and Tracking,
/// then with `more` the scales, Baseline Shift and Character Rotation, and a row with the
/// Anti-aliasing Method dropdown under Character Rotation (`s`: the style shown), and with Show East
/// Asian Options on a row with Insert Aki Left and Right. With Show Font
/// Asian Options on a row with Insert Aki Left and Right and a row with Tsume. With Show Font
/// Height Options on, a row under Font Size holds the Set Font Height Reference dropdown. The
/// Properties panel shows its first rows.
pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &CharStyle, w: f32, more: bool) {
@@ -942,6 +944,14 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &
});
}
ui.end_row();
// Tsume: the share of the blank space beside each character's ink taken away.
cell(ui, "→A←", tl!("Tsume"), |ui| {
if let Some(v) = widgets::spin_plain(ui, "ch-tsume", shared(app, |s| s.tsume), "%", 1, fw, 1.0, 0.0, &TSUME_PRESETS) {
// The field stops at the largest value the command takes.
format(app, json!({ "tsume": v.min(vectorcraft_doc::text::MAX_TSUME) }));
}
});
ui.end_row();
}
});
}
@@ -1686,7 +1696,7 @@ fn fractional_widths_on(app: &VectorcraftApp) -> bool {
/// and Small Caps off, the normal position (neither superscript nor subscript), Roman Baseline
/// character alignment, Proportional Metrics, Tate-chu-yoko, Warichu, Standard Vertical Roman Alignment and
/// No Break off, Tate-chu-yoko Settings Up/Down and Left/Right 0, the default Warichu Settings (2 lines, no
/// line gap, 50%, Auto alignment), Insert Aki Left and Right 0, the Default character direction,
/// line gap, 50%, Auto alignment), Insert Aki Left and Right 0, Tsume 0, the Default character direction,
/// Kashidas on, the Default digits and the Em Box font height reference, whether or not Show East Asian
/// Options, Show Indic Options and Show Font Height Options are on. Inside an undo group that is
/// already open, the reset is part of that group's step.
@@ -1706,7 +1716,7 @@ fn reset_panel(app: &mut VectorcraftApp) {
"allCaps": false, "smallCaps": d.small_caps.is_some(), "position": d.position.id(), "charAlign": d.char_align,
"proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko,
"tcyUpDown": d.tcy_up_down, "tcyLeftRight": d.tcy_left_right, "akiLeft": d.aki_left, "akiRight": d.aki_right,
"warichu": d.warichu, "warichuLines": d.warichu_lines, "warichuGap": d.warichu_gap, "warichuScale": d.warichu_scale,
"tsume": d.tsume, "warichu": d.warichu, "warichuLines": d.warichu_lines, "warichuGap": d.warichu_gap, "warichuScale": d.warichu_scale,
"warichuAlign": d.warichu_align,
"standardVerticalRoman": d.standard_vertical_roman, "noBreak": d.no_break, "charDirection": "default", "kashidas": d.kashidas,
"digits": d.digits, "fontHeightReference": d.font_height_reference
+39 -3
View File
@@ -780,6 +780,41 @@ fn insert_aki_fields_show_with_the_east_asian_options() {
assert!(left(&app).iter().all(|v| *v == 1000.0), "{:?}", left(&app));
}
/// Tsume is a field of the panel with the East Asian options only, and shows the selected
/// characters' value, blank where they differ. A value typed past 100 sets 100.
#[test]
fn tsume_field_shows_with_the_east_asian_options() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "(日本)"})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [id]})).unwrap();
app.run("text.setFormat", json!({"tsume": 37.5})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, show);
assert!(!text.contains("→A←") && !text.contains("37.5"), "{text}");
app.session.prefs.show_east_asian_options = true;
let text = crate::tests_labels::painted_text(&mut app, show);
assert!(text.contains("→A←") && text.contains("37.5"), "{text}");
// The characters differ: the field is blank.
app.run("text.setRangeStyle", json!({"id": id, "start": 3, "end": 12, "tsume": 62.5})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, show);
assert!(!text.contains("37.5") && !text.contains("62.5"), "{text}");
app.run("text.setFormat", json!({"tsume": 62.5})).unwrap();
use super::super::tests_appearance::{click, frame_events, text_rect};
let ctx = egui::Context::default();
let texts = frame_events(&ctx, &mut app, vec![], show);
click(&ctx, &mut app, text_rect(&texts, "62.5%").center(), show);
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
frame_events(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)], show);
frame_events(&ctx, &mut app, vec![egui::Event::Text("150".into())], show);
frame_events(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], show);
frame_events(&ctx, &mut app, vec![], show);
let tsume = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.runs.iter().map(|r| r.style.tsume).collect::<Vec<_>>(),
_ => panic!("text"),
};
assert!(tsume(&app).iter().all(|v| *v == 100.0), "{:?}", tsume(&app));
}
/// The panel menu's Show Font Height Options shows the Set Font Height Reference dropdown,
/// which shows the selected type's reference and sets the one picked (one undo step); Hide
/// Font Height Options hides it again.
@@ -1444,6 +1479,7 @@ fn reset_panel_resets_every_attribute_but_the_font() {
s.tcy_left_right != d.tcy_left_right,
s.aki_left != d.aki_left,
s.aki_right != d.aki_right,
s.tsume != d.tsume,
s.standard_vertical_roman != d.standard_vertical_roman,
s.no_break != d.no_break,
s.char_direction != d.char_direction,
@@ -1458,16 +1494,16 @@ fn reset_panel_resets_every_attribute_but_the_font() {
"id": id, "tracking": 50, "leading": 30, "kerning": 20, "baselineShift": 3, "hScale": 120, "vScale": 90, "rotation": 15,
"underline": true, "strikethrough": true, "allCaps": true, "smallCaps": true, "position": "superscript",
"charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "tcyUpDown": 2, "tcyLeftRight": -3,
"akiLeft": 250, "akiRight": 500, "standardVerticalRoman": true, "noBreak": true, "charDirection": "rightToLeft", "digits": "hindi", "fontHeightReference": "capHeight"
"akiLeft": 250, "akiRight": 500, "tsume": 50, "standardVerticalRoman": true, "noBreak": true, "charDirection": "rightToLeft", "digits": "hindi", "fontHeightReference": "capHeight"
}),
)
.unwrap();
};
let plain = runs(&app)[0].1.clone();
assert_eq!(differs(&plain, &CharStyle::default()), [false; 24]);
assert_eq!(differs(&plain, &CharStyle::default()), [false; 25]);
custom(&mut app);
let styled = runs(&app)[0].1.clone();
assert_eq!(differs(&styled, &CharStyle::default()), [true; 24]);
assert_eq!(differs(&styled, &CharStyle::default()), [true; 25]);
app.run("select.set", json!({"ids": [id]})).unwrap();
reset_panel(&mut app);
assert_eq!(runs(&app), [("One 12".to_string(), plain.clone())]);
+25 -2
View File
@@ -8812,6 +8812,28 @@ Insert Aki values; save it with Preserve Editing to keep them editable.
{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":3,"akiRight":500}}}
```
## Tsume
`text.setFormat` and `text.setRangeStyle {tsume?}` set Tsume of the characters, a percentage that narrows the space
around them ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)). The range,
the default and how the space is measured are VectorCraft's own rules: the value is from 0 to 100, 0 by default and
saved only when not 0, and it is the share of the blank space on each side of a character's ink, inside the
character's own width, that is taken away. 50 halves that space, and 100 sets the ink against the characters before and
after it; the glyph keeps its shape. A character without ink, such as a space, keeps its width. A ligature or a letter
with its marks takes the value of its first character. Tracking, kerning and Insert Aki are added after it, and
justification keeps it. Tsume applies to horizontal type; vertical type keeps its spacing. Character styles carry it
(`charStyle.new`, `charStyle.setAttrs` and `charStyle.edit` refuse a value outside the range). A value that is not a
number is refused with `` `tsume` must be a number ``, and one outside the range with `` `tsume` must be from 0 to
100 ``. One undo step, and a call that gives the value the characters already have records none. With Show East Asian
Options on, the Character panel shows a Tsume field, blank where the selected characters differ, with 0, 25, 50, 75 and
100 in its menu, and a value typed past 100 sets 100; Reset Panel sets it to 0. Character Style Options shows it in
Advanced Character Formats. Live SVG text places each character Tsume moves, and type on a path with Tsume is written
as outlines.
```json
{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":3,"tsume":50}}}
```
## Composite Fonts
A composite font sets each class of character in a font of its own
@@ -9514,7 +9536,7 @@ attribute map in one undo step. `to` is trimmed and must be unique. The command
while preserving local overrides; Undo/Redo restores the definition and its users. The `charStyleOptions`
dialog exposes `name`, `font_family`, `font_style`, `size`, `leading`, `kerning`, `tracking`, `case`,
`position`, `underline`, `strikethrough`, `charAlign`, `h_scale`, `v_scale`, `baseline_shift`, `tcyUpDown`,
`tcyLeftRight`, `rotation`, `akiLeft`, `akiRight`, `language`, `fill`, `fill_tint`, `stroke`, `stroke_tint`, `stroke_width`, `overprint_fill`, `overprint_stroke`,
`tcyLeftRight`, `rotation`, `akiLeft`, `akiRight`, `tsume`, `language`, `fill`, `fill_tint`, `stroke`, `stroke_tint`, `stroke_width`, `overprint_fill`, `overprint_stroke`,
`ot_figure`, `ot_position`, `ot_liga`, `ot_calt`, `ot_dlig`, `ot_swsh`, `ot_salt`, `ot_titl`, `ot_ordn`, `ot_frac`,
`ot_sets`, `section` (`general`, `basic`, `advanced`, `color` or `opentype`), and `preview`. An empty field leaves its
attribute unset. `leading` and `kerning` also accept `Auto` to store an explicit automatic value.
@@ -9522,7 +9544,8 @@ Advanced Character Formats holds `h_scale` and `v_scale` (percent, 1 to 10000),
`baseline_shift` (points, -1296 to 1296), the Tate-chu-yoko Settings `tcyUpDown` and `tcyLeftRight` (points,
-1296 to 1296, as `text.setRangeStyle` takes them; an explicit 0 stays distinct from unset), `rotation` (degrees, stored from -180 to 180), Insert Aki Left and Right `akiLeft` and `akiRight` (thousandths of
an em, with the Character panel's 0, 250, 500 and 1000 in their menus; a value typed below 0 or past 1000 sets 0 or
1000) and `language` (a name or tag from Document
1000), Tsume `tsume` (percent, with the Character panel's 0, 25, 50, 75 and 100 in its menu; a value typed below 0 or
past 100 sets 0 or 100; an explicit 0 stays distinct from unset) and `language` (a name or tag from Document
Setup's languages; English: USA stores the explicit default `null`, and a tag not in the list that the style
already has is kept). Language sets the language the characters are shaped in; it does not change hyphenation
or spelling.
+1
View File
@@ -1403,6 +1403,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, on a vertical path it moves toward the path's start and to the path's left, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them.
A file whose character styles hold a Tate-chu-yoko Up/Down or Left/Right offset past the coordinate limit (`MAX_COORD`) either way opens with the nearer end, as the type's own offsets do; an offset within it stays as saved.
Character panel › Insert Aki Left and Insert Aki Right (with the East Asian options; `text.setFormat` or `text.setRangeStyle {akiLeft, akiRight}`, thousandths of an em from 0 to 1000, 0 by default) add space before and after each selected character, measured as tracking is, and kept as set through justification ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); before and after follow the reading order in vertical and right-to-left type, character styles carry both values (Character Style Options › Advanced Character Formats edits them), and a file holding a value outside the range opens with the nearer end.
Character panel › Tsume (with the East Asian options; `text.setFormat` or `text.setRangeStyle {tsume}`) narrows the space around each selected character ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); by VectorCraft's own rule the value is a percentage from 0 to 100, 0 by default, of the blank space on each side of the character's ink that horizontal type takes away without changing the glyph's shape, with tracking, kerning and Insert Aki added after it. Character styles carry it (Character Style Options › Advanced Character Formats edits it), and a file holding a value outside the range opens with the nearer end.
The Character panel's All Caps, Underline and Strikethrough buttons and its menu's All Caps, Underline, Strikethrough, Proportional Metrics, Tate-chu-yoko and No Break show off while only some of the selected characters have the attribute, and choosing one then turns it on for all of them, as `type.underline` (Cmd+Shift+U) does for underlining; the Character Alignment and Digits submenus check no item while the selected characters differ. `text.setFormat` refuses a value that is not true or false for its on/off attributes, as `text.setRangeStyle` does.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286).
Isolation mode and opacity mask editing exclude each other ([transparency and blending modes](https://web.archive.org/web/20230102045151/https://helpx.adobe.com/illustrator/using/transparency-blending-modes.html)): in isolation mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Enter Isolation Mode and Object › Perspective › Edit Text are off, the Contextual Task Bar leaves out Isolate, and Quick Actions leave out Isolate Group.