hwpforge_core/style_lookup.rs
1//! Format-agnostic style querying trait.
2//!
3//! [`StyleLookup`] provides a uniform interface for retrieving character,
4//! paragraph, and style properties by index. Each format-specific style
5//! store (e.g. `HwpxStyleStore`) implements this trait so that downstream
6//! consumers (e.g. the Markdown encoder) can query styles without knowing
7//! the underlying format.
8//!
9//! All methods have default implementations returning `None`, so
10//! implementors only need to override the methods they can support.
11
12use hwpforge_foundation::{
13 Alignment, CharShapeIndex, Color, HwpUnit, ParaShapeIndex, StyleIndex, UnderlineType,
14};
15
16/// Trait for querying resolved style properties by index.
17///
18/// This is the bridge between format-specific style stores and
19/// format-independent consumers (like the Markdown encoder). Each method
20/// takes a branded index and returns `Option<T>`, where `None` means the
21/// property is unavailable or unsupported.
22///
23/// # Default Implementations
24///
25/// Every method defaults to `None`, so an empty implementation is valid:
26///
27/// ```
28/// use hwpforge_core::StyleLookup;
29/// use hwpforge_foundation::CharShapeIndex;
30///
31/// struct NoopStore;
32/// impl StyleLookup for NoopStore {}
33///
34/// let store = NoopStore;
35/// assert!(store.char_bold(CharShapeIndex::new(0)).is_none());
36/// ```
37pub trait StyleLookup {
38 /// Returns whether the character shape at `id` is bold.
39 fn char_bold(&self, _id: CharShapeIndex) -> Option<bool> {
40 None
41 }
42
43 /// Returns whether the character shape at `id` is italic.
44 fn char_italic(&self, _id: CharShapeIndex) -> Option<bool> {
45 None
46 }
47
48 /// Returns the underline type of the character shape at `id`.
49 fn char_underline(&self, _id: CharShapeIndex) -> Option<UnderlineType> {
50 None
51 }
52
53 /// Returns whether the character shape at `id` has strikeout.
54 fn char_strikeout(&self, _id: CharShapeIndex) -> Option<bool> {
55 None
56 }
57
58 /// Returns whether the character shape at `id` is superscript.
59 fn char_superscript(&self, _id: CharShapeIndex) -> Option<bool> {
60 None
61 }
62
63 /// Returns whether the character shape at `id` is subscript.
64 fn char_subscript(&self, _id: CharShapeIndex) -> Option<bool> {
65 None
66 }
67
68 /// Returns the font name of the character shape at `id`.
69 fn char_font_name(&self, _id: CharShapeIndex) -> Option<&str> {
70 None
71 }
72
73 /// Returns the **distinct** font face names referenced across the
74 /// per-language axes (hangul/latin/hanja/…) of the character shape.
75 ///
76 /// Formats with per-language font references (HWPX `fontRef`) override
77 /// this to surface axis mismatches — a result longer than 1 means the
78 /// character shape renders with different fonts per script, which a
79 /// single-font consumer cannot reproduce faithfully. The first element
80 /// matches [`char_font_name`](Self::char_font_name) when both resolve.
81 ///
82 /// The default implementation returns the single
83 /// [`char_font_name`](Self::char_font_name) (no axis information).
84 fn char_font_axis_names(&self, id: CharShapeIndex) -> Vec<&str> {
85 self.char_font_name(id).into_iter().collect()
86 }
87
88 /// Returns the font size (in [`HwpUnit`]) of the character shape at `id`.
89 fn char_font_size(&self, _id: CharShapeIndex) -> Option<HwpUnit> {
90 None
91 }
92
93 /// Returns the char shape referenced by the named **character** style
94 /// (style table `type="CHAR"`), matching either the localized or the
95 /// English style name.
96 ///
97 /// Hancom renders page numbers (`hp:pageNum`) with the dedicated
98 /// "쪽 번호"/"Page Number" CHAR style rather than the document default —
99 /// fixture-verified (rules-pagenum, 2026-08-10). Consumers that
100 /// synthesize such text need the style's char shape to match Hancom
101 /// output. The default implementation reports no style table.
102 fn char_style_shape(&self, _name: &str) -> Option<CharShapeIndex> {
103 None
104 }
105
106 /// Returns the text color of the character shape at `id`.
107 fn char_text_color(&self, _id: CharShapeIndex) -> Option<Color> {
108 None
109 }
110
111 /// Returns the horizontal alignment of the paragraph shape at `id`.
112 fn para_alignment(&self, _id: ParaShapeIndex) -> Option<Alignment> {
113 None
114 }
115
116 /// Returns the left indent of the paragraph shape at `id`.
117 fn para_indent_left(&self, _id: ParaShapeIndex) -> Option<HwpUnit> {
118 None
119 }
120
121 /// Returns the first-line indent of the paragraph shape at `id`.
122 fn para_indent_first_line(&self, _id: ParaShapeIndex) -> Option<HwpUnit> {
123 None
124 }
125
126 /// Returns the list type for a paragraph shape: `"BULLET"`, `"NUMBER"`, or `None`.
127 ///
128 /// Returns `None` if the paragraph has no list heading or if the heading
129 /// type is `NONE` / `OUTLINE`.
130 fn para_list_type(&self, _id: ParaShapeIndex) -> Option<&str> {
131 None
132 }
133
134 /// Returns the zero-based list nesting level for a paragraph shape.
135 ///
136 /// This is only meaningful for numbered/bulleted list semantics. Outline
137 /// headings should use [`para_heading_level`](Self::para_heading_level)
138 /// instead.
139 fn para_list_level(&self, _id: ParaShapeIndex) -> Option<u8> {
140 None
141 }
142
143 /// Returns the checkbox state for a paragraph shape when it is a checkable bullet.
144 ///
145 /// `Some(true)` means a checked checkbox item, `Some(false)` means an
146 /// unchecked checkbox item, and `None` means the paragraph is not a
147 /// checkable bullet.
148 fn para_checked_state(&self, _id: ParaShapeIndex) -> Option<bool> {
149 None
150 }
151
152 /// Returns the preferred style name associated with the paragraph shape.
153 ///
154 /// This is useful for encoders that need to recover semantics carried by a
155 /// dedicated paragraph shape even when the paragraph itself has no explicit
156 /// `style_id`.
157 fn para_style_name(&self, _id: ParaShapeIndex) -> Option<&str> {
158 None
159 }
160
161 /// Returns the heading level (1–6) implied by the paragraph shape at `id`.
162 ///
163 /// This is the format-agnostic truth source for paragraph-level outline
164 /// semantics. Implementors that can inspect real paragraph-shape outline
165 /// metadata should override this method; downstream styled export paths use
166 /// it before style-name heuristics whenever both are available.
167 fn para_heading_level(&self, _id: ParaShapeIndex) -> Option<u8> {
168 None
169 }
170
171 /// Returns the Korean name of the style at `id`.
172 fn style_name(&self, _id: StyleIndex) -> Option<&str> {
173 None
174 }
175
176 /// Returns the heading level (1–6) of the style at `id`, if it is
177 /// a heading style. Returns `None` for non-heading styles.
178 fn style_heading_level(&self, _id: StyleIndex) -> Option<u8> {
179 None
180 }
181
182 /// Resolves a `binaryItemIDRef` (e.g. `"BinData/image1"`) to the actual
183 /// filename with extension (e.g. `"image1.png"`).
184 ///
185 /// Returns `None` if no matching image is found.
186 fn image_resolve_filename(&self, _key: &str) -> Option<&str> {
187 None
188 }
189
190 /// Returns the raw binary data for the image identified by `key`.
191 ///
192 /// `key` is typically a path like `"image1.jpg"`. Returns `None` if
193 /// the image is not available or if the implementor does not store
194 /// image data.
195 fn image_data(&self, _key: &str) -> Option<&[u8]> {
196 None
197 }
198
199 /// Returns the four rendered border edges of the `borderFill` at `id`
200 /// (1-based wire reference, e.g. [`crate::table::TableCell::border_fill_id`]).
201 ///
202 /// `None` means the id is not registered in this store.
203 fn border_fill_lines(&self, _id: u32) -> Option<BorderFillLines> {
204 None
205 }
206
207 /// Returns the face-fill verdict of the `borderFill` at `id` (1-based
208 /// wire reference).
209 ///
210 /// `None` means the id is not registered — distinguish this from
211 /// [`FillKind::None`] (registered, but transparent) and
212 /// [`FillKind::Unsupported`] (registered, but not renderable — warn).
213 fn border_fill_face(&self, _id: u32) -> Option<FillKind> {
214 None
215 }
216}
217
218/// Render kind of one border edge.
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220#[non_exhaustive]
221pub enum BorderLineKind {
222 /// No line on this edge.
223 None,
224 /// Solid stroke.
225 Solid,
226 /// Any other style (dashed, double, …) or an unparsable width/color —
227 /// consumers must warn and skip instead of guessing (no fake support).
228 Other,
229}
230
231/// One rendered border edge of a `borderFill`.
232#[derive(Debug, Clone, Copy, PartialEq)]
233#[non_exhaustive]
234pub struct BorderLine {
235 /// Render kind of this edge.
236 pub kind: BorderLineKind,
237 /// Stroke width ([`HwpUnit::ZERO`] when [`BorderLineKind::None`]/`Other`).
238 pub width: HwpUnit,
239 /// Stroke color.
240 pub color: Color,
241}
242
243impl BorderLine {
244 /// Creates a border line.
245 #[must_use]
246 pub fn new(kind: BorderLineKind, width: HwpUnit, color: Color) -> Self {
247 Self { kind, width, color }
248 }
249}
250
251/// The four rendered edges of a `borderFill`.
252#[derive(Debug, Clone, Copy, PartialEq)]
253#[non_exhaustive]
254pub struct BorderFillLines {
255 /// Left edge.
256 pub left: BorderLine,
257 /// Right edge.
258 pub right: BorderLine,
259 /// Top edge.
260 pub top: BorderLine,
261 /// Bottom edge.
262 pub bottom: BorderLine,
263}
264
265impl BorderFillLines {
266 /// Creates the four edges.
267 #[must_use]
268 pub fn new(left: BorderLine, right: BorderLine, top: BorderLine, bottom: BorderLine) -> Self {
269 Self { left, right, top, bottom }
270 }
271}
272
273/// Face-fill verdict of a `borderFill` — distinguishes "no fill" from
274/// "unsupported fill": only the latter warrants a consumer warning.
275#[derive(Debug, Clone, Copy, PartialEq)]
276#[non_exhaustive]
277pub enum FillKind {
278 /// No fill (transparent) — normal, no warning.
279 None,
280 /// Solid color fill.
281 Solid(Color),
282 /// Gradient/image/hatch fill or an unparsable color — consumers must
283 /// warn and skip instead of guessing (no fake support).
284 Unsupported,
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use hwpforge_foundation::{ParaShapeIndex, StyleIndex};
291
292 struct NoopStore;
293 impl StyleLookup for NoopStore {}
294
295 #[test]
296 fn noop_store_returns_none_for_all_methods() {
297 let store = NoopStore;
298 let cs = CharShapeIndex::new(0);
299 let ps = ParaShapeIndex::new(0);
300 let si = StyleIndex::new(0);
301
302 assert!(store.char_bold(cs).is_none());
303 assert!(store.char_italic(cs).is_none());
304 assert!(store.char_underline(cs).is_none());
305 assert!(store.char_strikeout(cs).is_none());
306 assert!(store.char_superscript(cs).is_none());
307 assert!(store.char_subscript(cs).is_none());
308 assert!(store.char_font_name(cs).is_none());
309 assert!(store.char_font_size(cs).is_none());
310 assert!(store.char_text_color(cs).is_none());
311 assert!(store.para_alignment(ps).is_none());
312 assert!(store.para_indent_left(ps).is_none());
313 assert!(store.para_indent_first_line(ps).is_none());
314 assert!(store.para_list_type(ps).is_none());
315 assert!(store.para_list_level(ps).is_none());
316 assert!(store.para_checked_state(ps).is_none());
317 assert!(store.para_style_name(ps).is_none());
318 assert!(store.para_heading_level(ps).is_none());
319 assert!(store.style_name(si).is_none());
320 assert!(store.style_heading_level(si).is_none());
321 assert!(store.image_data("image1.jpg").is_none());
322 assert!(store.border_fill_lines(1).is_none());
323 assert!(store.border_fill_face(1).is_none());
324 }
325
326 #[test]
327 fn partial_impl_returns_some_for_overridden_methods() {
328 struct BoldOnly;
329 impl StyleLookup for BoldOnly {
330 fn char_bold(&self, _id: CharShapeIndex) -> Option<bool> {
331 Some(true)
332 }
333 }
334
335 let store = BoldOnly;
336 assert_eq!(store.char_bold(CharShapeIndex::new(0)), Some(true));
337 // Non-overridden methods still return None
338 assert!(store.char_italic(CharShapeIndex::new(0)).is_none());
339 }
340
341 #[test]
342 fn trait_object_works() {
343 let store: &dyn StyleLookup = &NoopStore;
344 assert!(store.char_bold(CharShapeIndex::new(0)).is_none());
345 }
346
347 #[test]
348 fn default_axis_names_mirror_single_font_name() {
349 // 기본 구현 = char_font_name 단일 원소 (축 정보 없는 포맷).
350 let cs = CharShapeIndex::new(0);
351 assert!(NoopStore.char_font_axis_names(cs).is_empty());
352
353 struct OneFont;
354 impl StyleLookup for OneFont {
355 fn char_font_name(&self, _id: CharShapeIndex) -> Option<&str> {
356 Some("함초롬바탕")
357 }
358 }
359 assert_eq!(OneFont.char_font_axis_names(cs), vec!["함초롬바탕"]);
360 }
361}