Skip to content

Commit 2ab840d

Browse files
developer0hyeclaude
andcommitted
feat(pptx): add homePlate shape, fillRef/fontRef style fallback, and non-rectangular text box rendering
- Add homePlate preset geometry (pentagon arrow tab) to shape parser - Parse <a:fillRef> from <p:style> as fallback fill color when no explicit solidFill/noFill is set on the shape - Parse <a:fontRef> from <p:style> as fallback text color, overriding inherited layout/master defaults - Handle <a:noFill/> explicitly to prevent style fill fallback - Split text boxes with non-rectangular shapes (roundRect, homePlate, ellipse, polygon) into Shape background + transparent TextBox overlay so geometry is rendered by the proven shape renderer - Add shape_kind field to TextBoxData for future use - Add write_text_box_shape_background() for custom shape rendering Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Yonghye Kwon <developer.0hye@gmail.com>
1 parent 99807f5 commit 2ab840d

11 files changed

Lines changed: 526 additions & 29 deletions

crates/office2pdf/src/ir/elements.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ pub struct TextBoxData {
146146
pub opacity: Option<f64>,
147147
/// Border stroke for the text box.
148148
pub stroke: Option<BorderSide>,
149+
/// Shape geometry when the text box originates from a non-rectangular shape
150+
/// (e.g., `roundRect`, `homePlate`). `None` means default rectangle.
151+
pub shape_kind: Option<ShapeKind>,
149152
}
150153

151154
/// The kind of list: ordered (numbered) or unordered (bulleted).

crates/office2pdf/src/lib_render_tests.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,7 @@ fn test_render_pptx_style_document_size() {
583583
fill: None,
584584
opacity: None,
585585
stroke: None,
586+
shape_kind: None,
586587
}),
587588
}],
588589
}));
@@ -640,6 +641,7 @@ fn test_render_document_with_centered_fixed_text_box() {
640641
fill: None,
641642
opacity: None,
642643
stroke: None,
644+
shape_kind: None,
643645
}),
644646
}],
645647
})],

crates/office2pdf/src/parser/pptx.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,16 @@ impl PptxTextBodyStyleDefaults {
355355
bullet
356356
}
357357

358+
/// Apply a default text color from `<p:style><a:fontRef>`.
359+
/// This overrides inherited layout/master defaults because `fontRef` is
360+
/// a shape-level style with higher precedence.
361+
fn apply_default_color(&mut self, color: Color) {
362+
self.default_run.color = Some(color);
363+
for level_style in self.levels.values_mut() {
364+
level_style.run.color = Some(color);
365+
}
366+
}
367+
358368
fn merge_from(&mut self, overlay: &PptxTextBodyStyleDefaults) {
359369
self.default_paragraph
360370
.merge_from(&overlay.default_paragraph);

crates/office2pdf/src/parser/pptx_preset_shape_tests.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,78 @@ fn test_shape_star6() {
306306
}
307307
}
308308

309+
#[test]
310+
fn test_shape_home_plate() {
311+
// homePlate: pentagon arrow shape (rect with pointed right edge)
312+
// Wide shape: cx=1980000 (wider than tall), cy=584391
313+
let shape = make_shape(
314+
0,
315+
0,
316+
1_980_000,
317+
584_391,
318+
"homePlate",
319+
Some("00259A"),
320+
None,
321+
None,
322+
);
323+
let slide = make_slide_xml(&[shape]);
324+
let data = build_test_pptx(SLIDE_CX, SLIDE_CY, &[slide]);
325+
let parser = PptxParser;
326+
let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap();
327+
328+
let page = first_fixed_page(&doc);
329+
let shape = get_shape(&page.elements[0]);
330+
match &shape.kind {
331+
ShapeKind::Polygon { vertices } => {
332+
assert_eq!(vertices.len(), 5, "homePlate should have 5 vertices");
333+
// First vertex is top-left (0, 0)
334+
assert!(vertices[0].0.abs() < 0.01);
335+
assert!(vertices[0].1.abs() < 0.01);
336+
// Last vertex is bottom-left (0, 1)
337+
assert!(vertices[4].0.abs() < 0.01);
338+
assert!((vertices[4].1 - 1.0).abs() < 0.01);
339+
// Middle vertex is the rightmost point at (1.0, 0.5)
340+
assert!((vertices[2].0 - 1.0).abs() < 0.01);
341+
assert!((vertices[2].1 - 0.5).abs() < 0.01);
342+
// Arrow notch vertices should be between 0 and 1 on x
343+
assert!(vertices[1].0 > 0.5 && vertices[1].0 < 1.0);
344+
assert!(vertices[3].0 > 0.5 && vertices[3].0 < 1.0);
345+
}
346+
other => panic!("Expected Polygon for homePlate, got {other:?}"),
347+
}
348+
assert_eq!(shape.fill, Some(Color::new(0, 37, 154)));
349+
}
350+
351+
#[test]
352+
fn test_shape_home_plate_square() {
353+
// Square bounding box: the notch should be at x = 0.5
354+
let shape = make_shape(
355+
0,
356+
0,
357+
1_000_000,
358+
1_000_000,
359+
"homePlate",
360+
None,
361+
None,
362+
None,
363+
);
364+
let slide = make_slide_xml(&[shape]);
365+
let data = build_test_pptx(SLIDE_CX, SLIDE_CY, &[slide]);
366+
let parser = PptxParser;
367+
let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap();
368+
369+
let page = first_fixed_page(&doc);
370+
let shape = get_shape(&page.elements[0]);
371+
match &shape.kind {
372+
ShapeKind::Polygon { vertices } => {
373+
assert_eq!(vertices.len(), 5);
374+
// For square with default adj=50000: notch_x = 1.0 - 0.5 = 0.5
375+
assert!((vertices[1].0 - 0.5).abs() < 0.01);
376+
}
377+
other => panic!("Expected Polygon for homePlate square, got {other:?}"),
378+
}
379+
}
380+
309381
#[test]
310382
fn test_unsupported_preset_falls_back_to_rectangle() {
311383
let shape = make_shape(

crates/office2pdf/src/parser/pptx_shape_style_tests.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,3 +376,95 @@ fn test_shape_shadow_default_opacity() {
376376
shadow.opacity
377377
);
378378
}
379+
380+
// ── fillRef style fallback tests ─────────────────────────────────
381+
382+
#[test]
383+
fn test_shape_fill_from_style_fill_ref() {
384+
// Shape with no explicit fill, but <p:style><a:fillRef> referencing accent1.
385+
// accent1 = #4472C4 in standard theme.
386+
let shape_xml = r#"<p:sp><p:nvSpPr><p:cNvPr id="2" name="Rect"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm><a:prstGeom prst="roundRect"><a:avLst/></a:prstGeom><a:ln><a:solidFill><a:srgbClr val="000000"/></a:solidFill></a:ln></p:spPr><p:style><a:lnRef idx="2"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="lt1"/></a:fontRef></p:style></p:sp>"#.to_string();
387+
let slide_xml = make_slide_xml(&[shape_xml]);
388+
389+
let theme_xml = make_theme_xml(&standard_theme_colors(), "Calibri", "Calibri");
390+
let data = build_test_pptx_with_theme(SLIDE_CX, SLIDE_CY, &[slide_xml], &theme_xml);
391+
let parser = PptxParser;
392+
let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap();
393+
394+
let page = first_fixed_page(&doc);
395+
let shape = get_shape(&page.elements[0]);
396+
// accent1 = #4472C4
397+
assert_eq!(
398+
shape.fill,
399+
Some(Color::new(0x44, 0x72, 0xC4)),
400+
"Shape should get fill from fillRef accent1"
401+
);
402+
}
403+
404+
#[test]
405+
fn test_shape_explicit_fill_overrides_fill_ref() {
406+
// Shape with explicit solidFill AND <p:style><a:fillRef>.
407+
// Explicit fill should win.
408+
let shape_xml = r#"<p:sp><p:nvSpPr><p:cNvPr id="2" name="Rect"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill></p:spPr><p:style><a:lnRef idx="2"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="lt1"/></a:fontRef></p:style></p:sp>"#.to_string();
409+
let slide_xml = make_slide_xml(&[shape_xml]);
410+
411+
let theme_xml = make_theme_xml(&standard_theme_colors(), "Calibri", "Calibri");
412+
let data = build_test_pptx_with_theme(SLIDE_CX, SLIDE_CY, &[slide_xml], &theme_xml);
413+
let parser = PptxParser;
414+
let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap();
415+
416+
let page = first_fixed_page(&doc);
417+
let shape = get_shape(&page.elements[0]);
418+
assert_eq!(
419+
shape.fill,
420+
Some(Color::new(255, 0, 0)),
421+
"Explicit solidFill should override fillRef"
422+
);
423+
}
424+
425+
#[test]
426+
fn test_shape_no_fill_overrides_fill_ref() {
427+
// Shape with explicit <a:noFill/> AND <p:style><a:fillRef>.
428+
// noFill should prevent style fallback.
429+
let shape_xml = r#"<p:sp><p:nvSpPr><p:cNvPr id="2" name="Rect"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/></p:spPr><p:style><a:lnRef idx="2"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="lt1"/></a:fontRef></p:style></p:sp>"#.to_string();
430+
let slide_xml = make_slide_xml(&[shape_xml]);
431+
432+
let theme_xml = make_theme_xml(&standard_theme_colors(), "Calibri", "Calibri");
433+
let data = build_test_pptx_with_theme(SLIDE_CX, SLIDE_CY, &[slide_xml], &theme_xml);
434+
let parser = PptxParser;
435+
let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap();
436+
437+
let page = first_fixed_page(&doc);
438+
let shape = get_shape(&page.elements[0]);
439+
assert_eq!(
440+
shape.fill, None,
441+
"noFill should prevent style fillRef fallback"
442+
);
443+
}
444+
445+
#[test]
446+
fn test_textbox_fill_from_style_fill_ref() {
447+
// TextBox with roundRect (non-rectangular shape) and text gets split into
448+
// two elements: Shape background (with fill) + transparent TextBox overlay.
449+
let shape_xml = r#"<p:sp><p:nvSpPr><p:cNvPr id="2" name="TextBox"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm><a:prstGeom prst="roundRect"><a:avLst/></a:prstGeom><a:ln><a:solidFill><a:srgbClr val="000000"/></a:solidFill></a:ln></p:spPr><p:style><a:lnRef idx="2"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="lt1"/></a:fontRef></p:style><p:txBody><a:bodyPr/><a:p><a:r><a:rPr lang="en-US"/><a:t>Hello</a:t></a:r></a:p></p:txBody></p:sp>"#.to_string();
450+
let slide_xml = make_slide_xml(&[shape_xml]);
451+
452+
let theme_xml = make_theme_xml(&standard_theme_colors(), "Calibri", "Calibri");
453+
let data = build_test_pptx_with_theme(SLIDE_CX, SLIDE_CY, &[slide_xml], &theme_xml);
454+
let parser = PptxParser;
455+
let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap();
456+
457+
let page = first_fixed_page(&doc);
458+
// First element: Shape background with geometry and fill
459+
assert_eq!(page.elements.len(), 2, "Expected Shape + TextBox pair");
460+
let shape = get_shape(&page.elements[0]);
461+
assert_eq!(
462+
shape.fill,
463+
Some(Color::new(0x44, 0x72, 0xC4)),
464+
"Shape background should get fill from fillRef accent1"
465+
);
466+
assert!(matches!(shape.kind, ShapeKind::RoundedRectangle { .. }));
467+
// Second element: Transparent text overlay
468+
let tb = text_box_data(&page.elements[1]);
469+
assert_eq!(tb.fill, None, "Text overlay should have no fill");
470+
}

crates/office2pdf/src/parser/pptx_shapes.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,22 @@ pub(super) fn prst_to_shape_kind(
282282
"roundRect" => ShapeKind::RoundedRectangle {
283283
radius_fraction: 0.1,
284284
},
285+
// homePlate: pentagon arrow tab (rect with pointed right edge)
286+
"homePlate" => {
287+
let adj: f64 = adj_values.first().copied().unwrap_or(50_000.0);
288+
let ss: f64 = width.min(height);
289+
let dx: f64 = (adj / 100_000.0 * ss).min(width);
290+
let notch_x: f64 = (width - dx) / width;
291+
ShapeKind::Polygon {
292+
vertices: vec![
293+
(0.0, 0.0),
294+
(notch_x, 0.0),
295+
(1.0, 0.5),
296+
(notch_x, 1.0),
297+
(0.0, 1.0),
298+
],
299+
}
300+
}
285301
"triangle" => ShapeKind::Polygon {
286302
vertices: vec![(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)],
287303
},

0 commit comments

Comments
 (0)