Merge branch 'fix_issue' of https://gitea.maskantech.in/gitea_admin/pdf into ribai

This commit is contained in:
momorew
2026-08-22 16:08:43 +05:30
32 changed files with 1605 additions and 852 deletions
+1
View File
@@ -16,6 +16,7 @@
**/.idea
**/coverage
**/tmp
corpus/
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
-162
View File
@@ -1,162 +0,0 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Rule R2 — PDFium boundary check
run: bash scripts/check_pdfium_boundary.sh
- name: Install clang-format (pinned)
run: pipx install clang-format==22.1.5
- name: clang-format
run: |
clang-format --version
find engine \( -name '*.cpp' -o -name '*.cc' -o -name '*.h' -o -name '*.hpp' \) \
-print0 | xargs -0 clang-format --dry-run --Werror
build:
needs: lint
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
preset: linux-debug
- os: macos-latest
preset: macos-debug
experimental: true
- os: windows-latest
preset: windows-debug
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental == true }}
env:
VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/.vcpkg-cache
steps:
- uses: actions/checkout@v4
- name: Install Ninja
uses: seanmiddleditch/gha-setup-ninja@v5
- name: Set up MSVC environment
if: runner.os == 'Windows'
uses: ilammy/msvc-dev-cmd@v1
- name: Locate vcpkg
shell: bash
run: |
echo "VCPKG_ROOT=$VCPKG_INSTALLATION_ROOT" >> "$GITHUB_ENV"
git -C "$VCPKG_INSTALLATION_ROOT" fetch --quiet origin || true
- name: Create vcpkg binary cache dir
shell: bash
run: mkdir -p "$VCPKG_DEFAULT_BINARY_CACHE"
- name: Cache vcpkg artifacts
uses: actions/cache@v4
with:
path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }}
key: vcpkg-${{ matrix.os }}-${{ hashFiles('vcpkg.json') }}
restore-keys: vcpkg-${{ matrix.os }}-
- name: Pin vcpkg dependency baseline
shell: bash
run: |
if ! grep -q '"builtin-baseline"' vcpkg.json; then
"$VCPKG_ROOT/vcpkg" x-update-baseline --add-initial-baseline
fi
- name: Configure
run: cmake --preset ${{ matrix.preset }}
- name: Build
run: cmake --build --preset ${{ matrix.preset }}
- name: Test
run: ctest --preset ${{ matrix.preset }}
gateway:
needs: lint
runs-on: ubuntu-latest
defaults:
run:
working-directory: gateway
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: gateway/pyproject.toml
- name: Install gateway (editable, with dev extras)
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
- name: Ruff — lint
run: python -m ruff check .
- name: Ruff — format check
run: python -m ruff format --check .
- name: Pytest
run: python -m pytest
wasm:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Read pinned emsdk version
id: emsdk-version
run: |
version=$(grep '^EMSDK_VERSION=' wasm/emsdk.pinned | cut -d= -f2)
if [ -z "$version" ]; then
echo "::error::EMSDK_VERSION not found in wasm/emsdk.pinned"
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install Ninja
uses: seanmiddleditch/gha-setup-ninja@v5
- name: Set up Emscripten ${{ steps.emsdk-version.outputs.version }}
uses: mymindstorm/setup-emsdk@v14
with:
version: ${{ steps.emsdk-version.outputs.version }}
actions-cache-folder: emsdk-cache-${{ steps.emsdk-version.outputs.version }}
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Configure (WASM)
run: cmake --preset wasm
- name: Build
run: cmake --build --preset wasm
- name: Smoke test
run: node wasm/hello.test.mjs
-104
View File
@@ -1,104 +0,0 @@
# Extended robustness: corpus render-stability sweep + libFuzzer run.
#
# DELIBERATELY ISOLATED from the main CI gate:
# - never triggers on push or pull_request, so it can NEVER block a merge,
# push, or pull;
# - the whole job is continue-on-error, so a crash finding or a build/runner
# problem reports red here but does not fail any required check;
# - runs on a weekly schedule and on manual dispatch only.
#
# A full 24h fuzz run needs a self-hosted runner (GitHub-hosted runners cap a job
# at 6h). Use the workflow_dispatch `duration_seconds` input for that; the weekly
# schedule does a short smoke instead.
name: Fuzz & corpus sweep
on:
schedule:
- cron: "0 3 * * 0" # Sundays 03:00 UTC — short smoke
workflow_dispatch:
inputs:
duration_seconds:
description: "libFuzzer -max_total_time (e.g. 1800 smoke, 86400 for 24h on a self-hosted runner)"
default: "1800"
sanitizers:
description: "Sanitizer set (fuzzer,address,undefined needs an ASan/UBSan-built PDFium; fuzzer = coverage-only)"
default: "fuzzer,address,undefined"
permissions:
contents: read
concurrency:
group: fuzz-${{ github.ref }}
cancel-in-progress: true
jobs:
fuzz:
# Non-blocking by construction: nothing depends on this job and it is allowed to fail.
continue-on-error: true
runs-on: ubuntu-latest
timeout-minutes: 1500 # permits a 24h dispatch on a self-hosted runner; hosted runners stop at 6h
env:
VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/.vcpkg-cache
FUZZ_SANITIZERS: ${{ github.event.inputs.sanitizers || 'fuzzer' }}
FUZZ_DURATION: ${{ github.event.inputs.duration_seconds || '600' }}
steps:
- uses: actions/checkout@v4
- name: Install Ninja + Clang
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y ninja-build clang
- name: Locate vcpkg
shell: bash
run: |
echo "VCPKG_ROOT=$VCPKG_INSTALLATION_ROOT" >> "$GITHUB_ENV"
mkdir -p "$VCPKG_DEFAULT_BINARY_CACHE"
- name: Cache vcpkg artifacts
uses: actions/cache@v4
with:
path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }}
key: vcpkg-fuzz-${{ hashFiles('vcpkg.json') }}
restore-keys: vcpkg-fuzz-
- name: Pin vcpkg dependency baseline
shell: bash
run: |
if ! grep -q '"builtin-baseline"' vcpkg.json; then
"$VCPKG_ROOT/vcpkg" x-update-baseline --add-initial-baseline
fi
- name: Configure (fuzz-linux)
run: cmake --preset fuzz-linux -DPDFENGINE_FUZZ_SANITIZERS="$FUZZ_SANITIZERS"
- name: Build fuzzer
run: cmake --build --preset fuzz-linux --target pdfengine_fuzz
- name: Fetch corpus (pinned + hash-verified)
shell: bash
run: python3 scripts/fetch_corpus.py --manifest tests/regression/corpus-manifest.json || echo "corpus fetch failed (network) — continuing with committed corpus"
- name: Render-stability sweep (replay every corpus PDF once)
shell: bash
run: |
BIN=out/build/fuzz-linux/bin/pdfengine_fuzz
mkdir -p engine/fuzz/artifacts
"$BIN" -runs=0 -artifact_prefix=engine/fuzz/artifacts/ corpus/ corpus/fuzz/ || true
- name: Fuzz run
shell: bash
run: |
BIN=out/build/fuzz-linux/bin/pdfengine_fuzz
"$BIN" -max_total_time="$FUZZ_DURATION" -print_final_stats=1 \
-artifact_prefix=engine/fuzz/artifacts/ corpus/fuzz/ corpus/ || true
- name: Upload crash artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: fuzz-artifacts
path: engine/fuzz/artifacts/
if-no-files-found: ignore
+2 -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/
@@ -88,4 +89,4 @@ models/**/*.pt
models/**/*.safetensors
models/**/*.index
!models/**/.gitkeep
.github
+1 -1
View File
@@ -30,7 +30,7 @@ services:
image: pdf-engine-frontend:dev
container_name: pdf-engine-frontend
environment:
VITE_GATEWAY_URL: http://localhost:8765
VITE_GATEWAY_URL: https://pdfapi-dev.maskantech.in
ports:
- "5173:5173"
volumes:
+1 -1
View File
@@ -1 +1 @@
VITE_GATEWAY_URL=http://127.0.0.1:8765
VITE_GATEWAY_URL=https://pdfapi-dev.maskantech.in
+235 -103
View File
@@ -9,11 +9,14 @@ 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';
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
import { UnsavedChangesModal } from './components/UnsavedChangesModal';
import type { UnsavedChangesModalState } from './components/UnsavedChangesModal';
import { PDFViewer } from './viewer/PDFViewer';
import { CreatePDFModal } from './features/document-creator/components/CreatePDFModal';
import type { PageLayout } from './features/document-creator/model/PaginationEngine';
@@ -53,6 +56,7 @@ function App() {
const can = (flag: keyof PDFPermissions) => !permissions || permissions[flag] !== false;
const denyToast = (_label: string) => { };
const disabledTools = new Set<ToolId>();
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));
@@ -66,13 +70,7 @@ function App() {
const [isInspectorOpen, setIsInspectorOpen] = useState(true);
const [createPdfModalOpen, setCreatePdfModalOpen] = useState(false);
const [createPdfKey, setCreatePdfKey] = useState(0);
const startNewBlankPDF = useCallback(() => {
localStorage.setItem('active_mode', 'create_pdf');
setCreatePdfModalOpen(true);
setActiveTool('create_pdf');
setCreatePdfKey((k) => k + 1);
}, []);
const [unsavedModalState, setUnsavedModalState] = useState<UnsavedChangesModalState | null>(null);
const [creatorActions, setCreatorActions] = useState<{
canUndo: boolean;
canRedo: boolean;
@@ -93,6 +91,8 @@ function App() {
useEffect(() => {
if (activeTool === 'create_pdf') {
setCreatePdfModalOpen(true);
} else if (activeTool === 'merge_pdf') {
setMergeModalOpen(true);
setActiveTool('select');
}
}, [activeTool]);
@@ -120,10 +120,201 @@ 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 [isInspectorExpanded, setIsInspectorExpanded] = useState(false);
const [activeStamp, setActiveStamp] = useState<StampPreset | null>(null);
const [redactionMode, setRedactionMode] = useState<'area' | 'text'>('area');
const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchCaseSensitive, setSearchCaseSensitive] = useState(false);
const [searchWholeWords, setSearchWholeWords] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searchCurrentMatch, setSearchCurrentMatch] = useState(0);
const [isOCRLoading, setIsOCRLoading] = useState(false);
const [creatorPageCount, setCreatorPageCount] = useState<number>(1);
const [creatorPages, setCreatorPages] = useState<PageLayout[]>([]);
const [watermarkPreview, setWatermarkPreview] = useState<WatermarkConfig | null>(null);
const handleCreatorPageCountChange = useCallback((count: number, pages?: PageLayout[]) => {
setCreatorPageCount(count);
if (pages) setCreatorPages(pages);
}, []);
const handleRunOCR = async () => {
if (!activeDoc) return;
setIsOCRLoading(true);
try {
await gatewayService.performPageOCR(activeDoc.id, currentPage);
viewerRef.current?.refreshPageLayout(currentPage);
} catch (err: any) {
alert(`OCR processing failed: ${err.message || err}`);
} finally {
setIsOCRLoading(false);
}
};
const forceOpenDocument = useCallback((id: string) => {
localStorage.removeItem('active_mode');
setCreatePdfModalOpen(false);
setActiveTool((t) => (t === 'create_pdf' ? 'select' : t));
setHist({ stack: [id], index: 0 });
}, []);
const urlParams = new URLSearchParams(window.location.search);
const isRemote = urlParams.has('stream_url') && urlParams.has('upload_url') && urlParams.has('token');
const urlToken = urlParams.get('token') || undefined;
const hasUnsavedChanges = useCallback(() => {
if (activeTool === 'create_pdf' || createPdfModalOpen) {
return Boolean(creatorActions?.canUndo);
}
return hist.stack.length > 1;
}, [activeTool, createPdfModalOpen, creatorActions?.canUndo, hist.stack.length]);
const handleSave = useCallback(async () => {
const isCreatorActive = activeTool === 'create_pdf' || createPdfModalOpen;
if (isCreatorActive) {
if (creatorActions?.generate) {
setIsSaving(true);
try {
await creatorActions.generate();
} finally {
setIsSaving(false);
}
}
return;
}
if (!activeDoc) return;
setIsSaving(true);
try {
if (isRemote) {
await gatewayService.exportRemoteDocument(selectedDocId, urlToken);
const parentOrigin = urlParams.get('parent_origin') || import.meta.env.VITE_PARENT_ORIGIN || '*';
window.parent.postMessage({ type: 'REMOTE_SAVE_COMPLETE' }, parentOrigin);
} else {
await new Promise(resolve => setTimeout(resolve, 600));
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
}
} catch (e) {
console.error('Save failed', e);
alert('Save failed: ' + String(e));
} finally {
setIsSaving(false);
}
}, [activeDoc, activeTool, createPdfModalOpen, creatorActions, isRemote, selectedDocId, urlToken]);
const executeStartNewBlankPDF = useCallback(() => {
localStorage.setItem('active_mode', 'create_pdf');
setCreatePdfModalOpen(true);
setActiveTool('create_pdf');
setCreatePdfKey((k) => k + 1);
}, []);
const startNewBlankPDF = useCallback(() => {
if (hasUnsavedChanges()) {
setUnsavedModalState({
title: 'Save Unsaved Document?',
message: 'You have unsaved changes in your document. Would you like to save your document before creating a new blank document?',
onSaveAndContinue: async () => {
if (activeTool === 'create_pdf' || createPdfModalOpen) {
if (creatorActions?.generate) {
await creatorActions.generate();
}
} else {
await handleSave();
}
executeStartNewBlankPDF();
},
onDiscardAndContinue: () => {
executeStartNewBlankPDF();
},
});
return;
}
executeStartNewBlankPDF();
}, [hasUnsavedChanges, activeTool, createPdfModalOpen, creatorActions, handleSave, executeStartNewBlankPDF]);
const openDocument = useCallback((id: string, bypassCheck = false) => {
if (!bypassCheck && selectedDocId && selectedDocId !== id && hasUnsavedChanges()) {
setUnsavedModalState({
title: 'Save Unsaved Document?',
message: 'You have unsaved changes in your current document. Would you like to save before opening another document?',
onSaveAndContinue: async () => {
if (activeTool === 'create_pdf' || createPdfModalOpen) {
if (creatorActions?.generate) {
await creatorActions.generate();
}
} else {
await handleSave();
}
forceOpenDocument(id);
},
onDiscardAndContinue: () => {
forceOpenDocument(id);
},
});
return;
}
forceOpenDocument(id);
}, [selectedDocId, hasUnsavedChanges, activeTool, createPdfModalOpen, creatorActions, handleSave, forceOpenDocument]);
const executeUpload = async (file: File, password = '') => {
try {
setIsLoading(true);
const newDoc = await gatewayService.uploadDocument(file, password);
setDocuments((prev) => [newDoc, ...prev]);
setCreatePdfModalOpen(false);
setActiveTool((t) => (t === 'create_pdf' ? 'select' : t));
forceOpenDocument(newDoc.id);
setPasswordPrompt(null);
} catch (e) {
if (e instanceof PasswordError) {
setPasswordPrompt({ file, filename: file.name, error: password ? 'Incorrect password — please try again.' : undefined });
} else {
console.error('Upload failed', e);
}
} finally {
setIsLoading(false);
}
};
const handleUpload = async (file: File, password = '', bypassCheck = false) => {
if (!bypassCheck && hasUnsavedChanges()) {
setUnsavedModalState({
title: 'Save Unsaved Document?',
message: `You have unsaved changes in your document. Would you like to save before opening "${file.name}"?`,
onSaveAndContinue: async () => {
if (activeTool === 'create_pdf' || createPdfModalOpen) {
if (creatorActions?.generate) {
await creatorActions.generate();
}
} else {
await handleSave();
}
await executeUpload(file, password);
},
onDiscardAndContinue: async () => {
await executeUpload(file, password);
},
});
return;
}
await executeUpload(file, password);
};
const handleMergeCompleted = (docInfo: DocumentInfo) => {
setDocuments((prev) => [...prev, docInfo]);
openDocument(docInfo.id);
};
const handleOpenCompareModal = () => {
if (!activeDoc) return;
setCompareDocA({
@@ -174,46 +365,6 @@ function App() {
});
}
};
const [isInspectorExpanded, setIsInspectorExpanded] = useState(false);
const [activeStamp, setActiveStamp] = useState<StampPreset | null>(null);
const [redactionMode, setRedactionMode] = useState<'area' | 'text'>('area');
const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchCaseSensitive, setSearchCaseSensitive] = useState(false);
const [searchWholeWords, setSearchWholeWords] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searchCurrentMatch, setSearchCurrentMatch] = useState(0);
const [isOCRLoading, setIsOCRLoading] = useState(false);
const [creatorPageCount, setCreatorPageCount] = useState<number>(1);
const [creatorPages, setCreatorPages] = useState<PageLayout[]>([]);
const [watermarkPreview, setWatermarkPreview] = useState<WatermarkConfig | null>(null);
const handleCreatorPageCountChange = useCallback((count: number, pages?: PageLayout[]) => {
setCreatorPageCount(count);
if (pages) setCreatorPages(pages);
}, []);
const handleRunOCR = async () => {
if (!activeDoc) return;
setIsOCRLoading(true);
try {
await gatewayService.performPageOCR(activeDoc.id, currentPage);
viewerRef.current?.refreshPageLayout(currentPage);
} catch (err: any) {
alert(`OCR processing failed: ${err.message || err}`);
} finally {
setIsOCRLoading(false);
}
};
const openDocument = useCallback((id: string) => {
localStorage.removeItem('active_mode');
setCreatePdfModalOpen(false);
setActiveTool((t) => (t === 'create_pdf' ? 'select' : t));
setHist({ stack: [id], index: 0 });
}, []);
const pushHistory = (id: string) =>
setHist((h) => ({ stack: [...h.stack.slice(0, h.index + 1), id], index: h.index + 1 }));
@@ -790,62 +941,15 @@ function App() {
}
};
const handleUpload = async (file: File, password = '') => {
try {
setIsLoading(true);
const newDoc = await gatewayService.uploadDocument(file, password);
setDocuments((prev) => [newDoc, ...prev]);
setCreatePdfModalOpen(false);
setActiveTool((t) => (t === 'create_pdf' ? 'select' : t));
openDocument(newDoc.id);
setPasswordPrompt(null);
} catch (e) {
if (e instanceof PasswordError) {
setPasswordPrompt({ file, filename: file.name, error: password ? 'Incorrect password — please try again.' : undefined });
} else {
console.error('Upload failed', e);
}
} finally {
setIsLoading(false);
}
};
const urlParams = new URLSearchParams(window.location.search);
const isRemote = urlParams.has('stream_url') && urlParams.has('upload_url') && urlParams.has('token');
// Grab the token that was injected into the URL when this iframe was opened.
// This is always fresher than whatever the gateway has cached.
const urlToken = urlParams.get('token') || undefined;
const handleSave = async () => {
if (!activeDoc) return;
setIsSaving(true);
try {
if (isRemote) {
await gatewayService.exportRemoteDocument(selectedDocId, urlToken);
const parentOrigin = urlParams.get('parent_origin') || import.meta.env.VITE_PARENT_ORIGIN || '*';
window.parent.postMessage({ type: 'REMOTE_SAVE_COMPLETE' }, parentOrigin);
} else {
// When running locally, simulate a save delay to provide UI feedback.
await new Promise(resolve => setTimeout(resolve, 600));
// If the query parameter is present (like from our landing page flow), trigger a local download
if (urlParams.has('download_on_save')) {
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
}
}
} catch (e) {
console.error('Save failed', e);
alert('Save failed: ' + String(e));
} finally {
setIsSaving(false);
}
};
const handleProtectSubmit = async (payload: {
userPassword: string;
ownerPassword?: string;
confirmPassword: string;
permissions: any;
permissions?: any;
}) => {
const isCreatorActive = activeTool === 'create_pdf' || createPdfModalOpen || selectedDocId === 'new-blank-creator' || !selectedDocId;
try {
@@ -870,7 +974,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);
@@ -940,23 +1047,19 @@ function App() {
if (tool === 'underline' || tool === 'squiggly') {
creatorActions?.updateRunFormatting?.({ underline: !activeRun?.underline });
setActiveTool('select');
return;
}
if (tool === 'strikeout') {
creatorActions?.updateRunFormatting?.({ strikethrough: !activeRun?.strikethrough });
setActiveTool('select');
return;
}
if (tool === 'highlight') {
const nextColor = activeRun?.highlightColor ? undefined : '#fef08a';
creatorActions?.updateRunFormatting?.({ highlightColor: nextColor });
setActiveTool('select');
return;
}
if (tool === 'textbox') {
creatorActions?.addParagraph?.();
setActiveTool('select');
return;
}
if (tool === 'stamp') {
@@ -965,7 +1068,6 @@ function App() {
}
if (tool === 'comment') {
creatorActions?.insertComment?.();
setActiveTool('select');
return;
}
if (tool === 'draw') {
@@ -1087,7 +1189,7 @@ function App() {
onUndo={activeTool === 'create_pdf' || createPdfModalOpen ? (() => creatorActions?.undo()) : undo}
onRedo={activeTool === 'create_pdf' || createPdfModalOpen ? (() => creatorActions?.redo()) : redo}
isSaving={isSaving}
isDirtySaved={hist.stack.length > 1}
isDirtySaved={(activeTool === 'create_pdf' || createPdfModalOpen) ? (creatorActions?.canUndo ?? false) : hist.stack.length > 1}
onRotate={handleRotate}
onExport={activeTool === 'create_pdf' || createPdfModalOpen ? (() => creatorActions?.generate()) : handleExport}
onPrint={(activeTool === 'create_pdf' || createPdfModalOpen) ? (() => creatorActions?.print?.()) : handlePrint}
@@ -1101,6 +1203,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')}
@@ -1184,12 +1287,32 @@ function App() {
if (preset) setActiveStamp(preset);
}}
onClose={() => {
if (hasUnsavedChanges()) {
setUnsavedModalState({
title: 'Save Unsaved Document?',
message: 'You have unsaved changes in your document. Would you like to save before closing?',
onSaveAndContinue: async () => {
if (creatorActions?.generate) {
await creatorActions.generate();
}
localStorage.removeItem('active_mode');
setCreatePdfModalOpen(false);
if (activeTool === 'create_pdf') setActiveTool('select');
},
onDiscardAndContinue: () => {
localStorage.removeItem('active_mode');
setCreatePdfModalOpen(false);
if (activeTool === 'create_pdf') setActiveTool('select');
},
});
return;
}
localStorage.removeItem('active_mode');
setCreatePdfModalOpen(false);
if (activeTool === 'create_pdf') setActiveTool('select');
}}
onCreatePDF={async (file) => {
await handleUpload(file);
await handleUpload(file, '', true);
setCreatePdfModalOpen(false);
setActiveTool('select');
}}
@@ -1432,6 +1555,8 @@ function App() {
<CustomConfirmationModal state={confirmState} onClose={() => setConfirmState(null)} />
<UnsavedChangesModal state={unsavedModalState} onClose={() => setUnsavedModalState(null)} />
<PasswordModal
state={passwordPrompt}
onSubmit={(pw) => { if (passwordPrompt) handleUpload(passwordPrompt.file, pw); }}
@@ -1477,6 +1602,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>
);
}
+405
View File
@@ -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>
);
};
+16 -5
View File
@@ -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 /> },
{
@@ -111,11 +122,11 @@ 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]'
: active
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-brand-secondary text-brand-primary'
: t.danger ? 'text-text-secondary hover:bg-[#fdecec] hover:text-[#dc2626]'
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
? '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]'
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}
>
{active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: t.danger ? '#dc2626' : 'var(--brand-primary)' }} />}
+8
View File
@@ -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' }) => (
+3 -1
View File
@@ -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>
@@ -0,0 +1,100 @@
import React, { useState } from 'react';
import { CustomButton } from './custom/CustomButton';
export interface UnsavedChangesModalState {
title?: string;
message?: string;
onSaveAndContinue: () => Promise<void> | void;
onDiscardAndContinue: () => void;
}
interface UnsavedChangesModalProps {
state: UnsavedChangesModalState | null;
onClose: () => void;
}
export const UnsavedChangesModal: React.FC<UnsavedChangesModalProps> = ({ state, onClose }) => {
const [isSaving, setIsSaving] = useState(false);
if (!state) return null;
const title = state.title || 'Unsaved Document Changes';
const message = state.message || 'You have unsaved changes in your current document. Would you like to save before proceeding?';
const handleSave = async () => {
setIsSaving(true);
try {
await state.onSaveAndContinue();
onClose();
} catch (err) {
console.error('Failed to save document:', err);
alert('Failed to save document. Please try again.');
} finally {
setIsSaving(false);
}
};
const handleDiscard = () => {
state.onDiscardAndContinue();
onClose();
};
return (
<div className="fixed inset-0 z-[250] flex items-center justify-center p-4" onMouseDown={onClose}>
<div
className="absolute inset-0 bg-[#0f172a]/40 backdrop-blur-[2px]"
style={{ animation: 'toastIn 0.2s ease-out' }}
/>
<div
style={{ width: 460, animation: 'slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1)' }}
className="relative flex max-h-[90vh] flex-col overflow-hidden rounded-[16px] bg-bg-primary shadow-[0_24px_48px_rgba(16,24,40,0.18)] ring-1 ring-border-primary"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex flex-col gap-3 p-7 pb-6">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#fef3c7] text-[#d97706]">
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h2 className="text-[18px] font-bold tracking-tight text-text-primary">{title}</h2>
<p className="text-[12px] font-medium text-text-tertiary">Document Protection</p>
</div>
</div>
<p className="text-[13.5px] leading-relaxed text-text-secondary">{message}</p>
</div>
<div className="flex items-center justify-end gap-2.5 bg-bg-secondary px-7 py-4 border-t border-border-primary">
<CustomButton
variant="ghost"
onClick={onClose}
disabled={isSaving}
className="rounded-[8px] font-semibold px-4 text-text-secondary hover:text-text-primary"
>
Cancel
</CustomButton>
<CustomButton
variant="secondary"
onClick={handleDiscard}
disabled={isSaving}
className="rounded-[8px] px-4 font-semibold text-[#dc2626] hover:bg-[#fdecec]"
>
Discard
</CustomButton>
<CustomButton
variant="primary"
onClick={handleSave}
disabled={isSaving}
className="rounded-[8px] px-5 font-semibold text-white bg-[#2563eb] hover:bg-[#1d4ed8]"
>
{isSaving ? 'Saving...' : 'Save & Continue'}
</CustomButton>
</div>
</div>
</div>
);
};
@@ -7,6 +7,7 @@ import type { PageLayout } from '../model/PaginationEngine';
import { PdfDocumentRenderer } from '../renderer/PdfDocumentRenderer';
import { DocumentToolbar } from './DocumentToolbar';
import { DocumentEditor } from './DocumentEditor';
import { triggerPDFDownload } from '../../../lib/pdfExport';
interface CreatePDFModalProps {
isOpen: boolean;
@@ -59,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> = ({
@@ -96,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);
@@ -116,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]);
@@ -330,7 +288,7 @@ export const CreatePDFModal: React.FC<CreatePDFModalProps> = ({
],
alignment: 'left',
spaceBefore: 0,
spaceAfter: 6,
spaceAfter: 2,
};
let nextBlocks = [...doc.blocks];
@@ -684,6 +642,7 @@ export const CreatePDFModal: React.FC<CreatePDFModalProps> = ({
const filename = `${doc.title.replace(/[^a-zA-Z0-9_-]/g, '_') || 'Custom_Document'}.pdf`;
const file = new File([pdfBlob], filename, { type: 'application/pdf' });
await onCreatePDF(file);
await triggerPDFDownload(pdfBlob, filename);
onClose();
} catch (err) {
console.error('PDF Generation failed', err);
@@ -723,6 +682,7 @@ export const CreatePDFModal: React.FC<CreatePDFModalProps> = ({
doc={doc}
selectedBlockId={selectedBlockId}
activeRun={activeRun}
activeParagraph={activeParagraph}
activeTool={activeTool}
drawColor={drawColor}
drawWidth={drawWidth}
@@ -109,12 +109,11 @@ 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 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
@@ -126,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`,
@@ -139,23 +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 -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]"
>
</button>
)}
</div>
);
};
@@ -233,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"
@@ -369,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..."
/>
@@ -408,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,
@@ -800,21 +782,25 @@ 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 ${
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`,
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 +826,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) {
@@ -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>
@@ -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,39 @@ 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 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 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 !== undefined ? block.spaceAfter : 2) * 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;
@@ -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',
+3 -11
View File
@@ -1,4 +1,5 @@
import type { ReflowLayout } from './pdfiumEngine';
import { triggerPDFDownload } from './pdfExport';
export interface PageInfo {
index: number;
@@ -1074,17 +1075,8 @@ class GatewayService {
}
async exportDocument(documentId: string, filename: string): Promise<void> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/export`);
if (!response.ok) throw new Error(`Export failed: ${response.statusText}`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
const blob = await this.exportDocumentBlob(documentId);
await triggerPDFDownload(blob, filename);
}
async exportRemoteDocument(documentId: string, freshToken?: string): Promise<any> {
+2 -1
View File
@@ -15,7 +15,8 @@ export type ToolId =
| 'squiggly'
| 'stream_edit'
| 'create_pdf'
| 'watermark';
| 'watermark'
| 'merge_pdf';
export interface ToolSettings {
highlightColor: string;
+39 -8
View File
@@ -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);
}
+22 -23
View File
@@ -8,7 +8,6 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
cmake \
ninja-build \
pkg-config \
git \
@@ -35,23 +34,20 @@ RUN git clone https://github.com/microsoft/vcpkg.git /opt/vcpkg \
&& /opt/vcpkg/bootstrap-vcpkg.sh -disableMetrics
ENV VCPKG_ROOT=/opt/vcpkg
# Ensure a recent CMake is available (system cmake in slim images can be too old for vcpkg)
RUN python -m pip install --upgrade pip cmake
# Cache vcpkg dependencies in a separate layer
COPY vcpkg.json ./
RUN --mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/opt/vcpkg/downloads \
/opt/vcpkg/vcpkg install --triplet x64-linux
# Install depot_tools globally with caching
RUN --mount=type=cache,target=/opt/depot_tools \
if [ ! -d /opt/depot_tools/.git ]; then \
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git /opt/depot_tools; \
fi
ENV PATH="/opt/depot_tools:${PATH}"
# Build PDFium for Linux (heavily cached, keeping the huge source tree out of the image layer)
COPY third_party/pdfium/ ./third_party/pdfium/
RUN --mount=type=cache,target=/build/third_party/pdfium/checkout \
./third_party/pdfium/build_pdfium.sh
RUN --mount=type=cache,target=/build/third_party/pdfium/depot_tools \
--mount=type=cache,target=/build/third_party/pdfium/checkout \
sed -i 's/\r$//' ./third_party/pdfium/build_pdfium.sh && bash ./third_party/pdfium/build_pdfium.sh
# Copy everything needed for the engine and bindings build
COPY CMakeLists.txt CMakePresets.json ./
@@ -59,26 +55,24 @@ COPY cmake/ ./cmake/
COPY engine/ ./engine/
COPY bindings/ ./bindings/
COPY gateway/ ./gateway/
COPY corpus/ ./corpus/
# Configure CMake with tests enabled
# Configure and build in the same layer. Keeping these operations together
# avoids Docker/overlayfs timestamp skew causing Ninja to regenerate
# build.ninja indefinitely ("manifest still dirty after 100 tries"). The
# source tree is immutable for this image, so automatic build-system
# regeneration is unnecessary once configure has completed.
RUN --mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/opt/vcpkg/downloads \
cmake --preset linux-release \
-DPDFENGINE_BUILD_TESTS=ON \
-DPDFENGINE_BUILD_TESTS=OFF \
-DPDFENGINE_WITH_PDFIUM=ON \
-DPDFENGINE_WITH_SKIA=OFF \
-DPDFENGINE_WITH_QPDF=ON \
-DCMAKE_TOOLCHAIN_FILE=${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake
-DCMAKE_TOOLCHAIN_FILE=${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake \
-DCMAKE_SUPPRESS_REGENERATION=ON \
&& cmake --build out/build/linux-release
# Build all targets including tests and pdfengine_py
RUN cmake --build out/build/linux-release
# Stage 2: Tester
FROM builder AS tester
RUN ctest --test-dir out/build/linux-release --output-on-failure
# Stage 3: Runtime
# Stage 2: Runtime
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
@@ -93,10 +87,15 @@ RUN apt-get update \
zlib1g \
libpng16-16 \
fonts-liberation \
libgl1 \
libglib2.0-0 \
libgomp1 \
libsm6 \
libxext6 \
&& rm -rf /var/lib/apt/lists/*
# Install the extension globally so it's not shadowed by the volume mount ./gateway:/home/app
COPY --from=tester /build/gateway/pdfengine*.so /usr/local/lib/python3.11/site-packages/
COPY --from=builder /build/gateway/pdfengine*.so /usr/local/lib/python3.11/site-packages/
RUN groupadd --system app \
&& useradd --system --gid app --create-home --home-dir /home/app app
+79 -2
View File
@@ -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}",
)
+7 -1
View File
@@ -77,9 +77,15 @@ def perform_ocr_on_page(document_id: str, page_index: int) -> OCRPageResponse:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Page index out of bounds")
if not ocr_service.is_ocr_available():
init_err = ocr_service.get_ocr_init_error()
err_detail = (
f"RapidOCR engine is not installed or available ({init_err})."
if init_err
else "RapidOCR engine is not installed or available."
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="RapidOCR engine is not installed or available.",
detail=err_detail,
)
try:
+11
View File
@@ -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")
+10 -1
View File
@@ -8,11 +8,12 @@ logger = logging.getLogger(__name__)
_ocr_engine = None
_ocr_available = False
_ocr_init_error: Optional[str] = None
def load_ocr() -> bool:
"""Explicitly initializes the RapidOCR engine once during application startup."""
global _ocr_engine, _ocr_available
global _ocr_engine, _ocr_available, _ocr_init_error
if _ocr_engine is not None:
return _ocr_available
@@ -20,10 +21,14 @@ def load_ocr() -> bool:
from rapidocr_onnxruntime import RapidOCR
_ocr_engine = RapidOCR()
_ocr_available = True
_ocr_init_error = None
logger.info("[Startup] RapidOCR engine loaded successfully into memory.")
print("[Startup] RapidOCR engine loaded successfully into memory.")
except Exception as e:
_ocr_engine = None
_ocr_available = False
_ocr_init_error = str(e)
logger.error(f"[Startup] Warning: RapidOCR engine initialization failed: {e}", exc_info=True)
print(f"[Startup] Warning: RapidOCR engine not available: {e}")
return _ocr_available
@@ -37,6 +42,10 @@ def is_ocr_available() -> bool:
return _ocr_available and _ocr_engine is not None
def get_ocr_init_error() -> Optional[str]:
return _ocr_init_error
def recognize_image_bytes(image_bytes: bytes) -> Dict[str, Any]:
if not is_ocr_available():
+79
View File
@@ -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()
+3
View File
@@ -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]
+68
View File
@@ -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
+7 -2
View File
@@ -13,8 +13,8 @@ Day-1 risk — budget a full day for depot_tools quirks."*
still the placeholder — Rule: never track rolling HEAD).
2. Clone `depot_tools` under the **build root** and run its Windows bootstrap
(fetches bundled git + python via CIPD).
3. `gclient config` + `gclient sync` the PDFium tree into `<build root>/checkout/`.
4. Check out the exact pinned commit and re-sync its DEPS.
3. `gclient config` + a throttled, retried `gclient sync --revision` of the
exact pinned PDFium commit into `<build root>/checkout/`.
5. Write `args.gn` for a **static, standalone, monolithic, embed-friendly** build:
- `is_component_build = false` — one static lib, not many DLLs
- `pdf_is_standalone = true`
@@ -88,6 +88,11 @@ several GB, and the compile is lengthy. `depot_tools/`, `checkout/`, and
- **`gclient sync` aborts with "uncommitted changes"** — git's `core.autocrlf`
rewrote a dependency checkout. The scripts already inject `core.autocrlf=false`
per-process; if you bypass them, set it yourself.
- **`gclient sync` reports HTTP 429** — the public Chromium source host has
throttled dependency downloads. The scripts retry the sync and limit it to
two concurrent source-control operations. If your network is reliable and
has sufficient capacity, increase `PDFIUM_GCLIENT_JOBS`; otherwise re-run
the same command and it will resume from the cached checkout.
- **depot_tools `git`/`python` not found** — depot_tools was not bootstrapped.
The scripts run `bootstrap\win_tools.bat`; do not set `DEPOT_TOOLS_UPDATE=0`,
which suppresses that bootstrap.
+41 -12
View File
@@ -38,6 +38,33 @@ $DepotTools = Join-Path $BuildRoot 'depot_tools'
$Checkout = Join-Path $BuildRoot 'checkout'
Write-Host ">> Build root: $BuildRoot"
# gclient otherwise defaults to at least eight concurrent repository updates.
# A modest default avoids HTTP 429 responses from public googlesource endpoints.
$GclientJobs = if ($env:PDFIUM_GCLIENT_JOBS) { $env:PDFIUM_GCLIENT_JOBS } else { '2' }
if ($GclientJobs -notmatch '^[1-9][0-9]*$') {
Write-Error "PDFIUM_GCLIENT_JOBS must be a positive integer, got: $GclientJobs"
}
function Invoke-ExternalWithRetry {
param(
[Parameter(Mandatory = $true)][string]$Description,
[Parameter(Mandatory = $true)][scriptblock]$Command
)
$maxAttempts = if ($env:PDFIUM_MAX_ATTEMPTS) { [int]$env:PDFIUM_MAX_ATTEMPTS } else { 6 }
$delay = if ($env:PDFIUM_INITIAL_RETRY_DELAY) { [int]$env:PDFIUM_INITIAL_RETRY_DELAY } else { 15 }
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
& $Command
if ($LASTEXITCODE -eq 0) { return }
if ($attempt -eq $maxAttempts) {
Write-Error "$Description failed after $attempt attempts."
}
Write-Warning "$Description failed (attempt $attempt/$maxAttempts). Retrying in ${delay}s..."
Start-Sleep -Seconds $delay
$delay *= 2
}
}
# --- 1. Read and validate the pinned revision -------------------------------
$pinned = Get-Content $PinnedFile | Where-Object { $_ -match '^\s*PDFIUM_' }
$repo = ($pinned | Where-Object { $_ -match '^PDFIUM_REPO=' }) -replace '^PDFIUM_REPO=', ''
@@ -61,11 +88,12 @@ $env:DEPOT_TOOLS_WIN_TOOLCHAIN = '0'
# so the user's global git config is never touched. core.autocrlf=false is the
# critical one: without it, gclient sees CRLF-converted dependency checkouts as
# "uncommitted changes" and aborts the sync.
$env:GIT_CONFIG_COUNT = '4'
$env:GIT_CONFIG_COUNT = '5'
$env:GIT_CONFIG_KEY_0 = 'core.autocrlf'; $env:GIT_CONFIG_VALUE_0 = 'false'
$env:GIT_CONFIG_KEY_1 = 'core.filemode'; $env:GIT_CONFIG_VALUE_1 = 'false'
$env:GIT_CONFIG_KEY_2 = 'core.fscache'; $env:GIT_CONFIG_VALUE_2 = 'true'
$env:GIT_CONFIG_KEY_3 = 'core.preloadindex'; $env:GIT_CONFIG_VALUE_3 = 'true'
$env:GIT_CONFIG_KEY_4 = 'http.maxRequests'; $env:GIT_CONFIG_VALUE_4 = '2'
# Bootstrap depot_tools. On Windows it must fetch its bundled git + python via
# CIPD and generate the git.bat / python3.bat wrappers before gclient can run.
@@ -101,21 +129,22 @@ $env:GYP_MSVS_VERSION = $vsYear
New-Item -ItemType Directory -Force -Path $Checkout | Out-Null
Push-Location $Checkout
if (-not (Test-Path (Join-Path $Checkout 'pdfium'))) {
Write-Host '>> gclient config (unmanaged)'
& gclient config --unmanaged $repo
Write-Host '>> gclient config'
& gclient config $repo
if ($LASTEXITCODE -ne 0) { Write-Error 'gclient config failed.' }
}
Write-Host '>> gclient sync (pulls several GB; slow)'
& gclient sync --no-history --shallow --reset --force
if ($LASTEXITCODE -ne 0) { Write-Error 'gclient sync failed.' }
Write-Host ">> gclient sync pinned at $commit (pulls several GB; slow)"
Invoke-ExternalWithRetry 'gclient sync' {
& gclient sync "--jobs=$GclientJobs" --no-history --shallow --reset --force --delete_unversioned_trees --revision "pdfium@$commit"
}
# --- 4. Pin to the exact commit + sync its DEPS -----------------------------
# gclient --revision keeps both the root and its DEPS at the requested
# revision, avoiding a sync at HEAD followed by a second full pinned sync.
Push-Location (Join-Path $Checkout 'pdfium')
& git fetch origin $commit
& git checkout --detach $commit
if ($LASTEXITCODE -ne 0) { Write-Error "git checkout $commit failed." }
& gclient sync --no-history --shallow --reset --force -D
if ($LASTEXITCODE -ne 0) { Write-Error 'gclient sync (pinned DEPS) failed.' }
$actualCommit = (& git rev-parse HEAD).Trim()
if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $commit) {
Write-Error "gclient did not check out the requested PDFium revision. Expected $commit, got $actualCommit"
}
# --- 5. GN args: static, standalone, monolithic, embed-friendly -------------
New-Item -ItemType Directory -Force -Path 'out\Release' | Out-Null
+52 -11
View File
@@ -34,6 +34,43 @@ DEPOT_TOOLS_DIR="${BUILD_ROOT}/depot_tools"
CHECKOUT_DIR="${BUILD_ROOT}/checkout"
echo ">> Build root: ${BUILD_ROOT}"
# Simple retry helper for transient network/git errors. A PDFium sync contacts
# many Chromium-hosted repositories, so a retry resumes the partially populated
# checkout instead of starting the multi-GB download again.
retry() {
local -r -i max_attempts=${PDFIUM_MAX_ATTEMPTS:-6}
local -r -i initial_sleep=${PDFIUM_INITIAL_RETRY_DELAY:-15}
local -i attempt=1
local sleep_time=$initial_sleep
while true; do
if "$@"; then
return 0
fi
if [ "$attempt" -ge "$max_attempts" ]; then
echo "Command failed after $attempt attempts: $*" >&2
return 1
fi
echo "Command failed (attempt $attempt/$max_attempts). Retrying in ${sleep_time}s..." >&2
sleep "$sleep_time"
attempt=$((attempt+1))
sleep_time=$((sleep_time*2))
done
}
# gclient defaults to at least eight simultaneous source-control operations.
# That is unnecessarily aggressive for public googlesource endpoints and can
# trigger HTTP 429 responses while Docker is fetching the PDFium dependency
# graph. Keep the default conservative; callers that have a trusted mirror or
# more capacity can override it explicitly.
GCLIENT_JOBS="${PDFIUM_GCLIENT_JOBS:-2}"
if ! [[ "${GCLIENT_JOBS}" =~ ^[1-9][0-9]*$ ]]; then
echo "ERROR: PDFIUM_GCLIENT_JOBS must be a positive integer, got: ${GCLIENT_JOBS}" >&2
exit 1
fi
gclient_sync() {
retry gclient sync --jobs="${GCLIENT_JOBS}" --no-history --shallow --reset --force "$@"
}
# --- 1. Read and validate the pinned revision -------------------------------
PDFIUM_REPO="$(grep -E '^PDFIUM_REPO=' "${PINNED_FILE}" | cut -d= -f2- | tr -d '\r')"
PDFIUM_COMMIT="$(grep -E '^PDFIUM_COMMIT=' "${PINNED_FILE}" | cut -d= -f2- | tr -d '\r')"
@@ -60,33 +97,37 @@ fi
# Git settings injected per-process via GIT_CONFIG_* so the user's global git
# config is never touched. core.autocrlf=false avoids gclient seeing dependency
# checkouts as "uncommitted changes" on platforms where autocrlf is enabled.
export GIT_CONFIG_COUNT=4
export GIT_CONFIG_COUNT=5
export GIT_CONFIG_KEY_0=core.autocrlf GIT_CONFIG_VALUE_0=false
export GIT_CONFIG_KEY_1=core.filemode GIT_CONFIG_VALUE_1=false
export GIT_CONFIG_KEY_2=http.postBuffer GIT_CONFIG_VALUE_2=1048576000
export GIT_CONFIG_KEY_3=core.compression GIT_CONFIG_VALUE_3=0
export GIT_CONFIG_KEY_4=http.maxRequests GIT_CONFIG_VALUE_4=2
# --- 3. Fetch / sync the PDFium tree ----------------------------------------
mkdir -p "${CHECKOUT_DIR}"
cd "${CHECKOUT_DIR}"
if [[ ! -d "${CHECKOUT_DIR}/pdfium" ]]; then
echo ">> gclient config (unmanaged)"
gclient config --unmanaged "${PDFIUM_REPO}"
echo ">> gclient config"
gclient config "${PDFIUM_REPO}"
fi
echo ">> gclient sync (pulls several GB; slow)"
gclient sync --no-history --shallow --reset --force
echo ">> gclient sync pinned at ${PDFIUM_COMMIT} (pulls several GB; slow)"
gclient_sync --delete_unversioned_trees --revision "pdfium@${PDFIUM_COMMIT}"
# --- 4. Pin to the exact commit + sync its DEPS -----------------------------
# gclient --revision keeps both the root and its DEPS at the requested
# revision, avoiding the former expensive sync-at-HEAD followed by a second
# complete pinned sync.
cd "${CHECKOUT_DIR}/pdfium"
git fetch origin "${PDFIUM_COMMIT}"
git checkout --detach "${PDFIUM_COMMIT}"
gclient sync --no-history --shallow --reset --force -D
if [[ "$(git rev-parse HEAD)" != "${PDFIUM_COMMIT}" ]]; then
echo "ERROR: gclient did not check out the requested PDFium revision." >&2
echo "Expected: ${PDFIUM_COMMIT}" >&2
echo "Actual: $(git rev-parse HEAD)" >&2
exit 1
fi
# --- 5. GN args: static, standalone, monolithic, embed-friendly -------------
mkdir -p out/Release
cp "${SCRIPT_DIR}/args.gn" out/Release/args.gn
# --- 6. Generate + build ----------------------------------------------------
echo ">> gn gen + ninja"
gn gen out/Release
ninja -C out/Release pdfium