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)
}
pub fn office_markdown_from(
bytes: &[u8],
format: Format,
content_type: Option<&str>,
deadline: Option<Instant>,
) -> Result<Converted, pagesift_office::Error> {
let html = |xhtml: &str, path: &str| {
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
};
let opts = pagesift_office::Options {
deadline,
html: Some(&html),
..Default::default()
};
if format == Format::Csv {
let (text, warning) = csv_text::decode(bytes, content_type);
let mut doc = pagesift_office::convert_csv_text(&text, &opts)?;
doc.warnings.extend(warning);
return Ok(doc);
}
pagesift_office::convert(bytes, format, &opts)
}
fn office_media(
bytes: &[u8],
format: Format,
) -> (Vec<pagesift_api::types::Attachment>, Vec<String>) {
let opts = pagesift_office::Options::default();
let mut out = Vec::new();
let mut warnings = Vec::new();
let list = match pagesift_office::media(bytes, format, &opts) {
Ok(list) => list,
Err(e) => return (out, vec![format!("images_not_exported: {e}")]),
};
for m in list {
let name = m.file_name();
let ext = name.rsplit_once('.').map_or("bin", |(_, e)| e);
match pagesift_office::media_bytes(bytes, &m.part, &opts) {
Ok(Some(data)) => out.push(pagesift_api::types::Attachment::with_hash(
m.hash.clone(),
ext,
data,
Some(m.part.replace(' ', "%20")),
)),
Ok(None) => warnings.push(format!("image_not_exported: {}: missing part", m.part)),
Err(e) => warnings.push(format!("image_not_exported: {}: {e}", m.part)),
}
}
(out, warnings)
}
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| !(html && *f == Format::Csv))
}
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) = media.unwrap_or_default();
p.warnings.extend(media_warnings);
meta.title = doc.title;
Ok(finish(
Document {
markdown: opts.wants("markdown").then_some(doc.markdown),
attachments,
..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);
}
}