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 +6 -0
- docfgp/cli.py +49 -0
- docfgp/extract.py +274 -0
- docfgp/fingerprint.py +288 -0
- docfgp/minhash.py +81 -0
- docfgp/normalize.py +49 -0
- docfgp/ocr.py +111 -0
- docfgp/py.typed +0 -0
- docfgp/schemas.py +222 -0
- docfgp/visual.py +72 -0
- docfgp/winnowing.py +32 -0
- docfgp-0.1.0.dist-info/METADATA +84 -0
- docfgp-0.1.0.dist-info/RECORD +16 -0
- docfgp-0.1.0.dist-info/WHEEL +4 -0
- docfgp-0.1.0.dist-info/entry_points.txt +2 -0
- docfgp-0.1.0.dist-info/licenses/LICENSE +21 -0
docfgp/__init__.py
ADDED
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
|
+
[](https://pypi.org/project/docfgp/)
|
|
28
|
+
[](https://pepy.tech/projects/docfgp)
|
|
29
|
+
[](https://github.com/iamzices/docfgp-document-fingerprint/actions/workflows/ci.yml)
|
|
30
|
+
[](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,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.
|