use super::paragraph_props::ParagraphProp;
use super::piece_table::ReconstructedText;
use super::stylesheet::{StyleKind, StyleSheet};
use super::tables::{CellMark, DocTable, TableShape};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParagraphType {
Heading(u8),
Normal,
Table,
ListItem,
PageBreak,
}
#[derive(Debug, Clone)]
pub struct DocParagraph {
pub content: String,
pub paragraph_type: ParagraphType,
pub heading_level: Option<u8>,
pub page_index: Option<usize>,
pub list_level: Option<u8>,
pub table: Option<TableShape>,
}
impl DocParagraph {
pub fn plain(content: String, paragraph_type: ParagraphType, page_index: Option<usize>) -> Self {
let heading_level = match paragraph_type {
ParagraphType::Heading(level) => Some(level),
_ => None,
};
DocParagraph {
content,
paragraph_type,
heading_level,
page_index,
list_level: None,
table: None,
}
}
}
pub(super) fn collapse_whitespace(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut in_space = false;
for ch in text.chars() {
if ch.is_whitespace() {
if !in_space {
out.push(' ');
in_space = true;
}
} else {
out.push(ch);
in_space = false;
}
}
out.trim().to_string()
}
fn looks_like_fallback_heading(content: &str) -> bool {
let trimmed = content.trim();
if trimmed.is_empty() {
return false;
}
let words: Vec<&str> = trimmed.split_whitespace().collect();
if words.is_empty() || words.len() > 10 {
return false;
}
if trimmed.chars().count() < 2 {
return false;
}
if trimmed.ends_with('.') || trimmed.ends_with('!') || trimmed.ends_with('?') {
return false;
}
let has_alpha = trimmed.chars().any(|c| c.is_alphabetic());
if !has_alpha {
return false;
}
let all_caps = trimmed
.chars()
.filter(|c| c.is_alphabetic())
.all(|c| c.is_uppercase());
let title_case = words
.iter()
.all(|w| w.chars().next().map(|c| c.is_uppercase()).unwrap_or(false));
all_caps || title_case
}
struct Unit<'a> {
text: &'a str,
is_cell_mark: bool,
}
fn split_units(text: &str) -> Vec<Unit<'_>> {
let mut out = Vec::new();
let mut start = 0usize;
for (idx, ch) in text.char_indices() {
if ch == '\r' || ch == '\x07' {
out.push(Unit {
text: &text[start..idx],
is_cell_mark: ch == '\x07',
});
start = idx + ch.len_utf8();
}
}
out.push(Unit {
text: &text[start..],
is_cell_mark: false,
});
out
}
fn is_in_table(unit: &Unit<'_>, prop: Option<&ParagraphProp>) -> bool {
unit.is_cell_mark || prop.map(|p| p.f_in_table).unwrap_or(false)
}
pub fn extract_paragraphs(
story: &ReconstructedText,
props: &[Option<ParagraphProp>],
stylesheet: &StyleSheet,
) -> Vec<DocParagraph> {
extract_paragraphs_indexed(story, props, stylesheet)
.into_iter()
.map(|(_, p)| p)
.collect()
}
pub fn extract_paragraphs_indexed(
story: &ReconstructedText,
props: &[Option<ParagraphProp>],
stylesheet: &StyleSheet,
) -> Vec<(usize, DocParagraph)> {
let units = split_units(&story.text);
let mut out = Vec::new();
let declares_pages = units.iter().any(|u| u.text.starts_with('\x0C'));
let mut page_index = 0usize;
let mut idx = 0usize;
while idx < units.len() {
let unit = &units[idx];
let prop = props.get(idx).and_then(Option::as_ref);
if unit.text.starts_with('\x0C') {
out.push((
idx,
DocParagraph::plain(String::new(), ParagraphType::PageBreak, Some(page_index)),
));
page_index += 1;
idx += 1;
continue;
}
if is_in_table(unit, prop) {
let (table, consumed) = collect_table(&units, props, idx);
idx += consumed;
if !table.is_empty() {
let shape = table.shape();
out.push((
idx - consumed,
DocParagraph {
content: table.to_markdown(),
paragraph_type: ParagraphType::Table,
heading_level: None,
page_index: declares_pages.then_some(page_index),
list_level: None,
table: Some(shape),
},
));
}
continue;
}
if let Some(paragraph) = classify_paragraph(unit.text, prop, stylesheet) {
out.push((
idx,
DocParagraph {
page_index: declares_pages.then_some(page_index),
..paragraph
},
));
}
idx += 1;
}
out
}
fn collect_table(
units: &[Unit<'_>],
props: &[Option<ParagraphProp>],
start: usize,
) -> (DocTable, usize) {
let mut cells: Vec<(&str, CellMark)> = Vec::new();
let mut idx = start;
while idx < units.len() {
let unit = &units[idx];
let prop = props.get(idx).and_then(Option::as_ref);
if !is_in_table(unit, prop) {
break;
}
let mark = if !unit.is_cell_mark {
CellMark::Line
} else if prop.map(|p| p.f_tt_p).unwrap_or(false) {
CellMark::EndOfRow
} else {
CellMark::EndOfCell
};
cells.push((unit.text, mark));
idx += 1;
}
if !cells.iter().any(|(_, m)| *m == CellMark::EndOfRow) {
for entry in cells.iter_mut() {
if entry.0.trim().is_empty() && entry.1 == CellMark::EndOfCell {
entry.1 = CellMark::EndOfRow;
}
}
}
(DocTable::from_units(cells), idx - start)
}
fn classify_paragraph(
raw: &str,
prop: Option<&ParagraphProp>,
stylesheet: &StyleSheet,
) -> Option<DocParagraph> {
let style = prop.map(|p| stylesheet.kind(p.istd as usize));
let mut paragraph_type = ParagraphType::Normal;
let mut heading_level = None;
let mut list_level = None;
if let Some(p) = prop {
if p.ilfo > 0 || matches!(style, Some(StyleKind::ListParagraph)) {
paragraph_type = ParagraphType::ListItem;
list_level = Some(p.ilvl);
}
}
if matches!(paragraph_type, ParagraphType::Normal) {
if let Some(StyleKind::Heading(level)) = style {
paragraph_type = ParagraphType::Heading(level);
heading_level = Some(level);
}
}
let content = collapse_whitespace(&raw.replace(['\x07', '\x0C'], " "));
if matches!(paragraph_type, ParagraphType::Normal) && content.is_empty() {
return None;
}
if matches!(paragraph_type, ParagraphType::Normal) && looks_like_fallback_heading(&content) {
paragraph_type = ParagraphType::Heading(2);
heading_level = Some(2);
}
Some(DocParagraph {
content,
paragraph_type,
heading_level,
page_index: None,
list_level,
table: None,
})
}
#[cfg(test)]
mod tests {
use super::super::piece_table::{reconstruct_from_pieces, Piece};
use super::*;
fn story_of(text: &str) -> ReconstructedText {
let bytes: Vec<u8> = text.chars().map(|c| c as u8).collect();
let pieces = vec![Piece {
cp_start: 0,
cp_end: bytes.len() as u32,
fc: 0,
compressed: true,
}];
reconstruct_from_pieces(&bytes, &pieces)
}
fn empty_styles() -> StyleSheet {
StyleSheet { styles: Vec::new() }
}
fn heading_styles() -> StyleSheet {
StyleSheet {
styles: vec![
StyleKind::Normal,
StyleKind::Heading(1),
StyleKind::Heading(2),
StyleKind::Heading(3),
],
}
}
fn prop(istd: u16) -> Option<ParagraphProp> {
Some(ParagraphProp {
istd,
..Default::default()
})
}
#[test]
fn heading_level_comes_from_the_style() {
let story = story_of("Chapter One\rSection Two\rbody text here.\r");
let props = vec![prop(1), prop(2), prop(0), None];
let paragraphs = extract_paragraphs(&story, &props, &heading_styles());
let kinds: Vec<_> = paragraphs.iter().map(|p| p.paragraph_type.clone()).collect();
assert_eq!(
kinds,
vec![
ParagraphType::Heading(1),
ParagraphType::Heading(2),
ParagraphType::Normal
]
);
assert_eq!(paragraphs[0].heading_level, Some(1));
}
#[test]
fn a_list_paragraph_reports_its_nesting_depth() {
let story = story_of("Top level\rNested\rDeeper\r");
let props = vec![
Some(ParagraphProp { ilfo: 1, ilvl: 0, ..Default::default() }),
Some(ParagraphProp { ilfo: 1, ilvl: 1, ..Default::default() }),
Some(ParagraphProp { ilfo: 1, ilvl: 2, ..Default::default() }),
None,
];
let paragraphs = extract_paragraphs(&story, &props, &empty_styles());
assert!(paragraphs
.iter()
.all(|p| p.paragraph_type == ParagraphType::ListItem));
assert_eq!(
paragraphs.iter().map(|p| p.list_level).collect::<Vec<_>>(),
vec![Some(0), Some(1), Some(2)]
);
}
#[test]
fn a_table_is_rebuilt_into_rows_and_columns() {
let story = story_of("a\x07b\x07\x07c\x07d\x07\x07after\r");
let cell = ParagraphProp { f_in_table: true, ..Default::default() };
let row_end = ParagraphProp { f_in_table: true, f_tt_p: true, ..Default::default() };
let props = vec![
Some(cell.clone()),
Some(cell.clone()),
Some(row_end.clone()),
Some(cell.clone()),
Some(cell),
Some(row_end),
None,
None,
];
let paragraphs = extract_paragraphs(&story, &props, &empty_styles());
assert_eq!(paragraphs.len(), 2, "one table paragraph, then the body text");
let table = ¶graphs[0];
assert_eq!(table.paragraph_type, ParagraphType::Table);
assert_eq!(
table.table,
Some(TableShape { rows: 2, columns: 2, cells: 4 })
);
assert_eq!(table.content, "| a | b |\n| --- | --- |\n| c | d |");
assert_eq!(paragraphs[1].content, "after");
}
#[test]
fn a_table_without_properties_falls_back_to_the_empty_row_mark() {
let story = story_of("a\x07b\x07\x07c\x07d\x07\x07");
let props: Vec<Option<ParagraphProp>> = vec![None; 7];
let paragraphs = extract_paragraphs(&story, &props, &empty_styles());
assert_eq!(paragraphs.len(), 1);
assert_eq!(
paragraphs[0].table,
Some(TableShape { rows: 2, columns: 2, cells: 4 })
);
}
#[test]
fn a_drop_cap_is_not_a_heading() {
let story = story_of("INTRODUCTION\rT\rHIS document is a template.\r");
let props: Vec<Option<ParagraphProp>> = vec![None; 4];
let paragraphs = extract_paragraphs(&story, &props, &empty_styles());
assert_eq!(paragraphs[0].paragraph_type, ParagraphType::Heading(2));
assert_eq!(
paragraphs[1].paragraph_type,
ParagraphType::Normal,
"a one-character paragraph is a drop cap"
);
}
#[test]
fn a_page_break_still_advances_the_page_ordinal() {
let story = story_of("first page body\r\x0C\rsecond page body\r");
let props: Vec<Option<ParagraphProp>> = vec![None; 4];
let paragraphs = extract_paragraphs(&story, &props, &empty_styles());
let pages: Vec<_> = paragraphs.iter().map(|p| p.page_index).collect();
assert_eq!(pages, vec![Some(0), Some(0), Some(1)]);
}
}