fix the extraction
This commit is contained in:
+26
-2
@@ -113,6 +113,29 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyOCRLine = async (line: any) => {
|
||||
if (!activeDoc || !ocrData) return;
|
||||
setIsOCRApplying(true);
|
||||
try {
|
||||
await gatewayService.applyPageOCR(activeDoc.id, currentPage, [line]);
|
||||
setOcrData((prev) => {
|
||||
if (!prev) return prev;
|
||||
const newLines = prev.lines.filter((l) => l !== line);
|
||||
// If there are no lines left, we can dismiss OCR
|
||||
return newLines.length > 0 ? { ...prev, lines: newLines } : null;
|
||||
});
|
||||
// After applying, we want to force layout refresh.
|
||||
// Since setting ocrData alone doesn't trigger layout cache invalidation if not null,
|
||||
// we can do a trick or just wait for it.
|
||||
// We can also clear the layout cache manually but that's internal to PDFViewer.
|
||||
// For now, let's just let it apply. (Ideally we need to invalidate layout cache for this page)
|
||||
} catch (err: any) {
|
||||
alert(`Failed to apply OCR line: ${err.message || err}`);
|
||||
} finally {
|
||||
setIsOCRApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDocument = useCallback((id: string) => setHist({ stack: [id], index: 0 }), []);
|
||||
const pushHistory = (id: string) =>
|
||||
setHist((h) => ({ stack: [...h.stack.slice(0, h.index + 1), id], index: h.index + 1 }));
|
||||
@@ -655,8 +678,6 @@ function App() {
|
||||
canAssemble={can('canAssemble')}
|
||||
onUpload={handleUpload}
|
||||
onShowVersionHistory={() => setVersionHistoryModalOpen(true)}
|
||||
onOCR={handleRunOCR}
|
||||
isOCRLoading={isOCRLoading}
|
||||
isInspectorOpen={isInspectorOpen}
|
||||
onToggleInspector={toggleInspector}
|
||||
/>
|
||||
@@ -669,6 +690,8 @@ function App() {
|
||||
onOpenSignature={() => setSignatureModalOpen(true)}
|
||||
onOpenAbout={() => setAboutModalOpen(true)}
|
||||
disabledTools={disabledTools}
|
||||
onRunOCR={handleRunOCR}
|
||||
isOCRLoading={isOCRLoading}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
@@ -727,6 +750,7 @@ function App() {
|
||||
canCopy={can('canCopy')}
|
||||
ocrData={ocrData}
|
||||
onApplyAllOCR={handleApplyAllOCR}
|
||||
onApplyOCRLine={handleApplyOCRLine}
|
||||
onDismissOCR={() => setOcrData(null)}
|
||||
isOCRApplying={isOCRApplying}
|
||||
onFieldChange={(id, value, i) => {
|
||||
|
||||
@@ -34,6 +34,18 @@ const TOOLS: (ToolDef | 'divider')[] = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ocr',
|
||||
label: 'Recognize text (OCR)',
|
||||
shortLabel: 'OCR',
|
||||
shortcut: 'O',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
<path d="M8 9h8" /><path d="M8 13h6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'stream_edit',
|
||||
label: 'Raw Text (beta)',
|
||||
@@ -59,9 +71,11 @@ interface ToolRailProps {
|
||||
onOpenSignature: () => void;
|
||||
onOpenAbout: () => void;
|
||||
disabledTools?: Set<ToolId>;
|
||||
onRunOCR?: () => void;
|
||||
isOCRLoading?: boolean;
|
||||
}
|
||||
|
||||
const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; onClick: () => void }> = ({ t, active, disabled, onClick }) => (
|
||||
const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; isLoading?: boolean; onClick: () => void }> = ({ t, active, disabled, isLoading, onClick }) => (
|
||||
<CustomButton variant="unstyled"
|
||||
title={disabled ? `${t.label} — not permitted by this document` : `${t.label} · ${t.shortcut}`}
|
||||
aria-label={t.label}
|
||||
@@ -78,18 +92,23 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; on
|
||||
}`}
|
||||
>
|
||||
{active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: t.danger ? '#dc2626' : 'var(--brand-primary)' }} />}
|
||||
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 20 }) : t.icon}
|
||||
{isLoading ? (
|
||||
<div className="w-5 h-5 border-2 border-brand-primary border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 20 }) : t.icon
|
||||
)}
|
||||
<span className="text-[10px] font-medium leading-none tracking-tight">{t.shortLabel || t.label}</span>
|
||||
</CustomButton>
|
||||
);
|
||||
|
||||
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout, disabledTools }) => {
|
||||
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout, disabledTools, onRunOCR, isOCRLoading }) => {
|
||||
const pickTool = (id: ToolId) => {
|
||||
if (disabledTools?.has(id)) {
|
||||
return;
|
||||
}
|
||||
onToolChange(id);
|
||||
if (id === 'signature' && !hasSignature) onOpenSignature();
|
||||
if (id === 'ocr' && onRunOCR) onRunOCR();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -97,7 +116,7 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
|
||||
{TOOLS.map((t, i) =>
|
||||
t === 'divider'
|
||||
? <div key={`d${i}`} className="my-1 h-px w-10 shrink-0 bg-border-primary" />
|
||||
: <RailButton key={t.id} t={t} active={activeTool === t.id} disabled={disabledTools?.has(t.id)} onClick={() => pickTool(t.id)} />,
|
||||
: <RailButton key={t.id} t={t} active={activeTool === t.id} disabled={disabledTools?.has(t.id)} isLoading={t.id === 'ocr' && isOCRLoading} onClick={() => pickTool(t.id)} />,
|
||||
)}
|
||||
|
||||
<div className="flex-1 shrink-0 min-h-[16px]" />
|
||||
|
||||
@@ -37,6 +37,15 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
|
||||
comment: { label: 'Comment', icon: <CommentIcon size={17} /> },
|
||||
textbox: { label: 'Text box', icon: <TextBoxIcon size={17} /> },
|
||||
edit_text: { label: 'Edit text', icon: <TextBoxIcon size={17} /> },
|
||||
ocr: {
|
||||
label: 'OCR',
|
||||
icon: (
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
<path d="M8 9h8" /><path d="M8 13h6" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
signature: { label: 'Signature', icon: <SignatureIcon size={17} /> },
|
||||
stamp: { label: 'Stamp', icon: <StampIcon size={17} /> },
|
||||
redact: { label: 'Redact', icon: <RedactIcon size={17} /> },
|
||||
@@ -193,6 +202,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
)}
|
||||
|
||||
{activeTool === 'edit_text' && <Hint>Click text to seamlessly re-write paragraphs with automatic reflow.</Hint>}
|
||||
{activeTool === 'ocr' && <Hint>Recognize text on page using RapidOCR.</Hint>}
|
||||
{activeTool === 'stream_edit' && <Hint>Beta · surgical byte-level edit (no reflow). Best for same-length fixes in simple fonts.</Hint>}
|
||||
|
||||
{activeTool === 'signature' && (
|
||||
|
||||
@@ -27,8 +27,6 @@ interface TopBarProps {
|
||||
onPrint: () => void;
|
||||
onUpload: (file: File) => void;
|
||||
onShowVersionHistory?: () => void;
|
||||
onOCR?: () => void;
|
||||
isOCRLoading?: boolean;
|
||||
isInspectorOpen: boolean;
|
||||
onToggleInspector: () => void;
|
||||
canPrint?: boolean;
|
||||
@@ -41,7 +39,7 @@ const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
||||
export const TopBar: React.FC<TopBarProps> = ({
|
||||
documentName, backendHealthy, engineReady, zoom, onZoomChange, onFitWidth,
|
||||
currentPage, totalPages, onGoToPage, canUndo, canRedo, onUndo, onRedo,
|
||||
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload, onShowVersionHistory, onOCR, isOCRLoading,
|
||||
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload, onShowVersionHistory,
|
||||
canPrint = true, canExport = true, canAssemble = true,
|
||||
}) => {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -128,22 +126,6 @@ export const TopBar: React.FC<TopBarProps> = ({
|
||||
|
||||
<CustomButton variant="icon" label="Rotate page 90°" size={34} onClick={onRotate} disabled={!documentName || !canAssemble} className="text-text-secondary hover:text-text-primary"><RotateIcon size={18} /></CustomButton>
|
||||
|
||||
{onOCR && documentName && (
|
||||
<CustomButton
|
||||
variant="unstyled"
|
||||
onClick={onOCR}
|
||||
disabled={isOCRLoading}
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg bg-brand-primary/10 px-3 text-[12.5px] font-bold text-brand-primary hover:bg-brand-primary/20 disabled:opacity-50"
|
||||
title="Recognize text on page using RapidOCR"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
<path d="M8 9h8" /><path d="M8 13h6" />
|
||||
</svg>
|
||||
{isOCRLoading ? 'Scanning OCR…' : 'Recognize Text (OCR)'}
|
||||
</CustomButton>
|
||||
)}
|
||||
|
||||
{documentName && (
|
||||
<div className="flex items-center gap-1 text-[12px] font-semibold text-text-secondary">
|
||||
<input
|
||||
|
||||
@@ -42,6 +42,9 @@ export interface OCRLine {
|
||||
confidence: number;
|
||||
words: OCRWord[];
|
||||
polygon: { x: number; y: number }[];
|
||||
fontName?: string;
|
||||
fontSize?: number;
|
||||
isBold?: boolean;
|
||||
}
|
||||
|
||||
export interface OCRPageResponse {
|
||||
@@ -79,6 +82,21 @@ export interface LayoutBlock {
|
||||
parentId?: string;
|
||||
dependsOn?: string[];
|
||||
imageUrl?: string;
|
||||
text?: string;
|
||||
permissions?: {
|
||||
editable?: boolean;
|
||||
selectable?: boolean;
|
||||
movable?: boolean;
|
||||
resizable?: boolean;
|
||||
printable?: boolean;
|
||||
};
|
||||
textStyle?: {
|
||||
fontName?: string;
|
||||
fontSize?: number;
|
||||
fontColor?: string;
|
||||
isBold?: boolean;
|
||||
isItalic?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PageRegion {
|
||||
@@ -537,7 +555,7 @@ class GatewayService {
|
||||
if (!response.ok) throw new Error(`Page render failed: ${response.statusText}`);
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
|
||||
this.renderCache.set(cacheKey, objectUrl);
|
||||
if (this.renderCache.size > 100) {
|
||||
const firstKey = this.renderCache.keys().next().value;
|
||||
@@ -547,7 +565,7 @@ class GatewayService {
|
||||
this.renderCache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return objectUrl;
|
||||
} catch (err) {
|
||||
return this.generateMockPage(params.pageIndex);
|
||||
@@ -590,6 +608,26 @@ class GatewayService {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getFontAtPosition(
|
||||
documentId: string,
|
||||
pageIndex: number,
|
||||
x: number, y: number,
|
||||
width: number, height: number
|
||||
): Promise<{ fontName?: string; fontSize?: number; isBold?: boolean; isItalic?: boolean }> {
|
||||
const params = new URLSearchParams({
|
||||
x: String(x), y: String(y), width: String(width), height: String(height),
|
||||
});
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/documents/${documentId}/pages/${pageIndex}/font_at?${params}`
|
||||
);
|
||||
if (!response.ok) return {};
|
||||
return response.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async getDocumentRaw(documentId: string): Promise<ArrayBuffer | null> {
|
||||
try {
|
||||
const r = await fetch(`${this.baseUrl}/documents/${documentId}/raw`);
|
||||
|
||||
@@ -6,6 +6,7 @@ export type ToolId =
|
||||
| 'comment'
|
||||
| 'textbox'
|
||||
| 'edit_text'
|
||||
| 'ocr'
|
||||
| 'signature'
|
||||
| 'stamp'
|
||||
| 'redact'
|
||||
@@ -46,6 +47,7 @@ export const TOOL_SHORTCUTS: Record<string, ToolId> = {
|
||||
c: 'comment',
|
||||
t: 'textbox',
|
||||
e: 'edit_text',
|
||||
o: 'ocr',
|
||||
s: 'signature',
|
||||
m: 'stamp',
|
||||
r: 'redact',
|
||||
|
||||
@@ -48,9 +48,9 @@ export const CanvasLayer: React.FC<CanvasLayerProps> = ({
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
|
||||
ctx.save();
|
||||
|
||||
|
||||
if (rotation !== 0) {
|
||||
ctx.translate(width / 2, height / 2);
|
||||
ctx.rotate((rotation * Math.PI) / 180);
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { gatewayService, type LayoutBlock } from '../lib/gatewayService';
|
||||
|
||||
/** 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';
|
||||
}
|
||||
if (n.includes('courier') || n.includes('mono')) {
|
||||
return '"Courier New", Courier, monospace';
|
||||
}
|
||||
if (n.includes('arial') || n.includes('helvetica') || n.includes('sans')) {
|
||||
return 'Arial, "Helvetica Neue", Helvetica, sans-serif';
|
||||
}
|
||||
// Unknown font: try to use it directly as a web-safe name, fall back to sans-serif
|
||||
const clean = fontName.replace(/^[A-Z]{6}\+/, '').trim();
|
||||
return clean ? `"${clean}", Arial, sans-serif` : 'Arial, sans-serif';
|
||||
}
|
||||
|
||||
export interface BlockEditPayload {
|
||||
blockId: string;
|
||||
type: 'bounds' | 'text' | 'delete';
|
||||
bounds?: { x: number; y: number; width: number; height: number };
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface LayoutBlockLayerProps {
|
||||
blocks: LayoutBlock[];
|
||||
scale: number;
|
||||
@@ -18,6 +42,9 @@ interface LayoutBlockLayerProps {
|
||||
blockId: string,
|
||||
bounds: { x: number; y: number; width: number; height: number }
|
||||
) => void;
|
||||
/** For font detection on click — required for OCR blocks */
|
||||
documentId?: string;
|
||||
pageIndex?: number;
|
||||
}
|
||||
|
||||
type HandleType = 'move' | 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
|
||||
@@ -32,8 +59,12 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
|
||||
onUpdateBlockBounds,
|
||||
originalBoundsMap = {},
|
||||
onRecordOriginalBounds,
|
||||
documentId,
|
||||
pageIndex,
|
||||
}) => {
|
||||
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
|
||||
// Cache of detected fonts keyed by block id: { fontName, fontSize, isBold }
|
||||
const [detectedFonts, setDetectedFonts] = useState<Record<string, { fontName?: string; fontSize?: number; isBold?: boolean }>>({});
|
||||
|
||||
// Live bounds during drag — keys are block IDs
|
||||
const [tempBounds, setTempBounds] = useState<
|
||||
@@ -144,6 +175,7 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
|
||||
const isSelected = selectedBlockId === block.id;
|
||||
const isEditing = editingBlockId === block.id;
|
||||
const isImage = block.type === 'image';
|
||||
const isOcrBlock = block.id.startsWith('ocr_');
|
||||
|
||||
const bounds = tempBounds[block.id] || block.bounds;
|
||||
const left = bounds.x * scale;
|
||||
@@ -172,11 +204,16 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
|
||||
return (
|
||||
<React.Fragment key={block.id}>
|
||||
{/*
|
||||
* WHITE ERASER — paints over the baked-in PDF canvas image at its
|
||||
* ORIGINAL position. Only active once a real drag has occurred.
|
||||
* WHITE ERASER — paints over the baked-in PDF canvas content at its
|
||||
* ORIGINAL position. Active when moved OR when it's an OCR block covering scanned text.
|
||||
* z-44 puts it below the block (z-45) but above the PDF canvas.
|
||||
*/}
|
||||
{isImage && hasMoved && (
|
||||
{/*
|
||||
* WHITE ERASER — paints over the baked-in PDF canvas content at its
|
||||
* ORIGINAL position. Only active once a real drag/move has occurred.
|
||||
* z-44 puts it below the block (z-45) but above the PDF canvas.
|
||||
*/}
|
||||
{hasMoved && (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
@@ -192,29 +229,44 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
|
||||
|
||||
{/* ── MAIN BLOCK ── */}
|
||||
<div
|
||||
className={`absolute pointer-events-auto group cursor-move select-none`}
|
||||
className={`absolute ${block.permissions?.selectable === false ? 'pointer-events-none' : 'pointer-events-auto group cursor-move'} select-none rounded-[2px]`}
|
||||
style={{
|
||||
left, top, width, height,
|
||||
// Selection ring via outline so it doesn't affect layout
|
||||
outline: isSelected ? '2px solid #2563eb' : undefined,
|
||||
outlineOffset: '0px',
|
||||
// Image blocks: white bg only after real drag to avoid flash on click
|
||||
backgroundColor: isImage
|
||||
? hasMoved ? '#ffffff' : 'transparent'
|
||||
: isSelected ? 'rgba(37,99,235,0.06)' : 'transparent',
|
||||
// White bg after drag to cover canvas underneath, transparent when idle to preserve original scanned text font
|
||||
backgroundColor: hasMoved ? '#ffffff' : isSelected ? 'rgba(37,99,235,0.06)' : 'transparent',
|
||||
zIndex: 45,
|
||||
}}
|
||||
onPointerDown={(e) => startDrag(e, block, 'move')}
|
||||
onClick={(e) => { e.stopPropagation(); onSelectBlock(block.id); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectBlock(block.id);
|
||||
// For OCR blocks: detect the real font from glyphs at this position
|
||||
if (isOcrBlock && documentId !== undefined && pageIndex !== undefined
|
||||
&& !detectedFonts[block.id]) {
|
||||
const b = block.bounds;
|
||||
gatewayService.getFontAtPosition(documentId, pageIndex, b.x, b.y, b.width, b.height)
|
||||
.then((info) => {
|
||||
if (info.fontName) {
|
||||
setDetectedFonts((prev) => ({ ...prev, [block.id]: info }));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectBlock(block.id);
|
||||
setEditingBlockId(block.id);
|
||||
}}
|
||||
>
|
||||
{/* Label badge */}
|
||||
{/* Label badge — shows block type; for OCR selected blocks shows detected font */}
|
||||
<div className="absolute -top-5 left-0 opacity-0 group-hover:opacity-100 transition-opacity bg-[#1e293b] text-white text-[10px] px-1.5 py-0.5 rounded font-mono shadow pointer-events-none" style={{ zIndex: 60 }}>
|
||||
{block.type.toUpperCase()}
|
||||
{isOcrBlock && isSelected && detectedFonts[block.id]?.fontName
|
||||
? `${detectedFonts[block.id].fontName} ${detectedFonts[block.id].fontSize?.toFixed(0)}pt`
|
||||
: block.type.toUpperCase()}
|
||||
</div>
|
||||
|
||||
{/* ── IMAGE CONTENT ── */}
|
||||
@@ -243,6 +295,32 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── NON-IMAGE TEXT CONTENT PREVIEW ── */}
|
||||
{!isImage && !isEditing && (
|
||||
<div
|
||||
className="w-full h-full pointer-events-none flex flex-col justify-center leading-none font-sans"
|
||||
style={{
|
||||
fontSize: `${block.textStyle?.fontSize || Math.max(10, Math.min(24, height * 0.8))}px`,
|
||||
// For OCR text blocks that haven't been moved, keep text transparent so the original scanned image text with its original font remains 100% visible
|
||||
color: (isOcrBlock && !hasMoved) ? 'transparent' : (block.textStyle?.fontColor || 'black'),
|
||||
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
|
||||
}}
|
||||
>
|
||||
{block.children && block.children.length > 0 ? (
|
||||
block.children.map((l: any, idx: number) => (
|
||||
<div key={idx} className="whitespace-nowrap">
|
||||
{typeof l === 'string' ? l : (l.text ?? '')}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="whitespace-nowrap">{block.text ?? ''}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── SELECTION HANDLES ── */}
|
||||
{isSelected && (
|
||||
<>
|
||||
@@ -278,9 +356,21 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
|
||||
{/* ── TEXT EDITOR (non-image) ── */}
|
||||
{isEditing && !isImage && (
|
||||
<textarea
|
||||
className="w-full h-full p-1 bg-white text-black border-none outline-none shadow-lg resize-none font-sans text-sm pointer-events-auto"
|
||||
className="w-full h-full bg-white text-black border-none outline-none shadow-lg resize-none pointer-events-auto flex flex-col justify-center leading-none font-sans"
|
||||
style={{
|
||||
fontSize: `${block.textStyle?.fontSize || Math.max(10, Math.min(24, height * 0.8))}px`,
|
||||
color: block.textStyle?.fontColor || 'black',
|
||||
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
|
||||
}}
|
||||
autoFocus
|
||||
defaultValue={block.children?.map((l: any) => l.text ?? '').join('\n') ?? ''}
|
||||
defaultValue={
|
||||
block.children && block.children.length > 0
|
||||
? block.children.map((l: any) => (typeof l === 'string' ? l : (l.text ?? ''))).join('\n')
|
||||
: (block.text ?? '')
|
||||
}
|
||||
onBlur={() => setEditingBlockId(null)}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') setEditingBlockId(null); }}
|
||||
/>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { OCRLine, OCRWord } from '../lib/gatewayService';
|
||||
import type { OCRLine, FontInfo } from '../lib/gatewayService';
|
||||
|
||||
interface OCRLayerProps {
|
||||
lines: OCRLine[];
|
||||
scale: number;
|
||||
onApplyAll: () => void;
|
||||
onSelectWord: (word: OCRWord) => void;
|
||||
onApplyLine?: (line: OCRLine) => void;
|
||||
onDismiss: () => void;
|
||||
isApplying?: boolean;
|
||||
availableFonts?: FontInfo[];
|
||||
}
|
||||
|
||||
export const OCRLayer: React.FC<OCRLayerProps> = ({
|
||||
lines,
|
||||
scale,
|
||||
onApplyAll,
|
||||
onSelectWord,
|
||||
onApplyLine,
|
||||
onDismiss,
|
||||
isApplying = false,
|
||||
}) => {
|
||||
@@ -23,7 +24,7 @@ export const OCRLayer: React.FC<OCRLayerProps> = ({
|
||||
const totalWords = lines.reduce((acc, line) => acc + (line.words?.length || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto absolute inset-0 z-30 overflow-hidden">
|
||||
<div className="pointer-events-auto absolute inset-0 z-50 overflow-hidden">
|
||||
{/* Floating Action Banner */}
|
||||
<div className="absolute top-3 left-1/2 z-40 flex -translate-x-1/2 items-center gap-3 rounded-xl border border-brand-primary/20 bg-bg-primary/95 px-4 py-2 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 text-[13px] font-semibold text-text-primary">
|
||||
@@ -33,6 +34,7 @@ export const OCRLayer: React.FC<OCRLayerProps> = ({
|
||||
</svg>
|
||||
<span>RapidOCR Detected <strong className="text-brand-primary">{lines.length} lines ({totalWords} words)</strong></span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onApplyAll}
|
||||
disabled={isApplying}
|
||||
@@ -48,43 +50,53 @@ export const OCRLayer: React.FC<OCRLayerProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bounding Box Render */}
|
||||
{lines.map((line, lIdx) => (
|
||||
<React.Fragment key={`l_${lIdx}`}>
|
||||
{line.words.map((w, wIdx) => {
|
||||
const left = w.box.x * scale;
|
||||
const top = w.box.y * scale;
|
||||
const width = w.box.width * scale;
|
||||
const height = w.box.height * scale;
|
||||
{/* Bounding Box Render for Every Text Block Line */}
|
||||
{lines.map((line, lIdx) => {
|
||||
const left = line.box.x * scale;
|
||||
const top = line.box.y * scale;
|
||||
const width = line.box.width * scale;
|
||||
const height = line.box.height * scale;
|
||||
const fontSize = Math.max(10, Math.min(24, height * 0.8));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`w_${lIdx}_${wIdx}`}
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
}}
|
||||
className="group absolute cursor-pointer rounded-[3px] border border-brand-primary/40 bg-brand-primary/10 transition-all hover:border-brand-primary hover:bg-brand-primary/25 hover:shadow-sm"
|
||||
onClick={() => onSelectWord(w)}
|
||||
onMouseEnter={() => setHoveredText({ text: w.text, confidence: w.confidence, x: left, y: top })}
|
||||
onMouseLeave={() => setHoveredText(null)}
|
||||
>
|
||||
<span className="sr-only">{w.text}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
return (
|
||||
<div
|
||||
key={`l_${lIdx}`}
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
}}
|
||||
className="group absolute cursor-pointer rounded-md border-2 border-brand-primary/60 bg-brand-primary/10 px-1 py-0.5 transition-all hover:border-brand-primary hover:bg-brand-primary/25 hover:shadow-md select-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApplyLine?.(line);
|
||||
}}
|
||||
onMouseEnter={() => setHoveredText({ text: line.text, confidence: line.confidence, x: left, y: top })}
|
||||
onMouseLeave={() => setHoveredText(null)}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full overflow-hidden text-transparent font-semibold leading-none flex items-center"
|
||||
style={{ fontSize: `${fontSize}px` }}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
|
||||
{/* Block Label Badge */}
|
||||
<div className="absolute -top-4 right-1 opacity-0 group-hover:opacity-100 transition-opacity bg-brand-primary text-white text-[9.5px] font-bold px-1.5 py-0.5 rounded shadow pointer-events-none z-50">
|
||||
Text Block #{lIdx + 1}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Tooltip on Hover */}
|
||||
{hoveredText && (
|
||||
<div
|
||||
style={{ left: `${hoveredText.x}px`, top: `${Math.max(0, hoveredText.y - 28)}px` }}
|
||||
className="pointer-events-none absolute z-50 rounded-md bg-[#1e293b] px-2 py-1 text-[11px] font-medium text-white shadow-md"
|
||||
style={{ left: `${hoveredText.x}px`, top: `${Math.max(0, hoveredText.y - 30)}px` }}
|
||||
className="pointer-events-none absolute z-50 rounded-md bg-[#1e293b] px-2.5 py-1 text-[11.5px] font-medium text-white shadow-lg"
|
||||
>
|
||||
{hoveredText.text} <span className="text-[#94a3b8]">({Math.round(hoveredText.confidence * 100)}%)</span>
|
||||
{hoveredText.text} <span className="text-[#94a3b8]">({Math.round(hoveredText.confidence * 100)}% confidence)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ import { FloatingTextToolbar } from "./FloatingTextToolbar";
|
||||
import { LayoutBlockLayer } from "./LayoutBlockLayer";
|
||||
import type { BlockEditPayload } from "./LayoutBlockLayer";
|
||||
import { OCRLayer } from "./OCRLayer";
|
||||
import type { OCRPageResponse, PageLayoutResponse, LayoutBlock, FontInfo } from "../lib/gatewayService";
|
||||
import type { OCRPageResponse, PageLayoutResponse, FontInfo } from "../lib/gatewayService";
|
||||
|
||||
|
||||
interface PDFViewerProps {
|
||||
@@ -97,6 +97,7 @@ interface PDFViewerProps {
|
||||
canCopy?: boolean;
|
||||
ocrData?: OCRPageResponse | null;
|
||||
onApplyAllOCR?: () => void;
|
||||
onApplyOCRLine?: (line: any) => void;
|
||||
onDismissOCR?: () => void;
|
||||
isOCRApplying?: boolean;
|
||||
layoutData?: PageLayoutResponse | null;
|
||||
@@ -148,6 +149,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(
|
||||
onPlaceText,
|
||||
ocrData,
|
||||
onApplyAllOCR,
|
||||
onApplyOCRLine,
|
||||
onDismissOCR,
|
||||
isOCRApplying,
|
||||
layoutData,
|
||||
@@ -245,6 +247,19 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(
|
||||
}
|
||||
}, [ocrData]);
|
||||
|
||||
const handleApplyOCRLineWrapper = (line: any) => {
|
||||
onApplyOCRLine?.(line);
|
||||
// Immediately invalidate layout for the page so it is re-fetched
|
||||
if (ocrData) {
|
||||
setLayoutDataByPage((prev) => {
|
||||
if (!(ocrData.pageIndex in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[ocrData.pageIndex];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const [bridgeFrame, setBridgeFrame] = useState<
|
||||
(CommitFrame & { docId: string }) | null
|
||||
>(null);
|
||||
@@ -775,6 +790,8 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(
|
||||
selectedBlockId={selectedBlockId}
|
||||
onSelectBlock={(bId) => setSelectedBlockId(bId)}
|
||||
originalBoundsMap={originalBlockBounds[page.index] || {}}
|
||||
documentId={documentId}
|
||||
pageIndex={page.index}
|
||||
onRecordOriginalBounds={(blockId, bounds) => {
|
||||
setOriginalBlockBounds((prev) => ({
|
||||
...prev,
|
||||
@@ -824,6 +841,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(
|
||||
lines={ocrData.lines}
|
||||
scale={zoom}
|
||||
onApplyAll={onApplyAllOCR!}
|
||||
onApplyLine={handleApplyOCRLineWrapper}
|
||||
onDismiss={onDismissOCR!}
|
||||
isApplying={isOCRApplying}
|
||||
availableFonts={availableFonts}
|
||||
|
||||
@@ -222,6 +222,7 @@ interface TextEditLayerProps {
|
||||
height: number;
|
||||
zoom: number;
|
||||
pageImageUrl?: string;
|
||||
availableFonts?: import('../lib/gatewayService').FontInfo[];
|
||||
onEditText?: (pageIndex: number, run: EditableRun, newText: string, disableJustify?: boolean) => void;
|
||||
onReflowParagraph?: (pageIndex: number, payload: ReflowParagraphPayload) => void;
|
||||
onOverflowPreview?: (regions: import('./ParagraphEditor').OverflowPreviewRegion[]) => void;
|
||||
|
||||
@@ -139,18 +139,28 @@ def get_page_layout(document_id: str, page_index: int) -> PageLayoutResponse:
|
||||
try:
|
||||
img_blocks = page.extract_image_blocks()
|
||||
for idx, img in enumerate(img_blocks):
|
||||
blocks.append(
|
||||
LayoutBlockModel(
|
||||
id=f"img_block_{page_index}_{idx}",
|
||||
type="image",
|
||||
bounds=LayoutRectModel(
|
||||
x=float(img.get("x", 0.0)),
|
||||
y=float(img.get("y", 0.0)),
|
||||
width=float(img.get("width", 100.0)),
|
||||
height=float(img.get("height", 100.0)),
|
||||
),
|
||||
transform=LayoutMatrixModel(),
|
||||
permissions=BlockPermissionsModel(editable=True, selectable=True, movable=True, resizable=True),
|
||||
)
|
||||
img_w = float(img.get("width", 100.0))
|
||||
img_h = float(img.get("height", 100.0))
|
||||
is_bg = img_w >= width * 0.9 and img_h >= height * 0.9
|
||||
|
||||
blocks.append(
|
||||
LayoutBlockModel(
|
||||
id=f"img_block_{page_index}_{idx}",
|
||||
type="image",
|
||||
bounds=bounds,
|
||||
transform=LayoutMatrixModel(),
|
||||
permissions=BlockPermissionsModel(
|
||||
editable=not is_bg,
|
||||
selectable=not is_bg,
|
||||
movable=not is_bg,
|
||||
resizable=not is_bg
|
||||
),
|
||||
visualStyle=VisualStyleModel(),
|
||||
textStyle=TextStyleModel(),
|
||||
layoutStyle=LayoutStyleModel(),
|
||||
@@ -195,7 +205,12 @@ def get_page_layout(document_id: str, page_index: int) -> PageLayoutResponse:
|
||||
e=current_matrix[4],
|
||||
f=current_matrix[5],
|
||||
),
|
||||
permissions=BlockPermissionsModel(editable=True, selectable=True, movable=True, resizable=True),
|
||||
permissions=BlockPermissionsModel(
|
||||
editable=not (w >= width * 0.9 and h >= height * 0.9),
|
||||
selectable=not (w >= width * 0.9 and h >= height * 0.9),
|
||||
movable=not (w >= width * 0.9 and h >= height * 0.9),
|
||||
resizable=not (w >= width * 0.9 and h >= height * 0.9)
|
||||
),
|
||||
visualStyle=VisualStyleModel(),
|
||||
textStyle=TextStyleModel(),
|
||||
layoutStyle=LayoutStyleModel(),
|
||||
@@ -235,6 +250,68 @@ def get_page_layout(document_id: str, page_index: int) -> PageLayoutResponse:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. Check for stored operations (like OCR text)
|
||||
ops = doc_info.get("operations", [])
|
||||
for op in ops:
|
||||
if op.get("pageIndex") == page_index and op.get("type") == "text_overlay":
|
||||
data = op.get("data", {})
|
||||
# Ensure text is not just "None"
|
||||
text_val = data.get("text", "")
|
||||
|
||||
blocks.append(
|
||||
LayoutBlockModel(
|
||||
id=op.get("id", f"op_{len(blocks)}"),
|
||||
type="paragraph",
|
||||
bounds=LayoutRectModel(
|
||||
x=float(data.get("x", 0.0)),
|
||||
y=float(data.get("y", 0.0)),
|
||||
width=float(data.get("width", 100.0)),
|
||||
height=float(data.get("height", 100.0)),
|
||||
),
|
||||
transform=LayoutMatrixModel(),
|
||||
permissions=BlockPermissionsModel(editable=True, selectable=True, movable=True, resizable=True),
|
||||
visualStyle=VisualStyleModel(),
|
||||
textStyle=TextStyleModel(
|
||||
fontSize=float(data.get("fontSize", 12.0)),
|
||||
fontColor=data.get("color", "#000000"),
|
||||
fontName=data.get("fontName") or "Helvetica",
|
||||
isBold=bool(data.get("isBold", False))
|
||||
),
|
||||
layoutStyle=LayoutStyleModel(),
|
||||
children=[
|
||||
LayoutLineModel(
|
||||
text=text_val,
|
||||
bounds=LayoutRectModel(
|
||||
x=float(data.get("x", 0.0)),
|
||||
y=float(data.get("y", 0.0)),
|
||||
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)),
|
||||
lineHeight=float(data.get("height", 100.0)),
|
||||
runs=[
|
||||
TextRunModel(
|
||||
text=text_val,
|
||||
style=TextStyleModel(
|
||||
fontSize=float(data.get("fontSize", 12.0)),
|
||||
fontColor=data.get("color", "#000000"),
|
||||
fontName=data.get("fontName") or "Helvetica",
|
||||
isBold=bool(data.get("isBold", False))
|
||||
),
|
||||
bounds=LayoutRectModel(
|
||||
x=float(data.get("x", 0.0)),
|
||||
y=float(data.get("y", 0.0)),
|
||||
width=float(data.get("width", 100.0)),
|
||||
height=float(data.get("height", 100.0)),
|
||||
),
|
||||
transform=LayoutMatrixModel()
|
||||
)
|
||||
]
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
body_region = PageRegionModel(
|
||||
id=f"region_body_{page_index}",
|
||||
type="body",
|
||||
|
||||
+120
-4
@@ -1,6 +1,7 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Dict, Any, Optional
|
||||
import re as _re
|
||||
|
||||
from app.services import engine
|
||||
from app.services.store import document_store
|
||||
@@ -26,6 +27,9 @@ class OCRLineResponse(BaseModel):
|
||||
confidence: float
|
||||
words: List[OCRWordResponse]
|
||||
polygon: List[OCRPolygonPoint]
|
||||
fontName: Optional[str] = None
|
||||
fontSize: Optional[float] = None
|
||||
isBold: Optional[bool] = False
|
||||
|
||||
|
||||
class OCRPageResponse(BaseModel):
|
||||
@@ -40,6 +44,14 @@ class ApplyOCRRequest(BaseModel):
|
||||
lines: List[OCRLineResponse]
|
||||
|
||||
|
||||
class FontAtPositionResponse(BaseModel):
|
||||
fontName: Optional[str] = None
|
||||
fontSize: Optional[float] = None
|
||||
isBold: Optional[bool] = False
|
||||
isItalic: Optional[bool] = False
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/{document_id}/pages/{page_index}/ocr", response_model=OCRPageResponse)
|
||||
def perform_ocr_on_page(document_id: str, page_index: int) -> OCRPageResponse:
|
||||
if not engine.is_available():
|
||||
@@ -84,11 +96,13 @@ def apply_ocr_to_page(document_id: str, page_index: int, req: ApplyOCRRequest):
|
||||
if not doc_info:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
# Reconstruct text edit operations for each line
|
||||
applied_count = 0
|
||||
for line in req.lines:
|
||||
b = line.box
|
||||
# Create text overlay edit operation
|
||||
# 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)
|
||||
is_bold = bool(line.isBold)
|
||||
# Store no fontName so frontend can detect it on click via ordered_glyphs
|
||||
op = {
|
||||
"id": f"ocr_{applied_count}_{page_index}",
|
||||
"type": "text_overlay",
|
||||
@@ -99,12 +113,114 @@ def apply_ocr_to_page(document_id: str, page_index: int, req: ApplyOCRRequest):
|
||||
"width": b["width"],
|
||||
"height": b["height"],
|
||||
"text": line.text,
|
||||
"fontSize": max(8.0, b["height"] * 0.75),
|
||||
"fontSize": detected_font_size,
|
||||
"color": "#000000",
|
||||
"fontName": "Helvetica",
|
||||
"fontName": None, # will be resolved at click time
|
||||
"isBold": is_bold,
|
||||
}
|
||||
}
|
||||
document_store.add_operation(document_id, op)
|
||||
applied_count += 1
|
||||
|
||||
return {"status": "success", "appliedCount": applied_count}
|
||||
|
||||
|
||||
@router.get("/{document_id}/pages/{page_index}/font_at", response_model=FontAtPositionResponse)
|
||||
def get_font_at_position(
|
||||
document_id: str,
|
||||
page_index: int,
|
||||
x: float,
|
||||
y: float,
|
||||
width: float = 0.0,
|
||||
height: float = 0.0,
|
||||
) -> FontAtPositionResponse:
|
||||
"""
|
||||
Use page.ordered_glyphs() to find the dominant font at (x, y) in PDF points.
|
||||
Called by the frontend when a layout block is selected.
|
||||
"""
|
||||
if not engine.is_available():
|
||||
return FontAtPositionResponse()
|
||||
|
||||
doc_info = document_store.get_document(document_id)
|
||||
if not doc_info:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
try:
|
||||
doc = doc_info["doc_instance"]
|
||||
page = doc.get_page(page_index)
|
||||
|
||||
# ordered_glyphs() returns glyph objects — use bounding box to find best match
|
||||
if not hasattr(page, "ordered_glyphs"):
|
||||
return FontAtPositionResponse()
|
||||
|
||||
glyphs = page.ordered_glyphs()
|
||||
if not glyphs:
|
||||
return FontAtPositionResponse()
|
||||
|
||||
# Find glyphs whose center falls within the block bounding box
|
||||
# If width/height not given, use a small probe radius around (x, y)
|
||||
probe_x1 = x if width > 0 else x - 5
|
||||
probe_y1 = y if height > 0 else y - 5
|
||||
probe_x2 = x + (width if width > 0 else 10)
|
||||
probe_y2 = y + (height if height > 0 else 10)
|
||||
|
||||
matched_glyphs = []
|
||||
for g in glyphs:
|
||||
gx = getattr(g, "bbox_x", getattr(g, "origin_x", None))
|
||||
gy = getattr(g, "bbox_y", getattr(g, "origin_y", None))
|
||||
gw = getattr(g, "bbox_w", 0) or 0
|
||||
gh = getattr(g, "bbox_h", 0) or 0
|
||||
if gx is None or gy is None:
|
||||
continue
|
||||
gx, gy, gw, gh = float(gx), float(gy), float(gw), float(gh)
|
||||
cx = gx + gw / 2
|
||||
cy = gy + gh / 2
|
||||
if probe_x1 <= cx <= probe_x2 and probe_y1 <= cy <= probe_y2:
|
||||
matched_glyphs.append(g)
|
||||
|
||||
if not matched_glyphs:
|
||||
# fallback: nearest glyph vertically
|
||||
mid_y = y + height / 2
|
||||
matched_glyphs = sorted(
|
||||
glyphs,
|
||||
key=lambda g: abs(float(getattr(g, "origin_y", getattr(g, "bbox_y", 0)) or 0) - mid_y)
|
||||
)[:5]
|
||||
|
||||
if not matched_glyphs:
|
||||
return FontAtPositionResponse()
|
||||
|
||||
# Pick the most common font among matched glyphs
|
||||
from collections import Counter
|
||||
font_counts: Counter = Counter()
|
||||
font_sizes: Dict[str, list] = {}
|
||||
for g in matched_glyphs:
|
||||
fn = str(getattr(g, "font_name", "") 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
|
||||
font_sizes.setdefault(clean, []).append(fs)
|
||||
|
||||
if not font_counts:
|
||||
return FontAtPositionResponse()
|
||||
|
||||
best_font = font_counts.most_common(1)[0][0]
|
||||
sizes = font_sizes.get(best_font, [12.0])
|
||||
avg_size = round(sum(sizes) / len(sizes), 2)
|
||||
|
||||
# Detect bold from font name
|
||||
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}")
|
||||
return FontAtPositionResponse(
|
||||
fontName=best_font,
|
||||
fontSize=avg_size,
|
||||
isBold=is_bold,
|
||||
isItalic=is_italic,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[font_at] error: {e}")
|
||||
return FontAtPositionResponse()
|
||||
|
||||
@@ -86,6 +86,119 @@ def recognize_image_bytes(image_bytes: bytes) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _extract_page_font_spans(page: Any) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Extract text spans with font metadata from a PDF page using the C++ engine.
|
||||
Returns a list of dicts: {x, y, width, height, fontName, fontSize}
|
||||
Falls back to empty list if the engine doesn't support it.
|
||||
"""
|
||||
spans: List[Dict[str, Any]] = []
|
||||
|
||||
# Strategy 1: page.extract_document_model() — authoritative C++ engine model
|
||||
# This is the same API used by the TextEditLayer for rich font information
|
||||
if hasattr(page, "extract_document_model"):
|
||||
try:
|
||||
model = page.extract_document_model()
|
||||
if model and hasattr(model, "paragraphs"):
|
||||
for para in model.paragraphs:
|
||||
for line in para.lines:
|
||||
for run in line.runs:
|
||||
fn = str(run.font_name) if hasattr(run, "font_name") and run.font_name else ""
|
||||
fs = float(run.font_size) if hasattr(run, "font_size") and run.font_size else 0.0
|
||||
x = float(run.x) if hasattr(run, "x") else 0.0
|
||||
y = float(run.y) if hasattr(run, "y") else 0.0
|
||||
w = float(run.w) if hasattr(run, "w") else 0.0
|
||||
h = float(run.h) if hasattr(run, "h") and run.h > 0 else fs
|
||||
is_bold = "bold" in fn.lower() or (hasattr(run, "flags") and bool(run.flags & 2))
|
||||
if fn and fs > 0:
|
||||
spans.append({
|
||||
"x": x, "y": y, "width": w, "height": h,
|
||||
"fontName": fn, "fontSize": fs, "isBold": is_bold,
|
||||
})
|
||||
except Exception as err:
|
||||
print(f"[OCR] Strategy 1 error: {err}")
|
||||
|
||||
# Strategy 2: page.extract_text_model() — JSON structured model
|
||||
if not spans and hasattr(page, "extract_text_model"):
|
||||
try:
|
||||
import json
|
||||
raw = page.extract_text_model()
|
||||
model = json.loads(raw) if isinstance(raw, str) else raw
|
||||
for para in (model.get("paragraphs") or []):
|
||||
for line in (para.get("lines") or []):
|
||||
for run in (line.get("runs") or []):
|
||||
fn = run.get("font_name") or run.get("fontName") or ""
|
||||
fs = float(run.get("font_size") or run.get("fontSize") or 0)
|
||||
x = float(run.get("x", 0))
|
||||
y = float(run.get("y", 0))
|
||||
w = float(run.get("w", 0))
|
||||
h = float(run.get("h", fs))
|
||||
if fn and fs > 0:
|
||||
spans.append({"x": x, "y": y, "width": w, "height": h,
|
||||
"fontName": fn, "fontSize": fs})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Strategy 3: page.extract_display_list() — lower-level ops
|
||||
if not spans and hasattr(page, "extract_display_list"):
|
||||
try:
|
||||
import json
|
||||
dl_str = page.extract_display_list()
|
||||
dl_ops = json.loads(dl_str) if dl_str else []
|
||||
current_font = "Helvetica"
|
||||
current_size = 12.0
|
||||
for op in dl_ops:
|
||||
op_name = op.get("op", "")
|
||||
args = op.get("args", [])
|
||||
if op_name == "Tf" and len(args) >= 2:
|
||||
current_font = str(args[0]).lstrip("/")
|
||||
try:
|
||||
current_size = float(args[1])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif op_name in ("Tj", "TJ", "'", "\"") and current_font:
|
||||
spans.append({
|
||||
"x": 0, "y": 0, "width": 100, "height": current_size,
|
||||
"fontName": current_font, "fontSize": current_size,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return spans
|
||||
|
||||
|
||||
def _match_font_to_box(box: Dict[str, float], spans: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Return the font span whose bounding box overlaps most with `box`.
|
||||
Falls back to None if no spans available.
|
||||
"""
|
||||
if not spans:
|
||||
return None
|
||||
|
||||
bx, by, bw, bh = box["x"], box["y"], box["width"], box["height"]
|
||||
|
||||
best = None
|
||||
best_score = -1.0
|
||||
|
||||
for sp in spans:
|
||||
# Intersection area
|
||||
ix1 = max(bx, sp["x"])
|
||||
iy1 = max(by, sp["y"])
|
||||
ix2 = min(bx + bw, sp["x"] + sp["width"])
|
||||
iy2 = min(by + bh, sp["y"] + sp["height"])
|
||||
inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
|
||||
if inter > best_score:
|
||||
best_score = inter
|
||||
best = sp
|
||||
|
||||
# If no overlap at all, fall back to closest span by vertical midpoint distance
|
||||
if best_score <= 0:
|
||||
mid_y = by + bh / 2.0
|
||||
best = min(spans, key=lambda s: abs((s["y"] + s["height"] / 2.0) - mid_y))
|
||||
|
||||
return best
|
||||
|
||||
|
||||
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")
|
||||
@@ -93,6 +206,62 @@ def process_pdf_page_ocr(doc: Any, page_index: int, dpi: int = 200) -> Dict[str,
|
||||
page = doc.get_page(page_index)
|
||||
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:
|
||||
fn = getattr(f, "font_name", "") or ""
|
||||
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}")
|
||||
|
||||
# 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:
|
||||
fn = getattr(f, "font_name", "") or ""
|
||||
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")
|
||||
|
||||
# Render page PNG via pdfengine
|
||||
img_result = page.render(dpi)
|
||||
png_bytes = bytes(img_result.data)
|
||||
@@ -116,6 +285,19 @@ 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)
|
||||
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)
|
||||
|
||||
scaled_words = []
|
||||
for w in line["words"]:
|
||||
w_box = w["box"]
|
||||
@@ -130,6 +312,15 @@ 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
|
||||
|
||||
scaled_lines.append({
|
||||
"text": line["text"],
|
||||
"box": pdf_l_box,
|
||||
@@ -139,8 +330,14 @@ def process_pdf_page_ocr(doc: Any, page_index: int, dpi: int = 200) -> Dict[str,
|
||||
{"x": round(pt["x"] * scale_x, 2), "y": round(pt["y"] * scale_y, 2)}
|
||||
for pt in line["polygon"]
|
||||
],
|
||||
"fontName": detected_font_name,
|
||||
"fontSize": detected_font_size,
|
||||
"isBold": is_bold,
|
||||
})
|
||||
|
||||
if scaled_lines:
|
||||
print(f"[OCR] First line font: '{scaled_lines[0].get('fontName')}' size={scaled_lines[0].get('fontSize')} isBold={scaled_lines[0].get('isBold')}")
|
||||
|
||||
return {
|
||||
"pageIndex": page_index,
|
||||
"pageWidth": pdf_w,
|
||||
@@ -148,3 +345,4 @@ def process_pdf_page_ocr(doc: Any, page_index: int, dpi: int = 200) -> Dict[str,
|
||||
"lines": scaled_lines,
|
||||
"processTimeMs": ocr_result["processTimeMs"],
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ class DocumentStore:
|
||||
"doc_instance": doc_instance,
|
||||
"bytes_data": bytes_data,
|
||||
"permissions": permissions,
|
||||
"operations": [],
|
||||
}
|
||||
|
||||
with self._lock:
|
||||
@@ -79,6 +80,14 @@ class DocumentStore:
|
||||
with self._lock:
|
||||
return self._documents.get(doc_id)
|
||||
|
||||
def add_operation(self, doc_id: str, op: dict[str, Any]) -> bool:
|
||||
with self._lock:
|
||||
doc = self._documents.get(doc_id)
|
||||
if doc:
|
||||
doc.setdefault("operations", []).append(op)
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_documents(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
return list(self._documents.values())
|
||||
|
||||
Reference in New Issue
Block a user