Skip to main content

ebook_rs/
rtf.rs

1use crate::archive::EpubArchive;
2use crate::book::Book;
3use crate::deobfuscate::FontDeobfuscator;
4use crate::error::EbookError;
5use crate::layout::RenditionLayout;
6use crate::metadata::{Metadata, PageProgressionDirection, SpineItem};
7use crate::nav::NavPoint;
8use crate::opf::OpfPackage;
9use crate::section::Section;
10use ahash::AHashMap;
11
12/// Rich Text Format (.rtf) document parser engine.
13pub struct RtfBook;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16struct RtfState {
17    bold: bool,
18    italic: bool,
19    underline: bool,
20    strike: bool,
21    is_destination: bool,
22    dest_name: String,
23    uc: usize,
24}
25
26impl Default for RtfState {
27    fn default() -> Self {
28        Self {
29            bold: false,
30            italic: false,
31            underline: false,
32            strike: false,
33            is_destination: false,
34            dest_name: String::new(),
35            uc: 1,
36        }
37    }
38}
39
40impl RtfBook {
41    /// Parse Rich Text Format (.rtf) byte slice into a unified `Book` instance.
42    pub fn parse(bytes: &[u8], title_fallback: &str) -> Result<Book, EbookError> {
43        let text = match std::str::from_utf8(bytes) {
44            Ok(s) => s.to_string(),
45            Err(_) => String::from_utf8_lossy(bytes).to_string(),
46        };
47
48        if !text.starts_with("{\\rtf") && !text.contains("{\\rtf") {
49            return Err(EbookError::InvalidFormat(
50                "Not a valid RTF document (missing {\\rtf header)".to_string(),
51            ));
52        }
53
54        let mut archive = EpubArchive::empty();
55        let mut title = title_fallback.to_string();
56        let mut creators = Vec::new();
57        let mut description = None;
58
59        let mut sections: Vec<Section> = Vec::new();
60        let mut spine: Vec<SpineItem> = Vec::new();
61        let mut toc: Vec<NavPoint> = Vec::new();
62
63        let mut current_html = String::new();
64        let mut current_text = String::new();
65        let mut cur_run = String::new();
66        let mut section_index = 0;
67        let mut img_counter = 0;
68
69        let chars: Vec<char> = text.chars().collect();
70        let len = chars.len();
71        let mut i = 0;
72
73        let mut state_stack: Vec<RtfState> = vec![RtfState::default()];
74        let mut cur_state = RtfState::default();
75
76        let mut dest_buffer = String::new();
77        let mut in_pict = false;
78        let mut pict_hex = String::new();
79        let mut pict_type = "png";
80
81        let mut in_table = false;
82        let mut cell_start_idx = 0;
83        let mut table_cells: Vec<String> = Vec::new();
84
85        let flush_run =
86            |html: &mut String, text: &mut String, run: &mut String, state: &RtfState| {
87                if run.is_empty() {
88                    return;
89                }
90                let mut formatted = xml_escape(run);
91                if state.strike {
92                    formatted = format!("<s>{}</s>", formatted);
93                }
94                if state.underline {
95                    formatted = format!("<u>{}</u>", formatted);
96                }
97                if state.italic {
98                    formatted = format!("<em>{}</em>", formatted);
99                }
100                if state.bold {
101                    formatted = format!("<strong>{}</strong>", formatted);
102                }
103                html.push_str(&formatted);
104                text.push_str(run);
105                run.clear();
106            };
107
108        let flush_section = |sec_idx: usize,
109                             cur_html: &str,
110                             cur_txt: &str,
111                             sections: &mut Vec<Section>,
112                             spine: &mut Vec<SpineItem>,
113                             toc: &mut Vec<NavPoint>| {
114            if cur_txt.trim().is_empty() && !sections.is_empty() {
115                return;
116            }
117            let href = format!("section_{}.html", sec_idx);
118            let full_html = format!("<div class=\"rtf-section\">\n{}\n</div>", cur_html);
119            let char_count = cur_txt.chars().count();
120            let plain_text_lower = cur_txt.to_lowercase();
121
122            sections.push(Section {
123                index: sec_idx,
124                idref: format!("sec_{}", sec_idx),
125                href: href.clone(),
126                full_path: href.clone(),
127                raw_html: full_html.clone(),
128                processed_html: full_html,
129                plain_text: cur_txt.to_string(),
130                plain_text_lower,
131                char_count,
132                viewport_width: None,
133                viewport_height: None,
134            });
135
136            spine.push(SpineItem {
137                idref: format!("sec_{}", sec_idx),
138                linear: true,
139                properties: Vec::new(),
140                index: sec_idx,
141                href: href.clone(),
142                media_type: "application/xhtml+xml".to_string(),
143            });
144
145            toc.push(NavPoint {
146                id: format!("nav_{}", toc.len() + 1),
147                label: format!("Section {}", sec_idx + 1),
148                href: href.clone(),
149                full_path: href,
150                subitems: Vec::new(),
151            });
152        };
153
154        while i < len {
155            match chars[i] {
156                '{' => {
157                    flush_run(
158                        &mut current_html,
159                        &mut current_text,
160                        &mut cur_run,
161                        &cur_state,
162                    );
163                    state_stack.push(cur_state.clone());
164                    cur_state.is_destination = false;
165                    cur_state.dest_name.clear();
166                    i += 1;
167                }
168                '}' => {
169                    flush_run(
170                        &mut current_html,
171                        &mut current_text,
172                        &mut cur_run,
173                        &cur_state,
174                    );
175                    if in_pict {
176                        if !pict_hex.is_empty() {
177                            if let Ok(bin) = hex_to_bytes(&pict_hex) {
178                                img_counter += 1;
179                                let img_name = format!("images/img_{}.{}", img_counter, pict_type);
180                                archive.insert(&img_name, bin);
181                                current_html.push_str(&format!(
182                                    "<p><img src=\"{}\" alt=\"image\" /></p>\n",
183                                    img_name
184                                ));
185                            }
186                        }
187                        in_pict = false;
188                        pict_hex.clear();
189                    }
190
191                    if cur_state.is_destination {
192                        let dest = cur_state.dest_name.as_str();
193                        let buf_clean = dest_buffer.trim().to_string();
194                        if !buf_clean.is_empty() {
195                            match dest {
196                                "title" => title = buf_clean,
197                                "author" => creators.push(buf_clean),
198                                "doccomm" | "subject" => description = Some(buf_clean),
199                                _ => {}
200                            }
201                        }
202                        dest_buffer.clear();
203                    }
204
205                    if let Some(prev) = state_stack.pop() {
206                        cur_state = prev;
207                    }
208                    i += 1;
209                }
210                '\\' => {
211                    i += 1;
212                    if i >= len {
213                        break;
214                    }
215
216                    // Special escaped characters
217                    match chars[i] {
218                        '{' | '}' | '\\' => {
219                            let c = chars[i];
220                            if cur_state.is_destination {
221                                dest_buffer.push(c);
222                            } else if in_pict {
223                                pict_hex.push(c);
224                            } else {
225                                cur_run.push(c);
226                            }
227                            i += 1;
228                            continue;
229                        }
230                        '\'' => {
231                            // Hex escape \'XX
232                            if i + 2 < len {
233                                let hex_str: String = chars[i + 1..=i + 2].iter().collect();
234                                if let Ok(byte) = u8::from_str_radix(&hex_str, 16) {
235                                    let s = if byte < 0x80 {
236                                        (byte as char).to_string()
237                                    } else {
238                                        let byte_arr = [byte];
239                                        let (cow, _) = encoding_rs::WINDOWS_1252
240                                            .decode_without_bom_handling(&byte_arr);
241                                        cow.to_string()
242                                    };
243                                    if cur_state.is_destination {
244                                        dest_buffer.push_str(&s);
245                                    } else {
246                                        cur_run.push_str(&s);
247                                    }
248                                }
249                                i += 3;
250                                continue;
251                            }
252                        }
253                        '*' => {
254                            // Ignorable destination marker
255                            cur_state.is_destination = true;
256                            i += 1;
257                            continue;
258                        }
259                        _ => {}
260                    }
261
262                    // Read control word [a-zA-Z]+
263                    let mut word = String::new();
264                    while i < len && chars[i].is_ascii_alphabetic() {
265                        word.push(chars[i]);
266                        i += 1;
267                    }
268
269                    // Read optional numeric parameter
270                    let mut is_neg = false;
271                    if i < len && chars[i] == '-' {
272                        is_neg = true;
273                        i += 1;
274                    }
275                    let mut param_str = String::new();
276                    while i < len && chars[i].is_ascii_digit() {
277                        param_str.push(chars[i]);
278                        i += 1;
279                    }
280                    let param: Option<i32> = if !param_str.is_empty() {
281                        let p = param_str.parse::<i32>().unwrap_or(0);
282                        Some(if is_neg { -p } else { p })
283                    } else {
284                        None
285                    };
286
287                    // Optional trailing space delimiter after control word
288                    if i < len && chars[i] == ' ' {
289                        i += 1;
290                    }
291
292                    match word.as_str() {
293                        "b" => {
294                            let next_bold = param != Some(0);
295                            if next_bold != cur_state.bold {
296                                flush_run(
297                                    &mut current_html,
298                                    &mut current_text,
299                                    &mut cur_run,
300                                    &cur_state,
301                                );
302                                cur_state.bold = next_bold;
303                            }
304                        }
305                        "i" => {
306                            let next_italic = param != Some(0);
307                            if next_italic != cur_state.italic {
308                                flush_run(
309                                    &mut current_html,
310                                    &mut current_text,
311                                    &mut cur_run,
312                                    &cur_state,
313                                );
314                                cur_state.italic = next_italic;
315                            }
316                        }
317                        "ul" => {
318                            let next_ul = param != Some(0);
319                            if next_ul != cur_state.underline {
320                                flush_run(
321                                    &mut current_html,
322                                    &mut current_text,
323                                    &mut cur_run,
324                                    &cur_state,
325                                );
326                                cur_state.underline = next_ul;
327                            }
328                        }
329                        "ulnone" => {
330                            if cur_state.underline {
331                                flush_run(
332                                    &mut current_html,
333                                    &mut current_text,
334                                    &mut cur_run,
335                                    &cur_state,
336                                );
337                                cur_state.underline = false;
338                            }
339                        }
340                        "strike" => {
341                            let next_strike = param != Some(0);
342                            if next_strike != cur_state.strike {
343                                flush_run(
344                                    &mut current_html,
345                                    &mut current_text,
346                                    &mut cur_run,
347                                    &cur_state,
348                                );
349                                cur_state.strike = next_strike;
350                            }
351                        }
352                        "par" => {
353                            flush_run(
354                                &mut current_html,
355                                &mut current_text,
356                                &mut cur_run,
357                                &cur_state,
358                            );
359                            if !cur_state.is_destination && !in_pict {
360                                current_html.push_str("<br/>\n");
361                                current_text.push('\n');
362                            }
363                        }
364                        "line" => {
365                            flush_run(
366                                &mut current_html,
367                                &mut current_text,
368                                &mut cur_run,
369                                &cur_state,
370                            );
371                            if !cur_state.is_destination && !in_pict {
372                                current_html.push_str("<br/>");
373                                current_text.push('\n');
374                            }
375                        }
376                        "page" => {
377                            flush_run(
378                                &mut current_html,
379                                &mut current_text,
380                                &mut cur_run,
381                                &cur_state,
382                            );
383                            if !cur_state.is_destination && !in_pict && !current_text.is_empty() {
384                                flush_section(
385                                    section_index,
386                                    &current_html,
387                                    &current_text,
388                                    &mut sections,
389                                    &mut spine,
390                                    &mut toc,
391                                );
392                                section_index += 1;
393                                current_html.clear();
394                                current_text.clear();
395                            }
396                        }
397                        "tab" => {
398                            flush_run(
399                                &mut current_html,
400                                &mut current_text,
401                                &mut cur_run,
402                                &cur_state,
403                            );
404                            if !cur_state.is_destination && !in_pict {
405                                current_html.push_str("&emsp;");
406                                current_text.push('\t');
407                            }
408                        }
409                        "uc" => {
410                            if let Some(n) = param {
411                                cur_state.uc = (n.max(0)) as usize;
412                            }
413                        }
414                        "u" => {
415                            if let Some(code) = param {
416                                let unsigned_code = if code < 0 {
417                                    (code + 65536) as u32
418                                } else {
419                                    code as u32
420                                };
421                                if let Some(ch) = char::from_u32(unsigned_code) {
422                                    if cur_state.is_destination {
423                                        dest_buffer.push(ch);
424                                    } else {
425                                        cur_run.push(ch);
426                                    }
427                                }
428                                // Skip optional fallback chars according to \ucN (including \'hh hex escapes)
429                                let mut skipped = 0;
430                                while i < len && skipped < cur_state.uc {
431                                    if chars[i] == '{' || chars[i] == '}' {
432                                        break;
433                                    }
434                                    if chars[i] == '\\' {
435                                        if i + 3 < len && chars[i + 1] == '\'' {
436                                            i += 4;
437                                            skipped += 1;
438                                            continue;
439                                        } else {
440                                            break;
441                                        }
442                                    }
443                                    i += 1;
444                                    skipped += 1;
445                                }
446                            }
447                        }
448                        "info" | "title" | "author" | "subject" | "doccomm" | "keywords"
449                        | "fonttbl" | "colortbl" | "stylesheet" => {
450                            flush_run(
451                                &mut current_html,
452                                &mut current_text,
453                                &mut cur_run,
454                                &cur_state,
455                            );
456                            cur_state.is_destination = true;
457                            cur_state.dest_name = word;
458                        }
459                        "pict" => {
460                            flush_run(
461                                &mut current_html,
462                                &mut current_text,
463                                &mut cur_run,
464                                &cur_state,
465                            );
466                            in_pict = true;
467                            pict_hex.clear();
468                        }
469                        "pngblip" => pict_type = "png",
470                        "jpegblip" => pict_type = "jpg",
471                        "trowd" => {
472                            flush_run(
473                                &mut current_html,
474                                &mut current_text,
475                                &mut cur_run,
476                                &cur_state,
477                            );
478                            in_table = true;
479                            table_cells.clear();
480                            cell_start_idx = current_html.len();
481                        }
482                        "cell" => {
483                            flush_run(
484                                &mut current_html,
485                                &mut current_text,
486                                &mut cur_run,
487                                &cur_state,
488                            );
489                            if in_table && current_html.len() >= cell_start_idx {
490                                let cell_content = current_html.split_off(cell_start_idx);
491                                table_cells.push(cell_content);
492                                cell_start_idx = current_html.len();
493                            }
494                        }
495                        "row" => {
496                            flush_run(
497                                &mut current_html,
498                                &mut current_text,
499                                &mut cur_run,
500                                &cur_state,
501                            );
502                            if in_table {
503                                if current_html.len() > cell_start_idx {
504                                    let cell_content = current_html.split_off(cell_start_idx);
505                                    table_cells.push(cell_content);
506                                }
507                                current_html.push_str("<table>\n<tr>\n");
508                                for cell in &table_cells {
509                                    current_html.push_str(&format!("  <td>{}</td>\n", cell));
510                                }
511                                current_html.push_str("</tr>\n</table>\n");
512                                table_cells.clear();
513                                in_table = false;
514                            }
515                        }
516                        _ => {}
517                    }
518                }
519                c => {
520                    if in_pict {
521                        if c.is_ascii_hexdigit() {
522                            pict_hex.push(c);
523                        }
524                    } else if cur_state.is_destination {
525                        dest_buffer.push(c);
526                    } else if c != '\r' && c != '\n' {
527                        cur_run.push(c);
528                    }
529                    i += 1;
530                }
531            }
532        }
533
534        // Flush remaining run and trailing section
535        flush_run(
536            &mut current_html,
537            &mut current_text,
538            &mut cur_run,
539            &cur_state,
540        );
541        flush_section(
542            section_index,
543            &current_html,
544            &current_text,
545            &mut sections,
546            &mut spine,
547            &mut toc,
548        );
549
550        let metadata = Metadata {
551            title,
552            creators,
553            publishers: Vec::new(),
554            languages: vec!["en".to_string()],
555            rights: None,
556            description,
557            identifier: None,
558            pub_date: None,
559            modified_date: None,
560            subjects: vec!["Document".to_string()],
561            cover_id: None,
562            cover_href: None,
563            direction: PageProgressionDirection::Ltr,
564            meta_properties: AHashMap::new(),
565            accessibility: Default::default(),
566        };
567
568        let opf = OpfPackage {
569            version: "3.0".to_string(),
570            opf_path: "content.opf".to_string(),
571            opf_dir: "".to_string(),
572            metadata,
573            manifest: AHashMap::new(),
574            spine,
575            guide: Vec::new(),
576            toc_item_id: None,
577            nav_item_id: None,
578        };
579
580        let mut book = Book {
581            archive,
582            opf,
583            layout: RenditionLayout::default(),
584            toc,
585            landmarks: Vec::new(),
586            page_list: Vec::new(),
587            sections,
588            locations: crate::locations::Locations::default(),
589            annotations: crate::annotations::AnnotationManager::default(),
590            before_display_hooks: Vec::new(),
591            font_deobfuscator: FontDeobfuscator::parse_encryption_xml(""),
592            media_overlays: AHashMap::new(),
593            render_cache: parking_lot::Mutex::new(AHashMap::new()),
594        };
595
596        book.generate_locations(1000);
597        Ok(book)
598    }
599}
600
601fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
602    let clean: String = hex.chars().filter(|c| c.is_ascii_hexdigit()).collect();
603    if !clean.len().is_multiple_of(2) {
604        return Err("Invalid hex length".to_string());
605    }
606    let mut bytes = Vec::with_capacity(clean.len() / 2);
607    let chars: Vec<char> = clean.chars().collect();
608    for chunk in chars.chunks(2) {
609        let pair: String = chunk.iter().collect();
610        let b = u8::from_str_radix(&pair, 16).map_err(|e| e.to_string())?;
611        bytes.push(b);
612    }
613    Ok(bytes)
614}
615
616fn xml_escape(input: &str) -> String {
617    input
618        .replace('&', "&amp;")
619        .replace('<', "&lt;")
620        .replace('>', "&gt;")
621        .replace('"', "&quot;")
622        .replace('\'', "&apos;")
623}