From 0e2815629f48ea165f69f9506635b1de50481134 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 14:07:27 +0000 Subject: [PATCH] refactor(text): split flow and ruby layout phases --- crates/text/src/fontdb/wanted.rs | 2 +- crates/text/src/layout/bidi.rs | 2 +- crates/text/src/layout/breaking.rs | 5 +- crates/text/src/layout/driver.rs | 4 +- crates/text/src/layout/flow.rs | 1006 ++++++++++++++++------------ crates/text/src/layout/on_path.rs | 5 +- crates/text/src/layout/region.rs | 4 +- crates/text/src/layout/ruby.rs | 510 +++++++------- crates/text/src/layout/vertical.rs | 2 +- 9 files changed, 868 insertions(+), 672 deletions(-) diff --git a/crates/text/src/fontdb/wanted.rs b/crates/text/src/fontdb/wanted.rs index d06f129e4..31539737a 100644 --- a/crates/text/src/fontdb/wanted.rs +++ b/crates/text/src/fontdb/wanted.rs @@ -2,7 +2,7 @@ use super::names::norm; #[cfg(not(target_arch = "wasm32"))] use super::scan::{file_face_names_within, is_font_file}; use super::traits::FaceStyle; -use super::*; +use super::{HashMap, Path}; /// A font a document names that isn't available, to look for in font files ([`WantedFonts`]). #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/crates/text/src/layout/bidi.rs b/crates/text/src/layout/bidi.rs index 5512725a6..e4b626324 100644 --- a/crates/text/src/layout/bidi.rs +++ b/crates/text/src/layout/bidi.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{BidiInfo, Level, ParaDirection, Range, SGlyph}; /// The bidirectional resolution (UAX #9) of paragraph `text`, its base direction `direction` or, /// without one, from its first strong character. `None` for the usual paragraph with nothing right diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 6dea3d9c6..0b9251e83 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -1,5 +1,8 @@ use super::region::Region; -use super::*; +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, +}; /// Vertical metrics of a line: (ascent, descent, leading, cap height, x height). #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 2c8f74b0c..33d25f34f 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -3,7 +3,9 @@ use super::on_path::on_path; use super::region::Region; use super::ruby::ruby_layout; use super::vertical::{EM_CENTER, QUARTER_TURN, align_vertically, finish_bounds}; -use super::*; +use super::{ + Affine, BezPath, CharStyle, Ctx, FontDb, LayoutOptions, OtFeatures, Point, Rect, Shape, TextKind, TextLayout, TextObject, VerticalAlign, +}; /// Lay out a text object into text-space glyph outlines (default [`LayoutOptions`]). pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout { diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index b923328f7..7c5c4de35 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -5,7 +5,11 @@ use super::breaking::{ }; use super::region::Region; use super::vertical::{EM_CENTER, align_shift, compress_punctuation, glyph_em, punct_half, space_japanese_and_latin}; -use super::*; +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, +}; /// Paragraphs longer than this (glyphs) are always broken line by line. const MAX_COMPOSE_GLYPHS: usize = 200_000; @@ -130,451 +134,587 @@ pub(super) fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, reg no_room_for: f64::INFINITY, }; let markers = list_markers(t, paras); - 'paras: for (pi, pr) in paras.iter().enumerate() { - let para = t.para_at(pi); + for (pi, pr) in paras.iter().enumerate() { let marker_text = markers.get(pi).and_then(Option::as_deref); - // The leading model is a paragraph attribute: this paragraph's lines (and the space to - // its first line) follow its own. - pen.model = para.leading_model; - let text = cx.text; - let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr)); - let rtl = is_rtl(bidi.as_ref()); - let mut sg = cx.shape_para(pr.clone(), bidi.as_ref()); - // Thai, Lao, Khmer and Myanmar break lines between words, not at spaces. - mark_word_ends(text.get(pr.clone()).unwrap_or_default(), pr.start, &mut sg); - // Japanese composition: consecutive punctuation shares one half-em space. - let mut compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; - // …and a quarter em between Japanese and Latin text. - let mut wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] }; - // Justification…: the glyphs at Desired word and letter spacing and glyph scaling, and how - // far a justified line may move each one from it. - let mut flex = apply_desired(&mut sg, ¶.justification, !cx.vertical); - // Threaded text: a first paragraph that continues from the frame before has its first - // line, first-line indent and list marker there. - let continued = pi == 0 && t.first_para_continues; - // Bullets and numbers: the list marker's glyphs `0..marker` start the paragraph. - // Justification and Optical Margin Alignment leave them as they are. - let marker = if continued { 0 } else { cx.prepend_marker(pr, para, marker_text, rtl, &mut sg) }; - if marker > 0 { - flex.splice(0..0, vec![Flex::default(); marker]); - if !compressed.is_empty() { - compressed.splice(0..0, vec![false; marker]); - } - if !wakan.is_empty() { - wakan.splice(0..0, vec![0.0; marker]); - } + if !flow_paragraph(cx, &mut pen, t, pi, pr.clone(), marker_text, regions) { + break; } - // …and Auto Leading: characters whose leading is Auto take this paragraph's share of their size. - let auto_leading = |st: &CharStyle| st.leading.unwrap_or_else(|| para.justification.auto_leading_for(st.size)); - if para.justification.auto_leading != vectorcraft_doc::JustificationOptions::default().auto_leading { - for g in sg.iter_mut().filter(|g| g.inline.is_none()) { - g.leading = auto_leading(cx.style_at(g.byte)); - } + } +} + +fn flow_paragraph( + cx: &mut Ctx<'_>, + pen: &mut Pen<'_>, + t: &TextObject, + pi: usize, + pr: Range, + marker_text: Option<&str>, + regions: Option<&[Region]>, +) -> bool { + let paragraph = prepare_paragraph(cx, pen, t, pi, pr, marker_text, regions); + run_paragraph_lines(cx, pen, t, paragraph) +} + +struct ParagraphData<'a, 'text> { + para: &'a ParaStyle, + pr: Range, + marker_text: Option<&'a str>, + bidi: Option>, + rtl: bool, + continued: bool, + sg: Vec, + compressed: Vec, + wakan: Vec, + flex: Vec, + marker: usize, + pm: Metrics, + composed: Option>, + zone: f64, + hyphen_limit: usize, + mirror: bool, + regions: Option<&'a [Region]>, +} + +fn prepare_paragraph<'a, 'text>( + cx: &mut Ctx<'text>, + pen: &mut Pen<'_>, + t: &'a TextObject, + pi: usize, + pr: Range, + marker_text: Option<&'a str>, + regions: Option<&'a [Region]>, +) -> ParagraphData<'a, 'text> { + let para = t.para_at(pi); + pen.model = para.leading_model; + let text = cx.text; + let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(&pr)); + let rtl = is_rtl(bidi.as_ref()); + let mut sg = cx.shape_para(pr.clone(), bidi.as_ref()); + mark_word_ends(text.get(pr.clone()).unwrap_or_default(), pr.start, &mut sg); + let mut compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; + let mut wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] }; + let mut flex = apply_desired(&mut sg, ¶.justification, !cx.vertical); + let continued = pi == 0 && t.first_para_continues; + let marker = if continued { 0 } else { cx.prepend_marker(&pr, para, marker_text, rtl, &mut sg) }; + if marker > 0 { + flex.splice(0..0, vec![Flex::default(); marker]); + if !compressed.is_empty() { + compressed.splice(0..0, vec![false; marker]); + } + if !wakan.is_empty() { + wakan.splice(0..0, vec![0.0; marker]); + } + } + let auto_leading = |style: &CharStyle| style.leading.unwrap_or_else(|| para.justification.auto_leading_for(style.size)); + if para.justification.auto_leading != vectorcraft_doc::JustificationOptions::default().auto_leading { + for glyph in sg.iter_mut().filter(|glyph| glyph.inline.is_none()) { + glyph.leading = auto_leading(cx.style_at(glyph.byte)); + } + } + let pm = { + let (asc, desc, _) = style_metrics(cx.db, cx.style_at(pr.start)); + let lead = auto_leading(cx.style_at(pr.start)); + let style = cx.style_at(pr.start); + let (cap, xh) = cap_x_heights(cx.db, style); + let centre = cx.db.style_face(style).map_or(EM_CENTER, |face| face.ideographic_centre()); + Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * style.size * style.v_scale / 100.0 } + }; + if pi > 0 { + pen.pending += para.space_before; + } + let composed = compose_para(cx, &sg, &flex, para, pen, rtl, continued); + let zone = if cx.opts.composer.unwrap_or(para.composer) == Composer::SingleLine { para.hyphenation.zone } else { 0.0 }; + let hyphen_limit = usize::try_from(para.hyphenation.hyphen_limit).unwrap_or(usize::MAX); + let mirror = mirrors_indents(para, rtl, cx.vertical); + ParagraphData { + para, + pr, + marker_text, + bidi, + rtl, + continued, + sg, + compressed, + wakan, + flex, + marker, + pm, + composed, + zone, + hyphen_limit, + mirror, + regions, + } +} + +struct PlacementContext<'a> { + para: &'a ParaStyle, + pr: &'a Range, + sg: &'a mut [SGlyph], + continued: bool, + mirror: bool, + pm: Metrics, + composed: &'a Option>, + hyphen_run: usize, + hyphen_limit: usize, + zone: f64, + index: usize, + para_line: usize, +} + +struct LinePosition { + baseline: f64, + x0: f64, + x1: f64, + end: usize, + hyph: 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, mirror, pm, composed, hyphen_run, hyphen_limit, zone, index: i, para_line } = context; + let n = sg.len(); + let est = if *i < n { Metrics::of(&sg[*i]) } else { *pm }; + let first_line = *para_line == 0 && !*continued; + let (ind_l, ind_r) = line_indents(para, first_line, *mirror); + let word = word_width(sg, *i); + if !cx.vertical + && para.mojikumi == Mojikumi::LineEndHalf + && let Some(glyph) = sg.get_mut(*i) + && glyph.lead <= 0.0 + && let Some(half) = punct_half(glyph, Punct::Opening) + { + glyph.adv -= half; + glyph.lead += half; + } + loop { + let first_in_region = pen.prev.is_none(); + let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else { + cx.out.overflow = cx.text.len() > if *i < n { sg[*i].byte } else { pr.start }; + return None; + }; + 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 m = Metrics::max(&sg[*i..end]).unwrap_or(*pm); + baseline += if same_baseline || (pen.regions.is_none() && first_in_region) { + 0.0 + } else if pen.model == LeadingModel::EmBoxTop { + if first_in_region { m.top.max(pen.fb_min) - est.top.max(pen.fb_min) } else { m.top - est.top } + } else if first_in_region { + m.first_baseline(pen.fb, pen.fb_min) - est.first_baseline(pen.fb, pen.fb_min) + } else { + m.lead - est.lead + }; + if pen.regions.is_some() && baseline + m.desc > pen.bottom() + 0.01 { + pen.ri += 1; + pen.prev = None; + pen.pending = 0.0; + pen.queued.clear(); + continue; } - let pm = { - let (asc, desc, _) = style_metrics(cx.db, cx.style_at(pr.start)); - let lead = auto_leading(cx.style_at(pr.start)); - let st = cx.style_at(pr.start); - let (cap, xh) = cap_x_heights(cx.db, st); - let centre = cx.db.style_face(st).map_or(EM_CENTER, |f| f.ideographic_centre()); - Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * st.size * st.v_scale / 100.0 } + return Some(LinePosition { baseline, x0, x1, end, hyph, m, ind_l, ind_r }); + } +} + +struct LineGeometry { + position: LinePosition, + index: usize, + trimmed: usize, + body: usize, + end_trim: f64, + hyphen: Option, + ax0: f64, + ax1: f64, + width: f64, + render_width: f64, + order: Vec, + drawn: Vec, + opt_l: f64, + opt_r: f64, + held: Vec, + hung: Vec, + last_of_para: bool, +} + +struct LineJustification { + per_space: f64, + per_gap: f64, + per_cjk: f64, + fit: LineFit, + start_x: f64, + after: Vec>, + kashidas: Vec<(usize, f64)>, +} + +fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut paragraph: ParagraphData<'_, '_>) -> bool { + let n = paragraph.sg.len(); + let mut hyphen_run = 0; + let mut line_in_paragraph = 0; + let mut index = 0; + loop { + let mut placement = PlacementContext { + para: paragraph.para, + pr: ¶graph.pr, + sg: &mut paragraph.sg, + continued: paragraph.continued, + mirror: paragraph.mirror, + pm: paragraph.pm, + composed: ¶graph.composed, + hyphen_run, + hyphen_limit: paragraph.hyphen_limit, + zone: paragraph.zone, + index, + para_line: line_in_paragraph, }; - if pi > 0 { - pen.pending += para.space_before; + 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 }; + pen.pending = 0.0; + let geometry = prepare_line_geometry(cx, t, ¶graph, position, index); + let justification = justify_flow_line(cx.vertical, cx.text, ¶graph, &geometry); + emit_flow_line(cx, pen, ¶graph, &geometry, &justification); + line_in_paragraph += 1; + index = geometry.position.end; + if index >= n { + break; + } + } + pen.pending += paragraph.para.space_after; + true +} + +fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData<'_, '_>, position: LinePosition, index: usize) -> LineGeometry { + let para = paragraph.para; + let sg = ¶graph.sg; + let n = sg.len(); + let (ax0, ax1) = + if paragraph.regions.is_some() { (position.x0 + position.ind_l, position.x1 - position.ind_r) } else { (position.x0, position.x1) }; + let width = ax1 - ax0; + let mut trimmed = position.end; + while trimmed > index && sg[trimmed - 1].is_space() { + trimmed -= 1; + } + let hyphen = (position.hyph && position.end > index).then(|| hyphen_glyph(&sg[position.end - 1])); + let end_trim = trimmed.checked_sub(1).filter(|&k| k >= index && para.mojikumi == Mojikumi::LineEndHalf).map_or(0.0, |k| { + let half = if paragraph.compressed.get(k).copied().unwrap_or(false) { None } else { sg.get(k).and_then(|g| punct_half(g, Punct::Closing)) }; + half.unwrap_or(0.0) + paragraph.wakan.get(k).copied().unwrap_or(0.0) + }); + let measured_width = set_width(sg, index, trimmed, ¶.tabs, position.ind_l) + hyphen.as_ref().map_or(0.0, |g| g.adv) - end_trim; + let line_start = sg.get(index).map_or(paragraph.pr.start, |g| g.byte); + let line_end = sg.get(position.end).map_or(paragraph.pr.end, |g| g.byte); + let order = visual_order(paragraph.bidi.as_ref().filter(|_| !cx.vertical), paragraph.pr.start, line_start..line_end, &sg[index..position.end]); + let visible = |stop: usize| -> (Vec, Vec<(&SGlyph, f64)>) { + let indices: Vec = order.iter().map(|&offset| index + offset).filter(|&j| j < stop).collect(); + let glyphs = indices + .iter() + .filter_map(|&j| { + sg.get(j).map(|g| { + ( + g, + if j < paragraph.marker { + 0.0 + } else if j + 1 == trimmed { + g.adv - end_trim + } else { + g.adv + }, + ) + }) + }) + .collect(); + (indices, glyphs) + }; + let share = (t.optical_margin || para.roman_hanging).then_some(if t.optical_margin { optical_share } else { roman_share }); + let room_at_start = || share.map_or(0.0, |share| optical_hangs(&visible(trimmed).1, share).0); + let hang = (paragraph.regions.is_some() && !paragraph.rtl && hyphen.is_none() && trimmed > index + 1) + .then(|| sg.get(trimmed - 1)) + .flatten() + .filter(|g| hangs(g)) + .map(|g| g.adv - end_trim) + .filter(|_| match para.burasagari { + Burasagari::None => false, + Burasagari::Standard => measured_width > width + room_at_start() + EPS, + Burasagari::Forced => true, + }); + let body = if hang.is_some() { trimmed - 1 } else { trimmed }; + let render_width = measured_width - hang.unwrap_or(0.0); + let (drawn, mut visible) = visible(body); + let (opt_l, opt_r, held, hung) = if let Some(share) = share { + if let Some(hyphen) = &hyphen { + visible.push((hyphen, hyphen.adv)); } - let n = sg.len(); - let composed = compose_para(cx, &sg, &flex, para, &pen, rtl, continued); - // Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the - // Hyphen Limit counts the lines in a row that end with a hyphen. - let zone = if cx.opts.composer.unwrap_or(para.composer) == Composer::SingleLine { para.hyphenation.zone } else { 0.0 }; - let hyphen_limit = usize::try_from(para.hyphenation.hyphen_limit).unwrap_or(usize::MAX); - let mut hyphen_run = 0; - let mut li_para = 0; - let mut i = 0; - let mirror = mirrors_indents(para, rtl, cx.vertical); - loop { - let est = if i < n { Metrics::of(&sg[i]) } else { pm }; - let first_line = li_para == 0 && !continued; - let (ind_l, ind_r) = line_indents(para, first_line, mirror); - // Measured before Mojikumi narrows an opening bracket, as the composer measures it. - let word = word_width(&sg, i); - // Mojikumi: an opening bracket starting a line is set flush with the line's start (the - // space before it goes), and the line has that much more room. At a paragraph's start - // that is the first-line indent: JIS X 4051's principle (JLREQ 3.1.5, Figure 71 ①). - if !rtl - && para.mojikumi == Mojikumi::LineEndHalf - && let Some(g) = sg.get_mut(i) - && g.lead <= 0.0 - && let Some(h) = punct_half(g, Punct::Opening) - { - g.adv -= h; - g.lead += h; + let (left, right, left_count, right_count) = + if paragraph.rtl && !cx.vertical { optical_hangs_rtl(&visible, share) } else { optical_hangs(&visible, share) }; + let (right, right_count) = if hang.is_some() { (0.0, 0) } else { (right, right_count) }; + let visible_count = visible.len(); + let punctuation = |k: usize| visible.get(k).is_some_and(|&(g, _)| optical_punct(g)); + let held = drawn + .iter() + .enumerate() + .filter(|&(k, _)| (k < left_count && punctuation(k)) || (right_count > 0 && k + 1 + right_count >= visible_count && punctuation(k + 1))) + .map(|(_, &j)| j) + .collect(); + let hung = drawn + .iter() + .enumerate() + .filter(|&(k, _)| k < left_count || (right_count > 0 && k + right_count >= visible_count)) + .map(|(_, &j)| j) + .collect(); + (left, right, held, hung) + } else { + (0.0, 0.0, Vec::new(), Vec::new()) + }; + let mut held = held; + held.extend(index..paragraph.marker.min(body)); + let last_of_para = position.end >= n; + LineGeometry { + position, + index, + trimmed, + body, + end_trim, + hyphen, + ax0, + ax1, + width, + render_width, + order, + drawn, + opt_l, + opt_r, + held, + hung, + last_of_para, + } +} + +fn drawn_after(index: usize, glyph: usize, after: &[Option]) -> Option { + glyph.checked_sub(index).and_then(|offset| after.get(offset).copied().flatten()) +} + +fn is_cjk_gap(glyph: usize, held: &[usize], after: &[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))) +} + +fn flow_line_flex(glyph: usize, flex: &[Flex], held: &[usize], hung: &[usize]) -> Flex { + if held.contains(&glyph) { + return Flex::default(); + } + let mut value = flex.get(glyph).copied().unwrap_or_default(); + if hung.contains(&glyph) { + value.glyph = (0.0, 0.0); + } + value +} + +fn justify_flow_line(vertical: bool, text: &str, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry) -> LineJustification { + let para = paragraph.para; + let sg = ¶graph.sg; + let (held, hung) = (&line.held, &line.hung); + let room = line.width + line.opt_l + line.opt_r; + let (mut align, mut justify) = match para.justify { + Justify::Auto => (if paragraph.rtl { 2 } else { 0 }, false), + Justify::Left => (0, false), + Justify::Center => (1, false), + Justify::Right => (2, false), + Justify::JustifyLeft => (0, !line.last_of_para), + Justify::JustifyCenter => (1, !line.last_of_para), + Justify::JustifyRight => (2, !line.last_of_para), + Justify::JustifyAll => (0, true), + }; + justify &= paragraph.regions.is_some(); + let spaces = (line.index..line.body).filter(|&j| j >= paragraph.marker && sg[j].is_space()).count(); + let mut after: Vec> = vec![None; line.body.saturating_sub(line.index)]; + for (&a, &b) in line.drawn.iter().zip(line.drawn.iter().skip(1)) { + if let Some(slot) = after.get_mut(a - line.index) { + *slot = Some(b); + } + } + let cjk_gaps = (line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).count(); + let single_word = justify && spaces == 0 && cjk_gaps == 0; + (align, justify) = match (single_word, para.justification.single_word) { + (false, _) | (true, SingleWordJustify::FullJustify) => (align, justify), + (true, SingleWordJustify::AlignLeft) => (0, false), + (true, SingleWordJustify::AlignCenter) => (1, false), + (true, SingleWordJustify::AlignRight) => (2, false), + }; + let kashidas = if justify && !vertical && room > line.render_width + EPS { + kashida::plan(text, sg, line.index..line.body, para.kashida, room - line.render_width) + } else { + Vec::new() + }; + 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 { + if cjk_gaps > 0 && room > width { + per_space = (room - width) / (cjk_gaps + spaces) as f64; + per_cjk = per_space; + } else if spaces > 0 { + let flex: Vec = (line.index..line.body).map(|j| flow_line_flex(j, ¶graph.flex, held, hung)).collect(); + let floor = (line.index..line.body).filter(|&j| j >= paragraph.marker && sg[j].is_space()).map(|j| sg[j].adv).fold(f64::MAX, f64::min); + fit = fit_line(&flex, room - width, spaces, floor); + } else if room > width { + let flex: Vec = (line.index..line.body).map(|j| flow_line_flex(j, ¶graph.flex, held, hung)).collect(); + fit = fit_line(&flex, room - width, 0, 0.0); + let used: f64 = flex.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum(); + let rest = room - width - used; + let gaps = (line.index..line.body) + .filter(|&j| { + !held.contains(&j) && drawn_after(line.index, j, &after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy()) + }) + .count(); + if gaps > 0 && rest > EPS { + per_gap = rest / gaps as f64; } - // Place, break, then settle the baseline on the line's real metrics (moving on to the - // next row/column if it no longer fits). - let (baseline, x0, x1, end, hyph, m) = loop { - let first_in_region = pen.prev.is_none(); - let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else { - cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start }; - break 'paras; - }; - 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(|c| c.get(li_para)) { - Some(&(e, h)) if e > i => (e, h), - _ if i < n => break_line(cx.text, &sg, i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku), - _ => (n, false), - }; - let m = Metrics::max(&sg[i..end]).unwrap_or(pm); - baseline += if same_baseline || (pen.regions.is_none() && first_in_region) { - 0.0 - } else if pen.model == LeadingModel::EmBoxTop { - // The em box top is fixed (the frame's top, or the line above's): the baseline - // hangs from it by this line's tallest em box. - if first_in_region { m.top.max(pen.fb_min) - est.top.max(pen.fb_min) } else { m.top - est.top } - } else if first_in_region { - m.first_baseline(pen.fb, pen.fb_min) - est.first_baseline(pen.fb, pen.fb_min) - } else { - m.lead - est.lead - }; - if pen.regions.is_some() && baseline + m.desc > pen.bottom() + 0.01 { - // Try the next row/column; overflow if there is none. - pen.ri += 1; - pen.prev = None; - pen.pending = 0.0; - pen.queued.clear(); - continue; - } - break (baseline, x0, x1, end, hyph, m); - }; - hyphen_run = if hyph && end > i { hyphen_run + 1 } else { 0 }; - let (ax0, ax1) = if regions.is_some() { (x0 + ind_l, x1 - ind_r) } else { (x0, x1) }; - let width = ax1 - ax0; - pen.pending = 0.0; - let last_of_para = end >= n; - let mut trimmed = end; - while trimmed > i && sg[trimmed - 1].is_space() { - trimmed -= 1; + } + } + let position = &line.position; + let start_x = if justify { + line.ax0 - line.opt_l + } else if paragraph.regions.is_none() { + match align { + 0 => position.ind_l - line.opt_l, + 1 => (position.ind_l - line.opt_l - position.ind_r + line.opt_r - width) * 0.5, + _ => -position.ind_r + line.opt_r - width, + } + } else { + match align { + 0 => line.ax0 - line.opt_l, + 1 => line.ax0 - line.opt_l + (room - width) * 0.5, + _ => line.ax1 + line.opt_r - width, + } + }; + LineJustification { per_space, per_gap, per_cjk, fit, start_x, after, kashidas } +} + +fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, justification: &LineJustification) { + let para = paragraph.para; + let sg = ¶graph.sg; + let index = line.index; + let trimmed = line.trimmed; + let body = line.body; + let order = &line.order; + let start_x = justification.start_x; + let (ax0, ax1) = (line.ax0, line.ax1); + let position = &line.position; + let line_index = cx.out.lines.len(); + let glyph_start = cx.out.glyphs.len(); + let line_em = sg.get(index..position.end).map_or(0.0, |glyphs| glyphs.iter().map(glyph_em).fold(0.0, f64::max)); + let tab_origin = if paragraph.regions.is_some() { position.x0 } else { 0.0 }; + let tab_shift = start_x - if paragraph.regions.is_some() { ax0 } else { position.ind_l }; + let leading_space_width: f64 = + order.iter().take_while(|&&offset| index + offset >= trimmed).filter_map(|&offset| sg.get(index + offset)).map(|glyph| glyph.adv).sum(); + let mut x = start_x - leading_space_width; + let mut x_end = start_x; + let mut marker_glyphs: Option> = None; + let mut marker_tab = 0; + for (order_index, &offset) in order.iter().enumerate() { + let glyph_index = index + offset; + let Some(glyph) = sg.get(glyph_index) else { continue }; + let mut advance = glyph.adv; + if glyph_index + 1 == trimmed { + advance -= line.end_trim; + } + let mut scale = 1.0; + let mut leader = ""; + if glyph.ch == '\t' { + if glyph.len > 0 { + (advance, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[glyph_index + 1..trimmed.max(glyph_index + 1)]); } - let hyphen = (hyph && end > i).then(|| hyphen_glyph(&sg[end - 1])); - // Mojikumi: a closing bracket, comma or full stop ending the line is set half width, and - // no Japanese–Latin space is left at the end of a line. - let end_trim = match trimmed.checked_sub(1).filter(|&k| k >= i && para.mojikumi == Mojikumi::LineEndHalf) { - Some(k) => { - let half = if compressed.get(k).copied().unwrap_or(false) { None } else { sg.get(k).and_then(|g| punct_half(g, Punct::Closing)) }; - half.unwrap_or(0.0) + wakan.get(k).copied().unwrap_or(0.0) - } - None => 0.0, - }; - let w: f64 = set_width(&sg, i, trimmed, ¶.tabs, ind_l) + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim; - let line_start = sg.get(i).map_or(pr.start, |g| g.byte); - let line_end = sg.get(end).map_or(pr.end, |g| g.byte); - let order = visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, line_start..line_end, &sg[i..end]); - // Optical Margin Alignment or Roman Hanging Punctuation: the share of a glyph that hangs. - let share: Option f64> = - (t.optical_margin || para.roman_hanging).then_some(if t.optical_margin { optical_share } else { roman_share }); - // The glyphs before `stop` in visual order, each with the advance it is set with. A list - // marker does not hang: it is counted with no width. - let set = |j: usize, g: &SGlyph| { - if j < marker { - 0.0 - } else if j + 1 == trimmed { - g.adv - end_trim - } else { - g.adv - } - }; - let visible = |stop: usize| -> (Vec, Vec<(&SGlyph, f64)>) { - let at: Vec = order.iter().map(|&offset| i + offset).filter(|&j| j < stop).collect(); - let glyphs = at.iter().filter_map(|&j| sg.get(j).map(|g| (g, set(j, g)))).collect(); - (at, glyphs) - }; - // Burasagari: a comma or full stop ending an area type line hangs outside it (Standard: - // when it doesn't fit, counting what hangs at the line's start as room; Forced: - // always). The rest of the line is aligned and justified without it, and it follows - // the line's last character. - let room_at_start = || share.map_or(0.0, |share| optical_hangs(&visible(trimmed).1, share).0); - let hang = (regions.is_some() && !rtl && hyphen.is_none() && trimmed > i + 1) - .then(|| sg.get(trimmed - 1)) - .flatten() - .filter(|g| hangs(g)) - .map(|g| g.adv - end_trim) - .filter(|_| match para.burasagari { - Burasagari::None => false, - Burasagari::Standard => w > width + room_at_start() + EPS, - Burasagari::Forced => true, - }); - let body = if hang.is_some() { trimmed - 1 } else { trimmed }; - let w = w - hang.unwrap_or(0.0); - // The glyphs before the line's trailing spaces (and its burasagari mark), in the order - // they are drawn. - let (drawn, mut visible) = visible(body); - // Optical Margin Alignment: the glyphs at the ends of a line hang outside its margins - // by `opt_l` and `opt_r`. The line is aligned and justified in a measure that much - // wider at each end. A left-aligned line then hangs only at its left, a right-aligned - // line hangs only at its right, and centered and justified lines hang at both; Auto - // aligns a right-to-left line at its right. A right-to-left line is read from right to - // left ([`optical_hangs_rtl`]). A line that ends in a burasagari mark hangs only at its - // left. Justification adds no space after a punctuation mark hanging at the left, nor - // before one hanging at the right (`held`). A letter that hangs by part of its width - // keeps its gaps. Glyph Scaling leaves the glyphs that hang (`hung`) at their width, so - // they hang exactly by their published share. Roman Hanging Punctuation (a paragraph - // attribute) hangs the same punctuation by the same amounts, and no letters; with - // Optical Margin Alignment on as well, the letters hang too. - let (opt_l, opt_r, held, hung) = if let Some(share) = share { - if let Some(h) = &hyphen { - visible.push((h, h.adv)); - } - let (l, r, left_glyphs, right_glyphs) = - if rtl && !cx.vertical { optical_hangs_rtl(&visible, share) } else { optical_hangs(&visible, share) }; - let (r, right_glyphs) = if hang.is_some() { (0.0, 0) } else { (r, right_glyphs) }; - let n_visible = visible.len(); - let punct = |k: usize| visible.get(k).is_some_and(|&(g, _)| optical_punct(g)); - let held: Vec = drawn - .iter() - .enumerate() - .filter(|&(k, _)| (k < left_glyphs && punct(k)) || (right_glyphs > 0 && k + 1 + right_glyphs >= n_visible && punct(k + 1))) - .map(|(_, &j)| j) - .collect(); - let hung: Vec = drawn - .iter() - .enumerate() - .filter(|&(k, _)| k < left_glyphs || (right_glyphs > 0 && k + right_glyphs >= n_visible)) - .map(|(_, &j)| j) - .collect(); - (l, r, held, hung) + } else if glyph_index < body { + let flex = flow_line_flex(glyph_index, ¶graph.flex, &line.held, &line.hung); + if is_word_space(glyph) { + advance += justification.fit.space(&flex); } else { - (0.0, 0.0, vec![], vec![]) - }; - // Justification adds nothing to the list marker's glyphs. - let mut held = held; - held.extend(i..marker.min(body)); - // What justification may change of glyph `j`: nothing of a `held` glyph, and no glyph - // scaling of a `hung` one. - let line_flex = |j: usize| { - if held.contains(&j) { - return Flex::default(); - } - let mut f = flex.get(j).copied().unwrap_or_default(); - if hung.contains(&j) { - f.glyph = (0.0, 0.0); - } - f - }; - let room = width + opt_l + opt_r; - let (align, justify) = match para.justify { - Justify::Auto => (if rtl { 2 } else { 0 }, false), - Justify::Left => (0, false), - Justify::Center => (1, false), - Justify::Right => (2, false), - Justify::JustifyLeft => (0, !last_of_para), - Justify::JustifyCenter => (1, !last_of_para), - Justify::JustifyRight => (2, !last_of_para), - Justify::JustifyAll => (0, true), - }; - let justify = justify && regions.is_some(); - let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0); - let mut fit = LineFit::default(); - let spaces = (i..body).filter(|&j| j >= marker && sg[j].is_space()).count(); - // A gap that justification adds between two glyphs goes into the advance of the one - // drawn first: `drawn_after(j)` is the glyph drawn right after glyph `j`, which in a - // right-to-left line comes before it in the text. - let mut after: Vec> = vec![None; body.saturating_sub(i)]; - for (&a, &b) in drawn.iter().zip(drawn.iter().skip(1)) { - if let Some(slot) = after.get_mut(a - i) { - *slot = Some(b); - } + advance += justification.fit.letter(&flex) + justification.fit.glyph(&flex); + scale = justification.fit.glyph_scale(&flex); } - let drawn_after = |j: usize| j.checked_sub(i).and_then(|k| after.get(k).copied().flatten()); - // Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the - // gaps next to a CJK character, not inside a Latin word or a tate-chu-yoko block. - let cjk_gap = |j: usize| { - !held.contains(&j) - && drawn_after(j) - .and_then(|k| sg.get(j).zip(sg.get(k))) - .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))) - }; - let cjk_gaps = (i..body).filter(|&j| cjk_gap(j)).count(); - // Single Word Justification: a justified line holding one word (no word space) is - // spread between its letters (Full Justify) or aligned. - let single_word = justify && spaces == 0 && cjk_gaps == 0; - let (align, justify) = match (single_word, para.justification.single_word) { - (false, _) | (true, SingleWordJustify::FullJustify) => (align, justify), - (true, SingleWordJustify::AlignLeft) => (0, false), - (true, SingleWordJustify::AlignCenter) => (1, false), - (true, SingleWordJustify::AlignRight) => (2, false), - }; - // Insert Kashidas: the Arabic words of a horizontal line take its room on kashidas, - // and the word spaces and gaps take what is left. - let kashidas = - if justify && !cx.vertical && room > w + EPS { kashida::plan(cx.text, &sg, i..body, para.kashida, room - w) } else { vec![] }; - let w = w + kashidas.iter().map(|k| k.1).sum::(); - if justify && (room - w).abs() > EPS { - if cjk_gaps > 0 && room > w { - // Spread over the CJK gaps and the word spaces alike. - let per = (room - w) / (cjk_gaps + spaces) as f64; - (per_space, per_cjk) = (per, per); - } else if spaces > 0 { - // Word spacing, letter spacing and glyph scaling within their ranges, then the - // word spaces alone; composed lines may shrink word spaces (never below zero). - let line: Vec = (i..body).map(line_flex).collect(); - let floor = (i..body).filter(|&j| j >= marker && sg[j].is_space()).map(|j| sg[j].adv).fold(f64::MAX, f64::min); - fit = fit_line(&line, room - w, spaces, floor); - } else if room > w { - // One word: its letter spacing and glyph scaling, then the gaps between its glyphs. - let line: Vec = (i..body).map(line_flex).collect(); - fit = fit_line(&line, room - w, 0, 0.0); - let used: f64 = line.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum(); - let rest = room - w - used; - let gaps = (i..body) - .filter(|&j| !held.contains(&j) && drawn_after(j).and_then(|k| sg.get(k)).is_some_and(|next| !next.continues_tcy())) - .count(); - if gaps > 0 && rest > EPS { - per_gap = rest / gaps as f64; - } + if glyph.is_space() { + advance += justification.per_space; + } else if justification.per_cjk != 0.0 { + if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { + advance += justification.per_cjk; } + } 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()) + { + advance += justification.per_gap; } - let start_x = if justify { - ax0 - opt_l - } else if regions.is_none() { - match align { - 0 => ind_l - opt_l, - 1 => (ind_l - opt_l - ind_r + opt_r - w) * 0.5, - _ => -ind_r + opt_r - w, - } - } else { - match align { - 0 => ax0 - opt_l, - 1 => ax0 - opt_l + (room - w) * 0.5, - _ => ax1 + opt_r - w, - } - }; - let li = cx.out.lines.len(); - let glyph_start = cx.out.glyphs.len(); - // Character Alignment lines smaller characters up with the line's largest em box. - let line_em = sg.get(i..end).map_or(0.0, |line| line.iter().map(glyph_em).fold(0.0, f64::max)); - let mut x_end = start_x; - // Tab stops are measured from the frame's left edge (point type: the origin). - let tab_origin = if regions.is_some() { x0 } else { 0.0 }; - // A line with tabs is measured as if it started at the left (see `set_width`), then - // aligned as a whole: its tabs stop where they were measured, however it is aligned. - let tab_shift = start_x - if regions.is_some() { ax0 } else { ind_l }; - // RTL's logical trailing spaces precede the visible content. Keep them outside - // the aligned content extent, just as LTR trailing spaces extend to its right. - let leading_space_width: f64 = - order.iter().take_while(|&&offset| i + offset >= trimmed).filter_map(|&offset| sg.get(i + offset)).map(|g| g.adv).sum(); - let mut x = start_x - leading_space_width; - // 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; - for (oi, &offset) in order.iter().enumerate() { - let j = i + offset; - let Some(g) = sg.get(j) else { continue }; - let mut adv = g.adv; - if j + 1 == trimmed { - adv -= end_trim; - } - // Glyph scaling a justified line adds to the glyph (1: none). - let mut scale = 1.0; - let mut leader = ""; - if g.ch == '\t' { - // The tab after a list marker keeps the width `prepend_marker` gave it. - if g.len > 0 { - (adv, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[j + 1..trimmed.max(j + 1)]); - } - } else if j < body { - let f = line_flex(j); - if is_word_space(g) { - adv += fit.space(&f); - } else { - adv += fit.letter(&f) + fit.glyph(&f); - scale = fit.glyph_scale(&f); - } - if g.is_space() { - adv += per_space; - } else if per_cjk != 0.0 { - if cjk_gap(j) { - adv += per_cjk; - } - } else if !held.contains(&j) && drawn_after(j).and_then(|k| sg.get(k)).is_some_and(|next| !next.continues_tcy()) { - // Between glyphs, never inside a tate-chu-yoko block (one cell). - adv += per_gap; - } - } - // The kashida before a cluster in the text is drawn at the cluster's right, between - // it and the letter before it, and the cluster's last glyph advances over it: the - // caret, selection and hit testing take it as part of the cluster. - let kashida = kashidas - .binary_search_by_key(&g.byte, |k| k.0) - .ok() - .and_then(|at| kashidas.get(at)) - .filter(|_| order.get(oi + 1).and_then(|&o| sg.get(i + o)).is_none_or(|next| next.byte != g.byte)) - .map(|k| k.1); - adv += kashida.unwrap_or(0.0); - let y = baseline - align_shift(g, cx.style_at(g.byte).char_align, line_em, cx.vertical); - let place = if scale != 1.0 { Affine::translate((x, y)) * Affine::scale_non_uniform(scale, 1.0) } else { Affine::translate((x, y)) }; - if j < marker { - let at = cx.out.glyphs.len(); - marker_glyphs = Some(marker_glyphs.map_or(at..at + 1, |r: Range| r.start.min(at)..r.end.max(at + 1))); - if j + 1 == marker { - marker_tab = at; - } - } - cx.emit(g, place, Point::new(x, y), 0.0, adv, li); - if let Some(out) = cx.out.glyphs.last_mut() { - out.scale = flex.get(j).map_or(1.0, |f| f.scale) * 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. - let dots = leader_outline(cx.db, g, leader, tab_origin, x, x + adv, y); - if let Some(tab) = cx.out.glyphs.last_mut() { - tab.outline.extend(dots); - } - } - if let Some(len) = kashida { - cx.emit_kashida(g, para.kashida, len, Point::new(x + adv - len, y), li); - } - x += adv; - if j < trimmed { - x_end = x; - } + } + let kashida = justification + .kashidas + .binary_search_by_key(&glyph.byte, |item| item.0) + .ok() + .and_then(|at| justification.kashidas.get(at)) + .filter(|_| order.get(order_index + 1).and_then(|&offset| sg.get(index + offset)).is_none_or(|next| next.byte != glyph.byte)) + .map(|item| item.1); + advance += kashida.unwrap_or(0.0); + let y = line.position.baseline - align_shift(glyph, cx.style_at(glyph.byte).char_align, line_em, cx.vertical); + let transform = if scale != 1.0 { Affine::translate((x, y)) * Affine::scale_non_uniform(scale, 1.0) } else { Affine::translate((x, y)) }; + if glyph_index < paragraph.marker { + let at = cx.out.glyphs.len(); + marker_glyphs = Some(marker_glyphs.map_or(at..at + 1, |range: Range| range.start.min(at)..range.end.max(at + 1))); + if glyph_index + 1 == paragraph.marker { + marker_tab = at; } - if let (Some(glyphs), Some(text)) = (marker_glyphs, marker_text) { - cx.out.markers.push(ListMarker { glyphs, tab: marker_tab, text: text.to_string() }); + } + cx.emit(glyph, transform, Point::new(x, y), 0.0, advance, line_index); + if let Some(out) = cx.out.glyphs.last_mut() { + out.scale = paragraph.flex.get(glyph_index).map_or(1.0, |flex| flex.scale) * scale; + } + if !leader.is_empty() { + let dots = leader_outline(cx.db, glyph, leader, tab_origin, x, x + advance, y); + if let Some(tab) = cx.out.glyphs.last_mut() { + tab.outline.extend(dots); } - if let Some(h) = &hyphen { - // The hyphen follows the last non-space glyph. - let hx = x_end; - cx.emit(h, Affine::translate((hx, baseline)), Point::new(hx, baseline), 0.0, h.adv, li); - x_end = hx + h.adv; - } - cx.out.lines.push(LineInfo { - rtl: rtl && !cx.vertical, - baseline, - x0: start_x, - x1: x_end, - ascent: m.asc, - descent: m.desc, - first_baseline: if pen.model == LeadingModel::EmBoxTop { m.top.max(pen.fb_min) } else { m.first_baseline(pen.fb, pen.fb_min) }, - start: if i < n { sg[i].byte } else { pr.start }, - end: if last_of_para { pr.end } else { sg[end].byte }, - glyph_start, - glyph_end: cx.out.glyphs.len(), - avail: if regions.is_some() { (ax0, ax1) } else { (start_x, x_end) }, - region: if regions.is_some() { pen.ri } else { 0 }, - }); - pen.settled(baseline, m); - // Further spans of this line band share the settled baseline. - for q in &mut pen.queued { - q.0 = baseline; - } - li_para += 1; - i = end; - if i >= n { - break; - } + } + if let Some(length) = kashida { + cx.emit_kashida(glyph, para.kashida, length, Point::new(x + advance - length, y), line_index); + } + x += advance; + if glyph_index < trimmed { + x_end = x; } - pen.pending += para.space_after; + } + if let (Some(glyphs), Some(text)) = (marker_glyphs, paragraph.marker_text) { + cx.out.markers.push(ListMarker { glyphs, tab: marker_tab, text: text.to_string() }); + } + if let Some(hyphen) = &line.hyphen { + let x = x_end; + cx.emit(hyphen, Affine::translate((x, position.baseline)), Point::new(x, position.baseline), 0.0, hyphen.adv, line_index); + x_end = x + hyphen.adv; + } + cx.out.lines.push(LineInfo { + rtl: paragraph.rtl && !cx.vertical, + baseline: position.baseline, + x0: start_x, + x1: x_end, + ascent: position.m.asc, + descent: position.m.desc, + first_baseline: if pen.model == LeadingModel::EmBoxTop { + position.m.top.max(pen.fb_min) + } else { + position.m.first_baseline(pen.fb, pen.fb_min) + }, + start: if index < sg.len() { sg[index].byte } else { paragraph.pr.start }, + end: if line.last_of_para { paragraph.pr.end } else { sg[position.end].byte }, + glyph_start, + glyph_end: cx.out.glyphs.len(), + avail: if paragraph.regions.is_some() { (ax0, ax1) } else { (start_x, x_end) }, + region: if paragraph.regions.is_some() { pen.ri } else { 0 }, + }); + pen.settled(position.baseline, position.m); + for queued in &mut pen.queued { + queued.0 = position.baseline; } } diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index 7bdeba66d..ddf4e56f3 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -2,7 +2,10 @@ use super::bidi::{is_rtl, para_bidi_with, visual_order}; use super::breaking::{Metrics, optical_hangs, optical_hangs_rtl, optical_share, roman_share}; use super::flow::list_markers; use super::region::in_range; -use super::*; +use super::{ + Affine, ArcPath, BezPath, Ctx, GlyphBend, Justify, LineInfo, ListMarker, ParamCurve, PathAlign, PathData, PathEffect, PathEl, Point, Range, + SGlyph, Shape, TextObject, Vec2, style_metrics, +}; /// The distance along type on a path each glyph takes: its advance measured `spacing` points above /// the path (Type on a Path Options › Spacing), so glyphs close up round the outside of a curve and diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index d3f953359..e0423e1dd 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -1,4 +1,6 @@ -use super::*; +#[cfg(test)] +use super::PathData; +use super::{BezPath, LayoutOptions, PathEl, Point, Rect, Shape}; /// A flattened text wrap shape: (polygons, offset, invert). pub(super) type FlatWrap = (Vec>, f64, bool); diff --git a/crates/text/src/layout/ruby.rs b/crates/text/src/layout/ruby.rs index c09786bbc..00d03c90a 100644 --- a/crates/text/src/layout/ruby.rs +++ b/crates/text/src/layout/ruby.rs @@ -1,5 +1,5 @@ use super::driver::layout; -use super::*; +use super::{Affine, CharStyle, Ctx, EPS, Point, PositionedGlyph, RubyAlign, RubyPosition, Shape, TextKind, TextObject}; /// Ruby (furigana): lays each reading out over its base text in line space, grows the line's /// ascent/descent so the ruby fits, and moves the lines after it on in the block direction. @@ -11,270 +11,316 @@ use super::*; /// A reading wider than its base hangs over the neighbours (`overhang`), or is squeezed to the /// base's width. Type on a path is left out (it has no above/below). pub(super) fn ruby_layout(cx: &mut Ctx<'_>, t: &TextObject) { - use std::collections::BTreeMap; - - let db = cx.db; - let text_len = cx.text.len(); - let n_lines = cx.out.lines.len(); - if n_lines == 0 || cx.out.glyphs.is_empty() { + let line_count = cx.out.lines.len(); + if line_count == 0 || cx.out.glyphs.is_empty() { return; } - let base_baselines: Vec = cx.out.lines.iter().map(|l| l.baseline).collect(); - let old_ascent: Vec = cx.out.lines.iter().map(|l| l.ascent).collect(); - let old_descent: Vec = cx.out.lines.iter().map(|l| l.descent).collect(); - // One piece of base text on one line, with the part of the reading that belongs to it. - struct Piece { - line: usize, - x0: f64, - x1: f64, - run: usize, - byte: usize, - reading: String, - style: CharStyle, - position: RubyPosition, - align: RubyAlign, - base_size: f64, - overhang: bool, + let base_baselines = cx.out.lines.iter().map(|line| line.baseline).collect::>(); + let old_ascent = cx.out.lines.iter().map(|line| line.ascent).collect::>(); + let old_descent = cx.out.lines.iter().map(|line| line.descent).collect::>(); + let pieces = ruby_pieces(cx, t); + let placed = place_ruby_pieces(cx, &pieces, &base_baselines, &old_descent); + if !placed.is_empty() { + grow_ruby_lines(cx, t, placed, &old_ascent, &old_descent); } - let mut pieces: Vec = Vec::new(); - // The glyphs by their byte, to find a span's without a scan of them all for each span. +} + +struct RubyPiece { + line: usize, + x0: f64, + x1: f64, + run: usize, + byte: usize, + reading: String, + style: CharStyle, + position: RubyPosition, + align: RubyAlign, + base_size: f64, + overhang: bool, +} + +struct PlacedRuby { + line: usize, + glyphs: Vec, + ascent: f64, + descent: f64, +} + +fn ruby_pieces(cx: &Ctx<'_>, t: &TextObject) -> Vec { + use std::collections::BTreeMap; + + let text_len = cx.text.len(); let byte_of = |i: usize| cx.out.glyphs.get(i).map_or(0, |g| g.byte); let mut by_byte: Vec = (0..cx.out.glyphs.len()).collect(); by_byte.sort_by_key(|&i| byte_of(i)); let longest = cx.out.glyphs.iter().map(|g| g.len.max(1)).max().unwrap_or(1); + let mut pieces = Vec::new(); for span in &t.ruby { let (start, end) = (span.start.min(text_len), span.end.min(text_len)); if start >= end || span.text.trim().is_empty() { continue; } - // The base glyphs of the span, by line; a glyph's cluster is its byte range (`len`, or - // one byte for generated glyphs). A list marker is not part of the base. let mut by_line: BTreeMap> = BTreeMap::new(); let hi = by_byte.partition_point(|&i| byte_of(i) < end); let lo = by_byte.partition_point(|&i| byte_of(i).saturating_add(longest) <= start).min(hi); let mut hits: Vec = by_byte.get(lo..hi).unwrap_or_default().to_vec(); - // In glyph order, as the scan found them. hits.sort_unstable(); for i in hits { - let Some(g) = cx.out.glyphs.get(i) else { continue }; - let b = g.byte.saturating_add(g.len.max(1)); - if g.byte < end && b > start && cx.out.marker_of(i).is_none() { - by_line.entry(g.line).or_default().push(i); + let Some(glyph) = cx.out.glyphs.get(i) else { continue }; + let glyph_end = glyph.byte.saturating_add(glyph.len.max(1)); + if glyph.byte < end && glyph_end > start && cx.out.marker_of(i).is_none() { + by_line.entry(glyph.line).or_default().push(i); } } let total: usize = by_line.values().map(Vec::len).sum(); if total == 0 { continue; } - let reading: Vec = span.text.trim().chars().collect(); - let ratio = span.safe_size_percent() / 100.0; - // Split the reading by each line's share of the base glyphs: cumulative fractions keep - // every character once and in order. - let mut taken = 0; - let mut done = 0; - let lines: Vec = by_line.keys().copied().collect(); - for line in &lines { - let count = by_line.get(line).map_or(0, Vec::len); - done += count; - let upto = if reading.is_empty() { 0 } else { (reading.len() * done / total).min(reading.len()) }; - let part: String = reading.get(taken.min(reading.len())..upto.min(reading.len())).unwrap_or_default().iter().collect(); - taken = upto; - if part.is_empty() { - continue; - } - let glyphs = by_line.get(line).map(Vec::as_slice).unwrap_or_default(); - let mut x0 = f64::INFINITY; - let mut x1 = f64::NEG_INFINITY; - let mut base_size: f64 = 0.0; - let mut run = 0; - let mut byte = start; - for (j, &gi) in glyphs.iter().enumerate() { - let Some(g) = cx.out.glyphs.get(gi) else { continue }; - x0 = x0.min(g.origin.x); - x1 = x1.max(g.origin.x + g.advance); - base_size = base_size.max(cx.style_at(g.byte).size); - if j == 0 { - run = g.run; - byte = g.byte; - } - } - if !(x0.is_finite() && x1.is_finite()) || base_size <= 0.0 { - continue; - } - let mut style = cx.style_at(byte).clone(); - style.size = base_size * ratio; - style.leading = None; - style.baseline_shift = 0.0; - style.tracking = 0.0; - style.kerning = None; - pieces.push(Piece { - line: *line, - x0, - x1, - run, - byte, - reading: part, - style, - position: span.position, - align: span.align, - base_size, - overhang: span.overhang, - }); + collect_ruby_pieces_for_span(cx, span, start, total, &by_line, &mut pieces); + } + pieces +} + +fn collect_ruby_pieces_for_span( + cx: &Ctx<'_>, + span: &vectorcraft_doc::text::RubySpan, + start: usize, + total: usize, + by_line: &std::collections::BTreeMap>, + pieces: &mut Vec, +) { + let reading: Vec = span.text.trim().chars().collect(); + let ratio = span.safe_size_percent() / 100.0; + let mut taken = 0; + let mut done = 0; + for (&line, glyph_indices) in by_line { + done += glyph_indices.len(); + let upto = if reading.is_empty() { 0 } else { (reading.len() * done / total).min(reading.len()) }; + let part: String = reading.get(taken.min(reading.len())..upto.min(reading.len())).unwrap_or_default().iter().collect(); + taken = upto; + if part.is_empty() { + continue; + } + if let Some(piece) = ruby_piece_for_line(cx, line, start, &part, ratio, glyph_indices, span) { + pieces.push(piece); } } - // Phase A: shape each reading, place it relative to the (unshifted) baseline, and note how - // far above/below the baseline its ink reaches. - let mut placed: Vec<(usize, Vec, f64, f64)> = Vec::new(); // (line, glyphs, ascent, descent) - for piece in &pieces { - let Some(baseline) = base_baselines.get(piece.line).copied() else { continue }; - let mini = TextObject::point(Point::ZERO, &piece.reading, piece.style.clone()); - let lay = layout(db, &mini); - let Some(first_line) = lay.lines.first() else { continue }; - let mini_base = first_line.baseline; - let mini_asc = first_line.ascent; - let mini_desc = first_line.descent; - let base_w = (piece.x1 - piece.x0).max(0.0); - // Above: the reading's bottom sits on the base's em box top. Below: its top sits on the - // base's em box bottom (the line's descent below the baseline). - let ruby_y = if piece.position == RubyPosition::Above { - baseline - piece.base_size - mini_desc - } else { - baseline + old_descent.get(piece.line).copied().unwrap_or(0.0) + mini_asc - }; - let dest = |width: f64| match piece.align { - RubyAlign::Center => piece.x0 + (base_w - width) * 0.5, - RubyAlign::Start => piece.x0, - RubyAlign::End => piece.x1 - width, - }; - let mut glyphs: Vec = Vec::new(); - if cx.vertical { - // Each reading character in its own cell along the column, upright once the lines turn. - let cell = (mini_asc + mini_desc).max(piece.style.size * 0.5); - let mut clusters: Vec> = Vec::new(); - for g in &lay.glyphs { - match clusters.last_mut() { - Some(c) if c.last().is_some_and(|p| p.byte == g.byte && p.len == g.len) => c.push(g), - _ => clusters.push(vec![g]), - } - } - let mut ruby_w = clusters.len() as f64 * cell; - let mut squeeze = 1.0; - if !piece.overhang && ruby_w > base_w + EPS && ruby_w > EPS { - squeeze = base_w / ruby_w; - ruby_w = base_w; - } - let x = dest(ruby_w); - for (ci, cluster) in clusters.iter().enumerate() { - let anchor = Point::new(x + (ci as f64 + 0.5) * cell, ruby_y); - let mut box_: Option = None; - for g in cluster { - if g.outline.elements().is_empty() { - continue; - } - let b = g.outline.bounding_box(); - box_ = Some(box_.map_or(b, |a| a.union(b))); - } - let centre = box_.map_or(anchor, |b| Point::new((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5)); - let mut m = - Affine::rotate_about(-std::f64::consts::FRAC_PI_2, anchor) * Affine::translate((anchor.x - centre.x, anchor.y - centre.y)); - if squeeze != 1.0 { - m = Affine::translate((x, 0.0)) * Affine::scale_non_uniform(squeeze, 1.0) * Affine::translate((-x, 0.0)) * m; - } - for g in cluster { - glyphs.push(ruby_glyph(g, m, piece.run, piece.byte, piece.line)); - } - } - } else { - // The reading set along the line: the line's extent, centred or aligned to the base. - let mut ruby_w = (first_line.x1 - first_line.x0).max(0.0); - let mut squeeze = 1.0; - if !piece.overhang && ruby_w > base_w + EPS && ruby_w > EPS { - squeeze = base_w / ruby_w; - ruby_w = base_w; - } - let x = dest(ruby_w); - let mut m = Affine::translate((x - first_line.x0, ruby_y - mini_base)); - if squeeze != 1.0 { - m = Affine::translate((x, 0.0)) * Affine::scale_non_uniform(squeeze, 1.0) * Affine::translate((-x, 0.0)) * m; - } - for g in &lay.glyphs { - glyphs.push(ruby_glyph(g, m, piece.run, piece.byte, piece.line)); - } - } - // How far the reading reaches above and below the base line's baseline (its ink). - let mut ascent: f64 = 0.0; - let mut descent: f64 = 0.0; - for g in &glyphs { - if g.outline.elements().is_empty() { - continue; - } - let b = g.outline.bounding_box(); - ascent = ascent.max(baseline - b.y0); - descent = descent.max(b.y1 - baseline); - } - if !glyphs.is_empty() { - placed.push((piece.line, glyphs, ascent, descent)); +} + +fn ruby_piece_for_line( + cx: &Ctx<'_>, + line: usize, + start: usize, + reading: &str, + ratio: f64, + glyph_indices: &[usize], + span: &vectorcraft_doc::text::RubySpan, +) -> Option { + let mut x0 = f64::INFINITY; + let mut x1 = f64::NEG_INFINITY; + let mut base_size: f64 = 0.0; + let mut run = 0; + let mut byte = start; + for (index, &glyph_index) in glyph_indices.iter().enumerate() { + let Some(glyph) = cx.out.glyphs.get(glyph_index) else { continue }; + x0 = x0.min(glyph.origin.x); + x1 = x1.max(glyph.origin.x + glyph.advance); + base_size = base_size.max(cx.style_at(glyph.byte).size); + if index == 0 { + run = glyph.run; + byte = glyph.byte; } } - if placed.is_empty() { - return; + if !(x0.is_finite() && x1.is_finite()) || base_size <= 0.0 { + return None; } - // Phase B: grow each line's box to fit its ruby, then move the lines on after it by what they - // need in the block direction (the first line too for area type, so the ruby stays inside the - // frame; point type's first baseline is its anchor and stays). - let mut growth = vec![0.0_f64; n_lines]; - for (line, _, ascent, descent) in &placed { - if let (Some(a), Some(d)) = (old_ascent.get(*line), old_descent.get(*line)) { - let grown = (a.max(*ascent) + d.max(*descent)) - (a + d); - if grown > 0.0 { - let g = growth.get_mut(*line); - if let Some(slot) = g { - *slot = slot.max(grown); - } - } + let mut style = cx.style_at(byte).clone(); + style.size = base_size * ratio; + style.leading = None; + style.baseline_shift = 0.0; + style.tracking = 0.0; + style.kerning = None; + Some(RubyPiece { + line, + x0, + x1, + run, + byte, + reading: reading.to_string(), + style, + position: span.position, + align: span.align, + base_size, + overhang: span.overhang, + }) +} + +fn place_ruby_pieces(cx: &Ctx<'_>, pieces: &[RubyPiece], baselines: &[f64], old_descent: &[f64]) -> Vec { + pieces.iter().filter_map(|piece| place_ruby_piece(cx, piece, baselines, old_descent)).collect() +} + +fn place_ruby_piece(cx: &Ctx<'_>, piece: &RubyPiece, baselines: &[f64], old_descent: &[f64]) -> Option { + let baseline = baselines.get(piece.line).copied()?; + let mini = TextObject::point(Point::ZERO, &piece.reading, piece.style.clone()); + let layout = layout(cx.db, &mini); + let first_line = layout.lines.first()?; + let base_width = (piece.x1 - piece.x0).max(0.0); + let ruby_y = ruby_baseline(piece, baseline, first_line.ascent, first_line.descent, old_descent); + let glyphs = if cx.vertical { + place_vertical_ruby(piece, &layout.glyphs, first_line.ascent, first_line.descent, base_width, ruby_y) + } else { + place_horizontal_ruby(piece, &layout.glyphs, first_line, base_width, ruby_y) + }; + let (ascent, descent) = ruby_ink_extents(&glyphs, baseline); + (!glyphs.is_empty()).then_some(PlacedRuby { line: piece.line, glyphs, ascent, descent }) +} + +fn ruby_baseline(piece: &RubyPiece, baseline: f64, reading_ascent: f64, reading_descent: f64, old_descent: &[f64]) -> f64 { + if piece.position == RubyPosition::Above { + baseline - piece.base_size - reading_descent + } else { + baseline + old_descent.get(piece.line).copied().unwrap_or(0.0) + reading_ascent + } +} + +fn ruby_destination(piece: &RubyPiece, base_width: f64, reading_width: f64) -> f64 { + match piece.align { + RubyAlign::Center => piece.x0 + (base_width - reading_width) * 0.5, + RubyAlign::Start => piece.x0, + RubyAlign::End => piece.x1 - reading_width, + } +} + +fn place_vertical_ruby(piece: &RubyPiece, reading: &[PositionedGlyph], asc: f64, desc: f64, base_width: f64, ruby_y: f64) -> Vec { + let cell = (asc + desc).max(piece.style.size * 0.5); + let clusters = ruby_clusters(reading); + let (ruby_width, squeeze) = ruby_width(clusters.len() as f64 * cell, base_width, piece.overhang); + let x = ruby_destination(piece, base_width, ruby_width); + let mut glyphs = Vec::new(); + for (index, cluster) in clusters.iter().enumerate() { + let anchor = Point::new(x + (index as f64 + 0.5) * cell, ruby_y); + let centre = ruby_cluster_center(cluster, anchor); + let transform = squeeze_transform( + Affine::rotate_about(-std::f64::consts::FRAC_PI_2, anchor) * Affine::translate((anchor.x - centre.x, anchor.y - centre.y)), + x, + squeeze, + ); + for glyph in cluster { + glyphs.push(ruby_glyph(glyph, transform, piece.run, piece.byte, piece.line)); + } + } + glyphs +} + +fn ruby_clusters(glyphs: &[PositionedGlyph]) -> Vec> { + let mut clusters: Vec> = Vec::new(); + for glyph in glyphs { + match clusters.last_mut() { + Some(cluster) if cluster.last().is_some_and(|previous| previous.byte == glyph.byte && previous.len == glyph.len) => cluster.push(glyph), + _ => clusters.push(vec![glyph]), + } + } + clusters +} + +fn ruby_cluster_center(cluster: &[&PositionedGlyph], anchor: Point) -> Point { + let bounds = cluster + .iter() + .filter(|glyph| !glyph.outline.elements().is_empty()) + .map(|glyph| glyph.outline.bounding_box()) + .reduce(|left, right| left.union(right)); + bounds.map_or(anchor, |bounds| Point::new((bounds.x0 + bounds.x1) * 0.5, (bounds.y0 + bounds.y1) * 0.5)) +} + +fn place_horizontal_ruby( + piece: &RubyPiece, + reading: &[PositionedGlyph], + line: &super::LineInfo, + base_width: f64, + ruby_y: f64, +) -> Vec { + let reading_width = (line.x1 - line.x0).max(0.0); + let (ruby_width, squeeze) = ruby_width(reading_width, base_width, piece.overhang); + let x = ruby_destination(piece, base_width, ruby_width); + let transform = squeeze_transform(Affine::translate((x - line.x0, ruby_y - line.baseline)), x, squeeze); + reading.iter().map(|glyph| ruby_glyph(glyph, transform, piece.run, piece.byte, piece.line)).collect() +} + +fn ruby_width(reading_width: f64, base_width: f64, overhang: bool) -> (f64, f64) { + if !overhang && reading_width > base_width + EPS && reading_width > EPS { (base_width, base_width / reading_width) } else { (reading_width, 1.0) } +} + +fn squeeze_transform(transform: Affine, x: f64, squeeze: f64) -> Affine { + if squeeze == 1.0 { + transform + } else { + Affine::translate((x, 0.0)) * Affine::scale_non_uniform(squeeze, 1.0) * Affine::translate((-x, 0.0)) * transform + } +} + +fn ruby_ink_extents(glyphs: &[PositionedGlyph], baseline: f64) -> (f64, f64) { + let mut ascent: f64 = 0.0; + let mut descent: f64 = 0.0; + for glyph in glyphs.iter().filter(|glyph| !glyph.outline.elements().is_empty()) { + let bounds = glyph.outline.bounding_box(); + ascent = ascent.max(baseline - bounds.y0); + descent = descent.max(bounds.y1 - baseline); + } + (ascent, descent) +} + +fn grow_ruby_lines(cx: &mut Ctx<'_>, t: &TextObject, placed: Vec, old_ascent: &[f64], old_descent: &[f64]) { + let line_count = cx.out.lines.len(); + let mut growth = vec![0.0_f64; line_count]; + for ruby in &placed { + if let (Some(asc), Some(desc), Some(slot)) = (old_ascent.get(ruby.line), old_descent.get(ruby.line), growth.get_mut(ruby.line)) { + let grown = (asc.max(ruby.ascent) + desc.max(ruby.descent)) - (asc + desc); + *slot = slot.max(grown.max(0.0)); } } - // A line's ruby grows above it: the line moves on in the block direction by its own growth, - // so the line before it keeps its room (for area type the first line moves too, keeping the - // ruby inside the frame; point type's first baseline is its anchor and stays). let area = matches!(t.kind, TextKind::Area { .. }); - let mut shift = vec![0.0_f64; n_lines]; - let mut cum = 0.0; - for i in 0..n_lines { - if area || i > 0 { - cum += growth.get(i).copied().unwrap_or(0.0); - if let Some(s) = shift.get_mut(i) { - *s = cum; + let mut shift = vec![0.0_f64; line_count]; + let mut cumulative = 0.0; + for (index, amount) in growth.iter().copied().enumerate() { + if area || index > 0 { + cumulative += amount; + shift[index] = cumulative; + } + } + for ruby in &placed { + if let Some(line) = cx.out.lines.get_mut(ruby.line) { + line.ascent = line.ascent.max(ruby.ascent); + line.descent = line.descent.max(ruby.descent); + } + } + for (index, line) in cx.out.lines.iter_mut().enumerate() { + line.baseline += shift.get(index).copied().unwrap_or(0.0); + } + shift_base_glyphs(cx, &shift); + append_ruby_glyphs(cx, placed, &shift); +} + +fn shift_base_glyphs(cx: &mut Ctx<'_>, shift: &[f64]) { + for glyph in &mut cx.out.glyphs { + let amount = shift.get(glyph.line).copied().unwrap_or(0.0); + if amount != 0.0 { + glyph.origin.y += amount; + glyph.outline.apply_affine(Affine::translate((0.0, amount))); + glyph.xf = Affine::translate((0.0, amount)) * glyph.xf; + } + } +} + +fn append_ruby_glyphs(cx: &mut Ctx<'_>, placed: Vec, shift: &[f64]) { + for ruby in placed { + let amount = shift.get(ruby.line).copied().unwrap_or(0.0); + for mut glyph in ruby.glyphs { + if amount != 0.0 { + glyph.origin.y += amount; + glyph.outline.apply_affine(Affine::translate((0.0, amount))); } - } - } - // Grow the lines' boxes to fit their ruby, then move them on in the block direction. - for (line, _, ascent, descent) in &placed { - if let Some(l) = cx.out.lines.get_mut(*line) { - l.ascent = l.ascent.max(*ascent); - l.descent = l.descent.max(*descent); - } - } - for (i, line) in cx.out.lines.iter_mut().enumerate() { - line.baseline += shift.get(i).copied().unwrap_or(0.0); - } - for g in &mut cx.out.glyphs { - let s = shift.get(g.line).copied().unwrap_or(0.0); - if s != 0.0 { - g.origin.y += s; - g.outline.apply_affine(Affine::translate((0.0, s))); - g.xf = Affine::translate((0.0, s)) * g.xf; - } - } - for (line, glyphs, _, _) in placed { - let s = shift.get(line).copied().unwrap_or(0.0); - for mut g in glyphs { - if s != 0.0 { - g.origin.y += s; - g.outline.apply_affine(Affine::translate((0.0, s))); - } - g.line = line; - cx.out.ruby.push(g); + glyph.line = ruby.line; + cx.out.ruby.push(glyph); } } } diff --git a/crates/text/src/layout/vertical.rs b/crates/text/src/layout/vertical.rs index a8ffad796..4ce9f03d8 100644 --- a/crates/text/src/layout/vertical.rs +++ b/crates/text/src/layout/vertical.rs @@ -1,6 +1,6 @@ use super::flow::flow; use super::region::Region; -use super::*; +use super::{Affine, Ctx, Punct, Range, Rect, SGlyph, Shape, Tcy, TextLayout, TextObject, VerticalAlign, is_cjk, punct}; /// System Layout on or Fractional Widths off: each character that takes space advances by a whole /// number of points, at least one, so small type does not collapse onto itself. A zero advance (a