fix the cursor issue
This commit is contained in:
@@ -93,6 +93,7 @@ export interface LayoutBlock {
|
||||
};
|
||||
textStyle?: {
|
||||
fontName?: string;
|
||||
fontId?: string;
|
||||
fontSize?: number;
|
||||
fontColor?: string;
|
||||
isBold?: boolean;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user