From 473a704bf73bf4d60c92cc5ea697c5e9a4fd815f Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Sat, 14 Mar 2026 01:09:51 +0900 Subject: [PATCH 1/6] feat: add Tint/Shade color transforms to PPTX theme parser Add Tint and Shade variants to ColorTransform enum for OOXML color processing. Tint blends toward white (new = 255 - (255 - old) * t) and Shade blends toward black (new = old * s), both applied in RGB space before luminance transforms. Fix test_scheme_color_as_start_element to assert correct tinted color instead of ignoring the transform. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Yonghye Kwon --- crates/office2pdf/src/parser/pptx_theme.rs | 43 ++++++++++++++++++- .../office2pdf/src/parser/pptx_theme_tests.rs | 23 +++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/crates/office2pdf/src/parser/pptx_theme.rs b/crates/office2pdf/src/parser/pptx_theme.rs index 8105447..647e562 100644 --- a/crates/office2pdf/src/parser/pptx_theme.rs +++ b/crates/office2pdf/src/parser/pptx_theme.rs @@ -27,6 +27,8 @@ pub(super) struct ParsedColor { #[derive(Debug, Clone, Copy)] enum ColorTransform { + Tint(f64), + Shade(f64), LumMod(f64), LumOff(f64), } @@ -176,6 +178,8 @@ fn parse_base_color( fn parse_color_transform(element: &BytesStart<'_>) -> Option { let val = get_attr_i64(element, b"val")? as f64 / 100_000.0; match element.local_name().as_ref() { + b"tint" => Some(ColorTransform::Tint(val)), + b"shade" => Some(ColorTransform::Shade(val)), b"lumMod" => Some(ColorTransform::LumMod(val)), b"lumOff" => Some(ColorTransform::LumOff(val)), _ => None, @@ -183,7 +187,43 @@ fn parse_color_transform(element: &BytesStart<'_>) -> Option { } fn apply_color_transforms(color: Color, transforms: &[ColorTransform]) -> Color { - let (mut hue, mut saturation, mut lightness) = rgb_to_hsl(color); + // Apply tint/shade in RGB space first (OOXML spec: blend toward white/black). + let mut r: f64 = color.r as f64; + let mut g: f64 = color.g as f64; + let mut b: f64 = color.b as f64; + + for transform in transforms { + match transform { + ColorTransform::Tint(t) => { + r = 255.0 - (255.0 - r) * t; + g = 255.0 - (255.0 - g) * t; + b = 255.0 - (255.0 - b) * t; + } + ColorTransform::Shade(s) => { + r *= s; + g *= s; + b *= s; + } + _ => {} + } + } + + let tinted = Color::new( + r.round().clamp(0.0, 255.0) as u8, + g.round().clamp(0.0, 255.0) as u8, + b.round().clamp(0.0, 255.0) as u8, + ); + + // Then apply luminance transforms in HSL space. + let has_lum_transforms: bool = transforms + .iter() + .any(|t| matches!(t, ColorTransform::LumMod(_) | ColorTransform::LumOff(_))); + + if !has_lum_transforms { + return tinted; + } + + let (mut hue, mut saturation, mut lightness) = rgb_to_hsl(tinted); for transform in transforms { match transform { @@ -193,6 +233,7 @@ fn apply_color_transforms(color: Color, transforms: &[ColorTransform]) -> Color ColorTransform::LumOff(value) => { lightness = (lightness + value).clamp(0.0, 1.0); } + _ => {} } } diff --git a/crates/office2pdf/src/parser/pptx_theme_tests.rs b/crates/office2pdf/src/parser/pptx_theme_tests.rs index ba4a350..28afc81 100644 --- a/crates/office2pdf/src/parser/pptx_theme_tests.rs +++ b/crates/office2pdf/src/parser/pptx_theme_tests.rs @@ -533,7 +533,8 @@ fn test_no_theme_scheme_color_ignored() { } #[test] -fn test_scheme_color_as_start_element() { +fn test_scheme_color_tint_blends_toward_white() { + // accent3=#A5A5A5 with tint 50% → each channel: 255 - (255-165)*0.5 = 210 = 0xD2 let shape_xml = r#""#; let slide = make_slide_xml(&[shape_xml.to_string()]); let theme_xml = make_theme_xml(&standard_theme_colors(), "Calibri Light", "Calibri"); @@ -544,7 +545,25 @@ fn test_scheme_color_as_start_element() { let page = first_fixed_page(&doc); let shape = get_shape(&page.elements[0]); - assert_eq!(shape.fill, Some(Color::new(0xA5, 0xA5, 0xA5))); + assert_eq!(shape.fill, Some(Color::new(0xD2, 0xD2, 0xD2))); +} + +#[test] +fn test_scheme_color_shade_blends_toward_black() { + // accent1=#4472C4 with shade 50% → each channel * 0.5 → (34, 57, 98) + let shape_xml = r#""#; + let slide = make_slide_xml(&[shape_xml.to_string()]); + let theme_xml = make_theme_xml(&standard_theme_colors(), "Calibri Light", "Calibri"); + let data = build_test_pptx_with_theme(SLIDE_CX, SLIDE_CY, &[slide], &theme_xml); + + let parser = PptxParser; + let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap(); + + let page = first_fixed_page(&doc); + let shape = get_shape(&page.elements[0]); + // accent1 = (0x44, 0x72, 0xC4) = (68, 114, 196) + // shade 50%: (34, 57, 98) = (0x22, 0x39, 0x62) + assert_eq!(shape.fill, Some(Color::new(0x22, 0x39, 0x62))); } #[test] From d3a3446402c3a4174d807be42a546c68511b39d3 Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Sat, 14 Mar 2026 01:12:15 +0900 Subject: [PATCH 2/6] feat: add PPTX table style parser and application logic Add pptx_table_styles module that: - Parses XML from ppt/tableStyles.xml into a TableStyleMap keyed by style ID - Extracts per-region styles (wholeTbl, firstRow, lastRow, firstCol, lastCol, band1H, band2H) including fill color, text color, bold - Applies resolved styles to table cells with correct priority: cell-explicit > firstRow/lastRow/firstCol/lastCol > band > wholeTbl - Preserves explicit cell-level overrides (background, text color) Signed-off-by: Yonghye Kwon --- crates/office2pdf/src/parser/pptx.rs | 2 + .../src/parser/pptx_table_style_tests.rs | 370 ++++++++++++++++++ .../src/parser/pptx_table_styles.rs | 283 ++++++++++++++ crates/office2pdf/src/parser/pptx_tests.rs | 3 + 4 files changed, 658 insertions(+) create mode 100644 crates/office2pdf/src/parser/pptx_table_style_tests.rs create mode 100644 crates/office2pdf/src/parser/pptx_table_styles.rs diff --git a/crates/office2pdf/src/parser/pptx.rs b/crates/office2pdf/src/parser/pptx.rs index 5edddc1..2d3910f 100644 --- a/crates/office2pdf/src/parser/pptx.rs +++ b/crates/office2pdf/src/parser/pptx.rs @@ -44,6 +44,8 @@ mod package; mod shapes; #[path = "pptx_slides.rs"] mod slides; +#[path = "pptx_table_styles.rs"] +mod table_styles; #[path = "pptx_tables.rs"] mod tables; #[path = "pptx_text.rs"] diff --git a/crates/office2pdf/src/parser/pptx_table_style_tests.rs b/crates/office2pdf/src/parser/pptx_table_style_tests.rs new file mode 100644 index 0000000..d527acb --- /dev/null +++ b/crates/office2pdf/src/parser/pptx_table_style_tests.rs @@ -0,0 +1,370 @@ +use super::*; +use table_styles::{PptxTableProps, PptxTableStyleDef, TableCellRegionStyle, TableStyleMap}; + +// ── Unit tests: parse_table_styles_xml ───────────────────────────────── + +fn make_table_style_xml(styles: &[(&str, &str)]) -> String { + let mut xml = String::from( + r#""#, + ); + for (style_id, body) in styles { + xml.push_str(&format!( + r#"{body}"# + )); + } + xml.push_str(""); + xml +} + +fn test_theme() -> ThemeData { + let theme_xml = make_theme_xml(&standard_theme_colors(), "Calibri Light", "Calibri"); + parse_theme_xml(&theme_xml) +} + +fn test_color_map() -> ColorMapData { + default_color_map() +} + +#[test] +fn test_parse_table_style_with_whole_table_fill() { + let body = r#""#; + let xml = make_table_style_xml(&[("{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}", body)]); + let theme: ThemeData = test_theme(); + let color_map: ColorMapData = test_color_map(); + + let styles: TableStyleMap = + table_styles::parse_table_styles_xml(&xml, &theme, &color_map); + + let style: &PptxTableStyleDef = styles + .get("{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}") + .expect("style not found"); + let whole = style.whole_table.as_ref().expect("wholeTbl missing"); + assert_eq!(whole.fill, Some(Color::new(255, 0, 0))); +} + +#[test] +fn test_parse_table_style_with_first_row_scheme_color() { + // firstRow with accent1 fill and white bold text + let body = concat!( + r#""#, + r#""#, + r#""#, + r#""#, + ); + let xml = make_table_style_xml(&[("style1", body)]); + let theme: ThemeData = test_theme(); + let color_map: ColorMapData = test_color_map(); + + let styles: TableStyleMap = + table_styles::parse_table_styles_xml(&xml, &theme, &color_map); + + let style: &PptxTableStyleDef = styles.get("style1").expect("style not found"); + let first_row = style.first_row.as_ref().expect("firstRow missing"); + assert_eq!(first_row.fill, Some(Color::new(0x44, 0x72, 0xC4))); + assert_eq!(first_row.text_color, Some(Color::new(0xFF, 0xFF, 0xFF))); + assert_eq!(first_row.text_bold, Some(true)); +} + +#[test] +fn test_parse_table_style_banded_rows() { + let body = concat!( + r#""#, + r#""#, + ); + let xml = make_table_style_xml(&[("bandtest", body)]); + let theme: ThemeData = test_theme(); + let color_map: ColorMapData = test_color_map(); + + let styles: TableStyleMap = + table_styles::parse_table_styles_xml(&xml, &theme, &color_map); + + let style: &PptxTableStyleDef = styles.get("bandtest").expect("style not found"); + assert_eq!( + style.band1_h.as_ref().unwrap().fill, + Some(Color::new(0xDD, 0xDD, 0xDD)) + ); + assert_eq!( + style.band2_h.as_ref().unwrap().fill, + Some(Color::new(0xFF, 0xFF, 0xFF)) + ); +} + +#[test] +fn test_parse_table_style_with_color_transforms() { + // accent1=#4472C4 with tint 40% → blend toward white + let body = r#""#; + let xml = make_table_style_xml(&[("tinttest", body)]); + let theme: ThemeData = test_theme(); + let color_map: ColorMapData = test_color_map(); + + let styles: TableStyleMap = + table_styles::parse_table_styles_xml(&xml, &theme, &color_map); + + let style: &PptxTableStyleDef = styles.get("tinttest").expect("style not found"); + let band = style.band1_h.as_ref().expect("band1H missing"); + // accent1 = (68, 114, 196). tint 40%: channel = 255 - (255-ch)*0.4 + // r = 255 - 187*0.4 = 255 - 74.8 = 180.2 → 180 + // g = 255 - 141*0.4 = 255 - 56.4 = 198.6 → 199 + // b = 255 - 59*0.4 = 255 - 23.6 = 231.4 → 231 + assert_eq!(band.fill, Some(Color::new(180, 199, 231))); +} + +// ── Unit tests: apply_table_style ────────────────────────────────────── + +#[test] +fn test_apply_table_style_first_row_gets_header_fill_and_text_color() { + let mut styles: TableStyleMap = HashMap::new(); + styles.insert( + "style1".to_string(), + PptxTableStyleDef { + first_row: Some(TableCellRegionStyle { + fill: Some(Color::new(0x44, 0x72, 0xC4)), + text_color: Some(Color::new(255, 255, 255)), + text_bold: Some(true), + }), + ..Default::default() + }, + ); + let props = PptxTableProps { + style_id: Some("style1".to_string()), + first_row: true, + ..Default::default() + }; + + // Build a simple 2-row table with no explicit fills + let mut table = Table { + rows: vec![ + TableRow { + cells: vec![TableCell { + content: vec![Block::Paragraph(Paragraph { + style: ParagraphStyle::default(), + runs: vec![Run { + text: "Header".to_string(), + style: TextStyle::default(), + href: None, + footnote: None, + }], + })], + col_span: 1, + row_span: 1, + border: None, + background: None, + data_bar: None, + icon_text: None, + vertical_align: None, + padding: None, + }], + height: Some(30.0), + }, + TableRow { + cells: vec![TableCell { + content: vec![Block::Paragraph(Paragraph { + style: ParagraphStyle::default(), + runs: vec![Run { + text: "Data".to_string(), + style: TextStyle::default(), + href: None, + footnote: None, + }], + })], + col_span: 1, + row_span: 1, + border: None, + background: None, + data_bar: None, + icon_text: None, + vertical_align: None, + padding: None, + }], + height: Some(30.0), + }, + ], + column_widths: vec![200.0], + header_row_count: 1, + alignment: None, + default_cell_padding: None, + use_content_driven_row_heights: true, + }; + + table_styles::apply_table_style(&mut table, &props, &styles); + + // Header row cell should have blue background and white bold text + let header_cell = &table.rows[0].cells[0]; + assert_eq!(header_cell.background, Some(Color::new(0x44, 0x72, 0xC4))); + let header_run = match &header_cell.content[0] { + Block::Paragraph(p) => &p.runs[0], + _ => panic!("Expected paragraph"), + }; + assert_eq!(header_run.style.color, Some(Color::new(255, 255, 255))); + assert_eq!(header_run.style.bold, Some(true)); + + // Data row should be unaffected + let data_cell = &table.rows[1].cells[0]; + assert_eq!(data_cell.background, None); +} + +#[test] +fn test_apply_table_style_banded_rows_skip_first_row() { + let mut styles: TableStyleMap = HashMap::new(); + styles.insert( + "bandstyle".to_string(), + PptxTableStyleDef { + band1_h: Some(TableCellRegionStyle { + fill: Some(Color::new(0xDD, 0xEE, 0xFF)), + text_color: None, + text_bold: None, + }), + ..Default::default() + }, + ); + let props = PptxTableProps { + style_id: Some("bandstyle".to_string()), + first_row: true, + band_row: true, + ..Default::default() + }; + + let make_row = |text: &str| -> TableRow { + TableRow { + cells: vec![TableCell { + content: vec![Block::Paragraph(Paragraph { + style: ParagraphStyle::default(), + runs: vec![Run { + text: text.to_string(), + style: TextStyle::default(), + href: None, + footnote: None, + }], + })], + col_span: 1, + row_span: 1, + border: None, + background: None, + data_bar: None, + icon_text: None, + vertical_align: None, + padding: None, + }], + height: Some(30.0), + } + }; + + let mut table = Table { + rows: vec![make_row("Header"), make_row("Row1"), make_row("Row2"), make_row("Row3")], + column_widths: vec![200.0], + header_row_count: 1, + alignment: None, + default_cell_padding: None, + use_content_driven_row_heights: true, + }; + + table_styles::apply_table_style(&mut table, &props, &styles); + + // Header row (row 0) excluded from banding + assert_eq!(table.rows[0].cells[0].background, None); + // Row 1 (data row index 0) = band1 → fill applied + assert_eq!( + table.rows[1].cells[0].background, + Some(Color::new(0xDD, 0xEE, 0xFF)) + ); + // Row 2 (data row index 1) = band2 → no fill (band2 not defined) + assert_eq!(table.rows[2].cells[0].background, None); + // Row 3 (data row index 2) = band1 → fill applied + assert_eq!( + table.rows[3].cells[0].background, + Some(Color::new(0xDD, 0xEE, 0xFF)) + ); +} + +#[test] +fn test_apply_table_style_explicit_cell_fill_not_overridden() { + let mut styles: TableStyleMap = HashMap::new(); + styles.insert( + "override".to_string(), + PptxTableStyleDef { + whole_table: Some(TableCellRegionStyle { + fill: Some(Color::new(0xAA, 0xBB, 0xCC)), + text_color: None, + text_bold: None, + }), + ..Default::default() + }, + ); + let props = PptxTableProps { + style_id: Some("override".to_string()), + ..Default::default() + }; + + let mut table = Table { + rows: vec![TableRow { + cells: vec![TableCell { + content: vec![Block::Paragraph(Paragraph { + style: ParagraphStyle::default(), + runs: vec![Run { + text: "Explicit".to_string(), + style: TextStyle::default(), + href: None, + footnote: None, + }], + })], + col_span: 1, + row_span: 1, + border: None, + background: Some(Color::new(0xFF, 0x00, 0x00)), + data_bar: None, + icon_text: None, + vertical_align: None, + padding: None, + }], + height: Some(30.0), + }], + column_widths: vec![200.0], + header_row_count: 0, + alignment: None, + default_cell_padding: None, + use_content_driven_row_heights: true, + }; + + table_styles::apply_table_style(&mut table, &props, &styles); + + // Explicit cell fill should be preserved, not overridden by wholeTbl + assert_eq!( + table.rows[0].cells[0].background, + Some(Color::new(0xFF, 0x00, 0x00)) + ); +} + +#[test] +fn test_apply_table_style_missing_style_id_is_noop() { + let styles: TableStyleMap = HashMap::new(); + let props = PptxTableProps { + style_id: None, + ..Default::default() + }; + + let mut table = Table { + rows: vec![TableRow { + cells: vec![TableCell { + content: vec![], + col_span: 1, + row_span: 1, + border: None, + background: None, + data_bar: None, + icon_text: None, + vertical_align: None, + padding: None, + }], + height: Some(30.0), + }], + column_widths: vec![200.0], + header_row_count: 0, + alignment: None, + default_cell_padding: None, + use_content_driven_row_heights: true, + }; + + table_styles::apply_table_style(&mut table, &props, &styles); + + assert_eq!(table.rows[0].cells[0].background, None); +} diff --git a/crates/office2pdf/src/parser/pptx_table_styles.rs b/crates/office2pdf/src/parser/pptx_table_styles.rs new file mode 100644 index 0000000..7ec4ff0 --- /dev/null +++ b/crates/office2pdf/src/parser/pptx_table_styles.rs @@ -0,0 +1,283 @@ +use super::*; + +// ── Table style data structures ───────────────────────────────────────── + +/// Styling for a table cell region (e.g., firstRow, band1H, wholeTbl). +#[derive(Debug, Clone, Default)] +pub(super) struct TableCellRegionStyle { + pub(super) fill: Option, + pub(super) text_color: Option, + pub(super) text_bold: Option, +} + +/// Parsed definition of a single `` element. +#[derive(Debug, Clone, Default)] +pub(super) struct PptxTableStyleDef { + pub(super) whole_table: Option, + pub(super) band1_h: Option, + pub(super) band2_h: Option, + pub(super) first_row: Option, + pub(super) last_row: Option, + pub(super) first_col: Option, + pub(super) last_col: Option, +} + +/// Map from style ID (GUID string) to parsed table style definition. +pub(super) type TableStyleMap = HashMap; + +/// Attributes from `` that control which style regions are active. +#[derive(Debug, Clone, Default)] +pub(super) struct PptxTableProps { + pub(super) style_id: Option, + pub(super) first_row: bool, + pub(super) last_row: bool, + pub(super) first_col: bool, + pub(super) last_col: bool, + pub(super) band_row: bool, + pub(super) band_col: bool, +} + +// ── Parsing ───────────────────────────────────────────────────────────── + +/// Parse `ppt/tableStyles.xml` into a map of table style definitions. +pub(super) fn parse_table_styles_xml( + xml: &str, + theme: &ThemeData, + color_map: &ColorMapData, +) -> TableStyleMap { + let mut styles: TableStyleMap = HashMap::new(); + let mut reader = Reader::from_str(xml); + + let mut current_style_id: Option = None; + let mut current_def = PptxTableStyleDef::default(); + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => match e.local_name().as_ref() { + b"tblStyle" => { + current_style_id = get_attr_str(e, b"styleId"); + current_def = PptxTableStyleDef::default(); + } + b"wholeTbl" if current_style_id.is_some() => { + current_def.whole_table = + Some(parse_region_style(&mut reader, b"wholeTbl", theme, color_map)); + } + b"band1H" if current_style_id.is_some() => { + current_def.band1_h = + Some(parse_region_style(&mut reader, b"band1H", theme, color_map)); + } + b"band2H" if current_style_id.is_some() => { + current_def.band2_h = + Some(parse_region_style(&mut reader, b"band2H", theme, color_map)); + } + b"firstRow" if current_style_id.is_some() => { + current_def.first_row = + Some(parse_region_style(&mut reader, b"firstRow", theme, color_map)); + } + b"lastRow" if current_style_id.is_some() => { + current_def.last_row = + Some(parse_region_style(&mut reader, b"lastRow", theme, color_map)); + } + b"firstCol" if current_style_id.is_some() => { + current_def.first_col = + Some(parse_region_style(&mut reader, b"firstCol", theme, color_map)); + } + b"lastCol" if current_style_id.is_some() => { + current_def.last_col = + Some(parse_region_style(&mut reader, b"lastCol", theme, color_map)); + } + _ => {} + }, + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"tblStyle" => { + if let Some(id) = current_style_id.take() { + styles.insert(id, std::mem::take(&mut current_def)); + } + } + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + styles +} + +/// Parse a region element (e.g., ``) and extract fill, text color, and bold. +fn parse_region_style( + reader: &mut Reader<&[u8]>, + end_tag: &[u8], + theme: &ThemeData, + color_map: &ColorMapData, +) -> TableCellRegionStyle { + let mut style = TableCellRegionStyle::default(); + let mut in_tc_style = false; + let mut in_tc_tx_style = false; + let mut in_fill = false; + let mut in_solid_fill = false; + let mut in_font_ref = false; + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => match e.local_name().as_ref() { + b"tcStyle" => in_tc_style = true, + b"tcTxStyle" => { + in_tc_tx_style = true; + if let Some(bold) = get_attr_str(e, b"b") { + style.text_bold = Some(bold == "on"); + } + } + b"fill" if in_tc_style => in_fill = true, + b"solidFill" if in_fill || in_tc_style => in_solid_fill = true, + b"fontRef" if in_tc_tx_style => in_font_ref = true, + b"srgbClr" | b"schemeClr" | b"sysClr" if in_solid_fill => { + let parsed: ParsedColor = + parse_color_from_start(reader, e, theme, color_map); + style.fill = parsed.color; + } + b"srgbClr" | b"schemeClr" | b"sysClr" if in_font_ref => { + let parsed: ParsedColor = + parse_color_from_start(reader, e, theme, color_map); + style.text_color = parsed.color; + } + _ => {} + }, + Ok(Event::Empty(ref e)) => match e.local_name().as_ref() { + b"srgbClr" | b"schemeClr" | b"sysClr" if in_solid_fill => { + let parsed: ParsedColor = parse_color_from_empty(e, theme, color_map); + style.fill = parsed.color; + } + b"srgbClr" | b"schemeClr" | b"sysClr" if in_font_ref => { + let parsed: ParsedColor = parse_color_from_empty(e, theme, color_map); + style.text_color = parsed.color; + } + b"tcTxStyle" => { + if let Some(bold) = get_attr_str(e, b"b") { + style.text_bold = Some(bold == "on"); + } + } + _ => {} + }, + Ok(Event::End(ref e)) => { + let local = e.local_name(); + if local.as_ref() == end_tag { + break; + } + match local.as_ref() { + b"tcStyle" => in_tc_style = false, + b"tcTxStyle" => in_tc_tx_style = false, + b"fill" => in_fill = false, + b"solidFill" => in_solid_fill = false, + b"fontRef" => in_font_ref = false, + _ => {} + } + } + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + style +} + +// ── Style application ─────────────────────────────────────────────────── + +/// Apply table style colors/formatting to cells that don't have explicit overrides. +/// +/// Priority (highest wins): cell-level explicit → firstRow/lastRow/firstCol/lastCol → band → wholeTbl +pub(super) fn apply_table_style( + table: &mut Table, + props: &PptxTableProps, + styles: &TableStyleMap, +) { + let style_id: &str = match props.style_id.as_deref() { + Some(id) => id, + None => return, + }; + let style_def: &PptxTableStyleDef = match styles.get(style_id) { + Some(def) => def, + None => return, + }; + + let total_rows: usize = table.rows.len(); + let total_cols: usize = table.column_widths.len(); + let header_rows: usize = if props.first_row { 1 } else { 0 }; + let footer_rows: usize = if props.last_row { 1 } else { 0 }; + + for (row_idx, row) in table.rows.iter_mut().enumerate() { + let is_first_row: bool = props.first_row && row_idx < header_rows; + let is_last_row: bool = props.last_row && total_rows > header_rows && row_idx == total_rows - 1; + + // Data row index for banding (excludes first/last special rows) + let data_row_idx: Option = if !is_first_row && !is_last_row { + Some(row_idx.saturating_sub(header_rows)) + } else { + None + }; + + for (col_idx, cell) in row.cells.iter_mut().enumerate() { + let is_first_col: bool = props.first_col && col_idx == 0; + let is_last_col: bool = props.last_col && total_cols > 0 && col_idx == total_cols - 1; + + // Determine which region style applies (highest priority first) + let region_style: Option<&TableCellRegionStyle> = if is_first_row { + style_def.first_row.as_ref() + } else if is_last_row { + style_def.last_row.as_ref() + } else if is_first_col { + style_def.first_col.as_ref() + } else if is_last_col { + style_def.last_col.as_ref() + } else if props.band_row + && let Some(data_idx) = data_row_idx + { + if data_idx % 2 == 0 { + style_def.band1_h.as_ref() + } else { + style_def.band2_h.as_ref() + } + } else { + style_def.whole_table.as_ref() + }; + + let Some(region) = region_style else { + // Fall back to wholeTbl if the region is defined but has no style + if let Some(whole) = style_def.whole_table.as_ref() { + apply_region_to_cell(cell, whole); + } + continue; + }; + + apply_region_to_cell(cell, region); + } + } + + // Suppress footer_rows warning + let _ = footer_rows; +} + +/// Apply a region style to a cell, respecting explicit cell-level overrides. +fn apply_region_to_cell(cell: &mut TableCell, region: &TableCellRegionStyle) { + // Only apply fill if cell doesn't have an explicit background + if cell.background.is_none() { + cell.background = region.fill; + } + + // Apply text color and bold to all runs that don't have explicit overrides + if region.text_color.is_some() || region.text_bold.is_some() { + for block in &mut cell.content { + if let Block::Paragraph(paragraph) = block { + for run in &mut paragraph.runs { + if region.text_color.is_some() && run.style.color.is_none() { + run.style.color = region.text_color; + } + if let Some(bold) = region.text_bold { + if run.style.bold.is_none() { + run.style.bold = Some(bold); + } + } + } + } + } + } +} diff --git a/crates/office2pdf/src/parser/pptx_tests.rs b/crates/office2pdf/src/parser/pptx_tests.rs index 535cc55..d3fef0b 100644 --- a/crates/office2pdf/src/parser/pptx_tests.rs +++ b/crates/office2pdf/src/parser/pptx_tests.rs @@ -489,6 +489,9 @@ use self::theme_tests::{ #[path = "pptx_table_tests.rs"] mod table_tests; +#[path = "pptx_table_style_tests.rs"] +mod table_style_tests; + #[path = "pptx_slide_feature_tests.rs"] mod slide_feature_tests; From 2c7f1f8172165a4d762a6a79e40433f61e70b925 Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Sat, 14 Mar 2026 01:21:18 +0900 Subject: [PATCH 3/6] feat: load tableStyles.xml and apply styles during table parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread TableStyleMap from ppt/tableStyles.xml through the parser chain: PptxParser::parse → parse_single_slide → SlideXmlParser → parse_pptx_table. Parse attributes (firstRow, bandRow, etc.) and text content. Apply resolved table styles in PptxTableParser::finish() so cells receive background fills and text formatting from the style definition. Set header_row_count from the firstRow attribute. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Yonghye Kwon --- crates/office2pdf/src/parser/pptx.rs | 10 +++- crates/office2pdf/src/parser/pptx_package.rs | 13 +++++ crates/office2pdf/src/parser/pptx_shapes.rs | 2 + crates/office2pdf/src/parser/pptx_slides.rs | 14 ++++- crates/office2pdf/src/parser/pptx_tables.rs | 58 ++++++++++++++++++-- 5 files changed, 89 insertions(+), 8 deletions(-) diff --git a/crates/office2pdf/src/parser/pptx.rs b/crates/office2pdf/src/parser/pptx.rs index 2d3910f..641d541 100644 --- a/crates/office2pdf/src/parser/pptx.rs +++ b/crates/office2pdf/src/parser/pptx.rs @@ -21,7 +21,9 @@ use crate::parser::Parser; use crate::parser::smartart; use crate::parser::units::emu_to_pt; -use self::package::{load_theme, parse_presentation_xml, parse_rels_xml, read_zip_entry}; +use self::package::{ + load_table_styles, load_theme, parse_presentation_xml, parse_rels_xml, read_zip_entry, +}; #[cfg(test)] use self::package::{resolve_relative_path, scan_chart_refs}; use self::shapes::{ @@ -393,6 +395,11 @@ impl Parser for PptxParser { // Load theme data (if available) let theme = load_theme(&rel_map, &mut archive); + // Load table styles (uses theme colors for scheme color resolution) + let master_color_map: ColorMapData = default_color_map(); + let table_styles: table_styles::TableStyleMap = + load_table_styles(&mut archive, &theme, &master_color_map); + let mut warnings = Vec::new(); // Parse each slide in order, skipping broken slides with warnings @@ -419,6 +426,7 @@ impl Parser for PptxParser { &slide_label, slide_size, &theme, + &table_styles, &mut archive, ) { Ok((page, slide_warnings)) => { diff --git a/crates/office2pdf/src/parser/pptx_package.rs b/crates/office2pdf/src/parser/pptx_package.rs index 043d34f..3e9e18c 100644 --- a/crates/office2pdf/src/parser/pptx_package.rs +++ b/crates/office2pdf/src/parser/pptx_package.rs @@ -380,6 +380,19 @@ pub(super) fn load_theme( parse_theme_xml(&theme_xml) } +/// Load and parse `ppt/tableStyles.xml` from the archive. +/// Returns an empty map if the file is missing. +pub(super) fn load_table_styles( + archive: &mut ZipArchive, + theme: &ThemeData, + color_map: &ColorMapData, +) -> table_styles::TableStyleMap { + let Ok(xml) = read_zip_entry(archive, "ppt/tableStyles.xml") else { + return table_styles::TableStyleMap::new(); + }; + table_styles::parse_table_styles_xml(&xml, theme, color_map) +} + pub(super) fn resolve_relative_path(base_dir: &str, relative: &str) -> String { crate::parser::xml_util::resolve_relative_path(base_dir, relative) } diff --git a/crates/office2pdf/src/parser/pptx_shapes.rs b/crates/office2pdf/src/parser/pptx_shapes.rs index 7b9e656..a45e1c5 100644 --- a/crates/office2pdf/src/parser/pptx_shapes.rs +++ b/crates/office2pdf/src/parser/pptx_shapes.rs @@ -86,6 +86,7 @@ pub(super) fn parse_group_shape( color_map: &ColorMapData, warning_context: &str, inherited_text_body_defaults: &PptxTextBodyStyleDefaults, + table_styles: &table_styles::TableStyleMap, ) -> Result<(Vec, Vec), ConvertError> { let mut transform = GroupTransform::default(); let mut in_xfrm = false; @@ -173,6 +174,7 @@ pub(super) fn parse_group_shape( color_map, warning_context, inherited_text_body_defaults, + table_styles, )?; for element in &mut child_elements { transform.apply(element); diff --git a/crates/office2pdf/src/parser/pptx_slides.rs b/crates/office2pdf/src/parser/pptx_slides.rs index 67b55b9..f0a817b 100644 --- a/crates/office2pdf/src/parser/pptx_slides.rs +++ b/crates/office2pdf/src/parser/pptx_slides.rs @@ -75,6 +75,7 @@ fn parse_layer_elements( archive: &mut ZipArchive, ) -> (Vec, Vec) { let images: SlideImageMap = load_slide_images(layer_path, archive); + let empty_table_styles: table_styles::TableStyleMap = table_styles::TableStyleMap::new(); parse_slide_xml( layer_xml, &images, @@ -82,6 +83,7 @@ fn parse_layer_elements( color_map, label, text_style_defaults, + &empty_table_styles, ) .unwrap_or_default() } @@ -192,6 +194,7 @@ pub(super) fn parse_single_slide( slide_label: &str, slide_size: PageSize, theme: &ThemeData, + table_styles: &table_styles::TableStyleMap, archive: &mut ZipArchive, ) -> Result<(Page, Vec), ConvertError> { let chain: SlideInheritanceChain = resolve_inheritance_chain(slide_path, theme, archive)?; @@ -206,6 +209,7 @@ pub(super) fn parse_single_slide( &chain.slide_color_map, slide_label, &chain.master_text_style_defaults, + table_styles, )?; warnings.extend(slide_warnings); @@ -613,6 +617,7 @@ struct SlideXmlParser<'a> { color_map: &'a ColorMapData, warning_context: &'a str, inherited_text_body_defaults: &'a PptxTextBodyStyleDefaults, + table_styles: &'a table_styles::TableStyleMap, // ── Output accumulators ───────────────────────────────────────── elements: Vec, @@ -669,6 +674,7 @@ impl<'a> SlideXmlParser<'a> { color_map: &'a ColorMapData, warning_context: &'a str, inherited_text_body_defaults: &'a PptxTextBodyStyleDefaults, + table_styles: &'a table_styles::TableStyleMap, ) -> Self { Self { xml, @@ -677,6 +683,7 @@ impl<'a> SlideXmlParser<'a> { color_map, warning_context, inherited_text_body_defaults, + table_styles, elements: Vec::new(), warnings: Vec::new(), @@ -729,7 +736,9 @@ impl<'a> SlideXmlParser<'a> { self.gf.in_xfrm = true; } b"tbl" if self.in_graphic_frame => { - if let Ok(mut table) = parse_pptx_table(reader, self.theme, self.color_map) { + if let Ok(mut table) = + parse_pptx_table(reader, self.theme, self.color_map, self.table_styles) + { scale_pptx_table_geometry_to_frame( &mut table, emu_to_pt(self.gf.cx), @@ -753,6 +762,7 @@ impl<'a> SlideXmlParser<'a> { self.color_map, self.warning_context, self.inherited_text_body_defaults, + self.table_styles, ) { self.elements.extend(group_elems); self.warnings.extend(group_warnings); @@ -1320,6 +1330,7 @@ pub(super) fn parse_slide_xml( color_map: &ColorMapData, warning_context: &str, inherited_text_body_defaults: &PptxTextBodyStyleDefaults, + table_styles: &table_styles::TableStyleMap, ) -> Result<(Vec, Vec), ConvertError> { let mut reader = Reader::from_str(xml); let mut parser = SlideXmlParser::new( @@ -1329,6 +1340,7 @@ pub(super) fn parse_slide_xml( color_map, warning_context, inherited_text_body_defaults, + table_styles, ); loop { diff --git a/crates/office2pdf/src/parser/pptx_tables.rs b/crates/office2pdf/src/parser/pptx_tables.rs index 6551500..fc5a5ff 100644 --- a/crates/office2pdf/src/parser/pptx_tables.rs +++ b/crates/office2pdf/src/parser/pptx_tables.rs @@ -20,10 +20,14 @@ struct PptxTableParser<'a> { // External context (immutable references) theme: &'a ThemeData, color_map: &'a ColorMapData, + table_styles: &'a table_styles::TableStyleMap, // ── Table-level state ─────────────────────────────────────────── column_widths: Vec, rows: Vec, + table_props: table_styles::PptxTableProps, + is_in_tbl_pr: bool, + is_in_table_style_id: bool, // ── Row-level state ───────────────────────────────────────────── is_in_row: bool, @@ -80,13 +84,21 @@ struct PptxTableParser<'a> { } impl<'a> PptxTableParser<'a> { - fn new(theme: &'a ThemeData, color_map: &'a ColorMapData) -> Self { + fn new( + theme: &'a ThemeData, + color_map: &'a ColorMapData, + table_styles: &'a table_styles::TableStyleMap, + ) -> Self { Self { theme, color_map, + table_styles, column_widths: Vec::new(), rows: Vec::new(), + table_props: table_styles::PptxTableProps::default(), + is_in_tbl_pr: false, + is_in_table_style_id: false, is_in_row: false, row_height_emu: 0, @@ -145,6 +157,13 @@ impl<'a> PptxTableParser<'a> { ) -> Result<(), ConvertError> { let local = e.local_name(); match local.as_ref() { + b"tblPr" => { + self.is_in_tbl_pr = true; + self.parse_tbl_pr_attrs(e); + } + b"tableStyleId" if self.is_in_tbl_pr => { + self.is_in_table_style_id = true; + } b"gridCol" => { if let Some(width) = get_attr_i64(e, b"w") { self.column_widths.push(emu_to_pt(width)); @@ -236,6 +255,9 @@ impl<'a> PptxTableParser<'a> { fn handle_empty(&mut self, e: &BytesStart) { let local = e.local_name(); match local.as_ref() { + b"tblPr" => { + self.parse_tbl_pr_attrs(e); + } b"gridCol" => { if let Some(width) = get_attr_i64(e, b"w") { self.column_widths.push(emu_to_pt(width)); @@ -296,7 +318,11 @@ impl<'a> PptxTableParser<'a> { // ── Text / GeneralRef events ──────────────────────────────────── fn handle_text(&mut self, text: &quick_xml::events::BytesText<'_>) { - if self.is_in_text + if self.is_in_table_style_id { + if let Some(decoded) = decode_pptx_text_event(text) { + self.table_props.style_id = Some(decoded); + } + } else if self.is_in_text && let Some(decoded) = decode_pptx_text_event(text) { self.run_text.push_str(&decoded); @@ -318,6 +344,12 @@ impl<'a> PptxTableParser<'a> { let local = e.local_name(); match local.as_ref() { b"tbl" => return true, + b"tblPr" if self.is_in_tbl_pr => { + self.is_in_tbl_pr = false; + } + b"tableStyleId" if self.is_in_table_style_id => { + self.is_in_table_style_id = false; + } b"tr" if self.is_in_row => { self.finish_row(); } @@ -362,14 +394,27 @@ impl<'a> PptxTableParser<'a> { // ── Consume accumulated state into the final Table ────────────── fn finish(self) -> Table { - Table { + let header_row_count: usize = if self.table_props.first_row { 1 } else { 0 }; + let mut table = Table { rows: self.rows, column_widths: self.column_widths, - header_row_count: 0, + header_row_count, alignment: None, default_cell_padding: Some(default_pptx_table_cell_padding()), use_content_driven_row_heights: true, - } + }; + table_styles::apply_table_style(&mut table, &self.table_props, self.table_styles); + table + } + + /// Extract tblPr attributes (firstRow, bandRow, etc.) + fn parse_tbl_pr_attrs(&mut self, e: &BytesStart) { + self.table_props.first_row = get_attr_str(e, b"firstRow").as_deref() == Some("1"); + self.table_props.last_row = get_attr_str(e, b"lastRow").as_deref() == Some("1"); + self.table_props.first_col = get_attr_str(e, b"firstCol").as_deref() == Some("1"); + self.table_props.last_col = get_attr_str(e, b"lastCol").as_deref() == Some("1"); + self.table_props.band_row = get_attr_str(e, b"bandRow").as_deref() == Some("1"); + self.table_props.band_col = get_attr_str(e, b"bandCol").as_deref() == Some("1"); } // ── Private helpers: cell lifecycle ────────────────────────────── @@ -735,8 +780,9 @@ pub(super) fn parse_pptx_table( reader: &mut Reader<&[u8]>, theme: &ThemeData, color_map: &ColorMapData, + table_styles: &table_styles::TableStyleMap, ) -> Result { - let mut state = PptxTableParser::new(theme, color_map); + let mut state = PptxTableParser::new(theme, color_map, table_styles); loop { match reader.read_event() { From f627b4e98d40b7aa2564d807459239f904716500 Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Sat, 14 Mar 2026 01:39:33 +0900 Subject: [PATCH 4/6] test: add integration tests for PPTX table style end-to-end parsing Co-Authored-By: Claude Opus 4.6 Signed-off-by: Yonghye Kwon --- .../src/parser/pptx_table_style_tests.rs | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) diff --git a/crates/office2pdf/src/parser/pptx_table_style_tests.rs b/crates/office2pdf/src/parser/pptx_table_style_tests.rs index d527acb..993029d 100644 --- a/crates/office2pdf/src/parser/pptx_table_style_tests.rs +++ b/crates/office2pdf/src/parser/pptx_table_style_tests.rs @@ -1,6 +1,44 @@ use super::*; +use std::io::Write; use table_styles::{PptxTableProps, PptxTableStyleDef, TableCellRegionStyle, TableStyleMap}; +// ── Helpers ──────────────────────────────────────────────────────────── + +fn make_table_graphic_frame( + x: i64, + y: i64, + cx: i64, + cy: i64, + col_widths_emu: &[i64], + rows_xml: &str, +) -> String { + let mut grid = String::new(); + for width in col_widths_emu { + grid.push_str(&format!(r#""#)); + } + format!( + r#"{grid}{rows_xml}"# + ) +} + +fn make_table_row(cells: &[&str]) -> String { + let mut xml = String::from(r#""#); + for text in cells { + xml.push_str(&format!( + r#"{text}"# + )); + } + xml.push_str(""); + xml +} + +fn table_element(elem: &FixedElement) -> &Table { + match &elem.kind { + FixedElementKind::Table(table) => table, + _ => panic!("Expected Table, got {:?}", elem.kind), + } +} + // ── Unit tests: parse_table_styles_xml ───────────────────────────────── fn make_table_style_xml(styles: &[(&str, &str)]) -> String { @@ -368,3 +406,202 @@ fn test_apply_table_style_missing_style_id_is_noop() { assert_eq!(table.rows[0].cells[0].background, None); } + +// ── Integration tests: end-to-end PPTX with table styles ────────────── + +/// Build a PPTX with theme and tableStyles.xml included. +fn build_test_pptx_with_table_styles( + slide_cx_emu: i64, + slide_cy_emu: i64, + slide_xmls: &[String], + theme_xml: &str, + table_styles_xml: &str, +) -> Vec { + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + let opts = FileOptions::default(); + + let mut ct = String::from(r#""#); + ct.push_str(r#""#); + ct.push_str(r#""#); + ct.push_str(r#""#); + for i in 0..slide_xmls.len() { + ct.push_str(&format!( + r#""#, + i + 1 + )); + } + ct.push_str(""); + zip.start_file("[Content_Types].xml", opts).unwrap(); + zip.write_all(ct.as_bytes()).unwrap(); + + zip.start_file("_rels/.rels", opts).unwrap(); + zip.write_all( + br#""#, + ) + .unwrap(); + + let mut pres = format!( + r#""#, + slide_cx_emu, slide_cy_emu + ); + for i in 0..slide_xmls.len() { + pres.push_str(&format!( + r#""#, + 256 + i, + 2 + i + )); + } + pres.push_str(""); + zip.start_file("ppt/presentation.xml", opts).unwrap(); + zip.write_all(pres.as_bytes()).unwrap(); + + let mut pres_rels = String::from( + r#""#, + ); + pres_rels.push_str( + r#""#, + ); + for i in 0..slide_xmls.len() { + pres_rels.push_str(&format!( + r#""#, + 2 + i, + 1 + i + )); + } + pres_rels.push_str(""); + zip.start_file("ppt/_rels/presentation.xml.rels", opts) + .unwrap(); + zip.write_all(pres_rels.as_bytes()).unwrap(); + + zip.start_file("ppt/theme/theme1.xml", opts).unwrap(); + zip.write_all(theme_xml.as_bytes()).unwrap(); + + zip.start_file("ppt/tableStyles.xml", opts).unwrap(); + zip.write_all(table_styles_xml.as_bytes()).unwrap(); + + for (i, slide_xml) in slide_xmls.iter().enumerate() { + zip.start_file(format!("ppt/slides/slide{}.xml", i + 1), opts) + .unwrap(); + zip.write_all(slide_xml.as_bytes()).unwrap(); + } + + zip.finish().unwrap().into_inner() +} + +#[test] +fn test_pptx_table_with_style_applies_header_fill_and_text_color() { + // Table style: firstRow has accent1 fill and white bold text, band1H has light tint + let table_styles_xml = concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ); + + // Table with tblPr firstRow=1 bandRow=1 and a tableStyleId + let table_xml = concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#"{5940675A-B579-460E-94D1-54222C63F5DA}"#, + r#""#, + // Header row with white text (schemeClr bg1 = lt1 = white) + r#""#, + r#"Model"#, + r#"GPU"#, + r#""#, + // Data row 1 + r#""#, + r#"YOLOv8n"#, + r#"RTX 4090"#, + r#""#, + // Data row 2 + r#""#, + r#"YOLOv8s"#, + r#"A100"#, + r#""#, + r#""#, + ); + + let slide = make_slide_xml(&[table_xml.to_string()]); + let theme_xml = make_theme_xml(&standard_theme_colors(), "Calibri Light", "Calibri"); + let data = build_test_pptx_with_table_styles( + SLIDE_CX, + SLIDE_CY, + &[slide], + &theme_xml, + table_styles_xml, + ); + + let parser = PptxParser; + let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap(); + + let page = first_fixed_page(&doc); + let table = table_element(&page.elements[0]); + + // Header row should get accent1 (#4472C4) background from firstRow style + assert_eq!( + table.rows[0].cells[0].background, + Some(Color::new(0x44, 0x72, 0xC4)) + ); + assert_eq!( + table.rows[0].cells[1].background, + Some(Color::new(0x44, 0x72, 0xC4)) + ); + + // Header row text: explicit white (bg1→lt1→#FFFFFF) preserved, bold from style + let header_run = match &table.rows[0].cells[0].content[0] { + Block::Paragraph(p) => &p.runs[0], + _ => panic!("Expected paragraph"), + }; + assert_eq!(header_run.text, "Model"); + assert_eq!(header_run.style.color, Some(Color::new(0xFF, 0xFF, 0xFF))); + assert_eq!(header_run.style.bold, Some(true)); + + // Data row 1 (band index 0 → band1H) should get tinted accent1 + // accent1=(68,114,196) with tint 40%: (180,199,231) + assert_eq!( + table.rows[1].cells[0].background, + Some(Color::new(180, 199, 231)) + ); + + // Data row 2 (band index 1 → band2H, not defined) → no fill + assert_eq!(table.rows[2].cells[0].background, None); + + // header_row_count should be 1 + assert_eq!(table.header_row_count, 1); +} + +#[test] +fn test_pptx_table_without_table_styles_xml_still_works() { + // Regular PPTX without tableStyles.xml should work fine + let rows = format!( + "{}{}", + make_table_row(&["A1", "B1"]), + make_table_row(&["A2", "B2"]), + ); + let table_frame = + make_table_graphic_frame(0, 0, 3657600, 1828800, &[1828800, 1828800], &rows); + let slide = make_slide_xml(&[table_frame]); + let theme_xml = make_theme_xml(&standard_theme_colors(), "Calibri Light", "Calibri"); + let data = build_test_pptx_with_theme(SLIDE_CX, SLIDE_CY, &[slide], &theme_xml); + + let parser = PptxParser; + let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap(); + + let page = first_fixed_page(&doc); + let table = table_element(&page.elements[0]); + assert_eq!(table.rows.len(), 2); + assert_eq!(table.rows[0].cells[0].background, None); +} From 627e4b5edc54a23150d2b3b42692725df42dc3b7 Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Sat, 14 Mar 2026 01:49:04 +0900 Subject: [PATCH 5/6] docs: add PPTX table styles to README features Co-Authored-By: Claude Opus 4.6 Signed-off-by: Yonghye Kwon --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5a10666..6f70199 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ No LibreOffice, no Chromium, no Docker — just a single binary powered by [Typs ## Features - **DOCX** — paragraphs, inline formatting (bold/italic/underline/color), tables, images, lists, headers/footers, page setup -- **PPTX** — slides, text boxes, shapes, images, slide masters, speaker notes, gradient backgrounds, shadow/reflection effects +- **PPTX** — slides, text boxes, shapes, tables (with theme-based table styles), images, slide masters, speaker notes, gradient backgrounds, shadow/reflection effects - **XLSX** — sheets, cell formatting, merged cells, column widths, row heights, conditional formatting (DataBar, IconSet) - **PDF/A-2b** — archival-compliant output via `--pdf-a` - **Embedded font extraction** — fonts embedded in PPTX/DOCX are automatically extracted, deobfuscated, and used during conversion @@ -129,7 +129,7 @@ Available functions: `convertToPdf(data, format)`, `convertDocxToPdf(data)`, `co | Format | Status | Key Features | |--------|--------|-------------| | DOCX | Supported | Text, tables, images, lists, headers/footers, page setup | -| PPTX | Supported | Slides, text boxes, shapes, images, masters, gradients, effects | +| PPTX | Supported | Slides, text boxes, shapes, tables, images, masters, gradients, effects | | XLSX | Supported | Sheets, formatting, merged cells, column/row sizing, conditional formatting | ## License From e7f2826bc94cb113b4a5131dc2cb04870dd8edfc Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Sat, 14 Mar 2026 09:20:22 +0900 Subject: [PATCH 6/6] style: fix rustfmt and clippy warnings in table style code Co-Authored-By: Claude Opus 4.6 Signed-off-by: Yonghye Kwon --- .../src/parser/pptx_table_style_tests.rs | 22 +++---- .../src/parser/pptx_table_styles.rs | 63 ++++++++++++------- 2 files changed, 50 insertions(+), 35 deletions(-) diff --git a/crates/office2pdf/src/parser/pptx_table_style_tests.rs b/crates/office2pdf/src/parser/pptx_table_style_tests.rs index 993029d..1e1cf8c 100644 --- a/crates/office2pdf/src/parser/pptx_table_style_tests.rs +++ b/crates/office2pdf/src/parser/pptx_table_style_tests.rs @@ -70,8 +70,7 @@ fn test_parse_table_style_with_whole_table_fill() { let theme: ThemeData = test_theme(); let color_map: ColorMapData = test_color_map(); - let styles: TableStyleMap = - table_styles::parse_table_styles_xml(&xml, &theme, &color_map); + let styles: TableStyleMap = table_styles::parse_table_styles_xml(&xml, &theme, &color_map); let style: &PptxTableStyleDef = styles .get("{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}") @@ -93,8 +92,7 @@ fn test_parse_table_style_with_first_row_scheme_color() { let theme: ThemeData = test_theme(); let color_map: ColorMapData = test_color_map(); - let styles: TableStyleMap = - table_styles::parse_table_styles_xml(&xml, &theme, &color_map); + let styles: TableStyleMap = table_styles::parse_table_styles_xml(&xml, &theme, &color_map); let style: &PptxTableStyleDef = styles.get("style1").expect("style not found"); let first_row = style.first_row.as_ref().expect("firstRow missing"); @@ -113,8 +111,7 @@ fn test_parse_table_style_banded_rows() { let theme: ThemeData = test_theme(); let color_map: ColorMapData = test_color_map(); - let styles: TableStyleMap = - table_styles::parse_table_styles_xml(&xml, &theme, &color_map); + let styles: TableStyleMap = table_styles::parse_table_styles_xml(&xml, &theme, &color_map); let style: &PptxTableStyleDef = styles.get("bandtest").expect("style not found"); assert_eq!( @@ -135,8 +132,7 @@ fn test_parse_table_style_with_color_transforms() { let theme: ThemeData = test_theme(); let color_map: ColorMapData = test_color_map(); - let styles: TableStyleMap = - table_styles::parse_table_styles_xml(&xml, &theme, &color_map); + let styles: TableStyleMap = table_styles::parse_table_styles_xml(&xml, &theme, &color_map); let style: &PptxTableStyleDef = styles.get("tinttest").expect("style not found"); let band = style.band1_h.as_ref().expect("band1H missing"); @@ -288,7 +284,12 @@ fn test_apply_table_style_banded_rows_skip_first_row() { }; let mut table = Table { - rows: vec![make_row("Header"), make_row("Row1"), make_row("Row2"), make_row("Row3")], + rows: vec![ + make_row("Header"), + make_row("Row1"), + make_row("Row2"), + make_row("Row3"), + ], column_widths: vec![200.0], header_row_count: 1, alignment: None, @@ -591,8 +592,7 @@ fn test_pptx_table_without_table_styles_xml_still_works() { make_table_row(&["A1", "B1"]), make_table_row(&["A2", "B2"]), ); - let table_frame = - make_table_graphic_frame(0, 0, 3657600, 1828800, &[1828800, 1828800], &rows); + let table_frame = make_table_graphic_frame(0, 0, 3657600, 1828800, &[1828800, 1828800], &rows); let slide = make_slide_xml(&[table_frame]); let theme_xml = make_theme_xml(&standard_theme_colors(), "Calibri Light", "Calibri"); let data = build_test_pptx_with_theme(SLIDE_CX, SLIDE_CY, &[slide], &theme_xml); diff --git a/crates/office2pdf/src/parser/pptx_table_styles.rs b/crates/office2pdf/src/parser/pptx_table_styles.rs index 7ec4ff0..957534b 100644 --- a/crates/office2pdf/src/parser/pptx_table_styles.rs +++ b/crates/office2pdf/src/parser/pptx_table_styles.rs @@ -59,8 +59,12 @@ pub(super) fn parse_table_styles_xml( current_def = PptxTableStyleDef::default(); } b"wholeTbl" if current_style_id.is_some() => { - current_def.whole_table = - Some(parse_region_style(&mut reader, b"wholeTbl", theme, color_map)); + current_def.whole_table = Some(parse_region_style( + &mut reader, + b"wholeTbl", + theme, + color_map, + )); } b"band1H" if current_style_id.is_some() => { current_def.band1_h = @@ -71,20 +75,36 @@ pub(super) fn parse_table_styles_xml( Some(parse_region_style(&mut reader, b"band2H", theme, color_map)); } b"firstRow" if current_style_id.is_some() => { - current_def.first_row = - Some(parse_region_style(&mut reader, b"firstRow", theme, color_map)); + current_def.first_row = Some(parse_region_style( + &mut reader, + b"firstRow", + theme, + color_map, + )); } b"lastRow" if current_style_id.is_some() => { - current_def.last_row = - Some(parse_region_style(&mut reader, b"lastRow", theme, color_map)); + current_def.last_row = Some(parse_region_style( + &mut reader, + b"lastRow", + theme, + color_map, + )); } b"firstCol" if current_style_id.is_some() => { - current_def.first_col = - Some(parse_region_style(&mut reader, b"firstCol", theme, color_map)); + current_def.first_col = Some(parse_region_style( + &mut reader, + b"firstCol", + theme, + color_map, + )); } b"lastCol" if current_style_id.is_some() => { - current_def.last_col = - Some(parse_region_style(&mut reader, b"lastCol", theme, color_map)); + current_def.last_col = Some(parse_region_style( + &mut reader, + b"lastCol", + theme, + color_map, + )); } _ => {} }, @@ -130,13 +150,11 @@ fn parse_region_style( b"solidFill" if in_fill || in_tc_style => in_solid_fill = true, b"fontRef" if in_tc_tx_style => in_font_ref = true, b"srgbClr" | b"schemeClr" | b"sysClr" if in_solid_fill => { - let parsed: ParsedColor = - parse_color_from_start(reader, e, theme, color_map); + let parsed: ParsedColor = parse_color_from_start(reader, e, theme, color_map); style.fill = parsed.color; } b"srgbClr" | b"schemeClr" | b"sysClr" if in_font_ref => { - let parsed: ParsedColor = - parse_color_from_start(reader, e, theme, color_map); + let parsed: ParsedColor = parse_color_from_start(reader, e, theme, color_map); style.text_color = parsed.color; } _ => {} @@ -185,11 +203,7 @@ fn parse_region_style( /// Apply table style colors/formatting to cells that don't have explicit overrides. /// /// Priority (highest wins): cell-level explicit → firstRow/lastRow/firstCol/lastCol → band → wholeTbl -pub(super) fn apply_table_style( - table: &mut Table, - props: &PptxTableProps, - styles: &TableStyleMap, -) { +pub(super) fn apply_table_style(table: &mut Table, props: &PptxTableProps, styles: &TableStyleMap) { let style_id: &str = match props.style_id.as_deref() { Some(id) => id, None => return, @@ -206,7 +220,8 @@ pub(super) fn apply_table_style( for (row_idx, row) in table.rows.iter_mut().enumerate() { let is_first_row: bool = props.first_row && row_idx < header_rows; - let is_last_row: bool = props.last_row && total_rows > header_rows && row_idx == total_rows - 1; + let is_last_row: bool = + props.last_row && total_rows > header_rows && row_idx == total_rows - 1; // Data row index for banding (excludes first/last special rows) let data_row_idx: Option = if !is_first_row && !is_last_row { @@ -271,10 +286,10 @@ fn apply_region_to_cell(cell: &mut TableCell, region: &TableCellRegionStyle) { if region.text_color.is_some() && run.style.color.is_none() { run.style.color = region.text_color; } - if let Some(bold) = region.text_bold { - if run.style.bold.is_none() { - run.style.bold = Some(bold); - } + if let Some(bold) = region.text_bold + && run.style.bold.is_none() + { + run.style.bold = Some(bold); } } }