docfgp 0.1.0__py3-none-any.whl

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.
docfgp/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Create deterministic document fingerprints."""
2
+
3
+ from .fingerprint import Fingerprinter
4
+ from .schemas import DocumentFingerprint, FingerprintConfig, FingerprintError
5
+
6
+ __all__ = ["Fingerprinter", "DocumentFingerprint", "FingerprintConfig", "FingerprintError"]
docfgp/cli.py ADDED
@@ -0,0 +1,49 @@
1
+ """Expose document fingerprinting as a CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from .fingerprint import Fingerprinter
9
+ from .schemas import FingerprintConfig, FingerprintError
10
+
11
+
12
+ def main(argv: list[str] | None = None) -> int:
13
+ """Print a document fingerprint as JSON.
14
+
15
+ Args:
16
+ argv: Optional command-line arguments.
17
+
18
+ Returns:
19
+ Zero on success and nonzero on processing errors.
20
+ """
21
+ parser = argparse.ArgumentParser(description="Fingerprint a document")
22
+ parser.add_argument("path", help="PDF, DOCX, PPTX, or image path")
23
+ parser.add_argument("--office2pdf", help="Path to the office2pdf binary")
24
+ parser.add_argument("--tesseract", help="Path to the Tesseract binary")
25
+ parser.add_argument("--ocr-language", default="eng", help="Installed Tesseract language codes")
26
+ parser.add_argument(
27
+ "--max-document-seconds", type=float, default=180.0, help="Processing deadline in seconds"
28
+ )
29
+ parser.add_argument(
30
+ "--no-shingle-counts", action="store_true", help="Omit precise overlap counts"
31
+ )
32
+ args = parser.parse_args(argv)
33
+ try:
34
+ config = FingerprintConfig(
35
+ office2pdf_path=args.office2pdf,
36
+ tesseract_path=args.tesseract,
37
+ ocr_language=args.ocr_language,
38
+ max_document_seconds=args.max_document_seconds,
39
+ include_shingle_counts=not args.no_shingle_counts,
40
+ )
41
+ print(Fingerprinter(config).fingerprint(args.path).to_json())
42
+ except (FingerprintError, ValueError) as exc:
43
+ print(f"docfgp: {exc}", file=sys.stderr)
44
+ return 2
45
+ return 0
46
+
47
+
48
+ if __name__ == "__main__":
49
+ raise SystemExit(main())
docfgp/extract.py ADDED
@@ -0,0 +1,274 @@
1
+ """Validate input and extract PDF or image pages with bounded decoding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import math
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ import unicodedata
11
+ import zipfile
12
+ from collections.abc import Generator, Iterator
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ import pypdfium2 as pdfium
17
+ from PIL import Image, UnidentifiedImageError
18
+
19
+ from .normalize import strict_text, word_tokens
20
+ from .schemas import FingerprintConfig, FingerprintError, OCRReason, _remaining_seconds
21
+
22
+ _IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp"}
23
+ _IMAGE_FORMATS = {"PNG", "JPEG", "TIFF", "BMP", "WEBP"}
24
+ _OFFICE_SUFFIXES = {".docx", ".pptx"}
25
+ RASTER_OCR_COVERAGE = 0.5
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ExtractedPage:
30
+ """Carry one page's native text, image renders, and OCR trigger."""
31
+
32
+ number: int
33
+ native_text: str
34
+ visual: Image.Image
35
+ ocr_image: Image.Image | None
36
+ ocr_reason: OCRReason | None
37
+
38
+
39
+ def identify(path: Path, config: FingerprintConfig) -> tuple[str, int]:
40
+ """Validate the format signature, size, and Office ZIP structure.
41
+
42
+ Args:
43
+ path: Existing input path.
44
+ config: Input and expansion limits.
45
+
46
+ Returns:
47
+ Format name and original byte size.
48
+
49
+ Raises:
50
+ FingerprintError: The file is unsupported, unsafe, or invalid.
51
+ """
52
+ try:
53
+ size = path.stat().st_size
54
+ with path.open("rb") as stream:
55
+ header = stream.read(16)
56
+ except OSError as exc:
57
+ raise FingerprintError(f"Cannot read document: {exc}") from exc
58
+ if size == 0 or size > config.max_file_bytes:
59
+ raise FingerprintError("Document is empty or exceeds max_file_bytes")
60
+ suffix = path.suffix.lower()
61
+ if suffix == ".pdf" and header.startswith(b"%PDF-"):
62
+ return "pdf", size
63
+ if suffix in _OFFICE_SUFFIXES and header.startswith(b"PK\x03\x04"):
64
+ try:
65
+ with zipfile.ZipFile(path) as archive:
66
+ infos = archive.infolist()
67
+ if len(infos) > 10_000 or sum(item.file_size for item in infos) > 200_000_000:
68
+ raise FingerprintError("Office package exceeds expansion limits")
69
+ names = set(archive.namelist())
70
+ required = "word/document.xml" if suffix == ".docx" else "ppt/presentation.xml"
71
+ if "[Content_Types].xml" not in names or required not in names:
72
+ raise FingerprintError("Office package is missing required parts")
73
+ if any(item.flag_bits & 1 for item in infos):
74
+ raise FingerprintError("Encrypted Office packages are unsupported")
75
+ if archive.testzip() is not None:
76
+ raise FingerprintError("Office package failed CRC validation")
77
+ except (OSError, zipfile.BadZipFile, RuntimeError, ValueError, EOFError) as exc:
78
+ raise FingerprintError(f"Invalid Office package: {exc}") from exc
79
+ return suffix[1:], size
80
+ if suffix in _IMAGE_SUFFIXES:
81
+ try:
82
+ with Image.open(path) as image:
83
+ image_format = image.format
84
+ if image_format not in _IMAGE_FORMATS:
85
+ raise FingerprintError("Unsupported image format")
86
+ image.verify()
87
+ except (OSError, UnidentifiedImageError, ValueError) as exc:
88
+ raise FingerprintError(f"Invalid image: {exc}") from exc
89
+ return image_format.lower(), size
90
+ raise FingerprintError("Unsupported format or file signature")
91
+
92
+
93
+ @contextlib.contextmanager
94
+ def pdf_path(
95
+ path: Path, source_format: str, config: FingerprintConfig, deadline: float
96
+ ) -> Iterator[Path]:
97
+ """Yield a PDF path, converting Office files in a temporary directory.
98
+
99
+ Args:
100
+ path: Original path.
101
+ source_format: Identified input format.
102
+ config: Converter path and timeout.
103
+ deadline: Cooperative document deadline.
104
+
105
+ Yields:
106
+ Original or temporary PDF path.
107
+
108
+ Raises:
109
+ FingerprintError: Conversion fails or produces invalid output.
110
+ """
111
+ if source_format == "pdf":
112
+ _remaining_seconds(deadline)
113
+ yield path
114
+ return
115
+ configured = config.office2pdf_path
116
+ executable = (
117
+ str(Path(configured).expanduser().resolve())
118
+ if configured and Path(configured).expanduser().is_file()
119
+ else shutil.which(configured or "office2pdf")
120
+ )
121
+ if not executable:
122
+ raise FingerprintError("office2pdf is required for DOCX and PPTX")
123
+ with tempfile.TemporaryDirectory(prefix="docfgp-office-") as directory:
124
+ output = Path(directory) / "converted.pdf"
125
+ error_path = Path(directory) / "converter.stderr"
126
+ try:
127
+ with error_path.open("wb") as errors:
128
+ result = subprocess.run(
129
+ [executable, str(path.resolve()), "-o", str(output)],
130
+ cwd=directory,
131
+ stdout=subprocess.DEVNULL,
132
+ stderr=errors,
133
+ timeout=min(config.process_timeout, _remaining_seconds(deadline)),
134
+ check=False,
135
+ )
136
+ except (OSError, subprocess.TimeoutExpired) as exc:
137
+ _remaining_seconds(deadline)
138
+ raise FingerprintError(f"office2pdf could not complete: {exc}") from exc
139
+ _remaining_seconds(deadline)
140
+ if result.returncode or not output.is_file():
141
+ with error_path.open("rb") as errors:
142
+ message = errors.read(500).decode("utf-8", "replace").strip()
143
+ raise FingerprintError(f"office2pdf failed: {message}")
144
+ with output.open("rb") as stream:
145
+ signature = stream.read(5)
146
+ if output.stat().st_size > config.max_file_bytes or signature != b"%PDF-":
147
+ raise FingerprintError("office2pdf produced an invalid or oversized PDF")
148
+ yield output
149
+
150
+
151
+ def _render(page: pdfium.PdfPage, dpi: int, config: FingerprintConfig) -> Image.Image:
152
+ width, height = page.get_size()
153
+ if not (math.isfinite(width) and math.isfinite(height) and width > 0 and height > 0):
154
+ raise FingerprintError("Invalid PDF page geometry")
155
+ scale = min(dpi / 72, math.sqrt(config.max_page_pixels / (width * height)))
156
+ if scale <= 0:
157
+ raise FingerprintError("PDF page exceeds rendering limits")
158
+ image = page.render(scale=scale).to_pil()
159
+ if image.width * image.height > config.max_page_pixels:
160
+ image.close()
161
+ raise FingerprintError("PDF page exceeds max_page_pixels")
162
+ return image
163
+
164
+
165
+ def _ocr_reason(page: pdfium.PdfPage, text: str) -> OCRReason | None:
166
+ if any(
167
+ character == "\ufffd"
168
+ or (unicodedata.category(character) == "Cc" and character not in "\r\n\t\f")
169
+ for character in text
170
+ ):
171
+ return "invalid_native_text"
172
+ if not word_tokens(strict_text(text)):
173
+ return "missing_native_text"
174
+ width, height = page.get_size()
175
+ page_area = width * height
176
+ if not math.isfinite(page_area) or page_area <= 0:
177
+ raise FingerprintError("Invalid PDF page geometry")
178
+ image_area = 0.0
179
+ for image in page.get_objects(filter=[pdfium.raw.FPDF_PAGEOBJ_IMAGE]):
180
+ left, bottom, right, top = image.get_bounds()
181
+ if not all(math.isfinite(value) for value in (left, bottom, right, top)):
182
+ continue
183
+ visible_width = max(0.0, min(right, width) - max(left, 0.0))
184
+ visible_height = max(0.0, min(top, height) - max(bottom, 0.0))
185
+ image_area += visible_width * visible_height
186
+ if image_area >= page_area * RASTER_OCR_COVERAGE:
187
+ return "rasterized_content"
188
+ return None
189
+
190
+
191
+ def pdf_pages(
192
+ path: Path, config: FingerprintConfig, deadline: float
193
+ ) -> Generator[ExtractedPage, None, None]:
194
+ """Yield native text plus visual and optional OCR render for each PDF page.
195
+
196
+ Args:
197
+ path: Input PDF.
198
+ config: Page, pixel, and resolution limits.
199
+ deadline: Cooperative document deadline.
200
+
201
+ Yields:
202
+ Page data with native text and bounded image renders.
203
+
204
+ Raises:
205
+ FingerprintError: PDF opening, page decoding, or limits fail.
206
+ """
207
+ try:
208
+ document = pdfium.PdfDocument(str(path))
209
+ except Exception as exc:
210
+ raise FingerprintError(f"Cannot open PDF: {exc}") from exc
211
+ try:
212
+ count = len(document)
213
+ if count == 0 or count > config.max_pages:
214
+ raise FingerprintError("PDF has no pages or exceeds max_pages")
215
+ for index in range(count):
216
+ _remaining_seconds(deadline)
217
+ try:
218
+ page = document[index]
219
+ except Exception as exc:
220
+ raise FingerprintError(f"Cannot open PDF page {index + 1}: {exc}") from exc
221
+ try:
222
+ textpage = page.get_textpage()
223
+ try:
224
+ text = textpage.get_text_range()
225
+ finally:
226
+ textpage.close()
227
+ reason = _ocr_reason(page, text)
228
+ visual = _render(page, config.render_dpi, config)
229
+ try:
230
+ ocr_image = _render(page, config.ocr_dpi, config) if reason else None
231
+ except Exception:
232
+ visual.close()
233
+ raise
234
+ yield ExtractedPage(index + 1, text, visual, ocr_image, reason)
235
+ except FingerprintError:
236
+ raise
237
+ except Exception as exc:
238
+ raise FingerprintError(f"Cannot process PDF page {index + 1}: {exc}") from exc
239
+ finally:
240
+ page.close()
241
+ finally:
242
+ document.close()
243
+
244
+
245
+ def image_pages(
246
+ path: Path, config: FingerprintConfig, deadline: float
247
+ ) -> Generator[ExtractedPage, None, None]:
248
+ """Yield decoded image frames within configured limits.
249
+
250
+ Args:
251
+ path: Standalone image path.
252
+ config: Frame and pixel limits.
253
+ deadline: Cooperative document deadline.
254
+
255
+ Yields:
256
+ One-based image frames with OCR provenance.
257
+
258
+ Raises:
259
+ FingerprintError: Decoding fails or limits are exceeded.
260
+ """
261
+ try:
262
+ with Image.open(path) as image:
263
+ frames = getattr(image, "n_frames", 1)
264
+ if frames > config.max_pages:
265
+ raise FingerprintError("Image exceeds max_pages")
266
+ for index in range(frames):
267
+ _remaining_seconds(deadline)
268
+ image.seek(index)
269
+ if image.width * image.height > config.max_page_pixels:
270
+ raise FingerprintError("Image frame exceeds max_page_pixels")
271
+ frame = image.copy()
272
+ yield ExtractedPage(index + 1, "", frame, frame, "image_input")
273
+ except (OSError, UnidentifiedImageError) as exc:
274
+ raise FingerprintError(f"Cannot decode image: {exc}") from exc
docfgp/fingerprint.py ADDED
@@ -0,0 +1,288 @@
1
+ """Coordinate one stateless document fingerprinting invocation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import time
7
+ from collections import Counter
8
+ from collections.abc import Iterable, Iterator
9
+ from contextlib import closing
10
+ from pathlib import Path
11
+
12
+ from . import extract, ocr
13
+ from .minhash import BANDS, COMPONENTS, ROWS, band_keys, sketch, stable_digest
14
+ from .normalize import strict_text, tolerant_text, word_tokens
15
+ from .schemas import (
16
+ DocumentFingerprint,
17
+ FingerprintConfig,
18
+ FingerprintError,
19
+ PageHash,
20
+ PageQuality,
21
+ PageSource,
22
+ Quality,
23
+ QualityStatus,
24
+ TextEvidence,
25
+ _remaining_seconds,
26
+ )
27
+ from .visual import page_phash, visual_keys
28
+ from .winnowing import anchors
29
+
30
+ SCHEMA_VERSION = "1.1"
31
+ ALGORITHM_VERSION = "1.3"
32
+ WORD_SHINGLE_SIZE = 5
33
+ CHAR_SHINGLE_SIZE = 13
34
+ ANCHOR_WINDOW = 32
35
+ OCR_MIN_CONFIDENCE = 30.0
36
+ OCR_REPLACEMENT_CONFIDENCE = 60.0
37
+
38
+
39
+ def _raw_hash(path: Path, deadline: float) -> str:
40
+ digest = hashlib.sha256()
41
+ try:
42
+ with path.open("rb") as stream:
43
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
44
+ _remaining_seconds(deadline)
45
+ digest.update(chunk)
46
+ except OSError as exc:
47
+ raise FingerprintError(f"Cannot hash document: {exc}") from exc
48
+ return digest.hexdigest()
49
+
50
+
51
+ def _shingles(tokens: tuple[str, ...]) -> Iterator[str]:
52
+ for index in range(len(tokens) - WORD_SHINGLE_SIZE + 1):
53
+ yield stable_digest("\x1f".join(tokens[index : index + WORD_SHINGLE_SIZE]).encode("utf-8"))
54
+
55
+
56
+ def _assemble_fingerprint(
57
+ text: str,
58
+ *,
59
+ config: FingerprintConfig,
60
+ deadline: float,
61
+ source_format: str,
62
+ byte_size: int,
63
+ raw_sha256: str,
64
+ page_quality: tuple[PageQuality, ...],
65
+ page_hashes: tuple[PageHash, ...] = (),
66
+ warnings: tuple[str, ...] = (),
67
+ ) -> DocumentFingerprint:
68
+ """Build shared text features and a versioned result from extracted content."""
69
+ tokens = word_tokens(text)
70
+ canonical = " ".join(tokens)
71
+ word_hashes: list[str] = []
72
+ counts: Counter[str] = Counter()
73
+ for value in _shingles(tokens):
74
+ if len(word_hashes) % 4096 == 0:
75
+ _remaining_seconds(deadline)
76
+ word_hashes.append(value)
77
+ counts[value] += 1
78
+ if len(counts) > config.max_features:
79
+ raise FingerprintError("Word shingles exceed max_features")
80
+ word_sketch = sketch(counts)
81
+ _remaining_seconds(deadline)
82
+ characters = tolerant_text(canonical)
83
+ char_features = set()
84
+ for index in range(len(characters) - CHAR_SHINGLE_SIZE + 1):
85
+ if index % 4096 == 0:
86
+ _remaining_seconds(deadline)
87
+ char_features.add(stable_digest(characters[index : index + CHAR_SHINGLE_SIZE].encode()))
88
+ if len(char_features) > config.max_features:
89
+ raise FingerprintError("Character shingles exceed max_features")
90
+ char_sketch = sketch(char_features)
91
+ _remaining_seconds(deadline)
92
+ passage_anchors = anchors(word_hashes, ANCHOR_WINDOW)
93
+ visual_lookup = tuple(sorted({key for page in page_hashes for key in page.lookup_keys}))
94
+ if word_sketch:
95
+ text_evidence: TextEvidence = "word_shingles"
96
+ elif char_sketch:
97
+ text_evidence = "character_shingles"
98
+ elif canonical:
99
+ text_evidence = "exact_only"
100
+ else:
101
+ text_evidence = "none"
102
+ if tokens and page_hashes:
103
+ status: QualityStatus = "text_and_visual"
104
+ elif tokens:
105
+ status = "text_only"
106
+ elif page_hashes:
107
+ status = "visual_only"
108
+ else:
109
+ status = "insufficient_evidence"
110
+ quality_warnings = list(warnings)
111
+ if status == "visual_only":
112
+ quality_warnings.append("No reliable text was extracted; textual matching is unavailable")
113
+ if status == "insufficient_evidence":
114
+ quality_warnings.append("Neither text nor nonblank visual evidence was extracted")
115
+ if text_evidence == "exact_only":
116
+ quality_warnings.append(
117
+ "Text supports exact matching only; no shingle sketch was available"
118
+ )
119
+ elif text_evidence == "character_shingles":
120
+ quality_warnings.append(
121
+ "Word-shingle matching is unavailable; character retrieval remains available"
122
+ )
123
+ return DocumentFingerprint(
124
+ schema_version=SCHEMA_VERSION,
125
+ algorithm_version=ALGORITHM_VERSION,
126
+ source_format=source_format,
127
+ byte_size=byte_size,
128
+ page_count=len(page_quality),
129
+ exact_hashes={
130
+ "raw_sha256": raw_sha256,
131
+ "canonical_sha256": stable_digest(canonical.encode()) if canonical else None,
132
+ },
133
+ lookup_keys={
134
+ "word_lsh": band_keys(word_sketch, "word"),
135
+ "char_lsh": band_keys(char_sketch, "char"),
136
+ "passage": tuple(f"passage:v1:{value}" for value in passage_anchors),
137
+ "visual": visual_lookup,
138
+ },
139
+ minhash=word_sketch,
140
+ char_minhash=char_sketch,
141
+ shingle_counts=tuple(sorted(counts.items())) if config.include_shingle_counts else (),
142
+ shingle_total=sum(counts.values()),
143
+ shingle_unique=len(counts),
144
+ passage_anchors=passage_anchors,
145
+ page_hashes=page_hashes,
146
+ quality=Quality(status, text_evidence, page_quality, tuple(quality_warnings)),
147
+ parameters={
148
+ "word_shingle_size": WORD_SHINGLE_SIZE,
149
+ "char_shingle_size": CHAR_SHINGLE_SIZE,
150
+ "minhash_components": COMPONENTS,
151
+ "lsh_bands": BANDS,
152
+ "lsh_rows": ROWS,
153
+ "anchor_window": ANCHOR_WINDOW,
154
+ "visual_hash": "phash64-dct32",
155
+ "render_dpi": config.render_dpi,
156
+ "ocr_dpi": config.ocr_dpi,
157
+ "ocr_language": config.ocr_language,
158
+ "ocr_min_confidence": OCR_MIN_CONFIDENCE,
159
+ "ocr_replacement_confidence": OCR_REPLACEMENT_CONFIDENCE,
160
+ "raster_ocr_coverage": extract.RASTER_OCR_COVERAGE,
161
+ "shingle_counts_included": config.include_shingle_counts,
162
+ },
163
+ )
164
+
165
+
166
+ class Fingerprinter:
167
+ """Generate stateless document fingerprints.
168
+
169
+ Args:
170
+ config: Optional bounded processing settings.
171
+ """
172
+
173
+ def __init__(self, config: FingerprintConfig | None = None) -> None:
174
+ """Store immutable processing limits.
175
+
176
+ Args:
177
+ config: Optional processing settings.
178
+ """
179
+ self.config = config or FingerprintConfig()
180
+
181
+ def fingerprint(self, path: str | Path) -> DocumentFingerprint:
182
+ """Create a document's textual and visual fingerprint.
183
+
184
+ Args:
185
+ path: PDF, DOCX, PPTX, or supported image path.
186
+
187
+ Returns:
188
+ Versioned fingerprint.
189
+
190
+ Raises:
191
+ FingerprintError: Input is invalid, limits are exceeded, or processing fails.
192
+ """
193
+ deadline = time.monotonic() + self.config.max_document_seconds
194
+ source = Path(path).expanduser()
195
+ source_format, byte_size = extract.identify(source, self.config)
196
+ _remaining_seconds(deadline)
197
+ raw_sha256 = _raw_hash(source, deadline)
198
+ page_text: list[str] = []
199
+ page_hashes: list[PageHash] = []
200
+ page_quality: list[PageQuality] = []
201
+ warnings: list[str] = []
202
+ total_text_chars = 0
203
+
204
+ def consume(
205
+ pages: Iterable[extract.ExtractedPage],
206
+ ) -> None:
207
+ nonlocal total_text_chars
208
+ for page in pages:
209
+ page_number = page.number
210
+ visual = page.visual
211
+ ocr_image = page.ocr_image
212
+ try:
213
+ _remaining_seconds(deadline)
214
+ native = strict_text(page.native_text)
215
+ extracted = "" if page.ocr_reason == "invalid_native_text" else native
216
+ source: PageSource = "native" if extracted else "none"
217
+ attempted = ocr_image is not None
218
+ warning = None
219
+ confidence = None
220
+ if ocr_image is not None:
221
+ try:
222
+ result = ocr.read_text(ocr_image, self.config, deadline)
223
+ confidence = result.confidence
224
+ recognized = strict_text(result.text)
225
+ if not recognized or confidence is None:
226
+ warning = "OCR found no reliable text"
227
+ elif page.ocr_reason == "rasterized_content":
228
+ novel = set(word_tokens(recognized)) - set(word_tokens(native))
229
+ if confidence >= OCR_REPLACEMENT_CONFIDENCE and len(novel) >= 3:
230
+ extracted = recognized
231
+ source = "ocr"
232
+ elif novel:
233
+ warning = "OCR did not provide confident additional text"
234
+ elif confidence >= OCR_MIN_CONFIDENCE:
235
+ extracted = recognized
236
+ source = "ocr"
237
+ else:
238
+ warning = "OCR confidence was too low to use its text"
239
+ except FingerprintError as exc:
240
+ warning = str(exc)
241
+ if warning:
242
+ warnings.append(f"page {page_number}: {warning}")
243
+ page_text.append(extracted)
244
+ total_text_chars += len(extracted)
245
+ if total_text_chars > self.config.max_text_chars:
246
+ raise FingerprintError("Extracted text exceeds max_text_chars")
247
+ value = page_phash(visual)
248
+ if value is not None:
249
+ page_hashes.append(PageHash(page_number, value, visual_keys(value)))
250
+ page_quality.append(
251
+ PageQuality(
252
+ page=page_number,
253
+ source=source,
254
+ text_chars=len(extracted),
255
+ native_text_chars=len(native),
256
+ ocr_attempted=attempted,
257
+ ocr_reason=page.ocr_reason,
258
+ ocr_confidence=confidence,
259
+ warning=warning,
260
+ )
261
+ )
262
+ _remaining_seconds(deadline)
263
+ except (OSError, ValueError) as exc:
264
+ raise FingerprintError(f"Cannot process page {page_number}: {exc}") from exc
265
+ finally:
266
+ visual.close()
267
+ if ocr_image is not None and ocr_image is not visual:
268
+ ocr_image.close()
269
+
270
+ if source_format not in {"pdf", "docx", "pptx"}:
271
+ with closing(extract.image_pages(source, self.config, deadline)) as image_frames:
272
+ consume(image_frames)
273
+ else:
274
+ with extract.pdf_path(source, source_format, self.config, deadline) as pdf:
275
+ with closing(extract.pdf_pages(pdf, self.config, deadline)) as pdf_frames:
276
+ consume(pdf_frames)
277
+
278
+ return _assemble_fingerprint(
279
+ " ".join(page_text),
280
+ config=self.config,
281
+ deadline=deadline,
282
+ source_format=source_format,
283
+ byte_size=byte_size,
284
+ raw_sha256=raw_sha256,
285
+ page_quality=tuple(page_quality),
286
+ page_hashes=tuple(page_hashes),
287
+ warnings=tuple(warnings),
288
+ )
docfgp/minhash.py ADDED
@@ -0,0 +1,81 @@
1
+ """Stable 512-permutation MinHash and indexed LSH keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from collections.abc import Iterable
7
+
8
+ COMPONENTS = 512
9
+ BANDS = 32
10
+ ROWS = 16
11
+ _PRIME = (1 << 61) - 1
12
+
13
+
14
+ def _coefficients(index: int) -> tuple[int, int]:
15
+ # Derive every permutation from bytes, independent of Python's random module.
16
+ digest = hashlib.sha256(b"docfgp-minhash-v2" + index.to_bytes(2, "big")).digest()
17
+ return int.from_bytes(digest[:8], "big") % (_PRIME - 1) + 1, int.from_bytes(
18
+ digest[8:16], "big"
19
+ ) % _PRIME
20
+
21
+
22
+ _COEFFICIENTS = tuple(_coefficients(index) for index in range(COMPONENTS))
23
+
24
+
25
+ def stable_digest(data: bytes) -> str:
26
+ """Return a stable SHA-256 digest.
27
+
28
+ Args:
29
+ data: Input bytes.
30
+
31
+ Returns:
32
+ Lowercase hexadecimal digest.
33
+ """
34
+ return hashlib.sha256(data).hexdigest()
35
+
36
+
37
+ def sketch(features: Iterable[str]) -> tuple[str, ...]:
38
+ """Compute a deterministic, 512-component MinHash over unique features.
39
+
40
+ Args:
41
+ features: Textual shingles or their stable hexadecimal digests.
42
+
43
+ Returns:
44
+ Fixed-width hexadecimal values, or an empty tuple without features.
45
+ """
46
+ minima = [_PRIME] * COMPONENTS
47
+ seen: set[str] = set()
48
+ for feature in features:
49
+ if feature in seen:
50
+ continue
51
+ seen.add(feature)
52
+ x = int.from_bytes(hashlib.sha256(feature.encode("utf-8")).digest()[:8], "big") % _PRIME
53
+ for index, (a, b) in enumerate(_COEFFICIENTS):
54
+ value = (a * x + b) % _PRIME
55
+ if value < minima[index]:
56
+ minima[index] = value
57
+ return tuple(f"{value:016x}" for value in minima) if seen else ()
58
+
59
+
60
+ def band_keys(values: tuple[str, ...], namespace: str) -> tuple[str, ...]:
61
+ """Build 32 stable keys from consecutive groups of 16 components.
62
+
63
+ Args:
64
+ values: A complete MinHash sketch.
65
+ namespace: Word or character sketch identifier.
66
+
67
+ Returns:
68
+ Indexed keys, or no keys for an empty sketch.
69
+
70
+ Raises:
71
+ ValueError: The sketch has the wrong length.
72
+ """
73
+ if not values:
74
+ return ()
75
+ if len(values) != COMPONENTS:
76
+ raise ValueError("A banded sketch must contain 512 components")
77
+ keys = []
78
+ for band in range(BANDS):
79
+ data = "".join(values[band * ROWS : (band + 1) * ROWS]).encode()
80
+ keys.append(f"{namespace}:v2:{band:02d}:{stable_digest(data)[:24]}")
81
+ return tuple(keys)
docfgp/normalize.py ADDED
@@ -0,0 +1,49 @@
1
+ """Canonicalize extracted text while retaining significant numbers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import unicodedata
7
+
8
+ _HYPHENATION = re.compile(r"(?<=\w)[-\u00ad][ \t]*\r?\n[ \t]*(?=\w)")
9
+ _WHITESPACE = re.compile(r"\s+")
10
+ _WORDS = re.compile(r"[^\W_]+(?:['’][^\W_]+)?", re.UNICODE)
11
+
12
+
13
+ def strict_text(text: str) -> str:
14
+ """Normalize Unicode, soft line breaks, and whitespace.
15
+
16
+ Args:
17
+ text: Raw extracted page text.
18
+
19
+ Returns:
20
+ Canonical casefolded text.
21
+ """
22
+ text = unicodedata.normalize("NFKC", text)
23
+ text = _HYPHENATION.sub("", text)
24
+ text = text.replace("\u00ad", "")
25
+ return _WHITESPACE.sub(" ", text).strip().casefold()
26
+
27
+
28
+ def word_tokens(text: str) -> tuple[str, ...]:
29
+ """Extract Unicode word tokens from normalized text.
30
+
31
+ Args:
32
+ text: Canonical text.
33
+
34
+ Returns:
35
+ Tokens in reading order.
36
+ """
37
+ return tuple(_WORDS.findall(text))
38
+
39
+
40
+ def tolerant_text(text: str) -> str:
41
+ """Remove punctuation and spacing for OCR-tolerant character retrieval.
42
+
43
+ Args:
44
+ text: Canonical text.
45
+
46
+ Returns:
47
+ Alphanumeric Unicode characters, including digits.
48
+ """
49
+ return "".join(ch for ch in text if ch.isalnum())
docfgp/ocr.py ADDED
@@ -0,0 +1,111 @@
1
+ """Run bounded Tesseract OCR and retain word confidence evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import math
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import tempfile
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from PIL import Image
15
+
16
+ from .schemas import FingerprintConfig, FingerprintError, _remaining_seconds
17
+ from .visual import normalized_grayscale
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class OCRResult:
22
+ """Hold recognized text and mean confidence over recognized words."""
23
+
24
+ text: str
25
+ confidence: float | None
26
+
27
+
28
+ def read_text(image: Image.Image, config: FingerprintConfig, deadline: float) -> OCRResult:
29
+ """Extract text from a rendered page in an isolated temporary directory.
30
+
31
+ Args:
32
+ image: Page image within configured pixel limits.
33
+ config: OCR executable, language, and timeout settings.
34
+ deadline: Cooperative document deadline.
35
+
36
+ Returns:
37
+ Text and Tesseract word confidence from the same OCR invocation.
38
+
39
+ Raises:
40
+ FingerprintError: Tesseract is missing, times out, or fails.
41
+ """
42
+ configured = config.tesseract_path
43
+ executable = (
44
+ str(Path(configured).expanduser().resolve())
45
+ if configured and Path(configured).expanduser().is_file()
46
+ else shutil.which(configured or "tesseract")
47
+ )
48
+ if not executable:
49
+ raise FingerprintError("Tesseract 5 is not available")
50
+ with tempfile.TemporaryDirectory(prefix="docfgp-ocr-") as directory:
51
+ input_path = Path(directory) / "page.png"
52
+ output_base = Path(directory) / "result"
53
+ output_path = output_base.with_suffix(".txt")
54
+ confidence_path = output_base.with_suffix(".tsv")
55
+ error_path = Path(directory) / "ocr.stderr"
56
+ with normalized_grayscale(image) as prepared:
57
+ prepared.save(input_path)
58
+ command = [
59
+ executable,
60
+ str(input_path),
61
+ str(output_base),
62
+ "-l",
63
+ config.ocr_language,
64
+ "--psm",
65
+ "3",
66
+ "txt",
67
+ "tsv",
68
+ ]
69
+ try:
70
+ with error_path.open("wb") as errors:
71
+ result = subprocess.run(
72
+ command,
73
+ cwd=directory,
74
+ stdout=subprocess.DEVNULL,
75
+ stderr=errors,
76
+ timeout=min(config.process_timeout, _remaining_seconds(deadline)),
77
+ check=False,
78
+ env={**os.environ, "OMP_THREAD_LIMIT": "1"},
79
+ )
80
+ except (OSError, subprocess.TimeoutExpired) as exc:
81
+ _remaining_seconds(deadline)
82
+ raise FingerprintError(f"Tesseract could not complete: {exc}") from exc
83
+ _remaining_seconds(deadline)
84
+ if result.returncode:
85
+ with error_path.open("rb") as errors:
86
+ message = errors.read(500).decode("utf-8", "replace").strip()
87
+ raise FingerprintError(f"Tesseract failed: {message}")
88
+ if not output_path.is_file() or output_path.stat().st_size > config.max_text_chars * 4:
89
+ raise FingerprintError("Tesseract produced missing or oversized text")
90
+ if (
91
+ not confidence_path.is_file()
92
+ or confidence_path.stat().st_size > config.max_text_chars * 16
93
+ ):
94
+ raise FingerprintError("Tesseract produced missing or oversized confidence data")
95
+ total_confidence = 0.0
96
+ word_count = 0
97
+ try:
98
+ with confidence_path.open(encoding="utf-8", errors="replace", newline="") as stream:
99
+ for row in csv.DictReader(stream, delimiter="\t"):
100
+ if row["level"] != "5" or not row["text"].strip():
101
+ continue
102
+ confidence = float(row["conf"])
103
+ if math.isfinite(confidence) and confidence >= 0:
104
+ total_confidence += confidence
105
+ word_count += 1
106
+ except (OSError, KeyError, ValueError, TypeError, AttributeError) as exc:
107
+ raise FingerprintError(f"Cannot read Tesseract confidence data: {exc}") from exc
108
+ return OCRResult(
109
+ output_path.read_text(encoding="utf-8", errors="replace"),
110
+ round(total_confidence / word_count, 2) if word_count else None,
111
+ )
docfgp/py.typed ADDED
File without changes
docfgp/schemas.py ADDED
@@ -0,0 +1,222 @@
1
+ """Versioned public schemas and processing limits for document fingerprints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import time
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from types import MappingProxyType
12
+ from typing import Any, Literal
13
+
14
+ QualityStatus = Literal["text_and_visual", "text_only", "visual_only", "insufficient_evidence"]
15
+ TextEvidence = Literal["word_shingles", "character_shingles", "exact_only", "none"]
16
+ PageSource = Literal["native", "ocr", "none"]
17
+ OCRReason = Literal[
18
+ "missing_native_text", "invalid_native_text", "rasterized_content", "image_input"
19
+ ]
20
+
21
+
22
+ class FingerprintError(Exception):
23
+ """Report invalid input, unavailable dependencies, or incomplete processing."""
24
+
25
+
26
+ def _remaining_seconds(deadline: float) -> float:
27
+ remaining = deadline - time.monotonic()
28
+ if remaining <= 0:
29
+ raise FingerprintError("Document exceeded max_document_seconds")
30
+ return remaining
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class FingerprintConfig:
35
+ """Configure bounded document processing.
36
+
37
+ Attributes:
38
+ max_file_bytes: Maximum original input size.
39
+ max_pages: Maximum PDF pages or image frames.
40
+ max_page_pixels: Maximum pixels decoded for one page or image.
41
+ max_text_chars: Maximum total extracted characters.
42
+ max_features: Maximum distinct word or character shingles.
43
+ render_dpi: Visual rendering resolution, bounded by max_page_pixels.
44
+ ocr_dpi: OCR rendering resolution, bounded by max_page_pixels.
45
+ process_timeout: Seconds allowed for each external process.
46
+ max_document_seconds: Cooperative deadline checked between processing stages.
47
+ office2pdf_path: Optional explicit converter executable.
48
+ tesseract_path: Optional explicit OCR executable.
49
+ ocr_language: Installed Tesseract language codes.
50
+ include_shingle_counts: Include sorted exact occurrence counts.
51
+ """
52
+
53
+ max_file_bytes: int = 50_000_000
54
+ max_pages: int = 200
55
+ max_page_pixels: int = 8_000_000
56
+ max_text_chars: int = 2_000_000
57
+ max_features: int = 200_000
58
+ render_dpi: int = 96
59
+ ocr_dpi: int = 180
60
+ process_timeout: float = 60.0
61
+ max_document_seconds: float = 180.0
62
+ office2pdf_path: str | Path | None = None
63
+ tesseract_path: str | Path | None = None
64
+ ocr_language: str = "eng"
65
+ include_shingle_counts: bool = True
66
+
67
+ def __post_init__(self) -> None:
68
+ """Reject limits that would make bounded processing impossible.
69
+
70
+ Raises:
71
+ ValueError: A limit or resolution is invalid.
72
+ """
73
+ values = (
74
+ self.max_file_bytes,
75
+ self.max_pages,
76
+ self.max_page_pixels,
77
+ self.max_text_chars,
78
+ self.max_features,
79
+ self.render_dpi,
80
+ self.ocr_dpi,
81
+ )
82
+ durations = (self.process_timeout, self.max_document_seconds)
83
+ if any(
84
+ not isinstance(value, int) or isinstance(value, bool) or value <= 0 for value in values
85
+ ):
86
+ raise ValueError("All integer processing limits must be positive")
87
+ if any(
88
+ isinstance(value, bool)
89
+ or not isinstance(value, (int, float))
90
+ or not math.isfinite(value)
91
+ or value <= 0
92
+ for value in durations
93
+ ):
94
+ raise ValueError("All processing limits must be positive")
95
+ if self.render_dpi > 300 or self.ocr_dpi > 300:
96
+ raise ValueError("Rendering resolution cannot exceed 300 DPI")
97
+ if not isinstance(self.ocr_language, str) or not self.ocr_language.strip():
98
+ raise ValueError("ocr_language must not be empty")
99
+ for name in ("office2pdf_path", "tesseract_path"):
100
+ value = getattr(self, name)
101
+ if value is not None:
102
+ if not isinstance(value, (str, Path)):
103
+ raise ValueError(f"{name} must be a path")
104
+ object.__setattr__(self, name, str(value))
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class PageQuality:
109
+ """Record extraction provenance for one page or image frame."""
110
+
111
+ page: int
112
+ source: PageSource
113
+ text_chars: int
114
+ native_text_chars: int
115
+ ocr_attempted: bool
116
+ ocr_reason: OCRReason | None = None
117
+ ocr_confidence: float | None = None
118
+ warning: str | None = None
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class Quality:
123
+ """State whether the fingerprint carries text, visual, or insufficient evidence."""
124
+
125
+ status: QualityStatus
126
+ text_evidence: TextEvidence
127
+ pages: tuple[PageQuality, ...]
128
+ warnings: tuple[str, ...] = ()
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class PageHash:
133
+ """Store a page perceptual hash and its indexed visual keys."""
134
+
135
+ page: int
136
+ phash: str
137
+ lookup_keys: tuple[str, ...]
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class DocumentFingerprint:
142
+ """Serializable, versioned document fingerprint."""
143
+
144
+ schema_version: str
145
+ algorithm_version: str
146
+ source_format: str
147
+ byte_size: int
148
+ page_count: int
149
+ exact_hashes: Mapping[str, str | None]
150
+ lookup_keys: Mapping[str, tuple[str, ...]]
151
+ minhash: tuple[str, ...]
152
+ char_minhash: tuple[str, ...]
153
+ shingle_counts: tuple[tuple[str, int], ...]
154
+ shingle_total: int
155
+ shingle_unique: int
156
+ passage_anchors: tuple[str, ...]
157
+ page_hashes: tuple[PageHash, ...]
158
+ quality: Quality
159
+ parameters: Mapping[str, int | float | str | bool] = field(default_factory=dict)
160
+
161
+ def __post_init__(self) -> None:
162
+ """Freeze mapping fields so the fingerprint cannot change after creation."""
163
+ object.__setattr__(self, "exact_hashes", MappingProxyType(dict(self.exact_hashes)))
164
+ object.__setattr__(
165
+ self,
166
+ "lookup_keys",
167
+ MappingProxyType({name: tuple(keys) for name, keys in self.lookup_keys.items()}),
168
+ )
169
+ object.__setattr__(self, "parameters", MappingProxyType(dict(self.parameters)))
170
+
171
+ def to_dict(self) -> dict[str, Any]:
172
+ """Return a JSON-compatible representation.
173
+
174
+ Returns:
175
+ Recursively serialized fingerprint with stable field order.
176
+ """
177
+ return {
178
+ "schema_version": self.schema_version,
179
+ "algorithm_version": self.algorithm_version,
180
+ "source_format": self.source_format,
181
+ "byte_size": self.byte_size,
182
+ "page_count": self.page_count,
183
+ "exact_hashes": dict(self.exact_hashes),
184
+ "lookup_keys": {name: list(keys) for name, keys in self.lookup_keys.items()},
185
+ "minhash": list(self.minhash),
186
+ "char_minhash": list(self.char_minhash),
187
+ "shingle_counts": [[value, count] for value, count in self.shingle_counts],
188
+ "shingle_total": self.shingle_total,
189
+ "shingle_unique": self.shingle_unique,
190
+ "passage_anchors": list(self.passage_anchors),
191
+ "page_hashes": [
192
+ {"page": item.page, "phash": item.phash, "lookup_keys": list(item.lookup_keys)}
193
+ for item in self.page_hashes
194
+ ],
195
+ "quality": {
196
+ "status": self.quality.status,
197
+ "text_evidence": self.quality.text_evidence,
198
+ "pages": [
199
+ {
200
+ "page": page.page,
201
+ "source": page.source,
202
+ "text_chars": page.text_chars,
203
+ "native_text_chars": page.native_text_chars,
204
+ "ocr_attempted": page.ocr_attempted,
205
+ "ocr_reason": page.ocr_reason,
206
+ "ocr_confidence": page.ocr_confidence,
207
+ "warning": page.warning,
208
+ }
209
+ for page in self.quality.pages
210
+ ],
211
+ "warnings": list(self.quality.warnings),
212
+ },
213
+ "parameters": dict(self.parameters),
214
+ }
215
+
216
+ def to_json(self) -> str:
217
+ """Serialize the fingerprint as compact JSON.
218
+
219
+ Returns:
220
+ JSON string.
221
+ """
222
+ return json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"))
docfgp/visual.py ADDED
@@ -0,0 +1,72 @@
1
+ """Generate CPU perceptual hashes and indexed keys for page images."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import statistics
7
+ from typing import cast
8
+
9
+ from PIL import Image, ImageOps
10
+
11
+ _COSINES = tuple(
12
+ tuple(math.cos(math.pi * (2 * x + 1) * frequency / 64) for x in range(32))
13
+ for frequency in range(8)
14
+ )
15
+
16
+
17
+ def normalized_grayscale(image: Image.Image) -> Image.Image:
18
+ """Apply orientation and transparency before visual analysis.
19
+
20
+ Args:
21
+ image: One decoded page or image frame.
22
+
23
+ Returns:
24
+ Grayscale image with transparent pixels composited on white.
25
+ """
26
+ with ImageOps.exif_transpose(image) as oriented:
27
+ if oriented.mode in {"RGBA", "LA"} or "transparency" in oriented.info:
28
+ with oriented.convert("RGBA") as rgba, Image.new("RGBA", rgba.size, "white") as white:
29
+ with Image.alpha_composite(white, rgba) as composited:
30
+ return composited.convert("L")
31
+ return oriented.convert("L")
32
+
33
+
34
+ def page_phash(image: Image.Image) -> str | None:
35
+ """Hash a nonblank page after consistent visual normalization.
36
+
37
+ Args:
38
+ image: One decoded page or image frame.
39
+
40
+ Returns:
41
+ A 64-bit hash as 16 hexadecimal digits, or None for a blank page.
42
+ """
43
+ with normalized_grayscale(image) as grayscale:
44
+ low, high = cast(tuple[int, int], grayscale.getextrema())
45
+ if high - low < 5:
46
+ return None
47
+ with grayscale.resize((32, 32), Image.Resampling.LANCZOS) as reduced:
48
+ pixels = list(reduced.tobytes())
49
+ rows = [pixels[offset : offset + 32] for offset in range(0, 1024, 32)]
50
+ horizontal = [
51
+ [sum(row[x] * _COSINES[u][x] for x in range(32)) for u in range(8)] for row in rows
52
+ ]
53
+ coefficients = [
54
+ sum(horizontal[y][u] * _COSINES[v][y] for y in range(32))
55
+ for v in range(8)
56
+ for u in range(8)
57
+ ]
58
+ median = statistics.median(coefficients[1:])
59
+ bits = sum(1 << index for index, value in enumerate(coefficients) if value > median)
60
+ return f"{bits:016x}"
61
+
62
+
63
+ def visual_keys(value: str) -> tuple[str, ...]:
64
+ """Split a 64-bit page hash into four indexed chunks.
65
+
66
+ Args:
67
+ value: Page hash returned by `page_phash`.
68
+
69
+ Returns:
70
+ Four versioned lookup keys.
71
+ """
72
+ return tuple(f"visual:v1:{index}:{value[index * 4 : (index + 1) * 4]}" for index in range(4))
docfgp/winnowing.py ADDED
@@ -0,0 +1,32 @@
1
+ """Select deterministic rightmost-minimum passage anchors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from collections.abc import Sequence
7
+
8
+
9
+ def anchors(hashes: Sequence[str], window: int = 32) -> tuple[str, ...]:
10
+ """Winnow ordered shingle hashes with a rightmost minimum tie rule.
11
+
12
+ Args:
13
+ hashes: SHA-256 word-shingle hashes in document order.
14
+ window: Number of consecutive hashes per selection window.
15
+
16
+ Returns:
17
+ Distinct anchor hashes in first occurrence order.
18
+ """
19
+ if not hashes:
20
+ return ()
21
+ window = max(1, window)
22
+ queue: deque[int] = deque()
23
+ selected: dict[str, None] = {}
24
+ for index, value in enumerate(hashes):
25
+ while queue and hashes[queue[-1]] >= value:
26
+ queue.pop()
27
+ queue.append(index)
28
+ while queue and queue[0] <= index - window:
29
+ queue.popleft()
30
+ if index >= min(window, len(hashes)) - 1:
31
+ selected.setdefault(hashes[queue[0]], None)
32
+ return tuple(selected)
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.5
2
+ Name: docfgp
3
+ Version: 0.1.0
4
+ Summary: Generate deterministic fingerprints for documents
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Keywords: document,fingerprint,minhash,pdf
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: pillow<14,>=10
12
+ Requires-Dist: pypdfium2<6,>=4.30
13
+ Provides-Extra: benchmark
14
+ Requires-Dist: datasets<5,>=3; extra == 'benchmark'
15
+ Requires-Dist: psutil>=6; extra == 'benchmark'
16
+ Provides-Extra: dev
17
+ Requires-Dist: build>=1.2; extra == 'dev'
18
+ Requires-Dist: mypy>=1.15; extra == 'dev'
19
+ Requires-Dist: psutil>=6; extra == 'dev'
20
+ Requires-Dist: pytest>=8; extra == 'dev'
21
+ Requires-Dist: reportlab>=4; extra == 'dev'
22
+ Requires-Dist: ruff>=0.11; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # DocFGP
26
+
27
+ [![PyPI version](https://img.shields.io/pypi/v/docfgp)](https://pypi.org/project/docfgp/)
28
+ [![PyPI downloads](https://img.shields.io/pepy/dt/docfgp)](https://pepy.tech/projects/docfgp)
29
+ [![CI](https://github.com/iamzices/docfgp-document-fingerprint/actions/workflows/ci.yml/badge.svg)](https://github.com/iamzices/docfgp-document-fingerprint/actions/workflows/ci.yml)
30
+ [![License: MIT](https://img.shields.io/github/license/iamzices/docfgp-document-fingerprint)](LICENSE)
31
+
32
+ DocFGP creates text and visual fingerprints for finding duplicates, near-duplicates, and shared passages across document formats.
33
+
34
+ ## Features
35
+
36
+ - PDF, DOCX, PPTX, PNG, JPEG, TIFF, BMP, and WebP support
37
+ - Exact, near-duplicate, and partial-match
38
+ - OCR and visual fingerprints for scanned documents
39
+
40
+ ## Requirements
41
+
42
+ - Python 3.11+
43
+ - [office2pdf](https://github.com/developer0hye/office2pdf)
44
+ - [Tesseract 5](https://github.com/tesseract-ocr/tesseract)
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ pip install docfgp
50
+ ```
51
+
52
+ ## Python API
53
+
54
+ ```python
55
+ from docfgp import Fingerprinter
56
+
57
+ fingerprint = Fingerprinter().fingerprint("report.pdf")
58
+ record = fingerprint.to_dict()
59
+ ```
60
+
61
+ | Field | Description |
62
+ | --- | --- |
63
+ | `schema_version` | Fingerprint data format version |
64
+ | `algorithm_version` | Feature-generation version |
65
+ | `source_format` | Detected input format |
66
+ | `byte_size` | Original file size in bytes |
67
+ | `page_count` | Number of pages or image frames |
68
+ | `exact_hashes` | Raw and canonical-text SHA-256 hashes |
69
+ | `lookup_keys` | Word, character, passage, and visual candidate keys |
70
+ | `minhash` | Word-shingle MinHash sketch |
71
+ | `char_minhash` | Character-shingle MinHash sketch |
72
+ | `shingle_counts` | Hashed five-word shingle occurrence counts |
73
+ | `shingle_total` | Total five-word shingle occurrences |
74
+ | `shingle_unique` | Distinct five-word shingle hashes |
75
+ | `passage_anchors` | Selected passage fingerprints |
76
+ | `page_hashes` | Per-page visual hashes and lookup keys |
77
+ | `quality` | Evidence status, page provenance, and warnings |
78
+ | `parameters` | Parameters used to construct the fingerprint |
79
+
80
+ ## CLI
81
+
82
+ ```bash
83
+ docfgp report.pdf > report.fingerprint.json
84
+ ```
@@ -0,0 +1,16 @@
1
+ docfgp/__init__.py,sha256=TO51MIL6EqOStlTB8HgDvykcWYmVAYe-oLer5RMMCWE,261
2
+ docfgp/cli.py,sha256=lydu7_tZ0igU0Wl74AZK6k1CVTdmVP2K11kjgHwiDm4,1691
3
+ docfgp/extract.py,sha256=DUjIEqgvWqUaYyXakltbOhE0dGu-U6L6JHB2IP8gGlA,10777
4
+ docfgp/fingerprint.py,sha256=BAdjlZEsX-YLbKZsScggvY9Xa9NjjDDk5NBW2ZAWerM,11507
5
+ docfgp/minhash.py,sha256=WbrsVqtHoThlzam6Ppi0SgyqVMH99oGt4Q1XxX6S7p4,2432
6
+ docfgp/normalize.py,sha256=F_hV5UChAuaymiv2LplutfjCd3wiXXMk3NiaHuncW9A,1219
7
+ docfgp/ocr.py,sha256=l_ZwQUfgORTWG6Pk_7KGtky5JfBTeFmb4vixkyfoRrw,4294
8
+ docfgp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ docfgp/schemas.py,sha256=MM_bKuHHx5KYFN_h-j9fmcT7cVNSjCxgDXwWpZPNVNw,8108
10
+ docfgp/visual.py,sha256=5iw744jE2T2fed5zCYgMSs-dlIt8hhbJtvk5p39RksA,2386
11
+ docfgp/winnowing.py,sha256=AsldFZJ-r4SXNVEQoD8xZjwksVyIH8F9hFvNTk__4sw,1026
12
+ docfgp-0.1.0.dist-info/METADATA,sha256=orXHQeA3xWSmrrse31Wj2uatGJNBQnnmEceKzI-1Z6Q,2952
13
+ docfgp-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
14
+ docfgp-0.1.0.dist-info/entry_points.txt,sha256=LmR7UIjgOuj_WM7dabj1SjS9h3OwEPCHC4HdlAKAum8,43
15
+ docfgp-0.1.0.dist-info/licenses/LICENSE,sha256=0XIJv9rwK4ANSjTRJwHQixCR2c68CnPRC6i-horntwI,1062
16
+ docfgp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ docfgp = docfgp.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zices
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.