Skip to main content

undoc/model/
paragraph.rs

1//! Paragraph and text run models.
2
3use serde::{Deserialize, Serialize};
4
5/// Text alignment within a paragraph.
6#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "lowercase")]
8pub enum TextAlignment {
9    #[default]
10    Left,
11    Center,
12    Right,
13    Justify,
14}
15
16/// Heading level (h1-h6 or none).
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
18pub enum HeadingLevel {
19    #[default]
20    None,
21    H1,
22    H2,
23    H3,
24    H4,
25    H5,
26    H6,
27}
28
29impl HeadingLevel {
30    /// Create a heading level from a number (1-6).
31    pub fn from_number(n: u8) -> Self {
32        match n {
33            1 => HeadingLevel::H1,
34            2 => HeadingLevel::H2,
35            3 => HeadingLevel::H3,
36            4 => HeadingLevel::H4,
37            5 => HeadingLevel::H5,
38            6 => HeadingLevel::H6,
39            _ => HeadingLevel::None,
40        }
41    }
42
43    /// Get the numeric level (0 for none, 1-6 for headings).
44    pub fn level(&self) -> u8 {
45        match self {
46            HeadingLevel::None => 0,
47            HeadingLevel::H1 => 1,
48            HeadingLevel::H2 => 2,
49            HeadingLevel::H3 => 3,
50            HeadingLevel::H4 => 4,
51            HeadingLevel::H5 => 5,
52            HeadingLevel::H6 => 6,
53        }
54    }
55
56    /// Check if this is a heading (not None).
57    pub fn is_heading(&self) -> bool {
58        !matches!(self, HeadingLevel::None)
59    }
60}
61
62/// List type for paragraphs.
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "lowercase")]
65pub enum ListType {
66    #[default]
67    None,
68    /// Unordered (bulleted) list
69    Bullet,
70    /// Ordered (numbered) list
71    Numbered,
72}
73
74/// Revision type for tracked changes support.
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "lowercase")]
77pub enum RevisionType {
78    /// Normal text (not a tracked change)
79    #[default]
80    None,
81    /// Inserted text (addition)
82    Inserted,
83    /// Deleted text (deletion)
84    Deleted,
85}
86
87/// List information for a paragraph.
88#[derive(Debug, Clone, Default, Serialize, Deserialize)]
89pub struct ListInfo {
90    /// Type of list
91    pub list_type: ListType,
92    /// Nesting level (0 = top level)
93    pub level: u8,
94    /// Item number (for numbered lists)
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub number: Option<u32>,
97}
98
99/// Text style properties.
100#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
101pub struct TextStyle {
102    /// Bold text
103    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
104    pub bold: bool,
105
106    /// Italic text
107    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
108    pub italic: bool,
109
110    /// Underlined text
111    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
112    pub underline: bool,
113
114    /// Strikethrough text
115    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
116    pub strikethrough: bool,
117
118    /// Superscript
119    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
120    pub superscript: bool,
121
122    /// Subscript
123    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
124    pub subscript: bool,
125
126    /// Code/monospace font
127    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
128    pub code: bool,
129
130    /// Font name
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub font: Option<String>,
133
134    /// Font size in half-points (e.g., 24 = 12pt)
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub size: Option<u32>,
137
138    /// Text color (hex, e.g., "FF0000")
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub color: Option<String>,
141
142    /// Background/highlight color
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub highlight: Option<String>,
145}
146
147impl TextStyle {
148    /// Create a new default style.
149    pub fn new() -> Self {
150        Self::default()
151    }
152
153    /// Create a bold style.
154    pub fn bold() -> Self {
155        Self {
156            bold: true,
157            ..Default::default()
158        }
159    }
160
161    /// Create an italic style.
162    pub fn italic() -> Self {
163        Self {
164            italic: true,
165            ..Default::default()
166        }
167    }
168
169    /// Check if style has any formatting.
170    pub fn has_formatting(&self) -> bool {
171        self.bold
172            || self.italic
173            || self.underline
174            || self.strikethrough
175            || self.superscript
176            || self.subscript
177            || self.code
178    }
179}
180
181/// A run of text with consistent styling.
182#[derive(Debug, Clone, Default, Serialize, Deserialize)]
183pub struct TextRun {
184    /// The text content
185    pub text: String,
186
187    /// Text styling
188    #[serde(default, skip_serializing_if = "is_default_style")]
189    pub style: TextStyle,
190
191    /// Hyperlink URL (if this run is a link)
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub hyperlink: Option<String>,
194
195    /// Whether this run ends with a line break (<w:br/>)
196    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
197    pub line_break: bool,
198
199    /// Whether this run ends with a page break (<w:br w:type="page"/>)
200    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
201    pub page_break: bool,
202
203    /// Revision type for tracked changes (inserted/deleted)
204    #[serde(default, skip_serializing_if = "is_default_revision")]
205    pub revision: RevisionType,
206}
207
208fn is_default_style(style: &TextStyle) -> bool {
209    *style == TextStyle::default()
210}
211
212fn is_default_revision(revision: &RevisionType) -> bool {
213    *revision == RevisionType::None
214}
215
216impl TextRun {
217    /// Create a plain text run with no styling.
218    pub fn plain(text: impl Into<String>) -> Self {
219        Self {
220            text: text.into(),
221            style: TextStyle::default(),
222            hyperlink: None,
223            line_break: false,
224            page_break: false,
225            revision: RevisionType::None,
226        }
227    }
228
229    /// Create a styled text run.
230    pub fn styled(text: impl Into<String>, style: TextStyle) -> Self {
231        Self {
232            text: text.into(),
233            style,
234            hyperlink: None,
235            line_break: false,
236            page_break: false,
237            revision: RevisionType::None,
238        }
239    }
240
241    /// Create a hyperlink text run.
242    pub fn link(text: impl Into<String>, url: impl Into<String>) -> Self {
243        Self {
244            text: text.into(),
245            style: TextStyle::default(),
246            hyperlink: Some(url.into()),
247            line_break: false,
248            page_break: false,
249            revision: RevisionType::None,
250        }
251    }
252
253    /// Check if this run is a hyperlink.
254    pub fn is_link(&self) -> bool {
255        self.hyperlink.is_some()
256    }
257
258    /// Check if this run is empty.
259    pub fn is_empty(&self) -> bool {
260        self.text.is_empty()
261    }
262}
263
264/// An inline image within text.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct InlineImage {
267    /// Resource ID for the image
268    pub resource_id: String,
269
270    /// Alt text
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub alt_text: Option<String>,
273
274    /// Width in EMUs
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub width: Option<u32>,
277
278    /// Height in EMUs
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub height: Option<u32>,
281}
282
283/// An element within a paragraph (text run or inline image).
284#[derive(Debug, Clone, Serialize, Deserialize)]
285#[serde(untagged)]
286pub enum ParagraphElement {
287    Text(TextRun),
288    Image(InlineImage),
289}
290
291/// A paragraph of text.
292#[derive(Debug, Clone, Default, Serialize, Deserialize)]
293pub struct Paragraph {
294    /// Text runs in this paragraph
295    #[serde(default)]
296    pub runs: Vec<TextRun>,
297
298    /// Inline images in this paragraph
299    #[serde(default, skip_serializing_if = "Vec::is_empty")]
300    pub images: Vec<InlineImage>,
301
302    /// Heading level
303    #[serde(default, skip_serializing_if = "HeadingLevel::is_none")]
304    pub heading: HeadingLevel,
305
306    /// Text alignment
307    #[serde(default, skip_serializing_if = "is_default_alignment")]
308    pub alignment: TextAlignment,
309
310    /// List information
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub list_info: Option<ListInfo>,
313
314    /// Style ID reference
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub style_id: Option<String>,
317
318    /// Style name (human-readable, from styles.xml)
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub style_name: Option<String>,
321
322    /// Indentation level
323    #[serde(default, skip_serializing_if = "is_zero")]
324    pub indent_level: u8,
325}
326
327fn is_default_alignment(a: &TextAlignment) -> bool {
328    *a == TextAlignment::Left
329}
330
331fn is_zero(n: &u8) -> bool {
332    *n == 0
333}
334
335impl HeadingLevel {
336    fn is_none(&self) -> bool {
337        matches!(self, HeadingLevel::None)
338    }
339}
340
341impl Paragraph {
342    /// Create a new empty paragraph.
343    pub fn new() -> Self {
344        Self::default()
345    }
346
347    /// Create a paragraph with the given text.
348    pub fn with_text(text: impl Into<String>) -> Self {
349        Self {
350            runs: vec![TextRun::plain(text)],
351            ..Default::default()
352        }
353    }
354
355    /// Create a heading paragraph.
356    pub fn heading(level: HeadingLevel, text: impl Into<String>) -> Self {
357        Self {
358            runs: vec![TextRun::plain(text)],
359            heading: level,
360            ..Default::default()
361        }
362    }
363
364    /// Add a text run to this paragraph.
365    pub fn add_run(&mut self, run: TextRun) {
366        self.runs.push(run);
367    }
368
369    /// Get the plain text content.
370    pub fn plain_text(&self) -> String {
371        let mut text = String::new();
372
373        for run in &self.runs {
374            text.push_str(&run.text);
375            if run.line_break {
376                text.push('\n');
377            }
378            if run.page_break {
379                text.push_str("\n---\n");
380            }
381        }
382
383        text
384    }
385
386    /// Check if this paragraph is empty.
387    pub fn is_empty(&self) -> bool {
388        self.runs.is_empty() || self.runs.iter().all(|r| r.is_empty())
389    }
390
391    /// Check if this paragraph is a heading.
392    pub fn is_heading(&self) -> bool {
393        self.heading.is_heading()
394    }
395
396    /// Check if this paragraph is a list item.
397    pub fn is_list_item(&self) -> bool {
398        self.list_info.is_some()
399    }
400
401    /// Merge consecutive runs with the same style.
402    ///
403    /// This is useful for documents where each character or word is in a separate run
404    /// with the same styling (common in Word documents with letter spacing).
405    ///
406    /// Example: `**시** **험**` becomes `**시험**` after merging.
407    ///
408    /// Smart spacing is applied when merging runs - spaces are added between
409    /// CJK text and ASCII alphanumeric characters, and between ASCII words.
410    pub fn merge_adjacent_runs(&mut self) {
411        if self.runs.len() <= 1 {
412            return;
413        }
414
415        let mut merged: Vec<TextRun> = Vec::with_capacity(self.runs.len());
416
417        for run in self.runs.drain(..) {
418            // Check if we can merge with the last run
419            let should_merge = merged.last().is_some_and(|last: &TextRun| {
420                // Same style and same hyperlink (both None or both Some with same URL)
421                // Don't merge if the previous run has a line break (preserve the break)
422                last.style == run.style
423                    && last.hyperlink == run.hyperlink
424                    && !last.line_break
425                    && !last.page_break
426            });
427
428            if should_merge {
429                // Merge text with the last run, with smart spacing
430                if let Some(last) = merged.last_mut() {
431                    // Check if we need to add a space between the runs
432                    let needs_space = Self::needs_space_between(&last.text, &run.text);
433                    if needs_space {
434                        last.text.push(' ');
435                    }
436                    last.text.push_str(&run.text);
437                    // Preserve line_break from the merged run
438                    if run.line_break {
439                        last.line_break = true;
440                    }
441                    if run.page_break {
442                        last.page_break = true;
443                    }
444                }
445            } else {
446                // Start a new run
447                merged.push(run);
448            }
449        }
450
451        self.runs = merged;
452    }
453
454    /// Determine if a space is needed between two text fragments when merging.
455    fn needs_space_between(prev: &str, next: &str) -> bool {
456        let last_char = match prev.chars().last() {
457            Some(c) => c,
458            None => return false,
459        };
460        let first_char = match next.chars().next() {
461            Some(c) => c,
462            None => return false,
463        };
464
465        // No space needed if either side already has whitespace
466        if last_char.is_whitespace() || first_char.is_whitespace() {
467            return false;
468        }
469
470        // No space before punctuation
471        if matches!(
472            first_char,
473            '.' | ',' | ':' | ';' | '!' | '?' | ')' | ']' | '}' | '"' | '\'' | '…' | '~'
474        ) {
475            return false;
476        }
477
478        // No space after opening brackets/quotes
479        if matches!(last_char, '(' | '[' | '{' | '"' | '\'') {
480            return false;
481        }
482
483        // Same-style runs = same word (artificially split by Word)
484        // This applies to ALL scripts: ASCII, CJK, Hangul, mixed scripts, etc.
485        //
486        // Examples of runs that should NOT have spaces added:
487        // - "DRBD" stored as ["DRB", "D"] → "DRBD"
488        // - "정의" stored as ["정", "의"] → "정의"
489        // - "CJ대한통운" stored as ["C", "J", "대한통운"] → "CJ대한통운" (brand name)
490        //
491        // Key insight: Word splits runs for various reasons (formatting, editing history),
492        // but same-style consecutive runs are ALWAYS part of the same word.
493        // If they were different words, they would have explicit whitespace between them.
494        //
495        // Note: Korean DOES use spaces between words, but those spaces exist in the
496        // source document (with xml:space="preserve"). We don't invent spaces.
497        //
498        // Previously this function added spaces at ASCII↔CJK boundaries, but this was
499        // incorrect for Korean brand names like "CJ대한통운" where the intent is no space.
500        false
501    }
502
503    /// Get a version of this paragraph with merged adjacent runs.
504    ///
505    /// This is a non-mutating version of `merge_adjacent_runs`.
506    pub fn with_merged_runs(&self) -> Self {
507        let mut para = self.clone();
508        para.merge_adjacent_runs();
509        para
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    #[test]
518    fn test_heading_level() {
519        assert_eq!(HeadingLevel::from_number(1), HeadingLevel::H1);
520        assert_eq!(HeadingLevel::from_number(6), HeadingLevel::H6);
521        assert_eq!(HeadingLevel::from_number(7), HeadingLevel::None);
522        assert_eq!(HeadingLevel::from_number(0), HeadingLevel::None);
523
524        assert_eq!(HeadingLevel::H3.level(), 3);
525        assert!(HeadingLevel::H1.is_heading());
526        assert!(!HeadingLevel::None.is_heading());
527    }
528
529    #[test]
530    fn test_text_run() {
531        let plain = TextRun::plain("Hello");
532        assert_eq!(plain.text, "Hello");
533        assert!(!plain.is_link());
534
535        let link = TextRun::link("Click here", "https://example.com");
536        assert!(link.is_link());
537        assert_eq!(link.hyperlink, Some("https://example.com".to_string()));
538    }
539
540    #[test]
541    fn test_text_style() {
542        let style = TextStyle::bold();
543        assert!(style.bold);
544        assert!(style.has_formatting());
545
546        let plain = TextStyle::default();
547        assert!(!plain.has_formatting());
548    }
549
550    #[test]
551    fn test_paragraph() {
552        let para = Paragraph::with_text("Hello, World!");
553        assert_eq!(para.plain_text(), "Hello, World!");
554        assert!(!para.is_heading());
555        assert!(!para.is_empty());
556
557        let heading = Paragraph::heading(HeadingLevel::H1, "Title");
558        assert!(heading.is_heading());
559        assert_eq!(heading.heading.level(), 1);
560    }
561
562    #[test]
563    fn test_paragraph_plain_text_preserves_run_breaks() {
564        let para = Paragraph {
565            runs: vec![
566                TextRun {
567                    text: "First line".to_string(),
568                    line_break: true,
569                    ..Default::default()
570                },
571                TextRun {
572                    text: "Second line".to_string(),
573                    page_break: true,
574                    ..Default::default()
575                },
576                TextRun::plain("Third line"),
577            ],
578            ..Default::default()
579        };
580
581        assert_eq!(
582            para.plain_text(),
583            "First line\nSecond line\n---\nThird line"
584        );
585    }
586
587    #[test]
588    fn test_paragraph_serialization() {
589        let para = Paragraph::with_text("Test");
590        let json = serde_json::to_string(&para).unwrap();
591        // Default values should not be serialized
592        assert!(!json.contains("heading"));
593        assert!(!json.contains("alignment"));
594    }
595
596    #[test]
597    fn test_merge_adjacent_runs_ascii_no_split() {
598        // Test fix for word splitting bug: "DRBD" stored as ["DRB", "D"] should merge to "DRBD"
599        let mut para = Paragraph::new();
600        para.runs.push(TextRun::plain("DRB"));
601        para.runs.push(TextRun::plain("D"));
602        para.merge_adjacent_runs();
603
604        assert_eq!(para.runs.len(), 1);
605        assert_eq!(para.runs[0].text, "DRBD"); // NOT "DRB D"
606    }
607
608    #[test]
609    fn test_merge_adjacent_runs_ping() {
610        // Test fix: "PING" stored as ["P", "ING"] should merge to "PING"
611        let mut para = Paragraph::new();
612        para.runs.push(TextRun::plain("P"));
613        para.runs.push(TextRun::plain("ING"));
614        para.merge_adjacent_runs();
615
616        assert_eq!(para.runs.len(), 1);
617        assert_eq!(para.runs[0].text, "PING"); // NOT "P ING"
618    }
619
620    #[test]
621    fn test_merge_adjacent_runs_tcp() {
622        // Test fix: "TCP" stored as ["T", "CP"] should merge to "TCP"
623        let mut para = Paragraph::new();
624        para.runs.push(TextRun::plain("T"));
625        para.runs.push(TextRun::plain("CP"));
626        para.merge_adjacent_runs();
627
628        assert_eq!(para.runs.len(), 1);
629        assert_eq!(para.runs[0].text, "TCP"); // NOT "T CP"
630    }
631
632    #[test]
633    fn test_merge_adjacent_runs_cjk_ascii_no_space() {
634        // Same-style runs merge WITHOUT space - even across script boundaries
635        // Example: "VIP리소스" is a valid Korean compound where VIP is a brand/term
636        let mut para = Paragraph::new();
637        para.runs.push(TextRun::plain("리소스"));
638        para.runs.push(TextRun::plain("DRBD"));
639        para.merge_adjacent_runs();
640
641        assert_eq!(para.runs.len(), 1);
642        assert_eq!(para.runs[0].text, "리소스DRBD"); // No space - same-style runs = same word
643    }
644
645    #[test]
646    fn test_merge_adjacent_runs_ascii_cjk_no_space() {
647        // Same-style runs merge WITHOUT space - even across script boundaries
648        // Example: "CJ대한통운" is a brand name where CJ is part of the Korean name
649        let mut para = Paragraph::new();
650        para.runs.push(TextRun::plain("CJ"));
651        para.runs.push(TextRun::plain("대한통운"));
652        para.merge_adjacent_runs();
653
654        assert_eq!(para.runs.len(), 1);
655        assert_eq!(para.runs[0].text, "CJ대한통운"); // No space - brand name
656    }
657
658    #[test]
659    fn test_merge_adjacent_runs_korean_no_space() {
660        // Korean: Same-style runs merge WITHOUT space (like ASCII, Chinese, Japanese)
661        // Word may split a single word into multiple runs for various reasons.
662        // Example: "정의" stored as ["정", "의"] should become "정의", not "정 의"
663        let mut para = Paragraph::new();
664        para.runs.push(TextRun::plain("네트워크"));
665        para.runs.push(TextRun::plain("카드"));
666        para.merge_adjacent_runs();
667
668        assert_eq!(para.runs.len(), 1);
669        assert_eq!(para.runs[0].text, "네트워크카드"); // No space - same word or compound
670    }
671
672    #[test]
673    fn test_merge_adjacent_runs_korean_syllables() {
674        // When Word splits Korean syllables, they should merge without space
675        // This was the bug: "정의" → "정 의" (wrong) instead of "정의" (correct)
676        let mut para = Paragraph::new();
677        para.runs.push(TextRun::plain("정"));
678        para.runs.push(TextRun::plain("의"));
679        para.merge_adjacent_runs();
680
681        assert_eq!(para.runs.len(), 1);
682        assert_eq!(para.runs[0].text, "정의"); // Syllables merge without space
683    }
684
685    #[test]
686    fn test_merge_adjacent_runs_korean_with_explicit_space() {
687        // When source has explicit space, it's preserved in the run text
688        let mut para = Paragraph::new();
689        para.runs.push(TextRun::plain("서버 ")); // Note: space is in the text
690        para.runs.push(TextRun::plain("리부팅"));
691        para.merge_adjacent_runs();
692
693        assert_eq!(para.runs.len(), 1);
694        assert_eq!(para.runs[0].text, "서버 리부팅"); // Space preserved from source
695    }
696
697    #[test]
698    fn test_merge_adjacent_runs_chinese_no_space() {
699        // Chinese: Same-style runs merge without space
700        let mut para = Paragraph::new();
701        para.runs.push(TextRun::plain("中文"));
702        para.runs.push(TextRun::plain("测试"));
703        para.merge_adjacent_runs();
704
705        assert_eq!(para.runs.len(), 1);
706        assert_eq!(para.runs[0].text, "中文测试"); // No space between Chinese
707    }
708
709    #[test]
710    fn test_merge_adjacent_runs_japanese_no_space() {
711        // Japanese: Same-style runs merge without space
712        let mut para = Paragraph::new();
713        para.runs.push(TextRun::plain("日本語"));
714        para.runs.push(TextRun::plain("テスト"));
715        para.merge_adjacent_runs();
716
717        assert_eq!(para.runs.len(), 1);
718        assert_eq!(para.runs[0].text, "日本語テスト"); // No space between Japanese
719    }
720
721    #[test]
722    fn test_merge_adjacent_runs_different_styles_not_merged() {
723        // Runs with different styles should NOT be merged
724        let mut para = Paragraph::new();
725        para.runs.push(TextRun::plain("normal"));
726        para.runs.push(TextRun::styled("bold", TextStyle::bold()));
727        para.merge_adjacent_runs();
728
729        assert_eq!(para.runs.len(), 2); // Still 2 runs
730        assert_eq!(para.runs[0].text, "normal");
731        assert_eq!(para.runs[1].text, "bold");
732    }
733
734    #[test]
735    fn test_merge_preserves_existing_spaces() {
736        // Existing spaces should be preserved
737        let mut para = Paragraph::new();
738        para.runs.push(TextRun::plain("Hello "));
739        para.runs.push(TextRun::plain("World"));
740        para.merge_adjacent_runs();
741
742        assert_eq!(para.runs.len(), 1);
743        assert_eq!(para.runs[0].text, "Hello World"); // Original space preserved
744    }
745
746    #[test]
747    fn test_merge_adjacent_runs_preserves_page_break() {
748        let mut para = Paragraph {
749            runs: vec![
750                TextRun::plain("Before"),
751                TextRun {
752                    text: "After".to_string(),
753                    page_break: true,
754                    ..Default::default()
755                },
756            ],
757            ..Default::default()
758        };
759        para.merge_adjacent_runs();
760
761        assert!(
762            para.runs.iter().any(|r| r.page_break),
763            "page_break lost after merge: runs = {:?}",
764            para.runs
765        );
766    }
767
768    #[test]
769    fn test_merge_adjacent_runs_blocks_on_last_page_break() {
770        let mut para = Paragraph {
771            runs: vec![
772                TextRun {
773                    text: "Before".to_string(),
774                    page_break: true,
775                    ..Default::default()
776                },
777                TextRun::plain("After"),
778            ],
779            ..Default::default()
780        };
781        para.merge_adjacent_runs();
782
783        assert_eq!(para.runs.len(), 2, "must not merge across a page_break");
784        assert!(para.runs[0].page_break);
785        assert!(!para.runs[1].page_break);
786    }
787}