diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8507b50754..d6b797a371 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -119,6 +119,7 @@ pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOption pub use style_libs::StyleLibrary; pub use text::FontHeightReference; pub use text::GlyphChoice; +pub use text::GlyphForm; pub use text::WarichuAlign; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index c3c1464cfc..89388d8598 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -18,8 +18,8 @@ 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_TSUME, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES, - WARICHU_SCALE, WarichuAlign, + CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, GlyphForm, MAX_AKI, MAX_TSUME, MAX_WARICHU_GAP, ScriptMetrics, + WARICHU_LINES, WARICHU_SCALE, WarichuAlign, }; pub use composite::{ COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CharClass, CompositeFont, CompositePart, diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index ef3cf29dc9..97f744dba8 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -205,6 +205,99 @@ pub struct CharStyle { /// default glyph otherwise. Saved only when set. #[serde(default, rename = "glyphAlternate", skip_serializing_if = "Option::is_none")] pub glyph_alternate: Option, + /// The East Asian glyph form these characters are drawn in (Glyphs panel menu, with Show East + /// Asian Options): one OpenType feature of the font, such as JIS 78 forms (`jp78`), or the + /// font's default glyphs. The text keeps its characters. Saved only when set. + /// + #[serde(default, rename = "glyphForm", skip_serializing_if = "crate::skip::is_default")] + pub glyph_form: GlyphForm, +} + +/// The East Asian glyph form characters are drawn in ([`CharStyle::glyph_form`]): the font's +/// default glyphs, or the glyphs one OpenType feature substitutes. One form at a time. +/// +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GlyphForm { + /// The font's default glyphs. + #[default] + Default, + /// Traditional forms (`trad`). + Traditional, + /// Expert forms (`expt`). + Expert, + /// JIS 2004 forms (`jp04`). + Jis04, + /// JIS 90 forms (`jp90`). + Jis90, + /// JIS 78 forms (`jp78`). + Jis78, + /// JIS 83 forms (`jp83`). + Jis83, + /// Monospaced full-width forms (`fwid`). + FullWidth, + /// Monospaced half-width forms (`hwid`). + HalfWidth, + /// Monospaced third-width forms (`twid`). + ThirdWidth, + /// Monospaced quarter-width forms (`qwid`). + QuarterWidth, +} + +impl GlyphForm { + /// Every form, in menu order, the default last. + pub const ALL: [GlyphForm; 11] = [ + GlyphForm::Traditional, + GlyphForm::Expert, + GlyphForm::Jis04, + GlyphForm::Jis90, + GlyphForm::Jis78, + GlyphForm::Jis83, + GlyphForm::FullWidth, + GlyphForm::HalfWidth, + GlyphForm::ThirdWidth, + GlyphForm::QuarterWidth, + GlyphForm::Default, + ]; + + /// The name files and commands give this form (as it is saved). + pub fn id(self) -> &'static str { + match self { + GlyphForm::Default => "default", + GlyphForm::Traditional => "traditional", + GlyphForm::Expert => "expert", + GlyphForm::Jis04 => "jis04", + GlyphForm::Jis90 => "jis90", + GlyphForm::Jis78 => "jis78", + GlyphForm::Jis83 => "jis83", + GlyphForm::FullWidth => "fullWidth", + GlyphForm::HalfWidth => "halfWidth", + GlyphForm::ThirdWidth => "thirdWidth", + GlyphForm::QuarterWidth => "quarterWidth", + } + } + + /// The form [`GlyphForm::id`] names; `None` for any other name. + pub fn from_id(id: &str) -> Option { + GlyphForm::ALL.into_iter().find(|f| f.id() == id) + } + + /// The OpenType feature that draws this form; `None` for the default glyphs. + pub fn feature(self) -> Option<&'static [u8; 4]> { + Some(match self { + GlyphForm::Default => return None, + GlyphForm::Traditional => b"trad", + GlyphForm::Expert => b"expt", + GlyphForm::Jis04 => b"jp04", + GlyphForm::Jis90 => b"jp90", + GlyphForm::Jis78 => b"jp78", + GlyphForm::Jis83 => b"jp83", + GlyphForm::FullWidth => b"fwid", + GlyphForm::HalfWidth => b"hwid", + GlyphForm::ThirdWidth => b"twid", + GlyphForm::QuarterWidth => b"qwid", + }) + } } /// A glyph chosen for one character ([`CharStyle::glyph_alternate`]): the glyph id and the face it @@ -586,6 +679,7 @@ impl Default for CharStyle { no_break: false, language: None, glyph_alternate: None, + glyph_form: GlyphForm::Default, } } } diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index c6cb4d987d..fa0492c2db 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), 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}", + "{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), glyphForm?: \"traditional\"|\"expert\"|\"jis04\"|\"jis90\"|\"jis78\"|\"jis83\"|\"fullWidth\"|\"halfWidth\"|\"thirdWidth\"|\"quarterWidth\"|\"default\" (Glyphs panel menu, with Show East Asian Options, as text.setRangeStyle: the characters drawn in one OpenType form of the font, or its default glyphs), 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 ), @@ -617,6 +617,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "standardVerticalRoman", "noBreak", "digits", + "glyphForm", "fontHeightReference", "language", "fontAxes", @@ -651,6 +652,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let char_align = super::textedit::char_align_param(p, C)?; let char_direction = super::textedit::char_direction_param(p, C)?; let digits = super::textedit::digits_param(p, C)?; + let glyph_form = super::textedit::glyph_form_param(p, C)?; let language = super::textedit::language_param(p, C)?; let font_axes = super::textedit::font_axes_param(p, C)?; let font_height_reference = super::textedit::font_height_reference_param(p, C)?; @@ -821,6 +823,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = digits { st.digits = v; } + if let Some(v) = glyph_form { + st.glyph_form = v; + } if let Some(v) = &language { st.language.clone_from(v); } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 9ed7f3252b..ee843df8dd 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), 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)}", + "{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), glyphForm?: \"traditional\"|\"expert\"|\"jis04\"|\"jis90\"|\"jis78\"|\"jis83\"|\"fullWidth\"|\"halfWidth\"|\"thirdWidth\"|\"quarterWidth\"|\"default\" (Glyphs panel menu, with Show East Asian Options: the characters are drawn in that form of the font, the OpenType feature trad, expt, jp04, jp90, jp78, jp83, fwid, hwid, twid or qwid, one form at a time; a character the font has no such glyph for keeps its own; \"default\", Revert To Default Forms, draws the font's default glyphs; another value is refused), 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 ), @@ -473,6 +473,7 @@ pub(crate) struct CharChange { standard_vertical_roman: Option, no_break: Option, digits: Option, + glyph_form: Option, /// The language to set: a tag, or `None` for the default. language: Option>, font_axes: Option, @@ -571,6 +572,20 @@ pub(crate) fn digits_param(p: &Value, cmd: &str) -> Result Result> { + use vectorcraft_doc::GlyphForm; + let Some(v) = p.get("glyphForm").filter(|v| !v.is_null()) else { return Ok(None) }; + let Some(f) = v.as_str().and_then(GlyphForm::from_id) else { + let ids: Vec = GlyphForm::ALL.iter().map(|f| format!("\"{}\"", f.id())).collect(); + return Err(bad(cmd, format!("`glyphForm` must be one of {}", ids.join(", ")))); + }; + Ok(Some(f)) +} + /// `language: name|tag` (one of Document Setup's languages, case-insensitive) → the language to /// set ([`vectorcraft_doc::CharStyle::language`]: `None` inside for the default). pub(crate) fn language_param(p: &Value, cmd: &str) -> Result>> { @@ -840,6 +855,7 @@ impl CharChange { standard_vertical_roman: flag("standardVerticalRoman"), no_break: flag("noBreak"), digits: digits_param(p, cmd)?, + glyph_form: glyph_form_param(p, cmd)?, language: language_param(p, cmd)?, font_axes: font_axes_param(p, cmd)?, font_height_reference: font_height_reference_param(p, cmd)?, @@ -905,6 +921,7 @@ impl CharChange { && self.standard_vertical_roman.is_none() && self.no_break.is_none() && self.digits.is_none() + && self.glyph_form.is_none() && self.language.is_none() && self.font_axes.is_none() && self.font_height_reference.is_none() @@ -1070,6 +1087,9 @@ impl CharChange { if let Some(v) = self.digits { st.digits = v; } + if let Some(v) = self.glyph_form { + st.glyph_form = v; + } if let Some(v) = self.font_height_reference { st.font_height_reference = v; } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 17d453abf4..18f87d44f7 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -228,6 +228,7 @@ pub fn char_attrs(v: &CharStyle) -> Map { ("digits", json!(v.digits)), ("fontHeightReference", json!(v.font_height_reference)), ("language", json!(v.language)), + ("glyphForm", json!(v.glyph_form)), ] { m.insert(k.into(), x); } diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 36a0f965b7..ca9e4dc2ed 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -1925,6 +1925,55 @@ fn standard_vertical_roman_is_a_character_attribute_saved_only_when_on() { assert!(runs(&s).iter().all(|r| !r.style.standard_vertical_roman)); } +/// The East Asian glyph form (Glyphs panel menu) is one character attribute, `glyphForm`: one +/// form replaces another, "default" (Revert To Default Forms) takes it away, and it is saved +/// only when set. A call that changes nothing records no undo step, an unknown form is refused, +/// and character styles carry it. +#[test] +fn glyph_form_is_one_character_attribute_saved_only_when_set() { + use vectorcraft_doc::GlyphForm; + 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 forms = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.glyph_form)).collect::>(); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("glyphForm").is_none()); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "glyphForm": "jis78"})).unwrap(); + assert_eq!(forms(&s), [("亜".into(), GlyphForm::Default), ("唖".into(), GlyphForm::Jis78), ("芦".into(), GlyphForm::Default)]); + assert_eq!(serde_json::to_value(&runs(&s)[1].style).unwrap()["glyphForm"], "jis78"); + // Saved and reopened as it was. + 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") }; + assert_eq!(t.runs.iter().map(|r| (r.text.clone(), r.style.glyph_form)).collect::>(), forms(&s)); + // Another form replaces it: one form at a time. + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"glyphForm": "fullWidth"})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::FullWidth)); + let n = s.doc().unwrap().history.undo.len(); + s.execute("text.setFormat", &json!({"glyphForm": "fullWidth"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n, "nothing changed, no undo step"); + for bad in [json!("jis2004"), json!(78), json!(true), json!("Traditional")] { + let e = s.execute("text.setFormat", &json!({"glyphForm": bad})).unwrap_err().to_string(); + assert!(e.contains("`glyphForm` must be one of \"traditional\""), "{bad}: {e}"); + assert!(s.execute("text.setRangeStyle", &json!({"id": id, "glyphForm": bad})).is_err(), "{bad}"); + } + assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::FullWidth), "a refused call changes nothing"); + assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[0].style)["glyphForm"], "fullWidth"); + s.execute("text.setFormat", &json!({"glyphForm": "default"})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::Default)); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("glyphForm").is_none()); + // A character style carries the form. + s.execute("charStyle.new", &json!({"name": "Old forms", "attrs": {"glyphForm": "jis83"}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Old forms", "id": id})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::Jis83)); + assert!(s.execute("charStyle.new", &json!({"name": "Bad", "attrs": {"glyphForm": "jis2004"}})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::Default)); +} + /// No Break is a character attribute, saved only when on. #[test] fn no_break_is_a_character_attribute_saved_only_when_on() { diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index f7e067aa30..5b0b921e32 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -194,6 +194,10 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { if st.proportional_metrics { v.push("\"palt\" 1".into()); } + // The East Asian glyph form: its one feature. + if let Some(tag) = st.glyph_form.feature().and_then(|t| std::str::from_utf8(t).ok()) { + v.push(format!("\"{tag}\" 1")); + } if !v.is_empty() { p.push(("font-feature-settings", v.join(", "))); } diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index 9f2b555e23..112573104d 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -319,6 +319,9 @@ impl OtFeatures { v.push(f(&t, u32::from(s.extra_values.get(i).copied().unwrap_or(0).max(1)))); } } + if let Some(tag) = st.glyph_form.feature() { + v.push(f(tag, true)); + } if self.vertical { // Not `vrt2`: its glyphs are already turned on their side, and the layout turns Latin // and digits itself (they would lie upside down, and tate-chu-yoko break). diff --git a/crates/text/src/fontdb/gsub.rs b/crates/text/src/fontdb/gsub.rs index 888c89ab09..bcc19f74b0 100644 --- a/crates/text/src/fontdb/gsub.rs +++ b/crates/text/src/fontdb/gsub.rs @@ -304,6 +304,20 @@ impl FontFace { self.gsub.get_or_init(|| GsubIndex::read(self)) } + /// Does the face's `GSUB` table have feature `tag` with at least one lookup? At most + /// [`MAX_FEATURES`] feature records are read; false when the face has no `GSUB` table or it + /// cannot be read. + pub fn has_substitution_feature(&self, tag: [u8; 4]) -> bool { + let Some(font) = self.skrifa() else { return false }; + let Some(features) = font.gsub().ok().and_then(|g| g.feature_list().ok()) else { return false }; + features + .feature_records() + .iter() + .take(MAX_FEATURES) + .filter(|r| r.feature_tag().to_be_bytes() == tag) + .any(|r| r.feature(features.offset_data()).is_ok_and(|f| !f.lookup_list_indices().is_empty())) + } + /// What the features `aalt`, `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, /// `jalt`, `nalt`, `ornm` and `titl` of the face's `GSUB` table substitute for glyph `gid`, in /// that order of features and within a feature in lookup order: the glyph of each single diff --git a/crates/text/src/harakat.rs b/crates/text/src/harakat.rs index 0f8ce4facd..ab5aa593bd 100644 --- a/crates/text/src/harakat.rs +++ b/crates/text/src/harakat.rs @@ -86,6 +86,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { no_break, language, glyph_alternate, + glyph_form: _, fill, stroke, stroke_width, diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index befcbed41f..88d40eb1e8 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -1028,6 +1028,8 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { no_break, language, glyph_alternate, + // An OpenType feature over its own characters, like `features`. + glyph_form: _, } = a; // Keep PartialEq's non-reflexive behavior for ignored numeric fields, without cloning styles. let offsets = (standard_vertical_roman, tcy_up_down, tcy_left_right); @@ -1096,7 +1098,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`]), Tsume ([`tighten`]), the OpenType features (each run's apply to its own characters, as features +/// Insert Aki ([`aki`]), Tsume ([`tighten`]), the OpenType features and the East Asian glyph form (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 { @@ -1159,6 +1161,8 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { no_break, language, glyph_alternate, + // Applied as a feature over its run's own characters, with `features`. + glyph_form: _, } = a; *font_family == b.font_family && warichu_alike(a, b) @@ -1442,8 +1446,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let mut ranged = Vec::new(); for (r, run_style) in runs.get(source_runs.clone()).unwrap_or_default() { let (a, b) = (r.start.max(range.start), r.end.min(range.end)); - if run_style.features != base_style.features && a < b { - let own = variant(&CharStyle { features: run_style.features.clone(), ..base_style.clone() }); + if (run_style.features != base_style.features || run_style.glyph_form != base_style.glyph_form) && a < b { + let own = variant(&CharStyle { features: run_style.features.clone(), glyph_form: run_style.glyph_form, ..base_style.clone() }); ranged_features(base, &own, a..b, &mut ranged); } } diff --git a/crates/text/src/tests/typo.rs b/crates/text/src/tests/typo.rs index 4790b642af..351c698451 100644 --- a/crates/text/src/tests/typo.rs +++ b/crates/text/src/tests/typo.rs @@ -2041,3 +2041,40 @@ fn soft_return_breaks_line_even_inside_no_break() { let lay = layout(db(), &t); assert_eq!(lay.lines.len(), 2, "soft return honors line break despite no_break"); } + +/// An East Asian glyph form (the Glyphs panel menu's Traditional Forms, JIS 78 Forms, …) draws +/// the characters with the font's glyphs of that form, and Default with its default glyphs. Needs +/// craft-fonts' BIZ UDMincho, which has traditional, expert, JIS 78, 83 and 90, full-width and +/// half-width forms and no JIS 04 forms (skipped without it). A form on one character of a run +/// changes that character only. +#[test] +fn glyph_forms_draw_the_fonts_east_asian_forms() { + use vectorcraft_doc::GlyphForm; + const FAMILY: &str = "BIZ UDMincho"; + if db().face(FAMILY, "Regular").is_none_or(|f| f.family != FAMILY) { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + return; + } + let st = |form| CharStyle { size: 20.0, font_family: FAMILY.into(), glyph_form: form, ..CharStyle::default() }; + let glyphs = |text: &str, form| layout(db(), &TextObject::point(Point::ZERO, text, st(form))).glyphs; + let gids = |text: &str, form| glyphs(text, form).iter().map(|g| g.gid).collect::>(); + for (form, text) in + [(GlyphForm::Traditional, "亜"), (GlyphForm::Expert, "唖"), (GlyphForm::Jis78, "唖"), (GlyphForm::Jis83, "芦"), (GlyphForm::Jis90, "芦")] + { + assert_ne!(gids(text, form), gids(text, GlyphForm::Default), "{form:?} {text}"); + } + // The font has no JIS 04 forms, so the characters keep their glyphs. + assert_eq!(gids("亜唖芦", GlyphForm::Jis04), gids("亜唖芦", GlyphForm::Default)); + // Full-width Latin takes a whole em, half-width punctuation half of one. + let advance = |text: &str, form| glyphs(text, form).first().map_or(0.0, |g| g.advance); + assert!(advance("A", GlyphForm::Default) < 15.0, "{}", advance("A", GlyphForm::Default)); + assert!((advance("A", GlyphForm::FullWidth) - 20.0).abs() < 0.01, "{}", advance("A", GlyphForm::FullWidth)); + assert!((advance("、", GlyphForm::Default) - 20.0).abs() < 0.01, "{}", advance("、", GlyphForm::Default)); + assert!((advance("、", GlyphForm::HalfWidth) - 10.0).abs() < 0.01, "{}", advance("、", GlyphForm::HalfWidth)); + // Traditional Forms on the middle character only. + let mut t = TextObject::point(Point::ZERO, "亜亜亜", st(GlyphForm::Default)); + edit::style_range(&mut t.runs, 3, 6, |s| s.glyph_form = GlyphForm::Traditional); + let ids: Vec = layout(db(), &t).glyphs.iter().map(|g| g.gid).collect(); + let (plain, traditional) = (gids("亜", GlyphForm::Default), gids("亜", GlyphForm::Traditional)); + assert_eq!(ids, [plain[0], traditional[0], plain[0]]); +} diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index e3c1f68bef..32de20ec95 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -108,6 +108,20 @@ pub(crate) fn shared(app: &VectorcraftApp, f: impl Fn(&CharStyle) shared_styles(app, |_, _, style, _| Some(f(style))) } +/// The fonts (family and style) of the character styles the panels act on ([`shared`]), each +/// once, in the order the runs come. +pub(crate) fn selected_fonts(app: &VectorcraftApp) -> Vec<(String, String)> { + let fonts = std::cell::RefCell::new(Vec::<(String, String)>::new()); + shared_styles(app, |_, _, style, _| { + let mut fonts = fonts.borrow_mut(); + if !fonts.iter().any(|(family, face)| *family == style.font_family && *face == style.font_style) { + fonts.push((style.font_family.clone(), style.font_style.clone())); + } + Some(()) + }); + fonts.into_inner() +} + /// Dimensional controls use document units and tolerate arithmetic roundoff when comparing /// compensated runs. Keep raw styles available to font/paragraph controls and style definitions, /// even if a collapsed affine prevents editing dimensions. diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 2a3251b012..998b737c09 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -9,12 +9,14 @@ //! that glyph as one of its alternates in its word. A glyph no feature reaches whose name does //! not belong to another letter is applied as a glyph choice, which may not join. Other glyphs //! are drawn dimmed, and their tooltips say why they cannot be applied to the selection. +//! With Show East Asian Options, the panel menu sets the selected characters' East Asian glyph +//! form ([`glyph_form_items`]). use std::collections::HashMap; use egui::{Color32, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::json; -use vectorcraft_doc::{CharStyle, NodeId, NodeKind}; +use vectorcraft_doc::{CharStyle, GlyphForm, NodeId, NodeKind}; use vectorcraft_geom::{Affine, BezPath, PathEl}; use vectorcraft_text::FontDb; @@ -904,11 +906,115 @@ fn zoom_button(ui: &mut Ui, id: &str, label: &str, tip: &str, enabled: bool) -> resp.on_hover_text(tl!(tip)).clicked() && enabled } -pub fn menu(_app: &mut VectorcraftApp, ui: &mut Ui) { +/// The menu label of East Asian glyph form `form` ([`GlyphForm::ALL`]). +fn glyph_form_label(form: GlyphForm) -> &'static str { + match form { + GlyphForm::Traditional => "Traditional Forms", + GlyphForm::Expert => "Expert Forms", + GlyphForm::Jis04 => "JIS 04 Forms", + GlyphForm::Jis90 => "JIS 90 Forms", + GlyphForm::Jis78 => "JIS 78 Forms", + GlyphForm::Jis83 => "JIS 83 Forms", + GlyphForm::FullWidth => "Monospaced Full-Width Forms", + GlyphForm::HalfWidth => "Monospaced Half-Width Forms", + GlyphForm::ThirdWidth => "Monospaced Third-Width Forms", + GlyphForm::QuarterWidth => "Monospaced Quarter-Width Forms", + GlyphForm::Default => "Revert To Default Forms", + } +} + +/// The ids of the faces that draw the characters [`glyph_form_items`] acts on: the faces the +/// fonts of their styles name, and the faces layout draws them with, fallback faces included: +/// the Type tool's selected range (every frame of its story after Select All in threaded text, +/// the whole text object at a caret), else every frame of the selected stories. Laid out once for +/// each document revision, selection and set of fonts while the menu is open, overflow text +/// included. +fn selection_faces(app: &VectorcraftApp, ui: &Ui) -> Vec { + type Key = (u64, u64, u64, Option<(NodeId, usize, usize)>, Vec); + let editing = super::character::text_editing(app).map(|(id, a, b)| (id, a.min(b), a.max(b))); + let Some(state) = app.session.active() else { return vec![] }; + let story = super::character::text_story(app); + let stories = match editing { + Some(_) => story.clone().unwrap_or_default(), + None => super::character::selected_stories(app), + }; + let db = FontDb::global(); + let key: Key = (state.uid, state.revision, db.generation(), editing, stories); + let slot = ui.scope_id().with("gl-form-faces"); + if let Some((cached, ids)) = ui.ctx().data(|d| d.get_temp::<(Key, Vec)>(slot)) + && cached == key + { + return ids; + } + let mut ids: Vec = + super::character::selected_fonts(app).iter().filter_map(|(family, style)| db.face(family, style)).map(|f| f.id()).collect(); + let mut add = |t: &vectorcraft_doc::TextObject, range: Option<(usize, usize)>| { + // Laid out as point type, so that text overflowing its area or path counts too. + let mut all = t.clone(); + all.kind = vectorcraft_doc::TextKind::Point; + for g in vectorcraft_text::layout(db, &all).glyphs { + // A glyph counts when its characters overlap the range (a ligature may start before it). + if range.is_none_or(|(a, b)| g.byte < b && g.byte + g.len.max(1) > a) && !ids.contains(&g.font_id) { + ids.push(g.font_id); + } + } + }; + let text = |id: NodeId| match state.doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some(t), + _ => None, + }; + match (editing, story) { + // After Select All in threaded text: every frame of the story. + (Some(_), Some(frames)) => frames.into_iter().filter_map(text).for_each(|t| add(t, None)), + (Some((id, a, b)), None) => { + if let Some(t) = text(id) { + // A caret applies the form to all the text of the object. + add(t, (a < b).then_some((a, b))); + } + } + (None, _) => super::character::selected_story_texts(app).into_iter().for_each(|t| add(t, None)), + } + ui.ctx().data_mut(|d| d.insert_temp(slot, (key, ids.clone()))); + ids +} + +/// The East Asian glyph forms of the panel menu, with Show East Asian Options: each draws the +/// selected characters (all the text of the selected type objects without a Type tool range) in +/// one form of their font, and is checked when they all have it; a form none of the faces that +/// draw the selected text has a feature for ([`selection_faces`]) is disabled. Revert To Default +/// Forms draws the font's default glyphs. +/// Public behavior: [Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html) +/// (Replace Asian characters with a different glyph form). +fn glyph_form_items(app: &mut VectorcraftApp, ui: &mut Ui) { + let has = super::character::text_style(app).is_some(); + let db = FontDb::global(); + let faces: Vec<_> = selection_faces(app, ui).into_iter().filter_map(|id| db.face_by_id(id)).collect(); + let current = super::character::shared(app, |s| s.glyph_form); + for form in GlyphForm::ALL { + let enabled = match form.feature() { + Some(tag) => faces.iter().any(|f| f.has_substitution_feature(*tag)), + None => has, + }; + let checked = form != GlyphForm::Default && current == Some(form); + if form == GlyphForm::Default { + ui.separator(); + } + // Choosing the form every selected character already has changes nothing. + if menu_item(ui, tl!(glyph_form_label(form)), enabled, checked) && current != Some(form) { + super::character::format(app, json!({"glyphForm": form.id()})); + } + } +} + +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Use the Selected Text's Font"), true, false) { set_pstate::>(ui.ctx(), "gl-font", None); } ui.separator(); + if app.session.prefs.show_east_asian_options { + glyph_form_items(app, ui); + ui.separator(); + } for (i, (name, _, _)) in SUBSETS.iter().enumerate() { let cur: usize = pstate(ui.ctx(), "gl-subset"); if menu_item(ui, tl!(name), true, cur == i) { @@ -1102,6 +1208,59 @@ mod tests { text_anchors(&shapes(ctx, app, vec![])).into_iter().map(|(t, _)| t).collect() } + /// With Show East Asian Options, the panel menu lists the East Asian glyph forms; choosing one + /// draws the Type tool's selected characters in it, checks it, and Revert To Default Forms + /// takes it away. A form the font has no feature for is disabled. The second half needs + /// craft-fonts' BIZ UDMincho, which has JIS 78 forms and no JIS 04 forms (skipped without it). + #[test] + fn east_asian_glyph_forms_in_the_panel_menu_set_the_selected_characters_form() { + let (mut app, id) = editing("亜唖"); + let ctx = egui::Context::default(); + let labels = |app: &mut VectorcraftApp| menu_texts(&ctx, app, vec![]).into_iter().map(|(t, _)| t).collect::>(); + let plain = |l: &String| l.trim_start_matches(['✓', ' ']).to_string(); + app.session.prefs.show_east_asian_options = false; + assert!(!labels(&mut app).iter().map(plain).any(|l| l == "JIS 78 Forms"), "hidden without Show East Asian Options"); + app.session.prefs.show_east_asian_options = true; + let shown: Vec = labels(&mut app).iter().map(plain).collect(); + for form in GlyphForm::ALL { + assert!(shown.iter().any(|l| l == glyph_form_label(form)), "{form:?} in {shown:?}"); + } + const FAMILY: &str = "BIZ UDMincho"; + if FontDb::global().face(FAMILY, "Regular").is_none_or(|f| f.family != FAMILY) { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + return; + } + app.run("text.setRangeStyle", json!({"id": id, "font": FAMILY})).unwrap(); + let forms = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap_or(0))).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.glyph_form)).collect::>(), + _ => panic!("text"), + }; + select(&mut app, 3, 6); + choose(&ctx, &mut app, "JIS 78 Forms"); + assert_eq!(forms(&app), [("亜".to_string(), GlyphForm::Default), ("唖".to_string(), GlyphForm::Jis78)]); + assert!(labels(&mut app).iter().any(|l| l.starts_with('✓') && l.ends_with("JIS 78 Forms")), "checked"); + // A selection whose first font has no JIS 83 forms, and whose second one does, takes them. + let fallback = vectorcraft_text::FALLBACK_FAMILY; + if FontDb::global().face(fallback, "Regular").is_some_and(|f| !f.has_substitution_feature(*b"jp83")) { + app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 3, "font": fallback})).unwrap(); + select(&mut app, 0, 6); + choose(&ctx, &mut app, "JIS 83 Forms"); + assert!(forms(&app).iter().all(|(_, f)| *f == GlyphForm::Jis83), "{:?}", forms(&app)); + app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 3, "font": FAMILY})).unwrap(); + app.run("text.setRangeStyle", json!({"id": id, "glyphForm": "default"})).unwrap(); + select(&mut app, 3, 6); + choose(&ctx, &mut app, "JIS 78 Forms"); + } + // Choosing it again changes nothing, and the font has no JIS 04 forms: that item is disabled. + let n = app.session.active().unwrap().history.undo.len(); + choose(&ctx, &mut app, "JIS 78 Forms"); + choose(&ctx, &mut app, "JIS 04 Forms"); + assert_eq!(app.session.active().unwrap().history.undo.len(), n); + assert_eq!(forms(&app)[1].1, GlyphForm::Jis78); + choose(&ctx, &mut app, "Revert To Default Forms"); + assert!(forms(&app).iter().all(|(_, f)| *f == GlyphForm::Default), "{:?}", forms(&app)); + } + /// Show › Alternates for Current Selection lists the selected character's alternates from the /// font of its text, whatever font the panel browses; a double-click draws the character with /// one. A character without alternates, no selected character and a missing font each show a diff --git a/docs/mcp.md b/docs/mcp.md index 6ee778bef4..f58ef2eb3a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9101,6 +9101,27 @@ as outlines. {"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":3,"tsume":50}}} ``` +## East Asian glyph forms + +`text.setFormat` and `text.setRangeStyle {glyphForm}` set the East Asian glyph form the characters are drawn in +([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)): `"traditional"`, +`"expert"`, `"jis04"`, `"jis90"`, `"jis78"`, `"jis83"`, `"fullWidth"`, `"halfWidth"`, `"thirdWidth"` or +`"quarterWidth"`, which draw them with the font's OpenType feature `trad`, `expt`, `jp04`, `jp90`, `jp78`, `jp83`, +`fwid`, `hwid`, `twid` or `qwid` ([feature list](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist)), +or `"default"`, the font's default glyphs. A character holds one form at a time, so setting one replaces another. The +text keeps its characters, and a character the font has no glyph of that form for keeps its own. Runs that differ only +in their form are shaped as one piece of text. The form is saved only when it is not `"default"`, character styles +carry it, PDF draws the glyphs the canvas draws, and live SVG text writes it in `font-feature-settings`. Another value +is refused with `` `glyphForm` must be one of "traditional", … ``. One undo step, and a `text.setFormat` call that +gives the form the characters already have records none. With Show East Asian Options on, the Glyphs panel menu lists +the ten forms and Revert To Default Forms: a form is checked when every selected character has it, choosing it again +does nothing, and it is disabled when none of the faces that draw the selected text, fallback faces included, has a +feature for it. + +```json +{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":3,"glyphForm":"jis78"}}} +``` + ## Composite Fonts A composite font sets each class of character in a font of its own diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 145612253a..cf67a5943f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1447,6 +1447,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Paragraph panel's Mojikumi Set lists the five predefined sets the help page names (None, YakumonoHankaku, GyomatsuYakumonoHankaku, GyomatsuYakumonoZenkaku and YakumonoZenkaku), then Line-end Punctuation Half Width, and `text.setFormat {mojikumi}` takes each of them. YakumonoHankaku sets every bracket, comma and full stop half width and takes a quarter em off each side of a middle dot; GyomatsuYakumonoHankaku keeps punctuation full width inside a line, sets an opening bracket flush at a line's start and a closing mark half width at its end, and takes the quarter em after a middle dot off at a line's end; GyomatsuYakumonoZenkaku keeps punctuation full width and adds a quarter em between Japanese and Latin text; YakumonoZenkaku sets characters as None does. These widths are VectorCraft's own rules, from the help page's one-line description of each set. Both composers and vertical type measure each line as it is set, the sets save with the document and paragraph styles, and a `.ai` save reports the new sets as left out (`crates/text/src/tests/vertical.rs`, `crates/ui-egui/src/panels/paragraph/tests.rs`). Scores and estimates are unchanged. Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`). Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment. + The Glyphs panel menu (with the East Asian options) offers Traditional, Expert, JIS 04, JIS 90, JIS 78 and JIS 83 Forms, Monospaced Full-Width, Half-Width, Third-Width and Quarter-Width Forms, and Revert To Default Forms ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); each draws the selected characters with the font's OpenType feature for that form (`trad`, `expt`, `jp04`, `jp90`, `jp78`, `jp83`, `fwid`, `hwid`, `twid`, `qwid`), one form at a time, and a form none of the faces that draw the selected text has a feature for is disabled. `text.setFormat` and `text.setRangeStyle {glyphForm}` set it; the native file, character styles, the canvas, PDF and SVG (`font-feature-settings`) keep it (`tests::typo::glyph_forms_draw_the_fonts_east_asian_forms`, `typecmd::area_tests::glyph_form_is_one_character_attribute_saved_only_when_set`, `panels::glyphs` tests). 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. diff --git a/docs/type-parity.md b/docs/type-parity.md index 64c6bcf798..57578e454b 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -69,6 +69,7 @@ against Illustrator's public documentation; **estimated** unless noted. | CJK: East Asian Type units (Q, Ha) | done | Preferences › Units › East Asian Type (`unitsAsianType`, Points by default) offers every unit and Q and Ha, a quarter of a millimetre each ([Scripting Guide](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/): 0.709 pt per Q; [InDesign's unit notation](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html): `q`, `h`). The public pages do not say which fields follow it, so no field uses it yet and that waits for a source. Evidence: `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` | | CJK: manual tate-chu-yoko | done | Character panel menu › Tate-chu-yoko (`text.setFormat` / `text.setRangeStyle {tateChuYoko}`), with Show East Asian Options: marked characters stand side by side across one em of a vertical column, squeezed to the em when wider; two or three half-width digits still do so without the mark; horizontal type keeps the mark and does not move them. Tate-chu-yoko Settings… (`ui.tateChuYokoSettings`, `{tcyUpDown, tcyLeftRight}` in points) moves a marked block up or down and right or left as one piece. Evidence: `tests_vertical::manual_tate_chu_yoko_sets_marked_characters_across_one_em`, `tests_vertical::tate_chu_yoko_settings_move_a_marked_block_up_down_left_and_right`, `typecmd::tate_chu_yoko_is_a_character_attribute_saved_only_when_on`, `typecmd::tate_chu_yoko_settings_are_character_attributes_saved_only_when_set`, `dialogs::tate_chu_yoko::tests` | | CJK: Standard Vertical Roman Alignment | done | Character panel menu › Standard Vertical Roman Alignment (`text.setFormat` / `text.setRangeStyle {standardVerticalRoman}`), with Show East Asian Options, off by default: in vertical type the marked half-width characters (Latin letters, digits and other characters that would lie on their side) stand upright one below the other, each in its upright cell; spaces keep their width and tate-chu-yoko blocks stay across the column; horizontal type keeps the mark and does not change. Character Style Options has its own checkbox for it. Evidence: `tests_vertical::standard_vertical_roman_alignment_stands_half_width_characters_upright`, `typecmd::standard_vertical_roman_is_a_character_attribute_saved_only_when_on` | +| CJK: East Asian glyph forms | done | Glyphs panel menu › Traditional Forms, Expert Forms, JIS 04, JIS 90, JIS 78 and JIS 83 Forms, Monospaced Full-Width, Half-Width, Third-Width and Quarter-Width Forms, and Revert To Default Forms, with Show East Asian Options ([Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)): the selected characters are drawn with the font's OpenType feature for that form (`trad`, `expt`, `jp04`, `jp90`, `jp78`, `jp83`, `fwid`, `hwid`, `twid`, `qwid`; [feature list](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist)), one form at a time (`text.setFormat` / `text.setRangeStyle {glyphForm}`); a form none of the faces that draw the selected text (fallback faces included) has a feature for is disabled, and Revert To Default Forms draws the default glyphs. Saved natively only when set, carried by character styles, drawn on the canvas and in PDF, and written to SVG as `font-feature-settings`. Character Style Options has no field for it. Evidence: `tests::typo::glyph_forms_draw_the_fonts_east_asian_forms`, `typecmd::area_tests::glyph_form_is_one_character_attribute_saved_only_when_set`, `panels::glyphs::tests::east_asian_glyph_forms_in_the_panel_menu_set_the_selected_characters_form` | | CJK: warichu | done | Character panel menu › Warichu and Warichu Settings… (`text.setFormat` / `text.setRangeStyle {warichu, warichuLines, warichuGap, warichuScale, warichuAlign}`, `ui.warichuSettings`), with Show East Asian Options ([Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)): marked characters set at a percentage of their size and stacked on 1 to 6 lines inside their line, one above the other in horizontal type and side by side in vertical type, with a line gap and eight alignments; a block stays whole on one line. Saved natively, carried by character styles, drawn on the canvas and in every export (SVG, PDF and `.ai` write such type as outlines). Type on a path sets the characters at their own size. Evidence: `tests::warichu`, `dialogs::warichu::tests`, `typecmd::area_tests::warichu_is_a_character_attribute_set_saved_and_exported` | | CJK: composite fonts | partial | Type › Composite Fonts… (`type.compositeFonts.set`), with Show East Asian Options: a font made of a font per class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals), each with its own style, size and baseline in percent of the type's size, Kana, Roman and Numerals with horizontal and vertical scale, and Kana with Scale at Center; New, Delete Font, Import… and Export… (`.vccomposite`, `type.compositeFonts.import` and `.export`) ([Creating composite fonts](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347)). Composite fonts are listed first in the font menus, saved with the document, and their type is laid out per class; SVG and legacy `.ai` write it as outlines. Not yet: custom character sets, the Units menu, Show Sample. Evidence: `text::composite::tests::characters_fall_in_their_classes`, `tests::composite::composite_type_draws_each_class_in_its_font`, `compositefonts::tests`, `dialogs::composite_fonts::tests` | | CJK: ruby | partial | ruby (ふりがな) for live text (`type.ruby`): a reading over a byte range, above (right of the column in vertical type) or below, start/centre/end alignment, size, overhang or squeeze; saved natively, laid out with its line growing, drawn on the canvas, in SVG and PDF (as outlines where ruby is present), in EPS, EMF/WMF and legacy `.ai` (legacy `.ai` writes type with ruby as outlines), and kept by Create Outlines, Expand Appearance and flattening, each glyph where the layout puts it (`typecmd::ruby_tests::create_outlines_keeps_the_ruby_where_it_is_laid_out`) | #633 |