use std::io::Cursor;
use image::ImageFormat;
use image::{DynamicImage, ImageReader, Rgba, RgbaImage};
#[cfg(not(target_arch = "wasm32"))]
use liteparse::types::PdfInput;
#[cfg(not(target_arch = "wasm32"))]
use liteparse::LiteParse;
#[cfg(not(target_arch = "wasm32"))]
use printpdf::{
ImageOptimizationOptions, Mm, Op, PdfDocument, PdfPage, PdfSaveOptions, RawImage,
XObjectTransform,
};
use crate::error::EngineError;
use crate::types::{Entity, ExtractedDocument, OutputFormat, RedactionResult};
const MASK_CHAR: char = '█';
const MASK_COLOR: Rgba<u8> = Rgba([0, 0, 0, 255]);
fn mask_text(text: &str, entities: &[Entity]) -> String {
let snap = |mut i: usize| -> usize {
i = i.min(text.len());
while !text.is_char_boundary(i) {
i -= 1;
}
i
};
let mut ranges: Vec<(usize, usize)> = entities
.iter()
.map(|e| (snap(e.span.start), snap(e.span.end)))
.filter(|(start, end)| start < end)
.collect();
ranges.sort_unstable();
let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
for (start, end) in ranges {
match merged.last_mut() {
Some((_, last_end)) if start <= *last_end => *last_end = (*last_end).max(end),
_ => merged.push((start, end)),
}
}
let mut out = String::with_capacity(text.len());
let mut cursor = 0usize;
for (start, end) in merged {
out.push_str(&text[cursor..start]);
for _ in 0..text[start..end].chars().count() {
out.push(MASK_CHAR);
}
cursor = end;
}
out.push_str(&text[cursor..]);
out
}
pub fn redact_text(
doc: &ExtractedDocument,
entities: &[Entity],
format: OutputFormat,
) -> RedactionResult {
let redacted_text = mask_text(&doc.text, entities);
let redacted_markdown = doc.markdown.as_deref().map(|md| mask_text(md, entities));
match format {
OutputFormat::Markdown => RedactionResult {
format,
markdown: Some(redacted_markdown.unwrap_or_else(|| redacted_text.clone())),
text: Some(redacted_text),
bytes: None,
entities: entities.to_vec(),
},
OutputFormat::Native => RedactionResult {
format,
text: Some(redacted_text),
markdown: redacted_markdown,
bytes: None,
entities: entities.to_vec(),
},
}
}
fn draw_redaction_boxes(
img: &mut RgbaImage,
entities: &[Entity],
page: Option<u32>,
dpi_scale: f32,
) {
let (img_w, img_h) = img.dimensions();
for e in entities {
let Some(bbox) = e.bbox else { continue };
if let Some(p) = page {
if bbox.page != p {
continue;
}
}
let x0 = (bbox.x * dpi_scale).max(0.0) as u32;
let y0 = (bbox.y * dpi_scale).max(0.0) as u32;
let x1 = (((bbox.x + bbox.width) * dpi_scale).max(0.0) as u32).min(img_w);
let y1 = (((bbox.y + bbox.height) * dpi_scale).max(0.0) as u32).min(img_h);
for y in y0..y1 {
for x in x0..x1 {
img.put_pixel(x, y, MASK_COLOR);
}
}
}
}
pub fn redact_image_bytes(
bytes: &[u8],
entities: &[Entity],
ingest_dpi: f32,
) -> Result<Vec<u8>, EngineError> {
let format = image::guess_format(bytes)
.map_err(|e| EngineError::Redact(format!("unrecognized image format: {e}")))?;
let decoded = ImageReader::with_format(Cursor::new(bytes), format)
.decode()
.map_err(|e| EngineError::Redact(format!("failed to decode image: {e}")))?;
let mut rgba = decoded.to_rgba8();
draw_redaction_boxes(&mut rgba, entities, None, ingest_dpi / 72.0);
let mut out = Cursor::new(Vec::new());
let write_result = if format == ImageFormat::Jpeg {
DynamicImage::ImageRgba8(rgba.clone())
.to_rgb8()
.write_to(&mut out, format)
} else {
rgba.write_to(&mut out, format)
};
if let Err(err) = write_result {
out = Cursor::new(Vec::new());
DynamicImage::ImageRgba8(rgba)
.to_rgb8()
.write_to(&mut out, format)
.map_err(|_| {
EngineError::Redact(format!("failed to re-encode image: {err}"))
})?;
}
Ok(out.into_inner())
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn redact_pdf_bytes(
bytes: &[u8],
entities: &[Entity],
config: &liteparse::config::LiteParseConfig,
) -> Result<Vec<u8>, EngineError> {
let parser = LiteParse::new(config.clone());
let screenshots = parser
.screenshot_input(PdfInput::Bytes(bytes.to_vec()), None)
.await
.map_err(|e| EngineError::Redact(format!("failed to render PDF pages: {e}")))?;
let dpi_scale = config.dpi / 72.0;
let mut doc = PdfDocument::new("redacted");
let mut pages = Vec::with_capacity(screenshots.len());
for shot in screenshots {
let decoded = ImageReader::with_format(Cursor::new(&shot.image_bytes), ImageFormat::Png)
.decode()
.map_err(|e| {
EngineError::Redact(format!(
"failed to decode rendered page {}: {e}",
shot.page_num
))
})?;
let mut rgba = decoded.to_rgba8();
draw_redaction_boxes(&mut rgba, entities, Some(shot.page_num), dpi_scale);
let mut png_bytes = Cursor::new(Vec::new());
rgba.write_to(&mut png_bytes, ImageFormat::Png)
.map_err(|e| {
EngineError::Redact(format!(
"failed to re-encode redacted page {}: {e}",
shot.page_num
))
})?;
let raw_image = RawImage::decode_from_bytes(&png_bytes.into_inner(), &mut Vec::new())
.map_err(|e| {
EngineError::Redact(format!(
"printpdf failed to load redacted page {}: {e}",
shot.page_num
))
})?;
let image_id = doc.add_image(&raw_image);
let width_mm = Mm(shot.width as f32 / config.dpi * 25.4);
let height_mm = Mm(shot.height as f32 / config.dpi * 25.4);
let ops = vec![Op::UseXobject {
id: image_id,
transform: XObjectTransform {
dpi: Some(config.dpi),
..Default::default()
},
}];
pages.push(PdfPage::new(width_mm, height_mm, ops));
}
let save_options = PdfSaveOptions {
image_optimization: Some(ImageOptimizationOptions {
max_image_size: None,
quality: Some(0.90),
dither_greyscale: Some(false),
..Default::default()
}),
..Default::default()
};
let mut warnings = Vec::new();
Ok(doc.with_pages(pages).save(&save_options, &mut warnings))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{DetectionSource, Span};
fn entity(start: usize, end: usize) -> Entity {
Entity {
entity_type: "TEST".into(),
span: Span { start, end },
score: 1.0,
bbox: None,
source: DetectionSource::TierA,
}
}
#[test]
fn an_out_of_range_span_is_clamped_rather_than_panicking() {
let text = "short text";
assert_eq!(mask_text(text, &[entity(102, 112)]), "short text");
}
#[test]
fn a_span_cutting_a_multibyte_char_snaps_to_the_boundary() {
let masked = mask_text("Numéro", &[entity(0, 4)]);
assert!(!masked.is_empty(), "must not panic and must produce text");
}
#[test]
fn masks_correctly_with_multibyte_text_before_the_span() {
let text = "mon numéro de sécurité sociale est 1 85 01 75 123 456 09";
let matched = "1 85 01 75 123 456 09";
let start = text.find(matched).expect("fixture contains the match");
let redacted = mask_text(text, &[entity(start, start + matched.len())]);
assert_eq!(
redacted,
"mon numéro de sécurité sociale est █████████████████████"
);
assert!(!redacted.contains(matched));
assert_eq!(
redacted.chars().filter(|&c| c == MASK_CHAR).count(),
matched.chars().count()
);
}
#[test]
fn merges_overlapping_entities_instead_of_double_masking() {
let redacted = mask_text("abcdefgh", &[entity(0, 4), entity(2, 6)]);
assert_eq!(redacted, "██████gh");
}
#[test]
fn leaves_untouched_text_around_the_span() {
let redacted = mask_text("prefix SECRET suffix", &[entity(7, 13)]);
assert_eq!(redacted, "prefix ██████ suffix");
}
fn encode_blank(format: ImageFormat) -> Vec<u8> {
let img = DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
64,
64,
image::Rgb([255, 255, 255]),
));
let mut buf = Cursor::new(Vec::new());
img.write_to(&mut buf, format).expect("encode should work");
buf.into_inner()
}
fn entity_with_box() -> Entity {
Entity {
entity_type: "TEST".into(),
span: Span { start: 0, end: 4 },
score: 1.0,
bbox: Some(crate::types::BoundingBox {
page: 1,
x: 4.0,
y: 4.0,
width: 8.0,
height: 4.0,
}),
source: DetectionSource::TierA,
}
}
#[test]
fn redacts_a_jpeg_without_failing_on_the_alpha_channel() {
let jpeg = encode_blank(ImageFormat::Jpeg);
let out = redact_image_bytes(&jpeg, &[entity_with_box()], 150.0)
.expect("redacting a JPEG must not fail on the alpha channel");
assert_eq!(
image::guess_format(&out).expect("output should be a real image"),
ImageFormat::Jpeg,
"a JPEG in should stay a JPEG out",
);
image::load_from_memory(&out).expect("redacted JPEG should decode");
}
#[test]
fn redacts_a_png_preserving_its_format() {
let png = encode_blank(ImageFormat::Png);
let out =
redact_image_bytes(&png, &[entity_with_box()], 150.0).expect("redacting a PNG works");
assert_eq!(
image::guess_format(&out).expect("output should be a real image"),
ImageFormat::Png,
);
image::load_from_memory(&out).expect("redacted PNG should decode");
}
}