use std::io::{Cursor, Read};
const HANDLED: &[&str] = &[
"ipynb", "html", "htm", "docx", "pptx", "xlsx", "odt", "odp", "ods", "epub", "rtf", "eml",
"mbox",
];
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_counted(ext: &str, bytes: &[u8]) -> Option<(String, Option<i64>)> {
let (text, units) = match ext {
"ipynb" => notebook(bytes).map(|(t, n)| (t, Some(n)))?,
"html" | "htm" => (html(bytes)?, None),
"docx" => (docx(bytes)?, None),
"pptx" => pptx(bytes).map(|(t, n)| (t, Some(n)))?,
"xlsx" => xlsx(bytes).map(|(t, n)| (t, Some(n)))?,
"odt" | "odp" | "ods" => {
let (text, pages, cells) = odf(bytes)?;
let units = match ext {
"odp" => Some(pages),
"ods" => Some(cells),
_ => None,
};
(text, units)
}
"epub" => (epub(bytes)?, None),
"rtf" => (rtf(bytes)?, None),
"eml" => (eml(bytes)?, None),
"mbox" => (mbox(bytes)?, None),
_ => return None,
};
(!text.trim().is_empty()).then_some((text, units))
}
pub(crate) fn why_no_text(ext: &str, bytes: &[u8]) -> crate::SkipReason {
use crate::SkipReason;
const OLE: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
match ext {
"docx" | "pptx" | "xlsx" if bytes.starts_with(OLE) => return SkipReason::Encrypted,
"pdf" if has(b"/Encrypt") => return SkipReason::Encrypted,
_ => {}
}
if let Ok(mut archive) = zip::ZipArchive::new(Cursor::new(bytes)) {
let mut declared: u64 = 0;
let mut manifests = Vec::new();
for i in 0..archive.len() {
let Ok(entry) = archive.by_index_raw(i) else {
continue;
};
let name = entry.name().unwrap_or_default();
if name == "META-INF/manifest.xml" || name.ends_with("encryption.xml") {
manifests.push(i);
} else {
declared = declared.saturating_add(entry.size());
}
}
for i in manifests {
let mut text = String::new();
if let Ok(e) = archive.by_index(i) {
let _ = e.take(1 << 20).read_to_string(&mut text);
}
if text.contains("encryption-data") || text.contains("EncryptedData") {
return SkipReason::Encrypted;
}
}
if declared > MAX_ARCHIVE_TEXT {
return SkipReason::ArchiveTooLarge;
}
}
SkipReason::NoText
}
pub(crate) fn delimited_cells(text: &str, delimiter: char) -> i64 {
let mut cells = 0;
let mut quoted = false;
let mut filled = false;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if quoted && chars.peek() == Some(&'"') => {
chars.next();
filled = true;
}
'"' => quoted = !quoted,
c if !quoted && (c == delimiter || c == '\n') => {
cells += i64::from(filled);
filled = false;
}
c if !c.is_whitespace() => filled = true,
_ => {}
}
}
cells + i64::from(filled)
}
fn notebook(bytes: &[u8]) -> Option<(String, i64)> {
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, cells.len() as i64))
}
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> {
Some(html_text(&String::from_utf8_lossy(bytes)))
}
fn html_text(source: &str) -> String {
let mut out = String::with_capacity(source.len());
let mut rest = source;
while let Some(at) = rest.find('<') {
out.push_str(&rest[..at]);
rest = &rest[at..];
let opens = rest[1..]
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || matches!(c, '/' | '!' | '?'));
if !opens {
out.push('<');
rest = &rest[1..];
continue;
}
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);
decode_entities(&out)
.split('\n')
.map(|line| if line.trim().is_empty() { "" } else { line })
.collect::<Vec<_>>()
.join("\n")
}
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> {
let s = s.as_bytes();
for tag in ["script", "style"] {
let open = format!("<{tag}");
if s.get(..open.len())
.is_some_and(|head| head.eq_ignore_ascii_case(open.as_bytes()))
&& matches!(s.get(open.len()), Some(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.as_bytes()[..rest.len().min(12)]
.iter()
.position(|&b| b == b';')
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, i64)> {
let mut zip = archive(bytes)?;
let mut budget = MAX_ARCHIVE_TEXT;
let mut slides: Vec<(u32, String)> = zip
.file_names()
.filter_map(|name| {
let name = name.ok()?;
Some((slide_number(&name)?, name.into_owned()))
})
.collect();
slides.sort_unstable();
let count = slides.len() as i64;
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, count))
}
fn slide_number(name: &str) -> Option<u32> {
name.strip_prefix("ppt/slides/slide")?
.strip_suffix(".xml")?
.parse()
.ok()
}
fn xlsx(bytes: &[u8]) -> Option<(String, i64)> {
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();
let mut cells = 0;
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, filled) = sheet_text(&xml, &shared);
cells += filled;
if text.trim().is_empty() {
continue;
}
out.push_str(&format!("## Sheet: {name}\n{}\n\n", text.trim_end()));
}
Some((out, cells))
}
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, i64) {
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;
let mut filled = 0i64;
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;
filled += 1;
}
}
Event::Close("row") => {
if cells_in_row > 0 {
out.push('\n');
}
cells_in_row = 0;
}
_ => {}
});
(out, filled)
}
fn odf(bytes: &[u8]) -> Option<(String, i64, i64)> {
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;
let (mut cells, mut row_cells, mut row_repeat) = (0i64, 0i64, 1i64);
let (mut cell_repeat, mut in_cell, mut cell_filled) = (1i64, false, false);
let repeat = |attrs: &str, key: &str| {
attr(attrs, key)
.and_then(|n| n.parse::<i64>().ok())
.unwrap_or(1)
.max(1)
};
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) => {
cell_filled |= in_cell && !text.trim().is_empty();
lines.text(text)
}
Event::Close("text:p" | "text:h") => lines.end_line(),
Event::Open {
name: "table:table-row",
attrs,
} => {
row_cells = 0;
row_repeat = repeat(attrs, "table:number-rows-repeated");
lines.start_row()
}
Event::Open {
name: "table:table-cell",
attrs,
} => {
in_cell = true;
cell_filled = false;
cell_repeat = repeat(attrs, "table:number-columns-repeated");
}
Event::Close("table:table-cell") => {
if cell_filled {
row_cells += cell_repeat;
}
in_cell = false;
lines.end_cell()
}
Event::Close("table:table-row") => {
cells += row_cells * row_repeat;
lines.end_row()
}
_ => {}
});
Some((lines.finish(), i64::from(page), cells))
}
#[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
}
fn epub(bytes: &[u8]) -> Option<String> {
let mut zip = archive(bytes)?;
let mut budget = MAX_ARCHIVE_TEXT;
let container = entry(&mut zip, "META-INF/container.xml", &mut budget)?;
let opf_path = rootfile(&container)?;
let opf = entry(&mut zip, &opf_path, &mut budget)?;
let base = opf_path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("");
let mut out = String::new();
for href in spine(&opf) {
let Some(source) = entry(&mut zip, &join(base, &href), &mut budget) else {
continue;
};
let text = squeeze(&html_text(&source));
if text.is_empty() {
continue;
}
if !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("# {href}\n"));
out.push_str(&text);
out.push('\n');
}
(!out.is_empty()).then_some(out)
}
fn squeeze(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut blanks = 0;
for line in text.lines() {
let line = line.trim_end();
if line.is_empty() {
blanks += 1;
continue;
}
if blanks > 0 && !out.is_empty() {
out.push('\n');
}
blanks = 0;
out.push_str(line);
out.push('\n');
}
out.trim_end().to_string()
}
fn rootfile(xml: &str) -> Option<String> {
let mut path = None;
scan(xml, |event| {
if let Event::Open { name, attrs } = event
&& local(name) == "rootfile"
&& path.is_none()
{
path = attr(attrs, "full-path");
}
});
path.filter(|p| !p.is_empty())
}
fn spine(opf: &str) -> Vec<String> {
let mut manifest: Vec<(String, String)> = Vec::new();
let mut order: Vec<String> = Vec::new();
scan(opf, |event| {
if let Event::Open { name, attrs } = event {
match local(name) {
"item" => {
if let (Some(id), Some(href)) = (attr(attrs, "id"), attr(attrs, "href")) {
manifest.push((id, href));
}
}
"itemref" => {
if let Some(idref) = attr(attrs, "idref") {
order.push(idref);
}
}
_ => {}
}
}
});
order
.iter()
.filter_map(|wanted| {
manifest
.iter()
.find(|(id, _)| id == wanted)
.map(|(_, href)| href.clone())
})
.collect()
}
fn local(name: &str) -> &str {
name.rsplit(':').next().unwrap_or(name)
}
fn join(base: &str, href: &str) -> String {
let href = unpercent(href.split('#').next().unwrap_or(href));
let mut parts: Vec<&str> = base.split('/').filter(|p| !p.is_empty()).collect();
for segment in href.split('/') {
match segment {
"" | "." => {}
".." => {
parts.pop();
}
other => parts.push(other),
}
}
parts.join("/")
}
pub(crate) fn unpercent(s: &str) -> String {
if !s.contains('%') {
return s.to_string();
}
let mut out = Vec::with_capacity(s.len());
let mut rest = s.as_bytes();
while let Some((&byte, tail)) = rest.split_first() {
rest = tail;
if byte != b'%' || rest.len() < 2 {
out.push(byte);
continue;
}
match rest.get(..2).and_then(hex_byte) {
Some(decoded) => {
out.push(decoded);
rest = &rest[2..];
}
None => out.push(byte),
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn hex_byte(pair: &[u8]) -> Option<u8> {
let text = std::str::from_utf8(pair).ok()?;
u8::from_str_radix(text, 16).ok()
}
const RTF_SKIPPED: &[&str] = &[
"fonttbl",
"colortbl",
"stylesheet",
"info",
"pict",
"filetbl",
"listtable",
"listoverridetable",
"rsidtbl",
"generator",
"themedata",
"colorschememapping",
"latentstyles",
"datastore",
"objdata",
"xmlnstbl",
];
const CP1252_HIGH: [char; 32] = [
'\u{20AC}', '\u{0081}', '\u{201A}', '\u{0192}', '\u{201E}', '\u{2026}', '\u{2020}', '\u{2021}',
'\u{02C6}', '\u{2030}', '\u{0160}', '\u{2039}', '\u{0152}', '\u{008D}', '\u{017D}', '\u{008F}',
'\u{0090}', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}', '\u{2022}', '\u{2013}', '\u{2014}',
'\u{02DC}', '\u{2122}', '\u{0161}', '\u{203A}', '\u{0153}', '\u{009D}', '\u{017E}', '\u{0178}',
];
fn rtf(bytes: &[u8]) -> Option<String> {
let source = String::from_utf8_lossy(bytes);
if !source.trim_start().starts_with("{\\rtf") {
return None;
}
let mut out = String::new();
let mut depth: i32 = 0;
let mut skipping: Option<i32> = None;
let mut fallback = 0usize;
let mut fallback_width = 1usize;
let mut codepage = 1252u32;
let mut at_group_start = false;
let mut chars = source.chars().peekable();
while let Some(c) = chars.next() {
match c {
'{' => {
depth += 1;
at_group_start = true;
fallback = 0;
}
'}' => {
depth -= 1;
at_group_start = false;
fallback = 0;
if skipping.is_some_and(|opened| depth < opened) {
skipping = None;
}
}
'\r' | '\n' => at_group_start = false,
'\\' => {
let first_in_group = at_group_start;
at_group_start = false;
match control(&mut chars) {
Control::Literal(c) => {
emit(&mut out, c, &skipping, &mut fallback);
}
Control::Byte(byte) => {
emit(
&mut out,
ansi_char(byte, codepage),
&skipping,
&mut fallback,
);
}
Control::Word { word, param } => {
if first_in_group && RTF_SKIPPED.contains(&word.as_str()) {
skipping = Some(depth);
continue;
}
match word.as_str() {
"*" => skipping = Some(depth),
"ansicpg" => codepage = param.unwrap_or(1252) as u32,
"uc" => fallback_width = param.unwrap_or(1).max(0) as usize,
"u" => {
let code = param.unwrap_or(0);
let code = if code < 0 { code + 65536 } else { code };
if let Some(c) = u32::try_from(code).ok().and_then(char::from_u32) {
emit(&mut out, c, &skipping, &mut fallback);
}
fallback = fallback_width;
}
"par" | "line" | "sect" | "page" | "row" => {
emit(&mut out, '\n', &skipping, &mut fallback)
}
"tab" | "cell" => emit(&mut out, '\t', &skipping, &mut fallback),
"emdash" => emit(&mut out, '\u{2014}', &skipping, &mut fallback),
"endash" => emit(&mut out, '\u{2013}', &skipping, &mut fallback),
"bullet" => emit(&mut out, '\u{2022}', &skipping, &mut fallback),
"lquote" => emit(&mut out, '\u{2018}', &skipping, &mut fallback),
"rquote" => emit(&mut out, '\u{2019}', &skipping, &mut fallback),
"ldblquote" => emit(&mut out, '\u{201C}', &skipping, &mut fallback),
"rdblquote" => emit(&mut out, '\u{201D}', &skipping, &mut fallback),
_ => {}
}
}
Control::End => break,
}
}
_ => {
at_group_start = false;
emit(&mut out, c, &skipping, &mut fallback);
}
}
}
let text: String = out
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n");
(!text.trim().is_empty()).then_some(text)
}
fn emit(out: &mut String, c: char, skipping: &Option<i32>, fallback: &mut usize) {
if skipping.is_some() {
return;
}
if *fallback > 0 {
*fallback -= 1;
return;
}
out.push(c);
}
enum Control {
Literal(char),
Byte(u8),
Word {
word: String,
param: Option<i32>,
},
End,
}
fn control(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Control {
let Some(&next) = chars.peek() else {
return Control::End;
};
if !next.is_ascii_alphabetic() {
chars.next();
return match next {
'\\' | '{' | '}' => Control::Literal(next),
'~' => Control::Literal('\u{00A0}'),
'_' => Control::Literal('\u{2011}'),
'\r' | '\n' => Control::Literal('\n'),
'\'' => {
let hex: String = chars.by_ref().take(2).collect();
match hex_byte(hex.as_bytes()) {
Some(byte) => Control::Byte(byte),
None => Control::End,
}
}
other => Control::Word {
word: other.to_string(),
param: None,
},
};
}
let mut word = String::new();
while let Some(&c) = chars.peek() {
if !c.is_ascii_alphabetic() {
break;
}
word.push(c);
chars.next();
}
let mut digits = String::new();
if chars.peek() == Some(&'-') {
digits.push('-');
chars.next();
}
while let Some(&c) = chars.peek() {
if !c.is_ascii_digit() {
break;
}
digits.push(c);
chars.next();
}
if chars.peek() == Some(&' ') {
chars.next();
}
Control::Word {
word,
param: digits.parse().ok(),
}
}
fn ansi_char(byte: u8, codepage: u32) -> char {
if byte < 0x80 {
return byte as char;
}
if codepage == 1252 && byte <= 0x9F {
return CP1252_HIGH[(byte - 0x80) as usize];
}
byte as char
}
const MAIL_HEADERS: [&str; 5] = ["From", "To", "Cc", "Date", "Subject"];
const MAX_MAIL_DEPTH: usize = 8;
fn eml(bytes: &[u8]) -> Option<String> {
message(&String::from_utf8_lossy(bytes)).map(|(_, text)| text)
}
fn mbox(bytes: &[u8]) -> Option<String> {
let source = String::from_utf8_lossy(bytes);
if !source.starts_with("From ") {
return None;
}
let mut out = String::new();
let mut number = 0u32;
for raw in mbox_messages(&source) {
let Some((subject, text)) = message(&raw) else {
continue;
};
number += 1;
if !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("# Message {number}: {subject}\n"));
out.push_str(text.trim_end());
out.push('\n');
}
(!out.trim().is_empty()).then_some(out)
}
fn mbox_messages(source: &str) -> Vec<String> {
let mut messages = Vec::new();
let mut current = String::new();
for line in source.lines() {
if line.starts_with("From ") {
if !current.is_empty() {
messages.push(std::mem::take(&mut current));
}
continue;
}
let line = match line.strip_prefix('>') {
Some(rest) if rest.starts_with("From ") => rest,
_ => line,
};
current.push_str(line);
current.push('\n');
}
if !current.is_empty() {
messages.push(current);
}
messages
}
fn message(raw: &str) -> Option<(String, String)> {
let (head, body) = split_head(raw)?;
let fields = fields(head);
let mut out = String::new();
let mut subject = String::new();
for name in MAIL_HEADERS {
let Some(value) = field(&fields, name) else {
continue;
};
let decoded = decode_words(value);
if name == "Subject" {
subject = decoded.clone();
}
out.push_str(&format!("{name}: {decoded}\n"));
}
out.push('\n');
out.push_str(body_text(&fields, body, 0).trim_end());
out.push('\n');
(!out.trim().is_empty()).then_some((subject, out))
}
fn split_head(raw: &str) -> Option<(&str, &str)> {
let crlf = raw.find("\r\n\r\n").map(|at| (at, 4));
let lf = raw.find("\n\n").map(|at| (at, 2));
let (at, width) = match (crlf, lf) {
(Some(a), Some(b)) => {
if a.0 <= b.0 {
a
} else {
b
}
}
(Some(a), None) => a,
(None, Some(b)) => b,
(None, None) => return None,
};
Some((&raw[..at], &raw[at + width..]))
}
fn fields(head: &str) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
for line in head.lines() {
if line.starts_with([' ', '\t']) {
if let Some(last) = out.last_mut() {
last.1.push(' ');
last.1.push_str(line.trim());
}
continue;
}
if let Some((name, value)) = line.split_once(':') {
out.push((name.trim().to_ascii_lowercase(), value.trim().to_string()));
}
}
out
}
fn field<'a>(fields: &'a [(String, String)], name: &str) -> Option<&'a str> {
let wanted = name.to_ascii_lowercase();
fields
.iter()
.find(|(key, _)| *key == wanted)
.map(|(_, value)| value.as_str())
}
fn body_text(fields: &[(String, String)], body: &str, depth: usize) -> String {
let content_type = field(fields, "content-type").unwrap_or_default();
let mime = mime_of(content_type);
if mime.starts_with("multipart/") {
if depth >= MAX_MAIL_DEPTH {
return String::new();
}
return match parameter(content_type, "boundary") {
Some(boundary) => multipart(body, &boundary, depth + 1),
None => String::new(),
};
}
let encoding = field(fields, "content-transfer-encoding").unwrap_or_default();
let decoded = decode_transfer(body, encoding.trim());
match mime.as_str() {
"text/html" => html_text(&decoded),
_ => decoded,
}
}
fn multipart(body: &str, boundary: &str, depth: usize) -> String {
let mut plain: Option<String> = None;
let mut html: Option<String> = None;
let mut attachments: Vec<String> = Vec::new();
for part in parts(body, boundary) {
let Some((head, part_body)) = split_head(part) else {
continue;
};
let fields = fields(head);
let content_type = field(&fields, "content-type").unwrap_or_default();
let disposition = field(&fields, "content-disposition").unwrap_or_default();
let mime = mime_of(content_type);
if disposition
.trim_start()
.to_ascii_lowercase()
.starts_with("attachment")
{
attachments.push(
parameter(disposition, "filename")
.or_else(|| parameter(content_type, "name"))
.map(|name| decode_words(&name))
.unwrap_or_else(|| "attachment".to_string()),
);
continue;
}
let text = body_text(&fields, part_body, depth);
match mime.as_str() {
"text/html" if html.is_none() => html = Some(text),
"text/plain" | "" if plain.is_none() => plain = Some(text),
_ if mime.starts_with("multipart/") && plain.is_none() && !text.trim().is_empty() => {
plain = Some(text)
}
_ => {}
}
}
let mut out = plain.or(html).unwrap_or_default().trim_end().to_string();
for name in attachments {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("# Attachment: {name}"));
}
out
}
fn parts<'a>(body: &'a str, boundary: &str) -> Vec<&'a str> {
let delimiter = format!("--{boundary}");
let mut out = Vec::new();
for piece in body.split(delimiter.as_str()).skip(1) {
if piece.starts_with("--") {
break;
}
out.push(piece.trim_start_matches(['\r', '\n']));
}
out
}
fn mime_of(content_type: &str) -> String {
content_type
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase()
}
fn parameter(value: &str, key: &str) -> Option<String> {
for piece in value.split(';').skip(1) {
let Some((name, raw)) = piece.split_once('=') else {
continue;
};
if !name.trim().eq_ignore_ascii_case(key) {
continue;
}
let raw = raw.trim();
let unquoted = raw
.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
.unwrap_or(raw);
return Some(unquoted.to_string());
}
None
}
fn decode_transfer(body: &str, encoding: &str) -> String {
let bytes = match encoding.to_ascii_lowercase().as_str() {
"quoted-printable" => quoted_printable(body, false),
"base64" => base64(body),
_ => return body.to_string(),
};
String::from_utf8_lossy(&bytes).into_owned()
}
fn quoted_printable(s: &str, underscore_is_space: bool) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len());
let mut rest = s.as_bytes();
while let Some((&byte, tail)) = rest.split_first() {
rest = tail;
match byte {
b'=' => {
if let Some(after) = rest.strip_prefix(b"\r\n".as_slice()) {
rest = after;
continue;
}
if let Some(after) = rest.strip_prefix(b"\n".as_slice()) {
rest = after;
continue;
}
match rest.get(..2).and_then(hex_byte) {
Some(decoded) => {
out.push(decoded);
rest = &rest[2..];
}
None => out.push(b'='),
}
}
b'_' if underscore_is_space => out.push(b' '),
_ => out.push(byte),
}
}
out
}
fn base64(s: &str) -> Vec<u8> {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = Vec::with_capacity(s.len() / 4 * 3);
let mut accumulator = 0u32;
let mut bits = 0u32;
for byte in s.bytes() {
let Some(value) = ALPHABET.iter().position(|&c| c == byte) else {
continue;
};
accumulator = (accumulator << 6) | value as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((accumulator >> bits) as u8);
}
}
out
}
fn decode_words(value: &str) -> String {
let mut out = String::new();
let mut rest = value;
let mut previous_was_word = false;
while let Some(at) = rest.find("=?") {
let (before, from) = rest.split_at(at);
match encoded_word(from) {
Some((text, tail)) => {
if !(previous_was_word && before.trim().is_empty()) {
out.push_str(before);
}
out.push_str(&text);
rest = tail;
previous_was_word = true;
}
None => {
out.push_str(before);
out.push_str("=?");
rest = &from[2..];
previous_was_word = false;
}
}
}
out.push_str(rest);
out
}
fn encoded_word(s: &str) -> Option<(String, &str)> {
let body = s.strip_prefix("=?")?;
let end = body.find("?=")?;
let (inside, tail) = (&body[..end], &body[end + 2..]);
let mut parts = inside.splitn(3, '?');
let charset = parts.next()?.to_ascii_lowercase();
let encoding = parts.next()?.to_ascii_lowercase();
let text = parts.next()?;
let bytes = match encoding.as_str() {
"b" => base64(text),
"q" => quoted_printable(text, true),
_ => return None,
};
let decoded = match charset.as_str() {
"utf-8" | "utf8" | "us-ascii" | "ascii" => String::from_utf8_lossy(&bytes).into_owned(),
_ => bytes.iter().map(|&b| b as char).collect(),
};
Some((decoded, tail))
}
#[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 a_line_the_markup_emptied_is_empty_and_keeps_its_place() {
let page = std::fs::read(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/page.html"),
)
.unwrap();
let text = html(&page).unwrap();
let source = String::from_utf8_lossy(&page);
assert_eq!(text.lines().count(), source.lines().count(), "{text:?}");
for (n, line) in text.lines().enumerate() {
assert!(
line.is_empty() || !line.trim().is_empty(),
"line {} is only whitespace: {line:?}",
n + 1
);
}
assert!(text.contains("The wombat migration corridor"), "{text:?}");
}
#[test]
fn a_bare_less_than_is_text_not_a_tag() {
let page = "<pre>\nwhile (input.pos < input.size) {\n x <= y; 1 <3\n}\n\
output->pos is updated</pre>\n<p>after</p>\n";
let text = html(page.as_bytes()).unwrap();
assert!(
text.contains("while (input.pos < input.size) {"),
"{text:?}"
);
assert!(text.contains("x <= y; 1 <3"), "{text:?}");
assert!(text.contains("output->pos is updated"), "{text:?}");
assert!(
!text.contains("<pre>") && !text.contains("<p>"),
"a tag survived: {text:?}"
);
assert_eq!(
text.lines().count(),
page.lines().count(),
"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");
assert_eq!(decode_entities("&abcdefghij’s;"), "&abcdefghij’s;");
assert_eq!(decode_entities("a &ab’&"), "a &ab’&");
}
#[test]
fn a_multi_byte_character_straight_after_a_four_letter_tag_does_not_panic() {
assert_eq!(raw_text_tag("<abbré>x</abbr>"), None);
assert_eq!(raw_text_tag("<styl€"), None);
let text = html("<p><abbré>déjà vu</abbré></p>".as_bytes()).unwrap();
assert!(text.contains("déjà vu"), "{text:?}");
}
#[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, cells) = notebook(nb).unwrap();
assert_eq!(cells, 2);
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".to_string(), 3)
);
}
#[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_counted(ext, bytes);
}
assert!(
extract_counted(ext, b"").is_none(),
"{ext} read an empty file"
);
}
}
#[test]
fn delimited_cells_honour_quotes_and_skip_empties() {
assert_eq!(delimited_cells("a,b,c\n1,,3\n", ','), 5);
assert_eq!(
delimited_cells("\"x, y\",\"\"\n\"he said \"\"hi\"\"\",z", ','),
3
);
assert_eq!(delimited_cells("a\tb\n\t\n", '\t'), 2);
assert_eq!(delimited_cells("", ','), 0);
assert_eq!(delimited_cells("\"line one\nline two\",b", ','), 2);
}
#[test]
fn a_spreadsheet_counts_repeated_cells_once_per_repeat() {
let xml = r#"<office:document-content><office:body><office:spreadsheet>
<table:table table:name="S">
<table:table-row table:number-rows-repeated="2">
<table:table-cell table:number-columns-repeated="3"><text:p>x</text:p></table:table-cell>
<table:table-cell table:number-columns-repeated="1000"/>
</table:table-row>
<table:table-row><table:table-cell><text:p>y</text:p></table:table-cell></table:table-row>
</table:table></office:spreadsheet></office:body></office:document-content>"#;
let mut buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(Cursor::new(&mut buf));
zip.start_file("content.xml", zip::write::SimpleFileOptions::default())
.unwrap();
std::io::Write::write_all(&mut zip, xml.as_bytes()).unwrap();
zip.finish().unwrap();
}
let (text, pages, cells) = odf(&buf).unwrap();
assert_eq!((pages, cells), (0, 7), "{text}");
assert_eq!(extract_counted("ods", &buf).unwrap().1, Some(7));
assert_eq!(extract_counted("odt", &buf).unwrap().1, None);
}
#[test]
fn a_bomb_and_an_encrypted_file_say_why() {
use crate::SkipReason;
let mut ole = vec![0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
ole.extend_from_slice(b"EncryptedPackage");
assert_eq!(extract_counted("docx", &ole), None);
assert_eq!(why_no_text("docx", &ole), SkipReason::Encrypted);
let mut buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(Cursor::new(&mut buf));
zip.start_file(
"word/document.xml",
zip::write::SimpleFileOptions::default(),
)
.unwrap();
let zeros = vec![b' '; 1 << 20];
for _ in 0..33 {
std::io::Write::write_all(&mut zip, &zeros).unwrap();
}
zip.finish().unwrap();
}
assert!(
buf.len() < 1 << 20,
"the bomb is small on disk: {}",
buf.len()
);
assert_eq!(extract_counted("docx", &buf), None);
assert_eq!(why_no_text("docx", &buf), SkipReason::ArchiveTooLarge);
assert_eq!(
why_no_text("docx", b"PK\x03\x04 nothing"),
SkipReason::NoText
);
}
}