use std::path::PathBuf;
use clap::{Parser, ValueEnum};
use redact_paperasse_core::{DocumentFormat, Engine, Input, OutputFormat};
#[derive(Parser)]
#[command(
name = "redactpapr",
about = "Redact PII from images, PDFs, text, and office documents (DOCX/XLSX/PPTX/RTF/EPUB/ODT/CSV/...)"
)]
struct Cli {
file: Option<PathBuf>,
#[arg(long, value_enum)]
r#as: Option<InputKind>,
#[arg(long, value_enum, default_value = "native")]
format: FormatArg,
#[arg(long)]
output: Option<PathBuf>,
#[arg(long)]
report: bool,
#[arg(long, value_delimiter = ',')]
entities: Option<Vec<String>>,
#[arg(long)]
score_threshold: Option<f32>,
#[cfg(feature = "tier-b")]
#[arg(long)]
tier_b: bool,
#[cfg(feature = "tier-b")]
#[arg(long, default_value = "en")]
language: String,
}
#[derive(Clone, ValueEnum)]
enum InputKind {
Text,
Pdf,
Image,
Docx,
Doc,
Xlsx,
Ods,
Pptx,
Ppt,
Odt,
Odp,
Rtf,
Epub,
Csv,
}
impl InputKind {
fn document_format(&self) -> Option<DocumentFormat> {
Some(match self {
InputKind::Docx => DocumentFormat::Docx,
InputKind::Doc => DocumentFormat::Doc,
InputKind::Xlsx => DocumentFormat::Excel,
InputKind::Ods => DocumentFormat::Ods,
InputKind::Pptx => DocumentFormat::Pptx,
InputKind::Ppt => DocumentFormat::Ppt,
InputKind::Odt => DocumentFormat::Odt,
InputKind::Odp => DocumentFormat::Odp,
InputKind::Rtf => DocumentFormat::Rtf,
InputKind::Epub => DocumentFormat::Epub,
InputKind::Csv => DocumentFormat::Csv,
InputKind::Text | InputKind::Pdf | InputKind::Image => return None,
})
}
}
#[derive(Clone, ValueEnum)]
enum FormatArg {
Native,
Markdown,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let bytes = match &cli.file {
Some(path) => std::fs::read(path)?,
None => {
use std::io::Read;
let mut buf = Vec::new();
std::io::stdin().read_to_end(&mut buf)?;
buf
}
};
let kind = cli
.r#as
.clone()
.unwrap_or_else(|| guess_kind(cli.file.as_deref(), &bytes));
let input = match kind {
InputKind::Text => Input::Text(String::from_utf8_lossy(&bytes).into_owned()),
InputKind::Pdf => Input::Pdf(bytes),
InputKind::Image => Input::Image(bytes),
_ => Input::Document {
bytes,
format: kind.document_format(),
},
};
let format = match cli.format {
FormatArg::Native => OutputFormat::Native,
FormatArg::Markdown => OutputFormat::Markdown,
};
#[cfg(feature = "tier-b")]
let result = if cli.tier_b {
run_with_tier_b(&cli, input, format).await?
} else {
Engine::default()
.process(input, format, cli.entities.as_deref(), cli.score_threshold)
.await?
};
#[cfg(not(feature = "tier-b"))]
let result = Engine::default()
.process(input, format, cli.entities.as_deref(), cli.score_threshold)
.await?;
if cli.report {
eprintln!("{}", serde_json::to_string_pretty(&result.entities)?);
}
let text_output = if format == OutputFormat::Markdown {
result.markdown.as_ref().or(result.text.as_ref())
} else {
result.text.as_ref().or(result.markdown.as_ref())
};
match (text_output, &result.bytes) {
(Some(text), None) => match cli.output {
Some(path) => std::fs::write(path, text)?,
None => println!("{text}"),
},
(_, Some(bytes)) => {
let path = cli
.output
.or_else(|| cli.file.as_ref().map(|f| f.with_extension("redacted.out")))
.expect("--output is required when redacting from stdin");
std::fs::write(path, bytes)?;
}
_ => unreachable!("Engine::process always sets text or bytes"),
}
Ok(())
}
#[cfg(feature = "tier-b")]
async fn run_with_tier_b(
cli: &Cli,
input: Input,
format: OutputFormat,
) -> anyhow::Result<redact_paperasse_core::RedactionResult> {
use redact_paperasse_core::detect::{TierA, TierB};
use redact_paperasse_core::redact::redact_text;
use redact_paperasse_core::ExtractedDocument;
let text = match input {
Input::Text(text) => text,
_ => anyhow::bail!(
"--tier-b only supports text input today: Tier B never has pixel coordinates, so it \
can't safely participate in image/PDF/document redaction (see run_with_tier_b's doc \
comment). Redact this input without --tier-b, or extract its text first."
),
};
let doc = ExtractedDocument {
text: text.clone(),
..Default::default()
};
let mut entities = TierA::default().analyze(&doc, cli.entities.as_deref(), cli.score_threshold);
let tier_b_entities = TierB::from_env()
.analyze(&text, &cli.language)
.await
.map_err(|e| anyhow::anyhow!("Tier B (Presidio) request failed: {e}. Set {} to point at a running presidio-analyzer, or drop --tier-b.", redact_paperasse_core::detect::tier_b::ANALYZER_URL_ENV))?;
entities.extend(tier_b_entities.into_iter().filter(|e| {
let entity_ok = cli
.entities
.as_deref()
.is_none_or(|wanted| wanted.iter().any(|w| w == &e.entity_type));
let score_ok = cli
.score_threshold
.is_none_or(|threshold| e.score >= threshold);
entity_ok && score_ok
}));
Ok(redact_text(&doc, &entities, format))
}
fn guess_kind(path: Option<&std::path::Path>, bytes: &[u8]) -> InputKind {
if bytes.starts_with(b"%PDF") {
return InputKind::Pdf;
}
match path.and_then(|p| p.extension()).and_then(|e| e.to_str()) {
Some("pdf") => InputKind::Pdf,
Some("png" | "jpg" | "jpeg" | "gif" | "webp") => InputKind::Image,
Some("docx" | "docm") => InputKind::Docx,
Some("doc") => InputKind::Doc,
Some("xlsx" | "xlsm" | "xlsb" | "xls") => InputKind::Xlsx,
Some("ods") => InputKind::Ods,
Some("pptx" | "pptm" | "ppsx" | "ppsm") => InputKind::Pptx,
Some("ppt" | "pps" | "pot") => InputKind::Ppt,
Some("odt") => InputKind::Odt,
Some("odp") => InputKind::Odp,
Some("rtf") => InputKind::Rtf,
Some("epub") => InputKind::Epub,
Some("csv") => InputKind::Csv,
_ => InputKind::Text,
}
}