From 1494e2c230f173335c64baa71bad705dee4b860d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:50:05 -0800 Subject: [PATCH 1/2] Type: Tsume narrows the space around each character's ink Character panel > Tsume, shown with Show East Asian Options, takes away a percentage of the blank space on each side of a character's ink, inside its own width, in horizontal type. The value is from 0 to 100, 0 by default. 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, and a ligature or a letter with its marks takes the value of its first character. The font's pair kerning, tracking, manual kerning and Insert Aki are added after it, and justification keeps it. Vertical type keeps its spacing. - text.setFormat and text.setRangeStyle take `tsume`; a value that is not a number or is outside 0 to 100 is refused, naming the key. Character styles carry it, and a file holding a value outside the range opens with the nearer end. - The Character panel's Tsume field has 0, 25, 50, 75 and 100 in its menu, and 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. Docs: docs/mcp.md (Tsume, Character Style Options fields), docs/target-app-parity.md. --- crates/doc/src/text.rs | 2 +- crates/doc/src/text/char_style.rs | 16 +- crates/doc/src/text/object.rs | 2 + crates/doc/src/text/tabs_lists.rs | 3 +- crates/doc/src/text/tests.rs | 28 ++++ crates/engine/src/cmd/panelcmds.rs | 7 +- crates/engine/src/cmd/textedit.rs | 19 ++- crates/engine/src/cmd/textstyles.rs | 10 +- crates/engine/src/cmd/textstyles/tests.rs | 5 +- crates/engine/src/cmd/typecmd/area_tests.rs | 55 +++++++ crates/svg/src/export/text.rs | 16 +- crates/svg/tests/it/text_layout.rs | 29 ++++ crates/text/src/harakat.rs | 1 + crates/text/src/shape.rs | 94 ++++++++++-- crates/text/src/tests.rs | 1 + crates/text/src/tests/tsume.rs | 143 ++++++++++++++++++ .../ui-egui/src/dialogs/char_style_options.rs | 70 ++++++++- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels/character.rs | 16 +- crates/ui-egui/src/panels/character/tests.rs | 42 ++++- docs/mcp.md | 27 +++- docs/target-app-parity.md | 1 + 48 files changed, 579 insertions(+), 35 deletions(-) create mode 100644 crates/text/src/tests/tsume.rs diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 440c379283..c3c1464cfc 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -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::{ diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 16b63e0809..ef3cf29dc9 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -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. + /// + #[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 = 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 = diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index dd4002dd60..9af4d9c5dc 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -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 { diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index b8531b402d..26622fa115 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -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, diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index 0f474d2ed8..c3afafb6e1 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -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::(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::(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] diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index f9c4b10049..d879a546ab 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"yakumonoHankaku\"|\"gyomatsuYakumonoHankaku\"|\"gyomatsuYakumonoZenkaku\"|\"yakumonoZenkaku\"|\"lineEndHalf\" (Mojikumi Set: Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"yakumonoHankaku\"|\"gyomatsuYakumonoHankaku\"|\"gyomatsuYakumonoZenkaku\"|\"yakumonoZenkaku\"|\"lineEndHalf\" (Mojikumi Set: Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options, as text.setRangeStyle: the share of the blank space beside each character's ink taken away in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", has_doc, set_format ), @@ -588,6 +588,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "warichuAlign", "akiLeft", "akiRight", + "tsume", "standardVerticalRoman", "noBreak", "digits", @@ -632,6 +633,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let tcy_left_right = super::textedit::tcy_offset_param(p, "tcyLeftRight", C)?; let aki_left = super::textedit::aki_param(p, "akiLeft", C)?; let aki_right = super::textedit::aki_param(p, "akiRight", C)?; + let tsume = super::textedit::tsume_param(p, C)?; let warichu_lines = super::textedit::warichu_lines_param(p, C)?; let warichu_gap = super::textedit::warichu_number_param(p, "warichuGap", C)?; let warichu_scale = super::textedit::warichu_number_param(p, "warichuScale", C)?; @@ -816,6 +818,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = aki_right { st.aki_right = v; } + if let Some(v) = tsume { + st.tsume = v; + } if let Some(v) = flag("standardVerticalRoman") { st.standard_vertical_roman = v; } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 0fd7c7d97d..9ed7f3252b 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options: the share of the blank space on each side of each character's ink, inside its own width, that is taken away in horizontal type, the glyph keeping its shape; 100 sets the ink edge to edge; 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; vertical type keeps the characters' spacing; 0 by default; a value that is not a number or is outside the range is refused), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -468,6 +468,8 @@ pub(crate) struct CharChange { /// Insert Aki Left and Right, in 1/1000 em. aki_left: Option, aki_right: Option, + /// Tsume, in percent. + tsume: Option, standard_vertical_roman: Option, no_break: Option, digits: Option, @@ -545,6 +547,16 @@ pub(crate) fn aki_param(p: &Value, key: &str, cmd: &str) -> Result> if (lo..=hi).contains(&v) { Ok(Some(v)) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) } } +/// `tsume` (Tsume): a percentage from 0 to [`vectorcraft_doc::text::MAX_TSUME`]. Any other value is +/// an error. +pub(crate) fn tsume_param(p: &Value, cmd: &str) -> Result> { + let key = "tsume"; + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; + let v = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?; + let (lo, hi) = (0.0, vectorcraft_doc::text::MAX_TSUME); + if (lo..=hi).contains(&v) { Ok(Some(v)) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) } +} + /// `digits: "default"|"arabic"|"hindi"|"farsi"|"devanagari"|…` ([`Digits::id`]) → the numeral /// system the digits are drawn in. /// @@ -824,6 +836,7 @@ impl CharChange { warichu_align: warichu_align_param(p, cmd)?, aki_left: aki_param(p, "akiLeft", cmd)?, aki_right: aki_param(p, "akiRight", cmd)?, + tsume: tsume_param(p, cmd)?, standard_vertical_roman: flag("standardVerticalRoman"), no_break: flag("noBreak"), digits: digits_param(p, cmd)?, @@ -888,6 +901,7 @@ impl CharChange { && self.warichu_align.is_none() && self.aki_left.is_none() && self.aki_right.is_none() + && self.tsume.is_none() && self.standard_vertical_roman.is_none() && self.no_break.is_none() && self.digits.is_none() @@ -976,6 +990,9 @@ impl CharChange { if let Some(v) = self.aki_right { st.aki_right = v; } + if let Some(v) = self.tsume { + st.tsume = v; + } if let Some(v) = self.standard_vertical_roman { st.standard_vertical_roman = v; } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index ea1a7a22bd..17d453abf4 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -222,6 +222,7 @@ pub fn char_attrs(v: &CharStyle) -> Map { ("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 { /// 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, Vec) { - const RANGED: [&str; 21] = [ + const RANGED: [&str; 22] = [ "size", "h_scale", "v_scale", @@ -1088,6 +1093,7 @@ fn loadable(src: &Document, kind: Kind) -> (Vec, Vec) { "baseline_shift", "akiLeft", "akiRight", + "tsume", "tcyUpDown", "tcyLeftRight", "warichuLines", diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 3e0727ce4a..c8da4b578c 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -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(); diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index c68334e2c0..aa052d5469 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -1822,6 +1822,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::>(); + 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. diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index 2b9139f19b..ccd5f56806 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -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 `` 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 { 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 { diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index 25d4d27d68..0678f39f8a 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -196,6 +196,35 @@ fn insert_aki_places_each_moved_character() { assert!(!out.svg.contains("` where the character is +/// drawn, so a reader draws the text where the canvas does, with fewer ``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(" bool { kerning_before: _, aki_left: _, aki_right: _, + tsume: _, optical_kerning, baseline_shift, h_scale, diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index ff79ce9a4c..befcbed41f 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -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, &CharStyle)], span: &Range, 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, + /// 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, } /// 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, 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, seg: &Segment, feats: &OtFe .filter(|l| l.len() <= 35 && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')) .and_then(|l| l.parse::().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, 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, 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, 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, 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, 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::*; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index e7e4f33ff6..423984a630 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -30,6 +30,7 @@ mod shape_plans; mod snap; mod svg_glyphs; mod sysfonts; +mod tsume; mod typo; mod variable; mod versions; diff --git a/crates/text/src/tests/tsume.rs b/crates/text/src/tests/tsume.rs new file mode 100644 index 0000000000..72f0d69f19 --- /dev/null +++ b/crates/text/src/tests/tsume.rs @@ -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 { + 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 = l.glyphs.iter().map(|g| g.xf.as_coeffs()[4]).collect(); + let base_xs: Vec = 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::>(); + assert_eq!(centers(&with(&plain, 0, |s| s.tsume = 100.0)), centers(&plain), "{text}"); + } +} diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index f34363e5b7..e807df7b01 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -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, 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, 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 = 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 { vectorcraft_text::EXTRA_FEATURES[..20].iter().map(|s| format!("-{s}")).collect() diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1f469c69c9..3efa7fccd5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2586,6 +2586,7 @@ font-technology Variable Променливи Proportional Metrics Пропорционални метрики Insert Aki Left Вмъкване на аки отляво Insert Aki Right Вмъкване на аки отдясно + Tsume Цуме Character Direction Посока на знаците Digits Цифри Arabic Арабски diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a23133933a..abab434790 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1570,6 +1570,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 diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 373eb0b79d..6bed026393 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3356,6 +3356,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 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7178be3143..e9f0c1f5a7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1598,6 +1598,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é diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 433c6af3d6..404e6c1fba 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1506,6 +1506,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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 951f6f9c37..6cca4c48fb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2630,6 +2630,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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 62e3adc491..10c16e2ca2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3425,6 +3425,7 @@ theme Appearance Εμφάνιση Proportional Metrics Αναλογικές μετρικές Insert Aki Left Εισαγωγή aki αριστερά Insert Aki Right Εισαγωγή aki δεξιά + Tsume Tsume Arabic Αραβικά Hindi Χίντι Farsi Περσικά diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d1c6aa53af..0bb69b2ac4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2664,6 +2664,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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 45e47fd355..cbd9126586 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1573,6 +1573,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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 057dce6872..020249fba0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2656,6 +2656,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 diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9d056e085a..c675e8c62d 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3694,6 +3694,7 @@ antiAlias Sharp शार्प Proportional Metrics आनुपातिक मीट्रिक Insert Aki Left बाईं ओर आकी जोड़ें Insert Aki Right दाईं ओर आकी जोड़ें + Tsume त्सुमे Proportional Oldstyle आनुपातिक ओल्डस्टाइल Protect Corners कॉर्नर को सुरक्षित रखें Proximity Guides निकटता गाइड diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c2f1a52cd9..f2193f9f5a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3222,6 +3222,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ó diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a36c629332..02cd2ecd6f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1506,6 +1506,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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0ac2816b07..978afec841 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2706,6 +2706,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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 71de1ce91c..ee3c06bfcd 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -52,6 +52,7 @@ font-technology Variable バリアブル Proportional Metrics プロポーショナルメトリクス Insert Aki Left 前にアキを挿入 Insert Aki Right 後ろにアキを挿入 + Tsume 文字ツメ Character Direction 文字の方向 Digits 数字 Arabic アラビア数字 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 2cff51d94b..a4eeb727df 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1443,6 +1443,7 @@ lighting Light სინათლე Proportional Metrics პროპორციული მეტრიკა Insert Aki Left აკის ჩასმა მარცხნივ Insert Aki Right აკის ჩასმა მარჯვნივ + Tsume ცუმე Character Direction სიმბოლოების მიმართულება Digits ციფრები Arabic არაბული diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4715a413f4..277ba94b19 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -46,6 +46,7 @@ font-technology Variable 가변 Proportional Metrics 비례 메트릭 Insert Aki Left 앞 아키 삽입 Insert Aki Right 뒤 아키 삽입 + Tsume 츠메 Character Direction 문자 방향 Digits 숫자 Arabic 아라비아어 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 94957fb782..9136088b9c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2023,6 +2023,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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 48f2671c2d..398a132865 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1511,6 +1511,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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f6e54552d4..8c932fc75e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2230,6 +2230,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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8dc8336089..2c665847b3 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2693,6 +2693,7 @@ mark-style Japanese Японский Proportional Metrics Пропорциональные метрики Insert Aki Left Вставить аки слева Insert Aki Right Вставить аки справа + Tsume Цумэ Character Direction Направление символов Digits Цифры Arabic Арабские diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index cfb9d9734b..3257eea28a 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2601,6 +2601,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 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2b3e843ba3..ff73450aa6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2448,6 +2448,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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 50690fa0c7..fe95325a35 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2698,6 +2698,7 @@ mark-style Japanese Японський Proportional Metrics Пропорційні метрики Insert Aki Left Вставити акі ліворуч Insert Aki Right Вставити акі праворуч + Tsume Цуме Character Direction Напрямок символів Digits Цифри Arabic Арабські diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d8469bce53..61ef698a95 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3011,6 +3011,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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d9d9110276..eac6bdc3ef 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2561,6 +2561,7 @@ font-script Japanese 汉字 Proportional Metrics 比例度量 Insert Aki Left 在前方插入空白 Insert Aki Right 在后方插入空白 + Tsume 比例间距 Character Direction 字符方向 Digits 数字 Arabic 阿拉伯数字 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a0d987fe69..5dc06037f5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3776,6 +3776,7 @@ font-script Japanese 漢字 Proportional Metrics 比例度量 Insert Aki Left 在前方插入空白 Insert Aki Right 在後方插入空白 + Tsume 比例間距 Character Direction 字元方向 Digits 數字 Arabic 阿拉伯數字 diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 44ae643e86..9926855375 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -25,6 +25,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 @@ -795,7 +797,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) { @@ -891,6 +893,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(); } }); } @@ -1612,7 +1622,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. @@ -1632,7 +1642,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 diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index 7a2a96efb2..64a8fa0359 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -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::>(), + _ => 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())]); diff --git a/docs/mcp.md b/docs/mcp.md index f756bc8c97..016e7e26d0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8747,6 +8747,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 @@ -9449,7 +9471,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. @@ -9457,7 +9479,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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..ac9cad339a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1381,6 +1381,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 `` 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. From a2e98feb646111f6555885c1bb9c215aba4ad340 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 02:18:24 -0800 Subject: [PATCH 2/2] i18n: keep bracketed field names untranslated in fr rows --- crates/ui-egui/src/i18n/fr.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index df0ba85bc8..61a35d5db5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -7568,7 +7568,7 @@ breakText Glyphs Glyphes @msg `alignOn` must be a string `alignOn` doit être une chaîne @msg `align` is start, center or end `align` vaut start, center ou end @msg `align` must be a string `align` doit être une chaîne -@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` doit être une liste de paires [sous-tracé, point d’ancrage] +@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` doit être une liste de paires [subpath, anchor] @msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` doit lister des indices de points d’ancrage (0, 1, 2…), pas {k} @msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` doit lister des indices de points d’ancrage (0, 1, 2…), pas {v} @msg `answer` for this {kind} dialog must be one of: {_1} `answer` pour cette boîte de dialogue {kind} doit valoir l’une de ces valeurs : {_1} @@ -7632,7 +7632,7 @@ breakText Glyphs Glyphes @msg `fidelity` must be from {lo} to {hi} pt `fidelity` doit être compris entre {lo} et {hi} pt @msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` doit contenir de 1 à 1000 chemins de fichiers de police trouvés par la dernière recherche text.findFontFiles @msg `focal` and `radius` cannot be used together `focal` et `radius` ne peuvent pas être utilisés ensemble -@msg `focal` must be an array of [subpath, anchor] pairs `focal` doit être une liste de paires [sous-tracé, point d’ancrage] +@msg `focal` must be an array of [subpath, anchor] pairs `focal` doit être une liste de paires [subpath, anchor] @msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` désigne [{si}, {ai}], qui n’est pas un point d’ancrage du tracé @msg `folder` must be an absolute folder path `folder` doit être le chemin absolu d’un dossier @msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` doit être un objet de balises d’axe et de valeurs, comme {"wght"}