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 {
if self.handle_table_event(event) {
continue;
}
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::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(),
WpdEvent::TableStart
| WpdEvent::RowStart { .. }
| WpdEvent::CellStart { .. }
| WpdEvent::CoveredCell { .. }
| WpdEvent::CellEnd
| WpdEvent::RowEnd
| WpdEvent::TableEnd => {
unreachable!("table events are intercepted by handle_table_event before this match")
}
}
}
self.flush_paragraph();
while self.list_stack.pop().is_some() {
self.builder.end_list();
}
}
fn handle_table_event(&mut self, event: &WpdEvent) -> bool {
match event {
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();
}
}
_ => return false,
}
true
}
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;