mirror of
https://github.com/penpot/penpot.git
synced 2026-09-11 22:49:30 +00:00
🐛 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:
parent
37dab75e1a
commit
0913545b41
1332
frontend/playwright/data/render-wasm/get-file-text-flatten.json
Normal file
1332
frontend/playwright/data/render-wasm/get-file-text-flatten.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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 |
@ -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)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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::*;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user