text_mirror/convert/
document.rs1use crate::segments::Segment;
24
25use super::anydoc_log::capture_anydoc;
26use super::differential::{self, DifferentialOutcome};
27use super::{ConvertError, Converter, Outcome, normalize_text};
28
29fn promote_warning(message: &str) -> String {
31 format!("anydoc_recovery: {message}")
32}
33
34pub const ANYDOC_ID: &str = "anydoc-document";
36
37pub const ANYDOC_VERSION: &str = "1.0.0";
39
40fn anydoc_format(format_id: &str) -> Option<anydoc::Format> {
41 Some(match format_id {
42 "doc" => anydoc::Format::Doc,
43 "docx" => anydoc::Format::Docx,
44 "odt" => anydoc::Format::Odt,
45 "ppt" => anydoc::Format::Ppt,
46 "pptx" => anydoc::Format::Pptx,
47 "rtf" => anydoc::Format::Rtf,
48 "epub" => anydoc::Format::Epub,
49 "odp" => anydoc::Format::Odp,
50 _ => return None,
51 })
52}
53
54fn error_code(error: &anydoc::ConvertError) -> &'static str {
55 match error.code() {
56 "unsupported" => "unsupported",
57 "malformed" => "malformed",
58 "encrypted" => "encrypted",
59 "resourceLimit" => "resourceLimit",
60 "missingPart" => "missingPart",
61 _ => "io",
62 }
63}
64
65pub fn markdown_to_plain(markdown: &str) -> String {
75 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
76
77 let mut options = Options::empty();
78 options.insert(Options::ENABLE_TABLES);
79 options.insert(Options::ENABLE_STRIKETHROUGH);
80 options.insert(Options::ENABLE_TASKLISTS);
81
82 fn close_block(out: &mut String) {
83 while out.ends_with('\n') {
84 out.pop();
85 }
86 if !out.is_empty() {
87 out.push_str("\n\n");
88 }
89 }
90
91 fn close_line(out: &mut String) {
92 if !out.is_empty() && !out.ends_with('\n') {
93 out.push('\n');
94 }
95 }
96
97 let mut out = String::with_capacity(markdown.len());
98 let mut first_cell = true;
99 for event in Parser::new_ext(markdown, options) {
100 match event {
101 Event::Start(tag) => match tag {
102 Tag::TableHead | Tag::TableRow => first_cell = true,
103 Tag::TableCell => {
104 if !first_cell {
105 out.push('\t');
106 }
107 first_cell = false;
108 }
109 _ => {}
110 },
111 Event::End(tag) => match tag {
112 TagEnd::Paragraph
113 | TagEnd::Heading(_)
114 | TagEnd::List(_)
115 | TagEnd::Table
116 | TagEnd::CodeBlock => close_block(&mut out),
117 TagEnd::Item | TagEnd::TableHead | TagEnd::TableRow => close_line(&mut out),
118 _ => {}
119 },
120 Event::Text(text) => out.push_str(&text),
121 Event::Code(code) => out.push_str(&code),
122 Event::Html(html) | Event::InlineHtml(html) => out.push_str(&html),
123 Event::SoftBreak | Event::HardBreak => out.push('\n'),
124 Event::Rule => {}
125 Event::TaskListMarker(checked) => {
126 out.push_str(if checked { "[x] " } else { "[ ] " });
127 }
128 Event::FootnoteReference(label) => {
129 out.push('[');
130 out.push_str(&label);
131 out.push(']');
132 }
133 _ => {}
134 }
135 }
136 while out.ends_with('\n') {
137 out.pop();
138 }
139 if !out.is_empty() {
140 out.push('\n');
141 }
142 out
143}
144
145pub struct AnydocDocument;
147
148impl Converter for AnydocDocument {
149 fn id(&self) -> &'static str {
150 ANYDOC_ID
151 }
152
153 fn version(&self) -> &'static str {
154 ANYDOC_VERSION
155 }
156
157 fn convert(
158 &self,
159 source: &[u8],
160 detected_format: &str,
161 ) -> std::result::Result<Outcome, ConvertError> {
162 let Some(format) = anydoc_format(detected_format) else {
163 return Err(ConvertError {
164 code: "unclaimed_format",
165 message: format!("the document adapter does not handle {detected_format}"),
166 });
167 };
168 let (converted, captured) = capture_anydoc(|| anydoc::to_markdown_bytes(source, format));
169 let markdown = converted.map_err(|error| ConvertError {
170 code: error_code(&error),
171 message: error.to_string(),
172 })?;
173 let text = normalize_text(&markdown_to_plain(&markdown));
174 if !source.is_empty() && text.is_empty() {
175 return Err(ConvertError {
176 code: "empty_output",
177 message: format!(
178 "source is {} bytes but conversion produced no text",
179 source.len()
180 ),
181 });
182 }
183 let mut warnings: Vec<String> = captured
184 .iter()
185 .map(|message| promote_warning(message))
186 .collect();
187
188 if matches!(detected_format, "doc" | "ppt") {
190 match differential::check(&text, source, detected_format) {
191 DifferentialOutcome::Agreement => {}
192 DifferentialOutcome::Warning(warning) => warnings.push(warning),
193 DifferentialOutcome::Failure(detail) => {
194 return Err(ConvertError {
195 code: "differential-divergence",
196 message: detail,
197 });
198 }
199 }
200 }
201
202 let segments = vec![Segment::span(0, text.len(), "document")];
203 Ok(Outcome {
204 artifact_kind: crate::manifest::ArtifactKind::Text,
205 converter_id: ANYDOC_ID.to_string(),
206 converter_version: ANYDOC_VERSION.to_string(),
207 detected_format: detected_format.to_string(),
208 text,
209 warnings,
210 segments,
211 media: None,
212 })
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn markdown_structure_flattens_to_plain_text() {
222 let markdown = "\
223# Title
224
225Some *emphasis* and **bold** and `code`.
226
227> quoted line
228
229- first item
230- second item
2311. numbered
232
233| a | b |
234| --- | --- |
235| 1 | 2 |
236
237[label](https://example.invalid/path) and \\*literal\\*
238
239```
240let x = 1;
241```
242";
243 let plain = markdown_to_plain(markdown);
244 assert_eq!(
245 plain,
246 "\
247Title
248
249Some emphasis and bold and code.
250
251quoted line
252
253first item
254second item
255
256numbered
257
258a\tb
2591\t2
260
261label and *literal*
262
263let x = 1;
264"
265 );
266 }
267
268 #[test]
269 fn literal_identifiers_and_operators_survive_verbatim() {
270 assert_eq!(
271 markdown_to_plain("employee_id and 2 * 3 and snake_case_name"),
272 "employee_id and 2 * 3 and snake_case_name\n"
273 );
274 assert_eq!(markdown_to_plain("`2 * 3 _x_ [y]`"), "2 * 3 _x_ [y]\n");
275 assert_eq!(
276 markdown_to_plain("ends with a backslash \\"),
277 "ends with a backslash \\\n"
278 );
279 }
280
281 #[test]
282 fn escaped_pipes_stay_inside_their_table_cell() {
283 let table = "| a \\| b | c |\n| --- | --- |\n| 1 | 2 |";
284 assert_eq!(markdown_to_plain(table), "a | b\tc\n1\t2\n");
285 }
286
287 #[test]
288 fn link_labels_survive_and_destinations_never_leak() {
289 assert_eq!(
290 markdown_to_plain("[label](https://example.invalid/a(b)) tail"),
291 "label tail\n"
292 );
293 assert_eq!(
294 markdown_to_plain("[ref label][r] tail\n\n[r]: https://example.invalid/x"),
295 "ref label tail\n"
296 );
297 assert_eq!(
298 markdown_to_plain(" tail"),
299 "alt text tail\n"
300 );
301 }
302
303 #[test]
304 fn gfm_extensions_render_their_content() {
305 assert_eq!(markdown_to_plain("~~struck~~ kept"), "struck kept\n");
306 assert_eq!(
307 markdown_to_plain("- [x] done\n- [ ] open"),
308 "[x] done\n[ ] open\n"
309 );
310 }
311
312 #[test]
313 fn the_log_bridge_captures_and_promotes_anydoc_warnings() {
314 let ((), captured) = capture_anydoc(|| {
315 log::warn!(target: "anydoc::formats::pptx", "skipped slide 3: corrupt part");
316 log::warn!(target: "unrelated::crate", "never captured");
317 });
318 assert_eq!(captured.len(), 1);
319 assert_eq!(
320 promote_warning(&captured[0]),
321 "anydoc_recovery: skipped slide 3: corrupt part"
322 );
323 }
324
325 #[test]
326 fn adapter_rejects_formats_it_does_not_map() {
327 let err = AnydocDocument.convert(b"anything", "png").unwrap_err();
328 assert_eq!(err.code, "unclaimed_format");
329 }
330
331 #[test]
332 fn malformed_bytes_preserve_the_anydoc_error_class() {
333 let err = AnydocDocument
334 .convert(b"PK\x03\x04 not a real docx", "docx")
335 .unwrap_err();
336 assert!(
337 ["malformed", "unsupported", "missingPart", "io"].contains(&err.code),
338 "unexpected class {}",
339 err.code
340 );
341 }
342}