likhit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. likhit-0.1.0/PKG-INFO +104 -0
  2. likhit-0.1.0/README.md +85 -0
  3. likhit-0.1.0/pyproject.toml +31 -0
  4. likhit-0.1.0/src/likhit/__init__.py +6 -0
  5. likhit-0.1.0/src/likhit/cli.py +84 -0
  6. likhit-0.1.0/src/likhit/core.py +126 -0
  7. likhit-0.1.0/src/likhit/errors.py +13 -0
  8. likhit-0.1.0/src/likhit/extractors/__init__.py +5 -0
  9. likhit-0.1.0/src/likhit/extractors/base.py +45 -0
  10. likhit-0.1.0/src/likhit/extractors/font_based.py +261 -0
  11. likhit-0.1.0/src/likhit/extractors/font_classifier.py +50 -0
  12. likhit-0.1.0/src/likhit/extractors/kalimati.py +501 -0
  13. likhit-0.1.0/src/likhit/extractors/legacy_maps.py +68 -0
  14. likhit-0.1.0/src/likhit/extractors/tables.py +291 -0
  15. likhit-0.1.0/src/likhit/handlers/__init__.py +6 -0
  16. likhit-0.1.0/src/likhit/handlers/base.py +22 -0
  17. likhit-0.1.0/src/likhit/handlers/ciaa_press_release.py +363 -0
  18. likhit-0.1.0/src/likhit/handlers/content_blocks.py +143 -0
  19. likhit-0.1.0/src/likhit/handlers/kanun_patrika.py +201 -0
  20. likhit-0.1.0/src/likhit/markdown_assembly.py +94 -0
  21. likhit-0.1.0/src/likhit/markitdown_integration.py +94 -0
  22. likhit-0.1.0/src/likhit/models/__init__.py +25 -0
  23. likhit-0.1.0/src/likhit/models/repair_types.py +19 -0
  24. likhit-0.1.0/src/likhit/models/types.py +132 -0
  25. likhit-0.1.0/src/likhit/nepali_pdf_repair.py +324 -0
  26. likhit-0.1.0/src/likhit/renderers/__init__.py +5 -0
  27. likhit-0.1.0/src/likhit/renderers/base.py +15 -0
  28. likhit-0.1.0/src/likhit/renderers/markdown.py +649 -0
  29. likhit-0.1.0/src/likhit/utils/__init__.py +0 -0
  30. likhit-0.1.0/src/likhit/version.py +3 -0
likhit-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: likhit
3
+ Version: 0.1.0
4
+ Summary: Nepali PDF-to-Markdown converter powered by MarkItDown
5
+ Author: notashwinii
6
+ Author-email: ashwinii.subedi@gmail.com
7
+ Requires-Python: >=3.12,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Programming Language :: Python :: 3.14
12
+ Requires-Dist: fonttools (>=4.47.0,<5.0.0)
13
+ Requires-Dist: markitdown[pdf] (==0.1.5)
14
+ Requires-Dist: npttf2utf (>=0.3.7,<1.0.0)
15
+ Requires-Dist: pymupdf (==1.27.2)
16
+ Requires-Dist: pyyaml (>=6.0,<7.0)
17
+ Description-Content-Type: text/markdown
18
+
19
+ # likhit
20
+
21
+ `likhit` is a public PDF-to-Markdown tool for Nepali government documents.
22
+
23
+ The default path is powered by [MarkItDown](https://github.com/microsoft/markitdown), with `likhit` intercepting born-digital Nepali PDFs that need Nepal-specific repair before Markdown is emitted. That repair layer handles Kalimati broken-CMap fixes, Devanagari reordering and spacing normalization, and legacy Nepali font remapping where applicable.
24
+
25
+ ## Installation
26
+
27
+ ### With Poetry
28
+
29
+ ```bash
30
+ poetry install
31
+ ```
32
+
33
+ Run project commands with `poetry run`:
34
+
35
+ ```bash
36
+ poetry run pytest
37
+ poetry run ruff check .
38
+ poetry run black --check .
39
+ ```
40
+
41
+ ## Recommended Usage
42
+
43
+ Convert a single PDF to editable Markdown:
44
+
45
+ ```bash
46
+ poetry run likhit convert path/to/document.pdf --out path/to/document.md
47
+ ```
48
+
49
+ Convert multiple PDFs at once:
50
+
51
+ ```bash
52
+ poetry run likhit convert path/to/a.pdf path/to/b.pdf --out-dir path/to/output-dir
53
+ ```
54
+
55
+ If `--out` or `--out-dir` is omitted, `likhit` writes Markdown files in the current directory using the input filename stem.
56
+
57
+ ## Usage
58
+
59
+ `convert` is the public path.
60
+
61
+ - Input scope: born-digital PDFs only
62
+ - Output: generic editable Markdown
63
+ - Engine: MarkItDown by default
64
+ - `likhit` value-add: Nepali PDF repair before Markdown output when needed
65
+ - Recognized document layouts such as Kanun Patrika and CIAA-style PDFs are auto-detected internally so `likhit` can preserve better text order and structure without a `--type` flag
66
+ - No OCR support is included in this branch
67
+
68
+ ## Architecture
69
+
70
+ The new default pipeline is:
71
+
72
+ 1. `likhit convert` opens the PDF and checks whether it matches a known structure-aware document type.
73
+ 2. If the PDF matches a known layout such as Kanun Patrika or a CIAA-style document, `likhit` reuses its existing structure-aware extraction logic internally.
74
+ 3. Otherwise, MarkItDown handles the default conversion path.
75
+ 4. When the PDF needs Nepali repair, `likhit` repairs the text first:
76
+ - Kalimati broken-CMap repair
77
+ - Devanagari reordering
78
+ - Devanagari spacing normalization
79
+ - Legacy-font remapping through `npttf2utf`
80
+ 5. `likhit` assembles repaired text blocks into Markdown.
81
+
82
+ This keeps the public product story simple: `likhit` is the tool users call, while MarkItDown is embedded infrastructure.
83
+
84
+ ## Current Scope
85
+
86
+ - Supported default input: PDF only
87
+ - Supported default output: Markdown only
88
+ - Supported default document class: born-digital PDFs
89
+ - Unsupported in this branch: OCR, scanned/image-only PDFs, `.doc`, `.docx`, and image inputs
90
+
91
+ ## Project Layout
92
+
93
+ - `src/likhit/core.py`: public `convert` and `convert_many` entry points
94
+ - `src/likhit/markitdown_integration.py`: MarkItDown instance setup and custom PDF converter
95
+ - `src/likhit/nepali_pdf_repair.py`: reusable Nepal-specific PDF repair layer
96
+ - `src/likhit/markdown_assembly.py`: generic Markdown assembly for the default conversion path
97
+ - `src/likhit/extractors/`, `src/likhit/handlers/`, `src/likhit/renderers/`: internal Nepali PDF repair and legacy extraction internals
98
+ - `tests/`: conversion, extraction, and CLI coverage
99
+
100
+ ## References
101
+
102
+ - MarkItDown: https://github.com/microsoft/markitdown
103
+ - MarkItDown sample plugin: https://github.com/microsoft/markitdown/tree/main/packages/markitdown-sample-plugin
104
+
likhit-0.1.0/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # likhit
2
+
3
+ `likhit` is a public PDF-to-Markdown tool for Nepali government documents.
4
+
5
+ The default path is powered by [MarkItDown](https://github.com/microsoft/markitdown), with `likhit` intercepting born-digital Nepali PDFs that need Nepal-specific repair before Markdown is emitted. That repair layer handles Kalimati broken-CMap fixes, Devanagari reordering and spacing normalization, and legacy Nepali font remapping where applicable.
6
+
7
+ ## Installation
8
+
9
+ ### With Poetry
10
+
11
+ ```bash
12
+ poetry install
13
+ ```
14
+
15
+ Run project commands with `poetry run`:
16
+
17
+ ```bash
18
+ poetry run pytest
19
+ poetry run ruff check .
20
+ poetry run black --check .
21
+ ```
22
+
23
+ ## Recommended Usage
24
+
25
+ Convert a single PDF to editable Markdown:
26
+
27
+ ```bash
28
+ poetry run likhit convert path/to/document.pdf --out path/to/document.md
29
+ ```
30
+
31
+ Convert multiple PDFs at once:
32
+
33
+ ```bash
34
+ poetry run likhit convert path/to/a.pdf path/to/b.pdf --out-dir path/to/output-dir
35
+ ```
36
+
37
+ If `--out` or `--out-dir` is omitted, `likhit` writes Markdown files in the current directory using the input filename stem.
38
+
39
+ ## Usage
40
+
41
+ `convert` is the public path.
42
+
43
+ - Input scope: born-digital PDFs only
44
+ - Output: generic editable Markdown
45
+ - Engine: MarkItDown by default
46
+ - `likhit` value-add: Nepali PDF repair before Markdown output when needed
47
+ - Recognized document layouts such as Kanun Patrika and CIAA-style PDFs are auto-detected internally so `likhit` can preserve better text order and structure without a `--type` flag
48
+ - No OCR support is included in this branch
49
+
50
+ ## Architecture
51
+
52
+ The new default pipeline is:
53
+
54
+ 1. `likhit convert` opens the PDF and checks whether it matches a known structure-aware document type.
55
+ 2. If the PDF matches a known layout such as Kanun Patrika or a CIAA-style document, `likhit` reuses its existing structure-aware extraction logic internally.
56
+ 3. Otherwise, MarkItDown handles the default conversion path.
57
+ 4. When the PDF needs Nepali repair, `likhit` repairs the text first:
58
+ - Kalimati broken-CMap repair
59
+ - Devanagari reordering
60
+ - Devanagari spacing normalization
61
+ - Legacy-font remapping through `npttf2utf`
62
+ 5. `likhit` assembles repaired text blocks into Markdown.
63
+
64
+ This keeps the public product story simple: `likhit` is the tool users call, while MarkItDown is embedded infrastructure.
65
+
66
+ ## Current Scope
67
+
68
+ - Supported default input: PDF only
69
+ - Supported default output: Markdown only
70
+ - Supported default document class: born-digital PDFs
71
+ - Unsupported in this branch: OCR, scanned/image-only PDFs, `.doc`, `.docx`, and image inputs
72
+
73
+ ## Project Layout
74
+
75
+ - `src/likhit/core.py`: public `convert` and `convert_many` entry points
76
+ - `src/likhit/markitdown_integration.py`: MarkItDown instance setup and custom PDF converter
77
+ - `src/likhit/nepali_pdf_repair.py`: reusable Nepal-specific PDF repair layer
78
+ - `src/likhit/markdown_assembly.py`: generic Markdown assembly for the default conversion path
79
+ - `src/likhit/extractors/`, `src/likhit/handlers/`, `src/likhit/renderers/`: internal Nepali PDF repair and legacy extraction internals
80
+ - `tests/`: conversion, extraction, and CLI coverage
81
+
82
+ ## References
83
+
84
+ - MarkItDown: https://github.com/microsoft/markitdown
85
+ - MarkItDown sample plugin: https://github.com/microsoft/markitdown/tree/main/packages/markitdown-sample-plugin
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "likhit"
3
+ version = "0.1.0"
4
+ description = "Nepali PDF-to-Markdown converter powered by MarkItDown"
5
+ authors = [
6
+ { name = "notashwinii", email = "ashwinii.subedi@gmail.com" }
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.12,<4.0"
10
+ dependencies = [
11
+ "fonttools>=4.47.0,<5.0.0",
12
+ "npttf2utf>=0.3.7,<1.0.0",
13
+ "pymupdf==1.27.2",
14
+ "pyyaml>=6.0,<7.0",
15
+ "markitdown[pdf]==0.1.5"
16
+ ]
17
+
18
+ [project.scripts]
19
+ likhit = "likhit.cli:main"
20
+
21
+ [tool.poetry]
22
+ packages = [{ include = "likhit", from = "src" }]
23
+
24
+ [tool.poetry.group.dev.dependencies]
25
+ pytest = "^8.0.0"
26
+ ruff = "^0.11.0"
27
+ black = "^25.0.0"
28
+
29
+ [build-system]
30
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
31
+ build-backend = "poetry.core.masonry.api"
@@ -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"]
@@ -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())
@@ -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]
@@ -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."""