Skip to main content

hwpforge_core/control/
mod.rs

1//! Control elements: text boxes, hyperlinks, footnotes, endnotes, etc.
2//!
3//! [`Control`] represents non-text inline elements within a document.
4//! The enum is `#[non_exhaustive]` so new control types can be added
5//! in future phases without a breaking change.
6//!
7//! TextBox, Footnote, and Endnote contain `Vec<Paragraph>` (recursive
8//! reference through the document tree). This is how HWP models inline
9//! frames and annotations.
10//!
11//! # Examples
12//!
13//! ```
14//! use hwpforge_core::control::Control;
15//! use hwpforge_core::paragraph::Paragraph;
16//! use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
17//!
18//! let link = Control::Hyperlink {
19//!     text: "Click here".to_string(),
20//!     url: "https://example.com".to_string(),
21//! };
22//! assert!(link.is_hyperlink());
23//! ```
24
25mod fields;
26mod metadata;
27mod shapes;
28
29pub use fields::*;
30pub use metadata::*;
31pub use shapes::*;
32
33use hwpforge_foundation::{
34    ArcType, BookmarkType, Color, CurveSegmentType, FieldType, HwpUnit, RefContentType, RefType,
35    VerticalAlign,
36};
37use schemars::JsonSchema;
38use serde::{Deserialize, Serialize};
39
40use crate::caption::Caption;
41use crate::chart::{
42    BarShape, ChartData, ChartGrouping, ChartType, LegendPosition, OfPieType, RadarStyle,
43    ScatterStyle, StockVariant,
44};
45use crate::error::{CoreError, CoreResult};
46use crate::object_id::ObjectId;
47use crate::paragraph::Paragraph;
48use crate::placement::ObjectPlacement;
49use crate::run::Run;
50
51/// An inline control element.
52///
53/// Controls are non-text elements that appear within a Run.
54/// Each variant carries its own data; the enum is `#[non_exhaustive]`
55/// for forward compatibility.
56///
57/// # Examples
58///
59/// ```
60/// use hwpforge_core::control::Control;
61/// use hwpforge_core::paragraph::Paragraph;
62/// use hwpforge_foundation::{HwpUnit, ParaShapeIndex, VerticalAlign};
63///
64/// let text_box = Control::TextBox {
65///     paragraphs: vec![Paragraph::new(ParaShapeIndex::new(0))],
66///     width: HwpUnit::from_mm(80.0).unwrap(),
67///     height: HwpUnit::from_mm(40.0).unwrap(),
68///     placement: None,
69///     caption: None,
70///     style: None,
71///     text_vertical_align: VerticalAlign::Top,
72/// };
73/// assert!(text_box.is_text_box());
74/// assert!(!text_box.is_hyperlink());
75/// ```
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
77#[non_exhaustive]
78pub enum Control {
79    /// An inline text box with its own paragraph content.
80    /// Maps to HWPX `<hp:rect>` + `<hp:drawText>` (drawing object, not control).
81    TextBox {
82        /// Paragraphs inside the text box.
83        paragraphs: Vec<Paragraph>,
84        /// Box width (HWPUNIT).
85        width: HwpUnit,
86        /// Box height (HWPUNIT).
87        height: HwpUnit,
88        /// Object placement / anchoring metadata (HWPX `<hp:pos>` block plus
89        /// the shape's `textWrap`/`textFlow`). `None` = the historical inline
90        /// default (treat-as-char, zero offset) — the encoder emits the legacy
91        /// inline placement for it; `Some` carries the full floating/anchored
92        /// placement. See [`ObjectPlacement`].
93        #[serde(default, skip_serializing_if = "Option::is_none")]
94        placement: Option<ObjectPlacement>,
95        /// Optional caption attached to this text box.
96        caption: Option<Caption>,
97        /// Optional visual style overrides (border color, fill, line width).
98        style: Option<ShapeStyle>,
99        /// Vertical alignment of the embedded text within the box.
100        /// Maps to HWPX `<hp:drawText><hp:subList vertAlign="...">` and HWP5
101        /// 문단 리스트 헤더 속성 bits 5–6. Defaults to [`VerticalAlign::Top`].
102        #[serde(default)]
103        text_vertical_align: VerticalAlign,
104    },
105
106    /// A hyperlink with display text and URL.
107    Hyperlink {
108        /// Visible text of the link.
109        text: String,
110        /// Target URL.
111        url: String,
112    },
113
114    /// A footnote containing paragraph content.
115    /// Maps to HWPX `<hp:ctrl><hp:footNote>`.
116    ///
117    /// **번호 재계산 계약**: wire 의 종류별 순번 캐시(`footNote@number`)와
118    /// 접미 문자(`suffixChar`), 본문 선두의 자동 번호 머리(`<hp:autoNum
119    /// numType="FOOTNOTE">` + 구분 공백)는 표현 산물이라 Core 로 나르지
120    /// 않는다 — 인코더가 문서 순서로 재계산·재주입한다. 비표준(custom)
121    /// suffix 의 typed 보존은 section note-format 승격(별도 승인) 대상.
122    Footnote {
123        /// Object identity for cross-ref linking (optional). Shares the
124        /// [`ObjectId`] space with [`RefTarget::Object`](crate::control::RefTarget::Object).
125        inst_id: Option<ObjectId>,
126        /// Paragraphs that form the footnote body.
127        paragraphs: Vec<Paragraph>,
128    },
129
130    /// An endnote containing paragraph content.
131    /// Maps to HWPX `<hp:ctrl><hp:endNote>`.
132    ///
133    /// 번호 재계산 계약은 [`Control::Footnote`] 와 동일 (`numType="ENDNOTE"`,
134    /// 독립 카운터).
135    Endnote {
136        /// Object identity for cross-ref linking (optional). Shares the
137        /// [`ObjectId`] space with [`RefTarget::Object`](crate::control::RefTarget::Object).
138        inst_id: Option<ObjectId>,
139        /// Paragraphs that form the endnote body.
140        paragraphs: Vec<Paragraph>,
141    },
142
143    /// A line drawing object (2 endpoints).
144    /// Maps to HWPX `<hp:line>`.
145    Line {
146        /// Start point (x, y in HWPUNIT).
147        start: ShapePoint,
148        /// End point (x, y in HWPUNIT).
149        end: ShapePoint,
150        /// Bounding box width (HWPUNIT).
151        width: HwpUnit,
152        /// Bounding box height (HWPUNIT).
153        height: HwpUnit,
154        /// Object placement / anchoring metadata (HWPX `<hp:pos>` block plus
155        /// the shape's `textWrap`/`textFlow`). `None` = the historical inline
156        /// default (treat-as-char, zero offset) — the encoder emits the legacy
157        /// inline placement for it; `Some` carries the full floating/anchored
158        /// placement. See [`ObjectPlacement`].
159        #[serde(default, skip_serializing_if = "Option::is_none")]
160        placement: Option<ObjectPlacement>,
161        /// Optional caption attached to this line.
162        caption: Option<Caption>,
163        /// Optional visual style overrides (border color, fill, line width).
164        style: Option<ShapeStyle>,
165    },
166
167    /// An ellipse (or circle) drawing object.
168    /// Maps to HWPX `<hp:ellipse>`.
169    Ellipse {
170        /// Center point (x, y in HWPUNIT).
171        center: ShapePoint,
172        /// Axis 1 endpoint (defines semi-major axis direction and length).
173        axis1: ShapePoint,
174        /// Axis 2 endpoint (perpendicular to axis1, defines semi-minor axis).
175        axis2: ShapePoint,
176        /// Bounding box width (HWPUNIT).
177        width: HwpUnit,
178        /// Bounding box height (HWPUNIT).
179        height: HwpUnit,
180        /// Object placement / anchoring metadata (HWPX `<hp:pos>` block plus
181        /// the shape's `textWrap`/`textFlow`). `None` = the historical inline
182        /// default (treat-as-char, zero offset) — the encoder emits the legacy
183        /// inline placement for it; `Some` carries the full floating/anchored
184        /// placement. See [`ObjectPlacement`].
185        #[serde(default, skip_serializing_if = "Option::is_none")]
186        placement: Option<ObjectPlacement>,
187        /// Optional text content inside the ellipse.
188        paragraphs: Vec<Paragraph>,
189        /// Optional caption attached to this ellipse.
190        caption: Option<Caption>,
191        /// Optional visual style overrides (border color, fill, line width).
192        style: Option<ShapeStyle>,
193        /// Vertical alignment of the embedded text within the ellipse.
194        /// Maps to HWPX `<hp:drawText><hp:subList vertAlign="...">` and HWP5
195        /// 문단 리스트 헤더 속성 bits 5–6. Defaults to [`VerticalAlign::Top`].
196        #[serde(default)]
197        text_vertical_align: VerticalAlign,
198    },
199
200    /// A HWP5 chart carried as opaque OOXML + OLE blob passthrough.
201    ///
202    /// Used when chart data is extracted from a HWP5 BinData OLE container
203    /// and emitted to HWPX without round-tripping through the structured
204    /// [`Control::Chart`] data model. Renders in 한컴 via the `<hp:switch>`
205    /// block with full OOXML chart inside `<hp:case>` and an OLE fallback
206    /// inside `<hp:default>`.
207    ///
208    /// Wave 4c passthrough: the chart XML and OLE bytes are carried as-is
209    /// from the source HWP5 file. The encoder writes:
210    /// - `Chart/chartN.xml` (NOT registered in manifest — gotcha #5)
211    /// - `BinData/oleN.ole` (registered in `content.hpf` as `application/ole`)
212    /// - section `<hp:switch>` with `<hp:case>` chart + `<hp:default>` ole
213    EmbeddedChart {
214        /// Full OOXML chart XML (starts with `<?xml`, contains `<c:chartSpace>`).
215        chart_xml: String,
216        /// Raw OLE2 compound file bytes for `<hp:ole>` fallback rendering.
217        ole_bytes: Vec<u8>,
218        /// Chart width (HWPUNIT).
219        width: HwpUnit,
220        /// Chart height (HWPUNIT).
221        height: HwpUnit,
222        /// Object placement / anchoring metadata (HWPX `<hp:pos>` block plus
223        /// the shape's `textWrap`/`textFlow`). `None` = the historical inline
224        /// default (treat-as-char, zero offset) — the encoder emits the legacy
225        /// inline placement for it; `Some` carries the full floating/anchored
226        /// placement. See [`ObjectPlacement`].
227        #[serde(default, skip_serializing_if = "Option::is_none")]
228        placement: Option<ObjectPlacement>,
229    },
230
231    /// A pure rectangle drawing object (no embedded text).
232    ///
233    /// Distinct from [`Control::TextBox`], which uses `<hp:rect>` with a
234    /// `<hp:drawText>` child for inline text. A pure `Rect` carries only the
235    /// rectangle geometry and visual style and emits `<hp:rect>` without
236    /// `<hp:drawText>`.
237    Rect {
238        /// Bounding box width (HWPUNIT).
239        width: HwpUnit,
240        /// Bounding box height (HWPUNIT).
241        height: HwpUnit,
242        /// Object placement / anchoring metadata (HWPX `<hp:pos>` block plus
243        /// the shape's `textWrap`/`textFlow`). `None` = the historical inline
244        /// default (treat-as-char, zero offset) — the encoder emits the legacy
245        /// inline placement for it; `Some` carries the full floating/anchored
246        /// placement. See [`ObjectPlacement`].
247        #[serde(default, skip_serializing_if = "Option::is_none")]
248        placement: Option<ObjectPlacement>,
249        /// Optional caption attached to this rectangle.
250        caption: Option<Caption>,
251        /// Optional visual style overrides (border color, fill, line width).
252        style: Option<ShapeStyle>,
253    },
254
255    /// A polygon drawing object (3+ vertices).
256    /// Maps to HWPX `<hp:polygon>`.
257    Polygon {
258        /// Ordered list of vertices (minimum 3).
259        vertices: Vec<ShapePoint>,
260        /// Bounding box width (HWPUNIT).
261        width: HwpUnit,
262        /// Bounding box height (HWPUNIT).
263        height: HwpUnit,
264        /// Object placement / anchoring metadata (HWPX `<hp:pos>` block plus
265        /// the shape's `textWrap`/`textFlow`). `None` = the historical inline
266        /// default (treat-as-char, zero offset) — the encoder emits the legacy
267        /// inline placement for it; `Some` carries the full floating/anchored
268        /// placement. See [`ObjectPlacement`].
269        #[serde(default, skip_serializing_if = "Option::is_none")]
270        placement: Option<ObjectPlacement>,
271        /// Optional text content inside the polygon.
272        paragraphs: Vec<Paragraph>,
273        /// Optional caption attached to this polygon.
274        caption: Option<Caption>,
275        /// Optional visual style overrides (border color, fill, line width).
276        style: Option<ShapeStyle>,
277        /// Vertical alignment of the embedded text within the polygon.
278        /// Maps to HWPX `<hp:drawText><hp:subList vertAlign="...">` and HWP5
279        /// 문단 리스트 헤더 속성 bits 5–6. Defaults to [`VerticalAlign::Top`].
280        #[serde(default)]
281        text_vertical_align: VerticalAlign,
282    },
283
284    /// An inline equation (수식) using HancomEQN script format.
285    /// Maps to HWPX `<hp:equation>` with `<hp:script>` child.
286    ///
287    /// Equations have NO shape common block (no offset, orgSz, curSz, flip,
288    /// rotation, lineShape, fillBrush, shadow). Only sz + pos + outMargin + script.
289    Equation {
290        /// HancomEQN script text (e.g. `"{a+b} over {c+d}"`).
291        script: String,
292        /// Bounding box width (HWPUNIT).
293        width: HwpUnit,
294        /// Bounding box height (HWPUNIT).
295        height: HwpUnit,
296        /// Baseline position (51-90 typical range).
297        base_line: u32,
298        /// Text color.
299        text_color: Color,
300        /// Font name (typically `"HancomEQN"`).
301        font: String,
302        /// Wave 12p Step 2c: instance ID for cross-ref target lookup.
303        /// HWP5 변환 시 `eqed` CtrlHeader trailer 의 instance ID 가
304        /// 채워지고, HWPX encoder 가 `<hp:equation id="...">` attribute
305        /// 로 emit. `None` 이면 encoder fallback 허용.
306        inst_id: Option<ObjectId>,
307    },
308
309    /// An OOXML chart embedded in the document.
310    /// Maps to HWPX `<hp:switch><hp:case><hp:chart>` with separate Chart XML file.
311    ///
312    /// Charts have NO shape common block (like Equation): only sz + pos + outMargin.
313    Chart {
314        /// Chart type (18 variants covering all OOXML chart types).
315        chart_type: ChartType,
316        /// Chart data (category-based or XY-based).
317        data: ChartData,
318        /// Chart width (HWPUNIT, default ~32250 ≈ 114mm).
319        width: HwpUnit,
320        /// Chart height (HWPUNIT, default ~18750 ≈ 66mm).
321        height: HwpUnit,
322        /// Optional chart title.
323        title: Option<String>,
324        /// Legend position.
325        legend: LegendPosition,
326        /// Series grouping mode.
327        grouping: ChartGrouping,
328        /// 3D bar/column shape (None = default Box).
329        bar_shape: Option<BarShape>,
330        /// Exploded pie/doughnut percentage (None = not exploded, Some(25) = 25% explosion).
331        explosion: Option<u32>,
332        /// Pie-of-pie or bar-of-pie sub-type (None = default pie-of-pie).
333        of_pie_type: Option<OfPieType>,
334        /// Radar chart rendering style (None = default Standard).
335        radar_style: Option<RadarStyle>,
336        /// Surface chart wireframe mode (None = default solid).
337        wireframe: Option<bool>,
338        /// 3D bubble effect (None = default flat).
339        bubble_3d: Option<bool>,
340        /// Scatter chart style (None = default Dots).
341        scatter_style: Option<ScatterStyle>,
342        /// Show data point markers on line charts (None = no markers).
343        show_markers: Option<bool>,
344        /// Stock chart sub-variant (None = default HLC, 3 series).
345        ///
346        /// VHLC and VOHLC generate a composite `<c:plotArea>` with both
347        /// `<c:barChart>` (volume) and `<c:stockChart>` (price) elements.
348        stock_variant: Option<StockVariant>,
349    },
350
351    /// Dutmal (덧말): annotation text displayed above or below main text.
352    /// Maps to HWPX `<hp:dutmal>`.
353    Dutmal {
354        /// Main text that receives the annotation.
355        main_text: String,
356        /// Annotation text displayed above/below.
357        sub_text: String,
358        /// Position of the annotation relative to main text.
359        position: DutmalPosition,
360        /// Size ratio of annotation text relative to main (0 = auto).
361        sz_ratio: u32,
362        /// Alignment of the annotation text.
363        align: DutmalAlign,
364        /// Optional metadata that mirrors HWPX `<hp:dutmal>` attributes
365        /// HwpForge doesn't promote to typed fields yet — currently
366        /// carries `option` verbatim so HWP5↔HWPX round-trips preserve
367        /// it. `#[non_exhaustive]` so future fields are additive.
368        metadata: DutmalMetadata,
369    },
370
371    /// Compose (글자겹침): overlaid/combined characters.
372    /// Maps to HWPX `<hp:compose>`.
373    Compose {
374        /// The combined text (e.g. "12" for two overlaid digits).
375        compose_text: String,
376        /// Circle/frame type for the composition.
377        circle_type: String,
378        /// Character size adjustment (-3 = slightly smaller).
379        char_sz: i32,
380        /// Composition layout type.
381        compose_type: String,
382        /// 10 `<hp:charPr prIDRef="N"/>` references (HWPX `charPrCnt` is
383        /// fixed at 10). `u32::MAX` is the "no override" sentinel —
384        /// 한컴 emits it for unused slots. A `Vec` shorter or longer
385        /// than 10 is normalized by the HWPX encoder (pad / truncate).
386        char_pr_ids: Vec<u32>,
387    },
388
389    /// An arc (partial ellipse) drawing object.
390    /// Maps to HWPX `<hp:ellipse>` with `hasArcPr="1"`.
391    Arc {
392        /// Arc type (normal open arc, pie/sector, chord).
393        arc_type: ArcType,
394        /// Center point of the parent ellipse.
395        center: ShapePoint,
396        /// Axis 1 endpoint (semi-major axis).
397        axis1: ShapePoint,
398        /// Axis 2 endpoint (semi-minor axis).
399        axis2: ShapePoint,
400        /// Arc start point 1.
401        start1: ShapePoint,
402        /// Arc end point 1.
403        end1: ShapePoint,
404        /// Arc start point 2.
405        start2: ShapePoint,
406        /// Arc end point 2.
407        end2: ShapePoint,
408        /// Bounding box width (HWPUNIT).
409        width: HwpUnit,
410        /// Bounding box height (HWPUNIT).
411        height: HwpUnit,
412        /// Object placement / anchoring metadata (HWPX `<hp:pos>` block plus
413        /// the shape's `textWrap`/`textFlow`). `None` = the historical inline
414        /// default (treat-as-char, zero offset) — the encoder emits the legacy
415        /// inline placement for it; `Some` carries the full floating/anchored
416        /// placement. See [`ObjectPlacement`].
417        #[serde(default, skip_serializing_if = "Option::is_none")]
418        placement: Option<ObjectPlacement>,
419        /// Optional caption attached to this arc.
420        caption: Option<Caption>,
421        /// Optional visual style overrides.
422        style: Option<ShapeStyle>,
423    },
424
425    /// A curve drawing object (bezier/polyline).
426    /// Maps to HWPX `<hp:curve>`.
427    Curve {
428        /// Ordered control points for the curve path.
429        points: Vec<ShapePoint>,
430        /// Segment types (one per segment between points).
431        segment_types: Vec<CurveSegmentType>,
432        /// Bounding box width (HWPUNIT).
433        width: HwpUnit,
434        /// Bounding box height (HWPUNIT).
435        height: HwpUnit,
436        /// Object placement / anchoring metadata (HWPX `<hp:pos>` block plus
437        /// the shape's `textWrap`/`textFlow`). `None` = the historical inline
438        /// default (treat-as-char, zero offset) — the encoder emits the legacy
439        /// inline placement for it; `Some` carries the full floating/anchored
440        /// placement. See [`ObjectPlacement`].
441        #[serde(default, skip_serializing_if = "Option::is_none")]
442        placement: Option<ObjectPlacement>,
443        /// Optional caption attached to this curve.
444        caption: Option<Caption>,
445        /// Optional visual style overrides.
446        style: Option<ShapeStyle>,
447    },
448
449    /// A connect line drawing object (line with control points for routing).
450    /// Maps to HWPX `<hp:connectLine>`.
451    ConnectLine {
452        /// Start point of the connect line.
453        start: ShapePoint,
454        /// End point of the connect line.
455        end: ShapePoint,
456        /// Intermediate control points for routing.
457        control_points: Vec<ShapePoint>,
458        /// Connect line type (e.g. "STRAIGHT", "BENT", "CURVED").
459        connect_type: String,
460        /// Bounding box width (HWPUNIT).
461        width: HwpUnit,
462        /// Bounding box height (HWPUNIT).
463        height: HwpUnit,
464        /// Object placement / anchoring metadata (HWPX `<hp:pos>` block plus
465        /// the shape's `textWrap`/`textFlow`). `None` = the historical inline
466        /// default (treat-as-char, zero offset) — the encoder emits the legacy
467        /// inline placement for it; `Some` carries the full floating/anchored
468        /// placement. See [`ObjectPlacement`].
469        #[serde(default, skip_serializing_if = "Option::is_none")]
470        placement: Option<ObjectPlacement>,
471        /// Optional caption attached to this connect line.
472        caption: Option<Caption>,
473        /// Optional visual style overrides.
474        style: Option<ShapeStyle>,
475    },
476
477    /// A group of drawing objects (묶음 객체 / 개체 묶기).
478    /// Maps to HWPX `<hp:container>` and HWP5 `gso` → `ShapeComponent` with
479    /// the `"$con"` type tag wrapping child `ShapeComponent`s.
480    ///
481    /// `children` reuses the shape `Control` variants (`Rect`/`Ellipse`/
482    /// `Line`/`Polygon`/`Curve`/`ConnectLine`/`Image`/`EmbeddedChart` and,
483    /// recursively, `Group`). Non-shape variants are rejected by
484    /// `validate` rather than the type system, matching how every other
485    /// recursive container (`TextBox`/`Footnote`/`Memo.content`) carries a
486    /// loose `Vec<Paragraph>` / `Vec<Run>`.
487    Group {
488        /// Child drawing objects, in z-order. May nest further `Group`s.
489        children: Vec<Control>,
490        /// Bounding box width (HWPUNIT).
491        width: HwpUnit,
492        /// Bounding box height (HWPUNIT).
493        height: HwpUnit,
494        /// Object placement / anchoring metadata (HWPX `<hp:pos>` block plus
495        /// the shape's `textWrap`/`textFlow`). `None` = the historical inline
496        /// default (treat-as-char, zero offset) — the encoder emits the legacy
497        /// inline placement for it; `Some` carries the full floating/anchored
498        /// placement. See [`ObjectPlacement`].
499        #[serde(default, skip_serializing_if = "Option::is_none")]
500        placement: Option<ObjectPlacement>,
501        /// HWP5 ParaHeader / GSO trailer instance ID, mirrored to the
502        /// HWPX `<hp:container instid>` attribute. `None` = not carried.
503        inst_id: Option<ObjectId>,
504    },
505
506    /// A TextArt (글맵시) decorative warped-text object.
507    /// Maps to HWPX `<hp:textart>` with `<hp:textartPr>`.
508    ///
509    /// TextArt warps a short string into a shape (wave, arch, circle, …) and
510    /// renders it as a drawing object. The HWP5 wire stores `shape` as an
511    /// integer enum (`0..=54`); the HWPX wire stores it as a string name
512    /// (e.g. `"WAVE2"`). This carries the HWPX string form directly.
513    TextArt {
514        /// The displayed text content.
515        text: String,
516        /// HWPX `textShape` name (e.g. `"WAVE2"`). One of 55 known shapes.
517        shape: String,
518        /// Font family name (e.g. `"함초롬바탕"`).
519        font_name: String,
520        /// Font style label (e.g. `"보통"`).
521        font_style: String,
522        /// HWPX `align` value within the textart (e.g. `"LEFT"`).
523        align: String,
524        /// Line spacing (percent, HWPX `lineSpacing`).
525        line_spacing: u32,
526        /// Character spacing (percent, HWPX `charSpacing`).
527        char_spacing: u32,
528        /// Bounding box width (HWPUNIT).
529        width: HwpUnit,
530        /// Bounding box height (HWPUNIT).
531        height: HwpUnit,
532        /// Object placement / anchoring metadata (HWPX `<hp:pos>` block plus
533        /// the shape's `textWrap`/`textFlow`). `None` = the historical inline
534        /// default (treat-as-char, zero offset) — the encoder emits the legacy
535        /// inline placement for it; `Some` carries the full floating/anchored
536        /// placement. See [`ObjectPlacement`].
537        #[serde(default, skip_serializing_if = "Option::is_none")]
538        placement: Option<ObjectPlacement>,
539        /// Fill color (the `<hc:winBrush faceColor>` of the textart glyphs).
540        /// `None` = no explicit fill carried.
541        fill_color: Option<Color>,
542        /// HWP5 GSO trailer instance ID, mirrored to HWPX `<hp:textart instid>`.
543        /// `None` = not carried.
544        inst_id: Option<ObjectId>,
545    },
546
547    /// A bookmark marking a named location in the document.
548    /// Maps to HWPX `<hp:ctrl><hp:bookmark>` (point) or `fieldBegin/fieldEnd type="BOOKMARK"` (span).
549    Bookmark {
550        /// Bookmark name (unique within the document).
551        name: String,
552        /// Type: point bookmark or span start/end.
553        bookmark_type: BookmarkType,
554    },
555
556    /// A cross-reference (상호참조) to a bookmark, footnote, endnote,
557    /// outline heading, table/figure/equation caption.
558    ///
559    /// Maps to HWPX `fieldBegin type="CROSSREF"` with parameters.
560    ///
561    /// Wave 12m Phase 2: `target_name: String` 가 `target: RefTarget` 로
562    /// 변경 (breaking). 책갈피 이름과 한컴 자동 ID (#<id>) 가 타입으로
563    /// 구분되어 caller 가 의미를 정확히 알 수 있음.
564    ///
565    /// Wave 12m Phase 2 Step 4 (breaking): `display_text: String` 추가.
566    /// HWPX wire 는 `<hp:fieldBegin>` 과 `<hp:fieldEnd>` 사이의 visible run
567    /// 으로 display text 를 embedding 한다. HWP5 `%xrf` wire 는 display
568    /// text 를 직접 carry 하지 않고 ParaText 본문에 풀어 두지만, projection
569    /// 이 FieldBegin..FieldEnd span 을 읽어 이 필드에 채워 넣는다.
570    /// 빈 문자열은 "display text 없음" 의미 (Hyperlink::text 와 동일).
571    CrossRef {
572        /// Reference target — Bookmark name (`Name(String)`) or system
573        /// id (`SystemId(u64)`) or unparseable raw (`Raw(String)`).
574        target: RefTarget,
575        /// What kind of target is being referenced.
576        ref_type: RefType,
577        /// What content to display at the reference site.
578        content_type: RefContentType,
579        /// Whether to render the reference as a clickable hyperlink.
580        as_hyperlink: bool,
581        /// Visible body text shown between `fieldBegin` and `fieldEnd`
582        /// in the encoded wire. HWP5 sources this from the FieldBegin
583        /// span; native builders may leave this empty.
584        display_text: String,
585    },
586
587    /// A press-field (누름틀) — an interactive form field.
588    /// Maps to HWPX `fieldBegin type="CLICK_HERE"` with parameters and `metaTag`.
589    Field {
590        /// Field type (ClickHere, Date, Time, etc.).
591        field_type: FieldType,
592        /// Hint/visible text shown in the field placeholder.
593        hint_text: Option<String>,
594        /// Help text shown when hovering or clicking the field.
595        help_text: Option<String>,
596        /// Form-mode identifier used to reference the field programmatically.
597        /// Maps to HWPX `fieldBegin name="..."` attribute. `None` represents
598        /// the empty string convention (한컴 wire stores it as a 0-length BSTR).
599        name: Option<String>,
600        /// Cached resolved value rendered between `<hp:fieldBegin>` and
601        /// `<hp:fieldEnd>` (e.g. the author name, the locale-formatted date).
602        /// HWP5 sources this from the FieldBegin..FieldEnd span; 한컴 native
603        /// HWPX carries the same cached render and recomputes it on save.
604        /// Empty string = "no cached value" (same convention as
605        /// [`Self::CrossRef::display_text`] / [`Self::Hyperlink::text`]).
606        /// For `ClickHere` this is the user-filled value; an unfilled field
607        /// carries either the same string as [`Self::Field::hint_text`]
608        /// (decoded from a native body) or the empty string (constructed).
609        /// When empty, the HWPX encoder emits `hint_text` as the body.
610        ///
611        /// An empty body triggers 한컴's "낮은 보안 수준 복구" warning on
612        /// open for SUMMERY fields (#120/#136) — carrying the verbatim
613        /// source value avoids it.
614        display_text: String,
615    },
616
617    /// A memo (메모) annotation attached to text.
618    ///
619    /// Maps to HWPX `fieldBegin type="MEMO"` + anchor body runs +
620    /// `fieldEnd` flat inside one `<hp:run>`. The memo's body lives inside
621    /// `fieldBegin`'s `<hp:subList>`; the *anchor* runs sit between
622    /// `fieldBegin` and `fieldEnd` so 한컴 can pair the markers and render
623    /// the `[메모 시작]…[메모 끝]` UI labels instead of generic
624    /// `[메모 시작]…[필드 끝]`.
625    ///
626    /// Wave 12e: `author`/`date` fields removed (no wire path populated
627    /// them).
628    ///
629    /// Wave 12f: `anchor_runs` added. Without it, the encoder produced an
630    /// empty `<hp:t/>` between `fieldBegin` and `fieldEnd`, which 한컴 reads
631    /// as an unpaired field — visible bug.
632    Memo {
633        /// Paragraphs forming the memo body content (rendered in
634        /// `<hp:subList>`).
635        content: Vec<Paragraph>,
636        /// Runs that form the visible *anchor* text — the body span the memo
637        /// is attached to. Encoders interleave these between `fieldBegin`
638        /// and `fieldEnd` inside one `<hp:run>`. Should normally hold only
639        /// `RunContent::Text`; other variants are downgraded by the encoder
640        /// with a warning (memos cannot anchor on tables/images/nested
641        /// controls in HWPX).
642        anchor_runs: Vec<Run>,
643        /// HWPX `<hp:parameters>` for the memo. Carrying these as a
644        /// dedicated [`MemoMetadata`] (instead of half-empty hard-coded
645        /// values) keeps the metadata format-agnostic — encoders for HWPX
646        /// (and any future format with similar metadata) consume the same
647        /// struct.
648        metadata: MemoMetadata,
649    },
650
651    /// An index mark for building a document index (찾아보기).
652    /// Maps to HWPX `<hp:ctrl><hp:indexmark>`.
653    IndexMark {
654        /// Primary index key (required).
655        primary: String,
656        /// Secondary (sub-entry) index key.
657        secondary: Option<String>,
658    },
659
660    /// An unknown SUMMERY (`%smr`) `$token` carried verbatim for forward
661    /// compatibility (Wave 12n).
662    ///
663    /// Wave 12n only models the five HwpForge-observed tokens (`$author`,
664    /// `$lastsaveby`, `$createtime`, `$modifiedtime`, `$title`) as typed
665    /// [`FieldType`] variants. Any other `%smr` Command (e.g. additional
666    /// 한컴 metadata tokens not yet measured) is preserved here instead of
667    /// being silently coerced to `ClickHere`.
668    UnknownSummary {
669        /// Raw `Command` string after envelope (e.g. `"$company"`).
670        token: String,
671        /// Cached resolved value rendered between `fieldBegin`/`fieldEnd`.
672        /// Same semantics as [`Self::Field::display_text`]; empty = none.
673        display_text: String,
674    },
675
676    /// A `%dte` date/time **format-pattern** field (Wave 12n).
677    ///
678    /// HWP5 family `%dte` (ctrl_id `0x2564_7465`) used by 한컴
679    /// `입력 → 날짜/시간/파일 이름 → 날짜/시간 코드` menu. Unlike SUMMERY
680    /// (which carries semantic tokens like `$createtime`), `%dte` carries
681    /// a raw format pattern string (e.g. `"\:1년 2월 3일 (6);0;"` for date,
682    /// `"T\:;0;"` for time-only). The HWP5 wire format pattern is
683    /// smithy-internal; the format-agnostic core retains only the derived
684    /// `is_time_mode` helper (was based on the `T` prefix at projection time).
685    DateCodeField {
686        /// Helper view: `true` for a time-only (`T`-prefixed) format,
687        /// `false` for a date format. Derived at HWP5 projection time.
688        is_time_mode: bool,
689        /// Cached resolved value rendered between `fieldBegin`/`fieldEnd`
690        /// (the locale-formatted date/time string). Same semantics as
691        /// [`Self::Field::display_text`]; empty = none.
692        display_text: String,
693    },
694
695    /// A `%pat` path / file-name field (Wave 12n).
696    ///
697    /// HWP5 family `%pat` (ctrl_id `0x2570_6174`) emitted by 한컴
698    /// `상용구 → 파일 이름 / 파일 이름과 경로`. Uses `$P` (path) and
699    /// `$F` (file name) format codes.
700    PathField {
701        /// Typed variant of the observed `Command` pattern.
702        command: PathFieldCommand,
703        /// Cached resolved value rendered between `fieldBegin`/`fieldEnd`
704        /// (the absolute path/file name 한컴 last evaluated). Same semantics
705        /// as [`Self::Field::display_text`]; empty = none. 한컴 recomputes
706        /// `$P`/`$F` against the file's on-disk path on save, but an empty
707        /// body on open triggers the recovery warning (#120).
708        display_text: String,
709    },
710
711    /// An `atno` **inline** page number control (Wave 12n).
712    ///
713    /// HWP5 family `atno` (ctrl_id `0x6174_6E6F`) used by 한컴
714    /// `상용구 → 현재 쪽 번호 / 전체 쪽수 / 현재 쪽/전체 쪽수`. Distinct
715    /// from `pgnp` (section-level page numbering control already modeled
716    /// as `Section.page_number`). Inline `atno` renders to HWPX
717    /// `<hp:autoNum>` inside a `<hp:run>`.
718    ///
719    /// The 16-byte wire envelope carries a single 4-byte flag that
720    /// distinguishes current-page from total-pages; the HWP5 projection
721    /// maps it to [`InlinePageKind`].
722    InlinePageNumber {
723        /// Typed variant of the observed `flag` byte.
724        kind: InlinePageKind,
725    },
726
727    /// A `nwno` **새 번호 지정** control — restarts a numbering counter
728    /// from this position (HWPX `<hp:newNum num numType>`).
729    ///
730    /// HWP5 wire (native fixture 실측 2026-08-12): 10 bytes —
731    /// `ctrl_id + 속성 u32(bits 0-3 = kind) + 번호 u16`, anchored in the
732    /// paragraph text by a `0x15` inline marker. 한컴 [쪽]→[새 번호로
733    /// 시작] 이 쓰는 경로다 (corpus: 407/2,231 문서). The restart applies
734    /// from the **physical page containing the control** (PDF 실측:
735    /// 1쪽 앵커 = 전문서 재번호, 2쪽 앵커 = `1, 7`).
736    NewNumber {
737        /// Which counter restarts ([`NewNumberKind::Page`] is the only
738        /// renderer-consumed kind; others carry through to HWPX).
739        kind: NewNumberKind,
740        /// The new counter value (fixture sentinel `7`). `u32` covers both
741        /// wires losslessly — HWP5 `nwno` carries u16, HWPX inline
742        /// `hp:newNum@num` is `xs:integer` (so `0` is wire-valid and the
743        /// HWPX encoder carries it verbatim into the regenerated number
744        /// cache; range enforcement, if any, belongs to Core validation).
745        number: u32,
746    },
747
748    /// A `pghd` **감추기** control — hides the listed page furniture on the
749    /// **physical page containing the control** (HWPX `<hp:pageHiding>`).
750    ///
751    /// HWP5 wire (native fixture 실측 2026-08-12): 8 bytes — `ctrl_id +
752    /// 속성 u32`, bits 0-5 = 머리말/꼬리말/바탕쪽/테두리/배경/쪽번호
753    /// (secd 속성 word 와 동일 배열 — libhwp·hwp-rs 의 다른 주장은 반증).
754    /// `0x15` inline marker 로 앵커. corpus: 403/2,231 문서, 87% 가
755    /// 쪽번호만 감춤 (표지 관행). 감춤은 쪽번호 **카운터 전진을 막지
756    /// 않는다** (PDF 실측: `1, _, 3`).
757    PageHiding {
758        /// 머리말 감춤 (bit 0, `hideHeader`).
759        hide_header: bool,
760        /// 꼬리말 감춤 (bit 1, `hideFooter`).
761        hide_footer: bool,
762        /// 바탕쪽 감춤 (bit 2, `hideMasterPage`).
763        hide_master_page: bool,
764        /// 테두리 감춤 (bit 3, `hideBorder`).
765        hide_border: bool,
766        /// 배경 감춤 (bit 4, `hideFill`).
767        hide_fill: bool,
768        /// 쪽번호 감춤 (bit 5, `hidePageNum`).
769        hide_page_num: bool,
770    },
771
772    /// An unrecognized control element preserved for round-trip fidelity.
773    ///
774    /// `tag` holds the element's tag name or type identifier.
775    /// `data` holds optional serialized content for lossless preservation.
776    Unknown {
777        /// Tag name or type identifier of the unrecognized element.
778        tag: String,
779        /// Optional serialized data for round-trip preservation.
780        data: Option<String>,
781    },
782}
783
784impl Control {
785    /// 이 컨트롤 안에 중첩된 모든 문단을 재귀 방문한다
786    /// (글상자/도형 본문·캡션·각주/미주·메모 본문+앵커 run·묶음 자식 포함).
787    ///
788    /// 새 variant 가 문단을 담게 되면 이 match 가 컴파일 에러로 강제한다
789    /// (와일드카드 없음 — 순회 완전성 보장).
790    pub(crate) fn walk_paragraphs_mut(
791        &mut self,
792        f: &mut dyn FnMut(&mut crate::paragraph::Paragraph),
793    ) {
794        fn walk_vec(
795            paragraphs: &mut [crate::paragraph::Paragraph],
796            f: &mut dyn FnMut(&mut crate::paragraph::Paragraph),
797        ) {
798            for p in paragraphs {
799                p.walk_paragraphs_mut(f);
800            }
801        }
802        fn walk_caption(
803            caption: &mut Option<Caption>,
804            f: &mut dyn FnMut(&mut crate::paragraph::Paragraph),
805        ) {
806            if let Some(c) = caption {
807                c.walk_paragraphs_mut(f);
808            }
809        }
810        match self {
811            Self::TextBox { paragraphs, caption, .. }
812            | Self::Ellipse { paragraphs, caption, .. }
813            | Self::Polygon { paragraphs, caption, .. } => {
814                walk_vec(paragraphs, f);
815                walk_caption(caption, f);
816            }
817            Self::Footnote { paragraphs, .. } | Self::Endnote { paragraphs, .. } => {
818                walk_vec(paragraphs, f);
819            }
820            Self::Line { caption, .. }
821            | Self::Rect { caption, .. }
822            | Self::Arc { caption, .. }
823            | Self::Curve { caption, .. }
824            | Self::ConnectLine { caption, .. } => walk_caption(caption, f),
825            Self::Group { children, .. } => {
826                for child in children {
827                    child.walk_paragraphs_mut(f);
828                }
829            }
830            Self::Memo { content, anchor_runs, .. } => {
831                walk_vec(content, f);
832                for run in anchor_runs {
833                    run.walk_paragraphs_mut(f);
834                }
835            }
836            Self::Hyperlink { .. }
837            | Self::EmbeddedChart { .. }
838            | Self::Equation { .. }
839            | Self::Chart { .. }
840            | Self::Dutmal { .. }
841            | Self::Compose { .. }
842            | Self::TextArt { .. }
843            | Self::Bookmark { .. }
844            | Self::CrossRef { .. }
845            | Self::Field { .. }
846            | Self::IndexMark { .. }
847            | Self::UnknownSummary { .. }
848            | Self::DateCodeField { .. }
849            | Self::PathField { .. }
850            | Self::InlinePageNumber { .. }
851            | Self::NewNumber { .. }
852            | Self::PageHiding { .. }
853            | Self::Unknown { .. } => {}
854        }
855    }
856
857    /// [`Self::walk_paragraphs_mut`] 의 불변 쌍둥이 — 방문 순서와 재귀
858    /// 대상이 완전히 같다.
859    ///
860    /// 가변판과 마찬가지로 와일드카드가 없다 — 새 variant 가 문단을 담게
861    /// 되면 **두 match 가 함께** 컴파일 에러로 강제된다 (순회 완전성 보장).
862    pub(crate) fn walk_paragraphs(&self, f: &mut dyn FnMut(&crate::paragraph::Paragraph)) {
863        fn walk_vec(
864            paragraphs: &[crate::paragraph::Paragraph],
865            f: &mut dyn FnMut(&crate::paragraph::Paragraph),
866        ) {
867            for p in paragraphs {
868                p.walk_paragraphs(f);
869            }
870        }
871        fn walk_caption(
872            caption: &Option<Caption>,
873            f: &mut dyn FnMut(&crate::paragraph::Paragraph),
874        ) {
875            if let Some(c) = caption {
876                c.walk_paragraphs(f);
877            }
878        }
879        match self {
880            Self::TextBox { paragraphs, caption, .. }
881            | Self::Ellipse { paragraphs, caption, .. }
882            | Self::Polygon { paragraphs, caption, .. } => {
883                walk_vec(paragraphs, f);
884                walk_caption(caption, f);
885            }
886            Self::Footnote { paragraphs, .. } | Self::Endnote { paragraphs, .. } => {
887                walk_vec(paragraphs, f);
888            }
889            Self::Line { caption, .. }
890            | Self::Rect { caption, .. }
891            | Self::Arc { caption, .. }
892            | Self::Curve { caption, .. }
893            | Self::ConnectLine { caption, .. } => walk_caption(caption, f),
894            Self::Group { children, .. } => {
895                for child in children {
896                    child.walk_paragraphs(f);
897                }
898            }
899            Self::Memo { content, anchor_runs, .. } => {
900                walk_vec(content, f);
901                for run in anchor_runs {
902                    run.walk_paragraphs(f);
903                }
904            }
905            Self::Hyperlink { .. }
906            | Self::EmbeddedChart { .. }
907            | Self::Equation { .. }
908            | Self::Chart { .. }
909            | Self::Dutmal { .. }
910            | Self::Compose { .. }
911            | Self::TextArt { .. }
912            | Self::Bookmark { .. }
913            | Self::CrossRef { .. }
914            | Self::Field { .. }
915            | Self::IndexMark { .. }
916            | Self::UnknownSummary { .. }
917            | Self::DateCodeField { .. }
918            | Self::PathField { .. }
919            | Self::InlinePageNumber { .. }
920            | Self::NewNumber { .. }
921            | Self::PageHiding { .. }
922            | Self::Unknown { .. } => {}
923        }
924    }
925
926    /// Returns the stable snake_case name of this control's kind.
927    ///
928    /// Read/diff projections use this to label embedded content without
929    /// exposing payload details. The match is deliberately exhaustive (no
930    /// wildcard) so adding a variant forces a name here at compile time.
931    #[must_use]
932    pub fn kind_name(&self) -> &'static str {
933        match self {
934            Self::TextBox { .. } => "text_box",
935            Self::Hyperlink { .. } => "hyperlink",
936            Self::Footnote { .. } => "footnote",
937            Self::Endnote { .. } => "endnote",
938            Self::Line { .. } => "line",
939            Self::Ellipse { .. } => "ellipse",
940            Self::EmbeddedChart { .. } => "embedded_chart",
941            Self::Rect { .. } => "rect",
942            Self::Polygon { .. } => "polygon",
943            Self::Equation { .. } => "equation",
944            Self::Chart { .. } => "chart",
945            Self::Dutmal { .. } => "dutmal",
946            Self::Compose { .. } => "compose",
947            Self::Arc { .. } => "arc",
948            Self::Curve { .. } => "curve",
949            Self::ConnectLine { .. } => "connect_line",
950            Self::Group { .. } => "group",
951            Self::TextArt { .. } => "text_art",
952            Self::Bookmark { .. } => "bookmark",
953            Self::CrossRef { .. } => "cross_ref",
954            Self::Field { .. } => "field",
955            Self::Memo { .. } => "memo",
956            Self::IndexMark { .. } => "index_mark",
957            Self::UnknownSummary { .. } => "unknown_summary",
958            Self::DateCodeField { .. } => "date_code_field",
959            Self::PathField { .. } => "path_field",
960            Self::InlinePageNumber { .. } => "inline_page_number",
961            Self::NewNumber { .. } => "new_number",
962            Self::PageHiding { .. } => "page_hiding",
963            Self::Unknown { .. } => "unknown",
964        }
965    }
966
967    /// Returns `true` if this is a [`Control::TextBox`].
968    pub fn is_text_box(&self) -> bool {
969        matches!(self, Self::TextBox { .. })
970    }
971
972    /// Returns `true` if this is a [`Control::Hyperlink`].
973    pub fn is_hyperlink(&self) -> bool {
974        matches!(self, Self::Hyperlink { .. })
975    }
976
977    /// Returns `true` if this is a [`Control::Footnote`].
978    pub fn is_footnote(&self) -> bool {
979        matches!(self, Self::Footnote { .. })
980    }
981
982    /// Returns `true` if this is a [`Control::Endnote`].
983    pub fn is_endnote(&self) -> bool {
984        matches!(self, Self::Endnote { .. })
985    }
986
987    /// Returns `true` if this is a [`Control::Line`].
988    pub fn is_line(&self) -> bool {
989        matches!(self, Self::Line { .. })
990    }
991
992    /// Returns `true` if this is a [`Control::Ellipse`].
993    pub fn is_ellipse(&self) -> bool {
994        matches!(self, Self::Ellipse { .. })
995    }
996
997    /// Returns `true` if this is a [`Control::Rect`].
998    pub fn is_rect(&self) -> bool {
999        matches!(self, Self::Rect { .. })
1000    }
1001
1002    /// Returns `true` if this is a [`Control::Polygon`].
1003    pub fn is_polygon(&self) -> bool {
1004        matches!(self, Self::Polygon { .. })
1005    }
1006
1007    /// Returns `true` if this is a [`Control::Equation`].
1008    pub fn is_equation(&self) -> bool {
1009        matches!(self, Self::Equation { .. })
1010    }
1011
1012    /// Returns `true` if this is a [`Control::Chart`].
1013    pub fn is_chart(&self) -> bool {
1014        matches!(self, Self::Chart { .. })
1015    }
1016
1017    /// Returns `true` if this is a [`Control::EmbeddedChart`].
1018    pub fn is_embedded_chart(&self) -> bool {
1019        matches!(self, Self::EmbeddedChart { .. })
1020    }
1021
1022    /// Returns `true` if this is a [`Control::Unknown`].
1023    pub fn is_unknown(&self) -> bool {
1024        matches!(self, Self::Unknown { .. })
1025    }
1026
1027    /// Returns `true` if this is a [`Control::Dutmal`].
1028    pub fn is_dutmal(&self) -> bool {
1029        matches!(self, Self::Dutmal { .. })
1030    }
1031
1032    /// Returns `true` if this is a [`Control::Compose`].
1033    pub fn is_compose(&self) -> bool {
1034        matches!(self, Self::Compose { .. })
1035    }
1036
1037    /// Returns `true` if this is a [`Control::Arc`].
1038    pub fn is_arc(&self) -> bool {
1039        matches!(self, Self::Arc { .. })
1040    }
1041
1042    /// Returns `true` if this is a [`Control::Curve`].
1043    pub fn is_curve(&self) -> bool {
1044        matches!(self, Self::Curve { .. })
1045    }
1046
1047    /// Returns `true` if this is a [`Control::ConnectLine`].
1048    pub fn is_connect_line(&self) -> bool {
1049        matches!(self, Self::ConnectLine { .. })
1050    }
1051
1052    /// Returns `true` if this is a [`Control::Group`].
1053    pub fn is_group(&self) -> bool {
1054        matches!(self, Self::Group { .. })
1055    }
1056
1057    /// Returns `true` if this is a [`Control::Bookmark`].
1058    pub fn is_bookmark(&self) -> bool {
1059        matches!(self, Self::Bookmark { .. })
1060    }
1061
1062    /// Returns `true` if this is a [`Control::CrossRef`].
1063    pub fn is_cross_ref(&self) -> bool {
1064        matches!(self, Self::CrossRef { .. })
1065    }
1066
1067    /// Returns `true` if this is a [`Control::Field`].
1068    pub fn is_field(&self) -> bool {
1069        matches!(self, Self::Field { .. })
1070    }
1071
1072    /// Returns `true` if this is a [`Control::Memo`].
1073    pub fn is_memo(&self) -> bool {
1074        matches!(self, Self::Memo { .. })
1075    }
1076
1077    /// Returns `true` if this is a [`Control::IndexMark`].
1078    pub fn is_index_mark(&self) -> bool {
1079        matches!(self, Self::IndexMark { .. })
1080    }
1081
1082    /// Creates a point bookmark at a named location.
1083    ///
1084    /// # Examples
1085    ///
1086    /// ```
1087    /// use hwpforge_core::control::Control;
1088    ///
1089    /// let bm = Control::bookmark("section1");
1090    /// assert!(bm.is_bookmark());
1091    /// ```
1092    pub fn bookmark(name: &str) -> Self {
1093        Self::Bookmark { name: name.to_string(), bookmark_type: BookmarkType::Point }
1094    }
1095
1096    /// Creates a press-field (누름틀) with the given hint text.
1097    ///
1098    /// # Examples
1099    ///
1100    /// ```
1101    /// use hwpforge_core::control::Control;
1102    ///
1103    /// let field = Control::field("이름을 입력하세요");
1104    /// assert!(field.is_field());
1105    /// ```
1106    pub fn field(hint: &str) -> Self {
1107        Self::Field {
1108            field_type: FieldType::ClickHere,
1109            hint_text: Some(hint.to_string()),
1110            help_text: None,
1111            name: None,
1112            display_text: String::new(),
1113        }
1114    }
1115
1116    /// Creates an index mark with a primary key.
1117    ///
1118    /// # Examples
1119    ///
1120    /// ```
1121    /// use hwpforge_core::control::Control;
1122    ///
1123    /// let mark = Control::index_mark("한글");
1124    /// assert!(mark.is_index_mark());
1125    /// ```
1126    pub fn index_mark(primary: &str) -> Self {
1127        Self::IndexMark { primary: primary.to_string(), secondary: None }
1128    }
1129
1130    /// Creates a memo annotation with the given paragraph body.
1131    ///
1132    /// # Examples
1133    ///
1134    /// ```
1135    /// use hwpforge_core::control::Control;
1136    /// use hwpforge_core::paragraph::Paragraph;
1137    /// use hwpforge_foundation::ParaShapeIndex;
1138    ///
1139    /// let para = Paragraph::new(ParaShapeIndex::new(0));
1140    /// let memo = Control::memo(vec![para]);
1141    /// assert!(memo.is_memo());
1142    /// ```
1143    pub fn memo(content: Vec<Paragraph>) -> Self {
1144        Self::Memo { content, anchor_runs: Vec::new(), metadata: MemoMetadata::default() }
1145    }
1146
1147    /// Creates a memo annotation with both body content and anchor runs.
1148    ///
1149    /// `anchor_runs` are the visible body span the memo is attached to (the
1150    /// text between HWPX `<hp:fieldBegin type="MEMO">` and `<hp:fieldEnd>`);
1151    /// `content` is the memo body inside `<hp:subList>`.
1152    ///
1153    /// # Examples
1154    ///
1155    /// ```
1156    /// use hwpforge_core::control::Control;
1157    /// use hwpforge_core::paragraph::Paragraph;
1158    /// use hwpforge_core::run::Run;
1159    /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
1160    ///
1161    /// let body = vec![Paragraph::new(ParaShapeIndex::new(0))];
1162    /// let anchor = vec![Run::text("hello", CharShapeIndex::new(0))];
1163    /// let memo = Control::memo_with_anchor(body, anchor);
1164    /// assert!(memo.is_memo());
1165    /// ```
1166    pub fn memo_with_anchor(content: Vec<Paragraph>, anchor_runs: Vec<Run>) -> Self {
1167        Self::Memo { content, anchor_runs, metadata: MemoMetadata::default() }
1168    }
1169
1170    /// Creates a cross-reference to a bookmark target (convenience helper).
1171    ///
1172    /// Wave 12m Phase 2: 인자 타입이 `&str` 에서 `RefTarget` 로 변경
1173    /// (breaking). 책갈피 이름이라면 `RefTarget::Name(...)`, 한컴 시스템
1174    /// ID 라면 `RefTarget::SystemId(...)` 를 명시.
1175    ///
1176    /// # Examples
1177    ///
1178    /// ```
1179    /// use hwpforge_core::control::{Control, RefTarget};
1180    /// use hwpforge_foundation::{RefType, RefContentType};
1181    ///
1182    /// let xref = Control::cross_ref(
1183    ///     RefTarget::Name("section1".to_string()),
1184    ///     RefType::Bookmark,
1185    ///     RefContentType::Page,
1186    /// );
1187    /// assert!(xref.is_cross_ref());
1188    /// ```
1189    pub fn cross_ref(target: RefTarget, ref_type: RefType, content_type: RefContentType) -> Self {
1190        Self::CrossRef {
1191            target,
1192            ref_type,
1193            content_type,
1194            as_hyperlink: false,
1195            display_text: String::new(),
1196        }
1197    }
1198
1199    /// Creates a chart control with default dimensions and settings.
1200    ///
1201    /// Defaults: width ≈ 114mm, height ≈ 66mm, no title, right legend, clustered grouping.
1202    ///
1203    /// # Examples
1204    ///
1205    /// ```
1206    /// use hwpforge_core::control::Control;
1207    /// use hwpforge_core::chart::{ChartType, ChartData};
1208    ///
1209    /// let data = ChartData::category(&["A", "B"], &[("S1", &[10.0, 20.0])]);
1210    /// let ctrl = Control::chart(ChartType::Column, data);
1211    /// assert!(ctrl.is_chart());
1212    /// ```
1213    pub fn chart(chart_type: ChartType, data: ChartData) -> Self {
1214        Self::Chart {
1215            chart_type,
1216            data,
1217            width: HwpUnit::new(32250).expect("32250 is valid"),
1218            height: HwpUnit::new(18750).expect("18750 is valid"),
1219            title: None,
1220            legend: LegendPosition::default(),
1221            grouping: ChartGrouping::default(),
1222            bar_shape: None,
1223            explosion: None,
1224            of_pie_type: None,
1225            radar_style: None,
1226            wireframe: None,
1227            bubble_3d: None,
1228            scatter_style: None,
1229            show_markers: None,
1230            stock_variant: None,
1231        }
1232    }
1233
1234    /// Creates an equation control with default dimensions for the given HancomEQN script.
1235    ///
1236    /// Defaults: width ≈ 31mm (8779 HWPUNIT), height ≈ 9.2mm (2600 HWPUNIT),
1237    /// baseline 71%, black text, `HancomEQN` font.
1238    ///
1239    /// # Examples
1240    ///
1241    /// ```
1242    /// use hwpforge_core::control::Control;
1243    ///
1244    /// let ctrl = Control::equation("{a+b} over {c+d}");
1245    /// assert!(ctrl.is_equation());
1246    /// ```
1247    pub fn equation(script: &str) -> Self {
1248        Self::Equation {
1249            script: script.to_string(),
1250            width: HwpUnit::new(8779).expect("8779 is valid"),
1251            height: HwpUnit::new(2600).expect("2600 is valid"),
1252            base_line: 71,
1253            text_color: Color::BLACK,
1254            font: "HancomEQN".to_string(),
1255            inst_id: None,
1256        }
1257    }
1258
1259    /// Creates a text box control with the given paragraphs and dimensions.
1260    ///
1261    /// Defaults: inline positioning (placement=None), no caption, no style override.
1262    ///
1263    /// # Examples
1264    ///
1265    /// ```
1266    /// use hwpforge_core::control::Control;
1267    /// use hwpforge_core::paragraph::Paragraph;
1268    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
1269    ///
1270    /// let para = Paragraph::new(ParaShapeIndex::new(0));
1271    /// let width = HwpUnit::from_mm(80.0).unwrap();
1272    /// let height = HwpUnit::from_mm(40.0).unwrap();
1273    /// let ctrl = Control::text_box(vec![para], width, height);
1274    /// assert!(ctrl.is_text_box());
1275    /// ```
1276    pub fn text_box(paragraphs: Vec<Paragraph>, width: HwpUnit, height: HwpUnit) -> Self {
1277        Self::TextBox {
1278            paragraphs,
1279            width,
1280            height,
1281            placement: None,
1282            caption: None,
1283            style: None,
1284            text_vertical_align: VerticalAlign::Top,
1285        }
1286    }
1287
1288    /// Creates a footnote control with the given paragraph content.
1289    ///
1290    /// Defaults: no inst_id.
1291    ///
1292    /// # Examples
1293    ///
1294    /// ```
1295    /// use hwpforge_core::control::Control;
1296    /// use hwpforge_core::run::Run;
1297    /// use hwpforge_core::paragraph::Paragraph;
1298    /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
1299    ///
1300    /// let para = Paragraph::with_runs(
1301    ///     vec![Run::text("Note text", CharShapeIndex::new(0))],
1302    ///     ParaShapeIndex::new(0),
1303    /// );
1304    /// let ctrl = Control::footnote(vec![para]);
1305    /// assert!(ctrl.is_footnote());
1306    /// ```
1307    pub fn footnote(paragraphs: Vec<Paragraph>) -> Self {
1308        Self::Footnote { inst_id: None, paragraphs }
1309    }
1310
1311    /// Creates an endnote control with the given paragraph content.
1312    ///
1313    /// Defaults: no inst_id.
1314    ///
1315    /// # Examples
1316    ///
1317    /// ```
1318    /// use hwpforge_core::control::Control;
1319    /// use hwpforge_core::run::Run;
1320    /// use hwpforge_core::paragraph::Paragraph;
1321    /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
1322    ///
1323    /// let para = Paragraph::with_runs(
1324    ///     vec![Run::text("End note", CharShapeIndex::new(0))],
1325    ///     ParaShapeIndex::new(0),
1326    /// );
1327    /// let ctrl = Control::endnote(vec![para]);
1328    /// assert!(ctrl.is_endnote());
1329    /// ```
1330    pub fn endnote(paragraphs: Vec<Paragraph>) -> Self {
1331        Self::Endnote { inst_id: None, paragraphs }
1332    }
1333
1334    /// Creates a footnote with an explicit instance ID for cross-referencing.
1335    ///
1336    /// Use this when you need stable `inst_id` references (e.g. matching decoder output).
1337    /// For simple footnotes without cross-references, prefer [`Control::footnote`].
1338    ///
1339    /// # Examples
1340    ///
1341    /// ```
1342    /// use hwpforge_core::control::Control;
1343    /// use hwpforge_core::paragraph::Paragraph;
1344    /// use hwpforge_foundation::ParaShapeIndex;
1345    ///
1346    /// let ctrl = Control::footnote_with_id(1, vec![Paragraph::new(ParaShapeIndex::new(0))]);
1347    /// assert!(ctrl.is_footnote());
1348    /// ```
1349    pub fn footnote_with_id(inst_id: u64, paragraphs: Vec<Paragraph>) -> Self {
1350        Self::Footnote { inst_id: Some(ObjectId::new(inst_id)), paragraphs }
1351    }
1352
1353    /// Creates an endnote with an explicit instance ID for cross-referencing.
1354    ///
1355    /// Use this when you need stable `inst_id` references (e.g. matching decoder output).
1356    /// For simple endnotes without cross-references, prefer [`Control::endnote`].
1357    ///
1358    /// # Examples
1359    ///
1360    /// ```
1361    /// use hwpforge_core::control::Control;
1362    /// use hwpforge_core::paragraph::Paragraph;
1363    /// use hwpforge_foundation::ParaShapeIndex;
1364    ///
1365    /// let ctrl = Control::endnote_with_id(2, vec![Paragraph::new(ParaShapeIndex::new(0))]);
1366    /// assert!(ctrl.is_endnote());
1367    /// ```
1368    pub fn endnote_with_id(inst_id: u64, paragraphs: Vec<Paragraph>) -> Self {
1369        Self::Endnote { inst_id: Some(ObjectId::new(inst_id)), paragraphs }
1370    }
1371
1372    /// Creates an ellipse control with the given bounding box dimensions.
1373    ///
1374    /// Geometry is auto-derived: center=(w/2, h/2), axis1=(w, h/2), axis2=(w/2, h).
1375    /// Defaults: inline positioning (placement=None), no paragraphs, no caption, no style.
1376    ///
1377    /// # Examples
1378    ///
1379    /// ```
1380    /// use hwpforge_core::control::Control;
1381    /// use hwpforge_foundation::HwpUnit;
1382    ///
1383    /// let width = HwpUnit::from_mm(40.0).unwrap();
1384    /// let height = HwpUnit::from_mm(30.0).unwrap();
1385    /// let ctrl = Control::ellipse(width, height);
1386    /// assert!(ctrl.is_ellipse());
1387    /// ```
1388    pub fn ellipse(width: HwpUnit, height: HwpUnit) -> Self {
1389        let w = width.as_i32();
1390        let h = height.as_i32();
1391        Self::Ellipse {
1392            center: ShapePoint::new(w / 2, h / 2),
1393            axis1: ShapePoint::new(w, h / 2),
1394            axis2: ShapePoint::new(w / 2, h),
1395            width,
1396            height,
1397            placement: None,
1398            paragraphs: vec![],
1399            caption: None,
1400            style: None,
1401            text_vertical_align: VerticalAlign::Top,
1402        }
1403    }
1404
1405    /// Creates an ellipse control with paragraph content inside.
1406    ///
1407    /// Same as [`Control::ellipse`] but accepts paragraphs for text drawn inside the ellipse.
1408    /// Geometry is auto-derived: center=(w/2, h/2), axis1=(w, h/2), axis2=(w/2, h).
1409    /// Defaults: inline positioning (placement=None), no caption, no style.
1410    ///
1411    /// # Examples
1412    ///
1413    /// ```
1414    /// use hwpforge_core::control::Control;
1415    /// use hwpforge_core::paragraph::Paragraph;
1416    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
1417    ///
1418    /// let width = HwpUnit::from_mm(40.0).unwrap();
1419    /// let height = HwpUnit::from_mm(30.0).unwrap();
1420    /// let para = Paragraph::new(ParaShapeIndex::new(0));
1421    /// let ctrl = Control::ellipse_with_text(width, height, vec![para]);
1422    /// assert!(ctrl.is_ellipse());
1423    /// ```
1424    pub fn ellipse_with_text(width: HwpUnit, height: HwpUnit, paragraphs: Vec<Paragraph>) -> Self {
1425        let w = width.as_i32();
1426        let h = height.as_i32();
1427        Self::Ellipse {
1428            center: ShapePoint::new(w / 2, h / 2),
1429            axis1: ShapePoint::new(w, h / 2),
1430            axis2: ShapePoint::new(w / 2, h),
1431            width,
1432            height,
1433            placement: None,
1434            paragraphs,
1435            caption: None,
1436            style: None,
1437            text_vertical_align: VerticalAlign::Top,
1438        }
1439    }
1440
1441    /// Creates a pure rectangle control with the given bounding box dimensions.
1442    ///
1443    /// Pure rectangle means no embedded text content; for a textbox-style rect with
1444    /// inline paragraphs, use [`Control::text_box`].
1445    /// Defaults: inline positioning (placement=None), no caption, no style.
1446    ///
1447    /// # Errors
1448    ///
1449    /// Returns [`CoreError::InvalidStructure`] if either dimension is zero.
1450    ///
1451    /// # Examples
1452    ///
1453    /// ```
1454    /// use hwpforge_core::control::Control;
1455    /// use hwpforge_foundation::HwpUnit;
1456    ///
1457    /// let width = HwpUnit::from_mm(40.0).unwrap();
1458    /// let height = HwpUnit::from_mm(20.0).unwrap();
1459    /// let ctrl = Control::rect(width, height).unwrap();
1460    /// assert!(ctrl.is_rect());
1461    /// ```
1462    pub fn rect(width: HwpUnit, height: HwpUnit) -> CoreResult<Self> {
1463        if width.as_i32() == 0 || height.as_i32() == 0 {
1464            return Err(CoreError::InvalidStructure {
1465                context: "Control::rect".to_string(),
1466                reason: format!(
1467                    "rectangle requires non-zero dimensions, got {}x{}",
1468                    width.as_i32(),
1469                    height.as_i32()
1470                ),
1471            });
1472        }
1473        Ok(Self::Rect { width, height, placement: None, caption: None, style: None })
1474    }
1475
1476    /// Creates a polygon control from the given vertices.
1477    ///
1478    /// The bounding box is auto-derived from the min/max of vertex coordinates.
1479    /// Defaults: no paragraphs, no caption, no style.
1480    ///
1481    /// Returns an error if fewer than 3 vertices are provided.
1482    ///
1483    /// # Errors
1484    ///
1485    /// Returns [`CoreError::InvalidStructure`] if `vertices.len() < 3`.
1486    ///
1487    /// # Examples
1488    ///
1489    /// ```
1490    /// use hwpforge_core::control::{Control, ShapePoint};
1491    ///
1492    /// let vertices = vec![
1493    ///     ShapePoint::new(0, 1000),
1494    ///     ShapePoint::new(500, 0),
1495    ///     ShapePoint::new(1000, 1000),
1496    /// ];
1497    /// let ctrl = Control::polygon(vertices).unwrap();
1498    /// assert!(ctrl.is_polygon());
1499    /// ```
1500    pub fn polygon(vertices: Vec<ShapePoint>) -> CoreResult<Self> {
1501        if vertices.len() < 3 {
1502            return Err(CoreError::InvalidStructure {
1503                context: "Control::polygon".to_string(),
1504                reason: format!("polygon requires at least 3 vertices, got {}", vertices.len()),
1505            });
1506        }
1507        let min_x = vertices.iter().map(|p| p.x as i64).min().unwrap_or(0);
1508        let max_x = vertices.iter().map(|p| p.x as i64).max().unwrap_or(0);
1509        let min_y = vertices.iter().map(|p| p.y as i64).min().unwrap_or(0);
1510        let max_y = vertices.iter().map(|p| p.y as i64).max().unwrap_or(0);
1511        let bbox_w = i32::try_from((max_x - min_x).max(0)).unwrap_or(i32::MAX);
1512        let bbox_h = i32::try_from((max_y - min_y).max(0)).unwrap_or(i32::MAX);
1513        let width = HwpUnit::new(bbox_w).map_err(|_| CoreError::InvalidStructure {
1514            context: "Control::polygon".into(),
1515            reason: format!("bounding box width {bbox_w} exceeds HwpUnit range"),
1516        })?;
1517        let height = HwpUnit::new(bbox_h).map_err(|_| CoreError::InvalidStructure {
1518            context: "Control::polygon".into(),
1519            reason: format!("bounding box height {bbox_h} exceeds HwpUnit range"),
1520        })?;
1521        Ok(Self::Polygon {
1522            vertices,
1523            width,
1524            height,
1525            placement: None,
1526            paragraphs: vec![],
1527            caption: None,
1528            style: None,
1529            text_vertical_align: VerticalAlign::Top,
1530        })
1531    }
1532
1533    /// Creates a line control between two endpoints.
1534    ///
1535    /// The bounding box width and height are derived from the absolute difference
1536    /// of the endpoint coordinates: `width = |end.x - start.x|`, `height = |end.y - start.y|`.
1537    /// Each axis is clamped to a minimum of 100 HwpUnit (~1pt) because 한글 cannot
1538    /// render lines with a zero-dimension bounding box.
1539    /// Defaults: no caption, no style.
1540    ///
1541    /// Returns an error if start and end are the same point (degenerate line).
1542    ///
1543    /// # Errors
1544    ///
1545    /// Returns [`CoreError::InvalidStructure`] if start equals end.
1546    ///
1547    /// # Examples
1548    ///
1549    /// ```
1550    /// use hwpforge_core::control::{Control, ShapePoint};
1551    ///
1552    /// let ctrl = Control::line(ShapePoint::new(0, 0), ShapePoint::new(5000, 0)).unwrap();
1553    /// assert!(ctrl.is_line());
1554    /// ```
1555    pub fn line(start: ShapePoint, end: ShapePoint) -> CoreResult<Self> {
1556        if start == end {
1557            return Err(CoreError::InvalidStructure {
1558                context: "Control::line".to_string(),
1559                reason: "start and end points are identical (degenerate line)".to_string(),
1560            });
1561        }
1562        // Normalize points to bounding-box-relative coordinates.
1563        // HWPX requires startPt/endPt within the shape's bounding box (0,0)→(w,h).
1564        let min_x = start.x.min(end.x);
1565        let min_y = start.y.min(end.y);
1566        let norm_start =
1567            ShapePoint::new(start.x.saturating_sub(min_x), start.y.saturating_sub(min_y));
1568        let norm_end = ShapePoint::new(end.x.saturating_sub(min_x), end.y.saturating_sub(min_y));
1569
1570        let raw_w =
1571            i32::try_from(((end.x as i64) - (start.x as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1572        let raw_h =
1573            i32::try_from(((end.y as i64) - (start.y as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1574        // Minimum bounding box of 100 HwpUnit (~1pt) per axis.
1575        // 한글 cannot render lines with a zero-dimension bounding box.
1576        let raw_w = raw_w.max(100);
1577        let raw_h = raw_h.max(100);
1578        let width = HwpUnit::new(raw_w).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1579        let height = HwpUnit::new(raw_h).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1580        Ok(Self::Line {
1581            start: norm_start,
1582            end: norm_end,
1583            width,
1584            height,
1585            placement: None,
1586            caption: None,
1587            style: None,
1588        })
1589    }
1590
1591    /// Creates a horizontal line of the given width.
1592    ///
1593    /// Shortcut for `line(ShapePoint::new(0, 0), ShapePoint::new(width.as_i32(), 0))`.
1594    /// The bounding box height is clamped to 100 HwpUnit (~1pt minimum) because
1595    /// 한글 cannot render lines with a zero-dimension bounding box.
1596    /// Defaults: no caption, no style.
1597    ///
1598    /// # Examples
1599    ///
1600    /// ```
1601    /// use hwpforge_core::control::Control;
1602    /// use hwpforge_foundation::HwpUnit;
1603    ///
1604    /// let width = HwpUnit::from_mm(100.0).unwrap();
1605    /// let ctrl = Control::horizontal_line(width);
1606    /// assert!(ctrl.is_line());
1607    /// ```
1608    pub fn horizontal_line(width: HwpUnit) -> Self {
1609        let w = width.as_i32();
1610        Self::Line {
1611            start: ShapePoint::new(0, 0),
1612            end: ShapePoint::new(w, 0),
1613            width,
1614            height: HwpUnit::new(100).expect("100 is valid"),
1615            placement: None,
1616            caption: None,
1617            style: None,
1618        }
1619    }
1620
1621    /// Creates a dutmal (annotation text) control with default positioning.
1622    ///
1623    /// Defaults: position = Top, sz_ratio = 0 (auto), align = Center.
1624    ///
1625    /// # Examples
1626    ///
1627    /// ```
1628    /// use hwpforge_core::control::Control;
1629    ///
1630    /// let ctrl = Control::dutmal("본문", "주석");
1631    /// assert!(ctrl.is_dutmal());
1632    /// ```
1633    pub fn dutmal(main_text: impl Into<String>, sub_text: impl Into<String>) -> Self {
1634        Self::Dutmal {
1635            main_text: main_text.into(),
1636            sub_text: sub_text.into(),
1637            position: DutmalPosition::Top,
1638            sz_ratio: 0,
1639            align: DutmalAlign::Center,
1640            metadata: DutmalMetadata::default(),
1641        }
1642    }
1643
1644    /// Creates a compose (글자겹침) control with default settings.
1645    ///
1646    /// Defaults: `circle_type = "SHAPE_REVERSAL_TIRANGLE"` (spec typo preserved),
1647    /// `char_sz = -3`, `compose_type = "SPREAD"`.
1648    ///
1649    /// # Examples
1650    ///
1651    /// ```
1652    /// use hwpforge_core::control::Control;
1653    ///
1654    /// let ctrl = Control::compose("12");
1655    /// assert!(ctrl.is_compose());
1656    /// ```
1657    pub fn compose(text: impl Into<String>) -> Self {
1658        Self::Compose {
1659            compose_text: text.into(),
1660            circle_type: "SHAPE_REVERSAL_TIRANGLE".to_string(), // official spec typo preserved
1661            char_sz: -3,
1662            compose_type: "SPREAD".to_string(),
1663            // 10 × no-override sentinel (HWPX `charPrCnt` is fixed at 10).
1664            char_pr_ids: vec![u32::MAX; 10],
1665        }
1666    }
1667
1668    /// Creates an arc control with the given bounding box dimensions.
1669    ///
1670    /// Geometry is auto-derived from the bounding box.
1671    /// Defaults: inline positioning, no caption, no style.
1672    ///
1673    /// # Examples
1674    ///
1675    /// ```
1676    /// use hwpforge_core::control::Control;
1677    /// use hwpforge_foundation::{ArcType, HwpUnit};
1678    ///
1679    /// let width = HwpUnit::from_mm(40.0).unwrap();
1680    /// let height = HwpUnit::from_mm(30.0).unwrap();
1681    /// let ctrl = Control::arc(ArcType::Pie, width, height);
1682    /// assert!(ctrl.is_arc());
1683    /// ```
1684    pub fn arc(arc_type: ArcType, width: HwpUnit, height: HwpUnit) -> Self {
1685        let w = width.as_i32();
1686        let h = height.as_i32();
1687        Self::Arc {
1688            arc_type,
1689            center: ShapePoint::new(w / 2, h / 2),
1690            axis1: ShapePoint::new(w, h / 2),
1691            axis2: ShapePoint::new(w / 2, h),
1692            start1: ShapePoint::new(w, h / 2),
1693            end1: ShapePoint::new(w / 2, 0),
1694            start2: ShapePoint::new(w, h / 2),
1695            end2: ShapePoint::new(w / 2, 0),
1696            width,
1697            height,
1698            placement: None,
1699            caption: None,
1700            style: None,
1701        }
1702    }
1703
1704    /// Creates a curve control from the given control points.
1705    ///
1706    /// All segments default to [`CurveSegmentType::Curve`].
1707    /// The bounding box is auto-derived from min/max of point coordinates.
1708    ///
1709    /// Returns an error if fewer than 2 points are provided.
1710    ///
1711    /// # Errors
1712    ///
1713    /// Returns [`CoreError::InvalidStructure`] if `points.len() < 2`.
1714    ///
1715    /// # Examples
1716    ///
1717    /// ```
1718    /// use hwpforge_core::control::{Control, ShapePoint};
1719    ///
1720    /// let pts = vec![
1721    ///     ShapePoint::new(0, 0),
1722    ///     ShapePoint::new(2500, 5000),
1723    ///     ShapePoint::new(5000, 0),
1724    /// ];
1725    /// let ctrl = Control::curve(pts).unwrap();
1726    /// assert!(ctrl.is_curve());
1727    /// ```
1728    pub fn curve(points: Vec<ShapePoint>) -> CoreResult<Self> {
1729        if points.len() < 2 {
1730            return Err(CoreError::InvalidStructure {
1731                context: "Control::curve".to_string(),
1732                reason: format!("curve requires at least 2 points, got {}", points.len()),
1733            });
1734        }
1735        let min_x = points.iter().map(|p| p.x as i64).min().unwrap_or(0);
1736        let max_x = points.iter().map(|p| p.x as i64).max().unwrap_or(0);
1737        let min_y = points.iter().map(|p| p.y as i64).min().unwrap_or(0);
1738        let max_y = points.iter().map(|p| p.y as i64).max().unwrap_or(0);
1739        let bbox_w = i32::try_from((max_x - min_x).max(1)).unwrap_or(i32::MAX);
1740        let bbox_h = i32::try_from((max_y - min_y).max(1)).unwrap_or(i32::MAX);
1741        let width = HwpUnit::new(bbox_w).map_err(|_| CoreError::InvalidStructure {
1742            context: "Control::curve".into(),
1743            reason: format!("bounding box width {bbox_w} exceeds HwpUnit range"),
1744        })?;
1745        let height = HwpUnit::new(bbox_h).map_err(|_| CoreError::InvalidStructure {
1746            context: "Control::curve".into(),
1747            reason: format!("bounding box height {bbox_h} exceeds HwpUnit range"),
1748        })?;
1749        let seg_count = points.len().saturating_sub(1);
1750        Ok(Self::Curve {
1751            points,
1752            segment_types: vec![CurveSegmentType::Curve; seg_count],
1753            width,
1754            height,
1755            placement: None,
1756            caption: None,
1757            style: None,
1758        })
1759    }
1760
1761    /// Creates a connect line between two endpoints.
1762    ///
1763    /// Defaults: no control points, type "STRAIGHT", no caption, no style.
1764    ///
1765    /// Returns an error if start equals end.
1766    ///
1767    /// # Errors
1768    ///
1769    /// Returns [`CoreError::InvalidStructure`] if start equals end.
1770    ///
1771    /// # Examples
1772    ///
1773    /// ```
1774    /// use hwpforge_core::control::{Control, ShapePoint};
1775    ///
1776    /// let ctrl = Control::connect_line(
1777    ///     ShapePoint::new(0, 0),
1778    ///     ShapePoint::new(5000, 5000),
1779    /// ).unwrap();
1780    /// assert!(ctrl.is_connect_line());
1781    /// ```
1782    pub fn connect_line(start: ShapePoint, end: ShapePoint) -> CoreResult<Self> {
1783        if start == end {
1784            return Err(CoreError::InvalidStructure {
1785                context: "Control::connect_line".to_string(),
1786                reason: "start and end points are identical (degenerate line)".to_string(),
1787            });
1788        }
1789        // Normalize points to bounding-box-relative coordinates.
1790        // HWPX requires startPt/endPt within the shape's bounding box (0,0)→(w,h).
1791        let min_x = start.x.min(end.x);
1792        let min_y = start.y.min(end.y);
1793        let norm_start =
1794            ShapePoint::new(start.x.saturating_sub(min_x), start.y.saturating_sub(min_y));
1795        let norm_end = ShapePoint::new(end.x.saturating_sub(min_x), end.y.saturating_sub(min_y));
1796
1797        let raw_w =
1798            i32::try_from(((end.x as i64) - (start.x as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1799        let raw_h =
1800            i32::try_from(((end.y as i64) - (start.y as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1801        let raw_w = raw_w.max(100);
1802        let raw_h = raw_h.max(100);
1803        let width = HwpUnit::new(raw_w).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1804        let height = HwpUnit::new(raw_h).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1805        Ok(Self::ConnectLine {
1806            start: norm_start,
1807            end: norm_end,
1808            control_points: Vec::new(),
1809            connect_type: "STRAIGHT".to_string(),
1810            width,
1811            height,
1812            placement: None,
1813            caption: None,
1814            style: None,
1815        })
1816    }
1817
1818    /// Creates a hyperlink control with the given display text and URL.
1819    ///
1820    /// # Examples
1821    ///
1822    /// ```
1823    /// use hwpforge_core::control::Control;
1824    ///
1825    /// let ctrl = Control::hyperlink("Visit Rust", "https://rust-lang.org");
1826    /// assert!(ctrl.is_hyperlink());
1827    /// ```
1828    pub fn hyperlink(text: &str, url: &str) -> Self {
1829        Self::Hyperlink { text: text.to_string(), url: url.to_string() }
1830    }
1831}
1832
1833impl std::fmt::Display for Control {
1834    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1835        match self {
1836            Self::TextBox { paragraphs, .. } => {
1837                let n = paragraphs.len();
1838                let word = if n == 1 { "paragraph" } else { "paragraphs" };
1839                write!(f, "TextBox({n} {word})")
1840            }
1841            Self::Hyperlink { text, url } => {
1842                let preview: String =
1843                    if text.len() > 30 { text.chars().take(30).collect() } else { text.clone() };
1844                write!(f, "Hyperlink(\"{preview}\" -> {url})")
1845            }
1846            Self::Footnote { paragraphs, .. } => {
1847                let n = paragraphs.len();
1848                let word = if n == 1 { "paragraph" } else { "paragraphs" };
1849                write!(f, "Footnote({n} {word})")
1850            }
1851            Self::Endnote { paragraphs, .. } => {
1852                let n = paragraphs.len();
1853                let word = if n == 1 { "paragraph" } else { "paragraphs" };
1854                write!(f, "Endnote({n} {word})")
1855            }
1856            Self::Line { .. } => {
1857                write!(f, "Line")
1858            }
1859            Self::Ellipse { paragraphs, .. } => {
1860                let n = paragraphs.len();
1861                let word = if n == 1 { "paragraph" } else { "paragraphs" };
1862                write!(f, "Ellipse({n} {word})")
1863            }
1864            Self::Rect { width, height, .. } => {
1865                write!(f, "Rect({}x{})", width.as_i32(), height.as_i32())
1866            }
1867            Self::Polygon { vertices, paragraphs, .. } => {
1868                let nv = vertices.len();
1869                let np = paragraphs.len();
1870                let vw = if nv == 1 { "vertex" } else { "vertices" };
1871                let pw = if np == 1 { "paragraph" } else { "paragraphs" };
1872                write!(f, "Polygon({nv} {vw}, {np} {pw})")
1873            }
1874            Self::Chart { chart_type, data, .. } => {
1875                let series_count = match data {
1876                    ChartData::Category { series, .. } => series.len(),
1877                    ChartData::Xy { series } => series.len(),
1878                };
1879                write!(f, "Chart({chart_type:?}, {series_count} series)")
1880            }
1881            Self::EmbeddedChart { chart_xml, ole_bytes, width, height, .. } => {
1882                write!(
1883                    f,
1884                    "EmbeddedChart(xml={} bytes, ole={} bytes, {}x{})",
1885                    chart_xml.len(),
1886                    ole_bytes.len(),
1887                    width.as_i32(),
1888                    height.as_i32()
1889                )
1890            }
1891            Self::Equation { script, .. } => {
1892                let preview: String = if script.len() > 30 {
1893                    script.chars().take(30).collect()
1894                } else {
1895                    script.clone()
1896                };
1897                write!(f, "Equation(\"{preview}\")")
1898            }
1899            Self::Dutmal { main_text, sub_text, .. } => {
1900                write!(f, "Dutmal(\"{main_text}\" / \"{sub_text}\")")
1901            }
1902            Self::Compose { compose_text, .. } => {
1903                write!(f, "Compose(\"{compose_text}\")")
1904            }
1905            Self::Arc { arc_type, .. } => {
1906                write!(f, "Arc({arc_type})")
1907            }
1908            Self::Curve { points, .. } => {
1909                write!(f, "Curve({} points)", points.len())
1910            }
1911            Self::ConnectLine { .. } => {
1912                write!(f, "ConnectLine")
1913            }
1914            Self::Group { children, .. } => {
1915                write!(f, "Group({} children)", children.len())
1916            }
1917            Self::TextArt { text, shape, .. } => {
1918                write!(f, "TextArt(\"{text}\", {shape})")
1919            }
1920            Self::Bookmark { name, bookmark_type } => {
1921                write!(f, "Bookmark(\"{name}\", {bookmark_type})")
1922            }
1923            Self::CrossRef { target, ref_type, .. } => {
1924                write!(f, "CrossRef({:?}, {ref_type})", target.as_display())
1925            }
1926            Self::Field { field_type, hint_text, name, .. } => {
1927                let hint = hint_text.as_deref().unwrap_or("");
1928                match name.as_deref().filter(|s| !s.is_empty()) {
1929                    Some(n) => write!(f, "Field({field_type}, name=\"{n}\", \"{hint}\")"),
1930                    None => write!(f, "Field({field_type}, \"{hint}\")"),
1931                }
1932            }
1933            Self::Memo { content, anchor_runs, .. } => {
1934                let n = content.len();
1935                let word = if n == 1 { "paragraph" } else { "paragraphs" };
1936                let anchor_len = anchor_runs.len();
1937                write!(f, "Memo({n} {word}, anchor={anchor_len} runs)")
1938            }
1939            Self::IndexMark { primary, secondary } => {
1940                if let Some(sec) = secondary {
1941                    write!(f, "IndexMark(\"{primary}\" / \"{sec}\")")
1942                } else {
1943                    write!(f, "IndexMark(\"{primary}\")")
1944                }
1945            }
1946            Self::UnknownSummary { token, .. } => {
1947                write!(f, "UnknownSummary({token})")
1948            }
1949            Self::DateCodeField { is_time_mode, .. } => {
1950                let mode = if *is_time_mode { "time" } else { "date" };
1951                write!(f, "DateCodeField({mode})")
1952            }
1953            Self::PathField { command, .. } => {
1954                write!(f, "PathField({})", command.wire_command())
1955            }
1956            Self::InlinePageNumber { kind } => match kind {
1957                InlinePageKind::CurrentPage => write!(f, "InlinePageNumber(current)"),
1958                InlinePageKind::TotalPages => write!(f, "InlinePageNumber(total)"),
1959                InlinePageKind::Unknown => write!(f, "InlinePageNumber(unknown)"),
1960            },
1961            Self::NewNumber { kind, number } => {
1962                write!(f, "NewNumber({kind:?}, {number})")
1963            }
1964            Self::PageHiding {
1965                hide_header,
1966                hide_footer,
1967                hide_master_page,
1968                hide_border,
1969                hide_fill,
1970                hide_page_num,
1971            } => {
1972                let flags: Vec<&str> = [
1973                    (*hide_header, "header"),
1974                    (*hide_footer, "footer"),
1975                    (*hide_master_page, "master_page"),
1976                    (*hide_border, "border"),
1977                    (*hide_fill, "fill"),
1978                    (*hide_page_num, "page_num"),
1979                ]
1980                .into_iter()
1981                .filter_map(|(on, name)| on.then_some(name))
1982                .collect();
1983                write!(f, "PageHiding({})", flags.join(","))
1984            }
1985            Self::Unknown { tag, .. } => {
1986                write!(f, "Unknown({tag})")
1987            }
1988        }
1989    }
1990}
1991
1992#[cfg(test)]
1993mod tests {
1994    use super::*;
1995    use crate::run::Run;
1996    use hwpforge_foundation::{CharShapeIndex, Color, ParaShapeIndex, VerticalAlign};
1997
1998    fn simple_paragraph() -> Paragraph {
1999        Paragraph::with_runs(
2000            vec![Run::text("footnote text", CharShapeIndex::new(0))],
2001            ParaShapeIndex::new(0),
2002        )
2003    }
2004
2005    #[test]
2006    fn shape_style_default_all_none() {
2007        let s = ShapeStyle::default();
2008        assert!(s.line_color.is_none());
2009        assert!(s.fill_color.is_none());
2010        assert!(s.line_width.is_none());
2011        assert!(s.line_style.is_none());
2012    }
2013
2014    #[test]
2015    fn shape_style_with_typed_fields() {
2016        let s = ShapeStyle {
2017            line_color: Some(Color::from_rgb(255, 0, 0)),
2018            fill_color: Some(Color::from_rgb(0, 255, 0)),
2019            line_width: Some(100),
2020            line_style: Some(LineStyle::Dash),
2021            ..Default::default()
2022        };
2023        assert_eq!(s.line_color.unwrap(), Color::from_rgb(255, 0, 0));
2024        assert_eq!(s.fill_color.unwrap(), Color::from_rgb(0, 255, 0));
2025        assert_eq!(s.line_width.unwrap(), 100);
2026        assert_eq!(s.line_style.unwrap(), LineStyle::Dash);
2027    }
2028
2029    #[test]
2030    fn line_style_default() {
2031        assert_eq!(LineStyle::default(), LineStyle::Solid);
2032    }
2033
2034    #[test]
2035    fn line_style_display() {
2036        assert_eq!(LineStyle::Solid.to_string(), "SOLID");
2037        assert_eq!(LineStyle::Dash.to_string(), "DASH");
2038        assert_eq!(LineStyle::Dot.to_string(), "DOT");
2039        assert_eq!(LineStyle::DashDot.to_string(), "DASH_DOT");
2040        assert_eq!(LineStyle::DashDotDot.to_string(), "DASH_DOT_DOT");
2041        assert_eq!(LineStyle::None.to_string(), "NONE");
2042    }
2043
2044    #[test]
2045    fn line_style_from_str() {
2046        assert_eq!("SOLID".parse::<LineStyle>().unwrap(), LineStyle::Solid);
2047        assert_eq!("Dash".parse::<LineStyle>().unwrap(), LineStyle::Dash);
2048        assert_eq!("dot".parse::<LineStyle>().unwrap(), LineStyle::Dot);
2049        assert_eq!("DASH_DOT".parse::<LineStyle>().unwrap(), LineStyle::DashDot);
2050        assert_eq!("DashDotDot".parse::<LineStyle>().unwrap(), LineStyle::DashDotDot);
2051        assert_eq!("NONE".parse::<LineStyle>().unwrap(), LineStyle::None);
2052        assert!("INVALID".parse::<LineStyle>().is_err());
2053    }
2054
2055    #[test]
2056    fn line_style_serde_roundtrip() {
2057        for style in [
2058            LineStyle::Solid,
2059            LineStyle::Dash,
2060            LineStyle::Dot,
2061            LineStyle::DashDot,
2062            LineStyle::DashDotDot,
2063            LineStyle::None,
2064        ] {
2065            let json = serde_json::to_string(&style).unwrap();
2066            let back: LineStyle = serde_json::from_str(&json).unwrap();
2067            assert_eq!(style, back);
2068        }
2069    }
2070
2071    #[test]
2072    fn text_box_construction() {
2073        let ctrl = Control::TextBox {
2074            paragraphs: vec![simple_paragraph()],
2075            width: HwpUnit::from_mm(80.0).unwrap(),
2076            height: HwpUnit::from_mm(40.0).unwrap(),
2077            placement: None,
2078            caption: None,
2079            style: None,
2080            text_vertical_align: VerticalAlign::Top,
2081        };
2082        assert!(ctrl.is_text_box());
2083        assert!(!ctrl.is_hyperlink());
2084        assert!(!ctrl.is_footnote());
2085        assert!(!ctrl.is_endnote());
2086        assert!(!ctrl.is_unknown());
2087    }
2088
2089    #[test]
2090    fn hyperlink_construction() {
2091        let ctrl = Control::Hyperlink {
2092            text: "Click".to_string(),
2093            url: "https://example.com".to_string(),
2094        };
2095        assert!(ctrl.is_hyperlink());
2096        assert!(!ctrl.is_text_box());
2097    }
2098
2099    #[test]
2100    fn footnote_construction() {
2101        let ctrl = Control::Footnote { inst_id: None, paragraphs: vec![simple_paragraph()] };
2102        assert!(ctrl.is_footnote());
2103        assert!(!ctrl.is_text_box());
2104        assert!(!ctrl.is_endnote());
2105    }
2106
2107    #[test]
2108    fn endnote_construction() {
2109        let ctrl = Control::Endnote {
2110            inst_id: Some(ObjectId::new(123456)),
2111            paragraphs: vec![simple_paragraph()],
2112        };
2113        assert!(ctrl.is_endnote());
2114        assert!(!ctrl.is_footnote());
2115        assert!(!ctrl.is_text_box());
2116    }
2117
2118    #[test]
2119    fn unknown_construction() {
2120        let ctrl = Control::Unknown {
2121            tag: "custom:widget".to_string(),
2122            data: Some("<data>value</data>".to_string()),
2123        };
2124        assert!(ctrl.is_unknown());
2125    }
2126
2127    #[test]
2128    fn unknown_without_data() {
2129        let ctrl = Control::Unknown { tag: "header".to_string(), data: None };
2130        assert!(ctrl.is_unknown());
2131    }
2132
2133    #[test]
2134    fn display_and_kind_name_for_page_control_variants() {
2135        // W2/W3 variant 의 Display·kind_name·traversal no-op 커버 (커버리지
2136        // 게이트 — 리눅스는 폰트 의존 테스트 스킵으로 마진이 얇다).
2137        let nn = Control::NewNumber { kind: NewNumberKind::Page, number: 7 };
2138        assert_eq!(nn.to_string(), "NewNumber(Page, 7)");
2139        assert_eq!(nn.kind_name(), "new_number");
2140
2141        let ph = Control::PageHiding {
2142            hide_header: true,
2143            hide_footer: false,
2144            hide_master_page: false,
2145            hide_border: false,
2146            hide_fill: true,
2147            hide_page_num: true,
2148        };
2149        assert_eq!(ph.to_string(), "PageHiding(header,fill,page_num)");
2150        assert_eq!(ph.kind_name(), "page_hiding");
2151
2152        // 빈 mask (corpus 실측 존재 — 렌더 no-op).
2153        let empty = Control::PageHiding {
2154            hide_header: false,
2155            hide_footer: false,
2156            hide_master_page: false,
2157            hide_border: false,
2158            hide_fill: false,
2159            hide_page_num: false,
2160        };
2161        assert_eq!(empty.to_string(), "PageHiding()");
2162
2163        // traversal: leaf 컨트롤은 중첩 문단이 없다.
2164        let mut nn = nn;
2165        let mut ph = ph;
2166        let mut seen = 0usize;
2167        nn.walk_paragraphs_mut(&mut |_| seen += 1);
2168        ph.walk_paragraphs_mut(&mut |_| seen += 1);
2169        assert_eq!(seen, 0);
2170    }
2171
2172    #[test]
2173    fn display_text_box() {
2174        let ctrl = Control::TextBox {
2175            paragraphs: vec![simple_paragraph(), simple_paragraph()],
2176            width: HwpUnit::from_mm(80.0).unwrap(),
2177            height: HwpUnit::from_mm(40.0).unwrap(),
2178            placement: None,
2179            caption: None,
2180            style: None,
2181            text_vertical_align: VerticalAlign::Top,
2182        };
2183        assert_eq!(ctrl.to_string(), "TextBox(2 paragraphs)");
2184    }
2185
2186    #[test]
2187    fn display_hyperlink() {
2188        let ctrl =
2189            Control::Hyperlink { text: "Short".to_string(), url: "https://x.com".to_string() };
2190        let s = ctrl.to_string();
2191        assert!(s.contains("Short"), "display: {s}");
2192        assert!(s.contains("https://x.com"), "display: {s}");
2193    }
2194
2195    #[test]
2196    fn display_hyperlink_long_text_truncated() {
2197        let ctrl =
2198            Control::Hyperlink { text: "A".repeat(100), url: "https://example.com".to_string() };
2199        let s = ctrl.to_string();
2200        // Should show first 30 chars
2201        assert!(s.contains(&"A".repeat(30)), "display: {s}");
2202        assert!(!s.contains(&"A".repeat(31)), "display: {s}");
2203    }
2204
2205    #[test]
2206    fn display_footnote() {
2207        let ctrl = Control::Footnote { inst_id: None, paragraphs: vec![simple_paragraph()] };
2208        assert_eq!(ctrl.to_string(), "Footnote(1 paragraph)");
2209    }
2210
2211    #[test]
2212    fn display_endnote() {
2213        let ctrl = Control::Endnote {
2214            inst_id: Some(ObjectId::new(999)),
2215            paragraphs: vec![simple_paragraph()],
2216        };
2217        assert_eq!(ctrl.to_string(), "Endnote(1 paragraph)");
2218    }
2219
2220    #[test]
2221    fn display_unknown() {
2222        let ctrl = Control::Unknown { tag: "bookmark".to_string(), data: None };
2223        assert_eq!(ctrl.to_string(), "Unknown(bookmark)");
2224    }
2225
2226    #[test]
2227    fn equality() {
2228        let a = Control::Hyperlink { text: "A".to_string(), url: "B".to_string() };
2229        let b = Control::Hyperlink { text: "A".to_string(), url: "B".to_string() };
2230        let c = Control::Hyperlink { text: "A".to_string(), url: "C".to_string() };
2231        assert_eq!(a, b);
2232        assert_ne!(a, c);
2233    }
2234
2235    #[test]
2236    fn serde_roundtrip_text_box() {
2237        let ctrl = Control::TextBox {
2238            paragraphs: vec![simple_paragraph()],
2239            width: HwpUnit::from_mm(80.0).unwrap(),
2240            height: HwpUnit::from_mm(40.0).unwrap(),
2241            placement: None,
2242            caption: None,
2243            style: None,
2244            text_vertical_align: VerticalAlign::Top,
2245        };
2246        let json = serde_json::to_string(&ctrl).unwrap();
2247        let back: Control = serde_json::from_str(&json).unwrap();
2248        assert_eq!(ctrl, back);
2249    }
2250
2251    #[test]
2252    fn serde_roundtrip_hyperlink() {
2253        let ctrl = Control::Hyperlink {
2254            text: "link text".to_string(),
2255            url: "https://rust-lang.org".to_string(),
2256        };
2257        let json = serde_json::to_string(&ctrl).unwrap();
2258        let back: Control = serde_json::from_str(&json).unwrap();
2259        assert_eq!(ctrl, back);
2260    }
2261
2262    #[test]
2263    fn serde_roundtrip_footnote() {
2264        let ctrl = Control::Footnote {
2265            inst_id: Some(ObjectId::new(12345)),
2266            paragraphs: vec![simple_paragraph()],
2267        };
2268        let json = serde_json::to_string(&ctrl).unwrap();
2269        let back: Control = serde_json::from_str(&json).unwrap();
2270        assert_eq!(ctrl, back);
2271    }
2272
2273    #[test]
2274    fn serde_roundtrip_endnote() {
2275        let ctrl = Control::Endnote { inst_id: None, paragraphs: vec![simple_paragraph()] };
2276        let json = serde_json::to_string(&ctrl).unwrap();
2277        let back: Control = serde_json::from_str(&json).unwrap();
2278        assert_eq!(ctrl, back);
2279    }
2280
2281    #[test]
2282    fn serde_roundtrip_unknown() {
2283        let ctrl = Control::Unknown { tag: "test".to_string(), data: Some("payload".to_string()) };
2284        let json = serde_json::to_string(&ctrl).unwrap();
2285        let back: Control = serde_json::from_str(&json).unwrap();
2286        assert_eq!(ctrl, back);
2287    }
2288
2289    // ── Shape variant tests ──────────────────────────────────────
2290
2291    #[test]
2292    fn line_construction() {
2293        let ctrl = Control::Line {
2294            start: ShapePoint { x: 0, y: 0 },
2295            end: ShapePoint { x: 1000, y: 500 },
2296            width: HwpUnit::from_mm(50.0).unwrap(),
2297            height: HwpUnit::from_mm(25.0).unwrap(),
2298            placement: None,
2299            caption: None,
2300            style: None,
2301        };
2302        assert!(ctrl.is_line());
2303        assert!(!ctrl.is_text_box());
2304        assert!(!ctrl.is_ellipse());
2305        assert!(!ctrl.is_polygon());
2306    }
2307
2308    #[test]
2309    fn ellipse_construction() {
2310        let ctrl = Control::Ellipse {
2311            center: ShapePoint { x: 500, y: 500 },
2312            axis1: ShapePoint { x: 1000, y: 500 },
2313            axis2: ShapePoint { x: 500, y: 1000 },
2314            width: HwpUnit::from_mm(40.0).unwrap(),
2315            height: HwpUnit::from_mm(30.0).unwrap(),
2316            placement: None,
2317            paragraphs: vec![],
2318            caption: None,
2319            style: None,
2320            text_vertical_align: VerticalAlign::Top,
2321        };
2322        assert!(ctrl.is_ellipse());
2323        assert!(!ctrl.is_line());
2324        assert!(!ctrl.is_polygon());
2325    }
2326
2327    #[test]
2328    fn ellipse_with_paragraphs() {
2329        let ctrl = Control::Ellipse {
2330            center: ShapePoint { x: 500, y: 500 },
2331            axis1: ShapePoint { x: 1000, y: 500 },
2332            axis2: ShapePoint { x: 500, y: 1000 },
2333            width: HwpUnit::from_mm(40.0).unwrap(),
2334            height: HwpUnit::from_mm(30.0).unwrap(),
2335            placement: None,
2336            paragraphs: vec![simple_paragraph()],
2337            caption: None,
2338            style: None,
2339            text_vertical_align: VerticalAlign::Top,
2340        };
2341        assert!(ctrl.is_ellipse());
2342        assert_eq!(ctrl.to_string(), "Ellipse(1 paragraph)");
2343    }
2344
2345    #[test]
2346    fn polygon_construction() {
2347        let ctrl = Control::Polygon {
2348            vertices: vec![
2349                ShapePoint { x: 0, y: 0 },
2350                ShapePoint { x: 1000, y: 0 },
2351                ShapePoint { x: 500, y: 1000 },
2352            ],
2353            width: HwpUnit::from_mm(50.0).unwrap(),
2354            height: HwpUnit::from_mm(50.0).unwrap(),
2355            placement: None,
2356            paragraphs: vec![],
2357            caption: None,
2358            style: None,
2359            text_vertical_align: VerticalAlign::Top,
2360        };
2361        assert!(ctrl.is_polygon());
2362        assert!(!ctrl.is_line());
2363        assert!(!ctrl.is_ellipse());
2364        assert_eq!(ctrl.to_string(), "Polygon(3 vertices, 0 paragraphs)");
2365    }
2366
2367    #[test]
2368    fn display_line() {
2369        let ctrl = Control::Line {
2370            start: ShapePoint { x: 0, y: 0 },
2371            end: ShapePoint { x: 100, y: 200 },
2372            width: HwpUnit::from_mm(10.0).unwrap(),
2373            height: HwpUnit::from_mm(5.0).unwrap(),
2374            placement: None,
2375            caption: None,
2376            style: None,
2377        };
2378        assert_eq!(ctrl.to_string(), "Line");
2379    }
2380
2381    #[test]
2382    fn serde_roundtrip_line() {
2383        let ctrl = Control::Line {
2384            start: ShapePoint { x: 100, y: 200 },
2385            end: ShapePoint { x: 300, y: 400 },
2386            width: HwpUnit::from_mm(20.0).unwrap(),
2387            height: HwpUnit::from_mm(10.0).unwrap(),
2388            placement: None,
2389            caption: None,
2390            style: None,
2391        };
2392        let json = serde_json::to_string(&ctrl).unwrap();
2393        let back: Control = serde_json::from_str(&json).unwrap();
2394        assert_eq!(ctrl, back);
2395    }
2396
2397    #[test]
2398    fn serde_roundtrip_ellipse() {
2399        let ctrl = Control::Ellipse {
2400            center: ShapePoint { x: 500, y: 500 },
2401            axis1: ShapePoint { x: 1000, y: 500 },
2402            axis2: ShapePoint { x: 500, y: 1000 },
2403            width: HwpUnit::from_mm(40.0).unwrap(),
2404            height: HwpUnit::from_mm(30.0).unwrap(),
2405            placement: None,
2406            paragraphs: vec![simple_paragraph()],
2407            caption: None,
2408            style: None,
2409            text_vertical_align: VerticalAlign::Top,
2410        };
2411        let json = serde_json::to_string(&ctrl).unwrap();
2412        let back: Control = serde_json::from_str(&json).unwrap();
2413        assert_eq!(ctrl, back);
2414    }
2415
2416    #[test]
2417    fn serde_roundtrip_polygon() {
2418        let ctrl = Control::Polygon {
2419            vertices: vec![
2420                ShapePoint { x: 0, y: 0 },
2421                ShapePoint { x: 1000, y: 0 },
2422                ShapePoint { x: 500, y: 1000 },
2423            ],
2424            width: HwpUnit::from_mm(50.0).unwrap(),
2425            height: HwpUnit::from_mm(50.0).unwrap(),
2426            placement: None,
2427            paragraphs: vec![],
2428            caption: None,
2429            style: None,
2430            text_vertical_align: VerticalAlign::Top,
2431        };
2432        let json = serde_json::to_string(&ctrl).unwrap();
2433        let back: Control = serde_json::from_str(&json).unwrap();
2434        assert_eq!(ctrl, back);
2435    }
2436
2437    #[test]
2438    fn shape_point_equality() {
2439        let a = ShapePoint { x: 10, y: 20 };
2440        let b = ShapePoint { x: 10, y: 20 };
2441        let c = ShapePoint { x: 10, y: 30 };
2442        assert_eq!(a, b);
2443        assert_ne!(a, c);
2444    }
2445
2446    #[test]
2447    fn shape_point_new() {
2448        let pt = ShapePoint::new(100, 200);
2449        assert_eq!(pt.x, 100);
2450        assert_eq!(pt.y, 200);
2451    }
2452
2453    #[test]
2454    fn shape_point_serde_roundtrip() {
2455        let pt = ShapePoint::new(500, 750);
2456        let json = serde_json::to_string(&pt).unwrap();
2457        let back: ShapePoint = serde_json::from_str(&json).unwrap();
2458        assert_eq!(pt, back);
2459    }
2460
2461    // ── Convenience constructor tests ────────────────────────────────────
2462
2463    #[test]
2464    fn equation_constructor_defaults() {
2465        let ctrl = Control::equation("{a+b} over {c+d}");
2466        assert!(ctrl.is_equation());
2467        match ctrl {
2468            Control::Equation {
2469                script,
2470                width,
2471                height,
2472                base_line,
2473                text_color,
2474                ref font,
2475                inst_id: _,
2476            } => {
2477                assert_eq!(script, "{a+b} over {c+d}");
2478                assert_eq!(width, HwpUnit::new(8779).unwrap());
2479                assert_eq!(height, HwpUnit::new(2600).unwrap());
2480                assert_eq!(base_line, 71);
2481                assert_eq!(text_color, Color::BLACK);
2482                assert_eq!(font, "HancomEQN");
2483            }
2484            _ => panic!("expected Equation"),
2485        }
2486    }
2487
2488    #[test]
2489    fn equation_constructor_empty_script() {
2490        let ctrl = Control::equation("");
2491        assert!(ctrl.is_equation());
2492    }
2493
2494    #[test]
2495    fn text_box_constructor_defaults() {
2496        let width = HwpUnit::from_mm(80.0).unwrap();
2497        let height = HwpUnit::from_mm(40.0).unwrap();
2498        let ctrl = Control::text_box(vec![simple_paragraph()], width, height);
2499        assert!(ctrl.is_text_box());
2500        match ctrl {
2501            Control::TextBox { paragraphs, placement, caption, style, .. } => {
2502                assert_eq!(paragraphs.len(), 1);
2503                assert!(placement.is_none());
2504                assert!(caption.is_none());
2505                assert!(style.is_none());
2506            }
2507            _ => panic!("expected TextBox"),
2508        }
2509    }
2510
2511    #[test]
2512    fn footnote_constructor_defaults() {
2513        let ctrl = Control::footnote(vec![simple_paragraph()]);
2514        assert!(ctrl.is_footnote());
2515        match ctrl {
2516            Control::Footnote { inst_id, paragraphs } => {
2517                assert!(inst_id.is_none());
2518                assert_eq!(paragraphs.len(), 1);
2519            }
2520            _ => panic!("expected Footnote"),
2521        }
2522    }
2523
2524    #[test]
2525    fn endnote_constructor_defaults() {
2526        let ctrl = Control::endnote(vec![simple_paragraph()]);
2527        assert!(ctrl.is_endnote());
2528        match ctrl {
2529            Control::Endnote { inst_id, paragraphs } => {
2530                assert!(inst_id.is_none());
2531                assert_eq!(paragraphs.len(), 1);
2532            }
2533            _ => panic!("expected Endnote"),
2534        }
2535    }
2536
2537    #[test]
2538    fn ellipse_constructor_geometry() {
2539        let width = HwpUnit::from_mm(40.0).unwrap();
2540        let height = HwpUnit::from_mm(30.0).unwrap();
2541        let ctrl = Control::ellipse(width, height);
2542        assert!(ctrl.is_ellipse());
2543        match &ctrl {
2544            Control::Ellipse {
2545                center,
2546                axis1,
2547                axis2,
2548                placement,
2549                paragraphs,
2550                caption,
2551                style,
2552                ..
2553            } => {
2554                let w = width.as_i32();
2555                let h = height.as_i32();
2556                assert_eq!(*center, ShapePoint::new(w / 2, h / 2));
2557                assert_eq!(*axis1, ShapePoint::new(w, h / 2));
2558                assert_eq!(*axis2, ShapePoint::new(w / 2, h));
2559                assert!(placement.is_none());
2560                assert!(paragraphs.is_empty());
2561                assert!(caption.is_none());
2562                assert!(style.is_none());
2563            }
2564            _ => panic!("expected Ellipse"),
2565        }
2566    }
2567
2568    #[test]
2569    fn rect_constructor_basic_geometry() {
2570        let width = HwpUnit::from_mm(40.0).unwrap();
2571        let height = HwpUnit::from_mm(20.0).unwrap();
2572        let ctrl = Control::rect(width, height).unwrap();
2573        assert!(ctrl.is_rect());
2574        match ctrl {
2575            Control::Rect { width: w, height: h, placement, caption, style } => {
2576                assert_eq!(w, width);
2577                assert_eq!(h, height);
2578                assert!(placement.is_none());
2579                assert!(caption.is_none());
2580                assert!(style.is_none());
2581            }
2582            _ => panic!("expected Rect"),
2583        }
2584    }
2585
2586    #[test]
2587    fn rect_constructor_zero_dimension_errors() {
2588        let zero = HwpUnit::new(0).unwrap();
2589        let nonzero = HwpUnit::from_mm(10.0).unwrap();
2590        assert!(Control::rect(zero, nonzero).is_err());
2591        assert!(Control::rect(nonzero, zero).is_err());
2592    }
2593
2594    #[test]
2595    fn polygon_constructor_triangle() {
2596        let vertices =
2597            vec![ShapePoint::new(0, 1000), ShapePoint::new(500, 0), ShapePoint::new(1000, 1000)];
2598        let ctrl = Control::polygon(vertices).unwrap();
2599        assert!(ctrl.is_polygon());
2600        match &ctrl {
2601            Control::Polygon {
2602                vertices,
2603                width,
2604                height,
2605                placement,
2606                paragraphs,
2607                caption,
2608                style,
2609                ..
2610            } => {
2611                assert_eq!(vertices.len(), 3);
2612                // bbox: x 0..1000, y 0..1000
2613                assert_eq!(*width, HwpUnit::new(1000).unwrap());
2614                assert_eq!(*height, HwpUnit::new(1000).unwrap());
2615                assert!(placement.is_none());
2616                assert!(paragraphs.is_empty());
2617                assert!(caption.is_none());
2618                assert!(style.is_none());
2619            }
2620            _ => panic!("expected Polygon"),
2621        }
2622    }
2623
2624    #[test]
2625    fn polygon_constructor_fewer_than_3_vertices_errors() {
2626        assert!(Control::polygon(vec![]).is_err());
2627        assert!(Control::polygon(vec![ShapePoint::new(0, 0)]).is_err());
2628        assert!(Control::polygon(vec![ShapePoint::new(0, 0), ShapePoint::new(1, 1)]).is_err());
2629    }
2630
2631    #[test]
2632    fn polygon_constructor_negative_coordinates() {
2633        let vertices =
2634            vec![ShapePoint::new(-500, -500), ShapePoint::new(500, -500), ShapePoint::new(0, 500)];
2635        let ctrl = Control::polygon(vertices).unwrap();
2636        assert!(ctrl.is_polygon());
2637        match ctrl {
2638            Control::Polygon { width, height, .. } => {
2639                // bbox: x -500..500 = 1000, y -500..500 = 1000
2640                assert_eq!(width, HwpUnit::new(1000).unwrap());
2641                assert_eq!(height, HwpUnit::new(1000).unwrap());
2642            }
2643            _ => panic!("expected Polygon"),
2644        }
2645    }
2646
2647    #[test]
2648    fn polygon_constructor_degenerate_collinear() {
2649        // 3 collinear points: height = 0 (flat), should succeed
2650        let vertices =
2651            vec![ShapePoint::new(0, 0), ShapePoint::new(500, 0), ShapePoint::new(1000, 0)];
2652        let ctrl = Control::polygon(vertices).unwrap();
2653        assert!(ctrl.is_polygon());
2654        match ctrl {
2655            Control::Polygon { width, height, .. } => {
2656                assert_eq!(width, HwpUnit::new(1000).unwrap());
2657                assert_eq!(height, HwpUnit::new(0).unwrap());
2658            }
2659            _ => panic!("expected Polygon"),
2660        }
2661    }
2662
2663    #[test]
2664    fn line_constructor_horizontal() {
2665        let ctrl = Control::line(ShapePoint::new(0, 0), ShapePoint::new(5000, 0)).unwrap();
2666        assert!(ctrl.is_line());
2667        match ctrl {
2668            Control::Line { start, end, width, height, placement, caption, style } => {
2669                assert_eq!(start, ShapePoint::new(0, 0));
2670                assert_eq!(end, ShapePoint::new(5000, 0));
2671                assert_eq!(width, HwpUnit::new(5000).unwrap());
2672                assert_eq!(height, HwpUnit::new(100).unwrap()); // min bounding box
2673                assert!(placement.is_none());
2674                assert!(caption.is_none());
2675                assert!(style.is_none());
2676            }
2677            _ => panic!("expected Line"),
2678        }
2679    }
2680
2681    #[test]
2682    fn line_constructor_vertical() {
2683        let ctrl = Control::line(ShapePoint::new(0, 0), ShapePoint::new(0, 3000)).unwrap();
2684        assert!(ctrl.is_line());
2685        match ctrl {
2686            Control::Line { width, height, .. } => {
2687                assert_eq!(width, HwpUnit::new(100).unwrap()); // min bounding box
2688                assert_eq!(height, HwpUnit::new(3000).unwrap());
2689            }
2690            _ => panic!("expected Line"),
2691        }
2692    }
2693
2694    #[test]
2695    fn line_constructor_diagonal_bounding_box() {
2696        let ctrl = Control::line(ShapePoint::new(100, 200), ShapePoint::new(400, 500)).unwrap();
2697        match ctrl {
2698            Control::Line { width, height, .. } => {
2699                assert_eq!(width, HwpUnit::new(300).unwrap());
2700                assert_eq!(height, HwpUnit::new(300).unwrap());
2701            }
2702            _ => panic!("expected Line"),
2703        }
2704    }
2705
2706    #[test]
2707    fn line_constructor_same_point_errors() {
2708        let pt = ShapePoint::new(100, 200);
2709        assert!(Control::line(pt, pt).is_err());
2710    }
2711
2712    #[test]
2713    fn horizontal_line_constructor() {
2714        let width = HwpUnit::from_mm(100.0).unwrap();
2715        let ctrl = Control::horizontal_line(width);
2716        assert!(ctrl.is_line());
2717        match ctrl {
2718            Control::Line { start, end, width: w, height, placement, caption, style } => {
2719                assert_eq!(start, ShapePoint::new(0, 0));
2720                assert_eq!(end.y, 0);
2721                assert_eq!(end.x, width.as_i32());
2722                assert_eq!(w, width);
2723                assert_eq!(height, HwpUnit::new(100).unwrap()); // min bounding box
2724                assert!(placement.is_none());
2725                assert!(caption.is_none());
2726                assert!(style.is_none());
2727            }
2728            _ => panic!("expected Line"),
2729        }
2730    }
2731
2732    #[test]
2733    fn hyperlink_constructor() {
2734        let ctrl = Control::hyperlink("Visit Rust", "https://rust-lang.org");
2735        assert!(ctrl.is_hyperlink());
2736        match ctrl {
2737            Control::Hyperlink { text, url } => {
2738                assert_eq!(text, "Visit Rust");
2739                assert_eq!(url, "https://rust-lang.org");
2740            }
2741            _ => panic!("expected Hyperlink"),
2742        }
2743    }
2744
2745    #[test]
2746    fn footnote_with_id_sets_inst_id() {
2747        let para = Paragraph::new(ParaShapeIndex::new(0));
2748        let ctrl = Control::footnote_with_id(42, vec![para]);
2749        assert!(ctrl.is_footnote());
2750        match ctrl {
2751            Control::Footnote { inst_id, paragraphs } => {
2752                assert_eq!(inst_id, Some(ObjectId::new(42)));
2753                assert_eq!(paragraphs.len(), 1);
2754            }
2755            _ => panic!("expected Footnote"),
2756        }
2757    }
2758
2759    #[test]
2760    fn endnote_with_id_sets_inst_id() {
2761        let para = Paragraph::new(ParaShapeIndex::new(0));
2762        let ctrl = Control::endnote_with_id(7, vec![para]);
2763        assert!(ctrl.is_endnote());
2764        match ctrl {
2765            Control::Endnote { inst_id, paragraphs } => {
2766                assert_eq!(inst_id, Some(ObjectId::new(7)));
2767                assert_eq!(paragraphs.len(), 1);
2768            }
2769            _ => panic!("expected Endnote"),
2770        }
2771    }
2772
2773    #[test]
2774    fn footnote_with_id_differs_from_plain_footnote() {
2775        let ctrl_plain = Control::footnote(vec![]);
2776        let ctrl_id = Control::footnote_with_id(1, vec![]);
2777        match ctrl_plain {
2778            Control::Footnote { inst_id, .. } => assert_eq!(inst_id, None),
2779            _ => panic!("expected Footnote"),
2780        }
2781        match ctrl_id {
2782            Control::Footnote { inst_id, .. } => assert_eq!(inst_id, Some(ObjectId::new(1))),
2783            _ => panic!("expected Footnote"),
2784        }
2785    }
2786
2787    #[test]
2788    fn ellipse_with_text_has_correct_geometry_and_paragraphs() {
2789        use hwpforge_foundation::HwpUnit;
2790        let width = HwpUnit::from_mm(40.0).unwrap();
2791        let height = HwpUnit::from_mm(30.0).unwrap();
2792        let para = Paragraph::new(ParaShapeIndex::new(0));
2793        let ctrl = Control::ellipse_with_text(width, height, vec![para]);
2794        assert!(ctrl.is_ellipse());
2795        match ctrl {
2796            Control::Ellipse {
2797                center,
2798                axis1,
2799                axis2,
2800                width: w,
2801                height: h,
2802                placement,
2803                paragraphs,
2804                caption,
2805                style,
2806                ..
2807            } => {
2808                let wv = w.as_i32();
2809                let hv = h.as_i32();
2810                assert_eq!(center, ShapePoint::new(wv / 2, hv / 2));
2811                assert_eq!(axis1, ShapePoint::new(wv, hv / 2));
2812                assert_eq!(axis2, ShapePoint::new(wv / 2, hv));
2813                assert!(placement.is_none());
2814                assert_eq!(paragraphs.len(), 1);
2815                assert!(caption.is_none());
2816                assert!(style.is_none());
2817            }
2818            _ => panic!("expected Ellipse"),
2819        }
2820    }
2821
2822    #[test]
2823    fn serde_roundtrip_chart() {
2824        use crate::chart::{ChartData, ChartGrouping, ChartType, LegendPosition};
2825        let ctrl = Control::Chart {
2826            chart_type: ChartType::Column,
2827            data: ChartData::category(&["A", "B"], &[("S1", &[1.0, 2.0])]),
2828            title: Some("Test Chart".to_string()),
2829            legend: LegendPosition::Bottom,
2830            grouping: ChartGrouping::Stacked,
2831            width: HwpUnit::from_mm(100.0).unwrap(),
2832            height: HwpUnit::from_mm(80.0).unwrap(),
2833            stock_variant: None,
2834            bar_shape: None,
2835            scatter_style: None,
2836            radar_style: None,
2837            of_pie_type: None,
2838            explosion: None,
2839            wireframe: None,
2840            bubble_3d: None,
2841            show_markers: None,
2842        };
2843        let json = serde_json::to_string(&ctrl).unwrap();
2844        let back: Control = serde_json::from_str(&json).unwrap();
2845        assert_eq!(ctrl, back);
2846    }
2847
2848    #[test]
2849    fn serde_roundtrip_equation() {
2850        let ctrl = Control::Equation {
2851            script: "{a+b} over {c+d}".to_string(),
2852            width: HwpUnit::new(8779).unwrap(),
2853            height: HwpUnit::new(2600).unwrap(),
2854            base_line: 71,
2855            text_color: Color::BLACK,
2856            font: "HancomEQN".to_string(),
2857            inst_id: None,
2858        };
2859        let json = serde_json::to_string(&ctrl).unwrap();
2860        let back: Control = serde_json::from_str(&json).unwrap();
2861        assert_eq!(ctrl, back);
2862    }
2863
2864    #[test]
2865    fn ellipse_with_text_empty_paragraphs_matches_ellipse() {
2866        use hwpforge_foundation::HwpUnit;
2867        let width = HwpUnit::from_mm(20.0).unwrap();
2868        let height = HwpUnit::from_mm(10.0).unwrap();
2869        let plain = Control::ellipse(width, height);
2870        let with_text = Control::ellipse_with_text(width, height, vec![]);
2871        // Both should produce identical shapes when paragraphs are empty
2872        assert_eq!(plain, with_text);
2873    }
2874
2875    // ── Dutmal (덧말) tests ──────────────────────────────────────
2876
2877    #[test]
2878    fn dutmal_constructor_defaults() {
2879        let ctrl = Control::dutmal("본문", "주석");
2880        assert!(ctrl.is_dutmal());
2881        match ctrl {
2882            Control::Dutmal { main_text, sub_text, position, sz_ratio, align, .. } => {
2883                assert_eq!(main_text, "본문");
2884                assert_eq!(sub_text, "주석");
2885                assert_eq!(position, DutmalPosition::Top);
2886                assert_eq!(sz_ratio, 0);
2887                assert_eq!(align, DutmalAlign::Center);
2888            }
2889            _ => panic!("expected Dutmal"),
2890        }
2891    }
2892
2893    #[test]
2894    fn dutmal_is_dutmal_true() {
2895        assert!(Control::dutmal("a", "b").is_dutmal());
2896    }
2897
2898    #[test]
2899    fn dutmal_is_compose_false() {
2900        assert!(!Control::dutmal("a", "b").is_compose());
2901    }
2902
2903    #[test]
2904    fn dutmal_display() {
2905        let ctrl = Control::dutmal("hello", "world");
2906        assert_eq!(ctrl.to_string(), r#"Dutmal("hello" / "world")"#);
2907    }
2908
2909    #[test]
2910    fn dutmal_serde_roundtrip() {
2911        let ctrl = Control::Dutmal {
2912            main_text: "테스트".to_string(),
2913            sub_text: "test".to_string(),
2914            position: DutmalPosition::Bottom,
2915            sz_ratio: 50,
2916            align: DutmalAlign::Right,
2917            metadata: DutmalMetadata::default(),
2918        };
2919        let json = serde_json::to_string(&ctrl).unwrap();
2920        let decoded: Control = serde_json::from_str(&json).unwrap();
2921        assert_eq!(ctrl, decoded);
2922    }
2923
2924    #[test]
2925    fn dutmal_position_default_is_top() {
2926        assert_eq!(DutmalPosition::default(), DutmalPosition::Top);
2927    }
2928
2929    #[test]
2930    fn dutmal_align_default_is_center() {
2931        assert_eq!(DutmalAlign::default(), DutmalAlign::Center);
2932    }
2933
2934    // ── Compose (글자겹침) tests ─────────────────────────────────
2935
2936    #[test]
2937    fn compose_constructor_defaults() {
2938        let ctrl = Control::compose("가");
2939        assert!(ctrl.is_compose());
2940        match ctrl {
2941            Control::Compose { compose_text, circle_type, char_sz, compose_type, char_pr_ids } => {
2942                assert_eq!(compose_text, "가");
2943                assert_eq!(circle_type, "SHAPE_REVERSAL_TIRANGLE");
2944                assert_eq!(char_sz, -3);
2945                assert_eq!(compose_type, "SPREAD");
2946                assert_eq!(char_pr_ids, vec![u32::MAX; 10]);
2947            }
2948            _ => panic!("expected Compose"),
2949        }
2950    }
2951
2952    #[test]
2953    fn compose_is_compose_true() {
2954        assert!(Control::compose("나").is_compose());
2955    }
2956
2957    #[test]
2958    fn compose_is_dutmal_false() {
2959        assert!(!Control::compose("나").is_dutmal());
2960    }
2961
2962    #[test]
2963    fn compose_display() {
2964        let ctrl = Control::compose("가나");
2965        assert_eq!(ctrl.to_string(), r#"Compose("가나")"#);
2966    }
2967
2968    #[test]
2969    fn compose_serde_roundtrip() {
2970        let ctrl = Control::Compose {
2971            compose_text: "①".to_string(),
2972            circle_type: "SHAPE_REVERSAL_TIRANGLE".to_string(),
2973            char_sz: -3,
2974            compose_type: "SPREAD".to_string(),
2975            char_pr_ids: vec![u32::MAX; 10],
2976        };
2977        let json = serde_json::to_string(&ctrl).unwrap();
2978        let decoded: Control = serde_json::from_str(&json).unwrap();
2979        assert_eq!(ctrl, decoded);
2980    }
2981
2982    #[test]
2983    fn compose_spec_typo_preserved() {
2984        // "SHAPE_REVERSAL_TIRANGLE" is an official spec typo — must be preserved exactly
2985        let ctrl = Control::compose("X");
2986        match ctrl {
2987            Control::Compose { circle_type, .. } => {
2988                assert_eq!(circle_type, "SHAPE_REVERSAL_TIRANGLE");
2989                assert!(!circle_type.contains("TRIANGLE")); // confirm the typo
2990            }
2991            _ => panic!("expected Compose"),
2992        }
2993    }
2994
2995    // ===================================================================
2996    // H2: saturating i64→i32 conversion in shape constructors
2997    // ===================================================================
2998
2999    #[test]
3000    fn line_extreme_coords_no_panic() {
3001        // Coordinates near i32 extremes produce a valid line without panicking
3002        let start = ShapePoint::new(i32::MIN, i32::MIN);
3003        let end = ShapePoint::new(i32::MAX, i32::MAX);
3004        let ctrl = Control::line(start, end).unwrap();
3005        assert!(ctrl.is_line());
3006    }
3007
3008    #[test]
3009    fn connect_line_extreme_coords_no_panic() {
3010        let start = ShapePoint::new(i32::MIN, 0);
3011        let end = ShapePoint::new(i32::MAX, 0);
3012        let ctrl = Control::connect_line(start, end).unwrap();
3013        assert!(ctrl.is_connect_line());
3014    }
3015
3016    #[test]
3017    fn polygon_extreme_coords_no_panic() {
3018        // Span exceeds i32::MAX — should error (HwpUnit range exceeded), not panic
3019        let vertices = vec![
3020            ShapePoint::new(i32::MIN, 0),
3021            ShapePoint::new(i32::MAX, 0),
3022            ShapePoint::new(0, i32::MAX),
3023        ];
3024        // Either succeeds (saturated) or returns an error — must not panic
3025        let _ = Control::polygon(vertices);
3026    }
3027
3028    #[test]
3029    fn curve_extreme_coords_no_panic() {
3030        let points = vec![ShapePoint::new(i32::MIN, i32::MIN), ShapePoint::new(i32::MAX, i32::MAX)];
3031        let _ = Control::curve(points);
3032    }
3033}