cusor issue
This commit is contained in:
+23
-2
@@ -9,6 +9,7 @@ import { RedactPagesModal } from './components/RedactPagesModal';
|
||||
import { AboutModal } from './components/AboutModal';
|
||||
import { VersionHistoryModal } from './components/VersionHistoryModal';
|
||||
import { ExportPDFModal } from './components/ExportPDFModal';
|
||||
import { MergePDFModal } from './components/MergePDFModal';
|
||||
import { WatermarkModal, type WatermarkConfig } from './components/WatermarkModal';
|
||||
import { triggerPDFDownload } from './lib/pdfExport';
|
||||
|
||||
@@ -94,6 +95,9 @@ function App() {
|
||||
if (activeTool === 'create_pdf') {
|
||||
setCreatePdfModalOpen(true);
|
||||
setActiveTool('select');
|
||||
} else if (activeTool === 'merge_pdf') {
|
||||
setMergeModalOpen(true);
|
||||
setActiveTool('select');
|
||||
}
|
||||
}, [activeTool]);
|
||||
|
||||
@@ -120,10 +124,16 @@ function App() {
|
||||
const [protectModalState, setProtectModalState] = useState<ProtectModalState | null>(null);
|
||||
const [unlockModalState, setUnlockModalState] = useState<UnlockModalState | null>(null);
|
||||
const [compareModalOpen, setCompareModalOpen] = useState(false);
|
||||
const [mergeModalOpen, setMergeModalOpen] = useState(false);
|
||||
const [compareResult, setCompareResult] = useState<CompareResponse | null>(null);
|
||||
const [compareDocA, setCompareDocA] = useState<DocumentSummary | null>(null);
|
||||
const [compareDocB, setCompareDocB] = useState<DocumentSummary | null>(null);
|
||||
|
||||
const handleMergeCompleted = (docInfo: DocumentInfo) => {
|
||||
setDocuments((prev) => [...prev, docInfo]);
|
||||
openDocument(docInfo.id);
|
||||
};
|
||||
|
||||
const handleOpenCompareModal = () => {
|
||||
if (!activeDoc) return;
|
||||
setCompareDocA({
|
||||
@@ -845,7 +855,7 @@ function App() {
|
||||
userPassword: string;
|
||||
ownerPassword?: string;
|
||||
confirmPassword: string;
|
||||
permissions: any;
|
||||
permissions?: any;
|
||||
}) => {
|
||||
const isCreatorActive = activeTool === 'create_pdf' || createPdfModalOpen || selectedDocId === 'new-blank-creator' || !selectedDocId;
|
||||
try {
|
||||
@@ -870,7 +880,10 @@ function App() {
|
||||
throw new Error('No document available to protect');
|
||||
}
|
||||
|
||||
const updatedDoc = await gatewayService.protectDocument(targetDocId, payload);
|
||||
const updatedDoc = await gatewayService.protectDocument(targetDocId, {
|
||||
...payload,
|
||||
permissions: payload.permissions || {},
|
||||
});
|
||||
setDocuments((prev) => prev.map((d) => (d.id === targetDocId ? updatedDoc : d)));
|
||||
setActiveDoc(updatedDoc);
|
||||
setProtectModalState(null);
|
||||
@@ -1087,6 +1100,7 @@ function App() {
|
||||
}}
|
||||
onUnlock={() => selectedDocId && setUnlockModalState({ documentId: selectedDocId, filename: activeDoc?.filename || 'Document.pdf' })}
|
||||
onCompare={handleOpenCompareModal}
|
||||
onMergePDF={() => setMergeModalOpen(true)}
|
||||
isEncrypted={activeDoc?.permissions?.isEncrypted ?? false}
|
||||
canPrint={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canPrint')}
|
||||
canExport={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canCopy')}
|
||||
@@ -1463,6 +1477,13 @@ function App() {
|
||||
renderPageUrl={(docId, pageIdx, dpi) => gatewayService.getPageRenderUrl(docId, pageIdx, dpi)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MergePDFModal
|
||||
isOpen={mergeModalOpen}
|
||||
onClose={() => setMergeModalOpen(false)}
|
||||
onMergeComplete={handleMergeCompleted}
|
||||
apiBaseUrl={import.meta.env.VITE_GATEWAY_URL || 'http://localhost:8000'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
import { SpinnerIcon, UploadIcon, DownloadIcon } from './icons';
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
file: File;
|
||||
pagesMode: 'all' | 'custom';
|
||||
customPages: string;
|
||||
pageCount?: number;
|
||||
}
|
||||
|
||||
interface MergePDFModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onMergeComplete: (docInfo: any) => void;
|
||||
apiBaseUrl?: string;
|
||||
}
|
||||
|
||||
export const MergePDFModal: React.FC<MergePDFModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onMergeComplete,
|
||||
apiBaseUrl = 'http://localhost:8000',
|
||||
}) => {
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [outputFilename, setOutputFilename] = useState<string>('merged_document.pdf');
|
||||
const [isMerging, setIsMerging] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mergedResult, setMergedResult] = useState<any | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAddFiles = (selectedFiles: FileList | null) => {
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
setError(null);
|
||||
const newItems: FileItem[] = Array.from(selectedFiles).map((file) => ({
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
file,
|
||||
pagesMode: 'all',
|
||||
customPages: '',
|
||||
}));
|
||||
setFiles((prev) => [...prev, ...newItems]);
|
||||
};
|
||||
|
||||
const handleMoveUp = (index: number) => {
|
||||
if (index <= 0) return;
|
||||
setFiles((prev) => {
|
||||
const updated = [...prev];
|
||||
const temp = updated[index - 1];
|
||||
updated[index - 1] = updated[index];
|
||||
updated[index] = temp;
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoveDown = (index: number) => {
|
||||
if (index >= files.length - 1) return;
|
||||
setFiles((prev) => {
|
||||
const updated = [...prev];
|
||||
const temp = updated[index + 1];
|
||||
updated[index + 1] = updated[index];
|
||||
updated[index] = temp;
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFile = (index: number) => {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handlePagesModeChange = (index: number, mode: 'all' | 'custom') => {
|
||||
setFiles((prev) =>
|
||||
prev.map((item, i) => (i === index ? { ...item, pagesMode: mode } : item))
|
||||
);
|
||||
};
|
||||
|
||||
const handleCustomPagesChange = (index: number, val: string) => {
|
||||
setFiles((prev) =>
|
||||
prev.map((item, i) => (i === index ? { ...item, customPages: val } : item))
|
||||
);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFiles([]);
|
||||
setOutputFilename('merged_document.pdf');
|
||||
setError(null);
|
||||
setMergedResult(null);
|
||||
setIsMerging(false);
|
||||
};
|
||||
|
||||
const handlePerformMerge = async () => {
|
||||
if (files.length === 0) {
|
||||
setError('Please add at least one PDF file to merge.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsMerging(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
const manifest = files.map((item, idx) => ({
|
||||
fileIndex: idx,
|
||||
pages: item.pagesMode === 'all' ? 'all' : item.customPages || 'all',
|
||||
}));
|
||||
|
||||
files.forEach((item) => {
|
||||
formData.append('files', item.file);
|
||||
});
|
||||
formData.append('manifest', JSON.stringify(manifest));
|
||||
formData.append('output_filename', outputFilename || 'merged_document.pdf');
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/documents/merge`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}));
|
||||
throw new Error(errData.detail || `Merge failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const docInfo = await response.json();
|
||||
setMergedResult(docInfo);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to merge PDF files. Please try again.');
|
||||
} finally {
|
||||
setIsMerging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadMerged = () => {
|
||||
if (!mergedResult) return;
|
||||
const downloadUrl = `${apiBaseUrl}/documents/${mergedResult.id}/export`;
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = mergedResult.filename || 'merged_document.pdf';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
const handleOpenInEditor = () => {
|
||||
if (mergedResult) {
|
||||
onMergeComplete(mergedResult);
|
||||
onClose();
|
||||
handleReset();
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||
<div className="flex flex-col w-full max-w-2xl max-h-[90vh] bg-bg-primary rounded-xl border border-border-primary shadow-2xl overflow-hidden text-text-primary">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border-primary bg-bg-secondary">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 7v10M12 4v16M16 7v10M4 11h16M4 15h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold tracking-tight">Merge PDF Files</h2>
|
||||
<p className="text-xs text-text-secondary">Combine multiple PDFs into a single, ordered document</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { onClose(); handleReset(); }}
|
||||
className="text-text-secondary hover:text-text-primary p-1 rounded-md hover:bg-bg-tertiary transition-colors"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Body */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
||||
{mergedResult ? (
|
||||
/* Success View */
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center space-y-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-emerald-500/10 text-emerald-500">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-extrabold text-text-primary">PDFs Merged Successfully!</h3>
|
||||
<p className="text-sm text-text-secondary mt-1">
|
||||
Merged {files.length} document{files.length > 1 ? 's' : ''} into <span className="font-semibold text-text-primary">{mergedResult.filename}</span> ({mergedResult.totalPages} total pages).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 pt-4">
|
||||
<CustomButton variant="outline" onClick={handleDownloadMerged} className="flex items-center gap-2 px-4 py-2">
|
||||
<DownloadIcon size={16} /> Download Merged PDF
|
||||
</CustomButton>
|
||||
<CustomButton variant="primary" onClick={handleOpenInEditor} className="flex items-center gap-2 px-5 py-2">
|
||||
Open in PDF Editor
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="text-xs text-brand-primary hover:underline pt-3"
|
||||
>
|
||||
Merge more PDF files
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Upload & Configuration Form */
|
||||
<>
|
||||
{error && (
|
||||
<div className="p-3.5 rounded-lg bg-red-500/10 border border-red-500/20 text-red-500 text-xs font-medium flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700 ml-2">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Dropzone */}
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddFiles(e.dataTransfer.files);
|
||||
}}
|
||||
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border-primary hover:border-brand-primary rounded-xl cursor-pointer bg-bg-secondary/40 hover:bg-bg-secondary transition-all text-center group"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleAddFiles(e.target.files)}
|
||||
/>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-brand-primary/10 text-brand-primary group-hover:scale-110 transition-transform">
|
||||
<UploadIcon size={22} />
|
||||
</div>
|
||||
<span className="mt-3 text-sm font-bold text-text-primary">Click or drop PDF files here</span>
|
||||
<span className="text-xs text-text-secondary mt-0.5">Select multiple PDF files to combine</span>
|
||||
</div>
|
||||
|
||||
{/* File Queue List */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-text-tertiary">
|
||||
Merge Sequence ({files.length} {files.length === 1 ? 'file' : 'files'})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setFiles([])}
|
||||
className="text-xs text-red-400 hover:text-red-300 font-medium"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-[320px] overflow-y-auto pr-1">
|
||||
{files.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 rounded-lg border border-border-primary bg-bg-secondary/70 hover:bg-bg-secondary transition-colors"
|
||||
>
|
||||
{/* Left Info */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
disabled={index === 0}
|
||||
onClick={() => handleMoveUp(index)}
|
||||
className="p-1 rounded text-text-secondary hover:text-text-primary hover:bg-bg-tertiary disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
title="Move Up"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
disabled={index === files.length - 1}
|
||||
onClick={() => handleMoveDown(index)}
|
||||
className="p-1 rounded text-text-secondary hover:text-text-primary hover:bg-bg-tertiary disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
title="Move Down"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-bg-tertiary text-xs font-bold text-text-secondary">
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-bold text-text-primary truncate" title={item.file.name}>
|
||||
{item.file.name}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-secondary">
|
||||
{formatFileSize(item.file.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Page Controls */}
|
||||
<div className="flex items-center gap-2 self-end sm:self-center shrink-0">
|
||||
<div className="flex items-center gap-1.5 bg-bg-tertiary/60 p-1 rounded-md border border-border-primary">
|
||||
<button
|
||||
onClick={() => handlePagesModeChange(index, 'all')}
|
||||
className={`px-2 py-0.5 text-[11px] font-semibold rounded ${
|
||||
item.pagesMode === 'all'
|
||||
? 'bg-brand-primary text-white shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePagesModeChange(index, 'custom')}
|
||||
className={`px-2 py-0.5 text-[11px] font-semibold rounded ${
|
||||
item.pagesMode === 'custom'
|
||||
? 'bg-brand-primary text-white shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
Pages
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{item.pagesMode === 'custom' && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 1-3, 5"
|
||||
value={item.customPages}
|
||||
onChange={(e) => handleCustomPagesChange(index, e.target.value)}
|
||||
className="w-24 px-2 py-1 text-xs rounded border border-border-primary bg-bg-primary text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-brand-primary"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleRemoveFile(index)}
|
||||
className="p-1 text-text-secondary hover:text-red-500 rounded hover:bg-bg-tertiary transition-colors"
|
||||
title="Remove File"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output Filename Field */}
|
||||
<div className="pt-2">
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-text-tertiary mb-1.5">
|
||||
Output Filename
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={outputFilename}
|
||||
onChange={(e) => setOutputFilename(e.target.value)}
|
||||
placeholder="merged_document.pdf"
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-border-primary bg-bg-secondary text-text-primary focus:outline-none focus:border-brand-primary"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!mergedResult && (
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-border-primary bg-bg-secondary">
|
||||
<CustomButton
|
||||
variant="outline"
|
||||
onClick={() => { onClose(); handleReset(); }}
|
||||
disabled={isMerging}
|
||||
>
|
||||
Cancel
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
onClick={handlePerformMerge}
|
||||
disabled={files.length === 0 || isMerging}
|
||||
className="flex items-center gap-2 min-w-[120px] justify-center"
|
||||
>
|
||||
{isMerging ? (
|
||||
<>
|
||||
<SpinnerIcon size={16} /> Merging...
|
||||
</>
|
||||
) : (
|
||||
`Merge ${files.length > 0 ? `(${files.length})` : ''} PDFs`
|
||||
)}
|
||||
</CustomButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -34,6 +34,17 @@ const TOOLS: (ToolDef | 'divider')[] = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'merge_pdf',
|
||||
label: 'Merge PDFs',
|
||||
shortLabel: 'Merge',
|
||||
shortcut: 'G',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 7v10M12 4v16M16 7v10M4 11h16M4 15h16" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{ id: 'comment', label: 'Comment', shortcut: 'C', icon: <CommentIcon /> },
|
||||
{ id: 'textbox', label: 'Text box', shortLabel: 'Text', shortcut: 'T', icon: <TextBoxIcon /> },
|
||||
{
|
||||
|
||||
@@ -82,6 +82,14 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
merge_pdf: {
|
||||
label: 'Merge PDFs',
|
||||
icon: (
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 7v10M12 4v16M16 7v10M4 11h16M4 15h16" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const Hint: React.FC<{ children: React.ReactNode; tone?: 'normal' | 'warn' }> = ({ children, tone = 'normal' }) => (
|
||||
|
||||
@@ -28,6 +28,7 @@ interface TopBarProps {
|
||||
onProtect?: () => void;
|
||||
onUnlock?: () => void;
|
||||
onCompare?: () => void;
|
||||
onMergePDF?: () => void;
|
||||
isEncrypted?: boolean;
|
||||
onUpload: (file: File) => void;
|
||||
onNewBlankPDF?: () => void;
|
||||
@@ -45,7 +46,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, onProtect, onUnlock, onCompare, isEncrypted, onUpload, onNewBlankPDF, onSave,
|
||||
isSaving, isDirtySaved, onRotate, onExport, onPrint, onProtect, onUnlock, onCompare, onMergePDF, isEncrypted, onUpload, onNewBlankPDF, onSave,
|
||||
canPrint = true, canExport = true, canAssemble = true,
|
||||
}) => {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -82,6 +83,7 @@ export const TopBar: React.FC<TopBarProps> = ({
|
||||
<div className="flex flex-col text-[13px]">
|
||||
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="12" y1="18" x2="12" y2="12" /><line x1="9" y1="15" x2="15" y2="15" /></svg>} onClick={() => onNewBlankPDF?.()}>New Blank PDF…</MenuItem>
|
||||
<MenuItem icon={<UploadIcon size={16} />} onClick={() => fileRef.current?.click()}>Open PDF…</MenuItem>
|
||||
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M8 7v10M12 4v16M16 7v10M4 11h16M4 15h16"/></svg>} onClick={() => onMergePDF?.()}>Merge PDFs…</MenuItem>
|
||||
<MenuItem icon={<DownloadIcon size={16} />} onClick={onExport} disabled={!documentName || !canExport}>Export / Download</MenuItem>
|
||||
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg>} onClick={onPrint} disabled={!documentName || !canPrint}>Print</MenuItem>
|
||||
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/></svg>} onClick={() => onCompare?.()} disabled={!documentName}>Compare PDFs…</MenuItem>
|
||||
|
||||
@@ -109,8 +109,9 @@ const EditableParagraphBlock: React.FC<{
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelectBlock(block.id, firstRun.id)}
|
||||
className={`relative rounded px-2 py-1 transition-all ${isSelected ? 'bg-blue-50/40 ring-1 ring-brand-primary/50' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
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'
|
||||
}`}
|
||||
style={{
|
||||
textAlign: block.alignment || 'left',
|
||||
marginTop: `${block.spaceBefore || 0}px`,
|
||||
@@ -140,6 +141,9 @@ const EditableParagraphBlock: React.FC<{
|
||||
color: firstRun.color || '#0f172a',
|
||||
backgroundColor: firstRun.highlightColor || 'transparent',
|
||||
lineHeight: block.lineSpacing || 1.25,
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -151,7 +155,7 @@ const EditableParagraphBlock: React.FC<{
|
||||
e.stopPropagation();
|
||||
onRemoveBlock(block.id);
|
||||
}}
|
||||
className="absolute -right-6 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-100 text-red-600 hover:bg-red-200 text-[10px]"
|
||||
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>
|
||||
@@ -800,21 +804,26 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
const activePoints = activeDrawing?.pageIdx === pageIdx ? activeDrawing.points : [];
|
||||
const activeD = activePoints.reduce((acc, pt, i) => (i === 0 ? `M ${pt.x} ${pt.y}` : `${acc} L ${pt.x} ${pt.y}`), '');
|
||||
|
||||
const usableContentHeightPx = pageH - padTop - padBottom - (header.enabled ? 36 * PT_TO_PX : 0) - (footer.enabled ? 36 * PT_TO_PX : 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`page-${page.pageNumber}`}
|
||||
onMouseDown={(e) => handleMouseDownPage(e, pageIdx)}
|
||||
onMouseMove={(e) => handleMouseMovePage(e, pageIdx)}
|
||||
className={`relative bg-white shadow-xl transition-shadow border border-slate-200 flex flex-col ${
|
||||
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`,
|
||||
minHeight: `${pageH}px`,
|
||||
maxHeight: `${pageH}px`,
|
||||
paddingTop: `${padTop}px`,
|
||||
paddingBottom: `${padBottom}px`,
|
||||
paddingLeft: `${padLeft}px`,
|
||||
paddingRight: `${padRight}px`,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{/* Ink Drawing Overlay */}
|
||||
@@ -840,7 +849,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
|
||||
{/* Page Content Blocks */}
|
||||
<div
|
||||
className="flex flex-col gap-2 flex-1 overflow-visible relative"
|
||||
className="flex flex-col gap-2 flex-1 relative overflow-hidden pb-10"
|
||||
onClick={(e) => {
|
||||
// Only trigger if the click landed directly on this container (the empty area below blocks)
|
||||
if (e.target === e.currentTarget) {
|
||||
|
||||
@@ -18,7 +18,7 @@ export class PaginationEngine {
|
||||
const pages: PageLayout[] = [];
|
||||
|
||||
// Usable height per page in screen pixels (96 DPI)
|
||||
const headerFooterHeightPt = (doc.header.enabled ? 36 : 0) + (doc.footer.enabled ? 36 : 0);
|
||||
const headerFooterHeightPt = (doc.header.enabled ? 36 : 0) + (doc.footer.enabled ? 36 : 30);
|
||||
const usableHeightPx = (settings.heightPt - settings.marginTopPt - settings.marginBottomPt - headerFooterHeightPt) * PT_TO_PX;
|
||||
|
||||
let currentPageBlocks: DocumentBlock[] = [];
|
||||
@@ -27,10 +27,12 @@ export class PaginationEngine {
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'page-break') {
|
||||
// Explicit manual page break — always start a new page
|
||||
pages.push({ pageNumber: pageNum++, blocks: currentPageBlocks });
|
||||
currentPageBlocks = [];
|
||||
currentHeightPx = 0;
|
||||
// Explicit manual page break — start new page if current page has content
|
||||
if (currentPageBlocks.length > 0) {
|
||||
pages.push({ pageNumber: pageNum++, blocks: currentPageBlocks });
|
||||
currentPageBlocks = [];
|
||||
currentHeightPx = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -59,28 +61,38 @@ export class PaginationEngine {
|
||||
|
||||
if (block.type === 'table') {
|
||||
const rowCount = block.rows.length;
|
||||
return Math.max(50, rowCount * (30 * PT_TO_PX) + (12 * PT_TO_PX));
|
||||
return Math.max(50, rowCount * (30 * PT_TO_PX) + (16 * PT_TO_PX));
|
||||
}
|
||||
|
||||
if (block.type === 'image') {
|
||||
return Math.min(block.height + (20 * PT_TO_PX), 500 * PT_TO_PX);
|
||||
return Math.min(block.height + (16 * PT_TO_PX), 500 * PT_TO_PX);
|
||||
}
|
||||
|
||||
if (block.type === 'paragraph' || block.type === 'heading' || block.type === 'list-item' || block.type === 'quote') {
|
||||
const fullText = block.runs.map((r) => r.text).join('');
|
||||
if (!fullText) return 24 * PT_TO_PX; // Empty paragraph height
|
||||
|
||||
const fontSizePt = block.runs[0]?.fontSize || 12;
|
||||
const fontSizePx = fontSizePt * PT_TO_PX;
|
||||
const lineSpacing = block.lineSpacing || 1.15;
|
||||
const avgCharWidthPx = fontSizePx * 0.55;
|
||||
const lineSpacing = block.lineSpacing || 1.25;
|
||||
const avgCharWidthPx = fontSizePx * 0.52;
|
||||
const charsPerLine = Math.max(1, Math.floor(usableWidthPx / avgCharWidthPx));
|
||||
|
||||
const lineCount = Math.ceil(fullText.length / charsPerLine);
|
||||
const spaceBeforePx = (block.spaceBefore || 0) * PT_TO_PX;
|
||||
const spaceAfterPx = (block.spaceAfter || 6) * PT_TO_PX;
|
||||
const paragraphs = fullText.split('\n');
|
||||
let totalLines = 0;
|
||||
for (const p of paragraphs) {
|
||||
if (!p) {
|
||||
totalLines += 1;
|
||||
} else {
|
||||
totalLines += Math.max(1, Math.ceil(p.length / charsPerLine));
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(20 * PT_TO_PX, lineCount * (fontSizePx * lineSpacing) + spaceBeforePx + spaceAfterPx);
|
||||
const paddingPx = 4;
|
||||
const spaceBeforePx = (block.spaceBefore || 0) * PT_TO_PX;
|
||||
const spaceAfterPx = (block.spaceAfter || 4) * PT_TO_PX;
|
||||
const flexGapPx = 4;
|
||||
|
||||
const contentHeight = totalLines * (fontSizePx * lineSpacing) + paddingPx + spaceBeforePx + spaceAfterPx + flexGapPx;
|
||||
return Math.max(24 * PT_TO_PX, contentHeight);
|
||||
}
|
||||
|
||||
return 24 * PT_TO_PX;
|
||||
|
||||
@@ -15,7 +15,8 @@ export type ToolId =
|
||||
| 'squiggly'
|
||||
| 'stream_edit'
|
||||
| 'create_pdf'
|
||||
| 'watermark';
|
||||
| 'watermark'
|
||||
| 'merge_pdf';
|
||||
|
||||
export interface ToolSettings {
|
||||
highlightColor: string;
|
||||
|
||||
@@ -541,7 +541,7 @@ function layoutFromOrigLines(
|
||||
|
||||
export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
documentId, pageIndex, para, pushColumnLeft, leadingOverride, alignOverride, columnLeftOverride, columnRightOverride,
|
||||
caretClick: _caretClick, heightPts, zoom, pageWidthPx, pageHeightPx, onCommit, onCommitPreview, onOverflowPreview, onOverflowCaret, onCancel,
|
||||
caretClick, heightPts, zoom, pageWidthPx, pageHeightPx, onCommit, onCommitPreview, onOverflowPreview, onOverflowCaret, onCancel,
|
||||
}) => {
|
||||
const layout = useMemo(() => computeLayout(para), [para]);
|
||||
const leading = leadingOverride ?? layout.leading;
|
||||
@@ -755,6 +755,20 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
return lineStarts(lay, fullText)[li] + offset;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where the caret should land the moment the editor opens. `caretClick` carries the
|
||||
* viewport coordinates of the click that opened this paragraph for editing (plumbed
|
||||
* down from TextEditLayer) — without it the caret always fell back to the end of the
|
||||
* paragraph regardless of where the user actually clicked, which is the "cursor jumps
|
||||
* to the wrong place" bug. Falls back to end-of-text for programmatic opens that have
|
||||
* no originating click (e.g. reflowing into a bullet sub-paragraph).
|
||||
*/
|
||||
const initialCaretIndexFor = (el: HTMLElement, lay: ReflowLayout): number => {
|
||||
const fullText = el.textContent ?? '';
|
||||
if (caretClick) return globalFromPoint(caretClick.x, caretClick.y, lay, fullText);
|
||||
return fullText.length;
|
||||
};
|
||||
|
||||
const positionCaret = () => {
|
||||
const el = editRef.current, lay = engineLayoutRef.current;
|
||||
if (!el || !lay) return;
|
||||
@@ -792,8 +806,9 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
engineLayoutRef.current = origLay;
|
||||
if (!initialCaretApplied.current) {
|
||||
initialCaretApplied.current = true;
|
||||
const fullLen = (el.textContent ?? '').length;
|
||||
setGlobalCaretOffset(el, fullLen);
|
||||
const target = initialCaretIndexFor(el, origLay);
|
||||
caretIndexRef.current = target;
|
||||
setGlobalCaretOffset(el, target);
|
||||
}
|
||||
positionCaret();
|
||||
setHasPreview(true);
|
||||
@@ -805,6 +820,11 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
const dpr = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1;
|
||||
const dpi = Math.round(96 * zoom * dpr);
|
||||
const runs = extractFlatRuns(el, dominantFid, domSize, domColor);
|
||||
// Snapshot of the editor text this preview request is based on. If the user keeps
|
||||
// typing/deleting while this request is in flight, the response below will lag behind
|
||||
// and must not be allowed to move the caret to a position computed against stale text
|
||||
// (that's the "cursor jumps to the wrong place" / jittery caret while typing symptom).
|
||||
const capturedText = el.textContent ?? '';
|
||||
const data = buildReflowData(runs);
|
||||
const yTopPt = bandTop / zoom;
|
||||
const operations = [{
|
||||
@@ -881,6 +901,13 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
})));
|
||||
}
|
||||
|
||||
// The editor text has moved on since this request was sent (user kept typing/deleting
|
||||
// while it was in flight). Drop this stale response rather than repaint the canvas or
|
||||
// reposition the caret from it — the in-flight/queued render loop (scheduleRender's
|
||||
// do/while) will immediately re-run against the current text and catch up. The fast,
|
||||
// synchronous optimistic caret set in onInput keeps the cursor smooth in the meantime.
|
||||
if ((editRef.current?.textContent ?? '') !== capturedText) return;
|
||||
|
||||
console.log('[STAGE_7_PREVIEW_DRAW]', {
|
||||
fontSize: domSize,
|
||||
fontPx,
|
||||
@@ -932,10 +959,11 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
}
|
||||
}
|
||||
if (!hasPreview) setHasPreview(true);
|
||||
if (!initialCaretApplied.current) {
|
||||
if (!initialCaretApplied.current && lay) {
|
||||
initialCaretApplied.current = true;
|
||||
const fullLen = (el.textContent ?? '').length;
|
||||
setGlobalCaretOffset(el, fullLen);
|
||||
const target = initialCaretIndexFor(el, lay);
|
||||
caretIndexRef.current = target;
|
||||
setGlobalCaretOffset(el, target);
|
||||
}
|
||||
positionCaret();
|
||||
};
|
||||
@@ -1008,9 +1036,12 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
initialTextRef.current = normalizeForCompare(domTextWithBreaks(el));
|
||||
// Caret layout after DOM seed is ready (documentId effect may have run first on an empty editor).
|
||||
if (!editedRef.current) {
|
||||
engineLayoutRef.current = layoutFromOrigLines(layout.origLines, columnLeft, pageIndex, domSize);
|
||||
const origLay = layoutFromOrigLines(layout.origLines, columnLeft, pageIndex, domSize);
|
||||
engineLayoutRef.current = origLay;
|
||||
initialCaretApplied.current = true;
|
||||
setGlobalCaretOffset(el, (el.textContent ?? '').length);
|
||||
const target = initialCaretIndexFor(el, origLay);
|
||||
caretIndexRef.current = target;
|
||||
setGlobalCaretOffset(el, target);
|
||||
positionCaret();
|
||||
setHasPreview(true);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import re
|
||||
import httpx
|
||||
from urllib.parse import urlparse
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.document import (
|
||||
@@ -371,4 +371,81 @@ async def unlock_document(document_id: str) -> DocumentInfoResponse:
|
||||
if not updated_info:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found during update")
|
||||
|
||||
return make_document_response(updated_info)
|
||||
return make_document_response(updated_info)
|
||||
|
||||
|
||||
import json
|
||||
from app.services.pdf_merge import merge_pdf_files
|
||||
|
||||
|
||||
@router.post("/merge", response_model=DocumentInfoResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def merge_documents(
|
||||
files: list[UploadFile] = File(...),
|
||||
manifest: str = Form(default="[]"),
|
||||
output_filename: str = Form(default="merged.pdf"),
|
||||
) -> DocumentInfoResponse:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
if not files or len(files) < 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="At least one PDF file must be provided for merging.",
|
||||
)
|
||||
|
||||
files_bytes: list[bytes] = []
|
||||
for file in files:
|
||||
data = await file.read()
|
||||
fn_lower = (file.filename or "").lower()
|
||||
image_exts = [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff", ".gif"]
|
||||
is_img = any(fn_lower.endswith(ext) for ext in image_exts) or \
|
||||
data.startswith(b"\x89PNG") or \
|
||||
data.startswith(b"\xff\xd8") or \
|
||||
data.startswith(b"RIFF") or \
|
||||
data.startswith(b"BM")
|
||||
if is_img:
|
||||
try:
|
||||
data = _convert_image_to_pdf_bytes(data)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to convert image {file.filename} to PDF: {e!s}",
|
||||
)
|
||||
files_bytes.append(data)
|
||||
|
||||
parsed_manifest: list[dict] = []
|
||||
if manifest and manifest.strip():
|
||||
try:
|
||||
parsed = json.loads(manifest)
|
||||
if isinstance(parsed, list):
|
||||
parsed_manifest = parsed
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not parsed_manifest:
|
||||
parsed_manifest = [{"fileIndex": idx, "pages": "all"} for idx in range(len(files))]
|
||||
|
||||
try:
|
||||
merged_bytes = merge_pdf_files(files_bytes, parsed_manifest)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to merge PDF files: {e!s}",
|
||||
)
|
||||
|
||||
if not output_filename or not output_filename.lower().endswith(".pdf"):
|
||||
output_filename = f"{output_filename or 'merged'}.pdf"
|
||||
|
||||
try:
|
||||
pdfengine = engine.require()
|
||||
doc = pdfengine.PdfDocument.load_from_memory(merged_bytes, "")
|
||||
info = document_store.add_document(output_filename, merged_bytes, doc)
|
||||
return make_document_response(info)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to load merged PDF into engine: {e!s}",
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MergeFileItem(BaseModel):
|
||||
fileIndex: int = Field(..., description="0-based index of the uploaded file")
|
||||
pages: str | None = Field(default="all", description="Page range e.g. 'all', '1-3, 5', '2'")
|
||||
|
||||
|
||||
class MergeRequestManifest(BaseModel):
|
||||
outputFilename: str = Field(default="merged.pdf", description="Output filename for merged PDF")
|
||||
items: list[MergeFileItem] = Field(default_factory=list, description="Ordered list of files and pages to merge")
|
||||
@@ -0,0 +1,79 @@
|
||||
import io
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
|
||||
def parse_page_selection(page_spec: str | None, total_pages: int) -> list[int]:
|
||||
"""
|
||||
Parses a page selection string (1-indexed, user-facing) into a list of 0-indexed page indices.
|
||||
Supports formats like: 'all', '', '1-3, 5', '2', '4-1'.
|
||||
Out-of-bound page numbers are ignored.
|
||||
"""
|
||||
if not page_spec or page_spec.strip().lower() in ("all", "*", ""):
|
||||
return list(range(total_pages))
|
||||
|
||||
result_indices: list[int] = []
|
||||
parts = page_spec.split(",")
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if "-" in part:
|
||||
subparts = part.split("-", 1)
|
||||
try:
|
||||
start = int(subparts[0].strip())
|
||||
end = int(subparts[1].strip())
|
||||
if start <= end:
|
||||
step = 1
|
||||
else:
|
||||
step = -1
|
||||
for p in range(start, end + step, step):
|
||||
idx = p - 1
|
||||
if 0 <= idx < total_pages:
|
||||
result_indices.append(idx)
|
||||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
try:
|
||||
p = int(part)
|
||||
idx = p - 1
|
||||
if 0 <= idx < total_pages:
|
||||
result_indices.append(idx)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return result_indices
|
||||
|
||||
|
||||
def merge_pdf_files(
|
||||
files_data: list[bytes],
|
||||
items: list[dict],
|
||||
) -> bytes:
|
||||
"""
|
||||
Merges multiple PDF byte buffers according to items configuration.
|
||||
Each item dict should contain:
|
||||
- 'fileIndex': index in files_data
|
||||
- 'pages': page selection string e.g. 'all' or '1-3, 5'
|
||||
"""
|
||||
writer = PdfWriter()
|
||||
|
||||
for item in items:
|
||||
f_idx = item.get("fileIndex", 0)
|
||||
pages_spec = item.get("pages", "all")
|
||||
|
||||
if f_idx < 0 or f_idx >= len(files_data):
|
||||
continue
|
||||
|
||||
pdf_bytes = files_data[f_idx]
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(pdf_bytes))
|
||||
total_pages = len(reader.pages)
|
||||
target_indices = parse_page_selection(pages_spec, total_pages)
|
||||
|
||||
for page_idx in target_indices:
|
||||
writer.add_page(reader.pages[page_idx])
|
||||
except Exception as err:
|
||||
raise ValueError(f"Failed to process PDF at index {f_idx}: {err!s}") from err
|
||||
|
||||
output_stream = io.BytesIO()
|
||||
writer.write(output_stream)
|
||||
return output_stream.getvalue()
|
||||
@@ -16,6 +16,9 @@ dependencies = [
|
||||
"python-multipart==0.0.19",
|
||||
"pillow==10.4.0",
|
||||
"httpx==0.28.1",
|
||||
"pypdf==5.3.0",
|
||||
"rapidocr-onnxruntime==1.2.3",
|
||||
"onnxruntime==1.28.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import io
|
||||
import json
|
||||
import pytest
|
||||
from pypdf import PdfWriter, PdfReader
|
||||
from fastapi.testclient import TestClient
|
||||
from app.services.pdf_merge import parse_page_selection, merge_pdf_files
|
||||
from app.services import engine
|
||||
|
||||
|
||||
def _create_sample_pdf(page_count: int = 1) -> bytes:
|
||||
writer = PdfWriter()
|
||||
for _ in range(page_count):
|
||||
writer.add_blank_page(width=612, height=792)
|
||||
stream = io.BytesIO()
|
||||
writer.write(stream)
|
||||
return stream.getvalue()
|
||||
|
||||
|
||||
def test_parse_page_selection():
|
||||
assert parse_page_selection("all", 5) == [0, 1, 2, 3, 4]
|
||||
assert parse_page_selection("", 3) == [0, 1, 2]
|
||||
assert parse_page_selection("1, 3", 5) == [0, 2]
|
||||
assert parse_page_selection("1-3", 5) == [0, 1, 2]
|
||||
assert parse_page_selection("1-2, 4-5", 5) == [0, 1, 3, 4]
|
||||
assert parse_page_selection("10", 3) == [] # out of bounds
|
||||
|
||||
|
||||
def test_merge_pdf_files_service():
|
||||
pdf1 = _create_sample_pdf(2)
|
||||
pdf2 = _create_sample_pdf(3)
|
||||
|
||||
items = [
|
||||
{"fileIndex": 0, "pages": "1-2"},
|
||||
{"fileIndex": 1, "pages": "1, 3"},
|
||||
]
|
||||
|
||||
merged_bytes = merge_pdf_files([pdf1, pdf2], items)
|
||||
reader = PdfReader(io.BytesIO(merged_bytes))
|
||||
assert len(reader.pages) == 4
|
||||
|
||||
|
||||
def test_merge_documents_api_endpoint(client: TestClient):
|
||||
if not engine.is_available():
|
||||
pytest.skip("PDF Engine binary binding not available in test environment.")
|
||||
|
||||
pdf1 = _create_sample_pdf(2)
|
||||
pdf2 = _create_sample_pdf(1)
|
||||
|
||||
manifest = json.dumps([
|
||||
{"fileIndex": 0, "pages": "1"},
|
||||
{"fileIndex": 1, "pages": "all"},
|
||||
])
|
||||
|
||||
files = [
|
||||
("files", ("doc1.pdf", pdf1, "application/pdf")),
|
||||
("files", ("doc2.pdf", pdf2, "application/pdf")),
|
||||
]
|
||||
data = {
|
||||
"manifest": manifest,
|
||||
"output_filename": "final_merged.pdf",
|
||||
}
|
||||
|
||||
res = client.post("/documents/merge", files=files, data=data)
|
||||
assert res.status_code == 201
|
||||
payload = res.json()
|
||||
assert payload["filename"] == "final_merged.pdf"
|
||||
assert payload["totalPages"] == 2
|
||||
assert "id" in payload
|
||||
Reference in New Issue
Block a user