From ea4d0e12381687e5e43eacc7e86b5d17dccf5d8a Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 11 Dec 2025 13:29:43 +0100 Subject: [PATCH] :sparkles: Calculate position data in wasm --- frontend/src/app/main/data/persistence.cljs | 2 + frontend/src/app/main/data/workspace.cljs | 56 ++- frontend/src/app/main/refs.cljs | 3 + .../app/main/ui/workspace/viewport/debug.cljs | 24 ++ .../app/main/ui/workspace/viewport_wasm.cljs | 6 + frontend/src/app/render_wasm/api.cljs | 59 ++- .../src/app/render_wasm/deserializers.cljs | 25 ++ render-wasm/src/render.rs | 3 +- render-wasm/src/render/text.rs | 184 ++------- render-wasm/src/shapes/text.rs | 388 +++++++++++++++--- render-wasm/src/wasm/text.rs | 39 +- 11 files changed, 570 insertions(+), 219 deletions(-) diff --git a/frontend/src/app/main/data/persistence.cljs b/frontend/src/app/main/data/persistence.cljs index 4ecbac5458..adcc70cbb3 100644 --- a/frontend/src/app/main/data/persistence.cljs +++ b/frontend/src/app/main/data/persistence.cljs @@ -24,6 +24,8 @@ (def revn-data (atom {})) (def queue-conj (fnil conj #queue [])) +(def force-persist? #(= % ::force-persist)) + (defn- update-status [status] (ptk/reify ::update-status diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index c550c150a5..93db68bd85 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -32,7 +32,7 @@ [app.main.data.helpers :as dsh] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] - [app.main.data.persistence :as-alias dps] + [app.main.data.persistence :as dps] [app.main.data.plugins :as dp] [app.main.data.profile :as du] [app.main.data.project :as dpj] @@ -67,6 +67,7 @@ [app.main.errors] [app.main.features :as features] [app.main.features.pointer-map :as fpmap] + [app.main.refs :as refs] [app.main.repo :as rp] [app.main.router :as rt] [app.render-wasm :as wasm] @@ -379,6 +380,59 @@ (->> (rx/from added) (rx/map process-wasm-object))))))) + (when render-wasm? + (let [local-commits-s + (->> stream + (rx/filter dch/commit?) + (rx/map deref) + (rx/filter #(and (= :local (:source %)) + (not (contains? (:tags %) :position-data)))) + (rx/filter (complement empty?))) + + notifier-s + (rx/merge + (->> local-commits-s (rx/debounce 1000)) + (->> stream (rx/filter dps/force-persist?))) + + objects-s + (rx/from-atom refs/workspace-page-objects {:emit-current-value? true}) + + current-page-id-s + (rx/from-atom refs/current-page-id {:emit-current-value? true})] + + (->> local-commits-s + (rx/buffer-until notifier-s) + (rx/with-latest-from objects-s) + (rx/map + (fn [[commits objects]] + (->> commits + (mapcat :redo-changes) + (filter #(contains? #{:mod-obj :add-obj} (:type %))) + (filter #(cfh/text-shape? objects (:id %))) + (map #(vector + (:id %) + (wasm.api/calculate-position-data (get objects (:id %)))))))) + + (rx/with-latest-from current-page-id-s) + (rx/map + (fn [[text-position-data page-id]] + (let [changes + (->> text-position-data + (mapv (fn [[id position-data]] + {:type :mod-obj + :id id + :page-id page-id + :operations + [{:type :set + :attr :position-data + :val position-data + :ignore-touched true + :ignore-geometry true}]})))] + (dch/commit-changes + {:redo-changes changes :undo-changes [] + :save-undo? false + :tags #{:position-data}}))))))) + (->> stream (rx/filter dch/commit?) (rx/map deref) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index c4454c430a..e4473c1731 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -30,6 +30,9 @@ (def profile (l/derived (l/key :profile) st/state)) +(def current-page-id + (l/derived (l/key :current-page-id) st/state)) + (def team (l/derived (fn [state] (let [team-id (:current-team-id state) diff --git a/frontend/src/app/main/ui/workspace/viewport/debug.cljs b/frontend/src/app/main/ui/workspace/viewport/debug.cljs index c14ad650bf..2b1587533b 100644 --- a/frontend/src/app/main/ui/workspace/viewport/debug.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/debug.cljs @@ -16,6 +16,7 @@ [app.common.geom.shapes.points :as gpo] [app.common.types.shape.layout :as ctl] [app.common.uuid :as uuid] + [app.render-wasm.api :as wasm.api] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -275,3 +276,26 @@ :y2 (:y end-p) :style {:stroke "red" :stroke-width (/ 1 zoom)}}]))])))) + +(mf/defc debug-text-wasm-position-data + {::mf/wrap-props false} + [props] + (let [zoom (unchecked-get props "zoom") + selected-shapes (unchecked-get props "selected-shapes") + + selected-text + (when (and (= (count selected-shapes) 1) (= :text (-> selected-shapes first :type))) + (first selected-shapes)) + + position-data + (when selected-text + (wasm.api/calculate-position-data selected-text))] + + (for [{:keys [x y width height]} position-data] + [:rect {:x x + :y (- y height) + :width width + :height height + :fill "none" + :strokeWidth (/ 1 zoom) + :stroke "red"}]))) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index d3404a2b41..a667d3abc5 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -647,6 +647,12 @@ :hover-top-frame-id @hover-top-frame-id :zoom zoom}]) + (when (dbg/enabled? :text-outline) + [:& wvd/debug-text-wasm-position-data + {:selected-shapes selected-shapes + :objects base-objects + :zoom zoom}]) + (when show-selection-handlers? [:g.selection-handlers {:clipPath "url(#clip-handlers)"} (when-not text-editing? diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index bdf32269fb..01187c08cf 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -23,6 +23,7 @@ [app.main.refs :as refs] [app.main.render :as render] [app.main.store :as st] + [app.main.ui.shapes.text] [app.main.worker :as mw] [app.render-wasm.api.fonts :as f] [app.render-wasm.api.texts :as t] @@ -1002,10 +1003,7 @@ (run! (fn [id] (f/update-text-layout id) - (mw/emit! {:cmd :index/update-text-rect - :page-id (:current-page-id @st/state) - :shape-id id - :dimensions (get-text-dimensions id)}))))) + (update-text-rect! id))))) (defn process-pending ([shapes thumbnails full on-complete] @@ -1361,6 +1359,59 @@ (h/call wasm/internal-module "_end_temp_objects") content))) +(def POSITION-DATA-U8-SIZE 36) +(def POSITION-DATA-U32-SIZE (/ POSITION-DATA-U8-SIZE 4)) + +(defn calculate-position-data + [shape] + (when wasm/context-initialized? + (use-shape (:id shape)) + (let [heapf32 (mem/get-heap-f32) + heapu32 (mem/get-heap-u32) + offset (-> (h/call wasm/internal-module "_calculate_position_data") + (mem/->offset-32)) + length (aget heapu32 offset) + + max-offset (+ offset 1 (* length POSITION-DATA-U32-SIZE)) + + result + (loop [result (transient []) + offset (inc offset)] + (if (< offset max-offset) + (let [entry (dr/read-position-data-entry heapu32 heapf32 offset)] + (recur (conj! result entry) + (+ offset POSITION-DATA-U32-SIZE))) + (persistent! result))) + + result + (->> result + (mapv + (fn [{:keys [paragraph span start-pos end-pos direction x y width height]}] + (let [content (:content shape) + element (-> content :children + (get 0) :children ;; paragraph-set + (get paragraph) :children ;; paragraph + (get span)) + text (subs (:text element) start-pos end-pos)] + + {:x x + :y (+ y height) + :width width + :height height + :direction (dr/translate-direction direction) + :font-family (get element :font-family) + :font-size (get element :font-size) + :font-weight (get element :font-weight) + :text-transform (get element :text-transform) + :text-decoration (get element :text-decoration) + :letter-spacing (get element :letter-spacing) + :font-style (get element :font-style) + :fills (get element :fills) + :text text}))))] + (mem/free) + + result))) + (defn init-wasm-module [module] (let [default-fn (unchecked-get module "default") diff --git a/frontend/src/app/render_wasm/deserializers.cljs b/frontend/src/app/render_wasm/deserializers.cljs index dd718d82c4..09376033d1 100644 --- a/frontend/src/app/render_wasm/deserializers.cljs +++ b/frontend/src/app/render_wasm/deserializers.cljs @@ -45,4 +45,29 @@ :center (gpt/point cx cy) :transform (gmt/matrix a b c d e f)})) +(defn read-position-data-entry + [heapu32 heapf32 offset] + (let [paragraph (aget heapu32 (+ offset 0)) + span (aget heapu32 (+ offset 1)) + start-pos (aget heapu32 (+ offset 2)) + end-pos (aget heapu32 (+ offset 3)) + x (aget heapf32 (+ offset 4)) + y (aget heapf32 (+ offset 5)) + width (aget heapf32 (+ offset 6)) + height (aget heapf32 (+ offset 7)) + direction (aget heapu32 (+ offset 8))] + {:paragraph paragraph + :span span + :start-pos start-pos + :end-pos end-pos + :x x + :y y + :width width + :height height + :direction direction})) +(defn translate-direction + [direction] + (case direction + 0 "rtl" + "ltr")) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 86e338f7c2..2af83dc286 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -9,7 +9,8 @@ mod options; mod shadows; mod strokes; mod surfaces; -mod text; +pub mod text; + mod ui; use skia_safe::{self as skia, Matrix, RRect, Rect}; diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 7285386af5..58f10cbc6c 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -2,18 +2,15 @@ use super::{filters, RenderState, Shape, SurfaceId}; use crate::{ math::Rect, shapes::{ - merge_fills, set_paint_fill, ParagraphBuilderGroup, Stroke, StrokeKind, TextContent, - VerticalAlign, + calculate_position_data, calculate_text_layout_data, merge_fills, set_paint_fill, + ParagraphBuilderGroup, Stroke, StrokeKind, TextContent, }, utils::{get_fallback_fonts, get_font_collection}, }; use skia_safe::{ self as skia, canvas::SaveLayerRec, - textlayout::{ - LineMetrics, Paragraph, ParagraphBuilder, RectHeightStyle, RectWidthStyle, StyleMetrics, - TextDecoration, TextStyle, - }, + textlayout::{ParagraphBuilder, StyleMetrics, TextDecoration, TextStyle}, Canvas, ImageFilter, Paint, Path, }; @@ -241,48 +238,24 @@ fn draw_text( paragraph_builder_groups: &mut [Vec], ) { let text_content = shape.get_text_content(); - let selrect_width = shape.selrect().width(); - let text_width = text_content.get_width(selrect_width); - let text_height = text_content.get_height(selrect_width); - let selrect_height = shape.selrect().height(); - let mut global_offset_y = match shape.vertical_align() { - VerticalAlign::Center => (selrect_height - text_height) / 2.0, - VerticalAlign::Bottom => selrect_height - text_height, - _ => 0.0, - }; + let layout_info = + calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true); let layer_rec = SaveLayerRec::default(); canvas.save_layer(&layer_rec); - let mut previous_line_height = text_content.normalized_line_height(); - for paragraph_builder_group in paragraph_builder_groups { - let group_offset_y = global_offset_y; - let group_len = paragraph_builder_group.len(); - let mut paragraph_offset_y = previous_line_height; - - for (paragraph_index, paragraph_builder) in paragraph_builder_group.iter_mut().enumerate() { - let mut paragraph = paragraph_builder.build(); - paragraph.layout(text_width); - let xy = (shape.selrect().x(), shape.selrect().y() + group_offset_y); - paragraph.paint(canvas, xy); - - let line_metrics = paragraph.get_line_metrics(); - - if paragraph_index == group_len - 1 { - if line_metrics.is_empty() { - paragraph_offset_y = paragraph.ideographic_baseline(); - } else { - paragraph_offset_y = paragraph.height(); - previous_line_height = paragraph.ideographic_baseline(); - } - } - - for line_metrics in paragraph.get_line_metrics().iter() { - render_text_decoration(canvas, ¶graph, paragraph_builder, line_metrics, xy); - } + for para in &layout_info.paragraphs { + para.paragraph.paint(canvas, (para.x, para.y)); + for deco in ¶.decorations { + draw_text_decorations( + canvas, + &deco.text_style, + Some(deco.y), + deco.thickness, + deco.left, + deco.width, + ); } - - global_offset_y += paragraph_offset_y; } } @@ -307,7 +280,7 @@ fn draw_text_decorations( } } -fn calculate_decoration_metrics( +pub fn calculate_decoration_metrics( style_metrics: &Vec<(usize, &StyleMetrics)>, line_baseline: f32, ) -> (f32, Option, f32, Option) { @@ -357,106 +330,6 @@ fn calculate_decoration_metrics( ) } -fn render_text_decoration( - canvas: &Canvas, - skia_paragraph: &Paragraph, - builder: &mut ParagraphBuilder, - line_metrics: &LineMetrics, - xy: (f32, f32), -) { - let style_metrics: Vec<_> = line_metrics - .get_style_metrics(line_metrics.start_index..line_metrics.end_index) - .into_iter() - .collect(); - - let mut current_x_offset = 0.0; - let total_chars = line_metrics.end_index - line_metrics.start_index; - let line_start_offset = line_metrics.left as f32; - - if total_chars == 0 || style_metrics.is_empty() { - return; - } - - let line_baseline = xy.1 + line_metrics.baseline as f32; - let full_text = builder.get_text(); - - // Calculate decoration metrics - let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) = - calculate_decoration_metrics(&style_metrics, line_baseline); - - // Draw decorations per segment (text span) - for (i, (style_start, style_metric)) in style_metrics.iter().enumerate() { - let text_style = &style_metric.text_style; - let style_end = style_metrics - .get(i + 1) - .map(|(next_i, _)| *next_i) - .unwrap_or(line_metrics.end_index); - - let seg_start = (*style_start).max(line_metrics.start_index); - let seg_end = style_end.min(line_metrics.end_index); - if seg_start >= seg_end { - continue; - } - - let start_byte = full_text - .char_indices() - .nth(seg_start) - .map(|(i, _)| i) - .unwrap_or(0); - let end_byte = full_text - .char_indices() - .nth(seg_end) - .map(|(i, _)| i) - .unwrap_or(full_text.len()); - let segment_text = &full_text[start_byte..end_byte]; - - let rects = skia_paragraph.get_rects_for_range( - seg_start..seg_end, - RectHeightStyle::Tight, - RectWidthStyle::Tight, - ); - let (segment_width, actual_x_offset) = if !rects.is_empty() { - let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum(); - let skia_x_offset = rects - .first() - .map(|r| r.rect.left - line_start_offset) - .unwrap_or(0.0); - (total_width, skia_x_offset) - } else { - let font = skia_paragraph.get_font_at(seg_start); - let measured_width = font.measure_text(segment_text, None).0; - (measured_width, current_x_offset) - }; - - let text_left = xy.0 + line_start_offset + actual_x_offset; - let text_width = segment_width; - - // Underline - if text_style.decoration().ty == TextDecoration::UNDERLINE { - draw_text_decorations( - canvas, - text_style, - underline_y, - max_underline_thickness, - text_left, - text_width, - ); - } - // Strikethrough - if text_style.decoration().ty == TextDecoration::LINE_THROUGH { - draw_text_decorations( - canvas, - text_style, - strike_y, - max_strike_thickness, - text_left, - text_width, - ); - } - current_x_offset += segment_width; - } -} - #[allow(dead_code)] fn calculate_total_paragraphs_height(paragraphs: &mut [ParagraphBuilder], width: f32) -> f32 { paragraphs @@ -506,6 +379,29 @@ pub fn render_as_path( } } +#[allow(dead_code)] +pub fn render_position_data( + render_state: &mut RenderState, + surface_id: SurfaceId, + shape: &Shape, + text_content: &TextContent, +) { + let position_data = calculate_position_data(shape, text_content, false); + + let mut paint = skia::Paint::default(); + paint.set_style(skia::PaintStyle::Stroke); + paint.set_color(skia::Color::from_argb(255, 255, 0, 0)); + paint.set_stroke_width(2.); + + for pd in position_data { + let rect = Rect::from_xywh(pd.x, pd.y, pd.width, pd.height); + render_state + .surfaces + .canvas(surface_id) + .draw_rect(rect, &paint); + } +} + // How to use it? // Type::Text(text_content) => { // self.surfaces diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index ecace5d187..f99776d280 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -1,3 +1,4 @@ +use crate::render::text::calculate_decoration_metrics; use crate::{ math::{Bounds, Matrix, Rect}, render::{default_font, DEFAULT_EMOJI_FONT}, @@ -185,6 +186,17 @@ impl TextContentLayout { } } +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct TextDecorationSegment { + pub kind: skia::textlayout::TextDecoration, + pub text_style: skia::textlayout::TextStyle, + pub y: f32, + pub thickness: f32, + pub left: f32, + pub width: f32, +} + /* * Check if the current x,y (in paragraph relative coordinates) is inside * the paragraph @@ -204,6 +216,48 @@ fn intersects(paragraph: &skia_safe::textlayout::Paragraph, x: f32, y: f32) -> b rects.iter().any(|r| r.rect.contains(&Point::new(x, y))) } +// Performs a text auto layout without width limits. +// This should be the same as text_auto_layout. +pub fn build_paragraphs_from_paragraph_builders( + paragraph_builders: &mut [ParagraphBuilderGroup], + width: f32, +) -> Vec> { + let paragraphs = paragraph_builders + .iter_mut() + .map(|builders| { + builders + .iter_mut() + .map(|builder| { + let mut paragraph = builder.build(); + // For auto-width, always layout with infinite width first to get intrinsic width + paragraph.layout(width); + paragraph + }) + .collect() + }) + .collect(); + paragraphs +} + +/// Calculate the normalized line height from paragraph builders +pub fn calculate_normalized_line_height( + paragraph_builders: &mut [ParagraphBuilderGroup], + width: f32, +) -> f32 { + let mut normalized_line_height = 0.0; + for paragraph_builder_group in paragraph_builders.iter_mut() { + for paragraph_builder in paragraph_builder_group.iter_mut() { + let mut paragraph = paragraph_builder.build(); + paragraph.layout(width); + let baseline = paragraph.ideographic_baseline(); + if baseline > normalized_line_height { + normalized_line_height = baseline; + } + } + } + normalized_line_height +} + #[derive(Debug, PartialEq, Clone)] pub struct TextContent { pub paragraphs: Vec, @@ -440,59 +494,15 @@ impl TextContent { paragraph_group } - /// Performs a text auto layout without width limits. - /// This should be the same as text_auto_layout. - fn build_paragraphs_from_paragraph_builders( - &self, - paragraph_builders: &mut [ParagraphBuilderGroup], - width: f32, - ) -> Vec> { - let paragraphs = paragraph_builders - .iter_mut() - .map(|builders| { - builders - .iter_mut() - .map(|builder| { - let mut paragraph = builder.build(); - // For auto-width, always layout with infinite width first to get intrinsic width - paragraph.layout(width); - paragraph - }) - .collect() - }) - .collect(); - paragraphs - } - - /// Calculate the normalized line height from paragraph builders - fn calculate_normalized_line_height( - &self, - paragraph_builders: &mut [ParagraphBuilderGroup], - width: f32, - ) -> f32 { - let mut normalized_line_height = 0.0; - for paragraph_builder_group in paragraph_builders.iter_mut() { - for paragraph_builder in paragraph_builder_group.iter_mut() { - let mut paragraph = paragraph_builder.build(); - paragraph.layout(width); - let baseline = paragraph.ideographic_baseline(); - if baseline > normalized_line_height { - normalized_line_height = baseline; - } - } - } - normalized_line_height - } - /// Performs an Auto Width text layout. fn text_layout_auto_width(&self) -> TextContentLayoutResult { let mut paragraph_builders = self.paragraph_builder_group_from_text(None); let normalized_line_height = - self.calculate_normalized_line_height(&mut paragraph_builders, f32::MAX); + calculate_normalized_line_height(&mut paragraph_builders, f32::MAX); let paragraphs = - self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, f32::MAX); + build_paragraphs_from_paragraph_builders(&mut paragraph_builders, f32::MAX); let (width, height) = paragraphs @@ -521,10 +531,9 @@ impl TextContent { let mut paragraph_builders = self.paragraph_builder_group_from_text(None); let normalized_line_height = - self.calculate_normalized_line_height(&mut paragraph_builders, width); + calculate_normalized_line_height(&mut paragraph_builders, width); - let paragraphs = - self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); + let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); let height = paragraphs .iter() .flatten() @@ -546,10 +555,9 @@ impl TextContent { let mut paragraph_builders = self.paragraph_builder_group_from_text(None); let normalized_line_height = - self.calculate_normalized_line_height(&mut paragraph_builders, width); + calculate_normalized_line_height(&mut paragraph_builders, width); - let paragraphs = - self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); + let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); let paragraph_height = paragraphs .iter() .flatten() @@ -576,8 +584,7 @@ impl TextContent { pub fn get_height(&self, width: f32) -> f32 { let mut paragraph_builders = self.paragraph_builder_group_from_text(None); - let paragraphs = - self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); + let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); let paragraph_height = paragraphs .iter() .flatten() @@ -733,8 +740,7 @@ impl TextContent { let width = self.width(); let mut paragraph_builders = self.paragraph_builder_group_from_text(None); - let paragraphs = - self.build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); + let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); paragraphs .iter() @@ -863,17 +869,17 @@ impl Paragraph { #[derive(Debug, PartialEq, Clone)] pub struct TextSpan { - text: String, - font_family: FontFamily, - font_size: f32, - line_height: f32, - letter_spacing: f32, - font_weight: i32, - font_variant_id: Uuid, - text_decoration: Option, - text_transform: Option, - text_direction: TextDirection, - fills: Vec, + pub text: String, + pub font_family: FontFamily, + pub font_size: f32, + pub line_height: f32, + pub letter_spacing: f32, + pub font_weight: i32, + pub font_variant_id: Uuid, + pub text_decoration: Option, + pub text_transform: Option, + pub text_direction: TextDirection, + pub fills: Vec, } impl TextSpan { @@ -1045,3 +1051,251 @@ impl TextSpan { }) } } + +#[allow(dead_code)] +#[derive(Debug)] +pub struct PositionData { + pub paragraph: u32, + pub span: u32, + pub start_pos: u32, + pub end_pos: u32, + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, + pub direction: u32, +} + +#[allow(dead_code)] +#[derive(Debug)] +pub struct ParagraphLayout { + pub paragraph: skia::textlayout::Paragraph, + pub x: f32, + pub y: f32, + pub spans: Vec, + pub decorations: Vec, +} + +#[allow(dead_code)] +#[derive(Debug)] +pub struct TextLayoutData { + pub position_data: Vec, + pub content_rect: Rect, + pub paragraphs: Vec, +} + +fn direction_to_int(direction: TextDirection) -> u32 { + match direction { + TextDirection::RTL => 0, + TextDirection::LTR => 1, + } +} + +pub fn calculate_text_layout_data( + shape: &Shape, + text_content: &TextContent, + paragraph_builder_groups: &mut [ParagraphBuilderGroup], + skip_position_data: bool, +) -> TextLayoutData { + let selrect_width = shape.selrect().width(); + let text_width = text_content.get_width(selrect_width); + let selrect_height = shape.selrect().height(); + let x = shape.selrect.x(); + let base_y = shape.selrect.y(); + let mut position_data: Vec = Vec::new(); + let mut previous_line_height = text_content.normalized_line_height(); + let text_paragraphs = text_content.paragraphs(); + + // 1. Calculate paragraph heights + let mut paragraph_heights: Vec = Vec::new(); + for paragraph_builder_group in paragraph_builder_groups.iter_mut() { + let group_len = paragraph_builder_group.len(); + let mut paragraph_offset_y = previous_line_height; + for (builder_index, paragraph_builder) in paragraph_builder_group.iter_mut().enumerate() { + let mut skia_paragraph = paragraph_builder.build(); + skia_paragraph.layout(text_width); + if builder_index == group_len - 1 { + if skia_paragraph.get_line_metrics().is_empty() { + paragraph_offset_y = skia_paragraph.ideographic_baseline(); + } else { + paragraph_offset_y = skia_paragraph.height(); + } + } + if builder_index == 0 { + paragraph_heights.push(skia_paragraph.height()); + } + } + previous_line_height = paragraph_offset_y; + } + + // 2. Calculate vertical offset and build paragraphs with positions + 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 mut paragraph_layouts: Vec = Vec::new(); + let mut y_accum = base_y + vertical_offset; + for (i, paragraph_builder_group) in paragraph_builder_groups.iter_mut().enumerate() { + // For each paragraph in the group (e.g., fill, stroke, etc.) + for paragraph_builder in paragraph_builder_group.iter_mut() { + let mut skia_paragraph = paragraph_builder.build(); + skia_paragraph.layout(text_width); + + let spans = if let Some(text_para) = text_paragraphs.get(i) { + text_para.children().to_vec() + } else { + Vec::new() + }; + + // Calculate text decorations for this paragraph + let mut decorations = Vec::new(); + let line_metrics = skia_paragraph.get_line_metrics(); + for line in &line_metrics { + let style_metrics: Vec<_> = line + .get_style_metrics(line.start_index..line.end_index) + .into_iter() + .collect(); + let line_baseline = y_accum + line.baseline as f32; + let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) = + calculate_decoration_metrics(&style_metrics, line_baseline); + for (i, (style_start, style_metric)) in style_metrics.iter().enumerate() { + let text_style = &style_metric.text_style; + let style_end = style_metrics + .get(i + 1) + .map(|(next_i, _)| *next_i) + .unwrap_or(line.end_index); + let seg_start = (*style_start).max(line.start_index); + let seg_end = style_end.min(line.end_index); + if seg_start >= seg_end { + continue; + } + let rects = skia_paragraph.get_rects_for_range( + seg_start..seg_end, + skia::textlayout::RectHeightStyle::Tight, + skia::textlayout::RectWidthStyle::Tight, + ); + let (segment_width, actual_x_offset) = if !rects.is_empty() { + let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum(); + let skia_x_offset = rects + .first() + .map(|r| r.rect.left - line.left as f32) + .unwrap_or(0.0); + (total_width, skia_x_offset) + } else { + (0.0, 0.0) + }; + let text_left = x + line.left as f32 + actual_x_offset; + let text_width = segment_width; + use skia::textlayout::TextDecoration; + if text_style.decoration().ty == TextDecoration::UNDERLINE { + decorations.push(TextDecorationSegment { + kind: TextDecoration::UNDERLINE, + text_style: (*text_style).clone(), + y: underline_y.unwrap_or(line_baseline), + thickness: max_underline_thickness, + left: text_left, + width: text_width, + }); + } + if text_style.decoration().ty == TextDecoration::LINE_THROUGH { + decorations.push(TextDecorationSegment { + kind: TextDecoration::LINE_THROUGH, + text_style: (*text_style).clone(), + y: strike_y.unwrap_or(line_baseline), + thickness: max_strike_thickness, + left: text_left, + width: text_width, + }); + } + } + } + paragraph_layouts.push(ParagraphLayout { + paragraph: skia_paragraph, + x, + y: y_accum, + spans: spans.clone(), + decorations, + }); + } + y_accum += paragraph_heights[i]; + } + + // Calculate position data from paragraph_layouts + if !skip_position_data { + for (paragraph_index, para_layout) in paragraph_layouts.iter().enumerate() { + let current_y = para_layout.y; + let text_paragraph = text_paragraphs.get(paragraph_index); + if let Some(text_para) = text_paragraph { + let mut span_ranges: Vec<(usize, usize, usize)> = vec![]; + let mut cur = 0; + for (span_index, span) in text_para.children().iter().enumerate() { + let text: String = span.apply_text_transform(); + span_ranges.push((cur, cur + text.len(), span_index)); + cur += text.len(); + } + for (start, end, span_index) in span_ranges { + let rects = para_layout.paragraph.get_rects_for_range( + start..end, + RectHeightStyle::Tight, + RectWidthStyle::Tight, + ); + for textbox in rects { + let direction = textbox.direct; + let mut rect = textbox.rect; + let cy = rect.top + rect.height() / 2.0; + let start_pos = para_layout + .paragraph + .get_glyph_position_at_coordinate((rect.left + 0.1, cy)) + .position as usize; + let end_pos = para_layout + .paragraph + .get_glyph_position_at_coordinate((rect.right - 0.1, cy)) + .position as usize; + let start_pos = start_pos.saturating_sub(start); + let end_pos = end_pos.saturating_sub(start); + rect.offset((x, current_y)); + position_data.push(PositionData { + paragraph: paragraph_index as u32, + span: span_index as u32, + start_pos: start_pos as u32, + end_pos: end_pos as u32, + x: rect.x(), + y: rect.y(), + width: rect.width(), + height: rect.height(), + direction: direction_to_int(direction), + }); + } + } + } + } + } + + let content_rect = Rect::from_xywh(x, base_y + vertical_offset, text_width, total_text_height); + TextLayoutData { + position_data, + content_rect, + paragraphs: paragraph_layouts, + } +} + +pub fn calculate_position_data( + shape: &Shape, + text_content: &TextContent, + skip_position_data: bool, +) -> Vec { + let mut text_content = text_content.clone(); + text_content.update_layout(shape.selrect); + + let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); + let layout_info = calculate_text_layout_data( + shape, + &text_content, + &mut paragraph_builders, + skip_position_data, + ); + + layout_info.position_data +} diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index 1ae81d06b9..a8303756f5 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -2,13 +2,14 @@ use macros::ToJs; use super::{fills::RawFillData, fonts::RawFontStyle}; use crate::math::{Matrix, Point}; -use crate::mem; +use crate::mem::{self, SerializableResult}; use crate::shapes::{ self, GrowType, Shape, TextAlign, TextDecoration, TextDirection, TextTransform, Type, }; use crate::utils::{uuid_from_u32, uuid_from_u32_quartet}; use crate::{ - with_current_shape_mut, with_state, with_state_mut, with_state_mut_current_shape, STATE, + with_current_shape, with_current_shape_mut, with_state, with_state_mut, + with_state_mut_current_shape, STATE, }; const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::(); @@ -411,3 +412,37 @@ pub extern "C" fn get_caret_position_at(x: f32, y: f32) -> i32 { }); -1 } + +const RAW_POSITION_DATA_SIZE: usize = size_of::(); + +impl SerializableResult for shapes::PositionData { + type BytesType = [u8; RAW_POSITION_DATA_SIZE]; + + fn from_bytes(bytes: Self::BytesType) -> Self { + unsafe { std::mem::transmute(bytes) } + } + fn as_bytes(&self) -> Self::BytesType { + let ptr = self as *const shapes::PositionData as *const u8; + let bytes: &[u8] = unsafe { std::slice::from_raw_parts(ptr, RAW_POSITION_DATA_SIZE) }; + let mut result = [0; RAW_POSITION_DATA_SIZE]; + result.copy_from_slice(bytes); + result + } + + // The generic trait doesn't know the size of the array. This is why the + // clone needs to be here even if it could be generic. + fn clone_to_slice(&self, slice: &mut [u8]) { + slice.clone_from_slice(&self.as_bytes()); + } +} + +#[no_mangle] +pub extern "C" fn calculate_position_data() -> *mut u8 { + let mut result = Vec::::default(); + with_current_shape!(state, |shape: &Shape| { + if let Type::Text(text_content) = &shape.shape_type { + result = shapes::calculate_position_data(shape, text_content, false); + } + }); + mem::write_vec(result) +}