1pub 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
48const TREE_BATCH: usize = 64;
50
51fn 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
74struct 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
85pub async fn run(doc: AsyncDocument, title: String, target: String) -> std::io::Result<()> {
90 enable_raw_mode().map_err(friendly_no_tty_error)?;
91 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
134fn 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
247fn 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
257fn 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
269async 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
302fn pass_failed(total_elements: usize, total_errors: usize) -> bool {
309 total_elements == 0 && total_errors > 0
310}
311
312async 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 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 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 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
383async 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
413async 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
435async 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
473async 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
506async 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 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
565async 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
579async 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
588async 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 #[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 #[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}