Skip to main content

oxitext_core/
lib.rs

1//! `oxitext-core` — Core traits and value types for OxiText.
2//!
3//! This crate provides the shared data types used throughout the OxiText
4//! pipeline: [`ShapedGlyph`], [`ShapedRun`], [`PositionedGlyph`], [`Bitmap`],
5//! [`ColorBitmap`], [`LcdBitmap`], [`RenderOutput`],
6//! [`LayoutConstraints`], [`TextStyle`], [`FlowDirection`], and [`OxiTextError`].
7#![cfg_attr(not(feature = "std"), no_std)]
8#![forbid(unsafe_code)]
9#![warn(missing_docs)]
10
11#[cfg(not(feature = "std"))]
12extern crate alloc;
13
14#[cfg(not(feature = "std"))]
15use alloc::{string::String, sync::Arc, vec, vec::Vec};
16#[cfg(feature = "std")]
17use std::sync::Arc;
18
19use smallvec::SmallVec;
20
21/// Pure-Rust 8-bit PNG writer (feature `png-encode`).
22///
23/// Built on `oxiarc-deflate`/`oxiarc-core` so that PNG output never pulls the
24/// `flate2` + `miniz_oxide` pair banned by this repository's `deny.toml`.
25#[cfg(feature = "png-encode")]
26pub mod png_encode;
27
28/// A glyph produced by the shaper.
29#[derive(Debug, Clone)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub struct ShapedGlyph {
32    /// Glyph ID in the font.
33    pub gid: u16,
34    /// Horizontal advance in pixels (scaled by font size).
35    pub x_advance: f32,
36    /// Vertical advance (usually 0.0 for LTR text).
37    pub y_advance: f32,
38    /// Horizontal offset from the cursor position.
39    pub x_offset: f32,
40    /// Vertical offset from the baseline.
41    pub y_offset: f32,
42    /// Index into the source string (UTF-8 byte offset of cluster start).
43    pub cluster: u32,
44    /// `true` if this glyph represents whitespace (space, tab, newline).
45    ///
46    /// Layout engines use this to distinguish trimmable trailing whitespace
47    /// and to compute expandable gaps for justified text.
48    pub is_whitespace: bool,
49    /// `true` if breaking a line *before* this glyph is unsafe because the
50    /// glyph is part of a multi-glyph cluster (e.g. a ligature or a mark
51    /// attached to a base glyph). Mirrors HarfBuzz's `unsafe_to_break` flag.
52    pub unsafe_to_break: bool,
53}
54
55impl Default for ShapedGlyph {
56    /// A `.notdef` glyph (GID 0) with zero advance and zero offsets.
57    fn default() -> Self {
58        Self {
59            gid: 0,
60            x_advance: 0.0,
61            y_advance: 0.0,
62            x_offset: 0.0,
63            y_offset: 0.0,
64            cluster: 0,
65            is_whitespace: false,
66            unsafe_to_break: false,
67        }
68    }
69}
70
71/// Font-wide vertical metrics needed to compute line height, in font design
72/// units.
73///
74/// This is a deliberately minimal, font-library-agnostic mirror of the
75/// ascender/descender/line-gap fields found in a font's `hhea`/`OS/2` tables.
76/// Higher layers (e.g. the `oxitext` facade) translate their font library's
77/// richer metrics type into this struct so the layout engine stays free of any
78/// font-parser dependency.
79///
80/// Convert to pixels with `value * (font_size_px / units_per_em)`.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
83pub struct FontVerticalMetrics {
84    /// Design units per em (typically 1000 for CFF, 2048 for TrueType).
85    pub units_per_em: u16,
86    /// Typographic ascender in design units (positive, above baseline).
87    pub ascender: i16,
88    /// Typographic descender in design units (negative, below baseline).
89    pub descender: i16,
90    /// Typographic line gap (extra leading between lines), in design units.
91    pub line_gap: i16,
92}
93
94impl FontVerticalMetrics {
95    /// Returns the pixel ascent (always positive) at `font_size_px`.
96    pub fn ascent_px(&self, font_size_px: f32) -> f32 {
97        if self.units_per_em == 0 {
98            return font_size_px * 0.8;
99        }
100        self.ascender as f32 * font_size_px / self.units_per_em as f32
101    }
102
103    /// Returns the pixel descent depth (always positive) at `font_size_px`.
104    pub fn descent_px(&self, font_size_px: f32) -> f32 {
105        if self.units_per_em == 0 {
106            return font_size_px * 0.2;
107        }
108        (-(self.descender as f32)) * font_size_px / self.units_per_em as f32
109    }
110
111    /// Returns the pixel line gap at `font_size_px`.
112    pub fn line_gap_px(&self, font_size_px: f32) -> f32 {
113        if self.units_per_em == 0 {
114            return font_size_px * 0.4;
115        }
116        self.line_gap as f32 * font_size_px / self.units_per_em as f32
117    }
118}
119
120/// Per-glyph metrics usable for layout without rasterising.
121///
122/// All values are in pixels (already scaled by the rendering font size). The
123/// bearings follow the usual font conventions: `bearing_x` is the horizontal
124/// distance from the pen origin to the left edge of the glyph bounding box,
125/// and `bearing_y` is the vertical distance from the baseline to the top of
126/// the bounding box (positive = above the baseline).
127#[derive(Debug, Clone, Copy, PartialEq)]
128#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
129pub struct GlyphMetrics {
130    /// Horizontal distance from the pen origin to the left edge (signed).
131    pub bearing_x: f32,
132    /// Vertical distance from the baseline to the top edge (positive = up).
133    pub bearing_y: f32,
134    /// Horizontal advance in pixels.
135    pub advance_x: f32,
136    /// Vertical advance in pixels (usually `0.0` for horizontal text).
137    pub advance_y: f32,
138    /// Glyph bounding-box width in pixels.
139    pub width: f32,
140    /// Glyph bounding-box height in pixels.
141    pub height: f32,
142}
143
144impl Default for GlyphMetrics {
145    fn default() -> Self {
146        Self {
147            bearing_x: 0.0,
148            bearing_y: 0.0,
149            advance_x: 0.0,
150            advance_y: 0.0,
151            width: 0.0,
152            height: 0.0,
153        }
154    }
155}
156
157/// A group of [`ShapedGlyph`]s that together form a single user-perceived
158/// grapheme cluster (e.g. a base letter plus combining marks, or an emoji
159/// ZWJ sequence rendered as one glyph).
160///
161/// Clusters are the atomic unit for cursor movement, selection, and
162/// line-breaking: a layout engine must never split text inside a cluster.
163#[derive(Debug, Clone)]
164pub struct GlyphCluster {
165    /// The glyphs that make up this cluster, in logical order.
166    pub glyphs: Vec<ShapedGlyph>,
167    /// UTF-8 byte offset of the cluster start in the source string.
168    pub source_start: u32,
169    /// UTF-8 byte offset of the cluster end (exclusive) in the source string.
170    pub source_end: u32,
171}
172
173impl GlyphCluster {
174    /// Returns the total horizontal advance of all glyphs in the cluster.
175    pub fn advance(&self) -> f32 {
176        self.glyphs.iter().map(|g| g.x_advance).sum()
177    }
178
179    /// Returns `true` if the cluster contains no glyphs.
180    pub fn is_empty(&self) -> bool {
181        self.glyphs.is_empty()
182    }
183}
184
185/// A run of shaped glyphs sharing a single font face.
186#[derive(Debug, Clone)]
187pub struct ShapedRun {
188    /// Glyphs in this run, in logical order.
189    ///
190    /// Uses [`SmallVec`] with an inline capacity of 8 to avoid heap allocation
191    /// for the common case of short runs.
192    pub glyphs: SmallVec<[ShapedGlyph; 8]>,
193    /// Raw font bytes used to shape this run.
194    pub font_data: Arc<[u8]>,
195}
196
197/// A glyph positioned on the layout canvas.
198#[derive(Debug, Clone)]
199pub struct PositionedGlyph {
200    /// Glyph ID.
201    pub gid: u16,
202    /// Font data associated with this glyph.
203    pub font_data: Arc<[u8]>,
204    /// Position `(x, y)` in pixels from the top-left origin.
205    pub pos: (f32, f32),
206    /// Font size in pixels-per-em used to shape and rasterise this glyph.
207    ///
208    /// Carried per-glyph so that a single line may mix multiple sizes (e.g.
209    /// superscripts, mixed-style runs) and the rasteriser knows the size for
210    /// each glyph without re-deriving it from a shared style.
211    pub font_size: f32,
212    /// Horizontal advance in pixels (same unit as `pos`).
213    ///
214    /// Needed for hit-testing (cursor placement) and for determining a glyph's
215    /// x-extent without referencing the original `ShapedRun` again.
216    pub advance_x: f32,
217    /// UTF-8 byte offset of this glyph's cluster in the source text.
218    ///
219    /// Mirrors [`ShapedGlyph::cluster`]. Carried here so that hit-testing,
220    /// hanging-punctuation checks, and other post-layout passes can identify
221    /// the source codepoint without walking the original `ShapedRun` list.
222    pub cluster: u32,
223}
224
225/// A greyscale glyph bitmap.
226#[derive(Debug, Clone)]
227#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
228pub struct Bitmap {
229    /// Width in pixels.
230    pub width: u32,
231    /// Height in pixels.
232    pub height: u32,
233    /// Pixel data, one byte per pixel (0 = transparent, 255 = fully opaque).
234    pub pixels: Vec<u8>,
235}
236
237impl Bitmap {
238    /// Returns `true` if the bitmap has zero area (no visible pixels), as is
239    /// the case for whitespace glyphs.
240    pub fn is_empty(&self) -> bool {
241        self.width == 0 || self.height == 0 || self.pixels.is_empty()
242    }
243
244    /// Invert the coverage values (`255 - x`) for use in inside/outside SDF generation.
245    ///
246    /// Coverage bitmaps from rasterizers use 255 = opaque, 0 = transparent.
247    /// Some SDF algorithms expect the inverse convention where 0 = inside the
248    /// glyph outline. This method produces a new bitmap with all values flipped.
249    pub fn invert_coverage(&self) -> Self {
250        Bitmap {
251            width: self.width,
252            height: self.height,
253            pixels: self.pixels.iter().map(|&v| 255 - v).collect(),
254        }
255    }
256
257    /// Return a copy with pixels below the threshold set to 0, above (or equal) to 255.
258    ///
259    /// Useful for binarizing a greyscale coverage map before Euclidean Distance
260    /// Transform (EDT) so that only fully-inside and fully-outside pixels are
261    /// distinguished.
262    pub fn threshold(&self, threshold: u8) -> Self {
263        Bitmap {
264            width: self.width,
265            height: self.height,
266            pixels: self
267                .pixels
268                .iter()
269                .map(|&v| if v >= threshold { 255 } else { 0 })
270                .collect(),
271        }
272    }
273
274    /// Return a cropped sub-bitmap starting at pixel `(x, y)` with the given
275    /// `width` and `height`. Out-of-bounds source regions are filled with 0.
276    pub fn crop(&self, x: u32, y: u32, width: u32, height: u32) -> Self {
277        let mut pixels = vec![0u8; (width * height) as usize];
278        for row in 0..height {
279            for col in 0..width {
280                let src_x = x + col;
281                let src_y = y + row;
282                if src_x < self.width && src_y < self.height {
283                    let src_idx = (src_y * self.width + src_x) as usize;
284                    let dst_idx = (row * width + col) as usize;
285                    pixels[dst_idx] = self.pixels[src_idx];
286                }
287            }
288        }
289        Bitmap {
290            width,
291            height,
292            pixels,
293        }
294    }
295
296    /// Return the minimum bounding box of non-zero pixels, useful for tight
297    /// SDF tile sizing and atlas packing.
298    ///
299    /// Returns `(x_min, y_min, x_max, y_max)` in pixel coordinates, or `None`
300    /// if the bitmap contains no non-zero pixels (e.g. a space glyph).
301    pub fn tight_bounds(&self) -> Option<(u32, u32, u32, u32)> {
302        let mut x_min = self.width;
303        let mut y_min = self.height;
304        let mut x_max = 0u32;
305        let mut y_max = 0u32;
306
307        for row in 0..self.height {
308            for col in 0..self.width {
309                if self.pixels[(row * self.width + col) as usize] > 0 {
310                    x_min = x_min.min(col);
311                    y_min = y_min.min(row);
312                    x_max = x_max.max(col);
313                    y_max = y_max.max(row);
314                }
315            }
316        }
317
318        if x_min > x_max {
319            None
320        } else {
321            Some((x_min, y_min, x_max, y_max))
322        }
323    }
324}
325
326/// An RGBA color glyph bitmap.
327///
328/// Produced by color-font rendering (COLR/CPAL, CBDT, sbix, SVG). Pixels are
329/// stored in row-major RGBA order, four bytes per pixel.
330#[derive(Debug, Clone)]
331#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
332pub struct ColorBitmap {
333    /// Width in pixels.
334    pub width: u32,
335    /// Height in pixels.
336    pub height: u32,
337    /// Pixel data in RGBA order: `width * height * 4` bytes.
338    pub rgba: Vec<u8>,
339}
340
341impl ColorBitmap {
342    /// Returns `true` if the bitmap has zero area.
343    pub fn is_empty(&self) -> bool {
344        self.width == 0 || self.height == 0 || self.rgba.is_empty()
345    }
346}
347
348/// An LCD subpixel bitmap.
349///
350/// Stores three bytes per pixel (R, G, B) corresponding to the physical
351/// sub-pixel layout of an LCD screen. LCD rendering allows individual
352/// sub-pixel addressing for smoother horizontal antialiasing at small
353/// sizes on colour displays.
354///
355/// The buffer length must equal `width * height * 3`.
356#[derive(Debug, Clone)]
357#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
358pub struct LcdBitmap {
359    /// Width in pixels (each pixel contains 3 sub-pixel bytes).
360    pub width: u32,
361    /// Height in pixels.
362    pub height: u32,
363    /// Sub-pixel data in RGB order: `width * height * 3` bytes.
364    pub rgb: Vec<u8>,
365}
366
367impl LcdBitmap {
368    /// Constructs a new [`LcdBitmap`] from its components.
369    ///
370    /// # Panics (debug only)
371    ///
372    /// In debug builds a debug assertion fires if `rgb.len()` does not equal
373    /// `width * height * 3`, catching accidental buffer-size mismatches early.
374    pub fn new(width: u32, height: u32, rgb: Vec<u8>) -> Self {
375        debug_assert_eq!(
376            rgb.len(),
377            (width as usize) * (height as usize) * 3,
378            "LcdBitmap: rgb buffer length must equal width * height * 3"
379        );
380        Self { width, height, rgb }
381    }
382
383    /// Returns `true` if the bitmap has zero area.
384    pub fn is_empty(&self) -> bool {
385        self.width == 0 || self.height == 0 || self.rgb.is_empty()
386    }
387}
388
389/// Unified per-glyph render output.
390///
391/// Lets a rendering pipeline return greyscale, color, SDF, LCD subpixel, or
392/// multi-channel SDF output through a single channel so callers can handle a
393/// mixed set of glyphs uniformly.
394#[derive(Debug, Clone)]
395#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
396pub enum RenderOutput {
397    /// A greyscale coverage bitmap.
398    Greyscale(Bitmap),
399    /// An RGBA color bitmap (color fonts).
400    Color(ColorBitmap),
401    /// A single-channel signed-distance-field tile (`width * height` bytes).
402    Sdf {
403        /// Tile width in pixels.
404        width: u32,
405        /// Tile height in pixels.
406        height: u32,
407        /// SDF bytes (`< 128` outside, `≈ 128` outline, `> 128` inside).
408        data: Vec<u8>,
409    },
410    /// An LCD subpixel bitmap (three bytes per pixel: R, G, B channels).
411    ///
412    /// Used for ClearType / FreeType LCD rendering to achieve sub-pixel
413    /// horizontal precision on colour LCD displays.
414    Lcd(LcdBitmap),
415    /// A multi-channel signed-distance-field tile.
416    ///
417    /// MSDF encodes the distance field across three independent colour channels
418    /// to resolve corner artefacts that appear in single-channel SDF at large
419    /// magnifications. The data layout is `width * height * 3` bytes (RGB).
420    Msdf {
421        /// Tile width in pixels.
422        width: u32,
423        /// Tile height in pixels.
424        height: u32,
425        /// MSDF bytes in RGB order: `width * height * 3` bytes.
426        data: Vec<u8>,
427    },
428}
429
430/// Layout constraints for the layouter.
431#[derive(Debug, Clone)]
432#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
433pub struct LayoutConstraints {
434    /// Maximum line width in pixels (0.0 = no wrap).
435    pub max_width: f32,
436    /// Font size in points.
437    pub font_size: f32,
438}
439
440impl Default for LayoutConstraints {
441    fn default() -> Self {
442        Self {
443            max_width: 800.0,
444            font_size: 16.0,
445        }
446    }
447}
448
449/// Text flow direction for a rendering run.
450///
451/// Governs how the layout engine advances the cursor between glyphs and lines.
452/// Horizontal is the default (left-to-right or bidi-resolved RTL within lines).
453/// Vertical enables top-to-bottom CJK flow as per UAX #50.
454#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
455#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
456pub enum FlowDirection {
457    /// Standard horizontal text (LTR/RTL decided by bidi algorithm).
458    #[default]
459    Horizontal,
460    /// Vertical text, advancing top-to-bottom (used for CJK vertical layout).
461    Vertical,
462}
463
464/// Horizontal text alignment within the layout's line box.
465///
466/// Per CSS Text Module Level 3 `text-align`.
467#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
468#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
469pub enum TextAlignment {
470    /// Align lines to the start (left edge for LTR, right edge for RTL).
471    #[default]
472    Left,
473    /// Align lines to the right edge.
474    Right,
475    /// Center lines within the available width.
476    Center,
477    /// Stretch lines to fill the available width by expanding inter-word gaps
478    /// (the last line of a paragraph is not justified).
479    Justify,
480}
481
482/// CSS Writing Modes Level 4 `writing-mode`.
483///
484/// Determines the block flow direction and inline base direction. This is a
485/// richer companion to [`FlowDirection`]: `HorizontalTb` corresponds to
486/// [`FlowDirection::Horizontal`], while the two vertical modes map to
487/// [`FlowDirection::Vertical`] with differing block progression.
488#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
489#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
490pub enum WritingMode {
491    /// Horizontal lines stacked top-to-bottom (Latin, Cyrillic, etc.).
492    #[default]
493    HorizontalTb,
494    /// Vertical lines progressing right-to-left (traditional CJK).
495    VerticalRl,
496    /// Vertical lines progressing left-to-right (Mongolian, some CJK).
497    VerticalLr,
498}
499
500impl WritingMode {
501    /// Returns the [`FlowDirection`] implied by this writing mode.
502    pub fn flow_direction(self) -> FlowDirection {
503        match self {
504            WritingMode::HorizontalTb => FlowDirection::Horizontal,
505            WritingMode::VerticalRl | WritingMode::VerticalLr => FlowDirection::Vertical,
506        }
507    }
508
509    /// Returns `true` if this writing mode lays text out vertically.
510    pub fn is_vertical(self) -> bool {
511        !matches!(self, WritingMode::HorizontalTb)
512    }
513}
514
515/// Line spacing configuration.
516///
517/// The effective line height is computed as
518/// `font_ascent + font_descent + line_gap` (the font's natural line height)
519/// multiplied by `line_height_multiplier`, plus `leading` extra pixels.
520#[derive(Debug, Clone, Copy, PartialEq)]
521#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
522pub struct LineSpacing {
523    /// Extra leading added between baselines, in pixels.
524    pub leading: f32,
525    /// Multiplier applied to the natural font line height (1.0 = single).
526    pub line_height_multiplier: f32,
527}
528
529impl Default for LineSpacing {
530    fn default() -> Self {
531        Self {
532            leading: 0.0,
533            line_height_multiplier: 1.0,
534        }
535    }
536}
537
538impl LineSpacing {
539    /// Computes the effective line height in pixels from a natural line height.
540    pub fn resolve(&self, natural_line_height: f32) -> f32 {
541        natural_line_height * self.line_height_multiplier + self.leading
542    }
543}
544
545/// An sRGB color with straight (non-premultiplied) alpha.
546#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
547#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
548pub struct Rgba8 {
549    /// Red channel (0–255).
550    pub r: u8,
551    /// Green channel (0–255).
552    pub g: u8,
553    /// Blue channel (0–255).
554    pub b: u8,
555    /// Alpha channel (0 = transparent, 255 = opaque).
556    pub a: u8,
557}
558
559impl Rgba8 {
560    /// Opaque black.
561    pub const BLACK: Rgba8 = Rgba8 {
562        r: 0,
563        g: 0,
564        b: 0,
565        a: 255,
566    };
567    /// Fully transparent.
568    pub const TRANSPARENT: Rgba8 = Rgba8 {
569        r: 0,
570        g: 0,
571        b: 0,
572        a: 0,
573    };
574
575    /// Constructs a new color from components.
576    pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
577        Self { r, g, b, a }
578    }
579}
580
581impl Default for Rgba8 {
582    fn default() -> Self {
583        Rgba8::BLACK
584    }
585}
586
587/// A single text decoration line (underline, overline, or strikethrough).
588///
589/// Position and thickness are in pixels relative to the text baseline.
590#[derive(Debug, Clone, Copy, PartialEq)]
591#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
592pub struct DecorationLine {
593    /// Distance from the baseline to the decoration line, in pixels. By
594    /// convention positive values are above the baseline (overline,
595    /// strikethrough) and negative values below (underline).
596    pub position: f32,
597    /// Stroke thickness in pixels.
598    pub thickness: f32,
599    /// Decoration color.
600    pub color: Rgba8,
601}
602
603/// A text decoration style applied to a run of text.
604///
605/// Describes the visual decoration (underline, overline, or strikethrough) and
606/// its rendering parameters. Used with `LayoutOptions::decoration` to
607/// produce [`DecorationRect`]s from a layout pass.
608#[derive(Debug, Clone, Copy, PartialEq)]
609#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
610pub enum TextDecoration {
611    /// Underline drawn below the text baseline.
612    Underline {
613        /// Color of the underline (RGBA).
614        color: Rgba8,
615        /// Thickness in pixels (default: 1.0).
616        thickness: f32,
617        /// Vertical offset from baseline in pixels (positive = downward).
618        offset: f32,
619    },
620    /// Overline drawn above the ascender line.
621    Overline {
622        /// Color of the overline (RGBA).
623        color: Rgba8,
624        /// Thickness in pixels.
625        thickness: f32,
626        /// Vertical offset from the top of the ascender (positive = upward
627        /// from the ascender line).
628        offset: f32,
629    },
630    /// Strikethrough drawn through the middle of the text (at x-height
631    /// midpoint).
632    Strikethrough {
633        /// Color of the strikethrough (RGBA).
634        color: Rgba8,
635        /// Thickness in pixels.
636        thickness: f32,
637    },
638}
639
640/// A positioned decoration rectangle ready to be composited onto the output
641/// canvas.
642///
643/// Produced by the layout engine when `LayoutOptions::decoration` is set.
644/// The caller is responsible for painting the rectangle (e.g. by calling
645/// `RenderResult::composite_to_rgba` which applies decorations
646/// automatically).
647#[derive(Debug, Clone, Copy, PartialEq)]
648#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
649pub struct DecorationRect {
650    /// Left edge in canvas pixels.
651    pub x: f32,
652    /// Top edge in canvas pixels.
653    pub y: f32,
654    /// Width in canvas pixels.
655    pub width: f32,
656    /// Height in canvas pixels (equals the decoration thickness).
657    pub height: f32,
658    /// Color of the decoration.
659    pub color: Rgba8,
660}
661
662/// Text decorations applied to a run: underline, overline, strikethrough.
663///
664/// Each field is `Some` when the corresponding decoration is enabled. Default
665/// is no decorations.
666#[derive(Debug, Clone, Copy, PartialEq, Default)]
667#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
668pub struct Decoration {
669    /// Underline (below the baseline), if any.
670    pub underline: Option<DecorationLine>,
671    /// Overline (above the text), if any.
672    pub overline: Option<DecorationLine>,
673    /// Strikethrough (through the text), if any.
674    pub strikethrough: Option<DecorationLine>,
675}
676
677impl Decoration {
678    /// Returns `true` if any decoration line is enabled.
679    pub fn any(&self) -> bool {
680        self.underline.is_some() || self.overline.is_some() || self.strikethrough.is_some()
681    }
682}
683
684/// Text rendering style.
685#[derive(Debug, Clone)]
686#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
687pub struct TextStyle {
688    /// Font size in points.
689    pub font_size: f32,
690    /// Maximum line width in pixels (0.0 = no wrap).
691    pub max_width: f32,
692    /// Text flow direction (horizontal or vertical).
693    pub flow_direction: FlowDirection,
694    /// Horizontal alignment of laid-out lines.
695    pub alignment: TextAlignment,
696    /// Line spacing configuration.
697    pub line_spacing: LineSpacing,
698}
699
700impl Default for TextStyle {
701    fn default() -> Self {
702        Self {
703            font_size: 16.0,
704            max_width: 800.0,
705            flow_direction: FlowDirection::Horizontal,
706            alignment: TextAlignment::Left,
707            line_spacing: LineSpacing::default(),
708        }
709    }
710}
711
712impl TextStyle {
713    /// Returns a copy of this style with the given alignment.
714    pub fn with_alignment(mut self, alignment: TextAlignment) -> Self {
715        self.alignment = alignment;
716        self
717    }
718
719    /// Returns a copy of this style with the given font size (pixels-per-em).
720    pub fn with_font_size(mut self, font_size: f32) -> Self {
721        self.font_size = font_size;
722        self
723    }
724
725    /// Returns a copy of this style with the given maximum line width.
726    pub fn with_max_width(mut self, max_width: f32) -> Self {
727        self.max_width = max_width;
728        self
729    }
730
731    /// Returns a copy of this style with the given flow direction.
732    pub fn with_flow_direction(mut self, flow_direction: FlowDirection) -> Self {
733        self.flow_direction = flow_direction;
734        self
735    }
736}
737
738/// Paragraph-level layout style.
739///
740/// Governs alignment, indentation, vertical spacing around the paragraph, and
741/// base direction. Per CSS Text / Writing Modes.
742#[derive(Debug, Clone)]
743#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
744pub struct ParagraphStyle {
745    /// Horizontal alignment of lines within the paragraph.
746    pub alignment: TextAlignment,
747    /// First-line indent in pixels.
748    pub indent: f32,
749    /// Vertical space before the paragraph, in pixels.
750    pub spacing_before: f32,
751    /// Vertical space after the paragraph, in pixels.
752    pub spacing_after: f32,
753    /// Base flow direction for the paragraph.
754    pub direction: FlowDirection,
755    /// Line spacing within the paragraph.
756    pub line_spacing: LineSpacing,
757}
758
759impl Default for ParagraphStyle {
760    fn default() -> Self {
761        Self {
762            alignment: TextAlignment::Left,
763            indent: 0.0,
764            spacing_before: 0.0,
765            spacing_after: 0.0,
766            direction: FlowDirection::Horizontal,
767            line_spacing: LineSpacing::default(),
768        }
769    }
770}
771
772/// A styled span of text within a paragraph.
773///
774/// Pairs a text slice with the font bytes to shape it and a [`TextStyle`].
775/// Used by multi-style ("rich text") layout where a single paragraph mixes
776/// fonts, sizes, and decorations.
777#[derive(Debug, Clone)]
778pub struct TextRun {
779    /// The text content of this run.
780    pub text: String,
781    /// Font bytes used to shape and rasterise this run.
782    pub font_data: Arc<[u8]>,
783    /// Rendering style for this run.
784    pub style: TextStyle,
785    /// Optional text decorations for this run.
786    pub decoration: Decoration,
787}
788
789/// An inline object (image, custom widget) that can be positioned inline with text.
790/// The layout engine treats it as a glyph with known advance and baseline offset.
791#[derive(Debug, Clone, PartialEq)]
792pub struct InlineObject {
793    /// Unique identifier for this object (caller-defined, used for lookup after layout).
794    pub id: u64,
795    /// Width in pixels.
796    pub width: f32,
797    /// Height in pixels.
798    pub height: f32,
799    /// Offset from the text baseline in pixels (positive = above baseline, for typical images).
800    pub baseline_offset: f32,
801    /// Horizontal advance (usually == width, but may differ for glyph-adjacent images).
802    pub advance: f32,
803}
804
805/// A positioned inline object from a layout pass.
806#[derive(Debug, Clone, PartialEq)]
807pub struct PositionedInlineObject {
808    /// The inline object descriptor.
809    pub object: InlineObject,
810    /// X position in canvas pixels.
811    pub x: f32,
812    /// Y position in canvas pixels (of the baseline).
813    pub y: f32,
814    /// Line index (0-based) this object is placed on.
815    pub line: usize,
816}
817
818/// Vertical text positioning for subscript/superscript effects.
819#[derive(Debug, Clone, Copy, PartialEq, Default)]
820pub enum VerticalPosition {
821    /// Normal baseline.
822    #[default]
823    Normal,
824    /// Superscript: smaller text raised above the baseline.
825    Superscript {
826        /// Font size ratio (e.g. 0.6 for 60% of base size).
827        size_ratio: f32,
828        /// Baseline rise in pixels (positive = upward).
829        baseline_rise: f32,
830    },
831    /// Subscript: smaller text lowered below the baseline.
832    Subscript {
833        /// Font size ratio (e.g. 0.6 for 60% of base size).
834        size_ratio: f32,
835        /// Baseline drop in pixels (positive = downward).
836        baseline_drop: f32,
837    },
838}
839
840impl VerticalPosition {
841    /// Compute the actual font size for this position given a base size.
842    pub fn effective_size(&self, base_px: f32) -> f32 {
843        match self {
844            Self::Normal => base_px,
845            Self::Superscript { size_ratio, .. } => base_px * size_ratio,
846            Self::Subscript { size_ratio, .. } => base_px * size_ratio,
847        }
848    }
849
850    /// Compute the Y baseline adjustment in pixels (positive = upward).
851    pub fn baseline_adjustment(&self, _base_px: f32) -> f32 {
852        match self {
853            Self::Normal => 0.0,
854            Self::Superscript { baseline_rise, .. } => *baseline_rise,
855            Self::Subscript { baseline_drop, .. } => -*baseline_drop,
856        }
857    }
858}
859
860/// Errors returned by the OxiText pipeline.
861#[derive(Debug)]
862pub enum OxiTextError {
863    /// An error occurred during glyph shaping.
864    Shaping(String),
865    /// An error occurred during layout computation.
866    Layout(String),
867    /// An error occurred during glyph rasterization.
868    Raster(String),
869    /// No usable font was found.
870    FontNotFound,
871    /// The supplied font data is corrupt or uses an unsupported format.
872    InvalidFont,
873    /// A miscellaneous error not covered by a more specific variant.
874    Other(String),
875}
876
877impl core::fmt::Display for OxiTextError {
878    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
879        match self {
880            OxiTextError::Shaping(s) => write!(f, "shaping error: {s}"),
881            OxiTextError::Layout(s) => write!(f, "layout error: {s}"),
882            OxiTextError::Raster(s) => write!(f, "raster error: {s}"),
883            OxiTextError::FontNotFound => write!(f, "font not found"),
884            OxiTextError::InvalidFont => write!(f, "invalid font"),
885            OxiTextError::Other(s) => write!(f, "text error: {s}"),
886        }
887    }
888}
889
890impl core::error::Error for OxiTextError {}
891
892impl RenderOutput {
893    /// Extracts the greyscale [`Bitmap`] from a [`RenderOutput::Greyscale`] variant,
894    /// returning `None` for all other variants.
895    pub fn into_bitmap(self) -> Option<Bitmap> {
896        match self {
897            RenderOutput::Greyscale(b) => Some(b),
898            _ => None,
899        }
900    }
901}
902
903impl From<RenderOutput> for Option<Bitmap> {
904    /// Converts a [`RenderOutput`] into `Some(Bitmap)` for the greyscale variant,
905    /// or `None` for all other variants.
906    fn from(output: RenderOutput) -> Self {
907        output.into_bitmap()
908    }
909}
910
911#[cfg(all(test, feature = "std"))]
912mod tests {
913    use super::*;
914    use std::sync::Arc;
915
916    #[test]
917    fn layout_constraints_default_values() {
918        let c = LayoutConstraints::default();
919        assert_eq!(c.max_width, 800.0);
920        assert_eq!(c.font_size, 16.0);
921    }
922
923    #[test]
924    fn text_style_default_values() {
925        let s = TextStyle::default();
926        assert_eq!(s.font_size, 16.0);
927        assert_eq!(s.max_width, 800.0);
928        assert_eq!(s.flow_direction, FlowDirection::Horizontal);
929        assert_eq!(s.alignment, TextAlignment::Left);
930        assert_eq!(s.line_spacing.line_height_multiplier, 1.0);
931    }
932
933    #[test]
934    fn text_style_builders() {
935        let s = TextStyle::default()
936            .with_alignment(TextAlignment::Center)
937            .with_font_size(24.0)
938            .with_max_width(400.0);
939        assert_eq!(s.alignment, TextAlignment::Center);
940        assert_eq!(s.font_size, 24.0);
941        assert_eq!(s.max_width, 400.0);
942    }
943
944    #[test]
945    fn shaped_glyph_default_is_notdef() {
946        let g = ShapedGlyph::default();
947        assert_eq!(g.gid, 0);
948        assert_eq!(g.x_advance, 0.0);
949        assert!(!g.is_whitespace);
950        assert!(!g.unsafe_to_break);
951    }
952
953    #[test]
954    fn glyph_metrics_default_is_zero() {
955        let m = GlyphMetrics::default();
956        assert_eq!(m.advance_x, 0.0);
957        assert_eq!(m.width, 0.0);
958    }
959
960    #[test]
961    fn writing_mode_flow_direction_mapping() {
962        assert_eq!(
963            WritingMode::HorizontalTb.flow_direction(),
964            FlowDirection::Horizontal
965        );
966        assert_eq!(
967            WritingMode::VerticalRl.flow_direction(),
968            FlowDirection::Vertical
969        );
970        assert_eq!(
971            WritingMode::VerticalLr.flow_direction(),
972            FlowDirection::Vertical
973        );
974        assert!(!WritingMode::HorizontalTb.is_vertical());
975        assert!(WritingMode::VerticalRl.is_vertical());
976    }
977
978    #[test]
979    fn line_spacing_resolve() {
980        let ls = LineSpacing {
981            leading: 2.0,
982            line_height_multiplier: 1.5,
983        };
984        // natural 20 → 20*1.5 + 2 = 32
985        assert!((ls.resolve(20.0) - 32.0).abs() < f32::EPSILON);
986        let def = LineSpacing::default();
987        assert!((def.resolve(20.0) - 20.0).abs() < f32::EPSILON);
988    }
989
990    #[test]
991    fn decoration_any_flag() {
992        let none = Decoration::default();
993        assert!(!none.any());
994        let under = Decoration {
995            underline: Some(DecorationLine {
996                position: -2.0,
997                thickness: 1.0,
998                color: Rgba8::BLACK,
999            }),
1000            ..Default::default()
1001        };
1002        assert!(under.any());
1003    }
1004
1005    #[test]
1006    fn glyph_cluster_advance_and_empty() {
1007        let empty = GlyphCluster {
1008            glyphs: vec![],
1009            source_start: 0,
1010            source_end: 0,
1011        };
1012        assert!(empty.is_empty());
1013        assert_eq!(empty.advance(), 0.0);
1014
1015        let cluster = GlyphCluster {
1016            glyphs: vec![
1017                ShapedGlyph {
1018                    x_advance: 10.0,
1019                    ..Default::default()
1020                },
1021                ShapedGlyph {
1022                    x_advance: 5.0,
1023                    ..Default::default()
1024                },
1025            ],
1026            source_start: 0,
1027            source_end: 3,
1028        };
1029        assert!(!cluster.is_empty());
1030        assert!((cluster.advance() - 15.0).abs() < f32::EPSILON);
1031    }
1032
1033    #[test]
1034    fn bitmap_and_color_bitmap_empty() {
1035        let bm = Bitmap {
1036            width: 0,
1037            height: 0,
1038            pixels: vec![],
1039        };
1040        assert!(bm.is_empty());
1041        let cbm = ColorBitmap {
1042            width: 2,
1043            height: 2,
1044            rgba: vec![0; 16],
1045        };
1046        assert!(!cbm.is_empty());
1047    }
1048
1049    #[test]
1050    fn render_output_variants_construct() {
1051        let g = RenderOutput::Greyscale(Bitmap {
1052            width: 1,
1053            height: 1,
1054            pixels: vec![255],
1055        });
1056        let c = RenderOutput::Color(ColorBitmap {
1057            width: 1,
1058            height: 1,
1059            rgba: vec![0, 0, 0, 255],
1060        });
1061        let s = RenderOutput::Sdf {
1062            width: 1,
1063            height: 1,
1064            data: vec![128],
1065        };
1066        let lcd = RenderOutput::Lcd(LcdBitmap::new(1, 1, vec![255, 0, 0]));
1067        let msdf = RenderOutput::Msdf {
1068            width: 1,
1069            height: 1,
1070            data: vec![100, 128, 200],
1071        };
1072        // Pattern-match to exercise each arm.
1073        assert!(matches!(g, RenderOutput::Greyscale(_)));
1074        assert!(matches!(c, RenderOutput::Color(_)));
1075        assert!(matches!(s, RenderOutput::Sdf { .. }));
1076        assert!(matches!(lcd, RenderOutput::Lcd(_)));
1077        assert!(matches!(msdf, RenderOutput::Msdf { .. }));
1078    }
1079
1080    #[test]
1081    fn lcd_bitmap_new_constructor() {
1082        let bm = LcdBitmap::new(4, 2, vec![0u8; 4 * 2 * 3]);
1083        assert_eq!(bm.width, 4);
1084        assert_eq!(bm.height, 2);
1085        assert_eq!(bm.rgb.len(), 24);
1086        assert!(!bm.is_empty());
1087    }
1088
1089    #[test]
1090    fn lcd_bitmap_is_empty() {
1091        let empty_w = LcdBitmap {
1092            width: 0,
1093            height: 1,
1094            rgb: vec![],
1095        };
1096        assert!(empty_w.is_empty());
1097        let empty_h = LcdBitmap {
1098            width: 1,
1099            height: 0,
1100            rgb: vec![],
1101        };
1102        assert!(empty_h.is_empty());
1103        let empty_buf = LcdBitmap {
1104            width: 1,
1105            height: 1,
1106            rgb: vec![],
1107        };
1108        assert!(empty_buf.is_empty());
1109    }
1110
1111    #[test]
1112    fn msdf_variant_fields() {
1113        let msdf = RenderOutput::Msdf {
1114            width: 8,
1115            height: 8,
1116            data: vec![0u8; 8 * 8 * 3],
1117        };
1118        if let RenderOutput::Msdf {
1119            width,
1120            height,
1121            data,
1122        } = &msdf
1123        {
1124            assert_eq!(*width, 8);
1125            assert_eq!(*height, 8);
1126            assert_eq!(data.len(), 192);
1127        } else {
1128            panic!("expected Msdf variant");
1129        }
1130    }
1131
1132    #[test]
1133    fn positioned_glyph_carries_font_size() {
1134        let pg = PositionedGlyph {
1135            gid: 5,
1136            font_data: Arc::from(&[][..]),
1137            pos: (1.0, 2.0),
1138            font_size: 18.0,
1139            advance_x: 12.0,
1140            cluster: 0,
1141        };
1142        assert_eq!(pg.font_size, 18.0);
1143    }
1144
1145    #[test]
1146    fn text_run_construction() {
1147        let run = TextRun {
1148            text: "hi".to_string(),
1149            font_data: Arc::from(&[][..]),
1150            style: TextStyle::default(),
1151            decoration: Decoration::default(),
1152        };
1153        assert_eq!(run.text, "hi");
1154        assert!(!run.decoration.any());
1155    }
1156
1157    #[test]
1158    fn flow_direction_is_hashable() {
1159        use std::collections::HashSet;
1160        let mut set = HashSet::new();
1161        set.insert(FlowDirection::Horizontal);
1162        set.insert(FlowDirection::Vertical);
1163        set.insert(FlowDirection::Horizontal);
1164        assert_eq!(set.len(), 2);
1165    }
1166
1167    #[test]
1168    fn text_alignment_is_hashable() {
1169        use std::collections::HashMap;
1170        let mut map = HashMap::new();
1171        map.insert(TextAlignment::Left, 1);
1172        map.insert(TextAlignment::Center, 2);
1173        assert_eq!(map.get(&TextAlignment::Left), Some(&1));
1174    }
1175
1176    #[test]
1177    fn oxitext_error_display_all_variants() {
1178        assert_eq!(
1179            OxiTextError::Shaping("x".into()).to_string(),
1180            "shaping error: x"
1181        );
1182        assert_eq!(
1183            OxiTextError::Layout("x".into()).to_string(),
1184            "layout error: x"
1185        );
1186        assert_eq!(
1187            OxiTextError::Raster("x".into()).to_string(),
1188            "raster error: x"
1189        );
1190        assert_eq!(OxiTextError::FontNotFound.to_string(), "font not found");
1191        assert_eq!(OxiTextError::InvalidFont.to_string(), "invalid font");
1192        assert_eq!(OxiTextError::Other("x".into()).to_string(), "text error: x");
1193    }
1194
1195    // ── Test 1a: FlowDirection property tests ────────────────────────────────
1196
1197    #[test]
1198    fn test_flow_direction_equality() {
1199        assert_eq!(FlowDirection::Horizontal, FlowDirection::Horizontal);
1200        assert_ne!(FlowDirection::Horizontal, FlowDirection::Vertical);
1201    }
1202
1203    #[test]
1204    fn test_flow_direction_clone() {
1205        let a = FlowDirection::Vertical;
1206        #[allow(clippy::clone_on_copy)]
1207        let b = Clone::clone(&a);
1208        assert_eq!(a, b);
1209    }
1210
1211    #[test]
1212    fn test_flow_direction_debug() {
1213        let s = format!("{:?}", FlowDirection::Horizontal);
1214        assert!(s.contains("Horizontal"));
1215    }
1216
1217    #[test]
1218    fn test_text_alignment_ordering() {
1219        // TextAlignment should support equality
1220        assert_eq!(TextAlignment::Left, TextAlignment::Left);
1221        assert_ne!(TextAlignment::Left, TextAlignment::Right);
1222    }
1223
1224    // ── Test 1b: ShapedGlyph with negative offsets (combining marks) ─────────
1225
1226    #[test]
1227    fn test_shaped_glyph_negative_offsets() {
1228        // Combining marks (diacritics) have negative y_offset to position above the base
1229        let g = ShapedGlyph {
1230            gid: 0x301,     // combining acute accent
1231            x_advance: 0.0, // zero-width
1232            y_advance: 0.0,
1233            x_offset: -2.5, // shifted left onto the base glyph
1234            y_offset: -8.0, // shifted up above baseline
1235            cluster: 0,
1236            is_whitespace: false,
1237            unsafe_to_break: true, // unsafe to break with base glyph
1238        };
1239        assert!(g.x_offset < 0.0);
1240        assert!(g.y_offset < 0.0);
1241        assert!(g.unsafe_to_break);
1242        assert_eq!(g.x_advance, 0.0);
1243    }
1244
1245    #[test]
1246    fn test_shaped_glyph_default_is_notdef() {
1247        let g = ShapedGlyph::default();
1248        assert_eq!(g.gid, 0);
1249        assert_eq!(g.x_advance, 0.0);
1250        assert!(!g.unsafe_to_break);
1251    }
1252
1253    // ── Test 1c: OxiTextError variants ───────────────────────────────────────
1254
1255    #[test]
1256    fn test_error_display() {
1257        let e = OxiTextError::FontNotFound;
1258        let s = format!("{e}");
1259        assert!(!s.is_empty());
1260    }
1261
1262    #[test]
1263    fn test_error_invalid_font() {
1264        let e = OxiTextError::InvalidFont;
1265        assert_ne!(format!("{e}"), format!("{}", OxiTextError::FontNotFound));
1266    }
1267
1268    #[test]
1269    fn types_are_send_sync() {
1270        fn assert_send_sync<T: Send + Sync>() {}
1271        assert_send_sync::<ShapedGlyph>();
1272        assert_send_sync::<ShapedRun>();
1273        assert_send_sync::<PositionedGlyph>();
1274        assert_send_sync::<Bitmap>();
1275        assert_send_sync::<ColorBitmap>();
1276        assert_send_sync::<LcdBitmap>();
1277        assert_send_sync::<RenderOutput>();
1278        assert_send_sync::<TextStyle>();
1279        assert_send_sync::<ParagraphStyle>();
1280        assert_send_sync::<TextRun>();
1281        assert_send_sync::<GlyphCluster>();
1282        assert_send_sync::<GlyphMetrics>();
1283    }
1284
1285    #[test]
1286    fn render_output_into_bitmap_greyscale() {
1287        let bm = Bitmap {
1288            width: 4,
1289            height: 4,
1290            pixels: vec![255u8; 16],
1291        };
1292        let out = RenderOutput::Greyscale(bm.clone());
1293        let extracted: Option<Bitmap> = out.into();
1294        assert!(extracted.is_some());
1295        let extracted = extracted.expect("greyscale should yield Some(Bitmap)");
1296        assert_eq!(extracted.width, 4);
1297        assert_eq!(extracted.pixels.len(), 16);
1298    }
1299
1300    #[test]
1301    fn render_output_into_bitmap_non_greyscale_is_none() {
1302        let out = RenderOutput::Sdf {
1303            width: 4,
1304            height: 4,
1305            data: vec![128u8; 16],
1306        };
1307        let extracted: Option<Bitmap> = out.into();
1308        assert!(extracted.is_none());
1309
1310        let out2 = RenderOutput::Msdf {
1311            width: 4,
1312            height: 4,
1313            data: vec![100u8; 48],
1314        };
1315        let extracted2: Option<Bitmap> = out2.into();
1316        assert!(extracted2.is_none());
1317    }
1318
1319    #[cfg(feature = "serde")]
1320    #[test]
1321    fn serde_roundtrip_bitmap() {
1322        let bm = Bitmap {
1323            width: 2,
1324            height: 2,
1325            pixels: vec![0, 128, 200, 255],
1326        };
1327        let json = serde_json::to_string(&bm).expect("serialize Bitmap");
1328        let back: Bitmap = serde_json::from_str(&json).expect("deserialize Bitmap");
1329        assert_eq!(back.width, bm.width);
1330        assert_eq!(back.pixels, bm.pixels);
1331    }
1332
1333    #[test]
1334    fn test_decoration_rect_fields() {
1335        let r = DecorationRect {
1336            x: 1.0,
1337            y: 2.0,
1338            width: 10.0,
1339            height: 1.5,
1340            color: Rgba8 {
1341                r: 0,
1342                g: 0,
1343                b: 0,
1344                a: 255,
1345            },
1346        };
1347        assert_eq!(r.width, 10.0);
1348        assert_eq!(r.height, 1.5);
1349        assert_eq!(r.color.a, 255);
1350    }
1351
1352    #[test]
1353    fn test_text_decoration_variants() {
1354        let under = TextDecoration::Underline {
1355            color: Rgba8::BLACK,
1356            thickness: 1.0,
1357            offset: 2.0,
1358        };
1359        let over = TextDecoration::Overline {
1360            color: Rgba8::BLACK,
1361            thickness: 1.0,
1362            offset: 0.0,
1363        };
1364        let strike = TextDecoration::Strikethrough {
1365            color: Rgba8::BLACK,
1366            thickness: 1.5,
1367        };
1368        assert_ne!(under, over);
1369        assert_ne!(under, strike);
1370        // TextDecoration is Copy
1371        let _copy = under;
1372        let _copy2 = over;
1373    }
1374
1375    #[cfg(feature = "serde")]
1376    #[test]
1377    fn serde_roundtrip_text_style() {
1378        let style = TextStyle {
1379            font_size: 24.0,
1380            max_width: 600.0,
1381            flow_direction: FlowDirection::Vertical,
1382            alignment: TextAlignment::Center,
1383            line_spacing: LineSpacing {
1384                leading: 2.0,
1385                line_height_multiplier: 1.5,
1386            },
1387        };
1388        let json = serde_json::to_string(&style).expect("serialize TextStyle");
1389        let back: TextStyle = serde_json::from_str(&json).expect("deserialize TextStyle");
1390        assert_eq!(back.font_size, 24.0);
1391        assert_eq!(back.alignment, TextAlignment::Center);
1392        assert_eq!(back.flow_direction, FlowDirection::Vertical);
1393    }
1394
1395    // ── Bitmap SDF alignment helpers ─────────────────────────────────────────
1396
1397    #[test]
1398    fn test_bitmap_invert_coverage() {
1399        let b = Bitmap {
1400            width: 2,
1401            height: 1,
1402            pixels: vec![0u8, 255],
1403        };
1404        let inv = b.invert_coverage();
1405        assert_eq!(inv.pixels[0], 255);
1406        assert_eq!(inv.pixels[1], 0);
1407    }
1408
1409    #[test]
1410    fn test_bitmap_threshold() {
1411        let b = Bitmap {
1412            width: 3,
1413            height: 1,
1414            pixels: vec![64u8, 128, 200],
1415        };
1416        let t = b.threshold(128);
1417        assert_eq!(t.pixels[0], 0);
1418        assert_eq!(t.pixels[1], 255);
1419        assert_eq!(t.pixels[2], 255);
1420    }
1421
1422    #[test]
1423    fn test_bitmap_tight_bounds_all_zero_returns_none() {
1424        let b = Bitmap {
1425            width: 4,
1426            height: 4,
1427            pixels: vec![0u8; 16],
1428        };
1429        assert!(b.tight_bounds().is_none());
1430    }
1431
1432    #[test]
1433    fn test_bitmap_tight_bounds_single_pixel() {
1434        let mut pixels = vec![0u8; 16];
1435        pixels[4 * 2 + 1] = 255; // row 2, col 1
1436        let b = Bitmap {
1437            width: 4,
1438            height: 4,
1439            pixels,
1440        };
1441        let bounds = b.tight_bounds().expect("should find pixel");
1442        assert_eq!(bounds, (1, 2, 1, 2));
1443    }
1444
1445    #[test]
1446    fn test_bitmap_crop() {
1447        let pixels = vec![1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
1448        let b = Bitmap {
1449            width: 4,
1450            height: 4,
1451            pixels,
1452        };
1453        let cropped = b.crop(1, 1, 2, 2);
1454        assert_eq!(cropped.width, 2);
1455        assert_eq!(cropped.height, 2);
1456        assert_eq!(cropped.pixels, vec![6u8, 7, 10, 11]);
1457    }
1458
1459    #[test]
1460    fn test_bitmap_invert_is_involution() {
1461        let b = Bitmap {
1462            width: 3,
1463            height: 1,
1464            pixels: vec![10u8, 128, 200],
1465        };
1466        let double_inv = b.invert_coverage().invert_coverage();
1467        assert_eq!(double_inv.pixels, b.pixels);
1468    }
1469
1470    #[test]
1471    fn test_bitmap_crop_out_of_bounds_fills_zero() {
1472        let b = Bitmap {
1473            width: 2,
1474            height: 2,
1475            pixels: vec![1u8, 2, 3, 4],
1476        };
1477        // Crop starting beyond the bitmap width; all pixels should be 0
1478        let cropped = b.crop(5, 5, 3, 3);
1479        assert_eq!(cropped.pixels, vec![0u8; 9]);
1480    }
1481
1482    #[test]
1483    fn test_std_feature_enabled_by_default() {
1484        // This test verifies the feature flag logic compiles correctly.
1485        // In a no_std build (--no-default-features), this test wouldn't run.
1486        #[cfg(feature = "std")]
1487        {
1488            // std is enabled — we can use core::error::Error
1489            let err: &dyn core::error::Error = &OxiTextError::InvalidFont;
1490            let _ = err.to_string();
1491        }
1492    }
1493
1494    #[test]
1495    fn test_vertical_position_effective_size() {
1496        let vp = VerticalPosition::Superscript {
1497            size_ratio: 0.6,
1498            baseline_rise: 4.0,
1499        };
1500        assert!((vp.effective_size(16.0) - 9.6).abs() < 0.001);
1501    }
1502
1503    #[test]
1504    fn test_vertical_position_baseline_adjustment() {
1505        let sub = VerticalPosition::Subscript {
1506            size_ratio: 0.6,
1507            baseline_drop: 3.0,
1508        };
1509        assert_eq!(sub.baseline_adjustment(16.0), -3.0);
1510        let norm = VerticalPosition::Normal;
1511        assert_eq!(norm.baseline_adjustment(16.0), 0.0);
1512    }
1513}