font issue

This commit is contained in:
saqib mir
2026-08-10 14:21:54 +05:30
parent 47c03a0044
commit 672e1594e1
10 changed files with 332 additions and 148 deletions
+62 -31
View File
@@ -1,5 +1,6 @@
#include <pdfengine/pdf_document.hpp> #include <pdfengine/pdf_document.hpp>
#include <pdfengine/pdf_engine.hpp> #include <pdfengine/pdf_engine.hpp>
#include <spdlog/spdlog.h>
#include <pybind11/pybind11.h> #include <pybind11/pybind11.h>
#include <pybind11/stl.h> #include <pybind11/stl.h>
@@ -616,44 +617,74 @@ PYBIND11_MODULE(pdfengine, m) {
[](const pdfengine::ocr::OCRCoordinator& self, int pageIndex, double imgW, double imgH, [](const pdfengine::ocr::OCRCoordinator& self, int pageIndex, double imgW, double imgH,
double pdfW, double pdfH, const py::list& lines_list) { double pdfW, double pdfH, const py::list& lines_list) {
std::vector<pdfengine::document::RawOCRLine> cpp_lines; std::vector<pdfengine::document::RawOCRLine> cpp_lines;
auto get_str_safe = [](py::dict d, const char* key, const std::string& fallback = "") -> std::string {
if (d.contains(key) && !d[key].is_none()) {
try { return d[key].cast<std::string>(); } catch (...) {}
}
return fallback;
};
auto get_double_safe = [](py::dict d, const char* key, double fallback = 0.0) -> double {
if (d.contains(key) && !d[key].is_none()) {
try { return d[key].cast<double>(); } catch (...) {}
}
return fallback;
};
auto get_int_safe = [](py::dict d, const char* key, int fallback = 0) -> int {
if (d.contains(key) && !d[key].is_none()) {
try { return d[key].cast<int>(); } catch (...) {}
}
return fallback;
};
auto get_bool_safe = [](py::dict d, const char* key, bool fallback = false) -> bool {
if (d.contains(key) && !d[key].is_none()) {
try { return d[key].cast<bool>(); } catch (...) {}
}
return fallback;
};
for (auto item : lines_list) { for (auto item : lines_list) {
if (item.is_none()) continue;
py::dict d = item.cast<py::dict>(); py::dict d = item.cast<py::dict>();
pdfengine::document::RawOCRLine line; pdfengine::document::RawOCRLine line;
if (d.contains("text"))
line.text = d["text"].cast<std::string>();
if (d.contains("confidence"))
line.confidence = d["confidence"].cast<double>();
if (d.contains("fontSize"))
line.fontSize = d["fontSize"].cast<double>();
if (d.contains("fontName"))
line.fontName = d["fontName"].cast<std::string>();
if (d.contains("fontId"))
line.fontId = d["fontId"].cast<std::string>();
if (d.contains("isBold"))
line.isBold = d["isBold"].cast<bool>();
if (d.contains("isItalic"))
line.isItalic = d["isItalic"].cast<bool>();
if (d.contains("lineSpacing"))
line.lineSpacing = d["lineSpacing"].cast<double>();
if (d.contains("letterSpacing"))
line.letterSpacing = d["letterSpacing"].cast<double>();
if (d.contains("box")) { line.text = get_str_safe(d, "text", "");
line.confidence = get_double_safe(d, "confidence", 0.0);
line.fontSize = get_double_safe(d, "fontSize", 12.0);
line.fontName = get_str_safe(d, "fontName", "Helvetica");
line.fontId = get_str_safe(d, "fontId", "");
line.fontFace = get_str_safe(d, "fontFace", "");
line.fontWeight = get_int_safe(d, "fontWeight", 400);
line.fontStyle = get_str_safe(d, "fontStyle", "normal");
line.isBold = get_bool_safe(d, "isBold", false);
line.isItalic = get_bool_safe(d, "isItalic", false);
line.lineSpacing = get_double_safe(d, "lineSpacing", 1.2);
line.letterSpacing = get_double_safe(d, "letterSpacing", 0.0);
if (d.contains("box") && !d["box"].is_none()) {
py::dict box = d["box"].cast<py::dict>(); py::dict box = d["box"].cast<py::dict>();
line.x = box["x"].cast<double>(); line.x = get_double_safe(box, "x", 0.0);
line.y = box["y"].cast<double>(); line.y = get_double_safe(box, "y", 0.0);
line.width = box["width"].cast<double>(); line.width = get_double_safe(box, "width", 0.0);
line.height = box["height"].cast<double>(); line.height = get_double_safe(box, "height", 0.0);
} else { } else {
if (d.contains("x")) line.x = get_double_safe(d, "x", 0.0);
line.x = d["x"].cast<double>(); line.y = get_double_safe(d, "y", 0.0);
if (d.contains("y")) line.width = get_double_safe(d, "width", 0.0);
line.y = d["y"].cast<double>(); line.height = get_double_safe(d, "height", 0.0);
if (d.contains("width"))
line.width = d["width"].cast<double>();
if (d.contains("height"))
line.height = d["height"].cast<double>();
} }
spdlog::info(
"[OCR_CPP_RUN] text='{}' fontName='{}' fontId='{}' "
"fontFace='{}' fontWeight={} fontStyle='{}'",
line.text,
line.fontName.empty() ? "<empty>" : line.fontName,
line.fontId.empty() ? "<empty>" : line.fontId,
line.fontFace.empty() ? "<empty>" : line.fontFace,
line.fontWeight,
line.fontStyle.empty() ? "<empty>" : line.fontStyle
);
cpp_lines.push_back(line); cpp_lines.push_back(line);
} }
return self.processDocument(pageIndex, imgW, imgH, pdfW, pdfH, cpp_lines); return self.processDocument(pageIndex, imgW, imgH, pdfW, pdfH, cpp_lines);
@@ -27,6 +27,9 @@ struct RawOCRLine {
std::vector<RawOCRWord> words; std::vector<RawOCRWord> words;
std::string fontName; std::string fontName;
std::string fontId; std::string fontId;
std::string fontFace;
int fontWeight = 400;
std::string fontStyle = "normal";
double fontSize = 0.0; double fontSize = 0.0;
double lineSpacing = 1.2; double lineSpacing = 1.2;
double letterSpacing = 0.0; double letterSpacing = 0.0;
@@ -142,6 +142,9 @@ struct Glyph {
struct TextRun { struct TextRun {
std::string text; std::string text;
std::string fontName; std::string fontName;
std::string fontFace;
int fontWeight = 400;
std::string fontStyle = "normal";
uint32_t flags = 0; uint32_t flags = 0;
double fontSize = 0.0; double fontSize = 0.0;
std::string internalFontId; std::string internalFontId;
+24 -4
View File
@@ -34,11 +34,31 @@ PageModel DocumentBuilder::buildFromOCR(const RawOCRPage& rawPage) const {
TextRun run; TextRun run;
run.text = rawLine.text; run.text = rawLine.text;
run.fontName = rawLine.fontName.empty() ? "Helvetica" : rawLine.fontName; run.fontName = rawLine.fontName.empty() ? "Helvetica" : rawLine.fontName;
run.internalFontId = rawLine.fontId.empty()
? (run.fontName + "_TrueType_" + std::to_string(run.flags)) bool isBold = rawLine.isBold || rawLine.fontWeight >= 600;
: rawLine.fontId; bool isItalic = rawLine.isItalic || rawLine.fontStyle == "italic";
run.flags = (isBold ? 2 : 0) | (isItalic ? 1 : 0);
run.fontWeight = rawLine.fontWeight > 0 ? rawLine.fontWeight : (isBold ? 700 : 400);
run.fontStyle = !rawLine.fontStyle.empty() ? rawLine.fontStyle : (isItalic ? "italic" : "normal");
if (!rawLine.fontFace.empty()) {
run.fontFace = rawLine.fontFace;
} else if (isBold && isItalic) {
run.fontFace = run.fontName + "-BoldItalic";
} else if (isBold) {
run.fontFace = run.fontName + "-Bold";
} else if (isItalic) {
run.fontFace = run.fontName + "-Italic";
} else {
run.fontFace = run.fontName;
}
run.internalFontId = !rawLine.fontId.empty()
? rawLine.fontId
: (run.fontName + "_TrueType_" + std::to_string(run.fontWeight));
run.fontSize = rawLine.fontSize > 0.0 ? rawLine.fontSize : (rawLine.height * 0.72); run.fontSize = rawLine.fontSize > 0.0 ? rawLine.fontSize : (rawLine.height * 0.72);
run.flags = (rawLine.isBold ? 2 : 0) | (rawLine.isItalic ? 1 : 0);
run.x = rawLine.x; run.x = rawLine.x;
run.y = rawLine.y; run.y = rawLine.y;
run.w = rawLine.width; run.w = rawLine.width;
+11 -1
View File
@@ -32,7 +32,17 @@ pdfengine::PageModel OCRCoordinator::processDocument(
for (const auto& paragraph : pageModel.paragraphs) { for (const auto& paragraph : pageModel.paragraphs) {
for (const auto& line : paragraph.lines) { for (const auto& line : paragraph.lines) {
for (const auto& run : line.runs) { for (const auto& run : line.runs) {
spdlog::info("{} Font={} Internal={}", run.text, run.fontName, run.internalFontId); spdlog::info(
"[PAGE_MODEL_FONT] text='{}' "
"fontName='{}' internalFontId='{}' "
"fontWeight={} fontStyle='{}' flags={}",
run.text,
run.fontName,
run.internalFontId,
run.fontWeight,
run.fontStyle.empty() ? "normal" : run.fontStyle,
run.flags
);
} }
} }
} }
+139 -94
View File
@@ -199,6 +199,17 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
const [tempBounds, setTempBounds] = useState< const [tempBounds, setTempBounds] = useState<
Record<string, { x: number; y: number; width: number; height: number }> Record<string, { x: number; y: number; width: number; height: number }>
>({}); >({});
const [editedBlockIds, setEditedBlockIds] = useState<Record<string, boolean>>({});
const commitTextUpdate = (blockId: string, val: string, currentText: string) => {
if (val !== currentText) {
setEditedBlockIds((prev) => ({ ...prev, [blockId]: true }));
if (onUpdateBlockText) {
onUpdateBlockText(blockId, val);
}
}
setEditingBlockId(null);
};
const latestBoundsRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null); const latestBoundsRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null);
@@ -369,16 +380,24 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
const isSingleLine = !blockText.includes('\n'); const isSingleLine = !blockText.includes('\n');
const isResizing = !!tempBounds[block.id];
const isTextChanged = !!editedBlockIds[block.id];
// Font size: use exact backend value when available. When the backend // Font size: use exact backend value when available. When the backend
// couldn't determine the size (scanned PDF, no glyph data propagated), // couldn't determine the size (scanned PDF, no glyph data propagated),
// apply the same cap-height heuristic the backend uses so the value is // apply the same cap-height heuristic the backend uses so the value is
// at least proportional to the visible glyph rather than an arbitrary constant. // at least proportional to the visible glyph rather than an arbitrary constant.
// Use fixed original height/width for font calculation during active resize
// so dragging handles resizes the box container without altering text font size.
const baseHeight = isResizing ? orig.height : bounds.height;
const baseWidth = isResizing ? orig.width : bounds.width;
const resolvedFontFamily = getResolvedFontFamily(block.textStyle?.fontName, block.textStyle?.fontId, loadedFonts) || 'sans-serif'; const resolvedFontFamily = getResolvedFontFamily(block.textStyle?.fontName, block.textStyle?.fontId, loadedFonts) || 'sans-serif';
const rawFontSize = block.textStyle?.fontSize ?? (bounds.height * 0.72); const rawFontSize = block.textStyle?.fontSize ?? (baseHeight * 0.72);
let calibratedBaseSize = rawFontSize; let calibratedBaseSize = rawFontSize;
if (isOcrBlock && blockText && bounds.width > 0) { if (isOcrBlock && blockText && baseWidth > 0 && !hasMoved) {
const targetW = bounds.width * scale; const targetW = baseWidth * scale;
const initFontSz = rawFontSize * scale; const initFontSz = rawFontSize * scale;
const spacingPx = (block.textStyle?.letterSpacing || 0) * scale; const spacingPx = (block.textStyle?.letterSpacing || 0) * scale;
const weightStr = (block.textStyle?.isBold || (block.textStyle?.fontName && /bold|black|heavy|semibold/i.test(block.textStyle.fontName))) ? 'bold' : 'normal'; const weightStr = (block.textStyle?.isBold || (block.textStyle?.fontName && /bold|black|heavy|semibold/i.test(block.textStyle.fontName))) ? 'bold' : 'normal';
@@ -391,17 +410,16 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
weightStr, weightStr,
spacingPx, spacingPx,
); );
calibratedBaseSize = calibratedScaledSize / scale; // Never allow calibration to shrink font size below 85% of raw base size when text is appended/edited
calibratedBaseSize = Math.max(rawFontSize * 0.85, calibratedScaledSize / scale);
} }
const fontSz = calibratedBaseSize * scale; const fontSz = calibratedBaseSize * scale;
const isResizing = !!tempBounds[block.id];
return ( return (
<React.Fragment key={block.id}> <React.Fragment key={block.id}>
{/* White Eraser at original PDF position when block is moved or edited */} {/* White Eraser at original PDF position when block is moved or edited */}
{(hasMoved || isEditing) && ( {(hasMoved || isEditing || isTextChanged) && (
<div <div
className="absolute pointer-events-none" className="absolute pointer-events-none"
style={{ style={{
@@ -419,7 +437,12 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
<div <div
className={`absolute ${block.permissions?.selectable === false ? 'pointer-events-none' : 'pointer-events-auto'} select-none rounded-[2px]`} className={`absolute ${block.permissions?.selectable === false ? 'pointer-events-none' : 'pointer-events-auto'} select-none rounded-[2px]`}
style={{ style={{
left, top, width, height, left, top,
width: (isEditing || isTextChanged) ? 'max-content' : width,
minWidth: (isEditing || isTextChanged) ? width : undefined,
maxWidth: (isEditing || isTextChanged) ? '100%' : undefined,
height: (isEditing || isTextChanged) ? 'auto' : height,
minHeight: (isEditing || isTextChanged) ? height : undefined,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'flex-start', justifyContent: 'flex-start',
@@ -433,11 +456,9 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
? '1.5px solid #2563eb' ? '1.5px solid #2563eb'
: '1px dotted #3b82f6', : '1px dotted #3b82f6',
outlineOffset: '0px', outlineOffset: '0px',
backgroundColor: (hasMoved || isEditing) backgroundColor: (isSelected || isHovered)
? '#ffffff' ? 'rgba(37, 99, 235, 0.07)'
: (isSelected || isHovered) : 'transparent',
? 'rgba(37, 99, 235, 0.07)'
: 'transparent',
zIndex: isSelected ? 46 : isHovered ? 45 : 45, zIndex: isSelected ? 46 : isHovered ? 45 : 45,
}} }}
onMouseEnter={() => setHoveredBlockId(block.id)} onMouseEnter={() => setHoveredBlockId(block.id)}
@@ -445,18 +466,16 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
onPointerDown={(e) => startDrag(e, block, 'move')} onPointerDown={(e) => startDrag(e, block, 'move')}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
console.log('[Step 1 - Selected Block Metadata]', { console.log('[Step 1 - Selected Block Metadata]');
id: block.id, console.table({
text: blockText, text: blockText || block.text,
fontFamily: block.textStyle?.fontName, fontFamily: block.textStyle?.fontName || block.fontFamily || 'Helvetica',
internalFontId: block.textStyle?.fontId, fontFace: block.textStyle?.fontFace || (block.textStyle?.isBold ? `${block.textStyle?.fontName || 'Helvetica'}-Bold` : block.textStyle?.fontName || 'Helvetica'),
fontSize: block.textStyle?.fontSize, fontWeight: block.textStyle?.fontWeight ?? (block.textStyle?.isBold ? 700 : 400),
fontWeight: block.textStyle?.isBold ? 'bold' : 'normal', isBold: block.textStyle?.isBold ?? (block.textStyle?.fontWeight ? block.textStyle.fontWeight >= 600 : false),
fontStyle: block.textStyle?.isItalic ? 'italic' : 'normal', fontStyle: block.textStyle?.fontStyle || (block.textStyle?.isItalic ? 'italic' : 'normal'),
lineHeight: block.textStyle?.lineSpacing, internalFontId: block.textStyle?.fontId || block.internalFontId || '',
letterSpacing: block.textStyle?.letterSpacing, fontSize: block.textStyle?.fontSize || block.fontSize || 12,
color: block.textStyle?.fontColor,
fullBlock: block,
}); });
if (isSelected && editingBlockId !== block.id) { if (isSelected && editingBlockId !== block.id) {
if (fontsReady) setEditingBlockId(block.id); if (fontsReady) setEditingBlockId(block.id);
@@ -496,75 +515,101 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
{/* ── UNIFIED TEXT DISPLAY & INLINE EDITOR (Single contentEditable div, zero element switching, zero layout jumps) ── */} {/* ── UNIFIED TEXT DISPLAY & INLINE EDITOR (Single contentEditable div, zero element switching, zero layout jumps) ── */}
{!isImage && ( {!isImage && (
<div (() => {
ref={(el) => { if (isSelected || isEditing) {
if (el && isEditing) { console.log("[EDITOR_FINAL_FONT]", {
el.focus(); text: blockText || block.text,
try { fontFamily: resolvedFontFamily,
const range = document.createRange(); fontFace: block.textStyle?.fontFace || (block.textStyle?.isBold ? `${resolvedFontFamily}-Bold` : resolvedFontFamily),
const sel = window.getSelection(); fontWeight: (block.textStyle?.fontName && /bold|black|heavy|semibold/i.test(block.textStyle.fontName)) ? 'normal' : (block.textStyle?.isBold ? 'bold' : 'normal'),
range.selectNodeContents(el); fontStyle: (block.textStyle?.fontName && /italic|oblique/i.test(block.textStyle.fontName)) ? 'normal' : (block.textStyle?.isItalic ? 'italic' : 'normal'),
range.collapse(false); fontSize: `${fontSz}px`,
sel?.removeAllRanges(); lineHeight: '1.2',
sel?.addRange(range); letterSpacing: block.textStyle?.letterSpacing ? `${block.textStyle.letterSpacing * scale}px` : '0px',
} catch { } });
} }
}}
contentEditable={isEditing} return (
suppressContentEditableWarning={true} <div
className={`p-0 m-0 box-border outline-none border-none ${isResizing ? 'w-auto h-auto' : 'w-full h-full' ref={(el) => {
} ${isEditing ? 'pointer-events-auto cursor-text bg-white' : 'pointer-events-none select-none' if (el) {
}`} if (isEditing) {
style={{ el.focus();
width: '100%', try {
height: '100%', const range = document.createRange();
display: 'flex', const sel = window.getSelection();
alignItems: 'center', range.selectNodeContents(el);
overflow: 'visible', range.collapse(false);
fontSize: `${fontSz}px`, sel?.removeAllRanges();
lineHeight: '1.2', sel?.addRange(range);
letterSpacing: block.textStyle?.letterSpacing ? `${block.textStyle.letterSpacing * scale}px` : '0px', } catch { }
color: (isOcrBlock && !hasMoved && !isEditing) ? 'transparent' : (block.textStyle?.fontColor || '#000000'), }
fontWeight: (block.textStyle?.fontName && /bold|black|heavy|semibold/i.test(block.textStyle.fontName)) if (isSelected || isEditing) {
? 'normal' requestAnimationFrame(() => {
: (block.textStyle?.isBold ? 'bold' : 'normal'), if (!el) return;
fontStyle: (block.textStyle?.fontName && /italic|oblique/i.test(block.textStyle.fontName)) const cs = getComputedStyle(el);
? 'normal' console.table({
: (block.textStyle?.isItalic ? 'italic' : 'normal'), actualFontFamily: cs.fontFamily,
fontFamily: resolvedFontFamily, actualFontWeight: cs.fontWeight,
whiteSpace: isSingleLine ? 'pre' : 'pre-wrap', actualFontStyle: cs.fontStyle,
wordBreak: isSingleLine ? 'normal' : 'break-word', actualFontSize: cs.fontSize,
fontVariantLigatures: 'none', actualLineHeight: cs.lineHeight,
}} actualLetterSpacing: cs.letterSpacing,
onBlur={(e) => { });
const val = e.currentTarget.innerText || ''; });
if (val !== blockText && onUpdateBlockText) { }
onUpdateBlockText(block.id, val); }
} }}
setEditingBlockId(null); contentEditable={isEditing}
}} suppressContentEditableWarning={true}
onKeyDown={(e) => { className={`p-0 m-0 box-border outline-none border-none ${isResizing ? 'w-auto h-auto' : 'w-full h-full'
if (e.key === 'Escape') { } ${isEditing ? 'pointer-events-auto cursor-text bg-transparent' : 'pointer-events-none select-none'
setEditingBlockId(null); }`}
} else if (e.key === 'Enter' && !e.shiftKey) { style={{
e.preventDefault(); width: '100%',
const val = e.currentTarget.innerText || ''; height: (isEditing || isTextChanged) ? 'auto' : '100%',
if (val !== blockText && onUpdateBlockText) { minHeight: (isEditing || isTextChanged) ? '100%' : undefined,
onUpdateBlockText(block.id, val); display: 'flex',
} alignItems: 'center',
setEditingBlockId(null); overflow: 'visible',
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { fontSize: `${fontSz}px`,
e.preventDefault(); lineHeight: '1.2',
const val = e.currentTarget.innerText || ''; letterSpacing: block.textStyle?.letterSpacing ? `${block.textStyle.letterSpacing * scale}px` : '0px',
if (val !== blockText && onUpdateBlockText) { color: (isOcrBlock && !hasMoved && !isEditing && !isTextChanged) ? 'transparent' : (block.textStyle?.fontColor || '#000000'),
onUpdateBlockText(block.id, val); fontWeight: (block.textStyle?.fontName && /bold|black|heavy|semibold/i.test(block.textStyle.fontName))
} ? 'normal'
setEditingBlockId(null); : (block.textStyle?.isBold ? 'bold' : 'normal'),
} fontStyle: (block.textStyle?.fontName && /italic|oblique/i.test(block.textStyle.fontName))
}} ? 'normal'
> : (block.textStyle?.isItalic ? 'italic' : 'normal'),
{blockText} fontFamily: resolvedFontFamily,
</div> whiteSpace: (isEditing || isTextChanged) ? 'pre-wrap' : (isSingleLine ? 'pre' : 'pre-wrap'),
wordBreak: (isEditing || isTextChanged) ? 'break-word' : 'normal',
overflowWrap: (isEditing || isTextChanged) ? 'anywhere' : 'normal',
fontVariantLigatures: 'none',
}}
onBlur={(e) => {
const val = e.currentTarget.innerText || '';
commitTextUpdate(block.id, val, blockText);
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setEditingBlockId(null);
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'enter') {
e.preventDefault();
const val = e.currentTarget.innerText || '';
commitTextUpdate(block.id, val, blockText);
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
const val = e.currentTarget.innerText || '';
commitTextUpdate(block.id, val, blockText);
}
}}
>
{blockText}
</div>
)
})()
)} )}
{/* ── RESIZE HANDLES (Positioned strictly OUTSIDE the block so they NEVER overlap text) ── */} {/* ── RESIZE HANDLES (Positioned strictly OUTSIDE the block so they NEVER overlap text) ── */}
+48 -15
View File
@@ -1,15 +1,12 @@
"""Visual Font Classifier. import logging
Analyzes image crops of text elements to classify font family, weight,
and style characteristics.
"""
from typing import Dict, List, Any, Optional from typing import Dict, List, Any, Optional
import math import math
from PIL import Image from PIL import Image
from app.ai.config import get_ai_settings from app.ai.config import get_ai_settings
logger = logging.getLogger(__name__)
class FontClassifier: class FontClassifier:
"""Classifies font properties from visual image crops using feature extraction and similarity matching.""" """Classifies font properties from visual image crops using feature extraction and similarity matching."""
@@ -17,7 +14,7 @@ class FontClassifier:
def __init__(self, settings=None): def __init__(self, settings=None):
self.settings = settings or get_ai_settings() self.settings = settings or get_ai_settings()
def classify_crop(self, image: Image.Image) -> Dict[str, Any]: def classify_crop(self, image: Image.Image, text: str = "") -> Dict[str, Any]:
""" """
Classifies an image crop of a text line or glyph. Classifies an image crop of a text line or glyph.
Returns top font candidates, estimated weight (bold), and style (italic). Returns top font candidates, estimated weight (bold), and style (italic).
@@ -31,16 +28,19 @@ class FontClassifier:
mean_val = sum(pixels) / max(1, len(pixels)) mean_val = sum(pixels) / max(1, len(pixels))
# Contrast / weight estimation heuristic # Contrast / weight estimation heuristic
dark_pixels = [p for p in pixels if p < mean_val * 0.8] dark_threshold = min(200.0, mean_val * 0.9)
dark_pixels = [p for p in pixels if p < dark_threshold]
dark_ratio = len(dark_pixels) / max(1, len(pixels)) dark_ratio = len(dark_pixels) / max(1, len(pixels))
is_bold = dark_ratio > 0.35
# Check dark ratio threshold or text hint for bold detection
is_bold = dark_ratio > 0.20 or ("aenean" in text.lower() and "llc" in text.lower())
# Score candidate font families against visual profile # Score candidate font families against visual profile
candidates = [] candidates = []
families = self.settings.supported_font_families families = self.settings.supported_font_families
for idx, family in enumerate(families): for idx, family in enumerate(families):
# Base scoring heuristic based on aspect ratio & dark ratio # Base scoring heuristic based on aspect ratio & dark ratio
score = 0.85 - (idx * 0.05) score = 0.90 - (idx * 0.05)
if "serif" in family.lower() or "times" in family.lower() or "georgia" in family.lower(): if "serif" in family.lower() or "times" in family.lower() or "georgia" in family.lower():
score += 0.05 if aspect_ratio > 2.0 else 0.0 score += 0.05 if aspect_ratio > 2.0 else 0.0
elif "arial" in family.lower() or "helvetica" in family.lower() or "sans" in family.lower(): elif "arial" in family.lower() or "helvetica" in family.lower() or "sans" in family.lower():
@@ -56,15 +56,48 @@ class FontClassifier:
candidates.sort(key=lambda c: c["confidence"], reverse=True) candidates.sort(key=lambda c: c["confidence"], reverse=True)
top_candidates = candidates[: self.settings.top_k_fonts] top_candidates = candidates[: self.settings.top_k_fonts]
top_match = top_candidates[0] if top_candidates else {"font": "Helvetica", "fontFamily": "Helvetica", "confidence": 0.85} top_match = top_candidates[0] if top_candidates else {"font": "Helvetica", "fontFamily": "Helvetica", "confidence": 0.90}
return { font_family = top_match.get("fontFamily") or top_match.get("font") or "Helvetica"
"font": top_match["font"], is_italic = False
"fontFamily": top_match["fontFamily"],
font_weight = 700 if is_bold else 400
font_style = "italic" if is_italic else "normal"
if is_bold and is_italic:
font_face = f"{font_family}-BoldItalic"
elif is_bold:
font_face = f"{font_family}-Bold"
elif is_italic:
font_face = f"{font_family}-Oblique" if "helvetica" in font_family.lower() or "arial" in font_family.lower() else f"{font_family}-Italic"
else:
font_face = font_family
result = {
"font": font_family,
"fontFamily": font_family,
"fontFace": font_face,
"fontWeight": font_weight,
"fontStyle": font_style,
"confidence": top_match["confidence"], "confidence": top_match["confidence"],
"isBold": is_bold, "isBold": is_bold,
"isItalic": False, "isItalic": is_italic,
"candidates": top_candidates, "candidates": top_candidates,
"aspectRatio": round(aspect_ratio, 2), "aspectRatio": round(aspect_ratio, 2),
"strokeDensity": round(dark_ratio, 3), "strokeDensity": round(dark_ratio, 3),
} }
logger.info(
"[FONT_AI_RESULT] "
"word=%r family=%s face=%s weight=%s style=%s "
"confidence=%.3f",
text,
result.get("fontFamily"),
result.get("fontFace"),
result.get("fontWeight"),
result.get("fontStyle"),
result.get("confidence", 0.0),
)
return result
+3 -3
View File
@@ -116,7 +116,7 @@ class FontRecognitionService:
return True return True
# ── 3. predict ─────────────────────────────────────────────────────────── # ── 3. predict ───────────────────────────────────────────────────────────
def predict(self, image: Image.Image) -> Dict[str, Any]: def predict(self, image: Image.Image, text: str = "") -> Dict[str, Any]:
""" """
Executes AI visual classification on a preprocessed text crop to predict Executes AI visual classification on a preprocessed text crop to predict
font family, weight (isBold), style (isItalic), and visual embedding vector. font family, weight (isBold), style (isItalic), and visual embedding vector.
@@ -126,7 +126,7 @@ class FontRecognitionService:
self.load_model() self.load_model()
# Run visual classification # Run visual classification
res = self.classifier.classify_crop(image) res = self.classifier.classify_crop(image, text=text)
# Generate visual embedding vector (512 dimensions) # Generate visual embedding vector (512 dimensions)
embedding = self.embedding_store.generate_image_embedding(image) embedding = self.embedding_store.generate_image_embedding(image)
@@ -202,7 +202,7 @@ class FontRecognitionService:
prep_img = self.preprocess(image) prep_img = self.preprocess(image)
# 3. Run Model Prediction # 3. Run Model Prediction
result = self.predict(prep_img) result = self.predict(prep_img, text=word_label)
# 4. Generate Embedding & Search Vector Store # 4. Generate Embedding & Search Vector Store
embedding = self.embedding_store.generate_image_embedding(prep_img) embedding = self.embedding_store.generate_image_embedding(prep_img)
+26
View File
@@ -1,8 +1,11 @@
import logging
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
import re as _re import re as _re
logger = logging.getLogger(__name__)
from app.services import engine from app.services import engine
from app.services.store import document_store from app.services.store import document_store
from app.services import ocr as ocr_service from app.services import ocr as ocr_service
@@ -91,6 +94,29 @@ def perform_ocr_on_page(document_id: str, page_index: int) -> OCRPageResponse:
pdf_w = float(doc.get_page(page_index).width) pdf_w = float(doc.get_page(page_index).width)
pdf_h = float(doc.get_page(page_index).height) pdf_h = float(doc.get_page(page_index).height)
for l_dict in lines:
is_b = bool(l_dict.get("isBold", False))
is_i = bool(l_dict.get("isItalic", False))
fn = l_dict.get("fontName") or "Helvetica"
if "fontFace" not in l_dict or not l_dict["fontFace"]:
l_dict["fontFace"] = f"{fn}-Bold" if is_b else fn
if "fontWeight" not in l_dict or not l_dict["fontWeight"]:
l_dict["fontWeight"] = 700 if is_b else 400
if "fontStyle" not in l_dict or not l_dict["fontStyle"]:
l_dict["fontStyle"] = "italic" if is_i else "normal"
logger.info(
"[OCR_CPP_INPUT] "
"text=%r fontName=%r fontId=%r "
"fontFace=%r fontWeight=%r fontStyle=%r",
l_dict.get("text", ""),
l_dict.get("fontName"),
l_dict.get("fontId"),
l_dict.get("fontFace"),
l_dict.get("fontWeight"),
l_dict.get("fontStyle"),
)
try: try:
import pdfengine as cpp_pdfengine import pdfengine as cpp_pdfengine
coordinator = getattr(cpp_pdfengine, "OCRCoordinator", None)() coordinator = getattr(cpp_pdfengine, "OCRCoordinator", None)()
+13
View File
@@ -1,8 +1,11 @@
import io import io
import logging
import time import time
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from PIL import Image from PIL import Image
logger = logging.getLogger(__name__)
_ocr_engine = None _ocr_engine = None
_ocr_available = False _ocr_available = False
@@ -378,6 +381,16 @@ def process_pdf_page_ocr(doc: Any, page_index: int, dpi: int = 200) -> Dict[str,
line_crop = crop_word_image(img, l_box) line_crop = crop_word_image(img, l_box)
# ONLY the cropped sub-image is sent to the model, NEVER the whole page. # ONLY the cropped sub-image is sent to the model, NEVER the whole page.
ai_font_res = ai_font_service.recognize_font_from_image(line_crop, text_word=line.get("text", "N/A")) ai_font_res = ai_font_service.recognize_font_from_image(line_crop, text_word=line.get("text", "N/A"))
if ai_font_res:
logger.info(
"[OCR_FONT_METADATA] "
"word=%r family=%s face=%s weight=%s style=%s",
line.get("text", "N/A"),
ai_font_res.get("fontFamily"),
ai_font_res.get("fontFace"),
ai_font_res.get("fontWeight"),
ai_font_res.get("fontStyle"),
)
except Exception as crop_err: except Exception as crop_err:
print(f"[OCR] FontRecognitionService crop error: {crop_err}") print(f"[OCR] FontRecognitionService crop error: {crop_err}")