Skip to main content

supercode_harness/tools/
convert.rs

1//! BP-2: document conversion for the read and web tools — the "returns it
2//! in a form the model can actually use" half of three catalog rows:
3//!
4//! * [`pdf_text`] — PDF → extracted text pages (catalog:27 "Read tool
5//!   returns images/PDFs/ipynb as model-visible content"). A small
6//!   in-crate extractor over the PDF content streams (`flate2` for the
7//!   `/FlateDecode` filter every real-world writer uses), not a full PDF
8//!   renderer: it recovers the text layer, and says so honestly when a
9//!   document has none (scanned/image-only or encrypted).
10//! * [`notebook_markdown`] — `.ipynb` → cells rendered WITH their outputs
11//!   (same row).
12//! * [`html_to_markdown`] — HTML → markdown (catalog:44 "Fetch a URL,
13//!   convert to markdown, return to model").
14//!
15//! Same "small parser over a crate" precedent as `config::glob_match`,
16//! `tools::url_host` and `builtins::base64_encode`.
17
18use std::io::Read as _;
19
20// ---- PDF ------------------------------------------------------------------
21
22/// Whether `path` has a `.pdf` extension (case-insensitive).
23pub 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/// One extracted PDF page (content stream, in file order).
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct PdfPage {
32    /// 1-based ordinal of the content stream this text came from.
33    pub index: usize,
34    /// The text recovered from that stream.
35    pub text: String,
36}
37
38/// What [`pdf_text`] recovered from a PDF's bytes.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct PdfDocument {
41    /// `/Type /Page` objects counted in the file (0 when unparseable).
42    pub page_count: usize,
43    /// Whether the document declares an `/Encrypt` dictionary — the usual
44    /// reason a structurally fine PDF yields no text.
45    pub encrypted: bool,
46    /// Pages whose content stream yielded any text, in file order.
47    pub pages: Vec<PdfPage>,
48}
49
50/// Extract the text layer of a PDF.
51///
52/// Never fails: a document with no recoverable text comes back with an
53/// empty `pages`, which the caller renders as an honest structured summary
54/// rather than as silence.
55pub 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
97/// Every `stream`…`endstream` payload, inflated when the object's
98/// dictionary declares `/FlateDecode`, skipping the ones that are plainly
99/// not text (images, embedded fonts/files).
100fn 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        // `endstream`/`endobj` also contain "stream"; require a token start.
106        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        // Skip the EOL that must follow the `stream` keyword.
113        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        // The dictionary is whatever precedes the `stream` keyword back to
124        // the object header — enough to read the filter and subtype.
125        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            // Some other filter (LZW, RunLength, ASCII85…) — not decoded.
148            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
161/// zlib-inflate `data`, tolerating the odd writer that omits the zlib
162/// header (raw deflate).
163fn 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
177/// Pull the shown strings out of a decoded content stream: `(...) Tj`,
178/// `(...) '`/`"`, and the `[ (..) -250 (..) ] TJ` array form, with
179/// `Td`/`TD`/`T*`/`ET` treated as line breaks.
180fn 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    // Collapse the blank runs an operator-level extraction inevitably makes.
234    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
252/// A `(...)` literal string with PDF escapes, starting at `open`.
253/// Returns the decoded text and the index just past the closing paren.
254fn 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
312/// A `<...>` hex string starting at `open`.
313fn 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
336/// PDFDocEncoding is Latin-1-compatible for the printable range; a
337/// UTF-16BE string (the other encoding a writer may emit) announces itself
338/// with a BOM or with a run of NUL high bytes.
339fn 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
360/// Render a PDF for the model: extracted pages, or an honest structured
361/// summary when the document carries no recoverable text layer.
362pub 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
391// ---- Jupyter notebooks ----------------------------------------------------
392
393/// Whether `path` has an `.ipynb` extension (case-insensitive).
394pub 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
400/// A notebook `source`/`text` field: either a string or an array of lines.
401fn 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
413/// Render a `.ipynb` for the model: every cell with its type, execution
414/// count and its OUTPUTS (stdout/stderr streams, text results, errors) —
415/// the half a raw JSON decode buries.
416pub 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
509// ---- HTML -----------------------------------------------------------------
510
511/// Convert an HTML document to markdown: headings, links, list items,
512/// emphasis, code and block structure survive; `<script>`/`<style>`/
513/// comments and every other tag are dropped, entities are decoded.
514///
515/// A small tag-walking converter, not a DOM: the model needs the readable
516/// text and its structure, and a fetched page's markup is never trusted
517/// input for anything but text.
518pub 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    // Open-anchor href, waiting for its text.
523    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    // Collapse the blank runs block-level tags leave behind.
606    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
646/// The lowercase tag name of a raw tag body (`/` kept for closing tags).
647fn 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
664/// One attribute's value out of a raw tag body, single- or double-quoted.
665fn 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
692/// Collapse every whitespace run in a text node to a single space,
693/// KEEPING the leading/trailing one: inter-tag spacing (`Hello
694/// <strong>world</strong> and`) lives in exactly those runs, and the
695/// caller drops a leading space when the output already ends in one.
696fn 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
716/// Decode the named entities a text-extraction path actually meets, plus
717/// numeric (`&#123;` / `&#x7b;`) references.
718pub 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// ---- search-result extraction --------------------------------------------
775
776/// One result from an HTML search-results page.
777#[derive(Debug, Clone, PartialEq, Eq)]
778pub struct SearchResult {
779    /// The result's link text.
780    pub title: String,
781    /// The destination URL, unwrapped from the engine's redirector.
782    pub url: String,
783    /// The engine's snippet, when the page carries one.
784    pub snippet: String,
785}
786
787/// BP-2 (catalog:45 "Provider/server-backed search"): pull results out of a
788/// DuckDuckGo-style HTML results page — the `result__a` anchors and their
789/// `result__snippet` siblings, in page order.
790///
791/// Structure-tolerant on purpose: an engine that changes its markup yields
792/// zero results here, and the caller then falls back to the page as
793/// markdown rather than pretending there were no hits.
794pub 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
825/// Every `<tag …class="… wanted …">inner</tag>` in `html`, as
826/// `(raw attributes, inner html)`. Nesting of the SAME tag inside a match
827/// is not handled — search-result markup does not nest anchors.
828fn 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
857/// A results page links through a redirector (`//duckduckgo.com/l/?uddg=…`);
858/// the destination the model needs is the encoded parameter.
859fn 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
876/// Percent-decode a URL component (`%20`, `+` as space).
877pub 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
910/// Render extracted results for the model: a numbered list of
911/// `title — url` with each engine snippet under it.
912pub 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> &amp; 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        // A minimal one-page PDF with a plain (unfiltered) content stream.
981        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&amp;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}