1use serde::{Deserialize, Serialize};
4
5#[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#[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 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 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 pub fn is_heading(&self) -> bool {
58 !matches!(self, HeadingLevel::None)
59 }
60}
61
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "lowercase")]
65pub enum ListType {
66 #[default]
67 None,
68 Bullet,
70 Numbered,
72}
73
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "lowercase")]
77pub enum RevisionType {
78 #[default]
80 None,
81 Inserted,
83 Deleted,
85}
86
87#[derive(Debug, Clone, Default, Serialize, Deserialize)]
89pub struct ListInfo {
90 pub list_type: ListType,
92 pub level: u8,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub number: Option<u32>,
97}
98
99#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
101pub struct TextStyle {
102 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
104 pub bold: bool,
105
106 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
108 pub italic: bool,
109
110 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
112 pub underline: bool,
113
114 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
116 pub strikethrough: bool,
117
118 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
120 pub superscript: bool,
121
122 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
124 pub subscript: bool,
125
126 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
128 pub code: bool,
129
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub font: Option<String>,
133
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub size: Option<u32>,
137
138 #[serde(skip_serializing_if = "Option::is_none")]
140 pub color: Option<String>,
141
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub highlight: Option<String>,
145}
146
147impl TextStyle {
148 pub fn new() -> Self {
150 Self::default()
151 }
152
153 pub fn bold() -> Self {
155 Self {
156 bold: true,
157 ..Default::default()
158 }
159 }
160
161 pub fn italic() -> Self {
163 Self {
164 italic: true,
165 ..Default::default()
166 }
167 }
168
169 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
183pub struct TextRun {
184 pub text: String,
186
187 #[serde(default, skip_serializing_if = "is_default_style")]
189 pub style: TextStyle,
190
191 #[serde(skip_serializing_if = "Option::is_none")]
193 pub hyperlink: Option<String>,
194
195 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
197 pub line_break: bool,
198
199 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
201 pub page_break: bool,
202
203 #[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 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 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 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 pub fn is_link(&self) -> bool {
255 self.hyperlink.is_some()
256 }
257
258 pub fn is_empty(&self) -> bool {
260 self.text.is_empty()
261 }
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct InlineImage {
267 pub resource_id: String,
269
270 #[serde(skip_serializing_if = "Option::is_none")]
272 pub alt_text: Option<String>,
273
274 #[serde(skip_serializing_if = "Option::is_none")]
276 pub width: Option<u32>,
277
278 #[serde(skip_serializing_if = "Option::is_none")]
280 pub height: Option<u32>,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
285#[serde(untagged)]
286pub enum ParagraphElement {
287 Text(TextRun),
288 Image(InlineImage),
289}
290
291#[derive(Debug, Clone, Default, Serialize, Deserialize)]
293pub struct Paragraph {
294 #[serde(default)]
296 pub runs: Vec<TextRun>,
297
298 #[serde(default, skip_serializing_if = "Vec::is_empty")]
300 pub images: Vec<InlineImage>,
301
302 #[serde(default, skip_serializing_if = "HeadingLevel::is_none")]
304 pub heading: HeadingLevel,
305
306 #[serde(default, skip_serializing_if = "is_default_alignment")]
308 pub alignment: TextAlignment,
309
310 #[serde(skip_serializing_if = "Option::is_none")]
312 pub list_info: Option<ListInfo>,
313
314 #[serde(skip_serializing_if = "Option::is_none")]
316 pub style_id: Option<String>,
317
318 #[serde(skip_serializing_if = "Option::is_none")]
320 pub style_name: Option<String>,
321
322 #[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 pub fn new() -> Self {
344 Self::default()
345 }
346
347 pub fn with_text(text: impl Into<String>) -> Self {
349 Self {
350 runs: vec![TextRun::plain(text)],
351 ..Default::default()
352 }
353 }
354
355 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 pub fn add_run(&mut self, run: TextRun) {
366 self.runs.push(run);
367 }
368
369 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 pub fn is_empty(&self) -> bool {
388 self.runs.is_empty() || self.runs.iter().all(|r| r.is_empty())
389 }
390
391 pub fn is_heading(&self) -> bool {
393 self.heading.is_heading()
394 }
395
396 pub fn is_list_item(&self) -> bool {
398 self.list_info.is_some()
399 }
400
401 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 let should_merge = merged.last().is_some_and(|last: &TextRun| {
420 last.style == run.style
423 && last.hyperlink == run.hyperlink
424 && !last.line_break
425 && !last.page_break
426 });
427
428 if should_merge {
429 if let Some(last) = merged.last_mut() {
431 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 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 merged.push(run);
448 }
449 }
450
451 self.runs = merged;
452 }
453
454 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 if last_char.is_whitespace() || first_char.is_whitespace() {
467 return false;
468 }
469
470 if matches!(
472 first_char,
473 '.' | ',' | ':' | ';' | '!' | '?' | ')' | ']' | '}' | '"' | '\'' | '…' | '~'
474 ) {
475 return false;
476 }
477
478 if matches!(last_char, '(' | '[' | '{' | '"' | '\'') {
480 return false;
481 }
482
483 false
501 }
502
503 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(¶).unwrap();
591 assert!(!json.contains("heading"));
593 assert!(!json.contains("alignment"));
594 }
595
596 #[test]
597 fn test_merge_adjacent_runs_ascii_no_split() {
598 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"); }
607
608 #[test]
609 fn test_merge_adjacent_runs_ping() {
610 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"); }
619
620 #[test]
621 fn test_merge_adjacent_runs_tcp() {
622 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"); }
631
632 #[test]
633 fn test_merge_adjacent_runs_cjk_ascii_no_space() {
634 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"); }
644
645 #[test]
646 fn test_merge_adjacent_runs_ascii_cjk_no_space() {
647 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대한통운"); }
657
658 #[test]
659 fn test_merge_adjacent_runs_korean_no_space() {
660 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, "네트워크카드"); }
671
672 #[test]
673 fn test_merge_adjacent_runs_korean_syllables() {
674 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, "정의"); }
684
685 #[test]
686 fn test_merge_adjacent_runs_korean_with_explicit_space() {
687 let mut para = Paragraph::new();
689 para.runs.push(TextRun::plain("서버 ")); 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, "서버 리부팅"); }
696
697 #[test]
698 fn test_merge_adjacent_runs_chinese_no_space() {
699 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, "中文测试"); }
708
709 #[test]
710 fn test_merge_adjacent_runs_japanese_no_space() {
711 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, "日本語テスト"); }
720
721 #[test]
722 fn test_merge_adjacent_runs_different_styles_not_merged() {
723 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); 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 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"); }
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}