use crate::segments::Segment;
use super::anydoc_log::capture_anydoc;
use super::differential::{self, DifferentialOutcome};
use super::{ConvertError, Converter, Outcome, normalize_text};
fn promote_warning(message: &str) -> String {
format!("anydoc_recovery: {message}")
}
pub const ANYDOC_ID: &str = "anydoc-document";
pub const ANYDOC_VERSION: &str = "1.0.0";
fn anydoc_format(format_id: &str) -> Option<anydoc::Format> {
Some(match format_id {
"doc" => anydoc::Format::Doc,
"docx" => anydoc::Format::Docx,
"odt" => anydoc::Format::Odt,
"ppt" => anydoc::Format::Ppt,
"pptx" => anydoc::Format::Pptx,
"rtf" => anydoc::Format::Rtf,
"epub" => anydoc::Format::Epub,
"odp" => anydoc::Format::Odp,
_ => return None,
})
}
fn error_code(error: &anydoc::ConvertError) -> &'static str {
match error.code() {
"unsupported" => "unsupported",
"malformed" => "malformed",
"encrypted" => "encrypted",
"resourceLimit" => "resourceLimit",
"missingPart" => "missingPart",
_ => "io",
}
}
pub fn markdown_to_plain(markdown: &str) -> String {
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TASKLISTS);
fn close_block(out: &mut String) {
while out.ends_with('\n') {
out.pop();
}
if !out.is_empty() {
out.push_str("\n\n");
}
}
fn close_line(out: &mut String) {
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
}
let mut out = String::with_capacity(markdown.len());
let mut first_cell = true;
for event in Parser::new_ext(markdown, options) {
match event {
Event::Start(tag) => match tag {
Tag::TableHead | Tag::TableRow => first_cell = true,
Tag::TableCell => {
if !first_cell {
out.push('\t');
}
first_cell = false;
}
_ => {}
},
Event::End(tag) => match tag {
TagEnd::Paragraph
| TagEnd::Heading(_)
| TagEnd::List(_)
| TagEnd::Table
| TagEnd::CodeBlock => close_block(&mut out),
TagEnd::Item | TagEnd::TableHead | TagEnd::TableRow => close_line(&mut out),
_ => {}
},
Event::Text(text) => out.push_str(&text),
Event::Code(code) => out.push_str(&code),
Event::Html(html) | Event::InlineHtml(html) => out.push_str(&html),
Event::SoftBreak | Event::HardBreak => out.push('\n'),
Event::Rule => {}
Event::TaskListMarker(checked) => {
out.push_str(if checked { "[x] " } else { "[ ] " });
}
Event::FootnoteReference(label) => {
out.push('[');
out.push_str(&label);
out.push(']');
}
_ => {}
}
}
while out.ends_with('\n') {
out.pop();
}
if !out.is_empty() {
out.push('\n');
}
out
}
pub struct AnydocDocument;
impl Converter for AnydocDocument {
fn id(&self) -> &'static str {
ANYDOC_ID
}
fn version(&self) -> &'static str {
ANYDOC_VERSION
}
fn convert(
&self,
source: &[u8],
detected_format: &str,
) -> std::result::Result<Outcome, ConvertError> {
let Some(format) = anydoc_format(detected_format) else {
return Err(ConvertError {
code: "unclaimed_format",
message: format!("the document adapter does not handle {detected_format}"),
});
};
let (converted, captured) = capture_anydoc(|| anydoc::to_markdown_bytes(source, format));
let markdown = converted.map_err(|error| ConvertError {
code: error_code(&error),
message: error.to_string(),
})?;
let text = normalize_text(&markdown_to_plain(&markdown));
if !source.is_empty() && text.is_empty() {
return Err(ConvertError {
code: "empty_output",
message: format!(
"source is {} bytes but conversion produced no text",
source.len()
),
});
}
let mut warnings: Vec<String> = captured
.iter()
.map(|message| promote_warning(message))
.collect();
if matches!(detected_format, "doc" | "ppt") {
match differential::check(&text, source, detected_format) {
DifferentialOutcome::Agreement => {}
DifferentialOutcome::Warning(warning) => warnings.push(warning),
DifferentialOutcome::Failure(detail) => {
return Err(ConvertError {
code: "differential-divergence",
message: detail,
});
}
}
}
let segments = vec![Segment::span(0, text.len(), "document")];
Ok(Outcome {
artifact_kind: crate::manifest::ArtifactKind::Text,
converter_id: ANYDOC_ID.to_string(),
converter_version: ANYDOC_VERSION.to_string(),
detected_format: detected_format.to_string(),
text,
warnings,
segments,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn markdown_structure_flattens_to_plain_text() {
let markdown = "\
# Title
Some *emphasis* and **bold** and `code`.
> quoted line
- first item
- second item
1. numbered
| a | b |
| --- | --- |
| 1 | 2 |
[label](https://example.invalid/path) and \\*literal\\*
```
let x = 1;
```
";
let plain = markdown_to_plain(markdown);
assert_eq!(
plain,
"\
Title
Some emphasis and bold and code.
quoted line
first item
second item
numbered
a\tb
1\t2
label and *literal*
let x = 1;
"
);
}
#[test]
fn literal_identifiers_and_operators_survive_verbatim() {
assert_eq!(
markdown_to_plain("employee_id and 2 * 3 and snake_case_name"),
"employee_id and 2 * 3 and snake_case_name\n"
);
assert_eq!(markdown_to_plain("`2 * 3 _x_ [y]`"), "2 * 3 _x_ [y]\n");
assert_eq!(
markdown_to_plain("ends with a backslash \\"),
"ends with a backslash \\\n"
);
}
#[test]
fn escaped_pipes_stay_inside_their_table_cell() {
let table = "| a \\| b | c |\n| --- | --- |\n| 1 | 2 |";
assert_eq!(markdown_to_plain(table), "a | b\tc\n1\t2\n");
}
#[test]
fn link_labels_survive_and_destinations_never_leak() {
assert_eq!(
markdown_to_plain("[label](https://example.invalid/a(b)) tail"),
"label tail\n"
);
assert_eq!(
markdown_to_plain("[ref label][r] tail\n\n[r]: https://example.invalid/x"),
"ref label tail\n"
);
assert_eq!(
markdown_to_plain(" tail"),
"alt text tail\n"
);
}
#[test]
fn gfm_extensions_render_their_content() {
assert_eq!(markdown_to_plain("~~struck~~ kept"), "struck kept\n");
assert_eq!(
markdown_to_plain("- [x] done\n- [ ] open"),
"[x] done\n[ ] open\n"
);
}
#[test]
fn the_log_bridge_captures_and_promotes_anydoc_warnings() {
let ((), captured) = capture_anydoc(|| {
log::warn!(target: "anydoc::formats::pptx", "skipped slide 3: corrupt part");
log::warn!(target: "unrelated::crate", "never captured");
});
assert_eq!(captured.len(), 1);
assert_eq!(
promote_warning(&captured[0]),
"anydoc_recovery: skipped slide 3: corrupt part"
);
}
#[test]
fn adapter_rejects_formats_it_does_not_map() {
let err = AnydocDocument.convert(b"anything", "png").unwrap_err();
assert_eq!(err.code, "unclaimed_format");
}
#[test]
fn malformed_bytes_preserve_the_anydoc_error_class() {
let err = AnydocDocument
.convert(b"PK\x03\x04 not a real docx", "docx")
.unwrap_err();
assert!(
["malformed", "unsupported", "missingPart", "io"].contains(&err.code),
"unexpected class {}",
err.code
);
}
}