oxipdf-ir 0.1.0

Intermediate representation types for the oxipdf PDF engine
Documentation
//! List style properties: marker type, position, and text generation.

/// List style properties for list item nodes.
///
/// Applied to nodes with `SemanticRole::ListItem`. Controls what marker
/// is rendered and where it appears relative to the content.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ListStyle {
    /// The marker to render for this list item. `None` means no marker.
    pub marker: Option<ListMarker>,
    /// Marker position relative to the list item's content box.
    pub position: ListMarkerPosition,
}

/// The type of marker to render for a list item.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum ListMarker {
    /// Filled circle bullet: "•"
    Disc,
    /// Hollow circle bullet: "◦"
    Circle,
    /// Filled square bullet: "▪"
    Square,
    /// Dash marker: "–"
    Dash,
    /// Ordered decimal: "1.", "2.", "3.", etc.
    Decimal(u32),
    /// Ordered lower-alpha: "a.", "b.", "c.", etc.
    LowerAlpha(u32),
    /// Ordered upper-alpha: "A.", "B.", "C.", etc.
    UpperAlpha(u32),
    /// Ordered lower-roman: "i.", "ii.", "iii.", etc.
    LowerRoman(u32),
    /// Ordered upper-roman: "I.", "II.", "III.", etc.
    UpperRoman(u32),
    /// Custom marker string (consumer-provided).
    Custom(String),
}

impl ListMarker {
    /// Generate the marker text string for rendering.
    ///
    /// Bullet markers use Helvetica-compatible characters (bullet U+00B7,
    /// "o" for circle, "-" for square/dash) rather than Unicode symbols
    /// that are not available in standard PDF Type 1 fonts.
    #[must_use]
    pub fn text(&self) -> String {
        match self {
            Self::Disc => "\u{00B7}".into(), // middle dot (·), available in Helvetica
            Self::Circle => "o".into(),      // lowercase 'o' as hollow bullet
            Self::Square => "-".into(),      // dash as square substitute
            Self::Dash => "-".into(),        // en-dash → ASCII dash
            Self::Decimal(n) => format!("{n}."),
            Self::LowerAlpha(n) => format!("{}.", nth_alpha(*n, false)),
            Self::UpperAlpha(n) => format!("{}.", nth_alpha(*n, true)),
            Self::LowerRoman(n) => format!("{}.", to_roman(*n, false)),
            Self::UpperRoman(n) => format!("{}.", to_roman(*n, true)),
            Self::Custom(s) => s.clone(),
        }
    }
}

/// Where the marker is positioned relative to the list item content.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum ListMarkerPosition {
    /// Marker sits outside the content box, in the left padding area.
    /// This is the standard "hanging indent" behavior.
    #[default]
    Outside,
    /// Marker is placed inside the content box, before the first line.
    Inside,
}

/// Convert a 1-based number to alphabetic: 1→a, 2→b, ... 26→z, 27→aa.
fn nth_alpha(n: u32, upper: bool) -> String {
    if n == 0 {
        return String::new();
    }
    let mut result = String::new();
    let mut val = n - 1;
    loop {
        let ch = (val % 26) as u8;
        let base = if upper { b'A' } else { b'a' };
        result.insert(0, (base + ch) as char);
        if val < 26 {
            break;
        }
        val = val / 26 - 1;
    }
    result
}

/// Convert a number to roman numerals.
fn to_roman(n: u32, upper: bool) -> String {
    const VALS: [(u32, &str, &str); 13] = [
        (1000, "m", "M"),
        (900, "cm", "CM"),
        (500, "d", "D"),
        (400, "cd", "CD"),
        (100, "c", "C"),
        (90, "xc", "XC"),
        (50, "l", "L"),
        (40, "xl", "XL"),
        (10, "x", "X"),
        (9, "ix", "IX"),
        (5, "v", "V"),
        (4, "iv", "IV"),
        (1, "i", "I"),
    ];

    if n == 0 {
        return "0".into();
    }

    let mut result = String::new();
    let mut remaining = n;
    for &(val, lower, upper_str) in &VALS {
        while remaining >= val {
            result.push_str(if upper { upper_str } else { lower });
            remaining -= val;
        }
    }
    result
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn decimal_markers() {
        assert_eq!(ListMarker::Decimal(1).text(), "1.");
        assert_eq!(ListMarker::Decimal(42).text(), "42.");
    }

    #[test]
    fn alpha_markers() {
        assert_eq!(ListMarker::LowerAlpha(1).text(), "a.");
        assert_eq!(ListMarker::LowerAlpha(26).text(), "z.");
        assert_eq!(ListMarker::LowerAlpha(27).text(), "aa.");
        assert_eq!(ListMarker::UpperAlpha(3).text(), "C.");
    }

    #[test]
    fn roman_markers() {
        assert_eq!(ListMarker::LowerRoman(1).text(), "i.");
        assert_eq!(ListMarker::LowerRoman(4).text(), "iv.");
        assert_eq!(ListMarker::LowerRoman(9).text(), "ix.");
        assert_eq!(ListMarker::LowerRoman(14).text(), "xiv.");
        assert_eq!(ListMarker::UpperRoman(2024).text(), "MMXXIV.");
    }

    #[test]
    fn bullet_markers() {
        assert_eq!(ListMarker::Disc.text(), "\u{00B7}"); // middle dot
        assert_eq!(ListMarker::Circle.text(), "o");
        assert_eq!(ListMarker::Square.text(), "-");
        assert_eq!(ListMarker::Dash.text(), "-");
    }

    #[test]
    fn custom_marker() {
        assert_eq!(ListMarker::Custom(">>".into()).text(), ">>");
    }
}