mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 14:41:52 +00:00
Merge pull request #7507 from dotCooCoo/feat/cjk-tsume
Type: Tsume narrows the space around each character's ink
This commit is contained in:
@@ -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::{
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -30,6 +30,7 @@ mod shape_plans;
|
||||
mod snap;
|
||||
mod svg_glyphs;
|
||||
mod sysfonts;
|
||||
mod tsume;
|
||||
mod typo;
|
||||
mod variable;
|
||||
mod versions;
|
||||
|
||||
@@ -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(<) < gap(&lp) - 1.0, "{} {}", gap(&lp), gap(<));
|
||||
}
|
||||
|
||||
#[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()
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user