Skip to content

Commit 8826ebc

Browse files
committed
fix: improve fixed textbox slide fidelity
Signed-off-by: Yonghye Kwon <developer.0hye@gmail.com>
1 parent a132a92 commit 8826ebc

11 files changed

Lines changed: 840 additions & 28 deletions

crates/office2pdf/src/ir/elements.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ pub struct TextBoxData {
152152
/// When true, text should not wrap — the content width is unconstrained.
153153
/// Corresponds to `<a:bodyPr wrap="none"/>` in OOXML.
154154
pub no_wrap: bool,
155+
/// Whether the source requested PowerPoint autofit behavior for this box.
156+
pub auto_fit: bool,
155157
}
156158

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

crates/office2pdf/src/lib_render_tests.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ fn test_render_document_fixed_textbox_ordered_list_keeps_all_numbers() {
357357
stroke: None,
358358
shape_kind: None,
359359
no_wrap: false,
360+
auto_fit: false,
360361
}),
361362
}],
362363
background_color: None,
@@ -710,6 +711,7 @@ fn test_render_pptx_style_document_size() {
710711
stroke: None,
711712
shape_kind: None,
712713
no_wrap: false,
714+
auto_fit: false,
713715
}),
714716
}],
715717
}));
@@ -769,6 +771,7 @@ fn test_render_document_with_centered_fixed_text_box() {
769771
stroke: None,
770772
shape_kind: None,
771773
no_wrap: false,
774+
auto_fit: false,
772775
}),
773776
}],
774777
})],
@@ -781,3 +784,69 @@ fn test_render_document_with_centered_fixed_text_box() {
781784
"Centered fixed text box should compile to a valid PDF"
782785
);
783786
}
787+
788+
#[test]
789+
fn test_render_document_with_auto_fit_fixed_text_box() {
790+
let doc = Document {
791+
metadata: Metadata::default(),
792+
pages: vec![Page::Fixed(FixedPage {
793+
size: PageSize {
794+
width: 300.0,
795+
height: 200.0,
796+
},
797+
background_color: None,
798+
background_gradient: None,
799+
elements: vec![FixedElement {
800+
x: 20.0,
801+
y: 20.0,
802+
width: 150.0,
803+
height: 22.0,
804+
kind: FixedElementKind::TextBox(TextBoxData {
805+
content: vec![Block::Paragraph(Paragraph {
806+
style: ParagraphStyle {
807+
alignment: Some(Alignment::Right),
808+
..ParagraphStyle::default()
809+
},
810+
runs: vec![
811+
Run {
812+
text: "3. 시스템 연동 방안 ".to_string(),
813+
style: TextStyle {
814+
font_size: Some(28.0),
815+
bold: Some(true),
816+
..TextStyle::default()
817+
},
818+
href: None,
819+
footnote: None,
820+
},
821+
Run {
822+
text: "클라우드 기반 업무 시스템 연동".to_string(),
823+
style: TextStyle {
824+
font_size: Some(16.0),
825+
bold: Some(true),
826+
..TextStyle::default()
827+
},
828+
href: None,
829+
footnote: None,
830+
},
831+
],
832+
})],
833+
padding: Insets::default(),
834+
vertical_align: TextBoxVerticalAlign::Top,
835+
fill: None,
836+
opacity: None,
837+
stroke: None,
838+
shape_kind: None,
839+
no_wrap: false,
840+
auto_fit: true,
841+
}),
842+
}],
843+
})],
844+
styles: StyleSheet::default(),
845+
};
846+
847+
let pdf = render_document(&doc).unwrap();
848+
assert!(
849+
pdf.starts_with(b"%PDF"),
850+
"Auto-fit fixed text box should compile to a valid PDF"
851+
);
852+
}

crates/office2pdf/src/parser/pptx_shape_style_tests.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,86 @@ fn test_gradient_background_with_scheme_colors() {
203203
assert!((gradient.angle - 45.0).abs() < 0.001);
204204
}
205205

206+
#[test]
207+
fn test_gradient_filled_shape_keeps_following_siblings() {
208+
let before = make_text_box(0, 0, 2_000_000, 600_000, "Before");
209+
let gradient_shape = concat!(
210+
r#"<p:sp><p:nvSpPr><p:cNvPr id="30" name="GradientShape"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>"#,
211+
r#"<p:spPr><a:xfrm><a:off x="0" y="800000"/><a:ext cx="3000000" cy="1200000"/></a:xfrm>"#,
212+
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#,
213+
r#"<a:gradFill flip="none" rotWithShape="1"><a:gsLst>"#,
214+
r#"<a:gs pos="0"><a:srgbClr val="367482"/></a:gs>"#,
215+
r#"<a:gs pos="100000"><a:srgbClr val="306572"/></a:gs>"#,
216+
r#"</a:gsLst><a:lin ang="5400000" scaled="1"/><a:tileRect/></a:gradFill>"#,
217+
r#"</p:spPr></p:sp>"#
218+
)
219+
.to_string();
220+
let after = make_text_box(0, 2_400_000, 2_500_000, 600_000, "After");
221+
let slide = make_slide_xml(&[before, gradient_shape, after]);
222+
let data = build_test_pptx(SLIDE_CX, SLIDE_CY, &[slide]);
223+
224+
let parser = PptxParser;
225+
let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap();
226+
227+
let page = first_fixed_page(&doc);
228+
assert_eq!(
229+
page.elements.len(),
230+
3,
231+
"Gradient-filled shapes must not consume later siblings: {:#?}",
232+
page.elements
233+
);
234+
235+
let last_text = match &page.elements[2].kind {
236+
FixedElementKind::TextBox(text_box) => match &text_box.content[0] {
237+
Block::Paragraph(paragraph) => paragraph.runs[0].text.clone(),
238+
other => panic!("Expected paragraph block, got {other:?}"),
239+
},
240+
other => panic!("Expected final sibling text box, got {other:?}"),
241+
};
242+
assert_eq!(last_text, "After");
243+
}
244+
245+
#[test]
246+
fn test_gradient_text_shape_with_style_keeps_following_siblings() {
247+
let before = make_text_box(0, 0, 2_000_000, 600_000, "Before");
248+
let gradient_text_shape = concat!(
249+
r#"<p:sp><p:nvSpPr><p:cNvPr id="31" name="StyledGradientShape"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>"#,
250+
r#"<p:spPr><a:xfrm flipV="1"><a:off x="0" y="800000"/><a:ext cx="3000000" cy="200000"/></a:xfrm>"#,
251+
r#"<a:prstGeom prst="trapezoid"><a:avLst/></a:prstGeom>"#,
252+
r#"<a:gradFill flip="none" rotWithShape="1"><a:gsLst>"#,
253+
r#"<a:gs pos="0"><a:srgbClr val="FFFFFF"><a:alpha val="70000"/></a:srgbClr></a:gs>"#,
254+
r#"<a:gs pos="76000"><a:srgbClr val="FFFFFF"><a:alpha val="29000"/></a:srgbClr></a:gs>"#,
255+
r#"<a:gs pos="92000"><a:srgbClr val="FFFFFF"><a:alpha val="0"/></a:srgbClr></a:gs>"#,
256+
r#"</a:gsLst><a:lin ang="16200000" scaled="1"/><a:tileRect/></a:gradFill><a:ln><a:noFill/></a:ln></p:spPr>"#,
257+
r#"<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>"#,
258+
r#"<p:txBody><a:bodyPr rtlCol="0" anchor="ctr"/><a:lstStyle/><a:p><a:pPr algn="ctr"/><a:endParaRPr lang="en-US"/></a:p></p:txBody></p:sp>"#
259+
)
260+
.to_string();
261+
let after = make_text_box(0, 1_400_000, 2_500_000, 600_000, "After");
262+
let slide = make_slide_xml(&[before, gradient_text_shape, after]);
263+
let data = build_test_pptx(SLIDE_CX, SLIDE_CY, &[slide]);
264+
265+
let parser = PptxParser;
266+
let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap();
267+
268+
let page = first_fixed_page(&doc);
269+
assert_eq!(
270+
page.elements.len(),
271+
3,
272+
"Styled gradient text shapes must not consume later siblings: {:#?}",
273+
page.elements
274+
);
275+
276+
let last_text = match &page.elements[2].kind {
277+
FixedElementKind::TextBox(text_box) => match &text_box.content[0] {
278+
Block::Paragraph(paragraph) => paragraph.runs[0].text.clone(),
279+
other => panic!("Expected paragraph block, got {other:?}"),
280+
},
281+
other => panic!("Expected final sibling text box, got {other:?}"),
282+
};
283+
assert_eq!(last_text, "After");
284+
}
285+
206286
#[test]
207287
fn test_solid_background_no_gradient() {
208288
let bg_xml =

crates/office2pdf/src/parser/pptx_slides.rs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,7 @@ fn finalize_shape(
482482
text_box_padding: Insets,
483483
text_box_vertical_align: TextBoxVerticalAlign,
484484
text_box_no_wrap: bool,
485+
text_box_auto_fit: bool,
485486
) -> Vec<FixedElement> {
486487
// Resolve effective fill: explicit > noFill > style fallback.
487488
let effective_fill: Option<Color> = if shape.fill.is_some() {
@@ -559,6 +560,7 @@ fn finalize_shape(
559560
stroke: None,
560561
shape_kind: None,
561562
no_wrap: text_box_no_wrap,
563+
auto_fit: text_box_auto_fit,
562564
}),
563565
});
564566
} else {
@@ -577,6 +579,7 @@ fn finalize_shape(
577579
stroke,
578580
shape_kind: None,
579581
no_wrap: text_box_no_wrap,
582+
auto_fit: text_box_auto_fit,
580583
}),
581584
});
582585
}
@@ -724,6 +727,7 @@ struct SlideXmlParser<'a> {
724727
text_box_padding: Insets,
725728
text_box_vertical_align: TextBoxVerticalAlign,
726729
text_box_no_wrap: bool,
730+
text_box_auto_fit: bool,
727731
text_body_style_defaults: PptxTextBodyStyleDefaults,
728732

729733
// ── Paragraph state (`<a:p>`) ───────────────────────────────────
@@ -745,6 +749,7 @@ struct SlideXmlParser<'a> {
745749
in_text: bool,
746750
in_rpr: bool,
747751
in_end_para_rpr: bool,
752+
in_text_line: bool,
748753
solid_fill_ctx: SolidFillCtx,
749754
/// Inside `<a:lnRef>` within `<p:style>` — for resolving fallback line color.
750755
in_style_ln_ref: bool,
@@ -794,6 +799,7 @@ impl<'a> SlideXmlParser<'a> {
794799
text_box_padding: default_pptx_text_box_padding(),
795800
text_box_vertical_align: TextBoxVerticalAlign::Top,
796801
text_box_no_wrap: false,
802+
text_box_auto_fit: false,
797803
text_body_style_defaults: PptxTextBodyStyleDefaults::default(),
798804

799805
in_para: false,
@@ -812,6 +818,7 @@ impl<'a> SlideXmlParser<'a> {
812818
in_text: false,
813819
in_rpr: false,
814820
in_end_para_rpr: false,
821+
in_text_line: false,
815822
solid_fill_ctx: SolidFillCtx::None,
816823
in_style_ln_ref: false,
817824
in_style_fill_ref: false,
@@ -878,6 +885,7 @@ impl<'a> SlideXmlParser<'a> {
878885
self.text_box_padding = default_pptx_text_box_padding();
879886
self.text_box_vertical_align = TextBoxVerticalAlign::Top;
880887
self.text_box_no_wrap = false;
888+
self.text_box_auto_fit = false;
881889
}
882890
b"sp" | b"cxnSp" if self.in_shape => {
883891
self.shape.depth += 1;
@@ -966,6 +974,9 @@ impl<'a> SlideXmlParser<'a> {
966974
&mut self.text_box_no_wrap,
967975
);
968976
}
977+
b"spAutoFit" | b"normAutofit" if self.in_shape && self.in_txbody => {
978+
self.text_box_auto_fit = true;
979+
}
969980
b"lstStyle" if self.in_shape && self.in_txbody => {
970981
let local_defaults = parse_pptx_list_style(reader, self.theme, self.color_map);
971982
self.text_body_style_defaults.merge_from(&local_defaults);
@@ -1068,10 +1079,13 @@ impl<'a> SlideXmlParser<'a> {
10681079
self.para_end_run_style = self.para_default_run_style.clone();
10691080
extract_rpr_attributes(e, &mut self.para_end_run_style);
10701081
}
1071-
b"solidFill" if self.in_rpr => {
1082+
b"ln" if self.in_rpr || self.in_end_para_rpr => {
1083+
self.in_text_line = true;
1084+
}
1085+
b"solidFill" if self.in_rpr && !self.in_text_line => {
10721086
self.solid_fill_ctx = SolidFillCtx::RunFill;
10731087
}
1074-
b"solidFill" if self.in_end_para_rpr => {
1088+
b"solidFill" if self.in_end_para_rpr && !self.in_text_line => {
10751089
self.solid_fill_ctx = SolidFillCtx::EndParaFill;
10761090
}
10771091
b"srgbClr" | b"schemeClr" | b"sysClr" if self.solid_fill_ctx != SolidFillCtx::None => {
@@ -1205,6 +1219,9 @@ impl<'a> SlideXmlParser<'a> {
12051219
&mut self.text_box_no_wrap,
12061220
);
12071221
}
1222+
b"spAutoFit" | b"normAutofit" if self.in_shape && self.in_txbody => {
1223+
self.text_box_auto_fit = true;
1224+
}
12081225
b"prstGeom" if self.shape.in_sp_pr => {
12091226
if let Some(prst) = get_attr_str(e, b"prst") {
12101227
self.shape.prst_geom = Some(prst);
@@ -1273,6 +1290,9 @@ impl<'a> SlideXmlParser<'a> {
12731290
self.para_end_run_style = self.para_default_run_style.clone();
12741291
extract_rpr_attributes(e, &mut self.para_end_run_style);
12751292
}
1293+
b"ln" if self.in_rpr || self.in_end_para_rpr => {
1294+
self.in_text_line = true;
1295+
}
12761296
b"pPr" if self.in_para && !self.in_run => {
12771297
self.para_level = extract_paragraph_level(e);
12781298
self.para_style = self
@@ -1375,6 +1395,7 @@ impl<'a> SlideXmlParser<'a> {
13751395
self.text_box_padding,
13761396
self.text_box_vertical_align,
13771397
self.text_box_no_wrap,
1398+
self.text_box_auto_fit,
13781399
));
13791400
}
13801401
self.in_shape = false;
@@ -1430,6 +1451,9 @@ impl<'a> SlideXmlParser<'a> {
14301451
b"endParaRPr" if self.in_end_para_rpr => {
14311452
self.in_end_para_rpr = false;
14321453
}
1454+
b"ln" if self.in_text_line => {
1455+
self.in_text_line = false;
1456+
}
14331457
b"lnSpc" if self.in_ln_spc => {
14341458
self.in_ln_spc = false;
14351459
}

crates/office2pdf/src/parser/pptx_text_box_semantic_tests.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,11 @@ fn test_text_box_body_pr_defaults_and_center_anchor_extracted() {
190190
text_box.vertical_align,
191191
crate::ir::TextBoxVerticalAlign::Center
192192
);
193+
assert!(
194+
text_box.auto_fit,
195+
"spAutoFit text boxes should preserve the autofit hint in the IR"
196+
);
197+
assert!(!text_box.no_wrap);
193198
}
194199

195200
#[test]
@@ -465,6 +470,47 @@ fn test_text_box_lst_style_default_run_props_are_applied_to_runs() {
465470
assert_eq!(run.style.color, Some(Color::new(0x03, 0x25, 0x43)));
466471
}
467472

473+
#[test]
474+
fn test_text_box_font_ref_default_color_is_not_overridden_by_run_line_fill() {
475+
let slide_shape = concat!(
476+
r#"<p:sp><p:nvSpPr><p:cNvPr id="2" name="OrgBox"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>"#,
477+
r#"<p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2000000" cy="900000"/></a:xfrm>"#,
478+
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:ln><a:noFill/></a:ln></p:spPr>"#,
479+
r#"<p:style>"#,
480+
r#"<a:lnRef idx="2"><a:schemeClr val="accent1"/></a:lnRef>"#,
481+
r#"<a:fillRef idx="1"><a:schemeClr val="lt1"/></a:fillRef>"#,
482+
r#"<a:effectRef idx="0"><a:schemeClr val="accent1"/></a:effectRef>"#,
483+
r#"<a:fontRef idx="minor"><a:schemeClr val="dk1"/></a:fontRef>"#,
484+
r#"</p:style>"#,
485+
r#"<p:txBody><a:bodyPr/><a:lstStyle/>"#,
486+
r#"<a:p><a:pPr algn="ctr"/>"#,
487+
r#"<a:r><a:rPr lang="ko-KR">"#,
488+
r#"<a:ln><a:solidFill><a:sysClr val="window" lastClr="FFFFFF"><a:alpha val="0"/></a:sysClr></a:solidFill></a:ln>"#,
489+
r#"<a:latin typeface="Pretendard"/><a:ea typeface="Pretendard"/></a:rPr><a:t>이동욱 이사</a:t></a:r>"#,
490+
r#"</a:p></p:txBody></p:sp>"#,
491+
);
492+
let slide_xml = make_slide_xml(&[slide_shape.to_string()]);
493+
let theme_xml = make_theme_xml(&standard_theme_colors(), "Pretendard", "Pretendard");
494+
let data = build_test_pptx_with_theme(SLIDE_CX, SLIDE_CY, &[slide_xml], &theme_xml);
495+
496+
let parser = PptxParser;
497+
let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap();
498+
499+
let page = first_fixed_page(&doc);
500+
let blocks = text_box_blocks(&page.elements[0]);
501+
let paragraph = match &blocks[0] {
502+
Block::Paragraph(paragraph) => paragraph,
503+
other => panic!("Expected Paragraph block, got {other:?}"),
504+
};
505+
assert_eq!(paragraph.runs.len(), 1);
506+
assert_eq!(paragraph.runs[0].text, "이동욱 이사");
507+
assert_eq!(
508+
paragraph.runs[0].style.color,
509+
Some(Color::new(0x00, 0x00, 0x00)),
510+
"Run line fill should not overwrite the fontRef default text color"
511+
);
512+
}
513+
468514
#[test]
469515
fn test_non_placeholder_shape_inherits_master_other_style_run_defaults() {
470516
let slide_shape = concat!(

crates/office2pdf/src/parser/pptx_theme.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,8 @@ pub(super) fn parse_shape_gradient_fill(
599599
color,
600600
});
601601
}
602+
// `parse_color_from_start` consumes the matching end tag too.
603+
depth = depth.saturating_sub(1);
602604
}
603605
_ => {}
604606
}
@@ -681,6 +683,8 @@ pub(super) fn parse_effect_list(
681683
if let Some(alpha) = parsed.alpha {
682684
shdw_opacity = alpha;
683685
}
686+
// `parse_color_from_start` consumes the matching end tag too.
687+
depth = depth.saturating_sub(1);
684688
}
685689
_ => {}
686690
}

0 commit comments

Comments
 (0)