1use std::io::Read as _;
19
20pub fn is_pdf_path(path: &std::path::Path) -> bool {
24 path.extension()
25 .and_then(|e| e.to_str())
26 .is_some_and(|e| e.eq_ignore_ascii_case("pdf"))
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct PdfPage {
32 pub index: usize,
34 pub text: String,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct PdfDocument {
41 pub page_count: usize,
43 pub encrypted: bool,
46 pub pages: Vec<PdfPage>,
48}
49
50pub fn pdf_text(bytes: &[u8]) -> PdfDocument {
56 let page_count = count_occurrences(bytes, b"/Type /Page")
57 + count_occurrences(bytes, b"/Type/Page")
58 - count_occurrences(bytes, b"/Type /Pages")
59 - count_occurrences(bytes, b"/Type/Pages");
60 let encrypted = find(bytes, b"/Encrypt").is_some();
61 let mut pages = Vec::new();
62 for (index, stream) in content_streams(bytes).into_iter().enumerate() {
63 let text = text_from_content_stream(&stream);
64 if !text.trim().is_empty() {
65 pages.push(PdfPage {
66 index: index + 1,
67 text,
68 });
69 }
70 }
71 PdfDocument {
72 page_count,
73 encrypted,
74 pages,
75 }
76}
77
78fn count_occurrences(haystack: &[u8], needle: &[u8]) -> usize {
79 let mut n = 0;
80 let mut from = 0;
81 while let Some(at) = find(&haystack[from..], needle) {
82 n += 1;
83 from += at + needle.len();
84 }
85 n
86}
87
88fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
89 if needle.is_empty() || haystack.len() < needle.len() {
90 return None;
91 }
92 haystack
93 .windows(needle.len())
94 .position(|window| window == needle)
95}
96
97fn content_streams(bytes: &[u8]) -> Vec<Vec<u8>> {
101 let mut out = Vec::new();
102 let mut at = 0usize;
103 while let Some(rel) = find(&bytes[at..], b"stream") {
104 let start = at + rel;
105 let is_token_start = start == 0 || !bytes[start - 1].is_ascii_alphanumeric();
107 let mut body = start + b"stream".len();
108 if !is_token_start {
109 at = start + b"stream".len();
110 continue;
111 }
112 if bytes.get(body) == Some(&b'\r') {
114 body += 1;
115 }
116 if bytes.get(body) == Some(&b'\n') {
117 body += 1;
118 }
119 let Some(end_rel) = find(&bytes[body..], b"endstream") else {
120 break;
121 };
122 let end = body + end_rel;
123 let dict_start = bytes[..start]
126 .windows(3)
127 .rposition(|w| w == b"obj")
128 .map(|p| p + 3)
129 .unwrap_or(0);
130 let dict = &bytes[dict_start..start];
131 let payload = &bytes[body..end];
132 at = end + b"endstream".len();
133 if contains(dict, b"/Image")
134 || contains(dict, b"/DCTDecode")
135 || contains(dict, b"/JPXDecode")
136 || contains(dict, b"/FontFile")
137 || contains(dict, b"/EmbeddedFile")
138 {
139 continue;
140 }
141 let decoded = if contains(dict, b"/FlateDecode") {
142 match inflate(payload) {
143 Some(d) => d,
144 None => continue,
145 }
146 } else if contains(dict, b"/Filter") {
147 continue;
149 } else {
150 payload.to_vec()
151 };
152 out.push(decoded);
153 }
154 out
155}
156
157fn contains(haystack: &[u8], needle: &[u8]) -> bool {
158 find(haystack, needle).is_some()
159}
160
161fn inflate(data: &[u8]) -> Option<Vec<u8>> {
164 let mut out = Vec::new();
165 let mut zlib = flate2::read::ZlibDecoder::new(data);
166 if zlib.read_to_end(&mut out).is_ok() && !out.is_empty() {
167 return Some(out);
168 }
169 out.clear();
170 let mut raw = flate2::read::DeflateDecoder::new(data);
171 if raw.read_to_end(&mut out).is_ok() && !out.is_empty() {
172 return Some(out);
173 }
174 None
175}
176
177fn text_from_content_stream(stream: &[u8]) -> String {
181 let mut out = String::new();
182 let mut pending: Vec<String> = Vec::new();
183 let mut i = 0usize;
184 while i < stream.len() {
185 match stream[i] {
186 b'(' => {
187 let (s, next) = read_literal_string(stream, i);
188 pending.push(s);
189 i = next;
190 }
191 b'<' if stream.get(i + 1) != Some(&b'<') => {
192 let (s, next) = read_hex_string(stream, i);
193 pending.push(s);
194 i = next;
195 }
196 b'T' => {
197 let op = &stream[i..(i + 2).min(stream.len())];
198 if op == b"Tj" || op == b"TJ" {
199 out.push_str(&pending.join(""));
200 pending.clear();
201 i += 2;
202 } else if op == b"Td" || op == b"TD" || op == b"T*" {
203 out.push_str(&pending.join(""));
204 pending.clear();
205 if !out.ends_with('\n') {
206 out.push('\n');
207 }
208 i += 2;
209 } else {
210 i += 1;
211 }
212 }
213 b'\'' | b'"' => {
214 out.push_str(&pending.join(""));
215 pending.clear();
216 if !out.ends_with('\n') {
217 out.push('\n');
218 }
219 i += 1;
220 }
221 b'E' if stream[i..].starts_with(b"ET") => {
222 out.push_str(&pending.join(""));
223 pending.clear();
224 if !out.ends_with('\n') {
225 out.push('\n');
226 }
227 i += 2;
228 }
229 _ => i += 1,
230 }
231 }
232 out.push_str(&pending.join(""));
233 let mut cleaned = String::with_capacity(out.len());
235 let mut blank = 0;
236 for line in out.lines() {
237 let line = line.trim_end();
238 if line.is_empty() {
239 blank += 1;
240 if blank > 1 {
241 continue;
242 }
243 } else {
244 blank = 0;
245 }
246 cleaned.push_str(line);
247 cleaned.push('\n');
248 }
249 cleaned.trim_end().to_string()
250}
251
252fn read_literal_string(stream: &[u8], open: usize) -> (String, usize) {
255 let mut bytes: Vec<u8> = Vec::new();
256 let mut depth = 1usize;
257 let mut i = open + 1;
258 while i < stream.len() {
259 match stream[i] {
260 b'\\' => {
261 i += 1;
262 let Some(&c) = stream.get(i) else { break };
263 match c {
264 b'n' => bytes.push(b'\n'),
265 b'r' => bytes.push(b'\r'),
266 b't' => bytes.push(b'\t'),
267 b'b' => bytes.push(8),
268 b'f' => bytes.push(12),
269 b'\n' => {}
270 b'0'..=b'7' => {
271 let mut val = 0u32;
272 let mut digits = 0;
273 while digits < 3 {
274 match stream.get(i) {
275 Some(&d @ b'0'..=b'7') => {
276 val = val * 8 + u32::from(d - b'0');
277 i += 1;
278 digits += 1;
279 }
280 _ => break,
281 }
282 }
283 i -= 1;
284 bytes.push(val as u8);
285 }
286 other => bytes.push(other),
287 }
288 i += 1;
289 }
290 b'(' => {
291 depth += 1;
292 bytes.push(b'(');
293 i += 1;
294 }
295 b')' => {
296 depth -= 1;
297 i += 1;
298 if depth == 0 {
299 break;
300 }
301 bytes.push(b')');
302 }
303 c => {
304 bytes.push(c);
305 i += 1;
306 }
307 }
308 }
309 (decode_pdf_bytes(&bytes), i)
310}
311
312fn read_hex_string(stream: &[u8], open: usize) -> (String, usize) {
314 let mut digits: Vec<u8> = Vec::new();
315 let mut i = open + 1;
316 while i < stream.len() && stream[i] != b'>' {
317 if stream[i].is_ascii_hexdigit() {
318 digits.push(stream[i]);
319 }
320 i += 1;
321 }
322 if digits.len() % 2 == 1 {
323 digits.push(b'0');
324 }
325 let bytes: Vec<u8> = digits
326 .chunks(2)
327 .map(|pair| {
328 let hi = (pair[0] as char).to_digit(16).unwrap_or(0) as u8;
329 let lo = (pair[1] as char).to_digit(16).unwrap_or(0) as u8;
330 (hi << 4) | lo
331 })
332 .collect();
333 (decode_pdf_bytes(&bytes), i + 1)
334}
335
336fn decode_pdf_bytes(bytes: &[u8]) -> String {
340 let utf16 = bytes.len() >= 2
341 && (bytes[0] == 0xFE && bytes[1] == 0xFF
342 || (bytes.len() % 2 == 0
343 && bytes.chunks(2).filter(|c| c[0] == 0).count() * 2 > bytes.len()));
344 if utf16 {
345 let body = if bytes[0] == 0xFE && bytes[1] == 0xFF {
346 &bytes[2..]
347 } else {
348 bytes
349 };
350 let units: Vec<u16> = body
351 .chunks(2)
352 .filter(|c| c.len() == 2)
353 .map(|c| u16::from_be_bytes([c[0], c[1]]))
354 .collect();
355 return String::from_utf16_lossy(&units);
356 }
357 bytes.iter().map(|&b| b as char).collect()
358}
359
360pub fn pdf_markdown(name: &str, bytes: &[u8]) -> String {
363 let doc = pdf_text(bytes);
364 if doc.pages.is_empty() {
365 let why = if doc.encrypted {
366 "the document is encrypted"
367 } else {
368 "no text layer was found (a scanned/image-only PDF, or a filter this \
369 extractor does not decode)"
370 };
371 return format!(
372 "[read_file: PDF {name} — {} bytes, {} page objects; no text extracted: {why}. \
373 The bytes themselves were not decoded as text.]",
374 bytes.len(),
375 doc.page_count
376 );
377 }
378 let mut out = format!(
379 "[read_file: PDF {name} — {} bytes, {} page objects, text extracted from {} content \
380 stream(s) in file order]\n",
381 bytes.len(),
382 doc.page_count,
383 doc.pages.len()
384 );
385 for page in &doc.pages {
386 out.push_str(&format!("\n--- page {} ---\n{}\n", page.index, page.text));
387 }
388 out
389}
390
391pub fn is_notebook_path(path: &std::path::Path) -> bool {
395 path.extension()
396 .and_then(|e| e.to_str())
397 .is_some_and(|e| e.eq_ignore_ascii_case(super::NOTEBOOK_EXTENSION))
398}
399
400fn json_text(value: Option<&serde_json::Value>) -> String {
402 match value {
403 Some(serde_json::Value::String(s)) => s.clone(),
404 Some(serde_json::Value::Array(items)) => items
405 .iter()
406 .filter_map(|i| i.as_str())
407 .collect::<Vec<_>>()
408 .join(""),
409 _ => String::new(),
410 }
411}
412
413pub fn notebook_markdown(name: &str, bytes: &[u8]) -> String {
417 let Ok(nb) = serde_json::from_slice::<serde_json::Value>(bytes) else {
418 return format!(
419 "[read_file: {name} is not valid Jupyter notebook JSON; {} bytes not decoded]",
420 bytes.len()
421 );
422 };
423 let cells = nb.get("cells").and_then(|c| c.as_array());
424 let kernel = nb
425 .get("metadata")
426 .and_then(|m| m.get("kernelspec"))
427 .and_then(|k| k.get("display_name").or_else(|| k.get("name")))
428 .and_then(|n| n.as_str())
429 .unwrap_or("unknown");
430 let Some(cells) = cells else {
431 return format!("[read_file: {name} has no `cells` array (kernel: {kernel})]");
432 };
433 let mut out = format!(
434 "[read_file: Jupyter notebook {name} — {} cells, kernel {kernel}]\n",
435 cells.len()
436 );
437 for (index, cell) in cells.iter().enumerate() {
438 let kind = cell
439 .get("cell_type")
440 .and_then(|t| t.as_str())
441 .unwrap_or("unknown");
442 let exec = cell
443 .get("execution_count")
444 .and_then(|c| c.as_u64())
445 .map(|c| format!(" [{c}]"))
446 .unwrap_or_default();
447 let source = json_text(cell.get("source"));
448 out.push_str(&format!(
449 "\n--- cell {index} ({kind}){exec} ---\n{source}\n"
450 ));
451 let Some(outputs) = cell.get("outputs").and_then(|o| o.as_array()) else {
452 continue;
453 };
454 for output in outputs {
455 let rendered = match output.get("output_type").and_then(|t| t.as_str()) {
456 Some("stream") => {
457 let stream = output
458 .get("name")
459 .and_then(|n| n.as_str())
460 .unwrap_or("stdout");
461 format!("[{stream}]\n{}", json_text(output.get("text")))
462 }
463 Some("error") => {
464 let ename = output
465 .get("ename")
466 .and_then(|e| e.as_str())
467 .unwrap_or("Error");
468 let evalue = output.get("evalue").and_then(|e| e.as_str()).unwrap_or("");
469 let traceback = output
470 .get("traceback")
471 .and_then(|t| t.as_array())
472 .map(|lines| {
473 lines
474 .iter()
475 .filter_map(|l| l.as_str())
476 .collect::<Vec<_>>()
477 .join("\n")
478 })
479 .unwrap_or_default();
480 format!("[error] {ename}: {evalue}\n{traceback}")
481 }
482 Some(kind @ ("execute_result" | "display_data")) => {
483 let data = output.get("data");
484 let text = data
485 .and_then(|d| d.get("text/plain"))
486 .map(|t| json_text(Some(t)))
487 .unwrap_or_default();
488 let mime_note = data
489 .and_then(|d| d.as_object())
490 .map(|o| {
491 o.keys()
492 .filter(|k| k.as_str() != "text/plain")
493 .cloned()
494 .collect::<Vec<_>>()
495 })
496 .filter(|extra| !extra.is_empty())
497 .map(|extra| format!(" (also: {})", extra.join(", ")))
498 .unwrap_or_default();
499 format!("[{kind}{mime_note}]\n{text}")
500 }
501 other => format!("[{}]", other.unwrap_or("output")),
502 };
503 out.push_str(&format!("--- output ---\n{}\n", rendered.trim_end()));
504 }
505 }
506 out
507}
508
509pub fn html_to_markdown(html: &str) -> String {
519 let bytes = html.as_bytes();
520 let mut out = String::with_capacity(html.len() / 2);
521 let mut i = 0usize;
522 let mut link_href: Option<String> = None;
524 let mut link_text = String::new();
525 while i < bytes.len() {
526 if bytes[i] == b'<' {
527 if html[i..].starts_with("<!--") {
528 i = html[i..]
529 .find("-->")
530 .map(|p| i + p + 3)
531 .unwrap_or(bytes.len());
532 continue;
533 }
534 let Some(close) = html[i..].find('>') else {
535 break;
536 };
537 let raw = &html[i + 1..i + close];
538 let end = i + close + 1;
539 let name = tag_name(raw);
540 match name.as_str() {
541 "script" | "style" | "noscript" | "svg" | "head" => {
542 let closing = format!("</{name}");
543 i = html[end..]
544 .find(&closing)
545 .map(|p| {
546 let from = end + p;
547 html[from..].find('>').map(|q| from + q + 1).unwrap_or(end)
548 })
549 .unwrap_or(bytes.len());
550 continue;
551 }
552 "br" => push_line(&mut out),
553 "p" | "div" | "section" | "article" | "tr" | "table" | "blockquote" | "pre"
554 | "ul" | "ol" => push_block(&mut out),
555 "/p" | "/div" | "/section" | "/article" | "/tr" | "/table" | "/blockquote"
556 | "/pre" | "/ul" | "/ol" => push_block(&mut out),
557 "li" => {
558 push_line(&mut out);
559 out.push_str("- ");
560 }
561 "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
562 push_block(&mut out);
563 let level: usize = name[1..].parse().unwrap_or(1);
564 out.push_str(&"#".repeat(level));
565 out.push(' ');
566 }
567 "/h1" | "/h2" | "/h3" | "/h4" | "/h5" | "/h6" => push_block(&mut out),
568 "code" | "/code" => out.push('`'),
569 "strong" | "/strong" | "b" | "/b" => out.push_str("**"),
570 "em" | "/em" | "i" | "/i" => out.push('*'),
571 "a" => {
572 link_href = attribute(raw, "href");
573 link_text.clear();
574 }
575 "/a" => {
576 let text = link_text.trim().to_string();
577 match link_href.take() {
578 Some(href) if !text.is_empty() => {
579 out.push_str(&format!("[{text}]({href})"))
580 }
581 _ => out.push_str(&text),
582 }
583 link_text.clear();
584 }
585 "td" | "th" => out.push_str(" | "),
586 _ => {}
587 }
588 i = end;
589 continue;
590 }
591 let next = html[i..].find('<').map(|p| i + p).unwrap_or(bytes.len());
592 let text = decode_entities(&html[i..next]);
593 let mut collapsed = collapse_whitespace(&text);
594 let target = if link_href.is_some() {
595 &mut link_text
596 } else {
597 &mut out
598 };
599 if target.is_empty() || target.ends_with(char::is_whitespace) {
600 collapsed = collapsed.trim_start().to_string();
601 }
602 target.push_str(&collapsed);
603 i = next;
604 }
605 let mut cleaned = String::with_capacity(out.len());
607 let mut blank = 0;
608 for line in out.lines() {
609 let line = line.trim_end();
610 if line.is_empty() {
611 blank += 1;
612 if blank > 1 {
613 continue;
614 }
615 } else {
616 blank = 0;
617 }
618 cleaned.push_str(line);
619 cleaned.push('\n');
620 }
621 cleaned.trim().to_string()
622}
623
624fn push_line(out: &mut String) {
625 if !out.is_empty() && !out.ends_with('\n') {
626 out.push('\n');
627 }
628}
629
630fn push_block(out: &mut String) {
631 if out.is_empty() {
632 return;
633 }
634 while out.ends_with(' ') {
635 out.pop();
636 }
637 if !out.ends_with("\n\n") {
638 if out.ends_with('\n') {
639 out.push('\n');
640 } else {
641 out.push_str("\n\n");
642 }
643 }
644}
645
646fn tag_name(raw: &str) -> String {
648 let raw = raw.trim();
649 let mut name = String::new();
650 for (index, c) in raw.char_indices() {
651 if index == 0 && c == '/' {
652 name.push('/');
653 continue;
654 }
655 if c.is_ascii_alphanumeric() {
656 name.push(c.to_ascii_lowercase());
657 } else {
658 break;
659 }
660 }
661 name
662}
663
664fn attribute(raw: &str, name: &str) -> Option<String> {
666 let lower = raw.to_ascii_lowercase();
667 let mut from = 0usize;
668 while let Some(at) = lower[from..].find(name) {
669 let start = from + at;
670 let before_ok = start == 0
671 || !lower.as_bytes()[start - 1].is_ascii_alphanumeric()
672 && lower.as_bytes()[start - 1] != b'-';
673 let rest = &raw[start + name.len()..];
674 let trimmed = rest.trim_start();
675 if before_ok && trimmed.starts_with('=') {
676 let value = trimmed[1..].trim_start();
677 let quote = value.chars().next()?;
678 if quote == '"' || quote == '\'' {
679 let end = value[1..].find(quote)? + 1;
680 return Some(decode_entities(&value[1..end]));
681 }
682 let end = value
683 .find(|c: char| c.is_whitespace())
684 .unwrap_or(value.len());
685 return Some(decode_entities(&value[..end]));
686 }
687 from = start + name.len();
688 }
689 None
690}
691
692fn collapse_whitespace(text: &str) -> String {
697 let mut out = String::with_capacity(text.len());
698 let mut space = false;
699 for c in text.chars() {
700 if c.is_whitespace() {
701 space = true;
702 continue;
703 }
704 if space {
705 out.push(' ');
706 }
707 space = false;
708 out.push(c);
709 }
710 if space {
711 out.push(' ');
712 }
713 out
714}
715
716pub fn decode_entities(text: &str) -> String {
719 if !text.contains('&') {
720 return text.to_string();
721 }
722 let mut out = String::with_capacity(text.len());
723 let bytes = text.as_bytes();
724 let mut i = 0usize;
725 while i < bytes.len() {
726 if bytes[i] != b'&' {
727 let next = text[i..].find('&').map(|p| i + p).unwrap_or(bytes.len());
728 out.push_str(&text[i..next]);
729 i = next;
730 continue;
731 }
732 let Some(semi) = text[i..].find(';').filter(|p| *p <= 10) else {
733 out.push('&');
734 i += 1;
735 continue;
736 };
737 let entity = &text[i + 1..i + semi];
738 let decoded = match entity {
739 "amp" => Some('&'),
740 "lt" => Some('<'),
741 "gt" => Some('>'),
742 "quot" => Some('"'),
743 "apos" | "#39" => Some('\''),
744 "nbsp" => Some(' '),
745 "hellip" => Some('…'),
746 "mdash" => Some('—'),
747 "ndash" => Some('–'),
748 "rsquo" => Some('’'),
749 "lsquo" => Some('‘'),
750 "ldquo" => Some('“'),
751 "rdquo" => Some('”'),
752 other => other
753 .strip_prefix('#')
754 .and_then(|n| match n.strip_prefix(['x', 'X']) {
755 Some(hex) => u32::from_str_radix(hex, 16).ok(),
756 None => n.parse::<u32>().ok(),
757 })
758 .and_then(char::from_u32),
759 };
760 match decoded {
761 Some(c) => {
762 out.push(c);
763 i += semi + 1;
764 }
765 None => {
766 out.push('&');
767 i += 1;
768 }
769 }
770 }
771 out
772}
773
774#[derive(Debug, Clone, PartialEq, Eq)]
778pub struct SearchResult {
779 pub title: String,
781 pub url: String,
783 pub snippet: String,
785}
786
787pub fn parse_html_search_results(html: &str) -> Vec<SearchResult> {
795 let titles = elements_with_class(html, "a", "result__a");
796 let snippets = elements_with_class(html, "a", "result__snippet");
797 let snippets = if snippets.is_empty() {
798 elements_with_class(html, "div", "result__snippet")
799 } else {
800 snippets
801 };
802 let mut out = Vec::new();
803 for (index, (attrs, inner)) in titles.into_iter().enumerate() {
804 let Some(href) = attribute(&attrs, "href") else {
805 continue;
806 };
807 let url = unwrap_redirector(&href);
808 let title = html_to_markdown(&inner);
809 if title.is_empty() || url.is_empty() {
810 continue;
811 }
812 let snippet = snippets
813 .get(index)
814 .map(|(_, text)| html_to_markdown(text))
815 .unwrap_or_default();
816 out.push(SearchResult {
817 title,
818 url,
819 snippet,
820 });
821 }
822 out
823}
824
825fn elements_with_class(html: &str, tag: &str, wanted: &str) -> Vec<(String, String)> {
829 let open = format!("<{tag}");
830 let close = format!("</{tag}");
831 let mut out = Vec::new();
832 let mut from = 0usize;
833 while let Some(at) = html[from..].find(&open) {
834 let start = from + at;
835 let Some(gt) = html[start..].find('>') else {
836 break;
837 };
838 let attrs = &html[start + open.len()..start + gt];
839 let body_start = start + gt + 1;
840 from = body_start;
841 let has_class = attribute(attrs, "class")
842 .is_some_and(|class| class.split_whitespace().any(|c| c == wanted));
843 if !has_class {
844 continue;
845 }
846 let Some(end) = html[body_start..].find(&close) else {
847 continue;
848 };
849 out.push((
850 attrs.to_string(),
851 html[body_start..body_start + end].to_string(),
852 ));
853 }
854 out
855}
856
857fn unwrap_redirector(href: &str) -> String {
860 for key in ["uddg=", "url=", "u=", "q="] {
861 if let Some(at) = href.find(key) {
862 let value = &href[at + key.len()..];
863 let end = value.find('&').unwrap_or(value.len());
864 let decoded = percent_decode(&value[..end]);
865 if decoded.starts_with("http") {
866 return decoded;
867 }
868 }
869 }
870 if let Some(rest) = href.strip_prefix("//") {
871 return format!("https://{rest}");
872 }
873 href.to_string()
874}
875
876pub fn percent_decode(text: &str) -> String {
878 let bytes = text.as_bytes();
879 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
880 let mut i = 0usize;
881 while i < bytes.len() {
882 match bytes[i] {
883 b'%' if i + 2 < bytes.len() => {
884 let hi = (bytes[i + 1] as char).to_digit(16);
885 let lo = (bytes[i + 2] as char).to_digit(16);
886 match (hi, lo) {
887 (Some(hi), Some(lo)) => {
888 out.push(((hi << 4) | lo) as u8);
889 i += 3;
890 }
891 _ => {
892 out.push(b'%');
893 i += 1;
894 }
895 }
896 }
897 b'+' => {
898 out.push(b' ');
899 i += 1;
900 }
901 c => {
902 out.push(c);
903 i += 1;
904 }
905 }
906 }
907 String::from_utf8_lossy(&out).into_owned()
908}
909
910pub fn render_search_results(query: &str, results: &[SearchResult]) -> String {
913 let mut out = format!("[web_search: {} results for {query:?}]\n", results.len());
914 for (index, result) in results.iter().enumerate() {
915 out.push_str(&format!(
916 "\n{}. {} — {}\n",
917 index + 1,
918 result.title,
919 result.url
920 ));
921 if !result.snippet.is_empty() {
922 out.push_str(&format!(" {}\n", result.snippet));
923 }
924 }
925 out
926}
927
928#[cfg(test)]
929mod tests {
930 use super::*;
931
932 #[test]
933 fn html_converts_headings_links_and_lists_and_drops_scripts() {
934 let html = "<html><head><title>t</title></head><body>\
935 <script>var x = '<b>no</b>';</script>\
936 <h1>Title</h1><p>Hello <strong>world</strong> & friends.</p>\
937 <ul><li>one</li><li><a href=\"https://example.com/a\">two</a></li></ul>\
938 </body></html>";
939 let md = html_to_markdown(html);
940 assert!(md.contains("# Title"), "{md}");
941 assert!(md.contains("Hello **world** & friends."), "{md}");
942 assert!(md.contains("- one"), "{md}");
943 assert!(md.contains("[two](https://example.com/a)"), "{md}");
944 assert!(!md.contains("var x"), "script body leaked: {md}");
945 assert!(!md.contains('<'), "raw markup leaked: {md}");
946 }
947
948 #[test]
949 fn notebook_renders_cells_with_their_outputs() {
950 let nb = serde_json::json!({
951 "metadata": {"kernelspec": {"display_name": "Python 3"}},
952 "cells": [
953 {"cell_type": "markdown", "source": ["# Demo\n"]},
954 {"cell_type": "code", "execution_count": 1,
955 "source": ["print('hi')\n", "1 + 1\n"],
956 "outputs": [
957 {"output_type": "stream", "name": "stdout", "text": ["hi\n"]},
958 {"output_type": "execute_result", "data": {"text/plain": ["2"]}}
959 ]},
960 {"cell_type": "code", "source": ["boom()"],
961 "outputs": [{"output_type": "error", "ename": "NameError",
962 "evalue": "name 'boom' is not defined", "traceback": ["line 1"]}]}
963 ]
964 });
965 let out = notebook_markdown("demo.ipynb", nb.to_string().as_bytes());
966 assert!(out.contains("3 cells, kernel Python 3"), "{out}");
967 assert!(out.contains("--- cell 0 (markdown) ---"), "{out}");
968 assert!(out.contains("--- cell 1 (code) [1] ---"), "{out}");
969 assert!(out.contains("print('hi')"), "{out}");
970 assert!(out.contains("[stdout]\nhi"), "{out}");
971 assert!(out.contains("[execute_result]\n2"), "{out}");
972 assert!(
973 out.contains("[error] NameError: name 'boom' is not defined"),
974 "{out}"
975 );
976 }
977
978 #[test]
979 fn pdf_extracts_text_from_an_uncompressed_content_stream() {
980 let pdf = b"%PDF-1.4\n1 0 obj\n<< /Type /Page >>\nendobj\n\
982 2 0 obj\n<< /Length 60 >>\nstream\n\
983 BT /F1 12 Tf 72 720 Td (Hello parity) Tj T* (second line) Tj ET\n\
984 endstream\nendobj\ntrailer\n%%EOF\n";
985 let doc = pdf_text(pdf);
986 assert_eq!(doc.page_count, 1, "{doc:?}");
987 assert!(!doc.encrypted);
988 assert_eq!(doc.pages.len(), 1, "{doc:?}");
989 assert!(doc.pages[0].text.contains("Hello parity"), "{doc:?}");
990 assert!(doc.pages[0].text.contains("second line"), "{doc:?}");
991 let md = pdf_markdown("x.pdf", pdf);
992 assert!(md.contains("--- page 1 ---"), "{md}");
993 }
994
995 #[test]
996 fn pdf_extracts_text_from_a_flate_compressed_content_stream() {
997 use std::io::Write as _;
998 let content = b"BT (compressed text) Tj ET";
999 let mut encoder =
1000 flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
1001 encoder.write_all(content).unwrap();
1002 let compressed = encoder.finish().unwrap();
1003 let mut pdf = b"%PDF-1.7\n1 0 obj\n<< /Type /Page >>\nendobj\n\
1004 2 0 obj\n<< /Filter /FlateDecode >>\nstream\n"
1005 .to_vec();
1006 pdf.extend_from_slice(&compressed);
1007 pdf.extend_from_slice(b"\nendstream\nendobj\n%%EOF\n");
1008 let doc = pdf_text(&pdf);
1009 assert_eq!(doc.pages.len(), 1, "{doc:?}");
1010 assert!(doc.pages[0].text.contains("compressed text"), "{doc:?}");
1011 }
1012
1013 #[test]
1014 fn pdf_with_no_text_layer_says_so_honestly() {
1015 let pdf = b"%PDF-1.4\n1 0 obj\n<< /Type /Page >>\nendobj\n%%EOF\n";
1016 let md = pdf_markdown("scan.pdf", pdf);
1017 assert!(md.contains("no text extracted"), "{md}");
1018 assert!(md.contains("1 page objects"), "{md}");
1019 }
1020
1021 #[test]
1022 fn pdf_hex_and_utf16_strings_decode() {
1023 let pdf = b"%PDF-1.4\n1 0 obj\n<< /Type /Page >>\nendobj\n2 0 obj\n<< >>\nstream\n\
1024 BT <48656C6C6F> Tj ET\nendstream\nendobj\n%%EOF\n";
1025 let doc = pdf_text(pdf);
1026 assert!(doc.pages[0].text.contains("Hello"), "{doc:?}");
1027 }
1028
1029 #[test]
1030 fn search_results_parse_titles_urls_and_snippets_out_of_a_results_page() {
1031 let html = r#"<html><body>
1032 <div class="result results_links">
1033 <a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fdoc.rust-lang.org%2Fbook%2F&rut=xyz">The Rust <b>Book</b></a>
1034 <a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fdoc.rust-lang.org%2Fbook%2F">The official book about the Rust language.</a>
1035 </div>
1036 <div class="result results_links">
1037 <a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fcrates.io%2F">crates.io</a>
1038 <a class="result__snippet">The Rust package registry.</a>
1039 </div>
1040 </body></html>"#;
1041 let results = parse_html_search_results(html);
1042 assert_eq!(results.len(), 2, "{results:?}");
1043 assert_eq!(results[0].url, "https://doc.rust-lang.org/book/");
1044 assert_eq!(results[0].title, "The Rust **Book**");
1045 assert!(results[0].snippet.contains("official book"), "{results:?}");
1046 assert_eq!(results[1].url, "https://crates.io/");
1047 let rendered = render_search_results("rust book", &results);
1048 assert!(rendered.contains("2 results"), "{rendered}");
1049 assert!(
1050 rendered.contains("1. The Rust **Book** — https://doc.rust-lang.org/book/"),
1051 "{rendered}"
1052 );
1053 }
1054
1055 #[test]
1056 fn a_page_with_no_recognizable_results_yields_none() {
1057 assert!(parse_html_search_results("<html><body>nothing</body></html>").is_empty());
1058 }
1059}