Merge pull request 'fix_issue' (#87) from fix_issue into docker

Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/87
This commit is contained in:
2026-08-22 10:36:49 +00:00
10 changed files with 382 additions and 408 deletions
+1
View File
@@ -21,6 +21,7 @@ CMakeUserPresets.json
/third_party/pdfium/checkout/
/third_party/pdfium/install/
/third_party/pdfium/.gclient*
/corpus/
# Skia from-source build (depot_tools / GN / Ninja)
/third_party/skia/depot_tools/
+5 -15
View File
@@ -52,20 +52,14 @@ function App() {
const canRedo = hist.index < hist.stack.length - 1;
const preservePageRef = useRef(false);
const [isOcrDisabled, setIsOcrDisabled] = useState(false);
const permissions = activeDoc?.permissions ?? null;
const can = (flag: keyof PDFPermissions) => !permissions || permissions[flag] !== false;
const denyToast = (_label: string) => { };
const disabledTools = new Set<ToolId>();
disabledTools.add('ocr'); // OCR & Edit button is permanently disabled
if (!activeDoc) {
(['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'edit_text', 'stream_edit', 'signature', 'stamp', 'watermark', 'redact'] as ToolId[]).forEach((t) => disabledTools.add(t));
} else {
if (!can('canAnnotate'))
(['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'stamp', 'signature'] as ToolId[]).forEach((t) => disabledTools.add(t));
if (!can('canModify'))
(['edit_text', 'stream_edit', 'redact'] as ToolId[]).forEach((t) => disabledTools.add(t));
}
disabledTools.add('ocr');
if (!can('canAnnotate'))
(['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'stamp', 'signature'] as ToolId[]).forEach((t) => disabledTools.add(t));
if (!can('canModify')) (['edit_text', 'redact'] as ToolId[]).forEach((t) => disabledTools.add(t));
const disabledToolsRef = useRef(disabledTools);
disabledToolsRef.current = disabledTools;
@@ -160,11 +154,7 @@ function App() {
await gatewayService.performPageOCR(activeDoc.id, currentPage);
viewerRef.current?.refreshPageLayout(currentPage);
} catch (err: any) {
const errMsg = err.message || String(err);
alert(`OCR processing failed: ${errMsg}`);
if (errMsg.toLowerCase().includes('not installed') || errMsg.toLowerCase().includes('not available')) {
setIsOcrDisabled(true);
}
alert(`OCR processing failed: ${err.message || err}`);
} finally {
setIsOCRLoading(false);
}
+1 -1
View File
@@ -122,7 +122,7 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; is
aria-disabled={disabled}
onClick={onClick}
className={`relative flex h-[52px] w-[72px] shrink-0 flex-col items-center justify-center gap-[3px] rounded-[10px] transition-colors ${disabled
? 'cursor-not-allowed text-[#c5cad1]'
? 'cursor-not-allowed text-text-tertiary opacity-40 dark:text-zinc-600 dark:opacity-60'
: active
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-brand-secondary text-brand-primary'
: t.danger ? 'text-text-secondary hover:bg-[#fdecec] hover:text-[#dc2626]'
@@ -60,25 +60,7 @@ const INITIAL_DOC: DocumentModel = {
enabled: true,
showPageNumbers: true,
},
blocks: [
{
id: 'blk-1',
type: 'paragraph',
styleName: 'Normal',
runs: [
{
id: 'run-1',
text: '',
fontFamily: 'Helvetica',
fontSize: 12,
color: '#0f172a',
},
],
alignment: 'left',
spaceBefore: 0,
spaceAfter: 6,
},
],
blocks: [],
};
export const CreatePDFModal: React.FC<CreatePDFModalProps> = ({
@@ -97,8 +79,8 @@ export const CreatePDFModal: React.FC<CreatePDFModalProps> = ({
}) => {
const historyRef = useRef(new HistoryManager(INITIAL_DOC));
const [doc, setDoc] = useState<DocumentModel>(INITIAL_DOC);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>('blk-1');
const [activeRunId, setActiveRunId] = useState<string | null>('run-1');
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const [activeRunId, setActiveRunId] = useState<string | null>(null);
const [zoomScale, setZoomScale] = useState<number>(1.0);
const [isGenerating, setIsGenerating] = useState(false);
@@ -117,37 +99,12 @@ export const CreatePDFModal: React.FC<CreatePDFModalProps> = ({
const freshDoc: DocumentModel = {
...INITIAL_DOC,
id: `doc-creator-${Date.now()}`,
blocks: [
{
id: 'blk-1',
type: 'paragraph',
styleName: 'Normal',
runs: [
{
id: 'run-1',
text: '',
fontFamily: 'Helvetica',
fontSize: 12,
color: '#0f172a',
},
],
alignment: 'left',
spaceBefore: 0,
spaceAfter: 6,
},
],
blocks: [],
};
historyRef.current = new HistoryManager(freshDoc);
setDoc(freshDoc);
setSelectedBlockId('blk-1');
setActiveRunId('run-1');
setTimeout(() => {
const el = document.querySelector<HTMLDivElement>('[data-block-id="blk-1"]');
if (el) {
el.focus();
}
}, 50);
setSelectedBlockId(null);
setActiveRunId(null);
}
}, [isOpen]);
@@ -331,7 +288,7 @@ export const CreatePDFModal: React.FC<CreatePDFModalProps> = ({
],
alignment: 'left',
spaceBefore: 0,
spaceAfter: 6,
spaceAfter: 2,
};
let nextBlocks = [...doc.blocks];
@@ -725,6 +682,7 @@ export const CreatePDFModal: React.FC<CreatePDFModalProps> = ({
doc={doc}
selectedBlockId={selectedBlockId}
activeRun={activeRun}
activeParagraph={activeParagraph}
activeTool={activeTool}
drawColor={drawColor}
drawWidth={drawWidth}
@@ -109,13 +109,11 @@ const EditableParagraphBlock: React.FC<{
return (
<div
onClick={() => onSelectBlock(block.id, firstRun.id)}
className={`relative rounded px-2.5 py-1.5 transition-all ${
isSelected ? 'bg-blue-50/40 ring-1 ring-brand-primary/50' : 'hover:bg-slate-50'
}`}
className="relative py-0.5"
style={{
textAlign: block.alignment || 'left',
marginTop: `${block.spaceBefore || 0}px`,
marginBottom: `${block.spaceAfter || 4}px`,
marginBottom: `${block.spaceAfter !== undefined ? block.spaceAfter : 2}px`,
}}
>
<div
@@ -127,8 +125,8 @@ const EditableParagraphBlock: React.FC<{
onInput={(e) => onUpdateParagraphText(block.id, e.currentTarget.innerText)}
onBlur={(e) => onUpdateParagraphText(block.id, e.currentTarget.innerText)}
onKeyDown={handleKeyDown}
className="outline-none min-h-[1.5em] empty:before:content-[attr(data-placeholder)] empty:before:text-slate-300 empty:before:pointer-events-none"
data-placeholder="Start typing paragraph text..."
className="outline-none min-h-[1.5em]"
data-placeholder=""
style={{
fontFamily: fontStack,
fontSize: `${firstRun.fontSize || 12}pt`,
@@ -140,26 +138,13 @@ const EditableParagraphBlock: React.FC<{
].filter(Boolean).join(' ') || 'none',
color: firstRun.color || '#0f172a',
backgroundColor: firstRun.highlightColor || 'transparent',
lineHeight: block.lineSpacing || 1.25,
lineHeight: block.lineSpacing ? (block.lineSpacing >= 1.0 ? block.lineSpacing : 1.15 + block.lineSpacing) : 1.15,
letterSpacing: firstRun.letterSpacing !== undefined ? `${firstRun.letterSpacing}px` : (block.letterSpacing !== undefined ? `${block.letterSpacing}px` : 'normal'),
wordBreak: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'pre-wrap',
}}
/>
{isSelected && (
<button
type="button"
title="Delete Paragraph"
onClick={(e) => {
e.stopPropagation();
onRemoveBlock(block.id);
}}
className="absolute -top-2.5 -right-2.5 z-20 flex h-5.5 w-5.5 items-center justify-center rounded-full bg-white text-red-500 hover:bg-red-50 hover:text-red-700 border border-slate-300 shadow-sm text-[11px] font-extrabold cursor-pointer transition-transform hover:scale-110"
>
</button>
)}
</div>
);
};
@@ -237,13 +222,12 @@ const EditableImageBlock: React.FC<{
{/* Floating Quick Action Toolbar */}
{isSelected && (
<div
className={`absolute -top-10 flex items-center gap-1 rounded-md bg-slate-900 px-2 py-1 text-white shadow-xl z-30 text-[11px] whitespace-nowrap max-w-none ${
block.alignment === 'right'
className={`absolute -top-10 flex items-center gap-1 rounded-md bg-slate-900 px-2 py-1 text-white shadow-xl z-30 text-[11px] whitespace-nowrap max-w-none ${block.alignment === 'right'
? 'right-0'
: block.alignment === 'left'
? 'left-0'
: 'left-1/2 -translate-x-1/2'
}`}
? 'left-0'
: 'left-1/2 -translate-x-1/2'
}`}
>
<button
type="button"
@@ -373,9 +357,8 @@ const EditableTableCellDiv: React.FC<{
onFocus={onFocus}
onInput={(e) => onUpdateText(e.currentTarget.innerText)}
onBlur={(e) => onUpdateText(e.currentTarget.innerText)}
className={`outline-none min-h-[1.5em] empty:before:content-[attr(data-placeholder)] empty:before:text-slate-300 empty:before:pointer-events-none ${
isHeader ? 'font-bold' : ''
}`}
className={`outline-none min-h-[1.5em] empty:before:content-[attr(data-placeholder)] empty:before:text-slate-300 empty:before:pointer-events-none ${isHeader ? 'font-bold' : ''
}`}
style={{ textAlign: alignment }}
data-placeholder="Cell..."
/>
@@ -412,305 +395,300 @@ const EditableTableBlock: React.FC<{
onUpdateTableBlock,
onUpdateTableCellAlignment,
}) => {
const [activeCell, setActiveCell] = useState<{ r: number; c: number } | null>(null);
const [activeCell, setActiveCell] = useState<{ r: number; c: number } | null>(null);
const colors = [
{ label: 'Clear', value: 'transparent' },
{ label: 'Gray', value: '#f1f5f9' },
{ label: 'Blue', value: '#dbeafe' },
{ label: 'Green', value: '#dcfce7' },
{ label: 'Yellow', value: '#fef9c3' },
{ label: 'Purple', value: '#f3e8ff' },
{ label: 'Red', value: '#fee2e2' },
];
const colors = [
{ label: 'Clear', value: 'transparent' },
{ label: 'Gray', value: '#f1f5f9' },
{ label: 'Blue', value: '#dbeafe' },
{ label: 'Green', value: '#dcfce7' },
{ label: 'Yellow', value: '#fef9c3' },
{ label: 'Purple', value: '#f3e8ff' },
{ label: 'Red', value: '#fee2e2' },
];
const tableAlignClass =
block.alignment === 'right'
? 'justify-end'
: block.alignment === 'center'
? 'justify-center'
: 'justify-start';
const tableAlignClass =
block.alignment === 'right'
? 'justify-end'
: block.alignment === 'center'
? 'justify-center'
: 'justify-start';
const themeName = block.themeName || 'default';
const themeName = block.themeName || 'default';
return (
<div
onClick={() => onSelectBlock(block.id)}
className={`my-3 flex w-full ${tableAlignClass}`}
>
return (
<div
className={`relative rounded p-1 transition-all inline-block max-w-full ${
isSelected ? 'ring-2 ring-brand-primary bg-blue-50/20' : 'hover:ring-1 hover:ring-slate-300'
}`}
onClick={() => onSelectBlock(block.id)}
className={`my-3 flex w-full ${tableAlignClass}`}
>
{/* Advanced Floating Table Action Bar */}
{isSelected && (
<div className="mb-2 flex flex-wrap items-center gap-1.5 rounded-md bg-slate-900 px-3 py-1.5 text-white shadow-lg text-[11px] z-20 select-none">
<span className="font-semibold text-slate-300 mr-1">Table:</span>
{/* Header Row Toggle */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onUpdateTableBlock?.(block.id, { hasHeaderRow: !block.hasHeaderRow });
}}
className={`rounded px-2 py-0.5 border ${
block.hasHeaderRow ? 'bg-brand-primary border-brand-primary font-semibold' : 'bg-slate-800 border-slate-700 hover:bg-slate-700'
}`}
>
Header Row
</button>
{/* Striped Rows Toggle */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onUpdateTableBlock?.(block.id, { stripedRows: !block.stripedRows });
}}
className={`rounded px-2 py-0.5 border ${
block.stripedRows ? 'bg-brand-primary border-brand-primary font-semibold' : 'bg-slate-800 border-slate-700 hover:bg-slate-700'
}`}
>
Striped Rows
</button>
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
{/* Theme Preset Selector */}
<span className="text-slate-400">Theme:</span>
<select
value={themeName}
onChange={(e) => {
e.stopPropagation();
onUpdateTableBlock?.(block.id, { themeName: e.target.value as any });
}}
onClick={(e) => e.stopPropagation()}
className="bg-slate-800 text-white rounded px-1.5 py-0.5 outline-none border border-slate-700 text-[11px]"
>
<option value="default">Default</option>
<option value="modern">Modern Navy</option>
<option value="minimal">Minimalist</option>
<option value="bordered">Grid Bordered</option>
</select>
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
{/* Row / Col Manipulations */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAddTableRow?.(block.id, activeCell?.r ?? 0, 'above');
}}
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
>
+ Row Above
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAddTableRow?.(block.id, activeCell?.r ?? block.rows.length - 1, 'below');
}}
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
>
+ Row Below
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAddTableColumn?.(block.id, activeCell?.c ?? 0, 'left');
}}
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
>
+ Col Left
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAddTableColumn?.(block.id, activeCell?.c ?? (block.rows[0]?.cells.length || 1) - 1, 'right');
}}
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
>
+ Col Right
</button>
{activeCell && (
<>
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
{/* Cell Alignment */}
<span className="text-slate-400">Cell Align:</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onUpdateTableCellAlignment?.(block.id, activeCell.r, activeCell.c, 'left');
}}
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
title="Align Cell Left"
>
Left
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onUpdateTableCellAlignment?.(block.id, activeCell.r, activeCell.c, 'center');
}}
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
title="Align Cell Center"
>
Center
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onUpdateTableCellAlignment?.(block.id, activeCell.r, activeCell.c, 'right');
}}
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
title="Align Cell Right"
>
Right
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDeleteTableRow?.(block.id, activeCell.r);
}}
className="rounded px-1.5 py-0.5 bg-red-900/80 hover:bg-red-800 text-red-200 ml-1"
>
- Row
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDeleteTableColumn?.(block.id, activeCell.c);
}}
className="rounded px-1.5 py-0.5 bg-red-900/80 hover:bg-red-800 text-red-200"
>
- Col
</button>
</>
)}
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
<span className="text-slate-400">Fill:</span>
{colors.map((c) => (
<button
key={c.label}
type="button"
title={`Fill Cell ${c.label}`}
onClick={(e) => {
e.stopPropagation();
if (activeCell) {
onUpdateTableCellBackground?.(block.id, activeCell.r, activeCell.c, c.value);
}
}}
className="h-4 w-4 rounded border border-slate-600 hover:scale-110 transition-transform"
style={{ backgroundColor: c.value === 'transparent' ? '#ffffff' : c.value }}
/>
))}
<div className="ml-auto flex items-center gap-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveBlock(block.id);
}}
className="rounded px-2 py-0.5 bg-red-600 hover:bg-red-700 text-white font-medium"
>
Delete Table
</button>
</div>
</div>
)}
<table
className={`w-full border-collapse bg-white ${
themeName === 'minimal'
? 'border-b border-slate-200'
: themeName === 'bordered'
? 'border-2 border-slate-400'
: 'border border-slate-300'
}`}
<div
className={`relative rounded p-1 transition-all inline-block max-w-full ${isSelected ? 'ring-2 ring-brand-primary bg-blue-50/20' : 'hover:ring-1 hover:ring-slate-300'
}`}
>
<tbody>
{block.rows.map((row, rIdx) => {
const isHeaderRow = (block.hasHeaderRow && rIdx === 0) || row.isHeader;
const isStriped = block.stripedRows && rIdx % 2 === 1;
{/* Advanced Floating Table Action Bar */}
{isSelected && (
<div className="mb-2 flex flex-wrap items-center gap-1.5 rounded-md bg-slate-900 px-3 py-1.5 text-white shadow-lg text-[11px] z-20 select-none">
<span className="font-semibold text-slate-300 mr-1">Table:</span>
return (
<tr
key={row.id}
className={
isHeaderRow
? themeName === 'modern'
? 'bg-slate-900 text-white'
: 'bg-slate-100 font-bold'
: isStriped
? 'bg-slate-50/80'
: ''
}
{/* Header Row Toggle */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onUpdateTableBlock?.(block.id, { hasHeaderRow: !block.hasHeaderRow });
}}
className={`rounded px-2 py-0.5 border ${block.hasHeaderRow ? 'bg-brand-primary border-brand-primary font-semibold' : 'bg-slate-800 border-slate-700 hover:bg-slate-700'
}`}
>
Header Row
</button>
{/* Striped Rows Toggle */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onUpdateTableBlock?.(block.id, { stripedRows: !block.stripedRows });
}}
className={`rounded px-2 py-0.5 border ${block.stripedRows ? 'bg-brand-primary border-brand-primary font-semibold' : 'bg-slate-800 border-slate-700 hover:bg-slate-700'
}`}
>
Striped Rows
</button>
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
{/* Theme Preset Selector */}
<span className="text-slate-400">Theme:</span>
<select
value={themeName}
onChange={(e) => {
e.stopPropagation();
onUpdateTableBlock?.(block.id, { themeName: e.target.value as any });
}}
onClick={(e) => e.stopPropagation()}
className="bg-slate-800 text-white rounded px-1.5 py-0.5 outline-none border border-slate-700 text-[11px]"
>
<option value="default">Default</option>
<option value="modern">Modern Navy</option>
<option value="minimal">Minimalist</option>
<option value="bordered">Grid Bordered</option>
</select>
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
{/* Row / Col Manipulations */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAddTableRow?.(block.id, activeCell?.r ?? 0, 'above');
}}
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
>
+ Row Above
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAddTableRow?.(block.id, activeCell?.r ?? block.rows.length - 1, 'below');
}}
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
>
+ Row Below
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAddTableColumn?.(block.id, activeCell?.c ?? 0, 'left');
}}
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
>
+ Col Left
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAddTableColumn?.(block.id, activeCell?.c ?? (block.rows[0]?.cells.length || 1) - 1, 'right');
}}
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
>
+ Col Right
</button>
{activeCell && (
<>
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
{/* Cell Alignment */}
<span className="text-slate-400">Cell Align:</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onUpdateTableCellAlignment?.(block.id, activeCell.r, activeCell.c, 'left');
}}
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
title="Align Cell Left"
>
Left
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onUpdateTableCellAlignment?.(block.id, activeCell.r, activeCell.c, 'center');
}}
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
title="Align Cell Center"
>
Center
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onUpdateTableCellAlignment?.(block.id, activeCell.r, activeCell.c, 'right');
}}
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
title="Align Cell Right"
>
Right
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDeleteTableRow?.(block.id, activeCell.r);
}}
className="rounded px-1.5 py-0.5 bg-red-900/80 hover:bg-red-800 text-red-200 ml-1"
>
- Row
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDeleteTableColumn?.(block.id, activeCell.c);
}}
className="rounded px-1.5 py-0.5 bg-red-900/80 hover:bg-red-800 text-red-200"
>
- Col
</button>
</>
)}
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
<span className="text-slate-400">Fill:</span>
{colors.map((c) => (
<button
key={c.label}
type="button"
title={`Fill Cell ${c.label}`}
onClick={(e) => {
e.stopPropagation();
if (activeCell) {
onUpdateTableCellBackground?.(block.id, activeCell.r, activeCell.c, c.value);
}
}}
className="h-4 w-4 rounded border border-slate-600 hover:scale-110 transition-transform"
style={{ backgroundColor: c.value === 'transparent' ? '#ffffff' : c.value }}
/>
))}
<div className="ml-auto flex items-center gap-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveBlock(block.id);
}}
className="rounded px-2 py-0.5 bg-red-600 hover:bg-red-700 text-white font-medium"
>
{row.cells.map((cell, cIdx) => {
const cellText = cell.runs.map((r) => r.text).join('');
const isCellFocused = activeCell?.r === rIdx && activeCell?.c === cIdx;
Delete Table
</button>
</div>
</div>
)}
const cellBg =
cell.backgroundColor ||
(isHeaderRow
<table
className={`w-full border-collapse bg-white ${themeName === 'minimal'
? 'border-b border-slate-200'
: themeName === 'bordered'
? 'border-2 border-slate-400'
: 'border border-slate-300'
}`}
>
<tbody>
{block.rows.map((row, rIdx) => {
const isHeaderRow = (block.hasHeaderRow && rIdx === 0) || row.isHeader;
const isStriped = block.stripedRows && rIdx % 2 === 1;
return (
<tr
key={row.id}
className={
isHeaderRow
? themeName === 'modern'
? '#0f172a'
: '#f1f5f9'
? 'bg-slate-900 text-white'
: 'bg-slate-100 font-bold'
: isStriped
? '#f8fafc'
: 'transparent');
? 'bg-slate-50/80'
: ''
}
>
{row.cells.map((cell, cIdx) => {
const cellText = cell.runs.map((r) => r.text).join('');
const isCellFocused = activeCell?.r === rIdx && activeCell?.c === cIdx;
const cellTextColor = isHeaderRow && themeName === 'modern' ? '#ffffff' : '#0f172a';
const cellBg =
cell.backgroundColor ||
(isHeaderRow
? themeName === 'modern'
? '#0f172a'
: '#f1f5f9'
: isStriped
? '#f8fafc'
: 'transparent');
return (
<td
key={cell.id}
onClick={() => setActiveCell({ r: rIdx, c: cIdx })}
className={`p-2.5 text-[12px] relative transition-colors ${
themeName === 'minimal'
? 'border-b border-slate-200'
: themeName === 'bordered'
? 'border border-slate-400'
: 'border border-slate-300'
} ${isCellFocused ? 'ring-2 ring-brand-primary z-10' : ''}`}
style={{ backgroundColor: cellBg, color: cellTextColor }}
>
<EditableTableCellDiv
cellText={cellText}
alignment={cell.alignment}
isHeader={isHeaderRow}
onUpdateText={(text) => onUpdateTableCell?.(block.id, rIdx, cIdx, text)}
onFocus={() => setActiveCell({ r: rIdx, c: cIdx })}
/>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
const cellTextColor = isHeaderRow && themeName === 'modern' ? '#ffffff' : '#0f172a';
return (
<td
key={cell.id}
onClick={() => setActiveCell({ r: rIdx, c: cIdx })}
className={`p-2.5 text-[12px] relative transition-colors ${themeName === 'minimal'
? 'border-b border-slate-200'
: themeName === 'bordered'
? 'border border-slate-400'
: 'border border-slate-300'
} ${isCellFocused ? 'ring-2 ring-brand-primary z-10' : ''}`}
style={{ backgroundColor: cellBg, color: cellTextColor }}
>
<EditableTableCellDiv
cellText={cellText}
alignment={cell.alignment}
isHeader={isHeaderRow}
onUpdateText={(text) => onUpdateTableCell?.(block.id, rIdx, cIdx, text)}
onFocus={() => setActiveCell({ r: rIdx, c: cIdx })}
/>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
};
);
};
export const DocumentEditor: React.FC<DocumentEditorProps> = ({
doc,
@@ -811,9 +789,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
key={`page-${page.pageNumber}`}
onMouseDown={(e) => handleMouseDownPage(e, pageIdx)}
onMouseMove={(e) => handleMouseMovePage(e, pageIdx)}
className={`shrink-0 relative bg-white shadow-xl transition-shadow border border-slate-200 flex flex-col overflow-hidden ${
isDrawTool ? 'cursor-crosshair' : ''
}`}
className={`shrink-0 relative bg-white shadow-xl transition-shadow border border-slate-200 flex flex-col overflow-hidden ${isDrawTool ? 'cursor-crosshair' : ''
}`}
style={{
width: `${pageW}px`,
height: `${pageH}px`,
@@ -8,6 +8,7 @@ interface DocumentToolbarProps {
doc: DocumentModel;
selectedBlockId: string | null;
activeRun: TextRun | null;
activeParagraph?: ParagraphBlock | null;
activeTool?: string;
drawColor?: string;
drawWidth?: number;
@@ -38,6 +39,7 @@ const FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 64, 72
export const DocumentToolbar: React.FC<DocumentToolbarProps> = ({
selectedBlockId,
activeRun,
activeParagraph,
activeTool = 'select',
drawColor = '#2563eb',
drawWidth = 3,
@@ -86,9 +88,8 @@ export const DocumentToolbar: React.FC<DocumentToolbarProps> = ({
key={c}
type="button"
onClick={() => onUpdateDrawColor?.(c)}
className={`h-5 w-5 rounded-full border border-white shadow-2xs transition-transform ${
drawColor === c ? 'scale-125 ring-2 ring-blue-600' : 'hover:scale-110'
}`}
className={`h-5 w-5 rounded-full border border-white shadow-2xs transition-transform ${drawColor === c ? 'scale-125 ring-2 ring-blue-600' : 'hover:scale-110'
}`}
style={{ backgroundColor: c }}
/>
))}
@@ -135,9 +136,8 @@ export const DocumentToolbar: React.FC<DocumentToolbarProps> = ({
onSelectStamp?.(s.label);
onInsertStamp?.(s.label);
}}
className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-all hover:scale-105 active:scale-95 shadow-2xs cursor-pointer ${
activeStamp === s.label ? 'ring-2 ring-emerald-600 ring-offset-1' : ''
}`}
className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-all hover:scale-105 active:scale-95 shadow-2xs cursor-pointer ${activeStamp === s.label ? 'ring-2 ring-emerald-600 ring-offset-1' : ''
}`}
style={{ color: s.textColor, borderColor: s.borderColor, background: s.backgroundColor }}
title={`Click to insert ${s.label} stamp`}
>
@@ -205,14 +205,56 @@ export const DocumentToolbar: React.FC<DocumentToolbarProps> = ({
<div className="h-4 w-[1px] bg-border-primary mx-0.5" />
{/* Line Gap / Line Spacing */}
<div className="flex items-center gap-1" title="Line Spacing / Line Gap">
<span className="text-[11.5px] font-semibold text-text-secondary"> Line Gap:</span>
<select
className="h-8 rounded-md border border-border-primary bg-bg-primary px-1.5 font-medium text-text-primary outline-none hover:bg-bg-tertiary focus:border-brand-primary text-[12px]"
value={activeParagraph?.lineSpacing || 1.25}
onChange={(e) => onUpdateParagraphFormatting({ lineSpacing: Number(e.target.value) })}
>
<option value={1.0}>1.0 (Single)</option>
<option value={1.15}>1.15 (Compact)</option>
<option value={1.25}>1.25 (Default)</option>
<option value={1.5}>1.5 (1.5 lines)</option>
<option value={2.0}>2.0 (Double)</option>
<option value={2.5}>2.5 (Wide)</option>
<option value={3.0}>3.0 (Extra Wide)</option>
</select>
</div>
{/* Letter Space / Letter Spacing */}
<div className="flex items-center gap-1" title="Letter Spacing / Character Gap">
<span className="text-[11.5px] font-semibold text-text-secondary"> Letter Space:</span>
<select
className="h-8 rounded-md border border-border-primary bg-bg-primary px-1.5 font-medium text-text-primary outline-none hover:bg-bg-tertiary focus:border-brand-primary text-[12px]"
value={activeRun?.letterSpacing ?? (activeParagraph?.letterSpacing ?? 0)}
onChange={(e) => {
const val = Number(e.target.value);
onUpdateRunFormatting({ letterSpacing: val });
onUpdateParagraphFormatting({ letterSpacing: val });
}}
>
<option value={-1}>-1px (Tight)</option>
<option value={0}>0px (Default)</option>
<option value={0.5}>0.5px (Slight)</option>
<option value={1}>1px (Medium)</option>
<option value={1.5}>1.5px (Expanded)</option>
<option value={2}>2px (Wide)</option>
<option value={3}>3px (Very Wide)</option>
<option value={5}>5px (Ultra Wide)</option>
</select>
</div>
<div className="h-4 w-[1px] bg-border-primary mx-0.5" />
{/* Bold / Italic / Underline / Strikethrough */}
<button
type="button"
title="Bold (Ctrl+B)"
onClick={() => onUpdateRunFormatting({ bold: !activeRun?.bold })}
className={`flex h-8 w-8 items-center justify-center rounded-md font-bold transition-colors ${
activeRun?.bold ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}
className={`flex h-8 w-8 items-center justify-center rounded-md font-bold transition-colors ${activeRun?.bold ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}
>
B
</button>
@@ -221,9 +263,8 @@ export const DocumentToolbar: React.FC<DocumentToolbarProps> = ({
type="button"
title="Italic (Ctrl+I)"
onClick={() => onUpdateRunFormatting({ italic: !activeRun?.italic })}
className={`flex h-8 w-8 items-center justify-center rounded-md italic font-serif transition-colors ${
activeRun?.italic ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}
className={`flex h-8 w-8 items-center justify-center rounded-md italic font-serif transition-colors ${activeRun?.italic ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}
>
I
</button>
@@ -232,9 +273,8 @@ export const DocumentToolbar: React.FC<DocumentToolbarProps> = ({
type="button"
title="Underline (Ctrl+U)"
onClick={() => onUpdateRunFormatting({ underline: !activeRun?.underline })}
className={`flex h-8 w-8 items-center justify-center rounded-md underline transition-colors ${
activeRun?.underline ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}
className={`flex h-8 w-8 items-center justify-center rounded-md underline transition-colors ${activeRun?.underline ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}
>
U
</button>
@@ -243,9 +283,8 @@ export const DocumentToolbar: React.FC<DocumentToolbarProps> = ({
type="button"
title="Strikethrough"
onClick={() => onUpdateRunFormatting({ strikethrough: !activeRun?.strikethrough })}
className={`flex h-8 w-8 items-center justify-center rounded-md line-through transition-colors ${
activeRun?.strikethrough ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}
className={`flex h-8 w-8 items-center justify-center rounded-md line-through transition-colors ${activeRun?.strikethrough ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}
>
S
</button>
@@ -73,7 +73,8 @@ export class PaginationEngine {
const fontSizePt = block.runs[0]?.fontSize || 12;
const fontSizePx = fontSizePt * PT_TO_PX;
const lineSpacing = block.lineSpacing || 1.25;
const avgCharWidthPx = fontSizePx * 0.52;
const letterSpacingPx = ((block.runs[0]?.letterSpacing !== undefined ? block.runs[0].letterSpacing : block.letterSpacing) || 0) * PT_TO_PX;
const avgCharWidthPx = fontSizePx * 0.52 + letterSpacingPx;
const charsPerLine = Math.max(1, Math.floor(usableWidthPx / avgCharWidthPx));
const paragraphs = fullText.split('\n');
@@ -88,7 +89,7 @@ export class PaginationEngine {
const paddingPx = 4;
const spaceBeforePx = (block.spaceBefore || 0) * PT_TO_PX;
const spaceAfterPx = (block.spaceAfter || 4) * PT_TO_PX;
const spaceAfterPx = (block.spaceAfter !== undefined ? block.spaceAfter : 2) * PT_TO_PX;
const flexGapPx = 4;
const contentHeight = totalLines * (fontSizePx * lineSpacing) + paddingPx + spaceBeforePx + spaceAfterPx + flexGapPx;
@@ -127,7 +127,12 @@ export class PdfDocumentRenderer {
indentOffset = 18;
}
const lineSpacing = pBlock.lineSpacing || 1.15;
const rawLineSpacing = pBlock.lineSpacing || 0;
const lineSpacing = rawLineSpacing >= 1.0 ? rawLineSpacing : 1.15 + rawLineSpacing;
const letterSpacing = (pBlock.runs[0]?.letterSpacing !== undefined ? pBlock.runs[0].letterSpacing : pBlock.letterSpacing) || 0;
if (typeof (pdf as any).setCharSpace === 'function') {
(pdf as any).setCharSpace(letterSpacing * 0.75);
}
const lines = pdf.splitTextToSize(textToRender, usableWidth - indentOffset);
const blockHeight = lines.length * (fontSize * lineSpacing);
@@ -150,6 +155,9 @@ export class PdfDocumentRenderer {
pdf.text(line, xPos, currentY + fontSize, { align: alignOption });
currentY += fontSize * lineSpacing;
});
if (typeof (pdf as any).setCharSpace === 'function') {
(pdf as any).setCharSpace(0);
}
// Hyperlinks handling
pBlock.runs.forEach((run) => {
@@ -10,6 +10,7 @@ export interface TextRun {
color?: string;
highlightColor?: string;
linkUrl?: string;
letterSpacing?: number;
}
export type BlockType = 'paragraph' | 'heading' | 'list-item' | 'table' | 'image' | 'quote' | 'page-break';
@@ -21,7 +22,8 @@ export interface ParagraphBlock {
headingLevel?: 1 | 2 | 3 | 4;
runs: TextRun[];
alignment?: 'left' | 'center' | 'right' | 'justify';
lineSpacing?: number; // 1.0, 1.15, 1.5, 2.0
lineSpacing?: number; // 1.0, 1.15, 1.25, 1.5, 2.0, 2.5, 3.0
letterSpacing?: number; // -1, 0, 0.5, 1.0, 1.5, 2.0, 3.0, 5.0
spaceBefore?: number;
spaceAfter?: number;
indentLeft?: number;
@@ -125,7 +127,7 @@ export const DEFAULT_STYLES: Record<string, StyleDefinition> = {
color: '#0f172a',
alignment: 'left',
spaceBefore: 0,
spaceAfter: 6,
spaceAfter: 2,
},
Title: {
name: 'Title',
+1 -3
View File
@@ -727,9 +727,7 @@ class GatewayService {
method: 'POST',
});
if (!response.ok) {
const errJson = await response.json().catch(() => null);
const detail = errJson?.detail || response.statusText;
throw new Error(detail);
throw new Error(`OCR failed: ${response.statusText}`);
}
return response.json();
}