mod control_word;
mod formatting_extract;
mod text_extract;
pub(crate) use formatting_extract::{extract_rtf_formatting, spans_to_annotations};
pub(crate) use text_extract::extract_text_from_rtf;
use crate::extractors::rtf::encoding::{fcharset_to_codepage, parse_hex_byte, parse_rtf_control_word};
use crate::extractors::rtf::formatting::map_offset;
use std::collections::HashMap;
#[cfg_attr(alef, alef(skip))]
#[derive(Debug, Clone, Default)]
pub struct ParagraphMeta {
pub heading_level: u8,
pub list_level: Option<u8>,
pub list_id: Option<u16>,
pub is_table: bool,
pub ordered: bool,
}
#[derive(Debug, Clone)]
pub struct RtfFormattingSpan {
pub start: usize,
pub end: usize,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub strikethrough: bool,
pub color_index: u16,
}
pub struct RtfFormattingData {
pub spans: Vec<RtfFormattingSpan>,
pub color_table: Vec<String>,
pub header_text: Option<String>,
pub footer_text: Option<String>,
pub hyperlinks: Vec<(usize, usize, String)>,
}
#[derive(Clone, Default)]
struct FmtState {
bold: bool,
italic: bool,
underline: bool,
strikethrough: bool,
color_idx: u16,
}
struct FormattingTracker {
fmt: FmtState,
fmt_stack: Vec<FmtState>,
span_start: usize,
spans: Vec<RtfFormattingSpan>,
}
impl FormattingTracker {
fn new() -> Self {
Self {
fmt: FmtState::default(),
fmt_stack: Vec::new(),
span_start: 0,
spans: Vec::new(),
}
}
fn push(&mut self) {
self.fmt_stack.push(self.fmt.clone());
}
fn pop(&mut self, text_offset: usize) {
if let Some(parent) = self.fmt_stack.pop() {
let changed = self.fmt.bold != parent.bold
|| self.fmt.italic != parent.italic
|| self.fmt.underline != parent.underline
|| self.fmt.strikethrough != parent.strikethrough
|| self.fmt.color_idx != parent.color_idx;
if changed {
if text_offset > self.span_start {
self.spans.push(RtfFormattingSpan {
start: self.span_start,
end: text_offset,
bold: self.fmt.bold,
italic: self.fmt.italic,
underline: self.fmt.underline,
strikethrough: self.fmt.strikethrough,
color_index: self.fmt.color_idx,
});
}
self.span_start = text_offset;
self.fmt = parent;
}
}
}
fn update_bold(&mut self, text_offset: usize, val: bool) {
if val != self.fmt.bold {
self.close_span(text_offset);
self.fmt.bold = val;
}
}
fn update_italic(&mut self, text_offset: usize, val: bool) {
if val != self.fmt.italic {
self.close_span(text_offset);
self.fmt.italic = val;
}
}
fn update_underline(&mut self, text_offset: usize, val: bool) {
if val != self.fmt.underline {
self.close_span(text_offset);
self.fmt.underline = val;
}
}
fn update_strikethrough(&mut self, text_offset: usize, val: bool) {
if val != self.fmt.strikethrough {
self.close_span(text_offset);
self.fmt.strikethrough = val;
}
}
fn update_color(&mut self, text_offset: usize, val: u16) {
if val != self.fmt.color_idx {
self.close_span(text_offset);
self.fmt.color_idx = val;
}
}
fn reset_all(&mut self, text_offset: usize) {
if self.fmt.bold || self.fmt.italic || self.fmt.underline || self.fmt.strikethrough || self.fmt.color_idx != 0 {
self.close_span(text_offset);
self.fmt = FmtState::default();
}
}
fn close_span(&mut self, text_offset: usize) {
if text_offset > self.span_start {
self.spans.push(RtfFormattingSpan {
start: self.span_start,
end: text_offset,
bold: self.fmt.bold,
italic: self.fmt.italic,
underline: self.fmt.underline,
strikethrough: self.fmt.strikethrough,
color_index: self.fmt.color_idx,
});
}
self.span_start = text_offset;
}
fn finalize(&mut self, text_offset: usize) {
if text_offset > self.span_start
&& (self.fmt.bold
|| self.fmt.italic
|| self.fmt.underline
|| self.fmt.strikethrough
|| self.fmt.color_idx != 0)
{
self.spans.push(RtfFormattingSpan {
start: self.span_start,
end: text_offset,
bold: self.fmt.bold,
italic: self.fmt.italic,
underline: self.fmt.underline,
strikethrough: self.fmt.strikethrough,
color_index: self.fmt.color_idx,
});
}
}
fn remap_spans(&mut self, mapping: &[(usize, usize)]) {
for span in &mut self.spans {
span.start = map_offset(mapping, span.start);
span.end = map_offset(mapping, span.end);
}
self.spans.retain(|s| s.start < s.end);
}
}
fn extract_balanced_group_body(rest: &str) -> String {
let mut depth = 0;
let mut table_content = String::new();
for ch in rest.chars() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
if depth > 0 {
table_content.push(ch);
}
}
table_content
}
fn parse_rtf_color_table(content: &str) -> Vec<String> {
let mut colors = Vec::new();
let Some(start) = content.find("{\\colortbl") else {
return colors;
};
let table_content = extract_balanced_group_body(&content[start..]);
let table_body = table_content.strip_prefix("{\\colortbl").unwrap_or(&table_content);
for entry in table_body.split(';') {
let entry = entry.trim();
if entry.is_empty() {
colors.push(String::new());
continue;
}
let mut r = 0u8;
let mut g = 0u8;
let mut b = 0u8;
for part in entry.split('\\') {
let part = part.trim();
if let Some(val) = part.strip_prefix("red") {
r = val.parse().unwrap_or(0);
} else if let Some(val) = part.strip_prefix("green") {
g = val.parse().unwrap_or(0);
} else if let Some(val) = part.strip_prefix("blue") {
b = val.parse().unwrap_or(0);
}
}
colors.push(format!("#{r:02x}{g:02x}{b:02x}"));
}
colors
}
fn parse_font_charset_table(content: &str) -> HashMap<u16, u32> {
let mut map = HashMap::new();
let Some(start) = content.find("{\\*\\fonttbl").or_else(|| content.find("{\\fonttbl")) else {
return map;
};
let table_content = extract_balanced_group_body(&content[start..]);
let mut chars = table_content.chars().peekable();
let mut entry_depth: i32 = 0;
let mut current_font_id: Option<u16> = None;
let mut current_fcharset: Option<u8> = None;
let mut current_cpg: Option<u32> = None;
while let Some(ch) = chars.next() {
match ch {
'{' => {
entry_depth += 1;
if entry_depth == 2 {
current_font_id = None;
current_fcharset = None;
current_cpg = None;
}
}
'}' => {
entry_depth -= 1;
if entry_depth == 1
&& let Some(id) = current_font_id
{
let codepage = if current_fcharset.is_some() {
current_fcharset.and_then(fcharset_to_codepage)
} else {
current_cpg
};
if let Some(cp) = codepage {
map.insert(id, cp);
}
}
}
'\\' => {
if entry_depth < 2 {
continue;
}
let (word, param) = parse_rtf_control_word(&mut chars);
match word.as_str() {
"f" => {
if let Some(val) = param {
current_font_id = Some(val.max(0) as u16);
}
}
"fcharset" => {
if let Some(val) = param {
current_fcharset = Some(val.max(0) as u8);
}
}
"cpg" => {
if let Some(val) = param
&& val > 0
{
current_cpg = Some(val as u32);
}
}
_ => {}
}
}
_ => {}
}
}
map
}
#[inline]
fn resolve_decode_codepage(
font_id_stack: &[Option<u16>],
default_font_id: Option<u16>,
font_charsets: &HashMap<u16, u32>,
ansi_codepage_stack: &[u32],
) -> u32 {
font_id_stack
.last()
.copied()
.flatten()
.or(default_font_id)
.and_then(|id| font_charsets.get(&id).copied())
.or_else(|| ansi_codepage_stack.last().copied())
.unwrap_or(1252)
}
fn close_fldinst_group(
group_depth: i32,
in_fldinst: &mut bool,
fldinst_depth: i32,
fldinst_content: &mut String,
pending_hyperlink_url: &mut Option<String>,
) {
if !*in_fldinst || group_depth >= fldinst_depth {
return;
}
*in_fldinst = false;
let trimmed = fldinst_content.trim();
if let Some(rest) = trimmed.strip_prefix("HYPERLINK") {
let url = rest.trim().trim_matches('"').trim().to_string();
let url = if let Some(bookmark) = url.strip_prefix("\\l ") {
format!("#{}", bookmark.trim().trim_matches('"'))
} else if let Some(bookmark) = url.strip_prefix("\\l\"") {
format!("#{}", bookmark.trim_matches('"'))
} else {
url
};
if !url.is_empty() {
*pending_hyperlink_url = Some(url);
}
}
fldinst_content.clear();
}
struct FldrsltCloseState<'a> {
in_fldrslt: &'a mut bool,
fldrslt_depth: i32,
fldrslt_start: usize,
pending_hyperlink_url: &'a mut Option<String>,
hyperlinks: &'a mut Vec<(usize, usize, String)>,
}
fn close_fldrslt_group(group_depth: i32, current_offset: usize, state: FldrsltCloseState) {
if !*state.in_fldrslt || group_depth >= state.fldrslt_depth {
return;
}
*state.in_fldrslt = false;
if let Some(url) = state.pending_hyperlink_url.take() {
state.hyperlinks.push((state.fldrslt_start, current_offset, url));
}
}
fn consume_adjacent_hex_escape(chars: &mut std::iter::Peekable<std::str::Chars>) -> Option<u8> {
let mut lookahead = chars.clone();
let mut skipped = 0usize;
while matches!(lookahead.peek(), Some('\r' | '\n')) {
lookahead.next();
skipped += 1;
}
if lookahead.next()? != '\\' || lookahead.next()? != '\'' {
return None;
}
let h1 = lookahead.next()?;
let h2 = lookahead.next()?;
let byte = parse_hex_byte(h1 as u8, h2 as u8)?;
for _ in 0..skipped + 4 {
chars.next();
}
Some(byte)
}