Skip to content

Commit 8c72a0b

Browse files
Merge pull request #171 from developer0hye/fix/pptx-rendering-bugs
fix(pptx): skip master/layout placeholders, render custGeom, fix wrap and list numbering
2 parents 8130c9d + 67bbca0 commit 8c72a0b

12 files changed

Lines changed: 149 additions & 46 deletions

crates/office2pdf/src/ir/elements.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ pub struct TextBoxData {
149149
/// Shape geometry when the text box originates from a non-rectangular shape
150150
/// (e.g., `roundRect`, `homePlate`). `None` means default rectangle.
151151
pub shape_kind: Option<ShapeKind>,
152+
/// When true, text should not wrap — the content width is unconstrained.
153+
/// Corresponds to `<a:bodyPr wrap="none"/>` in OOXML.
154+
pub no_wrap: bool,
152155
}
153156

154157
/// 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
@@ -584,6 +584,7 @@ fn test_render_pptx_style_document_size() {
584584
opacity: None,
585585
stroke: None,
586586
shape_kind: None,
587+
no_wrap: false,
587588
}),
588589
}],
589590
}));
@@ -642,6 +643,7 @@ fn test_render_document_with_centered_fixed_text_box() {
642643
opacity: None,
643644
stroke: None,
644645
shape_kind: None,
646+
no_wrap: false,
645647
}),
646648
}],
647649
})],

crates/office2pdf/src/parser/pptx_preset_shape_tests.rs

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -351,16 +351,7 @@ fn test_shape_home_plate() {
351351
#[test]
352352
fn test_shape_home_plate_square() {
353353
// 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-
);
354+
let shape = make_shape(0, 0, 1_000_000, 1_000_000, "homePlate", None, None, None);
364355
let slide = make_slide_xml(&[shape]);
365356
let data = build_test_pptx(SLIDE_CX, SLIDE_CY, &[slide]);
366357
let parser = PptxParser;

crates/office2pdf/src/parser/pptx_shapes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ impl GroupTransform {
7878
/// Reads through the group's header sections (`nvGrpSpPr`, `grpSpPr`),
7979
/// extracts the coordinate transform, then slices the original XML to
8080
/// get the child shapes, and recursively parses them via `parse_slide_xml`.
81+
#[allow(clippy::too_many_arguments)]
8182
pub(super) fn parse_group_shape(
8283
reader: &mut Reader<&[u8]>,
8384
xml: &str,

crates/office2pdf/src/parser/pptx_slides.rs

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,15 @@ fn parse_layer_elements<R: Read + std::io::Seek>(
7676
) -> (Vec<FixedElement>, Vec<ConvertWarning>) {
7777
let images: SlideImageMap = load_slide_images(layer_path, archive);
7878
let empty_table_styles: table_styles::TableStyleMap = table_styles::TableStyleMap::new();
79-
parse_slide_xml(
79+
parse_slide_xml_inner(
8080
layer_xml,
8181
&images,
8282
theme,
8383
color_map,
8484
label,
8585
text_style_defaults,
8686
&empty_table_styles,
87+
true, // skip placeholder shapes in master/layout layers
8788
)
8889
.unwrap_or_default()
8990
}
@@ -480,6 +481,7 @@ fn finalize_shape(
480481
paragraphs: &mut Vec<PptxParagraphEntry>,
481482
text_box_padding: Insets,
482483
text_box_vertical_align: TextBoxVerticalAlign,
484+
text_box_no_wrap: bool,
483485
) -> Vec<FixedElement> {
484486
// Resolve effective fill: explicit > noFill > style fallback.
485487
let effective_fill: Option<Color> = if shape.fill.is_some() {
@@ -556,6 +558,7 @@ fn finalize_shape(
556558
opacity: None,
557559
stroke: None,
558560
shape_kind: None,
561+
no_wrap: text_box_no_wrap,
559562
}),
560563
});
561564
} else {
@@ -573,6 +576,7 @@ fn finalize_shape(
573576
opacity: shape.opacity,
574577
stroke,
575578
shape_kind: None,
579+
no_wrap: text_box_no_wrap,
576580
}),
577581
});
578582
}
@@ -700,6 +704,12 @@ struct SlideXmlParser<'a> {
700704
inherited_text_body_defaults: &'a PptxTextBodyStyleDefaults,
701705
table_styles: &'a table_styles::TableStyleMap,
702706

707+
// ── Options ─────────────────────────────────────────────────────
708+
/// When true, shapes with `<p:ph>` (placeholder) are skipped.
709+
/// Used when parsing master/layout layers whose placeholder content
710+
/// should not render unless the slide overrides it.
711+
skip_placeholders: bool,
712+
703713
// ── Output accumulators ─────────────────────────────────────────
704714
elements: Vec<FixedElement>,
705715
warnings: Vec<ConvertWarning>,
@@ -713,6 +723,7 @@ struct SlideXmlParser<'a> {
713723
paragraphs: Vec<PptxParagraphEntry>,
714724
text_box_padding: Insets,
715725
text_box_vertical_align: TextBoxVerticalAlign,
726+
text_box_no_wrap: bool,
716727
text_body_style_defaults: PptxTextBodyStyleDefaults,
717728

718729
// ── Paragraph state (`<a:p>`) ───────────────────────────────────
@@ -770,6 +781,8 @@ impl<'a> SlideXmlParser<'a> {
770781
inherited_text_body_defaults,
771782
table_styles,
772783

784+
skip_placeholders: false,
785+
773786
elements: Vec::new(),
774787
warnings: Vec::new(),
775788

@@ -780,6 +793,7 @@ impl<'a> SlideXmlParser<'a> {
780793
paragraphs: Vec::new(),
781794
text_box_padding: default_pptx_text_box_padding(),
782795
text_box_vertical_align: TextBoxVerticalAlign::Top,
796+
text_box_no_wrap: false,
783797
text_body_style_defaults: PptxTextBodyStyleDefaults::default(),
784798

785799
in_para: false,
@@ -863,6 +877,7 @@ impl<'a> SlideXmlParser<'a> {
863877
self.paragraphs.clear();
864878
self.text_box_padding = default_pptx_text_box_padding();
865879
self.text_box_vertical_align = TextBoxVerticalAlign::Top;
880+
self.text_box_no_wrap = false;
866881
}
867882
b"sp" | b"cxnSp" if self.in_shape => {
868883
self.shape.depth += 1;
@@ -885,6 +900,10 @@ impl<'a> SlideXmlParser<'a> {
885900
self.shape.prst_geom = Some(prst);
886901
}
887902
}
903+
// Treat custom geometry as a rectangle fallback so the fill renders.
904+
b"custGeom" if self.shape.in_sp_pr && self.shape.prst_geom.is_none() => {
905+
self.shape.prst_geom = Some("rect".to_string());
906+
}
888907
b"noFill" if self.shape.in_sp_pr && !self.shape.in_ln && !self.in_rpr => {
889908
self.shape.explicit_no_fill = true;
890909
}
@@ -944,6 +963,7 @@ impl<'a> SlideXmlParser<'a> {
944963
e,
945964
&mut self.text_box_padding,
946965
&mut self.text_box_vertical_align,
966+
&mut self.text_box_no_wrap,
947967
);
948968
}
949969
b"lstStyle" if self.in_shape && self.in_txbody => {
@@ -1172,19 +1192,27 @@ impl<'a> SlideXmlParser<'a> {
11721192
.map(pptx_dash_to_border_style)
11731193
.unwrap_or(BorderLineStyle::Solid);
11741194
}
1195+
// Handle self-closing <p:ph type="..."/> (placeholder marker).
1196+
b"ph" if self.in_shape => {
1197+
self.shape.has_placeholder = true;
1198+
}
11751199
// Handle self-closing <a:bodyPr anchor="ctr"/> (no child elements).
11761200
b"bodyPr" if self.in_shape && self.in_txbody => {
11771201
extract_pptx_text_box_body_props(
11781202
e,
11791203
&mut self.text_box_padding,
11801204
&mut self.text_box_vertical_align,
1205+
&mut self.text_box_no_wrap,
11811206
);
11821207
}
11831208
b"prstGeom" if self.shape.in_sp_pr => {
11841209
if let Some(prst) = get_attr_str(e, b"prst") {
11851210
self.shape.prst_geom = Some(prst);
11861211
}
11871212
}
1213+
b"custGeom" if self.shape.in_sp_pr && self.shape.prst_geom.is_none() => {
1214+
self.shape.prst_geom = Some("rect".to_string());
1215+
}
11881216
b"ln" if self.shape.in_sp_pr => {
11891217
self.shape.ln_width_emu = get_attr_i64(e, b"w").unwrap_or(12700);
11901218
}
@@ -1336,12 +1364,19 @@ impl<'a> SlideXmlParser<'a> {
13361364
b"sp" | b"cxnSp" if self.in_shape => {
13371365
self.shape.depth -= 1;
13381366
if self.shape.depth == 0 {
1339-
self.elements.extend(finalize_shape(
1340-
&mut self.shape,
1341-
&mut self.paragraphs,
1342-
self.text_box_padding,
1343-
self.text_box_vertical_align,
1344-
));
1367+
// Skip placeholder shapes when parsing master/layout layers.
1368+
// Placeholder content is only visible when the slide itself
1369+
// overrides it; master/layout placeholder text (e.g.
1370+
// "마스터 제목 스타일 편집") should never be rendered.
1371+
if !(self.skip_placeholders && self.shape.has_placeholder) {
1372+
self.elements.extend(finalize_shape(
1373+
&mut self.shape,
1374+
&mut self.paragraphs,
1375+
self.text_box_padding,
1376+
self.text_box_vertical_align,
1377+
self.text_box_no_wrap,
1378+
));
1379+
}
13451380
self.in_shape = false;
13461381
}
13471382
}
@@ -1458,6 +1493,29 @@ pub(super) fn parse_slide_xml(
14581493
warning_context: &str,
14591494
inherited_text_body_defaults: &PptxTextBodyStyleDefaults,
14601495
table_styles: &table_styles::TableStyleMap,
1496+
) -> Result<(Vec<FixedElement>, Vec<ConvertWarning>), ConvertError> {
1497+
parse_slide_xml_inner(
1498+
xml,
1499+
images,
1500+
theme,
1501+
color_map,
1502+
warning_context,
1503+
inherited_text_body_defaults,
1504+
table_styles,
1505+
false,
1506+
)
1507+
}
1508+
1509+
#[allow(clippy::too_many_arguments)]
1510+
fn parse_slide_xml_inner(
1511+
xml: &str,
1512+
images: &SlideImageMap,
1513+
theme: &ThemeData,
1514+
color_map: &ColorMapData,
1515+
warning_context: &str,
1516+
inherited_text_body_defaults: &PptxTextBodyStyleDefaults,
1517+
table_styles: &table_styles::TableStyleMap,
1518+
skip_placeholders: bool,
14611519
) -> Result<(Vec<FixedElement>, Vec<ConvertWarning>), ConvertError> {
14621520
let mut reader = Reader::from_str(xml);
14631521
let mut parser = SlideXmlParser::new(
@@ -1469,6 +1527,7 @@ pub(super) fn parse_slide_xml(
14691527
inherited_text_body_defaults,
14701528
table_styles,
14711529
);
1530+
parser.skip_placeholders = skip_placeholders;
14721531

14731532
loop {
14741533
match reader.read_event() {

crates/office2pdf/src/parser/pptx_text.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,7 @@ pub(super) fn extract_pptx_text_box_body_props(
509509
e: &quick_xml::events::BytesStart,
510510
padding: &mut Insets,
511511
vertical_align: &mut TextBoxVerticalAlign,
512+
no_wrap: &mut bool,
512513
) {
513514
if let Some(value) = get_attr_i64(e, b"lIns") {
514515
padding.left = emu_to_pt(value);
@@ -529,6 +530,9 @@ pub(super) fn extract_pptx_text_box_body_props(
529530
_ => TextBoxVerticalAlign::Top,
530531
};
531532
}
533+
if get_attr_str(e, b"wrap").as_deref() == Some("none") {
534+
*no_wrap = true;
535+
}
532536
}
533537

534538
pub(super) fn extract_pptx_table_cell_props(

crates/office2pdf/src/render/typst_gen.rs

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,9 @@ fn generate_fixed_text_box(
588588
format_f64(outer_height_pt),
589589
format_insets(&text_box.padding),
590590
);
591+
if text_box.no_wrap {
592+
out.push_str(", clip: false");
593+
}
591594
// For non-rectangular shapes, render fill/stroke as a placed background shape.
592595
if has_custom_shape {
593596
// Transparent outer block — shape background is placed inside.
@@ -612,19 +615,42 @@ fn generate_fixed_text_box(
612615
&text_box.stroke,
613616
);
614617
}
615-
let _ = writeln!(
616-
out,
617-
" #let text_box_content_{text_box_id} = block(width: {}pt)[",
618-
format_f64(inner_width_pt),
619-
);
620-
for (index, block) in text_box.content.iter().enumerate() {
621-
if index > 0 {
622-
out.push('\n');
618+
if text_box.no_wrap {
619+
// For wrap="none" text boxes: measure the natural content width first,
620+
// then use the larger of (measured width, original width) so that text
621+
// with slightly wider substitute fonts does not wrap.
622+
let _ = writeln!(out, " #let text_box_content_{text_box_id} = context {{");
623+
let _ = writeln!(out, " let _nowrap_draft = [");
624+
for (index, block) in text_box.content.iter().enumerate() {
625+
if index > 0 {
626+
out.push('\n');
627+
}
628+
out.push_str(" ");
629+
generate_fixed_text_box_block(out, block, ctx, Some(inner_width_pt), false)?;
630+
}
631+
let _ = writeln!(out, " ]");
632+
let _ = writeln!(
633+
out,
634+
" let _nowrap_w = calc.max(measure(_nowrap_draft).width, {}pt)",
635+
format_f64(inner_width_pt),
636+
);
637+
let _ = writeln!(out, " block(width: _nowrap_w, _nowrap_draft)");
638+
let _ = writeln!(out, " }}");
639+
} else {
640+
let _ = writeln!(
641+
out,
642+
" #let text_box_content_{text_box_id} = block(width: {}pt)[",
643+
format_f64(inner_width_pt),
644+
);
645+
for (index, block) in text_box.content.iter().enumerate() {
646+
if index > 0 {
647+
out.push('\n');
648+
}
649+
out.push_str(" ");
650+
generate_fixed_text_box_block(out, block, ctx, Some(inner_width_pt), false)?;
623651
}
624-
out.push_str(" ");
625-
generate_fixed_text_box_block(out, block, ctx, Some(inner_width_pt))?;
652+
out.push_str(" ]\n");
626653
}
627-
out.push_str(" ]\n");
628654

629655
match text_box.vertical_align {
630656
TextBoxVerticalAlign::Top => {
@@ -1045,7 +1071,7 @@ fn generate_floating_text_box_content(
10451071
if index > 0 {
10461072
out.push('\n');
10471073
}
1048-
generate_fixed_text_box_block(out, block, ctx, Some(ftb.width))?;
1074+
generate_fixed_text_box_block(out, block, ctx, Some(ftb.width), false)?;
10491075
}
10501076
out.push_str("]\n");
10511077
Ok(())
@@ -1056,17 +1082,22 @@ fn generate_fixed_text_box_block(
10561082
block: &Block,
10571083
ctx: &mut GenCtx,
10581084
available_width_pt: Option<f64>,
1085+
no_wrap: bool,
10591086
) -> Result<(), ConvertError> {
10601087
match block {
10611088
Block::List(list) if can_render_fixed_text_list_inline(list) => {
10621089
generate_fixed_text_list(out, list, true, available_width_pt)
10631090
}
1064-
Block::Paragraph(para) => generate_fixed_text_paragraph(out, para),
1091+
Block::Paragraph(para) => generate_fixed_text_paragraph(out, para, no_wrap),
10651092
_ => generate_block(out, block, ctx),
10661093
}
10671094
}
10681095

1069-
fn generate_fixed_text_paragraph(out: &mut String, para: &Paragraph) -> Result<(), ConvertError> {
1096+
fn generate_fixed_text_paragraph(
1097+
out: &mut String,
1098+
para: &Paragraph,
1099+
_no_wrap: bool,
1100+
) -> Result<(), ConvertError> {
10701101
let style: &ParagraphStyle = &para.style;
10711102
let needs_text_scope: bool = common_text_style(&para.runs).is_some();
10721103
let has_para_style: bool = needs_block_wrapper(style) || needs_text_scope;
@@ -1095,7 +1126,7 @@ fn generate_fixed_text_paragraph(out: &mut String, para: &Paragraph) -> Result<(
10951126
Some(Alignment::Right) => "right",
10961127
_ => "left",
10971128
};
1098-
let _ = write!(out, "#block(width: 100%)[#set align({align_str})\n");
1129+
let _ = writeln!(out, "#block(width: 100%)[#set align({align_str})");
10991130
}
11001131

11011132
generate_runs_with_tabs(out, &para.runs, style.tab_stops.as_deref());

0 commit comments

Comments
 (0)