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            // 표 수준 decode-only 캐시도 부착 — strip 이 문단 캐시와 함께
182            // 제거해야 한다 (out/in_margin 은 구조라 남아야 한다).
183            Table::new(vec![TableRow::new(vec![TableCell::new(
184                vec![cell_para],
185                HwpUnit::from_pt(100.0).unwrap(),
186            )])])
187            .with_layout_cache(crate::table::TableLayoutCache::new(
188                Some(HwpUnit::from_pt(56.7).unwrap()),
189                true,
190            ))
191            .with_out_margin(crate::table::TableMargin::default())
192        }
193
194        /// 모든 문단 컨테이너를 담은 문서: 본문·표 셀(중첩 표 포함)·표 캡션·
195        /// 머리말·꼬리말·바탕쪽·글상자(+캡션)·묶음(재귀)·메모(본문+앵커 run)·
196        /// 각주·타원 본문.
197        fn document_with_all_containers() -> (Document<crate::document::Draft>, usize) {
198            let mut expected = 0usize;
199
200            // 본문 + 중첩 표(셀 문단 안에 또 표) + 표 캡션
201            let inner = one_cell_table(cached_para("inner-cell"));
202            let mut mid_cell = cached_para("mid-cell");
203            mid_cell.add_run(Run::table(inner, CharShapeIndex::new(0)));
204            let outer = one_cell_table(mid_cell)
205                .with_caption(Caption::new(vec![cached_para("tbl-caption")], CaptionSide::Bottom));
206            let mut host = cached_para("tbl-host");
207            host.add_run(Run::table(outer, CharShapeIndex::new(0)));
208            expected += 4; // host + mid-cell + inner-cell + caption
209
210            // 글상자(+캡션) — 캡션은 variant 필드라 직접 부착
211            let mut textbox = Control::text_box(
212                vec![cached_para("textbox-body")],
213                HwpUnit::from_pt(100.0).unwrap(),
214                HwpUnit::from_pt(50.0).unwrap(),
215            );
216            if let Control::TextBox { caption, .. } = &mut textbox {
217                *caption =
218                    Some(Caption::new(vec![cached_para("textbox-caption")], CaptionSide::Top));
219            }
220            let mut tb_host = cached_para("textbox-host");
221            tb_host.add_run(Run::control(textbox, CharShapeIndex::new(0)));
222            expected += 3; // host + body + caption
223
224            // 묶음(재귀) — 자식 글상자
225            let child = Control::text_box(
226                vec![cached_para("group-child-body")],
227                HwpUnit::from_pt(10.0).unwrap(),
228                HwpUnit::from_pt(10.0).unwrap(),
229            );
230            let group = Control::Group {
231                children: vec![child],
232                width: HwpUnit::from_pt(10.0).unwrap(),
233                height: HwpUnit::from_pt(10.0).unwrap(),
234                horz_offset: 0,
235                vert_offset: 0,
236                inst_id: None,
237            };
238            let mut group_host = cached_para("group-host");
239            group_host.add_run(Run::control(group, CharShapeIndex::new(0)));
240            expected += 2; // host + child body
241
242            // 메모: 본문 + 앵커 run 속 표 셀
243            let memo = Control::memo_with_anchor(
244                vec![cached_para("memo-body")],
245                vec![Run::table(
246                    one_cell_table(cached_para("memo-anchor-cell")),
247                    CharShapeIndex::new(0),
248                )],
249            );
250            let mut memo_host = cached_para("memo-host");
251            memo_host.add_run(Run::control(memo, CharShapeIndex::new(0)));
252            expected += 3; // host + body + anchor cell
253
254            // 각주 + 타원 본문 (한 문단에 함께)
255            let mut note_host = cached_para("note-host");
256            note_host.add_run(Run::control(
257                Control::footnote(vec![cached_para("footnote-body")]),
258                CharShapeIndex::new(0),
259            ));
260            note_host.add_run(Run::control(
261                Control::ellipse_with_text(
262                    HwpUnit::from_pt(20.0).unwrap(),
263                    HwpUnit::from_pt(20.0).unwrap(),
264                    vec![cached_para("ellipse-body")],
265                ),
266                CharShapeIndex::new(0),
267            ));
268            expected += 3; // host + footnote + ellipse
269
270            let mut section = Section::with_paragraphs(
271                vec![host, tb_host, group_host, memo_host, note_host],
272                PageSettings::a4(),
273            );
274            section.headers.push(HeaderFooter::all_pages(vec![cached_para("header")]));
275            section.footers.push(HeaderFooter::all_pages(vec![cached_para("footer")]));
276            section.master_pages =
277                Some(vec![MasterPage::new(ApplyPageType::Both, vec![cached_para("master")])]);
278            expected += 3; // header + footer + master
279
280            let mut doc = Document::new();
281            doc.add_section(section);
282            (doc, expected)
283        }
284
285        #[test]
286        fn for_each_paragraph_mut_visits_every_container() {
287            let (mut doc, expected) = document_with_all_containers();
288            let mut visited = Vec::new();
289            doc.for_each_paragraph_mut(|p| visited.push(p.text_content()));
290            assert_eq!(visited.len(), expected, "visited: {visited:?}");
291            // 대표 중첩 지점들이 실제로 방문됐는지
292            for needle in [
293                "inner-cell",
294                "tbl-caption",
295                "textbox-caption",
296                "group-child-body",
297                "memo-anchor-cell",
298                "footnote-body",
299                "master",
300            ] {
301                assert!(visited.iter().any(|t| t == needle), "missing {needle}: {visited:?}");
302            }
303        }
304
305        #[test]
306        fn strip_layout_caches_clears_every_container() {
307            let (mut doc, expected) = document_with_all_containers();
308            doc.strip_layout_caches();
309            let mut remaining = 0;
310            let mut total = 0;
311            let mut table_caches = 0;
312            let mut table_margins = 0;
313            doc.for_each_paragraph_mut(|p| {
314                total += 1;
315                if p.layout_cache.is_some() {
316                    remaining += 1;
317                }
318                for run in &p.runs {
319                    if let crate::run::RunContent::Table(t) = &run.content {
320                        table_caches += usize::from(t.layout_cache.is_some());
321                        table_margins += usize::from(t.out_margin.is_some());
322                    }
323                }
324            });
325            assert_eq!(total, expected);
326            assert_eq!(remaining, 0);
327            // 표 캐시도 제거 (중첩 표 포함 — one_cell_table 이 전부 부착).
328            assert_eq!(table_caches, 0, "table layout caches must be stripped");
329            // 반면 out_margin 은 구조 — strip 이 건드리면 안 된다.
330            assert!(table_margins >= 2, "structural margins must survive strip");
331        }
332
333        #[test]
334        fn cache_difference_breaks_eq_until_stripped() {
335            let (doc_a, _) = document_with_all_containers();
336            let (mut doc_b, _) = document_with_all_containers();
337            assert_eq!(doc_a, doc_b);
338            doc_b.strip_layout_caches();
339            assert_ne!(doc_a, doc_b, "derived eq must still see cache differences");
340            let mut doc_a2 = doc_a.clone();
341            doc_a2.strip_layout_caches();
342            assert_eq!(doc_a2, doc_b, "normalized copies must compare equal");
343        }
344    }
345}