ocr-demo / export.py
billingsmoore's picture
Add per-line translation and interlinear export
eda332c verified
Raw
History Blame Contribute Delete
10.1 kB
"""
Export an edited transcription to txt, docx or pdf.
Tibetan needs care in two places. It has no spaces, so naive word-wrapping never
finds a break point and lines overflow; instead we break after the tsheg (U+0F0B)
or shad (U+0F0D) that already mark syllable and clause boundaries. And most
system fonts have no Tibetan glyphs, so the PDF embeds TibMachUni, which ships
with the upstream app.
"""
from __future__ import annotations
import os
from typing import List, Sequence
TIBETAN_FONT = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "Assets", "Fonts", "TibMachUni-1.901b.ttf"
)
FONT_NAME = "TibMachUni"
# Tsheg (syllable dot) and shad (clause bar) are the natural break points.
BREAK_AFTER = "་།"
def wrap_tibetan(text: str, limit: int) -> List[str]:
"""Wrap ``text`` to ``limit`` characters, breaking only after tsheg/shad.
Falls back to a hard break when a single run of characters exceeds the limit
with no break point in it, so output is never silently clipped.
"""
if limit <= 0:
return [text]
lines: List[str] = []
current = ""
for char in text:
current += char
if len(current) >= limit:
cut = max(current.rfind(c) for c in BREAK_AFTER)
if cut > 0:
lines.append(current[: cut + 1])
current = current[cut + 1 :]
else:
lines.append(current)
current = ""
if current:
lines.append(current)
return lines
def to_txt(lines: Sequence[str], path: str) -> str:
"""Write the transcription as UTF-8 plain text, one detected line per row."""
with open(path, "w", encoding="utf-8") as fh:
fh.write("\n".join(lines))
return path
def to_docx(lines: Sequence[str], path: str) -> str:
"""Write a .docx with each line as its own paragraph in a Tibetan font."""
from docx import Document
from docx.oxml.ns import qn
from docx.shared import Pt
document = Document()
for text in lines:
paragraph = document.add_paragraph()
run = paragraph.add_run(text)
run.font.name = FONT_NAME
run.font.size = Pt(14)
# Word picks the font for Tibetan off the complex-script slot, not the
# latin one, so set both or the glyphs fall back to a boxes font.
rpr = run._element.get_or_add_rPr()
rfonts = rpr.get_or_add_rFonts()
rfonts.set(qn("w:cs"), FONT_NAME)
rfonts.set(qn("w:eastAsia"), FONT_NAME)
document.save(path)
return path
def to_pdf(lines: Sequence[str], path: str, font_size: int = 14) -> str:
"""Write a PDF with TibMachUni embedded so it renders anywhere."""
from reportlab.lib.pagesizes import A4
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas as pdfcanvas
if FONT_NAME not in pdfmetrics.getRegisteredFontNames():
pdfmetrics.registerFont(TTFont(FONT_NAME, TIBETAN_FONT))
page_width, page_height = A4
margin = 40
leading = font_size * 1.9 # Tibetan stacks above and below the baseline
usable = page_width - 2 * margin
pdf = pdfcanvas.Canvas(path, pagesize=A4)
pdf.setFont(FONT_NAME, font_size)
y = page_height - margin
# Estimate how many glyphs fit, then wrap on real break points.
sample = pdfmetrics.stringWidth("ག", FONT_NAME, font_size) or font_size * 0.5
limit = max(int(usable / sample), 10)
for text in lines:
for chunk in wrap_tibetan(text, limit) or [""]:
if y < margin + leading:
pdf.showPage()
pdf.setFont(FONT_NAME, font_size)
y = page_height - margin
pdf.drawString(margin, y, chunk)
y -= leading
pdf.save()
return path
# A whole pecha line is around 20:1. Drawn across one page width that leaves the
# script about five points tall -- present but unreadable. Splitting the strip
# into stacked segments trades vertical space for legibility; this is the
# rendered height each segment aims for.
SEGMENT_TARGET_HEIGHT = 46
MAX_SEGMENTS = 3
def _trim_dark_border(array):
"""Drop the black margin deskewing leaves around a crop.
Rotating the page fills the corners with black, and a slanted line's crop
inherits bands of it. Left in, those bands dominate the strip's height and
push the script down to nothing once it is scaled to page width.
"""
if array is None or getattr(array, "size", 0) == 0 or array.ndim != 3:
return array
lit = array.mean(axis=2) > 12
rows, cols = lit.any(axis=1), lit.any(axis=0)
if not rows.any() or not cols.any():
return array
top, bottom = int(rows.argmax()), len(rows) - int(rows[::-1].argmax())
left, right = int(cols.argmax()), len(cols) - int(cols[::-1].argmax())
return array[top:bottom, left:right]
def _segment_strip(image, usable: float):
"""Cut a wide line strip into pieces that stay legible at page width."""
from math import ceil
if image.width <= 0 or image.height <= 0:
return []
natural = usable * image.height / image.width
parts = max(1, min(MAX_SEGMENTS, ceil(SEGMENT_TARGET_HEIGHT / max(natural, 1e-6))))
if parts == 1:
return [image]
step = ceil(image.width / parts)
return [
image.crop((start, 0, min(start + step, image.width), image.height))
for start in range(0, image.width, step)
]
def to_interlinear_pdf(items: Sequence[dict], path: str, font_size: int = 13) -> str:
"""Write a PDF of line strips, each with its transcription and translation.
``items`` are dicts with ``crop`` (an RGB numpy array of the page strip the
line was read from), ``source`` (Tibetan) and ``target`` (English). Laying
the three out together mirrors the editing UI, so the PDF can be checked
against the original without going back to the page.
"""
from io import BytesIO
from PIL import Image
from reportlab.lib.pagesizes import A4
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas as pdfcanvas
if FONT_NAME not in pdfmetrics.getRegisteredFontNames():
pdfmetrics.registerFont(TTFont(FONT_NAME, TIBETAN_FONT))
page_width, page_height = A4
margin = 42
usable = page_width - 2 * margin
tibetan_leading = font_size * 1.95
english_size = font_size - 2
english_leading = english_size * 1.45
pdf = pdfcanvas.Canvas(path, pagesize=A4)
pdf.setTitle("Transcription and translation")
y = page_height - margin
tibetan_glyph = pdfmetrics.stringWidth("ག", FONT_NAME, font_size) or font_size * 0.5
tibetan_limit = max(int(usable / tibetan_glyph), 10)
for number, item in enumerate(items, start=1):
crop = item.get("crop")
source = (item.get("source") or "").strip()
target = (item.get("target") or "").strip()
source_lines = wrap_tibetan(source, tibetan_limit) if source else []
english_lines = _wrap_plain(target, usable, english_size) if target else []
segments = []
crop = _trim_dark_border(crop)
if crop is not None and getattr(crop, "size", 0):
for piece in _segment_strip(Image.fromarray(crop), usable):
height = usable * piece.height / piece.width
buffer = BytesIO()
piece.save(buffer, format="PNG")
buffer.seek(0)
segments.append((ImageReader(buffer), height))
block = (
14
+ sum(height + 3 for _, height in segments)
+ 6
+ tibetan_leading * len(source_lines)
+ (6 + english_leading * len(english_lines) if english_lines else 0)
+ 18
)
# Start a fresh page rather than split an entry, unless it is taller than
# a page on its own -- then it has to flow regardless.
if y - block < margin and y < page_height - margin - 1:
pdf.showPage()
y = page_height - margin
pdf.setFont("Helvetica-Bold", 8)
pdf.setFillGray(0.5)
pdf.drawString(margin, y - 9, f"{number}")
pdf.setFillGray(0)
y -= 14
for reader, height in segments:
pdf.drawImage(
reader, margin, y - height, width=usable, height=height, mask="auto"
)
y -= height + 3
y -= 6
pdf.setFont(FONT_NAME, font_size)
for chunk in source_lines:
pdf.drawString(margin, y - font_size, chunk)
y -= tibetan_leading
if english_lines:
y -= 6
pdf.setFont("Helvetica", english_size)
pdf.setFillGray(0.3)
for chunk in english_lines:
pdf.drawString(margin, y - english_size, chunk)
y -= english_leading
pdf.setFillGray(0)
y -= 10
pdf.setStrokeGray(0.85)
pdf.setLineWidth(0.5)
pdf.line(margin, y, page_width - margin, y)
y -= 8
pdf.save()
return path
def _wrap_plain(text: str, width: float, size: int) -> List[str]:
"""Wrap Latin text on spaces to fit ``width`` points."""
from reportlab.pdfbase.pdfmetrics import stringWidth
words = text.split()
lines: List[str] = []
current = ""
for word in words:
candidate = f"{current} {word}".strip()
if stringWidth(candidate, "Helvetica", size) <= width:
current = candidate
else:
if current:
lines.append(current)
current = word
if current:
lines.append(current)
return lines
WRITERS = {"txt": to_txt, "docx": to_docx, "pdf": to_pdf}
def export(text: str, fmt: str, path: str) -> str:
"""Write ``text`` (newline-separated lines) to ``path`` in ``fmt``."""
if fmt not in WRITERS:
raise ValueError(f"unknown format {fmt!r}; known: {sorted(WRITERS)}")
return WRITERS[fmt](text.split("\n"), path)