font issue
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
#include <pdfengine/pdf_document.hpp>
|
||||
#include <pdfengine/pdf_engine.hpp>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <pybind11/pybind11.h>
|
||||
#include <pybind11/stl.h>
|
||||
|
||||
@@ -616,44 +617,74 @@ PYBIND11_MODULE(pdfengine, m) {
|
||||
[](const pdfengine::ocr::OCRCoordinator& self, int pageIndex, double imgW, double imgH,
|
||||
double pdfW, double pdfH, const py::list& lines_list) {
|
||||
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) {
|
||||
if (item.is_none()) continue;
|
||||
py::dict d = item.cast<py::dict>();
|
||||
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>();
|
||||
line.x = box["x"].cast<double>();
|
||||
line.y = box["y"].cast<double>();
|
||||
line.width = box["width"].cast<double>();
|
||||
line.height = box["height"].cast<double>();
|
||||
line.x = get_double_safe(box, "x", 0.0);
|
||||
line.y = get_double_safe(box, "y", 0.0);
|
||||
line.width = get_double_safe(box, "width", 0.0);
|
||||
line.height = get_double_safe(box, "height", 0.0);
|
||||
} else {
|
||||
if (d.contains("x"))
|
||||
line.x = d["x"].cast<double>();
|
||||
if (d.contains("y"))
|
||||
line.y = d["y"].cast<double>();
|
||||
if (d.contains("width"))
|
||||
line.width = d["width"].cast<double>();
|
||||
if (d.contains("height"))
|
||||
line.height = d["height"].cast<double>();
|
||||
line.x = get_double_safe(d, "x", 0.0);
|
||||
line.y = get_double_safe(d, "y", 0.0);
|
||||
line.width = get_double_safe(d, "width", 0.0);
|
||||
line.height = get_double_safe(d, "height", 0.0);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return self.processDocument(pageIndex, imgW, imgH, pdfW, pdfH, cpp_lines);
|
||||
|
||||
@@ -27,6 +27,9 @@ struct RawOCRLine {
|
||||
std::vector<RawOCRWord> words;
|
||||
std::string fontName;
|
||||
std::string fontId;
|
||||
std::string fontFace;
|
||||
int fontWeight = 400;
|
||||
std::string fontStyle = "normal";
|
||||
double fontSize = 0.0;
|
||||
double lineSpacing = 1.2;
|
||||
double letterSpacing = 0.0;
|
||||
|
||||
@@ -142,6 +142,9 @@ struct Glyph {
|
||||
struct TextRun {
|
||||
std::string text;
|
||||
std::string fontName;
|
||||
std::string fontFace;
|
||||
int fontWeight = 400;
|
||||
std::string fontStyle = "normal";
|
||||
uint32_t flags = 0;
|
||||
double fontSize = 0.0;
|
||||
std::string internalFontId;
|
||||
|
||||
@@ -34,11 +34,31 @@ PageModel DocumentBuilder::buildFromOCR(const RawOCRPage& rawPage) const {
|
||||
TextRun run;
|
||||
run.text = rawLine.text;
|
||||
run.fontName = rawLine.fontName.empty() ? "Helvetica" : rawLine.fontName;
|
||||
run.internalFontId = rawLine.fontId.empty()
|
||||
? (run.fontName + "_TrueType_" + std::to_string(run.flags))
|
||||
: rawLine.fontId;
|
||||
|
||||
bool isBold = rawLine.isBold || rawLine.fontWeight >= 600;
|
||||
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.flags = (rawLine.isBold ? 2 : 0) | (rawLine.isItalic ? 1 : 0);
|
||||
run.x = rawLine.x;
|
||||
run.y = rawLine.y;
|
||||
run.w = rawLine.width;
|
||||
|
||||
@@ -32,7 +32,17 @@ pdfengine::PageModel OCRCoordinator::processDocument(
|
||||
for (const auto& paragraph : pageModel.paragraphs) {
|
||||
for (const auto& line : paragraph.lines) {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +199,17 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
|
||||
const [tempBounds, setTempBounds] = useState<
|
||||
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);
|
||||
|
||||
@@ -369,16 +380,24 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
|
||||
|
||||
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
|
||||
// couldn't determine the size (scanned PDF, no glyph data propagated),
|
||||
// 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.
|
||||
// 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 rawFontSize = block.textStyle?.fontSize ?? (bounds.height * 0.72);
|
||||
const rawFontSize = block.textStyle?.fontSize ?? (baseHeight * 0.72);
|
||||
let calibratedBaseSize = rawFontSize;
|
||||
|
||||
if (isOcrBlock && blockText && bounds.width > 0) {
|
||||
const targetW = bounds.width * scale;
|
||||
if (isOcrBlock && blockText && baseWidth > 0 && !hasMoved) {
|
||||
const targetW = baseWidth * scale;
|
||||
const initFontSz = rawFontSize * 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';
|
||||
@@ -391,17 +410,16 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
|
||||
weightStr,
|
||||
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 isResizing = !!tempBounds[block.id];
|
||||
|
||||
return (
|
||||
<React.Fragment key={block.id}>
|
||||
{/* White Eraser at original PDF position when block is moved or edited */}
|
||||
{(hasMoved || isEditing) && (
|
||||
{(hasMoved || isEditing || isTextChanged) && (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
@@ -419,7 +437,12 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
|
||||
<div
|
||||
className={`absolute ${block.permissions?.selectable === false ? 'pointer-events-none' : 'pointer-events-auto'} select-none rounded-[2px]`}
|
||||
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',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
@@ -433,11 +456,9 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
|
||||
? '1.5px solid #2563eb'
|
||||
: '1px dotted #3b82f6',
|
||||
outlineOffset: '0px',
|
||||
backgroundColor: (hasMoved || isEditing)
|
||||
? '#ffffff'
|
||||
: (isSelected || isHovered)
|
||||
? 'rgba(37, 99, 235, 0.07)'
|
||||
: 'transparent',
|
||||
backgroundColor: (isSelected || isHovered)
|
||||
? 'rgba(37, 99, 235, 0.07)'
|
||||
: 'transparent',
|
||||
zIndex: isSelected ? 46 : isHovered ? 45 : 45,
|
||||
}}
|
||||
onMouseEnter={() => setHoveredBlockId(block.id)}
|
||||
@@ -445,18 +466,16 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
|
||||
onPointerDown={(e) => startDrag(e, block, 'move')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
console.log('[Step 1 - Selected Block Metadata]', {
|
||||
id: block.id,
|
||||
text: blockText,
|
||||
fontFamily: block.textStyle?.fontName,
|
||||
internalFontId: block.textStyle?.fontId,
|
||||
fontSize: block.textStyle?.fontSize,
|
||||
fontWeight: block.textStyle?.isBold ? 'bold' : 'normal',
|
||||
fontStyle: block.textStyle?.isItalic ? 'italic' : 'normal',
|
||||
lineHeight: block.textStyle?.lineSpacing,
|
||||
letterSpacing: block.textStyle?.letterSpacing,
|
||||
color: block.textStyle?.fontColor,
|
||||
fullBlock: block,
|
||||
console.log('[Step 1 - Selected Block Metadata]');
|
||||
console.table({
|
||||
text: blockText || block.text,
|
||||
fontFamily: block.textStyle?.fontName || block.fontFamily || 'Helvetica',
|
||||
fontFace: block.textStyle?.fontFace || (block.textStyle?.isBold ? `${block.textStyle?.fontName || 'Helvetica'}-Bold` : block.textStyle?.fontName || 'Helvetica'),
|
||||
fontWeight: block.textStyle?.fontWeight ?? (block.textStyle?.isBold ? 700 : 400),
|
||||
isBold: block.textStyle?.isBold ?? (block.textStyle?.fontWeight ? block.textStyle.fontWeight >= 600 : false),
|
||||
fontStyle: block.textStyle?.fontStyle || (block.textStyle?.isItalic ? 'italic' : 'normal'),
|
||||
internalFontId: block.textStyle?.fontId || block.internalFontId || '',
|
||||
fontSize: block.textStyle?.fontSize || block.fontSize || 12,
|
||||
});
|
||||
if (isSelected && editingBlockId !== 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) ── */}
|
||||
{!isImage && (
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el && isEditing) {
|
||||
el.focus();
|
||||
try {
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.selectNodeContents(el);
|
||||
range.collapse(false);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
} catch { }
|
||||
}
|
||||
}}
|
||||
contentEditable={isEditing}
|
||||
suppressContentEditableWarning={true}
|
||||
className={`p-0 m-0 box-border outline-none border-none ${isResizing ? 'w-auto h-auto' : 'w-full h-full'
|
||||
} ${isEditing ? 'pointer-events-auto cursor-text bg-white' : 'pointer-events-none select-none'
|
||||
}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
overflow: 'visible',
|
||||
fontSize: `${fontSz}px`,
|
||||
lineHeight: '1.2',
|
||||
letterSpacing: block.textStyle?.letterSpacing ? `${block.textStyle.letterSpacing * scale}px` : '0px',
|
||||
color: (isOcrBlock && !hasMoved && !isEditing) ? 'transparent' : (block.textStyle?.fontColor || '#000000'),
|
||||
fontWeight: (block.textStyle?.fontName && /bold|black|heavy|semibold/i.test(block.textStyle.fontName))
|
||||
? 'normal'
|
||||
: (block.textStyle?.isBold ? 'bold' : 'normal'),
|
||||
fontStyle: (block.textStyle?.fontName && /italic|oblique/i.test(block.textStyle.fontName))
|
||||
? 'normal'
|
||||
: (block.textStyle?.isItalic ? 'italic' : 'normal'),
|
||||
fontFamily: resolvedFontFamily,
|
||||
whiteSpace: isSingleLine ? 'pre' : 'pre-wrap',
|
||||
wordBreak: isSingleLine ? 'normal' : 'break-word',
|
||||
fontVariantLigatures: 'none',
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const val = e.currentTarget.innerText || '';
|
||||
if (val !== blockText && onUpdateBlockText) {
|
||||
onUpdateBlockText(block.id, val);
|
||||
}
|
||||
setEditingBlockId(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setEditingBlockId(null);
|
||||
} else if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const val = e.currentTarget.innerText || '';
|
||||
if (val !== blockText && onUpdateBlockText) {
|
||||
onUpdateBlockText(block.id, val);
|
||||
}
|
||||
setEditingBlockId(null);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
const val = e.currentTarget.innerText || '';
|
||||
if (val !== blockText && onUpdateBlockText) {
|
||||
onUpdateBlockText(block.id, val);
|
||||
}
|
||||
setEditingBlockId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{blockText}
|
||||
</div>
|
||||
(() => {
|
||||
if (isSelected || isEditing) {
|
||||
console.log("[EDITOR_FINAL_FONT]", {
|
||||
text: blockText || block.text,
|
||||
fontFamily: resolvedFontFamily,
|
||||
fontFace: block.textStyle?.fontFace || (block.textStyle?.isBold ? `${resolvedFontFamily}-Bold` : resolvedFontFamily),
|
||||
fontWeight: (block.textStyle?.fontName && /bold|black|heavy|semibold/i.test(block.textStyle.fontName)) ? 'normal' : (block.textStyle?.isBold ? 'bold' : 'normal'),
|
||||
fontStyle: (block.textStyle?.fontName && /italic|oblique/i.test(block.textStyle.fontName)) ? 'normal' : (block.textStyle?.isItalic ? 'italic' : 'normal'),
|
||||
fontSize: `${fontSz}px`,
|
||||
lineHeight: '1.2',
|
||||
letterSpacing: block.textStyle?.letterSpacing ? `${block.textStyle.letterSpacing * scale}px` : '0px',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
if (isEditing) {
|
||||
el.focus();
|
||||
try {
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.selectNodeContents(el);
|
||||
range.collapse(false);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
} catch { }
|
||||
}
|
||||
if (isSelected || isEditing) {
|
||||
requestAnimationFrame(() => {
|
||||
if (!el) return;
|
||||
const cs = getComputedStyle(el);
|
||||
console.table({
|
||||
actualFontFamily: cs.fontFamily,
|
||||
actualFontWeight: cs.fontWeight,
|
||||
actualFontStyle: cs.fontStyle,
|
||||
actualFontSize: cs.fontSize,
|
||||
actualLineHeight: cs.lineHeight,
|
||||
actualLetterSpacing: cs.letterSpacing,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
contentEditable={isEditing}
|
||||
suppressContentEditableWarning={true}
|
||||
className={`p-0 m-0 box-border outline-none border-none ${isResizing ? 'w-auto h-auto' : 'w-full h-full'
|
||||
} ${isEditing ? 'pointer-events-auto cursor-text bg-transparent' : 'pointer-events-none select-none'
|
||||
}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: (isEditing || isTextChanged) ? 'auto' : '100%',
|
||||
minHeight: (isEditing || isTextChanged) ? '100%' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
overflow: 'visible',
|
||||
fontSize: `${fontSz}px`,
|
||||
lineHeight: '1.2',
|
||||
letterSpacing: block.textStyle?.letterSpacing ? `${block.textStyle.letterSpacing * scale}px` : '0px',
|
||||
color: (isOcrBlock && !hasMoved && !isEditing && !isTextChanged) ? 'transparent' : (block.textStyle?.fontColor || '#000000'),
|
||||
fontWeight: (block.textStyle?.fontName && /bold|black|heavy|semibold/i.test(block.textStyle.fontName))
|
||||
? 'normal'
|
||||
: (block.textStyle?.isBold ? 'bold' : 'normal'),
|
||||
fontStyle: (block.textStyle?.fontName && /italic|oblique/i.test(block.textStyle.fontName))
|
||||
? 'normal'
|
||||
: (block.textStyle?.isItalic ? 'italic' : 'normal'),
|
||||
fontFamily: resolvedFontFamily,
|
||||
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) ── */}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
"""Visual Font Classifier.
|
||||
|
||||
Analyzes image crops of text elements to classify font family, weight,
|
||||
and style characteristics.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional
|
||||
import math
|
||||
from PIL import Image
|
||||
|
||||
from app.ai.config import get_ai_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FontClassifier:
|
||||
"""Classifies font properties from visual image crops using feature extraction and similarity matching."""
|
||||
@@ -17,7 +14,7 @@ class FontClassifier:
|
||||
def __init__(self, settings=None):
|
||||
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.
|
||||
Returns top font candidates, estimated weight (bold), and style (italic).
|
||||
@@ -31,16 +28,19 @@ class FontClassifier:
|
||||
mean_val = sum(pixels) / max(1, len(pixels))
|
||||
|
||||
# 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))
|
||||
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
|
||||
candidates = []
|
||||
families = self.settings.supported_font_families
|
||||
for idx, family in enumerate(families):
|
||||
# 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():
|
||||
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():
|
||||
@@ -56,15 +56,48 @@ class FontClassifier:
|
||||
candidates.sort(key=lambda c: c["confidence"], reverse=True)
|
||||
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": top_match["font"],
|
||||
"fontFamily": top_match["fontFamily"],
|
||||
font_family = top_match.get("fontFamily") or top_match.get("font") or "Helvetica"
|
||||
is_italic = False
|
||||
|
||||
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"],
|
||||
"isBold": is_bold,
|
||||
"isItalic": False,
|
||||
"isItalic": is_italic,
|
||||
"candidates": top_candidates,
|
||||
"aspectRatio": round(aspect_ratio, 2),
|
||||
"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
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ class FontRecognitionService:
|
||||
return True
|
||||
|
||||
# ── 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
|
||||
font family, weight (isBold), style (isItalic), and visual embedding vector.
|
||||
@@ -126,7 +126,7 @@ class FontRecognitionService:
|
||||
self.load_model()
|
||||
|
||||
# Run visual classification
|
||||
res = self.classifier.classify_crop(image)
|
||||
res = self.classifier.classify_crop(image, text=text)
|
||||
|
||||
# Generate visual embedding vector (512 dimensions)
|
||||
embedding = self.embedding_store.generate_image_embedding(image)
|
||||
@@ -202,7 +202,7 @@ class FontRecognitionService:
|
||||
prep_img = self.preprocess(image)
|
||||
|
||||
# 3. Run Model Prediction
|
||||
result = self.predict(prep_img)
|
||||
result = self.predict(prep_img, text=word_label)
|
||||
|
||||
# 4. Generate Embedding & Search Vector Store
|
||||
embedding = self.embedding_store.generate_image_embedding(prep_img)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Dict, Any, Optional
|
||||
import re as _re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from app.services import engine
|
||||
from app.services.store import document_store
|
||||
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_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:
|
||||
import pdfengine as cpp_pdfengine
|
||||
coordinator = getattr(cpp_pdfengine, "OCRCoordinator", None)()
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
from PIL import Image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ocr_engine = None
|
||||
_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)
|
||||
# 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"))
|
||||
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:
|
||||
print(f"[OCR] FontRecognitionService crop error: {crop_err}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user