use super::options::{RedactionOptions, RedactionReport};
use super::overlay::region_overlay_ops;
use super::region::RegionSet;
use super::serialize::serialize_operator;
use super::text_engine::{redact_text_stream, FontMetrics};
use crate::content::parser::parse_content_stream;
use crate::error::{Error, Result};
use crate::fonts::FontInfo;
use std::collections::HashMap;
use std::sync::Arc;
pub struct FontInfoMetrics {
fonts: HashMap<String, Arc<FontInfo>>,
}
impl FontInfoMetrics {
pub fn new(fonts: HashMap<String, Arc<FontInfo>>) -> Self {
Self { fonts }
}
}
impl FontMetrics for FontInfoMetrics {
fn width(&self, font: &str, code: u32) -> f32 {
match self.fonts.get(font) {
Some(fi) => fi.get_glyph_width(code.min(u32::from(u16::MAX)) as u16),
None => 1000.0,
}
}
fn is_simple(&self, font: &str) -> bool {
match self.fonts.get(font) {
Some(fi) => fi.subtype != "Type0",
None => false,
}
}
}
pub fn redact_content_stream(
content: &[u8],
regions: &RegionSet,
opts: &RedactionOptions,
fonts: &dyn FontMetrics,
) -> Result<(Vec<u8>, RedactionReport)> {
let ops = parse_content_stream(content)?;
let te = redact_text_stream(&ops, regions, opts.edge_padding, fonts);
if te.unsupported_font {
return Err(Error::Unsupported(
"destructive text redaction of composite/Type0 font content is not yet \
supported; refusing rather than risk leaving recoverable text"
.to_string(),
));
}
let mut body = Vec::with_capacity(content.len());
for op in &te.operators {
serialize_operator(&mut body, op);
}
for region in ®ions.regions {
body.extend_from_slice(®ion_overlay_ops(region, opts));
}
let report = RedactionReport {
regions: regions.len(),
glyphs_removed: te.glyphs_removed,
annotations_removed: 0,
fonts_scrubbed: 0,
bytes_removed: te.bytes_removed,
..RedactionReport::default()
};
Ok((body, report))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::redaction::region::RedactionRegion;
struct Stub;
impl FontMetrics for Stub {
fn width(&self, _f: &str, _c: u32) -> f32 {
500.0
}
}
fn assert_absent(out: &[u8], secret: &[u8]) {
assert!(
out.windows(secret.len()).all(|w| w != secret),
"secret {:?} still present in redacted output: {:?}",
String::from_utf8_lossy(secret),
String::from_utf8_lossy(out)
);
}
fn one_region(x0: f32, y0: f32, x1: f32, y1: f32) -> RegionSet {
let mut rs = RegionSet::new(0);
rs.push(RedactionRegion::from_rect(x0, y0, x1, y1, Some([0.0, 0.0, 0.0])));
rs
}
const SECRET_DOC: &[u8] = b"BT\n/F1 10 Tf\n1 0 0 1 100 700 Tm\n(TOPSECRET) Tj\nET\n";
#[test]
fn secret_fully_in_region_is_removed_and_overlaid() {
let regions = one_region(90.0, 695.0, 160.0, 715.0);
let (out, report) =
redact_content_stream(SECRET_DOC, ®ions, &RedactionOptions::default(), &Stub)
.unwrap();
assert_absent(&out, b"TOPSECRET");
assert_absent(&out, b"SECRET");
assert_eq!(report.glyphs_removed, 9);
assert_eq!(report.regions, 1);
assert!(report.bytes_removed > 0);
let s = String::from_utf8_lossy(&out);
assert!(
s.contains("rg\n") && s.contains(" re\n") && s.contains("\nf\n"),
"overlay missing in: {s}"
);
}
#[test]
fn public_text_outside_region_survives_verbatim() {
let doc = b"BT\n/F1 10 Tf\n1 0 0 1 100 700 Tm\n(PUBLIC) Tj\nET\n";
let regions = one_region(0.0, 0.0, 5.0, 5.0); let (out, report) =
redact_content_stream(doc, ®ions, &RedactionOptions::default(), &Stub).unwrap();
assert_eq!(report.glyphs_removed, 0);
assert!(
out.windows(6).any(|w| w == b"PUBLIC"),
"public text must survive: {}",
String::from_utf8_lossy(&out)
);
}
#[test]
fn straddling_secret_partially_removed_public_kept() {
let doc = b"BT\n/F1 10 Tf\n1 0 0 1 100 700 Tm\n(PUBSECRET) Tj\nET\n";
let regions = one_region(120.0, 695.0, 400.0, 715.0);
let (out, report) =
redact_content_stream(doc, ®ions, &RedactionOptions::default(), &Stub).unwrap();
assert_absent(&out, b"SECRET");
assert!(report.glyphs_removed >= 6);
assert!(
out.windows(3).any(|w| w == b"PUB"),
"public prefix must survive: {}",
String::from_utf8_lossy(&out)
);
}
#[test]
fn composite_font_is_refused_not_under_redacted() {
struct Composite;
impl FontMetrics for Composite {
fn width(&self, _f: &str, _c: u32) -> f32 {
500.0
}
fn is_simple(&self, _f: &str) -> bool {
false
}
}
let regions = one_region(0.0, 0.0, 1000.0, 1000.0);
let err =
redact_content_stream(SECRET_DOC, ®ions, &RedactionOptions::default(), &Composite)
.unwrap_err();
assert!(matches!(err, Error::Unsupported(_)), "expected refusal, got {err:?}");
}
#[test]
fn no_regions_keeps_content_and_draws_nothing() {
let (out, report) = redact_content_stream(
SECRET_DOC,
&RegionSet::new(0),
&RedactionOptions::default(),
&Stub,
)
.unwrap();
assert_eq!(report.glyphs_removed, 0);
assert_eq!(report.regions, 0);
assert!(out.windows(9).any(|w| w == b"TOPSECRET"));
}
#[test]
fn malformed_stream_is_a_clean_error_not_a_panic() {
let regions = one_region(0.0, 0.0, 1000.0, 1000.0);
let _ = redact_content_stream(
b"q Q (unbalanced",
®ions,
&RedactionOptions::default(),
&Stub,
);
let _ = redact_content_stream(b"", ®ions, &RedactionOptions::default(), &Stub);
}
}