fix(xtask): the text oracle scored us zero for honouring a no-copy document

A PDF can forbid copying its text. `pdfcraft-cli text` honours that and refuses, naming the owner
password as the way through. pdftotext ignores the flag and extracts anyway, so the oracle was
comparing our refusal against its text and scoring 0.000 -- measuring obedience, not quality.

bug1815476.pdf is the case: a one-page Spanish form we render perfectly, whose text we decline to
extract and pdftotext hands over regardless. It sat at the bottom of the worst list looking like a
font bug, which is what sent me looking at it.

Those files are now skipped and counted, not scored, and the count is printed rather than passed
over in silence, on the same rule the oracle crate exists for: a comparison that did not happen
must not look like one that succeeded. Over the first 250 corpus files that moves the mean from
0.811 to 0.854 and drops one file from the sample, with the median unchanged at 0.991.

Permissions come from `Document::permissions`, so this is not a match on our own error text. A
file that cannot be opened at all counts as permitted, because that is a different problem and
dropping it quietly would hide it.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Tanish MankuandClaude Opus 5 committed 2026-10-14 15:51:32 -07:00
1 parent f802257be6
commit ac89a11391
3 files changed
+25

No files matched your search

Generated
+1
View File
@@ -7959,6 +7959,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"lopdf",
"pdfcraft-cos",
"pdfcraft-oracle",
"png 0.18.1",
"serde",
+1
View File
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
pdfcraft-oracle = { workspace = true }
pdfcraft-cos = { workspace = true }
anyhow = "1"
lopdf = "0.45"
png = "0.18"
+23
View File
@@ -212,7 +212,16 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> {
let cli = release_cli();
let mut scores = Vec::new();
let mut worst: Vec<(f64, String)> = Vec::new();
let mut not_permitted = 0usize;
for f in files.iter().take(limit) {
// A document may forbid copying its text, and we honour that: `pdfcraft-cli text` refuses
// and says how to override it with the owner password. pdftotext ignores the flag and
// extracts anyway, so scoring us against it there measures obedience, not quality --
// bug1815476.pdf scored 0.000 for behaving correctly on a page we render perfectly.
if !copying_allowed(f) {
not_permitted = not_permitted.saturating_add(1);
continue;
}
let ours = run_capture(Command::new(&cli).arg("text").arg(f), 20);
let theirs = run_capture(pdftotext.command().arg("-q").arg(f).arg("-"), 20);
let (Some(ours), Some(theirs)) = (ours, theirs) else { continue };
@@ -232,6 +241,9 @@ pub fn text_oracle(args: &[String]) -> anyhow::Result<()> {
let median = scores[scores.len() / 2];
let mean = scores.iter().sum::<f64>() / scores.len() as f64;
println!("text oracle: {} files with text; median F1 {:.3}, mean {:.3}, p10 {:.3}", scores.len(), median, mean, scores[scores.len() / 10]);
if not_permitted > 0 {
println!(" {not_permitted} skipped: the document forbids copying its text, which we honour and pdftotext does not");
}
println!("lowest:");
for (s, n) in worst.iter().take(12) {
println!(" {s:.3} {n}");
@@ -350,6 +362,17 @@ pub fn render_oracle(args: &[String]) -> anyhow::Result<()> {
Ok(())
}
/// Whether the document lets its text be copied.
///
/// `true` when it is unencrypted, when the flag is set, and when the file cannot be opened at all
/// — a file we cannot read is a different problem, and silently dropping it from the comparison
/// would hide it.
fn copying_allowed(path: &Path) -> bool {
let Ok(bytes) = std::fs::read(path) else { return true };
let Ok(doc) = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)) else { return true };
doc.permissions().is_none_or(|p| p.copy())
}
/// The value after `name` on the command line, parsed, or `None` when it is absent or malformed.
fn flag_num<T: std::str::FromStr>(args: &[String], name: &str) -> Option<T> {
args.iter().position(|a| a == name).and_then(|i| args.get(i.checked_add(1)?)).and_then(|v| v.parse().ok())