1use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
25pub struct LineSeg {
26 pub textpos: u32,
28 pub vertpos: i32,
30 pub vertsize: i32,
32 pub textheight: i32,
34 pub baseline: i32,
36 pub spacing: i32,
38 pub horzpos: i32,
40 pub horzsize: i32,
42 pub flags: u32,
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
51pub struct LayoutCache {
52 pub lines: Vec<LineSeg>,
54}
55
56impl LayoutCache {
57 pub fn new(lines: Vec<LineSeg>) -> Self {
59 Self { lines }
60 }
61
62 pub fn line_count(&self) -> usize {
64 self.lines.len()
65 }
66
67 pub fn is_empty(&self) -> bool {
69 self.lines.is_empty()
70 }
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76
77 fn seg(textpos: u32, vertpos: i32) -> LineSeg {
78 LineSeg {
79 textpos,
80 vertpos,
81 vertsize: 1000,
82 textheight: 1000,
83 baseline: 850,
84 spacing: 600,
85 horzpos: 0,
86 horzsize: 48188,
87 flags: 0x0060_0000,
88 }
89 }
90
91 #[test]
92 fn lineseg_boundary_values_roundtrip_serde() {
93 let s = LineSeg {
94 textpos: u32::MAX,
95 vertpos: i32::MIN,
96 vertsize: i32::MAX,
97 textheight: 0,
98 baseline: -1,
99 spacing: i32::MIN,
100 horzpos: i32::MAX,
101 horzsize: 0,
102 flags: u32::MAX,
103 };
104 let json = serde_json::to_string(&s).unwrap();
105 let back: LineSeg = serde_json::from_str(&json).unwrap();
106 assert_eq!(s, back);
107 }
108
109 #[test]
110 fn layout_cache_default_is_empty() {
111 let c = LayoutCache::default();
112 assert!(c.is_empty());
113 assert_eq!(c.line_count(), 0);
114 }
115
116 #[test]
117 fn layout_cache_preserves_wire_order() {
118 let c = LayoutCache::new(vec![seg(0, 0), seg(70, 1600), seg(122, 3200)]);
119 assert_eq!(c.line_count(), 3);
120 assert_eq!(c.lines[1].textpos, 70);
121 assert_eq!(c.lines[2].vertpos, 3200);
122 }
123
124 #[test]
125 fn layout_cache_eq_is_structural() {
126 let a = LayoutCache::new(vec![seg(0, 0)]);
127 let b = LayoutCache::new(vec![seg(0, 0)]);
128 let c = LayoutCache::new(vec![seg(0, 16)]);
129 assert_eq!(a, b);
130 assert_ne!(a, c);
131 }
132
133 #[test]
134 fn paragraph_json_without_cache_field_deserializes() {
135 let old_json = r#"{"runs":[],"para_shape_id":0}"#;
137 let p: crate::paragraph::Paragraph = serde_json::from_str(old_json).unwrap();
138 assert!(p.layout_cache.is_none());
139 }
140
141 #[test]
142 fn paragraph_json_omits_cache_when_none() {
143 let p = crate::paragraph::Paragraph::new(hwpforge_foundation::ParaShapeIndex::new(0));
144 let json = serde_json::to_string(&p).unwrap();
145 assert!(!json.contains("layout_cache"), "None 캐시는 직렬화 생략: {json}");
146 let mut cached = p.clone();
147 cached.layout_cache = Some(LayoutCache::new(vec![seg(0, 0)]));
148 let json2 = serde_json::to_string(&cached).unwrap();
149 assert!(json2.contains("layout_cache"));
150 let back: crate::paragraph::Paragraph = serde_json::from_str(&json2).unwrap();
151 assert_eq!(cached, back);
152 }
153
154 mod traversal {
159 use super::*;
160 use crate::caption::{Caption, CaptionSide};
161 use crate::control::Control;
162 use crate::document::Document;
163 use crate::page::PageSettings;
164 use crate::paragraph::Paragraph;
165 use crate::run::Run;
166 use crate::section::{HeaderFooter, MasterPage, Section};
167 use crate::table::{Table, TableCell, TableRow};
168 use hwpforge_foundation::{ApplyPageType, CharShapeIndex, HwpUnit, ParaShapeIndex};
169
170 fn cached_para(text: &str) -> Paragraph {
172 let mut p = Paragraph::with_runs(
173 vec![Run::text(text, CharShapeIndex::new(0))],
174 ParaShapeIndex::new(0),
175 );
176 p.layout_cache = Some(LayoutCache::new(vec![seg(0, 0)]));
177 p
178 }
179
180 fn one_cell_table(cell_para: Paragraph) -> Table {
181 Table::new(vec![TableRow::new(vec![TableCell::new(
182 vec![cell_para],
183 HwpUnit::from_pt(100.0).unwrap(),
184 )])])
185 }
186
187 fn document_with_all_containers() -> (Document<crate::document::Draft>, usize) {
191 let mut expected = 0usize;
192
193 let inner = one_cell_table(cached_para("inner-cell"));
195 let mut mid_cell = cached_para("mid-cell");
196 mid_cell.add_run(Run::table(inner, CharShapeIndex::new(0)));
197 let outer = one_cell_table(mid_cell)
198 .with_caption(Caption::new(vec![cached_para("tbl-caption")], CaptionSide::Bottom));
199 let mut host = cached_para("tbl-host");
200 host.add_run(Run::table(outer, CharShapeIndex::new(0)));
201 expected += 4; let mut textbox = Control::text_box(
205 vec![cached_para("textbox-body")],
206 HwpUnit::from_pt(100.0).unwrap(),
207 HwpUnit::from_pt(50.0).unwrap(),
208 );
209 if let Control::TextBox { caption, .. } = &mut textbox {
210 *caption =
211 Some(Caption::new(vec![cached_para("textbox-caption")], CaptionSide::Top));
212 }
213 let mut tb_host = cached_para("textbox-host");
214 tb_host.add_run(Run::control(textbox, CharShapeIndex::new(0)));
215 expected += 3; let child = Control::text_box(
219 vec![cached_para("group-child-body")],
220 HwpUnit::from_pt(10.0).unwrap(),
221 HwpUnit::from_pt(10.0).unwrap(),
222 );
223 let group = Control::Group {
224 children: vec![child],
225 width: HwpUnit::from_pt(10.0).unwrap(),
226 height: HwpUnit::from_pt(10.0).unwrap(),
227 horz_offset: 0,
228 vert_offset: 0,
229 inst_id: None,
230 };
231 let mut group_host = cached_para("group-host");
232 group_host.add_run(Run::control(group, CharShapeIndex::new(0)));
233 expected += 2; let memo = Control::memo_with_anchor(
237 vec![cached_para("memo-body")],
238 vec![Run::table(
239 one_cell_table(cached_para("memo-anchor-cell")),
240 CharShapeIndex::new(0),
241 )],
242 );
243 let mut memo_host = cached_para("memo-host");
244 memo_host.add_run(Run::control(memo, CharShapeIndex::new(0)));
245 expected += 3; let mut note_host = cached_para("note-host");
249 note_host.add_run(Run::control(
250 Control::footnote(vec![cached_para("footnote-body")]),
251 CharShapeIndex::new(0),
252 ));
253 note_host.add_run(Run::control(
254 Control::ellipse_with_text(
255 HwpUnit::from_pt(20.0).unwrap(),
256 HwpUnit::from_pt(20.0).unwrap(),
257 vec![cached_para("ellipse-body")],
258 ),
259 CharShapeIndex::new(0),
260 ));
261 expected += 3; let mut section = Section::with_paragraphs(
264 vec![host, tb_host, group_host, memo_host, note_host],
265 PageSettings::a4(),
266 );
267 section.headers.push(HeaderFooter::all_pages(vec![cached_para("header")]));
268 section.footers.push(HeaderFooter::all_pages(vec![cached_para("footer")]));
269 section.master_pages =
270 Some(vec![MasterPage::new(ApplyPageType::Both, vec![cached_para("master")])]);
271 expected += 3; let mut doc = Document::new();
274 doc.add_section(section);
275 (doc, expected)
276 }
277
278 #[test]
279 fn for_each_paragraph_mut_visits_every_container() {
280 let (mut doc, expected) = document_with_all_containers();
281 let mut visited = Vec::new();
282 doc.for_each_paragraph_mut(|p| visited.push(p.text_content()));
283 assert_eq!(visited.len(), expected, "visited: {visited:?}");
284 for needle in [
286 "inner-cell",
287 "tbl-caption",
288 "textbox-caption",
289 "group-child-body",
290 "memo-anchor-cell",
291 "footnote-body",
292 "master",
293 ] {
294 assert!(visited.iter().any(|t| t == needle), "missing {needle}: {visited:?}");
295 }
296 }
297
298 #[test]
299 fn strip_layout_caches_clears_every_container() {
300 let (mut doc, expected) = document_with_all_containers();
301 doc.strip_layout_caches();
302 let mut remaining = 0;
303 let mut total = 0;
304 doc.for_each_paragraph_mut(|p| {
305 total += 1;
306 if p.layout_cache.is_some() {
307 remaining += 1;
308 }
309 });
310 assert_eq!(total, expected);
311 assert_eq!(remaining, 0);
312 }
313
314 #[test]
315 fn cache_difference_breaks_eq_until_stripped() {
316 let (doc_a, _) = document_with_all_containers();
317 let (mut doc_b, _) = document_with_all_containers();
318 assert_eq!(doc_a, doc_b);
319 doc_b.strip_layout_caches();
320 assert_ne!(doc_a, doc_b, "derived eq must still see cache differences");
321 let mut doc_a2 = doc_a.clone();
322 doc_a2.strip_layout_caches();
323 assert_eq!(doc_a2, doc_b, "normalized copies must compare equal");
324 }
325 }
326}