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) {
144 self.walk_paragraphs_mut(&mut f);
145 }
146
147 pub(crate) fn walk_paragraphs_mut(&mut self, f: &mut dyn FnMut(&mut Paragraph)) {
149 f(self);
150 for run in &mut self.runs {
151 run.walk_paragraphs_mut(f);
152 }
153 }
154
155 pub fn add_run(&mut self, run: Run) {
169 self.runs.push(run);
170 }
171
172 pub fn with_heading_level(mut self, level: u8) -> Self {
192 assert!((1..=7).contains(&level), "heading_level must be 1-7, got {level}");
193 self.heading_level = Some(level);
194 self
195 }
196
197 pub fn with_style(mut self, style_id: StyleIndex) -> Self {
210 self.style_id = Some(style_id);
211 self
212 }
213
214 pub fn with_page_break(mut self) -> Self {
226 self.page_break = true;
227 self
228 }
229
230 pub fn try_with_heading_level(mut self, level: u8) -> CoreResult<Self> {
256 if !(1..=7).contains(&level) {
257 return Err(CoreError::InvalidStructure {
258 context: "Paragraph::try_with_heading_level".into(),
259 reason: format!("heading_level must be 1-7, got {level}"),
260 });
261 }
262 self.heading_level = Some(level);
263 Ok(self)
264 }
265
266 pub fn text_content(&self) -> String {
290 self.runs.iter().filter_map(|r| r.content.plain_text()).fold(
294 String::new(),
295 |mut acc, cow| {
296 acc.push_str(&cow);
297 acc
298 },
299 )
300 }
301
302 pub fn run_count(&self) -> usize {
304 self.runs.len()
305 }
306
307 pub fn is_empty(&self) -> bool {
309 self.runs.is_empty()
310 }
311}
312
313impl std::fmt::Display for Paragraph {
314 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315 write!(f, "Paragraph({} runs)", self.runs.len())
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use crate::control::Control;
323 use crate::table::Table;
324 use hwpforge_foundation::CharShapeIndex;
325
326 fn text_run(s: &str) -> Run {
327 Run::text(s, CharShapeIndex::new(0))
328 }
329
330 #[test]
331 fn new_is_empty() {
332 let para = Paragraph::new(ParaShapeIndex::new(0));
333 assert!(para.is_empty());
334 assert_eq!(para.run_count(), 0);
335 assert_eq!(para.text_content(), "");
336 }
337
338 #[test]
339 fn with_runs() {
340 let para = Paragraph::with_runs(vec![text_run("a"), text_run("b")], ParaShapeIndex::new(0));
341 assert_eq!(para.run_count(), 2);
342 assert!(!para.is_empty());
343 }
344
345 #[test]
346 fn add_run() {
347 let mut para = Paragraph::new(ParaShapeIndex::new(0));
348 para.add_run(text_run("first"));
349 para.add_run(text_run("second"));
350 assert_eq!(para.run_count(), 2);
351 }
352
353 #[test]
354 fn text_content_concatenation() {
355 let para = Paragraph::with_runs(
356 vec![text_run("Hello "), text_run("world!")],
357 ParaShapeIndex::new(0),
358 );
359 assert_eq!(para.text_content(), "Hello world!");
360 }
361
362 #[test]
363 fn text_content_skips_non_text() {
364 let para = Paragraph::with_runs(
365 vec![
366 text_run("before"),
367 Run::table(Table::new(vec![]), CharShapeIndex::new(0)),
368 text_run("after"),
369 ],
370 ParaShapeIndex::new(0),
371 );
372 assert_eq!(para.text_content(), "beforeafter");
373 }
374
375 #[test]
376 fn text_content_empty_paragraph() {
377 let para = Paragraph::new(ParaShapeIndex::new(0));
378 assert_eq!(para.text_content(), "");
379 }
380
381 #[test]
382 fn text_content_no_text_runs() {
383 let para = Paragraph::with_runs(
384 vec![Run::table(Table::new(vec![]), CharShapeIndex::new(0))],
385 ParaShapeIndex::new(0),
386 );
387 assert_eq!(para.text_content(), "");
388 }
389
390 #[test]
391 fn korean_text_content() {
392 let para = Paragraph::with_runs(
393 vec![text_run("안녕"), text_run("하세요")],
394 ParaShapeIndex::new(0),
395 );
396 assert_eq!(para.text_content(), "안녕하세요");
397 }
398
399 #[test]
400 fn display() {
401 let para = Paragraph::with_runs(
402 vec![text_run("a"), text_run("b"), text_run("c")],
403 ParaShapeIndex::new(0),
404 );
405 assert_eq!(para.to_string(), "Paragraph(3 runs)");
406 }
407
408 #[test]
409 fn equality() {
410 let a = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(0));
411 let b = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(0));
412 let c = Paragraph::with_runs(vec![text_run("y")], ParaShapeIndex::new(0));
413 let d = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(1));
414 assert_eq!(a, b);
415 assert_ne!(a, c);
416 assert_ne!(a, d);
417 }
418
419 #[test]
420 fn clone_independence() {
421 let para = Paragraph::with_runs(vec![text_run("original")], ParaShapeIndex::new(0));
422 let mut cloned = para.clone();
423 cloned.add_run(text_run("added"));
424 assert_eq!(para.run_count(), 1);
425 assert_eq!(cloned.run_count(), 2);
426 }
427
428 #[test]
429 fn many_runs() {
430 let runs: Vec<Run> = (0..100).map(|i| text_run(&format!("run{i}"))).collect();
431 let para = Paragraph::with_runs(runs, ParaShapeIndex::new(0));
432 assert_eq!(para.run_count(), 100);
433 assert!(para.text_content().starts_with("run0"));
434 }
435
436 #[test]
437 fn serde_roundtrip() {
438 let para = Paragraph::with_runs(
439 vec![text_run("hello"), text_run("world")],
440 ParaShapeIndex::new(5),
441 );
442 let json = serde_json::to_string(¶).unwrap();
443 let back: Paragraph = serde_json::from_str(&json).unwrap();
444 assert_eq!(para, back);
445 }
446
447 #[test]
448 fn serde_roundtrip_with_control() {
449 let ctrl =
450 Control::Hyperlink { text: "link".to_string(), url: "https://example.com".to_string() };
451 let para = Paragraph::with_runs(
452 vec![text_run("see "), Run::control(ctrl, CharShapeIndex::new(1))],
453 ParaShapeIndex::new(0),
454 );
455 let json = serde_json::to_string(¶).unwrap();
456 let back: Paragraph = serde_json::from_str(&json).unwrap();
457 assert_eq!(para, back);
458 }
459
460 #[test]
461 fn serde_empty_paragraph() {
462 let para = Paragraph::new(ParaShapeIndex::new(0));
463 let json = serde_json::to_string(¶).unwrap();
464 let back: Paragraph = serde_json::from_str(&json).unwrap();
465 assert_eq!(para, back);
466 }
467
468 #[test]
469 fn with_heading_level_sets_field() {
470 let para = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(1);
471 assert_eq!(para.heading_level, Some(1));
472
473 let para7 = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(7);
474 assert_eq!(para7.heading_level, Some(7));
475 }
476
477 #[test]
478 fn with_heading_level_all_valid_levels() {
479 for level in 1u8..=7 {
480 let para = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(level);
481 assert_eq!(para.heading_level, Some(level));
482 }
483 }
484
485 #[test]
486 #[should_panic(expected = "heading_level must be 1-7")]
487 fn with_heading_level_zero_panics() {
488 let _ = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(0);
489 }
490
491 #[test]
492 #[should_panic(expected = "heading_level must be 1-7")]
493 fn with_heading_level_eight_panics() {
494 let _ = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(8);
495 }
496
497 #[test]
498 fn new_has_no_heading_level() {
499 let para = Paragraph::new(ParaShapeIndex::new(0));
500 assert_eq!(para.heading_level, None);
501 }
502
503 #[test]
504 fn serde_roundtrip_with_heading_level() {
505 let para = Paragraph::with_runs(vec![text_run("heading text")], ParaShapeIndex::new(0))
506 .with_heading_level(2);
507 let json = serde_json::to_string(¶).unwrap();
508 let back: Paragraph = serde_json::from_str(&json).unwrap();
509 assert_eq!(para, back);
510 assert_eq!(back.heading_level, Some(2));
511 }
512
513 #[test]
514 fn serde_heading_level_omitted_when_none() {
515 let para = Paragraph::new(ParaShapeIndex::new(0));
516 let json = serde_json::to_string(¶).unwrap();
517 assert!(!json.contains("heading_level"), "None should be skipped in serialization");
518 }
519
520 #[test]
521 fn try_with_heading_level_valid() {
522 for level in 1u8..=7 {
523 let para =
524 Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(level).unwrap();
525 assert_eq!(para.heading_level, Some(level));
526 }
527 }
528
529 #[test]
530 fn try_with_heading_level_zero_errors() {
531 let result = Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(0);
532 assert!(result.is_err());
533 }
534
535 #[test]
536 fn try_with_heading_level_eight_errors() {
537 let result = Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(8);
538 assert!(result.is_err());
539 }
540
541 #[test]
542 fn try_with_heading_level_255_errors() {
543 let result = Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(255);
544 assert!(result.is_err());
545 }
546
547 #[test]
548 fn serde_roundtrip_all_7_heading_levels() {
549 for level in 1u8..=7 {
550 let para = Paragraph::with_runs(vec![text_run("heading")], ParaShapeIndex::new(0))
551 .with_heading_level(level);
552 let json = serde_json::to_string(¶).unwrap();
553 let back: Paragraph = serde_json::from_str(&json).unwrap();
554 assert_eq!(back.heading_level, Some(level), "level {level} roundtrip failed");
555 }
556 }
557
558 #[test]
559 fn new_has_no_style_id() {
560 let para = Paragraph::new(ParaShapeIndex::new(0));
561 assert_eq!(para.style_id, None);
562 }
563
564 #[test]
565 fn with_style_builder_works() {
566 let para = Paragraph::new(ParaShapeIndex::new(0)).with_style(StyleIndex::new(2));
567 assert_eq!(para.style_id, Some(StyleIndex::new(2)));
568 }
569
570 #[test]
571 fn with_runs_has_no_style_id() {
572 let para = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(0));
573 assert_eq!(para.style_id, None);
574 }
575
576 #[test]
577 fn serde_roundtrip_with_style_id() {
578 let para = Paragraph::new(ParaShapeIndex::new(0)).with_style(StyleIndex::new(5));
579 let json = serde_json::to_string(¶).unwrap();
580 let back: Paragraph = serde_json::from_str(&json).unwrap();
581 assert_eq!(back.style_id, Some(StyleIndex::new(5)));
582 }
583
584 #[test]
585 fn serde_missing_style_id_deserializes_to_none() {
586 let json = r#"{"runs":[],"para_shape_id":0,"column_break":false}"#;
588 let para: Paragraph = serde_json::from_str(json).unwrap();
589 assert_eq!(para.style_id, None);
590 }
591
592 #[test]
593 fn serde_style_id_omitted_when_none() {
594 let para = Paragraph::new(ParaShapeIndex::new(0));
595 let json = serde_json::to_string(¶).unwrap();
596 assert!(!json.contains("style_id"), "None should be skipped in serialization");
597 }
598}