use crate::Result;
use crate::core::config::ExtractionConfig;
use crate::plugins::{InternalDocumentExtractor, Plugin};
use crate::types::Metadata;
use crate::types::document_structure::{AnnotationKind, ContentLayer, TextAnnotation};
use crate::types::internal::InternalDocument;
use crate::types::internal_builder::InternalDocumentBuilder;
use ahash::AHashMap;
use async_trait::async_trait;
use xberg_libwpd::{WpdDocument, WpdEvent, WpdMetadata};
#[cfg_attr(alef, alef(skip))]
pub struct WordPerfectExtractor;
impl WordPerfectExtractor {
pub(crate) fn new() -> Self {
Self
}
}
impl Default for WordPerfectExtractor {
fn default() -> Self {
Self::new()
}
}
impl Plugin for WordPerfectExtractor {
fn name(&self) -> &str {
"wordperfect-extractor"
}
fn version(&self) -> String {
env!("CARGO_PKG_VERSION").to_string()
}
fn initialize(&self) -> Result<()> {
Ok(())
}
fn shutdown(&self) -> Result<()> {
Ok(())
}
fn description(&self) -> &str {
"WordPerfect (.wpd) text extraction"
}
fn author(&self) -> &str {
"Xberg Team"
}
}
#[derive(Clone)]
enum SpanKind {
Bold,
Italic,
Underline,
Strikethrough,
Superscript,
Subscript,
Link(String),
}
struct OpenSpan {
start: u32,
kind: SpanKind,
}
#[derive(Default)]
struct Ctx {
text: String,
spans: Vec<OpenSpan>,
annotations: Vec<TextAnnotation>,
}
impl Ctx {
fn append(&mut self, s: &str) {
self.text.push_str(s);
}
fn open_span(&mut self, kind: SpanKind) {
let start = self.text.len() as u32;
self.spans.push(OpenSpan { start, kind });
}
fn record_span(&mut self, open: OpenSpan) {
let end = self.text.len() as u32;
if end > open.start {
self.annotations.push(TextAnnotation {
start: open.start,
end,
kind: span_to_annotation(open.kind),
});
}
}
fn close_span(&mut self, kind: &SpanKind) {
let target = std::mem::discriminant(kind);
if let Some(pos) = self
.spans
.iter()
.rposition(|s| std::mem::discriminant(&s.kind) == target)
{
let open = self.spans.remove(pos);
self.record_span(open);
}
}
fn close_open_spans(&mut self) {
for open in std::mem::take(&mut self.spans) {
self.record_span(open);
}
}
fn take(&mut self) -> (String, Vec<TextAnnotation>) {
self.close_open_spans();
let text = std::mem::take(&mut self.text);
let mut annotations = std::mem::take(&mut self.annotations);
merge_adjacent_annotations(&mut annotations);
(text, annotations)
}
}
fn span_to_annotation(kind: SpanKind) -> AnnotationKind {
match kind {
SpanKind::Bold => AnnotationKind::Bold,
SpanKind::Italic => AnnotationKind::Italic,
SpanKind::Underline => AnnotationKind::Underline,
SpanKind::Strikethrough => AnnotationKind::Strikethrough,
SpanKind::Superscript => AnnotationKind::Superscript,
SpanKind::Subscript => AnnotationKind::Subscript,
SpanKind::Link(href) => AnnotationKind::Link { url: href, title: None },
}
}
const MAX_TABLE_COL_SPAN: u32 = 4096;
enum DiversionKind {
Header,
Footer,
Note {
key: String,
},
InlineNote,
Aside {
kind: String,
},
}
struct Diversion {
kind: DiversionKind,
ctx: Ctx,
}
#[derive(Default)]
struct TableBuilder {
rows: Vec<Vec<String>>,
current_row: Vec<String>,
current_row_is_header: bool,
header_row_indices: Vec<usize>,
in_cell: bool,
cell_ctx: Ctx,
cell_col_span: u32,
}
impl TableBuilder {
fn start_row(&mut self, header: bool) {
self.current_row = Vec::new();
self.current_row_is_header = header;
}
fn start_cell(&mut self, col_span: u32) {
self.in_cell = true;
self.cell_ctx = Ctx::default();
self.cell_col_span = col_span.clamp(1, MAX_TABLE_COL_SPAN);
}
fn end_cell(&mut self) {
let (text, annotations) = self.cell_ctx.take();
let mut rendered = annotations_to_markdown(&text, &annotations);
for i in 0..self.cell_col_span {
if i + 1 == self.cell_col_span {
self.current_row.push(std::mem::take(&mut rendered));
} else {
self.current_row.push(rendered.clone());
}
}
self.in_cell = false;
}
fn covered_cell(&mut self) {
let col_idx = self.current_row.len();
let filler = self
.rows
.last()
.and_then(|r| r.get(col_idx))
.cloned()
.unwrap_or_default();
self.current_row.push(filler);
}
fn end_row(&mut self) {
let row = std::mem::take(&mut self.current_row);
if self.current_row_is_header {
self.header_row_indices.push(self.rows.len());
}
self.rows.push(row);
}
}
struct WpdWalker<'a> {
builder: &'a mut InternalDocumentBuilder,
main: Ctx,
pending_heading_level: Option<u8>,
list_stack: Vec<bool>,
current_list_item_ordered: Option<bool>,
table: Option<TableBuilder>,
table_depth: u32,
diversions: Vec<Diversion>,
note_counter: u32,
}
impl<'a> WpdWalker<'a> {
fn new(builder: &'a mut InternalDocumentBuilder) -> Self {
Self {
builder,
main: Ctx::default(),
pending_heading_level: None,
list_stack: Vec::new(),
current_list_item_ordered: None,
table: None,
table_depth: 0,
diversions: Vec::new(),
note_counter: 0,
}
}
fn in_cell(&self) -> bool {
self.table.as_ref().is_some_and(|t| t.in_cell)
}
fn active_table(&mut self) -> Option<&mut TableBuilder> {
if self.table_depth == 0 {
self.table.as_mut()
} else {
None
}
}
fn active_ctx(&mut self) -> &mut Ctx {
if self.in_cell() {
&mut self.table.as_mut().expect("in_cell implies table is Some").cell_ctx
} else if let Some(diversion) = self.diversions.last_mut() {
&mut diversion.ctx
} else {
&mut self.main
}
}
fn append(&mut self, s: &str) {
self.active_ctx().append(s);
}
fn open_span(&mut self, kind: SpanKind) {
self.active_ctx().open_span(kind);
}
fn close_span(&mut self, kind: &SpanKind) {
self.active_ctx().close_span(kind);
}
fn on_paragraph_end(&mut self) {
if self.in_cell() || !self.diversions.is_empty() {
self.active_ctx().append("\n");
return;
}
self.flush_paragraph();
}
fn flush_paragraph(&mut self) {
let (text, annotations) = self.main.take();
if text.trim().is_empty() {
self.pending_heading_level = None;
return;
}
if let Some(level) = self.pending_heading_level.take() {
let idx = self.builder.push_heading(level, &text, None, None);
if !annotations.is_empty() {
self.builder.set_annotations(idx, annotations);
}
} else if let Some(ordered) = self.current_list_item_ordered {
self.builder.push_list_item(&text, ordered, annotations, None, None);
} else {
self.builder.push_paragraph(&text, annotations, None, None);
}
}
fn enter_list_item(&mut self, ordered: bool, level: u8) {
let level = level.max(1) as usize;
while self.list_stack.len() > level {
self.builder.end_list();
self.list_stack.pop();
}
if self.list_stack.len() < level {
while self.list_stack.len() < level {
self.builder.push_list(ordered);
self.list_stack.push(ordered);
}
} else if self.list_stack.last() != Some(&ordered) {
self.builder.end_list();
self.list_stack.pop();
self.builder.push_list(ordered);
self.list_stack.push(ordered);
}
self.current_list_item_ordered = Some(ordered);
}
fn exit_list_item(&mut self) {
self.flush_paragraph();
self.current_list_item_ordered = None;
}
fn enter_note(&mut self, endnote: bool) {
self.note_counter += 1;
if !self.diversions.is_empty() || self.in_cell() {
let marker = format!("[{}]", self.note_counter);
self.append(&marker);
self.diversions.push(Diversion {
kind: DiversionKind::InlineNote,
ctx: Ctx::default(),
});
return;
}
let open_kinds: Vec<SpanKind> = self.main.spans.iter().map(|s| s.kind.clone()).collect();
self.flush_paragraph();
for kind in open_kinds {
self.main.open_span(kind);
}
let prefix = if endnote { "en" } else { "fn" };
let key = format!("{prefix}{}", self.note_counter);
let marker = self.note_counter.to_string();
self.builder.push_footnote_ref(&marker, &key, None);
self.diversions.push(Diversion {
kind: DiversionKind::Note { key },
ctx: Ctx::default(),
});
}
fn enter_diversion(&mut self, kind: DiversionKind) {
self.diversions.push(Diversion {
kind,
ctx: Ctx::default(),
});
}
fn exit_diversion(&mut self) {
let Some(mut diversion) = self.diversions.pop() else {
return;
};
let (text, annotations) = diversion.ctx.take();
match diversion.kind {
DiversionKind::Header => {
if !text.trim().is_empty() {
let idx = self.builder.push_paragraph(&text, annotations, None, None);
self.builder.set_layer(idx, ContentLayer::Header);
}
}
DiversionKind::Footer => {
if !text.trim().is_empty() {
let idx = self.builder.push_paragraph(&text, annotations, None, None);
self.builder.set_layer(idx, ContentLayer::Footer);
}
}
DiversionKind::Aside { kind } => {
if !text.trim().is_empty() {
let idx = self.builder.push_paragraph(&text, annotations, None, None);
let mut attrs = AHashMap::new();
attrs.insert("aside_kind".to_string(), kind);
self.builder.set_attributes(idx, attrs);
}
}
DiversionKind::Note { key } => {
self.builder.push_footnote_definition(&text, &key, None);
}
DiversionKind::InlineNote => {
let body = text.trim();
if !body.is_empty() {
self.active_ctx().append(" ");
self.active_ctx().append(body);
}
}
}
}
fn walk(&mut self, events: &[WpdEvent]) {
for event in events {
match event {
WpdEvent::Text(s) => self.append(s),
WpdEvent::Tab => self.append("\t"),
WpdEvent::Space => self.append(" "),
WpdEvent::LineBreak => self.append("\n"),
WpdEvent::Field(s) => self.append(s),
WpdEvent::ParagraphEnd => self.on_paragraph_end(),
WpdEvent::HeadingStart { level } => self.pending_heading_level = Some(*level),
WpdEvent::BoldStart => self.open_span(SpanKind::Bold),
WpdEvent::BoldEnd => self.close_span(&SpanKind::Bold),
WpdEvent::ItalicStart => self.open_span(SpanKind::Italic),
WpdEvent::ItalicEnd => self.close_span(&SpanKind::Italic),
WpdEvent::UnderlineStart => self.open_span(SpanKind::Underline),
WpdEvent::UnderlineEnd => self.close_span(&SpanKind::Underline),
WpdEvent::StrikethroughStart => self.open_span(SpanKind::Strikethrough),
WpdEvent::StrikethroughEnd => self.close_span(&SpanKind::Strikethrough),
WpdEvent::SuperscriptStart => self.open_span(SpanKind::Superscript),
WpdEvent::SuperscriptEnd => self.close_span(&SpanKind::Superscript),
WpdEvent::SubscriptStart => self.open_span(SpanKind::Subscript),
WpdEvent::SubscriptEnd => self.close_span(&SpanKind::Subscript),
WpdEvent::LinkStart { href } => self.open_span(SpanKind::Link(href.clone())),
WpdEvent::LinkEnd => self.close_span(&SpanKind::Link(String::new())),
WpdEvent::ListItemStart { ordered, level, .. } => self.enter_list_item(*ordered, *level),
WpdEvent::ListItemEnd => self.exit_list_item(),
WpdEvent::TableStart => {
if self.table.is_some() {
self.table_depth += 1;
} else {
self.table = Some(TableBuilder::default());
}
}
WpdEvent::RowStart { header } => {
let header = *header;
if let Some(t) = self.active_table() {
t.start_row(header);
}
}
WpdEvent::CellStart { col_span, .. } => {
let col_span = *col_span;
if let Some(t) = self.active_table() {
t.start_cell(col_span);
}
}
WpdEvent::CoveredCell { .. } => {
if let Some(t) = self.active_table() {
t.covered_cell();
}
}
WpdEvent::CellEnd => {
if let Some(t) = self.active_table() {
t.end_cell();
}
}
WpdEvent::RowEnd => {
if let Some(t) = self.active_table() {
t.end_row();
}
}
WpdEvent::TableEnd => {
if self.table_depth > 0 {
self.table_depth -= 1;
} else {
self.end_table();
}
}
WpdEvent::HeaderStart => self.enter_diversion(DiversionKind::Header),
WpdEvent::HeaderEnd => self.exit_diversion(),
WpdEvent::FooterStart => self.enter_diversion(DiversionKind::Footer),
WpdEvent::FooterEnd => self.exit_diversion(),
WpdEvent::NoteStart { endnote } => self.enter_note(*endnote),
WpdEvent::NoteEnd => self.exit_diversion(),
WpdEvent::AsideStart { kind } => self.enter_diversion(DiversionKind::Aside { kind: kind.clone() }),
WpdEvent::AsideEnd => self.exit_diversion(),
}
}
self.flush_paragraph();
while self.list_stack.pop().is_some() {
self.builder.end_list();
}
}
fn end_table(&mut self) {
let Some(t) = self.table.take() else {
return;
};
if t.rows.is_empty() {
return;
}
let idx = self.builder.push_table_from_cells(&t.rows, None, None);
if !t.header_row_indices.is_empty() {
let header_rows = t
.header_row_indices
.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",");
let mut attrs = AHashMap::new();
attrs.insert("header_rows".to_string(), header_rows);
self.builder.set_attributes(idx, attrs);
}
}
}
fn merge_adjacent_annotations(annotations: &mut Vec<TextAnnotation>) {
if annotations.len() < 2 {
return;
}
fn same_kind_for_merge(a: &AnnotationKind, b: &AnnotationKind) -> bool {
match (a, b) {
(AnnotationKind::Bold, AnnotationKind::Bold)
| (AnnotationKind::Italic, AnnotationKind::Italic)
| (AnnotationKind::Underline, AnnotationKind::Underline)
| (AnnotationKind::Strikethrough, AnnotationKind::Strikethrough)
| (AnnotationKind::Subscript, AnnotationKind::Subscript)
| (AnnotationKind::Superscript, AnnotationKind::Superscript) => true,
(AnnotationKind::Link { url: url_a, .. }, AnnotationKind::Link { url: url_b, .. }) => url_a == url_b,
_ => false,
}
}
let kind_key = |kind: &AnnotationKind| -> u8 {
match kind {
AnnotationKind::Bold => 0,
AnnotationKind::Italic => 1,
AnnotationKind::Underline => 2,
AnnotationKind::Strikethrough => 3,
AnnotationKind::Subscript => 4,
AnnotationKind::Superscript => 5,
AnnotationKind::Link { .. } => 6,
_ => 255,
}
};
annotations.sort_by(|a, b| kind_key(&a.kind).cmp(&kind_key(&b.kind)).then(a.start.cmp(&b.start)));
let mut merged = Vec::with_capacity(annotations.len());
let mut i = 0;
while i < annotations.len() {
let mut ann = annotations[i].clone();
let mut j = i + 1;
while j < annotations.len()
&& same_kind_for_merge(&annotations[j].kind, &ann.kind)
&& annotations[j].start <= ann.end
{
ann.end = ann.end.max(annotations[j].end);
j += 1;
}
merged.push(ann);
i = j;
}
*annotations = merged;
}
fn markdown_markers(kind: &AnnotationKind) -> Option<(String, String)> {
match kind {
AnnotationKind::Bold => Some(("**".to_string(), "**".to_string())),
AnnotationKind::Italic => Some(("*".to_string(), "*".to_string())),
AnnotationKind::Strikethrough => Some(("~~".to_string(), "~~".to_string())),
AnnotationKind::Link { url, .. } => Some(("[".to_string(), format!("]({url})"))),
_ => None,
}
}
fn annotations_to_markdown(text: &str, annotations: &[TextAnnotation]) -> String {
if annotations.is_empty() {
return text.to_string();
}
let mut result = String::with_capacity(text.len() + annotations.len() * 4);
let emit_boundary = |result: &mut String, pos: u32| {
for a in annotations {
if a.end == pos
&& let Some((_, close)) = markdown_markers(&a.kind)
{
result.push_str(&close);
}
}
for a in annotations {
if a.start == pos
&& let Some((open, _)) = markdown_markers(&a.kind)
{
result.push_str(&open);
}
}
};
let mut end_pos = 0u32;
for (byte, ch) in text.char_indices() {
emit_boundary(&mut result, byte as u32);
result.push(ch);
end_pos = byte as u32 + ch.len_utf8() as u32;
}
emit_boundary(&mut result, end_pos);
result
}
fn apply_metadata(builder: &mut InternalDocumentBuilder, meta: &WpdMetadata) {
builder.set_metadata(Metadata {
title: meta.title.clone(),
subject: meta.subject.clone(),
authors: meta.author.clone().map(|a| vec![a]),
keywords: meta.keywords.clone().map(|k| vec![k]),
..Metadata::default()
});
}
fn build_wordperfect_internal_document(doc: &WpdDocument) -> InternalDocument {
let mut builder = InternalDocumentBuilder::new("wordperfect");
apply_metadata(&mut builder, &doc.metadata);
WpdWalker::new(&mut builder).walk(&doc.events);
builder.build()
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl InternalDocumentExtractor for WordPerfectExtractor {
async fn extract_content(
&self,
content: &[u8],
mime_type: &str,
config: &ExtractionConfig,
) -> Result<InternalDocument> {
let limits = config.security_limits.clone().unwrap_or_default();
if content.len() > limits.max_content_size {
return Err(crate::XbergError::validation(format!(
"WordPerfect file exceeds size limit ({} > {} bytes)",
content.len(),
limits.max_content_size
)));
}
if config.cancel_token.as_ref().map(|t| t.is_cancelled()).unwrap_or(false) {
return Err(crate::XbergError::Cancelled);
}
let doc = {
#[cfg(feature = "tokio-runtime")]
{
let content_owned = content.to_vec();
let span = tracing::Span::current();
tokio::task::spawn_blocking(move || {
let _guard = span.entered();
xberg_libwpd::extract_document(&content_owned)
})
.await
.map_err(|e| crate::XbergError::parsing(format!("WordPerfect extraction task failed: {e}")))?
}
#[cfg(not(feature = "tokio-runtime"))]
{
xberg_libwpd::extract_document(content)
}
}
.map_err(|e| crate::XbergError::parsing(format!("Failed to read WordPerfect document: {e}")))?;
let mut internal_doc = build_wordperfect_internal_document(&doc);
if internal_doc.elements.is_empty() {
return Err(crate::XbergError::parsing(
"WordPerfect document produced no extractable content".to_string(),
));
}
internal_doc.mime_type = mime_type.to_string();
Ok(internal_doc)
}
fn supported_mime_types(&self) -> &[&str] {
&["application/vnd.wordperfect", "application/wordperfect"]
}
fn priority(&self) -> i32 {
50
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wordperfect_extractor_plugin_interface() {
let extractor = WordPerfectExtractor::new();
assert_eq!(extractor.name(), "wordperfect-extractor");
assert_eq!(extractor.version(), env!("CARGO_PKG_VERSION"));
assert_eq!(extractor.priority(), 50);
assert_eq!(
extractor.supported_mime_types(),
&["application/vnd.wordperfect", "application/wordperfect"]
);
}
#[test]
fn test_wordperfect_extractor_initialize_shutdown() {
let extractor = WordPerfectExtractor::new();
assert!(extractor.initialize().is_ok());
assert!(extractor.shutdown().is_ok());
}
#[test]
fn test_wordperfect_extractor_default() {
let extractor = WordPerfectExtractor;
assert_eq!(extractor.name(), "wordperfect-extractor");
}
#[test]
fn test_build_document_simple_paragraph_with_bold() {
let doc = WpdDocument {
events: vec![
WpdEvent::BoldStart,
WpdEvent::Text("Hello".to_string()),
WpdEvent::BoldEnd,
WpdEvent::Space,
WpdEvent::Text("world".to_string()),
WpdEvent::ParagraphEnd,
],
metadata: WpdMetadata::default(),
};
let internal_doc = build_wordperfect_internal_document(&doc);
assert_eq!(internal_doc.elements.len(), 1);
let elem = &internal_doc.elements[0];
assert_eq!(elem.text, "Hello world");
assert_eq!(elem.kind, crate::types::internal::ElementKind::Paragraph);
assert_eq!(elem.annotations.len(), 1);
assert_eq!(elem.annotations[0].kind, AnnotationKind::Bold);
assert_eq!(elem.annotations[0].start, 0);
assert_eq!(elem.annotations[0].end, 5);
}
#[test]
fn test_build_document_heading() {
let doc = WpdDocument {
events: vec![
WpdEvent::HeadingStart { level: 2 },
WpdEvent::Text("Title".to_string()),
WpdEvent::ParagraphEnd,
],
metadata: WpdMetadata::default(),
};
let internal_doc = build_wordperfect_internal_document(&doc);
assert_eq!(internal_doc.elements.len(), 1);
assert_eq!(
internal_doc.elements[0].kind,
crate::types::internal::ElementKind::Heading { level: 2 }
);
assert_eq!(internal_doc.elements[0].text, "Title");
}
#[test]
fn test_build_document_list() {
let doc = WpdDocument {
events: vec![
WpdEvent::ListItemStart {
ordered: true,
level: 1,
counter: 1,
},
WpdEvent::Text("First".to_string()),
WpdEvent::ParagraphEnd,
WpdEvent::ListItemEnd,
WpdEvent::ListItemStart {
ordered: true,
level: 1,
counter: 2,
},
WpdEvent::Text("Second".to_string()),
WpdEvent::ParagraphEnd,
WpdEvent::ListItemEnd,
],
metadata: WpdMetadata::default(),
};
let internal_doc = build_wordperfect_internal_document(&doc);
use crate::types::internal::ElementKind;
assert_eq!(internal_doc.elements[0].kind, ElementKind::ListStart { ordered: true });
assert_eq!(internal_doc.elements[1].kind, ElementKind::ListItem { ordered: true });
assert_eq!(internal_doc.elements[1].text, "First");
assert_eq!(internal_doc.elements[2].kind, ElementKind::ListItem { ordered: true });
assert_eq!(internal_doc.elements[2].text, "Second");
assert_eq!(internal_doc.elements[3].kind, ElementKind::ListEnd);
}
#[test]
fn test_build_document_table() {
let doc = WpdDocument {
events: vec![
WpdEvent::TableStart,
WpdEvent::RowStart { header: true },
WpdEvent::CellStart {
column: 0,
col_span: 1,
row_span: 1,
},
WpdEvent::Text("A".to_string()),
WpdEvent::CellEnd,
WpdEvent::CellStart {
column: 1,
col_span: 1,
row_span: 1,
},
WpdEvent::Text("B".to_string()),
WpdEvent::CellEnd,
WpdEvent::RowEnd,
WpdEvent::RowStart { header: false },
WpdEvent::CellStart {
column: 0,
col_span: 1,
row_span: 1,
},
WpdEvent::Text("1".to_string()),
WpdEvent::CellEnd,
WpdEvent::CellStart {
column: 1,
col_span: 1,
row_span: 1,
},
WpdEvent::Text("2".to_string()),
WpdEvent::CellEnd,
WpdEvent::RowEnd,
WpdEvent::TableEnd,
],
metadata: WpdMetadata::default(),
};
let internal_doc = build_wordperfect_internal_document(&doc);
use crate::types::internal::ElementKind;
assert_eq!(internal_doc.elements.len(), 1);
assert!(matches!(internal_doc.elements[0].kind, ElementKind::Table { .. }));
assert_eq!(internal_doc.tables.len(), 1);
assert_eq!(
internal_doc.tables[0].cells,
vec![
vec!["A".to_string(), "B".to_string()],
vec!["1".to_string(), "2".to_string()],
]
);
let attrs = internal_doc.elements[0].attributes.as_ref().unwrap();
assert_eq!(attrs.get("header_rows").unwrap(), "0");
}
#[test]
fn test_build_document_footnote() {
let doc = WpdDocument {
events: vec![
WpdEvent::Text("See".to_string()),
WpdEvent::NoteStart { endnote: false },
WpdEvent::Text("A footnote body.".to_string()),
WpdEvent::NoteEnd,
WpdEvent::Text("here.".to_string()),
WpdEvent::ParagraphEnd,
],
metadata: WpdMetadata::default(),
};
let internal_doc = build_wordperfect_internal_document(&doc);
use crate::types::internal::ElementKind;
let ref_elem = internal_doc
.elements
.iter()
.find(|e| e.kind == ElementKind::FootnoteRef)
.expect("expected a FootnoteRef element");
assert_eq!(ref_elem.anchor.as_deref(), Some("fn1"));
let def_elem = internal_doc
.elements
.iter()
.find(|e| e.kind == ElementKind::FootnoteDefinition)
.expect("expected a FootnoteDefinition element");
assert_eq!(def_elem.text, "A footnote body.");
assert_eq!(def_elem.anchor.as_deref(), Some("fn1"));
let trailing = internal_doc
.elements
.iter()
.find(|e| e.kind == ElementKind::Paragraph && e.text == "here.");
assert!(trailing.is_some(), "text after NoteEnd should form its own paragraph");
}
#[test]
fn test_build_document_header_and_footer_are_bracketed() {
let doc = WpdDocument {
events: vec![
WpdEvent::HeaderStart,
WpdEvent::Text("Running Header".to_string()),
WpdEvent::HeaderEnd,
WpdEvent::Text("Body text.".to_string()),
WpdEvent::ParagraphEnd,
WpdEvent::FooterStart,
WpdEvent::Text("Running Footer".to_string()),
WpdEvent::FooterEnd,
],
metadata: WpdMetadata::default(),
};
let internal_doc = build_wordperfect_internal_document(&doc);
let header = internal_doc
.elements
.iter()
.find(|e| e.text == "Running Header")
.unwrap();
assert_eq!(header.layer, ContentLayer::Header);
let footer = internal_doc
.elements
.iter()
.find(|e| e.text == "Running Footer")
.unwrap();
assert_eq!(footer.layer, ContentLayer::Footer);
let body = internal_doc.elements.iter().find(|e| e.text == "Body text.").unwrap();
assert_eq!(body.layer, ContentLayer::Body);
}
#[test]
fn test_build_document_aside_tagged_and_kept_separate() {
let doc = WpdDocument {
events: vec![
WpdEvent::Text("Main text.".to_string()),
WpdEvent::AsideStart {
kind: "comment".to_string(),
},
WpdEvent::Text("A reviewer comment.".to_string()),
WpdEvent::AsideEnd,
WpdEvent::ParagraphEnd,
],
metadata: WpdMetadata::default(),
};
let internal_doc = build_wordperfect_internal_document(&doc);
let aside = internal_doc
.elements
.iter()
.find(|e| e.text == "A reviewer comment.")
.expect("aside body pushed as its own element");
assert_eq!(aside.attributes.as_ref().unwrap().get("aside_kind").unwrap(), "comment");
let main = internal_doc
.elements
.iter()
.find(|e| e.text == "Main text.")
.expect("main text not spliced with aside content");
assert_ne!(main.text, "Main text.A reviewer comment.");
}
#[test]
fn test_build_document_metadata() {
let doc = WpdDocument {
events: vec![],
metadata: WpdMetadata {
title: Some("My Doc".to_string()),
author: Some("Jane Doe".to_string()),
subject: Some("A subject".to_string()),
keywords: Some("k1, k2".to_string()),
raw: vec![],
},
};
let internal_doc = build_wordperfect_internal_document(&doc);
assert_eq!(internal_doc.metadata.title.as_deref(), Some("My Doc"));
assert_eq!(internal_doc.metadata.authors, Some(vec!["Jane Doe".to_string()]));
assert_eq!(internal_doc.metadata.subject.as_deref(), Some("A subject"));
assert_eq!(internal_doc.metadata.keywords, Some(vec!["k1, k2".to_string()]));
}
#[test]
fn test_formatting_spanning_a_footnote_anchor_is_preserved() {
let doc = WpdDocument {
events: vec![
WpdEvent::BoldStart,
WpdEvent::Text("before".to_string()),
WpdEvent::NoteStart { endnote: false },
WpdEvent::Text("note body".to_string()),
WpdEvent::NoteEnd,
WpdEvent::Text("after".to_string()),
WpdEvent::BoldEnd,
WpdEvent::ParagraphEnd,
],
metadata: WpdMetadata::default(),
};
let internal_doc = build_wordperfect_internal_document(&doc);
let before = internal_doc
.elements
.iter()
.find(|e| e.text == "before")
.expect("pre-anchor paragraph");
assert_eq!(before.annotations.len(), 1);
assert_eq!(before.annotations[0].kind, AnnotationKind::Bold);
assert_eq!((before.annotations[0].start, before.annotations[0].end), (0, 6));
let after = internal_doc
.elements
.iter()
.find(|e| e.text == "after")
.expect("post-anchor paragraph");
assert_eq!(after.annotations.len(), 1, "formatting must continue after the note");
assert_eq!(after.annotations[0].kind, AnnotationKind::Bold);
assert_eq!((after.annotations[0].start, after.annotations[0].end), (0, 5));
}
#[test]
fn test_table_cell_inline_formatting_rendered_as_markdown() {
let doc = WpdDocument {
events: vec![
WpdEvent::TableStart,
WpdEvent::RowStart { header: false },
WpdEvent::CellStart {
column: 0,
col_span: 1,
row_span: 1,
},
WpdEvent::BoldStart,
WpdEvent::Text("Hi".to_string()),
WpdEvent::BoldEnd,
WpdEvent::CellEnd,
WpdEvent::RowEnd,
WpdEvent::TableEnd,
],
metadata: WpdMetadata::default(),
};
let internal_doc = build_wordperfect_internal_document(&doc);
assert_eq!(internal_doc.tables.len(), 1);
assert_eq!(internal_doc.tables[0].cells, vec![vec!["**Hi**".to_string()]]);
}
#[test]
fn test_whitespace_list_item_does_not_leak_into_next_element() {
let doc = WpdDocument {
events: vec![
WpdEvent::ListItemStart {
ordered: true,
level: 1,
counter: 1,
},
WpdEvent::BoldStart,
WpdEvent::Text(" ".to_string()),
WpdEvent::BoldEnd,
WpdEvent::ListItemEnd,
WpdEvent::Text("Hello".to_string()),
WpdEvent::ParagraphEnd,
],
metadata: WpdMetadata::default(),
};
let internal_doc = build_wordperfect_internal_document(&doc);
let hello = internal_doc
.elements
.iter()
.find(|e| e.text.trim() == "Hello")
.expect("Hello element");
assert_eq!(hello.text, "Hello", "leading whitespace must not leak in");
assert!(hello.annotations.is_empty(), "stray annotation must not leak in");
}
#[test]
fn test_nested_table_does_not_destroy_outer_table() {
let doc = WpdDocument {
events: vec![
WpdEvent::TableStart,
WpdEvent::RowStart { header: false },
WpdEvent::CellStart {
column: 0,
col_span: 1,
row_span: 1,
},
WpdEvent::Text("outer".to_string()),
WpdEvent::TableStart,
WpdEvent::RowStart { header: false },
WpdEvent::CellStart {
column: 0,
col_span: 1,
row_span: 1,
},
WpdEvent::Text("inner".to_string()),
WpdEvent::CellEnd,
WpdEvent::RowEnd,
WpdEvent::TableEnd,
WpdEvent::CellEnd,
WpdEvent::RowEnd,
WpdEvent::TableEnd,
],
metadata: WpdMetadata::default(),
};
let internal_doc = build_wordperfect_internal_document(&doc);
assert_eq!(internal_doc.tables.len(), 1, "outer table must not be lost");
assert!(internal_doc.tables[0].cells[0][0].contains("outer"));
}
#[test]
fn test_footnote_inside_table_cell_does_not_reorder_before_table() {
use crate::types::internal::ElementKind;
let doc = WpdDocument {
events: vec![
WpdEvent::TableStart,
WpdEvent::RowStart { header: false },
WpdEvent::CellStart {
column: 0,
col_span: 1,
row_span: 1,
},
WpdEvent::Text("cell".to_string()),
WpdEvent::NoteStart { endnote: false },
WpdEvent::Text("note".to_string()),
WpdEvent::NoteEnd,
WpdEvent::CellEnd,
WpdEvent::RowEnd,
WpdEvent::TableEnd,
],
metadata: WpdMetadata::default(),
};
let internal_doc = build_wordperfect_internal_document(&doc);
assert_eq!(internal_doc.tables.len(), 1);
assert!(
!internal_doc.elements.iter().any(|e| e.kind == ElementKind::FootnoteRef),
"nested note must not emit a standalone FootnoteRef"
);
assert!(
internal_doc.tables[0].cells[0][0].contains("[1]"),
"inline marker folded into cell"
);
}
#[tokio::test]
async fn test_extract_content_empty_document_errors() {
use crate::core::config::ExtractionConfig;
let extractor = WordPerfectExtractor::new();
let config = ExtractionConfig::default();
let result = extractor
.extract_content(b"not a wordperfect document", "application/vnd.wordperfect", &config)
.await;
assert!(result.is_err());
}
}