Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/office2pdf/src/ir/elements.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ pub struct TextBoxData {
/// Shape geometry when the text box originates from a non-rectangular shape
/// (e.g., `roundRect`, `homePlate`). `None` means default rectangle.
pub shape_kind: Option<ShapeKind>,
/// When true, text should not wrap — the content width is unconstrained.
/// Corresponds to `<a:bodyPr wrap="none"/>` in OOXML.
pub no_wrap: bool,
}

/// The kind of list: ordered (numbered) or unordered (bulleted).
Expand Down
2 changes: 2 additions & 0 deletions crates/office2pdf/src/lib_render_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,7 @@ fn test_render_pptx_style_document_size() {
opacity: None,
stroke: None,
shape_kind: None,
no_wrap: false,
}),
}],
}));
Expand Down Expand Up @@ -642,6 +643,7 @@ fn test_render_document_with_centered_fixed_text_box() {
opacity: None,
stroke: None,
shape_kind: None,
no_wrap: false,
}),
}],
})],
Expand Down
11 changes: 1 addition & 10 deletions crates/office2pdf/src/parser/pptx_preset_shape_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,16 +351,7 @@ fn test_shape_home_plate() {
#[test]
fn test_shape_home_plate_square() {
// Square bounding box: the notch should be at x = 0.5
let shape = make_shape(
0,
0,
1_000_000,
1_000_000,
"homePlate",
None,
None,
None,
);
let shape = make_shape(0, 0, 1_000_000, 1_000_000, "homePlate", None, None, None);
let slide = make_slide_xml(&[shape]);
let data = build_test_pptx(SLIDE_CX, SLIDE_CY, &[slide]);
let parser = PptxParser;
Expand Down
1 change: 1 addition & 0 deletions crates/office2pdf/src/parser/pptx_shapes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ impl GroupTransform {
/// Reads through the group's header sections (`nvGrpSpPr`, `grpSpPr`),
/// extracts the coordinate transform, then slices the original XML to
/// get the child shapes, and recursively parses them via `parse_slide_xml`.
#[allow(clippy::too_many_arguments)]
pub(super) fn parse_group_shape(
reader: &mut Reader<&[u8]>,
xml: &str,
Expand Down
73 changes: 66 additions & 7 deletions crates/office2pdf/src/parser/pptx_slides.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,15 @@ fn parse_layer_elements<R: Read + std::io::Seek>(
) -> (Vec<FixedElement>, Vec<ConvertWarning>) {
let images: SlideImageMap = load_slide_images(layer_path, archive);
let empty_table_styles: table_styles::TableStyleMap = table_styles::TableStyleMap::new();
parse_slide_xml(
parse_slide_xml_inner(
layer_xml,
&images,
theme,
color_map,
label,
text_style_defaults,
&empty_table_styles,
true, // skip placeholder shapes in master/layout layers
)
.unwrap_or_default()
}
Expand Down Expand Up @@ -480,6 +481,7 @@ fn finalize_shape(
paragraphs: &mut Vec<PptxParagraphEntry>,
text_box_padding: Insets,
text_box_vertical_align: TextBoxVerticalAlign,
text_box_no_wrap: bool,
) -> Vec<FixedElement> {
// Resolve effective fill: explicit > noFill > style fallback.
let effective_fill: Option<Color> = if shape.fill.is_some() {
Expand Down Expand Up @@ -556,6 +558,7 @@ fn finalize_shape(
opacity: None,
stroke: None,
shape_kind: None,
no_wrap: text_box_no_wrap,
}),
});
} else {
Expand All @@ -573,6 +576,7 @@ fn finalize_shape(
opacity: shape.opacity,
stroke,
shape_kind: None,
no_wrap: text_box_no_wrap,
}),
});
}
Expand Down Expand Up @@ -700,6 +704,12 @@ struct SlideXmlParser<'a> {
inherited_text_body_defaults: &'a PptxTextBodyStyleDefaults,
table_styles: &'a table_styles::TableStyleMap,

// ── Options ─────────────────────────────────────────────────────
/// When true, shapes with `<p:ph>` (placeholder) are skipped.
/// Used when parsing master/layout layers whose placeholder content
/// should not render unless the slide overrides it.
skip_placeholders: bool,

// ── Output accumulators ─────────────────────────────────────────
elements: Vec<FixedElement>,
warnings: Vec<ConvertWarning>,
Expand All @@ -713,6 +723,7 @@ struct SlideXmlParser<'a> {
paragraphs: Vec<PptxParagraphEntry>,
text_box_padding: Insets,
text_box_vertical_align: TextBoxVerticalAlign,
text_box_no_wrap: bool,
text_body_style_defaults: PptxTextBodyStyleDefaults,

// ── Paragraph state (`<a:p>`) ───────────────────────────────────
Expand Down Expand Up @@ -770,6 +781,8 @@ impl<'a> SlideXmlParser<'a> {
inherited_text_body_defaults,
table_styles,

skip_placeholders: false,

elements: Vec::new(),
warnings: Vec::new(),

Expand All @@ -780,6 +793,7 @@ impl<'a> SlideXmlParser<'a> {
paragraphs: Vec::new(),
text_box_padding: default_pptx_text_box_padding(),
text_box_vertical_align: TextBoxVerticalAlign::Top,
text_box_no_wrap: false,
text_body_style_defaults: PptxTextBodyStyleDefaults::default(),

in_para: false,
Expand Down Expand Up @@ -863,6 +877,7 @@ impl<'a> SlideXmlParser<'a> {
self.paragraphs.clear();
self.text_box_padding = default_pptx_text_box_padding();
self.text_box_vertical_align = TextBoxVerticalAlign::Top;
self.text_box_no_wrap = false;
}
b"sp" | b"cxnSp" if self.in_shape => {
self.shape.depth += 1;
Expand All @@ -885,6 +900,10 @@ impl<'a> SlideXmlParser<'a> {
self.shape.prst_geom = Some(prst);
}
}
// Treat custom geometry as a rectangle fallback so the fill renders.
b"custGeom" if self.shape.in_sp_pr && self.shape.prst_geom.is_none() => {
self.shape.prst_geom = Some("rect".to_string());
}
b"noFill" if self.shape.in_sp_pr && !self.shape.in_ln && !self.in_rpr => {
self.shape.explicit_no_fill = true;
}
Expand Down Expand Up @@ -944,6 +963,7 @@ impl<'a> SlideXmlParser<'a> {
e,
&mut self.text_box_padding,
&mut self.text_box_vertical_align,
&mut self.text_box_no_wrap,
);
}
b"lstStyle" if self.in_shape && self.in_txbody => {
Expand Down Expand Up @@ -1172,19 +1192,27 @@ impl<'a> SlideXmlParser<'a> {
.map(pptx_dash_to_border_style)
.unwrap_or(BorderLineStyle::Solid);
}
// Handle self-closing <p:ph type="..."/> (placeholder marker).
b"ph" if self.in_shape => {
self.shape.has_placeholder = true;
}
// Handle self-closing <a:bodyPr anchor="ctr"/> (no child elements).
b"bodyPr" if self.in_shape && self.in_txbody => {
extract_pptx_text_box_body_props(
e,
&mut self.text_box_padding,
&mut self.text_box_vertical_align,
&mut self.text_box_no_wrap,
);
}
b"prstGeom" if self.shape.in_sp_pr => {
if let Some(prst) = get_attr_str(e, b"prst") {
self.shape.prst_geom = Some(prst);
}
}
b"custGeom" if self.shape.in_sp_pr && self.shape.prst_geom.is_none() => {
self.shape.prst_geom = Some("rect".to_string());
}
b"ln" if self.shape.in_sp_pr => {
self.shape.ln_width_emu = get_attr_i64(e, b"w").unwrap_or(12700);
}
Expand Down Expand Up @@ -1336,12 +1364,19 @@ impl<'a> SlideXmlParser<'a> {
b"sp" | b"cxnSp" if self.in_shape => {
self.shape.depth -= 1;
if self.shape.depth == 0 {
self.elements.extend(finalize_shape(
&mut self.shape,
&mut self.paragraphs,
self.text_box_padding,
self.text_box_vertical_align,
));
// Skip placeholder shapes when parsing master/layout layers.
// Placeholder content is only visible when the slide itself
// overrides it; master/layout placeholder text (e.g.
// "마스터 제목 스타일 편집") should never be rendered.
if !(self.skip_placeholders && self.shape.has_placeholder) {
self.elements.extend(finalize_shape(
&mut self.shape,
&mut self.paragraphs,
self.text_box_padding,
self.text_box_vertical_align,
self.text_box_no_wrap,
));
}
self.in_shape = false;
}
}
Expand Down Expand Up @@ -1458,6 +1493,29 @@ pub(super) fn parse_slide_xml(
warning_context: &str,
inherited_text_body_defaults: &PptxTextBodyStyleDefaults,
table_styles: &table_styles::TableStyleMap,
) -> Result<(Vec<FixedElement>, Vec<ConvertWarning>), ConvertError> {
parse_slide_xml_inner(
xml,
images,
theme,
color_map,
warning_context,
inherited_text_body_defaults,
table_styles,
false,
)
}

#[allow(clippy::too_many_arguments)]
fn parse_slide_xml_inner(
xml: &str,
images: &SlideImageMap,
theme: &ThemeData,
color_map: &ColorMapData,
warning_context: &str,
inherited_text_body_defaults: &PptxTextBodyStyleDefaults,
table_styles: &table_styles::TableStyleMap,
skip_placeholders: bool,
) -> Result<(Vec<FixedElement>, Vec<ConvertWarning>), ConvertError> {
let mut reader = Reader::from_str(xml);
let mut parser = SlideXmlParser::new(
Expand All @@ -1469,6 +1527,7 @@ pub(super) fn parse_slide_xml(
inherited_text_body_defaults,
table_styles,
);
parser.skip_placeholders = skip_placeholders;

loop {
match reader.read_event() {
Expand Down
4 changes: 4 additions & 0 deletions crates/office2pdf/src/parser/pptx_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@ pub(super) fn extract_pptx_text_box_body_props(
e: &quick_xml::events::BytesStart,
padding: &mut Insets,
vertical_align: &mut TextBoxVerticalAlign,
no_wrap: &mut bool,
) {
if let Some(value) = get_attr_i64(e, b"lIns") {
padding.left = emu_to_pt(value);
Expand All @@ -529,6 +530,9 @@ pub(super) fn extract_pptx_text_box_body_props(
_ => TextBoxVerticalAlign::Top,
};
}
if get_attr_str(e, b"wrap").as_deref() == Some("none") {
*no_wrap = true;
}
}

pub(super) fn extract_pptx_table_cell_props(
Expand Down
61 changes: 46 additions & 15 deletions crates/office2pdf/src/render/typst_gen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,9 @@ fn generate_fixed_text_box(
format_f64(outer_height_pt),
format_insets(&text_box.padding),
);
if text_box.no_wrap {
out.push_str(", clip: false");
}
// For non-rectangular shapes, render fill/stroke as a placed background shape.
if has_custom_shape {
// Transparent outer block — shape background is placed inside.
Expand All @@ -612,19 +615,42 @@ fn generate_fixed_text_box(
&text_box.stroke,
);
}
let _ = writeln!(
out,
" #let text_box_content_{text_box_id} = block(width: {}pt)[",
format_f64(inner_width_pt),
);
for (index, block) in text_box.content.iter().enumerate() {
if index > 0 {
out.push('\n');
if text_box.no_wrap {
// For wrap="none" text boxes: measure the natural content width first,
// then use the larger of (measured width, original width) so that text
// with slightly wider substitute fonts does not wrap.
let _ = writeln!(out, " #let text_box_content_{text_box_id} = context {{");
let _ = writeln!(out, " let _nowrap_draft = [");
for (index, block) in text_box.content.iter().enumerate() {
if index > 0 {
out.push('\n');
}
out.push_str(" ");
generate_fixed_text_box_block(out, block, ctx, Some(inner_width_pt), false)?;
}
let _ = writeln!(out, " ]");
let _ = writeln!(
out,
" let _nowrap_w = calc.max(measure(_nowrap_draft).width, {}pt)",
format_f64(inner_width_pt),
);
let _ = writeln!(out, " block(width: _nowrap_w, _nowrap_draft)");
let _ = writeln!(out, " }}");
} else {
let _ = writeln!(
out,
" #let text_box_content_{text_box_id} = block(width: {}pt)[",
format_f64(inner_width_pt),
);
for (index, block) in text_box.content.iter().enumerate() {
if index > 0 {
out.push('\n');
}
out.push_str(" ");
generate_fixed_text_box_block(out, block, ctx, Some(inner_width_pt), false)?;
}
out.push_str(" ");
generate_fixed_text_box_block(out, block, ctx, Some(inner_width_pt))?;
out.push_str(" ]\n");
}
out.push_str(" ]\n");

match text_box.vertical_align {
TextBoxVerticalAlign::Top => {
Expand Down Expand Up @@ -1045,7 +1071,7 @@ fn generate_floating_text_box_content(
if index > 0 {
out.push('\n');
}
generate_fixed_text_box_block(out, block, ctx, Some(ftb.width))?;
generate_fixed_text_box_block(out, block, ctx, Some(ftb.width), false)?;
}
out.push_str("]\n");
Ok(())
Expand All @@ -1056,17 +1082,22 @@ fn generate_fixed_text_box_block(
block: &Block,
ctx: &mut GenCtx,
available_width_pt: Option<f64>,
no_wrap: bool,
) -> Result<(), ConvertError> {
match block {
Block::List(list) if can_render_fixed_text_list_inline(list) => {
generate_fixed_text_list(out, list, true, available_width_pt)
}
Block::Paragraph(para) => generate_fixed_text_paragraph(out, para),
Block::Paragraph(para) => generate_fixed_text_paragraph(out, para, no_wrap),
_ => generate_block(out, block, ctx),
}
}

fn generate_fixed_text_paragraph(out: &mut String, para: &Paragraph) -> Result<(), ConvertError> {
fn generate_fixed_text_paragraph(
out: &mut String,
para: &Paragraph,
_no_wrap: bool,
) -> Result<(), ConvertError> {
let style: &ParagraphStyle = &para.style;
let needs_text_scope: bool = common_text_style(&para.runs).is_some();
let has_para_style: bool = needs_block_wrapper(style) || needs_text_scope;
Expand Down Expand Up @@ -1095,7 +1126,7 @@ fn generate_fixed_text_paragraph(out: &mut String, para: &Paragraph) -> Result<(
Some(Alignment::Right) => "right",
_ => "left",
};
let _ = write!(out, "#block(width: 100%)[#set align({align_str})\n");
let _ = writeln!(out, "#block(width: 100%)[#set align({align_str})");
}

generate_runs_with_tabs(out, &para.runs, style.tab_stops.as_deref());
Expand Down
Loading
Loading