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 is the user-filled value; an unfilled field
564 /// carries either the same string as [`Self::Field::hint_text`]
565 /// (decoded from a native body) or the empty string (constructed).
566 /// When empty, the HWPX encoder emits `hint_text` as the body.
567 ///
568 /// An empty body triggers 한컴's "낮은 보안 수준 복구" warning on
569 /// open for SUMMERY fields (#120/#136) — carrying the verbatim
570 /// source value avoids it.
571 display_text: String,
572 },
573
574 /// A memo (메모) annotation attached to text.
575 ///
576 /// Maps to HWPX `fieldBegin type="MEMO"` + anchor body runs +
577 /// `fieldEnd` flat inside one `<hp:run>`. The memo's body lives inside
578 /// `fieldBegin`'s `<hp:subList>`; the *anchor* runs sit between
579 /// `fieldBegin` and `fieldEnd` so 한컴 can pair the markers and render
580 /// the `[메모 시작]…[메모 끝]` UI labels instead of generic
581 /// `[메모 시작]…[필드 끝]`.
582 ///
583 /// Wave 12e: `author`/`date` fields removed (no wire path populated
584 /// them).
585 ///
586 /// Wave 12f: `anchor_runs` added. Without it, the encoder produced an
587 /// empty `<hp:t/>` between `fieldBegin` and `fieldEnd`, which 한컴 reads
588 /// as an unpaired field — visible bug.
589 Memo {
590 /// Paragraphs forming the memo body content (rendered in
591 /// `<hp:subList>`).
592 content: Vec<Paragraph>,
593 /// Runs that form the visible *anchor* text — the body span the memo
594 /// is attached to. Encoders interleave these between `fieldBegin`
595 /// and `fieldEnd` inside one `<hp:run>`. Should normally hold only
596 /// `RunContent::Text`; other variants are downgraded by the encoder
597 /// with a warning (memos cannot anchor on tables/images/nested
598 /// controls in HWPX).
599 anchor_runs: Vec<Run>,
600 /// HWPX `<hp:parameters>` for the memo. Carrying these as a
601 /// dedicated [`MemoMetadata`] (instead of half-empty hard-coded
602 /// values) keeps the metadata format-agnostic — encoders for HWPX
603 /// (and any future format with similar metadata) consume the same
604 /// struct.
605 metadata: MemoMetadata,
606 },
607
608 /// An index mark for building a document index (찾아보기).
609 /// Maps to HWPX `<hp:ctrl><hp:indexmark>`.
610 IndexMark {
611 /// Primary index key (required).
612 primary: String,
613 /// Secondary (sub-entry) index key.
614 secondary: Option<String>,
615 },
616
617 /// An unknown SUMMERY (`%smr`) `$token` carried verbatim for forward
618 /// compatibility (Wave 12n).
619 ///
620 /// Wave 12n only models the five HwpForge-observed tokens (`$author`,
621 /// `$lastsaveby`, `$createtime`, `$modifiedtime`, `$title`) as typed
622 /// [`FieldType`] variants. Any other `%smr` Command (e.g. additional
623 /// 한컴 metadata tokens not yet measured) is preserved here instead of
624 /// being silently coerced to `ClickHere`.
625 UnknownSummary {
626 /// Raw `Command` string after envelope (e.g. `"$company"`).
627 token: String,
628 /// Cached resolved value rendered between `fieldBegin`/`fieldEnd`.
629 /// Same semantics as [`Self::Field::display_text`]; empty = none.
630 display_text: String,
631 },
632
633 /// A `%dte` date/time **format-pattern** field (Wave 12n).
634 ///
635 /// HWP5 family `%dte` (ctrl_id `0x2564_7465`) used by 한컴
636 /// `입력 → 날짜/시간/파일 이름 → 날짜/시간 코드` menu. Unlike SUMMERY
637 /// (which carries semantic tokens like `$createtime`), `%dte` carries
638 /// a raw format pattern string (e.g. `"\:1년 2월 3일 (6);0;"` for date,
639 /// `"T\:;0;"` for time-only). The HWP5 wire format pattern is
640 /// smithy-internal; the format-agnostic core retains only the derived
641 /// `is_time_mode` helper (was based on the `T` prefix at projection time).
642 DateCodeField {
643 /// Helper view: `true` for a time-only (`T`-prefixed) format,
644 /// `false` for a date format. Derived at HWP5 projection time.
645 is_time_mode: bool,
646 /// Cached resolved value rendered between `fieldBegin`/`fieldEnd`
647 /// (the locale-formatted date/time string). Same semantics as
648 /// [`Self::Field::display_text`]; empty = none.
649 display_text: String,
650 },
651
652 /// A `%pat` path / file-name field (Wave 12n).
653 ///
654 /// HWP5 family `%pat` (ctrl_id `0x2570_6174`) emitted by 한컴
655 /// `상용구 → 파일 이름 / 파일 이름과 경로`. Uses `$P` (path) and
656 /// `$F` (file name) format codes.
657 PathField {
658 /// Typed variant of the observed `Command` pattern.
659 command: PathFieldCommand,
660 /// Cached resolved value rendered between `fieldBegin`/`fieldEnd`
661 /// (the absolute path/file name 한컴 last evaluated). Same semantics
662 /// as [`Self::Field::display_text`]; empty = none. 한컴 recomputes
663 /// `$P`/`$F` against the file's on-disk path on save, but an empty
664 /// body on open triggers the recovery warning (#120).
665 display_text: String,
666 },
667
668 /// An `atno` **inline** page number control (Wave 12n).
669 ///
670 /// HWP5 family `atno` (ctrl_id `0x6174_6E6F`) used by 한컴
671 /// `상용구 → 현재 쪽 번호 / 전체 쪽수 / 현재 쪽/전체 쪽수`. Distinct
672 /// from `pgnp` (section-level page numbering control already modeled
673 /// as `Section.page_number`). Inline `atno` renders to HWPX
674 /// `<hp:autoNum>` inside a `<hp:run>`.
675 ///
676 /// The 16-byte wire envelope carries a single 4-byte flag that
677 /// distinguishes current-page from total-pages; the HWP5 projection
678 /// maps it to [`InlinePageKind`].
679 InlinePageNumber {
680 /// Typed variant of the observed `flag` byte.
681 kind: InlinePageKind,
682 },
683
684 /// An unrecognized control element preserved for round-trip fidelity.
685 ///
686 /// `tag` holds the element's tag name or type identifier.
687 /// `data` holds optional serialized content for lossless preservation.
688 Unknown {
689 /// Tag name or type identifier of the unrecognized element.
690 tag: String,
691 /// Optional serialized data for round-trip preservation.
692 data: Option<String>,
693 },
694}
695
696impl Control {
697 /// Returns the stable snake_case name of this control's kind.
698 ///
699 /// Read/diff projections use this to label embedded content without
700 /// exposing payload details. The match is deliberately exhaustive (no
701 /// wildcard) so adding a variant forces a name here at compile time.
702 #[must_use]
703 pub fn kind_name(&self) -> &'static str {
704 match self {
705 Self::TextBox { .. } => "text_box",
706 Self::Hyperlink { .. } => "hyperlink",
707 Self::Footnote { .. } => "footnote",
708 Self::Endnote { .. } => "endnote",
709 Self::Line { .. } => "line",
710 Self::Ellipse { .. } => "ellipse",
711 Self::EmbeddedChart { .. } => "embedded_chart",
712 Self::Rect { .. } => "rect",
713 Self::Polygon { .. } => "polygon",
714 Self::Equation { .. } => "equation",
715 Self::Chart { .. } => "chart",
716 Self::Dutmal { .. } => "dutmal",
717 Self::Compose { .. } => "compose",
718 Self::Arc { .. } => "arc",
719 Self::Curve { .. } => "curve",
720 Self::ConnectLine { .. } => "connect_line",
721 Self::Group { .. } => "group",
722 Self::TextArt { .. } => "text_art",
723 Self::Bookmark { .. } => "bookmark",
724 Self::CrossRef { .. } => "cross_ref",
725 Self::Field { .. } => "field",
726 Self::Memo { .. } => "memo",
727 Self::IndexMark { .. } => "index_mark",
728 Self::UnknownSummary { .. } => "unknown_summary",
729 Self::DateCodeField { .. } => "date_code_field",
730 Self::PathField { .. } => "path_field",
731 Self::InlinePageNumber { .. } => "inline_page_number",
732 Self::Unknown { .. } => "unknown",
733 }
734 }
735
736 /// Returns `true` if this is a [`Control::TextBox`].
737 pub fn is_text_box(&self) -> bool {
738 matches!(self, Self::TextBox { .. })
739 }
740
741 /// Returns `true` if this is a [`Control::Hyperlink`].
742 pub fn is_hyperlink(&self) -> bool {
743 matches!(self, Self::Hyperlink { .. })
744 }
745
746 /// Returns `true` if this is a [`Control::Footnote`].
747 pub fn is_footnote(&self) -> bool {
748 matches!(self, Self::Footnote { .. })
749 }
750
751 /// Returns `true` if this is a [`Control::Endnote`].
752 pub fn is_endnote(&self) -> bool {
753 matches!(self, Self::Endnote { .. })
754 }
755
756 /// Returns `true` if this is a [`Control::Line`].
757 pub fn is_line(&self) -> bool {
758 matches!(self, Self::Line { .. })
759 }
760
761 /// Returns `true` if this is a [`Control::Ellipse`].
762 pub fn is_ellipse(&self) -> bool {
763 matches!(self, Self::Ellipse { .. })
764 }
765
766 /// Returns `true` if this is a [`Control::Rect`].
767 pub fn is_rect(&self) -> bool {
768 matches!(self, Self::Rect { .. })
769 }
770
771 /// Returns `true` if this is a [`Control::Polygon`].
772 pub fn is_polygon(&self) -> bool {
773 matches!(self, Self::Polygon { .. })
774 }
775
776 /// Returns `true` if this is a [`Control::Equation`].
777 pub fn is_equation(&self) -> bool {
778 matches!(self, Self::Equation { .. })
779 }
780
781 /// Returns `true` if this is a [`Control::Chart`].
782 pub fn is_chart(&self) -> bool {
783 matches!(self, Self::Chart { .. })
784 }
785
786 /// Returns `true` if this is a [`Control::EmbeddedChart`].
787 pub fn is_embedded_chart(&self) -> bool {
788 matches!(self, Self::EmbeddedChart { .. })
789 }
790
791 /// Returns `true` if this is a [`Control::Unknown`].
792 pub fn is_unknown(&self) -> bool {
793 matches!(self, Self::Unknown { .. })
794 }
795
796 /// Returns `true` if this is a [`Control::Dutmal`].
797 pub fn is_dutmal(&self) -> bool {
798 matches!(self, Self::Dutmal { .. })
799 }
800
801 /// Returns `true` if this is a [`Control::Compose`].
802 pub fn is_compose(&self) -> bool {
803 matches!(self, Self::Compose { .. })
804 }
805
806 /// Returns `true` if this is a [`Control::Arc`].
807 pub fn is_arc(&self) -> bool {
808 matches!(self, Self::Arc { .. })
809 }
810
811 /// Returns `true` if this is a [`Control::Curve`].
812 pub fn is_curve(&self) -> bool {
813 matches!(self, Self::Curve { .. })
814 }
815
816 /// Returns `true` if this is a [`Control::ConnectLine`].
817 pub fn is_connect_line(&self) -> bool {
818 matches!(self, Self::ConnectLine { .. })
819 }
820
821 /// Returns `true` if this is a [`Control::Group`].
822 pub fn is_group(&self) -> bool {
823 matches!(self, Self::Group { .. })
824 }
825
826 /// Returns `true` if this is a [`Control::Bookmark`].
827 pub fn is_bookmark(&self) -> bool {
828 matches!(self, Self::Bookmark { .. })
829 }
830
831 /// Returns `true` if this is a [`Control::CrossRef`].
832 pub fn is_cross_ref(&self) -> bool {
833 matches!(self, Self::CrossRef { .. })
834 }
835
836 /// Returns `true` if this is a [`Control::Field`].
837 pub fn is_field(&self) -> bool {
838 matches!(self, Self::Field { .. })
839 }
840
841 /// Returns `true` if this is a [`Control::Memo`].
842 pub fn is_memo(&self) -> bool {
843 matches!(self, Self::Memo { .. })
844 }
845
846 /// Returns `true` if this is a [`Control::IndexMark`].
847 pub fn is_index_mark(&self) -> bool {
848 matches!(self, Self::IndexMark { .. })
849 }
850
851 /// Creates a point bookmark at a named location.
852 ///
853 /// # Examples
854 ///
855 /// ```
856 /// use hwpforge_core::control::Control;
857 ///
858 /// let bm = Control::bookmark("section1");
859 /// assert!(bm.is_bookmark());
860 /// ```
861 pub fn bookmark(name: &str) -> Self {
862 Self::Bookmark { name: name.to_string(), bookmark_type: BookmarkType::Point }
863 }
864
865 /// Creates a press-field (누름틀) with the given hint text.
866 ///
867 /// # Examples
868 ///
869 /// ```
870 /// use hwpforge_core::control::Control;
871 ///
872 /// let field = Control::field("이름을 입력하세요");
873 /// assert!(field.is_field());
874 /// ```
875 pub fn field(hint: &str) -> Self {
876 Self::Field {
877 field_type: FieldType::ClickHere,
878 hint_text: Some(hint.to_string()),
879 help_text: None,
880 name: None,
881 display_text: String::new(),
882 }
883 }
884
885 /// Creates an index mark with a primary key.
886 ///
887 /// # Examples
888 ///
889 /// ```
890 /// use hwpforge_core::control::Control;
891 ///
892 /// let mark = Control::index_mark("한글");
893 /// assert!(mark.is_index_mark());
894 /// ```
895 pub fn index_mark(primary: &str) -> Self {
896 Self::IndexMark { primary: primary.to_string(), secondary: None }
897 }
898
899 /// Creates a memo annotation with the given paragraph body.
900 ///
901 /// # Examples
902 ///
903 /// ```
904 /// use hwpforge_core::control::Control;
905 /// use hwpforge_core::paragraph::Paragraph;
906 /// use hwpforge_foundation::ParaShapeIndex;
907 ///
908 /// let para = Paragraph::new(ParaShapeIndex::new(0));
909 /// let memo = Control::memo(vec![para]);
910 /// assert!(memo.is_memo());
911 /// ```
912 pub fn memo(content: Vec<Paragraph>) -> Self {
913 Self::Memo { content, anchor_runs: Vec::new(), metadata: MemoMetadata::default() }
914 }
915
916 /// Creates a memo annotation with both body content and anchor runs.
917 ///
918 /// `anchor_runs` are the visible body span the memo is attached to (the
919 /// text between HWPX `<hp:fieldBegin type="MEMO">` and `<hp:fieldEnd>`);
920 /// `content` is the memo body inside `<hp:subList>`.
921 ///
922 /// # Examples
923 ///
924 /// ```
925 /// use hwpforge_core::control::Control;
926 /// use hwpforge_core::paragraph::Paragraph;
927 /// use hwpforge_core::run::Run;
928 /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
929 ///
930 /// let body = vec![Paragraph::new(ParaShapeIndex::new(0))];
931 /// let anchor = vec![Run::text("hello", CharShapeIndex::new(0))];
932 /// let memo = Control::memo_with_anchor(body, anchor);
933 /// assert!(memo.is_memo());
934 /// ```
935 pub fn memo_with_anchor(content: Vec<Paragraph>, anchor_runs: Vec<Run>) -> Self {
936 Self::Memo { content, anchor_runs, metadata: MemoMetadata::default() }
937 }
938
939 /// Creates a cross-reference to a bookmark target (convenience helper).
940 ///
941 /// Wave 12m Phase 2: 인자 타입이 `&str` 에서 `RefTarget` 로 변경
942 /// (breaking). 책갈피 이름이라면 `RefTarget::Name(...)`, 한컴 시스템
943 /// ID 라면 `RefTarget::SystemId(...)` 를 명시.
944 ///
945 /// # Examples
946 ///
947 /// ```
948 /// use hwpforge_core::control::{Control, RefTarget};
949 /// use hwpforge_foundation::{RefType, RefContentType};
950 ///
951 /// let xref = Control::cross_ref(
952 /// RefTarget::Name("section1".to_string()),
953 /// RefType::Bookmark,
954 /// RefContentType::Page,
955 /// );
956 /// assert!(xref.is_cross_ref());
957 /// ```
958 pub fn cross_ref(target: RefTarget, ref_type: RefType, content_type: RefContentType) -> Self {
959 Self::CrossRef {
960 target,
961 ref_type,
962 content_type,
963 as_hyperlink: false,
964 display_text: String::new(),
965 }
966 }
967
968 /// Creates a chart control with default dimensions and settings.
969 ///
970 /// Defaults: width ≈ 114mm, height ≈ 66mm, no title, right legend, clustered grouping.
971 ///
972 /// # Examples
973 ///
974 /// ```
975 /// use hwpforge_core::control::Control;
976 /// use hwpforge_core::chart::{ChartType, ChartData};
977 ///
978 /// let data = ChartData::category(&["A", "B"], &[("S1", &[10.0, 20.0])]);
979 /// let ctrl = Control::chart(ChartType::Column, data);
980 /// assert!(ctrl.is_chart());
981 /// ```
982 pub fn chart(chart_type: ChartType, data: ChartData) -> Self {
983 Self::Chart {
984 chart_type,
985 data,
986 width: HwpUnit::new(32250).expect("32250 is valid"),
987 height: HwpUnit::new(18750).expect("18750 is valid"),
988 title: None,
989 legend: LegendPosition::default(),
990 grouping: ChartGrouping::default(),
991 bar_shape: None,
992 explosion: None,
993 of_pie_type: None,
994 radar_style: None,
995 wireframe: None,
996 bubble_3d: None,
997 scatter_style: None,
998 show_markers: None,
999 stock_variant: None,
1000 }
1001 }
1002
1003 /// Creates an equation control with default dimensions for the given HancomEQN script.
1004 ///
1005 /// Defaults: width ≈ 31mm (8779 HWPUNIT), height ≈ 9.2mm (2600 HWPUNIT),
1006 /// baseline 71%, black text, `HancomEQN` font.
1007 ///
1008 /// # Examples
1009 ///
1010 /// ```
1011 /// use hwpforge_core::control::Control;
1012 ///
1013 /// let ctrl = Control::equation("{a+b} over {c+d}");
1014 /// assert!(ctrl.is_equation());
1015 /// ```
1016 pub fn equation(script: &str) -> Self {
1017 Self::Equation {
1018 script: script.to_string(),
1019 width: HwpUnit::new(8779).expect("8779 is valid"),
1020 height: HwpUnit::new(2600).expect("2600 is valid"),
1021 base_line: 71,
1022 text_color: Color::BLACK,
1023 font: "HancomEQN".to_string(),
1024 inst_id: None,
1025 }
1026 }
1027
1028 /// Creates a text box control with the given paragraphs and dimensions.
1029 ///
1030 /// Defaults: inline positioning (horz_offset=0, vert_offset=0), no caption, no style override.
1031 ///
1032 /// # Examples
1033 ///
1034 /// ```
1035 /// use hwpforge_core::control::Control;
1036 /// use hwpforge_core::paragraph::Paragraph;
1037 /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
1038 ///
1039 /// let para = Paragraph::new(ParaShapeIndex::new(0));
1040 /// let width = HwpUnit::from_mm(80.0).unwrap();
1041 /// let height = HwpUnit::from_mm(40.0).unwrap();
1042 /// let ctrl = Control::text_box(vec![para], width, height);
1043 /// assert!(ctrl.is_text_box());
1044 /// ```
1045 pub fn text_box(paragraphs: Vec<Paragraph>, width: HwpUnit, height: HwpUnit) -> Self {
1046 Self::TextBox {
1047 paragraphs,
1048 width,
1049 height,
1050 horz_offset: 0,
1051 vert_offset: 0,
1052 caption: None,
1053 style: None,
1054 text_vertical_align: VerticalAlign::Top,
1055 }
1056 }
1057
1058 /// Creates a footnote control with the given paragraph content.
1059 ///
1060 /// Defaults: no inst_id.
1061 ///
1062 /// # Examples
1063 ///
1064 /// ```
1065 /// use hwpforge_core::control::Control;
1066 /// use hwpforge_core::run::Run;
1067 /// use hwpforge_core::paragraph::Paragraph;
1068 /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
1069 ///
1070 /// let para = Paragraph::with_runs(
1071 /// vec![Run::text("Note text", CharShapeIndex::new(0))],
1072 /// ParaShapeIndex::new(0),
1073 /// );
1074 /// let ctrl = Control::footnote(vec![para]);
1075 /// assert!(ctrl.is_footnote());
1076 /// ```
1077 pub fn footnote(paragraphs: Vec<Paragraph>) -> Self {
1078 Self::Footnote { inst_id: None, paragraphs }
1079 }
1080
1081 /// Creates an endnote control with the given paragraph content.
1082 ///
1083 /// Defaults: no inst_id.
1084 ///
1085 /// # Examples
1086 ///
1087 /// ```
1088 /// use hwpforge_core::control::Control;
1089 /// use hwpforge_core::run::Run;
1090 /// use hwpforge_core::paragraph::Paragraph;
1091 /// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
1092 ///
1093 /// let para = Paragraph::with_runs(
1094 /// vec![Run::text("End note", CharShapeIndex::new(0))],
1095 /// ParaShapeIndex::new(0),
1096 /// );
1097 /// let ctrl = Control::endnote(vec![para]);
1098 /// assert!(ctrl.is_endnote());
1099 /// ```
1100 pub fn endnote(paragraphs: Vec<Paragraph>) -> Self {
1101 Self::Endnote { inst_id: None, paragraphs }
1102 }
1103
1104 /// Creates a footnote with an explicit instance ID for cross-referencing.
1105 ///
1106 /// Use this when you need stable `inst_id` references (e.g. matching decoder output).
1107 /// For simple footnotes without cross-references, prefer [`Control::footnote`].
1108 ///
1109 /// # Examples
1110 ///
1111 /// ```
1112 /// use hwpforge_core::control::Control;
1113 /// use hwpforge_core::paragraph::Paragraph;
1114 /// use hwpforge_foundation::ParaShapeIndex;
1115 ///
1116 /// let ctrl = Control::footnote_with_id(1, vec![Paragraph::new(ParaShapeIndex::new(0))]);
1117 /// assert!(ctrl.is_footnote());
1118 /// ```
1119 pub fn footnote_with_id(inst_id: u64, paragraphs: Vec<Paragraph>) -> Self {
1120 Self::Footnote { inst_id: Some(ObjectId::new(inst_id)), paragraphs }
1121 }
1122
1123 /// Creates an endnote with an explicit instance ID for cross-referencing.
1124 ///
1125 /// Use this when you need stable `inst_id` references (e.g. matching decoder output).
1126 /// For simple endnotes without cross-references, prefer [`Control::endnote`].
1127 ///
1128 /// # Examples
1129 ///
1130 /// ```
1131 /// use hwpforge_core::control::Control;
1132 /// use hwpforge_core::paragraph::Paragraph;
1133 /// use hwpforge_foundation::ParaShapeIndex;
1134 ///
1135 /// let ctrl = Control::endnote_with_id(2, vec![Paragraph::new(ParaShapeIndex::new(0))]);
1136 /// assert!(ctrl.is_endnote());
1137 /// ```
1138 pub fn endnote_with_id(inst_id: u64, paragraphs: Vec<Paragraph>) -> Self {
1139 Self::Endnote { inst_id: Some(ObjectId::new(inst_id)), paragraphs }
1140 }
1141
1142 /// Creates an ellipse control with the given bounding box dimensions.
1143 ///
1144 /// Geometry is auto-derived: center=(w/2, h/2), axis1=(w, h/2), axis2=(w/2, h).
1145 /// Defaults: inline positioning (horz_offset=0, vert_offset=0), no paragraphs, no caption, no style.
1146 ///
1147 /// # Examples
1148 ///
1149 /// ```
1150 /// use hwpforge_core::control::Control;
1151 /// use hwpforge_foundation::HwpUnit;
1152 ///
1153 /// let width = HwpUnit::from_mm(40.0).unwrap();
1154 /// let height = HwpUnit::from_mm(30.0).unwrap();
1155 /// let ctrl = Control::ellipse(width, height);
1156 /// assert!(ctrl.is_ellipse());
1157 /// ```
1158 pub fn ellipse(width: HwpUnit, height: HwpUnit) -> Self {
1159 let w = width.as_i32();
1160 let h = height.as_i32();
1161 Self::Ellipse {
1162 center: ShapePoint::new(w / 2, h / 2),
1163 axis1: ShapePoint::new(w, h / 2),
1164 axis2: ShapePoint::new(w / 2, h),
1165 width,
1166 height,
1167 horz_offset: 0,
1168 vert_offset: 0,
1169 paragraphs: vec![],
1170 caption: None,
1171 style: None,
1172 text_vertical_align: VerticalAlign::Top,
1173 }
1174 }
1175
1176 /// Creates an ellipse control with paragraph content inside.
1177 ///
1178 /// Same as [`Control::ellipse`] but accepts paragraphs for text drawn inside the ellipse.
1179 /// Geometry is auto-derived: center=(w/2, h/2), axis1=(w, h/2), axis2=(w/2, h).
1180 /// Defaults: inline positioning (horz_offset=0, vert_offset=0), no caption, no style.
1181 ///
1182 /// # Examples
1183 ///
1184 /// ```
1185 /// use hwpforge_core::control::Control;
1186 /// use hwpforge_core::paragraph::Paragraph;
1187 /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
1188 ///
1189 /// let width = HwpUnit::from_mm(40.0).unwrap();
1190 /// let height = HwpUnit::from_mm(30.0).unwrap();
1191 /// let para = Paragraph::new(ParaShapeIndex::new(0));
1192 /// let ctrl = Control::ellipse_with_text(width, height, vec![para]);
1193 /// assert!(ctrl.is_ellipse());
1194 /// ```
1195 pub fn ellipse_with_text(width: HwpUnit, height: HwpUnit, paragraphs: Vec<Paragraph>) -> Self {
1196 let w = width.as_i32();
1197 let h = height.as_i32();
1198 Self::Ellipse {
1199 center: ShapePoint::new(w / 2, h / 2),
1200 axis1: ShapePoint::new(w, h / 2),
1201 axis2: ShapePoint::new(w / 2, h),
1202 width,
1203 height,
1204 horz_offset: 0,
1205 vert_offset: 0,
1206 paragraphs,
1207 caption: None,
1208 style: None,
1209 text_vertical_align: VerticalAlign::Top,
1210 }
1211 }
1212
1213 /// Creates a pure rectangle control with the given bounding box dimensions.
1214 ///
1215 /// Pure rectangle means no embedded text content; for a textbox-style rect with
1216 /// inline paragraphs, use [`Control::text_box`].
1217 /// Defaults: inline positioning (horz_offset=0, vert_offset=0), no caption, no style.
1218 ///
1219 /// # Errors
1220 ///
1221 /// Returns [`CoreError::InvalidStructure`] if either dimension is zero.
1222 ///
1223 /// # Examples
1224 ///
1225 /// ```
1226 /// use hwpforge_core::control::Control;
1227 /// use hwpforge_foundation::HwpUnit;
1228 ///
1229 /// let width = HwpUnit::from_mm(40.0).unwrap();
1230 /// let height = HwpUnit::from_mm(20.0).unwrap();
1231 /// let ctrl = Control::rect(width, height).unwrap();
1232 /// assert!(ctrl.is_rect());
1233 /// ```
1234 pub fn rect(width: HwpUnit, height: HwpUnit) -> CoreResult<Self> {
1235 if width.as_i32() == 0 || height.as_i32() == 0 {
1236 return Err(CoreError::InvalidStructure {
1237 context: "Control::rect".to_string(),
1238 reason: format!(
1239 "rectangle requires non-zero dimensions, got {}x{}",
1240 width.as_i32(),
1241 height.as_i32()
1242 ),
1243 });
1244 }
1245 Ok(Self::Rect { width, height, horz_offset: 0, vert_offset: 0, caption: None, style: None })
1246 }
1247
1248 /// Creates a polygon control from the given vertices.
1249 ///
1250 /// The bounding box is auto-derived from the min/max of vertex coordinates.
1251 /// Defaults: no paragraphs, no caption, no style.
1252 ///
1253 /// Returns an error if fewer than 3 vertices are provided.
1254 ///
1255 /// # Errors
1256 ///
1257 /// Returns [`CoreError::InvalidStructure`] if `vertices.len() < 3`.
1258 ///
1259 /// # Examples
1260 ///
1261 /// ```
1262 /// use hwpforge_core::control::{Control, ShapePoint};
1263 ///
1264 /// let vertices = vec![
1265 /// ShapePoint::new(0, 1000),
1266 /// ShapePoint::new(500, 0),
1267 /// ShapePoint::new(1000, 1000),
1268 /// ];
1269 /// let ctrl = Control::polygon(vertices).unwrap();
1270 /// assert!(ctrl.is_polygon());
1271 /// ```
1272 pub fn polygon(vertices: Vec<ShapePoint>) -> CoreResult<Self> {
1273 if vertices.len() < 3 {
1274 return Err(CoreError::InvalidStructure {
1275 context: "Control::polygon".to_string(),
1276 reason: format!("polygon requires at least 3 vertices, got {}", vertices.len()),
1277 });
1278 }
1279 let min_x = vertices.iter().map(|p| p.x as i64).min().unwrap_or(0);
1280 let max_x = vertices.iter().map(|p| p.x as i64).max().unwrap_or(0);
1281 let min_y = vertices.iter().map(|p| p.y as i64).min().unwrap_or(0);
1282 let max_y = vertices.iter().map(|p| p.y as i64).max().unwrap_or(0);
1283 let bbox_w = i32::try_from((max_x - min_x).max(0)).unwrap_or(i32::MAX);
1284 let bbox_h = i32::try_from((max_y - min_y).max(0)).unwrap_or(i32::MAX);
1285 let width = HwpUnit::new(bbox_w).map_err(|_| CoreError::InvalidStructure {
1286 context: "Control::polygon".into(),
1287 reason: format!("bounding box width {bbox_w} exceeds HwpUnit range"),
1288 })?;
1289 let height = HwpUnit::new(bbox_h).map_err(|_| CoreError::InvalidStructure {
1290 context: "Control::polygon".into(),
1291 reason: format!("bounding box height {bbox_h} exceeds HwpUnit range"),
1292 })?;
1293 Ok(Self::Polygon {
1294 vertices,
1295 width,
1296 height,
1297 horz_offset: 0,
1298 vert_offset: 0,
1299 paragraphs: vec![],
1300 caption: None,
1301 style: None,
1302 text_vertical_align: VerticalAlign::Top,
1303 })
1304 }
1305
1306 /// Creates a line control between two endpoints.
1307 ///
1308 /// The bounding box width and height are derived from the absolute difference
1309 /// of the endpoint coordinates: `width = |end.x - start.x|`, `height = |end.y - start.y|`.
1310 /// Each axis is clamped to a minimum of 100 HwpUnit (~1pt) because 한글 cannot
1311 /// render lines with a zero-dimension bounding box.
1312 /// Defaults: no caption, no style.
1313 ///
1314 /// Returns an error if start and end are the same point (degenerate line).
1315 ///
1316 /// # Errors
1317 ///
1318 /// Returns [`CoreError::InvalidStructure`] if start equals end.
1319 ///
1320 /// # Examples
1321 ///
1322 /// ```
1323 /// use hwpforge_core::control::{Control, ShapePoint};
1324 ///
1325 /// let ctrl = Control::line(ShapePoint::new(0, 0), ShapePoint::new(5000, 0)).unwrap();
1326 /// assert!(ctrl.is_line());
1327 /// ```
1328 pub fn line(start: ShapePoint, end: ShapePoint) -> CoreResult<Self> {
1329 if start == end {
1330 return Err(CoreError::InvalidStructure {
1331 context: "Control::line".to_string(),
1332 reason: "start and end points are identical (degenerate line)".to_string(),
1333 });
1334 }
1335 // Normalize points to bounding-box-relative coordinates.
1336 // HWPX requires startPt/endPt within the shape's bounding box (0,0)→(w,h).
1337 let min_x = start.x.min(end.x);
1338 let min_y = start.y.min(end.y);
1339 let norm_start =
1340 ShapePoint::new(start.x.saturating_sub(min_x), start.y.saturating_sub(min_y));
1341 let norm_end = ShapePoint::new(end.x.saturating_sub(min_x), end.y.saturating_sub(min_y));
1342
1343 let raw_w =
1344 i32::try_from(((end.x as i64) - (start.x as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1345 let raw_h =
1346 i32::try_from(((end.y as i64) - (start.y as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1347 // Minimum bounding box of 100 HwpUnit (~1pt) per axis.
1348 // 한글 cannot render lines with a zero-dimension bounding box.
1349 let raw_w = raw_w.max(100);
1350 let raw_h = raw_h.max(100);
1351 let width = HwpUnit::new(raw_w).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1352 let height = HwpUnit::new(raw_h).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1353 Ok(Self::Line {
1354 start: norm_start,
1355 end: norm_end,
1356 width,
1357 height,
1358 horz_offset: 0,
1359 vert_offset: 0,
1360 caption: None,
1361 style: None,
1362 })
1363 }
1364
1365 /// Creates a horizontal line of the given width.
1366 ///
1367 /// Shortcut for `line(ShapePoint::new(0, 0), ShapePoint::new(width.as_i32(), 0))`.
1368 /// The bounding box height is clamped to 100 HwpUnit (~1pt minimum) because
1369 /// 한글 cannot render lines with a zero-dimension bounding box.
1370 /// Defaults: no caption, no style.
1371 ///
1372 /// # Examples
1373 ///
1374 /// ```
1375 /// use hwpforge_core::control::Control;
1376 /// use hwpforge_foundation::HwpUnit;
1377 ///
1378 /// let width = HwpUnit::from_mm(100.0).unwrap();
1379 /// let ctrl = Control::horizontal_line(width);
1380 /// assert!(ctrl.is_line());
1381 /// ```
1382 pub fn horizontal_line(width: HwpUnit) -> Self {
1383 let w = width.as_i32();
1384 Self::Line {
1385 start: ShapePoint::new(0, 0),
1386 end: ShapePoint::new(w, 0),
1387 width,
1388 height: HwpUnit::new(100).expect("100 is valid"),
1389 horz_offset: 0,
1390 vert_offset: 0,
1391 caption: None,
1392 style: None,
1393 }
1394 }
1395
1396 /// Creates a dutmal (annotation text) control with default positioning.
1397 ///
1398 /// Defaults: position = Top, sz_ratio = 0 (auto), align = Center.
1399 ///
1400 /// # Examples
1401 ///
1402 /// ```
1403 /// use hwpforge_core::control::Control;
1404 ///
1405 /// let ctrl = Control::dutmal("본문", "주석");
1406 /// assert!(ctrl.is_dutmal());
1407 /// ```
1408 pub fn dutmal(main_text: impl Into<String>, sub_text: impl Into<String>) -> Self {
1409 Self::Dutmal {
1410 main_text: main_text.into(),
1411 sub_text: sub_text.into(),
1412 position: DutmalPosition::Top,
1413 sz_ratio: 0,
1414 align: DutmalAlign::Center,
1415 metadata: DutmalMetadata::default(),
1416 }
1417 }
1418
1419 /// Creates a compose (글자겹침) control with default settings.
1420 ///
1421 /// Defaults: `circle_type = "SHAPE_REVERSAL_TIRANGLE"` (spec typo preserved),
1422 /// `char_sz = -3`, `compose_type = "SPREAD"`.
1423 ///
1424 /// # Examples
1425 ///
1426 /// ```
1427 /// use hwpforge_core::control::Control;
1428 ///
1429 /// let ctrl = Control::compose("12");
1430 /// assert!(ctrl.is_compose());
1431 /// ```
1432 pub fn compose(text: impl Into<String>) -> Self {
1433 Self::Compose {
1434 compose_text: text.into(),
1435 circle_type: "SHAPE_REVERSAL_TIRANGLE".to_string(), // official spec typo preserved
1436 char_sz: -3,
1437 compose_type: "SPREAD".to_string(),
1438 // 10 × no-override sentinel (HWPX `charPrCnt` is fixed at 10).
1439 char_pr_ids: vec![u32::MAX; 10],
1440 }
1441 }
1442
1443 /// Creates an arc control with the given bounding box dimensions.
1444 ///
1445 /// Geometry is auto-derived from the bounding box.
1446 /// Defaults: inline positioning, no caption, no style.
1447 ///
1448 /// # Examples
1449 ///
1450 /// ```
1451 /// use hwpforge_core::control::Control;
1452 /// use hwpforge_foundation::{ArcType, HwpUnit};
1453 ///
1454 /// let width = HwpUnit::from_mm(40.0).unwrap();
1455 /// let height = HwpUnit::from_mm(30.0).unwrap();
1456 /// let ctrl = Control::arc(ArcType::Pie, width, height);
1457 /// assert!(ctrl.is_arc());
1458 /// ```
1459 pub fn arc(arc_type: ArcType, width: HwpUnit, height: HwpUnit) -> Self {
1460 let w = width.as_i32();
1461 let h = height.as_i32();
1462 Self::Arc {
1463 arc_type,
1464 center: ShapePoint::new(w / 2, h / 2),
1465 axis1: ShapePoint::new(w, h / 2),
1466 axis2: ShapePoint::new(w / 2, h),
1467 start1: ShapePoint::new(w, h / 2),
1468 end1: ShapePoint::new(w / 2, 0),
1469 start2: ShapePoint::new(w, h / 2),
1470 end2: ShapePoint::new(w / 2, 0),
1471 width,
1472 height,
1473 horz_offset: 0,
1474 vert_offset: 0,
1475 caption: None,
1476 style: None,
1477 }
1478 }
1479
1480 /// Creates a curve control from the given control points.
1481 ///
1482 /// All segments default to [`CurveSegmentType::Curve`].
1483 /// The bounding box is auto-derived from min/max of point coordinates.
1484 ///
1485 /// Returns an error if fewer than 2 points are provided.
1486 ///
1487 /// # Errors
1488 ///
1489 /// Returns [`CoreError::InvalidStructure`] if `points.len() < 2`.
1490 ///
1491 /// # Examples
1492 ///
1493 /// ```
1494 /// use hwpforge_core::control::{Control, ShapePoint};
1495 ///
1496 /// let pts = vec![
1497 /// ShapePoint::new(0, 0),
1498 /// ShapePoint::new(2500, 5000),
1499 /// ShapePoint::new(5000, 0),
1500 /// ];
1501 /// let ctrl = Control::curve(pts).unwrap();
1502 /// assert!(ctrl.is_curve());
1503 /// ```
1504 pub fn curve(points: Vec<ShapePoint>) -> CoreResult<Self> {
1505 if points.len() < 2 {
1506 return Err(CoreError::InvalidStructure {
1507 context: "Control::curve".to_string(),
1508 reason: format!("curve requires at least 2 points, got {}", points.len()),
1509 });
1510 }
1511 let min_x = points.iter().map(|p| p.x as i64).min().unwrap_or(0);
1512 let max_x = points.iter().map(|p| p.x as i64).max().unwrap_or(0);
1513 let min_y = points.iter().map(|p| p.y as i64).min().unwrap_or(0);
1514 let max_y = points.iter().map(|p| p.y as i64).max().unwrap_or(0);
1515 let bbox_w = i32::try_from((max_x - min_x).max(1)).unwrap_or(i32::MAX);
1516 let bbox_h = i32::try_from((max_y - min_y).max(1)).unwrap_or(i32::MAX);
1517 let width = HwpUnit::new(bbox_w).map_err(|_| CoreError::InvalidStructure {
1518 context: "Control::curve".into(),
1519 reason: format!("bounding box width {bbox_w} exceeds HwpUnit range"),
1520 })?;
1521 let height = HwpUnit::new(bbox_h).map_err(|_| CoreError::InvalidStructure {
1522 context: "Control::curve".into(),
1523 reason: format!("bounding box height {bbox_h} exceeds HwpUnit range"),
1524 })?;
1525 let seg_count = points.len().saturating_sub(1);
1526 Ok(Self::Curve {
1527 points,
1528 segment_types: vec![CurveSegmentType::Curve; seg_count],
1529 width,
1530 height,
1531 horz_offset: 0,
1532 vert_offset: 0,
1533 caption: None,
1534 style: None,
1535 })
1536 }
1537
1538 /// Creates a connect line between two endpoints.
1539 ///
1540 /// Defaults: no control points, type "STRAIGHT", no caption, no style.
1541 ///
1542 /// Returns an error if start equals end.
1543 ///
1544 /// # Errors
1545 ///
1546 /// Returns [`CoreError::InvalidStructure`] if start equals end.
1547 ///
1548 /// # Examples
1549 ///
1550 /// ```
1551 /// use hwpforge_core::control::{Control, ShapePoint};
1552 ///
1553 /// let ctrl = Control::connect_line(
1554 /// ShapePoint::new(0, 0),
1555 /// ShapePoint::new(5000, 5000),
1556 /// ).unwrap();
1557 /// assert!(ctrl.is_connect_line());
1558 /// ```
1559 pub fn connect_line(start: ShapePoint, end: ShapePoint) -> CoreResult<Self> {
1560 if start == end {
1561 return Err(CoreError::InvalidStructure {
1562 context: "Control::connect_line".to_string(),
1563 reason: "start and end points are identical (degenerate line)".to_string(),
1564 });
1565 }
1566 // Normalize points to bounding-box-relative coordinates.
1567 // HWPX requires startPt/endPt within the shape's bounding box (0,0)→(w,h).
1568 let min_x = start.x.min(end.x);
1569 let min_y = start.y.min(end.y);
1570 let norm_start =
1571 ShapePoint::new(start.x.saturating_sub(min_x), start.y.saturating_sub(min_y));
1572 let norm_end = ShapePoint::new(end.x.saturating_sub(min_x), end.y.saturating_sub(min_y));
1573
1574 let raw_w =
1575 i32::try_from(((end.x as i64) - (start.x as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1576 let raw_h =
1577 i32::try_from(((end.y as i64) - (start.y as i64)).unsigned_abs()).unwrap_or(i32::MAX);
1578 let raw_w = raw_w.max(100);
1579 let raw_h = raw_h.max(100);
1580 let width = HwpUnit::new(raw_w).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1581 let height = HwpUnit::new(raw_h).unwrap_or_else(|_| HwpUnit::new(100).expect("valid"));
1582 Ok(Self::ConnectLine {
1583 start: norm_start,
1584 end: norm_end,
1585 control_points: Vec::new(),
1586 connect_type: "STRAIGHT".to_string(),
1587 width,
1588 height,
1589 horz_offset: 0,
1590 vert_offset: 0,
1591 caption: None,
1592 style: None,
1593 })
1594 }
1595
1596 /// Creates a hyperlink control with the given display text and URL.
1597 ///
1598 /// # Examples
1599 ///
1600 /// ```
1601 /// use hwpforge_core::control::Control;
1602 ///
1603 /// let ctrl = Control::hyperlink("Visit Rust", "https://rust-lang.org");
1604 /// assert!(ctrl.is_hyperlink());
1605 /// ```
1606 pub fn hyperlink(text: &str, url: &str) -> Self {
1607 Self::Hyperlink { text: text.to_string(), url: url.to_string() }
1608 }
1609}
1610
1611impl std::fmt::Display for Control {
1612 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1613 match self {
1614 Self::TextBox { paragraphs, .. } => {
1615 let n = paragraphs.len();
1616 let word = if n == 1 { "paragraph" } else { "paragraphs" };
1617 write!(f, "TextBox({n} {word})")
1618 }
1619 Self::Hyperlink { text, url } => {
1620 let preview: String =
1621 if text.len() > 30 { text.chars().take(30).collect() } else { text.clone() };
1622 write!(f, "Hyperlink(\"{preview}\" -> {url})")
1623 }
1624 Self::Footnote { paragraphs, .. } => {
1625 let n = paragraphs.len();
1626 let word = if n == 1 { "paragraph" } else { "paragraphs" };
1627 write!(f, "Footnote({n} {word})")
1628 }
1629 Self::Endnote { paragraphs, .. } => {
1630 let n = paragraphs.len();
1631 let word = if n == 1 { "paragraph" } else { "paragraphs" };
1632 write!(f, "Endnote({n} {word})")
1633 }
1634 Self::Line { .. } => {
1635 write!(f, "Line")
1636 }
1637 Self::Ellipse { paragraphs, .. } => {
1638 let n = paragraphs.len();
1639 let word = if n == 1 { "paragraph" } else { "paragraphs" };
1640 write!(f, "Ellipse({n} {word})")
1641 }
1642 Self::Rect { width, height, .. } => {
1643 write!(f, "Rect({}x{})", width.as_i32(), height.as_i32())
1644 }
1645 Self::Polygon { vertices, paragraphs, .. } => {
1646 let nv = vertices.len();
1647 let np = paragraphs.len();
1648 let vw = if nv == 1 { "vertex" } else { "vertices" };
1649 let pw = if np == 1 { "paragraph" } else { "paragraphs" };
1650 write!(f, "Polygon({nv} {vw}, {np} {pw})")
1651 }
1652 Self::Chart { chart_type, data, .. } => {
1653 let series_count = match data {
1654 ChartData::Category { series, .. } => series.len(),
1655 ChartData::Xy { series } => series.len(),
1656 };
1657 write!(f, "Chart({chart_type:?}, {series_count} series)")
1658 }
1659 Self::EmbeddedChart { chart_xml, ole_bytes, width, height, .. } => {
1660 write!(
1661 f,
1662 "EmbeddedChart(xml={} bytes, ole={} bytes, {}x{})",
1663 chart_xml.len(),
1664 ole_bytes.len(),
1665 width.as_i32(),
1666 height.as_i32()
1667 )
1668 }
1669 Self::Equation { script, .. } => {
1670 let preview: String = if script.len() > 30 {
1671 script.chars().take(30).collect()
1672 } else {
1673 script.clone()
1674 };
1675 write!(f, "Equation(\"{preview}\")")
1676 }
1677 Self::Dutmal { main_text, sub_text, .. } => {
1678 write!(f, "Dutmal(\"{main_text}\" / \"{sub_text}\")")
1679 }
1680 Self::Compose { compose_text, .. } => {
1681 write!(f, "Compose(\"{compose_text}\")")
1682 }
1683 Self::Arc { arc_type, .. } => {
1684 write!(f, "Arc({arc_type})")
1685 }
1686 Self::Curve { points, .. } => {
1687 write!(f, "Curve({} points)", points.len())
1688 }
1689 Self::ConnectLine { .. } => {
1690 write!(f, "ConnectLine")
1691 }
1692 Self::Group { children, .. } => {
1693 write!(f, "Group({} children)", children.len())
1694 }
1695 Self::TextArt { text, shape, .. } => {
1696 write!(f, "TextArt(\"{text}\", {shape})")
1697 }
1698 Self::Bookmark { name, bookmark_type } => {
1699 write!(f, "Bookmark(\"{name}\", {bookmark_type})")
1700 }
1701 Self::CrossRef { target, ref_type, .. } => {
1702 write!(f, "CrossRef({:?}, {ref_type})", target.as_display())
1703 }
1704 Self::Field { field_type, hint_text, name, .. } => {
1705 let hint = hint_text.as_deref().unwrap_or("");
1706 match name.as_deref().filter(|s| !s.is_empty()) {
1707 Some(n) => write!(f, "Field({field_type}, name=\"{n}\", \"{hint}\")"),
1708 None => write!(f, "Field({field_type}, \"{hint}\")"),
1709 }
1710 }
1711 Self::Memo { content, anchor_runs, .. } => {
1712 let n = content.len();
1713 let word = if n == 1 { "paragraph" } else { "paragraphs" };
1714 let anchor_len = anchor_runs.len();
1715 write!(f, "Memo({n} {word}, anchor={anchor_len} runs)")
1716 }
1717 Self::IndexMark { primary, secondary } => {
1718 if let Some(sec) = secondary {
1719 write!(f, "IndexMark(\"{primary}\" / \"{sec}\")")
1720 } else {
1721 write!(f, "IndexMark(\"{primary}\")")
1722 }
1723 }
1724 Self::UnknownSummary { token, .. } => {
1725 write!(f, "UnknownSummary({token})")
1726 }
1727 Self::DateCodeField { is_time_mode, .. } => {
1728 let mode = if *is_time_mode { "time" } else { "date" };
1729 write!(f, "DateCodeField({mode})")
1730 }
1731 Self::PathField { command, .. } => {
1732 write!(f, "PathField({})", command.wire_command())
1733 }
1734 Self::InlinePageNumber { kind } => match kind {
1735 InlinePageKind::CurrentPage => write!(f, "InlinePageNumber(current)"),
1736 InlinePageKind::TotalPages => write!(f, "InlinePageNumber(total)"),
1737 InlinePageKind::Unknown => write!(f, "InlinePageNumber(unknown)"),
1738 },
1739 Self::Unknown { tag, .. } => {
1740 write!(f, "Unknown({tag})")
1741 }
1742 }
1743 }
1744}
1745
1746#[cfg(test)]
1747mod tests {
1748 use super::*;
1749 use crate::run::Run;
1750 use hwpforge_foundation::{CharShapeIndex, Color, ParaShapeIndex, VerticalAlign};
1751
1752 fn simple_paragraph() -> Paragraph {
1753 Paragraph::with_runs(
1754 vec![Run::text("footnote text", CharShapeIndex::new(0))],
1755 ParaShapeIndex::new(0),
1756 )
1757 }
1758
1759 #[test]
1760 fn shape_style_default_all_none() {
1761 let s = ShapeStyle::default();
1762 assert!(s.line_color.is_none());
1763 assert!(s.fill_color.is_none());
1764 assert!(s.line_width.is_none());
1765 assert!(s.line_style.is_none());
1766 }
1767
1768 #[test]
1769 fn shape_style_with_typed_fields() {
1770 let s = ShapeStyle {
1771 line_color: Some(Color::from_rgb(255, 0, 0)),
1772 fill_color: Some(Color::from_rgb(0, 255, 0)),
1773 line_width: Some(100),
1774 line_style: Some(LineStyle::Dash),
1775 ..Default::default()
1776 };
1777 assert_eq!(s.line_color.unwrap(), Color::from_rgb(255, 0, 0));
1778 assert_eq!(s.fill_color.unwrap(), Color::from_rgb(0, 255, 0));
1779 assert_eq!(s.line_width.unwrap(), 100);
1780 assert_eq!(s.line_style.unwrap(), LineStyle::Dash);
1781 }
1782
1783 #[test]
1784 fn line_style_default() {
1785 assert_eq!(LineStyle::default(), LineStyle::Solid);
1786 }
1787
1788 #[test]
1789 fn line_style_display() {
1790 assert_eq!(LineStyle::Solid.to_string(), "SOLID");
1791 assert_eq!(LineStyle::Dash.to_string(), "DASH");
1792 assert_eq!(LineStyle::Dot.to_string(), "DOT");
1793 assert_eq!(LineStyle::DashDot.to_string(), "DASH_DOT");
1794 assert_eq!(LineStyle::DashDotDot.to_string(), "DASH_DOT_DOT");
1795 assert_eq!(LineStyle::None.to_string(), "NONE");
1796 }
1797
1798 #[test]
1799 fn line_style_from_str() {
1800 assert_eq!("SOLID".parse::<LineStyle>().unwrap(), LineStyle::Solid);
1801 assert_eq!("Dash".parse::<LineStyle>().unwrap(), LineStyle::Dash);
1802 assert_eq!("dot".parse::<LineStyle>().unwrap(), LineStyle::Dot);
1803 assert_eq!("DASH_DOT".parse::<LineStyle>().unwrap(), LineStyle::DashDot);
1804 assert_eq!("DashDotDot".parse::<LineStyle>().unwrap(), LineStyle::DashDotDot);
1805 assert_eq!("NONE".parse::<LineStyle>().unwrap(), LineStyle::None);
1806 assert!("INVALID".parse::<LineStyle>().is_err());
1807 }
1808
1809 #[test]
1810 fn line_style_serde_roundtrip() {
1811 for style in [
1812 LineStyle::Solid,
1813 LineStyle::Dash,
1814 LineStyle::Dot,
1815 LineStyle::DashDot,
1816 LineStyle::DashDotDot,
1817 LineStyle::None,
1818 ] {
1819 let json = serde_json::to_string(&style).unwrap();
1820 let back: LineStyle = serde_json::from_str(&json).unwrap();
1821 assert_eq!(style, back);
1822 }
1823 }
1824
1825 #[test]
1826 fn text_box_construction() {
1827 let ctrl = Control::TextBox {
1828 paragraphs: vec![simple_paragraph()],
1829 width: HwpUnit::from_mm(80.0).unwrap(),
1830 height: HwpUnit::from_mm(40.0).unwrap(),
1831 horz_offset: 0,
1832 vert_offset: 0,
1833 caption: None,
1834 style: None,
1835 text_vertical_align: VerticalAlign::Top,
1836 };
1837 assert!(ctrl.is_text_box());
1838 assert!(!ctrl.is_hyperlink());
1839 assert!(!ctrl.is_footnote());
1840 assert!(!ctrl.is_endnote());
1841 assert!(!ctrl.is_unknown());
1842 }
1843
1844 #[test]
1845 fn hyperlink_construction() {
1846 let ctrl = Control::Hyperlink {
1847 text: "Click".to_string(),
1848 url: "https://example.com".to_string(),
1849 };
1850 assert!(ctrl.is_hyperlink());
1851 assert!(!ctrl.is_text_box());
1852 }
1853
1854 #[test]
1855 fn footnote_construction() {
1856 let ctrl = Control::Footnote { inst_id: None, paragraphs: vec![simple_paragraph()] };
1857 assert!(ctrl.is_footnote());
1858 assert!(!ctrl.is_text_box());
1859 assert!(!ctrl.is_endnote());
1860 }
1861
1862 #[test]
1863 fn endnote_construction() {
1864 let ctrl = Control::Endnote {
1865 inst_id: Some(ObjectId::new(123456)),
1866 paragraphs: vec![simple_paragraph()],
1867 };
1868 assert!(ctrl.is_endnote());
1869 assert!(!ctrl.is_footnote());
1870 assert!(!ctrl.is_text_box());
1871 }
1872
1873 #[test]
1874 fn unknown_construction() {
1875 let ctrl = Control::Unknown {
1876 tag: "custom:widget".to_string(),
1877 data: Some("<data>value</data>".to_string()),
1878 };
1879 assert!(ctrl.is_unknown());
1880 }
1881
1882 #[test]
1883 fn unknown_without_data() {
1884 let ctrl = Control::Unknown { tag: "header".to_string(), data: None };
1885 assert!(ctrl.is_unknown());
1886 }
1887
1888 #[test]
1889 fn display_text_box() {
1890 let ctrl = Control::TextBox {
1891 paragraphs: vec![simple_paragraph(), simple_paragraph()],
1892 width: HwpUnit::from_mm(80.0).unwrap(),
1893 height: HwpUnit::from_mm(40.0).unwrap(),
1894 horz_offset: 0,
1895 vert_offset: 0,
1896 caption: None,
1897 style: None,
1898 text_vertical_align: VerticalAlign::Top,
1899 };
1900 assert_eq!(ctrl.to_string(), "TextBox(2 paragraphs)");
1901 }
1902
1903 #[test]
1904 fn display_hyperlink() {
1905 let ctrl =
1906 Control::Hyperlink { text: "Short".to_string(), url: "https://x.com".to_string() };
1907 let s = ctrl.to_string();
1908 assert!(s.contains("Short"), "display: {s}");
1909 assert!(s.contains("https://x.com"), "display: {s}");
1910 }
1911
1912 #[test]
1913 fn display_hyperlink_long_text_truncated() {
1914 let ctrl =
1915 Control::Hyperlink { text: "A".repeat(100), url: "https://example.com".to_string() };
1916 let s = ctrl.to_string();
1917 // Should show first 30 chars
1918 assert!(s.contains(&"A".repeat(30)), "display: {s}");
1919 assert!(!s.contains(&"A".repeat(31)), "display: {s}");
1920 }
1921
1922 #[test]
1923 fn display_footnote() {
1924 let ctrl = Control::Footnote { inst_id: None, paragraphs: vec![simple_paragraph()] };
1925 assert_eq!(ctrl.to_string(), "Footnote(1 paragraph)");
1926 }
1927
1928 #[test]
1929 fn display_endnote() {
1930 let ctrl = Control::Endnote {
1931 inst_id: Some(ObjectId::new(999)),
1932 paragraphs: vec![simple_paragraph()],
1933 };
1934 assert_eq!(ctrl.to_string(), "Endnote(1 paragraph)");
1935 }
1936
1937 #[test]
1938 fn display_unknown() {
1939 let ctrl = Control::Unknown { tag: "bookmark".to_string(), data: None };
1940 assert_eq!(ctrl.to_string(), "Unknown(bookmark)");
1941 }
1942
1943 #[test]
1944 fn equality() {
1945 let a = Control::Hyperlink { text: "A".to_string(), url: "B".to_string() };
1946 let b = Control::Hyperlink { text: "A".to_string(), url: "B".to_string() };
1947 let c = Control::Hyperlink { text: "A".to_string(), url: "C".to_string() };
1948 assert_eq!(a, b);
1949 assert_ne!(a, c);
1950 }
1951
1952 #[test]
1953 fn serde_roundtrip_text_box() {
1954 let ctrl = Control::TextBox {
1955 paragraphs: vec![simple_paragraph()],
1956 width: HwpUnit::from_mm(80.0).unwrap(),
1957 height: HwpUnit::from_mm(40.0).unwrap(),
1958 horz_offset: 0,
1959 vert_offset: 0,
1960 caption: None,
1961 style: None,
1962 text_vertical_align: VerticalAlign::Top,
1963 };
1964 let json = serde_json::to_string(&ctrl).unwrap();
1965 let back: Control = serde_json::from_str(&json).unwrap();
1966 assert_eq!(ctrl, back);
1967 }
1968
1969 #[test]
1970 fn serde_roundtrip_hyperlink() {
1971 let ctrl = Control::Hyperlink {
1972 text: "link text".to_string(),
1973 url: "https://rust-lang.org".to_string(),
1974 };
1975 let json = serde_json::to_string(&ctrl).unwrap();
1976 let back: Control = serde_json::from_str(&json).unwrap();
1977 assert_eq!(ctrl, back);
1978 }
1979
1980 #[test]
1981 fn serde_roundtrip_footnote() {
1982 let ctrl = Control::Footnote {
1983 inst_id: Some(ObjectId::new(12345)),
1984 paragraphs: vec![simple_paragraph()],
1985 };
1986 let json = serde_json::to_string(&ctrl).unwrap();
1987 let back: Control = serde_json::from_str(&json).unwrap();
1988 assert_eq!(ctrl, back);
1989 }
1990
1991 #[test]
1992 fn serde_roundtrip_endnote() {
1993 let ctrl = Control::Endnote { inst_id: None, paragraphs: vec![simple_paragraph()] };
1994 let json = serde_json::to_string(&ctrl).unwrap();
1995 let back: Control = serde_json::from_str(&json).unwrap();
1996 assert_eq!(ctrl, back);
1997 }
1998
1999 #[test]
2000 fn serde_roundtrip_unknown() {
2001 let ctrl = Control::Unknown { tag: "test".to_string(), data: Some("payload".to_string()) };
2002 let json = serde_json::to_string(&ctrl).unwrap();
2003 let back: Control = serde_json::from_str(&json).unwrap();
2004 assert_eq!(ctrl, back);
2005 }
2006
2007 // ── Shape variant tests ──────────────────────────────────────
2008
2009 #[test]
2010 fn line_construction() {
2011 let ctrl = Control::Line {
2012 start: ShapePoint { x: 0, y: 0 },
2013 end: ShapePoint { x: 1000, y: 500 },
2014 width: HwpUnit::from_mm(50.0).unwrap(),
2015 height: HwpUnit::from_mm(25.0).unwrap(),
2016 horz_offset: 0,
2017 vert_offset: 0,
2018 caption: None,
2019 style: None,
2020 };
2021 assert!(ctrl.is_line());
2022 assert!(!ctrl.is_text_box());
2023 assert!(!ctrl.is_ellipse());
2024 assert!(!ctrl.is_polygon());
2025 }
2026
2027 #[test]
2028 fn ellipse_construction() {
2029 let ctrl = Control::Ellipse {
2030 center: ShapePoint { x: 500, y: 500 },
2031 axis1: ShapePoint { x: 1000, y: 500 },
2032 axis2: ShapePoint { x: 500, y: 1000 },
2033 width: HwpUnit::from_mm(40.0).unwrap(),
2034 height: HwpUnit::from_mm(30.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_ellipse());
2043 assert!(!ctrl.is_line());
2044 assert!(!ctrl.is_polygon());
2045 }
2046
2047 #[test]
2048 fn ellipse_with_paragraphs() {
2049 let ctrl = Control::Ellipse {
2050 center: ShapePoint { x: 500, y: 500 },
2051 axis1: ShapePoint { x: 1000, y: 500 },
2052 axis2: ShapePoint { x: 500, y: 1000 },
2053 width: HwpUnit::from_mm(40.0).unwrap(),
2054 height: HwpUnit::from_mm(30.0).unwrap(),
2055 horz_offset: 0,
2056 vert_offset: 0,
2057 paragraphs: vec![simple_paragraph()],
2058 caption: None,
2059 style: None,
2060 text_vertical_align: VerticalAlign::Top,
2061 };
2062 assert!(ctrl.is_ellipse());
2063 assert_eq!(ctrl.to_string(), "Ellipse(1 paragraph)");
2064 }
2065
2066 #[test]
2067 fn polygon_construction() {
2068 let ctrl = Control::Polygon {
2069 vertices: vec![
2070 ShapePoint { x: 0, y: 0 },
2071 ShapePoint { x: 1000, y: 0 },
2072 ShapePoint { x: 500, y: 1000 },
2073 ],
2074 width: HwpUnit::from_mm(50.0).unwrap(),
2075 height: HwpUnit::from_mm(50.0).unwrap(),
2076 horz_offset: 0,
2077 vert_offset: 0,
2078 paragraphs: vec![],
2079 caption: None,
2080 style: None,
2081 text_vertical_align: VerticalAlign::Top,
2082 };
2083 assert!(ctrl.is_polygon());
2084 assert!(!ctrl.is_line());
2085 assert!(!ctrl.is_ellipse());
2086 assert_eq!(ctrl.to_string(), "Polygon(3 vertices, 0 paragraphs)");
2087 }
2088
2089 #[test]
2090 fn display_line() {
2091 let ctrl = Control::Line {
2092 start: ShapePoint { x: 0, y: 0 },
2093 end: ShapePoint { x: 100, y: 200 },
2094 width: HwpUnit::from_mm(10.0).unwrap(),
2095 height: HwpUnit::from_mm(5.0).unwrap(),
2096 horz_offset: 0,
2097 vert_offset: 0,
2098 caption: None,
2099 style: None,
2100 };
2101 assert_eq!(ctrl.to_string(), "Line");
2102 }
2103
2104 #[test]
2105 fn serde_roundtrip_line() {
2106 let ctrl = Control::Line {
2107 start: ShapePoint { x: 100, y: 200 },
2108 end: ShapePoint { x: 300, y: 400 },
2109 width: HwpUnit::from_mm(20.0).unwrap(),
2110 height: HwpUnit::from_mm(10.0).unwrap(),
2111 horz_offset: 0,
2112 vert_offset: 0,
2113 caption: None,
2114 style: None,
2115 };
2116 let json = serde_json::to_string(&ctrl).unwrap();
2117 let back: Control = serde_json::from_str(&json).unwrap();
2118 assert_eq!(ctrl, back);
2119 }
2120
2121 #[test]
2122 fn serde_roundtrip_ellipse() {
2123 let ctrl = Control::Ellipse {
2124 center: ShapePoint { x: 500, y: 500 },
2125 axis1: ShapePoint { x: 1000, y: 500 },
2126 axis2: ShapePoint { x: 500, y: 1000 },
2127 width: HwpUnit::from_mm(40.0).unwrap(),
2128 height: HwpUnit::from_mm(30.0).unwrap(),
2129 horz_offset: 0,
2130 vert_offset: 0,
2131 paragraphs: vec![simple_paragraph()],
2132 caption: None,
2133 style: None,
2134 text_vertical_align: VerticalAlign::Top,
2135 };
2136 let json = serde_json::to_string(&ctrl).unwrap();
2137 let back: Control = serde_json::from_str(&json).unwrap();
2138 assert_eq!(ctrl, back);
2139 }
2140
2141 #[test]
2142 fn serde_roundtrip_polygon() {
2143 let ctrl = Control::Polygon {
2144 vertices: vec![
2145 ShapePoint { x: 0, y: 0 },
2146 ShapePoint { x: 1000, y: 0 },
2147 ShapePoint { x: 500, y: 1000 },
2148 ],
2149 width: HwpUnit::from_mm(50.0).unwrap(),
2150 height: HwpUnit::from_mm(50.0).unwrap(),
2151 horz_offset: 0,
2152 vert_offset: 0,
2153 paragraphs: vec![],
2154 caption: None,
2155 style: None,
2156 text_vertical_align: VerticalAlign::Top,
2157 };
2158 let json = serde_json::to_string(&ctrl).unwrap();
2159 let back: Control = serde_json::from_str(&json).unwrap();
2160 assert_eq!(ctrl, back);
2161 }
2162
2163 #[test]
2164 fn shape_point_equality() {
2165 let a = ShapePoint { x: 10, y: 20 };
2166 let b = ShapePoint { x: 10, y: 20 };
2167 let c = ShapePoint { x: 10, y: 30 };
2168 assert_eq!(a, b);
2169 assert_ne!(a, c);
2170 }
2171
2172 #[test]
2173 fn shape_point_new() {
2174 let pt = ShapePoint::new(100, 200);
2175 assert_eq!(pt.x, 100);
2176 assert_eq!(pt.y, 200);
2177 }
2178
2179 #[test]
2180 fn shape_point_serde_roundtrip() {
2181 let pt = ShapePoint::new(500, 750);
2182 let json = serde_json::to_string(&pt).unwrap();
2183 let back: ShapePoint = serde_json::from_str(&json).unwrap();
2184 assert_eq!(pt, back);
2185 }
2186
2187 // ── Convenience constructor tests ────────────────────────────────────
2188
2189 #[test]
2190 fn equation_constructor_defaults() {
2191 let ctrl = Control::equation("{a+b} over {c+d}");
2192 assert!(ctrl.is_equation());
2193 match ctrl {
2194 Control::Equation {
2195 script,
2196 width,
2197 height,
2198 base_line,
2199 text_color,
2200 ref font,
2201 inst_id: _,
2202 } => {
2203 assert_eq!(script, "{a+b} over {c+d}");
2204 assert_eq!(width, HwpUnit::new(8779).unwrap());
2205 assert_eq!(height, HwpUnit::new(2600).unwrap());
2206 assert_eq!(base_line, 71);
2207 assert_eq!(text_color, Color::BLACK);
2208 assert_eq!(font, "HancomEQN");
2209 }
2210 _ => panic!("expected Equation"),
2211 }
2212 }
2213
2214 #[test]
2215 fn equation_constructor_empty_script() {
2216 let ctrl = Control::equation("");
2217 assert!(ctrl.is_equation());
2218 }
2219
2220 #[test]
2221 fn text_box_constructor_defaults() {
2222 let width = HwpUnit::from_mm(80.0).unwrap();
2223 let height = HwpUnit::from_mm(40.0).unwrap();
2224 let ctrl = Control::text_box(vec![simple_paragraph()], width, height);
2225 assert!(ctrl.is_text_box());
2226 match ctrl {
2227 Control::TextBox { paragraphs, horz_offset, vert_offset, caption, style, .. } => {
2228 assert_eq!(paragraphs.len(), 1);
2229 assert_eq!(horz_offset, 0);
2230 assert_eq!(vert_offset, 0);
2231 assert!(caption.is_none());
2232 assert!(style.is_none());
2233 }
2234 _ => panic!("expected TextBox"),
2235 }
2236 }
2237
2238 #[test]
2239 fn footnote_constructor_defaults() {
2240 let ctrl = Control::footnote(vec![simple_paragraph()]);
2241 assert!(ctrl.is_footnote());
2242 match ctrl {
2243 Control::Footnote { inst_id, paragraphs } => {
2244 assert!(inst_id.is_none());
2245 assert_eq!(paragraphs.len(), 1);
2246 }
2247 _ => panic!("expected Footnote"),
2248 }
2249 }
2250
2251 #[test]
2252 fn endnote_constructor_defaults() {
2253 let ctrl = Control::endnote(vec![simple_paragraph()]);
2254 assert!(ctrl.is_endnote());
2255 match ctrl {
2256 Control::Endnote { inst_id, paragraphs } => {
2257 assert!(inst_id.is_none());
2258 assert_eq!(paragraphs.len(), 1);
2259 }
2260 _ => panic!("expected Endnote"),
2261 }
2262 }
2263
2264 #[test]
2265 fn ellipse_constructor_geometry() {
2266 let width = HwpUnit::from_mm(40.0).unwrap();
2267 let height = HwpUnit::from_mm(30.0).unwrap();
2268 let ctrl = Control::ellipse(width, height);
2269 assert!(ctrl.is_ellipse());
2270 match &ctrl {
2271 Control::Ellipse {
2272 center,
2273 axis1,
2274 axis2,
2275 horz_offset,
2276 vert_offset,
2277 paragraphs,
2278 caption,
2279 style,
2280 ..
2281 } => {
2282 let w = width.as_i32();
2283 let h = height.as_i32();
2284 assert_eq!(*center, ShapePoint::new(w / 2, h / 2));
2285 assert_eq!(*axis1, ShapePoint::new(w, h / 2));
2286 assert_eq!(*axis2, ShapePoint::new(w / 2, h));
2287 assert_eq!(*horz_offset, 0);
2288 assert_eq!(*vert_offset, 0);
2289 assert!(paragraphs.is_empty());
2290 assert!(caption.is_none());
2291 assert!(style.is_none());
2292 }
2293 _ => panic!("expected Ellipse"),
2294 }
2295 }
2296
2297 #[test]
2298 fn rect_constructor_basic_geometry() {
2299 let width = HwpUnit::from_mm(40.0).unwrap();
2300 let height = HwpUnit::from_mm(20.0).unwrap();
2301 let ctrl = Control::rect(width, height).unwrap();
2302 assert!(ctrl.is_rect());
2303 match ctrl {
2304 Control::Rect { width: w, height: h, horz_offset, vert_offset, caption, style } => {
2305 assert_eq!(w, width);
2306 assert_eq!(h, height);
2307 assert_eq!(horz_offset, 0);
2308 assert_eq!(vert_offset, 0);
2309 assert!(caption.is_none());
2310 assert!(style.is_none());
2311 }
2312 _ => panic!("expected Rect"),
2313 }
2314 }
2315
2316 #[test]
2317 fn rect_constructor_zero_dimension_errors() {
2318 let zero = HwpUnit::new(0).unwrap();
2319 let nonzero = HwpUnit::from_mm(10.0).unwrap();
2320 assert!(Control::rect(zero, nonzero).is_err());
2321 assert!(Control::rect(nonzero, zero).is_err());
2322 }
2323
2324 #[test]
2325 fn polygon_constructor_triangle() {
2326 let vertices =
2327 vec![ShapePoint::new(0, 1000), ShapePoint::new(500, 0), ShapePoint::new(1000, 1000)];
2328 let ctrl = Control::polygon(vertices).unwrap();
2329 assert!(ctrl.is_polygon());
2330 match &ctrl {
2331 Control::Polygon {
2332 vertices,
2333 width,
2334 height,
2335 horz_offset,
2336 vert_offset,
2337 paragraphs,
2338 caption,
2339 style,
2340 ..
2341 } => {
2342 assert_eq!(vertices.len(), 3);
2343 // bbox: x 0..1000, y 0..1000
2344 assert_eq!(*width, HwpUnit::new(1000).unwrap());
2345 assert_eq!(*height, HwpUnit::new(1000).unwrap());
2346 assert_eq!(*horz_offset, 0);
2347 assert_eq!(*vert_offset, 0);
2348 assert!(paragraphs.is_empty());
2349 assert!(caption.is_none());
2350 assert!(style.is_none());
2351 }
2352 _ => panic!("expected Polygon"),
2353 }
2354 }
2355
2356 #[test]
2357 fn polygon_constructor_fewer_than_3_vertices_errors() {
2358 assert!(Control::polygon(vec![]).is_err());
2359 assert!(Control::polygon(vec![ShapePoint::new(0, 0)]).is_err());
2360 assert!(Control::polygon(vec![ShapePoint::new(0, 0), ShapePoint::new(1, 1)]).is_err());
2361 }
2362
2363 #[test]
2364 fn polygon_constructor_negative_coordinates() {
2365 let vertices =
2366 vec![ShapePoint::new(-500, -500), ShapePoint::new(500, -500), ShapePoint::new(0, 500)];
2367 let ctrl = Control::polygon(vertices).unwrap();
2368 assert!(ctrl.is_polygon());
2369 match ctrl {
2370 Control::Polygon { width, height, .. } => {
2371 // bbox: x -500..500 = 1000, y -500..500 = 1000
2372 assert_eq!(width, HwpUnit::new(1000).unwrap());
2373 assert_eq!(height, HwpUnit::new(1000).unwrap());
2374 }
2375 _ => panic!("expected Polygon"),
2376 }
2377 }
2378
2379 #[test]
2380 fn polygon_constructor_degenerate_collinear() {
2381 // 3 collinear points: height = 0 (flat), should succeed
2382 let vertices =
2383 vec![ShapePoint::new(0, 0), ShapePoint::new(500, 0), ShapePoint::new(1000, 0)];
2384 let ctrl = Control::polygon(vertices).unwrap();
2385 assert!(ctrl.is_polygon());
2386 match ctrl {
2387 Control::Polygon { width, height, .. } => {
2388 assert_eq!(width, HwpUnit::new(1000).unwrap());
2389 assert_eq!(height, HwpUnit::new(0).unwrap());
2390 }
2391 _ => panic!("expected Polygon"),
2392 }
2393 }
2394
2395 #[test]
2396 fn line_constructor_horizontal() {
2397 let ctrl = Control::line(ShapePoint::new(0, 0), ShapePoint::new(5000, 0)).unwrap();
2398 assert!(ctrl.is_line());
2399 match ctrl {
2400 Control::Line {
2401 start,
2402 end,
2403 width,
2404 height,
2405 horz_offset,
2406 vert_offset,
2407 caption,
2408 style,
2409 } => {
2410 assert_eq!(start, ShapePoint::new(0, 0));
2411 assert_eq!(end, ShapePoint::new(5000, 0));
2412 assert_eq!(width, HwpUnit::new(5000).unwrap());
2413 assert_eq!(height, HwpUnit::new(100).unwrap()); // min bounding box
2414 assert_eq!(horz_offset, 0);
2415 assert_eq!(vert_offset, 0);
2416 assert!(caption.is_none());
2417 assert!(style.is_none());
2418 }
2419 _ => panic!("expected Line"),
2420 }
2421 }
2422
2423 #[test]
2424 fn line_constructor_vertical() {
2425 let ctrl = Control::line(ShapePoint::new(0, 0), ShapePoint::new(0, 3000)).unwrap();
2426 assert!(ctrl.is_line());
2427 match ctrl {
2428 Control::Line { width, height, .. } => {
2429 assert_eq!(width, HwpUnit::new(100).unwrap()); // min bounding box
2430 assert_eq!(height, HwpUnit::new(3000).unwrap());
2431 }
2432 _ => panic!("expected Line"),
2433 }
2434 }
2435
2436 #[test]
2437 fn line_constructor_diagonal_bounding_box() {
2438 let ctrl = Control::line(ShapePoint::new(100, 200), ShapePoint::new(400, 500)).unwrap();
2439 match ctrl {
2440 Control::Line { width, height, .. } => {
2441 assert_eq!(width, HwpUnit::new(300).unwrap());
2442 assert_eq!(height, HwpUnit::new(300).unwrap());
2443 }
2444 _ => panic!("expected Line"),
2445 }
2446 }
2447
2448 #[test]
2449 fn line_constructor_same_point_errors() {
2450 let pt = ShapePoint::new(100, 200);
2451 assert!(Control::line(pt, pt).is_err());
2452 }
2453
2454 #[test]
2455 fn horizontal_line_constructor() {
2456 let width = HwpUnit::from_mm(100.0).unwrap();
2457 let ctrl = Control::horizontal_line(width);
2458 assert!(ctrl.is_line());
2459 match ctrl {
2460 Control::Line {
2461 start,
2462 end,
2463 width: w,
2464 height,
2465 horz_offset,
2466 vert_offset,
2467 caption,
2468 style,
2469 } => {
2470 assert_eq!(start, ShapePoint::new(0, 0));
2471 assert_eq!(end.y, 0);
2472 assert_eq!(end.x, width.as_i32());
2473 assert_eq!(w, width);
2474 assert_eq!(height, HwpUnit::new(100).unwrap()); // min bounding box
2475 assert_eq!(horz_offset, 0);
2476 assert_eq!(vert_offset, 0);
2477 assert!(caption.is_none());
2478 assert!(style.is_none());
2479 }
2480 _ => panic!("expected Line"),
2481 }
2482 }
2483
2484 #[test]
2485 fn hyperlink_constructor() {
2486 let ctrl = Control::hyperlink("Visit Rust", "https://rust-lang.org");
2487 assert!(ctrl.is_hyperlink());
2488 match ctrl {
2489 Control::Hyperlink { text, url } => {
2490 assert_eq!(text, "Visit Rust");
2491 assert_eq!(url, "https://rust-lang.org");
2492 }
2493 _ => panic!("expected Hyperlink"),
2494 }
2495 }
2496
2497 #[test]
2498 fn footnote_with_id_sets_inst_id() {
2499 let para = Paragraph::new(ParaShapeIndex::new(0));
2500 let ctrl = Control::footnote_with_id(42, vec![para]);
2501 assert!(ctrl.is_footnote());
2502 match ctrl {
2503 Control::Footnote { inst_id, paragraphs } => {
2504 assert_eq!(inst_id, Some(ObjectId::new(42)));
2505 assert_eq!(paragraphs.len(), 1);
2506 }
2507 _ => panic!("expected Footnote"),
2508 }
2509 }
2510
2511 #[test]
2512 fn endnote_with_id_sets_inst_id() {
2513 let para = Paragraph::new(ParaShapeIndex::new(0));
2514 let ctrl = Control::endnote_with_id(7, vec![para]);
2515 assert!(ctrl.is_endnote());
2516 match ctrl {
2517 Control::Endnote { inst_id, paragraphs } => {
2518 assert_eq!(inst_id, Some(ObjectId::new(7)));
2519 assert_eq!(paragraphs.len(), 1);
2520 }
2521 _ => panic!("expected Endnote"),
2522 }
2523 }
2524
2525 #[test]
2526 fn footnote_with_id_differs_from_plain_footnote() {
2527 let ctrl_plain = Control::footnote(vec![]);
2528 let ctrl_id = Control::footnote_with_id(1, vec![]);
2529 match ctrl_plain {
2530 Control::Footnote { inst_id, .. } => assert_eq!(inst_id, None),
2531 _ => panic!("expected Footnote"),
2532 }
2533 match ctrl_id {
2534 Control::Footnote { inst_id, .. } => assert_eq!(inst_id, Some(ObjectId::new(1))),
2535 _ => panic!("expected Footnote"),
2536 }
2537 }
2538
2539 #[test]
2540 fn ellipse_with_text_has_correct_geometry_and_paragraphs() {
2541 use hwpforge_foundation::HwpUnit;
2542 let width = HwpUnit::from_mm(40.0).unwrap();
2543 let height = HwpUnit::from_mm(30.0).unwrap();
2544 let para = Paragraph::new(ParaShapeIndex::new(0));
2545 let ctrl = Control::ellipse_with_text(width, height, vec![para]);
2546 assert!(ctrl.is_ellipse());
2547 match ctrl {
2548 Control::Ellipse {
2549 center,
2550 axis1,
2551 axis2,
2552 width: w,
2553 height: h,
2554 horz_offset,
2555 vert_offset,
2556 paragraphs,
2557 caption,
2558 style,
2559 ..
2560 } => {
2561 let wv = w.as_i32();
2562 let hv = h.as_i32();
2563 assert_eq!(center, ShapePoint::new(wv / 2, hv / 2));
2564 assert_eq!(axis1, ShapePoint::new(wv, hv / 2));
2565 assert_eq!(axis2, ShapePoint::new(wv / 2, hv));
2566 assert_eq!(horz_offset, 0);
2567 assert_eq!(vert_offset, 0);
2568 assert_eq!(paragraphs.len(), 1);
2569 assert!(caption.is_none());
2570 assert!(style.is_none());
2571 }
2572 _ => panic!("expected Ellipse"),
2573 }
2574 }
2575
2576 #[test]
2577 fn serde_roundtrip_chart() {
2578 use crate::chart::{ChartData, ChartGrouping, ChartType, LegendPosition};
2579 let ctrl = Control::Chart {
2580 chart_type: ChartType::Column,
2581 data: ChartData::category(&["A", "B"], &[("S1", &[1.0, 2.0])]),
2582 title: Some("Test Chart".to_string()),
2583 legend: LegendPosition::Bottom,
2584 grouping: ChartGrouping::Stacked,
2585 width: HwpUnit::from_mm(100.0).unwrap(),
2586 height: HwpUnit::from_mm(80.0).unwrap(),
2587 stock_variant: None,
2588 bar_shape: None,
2589 scatter_style: None,
2590 radar_style: None,
2591 of_pie_type: None,
2592 explosion: None,
2593 wireframe: None,
2594 bubble_3d: None,
2595 show_markers: None,
2596 };
2597 let json = serde_json::to_string(&ctrl).unwrap();
2598 let back: Control = serde_json::from_str(&json).unwrap();
2599 assert_eq!(ctrl, back);
2600 }
2601
2602 #[test]
2603 fn serde_roundtrip_equation() {
2604 let ctrl = Control::Equation {
2605 script: "{a+b} over {c+d}".to_string(),
2606 width: HwpUnit::new(8779).unwrap(),
2607 height: HwpUnit::new(2600).unwrap(),
2608 base_line: 71,
2609 text_color: Color::BLACK,
2610 font: "HancomEQN".to_string(),
2611 inst_id: None,
2612 };
2613 let json = serde_json::to_string(&ctrl).unwrap();
2614 let back: Control = serde_json::from_str(&json).unwrap();
2615 assert_eq!(ctrl, back);
2616 }
2617
2618 #[test]
2619 fn ellipse_with_text_empty_paragraphs_matches_ellipse() {
2620 use hwpforge_foundation::HwpUnit;
2621 let width = HwpUnit::from_mm(20.0).unwrap();
2622 let height = HwpUnit::from_mm(10.0).unwrap();
2623 let plain = Control::ellipse(width, height);
2624 let with_text = Control::ellipse_with_text(width, height, vec![]);
2625 // Both should produce identical shapes when paragraphs are empty
2626 assert_eq!(plain, with_text);
2627 }
2628
2629 // ── Dutmal (덧말) tests ──────────────────────────────────────
2630
2631 #[test]
2632 fn dutmal_constructor_defaults() {
2633 let ctrl = Control::dutmal("본문", "주석");
2634 assert!(ctrl.is_dutmal());
2635 match ctrl {
2636 Control::Dutmal { main_text, sub_text, position, sz_ratio, align, .. } => {
2637 assert_eq!(main_text, "본문");
2638 assert_eq!(sub_text, "주석");
2639 assert_eq!(position, DutmalPosition::Top);
2640 assert_eq!(sz_ratio, 0);
2641 assert_eq!(align, DutmalAlign::Center);
2642 }
2643 _ => panic!("expected Dutmal"),
2644 }
2645 }
2646
2647 #[test]
2648 fn dutmal_is_dutmal_true() {
2649 assert!(Control::dutmal("a", "b").is_dutmal());
2650 }
2651
2652 #[test]
2653 fn dutmal_is_compose_false() {
2654 assert!(!Control::dutmal("a", "b").is_compose());
2655 }
2656
2657 #[test]
2658 fn dutmal_display() {
2659 let ctrl = Control::dutmal("hello", "world");
2660 assert_eq!(ctrl.to_string(), r#"Dutmal("hello" / "world")"#);
2661 }
2662
2663 #[test]
2664 fn dutmal_serde_roundtrip() {
2665 let ctrl = Control::Dutmal {
2666 main_text: "테스트".to_string(),
2667 sub_text: "test".to_string(),
2668 position: DutmalPosition::Bottom,
2669 sz_ratio: 50,
2670 align: DutmalAlign::Right,
2671 metadata: DutmalMetadata::default(),
2672 };
2673 let json = serde_json::to_string(&ctrl).unwrap();
2674 let decoded: Control = serde_json::from_str(&json).unwrap();
2675 assert_eq!(ctrl, decoded);
2676 }
2677
2678 #[test]
2679 fn dutmal_position_default_is_top() {
2680 assert_eq!(DutmalPosition::default(), DutmalPosition::Top);
2681 }
2682
2683 #[test]
2684 fn dutmal_align_default_is_center() {
2685 assert_eq!(DutmalAlign::default(), DutmalAlign::Center);
2686 }
2687
2688 // ── Compose (글자겹침) tests ─────────────────────────────────
2689
2690 #[test]
2691 fn compose_constructor_defaults() {
2692 let ctrl = Control::compose("가");
2693 assert!(ctrl.is_compose());
2694 match ctrl {
2695 Control::Compose { compose_text, circle_type, char_sz, compose_type, char_pr_ids } => {
2696 assert_eq!(compose_text, "가");
2697 assert_eq!(circle_type, "SHAPE_REVERSAL_TIRANGLE");
2698 assert_eq!(char_sz, -3);
2699 assert_eq!(compose_type, "SPREAD");
2700 assert_eq!(char_pr_ids, vec![u32::MAX; 10]);
2701 }
2702 _ => panic!("expected Compose"),
2703 }
2704 }
2705
2706 #[test]
2707 fn compose_is_compose_true() {
2708 assert!(Control::compose("나").is_compose());
2709 }
2710
2711 #[test]
2712 fn compose_is_dutmal_false() {
2713 assert!(!Control::compose("나").is_dutmal());
2714 }
2715
2716 #[test]
2717 fn compose_display() {
2718 let ctrl = Control::compose("가나");
2719 assert_eq!(ctrl.to_string(), r#"Compose("가나")"#);
2720 }
2721
2722 #[test]
2723 fn compose_serde_roundtrip() {
2724 let ctrl = Control::Compose {
2725 compose_text: "①".to_string(),
2726 circle_type: "SHAPE_REVERSAL_TIRANGLE".to_string(),
2727 char_sz: -3,
2728 compose_type: "SPREAD".to_string(),
2729 char_pr_ids: vec![u32::MAX; 10],
2730 };
2731 let json = serde_json::to_string(&ctrl).unwrap();
2732 let decoded: Control = serde_json::from_str(&json).unwrap();
2733 assert_eq!(ctrl, decoded);
2734 }
2735
2736 #[test]
2737 fn compose_spec_typo_preserved() {
2738 // "SHAPE_REVERSAL_TIRANGLE" is an official spec typo — must be preserved exactly
2739 let ctrl = Control::compose("X");
2740 match ctrl {
2741 Control::Compose { circle_type, .. } => {
2742 assert_eq!(circle_type, "SHAPE_REVERSAL_TIRANGLE");
2743 assert!(!circle_type.contains("TRIANGLE")); // confirm the typo
2744 }
2745 _ => panic!("expected Compose"),
2746 }
2747 }
2748
2749 // ===================================================================
2750 // H2: saturating i64→i32 conversion in shape constructors
2751 // ===================================================================
2752
2753 #[test]
2754 fn line_extreme_coords_no_panic() {
2755 // Coordinates near i32 extremes produce a valid line without panicking
2756 let start = ShapePoint::new(i32::MIN, i32::MIN);
2757 let end = ShapePoint::new(i32::MAX, i32::MAX);
2758 let ctrl = Control::line(start, end).unwrap();
2759 assert!(ctrl.is_line());
2760 }
2761
2762 #[test]
2763 fn connect_line_extreme_coords_no_panic() {
2764 let start = ShapePoint::new(i32::MIN, 0);
2765 let end = ShapePoint::new(i32::MAX, 0);
2766 let ctrl = Control::connect_line(start, end).unwrap();
2767 assert!(ctrl.is_connect_line());
2768 }
2769
2770 #[test]
2771 fn polygon_extreme_coords_no_panic() {
2772 // Span exceeds i32::MAX — should error (HwpUnit range exceeded), not panic
2773 let vertices = vec![
2774 ShapePoint::new(i32::MIN, 0),
2775 ShapePoint::new(i32::MAX, 0),
2776 ShapePoint::new(0, i32::MAX),
2777 ];
2778 // Either succeeds (saturated) or returns an error — must not panic
2779 let _ = Control::polygon(vertices);
2780 }
2781
2782 #[test]
2783 fn curve_extreme_coords_no_panic() {
2784 let points = vec![ShapePoint::new(i32::MIN, i32::MIN), ShapePoint::new(i32::MAX, i32::MAX)];
2785 let _ = Control::curve(points);
2786 }
2787}