use pagesift_office::{Converted, Format};
use super::*;
const DEFAULT_OFFICE_TIMEOUT: Duration = Duration::from_secs(30);
thread_local! {
static OFFICE_TIME: std::cell::RefCell<pagesift_stat::size_time::SizeTime> =
std::cell::RefCell::default();
}
fn office_deadline(bytes: usize, started: Instant, request: Option<Instant>) -> Instant {
let learned = OFFICE_TIME
.with_borrow(|m| m.timeout(bytes, pagesift_stat::Delta::DEFAULT))
.map(|t| started + t);
match (request, learned) {
(Some(r), Some(l)) => r.min(l),
(Some(r), None) => r,
(None, Some(l)) => l,
(None, None) => started + DEFAULT_OFFICE_TIMEOUT,
}
}
pub fn office_markdown(
bytes: &[u8],
format: Format,
deadline: Option<Instant>,
) -> Result<Converted, pagesift_office::Error> {
office_markdown_from(bytes, format, None, deadline)
}
fn html_to_markdown(xhtml: &str, path: &str) -> String {
let base = Url::parse("file:///")
.ok()
.and_then(|b| b.join(&path.replace(' ', "%20")).ok());
let opts = pagesift_content::Options {
base_url: base,
only_main_content: false,
detect_barrier: false,
..Default::default()
};
pagesift_content::process(xhtml, &opts).markdown
}
fn member_markdown(
bytes: &[u8],
name: &str,
room: usize,
deadline: Option<Instant>,
) -> Result<Option<Converted>, pagesift_office::Error> {
use pagesift_office::Error as E;
if bytes.starts_with(b"%PDF-") {
let popts = pagesift_pdf::Options {
timeout: deadline.map(|d| d.saturating_duration_since(Instant::now())),
..Default::default()
};
return match pagesift_pdf::extract_with(bytes, &popts) {
Ok(pdf) => Ok(Some(Converted {
parts: pdf.pages.len(),
markdown: pdf.markdown,
title: pdf.metadata.title,
..Default::default()
})),
Err(pagesift_pdf::Error::Timeout) => Err(E::Timeout),
Err(e) => Err(E::Malformed(e.to_string())),
};
}
let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
if matches!(ext.as_deref(), Some("html" | "htm" | "xhtml")) {
let decoded = pagesift_net::decode(bytes, None, None);
return Ok(Some(Converted {
markdown: html_to_markdown(&decoded.text, name),
..Default::default()
}));
}
let Some(format) = Format::detect(bytes, Some(name), None) else {
return Ok(None);
};
convert_with_room(bytes, format, None, deadline, Some(room)).map(Some)
}
fn convert_with_room(
bytes: &[u8],
format: Format,
content_type: Option<&str>,
deadline: Option<Instant>,
room: Option<usize>,
) -> Result<Converted, pagesift_office::Error> {
let member = |data: &[u8], name: &str, room: usize| member_markdown(data, name, room, deadline);
let mut opts = pagesift_office::Options {
deadline,
html: Some(&html_to_markdown),
member: Some(&member),
..Default::default()
};
if let Some(room) = room {
opts.limits.max_output_bytes = room;
}
if format.decodes_text() {
let (text, warning) = csv_text::decode(bytes, content_type);
let mut doc = pagesift_office::convert_text(&text, format, &opts)?;
doc.warnings.extend(warning);
return Ok(doc);
}
pagesift_office::convert(bytes, format, &opts)
}
pub fn office_markdown_from(
bytes: &[u8],
format: Format,
content_type: Option<&str>,
deadline: Option<Instant>,
) -> Result<Converted, pagesift_office::Error> {
convert_with_room(bytes, format, content_type, deadline, None)
}
fn office_media(
bytes: &[u8],
format: Format,
) -> (
Vec<pagesift_api::types::Attachment>,
Vec<String>,
Vec<pagesift_api::types::ImageAssetResult>,
) {
let opts = pagesift_office::Options::default();
let mut out = Vec::new();
let mut warnings = Vec::new();
let mut failed = Vec::new();
let list = match pagesift_office::media(bytes, format, &opts) {
Ok(list) => list,
Err(e) => return (out, vec![format!("images_not_exported: {e}")], failed),
};
for m in list {
match pagesift_office::media_bytes(bytes, &m.part, &opts) {
Ok(Some(data)) => match pagesift_api::types::attachment::sniff_image_format(&data) {
Some(format) => out.push(pagesift_api::types::Attachment::new(
data,
format,
Some(m.part.replace(' ', "%20")),
)),
None => {
warnings.push(format!(
"image_not_exported: {}: unrecognized image bytes",
m.part
));
failed.push(pagesift_api::types::ImageAssetResult {
source: m.part.clone(),
status: pagesift_api::types::ImageAssetStatus::Failed,
id: None,
mime: None,
bytes: None,
reason: Some("unrecognized_image_bytes".into()),
});
}
},
Ok(None) => {
warnings.push(format!("image_not_exported: {}: missing part", m.part));
failed.push(pagesift_api::types::ImageAssetResult {
source: m.part.clone(),
status: pagesift_api::types::ImageAssetStatus::Failed,
id: None,
mime: None,
bytes: None,
reason: Some("missing_part".into()),
});
}
Err(e) => {
warnings.push(format!("image_not_exported: {}: {e}", m.part));
failed.push(pagesift_api::types::ImageAssetResult {
source: m.part.clone(),
status: pagesift_api::types::ImageAssetStatus::Failed,
id: None,
mime: None,
bytes: None,
reason: Some(e.to_string()),
});
}
}
}
(out, warnings, failed)
}
pub(super) fn office_format(body: &[u8], name: &str, content_type: Option<&str>) -> Option<Format> {
let html = content_type.is_some_and(|c| c.to_ascii_lowercase().contains("html"));
Format::detect(body, Some(name), content_type)
.filter(|f| f.fetchable() && !(html && f.is_text_based()))
}
pub(super) struct OfficeInput {
pub(super) bytes: Vec<u8>,
pub(super) format: Format,
pub(super) content_type: Option<String>,
}
impl Scraper {
pub(super) async fn office_document(
&self,
input: OfficeInput,
opts: &ScrapeOptions,
(mut meta, ext): (Metadata, PagesiftMeta),
mut p: Progress,
deadline: Option<Instant>,
) -> Result<Document, ApiError> {
let stage = self
.policy
.fetcher()
.acquire_stage(pagesift_net::NetworkStage::Parse)
.await;
let t = Instant::now();
let format = input.format;
let export = opts.export_images;
let (result, media) = cpu_task(move || {
let started = Instant::now();
let bytes = input.bytes.len();
let deadline = office_deadline(bytes, started, deadline);
let result = office_markdown_from(
&input.bytes,
input.format,
input.content_type.as_deref(),
Some(deadline),
);
if result.is_ok() {
OFFICE_TIME.with_borrow_mut(|m| m.observe(bytes, started.elapsed()));
}
let media =
(export && result.is_ok()).then(|| office_media(&input.bytes, input.format));
(result, media)
})
.await?;
let doc = match result {
Ok(doc) => {
stage.finish(pagesift_net::StageOutcome::Success);
doc
}
Err(error) => {
stage.finish(pagesift_net::StageOutcome::DeterministicFailure);
return Err(office_error(format, error));
}
};
p.timings.insert(format.extension().into(), ms(t.elapsed()));
p.warnings.extend(doc.warnings);
let (attachments, media_warnings, mut image_assets) = media.unwrap_or_default();
if opts.image_assets.is_none() {
image_assets.clear();
}
p.warnings.extend(media_warnings);
meta.title = doc.title;
Ok(finish(
Document {
markdown: opts.wants("markdown").then_some(doc.markdown),
attachments,
image_assets,
..Default::default()
},
meta,
ext,
p.timings,
p.warnings,
None,
false,
p.started,
))
}
}
pub(super) fn office_error(format: Format, e: pagesift_office::Error) -> ApiError {
use pagesift_office::Error as E;
let code = match e {
E::Encrypted | E::Unsupported(_) => ErrorCode::UnsupportedOption,
E::TooLarge(_) => ErrorCode::TooLarge,
E::Timeout => ErrorCode::Timeout,
_ => ErrorCode::InvalidRequest,
};
ApiError::new(
code,
format!("could not read {}: {e}", format.extension().to_uppercase()),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn office_errors_are_caller_errors() {
use pagesift_office::Error as E;
for (e, code) in [
(E::Encrypted, ErrorCode::UnsupportedOption),
(E::TooLarge("x"), ErrorCode::TooLarge),
(E::Timeout, ErrorCode::Timeout),
(E::Malformed("x".into()), ErrorCode::InvalidRequest),
] {
assert_eq!(office_error(Format::Docx, e).code, code);
}
}
#[test]
fn html_responses_are_not_csv() {
assert_eq!(
office_format(b"a,b", "/data.csv", Some("text/plain")),
Some(Format::Csv)
);
assert_eq!(office_format(b"<p>", "/data.csv", Some("text/html")), None);
assert_eq!(office_format(b"<p>", "/page", None), None);
assert_eq!(office_format(b"x", "/a.txt", Some("text/plain")), None);
assert_eq!(
office_format(b"{}", "/a.json", Some("application/json")),
None
);
assert_eq!(office_format(b"<p>", "/m.eml", Some("text/html")), None);
assert_eq!(
office_format(b"Subject: x\n\ny", "/m.eml", None),
Some(Format::Eml)
);
assert_eq!(office_format(b"{\\rtf1 x}", "/f", None), Some(Format::Rtf));
}
}