use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Severity {
Info,
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Category {
FontIntegrity,
UnicodeHygiene,
HiddenContent,
Structural,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Verdict {
Confirmed,
Refuted,
Unconfirmed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
pub rule: String,
pub severity: Severity,
pub category: Category,
pub message: String,
pub location: String,
pub confidence: f32,
}
impl Finding {
pub fn new(
rule: &str,
severity: Severity,
category: Category,
location: impl Into<String>,
message: impl Into<String>,
confidence: f32,
) -> Self {
Finding {
rule: rule.to_string(),
severity,
category,
location: location.into(),
message: message.into(),
confidence,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Assessment {
pub ok: bool,
pub defaced: bool,
pub hidden_text: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_severity: Option<Severity>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Report {
pub file: String,
pub format: String,
pub fonts_examined: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<crate::metadata::DocumentMetadata>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assessment: Option<Assessment>,
pub findings: Vec<Finding>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub phrases: Vec<PhraseDiff>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verdict: Option<Verdict>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhraseDiff {
pub extracted: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub presumed: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub presumed_withheld: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ocr: Option<String>,
}
impl Report {
pub fn new(file: &str, format: &str) -> Self {
Report {
file: file.to_string(),
format: format.to_string(),
fonts_examined: 0,
metadata: None,
assessment: None,
findings: Vec::new(),
phrases: Vec::new(),
verdict: None,
}
}
pub fn push(&mut self, f: Finding) {
self.findings.push(f);
}
pub fn max_severity(&self) -> Option<Severity> {
self.findings.iter().map(|f| f.severity).max()
}
pub fn withdraw_uncorroborated_presumed(&mut self) {
if self.verdict == Some(Verdict::Confirmed) {
return;
}
for ph in &mut self.phrases {
if ph.presumed.take().is_some() {
ph.presumed_withheld = true;
}
}
}
pub fn finalize(&mut self) {
self.withdraw_uncorroborated_presumed();
let max = self.max_severity();
let defaced = self
.findings
.iter()
.any(|f| matches!(f.category, Category::FontIntegrity) && f.severity >= Severity::High);
let hidden_text = self
.findings
.iter()
.any(|f| matches!(f.category, Category::HiddenContent) && f.severity >= Severity::Medium);
self.assessment = Some(Assessment {
ok: max.is_none_or(|s| s < Severity::High),
defaced,
hidden_text,
max_severity: max,
});
}
}
pub struct OutlineScan {
pub findings: Vec<Finding>,
pub substitutions: Vec<(char, char)>,
}
pub fn clean_snippet(text: &str, max_chars: usize) -> String {
let collapsed: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.chars().count() > max_chars {
let mut s: String = collapsed.chars().take(max_chars).collect();
s.push('…');
s
} else {
collapsed
}
}
pub fn apply_substitutions(text: &str, subs: &[(char, char)]) -> String {
use std::collections::HashMap;
let map: HashMap<char, char> = subs.iter().copied().collect();
text.chars()
.map(|c| {
let lower = c.to_lowercase().next().unwrap_or(c);
match map.get(&lower) {
Some(&corr) if c.is_uppercase() => corr.to_uppercase().next().unwrap_or(corr),
Some(&corr) => corr,
None => c,
}
})
.collect()
}
pub fn split_sentences(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
for c in text.chars() {
cur.push(c);
if matches!(c, '.' | '!' | '?' | ';' | '\n' | '\r') {
let s = clean_snippet(&cur, 400);
if !s.is_empty() {
out.push(s);
}
cur.clear();
}
}
let s = clean_snippet(&cur, 400);
if !s.is_empty() {
out.push(s);
}
out
}
pub fn phrase_diffs(extracted_text: &str, subs: &[(char, char)]) -> Vec<PhraseDiff> {
phrase_diffs_paged(extracted_text, subs, None)
}
pub fn phrase_diffs_paged(extracted_text: &str, subs: &[(char, char)], page: Option<u32>) -> Vec<PhraseDiff> {
split_sentences(extracted_text)
.into_iter()
.filter_map(|s| {
let presumed = apply_substitutions(&s, subs);
if presumed != s {
Some(PhraseDiff {
extracted: s,
presumed: Some(presumed),
presumed_withheld: false,
page,
ocr: None,
})
} else {
None
}
})
.take(100)
.collect()
}
#[cfg(test)]
mod presumed_gating_tests {
use super::*;
const DAMAGING_SUBS: [(char, char); 1] = [('h', 'j')];
const SENTENCE: &str = "Auspichiamo di orchestrare i collegi che decidono.";
fn report_with_phrases(verdict: Option<Verdict>) -> Report {
let mut r = Report::new("deck.pdf", "pdf");
r.phrases = phrase_diffs(SENTENCE, &DAMAGING_SUBS);
r.verdict = verdict;
r
}
#[test]
fn la_ricostruzione_candidata_e_davvero_peggiorativa() {
let phrases = phrase_diffs(SENTENCE, &DAMAGING_SUBS);
let presumed = phrases[0].presumed.as_deref().unwrap();
assert!(presumed.contains("Auspicjiamo"), "{presumed}");
assert!(presumed.contains("orcjestrare"), "{presumed}");
assert!(presumed.contains("cje"), "{presumed}");
}
#[test]
fn confermato_la_ricostruzione_resta() {
let mut r = report_with_phrases(Some(Verdict::Confirmed));
r.finalize();
assert!(r.phrases[0].presumed.is_some(), "sotto Confirmed la ricostruzione va consegnata");
assert!(!r.phrases[0].presumed_withheld);
}
#[test]
fn non_confermato_la_ricostruzione_e_ritirata() {
for verdict in [None, Some(Verdict::Unconfirmed), Some(Verdict::Refuted)] {
let mut r = report_with_phrases(verdict);
r.finalize();
let ph = &r.phrases[0];
assert!(ph.presumed.is_none(), "verdetto {verdict:?}: la ricostruzione non va consegnata");
assert!(ph.presumed_withheld, "verdetto {verdict:?}: il ritiro va dichiarato");
assert_eq!(ph.extracted, SENTENCE, "il testo estratto resta intatto");
}
}
#[test]
fn il_ritiro_e_idempotente_e_non_resuscita() {
let mut r = report_with_phrases(Some(Verdict::Unconfirmed));
r.finalize();
r.finalize();
assert!(r.phrases[0].presumed.is_none());
assert!(r.phrases[0].presumed_withheld);
}
#[test]
fn nel_json_il_campo_sparisce_invece_di_mentire() {
let mut r = report_with_phrases(Some(Verdict::Unconfirmed));
r.finalize();
let js = serde_json::to_string(&r).unwrap();
assert!(!js.contains("\"presumed\":"), "{js}");
assert!(js.contains("\"presumed_withheld\":true"), "{js}");
let mut ok = report_with_phrases(Some(Verdict::Confirmed));
ok.finalize();
let js_ok = serde_json::to_string(&ok).unwrap();
assert!(js_ok.contains("\"presumed\":"), "{js_ok}");
assert!(!js_ok.contains("presumed_withheld"), "{js_ok}");
}
}