1use pagesift_office::{Converted, Format};
6
7use super::*;
8
9const DEFAULT_OFFICE_TIMEOUT: Duration = Duration::from_secs(30);
12
13thread_local! {
14 static OFFICE_TIME: std::cell::RefCell<pagesift_stat::size_time::SizeTime> =
16 std::cell::RefCell::default();
17}
18
19fn office_deadline(bytes: usize, started: Instant, request: Option<Instant>) -> Instant {
22 let learned = OFFICE_TIME
23 .with_borrow(|m| m.timeout(bytes, pagesift_stat::Delta::DEFAULT))
24 .map(|t| started + t);
25 match (request, learned) {
26 (Some(r), Some(l)) => r.min(l),
27 (Some(r), None) => r,
28 (None, Some(l)) => l,
29 (None, None) => started + DEFAULT_OFFICE_TIMEOUT,
30 }
31}
32
33pub fn office_markdown(
36 bytes: &[u8],
37 format: Format,
38 deadline: Option<Instant>,
39) -> Result<Converted, pagesift_office::Error> {
40 office_markdown_from(bytes, format, None, deadline)
41}
42
43fn html_to_markdown(xhtml: &str, path: &str) -> String {
45 let base = Url::parse("file:///")
46 .ok()
47 .and_then(|b| b.join(&path.replace(' ', "%20")).ok());
48 let opts = pagesift_content::Options {
49 base_url: base,
50 only_main_content: false,
51 detect_barrier: false,
52 ..Default::default()
53 };
54 pagesift_content::process(xhtml, &opts).markdown
55}
56
57fn member_markdown(
59 bytes: &[u8],
60 name: &str,
61 room: usize,
62 deadline: Option<Instant>,
63) -> Result<Option<Converted>, pagesift_office::Error> {
64 use pagesift_office::Error as E;
65 if bytes.starts_with(b"%PDF-") {
66 let popts = pagesift_pdf::Options {
67 timeout: deadline.map(|d| d.saturating_duration_since(Instant::now())),
68 ..Default::default()
69 };
70 return match pagesift_pdf::extract_with(bytes, &popts) {
71 Ok(pdf) => Ok(Some(Converted {
72 parts: pdf.pages.len(),
73 markdown: pdf.markdown,
74 title: pdf.metadata.title,
75 ..Default::default()
76 })),
77 Err(pagesift_pdf::Error::Timeout) => Err(E::Timeout),
78 Err(e) => Err(E::Malformed(e.to_string())),
79 };
80 }
81 let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
82 if matches!(ext.as_deref(), Some("html" | "htm" | "xhtml")) {
83 let decoded = pagesift_net::decode(bytes, None, None);
84 return Ok(Some(Converted {
85 markdown: html_to_markdown(&decoded.text, name),
86 ..Default::default()
87 }));
88 }
89 let Some(format) = Format::detect(bytes, Some(name), None) else {
90 return Ok(None);
91 };
92 convert_with_room(bytes, format, None, deadline, Some(room)).map(Some)
93}
94
95fn convert_with_room(
96 bytes: &[u8],
97 format: Format,
98 content_type: Option<&str>,
99 deadline: Option<Instant>,
100 room: Option<usize>,
101) -> Result<Converted, pagesift_office::Error> {
102 let member = |data: &[u8], name: &str, room: usize| member_markdown(data, name, room, deadline);
103 let mut opts = pagesift_office::Options {
104 deadline,
105 html: Some(&html_to_markdown),
106 member: Some(&member),
107 ..Default::default()
108 };
109 if let Some(room) = room {
110 opts.limits.max_output_bytes = room;
111 }
112 if format.decodes_text() {
113 let (text, warning) = csv_text::decode(bytes, content_type);
114 let mut doc = pagesift_office::convert_text(&text, format, &opts)?;
115 doc.warnings.extend(warning);
116 return Ok(doc);
117 }
118 pagesift_office::convert(bytes, format, &opts)
119}
120
121pub fn office_markdown_from(
124 bytes: &[u8],
125 format: Format,
126 content_type: Option<&str>,
127 deadline: Option<Instant>,
128) -> Result<Converted, pagesift_office::Error> {
129 convert_with_room(bytes, format, content_type, deadline, None)
130}
131
132fn office_media(
134 bytes: &[u8],
135 format: Format,
136) -> (Vec<pagesift_api::types::Attachment>, Vec<String>) {
137 let opts = pagesift_office::Options::default();
138 let mut out = Vec::new();
139 let mut warnings = Vec::new();
140 let list = match pagesift_office::media(bytes, format, &opts) {
141 Ok(list) => list,
142 Err(e) => return (out, vec![format!("images_not_exported: {e}")]),
143 };
144 for m in list {
145 let name = m.file_name();
146 let ext = name.rsplit_once('.').map_or("bin", |(_, e)| e);
147 match pagesift_office::media_bytes(bytes, &m.part, &opts) {
148 Ok(Some(data)) => out.push(pagesift_api::types::Attachment::with_hash(
149 m.hash.clone(),
150 ext,
151 data,
152 Some(m.part.replace(' ', "%20")),
153 )),
154 Ok(None) => warnings.push(format!("image_not_exported: {}: missing part", m.part)),
155 Err(e) => warnings.push(format!("image_not_exported: {}: {e}", m.part)),
156 }
157 }
158 (out, warnings)
159}
160
161pub(super) fn office_format(body: &[u8], name: &str, content_type: Option<&str>) -> Option<Format> {
165 let html = content_type.is_some_and(|c| c.to_ascii_lowercase().contains("html"));
166 Format::detect(body, Some(name), content_type)
167 .filter(|f| f.fetchable() && !(html && f.is_text_based()))
168}
169
170pub(super) struct OfficeInput {
172 pub(super) bytes: Vec<u8>,
173 pub(super) format: Format,
174 pub(super) content_type: Option<String>,
175}
176
177impl Scraper {
178 pub(super) async fn office_document(
179 &self,
180 input: OfficeInput,
181 opts: &ScrapeOptions,
182 (mut meta, ext): (Metadata, PagesiftMeta),
183 mut p: Progress,
184 deadline: Option<Instant>,
185 ) -> Result<Document, ApiError> {
186 let stage = self
187 .policy
188 .fetcher()
189 .acquire_stage(pagesift_net::NetworkStage::Parse)
190 .await;
191 let t = Instant::now();
192 let format = input.format;
193 let export = opts.export_images;
194 let (result, media) = cpu_task(move || {
195 let started = Instant::now();
196 let bytes = input.bytes.len();
197 let deadline = office_deadline(bytes, started, deadline);
198 let result = office_markdown_from(
199 &input.bytes,
200 input.format,
201 input.content_type.as_deref(),
202 Some(deadline),
203 );
204 if result.is_ok() {
205 OFFICE_TIME.with_borrow_mut(|m| m.observe(bytes, started.elapsed()));
206 }
207 let media =
209 (export && result.is_ok()).then(|| office_media(&input.bytes, input.format));
210 (result, media)
211 })
212 .await?;
213 let doc = match result {
214 Ok(doc) => {
215 stage.finish(pagesift_net::StageOutcome::Success);
216 doc
217 }
218 Err(error) => {
219 stage.finish(pagesift_net::StageOutcome::DeterministicFailure);
220 return Err(office_error(format, error));
221 }
222 };
223 p.timings.insert(format.extension().into(), ms(t.elapsed()));
224 p.warnings.extend(doc.warnings);
225 let (attachments, media_warnings) = media.unwrap_or_default();
226 p.warnings.extend(media_warnings);
227 meta.title = doc.title;
228 Ok(finish(
229 Document {
230 markdown: opts.wants("markdown").then_some(doc.markdown),
231 attachments,
232 ..Default::default()
233 },
234 meta,
235 ext,
236 p.timings,
237 p.warnings,
238 None,
239 false,
240 p.started,
241 ))
242 }
243}
244
245pub(super) fn office_error(format: Format, e: pagesift_office::Error) -> ApiError {
248 use pagesift_office::Error as E;
249 let code = match e {
250 E::Encrypted | E::Unsupported(_) => ErrorCode::UnsupportedOption,
251 E::TooLarge(_) => ErrorCode::TooLarge,
252 E::Timeout => ErrorCode::Timeout,
253 _ => ErrorCode::InvalidRequest,
254 };
255 ApiError::new(
256 code,
257 format!("could not read {}: {e}", format.extension().to_uppercase()),
258 )
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn office_errors_are_caller_errors() {
267 use pagesift_office::Error as E;
268 for (e, code) in [
269 (E::Encrypted, ErrorCode::UnsupportedOption),
270 (E::TooLarge("x"), ErrorCode::TooLarge),
271 (E::Timeout, ErrorCode::Timeout),
272 (E::Malformed("x".into()), ErrorCode::InvalidRequest),
273 ] {
274 assert_eq!(office_error(Format::Docx, e).code, code);
275 }
276 }
277
278 #[test]
279 fn html_responses_are_not_csv() {
280 assert_eq!(
281 office_format(b"a,b", "/data.csv", Some("text/plain")),
282 Some(Format::Csv)
283 );
284 assert_eq!(office_format(b"<p>", "/data.csv", Some("text/html")), None);
285 assert_eq!(office_format(b"<p>", "/page", None), None);
286 assert_eq!(office_format(b"x", "/a.txt", Some("text/plain")), None);
288 assert_eq!(
289 office_format(b"{}", "/a.json", Some("application/json")),
290 None
291 );
292 assert_eq!(office_format(b"<p>", "/m.eml", Some("text/html")), None);
293 assert_eq!(
294 office_format(b"Subject: x\n\ny", "/m.eml", None),
295 Some(Format::Eml)
296 );
297 assert_eq!(office_format(b"{\\rtf1 x}", "/f", None), Some(Format::Rtf));
298 }
299}