From 1992dc56783e504de135266a39842f0ee9d0d877 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Tue, 17 Mar 2026 15:37:56 +0100 Subject: [PATCH] :wrench: Text Editor Serialization WIP --- frontend/src/app/render_wasm/text_editor.cljs | 223 ++++++++++++++++++ render-wasm/src/shapes/fills.rs | 32 +++ render-wasm/src/shapes/fonts.rs | 12 + render-wasm/src/wasm/fills.rs | 63 ++++- render-wasm/src/wasm/fills/gradient.rs | 24 +- render-wasm/src/wasm/fills/image.rs | 16 +- render-wasm/src/wasm/text_editor.rs | 195 +++++++++++++-- 7 files changed, 510 insertions(+), 55 deletions(-) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index b94ff16033..f74bed1a44 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -154,6 +154,229 @@ (mem/free) text))))) +;; --------------------------------------------------------------------------- +;; Binary layout constants for TextEditorStyles (must match text_editor.rs) +;; --------------------------------------------------------------------------- +(def ^:const STYLES-HEADER-SIZE 120) + +;; RAW_FILL_DATA_SIZE = 4 (tag+padding) + RawGradientData (largest variant) +;; RawGradientData = 28 header + 16 stops × 8 bytes = 156 +(def ^:const RAW-FILL-DATA-SIZE 160) + +;; MultipleState enum values +(def ^:const MULTIPLE-UNDEFINED 0) +(def ^:const MULTIPLE-SINGLE 1) +(def ^:const MULTIPLE-MULTIPLE 2) + +(def ^:private vertical-align-map + {0 :top 1 :center 2 :bottom}) + +(def ^:private text-align-map + {0 :left 1 :center 2 :right 3 :justify}) + +(def ^:private text-direction-map + {0 :ltr 1 :rtl}) + +(def ^:private text-decoration-map + {0 nil 1 :underline 2 :line-through 3 :overline}) + +(def ^:private text-transform-map + {0 nil 1 :uppercase 2 :lowercase 3 :capitalize}) + +(def ^:private font-style-map + {0 :normal 1 :italic}) + +(defn- read-multiple + "Read a Multiple field from a DataView. Returns :mixed when Multiple, + nil when Undefined, or calls value-fn to read the value when Single." + [dview state-offset value-fn] + (let [state (.getUint32 dview state-offset true)] + (case state + 1 (value-fn) ;; Single + 2 :mixed ;; Multiple + nil))) ;; Undefined + +(defn- u32-argb->hex + "Convert u32 ARGB to #RRGGBB hex string." + [argb] + (let [r (bit-and (unsigned-bit-shift-right argb 16) 0xFF) + g (bit-and (unsigned-bit-shift-right argb 8) 0xFF) + b (bit-and argb 0xFF)] + (str "#" + (.padStart (.toString r 16) 2 "0") + (.padStart (.toString g 16) 2 "0") + (.padStart (.toString b 16) 2 "0")))) + +(defn- u32-argb->opacity + "Extract normalized opacity (0.0-1.0) from u32 ARGB." + [argb] + (/ (bit-and (unsigned-bit-shift-right argb 24) 0xFF) 255.0)) + +(defn- read-gradient-stops + "Read gradient stops from DataView at the given byte offset." + [dview base-offset stop-count] + (let [stops-offset (+ base-offset 28)] ;; stops start at byte 28 within gradient data + (into [] + (map (fn [i] + (let [stop-off (+ stops-offset (* i 8)) + color (.getUint32 dview stop-off true) + offset (.getFloat32 dview (+ stop-off 4) true)] + {:color (u32-argb->hex color) + :opacity (u32-argb->opacity color) + :offset offset}))) + (range stop-count)))) + +(defn- read-fill-from-dview + "Read a single RawFillData entry from DataView at the given byte offset. + Returns a fill map in the standard Penpot text content format." + [dview fill-offset] + (let [tag (.getUint8 dview fill-offset)] + (case tag + ;; Solid fill + 0 (let [color (.getUint32 dview (+ fill-offset 4) true)] + {:fill-color (u32-argb->hex color) + :fill-opacity (u32-argb->opacity color)}) + + ;; Linear gradient + 1 (let [base (+ fill-offset 4) + stop-count (.getUint8 dview (+ base 24))] + {:fill-color-gradient + {:type :linear + :start-x (.getFloat32 dview base true) + :start-y (.getFloat32 dview (+ base 4) true) + :end-x (.getFloat32 dview (+ base 8) true) + :end-y (.getFloat32 dview (+ base 12) true) + :width (.getFloat32 dview (+ base 20) true) + :stops (read-gradient-stops dview base stop-count)} + :fill-opacity (/ (.getUint8 dview (+ base 16)) 255.0)}) + + ;; Radial gradient + 2 (let [base (+ fill-offset 4) + stop-count (.getUint8 dview (+ base 24))] + {:fill-color-gradient + {:type :radial + :start-x (.getFloat32 dview base true) + :start-y (.getFloat32 dview (+ base 4) true) + :end-x (.getFloat32 dview (+ base 8) true) + :end-y (.getFloat32 dview (+ base 12) true) + :width (.getFloat32 dview (+ base 20) true) + :stops (read-gradient-stops dview base stop-count)} + :fill-opacity (/ (.getUint8 dview (+ base 16)) 255.0)}) + + ;; Image fill + 3 (let [base (+ fill-offset 4) + a (.getUint32 dview base true) + b (.getUint32 dview (+ base 4) true) + c (.getUint32 dview (+ base 8) true) + d (.getUint32 dview (+ base 12) true) + opacity (.getUint8 dview (+ base 16)) + flags (.getUint8 dview (+ base 17)) + width (.getInt32 dview (+ base 20) true) + height (.getInt32 dview (+ base 24) true)] + {:fill-image + {:id (uuid/from-unsigned-parts a b c d) + :width width + :height height + :keep-aspect-ratio (not (zero? (bit-and flags 1)))} + :fill-opacity (/ opacity 255.0)}) + + ;; Unknown tag + nil))) + +(defn text-editor-get-current-styles + "Read the current text editor styles from WASM. Returns a map with the + style values for the current selection, or nil if no selection/focus. + Multiple fields return :mixed when spans have different values." + [] + (when wasm/context-initialized? + (let [ptr (h/call wasm/internal-module "_text_editor_get_current_styles")] + (when (and ptr (not (zero? ptr))) + (let [heap-u8 (mem/get-heap-u8) + dview (js/DataView. (.-buffer heap-u8) (.-byteOffset heap-u8)) + + ;; Read fills count first to know total size + fills-count (.getUint32 dview (+ ptr 116) true) + + ;; Read scalar Multiple fields + vertical-align (get vertical-align-map + (.getUint32 dview ptr true) + :top) + + text-align + (read-multiple dview (+ ptr 4) + #(get text-align-map (.getUint32 dview (+ ptr 8) true))) + + text-direction + (read-multiple dview (+ ptr 12) + #(get text-direction-map (.getUint32 dview (+ ptr 16) true))) + + text-decoration + (read-multiple dview (+ ptr 20) + #(get text-decoration-map (.getUint32 dview (+ ptr 24) true))) + + text-transform + (read-multiple dview (+ ptr 28) + #(get text-transform-map (.getUint32 dview (+ ptr 32) true))) + + font-size + (read-multiple dview (+ ptr 36) + #(.getFloat32 dview (+ ptr 40) true)) + + font-weight + (read-multiple dview (+ ptr 44) + #(.getInt32 dview (+ ptr 48) true)) + + line-height + (read-multiple dview (+ ptr 52) + #(.getFloat32 dview (+ ptr 56) true)) + + letter-spacing + (read-multiple dview (+ ptr 60) + #(.getFloat32 dview (+ ptr 64) true)) + + font-family + (read-multiple dview (+ ptr 68) + (fn [] + (let [a (.getUint32 dview (+ ptr 72) true) + b (.getUint32 dview (+ ptr 76) true) + c (.getUint32 dview (+ ptr 80) true) + d (.getUint32 dview (+ ptr 84) true)] + {:id (uuid/from-unsigned-parts a b c d) + :style (get font-style-map (.getUint32 dview (+ ptr 88) true)) + :weight (.getUint32 dview (+ ptr 92) true)}))) + + font-variant-id + (read-multiple dview (+ ptr 96) + (fn [] + (let [a (.getUint32 dview (+ ptr 100) true) + b (.getUint32 dview (+ ptr 104) true) + c (.getUint32 dview (+ ptr 108) true) + d (.getUint32 dview (+ ptr 112) true)] + (uuid/from-unsigned-parts a b c d)))) + + ;; Read fills + fills + (into [] + (keep (fn [i] + (read-fill-from-dview dview + (+ ptr STYLES-HEADER-SIZE + (* i RAW-FILL-DATA-SIZE))))) + (range fills-count))] + + (mem/free) + {:vertical-align vertical-align + :text-align text-align + :text-direction text-direction + :text-decoration text-decoration + :text-transform text-transform + :font-size font-size + :font-weight font-weight + :line-height line-height + :letter-spacing letter-spacing + :font-family font-family + :font-variant-id font-variant-id + :fills fills}))))) + (defn text-editor-get-active-shape-id [] (when wasm/context-initialized? diff --git a/render-wasm/src/shapes/fills.rs b/render-wasm/src/shapes/fills.rs index 5b61b3ee2a..4242f9c069 100644 --- a/render-wasm/src/shapes/fills.rs +++ b/render-wasm/src/shapes/fills.rs @@ -35,6 +35,30 @@ impl Gradient { gradient } + pub fn start(&self) -> (f32, f32) { + self.start + } + + pub fn end(&self) -> (f32, f32) { + self.end + } + + pub fn opacity(&self) -> u8 { + self.opacity + } + + pub fn width(&self) -> f32 { + self.width + } + + pub fn colors(&self) -> &[Color] { + &self.colors + } + + pub fn offsets(&self) -> &[f32] { + &self.offsets + } + fn add_stops(&mut self, stops: &[(Color, f32)]) { let colors = stops.iter().map(|(color, _)| *color); let offsets = stops.iter().map(|(_, offset)| *offset); @@ -123,6 +147,14 @@ impl ImageFill { self.opacity } + pub fn width(&self) -> i32 { + self.width + } + + pub fn height(&self) -> i32 { + self.height + } + pub fn keep_aspect_ratio(&self) -> bool { self.keep_aspect_ratio } diff --git a/render-wasm/src/shapes/fonts.rs b/render-wasm/src/shapes/fonts.rs index 86ab5d3897..786e7864dc 100644 --- a/render-wasm/src/shapes/fonts.rs +++ b/render-wasm/src/shapes/fonts.rs @@ -30,6 +30,18 @@ impl FontFamily { Self { id, style, weight } } + pub fn id(&self) -> Uuid { + self.id + } + + pub fn style(&self) -> FontStyle { + self.style + } + + pub fn weight(&self) -> u32 { + self.weight + } + pub fn alias(&self) -> String { format!("{}", self) } diff --git a/render-wasm/src/wasm/fills.rs b/render-wasm/src/wasm/fills.rs index 5813a921d7..95ee89116f 100644 --- a/render-wasm/src/wasm/fills.rs +++ b/render-wasm/src/wasm/fills.rs @@ -36,6 +36,39 @@ impl From for shapes::Fill { } } +fn color_to_u32(color: &shapes::Color) -> u32 { + ((color.a() as u32) << 24) + | ((color.r() as u32) << 16) + | ((color.g() as u32) << 8) + | (color.b() as u32) +} + +fn gradient_to_raw(g: &shapes::Gradient) -> gradient::RawGradientData { + let mut stops = [gradient::RawStopData { + color: 0, + offset: 0.0, + }; gradient::MAX_GRADIENT_STOPS]; + let colors = g.colors(); + let offsets = g.offsets(); + let stop_count = colors.len().min(gradient::MAX_GRADIENT_STOPS); + for i in 0..stop_count { + stops[i] = gradient::RawStopData { + color: color_to_u32(&colors[i]), + offset: offsets[i], + }; + } + gradient::RawGradientData { + start_x: g.start().0, + start_y: g.start().1, + end_x: g.end().0, + end_y: g.end().1, + opacity: g.opacity(), + width: g.width(), + stop_count: stop_count as u8, + stops, + } +} + impl TryFrom<&shapes::Fill> for RawFillData { type Error = String; @@ -43,19 +76,29 @@ impl TryFrom<&shapes::Fill> for RawFillData { match fill { shapes::Fill::Solid(shapes::SolidColor(color)) => { Ok(RawFillData::Solid(solid::RawSolidData { - color: ((color.a() as u32) << 24) - | ((color.r() as u32) << 16) - | ((color.g() as u32) << 8) - | (color.b() as u32), + color: color_to_u32(color), })) } - shapes::Fill::LinearGradient(_) => { - Err("LinearGradient serialization is not implemented".to_string()) + shapes::Fill::LinearGradient(g) => Ok(RawFillData::Linear(gradient_to_raw(g))), + shapes::Fill::RadialGradient(g) => Ok(RawFillData::Radial(gradient_to_raw(g))), + shapes::Fill::Image(img) => { + let id_bytes: [u8; 16] = img.id().into(); + let a = u32::from_le_bytes(id_bytes[0..4].try_into().unwrap()); + let b = u32::from_le_bytes(id_bytes[4..8].try_into().unwrap()); + let c = u32::from_le_bytes(id_bytes[8..12].try_into().unwrap()); + let d = u32::from_le_bytes(id_bytes[12..16].try_into().unwrap()); + let flags = if img.keep_aspect_ratio() { 1u8 } else { 0u8 }; + Ok(RawFillData::Image(image::RawImageFillData { + a, + b, + c, + d, + opacity: img.opacity(), + flags, + width: img.width(), + height: img.height(), + })) } - shapes::Fill::RadialGradient(_) => { - Err("RadialGradient serialization is not implemented".to_string()) - } - shapes::Fill::Image(_) => Err("Image fill serialization is not implemented".to_string()), } } } diff --git a/render-wasm/src/wasm/fills/gradient.rs b/render-wasm/src/wasm/fills/gradient.rs index c656cf197e..13cfc7dc49 100644 --- a/render-wasm/src/wasm/fills/gradient.rs +++ b/render-wasm/src/wasm/fills/gradient.rs @@ -1,20 +1,20 @@ use crate::shapes::{Color, Gradient}; -const MAX_GRADIENT_STOPS: usize = 16; +pub(crate) const MAX_GRADIENT_STOPS: usize = 16; #[derive(Debug, PartialEq, Clone, Copy)] #[repr(C)] #[repr(align(4))] pub struct RawGradientData { - start_x: f32, - start_y: f32, - end_x: f32, - end_y: f32, - opacity: u8, + pub(crate) start_x: f32, + pub(crate) start_y: f32, + pub(crate) end_x: f32, + pub(crate) end_y: f32, + pub(crate) opacity: u8, // 24-bit padding here, reserved for future use - width: f32, - stop_count: u8, - stops: [RawStopData; MAX_GRADIENT_STOPS], + pub(crate) width: f32, + pub(crate) stop_count: u8, + pub(crate) stops: [RawStopData; MAX_GRADIENT_STOPS], } impl RawGradientData { @@ -29,9 +29,9 @@ impl RawGradientData { #[derive(Debug, PartialEq, Clone, Copy)] #[repr(C)] -struct RawStopData { - color: u32, - offset: f32, +pub(crate) struct RawStopData { + pub(crate) color: u32, + pub(crate) offset: f32, } impl RawStopData { diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs index f0e5b36526..f6034f10d0 100644 --- a/render-wasm/src/wasm/fills/image.rs +++ b/render-wasm/src/wasm/fills/image.rs @@ -15,15 +15,15 @@ const IMAGE_HEADER_SIZE: usize = 36; // 32 bytes for IDs + 4 bytes for is_thumbn #[repr(C)] #[repr(align(4))] pub struct RawImageFillData { - a: u32, - b: u32, - c: u32, - d: u32, - opacity: u8, - flags: u8, + pub(crate) a: u32, + pub(crate) b: u32, + pub(crate) c: u32, + pub(crate) d: u32, + pub(crate) opacity: u8, + pub(crate) flags: u8, // 16-bit padding here, reserved for future use - width: i32, - height: i32, + pub(crate) width: i32, + pub(crate) height: i32, } impl From for ImageFill { diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index ba5969f42f..a79a4c31d7 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -2,10 +2,14 @@ use macros::{wasm_error, ToJs}; use crate::math::{Matrix, Point, Rect}; use crate::mem; -use crate::shapes::{Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; +use crate::shapes::{ + FontStyle, Shape, TextContent, TextDecoration, TextPositionWithAffinity, TextTransform, Type, + VerticalAlign, +}; use crate::state::{TextEditorEvent, TextSelection}; use crate::utils::uuid_from_u32_quartet; use crate::utils::uuid_to_u32_quartet; +use crate::wasm::fills::write_fills_to_bytes; use crate::wasm::text::helpers as text_helpers; use crate::{with_state, with_state_mut, STATE}; use skia_safe::Color; @@ -517,6 +521,87 @@ pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 { }) } +/// Serialize a skia TextAlign to its raw u32 representation. +fn text_align_to_raw(align: &skia_safe::textlayout::TextAlign) -> u32 { + use skia_safe::textlayout::TextAlign; + match *align { + TextAlign::Left => 0, + TextAlign::Center => 1, + TextAlign::Right => 2, + TextAlign::Justify => 3, + _ => 0, + } +} + +/// Serialize a skia TextDirection to its raw u32 representation. +fn text_direction_to_raw(dir: &skia_safe::textlayout::TextDirection) -> u32 { + use skia_safe::textlayout::TextDirection; + match *dir { + TextDirection::LTR => 0, + TextDirection::RTL => 1, + } +} + +/// Serialize an Option to its raw u32 representation. +fn text_decoration_to_raw(dec: &Option) -> u32 { + match dec.as_ref() { + None => 0, + Some(d) if *d == TextDecoration::UNDERLINE => 1, + Some(d) if *d == TextDecoration::LINE_THROUGH => 2, + Some(d) if *d == TextDecoration::OVERLINE => 3, + _ => 0, + } +} + +/// Serialize an Option to its raw u32 representation. +fn text_transform_to_raw(transform: &Option) -> u32 { + match transform.as_ref() { + None => 0, + Some(TextTransform::Uppercase) => 1, + Some(TextTransform::Lowercase) => 2, + Some(TextTransform::Capitalize) => 3, + } +} + +/// Serialize a FontStyle to its raw u32 representation. +fn font_style_to_raw(style: &FontStyle) -> u32 { + match *style { + FontStyle::Normal => 0, + FontStyle::Italic => 1, + } +} + +/// Binary layout for TextEditorStyles (all offsets in bytes, 4-byte aligned): +/// +/// Offset Size Field +/// ------ ---- ----- +/// 0 4 vertical_align (u32) +/// 4 4 text_align.state (u32) +/// 8 4 text_align.value (u32) +/// 12 4 text_direction.state (u32) +/// 16 4 text_direction.value (u32) +/// 20 4 text_decoration.state (u32) +/// 24 4 text_decoration.value (u32) +/// 28 4 text_transform.state (u32) +/// 32 4 text_transform.value (u32) +/// 36 4 font_size.state (u32) +/// 40 4 font_size.value (f32) +/// 44 4 font_weight.state (u32) +/// 48 4 font_weight.value (i32) +/// 52 4 line_height.state (u32) +/// 56 4 line_height.value (f32) +/// 60 4 letter_spacing.state (u32) +/// 64 4 letter_spacing.value (f32) +/// 68 4 font_family.state (u32) +/// 72 16 font_family.id (Uuid: 4×u32 LE) +/// 88 4 font_family.style (u32) +/// 92 4 font_family.weight (u32) +/// 96 4 font_variant_id.state (u32) +/// 100 16 font_variant_id.value (Uuid: 4×u32 LE) +/// 116 4 fills_count (u32) +/// 120+ var fills (RAW_FILL_DATA_SIZE each, same format as set_shape_fills) +const STYLES_HEADER_SIZE: usize = 120; + #[no_mangle] pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { with_state_mut!(state, { @@ -528,38 +613,98 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { return std::ptr::null_mut(); } - let Some(shape_id) = state.text_editor_state.active_shape_id else { + let Some(_shape_id) = state.text_editor_state.active_shape_id else { return std::ptr::null_mut(); }; - let Some(shape) = state.shapes.get(&shape_id) else { - return std::ptr::null_mut(); - }; + let styles = &state.text_editor_state.current_styles; - let Type::Text(text_content) = &shape.shape_type else { - return std::ptr::null_mut(); - }; + // Serialize fills using the existing RawFillData format + let fill_bytes = write_fills_to_bytes(styles.fills.clone()); + let total_size = STYLES_HEADER_SIZE + fill_bytes.len(); + let mut bytes = vec![0u8; total_size]; - let mut bytes = vec![0u8; 1024]; - bytes[0..4].copy_from_slice(&u32::to_le_bytes(state.text_editor_state.current_styles.vertical_align as u32)); - bytes[4..8].copy_from_slice(&u32::to_le_bytes(*state.text_editor_state.current_styles.text_align.state() as u32)); - bytes[8..12].copy_from_slice(&u32::to_le_bytes(*state.text_editor_state.current_styles.text_direction.state() as u32)); - bytes[12..16].copy_from_slice(&u32::to_le_bytes(*state.text_editor_state.current_styles.text_decoration.state() as u32)); - bytes[16..20].copy_from_slice(&u32::to_le_bytes(*state.text_editor_state.current_styles.text_transform.state() as u32)); - bytes[20..24].copy_from_slice(&u32::to_le_bytes(*state.text_editor_state.current_styles.font_family.state() as u32)); - bytes[24..28].copy_from_slice(&u32::to_le_bytes(*state.text_editor_state.current_styles.font_size.state() as u32)); - bytes[28..32].copy_from_slice(&u32::to_le_bytes(*state.text_editor_state.current_styles.font_weight.state() as u32)); - bytes[32..36].copy_from_slice(&u32::to_le_bytes(*state.text_editor_state.current_styles.font_variant_id.state() as u32)); - bytes[36..40].copy_from_slice(&u32::to_le_bytes(*state.text_editor_state.current_styles.line_height.state() as u32)); - bytes[40..44].copy_from_slice(&u32::to_le_bytes(*state.text_editor_state.current_styles.letter_spacing.state() as u32)); - bytes[44..48].copy_from_slice(&usize::to_le_bytes(state.text_editor_state.current_styles.fills.len())); + // vertical_align + bytes[0..4].copy_from_slice(&u32::to_le_bytes(styles.vertical_align as u32)); - let offset: usize = 48; - for fill in state.text_editor_state.current_styles.fills.iter() { - + // text_align: state + value + bytes[4..8].copy_from_slice(&u32::to_le_bytes(*styles.text_align.state() as u32)); + if let Some(val) = styles.text_align.value() { + bytes[8..12].copy_from_slice(&u32::to_le_bytes(text_align_to_raw(val))); } - std::ptr::null_mut() + // text_direction: state + value + bytes[12..16].copy_from_slice(&u32::to_le_bytes(*styles.text_direction.state() as u32)); + if let Some(val) = styles.text_direction.value() { + bytes[16..20].copy_from_slice(&u32::to_le_bytes(text_direction_to_raw(val))); + } + + // text_decoration: state + value + bytes[20..24].copy_from_slice(&u32::to_le_bytes(*styles.text_decoration.state() as u32)); + // text_decoration merges Option, so value() is Option> + // When Single: value() is Some(inner) where inner is the Option + if styles.text_decoration.is_single() { + let inner = styles.text_decoration.value(); + // inner is &Option: the Some/None tells us Single vs not, + // but for text_decoration the merged value IS an Option. + // So we serialize the inner Option directly. + bytes[24..28].copy_from_slice(&u32::to_le_bytes(text_decoration_to_raw(inner))); + } + + // text_transform: state + value (same pattern as text_decoration) + bytes[28..32].copy_from_slice(&u32::to_le_bytes(*styles.text_transform.state() as u32)); + if styles.text_transform.is_single() { + let inner = styles.text_transform.value(); + bytes[32..36].copy_from_slice(&u32::to_le_bytes(text_transform_to_raw(inner))); + } + + // font_size: state + value (f32) + bytes[36..40].copy_from_slice(&u32::to_le_bytes(*styles.font_size.state() as u32)); + if let Some(val) = styles.font_size.value() { + bytes[40..44].copy_from_slice(&f32::to_le_bytes(*val)); + } + + // font_weight: state + value (i32) + bytes[44..48].copy_from_slice(&u32::to_le_bytes(*styles.font_weight.state() as u32)); + if let Some(val) = styles.font_weight.value() { + bytes[48..52].copy_from_slice(&i32::to_le_bytes(*val)); + } + + // line_height: state + value (f32) + bytes[52..56].copy_from_slice(&u32::to_le_bytes(*styles.line_height.state() as u32)); + if let Some(val) = styles.line_height.value() { + bytes[56..60].copy_from_slice(&f32::to_le_bytes(*val)); + } + + // letter_spacing: state + value (f32) + bytes[60..64].copy_from_slice(&u32::to_le_bytes(*styles.letter_spacing.state() as u32)); + if let Some(val) = styles.letter_spacing.value() { + bytes[64..68].copy_from_slice(&f32::to_le_bytes(*val)); + } + + // font_family: state + id (16 bytes) + style (u32) + weight (u32) + bytes[68..72].copy_from_slice(&u32::to_le_bytes(*styles.font_family.state() as u32)); + if let Some(family) = styles.font_family.value() { + let id_bytes: [u8; 16] = family.id().into(); + bytes[72..88].copy_from_slice(&id_bytes); + bytes[88..92].copy_from_slice(&u32::to_le_bytes(font_style_to_raw(&family.style()))); + bytes[92..96].copy_from_slice(&u32::to_le_bytes(family.weight())); + } + + // font_variant_id: state + uuid (16 bytes) + bytes[96..100].copy_from_slice(&u32::to_le_bytes(*styles.font_variant_id.state() as u32)); + if let Some(variant_id) = styles.font_variant_id.value() { + let id_bytes: [u8; 16] = (*variant_id).into(); + bytes[100..116].copy_from_slice(&id_bytes); + } + + // fills_count + fill data + bytes[116..120].copy_from_slice(&u32::to_le_bytes(styles.fills.len() as u32)); + if !fill_bytes.is_empty() { + bytes[STYLES_HEADER_SIZE..].copy_from_slice(&fill_bytes); + } + + mem::write_bytes(bytes) }) }