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