islam ayoub commited on
Commit
f954ee9
·
verified ·
1 Parent(s): f80e496

Add pdf-damage-classifier ONNX model

Browse files
Files changed (9) hide show
  1. LICENSE +21 -0
  2. README.md +77 -0
  3. labels.json +24 -0
  4. metrics.txt +12 -0
  5. pdf_damage_classifier.onnx +3 -0
  6. pdf_features.py +93 -0
  7. predict.py +23 -0
  8. requirements.txt +5 -0
  9. 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")