pdf-hidden-text-inspector 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PDF Hidden Text Inspector contributors
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.
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: pdf-hidden-text-inspector
3
+ Version: 0.2.0
4
+ Summary: Local PDF hidden-text inspection with visual evidence
5
+ License-Expression: MIT
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Environment :: Console
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Topic :: Security
12
+ Classifier: Topic :: Text Processing
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: pypdfium2>=4.30
17
+ Requires-Dist: pypdf>=5
18
+ Requires-Dist: Pillow>=10
19
+ Dynamic: license-file
20
+
21
+ # PDF Hidden Text Inspector
22
+
23
+ Inspect PDFs for text that extraction can read but a person may not see or easily notice on the page. The tool compares native PDF text with rendered pixels and local OCR, then writes an HTML evidence report and machine-readable JSON. It runs locally and does not send PDFs to a model or cloud service.
24
+
25
+ This is an early inspection tool. Findings need human review; a completed scan is not a claim that a PDF is safe.
26
+
27
+ ## Install
28
+
29
+ Requires Python 3.10 or newer.
30
+
31
+ ```powershell
32
+ py -m pip install pdf-hidden-text-inspector
33
+ ```
34
+
35
+ Until the first PyPI release, install from a local copy of this project:
36
+
37
+ ```powershell
38
+ py -m pip install .
39
+ ```
40
+
41
+ Windows uses built-in Windows OCR by default. On other systems, install Tesseract separately and make sure its executable is on `PATH`. OCR can be disabled with `--ocr none`, but the resulting scan will be marked partial.
42
+
43
+ ## Use
44
+
45
+ ```powershell
46
+ inspect-pdf-hidden-text "C:\path\document.pdf" --out report
47
+ ```
48
+
49
+ Open `report/report.html` to review highlighted page images and findings. Use `report/report.json` for structured results. Options include `--ocr auto|windows|tesseract|none`, `--lang eng`, and `--max-pages N`. For example:
50
+
51
+ ```powershell
52
+ inspect-pdf-hidden-text "C:\path\document.pdf" --out report --ocr tesseract --lang eng+chi_tra
53
+ ```
54
+
55
+ Exit code `0` means all implemented checks completed. Exit code `2` means coverage is partial, such as when OCR is unavailable, pages were skipped, or a page failed. The report records the specific coverage and errors. Neither exit code is a security verdict.
56
+
57
+ Reports contain extracted document text and rendered pages. Handle them like the source PDF.
58
+
59
+ ## What it checks
60
+
61
+ The inspector highlights very small or invisible PDF text, near-transparent text, text outside the visible page, regions with little rendered variation, and differences between native text and OCR in the same region. It also checks selected metadata and annotation fields for instruction-like wording. It distinguishes a possible legitimate OCR text layer when invisible text agrees with visible OCR.
62
+
63
+ The heuristic checks can produce false positives, especially for formulas, unusual fonts, and languages that OCR handles poorly. Some PDF channels are not covered, including disabled optional-content layers, arbitrary clipping, attachment contents, and historical revisions. The project source includes further coverage notes.
64
+
65
+ ## Develop and test
66
+
67
+ ```powershell
68
+ py -m pip install -r inspect-pdf-hidden-text/requirements.txt
69
+ py -m pip install reportlab
70
+ py inspect-pdf-hidden-text/tests/test_inspector.py --out outputs/tests
71
+ ```
72
+
73
+ The controlled fixtures exercise concealment methods, normal text, white text on a dark panel, OCR overlays, annotation text, and partial coverage. The packaged command and the underlying script share the same scanner.
74
+
75
+ ## 中文簡介
76
+
77
+ 這個工具在本機檢查 PDF 中「文字擷取讀得到,但頁面畫面看不到或難以閱讀」的內容,輸出 HTML 與 JSON 證據報告。候選項目需要人工確認;完成掃描不代表檔案安全。Windows 預設使用本機 Windows OCR,其他系統需要安裝 Tesseract。
@@ -0,0 +1,57 @@
1
+ # PDF Hidden Text Inspector
2
+
3
+ Inspect PDFs for text that extraction can read but a person may not see or easily notice on the page. The tool compares native PDF text with rendered pixels and local OCR, then writes an HTML evidence report and machine-readable JSON. It runs locally and does not send PDFs to a model or cloud service.
4
+
5
+ This is an early inspection tool. Findings need human review; a completed scan is not a claim that a PDF is safe.
6
+
7
+ ## Install
8
+
9
+ Requires Python 3.10 or newer.
10
+
11
+ ```powershell
12
+ py -m pip install pdf-hidden-text-inspector
13
+ ```
14
+
15
+ Until the first PyPI release, install from a local copy of this project:
16
+
17
+ ```powershell
18
+ py -m pip install .
19
+ ```
20
+
21
+ Windows uses built-in Windows OCR by default. On other systems, install Tesseract separately and make sure its executable is on `PATH`. OCR can be disabled with `--ocr none`, but the resulting scan will be marked partial.
22
+
23
+ ## Use
24
+
25
+ ```powershell
26
+ inspect-pdf-hidden-text "C:\path\document.pdf" --out report
27
+ ```
28
+
29
+ Open `report/report.html` to review highlighted page images and findings. Use `report/report.json` for structured results. Options include `--ocr auto|windows|tesseract|none`, `--lang eng`, and `--max-pages N`. For example:
30
+
31
+ ```powershell
32
+ inspect-pdf-hidden-text "C:\path\document.pdf" --out report --ocr tesseract --lang eng+chi_tra
33
+ ```
34
+
35
+ Exit code `0` means all implemented checks completed. Exit code `2` means coverage is partial, such as when OCR is unavailable, pages were skipped, or a page failed. The report records the specific coverage and errors. Neither exit code is a security verdict.
36
+
37
+ Reports contain extracted document text and rendered pages. Handle them like the source PDF.
38
+
39
+ ## What it checks
40
+
41
+ The inspector highlights very small or invisible PDF text, near-transparent text, text outside the visible page, regions with little rendered variation, and differences between native text and OCR in the same region. It also checks selected metadata and annotation fields for instruction-like wording. It distinguishes a possible legitimate OCR text layer when invisible text agrees with visible OCR.
42
+
43
+ The heuristic checks can produce false positives, especially for formulas, unusual fonts, and languages that OCR handles poorly. Some PDF channels are not covered, including disabled optional-content layers, arbitrary clipping, attachment contents, and historical revisions. The project source includes further coverage notes.
44
+
45
+ ## Develop and test
46
+
47
+ ```powershell
48
+ py -m pip install -r inspect-pdf-hidden-text/requirements.txt
49
+ py -m pip install reportlab
50
+ py inspect-pdf-hidden-text/tests/test_inspector.py --out outputs/tests
51
+ ```
52
+
53
+ The controlled fixtures exercise concealment methods, normal text, white text on a dark panel, OCR overlays, annotation text, and partial coverage. The packaged command and the underlying script share the same scanner.
54
+
55
+ ## 中文簡介
56
+
57
+ 這個工具在本機檢查 PDF 中「文字擷取讀得到,但頁面畫面看不到或難以閱讀」的內容,輸出 HTML 與 JSON 證據報告。候選項目需要人工確認;完成掃描不代表檔案安全。Windows 預設使用本機 Windows OCR,其他系統需要安裝 Tesseract。
@@ -0,0 +1 @@
1
+ """Local PDF hidden-text inspection package."""
@@ -0,0 +1,333 @@
1
+ """Read-only, local PDF hidden-text inspector. No model calls or network requests."""
2
+ import argparse
3
+ import base64
4
+ import ctypes
5
+ import csv
6
+ import html
7
+ import io
8
+ import json
9
+ import math
10
+ import os
11
+ from pathlib import Path
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import unicodedata
16
+ from difflib import SequenceMatcher
17
+
18
+ import pypdfium2 as pdfium
19
+ import pypdfium2.raw as raw
20
+ from pypdf import PdfReader
21
+ from pypdf.generic import DictionaryObject
22
+ from PIL import ImageDraw, ImageStat
23
+
24
+ HINT = re.compile(r"ignore.{0,30}(instruction|previous)|system\s*prompt|you are (chatgpt|an ai)|include.{0,40}(answer|response)|忽略.{0,20}(指令|要求)|回答.{0,20}(加入|包含)|請.{0,15}(輸出|回答)", re.I)
25
+ LABELS = {
26
+ 'tiny_font': '字級小於 3pt,難以閱讀', 'invisible_mode': 'PDF 指定不繪製文字',
27
+ 'low_alpha': '文字填色近乎透明', 'off_page': '文字超出可見頁面',
28
+ 'flat_pixels': '該區域畫面近乎單色,未見明顯字形',
29
+ 'ocr_mismatch': '文字層與同位置 OCR 不一致(可能是辨識誤差)',
30
+ 'unicode_control': '含不可見或方向控制字元', 'metadata_hint': '中繼資料含疑似指令用語',
31
+ }
32
+
33
+
34
+ def norm(s):
35
+ return ''.join(c.casefold() for c in unicodedata.normalize('NFKC', s) if c.isalnum())
36
+
37
+
38
+ def special(c):
39
+ return unicodedata.category(c) == 'Cf' or 0xE0000 <= ord(c) <= 0xE007F
40
+
41
+
42
+ def annotation_findings(reader):
43
+ """Report annotation strings that can be consumed without appearing on the page."""
44
+ found = []
45
+ for page_number, page in enumerate(reader.pages, 1):
46
+ for index, reference in enumerate(page.get('/Annots', []), 1):
47
+ try:
48
+ annot = reference.get_object()
49
+ if not isinstance(annot, DictionaryObject):
50
+ continue
51
+ fields = [('/Contents', annot.get('/Contents')),
52
+ ('/T', annot.get('/T')),
53
+ ('/Subj', annot.get('/Subj')),
54
+ ('/RC', annot.get('/RC'))]
55
+ action = annot.get('/A')
56
+ if action:
57
+ action = action.get_object()
58
+ if isinstance(action, DictionaryObject):
59
+ fields.extend((f'/A{k}', action.get(k)) for k in ('/URI', '/JS'))
60
+ for field, value in fields:
61
+ if isinstance(value, str) and (HINT.search(value) or any(special(c) for c in value)):
62
+ found.append({'page': page_number, 'annotation': index,
63
+ 'subtype': str(annot.get('/Subtype', 'unknown')),
64
+ 'field': field, 'text': value,
65
+ 'reason': 'annotation_hint'})
66
+ except Exception as exc:
67
+ found.append({'page': page_number, 'annotation': index,
68
+ 'subtype': 'unknown', 'field': 'error',
69
+ 'text': str(exc), 'reason': 'annotation_error'})
70
+ return found
71
+
72
+
73
+ def union(boxes):
74
+ return [min(b[0] for b in boxes), min(b[1] for b in boxes),
75
+ max(b[2] for b in boxes), max(b[3] for b in boxes)]
76
+
77
+
78
+ def intersect(a, b):
79
+ return max(0, min(a[2], b[2])-max(a[0], b[0])) * max(0, min(a[3], b[3])-max(a[1], b[1]))
80
+
81
+
82
+ def recognize(image_path, engine, lang):
83
+ if engine == 'none':
84
+ return {'status': 'skipped', 'reason': 'OCR explicitly disabled', 'words': []}
85
+ if engine == 'auto':
86
+ engine = 'windows' if os.name == 'nt' else 'tesseract'
87
+ try:
88
+ if engine == 'windows':
89
+ # Execute an inline command without changing the host execution policy.
90
+ script = Path(__file__).with_name('windows_ocr.ps1').read_text(encoding='utf-8')
91
+ quoted_path = str(image_path.resolve()).replace("'", "''")
92
+ cmd = ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command',
93
+ '& {\n' + script + "\n} -ImagePath '" + quoted_path + "'"]
94
+ result = subprocess.run(cmd, capture_output=True, timeout=90, check=True)
95
+ data = json.loads(result.stdout.decode('utf-8-sig'))
96
+ else:
97
+ exe = shutil.which('tesseract')
98
+ if not exe:
99
+ raise RuntimeError('Tesseract executable not installed')
100
+ result = subprocess.run([exe, str(image_path), 'stdout', '-l', lang, 'tsv'],
101
+ capture_output=True, timeout=90, check=True)
102
+ words = []
103
+ for row in csv.DictReader(io.StringIO(result.stdout.decode('utf-8-sig')), delimiter='\t'):
104
+ if row['text'].strip() and float(row['conf']) >= 0:
105
+ x,y,w,h = (int(row[k]) for k in ['left','top','width','height'])
106
+ words.append({'text':row['text'], 'box':[x,y,x+w,y+h]})
107
+ data = {'words':words, 'language':lang, 'text':' '.join(w['text'] for w in words)}
108
+ data.update(status='complete', engine=engine)
109
+ return data
110
+ except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as e:
111
+ message = str(e)
112
+ if isinstance(e, subprocess.CalledProcessError):
113
+ message = 'OCR process failed: ' + e.stderr.decode('utf-8', errors='replace')[-2000:]
114
+ return {'status':'unavailable', 'engine':engine, 'reason':message, 'words':[]}
115
+
116
+
117
+ def extract_runs(page):
118
+ """Keep PDF native coordinates until comparing with the rendered CropBox."""
119
+ tp = page.get_textpage()
120
+ runs, current = [], []
121
+ def flush():
122
+ if current:
123
+ runs.append({'text': ''.join(c['text'] for c in current),
124
+ 'box':union([c['box'] for c in current]),
125
+ 'size':min(c['size'] for c in current), 'mode':current[0]['mode'],
126
+ 'alpha':min(c['alpha'] for c in current), 'fill_rgba':current[0]['fill_rgba']})
127
+ current.clear()
128
+ try:
129
+ for i in range(tp.count_chars()):
130
+ t = tp.get_text_range(i, 1)
131
+ if not t or t in '\r\n':
132
+ flush()
133
+ continue
134
+ box = list(tp.get_charbox(i))
135
+ color = [ctypes.c_uint() for _ in range(4)]
136
+ ok = raw.FPDFText_GetFillColor(tp, i, *[ctypes.byref(v) for v in color])
137
+ obj = raw.FPDFText_GetTextObject(tp, i)
138
+ mode = raw.FPDFTextObj_GetTextRenderMode(obj) if obj else -1
139
+ nominal_size = raw.FPDFText_GetFontSize(tp,i)
140
+ matrix = raw.FS_MATRIX()
141
+ effective_size = nominal_size
142
+ if raw.FPDFText_GetMatrix(tp,i,ctypes.byref(matrix)):
143
+ effective_size *= math.hypot(matrix.c,matrix.d)
144
+ c = {'text':t, 'box':box, 'size':effective_size,
145
+ 'alpha':color[3].value if ok else 255, 'mode':mode,
146
+ 'fill_rgba':[v.value for v in color] if ok else None}
147
+ if not all(math.isfinite(v) for v in box+[c['size']]):
148
+ raise ValueError('Nonfinite character geometry')
149
+ if current:
150
+ prev = current[-1]
151
+ gap = box[0]-prev['box'][2]
152
+ if (mode != prev['mode'] or c['alpha'] != prev['alpha'] or c['fill_rgba'] != prev['fill_rgba'] or
153
+ abs(c['size']-prev['size']) > .5 or
154
+ abs(box[1]-prev['box'][1]) > max(2,c['size']*.4) or
155
+ gap > max(8,c['size']*1.5) or gap < -max(8,c['size'])):
156
+ flush()
157
+ current.append(c)
158
+ flush()
159
+ return runs
160
+ finally:
161
+ tp.close()
162
+
163
+
164
+ def inspect_page(page, number, out, engine, lang):
165
+ crop = list(page.get_bbox())
166
+ scale = min(2.5, 2400/max(page.get_size()))
167
+ bitmap = page.render(scale=scale)
168
+ image = bitmap.to_pil().convert('RGB')
169
+ bitmap.close()
170
+ path = out / f'page-{number:03}.png'
171
+ image.save(path)
172
+ ocr = recognize(path,engine,lang)
173
+ runs = extract_runs(page)
174
+ findings = []
175
+ geometry = page.get_rotation() == 0
176
+ width,height = crop[2]-crop[0],crop[3]-crop[1]
177
+ sx,sy = image.width/width,image.height/height
178
+ for run in runs:
179
+ text = run['text']
180
+ if not text.strip():
181
+ continue
182
+ reasons = []
183
+ if run['size'] < 3:
184
+ reasons.append('tiny_font')
185
+ if run['mode'] in (3,7):
186
+ reasons.append('invisible_mode')
187
+ if run['alpha'] < 26 and run['mode'] in (0,2,4,6):
188
+ reasons.append('low_alpha')
189
+ if any(special(c) for c in text):
190
+ reasons.append('unicode_control')
191
+ pixel_box = None
192
+ match, visible_text, stddev = None, '', None
193
+ if geometry:
194
+ x0,y0,x1,y1 = run['box']
195
+ pixel_box = [(x0-crop[0])*sx,(crop[3]-y1)*sy,(x1-crop[0])*sx,(crop[3]-y0)*sy]
196
+ # Ignore normal tiny glyph overhang; detect meaningful overflow.
197
+ if x0 < crop[0]-1 or y0 < crop[1]-1 or x1 > crop[2]+1 or y1 > crop[3]+1:
198
+ reasons.append('off_page')
199
+ a,b,c,d = pixel_box
200
+ region = [max(0,int(a)),max(0,int(b)),min(image.width,math.ceil(c)),min(image.height,math.ceil(d))]
201
+ if region[2]>region[0] and region[3]>region[1]:
202
+ stddev = max(ImageStat.Stat(image.crop(region)).stddev)
203
+ if stddev < 2 and len(norm(text)) >= 4 and 'off_page' not in reasons:
204
+ reasons.append('flat_pixels')
205
+ if ocr['status'] == 'complete':
206
+ # Expand enough for OCR glyph boxes, but do not compare against other columns.
207
+ pad = max(4,run['size']*sx*.4)
208
+ expanded = [a-pad,b-pad,c+pad,d+pad]
209
+ selected = [w for w in ocr['words'] if intersect(expanded,w['box']) > 0]
210
+ visible_text = ' '.join(w['text'] for w in selected)
211
+ native,visual = norm(text),norm(visible_text)
212
+ match = (1.0 if native and native in visual else
213
+ SequenceMatcher(None,native,visual,autojunk=False).ratio())
214
+ if len(native)>=5 and match < .55:
215
+ reasons.append('ocr_mismatch')
216
+ if not reasons:
217
+ continue
218
+ overlay = ('invisible_mode' in reasons or 'low_alpha' in reasons) and match is not None and match >= .8
219
+ style = [r for r in reasons if r != 'ocr_mismatch']
220
+ classification = 'possible_ocr_layer' if overlay else ('concealment_candidate' if style else 'ocr_difference')
221
+ findings.append({'page':number, **run, 'pixel_box':pixel_box, 'reasons':reasons,
222
+ 'classification':classification, 'instruction_hint':bool(HINT.search(text)),
223
+ 'ocr_text':visible_text, 'ocr_similarity':match, 'region_stddev':stddev,
224
+ 'unicode_controls':[f'U+{ord(c):04X}' for c in text if special(c)]})
225
+ annotated = image.copy()
226
+ draw = ImageDraw.Draw(annotated)
227
+ for index,f in enumerate(findings,1):
228
+ if f['pixel_box']:
229
+ b = f['pixel_box']
230
+ if b[2]>b[0] and b[3]>b[1]:
231
+ draw.rectangle(b,outline='#ff4d4d',width=3)
232
+ draw.text((max(0,b[0]),max(0,b[1]-14)),str(index),fill='#bf1b1b')
233
+ buf = io.BytesIO()
234
+ annotated.save(buf,format='PNG')
235
+ return {'page':number,'status':'complete','geometry_comparison':geometry,
236
+ 'ocr':ocr,'runs':runs,'findings':findings},base64.b64encode(buf.getvalue()).decode()
237
+
238
+
239
+ def write_html(report, pictures, out):
240
+ esc = lambda v: html.escape(str(v),quote=True)
241
+ cards=[]
242
+ classes={'concealment_candidate':'隱藏/難讀候選','possible_ocr_layer':'可能是正常 OCR 層','ocr_difference':'OCR 差異待確認'}
243
+ for p in report['pages']:
244
+ parts=[f'<section><h2>第 {p["page"]} 頁</h2><p>頁面:{esc(p["status"])} · OCR:{esc(p.get("ocr",{}).get("status","not run"))} · {esc(p.get("ocr",{}).get("language",""))}</p>']
245
+ if p.get('error'):
246
+ parts.append(f'<pre>{esc(p["error"])}</pre>')
247
+ if p.get('ocr',{}).get('reason'):
248
+ parts.append(f'<p>{esc(p["ocr"]["reason"])}</p>')
249
+ if not p.get('geometry_comparison',True):
250
+ parts.append('<p>旋轉頁面:已跳過位置與像素比對。</p>')
251
+ if p['page'] in pictures:
252
+ parts.append(f'<details><summary>查看頁面與紅框位置</summary><img alt="Rendered page with candidate locations" src="data:image/png;base64,{pictures[p["page"]]}"></details>')
253
+ for i,f in enumerate(p.get('findings',[]),1):
254
+ parts.append(f'<article><h3>{i}. {esc(classes[f["classification"]])}</h3><pre>{esc(f["text"])}</pre><p>{esc(";".join(LABELS[r] for r in f["reasons"]))}</p><p>字級 {f["size"]:.2f}pt · 疑似指令用語:{"有" if f["instruction_hint"] else "未命中"}</p>')
255
+ parts.append(f'<p>PDF 座標(左、下、右、上;pt):{esc(", ".join(f"{v:.2f}" for v in f["box"]))}</p>')
256
+ if f.get('fill_rgba'):
257
+ parts.append(f'<p>填色 RGBA(0–255):{esc(f["fill_rgba"])}</p>')
258
+ if 'off_page' in f['reasons']:
259
+ parts.append('<p>此文字位於頁面外,原始頁面圖片可能看不到對應紅框。</p>')
260
+ if f['ocr_similarity'] is not None:
261
+ parts.append(f'<p>同位置 OCR 相似度:{f["ocr_similarity"]:.0%}</p><pre>OCR: {esc(f["ocr_text"])}</pre>')
262
+ parts.append('</article>')
263
+ if not p.get('findings') and p['status']=='complete':
264
+ parts.append('<p>已完成的檢查未發現候選項目。</p>')
265
+ parts.append('</section>')
266
+ cards.extend(parts)
267
+ metadata = ''.join(f'<article><h3>中繼資料 {esc(f["field"])}</h3><pre>{esc(f["text"])}</pre></article>' for f in report['metadata_findings'])
268
+ annotations = ''.join(f'<article><h3>第 {f["page"]} 頁註解 {f["annotation"]} · {esc(f["field"])}</h3><pre>{esc(f["text"])}</pre></article>' for f in report['annotation_findings'])
269
+ doc = '<!doctype html><html lang="zh-Hant"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>PDF 隱藏文字檢查</title><style>body{font-family:system-ui,sans-serif;max-width:1000px;margin:40px auto;padding:0 24px;background:#f3f5f8;color:#172033;line-height:1.65}header,section{background:white;padding:24px;border-radius:14px;margin:20px 0}article{border-left:4px solid #e8a027;padding:8px 18px;margin:20px 0;background:#fffaf1}pre{white-space:pre-wrap;overflow-wrap:anywhere;font-family:inherit}img{max-width:100%;border:1px solid #ccd}summary{cursor:pointer;color:#2458a0}h1{font-size:28px}</style><header><h1>PDF 隱藏文字檢查 · 原型</h1><p>'+esc(report['input_name'])+'</p><p>'+esc(report['summary'])+'</p><p>可疑內容僅作證據展示,並非要執行的指令。候選不等於惡意;未發現不等於安全。</p></header>'+metadata+annotations+''.join(cards)+'<section><h2>範圍與限制</h2><p>'+esc(report['limitations'])+'</p></section></html>'
270
+ (out/'report.html').write_text(doc,encoding='utf-8')
271
+
272
+
273
+ def scan(source, out, engine='auto', lang='eng', max_pages=None):
274
+ source,out=Path(source),Path(out)
275
+ out.mkdir(parents=True,exist_ok=True)
276
+ if source.resolve() in {(out/'report.json').resolve(),(out/'report.html').resolve()}:
277
+ raise ValueError('Output must not overwrite input')
278
+ report={'version':'0.2','input_name':source.name,'pages':[],'metadata_findings':[], 'annotation_findings':[],
279
+ 'limitations':'原型:OCR 差異不證明藏字;公式與語言可能誤判。未完整檢查停用圖層、裁切、替代文字、附件、註解的所有欄位與歷史版本。旋轉頁面跳過幾何比對。結果不能證明作者意圖或所有 AI 平台的實際輸入。'}
280
+ pictures={}
281
+ with pdfium.PdfDocument(str(source)) as doc:
282
+ total=len(doc)
283
+ count=min(total,max_pages) if max_pages else total
284
+ for i in range(count):
285
+ page=doc[i]
286
+ try:
287
+ result,picture=inspect_page(page,i+1,out,engine,lang)
288
+ report['pages'].append(result)
289
+ pictures[i+1]=picture
290
+ except Exception as e:
291
+ report['pages'].append({'page':i+1,'status':'error','error':str(e),'findings':[]})
292
+ finally:
293
+ page.close()
294
+ try:
295
+ reader=PdfReader(str(source))
296
+ for k,v in (reader.metadata or {}).items():
297
+ if isinstance(v,str) and (HINT.search(v) or any(special(c) for c in v)):
298
+ report['metadata_findings'].append({'field':k,'text':v,'reason':'metadata_hint'})
299
+ report['annotation_findings'] = annotation_findings(reader)
300
+ report['annotation_status'] = ('partial' if any(f['reason']=='annotation_error' for f in report['annotation_findings']) else 'complete')
301
+ report['metadata_status']='complete'
302
+ except Exception as e:
303
+ report['metadata_status']='error: '+str(e)
304
+ report['annotation_status']='error: '+str(e)
305
+ errors=sum(p['status']!='complete' for p in report['pages'])
306
+ ocr_done=sum(p.get('ocr',{}).get('status')=='complete' for p in report['pages'])
307
+ findings=sum(len(p['findings']) for p in report['pages'])+len(report['metadata_findings'])+len(report['annotation_findings'])
308
+ report['coverage']={'total_pages':total,'attempted_pages':count,'skipped_pages':total-count,
309
+ 'failed_pages':errors,'ocr_completed_pages':ocr_done}
310
+ report['summary']=f'共 {total} 頁;嘗試 {count} 頁;失敗 {errors} 頁;OCR 完成 {ocr_done} 頁;候選 {findings} 項。'
311
+ report['status']='completed' if count==total and errors==0 and ocr_done==count and report['metadata_status']=='complete' and report['annotation_status']=='complete' and all(p.get('geometry_comparison') for p in report['pages']) else 'partial'
312
+ (out/'report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8')
313
+ write_html(report,pictures,out)
314
+ return report
315
+
316
+
317
+ def main():
318
+ parser=argparse.ArgumentParser(description=__doc__)
319
+ parser.add_argument('input',type=Path)
320
+ parser.add_argument('--out',type=Path,default=Path('pdf-inspection'))
321
+ parser.add_argument('--ocr',choices=['auto','windows','tesseract','none'],default='auto')
322
+ parser.add_argument('--lang',default='eng')
323
+ parser.add_argument('--max-pages',type=int)
324
+ args=parser.parse_args()
325
+ if args.max_pages is not None and args.max_pages<1:
326
+ parser.error('--max-pages must be positive')
327
+ result=scan(args.input,args.out,args.ocr,args.lang,args.max_pages)
328
+ print(json.dumps({'status':result['status'],'coverage':result['coverage'],'report':str((args.out/'report.html').resolve())},ensure_ascii=True))
329
+ return 0 if result['status']=='completed' else 2
330
+
331
+
332
+ if __name__=='__main__':
333
+ raise SystemExit(main())
@@ -0,0 +1,42 @@
1
+ param([Parameter(Mandatory=$true)][string]$ImagePath)
2
+ $ErrorActionPreference = 'Stop'
3
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
4
+ Add-Type -AssemblyName System.Runtime.WindowsRuntime
5
+ $null = [Windows.Storage.StorageFile,Windows.Storage,ContentType=WindowsRuntime]
6
+ $null = [Windows.Graphics.Imaging.BitmapDecoder,Windows.Foundation,ContentType=WindowsRuntime]
7
+ $null = [Windows.Media.Ocr.OcrEngine,Windows.Foundation,ContentType=WindowsRuntime]
8
+ function Await-Result($Operation, [Type]$ResultType) {
9
+ $method = [System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object {
10
+ $_.Name -eq 'AsTask' -and $_.IsGenericMethod -and $_.GetParameters().Count -eq 1 -and
11
+ $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1'
12
+ } | Select-Object -First 1
13
+ $task = $method.MakeGenericMethod($ResultType).Invoke($null, @($Operation))
14
+ try { $task.Wait() } catch { throw $task.Exception.ToString() }
15
+ return $task.Result
16
+ }
17
+ $null = [Windows.Storage.Streams.InMemoryRandomAccessStream,Windows.Storage.Streams,ContentType=WindowsRuntime]
18
+ $null = [Windows.Storage.Streams.DataWriter,Windows.Storage.Streams,ContentType=WindowsRuntime]
19
+ $stream = New-Object Windows.Storage.Streams.InMemoryRandomAccessStream
20
+ $writer = New-Object Windows.Storage.Streams.DataWriter($stream)
21
+ $writer.WriteBytes([System.IO.File]::ReadAllBytes($ImagePath))
22
+ $null = Await-Result ($writer.StoreAsync()) ([uint32])
23
+ $null = $writer.DetachStream()
24
+ $writer.Dispose()
25
+ $stream.Seek(0)
26
+ try {
27
+ $decoder = Await-Result ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($stream)) ([Windows.Graphics.Imaging.BitmapDecoder])
28
+ $bitmap = Await-Result ($decoder.GetSoftwareBitmapAsync()) ([Windows.Graphics.Imaging.SoftwareBitmap])
29
+ try {
30
+ $engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
31
+ if ($null -eq $engine) { throw 'No local Windows OCR language available' }
32
+ $result = Await-Result ($engine.RecognizeAsync($bitmap)) ([Windows.Media.Ocr.OcrResult])
33
+ $words = @()
34
+ foreach ($line in $result.Lines) {
35
+ foreach ($word in $line.Words) {
36
+ $r = $word.BoundingRect
37
+ $words += @{text=$word.Text; box=@($r.X,$r.Y,($r.X+$r.Width),($r.Y+$r.Height))}
38
+ }
39
+ }
40
+ @{language=$engine.RecognizerLanguage.LanguageTag; words=$words; text=$result.Text} | ConvertTo-Json -Depth 6 -Compress
41
+ } finally { $bitmap.Dispose() }
42
+ } finally { $stream.Dispose() }
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: pdf-hidden-text-inspector
3
+ Version: 0.2.0
4
+ Summary: Local PDF hidden-text inspection with visual evidence
5
+ License-Expression: MIT
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Environment :: Console
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Topic :: Security
12
+ Classifier: Topic :: Text Processing
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: pypdfium2>=4.30
17
+ Requires-Dist: pypdf>=5
18
+ Requires-Dist: Pillow>=10
19
+ Dynamic: license-file
20
+
21
+ # PDF Hidden Text Inspector
22
+
23
+ Inspect PDFs for text that extraction can read but a person may not see or easily notice on the page. The tool compares native PDF text with rendered pixels and local OCR, then writes an HTML evidence report and machine-readable JSON. It runs locally and does not send PDFs to a model or cloud service.
24
+
25
+ This is an early inspection tool. Findings need human review; a completed scan is not a claim that a PDF is safe.
26
+
27
+ ## Install
28
+
29
+ Requires Python 3.10 or newer.
30
+
31
+ ```powershell
32
+ py -m pip install pdf-hidden-text-inspector
33
+ ```
34
+
35
+ Until the first PyPI release, install from a local copy of this project:
36
+
37
+ ```powershell
38
+ py -m pip install .
39
+ ```
40
+
41
+ Windows uses built-in Windows OCR by default. On other systems, install Tesseract separately and make sure its executable is on `PATH`. OCR can be disabled with `--ocr none`, but the resulting scan will be marked partial.
42
+
43
+ ## Use
44
+
45
+ ```powershell
46
+ inspect-pdf-hidden-text "C:\path\document.pdf" --out report
47
+ ```
48
+
49
+ Open `report/report.html` to review highlighted page images and findings. Use `report/report.json` for structured results. Options include `--ocr auto|windows|tesseract|none`, `--lang eng`, and `--max-pages N`. For example:
50
+
51
+ ```powershell
52
+ inspect-pdf-hidden-text "C:\path\document.pdf" --out report --ocr tesseract --lang eng+chi_tra
53
+ ```
54
+
55
+ Exit code `0` means all implemented checks completed. Exit code `2` means coverage is partial, such as when OCR is unavailable, pages were skipped, or a page failed. The report records the specific coverage and errors. Neither exit code is a security verdict.
56
+
57
+ Reports contain extracted document text and rendered pages. Handle them like the source PDF.
58
+
59
+ ## What it checks
60
+
61
+ The inspector highlights very small or invisible PDF text, near-transparent text, text outside the visible page, regions with little rendered variation, and differences between native text and OCR in the same region. It also checks selected metadata and annotation fields for instruction-like wording. It distinguishes a possible legitimate OCR text layer when invisible text agrees with visible OCR.
62
+
63
+ The heuristic checks can produce false positives, especially for formulas, unusual fonts, and languages that OCR handles poorly. Some PDF channels are not covered, including disabled optional-content layers, arbitrary clipping, attachment contents, and historical revisions. The project source includes further coverage notes.
64
+
65
+ ## Develop and test
66
+
67
+ ```powershell
68
+ py -m pip install -r inspect-pdf-hidden-text/requirements.txt
69
+ py -m pip install reportlab
70
+ py inspect-pdf-hidden-text/tests/test_inspector.py --out outputs/tests
71
+ ```
72
+
73
+ The controlled fixtures exercise concealment methods, normal text, white text on a dark panel, OCR overlays, annotation text, and partial coverage. The packaged command and the underlying script share the same scanner.
74
+
75
+ ## 中文簡介
76
+
77
+ 這個工具在本機檢查 PDF 中「文字擷取讀得到,但頁面畫面看不到或難以閱讀」的內容,輸出 HTML 與 JSON 證據報告。候選項目需要人工確認;完成掃描不代表檔案安全。Windows 預設使用本機 Windows OCR,其他系統需要安裝 Tesseract。
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ inspect-pdf-hidden-text/scripts/__init__.py
5
+ inspect-pdf-hidden-text/scripts/inspect_pdf.py
6
+ inspect-pdf-hidden-text/scripts/windows_ocr.ps1
7
+ pdf_hidden_text_inspector.egg-info/PKG-INFO
8
+ pdf_hidden_text_inspector.egg-info/SOURCES.txt
9
+ pdf_hidden_text_inspector.egg-info/dependency_links.txt
10
+ pdf_hidden_text_inspector.egg-info/entry_points.txt
11
+ pdf_hidden_text_inspector.egg-info/requires.txt
12
+ pdf_hidden_text_inspector.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ inspect-pdf-hidden-text = hiddentextinspector.inspect_pdf:main
@@ -0,0 +1,3 @@
1
+ pypdfium2>=4.30
2
+ pypdf>=5
3
+ Pillow>=10
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pdf-hidden-text-inspector"
7
+ version = "0.2.0"
8
+ description = "Local PDF hidden-text inspection with visual evidence"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = ["pypdfium2>=4.30", "pypdf>=5", "Pillow>=10"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Environment :: Console",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Operating System :: OS Independent",
19
+ "Topic :: Security",
20
+ "Topic :: Text Processing",
21
+ ]
22
+
23
+ [project.scripts]
24
+ inspect-pdf-hidden-text = "hiddentextinspector.inspect_pdf:main"
25
+
26
+ [tool.setuptools]
27
+ packages = ["hiddentextinspector"]
28
+ package-dir = {hiddentextinspector = "inspect-pdf-hidden-text/scripts"}
29
+
30
+ [tool.setuptools.package-data]
31
+ hiddentextinspector = ["windows_ocr.ps1"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+