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