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