1use hwpforge_foundation::{ParaShapeIndex, StyleIndex};
32use schemars::JsonSchema;
33use serde::{Deserialize, Serialize};
34
35use crate::error::{CoreError, CoreResult};
36use crate::run::{Run, RunContent};
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}
76
77impl Paragraph {
78 pub fn new(para_shape_id: ParaShapeIndex) -> Self {
90 Self {
91 runs: Vec::new(),
92 para_shape_id,
93 column_break: false,
94 page_break: false,
95 heading_level: None,
96 style_id: None,
97 }
98 }
99
100 pub fn with_runs(runs: Vec<Run>, para_shape_id: ParaShapeIndex) -> Self {
116 Self {
117 runs,
118 para_shape_id,
119 column_break: false,
120 page_break: false,
121 heading_level: None,
122 style_id: None,
123 }
124 }
125
126 pub fn add_run(&mut self, run: Run) {
140 self.runs.push(run);
141 }
142
143 pub fn with_heading_level(mut self, level: u8) -> Self {
163 assert!((1..=7).contains(&level), "heading_level must be 1-7, got {level}");
164 self.heading_level = Some(level);
165 self
166 }
167
168 pub fn with_style(mut self, style_id: StyleIndex) -> Self {
181 self.style_id = Some(style_id);
182 self
183 }
184
185 pub fn with_page_break(mut self) -> Self {
197 self.page_break = true;
198 self
199 }
200
201 pub fn try_with_heading_level(mut self, level: u8) -> CoreResult<Self> {
227 if !(1..=7).contains(&level) {
228 return Err(CoreError::InvalidStructure {
229 context: "Paragraph::try_with_heading_level".into(),
230 reason: format!("heading_level must be 1-7, got {level}"),
231 });
232 }
233 self.heading_level = Some(level);
234 Ok(self)
235 }
236
237 pub fn text_content(&self) -> String {
261 self.runs
262 .iter()
263 .filter_map(
264 |r| {
265 if let RunContent::Text(s) = &r.content {
266 Some(s.as_str())
267 } else {
268 None
269 }
270 },
271 )
272 .collect()
273 }
274
275 pub fn run_count(&self) -> usize {
277 self.runs.len()
278 }
279
280 pub fn is_empty(&self) -> bool {
282 self.runs.is_empty()
283 }
284}
285
286impl std::fmt::Display for Paragraph {
287 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288 write!(f, "Paragraph({} runs)", self.runs.len())
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use crate::control::Control;
296 use crate::table::Table;
297 use hwpforge_foundation::CharShapeIndex;
298
299 fn text_run(s: &str) -> Run {
300 Run::text(s, CharShapeIndex::new(0))
301 }
302
303 #[test]
304 fn new_is_empty() {
305 let para = Paragraph::new(ParaShapeIndex::new(0));
306 assert!(para.is_empty());
307 assert_eq!(para.run_count(), 0);
308 assert_eq!(para.text_content(), "");
309 }
310
311 #[test]
312 fn with_runs() {
313 let para = Paragraph::with_runs(vec![text_run("a"), text_run("b")], ParaShapeIndex::new(0));
314 assert_eq!(para.run_count(), 2);
315 assert!(!para.is_empty());
316 }
317
318 #[test]
319 fn add_run() {
320 let mut para = Paragraph::new(ParaShapeIndex::new(0));
321 para.add_run(text_run("first"));
322 para.add_run(text_run("second"));
323 assert_eq!(para.run_count(), 2);
324 }
325
326 #[test]
327 fn text_content_concatenation() {
328 let para = Paragraph::with_runs(
329 vec![text_run("Hello "), text_run("world!")],
330 ParaShapeIndex::new(0),
331 );
332 assert_eq!(para.text_content(), "Hello world!");
333 }
334
335 #[test]
336 fn text_content_skips_non_text() {
337 let para = Paragraph::with_runs(
338 vec![
339 text_run("before"),
340 Run::table(Table::new(vec![]), CharShapeIndex::new(0)),
341 text_run("after"),
342 ],
343 ParaShapeIndex::new(0),
344 );
345 assert_eq!(para.text_content(), "beforeafter");
346 }
347
348 #[test]
349 fn text_content_empty_paragraph() {
350 let para = Paragraph::new(ParaShapeIndex::new(0));
351 assert_eq!(para.text_content(), "");
352 }
353
354 #[test]
355 fn text_content_no_text_runs() {
356 let para = Paragraph::with_runs(
357 vec![Run::table(Table::new(vec![]), CharShapeIndex::new(0))],
358 ParaShapeIndex::new(0),
359 );
360 assert_eq!(para.text_content(), "");
361 }
362
363 #[test]
364 fn korean_text_content() {
365 let para = Paragraph::with_runs(
366 vec![text_run("안녕"), text_run("하세요")],
367 ParaShapeIndex::new(0),
368 );
369 assert_eq!(para.text_content(), "안녕하세요");
370 }
371
372 #[test]
373 fn display() {
374 let para = Paragraph::with_runs(
375 vec![text_run("a"), text_run("b"), text_run("c")],
376 ParaShapeIndex::new(0),
377 );
378 assert_eq!(para.to_string(), "Paragraph(3 runs)");
379 }
380
381 #[test]
382 fn equality() {
383 let a = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(0));
384 let b = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(0));
385 let c = Paragraph::with_runs(vec![text_run("y")], ParaShapeIndex::new(0));
386 let d = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(1));
387 assert_eq!(a, b);
388 assert_ne!(a, c);
389 assert_ne!(a, d);
390 }
391
392 #[test]
393 fn clone_independence() {
394 let para = Paragraph::with_runs(vec![text_run("original")], ParaShapeIndex::new(0));
395 let mut cloned = para.clone();
396 cloned.add_run(text_run("added"));
397 assert_eq!(para.run_count(), 1);
398 assert_eq!(cloned.run_count(), 2);
399 }
400
401 #[test]
402 fn many_runs() {
403 let runs: Vec<Run> = (0..100).map(|i| text_run(&format!("run{i}"))).collect();
404 let para = Paragraph::with_runs(runs, ParaShapeIndex::new(0));
405 assert_eq!(para.run_count(), 100);
406 assert!(para.text_content().starts_with("run0"));
407 }
408
409 #[test]
410 fn serde_roundtrip() {
411 let para = Paragraph::with_runs(
412 vec![text_run("hello"), text_run("world")],
413 ParaShapeIndex::new(5),
414 );
415 let json = serde_json::to_string(¶).unwrap();
416 let back: Paragraph = serde_json::from_str(&json).unwrap();
417 assert_eq!(para, back);
418 }
419
420 #[test]
421 fn serde_roundtrip_with_control() {
422 let ctrl =
423 Control::Hyperlink { text: "link".to_string(), url: "https://example.com".to_string() };
424 let para = Paragraph::with_runs(
425 vec![text_run("see "), Run::control(ctrl, CharShapeIndex::new(1))],
426 ParaShapeIndex::new(0),
427 );
428 let json = serde_json::to_string(¶).unwrap();
429 let back: Paragraph = serde_json::from_str(&json).unwrap();
430 assert_eq!(para, back);
431 }
432
433 #[test]
434 fn serde_empty_paragraph() {
435 let para = Paragraph::new(ParaShapeIndex::new(0));
436 let json = serde_json::to_string(¶).unwrap();
437 let back: Paragraph = serde_json::from_str(&json).unwrap();
438 assert_eq!(para, back);
439 }
440
441 #[test]
442 fn with_heading_level_sets_field() {
443 let para = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(1);
444 assert_eq!(para.heading_level, Some(1));
445
446 let para7 = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(7);
447 assert_eq!(para7.heading_level, Some(7));
448 }
449
450 #[test]
451 fn with_heading_level_all_valid_levels() {
452 for level in 1u8..=7 {
453 let para = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(level);
454 assert_eq!(para.heading_level, Some(level));
455 }
456 }
457
458 #[test]
459 #[should_panic(expected = "heading_level must be 1-7")]
460 fn with_heading_level_zero_panics() {
461 let _ = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(0);
462 }
463
464 #[test]
465 #[should_panic(expected = "heading_level must be 1-7")]
466 fn with_heading_level_eight_panics() {
467 let _ = Paragraph::new(ParaShapeIndex::new(0)).with_heading_level(8);
468 }
469
470 #[test]
471 fn new_has_no_heading_level() {
472 let para = Paragraph::new(ParaShapeIndex::new(0));
473 assert_eq!(para.heading_level, None);
474 }
475
476 #[test]
477 fn serde_roundtrip_with_heading_level() {
478 let para = Paragraph::with_runs(vec![text_run("heading text")], ParaShapeIndex::new(0))
479 .with_heading_level(2);
480 let json = serde_json::to_string(¶).unwrap();
481 let back: Paragraph = serde_json::from_str(&json).unwrap();
482 assert_eq!(para, back);
483 assert_eq!(back.heading_level, Some(2));
484 }
485
486 #[test]
487 fn serde_heading_level_omitted_when_none() {
488 let para = Paragraph::new(ParaShapeIndex::new(0));
489 let json = serde_json::to_string(¶).unwrap();
490 assert!(!json.contains("heading_level"), "None should be skipped in serialization");
491 }
492
493 #[test]
494 fn try_with_heading_level_valid() {
495 for level in 1u8..=7 {
496 let para =
497 Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(level).unwrap();
498 assert_eq!(para.heading_level, Some(level));
499 }
500 }
501
502 #[test]
503 fn try_with_heading_level_zero_errors() {
504 let result = Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(0);
505 assert!(result.is_err());
506 }
507
508 #[test]
509 fn try_with_heading_level_eight_errors() {
510 let result = Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(8);
511 assert!(result.is_err());
512 }
513
514 #[test]
515 fn try_with_heading_level_255_errors() {
516 let result = Paragraph::new(ParaShapeIndex::new(0)).try_with_heading_level(255);
517 assert!(result.is_err());
518 }
519
520 #[test]
521 fn serde_roundtrip_all_7_heading_levels() {
522 for level in 1u8..=7 {
523 let para = Paragraph::with_runs(vec![text_run("heading")], ParaShapeIndex::new(0))
524 .with_heading_level(level);
525 let json = serde_json::to_string(¶).unwrap();
526 let back: Paragraph = serde_json::from_str(&json).unwrap();
527 assert_eq!(back.heading_level, Some(level), "level {level} roundtrip failed");
528 }
529 }
530
531 #[test]
532 fn new_has_no_style_id() {
533 let para = Paragraph::new(ParaShapeIndex::new(0));
534 assert_eq!(para.style_id, None);
535 }
536
537 #[test]
538 fn with_style_builder_works() {
539 let para = Paragraph::new(ParaShapeIndex::new(0)).with_style(StyleIndex::new(2));
540 assert_eq!(para.style_id, Some(StyleIndex::new(2)));
541 }
542
543 #[test]
544 fn with_runs_has_no_style_id() {
545 let para = Paragraph::with_runs(vec![text_run("x")], ParaShapeIndex::new(0));
546 assert_eq!(para.style_id, None);
547 }
548
549 #[test]
550 fn serde_roundtrip_with_style_id() {
551 let para = Paragraph::new(ParaShapeIndex::new(0)).with_style(StyleIndex::new(5));
552 let json = serde_json::to_string(¶).unwrap();
553 let back: Paragraph = serde_json::from_str(&json).unwrap();
554 assert_eq!(back.style_id, Some(StyleIndex::new(5)));
555 }
556
557 #[test]
558 fn serde_missing_style_id_deserializes_to_none() {
559 let json = r#"{"runs":[],"para_shape_id":0,"column_break":false}"#;
561 let para: Paragraph = serde_json::from_str(json).unwrap();
562 assert_eq!(para.style_id, None);
563 }
564
565 #[test]
566 fn serde_style_id_omitted_when_none() {
567 let para = Paragraph::new(ParaShapeIndex::new(0));
568 let json = serde_json::to_string(¶).unwrap();
569 assert!(!json.contains("style_id"), "None should be skipped in serialization");
570 }
571}