alonso.torres 0dd12b09e9 ✨ Add a CJK text layout proof of concept
Choose paragraph layout rules from an optional language tag. Pass language
to horizontal and vertical shaping, add basic Chinese punctuation rules,
and add experimental Korean spaces-only wrapping.

Keep text boundaries grapheme-safe and preserve paragraph profiles across
editor splits and merges. Add reproducible font fixtures for localized
glyphs and regression tests for the shared WASM protocol and layout paths.

Validation: renderer, common JVM/JS, frontend and exporter tests pass.
Both WASM targets build. Full CI still reports baseline lint and format
errors and one backend multipart-upload test failure.

AI-assisted-by: GPT-6
2026-10-27 18:00:32 +01:00

645 lines
21 KiB
Rust

use macros::{wasm_error, ToJs};
use super::{fills::RawFillData, fonts::RawFontStyle};
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, with_current_shape_mut, with_state};
use crate::error::Error;
pub mod helpers;
mod japanese_layout;
pub use japanese_layout::*;
const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::<RawTextSpan>();
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
const MAX_TEXT_FILLS: usize = 8;
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
pub enum RawTextAlign {
Left = 0,
Center = 1,
Right = 2,
Justify = 3,
}
impl From<RawTextAlign> for TextAlign {
fn from(value: RawTextAlign) -> Self {
match value {
RawTextAlign::Left => TextAlign::Left,
RawTextAlign::Center => TextAlign::Center,
RawTextAlign::Right => TextAlign::Right,
RawTextAlign::Justify => TextAlign::Justify,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
pub enum RawTextDirection {
Ltr = 0,
Rtl = 1,
}
impl From<RawTextDirection> for TextDirection {
fn from(value: RawTextDirection) -> Self {
match value {
RawTextDirection::Ltr => TextDirection::LTR,
RawTextDirection::Rtl => TextDirection::RTL,
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
pub enum RawTextDecoration {
None = 0,
Underline = 1,
LineThrough = 2,
Overline = 3,
}
impl From<RawTextDecoration> for Option<TextDecoration> {
fn from(value: RawTextDecoration) -> Self {
match value {
RawTextDecoration::None => None,
RawTextDecoration::Underline => Some(TextDecoration::UNDERLINE),
RawTextDecoration::LineThrough => Some(TextDecoration::LINE_THROUGH),
RawTextDecoration::Overline => Some(TextDecoration::OVERLINE),
}
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
pub enum RawTextTransform {
None = 0,
Uppercase = 1,
Lowercase = 2,
Capitalize = 3,
}
impl From<RawTextTransform> for Option<TextTransform> {
fn from(value: RawTextTransform) -> Self {
match value {
RawTextTransform::None => None,
RawTextTransform::Uppercase => Some(TextTransform::Uppercase),
RawTextTransform::Lowercase => Some(TextTransform::Lowercase),
RawTextTransform::Capitalize => Some(TextTransform::Capitalize),
}
}
}
#[repr(C)]
#[repr(align(4))]
#[derive(Debug, Clone, Copy)]
pub struct RawParagraphData {
span_count: u32,
text_align: RawTextAlign,
text_direction: RawTextDirection,
text_decoration: RawTextDecoration,
text_transform: RawTextTransform,
writing_mode: u8,
text_orientation: u8,
korean_wrap: u8,
// Padding for the CLJS writer's 4-byte-aligned layout; always zero.
_padding: [u8; 1],
line_height: f32,
letter_spacing: f32,
language_length: u32,
}
impl From<[u8; RAW_PARAGRAPH_DATA_SIZE]> for RawParagraphData {
fn from(bytes: [u8; RAW_PARAGRAPH_DATA_SIZE]) -> Self {
unsafe { std::mem::transmute(bytes) }
}
}
impl TryFrom<&[u8]> for RawParagraphData {
type Error = String;
fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
let data: [u8; RAW_PARAGRAPH_DATA_SIZE] = bytes
.get(0..RAW_PARAGRAPH_DATA_SIZE)
.and_then(|slice| slice.try_into().ok())
.ok_or("Invalid paragraph data".to_string())?;
Ok(RawParagraphData::from(data))
}
}
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RawTextSpan {
font_style: RawFontStyle,
text_decoration: RawTextDecoration,
text_transform: RawTextTransform,
text_direction: RawTextDirection,
text_orientation: u8,
text_combine_upright: u8,
text_emphasis: u8,
warichu: u8,
font_features: u8,
annotation_clearance: u8,
ruby_size: u8,
ruby_align: u8,
ruby_overhang: u8,
ruby_side: u8,
// Padding for the CLJS writer's 4-byte-aligned layout; always zero.
_padding: [u8; 2],
font_size: f32,
line_height: f32,
letter_spacing: f32,
font_weight: i32,
font_id: [u32; 4],
font_family: [u8; 4],
font_variant_id: [u32; 4], // TODO: maybe add RawUUID type
text_length: u32,
ruby_length: u32,
fill_count: u32,
fills: [RawFillData; MAX_TEXT_FILLS],
}
impl From<[u8; RAW_SPAN_DATA_SIZE]> for RawTextSpan {
fn from(bytes: [u8; RAW_SPAN_DATA_SIZE]) -> Self {
unsafe { std::mem::transmute(bytes) }
}
}
impl TryFrom<&[u8]> for RawTextSpan {
type Error = String;
fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
let data: [u8; RAW_SPAN_DATA_SIZE] = bytes
.get(0..RAW_SPAN_DATA_SIZE)
.and_then(|slice| slice.try_into().ok())
.ok_or("Invalid text span data".to_string())?;
Ok(RawTextSpan::from(data))
}
}
impl From<RawTextSpan> for shapes::TextSpan {
fn from(value: RawTextSpan) -> Self {
let text = String::default();
let font_family = shapes::FontFamily::new(
uuid_from_u32(value.font_id),
value.font_weight as u32,
value.font_style.into(),
);
let fills = value
.fills
.into_iter()
.take(value.fill_count as usize)
.map(|fill| fill.into())
.collect();
let mut span = Self::new(
text,
font_family,
value.font_size,
value.line_height,
value.letter_spacing,
value.text_decoration.into(),
value.text_transform.into(),
value.text_direction.into(),
value.font_weight,
uuid_from_u32(value.font_variant_id),
fills,
);
span.text_orientation = RawTextOrientation::from(value.text_orientation).into();
span.text_combine_upright = RawTextCombineUpright::from(value.text_combine_upright).into();
span.text_emphasis = RawTextEmphasis::from(value.text_emphasis).into();
span.warichu = RawWarichu::from(value.warichu).into();
span.font_features = RawFontFeatures::from(value.font_features).into();
span.annotation_clearance = RawAnnotationClearance::from(value.annotation_clearance).into();
span.ruby_size = RawRubySize::from(value.ruby_size).into();
span.ruby_align = RawRubyAlign::from(value.ruby_align).into();
span.ruby_overhang = RawRubyOverhang::from(value.ruby_overhang).into();
span.ruby_side = RawRubySide::from(value.ruby_side).into();
span
}
}
#[repr(C)]
#[derive(Debug, Clone)]
pub struct RawParagraph {
attrs: RawParagraphData,
spans: Vec<RawTextSpan>,
text_buffer: Vec<u8>,
}
impl TryFrom<&Vec<u8>> for RawParagraph {
// TODO: use a proper error type
type Error = String;
fn try_from(bytes: &Vec<u8>) -> Result<Self, Self::Error> {
let attrs = RawParagraphData::try_from(&bytes[..RAW_PARAGRAPH_DATA_SIZE])?;
let mut offset = RAW_PARAGRAPH_DATA_SIZE;
let mut raw_text_spans: Vec<RawTextSpan> = Vec::new();
for _ in 0..attrs.span_count {
let text_span = RawTextSpan::try_from(&bytes[offset..(offset + RAW_SPAN_DATA_SIZE)])?;
offset += RAW_SPAN_DATA_SIZE;
raw_text_spans.push(text_span);
}
let text_buffer = &bytes[offset..];
Ok(Self {
attrs,
spans: raw_text_spans,
text_buffer: text_buffer.to_vec(),
})
}
}
impl From<RawParagraph> for shapes::Paragraph {
fn from(value: RawParagraph) -> Self {
let mut spans = vec![];
// Layout: [<all span texts> <all span ruby texts>]. Annotation blobs
// begin after all base text.
let mut offset = 0;
let mut ruby_offset: usize = value.spans.iter().map(|s| s.text_length as usize).sum();
for raw_span in value.spans.into_iter() {
let delta = raw_span.text_length as usize;
let text_buffer = value.text_buffer.get(offset..offset + delta).unwrap_or(&[]);
let ruby_delta = raw_span.ruby_length as usize;
let ruby_buffer = value
.text_buffer
.get(ruby_offset..ruby_offset + ruby_delta)
.unwrap_or(&[]);
let mut span = shapes::TextSpan::from(raw_span);
if !text_buffer.is_empty() {
span.set_text(String::from_utf8_lossy(text_buffer).to_string());
}
if !ruby_buffer.is_empty() {
span.ruby = String::from_utf8_lossy(ruby_buffer).to_string();
}
spans.push(span);
offset += delta;
ruby_offset += ruby_delta;
}
let mut paragraph = shapes::Paragraph::new(
value.attrs.text_align.into(),
value.attrs.text_direction.into(),
value.attrs.text_decoration.into(),
value.attrs.text_transform.into(),
value.attrs.line_height,
value.attrs.letter_spacing,
spans,
);
paragraph.set_writing_mode(RawWritingMode::from(value.attrs.writing_mode).into());
paragraph
.set_text_orientation(RawTextOrientation::from(value.attrs.text_orientation).into());
let language = ruby_offset
.checked_add(value.attrs.language_length as usize)
.and_then(|end| value.text_buffer.get(ruby_offset..end))
.unwrap_or(&[]);
paragraph.set_language(String::from_utf8_lossy(language).into_owned());
paragraph.set_korean_spaces_only(value.attrs.korean_wrap == 1);
paragraph
}
}
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawGrowType {
Fixed = 0,
AutoWidth = 1,
AutoHeight = 2,
}
impl From<u8> for RawGrowType {
fn from(value: u8) -> Self {
unsafe { std::mem::transmute(value) }
}
}
impl From<RawGrowType> for GrowType {
fn from(value: RawGrowType) -> Self {
match value {
RawGrowType::Fixed => GrowType::Fixed,
RawGrowType::AutoWidth => GrowType::AutoWidth,
RawGrowType::AutoHeight => GrowType::AutoHeight,
}
}
}
#[no_mangle]
pub extern "C" fn clear_shape_text() {
with_current_shape_mut!(state, |shape: &mut Shape| {
shape.clear_text();
});
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_shape_text_content() -> crate::error::Result<()> {
let bytes = mem::bytes();
let raw_text_data = RawParagraph::try_from(&bytes)
.map_err(|_| Error::CriticalError("Invalid text data".to_string()))?;
with_current_shape_mut!(state, |shape: &mut Shape| {
shape.add_paragraph(raw_text_data.into()).map_err(|_| {
Error::RecoverableError(format!(
"Error with set_shape_text_content on {:?}",
shape.id
))
})?;
});
mem::free_bytes()?;
Ok(())
}
#[no_mangle]
pub extern "C" fn set_shape_grow_type(grow_type: u8) {
let grow_type = RawGrowType::from(grow_type);
with_current_shape_mut!(state, |shape: &mut Shape| {
if let Type::Text(text_content) = &mut shape.shape_type {
text_content.set_grow_type(GrowType::from(grow_type));
}
// Don't throw error if the object is not text.
// On swap component opperations is convenient.
});
}
#[no_mangle]
pub extern "C" fn set_shape_line_adjustment(line_adjustment: u8) {
with_current_shape_mut!(state, |shape: &mut Shape| {
if let Type::Text(text_content) = &mut shape.shape_type {
text_content.set_line_adjustment(RawLineAdjustment::from(line_adjustment).into());
}
});
}
#[no_mangle]
pub extern "C" fn get_text_dimensions() -> *mut u8 {
let mut ptr = std::ptr::null_mut();
with_current_shape_mut!(state, |shape: &mut Shape| {
if let Type::Text(content) = &mut shape.shape_type {
let text_content_size = content.update_layout(shape.selrect);
// Sacar de aqui x, y, width, height
let rect = content.content_rect(&shape.selrect, shape.vertical_align);
let mut bytes = vec![0; 20];
bytes[0..4].clone_from_slice(&text_content_size.width.to_le_bytes());
bytes[4..8].clone_from_slice(&text_content_size.height.to_le_bytes());
bytes[8..12].clone_from_slice(&text_content_size.max_width.to_le_bytes());
// veamos
bytes[12..16].clone_from_slice(&rect.x().to_le_bytes());
bytes[16..20].clone_from_slice(&rect.y().to_le_bytes());
ptr = mem::write_bytes(bytes)
}
});
// FIXME: I think it should be better if instead of returning
// a NULL ptr we failed gracefully.
ptr
}
#[no_mangle]
pub extern "C" fn intersect_position_in_shape(
a: u32,
b: u32,
c: u32,
d: u32,
x_pos: f32,
y_pos: f32,
) -> bool {
with_state!(state, {
let id = uuid_from_u32_quartet(a, b, c, d);
let Some(shape) = state.shapes.get(&id) else {
return false;
};
if let Type::Text(content) = &shape.shape_type {
return content.intersect_position_in_text(shape, x_pos, y_pos);
}
});
false
}
fn update_text_layout(shape: &mut Shape, force: bool) {
if let Type::Text(text_content) = &mut shape.shape_type {
if force {
text_content.force_next_layout_update();
}
text_content.update_layout(shape.selrect);
shape.apply_deferred_batch_paint();
shape.invalidate_extrect();
}
}
#[no_mangle]
pub extern "C" fn update_shape_text_layout() {
with_current_shape_mut!(state, |shape: &mut Shape| {
update_text_layout(shape, false);
});
}
#[no_mangle]
pub extern "C" fn update_shape_text_layout_for(a: u32, b: u32, c: u32, d: u32) {
with_state!(state, {
let shape_id = uuid_from_u32_quartet(a, b, c, d);
if let Some(shape) = state.shapes.get_mut(&shape_id) {
update_text_layout(shape, false);
}
state.touch_shape(shape_id);
});
}
#[no_mangle]
pub extern "C" fn force_update_shape_text_layout_for(a: u32, b: u32, c: u32, d: u32) {
with_state!(state, {
let shape_id = uuid_from_u32_quartet(a, b, c, d);
if let Some(shape) = state.shapes.get_mut(&shape_id) {
update_text_layout(shape, true);
}
state.touch_shape(shape_id);
});
}
const RAW_POSITION_DATA_SIZE: usize = size_of::<shapes::PositionData>();
impl From<[u8; RAW_POSITION_DATA_SIZE]> for shapes::PositionData {
fn from(bytes: [u8; RAW_POSITION_DATA_SIZE]) -> Self {
unsafe { std::mem::transmute(bytes) }
}
}
impl From<shapes::PositionData> for [u8; RAW_POSITION_DATA_SIZE] {
fn from(value: shapes::PositionData) -> Self {
unsafe { std::mem::transmute(value) }
}
}
impl SerializableResult for shapes::PositionData {
type BytesType = [u8; RAW_POSITION_DATA_SIZE];
// 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]) {
let bytes = Self::BytesType::from(*self);
slice.clone_from_slice(&bytes);
}
}
#[no_mangle]
pub extern "C" fn calculate_position_data() -> *mut u8 {
let mut result = Vec::<shapes::PositionData>::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)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shapes::{
AnnotationClearance, FontFeatures, RubyAlign, RubyOverhang, RubySide, RubySize,
};
/// The shared CLJS writer writes PARAGRAPH-ATTR-U8-SIZE (20)
/// attr bytes after the u32 span count, and SPAN-ATTR-U8-SIZE (80)
/// attr bytes before the fills block. These sizes must match the struct
/// layouts.
#[test]
fn raw_struct_sizes_match_cljs_writer() {
const PARAGRAPH_ATTR_U8_SIZE: usize = 20;
const SPAN_ATTR_U8_SIZE: usize = 80;
assert_eq!(RAW_PARAGRAPH_DATA_SIZE, 4 + PARAGRAPH_ATTR_U8_SIZE);
assert_eq!(
RAW_SPAN_DATA_SIZE,
SPAN_ATTR_U8_SIZE + MAX_TEXT_FILLS * std::mem::size_of::<RawFillData>()
);
}
#[test]
fn language_decodes_after_base_and_ruby_text() {
let mut attrs = [0u8; RAW_PARAGRAPH_DATA_SIZE];
attrs[10] = 1; // Korean spaces-only POC; byte 6 after span count.
attrs[20..24].copy_from_slice(&2u32.to_le_bytes());
let mut span = [0u8; RAW_SPAN_DATA_SIZE];
span[68..72].copy_from_slice(&3u32.to_le_bytes());
span[72..76].copy_from_slice(&3u32.to_le_bytes());
let paragraph = shapes::Paragraph::from(RawParagraph {
attrs: RawParagraphData::from(attrs),
spans: vec![RawTextSpan::from(span)],
text_buffer: "韓한ko".as_bytes().to_vec(),
});
assert_eq!(paragraph.language(), "ko");
assert!(paragraph.korean_spaces_only());
assert_eq!(paragraph.children()[0].text, "韓");
assert_eq!(paragraph.children()[0].ruby, "한");
assert_eq!(paragraph.children()[0].language, "ko");
}
#[test]
fn raw_text_combine_upright_counts_deserialize() {
// Byte 5 of the span attr block; the counted digits variants map
// onto their max run length.
let mut bytes = [0u8; RAW_SPAN_DATA_SIZE];
bytes[5] = RawTextCombineUpright::Digits2 as u8;
let span = shapes::TextSpan::from(RawTextSpan::from(bytes));
assert_eq!(span.text_combine_upright.digits_max(), Some(2));
bytes[5] = RawTextCombineUpright::Digits3 as u8;
let span = shapes::TextSpan::from(RawTextSpan::from(bytes));
assert_eq!(span.text_combine_upright.digits_max(), Some(3));
bytes[5] = RawTextCombineUpright::Digits as u8;
let span = shapes::TextSpan::from(RawTextSpan::from(bytes));
assert_eq!(span.text_combine_upright.digits_max(), Some(4));
}
#[test]
fn raw_font_features_deserializes_from_reserved_span_byte() {
let mut bytes = [0u8; RAW_SPAN_DATA_SIZE];
bytes[8] = RawFontFeatures::Vpal as u8;
let raw = RawTextSpan::from(bytes);
let span = shapes::TextSpan::from(raw);
assert_eq!(span.font_features, FontFeatures::Vpal);
}
#[test]
fn raw_annotation_clearance_deserializes_from_reserved_span_byte() {
let mut bytes = [0u8; RAW_SPAN_DATA_SIZE];
bytes[9] = RawAnnotationClearance::Auto as u8;
let span = shapes::TextSpan::from(RawTextSpan::from(bytes));
assert_eq!(span.annotation_clearance, AnnotationClearance::Auto);
}
#[test]
fn raw_ruby_customization_deserializes_from_span_bytes() {
let mut bytes = [0u8; RAW_SPAN_DATA_SIZE];
bytes[10] = RawRubySize::Quarter as u8;
bytes[11] = RawRubyAlign::SpaceBetween as u8;
bytes[12] = RawRubyOverhang::None as u8;
bytes[13] = RawRubySide::Under as u8;
let span = shapes::TextSpan::from(RawTextSpan::from(bytes));
assert_eq!(span.ruby_size, RubySize::Quarter);
assert_eq!(span.ruby_align, RubyAlign::SpaceBetween);
assert_eq!(span.ruby_overhang, RubyOverhang::None);
assert_eq!(span.ruby_side, RubySide::Under);
}
#[test]
fn out_of_range_japanese_span_bytes_fall_back_to_defaults() {
let mut bytes = [0u8; RAW_SPAN_DATA_SIZE];
bytes[4..14].fill(0xFF);
let span = shapes::TextSpan::from(RawTextSpan::from(bytes));
assert_eq!(span.text_orientation, shapes::TextOrientation::Mixed);
assert_eq!(span.text_combine_upright, shapes::TextCombineUpright::None);
assert_eq!(span.text_emphasis, shapes::TextEmphasis::None);
assert!(!span.warichu);
assert_eq!(span.font_features, FontFeatures::None);
assert_eq!(span.annotation_clearance, AnnotationClearance::None);
assert_eq!(span.ruby_size, RubySize::Half);
assert_eq!(span.ruby_align, RubyAlign::SpaceAround);
assert_eq!(span.ruby_overhang, RubyOverhang::Auto);
assert_eq!(span.ruby_side, RubySide::Over);
}
#[test]
fn out_of_range_paragraph_writing_bytes_fall_back_to_defaults() {
let mut bytes = [0u8; RAW_PARAGRAPH_DATA_SIZE];
bytes[8] = 0xFF;
bytes[9] = 0xFF;
let attrs = RawParagraphData::from(bytes);
assert_eq!(
shapes::WritingMode::from(RawWritingMode::from(attrs.writing_mode)),
shapes::WritingMode::HorizontalTb
);
assert_eq!(
shapes::TextOrientation::from(RawTextOrientation::from(attrs.text_orientation)),
shapes::TextOrientation::Mixed
);
}
}