betteroffice-pptx-parse 0.3.0

Bounded PresentationML parsing and part-preserving PPTX package serialization.
Documentation
//! Parses `ppt/tableStyles.xml`.

use ooxml_drawingml::{
    TableCellBorder, TableCellBorders, TableCellStyle, TableStyle, TableStyleList, TableStylePart,
    TableTextStyle,
};

use crate::drawing::{parse_color_container, parse_fill_element, parse_outline_element};
use crate::xml::XmlElement;

pub(crate) fn parse_table_styles(root: &XmlElement) -> TableStyleList {
    TableStyleList {
        default_style_id: root.attribute("def").map(str::to_owned),
        styles: root
            .children_named("tblStyle")
            .map(parse_table_style)
            .collect(),
    }
}

fn parse_table_style(element: &XmlElement) -> TableStyle {
    TableStyle {
        style_id: element.attribute("styleId").unwrap_or_default().to_owned(),
        style_name: element.attribute("styleName").map(str::to_owned),
        whole_table: element.child("wholeTbl").map(parse_part),
        band1_row: element.child("band1H").map(parse_part),
        band2_row: element.child("band2H").map(parse_part),
        first_row: element.child("firstRow").map(parse_part),
        last_row: element.child("lastRow").map(parse_part),
        first_column: element.child("firstCol").map(parse_part),
        last_column: element.child("lastCol").map(parse_part),
    }
}

fn parse_part(element: &XmlElement) -> TableStylePart {
    TableStylePart {
        text: element
            .child("tcTxStyle")
            .map(parse_text_style)
            .unwrap_or_default(),
        cell: element
            .child("tcStyle")
            .map(parse_cell_style)
            .unwrap_or_default(),
    }
}

fn parse_text_style(element: &XmlElement) -> TableTextStyle {
    TableTextStyle {
        bold: on_off(element.attribute("b")),
        italic: on_off(element.attribute("i")),
        color: parse_color_container(element),
    }
}

fn parse_cell_style(element: &XmlElement) -> TableCellStyle {
    TableCellStyle {
        fill: element
            .child("fill")
            .and_then(|fill| fill.child_elements().find_map(parse_fill_element)),
        borders: element
            .child("tcBdr")
            .map(parse_borders)
            .unwrap_or_default(),
    }
}

fn parse_borders(element: &XmlElement) -> TableCellBorders {
    TableCellBorders {
        left: element.child("left").and_then(parse_border),
        right: element.child("right").and_then(parse_border),
        top: element.child("top").and_then(parse_border),
        bottom: element.child("bottom").and_then(parse_border),
        inside_horizontal: element.child("insideH").and_then(parse_border),
        inside_vertical: element.child("insideV").and_then(parse_border),
    }
}

fn parse_border(element: &XmlElement) -> Option<TableCellBorder> {
    let line = element.child("ln")?;
    if line.child("noFill").is_some() {
        return Some(TableCellBorder::None);
    }
    parse_outline_element(line).map(|outline| TableCellBorder::Line(Box::new(outline)))
}

fn on_off(value: Option<&str>) -> Option<bool> {
    match value? {
        "on" | "1" | "true" => Some(true),
        "off" | "0" | "false" => Some(false),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use ooxml_drawingml::TableStyle;

    use super::*;
    use crate::xml::{ParseBudget, ParseLimits, parse_xml};

    const NO_GRID: &[u8] = br#"<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" def="{DEF}"><a:tblStyle styleId="{NOGRID}" styleName="No Style, No Grid"><a:wholeTbl><a:tcTxStyle b="off" i="def"/><a:tcStyle><a:tcBdr><a:left><a:ln><a:noFill/></a:ln></a:left><a:right><a:ln><a:noFill/></a:ln></a:right><a:top><a:ln><a:noFill/></a:ln></a:top><a:bottom><a:ln><a:noFill/></a:ln></a:bottom><a:insideH><a:ln><a:noFill/></a:ln></a:insideH><a:insideV><a:ln><a:noFill/></a:ln></a:insideV></a:tcBdr><a:fill><a:noFill/></a:fill></a:tcStyle></a:wholeTbl></a:tblStyle></a:tblStyleLst>"#;

    const REGIONS: &[u8] = br#"<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:tblStyle styleId="{R}"><a:band2H><a:tcStyle><a:fill><a:solidFill><a:srgbClr val="B2B2B2"/></a:solidFill></a:fill></a:tcStyle></a:band2H><a:lastRow><a:tcStyle><a:fill><a:solidFill><a:srgbClr val="111111"/></a:solidFill></a:fill></a:tcStyle></a:lastRow><a:firstCol><a:tcStyle><a:fill><a:solidFill><a:srgbClr val="222222"/></a:solidFill></a:fill></a:tcStyle></a:firstCol><a:lastCol><a:tcStyle><a:fill><a:solidFill><a:srgbClr val="333333"/></a:solidFill></a:fill></a:tcStyle></a:lastCol></a:tblStyle></a:tblStyleLst>"#;

    fn styles(xml: &[u8]) -> TableStyleList {
        let limits = ParseLimits::default();
        let mut budget = ParseBudget::new(&limits);
        let root = parse_xml(xml, "ppt/tableStyles.xml", &mut budget).unwrap();
        parse_table_styles(&root)
    }

    fn only(xml: &[u8]) -> TableStyle {
        styles(xml).styles.into_iter().next().unwrap()
    }

    fn fill_of(part: Option<&TableStylePart>) -> Option<&str> {
        part?.cell.fill.as_ref()?.color.as_ref()?.rgb.as_deref()
    }

    #[test]
    fn every_no_fill_edge_parses_as_an_explicit_clear() {
        let style = only(NO_GRID);
        let borders = &style.whole_table.as_ref().unwrap().cell.borders;

        for edge in [
            &borders.left,
            &borders.right,
            &borders.top,
            &borders.bottom,
            &borders.inside_horizontal,
            &borders.inside_vertical,
        ] {
            assert_eq!(edge.as_ref(), Some(&TableCellBorder::None));
        }
    }

    #[test]
    fn a_no_fill_cell_fill_survives_as_an_explicit_none() {
        let style = only(NO_GRID);
        let fill = style.whole_table.as_ref().unwrap().cell.fill.as_ref();

        assert_eq!(fill.map(|fill| fill.fill_type.as_str()), Some("none"));
    }

    #[test]
    fn an_off_or_def_text_flag_reads_as_off_or_unset() {
        let style = only(NO_GRID);
        let text = &style.whole_table.as_ref().unwrap().text;

        assert_eq!(text.bold, Some(false));
        assert_eq!(text.italic, None);
    }

    #[test]
    fn each_region_element_lands_on_its_own_slot() {
        let style = only(REGIONS);

        assert_eq!(fill_of(style.band2_row.as_ref()), Some("B2B2B2"));
        assert_eq!(fill_of(style.last_row.as_ref()), Some("111111"));
        assert_eq!(fill_of(style.first_column.as_ref()), Some("222222"));
        assert_eq!(fill_of(style.last_column.as_ref()), Some("333333"));
        assert!(style.whole_table.is_none());
        assert!(style.band1_row.is_none());
        assert!(style.first_row.is_none());
    }

    #[test]
    fn a_style_list_round_trips_through_the_stored_package_encoding() {
        let list = styles(NO_GRID);
        let json = serde_json::to_vec(&list).unwrap();

        assert_eq!(
            serde_json::from_slice::<TableStyleList>(&json).unwrap(),
            list
        );
    }
}