font issue
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) ── */}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)()
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user