From e1440a3a9012779be7f40cfd166349cbfdc61372 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 21:14:57 -0700 Subject: [PATCH 1/3] Paragraph: Kinsoku Shori Type chooses Push In First, Push Out First or Push Out Only The Paragraph panel menu (with the East Asian options) gains Kinsoku Shori Type, and text.setFormat takes kinsokuType. Where the Kinsoku Set keeps a character off the start of the next line or the end of this one, Push In First sets it on the line and closes the line's gaps next to CJK characters to fit, each by at most a tenth of an em of the character before it; a line that cannot close up that much, or that holds a tab, pushes out. With Optical Margin Alignment or Roman Hanging Punctuation on, the gaps that hanging punctuation keeps open are not counted. Push Out First pushes in only when pushing out would leave the line more than an em short. Push Out Only, the default, is the line breaking area type had before, so existing documents lay out unchanged. The setting does nothing with Kinsoku Set None, is saved only when it is not Push Out Only, and paragraph styles carry it. Reset Panel sets it back to Push Out Only. Docs: docs/mcp.md, docs/type-parity.md, docs/target-app-parity.md. --- crates/doc/src/lib.rs | 6 +- crates/doc/src/text.rs | 4 +- crates/doc/src/text/para_style.rs | 28 +++++ crates/engine/src/cmd/panelcmds.rs | 15 ++- crates/engine/src/cmd/textstyles.rs | 1 + crates/engine/src/cmd/typecmd/area_tests.rs | 37 ++++++ crates/text/src/layout.rs | 4 +- crates/text/src/layout/breaking.rs | 124 ++++++++++++++++++- crates/text/src/layout/flow.rs | 67 +++++++--- crates/text/src/layout/wrapping_tests.rs | 88 +++++++++++-- crates/text/src/tests/vertical.rs | 51 ++++++++ crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/bs.tsv | 4 + crates/ui-egui/src/i18n/ca.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/da.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ka.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/panels/paragraph.rs | 35 +++++- crates/ui-egui/src/panels/paragraph/tests.rs | 26 ++++ docs/mcp.md | 21 ++++ docs/target-app-parity.md | 1 + docs/type-parity.md | 1 + 42 files changed, 574 insertions(+), 39 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 26e2bfebf..0ff13cd31 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -108,9 +108,9 @@ pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOption pub use style_libs::StyleLibrary; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, - JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition, - RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, - WrapShape, + JustificationOptions, Justify, Kinsoku, KinsokuShoriType, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, + RubyPosition, RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, + VerticalAlign, WrapShape, }; pub use trace::TraceView; pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index c1a36e058..664247a30 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -17,8 +17,8 @@ pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, use char_style::yes; pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, ScriptMetrics}; pub use para_style::{ - Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection, - ParaStyle, SingleWordJustify, + Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, KinsokuShoriType, LeadingModel, + Mojikumi, ParaDirection, ParaStyle, SingleWordJustify, }; pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef}; pub use tabs_lists::{Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, NumberStyle, ParaList, TabAlign, TabStop}; diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index a257e2e0a..d78363681 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -233,6 +233,9 @@ pub struct ParaStyle { /// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a /// line. pub kinsoku: Kinsoku, + /// Kinsoku Shori Type (Paragraph panel menu): how a line keeps those characters off its start + /// and end, by pushing them in onto the line or out to the next one. No effect without kinsoku. + pub kinsoku_type: KinsokuShoriType, /// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and /// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses, /// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari. @@ -315,6 +318,8 @@ struct ParaStyleFile { #[serde(default, skip_serializing_if = "crate::skip::is_default")] kinsoku: Kinsoku, #[serde(default, skip_serializing_if = "crate::skip::is_default")] + kinsoku_type: KinsokuShoriType, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] roman_hanging: bool, #[serde(default, skip_serializing_if = "crate::skip::is_default")] kashida: Kashida, @@ -346,6 +351,7 @@ impl From for ParaStyleFile { leading_model, burasagari, kinsoku, + kinsoku_type, roman_hanging, kashida, .. @@ -370,6 +376,7 @@ impl From for ParaStyleFile { leading_model, burasagari, kinsoku, + kinsoku_type, roman_hanging, kashida, } @@ -398,6 +405,7 @@ impl From for ParaStyle { leading_model, burasagari, kinsoku, + kinsoku_type, roman_hanging, kashida, .. @@ -421,6 +429,7 @@ impl From for ParaStyle { leading_model, burasagari, kinsoku, + kinsoku_type, roman_hanging, kashida, } @@ -485,6 +494,25 @@ pub enum Kinsoku { Soft, } +/// How a line keeps the characters [`Kinsoku`] rules out of its start and end (Kinsoku Shori +/// Type). Pushing in sets the characters that would start the next line (or that an opening +/// bracket ending this line would pull with it) on this line, with the line's gaps next to CJK +/// characters closed to make room, each by the same share of the em of the character before it +/// and by at most a tenth of that em; pushing out moves the character before them to the next +/// line. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum KinsokuShoriType { + /// Push in when the line can close up enough, else push out. + PushInFirst, + /// Push out, unless that would leave the line more than an em short (of the character before + /// the break): then push in when the line can close up enough. + PushOutFirst, + /// Always push out; never push in. + #[default] + PushOutOnly, +} + /// Hanging punctuation (burasagari): an East Asian comma or full stop ending a line (、。,.、。) /// stands outside the line's measure, in the space its punctuation spacing gives it (half width /// with Line-end Punctuation Half Width). Closing brackets and Latin punctuation don't hang. Shown diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 7f504dcc4..83f4f72eb 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), 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\" (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), 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\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), 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), 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), 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), 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), 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)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), 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\" (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), 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\"|\"lineEndHalf\" (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, always moves it; no effect with kinsoku \"none\"), 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), 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), 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), 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), 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)", has_doc, set_format ), @@ -364,6 +364,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "singleWordJustification", "mojikumi", "kinsoku", + "kinsokuType", "direction", "leadingModel", "charAlign", @@ -414,6 +415,15 @@ fn set_format(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(C, "`kinsoku` must be \"none\", \"hard\" or \"soft\"")), }), }; + let kinsoku_type = match p.get("kinsokuType") { + None => None, + Some(v) => Some(match v.as_str() { + Some("pushInFirst") => vectorcraft_doc::KinsokuShoriType::PushInFirst, + Some("pushOutFirst") => vectorcraft_doc::KinsokuShoriType::PushOutFirst, + Some("pushOutOnly") => vectorcraft_doc::KinsokuShoriType::PushOutOnly, + _ => return Err(bad(C, "`kinsokuType` must be \"pushInFirst\", \"pushOutFirst\" or \"pushOutOnly\"")), + }), + }; let direction = match p.get("direction") { None => None, Some(v) => Some(match v.as_str() { @@ -635,6 +645,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = kinsoku { para.kinsoku = v; } + if let Some(v) = kinsoku_type { + para.kinsoku_type = v; + } if let Some(v) = direction { para.direction = v; } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 5995c490d..46c0038dd 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -201,6 +201,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map { ("leading_model", json!(v.leading_model)), ("burasagari", json!(v.burasagari)), ("kinsoku", json!(v.kinsoku)), + ("kinsoku_type", json!(v.kinsoku_type)), ("roman_hanging", json!(v.roman_hanging)), ("kashida", json!(v.kashida)), ] { diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 65febdf0b..ab74c1708 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -98,6 +98,43 @@ fn kinsoku_set_is_set_per_paragraph_and_saved_only_when_not_hard() { assert_eq!(old.kinsoku, Kinsoku::Hard); } +/// Paragraph panel menu › Kinsoku Shori Type: `text.setFormat {kinsokuType}` sets it per +/// paragraph in one undo step, refuses unknown values, saves it only when it is not Push Out Only +/// (new type's and older documents' type), reopens with the file, and paragraph styles carry it. +#[test] +fn kinsoku_shori_type_is_set_per_paragraph_and_saved_only_when_not_push_out_only() { + use vectorcraft_doc::KinsokuShoriType; + 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": 10, "text": "あ」\nい", "area": {"width": 100, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.clone(), + _ => panic!("text"), + }; + assert_eq!(text(&s).para_at(0).kinsoku_type, KinsokuShoriType::PushOutOnly); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("kinsoku_type").is_none()); + s.execute("text.setFormat", &json!({"id": id, "start": 0, "end": 1, "kinsokuType": "pushInFirst"})).unwrap(); + assert_eq!((text(&s).para_at(0).kinsoku_type, text(&s).para_at(1).kinsoku_type), (KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutOnly)); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["kinsoku_type"], "pushInFirst"); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.para_at(0).kinsoku_type, back.para_at(1).kinsoku_type), (KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutOnly)); + assert!(s.execute("text.setFormat", &json!({"id": id, "kinsokuType": "pushIn"})).is_err()); + assert_eq!(text(&s).para_at(0).kinsoku_type, KinsokuShoriType::PushInFirst, "an unknown value changes nothing"); + s.execute("text.setFormat", &json!({"id": id, "kinsokuType": "pushOutFirst"})).unwrap(); + assert_eq!(text(&s).para_at(1).kinsoku_type, KinsokuShoriType::PushOutFirst); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((text(&s).para_at(0).kinsoku_type, text(&s).para_at(1).kinsoku_type), (KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutOnly)); + // A paragraph style carries it. + s.execute("paraStyle.new", &json!({"name": "Pushed in", "attrs": {"kinsoku_type": "pushOutFirst"}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Pushed in", "id": id})).unwrap(); + assert_eq!(text(&s).para_at(1).kinsoku_type, KinsokuShoriType::PushOutFirst); + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert_eq!(old.kinsoku_type, KinsokuShoriType::PushOutOnly); +} + #[test] fn burasagari_is_set_per_paragraph_and_saved_only_when_on() { use vectorcraft_doc::Burasagari; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f217eaa3a..2a63aa80f 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -6,8 +6,8 @@ use kurbo::{Affine, BezPath, ParamCurve, PathEl, Point, Rect, Shape, Vec2}; use unicode_bidi::{BidiInfo, Level}; use vectorcraft_doc::text::Kashida; use vectorcraft_doc::{ - Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, - RubyAlign, RubyPosition, SingleWordJustify, TextKind, TextObject, + Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, KinsokuShoriType, LeadingModel, Mojikumi, ParaDirection, ParaStyle, + PathAlign, PathEffect, RubyAlign, RubyPosition, SingleWordJustify, TextKind, TextObject, }; use vectorcraft_geom::{ArcPath, PathData}; diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index b83640d02..05cb35902 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -1,7 +1,8 @@ use super::region::Region; +use super::vertical::glyph_em; use super::{ - Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, LeadingModel, SGlyph, hyphen_glyph, - hyphen_points_with, no_line_end, no_line_start, soft_line_start, + Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, KinsokuShoriType, LeadingModel, SGlyph, + hyphen_glyph, hyphen_points_with, is_cjk, no_line_end, no_line_start, soft_line_start, }; /// Vertical metrics of a line: (ascent, descent, leading, cap height, x height). @@ -268,7 +269,10 @@ pub(super) fn word_width(g: &[SGlyph], i: usize) -> f64 { /// `hyphenate` holds the paragraph's Hyphenation options and the Hyphenation Zone in force when /// the line may hyphenate a word; a line whose text before the overflowing word already ends /// within the zone breaks before that word. A tab is as wide as it is set: up to its next stop -/// (`tabs`), with the line's content starting `x_start` from the tab origin. +/// (`tabs`), with the line's content starting `x_start` from the tab origin. Where kinsoku alone +/// keeps the line from breaking before the character that overflows, `kinsoku_type` decides +/// whether the line pushes characters in ([`push_in`]) or out; `hanging` is true when Optical +/// Margin Alignment or Roman Hanging Punctuation is on. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -280,6 +284,8 @@ pub(super) fn break_line( hyphenate: Option<(&HyphenationOptions, f64)>, burasagari: Burasagari, kinsoku: Kinsoku, + kinsoku_type: KinsokuShoriType, + hanging: bool, ) -> (usize, bool) { if !width.is_finite() { if let Some(pos) = g[i..].iter().position(|gl| gl.ch == '\u{2028}') { @@ -329,6 +335,9 @@ pub(super) fn break_line( } return (end, false); } + if let Some(end) = push_in(g, i, j, cluster_end, x + cluster_width, width, last_break.map(|b| b.0), kinsoku, kinsoku_type, hanging) { + return (end, false); + } break; } x += cluster_width; @@ -491,6 +500,115 @@ fn kinsoku_between(before: char, after: Option, kinsoku: Kinsoku) -> bool } } +/// The share of an em by which a push-in ([`push_in`]) may close each gap next to a CJK character +/// on the line, measured on the character before the gap. +pub(super) const PUSH_IN_SQUEEZE: f64 = 0.1; + +/// The most glyphs after the overflowing cluster a push-in ([`push_in`]) takes onto the line; a +/// longer run of characters kinsoku keeps together pushes out. +const PUSH_IN_RUN: usize = 16; + +/// Kinsoku Shori Type: the end of the line from `i` that overflows `width` at the cluster +/// `j..cluster_end` (the line is `x` wide through it) when the line pushes in, or None when it +/// pushes out. A line pushes in only where kinsoku rules out the break before `j`: a character +/// that may not end a line comes before it (an opening bracket or quotation mark, Latin ones +/// included), or one that may not start a line comes at `j`. It then takes the characters up to +/// the next break kinsoku allows (not at a soft hyphen), and the spaces after it, when closing +/// its gaps next to CJK characters, each by up to [`PUSH_IN_SQUEEZE`] of an em of the character +/// before it, makes room for them. Push In First tries this before it pushes out. Push Out First +/// tries it only when pushing out, to the line's last allowed break `pushed_out`, would leave the +/// line short by more than an em of the character before `j`. Push Out Only never pushes in, and +/// neither does a line holding a tab, nor one where the characters taken in reach a space after a +/// character that may not end a line. With `hanging` (Optical Margin Alignment or Roman Hanging +/// Punctuation), the gaps that hanging punctuation keeps open are not counted. +#[allow(clippy::too_many_arguments)] +fn push_in( + g: &[SGlyph], + i: usize, + j: usize, + cluster_end: usize, + x: f64, + width: f64, + pushed_out: Option, + kinsoku: Kinsoku, + kinsoku_type: KinsokuShoriType, + hanging: bool, +) -> Option { + if kinsoku == Kinsoku::None || kinsoku_type == KinsokuShoriType::PushOutOnly { + return None; + } + let before = g.get(j.checked_sub(1)?)?; + if before.len == 0 || g.get(j)?.no_break || splits_no_break(g, j) || kinsoku_allows(g, j - 1, kinsoku) { + return None; + } + let allowed = |k: usize| { + k >= g.len() + || (g[k].byte != g[k - 1].byte + && kinsoku_allows(g, k - 1, kinsoku) + && (g[k].is_space() || (g[k - 1].break_after() && !g[k - 1].is_soft_hyphen()))) + }; + let (mut end, mut x) = (cluster_end, x); + while !allowed(end) { + let gl = &g[end]; + // A space here follows a character kinsoku keeps off a line's end: the line pushes out. + if gl.is_space() || gl.ch == '\t' || gl.ch == '\u{2028}' || gl.no_break || end - cluster_end >= PUSH_IN_RUN { + return None; + } + x += gl.adv; + end += 1; + } + // A tab's width depends on where it falls; a line with one (other than a list marker's) + // pushes out. + if g[i..end].iter().any(|h| h.ch == '\t' && h.len > 0) { + return None; + } + // With `hanging`, punctuation keeps the gap after a mark that may hang at the line's start, + // and the gap before one that may hang at its end ([`optical_hangs`]): those gaps are not + // counted. + let mut last = end; + while last > i && g[last - 1].is_space() { + last -= 1; + } + let punct = |k: usize| g.get(k).is_some_and(optical_punct); + let quote = |k: usize| g.get(k).is_some_and(|h| is_quote(h.ch)); + let may_hang = |a: usize| { + hanging + && ((a == i && punct(i)) + || (a == i + 1 && punct(i) && quote(i + 1)) + || (a + 2 == last && punct(last - 1)) + || (a + 3 == last && punct(last - 2) && quote(last - 1))) + }; + let room: f64 = (i..end - 1).filter(|&a| closes(g, a) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * glyph_em(&g[a])).sum(); + if x - width > room + EPS { + return None; + } + if kinsoku_type == KinsokuShoriType::PushOutFirst { + let mut kept = pushed_out.filter(|&b| b > i).unwrap_or(i); + while kept > i && g[kept - 1].is_space() { + kept -= 1; + } + let short = width - g[i..kept].iter().map(|h| h.adv).sum::(); + if short <= glyph_em(before) + EPS { + return None; + } + } + while g.get(end).is_some_and(SGlyph::is_space) { + end += 1; + } + if g.get(end).is_some_and(|h| h.ch == '\u{2028}') { + end += 1; + } + Some(end) +} + +/// May a push-in close the gap after glyph `a`? A gap between two characters, at least one of them +/// CJK, outside a tate-chu-yoko block and a list marker, and not next to a space, a tab or an +/// inline graphic before it. +fn closes(g: &[SGlyph], a: usize) -> bool { + let (Some(p), Some(n)) = (g.get(a), g.get(a + 1)) else { return false }; + p.len > 0 && p.inline.is_none() && !p.is_space() && !n.is_space() && p.ch != '\t' && !n.continues_tcy() && (is_cjk(p.ch) || is_cjk(n.ch)) +} + #[cfg(test)] #[test] fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_ones_off_line_ends() { diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 03c2eb3c4..c28508751 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -1,16 +1,22 @@ use super::bidi::{is_rtl, para_bidi_with, visual_order}; use super::breaking::{ - Metrics, Pen, break_line, candidates, hangs, leader_outline, optical_hangs, optical_hangs_rtl, optical_punct, optical_share, roman_share, - set_width, tab_advance, word_width, + Metrics, PUSH_IN_SQUEEZE, Pen, break_line, candidates, hangs, leader_outline, optical_hangs, optical_hangs_rtl, optical_punct, optical_share, + roman_share, set_width, tab_advance, word_width, }; use super::region::Region; use super::vertical::{EM_CENTER, align_shift, compress_punctuation, glyph_em, punct_half, space_japanese_and_latin}; use super::{ - Affine, BidiInfo, Burasagari, CharStyle, Composer, Ctx, EPS, Flex, Justify, LeadingModel, LineFit, LineInfo, ListMarker, Mojikumi, ParaStyle, - Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, fit_line, hyphen_glyph, is_cjk, - is_word_space, kashida, mark_word_ends, style_metrics, + Affine, BidiInfo, Burasagari, CharStyle, Composer, Ctx, EPS, Flex, Justify, Kinsoku, KinsokuShoriType, LeadingModel, LineFit, LineInfo, + ListMarker, Mojikumi, ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, + fit_line, hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics, }; +/// Does paragraph `para` push characters in (Kinsoku Shori Type Push In First or Push Out First, +/// with kinsoku on)? +fn pushes_in(para: &ParaStyle) -> bool { + para.kinsoku != Kinsoku::None && para.kinsoku_type != KinsokuShoriType::PushOutOnly +} + /// Paragraphs longer than this (glyphs) are always broken line by line. const MAX_COMPOSE_GLYPHS: usize = 200_000; @@ -32,6 +38,8 @@ fn compose_para( // A paragraph whose commas or full stops may hang is composed line by line (as the Japanese // single-line composer does). let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs); + // So is a paragraph that may push characters kinsoku keeps off a line's ends in onto a line. + let may_push_in = pushes_in(para) && sg.iter().any(|g| no_line_start(g.ch) || no_line_end(g.ch)); // A tab's width depends on where it falls in its line, which the composer's fixed glyph widths // can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs). // The tab after a list marker always starts the first line and has a fixed width. @@ -42,6 +50,7 @@ fn compose_para( || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang + || may_push_in || has_tab || has_soft_break { @@ -283,6 +292,8 @@ struct PlacementContext<'a> { zone: f64, index: usize, para_line: usize, + /// Optical Margin Alignment or Roman Hanging Punctuation is on. + hanging: bool, } struct LinePosition { @@ -291,13 +302,16 @@ struct LinePosition { x1: f64, end: usize, hyph: bool, + /// Kinsoku Shori Type pushed characters in onto the line ([`break_line`]). + pushed_in: bool, m: Metrics, ind_l: f64, ind_r: f64, } fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementContext<'_>) -> Option { - let PlacementContext { para, pr, sg, continued, rtl, mirror, pm, composed, hyphen_run, hyphen_limit, zone, index: i, para_line } = context; + let PlacementContext { para, pr, sg, continued, rtl, mirror, pm, composed, hyphen_run, hyphen_limit, zone, index: i, para_line, hanging } = + context; let n = sg.len(); let est = if *i < n { Metrics::of(&sg[*i]) } else { *pm }; let first_line = *para_line == 0 && !*continued; @@ -326,10 +340,16 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC }; let width = x1 - x0 - ind_l - ind_r; let hyphenate = (para.hyphenate && (*hyphen_limit == 0 || *hyphen_run < *hyphen_limit)).then_some((¶.hyphenation, *zone)); - let (end, hyph) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { - Some(&(end, hyph)) if end > *i => (end, hyph), - _ if *i < n => break_line(cx.text, sg, *i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku), - _ => (n, false), + let (end, hyph, pushed_in) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { + Some(&(end, hyph)) if end > *i => (end, hyph, false), + _ if *i < n => { + let line = |ty| break_line(cx.text, sg, *i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku, ty, *hanging); + let (end, hyph) = line(para.kinsoku_type); + // The line pushed characters in where it ends after the line that pushes out. + let pushed_in = pushes_in(para) && end > line(KinsokuShoriType::PushOutOnly).0; + (end, hyph, pushed_in) + } + _ => (n, false, false), }; let m = Metrics::max(&sg[*i..end]).unwrap_or(*pm); baseline += if same_baseline || (pen.regions.is_none() && first_in_region) { @@ -351,7 +371,7 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC pen.queued.clear(); continue; } - return Some(LinePosition { baseline, x0, x1, end, hyph, m, ind_l, ind_r }); + return Some(LinePosition { baseline, x0, x1, end, hyph, pushed_in, m, ind_l, ind_r }); } } @@ -379,6 +399,10 @@ struct LineJustification { per_space: f64, per_gap: f64, per_cjk: f64, + /// Kinsoku Shori Type: the share of an em (of the glyph before it, at most + /// [`PUSH_IN_SQUEEZE`]) each gap next to a CJK character closes by on a line that pushed + /// characters in; 0 on other lines. + squeeze: f64, fit: LineFit, start_x: f64, after: Vec>, @@ -413,6 +437,7 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut zone: paragraph.zone, index, para_line: line_in_paragraph, + hanging: t.optical_margin || paragraph.para.roman_hanging, }; let Some(position) = place_flow_line(cx, pen, &mut placement) else { return false }; hyphen_run = if position.hyph && position.end > index { hyphen_run + 1 } else { 0 }; @@ -637,7 +662,17 @@ fn justify_flow_line(vertical: bool, text: &str, paragraph: &ParagraphData<'_, ' let width = line.render_width + kashidas.iter().map(|k| k.1).sum::(); let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0); let mut fit = LineFit::default(); - if justify && (room - width).abs() > EPS { + let mut squeeze = 0.0; + // Kinsoku Shori Type: a line of area type that pushed characters in is wider than its + // measure, and closes its gaps next to CJK characters to fit, however it is aligned: each by + // the same share of the em of the glyph before it, at most [`PUSH_IN_SQUEEZE`]. + let pushed_in = line.position.pushed_in && paragraph.regions.is_some() && cjk_gaps > 0 && width > room + EPS; + if pushed_in { + let ems: f64 = (line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).map(|j| glyph_em(&sg[j])).sum(); + if ems > EPS { + squeeze = ((room - width) / ems).max(-PUSH_IN_SQUEEZE); + } + } else if justify && (room - width).abs() > EPS { if cjk_gaps > 0 && room > width { // Spread over the CJK gaps and the word spaces alike. per_space = (room - width) / (cjk_gaps + spaces) as f64; @@ -665,7 +700,7 @@ fn justify_flow_line(vertical: bool, text: &str, paragraph: &ParagraphData<'_, ' } } let position = &line.position; - let start_x = if justify { + let start_x = if justify || pushed_in { line.ax0 - line.opt_l } else if paragraph.regions.is_none() { match align { @@ -680,7 +715,7 @@ fn justify_flow_line(vertical: bool, text: &str, paragraph: &ParagraphData<'_, ' _ => line.ax1 + line.opt_r - width, } }; - LineJustification { per_space, per_gap, per_cjk, fit, start_x, after, kashidas } + LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas } } fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, justification: &LineJustification) { @@ -736,9 +771,9 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData } if glyph.is_space() { advance += justification.per_space; - } else if justification.per_cjk != 0.0 { + } else if justification.per_cjk != 0.0 || justification.squeeze != 0.0 { if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { - advance += justification.per_cjk; + advance += justification.per_cjk + justification.squeeze * glyph_em(glyph); } } else if !line.held.contains(&glyph_index) && drawn_after(index, glyph_index, &justification.after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy()) diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 360c386ea..e6b6ad08f 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -53,7 +53,7 @@ fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() { let text = "بَت"; let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]); - let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); + let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false); assert!(!hyphenated); assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together"); @@ -66,11 +66,12 @@ fn greedy_wrap_keeps_an_oversized_cluster_after_a_line_start_atomic() { let text = "بَتَث"; let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]); - let (first_end, _) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); + let (first_end, _) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false); assert_eq!(first_end, 1); assert_eq!(source_range(&glyphs, 0, first_end), 0..4); - let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); + let (second_end, hyphenated) = + break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false); assert!(!hyphenated); assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together"); @@ -125,7 +126,7 @@ fn narrow_arabic_area_wrap_keeps_base_and_mark_together_when_supported() { fn no_break_keeps_marked_characters_on_one_line() { let text = "A B"; let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]); - let (end, hyphen) = break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + let (end, hyphen) = break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); assert!(!hyphen); assert_eq!(end, 2, "without the mark the line ends after the space"); @@ -133,7 +134,7 @@ fn no_break_keeps_marked_characters_on_one_line() { for g in &mut marked { g.no_break = true; } - let (end, hyphen) = break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + let (end, hyphen) = break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); assert!(!hyphen); assert_eq!(end, 3, "the marked words stay together even though they are wider than the line"); @@ -142,11 +143,84 @@ fn no_break_keeps_marked_characters_on_one_line() { let mut glyphs = synthetic_glyphs(&[(0, 1, 8.0, 'A'), (1, 1, 2.0, ' '), (2, 3, 10.0, '日'), (5, 3, 10.0, '本')]); glyphs[2].no_break = true; glyphs[3].no_break = true; - let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); assert_eq!(end, 2, "the marked pair moves to the next line together"); - let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line"); let breaks = candidates(text, &glyphs, None, Kinsoku::None); assert!(breaks.iter().all(|b| b.end != 3), "no break between the marked characters: {breaks:?}"); } + +/// Synthetic full-width characters `text`, each 10 pt wide on a 10 pt em. +fn full_width(text: &str) -> Vec { + let specs: Vec<(usize, usize, f64, char)> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 10.0, ch)).collect(); + let mut glyphs = synthetic_glyphs(&specs); + for g in &mut glyphs { + g.sy = 10.0 / g.face.units_per_em(); + } + glyphs +} + +/// Kinsoku Shori Type, in a measure of 15 full-width characters: a closing bracket that would +/// start the next line is pushed in onto the line with Push In First, and the character before +/// it is pushed out with it with Push Out First and Push Out Only. Push Out First pushes in when +/// pushing out would leave the line two characters short (an opening bracket goes too). A line +/// that cannot close up enough pushes out, and with Kinsoku None the types do nothing. +#[test] +fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { + use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly}; + let hung = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType, hanging: bool| { + break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging).0 + }; + let end = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| hung(text, width, kinsoku, ty, false); + let text = "一二三四五六七八九十一二三四五」六"; + assert_eq!(end(text, 150.0, Kinsoku::Hard, PushInFirst), 16, "」 is pushed in"); + assert_eq!(end(text, 150.0, Kinsoku::Hard, PushOutFirst), 14, "五 is pushed out with 」"); + assert_eq!(end(text, 150.0, Kinsoku::Hard, PushOutOnly), 14, "五 is pushed out with 」"); + assert_eq!(end(text, 150.0, Kinsoku::Soft, PushInFirst), 16, "Soft keeps 」 off the line start too"); + assert_eq!(end(text, 150.0, Kinsoku::None, PushInFirst), 15, "no kinsoku: 」 starts the next line"); + + let bracketed = "一二三四五六七八九十一二三「四」六"; + assert_eq!(end(bracketed, 150.0, Kinsoku::Hard, PushOutFirst), 16, "pushing out 「四 would leave two ems"); + assert_eq!(end(bracketed, 150.0, Kinsoku::Hard, PushInFirst), 16); + assert_eq!(end(bracketed, 150.0, Kinsoku::Hard, PushOutOnly), 13); + + // Latin brackets and quotation marks are pushed in too: an opening one that would end the + // line, and a closing one after a Latin letter that would start the next. + for opening in ["一二三四五六七八九十一二三四(五六", "一二三四五六七八九十一二三四\u{201c}五六"] { + assert_eq!(end(opening, 150.0, Kinsoku::Hard, PushInFirst), 16, "{opening}: the bracket and 五 are pushed in"); + assert_eq!(end(opening, 150.0, Kinsoku::Hard, PushOutOnly), 14, "{opening}: the bracket is pushed out with 五"); + } + let latin = "一二三四五六七八九十一二三四a)"; + assert_eq!(end(latin, 150.0, Kinsoku::Hard, PushInFirst), 16, ") is pushed in"); + assert_eq!(end(latin, 150.0, Kinsoku::Hard, PushOutOnly), 14, "a is pushed out with )"); + // A push-in does not end at a soft hyphen: taking `b` and the soft hyphen in would fit, the + // whole word `bc` does not, so the line pushes out. + let shy = format!("{}a)b\u{ad}c", "一".repeat(31)); + assert_eq!(end(­, 320.0, Kinsoku::Hard, PushInFirst), 31); + assert_eq!(end(­, 320.0, Kinsoku::Hard, PushOutOnly), 31); + // Nor at a space after an opening bracket, which would leave the bracket ending the line. + let spaced = format!("{}「「 A", "一".repeat(14)); + assert_eq!(end(&spaced, 150.0, Kinsoku::Hard, PushInFirst), 14); + assert_eq!(end(&spaced, 150.0, Kinsoku::Hard, PushOutOnly), 14); + // With hanging punctuation on, the gap before a full stop that may hang at the line's end is + // not counted: the other nine gaps close by 9 pt, short of the 10 pt the full stop needs. With + // it off, all ten gaps close and the full stop is pushed in. + let stop = format!("{}五.", "一".repeat(9)); + assert_eq!(hung(&stop, 100.0, Kinsoku::Hard, PushInFirst, true), 9); + assert_eq!(hung(&stop, 100.0, Kinsoku::Hard, PushOutOnly, true), 9); + assert_eq!(end(&stop, 100.0, Kinsoku::Hard, PushInFirst), 11); + assert_eq!(end(&stop, 100.0, Kinsoku::Hard, PushOutOnly), 9); + + // Two gaps close by at most 2 pt, not the 5 pt 」 needs. + let short = "一二」三"; + assert_eq!(end(short, 25.0, Kinsoku::Hard, PushInFirst), 1, "too little to close up: 二 is pushed out"); + + // A line holding a tab pushes out. Here the tab reaches the default stop at 36 pt, so pushing + // out leaves the line one em short, though the tab glyph's own advance is 10 pt. + let tabbed = "一\t二三四五六七八九十一二」六"; + assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutOnly), 12); + assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutFirst), 12); + assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushInFirst), 12); +} diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index 1010ed84a..334a91105 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -699,6 +699,57 @@ fn kinsoku_set_decides_whether_a_small_kana_may_start_a_line() { } } +/// Kinsoku Shori Type: in a measure of fifteen ems, the closing bracket after the fifteenth +/// character ends the first line with Push In First, which closes the line's gaps to keep it inside +/// the frame whatever its alignment; Push Out Only moves the fifteenth character to the next line +/// with the bracket. Horizontal and vertical. Needs a font with full-width Japanese (skipped +/// without one). +#[test] +fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { + use vectorcraft_doc::KinsokuShoriType; + let text = "一二三四五六七八九十一二三四五」六"; + let lay = |ty: KinsokuShoriType, justify: Justify, vertical_type: bool| { + let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() }); + t.xf = Affine::IDENTITY; + t.vertical = vertical_type; + t.para.kinsoku_type = ty; + t.para.justify = justify; + let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, 300.0) } else { Rect::new(0.0, 0.0, 300.0, 200.0) }; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) }; + layout(FontDb::global(), &t) + }; + if (lay(KinsokuShoriType::PushOutOnly, Justify::Left, false).glyphs[0].advance - 20.0).abs() > 2.0 { + return; // no font with full-width Japanese here + } + for vertical_type in [false, true] { + let lines = |ty| lay(ty, Justify::Left, vertical_type).glyphs.iter().map(|g| g.line).collect::>(); + let mut pushed_in = vec![0; 16]; + pushed_in.push(1); + assert_eq!(lines(KinsokuShoriType::PushInFirst), pushed_in, "vertical {vertical_type}"); + let mut pushed_out = vec![0; 14]; + pushed_out.extend([1, 1, 1]); + assert_eq!(lines(KinsokuShoriType::PushOutOnly), pushed_out, "vertical {vertical_type}"); + assert_eq!(lines(KinsokuShoriType::PushOutFirst), pushed_out, "vertical {vertical_type}: a push-out leaves one em"); + } + for justify in [Justify::Left, Justify::Center, Justify::Right, Justify::JustifyLeft] { + let l = lay(KinsokuShoriType::PushInFirst, justify, false); + let (first, bracket) = (&l.glyphs[0], &l.glyphs[15]); + assert!(first.origin.x.abs() < 0.01, "{justify:?}: the line starts at the frame's left: {:?}", first.origin); + assert!(bracket.origin.x + bracket.advance <= 300.01, "{justify:?}: 」 ends inside the frame: {:?}", bracket.origin); + assert!(l.glyphs[1].origin.x < 20.0 - 0.5, "{justify:?}: the gaps close up"); + for g in &l.glyphs[..15] { + assert!(g.advance >= 18.0 - 0.01, "{justify:?}: a gap closes by at most a tenth of an em: {}", g.advance); + } + } + // A No Break run wider than the frame did not push anything in, and keeps its gaps. + let mut t = TextObject::point(Point::ZERO, "一二三四五六七八九十", CharStyle { size: 20.0, no_break: true, ..CharStyle::default() }); + t.xf = Affine::IDENTITY; + t.para.kinsoku_type = KinsokuShoriType::PushInFirst; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 150.0, 200.0).to_path(0.1)) }; + let l = layout(FontDb::global(), &t); + assert!((l.glyphs[1].origin.x - 20.0).abs() < 0.01, "{:?}", l.glyphs[1].origin); +} + /// Proportional Metrics in vertical type (#966): upright glyphs take the font's proportional /// heights (`vpal`, from shaping them top to bottom) and move with them, so each glyph's ink stays in /// its shorter cell. Line-end Punctuation Half Width takes nothing more off the punctuation `vpal` diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 801ed44b5..fd6e9459e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1859,6 +1859,10 @@ font-technology Variable Променливи Stylistic Стилистично Mojikumi Set Набор Mojikumi Kinsoku Set Набор Kinsoku + Kinsoku Shori Type Тип Kinsoku Shori + Push In First Първо прибиране + Push Out First Първо изтласкване + Push Out Only Само изтласкване Hard Твърдо Soft Меко Monitor RGB RGB на монитора diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index d2e67c11d..9ea2a57c6 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1814,6 +1814,10 @@ digits Malayalam Malajalam Stylistic Stilsko Mojikumi Set Mojikumi skup Kinsoku Set Kinsoku skup + Kinsoku Shori Type Vrsta Kinsoku Shori + Push In First Prvo uvlačenje + Push Out First Prvo izbacivanje + Push Out Only Samo izbacivanje Hard Tvrdo Right Indent Desna uvlaka Right-to-Left Paragraph Direction Smjer pasusa s desna nalijevo diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index ffb752f8a..8c166c172 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2074,6 +2074,7 @@ digits Malayalam Malaiàlam Keywords: Paraules clau: Kind Tipus Kinsoku Set Conjunt Kinsoku + Kinsoku Shori Type Tipus de Kinsoku Shori Kite Estel Knife Ganivet Knockout Group Grup knockout @@ -3117,6 +3118,9 @@ digits Malayalam Malaiàlam Puppet Warp Pins Pins de deformació de titella Puppet Warp Tool Eina de deformació de titella Purple Porpra + Push In First Primer cap a dins + Push Out First Primer cap a fora + Push Out Only Només cap a fora Put back in the toolbar Torna a posar a la barra d’eines Quality Qualitat Quality {quality} Qualitat {quality} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 97ab1c407..724a6888b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1840,6 +1840,10 @@ digits Malayalam Malajálamské Stylistic Stylistický Mojikumi Set Sada Mojikumi Kinsoku Set Sada Kinsoku + Kinsoku Shori Type Typ Kinsoku Shori + Push In First Nejprve vtáhnout + Push Out First Nejprve vysunout + Push Out Only Pouze vysunout Hard Pevné Right Indent Odsazení zprava Right-to-Left Paragraph Direction Směr odstavce zprava doleva diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 1d1741662..f008f91c0 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1645,6 +1645,10 @@ digits Malayalam Malayalam Stylistic Stilistisk Mojikumi Set Mojikumi-sæt Kinsoku Set Kinsoku-sæt + Kinsoku Shori Type Kinsoku Shori-type + Push In First Skub ind først + Push Out First Skub ud først + Push Out Only Kun skub ud Hard Hård Right Indent Højre indrykning Right-to-Left Paragraph Direction Afsnitsretning fra højre mod venstre diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 804542013..9fd518d98 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1914,6 +1914,10 @@ file-filter Templates Vorlagen Stylistic Stilistisch Mojikumi Set Mojikumi-Satz Kinsoku Set Kinsoku-Satz + Kinsoku Shori Type Kinsoku-Shori-Typ + Push In First Zuerst einziehen + Push Out First Zuerst hinausschieben + Push Out Only Nur hinausschieben Hard Hart Soft Weich Monitor RGB Monitor-RGB diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ce061787a..84a7b045c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2681,6 +2681,10 @@ theme Appearance Εμφάνιση Stylistic Στιλιστικό Mojikumi Set Σύνολο Mojikumi Kinsoku Set Σύνολο Kinsoku + Kinsoku Shori Type Τύπος Kinsoku Shori + Push In First Πρώτα προς τα μέσα + Push Out First Πρώτα προς τα έξω + Push Out Only Μόνο προς τα έξω Mono Μονοφωνικό Monochrome images Μονόχρωμες εικόνες Monospaced Σταθερού πλάτους diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3f389ff27..cf5b9c75c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1912,6 +1912,10 @@ font-technology Variable Variables Stylistic Estilística Mojikumi Set Conjunto de mojikumi Kinsoku Set Conjunto de kinsoku + Kinsoku Shori Type Tipo de kinsoku shori + Push In First Primero hacia dentro + Push Out First Primero hacia fuera + Push Out Only Solo hacia fuera Hard Fuerte Soft Suave Monitor RGB RGB del monitor diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d3cf99cd8..f62cb8d52 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1801,6 +1801,10 @@ digits Malayalam Malajalam Stylistic Tyylitelty Mojikumi Set Mojikumi-joukko Kinsoku Set Kinsoku-joukko + Kinsoku Shori Type Kinsoku Shori -tyyppi + Push In First Ensin sisään + Push Out First Ensin ulos + Push Out Only Vain ulos Hard Tiukka Right Indent Oikea sisennys Right-to-Left Paragraph Direction Kappaleen suunta oikealta vasemmalle diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e82972fbc..6378b4fc0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1912,6 +1912,10 @@ font-technology Variable Variables Stylistic Stylistique Mojikumi Set Jeu de mojikumi Kinsoku Set Jeu de kinsoku + Kinsoku Shori Type Type de kinsoku shori + Push In First D’abord vers l’intérieur + Push Out First D’abord vers l’extérieur + Push Out Only Vers l’extérieur uniquement Hard Fort Soft Souple Monitor RGB RVB du moniteur diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 1f7317fad..53f9bd35e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2114,6 +2114,7 @@ file-filter Templates Sablonok Keywords: Kulcsszavak: Kind Típus Kinsoku Set Kinsoku-készlet + Kinsoku Shori Type Kinsoku Shori típusa Kite Deltoid Knife Kés Knockout Group Kiütő csoport @@ -3019,6 +3020,9 @@ mark-style Japanese Japán Puppet Warp Pins Bábtorzítás tűi Puppet Warp Tool Bábtorzítás Purple Lila + Push In First Először behúzás + Push Out First Először kitolás + Push Out Only Csak kitolás Put back in the toolbar Visszahelyezés az eszköztárba Quality Minőség Quality {quality} Minőség: {quality} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index b90aadc98..fc3ff2fa4 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1645,6 +1645,10 @@ digits Malayalam Malayalam Stylistic Stilistik Mojikumi Set Set Mojikumi Kinsoku Set Set Kinsoku + Kinsoku Shori Type Jenis Kinsoku Shori + Push In First Dorong masuk dahulu + Push Out First Dorong keluar dahulu + Push Out Only Hanya dorong keluar Hard Keras Right Indent Indentasi kanan Right-to-Left Paragraph Direction Arah paragraf kanan ke kiri diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9a9709b8d..d008eddbf 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1939,6 +1939,10 @@ file-filter Templates Modelli Stylistic Stilistica Mojikumi Set Set mojikumi Kinsoku Set Set kinsoku + Kinsoku Shori Type Tipo di kinsoku shori + Push In First Prima all'interno + Push Out First Prima all'esterno + Push Out Only Solo all'esterno Hard Forte Soft Debole Monitor RGB RGB monitor diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b901b9336..e9ea281ee 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -68,6 +68,10 @@ digits Malayalam マラヤーラム数字 Stylistic スタイリスティック Mojikumi Set 文字組み Kinsoku Set 禁則処理 + Kinsoku Shori Type 禁則調整方式 + Push In First 追い込み優先 + Push Out First 追い出し優先 + Push Out Only 追い出しのみ Hard 強い禁則 Soft 弱い禁則 Latin 欧文 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index be58e0b36..9af41e3bc 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1548,6 +1548,10 @@ digits Malayalam მალაიალამური Stylistic სტილისტური Mojikumi Set Mojikumi-ის ნაკრები Kinsoku Set Kinsoku-ის ნაკრები + Kinsoku Shori Type Kinsoku Shori-ის ტიპი + Push In First ჯერ შეტანა + Push Out First ჯერ გატანა + Push Out Only მხოლოდ გატანა Hard მკაცრი Right Indent მარჯვენა შეწევა Right-to-Left Paragraph Direction აბზაცის მიმართულება მარჯვნიდან მარცხნივ diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b979e2613..2d45f2911 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -61,6 +61,10 @@ digits Malayalam 말라얄람어 Stylistic 스타일 Mojikumi Set 모지쿠미 세트 Kinsoku Set 긴소쿠 세트 + Kinsoku Shori Type 긴소쿠 처리 유형 + Push In First 밀어 넣기 우선 + Push Out First 밀어내기 우선 + Push Out Only 밀어내기만 Hard 강하게 Soft 약하게 Latin 라틴어 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 60b73cb0f..007d79567 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1659,6 +1659,10 @@ digits Malayalam Malayalam Stylistic Stilistisch Mojikumi Set Mojikumi-set Kinsoku Set Kinsoku-set + Kinsoku Shori Type Kinsoku Shori-type + Push In First Eerst inschuiven + Push Out First Eerst uitschuiven + Push Out Only Alleen uitschuiven Hard Hard Right Indent Rechts inspringen Right-to-Left Paragraph Direction Alinearichting van rechts naar links diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e32cb1fa1..acfb26482 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1759,6 +1759,10 @@ digits Malayalam Malajalam Stylistic Stylistyczny Mojikumi Set Zestaw mojikumi Kinsoku Set Zestaw kinsoku + Kinsoku Shori Type Typ kinsoku shori + Push In First Najpierw wciągaj + Push Out First Najpierw wypychaj + Push Out Only Tylko wypychaj Hard Ścisły Right Indent Wcięcie z prawej Right-to-Left Paragraph Direction Kierunek akapitu od prawej do lewej diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index af74d69dd..bb6b55d59 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3286,6 +3286,10 @@ font-technology Variable Variáveis Stylistic Estilístico Mojikumi Set Conjunto Mojikumi Kinsoku Set Conjunto Kinsoku + Kinsoku Shori Type Tipo de Kinsoku Shori + Push In First Primeiro para dentro + Push Out First Primeiro para fora + Push Out Only Somente para fora Hard Duro Soft Macio Monospaced Monoespaçado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c497b2ad2..74ed0d8c8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1937,6 +1937,10 @@ file-filter Templates Шаблоны Stylistic Стилистические Mojikumi Set Набор mojikumi Kinsoku Set Набор kinsoku + Kinsoku Shori Type Тип kinsoku shori + Push In First Сначала втягивать + Push Out First Сначала выталкивать + Push Out Only Только выталкивать Hard Строгий Soft Мягкий Monitor RGB RGB монитора diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 4b8742d00..79628cc8e 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1859,6 +1859,10 @@ font-technology Variable Variabla Stylistic Stilistisk Mojikumi Set Mojikumi-uppsättning Kinsoku Set Kinsoku-uppsättning + Kinsoku Shori Type Kinsoku Shori-typ + Push In First Skjut in först + Push Out First Skjut ut först + Push Out Only Skjut endast ut Hard Hård Soft Mjuk Monitor RGB Bildskärms-RGB diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5aeae9118..b4138a6a9 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1750,6 +1750,10 @@ font-technology Variable Değişken Mojikumi Mojikumi Mojikumi Set Mojikumi Seti Kinsoku Set Kinsoku Seti + Kinsoku Shori Type Kinsoku Shori Türü + Push In First Önce İçeri + Push Out First Önce Dışarı + Push Out Only Yalnızca Dışarı Hard Sert Soft Yumuşak Monitor RGB RGB'yi izleyin diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 53fe1f462..8de9a4c33 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1933,6 +1933,10 @@ file-filter Templates Шаблони Stylistic Стилістичні Mojikumi Set Набір mojikumi Kinsoku Set Набір kinsoku + Kinsoku Shori Type Тип kinsoku shori + Push In First Спершу втягувати + Push Out First Спершу виштовхувати + Push Out Only Лише виштовхувати Hard Суворий Soft М’який Monitor RGB RGB монітора diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ea692ca64..6368d6994 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1953,6 +1953,7 @@ digits Hindi Hindi Keywords: Từ khóa: Kind Loại Kinsoku Set Bộ Kinsoku + Kinsoku Shori Type Kiểu Kinsoku Shori Knife Dao Knockout Group Nhóm Knockout Lab Lab @@ -2798,6 +2799,9 @@ mark-style Japanese Kiểu Nhật Puppet Warp Biến dạng con rối Puppet Warp Pins Ghim biến dạng con rối Puppet Warp Tool Công cụ Biến dạng con rối + Push In First Đẩy vào trước + Push Out First Đẩy ra trước + Push Out Only Chỉ đẩy ra Put back in the toolbar Đặt trở lại vào thanh công cụ Quality Chất lượng Quality {quality} Chất lượng {quality} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 431eb3355..c206cc98b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4835,6 +4835,10 @@ font-technology Variable 可变 Stylistic 风格化 Mojikumi Set 标点挤压集 Kinsoku Set Kinsoku 集 + Kinsoku Shori Type Kinsoku 处理方式 + Push In First 先推入 + Push Out First 先推出 + Push Out Only 只推出 Hard 强 Soft 弱 Saved Selection 1 已保存的选区 1 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c55958717..a6bb8c11f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2737,6 +2737,10 @@ file-filter Templates 範本 Stylistic 風格化 Mojikumi Set 標點間距 Kinsoku Set Kinsoku 集 + Kinsoku Shori Type Kinsoku 處理方式 + Push In First 先推入 + Push Out First 先推出 + Push Out Only 只推出 Hard 強 Soft 弱 Monitor RGB 顯示器 RGB diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 1952de032..5c3bc899c 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -2,12 +2,14 @@ //! Bullets and Numbered List buttons and their presets, indents, space before/after, Hyphenate, //! Insert Kashidas (with the Indic options), Mojikumi Set and Kinsoku Set (with the East Asian //! options); the panel menu turns Roman Hanging Punctuation on and off, opens Justification… and -//! Hyphenation… and picks the Single-line or Every-line Composer. +//! Hyphenation…, picks the Kinsoku Shori Type (with the East Asian options) and picks the +//! Single-line or Every-line Composer. use egui::{Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::{ - Composer, HyphenationOptions, JustificationOptions, Justify, Kinsoku, Mojikumi, NodeKind, ParaDirection, ParaStyle, SingleWordJustify, + Composer, HyphenationOptions, JustificationOptions, Justify, Kinsoku, KinsokuShoriType, Mojikumi, NodeKind, ParaDirection, ParaStyle, + SingleWordJustify, }; use super::character::{Metric, Shared, text_style}; @@ -722,6 +724,22 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } }); }); + // How a line keeps the kinsoku characters off its ends: the selected paragraphs' type is + // checked, and none is when they differ. + let kinsoku_type = shared_para(app, |p| p.kinsoku_type); + ui.add_enabled_ui(has, |ui| { + ui.menu_button(format!(" {}", tl!("Kinsoku Shori Type")), |ui| { + for (label, t, key) in [ + (tl!("Push In First"), KinsokuShoriType::PushInFirst, "pushInFirst"), + (tl!("Push Out First"), KinsokuShoriType::PushOutFirst, "pushOutFirst"), + (tl!("Push Out Only"), KinsokuShoriType::PushOutOnly, "pushOutOnly"), + ] { + if menu_item(ui, label, true, kinsoku_type == Some(t)) { + set_kinsoku_type(app, key); + } + } + }); + }); } ui.separator(); let composer = shared_composer(app); @@ -738,10 +756,16 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// Kinsoku Shori Type (`key`: `"pushInFirst"`, `"pushOutFirst"` or `"pushOutOnly"`) for the +/// paragraphs the panel acts on ([`format`]), in one undo step. +fn set_kinsoku_type(app: &mut VectorcraftApp, key: &str) { + format(app, json!({"kinsokuType": key})); +} + /// Reset Panel: the paragraphs the panel acts on ([`format`]) take Auto alignment, no list, no /// indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification -/// options, Auto direction, Bottom-to-Bottom Leading, Regular burasagari, the Hard kinsoku set, the -/// Every-line Composer, Roman Hanging Punctuation off and no kashidas. +/// options, Auto direction, Bottom-to-Bottom Leading, Regular burasagari, the Hard kinsoku set, Push +/// Out Only, the Every-line Composer, Roman Hanging Punctuation off and no kashidas. fn reset_panel(app: &mut VectorcraftApp) { let h = HyphenationOptions::default(); let j = vectorcraft_doc::JustificationOptions::default(); @@ -754,7 +778,8 @@ fn reset_panel(app: &mut VectorcraftApp) { "hyphenLimit": h.hyphen_limit, "hyphenationZone": h.zone, "hyphenateCapitalized": h.capitalized, "kashida": "none", "wordSpacing": j.word_spacing, "letterSpacing": j.letter_spacing, "glyphScaling": j.glyph_scaling, "autoLeading": j.auto_leading, "singleWordJustification": "fullJustify", - "direction": "auto", "leadingModel": "romanBaseline", "burasagari": "standard", "kinsoku": "hard", "composer": "everyLine", + "direction": "auto", "leadingModel": "romanBaseline", "burasagari": "standard", "kinsoku": "hard", "kinsokuType": "pushOutOnly", + "composer": "everyLine", "romanHangingPunctuation": false }), ); diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 163427da5..48af94f97 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -106,6 +106,32 @@ fn paragraph_mixed_kinsoku_and_mojikumi_show_blank_dropdowns() { assert_eq!(shared_para(&app, |p| p.mojikumi), Some(Mojikumi::LineEndHalf)); } +/// Kinsoku Shori Type: the menu's item sets the type of the paragraphs the Type tool's selection +/// touches in one undo step, and none is checked while the selected paragraphs differ. +#[test] +fn kinsoku_shori_type_sets_the_selected_paragraphs_in_one_undo_step() { + let (mut app, id) = editing_text("あ\nい\n"); + app.session.prefs.show_east_asian_options = true; + let types = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + (text.para_at(0).kinsoku_type, text.para_at(1).kinsoku_type) + }; + select_range(&mut app, 0, 3); + let history = app.session.active().unwrap().history.undo.len(); + set_kinsoku_type(&mut app, "pushInFirst"); + assert_eq!(types(&app), (KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutOnly)); + assert_eq!(app.session.active().unwrap().history.undo.len(), history + 1); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(shared_para(&app, |p| p.kinsoku_type), None, "mixed: no item checked"); + set_kinsoku_type(&mut app, "pushOutFirst"); + assert_eq!(types(&app), (KinsokuShoriType::PushOutFirst, KinsokuShoriType::PushOutFirst)); + assert_eq!(shared_para(&app, |p| p.kinsoku_type), Some(KinsokuShoriType::PushOutFirst)); + app.run("edit.undo", json!({})).unwrap(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(types(&app), (KinsokuShoriType::PushOutOnly, KinsokuShoriType::PushOutOnly)); +} + #[test] fn paragraph_mixed_indent_fields_show_blank() { let (mut app, id) = editing_text("A\nB\n"); diff --git a/docs/mcp.md b/docs/mcp.md index ddd23ecfa..18a33f74d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4838,6 +4838,27 @@ JLREQ's level 3 line-breaking rules do (https://www.w3.org/TR/jlreq/#addendum_a3 {"name":"run_command","arguments":{"command":"text.setFormat","params":{"kinsoku":"soft"}}} ``` +## Kinsoku Shori Type + +`text.setFormat {kinsokuType: "pushInFirst"|"pushOutFirst"|"pushOutOnly"}` (Paragraph panel menu › Kinsoku Shori Type, +shown with the East Asian options) sets how the area type lines of the paragraphs of the selected type (or `ids`, or the +paragraphs a `start` / `end` range touches) keep the characters of their Kinsoku Set off their start and end. When the +character that does not fit on a line may not start the next one, or the character before it may not end the line, +**Push In First** sets it on the line, with the characters up to the next break the Kinsoku Set allows. The line then +closes its gaps next to CJK characters to fit its measure, however it is aligned, each gap by the same share of the em +of the character before it and by at most a tenth of that em; a line that cannot close up that much, or that holds a +tab, pushes out instead. **Push Out First** pushes in +the same way only when pushing out would leave the line more than an em, of the character before the break, short of +its measure. **Push Out Only** (the default: new type and documents from before the setting) always moves the +character before them to the next line with them. With Kinsoku Set None the setting does nothing. A paragraph that +pushes in and holds such characters is broken line by line, as the Single-line Composer breaks it. The setting is saved +with the paragraph (`kinsoku_type` in `para`) only when it is not `pushOutOnly`, paragraph styles carry it, and Reset +Panel sets it back to Push Out Only. One undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"kinsokuType":"pushInFirst"}}} +``` + ## Hanging punctuation (burasagari) `text.setFormat {burasagari: "none"|"standard"|"forced"}` sets the Paragraph panel menu's Burasagari (None / Regular / diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c592788df..e9343afe2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -872,6 +872,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs. + The Paragraph panel menu's Kinsoku Shori Type (`text.setFormat {kinsokuType}`, with the East Asian options) picks Push In First, Push Out First or Push Out Only for each paragraph: a character the Kinsoku Set keeps off a line's start or end is set on the line, with the line's gaps next to CJK characters closed by at most a tenth of an em each to fit, or moved to the next line with the character before it. Push Out First pushes in only when pushing out would leave the line more than an em short. Push Out Only is the default, the setting is saved only when it is not, and paragraph styles carry it. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. diff --git a/docs/type-parity.md b/docs/type-parity.md index e13ad6680..190c3565b 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -57,6 +57,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Middle Eastern: Character Direction, kashidas, Middle Eastern composers, split caret | missing | | | CJK: input methods (marked text, candidate window) | done | | | CJK: kinsoku (Hard, Soft, None), Line-end Punctuation Half Width, tate-chu-yoko (auto) | done / partial | custom kinsoku sets, the Mojikumi Settings dialog and the other mojikumi sets missing | +| CJK: Kinsoku Shori Type (Push In First, Push Out First, Push Out Only) | done | Paragraph panel menu › Kinsoku Shori Type (`text.setFormat {kinsokuType}`), with the East Asian options ([Formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); Push Out Only is the default, and a push-in closes the line's gaps next to CJK characters by at most a tenth of an em each (VectorCraft's own limit). Evidence: `layout::wrapping_tests::kinsoku_shori_type_pushes_a_closing_bracket_in_or_out`, `tests::vertical::kinsoku_shori_type_pushes_a_closing_bracket_in_or_out`, `typecmd::area_tests::kinsoku_shori_type_is_set_per_paragraph_and_saved_only_when_not_push_out_only` | | CJK: Proportional Metrics (`palt` in horizontal type, `vpal` in vertical type) | done | #966 | | 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. Up/Down and Left/Right settings remain. Evidence: `tests_vertical::manual_tate_chu_yoko_sets_marked_characters_across_one_em`, `typecmd::tate_chu_yoko_is_a_character_attribute_saved_only_when_on` | | 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` | From 932426eaadd2af652b0885e48ce4c9f68466924c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 08:22:06 -0800 Subject: [PATCH 2/3] Type: a line holding right-to-left text pushes out A line with right-to-left text is reordered before it is drawn, and justification closes the gaps between its glyphs in that order, which can differ from the gaps the push-in counted. Such a line could be taken as fitting and then run past its frame. Kinsoku Shori Type now pushes it out. --- crates/text/src/layout/breaking.rs | 9 +++++++-- crates/text/src/layout/wrapping_tests.rs | 9 +++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index e996cbba8..f41e4943f 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -518,8 +518,8 @@ const PUSH_IN_RUN: usize = 16; /// before it, makes room for them. Push In First tries this before it pushes out. Push Out First /// tries it only when pushing out, to the line's last allowed break `pushed_out`, would leave the /// line short by more than an em of the character before `j`. Push Out Only never pushes in, and -/// neither does a line holding a tab, nor one where the characters taken in reach a space after a -/// character that may not end a line. With `hanging` (Optical Margin Alignment or Roman Hanging +/// neither does a line holding a tab or right-to-left text, nor one where the characters taken in +/// reach a space after a character that may not end a line. With `hanging` (Optical Margin Alignment or Roman Hanging /// Punctuation), the gaps that hanging punctuation keeps open are not counted. #[allow(clippy::too_many_arguments)] fn push_in( @@ -569,6 +569,11 @@ fn push_in( while last > i && g[last - 1].is_space() { last -= 1; } + // A line holding right-to-left text is reordered before it is drawn, and justification closes + // the gaps between its glyphs in that order: it pushes out. + if g[i..last].iter().any(|h| h.len > 0 && h.level.is_rtl()) { + return None; + } let punct = |k: usize| g.get(k).is_some_and(optical_punct); let quote = |k: usize| g.get(k).is_some_and(|h| is_quote(h.ch)); let may_hang = |a: usize| { diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index e6b6ad08f..ea12f601b 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -223,4 +223,13 @@ fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutOnly), 12); assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutFirst), 12); assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushInFirst), 12); + + // A line holding right-to-left text is reordered before it is drawn, so the gaps that close + // differ from the ones counted here: it pushes out. + let mixed = "一二三א五六七八九十一二三四五」六"; + let mut glyphs = full_width(mixed); + glyphs[3].level = unicode_bidi::Level::rtl(); + let mixed_end = |ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false).0; + assert_eq!(mixed_end(PushInFirst), 14, "right-to-left text on the line: 五 is pushed out with 」"); + assert_eq!(mixed_end(PushOutOnly), 14); } From 0465fb833656822627667805bc3ad03176961e0e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 08:36:32 -0800 Subject: [PATCH 3/3] Type: push-in closes no gap inside a cluster and measures along the line A push-in no longer counts or closes the gap between two glyphs of one shaped character, and CJK justification adds no space there either. The share of an em each gap closes by is measured along the line: at the character's Horizontal Scale, or at its Vertical Scale for an upright character in vertical type whose font's vertical metrics set its length down the column. Before, horizontal text at 200% Vertical Scale closed its gaps by twice as much. --- crates/text/src/layout/breaking.rs | 40 +++++++++++++----- crates/text/src/layout/flow.rs | 18 ++++---- crates/text/src/layout/wrapping_tests.rs | 52 +++++++++++++++++++----- crates/text/src/tests/vertical.rs | 29 +++++++++++++ 4 files changed, 111 insertions(+), 28 deletions(-) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index f41e4943f..7fbb3f39c 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -1,5 +1,5 @@ use super::region::Region; -use super::vertical::glyph_em; +use super::vertical::stands_upright; use super::{ Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, KinsokuShoriType, LeadingModel, SGlyph, hyphen_glyph, hyphen_points_with, is_cjk, no_line_end, no_line_start, soft_line_start, @@ -272,7 +272,7 @@ pub(super) fn word_width(g: &[SGlyph], i: usize) -> f64 { /// (`tabs`), with the line's content starting `x_start` from the tab origin. Where kinsoku alone /// keeps the line from breaking before the character that overflows, `kinsoku_type` decides /// whether the line pushes characters in ([`push_in`]) or out; `hanging` is true when Optical -/// Margin Alignment or Roman Hanging Punctuation is on. +/// Margin Alignment or Roman Hanging Punctuation is on, and `vertical` in vertical type. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -286,6 +286,7 @@ pub(super) fn break_line( kinsoku: Kinsoku, kinsoku_type: KinsokuShoriType, hanging: bool, + vertical: bool, ) -> (usize, bool) { if !width.is_finite() { if let Some(pos) = g[i..].iter().position(|gl| gl.ch == '\u{2028}') { @@ -335,7 +336,9 @@ pub(super) fn break_line( } return (end, false); } - if let Some(end) = push_in(g, i, j, cluster_end, x + cluster_width, width, last_break.map(|b| b.0), kinsoku, kinsoku_type, hanging) { + if let Some(end) = + push_in(g, i, j, cluster_end, x + cluster_width, width, last_break.map(|b| b.0), kinsoku, kinsoku_type, hanging, vertical) + { return (end, false); } break; @@ -519,8 +522,9 @@ const PUSH_IN_RUN: usize = 16; /// tries it only when pushing out, to the line's last allowed break `pushed_out`, would leave the /// line short by more than an em of the character before `j`. Push Out Only never pushes in, and /// neither does a line holding a tab or right-to-left text, nor one where the characters taken in -/// reach a space after a character that may not end a line. With `hanging` (Optical Margin Alignment or Roman Hanging -/// Punctuation), the gaps that hanging punctuation keeps open are not counted. +/// reach a space after a character that may not end a line. With `hanging` (Optical Margin +/// Alignment or Roman Hanging Punctuation), the gaps that hanging punctuation keeps open are not +/// counted. `vertical`: the line is a column of vertical type ([`squeeze_em`]). #[allow(clippy::too_many_arguments)] fn push_in( g: &[SGlyph], @@ -533,6 +537,7 @@ fn push_in( kinsoku: Kinsoku, kinsoku_type: KinsokuShoriType, hanging: bool, + vertical: bool, ) -> Option { if kinsoku == Kinsoku::None || kinsoku_type == KinsokuShoriType::PushOutOnly { return None; @@ -583,7 +588,7 @@ fn push_in( || (a + 2 == last && punct(last - 1)) || (a + 3 == last && punct(last - 2) && quote(last - 1))) }; - let room: f64 = (i..end - 1).filter(|&a| closes(g, a) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * glyph_em(&g[a])).sum(); + let room: f64 = (i..end - 1).filter(|&a| closes(g, a) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * squeeze_em(&g[a], vertical)).sum(); if x - width > room + EPS { return None; } @@ -593,7 +598,7 @@ fn push_in( kept -= 1; } let short = width - g[i..kept].iter().map(|h| h.adv).sum::(); - if short <= glyph_em(before) + EPS { + if short <= squeeze_em(before, vertical) + EPS { return None; } } @@ -607,11 +612,26 @@ fn push_in( } /// May a push-in close the gap after glyph `a`? A gap between two characters, at least one of them -/// CJK, outside a tate-chu-yoko block and a list marker, and not next to a space, a tab or an -/// inline graphic before it. +/// CJK, outside a tate-chu-yoko block, a list marker and a shaped cluster, and not next to a space, +/// a tab or an inline graphic before it. fn closes(g: &[SGlyph], a: usize) -> bool { let (Some(p), Some(n)) = (g.get(a), g.get(a + 1)) else { return false }; - p.len > 0 && p.inline.is_none() && !p.is_space() && !n.is_space() && p.ch != '\t' && !n.continues_tcy() && (is_cjk(p.ch) || is_cjk(n.ch)) + p.len > 0 + && p.byte != n.byte + && p.inline.is_none() + && !p.is_space() + && !n.is_space() + && p.ch != '\t' + && !n.continues_tcy() + && (is_cjk(p.ch) || is_cjk(n.ch)) +} + +/// The em a push-in measures in, along the line: `g`'s em at its Horizontal Scale, as Mojikumi's +/// spacing is, or at its Vertical Scale when it stands upright in vertical type and the font's +/// vertical metrics set its length down the column ([`super::vertical::upright_cell`]). +pub(super) fn squeeze_em(g: &SGlyph, vertical: bool) -> f64 { + let down_the_column = vertical && stands_upright(g) && g.face.vertical_glyph(g.gid).is_some(); + g.face.units_per_em() * if down_the_column { g.sy } else { g.sx } } #[cfg(test)] diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index c28508751..a76f2080c 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -1,7 +1,7 @@ use super::bidi::{is_rtl, para_bidi_with, visual_order}; use super::breaking::{ Metrics, PUSH_IN_SQUEEZE, Pen, break_line, candidates, hangs, leader_outline, optical_hangs, optical_hangs_rtl, optical_punct, optical_share, - roman_share, set_width, tab_advance, word_width, + roman_share, set_width, squeeze_em, tab_advance, word_width, }; use super::region::Region; use super::vertical::{EM_CENTER, align_shift, compress_punctuation, glyph_em, punct_half, space_japanese_and_latin}; @@ -343,7 +343,8 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC let (end, hyph, pushed_in) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { Some(&(end, hyph)) if end > *i => (end, hyph, false), _ if *i < n => { - let line = |ty| break_line(cx.text, sg, *i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku, ty, *hanging); + let line = + |ty| break_line(cx.text, sg, *i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku, ty, *hanging, cx.vertical); let (end, hyph) = line(para.kinsoku_type); // The line pushed characters in where it ends after the line that pushes out. let pushed_in = pushes_in(para) && end > line(KinsokuShoriType::PushOutOnly).0; @@ -595,12 +596,13 @@ fn drawn_after(index: usize, glyph: usize, after: &[Option]) -> Option], index: usize, sg: &[SGlyph]) -> bool { !held.contains(&glyph) - && drawn_after(index, glyph, after) - .and_then(|next| sg.get(glyph).zip(sg.get(next))) - .is_some_and(|(a, b)| !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch))) + && drawn_after(index, glyph, after).and_then(|next| sg.get(glyph).zip(sg.get(next))).is_some_and(|(a, b)| { + a.byte != b.byte && !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch)) + }) } // What justification may change of glyph `j`: nothing of a `held` glyph, and no glyph @@ -668,7 +670,7 @@ fn justify_flow_line(vertical: bool, text: &str, paragraph: &ParagraphData<'_, ' // the same share of the em of the glyph before it, at most [`PUSH_IN_SQUEEZE`]. let pushed_in = line.position.pushed_in && paragraph.regions.is_some() && cjk_gaps > 0 && width > room + EPS; if pushed_in { - let ems: f64 = (line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).map(|j| glyph_em(&sg[j])).sum(); + let ems: f64 = (line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).map(|j| squeeze_em(&sg[j], vertical)).sum(); if ems > EPS { squeeze = ((room - width) / ems).max(-PUSH_IN_SQUEEZE); } @@ -773,7 +775,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData advance += justification.per_space; } else if justification.per_cjk != 0.0 || justification.squeeze != 0.0 { if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { - advance += justification.per_cjk + justification.squeeze * glyph_em(glyph); + advance += justification.per_cjk + justification.squeeze * squeeze_em(glyph, cx.vertical); } } else if !line.held.contains(&glyph_index) && drawn_after(index, glyph_index, &justification.after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy()) diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index ea12f601b..2e62e2956 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -53,7 +53,8 @@ fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() { let text = "بَت"; let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]); - let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false); + let (end, hyphenated) = + break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false); assert!(!hyphenated); assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together"); @@ -66,12 +67,13 @@ fn greedy_wrap_keeps_an_oversized_cluster_after_a_line_start_atomic() { let text = "بَتَث"; let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]); - let (first_end, _) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false); + let (first_end, _) = + break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false); assert_eq!(first_end, 1); assert_eq!(source_range(&glyphs, 0, first_end), 0..4); let (second_end, hyphenated) = - break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false); + break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false); assert!(!hyphenated); assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together"); @@ -126,7 +128,8 @@ fn narrow_arabic_area_wrap_keeps_base_and_mark_together_when_supported() { fn no_break_keeps_marked_characters_on_one_line() { let text = "A B"; let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]); - let (end, hyphen) = break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); + let (end, hyphen) = + break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); assert!(!hyphen); assert_eq!(end, 2, "without the mark the line ends after the space"); @@ -134,7 +137,8 @@ fn no_break_keeps_marked_characters_on_one_line() { for g in &mut marked { g.no_break = true; } - let (end, hyphen) = break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); + let (end, hyphen) = + break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); assert!(!hyphen); assert_eq!(end, 3, "the marked words stay together even though they are wider than the line"); @@ -143,9 +147,9 @@ fn no_break_keeps_marked_characters_on_one_line() { let mut glyphs = synthetic_glyphs(&[(0, 1, 8.0, 'A'), (1, 1, 2.0, ' '), (2, 3, 10.0, '日'), (5, 3, 10.0, '本')]); glyphs[2].no_break = true; glyphs[3].no_break = true; - let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); + let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); assert_eq!(end, 2, "the marked pair moves to the next line together"); - let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); + let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line"); let breaks = candidates(text, &glyphs, None, Kinsoku::None); @@ -157,7 +161,8 @@ fn full_width(text: &str) -> Vec { let specs: Vec<(usize, usize, f64, char)> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 10.0, ch)).collect(); let mut glyphs = synthetic_glyphs(&specs); for g in &mut glyphs { - g.sy = 10.0 / g.face.units_per_em(); + g.sx = 10.0 / g.face.units_per_em(); + g.sy = g.sx; } glyphs } @@ -171,7 +176,7 @@ fn full_width(text: &str) -> Vec { fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly}; let hung = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType, hanging: bool| { - break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging).0 + break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging, false).0 }; let end = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| hung(text, width, kinsoku, ty, false); let text = "一二三四五六七八九十一二三四五」六"; @@ -223,13 +228,40 @@ fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutOnly), 12); assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutFirst), 12); assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushInFirst), 12); +} +/// Kinsoku Shori Type counts only the gaps justification closes: a line holding right-to-left +/// text pushes out, the gaps close by a tenth of the em at the characters' Horizontal Scale, and +/// no gap closes inside a shaped cluster. +#[test] +fn kinsoku_shori_type_counts_the_gaps_that_close() { + use KinsokuShoriType::{PushInFirst, PushOutOnly}; // A line holding right-to-left text is reordered before it is drawn, so the gaps that close // differ from the ones counted here: it pushes out. let mixed = "一二三א五六七八九十一二三四五」六"; let mut glyphs = full_width(mixed); glyphs[3].level = unicode_bidi::Level::rtl(); - let mixed_end = |ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false).0; + let mixed_end = |ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false).0; assert_eq!(mixed_end(PushInFirst), 14, "right-to-left text on the line: 五 is pushed out with 」"); assert_eq!(mixed_end(PushOutOnly), 14); + + // The gaps close by a tenth of the em at the characters' Horizontal Scale: at 200% Vertical + // Scale five gaps still close by 5 pt, short of the 10 pt 」 needs. + let tall = "一二三四五」六"; + let mut glyphs = full_width(tall); + for g in &mut glyphs { + g.sy *= 2.0; + } + let tall_end = |ty: KinsokuShoriType| break_line(tall, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false).0; + assert_eq!(tall_end(PushInFirst), 4, "Vertical Scale does not widen the gaps: 五 is pushed out with 」"); + + // No gap closes inside a shaped cluster. 二 is two glyphs here, so the line has five gaps to + // close by 5 pt, short of the 6 pt it overflows by. + let mut glyphs = full_width(tall); + glyphs[1].adv = 5.0; + let half = glyphs[1].clone(); + glyphs.insert(2, half); + let split_end = |width: f64| break_line(tall, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushInFirst, false, false).0; + assert_eq!(split_end(54.0), 5, "五 is pushed out with 」"); + assert_eq!(split_end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in"); } diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index 334a91105..95c10cefe 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -750,6 +750,35 @@ fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { assert!((l.glyphs[1].origin.x - 20.0).abs() < 0.01, "{:?}", l.glyphs[1].origin); } +/// Kinsoku Shori Type at 200% Vertical Scale in a column of 225 pt: the font's vertical metrics set +/// each character 40 pt long, and its five gaps close by up to 4 pt each, room for the 15 pt 」 +/// overflows by, so 」 is pushed in; Push Out Only moves 五 to the next column with it. Needs +/// craft-fonts' Shippori Mincho (skipped without it). +#[test] +fn kinsoku_shori_type_closes_a_column_by_the_em_down_the_column() { + use vectorcraft_doc::KinsokuShoriType; + if FontDb::global().face("Shippori Mincho", "Regular").is_none_or(|f| f.family != "Shippori Mincho") { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + return; + } + let lay = |ty: KinsokuShoriType| { + let mut t = vertical_styled( + "一二三四五」六", + CharStyle { size: 20.0, font_family: "Shippori Mincho".into(), v_scale: 200.0, ..CharStyle::default() }, + ); + t.para.kinsoku_type = ty; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 200.0, 225.0).to_path(0.1)) }; + layout(FontDb::global(), &t) + }; + let lines = |ty| lay(ty).glyphs.iter().map(|g| g.line).collect::>(); + let out = lay(KinsokuShoriType::PushOutOnly); + assert!((out.glyphs[0].advance - 40.0).abs() < 0.01, "40 pt down the column: {}", out.glyphs[0].advance); + assert_eq!(lines(KinsokuShoriType::PushInFirst), [0, 0, 0, 0, 0, 0, 1]); + let bracket = &lay(KinsokuShoriType::PushInFirst).glyphs[5]; + assert!(bracket.origin.y + bracket.advance <= 225.01, "」 ends inside the column: {:?} +{}", bracket.origin, bracket.advance); + assert_eq!(lines(KinsokuShoriType::PushOutOnly), [0, 0, 0, 0, 1, 1, 1]); +} + /// Proportional Metrics in vertical type (#966): upright glyphs take the font's proportional /// heights (`vpal`, from shaping them top to bottom) and move with them, so each glyph's ink stays in /// its shorter cell. Line-end Punctuation Half Width takes nothing more off the punctuation `vpal`