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 /// Returns the stable snake_case name of this control's kind.
858 ///
859 /// Read/diff projections use this to label embedded content without
860 /// exposing payload details. The match is deliberately exhaustive (no
861 /// wildcard) so adding a variant forces a name here at compile time.
862 #[must_use]
863 pub fn kind_name(&self) -> &'static str {
864 match self {
865 Self::TextBox { .. } => "text_box",
866 Self::Hyperlink { .. } => "hyperlink",
867 Self::Footnote { .. } => "footnote",
868 Self::Endnote { .. } => "endnote",
869 Self::Line { .. } => "line",
870 Self::Ellipse { .. } => "ellipse",
871 Self::EmbeddedChart { .. } => "embedded_chart",
872 Self::Rect { .. } => "rect",
873 Self::Polygon { .. } => "polygon",
874 Self::Equation { .. } => "equation",
875 Self::Chart { .. } => "chart",
876 Self::Dutmal { .. } => "dutmal",
877 Self::Compose { .. } => "compose",
878 Self::Arc { .. } => "arc",
879 Self::Curve { .. } => "curve",
880 Self::ConnectLine { .. } => "connect_line",
881 Self::Group { .. } => "group",
882 Self::TextArt { .. } => "text_art",
883 Self::Bookmark { .. } => "bookmark",
884 Self::CrossRef { .. } => "cross_ref",
885 Self::Field { .. } => "field",
886 Self::Memo { .. } => "memo",
887 Self::IndexMark { .. } => "index_mark",
888 Self::UnknownSummary { .. } => "unknown_summary",
889 Self::DateCodeField { .. } => "date_code_field",
890 Self::PathField { .. } => "path_field",
891 Self::InlinePageNumber { .. } => "inline_page_number",
892 Self::NewNumber { .. } => "new_number",
893 Self::PageHiding { .. } => "page_hiding",
894 Self::Unknown { .. } => "unknown",
895 }
896 }
897
898 /// Returns `true` if this is a [`Control::TextBox`].
899 pub fn is_text_box(&self) -> bool {
900 matches!(self, Self::TextBox { .. })
901 }
902
903 /// Returns `true` if this is a [`Control::Hyperlink`].
904 pub fn is_hyperlink(&self) -> bool {
905 matches!(self, Self::Hyperlink { .. })
906 }
907
908 /// Returns `true` if this is a [`Control::Footnote`].
909 pub fn is_footnote(&self) -> bool {
910 matches!(self, Self::Footnote { .. })
911 }
912
913 /// Returns `true` if this is a [`Control::Endnote`].
914 pub fn is_endnote(&self) -> bool {
915 matches!(self, Self::Endnote { .. })
916 }
917
918 /// Returns `true` if this is a [`Control::Line`].
919 pub fn is_line(&self) -> bool {
920 matches!(self, Self::Line { .. })
921 }
922
923 /// Returns `true` if this is a [`Control::Ellipse`].
924 pub fn is_ellipse(&self) -> bool {
925 matches!(self, Self::Ellipse { .. })
926 }
927
928 /// Returns `true` if this is a [`Control::Rect`].
929 pub fn is_rect(&self) -> bool {
930 matches!(self, Self::Rect { .. })
931 }
932
933 /// Returns `true` if this is a [`Control::Polygon`].
934 pub fn is_polygon(&self) -> bool {
935 matches!(self, Self::Polygon { .. })
936 }
937
938 /// Returns `true` if this is a [`Control::Equation`].
939 pub fn is_equation(&self) -> bool {
940 matches!(self, Self::Equation { .. })
941 }
942
943 /// Returns `true` if this is a [`Control::Chart`].
944 pub fn is_chart(&self) -> bool {
945 matches!(self, Self::Chart { .. })
946 }
947
948 /// Returns `true` if this is a [`Control::EmbeddedChart`].
949 pub fn is_embedded_chart(&self) -> bool {
950 matches!(self, Self::EmbeddedChart { .. })
951 }
952
953 /// Returns `true` if this is a [`Control::Unknown`].
954 pub fn is_unknown(&self) -> bool {
955 matches!(self, Self::Unknown { .. })
956 }
957
958 /// Returns `true` if this is a [`Control::Dutmal`].
959 pub fn is_dutmal(&self) -> bool {
960 matches!(self, Self::Dutmal { .. })
961 }
962
963 /// Returns `true` if this is a [`Control::Compose`].
964 pub fn is_compose(&self) -> bool {
965 matches!(self, Self::Compose { .. })
966 }
967
968 /// Returns `true` if this is a [`Control::Arc`].
969 pub fn is_arc(&self) -> bool {
970 matches!(self, Self::Arc { .. })
971 }
972
973 /// Returns `true` if this is a [`Control::Curve`].
974 pub fn is_curve(&self) -> bool {
975 matches!(self, Self::Curve { .. })
976 }
977
978 /// Returns `true` if this is a [`Control::ConnectLine`].
979 pub fn is_connect_line(&self) -> bool {
980 matches!(self, Self::ConnectLine { .. })
981 }
982
983 /// Returns `true` if this is a [`Control::Group`].
984 pub fn is_group(&self) -> bool {
985 matches!(self, Self::Group { .. })
986 }
987
988 /// Returns `true` if this is a [`Control::Bookmark`].
989 pub fn is_bookmark(&self) -> bool {
990 matches!(self, Self::Bookmark { .. })
991 }
992
993 /// Returns `true` if this is a [`Control::CrossRef`].
994 pub fn is_cross_ref(&self) -> bool {
995 matches!(self, Self::CrossRef { .. })
996 }
997
998 /// Returns `true` if this is a [`Control::Field`].
999 pub fn is_field(&self) -> bool {
1000 matches!(self, Self::Field { .. })
1001 }
1002
1003 /// Returns `true` if this is a [`Control::Memo`].
1004 pub fn is_memo(&self) -> bool {
1005 matches!(self, Self::Memo { .. })
1006 }
1007
1008 /// Returns `true` if this is a [`Control::IndexMark`].
1009 pub fn is_index_mark(&self) -> bool {
1010 matches!(self, Self::IndexMark { .. })
1011 }
1012
1013 /// Creates a point bookmark at a named location.
1014 ///
1015 /// # Examples
1016 ///
1017 /// ```
1018 /// use hwpforge_core::control::Control;
1019 ///
1020 /// let bm = Control::bookmark("section1");
1021 /// assert!(bm.is_bookmark());
1022 /// ```
1023 pub fn bookmark(name: &str) -> Self {
1024 Self::Bookmark { name: name.to_string(), bookmark_type: BookmarkType::Point }
1025 }
1026
1027 /// Creates a press-field (누름틀) with the given hint text.
1028 ///
1029 /// # Examples
1030 ///
1031 /// ```
1032 /// use hwpforge_core::control::Control;
1033 ///
1034 /// let field = Control::field("이름을 입력하세요");
1035 /// assert!(field.is_field());
1036 /// ```
1037 pub fn field(hint: &str) -> Self {
1038 Self::Field {
1039 field_type: FieldType::ClickHere,
1040 hint_text: Some(hint.to_string()),
1041 help_text: None,
1042 name: None,
1043 display_text: String::new(),
1044 }
1045 }
1046
1047 /// Creates an index mark with a primary key.
1048 ///
1049 /// # Examples
1050 ///
1051 /// ```
1052 /// use hwpforge_core::control::Control;
1053 ///
1054 /// let mark = Control::index_mark("한글");
1055 /// assert!(mark.is_index_mark());
1056 /// ```
1057 pub fn index_mark(primary: &str) -> Self {
1058 Self::IndexMark { primary: primary.to_string(), secondary: None }
1059 }
1060
1061 /// Creates a memo annotation with the given paragraph body.
1062 ///
1063 /// # Examples
1064 ///
1065 /// ```
1066 /// use hwpforge_core::control::Control;
1067 /// use hwpforge_core::paragraph::Paragraph;
1068 /// use hwpforge_foundation::ParaShapeIndex;
1069 ///
1070 /// let para = Paragraph::new(ParaShapeIndex::new(0));
1071 /// let memo = Control::memo(vec![para]);
1072 /// assert!(memo.is_memo());
1073 /// ```
1074 pub fn memo(content: Vec<Paragraph>) -> Self {
1075 Self::Memo { content, anchor_runs: Vec::new(), metadata: MemoMetadata::default() }
1076 }
1077
1078 /// Creates a memo annotation with both body content and anchor runs.
1079 ///
1080 /// `anchor_runs` are the visible body span the memo is attached to (the
1081 /// text between HWPX `<hp:fieldBegin type="MEMO">` and `<hp:fieldEnd>`);
1082 /// `content` is the memo body inside `<hp:subList>`.
1083 ///
1084 /// # Examples
1085 ///
1086 /// ```
1087 /// use hwpforge_core::control::Control;
1088 /// use hwpforge_core::paragraph::Paragraph;
1089 /// use hwpforge_core::run::Run;
1090 /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
1091 ///
1092 /// let body = vec![Paragraph::new(ParaShapeIndex::new(0))];
1093 /// let anchor = vec![Run::text("hello", CharShapeIndex::new(0))];
1094 /// let memo = Control::memo_with_anchor(body, anchor);
1095 /// assert!(memo.is_memo());
1096 /// ```
1097 pub fn memo_with_anchor(content: Vec<Paragraph>, anchor_runs: Vec<Run>) -> Self {
1098 Self::Memo { content, anchor_runs, metadata: MemoMetadata::default() }
1099 }
1100
1101 /// Creates a cross-reference to a bookmark target (convenience helper).
1102 ///
1103 /// Wave 12m Phase 2: 인자 타입이 `&str` 에서 `RefTarget` 로 변경
1104 /// (breaking). 책갈피 이름이라면 `RefTarget::Name(...)`, 한컴 시스템
1105 /// ID 라면 `RefTarget::SystemId(...)` 를 명시.
1106 ///
1107 /// # Examples
1108 ///
1109 /// ```
1110 /// use hwpforge_core::control::{Control, RefTarget};
1111 /// use hwpforge_foundation::{RefType, RefContentType};
1112 ///
1113 /// let xref = Control::cross_ref(
1114 /// RefTarget::Name("section1".to_string()),
1115 /// RefType::Bookmark,
1116 /// RefContentType::Page,
1117 /// );
1118 /// assert!(xref.is_cross_ref());
1119 /// ```
1120 pub fn cross_ref(target: RefTarget, ref_type: RefType, content_type: RefContentType) -> Self {
1121 Self::CrossRef {
1122 target,
1123 ref_type,
1124 content_type,
1125 as_hyperlink: false,
1126 display_text: String::new(),
1127 }
1128 }
1129
1130 /// Creates a chart control with default dimensions and settings.
1131 ///
1132 /// Defaults: width ≈ 114mm, height ≈ 66mm, no title, right legend, clustered grouping.
1133 ///
1134 /// # Examples
1135 ///
1136 /// ```
1137 /// use hwpforge_core::control::Control;
1138 /// use hwpforge_core::chart::{ChartType, ChartData};
1139 ///
1140 /// let data = ChartData::category(&["A", "B"], &[("S1", &[10.0, 20.0])]);
1141 /// let ctrl = Control::chart(ChartType::Column, data);
1142 /// assert!(ctrl.is_chart());
1143 /// ```
1144 pub fn chart(chart_type: ChartType, data: ChartData) -> Self {
1145 Self::Chart {
1146 chart_type,
1147 data,
1148 width: HwpUnit::new(32250).expect("32250 is valid"),
1149 height: HwpUnit::new(18750).expect("18750 is valid"),
1150 title: None,
1151 legend: LegendPosition::default(),
1152 grouping: ChartGrouping::default(),
1153 bar_shape: None,
1154 explosion: None,
1155 of_pie_type: None,
1156 radar_style: None,
1157 wireframe: None,
1158 bubble_3d: None,
1159 scatter_style: None,
1160 show_markers: None,
1161 stock_variant: None,
1162 }
1163 }
1164
1165 /// Creates an equation control with default dimensions for the given HancomEQN script.
1166 ///
1167 /// Defaults: width ≈ 31mm (8779 HWPUNIT), height ≈ 9.2mm (2600 HWPUNIT),
1168 /// baseline 71%, black text, `HancomEQN` font.
1169 ///
1170 /// # Examples
1171 ///
1172 /// ```
1173 /// use hwpforge_core::control::Control;
1174 ///
1175 /// let ctrl = Control::equation("{a+b} over {c+d}");
1176 /// assert!(ctrl.is_equation());
1177 /// ```
1178 pub fn equation(script: &str) -> Self {
1179 Self::Equation {
1180 script: script.to_string(),
1181 width: HwpUnit::new(8779).expect("8779 is valid"),
1182 height: HwpUnit::new(2600).expect("2600 is valid"),
1183 base_line: 71,
1184 text_color: Color::BLACK,
1185 font: "HancomEQN".to_string(),
1186 inst_id: None,
1187 }
1188 }
1189
1190 /// Creates a text box control with the given paragraphs and dimensions.
1191 ///
1192 /// Defaults: inline positioning (placement=None), no caption, no style override.
1193 ///
1194 /// # Examples
1195 ///
1196 /// ```
1197 /// use hwpforge_core::control::Control;
1198 /// use hwpforge_core::paragraph::Paragraph;
1199 /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
1200 ///
1201 /// let para = Paragraph::new(ParaShapeIndex::new(0));
1202 /// let width = HwpUnit::from_mm(80.0).unwrap();
1203 /// let height = HwpUnit::from_mm(40.0).unwrap();
1204 /// let ctrl = Control::text_box(vec![para], width, height);
1205 /// assert!(ctrl.is_text_box());
1206 /// ```
1207 pub fn text_box(paragraphs: Vec<Paragraph>, width: HwpUnit, height: HwpUnit) -> Self {
1208 Self::TextBox {
1209 paragraphs,
1210 width,
1211 height,
1212 placement: None,
1213 caption: None,
1214 style: None,
1215 text_vertical_align: VerticalAlign::Top,
1216 }
1217 }
1218
1219 /// Creates a footnote control with the given paragraph content.
1220 ///
1221 /// Defaults: no inst_id.
1222 ///
1223 /// # Examples
1224 ///
1225 /// ```
1226 /// use hwpforge_core::control::Control;
1227 /// use hwpforge_core::run::Run;
1228 /// use hwpforge_core::paragraph::Paragraph;
1229 /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
1230 ///
1231 /// let para = Paragraph::with_runs(
1232 /// vec![Run::text("Note text", CharShapeIndex::new(0))],
1233 /// ParaShapeIndex::new(0),
1234 /// );
1235 /// let ctrl = Control::footnote(vec![para]);
1236 /// assert!(ctrl.is_footnote());
1237 /// ```
1238 pub fn footnote(paragraphs: Vec<Paragraph>) -> Self {
1239 Self::Footnote { inst_id: None, paragraphs }
1240 }
1241
1242 /// Creates an endnote control with the given paragraph content.
1243 ///
1244 /// Defaults: no inst_id.
1245 ///
1246 /// # Examples
1247 ///
1248 /// ```
1249 /// use hwpforge_core::control::Control;
1250 /// use hwpforge_core::run::Run;
1251 /// use hwpforge_core::paragraph::Paragraph;
1252 /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
1253 ///
1254 /// let para = Paragraph::with_runs(
1255 /// vec![Run::text("End note", CharShapeIndex::new(0))],
1256 /// ParaShapeIndex::new(0),
1257 /// );
1258 /// let ctrl = Control::endnote(vec![para]);
1259 /// assert!(ctrl.is_endnote());
1260 /// ```
1261 pub fn endnote(paragraphs: Vec<Paragraph>) -> Self {
1262 Self::Endnote { inst_id: None, paragraphs }
1263 }
1264
1265 /// Creates a footnote with an explicit instance ID for cross-referencing.
1266 ///
1267 /// Use this when you need stable `inst_id` references (e.g. matching decoder output).
1268 /// For simple footnotes without cross-references, prefer [`Control::footnote`].
1269 ///
1270 /// # Examples
1271 ///
1272 /// ```
1273 /// use hwpforge_core::control::Control;
1274 /// use hwpforge_core::paragraph::Paragraph;
1275 /// use hwpforge_foundation::ParaShapeIndex;
1276 ///
1277 /// let ctrl = Control::footnote_with_id(1, vec![Paragraph::new(ParaShapeIndex::new(0))]);
1278 /// assert!(ctrl.is_footnote());
1279 /// ```
1280 pub fn footnote_with_id(inst_id: u64, paragraphs: Vec<Paragraph>) -> Self {
1281 Self::Footnote { inst_id: Some(ObjectId::new(inst_id)), paragraphs }
1282 }
1283
1284 /// Creates an endnote with an explicit instance ID for cross-referencing.
1285 ///
1286 /// Use this when you need stable `inst_id` references (e.g. matching decoder output).
1287 /// For simple endnotes without cross-references, prefer [`Control::endnote`].
1288 ///
1289 /// # Examples
1290 ///
1291 /// ```
1292 /// use hwpforge_core::control::Control;
1293 /// use hwpforge_core::paragraph::Paragraph;
1294 /// use hwpforge_foundation::ParaShapeIndex;
1295 ///
1296 /// let ctrl = Control::endnote_with_id(2, vec![Paragraph::new(ParaShapeIndex::new(0))]);
1297 /// assert!(ctrl.is_endnote());
1298 /// ```
1299 pub fn endnote_with_id(inst_id: u64, paragraphs: Vec<Paragraph>) -> Self {
1300 Self::Endnote { inst_id: Some(ObjectId::new(inst_id)), paragraphs }
1301 }
1302
1303 /// Creates an ellipse control with the given bounding box dimensions.
1304 ///
1305 /// Geometry is auto-derived: center=(w/2, h/2), axis1=(w, h/2), axis2=(w/2, h).
1306 /// Defaults: inline positioning (placement=None), no paragraphs, no caption, no style.
1307 ///
1308 /// # Examples
1309 ///
1310 /// ```
1311 /// use hwpforge_core::control::Control;
1312 /// use hwpforge_foundation::HwpUnit;
1313 ///
1314 /// let width = HwpUnit::from_mm(40.0).unwrap();
1315 /// let height = HwpUnit::from_mm(30.0).unwrap();
1316 /// let ctrl = Control::ellipse(width, height);
1317 /// assert!(ctrl.is_ellipse());
1318 /// ```
1319 pub fn ellipse(width: HwpUnit, height: HwpUnit) -> Self {
1320 let w = width.as_i32();
1321 let h = height.as_i32();
1322 Self::Ellipse {
1323 center: ShapePoint::new(w / 2, h / 2),
1324 axis1: ShapePoint::new(w, h / 2),
1325 axis2: ShapePoint::new(w / 2, h),
1326 width,
1327 height,
1328 placement: None,
1329 paragraphs: vec![],
1330 caption: None,
1331 style: None,
1332 text_vertical_align: VerticalAlign::Top,
1333 }
1334 }
1335
1336 /// Creates an ellipse control with paragraph content inside.
1337 ///
1338 /// Same as [`Control::ellipse`] but accepts paragraphs for text drawn inside the ellipse.
1339 /// Geometry is auto-derived: center=(w/2, h/2), axis1=(w, h/2), axis2=(w/2, h).
1340 /// Defaults: inline positioning (placement=None), no caption, no style.
1341 ///
1342 /// # Examples
1343 ///
1344 /// ```
1345 /// use hwpforge_core::control::Control;
1346 /// use hwpforge_core::paragraph::Paragraph;
1347 /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
1348 ///
1349 /// let width = HwpUnit::from_mm(40.0).unwrap();
1350 /// let height = HwpUnit::from_mm(30.0).unwrap();
1351 /// let para = Paragraph::new(ParaShapeIndex::new(0));
1352 /// let ctrl = Control::ellipse_with_text(width, height, vec![para]);
1353 /// assert!(ctrl.is_ellipse());
1354 /// ```
1355 pub fn ellipse_with_text(width: HwpUnit, height: HwpUnit, paragraphs: Vec<Paragraph>) -> Self {
1356 let w = width.as_i32();
1357 let h = height.as_i32();
1358 Self::Ellipse {
1359 center: ShapePoint::new(w / 2, h / 2),
1360 axis1: ShapePoint::new(w, h / 2),
1361 axis2: ShapePoint::new(w / 2, h),
1362 width,
1363 height,
1364 placement: None,
1365 paragraphs,
1366 caption: None,
1367 style: None,
1368 text_vertical_align: VerticalAlign::Top,
1369 }
1370 }
1371
1372 /// Creates a pure rectangle control with the given bounding box dimensions.
1373 ///
1374 /// Pure rectangle means no embedded text content; for a textbox-style rect with
1375 /// inline paragraphs, use [`Control::text_box`].
1376 /// Defaults: inline positioning (placement=None), no caption, no style.
1377 ///
1378 /// # Errors
1379 ///
1380 /// Returns [`CoreError::InvalidStructure`] if either dimension is zero.
1381 ///
1382 /// # Examples
1383 ///
1384 /// ```
1385 /// use hwpforge_core::control::Control;
1386 /// use hwpforge_foundation::HwpUnit;
1387 ///
1388 /// let width = HwpUnit::from_mm(40.0).unwrap();
1389 /// let height = HwpUnit::from_mm(20.0).unwrap();
1390 /// let ctrl = Control::rect(width, height).unwrap();
1391 /// assert!(ctrl.is_rect());
1392 /// ```
1393 pub fn rect(width: HwpUnit, height: HwpUnit) -> CoreResult<Self> {
1394 if width.as_i32() == 0 || height.as_i32() == 0 {
1395 return Err(CoreError::InvalidStructure {
1396 context: "Control::rect".to_string(),
1397 reason: format!(
1398 "rectangle requires non-zero dimensions, got {}x{}",
1399 width.as_i32(),
1400 height.as_i32()
1401 ),
1402 });
1403 }
1404 Ok(Self::Rect { width, height, placement: None, caption: None, style: None })
1405 }
1406
1407 /// Creates a polygon control from the given vertices.
1408 ///
1409 /// The bounding box is auto-derived from the min/max of vertex coordinates.
1410 /// Defaults: no paragraphs, no caption, no style.
1411 ///
1412 /// Returns an error if fewer than 3 vertices are provided.
1413 ///
1414 /// # Errors
1415 ///
1416 /// Returns [`CoreError::InvalidStructure`] if `vertices.len() < 3`.
1417 ///
1418 /// # Examples
1419 ///
1420 /// ```
1421 /// use hwpforge_core::control::{Control, ShapePoint};
1422 ///
1423 /// let vertices = vec![
1424 /// ShapePoint::new(0, 1000),
1425 /// ShapePoint::new(500, 0),
1426 /// ShapePoint::new(1000, 1000),
1427 /// ];
1428 /// let ctrl = Control::polygon(vertices).unwrap();
1429 /// assert!(ctrl.is_polygon());
1430 /// ```
1431 pub fn polygon(vertices: Vec<ShapePoint>) -> CoreResult<Self> {
1432 if vertices.len() < 3 {
1433 return Err(CoreError::InvalidStructure {
1434 context: "Control::polygon".to_string(),
1435 reason: format!("polygon requires at least 3 vertices, got {}", vertices.len()),
1436 });
1437 }
1438 let min_x = vertices.iter().map(|p| p.x as i64).min().unwrap_or(0);
1439 let max_x = vertices.iter().map(|p| p.x as i64).max().unwrap_or(0);
1440 let min_y = vertices.iter().map(|p| p.y as i64).min().unwrap_or(0);
1441 let max_y = vertices.iter().map(|p| p.y as i64).max().unwrap_or(0);
1442 let bbox_w = i32::try_from((max_x - min_x).max(0)).unwrap_or(i32::MAX);
1443 let bbox_h = i32::try_from((max_y - min_y).max(0)).unwrap_or(i32::MAX);
1444 let width = HwpUnit::new(bbox_w).map_err(|_| CoreError::InvalidStructure {
1445 context: "Control::polygon".into(),
1446 reason: format!("bounding box width {bbox_w} exceeds HwpUnit range"),
1447 })?;
1448 let height = HwpUnit::new(bbox_h).map_err(|_| CoreError::InvalidStructure {
1449 context: "Control::polygon".into(),
1450 reason: format!("bounding box height {bbox_h} exceeds HwpUnit range"),
1451 })?;
1452 Ok(Self::Polygon {
1453 vertices,
1454 width,
1455 height,
1456 placement: None,
1457 paragraphs: vec![],
1458 caption: None,
1459 style: None,
1460 text_vertical_align: VerticalAlign::Top,
1461 })
1462 }
1463
1464 /// Creates a line control between two endpoints.
1465 ///
1466 /// The bounding box width and height are derived from the absolute difference
1467 /// of the endpoint coordinates: `width = |end.x - start.x|`, `height = |end.y - start.y|`.
1468 /// Each axis is clamped to a minimum of 100 HwpUnit (~1pt) because 한글 cannot
1469 /// render lines with a zero-dimension bounding box.
1470 /// Defaults: no caption, no style.
1471 ///
1472 /// Returns an error if start and end are the same point (degenerate line).
1473 ///
1474 /// # Errors
1475 ///
1476 /// Returns [`CoreError::InvalidStructure`] if start equals end.
1477 ///
1478 /// # Examples
1479 ///
1480 /// ```
1481 /// use hwpforge_core::control::{Control, ShapePoint};
1482 ///
1483 /// let ctrl = Control::line(ShapePoint::new(0, 0), ShapePoint::new(5000, 0)).unwrap();
1484 /// assert!(ctrl.is_line());
1485 /// ```
1486 pub fn line(start: ShapePoint, end: ShapePoint) -> CoreResult<Self> {
1487 if start == end {
1488 return Err(CoreError::InvalidStructure {
1489 context: "Control::line".to_string(),
1490 reason: "start and end points are identical (degenerate line)".to_string(),
1491 });
1492 }
1493 // Normalize points to bounding-box-relative coordinates.
1494 // HWPX requires startPt/endPt within the shape's bounding box (0,0)→(w,h).
1495 let min_x = start.x.min(end.x);
1496 let min_y = start.y.min(end.y);
1497 let norm_start =
1498 ShapePoint::new(start.x.saturating_sub(min_x), start.y.saturating_sub(min_y));
1499 let norm_end = ShapePoint::new(end.x.saturating_sub(min_x), end.y.saturating_sub(min_y));
1500
1501 let raw_w =
1502 i32::try_from(((end.x as i64) - (start.x as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1503 let raw_h =
1504 i32::try_from(((end.y as i64) - (start.y as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1505 // Minimum bounding box of 100 HwpUnit (~1pt) per axis.
1506 // 한글 cannot render lines with a zero-dimension bounding box.
1507 let raw_w = raw_w.max(100);
1508 let raw_h = raw_h.max(100);
1509 let width = HwpUnit::new(raw_w).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1510 let height = HwpUnit::new(raw_h).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1511 Ok(Self::Line {
1512 start: norm_start,
1513 end: norm_end,
1514 width,
1515 height,
1516 placement: None,
1517 caption: None,
1518 style: None,
1519 })
1520 }
1521
1522 /// Creates a horizontal line of the given width.
1523 ///
1524 /// Shortcut for `line(ShapePoint::new(0, 0), ShapePoint::new(width.as_i32(), 0))`.
1525 /// The bounding box height is clamped to 100 HwpUnit (~1pt minimum) because
1526 /// 한글 cannot render lines with a zero-dimension bounding box.
1527 /// Defaults: no caption, no style.
1528 ///
1529 /// # Examples
1530 ///
1531 /// ```
1532 /// use hwpforge_core::control::Control;
1533 /// use hwpforge_foundation::HwpUnit;
1534 ///
1535 /// let width = HwpUnit::from_mm(100.0).unwrap();
1536 /// let ctrl = Control::horizontal_line(width);
1537 /// assert!(ctrl.is_line());
1538 /// ```
1539 pub fn horizontal_line(width: HwpUnit) -> Self {
1540 let w = width.as_i32();
1541 Self::Line {
1542 start: ShapePoint::new(0, 0),
1543 end: ShapePoint::new(w, 0),
1544 width,
1545 height: HwpUnit::new(100).expect("100 is valid"),
1546 placement: None,
1547 caption: None,
1548 style: None,
1549 }
1550 }
1551
1552 /// Creates a dutmal (annotation text) control with default positioning.
1553 ///
1554 /// Defaults: position = Top, sz_ratio = 0 (auto), align = Center.
1555 ///
1556 /// # Examples
1557 ///
1558 /// ```
1559 /// use hwpforge_core::control::Control;
1560 ///
1561 /// let ctrl = Control::dutmal("본문", "주석");
1562 /// assert!(ctrl.is_dutmal());
1563 /// ```
1564 pub fn dutmal(main_text: impl Into<String>, sub_text: impl Into<String>) -> Self {
1565 Self::Dutmal {
1566 main_text: main_text.into(),
1567 sub_text: sub_text.into(),
1568 position: DutmalPosition::Top,
1569 sz_ratio: 0,
1570 align: DutmalAlign::Center,
1571 metadata: DutmalMetadata::default(),
1572 }
1573 }
1574
1575 /// Creates a compose (글자겹침) control with default settings.
1576 ///
1577 /// Defaults: `circle_type = "SHAPE_REVERSAL_TIRANGLE"` (spec typo preserved),
1578 /// `char_sz = -3`, `compose_type = "SPREAD"`.
1579 ///
1580 /// # Examples
1581 ///
1582 /// ```
1583 /// use hwpforge_core::control::Control;
1584 ///
1585 /// let ctrl = Control::compose("12");
1586 /// assert!(ctrl.is_compose());
1587 /// ```
1588 pub fn compose(text: impl Into<String>) -> Self {
1589 Self::Compose {
1590 compose_text: text.into(),
1591 circle_type: "SHAPE_REVERSAL_TIRANGLE".to_string(), // official spec typo preserved
1592 char_sz: -3,
1593 compose_type: "SPREAD".to_string(),
1594 // 10 × no-override sentinel (HWPX `charPrCnt` is fixed at 10).
1595 char_pr_ids: vec![u32::MAX; 10],
1596 }
1597 }
1598
1599 /// Creates an arc control with the given bounding box dimensions.
1600 ///
1601 /// Geometry is auto-derived from the bounding box.
1602 /// Defaults: inline positioning, no caption, no style.
1603 ///
1604 /// # Examples
1605 ///
1606 /// ```
1607 /// use hwpforge_core::control::Control;
1608 /// use hwpforge_foundation::{ArcType, HwpUnit};
1609 ///
1610 /// let width = HwpUnit::from_mm(40.0).unwrap();
1611 /// let height = HwpUnit::from_mm(30.0).unwrap();
1612 /// let ctrl = Control::arc(ArcType::Pie, width, height);
1613 /// assert!(ctrl.is_arc());
1614 /// ```
1615 pub fn arc(arc_type: ArcType, width: HwpUnit, height: HwpUnit) -> Self {
1616 let w = width.as_i32();
1617 let h = height.as_i32();
1618 Self::Arc {
1619 arc_type,
1620 center: ShapePoint::new(w / 2, h / 2),
1621 axis1: ShapePoint::new(w, h / 2),
1622 axis2: ShapePoint::new(w / 2, h),
1623 start1: ShapePoint::new(w, h / 2),
1624 end1: ShapePoint::new(w / 2, 0),
1625 start2: ShapePoint::new(w, h / 2),
1626 end2: ShapePoint::new(w / 2, 0),
1627 width,
1628 height,
1629 placement: None,
1630 caption: None,
1631 style: None,
1632 }
1633 }
1634
1635 /// Creates a curve control from the given control points.
1636 ///
1637 /// All segments default to [`CurveSegmentType::Curve`].
1638 /// The bounding box is auto-derived from min/max of point coordinates.
1639 ///
1640 /// Returns an error if fewer than 2 points are provided.
1641 ///
1642 /// # Errors
1643 ///
1644 /// Returns [`CoreError::InvalidStructure`] if `points.len() < 2`.
1645 ///
1646 /// # Examples
1647 ///
1648 /// ```
1649 /// use hwpforge_core::control::{Control, ShapePoint};
1650 ///
1651 /// let pts = vec![
1652 /// ShapePoint::new(0, 0),
1653 /// ShapePoint::new(2500, 5000),
1654 /// ShapePoint::new(5000, 0),
1655 /// ];
1656 /// let ctrl = Control::curve(pts).unwrap();
1657 /// assert!(ctrl.is_curve());
1658 /// ```
1659 pub fn curve(points: Vec<ShapePoint>) -> CoreResult<Self> {
1660 if points.len() < 2 {
1661 return Err(CoreError::InvalidStructure {
1662 context: "Control::curve".to_string(),
1663 reason: format!("curve requires at least 2 points, got {}", points.len()),
1664 });
1665 }
1666 let min_x = points.iter().map(|p| p.x as i64).min().unwrap_or(0);
1667 let max_x = points.iter().map(|p| p.x as i64).max().unwrap_or(0);
1668 let min_y = points.iter().map(|p| p.y as i64).min().unwrap_or(0);
1669 let max_y = points.iter().map(|p| p.y as i64).max().unwrap_or(0);
1670 let bbox_w = i32::try_from((max_x - min_x).max(1)).unwrap_or(i32::MAX);
1671 let bbox_h = i32::try_from((max_y - min_y).max(1)).unwrap_or(i32::MAX);
1672 let width = HwpUnit::new(bbox_w).map_err(|_| CoreError::InvalidStructure {
1673 context: "Control::curve".into(),
1674 reason: format!("bounding box width {bbox_w} exceeds HwpUnit range"),
1675 })?;
1676 let height = HwpUnit::new(bbox_h).map_err(|_| CoreError::InvalidStructure {
1677 context: "Control::curve".into(),
1678 reason: format!("bounding box height {bbox_h} exceeds HwpUnit range"),
1679 })?;
1680 let seg_count = points.len().saturating_sub(1);
1681 Ok(Self::Curve {
1682 points,
1683 segment_types: vec![CurveSegmentType::Curve; seg_count],
1684 width,
1685 height,
1686 placement: None,
1687 caption: None,
1688 style: None,
1689 })
1690 }
1691
1692 /// Creates a connect line between two endpoints.
1693 ///
1694 /// Defaults: no control points, type "STRAIGHT", no caption, no style.
1695 ///
1696 /// Returns an error if start equals end.
1697 ///
1698 /// # Errors
1699 ///
1700 /// Returns [`CoreError::InvalidStructure`] if start equals end.
1701 ///
1702 /// # Examples
1703 ///
1704 /// ```
1705 /// use hwpforge_core::control::{Control, ShapePoint};
1706 ///
1707 /// let ctrl = Control::connect_line(
1708 /// ShapePoint::new(0, 0),
1709 /// ShapePoint::new(5000, 5000),
1710 /// ).unwrap();
1711 /// assert!(ctrl.is_connect_line());
1712 /// ```
1713 pub fn connect_line(start: ShapePoint, end: ShapePoint) -> CoreResult<Self> {
1714 if start == end {
1715 return Err(CoreError::InvalidStructure {
1716 context: "Control::connect_line".to_string(),
1717 reason: "start and end points are identical (degenerate line)".to_string(),
1718 });
1719 }
1720 // Normalize points to bounding-box-relative coordinates.
1721 // HWPX requires startPt/endPt within the shape's bounding box (0,0)→(w,h).
1722 let min_x = start.x.min(end.x);
1723 let min_y = start.y.min(end.y);
1724 let norm_start =
1725 ShapePoint::new(start.x.saturating_sub(min_x), start.y.saturating_sub(min_y));
1726 let norm_end = ShapePoint::new(end.x.saturating_sub(min_x), end.y.saturating_sub(min_y));
1727
1728 let raw_w =
1729 i32::try_from(((end.x as i64) - (start.x as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1730 let raw_h =
1731 i32::try_from(((end.y as i64) - (start.y as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1732 let raw_w = raw_w.max(100);
1733 let raw_h = raw_h.max(100);
1734 let width = HwpUnit::new(raw_w).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1735 let height = HwpUnit::new(raw_h).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1736 Ok(Self::ConnectLine {
1737 start: norm_start,
1738 end: norm_end,
1739 control_points: Vec::new(),
1740 connect_type: "STRAIGHT".to_string(),
1741 width,
1742 height,
1743 placement: None,
1744 caption: None,
1745 style: None,
1746 })
1747 }
1748
1749 /// Creates a hyperlink control with the given display text and URL.
1750 ///
1751 /// # Examples
1752 ///
1753 /// ```
1754 /// use hwpforge_core::control::Control;
1755 ///
1756 /// let ctrl = Control::hyperlink("Visit Rust", "https://rust-lang.org");
1757 /// assert!(ctrl.is_hyperlink());
1758 /// ```
1759 pub fn hyperlink(text: &str, url: &str) -> Self {
1760 Self::Hyperlink { text: text.to_string(), url: url.to_string() }
1761 }
1762}
1763
1764impl std::fmt::Display for Control {
1765 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1766 match self {
1767 Self::TextBox { paragraphs, .. } => {
1768 let n = paragraphs.len();
1769 let word = if n == 1 { "paragraph" } else { "paragraphs" };
1770 write!(f, "TextBox({n} {word})")
1771 }
1772 Self::Hyperlink { text, url } => {
1773 let preview: String =
1774 if text.len() > 30 { text.chars().take(30).collect() } else { text.clone() };
1775 write!(f, "Hyperlink(\"{preview}\" -> {url})")
1776 }
1777 Self::Footnote { paragraphs, .. } => {
1778 let n = paragraphs.len();
1779 let word = if n == 1 { "paragraph" } else { "paragraphs" };
1780 write!(f, "Footnote({n} {word})")
1781 }
1782 Self::Endnote { paragraphs, .. } => {
1783 let n = paragraphs.len();
1784 let word = if n == 1 { "paragraph" } else { "paragraphs" };
1785 write!(f, "Endnote({n} {word})")
1786 }
1787 Self::Line { .. } => {
1788 write!(f, "Line")
1789 }
1790 Self::Ellipse { paragraphs, .. } => {
1791 let n = paragraphs.len();
1792 let word = if n == 1 { "paragraph" } else { "paragraphs" };
1793 write!(f, "Ellipse({n} {word})")
1794 }
1795 Self::Rect { width, height, .. } => {
1796 write!(f, "Rect({}x{})", width.as_i32(), height.as_i32())
1797 }
1798 Self::Polygon { vertices, paragraphs, .. } => {
1799 let nv = vertices.len();
1800 let np = paragraphs.len();
1801 let vw = if nv == 1 { "vertex" } else { "vertices" };
1802 let pw = if np == 1 { "paragraph" } else { "paragraphs" };
1803 write!(f, "Polygon({nv} {vw}, {np} {pw})")
1804 }
1805 Self::Chart { chart_type, data, .. } => {
1806 let series_count = match data {
1807 ChartData::Category { series, .. } => series.len(),
1808 ChartData::Xy { series } => series.len(),
1809 };
1810 write!(f, "Chart({chart_type:?}, {series_count} series)")
1811 }
1812 Self::EmbeddedChart { chart_xml, ole_bytes, width, height, .. } => {
1813 write!(
1814 f,
1815 "EmbeddedChart(xml={} bytes, ole={} bytes, {}x{})",
1816 chart_xml.len(),
1817 ole_bytes.len(),
1818 width.as_i32(),
1819 height.as_i32()
1820 )
1821 }
1822 Self::Equation { script, .. } => {
1823 let preview: String = if script.len() > 30 {
1824 script.chars().take(30).collect()
1825 } else {
1826 script.clone()
1827 };
1828 write!(f, "Equation(\"{preview}\")")
1829 }
1830 Self::Dutmal { main_text, sub_text, .. } => {
1831 write!(f, "Dutmal(\"{main_text}\" / \"{sub_text}\")")
1832 }
1833 Self::Compose { compose_text, .. } => {
1834 write!(f, "Compose(\"{compose_text}\")")
1835 }
1836 Self::Arc { arc_type, .. } => {
1837 write!(f, "Arc({arc_type})")
1838 }
1839 Self::Curve { points, .. } => {
1840 write!(f, "Curve({} points)", points.len())
1841 }
1842 Self::ConnectLine { .. } => {
1843 write!(f, "ConnectLine")
1844 }
1845 Self::Group { children, .. } => {
1846 write!(f, "Group({} children)", children.len())
1847 }
1848 Self::TextArt { text, shape, .. } => {
1849 write!(f, "TextArt(\"{text}\", {shape})")
1850 }
1851 Self::Bookmark { name, bookmark_type } => {
1852 write!(f, "Bookmark(\"{name}\", {bookmark_type})")
1853 }
1854 Self::CrossRef { target, ref_type, .. } => {
1855 write!(f, "CrossRef({:?}, {ref_type})", target.as_display())
1856 }
1857 Self::Field { field_type, hint_text, name, .. } => {
1858 let hint = hint_text.as_deref().unwrap_or("");
1859 match name.as_deref().filter(|s| !s.is_empty()) {
1860 Some(n) => write!(f, "Field({field_type}, name=\"{n}\", \"{hint}\")"),
1861 None => write!(f, "Field({field_type}, \"{hint}\")"),
1862 }
1863 }
1864 Self::Memo { content, anchor_runs, .. } => {
1865 let n = content.len();
1866 let word = if n == 1 { "paragraph" } else { "paragraphs" };
1867 let anchor_len = anchor_runs.len();
1868 write!(f, "Memo({n} {word}, anchor={anchor_len} runs)")
1869 }
1870 Self::IndexMark { primary, secondary } => {
1871 if let Some(sec) = secondary {
1872 write!(f, "IndexMark(\"{primary}\" / \"{sec}\")")
1873 } else {
1874 write!(f, "IndexMark(\"{primary}\")")
1875 }
1876 }
1877 Self::UnknownSummary { token, .. } => {
1878 write!(f, "UnknownSummary({token})")
1879 }
1880 Self::DateCodeField { is_time_mode, .. } => {
1881 let mode = if *is_time_mode { "time" } else { "date" };
1882 write!(f, "DateCodeField({mode})")
1883 }
1884 Self::PathField { command, .. } => {
1885 write!(f, "PathField({})", command.wire_command())
1886 }
1887 Self::InlinePageNumber { kind } => match kind {
1888 InlinePageKind::CurrentPage => write!(f, "InlinePageNumber(current)"),
1889 InlinePageKind::TotalPages => write!(f, "InlinePageNumber(total)"),
1890 InlinePageKind::Unknown => write!(f, "InlinePageNumber(unknown)"),
1891 },
1892 Self::NewNumber { kind, number } => {
1893 write!(f, "NewNumber({kind:?}, {number})")
1894 }
1895 Self::PageHiding {
1896 hide_header,
1897 hide_footer,
1898 hide_master_page,
1899 hide_border,
1900 hide_fill,
1901 hide_page_num,
1902 } => {
1903 let flags: Vec<&str> = [
1904 (*hide_header, "header"),
1905 (*hide_footer, "footer"),
1906 (*hide_master_page, "master_page"),
1907 (*hide_border, "border"),
1908 (*hide_fill, "fill"),
1909 (*hide_page_num, "page_num"),
1910 ]
1911 .into_iter()
1912 .filter_map(|(on, name)| on.then_some(name))
1913 .collect();
1914 write!(f, "PageHiding({})", flags.join(","))
1915 }
1916 Self::Unknown { tag, .. } => {
1917 write!(f, "Unknown({tag})")
1918 }
1919 }
1920 }
1921}
1922
1923#[cfg(test)]
1924mod tests {
1925 use super::*;
1926 use crate::run::Run;
1927 use hwpforge_foundation::{CharShapeIndex, Color, ParaShapeIndex, VerticalAlign};
1928
1929 fn simple_paragraph() -> Paragraph {
1930 Paragraph::with_runs(
1931 vec![Run::text("footnote text", CharShapeIndex::new(0))],
1932 ParaShapeIndex::new(0),
1933 )
1934 }
1935
1936 #[test]
1937 fn shape_style_default_all_none() {
1938 let s = ShapeStyle::default();
1939 assert!(s.line_color.is_none());
1940 assert!(s.fill_color.is_none());
1941 assert!(s.line_width.is_none());
1942 assert!(s.line_style.is_none());
1943 }
1944
1945 #[test]
1946 fn shape_style_with_typed_fields() {
1947 let s = ShapeStyle {
1948 line_color: Some(Color::from_rgb(255, 0, 0)),
1949 fill_color: Some(Color::from_rgb(0, 255, 0)),
1950 line_width: Some(100),
1951 line_style: Some(LineStyle::Dash),
1952 ..Default::default()
1953 };
1954 assert_eq!(s.line_color.unwrap(), Color::from_rgb(255, 0, 0));
1955 assert_eq!(s.fill_color.unwrap(), Color::from_rgb(0, 255, 0));
1956 assert_eq!(s.line_width.unwrap(), 100);
1957 assert_eq!(s.line_style.unwrap(), LineStyle::Dash);
1958 }
1959
1960 #[test]
1961 fn line_style_default() {
1962 assert_eq!(LineStyle::default(), LineStyle::Solid);
1963 }
1964
1965 #[test]
1966 fn line_style_display() {
1967 assert_eq!(LineStyle::Solid.to_string(), "SOLID");
1968 assert_eq!(LineStyle::Dash.to_string(), "DASH");
1969 assert_eq!(LineStyle::Dot.to_string(), "DOT");
1970 assert_eq!(LineStyle::DashDot.to_string(), "DASH_DOT");
1971 assert_eq!(LineStyle::DashDotDot.to_string(), "DASH_DOT_DOT");
1972 assert_eq!(LineStyle::None.to_string(), "NONE");
1973 }
1974
1975 #[test]
1976 fn line_style_from_str() {
1977 assert_eq!("SOLID".parse::<LineStyle>().unwrap(), LineStyle::Solid);
1978 assert_eq!("Dash".parse::<LineStyle>().unwrap(), LineStyle::Dash);
1979 assert_eq!("dot".parse::<LineStyle>().unwrap(), LineStyle::Dot);
1980 assert_eq!("DASH_DOT".parse::<LineStyle>().unwrap(), LineStyle::DashDot);
1981 assert_eq!("DashDotDot".parse::<LineStyle>().unwrap(), LineStyle::DashDotDot);
1982 assert_eq!("NONE".parse::<LineStyle>().unwrap(), LineStyle::None);
1983 assert!("INVALID".parse::<LineStyle>().is_err());
1984 }
1985
1986 #[test]
1987 fn line_style_serde_roundtrip() {
1988 for style in [
1989 LineStyle::Solid,
1990 LineStyle::Dash,
1991 LineStyle::Dot,
1992 LineStyle::DashDot,
1993 LineStyle::DashDotDot,
1994 LineStyle::None,
1995 ] {
1996 let json = serde_json::to_string(&style).unwrap();
1997 let back: LineStyle = serde_json::from_str(&json).unwrap();
1998 assert_eq!(style, back);
1999 }
2000 }
2001
2002 #[test]
2003 fn text_box_construction() {
2004 let ctrl = Control::TextBox {
2005 paragraphs: vec![simple_paragraph()],
2006 width: HwpUnit::from_mm(80.0).unwrap(),
2007 height: HwpUnit::from_mm(40.0).unwrap(),
2008 placement: None,
2009 caption: None,
2010 style: None,
2011 text_vertical_align: VerticalAlign::Top,
2012 };
2013 assert!(ctrl.is_text_box());
2014 assert!(!ctrl.is_hyperlink());
2015 assert!(!ctrl.is_footnote());
2016 assert!(!ctrl.is_endnote());
2017 assert!(!ctrl.is_unknown());
2018 }
2019
2020 #[test]
2021 fn hyperlink_construction() {
2022 let ctrl = Control::Hyperlink {
2023 text: "Click".to_string(),
2024 url: "https://example.com".to_string(),
2025 };
2026 assert!(ctrl.is_hyperlink());
2027 assert!(!ctrl.is_text_box());
2028 }
2029
2030 #[test]
2031 fn footnote_construction() {
2032 let ctrl = Control::Footnote { inst_id: None, paragraphs: vec![simple_paragraph()] };
2033 assert!(ctrl.is_footnote());
2034 assert!(!ctrl.is_text_box());
2035 assert!(!ctrl.is_endnote());
2036 }
2037
2038 #[test]
2039 fn endnote_construction() {
2040 let ctrl = Control::Endnote {
2041 inst_id: Some(ObjectId::new(123456)),
2042 paragraphs: vec![simple_paragraph()],
2043 };
2044 assert!(ctrl.is_endnote());
2045 assert!(!ctrl.is_footnote());
2046 assert!(!ctrl.is_text_box());
2047 }
2048
2049 #[test]
2050 fn unknown_construction() {
2051 let ctrl = Control::Unknown {
2052 tag: "custom:widget".to_string(),
2053 data: Some("<data>value</data>".to_string()),
2054 };
2055 assert!(ctrl.is_unknown());
2056 }
2057
2058 #[test]
2059 fn unknown_without_data() {
2060 let ctrl = Control::Unknown { tag: "header".to_string(), data: None };
2061 assert!(ctrl.is_unknown());
2062 }
2063
2064 #[test]
2065 fn display_and_kind_name_for_page_control_variants() {
2066 // W2/W3 variant 의 Display·kind_name·traversal no-op 커버 (커버리지
2067 // 게이트 — 리눅스는 폰트 의존 테스트 스킵으로 마진이 얇다).
2068 let nn = Control::NewNumber { kind: NewNumberKind::Page, number: 7 };
2069 assert_eq!(nn.to_string(), "NewNumber(Page, 7)");
2070 assert_eq!(nn.kind_name(), "new_number");
2071
2072 let ph = Control::PageHiding {
2073 hide_header: true,
2074 hide_footer: false,
2075 hide_master_page: false,
2076 hide_border: false,
2077 hide_fill: true,
2078 hide_page_num: true,
2079 };
2080 assert_eq!(ph.to_string(), "PageHiding(header,fill,page_num)");
2081 assert_eq!(ph.kind_name(), "page_hiding");
2082
2083 // 빈 mask (corpus 실측 존재 — 렌더 no-op).
2084 let empty = Control::PageHiding {
2085 hide_header: false,
2086 hide_footer: false,
2087 hide_master_page: false,
2088 hide_border: false,
2089 hide_fill: false,
2090 hide_page_num: false,
2091 };
2092 assert_eq!(empty.to_string(), "PageHiding()");
2093
2094 // traversal: leaf 컨트롤은 중첩 문단이 없다.
2095 let mut nn = nn;
2096 let mut ph = ph;
2097 let mut seen = 0usize;
2098 nn.walk_paragraphs_mut(&mut |_| seen += 1);
2099 ph.walk_paragraphs_mut(&mut |_| seen += 1);
2100 assert_eq!(seen, 0);
2101 }
2102
2103 #[test]
2104 fn display_text_box() {
2105 let ctrl = Control::TextBox {
2106 paragraphs: vec![simple_paragraph(), simple_paragraph()],
2107 width: HwpUnit::from_mm(80.0).unwrap(),
2108 height: HwpUnit::from_mm(40.0).unwrap(),
2109 placement: None,
2110 caption: None,
2111 style: None,
2112 text_vertical_align: VerticalAlign::Top,
2113 };
2114 assert_eq!(ctrl.to_string(), "TextBox(2 paragraphs)");
2115 }
2116
2117 #[test]
2118 fn display_hyperlink() {
2119 let ctrl =
2120 Control::Hyperlink { text: "Short".to_string(), url: "https://x.com".to_string() };
2121 let s = ctrl.to_string();
2122 assert!(s.contains("Short"), "display: {s}");
2123 assert!(s.contains("https://x.com"), "display: {s}");
2124 }
2125
2126 #[test]
2127 fn display_hyperlink_long_text_truncated() {
2128 let ctrl =
2129 Control::Hyperlink { text: "A".repeat(100), url: "https://example.com".to_string() };
2130 let s = ctrl.to_string();
2131 // Should show first 30 chars
2132 assert!(s.contains(&"A".repeat(30)), "display: {s}");
2133 assert!(!s.contains(&"A".repeat(31)), "display: {s}");
2134 }
2135
2136 #[test]
2137 fn display_footnote() {
2138 let ctrl = Control::Footnote { inst_id: None, paragraphs: vec![simple_paragraph()] };
2139 assert_eq!(ctrl.to_string(), "Footnote(1 paragraph)");
2140 }
2141
2142 #[test]
2143 fn display_endnote() {
2144 let ctrl = Control::Endnote {
2145 inst_id: Some(ObjectId::new(999)),
2146 paragraphs: vec![simple_paragraph()],
2147 };
2148 assert_eq!(ctrl.to_string(), "Endnote(1 paragraph)");
2149 }
2150
2151 #[test]
2152 fn display_unknown() {
2153 let ctrl = Control::Unknown { tag: "bookmark".to_string(), data: None };
2154 assert_eq!(ctrl.to_string(), "Unknown(bookmark)");
2155 }
2156
2157 #[test]
2158 fn equality() {
2159 let a = Control::Hyperlink { text: "A".to_string(), url: "B".to_string() };
2160 let b = Control::Hyperlink { text: "A".to_string(), url: "B".to_string() };
2161 let c = Control::Hyperlink { text: "A".to_string(), url: "C".to_string() };
2162 assert_eq!(a, b);
2163 assert_ne!(a, c);
2164 }
2165
2166 #[test]
2167 fn serde_roundtrip_text_box() {
2168 let ctrl = Control::TextBox {
2169 paragraphs: vec![simple_paragraph()],
2170 width: HwpUnit::from_mm(80.0).unwrap(),
2171 height: HwpUnit::from_mm(40.0).unwrap(),
2172 placement: None,
2173 caption: None,
2174 style: None,
2175 text_vertical_align: VerticalAlign::Top,
2176 };
2177 let json = serde_json::to_string(&ctrl).unwrap();
2178 let back: Control = serde_json::from_str(&json).unwrap();
2179 assert_eq!(ctrl, back);
2180 }
2181
2182 #[test]
2183 fn serde_roundtrip_hyperlink() {
2184 let ctrl = Control::Hyperlink {
2185 text: "link text".to_string(),
2186 url: "https://rust-lang.org".to_string(),
2187 };
2188 let json = serde_json::to_string(&ctrl).unwrap();
2189 let back: Control = serde_json::from_str(&json).unwrap();
2190 assert_eq!(ctrl, back);
2191 }
2192
2193 #[test]
2194 fn serde_roundtrip_footnote() {
2195 let ctrl = Control::Footnote {
2196 inst_id: Some(ObjectId::new(12345)),
2197 paragraphs: vec![simple_paragraph()],
2198 };
2199 let json = serde_json::to_string(&ctrl).unwrap();
2200 let back: Control = serde_json::from_str(&json).unwrap();
2201 assert_eq!(ctrl, back);
2202 }
2203
2204 #[test]
2205 fn serde_roundtrip_endnote() {
2206 let ctrl = Control::Endnote { inst_id: None, paragraphs: vec![simple_paragraph()] };
2207 let json = serde_json::to_string(&ctrl).unwrap();
2208 let back: Control = serde_json::from_str(&json).unwrap();
2209 assert_eq!(ctrl, back);
2210 }
2211
2212 #[test]
2213 fn serde_roundtrip_unknown() {
2214 let ctrl = Control::Unknown { tag: "test".to_string(), data: Some("payload".to_string()) };
2215 let json = serde_json::to_string(&ctrl).unwrap();
2216 let back: Control = serde_json::from_str(&json).unwrap();
2217 assert_eq!(ctrl, back);
2218 }
2219
2220 // ── Shape variant tests ──────────────────────────────────────
2221
2222 #[test]
2223 fn line_construction() {
2224 let ctrl = Control::Line {
2225 start: ShapePoint { x: 0, y: 0 },
2226 end: ShapePoint { x: 1000, y: 500 },
2227 width: HwpUnit::from_mm(50.0).unwrap(),
2228 height: HwpUnit::from_mm(25.0).unwrap(),
2229 placement: None,
2230 caption: None,
2231 style: None,
2232 };
2233 assert!(ctrl.is_line());
2234 assert!(!ctrl.is_text_box());
2235 assert!(!ctrl.is_ellipse());
2236 assert!(!ctrl.is_polygon());
2237 }
2238
2239 #[test]
2240 fn ellipse_construction() {
2241 let ctrl = Control::Ellipse {
2242 center: ShapePoint { x: 500, y: 500 },
2243 axis1: ShapePoint { x: 1000, y: 500 },
2244 axis2: ShapePoint { x: 500, y: 1000 },
2245 width: HwpUnit::from_mm(40.0).unwrap(),
2246 height: HwpUnit::from_mm(30.0).unwrap(),
2247 placement: None,
2248 paragraphs: vec![],
2249 caption: None,
2250 style: None,
2251 text_vertical_align: VerticalAlign::Top,
2252 };
2253 assert!(ctrl.is_ellipse());
2254 assert!(!ctrl.is_line());
2255 assert!(!ctrl.is_polygon());
2256 }
2257
2258 #[test]
2259 fn ellipse_with_paragraphs() {
2260 let ctrl = Control::Ellipse {
2261 center: ShapePoint { x: 500, y: 500 },
2262 axis1: ShapePoint { x: 1000, y: 500 },
2263 axis2: ShapePoint { x: 500, y: 1000 },
2264 width: HwpUnit::from_mm(40.0).unwrap(),
2265 height: HwpUnit::from_mm(30.0).unwrap(),
2266 placement: None,
2267 paragraphs: vec![simple_paragraph()],
2268 caption: None,
2269 style: None,
2270 text_vertical_align: VerticalAlign::Top,
2271 };
2272 assert!(ctrl.is_ellipse());
2273 assert_eq!(ctrl.to_string(), "Ellipse(1 paragraph)");
2274 }
2275
2276 #[test]
2277 fn polygon_construction() {
2278 let ctrl = Control::Polygon {
2279 vertices: vec![
2280 ShapePoint { x: 0, y: 0 },
2281 ShapePoint { x: 1000, y: 0 },
2282 ShapePoint { x: 500, y: 1000 },
2283 ],
2284 width: HwpUnit::from_mm(50.0).unwrap(),
2285 height: HwpUnit::from_mm(50.0).unwrap(),
2286 placement: None,
2287 paragraphs: vec![],
2288 caption: None,
2289 style: None,
2290 text_vertical_align: VerticalAlign::Top,
2291 };
2292 assert!(ctrl.is_polygon());
2293 assert!(!ctrl.is_line());
2294 assert!(!ctrl.is_ellipse());
2295 assert_eq!(ctrl.to_string(), "Polygon(3 vertices, 0 paragraphs)");
2296 }
2297
2298 #[test]
2299 fn display_line() {
2300 let ctrl = Control::Line {
2301 start: ShapePoint { x: 0, y: 0 },
2302 end: ShapePoint { x: 100, y: 200 },
2303 width: HwpUnit::from_mm(10.0).unwrap(),
2304 height: HwpUnit::from_mm(5.0).unwrap(),
2305 placement: None,
2306 caption: None,
2307 style: None,
2308 };
2309 assert_eq!(ctrl.to_string(), "Line");
2310 }
2311
2312 #[test]
2313 fn serde_roundtrip_line() {
2314 let ctrl = Control::Line {
2315 start: ShapePoint { x: 100, y: 200 },
2316 end: ShapePoint { x: 300, y: 400 },
2317 width: HwpUnit::from_mm(20.0).unwrap(),
2318 height: HwpUnit::from_mm(10.0).unwrap(),
2319 placement: None,
2320 caption: None,
2321 style: None,
2322 };
2323 let json = serde_json::to_string(&ctrl).unwrap();
2324 let back: Control = serde_json::from_str(&json).unwrap();
2325 assert_eq!(ctrl, back);
2326 }
2327
2328 #[test]
2329 fn serde_roundtrip_ellipse() {
2330 let ctrl = Control::Ellipse {
2331 center: ShapePoint { x: 500, y: 500 },
2332 axis1: ShapePoint { x: 1000, y: 500 },
2333 axis2: ShapePoint { x: 500, y: 1000 },
2334 width: HwpUnit::from_mm(40.0).unwrap(),
2335 height: HwpUnit::from_mm(30.0).unwrap(),
2336 placement: None,
2337 paragraphs: vec![simple_paragraph()],
2338 caption: None,
2339 style: None,
2340 text_vertical_align: VerticalAlign::Top,
2341 };
2342 let json = serde_json::to_string(&ctrl).unwrap();
2343 let back: Control = serde_json::from_str(&json).unwrap();
2344 assert_eq!(ctrl, back);
2345 }
2346
2347 #[test]
2348 fn serde_roundtrip_polygon() {
2349 let ctrl = Control::Polygon {
2350 vertices: vec![
2351 ShapePoint { x: 0, y: 0 },
2352 ShapePoint { x: 1000, y: 0 },
2353 ShapePoint { x: 500, y: 1000 },
2354 ],
2355 width: HwpUnit::from_mm(50.0).unwrap(),
2356 height: HwpUnit::from_mm(50.0).unwrap(),
2357 placement: None,
2358 paragraphs: vec![],
2359 caption: None,
2360 style: None,
2361 text_vertical_align: VerticalAlign::Top,
2362 };
2363 let json = serde_json::to_string(&ctrl).unwrap();
2364 let back: Control = serde_json::from_str(&json).unwrap();
2365 assert_eq!(ctrl, back);
2366 }
2367
2368 #[test]
2369 fn shape_point_equality() {
2370 let a = ShapePoint { x: 10, y: 20 };
2371 let b = ShapePoint { x: 10, y: 20 };
2372 let c = ShapePoint { x: 10, y: 30 };
2373 assert_eq!(a, b);
2374 assert_ne!(a, c);
2375 }
2376
2377 #[test]
2378 fn shape_point_new() {
2379 let pt = ShapePoint::new(100, 200);
2380 assert_eq!(pt.x, 100);
2381 assert_eq!(pt.y, 200);
2382 }
2383
2384 #[test]
2385 fn shape_point_serde_roundtrip() {
2386 let pt = ShapePoint::new(500, 750);
2387 let json = serde_json::to_string(&pt).unwrap();
2388 let back: ShapePoint = serde_json::from_str(&json).unwrap();
2389 assert_eq!(pt, back);
2390 }
2391
2392 // ── Convenience constructor tests ────────────────────────────────────
2393
2394 #[test]
2395 fn equation_constructor_defaults() {
2396 let ctrl = Control::equation("{a+b} over {c+d}");
2397 assert!(ctrl.is_equation());
2398 match ctrl {
2399 Control::Equation {
2400 script,
2401 width,
2402 height,
2403 base_line,
2404 text_color,
2405 ref font,
2406 inst_id: _,
2407 } => {
2408 assert_eq!(script, "{a+b} over {c+d}");
2409 assert_eq!(width, HwpUnit::new(8779).unwrap());
2410 assert_eq!(height, HwpUnit::new(2600).unwrap());
2411 assert_eq!(base_line, 71);
2412 assert_eq!(text_color, Color::BLACK);
2413 assert_eq!(font, "HancomEQN");
2414 }
2415 _ => panic!("expected Equation"),
2416 }
2417 }
2418
2419 #[test]
2420 fn equation_constructor_empty_script() {
2421 let ctrl = Control::equation("");
2422 assert!(ctrl.is_equation());
2423 }
2424
2425 #[test]
2426 fn text_box_constructor_defaults() {
2427 let width = HwpUnit::from_mm(80.0).unwrap();
2428 let height = HwpUnit::from_mm(40.0).unwrap();
2429 let ctrl = Control::text_box(vec![simple_paragraph()], width, height);
2430 assert!(ctrl.is_text_box());
2431 match ctrl {
2432 Control::TextBox { paragraphs, placement, caption, style, .. } => {
2433 assert_eq!(paragraphs.len(), 1);
2434 assert!(placement.is_none());
2435 assert!(caption.is_none());
2436 assert!(style.is_none());
2437 }
2438 _ => panic!("expected TextBox"),
2439 }
2440 }
2441
2442 #[test]
2443 fn footnote_constructor_defaults() {
2444 let ctrl = Control::footnote(vec![simple_paragraph()]);
2445 assert!(ctrl.is_footnote());
2446 match ctrl {
2447 Control::Footnote { inst_id, paragraphs } => {
2448 assert!(inst_id.is_none());
2449 assert_eq!(paragraphs.len(), 1);
2450 }
2451 _ => panic!("expected Footnote"),
2452 }
2453 }
2454
2455 #[test]
2456 fn endnote_constructor_defaults() {
2457 let ctrl = Control::endnote(vec![simple_paragraph()]);
2458 assert!(ctrl.is_endnote());
2459 match ctrl {
2460 Control::Endnote { inst_id, paragraphs } => {
2461 assert!(inst_id.is_none());
2462 assert_eq!(paragraphs.len(), 1);
2463 }
2464 _ => panic!("expected Endnote"),
2465 }
2466 }
2467
2468 #[test]
2469 fn ellipse_constructor_geometry() {
2470 let width = HwpUnit::from_mm(40.0).unwrap();
2471 let height = HwpUnit::from_mm(30.0).unwrap();
2472 let ctrl = Control::ellipse(width, height);
2473 assert!(ctrl.is_ellipse());
2474 match &ctrl {
2475 Control::Ellipse {
2476 center,
2477 axis1,
2478 axis2,
2479 placement,
2480 paragraphs,
2481 caption,
2482 style,
2483 ..
2484 } => {
2485 let w = width.as_i32();
2486 let h = height.as_i32();
2487 assert_eq!(*center, ShapePoint::new(w / 2, h / 2));
2488 assert_eq!(*axis1, ShapePoint::new(w, h / 2));
2489 assert_eq!(*axis2, ShapePoint::new(w / 2, h));
2490 assert!(placement.is_none());
2491 assert!(paragraphs.is_empty());
2492 assert!(caption.is_none());
2493 assert!(style.is_none());
2494 }
2495 _ => panic!("expected Ellipse"),
2496 }
2497 }
2498
2499 #[test]
2500 fn rect_constructor_basic_geometry() {
2501 let width = HwpUnit::from_mm(40.0).unwrap();
2502 let height = HwpUnit::from_mm(20.0).unwrap();
2503 let ctrl = Control::rect(width, height).unwrap();
2504 assert!(ctrl.is_rect());
2505 match ctrl {
2506 Control::Rect { width: w, height: h, placement, caption, style } => {
2507 assert_eq!(w, width);
2508 assert_eq!(h, height);
2509 assert!(placement.is_none());
2510 assert!(caption.is_none());
2511 assert!(style.is_none());
2512 }
2513 _ => panic!("expected Rect"),
2514 }
2515 }
2516
2517 #[test]
2518 fn rect_constructor_zero_dimension_errors() {
2519 let zero = HwpUnit::new(0).unwrap();
2520 let nonzero = HwpUnit::from_mm(10.0).unwrap();
2521 assert!(Control::rect(zero, nonzero).is_err());
2522 assert!(Control::rect(nonzero, zero).is_err());
2523 }
2524
2525 #[test]
2526 fn polygon_constructor_triangle() {
2527 let vertices =
2528 vec![ShapePoint::new(0, 1000), ShapePoint::new(500, 0), ShapePoint::new(1000, 1000)];
2529 let ctrl = Control::polygon(vertices).unwrap();
2530 assert!(ctrl.is_polygon());
2531 match &ctrl {
2532 Control::Polygon {
2533 vertices,
2534 width,
2535 height,
2536 placement,
2537 paragraphs,
2538 caption,
2539 style,
2540 ..
2541 } => {
2542 assert_eq!(vertices.len(), 3);
2543 // bbox: x 0..1000, y 0..1000
2544 assert_eq!(*width, HwpUnit::new(1000).unwrap());
2545 assert_eq!(*height, HwpUnit::new(1000).unwrap());
2546 assert!(placement.is_none());
2547 assert!(paragraphs.is_empty());
2548 assert!(caption.is_none());
2549 assert!(style.is_none());
2550 }
2551 _ => panic!("expected Polygon"),
2552 }
2553 }
2554
2555 #[test]
2556 fn polygon_constructor_fewer_than_3_vertices_errors() {
2557 assert!(Control::polygon(vec![]).is_err());
2558 assert!(Control::polygon(vec![ShapePoint::new(0, 0)]).is_err());
2559 assert!(Control::polygon(vec![ShapePoint::new(0, 0), ShapePoint::new(1, 1)]).is_err());
2560 }
2561
2562 #[test]
2563 fn polygon_constructor_negative_coordinates() {
2564 let vertices =
2565 vec![ShapePoint::new(-500, -500), ShapePoint::new(500, -500), ShapePoint::new(0, 500)];
2566 let ctrl = Control::polygon(vertices).unwrap();
2567 assert!(ctrl.is_polygon());
2568 match ctrl {
2569 Control::Polygon { width, height, .. } => {
2570 // bbox: x -500..500 = 1000, y -500..500 = 1000
2571 assert_eq!(width, HwpUnit::new(1000).unwrap());
2572 assert_eq!(height, HwpUnit::new(1000).unwrap());
2573 }
2574 _ => panic!("expected Polygon"),
2575 }
2576 }
2577
2578 #[test]
2579 fn polygon_constructor_degenerate_collinear() {
2580 // 3 collinear points: height = 0 (flat), should succeed
2581 let vertices =
2582 vec![ShapePoint::new(0, 0), ShapePoint::new(500, 0), ShapePoint::new(1000, 0)];
2583 let ctrl = Control::polygon(vertices).unwrap();
2584 assert!(ctrl.is_polygon());
2585 match ctrl {
2586 Control::Polygon { width, height, .. } => {
2587 assert_eq!(width, HwpUnit::new(1000).unwrap());
2588 assert_eq!(height, HwpUnit::new(0).unwrap());
2589 }
2590 _ => panic!("expected Polygon"),
2591 }
2592 }
2593
2594 #[test]
2595 fn line_constructor_horizontal() {
2596 let ctrl = Control::line(ShapePoint::new(0, 0), ShapePoint::new(5000, 0)).unwrap();
2597 assert!(ctrl.is_line());
2598 match ctrl {
2599 Control::Line { start, end, width, height, placement, caption, style } => {
2600 assert_eq!(start, ShapePoint::new(0, 0));
2601 assert_eq!(end, ShapePoint::new(5000, 0));
2602 assert_eq!(width, HwpUnit::new(5000).unwrap());
2603 assert_eq!(height, HwpUnit::new(100).unwrap()); // min bounding box
2604 assert!(placement.is_none());
2605 assert!(caption.is_none());
2606 assert!(style.is_none());
2607 }
2608 _ => panic!("expected Line"),
2609 }
2610 }
2611
2612 #[test]
2613 fn line_constructor_vertical() {
2614 let ctrl = Control::line(ShapePoint::new(0, 0), ShapePoint::new(0, 3000)).unwrap();
2615 assert!(ctrl.is_line());
2616 match ctrl {
2617 Control::Line { width, height, .. } => {
2618 assert_eq!(width, HwpUnit::new(100).unwrap()); // min bounding box
2619 assert_eq!(height, HwpUnit::new(3000).unwrap());
2620 }
2621 _ => panic!("expected Line"),
2622 }
2623 }
2624
2625 #[test]
2626 fn line_constructor_diagonal_bounding_box() {
2627 let ctrl = Control::line(ShapePoint::new(100, 200), ShapePoint::new(400, 500)).unwrap();
2628 match ctrl {
2629 Control::Line { width, height, .. } => {
2630 assert_eq!(width, HwpUnit::new(300).unwrap());
2631 assert_eq!(height, HwpUnit::new(300).unwrap());
2632 }
2633 _ => panic!("expected Line"),
2634 }
2635 }
2636
2637 #[test]
2638 fn line_constructor_same_point_errors() {
2639 let pt = ShapePoint::new(100, 200);
2640 assert!(Control::line(pt, pt).is_err());
2641 }
2642
2643 #[test]
2644 fn horizontal_line_constructor() {
2645 let width = HwpUnit::from_mm(100.0).unwrap();
2646 let ctrl = Control::horizontal_line(width);
2647 assert!(ctrl.is_line());
2648 match ctrl {
2649 Control::Line { start, end, width: w, height, placement, caption, style } => {
2650 assert_eq!(start, ShapePoint::new(0, 0));
2651 assert_eq!(end.y, 0);
2652 assert_eq!(end.x, width.as_i32());
2653 assert_eq!(w, width);
2654 assert_eq!(height, HwpUnit::new(100).unwrap()); // min bounding box
2655 assert!(placement.is_none());
2656 assert!(caption.is_none());
2657 assert!(style.is_none());
2658 }
2659 _ => panic!("expected Line"),
2660 }
2661 }
2662
2663 #[test]
2664 fn hyperlink_constructor() {
2665 let ctrl = Control::hyperlink("Visit Rust", "https://rust-lang.org");
2666 assert!(ctrl.is_hyperlink());
2667 match ctrl {
2668 Control::Hyperlink { text, url } => {
2669 assert_eq!(text, "Visit Rust");
2670 assert_eq!(url, "https://rust-lang.org");
2671 }
2672 _ => panic!("expected Hyperlink"),
2673 }
2674 }
2675
2676 #[test]
2677 fn footnote_with_id_sets_inst_id() {
2678 let para = Paragraph::new(ParaShapeIndex::new(0));
2679 let ctrl = Control::footnote_with_id(42, vec![para]);
2680 assert!(ctrl.is_footnote());
2681 match ctrl {
2682 Control::Footnote { inst_id, paragraphs } => {
2683 assert_eq!(inst_id, Some(ObjectId::new(42)));
2684 assert_eq!(paragraphs.len(), 1);
2685 }
2686 _ => panic!("expected Footnote"),
2687 }
2688 }
2689
2690 #[test]
2691 fn endnote_with_id_sets_inst_id() {
2692 let para = Paragraph::new(ParaShapeIndex::new(0));
2693 let ctrl = Control::endnote_with_id(7, vec![para]);
2694 assert!(ctrl.is_endnote());
2695 match ctrl {
2696 Control::Endnote { inst_id, paragraphs } => {
2697 assert_eq!(inst_id, Some(ObjectId::new(7)));
2698 assert_eq!(paragraphs.len(), 1);
2699 }
2700 _ => panic!("expected Endnote"),
2701 }
2702 }
2703
2704 #[test]
2705 fn footnote_with_id_differs_from_plain_footnote() {
2706 let ctrl_plain = Control::footnote(vec![]);
2707 let ctrl_id = Control::footnote_with_id(1, vec![]);
2708 match ctrl_plain {
2709 Control::Footnote { inst_id, .. } => assert_eq!(inst_id, None),
2710 _ => panic!("expected Footnote"),
2711 }
2712 match ctrl_id {
2713 Control::Footnote { inst_id, .. } => assert_eq!(inst_id, Some(ObjectId::new(1))),
2714 _ => panic!("expected Footnote"),
2715 }
2716 }
2717
2718 #[test]
2719 fn ellipse_with_text_has_correct_geometry_and_paragraphs() {
2720 use hwpforge_foundation::HwpUnit;
2721 let width = HwpUnit::from_mm(40.0).unwrap();
2722 let height = HwpUnit::from_mm(30.0).unwrap();
2723 let para = Paragraph::new(ParaShapeIndex::new(0));
2724 let ctrl = Control::ellipse_with_text(width, height, vec![para]);
2725 assert!(ctrl.is_ellipse());
2726 match ctrl {
2727 Control::Ellipse {
2728 center,
2729 axis1,
2730 axis2,
2731 width: w,
2732 height: h,
2733 placement,
2734 paragraphs,
2735 caption,
2736 style,
2737 ..
2738 } => {
2739 let wv = w.as_i32();
2740 let hv = h.as_i32();
2741 assert_eq!(center, ShapePoint::new(wv / 2, hv / 2));
2742 assert_eq!(axis1, ShapePoint::new(wv, hv / 2));
2743 assert_eq!(axis2, ShapePoint::new(wv / 2, hv));
2744 assert!(placement.is_none());
2745 assert_eq!(paragraphs.len(), 1);
2746 assert!(caption.is_none());
2747 assert!(style.is_none());
2748 }
2749 _ => panic!("expected Ellipse"),
2750 }
2751 }
2752
2753 #[test]
2754 fn serde_roundtrip_chart() {
2755 use crate::chart::{ChartData, ChartGrouping, ChartType, LegendPosition};
2756 let ctrl = Control::Chart {
2757 chart_type: ChartType::Column,
2758 data: ChartData::category(&["A", "B"], &[("S1", &[1.0, 2.0])]),
2759 title: Some("Test Chart".to_string()),
2760 legend: LegendPosition::Bottom,
2761 grouping: ChartGrouping::Stacked,
2762 width: HwpUnit::from_mm(100.0).unwrap(),
2763 height: HwpUnit::from_mm(80.0).unwrap(),
2764 stock_variant: None,
2765 bar_shape: None,
2766 scatter_style: None,
2767 radar_style: None,
2768 of_pie_type: None,
2769 explosion: None,
2770 wireframe: None,
2771 bubble_3d: None,
2772 show_markers: None,
2773 };
2774 let json = serde_json::to_string(&ctrl).unwrap();
2775 let back: Control = serde_json::from_str(&json).unwrap();
2776 assert_eq!(ctrl, back);
2777 }
2778
2779 #[test]
2780 fn serde_roundtrip_equation() {
2781 let ctrl = Control::Equation {
2782 script: "{a+b} over {c+d}".to_string(),
2783 width: HwpUnit::new(8779).unwrap(),
2784 height: HwpUnit::new(2600).unwrap(),
2785 base_line: 71,
2786 text_color: Color::BLACK,
2787 font: "HancomEQN".to_string(),
2788 inst_id: None,
2789 };
2790 let json = serde_json::to_string(&ctrl).unwrap();
2791 let back: Control = serde_json::from_str(&json).unwrap();
2792 assert_eq!(ctrl, back);
2793 }
2794
2795 #[test]
2796 fn ellipse_with_text_empty_paragraphs_matches_ellipse() {
2797 use hwpforge_foundation::HwpUnit;
2798 let width = HwpUnit::from_mm(20.0).unwrap();
2799 let height = HwpUnit::from_mm(10.0).unwrap();
2800 let plain = Control::ellipse(width, height);
2801 let with_text = Control::ellipse_with_text(width, height, vec![]);
2802 // Both should produce identical shapes when paragraphs are empty
2803 assert_eq!(plain, with_text);
2804 }
2805
2806 // ── Dutmal (덧말) tests ──────────────────────────────────────
2807
2808 #[test]
2809 fn dutmal_constructor_defaults() {
2810 let ctrl = Control::dutmal("본문", "주석");
2811 assert!(ctrl.is_dutmal());
2812 match ctrl {
2813 Control::Dutmal { main_text, sub_text, position, sz_ratio, align, .. } => {
2814 assert_eq!(main_text, "본문");
2815 assert_eq!(sub_text, "주석");
2816 assert_eq!(position, DutmalPosition::Top);
2817 assert_eq!(sz_ratio, 0);
2818 assert_eq!(align, DutmalAlign::Center);
2819 }
2820 _ => panic!("expected Dutmal"),
2821 }
2822 }
2823
2824 #[test]
2825 fn dutmal_is_dutmal_true() {
2826 assert!(Control::dutmal("a", "b").is_dutmal());
2827 }
2828
2829 #[test]
2830 fn dutmal_is_compose_false() {
2831 assert!(!Control::dutmal("a", "b").is_compose());
2832 }
2833
2834 #[test]
2835 fn dutmal_display() {
2836 let ctrl = Control::dutmal("hello", "world");
2837 assert_eq!(ctrl.to_string(), r#"Dutmal("hello" / "world")"#);
2838 }
2839
2840 #[test]
2841 fn dutmal_serde_roundtrip() {
2842 let ctrl = Control::Dutmal {
2843 main_text: "테스트".to_string(),
2844 sub_text: "test".to_string(),
2845 position: DutmalPosition::Bottom,
2846 sz_ratio: 50,
2847 align: DutmalAlign::Right,
2848 metadata: DutmalMetadata::default(),
2849 };
2850 let json = serde_json::to_string(&ctrl).unwrap();
2851 let decoded: Control = serde_json::from_str(&json).unwrap();
2852 assert_eq!(ctrl, decoded);
2853 }
2854
2855 #[test]
2856 fn dutmal_position_default_is_top() {
2857 assert_eq!(DutmalPosition::default(), DutmalPosition::Top);
2858 }
2859
2860 #[test]
2861 fn dutmal_align_default_is_center() {
2862 assert_eq!(DutmalAlign::default(), DutmalAlign::Center);
2863 }
2864
2865 // ── Compose (글자겹침) tests ─────────────────────────────────
2866
2867 #[test]
2868 fn compose_constructor_defaults() {
2869 let ctrl = Control::compose("가");
2870 assert!(ctrl.is_compose());
2871 match ctrl {
2872 Control::Compose { compose_text, circle_type, char_sz, compose_type, char_pr_ids } => {
2873 assert_eq!(compose_text, "가");
2874 assert_eq!(circle_type, "SHAPE_REVERSAL_TIRANGLE");
2875 assert_eq!(char_sz, -3);
2876 assert_eq!(compose_type, "SPREAD");
2877 assert_eq!(char_pr_ids, vec![u32::MAX; 10]);
2878 }
2879 _ => panic!("expected Compose"),
2880 }
2881 }
2882
2883 #[test]
2884 fn compose_is_compose_true() {
2885 assert!(Control::compose("나").is_compose());
2886 }
2887
2888 #[test]
2889 fn compose_is_dutmal_false() {
2890 assert!(!Control::compose("나").is_dutmal());
2891 }
2892
2893 #[test]
2894 fn compose_display() {
2895 let ctrl = Control::compose("가나");
2896 assert_eq!(ctrl.to_string(), r#"Compose("가나")"#);
2897 }
2898
2899 #[test]
2900 fn compose_serde_roundtrip() {
2901 let ctrl = Control::Compose {
2902 compose_text: "①".to_string(),
2903 circle_type: "SHAPE_REVERSAL_TIRANGLE".to_string(),
2904 char_sz: -3,
2905 compose_type: "SPREAD".to_string(),
2906 char_pr_ids: vec![u32::MAX; 10],
2907 };
2908 let json = serde_json::to_string(&ctrl).unwrap();
2909 let decoded: Control = serde_json::from_str(&json).unwrap();
2910 assert_eq!(ctrl, decoded);
2911 }
2912
2913 #[test]
2914 fn compose_spec_typo_preserved() {
2915 // "SHAPE_REVERSAL_TIRANGLE" is an official spec typo — must be preserved exactly
2916 let ctrl = Control::compose("X");
2917 match ctrl {
2918 Control::Compose { circle_type, .. } => {
2919 assert_eq!(circle_type, "SHAPE_REVERSAL_TIRANGLE");
2920 assert!(!circle_type.contains("TRIANGLE")); // confirm the typo
2921 }
2922 _ => panic!("expected Compose"),
2923 }
2924 }
2925
2926 // ===================================================================
2927 // H2: saturating i64→i32 conversion in shape constructors
2928 // ===================================================================
2929
2930 #[test]
2931 fn line_extreme_coords_no_panic() {
2932 // Coordinates near i32 extremes produce a valid line without panicking
2933 let start = ShapePoint::new(i32::MIN, i32::MIN);
2934 let end = ShapePoint::new(i32::MAX, i32::MAX);
2935 let ctrl = Control::line(start, end).unwrap();
2936 assert!(ctrl.is_line());
2937 }
2938
2939 #[test]
2940 fn connect_line_extreme_coords_no_panic() {
2941 let start = ShapePoint::new(i32::MIN, 0);
2942 let end = ShapePoint::new(i32::MAX, 0);
2943 let ctrl = Control::connect_line(start, end).unwrap();
2944 assert!(ctrl.is_connect_line());
2945 }
2946
2947 #[test]
2948 fn polygon_extreme_coords_no_panic() {
2949 // Span exceeds i32::MAX — should error (HwpUnit range exceeded), not panic
2950 let vertices = vec![
2951 ShapePoint::new(i32::MIN, 0),
2952 ShapePoint::new(i32::MAX, 0),
2953 ShapePoint::new(0, i32::MAX),
2954 ];
2955 // Either succeeds (saturated) or returns an error — must not panic
2956 let _ = Control::polygon(vertices);
2957 }
2958
2959 #[test]
2960 fn curve_extreme_coords_no_panic() {
2961 let points = vec![ShapePoint::new(i32::MIN, i32::MIN), ShapePoint::new(i32::MAX, i32::MAX)];
2962 let _ = Control::curve(points);
2963 }
2964}