hwpers/model/
hyperlink.rs

1use crate::error::{HwpError, Result};
2use crate::parser::record::Record;
3
4/// 하이퍼링크 유형
5#[derive(Debug, Clone, Copy, PartialEq)]
6pub enum HyperlinkType {
7    /// URL 링크 (http, https, ftp 등)
8    Url = 0,
9    /// 이메일 주소
10    Email = 1,
11    /// 파일 경로
12    File = 2,
13    /// 문서 내 책갈피
14    Bookmark = 3,
15    /// 다른 문서의 책갈피
16    ExternalBookmark = 4,
17}
18
19/// 하이퍼링크 표시 방식
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub enum HyperlinkDisplay {
22    /// 텍스트만 표시
23    TextOnly = 0,
24    /// URL만 표시
25    UrlOnly = 1,
26    /// 텍스트와 URL 모두 표시
27    Both = 2,
28}
29
30/// 하이퍼링크 정보
31#[derive(Debug, Clone)]
32pub struct Hyperlink {
33    /// 하이퍼링크 유형
34    pub hyperlink_type: HyperlinkType,
35    /// 표시할 텍스트
36    pub display_text: String,
37    /// 실제 링크 URL 또는 경로
38    pub target_url: String,
39    /// 툴팁 텍스트
40    pub tooltip: Option<String>,
41    /// 표시 방식
42    pub display_mode: HyperlinkDisplay,
43    /// 텍스트 색상 (RGB)
44    pub text_color: u32,
45    /// 방문한 링크 색상 (RGB)
46    pub visited_color: u32,
47    /// 밑줄 표시 여부
48    pub underline: bool,
49    /// 방문 여부
50    pub visited: bool,
51    /// 새 창에서 열기 여부
52    pub open_in_new_window: bool,
53    /// 문자 범위 시작 위치
54    pub start_position: u32,
55    /// 문자 범위 길이
56    pub length: u32,
57}
58
59impl Default for Hyperlink {
60    fn default() -> Self {
61        Self {
62            hyperlink_type: HyperlinkType::Url,
63            display_text: String::new(),
64            target_url: String::new(),
65            tooltip: None,
66            display_mode: HyperlinkDisplay::TextOnly,
67            text_color: 0x0000FF,    // Blue
68            visited_color: 0x800080, // Purple
69            underline: true,
70            visited: false,
71            open_in_new_window: false,
72            start_position: 0,
73            length: 0,
74        }
75    }
76}
77
78impl Hyperlink {
79    /// 새로운 URL 하이퍼링크 생성
80    pub fn new_url(display_text: &str, url: &str) -> Self {
81        Self {
82            hyperlink_type: HyperlinkType::Url,
83            display_text: display_text.to_string(),
84            target_url: url.to_string(),
85            length: display_text.chars().count() as u32,
86            ..Default::default()
87        }
88    }
89
90    /// 새로운 이메일 하이퍼링크 생성
91    pub fn new_email(display_text: &str, email: &str) -> Self {
92        let mailto_url = if email.starts_with("mailto:") {
93            email.to_string()
94        } else {
95            format!("mailto:{}", email)
96        };
97
98        Self {
99            hyperlink_type: HyperlinkType::Email,
100            display_text: display_text.to_string(),
101            target_url: mailto_url,
102            length: display_text.chars().count() as u32,
103            ..Default::default()
104        }
105    }
106
107    /// 새로운 파일 하이퍼링크 생성
108    pub fn new_file(display_text: &str, file_path: &str) -> Self {
109        Self {
110            hyperlink_type: HyperlinkType::File,
111            display_text: display_text.to_string(),
112            target_url: file_path.to_string(),
113            length: display_text.chars().count() as u32,
114            ..Default::default()
115        }
116    }
117
118    /// 새로운 북마크 하이퍼링크 생성
119    pub fn new_bookmark(display_text: &str, bookmark_name: &str) -> Self {
120        Self {
121            hyperlink_type: HyperlinkType::Bookmark,
122            display_text: display_text.to_string(),
123            target_url: format!("#{}", bookmark_name),
124            length: display_text.chars().count() as u32,
125            ..Default::default()
126        }
127    }
128
129    /// 시작 위치 설정
130    pub fn with_position(mut self, start_position: u32) -> Self {
131        self.start_position = start_position;
132        self
133    }
134
135    /// 길이 설정
136    pub fn with_length(mut self, length: u32) -> Self {
137        self.length = length;
138        self
139    }
140
141    /// 툴팁 설정
142    pub fn with_tooltip(mut self, tooltip: &str) -> Self {
143        self.tooltip = Some(tooltip.to_string());
144        self
145    }
146
147    /// 표시 방식 설정
148    pub fn with_display_mode(mut self, mode: HyperlinkDisplay) -> Self {
149        self.display_mode = mode;
150        self
151    }
152
153    /// 텍스트 색상 설정
154    pub fn with_text_color(mut self, color: u32) -> Self {
155        self.text_color = color;
156        self
157    }
158
159    /// 방문한 링크 색상 설정
160    pub fn with_visited_color(mut self, color: u32) -> Self {
161        self.visited_color = color;
162        self
163    }
164
165    /// 밑줄 표시 설정
166    pub fn with_underline(mut self, underline: bool) -> Self {
167        self.underline = underline;
168        self
169    }
170
171    /// 새 창에서 열기 설정
172    pub fn with_new_window(mut self, new_window: bool) -> Self {
173        self.open_in_new_window = new_window;
174        self
175    }
176
177    /// HWP 형식으로 직렬화
178    pub fn to_bytes(&self) -> Vec<u8> {
179        use crate::utils::encoding::string_to_utf16le;
180        use byteorder::{LittleEndian, WriteBytesExt};
181        use std::io::{Cursor, Write};
182
183        let mut data = Vec::new();
184        let mut writer = Cursor::new(&mut data);
185
186        // 하이퍼링크 속성
187        writer.write_u8(self.hyperlink_type as u8).unwrap();
188        writer.write_u8(self.display_mode as u8).unwrap();
189        writer.write_u32::<LittleEndian>(self.text_color).unwrap();
190        writer
191            .write_u32::<LittleEndian>(self.visited_color)
192            .unwrap();
193
194        // 플래그 비트 (underline, visited, new_window)
195        let mut flags = 0u8;
196        if self.underline {
197            flags |= 0x01;
198        }
199        if self.visited {
200            flags |= 0x02;
201        }
202        if self.open_in_new_window {
203            flags |= 0x04;
204        }
205        writer.write_u8(flags).unwrap();
206
207        // 위치 정보
208        writer
209            .write_u32::<LittleEndian>(self.start_position)
210            .unwrap();
211        writer.write_u32::<LittleEndian>(self.length).unwrap();
212
213        // 표시 텍스트
214        let display_text_utf16 = string_to_utf16le(&self.display_text);
215        writer
216            .write_u16::<LittleEndian>(display_text_utf16.len() as u16 / 2)
217            .unwrap();
218        writer.write_all(&display_text_utf16).unwrap();
219
220        // 대상 URL
221        let target_url_utf16 = string_to_utf16le(&self.target_url);
222        writer
223            .write_u16::<LittleEndian>(target_url_utf16.len() as u16 / 2)
224            .unwrap();
225        writer.write_all(&target_url_utf16).unwrap();
226
227        // 툴팁 (선택사항)
228        if let Some(tooltip) = &self.tooltip {
229            let tooltip_utf16 = string_to_utf16le(tooltip);
230            writer
231                .write_u16::<LittleEndian>(tooltip_utf16.len() as u16 / 2)
232                .unwrap();
233            writer.write_all(&tooltip_utf16).unwrap();
234        } else {
235            writer.write_u16::<LittleEndian>(0).unwrap();
236        }
237
238        data
239    }
240
241    /// HWP 레코드에서 파싱 (to_bytes()로 생성된 데이터 파싱)
242    pub fn from_record(record: &Record) -> Result<Self> {
243        let data = &record.data;
244
245        // Check minimum size for our serialization format
246        if data.len() < 19 {
247            return Err(HwpError::InvalidFormat(
248                "Record too small for hyperlink".to_string(),
249            ));
250        }
251
252        let mut offset = 0;
253
254        // Read hyperlink type (1 byte)
255        let hyperlink_type = match data[offset] {
256            0 => HyperlinkType::Url,
257            1 => HyperlinkType::Email,
258            2 => HyperlinkType::File,
259            3 => HyperlinkType::Bookmark,
260            4 => HyperlinkType::ExternalBookmark,
261            _ => HyperlinkType::Url,
262        };
263        offset += 1;
264
265        // Read display mode (1 byte)
266        let display_mode = match data[offset] {
267            0 => HyperlinkDisplay::TextOnly,
268            1 => HyperlinkDisplay::UrlOnly,
269            2 => HyperlinkDisplay::Both,
270            _ => HyperlinkDisplay::TextOnly,
271        };
272        offset += 1;
273
274        // Read text color (4 bytes)
275        let text_color = u32::from_le_bytes([
276            data[offset],
277            data[offset + 1],
278            data[offset + 2],
279            data[offset + 3],
280        ]);
281        offset += 4;
282
283        // Read visited color (4 bytes)
284        let visited_color = u32::from_le_bytes([
285            data[offset],
286            data[offset + 1],
287            data[offset + 2],
288            data[offset + 3],
289        ]);
290        offset += 4;
291
292        // Read flags (1 byte)
293        let flags = data[offset];
294        let underline = (flags & 0x01) != 0;
295        let visited = (flags & 0x02) != 0;
296        let open_in_new_window = (flags & 0x04) != 0;
297        offset += 1;
298
299        // Read start position (4 bytes)
300        let start_position = u32::from_le_bytes([
301            data[offset],
302            data[offset + 1],
303            data[offset + 2],
304            data[offset + 3],
305        ]);
306        offset += 4;
307
308        // Read length (4 bytes)
309        let length = u32::from_le_bytes([
310            data[offset],
311            data[offset + 1],
312            data[offset + 2],
313            data[offset + 3],
314        ]);
315        offset += 4;
316
317        // Read display text length (2 bytes) - number of UTF-16 characters
318        if offset + 2 > data.len() {
319            return Err(HwpError::InvalidFormat(
320                "Not enough data for display text length".to_string(),
321            ));
322        }
323        let display_text_len = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
324        offset += 2;
325
326        // Read display text (UTF-16)
327        let mut display_text = String::new();
328        if display_text_len > 0 {
329            if offset + display_text_len * 2 > data.len() {
330                return Err(HwpError::InvalidFormat(
331                    "Not enough data for display text".to_string(),
332                ));
333            }
334            let mut utf16_chars = Vec::new();
335            for i in 0..display_text_len {
336                let char_offset = offset + i * 2;
337                let char_val = u16::from_le_bytes([data[char_offset], data[char_offset + 1]]);
338                utf16_chars.push(char_val);
339            }
340            display_text = String::from_utf16(&utf16_chars).map_err(|_| {
341                HwpError::InvalidFormat("Invalid UTF-16 in display text".to_string())
342            })?;
343            offset += display_text_len * 2;
344        }
345
346        // Read target URL length (2 bytes)
347        if offset + 2 > data.len() {
348            return Err(HwpError::InvalidFormat(
349                "Not enough data for URL length".to_string(),
350            ));
351        }
352        let target_url_len = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
353        offset += 2;
354
355        // Read target URL (UTF-16)
356        let mut target_url = String::new();
357        if target_url_len > 0 {
358            if offset + target_url_len * 2 > data.len() {
359                return Err(HwpError::InvalidFormat(
360                    "Not enough data for target URL".to_string(),
361                ));
362            }
363            let mut utf16_chars = Vec::new();
364            for i in 0..target_url_len {
365                let char_offset = offset + i * 2;
366                let char_val = u16::from_le_bytes([data[char_offset], data[char_offset + 1]]);
367                utf16_chars.push(char_val);
368            }
369            target_url = String::from_utf16(&utf16_chars)
370                .map_err(|_| HwpError::InvalidFormat("Invalid UTF-16 in target URL".to_string()))?;
371            offset += target_url_len * 2;
372        }
373
374        // Read tooltip length (2 bytes)
375        let tooltip = if offset + 2 <= data.len() {
376            let tooltip_len = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
377            offset += 2;
378
379            if tooltip_len > 0 && offset + tooltip_len * 2 <= data.len() {
380                let mut utf16_chars = Vec::new();
381                for i in 0..tooltip_len {
382                    let char_offset = offset + i * 2;
383                    let char_val = u16::from_le_bytes([data[char_offset], data[char_offset + 1]]);
384                    utf16_chars.push(char_val);
385                }
386                String::from_utf16(&utf16_chars).ok()
387            } else {
388                None
389            }
390        } else {
391            None
392        };
393
394        Ok(Self {
395            hyperlink_type,
396            display_text,
397            target_url,
398            tooltip,
399            display_mode,
400            text_color,
401            visited_color,
402            underline,
403            visited,
404            open_in_new_window,
405            start_position,
406            length,
407        })
408    }
409}
410
411/// 미리 정의된 하이퍼링크 스타일들
412impl Hyperlink {
413    /// 기본 웹 링크
414    pub fn web_link(text: &str, url: &str) -> Self {
415        Self::new_url(text, url)
416            .with_text_color(0x0000FF) // Blue
417            .with_underline(true)
418    }
419
420    /// 이메일 링크
421    pub fn email_link(text: &str, email: &str) -> Self {
422        Self::new_email(text, email)
423            .with_text_color(0x0000FF) // Blue
424            .with_underline(true)
425    }
426
427    /// 파일 링크
428    pub fn file_link(text: &str, file_path: &str) -> Self {
429        Self::new_file(text, file_path)
430            .with_text_color(0x008000) // Green
431            .with_underline(true)
432    }
433
434    /// 문서 내 링크
435    pub fn internal_link(text: &str, bookmark: &str) -> Self {
436        Self::new_bookmark(text, bookmark)
437            .with_text_color(0x800080) // Purple
438            .with_underline(true)
439    }
440
441    /// 밑줄 없는 링크
442    pub fn plain_link(text: &str, url: &str) -> Self {
443        Self::new_url(text, url)
444            .with_text_color(0x0000FF) // Blue
445            .with_underline(false)
446    }
447
448    /// 새 창에서 열리는 링크
449    pub fn external_link(text: &str, url: &str) -> Self {
450        Self::new_url(text, url)
451            .with_text_color(0x0000FF) // Blue
452            .with_underline(true)
453            .with_new_window(true)
454    }
455}