Skip to main content

hwpforge_core/
layout.rs

1//! Line layout cache (줄 조판 캐시) — Hancom 이 문서에 저장하는 조판 결과.
2//!
3//! HWPX 의 `<hp:linesegarray>`/`<hp:lineseg>` 와 HWP5 의 `PARA_LINE_SEG`
4//! (36바이트 레코드) 가 같은 의미의 캐시를 나른다. 이 모듈은 그 캐시를
5//! 공유 모델로 승격한 **decode-only** 표현이다:
6//!
7//! - 디코더(HWPX/HWP5)는 wire 의 캐시를 [`LayoutCache`] 로 승격한다.
8//! - 인코더는 기본적으로 캐시를 **방출하지 않는다** (opt-in 전용).
9//!   기존 byte-splice 보존(`layout_carry`)·제거(`strip_line_segs`) 불변식은
10//!   그대로 유지된다.
11//! - 문서 동등성 비교(admission/golden)는 캐시를 정규화(제거)한 사본으로
12//!   수행한다 — [`crate::document::Document::strip_layout_caches`] 참조.
13//!
14//! 필드 이름·타입은 wire 를 그대로 미러링한다 (발명 금지 — KS X 6101
15//! `lineseg` 속성명 기준).
16
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20/// 한 줄의 조판 결과 (HWPX `<hp:lineseg>` 1개 / HWP5 LINE_SEG 36바이트 1개).
21///
22/// 모든 좌표·크기 단위는 HWPUNIT (1pt = 100). `vertpos` 는 본문 흐름
23/// 기준 상대값이며 쪽 단위로 리셋된다 (표 셀 안에서는 셀 상대값).
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
25pub struct LineSeg {
26    /// 줄 시작 텍스트 위치 (문단 텍스트의 UTF-16 코드유닛 오프셋).
27    pub textpos: u32,
28    /// 줄 세로 위치 (컨테이너 상대, 쪽/셀 단위 리셋).
29    pub vertpos: i32,
30    /// 줄 전체 높이.
31    pub vertsize: i32,
32    /// 텍스트 부분 높이.
33    pub textheight: i32,
34    /// 줄 상단에서 베이스라인까지 거리.
35    pub baseline: i32,
36    /// 줄 간격 (다음 줄과의 간격).
37    pub spacing: i32,
38    /// 컬럼 기준 가로 시작 위치.
39    pub horzpos: i32,
40    /// 줄 가로 폭 (컬럼 폭).
41    pub horzsize: i32,
42    /// 줄 플래그 비트필드 (wire 그대로 보존 — 해석하지 않음).
43    pub flags: u32,
44}
45
46/// 문단 하나의 줄 조판 캐시 (HWPX `<hp:linesegarray>` 전체).
47///
48/// [`crate::paragraph::Paragraph::layout_cache`] 로 부착된다.
49/// `lines` 는 wire 순서(첫 줄부터)를 그대로 보존한다.
50#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
51pub struct LayoutCache {
52    /// 줄 세그먼트 목록 (wire 순서).
53    pub lines: Vec<LineSeg>,
54}
55
56impl LayoutCache {
57    /// 주어진 줄 세그먼트들로 캐시를 만든다.
58    pub fn new(lines: Vec<LineSeg>) -> Self {
59        Self { lines }
60    }
61
62    /// 줄 수를 반환한다.
63    pub fn line_count(&self) -> usize {
64        self.lines.len()
65    }
66
67    /// 줄이 하나도 없으면 `true`.
68    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        // 구버전 to-json 산출물(layout_cache 필드 없음) 하위호환
136        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    // -----------------------------------------------------------------------
155    // 순회/정규화 완전성: 모든 문단 컨테이너를 한 문서에 담아 검증한다.
156    // -----------------------------------------------------------------------
157
158    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        /// 캐시가 박힌 문단 (텍스트로 방문 추적).
171        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        /// 모든 문단 컨테이너를 담은 문서: 본문·표 셀(중첩 표 포함)·표 캡션·
188        /// 머리말·꼬리말·바탕쪽·글상자(+캡션)·묶음(재귀)·메모(본문+앵커 run)·
189        /// 각주·타원 본문.
190        fn document_with_all_containers() -> (Document<crate::document::Draft>, usize) {
191            let mut expected = 0usize;
192
193            // 본문 + 중첩 표(셀 문단 안에 또 표) + 표 캡션
194            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; // host + mid-cell + inner-cell + caption
202
203            // 글상자(+캡션) — 캡션은 variant 필드라 직접 부착
204            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; // host + body + caption
216
217            // 묶음(재귀) — 자식 글상자
218            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; // host + child body
234
235            // 메모: 본문 + 앵커 run 속 표 셀
236            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; // host + body + anchor cell
246
247            // 각주 + 타원 본문 (한 문단에 함께)
248            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; // host + footnote + ellipse
262
263            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; // header + footer + master
272
273            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            // 대표 중첩 지점들이 실제로 방문됐는지
285            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}