mod hexdump;
mod input;
mod json;
mod q;
use pdfboss_core::pretty;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use clap::{Parser, Subcommand};
use pdfboss_core::{Document, Error, Metadata, ObjRef, Object};
use crate::input::is_url;
pub struct Failure {
pub message: String,
pub code: i32,
}
impl Failure {
pub fn new(message: impl Into<String>) -> Failure {
Failure {
message: message.into(),
code: 1,
}
}
pub fn program(message: impl Into<String>) -> Failure {
Failure {
message: message.into(),
code: 2,
}
}
}
impl From<String> for Failure {
fn from(message: String) -> Failure {
Failure::new(message)
}
}
#[derive(Parser)]
#[command(
name = "pdfboss",
version,
about = "PDF parsing, text extraction and rendering"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Info {
file: PathBuf,
},
Text {
file: PathBuf,
#[arg(long)]
page: Option<usize>,
},
Render {
file: PathBuf,
#[arg(long)]
page: usize,
#[arg(short, long)]
out: Option<PathBuf>,
#[arg(long, default_value_t = 1.0)]
scale: f32,
#[arg(long, value_enum, default_value_t = FontsArg::AllEmbedded)]
fonts: FontsArg,
#[arg(long)]
font_dir: Option<PathBuf>,
},
Obj {
file: PathBuf,
num: u32,
gen: Option<u16>,
},
Tui {
target: String,
},
Json {
input: String,
#[arg(long, conflicts_with = "decode")]
raw: bool,
#[arg(long)]
decode: bool,
#[arg(long, value_delimiter = ',')]
pages: Option<Vec<usize>>,
#[arg(long)]
no_logical: bool,
#[arg(long)]
content_ops: bool,
},
Hex {
input: String,
#[allow(rustdoc::broken_intra_doc_links)]
selector: Option<String>,
#[arg(long)]
annotate: bool,
#[arg(long, default_value_t = 16)]
width: usize,
},
Q {
input: String,
program: String,
#[arg(long, conflicts_with = "decode")]
raw: bool,
#[arg(long)]
decode: bool,
#[arg(long)]
hex: bool,
#[arg(short = 'r')]
raw_strings: bool,
#[arg(long, value_delimiter = ',')]
pages: Option<Vec<usize>>,
#[arg(long)]
no_logical: bool,
#[arg(long)]
content_ops: bool,
},
}
#[derive(Clone, Copy, Debug, Default, clap::ValueEnum)]
enum FontsArg {
EmbeddedOnly,
#[default]
AllEmbedded,
Full,
}
impl FontsArg {
fn to_painting(self) -> pdfboss_render::GlyphPainting {
use pdfboss_render::GlyphPainting;
match self {
FontsArg::EmbeddedOnly => GlyphPainting::EmbeddedTrueTypeOnly,
FontsArg::AllEmbedded => GlyphPainting::AllEmbedded,
FontsArg::Full => GlyphPainting::Full,
}
}
}
fn main() {
let cli = Cli::parse();
let result: Result<(), Failure> = match cli.command {
Command::Info { file } => cmd_info(&file).map_err(Failure::from),
Command::Text { file, page } => cmd_text(&file, page).map_err(Failure::from),
Command::Render {
file,
page,
out,
scale,
fonts,
font_dir,
} => cmd_render(&file, page, out, scale, fonts, font_dir).map_err(Failure::from),
Command::Obj { file, num, gen } => {
cmd_obj(&file, num, gen.unwrap_or(0)).map_err(Failure::from)
}
Command::Tui { target } => cmd_tui(&target).map_err(Failure::from),
Command::Json {
input,
raw,
decode,
pages,
no_logical,
content_ops,
} => {
let flags = q::value::TreeFlags {
raw,
decode,
pages,
no_logical,
content_ops,
};
json::cmd_json(&input, &flags).map_err(Failure::from)
}
Command::Hex {
input,
selector,
annotate,
width,
} => hexdump::cmd_hex(&input, selector.as_deref(), annotate, width).map_err(Failure::from),
Command::Q {
input,
program,
raw,
decode,
hex,
raw_strings,
pages,
no_logical,
content_ops,
} => {
let flags = q::value::TreeFlags {
raw,
decode,
pages,
no_logical,
content_ops,
};
q::run::cmd_q(&input, &program, &flags, hex, raw_strings)
}
};
if let Err(failure) = result {
eprintln!("pdfboss: {}", failure.message);
std::process::exit(failure.code);
}
}
fn cmd_info(file: &Path) -> Result<(), String> {
match Document::open(file) {
Ok(doc) => {
let sizes: Vec<Option<(f32, f32)>> = (0..doc.page_count())
.map(|i| doc.page(i).ok().map(|p| p.size()))
.collect();
print!(
"{}",
info_text(Some(doc.version()), false, Some(&sizes), &doc.metadata())
);
Ok(())
}
Err(Error::Encrypted) => {
let data = std::fs::read(file).map_err(|e| e.to_string())?;
print!(
"{}",
info_text(scan_version(&data), true, None, &Metadata::default())
);
Ok(())
}
Err(e) => Err(e.to_string()),
}
}
fn info_text(
version: Option<(u8, u8)>,
encrypted: bool,
sizes: Option<&[Option<(f32, f32)>]>,
meta: &Metadata,
) -> String {
let mut out = String::new();
match version {
Some((major, minor)) => {
let _ = writeln!(out, "version: {major}.{minor}");
}
None => {
let _ = writeln!(out, "version: unknown");
}
}
let _ = writeln!(out, "encrypted: {encrypted}");
match sizes {
Some(sizes) => {
let _ = writeln!(out, "pages: {}", sizes.len());
for (i, size) in sizes.iter().enumerate() {
match size {
Some((w, h)) => {
let _ = writeln!(out, " page {}: {w} x {h} pt", i + 1);
}
None => {
let _ = writeln!(out, " page {}: (unavailable)", i + 1);
}
}
}
}
None => {
let _ = writeln!(out, "pages: unknown");
}
}
let rows: [(&str, &Option<String>); 8] = [
("title", &meta.title),
("author", &meta.author),
("subject", &meta.subject),
("keywords", &meta.keywords),
("creator", &meta.creator),
("producer", &meta.producer),
("created", &meta.creation_date),
("modified", &meta.mod_date),
];
if rows.iter().any(|(_, v)| v.is_some()) {
let _ = writeln!(out, "metadata:");
for (label, value) in rows {
if let Some(value) = value {
let _ = writeln!(out, " {label:<9} {value}");
}
}
}
out
}
fn scan_version(data: &[u8]) -> Option<(u8, u8)> {
let window = &data[..data.len().min(1024)];
let pos = window.windows(5).position(|w| w == b"%PDF-")?;
let rest = &window[pos + 5..];
let major = (*rest.first()? as char).to_digit(10)? as u8;
if rest.get(1) != Some(&b'.') {
return None;
}
let minor = (*rest.get(2)? as char).to_digit(10)? as u8;
Some((major, minor))
}
fn cmd_text(file: &Path, page: Option<usize>) -> Result<(), String> {
let doc = Document::open(file).map_err(|e| e.to_string())?;
let text = match page {
Some(n) => {
let index = page_index(n, doc.page_count())?;
let page = doc.page(index).map_err(|e| e.to_string())?;
pdfboss_text::extract_text(&doc, &page).map_err(|e| e.to_string())?
}
None => {
let parts = pdfboss_core::map_pages(&doc, pdfboss_text::extract_text)
.into_iter()
.map(|text| text.map_err(|e| e.to_string()))
.collect::<Result<Vec<String>, String>>()?;
parts.join("\u{c}")
}
};
println!("{text}");
Ok(())
}
fn substitute_source(
fonts: FontsArg,
font_dir: Option<PathBuf>,
) -> Result<pdfboss_render::SubstituteSource, String> {
use pdfboss_render::SubstituteSource;
match fonts {
FontsArg::EmbeddedOnly | FontsArg::AllEmbedded => Ok(SubstituteSource::None),
FontsArg::Full => match font_dir {
Some(dir) => Ok(SubstituteSource::Dir(dir)),
None if pdfboss_render::builtin_fonts_available() => Ok(SubstituteSource::Builtin),
None => Err(
"--fonts full requested but no substitute faces are available: pass \
--font-dir <PATH> (a directory holding the substitute font files), or \
rebuild pdfboss with the default `substitute-fonts` feature (this \
binary was built without it) to bundle the OFL set."
.to_string(),
),
},
}
}
fn cmd_render(
file: &Path,
page: usize,
out: Option<PathBuf>,
scale: f32,
fonts: FontsArg,
font_dir: Option<PathBuf>,
) -> Result<(), String> {
if !scale.is_finite() || scale <= 0.0 {
return Err(format!("invalid scale {scale}: must be a positive number"));
}
let substitutes = substitute_source(fonts, font_dir)?;
let doc = Document::open(file).map_err(|e| e.to_string())?;
let index = page_index(page, doc.page_count())?;
let p = doc.page(index).map_err(|e| e.to_string())?;
let opts = pdfboss_render::RenderOptions {
glyph_painting: fonts.to_painting(),
substitutes,
};
let (pixmap, report) =
pdfboss_render::render_page_reporting(&doc, &p, scale, &opts).map_err(|e| e.to_string())?;
let out = out.unwrap_or_else(|| default_out(page));
pixmap.save_png(&out).map_err(|e| e.to_string())?;
for warning in report.warnings() {
eprintln!("warning: page {page}: {warning}");
}
match report.summary() {
Some(summary) => println!(
"wrote {} ({} x {} px) [{}]",
out.display(),
pixmap.width,
pixmap.height,
summary
),
None => println!(
"wrote {} ({} x {} px)",
out.display(),
pixmap.width,
pixmap.height
),
}
Ok(())
}
fn cmd_obj(file: &Path, num: u32, gen: u16) -> Result<(), String> {
let doc = Document::open(file).map_err(|e| e.to_string())?;
let obj = doc.get(ObjRef { num, gen }).map_err(|e| e.to_string())?;
match &obj {
Object::Stream(s) => {
println!("{}", pretty::format_dict(&s.dict));
match doc.stream_data(s) {
Ok(data) => println!("stream <{} bytes decoded>", data.len()),
Err(e) => println!("stream <decode failed: {e}>"),
}
}
other => println!("{}", pretty::format_object(other)),
}
Ok(())
}
fn cmd_tui(target: &str) -> Result<(), String> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
runtime.block_on(async {
let doc = open_async_document(target).await?;
pdfboss_tui::run(doc, display_title(target))
.await
.map_err(|e| e.to_string())
})
}
async fn open_async_document(target: &str) -> Result<pdfboss_aio::AsyncDocument, String> {
if is_url(target) {
return pdfboss_aio::AsyncDocument::open_url(target)
.await
.map_err(|e| format!("{target}: {e}"));
}
pdfboss_aio::AsyncDocument::open(target)
.await
.map_err(|e| format!("{target}: {e}"))
}
fn display_title(target: &str) -> String {
target
.rsplit('/')
.next()
.filter(|segment| !segment.is_empty())
.unwrap_or(target)
.to_string()
}
fn page_index(page: usize, count: usize) -> Result<usize, String> {
if page == 0 || page > count {
let plural = if count == 1 { "" } else { "s" };
Err(format!(
"page {page} out of range (document has {count} page{plural})"
))
} else {
Ok(page - 1)
}
}
fn default_out(page: usize) -> PathBuf {
PathBuf::from(format!("page-{page}.png"))
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn fonts_flag_defaults_to_all_embedded() {
let cli = Cli::parse_from(["pdfboss", "render", "in.pdf", "--page", "1"]);
let Command::Render { fonts, .. } = cli.command else {
panic!("expected render command");
};
assert!(matches!(fonts, FontsArg::AllEmbedded));
}
#[test]
fn fonts_flag_parses_embedded_only() {
let cli = Cli::parse_from([
"pdfboss",
"render",
"in.pdf",
"--page",
"1",
"--fonts",
"embedded-only",
]);
let Command::Render { fonts, .. } = cli.command else {
panic!("expected render command");
};
assert!(matches!(fonts, FontsArg::EmbeddedOnly));
}
#[test]
fn fonts_full_with_font_dir_parses_to_dir_source() {
let cli = Cli::parse_from([
"pdfboss",
"render",
"in.pdf",
"--page",
"1",
"--fonts",
"full",
"--font-dir",
"X",
]);
let Command::Render {
fonts, font_dir, ..
} = cli.command
else {
panic!("expected render command");
};
assert!(matches!(fonts, FontsArg::Full));
assert_eq!(font_dir, Some(PathBuf::from("X")));
let source = substitute_source(fonts, font_dir).expect("--font-dir given, always Ok");
assert!(matches!(source, pdfboss_render::SubstituteSource::Dir(p) if p == Path::new("X")));
}
#[test]
fn font_dir_defaults_to_none() {
let cli = Cli::parse_from(["pdfboss", "render", "in.pdf", "--page", "1"]);
let Command::Render { font_dir, .. } = cli.command else {
panic!("expected render command");
};
assert_eq!(font_dir, None);
}
#[test]
fn embedded_only_and_all_embedded_never_substitute() {
assert!(matches!(
substitute_source(FontsArg::EmbeddedOnly, None),
Ok(pdfboss_render::SubstituteSource::None)
));
assert!(matches!(
substitute_source(FontsArg::AllEmbedded, None),
Ok(pdfboss_render::SubstituteSource::None)
));
assert!(matches!(
substitute_source(FontsArg::AllEmbedded, Some(PathBuf::from("X"))),
Ok(pdfboss_render::SubstituteSource::None)
));
}
#[cfg(feature = "substitute-fonts")]
#[test]
fn full_without_font_dir_falls_back_to_builtin_faces() {
assert!(matches!(
substitute_source(FontsArg::Full, None),
Ok(pdfboss_render::SubstituteSource::Builtin)
));
}
#[cfg(not(feature = "substitute-fonts"))]
#[test]
fn full_without_font_dir_or_feature_is_actionable_error() {
let err = substitute_source(FontsArg::Full, None).expect_err("no dir, no feature");
assert!(err.contains("--font-dir"));
assert!(err.contains("substitute-fonts"));
}
#[test]
fn fonts_arg_maps_to_painting() {
assert_eq!(
FontsArg::EmbeddedOnly.to_painting(),
pdfboss_render::GlyphPainting::EmbeddedTrueTypeOnly
);
assert_eq!(
FontsArg::AllEmbedded.to_painting(),
pdfboss_render::GlyphPainting::AllEmbedded
);
assert_eq!(
FontsArg::Full.to_painting(),
pdfboss_render::GlyphPainting::Full
);
}
#[test]
fn info_text_normal_document() {
let sizes = [Some((612.0, 792.0))];
let meta = Metadata {
title: Some("Demo".to_string()),
..Metadata::default()
};
let report = info_text(Some((1, 7)), false, Some(&sizes), &meta);
assert!(report.contains("version: 1.7"));
assert!(report.contains("encrypted: false"));
assert!(report.contains("pages: 1"));
assert!(report.contains("page 1: 612 x 792 pt"));
assert!(report.contains("title"));
assert!(report.contains("Demo"));
}
#[test]
fn info_text_encrypted_document() {
let report = info_text(Some((1, 4)), true, None, &Metadata::default());
assert!(report.contains("encrypted: true"));
assert!(report.contains("pages: unknown"));
assert!(!report.contains("metadata:"));
}
#[test]
fn info_text_unavailable_page() {
let sizes = [None];
let report = info_text(None, false, Some(&sizes), &Metadata::default());
assert!(report.contains("version: unknown"));
assert!(report.contains("page 1: (unavailable)"));
}
#[test]
fn scan_version_finds_header() {
assert_eq!(scan_version(b"%PDF-1.7\n..."), Some((1, 7)));
assert_eq!(scan_version(b"junk\n%PDF-2.0\n"), Some((2, 0)));
assert_eq!(scan_version(b"no header here"), None);
assert_eq!(scan_version(b"%PDF-x.y"), None);
assert_eq!(scan_version(b""), None);
}
#[test]
fn page_index_validates_range() {
assert_eq!(page_index(1, 3), Ok(0));
assert_eq!(page_index(3, 3), Ok(2));
assert!(page_index(0, 3).is_err());
assert!(page_index(4, 3).is_err());
assert!(page_index(1, 0).is_err());
}
#[test]
fn default_out_names_by_page() {
assert_eq!(default_out(2), PathBuf::from("page-2.png"));
}
#[test]
fn failure_from_string_exits_one() {
let failure = Failure::from("boom".to_string());
assert_eq!(failure.code, 1);
assert_eq!(failure.message, "boom");
}
#[test]
fn failure_program_exits_two() {
let failure = Failure::program("bad program");
assert_eq!(failure.code, 2);
assert_eq!(failure.message, "bad program");
}
#[test]
fn json_flags_parse() {
let cli = Cli::parse_from([
"pdfboss",
"json",
"in.pdf",
"--raw",
"--pages",
"1,3",
"--no-logical",
"--content-ops",
]);
let Command::Json {
input,
raw,
decode,
pages,
no_logical,
content_ops,
} = cli.command
else {
panic!("expected json command");
};
assert_eq!(input, "in.pdf");
assert!(raw && !decode && no_logical && content_ops);
assert_eq!(pages, Some(vec![1, 3]));
}
#[test]
fn hex_flags_parse() {
let cli = Cli::parse_from([
"pdfboss",
"hex",
"in.pdf",
"obj:12",
"--annotate",
"--width",
"8",
]);
let Command::Hex {
input,
selector,
annotate,
width,
} = cli.command
else {
panic!("expected hex command");
};
assert_eq!(input, "in.pdf");
assert_eq!(selector.as_deref(), Some("obj:12"));
assert!(annotate);
assert_eq!(width, 8);
}
#[test]
fn q_flags_parse() {
let cli = Cli::parse_from(["pdfboss", "q", "in.pdf", ".header", "--hex", "-r"]);
let Command::Q {
input,
program,
raw,
decode,
hex,
raw_strings,
..
} = cli.command
else {
panic!("expected q command");
};
assert_eq!(input, "in.pdf");
assert_eq!(program, ".header");
assert!(hex && raw_strings);
assert!(!raw && !decode);
}
#[test]
fn tui_subcommand_parses() {
let cli = Cli::parse_from(["pdfboss", "tui", "in.pdf"]);
let Command::Tui { target } = cli.command else {
panic!("expected tui command");
};
assert_eq!(target, "in.pdf");
}
#[test]
fn url_detection() {
assert!(is_url("https://example.com/a.pdf"));
assert!(is_url("http://example.com/a.pdf"));
assert!(!is_url("plain.pdf"));
assert!(!is_url("dir/httpish.pdf"));
}
#[test]
fn display_title_takes_last_segment() {
assert_eq!(display_title("dir/sub/file.pdf"), "file.pdf");
assert_eq!(display_title("file.pdf"), "file.pdf");
assert_eq!(
display_title("https://example.com/docs/spec.pdf"),
"spec.pdf"
);
assert_eq!(display_title("trailing/"), "trailing/");
}
}