From 36ab39a7f4d8ea754236b78a3d43fedcfd64ae9e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 22:12:06 -0800 Subject: [PATCH] Type: Warichu blocks keep their places, clicks and line height in mixed text - Insert Kashidas skips the characters of a warichu block. - Blocks whose characters the bidi order draws in turn keep where each block starts. - A click on a block line goes to that line's characters. - Digits marked Warichu in vertical type on a path stay across the column. - A block of several fonts gives its line the tallest ascent and the deepest descent among them. - Warichu Settings on characters not marked Warichu do not split shaping. - Warichu Settings leaves a value outside its range as it is unless it is edited. --- crates/text/src/kashida.rs | 7 +- crates/text/src/layout.rs | 5 +- crates/text/src/layout/flow.rs | 23 ++--- crates/text/src/layout/ruby.rs | 1 + crates/text/src/layout/vertical.rs | 2 +- crates/text/src/layout/warichu.rs | 5 +- crates/text/src/lib.rs | 47 +++++++++- crates/text/src/shape.rs | 50 ++++++----- crates/text/src/test_fonts.rs | 30 +++++++ crates/text/src/tests/bidi.rs | 1 + crates/text/src/tests/harakat.rs | 1 + crates/text/src/tests/warichu.rs | 123 +++++++++++++++++++++++++- crates/ui-egui/src/dialogs/warichu.rs | 30 +++++-- 13 files changed, 274 insertions(+), 51 deletions(-) diff --git a/crates/text/src/kashida.rs b/crates/text/src/kashida.rs index 90f4efe277..c36eacbf2d 100644 --- a/crates/text/src/kashida.rs +++ b/crates/text/src/kashida.rs @@ -104,8 +104,8 @@ pub(crate) const MAX_TATWEELS: f64 = 64.0; /// The kashidas of the glyphs `line` (indices into `sg`, a paragraph's glyphs in logical order; /// `text` is the plain text) of a justified line with `room` points to fill: for each Arabic /// word with joined letters, at its last pair of them, the byte offset of the cluster after the kashida and -/// the kashida's length, in text order. Words end at spaces, inline graphics, and characters other than letters -/// and marks (punctuation, digits, symbols). Every kashida takes an equal share of `room`, up to +/// the kashida's length, in text order. Words end at spaces, inline graphics, warichu blocks, and characters other +/// than letters and marks (punctuation, digits, symbols). Every kashida takes an equal share of `room`, up to /// `kind`'s longest in ems of the letter after it (as wide as its horizontal scale draws it); a /// Stylistic kashida is a whole number of tatweels, at most [`MAX_TATWEELS`], and a word whose /// share is shorter than one tatweel gets none. A line of words without joined letters, or whose @@ -124,7 +124,8 @@ pub(crate) fn plan(text: &str, sg: &[SGlyph], line: Range, kind: Kashida, let end = (j + 1..line.end).find(|&e| sg.get(e).is_none_or(|n| n.byte != g.byte)).unwrap_or(line.end); let first = text.get(g.byte..g.byte + g.len).and_then(|s| s.chars().next()); let not_a_letter = first.is_some_and(|c| !c.is_alphabetic() && joining(c) != Joining::Transparent); - if g.is_space() || g.inline.is_some() || not_a_letter { + // A warichu block is set as one piece at the length its lines set: it ends a word too. + if g.is_space() || g.inline.is_some() || g.wari.is_some() || not_a_letter { picks.extend(word.take()); prev = None; } else { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 48ded7e205..a690e5e5c5 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -244,7 +244,9 @@ impl Ctx<'_> { if self.vertical { tate_chu_yoko(v, self.system_layout || !self.fractional_widths, |g| { let st = style(g); - TcyStyle { size: st.size, marked: st.tate_chu_yoko, up: st.tcy_up_down, right: st.tcy_left_right, warichu: st.warichu } + // Type on a path sets warichu characters as they are, its digits included. + let warichu = st.warichu && !self.on_path; + TcyStyle { size: st.size, marked: st.tate_chu_yoko, up: st.tcy_up_down, right: st.tcy_left_right, warichu } }); // An upright glyph advances down the column by its vertical advance (the font's vertical // metrics; without them at least one em, as CJK fonts' is), centred across it: a narrow @@ -619,6 +621,7 @@ impl Ctx<'_> { }, width, width_start, + warichu: g.wari.map(|w| w.start), }); } diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index ba6a4c9766..b429be8ef7 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -962,14 +962,15 @@ fn tab_glyph<'p>( /// Emit `glyph` of a line placed by `transform` at `origin` with `advance` along the line, its /// Insert Aki before it `aki` and the glyph scaling a justified line adds to it `scale`. A warichu /// glyph is drawn at its place in its block instead: the block starts where the first of its glyphs -/// drawn on the line is placed (`block` holds the byte naming that block and where it starts), and -/// the glyph sits on the baseline of its block line, measured from where Character Alignment sets -/// the block on the line (`origin`), with its own advance for the caret, selection and hit testing. +/// drawn on the line is placed (`blocks` holds the byte naming each block of the line and where it +/// starts, kept when the bidi order draws another block's glyphs between two of its own), and the +/// glyph sits on the baseline of its block line, measured from where Character Alignment sets the +/// block on the line (`origin`), with its own advance for the caret, selection and hit testing. fn emit_line_glyph( cx: &mut Ctx<'_>, glyph: &SGlyph, (transform, origin, advance): (Affine, Point, f64), - block: &mut Option<(usize, f64)>, + blocks: &mut Vec<(usize, f64)>, line_index: usize, (aki, scale): (f64, f64), ) { @@ -983,10 +984,10 @@ fn emit_line_glyph( } return; }; - let start = match *block { - Some((byte, at)) if byte == w.start => at, - _ => { - *block = Some((w.start, origin.x)); + let start = match blocks.iter().find(|(byte, _)| *byte == w.start) { + Some(&(_, at)) => at, + None => { + blocks.push((w.start, origin.x)); origin.x } }; @@ -1025,8 +1026,8 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData // The glyphs drawn for the list marker, and the index of its tab's glyph, when the line starts the paragraph. let mut marker_glyphs: Option> = None; let mut marker_tab = 0; - // The warichu block being drawn: the byte that names it and where it starts on the line. - let mut wari_block: Option<(usize, f64)> = None; + // The warichu blocks drawn on the line: the byte that names each and where it starts. + let mut wari_blocks: Vec<(usize, f64)> = Vec::new(); for (order_index, &offset) in order.iter().enumerate() { let glyph_index = index + offset; let Some(glyph) = sg.get(glyph_index) else { continue }; @@ -1081,7 +1082,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData } } let glyph_scale = paragraph.flex.get(glyph_index).map_or(1.0, |flex| flex.scale) * scale; - emit_line_glyph(cx, glyph, (transform, Point::new(x, y), advance), &mut wari_block, line_index, (aki, glyph_scale)); + emit_line_glyph(cx, glyph, (transform, Point::new(x, y), advance), &mut wari_blocks, line_index, (aki, glyph_scale)); if !leader.is_empty() { // The leader belongs to the tab's glyph: the gap stays one character for the // caret, selection and hit testing. diff --git a/crates/text/src/layout/ruby.rs b/crates/text/src/layout/ruby.rs index 34cb71f09a..9bf97a4adc 100644 --- a/crates/text/src/layout/ruby.rs +++ b/crates/text/src/layout/ruby.rs @@ -377,5 +377,6 @@ fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usi carets: Vec::new(), width: 0.0, width_start: 0.0, + warichu: None, } } diff --git a/crates/text/src/layout/vertical.rs b/crates/text/src/layout/vertical.rs index d87d445de4..bae883cacd 100644 --- a/crates/text/src/layout/vertical.rs +++ b/crates/text/src/layout/vertical.rs @@ -75,7 +75,7 @@ pub(super) fn tate_chu_yoko(g: &mut [SGlyph], whole: bool, style: impl Fn(&SGlyp /// What [`tate_chu_yoko`] reads from a glyph's character style: its size, whether it is marked /// Tate-chu-yoko, its Tate-chu-yoko Settings Up/Down and Left/Right in points, and whether it is -/// marked Warichu. +/// set as warichu (marked Warichu, and not on a path). pub(super) struct TcyStyle { pub size: f64, pub marked: bool, diff --git a/crates/text/src/layout/warichu.rs b/crates/text/src/layout/warichu.rs index c03bf5b633..dd816599c6 100644 --- a/crates/text/src/layout/warichu.rs +++ b/crates/text/src/layout/warichu.rs @@ -182,7 +182,10 @@ fn place_block(g: &mut [SGlyph], s: &WariStyle, (vertical, whole): (bool, bool)) let Some(first) = g.first() else { return }; let start = first.byte; let ic = first.face.ideographic_centre(); - let (ascent, descent) = (first.ascent, first.descent); + // The block's tallest ascent and deepest descent: its glyphs may come from several faces (runs + // in other fonts, the classes of a composite font, a fallback face). + let ascent = g.iter().map(|gl| gl.ascent).fold(first.ascent, f64::max); + let descent = g.iter().map(|gl| gl.descent).fold(first.descent, f64::max); let clusters = clusters(g); // Each glyph's advance without its Insert Aki. let own = |gl: &SGlyph| gl.adv - gl.aki; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 6586f3857a..3f5de9a309 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -180,6 +180,10 @@ pub struct PositionedGlyph { /// path stands with its top toward the path's start, and its cell starts at its top: 0.12 em /// before the pen without the font's vertical metrics. pub width_start: f64, + /// A glyph of a warichu block: the byte offset of the block's first character, which names the + /// block. The block's lines share its line of text, so hit testing takes the block line nearest + /// the point before the nearest caret stop along it. `None` for any other glyph. + pub warichu: Option, } /// How a glyph on a path is bent along it: a point of its pen space (x along its advance from its @@ -631,11 +635,18 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize { }) else { return 0; }; - byte_in_line(layout, li, p.x) + byte_near(layout, li, p.x, Some(p.y)) } /// Byte offset nearest to `x` on line `li`. pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize { + byte_near(layout, li, x, None) +} + +/// Byte offset nearest to `x` on line `li`; with `y` (text space, as `x`), a warichu block's caret +/// stops count only on the block line whose baseline is nearest `y`, since the block's lines share +/// one line of text. +fn byte_near(layout: &TextLayout, li: usize, x: f64, y: Option) -> usize { let Some(line) = layout.lines.get(li) else { return 0 }; let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; // A cluster drawn with several adjacent glyphs (a Devanagari pre-base matra and its consonant, @@ -653,11 +664,39 @@ pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize { _ => clusters.push((left, right, g)), } } + // Each warichu block's distance from `y` to the nearest baseline of its lines. + let mut rows: Vec<(usize, f64)> = Vec::new(); + if let Some(y) = y { + for g in glyphs { + let Some(block) = g.warichu else { continue }; + let d = (layout.logical_point(g.origin).y - y).abs(); + match rows.iter_mut().find(|(b, _)| *b == block) { + Some((_, best)) => *best = best.min(d), + None => rows.push((block, d)), + } + } + } + let on_row = |g: &PositionedGlyph| match (g.warichu, y) { + (Some(block), Some(y)) => { + let d = (layout.logical_point(g.origin).y - y).abs(); + rows.iter().find(|(b, _)| *b == block).is_none_or(|(_, best)| d <= best + 1e-9) + } + _ => true, + }; + // A stop of the block line taken wins over a stop of the text around the block at the same + // place: at the block's start the text before it ends where both of its lines start. let nearest = clusters .iter() - .flat_map(|&(left, right, g)| g.stops_along(right - left).map(move |(b, d)| (if g.rtl { right - d } else { left + d }, b))) - .min_by(|a, b| (a.0 - x).abs().total_cmp(&(b.0 - x).abs())); - nearest.map_or(line.start, |(_, byte)| byte) + .filter(|(_, _, g)| on_row(g)) + .flat_map(|&(left, right, g)| { + let row = y.is_some() && g.warichu.is_some(); + g.stops_along(right - left).map(move |(b, d)| (if g.rtl { right - d } else { left + d }, b, row)) + }) + .min_by(|a, b| { + let (da, db) = ((a.0 - x).abs(), (b.0 - x).abs()); + if a.2 != b.2 && (da - db).abs() <= 1e-9 { b.2.cmp(&a.2) } else { da.total_cmp(&db) } + }); + nearest.map_or(line.start, |(_, byte, _)| byte) } /// Caret byte at the end of line `li`: a soft-wrapped line ends before its trailing space so the diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index d4039926dd..ff79ce9a4c 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -971,7 +971,7 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte: /// Horizontal type does not read Standard Vertical Roman Alignment or the tate-chu-yoko offsets. /// The space before a line's first character ([`space_before`]) and Insert Aki ([`aki`]) come /// from the cluster's first character, so they do not split a ligature or a letter from its marks -/// either. +/// either. Warichu Settings count only on characters marked Warichu ([`warichu_alike`]). fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { // Exhaustive, like `shapes_alike`: compare borrowed fields without copying feature lists. let CharStyle { @@ -1016,11 +1016,12 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { tcy_up_down, tcy_left_right, standard_vertical_roman, - warichu, - warichu_lines, - warichu_gap, - warichu_scale, - warichu_align, + // Compared by `warichu_alike`. + warichu: _, + warichu_lines: _, + warichu_gap: _, + warichu_scale: _, + warichu_align: _, digits, font_height_reference, no_break, @@ -1068,11 +1069,7 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { && kashidas == &b.kashidas && proportional_metrics == &b.proportional_metrics && tate_chu_yoko == &b.tate_chu_yoko - && warichu == &b.warichu - && warichu_lines == &b.warichu_lines - && warichu_gap == &b.warichu_gap - && warichu_scale == &b.warichu_scale - && warichu_align == &b.warichu_align + && warichu_alike(a, b) && digits == &b.digits && font_height_reference == &b.font_height_reference && no_break == &b.no_break @@ -1080,6 +1077,18 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { && glyph_alternate == &b.glyph_alternate } +/// Do characters in styles `a` and `b` set warichu alike: neither marked Warichu, whatever their +/// Warichu Settings (which then take no part in setting them), or both marked with the same +/// Warichu Settings? +pub(crate) fn warichu_alike(a: &CharStyle, b: &CharStyle) -> bool { + a.warichu == b.warichu + && (!a.warichu + || (a.warichu_lines == b.warichu_lines + && a.warichu_gap == b.warichu_gap + && a.warichu_scale == b.warichu_scale + && a.warichu_align == b.warichu_align)) +} + /// Do text runs in styles `a` and `b` shape alike, so that they are shaped as one piece of text? /// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the /// character style's name, leading, baseline shift, rotation, Character Alignment, Character @@ -1135,12 +1144,13 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { tcy_left_right: _, standard_vertical_roman: _, // A warichu block is shaped apart from the text around it and from a block with other - // settings: its characters are set at their own size and stacked by its settings. - warichu, - warichu_lines, - warichu_gap, - warichu_scale, - warichu_align, + // settings: its characters are set at their own size and stacked by its settings + // ([`warichu_alike`]). + warichu: _, + warichu_lines: _, + warichu_gap: _, + warichu_scale: _, + warichu_align: _, digits, font_height_reference, no_break, @@ -1148,11 +1158,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { glyph_alternate, } = a; *font_family == b.font_family - && *warichu == b.warichu - && *warichu_lines == b.warichu_lines - && *warichu_gap == b.warichu_gap - && *warichu_scale == b.warichu_scale - && *warichu_align == b.warichu_align + && warichu_alike(a, b) && *font_style == b.font_style && *font_version == b.font_version && *font_axes == b.font_axes diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 2d766ae571..b580991070 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -702,6 +702,36 @@ pub fn lam_alef_font(feature: &[u8; 4]) -> Option> { Some(builder.build()) } +/// The family of [`tatweel_font`]. +pub const TATWEEL_FAMILY: &str = "Tatweeltest Sans"; + +/// A test-only Arabic cmap over bundled Source Sans 3, without Arabic shaping: beh uses b, teh t, +/// dal d, the tatweel the hyphen and the space the space, so that a justified line takes kashidas +/// without an OS font. `None` when it can't be built. +pub fn tatweel_font() -> Option> { + use write_fonts::tables::cmap::Cmap; + + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let charmap = font.charmap(); + let mapping = [(' ', ' '), ('\u{0628}', 'b'), ('\u{062A}', 't'), ('\u{062F}', 'd'), ('\u{0640}', '-')]; + let mappings = mapping.into_iter().map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32())))); + let cmap = Cmap::from_mappings(mappings.collect::>>()?).ok()?; + let full = format!("{TATWEEL_FAMILY} Regular"); + let postscript = format!("{}-Regular", TATWEEL_FAMILY.replace(' ', "")); + let name = name_table(&[(1, TATWEEL_FAMILY), (2, "Regular"), (4, &full), (6, &postscript)])?; + let mut builder = FontBuilder::new(); + builder.add_table(&cmap).ok()?; + builder.add_raw(Tag::new(b"name"), name); + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`alternates_font`] with an `aalt` feature, and without one. pub fn alternates_family(aalt: bool) -> &'static str { if aalt { "Alttest Sans" } else { "Alttest Sans Noaalt" } diff --git a/crates/text/src/tests/bidi.rs b/crates/text/src/tests/bidi.rs index 69f9bdb668..5ce4d5cef9 100644 --- a/crates/text/src/tests/bidi.rs +++ b/crates/text/src/tests/bidi.rs @@ -168,6 +168,7 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout carets: Vec::new(), width: advance, width_start: 0.0, + warichu: None, }); x += advance; } diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs index b50b39da83..e7fc7c470c 100644 --- a/crates/text/src/tests/harakat.rs +++ b/crates/text/src/tests/harakat.rs @@ -609,6 +609,7 @@ fn duplicate_authored_glyphs_keep_occurrence_order() { carets: Vec::new(), width: 0.0, width_start: 0.0, + warichu: None, }; let before = TextLayout { glyphs: vec![glyph(0), glyph(1)], ..TextLayout::default() }; assert_eq!(assert_kashida_ownership(&text, &before, &before, &red(), "duplicate glyphs", false), 0); diff --git a/crates/text/src/tests/warichu.rs b/crates/text/src/tests/warichu.rs index c86f843c21..8b07485242 100644 --- a/crates/text/src/tests/warichu.rs +++ b/crates/text/src/tests/warichu.rs @@ -1,7 +1,7 @@ //! Warichu (Character panel menu › Warichu and Warichu Settings): marked characters set smaller //! and stacked on lines inside their line. Bundled fonts: in vertical type `§` stands upright like //! CJK. -use super::{area, db, point, style}; +use super::{area, db, point, serif, style}; use crate::*; use kurbo::Shape; use vectorcraft_doc::{CharStyle, Justify, TextKind, TextRun, WarichuAlign}; @@ -412,3 +412,124 @@ fn shrink_text_to_fit_scales_the_warichu_line_gap() { let l = layout(db(), &s); assert!((l.glyphs[4].origin.y - l.glyphs[2].origin.y - (5.0 + 5.0)).abs() < 1e-6); } + +/// Two blocks whose characters the bidi order draws in turn (Latin, then Hebrew in the first; +/// Hebrew, then Latin in the second) each keep where they start: both lines of a block start there. +#[test] +fn blocks_the_bidi_order_interleaves_keep_their_places() { + let mut t = with_block("", marked(20.0)); + t.runs = vec![ + TextRun::new("ab", style(20.0)), + TextRun::new("cא", marked(20.0)), + TextRun::new("בd", CharStyle { warichu_lines: 3, ..marked(20.0) }), + TextRun::new("z", style(20.0)), + ]; + let l = layout(db(), &t); + let at = |byte: usize| l.glyphs.iter().find(|g| g.byte == byte).unwrap().origin; + let (c, alef, bet, d, z) = (at(2), at(3), at(5), at(7), at(8)); + assert!(alef.y > c.y && d.y > bet.y, "two lines each: {c:?} {alef:?} {bet:?} {d:?}"); + assert!((alef.x - c.x).abs() < 1e-9, "the first block's lines start together: {c:?} {alef:?}"); + assert!((d.x - bet.x).abs() < 1e-9, "the second block's lines start together: {bet:?} {d:?}"); + assert!(c.x < bet.x && bet.x < z.x, "{c:?} {bet:?} {z:?}"); +} + +/// A click on a block's lower line goes to that line's characters, not to the character of the +/// upper line at the same place along the block. +#[test] +fn a_click_on_a_block_line_goes_to_its_own_characters() { + let l = layout(db(), &with_block("cdef", marked(20.0))); + let (c, d, e, f) = (&l.glyphs[2], &l.glyphs[3], &l.glyphs[4], &l.glyphs[5]); + let near = |g: &PositionedGlyph, share: f64| Point::new(g.origin.x + g.advance * share, g.origin.y - 2.0); + assert_eq!(hit_byte(&l, near(e, 0.25)), 4); + assert_eq!(hit_byte(&l, near(f, 0.75)), 6); + assert_eq!(hit_byte(&l, near(c, 0.25)), 2); + assert_eq!(hit_byte(&l, near(d, 0.75)), 4); + // Without a point across the line, the nearest stop along it, as before. + assert_eq!(byte_in_line(&l, 0, l.glyphs[0].origin.x), 0); +} + +/// Digits marked Warichu in vertical type on a path stand across the column as digits without the +/// mark do: type on a path does not stack warichu. +#[test] +fn vertical_type_on_a_path_keeps_its_digits_across_the_column() { + let on_path = |st: CharStyle| { + let mut t = point("", style(20.0)); + t.xf = Affine::IDENTITY; + t.vertical = true; + t.runs = vec![TextRun::new("12", st)]; + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (0.0, 400.0)).to_path(0.1)), start: 0.0, end: None }; + let l = layout(db(), &t); + (ink(&l, 0), ink(&l, 1)) + }; + assert_eq!(on_path(marked(20.0)), on_path(style(20.0))); +} + +/// A block whose characters come from fonts of different heights gives its line the tallest ascent +/// and the deepest descent among them, whichever font comes first. +#[test] +fn a_block_of_several_fonts_keeps_the_tallest_of_them() { + let fonts = ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"]; + let st = |family: &str, warichu: bool| CharStyle { font_family: family.into(), warichu, ..style(20.0) }; + let line = |runs: Vec| { + let mut t = point("", style(20.0)); + t.xf = Affine::IDENTITY; + t.runs = runs; + let l = layout(db(), &t); + (l.lines[0].ascent, l.lines[0].descent) + }; + let plain: Vec<(f64, f64)> = fonts.iter().map(|f| line(vec![TextRun::new("x", st(f, false))])).collect(); + // The two fonts whose ascents differ most. + let mut pair = (0, 0); + for i in 0..fonts.len() { + for j in 0..fonts.len() { + if plain[i].0 - plain[j].0 > plain[pair.0].0 - plain[pair.1].0 { + pair = (i, j); + } + } + } + let (tall, short) = (fonts[pair.0], fonts[pair.1]); + assert!(plain[pair.0].0 - plain[pair.1].0 > 0.1, "the bundled fonts differ in height: {plain:?}"); + let first_short = line(vec![TextRun::new("c", st(short, true)), TextRun::new("d", st(tall, true))]); + let first_tall = line(vec![TextRun::new("c", st(tall, true)), TextRun::new("d", st(short, true))]); + assert!((first_short.0 - first_tall.0).abs() < 1e-6 && (first_short.1 - first_tall.1).abs() < 1e-6, "{first_short:?} {first_tall:?}"); + let deepest = plain[pair.0].1.max(plain[pair.1].1); + assert!(first_short.0 >= plain[pair.0].0 - 1e-6 && first_short.1 >= deepest - 1e-6, "{first_short:?} {plain:?}"); +} + +/// Warichu Settings on characters not marked Warichu leave their shaping alone: "fi" keeps its +/// ligature when only the f has other settings. +#[test] +fn warichu_settings_without_the_mark_keep_a_ligature() { + let fi = |f: CharStyle| { + let mut t = point("", serif(20.0)); + t.runs = vec![TextRun::new("f", f), TextRun::new("i", serif(20.0))]; + layout(db(), &t) + }; + let glyphs = |l: &TextLayout| l.glyphs.iter().map(|g| (g.gid, g.len)).collect::>(); + let plain = fi(serif(20.0)); + assert!(plain.glyphs.iter().any(|g| g.len == 2), "the bundled serif joins f and i: {:?}", glyphs(&plain)); + let settings = CharStyle { warichu_lines: 3, warichu_gap: 2.0, warichu_scale: 70.0, warichu_align: WarichuAlign::Center, ..serif(20.0) }; + assert_eq!(glyphs(&fi(settings)), glyphs(&plain)); +} + +/// Insert Kashidas leaves a warichu block alone: a justified line lengthens its other Arabic words +/// with kashidas, and the block keeps the length its lines set. +#[test] +fn kashidas_leave_a_warichu_block_alone() { + use crate::test_fonts::{TATWEEL_FAMILY, tatweel_font}; + use vectorcraft_doc::text::Kashida; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(tatweel_font().expect("the test font builds")); + let face = db.face(TATWEEL_FAMILY, "Regular").expect("the test face"); + let arabic = |warichu: bool| CharStyle { font_family: TATWEEL_FAMILY.into(), warichu, ..style(20.0) }; + let (before, block) = ("تبد تبد ", "بتب"); + let mut t = area("", arabic(false), Rect::new(0.0, 0.0, 300.0, 400.0), Justify::JustifyAll); + t.para.kashida = Kashida::Medium; + t.runs = vec![TextRun::new(before, arabic(false)), TextRun::new(block, arabic(true)), TextRun::new(" تبد", arabic(false))]; + let l = layout(&db, &t); + let tatweel = face.glyph_for('\u{0640}'); + let kashidas: Vec = l.glyphs.iter().filter(|g| g.len == 0 && g.gid == tatweel).map(|g| g.byte).collect(); + assert!(!kashidas.is_empty(), "the words around the block take kashidas"); + let inside = before.len()..before.len() + block.len(); + assert!(kashidas.iter().all(|b| !inside.contains(b)), "{kashidas:?} {inside:?}"); +} diff --git a/crates/ui-egui/src/dialogs/warichu.rs b/crates/ui-egui/src/dialogs/warichu.rs index f6283512c9..156d10e596 100644 --- a/crates/ui-egui/src/dialogs/warichu.rs +++ b/crates/ui-egui/src/dialogs/warichu.rs @@ -127,12 +127,15 @@ fn number(d: &Dialog, key: &str, name: &str) -> Result, String> { /// `text.setFormat` parameters: the fields that differ from the values the dialog opened with. /// Lines is rounded to a whole number and kept within 1..6, Line Gap is in points (a length typed /// with its unit, "1 mm", is converted) and kept within 0..100, and Scale is kept within 10..100. -/// A blank field is left alone; a field that is not a number is an error. +/// A blank field is left alone; a field that is not a number is an error. A number is compared +/// with the value the dialog opened with before it is rounded or kept within its range, so OK +/// leaves a value outside the range (Line Gap on a scaled object) as it is when it was not edited. fn params(d: &Dialog) -> Result, String> { let initial = d.fields.get(INITIAL).and_then(Value::as_object); let unchanged = |key: &str, v: &Value| initial.and_then(|m| m.get(key)) == Some(v); + let opened_with = |key: &str, v: f64| initial.and_then(|m| m.get(key)).and_then(Value::as_f64) == Some(v); let mut p = Map::new(); - if let Some(n) = number(d, "warichuLines", "Lines")? { + if let Some(n) = number(d, "warichuLines", "Lines")?.filter(|&n| !opened_with("warichuLines", n)) { let n = n.round().clamp(f64::from(*WARICHU_LINES.start()), f64::from(*WARICHU_LINES.end())); // Within 1..6 after the clamp, so the cast keeps the value. let v = json!(n as u8); @@ -147,14 +150,14 @@ fn params(d: &Dialog) -> Result, String> { Some(Value::String(s)) => Some(vectorcraft_doc::Unit::Points.parse(s).filter(|v| v.is_finite()).ok_or("Line Gap must be a number")?), Some(_) => return Err("Line Gap must be a number".into()), }; - if let Some(v) = gap.map(|v| json!(v.clamp(0.0, MAX_WARICHU_GAP))) - && initial.and_then(|m| m.get("warichuGap")).and_then(Value::as_f64) != v.as_f64() + if let Some(v) = gap.filter(|&v| !opened_with("warichuGap", v)).map(|v| v.clamp(0.0, MAX_WARICHU_GAP)) + && !opened_with("warichuGap", v) { - p.insert("warichuGap".into(), v); + p.insert("warichuGap".into(), json!(v)); } - if let Some(n) = number(d, "warichuScale", "Scale")? { + if let Some(n) = number(d, "warichuScale", "Scale")?.filter(|&n| !opened_with("warichuScale", n)) { let v = n.clamp(*WARICHU_SCALE.start(), *WARICHU_SCALE.end()); - if initial.and_then(|m| m.get("warichuScale")).and_then(Value::as_f64) != Some(v) { + if !opened_with("warichuScale", v) { p.insert("warichuScale".into(), json!(v)); } } @@ -241,6 +244,19 @@ mod tests { assert!(app.ui.dialog.is_none()); } + /// A value it opened with outside its range (Line Gap of 200 pt on an object scaled by 2) is + /// left alone when OK is pressed without editing it; an edited one is kept within the range. + #[test] + fn warichu_settings_dialog_leaves_an_unedited_value_outside_its_range() { + let opened = json!({"warichuLines": 9, "warichuGap": 200.0, "warichuScale": 5.0}); + let mut fields = opened.clone(); + fields[INITIAL] = opened; + let mut d = Dialog::new(KIND, fields); + assert_eq!(params(&d), Ok(Map::new())); + d.fields.insert("warichuGap".into(), json!(150.0)); + assert_eq!(params(&d).unwrap().get("warichuGap"), Some(&json!(MAX_WARICHU_GAP))); + } + /// Characters that differ open a blank field; OK leaves that value alone and sets the others. #[test] fn warichu_settings_dialog_leaves_mixed_values_blank() {