🐛 Fix letter spacing and position when flattening text to path (#11555)

* 🐛 Fix letter spacing and position when flattening text to path

* ♻️ Simplify text to path conversion and add flatten tests
This commit is contained in:
Elena Torró 2026-09-11 08:20:22 +02:00 committed by GitHub
parent 37dab75e1a
commit 0913545b41
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1476 additions and 219 deletions

File diff suppressed because it is too large Load Diff

View File

@ -653,3 +653,32 @@ test("Renders background blur on text shapes", async ({ page }) => {
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
});
test("Flattens texts to paths", async ({ page }) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-text-flatten.json");
await workspace.goToWorkspace({
id: "3b0d758a-8c9d-8013-8006-52c8337e5c72",
pageId: "3b0d758a-8c9d-8013-8006-52c8337e5c73",
});
await workspace.waitForFirstRender();
const flattenButton = workspace.page.getByRole("button", {
name: "Flatten",
exact: true,
});
for (const layer of ["Flat spacing", "Flat paragraph", "Flat centered"]) {
await workspace.clickLeafLayer(layer);
const renderCount = await workspace.getRenderCount();
await flattenButton.click();
await workspace.waitForNextRender(renderCount);
}
await workspace.page.keyboard.press("Escape");
await workspace.hideUI();
await expect(workspace.canvas).toHaveScreenshot({ timeout: 10000 });
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -370,6 +370,12 @@
(.-type ^ShapeProxy self)
delegate')))))
(def ^:private base-fields
"Base fields of the `Shape` record this proxy stands in for: `cr/defrecord`
nils them on dissoc instead of removing them, and the schema requires them."
#{:name :x :y :width :height :rotation :selrect :points
:transform :transform-inverse :parent-id :frame-id :flip-x :flip-y})
(defn- impl-dissoc
[self k]
(when shape/*shape-changes*
@ -385,7 +391,9 @@
nil
(.-delegate ^ShapeProxy self))
(let [delegate (.-delegate ^ShapeProxy self)
delegate' (dissoc delegate k)]
delegate' (if (contains? base-fields k)
(assoc delegate k nil)
(dissoc delegate k))]
(if (identical? delegate delegate')
self
(ShapeProxy. (.-id ^ShapeProxy self)

View File

@ -132,10 +132,6 @@ impl FontStore {
&self.fallback_fonts
}
pub fn get_emoji_font(&self, _size: f32) -> Option<Font> {
None
}
pub fn set_source_url(&mut self, alias: &str, url: String) {
if !url.is_empty() {
self.source_urls.insert(alias.to_string(), url);

View File

@ -3,9 +3,9 @@ use crate::{
error::Result,
math::Rect,
shapes::{
add_text_with_tabs, calculate_text_layout_data, set_paint_fill, Paragraph as TextParagraph,
ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind, TextContent,
TextDecorationSegment, VerticalAlign,
add_text_with_tabs, calculate_text_layout_data, set_paint_fill, vertical_align_offset,
Paragraph as TextParagraph, ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind,
TextContent, TextDecorationSegment,
},
utils::{get_fallback_fonts, get_font_collection},
};
@ -379,11 +379,8 @@ fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextC
.filter_map(|group| group.first())
.map(|p| p.height())
.sum();
let vertical_offset = match shape.vertical_align() {
VerticalAlign::Center => (selrect.height() - total_text_height) / 2.0,
VerticalAlign::Bottom => selrect.height() - total_text_height,
_ => 0.0,
};
let vertical_offset =
vertical_align_offset(selrect.height(), total_text_height, shape.vertical_align());
let mut y_accum = base_y + vertical_offset;
for (index, group) in paragraphs.iter().enumerate() {
@ -1235,32 +1232,6 @@ fn calculate_decoration_metrics(
)
}
// How to use it?
// Type::Text(text_content) => {
// self.surfaces
// .apply_mut(&[SurfaceId::Fills, SurfaceId::Strokes], |s| {
// s.canvas().concat(&matrix);
// });
// let text_content = text_content.new_bounds(shape.selrect());
// let paths = text_content.get_paths(antialias);
// shadows::render_text_shadows(self, &shape, &paths, antialias);
// text::render(self, &paths, None, None);
// for stroke in shape.visible_strokes().rev() {
// shadows::render_text_path_stroke_shadows(
// self, &shape, &paths, stroke, antialias,
// );
// strokes::render_text_paths(self, &shape, stroke, &paths, None, None, antialias);
// shadows::render_text_path_stroke_inner_shadows(
// self, &shape, &paths, stroke, antialias,
// );
// }
// shadows::render_text_inner_shadows(self, &shape, &paths, antialias);
// }
#[cfg(test)]
mod tests {
use super::*;

View File

@ -1,5 +1,5 @@
use crate::render::options::RenderOptions;
use crate::shapes::{Shape, TextContent, Type, VerticalAlign};
use crate::shapes::{vertical_align_offset, Shape, TextContent, Type};
use crate::state::{TextEditorState, TextSelection};
use crate::view::Viewbox;
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
@ -112,16 +112,16 @@ fn render_selection(
canvas.restore();
}
fn vertical_align_offset(
fn paragraphs_vertical_offset(
shape: &Shape,
layout_paragraphs: &[&skia_safe::textlayout::Paragraph],
) -> f32 {
let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
match shape.vertical_align() {
VerticalAlign::Center => (shape.selrect().height() - total_height) / 2.0,
VerticalAlign::Bottom => shape.selrect().height() - total_height,
_ => 0.0,
}
vertical_align_offset(
shape.selrect().height(),
total_height,
shape.vertical_align(),
)
}
fn calculate_cursor_rect(
@ -141,7 +141,7 @@ fn calculate_cursor_rect(
return None;
}
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs);
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
if idx == cursor.paragraph {
let char_pos = cursor.offset;
@ -236,7 +236,7 @@ fn calculate_selection_rects(
let paragraphs = text_content.paragraphs();
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
let mut y_offset = paragraphs_vertical_offset(shape, &layout_paragraphs);
for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
let para_height = laid_out_para.height();

View File

@ -264,9 +264,9 @@ impl ToPath for Shape {
Type::SVGRaw(_) => Path::default(),
Type::Text(ref text) => {
let text_paths = TextPaths::new(text.clone());
let text_paths = TextPaths::new(text.new_bounds(self.selrect()));
let mut result = Path::default();
for (path, _) in text_paths.get_paths(true) {
for path in text_paths.get_paths(self.vertical_align()) {
result = join_paths(result, Path::from_skia_path(path));
}

View File

@ -332,7 +332,7 @@ impl TextDecorationSegment {
}
}
fn vertical_align_offset(container_h: f32, content_h: f32, valign: VerticalAlign) -> f32 {
pub fn vertical_align_offset(container_h: f32, content_h: f32, valign: VerticalAlign) -> f32 {
match valign {
VerticalAlign::Center => (container_h - content_h) / 2.0,
VerticalAlign::Bottom => container_h - content_h,
@ -1859,11 +1859,8 @@ pub fn calculate_text_layout_data(
// 2. Position each built paragraph using the heights from step 1.
let total_text_height: f32 = paragraph_heights.iter().sum();
let vertical_offset = match shape.vertical_align() {
VerticalAlign::Center => (selrect_height - total_text_height) / 2.0,
VerticalAlign::Bottom => selrect_height - total_text_height,
_ => 0.0,
};
let vertical_offset =
vertical_align_offset(selrect_height, total_text_height, shape.vertical_align());
let mut paragraph_layouts: Vec<ParagraphLayout> = Vec::new();
let mut y_accum = base_y + vertical_offset;
for (i, group_paragraphs) in built_groups.into_iter().enumerate() {
@ -1971,6 +1968,39 @@ pub fn calculate_position_data(
mod tests {
use super::*;
#[test]
fn vertical_align_top_keeps_the_content_at_the_origin() {
assert_eq!(vertical_align_offset(200.0, 60.0, VerticalAlign::Top), 0.0);
}
#[test]
fn vertical_align_center_takes_half_the_slack() {
assert_eq!(
vertical_align_offset(200.0, 60.0, VerticalAlign::Center),
70.0
);
}
#[test]
fn vertical_align_bottom_takes_all_the_slack() {
assert_eq!(
vertical_align_offset(200.0, 60.0, VerticalAlign::Bottom),
140.0
);
}
#[test]
fn vertical_align_offset_is_negative_when_content_overflows() {
assert_eq!(
vertical_align_offset(60.0, 200.0, VerticalAlign::Center),
-70.0
);
assert_eq!(
vertical_align_offset(60.0, 200.0, VerticalAlign::Bottom),
-140.0
);
}
#[test]
fn capitalize_basic_words() {
assert_eq!(capitalize_words("hello world"), "Hello World");

View File

@ -1,190 +1,81 @@
use crate::get_resources;
use crate::shapes::text::TextContent;
use crate::render::text::decoration_segments;
use crate::shapes::text::{vertical_align_offset, Paragraph, TextContent};
use crate::shapes::VerticalAlign;
use skia_safe::{
self as skia, textlayout::Paragraph as SkiaParagraph, FontMetrics, Point, Rect, TextBlob,
self as skia,
textlayout::{paragraph::VisitorInfo, Paragraph as SkiaParagraph},
Point,
};
use std::ops::Deref;
pub struct TextPaths(TextContent);
// Note: This class is not being currently used.
// It's an example of how to convert texts to paths
#[allow(dead_code)]
impl TextPaths {
pub fn new(text_content: TextContent) -> Self {
Self(text_content)
}
pub fn get_paths(&self, antialias: bool) -> Vec<(skia::Path, skia::Paint)> {
let mut paths = Vec::new();
let mut offset_y = self.bounds.y();
pub fn get_paths(&self, vertical_align: VerticalAlign) -> Vec<skia::Path> {
let layout_width = self.0.get_width(self.bounds.width());
let mut paragraph_builders = self.0.paragraph_builder_group_from_text(None);
let mut paragraphs: Vec<SkiaParagraph> = paragraph_builders
.iter_mut()
.filter_map(|group| group.first_mut())
.map(|paragraph_builder| {
let mut paragraph = paragraph_builder.build();
paragraph.layout(layout_width);
paragraph
})
.collect();
for paragraphs in paragraph_builders.iter_mut() {
for paragraph_builder in paragraphs.iter_mut() {
// 1. Get paragraph and set the width layout
let mut skia_paragraph = paragraph_builder.build();
let text = paragraph_builder.get_text();
let paragraph_width = self.bounds.width();
skia_paragraph.layout(paragraph_width);
let total_height: f32 = paragraphs.iter().map(|p| p.height()).sum();
let mut offset_y = self.bounds.y()
+ vertical_align_offset(self.bounds.height(), total_height, vertical_align);
let mut line_offset_y = offset_y;
// 2. Iterate through each line in the paragraph
for line_metrics in skia_paragraph.get_line_metrics() {
let line_baseline = line_metrics.baseline as f32;
let start = line_metrics.start_index;
let end = line_metrics.end_index;
// 3. Get styles present in line for each text span
let style_metrics = line_metrics.get_style_metrics(start..end);
let mut offset_x = 0.0;
for (i, (start_index, style_metric)) in style_metrics.iter().enumerate() {
let end_index = style_metrics.get(i + 1).map_or(end, |next| next.0);
let start_byte = text
.char_indices()
.nth(*start_index)
.map(|(i, _)| i)
.unwrap_or(0);
let end_byte = text
.char_indices()
.nth(end_index)
.map(|(i, _)| i)
.unwrap_or(text.len());
let span_text = &text[start_byte..end_byte];
let font = skia_paragraph.get_font_at(*start_index);
let blob_offset_x = self.bounds.x() + line_metrics.left as f32 + offset_x;
let blob_offset_y = line_offset_y;
// 4. Get the path for each text span
if let Some((text_path, paint)) = self.generate_text_path(
span_text,
&font,
blob_offset_x,
blob_offset_y,
style_metric,
antialias,
) {
let text_width = font.measure_text(span_text, None).0;
offset_x += text_width;
paths.push((text_path, paint));
}
}
line_offset_y = offset_y + line_baseline;
}
offset_y += skia_paragraph.height();
}
let mut paths = Vec::new();
for (paragraph, text_paragraph) in paragraphs.iter_mut().zip(self.0.paragraphs()) {
let origin = Point::new(self.bounds.x(), offset_y);
Self::collect_paragraph_paths(paragraph, text_paragraph, origin, &mut paths);
offset_y += paragraph.height();
}
paths
}
fn generate_text_path(
&self,
span_text: &str,
font: &skia::Font,
blob_offset_x: f32,
blob_offset_y: f32,
style_metric: &skia::textlayout::StyleMetrics,
antialias: bool,
) -> Option<(skia::Path, skia::Paint)> {
// Convert text to path, including text decoration
// TextBlob might be empty and, in this case, we return None
// This is used to avoid rendering empty paths, but we can
// revisit this logic later
if let Some((text_blob_path, text_blob_bounds)) =
Self::get_text_blob_path(span_text, font, blob_offset_x, blob_offset_y)
{
let text_width = font.measure_text(span_text, None).0;
fn collect_paragraph_paths(
paragraph: &mut SkiaParagraph,
text_paragraph: &Paragraph,
origin: Point,
paths: &mut Vec<skia::Path>,
) {
for deco in decoration_segments(paragraph, text_paragraph, origin.x, origin.y) {
let mut builder = skia::PathBuilder::new();
builder.add_rect(deco.rect(), None, None);
paths.push(builder.detach());
}
let decoration = style_metric.text_style.decoration();
let font_metrics = style_metric.font_metrics;
let blob_left = blob_offset_x;
let blob_top = blob_offset_y;
let blob_height = text_blob_bounds.height();
let text_path = {
let mut pb = skia::PathBuilder::new_path(&text_blob_path);
if let Some(decoration_rect) = self.calculate_text_decoration_rect(
decoration.ty,
font_metrics,
blob_left,
blob_top,
text_width,
blob_height,
) {
pb.add_rect(decoration_rect, None, None);
}
pb.detach()
paragraph.visit(|_: usize, info: Option<&VisitorInfo>| {
let Some(info) = info else {
return;
};
let mut paint = style_metric.text_style.foreground();
paint.set_anti_alias(antialias);
let font = info.font();
let run_origin = origin + info.origin();
let mut builder = skia::PathBuilder::new();
let mut has_glyphs = false;
return Some((text_path, paint));
}
None
}
fn calculate_text_decoration_rect(
&self,
decoration: skia::textlayout::TextDecoration,
font_metrics: FontMetrics,
blob_left: f32,
blob_offset_y: f32,
text_width: f32,
blob_height: f32,
) -> Option<Rect> {
match decoration {
skia::textlayout::TextDecoration::LINE_THROUGH => {
let underline_thickness = font_metrics.underline_thickness().unwrap_or(0.0);
let underline_position = blob_height / 2.0;
Some(Rect::new(
blob_left,
blob_offset_y + underline_position - underline_thickness / 2.0,
blob_left + text_width,
blob_offset_y + underline_position + underline_thickness / 2.0,
))
for (glyph, position) in info.glyphs().iter().zip(info.positions().iter()) {
let Some(glyph_path) = font.get_path(*glyph) else {
continue;
};
builder.add_path(&glyph_path.with_offset(run_origin + *position));
has_glyphs = true;
}
skia::textlayout::TextDecoration::UNDERLINE => {
let underline_thickness = font_metrics.underline_thickness().unwrap_or(0.0);
let underline_position = blob_height - underline_thickness;
Some(Rect::new(
blob_left,
blob_offset_y + underline_position - underline_thickness / 2.0,
blob_left + text_width,
blob_offset_y + underline_position + underline_thickness / 2.0,
))
if has_glyphs {
paths.push(builder.detach());
}
_ => None,
}
}
fn get_text_blob_path(
span_text: &str,
font: &skia::Font,
blob_offset_x: f32,
blob_offset_y: f32,
) -> Option<(skia::Path, skia::Rect)> {
let utf16_text = span_text.encode_utf16().collect::<Vec<u16>>();
let text = unsafe { skia_safe::as_utf16_unchecked(&utf16_text) };
let emoji_font = get_resources().fonts.get_emoji_font(font.size());
let use_font = emoji_font.as_ref().unwrap_or(font);
if let Some(mut text_blob) = TextBlob::from_text(text, use_font) {
let path = SkiaParagraph::get_path(&mut text_blob);
let d = Point::new(blob_offset_x, blob_offset_y);
let offset_path = path.with_offset(d);
let bounds = text_blob.bounds();
return Some((offset_path, *bounds));
}
None
});
}
}