likhit
likhit is Jawafdehi's public MarkItDown plugin for Nepal-specific document support.
It extends MarkItDown with Nepal-specific PDF repair, layout-aware Markdown assembly, optional OCR fallback for image-dominant PDFs, and legacy .doc support. For PDFs, likhit now evaluates multiple extraction paths and returns the best result instead of relying on a single fixed pipeline.
It also ships two things that work on transcripts after extraction: a quality audit that scores a document on independent damage axes, and personal-data detection and redaction. Both are pure-Python and importable on their own — see Transcript quality and Personal data.
Owned and maintained by Jawafdehi.
Installation
pip install likhit
Two extras name the post-extraction feature sets:
pip install "likhit[quality]" # the transcript quality audit
pip install "likhit[privacy]" # personal-data detection and redaction
These extras currently install nothing extra. Both feature sets are pure-stdlib, so there is no dependency for an extra to gate, and an extra cannot withhold modules from a wheel —
likhit.qualityandlikhit.privacyare importable after a plainpip install likhit, andlikhit-audit/likhit-redactare always on your PATH. The extras exist to name the separable surfaces, so a downstream requirements file can record which part of this package it depends on, and to reserve the name for the first real optional dependency.
Project Links
- Website: https://jawafdehi.org/
- GitHub: https://github.com/Jawafdehi/likhit/
- Benchmark: https://jawafdehi.github.io/likhit/ — real, published Government of Nepal documents converted with and without OCR, with the extracted Markdown and the assertions behind every result
- Contact: inquiry@jawafdehi.org
Usage
likhit is primarily used as a MarkItDown plugin.
Python
Once installed, enable plugins when creating a MarkItDown instance:
from markitdown import MarkItDown
md = MarkItDown(enable_plugins=True)
result = md.convert("path/to/nepali-document.pdf")
print(result.text_content)
MarkItDown CLI
You can also use likhit through the standard MarkItDown CLI:
markitdown --use-plugins path/to/nepali-document.pdf
To write the output to a file:
markitdown --use-plugins path/to/nepali-document.pdf -o output.md
To verify the plugin is registered:
markitdown --list-plugins
You should see likhit in the output.
likhit-save CLI
This package also installs a small helper CLI that runs MarkItDown with the likhit plugin enabled and writes Markdown files for you:
likhit-save path/to/nepali-document.pdf --out output.md
Convert multiple files into a directory:
likhit-save samples/pressrelease.pdf samples/kanunpatrika.pdf --out-dir converted/
Extract only one page or a page range from a PDF:
likhit-save path/to/nepali-document.pdf --pages 5 --out page-5.md
likhit-save path/to/nepali-document.pdf --pages 2-4 --out pages-2-4.md
Transcript quality
A whole-document Devanagari ratio is not enough to tell a good transcript from a bad one: a
legacy-Kalimati report recovered by this package scored 0.977 while its first line was
untranslated legacy bytes. likhit.quality scores a transcript on independent axes —
legacy-ASCII leakage, mojibake, repha loss, numeric damage, structure, repetition, spacing
and matra damage — and the worst axis wins, because averaging lets seven clean axes bury
an eighth that is making a positive claim about damage.
from likhit.quality import audit_text
result = audit_text(markdown)
result["verdict"] # "clean" | "suspect" | "garbled"
result["failing"] # the axes that are not clean, worst first
result["checks"]["spacing"] # that axis's verdict and its evidence
audit_text is pure — text in, verdicts out, no filesystem and no assumptions about how your
corpus is laid out. For a directory, audit_tree walks it and takes an optional enrich
callback so you can join your own metadata onto each row.
likhit-audit path/to/markdown --out report.json --workers 8
The command exits non-zero on a tree it cannot evaluate — missing, not a directory, or
holding no *.md. That guard is the point: rglob raises nothing for any of those, so
without it an audit of a mistyped path writes a report of zero records and exits 0, which is
indistinguishable from a clean corpus.
Personal data
likhit.privacy separates finding personal data from removing it, and removes a deliberately
narrower set than it finds.
from likhit.privacy import scan_text, redact_inline_text
high_precision, name_shaped = scan_text(markdown) # counts and shapes, never a match
redacted, journal, counters = redact_inline_text(markdown)
scan_text never returns matched text, so a report of what a corpus contains can be
published beside the corpus. Redaction is anchored on the label and replaces only the
value, so a bare digit run can never match and a financial table cannot be touched.
likhit-redact scan path/to/markdown --out pii-report.json
# Two independent passes. Run BOTH: the first handles a label and value in one span, the
# second a value in a table cell away from its label. Either order works.
likhit-redact redact path/to/markdown --out-tree staged-1 --journal inline.json
likhit-redact redact staged-1 --out-tree staged-2 --journal tables.json --tables
--tablesis not optional in practice. On this corpus, citizenship numbers held in table cells are the case the second pass exists for, and the first pass cannot see them. A recipe that omits it leaves them in place.
Exit codes are distinguishable on purpose: 0 all documents processed, 1 ran but some
document failed and was skipped — the journal names each one — and 2 refused to start.
A partial redaction that exited 0 would be indistinguishable from a complete one.
Redaction is never in place: it reads a tree and writes a staging copy, and refuses if the destination exists or is the source. Documents with nothing to redact are copied byte-for-byte rather than rewritten, and the journal records value shapes — lengths, digit counts, separator presence — never a matched digit.
Redaction modes
A mode names a policy tier, not a pass. One call runs whichever passes the tier needs:
from likhit.privacy import redact_text
result = redact_text(markdown) # redacts — this is the default
result.text, result.journal, result.values_removed
| mode | removes |
|---|---|
none |
nothing — output is byte-identical to the input |
identifiers |
citizenship numbers, dates of birth |
contact |
the above, plus mobile, landline, email |
strict (default) |
the above, plus bank account, age, passport |
Measured over the 13 CIAA annual-report transcripts, by tier:
| tier | values removed | high-precision signals remaining |
|---|---|---|
| (none) | — | bank 32, citizenship 37, DOB 39, email 32, labelled-phone 21, landline 8, mobile 39, PAN 7 |
identifiers |
50 | bank 32, citizenship 33, DOB 10, email 32, labelled-phone 21, landline 8, mobile 39, PAN 7 |
contact |
131 | bank 32, citizenship 33, DOB 10, labelled-phone 2, PAN 7 |
strict |
195 | citizenship 33, DOB 10, labelled-phone 2, PAN 7 |
What is left at
strictis mostly not personal data. The residualcitizenshipanddate_of_birthcounts are the scanner's wide tier matching prose: 10 of them areनागरिकता ऐन, २०६३— the Citizenship Act and its year, a statute citation — and most of the rest are four-digit years in sentences likeनागरिकता लिएकोमा मिति २०७०. The scanner is deliberately wider than the remover, and that gap is meant to be disclosed rather than chased.The two remaining
labelled-phonehits are both the Commission's own switchboard in the report masthead, where one label covers two numbers on consecutive lines and only the first is reachable — seelikhit.privacy.contactfor why a continuation rule is not the fix.The 7 remaining
PANhits are meant to be there: no tier removes a PAN. See below.
The line the tiers draw: a person, not a company
🛑 No tier removes a PAN, and that is a decision rather than a gap. A PAN is a business registration number and is public by design — printed on invoices, filed with the registrar, searchable. No pattern can tell a company PAN from a sole trader's, so a rule that removed either would remove both, and removing a contractor's PAN from a procurement finding deletes the one identifier a reader can use to check the claim against the public register. PANs are detected and disclosed instead: the scanner counts them, so a corpus report still says how many are present.
That is the distinction the widest tier is built on. strict means remove the personal
details — citizenship number, date of birth, phone, email, bank account, age, passport —
not remove every identifier. Data about a person goes; data about a company stays,
because in an accountability corpus the company identifiers are the finding.
Bilingual by requirement, not by courtesy
Every label alternation matches its English form as well as its Nepali one, and passport numbers and internationally-formatted phone numbers have rules of their own.
This is a correctness property rather than a nicety. These documents are bilingual, and the
English is where the foreign parties are written: page 1 of samples/aarop-patra.pdf is
Nepali prose carrying, in Latin script, one Indian national's Passport No. Z4362932,
Date of Birth: 27 August 1979 and Mobile Number: 919971766006. With Nepali-only labels,
strict removed the Nepali-labelled mobile two clauses earlier and left all four of those.
🛑 A Nepali-only rule does not fail at random. It fails on exactly the people whose details were written in English — which in this corpus means foreign nationals and anyone transcribed from an English-language document — so the removal set acquires a shape nobody chose. That is why this is listed as a defect rather than as coverage.
English widens what counts as a label and nothing else: every value still runs the same three guards, so a wider label cannot admit a wider value.
🛑 No tier removes names, and that is a decision rather than a gap. Nothing detects a Nepali name reliably — the only signals are
श्रीfollowed by a non-institution token (2,179 hits across those 13 transcripts) and a filledनाम:field (0). Removing parties other than the accused needs role assignment — वादी/प्रतिवादी versus witness versus relative — which is a modelling project, not a pattern. And for an accountability corpus, stripping the names of prosecuted officials would invert the product.
🛑 Redaction is the default, and unredacted output is what you ask for.
likhit-save report.pdfwrites a redacted transcript;likhit-save report.pdf --redact nonewrites the unredacted one and says so at WARNING. The reasoning is that this command's output is a file, and a file outlives the invocation that made it — nobody readingreport.mdsix months later can tell whether redaction was considered. The safe default is the one where forgetting produces the harmless outcome.
--redact noneis a legitimate thing to want — the retained original, internal casework and the research corpus all need it — so it is one flag and not a fight.
Redaction fails closed: if a pass raises, nothing is written. Falling through to the unredacted text it was asked to clean is the one outcome worse than not converting at all.
PDF in, redacted PDF out
likhit-redact pdf aarop-patra.pdf --out redacted.pdf --journal redaction.json --mode contact
This is a true removal, not a drawn box: the value is gone from the extracted text and from the file's bytes. Only the value is removed — the label beside it is kept, because redaction is located from the detector's own character offsets mapped to per-glyph rectangles rather than by searching the page for the value's digits.
🛑 It refuses any PDF it cannot read, and that is the point. Only a page whose text layer is Unicode Devanagari can be redacted today. A page carrying a legacy pre-Unicode font decodes to Latin-looking bytes, so the detectors find almost nothing in it — measured on two CIAA annual reports, the raw text layer yields 1 and 4 high-precision hits where
likhit's decoded output yields 16 and 62, and in both cases the only survivors are the ASCII email addresses. "No personal data found" on such a page is not a finding, so those pages refuse the document rather than being passed through unchanged. A pure scan refuses for the same reason: it needs bounding-box OCR, whichlikhit's LLM-vision OCR cannot provide because it returns text without coordinates.
Document metadata, XMP and embedded attachments are scrubbed too — a document redacted only
where someone looked is worse than one nobody redacted. Saves are always full saves with
garbage=4, never incremental: an incremental save appends a revision and leaves the original
content recoverable inside the same file.
Scope
identifiers is the formally adjudicated scope — citizenship numbers and dates of birth —
and it is narrow on purpose: anchoring on the label means a bare digit run can never match,
so a financial table cannot be touched.
Everything above it is a widening: the default removes the personal details rather than only
the two adjudicated classes, because identifiers alone removes 0.3% of the personal data
detected in the charge-sheet corpus. The one cost it knowingly accepts:
- A bank account cited as the evidence of a transfer is the claim. Removing it protects
nobody and deletes the finding.
strictremoves it anyway; use--redact contactwhere the account numbers are the evidence.
PANs were the other candidate for that list and are not removed at all — see the line the tiers draw.
Passport numbers sit at
strict, not atidentifiers— and that is not a judgement that they are less personal. A passport number is plainly an identity-document number of the same class as a citizenship number. Butidentifiersis the formally adjudicated scope, and that adjudication names two classes; a caller choosing it is choosing "the scope I was authorised for", so quietly adding a third would break the only promise that tier makes.
Age is anchored on उमेर/Age and never on वर्ष or years alone. A वर्ष-adjacent rule matches 413
spans across the CIAA corpus and most of them are durations — # वर्षको लागि रु #,# is a
contract term, प्रमाणपत्र जारी भएको # वर्षभित्र a deadline, सेवा प्रवेश गरेको ## वर्षमा
years of service. Removing those would delete the financial evidence the corpus exists for.
The उमेर anchor matches 20, of which 2 are a statutory retirement age rather than a
person's.
What likhit does
likhit adds behavior beyond MarkItDown in these places:
- PDF:
likhitintercepts PDF inputs, runs the default MarkItDown PDF converter first, and then decides whether to keep that result, retry with Nepal-specific extraction, or add an OCR candidate for image-dominant pages. It prefers directlikhitextraction immediately when known Nepali repair fonts are detected. - DOC: Legacy Microsoft Word
.docfiles are handled bylikhit's own extraction pipeline. - DOCX:
.docxfiles are still handled by MarkItDown's built-in Word converter, even when plugins are enabled.
Supported document types
- PDFs, including Nepal-specific born-digital PDFs and image-dominant PDFs that may need OCR
- Legacy
.docfiles .docxpassthrough via MarkItDown
OCR Configuration
For image-dominant or scanned PDFs, likhit can add an OCR extraction candidate through markitdown-ocr when OCR is configured.
Required model configuration:
export MARKITDOWN_OCR_MODEL="your-model-name"
You can also provide the model through OPENAI_MODEL or GEMINI_MODEL.
Authentication options:
- OpenAI-compatible provider with a standard OpenAI key:
export OPENAI_API_KEY="your-api-key"
- OpenAI-compatible provider with a custom base URL:
export OPENAI_API_KEY="your-api-key"
export OPENAI_BASE_URL="https://your-provider.example/v1/"
export MARKITDOWN_OCR_MODEL="your-model-name"
- Gemini using the OpenAI compatibility endpoint:
export GEMINI_API_KEY="your-gemini-api-key"
export GEMINI_MODEL="gemini-2.5-flash"
When GEMINI_API_KEY is set, likhit automatically uses Gemini's OpenAI-compatible base URL unless you explicitly override OPENAI_BASE_URL.
Optional variables:
export MARKITDOWN_OCR_PROMPT="Custom OCR instructions"
Recipe: a vision model on your own machine
Ollama, llama.cpp and vLLM all serve an OpenAI-compatible endpoint, so no likhit
change is needed — and no page image leaves the machine:
ollama pull qwen2.5vl:7b
# Ollama ignores the key; the client refuses to start without one
export OPENAI_API_KEY="ollama"
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
export MARKITDOWN_OCR_MODEL="qwen2.5vl:7b"
likhit-save scanned-notice.pdf --out notice.md
Recipe: AWS Bedrock, through a gateway
Bedrock does not speak the OpenAI API, so put a translating proxy in front of it.
The proxy resolves AWS credentials the usual way — AWS_PROFILE, environment
variables or an instance role — and likhit only ever sees an OpenAI endpoint:
pip install 'litellm[proxy]'
litellm --model bedrock/anthropic.claude-sonnet-5
# whatever you configured the proxy to accept
export OPENAI_API_KEY="local-proxy-key"
export OPENAI_BASE_URL="http://127.0.0.1:4000/v1"
export MARKITDOWN_OCR_MODEL="bedrock/anthropic.claude-sonnet-5"
Any vision-capable model your account has enabled will do; swap the model id for one you have access to.
When OCR actually runs
likhit adds an OCR candidate only for pages whose text layer cannot serve them
(see Architecture below), so configuring a vision backend does
not mean paying for one on every document. In the published benchmark, 13 of 16
Government of Nepal documents are converted without a single vision call; only the
image-only scans need one.
If OCR is left unconfigured, likhit still converts. It logs that a page needed
OCR and returns the best result it could reach without it, rather than failing:
PDF converter: OCR appears necessary, but OCR is not configured.
Set OPENAI_API_KEY or GEMINI_API_KEY, plus MARKITDOWN_OCR_MODEL, to enable markitdown-ocr.
Architecture
The high-level PDF pipeline is:
- MarkItDown loads the plugin when
enable_plugins=Trueor--use-pluginsis used. - For PDF inputs,
likhitreads the file and optionally slices it to the requested page range. likhitscans embedded fonts. If it detects known Nepali repair fonts such as Kalimati broken-CMap fonts or legacy remap fonts, it tries the Nepal-specific extraction pipeline immediately.likhitalso runs the default MarkItDown PDF converter and keeps that result as a candidate.likhitanalyzes the PDF pages. If the file looks image-dominant with a suspicious text layer and OCR is configured, it adds an OCR candidate.- If the default Markdown output looks suspicious for Nepali text,
likhitretries extraction with its own PDF pipeline. - The Nepal-specific PDF pipeline can apply:
- Kalimati broken-CMap repair
- Devanagari reordering
- Devanagari spacing normalization
- Legacy-font remapping through
npttf2utf
- After extraction,
likhitchecks whether the document matches a whole-document semantic structure such as a single-column notice. - PDF layout ordering is assigned locally while assembling content blocks, so single-column, row-aligned, and two-column regions can coexist in one file.
- If multiple candidate outputs exist,
likhitscores them and returns the best one.
Project Layout
src/likhit/_plugin.py: MarkItDown plugin entry point and converter registrationsrc/likhit/converters/: plugin converters for PDF and legacy DOC inputssrc/likhit/nepali_pdf_repair.py: reusable Nepal-specific PDF repair layersrc/likhit/markdown_assembly.py: generic Markdown assembly for the default conversion pathsrc/likhit/extractors/: extraction strategies (PDF, DOC)font_based.py: PDF extraction with Nepali font repairdocx_based.py: legacy DOC text extraction
src/likhit/handlers/: structure-aware handlers and detection logicsrc/likhit/renderers/: Markdown renderingsrc/likhit/devanagari.py: Devanagari character classes and matra-damage patterns shared across the packagesrc/likhit/quality/: transcript quality axes, tree walker andlikhit-auditsrc/likhit/privacy/: personal-data signals, redaction passes, placeholder vocabulary andlikhit-redacttests/: conversion, extraction, and plugin coveragetests/integration/: end-to-end integration teststests/integration/test_data/: committed test fixtures (PDF, DOCX, DOC samples)
Development
likhit uses uv for dependency management, and the
rest of Astral's toolchain — ruff for linting and
formatting, ty for type checking.
Install the project together with its dev dependencies:
uv sync
Install the pre-commit hooks once per clone:
uv run pre-commit install
Testing
Run all tests:
uv run pytest # ~63s
uv run pytest -n auto # ~21s on 64 cores, ~31s on 4 — same results
-n auto (pytest-xdist) is not in addopts, because worker startup is a net
loss on the single-file runs that dominate local iteration — use plain pytest
for those. CI passes -n auto itself. pyproject.toml's
[tool.pytest.ini_options] comment carries the measured worker-count table.
Run only the end-to-end integration suite:
uv run pytest tests/integration
Read docs/writing-tests.md before adding one. likhit
reads adversarial input, and several classes of defect here are invisible to a suite
that merely passes — that file covers how to prove a test bites, and the
library-specific traps a test can be blind to.
The default suite is hermetic and should report executed tests only. Checks that need
private PDFs, unvendored fonts, paid OCR, or an adversarial external fixture are explicit
manual commands; their prerequisites and CI coverage are in
docs/writing-tests.md.
Gates
CI runs the following. Run them yourself before opening a pull request:
uv run ruff check . # lint — gated
uv run ruff format --check . # formatting — gated
uv run pytest -n auto # tests — gated (CI uses -n auto; plain works too)
uv run ty check # types — ADVISORY, see pyproject.toml
ty is advisory rather than a gate: it is pre-1.0, and several of this package's
dependencies (PyMuPDF among them) ship no type information, so the rule families
it cannot yet see through are silenced in pyproject.toml. Read its output; do
not ignore it. ty check reports a different number depending on the scope you give
it and on whether the worktree has a .venv — see
docs/writing-tests.md.
Before concluding a fix did or did not land in main, read
Checking whether a fix is actually in main
— git cherry and git patch-id are both insufficient here, in two independent ways.
References
- MarkItDown: https://github.com/microsoft/markitdown
- MarkItDown sample plugin: https://github.com/microsoft/markitdown/tree/main/packages/markitdown-sample-plugin
License
Licensed under the Hippocratic License 3.0, an Ethical Source license. See LICENSING.md for details.
Ownership
likhit is owned and maintained by Jawafdehi.
- Organization: Jawafdehi
- Website: https://jawafdehi.org/
- GitHub: https://github.com/Jawafdehi/likhit/
- Contact: inquiry@jawafdehi.org
Metadata
Release files for likhit 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| likhit-0.2.0.tar.gz | 427.1 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| likhit-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 872.5 kB
Release files / likhit-0.2.0.tar.gz
| Download URL | likhit-0.2.0.tar.gz |
|---|---|
| Size | 427.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
350d132c1d9a72fbb06ecaa3aeb787e9314bca260932c0426aa89098117a7945
|
|
BLAKE2b-256 checksum How to use checksums |
84eae095c0a8e827a97c904ebdb8b2a14d603ce48a95b23def427ba77ab1b82a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency logRelease files / likhit-0.2.0-py3-none-any.whl
| Download URL | likhit-0.2.0-py3-none-any.whl |
|---|---|
| Size | 445.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4e4093a9b553be996457e938d0d0019a2eb3bb18863d1a9b7c9abd3e62bf640b
|
|
BLAKE2b-256 checksum How to use checksums |
4947d942524dd1aeba3679ef8c72e42b644039de2c261229d8fc8066b55f31f2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency logSecurity for likhit 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for likhit-0.2.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.