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 ADDED
@@ -0,0 +1,6 @@
1
+ """Likhit package."""
2
+
3
+ from likhit.core import convert, convert_many
4
+ from likhit.version import __version__
5
+
6
+ __all__ = ["__version__", "convert", "convert_many"]
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,5 @@
1
+ """Extraction strategy exports."""
2
+
3
+ from likhit.extractors.font_based import FontBasedStrategy
4
+
5
+ __all__ = ["FontBasedStrategy"]
@@ -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