arjuna-ocr 1.2.1__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.
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: arjuna-ocr
3
+ Version: 1.2.1
4
+ Summary: Arjuna-OCR [Kn&En]: layout-aware Kannada + English document OCR (ONNX Runtime, optional TensorRT)
5
+ Author: Chethan (anandkaman)
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://huggingface.co/anandkaman/arjuna-ocr
8
+ Project-URL: Models, https://huggingface.co/anandkaman/arjuna-ocr
9
+ Project-URL: Releases, https://huggingface.co/anandkaman/arjuna-ocr-kn-en
10
+ Project-URL: Demo, https://huggingface.co/spaces/anandkaman/arjuna_ocr_demo
11
+ Keywords: ocr,kannada,document-ai,layout-analysis,onnx,tensorrt,indic
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
18
+ Classifier: Topic :: Text Processing :: Linguistic
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: onnxruntime>=1.20
23
+ Requires-Dist: numpy
24
+ Requires-Dist: opencv-python-headless
25
+ Requires-Dist: pyclipper
26
+ Requires-Dist: shapely
27
+ Requires-Dist: pyyaml
28
+ Requires-Dist: regex
29
+ Requires-Dist: onnx
30
+ Requires-Dist: huggingface_hub>=0.23
31
+ Provides-Extra: gpu
32
+ Requires-Dist: onnxruntime-gpu>=1.20; extra == "gpu"
33
+ Provides-Extra: trt
34
+ Requires-Dist: onnxruntime-gpu>=1.20; extra == "trt"
35
+ Requires-Dist: tensorrt>=10.0; extra == "trt"
36
+ Provides-Extra: server
37
+ Requires-Dist: fastapi>=0.110; extra == "server"
38
+ Requires-Dist: uvicorn>=0.29; extra == "server"
39
+ Requires-Dist: python-multipart; extra == "server"
40
+ Provides-Extra: pdf
41
+ Requires-Dist: pypdfium2>=4.0; extra == "pdf"
42
+ Provides-Extra: cpu
43
+ Requires-Dist: onnxruntime>=1.20; extra == "cpu"
44
+ Provides-Extra: all
45
+ Requires-Dist: onnxruntime-gpu>=1.20; extra == "all"
46
+ Requires-Dist: tensorrt>=10.0; extra == "all"
47
+ Requires-Dist: fastapi>=0.110; extra == "all"
48
+ Requires-Dist: uvicorn>=0.29; extra == "all"
49
+ Requires-Dist: python-multipart; extra == "all"
50
+ Requires-Dist: pypdfium2>=4.0; extra == "all"
51
+ Dynamic: license-file
52
+
53
+ # Arjuna-OCR [Kn&En]
54
+
55
+ Layout-aware **Kannada + English** document OCR: a layout model, a text detector and a CTC recognizer in ONNX, with
56
+ tables, reading order, per-line confidence and review flags. No language model, no GPU required.
57
+
58
+ ```bash
59
+ pip install arjuna-ocr # CPU, works anywhere
60
+
61
+
62
+ arjuna doctor # what this machine can do and what the pipeline will use
63
+ arjuna ocr page.pdf -o out/ # files, folders, globs or PDFs -> JSON / Markdown / hOCR / ALTO / TSV
64
+ arjuna serve --port 8080 # HTTP server
65
+ ```
66
+
67
+ ```python
68
+ from kanen_infer import KanEnOCR
69
+ ocr = KanEnOCR() # detects hardware, configures itself
70
+ doc = ocr.page("page.png") # kanen-ocr/1.0 JSON
71
+ print(ocr.render(doc, "md"))
72
+ ```
73
+
74
+ The models (~170 MB) download once from [anandkaman/arjuna-ocr](https://huggingface.co/anandkaman/arjuna-ocr) at the
75
+ revision matching this package version, and are cached. Point `ARJUNA_MODELS` at a local copy for air-gapped installs.
76
+
77
+ **Speed and accuracy.** 12.8 pages/s on an RTX 5060 Ti with TensorRT, ~4.6 on the CUDA provider, seconds per page on CPU.
78
+ On a frozen 40-page private-document set: word F1 0.967, page CER 3.1 %; 96.3 % word accuracy on Mozhi, 93.7 % on MILE.
79
+ TensorRT engines are built in the background on first use — the pipeline serves immediately on CUDA and swaps each engine
80
+ in only after it reproduces the output of the session it replaces.
81
+
82
+ Full documentation, hosted-inference instructions and the evaluation record: the model repositories linked above.
83
+ **Licences**: code, recognizer and detector Apache-2.0; the layout model is AGPL-3.0 (see its NOTICE).
@@ -0,0 +1,22 @@
1
+ arjuna_ocr-1.2.1.dist-info/licenses/LICENSE,sha256=kSUONfbMTSXdruS_p0-W_SeqHvGH6dVTgENFs2Od-Is,175
2
+ kanen_infer/__init__.py,sha256=ZArJb1qqZlvo_YhX-qqwUonRvhLevMWoiA9aS1fEYiw,800
3
+ kanen_infer/api.py,sha256=FLu6mS8_zgWChuXToeVzuF9pqXJ-UFI1pbKYOn8eHU8,7801
4
+ kanen_infer/arjuna_cli.py,sha256=9zew3ZrazqLH_X1VX_eWLqJ6u8i9cdc-JN8mRBNuIa4,7047
5
+ kanen_infer/assemble.py,sha256=7ka81_ajbx1MQu5oYCifm5aO86NzwBWGGwX56xZiASo,18787
6
+ kanen_infer/batch_pipeline.py,sha256=x3_bJpidJqEPfq39r1dhzfDovfsw-Zjr-o8SoxB3hFU,17855
7
+ kanen_infer/charset.py,sha256=k8cFqDWlYcmrCOH9n5IAR-bAEx_VKy04_ZSRBkbnE-Q,3468
8
+ kanen_infer/cli.py,sha256=m7_OcXUL7rLqiq17dSYd4ocU6QqA8R_DWL38LcoTMqo,5318
9
+ kanen_infer/config.py,sha256=lIYv7RmrOUy77w66zpiOPELW31yzbzoJNLEdyC5NHeI,5906
10
+ kanen_infer/formats.py,sha256=9TK4MrsGM3lRlIaWAOYwXI1_O4-85DrE7kcce4mQ52Q,5493
11
+ kanen_infer/geometry.py,sha256=4_QCbMxlaK0cq2RkJVIWu3vVUFiHmnkG2vd79-6eSX4,833
12
+ kanen_infer/hardware.py,sha256=SjnnivDjY7VLQNZQd4MTZLfHzkDbK7vluEXPKP68mGM,11113
13
+ kanen_infer/io.py,sha256=ozEicuwW3BBxg95oLojmT4YL-SFD7nsOQO-rKoMtdcQ,3777
14
+ kanen_infer/models.py,sha256=CC4B6Fc8EMrauPJ9cyz0-vMPgz7Bm7SOM2zWn8Kfew4,2907
15
+ kanen_infer/pipeline_base.py,sha256=dehRXLg9tyHSwHB8yEo-smLn67DrSb2FtMyd__BBHbo,18089
16
+ kanen_infer/providers.py,sha256=ergf0-l-25qxBsWFwrZzRVYsr6bRcq03jz7GUv2ciAY,15773
17
+ kanen_infer/rec_onnx.py,sha256=XMaKAyPHdDFEKoU9eT9b0Bj6lwy51GauGWyDBiqyc90,5435
18
+ arjuna_ocr-1.2.1.dist-info/METADATA,sha256=UTPRhcvTAD9ZNG0C39RfCXrHNLrHUTUtclTunmBzsJQ,3713
19
+ arjuna_ocr-1.2.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
20
+ arjuna_ocr-1.2.1.dist-info/entry_points.txt,sha256=NxHPJ_pJafnXexiIWrjUkLOgrXpxkogV7Ver1hDiajM,122
21
+ arjuna_ocr-1.2.1.dist-info/top_level.txt,sha256=1L1zgtkQPi2eDHA4MRpJIXVvWDYXg3NqhE-b9yh6tRI,12
22
+ arjuna_ocr-1.2.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ arjuna = kanen_infer.arjuna_cli:main
3
+ arjuna-ocr = kanen_infer.cli:main
4
+ kanen-ocr = kanen_infer.cli:main
@@ -0,0 +1,3 @@
1
+ Apache License 2.0 — see https://www.apache.org/licenses/LICENSE-2.0
2
+
3
+ Copyright 2026 Chethan (anandkaman). Base models: PaddleOCR (Apache-2.0), OpenOCR SVTRv2 (Apache-2.0).
@@ -0,0 +1 @@
1
+ kanen_infer
@@ -0,0 +1,9 @@
1
+ """Arjuna-OCR [Kn&En] inference package (Kannada + English document OCR): ONNX Runtime + optional TensorRT, no PyTorch."""
2
+ from pathlib import Path as _P
3
+ try: __version__ = (_P(__file__).resolve().parent.parent / "VERSION").read_text(encoding="utf-8").strip()
4
+ except Exception: __version__ = "1.2.1"
5
+ NAME = "Arjuna-OCR [Kn&En]" # product display name — also `name:` in kanen.yaml (the config value wins at run time)
6
+ from .config import load_config, PKG_ROOT # noqa: E402
7
+ from .api import KanEnOCR # noqa: E402
8
+ from .formats import to_json, to_markdown, to_text, to_hocr, to_alto, to_tsv, render # noqa: E402
9
+ __all__ = ["KanEnOCR", "load_config", "PKG_ROOT", "NAME", "__version__", "to_json", "to_markdown", "to_text", "to_hocr", "to_alto", "to_tsv", "render"]
kanen_infer/api.py ADDED
@@ -0,0 +1,119 @@
1
+ """Arjuna-OCR [Kn&En] Python API.
2
+
3
+ from kanen_infer import KanEnOCR
4
+ ocr = KanEnOCR() # loads kanen.yaml, builds/validates engines (warm-up), see ocr.ready()
5
+ doc = ocr.page("scan.png") # one kanen-ocr/1.0 document (dict)
6
+ docs = ocr.pages(["a.png", "b.pdf"]) # batched; ocr.stream(...) yields documents as they finish
7
+ boxes = ocr.detect(img) # text-line polygons only
8
+ texts = ocr.recognize(crops) # [(text, conf, min_conf)] for line images
9
+ blocks = ocr.layout(img) # layout blocks only
10
+ ocr.render(doc, "hocr") # json | md | txt | hocr | alto | tsv
11
+
12
+ Inputs: file path, bytes, numpy BGR array, PIL image, file object; PDFs through `pages()`/`stream()` (pypdfium2 or pdftoppm,
13
+ optional). The output document never depends on the provider or the batch composition beyond fp16 noise on garbage lines;
14
+ no confidence filtering, no text rewriting, no re-ordering happens here."""
15
+ import logging, time
16
+ import numpy as np, cv2
17
+ from pathlib import Path
18
+ from .config import load_config, PKG_ROOT
19
+ from .providers import Providers
20
+ from .rec_onnx import KanEnRecognizer
21
+ from .pipeline_base import Detector, Layout, crop
22
+ from .batch_pipeline import BatchOCR
23
+ from .io import to_bgr, expand_inputs, iter_page_sources
24
+ from . import formats
25
+
26
+ LOG = logging.getLogger("kanen")
27
+
28
+ class KanEnOCR:
29
+ def __init__(self, config=None, providers=None, layout_mode=None, workers=None, warmup=True, **overrides):
30
+ """config: path to a kanen.yaml/json, a dict, or None (package default). providers: 'trt' | 'cuda' | 'cpu' | 'auto'
31
+ (overrides config provider.policy). Other keyword overrides use dotted section names, e.g. layout={'mode': 'auto'}."""
32
+ ov = dict(overrides)
33
+ if providers: ov.setdefault("provider", {})["policy"] = providers
34
+ if layout_mode: ov.setdefault("layout", {})["mode"] = layout_mode
35
+ if workers: ov.setdefault("runtime", {})["workers"] = workers
36
+ self.cfg = load_config(config, ov); self.name = self.cfg.get("name", "Arjuna-OCR [Kn&En]"); self.version = str(self.cfg.get("version", "1.0.0"))
37
+ t0 = time.time(); self.prov = Providers(self.cfg)
38
+ self.rec = KanEnRecognizer(cfg=self.cfg, providers=self.prov); self.det = Detector(self.cfg, self.prov)
39
+ self.lay = Layout(self.cfg, self.prov) if self.cfg["layout"].get("mode", "always") != "never" else None
40
+ self.batch = BatchOCR(self.rec, cfg=self.cfg, providers=self.prov, det=self.det, lay=self.lay)
41
+ self._load_s = round(time.time() - t0, 1); self._warm = None
42
+ if warmup: self.warmup()
43
+
44
+ # ---- status --------------------------------------------------------------------------------------------------
45
+ def warmup(self):
46
+ """Run the bundled self-test page through the full pipeline once: builds/validates TensorRT engines for the
47
+ default shapes (layout self-test, detector canvas, recognizer profile) so the first real page is not slow."""
48
+ t0 = time.time(); p = self.cfg.path("models", "layout_selftest")
49
+ if p.exists():
50
+ img = to_bgr(str(p)); self.batch.run([img], page_batch=1, loader=lambda x: x)
51
+ self._warm = round(time.time() - t0, 1); return self.ready()
52
+
53
+ def optimize(self, wait=False, timeout=None):
54
+ """Build the TensorRT engines for this machine in the background and swap them in once each one is validated
55
+ against the session it replaces. Safe to call at any time; returns immediately unless `wait` is set.
56
+ Long-lived processes (server, endpoint handler, batch jobs) should call this at startup: the pipeline keeps
57
+ serving on the CUDA provider meanwhile, and gets 2-3x faster when the engines are ready."""
58
+ n = self.prov.start_background_builds() if hasattr(self.prov, "start_background_builds") else 0
59
+ if wait and n: return self.prov.wait_for_engines(timeout)
60
+ return {"builds_started": n}
61
+
62
+ def ready(self):
63
+ """Provider each model ended on, fallbacks that happened, engine-cache state, model hashes."""
64
+ st = self.prov.summary()
65
+ if self.lay is not None: st.setdefault("layout", {})["engine"] = self.lay.stage
66
+ return {"name": self.name, "version": self.version, "config": self.cfg.source, "gpu": self.prov.gpu, "tensorrt": self.prov.trt_ver,
67
+ "providers": st, "events": list(self.prov.events), "engine_cache": str(self.prov.cache_root), "load_s": self._load_s, "warmup_s": self._warm,
68
+ "model": dict(self.batch.model_ids)}
69
+
70
+ # ---- full documents -------------------------------------------------------------------------------------------
71
+ def page(self, image, polygons=None, dpi=None):
72
+ """Single page (latency path): one kanen-ocr/1.0 document."""
73
+ img = to_bgr(image); docs, _ = self.batch.process([img], dpi=dpi, with_polygons=self._poly(polygons)); d = docs[0]
74
+ if isinstance(image, (str, Path)): d["page"]["file"] = Path(image).name
75
+ return d
76
+
77
+ def pages(self, inputs, page_batch=None, polygons=None, pdf_dpi=200):
78
+ """Batched: list/dir/glob of image or PDF paths, or a list of arrays / PIL images / bytes -> list of documents."""
79
+ return [d for d in self.stream(inputs, page_batch, polygons, pdf_dpi)]
80
+
81
+ def stream(self, inputs, page_batch=None, polygons=None, pdf_dpi=200):
82
+ """Generator variant of pages(): yields documents in input order as each page batch completes."""
83
+ sources = self._sources(inputs, pdf_dpi); labels = [s[0] for s in sources]; items = [s[1] for s in sources]
84
+ page_batch = page_batch or self.cfg["runtime"].get("page_batch", 8); k = 0
85
+ for doc, st in self.batch.stream(items, page_batch=page_batch, loader=to_bgr, with_polygons=self._poly(polygons)):
86
+ if labels[k]: doc["page"]["file"] = labels[k]
87
+ self.last_stats = st; k += 1; yield doc
88
+
89
+ def _sources(self, inputs, pdf_dpi):
90
+ if isinstance(inputs, (str, Path)) or (isinstance(inputs, (list, tuple)) and inputs and all(isinstance(x, (str, Path)) for x in inputs)):
91
+ return list(iter_page_sources(expand_inputs(inputs), dpi=pdf_dpi))
92
+ if isinstance(inputs, (list, tuple)): return [(getattr(x, "name", None) or "", x) for x in inputs]
93
+ return [("", inputs)]
94
+
95
+ def _poly(self, polygons): return bool(self.cfg["runtime"].get("polygons", False)) if polygons is None else bool(polygons)
96
+
97
+ # ---- single stages --------------------------------------------------------------------------------------------
98
+ def detect(self, image):
99
+ """Text-line detection only -> list of (polygon 4x2 float32 in page pixels, score)."""
100
+ return self.det(to_bgr(image))
101
+
102
+ def recognize(self, crops, batch=None):
103
+ """Recognition only on a list of line images (any size, BGR or gray) -> [(text, confidence, min_conf)]."""
104
+ res = self.rec([to_bgr(c) for c in crops], bs=batch)
105
+ return [(t, float(c), float(getattr(c, "min_char", c))) for t, c in res]
106
+
107
+ def layout(self, image):
108
+ """Layout blocks only -> [{type, score, bbox, order}] (labels: title, text, table, figure, seal, id_panel, header, footer, handwriting)."""
109
+ if self.lay is None: self.lay = Layout(self.cfg, self.prov); self.batch.lay = self.lay
110
+ return self.lay(to_bgr(image))[0]
111
+
112
+ def crops(self, image, boxes=None):
113
+ """Perspective line crops for detect() boxes (helper for recognize())."""
114
+ img = to_bgr(image); boxes = self.detect(img) if boxes is None else boxes
115
+ return [crop(img, b) for b, _ in boxes]
116
+
117
+ # ---- serialisation --------------------------------------------------------------------------------------------
118
+ def render(self, doc, fmt="json"):
119
+ return formats.render(doc, fmt)
@@ -0,0 +1,111 @@
1
+ """`arjuna` command line — the deployment front end.
2
+
3
+ arjuna doctor what this machine can do, what the pipeline will use, and why
4
+ arjuna ocr page.pdf -o out/ OCR files, directories, globs or PDFs (same engine as kanen-ocr)
5
+ arjuna serve --port 8080 HTTP server (FastAPI); builds TensorRT engines in the background
6
+ arjuna bench measured pages/s on the bundled sample pages, before and after optimisation
7
+ arjuna warm build and validate the TensorRT engines for this machine, then exit
8
+
9
+ `ocr` stays on the CUDA/CPU provider for small jobs (a TensorRT build costs more than it saves for a page or two) and
10
+ optimises automatically from `--optimize-after` pages upwards. `serve`, `bench` and `warm` always optimise.
11
+ """
12
+ import argparse, json, os, sys, time
13
+
14
+ def _cfg(args):
15
+ from .config import load_config
16
+ from . import hardware
17
+ cfg = load_config(args.config)
18
+ if getattr(args, "provider", None): cfg["provider"]["policy"] = args.provider
19
+ changes = [] if getattr(args, "no_autoconfig", False) else hardware.autoconfig(cfg, quality=getattr(args, "quality", None))
20
+ return cfg, changes
21
+
22
+ def cmd_doctor(a):
23
+ from . import hardware
24
+ cfg, changes = _cfg(a)
25
+ hw = hardware.probe(cfg["provider"].get("policy")); print(hardware.report(cfg, changes, hw))
26
+ if a.run:
27
+ from .api import KanEnOCR
28
+ import cv2, glob
29
+ pages = sorted(glob.glob(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "samples", "pages", "*")))[:1]
30
+ if pages:
31
+ t = time.time(); ocr = KanEnOCR(config=cfg, warmup=False); load = time.time() - t
32
+ t = time.time(); doc = ocr.page(cv2.imread(pages[0])); dt = time.time() - t
33
+ lines = sum(len(b["lines"]) for b in doc["blocks"])
34
+ print(f"\n load {load:.1f}s\n sample page {dt:.1f}s, {lines} lines, {len(doc['blocks'])} blocks ({os.path.basename(pages[0])})")
35
+ print(f" providers {json.dumps({k: v['provider'] for k, v in ocr.prov.status.items() if k in ('det', 'rec', 'layout')})}")
36
+ for e in ocr.prov.events[:6]: print(f" {e}")
37
+ return 0
38
+
39
+ def cmd_warm(a):
40
+ from .api import KanEnOCR
41
+ cfg, changes = _cfg(a)
42
+ print("building TensorRT engines for this machine (this is the one slow step; it is cached afterwards)...", flush=True)
43
+ t = time.time(); ocr = KanEnOCR(config=cfg, warmup=True)
44
+ res = ocr.optimize(wait=True, timeout=a.timeout)
45
+ print(f"done in {time.time() - t:.0f}s: {json.dumps(res)}")
46
+ print("providers:", json.dumps({k: v["provider"] for k, v in ocr.prov.status.items() if k in ("det", "rec", "layout")}))
47
+ return 0
48
+
49
+ def cmd_bench(a):
50
+ import cv2, glob
51
+ from .api import KanEnOCR
52
+ cfg, changes = _cfg(a)
53
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
54
+ pages = sorted(glob.glob(os.path.join(root, "samples", "pages", "*")))
55
+ if not pages: print("no sample pages bundled"); return 1
56
+ imgs = [cv2.imread(p) for p in pages] * max(1, a.repeat)
57
+ ocr = KanEnOCR(config=cfg, warmup=True)
58
+ def run(tag):
59
+ t = time.time(); docs = ocr.pages(imgs); dt = time.time() - t
60
+ print(f" {tag:<22} {len(imgs)/dt:5.2f} pages/s ({dt:.1f}s for {len(imgs)} pages, {sum(len(b['lines']) for d in docs for b in d['blocks'])} lines)")
61
+ return len(imgs) / dt
62
+ print(f"pages: {len(imgs)} · providers before: {json.dumps({k: v['provider'] for k, v in ocr.prov.status.items() if k in ('det','rec','layout')})}")
63
+ run("as loaded")
64
+ ocr.optimize(wait=True, timeout=a.timeout)
65
+ print(f"providers after: {json.dumps({k: v['provider'] for k, v in ocr.prov.status.items() if k in ('det','rec','layout')})}")
66
+ run("after optimisation"); run("steady state")
67
+ return 0
68
+
69
+ def cmd_serve(a):
70
+ import importlib.util
71
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
72
+ spec = importlib.util.spec_from_file_location("kanen_serve", os.path.join(root, "serve.py"))
73
+ mod = importlib.util.module_from_spec(spec); sys.modules["kanen_serve"] = mod; spec.loader.exec_module(mod)
74
+ return mod.serve(a)
75
+
76
+ def cmd_ocr(a):
77
+ from .cli import main as kanen_main
78
+ argv = ["--input"] + a.inputs + ["--out-dir", a.out_dir, "--format", a.format]
79
+ for flag, val in (("--page-batch", a.page_batch), ("--workers", a.workers), ("--provider", a.provider), ("--config", a.config)):
80
+ if val: argv += [flag, str(val)]
81
+ if a.json_lines: argv.append("--json-lines")
82
+ if a.quiet: argv.append("--quiet")
83
+ os.environ["KANEN_OPTIMIZE_AFTER"] = str(a.optimize_after)
84
+ return kanen_main(argv)
85
+
86
+ def build_parser():
87
+ ap = argparse.ArgumentParser(prog="arjuna", description="Arjuna-OCR [Kn&En] — Kannada + English document OCR")
88
+ ap.add_argument("--version", action="store_true")
89
+ sub = ap.add_subparsers(dest="cmd")
90
+ common = lambda p: (p.add_argument("--config", default=None), p.add_argument("--provider", choices=["auto", "trt", "cuda", "cpu"], default=None),
91
+ p.add_argument("--quality", choices=["fast", "balanced", "exact"], default=None), p.add_argument("--no-autoconfig", action="store_true"))
92
+ d = sub.add_parser("doctor", help="hardware, providers, auto-configuration"); common(d); d.add_argument("--run", action="store_true", help="also OCR one bundled page"); d.set_defaults(fn=cmd_doctor)
93
+ w = sub.add_parser("warm", help="build + validate the TensorRT engines, then exit"); common(w); w.add_argument("--timeout", type=int, default=1800); w.set_defaults(fn=cmd_warm)
94
+ b = sub.add_parser("bench", help="measured pages/s on the bundled pages"); common(b); b.add_argument("--repeat", type=int, default=4); b.add_argument("--timeout", type=int, default=1800); b.set_defaults(fn=cmd_bench)
95
+ s = sub.add_parser("serve", help="HTTP server"); common(s); s.add_argument("--host", default="0.0.0.0"); s.add_argument("--port", type=int, default=8080)
96
+ s.add_argument("--workers", type=int, default=None); s.add_argument("--layout-mode", choices=["always", "auto", "never"], default=None); s.set_defaults(fn=cmd_serve)
97
+ o = sub.add_parser("ocr", help="OCR files / directories / globs / PDFs"); common(o)
98
+ o.add_argument("inputs", nargs="+"); o.add_argument("--out-dir", "-o", default="arjuna_out"); o.add_argument("--format", "-f", default="json")
99
+ o.add_argument("--page-batch", type=int, default=None); o.add_argument("--workers", type=int, default=None); o.add_argument("--json-lines", action="store_true")
100
+ o.add_argument("--quiet", "-q", action="store_true"); o.add_argument("--optimize-after", type=int, default=8, help="build TensorRT engines when a job has at least this many pages")
101
+ o.set_defaults(fn=cmd_ocr)
102
+ return ap
103
+
104
+ def main(argv=None):
105
+ a = build_parser().parse_args(argv)
106
+ if a.version:
107
+ from . import __version__; print(__version__); return 0
108
+ if not getattr(a, "fn", None): build_parser().print_help(); return 0
109
+ return a.fn(a)
110
+
111
+ if __name__ == "__main__": sys.exit(main())
@@ -0,0 +1,261 @@
1
+ """Assembler: layout blocks (Arjuna layout v12) + text-line polygons + recognised text -> kanen-ocr/1.0 JSON.
2
+ Pure geometry, no model. Also: Markdown / plain-text renderers."""
3
+ import numpy as np
4
+ from .charset import script_of
5
+ import re
6
+ WEAK_CHAR = 0.50 # min-char confidence below which a line is flagged weak_char (calibrated on 3,478 dev lines: < 0.5 -> 65 % exact)
7
+ _MIX_RE = re.compile(r"(?=\S*[A-Za-z])(?=\S*[\u0C85-\u0CB9])\S+") # token with both a Latin letter and a Kannada letter
8
+
9
+ TYPE_MAP = {"doc_title": "title", "paragraph_title": "section_title", "text": "text", "abstract": "text", "content": "text", "reference": "text",
10
+ "reference_content": "text", "aside_text": "text", "vertical_text": "text", "footnote": "footnote", "vision_footnote": "footnote",
11
+ "header": "header", "footer": "footer", "number": "page_number", "table": "table", "image": "figure", "chart": "figure",
12
+ "figure_title": "figure_caption", "seal": "seal", "display_formula": "formula", "inline_formula": "formula", "formula_number": "formula",
13
+ "algorithm": "text", "header_image": "figure", "footer_image": "figure",
14
+ "title": "title", "figure": "figure", "id_panel": "id_panel", "handwriting": "handwriting"} # v1.2 layout classes (text/table/seal/header/footer map 1:1 above)
15
+
16
+ def _bbox(poly): p = np.asarray(poly, np.float32); return [float(p[:, 0].min()), float(p[:, 1].min()), float(p[:, 0].max()), float(p[:, 1].max())]
17
+
18
+ def _overlap_frac(line_bb, block_bb):
19
+ ix = max(0, min(line_bb[2], block_bb[2]) - max(line_bb[0], block_bb[0])); iy = max(0, min(line_bb[3], block_bb[3]) - max(line_bb[1], block_bb[1]))
20
+ return ix * iy / max((line_bb[2] - line_bb[0]) * (line_bb[3] - line_bb[1]), 1e-6)
21
+
22
+ def merge_line_fragments(lines, y_tol=0.5, x_gap=1.5):
23
+ """Merge horizontally adjacent boxes on the same baseline (detector sometimes splits a line at wide gaps).
24
+ lines: list of dict(bbox, polygon, text, conf). Returns merged list."""
25
+ lines = sorted(lines, key=lambda l: (l["bbox"][1], l["bbox"][0])); out = []
26
+ for l in lines:
27
+ merged = False
28
+ for m in out:
29
+ h = min(m["bbox"][3] - m["bbox"][1], l["bbox"][3] - l["bbox"][1])
30
+ same_row = abs((m["bbox"][1] + m["bbox"][3]) / 2 - (l["bbox"][1] + l["bbox"][3]) / 2) < y_tol * h
31
+ gap = l["bbox"][0] - m["bbox"][2]
32
+ if same_row and -0.2 * h < gap < x_gap * h:
33
+ m["bbox"] = [min(m["bbox"][0], l["bbox"][0]), min(m["bbox"][1], l["bbox"][1]), max(m["bbox"][2], l["bbox"][2]), max(m["bbox"][3], l["bbox"][3])]
34
+ m["text"] = (m["text"] + " " + l["text"]).strip(); m["confidence"] = min(m["confidence"], l["confidence"]); m["min_conf"] = min(m.get("min_conf", 1), l.get("min_conf", 1)); m["polygon"] = None; merged = True; break
35
+ if not merged: out.append(dict(l))
36
+ return out
37
+
38
+ def xy_cut_order(blocks, page_w):
39
+ """Fallback reading order: column split then top-to-bottom."""
40
+ if not blocks: return []
41
+ mid = page_w / 2
42
+ left = [b for b in blocks if b["bbox"][2] < mid * 1.05]; right = [b for b in blocks if b["bbox"][0] > mid * 0.95]
43
+ spanning = [b for b in blocks if b not in left and b not in right]
44
+ if len(left) >= 2 and len(right) >= 2 and not spanning:
45
+ return sorted(left, key=lambda b: b["bbox"][1]) + sorted(right, key=lambda b: b["bbox"][1])
46
+ return sorted(blocks, key=lambda b: (round(b["bbox"][1] / 20), b["bbox"][0]))
47
+
48
+ def rows_of(lines, tol=0.5):
49
+ """Cluster line boxes into visual rows (y-centre within tol x the smaller height), left-to-right inside a row."""
50
+ ls = sorted(lines, key=lambda l: (l["bbox"][1] + l["bbox"][3]) / 2); rows = []
51
+ for l in ls:
52
+ yc = (l["bbox"][1] + l["bbox"][3]) / 2; h = l["bbox"][3] - l["bbox"][1]
53
+ if rows:
54
+ r = rows[-1]; ryc = np.mean([(m["bbox"][1] + m["bbox"][3]) / 2 for m in r]); rh = np.median([m["bbox"][3] - m["bbox"][1] for m in r])
55
+ if abs(yc - ryc) < tol * min(h, rh): r.append(l); continue
56
+ rows.append([l])
57
+ return [sorted(r, key=lambda l: l["bbox"][0]) for r in rows]
58
+
59
+ def split_row_by_gap(r, max_gap_h=2.5):
60
+ """Split a visual row into runs where the horizontal gap between neighbours exceeds max_gap_h x line height: two
61
+ columns (Kannada | English on ID cards, label | value pairs far apart) must stay separate lines; fragments of one
62
+ justified/spaced line (gaps of a few glyph widths) still join. Geometry only."""
63
+ r = sorted(r, key=lambda l: l["bbox"][0]); runs = [[r[0]]]
64
+ for l in r[1:]:
65
+ prev = runs[-1][-1]; h = max(8.0, min(prev["bbox"][3] - prev["bbox"][1], l["bbox"][3] - l["bbox"][1]))
66
+ if l["bbox"][0] - prev["bbox"][2] > max_gap_h * h: runs.append([l])
67
+ else: runs[-1].append(l)
68
+ return runs
69
+
70
+ def join_row(r):
71
+ """One text line per visual row: fragments of a justified/spaced line are joined with single spaces."""
72
+ if len(r) == 1: return dict(r[0])
73
+ return {"bbox": [min(l["bbox"][0] for l in r), min(l["bbox"][1] for l in r), max(l["bbox"][2] for l in r), max(l["bbox"][3] for l in r)],
74
+ "polygon": None, "text": " ".join(l["text"] for l in r).strip(), "confidence": min(l["confidence"] for l in r), "min_conf": min(l.get("min_conf", 1) for l in r)}
75
+
76
+ def _runs(mask1d, min_gap=3):
77
+ idx = np.flatnonzero(mask1d)
78
+ if idx.size == 0: return []
79
+ out = []; start = idx[0]; prev = idx[0]
80
+ for i in idx[1:]:
81
+ if i - prev > min_gap: out.append((start + prev) / 2); start = i
82
+ prev = i
83
+ out.append((start + prev) / 2); return out
84
+
85
+ def _rule_positions(mask, axis, min_len):
86
+ """Centre coordinate of every connected component of a rule mask longer than min_len along `axis` (robust to residual tilt)."""
87
+ import cv2
88
+ n, _, st, cen = cv2.connectedComponentsWithStats(mask, connectivity=8)
89
+ pos = sorted(cen[i][1 - axis] for i in range(1, n) if st[i][cv2.CC_STAT_WIDTH if axis == 0 else cv2.CC_STAT_HEIGHT] >= min_len)
90
+ out = []
91
+ for p in pos:
92
+ if out and p - out[-1] < 6: out[-1] = (out[-1] + p) / 2
93
+ else: out.append(p)
94
+ return out
95
+
96
+ def _merge_continuations(rows):
97
+ """A row whose cells all sit under occupied columns of the previous row and none in its first column is a wrapped cell."""
98
+ out = []
99
+ for r in rows:
100
+ ne = [c for c in r["cells"] if c["text"]]
101
+ if out and ne:
102
+ prev = out[-1]; pne = [c for c in prev["cells"] if c["text"]]
103
+ first = min(c["col"] for c in prev["cells"]) if prev["cells"] else 0
104
+ if all(c["col"] > first for c in ne) and all(any(p["col"] == c["col"] for p in pne) for c in ne):
105
+ for c in ne:
106
+ p = next(p for p in pne if p["col"] == c["col"]); p["text"] = (p["text"] + " " + c["text"]).strip()
107
+ p["bbox"] = [min(p["bbox"][0], c["bbox"][0]), min(p["bbox"][1], c["bbox"][1]), max(p["bbox"][2], c["bbox"][2]), max(p["bbox"][3], c["bbox"][3])]
108
+ continue
109
+ out.append(r)
110
+ return out
111
+
112
+ def ruled_grid(gray, bbox, lines):
113
+ """Table structure from printed ruling lines (morphological opening + connected components). Horizontal rules give row
114
+ bands; inside a band, vertical rules give cells - or, when a band has no vertical rules (aligned-column forms), each
115
+ visual row of the band becomes a table row whose cells are the separate line boxes. Returns
116
+ rows=[{cells:[{col,text,bbox,confidence}], ruled}] or None when the region has no horizontal rules. Pure geometry."""
117
+ import cv2
118
+ x0, y0, x1, y1 = [int(round(v)) for v in bbox]; reg = gray[max(0, y0):y1, max(0, x0):x1]
119
+ if reg.size == 0 or reg.shape[0] < 20 or reg.shape[1] < 20: return None
120
+ H, W = reg.shape; bw = cv2.adaptiveThreshold(reg, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY_INV, 31, 9)
121
+ hor = cv2.morphologyEx(bw, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (max(20, W // 30), 1)))
122
+ hor = cv2.dilate(hor, cv2.getStructuringElement(cv2.MORPH_RECT, (max(20, W // 8), 1))) # bridge faint / dashed rules (text-derived runs are rejected below)
123
+ loc = [(l["bbox"][0] - x0, l["bbox"][1] - y0, l["bbox"][2] - x0, l["bbox"][3] - y0) for l in lines]
124
+ def through_text(y): return any(b + 0.1 * (d - b) < y < d - 0.1 * (d - b) for a, b, c, d in loc) # a rule never crosses the middle of a text line
125
+ def through_text_x(x, ia, ib): return any(a + 0.1 * (c - a) < x < c - 0.1 * (c - a) and b < ib and d > ia for a, b, c, d in loc)
126
+ hr = [y for y in _rule_positions(hor, 0, 0.25 * W) if 0.01 * H + 4 < y < H - 0.01 * H - 4 and not through_text(y)]
127
+ if not hr: return None
128
+ ys = [0.0] + hr + [float(H)]; bands = []
129
+ for a, b in zip(ys[:-1], ys[1:]):
130
+ ia, ib = int(a), int(b)
131
+ if ib - ia < 8: continue
132
+ ver = cv2.morphologyEx(bw[ia:ib], cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(8, int((ib - ia) * 0.4)))))
133
+ ver = cv2.dilate(ver, cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(6, int((ib - ia) * 0.25)))))
134
+ vr = [x for x in _rule_positions(ver, 1, 0.85 * (ib - ia)) if 0.03 * W < x < 0.97 * W and not through_text_x(x, ia, ib)] # cell borders span the band, never cross text; table border excluded
135
+ bands.append((ia, ib, vr))
136
+ allx = sorted(x for _, _, vr in bands for x in vr); G = []
137
+ for x in allx:
138
+ if not G or x - G[-1][-1] > 12: G.append([x])
139
+ else: G[-1].append(x)
140
+ G = [float(np.mean(g)) for g in G]
141
+ def gcol(x): return sum(1 for g in G if g < x + 6)
142
+ rows = []
143
+ for ia, ib, vr in bands:
144
+ inband = [l for l in lines if ia <= (l["bbox"][1] + l["bbox"][3]) / 2 - y0 < ib]
145
+ if not inband: continue
146
+ if vr: # ruled cells: everything between two vertical rules is one cell (a wrapped cell keeps its lines together);
147
+ # the global rule set is used so a band whose faint rule was missed still gets the table's columns
148
+ xs = [0.0] + G + [float(W)]
149
+ percol = [[l for l in inband if ca <= (l["bbox"][0] + l["bbox"][2]) / 2 - x0 < cb] for ca, cb in zip(xs[:-1], xs[1:])]
150
+ multi = sum(1 for inc in percol if len(rows_of(inc)) > 1)
151
+ # several columns stacked -> the band holds several table rows whose separating rule was not printed/found
152
+ groups = rows_of(inband) if multi >= 2 else [inband]
153
+ for g in groups:
154
+ cells = []
155
+ for ca, cb in zip(xs[:-1], xs[1:]):
156
+ inc = [l for l in g if ca <= (l["bbox"][0] + l["bbox"][2]) / 2 - x0 < cb]
157
+ if not inc: cells.append({"col": gcol(ca), "text": "", "bbox": None, "confidence": None}); continue
158
+ cells.append({"col": gcol(ca), "text": " ".join(join_row(r)["text"] for r in rows_of(inc)), "bbox": [min(l["bbox"][0] for l in inc), min(l["bbox"][1] for l in inc), max(l["bbox"][2] for l in inc), max(l["bbox"][3] for l in inc)], "confidence": min(l["confidence"] for l in inc)})
159
+ rows.append({"cells": cells, "ruled": True})
160
+ if len(groups) > 1: rows[-len(groups):] = _merge_continuations(rows[-len(groups):])
161
+ else: # no vertical rules in this band: each visual row is a table row, each line box a cell
162
+ for r in rows_of(inband):
163
+ rows.append({"cells": [{"col": (gcol(l["bbox"][0] - x0) if G else ci), "text": l["text"], "bbox": l["bbox"], "confidence": l["confidence"]} for ci, l in enumerate(r)], "ruled": False})
164
+ return rows if rows else None
165
+
166
+ def group_table(lines):
167
+ """Unruled tables: rows by y-clustering, columns by clustering cell x-starts; a row that only continues cells of the
168
+ previous row (no cell in the first column, every cell under an occupied column) is a wrapped cell -> merged upward."""
169
+ if not lines: return []
170
+ rows = rows_of(lines)
171
+ spans = sorted(((l["bbox"][0], l["bbox"][2]) for r in rows for l in r)); cols = []
172
+ for a, b in spans: # columns = groups of horizontally overlapping cells (works for left/centre/right alignment)
173
+ if cols and a < cols[-1][1] - 4: cols[-1][1] = max(cols[-1][1], b)
174
+ else: cols.append([a, b])
175
+ def col_of(l):
176
+ xc = (l["bbox"][0] + l["bbox"][2]) / 2
177
+ return next((i for i, (a, b) in enumerate(cols) if a - 4 <= xc <= b + 4), int(np.argmin([abs(xc - (a + b) / 2) for a, b in cols])) if cols else 0)
178
+ return _merge_continuations([{"cells": [{"bbox": l["bbox"], "text": l["text"], "confidence": l["confidence"], "col": col_of(l)} for l in r], "ruled": False} for r in rows])
179
+
180
+ def assemble(page_w, page_h, layout_blocks, det_boxes, texts, model_ids, timing=None, page_index=0, dpi=None, with_polygons=True, img=None):
181
+ """layout_blocks: [{type, score, bbox, order}], det_boxes: [(poly4x2, score)], texts: [(text, conf)] aligned with det_boxes."""
182
+ lines = []
183
+ for (poly, dscore), (text, conf) in zip(det_boxes, texts):
184
+ if not text.strip(): continue
185
+ lines.append({"bbox": _bbox(poly), "polygon": [[float(x), float(y)] for x, y in poly] if with_polygons else None, "text": text, "confidence": round(float(conf), 4),
186
+ "min_conf": round(float(getattr(conf, "min_char", None) if getattr(conf, "min_char", None) is not None else conf), 4)})
187
+ blocks = []
188
+ for i, b in enumerate(layout_blocks):
189
+ blocks.append({"id": f"b{i}", "type": TYPE_MAP.get(b["type"], "other"), "raw_type": b["type"], "confidence": round(float(b["score"]), 4), "bbox": [round(v, 1) for v in b["bbox"]], "order": b.get("order", i), "lines": []})
190
+ # assign lines to best-overlapping block; unassigned lines become their own text blocks
191
+ orphans = []
192
+ area = lambda bb: max(1.0, (bb[2] - bb[0]) * (bb[3] - bb[1]))
193
+ for l in lines:
194
+ best, bi = 0.0, -1
195
+ for i, b in enumerate(blocks):
196
+ f = _overlap_frac(l["bbox"], b["bbox"])
197
+ # nested blocks (v1.2 layout: a handwriting / seal / id_panel box inside a text region): on a near-tie the
198
+ # smaller, more specific block wins, so signature fragments are not glued to the surrounding text rows
199
+ if f > best + 0.05 or (f >= best - 0.05 and bi >= 0 and f >= 0.5 and area(b["bbox"]) < area(blocks[bi]["bbox"])): best, bi = f, i
200
+ if best >= 0.5: blocks[bi]["lines"].append(l)
201
+ else: orphans.append(l)
202
+ for l in orphans:
203
+ blocks.append({"id": f"b{len(blocks)}", "type": "text", "raw_type": "orphan_line", "confidence": l["confidence"], "bbox": [round(v, 1) for v in l["bbox"]], "order": None, "lines": [l]})
204
+ blocks = [b for b in blocks if b["lines"] or b["type"] in ("figure", "seal", "table", "id_panel", "handwriting")]
205
+ # order: native rank where available, else xy-cut; orphans placed by position among ordered blocks
206
+ ranked = [b for b in blocks if b["order"] is not None]; unranked = [b for b in blocks if b["order"] is None]
207
+ ordered = sorted(ranked, key=lambda b: b["order"])
208
+ for b in unranked:
209
+ pos = next((k for k, o in enumerate(ordered) if o["bbox"][1] > b["bbox"][1] and o["bbox"][0] <= b["bbox"][2]), len(ordered)); ordered.insert(pos, b)
210
+ if not ranked: ordered = xy_cut_order(blocks, page_w)
211
+ full = []
212
+ gray = None
213
+ if img is not None:
214
+ import cv2; gray = img if img.ndim == 2 else cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
215
+ heights = [l["bbox"][3] - l["bbox"][1] for b in ordered for l in b["lines"]]; med_h = float(np.median(heights)) if heights else 0.0
216
+ for k, b in enumerate(ordered):
217
+ b["order"] = k
218
+ if b["type"] == "table":
219
+ # table cells stay separate boxes; structure from printed rules when present, else from alignment
220
+ b["lines"] = [l for r in rows_of(b["lines"]) for l in r]
221
+ rows = ruled_grid(gray, b["bbox"], b["lines"]) if gray is not None else None
222
+ b["rows"] = rows or group_table(b["lines"]); b["ruled"] = bool(rows)
223
+ else:
224
+ b["lines"] = [join_row(run) for r in rows_of(b["lines"]) for run in split_row_by_gap(r)] # one text line per visual row run (fragments of a spaced line re-joined; wide-gap columns kept apart)
225
+ for li, l in enumerate(b["lines"]):
226
+ l["id"] = f"{b['id']}.l{li}"; l["script"] = script_of(l["text"]); l["bbox"] = [round(v, 1) for v in l["bbox"]]
227
+ # review flags (never deletion): calibration on 5,478 labelled lines -> conf >= 0.99 is 97.7 % line-exact,
228
+ # 0.95-0.98 only 83.5 %; lines inside seal/figure/header blocks are mostly stamp/handwriting fragments
229
+ fl = []
230
+ if l["confidence"] < 0.90: fl.append("low_conf")
231
+ elif l["confidence"] < 0.98: fl.append("check")
232
+ if b["type"] in ("seal", "figure", "handwriting", "id_panel"): fl.append("in_" + b["type"])
233
+ # review flags (script/character-repertoire checks); flags only, the text is never altered
234
+ if l.get("min_conf", 1.0) < WEAK_CHAR: fl.append("weak_char") # one very uncertain character (garbage lines usually have one)
235
+ if _MIX_RE.search(l["text"]): fl.append("script_mix") # Latin and Kannada letters inside one token (other-script garbage)
236
+ if med_h and (l["bbox"][3] - l["bbox"][1]) > 3.0 * med_h: fl.append("oversize") # stray ornament / drop-cap sized line
237
+ if fl: l["flags"] = fl
238
+ if b["type"] == "table" and b["rows"]: block_text = "\n".join(" | ".join(c["text"] for c in r["cells"]) for r in b["rows"])
239
+ else: block_text = "\n".join(l["text"] for l in b["lines"])
240
+ if block_text.strip(): full.append(block_text)
241
+ return {"schema": "kanen-ocr/1.0", "model": model_ids, "page": {"index": page_index, "width": page_w, "height": page_h, "dpi": dpi, "rotation": 0},
242
+ "blocks": ordered, "text": "\n\n".join(full), "timing_ms": timing or {}}
243
+
244
+ def to_markdown(doc):
245
+ out = []
246
+ for b in doc["blocks"]:
247
+ t = b["type"]; txt = " ".join(l["text"] for l in b["lines"]).strip()
248
+ if t == "title": out.append(f"# {txt}")
249
+ elif t == "section_title": out.append(f"## {txt}")
250
+ elif t == "table" and b.get("rows"):
251
+ ncol = max((c["col"] for r in b["rows"] for c in r["cells"]), default=0) + 1
252
+ rows = []
253
+ for r in b["rows"]:
254
+ cells = [""] * ncol
255
+ for c in r["cells"]: cells[c["col"]] = (cells[c["col"]] + " " + c["text"]).strip()
256
+ rows.append("| " + " | ".join(x.replace("|", "\\|") for x in cells) + " |")
257
+ if rows: out.append(rows[0] + "\n|" + "---|" * ncol + "\n" + "\n".join(rows[1:]))
258
+ elif t in ("figure", "seal"): out.append(f"*[{t}]*")
259
+ elif t in ("header", "footer", "page_number"): out.append(f"<sub>{txt}</sub>")
260
+ elif txt: out.append(txt)
261
+ return "\n\n".join(out) + "\n"