mirror of
https://github.com/penpot/penpot.git
synced 2026-10-02 16:56:16 +00:00
bugfixes after w3c samples
This commit is contained in:
parent
a4a0be38d6
commit
3b934911f1
@ -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);
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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(" ");
|
||||
|
||||
@ -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(¶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::<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);
|
||||
|
||||
@ -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(¬e_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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(¬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]
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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::*;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user