From 5c559b2a0f462cb6d11f30c8b93d13b471143ceb Mon Sep 17 00:00:00 2001 From: saquib Date: Fri, 7 Aug 2026 10:50:04 +0530 Subject: [PATCH 01/11] fixed docker build issue --- docker-compose.yml | 2 +- frontend/.env | 2 +- gateway/Dockerfile | 6 ++++-- third_party/pdfium/build_pdfium.sh | 27 +++++++++++++++++++++++++-- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 4e18737..ff96dbd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/.env b/frontend/.env index e5771d2..4b5cf4e 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1 +1 @@ -VITE_GATEWAY_URL=http://127.0.0.1:8765 +VITE_GATEWAY_URL=https://pdfapi-dev.maskantech.in diff --git a/gateway/Dockerfile b/gateway/Dockerfile index be56642..e3b7c24 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -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,6 +34,9 @@ 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 \ @@ -51,7 +53,7 @@ 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 + 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 ./ diff --git a/third_party/pdfium/build_pdfium.sh b/third_party/pdfium/build_pdfium.sh index 57f25e1..082e227 100644 --- a/third_party/pdfium/build_pdfium.sh +++ b/third_party/pdfium/build_pdfium.sh @@ -34,6 +34,26 @@ 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 +retry() { + local -r -i max_attempts=${MAX_ATTEMPTS:-5} + local -r -i initial_sleep=${INITIAL_SLEEP:-5} + 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 +} # --- 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')" @@ -82,11 +102,14 @@ git fetch origin "${PDFIUM_COMMIT}" git checkout --detach "${PDFIUM_COMMIT}" gclient sync --no-history --shallow --reset --force -D -# --- 5. GN args: static, standalone, monolithic, embed-friendly ------------- +echo ">> gclient sync (pulls several GB; slow)" +retry gclient sync --no-history --shallow --reset --force mkdir -p out/Release cp "${SCRIPT_DIR}/args.gn" out/Release/args.gn -# --- 6. Generate + build ---------------------------------------------------- +git fetch origin "${PDFIUM_COMMIT}" +retry git checkout --detach "${PDFIUM_COMMIT}" +retry gclient sync --no-history --shallow --reset --force -D echo ">> gn gen + ninja" gn gen out/Release ninja -C out/Release pdfium From e04ac57e40d7f72117d527b0519f7aac7d7989f0 Mon Sep 17 00:00:00 2001 From: saquib Date: Tue, 18 Aug 2026 15:54:22 +0530 Subject: [PATCH 02/11] docker build fix --- .github/workflows/ci.yml | 162 ---------------------------- .github/workflows/fuzz.yml | 104 ------------------ .gitignore | 2 +- gateway/Dockerfile | 23 ++-- third_party/pdfium/README.md | 9 +- third_party/pdfium/build_pdfium.ps1 | 53 ++++++--- third_party/pdfium/build_pdfium.sh | 52 ++++++--- 7 files changed, 94 insertions(+), 311 deletions(-) delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/fuzz.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 266ab58..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml deleted file mode 100644 index 87eefb3..0000000 --- a/.github/workflows/fuzz.yml +++ /dev/null @@ -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 diff --git a/.gitignore b/.gitignore index f9eafbc..714700b 100644 --- a/.gitignore +++ b/.gitignore @@ -88,4 +88,4 @@ models/**/*.pt models/**/*.safetensors models/**/*.index !models/**/.gitkeep - +.github diff --git a/gateway/Dockerfile b/gateway/Dockerfile index e3b7c24..a55b9fd 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -43,16 +43,10 @@ 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 \ +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 @@ -63,7 +57,11 @@ 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 \ @@ -71,10 +69,9 @@ RUN --mount=type=cache,target=/root/.cache \ -DPDFENGINE_WITH_PDFIUM=ON \ -DPDFENGINE_WITH_SKIA=OFF \ -DPDFENGINE_WITH_QPDF=ON \ - -DCMAKE_TOOLCHAIN_FILE=${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake - -# Build all targets including tests and pdfengine_py -RUN cmake --build out/build/linux-release + -DCMAKE_TOOLCHAIN_FILE=${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake \ + -DCMAKE_SUPPRESS_REGENERATION=ON \ + && cmake --build out/build/linux-release # Stage 2: Tester FROM builder AS tester diff --git a/third_party/pdfium/README.md b/third_party/pdfium/README.md index 8281a85..90c6cd2 100644 --- a/third_party/pdfium/README.md +++ b/third_party/pdfium/README.md @@ -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 `/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 `/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. diff --git a/third_party/pdfium/build_pdfium.ps1 b/third_party/pdfium/build_pdfium.ps1 index 9975b78..9591bea 100644 --- a/third_party/pdfium/build_pdfium.ps1 +++ b/third_party/pdfium/build_pdfium.ps1 @@ -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 diff --git a/third_party/pdfium/build_pdfium.sh b/third_party/pdfium/build_pdfium.sh index 082e227..5a3c3d3 100644 --- a/third_party/pdfium/build_pdfium.sh +++ b/third_party/pdfium/build_pdfium.sh @@ -34,10 +34,12 @@ 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 +# 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=${MAX_ATTEMPTS:-5} - local -r -i initial_sleep=${INITIAL_SLEEP:-5} + 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 @@ -54,6 +56,21 @@ retry() { 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')" @@ -80,36 +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 -echo ">> gclient sync (pulls several GB; slow)" -retry gclient sync --no-history --shallow --reset --force mkdir -p out/Release cp "${SCRIPT_DIR}/args.gn" out/Release/args.gn -git fetch origin "${PDFIUM_COMMIT}" -retry git checkout --detach "${PDFIUM_COMMIT}" -retry gclient sync --no-history --shallow --reset --force -D echo ">> gn gen + ninja" gn gen out/Release ninja -C out/Release pdfium From 3f340e9689b0e5a74b3e81dd0b4eb81e3a4566e9 Mon Sep 17 00:00:00 2001 From: saqib mir Date: Sat, 22 Aug 2026 11:28:05 +0530 Subject: [PATCH 03/11] cusor issue --- frontend/src/App.tsx | 25 +- frontend/src/components/MergePDFModal.tsx | 405 ++++++++++++++++++ frontend/src/components/ToolRail.tsx | 11 + frontend/src/components/Toolbar.tsx | 8 + frontend/src/components/TopBar.tsx | 4 +- .../components/DocumentEditor.tsx | 19 +- .../model/PaginationEngine.ts | 42 +- frontend/src/lib/tools.ts | 3 +- frontend/src/viewer/ParagraphEditor.tsx | 47 +- gateway/app/routers/documents/crud.py | 81 +++- gateway/app/schemas/merge.py | 11 + gateway/app/services/pdf_merge.py | 79 ++++ gateway/pyproject.toml | 3 + gateway/tests/test_merge.py | 68 +++ 14 files changed, 772 insertions(+), 34 deletions(-) create mode 100644 frontend/src/components/MergePDFModal.tsx create mode 100644 gateway/app/schemas/merge.py create mode 100644 gateway/app/services/pdf_merge.py create mode 100644 gateway/tests/test_merge.py diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 844580c..3b5bb48 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { RedactPagesModal } from './components/RedactPagesModal'; import { AboutModal } from './components/AboutModal'; import { VersionHistoryModal } from './components/VersionHistoryModal'; import { ExportPDFModal } from './components/ExportPDFModal'; +import { MergePDFModal } from './components/MergePDFModal'; import { WatermarkModal, type WatermarkConfig } from './components/WatermarkModal'; import { triggerPDFDownload } from './lib/pdfExport'; @@ -94,6 +95,9 @@ function App() { if (activeTool === 'create_pdf') { setCreatePdfModalOpen(true); setActiveTool('select'); + } else if (activeTool === 'merge_pdf') { + setMergeModalOpen(true); + setActiveTool('select'); } }, [activeTool]); @@ -120,10 +124,16 @@ function App() { const [protectModalState, setProtectModalState] = useState(null); const [unlockModalState, setUnlockModalState] = useState(null); const [compareModalOpen, setCompareModalOpen] = useState(false); + const [mergeModalOpen, setMergeModalOpen] = useState(false); const [compareResult, setCompareResult] = useState(null); const [compareDocA, setCompareDocA] = useState(null); const [compareDocB, setCompareDocB] = useState(null); + const handleMergeCompleted = (docInfo: DocumentInfo) => { + setDocuments((prev) => [...prev, docInfo]); + openDocument(docInfo.id); + }; + const handleOpenCompareModal = () => { if (!activeDoc) return; setCompareDocA({ @@ -845,7 +855,7 @@ function App() { userPassword: string; ownerPassword?: string; confirmPassword: string; - permissions: any; + permissions?: any; }) => { const isCreatorActive = activeTool === 'create_pdf' || createPdfModalOpen || selectedDocId === 'new-blank-creator' || !selectedDocId; try { @@ -870,7 +880,10 @@ function App() { throw new Error('No document available to protect'); } - const updatedDoc = await gatewayService.protectDocument(targetDocId, payload); + const updatedDoc = await gatewayService.protectDocument(targetDocId, { + ...payload, + permissions: payload.permissions || {}, + }); setDocuments((prev) => prev.map((d) => (d.id === targetDocId ? updatedDoc : d))); setActiveDoc(updatedDoc); setProtectModalState(null); @@ -1087,6 +1100,7 @@ function App() { }} onUnlock={() => selectedDocId && setUnlockModalState({ documentId: selectedDocId, filename: activeDoc?.filename || 'Document.pdf' })} onCompare={handleOpenCompareModal} + onMergePDF={() => setMergeModalOpen(true)} isEncrypted={activeDoc?.permissions?.isEncrypted ?? false} canPrint={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canPrint')} canExport={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canCopy')} @@ -1463,6 +1477,13 @@ function App() { renderPageUrl={(docId, pageIdx, dpi) => gatewayService.getPageRenderUrl(docId, pageIdx, dpi)} /> )} + + setMergeModalOpen(false)} + onMergeComplete={handleMergeCompleted} + apiBaseUrl={import.meta.env.VITE_GATEWAY_URL || 'http://localhost:8000'} + /> ); } diff --git a/frontend/src/components/MergePDFModal.tsx b/frontend/src/components/MergePDFModal.tsx new file mode 100644 index 0000000..3abc5a3 --- /dev/null +++ b/frontend/src/components/MergePDFModal.tsx @@ -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 = ({ + isOpen, + onClose, + onMergeComplete, + apiBaseUrl = 'http://localhost:8000', +}) => { + const [files, setFiles] = useState([]); + const [outputFilename, setOutputFilename] = useState('merged_document.pdf'); + const [isMerging, setIsMerging] = useState(false); + const [error, setError] = useState(null); + const [mergedResult, setMergedResult] = useState(null); + const fileInputRef = useRef(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 ( +
+
+ {/* Header */} +
+
+
+ + + +
+
+

Merge PDF Files

+

Combine multiple PDFs into a single, ordered document

+
+
+ +
+ + {/* Content Body */} +
+ {mergedResult ? ( + /* Success View */ +
+
+ + + +
+
+

PDFs Merged Successfully!

+

+ Merged {files.length} document{files.length > 1 ? 's' : ''} into {mergedResult.filename} ({mergedResult.totalPages} total pages). +

+
+ +
+ + Download Merged PDF + + + Open in PDF Editor + +
+ + +
+ ) : ( + /* Upload & Configuration Form */ + <> + {error && ( +
+ {error} + +
+ )} + + {/* Upload Dropzone */} +
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" + > + handleAddFiles(e.target.files)} + /> +
+ +
+ Click or drop PDF files here + Select multiple PDF files to combine +
+ + {/* File Queue List */} + {files.length > 0 && ( +
+
+ + Merge Sequence ({files.length} {files.length === 1 ? 'file' : 'files'}) + + +
+ +
+ {files.map((item, index) => ( +
+ {/* Left Info */} +
+
+ + +
+ + + {index + 1} + + +
+

+ {item.file.name} +

+

+ {formatFileSize(item.file.size)} +

+
+
+ + {/* Right Page Controls */} +
+
+ + +
+ + {item.pagesMode === 'custom' && ( + 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" + /> + )} + + +
+
+ ))} +
+
+ )} + + {/* Output Filename Field */} +
+ + 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" + /> +
+ + )} +
+ + {/* Footer */} + {!mergedResult && ( +
+ { onClose(); handleReset(); }} + disabled={isMerging} + > + Cancel + + + {isMerging ? ( + <> + Merging... + + ) : ( + `Merge ${files.length > 0 ? `(${files.length})` : ''} PDFs` + )} + +
+ )} +
+
+ ); +}; diff --git a/frontend/src/components/ToolRail.tsx b/frontend/src/components/ToolRail.tsx index 6a8fe8c..03bf637 100644 --- a/frontend/src/components/ToolRail.tsx +++ b/frontend/src/components/ToolRail.tsx @@ -34,6 +34,17 @@ const TOOLS: (ToolDef | 'divider')[] = [ ), }, + { + id: 'merge_pdf', + label: 'Merge PDFs', + shortLabel: 'Merge', + shortcut: 'G', + icon: ( + + + + ), + }, { id: 'comment', label: 'Comment', shortcut: 'C', icon: }, { id: 'textbox', label: 'Text box', shortLabel: 'Text', shortcut: 'T', icon: }, { diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 061b58e..bc1eb5c 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -82,6 +82,14 @@ const TOOL_META: Record = { ), }, + merge_pdf: { + label: 'Merge PDFs', + icon: ( + + + + ), + }, }; const Hint: React.FC<{ children: React.ReactNode; tone?: 'normal' | 'warn' }> = ({ children, tone = 'normal' }) => ( diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index a479e15..9ea9fc6 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -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 = ({ 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(null); @@ -82,6 +83,7 @@ export const TopBar: React.FC = ({
} onClick={() => onNewBlankPDF?.()}>New Blank PDF… } onClick={() => fileRef.current?.click()}>Open PDF… + } onClick={() => onMergePDF?.()}>Merge PDFs… } onClick={onExport} disabled={!documentName || !canExport}>Export / Download } onClick={onPrint} disabled={!documentName || !canPrint}>Print } onClick={() => onCompare?.()} disabled={!documentName}>Compare PDFs… diff --git a/frontend/src/features/document-creator/components/DocumentEditor.tsx b/frontend/src/features/document-creator/components/DocumentEditor.tsx index f3536a1..0695b46 100644 --- a/frontend/src/features/document-creator/components/DocumentEditor.tsx +++ b/frontend/src/features/document-creator/components/DocumentEditor.tsx @@ -109,8 +109,9 @@ const EditableParagraphBlock: React.FC<{ return (
onSelectBlock(block.id, firstRun.id)} - className={`relative rounded px-2 py-1 transition-all ${isSelected ? 'bg-blue-50/40 ring-1 ring-brand-primary/50' : 'hover:bg-slate-50' - }`} + className={`relative rounded px-2.5 py-1.5 transition-all ${ + isSelected ? 'bg-blue-50/40 ring-1 ring-brand-primary/50' : 'hover:bg-slate-50' + }`} style={{ textAlign: block.alignment || 'left', marginTop: `${block.spaceBefore || 0}px`, @@ -140,6 +141,9 @@ const EditableParagraphBlock: React.FC<{ color: firstRun.color || '#0f172a', backgroundColor: firstRun.highlightColor || 'transparent', lineHeight: block.lineSpacing || 1.25, + wordBreak: 'break-word', + overflowWrap: 'break-word', + whiteSpace: 'pre-wrap', }} /> @@ -151,7 +155,7 @@ const EditableParagraphBlock: React.FC<{ e.stopPropagation(); onRemoveBlock(block.id); }} - className="absolute -right-6 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-100 text-red-600 hover:bg-red-200 text-[10px]" + className="absolute -top-2.5 -right-2.5 z-20 flex h-5.5 w-5.5 items-center justify-center rounded-full bg-white text-red-500 hover:bg-red-50 hover:text-red-700 border border-slate-300 shadow-sm text-[11px] font-extrabold cursor-pointer transition-transform hover:scale-110" > ✕ @@ -800,21 +804,26 @@ export const DocumentEditor: React.FC = ({ 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 (
handleMouseDownPage(e, pageIdx)} onMouseMove={(e) => handleMouseMovePage(e, pageIdx)} - className={`relative bg-white shadow-xl transition-shadow border border-slate-200 flex flex-col ${ + className={`shrink-0 relative bg-white shadow-xl transition-shadow border border-slate-200 flex flex-col overflow-hidden ${ isDrawTool ? 'cursor-crosshair' : '' }`} style={{ width: `${pageW}px`, + height: `${pageH}px`, minHeight: `${pageH}px`, + maxHeight: `${pageH}px`, paddingTop: `${padTop}px`, paddingBottom: `${padBottom}px`, paddingLeft: `${padLeft}px`, paddingRight: `${padRight}px`, + boxSizing: 'border-box', }} > {/* Ink Drawing Overlay */} @@ -840,7 +849,7 @@ export const DocumentEditor: React.FC = ({ {/* Page Content Blocks */}
{ // Only trigger if the click landed directly on this container (the empty area below blocks) if (e.target === e.currentTarget) { diff --git a/frontend/src/features/document-creator/model/PaginationEngine.ts b/frontend/src/features/document-creator/model/PaginationEngine.ts index 2e00bdb..67818d4 100644 --- a/frontend/src/features/document-creator/model/PaginationEngine.ts +++ b/frontend/src/features/document-creator/model/PaginationEngine.ts @@ -18,7 +18,7 @@ export class PaginationEngine { const pages: PageLayout[] = []; // Usable height per page in screen pixels (96 DPI) - const headerFooterHeightPt = (doc.header.enabled ? 36 : 0) + (doc.footer.enabled ? 36 : 0); + const headerFooterHeightPt = (doc.header.enabled ? 36 : 0) + (doc.footer.enabled ? 36 : 30); const usableHeightPx = (settings.heightPt - settings.marginTopPt - settings.marginBottomPt - headerFooterHeightPt) * PT_TO_PX; let currentPageBlocks: DocumentBlock[] = []; @@ -27,10 +27,12 @@ export class PaginationEngine { for (const block of blocks) { if (block.type === 'page-break') { - // Explicit manual page break — always start a new page - pages.push({ pageNumber: pageNum++, blocks: currentPageBlocks }); - currentPageBlocks = []; - currentHeightPx = 0; + // Explicit manual page break — start new page if current page has content + if (currentPageBlocks.length > 0) { + pages.push({ pageNumber: pageNum++, blocks: currentPageBlocks }); + currentPageBlocks = []; + currentHeightPx = 0; + } continue; } @@ -59,28 +61,38 @@ export class PaginationEngine { if (block.type === 'table') { const rowCount = block.rows.length; - return Math.max(50, rowCount * (30 * PT_TO_PX) + (12 * PT_TO_PX)); + return Math.max(50, rowCount * (30 * PT_TO_PX) + (16 * PT_TO_PX)); } if (block.type === 'image') { - return Math.min(block.height + (20 * PT_TO_PX), 500 * PT_TO_PX); + return Math.min(block.height + (16 * PT_TO_PX), 500 * PT_TO_PX); } if (block.type === 'paragraph' || block.type === 'heading' || block.type === 'list-item' || block.type === 'quote') { const fullText = block.runs.map((r) => r.text).join(''); - if (!fullText) return 24 * PT_TO_PX; // Empty paragraph height - const fontSizePt = block.runs[0]?.fontSize || 12; const fontSizePx = fontSizePt * PT_TO_PX; - const lineSpacing = block.lineSpacing || 1.15; - const avgCharWidthPx = fontSizePx * 0.55; + const lineSpacing = block.lineSpacing || 1.25; + const avgCharWidthPx = fontSizePx * 0.52; const charsPerLine = Math.max(1, Math.floor(usableWidthPx / avgCharWidthPx)); - const lineCount = Math.ceil(fullText.length / charsPerLine); - const spaceBeforePx = (block.spaceBefore || 0) * PT_TO_PX; - const spaceAfterPx = (block.spaceAfter || 6) * PT_TO_PX; + const paragraphs = fullText.split('\n'); + let totalLines = 0; + for (const p of paragraphs) { + if (!p) { + totalLines += 1; + } else { + totalLines += Math.max(1, Math.ceil(p.length / charsPerLine)); + } + } - return Math.max(20 * PT_TO_PX, lineCount * (fontSizePx * lineSpacing) + spaceBeforePx + spaceAfterPx); + const paddingPx = 4; + const spaceBeforePx = (block.spaceBefore || 0) * PT_TO_PX; + const spaceAfterPx = (block.spaceAfter || 4) * PT_TO_PX; + const flexGapPx = 4; + + const contentHeight = totalLines * (fontSizePx * lineSpacing) + paddingPx + spaceBeforePx + spaceAfterPx + flexGapPx; + return Math.max(24 * PT_TO_PX, contentHeight); } return 24 * PT_TO_PX; diff --git a/frontend/src/lib/tools.ts b/frontend/src/lib/tools.ts index 407d754..7b7b2f6 100644 --- a/frontend/src/lib/tools.ts +++ b/frontend/src/lib/tools.ts @@ -15,7 +15,8 @@ export type ToolId = | 'squiggly' | 'stream_edit' | 'create_pdf' - | 'watermark'; + | 'watermark' + | 'merge_pdf'; export interface ToolSettings { highlightColor: string; diff --git a/frontend/src/viewer/ParagraphEditor.tsx b/frontend/src/viewer/ParagraphEditor.tsx index 72ffdcd..361b608 100644 --- a/frontend/src/viewer/ParagraphEditor.tsx +++ b/frontend/src/viewer/ParagraphEditor.tsx @@ -541,7 +541,7 @@ function layoutFromOrigLines( export const ParagraphEditor: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ }))); } + // 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 = ({ } } 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 = ({ 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); } diff --git a/gateway/app/routers/documents/crud.py b/gateway/app/routers/documents/crud.py index 5623e8e..93fbf2e 100644 --- a/gateway/app/routers/documents/crud.py +++ b/gateway/app/routers/documents/crud.py @@ -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) \ No newline at end of file + 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}", + ) \ No newline at end of file diff --git a/gateway/app/schemas/merge.py b/gateway/app/schemas/merge.py new file mode 100644 index 0000000..d7e4f80 --- /dev/null +++ b/gateway/app/schemas/merge.py @@ -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") diff --git a/gateway/app/services/pdf_merge.py b/gateway/app/services/pdf_merge.py new file mode 100644 index 0000000..95533a7 --- /dev/null +++ b/gateway/app/services/pdf_merge.py @@ -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() diff --git a/gateway/pyproject.toml b/gateway/pyproject.toml index 3c2199a..72e1efd 100644 --- a/gateway/pyproject.toml +++ b/gateway/pyproject.toml @@ -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] diff --git a/gateway/tests/test_merge.py b/gateway/tests/test_merge.py new file mode 100644 index 0000000..e17a700 --- /dev/null +++ b/gateway/tests/test_merge.py @@ -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 From 83f47488ab32f52c5ff27012fbf067cb69e9554c Mon Sep 17 00:00:00 2001 From: saqib mir Date: Sat, 22 Aug 2026 12:39:00 +0530 Subject: [PATCH 04/11] fix the issue --- frontend/src/App.tsx | 314 ++++++++++++------ frontend/src/components/ToolRail.tsx | 10 +- .../src/components/UnsavedChangesModal.tsx | 100 ++++++ .../components/CreatePDFModal.tsx | 2 + frontend/src/lib/gatewayService.ts | 14 +- 5 files changed, 322 insertions(+), 118 deletions(-) create mode 100644 frontend/src/components/UnsavedChangesModal.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3b5bb48..9a22900 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,8 @@ 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'; @@ -67,13 +69,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(null); const [creatorActions, setCreatorActions] = useState<{ canUndo: boolean; canRedo: boolean; @@ -94,7 +90,6 @@ function App() { useEffect(() => { if (activeTool === 'create_pdf') { setCreatePdfModalOpen(true); - setActiveTool('select'); } else if (activeTool === 'merge_pdf') { setMergeModalOpen(true); setActiveTool('select'); @@ -129,6 +124,191 @@ function App() { const [compareDocA, setCompareDocA] = useState(null); const [compareDocB, setCompareDocB] = useState(null); + + const [isInspectorExpanded, setIsInspectorExpanded] = useState(false); + const [activeStamp, setActiveStamp] = useState(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([]); + const [searchCurrentMatch, setSearchCurrentMatch] = useState(0); + + const [isOCRLoading, setIsOCRLoading] = useState(false); + const [creatorPageCount, setCreatorPageCount] = useState(1); + const [creatorPages, setCreatorPages] = useState([]); + const [watermarkPreview, setWatermarkPreview] = useState(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); @@ -184,46 +364,6 @@ function App() { }); } }; - const [isInspectorExpanded, setIsInspectorExpanded] = useState(false); - const [activeStamp, setActiveStamp] = useState(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([]); - const [searchCurrentMatch, setSearchCurrentMatch] = useState(0); - - const [isOCRLoading, setIsOCRLoading] = useState(false); - const [creatorPageCount, setCreatorPageCount] = useState(1); - const [creatorPages, setCreatorPages] = useState([]); - const [watermarkPreview, setWatermarkPreview] = useState(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 })); @@ -800,56 +940,9 @@ 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; @@ -939,23 +1032,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') { @@ -964,7 +1053,6 @@ function App() { } if (tool === 'comment') { creatorActions?.insertComment?.(); - setActiveTool('select'); return; } if (tool === 'draw') { @@ -1086,7 +1174,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} @@ -1184,12 +1272,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 +1540,8 @@ function App() { setConfirmState(null)} /> + setUnsavedModalState(null)} /> + { if (passwordPrompt) handleUpload(passwordPrompt.file, pw); }} diff --git a/frontend/src/components/ToolRail.tsx b/frontend/src/components/ToolRail.tsx index 03bf637..3edae80 100644 --- a/frontend/src/components/ToolRail.tsx +++ b/frontend/src/components/ToolRail.tsx @@ -122,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-[#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' }`} > {active && !disabled && } diff --git a/frontend/src/components/UnsavedChangesModal.tsx b/frontend/src/components/UnsavedChangesModal.tsx new file mode 100644 index 0000000..2b6937d --- /dev/null +++ b/frontend/src/components/UnsavedChangesModal.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react'; +import { CustomButton } from './custom/CustomButton'; + +export interface UnsavedChangesModalState { + title?: string; + message?: string; + onSaveAndContinue: () => Promise | void; + onDiscardAndContinue: () => void; +} + +interface UnsavedChangesModalProps { + state: UnsavedChangesModalState | null; + onClose: () => void; +} + +export const UnsavedChangesModal: React.FC = ({ 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 ( +
+
+ +
e.stopPropagation()} + > +
+
+
+ + + +
+
+

{title}

+

Document Protection

+
+
+

{message}

+
+ +
+ + Cancel + + + + Discard + + + + {isSaving ? 'Saving...' : 'Save & Continue'} + +
+
+
+ ); +}; diff --git a/frontend/src/features/document-creator/components/CreatePDFModal.tsx b/frontend/src/features/document-creator/components/CreatePDFModal.tsx index 9f503e2..043d369 100644 --- a/frontend/src/features/document-creator/components/CreatePDFModal.tsx +++ b/frontend/src/features/document-creator/components/CreatePDFModal.tsx @@ -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; @@ -684,6 +685,7 @@ export const CreatePDFModal: React.FC = ({ 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); diff --git a/frontend/src/lib/gatewayService.ts b/frontend/src/lib/gatewayService.ts index d34bf10..9bc86fa 100644 --- a/frontend/src/lib/gatewayService.ts +++ b/frontend/src/lib/gatewayService.ts @@ -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 { - 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 { From eb514195bfd531d3893b241dcc9c98197555f8b9 Mon Sep 17 00:00:00 2001 From: saqib mir Date: Sat, 22 Aug 2026 13:16:15 +0530 Subject: [PATCH 05/11] fix the issue --- gateway/Dockerfile | 5 +++++ gateway/app/routers/ocr.py | 8 +++++++- gateway/app/services/ocr.py | 11 ++++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/gateway/Dockerfile b/gateway/Dockerfile index a55b9fd..732fa92 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -92,6 +92,11 @@ 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 diff --git a/gateway/app/routers/ocr.py b/gateway/app/routers/ocr.py index 370c90f..f30cf04 100644 --- a/gateway/app/routers/ocr.py +++ b/gateway/app/routers/ocr.py @@ -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: diff --git a/gateway/app/services/ocr.py b/gateway/app/services/ocr.py index 9c3f73f..9ab0926 100644 --- a/gateway/app/services/ocr.py +++ b/gateway/app/services/ocr.py @@ -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(): From f168b12c826dfa67afebc16df904a355a070046c Mon Sep 17 00:00:00 2001 From: saquib Date: Sat, 22 Aug 2026 13:43:16 +0530 Subject: [PATCH 06/11] fix --- .dockerignore | 1 + gateway/Dockerfile | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index 9e00d76..f9a33a6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,6 +16,7 @@ **/.idea **/coverage **/tmp +corpus/ **/.pytest_cache **/.mypy_cache **/.ruff_cache diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 732fa92..e09725f 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -55,7 +55,6 @@ COPY cmake/ ./cmake/ COPY engine/ ./engine/ COPY bindings/ ./bindings/ COPY gateway/ ./gateway/ -COPY corpus/ ./corpus/ # Configure and build in the same layer. Keeping these operations together # avoids Docker/overlayfs timestamp skew causing Ninja to regenerate From 39b125db9ad33456077aef44cd6f3ea4dbca9da5 Mon Sep 17 00:00:00 2001 From: saquib Date: Sat, 22 Aug 2026 13:57:57 +0530 Subject: [PATCH 07/11] docker build issue fix --- gateway/Dockerfile | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/gateway/Dockerfile b/gateway/Dockerfile index e09725f..9145155 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -64,7 +64,7 @@ COPY gateway/ ./gateway/ 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 \ @@ -72,11 +72,7 @@ RUN --mount=type=cache,target=/root/.cache \ -DCMAKE_SUPPRESS_REGENERATION=ON \ && 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 \ @@ -99,7 +95,7 @@ RUN apt-get update \ && 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 From f98d4fa934271e3f5d19e82c602b286c1f113176 Mon Sep 17 00:00:00 2001 From: saqib mir Date: Sat, 22 Aug 2026 14:49:06 +0530 Subject: [PATCH 08/11] fix the issue --- frontend/src/App.tsx | 19 +++++++++++++++---- frontend/src/lib/gatewayService.ts | 4 +++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9a22900..9148670 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -52,13 +52,20 @@ function App() { const canRedo = hist.index < hist.stack.length - 1; const preservePageRef = useRef(false); + const [isOcrDisabled, setIsOcrDisabled] = useState(false); const permissions = activeDoc?.permissions ?? null; const can = (flag: keyof PDFPermissions) => !permissions || permissions[flag] !== false; const denyToast = (_label: string) => { }; const disabledTools = new Set(); - 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)); + disabledTools.add('ocr'); // OCR & Edit button is permanently disabled + if (!activeDoc) { + (['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'edit_text', 'stream_edit', 'signature', 'stamp', 'watermark', 'redact'] as ToolId[]).forEach((t) => disabledTools.add(t)); + } else { + if (!can('canAnnotate')) + (['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'stamp', 'signature'] as ToolId[]).forEach((t) => disabledTools.add(t)); + if (!can('canModify')) + (['edit_text', 'stream_edit', 'redact'] as ToolId[]).forEach((t) => disabledTools.add(t)); + } const disabledToolsRef = useRef(disabledTools); disabledToolsRef.current = disabledTools; @@ -153,7 +160,11 @@ function App() { await gatewayService.performPageOCR(activeDoc.id, currentPage); viewerRef.current?.refreshPageLayout(currentPage); } catch (err: any) { - alert(`OCR processing failed: ${err.message || err}`); + const errMsg = err.message || String(err); + alert(`OCR processing failed: ${errMsg}`); + if (errMsg.toLowerCase().includes('not installed') || errMsg.toLowerCase().includes('not available')) { + setIsOcrDisabled(true); + } } finally { setIsOCRLoading(false); } diff --git a/frontend/src/lib/gatewayService.ts b/frontend/src/lib/gatewayService.ts index 9bc86fa..5642e0b 100644 --- a/frontend/src/lib/gatewayService.ts +++ b/frontend/src/lib/gatewayService.ts @@ -727,7 +727,9 @@ class GatewayService { method: 'POST', }); if (!response.ok) { - throw new Error(`OCR failed: ${response.statusText}`); + const errJson = await response.json().catch(() => null); + const detail = errJson?.detail || response.statusText; + throw new Error(detail); } return response.json(); } From b9a483917776100e74525b3d1dcacf0b667ec39a Mon Sep 17 00:00:00 2001 From: saqib mir Date: Sat, 22 Aug 2026 14:58:09 +0530 Subject: [PATCH 09/11] ignore the coprus file --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 714700b..e9eead8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ -# Build output +# Build output & generated directories /build/ /out/ +/corpus/ +/docs/ **/build/ **/cmake-build-*/ CMakeUserPresets.json @@ -21,6 +23,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/ From 904cf96bd6ba0f7db38be6579ec936899af15224 Mon Sep 17 00:00:00 2001 From: saqib mir Date: Sat, 22 Aug 2026 15:06:15 +0530 Subject: [PATCH 10/11] ignore the coprus file --- .gitignore | 4 +--- frontend/src/App.tsx | 20 +++++--------------- frontend/src/lib/gatewayService.ts | 4 +--- 3 files changed, 7 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index e9eead8..cb26f54 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ -# Build output & generated directories +# Build output /build/ /out/ -/corpus/ -/docs/ **/build/ **/cmake-build-*/ CMakeUserPresets.json diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9148670..4af7c75 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -52,20 +52,14 @@ function App() { const canRedo = hist.index < hist.stack.length - 1; const preservePageRef = useRef(false); - const [isOcrDisabled, setIsOcrDisabled] = useState(false); const permissions = activeDoc?.permissions ?? null; const can = (flag: keyof PDFPermissions) => !permissions || permissions[flag] !== false; const denyToast = (_label: string) => { }; const disabledTools = new Set(); - disabledTools.add('ocr'); // OCR & Edit button is permanently disabled - if (!activeDoc) { - (['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'edit_text', 'stream_edit', 'signature', 'stamp', 'watermark', 'redact'] as ToolId[]).forEach((t) => disabledTools.add(t)); - } else { - if (!can('canAnnotate')) - (['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'stamp', 'signature'] as ToolId[]).forEach((t) => disabledTools.add(t)); - if (!can('canModify')) - (['edit_text', 'stream_edit', 'redact'] as ToolId[]).forEach((t) => disabledTools.add(t)); - } + disabledTools.add('ocr'); + if (!can('canAnnotate')) + (['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'stamp', 'signature'] as ToolId[]).forEach((t) => disabledTools.add(t)); + if (!can('canModify')) (['edit_text', 'redact'] as ToolId[]).forEach((t) => disabledTools.add(t)); const disabledToolsRef = useRef(disabledTools); disabledToolsRef.current = disabledTools; @@ -160,11 +154,7 @@ function App() { await gatewayService.performPageOCR(activeDoc.id, currentPage); viewerRef.current?.refreshPageLayout(currentPage); } catch (err: any) { - const errMsg = err.message || String(err); - alert(`OCR processing failed: ${errMsg}`); - if (errMsg.toLowerCase().includes('not installed') || errMsg.toLowerCase().includes('not available')) { - setIsOcrDisabled(true); - } + alert(`OCR processing failed: ${err.message || err}`); } finally { setIsOCRLoading(false); } diff --git a/frontend/src/lib/gatewayService.ts b/frontend/src/lib/gatewayService.ts index 5642e0b..9bc86fa 100644 --- a/frontend/src/lib/gatewayService.ts +++ b/frontend/src/lib/gatewayService.ts @@ -727,9 +727,7 @@ class GatewayService { method: 'POST', }); if (!response.ok) { - const errJson = await response.json().catch(() => null); - const detail = errJson?.detail || response.statusText; - throw new Error(detail); + throw new Error(`OCR failed: ${response.statusText}`); } return response.json(); } From 4256ac8128cb3f3f710a0076583b522d0ab5e36b Mon Sep 17 00:00:00 2001 From: saqib mir Date: Sat, 22 Aug 2026 15:29:16 +0530 Subject: [PATCH 11/11] fix the disable feature and remove the block in the new pdf --- frontend/src/components/ToolRail.tsx | 2 +- .../components/CreatePDFModal.tsx | 58 +- .../components/DocumentEditor.tsx | 609 +++++++++--------- .../components/DocumentToolbar.tsx | 75 ++- .../model/PaginationEngine.ts | 5 +- .../renderer/PdfDocumentRenderer.ts | 10 +- .../document-creator/types/documentModel.ts | 6 +- 7 files changed, 375 insertions(+), 390 deletions(-) diff --git a/frontend/src/components/ToolRail.tsx b/frontend/src/components/ToolRail.tsx index 3edae80..eb883cf 100644 --- a/frontend/src/components/ToolRail.tsx +++ b/frontend/src/components/ToolRail.tsx @@ -122,7 +122,7 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; is aria-disabled={disabled} onClick={onClick} className={`relative flex h-[52px] w-[72px] shrink-0 flex-col items-center justify-center gap-[3px] rounded-[10px] transition-colors ${disabled - ? 'cursor-not-allowed text-[#c5cad1]' + ? 'cursor-not-allowed text-text-tertiary opacity-40 dark:text-zinc-600 dark:opacity-60' : active ? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-brand-secondary text-brand-primary' : t.danger ? 'text-text-secondary hover:bg-[#fdecec] hover:text-[#dc2626]' diff --git a/frontend/src/features/document-creator/components/CreatePDFModal.tsx b/frontend/src/features/document-creator/components/CreatePDFModal.tsx index 043d369..596166d 100644 --- a/frontend/src/features/document-creator/components/CreatePDFModal.tsx +++ b/frontend/src/features/document-creator/components/CreatePDFModal.tsx @@ -60,25 +60,7 @@ const INITIAL_DOC: DocumentModel = { enabled: true, showPageNumbers: true, }, - blocks: [ - { - id: 'blk-1', - type: 'paragraph', - styleName: 'Normal', - runs: [ - { - id: 'run-1', - text: '', - fontFamily: 'Helvetica', - fontSize: 12, - color: '#0f172a', - }, - ], - alignment: 'left', - spaceBefore: 0, - spaceAfter: 6, - }, - ], + blocks: [], }; export const CreatePDFModal: React.FC = ({ @@ -97,8 +79,8 @@ export const CreatePDFModal: React.FC = ({ }) => { const historyRef = useRef(new HistoryManager(INITIAL_DOC)); const [doc, setDoc] = useState(INITIAL_DOC); - const [selectedBlockId, setSelectedBlockId] = useState('blk-1'); - const [activeRunId, setActiveRunId] = useState('run-1'); + const [selectedBlockId, setSelectedBlockId] = useState(null); + const [activeRunId, setActiveRunId] = useState(null); const [zoomScale, setZoomScale] = useState(1.0); const [isGenerating, setIsGenerating] = useState(false); @@ -117,37 +99,12 @@ export const CreatePDFModal: React.FC = ({ 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('[data-block-id="blk-1"]'); - if (el) { - el.focus(); - } - }, 50); + setSelectedBlockId(null); + setActiveRunId(null); } }, [isOpen]); @@ -331,7 +288,7 @@ export const CreatePDFModal: React.FC = ({ ], alignment: 'left', spaceBefore: 0, - spaceAfter: 6, + spaceAfter: 2, }; let nextBlocks = [...doc.blocks]; @@ -725,6 +682,7 @@ export const CreatePDFModal: React.FC = ({ doc={doc} selectedBlockId={selectedBlockId} activeRun={activeRun} + activeParagraph={activeParagraph} activeTool={activeTool} drawColor={drawColor} drawWidth={drawWidth} diff --git a/frontend/src/features/document-creator/components/DocumentEditor.tsx b/frontend/src/features/document-creator/components/DocumentEditor.tsx index 0695b46..c3b87bb 100644 --- a/frontend/src/features/document-creator/components/DocumentEditor.tsx +++ b/frontend/src/features/document-creator/components/DocumentEditor.tsx @@ -109,13 +109,11 @@ const EditableParagraphBlock: React.FC<{ return (
onSelectBlock(block.id, firstRun.id)} - className={`relative rounded px-2.5 py-1.5 transition-all ${ - isSelected ? 'bg-blue-50/40 ring-1 ring-brand-primary/50' : 'hover:bg-slate-50' - }`} + className="relative py-0.5" style={{ textAlign: block.alignment || 'left', marginTop: `${block.spaceBefore || 0}px`, - marginBottom: `${block.spaceAfter || 4}px`, + marginBottom: `${block.spaceAfter !== undefined ? block.spaceAfter : 2}px`, }} >
onUpdateParagraphText(block.id, e.currentTarget.innerText)} onBlur={(e) => onUpdateParagraphText(block.id, e.currentTarget.innerText)} onKeyDown={handleKeyDown} - className="outline-none min-h-[1.5em] empty:before:content-[attr(data-placeholder)] empty:before:text-slate-300 empty:before:pointer-events-none" - data-placeholder="Start typing paragraph text..." + className="outline-none min-h-[1.5em]" + data-placeholder="" style={{ fontFamily: fontStack, fontSize: `${firstRun.fontSize || 12}pt`, @@ -140,26 +138,13 @@ const EditableParagraphBlock: React.FC<{ ].filter(Boolean).join(' ') || 'none', color: firstRun.color || '#0f172a', backgroundColor: firstRun.highlightColor || 'transparent', - lineHeight: block.lineSpacing || 1.25, + lineHeight: block.lineSpacing ? (block.lineSpacing >= 1.0 ? block.lineSpacing : 1.15 + block.lineSpacing) : 1.15, + letterSpacing: firstRun.letterSpacing !== undefined ? `${firstRun.letterSpacing}px` : (block.letterSpacing !== undefined ? `${block.letterSpacing}px` : 'normal'), wordBreak: 'break-word', overflowWrap: 'break-word', whiteSpace: 'pre-wrap', }} /> - - {isSelected && ( - - )}
); }; @@ -237,13 +222,12 @@ const EditableImageBlock: React.FC<{ {/* Floating Quick Action Toolbar */} {isSelected && (
- - {/* Striped Rows Toggle */} - - -
- - {/* Theme Preset Selector */} - Theme: - - -
- - {/* Row / Col Manipulations */} - - - - - - {activeCell && ( - <> -
- - {/* Cell Alignment */} - Cell Align: - - - - - - - - )} - -
- - Fill: - {colors.map((c) => ( - -
-
- )} - - - - {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 && ( +
+ Table: - return ( -
{ + 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 + + + {/* Striped Rows Toggle */} + + +
+ + {/* Theme Preset Selector */} + Theme: + + +
+ + {/* Row / Col Manipulations */} + + + + + + {activeCell && ( + <> +
+ + {/* Cell Alignment */} + Cell Align: + + + + + + + + )} + +
+ + Fill: + {colors.map((c) => ( + +
+
+ )} - const cellBg = - cell.backgroundColor || - (isHeaderRow +
+ + {block.rows.map((row, rIdx) => { + const isHeaderRow = (block.hasHeaderRow && rIdx === 0) || row.isHeader; + const isStriped = block.stripedRows && rIdx % 2 === 1; + + return ( + + {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 ( - - ); - })} - - ); - })} - -
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 }} - > - onUpdateTableCell?.(block.id, rIdx, cIdx, text)} - onFocus={() => setActiveCell({ r: rIdx, c: cIdx })} - /> -
+ const cellTextColor = isHeaderRow && themeName === 'modern' ? '#ffffff' : '#0f172a'; + + return ( + 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 }} + > + onUpdateTableCell?.(block.id, rIdx, cIdx, text)} + onFocus={() => setActiveCell({ r: rIdx, c: cIdx })} + /> + + ); + })} + + ); + })} + + +
-
- ); -}; + ); + }; export const DocumentEditor: React.FC = ({ doc, @@ -811,9 +789,8 @@ export const DocumentEditor: React.FC = ({ key={`page-${page.pageNumber}`} onMouseDown={(e) => handleMouseDownPage(e, pageIdx)} onMouseMove={(e) => handleMouseMovePage(e, pageIdx)} - className={`shrink-0 relative bg-white shadow-xl transition-shadow border border-slate-200 flex flex-col overflow-hidden ${ - isDrawTool ? 'cursor-crosshair' : '' - }`} + className={`shrink-0 relative bg-white shadow-xl transition-shadow border border-slate-200 flex flex-col overflow-hidden ${isDrawTool ? 'cursor-crosshair' : '' + }`} style={{ width: `${pageW}px`, height: `${pageH}px`, diff --git a/frontend/src/features/document-creator/components/DocumentToolbar.tsx b/frontend/src/features/document-creator/components/DocumentToolbar.tsx index bb0f3fa..36e205c 100644 --- a/frontend/src/features/document-creator/components/DocumentToolbar.tsx +++ b/frontend/src/features/document-creator/components/DocumentToolbar.tsx @@ -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 = ({ selectedBlockId, activeRun, + activeParagraph, activeTool = 'select', drawColor = '#2563eb', drawWidth = 3, @@ -86,9 +88,8 @@ export const DocumentToolbar: React.FC = ({ 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 = ({ 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 = ({
+ {/* Line Gap / Line Spacing */} +
+ ↕ Line Gap: + +
+ + {/* Letter Space / Letter Spacing */} +
+ ↔ Letter Space: + +
+ +
+ {/* Bold / Italic / Underline / Strikethrough */} @@ -221,9 +263,8 @@ export const DocumentToolbar: React.FC = ({ 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 @@ -232,9 +273,8 @@ export const DocumentToolbar: React.FC = ({ 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 @@ -243,9 +283,8 @@ export const DocumentToolbar: React.FC = ({ 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 diff --git a/frontend/src/features/document-creator/model/PaginationEngine.ts b/frontend/src/features/document-creator/model/PaginationEngine.ts index 67818d4..ede53f7 100644 --- a/frontend/src/features/document-creator/model/PaginationEngine.ts +++ b/frontend/src/features/document-creator/model/PaginationEngine.ts @@ -73,7 +73,8 @@ export class PaginationEngine { const fontSizePt = block.runs[0]?.fontSize || 12; const fontSizePx = fontSizePt * PT_TO_PX; const lineSpacing = block.lineSpacing || 1.25; - const avgCharWidthPx = fontSizePx * 0.52; + const letterSpacingPx = ((block.runs[0]?.letterSpacing !== undefined ? block.runs[0].letterSpacing : block.letterSpacing) || 0) * PT_TO_PX; + const avgCharWidthPx = fontSizePx * 0.52 + letterSpacingPx; const charsPerLine = Math.max(1, Math.floor(usableWidthPx / avgCharWidthPx)); const paragraphs = fullText.split('\n'); @@ -88,7 +89,7 @@ export class PaginationEngine { const paddingPx = 4; const spaceBeforePx = (block.spaceBefore || 0) * PT_TO_PX; - const spaceAfterPx = (block.spaceAfter || 4) * PT_TO_PX; + const spaceAfterPx = (block.spaceAfter !== undefined ? block.spaceAfter : 2) * PT_TO_PX; const flexGapPx = 4; const contentHeight = totalLines * (fontSizePx * lineSpacing) + paddingPx + spaceBeforePx + spaceAfterPx + flexGapPx; diff --git a/frontend/src/features/document-creator/renderer/PdfDocumentRenderer.ts b/frontend/src/features/document-creator/renderer/PdfDocumentRenderer.ts index 222b1bd..15565c4 100644 --- a/frontend/src/features/document-creator/renderer/PdfDocumentRenderer.ts +++ b/frontend/src/features/document-creator/renderer/PdfDocumentRenderer.ts @@ -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) => { diff --git a/frontend/src/features/document-creator/types/documentModel.ts b/frontend/src/features/document-creator/types/documentModel.ts index 6d4fe35..7fea089 100644 --- a/frontend/src/features/document-creator/types/documentModel.ts +++ b/frontend/src/features/document-creator/types/documentModel.ts @@ -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 = { color: '#0f172a', alignment: 'left', spaceBefore: 0, - spaceAfter: 6, + spaceAfter: 2, }, Title: { name: 'Title',