likhit 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.
- likhit/__init__.py +6 -0
- likhit/cli.py +84 -0
- likhit/core.py +126 -0
- likhit/errors.py +13 -0
- likhit/extractors/__init__.py +5 -0
- likhit/extractors/base.py +45 -0
- likhit/extractors/font_based.py +261 -0
- likhit/extractors/font_classifier.py +50 -0
- likhit/extractors/kalimati.py +501 -0
- likhit/extractors/legacy_maps.py +68 -0
- likhit/extractors/tables.py +291 -0
- likhit/handlers/__init__.py +6 -0
- likhit/handlers/base.py +22 -0
- likhit/handlers/ciaa_press_release.py +363 -0
- likhit/handlers/content_blocks.py +143 -0
- likhit/handlers/kanun_patrika.py +201 -0
- likhit/markdown_assembly.py +94 -0
- likhit/markitdown_integration.py +94 -0
- likhit/models/__init__.py +25 -0
- likhit/models/repair_types.py +19 -0
- likhit/models/types.py +132 -0
- likhit/nepali_pdf_repair.py +324 -0
- likhit/renderers/__init__.py +5 -0
- likhit/renderers/base.py +15 -0
- likhit/renderers/markdown.py +649 -0
- likhit/utils/__init__.py +0 -0
- likhit/version.py +3 -0
- likhit-0.1.0.dist-info/METADATA +104 -0
- likhit-0.1.0.dist-info/RECORD +31 -0
- likhit-0.1.0.dist-info/WHEEL +4 -0
- likhit-0.1.0.dist-info/entry_points.txt +3 -0
likhit/__init__.py
ADDED
likhit/cli.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""CLI entry point for conversion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from likhit.core import convert_many, derive_convert_output_name
|
|
10
|
+
from likhit.errors import LikhitError, ValidationError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="likhit",
|
|
16
|
+
description="Convert born-digital PDFs to editable Markdown.",
|
|
17
|
+
)
|
|
18
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
19
|
+
|
|
20
|
+
convert_parser = subparsers.add_parser(
|
|
21
|
+
"convert",
|
|
22
|
+
help="Convert PDFs to editable Markdown",
|
|
23
|
+
)
|
|
24
|
+
convert_parser.add_argument("inputs", nargs="+", help="One or more input PDF files")
|
|
25
|
+
convert_parser.add_argument("--out", help="Output path for a single input file")
|
|
26
|
+
convert_parser.add_argument(
|
|
27
|
+
"--out-dir",
|
|
28
|
+
help="Directory for generated Markdown files; defaults to current directory",
|
|
29
|
+
)
|
|
30
|
+
return parser
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _write_convert_outputs(args: argparse.Namespace) -> int:
|
|
34
|
+
if len(args.inputs) > 1 and args.out:
|
|
35
|
+
raise ValidationError("--out can only be used with a single input file")
|
|
36
|
+
|
|
37
|
+
for input_path in args.inputs:
|
|
38
|
+
path = Path(input_path)
|
|
39
|
+
if path.suffix.lower() != ".pdf":
|
|
40
|
+
raise ValidationError(
|
|
41
|
+
"Unsupported input format for convert. Only born-digital PDF files are supported."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
output_dir = Path(args.out_dir or ".")
|
|
45
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
results = convert_many(args.inputs)
|
|
47
|
+
|
|
48
|
+
existing_names: set[str] = {
|
|
49
|
+
path.name for path in output_dir.iterdir() if path.is_file()
|
|
50
|
+
}
|
|
51
|
+
for source_path, markdown in results:
|
|
52
|
+
if args.out:
|
|
53
|
+
destination = Path(args.out)
|
|
54
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
if destination.exists():
|
|
56
|
+
raise ValidationError(f"Output file already exists: {destination}")
|
|
57
|
+
else:
|
|
58
|
+
destination = output_dir / derive_convert_output_name(
|
|
59
|
+
source_path,
|
|
60
|
+
existing_names,
|
|
61
|
+
)
|
|
62
|
+
destination.write_text(markdown, encoding="utf-8")
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main(argv: list[str] | None = None) -> int:
|
|
67
|
+
parser = build_parser()
|
|
68
|
+
args = parser.parse_args(argv)
|
|
69
|
+
if args.command is None:
|
|
70
|
+
parser.print_help()
|
|
71
|
+
return 1
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
if args.command == "convert":
|
|
75
|
+
return _write_convert_outputs(args)
|
|
76
|
+
parser.print_help()
|
|
77
|
+
return 1
|
|
78
|
+
except LikhitError as exc:
|
|
79
|
+
print(str(exc), file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
raise SystemExit(main())
|
likhit/core.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Public conversion entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from likhit.errors import ExtractionError, ValidationError
|
|
9
|
+
from likhit.handlers import CIAAPressReleaseHandler, KanunPatrikaHandler
|
|
10
|
+
from likhit.markitdown_integration import convert_pdf_to_markdown
|
|
11
|
+
from likhit.models import DocumentType, ExtractionResult
|
|
12
|
+
from likhit.renderers import MarkdownRenderer
|
|
13
|
+
from likhit.extractors.font_based import FontBasedStrategy
|
|
14
|
+
|
|
15
|
+
_KANUN_MARKERS = (
|
|
16
|
+
"नेपालकानूनपत्रिका",
|
|
17
|
+
"नेपालकानुनपत्रिका",
|
|
18
|
+
"निर्णय नं",
|
|
19
|
+
"ने.का.प.",
|
|
20
|
+
)
|
|
21
|
+
_CIAA_MARKERS = (
|
|
22
|
+
"अख्तियार दुरुपयोग अनुसन्धान आयोग",
|
|
23
|
+
"प्रेस विज्ञ",
|
|
24
|
+
"प्रेस मिज्ञ",
|
|
25
|
+
"विषय:",
|
|
26
|
+
"मिषय:",
|
|
27
|
+
"आयोगकोनिर्णय",
|
|
28
|
+
"अनुसन्धानबाट पुष्टि भएको",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _metadata_from_options(
|
|
33
|
+
title: str | None,
|
|
34
|
+
publication_date: str | None,
|
|
35
|
+
source_url: str | None,
|
|
36
|
+
) -> dict[str, str | None]:
|
|
37
|
+
return {
|
|
38
|
+
"title": title,
|
|
39
|
+
"publication_date": publication_date,
|
|
40
|
+
"source_url": source_url,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_handler(
|
|
45
|
+
doc_type: DocumentType,
|
|
46
|
+
) -> CIAAPressReleaseHandler | KanunPatrikaHandler:
|
|
47
|
+
if doc_type is DocumentType.CIAA_PRESS_RELEASE:
|
|
48
|
+
return CIAAPressReleaseHandler()
|
|
49
|
+
if doc_type is DocumentType.KANUN_PATRIKA:
|
|
50
|
+
return KanunPatrikaHandler()
|
|
51
|
+
raise ValidationError(f"Unsupported document type: {doc_type.value}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _normalize_detection_text(text: str) -> str:
|
|
55
|
+
return re.sub(r"\s+", "", text).casefold()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _detect_document_type(raw_text: str) -> DocumentType | None:
|
|
59
|
+
normalized = _normalize_detection_text(raw_text)
|
|
60
|
+
|
|
61
|
+
if any(
|
|
62
|
+
_normalize_detection_text(marker) in normalized for marker in _KANUN_MARKERS
|
|
63
|
+
):
|
|
64
|
+
return DocumentType.KANUN_PATRIKA
|
|
65
|
+
if any(_normalize_detection_text(marker) in normalized for marker in _CIAA_MARKERS):
|
|
66
|
+
return DocumentType.CIAA_PRESS_RELEASE
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _render_markdown_without_frontmatter(result: ExtractionResult) -> str:
|
|
71
|
+
markdown = MarkdownRenderer().render(result).lstrip()
|
|
72
|
+
if not markdown.startswith("---\n"):
|
|
73
|
+
return markdown.strip()
|
|
74
|
+
|
|
75
|
+
parts = markdown.split("\n---\n", 1)
|
|
76
|
+
if len(parts) != 2:
|
|
77
|
+
return markdown.strip()
|
|
78
|
+
return parts[1].strip()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _convert_with_detected_structure(file_path: str) -> str | None:
|
|
82
|
+
strategy = FontBasedStrategy()
|
|
83
|
+
try:
|
|
84
|
+
raw_document = strategy.extract_text(file_path)
|
|
85
|
+
except ExtractionError as exc:
|
|
86
|
+
if str(exc) == "No text content found in document":
|
|
87
|
+
return None
|
|
88
|
+
raise
|
|
89
|
+
doc_type = _detect_document_type(raw_document.raw_text)
|
|
90
|
+
if doc_type is None:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
handler = _resolve_handler(doc_type)
|
|
94
|
+
result = handler.build_result(
|
|
95
|
+
raw_document,
|
|
96
|
+
_metadata_from_options(None, None, None),
|
|
97
|
+
)
|
|
98
|
+
return _render_markdown_without_frontmatter(result)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def convert(file_path: str) -> str:
|
|
102
|
+
path = Path(file_path)
|
|
103
|
+
if path.suffix.lower() != ".pdf":
|
|
104
|
+
raise ValidationError(
|
|
105
|
+
"Unsupported input format for convert. Only born-digital PDF files are supported."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
structured_markdown = _convert_with_detected_structure(file_path)
|
|
109
|
+
if structured_markdown is not None:
|
|
110
|
+
return structured_markdown
|
|
111
|
+
return convert_pdf_to_markdown(file_path)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def derive_convert_output_name(source_path: str, existing: set[str]) -> str:
|
|
115
|
+
base_name = Path(source_path).stem or "document"
|
|
116
|
+
candidate = f"{base_name}.md"
|
|
117
|
+
counter = 2
|
|
118
|
+
while candidate in existing:
|
|
119
|
+
candidate = f"{base_name}-{counter}.md"
|
|
120
|
+
counter += 1
|
|
121
|
+
existing.add(candidate)
|
|
122
|
+
return candidate
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def convert_many(file_paths: list[str]) -> list[tuple[str, str]]:
|
|
126
|
+
return [(file_path, convert(file_path)) for file_path in file_paths]
|
likhit/errors.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Project-specific exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LikhitError(Exception):
|
|
5
|
+
"""Base exception for extraction failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ValidationError(LikhitError):
|
|
9
|
+
"""Raised when user input or extracted content is invalid."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExtractionError(LikhitError):
|
|
13
|
+
"""Raised when document extraction fails."""
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Shared extraction abstractions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from likhit.models import Table
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class TextFragment:
|
|
13
|
+
"""A normalized text block plus its page/layout metadata."""
|
|
14
|
+
|
|
15
|
+
text: str
|
|
16
|
+
page_number: int
|
|
17
|
+
x0: float
|
|
18
|
+
y0: float
|
|
19
|
+
x1: float
|
|
20
|
+
y1: float
|
|
21
|
+
block_number: int = 0
|
|
22
|
+
line_number: int = 0
|
|
23
|
+
gap_before: float | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class RawDocument:
|
|
28
|
+
"""Unstructured text extracted from a document."""
|
|
29
|
+
|
|
30
|
+
paragraphs: list[str]
|
|
31
|
+
raw_text: str
|
|
32
|
+
fragments: list[TextFragment]
|
|
33
|
+
tables: list[Table] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ExtractionStrategy(ABC):
|
|
37
|
+
"""Base interface for extraction strategies."""
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def extract_text(self, file_path: str, pages: str | None = None) -> RawDocument:
|
|
41
|
+
"""Extract unstructured text from a document."""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def extract_tables(self, file_path: str) -> list[Table]:
|
|
45
|
+
"""Extract structured tables from a document."""
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Font-based extraction for CIAA documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import fitz
|
|
10
|
+
|
|
11
|
+
from likhit.errors import ExtractionError, ValidationError
|
|
12
|
+
from likhit.extractors.base import ExtractionStrategy, RawDocument, TextFragment
|
|
13
|
+
from likhit.extractors.font_classifier import scan_pdf_fonts
|
|
14
|
+
from likhit.extractors.kalimati import (
|
|
15
|
+
fix_kalimati_cmap,
|
|
16
|
+
normalize_devanagari_spacing,
|
|
17
|
+
reorder_devanagari,
|
|
18
|
+
)
|
|
19
|
+
from likhit.extractors.legacy_maps import get_converter
|
|
20
|
+
from likhit.extractors.tables import detect_page_tables, merge_continuation_tables
|
|
21
|
+
from likhit.models import Table
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
PAGE_RANGE_PATTERN = re.compile(r"^\d+(?:-\d+)?$")
|
|
25
|
+
SPAN_GAP_THRESHOLD = 0.75
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_page_range(spec: str, total_pages: int) -> tuple[int, int]:
|
|
29
|
+
"""Parse a 1-based inclusive page range to 0-based bounds."""
|
|
30
|
+
|
|
31
|
+
if not PAGE_RANGE_PATTERN.fullmatch(spec.strip()):
|
|
32
|
+
raise ValidationError("Invalid page range format. Use format: '1-3' or '5'")
|
|
33
|
+
|
|
34
|
+
if "-" in spec:
|
|
35
|
+
start_text, end_text = spec.split("-", 1)
|
|
36
|
+
start = int(start_text)
|
|
37
|
+
end = int(end_text)
|
|
38
|
+
else:
|
|
39
|
+
start = end = int(spec)
|
|
40
|
+
|
|
41
|
+
if start < 1 or end < 1 or end < start:
|
|
42
|
+
raise ValidationError("Invalid page range format. Use format: '1-3' or '5'")
|
|
43
|
+
|
|
44
|
+
if start > total_pages:
|
|
45
|
+
raise ValidationError(
|
|
46
|
+
f"Requested page range starts beyond document length ({total_pages} pages)"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
end = min(end, total_pages)
|
|
50
|
+
return start - 1, end - 1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def normalize_press_release_paragraph(text: str) -> str:
|
|
54
|
+
text = text.strip()
|
|
55
|
+
if not text:
|
|
56
|
+
return ""
|
|
57
|
+
|
|
58
|
+
normalized = text
|
|
59
|
+
normalized = re.sub(r"\s+", " ", normalized).strip()
|
|
60
|
+
normalized = re.sub(r"\s+([।,:;])", r"\1", normalized)
|
|
61
|
+
return normalized
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def join_words_with_spacing(words: list[str]) -> str:
|
|
65
|
+
"""Reconstruct a line from extracted word tokens."""
|
|
66
|
+
|
|
67
|
+
return " ".join(word.strip() for word in words if word.strip())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def join_spans_with_layout(
|
|
71
|
+
spans: list[tuple[float, float, float, float, str]],
|
|
72
|
+
) -> str:
|
|
73
|
+
"""Reconstruct a line from positioned spans without forcing spaces inside words."""
|
|
74
|
+
|
|
75
|
+
if not spans:
|
|
76
|
+
return ""
|
|
77
|
+
|
|
78
|
+
parts: list[str] = []
|
|
79
|
+
previous_x1: float | None = None
|
|
80
|
+
for x0, _y0, x1, _y1, text in spans:
|
|
81
|
+
if not text:
|
|
82
|
+
continue
|
|
83
|
+
if (
|
|
84
|
+
previous_x1 is not None
|
|
85
|
+
and x0 - previous_x1 > SPAN_GAP_THRESHOLD
|
|
86
|
+
and parts
|
|
87
|
+
and not parts[-1].endswith((" ", "\t"))
|
|
88
|
+
and not text.startswith((" ", "\t"))
|
|
89
|
+
):
|
|
90
|
+
parts.append(" ")
|
|
91
|
+
parts.append(text)
|
|
92
|
+
previous_x1 = x1
|
|
93
|
+
|
|
94
|
+
return "".join(parts)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def normalize_extracted_word(text: str) -> str:
|
|
98
|
+
"""Normalize a single extracted token without touching inter-word spacing."""
|
|
99
|
+
|
|
100
|
+
normalized = reorder_devanagari(text)
|
|
101
|
+
normalized = normalize_devanagari_spacing(normalized)
|
|
102
|
+
return normalized.strip()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class FontBasedStrategy(ExtractionStrategy):
|
|
106
|
+
"""Extract text from CIAA PDFs using PyMuPDF blocks."""
|
|
107
|
+
|
|
108
|
+
def extract_text(self, file_path: str, pages: str | None = None) -> RawDocument:
|
|
109
|
+
return self._extract_raw_document(file_path, pages=pages)
|
|
110
|
+
|
|
111
|
+
def extract_tables(self, file_path: str) -> list[Table]:
|
|
112
|
+
return self._extract_raw_document(file_path).tables
|
|
113
|
+
|
|
114
|
+
def _extract_raw_document(
|
|
115
|
+
self,
|
|
116
|
+
file_path: str,
|
|
117
|
+
pages: str | None = None,
|
|
118
|
+
) -> RawDocument:
|
|
119
|
+
path = Path(file_path)
|
|
120
|
+
if path.suffix.lower() != ".pdf":
|
|
121
|
+
raise ValidationError("Unsupported file format. Please upload a PDF file")
|
|
122
|
+
if not path.exists():
|
|
123
|
+
raise ValidationError(f"File not found: {file_path}")
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
doc = fitz.open(path)
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
raise ExtractionError(
|
|
129
|
+
"Unable to parse PDF. File may be corrupted or encrypted"
|
|
130
|
+
) from exc
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
page_start, page_end = 0, doc.page_count - 1
|
|
134
|
+
if pages:
|
|
135
|
+
page_start, page_end = parse_page_range(pages, doc.page_count)
|
|
136
|
+
|
|
137
|
+
font_strategies = scan_pdf_fonts(doc)
|
|
138
|
+
has_broken_cmap = any(
|
|
139
|
+
strategy == "broken_cmap" for strategy in font_strategies.values()
|
|
140
|
+
)
|
|
141
|
+
needs_reorder = False
|
|
142
|
+
if has_broken_cmap:
|
|
143
|
+
doc, needs_reorder = fix_kalimati_cmap(doc)
|
|
144
|
+
paragraphs: list[str] = []
|
|
145
|
+
fragments: list[TextFragment] = []
|
|
146
|
+
tables: list[Table] = []
|
|
147
|
+
table_index = 0
|
|
148
|
+
|
|
149
|
+
for page_index in range(page_start, page_end + 1):
|
|
150
|
+
page = doc[page_index]
|
|
151
|
+
page_dict = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)
|
|
152
|
+
lines_by_key: dict[
|
|
153
|
+
tuple[int, int], list[tuple[float, float, float, float, str]]
|
|
154
|
+
] = defaultdict(list)
|
|
155
|
+
for block_number, block in enumerate(page_dict["blocks"]):
|
|
156
|
+
if "lines" not in block:
|
|
157
|
+
continue
|
|
158
|
+
for line_number, line in enumerate(block["lines"]):
|
|
159
|
+
for span in line["spans"]:
|
|
160
|
+
text = self._convert_span_text(
|
|
161
|
+
str(span["text"]),
|
|
162
|
+
str(span["font"]),
|
|
163
|
+
font_strategies,
|
|
164
|
+
needs_reorder,
|
|
165
|
+
)
|
|
166
|
+
if not text:
|
|
167
|
+
continue
|
|
168
|
+
x0, y0, x1, y1 = span["bbox"]
|
|
169
|
+
lines_by_key[(block_number, line_number)].append(
|
|
170
|
+
(
|
|
171
|
+
float(x0),
|
|
172
|
+
float(y0),
|
|
173
|
+
float(x1),
|
|
174
|
+
float(y1),
|
|
175
|
+
text,
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
page_fragments: list[TextFragment] = []
|
|
180
|
+
previous_y1: float | None = None
|
|
181
|
+
for (block_number, line_number), line_words in sorted(
|
|
182
|
+
lines_by_key.items(),
|
|
183
|
+
key=lambda item: (
|
|
184
|
+
round(min(piece[1] for piece in item[1]), 2),
|
|
185
|
+
min(piece[0] for piece in item[1]),
|
|
186
|
+
),
|
|
187
|
+
):
|
|
188
|
+
ordered_words = sorted(line_words, key=lambda piece: piece[0])
|
|
189
|
+
line_text = join_spans_with_layout(ordered_words)
|
|
190
|
+
paragraph = normalize_press_release_paragraph(line_text)
|
|
191
|
+
if not paragraph:
|
|
192
|
+
previous_y1 = None
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
x0 = min(piece[0] for piece in ordered_words)
|
|
196
|
+
y0 = min(piece[1] for piece in ordered_words)
|
|
197
|
+
x1 = max(piece[2] for piece in ordered_words)
|
|
198
|
+
y1 = max(piece[3] for piece in ordered_words)
|
|
199
|
+
gap_before = None
|
|
200
|
+
if previous_y1 is not None:
|
|
201
|
+
gap_before = y0 - previous_y1
|
|
202
|
+
previous_y1 = y1
|
|
203
|
+
|
|
204
|
+
fragment = TextFragment(
|
|
205
|
+
text=paragraph,
|
|
206
|
+
page_number=page_index + 1,
|
|
207
|
+
x0=x0,
|
|
208
|
+
y0=y0,
|
|
209
|
+
x1=x1,
|
|
210
|
+
y1=y1,
|
|
211
|
+
block_number=block_number,
|
|
212
|
+
line_number=line_number,
|
|
213
|
+
gap_before=gap_before,
|
|
214
|
+
)
|
|
215
|
+
paragraphs.append(paragraph)
|
|
216
|
+
page_fragments.append(fragment)
|
|
217
|
+
|
|
218
|
+
fragments.extend(page_fragments)
|
|
219
|
+
page_tables = detect_page_tables(page, page_fragments, table_index)
|
|
220
|
+
tables.extend(page_tables)
|
|
221
|
+
table_index += len(page_tables)
|
|
222
|
+
|
|
223
|
+
raw_text = "\n\n".join(paragraphs).strip()
|
|
224
|
+
if not raw_text:
|
|
225
|
+
raise ExtractionError("No text content found in document")
|
|
226
|
+
|
|
227
|
+
return RawDocument(
|
|
228
|
+
paragraphs=paragraphs,
|
|
229
|
+
raw_text=raw_text,
|
|
230
|
+
fragments=fragments,
|
|
231
|
+
tables=merge_continuation_tables(tables),
|
|
232
|
+
)
|
|
233
|
+
except (ExtractionError, ValidationError):
|
|
234
|
+
raise
|
|
235
|
+
except Exception as exc:
|
|
236
|
+
raise ExtractionError(
|
|
237
|
+
f"Failed to extract text from PDF: {path.name}"
|
|
238
|
+
) from exc
|
|
239
|
+
finally:
|
|
240
|
+
doc.close()
|
|
241
|
+
|
|
242
|
+
def _convert_span_text(
|
|
243
|
+
self,
|
|
244
|
+
text: str,
|
|
245
|
+
font_name: str,
|
|
246
|
+
font_strategies: dict[str, str],
|
|
247
|
+
needs_reorder: bool,
|
|
248
|
+
) -> str:
|
|
249
|
+
base = font_name.split("+", 1)[-1] if "+" in font_name else font_name
|
|
250
|
+
strategy = font_strategies.get(base, "correct")
|
|
251
|
+
|
|
252
|
+
if strategy == "legacy_remap":
|
|
253
|
+
converter = get_converter(font_name)
|
|
254
|
+
if converter is not None:
|
|
255
|
+
return converter(text)
|
|
256
|
+
return text
|
|
257
|
+
|
|
258
|
+
if strategy == "broken_cmap" and needs_reorder:
|
|
259
|
+
text = reorder_devanagari(text)
|
|
260
|
+
text = normalize_devanagari_spacing(text)
|
|
261
|
+
return text
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Font strategy classification for extraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import fitz
|
|
8
|
+
|
|
9
|
+
from . import legacy_maps
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_KNOWN_BROKEN_CMAP = {"kalimati"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def classify_font(font_name: str, font_type: str) -> str:
|
|
17
|
+
"""Classify a PDF font into an extraction strategy."""
|
|
18
|
+
|
|
19
|
+
del font_type
|
|
20
|
+
|
|
21
|
+
base = font_name.split("+", 1)[-1] if "+" in font_name else font_name
|
|
22
|
+
base_lower = base.lower().strip()
|
|
23
|
+
|
|
24
|
+
if legacy_maps.is_legacy_font(font_name):
|
|
25
|
+
return "legacy_remap"
|
|
26
|
+
|
|
27
|
+
for name in _KNOWN_BROKEN_CMAP:
|
|
28
|
+
if name in base_lower:
|
|
29
|
+
return "broken_cmap"
|
|
30
|
+
|
|
31
|
+
return "correct"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def scan_pdf_fonts(doc: fitz.Document) -> dict[str, str]:
|
|
35
|
+
"""Scan all PDF fonts and return a strategy per unique base font name."""
|
|
36
|
+
|
|
37
|
+
font_strategies: dict[str, str] = {}
|
|
38
|
+
|
|
39
|
+
for page_index in range(doc.page_count):
|
|
40
|
+
page = doc[page_index]
|
|
41
|
+
for font_info in page.get_fonts(full=True):
|
|
42
|
+
_xref, _ext, font_type, name, _encoding = font_info[:5]
|
|
43
|
+
base = name.split("+", 1)[-1] if "+" in name else name
|
|
44
|
+
if base in font_strategies:
|
|
45
|
+
continue
|
|
46
|
+
strategy = classify_font(name, font_type)
|
|
47
|
+
font_strategies[base] = strategy
|
|
48
|
+
logger.info("Font '%s' (type=%s) -> %s", base, font_type, strategy)
|
|
49
|
+
|
|
50
|
+
return font_strategies
|