From 3b934911f154d29448fef52e496a4ae3b90f92f8 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Fri, 2 Oct 2026 16:37:03 +0200 Subject: [PATCH] bugfixes after w3c samples --- render-wasm/src/render/svg/mod.rs | 31 ++ render-wasm/src/render/svg/tests.rs | 38 ++ render-wasm/src/render/text.rs | 11 +- render-wasm/src/shapes/japanese.rs | 34 ++ render-wasm/src/shapes/text.rs | 26 +- render-wasm/src/shapes/text_japanese.rs | 131 ++++++- render-wasm/src/shapes/text_vertical/cells.rs | 327 +++++++++++++++--- render-wasm/src/shapes/text_vertical/flow.rs | 134 ++++--- .../src/shapes/text_vertical/layout.rs | 310 +++++++++++++++-- render-wasm/src/shapes/text_vertical/paint.rs | 24 +- .../src/shapes/text_vertical/positions.rs | 109 +++++- 11 files changed, 1021 insertions(+), 154 deletions(-) diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index ae4fdd3c08..9e67ea97e8 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -1,6 +1,7 @@ use skia_safe::{self as skia}; use std::borrow::Cow; +use std::cell::Cell; use std::collections::HashSet; use crate::error::Result; @@ -53,6 +54,34 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect } } +thread_local! { + /// Set while an SVG document is being written. + static WRITING_SVG: Cell = const { Cell::new(false) }; +} + +/// True while an SVG export draws. `SkSVGDevice` rebuilds `` from glyph +/// IDs through the font's cmap, which loses vertical alternates and maps +/// shared glyphs to the wrong code point, so vertical text draws outlines. +pub(crate) fn writing_svg() -> bool { + WRITING_SVG.with(Cell::get) +} + +/// Marks the SVG export as running until dropped. +struct WritingSvg; + +impl WritingSvg { + fn start() -> Self { + WRITING_SVG.with(|writing| writing.set(true)); + Self + } +} + +impl Drop for WritingSvg { + fn drop(&mut self) { + WRITING_SVG.with(|writing| writing.set(false)); + } +} + /// Renders a shape tree to an SVG document and returns the raw SVG bytes. /// /// Dedicated vector-SVG render path. Leaf content (paths, fills, …) is emitted @@ -98,7 +127,9 @@ pub(crate) fn render_tree_to_svg( let page_h = bounds.height() * scale; let rect = skia::Rect::from_xywh(0., 0., page_w, page_h); + let writing = WritingSvg::start(); let (defs, body) = render_body(shared, id, tree, scale, rect, -bounds.left(), -bounds.top())?; + drop(writing); let mut aliases = HashSet::new(); collect_font_aliases(tree, id, &mut aliases); diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 2f5e5ff8e6..f117c9d710 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -3656,3 +3656,41 @@ fn raster_export_of_child_paints_inherited_group_fill() { assert_eq!(inherited_png, raster_png(&own, uid(2))); assert_ne!(inherited_png, raster_png(&bare, uid(2))); } + +#[test] +fn exports_vertical_text_as_glyph_outlines() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_text( + &mut pool, + id, + (0.0, 0.0, 240.0, 560.0), + "HOLA", + 200.0, + skia::Color::from_rgb(0, 63, 255), + ); + let shape = pool.get_mut(&id).expect("text shape"); + if let crate::shapes::Type::Text(content) = &mut shape.shape_type { + for paragraph in content.paragraphs_mut() { + paragraph.set_writing_mode(crate::shapes::WritingMode::VerticalRl); + } + } + + let svg = render(&pool, id); + assert!( + !svg.contains(": {svg}" + ); + assert!( + svg.contains(" bool { matches!(self, Self::ClosingBracket | Self::FullStop | Self::Comma) } + + /// Aki (in em) a full-width font builds into the glyph before its ink. + pub const fn embedded_leading_aki_em(self) -> f32 { + match self { + Self::OpeningBracket => 0.5, + Self::MiddleDot => 0.25, + _ => 0.0, + } + } + + /// Aki (in em) a full-width font builds into the glyph after its ink. + pub const fn embedded_trailing_aki_em(self) -> f32 { + match self { + Self::ClosingBracket | Self::FullStop | Self::Comma => 0.5, + Self::MiddleDot => 0.25, + _ => 0.0, + } + } +} + +/// Whether the half-em aki between `before` and `after` goes (JLREQ §3.1.4): +/// closing sequences set solid, closing→opening keeps one half-em, opening +/// sequences set solid after the first bracket. Returns (shed the trailing +/// aki of `before`, shed the leading aki of `after`); a closing mark owns the +/// reduction, so both halves never go. +pub fn shed_pair_aki(before: JapaneseClass, after: JapaneseClass) -> (bool, bool) { + let preferred = pair_rule(before, after).preferred_em + f32::EPSILON; + if before.is_trailing_aki_punctuation() { + (0.5 + after.embedded_leading_aki_em() > preferred, false) + } else if after == JapaneseClass::OpeningBracket { + (false, before.embedded_trailing_aki_em() + 0.5 > preferred) + } else { + (false, false) + } } #[derive(Debug, Clone, Copy, PartialEq)] diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 9e09ae63ca..212fde2032 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -660,7 +660,10 @@ impl TextContent { // unusable for tight bounds. Fall back to content_rect. // Vertical writing takes its bounds from content_rect; the // skparagraph line metrics below describe an unused horizontal layout. - if self.grow_type() == GrowType::AutoWidth || self.is_vertical() { + if self.is_vertical() { + return self.vertical_extrect(selrect, valign); + } + if self.grow_type() == GrowType::AutoWidth { return self.content_rect(selrect, valign); } @@ -742,6 +745,22 @@ impl TextContent { bounds } + /// Content rect of vertical text grown to the laid-out column block, which + /// overflows a fixed shape that is too narrow for its columns. + fn vertical_extrect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect { + let mut rect = self.content_rect(selrect, valign); + let layout = super::text_vertical::layout_for_box(self, selrect.height()); + let left = selrect.left() + + super::text_vertical::block_axis_offset(selrect.width(), layout.width, valign); + rect.join(Rect::from_xywh( + left, + selrect.top(), + layout.width, + layout.height, + )); + rect + } + pub fn content_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect { // Vertical content anchors to the shape's right edge and always // aligns to the top; vertical-align does not apply. @@ -981,7 +1000,8 @@ impl TextContent { let mut builder = ParagraphBuilder::new(¶graph_style, fonts); let mut has_text = false; let (span_texts, _) = paragraph.layout_span_texts(); - for (span, text) in paragraph.children().iter().zip(span_texts.iter()) { + let sheds = super::text_japanese::horizontal_aki_sheds(paragraph, &span_texts); + for ((span, text), sheds) in paragraph.children().iter().zip(&span_texts).zip(&sheds) { let text_style = if let Some((layer, image_id)) = opaque_image_layer { let mut style = span.to_style( &self.bounds(), @@ -1043,7 +1063,7 @@ impl TextContent { has_text = true; } builder.push_style(&text_style); - add_horizontal_span(&mut builder, span, text, &text_style, fonts); + add_horizontal_span(&mut builder, span, text, sheds, &text_style, fonts); } if !has_text { builder.add_text(" "); diff --git a/render-wasm/src/shapes/text_japanese.rs b/render-wasm/src/shapes/text_japanese.rs index 3881a63c49..d4585d2375 100644 --- a/render-wasm/src/shapes/text_japanese.rs +++ b/render-wasm/src/shapes/text_japanese.rs @@ -4,6 +4,7 @@ use super::text_vertical::{ }; use crate::globals::get_resources; use crate::math::Point; +use crate::shapes::japanese::{classify, shed_pair_aki, JapaneseClass}; use crate::shapes::{kinsoku, merge_fills}; use crate::utils::{get_fallback_fonts, get_font_collection}; use skia_safe::{ @@ -47,13 +48,49 @@ pub(crate) fn layout_span_texts(paragraph: &Paragraph) -> (Vec, kinsoku: (texts, kinsoku::OffsetMap::default()) } +/// Char indices, per span of the layout `texts`, of the characters whose +/// advance loses a half-em to the punctuation aki rules of `shed_pair_aki`: +/// a closing mark sheds its trailing aki, and the character before an +/// opening bracket gives up the bracket's leading aki. Inserted word joiners +/// are transparent. Spans with `palt` already set punctuation proportionally. +pub(crate) fn horizontal_aki_sheds(paragraph: &Paragraph, texts: &[String]) -> Vec> { + let mut sheds: Vec> = vec![Vec::new(); texts.len()]; + // (span, char index in the span text, class) of every real character. + let chars: Vec<(usize, usize, Option)> = texts + .iter() + .enumerate() + .flat_map(|(span, text)| { + let proportional = paragraph + .children() + .get(span) + .is_some_and(|span| span.font_features == FontFeatures::Palt); + text.chars() + .enumerate() + .filter(|(_, ch)| *ch != kinsoku::WORD_JOINER) + .map(move |(index, ch)| (span, index, (!proportional).then(|| classify(ch)))) + }) + .collect(); + for pair in chars.windows(2) { + let [(before_span, before_index, Some(before)), (_, _, Some(after))] = *pair else { + continue; + }; + let (trailing, leading) = shed_pair_aki(before, after); + if trailing || leading { + sheds[before_span].push(before_index); + } + } + sheds +} + /// Add a span to a horizontal paragraph builder. A warichu span becomes one /// inline placeholder so its two lines wrap as a unit; its glyphs are painted -/// after layout. +/// after layout. The characters at `sheds` (from `horizontal_aki_sheds`) +/// set a half-em narrower. pub(crate) fn add_horizontal_span( builder: &mut ParagraphBuilder, span: &TextSpan, builder_text: &str, + sheds: &[usize], text_style: &skia::textlayout::TextStyle, fonts: &skia::textlayout::FontCollection, ) { @@ -88,10 +125,36 @@ pub(crate) fn add_horizontal_span( builder.add_text(HORIZONTAL_WARICHU_STYLE_ANCHOR.to_string()); builder.add_text(HORIZONTAL_WARICHU_BREAK_ANCHOR.to_string()); } else { - add_text_with_tabs(builder, builder_text, span.font_size); + add_text_with_sheds(builder, span, builder_text, sheds, text_style); } } +/// Add `text`, setting the characters at `sheds` a half-em narrower through +/// letter-spacing so the builder text stays unchanged. +fn add_text_with_sheds( + builder: &mut ParagraphBuilder, + span: &TextSpan, + text: &str, + sheds: &[usize], + text_style: &skia::textlayout::TextStyle, +) { + let mut shed_style = text_style.clone(); + shed_style.set_letter_spacing(text_style.letter_spacing() - span.font_size * 0.5); + let mut piece_start = 0; + for (index, (byte, ch)) in text.char_indices().enumerate() { + if !sheds.contains(&index) { + continue; + } + let end = byte + ch.len_utf8(); + add_text_with_tabs(builder, &text[piece_start..byte], span.font_size); + builder.push_style(&shed_style); + builder.add_text(&text[byte..end]); + builder.pop(); + piece_start = end; + } + add_text_with_tabs(builder, &text[piece_start..], span.font_size); +} + #[derive(Debug, Clone)] pub(crate) struct HorizontalSpanRange { pub span: usize, @@ -1241,7 +1304,7 @@ mod tests { fonts.set_default_font_manager(skia::FontMgr::new(), None); let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); builder.push_style(&style); - add_horizontal_span(&mut builder, &span, &span.text, &style, &fonts); + add_horizontal_span(&mut builder, &span, &span.text, &[], &style, &fonts); let mut laid_out = builder.build(); laid_out.layout(200.0); @@ -1271,7 +1334,7 @@ mod tests { fonts.set_default_font_manager(skia::FontMgr::new(), None); let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts); builder.push_style(&style); - add_horizontal_span(&mut builder, &span, &span.text, &style, &fonts); + add_horizontal_span(&mut builder, &span, &span.text, &[], &style, &fonts); builder.push_style(&style); builder.add_text(&following.text); @@ -1319,6 +1382,62 @@ mod tests { } } + #[test] + fn horizontal_aki_sheds_follow_punctuation_pairs() { + init_state(); + let texts = vec!["あ。」い、".to_string(), "「「う".to_string()]; + let paragraph = + make_paragraph(texts.iter().map(|text| make_span(text, 0.0)).collect(), 0.0); + assert_eq!( + horizontal_aki_sheds(¶graph, &texts), + vec![vec![1, 4], vec![0]], + "。 before 」, 、 before 「 across spans, and 「 before 「" + ); + } + + #[test] + fn horizontal_aki_sheds_skip_word_joiners_and_palt() { + init_state(); + let joined = vec!["。\u{2060}」".to_string()]; + let paragraph = make_paragraph(vec![make_span(&joined[0], 0.0)], 0.0); + assert_eq!(horizontal_aki_sheds(¶graph, &joined), vec![vec![0]]); + + let mut palt = make_span("。」", 0.0); + palt.font_features = FontFeatures::Palt; + let paragraph = make_paragraph(vec![palt], 0.0); + assert_eq!( + horizontal_aki_sheds(¶graph, &["。」".to_string()]), + vec![Vec::::new()] + ); + } + + #[test] + fn horizontal_shed_sets_the_closing_mark_half_an_em_narrower() { + init_state(); + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + let width = |span: TextSpan| { + let mut content = super::super::text::TextContent::new( + crate::math::Rect::from_xywh(0.0, 0.0, 400.0, 100.0), + crate::shapes::GrowType::Fixed, + ); + content.add_paragraph(make_paragraph(vec![span], 0.0)); + let mut groups = content.paragraph_builder_group_from_text(None); + let mut paragraph = groups[0][0].build(); + paragraph.layout(1000.0); + paragraph.max_intrinsic_width() + }; + let mut palt = make_span("。」", 0.0); + palt.font_features = FontFeatures::Palt; + let solid = width(make_span("。」", 0.0)); + let spaced = width(palt); + assert!( + (spaced - solid - 8.0).abs() < 0.01, + "the period sheds half of its 16px em: {spaced} vs {solid}" + ); + } + #[test] fn horizontal_position_data_carries_warichu_lines_and_emphasis_marks() { init_state(); @@ -1392,7 +1511,7 @@ mod tests { let (texts, _) = paragraph.layout_span_texts(); for (span, text) in paragraph.children().iter().zip(texts) { builder.push_style(&style); - add_horizontal_span(&mut builder, span, &text, &style, &fonts); + add_horizontal_span(&mut builder, span, &text, &[], &style, &fonts); } let mut laid_out = builder.build(); laid_out.layout(200.0); @@ -1425,7 +1544,7 @@ mod tests { let mut style = skia::textlayout::TextStyle::default(); style.set_font_size(if index == 0 { 16.0 } else { 24.0 }); builder.push_style(&style); - add_horizontal_span(&mut builder, span, &text, &style, &fonts); + add_horizontal_span(&mut builder, span, &text, &[], &style, &fonts); } let mut laid_out = builder.build(); laid_out.layout(200.0); diff --git a/render-wasm/src/shapes/text_vertical/cells.rs b/render-wasm/src/shapes/text_vertical/cells.rs index e04182daff..f73233f4c1 100644 --- a/render-wasm/src/shapes/text_vertical/cells.rs +++ b/render-wasm/src/shapes/text_vertical/cells.rs @@ -1,14 +1,17 @@ // Builders that turn one span's text into flow cells: one per upright glyph -// cluster, one per rotated (sideways) run, and one per tate-chu-yoko or -// warichu composite. +// cluster, one per rotated (sideways) run, and one per tate-chu-yoko +// composite. Warichu notes span several spans and build their own cells. + +use std::ops::Range; use skia_safe::{textlayout::TypefaceFontProvider, FontMgr}; use crate::shapes::japanese::{classify, JapaneseClass}; -use crate::shapes::text_japanese::{warichu_text_lines, WARICHU_FONT_SCALE}; +use crate::shapes::kinsoku::{forbidden_at_line_end, forbidden_at_line_start}; +use crate::shapes::text_japanese::{warichu_split_chars, WARICHU_FONT_SCALE}; use crate::shapes::{FontFeatures, TextCombineUpright, TextSpan}; -use super::flow::{minimum_oikomi_extent, FlowCell, FlowScript}; +use super::flow::{minimum_oikomi_extent, FlowCell, FlowScript, FIT_TOLERANCE}; use super::font_tables::{upright_baseline, vertical_metrics, vpal_table}; use super::layout::{CellKind, VerticalCell}; use super::orientation::{ @@ -156,9 +159,6 @@ impl<'a> SpanCells<'a> { { return; } - if self.span.is_warichu() && self.push_warichu(text, runs, cells) { - return; - } // `digits` merges each run of 2..=max digits into one upright cell. let pieces = match combine.digits_max() { Some(max) => split_digit_runs(text, max), @@ -249,46 +249,6 @@ impl<'a> SpanCells<'a> { true } - /// Warichu (割注): the span becomes one composite cell holding two - /// half-size sub-lines side by side in the column (the first on the - /// right, jlreq reading order), split by `warichu_split_chars`. Returns - /// false when a sub-line shapes empty. - fn push_warichu( - &self, - text: &str, - runs: &mut Vec, - cells: &mut Vec, - ) -> bool { - let half_size = self.span.font_size * WARICHU_FONT_SCALE; - let (first_text, second_text) = warichu_text_lines(text); - let first_runs = self.shape(first_text, half_size, true); - let second_runs = self.shape(second_text, half_size, true); - if first_runs.is_empty() || second_runs.is_empty() { - return false; - } - let line_extent = |runs: &[ShapedRun]| runs.iter().map(|r| r.advance).sum::(); - let extent = - line_extent(&first_runs).max(line_extent(&second_runs)) + self.span.letter_spacing; - let kind = CellKind::Warichu { - run_start: runs.len(), - run_count: first_runs.len() + second_runs.len(), - first_count: first_runs.len(), - first_chars: first_text.encode_utf16().count(), - }; - runs.extend(first_runs); - runs.extend(second_runs); - // Two half-em sub-columns fill the em, the default `h_advance`. - let end = self.start + text.encode_utf16().count(); - let cell = self.cell(kind, self.start, end, extent); - cells.push(FlowCell::new( - cell, - None, - FlowScript::Upright, - self.span.letter_spacing, - )); - true - } - /// One upright cell per glyph cluster of `run`, so combining sequences /// stay in one cell. fn push_upright_clusters( @@ -435,6 +395,279 @@ impl<'a> SpanCells<'a> { } } +/// Most characters a warichu piece split moves back to keep kinsoku. +const MAX_WARICHU_KINSOKU_SHIFT: usize = 4; + +/// One character of a warichu note. +#[derive(Debug, Clone, Copy)] +struct NoteChar { + ch: char, + /// Index of the owning span in the note's members. + member: usize, + /// UTF-8 offset in the member's text. + utf8: usize, + /// Paragraph UTF-16 offset. + utf16: usize, +} + +/// One span's share of a shaped warichu piece: its characters on each +/// sub-line and where they start along the piece. +struct PieceSegment { + member: usize, + chars: Range, + first_runs: Vec, + second_runs: Vec, + first_chars: usize, + first_top: f32, + first_extent: f32, + second_top: f32, + second_extent: f32, +} + +/// A shaped warichu piece: the part of a note set in one column. +struct ShapedPiece { + segments: Vec, + extent: f32, + font_size: f32, + letter_spacing: f32, +} + +/// Warichu (割注): consecutive warichu spans of a paragraph set as one note +/// of two half-size sub-lines (the first on the right, jlreq reading order). +/// The note breaks into pieces at paragraph UTF-16 `splits`, one per column. +/// Each piece emits one cell per span it touches; the first carries the +/// piece's flow extent and the rest share its box. +pub(super) struct WarichuNote<'a> { + members: Vec<(SpanCells<'a>, &'a str)>, + chars: Vec, +} + +impl<'a> WarichuNote<'a> { + pub(super) fn new(members: Vec<(SpanCells<'a>, &'a str)>) -> Self { + let mut chars = Vec::new(); + for (member, (span_cells, text)) in members.iter().enumerate() { + let mut utf16 = span_cells.start; + for (utf8, ch) in text.char_indices() { + chars.push(NoteChar { + ch, + member, + utf8, + utf16, + }); + utf16 += ch.len_utf16(); + } + } + Self { members, chars } + } + + /// True when `utf16` (paragraph offset) falls inside the note. + pub(super) fn contains(&self, utf16: usize) -> bool { + match (self.chars.first(), self.chars.last()) { + (Some(first), Some(last)) => { + (first.utf16..last.utf16 + last.ch.len_utf16()).contains(&utf16) + } + _ => false, + } + } + + /// Push the note's cells, or its spans' normal cells when it cannot be + /// set as warichu (fewer than two characters, or a sub-line that shapes + /// empty). + pub(super) fn push( + &self, + splits: &[usize], + runs: &mut Vec, + cells: &mut Vec, + ) { + if self.chars.len() >= 2 { + let shaped: Option> = self + .pieces(splits) + .into_iter() + .map(|piece| self.shape_piece(piece)) + .collect(); + if let Some(pieces) = shaped { + for piece in pieces { + self.push_piece(piece, runs, cells); + } + return; + } + } + for (span_cells, text) in &self.members { + span_cells.push(text, runs, cells); + } + } + + /// Paragraph UTF-16 offset where the piece starting at `piece_start` + /// should break so its first part fits in `room`, or None when no part + /// of at least one character per sub-line fits. The break keeps two + /// characters on each side and moves back to keep kinsoku. + pub(super) fn split_for_room( + &self, + piece_start: usize, + splits: &[usize], + room: f32, + ) -> Option { + let piece = self + .pieces(splits) + .into_iter() + .find(|piece| self.chars[piece.start].utf16 == piece_start)?; + if piece.len() < 4 { + return None; + } + let half_size = self.max_font_size(piece.clone()) * WARICHU_FONT_SCALE; + let estimate = 2 * (room / half_size.max(1.0)).floor().max(0.0) as usize; + let longest = (estimate + 2).min(piece.len() - 2); + let mut cut = longest; + while cut >= 2 { + let mut at = piece.start + cut; + let mut shift = 0; + while at > piece.start + 2 + && shift < MAX_WARICHU_KINSOKU_SHIFT + && (forbidden_at_line_start(self.chars[at].ch) + || forbidden_at_line_end(self.chars[at - 1].ch)) + { + at -= 1; + shift += 1; + } + let fits = self + .shape_piece(piece.start..at) + .is_some_and(|shaped| shaped.extent <= room + FIT_TOLERANCE); + if fits { + return Some(self.chars[at].utf16); + } + cut = (at - piece.start).min(cut) - 1; + } + None + } + + /// Char index ranges of the pieces cut at `splits`. + fn pieces(&self, splits: &[usize]) -> Vec> { + let mut pieces = Vec::new(); + let mut start = 0; + for (index, note_char) in self.chars.iter().enumerate().skip(1) { + if splits.contains(¬e_char.utf16) { + pieces.push(start..index); + start = index; + } + } + pieces.push(start..self.chars.len()); + pieces + } + + fn max_font_size(&self, chars: Range) -> f32 { + self.chars[chars] + .iter() + .map(|c| self.members[c.member].0.span.font_size) + .fold(0.0, f32::max) + } + + /// Member text of the chars `range`, which belong to one member. + fn member_text(&self, range: Range) -> &'a str { + let first = self.chars[range.start]; + let text = self.members[first.member].1; + let end = self + .chars + .get(range.end) + .filter(|next| next.member == first.member) + .map_or(text.len(), |next| next.utf8); + &text[first.utf8..end] + } + + /// Shape one sub-line part of a member; empty parts shape to no runs. + fn shape_part(&self, member: usize, range: Range) -> Option<(Vec, f32)> { + if range.is_empty() { + return Some((Vec::new(), 0.0)); + } + let span_cells = &self.members[member].0; + let half_size = span_cells.span.font_size * WARICHU_FONT_SCALE; + let runs = span_cells.shape(self.member_text(range), half_size, true); + if runs.is_empty() { + return None; + } + let advance = runs.iter().map(|r| r.advance).sum(); + Some((runs, advance)) + } + + fn shape_piece(&self, piece: Range) -> Option { + let text: String = self.chars[piece.clone()].iter().map(|c| c.ch).collect(); + let split = piece.start + warichu_split_chars(&text); + let mut segments = Vec::new(); + let (mut first_cursor, mut second_cursor) = (0.0f32, 0.0f32); + let mut index = piece.start; + while index < piece.end { + let member = self.chars[index].member; + let end = (index..piece.end) + .find(|&i| self.chars[i].member != member) + .unwrap_or(piece.end); + let first = index.min(split)..end.min(split); + let second = index.max(split)..end.max(split); + let first_chars = self.chars[first.clone()] + .iter() + .map(|c| c.ch.len_utf16()) + .sum(); + let (first_runs, first_extent) = self.shape_part(member, first)?; + let (second_runs, second_extent) = self.shape_part(member, second)?; + segments.push(PieceSegment { + member, + chars: index..end, + first_runs, + second_runs, + first_chars, + first_top: first_cursor, + first_extent, + second_top: second_cursor, + second_extent, + }); + first_cursor += first_extent; + second_cursor += second_extent; + index = end; + } + let letter_spacing = self.chars[piece.clone()] + .iter() + .map(|c| self.members[c.member].0.span.letter_spacing) + .fold(0.0, f32::max); + Some(ShapedPiece { + segments, + extent: first_cursor.max(second_cursor) + letter_spacing, + font_size: self.max_font_size(piece), + letter_spacing, + }) + } + + fn push_piece(&self, piece: ShapedPiece, runs: &mut Vec, cells: &mut Vec) { + for (index, segment) in piece.segments.into_iter().enumerate() { + let span_cells = &self.members[segment.member].0; + let kind = CellKind::Warichu { + run_start: runs.len(), + run_count: segment.first_runs.len() + segment.second_runs.len(), + first_count: segment.first_runs.len(), + first_chars: segment.first_chars, + first_top: segment.first_top, + first_extent: segment.first_extent, + second_top: segment.second_top, + second_extent: segment.second_extent, + }; + runs.extend(segment.first_runs); + runs.extend(segment.second_runs); + let last = self.chars[segment.chars.end - 1]; + let start = self.chars[segment.chars.start].utf16; + let end = last.utf16 + last.ch.len_utf16(); + // Two half-em sub-columns fill the em, the default `h_advance`. + let cell = VerticalCell { + font_size: piece.font_size, + ..span_cells.cell(kind, start, end, piece.extent) + }; + let mut flow = FlowCell::new(cell, None, FlowScript::Upright, piece.letter_spacing); + if index > 0 { + flow.shares_previous_box = true; + flow.cell.extent = 0.0; + flow.cell.minimum_oikomi_extent = 0.0; + } + cells.push(flow); + } + } +} + #[cfg(test)] mod tests { use super::super::layout::VerticalCell; diff --git a/render-wasm/src/shapes/text_vertical/flow.rs b/render-wasm/src/shapes/text_vertical/flow.rs index ef1819fc09..bd1d145b90 100644 --- a/render-wasm/src/shapes/text_vertical/flow.rs +++ b/render-wasm/src/shapes/text_vertical/flow.rs @@ -3,7 +3,7 @@ // (aki, oikomi, inter-script spacing) and pick column breaks (kinsoku, // burasage, oidashi). -use crate::shapes::japanese::{classify, pair_rule, JapaneseClass}; +use crate::shapes::japanese::{classify, pair_rule, shed_pair_aki, JapaneseClass}; use crate::shapes::kinsoku::{forbidden_at_line_end, forbidden_at_line_start}; use crate::shapes::TextAlign; @@ -14,6 +14,10 @@ const INTER_SCRIPT_SPACING_EM: f32 = 0.25; /// Amounts below this are treated as zero by the spacing passes. const EPSILON: f32 = 0.0001; +/// Overflow below this still fits a column: shape heights carry float noise +/// (a 19em box can measure 417.99998px for 418px of text). +pub(super) const FIT_TOLERANCE: f32 = 0.01; + /// An item placed along the column. `ch` is set for single-character cells /// and drives kinsoku at column breaks; rotated runs have none and never /// split. @@ -44,6 +48,9 @@ pub(super) struct FlowCell { pub script: FlowScript, /// Letter-spacing included at the end of the cell's extent. pub trailing_spacing: f32, + /// A later span's cell of the previous cell's warichu piece: it has no + /// flow extent and takes the piece's box after placement. + pub shares_previous_box: bool, } impl FlowCell { @@ -59,6 +66,7 @@ impl FlowCell { keep_with_previous: false, script, trailing_spacing, + shares_previous_box: false, } } @@ -66,7 +74,7 @@ impl FlowCell { FlowItem { extent: self.cell.extent, ch: self.ch, - keep_with_previous: self.keep_with_previous, + keep_with_previous: self.keep_with_previous || self.shares_previous_box, } } @@ -100,7 +108,9 @@ fn can_hang(c: char) -> bool { /// True when `item` would overflow a column already filled to `cursor`. fn overflows(cursor: f32, item: &FlowItem, max_height: f32) -> bool { - cursor > 0.0 && cursor + item.extent > max_height && !item.ch.is_some_and(can_hang) + cursor > 0.0 + && cursor + item.extent > max_height + FIT_TOLERANCE + && !item.ch.is_some_and(can_hang) } /// First item of the next column when `items[i]` overflows the column @@ -268,27 +278,10 @@ pub(super) fn flow_classes(cells: &[FlowCell], ruby_spans: &[bool]) -> Vec f32 { - match class { - JapaneseClass::OpeningBracket => 0.5, - JapaneseClass::MiddleDot => 0.25, - _ => 0.0, - } -} - -fn embedded_trailing_aki(class: JapaneseClass) -> f32 { - match class { - JapaneseClass::ClosingBracket | JapaneseClass::FullStop | JapaneseClass::Comma => 0.5, - JapaneseClass::MiddleDot => 0.25, - _ => 0.0, - } -} - /// JLREQ punctuation and cl-30 adjacency. Full-width fonts include a half-em /// aki in punctuation advances. Ordinary text keeps it; at the internal -/// boundaries of §3.1.4 one half-em goes: closing sequences set solid, -/// closing→opening keeps one half-em, opening sequences set solid after the -/// first bracket, and middle dots keep their quarter-em sides. +/// boundaries of §3.1.4 one half-em goes (see `shed_pair_aki`), and middle +/// dots keep their quarter-em sides. pub(super) fn shed_punctuation_aki(cells: &mut [FlowCell], classes: &[Option]) { for (i, flow) in cells.iter_mut().enumerate() { let Some(ch) = flow.ch else { @@ -298,20 +291,15 @@ pub(super) fn shed_punctuation_aki(cells: &mut [FlowCell], classes: &[Option pair_rule(class, next).preferred_em + f32::EPSILON - }) + classes + .get(i + 1) + .copied() + .flatten() + .is_some_and(|next| shed_pair_aki(class, next).0) } else if opening { i.checked_sub(1) .and_then(|previous| classes[previous]) - .is_some_and(|previous| { - // A preceding trailing-aki mark owns the reduction for - // closing→opening, so never remove both halves. - !previous.is_trailing_aki_punctuation() - && embedded_trailing_aki(previous) + 0.5 - > pair_rule(previous, class).preferred_em + f32::EPSILON - }) + .is_some_and(|previous| shed_pair_aki(previous, class).1) } else { false }; @@ -490,10 +478,24 @@ fn discard_explicit_spacing_at_column_edges( changed } -/// JLREQ oikomi: before wrapping a non-hanging item, try to keep it in the -/// column by reducing legal aki in table priority order. When the whole -/// deficit cannot be recovered, leave the line to the oidashi/kinsoku -/// planner. +/// True when breaking the column before `cells[i]`, with the column filled +/// to `cursor`, needs line adjustment: the break would violate kinsoku or +/// split a kept group, or would leave the column short of `max_height` (a +/// rotated run or other uneven extent). A column that is already full breaks +/// without it. +fn break_needs_adjustment(cells: &[FlowCell], i: usize, cursor: f32, max_height: f32) -> bool { + let flow = &cells[i]; + let previous_ch = i.checked_sub(1).and_then(|previous| cells[previous].ch); + flow.keep_with_previous + || flow.ch.is_some_and(forbidden_at_line_start) + || previous_ch.is_some_and(forbidden_at_line_end) + || max_height - cursor > FIT_TOLERANCE +} + +/// JLREQ oikomi: before wrapping a non-hanging item whose break needs line +/// adjustment, try to keep it in the column by reducing legal aki in table +/// priority order. When the whole deficit cannot be recovered, leave the +/// line to the oidashi/kinsoku planner. pub(super) fn apply_ordered_oikomi( cells: &mut [FlowCell], classes: &[Option], @@ -507,9 +509,11 @@ pub(super) fn apply_ordered_oikomi( let mut cursor = 0.0f32; for i in 0..cells.len() { if overflows(cursor, &cells[i].item(), max_height) { - let deficit = cursor + cells[i].cell.extent - max_height; - cursor -= compress_line(cells, classes, pair_spacing_em, column_start..i, deficit); - if cursor + cells[i].cell.extent > max_height + EPSILON { + if break_needs_adjustment(cells, i, cursor, max_height) { + let deficit = cursor + cells[i].cell.extent - max_height; + cursor -= compress_line(cells, classes, pair_spacing_em, column_start..i, deficit); + } + if cursor + cells[i].cell.extent > max_height + FIT_TOLERANCE { column_start = i; cursor = 0.0; } @@ -732,6 +736,13 @@ mod tests { ); } + #[test] + fn plan_columns_ignores_float_noise_in_the_budget() { + let items: Vec = "あいう".chars().map(|c| item(22.0, c)).collect(); + let placements = plan_columns(&items, 66.0 - 0.00002); + assert!(placements.iter().all(|(column, _)| *column == 0)); + } + #[test] fn plan_columns_oversized_item_gets_own_column() { let items = vec![item(10.0, 'あ'), item(100.0, 'い'), item(10.0, 'う')]; @@ -1276,6 +1287,49 @@ mod tests { ); } + #[test] + fn oikomi_keeps_a_full_column_solid() { + // Five cells fill the column exactly; the sixth く may start a column, + // so the commas keep their aki instead of compressing to pull it in. + let budget = 5.0 * EM; + let content = make_content(&["く、く、くく"], budget); + let layout = layout_with_height(&provider(VMTX_TEST_FONT), &content, budget); + assert_eq!(layout.cells[4].column, 0); + assert_eq!(layout.cells[5].column, 1); + for comma in [1, 3] { + assert!( + (layout.cells[comma].extent - EM).abs() < 0.01, + "comma {comma} keeps its aki, got {}", + layout.cells[comma].extent + ); + } + } + + #[test] + fn oikomi_fills_a_column_left_short_by_a_rotated_run() { + // The Latin run leaves the column uneven; reducing the comma aki + // pulls the last く in instead of leaving the column short. + let text = "くaく、くく"; + let provider = provider(VMTX_TEST_FONT); + let natural = layout_with_height(&provider, &make_content(&[text], 1000.0), 1000.0); + let total: f32 = natural.cells.iter().map(|cell| cell.extent).sum(); + let budget = total - 2.0; + let layout = layout_with_height(&provider, &make_content(&[text], budget), budget); + let last = layout.cells.last().expect("cells"); + assert_eq!(last.column, 0, "oikomi keeps the last く in the column"); + } + + #[test] + fn oikomi_keeps_a_line_start_forbidden_character_in_the_column() { + let budget = 5.0 * EM; + let content = make_content(&["く、く、く」"], budget); + let layout = layout_with_height(&provider(VMTX_TEST_FONT), &content, budget); + assert_eq!( + layout.cells[5].column, 0, + "」 cannot start a column, so oikomi keeps it in place" + ); + } + #[test] fn oikomi_preserves_sentence_final_full_stop_aki() { let natural = layout_content(&make_content(&["あ。あ"], 1000.0), 1000.0); diff --git a/render-wasm/src/shapes/text_vertical/layout.rs b/render-wasm/src/shapes/text_vertical/layout.rs index 40aebbdef8..e03434a5c1 100644 --- a/render-wasm/src/shapes/text_vertical/layout.rs +++ b/render-wasm/src/shapes/text_vertical/layout.rs @@ -18,11 +18,11 @@ use super::annotations::{ grow_ruby_bases, layout_emphasis, layout_ruby, ruby_base_units, spread_ruby_base_cells, EmphasisMark, RubyCell, }; -use super::cells::{Fonts, SpanCells}; +use super::cells::{Fonts, SpanCells, WarichuNote}; use super::flow::{ align_offset_along_column, apply_inter_script_spacing, apply_ordered_oikomi, flow_classes, is_bounded, materialize_explicit_pair_spacing, ordered_expansion_offsets, - plan_with_edge_trimming, preferred_pair_spacing, shed_punctuation_aki, FlowCell, + plan_with_edge_trimming, preferred_pair_spacing, shed_punctuation_aki, FlowCell, FIT_TOLERANCE, }; use super::shaping::ShapedRun; @@ -50,13 +50,20 @@ pub enum CellKind { run_count: usize, scale: f32, }, + /// One span's share of a warichu piece. Every cell of a piece has the + /// piece's box; the `*_top`/`*_extent` pairs place this span's glyphs + /// along each sub-line, relative to `top`. Warichu { /// First `first_count` runs are the right sub-line, the rest the left. run_start: usize, run_count: usize, first_count: usize, - /// UTF-16 length of the first sub-line (split point from `start`). + /// UTF-16 length of the first sub-line part (split point from `start`). first_chars: usize, + first_top: f32, + first_extent: f32, + second_top: f32, + second_extent: f32, }, } @@ -142,9 +149,10 @@ impl VerticalLayout { /// Horizontal offset of vertical content within its shape. `VerticalAlign` /// top/center/bottom mean block start/center/end: right/center/left in -/// vertical-rl. +/// vertical-rl. Content wider than the shape keeps that anchor and overflows +/// toward the block end, so a start-anchored block grows leftward. pub fn block_axis_offset(container_width: f32, content_width: f32, align: VerticalAlign) -> f32 { - let slack = (container_width - content_width).max(0.0); + let slack = container_width - content_width; match align { VerticalAlign::Top => slack, VerticalAlign::Center => slack / 2.0, @@ -224,19 +232,24 @@ impl ColumnGeometry { } } -/// The paragraph's cells in flow order, before spacing and placement, plus -/// the UTF-16 start of every span in the paragraph's layout text. -fn build_paragraph_flow( - fonts: &Fonts, +/// The paragraph's cells in flow order, before spacing and placement, the +/// UTF-16 start of every span in the paragraph's layout text, and its warichu +/// notes. Notes break into pieces at the paragraph UTF-16 `warichu_splits`. +#[allow(clippy::too_many_arguments)] +fn build_paragraph_flow<'a>( + fonts: &'a Fonts, paragraph_index: usize, - paragraph: &Paragraph, - transforms: &[AppliedTextTransform], + paragraph: &'a Paragraph, + transforms: &'a [AppliedTextTransform], bounds: Rect, + warichu_splits: &[usize], runs: &mut Vec, paints: &mut Vec, -) -> (Vec, Vec) { +) -> (Vec, Vec, Vec>) { let mut flow = Vec::new(); let mut span_starts = Vec::with_capacity(transforms.len()); + let mut notes: Vec> = Vec::new(); + let mut note_members = Vec::new(); let mut offset = 0usize; for (span_index, (span, transform)) in paragraph.children().iter().zip(transforms).enumerate() { span_starts.push(offset); @@ -252,13 +265,107 @@ fn build_paragraph_flow( paints.len() - 1, offset, ); - span_cells.push(&transform.text, runs, &mut flow); + if span.warichu { + note_members.push((span_cells, transform.text.as_str())); + } else { + flush_warichu_note( + &mut note_members, + &mut notes, + warichu_splits, + runs, + &mut flow, + ); + span_cells.push(&transform.text, runs, &mut flow); + } offset += transform.text.encode_utf16().count(); } + flush_warichu_note( + &mut note_members, + &mut notes, + warichu_splits, + runs, + &mut flow, + ); keep_transform_expansions_together(&mut flow, transforms, &span_starts); - (flow, span_starts) + (flow, span_starts, notes) } +/// Push the warichu note gathered in `members`, if any, and keep it. +fn flush_warichu_note<'a>( + members: &mut Vec<(SpanCells<'a>, &'a str)>, + notes: &mut Vec>, + warichu_splits: &[usize], + runs: &mut Vec, + flow: &mut Vec, +) { + if members.is_empty() { + return; + } + let note = WarichuNote::new(std::mem::take(members)); + note.push(warichu_splits, runs, flow); + notes.push(note); +} + +/// Most warichu breaks planned per paragraph. +const MAX_WARICHU_SPLITS: usize = 64; + +/// Next break that lets a warichu piece start where the planner left room +/// for it, as a paragraph UTF-16 offset. +fn next_warichu_split( + notes: &[WarichuNote], + flow: &[FlowCell], + placements: &[(usize, f32)], + splits: &[usize], + max_height: f32, +) -> Option { + warichu_overflows(flow, placements, max_height) + .into_iter() + .find_map(|(i, room)| { + let start = flow[i].cell.start; + let note = notes.iter().find(|note| note.contains(start))?; + note.split_for_room(start, splits, room) + .filter(|split| !splits.contains(split)) + }) +} + +/// Warichu pieces the planner moved whole to a later column, or that overrun +/// their column, with the room left for them where they should start: after +/// the cells kinsoku carried along, at the bottom of the previous column. +fn warichu_overflows( + flow: &[FlowCell], + placements: &[(usize, f32)], + max_height: f32, +) -> Vec<(usize, f32)> { + if !is_bounded(max_height) { + return Vec::new(); + } + let columns = placements.last().map_or(0, |(column, _)| column + 1); + let mut column_used = vec![0.0f32; columns]; + let mut column_first = vec![usize::MAX; columns]; + for (i, (cell, (column, top))) in flow.iter().zip(placements).enumerate() { + column_used[*column] = column_used[*column].max(top + cell.cell.extent); + column_first[*column] = column_first[*column].min(i); + } + let mut overflows = Vec::new(); + for (i, (cell, (column, top))) in flow.iter().zip(placements).enumerate() { + if !matches!(cell.cell.kind, CellKind::Warichu { .. }) || cell.shares_previous_box { + continue; + } + if top + cell.cell.extent > max_height + FIT_TOLERANCE { + overflows.push((i, max_height - top)); + } else if *column > 0 { + let carried: f32 = flow[column_first[*column]..i] + .iter() + .map(|carried| carried.cell.extent) + .sum(); + let room = max_height - column_used[column - 1] - carried; + if room > FIT_TOLERANCE { + overflows.push((i, room)); + } + } + } + overflows +} /// A CSS transform may expand one source character into several cells /// (`ß` -> `SS`). Keep them in one column so the SVG fallback renders each /// source slice once. @@ -379,31 +486,59 @@ pub fn layout_vertical( for (paragraph_index, paragraph) in paragraphs.iter().enumerate() { let transforms = &span_transforms[paragraph_index]; - let (mut flow, span_starts) = build_paragraph_flow( - &fonts, - paragraph_index, - paragraph, - transforms, - bounds, - &mut runs, - &mut paints, - ); - let ruby_units = ruby_base_units(paragraph, transforms, &span_starts); let ruby_spans: Vec = paragraph .children() .iter() .map(TextSpan::has_ruby) .collect(); - apply_inter_script_spacing(&mut flow); - let classes = flow_classes(&flow, &ruby_spans); - shed_punctuation_aki(&mut flow, &classes); - materialize_explicit_pair_spacing(&mut flow, &classes); - grow_ruby_bases(&mut flow, &ruby_units); - let mut pair_spacing_em = preferred_pair_spacing(&classes); - apply_ordered_oikomi(&mut flow, &classes, &mut pair_spacing_em, max_height); - let placements = - plan_with_edge_trimming(&mut flow, &classes, &mut pair_spacing_em, max_height); + // Plan, then break the first warichu piece that does not fit where + // it starts and plan again, until every piece fits. + let (run_mark, paint_mark) = (runs.len(), paints.len()); + let mut warichu_splits: Vec = Vec::new(); + let (flow, span_starts, ruby_units, classes, pair_spacing_em, placements) = loop { + runs.truncate(run_mark); + paints.truncate(paint_mark); + let (mut flow, span_starts, notes) = build_paragraph_flow( + &fonts, + paragraph_index, + paragraph, + transforms, + bounds, + &warichu_splits, + &mut runs, + &mut paints, + ); + let ruby_units = ruby_base_units(paragraph, transforms, &span_starts); + + apply_inter_script_spacing(&mut flow); + let classes = flow_classes(&flow, &ruby_spans); + shed_punctuation_aki(&mut flow, &classes); + materialize_explicit_pair_spacing(&mut flow, &classes); + grow_ruby_bases(&mut flow, &ruby_units); + let mut pair_spacing_em = preferred_pair_spacing(&classes); + apply_ordered_oikomi(&mut flow, &classes, &mut pair_spacing_em, max_height); + let placements = + plan_with_edge_trimming(&mut flow, &classes, &mut pair_spacing_em, max_height); + let split = if warichu_splits.len() < MAX_WARICHU_SPLITS { + next_warichu_split(¬es, &flow, &placements, &warichu_splits, max_height) + } else { + None + }; + match split { + Some(split) => warichu_splits.push(split), + None => { + break ( + flow, + span_starts, + ruby_units, + classes, + pair_spacing_em, + placements, + ) + } + } + }; let tops = aligned_tops( &flow, &classes, @@ -423,11 +558,21 @@ pub fn layout_vertical( let paragraph_cell_start = cells.len(); for ((flow, (column, _)), top) in flow.into_iter().zip(placements).zip(tops) { - cells.push(VerticalCell { + let mut cell = VerticalCell { column: column_base + column, top, ..flow.cell - }); + }; + if flow.shares_previous_box { + if let Some(piece) = cells.last() { + cell.top = piece.top; + cell.extent = piece.extent; + cell.minimum_oikomi_extent = piece.minimum_oikomi_extent; + cell.ink_top = piece.ink_top; + cell.ink_bottom = piece.ink_bottom; + } + } + cells.push(cell); } spread_ruby_base_cells(&mut cells[paragraph_cell_start..], &ruby_units, max_height); span_utf16_starts.push(span_starts); @@ -505,6 +650,97 @@ pub fn measure_content(text_content: &TextContent, height: f32) -> (f32, f32) { mod tests { use super::super::test_support::*; use super::*; + use crate::shapes::TextOrientation; + + fn warichu_span(text: &str) -> TextSpan { + TextSpan { + warichu: true, + text_orientation: TextOrientation::Upright, + ..make_span(text) + } + } + + /// Cells that start a warichu piece, in flow order. + fn warichu_pieces(layout: &VerticalLayout) -> Vec<&VerticalCell> { + let mut pieces: Vec<&VerticalCell> = Vec::new(); + for cell in &layout.cells { + if !matches!(cell.kind, CellKind::Warichu { .. }) { + continue; + } + let same_box = pieces + .last() + .is_some_and(|piece| piece.column == cell.column && piece.top == cell.top); + if !same_box { + pieces.push(cell); + } + } + pieces + } + + #[test] + fn warichu_breaks_at_the_column_end() { + // Four ems of base text leave two ems; ten half-size characters need + // two and a half, so four per sub-line stay and the rest wrap. + let budget = 6.0 * EM; + let content = spans_content( + vec![make_span("くくくく"), warichu_span("あくあくあくあくあく")], + budget, + ); + let layout = layout_with_height(&provider(VMTX_TEST_FONT), &content, budget); + let pieces = warichu_pieces(&layout); + assert_eq!(pieces.len(), 2, "the note breaks into two pieces"); + let (first, second) = (pieces[0], pieces[1]); + assert_eq!(first.column, 0); + assert!((first.top - 4.0 * EM).abs() < 0.01); + assert!(first.top + first.extent <= budget + 0.01); + assert_eq!(first.end - first.start, 8, "four characters per sub-line"); + assert_eq!(second.column, 1); + assert_eq!(second.top, 0.0); + assert_eq!((second.start, second.end), (first.end, 14)); + } + + #[test] + fn adjacent_warichu_spans_form_one_note() { + let content = spans_content(vec![warichu_span("くあく"), warichu_span("あくあ")], 400.0); + let layout = layout_with_height(&provider(VMTX_TEST_FONT), &content, 400.0); + assert_eq!(warichu_pieces(&layout).len(), 1, "one note, one piece"); + let [first, second] = &layout.cells[..] else { + panic!("one cell per span, got {}", layout.cells.len()); + }; + assert_eq!((first.span, second.span), (0, 1)); + assert_eq!((first.top, first.extent), (second.top, second.extent)); + let ( + CellKind::Warichu { + first_chars: first_span_chars, + first_extent, + .. + }, + CellKind::Warichu { + first_chars: second_span_chars, + second_top, + second_extent, + .. + }, + ) = (first.kind, second.kind) + else { + panic!("expected warichu cells"); + }; + assert_eq!( + first_span_chars, 3, + "the first span fills the first sub-line" + ); + assert_eq!(second_span_chars, 0, "the second span fills the second"); + assert_eq!( + second_top, 0.0, + "the second sub-line starts at the piece top" + ); + assert!((first_extent - second_extent).abs() < 0.01); + assert!( + (first.extent - 3.0 * EM / 2.0).abs() < 0.01, + "three half-size characters per sub-line, got {}", + first.extent + ); + } #[test] fn layout_cells_tile_the_text() { @@ -572,7 +808,9 @@ mod tests { assert_eq!(block_axis_offset(200.0, 40.0, VerticalAlign::Top), 160.0); assert_eq!(block_axis_offset(200.0, 40.0, VerticalAlign::Center), 80.0); assert_eq!(block_axis_offset(200.0, 40.0, VerticalAlign::Bottom), 0.0); - assert_eq!(block_axis_offset(20.0, 40.0, VerticalAlign::Top), 0.0); + assert_eq!(block_axis_offset(20.0, 40.0, VerticalAlign::Top), -20.0); + assert_eq!(block_axis_offset(20.0, 40.0, VerticalAlign::Center), -10.0); + assert_eq!(block_axis_offset(20.0, 40.0, VerticalAlign::Bottom), 0.0); } #[test] diff --git a/render-wasm/src/shapes/text_vertical/paint.rs b/render-wasm/src/shapes/text_vertical/paint.rs index 93d1b9a6a7..19acda9a4f 100644 --- a/render-wasm/src/shapes/text_vertical/paint.rs +++ b/render-wasm/src/shapes/text_vertical/paint.rs @@ -39,6 +39,10 @@ impl GlyphDraw { } fn draw(&self, canvas: &Canvas, paint: &Paint) { + if crate::render::svg::writing_svg() { + canvas.draw_path(&self.path(), paint); + return; + } match &self.transform { None => { canvas.draw_text_blob(&self.blob, self.offset, paint); @@ -53,10 +57,14 @@ impl GlyphDraw { } fn into_path(self) -> skia::Path { - let path = text_blob_path(self.blob, self.offset); - match self.transform { + self.path() + } + + fn path(&self) -> skia::Path { + let path = text_blob_path(self.blob.clone(), self.offset); + match &self.transform { None => path, - Some(transform) => path.make_transform(&transform), + Some(transform) => path.make_transform(transform), } } } @@ -141,14 +149,20 @@ fn cell_draws(layout: &VerticalLayout, cell: &VerticalCell, origin: (f32, f32)) run_start, run_count, first_count, + first_top, + second_top, .. } => { let (first, second) = layout.runs[run_start..run_start + run_count].split_at(first_count); let quarter = cell.font_size / 4.0; // vertical-rl: the first sub-line reads first, on the right half. - let mut draws = warichu_line_draws(first, x_center + quarter, y_top); - draws.extend(warichu_line_draws(second, x_center - quarter, y_top)); + let mut draws = warichu_line_draws(first, x_center + quarter, y_top + first_top); + draws.extend(warichu_line_draws( + second, + x_center - quarter, + y_top + second_top, + )); draws } } diff --git a/render-wasm/src/shapes/text_vertical/positions.rs b/render-wasm/src/shapes/text_vertical/positions.rs index 06532b0646..a863a06fcb 100644 --- a/render-wasm/src/shapes/text_vertical/positions.rs +++ b/render-wasm/src/shapes/text_vertical/positions.rs @@ -61,27 +61,54 @@ fn span_entry( } } -/// The two sub-line strips of a warichu cell: the first on the right half of -/// the base band, the second on the left, each holding its own characters. +/// Flow placement (top relative to the cell, extent) of a warichu cell's +/// part of its first (right) or second (left) sub-line. +fn warichu_line_span(cell: &VerticalCell, first_line: bool) -> (f32, f32) { + match cell.kind { + CellKind::Warichu { + first_top, + first_extent, + second_top, + second_extent, + .. + } => { + if first_line { + (first_top, first_extent) + } else { + (second_top, second_extent) + } + } + _ => (0.0, cell.extent), + } +} + +/// The sub-line strips of a warichu cell: its first sub-line part on the +/// right half of the base band, its second on the left, each holding its own +/// characters. A cell with no characters on a sub-line has no strip there. fn warichu_entries( layout: &VerticalLayout, cell: &VerticalCell, first_chars: usize, origin: (f32, f32), -) -> [PositionData; 2] { +) -> Vec { let column = &layout.columns[cell.column]; let center = origin.0 + column_base_center(column); let half = cell.font_size / 2.0; let split = cell.start + first_chars; - let line = |range: Range, x: f32| { + let line = |range: Range, x: f32, first_line: bool| { let source = source_utf16_range(layout, cell.paragraph, cell.span, range); - let rect = (x, origin.1 + cell.top, half, cell.extent); + let (top, extent) = warichu_line_span(cell, first_line); + let rect = (x, origin.1 + cell.top + top, half, extent); span_entry(layout, cell, source, rect, DIRECTION_VERTICAL_RL) }; [ - line(cell.start..split, center), - line(split..cell.end, center - half), + (cell.start..split, center, true), + (split..cell.end, center - half, false), ] + .into_iter() + .filter(|(range, _, _)| !range.is_empty()) + .map(|(range, x, first_line)| line(range, x, first_line)) + .collect() } /// Position-data entries for the v2 editor / exports: consecutive cells of @@ -278,10 +305,22 @@ pub fn caret_from_point(layout: &VerticalLayout, x: f32, y: f32) -> Option<(usiz return Some((paragraph, 0)); } - for cell in &column_cells { + for (index, cell) in column_cells.iter().enumerate() { if y < cell.top + cell.extent { let (cell_start, cell_end) = cell_scalar_range(layout, cell)?; let chars = (cell_end - cell_start).max(1); + // Cells of one warichu piece share its box: pass on to the next + // span's cell when the point is past this one's sub-line part. + if let CellKind::Warichu { .. } = cell.kind { + let first_line = x >= column_base_center(&layout.columns[cell.column]); + let (top, extent) = warichu_line_span(cell, first_line); + let next_shares_box = column_cells.get(index + 1).is_some_and(|next| { + matches!(next.kind, CellKind::Warichu { .. }) && next.top == cell.top + }); + if next_shares_box && (extent <= 0.0 || y >= cell.top + top + extent) { + continue; + } + } let offset = match cell.kind { CellKind::Rotated { .. } => { // Proportional position along the rotated run. @@ -303,13 +342,15 @@ pub fn caret_from_point(layout: &VerticalLayout, x: f32, y: f32) -> Option<(usiz let centre = column_base_center(column); let first = warichu_first_line_len(layout, cell, first_chars, cell_start, chars)?; - let (lo, hi) = if x >= centre { + let first_line = x >= centre; + let (lo, hi) = if first_line { (0, first) } else { (first, chars) }; + let (top, extent) = warichu_line_span(cell, first_line); let line_chars = (hi - lo).max(1); - let frac = ((y - cell.top) / cell.extent.max(1.0)).clamp(0.0, 1.0); + let frac = ((y - cell.top - top) / extent.max(1.0)).clamp(0.0, 1.0); cell_start + lo + ((frac * line_chars as f32).round() as usize).min(hi - lo) } CellKind::Upright { .. } | CellKind::SyntheticRotated { .. } => { @@ -374,11 +415,12 @@ pub fn caret_rect(layout: &VerticalLayout, paragraph: usize, offset: usize) -> O }; let line_chars = line_chars.max(1); let frac = (within - line_start) as f32 / line_chars as f32; + let (top, extent) = warichu_line_span(cell, within < first); Rect::from_xywh( band_x, - cell.top + frac * cell.extent, + cell.top + top + frac * extent, half, - cell.extent / line_chars as f32, + extent / line_chars as f32, ) } _ => { @@ -425,6 +467,19 @@ pub fn range_rects( } let column = &layout.columns[cell.column]; let chars = (cell_end - cell_start).max(1); + if let CellKind::Warichu { first_chars, .. } = cell.kind { + let Some(first) = warichu_first_line_len(layout, cell, first_chars, cell_start, chars) + else { + continue; + }; + rects.extend(warichu_range_rects( + layout, + cell, + (cell_start, cell_start + first, cell_end), + (start, end), + )); + continue; + } let sel_top = if start > cell_start { cell.top + ((start - cell_start) as f32 / chars as f32) * cell.extent } else { @@ -455,6 +510,36 @@ pub fn range_rects( rects } +/// Selection rects of the scalar range `start..end` over a warichu cell's +/// two sub-line parts, given as (cell start, first-part end, cell end). +fn warichu_range_rects( + layout: &VerticalLayout, + cell: &VerticalCell, + (cell_start, split, cell_end): (usize, usize, usize), + (start, end): (usize, usize), +) -> Vec { + let centre = column_base_center(&layout.columns[cell.column]); + let half = cell.font_size / 2.0; + [ + (cell_start, split, centre, true), + (split, cell_end, centre - half, false), + ] + .into_iter() + .filter_map(|(line_start, line_end, left, first_line)| { + let from = start.max(line_start); + let to = end.min(line_end); + if from >= to { + return None; + } + let (top, extent) = warichu_line_span(cell, first_line); + let per_char = extent / (line_end - line_start) as f32; + let sel_top = cell.top + top + (from - line_start) as f32 * per_char; + let sel_bottom = cell.top + top + (to - line_start) as f32 * per_char; + Some(Rect::from_ltrb(left, sel_top, left + half, sel_bottom)) + }) + .collect() +} + #[cfg(test)] mod tests { use super::super::test_support::*;