Skip to main content

pdfboss_tui/
lib.rs

1//! Interactive terminal explorer for PDF internals, implemented from
2//! ISO 32000 on top of `pdfboss-aio`'s async document model.
3//!
4//! State machine (`app`), pane models (`tree`, `inspector`, `hexview`,
5//! `preview`, `markdown`, `search`), key mapping (`input`) and rendering
6//! (`ui`) are pure and unit-testable; only [`run`] touches the terminal. The
7//! event loop `tokio::select!`s over the crossterm event stream, a
8//! background-task message channel and a 100 ms tick, so long operations
9//! (element streaming, hex fetches, search, preview rasterization) never
10//! block input.
11
12pub mod app;
13pub mod clipboard;
14pub mod hexview;
15pub mod input;
16pub mod inspector;
17pub mod markdown;
18pub mod preview;
19pub mod search;
20pub mod tree;
21pub mod ui;
22pub mod yank;
23
24use std::sync::atomic::{AtomicU64, Ordering};
25use std::sync::Arc;
26use std::time::Duration;
27
28use crossterm::event::{Event, EventStream};
29use crossterm::execute;
30use crossterm::terminal::{
31    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
32};
33use futures_util::StreamExt;
34use pdfboss_aio::AsyncDocument;
35use pdfboss_core::elements::{Element, ElementOpts, Span};
36use pdfboss_core::ObjRef;
37use ratatui::backend::CrosstermBackend;
38use ratatui::Terminal;
39use tokio::sync::mpsc::{self, UnboundedSender};
40
41use crate::app::{App, Cmd, Msg};
42use crate::hexview::{HexSource, WINDOW_BYTES};
43use crate::inspector::InspectorPayload;
44use crate::preview::{fit_scale, PreviewFrame};
45use crate::search::{object_matches, SearchHit};
46use crate::tree::TreeReq;
47
48/// Elements per tree batch message.
49const TREE_BATCH: usize = 64;
50
51/// Turns crossterm's raw terminal-attach failure into an actionable
52/// message when there is no real terminal to attach to, leaving any other
53/// I/O error untouched.
54///
55/// `enable_raw_mode` opens `/dev/tty` whenever stdin isn't itself a tty (a
56/// piped-stdio run under a test harness, a script, or a CI job); with no
57/// controlling terminal at all that open fails `ENXIO` ("Device not
58/// configured" -- raw OS error 6 on macOS, confirmed empirically: opening
59/// `/dev/tty` with no controlling terminal at all reports 6, not 25), and
60/// against a real non-tty device it fails `ENOTTY` ("Inappropriate ioctl
61/// for device", 25 on both macOS and Linux). Neither maps to a stable,
62/// matchable `io::ErrorKind` on stable Rust (macOS reports the
63/// nightly-only `ErrorKind::Uncategorized` here), so the raw OS error
64/// number is the only portable signal available.
65fn friendly_no_tty_error(err: std::io::Error) -> std::io::Error {
66    match err.raw_os_error() {
67        Some(6) | Some(25) => {
68            std::io::Error::new(err.kind(), "pdfboss tui requires an interactive terminal")
69        }
70        _ => err,
71    }
72}
73
74/// Restores the terminal on drop, so panics and early returns never leave
75/// the shell in raw mode.
76struct TerminalGuard;
77
78impl Drop for TerminalGuard {
79    fn drop(&mut self) {
80        disable_raw_mode().ok();
81        execute!(std::io::stdout(), LeaveAlternateScreen).ok();
82    }
83}
84
85/// Runs the explorer until the user quits. `doc` supplies all data (file-
86/// or HTTP-backed); `title` labels the status bar; `target` is the path
87/// or URL as given, used verbatim in yanked shell commands. Document-level
88/// errors become status-bar toasts; only terminal I/O errors are returned.
89pub async fn run(doc: AsyncDocument, title: String, target: String) -> std::io::Result<()> {
90    enable_raw_mode().map_err(friendly_no_tty_error)?;
91    // The guard is constructed before `EnterAlternateScreen` (not after) so
92    // that an early return from *that* fallible call still restores raw
93    // mode: a local already constructed at the point of an early `?` return
94    // is dropped, so there is no window where raw mode is enabled but
95    // unguarded.
96    let guard = TerminalGuard;
97    execute!(std::io::stdout(), EnterAlternateScreen)?;
98    let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?;
99    let size = terminal.size()?;
100    let mut app = App::new(
101        title,
102        target,
103        doc.version(),
104        doc.page_count(),
105        (size.width, size.height),
106    );
107    let (tx, mut rx) = mpsc::unbounded_channel::<Msg>();
108    let search_epoch = Arc::new(AtomicU64::new(0));
109    let mut events = EventStream::new();
110    let mut tick = tokio::time::interval(Duration::from_millis(100));
111    loop {
112        terminal.draw(|frame| ui::draw(&app, frame))?;
113        let msg = tokio::select! {
114            maybe_event = events.next() => match maybe_event {
115                Some(Ok(Event::Key(key))) => Msg::Key(key),
116                Some(Ok(Event::Resize(width, height))) => Msg::Resize(width, height),
117                Some(Ok(..)) => continue,
118                Some(Err(..)) | None => break,
119            },
120            Some(msg) = rx.recv() => msg,
121            _ = tick.tick() => Msg::Tick,
122        };
123        for cmd in app.update(msg) {
124            execute_cmd(&doc, &tx, &search_epoch, cmd);
125        }
126        if app.should_quit {
127            break;
128        }
129    }
130    drop(guard);
131    Ok(())
132}
133
134/// Spawns the background task a [`Cmd`] describes; completions come back
135/// to the loop as [`Msg`]s on the channel.
136fn execute_cmd(
137    doc: &AsyncDocument,
138    tx: &UnboundedSender<Msg>,
139    search_epoch: &Arc<AtomicU64>,
140    cmd: Cmd,
141) {
142    let doc = doc.clone();
143    let tx = tx.clone();
144    match cmd {
145        Cmd::LoadTree(req) => {
146            tokio::spawn(load_tree(doc, tx, req));
147        }
148        Cmd::LoadContents { page, r } => {
149            tokio::spawn(load_contents(doc, tx, page, r));
150        }
151        Cmd::LoadObject { generation, r } => {
152            tokio::spawn(async move {
153                let message = match doc.get_object(r).await {
154                    Ok(object) => Msg::InspectorLoaded {
155                        generation,
156                        payload: InspectorPayload::Object { r, object },
157                    },
158                    Err(error) => Msg::InspectorFailed {
159                        generation,
160                        error: error.to_string(),
161                    },
162                };
163                tx.send(message).ok();
164            });
165        }
166        Cmd::DecodeStream { generation, r } => {
167            tokio::spawn(async move {
168                let message = match decoded_stream_data(&doc, r).await {
169                    Ok((data, passthrough)) => Msg::InspectorLoaded {
170                        generation,
171                        payload: InspectorPayload::Decoded {
172                            r,
173                            data,
174                            passthrough,
175                        },
176                    },
177                    Err(error) => Msg::InspectorFailed { generation, error },
178                };
179                tx.send(message).ok();
180            });
181        }
182        Cmd::LoadHex {
183            generation,
184            source,
185            window_start,
186        } => {
187            tokio::spawn(load_hex(doc, tx, generation, source, window_start));
188        }
189        Cmd::StartSearch { generation, query } => {
190            let epoch = Arc::clone(search_epoch);
191            epoch.store(generation, Ordering::SeqCst);
192            tokio::spawn(run_search(doc, tx, epoch, generation, query));
193        }
194        Cmd::CancelSearch { generation } => {
195            search_epoch.store(generation, Ordering::SeqCst);
196        }
197        Cmd::RenderPreview {
198            generation,
199            page,
200            max_w,
201            max_h,
202            file_bytes,
203        } => {
204            tokio::spawn(render_preview(
205                doc, tx, generation, page, max_w, max_h, file_bytes,
206            ));
207        }
208        Cmd::ExtractMarkdown { generation, page } => {
209            tokio::spawn(extract_markdown(doc, tx, generation, page));
210        }
211        Cmd::Copy { text, what } => {
212            tx.send(Msg::Yanked {
213                result: copy_toast(&text, what),
214            })
215            .ok();
216        }
217        Cmd::YankMarkdown { page } => {
218            tokio::spawn(async move {
219                let result = match page_markdown(&doc, page).await {
220                    Ok(text) => copy_toast(&text, "markdown"),
221                    Err(error) => Err(error),
222                };
223                tx.send(Msg::Yanked { result }).ok();
224            });
225        }
226        Cmd::YankSpan {
227            source,
228            slice,
229            base,
230            format,
231        } => {
232            tokio::spawn(async move {
233                let what = match format {
234                    yank::YankFormat::Hexdump => "hexdump",
235                    yank::YankFormat::Bytes => "bytes",
236                };
237                let result = match yank_span_text(&doc, source, slice, base, format).await {
238                    Ok(text) => copy_toast(&text, what),
239                    Err(error) => Err(error),
240                };
241                tx.send(Msg::Yanked { result }).ok();
242            });
243        }
244    }
245}
246
247/// Copies `text` and words the toast: a native-clipboard copy is
248/// confirmed, an OSC 52 one is only sent.
249fn copy_toast(text: &str, what: &str) -> Result<String, String> {
250    let size = yank::human_size(text.len() as u64);
251    match clipboard::copy(text)? {
252        clipboard::Transport::Native => Ok(format!("copied {what} ({size})")),
253        clipboard::Transport::Osc52 => Ok(format!("sent {what} ({size}) via OSC 52")),
254    }
255}
256
257/// The over-cap refusal, or `None` when `len` fits.
258fn cap_error(len: u64) -> Option<String> {
259    if len <= yank::CAP_BYTES {
260        return None;
261    }
262    Some(format!(
263        "{} exceeds the {} yank cap",
264        yank::human_size(len),
265        yank::human_size(yank::CAP_BYTES)
266    ))
267}
268
269/// Fetches a yank's bytes (a file span, or a decoded objstm container
270/// narrowed to `slice`) and renders them as clipboard text.
271async fn yank_span_text(
272    doc: &AsyncDocument,
273    source: HexSource,
274    slice: Option<Span>,
275    base: u64,
276    format: yank::YankFormat,
277) -> Result<String, String> {
278    let bytes = match source {
279        HexSource::File { span } => doc
280            .read_span(span)
281            .await
282            .map_err(|error| error.to_string())?,
283        HexSource::DecodedObjStm { container } => decoded_stream_data(doc, container).await?.0,
284    };
285    let bytes = match slice {
286        Some(member) => {
287            let start = (member.start as usize).min(bytes.len());
288            let end = (member.end as usize).min(bytes.len()).max(start);
289            bytes[start..end].to_vec()
290        }
291        None => bytes,
292    };
293    if let Some(error) = cap_error(bytes.len() as u64) {
294        return Err(error);
295    }
296    Ok(match format {
297        yank::YankFormat::Hexdump => yank::hexdump_text(&bytes, base),
298        yank::YankFormat::Bytes => String::from_utf8_lossy(&bytes).into_owned(),
299    })
300}
301
302/// Whether a completed tree-population pass should be reported as an
303/// outright failure (`Msg::TreeFailed`) instead of a normal `done` batch:
304/// the pass delivered *zero* elements in total and recorded at least one
305/// parse error along the way (total salvage failure — nothing usable came
306/// out of it). A pass that delivered any real elements keeps the existing
307/// partial-salvage behavior, even when it also recorded errors.
308fn pass_failed(total_elements: usize, total_errors: usize) -> bool {
309    total_elements == 0 && total_errors > 0
310}
311
312/// Streams a tree section's elements in batches. Per-element parse errors
313/// are counted, never fatal (salvage semantics: a document with an
314/// unusable logical layer still explores physically).
315async fn load_tree(doc: AsyncDocument, tx: UnboundedSender<Msg>, req: TreeReq) {
316    let opts = match req {
317        TreeReq::Physical => ElementOpts {
318            physical: true,
319            logical: false,
320            pages: None,
321            content_ops: false,
322        },
323        TreeReq::Logical => ElementOpts {
324            physical: false,
325            logical: true,
326            pages: None,
327            content_ops: false,
328        },
329        // Contents folders load through `load_contents`.
330        TreeReq::Contents { .. } => return,
331    };
332    let mut stream = doc.elements(opts);
333    let mut batch: Vec<Element> = Vec::new();
334    let mut errors = 0usize;
335    // Totals persist across mid-stream flushes (which reset `batch` and
336    // `errors` below) so the end-of-pass decision sees the whole pass,
337    // not just the last unflushed chunk.
338    let mut total_elements = 0usize;
339    let mut total_errors = 0usize;
340    while let Some(item) = stream.next().await {
341        match item {
342            Ok(element) => {
343                batch.push(element);
344                total_elements += 1;
345            }
346            Err(..) => {
347                errors += 1;
348                total_errors += 1;
349            }
350        }
351        if batch.len() >= TREE_BATCH {
352            let elements = std::mem::take(&mut batch);
353            let batch_errors = std::mem::take(&mut errors);
354            let sent = tx.send(Msg::TreeBatch {
355                req,
356                elements,
357                errors: batch_errors,
358                done: false,
359            });
360            if sent.is_err() {
361                return;
362            }
363        }
364    }
365    if pass_failed(total_elements, total_errors) {
366        // Total salvage failure: nothing usable ever came out of this
367        // pass. Emitting `TreeFailed` (instead of a `done: true` batch
368        // with zero elements) marks the section Failed so a re-expand
369        // retries the load, rather than looking permanently empty.
370        let error = format!("{total_errors} element(s) failed to parse, nothing salvaged");
371        tx.send(Msg::TreeFailed { req, error }).ok();
372    } else {
373        tx.send(Msg::TreeBatch {
374            req,
375            elements: batch,
376            errors,
377            done: true,
378        })
379        .ok();
380    }
381}
382
383/// Fetches a page dict and reports its `/Contents` refs (a single ref or
384/// an array of refs).
385async fn load_contents(doc: AsyncDocument, tx: UnboundedSender<Msg>, page: usize, r: ObjRef) {
386    let message = match page_contents(&doc, r).await {
387        Ok(refs) => Msg::ContentsLoaded { page, refs },
388        Err(error) => Msg::ContentsFailed { page, error },
389    };
390    tx.send(message).ok();
391}
392
393async fn page_contents(doc: &AsyncDocument, r: ObjRef) -> Result<Vec<ObjRef>, String> {
394    let object = doc.get_object(r).await.map_err(|error| error.to_string())?;
395    let Some(dict) = object.as_dict() else {
396        return Err(format!("object {} {} is not a page dict", r.num, r.gen));
397    };
398    let mut refs = Vec::new();
399    match dict.get("Contents") {
400        Some(pdfboss_core::Object::Ref(content_ref)) => refs.push(*content_ref),
401        Some(pdfboss_core::Object::Array(items)) => {
402            for item in items {
403                if let pdfboss_core::Object::Ref(content_ref) = item {
404                    refs.push(*content_ref);
405                }
406            }
407        }
408        Some(..) | None => {}
409    }
410    Ok(refs)
411}
412
413/// Decoded data of stream object `r`, plus the trailing image codec's name
414/// when `decode_stream` leaves the bytes encoded for the image layer — the
415/// Ops view labels such a passthrough instead of disassembling it.
416async fn decoded_stream_data(
417    doc: &AsyncDocument,
418    r: ObjRef,
419) -> Result<(Vec<u8>, Option<String>), String> {
420    let object = doc.get_object(r).await.map_err(|error| error.to_string())?;
421    let Some(stream) = object.as_stream() else {
422        return Err(format!("object {} {} is not a stream", r.num, r.gen));
423    };
424    let passthrough = pdfboss_core::filters::trailing_filter_with(doc, &stream.dict)
425        .await
426        .filter(|name| pdfboss_core::filters::is_image_codec(&name.0))
427        .map(|name| name.0);
428    let data = doc
429        .decode_stream(stream)
430        .await
431        .map_err(|error| error.to_string())?;
432    Ok((data, passthrough))
433}
434
435/// Loads one hex window: a `read_span` window of a file span, or the whole
436/// decoded object-stream container (decoded buffers are small).
437async fn load_hex(
438    doc: AsyncDocument,
439    tx: UnboundedSender<Msg>,
440    generation: u64,
441    source: HexSource,
442    window_start: u64,
443) {
444    let outcome: Result<(u64, u64, Vec<u8>), String> = match source {
445        HexSource::File { span } => {
446            let total_len = span.end.saturating_sub(span.start);
447            let start = span.start + window_start;
448            let end = (start + WINDOW_BYTES as u64).min(span.end);
449            match doc.read_span(Span { start, end }).await {
450                Ok(bytes) => Ok((window_start, total_len, bytes)),
451                Err(error) => Err(error.to_string()),
452            }
453        }
454        HexSource::DecodedObjStm { container } => {
455            match decoded_stream_data(&doc, container).await {
456                Ok((bytes, _)) => Ok((0, bytes.len() as u64, bytes)),
457                Err(error) => Err(error),
458            }
459        }
460    };
461    let message = match outcome {
462        Ok((start, total_len, bytes)) => Msg::HexLoaded {
463            generation,
464            window_start: start,
465            total_len,
466            bytes,
467        },
468        Err(error) => Msg::HexFailed { generation, error },
469    };
470    tx.send(message).ok();
471}
472
473/// Visits physical objects lazily, streaming one message per match. A
474/// newer search generation (shared epoch) terminates this task early.
475async fn run_search(
476    doc: AsyncDocument,
477    tx: UnboundedSender<Msg>,
478    epoch: Arc<AtomicU64>,
479    generation: u64,
480    query: String,
481) {
482    let opts = ElementOpts {
483        physical: true,
484        logical: false,
485        pages: None,
486        content_ops: false,
487    };
488    let mut stream = doc.elements(opts);
489    while let Some(item) = stream.next().await {
490        if epoch.load(Ordering::SeqCst) != generation {
491            return;
492        }
493        let Ok(Element::IndirectObject { r, object, .. }) = item else {
494            continue;
495        };
496        if object_matches(&query, r.num, &object) {
497            let hit = SearchHit { r };
498            if tx.send(Msg::SearchResult { generation, hit }).is_err() {
499                return;
500            }
501        }
502    }
503    tx.send(Msg::SearchDone { generation }).ok();
504}
505
506/// Renders a page preview. The whole file is fetched once (and cached by
507/// the app for later renders); rasterization runs in `spawn_blocking`, and
508/// the sync `Document` is created and dropped entirely inside the closure
509/// (it is not `Send`).
510async fn render_preview(
511    doc: AsyncDocument,
512    tx: UnboundedSender<Msg>,
513    generation: u64,
514    page: usize,
515    max_w: u32,
516    max_h: u32,
517    file_bytes: Option<Arc<Vec<u8>>>,
518) {
519    let bytes = match file_bytes {
520        Some(bytes) => Ok(bytes),
521        None => fetch_whole_file(&doc).await.map(Arc::new),
522    };
523    let bytes = match bytes {
524        Ok(bytes) => bytes,
525        Err(error) => {
526            tx.send(Msg::PreviewReady {
527                generation,
528                result: Err(error),
529            })
530            .ok();
531            return;
532        }
533    };
534    let render_input = Arc::clone(&bytes);
535    // The render is lenient: content pdfboss cannot read is skipped, so the
536    // preview can come out blank with no error at all. The report's summary
537    // rides along and becomes a status-bar toast.
538    let rendered = tokio::task::spawn_blocking(
539        move || -> Result<(pdfboss_render::Pixmap, Option<String>), String> {
540            let document = pdfboss_core::Document::load(render_input.as_ref().clone())
541                .map_err(|error| error.to_string())?;
542            let page_object = document.page(page).map_err(|error| error.to_string())?;
543            let (page_w, page_h) = page_object.size();
544            let scale = fit_scale(page_w, page_h, max_w, max_h);
545            let options = pdfboss_render::RenderOptions::default();
546            let (pixmap, report) =
547                pdfboss_render::render_page_reporting(&document, &page_object, scale, &options)
548                    .map_err(|error| error.to_string())?;
549            Ok((pixmap, report.summary()))
550        },
551    )
552    .await;
553    let result = match rendered {
554        Ok(Ok((pixmap, notice))) => Ok(PreviewFrame {
555            file_bytes: bytes,
556            pixmap,
557            notice,
558        }),
559        Ok(Err(error)) => Err(error),
560        Err(join_error) => Err(join_error.to_string()),
561    };
562    tx.send(Msg::PreviewReady { generation, result }).ok();
563}
564
565/// Extracts one page as Markdown. Unlike the preview this needs no
566/// whole-file fetch and no `spawn_blocking`: `extract_page_markdown_with`
567/// runs directly over the `AsyncDocument`, which is `Send` and fetches
568/// only the objects the page's text touches.
569async fn extract_markdown(
570    doc: AsyncDocument,
571    tx: UnboundedSender<Msg>,
572    generation: u64,
573    page: usize,
574) {
575    let result = page_markdown(&doc, page).await;
576    tx.send(Msg::MarkdownReady { generation, result }).ok();
577}
578
579/// One page extracted as Markdown, shared by the pane and the yank menu.
580async fn page_markdown(doc: &AsyncDocument, page: usize) -> Result<String, String> {
581    let page_object = doc.page(page).map_err(|error| error.to_string())?;
582    let oc = doc.oc_state().await;
583    pdfboss_output::extract_page_markdown_with(doc, &page_object, oc.as_ref())
584        .await
585        .map_err(|error| error.to_string())
586}
587
588/// Fetches the entire file via one `read_span` over
589/// `0..doc.file_len()` (the aio crate reports the length synchronously).
590async fn fetch_whole_file(doc: &AsyncDocument) -> Result<Vec<u8>, String> {
591    let end = doc.file_len();
592    doc.read_span(Span { start: 0, end })
593        .await
594        .map_err(|error| error.to_string())
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    #[test]
602    fn pass_failed_true_only_when_zero_elements_and_some_errors() {
603        assert!(
604            pass_failed(0, 1),
605            "zero elements with at least one error is total failure"
606        );
607        assert!(
608            pass_failed(0, 5),
609            "any positive error count still fails when no elements arrived"
610        );
611        assert!(!pass_failed(0, 0), "empty-but-clean pass is not a failure");
612        assert!(
613            !pass_failed(3, 2),
614            "partial salvage: any real element wins over errors"
615        );
616        assert!(!pass_failed(3, 0), "clean pass with elements");
617    }
618
619    /// The markdown task end to end over the real async path: no
620    /// whole-file fetch, no `spawn_blocking`, and the page's text comes
621    /// back on the channel as a `MarkdownReady`.
622    #[tokio::test]
623    async fn extract_markdown_sends_the_page_text() {
624        let doc = AsyncDocument::from_bytes(pdfboss_testkit::simple_doc("Hello"))
625            .await
626            .expect("fixture opens");
627        let (tx, mut rx) = mpsc::unbounded_channel::<Msg>();
628        extract_markdown(doc, tx, 7, 0).await;
629        match rx.try_recv().expect("one message") {
630            Msg::MarkdownReady { generation, result } => {
631                assert_eq!(generation, 7, "the request's generation rides along");
632                assert!(
633                    result.expect("extraction succeeds").contains("Hello"),
634                    "the page's text must reach the pane"
635                );
636            }
637            other => panic!("expected MarkdownReady, got {:?}", other),
638        }
639    }
640
641    /// A page index the document does not have fails the task, not the
642    /// event loop: the error travels as the message's `Err`.
643    #[tokio::test]
644    async fn extract_markdown_reports_a_missing_page_as_an_error() {
645        let doc = AsyncDocument::from_bytes(pdfboss_testkit::simple_doc("Hello"))
646            .await
647            .expect("fixture opens");
648        let (tx, mut rx) = mpsc::unbounded_channel::<Msg>();
649        extract_markdown(doc, tx, 1, 99).await;
650        match rx.try_recv().expect("one message") {
651            Msg::MarkdownReady { result, .. } => {
652                assert!(result.is_err(), "page 99 does not exist");
653            }
654            other => panic!("expected MarkdownReady, got {:?}", other),
655        }
656    }
657
658    #[tokio::test]
659    async fn yank_span_text_formats_a_file_span() {
660        let data = pdfboss_testkit::simple_doc("Hello");
661        let doc = AsyncDocument::from_bytes(data.clone())
662            .await
663            .expect("fixture opens");
664        let source = HexSource::File {
665            span: Span { start: 0, end: 8 },
666        };
667        let bytes_text = yank_span_text(&doc, source.clone(), None, 0, yank::YankFormat::Bytes)
668            .await
669            .expect("bytes");
670        assert_eq!(bytes_text, String::from_utf8_lossy(&data[0..8]));
671        let hexdump = yank_span_text(&doc, source, None, 0, yank::YankFormat::Hexdump)
672            .await
673            .expect("hexdump");
674        assert!(
675            hexdump.starts_with("00000000: 25 "),
676            "%PDF starts with 0x25: {hexdump}"
677        );
678        assert_eq!(hexdump.lines().count(), 1, "8 bytes fit one line");
679    }
680
681    #[tokio::test]
682    async fn yank_span_text_slices_the_member_range() {
683        let data = pdfboss_testkit::simple_doc("Hello");
684        let doc = AsyncDocument::from_bytes(data.clone())
685            .await
686            .expect("fixture opens");
687        let source = HexSource::File {
688            span: Span { start: 0, end: 8 },
689        };
690        let text = yank_span_text(
691            &doc,
692            source,
693            Some(Span { start: 2, end: 4 }),
694            2,
695            yank::YankFormat::Bytes,
696        )
697        .await
698        .expect("sliced bytes");
699        assert_eq!(text, String::from_utf8_lossy(&data[2..4]));
700    }
701
702    #[test]
703    fn cap_error_rejects_over_cap_payloads() {
704        assert!(cap_error(yank::CAP_BYTES).is_none());
705        let message = cap_error(yank::CAP_BYTES + 1).expect("over cap");
706        assert!(message.contains("1.0 MiB"), "{message}");
707    }
708
709    #[tokio::test]
710    async fn fetch_whole_file_reads_exactly_file_len_bytes() {
711        let data = pdfboss_testkit::simple_doc("Hello");
712        let doc = AsyncDocument::from_bytes(data.clone())
713            .await
714            .expect("fixture opens");
715        assert_eq!(doc.file_len(), data.len() as u64, "reported length");
716        let fetched = fetch_whole_file(&doc).await.expect("whole-file fetch");
717        assert_eq!(fetched, data, "fetch covers the entire file");
718    }
719}