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