mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
🐛 Preserve emphasis across adjacent ruby spans
Recover merged annotation styles on the base character's own line, using separate UTF-16 line anchors and UTF-8 style keys. Include emphasis marks in horizontal text bounds so standalone exports keep each mark whole. Add regression coverage for adjacent readings, wrapped text, emoji line anchors, raster output and export bounds across all text grow modes. Verify 670 renderer tests, formatting and both WASM builds. Add four editable showcase examples and check their layout and exports. AI-assisted-by: GPT-6
This commit is contained in:
parent
7172cdf303
commit
578bd2111f
@ -76,6 +76,9 @@ For character-by-character readings, annotate each character separately.
|
||||
|
||||
Readings use the saved `ruby` attributes and the shared annotation renderer.
|
||||
They support size, alignment, overhang, side, hiding and annotation clearance.
|
||||
Adjacent reading spans recover the style covering each base even when Skia
|
||||
merges their styles. Emphasis marks retain their per-base placement across
|
||||
wraps; text bounds include the marks so individual exports keep them whole.
|
||||
Without a text selection, presentation changes affect existing readings.
|
||||
Horizontal readings form one Latin group above or below the base; vertical
|
||||
readings form one sideways Latin group beside it. Pronunciation is entered
|
||||
|
||||
@ -899,6 +899,47 @@ impl TextContent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Emphasis paints outside the base glyph metrics. Use its placement
|
||||
/// geometry for culling and export bounds as well as position data.
|
||||
fn horizontal_emphasis_bounds(&self, selrect: &Rect, valign: VerticalAlign) -> Option<Rect> {
|
||||
if !self
|
||||
.paragraphs
|
||||
.iter()
|
||||
.flat_map(Paragraph::children)
|
||||
.any(|span| !span.text_emphasis.is_none())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let total_height: f32 = self
|
||||
.layout
|
||||
.paragraphs
|
||||
.iter()
|
||||
.filter_map(|group| group.first())
|
||||
.map(|paragraph| paragraph.height())
|
||||
.sum();
|
||||
let mut y = selrect.y() + vertical_align_offset(selrect.height(), total_height, valign);
|
||||
let plans = self.horizontal_plans();
|
||||
let mut bounds = Rect::new_empty();
|
||||
for ((group, model), plan) in self
|
||||
.layout
|
||||
.paragraphs
|
||||
.iter()
|
||||
.zip(&self.paragraphs)
|
||||
.zip(plans.iter())
|
||||
{
|
||||
let Some(paragraph) = group.first() else {
|
||||
continue;
|
||||
};
|
||||
let x = selrect.x() + plan.x_offset(model, selrect.width());
|
||||
for placement in horizontal_emphasis_placements(model, &plan.offsets, paragraph) {
|
||||
let span = &model.children()[placement.span];
|
||||
bounds.join(horizontal_emphasis_mark_box(span, &placement).with_offset((x, y)));
|
||||
}
|
||||
y += paragraph.height();
|
||||
}
|
||||
(!bounds.is_empty()).then_some(bounds)
|
||||
}
|
||||
|
||||
/// Compute a tight text rect from laid-out Skia paragraphs using glyph
|
||||
/// metrics (fm.top for overshoot, line descent for bottom, line left/width
|
||||
/// for horizontal extent).
|
||||
@ -955,7 +996,11 @@ impl TextContent {
|
||||
}
|
||||
|
||||
if has_lines {
|
||||
Some(Rect::from_ltrb(min_x, min_y, max_x, max_y))
|
||||
let mut bounds = Rect::from_ltrb(min_x, min_y, max_x, max_y);
|
||||
if let Some(emphasis) = self.horizontal_emphasis_bounds(selrect, valign) {
|
||||
bounds.join(emphasis);
|
||||
}
|
||||
Some(bounds)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@ -976,7 +1021,11 @@ impl TextContent {
|
||||
return self.vertical_extrect(selrect, valign);
|
||||
}
|
||||
if self.grow_type() == GrowType::AutoWidth {
|
||||
return self.content_rect(selrect, valign);
|
||||
let mut bounds = self.content_rect(selrect, valign);
|
||||
if let Some(emphasis) = self.horizontal_emphasis_bounds(selrect, valign) {
|
||||
bounds.join(emphasis);
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
|
||||
let layout_matches_container = self
|
||||
|
||||
@ -420,15 +420,18 @@ pub(crate) fn horizontal_span_style(
|
||||
laid_out: &skia::textlayout::Paragraph,
|
||||
range: &HorizontalSpanRange,
|
||||
) -> Option<skia::textlayout::TextStyle> {
|
||||
// Indexed style metrics use builder UTF-8 byte positions, unlike the
|
||||
// UTF-16 offsets of glyph and line ranges.
|
||||
let anchor = range.style_anchor_start..range.style_anchor_start + 1;
|
||||
laid_out.get_line_metrics().iter().find_map(|line| {
|
||||
line.get_style_metrics(anchor.clone())
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|(_, metric)| metric.text_style.clone())
|
||||
})
|
||||
// Lines use UTF-16, while their style entries use UTF-8 run starts.
|
||||
// Skia can merge neighboring spans into one run, so an exact lookup at
|
||||
// the later span's anchor may be empty. Recover the preceding style on
|
||||
// the line containing the base, without carrying a style across lines.
|
||||
let lines = laid_out.get_line_metrics();
|
||||
let line = lines.iter().find(|line| {
|
||||
line.start_index <= range.style_anchor_utf16
|
||||
&& range.style_anchor_utf16 < line.end_including_newline
|
||||
})?;
|
||||
line.get_style_metrics(0..range.style_anchor_start.saturating_add(1))
|
||||
.last()
|
||||
.map(|(_, metric)| metric.text_style.clone())
|
||||
}
|
||||
|
||||
/// Top of the horizontal base em inside SkParagraph's typographic rect, less
|
||||
|
||||
@ -9,7 +9,10 @@ pub(crate) struct HorizontalSpanRange {
|
||||
pub source_start: usize,
|
||||
pub source_end: usize,
|
||||
pub warichu: bool,
|
||||
/// UTF-8 byte anchor for indexed Skia style metrics.
|
||||
pub style_anchor_start: usize,
|
||||
/// UTF-16 anchor for choosing the line containing those metrics.
|
||||
pub style_anchor_utf16: usize,
|
||||
}
|
||||
|
||||
/// Offset maps of one horizontal paragraph between source characters, the
|
||||
@ -181,23 +184,27 @@ fn span_ranges(
|
||||
builder_cursor += leading_break;
|
||||
builder_byte_cursor += leading_break * kinsoku::FORCED_LINE_BREAK.len_utf8();
|
||||
let builder_start = builder_cursor;
|
||||
let style_anchor_start = if warichu {
|
||||
builder_byte_cursor + '\u{FFFC}'.len_utf8()
|
||||
let (style_anchor_start, style_anchor_utf16) = if warichu {
|
||||
(
|
||||
builder_byte_cursor + '\u{FFFC}'.len_utf8(),
|
||||
builder_start + 1,
|
||||
)
|
||||
} else {
|
||||
// A forced separator belongs to the preceding line and has
|
||||
// no style metrics. Anchor annotations to the first source
|
||||
// character rather than a leading layout-only control.
|
||||
let mut shifted = shifted_start;
|
||||
builder_byte_cursor
|
||||
+ text
|
||||
.chars()
|
||||
.take_while(|ch| {
|
||||
let inserted = offset_map.is_inserted(shifted);
|
||||
shifted += ch.len_utf16();
|
||||
inserted
|
||||
})
|
||||
.map(char::len_utf8)
|
||||
.sum::<usize>()
|
||||
let (bytes, utf16) = text
|
||||
.chars()
|
||||
.take_while(|ch| {
|
||||
let inserted = offset_map.is_inserted(shifted);
|
||||
shifted += ch.len_utf16();
|
||||
inserted
|
||||
})
|
||||
.fold((0, 0), |(bytes, utf16), ch| {
|
||||
(bytes + ch.len_utf8(), utf16 + ch.len_utf16())
|
||||
});
|
||||
(builder_byte_cursor + bytes, builder_start + utf16)
|
||||
};
|
||||
builder_cursor += if warichu {
|
||||
HORIZONTAL_WARICHU_BUILDER_LEN
|
||||
@ -221,6 +228,7 @@ fn span_ranges(
|
||||
source_end,
|
||||
warichu,
|
||||
style_anchor_start,
|
||||
style_anchor_utf16,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
||||
@ -1781,3 +1781,176 @@ fn narrow_atomic_overflow_keeps_ruby_and_warichu_with_their_closing_marks() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_horizontal_ruby_bases_retain_each_annotation_style() {
|
||||
use crate::shapes::text_vertical::test_support::provider;
|
||||
init_state();
|
||||
let mut resources =
|
||||
crate::render::RenderResources::try_new_headless().expect("headless resources");
|
||||
let mut fonts = resources.fonts.font_collection().clone();
|
||||
fonts.set_asset_font_manager(Some(
|
||||
provider(include_bytes!("../../fonts/cjk-locl-test.ttf")).into(),
|
||||
));
|
||||
let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources);
|
||||
for width in [240.0, 48.0] {
|
||||
let spans = ["文", "文", "文", "文"]
|
||||
.iter()
|
||||
.map(|text| TextSpan {
|
||||
text: text.to_string(),
|
||||
font_size: 24.0,
|
||||
ruby: "wén".to_string(),
|
||||
text_emphasis: TextEmphasis::FilledDot,
|
||||
annotation_clearance: AnnotationClearance::Auto,
|
||||
..make_span(text, 0.0)
|
||||
})
|
||||
.collect();
|
||||
let mut paragraph = make_paragraph(spans, 0.0);
|
||||
paragraph.set_language("zh-Hant-TW".to_string());
|
||||
let (plan, layout) = cjk_layout_paragraph(¶graph, width, &fonts);
|
||||
assert_eq!(
|
||||
horizontal_emphasis_placements(¶graph, &plan.offsets, &layout).len(),
|
||||
4
|
||||
);
|
||||
for range in &plan.offsets.ranges {
|
||||
let style = horizontal_span_style(&layout, range);
|
||||
assert!(
|
||||
style.is_some(),
|
||||
"missing style for adjacent ruby base {} at {width}px",
|
||||
range.span
|
||||
);
|
||||
assert_eq!(style.unwrap().font_size(), 24.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_ruby_emphasis_paints_a_mark_for_every_base_on_either_side() {
|
||||
init_state();
|
||||
let mut resources =
|
||||
crate::render::RenderResources::try_new_headless().expect("headless resources");
|
||||
let fonts = resources.fonts.font_collection().clone();
|
||||
let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources);
|
||||
for position in [
|
||||
TextEmphasisPosition::OverRight,
|
||||
TextEmphasisPosition::UnderRight,
|
||||
] {
|
||||
let spans = ["A", "B", "C", "D"]
|
||||
.iter()
|
||||
.map(|text| TextSpan {
|
||||
font_size: 48.0,
|
||||
fills: vec![crate::shapes::Fill::Solid(crate::shapes::SolidColor(
|
||||
skia::Color::BLACK,
|
||||
))],
|
||||
ruby: "a".to_string(),
|
||||
text_emphasis: TextEmphasis::FilledDot,
|
||||
text_emphasis_position: position,
|
||||
annotation_clearance: AnnotationClearance::Auto,
|
||||
..make_span(text, 0.0)
|
||||
})
|
||||
.collect();
|
||||
let mut paragraph = make_paragraph(spans, 0.0);
|
||||
paragraph.set_language("zh-Hant-TW".to_string());
|
||||
let (plan, layout) = cjk_layout_paragraph(¶graph, 400.0, &fonts);
|
||||
let placements = horizontal_emphasis_placements(¶graph, &plan.offsets, &layout);
|
||||
assert_eq!(placements.len(), 4);
|
||||
let mut surface = skia::surfaces::raster_n32_premul((440, 200)).unwrap();
|
||||
surface.canvas().clear(skia::Color::TRANSPARENT);
|
||||
paint_horizontal_emphasis(surface.canvas(), ¶graph, &plan, &layout, 20.0, 40.0);
|
||||
let image = surface.image_snapshot();
|
||||
let pixels = image.peek_pixels().expect("raster pixels");
|
||||
for placement in placements {
|
||||
let left = (20.0 + placement.rect.left()).ceil() as i32;
|
||||
let right = (20.0 + placement.rect.right()).floor() as i32;
|
||||
let ink = (0..200).any(|y| (left..right).any(|x| pixels.get_color((x, y)).a() > 0));
|
||||
assert!(
|
||||
ink,
|
||||
"missing {position:?} emphasis on adjacent ruby base {}",
|
||||
placement.span
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotation_style_keeps_utf16_lines_separate_from_utf8_style_keys() {
|
||||
use crate::shapes::text_vertical::test_support::{provider, TEST_FONT};
|
||||
init_state();
|
||||
let mut resources =
|
||||
crate::render::RenderResources::try_new_headless().expect("headless resources");
|
||||
let mut fonts = resources.fonts.font_collection().clone();
|
||||
fonts.set_asset_font_manager(Some(provider(TEST_FONT).into()));
|
||||
let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources);
|
||||
let prefix = make_span("😀\n", 0.0);
|
||||
let spans = vec![
|
||||
prefix,
|
||||
TextSpan {
|
||||
font_size: 24.0,
|
||||
ruby: "a".to_string(),
|
||||
..make_span("A", 0.0)
|
||||
},
|
||||
TextSpan {
|
||||
font_size: 24.0,
|
||||
ruby: "b".to_string(),
|
||||
..make_span("B", 0.0)
|
||||
},
|
||||
];
|
||||
let mut paragraph = make_paragraph(spans, 0.0);
|
||||
paragraph.set_language("zh-Hant-TW".to_string());
|
||||
let (plan, layout) = cjk_layout_paragraph(¶graph, 240.0, &fonts);
|
||||
for (range, expected) in plan.offsets.ranges.iter().zip([16.0, 24.0, 24.0]) {
|
||||
let style = horizontal_span_style(&layout, range).expect("style on the base's line");
|
||||
assert_eq!(style.font_size(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_emphasis_export_bounds_include_every_adjacent_ruby_mark() {
|
||||
init_state();
|
||||
let mut resources =
|
||||
crate::render::RenderResources::try_new_headless().expect("headless resources");
|
||||
let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources);
|
||||
for grow in [
|
||||
crate::shapes::GrowType::Fixed,
|
||||
crate::shapes::GrowType::AutoHeight,
|
||||
crate::shapes::GrowType::AutoWidth,
|
||||
] {
|
||||
let spans = ["A", "B", "C", "D"]
|
||||
.iter()
|
||||
.map(|text| TextSpan {
|
||||
font_size: 48.0,
|
||||
language: "zh-Hant-TW".to_string(),
|
||||
ruby: "a".to_string(),
|
||||
text_emphasis: TextEmphasis::FilledDot,
|
||||
text_decoration: Some(skia::textlayout::TextDecoration::UNDERLINE),
|
||||
annotation_clearance: AnnotationClearance::Auto,
|
||||
..make_span(text, 0.0)
|
||||
})
|
||||
.collect();
|
||||
let mut paragraph = make_paragraph(spans, 0.0);
|
||||
paragraph.set_language("zh-Hant-TW".to_string());
|
||||
let bounds = crate::math::Rect::from_xywh(0.0, 0.0, 360.0, 160.0);
|
||||
let mut content = TextContent::new(bounds, grow);
|
||||
content.add_paragraph(paragraph);
|
||||
content.update_layout(bounds);
|
||||
let mut shape = crate::shapes::Shape::new(Uuid::nil());
|
||||
shape.set_selrect(0.0, 0.0, 360.0, 160.0);
|
||||
let export = content.calculate_bounds(&shape, false).to_rect();
|
||||
let plans = content.horizontal_plans();
|
||||
let paragraph = &content.paragraphs()[0];
|
||||
let placements = horizontal_emphasis_placements(
|
||||
paragraph,
|
||||
&plans[0].offsets,
|
||||
&content.layout.paragraphs[0][0],
|
||||
);
|
||||
assert_eq!(placements.len(), 4);
|
||||
for placement in placements {
|
||||
let mark =
|
||||
horizontal_emphasis_mark_box(¶graph.children()[placement.span], &placement);
|
||||
assert!(
|
||||
export.bottom() >= mark.bottom(),
|
||||
"{grow:?}: export {export:?} crops mark {mark:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user