Skip to main content

hwpforge_foundation/enums/
reference.rs

1//! Reference enums: bookmarks, fields, cross-references, and drop-cap styles.
2
3use crate::error::FoundationError;
4use serde::{Deserialize, Serialize};
5use std::fmt;
6
7// ---------------------------------------------------------------------------
8// BookmarkType
9// ---------------------------------------------------------------------------
10
11/// Type of bookmark in an HWPX document.
12///
13/// Bookmarks can mark a single point or span a range of content
14/// (start/end pair using `fieldBegin`/`fieldEnd`).
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
16#[non_exhaustive]
17#[repr(u8)]
18pub enum BookmarkType {
19    /// A point bookmark at a single location (direct serde in `<hp:ctrl>`).
20    #[default]
21    Point = 0,
22    /// Start of a span bookmark (`fieldBegin type="BOOKMARK"`).
23    SpanStart = 1,
24    /// End of a span bookmark (`fieldEnd beginIDRef`).
25    SpanEnd = 2,
26}
27
28impl fmt::Display for BookmarkType {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            Self::Point => f.write_str("Point"),
32            Self::SpanStart => f.write_str("SpanStart"),
33            Self::SpanEnd => f.write_str("SpanEnd"),
34        }
35    }
36}
37
38impl std::str::FromStr for BookmarkType {
39    type Err = FoundationError;
40
41    fn from_str(s: &str) -> Result<Self, Self::Err> {
42        match s {
43            "Point" | "point" => Ok(Self::Point),
44            "SpanStart" | "span_start" => Ok(Self::SpanStart),
45            "SpanEnd" | "span_end" => Ok(Self::SpanEnd),
46            _ => Err(FoundationError::ParseError {
47                type_name: "BookmarkType".to_string(),
48                value: s.to_string(),
49                valid_values: "Point, SpanStart, SpanEnd".to_string(),
50            }),
51        }
52    }
53}
54
55impl TryFrom<u8> for BookmarkType {
56    type Error = FoundationError;
57
58    fn try_from(value: u8) -> Result<Self, Self::Error> {
59        match value {
60            0 => Ok(Self::Point),
61            1 => Ok(Self::SpanStart),
62            2 => Ok(Self::SpanEnd),
63            _ => Err(FoundationError::ParseError {
64                type_name: "BookmarkType".to_string(),
65                value: value.to_string(),
66                valid_values: "0 (Point), 1 (SpanStart), 2 (SpanEnd)".to_string(),
67            }),
68        }
69    }
70}
71
72impl schemars::JsonSchema for BookmarkType {
73    fn schema_name() -> std::borrow::Cow<'static, str> {
74        std::borrow::Cow::Borrowed("BookmarkType")
75    }
76
77    fn json_schema(gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
78        gen.subschema_for::<String>()
79    }
80}
81
82// ---------------------------------------------------------------------------
83// FieldType
84// ---------------------------------------------------------------------------
85
86/// Type of a press-field (누름틀) or SUMMERY auto-field carried in
87/// [`Control::Field`].
88///
89/// `ClickHere` is the press-field (user-fillable form). The rest are
90/// SUMMERY-family auto-fields (HWPX `<hp:fieldBegin type="SUMMERY">`,
91/// HWP5 `%smr` ctrl_id) that resolve to document metadata at render time.
92///
93/// Wave 12n (2026-06-02) breaking: legacy `Date`/`Time`/`DocSummary`/`UserInfo`
94/// variants were renamed to match the actual HWPX `Command` token and 한컴
95/// menu item, and `PageNum` was moved out to [`Control::InlinePageNumber`]
96/// because the wire family is `atno`, not SUMMERY/fieldBegin.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
98#[non_exhaustive]
99#[repr(u8)]
100pub enum FieldType {
101    /// Click-here placeholder press-field (default). 한컴 누름틀. HWP5 `%clk`.
102    #[default]
103    ClickHere = 0,
104    /// SUMMERY `$author` — 만든 사람 (was [`FieldType::DocSummary`]).
105    Author = 1,
106    /// SUMMERY `$lastsaveby` — 마지막 저장한 사람 (was [`FieldType::UserInfo`]).
107    LastSavedBy = 2,
108    /// SUMMERY `$createtime` — 만든 날짜 (was [`FieldType::Time`]).
109    CreatedTime = 3,
110    /// SUMMERY `$modifiedtime` — 마지막 저장한 날짜 (was [`FieldType::Date`]).
111    ModifiedTime = 4,
112    /// SUMMERY `$title` — 문서 제목 (NEW in Wave 12n).
113    Title = 5,
114}
115
116impl FieldType {
117    /// Returns the HWPX `Command` `$token` for SUMMERY variants, or `None`
118    /// for non-SUMMERY (`ClickHere`).
119    #[must_use]
120    pub fn summary_token(self) -> Option<&'static str> {
121        match self {
122            Self::ClickHere => None,
123            Self::Author => Some("$author"),
124            Self::LastSavedBy => Some("$lastsaveby"),
125            Self::CreatedTime => Some("$createtime"),
126            Self::ModifiedTime => Some("$modifiedtime"),
127            Self::Title => Some("$title"),
128        }
129    }
130
131    /// Parses a SUMMERY `$token` (`$author`, `$createtime`, …) into a
132    /// matching [`FieldType`]. Returns `None` for unknown tokens; callers
133    /// should carry the raw token via [`Control::UnknownSummary`] instead.
134    #[must_use]
135    pub fn from_summary_token(token: &str) -> Option<Self> {
136        match token {
137            "$author" => Some(Self::Author),
138            "$lastsaveby" => Some(Self::LastSavedBy),
139            "$createtime" => Some(Self::CreatedTime),
140            "$modifiedtime" => Some(Self::ModifiedTime),
141            "$title" => Some(Self::Title),
142            _ => None,
143        }
144    }
145
146    /// Returns the HWPX `<hp:fieldBegin editable="…">` value for this
147    /// field type (Wave 12p task #124).
148    ///
149    /// Empirically derived from the Hancom-native
150    /// `sample-field-docsummary.hwpx`:
151    /// - `Author`, `Title` → `false` (stable user metadata; Hancom keeps
152    ///   the authored value)
153    /// - `LastSavedBy`, `CreatedTime`, `ModifiedTime` → `true` (Hancom
154    ///   recomputes from the document metadata on save)
155    /// - `ClickHere` → `true` (placeholder press-fields are user-editable)
156    ///
157    /// Encoders use this method to populate the `editable` attribute
158    /// instead of the prior hardcoded `"1"` (which previously caused
159    /// `$author`/`$title` to appear edited when re-saved by Hancom).
160    #[must_use]
161    pub fn hwpx_editable(self) -> bool {
162        match self {
163            Self::ClickHere => true,
164            Self::Author | Self::Title => false,
165            Self::LastSavedBy | Self::CreatedTime | Self::ModifiedTime => true,
166        }
167    }
168}
169
170impl fmt::Display for FieldType {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        match self {
173            Self::ClickHere => f.write_str("CLICK_HERE"),
174            Self::Author => f.write_str("AUTHOR"),
175            Self::LastSavedBy => f.write_str("LAST_SAVED_BY"),
176            Self::CreatedTime => f.write_str("CREATED_TIME"),
177            Self::ModifiedTime => f.write_str("MODIFIED_TIME"),
178            Self::Title => f.write_str("TITLE"),
179        }
180    }
181}
182
183impl std::str::FromStr for FieldType {
184    type Err = FoundationError;
185
186    fn from_str(s: &str) -> Result<Self, Self::Err> {
187        match s {
188            "CLICK_HERE" | "ClickHere" | "click_here" => Ok(Self::ClickHere),
189            "AUTHOR" | "Author" | "author"
190            // legacy Wave 12l alias: DocSummary used to emit $author
191            | "DOC_SUMMARY" | "DocSummary" | "doc_summary" => Ok(Self::Author),
192            "LAST_SAVED_BY" | "LastSavedBy" | "last_saved_by"
193            // legacy alias: UserInfo used to emit $lastsaveby
194            | "USER_INFO" | "UserInfo" | "user_info" => Ok(Self::LastSavedBy),
195            "CREATED_TIME" | "CreatedTime" | "created_time"
196            // legacy alias: Time used to emit $createtime
197            | "TIME" | "Time" | "time" => Ok(Self::CreatedTime),
198            "MODIFIED_TIME" | "ModifiedTime" | "modified_time"
199            // legacy alias: Date used to emit $modifiedtime
200            | "DATE" | "Date" | "date" => Ok(Self::ModifiedTime),
201            "TITLE" | "Title" | "title" => Ok(Self::Title),
202            _ => Err(FoundationError::ParseError {
203                type_name: "FieldType".to_string(),
204                value: s.to_string(),
205                valid_values:
206                    "CLICK_HERE, AUTHOR, LAST_SAVED_BY, CREATED_TIME, MODIFIED_TIME, TITLE"
207                        .to_string(),
208            }),
209        }
210    }
211}
212
213impl TryFrom<u8> for FieldType {
214    type Error = FoundationError;
215
216    fn try_from(value: u8) -> Result<Self, Self::Error> {
217        match value {
218            0 => Ok(Self::ClickHere),
219            1 => Ok(Self::Author),
220            2 => Ok(Self::LastSavedBy),
221            3 => Ok(Self::CreatedTime),
222            4 => Ok(Self::ModifiedTime),
223            5 => Ok(Self::Title),
224            _ => Err(FoundationError::ParseError {
225                type_name: "FieldType".to_string(),
226                value: value.to_string(),
227                valid_values: "0..5 (ClickHere..Title)".to_string(),
228            }),
229        }
230    }
231}
232
233impl schemars::JsonSchema for FieldType {
234    fn schema_name() -> std::borrow::Cow<'static, str> {
235        std::borrow::Cow::Borrowed("FieldType")
236    }
237
238    fn json_schema(gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
239        gen.subschema_for::<String>()
240    }
241}
242
243// ---------------------------------------------------------------------------
244// RefType
245// ---------------------------------------------------------------------------
246
247/// Target type of a cross-reference (상호참조).
248///
249/// Wave 12m Phase 2 (Codex(architect+critic) review 반영):
250/// - `#[repr(u8)]` 제거 — HWP5 wire 코드는 boundary 에서 매핑 (Core/foundation
251///   은 wire-agnostic 의미 모델만 유지).
252/// - `Footnote` / `Endnote` / `Outline` 추가 (한컴 native dropdown 7 종 완비).
253/// - `Unknown(u8)` tuple variant 추가 — silent fallback 위조 방지 (Codex HIGH).
254///
255/// HWP5 `%xrf` Command N1 코드 → `RefType` 변환은
256/// `smithy-hwp5/src/projection.rs::decode_hwp5_ref_type` 에서 수행.
257#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
258#[non_exhaustive]
259pub enum RefType {
260    /// Reference to a bookmark target. 한컴: "책갈피"
261    #[default]
262    Bookmark,
263    /// Reference to a table caption number. 한컴: "표"
264    Table,
265    /// Reference to a figure/image caption number. 한컴: "그림"
266    Figure,
267    /// Reference to an equation caption number. 한컴: "수식"
268    Equation,
269    /// Reference to a footnote (각주).
270    Footnote,
271    /// Reference to an endnote (미주).
272    Endnote,
273    /// Reference to an outline heading (개요).
274    Outline,
275    /// Unrecognized RefType code preserved from wire for forward
276    /// compatibility. Carriers MUST NOT silently fallback to a known
277    /// variant — the unknown code is preserved so future Hancom
278    /// extensions don't get silently lost.
279    Unknown(u8),
280}
281
282impl fmt::Display for RefType {
283    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284        match self {
285            Self::Bookmark => f.write_str("TARGET_BOOKMARK"),
286            Self::Table => f.write_str("TARGET_TABLE"),
287            Self::Figure => f.write_str("TARGET_FIGURE"),
288            Self::Equation => f.write_str("TARGET_EQUATION"),
289            Self::Footnote => f.write_str("TARGET_FOOTNOTE"),
290            Self::Endnote => f.write_str("TARGET_ENDNOTE"),
291            Self::Outline => f.write_str("TARGET_OUTLINE"),
292            Self::Unknown(code) => write!(f, "TARGET_UNKNOWN({code})"),
293        }
294    }
295}
296
297impl std::str::FromStr for RefType {
298    type Err = FoundationError;
299
300    fn from_str(s: &str) -> Result<Self, Self::Err> {
301        match s {
302            "TARGET_BOOKMARK" | "Bookmark" | "bookmark" => Ok(Self::Bookmark),
303            "TARGET_TABLE" | "Table" | "table" => Ok(Self::Table),
304            "TARGET_FIGURE" | "Figure" | "figure" => Ok(Self::Figure),
305            "TARGET_EQUATION" | "Equation" | "equation" => Ok(Self::Equation),
306            "TARGET_FOOTNOTE" | "Footnote" | "footnote" => Ok(Self::Footnote),
307            "TARGET_ENDNOTE" | "Endnote" | "endnote" => Ok(Self::Endnote),
308            "TARGET_OUTLINE" | "Outline" | "outline" => Ok(Self::Outline),
309            _ => Err(FoundationError::ParseError {
310                type_name: "RefType".to_string(),
311                value: s.to_string(),
312                valid_values: "TARGET_BOOKMARK, TARGET_TABLE, TARGET_FIGURE, TARGET_EQUATION, \
313                    TARGET_FOOTNOTE, TARGET_ENDNOTE, TARGET_OUTLINE"
314                    .to_string(),
315            }),
316        }
317    }
318}
319
320impl schemars::JsonSchema for RefType {
321    fn schema_name() -> std::borrow::Cow<'static, str> {
322        std::borrow::Cow::Borrowed("RefType")
323    }
324
325    fn json_schema(gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
326        gen.subschema_for::<String>()
327    }
328}
329
330// ---------------------------------------------------------------------------
331// RefContentType
332// ---------------------------------------------------------------------------
333
334/// Content display type for a cross-reference (what to show at the reference site).
335///
336/// Wave 12m Phase 2 (Codex(architect+critic) review 반영):
337/// - `#[repr(u8)]` 제거 — N2 wire 코드는 boundary 에서 매핑.
338/// - `Unknown(u8)` 추가 — silent fallback 위조 방지.
339///
340/// Wave 12p pre-fix (Wave 12m fixup regression revert + 한컴 native
341/// wire 일치 보정, 2026-06-08):
342///
343/// Wave 12m fixup 에서 `BookmarkName` 폐기 + Bookmark+Contents 를 N2=2
344/// 로 통일했으나, native wire 분석 결과 정반대였음:
345///
346/// | HWP5 N2 (Bookmark) | 한컴 의미     | HWPX RefContentType 문자열 |
347/// |--------------------|---------------|---------------------------|
348/// | 0                  | 책갈피 위치 페이지 | `OBJECT_TYPE_PAGE`        |
349/// | 1                  | 책갈피 본문 / 번호 | `OBJECT_TYPE_NUMBER` (!!) |
350/// | 2                  | 책갈피 이름       | `OBJECT_TYPE_CONTENTS`    |
351/// | 3                  | 위/아래          | `OBJECT_TYPE_UPDOWNPOS`   |
352///
353/// (책갈피의 wire 는 OWPML spec 표 156 의 직관과 어긋남 — `CONTENTS`
354/// 가 "책갈피 이름", `NUMBER` 가 "책갈피 내용" 의미.) `BookmarkName`
355/// variant 부활 + Display 는 `OBJECT_TYPE_CONTENTS` 동일 emit (한컴
356/// wire 일치) 하되 boundary 의 wire code 매핑은 분리.
357#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
358#[non_exhaustive]
359pub enum RefContentType {
360    /// Show page number where the target appears. 한컴: "쪽 번호" (모든 RefType 공통)
361    #[default]
362    Page,
363    /// Show the target's numbering. 의미는 RefType-상대적:
364    /// - Footnote/Endnote/Caption/Outline: "주 번호" / "표 번호" / "그림 번호" / "개요 번호" (N2=1)
365    /// - Bookmark: **책갈피 본문 / 번호 텍스트** (N2=1, 한컴 wire 특이성)
366    Number,
367    /// Show the target's content. 의미는 RefType-상대적:
368    /// - Figure/Table/Equation: "캡션 내용" (N2=2)
369    /// - Outline: "개요 내용" (N2=2)
370    /// - Bookmark 에서는 *사용 안 함* — 책갈피의 "내용" 의미는
371    ///   `RefContentType::Number` 가 carry (N2=1).
372    Contents,
373    /// Show the bookmark's NAME. 한컴: "책갈피 이름" (Bookmark+N2=2 전용).
374    /// HWPX wire 의 `OBJECT_TYPE_CONTENTS` 와 동일 문자열로 emit
375    /// (RefType=TARGET_BOOKMARK 컨텍스트에서 한컴이 의미 결정).
376    BookmarkName,
377    /// Show relative position ("위" / "아래"). 한컴: "위/아래" (N2=3).
378    UpDownPos,
379    /// Unrecognized ContentType code preserved from wire for forward
380    /// compatibility.
381    Unknown(u8),
382}
383
384impl fmt::Display for RefContentType {
385    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
386        match self {
387            Self::Page => f.write_str("OBJECT_TYPE_PAGE"),
388            Self::Number => f.write_str("OBJECT_TYPE_NUMBER"),
389            // Wave 12p pre-fix: `Contents` 와 `BookmarkName` 모두 한컴
390            // wire 의 `OBJECT_TYPE_CONTENTS` 로 emit. 의미 구분은
391            // RefType + Command 의 N2 wire code 에서 (Bookmark N2=2 =
392            // 책갈피 이름, Figure/Table/Eq/Outline N2=2 = 캡션 내용).
393            Self::Contents | Self::BookmarkName => f.write_str("OBJECT_TYPE_CONTENTS"),
394            Self::UpDownPos => f.write_str("OBJECT_TYPE_UPDOWNPOS"),
395            Self::Unknown(code) => write!(f, "OBJECT_TYPE_UNKNOWN({code})"),
396        }
397    }
398}
399
400impl std::str::FromStr for RefContentType {
401    type Err = FoundationError;
402
403    fn from_str(s: &str) -> Result<Self, Self::Err> {
404        match s {
405            "OBJECT_TYPE_PAGE" | "Page" | "page" => Ok(Self::Page),
406            "OBJECT_TYPE_NUMBER" | "Number" | "number" => Ok(Self::Number),
407            // Wave 12p pre-fix: `OBJECT_TYPE_CONTENTS` 는 caller 의 추가
408            // 컨텍스트 (RefType + N2 wire code) 없이는 `Contents` 와
409            // `BookmarkName` 을 구분할 수 없으므로 default 로 `Contents`
410            // 를 반환. boundary 함수 `decode_hwp5_crossref_content_type`
411            // 가 Bookmark N2=2 인 경우만 `BookmarkName` 으로 재매핑.
412            "OBJECT_TYPE_CONTENTS" | "Contents" | "contents" => Ok(Self::Contents),
413            "BookmarkName" | "bookmark_name" => Ok(Self::BookmarkName),
414            "OBJECT_TYPE_UPDOWNPOS" | "UpDownPos" | "updownpos" => Ok(Self::UpDownPos),
415            _ => Err(FoundationError::ParseError {
416                type_name: "RefContentType".to_string(),
417                value: s.to_string(),
418                valid_values: "OBJECT_TYPE_PAGE, OBJECT_TYPE_NUMBER, OBJECT_TYPE_CONTENTS, \
419                    OBJECT_TYPE_UPDOWNPOS"
420                    .to_string(),
421            }),
422        }
423    }
424}
425
426impl schemars::JsonSchema for RefContentType {
427    fn schema_name() -> std::borrow::Cow<'static, str> {
428        std::borrow::Cow::Borrowed("RefContentType")
429    }
430
431    fn json_schema(gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
432        gen.subschema_for::<String>()
433    }
434}
435
436/// Drop cap style for floating shape objects (HWPX `dropcapstyle` attribute).
437///
438/// Controls whether a shape (text box, image, table, etc.) is formatted as a
439/// drop capital that occupies multiple lines at the start of a paragraph.
440///
441/// # HWPX Values
442///
443/// | Variant      | HWPX string     |
444/// |--------------|-----------------|
445/// | `None`       | `"None"`        |
446/// | `DoubleLine` | `"DoubleLine"`  |
447/// | `TripleLine` | `"TripleLine"`  |
448/// | `Margin`     | `"Margin"`      |
449#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
450#[non_exhaustive]
451pub enum DropCapStyle {
452    /// No drop cap (default).
453    #[default]
454    None = 0,
455    /// Drop cap spanning 2 lines.
456    DoubleLine = 1,
457    /// Drop cap spanning 3 lines.
458    TripleLine = 2,
459    /// Drop cap positioned in the margin.
460    Margin = 3,
461}
462
463impl fmt::Display for DropCapStyle {
464    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
465        match self {
466            Self::None => f.write_str("None"),
467            Self::DoubleLine => f.write_str("DoubleLine"),
468            Self::TripleLine => f.write_str("TripleLine"),
469            Self::Margin => f.write_str("Margin"),
470        }
471    }
472}
473
474impl DropCapStyle {
475    /// Parses an HWPX `dropcapstyle` attribute value (PascalCase).
476    ///
477    /// Unknown values fall back to `None` (default) for forward compatibility.
478    pub fn from_hwpx_str(s: &str) -> Self {
479        match s {
480            "DoubleLine" => Self::DoubleLine,
481            "TripleLine" => Self::TripleLine,
482            "Margin" => Self::Margin,
483            _ => Self::None,
484        }
485    }
486}
487
488impl std::str::FromStr for DropCapStyle {
489    type Err = FoundationError;
490
491    fn from_str(s: &str) -> Result<Self, Self::Err> {
492        match s {
493            "None" | "NONE" | "none" => Ok(Self::None),
494            "DoubleLine" | "DOUBLE_LINE" => Ok(Self::DoubleLine),
495            "TripleLine" | "TRIPLE_LINE" => Ok(Self::TripleLine),
496            "Margin" | "MARGIN" => Ok(Self::Margin),
497            _ => Err(FoundationError::ParseError {
498                type_name: "DropCapStyle".to_string(),
499                value: s.to_string(),
500                valid_values: "None, DoubleLine, TripleLine, Margin".to_string(),
501            }),
502        }
503    }
504}
505
506impl schemars::JsonSchema for DropCapStyle {
507    fn schema_name() -> std::borrow::Cow<'static, str> {
508        std::borrow::Cow::Borrowed("DropCapStyle")
509    }
510
511    fn json_schema(gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
512        gen.subschema_for::<String>()
513    }
514}
515
516impl serde::Serialize for DropCapStyle {
517    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
518        serializer.serialize_str(&self.to_string())
519    }
520}
521
522impl<'de> serde::Deserialize<'de> for DropCapStyle {
523    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
524        let s = String::deserialize(deserializer)?;
525        s.parse().map_err(serde::de::Error::custom)
526    }
527}