Skip to main content

docling_core/
redact.rs

1//! Opt-in PII redaction of a [`DoclingDocument`] (#621).
2//!
3//! A converted document feeds search and RAG pipelines — Markdown, chunks,
4//! embeddings, vector stores, LLM prompts — and personal data in the source
5//! flows into every one of them. Redacting each export format after the
6//! fact misses the text that lives elsewhere in the model: the item tree
7//! behind the JSON export, the link table, a code block's `orig`, the
8//! key-value graph's cells, a caption's hyperlink, the pixels of an embedded
9//! image. This pass runs **once, on the model, before any serializer,
10//! chunker or stream reads it**, replaces every detected span in place with
11//! a placeholder and returns a [`RedactionReport`] of counts — never the
12//! original values (unless the caller asks for the mapping explicitly).
13//!
14//! Detection is pluggable through [`PiiDetector`]. The built-in
15//! [`PatternDetector`] is pure Rust and compiles for wasm: e-mail, phone,
16//! Luhn-validated card numbers, mod-97-validated IBANs, IPv4/IPv6, URL
17//! credentials and a few national IDs with their own check digits (US SSN,
18//! UK NINO, Indian Aadhaar — Verhoeff), plus the caller's own patterns and
19//! deny terms. A name/organization/location detector (NER) lives in the
20//! `docling` crate behind its `ner` feature and plugs into the same trait;
21//! several detectors compose through [`CompositeDetector`], overlapping
22//! spans resolved longest-first.
23//!
24//! Python docling has no redaction stage, so this is a docling.rs extension
25//! (recorded in `docs/MIGRATION.md`); it is off by default and, unused,
26//! changes nothing.
27//!
28//! What is walked: every user-visible string of the flat nodes, recursively
29//! (groups, furniture wrappers, picture children, rich table cells), the
30//! item tree (`tree`), the link table, code text and `orig`, a formula's
31//! `orig` (the LaTeX is left alone — it is a rendering, not text, and the
32//! pipeline's own `orig` is what a reader could recover PII from), tables
33//! (grid, first-class cells, rich-cell blocks), field regions, key-value
34//! cells, comments, hrefs, VTT cue voices. An inline group is matched on
35//! the concatenation of its runs and the spans mapped back onto the runs.
36//! The document's `name` (the file name) is not touched. Image handling is
37//! [`ImageRedaction`]: `Drop` removes every embedded image and page render
38//! here; `BoxOut` needs the OCR models and is the `docling` crate's job
39//! (this pass then leaves the pixels alone, as `Keep` does).
40//!
41//! Non-goals: no compliance guarantee (GDPR/HIPAA/PCI), no rewriting of the
42//! source file, no cross-node entity detection, no pseudonym persistence
43//! across documents.
44
45use std::collections::{BTreeMap, HashMap};
46use std::fmt;
47
48use regex::Regex;
49
50use crate::document::{InlineRun, Node, Table};
51use crate::tree::{ItemTree, TreeKind};
52use crate::DoclingDocument;
53
54/// The kinds of personal data a detector can report. The order is the
55/// precedence when two detectors' spans overlap at equal length — the
56/// check-digit formats (card, IBAN, national ID) over a phone number, whose
57/// digit groups they also look like.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
59pub enum PiiKind {
60    Email,
61    CreditCard,
62    Iban,
63    NationalId,
64    IpAddress,
65    UrlCredentials,
66    Phone,
67    Person,
68    Organization,
69    Location,
70    Address,
71    /// A caller-supplied pattern ([`CustomPattern`]) or deny term.
72    Custom,
73}
74
75impl PiiKind {
76    /// Every kind, in precedence order.
77    pub const ALL: [PiiKind; 12] = [
78        PiiKind::Email,
79        PiiKind::CreditCard,
80        PiiKind::Iban,
81        PiiKind::NationalId,
82        PiiKind::IpAddress,
83        PiiKind::UrlCredentials,
84        PiiKind::Phone,
85        PiiKind::Person,
86        PiiKind::Organization,
87        PiiKind::Location,
88        PiiKind::Address,
89        PiiKind::Custom,
90    ];
91
92    /// The kinds the pattern detector handles itself (no model needed).
93    pub const PATTERN: [PiiKind; 7] = [
94        PiiKind::Email,
95        PiiKind::CreditCard,
96        PiiKind::Iban,
97        PiiKind::NationalId,
98        PiiKind::IpAddress,
99        PiiKind::UrlCredentials,
100        PiiKind::Phone,
101    ];
102
103    /// The kinds only a named-entity model finds.
104    pub const NER: [PiiKind; 4] = [
105        PiiKind::Person,
106        PiiKind::Organization,
107        PiiKind::Location,
108        PiiKind::Address,
109    ];
110
111    /// The placeholder label: `[EMAIL]`, `[PERSON_2]`.
112    pub fn label(self) -> &'static str {
113        match self {
114            PiiKind::Email => "EMAIL",
115            PiiKind::Phone => "PHONE",
116            PiiKind::CreditCard => "CREDIT_CARD",
117            PiiKind::Iban => "IBAN",
118            PiiKind::IpAddress => "IP",
119            PiiKind::UrlCredentials => "CREDENTIALS",
120            PiiKind::NationalId => "NATIONAL_ID",
121            PiiKind::Person => "PERSON",
122            PiiKind::Organization => "ORG",
123            PiiKind::Location => "LOCATION",
124            PiiKind::Address => "ADDRESS",
125            PiiKind::Custom => "REDACTED",
126        }
127    }
128
129    /// The wire / CLI spelling (`credit_card`, `ip_address`, …).
130    pub fn name(self) -> &'static str {
131        match self {
132            PiiKind::Email => "email",
133            PiiKind::Phone => "phone",
134            PiiKind::CreditCard => "credit_card",
135            PiiKind::Iban => "iban",
136            PiiKind::IpAddress => "ip_address",
137            PiiKind::UrlCredentials => "url_credentials",
138            PiiKind::NationalId => "national_id",
139            PiiKind::Person => "person",
140            PiiKind::Organization => "organization",
141            PiiKind::Location => "location",
142            PiiKind::Address => "address",
143            PiiKind::Custom => "custom",
144        }
145    }
146
147    /// Parse a wire spelling (case-insensitive; `-` as `_`; `ip` and `org`
148    /// accepted as shorthands).
149    pub fn parse(s: &str) -> Option<Self> {
150        let s = s.trim().to_ascii_lowercase().replace('-', "_");
151        match s.as_str() {
152            "ip" => return Some(PiiKind::IpAddress),
153            "org" => return Some(PiiKind::Organization),
154            "credentials" | "url_credential" => return Some(PiiKind::UrlCredentials),
155            _ => {}
156        }
157        PiiKind::ALL.into_iter().find(|k| k.name() == s)
158    }
159
160    /// Every spelling [`parse`](Self::parse) accepts, for error messages.
161    pub const ACCEPTED: &'static str = "email, phone, credit_card, iban, ip_address, \
162        url_credentials, national_id, person, organization, location, address, custom";
163}
164
165/// What a detected span becomes.
166#[derive(Debug, Clone, PartialEq, Eq, Default)]
167pub enum Replacement {
168    /// `[EMAIL]`, `[PERSON]` — the kind's label (the default).
169    #[default]
170    Label,
171    /// `[EMAIL_1]`, `[EMAIL_2]`: a number per distinct value within the
172    /// document, so the same e-mail reads as the same placeholder wherever it
173    /// recurs and two different ones stay apart — what a downstream reader
174    /// (or an LLM) needs to follow "who did what" with the names gone.
175    Pseudonym,
176    /// One fixed string for everything, e.g. `█████` or `***`.
177    Fixed(String),
178}
179
180impl Replacement {
181    /// Parse the wire spelling: `label` | `pseudonym` | `fixed:<text>`.
182    pub fn parse(s: &str) -> Option<Self> {
183        let t = s.trim();
184        if t.eq_ignore_ascii_case("label") {
185            return Some(Replacement::Label);
186        }
187        if t.eq_ignore_ascii_case("pseudonym") {
188            return Some(Replacement::Pseudonym);
189        }
190        let lower = t.to_ascii_lowercase();
191        if let Some(rest) = lower.strip_prefix("fixed:") {
192            // Keep the caller's own casing of the text.
193            let text = &t[t.len() - rest.len()..];
194            return Some(Replacement::Fixed(text.to_string()));
195        }
196        None
197    }
198
199    pub const ACCEPTED: &'static str = "label, pseudonym, fixed:<text>";
200}
201
202/// What happens to embedded images (pictures, page renders) when the pass
203/// runs. An image can carry anything the text does — a scanned letter, a
204/// screenshot of a profile — and the pass cannot read it without OCR.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
206pub enum ImageRedaction {
207    /// Remove every embedded image and page render (the default): nothing
208    /// unread leaves the document.
209    #[default]
210    Drop,
211    /// OCR each image and paint solid boxes over the lines that carry a
212    /// detected span; keep the rest of the pixels. Needs the OCR models (the
213    /// `docling` crate does this; without the models it degrades to `Drop`
214    /// with a warning).
215    BoxOut,
216    /// Leave the images as they are.
217    Keep,
218}
219
220impl ImageRedaction {
221    /// Parse the wire spelling: `drop` | `box_out` | `keep`.
222    pub fn parse(s: &str) -> Option<Self> {
223        match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
224            "drop" => Some(ImageRedaction::Drop),
225            "box_out" | "boxout" | "box" => Some(ImageRedaction::BoxOut),
226            "keep" => Some(ImageRedaction::Keep),
227            _ => None,
228        }
229    }
230
231    pub const ACCEPTED: &'static str = "drop, box_out, keep";
232}
233
234/// A caller-supplied pattern: `name` is the placeholder label (`[CASE_ID]`
235/// for `case_id`), `regex` the expression (the `regex` crate's syntax —
236/// no look-around); the whole match is the span, or capture group 1 when
237/// the expression has one.
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct CustomPattern {
240    pub name: String,
241    pub regex: String,
242}
243
244impl CustomPattern {
245    /// Parse the wire spelling `NAME=REGEX` (a bare `REGEX` gets the label
246    /// `CUSTOM`).
247    pub fn parse(s: &str) -> Option<Self> {
248        let s = s.trim();
249        if s.is_empty() {
250            return None;
251        }
252        let (name, regex) = match s.split_once('=') {
253            // `NAME=…` only when the name looks like an identifier — a regex
254            // itself may start with `(?i)x=`.
255            Some((n, r))
256                if !n.is_empty()
257                    && n.chars()
258                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') =>
259            {
260                (n.to_string(), r.to_string())
261            }
262            _ => ("custom".to_string(), s.to_string()),
263        };
264        Some(CustomPattern { name, regex })
265    }
266}
267
268/// The pass's settings. `Default` = every kind, labels, no custom patterns,
269/// images dropped, no mapping.
270#[derive(Debug, Clone, PartialEq)]
271pub struct RedactionOptions {
272    /// Which kinds to redact; empty = every kind the available detectors
273    /// find (the pattern kinds, and the NER kinds when a model runs).
274    pub kinds: Vec<PiiKind>,
275    pub replacement: Replacement,
276    /// Extra patterns, redacted as [`PiiKind::Custom`] under their own label.
277    pub custom_patterns: Vec<CustomPattern>,
278    /// Literal terms (case-insensitive, whole words) always redacted, as
279    /// `[REDACTED]` — a project code name, a client's name the NER misses.
280    pub deny_terms: Vec<String>,
281    /// Literal terms (case-insensitive) never redacted even when a detector
282    /// flags them — `support@example.com`, the company's own name.
283    pub allow_terms: Vec<String>,
284    /// The NER detector's minimum confidence for a span (0–1; 0.85 by
285    /// default — a lone common word in a table header reads as a location
286    /// at ~0.8, a real name at 0.99+).
287    pub ner_min_score: f32,
288    pub images: ImageRedaction,
289    /// Keep the `original → placeholder` pairs in the report. Off by default
290    /// — the report then carries counts only and can be logged or returned
291    /// over HTTP without leaking what was removed.
292    pub return_mapping: bool,
293}
294
295impl Default for RedactionOptions {
296    fn default() -> Self {
297        Self {
298            kinds: Vec::new(),
299            replacement: Replacement::Label,
300            custom_patterns: Vec::new(),
301            deny_terms: Vec::new(),
302            allow_terms: Vec::new(),
303            ner_min_score: 0.85,
304            images: ImageRedaction::Drop,
305            return_mapping: false,
306        }
307    }
308}
309
310impl RedactionOptions {
311    /// Whether `kind` is in scope (an empty `kinds` means all).
312    pub fn wants(&self, kind: PiiKind) -> bool {
313        self.kinds.is_empty() || self.kinds.contains(&kind)
314    }
315
316    /// Whether any NER-only kind is in scope.
317    pub fn wants_ner(&self) -> bool {
318        PiiKind::NER.iter().any(|&k| self.wants(k))
319    }
320}
321
322/// One detected span: byte offsets into the text it was detected on, the
323/// kind, the detector's confidence (1.0 for a pattern) and, for a custom
324/// pattern, its name (the label).
325#[derive(Debug, Clone, PartialEq)]
326pub struct Span {
327    pub start: usize,
328    pub end: usize,
329    pub kind: PiiKind,
330    pub score: f32,
331    pub name: Option<String>,
332}
333
334impl Span {
335    pub fn new(start: usize, end: usize, kind: PiiKind) -> Self {
336        Span {
337            start,
338            end,
339            kind,
340            score: 1.0,
341            name: None,
342        }
343    }
344}
345
346/// A span detector. `detect` returns the spans of one text; the pass calls
347/// it once per string of the document, so a detector keeps no per-document
348/// state (pseudonym numbering is the pass's own).
349pub trait PiiDetector {
350    fn detect(&self, text: &str) -> Vec<Span>;
351}
352
353/// Several detectors as one; their spans are merged by the pass's overlap
354/// rule (longest first, then kind precedence).
355pub struct CompositeDetector(pub Vec<Box<dyn PiiDetector + Send + Sync>>);
356
357impl PiiDetector for CompositeDetector {
358    fn detect(&self, text: &str) -> Vec<Span> {
359        self.0.iter().flat_map(|d| d.detect(text)).collect()
360    }
361}
362
363/// What was redacted: one count per label (`EMAIL`, `PHONE`, a custom
364/// pattern's name, `REDACTED` for deny terms), the total, and — only with
365/// [`RedactionOptions::return_mapping`] — the `original → placeholder`
366/// pairs. A backend that also builds docling's item tree (DOCX, HTML) has
367/// its text redacted in both representations but counted once.
368#[derive(Debug, Clone, PartialEq, Eq, Default)]
369pub struct RedactionReport {
370    pub counts: BTreeMap<String, usize>,
371    pub total: usize,
372    pub mapping: Option<Vec<(String, String)>>,
373}
374
375impl RedactionReport {
376    /// The counts as a JSON object (`{"EMAIL": 2, "PHONE": 1}`), what the
377    /// HTTP surfaces answer with — never the mapping.
378    pub fn counts_json(&self) -> serde_json::Value {
379        serde_json::Value::Object(
380            self.counts
381                .iter()
382                .map(|(k, v)| (k.clone(), serde_json::json!(v)))
383                .collect(),
384        )
385    }
386}
387
388/// A rejected option (an invalid custom regex).
389#[derive(Debug, Clone, PartialEq, Eq)]
390pub struct RedactionError(pub String);
391
392impl fmt::Display for RedactionError {
393    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
394        f.write_str(&self.0)
395    }
396}
397
398impl std::error::Error for RedactionError {}
399
400// ---------------------------------------------------------------------------
401// The pattern detector
402// ---------------------------------------------------------------------------
403
404macro_rules! re {
405    ($pat:expr) => {{
406        static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
407        RE.get_or_init(|| Regex::new($pat).expect("static regex"))
408    }};
409}
410
411/// The built-in detector: patterns with check-digit validation where the
412/// format has one, so a random 16-digit order number is not a card and
413/// `2024-01-15` is not a phone.
414pub struct PatternDetector {
415    kinds: Vec<PiiKind>,
416    custom: Vec<(String, Regex)>,
417    deny: Vec<Regex>,
418    allow: Vec<String>,
419}
420
421impl PatternDetector {
422    /// Build from the options: the pattern kinds in scope, the custom
423    /// patterns compiled (an invalid one is the error), the deny terms as
424    /// whole-word case-insensitive matches.
425    pub fn new(opts: &RedactionOptions) -> Result<Self, RedactionError> {
426        let kinds = PiiKind::PATTERN
427            .into_iter()
428            .filter(|&k| opts.wants(k))
429            .collect();
430        let mut custom = Vec::new();
431        for p in &opts.custom_patterns {
432            let re = Regex::new(&p.regex)
433                .map_err(|e| RedactionError(format!("redact pattern {:?}: {e}", p.name)))?;
434            custom.push((p.name.trim().to_ascii_uppercase().replace('-', "_"), re));
435        }
436        let deny = opts
437            .deny_terms
438            .iter()
439            .filter(|t| !t.trim().is_empty())
440            .map(|t| {
441                Regex::new(&format!(r"(?i)\b{}\b", regex::escape(t.trim()))).expect("escaped term")
442            })
443            .collect();
444        let allow = opts
445            .allow_terms
446            .iter()
447            .map(|t| t.trim().to_lowercase())
448            .filter(|t| !t.is_empty())
449            .collect();
450        Ok(Self {
451            kinds,
452            custom,
453            deny,
454            allow,
455        })
456    }
457
458    fn wants(&self, kind: PiiKind) -> bool {
459        self.kinds.contains(&kind)
460    }
461
462    /// Whether the span's text is an allowed term.
463    pub fn allowed(&self, text: &str) -> bool {
464        !self.allow.is_empty() && self.allow.iter().any(|a| a == &text.to_lowercase())
465    }
466}
467
468impl PiiDetector for PatternDetector {
469    fn detect(&self, text: &str) -> Vec<Span> {
470        let mut out = Vec::new();
471        if text.is_empty() {
472            return out;
473        }
474        // `scheme://user:password@host` — the `user:password` part. Found
475        // first: `password@host.tld` also looks like an e-mail address, and
476        // the credentials span is the one to keep.
477        let mut credentials: Vec<(usize, usize)> = Vec::new();
478        if self.wants(PiiKind::UrlCredentials) {
479            for c in re!(r"(?i)[a-z][a-z0-9+.\-]*://([^\s/@:]+:[^\s/@]+)@").captures_iter(text) {
480                let m = c.get(1).expect("group 1");
481                credentials.push((m.start(), m.end()));
482                out.push(Span::new(m.start(), m.end(), PiiKind::UrlCredentials));
483            }
484        }
485        if self.wants(PiiKind::Email) {
486            for m in re!(r"(?i)[a-z0-9._%+\-\\]+@[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?)*\.[a-z]{2,}").find_iter(text) {
487                // A trailing `.` belongs to the sentence; a leading `\` to
488                // a Markdown escape of the character after it.
489                let (mut s, mut e) = (m.start(), m.end());
490                while e > s && text.as_bytes()[e - 1] == b'.' {
491                    e -= 1;
492                }
493                while s < e && text.as_bytes()[s] == b'\\' {
494                    s += 1;
495                }
496                if s < e && !credentials.iter().any(|&(cs, ce)| s < ce && cs < e) {
497                    out.push(Span::new(s, e, PiiKind::Email));
498                }
499            }
500        }
501        if self.wants(PiiKind::CreditCard) {
502            for m in re!(r"\b\d(?:[ \-]?\d){12,18}\b").find_iter(text) {
503                let digits: String = m.as_str().chars().filter(|c| c.is_ascii_digit()).collect();
504                // The whole number run, not a Luhn-valid suffix of a longer
505                // one (`4111 1111 1111 1112` is not a card).
506                if !maximal_digit_run(text, m.start(), m.end()) {
507                    continue;
508                }
509                if (13..=19).contains(&digits.len()) && luhn_valid(&digits) {
510                    out.push(Span::new(m.start(), m.end(), PiiKind::CreditCard));
511                }
512            }
513        }
514        if self.wants(PiiKind::Iban) {
515            for m in
516                re!(r"\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,4})?\b").find_iter(text)
517            {
518                let compact: String = m.as_str().chars().filter(|c| *c != ' ').collect();
519                if iban_valid(&compact) {
520                    out.push(Span::new(m.start(), m.end(), PiiKind::Iban));
521                }
522            }
523        }
524        if self.wants(PiiKind::IpAddress) {
525            for m in re!(r"\b(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}\b").find_iter(text) {
526                // Not the middle of a longer dotted run (`1.2.3.4.5`).
527                let before = text[..m.start()].chars().next_back();
528                let after = text[m.end()..].chars().next();
529                if before == Some('.') || after == Some('.') {
530                    continue;
531                }
532                // `v2.0.0.1` is a version, not an address.
533                if matches!(before, Some('v') | Some('V')) {
534                    continue;
535                }
536                out.push(Span::new(m.start(), m.end(), PiiKind::IpAddress));
537            }
538            for m in re!(r"[0-9A-Fa-f:]{3,45}").find_iter(text) {
539                let s = m.as_str();
540                if s.matches(':').count() < 2 || !s.chars().any(|c| c.is_ascii_hexdigit()) {
541                    continue;
542                }
543                if s.parse::<std::net::Ipv6Addr>().is_ok() {
544                    out.push(Span::new(m.start(), m.end(), PiiKind::IpAddress));
545                }
546            }
547        }
548        if self.wants(PiiKind::NationalId) {
549            // US SSN: AAA-GG-SSSS with the SSA's never-issued ranges excluded.
550            for m in re!(r"\b\d{3}-\d{2}-\d{4}\b").find_iter(text) {
551                let s = m.as_str();
552                let area = &s[0..3];
553                if area == "000" || area == "666" || area.starts_with('9') {
554                    continue;
555                }
556                if &s[4..6] == "00" || &s[7..11] == "0000" {
557                    continue;
558                }
559                out.push(Span::new(m.start(), m.end(), PiiKind::NationalId));
560            }
561            // UK National Insurance number.
562            for m in re!(r"\b[A-CEGHJ-PR-TW-Z][A-CEGHJ-NPR-TW-Z] ?\d{2} ?\d{2} ?\d{2} ?[A-D]\b")
563                .find_iter(text)
564            {
565                let prefix = &m.as_str()[0..2];
566                if ["BG", "GB", "NK", "KN", "TN", "NT", "ZZ"].contains(&prefix) {
567                    continue;
568                }
569                out.push(Span::new(m.start(), m.end(), PiiKind::NationalId));
570            }
571            // Indian Aadhaar: 12 digits, first 2–9, Verhoeff check digit.
572            for m in re!(r"\b[2-9]\d{3}[ \-]?\d{4}[ \-]?\d{4}\b").find_iter(text) {
573                let digits: String = m.as_str().chars().filter(|c| c.is_ascii_digit()).collect();
574                if verhoeff_valid(&digits) {
575                    out.push(Span::new(m.start(), m.end(), PiiKind::NationalId));
576                }
577            }
578        }
579        if self.wants(PiiKind::Phone) {
580            let ssn_shape = re!(r"^\d{3}-\d{2}-\d{4}$");
581            for m in re!(r"(?:\+\d{1,3}[ .\-]?)?(?:\(\d{1,4}\)[ .\-]?)?\d{2,4}(?:[ .\-]\d{2,4}){1,4}\b|\+\d{7,15}\b").find_iter(text) {
582                let s = m.as_str();
583                let digits = s.chars().filter(|c| c.is_ascii_digit()).count();
584                if !(7..=15).contains(&digits) || looks_like_date_or_version(s) {
585                    continue;
586                }
587                // `AAA-GG-SSSS` is an SSN shape, valid or not — never a phone.
588                if ssn_shape.is_match(s) {
589                    continue;
590                }
591                // A plain `+` form or a grouped one with at least two groups
592                // or a country/area code — a lone `12345 678` number line in
593                // a table would otherwise qualify; require a separator shape
594                // phones actually use.
595                out.push(Span::new(m.start(), m.end(), PiiKind::Phone));
596            }
597        }
598        for (name, re) in &self.custom {
599            for c in re.captures_iter(text) {
600                let m = c.get(1).or_else(|| c.get(0)).expect("match");
601                if m.start() == m.end() {
602                    continue;
603                }
604                out.push(Span {
605                    start: m.start(),
606                    end: m.end(),
607                    kind: PiiKind::Custom,
608                    score: 1.0,
609                    name: Some(name.clone()),
610                });
611            }
612        }
613        for re in &self.deny {
614            for m in re.find_iter(text) {
615                out.push(Span::new(m.start(), m.end(), PiiKind::Custom));
616            }
617        }
618        out.retain(|s| !self.allowed(&text[s.start..s.end]));
619        out
620    }
621}
622
623/// Whether `text[start..end]` is a whole run of digit groups: not preceded
624/// or followed by another digit group through a space or dash.
625fn maximal_digit_run(text: &str, start: usize, end: usize) -> bool {
626    let bytes = text.as_bytes();
627    let before =
628        start >= 2 && matches!(bytes[start - 1], b' ' | b'-') && bytes[start - 2].is_ascii_digit();
629    let after = end + 1 < bytes.len()
630        && matches!(bytes[end], b' ' | b'-')
631        && bytes[end + 1].is_ascii_digit();
632    !before && !after
633}
634
635/// Luhn (ISO/IEC 7812-1) check over a digit string.
636pub fn luhn_valid(digits: &str) -> bool {
637    let mut sum = 0u32;
638    let mut double = false;
639    for c in digits.chars().rev() {
640        let Some(d) = c.to_digit(10) else {
641            return false;
642        };
643        let d = if double {
644            let x = d * 2;
645            if x > 9 {
646                x - 9
647            } else {
648                x
649            }
650        } else {
651            d
652        };
653        sum += d;
654        double = !double;
655    }
656    digits.len() >= 2 && sum % 10 == 0
657}
658
659/// IBAN length per country (the SWIFT registry); an unknown country is not
660/// an IBAN for this detector, which keeps random uppercase-plus-digit tokens
661/// (`XY12…`) from matching on mod-97 luck alone.
662fn iban_length(country: &str) -> Option<usize> {
663    Some(match country {
664        "AL" | "AZ" | "CY" | "DO" | "GT" | "HU" | "LB" | "PL" | "BY" | "SV" | "NI" => 28,
665        "AD" | "CZ" | "MD" | "PK" | "RO" | "SA" | "SK" | "ES" | "SE" | "TN" | "VG" => 24,
666        "AT" | "BA" | "EE" | "KZ" | "LT" | "LU" | "XK" | "MN" => 20,
667        "BH" | "BG" | "CR" | "GE" | "DE" | "IE" | "ME" | "RS" | "GB" | "VA" => 22,
668        "BE" | "BI" => 16,
669        "BR" | "PS" | "QA" | "UA" | "EG" => 29,
670        "HR" | "LI" | "CH" => 21,
671        "DK" | "FO" | "FI" | "GL" | "NL" | "SD" | "FK" => 18,
672        "FR" | "GR" | "IT" | "MR" | "MC" | "SM" | "DJ" => 27,
673        "GI" | "IL" | "AE" | "IQ" | "TL" | "SO" | "OM" => 23,
674        "IS" => 26,
675        "JO" | "KW" | "MU" | "YE" => 30,
676        "LV" => 21,
677        "MK" | "SI" => 19,
678        "MT" => 31,
679        "NO" => 15,
680        "PT" | "ST" | "LY" => 25,
681        "TR" => 26,
682        "LC" => 32,
683        "RU" => 33,
684        _ => return None,
685    })
686}
687
688/// ISO 13616 mod-97 check over a compact (no spaces) IBAN.
689pub fn iban_valid(iban: &str) -> bool {
690    if iban.len() < 15 || iban.len() > 34 || !iban.is_ascii() {
691        return false;
692    }
693    let country = &iban[0..2];
694    if iban_length(country) != Some(iban.len()) {
695        return false;
696    }
697    let rearranged = format!("{}{}", &iban[4..], &iban[..4]);
698    let mut rem = 0u32;
699    for c in rearranged.chars() {
700        let v = match c {
701            '0'..='9' => c as u32 - '0' as u32,
702            'A'..='Z' => c as u32 - 'A' as u32 + 10,
703            _ => return false,
704        };
705        rem = if v >= 10 {
706            (rem * 100 + v) % 97
707        } else {
708            (rem * 10 + v) % 97
709        };
710    }
711    rem == 1
712}
713
714/// Verhoeff check (the Aadhaar check digit).
715pub fn verhoeff_valid(digits: &str) -> bool {
716    const D: [[u8; 10]; 10] = [
717        [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
718        [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
719        [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
720        [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
721        [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
722        [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
723        [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
724        [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
725        [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
726        [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
727    ];
728    const P: [[u8; 10]; 8] = [
729        [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
730        [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
731        [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
732        [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
733        [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
734        [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
735        [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
736        [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
737    ];
738    let mut c = 0u8;
739    for (i, ch) in digits.chars().rev().enumerate() {
740        let Some(d) = ch.to_digit(10) else {
741            return false;
742        };
743        c = D[c as usize][P[i % 8][d as usize] as usize];
744    }
745    !digits.is_empty() && c == 0
746}
747
748/// `2024-01-15`, `15.01.2024`, `01/15/24`, `1.2.3`, `10.0.1` — digit groups
749/// that are a date or a version number, not a phone.
750fn looks_like_date_or_version(s: &str) -> bool {
751    let s = s.trim();
752    re!(r"^\d{4}[\-./]\d{1,2}[\-./]\d{1,2}$").is_match(s)
753        || re!(r"^\d{1,2}[\-./]\d{1,2}[\-./]\d{2,4}$").is_match(s)
754        // Dotted groups with no other separator: a version string.
755        || (s.contains('.') && !s.contains(' ') && !s.contains('-') && !s.starts_with('+'))
756}
757
758// ---------------------------------------------------------------------------
759// The pass
760// ---------------------------------------------------------------------------
761
762/// The pass's state across one document: the pseudonym numbering and the
763/// report. [`DoclingDocument::redact`] drives it over a whole document; the
764/// streaming converter drives it over each page batch so the Markdown it
765/// emits matches the buffered export byte for byte.
766pub struct Redactor<'a> {
767    opts: &'a RedactionOptions,
768    detector: &'a dyn PiiDetector,
769    /// `(label, original) → number`, first-seen order per label.
770    numbers: HashMap<(String, String), usize>,
771    next: HashMap<String, usize>,
772    report: RedactionReport,
773    /// Off while the item tree is walked: a DOCX / HTML document holds its
774    /// text twice (the flat nodes and the tree the JSON export reads), and
775    /// the report counts a value once, not per representation.
776    counting: bool,
777}
778
779impl<'a> Redactor<'a> {
780    pub fn new(opts: &'a RedactionOptions, detector: &'a dyn PiiDetector) -> Self {
781        Redactor {
782            opts,
783            detector,
784            numbers: HashMap::new(),
785            next: HashMap::new(),
786            report: RedactionReport {
787                mapping: opts.return_mapping.then(Vec::new),
788                ..Default::default()
789            },
790            counting: true,
791        }
792    }
793
794    /// The report so far (the final one after the last batch).
795    pub fn report(&self) -> &RedactionReport {
796        &self.report
797    }
798
799    pub fn finish(self) -> RedactionReport {
800        self.report
801    }
802
803    /// The spans of `text` the options keep, non-overlapping, in order:
804    /// a longer span wins over a shorter one it overlaps, equal lengths by
805    /// kind precedence; NER spans under the score floor and out-of-scope
806    /// kinds are dropped.
807    fn spans(&self, text: &str) -> Vec<Span> {
808        let mut spans: Vec<Span> = self
809            .detector
810            .detect(text)
811            .into_iter()
812            .filter(|s| s.start < s.end && s.end <= text.len())
813            .filter(|s| text.is_char_boundary(s.start) && text.is_char_boundary(s.end))
814            .filter(|s| self.opts.wants(s.kind))
815            .filter(|s| !PiiKind::NER.contains(&s.kind) || s.score >= self.opts.ner_min_score)
816            .collect();
817        spans.sort_by(|a, b| {
818            a.start
819                .cmp(&b.start)
820                .then((b.end - b.start).cmp(&(a.end - a.start)))
821                .then(a.kind.cmp(&b.kind))
822        });
823        let mut kept: Vec<Span> = Vec::with_capacity(spans.len());
824        for s in spans {
825            match kept.last() {
826                Some(last) if s.start < last.end => {
827                    // Overlap: keep the longer (or, equal, the earlier kind).
828                    if s.end - s.start > last.end - last.start {
829                        kept.pop();
830                        kept.push(s);
831                    }
832                }
833                _ => kept.push(s),
834            }
835        }
836        kept
837    }
838
839    /// Whether `text` carries any span the options keep — what the image
840    /// box-out asks per OCR line.
841    pub fn has_pii(&self, text: &str) -> bool {
842        !self.spans(text).is_empty()
843    }
844
845    fn label_of(span: &Span) -> String {
846        span.name
847            .clone()
848            .unwrap_or_else(|| span.kind.label().to_string())
849    }
850
851    /// The placeholder for one span of `original`.
852    fn placeholder(&mut self, span: &Span, original: &str) -> String {
853        let label = Self::label_of(span);
854        if self.counting {
855            *self.report.counts.entry(label.clone()).or_insert(0) += 1;
856            self.report.total += 1;
857        }
858        let out = match &self.opts.replacement {
859            Replacement::Label => format!("[{label}]"),
860            Replacement::Fixed(s) => s.clone(),
861            Replacement::Pseudonym => {
862                // The same value — case-folded for e-mails and names,
863                // whitespace-folded for numbers — gets the same number.
864                let key = normalize(original);
865                let n = match self.numbers.get(&(label.clone(), key.clone())) {
866                    Some(n) => *n,
867                    None => {
868                        let next = self.next.entry(label.clone()).or_insert(0);
869                        *next += 1;
870                        self.numbers.insert((label.clone(), key), *next);
871                        *next
872                    }
873                };
874                format!("[{label}_{n}]")
875            }
876        };
877        if let Some(map) = self.report.mapping.as_mut() {
878            if !map.iter().any(|(o, _)| o == original) {
879                map.push((original.to_string(), out.clone()));
880            }
881        }
882        out
883    }
884
885    /// Redact one string in place.
886    pub fn redact_text(&mut self, text: &mut String) {
887        let spans = self.spans(text);
888        if spans.is_empty() {
889            return;
890        }
891        let mut out = String::with_capacity(text.len());
892        let mut pos = 0;
893        for span in &spans {
894            out.push_str(&text[pos..span.start]);
895            let original = text[span.start..span.end].to_string();
896            out.push_str(&self.placeholder(span, &original));
897            pos = span.end;
898        }
899        out.push_str(&text[pos..]);
900        *text = out;
901    }
902
903    fn redact_opt(&mut self, text: &mut Option<String>) {
904        if let Some(t) = text.as_mut() {
905            self.redact_text(t);
906        }
907    }
908
909    /// Redact a run sequence as one text: spans are found on the joined
910    /// runs and written back — the run the span starts in gets the
911    /// placeholder, the runs it crosses lose the covered characters.
912    pub fn redact_runs(&mut self, runs: &mut [InlineRun]) {
913        let joined: String = runs.iter().map(|r| r.text.as_str()).collect();
914        let spans = self.spans(&joined);
915        if spans.is_empty() {
916            return;
917        }
918        // Run byte ranges in the joined text.
919        let mut starts = Vec::with_capacity(runs.len());
920        let mut acc = 0;
921        for r in runs.iter() {
922            starts.push(acc);
923            acc += r.text.len();
924        }
925        // Rebuild every run's text from the joined text with the spans
926        // applied: for each run, walk its range and copy the pieces outside
927        // spans; a span starting inside the run contributes its placeholder.
928        let mut placeholders: Vec<String> = Vec::with_capacity(spans.len());
929        for span in &spans {
930            let original = joined[span.start..span.end].to_string();
931            placeholders.push(self.placeholder(span, &original));
932        }
933        for (i, run) in runs.iter_mut().enumerate() {
934            let (rs, re) = (starts[i], starts[i] + run.text.len());
935            let mut out = String::new();
936            let mut pos = rs;
937            for (span, ph) in spans.iter().zip(&placeholders) {
938                if span.end <= rs || span.start >= re {
939                    continue;
940                }
941                let s = span.start.max(rs);
942                out.push_str(&joined[pos..s]);
943                if span.start >= rs {
944                    out.push_str(ph);
945                }
946                pos = span.end.min(re);
947            }
948            out.push_str(&joined[pos..re]);
949            run.text = out;
950        }
951    }
952
953    fn redact_table(&mut self, table: &mut Table) {
954        for row in table.rows.iter_mut() {
955            for cell in row.iter_mut() {
956                self.redact_text(cell);
957            }
958        }
959        if let Some(cells) = table.cells.as_mut() {
960            for c in cells.iter_mut() {
961                self.redact_text(&mut c.text);
962            }
963        }
964        if let Some(blocks) = table.cell_blocks.as_mut() {
965            for row in blocks.iter_mut() {
966                for cell in row.iter_mut() {
967                    self.redact_nodes(cell);
968                }
969            }
970        }
971        self.redact_opt(&mut table.caption);
972    }
973
974    fn redact_fields(&mut self, items: &mut [crate::FieldItem]) {
975        for f in items.iter_mut() {
976            self.redact_opt(&mut f.marker);
977            self.redact_opt(&mut f.key);
978            self.redact_opt(&mut f.value);
979        }
980    }
981
982    fn redact_graph(&mut self, cells: &mut [crate::GraphCell]) {
983        for c in cells.iter_mut() {
984            self.redact_text(&mut c.text);
985            self.redact_text(&mut c.orig);
986        }
987    }
988
989    fn redact_image(&mut self, image: &mut Option<crate::PictureImage>) {
990        if self.opts.images == ImageRedaction::Drop {
991            *image = None;
992        }
993    }
994
995    /// Redact every string of `nodes`, recursively.
996    pub fn redact_nodes(&mut self, nodes: &mut [Node]) {
997        for node in nodes.iter_mut() {
998            self.redact_node(node);
999        }
1000    }
1001
1002    fn redact_node(&mut self, node: &mut Node) {
1003        match node {
1004            Node::Heading { text, .. }
1005            | Node::Paragraph { text }
1006            | Node::CheckboxItem { text, .. }
1007            | Node::PageFurniture { text, .. }
1008            | Node::FurnitureText { text, .. }
1009            | Node::TextDump(text) => self.redact_text(text),
1010            Node::ListItem {
1011                text,
1012                marker,
1013                dclx,
1014                href,
1015                ..
1016            } => {
1017                self.redact_text(text);
1018                self.redact_opt(marker);
1019                self.redact_opt(href);
1020                if let Some(d) = dclx.as_mut() {
1021                    self.redact_text(&mut d.text);
1022                    self.redact_opt(&mut d.marker);
1023                    self.redact_runs(&mut d.runs);
1024                }
1025            }
1026            Node::Code {
1027                text, orig, pretty, ..
1028            } => {
1029                self.redact_text(text);
1030                self.redact_opt(orig);
1031                self.redact_opt(pretty);
1032            }
1033            Node::Table(t) => self.redact_table(t),
1034            Node::Picture {
1035                caption,
1036                caption_href,
1037                image,
1038                description,
1039                ..
1040            } => {
1041                self.redact_opt(caption);
1042                self.redact_opt(caption_href);
1043                // The picture-OCR text (#645) is read off the image: redact
1044                // it like any other text.
1045                if let Some(d) = description.as_mut() {
1046                    self.redact_text(&mut d.text);
1047                }
1048                self.redact_image(image);
1049            }
1050            // The LaTeX is a rendering of the formula's glyphs; `orig` is
1051            // the extracted text a reader could recover a value from.
1052            Node::Formula { orig, .. } => self.redact_text(orig),
1053            Node::Caption { text, href } | Node::LabeledText { text, href, .. } => {
1054                self.redact_text(text);
1055                self.redact_opt(href);
1056            }
1057            Node::Chart { table, caption, .. } => {
1058                self.redact_table(table);
1059                self.redact_opt(caption);
1060            }
1061            Node::Group { name, children, .. } => {
1062                self.redact_opt(name);
1063                self.redact_nodes(children);
1064            }
1065            Node::FieldRegion { items } => self.redact_fields(items),
1066            Node::KeyValueGraph { cells, .. } => self.redact_graph(cells),
1067            Node::InlineGroup { runs, md_text, .. } => {
1068                self.redact_runs(runs);
1069                self.redact_text(md_text);
1070            }
1071            Node::Furniture { inner, .. }
1072            | Node::Commented { inner, .. }
1073            | Node::Located { inner, .. }
1074            | Node::Prov { inner, .. }
1075            | Node::DoclangOnly(inner) => self.redact_node(inner),
1076            Node::Track { track, cue, inner } => {
1077                self.redact_opt(&mut track.voice);
1078                self.redact_text(cue);
1079                self.redact_node(inner);
1080            }
1081            Node::CommentSection { name, text, .. } => {
1082                self.redact_text(name);
1083                self.redact_text(text);
1084            }
1085            Node::PictureChildren(children) => self.redact_nodes(children),
1086            Node::PageBreak | Node::PageInfo { .. } => {}
1087        }
1088    }
1089
1090    /// Redact the item tree (#621: the JSON export reads it, not the nodes,
1091    /// for the backends that build one). The same pseudonym numbering as
1092    /// the nodes got; the counts are not incremented again — the tree is
1093    /// the same text in docling's shape, not more of it.
1094    pub fn redact_tree(&mut self, tree: &mut ItemTree) {
1095        let counting = std::mem::replace(&mut self.counting, false);
1096        self.redact_tree_items(tree);
1097        self.counting = counting;
1098    }
1099
1100    fn redact_tree_items(&mut self, tree: &mut ItemTree) {
1101        for item in tree.items.iter_mut() {
1102            match &mut item.kind {
1103                TreeKind::Text {
1104                    text,
1105                    orig,
1106                    hyperlink,
1107                    list,
1108                    ..
1109                } => {
1110                    self.redact_text(text);
1111                    self.redact_opt(orig);
1112                    self.redact_opt(hyperlink);
1113                    if let Some(l) = list.as_mut() {
1114                        self.redact_text(&mut l.marker);
1115                    }
1116                }
1117                TreeKind::Code {
1118                    text,
1119                    orig,
1120                    hyperlink,
1121                    ..
1122                } => {
1123                    self.redact_text(text);
1124                    self.redact_opt(orig);
1125                    self.redact_opt(hyperlink);
1126                }
1127                TreeKind::Group { name, .. } => self.redact_text(name),
1128                TreeKind::Table { table, .. } => self.redact_table(table),
1129                TreeKind::Picture {
1130                    image,
1131                    chart,
1132                    description,
1133                    ..
1134                } => {
1135                    self.redact_image(image);
1136                    if let Some(d) = description.as_mut() {
1137                        self.redact_text(&mut d.text);
1138                    }
1139                    if let Some(t) = chart.as_mut() {
1140                        self.redact_table(t);
1141                    }
1142                }
1143                TreeKind::FieldRegion { items } => self.redact_fields(items),
1144                TreeKind::KeyValueGraph { cells, .. } => self.redact_graph(cells),
1145            }
1146            if let Some(track) = item.source.as_mut() {
1147                self.redact_opt(&mut track.voice);
1148            }
1149            for note in item.notes.iter_mut() {
1150                self.redact_text(&mut note.text);
1151            }
1152        }
1153    }
1154
1155    /// Redact the link table (`(text, href)` pairs).
1156    pub fn redact_links(&mut self, links: &mut [(String, String)]) {
1157        for (text, href) in links.iter_mut() {
1158            self.redact_text(text);
1159            self.redact_text(href);
1160        }
1161    }
1162
1163    /// The whole document: nodes, tree, links, page renders.
1164    pub fn redact_document(&mut self, doc: &mut DoclingDocument) {
1165        self.redact_nodes(&mut doc.nodes);
1166        if let Some(tree) = doc.tree.as_mut() {
1167            self.redact_tree(tree);
1168        }
1169        self.redact_links(&mut doc.links);
1170        if self.opts.images == ImageRedaction::Drop {
1171            doc.page_images.clear();
1172        }
1173    }
1174}
1175
1176/// The pseudonym key of a value: case-folded, inner whitespace collapsed,
1177/// so `John Smith` / `john smith` / `JOHN  SMITH` share one number and
1178/// `+1 555 123 4567` / `+1-555-123-4567` do too.
1179fn normalize(s: &str) -> String {
1180    s.to_lowercase()
1181        .chars()
1182        .filter(|c| !c.is_whitespace() && *c != '-' && *c != '.' && *c != '(' && *c != ')')
1183        .collect()
1184}
1185
1186impl DoclingDocument {
1187    /// Redact the document in place with `detector` (#621) and return what
1188    /// was redacted. See the [module docs](self) for what is walked. The
1189    /// built-in [`PatternDetector`] is `PatternDetector::new(&opts)?`.
1190    pub fn redact(
1191        &mut self,
1192        opts: &RedactionOptions,
1193        detector: &dyn PiiDetector,
1194    ) -> RedactionReport {
1195        let mut r = Redactor::new(opts, detector);
1196        r.redact_document(self);
1197        r.finish()
1198    }
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203    use super::*;
1204
1205    fn detector(opts: &RedactionOptions) -> PatternDetector {
1206        PatternDetector::new(opts).unwrap()
1207    }
1208
1209    fn redact(text: &str) -> String {
1210        let opts = RedactionOptions::default();
1211        let d = detector(&opts);
1212        let mut s = text.to_string();
1213        Redactor::new(&opts, &d).redact_text(&mut s);
1214        s
1215    }
1216
1217    fn kinds(text: &str) -> Vec<PiiKind> {
1218        let opts = RedactionOptions::default();
1219        let d = detector(&opts);
1220        Redactor::new(&opts, &d)
1221            .spans(text)
1222            .into_iter()
1223            .map(|s| s.kind)
1224            .collect()
1225    }
1226
1227    #[test]
1228    fn emails_including_markdown_links_and_escapes() {
1229        assert_eq!(
1230            redact("Write to [john.doe@example.com](mailto:john.doe@example.com)."),
1231            "Write to [[EMAIL]](mailto:[EMAIL])."
1232        );
1233        // docling escapes `_` in text; the escape is part of the address.
1234        assert_eq!(
1235            redact(r"jane\_doe@example.org, please."),
1236            "[EMAIL], please."
1237        );
1238        assert_eq!(redact("a@b"), "a@b");
1239    }
1240
1241    #[test]
1242    fn cards_need_luhn_and_ibans_need_mod97() {
1243        assert_eq!(kinds("card 4111 1111 1111 1111"), vec![PiiKind::CreditCard]);
1244        assert_eq!(kinds("card 4111-1111-1111-1111"), vec![PiiKind::CreditCard]);
1245        assert_eq!(kinds("order 4111 1111 1111 1112"), vec![]);
1246        assert_eq!(
1247            kinds("IBAN DE89 3704 0044 0532 0130 00"),
1248            vec![PiiKind::Iban]
1249        );
1250        assert_eq!(kinds("IBAN GB82WEST12345698765432"), vec![PiiKind::Iban]);
1251        assert_eq!(kinds("IBAN DE89 3704 0044 0532 0130 01"), vec![]);
1252        assert_eq!(kinds("code XY12 ABCD 1234 EFGH 5678"), vec![]);
1253    }
1254
1255    #[test]
1256    fn phones_but_not_dates_or_versions() {
1257        assert_eq!(kinds("call +1 (555) 123-4567"), vec![PiiKind::Phone]);
1258        assert_eq!(kinds("call 555-123-4567 today"), vec![PiiKind::Phone]);
1259        assert_eq!(kinds("tel +44 20 7946 0958"), vec![PiiKind::Phone]);
1260        assert_eq!(kinds("on 2024-01-15 and 15.01.2024 and 01/15/24"), vec![]);
1261        assert_eq!(kinds("version 1.2.3 and v10.0.1 and v2.0.0.1"), vec![]);
1262        assert_eq!(kinds("ISBN 978-3-16-148410-0"), vec![]);
1263    }
1264
1265    #[test]
1266    fn ip_addresses_and_url_credentials() {
1267        assert_eq!(kinds("host 192.168.1.10 up"), vec![PiiKind::IpAddress]);
1268        assert_eq!(kinds("v 1.2.3.4.5"), vec![]);
1269        assert_eq!(
1270            kinds("addr 2001:db8::1 and ::1"),
1271            vec![PiiKind::IpAddress, PiiKind::IpAddress]
1272        );
1273        assert_eq!(kinds("at 12:30:45 today"), vec![]);
1274        assert_eq!(kinds("mac aa:bb:cc:dd:ee:ff"), vec![]);
1275        assert_eq!(
1276            redact("see https://alice:s3cret@db.example.com/x"),
1277            "see https://[CREDENTIALS]@db.example.com/x"
1278        );
1279    }
1280
1281    #[test]
1282    fn national_ids_with_their_rules() {
1283        assert_eq!(kinds("SSN 123-45-6789"), vec![PiiKind::NationalId]);
1284        assert_eq!(
1285            kinds("not 000-45-6789 nor 123-00-6789 nor 900-45-6789"),
1286            vec![]
1287        );
1288        assert_eq!(kinds("NINO AB 12 34 56 C"), vec![PiiKind::NationalId]);
1289        assert_eq!(kinds("NINO QQ 12 34 56 C"), vec![]);
1290        assert_eq!(kinds("NINO BG 12 34 56 C"), vec![]);
1291        // Verhoeff: 2234 5678 9012 fails, the digit that passes is found by
1292        // the check itself.
1293        let base = "22345678901";
1294        let ok = (0..10)
1295            .map(|d| format!("{base}{d}"))
1296            .find(|s| verhoeff_valid(s))
1297            .unwrap();
1298        assert_eq!(kinds(&format!("Aadhaar {ok}")), vec![PiiKind::NationalId]);
1299        let bad = (0..10)
1300            .map(|d| format!("{base}{d}"))
1301            .find(|s| !verhoeff_valid(s))
1302            .unwrap();
1303        assert_eq!(kinds(&format!("Aadhaar {bad}")), vec![]);
1304    }
1305
1306    #[test]
1307    fn replacement_modes_and_pseudonym_consistency() {
1308        let opts = RedactionOptions {
1309            replacement: Replacement::Pseudonym,
1310            return_mapping: true,
1311            ..Default::default()
1312        };
1313        let d = detector(&opts);
1314        let mut r = Redactor::new(&opts, &d);
1315        let mut a = "a@x.com wrote to b@x.com; A@X.COM again".to_string();
1316        r.redact_text(&mut a);
1317        assert_eq!(a, "[EMAIL_1] wrote to [EMAIL_2]; [EMAIL_1] again");
1318        let mut b = "call 555-123-4567 or 555 123 4567".to_string();
1319        r.redact_text(&mut b);
1320        assert_eq!(b, "call [PHONE_1] or [PHONE_1]");
1321        let report = r.finish();
1322        assert_eq!(report.counts["EMAIL"], 3);
1323        assert_eq!(report.counts["PHONE"], 2);
1324        assert_eq!(report.total, 5);
1325        let map = report.mapping.unwrap();
1326        assert!(map.contains(&("a@x.com".into(), "[EMAIL_1]".into())));
1327        assert_eq!(map.len(), 5);
1328
1329        let opts = RedactionOptions {
1330            replacement: Replacement::Fixed("***".into()),
1331            ..Default::default()
1332        };
1333        let d = detector(&opts);
1334        let mut s = "a@x.com".to_string();
1335        Redactor::new(&opts, &d).redact_text(&mut s);
1336        assert_eq!(s, "***");
1337    }
1338
1339    #[test]
1340    fn kinds_filter_custom_patterns_deny_and_allow_terms() {
1341        let opts = RedactionOptions {
1342            kinds: vec![PiiKind::Phone],
1343            ..Default::default()
1344        };
1345        let d = detector(&opts);
1346        let mut s = "a@x.com 555-123-4567".to_string();
1347        Redactor::new(&opts, &d).redact_text(&mut s);
1348        assert_eq!(s, "a@x.com [PHONE]");
1349
1350        let opts = RedactionOptions {
1351            custom_patterns: vec![CustomPattern::parse("case_id=CASE-\\d{4}").unwrap()],
1352            deny_terms: vec!["Project Falcon".into()],
1353            allow_terms: vec!["support@example.com".into()],
1354            ..Default::default()
1355        };
1356        let d = detector(&opts);
1357        let mut s = "CASE-0042 by project falcon: support@example.com / x@example.com".to_string();
1358        Redactor::new(&opts, &d).redact_text(&mut s);
1359        assert_eq!(s, "[CASE_ID] by [REDACTED]: support@example.com / [EMAIL]");
1360        assert!(PatternDetector::new(&RedactionOptions {
1361            custom_patterns: vec![CustomPattern {
1362                name: "bad".into(),
1363                regex: "(".into()
1364            }],
1365            ..Default::default()
1366        })
1367        .is_err());
1368    }
1369
1370    #[test]
1371    fn overlapping_spans_keep_the_longest() {
1372        // A Luhn-valid 16-digit number is a card, not three phone groups.
1373        assert_eq!(kinds("4111 1111 1111 1111"), vec![PiiKind::CreditCard]);
1374        // Custom pattern covering an e-mail wins by length.
1375        let opts = RedactionOptions {
1376            custom_patterns: vec![CustomPattern::parse("contact=Contact: \\S+").unwrap()],
1377            ..Default::default()
1378        };
1379        let d = detector(&opts);
1380        let mut s = "Contact: a@x.com now".to_string();
1381        Redactor::new(&opts, &d).redact_text(&mut s);
1382        assert_eq!(s, "[CONTACT] now");
1383    }
1384
1385    #[test]
1386    fn inline_runs_are_matched_on_the_joined_text() {
1387        let opts = RedactionOptions::default();
1388        let d = detector(&opts);
1389        let mut runs = vec![
1390            InlineRun {
1391                text: "Mail john".into(),
1392                bold: true,
1393                ..Default::default()
1394            },
1395            InlineRun {
1396                text: "@example".into(),
1397                ..Default::default()
1398            },
1399            InlineRun {
1400                text: ".com today, or 555-".into(),
1401                italic: true,
1402                ..Default::default()
1403            },
1404            InlineRun {
1405                text: "123-4567.".into(),
1406                ..Default::default()
1407            },
1408        ];
1409        Redactor::new(&opts, &d).redact_runs(&mut runs);
1410        let texts: Vec<&str> = runs.iter().map(|r| r.text.as_str()).collect();
1411        assert_eq!(texts, vec!["Mail [EMAIL]", "", " today, or [PHONE]", "."]);
1412        assert!(runs[0].bold && runs[2].italic);
1413    }
1414
1415    #[test]
1416    fn the_document_walk_reaches_every_string() {
1417        use crate::tree::TreeKind;
1418        let mut doc = DoclingDocument::new("file.docx");
1419        doc.push(Node::Heading {
1420            level: 1,
1421            text: "Re: a@x.com".into(),
1422        });
1423        doc.push(Node::Group {
1424            label: "section".into(),
1425            name: Some("b@x.com".into()),
1426            layer: None,
1427            children: vec![Node::Furniture {
1428                layer: crate::ContentLayer::Furniture,
1429                inner: Box::new(Node::Paragraph {
1430                    text: "c@x.com".into(),
1431                }),
1432            }],
1433        });
1434        let mut table = Table {
1435            rows: vec![vec!["d@x.com".into()]],
1436            ..Default::default()
1437        };
1438        table.cell_blocks = Some(vec![vec![vec![Node::Paragraph {
1439            text: "e@x.com".into(),
1440        }]]]);
1441        doc.push(Node::Table(table));
1442        doc.push(Node::Picture {
1443            caption: Some("f@x.com".into()),
1444            caption_href: Some("mailto:f@x.com".into()),
1445            image: Some(crate::PictureImage {
1446                mimetype: "image/png".into(),
1447                width: 1,
1448                height: 1,
1449                data: vec![1],
1450                dpi: 72,
1451            }),
1452            classification: None,
1453            description: Some(crate::PictureDescription {
1454                text: "ocr j@x.com".into(),
1455                provenance: "ppocr".into(),
1456            }),
1457            caption_parent: Default::default(),
1458            caption_location: None,
1459        });
1460        doc.push(Node::Formula {
1461            latex: "x@y.com".into(),
1462            orig: "g@x.com".into(),
1463            location: None,
1464        });
1465        doc.links.push(("h@x.com".into(), "mailto:h@x.com".into()));
1466        let mut tree = ItemTree::default();
1467        tree.add(
1468            None,
1469            None,
1470            TreeKind::Text {
1471                label: "text".into(),
1472                text: "i@x.com".into(),
1473                orig: Some("i@x.com".into()),
1474                formatting: None,
1475                hyperlink: Some("mailto:i@x.com".into()),
1476                level: None,
1477                list: None,
1478            },
1479        );
1480        doc.tree = Some(tree);
1481        doc.page_images.insert(
1482            1,
1483            crate::PictureImage {
1484                mimetype: "image/png".into(),
1485                width: 1,
1486                height: 1,
1487                data: vec![1],
1488                dpi: 72,
1489            },
1490        );
1491
1492        let opts = RedactionOptions::default();
1493        let d = detector(&opts);
1494        let report = doc.redact(&opts, &d);
1495        // The tree's three copies are redacted but not counted again.
1496        assert_eq!(report.counts["EMAIL"], 11);
1497        let json = doc.export_to_json();
1498        let md = doc.export_to_markdown();
1499        let dl = doc.export_to_doclang();
1500        for out in [&json, &md, &dl] {
1501            assert!(!out.contains("@x.com"), "{out}");
1502        }
1503        assert_eq!(doc.name, "file.docx", "the file name is not touched");
1504        assert!(doc.page_images.is_empty());
1505        assert!(matches!(&doc.nodes[3], Node::Picture { image: None, .. }));
1506        // The LaTeX stays: it is a rendering, not extracted text.
1507        assert!(matches!(&doc.nodes[4], Node::Formula { latex, .. } if latex == "x@y.com"));
1508
1509        // `Keep` leaves the images.
1510        let mut doc2 = DoclingDocument::new("t");
1511        doc2.page_images.insert(
1512            1,
1513            crate::PictureImage {
1514                mimetype: "image/png".into(),
1515                width: 1,
1516                height: 1,
1517                data: vec![1],
1518                dpi: 72,
1519            },
1520        );
1521        let opts = RedactionOptions {
1522            images: ImageRedaction::Keep,
1523            ..Default::default()
1524        };
1525        let d = detector(&opts);
1526        doc2.redact(&opts, &d);
1527        assert_eq!(doc2.page_images.len(), 1);
1528    }
1529
1530    #[test]
1531    fn wire_spellings_parse() {
1532        assert_eq!(PiiKind::parse("Credit-Card"), Some(PiiKind::CreditCard));
1533        assert_eq!(PiiKind::parse("ip"), Some(PiiKind::IpAddress));
1534        assert_eq!(PiiKind::parse("nope"), None);
1535        assert_eq!(
1536            Replacement::parse("fixed:XXX"),
1537            Some(Replacement::Fixed("XXX".into()))
1538        );
1539        assert_eq!(
1540            Replacement::parse("Pseudonym"),
1541            Some(Replacement::Pseudonym)
1542        );
1543        assert_eq!(
1544            ImageRedaction::parse("box-out"),
1545            Some(ImageRedaction::BoxOut)
1546        );
1547        let p = CustomPattern::parse("case_id=CASE-\\d+").unwrap();
1548        assert_eq!(
1549            (p.name.as_str(), p.regex.as_str()),
1550            ("case_id", "CASE-\\d+")
1551        );
1552        assert_eq!(CustomPattern::parse("(?i)x=y").unwrap().name, "custom");
1553    }
1554}