diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index fadb0165d5..ef62fa2bcc 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -395,8 +395,29 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins if end >= g.len() { set(w, trailing, ink) } else { w } } +/// Whether the cluster `j..end` is drawn last, at the right end, of the left-to-right line of +/// glyphs `i..end`: the line's glyphs are reordered by their levels (UAX #9 L2), with each tab and +/// the spaces before it at the paragraph's level (L1). +fn drawn_last(g: &[SGlyph], i: usize, j: usize, end: usize) -> bool { + let line = g.get(i..end).unwrap_or_default(); + let mut levels: Vec = line.iter().map(|h| h.level).collect(); + let mut before_tab = false; + for (level, h) in levels.iter_mut().zip(line).rev() { + if h.ch == '\t' && h.len > 0 { + before_tab = true; + } else if !h.is_space() { + before_tab = false; + } + if before_tab { + *level = unicode_bidi::Level::ltr(); + } + } + unicode_bidi::BidiInfo::reorder_visual(&levels).last().is_some_and(|&k| i + k >= j) +} + /// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`. -/// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it. +/// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it, +/// unless No Break or Bunri-Kinshi keeps characters after it on its line. /// `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 @@ -416,7 +437,9 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins /// line holds a glyph with a negative advance and has been wider than `width` in the order of its /// text, it reads at most [`MAX_DRAWN_CHECKS_STEPPING_BACK`] legal breaks further, and a forced /// line break right after the last of them, and ends at the latest of the breaks read that fits as -/// drawn. +/// drawn. Such a line with a tab hangs a burasagari mark only when the mark is drawn at its right +/// end in the visual order of the line's levels ([`drawn_last`]) and the line before the mark fits +/// as drawn. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -543,8 +566,24 @@ pub(super) fn break_line( let measured = if tabbed && fits.is_some() { (ink + cluster_ink).max(stops_reached) } else { x + cluster_width }; overflowed |= tabbed && fits.is_some() && j > first && !gl.is_space() && x + cluster_width - end_credit > width + EPS; if j > first && !gl.is_space() && measured - end_credit > width + EPS { - // It ends the line with the spaces after it. - if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) { + // It ends the line with the spaces after it, when the mark is all the characters kept + // together (the line's last character is the mark, which is what hangs). Once the line + // holds a tab and is measured as drawn, the mark hangs only where it is drawn at the + // line's right end (its cluster is last in the line's visual order, [`drawn_last`]) and + // the line before it fits as drawn with no line-end trim on its last glyph; otherwise the + // line ends at a legal break that fits as drawn. + let alone = g[j..cluster_end].iter().all(|h| h.byte == gl.byte); + let hangs_as_drawn = || match fits.filter(|_| tabbed) { + None => true, + Some(fits) => { + let mut body = j; + while body > i && g[body - 1].is_space() { + body -= 1; + } + drawn_last(g, i, j, cluster_end) && line_end_credit(g, line_end, body) == 0.0 && fits(j, false) + } + }; + if burasagari != Burasagari::None && hangs(gl) && alone && kinsoku_allows(g, cluster_end - 1, kinsoku) && hangs_as_drawn() { let mut end = cluster_end; while g.get(end).is_some_and(SGlyph::is_space) { end += 1; diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 30b615c5fc..413bbf2692 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -474,7 +474,9 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC ¶.tabs, ind_l, hyphenate, - para.burasagari, + // A right-to-left paragraph draws a burasagari mark inside its line + // ([`prepare_line_geometry`]): the line ends where it fits with the mark. + if *rtl { Burasagari::None } else { para.burasagari }, para.kinsoku, ty, *hanging, diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index a0a09956bc..c7d8ff8a4b 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -477,6 +477,95 @@ fn a_tabbed_line_ends_at_the_latest_break_that_fits_as_drawn() { assert_eq!(ended, (8, false)); } +/// A tabbed line measured as drawn hangs a burasagari full stop that overflows only when the stop +/// is drawn at the line's right end (last in the visual order of the line's levels, whatever its +/// own level) and the line before it fits as drawn with no line-end trim on its last glyph; +/// otherwise it ends at the latest legal break that fits as drawn. +#[test] +fn a_tabbed_burasagari_mark_hangs_only_at_the_drawn_end_of_a_fitting_line() { + let text = "a \t\u{05D0} 漢。"; + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 10.0, ch)).collect(); + let mut glyphs = synthetic_glyphs(&specs); + glyphs[3].level = unicode_bidi::Level::rtl(); + // The text without its tab is 60 pt wide at 。 (glyph 6), past 55 pt; kinsoku keeps 。 off a line + // start, so the latest legal break before it ends the line before 漢 (glyph 5). + let end = |glyphs: &[SGlyph], line_end: &[f64], fits: Option<&dyn Fn(usize, bool) -> bool>| { + break_line( + text, + glyphs, + 0, + 55.0, + &[], + 0.0, + None, + Burasagari::Standard, + Kinsoku::Hard, + KinsokuShoriType::PushOutOnly, + false, + false, + line_end, + fits, + ) + }; + let all_fit = |_end: usize, _hyphenated: bool| true; + assert_eq!(end(&glyphs, &[], Some(&all_fit)), (7, false), "drawn at the right end of a line that fits: 。 hangs"); + let body_too_wide = |end: usize, _hyphenated: bool| end <= 5; + assert_eq!(end(&glyphs, &[], Some(&body_too_wide)), (5, false), "the line before 。 is too wide as drawn"); + let mut trimmed = vec![0.0; glyphs.len()]; + trimmed[5] = 2.0; + assert_eq!(end(&glyphs, &trimmed, Some(&all_fit)), (5, false), "the line before 。 fits only with 漢 trimmed"); + let level = |n: u8| unicode_bidi::Level::new(n).unwrap_or_else(|_| panic!("level {n}")); + glyphs[6].level = level(2); + assert_eq!(end(&glyphs, &[], Some(&all_fit)), (7, false), "。 set left to right in right-to-left text is drawn at the right end"); + glyphs[6].level = level(1); + assert_eq!(end(&glyphs, &[], Some(&all_fit)), (7, false), "。 alone right to left after 漢 is drawn at the right end"); + glyphs[5].level = level(1); + assert_eq!(end(&glyphs, &[], Some(&all_fit)), (5, false), "。 right to left after 漢 in the same run is drawn inside the line"); + glyphs[6].level = level(2); + assert_eq!(end(&glyphs, &[], Some(&all_fit)), (5, false), "。 left to right inside the right-to-left run of 漢 is drawn inside the line"); +} + +/// A burasagari full stop that No Break keeps with the character after it does not hang: the line +/// would end with that character, which is drawn inside the line, so it ends at an earlier break. +#[test] +fn a_burasagari_mark_kept_with_the_next_character_does_not_hang() { + let text = "漢漢。漢"; + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 10.0, ch)).collect(); + let mut glyphs = synthetic_glyphs(&specs); + let end = |glyphs: &[SGlyph]| { + break_line(text, glyphs, 0, 25.0, &[], 0.0, None, Burasagari::Standard, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[], None) + }; + assert_eq!(end(&glyphs), (3, false), "alone, 。 overflows by 5 pt and hangs"); + glyphs[2].no_break = true; + glyphs[3].no_break = true; + assert_eq!(end(&glyphs), (1, false), "kept with the 漢 after it, 。 does not end the line"); +} + +/// A line with a tab and right-to-left text pushes a closing bracket out with every Kinsoku Shori +/// Type, where the same widths without them push it in. +#[test] +fn push_in_leaves_a_tabbed_right_to_left_line_to_push_out() { + use vectorcraft_doc::{TabAlign, TabStop}; + let tabbed = "\u{05D0}\t一二三四五」六"; + let mut glyphs = full_width(tabbed); + glyphs[0].level = unicode_bidi::Level::rtl(); + glyphs[1].adv = 2.0; + let tabs = [TabStop { position: 12.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + let all_fit = |_end: usize, _hyphenated: bool| true; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&all_fit); + // 」 (glyph 7) ends 72 pt along, 5 pt past the measure: five gaps close by 1 pt each. + let end = |ty| break_line(tabbed, &glyphs, 0, 67.0, &tabs, 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[], fits); + for ty in [KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutFirst, KinsokuShoriType::PushOutOnly] { + assert_eq!(end(ty), (6, false), "{ty:?}: 五 is pushed out with 」"); + } + let plain = "ax一二三四五」六"; + let mut glyphs = full_width(plain); + glyphs[1].adv = 2.0; + let pushed = + break_line(plain, &glyphs, 0, 67.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushInFirst, false, false, &[], None); + assert_eq!(pushed, (8, false), "without the tab and right-to-left text, 」 is pushed in"); +} + /// Past the breaks a tabbed line measures one by one, it ends at the latest break without a hyphen /// that fits as drawn, whatever the breaks with a hyphen between them measure. #[test] diff --git a/crates/text/src/tests/hang.rs b/crates/text/src/tests/hang.rs index 5ccb79a5ca..ba978147bf 100644 --- a/crates/text/src/tests/hang.rs +++ b/crates/text/src/tests/hang.rs @@ -293,3 +293,38 @@ fn burasagari_counts_the_hung_start() { assert!((quote.origin.x + quote.advance).abs() < EPS, "the quote hangs: {:?}", quote.origin); assert!((comma.origin.x + comma.advance - 100.0).abs() < EPS, "、 inside, on the edge: {:?} {}", comma.origin, comma.advance); } + +/// A right-to-left paragraph draws a burasagari full stop inside its line, so the line breaks where +/// it fits with the stop: in a frame too narrow for the whole line by half the stop, the line ends +/// after the first word and nothing is drawn past the frame. +#[test] +fn right_to_left_burasagari_keeps_the_line_inside_the_frame() { + use vectorcraft_doc::{Burasagari, Kinsoku, ParaDirection}; + const EPS: f64 = 1e-6; + let text = "\u{05D0}\u{05D1} \u{05D2}\u{05D3}\u{3002}"; + let lay = |width: f64| { + let mut t = area(text, 20.0, Rect::new(0.0, 0.0, width, 400.0), Justify::Auto); + t.para.direction = Some(ParaDirection::RightToLeft); + t.para.burasagari = Burasagari::Standard; + t.para.kinsoku = Kinsoku::Hard; + let l = layout(db(), &t); + (t, l) + }; + let (_, wide) = lay(1000.0); + assert_eq!(wide.lines.len(), 1); + let full: f64 = wide.glyphs.iter().map(|g| g.advance).sum(); + let Some(stop) = wide.glyphs.iter().find(|g| g.byte == text.len() - '\u{3002}'.len_utf8()) else { + panic!("。 is set as a glyph of its own"); + }; + let (t, l) = lay(full - stop.advance / 2.0); + assert!(!l.overflow); + assert_eq!(l.lines.len(), 2, "the line ends after the first word"); + for (li, gs) in inked(&t, &l) { + // A right-to-left line's trailing space is set at its left, outside the aligned text. + for g in gs.into_iter().filter(|g| !is_space(ch(&t, g))) { + let c = ch(&t, g); + assert!(g.origin.x >= li.avail.0 - EPS, "{c:?} starts at {} before {}", g.origin.x, li.avail.0); + assert!(g.origin.x + g.advance <= li.avail.1 + EPS, "{c:?} ends at {} past {}", g.origin.x + g.advance, li.avail.1); + } + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2c05c6860a..da4bad7ef8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1484,6 +1484,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Georgian, Hindi, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Finnish, Turkish, Catalan, Dutch and Danish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line; on a vertical path, upright glyphs and tate-chu-yoko blocks face the path's start, and letters on their side are a quarter turn clockwise from them (with Skew, 3D Ribbon and Stair Step only on a path that runs to the right), in one column that Align to Path Baseline or Center centers on the path and that the caret and selection span); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). The Russian catalog (`crates/ui-egui/src/i18n/ru.tsv`) has 1,811 more `@msg` rows, for status and error messages of commands, scripts and file import and export, 3,444 `@msg` rows in all counted on 2026-11-13 (`russian_translates_command_script_and_file_messages` in `crates/ui-egui/src/i18n/tests.rs`). Scores and estimates are unchanged. With Line-end Punctuation Half Width, both composers measure a line with its last character set as narrow as Mojikumi sets it ([the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/use-mojikumi-for-japanese-text-composition.html)): a closing bracket, comma or full stop that fits at half width ends the line, and the character before it no longer goes to the next line with it. A mark that already lost its half to the punctuation after it and a line that ends in a hyphen take nothing more off, and the quarter em between Japanese and Latin text counts only where the line ends there. Kinsoku Shori Type and Burasagari Regular measure lines the same way; right-to-left lines and Mojikumi None break as before. In a frame that is not a rectangle, or with text wrap, a line's first word is measured as it is set (an opening bracket at its start flush, its last character narrowed) when layout looks for a line wide enough for it, and it ends only where the line may break, so kinsoku keeps an opening bracket with the character after it and a No Break run counts whole (`crates/text/src/layout/wrapping_tests.rs`, `crates/text/src/tests/vertical.rs`). Scores and estimates are unchanged. + Burasagari ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)) leaves a right-to-left paragraph's comma or full stop inside its line, which then ends at a break where it fits with the mark, and in a left-to-right line with right-to-left text and a tab it hangs a mark only when the mark is drawn at the line's right end and the rest of the line fits as drawn; otherwise the line ends at the latest break that fits as drawn. Both are VectorCraft's own rules (`crates/text/src/layout/wrapping_tests.rs`, `crates/text/src/tests/hang.rs`). Scores and estimates are unchanged. The Paragraph panel's Mojikumi Set lists the five predefined sets the help page names (None, YakumonoHankaku, GyomatsuYakumonoHankaku, GyomatsuYakumonoZenkaku and YakumonoZenkaku), then Line-end Punctuation Half Width, and `text.setFormat {mojikumi}` takes each of them. YakumonoHankaku sets every bracket, comma and full stop half width and takes a quarter em off each side of a middle dot; GyomatsuYakumonoHankaku keeps punctuation full width inside a line, sets an opening bracket flush at a line's start and a closing mark half width at its end, and takes the quarter em after a middle dot off at a line's end; GyomatsuYakumonoZenkaku keeps punctuation full width and adds a quarter em between Japanese and Latin text; YakumonoZenkaku sets characters as None does. These widths are VectorCraft's own rules, from the help page's one-line description of each set. Both composers and vertical type measure each line as it is set, the sets save with the document and paragraph styles, and a `.ai` save reports the new sets as left out (`crates/text/src/tests/vertical.rs`, `crates/ui-egui/src/panels/paragraph/tests.rs`). Scores and estimates are unchanged. Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`). Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment.