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