fix the cursor issue

This commit is contained in:
saqib mir
2026-08-06 16:27:59 +05:30
parent 0a97af54c9
commit 778845b340
5 changed files with 312 additions and 163 deletions
+1
View File
@@ -93,6 +93,7 @@ export interface LayoutBlock {
};
textStyle?: {
fontName?: string;
fontId?: string;
fontSize?: number;
fontColor?: string;
isBold?: boolean;
+163 -113
View File
@@ -1,20 +1,17 @@
import React, { useState, useRef, useEffect } from 'react';
import { gatewayService, type LayoutBlock } from '../lib/gatewayService';
import { loadPdfFont } from '../lib/fontFaceLoader';
/** Map a raw PDF font name to a CSS font-family stack */
function fallbackFamily(fontName: string): string {
const n = (fontName || '').toLowerCase().replace(/^[a-z]{6}\+/, '');
if (n.includes('times') || (n.includes('serif') && !n.includes('sans'))) {
return '"Times New Roman", Times, Georgia, serif';
/** Resolve backend font metadata directly into exact CSS font-family without generic browser fallbacks */
function getResolvedFontFamily(fontName?: string, fontId?: string, loadedFonts: Record<string, string> = {}): string | undefined {
if (fontId && loadedFonts[fontId]) {
return `"${loadedFonts[fontId]}"`;
}
if (n.includes('courier') || n.includes('mono')) {
return '"Courier New", Courier, monospace';
if (fontName) {
const clean = fontName.replace(/^[A-Z]{6}\+/, '').trim();
return clean ? `"${clean}"` : undefined;
}
if (n.includes('arial') || n.includes('helvetica') || n.includes('sans')) {
return 'Arial, "Helvetica Neue", Helvetica, sans-serif';
}
const clean = fontName.replace(/^[A-Z]{6}\+/, '').trim();
return clean ? `"${clean}", Arial, sans-serif` : 'Arial, sans-serif';
return undefined;
}
export interface BlockEditPayload {
@@ -71,6 +68,54 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
}) => {
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
const [hoveredBlockId, setHoveredBlockId] = useState<string | null>(null);
const [loadedFonts, setLoadedFonts] = useState<Record<string, string>>({});
const [fontsReady, setFontsReady] = useState<boolean>(false);
// Pre-load all real embedded PDF font bytes for page blocks up front BEFORE allowing interactions
useEffect(() => {
if (!documentId) {
setFontsReady(true);
return;
}
let isMounted = true;
const fontPromises: Promise<{ fontId: string; family: string | null }>[] = [];
blocks.forEach((block) => {
const fontId = block.textStyle?.fontId;
const fontName = block.textStyle?.fontName;
if (fontId && fontName && !loadedFonts[fontId]) {
const isBold = block.textStyle?.isBold;
fontPromises.push(
loadPdfFont(documentId, fontId, fontName, isBold ? 'bold' : 'normal').then((family) => ({
fontId,
family,
}))
);
}
});
if (fontPromises.length === 0) {
setFontsReady(true);
return;
}
Promise.all(fontPromises).then((results) => {
if (!isMounted) return;
const newFonts: Record<string, string> = {};
results.forEach(({ fontId, family }) => {
if (family) newFonts[fontId] = family;
});
setLoadedFonts((prev) => ({ ...prev, ...newFonts }));
document.fonts.ready.then(() => {
if (isMounted) setFontsReady(true);
});
});
return () => {
isMounted = false;
};
}, [documentId, blocks]);
// Live bounds during drag
const [tempBounds, setTempBounds] = useState<
@@ -98,7 +143,7 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
e.preventDefault();
const txt = selBlock.text || (selBlock.children ? selBlock.children.map((c: any) => typeof c === 'string' ? c : (c.text ?? '')).join('\n') : '');
if (txt) navigator.clipboard?.writeText(txt).catch(() => {});
if (txt) navigator.clipboard?.writeText(txt).catch(() => { });
onCopyBlock?.(selBlock);
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
e.preventDefault();
@@ -116,7 +161,7 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
) => {
e.stopPropagation();
const target = e.currentTarget as HTMLElement;
try { target.setPointerCapture(e.pointerId); } catch {}
try { target.setPointerCapture(e.pointerId); } catch { }
onSelectBlock(block.id);
const startX = e.clientX;
@@ -155,23 +200,23 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
y = Math.max(0, initial.y + dy);
} else {
switch (handle) {
case 'se': width = Math.max(MIN, initial.width + dx); height = Math.max(MIN, initial.height + dy); break;
case 's': height = Math.max(MIN, initial.height + dy); break;
case 'e': width = Math.max(MIN, initial.width + dx); break;
case 'se': width = Math.max(MIN, initial.width + dx); height = Math.max(MIN, initial.height + dy); break;
case 's': height = Math.max(MIN, initial.height + dy); break;
case 'e': width = Math.max(MIN, initial.width + dx); break;
case 'nw':
width = Math.max(MIN, initial.width - dx); x = initial.x + (initial.width - width);
width = Math.max(MIN, initial.width - dx); x = initial.x + (initial.width - width);
height = Math.max(MIN, initial.height - dy); y = initial.y + (initial.height - height);
break;
case 'n':
height = Math.max(MIN, initial.height - dy); y = initial.y + (initial.height - height); break;
case 'w':
width = Math.max(MIN, initial.width - dx); x = initial.x + (initial.width - width); break;
width = Math.max(MIN, initial.width - dx); x = initial.x + (initial.width - width); break;
case 'ne':
width = Math.max(MIN, initial.width + dx);
width = Math.max(MIN, initial.width + dx);
height = Math.max(MIN, initial.height - dy); y = initial.y + (initial.height - height);
break;
case 'sw':
width = Math.max(MIN, initial.width - dx); x = initial.x + (initial.width - width);
width = Math.max(MIN, initial.width - dx); x = initial.x + (initial.width - width);
height = Math.max(MIN, initial.height + dy);
break;
}
@@ -184,7 +229,7 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
const onPointerUp = (ev: PointerEvent) => {
ev.stopPropagation();
try { target.releasePointerCapture(e.pointerId); } catch {}
try { target.releasePointerCapture(e.pointerId); } catch { }
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
@@ -209,16 +254,16 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
>
{blocks.map((block) => {
const isSelected = selectedBlockId === block.id;
const isHovered = hoveredBlockId === block.id;
const isEditing = editingBlockId === block.id;
const isImage = block.type === 'image';
const isHovered = hoveredBlockId === block.id;
const isEditing = editingBlockId === block.id;
const isImage = block.type === 'image';
const isOcrBlock = block.id.startsWith('ocr_');
// Bounding Box Rule: Exact calculation from LayoutBlock geometry
// Bounding Box Rule: Immutable calculation directly from LayoutBlock geometry
const bounds = tempBounds[block.id] || block.bounds;
const left = bounds.x * scale;
const top = bounds.y * scale;
const width = bounds.width * scale;
const left = bounds.x * scale;
const top = bounds.y * scale;
const width = bounds.width * scale;
const height = bounds.height * scale;
const rotation = block.rotation || 0;
@@ -244,7 +289,18 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
: ''
);
const fontSz = block.textStyle?.fontSize || Math.max(10, Math.min(24, height * 0.8));
const isSingleLine = !blockText.includes('\n');
// 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.
const baseFontSize = block.textStyle?.fontSize ?? (bounds.height * 0.72);
const fontSz = baseFontSize * scale;
const resolvedFontFamily = getResolvedFontFamily(block.textStyle?.fontName, block.textStyle?.fontId, loadedFonts);
const isResizing = !!tempBounds[block.id];
return (
<React.Fragment key={block.id}>
@@ -253,10 +309,10 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
<div
className="absolute pointer-events-none"
style={{
left: `${orig.x * scale}px`,
top: `${orig.y * scale}px`,
left: `${orig.x * scale}px`,
top: `${orig.y * scale}px`,
width: `${orig.width * scale}px`,
height:`${orig.height * scale}px`,
height: `${orig.height * scale}px`,
backgroundColor: '#ffffff',
zIndex: 44,
}}
@@ -265,7 +321,7 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
{/* ── MAIN BLOCK CONTAINER ── */}
<div
className={`absolute ${block.permissions?.selectable === false ? 'pointer-events-none' : 'pointer-events-auto'} select-none rounded-[2px] transition-all duration-150`}
className={`absolute ${block.permissions?.selectable === false ? 'pointer-events-none' : 'pointer-events-auto'} select-none rounded-[2px]`}
style={{
left, top, width, height,
transform: rotation ? `rotate(${rotation}deg)` : undefined,
@@ -281,8 +337,8 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
backgroundColor: (hasMoved || isEditing)
? '#ffffff'
: (isSelected || isHovered)
? 'rgba(37, 99, 235, 0.07)'
: 'transparent',
? 'rgba(37, 99, 235, 0.07)'
: 'transparent',
zIndex: isSelected ? 46 : isHovered ? 45 : 45,
}}
onMouseEnter={() => setHoveredBlockId(block.id)}
@@ -291,8 +347,10 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
onClick={(e) => {
e.stopPropagation();
if (isSelected && editingBlockId !== block.id) {
// Second click on already selected block -> enter edit mode immediately!
setEditingBlockId(block.id);
// Second click on already selected block -> enter edit mode
// only after all embedded fonts are loaded and registered,
// preventing a mid-edit font swap.
if (fontsReady) setEditingBlockId(block.id);
} else {
// Single click -> select block
onSelectBlock(block.id);
@@ -301,7 +359,7 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
onDoubleClick={(e) => {
e.stopPropagation();
onSelectBlock(block.id);
setEditingBlockId(block.id);
if (fontsReady) setEditingBlockId(block.id);
}}
>
{/* ── IMAGE CONTENT ── */}
@@ -328,49 +386,92 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
</>
)}
{/* ── TEXT CONTENT DISPLAY ── */}
{!isImage && !isEditing && (
{/* ── UNIFIED TEXT DISPLAY & INLINE EDITOR (Single contentEditable div, zero element switching, zero layout jumps) ── */}
{!isImage && (
<div
className="w-full h-full pointer-events-none flex flex-col justify-center font-sans p-0 m-0 box-border overflow-visible"
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 align-top 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: isResizing ? `${orig.width * scale}px` : '100%',
height: isResizing ? `${orig.height * scale}px` : '100%',
overflow: isResizing ? 'visible' : 'hidden',
fontSize: `${fontSz}px`,
lineHeight: 1.15,
color: (isOcrBlock && !hasMoved) ? 'transparent' : (block.textStyle?.fontColor || '#000000'),
lineHeight: block.textStyle?.lineSpacing ? `${block.textStyle.lineSpacing}` : '1.0',
letterSpacing: block.textStyle?.letterSpacing ? `${block.textStyle.letterSpacing * scale}px` : '0px',
color: (isOcrBlock && !hasMoved && !isEditing) ? 'transparent' : (block.textStyle?.fontColor || '#000000'),
fontWeight: (block.textStyle?.isBold || (block.textStyle?.fontName && /bold|black|heavy/i.test(block.textStyle.fontName))) ? 'bold' : 'normal',
fontFamily: block.textStyle?.fontName
? fallbackFamily(block.textStyle.fontName)
: undefined,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontStyle: (block.textStyle?.isItalic || (block.textStyle?.fontName && /italic|oblique/i.test(block.textStyle.fontName))) ? 'italic' : 'normal',
fontFamily: resolvedFontFamily,
whiteSpace: isSingleLine ? 'nowrap' : 'pre-wrap',
wordBreak: isSingleLine ? 'normal' : 'break-word',
textRendering: 'geometricPrecision',
fontVariantLigatures: 'none',
WebkitFontSmoothing: 'antialiased',
}}
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);
}
}}
>
{block.children && block.children.length > 0 ? (
block.children.map((l: any, idx: number) => (
<div key={idx} className="whitespace-pre-wrap p-0 m-0">
{typeof l === 'string' ? l : (l.text ?? '')}
</div>
))
) : (
<div className="whitespace-pre-wrap p-0 m-0">{blockText}</div>
)}
{blockText}
</div>
)}
{/* ── RESIZE HANDLES (Positioned strictly OUTSIDE the block so they NEVER overlap text) ── */}
{isSelected && (
<>
{/* Left Circular Handle - Drawn outside left boundary */}
{/* Left Circular Handle - Drawn completely outside left boundary */}
<div
className="absolute top-1/2 -translate-y-1/2 w-4 h-4 rounded-full bg-[#4f46e5] border-2 border-white shadow cursor-ew-resize pointer-events-auto"
style={{ left: '-12px', zIndex: 70 }}
style={{ left: '-18px', zIndex: 70 }}
onPointerDown={(e) => startDrag(e, block, 'w')}
title="Drag to resize width"
/>
{/* Right Circular Handle - Drawn outside right boundary */}
{/* Right Circular Handle - Drawn completely outside right boundary */}
<div
className="absolute top-1/2 -translate-y-1/2 w-4 h-4 rounded-full bg-[#4f46e5] border-2 border-white shadow cursor-ew-resize pointer-events-auto"
style={{ right: '-12px', zIndex: 70 }}
style={{ right: '-18px', zIndex: 70 }}
onPointerDown={(e) => startDrag(e, block, 'e')}
title="Drag to resize width"
/>
@@ -410,57 +511,6 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
/>
</>
)}
{/* ── IMMEDIATE INLINE TEXT EDITOR (Zero-padding overlay matching block geometry exactly, with cursor auto-positioned at end) ── */}
{isEditing && !isImage && (
<textarea
ref={(el) => {
if (el) {
el.focus();
const len = el.value.length;
el.setSelectionRange(len, len);
}
}}
className="w-full h-full bg-white text-black outline-none border-none resize-none pointer-events-auto p-0 m-0 font-sans leading-tight cursor-text whitespace-pre-wrap overflow-hidden box-border"
style={{
fontSize: `${fontSz}px`,
lineHeight: 1.15,
color: block.textStyle?.fontColor || '#000000',
fontWeight: (block.textStyle?.isBold || (block.textStyle?.fontName && /bold|black|heavy/i.test(block.textStyle.fontName))) ? 'bold' : 'normal',
fontFamily: block.textStyle?.fontName
? fallbackFamily(block.textStyle.fontName)
: undefined,
outline: '1.5px solid #2563eb',
outlineOffset: '0px',
wordBreak: 'break-word',
}}
autoFocus
defaultValue={blockText}
onFocus={(e) => {
const len = e.currentTarget.value.length;
e.currentTarget.setSelectionRange(len, len);
}}
onBlur={(e) => {
const val = e.target.value;
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.target as HTMLTextAreaElement).value;
if (val !== blockText && onUpdateBlockText) {
onUpdateBlockText(block.id, val);
}
setEditingBlockId(null);
}
}}
/>
)}
</div>
</React.Fragment>
);
+11 -6
View File
@@ -34,8 +34,9 @@ class VisualStyleModel(BaseModel):
class TextStyleModel(BaseModel):
fontName: str = "Helvetica"
fontSize: float = 12.0
fontName: Optional[str] = None
fontId: Optional[str] = None
fontSize: Optional[float] = None
fontColor: str = "#000000"
isBold: bool = False
isItalic: bool = False
@@ -276,10 +277,14 @@ def get_page_layout(document_id: str, page_index: int) -> PageLayoutResponse:
visualStyle=VisualStyleModel(),
text=text_val,
textStyle=TextStyleModel(
fontSize=float(data.get("fontSize", 12.0)),
fontSize=data.get("fontSize"),
fontColor=data.get("color", "#000000"),
fontName=data.get("fontName") or "Helvetica",
isBold=bool(data.get("isBold", False))
fontName=data.get("fontName") or None,
fontId=data.get("fontId"),
isBold=bool(data.get("isBold", False)),
isItalic=bool(data.get("isItalic", False)),
lineSpacing=float(data.get("lineSpacing")) if data.get("lineSpacing") is not None else 1.2,
letterSpacing=float(data.get("letterSpacing", 0.0)),
),
layoutStyle=LayoutStyleModel(),
children=[
@@ -291,7 +296,7 @@ def get_page_layout(document_id: str, page_index: int) -> PageLayoutResponse:
width=float(data.get("width", 100.0)),
height=float(data.get("height", 100.0)),
),
baselineY=float(data.get("y", 0.0)) + float(data.get("fontSize", 12.0)),
baselineY=float(data.get("y", 0.0)) + float(data.get("fontSize") or 12.0),
lineHeight=float(data.get("height", 100.0)),
runs=[
TextRunModel(
+34 -9
View File
@@ -28,8 +28,12 @@ class OCRLineResponse(BaseModel):
words: List[OCRWordResponse]
polygon: List[OCRPolygonPoint]
fontName: Optional[str] = None
fontId: Optional[str] = None
fontSize: Optional[float] = None
lineSpacing: Optional[float] = None
letterSpacing: Optional[float] = None
isBold: Optional[bool] = False
isItalic: Optional[bool] = False
class OCRPageResponse(BaseModel):
@@ -46,6 +50,7 @@ class ApplyOCRRequest(BaseModel):
class FontAtPositionResponse(BaseModel):
fontName: Optional[str] = None
fontId: Optional[str] = None
fontSize: Optional[float] = None
isBold: Optional[bool] = False
isItalic: Optional[bool] = False
@@ -84,9 +89,16 @@ def perform_ocr_on_page(document_id: str, page_index: int) -> OCRPageResponse:
ts = int(time.time() * 1000)
for idx, line in enumerate(lines):
b = line["box"]
detected_font_size = line.get("fontSize") or max(8.0, b["height"] * 0.72)
# Use the value computed by the service pipeline — it already applied
# the best available estimate (glyph measurement or bh*0.72). Never
# reinvent a different fallback here.
font_size = line.get("fontSize")
is_bold = bool(line.get("isBold", False))
is_italic = bool(line.get("isItalic", False))
font_name = line.get("fontName")
font_id = line.get("fontId")
line_spacing = line.get("lineSpacing")
letter_spacing = line.get("letterSpacing", 0.0)
op = {
"id": f"ocr_{idx}_{page_index}_{ts}",
"type": "text_overlay",
@@ -97,10 +109,14 @@ def perform_ocr_on_page(document_id: str, page_index: int) -> OCRPageResponse:
"width": b["width"],
"height": b["height"],
"text": line["text"],
"fontSize": detected_font_size,
"fontSize": font_size,
"lineSpacing": line_spacing,
"letterSpacing": letter_spacing,
"color": "#000000",
"fontName": font_name,
"fontId": font_id,
"isBold": is_bold,
"isItalic": is_italic,
"rotation": 0.0,
}
}
@@ -131,10 +147,10 @@ def apply_ocr_to_page(document_id: str, page_index: int, req: ApplyOCRRequest):
applied_count = 0
for line in req.lines:
b = line.box
# Use box height to estimate font size; ignore any OCR-guessed font name
detected_font_size = line.fontSize if line.fontSize else max(8.0, b["height"] * 0.72)
# Pass through what the service already computed; no re-estimation here.
font_size = line.fontSize
is_bold = bool(line.isBold)
# Store no fontName so frontend can detect it on click via ordered_glyphs
is_italic = bool(line.isItalic)
op = {
"id": f"ocr_{applied_count}_{page_index}",
"type": "text_overlay",
@@ -145,10 +161,14 @@ def apply_ocr_to_page(document_id: str, page_index: int, req: ApplyOCRRequest):
"width": b["width"],
"height": b["height"],
"text": line.text,
"fontSize": detected_font_size,
"fontSize": font_size,
"lineSpacing": line.lineSpacing,
"letterSpacing": line.letterSpacing,
"color": "#000000",
"fontName": None, # will be resolved at click time
"fontName": line.fontName,
"fontId": line.fontId,
"isBold": is_bold,
"isItalic": is_italic,
}
}
document_store.add_operation(document_id, op)
@@ -225,19 +245,23 @@ def get_font_at_position(
from collections import Counter
font_counts: Counter = Counter()
font_sizes: Dict[str, list] = {}
font_ids: Dict[str, str] = {}
for g in matched_glyphs:
fn = str(getattr(g, "font_name", "") or "")
fid = str(getattr(g, "internal_font_id", getattr(g, "font_id", "")) or "")
fs = float(getattr(g, "font_size", 0) or 0)
flags = int(getattr(g, "flags", 0) or 0)
if fn:
clean = _re.sub(r"^[A-Z]{6}\+", "", fn).strip()
font_counts[clean] += 1
if fid and not font_ids.get(clean):
font_ids[clean] = fid
font_sizes.setdefault(clean, []).append(fs)
if not font_counts:
return FontAtPositionResponse()
best_font = font_counts.most_common(1)[0][0]
best_font_id = font_ids.get(best_font)
sizes = font_sizes.get(best_font, [12.0])
avg_size = round(sum(sizes) / len(sizes), 2)
@@ -245,9 +269,10 @@ def get_font_at_position(
is_bold = "bold" in best_font.lower() or "black" in best_font.lower() or "heavy" in best_font.lower()
is_italic = "italic" in best_font.lower() or "oblique" in best_font.lower()
print(f"[font_at] ({x:.1f},{y:.1f}) => font='{best_font}' size={avg_size} bold={is_bold}")
print(f"[font_at] ({x:.1f},{y:.1f}) => font='{best_font}' id='{best_font_id}' size={avg_size} bold={is_bold}")
return FontAtPositionResponse(
fontName=best_font,
fontId=best_font_id,
fontSize=avg_size,
isBold=is_bold,
isItalic=is_italic,
+103 -35
View File
@@ -199,6 +199,77 @@ def _match_font_to_box(box: Dict[str, float], spans: List[Dict[str, Any]]) -> Op
return best
def _match_glyph_font_to_box(pdf_l_box: Dict[str, float], page: Any) -> Optional[Dict[str, Any]]:
"""Query page.ordered_glyphs() for the dominant font, size, bold, and italic at box (x,y,w,h)."""
if not hasattr(page, "ordered_glyphs"):
return None
try:
glyphs = page.ordered_glyphs()
if not glyphs:
return None
bx, by, bw, bh = pdf_l_box["x"], pdf_l_box["y"], pdf_l_box["width"], pdf_l_box["height"]
matched = []
for g in glyphs:
gx = float(getattr(g, "bbox_x", getattr(g, "origin_x", 0)) or 0)
gy = float(getattr(g, "bbox_y", getattr(g, "origin_y", 0)) or 0)
gw = float(getattr(g, "bbox_w", 0) or 0)
gh = float(getattr(g, "bbox_h", 0) or 0)
cx = gx + gw / 2.0
cy = gy + gh / 2.0
if bx - 5 <= cx <= bx + bw + 5 and by - 5 <= cy <= by + bh + 5:
matched.append(g)
if not matched:
mid_y = by + bh / 2.0
matched = sorted(
glyphs,
key=lambda g: abs(float(getattr(g, "origin_y", getattr(g, "bbox_y", 0)) or 0) - mid_y)
)[:3]
if matched:
from collections import Counter
counts = Counter()
sizes: Dict[str, list] = {}
bolds: Dict[str, bool] = {}
italics: Dict[str, bool] = {}
font_ids: Dict[str, str] = {}
for g in matched:
fn = str(getattr(g, "font_name", "") or "")
fid = str(getattr(g, "internal_font_id", getattr(g, "font_id", "")) or "")
fs = float(getattr(g, "font_size", 0) or 0)
flags = int(getattr(g, "flags", 0) or 0)
if fn:
import re
clean = re.sub(r"^[A-Z]{6}\+", "", fn).strip()
counts[clean] += 1
if fid and not font_ids.get(clean):
font_ids[clean] = fid
if fs > 0:
sizes.setdefault(clean, []).append(fs)
if bool(flags & 2) or "bold" in clean.lower():
bolds[clean] = True
if bool(flags & 1) or "italic" in clean.lower() or "oblique" in clean.lower():
italics[clean] = True
if counts:
best_font = counts.most_common(1)[0][0]
# Use measured glyph size; if no glyphs had a size return None so
# the caller can fall back to its own estimation rather than baking
# in a heuristic inside the matcher.
avg_size = (
round(sum(sizes[best_font]) / len(sizes[best_font]), 2)
if best_font in sizes and sizes[best_font]
else None
)
return {
"fontName": best_font,
"fontId": font_ids.get(best_font),
"fontSize": avg_size,
"isBold": bolds.get(best_font, False),
"isItalic": italics.get(best_font, False),
}
except Exception as e:
print(f"[OCR] Glyph font matching error: {e}")
return None
def process_pdf_page_ocr(doc: Any, page_index: int, dpi: int = 200) -> Dict[str, Any]:
if not is_ocr_available():
raise RuntimeError("RapidOCR engine is not available")
@@ -207,22 +278,13 @@ def process_pdf_page_ocr(doc: Any, page_index: int, dpi: int = 200) -> Dict[str,
pdf_w, pdf_h = page.width, page.height
# ── Font span extraction ─────────────────────────────────────────────────
print(f"[OCR DEBUG] doc methods: {[m for m in dir(doc) if not m.startswith('_')]}")
print(f"[OCR DEBUG] page methods: {[m for m in dir(page) if not m.startswith('_')]}")
font_spans = _extract_page_font_spans(page)
print(f"[OCR] Page {page_index}: extracted {len(font_spans)} font spans from page")
if font_spans:
unique_fonts = set(s["fontName"] for s in font_spans)
print(f"[OCR] Unique fonts found: {unique_fonts}")
# ── Fallback: page-level and document-level fonts (for scanned PDFs) ──────
doc_font_name = None
if not font_spans:
# Try page.get_fonts() first
try:
if hasattr(page, "get_fonts"):
page_fonts = page.get_fonts()
print(f"[OCR DEBUG] page.get_fonts() returned {len(page_fonts) if page_fonts else 0} fonts")
if page_fonts:
import re as _re
for f in page_fonts:
@@ -230,20 +292,16 @@ def process_pdf_page_ocr(doc: Any, page_index: int, dpi: int = 200) -> Dict[str,
nf = getattr(f, "normalized_family", "") or ""
clean = _re.sub(r"^[A-Z]{6}\+", "", fn).strip()
family = nf or clean
print(f"[OCR DEBUG] page_font item: fn='{fn}', nf='{nf}', clean='{clean}'")
if family and not any(x in family.lower() for x in ["symbol", "zapf", "wingding"]):
doc_font_name = family
print(f"[OCR] Page-level font fallback: {doc_font_name} (from {fn})")
break
except Exception as e:
print(f"[OCR] page.get_fonts() failed: {e}")
except Exception:
pass
# Try doc.get_fonts(0, -1)
if not doc_font_name:
try:
if hasattr(doc, "get_fonts"):
doc_fonts = doc.get_fonts(0, -1)
print(f"[OCR DEBUG] doc.get_fonts(0, -1) returned {len(doc_fonts) if doc_fonts else 0} fonts")
if doc_fonts:
import re as _re
for f in doc_fonts:
@@ -251,16 +309,11 @@ def process_pdf_page_ocr(doc: Any, page_index: int, dpi: int = 200) -> Dict[str,
nf = getattr(f, "normalized_family", "") or ""
clean = _re.sub(r"^[A-Z]{6}\+", "", fn).strip()
family = nf or clean
print(f"[OCR DEBUG] doc_font item: fn='{fn}', nf='{nf}', clean='{clean}'")
if family and not any(x in family.lower() for x in ["symbol", "zapf", "wingding"]):
doc_font_name = family
print(f"[OCR] Doc-level font fallback: {doc_font_name} (from {fn})")
break
except Exception as e:
print(f"[OCR] doc.get_fonts(0, -1) failed: {e}")
if not doc_font_name:
print("[OCR] No font detected at all, using Helvetica default")
except Exception:
pass
# Render page PNG via pdfengine
img_result = page.render(dpi)
@@ -285,18 +338,35 @@ def process_pdf_page_ocr(doc: Any, page_index: int, dpi: int = 200) -> Dict[str,
"height": round(l_box["height"] * scale_y, 2),
}
# ── Font matching ────────────────────────────────────────────────────
matched = _match_font_to_box(pdf_l_box, font_spans)
# ── Font matching via C++ engine glyph stream & spans ────────────────
matched = _match_glyph_font_to_box(pdf_l_box, page) or _match_font_to_box(pdf_l_box, font_spans)
if matched:
detected_font_name = matched["fontName"]
elif doc_font_name:
detected_font_name = doc_font_name
else:
detected_font_name = "Helvetica"
# Derive display name: strip subset prefix like "ABCDEF+"
import re as _re
detected_font_name = _re.sub(r"^[A-Z]{6}\+", "", detected_font_name).strip() or "Helvetica"
detected_font_size = float(matched["fontSize"]) if matched else round(pdf_l_box["height"] * 0.75, 2)
detected_font_name = _re.sub(r"^[A-Z]{6}\+", "", detected_font_name).strip() or None
# Font size: prefer exact glyph measurement; fall back to the standard
# typographic approximation (cap-height ≈ 72% of em) when no vector
# glyph data exists. This is labeled explicitly as an estimate so it
# is always populated and the frontend never receives None.
if matched and matched.get("fontSize"):
detected_font_size = round(float(matched["fontSize"]), 2)
else:
# Box height from OCR raster-to-PDF-point conversion; cap-height ~ 72 % em
detected_font_size = round(pdf_l_box["height"] * 0.72, 2)
# Line spacing: if the glyph engine gave us a line height use it;
# otherwise compute the ratio of the box height to font size — this
# preserves the inter-line gap actually visible in the scanned image.
if detected_font_size and detected_font_size > 0:
computed_line_spacing = round(pdf_l_box["height"] / detected_font_size, 4)
else:
computed_line_spacing = 1.2
scaled_words = []
for w in line["words"]:
@@ -312,14 +382,8 @@ def process_pdf_page_ocr(doc: Any, page_index: int, dpi: int = 200) -> Dict[str,
"confidence": w["confidence"],
})
# Determine bold weight:
is_bold = False
if matched and matched.get("isBold"):
is_bold = True
elif pdf_l_box["height"] >= 16.5 or "bold" in detected_font_name.lower():
is_bold = True
elif line["text"].isupper() and len(line["text"].strip()) >= 3:
is_bold = True
is_bold = bool(matched.get("isBold", False)) if matched else ("bold" in (detected_font_name or "").lower())
is_italic = bool(matched.get("isItalic", False)) if matched else any(x in (detected_font_name or "").lower() for x in ["italic", "oblique"])
scaled_lines.append({
"text": line["text"],
@@ -331,8 +395,12 @@ def process_pdf_page_ocr(doc: Any, page_index: int, dpi: int = 200) -> Dict[str,
for pt in line["polygon"]
],
"fontName": detected_font_name,
"fontId": matched.get("fontId") if matched else None,
"fontSize": detected_font_size,
"lineSpacing": computed_line_spacing,
"letterSpacing": 0.0,
"isBold": is_bold,
"isItalic": is_italic,
})
if scaled_lines: