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