use alpha2_media::{EmojiPolicy, TextAlign};
use alpha2_text::{
TextFill, TextFont, TextGradient, TextGradientKind, TextGradientStop, TextRasterRequest,
TextRasterResult, TextSpread,
};
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct TextRequestWire {
text: String,
font_size: f32,
#[serde(default)]
max_width: f32,
#[serde(default = "default_line_height")]
line_height: f32,
#[serde(default)]
align: AlignWire,
fill: FillWire,
#[serde(default)]
emoji_policy: EmojiWire,
#[serde(default)]
strokes: Vec<StrokeWire>,
#[serde(default)]
background: Option<BackgroundWire>,
#[serde(default)]
split_background: bool,
#[serde(default)]
cluster_state: Option<StateWire>,
#[serde(default)]
cluster_states: Option<Vec<StateWire>>,
#[serde(default)]
font_family: Option<String>,
#[serde(default)]
letter_spacing: f32,
#[serde(default = "default_font_weight")]
font_weight: u16,
}
#[derive(Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct StateWire {
#[serde(default)]
offset: [f32; 2],
#[serde(default = "unit_scale")]
scale: [f32; 2],
#[serde(default)]
rotation: f32,
#[serde(default = "full")]
opacity: f32,
#[serde(default)]
fill: Option<MixWire>,
#[serde(default)]
stroke: Option<MixWire>,
#[serde(default)]
tracking: f32,
}
#[derive(Clone, Copy, Deserialize)]
#[serde(deny_unknown_fields)]
struct MixWire {
keep: f32,
add: [f32; 4],
}
const fn unit_scale() -> [f32; 2] {
[1.0, 1.0]
}
const fn full() -> f32 {
1.0
}
impl StateWire {
fn state(self) -> alpha2_text::ClusterState {
let mix = |mix: Option<MixWire>| {
mix.map(|mix| alpha2_text::ColorMix {
keep: mix.keep,
add: mix.add,
})
};
alpha2_text::ClusterState {
offset: self.offset,
scale: self.scale,
rotation: self.rotation,
opacity: self.opacity,
fill: mix(self.fill),
stroke: mix(self.stroke),
tracking: self.tracking,
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct StrokeWire {
color: [u8; 4],
width: f32,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum FillWire {
Solid([u8; 4]),
Gradient(GradientWire),
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
enum GradientWire {
Linear {
start: [f32; 2],
end: [f32; 2],
stops: Vec<StopWire>,
#[serde(default)]
spread: SpreadWire,
},
Radial {
center: [f32; 2],
radius: f32,
stops: Vec<StopWire>,
#[serde(default)]
spread: SpreadWire,
},
Conic {
center: [f32; 2],
#[serde(default)]
angle: f32,
stops: Vec<StopWire>,
},
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct StopWire {
offset: f32,
color: [f32; 4],
}
#[derive(Default, Deserialize)]
#[serde(rename_all = "lowercase")]
enum SpreadWire {
#[default]
Pad,
Repeat,
Reflect,
}
impl FillWire {
fn into_fill(self) -> TextFill {
let stops = |stops: Vec<StopWire>| {
stops
.into_iter()
.map(|stop| TextGradientStop {
offset: stop.offset,
color: stop.color,
})
.collect()
};
let spread = |spread: SpreadWire| match spread {
SpreadWire::Pad => TextSpread::Pad,
SpreadWire::Repeat => TextSpread::Repeat,
SpreadWire::Reflect => TextSpread::Reflect,
};
match self {
Self::Solid(color) => TextFill::Solid(color),
Self::Gradient(GradientWire::Linear {
start,
end,
stops: list,
spread: mode,
}) => TextFill::Gradient(TextGradient {
kind: TextGradientKind::Linear { start, end },
stops: stops(list),
spread: spread(mode),
}),
Self::Gradient(GradientWire::Radial {
center,
radius,
stops: list,
spread: mode,
}) => TextFill::Gradient(TextGradient {
kind: TextGradientKind::Radial { center, radius },
stops: stops(list),
spread: spread(mode),
}),
Self::Gradient(GradientWire::Conic {
center,
angle,
stops: list,
}) => TextFill::Gradient(TextGradient {
kind: TextGradientKind::Conic { center, angle },
stops: stops(list),
spread: TextSpread::Pad,
}),
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct BackgroundWire {
color: [u8; 4],
padding: f32,
}
const fn default_line_height() -> f32 {
1.2
}
const fn default_font_weight() -> u16 {
400
}
#[derive(Default, Deserialize)]
#[serde(rename_all = "lowercase")]
enum AlignWire {
#[default]
Start,
Center,
End,
}
#[derive(Default, Deserialize)]
#[serde(rename_all = "lowercase")]
enum EmojiWire {
#[default]
Color,
Monochrome,
Replace,
}
impl TextRequestWire {
pub(crate) const fn is_animated(&self) -> bool {
self.cluster_state.is_some() || self.cluster_states.is_some()
}
pub(crate) fn into_request(self, content_version: u64) -> TextRasterRequest {
TextRasterRequest {
text: self.text,
font_size: self.font_size,
max_width: self.max_width,
line_height: self.line_height,
align: match self.align {
AlignWire::Start => TextAlign::Start,
AlignWire::Center => TextAlign::Center,
AlignWire::End => TextAlign::End,
},
fill: self.fill.into_fill(),
emoji_policy: match self.emoji_policy {
EmojiWire::Color => EmojiPolicy::Color,
EmojiWire::Monochrome => EmojiPolicy::Monochrome,
EmojiWire::Replace => EmojiPolicy::Replace,
},
content_version,
strokes: self
.strokes
.into_iter()
.map(|stroke| alpha2_text::TextStroke {
color: stroke.color,
width: stroke.width,
})
.collect(),
split_background: self.split_background,
background: self
.background
.map(|background| alpha2_text::TextBackground {
color: background.color,
padding: background.padding,
}),
font_family: self.font_family,
letter_spacing: self.letter_spacing,
font_weight: self.font_weight,
}
}
}
pub(crate) fn animated_request(
wire: TextRequestWire,
content_version: u64,
rasterizer: &alpha2_text::TextRasterizer,
) -> Result<(TextRasterRequest, Vec<alpha2_text::ClusterState>), alpha2_text::TextRasterError> {
let (one, list) = (wire.cluster_state, wire.cluster_states.clone());
let request = TextRequestWire {
cluster_state: None,
cluster_states: None,
..wire
}
.into_request(content_version);
let states = if let Some(list) = list {
list.into_iter().map(StateWire::state).collect()
} else {
let clusters = rasterizer.layout(&request)?.clusters.len();
vec![one.map_or(alpha2_text::ClusterState::IDENTITY, StateWire::state); clusters]
};
Ok((request, states))
}
pub(crate) fn descriptor_json(result: &TextRasterResult) -> String {
let descriptor = &result.descriptor;
serde_json::json!({
"width": descriptor.texture.width,
"height": descriptor.texture.height,
"layoutX": descriptor.layout_x,
"layoutY": descriptor.layout_y,
"layoutWidth": descriptor.layout_width,
"layoutHeight": descriptor.layout_height,
"firstBaseline": descriptor.first_baseline,
"ascent": descriptor.ascent,
"descent": descriptor.descent,
"lineCount": descriptor.line_count,
})
.to_string()
}
pub(crate) fn font(family: String, bytes: Vec<u8>, face_index: u32) -> TextFont {
TextFont {
family,
bytes: bytes.into(),
face_index,
}
}
pub(crate) fn file_faces(
family: &str,
bytes: Vec<u8>,
) -> Result<Vec<TextFont>, alpha2_text::TextRasterError> {
let bytes: std::sync::Arc<[u8]> = bytes.into();
(0..alpha2_text::face_count(&bytes))
.map(|face_index| {
alpha2_text::check_font(&bytes, face_index)?;
Ok(TextFont {
family: family.to_owned(),
bytes: std::sync::Arc::clone(&bytes),
face_index,
})
})
.collect()
}
#[cfg(target_arch = "wasm32")]
mod web {
use std::sync::Arc;
use super::{TextRasterResult, TextRequestWire, descriptor_json, file_faces, font};
use alpha2_layout::json::{MeasureError, measure_layout_json, measure_text_json};
use alpha2_text::TextRasterizer;
use wasm_bindgen::prelude::*;
use web_sys::js_sys::Uint8Array;
use crate::web::{invalid_input, typed_error};
#[wasm_bindgen(js_name = fontFaceCount)]
#[must_use]
pub fn font_face_count(bytes: &[u8]) -> u32 {
alpha2_text::face_count(bytes)
}
fn measure_error(error: MeasureError) -> JsValue {
match error {
MeasureError::Text(error) => typed_error(&alpha2_engine::TextError::Raster(error)),
error => invalid_input(error.to_string()),
}
}
#[wasm_bindgen]
pub struct WasmTextRasterizer {
inner: TextRasterizer,
}
#[wasm_bindgen]
pub struct RasterizedText {
descriptor: String,
raster: Option<Arc<TextRasterResult>>,
}
#[wasm_bindgen]
impl RasterizedText {
#[wasm_bindgen(getter)]
pub fn descriptor(&self) -> String {
self.descriptor.clone()
}
#[wasm_bindgen(js_name = takeBackground)]
pub fn take_background(&mut self) -> Uint8Array {
self.raster
.as_ref()
.and_then(|raster| raster.background.as_deref())
.map_or_else(|| Uint8Array::new_with_length(0), Uint8Array::from)
}
#[wasm_bindgen(js_name = takePixels)]
pub fn take_pixels(&mut self) -> Uint8Array {
self.raster.take().map_or_else(
|| Uint8Array::new_with_length(0),
|raster| Uint8Array::from(raster.pixels.rgba8.as_slice()),
)
}
}
impl From<Arc<TextRasterResult>> for RasterizedText {
fn from(result: Arc<TextRasterResult>) -> Self {
Self {
descriptor: descriptor_json(&result),
raster: Some(result),
}
}
}
#[wasm_bindgen]
impl WasmTextRasterizer {
#[wasm_bindgen(constructor)]
#[must_use]
pub fn new() -> Self {
Self {
inner: TextRasterizer::empty(),
}
}
#[wasm_bindgen(js_name = addFont)]
pub fn add_font(
&mut self,
family: String,
bytes: Vec<u8>,
face_index: u32,
) -> Result<(), JsValue> {
self.inner
.add_font(font(family, bytes, face_index))
.map_err(|error| typed_error(&error))
}
#[wasm_bindgen(js_name = addFontFile)]
pub fn add_font_file(&mut self, family: &str, bytes: Vec<u8>) -> Result<(), JsValue> {
for face in file_faces(family, bytes).map_err(|error| typed_error(&error))? {
self.inner
.add_font(face)
.map_err(|error| typed_error(&error))?;
}
Ok(())
}
pub fn rasterize(
&mut self,
request_json: &str,
content_version: u64,
) -> Result<RasterizedText, JsValue> {
let wire: TextRequestWire = serde_json::from_str(request_json)
.map_err(|error| invalid_input(format!("invalid text request: {error}")))?;
if !wire.is_animated() {
return self
.inner
.rasterize(&wire.into_request(content_version))
.map(RasterizedText::from)
.map_err(|error| typed_error(&error));
}
let (request, states) = super::animated_request(wire, content_version, &self.inner)
.map_err(|error| typed_error(&error))?;
self.inner
.rasterize_animated(&request, &states)
.map(RasterizedText::from)
.map_err(|error| typed_error(&error))
}
pub fn invalidate(&mut self, content_version: u64) {
self.inner.invalidate(content_version);
}
pub fn clear(&mut self) {
self.inner.clear();
}
#[wasm_bindgen(getter, js_name = cacheSize)]
#[must_use]
pub fn cache_size(&self) -> usize {
self.inner.cache_len()
}
}
#[wasm_bindgen]
pub struct WasmTextMeasurer {
inner: TextRasterizer,
}
#[wasm_bindgen]
impl WasmTextMeasurer {
#[wasm_bindgen(constructor)]
#[must_use]
pub fn new() -> Self {
Self {
inner: TextRasterizer::empty(),
}
}
#[wasm_bindgen(js_name = addFont)]
pub fn add_font(
&mut self,
family: String,
bytes: Vec<u8>,
face_index: u32,
) -> Result<(), JsValue> {
self.inner
.add_font(font(family, bytes, face_index))
.map_err(|error| typed_error(&error))
}
#[wasm_bindgen(js_name = addFontFile)]
pub fn add_font_file(&mut self, family: &str, bytes: Vec<u8>) -> Result<(), JsValue> {
for face in file_faces(family, bytes).map_err(|error| typed_error(&error))? {
self.inner
.add_font(face)
.map_err(|error| typed_error(&error))?;
}
Ok(())
}
#[wasm_bindgen(js_name = clearFonts)]
pub fn clear_fonts(&mut self) {
self.inner = TextRasterizer::empty();
}
pub fn measure(
&self,
generator_json: &str,
width: u32,
height: u32,
) -> Result<String, JsValue> {
measure_text_json(&self.inner, generator_json, width, height).map_err(measure_error)
}
#[wasm_bindgen(js_name = measureLayout)]
pub fn measure_layout(
&self,
generator_json: &str,
width: u32,
height: u32,
time: f64,
canvas_width: Option<u32>,
canvas_height: Option<u32>,
) -> Result<String, JsValue> {
let canvas = match (canvas_width, canvas_height) {
(Some(canvas_width), Some(canvas_height)) => Some((canvas_width, canvas_height)),
(None, None) => None,
_ => {
return Err(invalid_input(
"give both canvasWidth and canvasHeight, or neither".to_owned(),
));
}
};
let time = crate::geometry::time(time).map_err(invalid_input)?;
measure_layout_json(&self.inner, generator_json, width, height, time, canvas)
.map_err(measure_error)
}
}
impl Default for WasmTextMeasurer {
fn default() -> Self {
Self::new()
}
}
impl Default for WasmTextRasterizer {
fn default() -> Self {
Self::new()
}
}
}
#[cfg(test)]
#[path = "text_tests.rs"]
mod tests;