mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
🐛 Paint CJK underlines with the color of their own span
Skia keys a line's style runs by UTF-8 byte offset, while line indices are UTF-16. Decoration segments searched those runs with UTF-16 offsets, so with CJK text every underline or strike on a line took the paint of the first run, and a decorated span wrapped onto a new line lost its bar. Look up each segment's run by the span's UTF-8 style anchor, falling back to the line's first run for a span continued from an earlier line. AI-assisted-by: claude-opus-5-5
This commit is contained in:
parent
01db64abaf
commit
fce12a9433
@ -5,7 +5,7 @@ use crate::{
|
||||
math::Rect,
|
||||
shapes::{
|
||||
add_horizontal_span, calculate_text_layout_data, set_paint_fill, text_vertical,
|
||||
vertical_align_offset, HorizontalOffsets, Paragraph as TextParagraph,
|
||||
vertical_align_offset, HorizontalOffsets, HorizontalSpanRange, Paragraph as TextParagraph,
|
||||
ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind, TextContent,
|
||||
TextDecorationSegment,
|
||||
},
|
||||
@ -1374,9 +1374,15 @@ fn draw_decoration_segment(canvas: &Canvas, deco: &TextDecorationSegment) {
|
||||
canvas.draw_rect(deco.rect(), &decoration_paint);
|
||||
}
|
||||
|
||||
/// One decorated span clipped to a line: UTF-16 range, decoration and the
|
||||
/// Skia style run it falls in (paint + font metrics).
|
||||
type LineDecoration<'a> = (usize, usize, TextDecoration, &'a StyleMetrics<'a>);
|
||||
/// One decorated span clipped to a line: UTF-16 range, decoration, the span's
|
||||
/// offsets and the Skia style run it falls in (paint + font metrics).
|
||||
type LineDecoration<'a> = (
|
||||
usize,
|
||||
usize,
|
||||
TextDecoration,
|
||||
&'a HorizontalSpanRange,
|
||||
&'a StyleMetrics<'a>,
|
||||
);
|
||||
|
||||
/// UTF-16 ranges of the spans that ask for a decoration we draw.
|
||||
/// Builder-text range of every underlined or struck span. Warichu spans
|
||||
@ -1402,15 +1408,28 @@ fn decorated_span_ranges(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Style run covering `offset`; runs are keyed by their start index.
|
||||
/// Builder-text range of the span holding UTF-16 `offset`.
|
||||
fn span_range_at(offsets: &HorizontalOffsets, offset: usize) -> Option<&HorizontalSpanRange> {
|
||||
let index = offsets
|
||||
.ranges
|
||||
.partition_point(|range| range.builder_start <= offset)
|
||||
.saturating_sub(1);
|
||||
offsets.ranges.get(index)
|
||||
}
|
||||
|
||||
/// Style run of a line covering the UTF-8 `byte_anchor`. Runs are keyed by
|
||||
/// their UTF-8 start, unlike the UTF-16 line indices. An anchor before the
|
||||
/// first run belongs to a span continued from an earlier line, which the
|
||||
/// line's first run carries on.
|
||||
fn style_metric_at<'a>(
|
||||
style_metrics: &[(usize, &'a StyleMetrics<'a>)],
|
||||
offset: usize,
|
||||
byte_anchor: usize,
|
||||
) -> Option<&'a StyleMetrics<'a>> {
|
||||
style_metrics
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(start, _)| *start <= offset)
|
||||
.find(|(start, _)| *start <= byte_anchor)
|
||||
.or_else(|| style_metrics.first())
|
||||
.map(|(_, metrics)| *metrics)
|
||||
}
|
||||
|
||||
@ -1433,10 +1452,7 @@ pub fn decoration_segments(
|
||||
|
||||
let mut segments = Vec::new();
|
||||
for line in &skia_paragraph.get_line_metrics() {
|
||||
let style_metrics: Vec<_> = line
|
||||
.get_style_metrics(line.start_index..line.end_index)
|
||||
.into_iter()
|
||||
.collect();
|
||||
let style_metrics = line.get_style_metrics(0..usize::MAX);
|
||||
let line_baseline = y_accum + line.baseline as f32;
|
||||
|
||||
let line_decorations: Vec<LineDecoration<'_>> = decorated
|
||||
@ -1447,15 +1463,16 @@ pub fn decoration_segments(
|
||||
if seg_start >= seg_end {
|
||||
return None;
|
||||
}
|
||||
let metrics = style_metric_at(&style_metrics, seg_start)?;
|
||||
Some((seg_start, seg_end, kind, metrics))
|
||||
let range = span_range_at(offsets, seg_start)?;
|
||||
let metrics = style_metric_at(&style_metrics, range.style_anchor_start)?;
|
||||
Some((seg_start, seg_end, kind, range, metrics))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) =
|
||||
calculate_decoration_metrics(&line_decorations, line_baseline);
|
||||
|
||||
for (seg_start, seg_end, kind, metrics) in line_decorations {
|
||||
for (seg_start, seg_end, kind, range, metrics) in line_decorations {
|
||||
let rects = skia_paragraph.get_rects_for_range(
|
||||
seg_start..seg_end,
|
||||
skia::textlayout::RectHeightStyle::Tight,
|
||||
@ -1474,14 +1491,9 @@ pub fn decoration_segments(
|
||||
(underline_y, max_underline_thickness)
|
||||
};
|
||||
if kind == TextDecoration::UNDERLINE {
|
||||
let index = offsets
|
||||
.ranges
|
||||
.partition_point(|range| range.builder_start <= seg_start)
|
||||
.saturating_sub(1);
|
||||
if let Some(span) = offsets
|
||||
.ranges
|
||||
.get(index)
|
||||
.and_then(|range| text_paragraph.children().get(range.span))
|
||||
if let Some(span) = text_paragraph
|
||||
.children()
|
||||
.get(range.span)
|
||||
.filter(|span| span.underline_below_ink())
|
||||
{
|
||||
let bottom = rects
|
||||
@ -1515,7 +1527,7 @@ fn calculate_decoration_metrics(
|
||||
let mut underline_y = None;
|
||||
let mut max_strike_thickness: f32 = 0.0;
|
||||
let mut strike_y = None;
|
||||
for (_seg_start, _seg_end, kind, style_metric) in line_decorations.iter() {
|
||||
for (_seg_start, _seg_end, kind, _range, style_metric) in line_decorations.iter() {
|
||||
let font_metrics = style_metric.font_metrics;
|
||||
let font_size = font_metrics
|
||||
.cap_height
|
||||
|
||||
@ -1469,6 +1469,45 @@ fn chinese_underline_stays_below_the_base_ink() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_underline_takes_the_paint_of_its_own_span() {
|
||||
use crate::shapes::text_vertical::test_support::{provider, EM};
|
||||
init_state();
|
||||
let provider = provider(include_bytes!("../../fonts/cjk-locl-test.ttf"));
|
||||
let mut fonts = skia::textlayout::FontCollection::new();
|
||||
fonts.set_asset_font_manager(Some(provider.into()));
|
||||
let dark = skia::Color::from_rgb(0x15, 0x10, 0x35);
|
||||
let pink = skia::Color::from_rgb(0xf2, 0x3a, 0xe5);
|
||||
let spans = [
|
||||
("文文", dark, false),
|
||||
("文文", pink, true),
|
||||
("文", dark, false),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(text, color, underline)| {
|
||||
let mut span = make_span(text, 0.0);
|
||||
span.font_size = EM;
|
||||
span.fills = vec![crate::shapes::Fill::Solid(crate::shapes::SolidColor(color))];
|
||||
if underline {
|
||||
span.text_decoration = Some(skia::textlayout::TextDecoration::UNDERLINE);
|
||||
}
|
||||
span
|
||||
})
|
||||
.collect();
|
||||
let mut paragraph = make_paragraph(spans, 0.0);
|
||||
paragraph.set_language("zh-Hans-CN".to_owned());
|
||||
// Three ems per line: the underlined span starts mid-line and wraps.
|
||||
let (plan, layout) = cjk_layout_paragraph(¶graph, EM * 3.0, &fonts);
|
||||
assert_eq!(layout.get_line_metrics().len(), 2);
|
||||
|
||||
let segments =
|
||||
crate::render::text::decoration_segments(&layout, ¶graph, &plan.offsets, 0.0, 0.0);
|
||||
assert_eq!(segments.len(), 2);
|
||||
for segment in segments {
|
||||
assert_eq!(segment.text_style.foreground().color(), pink);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn below_emphasis_clears_an_underline_on_the_same_side() {
|
||||
let span = TextSpan {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user