summaryrefslogtreecommitdiff
path: root/rust/crates/text/src
diff options
context:
space:
mode:
Diffstat (limited to 'rust/crates/text/src')
-rw-r--r--rust/crates/text/src/color.rs74
-rw-r--r--rust/crates/text/src/engine.rs194
-rw-r--r--rust/crates/text/src/layout.rs163
-rw-r--r--rust/crates/text/src/lib.rs21
-rw-r--r--rust/crates/text/src/params.rs166
-rw-r--r--rust/crates/text/src/raster.rs265
6 files changed, 883 insertions, 0 deletions
diff --git a/rust/crates/text/src/color.rs b/rust/crates/text/src/color.rs
new file mode 100644
index 00000000..fa6bd3e9
--- /dev/null
+++ b/rust/crates/text/src/color.rs
@@ -0,0 +1,74 @@
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct Color {
+ pub r: u8,
+ pub g: u8,
+ pub b: u8,
+ pub a: u8,
+}
+
+impl Color {
+ pub const TRANSPARENT: Self = Self {
+ r: 0,
+ g: 0,
+ b: 0,
+ a: 0,
+ };
+
+ pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
+ Self { r, g, b, a }
+ }
+
+ pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
+ Self { r, g, b, a: 0xFF }
+ }
+
+ pub fn parse(value: &str) -> Option<Self> {
+ if value == "transparent" {
+ return Some(Self::TRANSPARENT);
+ }
+ let hex = value.strip_prefix('#')?;
+ let channel = |pair: &str| u8::from_str_radix(pair, 16).ok();
+ match hex.len() {
+ 3 => {
+ let r = channel(&hex[0..1].repeat(2))?;
+ let g = channel(&hex[1..2].repeat(2))?;
+ let b = channel(&hex[2..3].repeat(2))?;
+ Some(Self::rgb(r, g, b))
+ }
+ 4 => {
+ let r = channel(&hex[0..1].repeat(2))?;
+ let g = channel(&hex[1..2].repeat(2))?;
+ let b = channel(&hex[2..3].repeat(2))?;
+ let a = channel(&hex[3..4].repeat(2))?;
+ Some(Self::rgba(r, g, b, a))
+ }
+ 6 => {
+ let r = channel(&hex[0..2])?;
+ let g = channel(&hex[2..4])?;
+ let b = channel(&hex[4..6])?;
+ Some(Self::rgb(r, g, b))
+ }
+ 8 => {
+ let r = channel(&hex[0..2])?;
+ let g = channel(&hex[2..4])?;
+ let b = channel(&hex[4..6])?;
+ let a = channel(&hex[6..8])?;
+ Some(Self::rgba(r, g, b, a))
+ }
+ _ => None,
+ }
+ }
+
+ pub(crate) fn to_cosmic(self) -> cosmic_text::Color {
+ cosmic_text::Color::rgba(self.r, self.g, self.b, self.a)
+ }
+
+ pub(crate) fn from_cosmic(color: cosmic_text::Color) -> Self {
+ Self::rgba(color.r(), color.g(), color.b(), color.a())
+ }
+
+ pub(crate) fn scaled_alpha(self, coverage: f64) -> Self {
+ let alpha = (f64::from(self.a) * coverage).round() as u8;
+ Self::rgba(self.r, self.g, self.b, alpha)
+ }
+}
diff --git a/rust/crates/text/src/engine.rs b/rust/crates/text/src/engine.rs
new file mode 100644
index 00000000..9d0289fd
--- /dev/null
+++ b/rust/crates/text/src/engine.rs
@@ -0,0 +1,194 @@
+use cosmic_text::fontdb::Source;
+use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, Style, SwashCache, Weight, Wrap};
+use std::sync::Arc;
+
+use crate::layout::{
+ LineMetrics, ResolvedTextLayout, TextBlockMeasurement, measure_text_block, resolve_text_layout,
+};
+use crate::params::{FontStyle, FontWeight, TextLayoutParams};
+
+#[derive(Debug, thiserror::Error)]
+pub enum TextError {
+ #[error("failed to load font: {0}")]
+ FontLoad(String),
+}
+
+pub struct TextEngine {
+ pub(crate) font_system: FontSystem,
+ pub(crate) swash_cache: SwashCache,
+}
+
+pub struct MeasuredTextLayout {
+ pub resolved: ResolvedTextLayout,
+ pub lines: Vec<String>,
+ pub line_metrics: Vec<LineMetrics>,
+ pub block: TextBlockMeasurement,
+ pub(crate) buffer: Buffer,
+}
+
+impl TextEngine {
+ pub fn new() -> Self {
+ Self::from_font_system(FontSystem::new())
+ }
+
+ pub fn with_font_bytes(fonts: &[&[u8]]) -> Self {
+ let sources = fonts
+ .iter()
+ .map(|bytes| Source::Binary(Arc::new(bytes.to_vec())));
+ Self::from_font_system(FontSystem::new_with_fonts(sources))
+ }
+
+ fn from_font_system(font_system: FontSystem) -> Self {
+ Self {
+ font_system,
+ swash_cache: SwashCache::new(),
+ }
+ }
+
+ pub fn load_font_bytes(&mut self, bytes: &[u8]) -> Result<(), TextError> {
+ let loaded = self
+ .font_system
+ .db_mut()
+ .load_font_source(Source::Binary(Arc::new(bytes.to_vec())));
+ if loaded.is_empty() {
+ return Err(TextError::FontLoad(
+ "no faces found in font data".to_string(),
+ ));
+ }
+ Ok(())
+ }
+
+ pub fn measure(&mut self, params: &TextLayoutParams, canvas_height: f64) -> MeasuredTextLayout {
+ self.measure_with_wrap(params, canvas_height, None)
+ }
+
+ pub fn measure_with_wrap(
+ &mut self,
+ params: &TextLayoutParams,
+ canvas_height: f64,
+ wrap_width: Option<f64>,
+ ) -> MeasuredTextLayout {
+ let resolved = resolve_text_layout(params, canvas_height);
+ let mut buffer = self.build_buffer(params, &resolved, wrap_width);
+ buffer.shape_until_scroll(&mut self.font_system, false);
+
+ let mut lines = Vec::new();
+ let mut line_metrics = Vec::new();
+ for line_i in 0..buffer.lines.len() {
+ let line_text = buffer.lines[line_i].text().to_string();
+ let layouts = buffer
+ .line_layout(&mut self.font_system, line_i)
+ .unwrap_or(&[]);
+ let mut laid_out = false;
+ for layout_line in layouts {
+ laid_out = true;
+ let (text, metrics) = visual_line_metrics(&line_text, layout_line, &resolved);
+ lines.push(text);
+ line_metrics.push(metrics);
+ }
+ if !laid_out {
+ lines.push(line_text);
+ line_metrics.push(empty_line_metrics(&resolved));
+ }
+ }
+ if line_metrics.is_empty() {
+ lines.push(String::new());
+ line_metrics.push(empty_line_metrics(&resolved));
+ }
+
+ let block = measure_text_block(&line_metrics, resolved.line_height_px);
+ MeasuredTextLayout {
+ resolved,
+ lines,
+ line_metrics,
+ block,
+ buffer,
+ }
+ }
+
+ fn build_buffer(
+ &mut self,
+ params: &TextLayoutParams,
+ resolved: &ResolvedTextLayout,
+ wrap_width: Option<f64>,
+ ) -> Buffer {
+ let metrics = Metrics::new(
+ resolved.scaled_font_size as f32,
+ resolved.line_height_px as f32,
+ );
+ let mut buffer = Buffer::new(&mut self.font_system, metrics);
+ let (wrap, width) = match wrap_width {
+ Some(width) => (Wrap::WordOrGlyph, Some(width as f32)),
+ None => (Wrap::None, None),
+ };
+ buffer.set_wrap(&mut self.font_system, wrap);
+ buffer.set_size(&mut self.font_system, width, None);
+ let attrs = Attrs::new()
+ .family(Family::Name(&params.font_family))
+ .weight(match params.font_weight {
+ FontWeight::Normal => Weight::NORMAL,
+ FontWeight::Bold => Weight::BOLD,
+ })
+ .style(match params.font_style {
+ FontStyle::Normal => Style::Normal,
+ FontStyle::Italic => Style::Italic,
+ })
+ .letter_spacing((resolved.letter_spacing / resolved.scaled_font_size.max(f64::EPSILON)) as f32);
+ buffer.set_text(
+ &mut self.font_system,
+ &params.content,
+ &attrs,
+ Shaping::Advanced,
+ None,
+ );
+ buffer
+ }
+}
+
+impl Default for TextEngine {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+fn visual_line_metrics(
+ line_text: &str,
+ layout_line: &cosmic_text::LayoutLine,
+ resolved: &ResolvedTextLayout,
+) -> (String, LineMetrics) {
+ if layout_line.glyphs.is_empty() {
+ return (line_text.to_string(), empty_line_metrics(resolved));
+ }
+ let raw_width = layout_line
+ .glyphs
+ .iter()
+ .fold(0.0_f32, |max, glyph| max.max(glyph.x + glyph.w));
+ let width = (f64::from(raw_width) - resolved.letter_spacing).max(0.0);
+ let start = layout_line
+ .glyphs
+ .iter()
+ .map(|glyph| glyph.start)
+ .min()
+ .unwrap_or(0);
+ let end = layout_line
+ .glyphs
+ .iter()
+ .map(|glyph| glyph.end)
+ .max()
+ .unwrap_or(line_text.len());
+ let text = line_text.get(start..end).unwrap_or(line_text).to_string();
+ let metrics = LineMetrics {
+ width,
+ ascent: f64::from(layout_line.max_ascent),
+ descent: f64::from(layout_line.max_descent),
+ };
+ (text, metrics)
+}
+
+fn empty_line_metrics(resolved: &ResolvedTextLayout) -> LineMetrics {
+ LineMetrics {
+ width: 0.0,
+ ascent: resolved.scaled_font_size * 0.8,
+ descent: resolved.scaled_font_size * 0.2,
+ }
+}
diff --git a/rust/crates/text/src/layout.rs b/rust/crates/text/src/layout.rs
new file mode 100644
index 00000000..c0e3a198
--- /dev/null
+++ b/rust/crates/text/src/layout.rs
@@ -0,0 +1,163 @@
+use crate::params::{
+ FONT_SIZE_SCALE_REFERENCE, TextAlign, TextBackground, TextDecoration, TextLayoutParams,
+};
+
+pub const TEXT_DECORATION_THICKNESS_RATIO: f64 = 0.07;
+pub const STRIKETHROUGH_VERTICAL_RATIO: f64 = 0.35;
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ResolvedTextLayout {
+ pub scaled_font_size: f64,
+ pub letter_spacing: f64,
+ pub line_height_px: f64,
+ pub font_size_ratio: f64,
+ pub text_align: TextAlign,
+ pub text_decoration: TextDecoration,
+}
+
+pub fn resolve_text_layout(text: &TextLayoutParams, canvas_height: f64) -> ResolvedTextLayout {
+ let scaled_font_size = text.font_size * (canvas_height / FONT_SIZE_SCALE_REFERENCE);
+ ResolvedTextLayout {
+ scaled_font_size,
+ letter_spacing: text.letter_spacing,
+ line_height_px: scaled_font_size * text.line_height,
+ font_size_ratio: text.font_size / 15.0,
+ text_align: text.text_align,
+ text_decoration: text.text_decoration,
+ }
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub struct LineMetrics {
+ pub width: f64,
+ pub ascent: f64,
+ pub descent: f64,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub struct TextBlockMeasurement {
+ pub visual_center_offset: f64,
+ pub height: f64,
+ pub max_width: f64,
+}
+
+pub fn measure_text_block(
+ line_metrics: &[LineMetrics],
+ line_height_px: f64,
+) -> TextBlockMeasurement {
+ let max_width = line_metrics
+ .iter()
+ .fold(0.0_f64, |max, metrics| max.max(metrics.width));
+ let line_count = line_metrics.len() as f64;
+ TextBlockMeasurement {
+ visual_center_offset: ((line_count - 1.0) * line_height_px) / 2.0,
+ height: line_count * line_height_px,
+ max_width,
+ }
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub struct Rect {
+ pub left: f64,
+ pub top: f64,
+ pub width: f64,
+ pub height: f64,
+}
+
+impl Rect {
+ pub fn right(self) -> f64 {
+ self.left + self.width
+ }
+
+ pub fn bottom(self) -> f64 {
+ self.top + self.height
+ }
+}
+
+pub fn align_offset(text_align: TextAlign, line_width: f64) -> f64 {
+ match text_align {
+ TextAlign::Left => 0.0,
+ TextAlign::Center => -line_width / 2.0,
+ TextAlign::Right => -line_width,
+ }
+}
+
+pub fn text_rect(text_align: TextAlign, block: &TextBlockMeasurement) -> Rect {
+ Rect {
+ left: align_offset(text_align, block.max_width),
+ top: -block.height / 2.0,
+ width: block.max_width,
+ height: block.height,
+ }
+}
+
+fn background_visible(background: &TextBackground) -> bool {
+ background.enabled && !background.color.is_empty() && background.color != "transparent"
+}
+
+pub fn text_background_rect(
+ text_align: TextAlign,
+ block: &TextBlockMeasurement,
+ background: &TextBackground,
+ font_size_ratio: f64,
+) -> Option<Rect> {
+ if !background_visible(background) {
+ return None;
+ }
+ let rect = text_rect(text_align, block);
+ let padding_x = background.padding_x * font_size_ratio;
+ let padding_y = background.padding_y * font_size_ratio;
+ Some(Rect {
+ left: rect.left - padding_x + background.offset_x,
+ top: rect.top - padding_y + background.offset_y,
+ width: rect.width + padding_x * 2.0,
+ height: rect.height + padding_y * 2.0,
+ })
+}
+
+pub fn text_visual_rect(
+ text_align: TextAlign,
+ block: &TextBlockMeasurement,
+ background: &TextBackground,
+ font_size_ratio: f64,
+) -> Rect {
+ let rect = text_rect(text_align, block);
+ let Some(background_rect) = text_background_rect(text_align, block, background, font_size_ratio)
+ else {
+ return rect;
+ };
+ let left = rect.left.min(background_rect.left);
+ let top = rect.top.min(background_rect.top);
+ Rect {
+ left,
+ top,
+ width: rect.right().max(background_rect.right()) - left,
+ height: rect.bottom().max(background_rect.bottom()) - top,
+ }
+}
+
+pub fn decoration_rect(
+ text_decoration: TextDecoration,
+ text_align: TextAlign,
+ line_width: f64,
+ line_y: f64,
+ ascent: f64,
+ descent: f64,
+ scaled_font_size: f64,
+) -> Option<Rect> {
+ if text_decoration == TextDecoration::None {
+ return None;
+ }
+ let thickness = (scaled_font_size * TEXT_DECORATION_THICKNESS_RATIO).max(1.0);
+ let top = match text_decoration {
+ TextDecoration::Underline => line_y + descent + thickness,
+ TextDecoration::LineThrough => line_y - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO,
+ TextDecoration::None => unreachable!(),
+ };
+ Some(Rect {
+ left: align_offset(text_align, line_width),
+ top,
+ width: line_width,
+ height: thickness,
+ })
+}
diff --git a/rust/crates/text/src/lib.rs b/rust/crates/text/src/lib.rs
index 8b137891..ccb324bf 100644
--- a/rust/crates/text/src/lib.rs
+++ b/rust/crates/text/src/lib.rs
@@ -1 +1,22 @@
+mod color;
+mod engine;
+mod layout;
+mod params;
+mod raster;
+pub use color::Color;
+pub use engine::{MeasuredTextLayout, TextEngine, TextError};
+pub use layout::{
+ LineMetrics, Rect, ResolvedTextLayout, STRIKETHROUGH_VERTICAL_RATIO,
+ TEXT_DECORATION_THICKNESS_RATIO, TextBlockMeasurement, align_offset, decoration_rect,
+ measure_text_block, resolve_text_layout, text_background_rect, text_rect, text_visual_rect,
+};
+pub use params::{
+ CORNER_RADIUS_MAX, CORNER_RADIUS_MIN, DEFAULT_BACKGROUND_COLOR,
+ DEFAULT_BACKGROUND_CORNER_RADIUS, DEFAULT_BACKGROUND_ENABLED, DEFAULT_BACKGROUND_OFFSET_X,
+ DEFAULT_BACKGROUND_OFFSET_Y, DEFAULT_BACKGROUND_PADDING_X, DEFAULT_BACKGROUND_PADDING_Y,
+ DEFAULT_LETTER_SPACING, DEFAULT_LINE_HEIGHT, DEFAULT_TEXT_COLOR, FONT_SIZE_SCALE_REFERENCE,
+ FontStyle, FontWeight, MAX_FONT_SIZE, MIN_FONT_SIZE, TextAlign, TextBackground, TextDecoration,
+ TextLayoutParams, text_background_from, text_layout_params_from,
+};
+pub use raster::{RasterOptions, RgbaBuffer, StrokeStyle, rasterize};
diff --git a/rust/crates/text/src/params.rs b/rust/crates/text/src/params.rs
new file mode 100644
index 00000000..bf2d7702
--- /dev/null
+++ b/rust/crates/text/src/params.rs
@@ -0,0 +1,166 @@
+use scene::params::{ParamValue, ParamValues};
+
+pub const MIN_FONT_SIZE: f64 = 5.0;
+pub const MAX_FONT_SIZE: f64 = 300.0;
+pub const DEFAULT_TEXT_COLOR: &str = "#000000";
+pub const FONT_SIZE_SCALE_REFERENCE: f64 = 90.0;
+pub const CORNER_RADIUS_MIN: f64 = 0.0;
+pub const CORNER_RADIUS_MAX: f64 = 100.0;
+pub const DEFAULT_LETTER_SPACING: f64 = 0.0;
+pub const DEFAULT_LINE_HEIGHT: f64 = 1.2;
+pub const DEFAULT_BACKGROUND_ENABLED: bool = false;
+pub const DEFAULT_BACKGROUND_COLOR: &str = "#000000";
+pub const DEFAULT_BACKGROUND_CORNER_RADIUS: f64 = 0.0;
+pub const DEFAULT_BACKGROUND_PADDING_X: f64 = 30.0;
+pub const DEFAULT_BACKGROUND_PADDING_Y: f64 = 42.0;
+pub const DEFAULT_BACKGROUND_OFFSET_X: f64 = 0.0;
+pub const DEFAULT_BACKGROUND_OFFSET_Y: f64 = 0.0;
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum TextAlign {
+ Left,
+ #[default]
+ Center,
+ Right,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum FontWeight {
+ #[default]
+ Normal,
+ Bold,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum FontStyle {
+ #[default]
+ Normal,
+ Italic,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum TextDecoration {
+ #[default]
+ None,
+ Underline,
+ LineThrough,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct TextLayoutParams {
+ pub content: String,
+ pub font_size: f64,
+ pub font_family: String,
+ pub font_weight: FontWeight,
+ pub font_style: FontStyle,
+ pub text_align: TextAlign,
+ pub text_decoration: TextDecoration,
+ pub letter_spacing: f64,
+ pub line_height: f64,
+}
+
+impl Default for TextLayoutParams {
+ fn default() -> Self {
+ Self {
+ content: "Default text".to_string(),
+ font_size: 15.0,
+ font_family: "Arial".to_string(),
+ font_weight: FontWeight::Normal,
+ font_style: FontStyle::Normal,
+ text_align: TextAlign::Center,
+ text_decoration: TextDecoration::None,
+ letter_spacing: DEFAULT_LETTER_SPACING,
+ line_height: DEFAULT_LINE_HEIGHT,
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct TextBackground {
+ pub enabled: bool,
+ pub color: String,
+ pub corner_radius: f64,
+ pub padding_x: f64,
+ pub padding_y: f64,
+ pub offset_x: f64,
+ pub offset_y: f64,
+}
+
+impl Default for TextBackground {
+ fn default() -> Self {
+ Self {
+ enabled: DEFAULT_BACKGROUND_ENABLED,
+ color: DEFAULT_BACKGROUND_COLOR.to_string(),
+ corner_radius: DEFAULT_BACKGROUND_CORNER_RADIUS,
+ padding_x: DEFAULT_BACKGROUND_PADDING_X,
+ padding_y: DEFAULT_BACKGROUND_PADDING_Y,
+ offset_x: DEFAULT_BACKGROUND_OFFSET_X,
+ offset_y: DEFAULT_BACKGROUND_OFFSET_Y,
+ }
+ }
+}
+
+pub fn text_layout_params_from(params: &ParamValues) -> TextLayoutParams {
+ TextLayoutParams {
+ content: string_param(params, "content", "Default text"),
+ font_size: number_param(params, "fontSize", 15.0),
+ font_family: string_param(params, "fontFamily", "Arial"),
+ font_weight: match string_param(params, "fontWeight", "normal").as_str() {
+ "bold" => FontWeight::Bold,
+ _ => FontWeight::Normal,
+ },
+ font_style: match string_param(params, "fontStyle", "normal").as_str() {
+ "italic" => FontStyle::Italic,
+ _ => FontStyle::Normal,
+ },
+ text_align: match string_param(params, "textAlign", "center").as_str() {
+ "left" => TextAlign::Left,
+ "right" => TextAlign::Right,
+ _ => TextAlign::Center,
+ },
+ text_decoration: match string_param(params, "textDecoration", "none").as_str() {
+ "underline" => TextDecoration::Underline,
+ "line-through" => TextDecoration::LineThrough,
+ _ => TextDecoration::None,
+ },
+ letter_spacing: number_param(params, "letterSpacing", DEFAULT_LETTER_SPACING),
+ line_height: number_param(params, "lineHeight", DEFAULT_LINE_HEIGHT),
+ }
+}
+
+pub fn text_background_from(params: &ParamValues) -> TextBackground {
+ TextBackground {
+ enabled: bool_param(params, "background.enabled", DEFAULT_BACKGROUND_ENABLED),
+ color: string_param(params, "background.color", DEFAULT_BACKGROUND_COLOR),
+ corner_radius: number_param(
+ params,
+ "background.cornerRadius",
+ DEFAULT_BACKGROUND_CORNER_RADIUS,
+ ),
+ padding_x: number_param(params, "background.paddingX", DEFAULT_BACKGROUND_PADDING_X),
+ padding_y: number_param(params, "background.paddingY", DEFAULT_BACKGROUND_PADDING_Y),
+ offset_x: number_param(params, "background.offsetX", DEFAULT_BACKGROUND_OFFSET_X),
+ offset_y: number_param(params, "background.offsetY", DEFAULT_BACKGROUND_OFFSET_Y),
+ }
+}
+
+fn string_param(params: &ParamValues, key: &str, fallback: &str) -> String {
+ match params.get(key) {
+ Some(ParamValue::String(value)) => value.clone(),
+ _ => fallback.to_string(),
+ }
+}
+
+fn number_param(params: &ParamValues, key: &str, fallback: f64) -> f64 {
+ match params.get(key) {
+ Some(ParamValue::Number(value)) => *value,
+ _ => fallback,
+ }
+}
+
+fn bool_param(params: &ParamValues, key: &str, fallback: bool) -> bool {
+ match params.get(key) {
+ Some(ParamValue::Bool(value)) => *value,
+ _ => fallback,
+ }
+}
diff --git a/rust/crates/text/src/raster.rs b/rust/crates/text/src/raster.rs
new file mode 100644
index 00000000..de945696
--- /dev/null
+++ b/rust/crates/text/src/raster.rs
@@ -0,0 +1,265 @@
+use crate::color::Color;
+use crate::engine::{MeasuredTextLayout, TextEngine};
+use crate::layout::{align_offset, decoration_rect, text_background_rect, text_visual_rect};
+use crate::params::TextBackground;
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct RgbaBuffer {
+ pub width: u32,
+ pub height: u32,
+ pub pixels: Vec<u8>,
+}
+
+impl RgbaBuffer {
+ fn new(width: u32, height: u32) -> Self {
+ Self {
+ width,
+ height,
+ pixels: vec![0; width as usize * height as usize * 4],
+ }
+ }
+
+ pub fn pixel(&self, x: u32, y: u32) -> Color {
+ let index = (y as usize * self.width as usize + x as usize) * 4;
+ Color::rgba(
+ self.pixels[index],
+ self.pixels[index + 1],
+ self.pixels[index + 2],
+ self.pixels[index + 3],
+ )
+ }
+
+ fn blend_pixel(&mut self, x: i32, y: i32, color: Color) {
+ if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 || color.a == 0 {
+ return;
+ }
+ let index = (y as usize * self.width as usize + x as usize) * 4;
+ let src_alpha = u32::from(color.a);
+ let dst_alpha = u32::from(self.pixels[index + 3]);
+ let out_alpha = src_alpha + dst_alpha * (255 - src_alpha) / 255;
+ if out_alpha == 0 {
+ return;
+ }
+ for (channel, src) in [(0, color.r), (1, color.g), (2, color.b)] {
+ let dst = u32::from(self.pixels[index + channel]);
+ let blended = (u32::from(src) * src_alpha * 255
+ + dst * dst_alpha * (255 - src_alpha))
+ / (out_alpha * 255);
+ self.pixels[index + channel] = blended as u8;
+ }
+ self.pixels[index + 3] = out_alpha as u8;
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct StrokeStyle {
+ pub color: Color,
+ pub width: f64,
+}
+
+#[derive(Clone, Copy, Debug)]
+pub struct RasterOptions<'a> {
+ pub text_color: Color,
+ pub background: Option<(&'a TextBackground, Color)>,
+ pub stroke: Option<StrokeStyle>,
+}
+
+pub fn rasterize(
+ engine: &mut TextEngine,
+ measured: &MeasuredTextLayout,
+ options: &RasterOptions,
+) -> RgbaBuffer {
+ let resolved = &measured.resolved;
+ let disabled_background = TextBackground::default();
+ let rect_background = options
+ .background
+ .map(|(background, _)| background)
+ .unwrap_or(&disabled_background);
+ let visual = text_visual_rect(
+ resolved.text_align,
+ &measured.block,
+ rect_background,
+ resolved.font_size_ratio,
+ );
+ let width = visual.width.ceil().max(1.0) as u32;
+ let height = visual.height.ceil().max(1.0) as u32;
+ let mut output = RgbaBuffer::new(width, height);
+ let origin_x = -visual.left;
+ let origin_y = -visual.top;
+
+ if let Some((background, color)) = options.background {
+ if color.a > 0 {
+ if let Some(rect) = text_background_rect(
+ resolved.text_align,
+ &measured.block,
+ background,
+ resolved.font_size_ratio,
+ ) {
+ let radius = (background.corner_radius.clamp(
+ crate::params::CORNER_RADIUS_MIN,
+ crate::params::CORNER_RADIUS_MAX,
+ ) / 100.0)
+ * (rect.width.min(rect.height) / 2.0);
+ fill_rounded_rect(
+ &mut output,
+ origin_x + rect.left,
+ origin_y + rect.top,
+ rect.width,
+ rect.height,
+ radius,
+ color,
+ );
+ }
+ }
+ }
+
+ if options.text_color.a > 0 || options.stroke.is_some_and(|stroke| stroke.color.a > 0) {
+ draw_glyphs(engine, measured, &mut output, origin_x, origin_y, options);
+ }
+
+ if options.text_color.a > 0 {
+ for (index, metrics) in measured.line_metrics.iter().enumerate() {
+ let line_y =
+ index as f64 * resolved.line_height_px - measured.block.visual_center_offset;
+ if let Some(rect) = decoration_rect(
+ resolved.text_decoration,
+ resolved.text_align,
+ metrics.width,
+ line_y,
+ metrics.ascent,
+ metrics.descent,
+ resolved.scaled_font_size,
+ ) {
+ fill_rect(
+ &mut output,
+ origin_x + rect.left,
+ origin_y + rect.top,
+ rect.width,
+ rect.height,
+ options.text_color,
+ );
+ }
+ }
+ }
+
+ output
+}
+
+fn draw_glyphs(
+ engine: &mut TextEngine,
+ measured: &MeasuredTextLayout,
+ output: &mut RgbaBuffer,
+ origin_x: f64,
+ origin_y: f64,
+ options: &RasterOptions,
+) {
+ let resolved = &measured.resolved;
+ let y_shift = origin_y - measured.block.height / 2.0;
+ for run in measured.buffer.layout_runs() {
+ let raw_width = run
+ .glyphs
+ .iter()
+ .fold(0.0_f32, |max, glyph| max.max(glyph.x + glyph.w));
+ let line_width = (f64::from(raw_width) - resolved.letter_spacing).max(0.0);
+ let x_offset = origin_x + align_offset(resolved.text_align, line_width);
+ for glyph in run.glyphs {
+ let physical = glyph.physical((x_offset as f32, y_shift as f32), 1.0);
+ let baseline = run.line_y as i32;
+ if let Some(stroke) = options.stroke {
+ if stroke.color.a > 0 && stroke.width > 0.0 {
+ let cosmic = stroke.color.to_cosmic();
+ let offsets = stroke_offsets(stroke.width / 2.0);
+ engine.swash_cache.with_pixels(
+ &mut engine.font_system,
+ physical.cache_key,
+ cosmic,
+ |x, y, color| {
+ for (dx, dy) in &offsets {
+ output.blend_pixel(
+ physical.x + x + dx,
+ baseline + physical.y + y + dy,
+ Color::from_cosmic(color),
+ );
+ }
+ },
+ );
+ }
+ }
+ if options.text_color.a > 0 {
+ let cosmic = options.text_color.to_cosmic();
+ engine.swash_cache.with_pixels(
+ &mut engine.font_system,
+ physical.cache_key,
+ cosmic,
+ |x, y, color| {
+ output.blend_pixel(
+ physical.x + x,
+ baseline + physical.y + y,
+ Color::from_cosmic(color),
+ );
+ },
+ );
+ }
+ }
+ }
+}
+
+fn stroke_offsets(radius: f64) -> Vec<(i32, i32)> {
+ let mut offsets = Vec::new();
+ if radius < 0.5 {
+ offsets.push((0, 0));
+ return offsets;
+ }
+ let steps = ((radius * 6.0).ceil() as u32).max(16);
+ for step in 0..steps {
+ let angle = std::f64::consts::TAU * f64::from(step) / f64::from(steps);
+ let dx = (radius * angle.cos()).round() as i32;
+ let dy = (radius * angle.sin()).round() as i32;
+ if !offsets.contains(&(dx, dy)) {
+ offsets.push((dx, dy));
+ }
+ }
+ offsets
+}
+
+fn fill_rect(output: &mut RgbaBuffer, left: f64, top: f64, width: f64, height: f64, color: Color) {
+ let x_start = left.round() as i32;
+ let y_start = top.round() as i32;
+ let x_end = (left + width).round() as i32;
+ let y_end = (top + height).round() as i32;
+ for y in y_start..y_end {
+ for x in x_start..x_end {
+ output.blend_pixel(x, y, color);
+ }
+ }
+}
+
+fn fill_rounded_rect(
+ output: &mut RgbaBuffer,
+ left: f64,
+ top: f64,
+ width: f64,
+ height: f64,
+ radius: f64,
+ color: Color) {
+ let radius = radius.clamp(0.0, width.min(height) / 2.0);
+ let center_x = left + width / 2.0;
+ let center_y = top + height / 2.0;
+ let inner_x = (width / 2.0 - radius).max(0.0);
+ let inner_y = (height / 2.0 - radius).max(0.0);
+ let x_start = left.floor() as i32;
+ let y_start = top.floor() as i32;
+ let x_end = (left + width).ceil() as i32;
+ let y_end = (top + height).ceil() as i32;
+ for y in y_start..y_end {
+ for x in x_start..x_end {
+ let qx = (f64::from(x) + 0.5 - center_x).abs() - inner_x;
+ let qy = (f64::from(y) + 0.5 - center_y).abs() - inner_y;
+ let outside = qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius;
+ let coverage = (0.5 - outside).clamp(0.0, 1.0);
+ if coverage > 0.0 {
+ output.blend_pixel(x, y, color.scaled_alpha(coverage));
+ }
+ }
+ }
+}