mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-04 02:20:19 +01:00
editor revamp, complete change
This commit is contained in:
@@ -11,8 +11,10 @@ import {
|
||||
FileButton,
|
||||
Group,
|
||||
Pagination,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
PdfJsonEditorViewData,
|
||||
PdfJsonFont,
|
||||
PdfJsonPage,
|
||||
ConversionProgress,
|
||||
} from '@app/tools/pdfJsonEditor/pdfJsonEditorTypes';
|
||||
import { getImageBounds, pageDimensions } from '@app/tools/pdfJsonEditor/pdfJsonEditorUtils';
|
||||
|
||||
@@ -205,6 +208,9 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
|
||||
const [activeImageId, setActiveImageId] = useState<string | null>(null);
|
||||
const [fontFamilies, setFontFamilies] = useState<Map<string, string>>(new Map());
|
||||
const [textGroupsExpanded, setTextGroupsExpanded] = useState(false);
|
||||
const [autoScaleText, setAutoScaleText] = useState(true);
|
||||
const [textScales, setTextScales] = useState<Map<string, number>>(new Map());
|
||||
const measurementKeyRef = useRef<string>('');
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const editorRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const caretOffsetsRef = useRef<Map<string, number>>(new Map());
|
||||
@@ -220,6 +226,7 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
|
||||
errorMessage,
|
||||
isGeneratingPdf,
|
||||
isConverting,
|
||||
conversionProgress,
|
||||
hasChanges,
|
||||
onLoadJson,
|
||||
onSelectPage,
|
||||
@@ -562,8 +569,73 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
|
||||
setActiveGroupId(null);
|
||||
setEditingGroupId(null);
|
||||
setActiveImageId(null);
|
||||
setTextScales(new Map());
|
||||
measurementKeyRef.current = '';
|
||||
}, [selectedPage]);
|
||||
|
||||
// Measure text widths once per page/configuration and apply static scaling
|
||||
useLayoutEffect(() => {
|
||||
if (!autoScaleText || visibleGroups.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a stable key for this measurement configuration
|
||||
const currentKey = `${selectedPage}-${fontFamilies.size}-${autoScaleText}`;
|
||||
|
||||
// Skip if we've already measured for this configuration
|
||||
if (measurementKeyRef.current === currentKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const measureTextScales = () => {
|
||||
const newScales = new Map<string, number>();
|
||||
|
||||
visibleGroups.forEach((group) => {
|
||||
// Skip groups that are being edited
|
||||
if (editingGroupId === group.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = document.querySelector<HTMLElement>(`[data-text-group="${group.id}"]`);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const textSpan = element.querySelector<HTMLSpanElement>('span[data-text-content]');
|
||||
if (!textSpan) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Temporarily remove any existing transform to get natural width
|
||||
const originalTransform = textSpan.style.transform;
|
||||
textSpan.style.transform = 'none';
|
||||
|
||||
const bounds = toCssBounds(currentPage, pageHeight, scale, group.bounds);
|
||||
const containerWidth = bounds.width;
|
||||
const textWidth = textSpan.getBoundingClientRect().width;
|
||||
|
||||
// Restore original transform
|
||||
textSpan.style.transform = originalTransform;
|
||||
|
||||
// Only scale if text overflows by more than 2%
|
||||
if (textWidth > 0 && textWidth > containerWidth * 1.02) {
|
||||
const scaleX = Math.max(containerWidth / textWidth, 0.5); // Min 50% scale
|
||||
newScales.set(group.id, scaleX);
|
||||
} else {
|
||||
newScales.set(group.id, 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Mark this configuration as measured
|
||||
measurementKeyRef.current = currentKey;
|
||||
setTextScales(newScales);
|
||||
};
|
||||
|
||||
// Delay measurement to ensure fonts and layout are ready
|
||||
const timer = setTimeout(measureTextScales, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}, [autoScaleText, visibleGroups, editingGroupId, currentPage, pageHeight, scale, fontFamilies.size, selectedPage]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!editingGroupId) {
|
||||
return;
|
||||
@@ -726,6 +798,27 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
|
||||
{t('pdfJsonEditor.currentFile', 'Current file: {{name}}', { name: fileName })}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('pdfJsonEditor.options.autoScaleText.title', 'Auto-scale text to fit boxes')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
'pdfJsonEditor.options.autoScaleText.description',
|
||||
'Automatically scales text horizontally to fit within its original bounding box when font rendering differs from PDF.'
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
checked={autoScaleText}
|
||||
onChange={(event) => setAutoScaleText(event.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -782,10 +875,39 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
|
||||
|
||||
{isConverting && (
|
||||
<Card withBorder radius="md" padding="xl">
|
||||
<Stack align="center" gap="md">
|
||||
<AutorenewIcon sx={{ fontSize: 48 }} className="animate-spin" />
|
||||
<Text size="lg" fw={600}>
|
||||
{t('pdfJsonEditor.converting', 'Converting PDF to editable format...')}
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="lg" fw={600} mb="xs">
|
||||
{conversionProgress
|
||||
? conversionProgress.message
|
||||
: t('pdfJsonEditor.converting', 'Converting PDF to editable format...')}
|
||||
</Text>
|
||||
{conversionProgress && (
|
||||
<Group gap="xs">
|
||||
<Text size="sm" c="dimmed" tt="capitalize">
|
||||
{t(`pdfJsonEditor.stages.${conversionProgress.stage}`, conversionProgress.stage)}
|
||||
</Text>
|
||||
{conversionProgress.current !== undefined &&
|
||||
conversionProgress.total !== undefined && (
|
||||
<Text size="sm" c="dimmed">
|
||||
• Page {conversionProgress.current} of {conversionProgress.total}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
<AutorenewIcon sx={{ fontSize: 36 }} className="animate-spin" />
|
||||
</Group>
|
||||
<Progress
|
||||
value={conversionProgress?.percent || 0}
|
||||
size="lg"
|
||||
radius="md"
|
||||
animated
|
||||
striped
|
||||
/>
|
||||
<Text size="sm" c="dimmed" ta="right">
|
||||
{conversionProgress?.percent || 0}% complete
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -1105,6 +1227,9 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
const textScale = textScales.get(group.id) ?? 1;
|
||||
const shouldScale = autoScaleText && textScale < 0.98;
|
||||
|
||||
return (
|
||||
<Box key={group.id} style={containerStyle}>
|
||||
{renderGroupContainer(
|
||||
@@ -1112,6 +1237,7 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
|
||||
isActive,
|
||||
changed,
|
||||
<div
|
||||
data-text-group={group.id}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '100%',
|
||||
@@ -1127,7 +1253,17 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
<span style={{ pointerEvents: 'none' }}>{group.text || '\u00A0'}</span>
|
||||
<span
|
||||
data-text-content
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
display: 'inline-block',
|
||||
transform: shouldScale ? `scaleX(${textScale})` : undefined,
|
||||
transformOrigin: 'left center',
|
||||
}}
|
||||
>
|
||||
{group.text || '\u00A0'}
|
||||
</span>
|
||||
</div>,
|
||||
() => {
|
||||
setEditingGroupId(group.id);
|
||||
|
||||
@@ -27,8 +27,8 @@ export function useProprietaryToolRegistry(): ProprietaryToolRegistry {
|
||||
"home.pdfJsonEditor.desc",
|
||||
"Review and edit Stirling PDF JSON exports with grouped text editing and PDF regeneration"
|
||||
),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
workbench: "custom:pdfJsonEditor",
|
||||
endpoints: ["json-pdf"],
|
||||
synonyms: getSynonyms(t, "pdfJsonEditor"),
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getFilenameFromHeaders } from '@app/utils/fileResponseUtils';
|
||||
import {
|
||||
PdfJsonDocument,
|
||||
PdfJsonImageElement,
|
||||
PdfJsonPage,
|
||||
TextGroup,
|
||||
PdfJsonEditorViewData,
|
||||
} from './pdfJsonEditorTypes';
|
||||
@@ -68,11 +69,39 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
|
||||
const [isConverting, setIsConverting] = useState(false);
|
||||
const [conversionProgress, setConversionProgress] = useState<{
|
||||
percent: number;
|
||||
stage: string;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
// Lazy loading state
|
||||
const [isLazyMode, setIsLazyMode] = useState(false);
|
||||
const [cachedJobId, setCachedJobId] = useState<string | null>(null);
|
||||
const [loadedImagePages, setLoadedImagePages] = useState<Set<number>>(new Set());
|
||||
const [loadingImagePages, setLoadingImagePages] = useState<Set<number>>(new Set());
|
||||
|
||||
const originalImagesRef = useRef<PdfJsonImageElement[][]>([]);
|
||||
const imagesByPageRef = useRef<PdfJsonImageElement[][]>([]);
|
||||
const autoLoadKeyRef = useRef<string | null>(null);
|
||||
const loadRequestIdRef = useRef(0);
|
||||
const latestPdfRequestIdRef = useRef<number | null>(null);
|
||||
const loadedDocumentRef = useRef<PdfJsonDocument | null>(null);
|
||||
const loadedImagePagesRef = useRef<Set<number>>(new Set());
|
||||
const loadingImagePagesRef = useRef<Set<number>>(new Set());
|
||||
|
||||
// Keep ref in sync with state for access in async callbacks
|
||||
useEffect(() => {
|
||||
loadedDocumentRef.current = loadedDocument;
|
||||
}, [loadedDocument]);
|
||||
|
||||
useEffect(() => {
|
||||
loadedImagePagesRef.current = new Set(loadedImagePages);
|
||||
}, [loadedImagePages]);
|
||||
|
||||
useEffect(() => {
|
||||
loadingImagePagesRef.current = new Set(loadingImagePages);
|
||||
}, [loadingImagePages]);
|
||||
|
||||
const dirtyPages = useMemo(
|
||||
() => getDirtyPages(groupsByPage, imagesByPage, originalImagesRef.current),
|
||||
@@ -88,18 +117,134 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
setGroupsByPage([]);
|
||||
setImagesByPage([]);
|
||||
originalImagesRef.current = [];
|
||||
imagesByPageRef.current = [];
|
||||
setLoadedImagePages(new Set());
|
||||
setLoadingImagePages(new Set());
|
||||
loadedImagePagesRef.current = new Set();
|
||||
loadingImagePagesRef.current = new Set();
|
||||
setSelectedPage(0);
|
||||
return;
|
||||
}
|
||||
const cloned = deepCloneDocument(document);
|
||||
const groups = groupDocumentText(cloned);
|
||||
const images = extractDocumentImages(cloned);
|
||||
originalImagesRef.current = images.map((page) => page.map(cloneImageElement));
|
||||
const originalImages = images.map((page) => page.map(cloneImageElement));
|
||||
originalImagesRef.current = originalImages;
|
||||
imagesByPageRef.current = images.map((page) => page.map(cloneImageElement));
|
||||
const initialLoaded = new Set<number>();
|
||||
originalImages.forEach((pageImages, index) => {
|
||||
if (pageImages.length > 0) {
|
||||
initialLoaded.add(index);
|
||||
}
|
||||
});
|
||||
setGroupsByPage(groups);
|
||||
setImagesByPage(images);
|
||||
setLoadedImagePages(initialLoaded);
|
||||
setLoadingImagePages(new Set());
|
||||
loadedImagePagesRef.current = new Set(initialLoaded);
|
||||
loadingImagePagesRef.current = new Set();
|
||||
setSelectedPage(0);
|
||||
}, []);
|
||||
|
||||
// Load images for a page in lazy mode
|
||||
const loadImagesForPage = useCallback(
|
||||
async (pageIndex: number) => {
|
||||
if (!isLazyMode) {
|
||||
return;
|
||||
}
|
||||
if (!cachedJobId) {
|
||||
console.log('[loadImagesForPage] No cached jobId, skipping');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
loadedImagePagesRef.current.has(pageIndex) ||
|
||||
loadingImagePagesRef.current.has(pageIndex)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingImagePagesRef.current.add(pageIndex);
|
||||
setLoadingImagePages((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(pageIndex);
|
||||
return next;
|
||||
});
|
||||
|
||||
const pageNumber = pageIndex + 1;
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/api/v1/convert/pdf/json/page/${cachedJobId}/${pageNumber}`,
|
||||
{
|
||||
responseType: 'json',
|
||||
},
|
||||
);
|
||||
|
||||
const pageData = response.data as PdfJsonPage;
|
||||
const normalizedImages = (pageData.imageElements ?? []).map(cloneImageElement);
|
||||
|
||||
if (imagesByPageRef.current.length <= pageIndex) {
|
||||
imagesByPageRef.current.length = pageIndex + 1;
|
||||
}
|
||||
imagesByPageRef.current[pageIndex] = normalizedImages.map(cloneImageElement);
|
||||
|
||||
setLoadedDocument((prevDoc) => {
|
||||
if (!prevDoc || !prevDoc.pages) {
|
||||
return prevDoc;
|
||||
}
|
||||
const nextPages = [...prevDoc.pages];
|
||||
const existingPage = nextPages[pageIndex] ?? {};
|
||||
nextPages[pageIndex] = {
|
||||
...existingPage,
|
||||
imageElements: normalizedImages.map(cloneImageElement),
|
||||
};
|
||||
return {
|
||||
...prevDoc,
|
||||
pages: nextPages,
|
||||
};
|
||||
});
|
||||
|
||||
setImagesByPage((prev) => {
|
||||
const next = [...prev];
|
||||
while (next.length <= pageIndex) {
|
||||
next.push([]);
|
||||
}
|
||||
next[pageIndex] = normalizedImages.map(cloneImageElement);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (originalImagesRef.current.length <= pageIndex) {
|
||||
originalImagesRef.current.length = pageIndex + 1;
|
||||
}
|
||||
originalImagesRef.current[pageIndex] = normalizedImages.map(cloneImageElement);
|
||||
|
||||
setLoadedImagePages((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(pageIndex);
|
||||
return next;
|
||||
});
|
||||
loadedImagePagesRef.current.add(pageIndex);
|
||||
|
||||
console.log(
|
||||
`[loadImagesForPage] Loaded ${normalizedImages.length} images for page ${pageNumber} in ${(
|
||||
performance.now() - start
|
||||
).toFixed(2)}ms`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[loadImagesForPage] Failed to load images for page ${pageNumber}:`, error);
|
||||
} finally {
|
||||
loadingImagePagesRef.current.delete(pageIndex);
|
||||
setLoadingImagePages((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(pageIndex);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[isLazyMode, cachedJobId],
|
||||
);
|
||||
|
||||
const handleLoadFile = useCallback(
|
||||
async (file: File | null) => {
|
||||
if (!file) {
|
||||
@@ -113,39 +258,200 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
|
||||
|
||||
try {
|
||||
let parsed: PdfJsonDocument;
|
||||
let parsed: PdfJsonDocument | null = null;
|
||||
let shouldUseLazyMode = false;
|
||||
let pendingJobId: string | null = null;
|
||||
|
||||
setErrorMessage(null);
|
||||
|
||||
if (isPdf) {
|
||||
latestPdfRequestIdRef.current = requestId;
|
||||
setIsConverting(true);
|
||||
setConversionProgress({
|
||||
percent: 0,
|
||||
stage: 'uploading',
|
||||
message: 'Uploading PDF file to server...',
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
|
||||
const response = await apiClient.post(CONVERSION_ENDPOINTS['pdf-json'], formData, {
|
||||
responseType: 'blob',
|
||||
console.log('Sending conversion request with async=true');
|
||||
const response = await apiClient.post(
|
||||
`${CONVERSION_ENDPOINTS['pdf-json']}?async=true`,
|
||||
formData,
|
||||
{
|
||||
responseType: 'json',
|
||||
},
|
||||
);
|
||||
|
||||
console.log('Conversion response:', response.data);
|
||||
const jobId = response.data.jobId;
|
||||
|
||||
if (!jobId) {
|
||||
console.error('No job ID in response:', response.data);
|
||||
throw new Error('No job ID received from server');
|
||||
}
|
||||
|
||||
pendingJobId = jobId;
|
||||
console.log('Got job ID:', jobId);
|
||||
setConversionProgress({
|
||||
percent: 3,
|
||||
stage: 'processing',
|
||||
message: 'Starting conversion...',
|
||||
});
|
||||
|
||||
const jsonText = await response.data.text();
|
||||
parsed = JSON.parse(jsonText) as PdfJsonDocument;
|
||||
let jobComplete = false;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 600;
|
||||
|
||||
while (!jobComplete && attempts < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
attempts += 1;
|
||||
|
||||
try {
|
||||
const statusResponse = await apiClient.get(`/api/v1/general/job/${jobId}`);
|
||||
const jobStatus = statusResponse.data;
|
||||
console.log(`Job status (attempt ${attempts}):`, jobStatus);
|
||||
|
||||
if (jobStatus.notes && jobStatus.notes.length > 0) {
|
||||
const lastNote = jobStatus.notes[jobStatus.notes.length - 1];
|
||||
console.log('Latest note:', lastNote);
|
||||
const matchWithCount = lastNote.match(
|
||||
/\[(\d+)%\]\s+(\w+):\s+(.+?)\s+\((\d+)\/(\d+)\)/,
|
||||
);
|
||||
if (matchWithCount) {
|
||||
const percent = parseInt(matchWithCount[1], 10);
|
||||
const stage = matchWithCount[2];
|
||||
const message = matchWithCount[3];
|
||||
const current = parseInt(matchWithCount[4], 10);
|
||||
const total = parseInt(matchWithCount[5], 10);
|
||||
setConversionProgress({
|
||||
percent,
|
||||
stage,
|
||||
message,
|
||||
current,
|
||||
total,
|
||||
});
|
||||
} else {
|
||||
const match = lastNote.match(/\[(\d+)%\]\s+(\w+):\s+(.+)/);
|
||||
if (match) {
|
||||
const percent = parseInt(match[1], 10);
|
||||
const stage = match[2];
|
||||
const message = match[3];
|
||||
setConversionProgress({
|
||||
percent,
|
||||
stage,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (jobStatus.progress !== undefined) {
|
||||
const percent = Math.min(Math.max(jobStatus.progress, 0), 100);
|
||||
setConversionProgress({
|
||||
percent,
|
||||
stage: jobStatus.stage || 'processing',
|
||||
message: jobStatus.note || 'Converting PDF to JSON...',
|
||||
});
|
||||
}
|
||||
|
||||
if (jobStatus.complete) {
|
||||
if (jobStatus.error) {
|
||||
console.error('Job failed:', jobStatus.error);
|
||||
throw new Error(jobStatus.error);
|
||||
}
|
||||
|
||||
console.log('Job completed, retrieving JSON result...');
|
||||
jobComplete = true;
|
||||
|
||||
const resultResponse = await apiClient.get(
|
||||
`/api/v1/general/job/${jobId}/result`,
|
||||
{
|
||||
responseType: 'blob',
|
||||
},
|
||||
);
|
||||
|
||||
const jsonText = await resultResponse.data.text();
|
||||
const result = JSON.parse(jsonText);
|
||||
|
||||
if (!Array.isArray(result.pages)) {
|
||||
console.error('Conversion result missing page array:', result);
|
||||
throw new Error(
|
||||
'PDF conversion result did not include page data. Please update the server.',
|
||||
);
|
||||
}
|
||||
|
||||
const docResult = result as PdfJsonDocument;
|
||||
parsed = {
|
||||
...docResult,
|
||||
pages: docResult.pages ?? [],
|
||||
};
|
||||
shouldUseLazyMode = Boolean(docResult.lazyImages);
|
||||
pendingJobId = shouldUseLazyMode ? jobId : null;
|
||||
setConversionProgress(null);
|
||||
} else {
|
||||
console.log('Job not complete yet, continuing to poll...');
|
||||
}
|
||||
} catch (pollError: any) {
|
||||
console.error('Error polling job status:', pollError);
|
||||
console.error('Poll error details:', {
|
||||
status: pollError?.response?.status,
|
||||
data: pollError?.response?.data,
|
||||
message: pollError?.message,
|
||||
});
|
||||
if (pollError?.response?.status === 404) {
|
||||
throw new Error('Job not found on server');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!jobComplete) {
|
||||
throw new Error('Conversion timed out');
|
||||
}
|
||||
if (!parsed) {
|
||||
throw new Error('Conversion did not return JSON content');
|
||||
}
|
||||
} else {
|
||||
const content = await file.text();
|
||||
parsed = JSON.parse(content) as PdfJsonDocument;
|
||||
const docResult = JSON.parse(content) as PdfJsonDocument;
|
||||
parsed = {
|
||||
...docResult,
|
||||
pages: docResult.pages ?? [],
|
||||
};
|
||||
shouldUseLazyMode = false;
|
||||
pendingJobId = null;
|
||||
}
|
||||
|
||||
setConversionProgress(null);
|
||||
|
||||
if (loadRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
throw new Error('Failed to parse PDF JSON document');
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[PdfJsonEditor] Document loaded. Lazy image mode: ${shouldUseLazyMode}, Pages: ${
|
||||
parsed.pages?.length || 0
|
||||
}`,
|
||||
);
|
||||
|
||||
setLoadedDocument(parsed);
|
||||
resetToDocument(parsed);
|
||||
setIsLazyMode(shouldUseLazyMode);
|
||||
setCachedJobId(shouldUseLazyMode ? pendingJobId : null);
|
||||
setFileName(file.name);
|
||||
setErrorMessage(null);
|
||||
autoLoadKeyRef.current = fileKey;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load file', error);
|
||||
console.error('Error details:', {
|
||||
message: error?.message,
|
||||
response: error?.response?.data,
|
||||
stack: error?.stack,
|
||||
});
|
||||
|
||||
if (loadRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
@@ -155,15 +461,17 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
resetToDocument(null);
|
||||
|
||||
if (isPdf) {
|
||||
setErrorMessage(
|
||||
t('pdfJsonEditor.conversionFailed', 'Failed to convert PDF. Please try again.')
|
||||
);
|
||||
const errorMsg =
|
||||
error?.message ||
|
||||
t('pdfJsonEditor.conversionFailed', 'Failed to convert PDF. Please try again.');
|
||||
setErrorMessage(errorMsg);
|
||||
console.error('Setting error message:', errorMsg);
|
||||
} else {
|
||||
setErrorMessage(
|
||||
t(
|
||||
'pdfJsonEditor.errors.invalidJson',
|
||||
'Unable to read the JSON file. Ensure it was generated by the PDF to JSON tool.'
|
||||
)
|
||||
'Unable to read the JSON file. Ensure it was generated by the PDF to JSON tool.',
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -172,12 +480,16 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
}
|
||||
}
|
||||
},
|
||||
[resetToDocument, t]
|
||||
[resetToDocument, t],
|
||||
);
|
||||
|
||||
const handleSelectPage = useCallback((pageIndex: number) => {
|
||||
setSelectedPage(pageIndex);
|
||||
}, []);
|
||||
// Trigger lazy loading for images on the selected page
|
||||
if (isLazyMode) {
|
||||
void loadImagesForPage(pageIndex);
|
||||
}
|
||||
}, [isLazyMode, loadImagesForPage]);
|
||||
|
||||
const handleGroupTextChange = useCallback((pageIndex: number, groupId: string, value: string) => {
|
||||
setGroupsByPage((previous) =>
|
||||
@@ -195,55 +507,63 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
imageId: string,
|
||||
next: { left: number; bottom: number; width: number; height: number; transform: number[] },
|
||||
) => {
|
||||
setImagesByPage((previous) =>
|
||||
previous.map((images, idx) => {
|
||||
if (idx !== pageIndex) {
|
||||
return images;
|
||||
setImagesByPage((previous) => {
|
||||
const current = previous[pageIndex] ?? [];
|
||||
let changed = false;
|
||||
const updatedPage = current.map((image) => {
|
||||
if ((image.id ?? '') !== imageId) {
|
||||
return image;
|
||||
}
|
||||
let changed = false;
|
||||
const updated = images.map((image) => {
|
||||
if ((image.id ?? '') !== imageId) {
|
||||
return image;
|
||||
}
|
||||
const originalTransform = image.transform ?? originalImagesRef.current[idx]?.find((base) => (base.id ?? '') === imageId)?.transform;
|
||||
const scaleXSign = originalTransform && originalTransform.length >= 6 ? Math.sign(originalTransform[0]) || 1 : 1;
|
||||
const scaleYSign = originalTransform && originalTransform.length >= 6 ? Math.sign(originalTransform[3]) || 1 : 1;
|
||||
const right = next.left + next.width;
|
||||
const top = next.bottom + next.height;
|
||||
const updatedImage: PdfJsonImageElement = {
|
||||
...image,
|
||||
x: next.left,
|
||||
y: next.bottom,
|
||||
left: next.left,
|
||||
bottom: next.bottom,
|
||||
right,
|
||||
top,
|
||||
width: next.width,
|
||||
height: next.height,
|
||||
transform: scaleXSign < 0 || scaleYSign < 0 ? [
|
||||
next.width * scaleXSign,
|
||||
0,
|
||||
0,
|
||||
next.height * scaleYSign,
|
||||
next.left,
|
||||
scaleYSign >= 0 ? next.bottom : next.bottom + next.height,
|
||||
] : null,
|
||||
};
|
||||
const originalTransform = image.transform ?? originalImagesRef.current[pageIndex]?.find((base) => (base.id ?? '') === imageId)?.transform;
|
||||
const scaleXSign = originalTransform && originalTransform.length >= 6 ? Math.sign(originalTransform[0]) || 1 : 1;
|
||||
const scaleYSign = originalTransform && originalTransform.length >= 6 ? Math.sign(originalTransform[3]) || 1 : 1;
|
||||
const right = next.left + next.width;
|
||||
const top = next.bottom + next.height;
|
||||
const updatedImage: PdfJsonImageElement = {
|
||||
...image,
|
||||
x: next.left,
|
||||
y: next.bottom,
|
||||
left: next.left,
|
||||
bottom: next.bottom,
|
||||
right,
|
||||
top,
|
||||
width: next.width,
|
||||
height: next.height,
|
||||
transform: scaleXSign < 0 || scaleYSign < 0
|
||||
? [
|
||||
next.width * scaleXSign,
|
||||
0,
|
||||
0,
|
||||
next.height * scaleYSign,
|
||||
next.left,
|
||||
scaleYSign >= 0 ? next.bottom : next.bottom + next.height,
|
||||
]
|
||||
: null,
|
||||
};
|
||||
|
||||
const isSame =
|
||||
Math.abs(valueOr(image.left, 0) - next.left) < 1e-4 &&
|
||||
Math.abs(valueOr(image.bottom, 0) - next.bottom) < 1e-4 &&
|
||||
Math.abs(valueOr(image.width, 0) - next.width) < 1e-4 &&
|
||||
Math.abs(valueOr(image.height, 0) - next.height) < 1e-4;
|
||||
const isSame =
|
||||
Math.abs(valueOr(image.left, 0) - next.left) < 1e-4 &&
|
||||
Math.abs(valueOr(image.bottom, 0) - next.bottom) < 1e-4 &&
|
||||
Math.abs(valueOr(image.width, 0) - next.width) < 1e-4 &&
|
||||
Math.abs(valueOr(image.height, 0) - next.height) < 1e-4;
|
||||
|
||||
if (!isSame) {
|
||||
changed = true;
|
||||
}
|
||||
return updatedImage;
|
||||
});
|
||||
return changed ? updated : images;
|
||||
}),
|
||||
);
|
||||
if (!isSame) {
|
||||
changed = true;
|
||||
}
|
||||
return updatedImage;
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const nextImages = previous.map((images, idx) => (idx === pageIndex ? updatedPage : images));
|
||||
if (imagesByPageRef.current.length <= pageIndex) {
|
||||
imagesByPageRef.current.length = pageIndex + 1;
|
||||
}
|
||||
imagesByPageRef.current[pageIndex] = updatedPage.map(cloneImageElement);
|
||||
return nextImages;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -253,14 +573,28 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
if (!baseline) {
|
||||
return;
|
||||
}
|
||||
setImagesByPage((previous) =>
|
||||
previous.map((images, idx) => {
|
||||
if (idx !== pageIndex) {
|
||||
return images;
|
||||
setImagesByPage((previous) => {
|
||||
const current = previous[pageIndex] ?? [];
|
||||
let changed = false;
|
||||
const updatedPage = current.map((image) => {
|
||||
if ((image.id ?? '') !== imageId) {
|
||||
return image;
|
||||
}
|
||||
return images.map((image) => ((image.id ?? '') === imageId ? cloneImageElement(baseline) : image));
|
||||
}),
|
||||
);
|
||||
changed = true;
|
||||
return cloneImageElement(baseline);
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const nextImages = previous.map((images, idx) => (idx === pageIndex ? updatedPage : images));
|
||||
if (imagesByPageRef.current.length <= pageIndex) {
|
||||
imagesByPageRef.current.length = pageIndex + 1;
|
||||
}
|
||||
imagesByPageRef.current[pageIndex] = updatedPage.map(cloneImageElement);
|
||||
return nextImages;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleResetEdits = useCallback(() => {
|
||||
@@ -279,7 +613,7 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
const updatedDocument = restoreGlyphElements(
|
||||
loadedDocument,
|
||||
groupsByPage,
|
||||
imagesByPage,
|
||||
imagesByPageRef.current,
|
||||
originalImagesRef.current,
|
||||
);
|
||||
const baseName = sanitizeBaseName(fileName || loadedDocument.metadata?.title || undefined);
|
||||
@@ -287,7 +621,7 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
document: updatedDocument,
|
||||
filename: `${baseName}.json`,
|
||||
};
|
||||
}, [fileName, groupsByPage, imagesByPage, loadedDocument]);
|
||||
}, [fileName, groupsByPage, loadedDocument]);
|
||||
|
||||
const handleDownloadJson = useCallback(() => {
|
||||
const payload = buildPayload();
|
||||
@@ -306,20 +640,129 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
}, [buildPayload, onComplete]);
|
||||
|
||||
const handleGeneratePdf = useCallback(async () => {
|
||||
const payload = buildPayload();
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { document, filename } = payload;
|
||||
const serialized = JSON.stringify(document, null, 2);
|
||||
const jsonFile = new File([serialized], filename, { type: 'application/json' });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', jsonFile);
|
||||
|
||||
try {
|
||||
setIsGeneratingPdf(true);
|
||||
|
||||
const ensureImagesForPages = async (pageIndices: number[]) => {
|
||||
const uniqueIndices = Array.from(new Set(pageIndices)).filter((index) => index >= 0);
|
||||
if (uniqueIndices.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const index of uniqueIndices) {
|
||||
if (!loadedImagePagesRef.current.has(index)) {
|
||||
await loadImagesForPage(index);
|
||||
}
|
||||
}
|
||||
|
||||
const maxWaitTime = 15000;
|
||||
const pollInterval = 150;
|
||||
const startWait = Date.now();
|
||||
while (Date.now() - startWait < maxWaitTime) {
|
||||
const allLoaded = uniqueIndices.every(
|
||||
(index) =>
|
||||
loadedImagePagesRef.current.has(index) &&
|
||||
imagesByPageRef.current[index] !== undefined,
|
||||
);
|
||||
const anyLoading = uniqueIndices.some((index) =>
|
||||
loadingImagePagesRef.current.has(index),
|
||||
);
|
||||
if (allLoaded && !anyLoading) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
const missing = uniqueIndices.filter(
|
||||
(index) => !loadedImagePagesRef.current.has(index),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Failed to load images for pages ${missing.map((i) => i + 1).join(', ')}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const currentDoc = loadedDocumentRef.current;
|
||||
const totalPages = currentDoc?.pages?.length ?? 0;
|
||||
const dirtyPageIndices = dirtyPages
|
||||
.map((isDirty, index) => (isDirty ? index : -1))
|
||||
.filter((index) => index >= 0);
|
||||
|
||||
const canUseIncremental =
|
||||
isLazyMode &&
|
||||
cachedJobId &&
|
||||
dirtyPageIndices.length > 0 &&
|
||||
dirtyPageIndices.length < totalPages;
|
||||
|
||||
if (canUseIncremental) {
|
||||
await ensureImagesForPages(dirtyPageIndices);
|
||||
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { document, filename } = payload;
|
||||
const dirtyPageSet = new Set(dirtyPageIndices);
|
||||
const partialPages =
|
||||
document.pages?.filter((_, index) => dirtyPageSet.has(index)) ?? [];
|
||||
|
||||
const partialDocument: PdfJsonDocument = {
|
||||
metadata: document.metadata,
|
||||
xmpMetadata: document.xmpMetadata,
|
||||
fonts: document.fonts,
|
||||
lazyImages: true,
|
||||
pages: partialPages,
|
||||
};
|
||||
|
||||
const baseName = sanitizeBaseName(filename).replace(/-edited$/u, '');
|
||||
const expectedName = `${baseName || 'document'}.pdf`;
|
||||
const response = await apiClient.post(
|
||||
`/api/v1/convert/pdf/json/partial/${cachedJobId}?filename=${encodeURIComponent(expectedName)}`,
|
||||
partialDocument,
|
||||
{
|
||||
responseType: 'blob',
|
||||
},
|
||||
);
|
||||
|
||||
const contentDisposition = response.headers?.['content-disposition'] ?? '';
|
||||
const detectedName = getFilenameFromHeaders(contentDisposition);
|
||||
const downloadName = detectedName || expectedName;
|
||||
|
||||
downloadBlob(response.data, downloadName);
|
||||
|
||||
if (onComplete) {
|
||||
const pdfFile = new File([response.data], downloadName, { type: 'application/pdf' });
|
||||
onComplete([pdfFile]);
|
||||
}
|
||||
setErrorMessage(null);
|
||||
return;
|
||||
} catch (incrementalError) {
|
||||
console.warn(
|
||||
'[handleGeneratePdf] Incremental export failed, falling back to full export',
|
||||
incrementalError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLazyMode && totalPages > 0) {
|
||||
const allPageIndices = Array.from({ length: totalPages }, (_, index) => index);
|
||||
await ensureImagesForPages(allPageIndices);
|
||||
}
|
||||
|
||||
const payload = buildPayload();
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { document, filename } = payload;
|
||||
const serialized = JSON.stringify(document, null, 2);
|
||||
const jsonFile = new File([serialized], filename, { type: 'application/json' });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', jsonFile);
|
||||
const response = await apiClient.post(CONVERSION_ENDPOINTS['json-pdf'], formData, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
@@ -350,7 +793,16 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
} finally {
|
||||
setIsGeneratingPdf(false);
|
||||
}
|
||||
}, [buildPayload, onComplete, onError, t]);
|
||||
}, [
|
||||
buildPayload,
|
||||
cachedJobId,
|
||||
dirtyPages,
|
||||
isLazyMode,
|
||||
loadImagesForPage,
|
||||
onComplete,
|
||||
onError,
|
||||
t,
|
||||
]);
|
||||
|
||||
const viewData = useMemo<PdfJsonEditorViewData>(() => ({
|
||||
document: loadedDocument,
|
||||
@@ -363,6 +815,7 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
errorMessage,
|
||||
isGeneratingPdf,
|
||||
isConverting,
|
||||
conversionProgress,
|
||||
hasChanges,
|
||||
onLoadJson: handleLoadFile,
|
||||
onSelectPage: handleSelectPage,
|
||||
@@ -390,6 +843,7 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
hasDocument,
|
||||
isGeneratingPdf,
|
||||
isConverting,
|
||||
conversionProgress,
|
||||
loadedDocument,
|
||||
selectedPage,
|
||||
]);
|
||||
@@ -397,6 +851,13 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
const latestViewDataRef = useRef<PdfJsonEditorViewData>(viewData);
|
||||
latestViewDataRef.current = viewData;
|
||||
|
||||
// Trigger initial image loading in lazy mode
|
||||
useEffect(() => {
|
||||
if (isLazyMode && loadedDocument) {
|
||||
void loadImagesForPage(selectedPage);
|
||||
}
|
||||
}, [isLazyMode, loadedDocument, selectedPage, loadImagesForPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFiles.length === 0) {
|
||||
autoLoadKeyRef.current = null;
|
||||
@@ -433,11 +894,20 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
setCustomWorkbenchViewData(VIEW_ID, latestViewDataRef.current);
|
||||
|
||||
return () => {
|
||||
// Clear backend cache if we were using lazy loading
|
||||
if (cachedJobId) {
|
||||
console.log(`[PdfJsonEditor] Cleaning up cached document for jobId: ${cachedJobId}`);
|
||||
apiClient.post(`/api/v1/convert/pdf/json/clear-cache/${cachedJobId}`).catch((error) => {
|
||||
console.warn('[PdfJsonEditor] Failed to clear cache:', error);
|
||||
});
|
||||
}
|
||||
|
||||
clearCustomWorkbenchViewData(VIEW_ID);
|
||||
unregisterCustomWorkbenchView(VIEW_ID);
|
||||
setLeftPanelView('toolPicker');
|
||||
};
|
||||
}, [
|
||||
cachedJobId,
|
||||
clearCustomWorkbenchViewData,
|
||||
registerCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
|
||||
@@ -122,6 +122,23 @@ export interface PdfJsonDocument {
|
||||
xmpMetadata?: string | null;
|
||||
fonts?: PdfJsonFont[] | null;
|
||||
pages?: PdfJsonPage[] | null;
|
||||
lazyImages?: boolean | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonPageDimension {
|
||||
pageNumber?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
rotation?: number | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonDocumentMetadata {
|
||||
metadata?: PdfJsonMetadata | null;
|
||||
xmpMetadata?: string | null;
|
||||
fonts?: PdfJsonFont[] | null;
|
||||
pageDimensions?: PdfJsonPageDimension[] | null;
|
||||
formFields?: unknown[] | null;
|
||||
lazyImages?: boolean | null;
|
||||
}
|
||||
|
||||
export interface BoundingBox {
|
||||
@@ -153,6 +170,14 @@ export interface TextGroup {
|
||||
export const DEFAULT_PAGE_WIDTH = 612;
|
||||
export const DEFAULT_PAGE_HEIGHT = 792;
|
||||
|
||||
export interface ConversionProgress {
|
||||
percent: number;
|
||||
stage: string;
|
||||
message: string;
|
||||
current?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface PdfJsonEditorViewData {
|
||||
document: PdfJsonDocument | null;
|
||||
groupsByPage: TextGroup[][];
|
||||
@@ -164,6 +189,7 @@ export interface PdfJsonEditorViewData {
|
||||
errorMessage: string | null;
|
||||
isGeneratingPdf: boolean;
|
||||
isConverting: boolean;
|
||||
conversionProgress: ConversionProgress | null;
|
||||
hasChanges: boolean;
|
||||
onLoadJson: (file: File | null) => Promise<void> | void;
|
||||
onSelectPage: (pageIndex: number) => void;
|
||||
|
||||
Reference in New Issue
Block a user