1use hwpforge_foundation::{ParaShapeIndex, StyleIndex};
32use schemars::JsonSchema;
33use serde::{Deserialize, Serialize};
34
35use crate::error::{CoreError, CoreResult};
36use crate::run::Run;
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
55pub struct Paragraph {
56 pub runs: Vec<Run>,
58 pub para_shape_id: ParaShapeIndex,
60 #[serde(default)]
62 pub column_break: bool,
63 #[serde(default)]
65 pub page_break: bool,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub heading_level: Option<u8>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub style_id: Option<StyleIndex>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub layout_cache: Option<crate::layout::LayoutCache>,
84}
85
86impl Paragraph {
87 pub fn new(para_shape_id: ParaShapeIndex) -> Self {
99 Self {
100 runs: Vec::new(),
101 para_shape_id,
102 column_break: false,
103 page_break: false,
104 heading_level: None,
105 style_id: None,
106 layout_cache: None,
107 }
108 }
109
110 pub fn with_runs(runs: Vec<Run>, para_shape_id: ParaShapeIndex) -> Self {
126 Self {
127 runs,
128 para_shape_id,
129 column_break: false,
130 page_break: false,
131 heading_level: None,
132 style_id: None,
133 layout_cache: None,
134 }
135 }
136
137 pub fn for_each_paragraph_mut<F: FnMut(&mut Paragraph)>(&mut self, mut f: F) {
149 self.walk_paragraphs_mut(&mut f);
150 }
151
152 pub(crate) fn walk_paragraphs_mut(&mut self, f: &mut dyn FnMut(&mut Paragraph)) {
154 f(self);
155 for run in &mut self.runs {
156 run.walk_paragraphs_mut(f);
157 }
158 }
159
160 pub fn for_each_paragraph<F: FnMut(&Paragraph)>(&self, mut f: F) {
163 self.walk_paragraphs(&mut f);
164 }
165
166 pub(crate) fn walk_paragraphs(&self, f: &mut dyn FnMut(&Paragraph)) {
171 f(self);
172 for run in &self.runs {
173 run.walk_paragraphs(f);
174 }
175 }
176
177 pub fn add_run(&mut self, run: Run) {
191 self.runs.push(run);
192 }
193
194 pub fn with_heading_level(mut self, level: u8) -> Self {
214 assert!((1..=7).contains(&level), "heading_level must be 1-7, got {level}");
215 self.heading_level = Some(level);
216 self
217 }
218
219 pub fn with_style(mut self, style_id: StyleIndex) -> Self {
232 self.style_id = Some(style_id);
233 self
234 }
235
236 pub fn with_page_break(mut self) -> Self {
248 self.page_break = true;
249 self
250 }
251
252 pub fn try_with_heading_level(mut self, level: u8) -> CoreResult<Self> {
278 if !(1..=7).contains(&level) {
279 return Err(CoreError::InvalidStructure {
280 context: "Paragraph::try_with_heading_level".into(),
281 reason: format!("heading_level must be 1-7, got {level}"),
282 });
283 }
284 self.heading_level = Some(level);
285 Ok(self)
286 }
287
288 pub fn text_content(&self) -> String {
312 self.runs.iter().filter_map(|r| r.content.plain_text()).fold(
316 String::new(),
317 |mut acc, cow| {
318 acc.push_str(&cow);
319 acc
320 },
321 )
322 }
323
324 pub fn run_count(&self) -> usize {
326 self.runs.len()
327 }
328
329 pub fn is_empty(&self) -> bool {
331 self.runs.is_empty()
332 }
333}
334
335impl std::fmt::Display for Paragraph {
336 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337 write!(f, "Paragraph({} runs)", self.runs.len())
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use crate::control::Control;
345 use crate::table::Table;
346 use hwpforge_foundation::CharShapeIndex;
347
348 fn text_run(s: &str) -> Run {
349 Run::text(s, CharShapeIndex::new(0))
350 }
351
352 #[test]
353 fn new_is_empty() {
354 let para = Paragraph::new(ParaShapeIndex::new(0));
355 assert!(para.is_empty());
356 assert_eq!(para.run_count(), 0);
357 assert_eq!(para.text_content(), "");
358 }
359
360 #[test]
361 fn with_runs() {
362 let para = Paragraph::with_runs(vec![text_run("a"), text_run("b")], ParaShapeIndex::new(0));
363 assert_eq!(para.run_count(), 2);
364 assert!(!para.is_empty());
365 }
366
367 #[test]
368 fn add_run() {
369 let mut para = Paragraph::new(ParaShapeIndex::new(0));
370 para.add_run(text_run("first"));
371 para.add_run(text_run("second"));
372 assert_eq!(para.run_count(), 2);
373 }
374
375 #[test]
376 fn text_content_concatenation() {
377 let para = Paragraph::with_runs(
378 vec![text_run("Hello "), text_run("world!")],
379 ParaShapeIndex::new(0),
380 );
381 assert_eq!(para.text_content(), "Hello world!");
382 }
383
384 #[test]
385 fn text_content_skips_non_text() {
386 let para = Paragraph::with_runs(
387 vec![
388 text_run("before"),
389 Run::table(Table::new(vec![]), CharShapeIndex::new(0)),
390 text_run("after"),
391 ],
392 ParaShapeIndex::new(0),
393 );
394 assert_eq!(para.text_content(), "beforeafter");
395 }
396
397 #[test]
398 fn text_content_empty_paragraph() {
399 let para = Paragraph::new(ParaShapeIndex::new(0));
400 assert_eq!(para.text_content(), "");
401 }
402
403 #[test]
404 fn text_content_no_text_runs() {
405 let para = Paragraph::with_runs(
406 vec![Run::table(Table::new(vec![]), CharShapeIndex::new(0))],
407 ParaShapeIndex::new(0),
408 );
409 assert_eq!(para.text_content(), "");
410 }
411
412 #[test]
413 fn korean_text_content() {
414 let para = Paragraph::with_runs(
415 vec![text_run("안녕"), text_run("하세요")],
416 ParaShapeIndex::new(0),
417 );
418 assert_eq!(para.text_content(), "안녕하세요");
419 }
420
421 #[test]
422 fn display() {
423 let para = Paragraph::with_runs(
424 vec![text_run("a"), text_run("b"), text_run("c")],
425 ParaShapeIndex::new(0),
426 );
427 assert_eq!(para.to_string(), "Paragraph(3 runs)");
428 }
429
430 #[test]
431 fn equality() {
432 let a = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(0));
433 let b = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(0));
434 let c = Paragraph::with_runs(vec![text_run("y")], ParaShapeIndex::new(0));
435 let d = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(1));
436 assert_eq!(a, b);
437 assert_ne!(a, c);
438 assert_ne!(a, d);
439 }
440
441 #[test]
442 fn clone_independence() {
443 let para = Paragraph::with_runs(vec![text_run("original")], ParaShapeIndex::new(0));
444 let mut cloned = para.clone();
445 cloned.add_run(text_run("added"));
446 assert_eq!(para.run_count(), 1);
447 assert_eq!(cloned.run_count(), 2);
448 }
449
450 #[test]
451 fn many_runs() {
452 let runs: Vec<Run> = (0..100).map(|i| text_run(&format!("run{i}"))).collect();
453 let para = Paragraph::with_runs(runs, ParaShapeIndex::new(0));
454 assert_eq!(para.run_count(), 100);
455 assert!(para.text_content().starts_with("run0"));
456 }
457
458 #[test]
459 fn serde_roundtrip() {
460 let para = Paragraph::with_runs(
461 vec![text_run("hello"), text_run("world")],
462 ParaShapeIndex::new(5),
463 );
464 let json = serde_json::to_string(¶).unwrap();
465 let back: Paragraph = serde_json::from_str(&json).unwrap();
466 assert_eq!(para, back);
467 }
468
469 #[test]
470 fn serde_roundtrip_with_control() {
471 let ctrl =
472 Control::Hyperlink { text: "link".to_string(), url: "https://example.com".to_string() };
473 let para = Paragraph::with_runs(
474 vec![text_run("see "), Run::control(ctrl, CharShapeIndex::new(1))],
475 ParaShapeIndex::new(0),
476 );
477 let json = serde_json::to_string(¶).unwrap();
478 let back: Paragraph = serde_json::from_str(&json).unwrap();
479 assert_eq!(para, back);
480 }
481
482 #[test]
483 fn serde_empty_paragraph() {
484 let para = Paragraph::new(ParaShapeIndex::new(0));
485 let json = serde_json::to_string(¶).unwrap();
486 let back: Paragraph = serde_json::from_str(&json).unwrap();
487 assert_eq!(para, back);
488 }
489
490 #[test]
491 fn with_heading_level_sets_field() {
492 let para = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(1);
493 assert_eq!(para.heading_level, Some(1));
494
495 let para7 = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(7);
496 assert_eq!(para7.heading_level, Some(7));
497 }
498
499 #[test]
500 fn with_heading_level_all_valid_levels() {
501 for level in 1u8..=7 {
502 let para = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(level);
503 assert_eq!(para.heading_level, Some(level));
504 }
505 }
506
507 #[test]
508 #[should_panic(expected = "heading_level must be 1-7")]
509 fn with_heading_level_zero_panics() {
510 let _ = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(0);
511 }
512
513 #[test]
514 #[should_panic(expected = "heading_level must be 1-7")]
515 fn with_heading_level_eight_panics() {
516 let _ = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(8);
517 }
518
519 #[test]
520 fn new_has_no_heading_level() {
521 let para = Paragraph::new(ParaShapeIndex::new(0));
522 assert_eq!(para.heading_level, None);
523 }
524
525 #[test]
526 fn serde_roundtrip_with_heading_level() {
527 let para = Paragraph::with_runs(vec![text_run("heading text")], ParaShapeIndex::new(0))
528 .with_heading_level(2);
529 let json = serde_json::to_string(¶).unwrap();
530 let back: Paragraph = serde_json::from_str(&json).unwrap();
531 assert_eq!(para, back);
532 assert_eq!(back.heading_level, Some(2));
533 }
534
535 #[test]
536 fn serde_heading_level_omitted_when_none() {
537 let para = Paragraph::new(ParaShapeIndex::new(0));
538 let json = serde_json::to_string(¶).unwrap();
539 assert!(!json.contains("heading_level"), "None should be skipped in serialization");
540 }
541
542 #[test]
543 fn try_with_heading_level_valid() {
544 for level in 1u8..=7 {
545 let para =
546 Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(level).unwrap();
547 assert_eq!(para.heading_level, Some(level));
548 }
549 }
550
551 #[test]
552 fn try_with_heading_level_zero_errors() {
553 let result = Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(0);
554 assert!(result.is_err());
555 }
556
557 #[test]
558 fn try_with_heading_level_eight_errors() {
559 let result = Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(8);
560 assert!(result.is_err());
561 }
562
563 #[test]
564 fn try_with_heading_level_255_errors() {
565 let result = Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(255);
566 assert!(result.is_err());
567 }
568
569 #[test]
570 fn serde_roundtrip_all_7_heading_levels() {
571 for level in 1u8..=7 {
572 let para = Paragraph::with_runs(vec![text_run("heading")], ParaShapeIndex::new(0))
573 .with_heading_level(level);
574 let json = serde_json::to_string(¶).unwrap();
575 let back: Paragraph = serde_json::from_str(&json).unwrap();
576 assert_eq!(back.heading_level, Some(level), "level {level} roundtrip failed");
577 }
578 }
579
580 #[test]
581 fn new_has_no_style_id() {
582 let para = Paragraph::new(ParaShapeIndex::new(0));
583 assert_eq!(para.style_id, None);
584 }
585
586 #[test]
587 fn with_style_builder_works() {
588 let para = Paragraph::new(ParaShapeIndex::new(0)).with_style(StyleIndex::new(2));
589 assert_eq!(para.style_id, Some(StyleIndex::new(2)));
590 }
591
592 #[test]
593 fn with_runs_has_no_style_id() {
594 let para = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(0));
595 assert_eq!(para.style_id, None);
596 }
597
598 #[test]
599 fn serde_roundtrip_with_style_id() {
600 let para = Paragraph::new(ParaShapeIndex::new(0)).with_style(StyleIndex::new(5));
601 let json = serde_json::to_string(¶).unwrap();
602 let back: Paragraph = serde_json::from_str(&json).unwrap();
603 assert_eq!(back.style_id, Some(StyleIndex::new(5)));
604 }
605
606 #[test]
607 fn serde_missing_style_id_deserializes_to_none() {
608 let json = r#"{"runs":[],"para_shape_id":0,"column_break":false}"#;
610 let para: Paragraph = serde_json::from_str(json).unwrap();
611 assert_eq!(para.style_id, None);
612 }
613
614 #[test]
615 fn serde_style_id_omitted_when_none() {
616 let para = Paragraph::new(ParaShapeIndex::new(0));
617 let json = serde_json::to_string(¶).unwrap();
618 assert!(!json.contains("style_id"), "None should be skipped in serialization");
619 }
620}