use std::io::{Cursor, Read};
const HANDLED: &[&str] = &[
"ipynb", "html", "htm", "docx", "pptx", "xlsx", "odt", "odp", "ods",
];
pub(crate) const MAX_ARCHIVE_TEXT: u64 = 32 * 1024 * 1024;
const MAX_OUTPUT_CHARS: usize = 2000;
pub(crate) fn handles(ext: &str) -> bool {
HANDLED.contains(&ext)
}
pub(crate) fn extract(ext: &str, bytes: &[u8]) -> Option<String> {
let text = match ext {
"ipynb" => notebook(bytes),
"html" | "htm" => html(bytes),
"docx" => docx(bytes),
"pptx" => pptx(bytes),
"xlsx" => xlsx(bytes),
"odt" | "odp" | "ods" => odf(bytes),
_ => None,
}?;
(!text.trim().is_empty()).then_some(text)
}
fn notebook(bytes: &[u8]) -> Option<String> {
let json: serde_json::Value = serde_json::from_slice(bytes).ok()?;
let cells = json.get("cells")?.as_array()?;
let mut out = String::new();
for (i, cell) in cells.iter().enumerate() {
let kind = cell
.get("cell_type")
.and_then(|k| k.as_str())
.unwrap_or("cell");
let source = source_text(cell.get("source"));
if source.trim().is_empty() {
continue;
}
out.push_str(&format!("# Cell {} ({kind})\n", i + 1));
out.push_str(source.trim_end());
out.push('\n');
for output in cell
.get("outputs")
.and_then(|o| o.as_array())
.map(|o| o.as_slice())
.unwrap_or_default()
{
let text = output_text(output);
if text.trim().is_empty() {
continue;
}
out.push_str("# Output:\n");
out.push_str(text.trim_end());
out.push('\n');
}
out.push('\n');
}
Some(out)
}
fn source_text(value: Option<&serde_json::Value>) -> String {
match value {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Array(lines)) => lines
.iter()
.filter_map(|l| l.as_str())
.collect::<Vec<_>>()
.concat(),
_ => String::new(),
}
}
fn output_text(output: &serde_json::Value) -> String {
let text = match output.get("text") {
Some(t) => source_text(Some(t)),
None => source_text(output.get("data").and_then(|d| d.get("text/plain"))),
};
truncate_chars(text, MAX_OUTPUT_CHARS)
}
fn truncate_chars(mut s: String, max: usize) -> String {
if s.chars().count() > max {
let end = s.char_indices().nth(max).map(|(i, _)| i).unwrap_or(s.len());
s.truncate(end);
}
s
}
fn html(bytes: &[u8]) -> Option<String> {
let source = String::from_utf8_lossy(bytes);
let mut out = String::with_capacity(source.len());
let mut rest = source.as_ref();
while let Some(at) = rest.find('<') {
out.push_str(&rest[..at]);
rest = &rest[at..];
let skipped = if rest.starts_with("<!--") {
skip_to(rest, "-->")
} else if let Some(tag) = raw_text_tag(rest) {
let close = format!("</{tag}");
let end = skip_to(rest, &close);
let after = skip_to(&rest[end..], ">");
end + after
} else {
skip_to(rest, ">")
};
for _ in 0..rest[..skipped].matches('\n').count() {
out.push('\n');
}
rest = &rest[skipped..];
}
out.push_str(rest);
Some(decode_entities(&out))
}
fn skip_to(s: &str, needle: &str) -> usize {
match s.find(needle) {
Some(at) => at + needle.len(),
None => s.len(),
}
}
fn raw_text_tag(s: &str) -> Option<&'static str> {
for tag in ["script", "style"] {
let open = format!("<{tag}");
if s.len() > open.len()
&& s[..open.len()].eq_ignore_ascii_case(&open)
&& matches!(
s.as_bytes()[open.len()],
b'>' | b' ' | b'\t' | b'\n' | b'\r'
)
{
return Some(tag);
}
}
None
}
fn decode_entities(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(at) = rest.find('&') {
out.push_str(&rest[..at]);
rest = &rest[at..];
let Some(end) = rest[..rest.len().min(12)].find(';') else {
out.push('&');
rest = &rest[1..];
continue;
};
let body = &rest[1..end];
let decoded = match body {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"nbsp" => Some(' '),
_ => numeric_entity(body),
};
match decoded {
Some(c) => {
out.push(c);
rest = &rest[end + 1..];
}
None => {
out.push('&');
rest = &rest[1..];
}
}
}
out.push_str(rest);
out
}
fn numeric_entity(body: &str) -> Option<char> {
let digits = body.strip_prefix('#')?;
let code = match digits.strip_prefix(['x', 'X']) {
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
None => digits.parse().ok()?,
};
char::from_u32(code)
}
type Archive<'a> = zip::ZipArchive<Cursor<&'a [u8]>>;
fn archive(bytes: &[u8]) -> Option<Archive<'_>> {
zip::ZipArchive::new(Cursor::new(bytes)).ok()
}
fn entry(zip: &mut Archive<'_>, name: &str, budget: &mut u64) -> Option<String> {
let file = zip.by_name(name).ok()?;
if file.size() > *budget {
return None;
}
let mut buf = Vec::new();
file.take(*budget + 1).read_to_end(&mut buf).ok()?;
if buf.len() as u64 > *budget {
return None;
}
*budget -= buf.len() as u64;
Some(String::from_utf8_lossy(&buf).into_owned())
}
fn docx(bytes: &[u8]) -> Option<String> {
let mut zip = archive(bytes)?;
let mut budget = MAX_ARCHIVE_TEXT;
let xml = entry(&mut zip, "word/document.xml", &mut budget)?;
Some(docx_body(&xml))
}
fn docx_body(xml: &str) -> String {
let mut lines = Lines::default();
scan(xml, |event| match event {
Event::Text(text) => lines.text(text),
Event::Close("w:p" | "w:br") => lines.end_line(),
Event::Open { name: "w:tr", .. } => lines.start_row(),
Event::Close("w:tc") => lines.end_cell(),
Event::Close("w:tr") => lines.end_row(),
Event::Close("w:tab") => lines.text("\t"),
_ => {}
});
lines.finish()
}
fn pptx(bytes: &[u8]) -> Option<String> {
let mut zip = archive(bytes)?;
let mut budget = MAX_ARCHIVE_TEXT;
let mut slides: Vec<(u32, String)> = zip
.file_names()
.filter_map(|name| Some((slide_number(name)?, name.to_string())))
.collect();
slides.sort_unstable();
let mut out = String::new();
for (number, name) in slides {
let Some(xml) = entry(&mut zip, &name, &mut budget) else {
break;
};
let mut lines = Lines::default();
scan(&xml, |event| match event {
Event::Text(text) => lines.text(text),
Event::Close("a:p" | "a:br") => lines.end_line(),
_ => {}
});
let text = lines.finish();
if text.trim().is_empty() {
continue;
}
out.push_str(&format!("# Slide {number}\n{}\n\n", text.trim_end()));
}
Some(out)
}
fn slide_number(name: &str) -> Option<u32> {
name.strip_prefix("ppt/slides/slide")?
.strip_suffix(".xml")?
.parse()
.ok()
}
fn xlsx(bytes: &[u8]) -> Option<String> {
let mut zip = archive(bytes)?;
let mut budget = MAX_ARCHIVE_TEXT;
let shared: Vec<String> = match entry(&mut zip, "xl/sharedStrings.xml", &mut budget) {
Some(xml) => shared_strings(&xml),
None => Vec::new(),
};
let workbook = entry(&mut zip, "xl/workbook.xml", &mut budget)?;
let rels = entry(&mut zip, "xl/_rels/workbook.xml.rels", &mut budget).unwrap_or_default();
let targets = relationships(&rels);
let mut out = String::new();
for (name, id) in sheets(&workbook) {
let Some(target) = targets.get(&id) else {
continue;
};
let path = match target.strip_prefix('/') {
Some(from_root) => from_root.to_string(),
None => format!("xl/{target}"),
};
let Some(xml) = entry(&mut zip, &path, &mut budget) else {
break;
};
let text = sheet_text(&xml, &shared);
if text.trim().is_empty() {
continue;
}
out.push_str(&format!("## Sheet: {name}\n{}\n\n", text.trim_end()));
}
Some(out)
}
fn shared_strings(xml: &str) -> Vec<String> {
let mut strings = Vec::new();
let mut current = String::new();
let mut depth = 0usize;
scan(xml, |event| match event {
Event::Open { name: "si", .. } => {
depth += 1;
current.clear();
}
Event::Text(text) if depth > 0 => current.push_str(text),
Event::Close("si") if depth > 0 => {
depth -= 1;
strings.push(decode_entities(¤t));
}
_ => {}
});
strings
}
fn relationships(xml: &str) -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
scan(xml, |event| {
if let Event::Open {
name: "Relationship",
attrs,
} = event
&& let (Some(id), Some(target)) = (attr(attrs, "Id"), attr(attrs, "Target"))
{
map.insert(id, target);
}
});
map
}
fn sheets(xml: &str) -> Vec<(String, String)> {
let mut found = Vec::new();
scan(xml, |event| {
if let Event::Open {
name: "sheet",
attrs,
} = event
&& let (Some(name), Some(id)) = (attr(attrs, "name"), attr(attrs, "r:id"))
{
found.push((name, id));
}
});
found
}
fn sheet_text(xml: &str, shared: &[String]) -> String {
let mut out = String::new();
let mut cell_kind = String::new();
let mut value = String::new();
let mut in_value = false;
let mut cells_in_row = 0usize;
scan(xml, |event| match event {
Event::Open { name: "c", attrs } => {
cell_kind = attr(attrs, "t").unwrap_or_default();
}
Event::Open {
name: "v" | "t", ..
} => {
in_value = true;
value.clear();
}
Event::Text(text) if in_value => value.push_str(text),
Event::Close("v" | "t") if in_value => {
in_value = false;
let text = match cell_kind.as_str() {
"s" => value
.trim()
.parse::<usize>()
.ok()
.and_then(|i| shared.get(i).cloned())
.unwrap_or_default(),
_ => decode_entities(&value),
};
if !text.is_empty() {
if cells_in_row > 0 {
out.push('\t');
}
out.push_str(&text);
cells_in_row += 1;
}
}
Event::Close("row") => {
if cells_in_row > 0 {
out.push('\n');
}
cells_in_row = 0;
}
_ => {}
});
out
}
fn odf(bytes: &[u8]) -> Option<String> {
let mut zip = archive(bytes)?;
let mut budget = MAX_ARCHIVE_TEXT;
let xml = entry(&mut zip, "content.xml", &mut budget)?;
let mut lines = Lines::default();
let mut page = 0u32;
scan(&xml, |event| match event {
Event::Open {
name: "draw:page",
attrs,
} => {
page += 1;
match attr(attrs, "draw:name")
.filter(|n| !n.eq_ignore_ascii_case(&format!("page{page}")))
{
Some(named) => lines.marker(&format!("# Slide {page} ({named})")),
None => lines.marker(&format!("# Slide {page}")),
}
}
Event::Open {
name: "table:table",
attrs,
} => {
let named = attr(attrs, "table:name").unwrap_or_default();
lines.marker(&format!("## Sheet: {named}"));
}
Event::Text(text) => lines.text(text),
Event::Close("text:p" | "text:h") => lines.end_line(),
Event::Open {
name: "table:table-row",
..
} => lines.start_row(),
Event::Close("table:table-cell") => lines.end_cell(),
Event::Close("table:table-row") => lines.end_row(),
_ => {}
});
Some(lines.finish())
}
#[derive(Default)]
struct Lines {
out: String,
line: String,
cell: String,
row: Vec<String>,
in_row: bool,
}
impl Lines {
fn text(&mut self, text: &str) {
self.line.push_str(text);
}
fn marker(&mut self, marker: &str) {
self.end_line();
if !self.out.is_empty() {
self.out.push('\n');
}
self.out.push_str(marker);
self.out.push('\n');
}
fn end_line(&mut self) {
let text = decode_entities(self.line.trim());
self.line.clear();
if text.is_empty() {
return;
}
if self.in_row {
if !self.cell.is_empty() {
self.cell.push(' ');
}
self.cell.push_str(&text);
} else {
self.out.push_str(&text);
self.out.push('\n');
}
}
fn start_row(&mut self) {
self.end_line();
self.in_row = true;
self.cell.clear();
self.row.clear();
}
fn end_cell(&mut self) {
self.end_line();
if self.in_row {
self.row.push(std::mem::take(&mut self.cell));
}
}
fn end_row(&mut self) {
self.end_line();
if !self.cell.is_empty() {
self.row.push(std::mem::take(&mut self.cell));
}
if self.row.iter().any(|c| !c.is_empty()) {
self.out.push_str(&self.row.join("\t"));
self.out.push('\n');
}
self.in_row = false;
self.row.clear();
}
fn finish(mut self) -> String {
if self.in_row {
self.end_row();
}
self.end_line();
self.out
}
}
enum Event<'a> {
Open { name: &'a str, attrs: &'a str },
Close(&'a str),
Text(&'a str),
}
fn scan(xml: &str, mut on_event: impl FnMut(Event<'_>)) {
let mut rest = xml;
while let Some(at) = rest.find('<') {
if at > 0 {
on_event(Event::Text(&rest[..at]));
}
rest = &rest[at..];
if rest.starts_with("<!--") {
rest = &rest[skip_to(rest, "-->")..];
continue;
}
let Some(end) = rest.find('>') else { return };
let inside = &rest[1..end];
rest = &rest[end + 1..];
if let Some(name) = inside.strip_prefix('/') {
on_event(Event::Close(name.trim()));
continue;
}
if inside.starts_with(['?', '!']) {
continue;
}
let self_closing = inside.ends_with('/');
let inside = inside.trim_end_matches('/');
let (name, attrs) = match inside.find([' ', '\t', '\n', '\r']) {
Some(at) => (&inside[..at], &inside[at + 1..]),
None => (inside, ""),
};
on_event(Event::Open { name, attrs });
if self_closing {
on_event(Event::Close(name));
}
}
if !rest.is_empty() {
on_event(Event::Text(rest));
}
}
fn attr(attrs: &str, key: &str) -> Option<String> {
let mut rest = attrs;
while let Some(at) = rest.find(key) {
let after = &rest[at + key.len()..];
let before_is_boundary = at == 0 || rest.as_bytes()[at - 1].is_ascii_whitespace();
let after = after.trim_start();
if !before_is_boundary || !after.starts_with('=') {
rest = &rest[at + key.len()..];
continue;
}
let value = after[1..].trim_start();
let quote = value.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let end = value[1..].find(quote)? + 1;
return Some(decode_entities(&value[1..end]));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn html_keeps_its_text_its_lines_and_none_of_its_markup() {
let page = "<!doctype html>\n<html>\n<head>\n<style>\n.a { color: red }\n</style>\n\
<script>var secret = 1;</script>\n</head>\n<body>\n\
<p class=\"lede\">Wombats & friends ét al.</p>\n</body>\n</html>\n";
let text = html(page.as_bytes()).unwrap();
assert!(text.contains("Wombats & friends ét al."), "{text:?}");
assert!(
!text.contains("secret"),
"script contents survived: {text:?}"
);
assert!(!text.contains("color: red"), "style contents survived");
assert!(!text.contains("class"), "an attribute survived: {text:?}");
let line = text.lines().position(|l| l.contains("Wombats")).unwrap() + 1;
let source_line = page.lines().position(|l| l.contains("Wombats")).unwrap() + 1;
assert_eq!(line, source_line, "line numbers drifted:\n{text}");
}
#[test]
fn an_unknown_entity_is_left_alone() {
assert_eq!(decode_entities("a &b; & c"), "a &b; & c");
assert_eq!(decode_entities("AB"), "AB");
}
#[test]
fn malformed_markup_terminates() {
assert!(html(b"<p>text<").is_some());
assert!(html(b"<!-- unclosed comment").is_some());
let mut count = 0;
scan("<a><b attr='1'/>text</a", |_| count += 1);
assert!(count > 0);
}
#[test]
fn notebook_cells_come_out_as_their_source() {
let nb = br##"{"cells":[
{"cell_type":"markdown","source":["# Title\n","prose here\n"]},
{"cell_type":"code","execution_count":1,"source":"def f(x):\n return x\n",
"outputs":[{"output_type":"stream","name":"stdout","text":["converged\n"]},
{"output_type":"display_data","data":{"image/png":"AAAA"}}]}
],"nbformat":4}"##;
let text = notebook(nb).unwrap();
assert!(text.contains("prose here"));
assert!(text.contains("def f(x):"));
assert!(text.contains("converged"), "stream output missing: {text}");
assert!(!text.contains("AAAA"), "a base64 image was indexed");
assert!(!text.contains("cell_type"), "JSON scaffolding survived");
assert!(
text.find("Cell 1").unwrap() < text.find("Cell 2").unwrap(),
"cells came out of order"
);
}
#[test]
fn a_notebook_that_is_not_a_notebook_is_skipped() {
assert!(notebook(b"[1, 2, 3]").is_none());
assert!(notebook(b"not json at all").is_none());
}
#[test]
fn attributes_are_read_by_name_not_by_substring() {
assert_eq!(attr(r#"r:id="rId3" id="9""#, "id").as_deref(), Some("9"));
assert_eq!(attr(r#"r:id="rId3""#, "r:id").as_deref(), Some("rId3"));
assert_eq!(attr(r#"name='Q3 & Q4'"#, "name").unwrap(), "Q3 & Q4");
assert_eq!(attr(r#"other="1""#, "name"), None);
}
#[test]
fn slides_are_numbered_not_named() {
assert_eq!(slide_number("ppt/slides/slide11.xml"), Some(11));
assert_eq!(slide_number("ppt/slides/_rels/slide1.xml.rels"), None);
assert_eq!(slide_number("ppt/notesSlides/notesSlide1.xml"), None);
}
#[test]
fn a_worksheet_resolves_its_shared_strings() {
let shared =
shared_strings(r#"<sst><si><t>alpha</t></si><si><t>beta & co</t></si></sst>"#);
assert_eq!(shared, ["alpha", "beta & co"]);
let sheet = r#"<worksheet><sheetData>
<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1"><v>42</v></c></row>
<row r="2"><c r="A2" t="s"><v>1</v></c></row>
</sheetData></worksheet>"#;
assert_eq!(sheet_text(sheet, &shared), "alpha\t42\nbeta & co\n");
}
#[test]
fn a_word_paragraph_is_a_line_and_a_table_row_is_one_line_of_cells() {
let xml = r#"<w:document><w:body>
<w:p><w:r><w:t>First para</w:t></w:r></w:p>
<w:tbl>
<w:tr><w:tc><w:p><w:r><w:t>Item</w:t></w:r></w:p></w:tc>
<w:tc><w:p><w:r><w:t>Status</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:p><w:r><w:t>ledger</w:t></w:r></w:p></w:tc>
<w:tc><w:p><w:r><w:t>Open</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
</w:body></w:document>"#;
let text = docx_body(xml);
assert_eq!(text, "First para\nItem\tStatus\nledger\tOpen\n", "{text:?}");
}
#[test]
fn rubbish_in_every_format_is_skipped_rather_than_read() {
let rubbish: &[&[u8]] = &[b"", b"not a zip", &[0u8; 64], b"PK\x03\x04truncated"];
for ext in HANDLED {
for bytes in rubbish {
let _ = extract(ext, bytes);
}
assert!(extract(ext, b"").is_none(), "{ext} read an empty file");
}
}
}