feat: font detection by comparing rendered vs OCR'd word widths

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Alex-GilbertandClaude Opus 4.6 committed 2026-02-15 18:46:07 -06:00
1 parent b79ca81965
commit 2a3a0f2076
2 files changed
+170

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
from pathlib import Path
from PIL import Image
from unredact.pipeline.font_detect import detect_font, FontMatch
from unredact.pipeline.ocr import ocr_page
from unredact.pipeline.rasterize import rasterize_pdf
def test_detect_font_returns_match(sample_pdf: Path):
pages = rasterize_pdf(sample_pdf, first_page=1, last_page=1)
lines = ocr_page(pages[0])
match = detect_font(lines, pages[0])
assert isinstance(match, FontMatch)
assert match.font_path is not None
assert match.font_size > 0
assert match.score > 0
def test_detect_font_reasonable_size(sample_pdf: Path):
"""At 300 DPI, body text in a letter-size doc is typically 30-60px."""
pages = rasterize_pdf(sample_pdf, first_page=1, last_page=1)
lines = ocr_page(pages[0])
match = detect_font(lines, pages[0])
# At 300 DPI, 11pt text ~ 46px. Allow wide range.
assert 20 < match.font_size < 80
def test_font_match_can_render(sample_pdf: Path):
"""The detected font should be usable for rendering."""
pages = rasterize_pdf(sample_pdf, first_page=1, last_page=1)
lines = ocr_page(pages[0])
match = detect_font(lines, pages[0])
font = match.to_pil_font()
# Should be able to measure text with it
bbox = font.getbbox("Hello")
assert bbox is not None
assert bbox[2] > 0 # width > 0
+132
View File
@@ -0,0 +1,132 @@
from dataclasses import dataclass
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from unredact.pipeline.ocr import OcrLine
# Candidate fonts to test (common document fonts available on the system)
CANDIDATE_FONTS: list[str] = [
"Times New Roman",
"Arial",
"Courier New",
"Georgia",
"Liberation Serif",
"Liberation Sans",
"DejaVu Serif",
"DejaVu Sans",
]
# Size range to search (in pixels at rendering DPI)
SIZE_RANGE = range(20, 80, 2)
def _find_font_path(font_name: str) -> Path | None:
"""Find the .ttf file for a font name using fc-match."""
import subprocess
result = subprocess.run(
["fc-match", "--format=%{file}", font_name],
capture_output=True, text=True,
)
if result.returncode == 0 and result.stdout:
p = Path(result.stdout.strip())
if p.exists():
return p
return None
@dataclass
class FontMatch:
font_name: str
font_path: Path
font_size: int # in pixels
score: float # lower is better (mean absolute error in px)
def to_pil_font(self) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(self.font_path), self.font_size)
def _score_font(
font: ImageFont.FreeTypeFont,
lines: list[OcrLine],
) -> float:
"""Score how well a font matches the OCR'd character widths.
Returns mean absolute error in pixels between rendered and OCR'd word widths.
Lower is better.
"""
errors: list[float] = []
for line in lines:
# Reconstruct words from chars
word = ""
word_start_x = -1
word_end_x = -1
for char in line.chars:
if char.text == " ":
if word and word_start_x >= 0:
# Measure this word
rendered_bbox = font.getbbox(word)
rendered_w = rendered_bbox[2] - rendered_bbox[0]
ocr_w = word_end_x - word_start_x
if ocr_w > 0:
errors.append(abs(rendered_w - ocr_w))
word = ""
word_start_x = -1
else:
if word_start_x < 0:
word_start_x = char.x
word += char.text
word_end_x = char.x + char.w
# Last word in line
if word and word_start_x >= 0:
rendered_bbox = font.getbbox(word)
rendered_w = rendered_bbox[2] - rendered_bbox[0]
ocr_w = word_end_x - word_start_x
if ocr_w > 0:
errors.append(abs(rendered_w - ocr_w))
if not errors:
return float("inf")
return sum(errors) / len(errors)
def detect_font(
lines: list[OcrLine],
page_image: Image.Image,
) -> FontMatch:
"""Detect the best matching font and size for OCR'd text.
Tries all candidate fonts at all sizes in SIZE_RANGE and returns
the combination with the lowest mean word-width error.
"""
best: FontMatch | None = None
for font_name in CANDIDATE_FONTS:
font_path = _find_font_path(font_name)
if font_path is None:
continue
for size in SIZE_RANGE:
try:
font = ImageFont.truetype(str(font_path), size)
except Exception:
continue
score = _score_font(font, lines)
if best is None or score < best.score:
best = FontMatch(
font_name=font_name,
font_path=font_path,
font_size=size,
score=score,
)
if best is None:
raise RuntimeError("No matching font found. Check system fonts.")
return best