rocketsplash-formats 0.3.0

Shared data types and serialization formats for Rocketsplash TUI animations
Documentation
// <FILE>crates/rocketsplash-formats/src/font_atlas.rs</FILE>
// <DESC>Font atlas format definitions and validation</DESC>
// <VERS>VERSION: 2.0.0</VERS>
// <WCTX>Foundation crates 0.3.0 (RELEASE_PLAN D2 typed errors, D3 hardening, §9.3 provenance)</WCTX>
// <CLOG>2.0.0: FONT_ATLAS_VERSION 2 — FontMeta gains trailing #[serde(default)] provenance field (v1 atlases still decode); typed FormatError (breaking API); add whole-atlas validate() covering glyph count and line height. 1.0.0: FontAtlas, GlyphData, and validation helpers.</CLOG>

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use crate::{validate_dimensions, FontProvenance, FormatError, RenderMode, StyleFlags, MAX_GLYPHS};

/// Format version constants for compatibility checking.
///
/// Version 2 appends `FontMeta::provenance`. Version-1 atlases decode into the
/// current types (the trailing field defaults to `None`); version-2 files are
/// rejected by version-1 readers.
pub const FONT_ATLAS_VERSION: u8 = 2;
pub const FONT_ATLAS_MIN_SUPPORTED: u8 = 1;

/// Complete font atlas for runtime text rendering.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FontAtlas {
    pub version: u8,
    pub meta: FontMeta,
    pub glyphs: BTreeMap<char, GlyphVariants>,
    pub line_height: u32,
    pub mode: RenderMode,
    pub available_styles: StyleFlags,
}

impl FontAtlas {
    /// Validate the whole atlas: glyph count, line height, and every glyph
    /// variant. Does not check `version` — the decode entry point owns that.
    pub fn validate(&self) -> Result<(), FormatError> {
        if self.glyphs.len() > MAX_GLYPHS {
            return Err(FormatError::GlyphCountExceeded {
                count: self.glyphs.len(),
                max: MAX_GLYPHS,
            });
        }
        if self.line_height == 0 {
            return Err(FormatError::ZeroLineHeight);
        }
        for variants in self.glyphs.values() {
            variants.validate()?;
        }
        Ok(())
    }
}

#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct FontMeta {
    pub font_name: String,
    pub font_size: f32,
    pub created_at: u64,
    pub editor_version: String,
    /// License/origin metadata (§9.3). Trailing field with a serde default so
    /// version-1 atlases (serialized without it) keep decoding. New fields
    /// must likewise be appended, never inserted or reordered.
    #[serde(default)]
    pub provenance: Option<FontProvenance>,
}

/// All style variants for a single character.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GlyphVariants {
    pub base: GlyphData,
    pub bold: Option<GlyphData>,
    pub italic: Option<GlyphData>,
    pub bold_italic: Option<GlyphData>,
    pub reverse: Option<GlyphData>,
}

impl GlyphVariants {
    /// Validate base and all optional variants.
    pub fn validate(&self) -> Result<(), FormatError> {
        self.base.validate()?;
        if let Some(glyph) = &self.bold {
            glyph.validate()?;
        }
        if let Some(glyph) = &self.italic {
            glyph.validate()?;
        }
        if let Some(glyph) = &self.bold_italic {
            glyph.validate()?;
        }
        if let Some(glyph) = &self.reverse {
            glyph.validate()?;
        }
        Ok(())
    }

    /// Select the best available variant given style flags.
    pub fn select(&self, bold: bool, italic: bool, reverse: bool) -> &GlyphData {
        if reverse {
            if let Some(glyph) = &self.reverse {
                return glyph;
            }
        }
        if bold && italic {
            if let Some(glyph) = &self.bold_italic {
                return glyph;
            }
        }
        if bold {
            if let Some(glyph) = &self.bold {
                return glyph;
            }
        }
        if italic {
            if let Some(glyph) = &self.italic {
                return glyph;
            }
        }
        &self.base
    }
}

/// Serializable glyph data (flat chars + optional opacity).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GlyphData {
    pub chars: String,
    pub width: u32,
    pub height: u32,
    pub opacity: Option<Vec<u8>>,
}

impl GlyphData {
    pub fn validate(&self) -> Result<(), FormatError> {
        let cells = validate_dimensions(self.width, self.height)?;
        let char_count = self.chars.chars().count();
        if char_count != cells {
            return Err(FormatError::GlyphCharCountMismatch {
                actual: char_count,
                expected: cells,
                width: self.width,
                height: self.height,
            });
        }
        if let Some(opacity) = &self.opacity {
            if opacity.len() != cells {
                return Err(FormatError::GlyphOpacityMismatch {
                    actual: opacity.len(),
                    expected: cells,
                    width: self.width,
                    height: self.height,
                });
            }
        }
        Ok(())
    }
}

// <FILE>crates/rocketsplash-formats/src/font_atlas.rs</FILE>
// <VERS>END OF VERSION: 2.0.0</VERS>