use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct LineSeg {
pub textpos: u32,
pub vertpos: i32,
pub vertsize: i32,
pub textheight: i32,
pub baseline: i32,
pub spacing: i32,
pub horzpos: i32,
pub horzsize: i32,
pub flags: u32,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct LayoutCache {
pub lines: Vec<LineSeg>,
}
impl LayoutCache {
pub fn new(lines: Vec<LineSeg>) -> Self {
Self { lines }
}
pub fn line_count(&self) -> usize {
self.lines.len()
}
pub fn is_empty(&self) -> bool {
self.lines.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn seg(textpos: u32, vertpos: i32) -> LineSeg {
LineSeg {
textpos,
vertpos,
vertsize: 1000,
textheight: 1000,
baseline: 850,
spacing: 600,
horzpos: 0,
horzsize: 48188,
flags: 0x0060_0000,
}
}
#[test]
fn lineseg_boundary_values_roundtrip_serde() {
let s = LineSeg {
textpos: u32::MAX,
vertpos: i32::MIN,
vertsize: i32::MAX,
textheight: 0,
baseline: -1,
spacing: i32::MIN,
horzpos: i32::MAX,
horzsize: 0,
flags: u32::MAX,
};
let json = serde_json::to_string(&s).unwrap();
let back: LineSeg = serde_json::from_str(&json).unwrap();
assert_eq!(s, back);
}
#[test]
fn layout_cache_default_is_empty() {
let c = LayoutCache::default();
assert!(c.is_empty());
assert_eq!(c.line_count(), 0);
}
#[test]
fn layout_cache_preserves_wire_order() {
let c = LayoutCache::new(vec![seg(0, 0), seg(70, 1600), seg(122, 3200)]);
assert_eq!(c.line_count(), 3);
assert_eq!(c.lines[1].textpos, 70);
assert_eq!(c.lines[2].vertpos, 3200);
}
#[test]
fn layout_cache_eq_is_structural() {
let a = LayoutCache::new(vec![seg(0, 0)]);
let b = LayoutCache::new(vec![seg(0, 0)]);
let c = LayoutCache::new(vec![seg(0, 16)]);
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn paragraph_json_without_cache_field_deserializes() {
let old_json = r#"{"runs":[],"para_shape_id":0}"#;
let p: crate::paragraph::Paragraph = serde_json::from_str(old_json).unwrap();
assert!(p.layout_cache.is_none());
}
#[test]
fn paragraph_json_omits_cache_when_none() {
let p = crate::paragraph::Paragraph::new(hwpforge_foundation::ParaShapeIndex::new(0));
let json = serde_json::to_string(&p).unwrap();
assert!(!json.contains("layout_cache"), "None 캐시는 직렬화 생략: {json}");
let mut cached = p.clone();
cached.layout_cache = Some(LayoutCache::new(vec![seg(0, 0)]));
let json2 = serde_json::to_string(&cached).unwrap();
assert!(json2.contains("layout_cache"));
let back: crate::paragraph::Paragraph = serde_json::from_str(&json2).unwrap();
assert_eq!(cached, back);
}
mod traversal {
use super::*;
use crate::caption::{Caption, CaptionSide};
use crate::control::Control;
use crate::document::Document;
use crate::page::PageSettings;
use crate::paragraph::Paragraph;
use crate::run::Run;
use crate::section::{HeaderFooter, MasterPage, Section};
use crate::table::{Table, TableCell, TableRow};
use hwpforge_foundation::{ApplyPageType, CharShapeIndex, HwpUnit, ParaShapeIndex};
fn cached_para(text: &str) -> Paragraph {
let mut p = Paragraph::with_runs(
vec![Run::text(text, CharShapeIndex::new(0))],
ParaShapeIndex::new(0),
);
p.layout_cache = Some(LayoutCache::new(vec![seg(0, 0)]));
p
}
fn one_cell_table(cell_para: Paragraph) -> Table {
Table::new(vec![TableRow::new(vec![TableCell::new(
vec![cell_para],
HwpUnit::from_pt(100.0).unwrap(),
)])])
.with_layout_cache(crate::table::TableLayoutCache::new(
Some(HwpUnit::from_pt(56.7).unwrap()),
true,
))
.with_out_margin(crate::table::TableMargin::default())
}
fn document_with_all_containers() -> (Document<crate::document::Draft>, usize) {
let mut expected = 0usize;
let inner = one_cell_table(cached_para("inner-cell"));
let mut mid_cell = cached_para("mid-cell");
mid_cell.add_run(Run::table(inner, CharShapeIndex::new(0)));
let outer = one_cell_table(mid_cell)
.with_caption(Caption::new(vec![cached_para("tbl-caption")], CaptionSide::Bottom));
let mut host = cached_para("tbl-host");
host.add_run(Run::table(outer, CharShapeIndex::new(0)));
expected += 4;
let mut textbox = Control::text_box(
vec![cached_para("textbox-body")],
HwpUnit::from_pt(100.0).unwrap(),
HwpUnit::from_pt(50.0).unwrap(),
);
if let Control::TextBox { caption, .. } = &mut textbox {
*caption =
Some(Caption::new(vec![cached_para("textbox-caption")], CaptionSide::Top));
}
let mut tb_host = cached_para("textbox-host");
tb_host.add_run(Run::control(textbox, CharShapeIndex::new(0)));
expected += 3;
let child = Control::text_box(
vec![cached_para("group-child-body")],
HwpUnit::from_pt(10.0).unwrap(),
HwpUnit::from_pt(10.0).unwrap(),
);
let group = Control::Group {
children: vec![child],
width: HwpUnit::from_pt(10.0).unwrap(),
height: HwpUnit::from_pt(10.0).unwrap(),
horz_offset: 0,
vert_offset: 0,
inst_id: None,
};
let mut group_host = cached_para("group-host");
group_host.add_run(Run::control(group, CharShapeIndex::new(0)));
expected += 2;
let memo = Control::memo_with_anchor(
vec![cached_para("memo-body")],
vec![Run::table(
one_cell_table(cached_para("memo-anchor-cell")),
CharShapeIndex::new(0),
)],
);
let mut memo_host = cached_para("memo-host");
memo_host.add_run(Run::control(memo, CharShapeIndex::new(0)));
expected += 3;
let mut note_host = cached_para("note-host");
note_host.add_run(Run::control(
Control::footnote(vec![cached_para("footnote-body")]),
CharShapeIndex::new(0),
));
note_host.add_run(Run::control(
Control::ellipse_with_text(
HwpUnit::from_pt(20.0).unwrap(),
HwpUnit::from_pt(20.0).unwrap(),
vec![cached_para("ellipse-body")],
),
CharShapeIndex::new(0),
));
expected += 3;
let mut section = Section::with_paragraphs(
vec![host, tb_host, group_host, memo_host, note_host],
PageSettings::a4(),
);
section.headers.push(HeaderFooter::all_pages(vec![cached_para("header")]));
section.footers.push(HeaderFooter::all_pages(vec![cached_para("footer")]));
section.master_pages =
Some(vec![MasterPage::new(ApplyPageType::Both, vec![cached_para("master")])]);
expected += 3;
let mut doc = Document::new();
doc.add_section(section);
(doc, expected)
}
#[test]
fn for_each_paragraph_mut_visits_every_container() {
let (mut doc, expected) = document_with_all_containers();
let mut visited = Vec::new();
doc.for_each_paragraph_mut(|p| visited.push(p.text_content()));
assert_eq!(visited.len(), expected, "visited: {visited:?}");
for needle in [
"inner-cell",
"tbl-caption",
"textbox-caption",
"group-child-body",
"memo-anchor-cell",
"footnote-body",
"master",
] {
assert!(visited.iter().any(|t| t == needle), "missing {needle}: {visited:?}");
}
}
#[test]
fn strip_layout_caches_clears_every_container() {
let (mut doc, expected) = document_with_all_containers();
doc.strip_layout_caches();
let mut remaining = 0;
let mut total = 0;
let mut table_caches = 0;
let mut table_margins = 0;
doc.for_each_paragraph_mut(|p| {
total += 1;
if p.layout_cache.is_some() {
remaining += 1;
}
for run in &p.runs {
if let crate::run::RunContent::Table(t) = &run.content {
table_caches += usize::from(t.layout_cache.is_some());
table_margins += usize::from(t.out_margin.is_some());
}
}
});
assert_eq!(total, expected);
assert_eq!(remaining, 0);
assert_eq!(table_caches, 0, "table layout caches must be stripped");
assert!(table_margins >= 2, "structural margins must survive strip");
}
#[test]
fn cache_difference_breaks_eq_until_stripped() {
let (doc_a, _) = document_with_all_containers();
let (mut doc_b, _) = document_with_all_containers();
assert_eq!(doc_a, doc_b);
doc_b.strip_layout_caches();
assert_ne!(doc_a, doc_b, "derived eq must still see cache differences");
let mut doc_a2 = doc_a.clone();
doc_a2.strip_layout_caches();
assert_eq!(doc_a2, doc_b, "normalized copies must compare equal");
}
}
}