bugfixes after w3c samples

This commit is contained in:
alonso.torres 2026-10-02 16:37:03 +02:00
parent a4a0be38d6
commit 3b934911f1
11 changed files with 1021 additions and 154 deletions

View File

@ -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<bool> = const { Cell::new(false) };
}
/// True while an SVG export draws. `SkSVGDevice` rebuilds `<text>` 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);

View File

@ -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("<text"),
"vertical glyphs must not go through cmap-rebuilt <text>: {svg}"
);
assert!(
svg.contains("<path"),
"vertical glyphs export as outlines: {svg}"
);
assert!(
svg.to_ascii_lowercase().contains("fill=\"#003fff\""),
"outlines keep the text fill: {svg}"
);
assert!(
!super::writing_svg(),
"the export flag resets once the document is written"
);
}

View File

@ -3,9 +3,9 @@ use crate::{
error::Result,
math::Rect,
shapes::{
add_horizontal_span, calculate_text_layout_data, set_paint_fill, text_vertical,
vertical_align_offset, Paragraph as TextParagraph, ParagraphBuilderGroup, ParagraphLayout,
Stroke, StrokeKind, TextContent, TextDecorationSegment,
add_horizontal_span, calculate_text_layout_data, horizontal_aki_sheds, set_paint_fill,
text_vertical, vertical_align_offset, Paragraph as TextParagraph, ParagraphBuilderGroup,
ParagraphLayout, Stroke, StrokeKind, TextContent, TextDecorationSegment,
},
utils::{get_fallback_fonts, get_font_collection},
};
@ -155,7 +155,8 @@ pub fn stroke_paragraph_builder_group_from_text(
std::collections::HashMap::new();
let (span_texts, _) = paragraph.layout_span_texts();
for (span, text) in paragraph.children().iter().zip(span_texts.iter()) {
let sheds = horizontal_aki_sheds(paragraph, &span_texts);
for ((span, text), sheds) in paragraph.children().iter().zip(&span_texts).zip(&sheds) {
let (stroke_paints, stroke_layer_opacity) =
get_text_stroke_paints(stroke, bounds, remove_stroke_alpha);
@ -177,7 +178,7 @@ pub fn stroke_paragraph_builder_group_from_text(
paragraph.line_height(),
);
builder.push_style(&stroke_style);
add_horizontal_span(builder, span, text, &stroke_style, fonts);
add_horizontal_span(builder, span, text, sheds, &stroke_style, fonts);
}
}

View File

@ -129,6 +129,40 @@ impl JapaneseClass {
pub const fn is_trailing_aki_punctuation(self) -> 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)]

View File

@ -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(&paragraph_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(" ");

View File

@ -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<String>, 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<Vec<usize>> {
let mut sheds: Vec<Vec<usize>> = vec![Vec::new(); texts.len()];
// (span, char index in the span text, class) of every real character.
let chars: Vec<(usize, usize, Option<JapaneseClass>)> = 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(&paragraph, &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(&paragraph, &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(&paragraph, &["。」".to_string()]),
vec![Vec::<usize>::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);

View File

@ -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<ShapedRun>,
cells: &mut Vec<FlowCell>,
) -> 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::<f32>();
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<usize>,
first_runs: Vec<ShapedRun>,
second_runs: Vec<ShapedRun>,
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<PieceSegment>,
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<NoteChar>,
}
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<ShapedRun>,
cells: &mut Vec<FlowCell>,
) {
if self.chars.len() >= 2 {
let shaped: Option<Vec<ShapedPiece>> = 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<usize> {
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<Range<usize>> {
let mut pieces = Vec::new();
let mut start = 0;
for (index, note_char) in self.chars.iter().enumerate().skip(1) {
if splits.contains(&note_char.utf16) {
pieces.push(start..index);
start = index;
}
}
pieces.push(start..self.chars.len());
pieces
}
fn max_font_size(&self, chars: Range<usize>) -> 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<usize>) -> &'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<usize>) -> Option<(Vec<ShapedRun>, 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<usize>) -> Option<ShapedPiece> {
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<ShapedRun>, cells: &mut Vec<FlowCell>) {
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;

View File

@ -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<Optio
.collect()
}
fn embedded_leading_aki(class: JapaneseClass) -> 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<JapaneseClass>]) {
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<Jap
let closing = class.is_trailing_aki_punctuation();
let opening = class == JapaneseClass::OpeningBracket;
let shed = if closing {
classes.get(i + 1).copied().flatten().is_some_and(|next| {
0.5 + embedded_leading_aki(next)
> 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<JapaneseClass>],
@ -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<FlowItem> = "あいう".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);

View File

@ -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<ShapedRun>,
paints: &mut Vec<skia::Paint>,
) -> (Vec<FlowCell>, Vec<usize>) {
) -> (Vec<FlowCell>, Vec<usize>, Vec<WarichuNote<'a>>) {
let mut flow = Vec::new();
let mut span_starts = Vec::with_capacity(transforms.len());
let mut notes: Vec<WarichuNote<'a>> = 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<WarichuNote<'a>>,
warichu_splits: &[usize],
runs: &mut Vec<ShapedRun>,
flow: &mut Vec<FlowCell>,
) {
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<usize> {
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<bool> = 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<usize> = 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(&notes, &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]

View File

@ -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
}
}

View File

@ -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<PositionData> {
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<usize>, x: f32| {
let line = |range: Range<usize>, 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<Rect> {
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::*;