islam ayoub commited on
Add pdf-damage-classifier ONNX model
Browse files- LICENSE +21 -0
- README.md +77 -0
- labels.json +24 -0
- metrics.txt +12 -0
- pdf_damage_classifier.onnx +3 -0
- pdf_features.py +93 -0
- predict.py +23 -0
- requirements.txt +5 -0
- train.py +42 -0
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Utilvo
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
library_name: onnx
|
| 4 |
+
tags:
|
| 5 |
+
- onnx
|
| 6 |
+
- pdf
|
| 7 |
+
- pdf-repair
|
| 8 |
+
- tabular-classification
|
| 9 |
+
- scikit-learn
|
| 10 |
+
datasets:
|
| 11 |
+
- utilvo/pdf-corruption-lab
|
| 12 |
+
pipeline_tag: tabular-classification
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
# pdf-damage-classifier
|
| 16 |
+
|
| 17 |
+
A tiny (75 KB) ONNX model that looks at the **raw bytes** of a PDF and predicts
|
| 18 |
+
what kind of structural damage it has, so a repair tool can choose the right
|
| 19 |
+
recovery strategy. It does not need a PDF parser, so it works on files that are
|
| 20 |
+
too broken to open.
|
| 21 |
+
|
| 22 |
+
Trained from scratch (scikit-learn `RandomForestClassifier`, exported with
|
| 23 |
+
`skl2onnx`) on synthetic data generated by the scripts in
|
| 24 |
+
[`utilvo/pdf-corruption-lab`](https://huggingface.co/datasets/utilvo/pdf-corruption-lab).
|
| 25 |
+
No third-party models or documents are used.
|
| 26 |
+
|
| 27 |
+
## Classes
|
| 28 |
+
|
| 29 |
+
`valid`, `missing_eof`, `invalid_startxref`, `scrambled_xref`,
|
| 30 |
+
`bad_root_reference`, `truncated`
|
| 31 |
+
|
| 32 |
+
## Input
|
| 33 |
+
|
| 34 |
+
A float32 vector of 12 structural features computed by
|
| 35 |
+
[`pdf_features.py`](pdf_features.py) (header present, `%%EOF` present,
|
| 36 |
+
`startxref` valid, share of xref entries that point to real objects, trailer
|
| 37 |
+
present, `/Root` object exists, `obj`/`endobj` and `stream`/`endstream` balance,
|
| 38 |
+
log file size). The exact order is in `labels.json`.
|
| 39 |
+
|
| 40 |
+
## Usage
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
pip install -r requirements.txt
|
| 44 |
+
python predict.py broken.pdf
|
| 45 |
+
# broken.pdf: scrambled_xref (p=0.99)
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## Evaluation and limitations (please read)
|
| 49 |
+
|
| 50 |
+
- Hold-out accuracy on the synthetic test split (300 files) is 100%, and the
|
| 51 |
+
7 reference files in the dataset repo are all classified correctly.
|
| 52 |
+
- **This number is not evidence of real-world accuracy.** The damage types are
|
| 53 |
+
generated by simple rules and are mostly separable by the features, so the
|
| 54 |
+
task is easy by construction.
|
| 55 |
+
- Training files are produced by `pdf-lib` with classic xref tables (no object
|
| 56 |
+
streams / xref streams) and are small. PDFs produced by other software,
|
| 57 |
+
encrypted files, incremental updates, and real-world corruption (bit flips,
|
| 58 |
+
partial overwrites, mixed damage) are **not covered** and will likely be
|
| 59 |
+
misclassified.
|
| 60 |
+
- Use it as a quick triage hint, not as a validator.
|
| 61 |
+
|
| 62 |
+
## Reproduce
|
| 63 |
+
|
| 64 |
+
```bash
|
| 65 |
+
git clone https://huggingface.co/datasets/utilvo/pdf-corruption-lab
|
| 66 |
+
cd pdf-corruption-lab && npm install
|
| 67 |
+
node gen_training.js ../data 200
|
| 68 |
+
cd .. && python train.py data .
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
## Disclosure
|
| 72 |
+
|
| 73 |
+
Maintained by the team behind Utilvo (https://utilvo.com/pdf-tools).
|
| 74 |
+
|
| 75 |
+
## License
|
| 76 |
+
|
| 77 |
+
MIT.
|
labels.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"classes": [
|
| 3 |
+
"bad_root_reference",
|
| 4 |
+
"invalid_startxref",
|
| 5 |
+
"missing_eof",
|
| 6 |
+
"scrambled_xref",
|
| 7 |
+
"truncated",
|
| 8 |
+
"valid"
|
| 9 |
+
],
|
| 10 |
+
"features": [
|
| 11 |
+
"has_pdf_header",
|
| 12 |
+
"eof_in_last_1k",
|
| 13 |
+
"ends_with_eof",
|
| 14 |
+
"startxref_present",
|
| 15 |
+
"startxref_numeric",
|
| 16 |
+
"startxref_points_to_xref",
|
| 17 |
+
"xref_valid_fraction",
|
| 18 |
+
"has_trailer",
|
| 19 |
+
"root_object_exists",
|
| 20 |
+
"obj_endobj_gap",
|
| 21 |
+
"stream_endstream_gap",
|
| 22 |
+
"log10_size"
|
| 23 |
+
]
|
| 24 |
+
}
|
metrics.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
precision recall f1-score support
|
| 2 |
+
|
| 3 |
+
bad_root_reference 1.000 1.000 1.000 50
|
| 4 |
+
invalid_startxref 1.000 1.000 1.000 50
|
| 5 |
+
missing_eof 1.000 1.000 1.000 50
|
| 6 |
+
scrambled_xref 1.000 1.000 1.000 50
|
| 7 |
+
truncated 1.000 1.000 1.000 50
|
| 8 |
+
valid 1.000 1.000 1.000 50
|
| 9 |
+
|
| 10 |
+
accuracy 1.000 300
|
| 11 |
+
macro avg 1.000 1.000 1.000 300
|
| 12 |
+
weighted avg 1.000 1.000 1.000 300
|
pdf_damage_classifier.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:038a87bb980f5ce9794c64e9fa4ae2ba670252ad4a43d120605b58fff880ae31
|
| 3 |
+
size 75503
|
pdf_features.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Byte-level structural features used by pdf-damage-classifier.
|
| 2 |
+
|
| 3 |
+
The same function is used for training and inference, so the ONNX model's
|
| 4 |
+
input vector is defined by FEATURE_NAMES (in this exact order).
|
| 5 |
+
Only the PDF's raw bytes are needed; no PDF parser is involved, so it works
|
| 6 |
+
on files that are too broken to open.
|
| 7 |
+
"""
|
| 8 |
+
import math
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
FEATURE_NAMES = [
|
| 12 |
+
"has_pdf_header",
|
| 13 |
+
"eof_in_last_1k",
|
| 14 |
+
"ends_with_eof",
|
| 15 |
+
"startxref_present",
|
| 16 |
+
"startxref_numeric",
|
| 17 |
+
"startxref_points_to_xref",
|
| 18 |
+
"xref_valid_fraction",
|
| 19 |
+
"has_trailer",
|
| 20 |
+
"root_object_exists",
|
| 21 |
+
"obj_endobj_gap",
|
| 22 |
+
"stream_endstream_gap",
|
| 23 |
+
"log10_size",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
_XREF_ENTRY = re.compile(rb"(\d{10}) (\d{5}) n")
|
| 27 |
+
_STARTXREF = re.compile(rb"startxref\s+(\S+)")
|
| 28 |
+
_ROOT = re.compile(rb"/Root\s+(\d+)\s+(\d+)\s+R")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def extract_features(data: bytes):
|
| 32 |
+
n = len(data)
|
| 33 |
+
tail = data[-1024:]
|
| 34 |
+
|
| 35 |
+
has_header = float(data[:5] == b"%PDF-")
|
| 36 |
+
eof_in_tail = float(b"%%EOF" in tail)
|
| 37 |
+
ends_eof = float(data.rstrip()[-5:] == b"%%EOF")
|
| 38 |
+
|
| 39 |
+
m = None
|
| 40 |
+
for m in _STARTXREF.finditer(tail):
|
| 41 |
+
pass
|
| 42 |
+
sx_present = float(m is not None)
|
| 43 |
+
sx_numeric = 0.0
|
| 44 |
+
sx_points = 0.0
|
| 45 |
+
if m is not None:
|
| 46 |
+
tok = m.group(1)
|
| 47 |
+
if tok.isdigit():
|
| 48 |
+
sx_numeric = 1.0
|
| 49 |
+
off = int(tok)
|
| 50 |
+
if off < n and data[off : off + 4] == b"xref":
|
| 51 |
+
sx_points = 1.0
|
| 52 |
+
|
| 53 |
+
entries = _XREF_ENTRY.findall(data)
|
| 54 |
+
if entries:
|
| 55 |
+
ok = 0
|
| 56 |
+
for off_s, _gen in entries:
|
| 57 |
+
off = int(off_s)
|
| 58 |
+
if off < n and re.match(rb"\d+ \d+ obj", data[off : off + 24]):
|
| 59 |
+
ok += 1
|
| 60 |
+
xref_frac = ok / len(entries)
|
| 61 |
+
else:
|
| 62 |
+
xref_frac = 0.0
|
| 63 |
+
|
| 64 |
+
has_trailer = float(b"trailer" in data)
|
| 65 |
+
|
| 66 |
+
rm = _ROOT.search(data)
|
| 67 |
+
root_exists = 0.0
|
| 68 |
+
if rm is not None:
|
| 69 |
+
root_exists = float(re.search(rb"(?:^|\s)" + rm.group(1) + b" " + rm.group(2) + b" obj", data) is not None)
|
| 70 |
+
|
| 71 |
+
objs = len(re.findall(rb"\d+ \d+ obj\b", data))
|
| 72 |
+
endobjs = data.count(b"endobj")
|
| 73 |
+
# Count stream starts, ignoring the "stream\n" inside "endstream\n".
|
| 74 |
+
stream_starts = len(re.findall(rb"(?<!end)stream\r?\n", data))
|
| 75 |
+
endstreams = data.count(b"endstream")
|
| 76 |
+
|
| 77 |
+
obj_gap = (objs - endobjs) / max(objs, 1)
|
| 78 |
+
stream_gap = (stream_starts - endstreams) / max(stream_starts, 1)
|
| 79 |
+
|
| 80 |
+
return [
|
| 81 |
+
has_header,
|
| 82 |
+
eof_in_tail,
|
| 83 |
+
ends_eof,
|
| 84 |
+
sx_present,
|
| 85 |
+
sx_numeric,
|
| 86 |
+
sx_points,
|
| 87 |
+
xref_frac,
|
| 88 |
+
has_trailer,
|
| 89 |
+
root_exists,
|
| 90 |
+
obj_gap,
|
| 91 |
+
stream_gap,
|
| 92 |
+
math.log10(max(n, 1)),
|
| 93 |
+
]
|
predict.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Classify the damage type of one or more PDF files with the ONNX model.
|
| 2 |
+
|
| 3 |
+
Usage: python predict.py file1.pdf [file2.pdf ...]
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import onnxruntime as ort
|
| 11 |
+
|
| 12 |
+
from pdf_features import extract_features
|
| 13 |
+
|
| 14 |
+
here = Path(__file__).parent
|
| 15 |
+
meta = json.loads((here / "labels.json").read_text())
|
| 16 |
+
sess = ort.InferenceSession(str(here / "pdf_damage_classifier.onnx"), providers=["CPUExecutionProvider"])
|
| 17 |
+
input_name = sess.get_inputs()[0].name
|
| 18 |
+
|
| 19 |
+
for path in sys.argv[1:]:
|
| 20 |
+
x = np.array([extract_features(Path(path).read_bytes())], dtype=np.float32)
|
| 21 |
+
label_idx, probs = sess.run(None, {input_name: x})
|
| 22 |
+
label = meta["classes"][int(label_idx[0])]
|
| 23 |
+
print(f"{Path(path).name}: {label} (p={float(probs[0][int(label_idx[0])]):.2f})")
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
numpy
|
| 2 |
+
onnxruntime
|
| 3 |
+
scikit-learn
|
| 4 |
+
skl2onnx
|
| 5 |
+
onnx
|
train.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Train the PDF damage classifier and export it to ONNX.
|
| 2 |
+
|
| 3 |
+
Usage: python train.py [data_dir] [out_dir]
|
| 4 |
+
"""
|
| 5 |
+
import csv
|
| 6 |
+
import json
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
from skl2onnx import to_onnx
|
| 12 |
+
from sklearn.ensemble import RandomForestClassifier
|
| 13 |
+
from sklearn.metrics import classification_report, confusion_matrix
|
| 14 |
+
from sklearn.model_selection import train_test_split
|
| 15 |
+
|
| 16 |
+
from pdf_features import FEATURE_NAMES, extract_features
|
| 17 |
+
|
| 18 |
+
data_dir = Path(sys.argv[1] if len(sys.argv) > 1 else "data")
|
| 19 |
+
out_dir = Path(sys.argv[2] if len(sys.argv) > 2 else ".")
|
| 20 |
+
|
| 21 |
+
X, y = [], []
|
| 22 |
+
with open(data_dir / "labels.csv", newline="") as f:
|
| 23 |
+
for row in csv.DictReader(f):
|
| 24 |
+
X.append(extract_features((data_dir / row["file"]).read_bytes()))
|
| 25 |
+
y.append(row["label"])
|
| 26 |
+
X = np.array(X, dtype=np.float32)
|
| 27 |
+
classes = sorted(set(y))
|
| 28 |
+
y_idx = np.array([classes.index(c) for c in y], dtype=np.int64)
|
| 29 |
+
|
| 30 |
+
X_tr, X_te, y_tr, y_te = train_test_split(X, y_idx, test_size=0.25, stratify=y_idx, random_state=0)
|
| 31 |
+
clf = RandomForestClassifier(n_estimators=60, max_depth=8, random_state=0).fit(X_tr, y_tr)
|
| 32 |
+
pred = clf.predict(X_te)
|
| 33 |
+
|
| 34 |
+
report = classification_report(y_te, pred, target_names=classes, digits=3)
|
| 35 |
+
print(report)
|
| 36 |
+
print(confusion_matrix(y_te, pred))
|
| 37 |
+
|
| 38 |
+
onx = to_onnx(clf, X_tr[:1], options={id(clf): {"zipmap": False}}, target_opset=15)
|
| 39 |
+
(out_dir / "pdf_damage_classifier.onnx").write_bytes(onx.SerializeToString())
|
| 40 |
+
(out_dir / "labels.json").write_text(json.dumps({"classes": classes, "features": FEATURE_NAMES}, indent=2))
|
| 41 |
+
(out_dir / "metrics.txt").write_text(report)
|
| 42 |
+
print("saved pdf_damage_classifier.onnx")
|