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