fix the extraction

This commit is contained in:
saqib mir
2026-08-04 18:30:40 +05:30
parent d7621a07ed
commit e68137219c
15 changed files with 686 additions and 90 deletions
+26 -2
View File
@@ -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) => {
+23 -4
View File
@@ -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]" />
+10
View File
@@ -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' && (
+1 -19
View File
@@ -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
+40 -2
View File
@@ -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`);
+2
View File
@@ -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',
+2 -2
View File
@@ -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);
+103 -13
View File
@@ -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); }}
/>
+47 -35
View File
@@ -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>
+19 -1
View File
@@ -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}
+1
View File
@@ -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;
+85 -8
View File
@@ -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
View File
@@ -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()
+198
View File
@@ -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"],
}
+9
View File
@@ -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())