diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index d56c462d05..077704d235 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -47,21 +47,19 @@ jobs: "windows") echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT ;; - # "macos") - # echo 'matrix={"include":[{"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT - # ;; + "macos") + echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT + ;; "linux") echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT ;; *) - echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT - # Disabled Mac builds: {"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"} + echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT ;; esac else # For PR/push events, build all platforms - echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT - # Disabled Mac builds: {"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"} + echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT fi build: @@ -96,7 +94,7 @@ jobs: uses: dtolnay/rust-toolchain@stable with: toolchain: stable - targets: ${{ (matrix.platform == 'macos-latest' || matrix.platform == 'macos-13') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} @@ -183,80 +181,96 @@ jobs: working-directory: ./frontend run: npm install - # Disabled Mac builds - Import Apple Developer Certificate - # - name: Import Apple Developer Certificate - # if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13' - # env: - # APPLE_ID: ${{ secrets.APPLE_ID }} - # APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} - # APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - # APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - # KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - # run: | - # echo "Importing Apple Developer Certificate..." - # echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12 - # security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain - # security default-keychain -s build.keychain - # security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain - # security set-keychain-settings -t 3600 -u build.keychain - # security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign - # security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain - # security find-identity -v -p codesigning build.keychain - # - name: Verify Certificate - # if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13' - # run: | - # echo "Verifying Apple Developer Certificate..." - # CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application") - # echo "Certificate Info: $CERT_INFO" - # CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') - # echo "Certificate ID: $CERT_ID" - # echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV - # echo "Certificate imported." + - name: Import Apple Developer Certificate + if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + echo "Importing Apple Developer Certificate..." + echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12 + # Create temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + # Import certificate + security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security list-keychain -d user -s $KEYCHAIN_PATH + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + # Clean up + rm certificate.p12 - # - name: Check DMG creation dependencies (macOS only) - # if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13' - # run: | - # echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..." - # echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')" - # echo "create-dmg availability: $(which create-dmg || echo 'NOT FOUND')" - # echo "Available disk space: $(df -h /tmp | tail -1)" - # echo "macOS version: $(sw_vers -productVersion)" - # echo "Available tools:" - # ls -la /usr/bin/hd* || echo "No hd* tools found" + - name: Verify Certificate + if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel' + run: | + echo "Verifying Apple Developer Certificate..." + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application") + echo "Certificate Info: $CERT_INFO" + CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') + echo "Certificate ID: $CERT_ID" + echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV + echo "Certificate imported successfully." - - name: Build Tauri app + - name: Check DMG creation dependencies (macOS only) + if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel' + run: | + echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..." + echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')" + echo "create-dmg availability: $(which create-dmg || echo 'NOT FOUND')" + echo "Available disk space: $(df -h /tmp | tail -1)" + echo "macOS version: $(sw_vers -productVersion)" + echo "Available tools:" + ls -la /usr/bin/hd* || echo "No hd* tools found" + + - name: Build Tauri app uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }} + APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }} SIGN: 1 - CI: true + CI: true with: projectPath: ./frontend tauriScript: npx tauri args: ${{ matrix.args }} - + + - name: Verify notarization (macOS only) + if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel' + run: | + echo "🔍 Verifying notarization status..." + cd ./frontend/src-tauri/target + DMG_FILE=$(find . -name "*.dmg" | head -1) + if [ -n "$DMG_FILE" ]; then + echo "Found DMG: $DMG_FILE" + echo "Checking notarization ticket..." + spctl -a -vvv -t install "$DMG_FILE" || echo "âš ī¸ Notarization check failed or not yet complete" + stapler validate "$DMG_FILE" || echo "âš ī¸ No notarization ticket attached" + else + echo "âš ī¸ No DMG file found to verify" + fi + - name: Rename artifacts shell: bash run: | mkdir -p ./dist cd ./frontend/src-tauri/target - + # Find and rename artifacts based on platform if [ "${{ matrix.platform }}" = "windows-latest" ]; then find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \; find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \; - # Disabled Mac builds - # elif [ "${{ matrix.platform }}" = "macos-latest" ] || [ "${{ matrix.platform }}" = "macos-13" ]; then - # find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \; - # find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \; + elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then + find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \; + find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \; else find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \; find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \; @@ -273,7 +287,7 @@ jobs: shell: bash run: | cd ./frontend/src-tauri/target - + # Check for expected artifacts based on platform if [ "${{ matrix.platform }}" = "windows-latest" ]; then echo "Checking for Windows artifacts..." @@ -282,14 +296,13 @@ jobs: echo "❌ No Windows executable found" exit 1 fi - # Disabled Mac builds - # elif [ "${{ matrix.platform }}" = "macos-latest" ] || [ "${{ matrix.platform }}" = "macos-13" ]; then - # echo "Checking for macOS artifacts..." - # find . -name "*.dmg" -o -name "*.app" | head -5 - # if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then - # echo "❌ No macOS artifacts found" - # exit 1 - # fi + elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then + echo "Checking for macOS artifacts..." + find . -name "*.dmg" -o -name "*.app" | head -5 + if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then + echo "❌ No macOS artifacts found" + exit 1 + fi else echo "Checking for Linux artifacts..." find . -name "*.deb" -o -name "*.AppImage" | head -5 @@ -298,7 +311,7 @@ jobs: exit 1 fi fi - + echo "✅ Build artifacts found for ${{ matrix.name }}" - name: Test artifact sizes diff --git a/app/common/src/main/java/stirling/software/common/service/UserServiceInterface.java b/app/common/src/main/java/stirling/software/common/service/UserServiceInterface.java index a833d4c848..074f422000 100644 --- a/app/common/src/main/java/stirling/software/common/service/UserServiceInterface.java +++ b/app/common/src/main/java/stirling/software/common/service/UserServiceInterface.java @@ -8,4 +8,6 @@ public interface UserServiceInterface { long getTotalUsersCount(); boolean isCurrentUserAdmin(); + + boolean isCurrentUserFirstLogin(); } diff --git a/app/common/src/main/java/stirling/software/common/util/FormUtils.java b/app/common/src/main/java/stirling/software/common/util/FormUtils.java deleted file mode 100644 index 19cda95ed8..0000000000 --- a/app/common/src/main/java/stirling/software/common/util/FormUtils.java +++ /dev/null @@ -1,658 +0,0 @@ -package stirling.software.common.util; - -import java.io.IOException; -import java.lang.reflect.Method; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import org.apache.pdfbox.cos.COSName; -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDDocumentCatalog; -import org.apache.pdfbox.pdmodel.PDPage; -import org.apache.pdfbox.pdmodel.PDResources; -import org.apache.pdfbox.pdmodel.common.PDRectangle; -import org.apache.pdfbox.pdmodel.font.PDType1Font; -import org.apache.pdfbox.pdmodel.font.Standard14Fonts; -import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; -import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; -import org.apache.pdfbox.pdmodel.interactive.form.*; - -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public final class FormUtils { - - private FormUtils() {} - - public static boolean hasAnyRotatedPage(PDDocument document) { - try { - for (PDPage page : document.getPages()) { - int rot = page.getRotation(); - int norm = ((rot % 360) + 360) % 360; - if (norm != 0) { - return true; - } - } - } catch (Exception e) { - log.warn("Failed to inspect page rotations: {}", e.getMessage(), e); - } - return false; - } - - public static void copyAndTransformFormFields( - PDDocument sourceDocument, - PDDocument newDocument, - int totalPages, - int pagesPerSheet, - int cols, - int rows, - float cellWidth, - float cellHeight) - throws IOException { - - PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog(); - PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm(); - - if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) { - return; - } - - PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog(); - PDAcroForm newAcroForm = new PDAcroForm(newDocument); - newCatalog.setAcroForm(newAcroForm); - - PDResources dr = new PDResources(); - PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA); - PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS); - dr.put(COSName.getPDFName("Helv"), helvetica); - dr.put(COSName.getPDFName("ZaDb"), zapfDingbats); - newAcroForm.setDefaultResources(dr); - newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g"); - - // Do not mutate the source AcroForm; skip bad widgets during copy - newAcroForm.setNeedAppearances(true); - - Map fieldNameCounters = new HashMap<>(); - - // Build widget -> field map once for efficient lookups - Map widgetFieldMap = buildWidgetFieldMap(sourceAcroForm); - - for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { - PDPage sourcePage = sourceDocument.getPage(pageIndex); - List annotations = sourcePage.getAnnotations(); - - if (annotations.isEmpty()) { - continue; - } - - int destinationPageIndex = pageIndex / pagesPerSheet; - int adjustedPageIndex = pageIndex % pagesPerSheet; - int rowIndex = adjustedPageIndex / cols; - int colIndex = adjustedPageIndex % cols; - - if (destinationPageIndex >= newDocument.getNumberOfPages()) { - continue; - } - - PDPage destinationPage = newDocument.getPage(destinationPageIndex); - PDRectangle sourceRect = sourcePage.getMediaBox(); - - float scaleWidth = cellWidth / sourceRect.getWidth(); - float scaleHeight = cellHeight / sourceRect.getHeight(); - float scale = Math.min(scaleWidth, scaleHeight); - - float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2; - float y = - destinationPage.getMediaBox().getHeight() - - ((rowIndex + 1) * cellHeight - - (cellHeight - sourceRect.getHeight() * scale) / 2); - - copyBasicFormFields( - sourceAcroForm, - newAcroForm, - sourcePage, - destinationPage, - x, - y, - scale, - pageIndex, - fieldNameCounters, - widgetFieldMap); - } - - // Refresh appearances to ensure widgets render correctly across viewers - try { - // Use reflection to avoid compile-time dependency on PDFBox version - Method m = newAcroForm.getClass().getMethod("refreshAppearances"); - m.invoke(newAcroForm); - } catch (NoSuchMethodException nsme) { - log.warn( - "AcroForm.refreshAppearances() not available in this PDFBox version; relying on NeedAppearances."); - } catch (Throwable t) { - log.warn("Failed to refresh field appearances via AcroForm: {}", t.getMessage(), t); - } - } - - private static void copyBasicFormFields( - PDAcroForm sourceAcroForm, - PDAcroForm newAcroForm, - PDPage sourcePage, - PDPage destinationPage, - float offsetX, - float offsetY, - float scale, - int pageIndex, - Map fieldNameCounters, - Map widgetFieldMap) { - - try { - List sourceAnnotations = sourcePage.getAnnotations(); - List destinationAnnotations = destinationPage.getAnnotations(); - - for (PDAnnotation annotation : sourceAnnotations) { - if (annotation instanceof PDAnnotationWidget widgetAnnotation) { - if (widgetAnnotation.getRectangle() == null) { - continue; - } - PDField sourceField = - widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null; - if (sourceField == null) { - continue; // skip widgets without a matching field - } - if (sourceField instanceof PDTextField pdtextfield) { - createSimpleTextField( - newAcroForm, - destinationPage, - destinationAnnotations, - pdtextfield, - widgetAnnotation, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - } else if (sourceField instanceof PDCheckBox pdCheckBox) { - createSimpleCheckBoxField( - newAcroForm, - destinationPage, - destinationAnnotations, - pdCheckBox, - widgetAnnotation, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - } else if (sourceField instanceof PDRadioButton pdRadioButton) { - createSimpleRadioButtonField( - newAcroForm, - destinationPage, - destinationAnnotations, - pdRadioButton, - widgetAnnotation, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - } else if (sourceField instanceof PDComboBox pdComboBox) { - createSimpleComboBoxField( - newAcroForm, - destinationPage, - destinationAnnotations, - pdComboBox, - widgetAnnotation, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - } else if (sourceField instanceof PDListBox pdlistbox) { - createSimpleListBoxField( - newAcroForm, - destinationPage, - destinationAnnotations, - pdlistbox, - widgetAnnotation, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - } else if (sourceField instanceof PDSignatureField pdSignatureField) { - createSimpleSignatureField( - newAcroForm, - destinationPage, - destinationAnnotations, - pdSignatureField, - widgetAnnotation, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - } else if (sourceField instanceof PDPushButton pdPushButton) { - createSimplePushButtonField( - newAcroForm, - destinationPage, - destinationAnnotations, - pdPushButton, - widgetAnnotation, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - } - } - } - } catch (Exception e) { - log.warn( - "Failed to copy basic form fields for page {}: {}", - pageIndex, - e.getMessage(), - e); - } - } - - private static void createSimpleTextField( - PDAcroForm newAcroForm, - PDPage destinationPage, - List destinationAnnotations, - PDTextField sourceField, - PDAnnotationWidget sourceWidget, - float offsetX, - float offsetY, - float scale, - int pageIndex, - Map fieldNameCounters) { - - try { - PDTextField newTextField = new PDTextField(newAcroForm); - newTextField.setDefaultAppearance("/Helv 12 Tf 0 g"); - - boolean initialized = - initializeFieldWithWidget( - newAcroForm, - destinationPage, - destinationAnnotations, - newTextField, - sourceField.getPartialName(), - "textField", - sourceWidget, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - - if (!initialized) { - return; - } - - if (sourceField.getValueAsString() != null) { - newTextField.setValue(sourceField.getValueAsString()); - } - - } catch (Exception e) { - log.warn( - "Failed to create text field '{}': {}", - sourceField.getPartialName(), - e.getMessage(), - e); - } - } - - private static void createSimpleCheckBoxField( - PDAcroForm newAcroForm, - PDPage destinationPage, - List destinationAnnotations, - PDCheckBox sourceField, - PDAnnotationWidget sourceWidget, - float offsetX, - float offsetY, - float scale, - int pageIndex, - Map fieldNameCounters) { - - try { - PDCheckBox newCheckBox = new PDCheckBox(newAcroForm); - - boolean initialized = - initializeFieldWithWidget( - newAcroForm, - destinationPage, - destinationAnnotations, - newCheckBox, - sourceField.getPartialName(), - "checkBox", - sourceWidget, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - - if (!initialized) { - return; - } - - if (sourceField.isChecked()) { - newCheckBox.check(); - } else { - newCheckBox.unCheck(); - } - - } catch (Exception e) { - log.warn( - "Failed to create checkbox field '{}': {}", - sourceField.getPartialName(), - e.getMessage(), - e); - } - } - - private static void createSimpleRadioButtonField( - PDAcroForm newAcroForm, - PDPage destinationPage, - List destinationAnnotations, - PDRadioButton sourceField, - PDAnnotationWidget sourceWidget, - float offsetX, - float offsetY, - float scale, - int pageIndex, - Map fieldNameCounters) { - - try { - PDRadioButton newRadioButton = new PDRadioButton(newAcroForm); - - boolean initialized = - initializeFieldWithWidget( - newAcroForm, - destinationPage, - destinationAnnotations, - newRadioButton, - sourceField.getPartialName(), - "radioButton", - sourceWidget, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - - if (!initialized) { - return; - } - - if (sourceField.getExportValues() != null) { - newRadioButton.setExportValues(sourceField.getExportValues()); - } - if (sourceField.getValue() != null) { - newRadioButton.setValue(sourceField.getValue()); - } - } catch (Exception e) { - log.warn( - "Failed to create radio button field '{}': {}", - sourceField.getPartialName(), - e.getMessage(), - e); - } - } - - private static void createSimpleComboBoxField( - PDAcroForm newAcroForm, - PDPage destinationPage, - List destinationAnnotations, - PDComboBox sourceField, - PDAnnotationWidget sourceWidget, - float offsetX, - float offsetY, - float scale, - int pageIndex, - Map fieldNameCounters) { - - try { - PDComboBox newComboBox = new PDComboBox(newAcroForm); - - boolean initialized = - initializeFieldWithWidget( - newAcroForm, - destinationPage, - destinationAnnotations, - newComboBox, - sourceField.getPartialName(), - "comboBox", - sourceWidget, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - - if (!initialized) { - return; - } - - if (sourceField.getOptions() != null) { - newComboBox.setOptions(sourceField.getOptions()); - } - if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) { - newComboBox.setValue(sourceField.getValue()); - } - } catch (Exception e) { - log.warn( - "Failed to create combo box field '{}': {}", - sourceField.getPartialName(), - e.getMessage(), - e); - } - } - - private static void createSimpleListBoxField( - PDAcroForm newAcroForm, - PDPage destinationPage, - List destinationAnnotations, - PDListBox sourceField, - PDAnnotationWidget sourceWidget, - float offsetX, - float offsetY, - float scale, - int pageIndex, - Map fieldNameCounters) { - - try { - PDListBox newListBox = new PDListBox(newAcroForm); - - boolean initialized = - initializeFieldWithWidget( - newAcroForm, - destinationPage, - destinationAnnotations, - newListBox, - sourceField.getPartialName(), - "listBox", - sourceWidget, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - - if (!initialized) { - return; - } - - if (sourceField.getOptions() != null) { - newListBox.setOptions(sourceField.getOptions()); - } - if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) { - newListBox.setValue(sourceField.getValue()); - } - } catch (Exception e) { - log.warn( - "Failed to create list box field '{}': {}", - sourceField.getPartialName(), - e.getMessage(), - e); - } - } - - private static void createSimpleSignatureField( - PDAcroForm newAcroForm, - PDPage destinationPage, - List destinationAnnotations, - PDSignatureField sourceField, - PDAnnotationWidget sourceWidget, - float offsetX, - float offsetY, - float scale, - int pageIndex, - Map fieldNameCounters) { - - try { - PDSignatureField newSignatureField = new PDSignatureField(newAcroForm); - - boolean initialized = - initializeFieldWithWidget( - newAcroForm, - destinationPage, - destinationAnnotations, - newSignatureField, - sourceField.getPartialName(), - "signature", - sourceWidget, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - - if (!initialized) { - return; - } - } catch (Exception e) { - log.warn( - "Failed to create signature field '{}': {}", - sourceField.getPartialName(), - e.getMessage(), - e); - } - } - - private static void createSimplePushButtonField( - PDAcroForm newAcroForm, - PDPage destinationPage, - List destinationAnnotations, - PDPushButton sourceField, - PDAnnotationWidget sourceWidget, - float offsetX, - float offsetY, - float scale, - int pageIndex, - Map fieldNameCounters) { - - try { - PDPushButton newPushButton = new PDPushButton(newAcroForm); - - boolean initialized = - initializeFieldWithWidget( - newAcroForm, - destinationPage, - destinationAnnotations, - newPushButton, - sourceField.getPartialName(), - "pushButton", - sourceWidget, - offsetX, - offsetY, - scale, - pageIndex, - fieldNameCounters); - - } catch (Exception e) { - log.warn( - "Failed to create push button field '{}': {}", - sourceField.getPartialName(), - e.getMessage(), - e); - } - } - - private static boolean initializeFieldWithWidget( - PDAcroForm newAcroForm, - PDPage destinationPage, - List destinationAnnotations, - T newField, - String originalName, - String fallbackName, - PDAnnotationWidget sourceWidget, - float offsetX, - float offsetY, - float scale, - int pageIndex, - Map fieldNameCounters) { - - String baseName = (originalName != null) ? originalName : fallbackName; - String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters); - newField.setPartialName(newFieldName); - - PDAnnotationWidget newWidget = new PDAnnotationWidget(); - PDRectangle sourceRect = sourceWidget.getRectangle(); - if (sourceRect == null) { - return false; - } - - float newX = (sourceRect.getLowerLeftX() * scale) + offsetX; - float newY = (sourceRect.getLowerLeftY() * scale) + offsetY; - float newWidth = sourceRect.getWidth() * scale; - float newHeight = sourceRect.getHeight() * scale; - newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight)); - newWidget.setPage(destinationPage); - - newField.getWidgets().add(newWidget); - newWidget.setParent(newField); - newAcroForm.getFields().add(newField); - destinationAnnotations.add(newWidget); - return true; - } - - private static String generateUniqueFieldName( - String originalName, int pageIndex, Map fieldNameCounters) { - String baseName = "page" + pageIndex + "_" + originalName; - - Integer counter = fieldNameCounters.get(baseName); - if (counter == null) { - counter = 0; - } else { - counter++; - } - fieldNameCounters.put(baseName, counter); - - return counter == 0 ? baseName : baseName + "_" + counter; - } - - private static Map buildWidgetFieldMap(PDAcroForm acroForm) { - Map map = new HashMap<>(); - if (acroForm == null) { - return map; - } - try { - for (PDField field : acroForm.getFieldTree()) { - List widgets = field.getWidgets(); - if (widgets != null) { - for (PDAnnotationWidget w : widgets) { - if (w != null) { - map.put(w, field); - } - } - } - } - } catch (Exception e) { - log.warn("Failed to build widget->field map: {}", e.getMessage(), e); - } - return map; - } -} diff --git a/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java b/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java index 8858c99bff..778e42ca47 100644 --- a/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java @@ -1,5 +1,6 @@ package stirling.software.common.util; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; @@ -241,6 +242,11 @@ public final class RegexPatternUtils { return getPattern("\\s+"); } + /** Pattern for matching punctuation characters */ + public Pattern getPunctuationPattern() { + return getPattern("[\\p{Punct}]+"); + } + /** Pattern for matching newlines (Windows and Unix style) */ public Pattern getNewlinesPattern() { return getPattern("\\r?\\n"); @@ -286,6 +292,24 @@ public final class RegexPatternUtils { return getPattern("[^a-zA-Z0-9 ]"); } + /** Pattern for removing bracketed indices like [0], [Child], etc. in field names */ + public Pattern getFormFieldBracketPattern() { + return getPattern("\\[[^\\]]*\\]"); + } + + /** Pattern that replaces underscores or hyphens with spaces */ + public Pattern getUnderscoreHyphenPattern() { + return getPattern("[-_]+"); + } + + /** + * Pattern that matches camelCase or alpha-numeric boundaries to allow inserting spaces. + * Examples: firstName -> first Name, field1 -> field 1, A5Size -> A5 Size + */ + public Pattern getCamelCaseBoundaryPattern() { + return getPattern("(?<=[a-z])(?=[A-Z])|(?<=[A-Za-z])(?=\\d)|(?<=\\d)(?=[A-Za-z])"); + } + /** Pattern for removing angle brackets */ public Pattern getAngleBracketsPattern() { return getPattern("[<>]"); @@ -335,6 +359,26 @@ public final class RegexPatternUtils { return getPattern("[1-9][0-9]{0,2}"); } + /** + * Pattern for very simple generic field tokens such as "field", "text", "checkbox" with + * optional numeric suffix (e.g. "field 1"). Case-insensitive. + */ + public Pattern getGenericFieldNamePattern() { + return getPattern( + "^(field|text|checkbox|radio|button|signature|name|value|option|select|choice)(\\s*\\d+)?$", + Pattern.CASE_INSENSITIVE); + } + + /** Pattern for short identifiers like t1, f2, a10 etc. */ + public Pattern getSimpleFormFieldPattern() { + return getPattern("^[A-Za-z]{1,2}\\s*\\d{1,3}$"); + } + + /** Pattern for optional leading 't' followed by digits, e.g., t1, 1, t 12. */ + public Pattern getOptionalTNumericPattern() { + return getPattern("^(?:t\\s*)?\\d+$", Pattern.CASE_INSENSITIVE); + } + /** Pattern for validating mathematical expressions */ public Pattern getMathExpressionPattern() { return getPattern("[0-9n+\\-*/() ]+"); @@ -467,6 +511,11 @@ public final class RegexPatternUtils { return getPattern("/"); } + /** Supported logical types when creating new fields programmatically */ + public Set getSupportedNewFieldTypes() { + return Set.of("text", "checkbox", "combobox", "listbox", "radio", "button", "signature"); + } + /** * Pre-compile commonly used patterns for immediate availability. This eliminates first-call * compilation overhead for frequent patterns. diff --git a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index 2b4fa32d95..0178c25971 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -294,6 +294,12 @@ public class EndpointConfiguration { addEndpointToGroup("Other", "replace-and-invert-color-pdf"); addEndpointToGroup("Other", "multi-tool"); + // Adding form-related endpoints to "Other" group + addEndpointToGroup("Other", "fields"); + addEndpointToGroup("Other", "modify-fields"); + addEndpointToGroup("Other", "delete-fields"); + addEndpointToGroup("Other", "fill"); + // Adding endpoints to "Advance" group addEndpointToGroup("Advance", "adjust-contrast"); addEndpointToGroup("Advance", "compress-pdf"); diff --git a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java index 2d261c6609..0a63a6f486 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java @@ -28,6 +28,8 @@ public class InitialSetup { private final ApplicationProperties applicationProperties; + private static boolean isNewServer = false; + @PostConstruct public void init() throws IOException { initUUIDKey(); @@ -88,6 +90,13 @@ public class InitialSetup { } public void initSetAppVersion() throws IOException { + // Check if this is a new server before setting the version + String existingVersion = applicationProperties.getAutomaticallyGenerated().getAppVersion(); + isNewServer = + existingVersion == null + || existingVersion.isEmpty() + || existingVersion.equals("0.0.0"); + String appVersion = "0.0.0"; Resource resource = new ClassPathResource("version.properties"); Properties props = new Properties(); @@ -99,4 +108,8 @@ public class InitialSetup { GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion); applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion); } + + public static boolean isNewServer() { + return isNewServer; + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java index 40301c63e9..1cf33e7309 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java @@ -26,7 +26,6 @@ import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.service.CustomPDFDocumentFactory; -import stirling.software.common.util.FormUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.WebResponseUtils; @@ -137,26 +136,6 @@ public class MultiPageLayoutController { contentStream.close(); - // If any source page is rotated, skip form copying/transformation entirely - boolean hasRotation = FormUtils.hasAnyRotatedPage(sourceDocument); - if (hasRotation) { - log.info("Source document has rotated pages; skipping form field copying."); - } else { - try { - FormUtils.copyAndTransformFormFields( - sourceDocument, - newDocument, - totalPages, - pagesPerSheet, - cols, - rows, - cellWidth, - cellHeight); - } catch (Exception e) { - log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e); - } - } - sourceDocument.close(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 6bb67d5b82..ffbec5a7d8 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -13,6 +13,7 @@ import io.swagger.v3.oas.annotations.Hidden; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.config.InitialSetup; import stirling.software.common.annotations.api.ConfigApi; import stirling.software.common.configuration.AppConfig; import stirling.software.common.model.ApplicationProperties; @@ -87,6 +88,22 @@ public class ConfigController { } configData.put("isAdmin", isAdmin); + // Check if this is a new server (version was 0.0.0 before initialization) + configData.put("isNewServer", InitialSetup.isNewServer()); + + // Check if the current user is a first-time user + boolean isNewUser = + false; // Default to false when security is disabled or user not found + if (userService != null) { + try { + isNewUser = userService.isCurrentUserFirstLogin(); + } catch (Exception e) { + // If there's an error, assume not new user for safety + isNewUser = false; + } + } + configData.put("isNewUser", isNewUser); + // System settings configData.put( "enableAlphaFunctionality", diff --git a/app/core/src/main/resources/static/css/theme/theme.css b/app/core/src/main/resources/static/css/theme/theme.css index e1ef98c83d..70958b0eb2 100644 --- a/app/core/src/main/resources/static/css/theme/theme.css +++ b/app/core/src/main/resources/static/css/theme/theme.css @@ -7,6 +7,8 @@ --md-sys-color-surface-3: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.11) 5%); --md-sys-color-surface-4: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.12) 5%); --md-sys-color-surface-5: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.14) 5%); + /* Clear button disabled text color (default/light) */ + --spdf-clear-disabled-text: var(--md-sys-color-primary); /* Icon fill */ --md-sys-icon-fill-0: 'FILL' 0, 'wght' 500; --md-sys-icon-fill-1: 'FILL' 1, 'wght' 500; @@ -25,6 +27,12 @@ --md-sys-elevation-5: 0px 8px 10px -6px rgb(var(--md-elevation-shadow-color), 0.2), 0px 16px 24px 2px rgb(var(--md-elevation-shadow-color), 0.14), 0px 6px 30px 5px rgb(var(--md-elevation-shadow-color), 0.12); } +/* Dark theme overrides */ +.dark-theme { + /* In dark mode, use a neutral grey for disabled Clear button text */ + --spdf-clear-disabled-text: var(--mantine-color-gray-5, #9e9e9e); +} + .fill { font-variation-settings: var(--md-sys-icon-fill-1); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormFillController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormFillController.java new file mode 100644 index 0000000000..ddc7048bdf --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormFillController.java @@ -0,0 +1,215 @@ +package stirling.software.proprietary.controller.api.form; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.github.pixee.security.Filenames; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ExceptionUtils; +import stirling.software.common.util.WebResponseUtils; +import stirling.software.proprietary.util.FormUtils; + +@RestController +@RequestMapping("/api/v1/form") +@Tag(name = "Forms", description = "PDF form APIs") +@RequiredArgsConstructor +public class FormFillController { + + private final CustomPDFDocumentFactory pdfDocumentFactory; + private final ObjectMapper objectMapper; + + private static ResponseEntity saveDocument(PDDocument document, String baseName) + throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + document.save(baos); + return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), baseName + ".pdf"); + } + + private static String buildBaseName(MultipartFile file, String suffix) { + String original = Filenames.toSimpleFileName(file.getOriginalFilename()); + if (original == null || original.isBlank()) { + original = "document"; + } + if (!original.toLowerCase().endsWith(".pdf")) { + return original + "_" + suffix; + } + String withoutExtension = original.substring(0, original.length() - 4); + return withoutExtension + "_" + suffix; + } + + private static void requirePdf(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.fileFormatRequired", "{0} must be in PDF format", "file"); + } + } + + private static String decodePart(byte[] payload) { + if (payload == null || payload.length == 0) { + return null; + } + return new String(payload, StandardCharsets.UTF_8); + } + + @PostMapping(value = "/fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Inspect PDF form fields", + description = "Returns metadata describing each field in the provided PDF form") + public ResponseEntity listFields( + @Parameter( + description = "The input PDF file", + required = true, + content = + @Content( + mediaType = MediaType.APPLICATION_PDF_VALUE, + schema = @Schema(type = "string", format = "binary"))) + @RequestParam("file") + MultipartFile file) + throws IOException { + + requirePdf(file); + try (PDDocument document = pdfDocumentFactory.load(file, true)) { + FormUtils.FormFieldExtraction extraction = + FormUtils.extractFieldsWithTemplate(document); + return ResponseEntity.ok(extraction); + } + } + + @PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Modify existing form fields", + description = + "Updates existing fields in the provided PDF and returns the updated file") + public ResponseEntity modifyFields( + @Parameter( + description = "The input PDF file", + required = true, + content = + @Content( + mediaType = MediaType.APPLICATION_PDF_VALUE, + schema = @Schema(type = "string", format = "binary"))) + @RequestParam("file") + MultipartFile file, + @RequestPart(value = "updates", required = false) byte[] updatesPayload) + throws IOException { + + String rawUpdates = decodePart(updatesPayload); + List modifications = + FormPayloadParser.parseModificationDefinitions(objectMapper, rawUpdates); + if (modifications.isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.dataRequired", + "{0} must contain at least one definition", + "updates payload"); + } + + return processSingleFile( + file, "updated", document -> FormUtils.modifyFormFields(document, modifications)); + } + + @PostMapping(value = "/delete-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Delete form fields", + description = "Removes the specified fields from the PDF and returns the updated file") + public ResponseEntity deleteFields( + @Parameter( + description = "The input PDF file", + required = true, + content = + @Content( + mediaType = MediaType.APPLICATION_PDF_VALUE, + schema = @Schema(type = "string", format = "binary"))) + @RequestParam("file") + MultipartFile file, + @Parameter( + description = + "JSON array of field names or objects with a name property," + + " matching the /fields response format", + example = "[{\"name\":\"Field1\"}]") + @RequestPart(value = "names", required = false) + byte[] namesPayload) + throws IOException { + + String rawNames = decodePart(namesPayload); + List names = FormPayloadParser.parseNameList(objectMapper, rawNames); + if (names.isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.dataRequired", "{0} must contain at least one value", "names payload"); + } + + return processSingleFile( + file, "updated", document -> FormUtils.deleteFormFields(document, names)); + } + + @PostMapping(value = "/fill", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Fill PDF form fields", + description = + "Populates the supplied PDF form using values from the provided JSON payload" + + " and returns the filled PDF") + public ResponseEntity fillForm( + @Parameter( + description = "The input PDF file", + required = true, + content = + @Content( + mediaType = MediaType.APPLICATION_PDF_VALUE, + schema = @Schema(type = "string", format = "binary"))) + @RequestParam("file") + MultipartFile file, + @Parameter( + description = "JSON object of field-value pairs to apply", + example = "{\"field\":\"value\"}") + @RequestPart(value = "data", required = false) + byte[] valuesPayload, + @RequestParam(value = "flatten", defaultValue = "false") boolean flatten) + throws IOException { + + String rawValues = decodePart(valuesPayload); + Map values = FormPayloadParser.parseValueMap(objectMapper, rawValues); + + return processSingleFile( + file, + "filled", + document -> FormUtils.applyFieldValues(document, values, flatten, true)); + } + + private ResponseEntity processSingleFile( + MultipartFile file, String suffix, DocumentProcessor processor) throws IOException { + requirePdf(file); + + String baseName = buildBaseName(file, suffix); + try (PDDocument document = pdfDocumentFactory.load(file)) { + processor.accept(document); + return saveDocument(document, baseName); + } + } + + @FunctionalInterface + private interface DocumentProcessor { + void accept(PDDocument document) throws IOException; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormPayloadParser.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormPayloadParser.java new file mode 100644 index 0000000000..2efffb21de --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormPayloadParser.java @@ -0,0 +1,295 @@ +package stirling.software.proprietary.controller.api.form; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import stirling.software.common.util.ExceptionUtils; +import stirling.software.proprietary.util.FormUtils; + +final class FormPayloadParser { + + private static final String KEY_FIELDS = "fields"; + private static final String KEY_NAME = "name"; + private static final String KEY_TARGET_NAME = "targetName"; + private static final String KEY_FIELD_NAME = "fieldName"; + private static final String KEY_FIELD = "field"; + private static final String KEY_VALUE = "value"; + private static final String KEY_DEFAULT_VALUE = "defaultValue"; + + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + private static final TypeReference> + MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {}; + private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {}; + + private FormPayloadParser() {} + + static Map parseValueMap(ObjectMapper objectMapper, String json) + throws IOException { + if (json == null || json.isBlank()) { + return Map.of(); + } + + JsonNode root; + try { + root = objectMapper.readTree(json); + } catch (IOException e) { + // Fallback to legacy direct map parse (will throw again if invalid) + return objectMapper.readValue(json, MAP_TYPE); + } + if (root == null || root.isNull()) { + return Map.of(); + } + + // 1. If payload already a flat object with no special wrapping, keep legacy behavior + if (root.isObject()) { + // a) Prefer explicit 'template' object if present (new combined /fields response) + JsonNode templateNode = root.get("template"); + if (templateNode != null && templateNode.isObject()) { + return objectToLinkedMap(templateNode); + } + // b) Accept an inline 'fields' array of field definitions (build map from them) + JsonNode fieldsNode = root.get(KEY_FIELDS); + if (fieldsNode != null && fieldsNode.isArray()) { + Map record = extractFieldInfoArray(fieldsNode); + if (!record.isEmpty()) { + return record; + } + } + // c) Fallback: treat entire object as the value map (legacy behavior) + return objectToLinkedMap(root); + } + + // 2. If an array was supplied to /fill (non-standard), treat first element as record + if (root.isArray()) { + if (root.isEmpty()) { + return Map.of(); + } + JsonNode first = root.get(0); + if (first != null && first.isObject()) { + if (first.has(KEY_NAME) || first.has(KEY_VALUE) || first.has(KEY_DEFAULT_VALUE)) { + return extractFieldInfoArray(root); + } + return objectToLinkedMap(first); + } + return Map.of(); + } + + // 3. Anything else: fallback to strict map parse + return objectMapper.readValue(json, MAP_TYPE); + } + + static List parseModificationDefinitions( + ObjectMapper objectMapper, String json) throws IOException { + if (json == null || json.isBlank()) { + return List.of(); + } + return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE); + } + + static List parseNameList(ObjectMapper objectMapper, String json) throws IOException { + if (json == null || json.isBlank()) { + return List.of(); + } + + final JsonNode root = objectMapper.readTree(json); + if (root == null || root.isNull()) { + return List.of(); + } + + final Set names = new LinkedHashSet<>(); + + if (root.isArray()) { + collectNames(root, names); + } else if (root.isObject()) { + if (root.has(KEY_FIELDS) && root.get(KEY_FIELDS).isArray()) { + collectNames(root.get(KEY_FIELDS), names); + } else { + final String single = extractName(root); + if (nonBlank(single)) { + names.add(single); + } + } + } else if (root.isTextual()) { + final String single = trimToNull(root.asText()); + if (single != null) { + names.add(single); + } + } + + if (!names.isEmpty()) { + return List.copyOf(names); + } + + try { + return objectMapper.readValue(json, STRING_LIST_TYPE); + } catch (IOException e) { + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidFormat", + "Invalid {0} format: {1}", + "names payload", + "expected array of strings or objects with 'name'-like properties"); + } + } + + private static Map extractFieldInfoArray(JsonNode fieldsNode) { + final Map record = new LinkedHashMap<>(); + if (fieldsNode == null || fieldsNode.isNull() || !fieldsNode.isArray()) { + return record; + } + + for (JsonNode fieldNode : fieldsNode) { + if (fieldNode == null || !fieldNode.isObject()) { + continue; + } + + final String name = extractName(fieldNode); + if (!nonBlank(name)) { + continue; + } + + JsonNode valueNode = fieldNode.get(KEY_VALUE); + if ((valueNode == null || valueNode.isNull()) + && fieldNode.hasNonNull(KEY_DEFAULT_VALUE)) { + valueNode = fieldNode.get(KEY_DEFAULT_VALUE); + } + + final String normalized = normalizeFieldValue(valueNode); + record.put(name, normalized == null ? "" : normalized); + } + + return record; + } + + private static String normalizeFieldValue(JsonNode valueNode) { + if (valueNode == null || valueNode.isNull()) { + return null; + } + + if (valueNode.isArray()) { + final List values = new ArrayList<>(); + for (JsonNode element : valueNode) { + final String text = coerceScalarToString(element); + if (text != null) { + values.add(text); + } + } + return String.join(",", values); + } + + if (valueNode.isObject()) { + // Preserve object as JSON string + return valueNode.toString(); + } + + // Scalar (text/number/boolean) + return coerceScalarToString(valueNode); + } + + private static String coerceScalarToString(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + if (node.isTextual()) { + return trimToEmpty(node.asText()); + } + if (node.isNumber()) { + return node.numberValue().toString(); + } + if (node.isBoolean()) { + return Boolean.toString(node.booleanValue()); + } + // Fallback for other scalar-like nodes + return trimToEmpty(node.asText()); + } + + private static void collectNames(JsonNode arrayNode, Set sink) { + if (arrayNode == null || !arrayNode.isArray()) { + return; + } + for (JsonNode node : arrayNode) { + final String name = extractName(node); + if (nonBlank(name)) { + sink.add(name); + } + } + } + + private static String extractName(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + + if (node.isTextual()) { + return trimToNull(node.asText()); + } + + if (node.isObject()) { + final String direct = textProperty(node, KEY_NAME, KEY_TARGET_NAME, KEY_FIELD_NAME); + if (nonBlank(direct)) { + return direct; + } + final JsonNode field = node.get(KEY_FIELD); + if (field != null && field.isObject()) { + final String nested = + textProperty(field, KEY_NAME, KEY_TARGET_NAME, KEY_FIELD_NAME); + if (nonBlank(nested)) { + return nested; + } + } + } + + return null; + } + + private static String textProperty(JsonNode node, String... keys) { + for (String key : keys) { + final JsonNode valueNode = node.get(key); + final String value = coerceScalarToString(valueNode); + if (nonBlank(value)) { + return value; + } + } + return null; + } + + private static Map objectToLinkedMap(JsonNode objectNode) { + final Map result = new LinkedHashMap<>(); + objectNode + .fieldNames() + .forEachRemaining( + key -> { + final JsonNode v = objectNode.get(key); + if (v == null || v.isNull()) { + result.put(key, null); + } else if (v.isTextual() || v.isNumber() || v.isBoolean()) { + result.put(key, coerceScalarToString(v)); + } else { + result.put(key, v.toString()); + } + }); + return result; + } + + private static boolean nonBlank(String s) { + return s != null && !s.isBlank(); + } + + private static String trimToNull(String s) { + if (s == null) return null; + final String t = s.trim(); + return t.isEmpty() ? null : t; + } + + private static String trimToEmpty(String s) { + return s == null ? "" : s.trim(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index 92a1f82ac0..9f2ce94560 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -742,4 +742,31 @@ public class UserController { return errorMessage; } } + + @PostMapping("/complete-initial-setup") + public ResponseEntity completeInitialSetup() { + try { + String username = userService.getCurrentUsername(); + if (username == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body("User not authenticated"); + } + + Optional userOpt = userService.findByUsernameIgnoreCase(username); + if (userOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found"); + } + + User user = userOpt.get(); + user.setHasCompletedInitialSetup(true); + userRepository.save(user); + + log.info("User {} completed initial setup", username); + return ResponseEntity.ok().body(Map.of("success", true)); + } catch (Exception e) { + log.error("Error completing initial setup", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Failed to complete initial setup"); + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index 02bd08a5bd..8f64d3187f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -56,6 +56,9 @@ public class User implements UserDetails, Serializable { @Column(name = "isFirstLogin") private Boolean isFirstLogin = false; + @Column(name = "hasCompletedInitialSetup") + private Boolean hasCompletedInitialSetup = false; + @Column(name = "roleName") private String roleName; @@ -103,6 +106,14 @@ public class User implements UserDetails, Serializable { this.isFirstLogin = isFirstLogin; } + public boolean hasCompletedInitialSetup() { + return hasCompletedInitialSetup != null && hasCompletedInitialSetup; + } + + public void setHasCompletedInitialSetup(boolean hasCompletedInitialSetup) { + this.hasCompletedInitialSetup = hasCompletedInitialSetup; + } + public void setAuthenticationType(AuthenticationType authenticationType) { this.authenticationType = authenticationType.toString().toLowerCase(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index d13fcc0cdf..4772368f88 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -663,6 +663,21 @@ public class UserService implements UserServiceInterface { return false; } + public boolean isCurrentUserFirstLogin() { + try { + String username = getCurrentUsername(); + if (username != null) { + Optional userOpt = findByUsernameIgnoreCase(username); + if (userOpt.isPresent()) { + return !userOpt.get().hasCompletedInitialSetup(); + } + } + } catch (Exception e) { + log.debug("Error checking first login status", e); + } + return false; + } + @Transactional public void syncCustomApiUser(String customApiKey) { if (customApiKey == null || customApiKey.trim().isBlank()) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/util/FormCopyUtils.java b/app/proprietary/src/main/java/stirling/software/proprietary/util/FormCopyUtils.java new file mode 100644 index 0000000000..c27a2ab2ba --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/util/FormCopyUtils.java @@ -0,0 +1,345 @@ +package stirling.software.proprietary.util; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentCatalog; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField; + +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@UtilityClass +public class FormCopyUtils { + + public boolean hasAnyRotatedPage(PDDocument document) { + try { + for (PDPage page : document.getPages()) { + int rot = page.getRotation(); + int norm = ((rot % 360) + 360) % 360; + if (norm != 0) { + return true; + } + } + } catch (Exception e) { + log.warn("Failed to inspect page rotations: {}", e.getMessage(), e); + } + return false; + } + + public void copyAndTransformFormFields( + PDDocument sourceDocument, + PDDocument newDocument, + int totalPages, + int pagesPerSheet, + int cols, + int rows, + float cellWidth, + float cellHeight) + throws IOException { + + PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog(); + PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm(); + + if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) { + return; + } + + PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog(); + PDAcroForm newAcroForm = new PDAcroForm(newDocument); + newCatalog.setAcroForm(newAcroForm); + + PDResources dr = new PDResources(); + PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS); + dr.put(COSName.getPDFName("Helv"), helvetica); + dr.put(COSName.getPDFName("ZaDb"), zapfDingbats); + newAcroForm.setDefaultResources(dr); + newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g"); + + // Temporarily set NeedAppearances to true during field creation + newAcroForm.setNeedAppearances(true); + + Map fieldNameCounters = new HashMap<>(); + + // Build widget -> field map once for efficient lookups + Map widgetFieldMap = buildWidgetFieldMap(sourceAcroForm); + + for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { + PDPage sourcePage = sourceDocument.getPage(pageIndex); + List annotations = sourcePage.getAnnotations(); + + if (annotations.isEmpty()) { + continue; + } + + int destinationPageIndex = pageIndex / pagesPerSheet; + int adjustedPageIndex = pageIndex % pagesPerSheet; + int rowIndex = adjustedPageIndex / cols; + int colIndex = adjustedPageIndex % cols; + + if (rowIndex >= rows) { + continue; + } + + if (destinationPageIndex >= newDocument.getNumberOfPages()) { + continue; + } + + PDPage destinationPage = newDocument.getPage(destinationPageIndex); + PDRectangle sourceRect = sourcePage.getMediaBox(); + + float scaleWidth = cellWidth / sourceRect.getWidth(); + float scaleHeight = cellHeight / sourceRect.getHeight(); + float scale = Math.min(scaleWidth, scaleHeight); + + float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2; + float y = + destinationPage.getMediaBox().getHeight() + - ((rowIndex + 1) * cellHeight + - (cellHeight - sourceRect.getHeight() * scale) / 2); + + copyBasicFormFields( + sourceAcroForm, + newAcroForm, + sourcePage, + destinationPage, + x, + y, + scale, + pageIndex, + fieldNameCounters, + widgetFieldMap); + } + + // Generate appearance streams and embed them authoritatively + boolean appearancesGenerated = false; + try { + newAcroForm.refreshAppearances(); + appearancesGenerated = true; + } catch (NoSuchMethodError nsme) { + log.warn( + "AcroForm.refreshAppearances() not available in this PDFBox version; " + + "leaving NeedAppearances=true for viewer-side rendering."); + } catch (Exception t) { + log.warn( + "Failed to refresh field appearances via AcroForm: {}. " + + "Leaving NeedAppearances=true as fallback.", + t.getMessage(), + t); + } + + // After successful appearance generation, set NeedAppearances to false + // to signal that appearance streams are now embedded authoritatively + if (appearancesGenerated) { + try { + newAcroForm.setNeedAppearances(false); + } catch (Exception e) { + log.debug( + "Failed to set NeedAppearances to false: {}. " + + "Appearances were generated but flag could not be updated.", + e.getMessage()); + } + } + } + + private void copyBasicFormFields( + PDAcroForm sourceAcroForm, + PDAcroForm newAcroForm, + PDPage sourcePage, + PDPage destinationPage, + float offsetX, + float offsetY, + float scale, + int pageIndex, + Map fieldNameCounters, + Map widgetFieldMap) { + + try { + List sourceAnnotations = sourcePage.getAnnotations(); + List destinationAnnotations = destinationPage.getAnnotations(); + + for (PDAnnotation annotation : sourceAnnotations) { + if (annotation instanceof PDAnnotationWidget widgetAnnotation) { + if (widgetAnnotation.getRectangle() == null) { + continue; + } + PDField sourceField = + widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null; + if (sourceField == null) { + continue; // skip widgets without a matching field + } + if (!(sourceField instanceof PDTerminalField terminalField)) { + continue; + } + + FormFieldTypeSupport handler = FormFieldTypeSupport.forField(terminalField); + if (handler == null) { + log.debug( + "Skipping unsupported field type '{}' for widget '{}'", + sourceField.getClass().getSimpleName(), + Optional.ofNullable(sourceField.getFullyQualifiedName()) + .orElseGet(sourceField::getPartialName)); + continue; + } + + copyFieldUsingHandler( + handler, + terminalField, + newAcroForm, + destinationPage, + destinationAnnotations, + widgetAnnotation, + offsetX, + offsetY, + scale, + pageIndex, + fieldNameCounters); + } + } + } catch (Exception e) { + log.warn( + "Failed to copy basic form fields for page {}: {}", + pageIndex, + e.getMessage(), + e); + } + } + + private void copyFieldUsingHandler( + FormFieldTypeSupport handler, + PDTerminalField sourceField, + PDAcroForm newAcroForm, + PDPage destinationPage, + List destinationAnnotations, + PDAnnotationWidget sourceWidget, + float offsetX, + float offsetY, + float scale, + int pageIndex, + Map fieldNameCounters) { + + try { + PDTerminalField newField = handler.createField(newAcroForm); + boolean initialized = + initializeFieldWithWidget( + newAcroForm, + destinationPage, + destinationAnnotations, + newField, + sourceField.getPartialName(), + handler.fallbackWidgetName(), + sourceWidget, + offsetX, + offsetY, + scale, + pageIndex, + fieldNameCounters); + + if (!initialized) { + return; + } + + handler.copyFromOriginal(sourceField, newField); + } catch (Exception e) { + log.warn( + "Failed to copy {} field '{}': {}", + handler.typeName(), + Optional.ofNullable(sourceField.getFullyQualifiedName()) + .orElseGet(sourceField::getPartialName), + e.getMessage(), + e); + } + } + + private boolean initializeFieldWithWidget( + PDAcroForm newAcroForm, + PDPage destinationPage, + List destinationAnnotations, + T newField, + String originalName, + String fallbackName, + PDAnnotationWidget sourceWidget, + float offsetX, + float offsetY, + float scale, + int pageIndex, + Map fieldNameCounters) { + + String baseName = (originalName != null) ? originalName : fallbackName; + String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters); + newField.setPartialName(newFieldName); + + PDAnnotationWidget newWidget = new PDAnnotationWidget(); + PDRectangle sourceRect = sourceWidget.getRectangle(); + if (sourceRect == null) { + return false; + } + + float newX = (sourceRect.getLowerLeftX() * scale) + offsetX; + float newY = (sourceRect.getLowerLeftY() * scale) + offsetY; + float newWidth = sourceRect.getWidth() * scale; + float newHeight = sourceRect.getHeight() * scale; + newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight)); + newWidget.setPage(destinationPage); + + newField.getWidgets().add(newWidget); + newWidget.setParent(newField); + newAcroForm.getFields().add(newField); + destinationAnnotations.add(newWidget); + return true; + } + + private String generateUniqueFieldName( + String originalName, int pageIndex, Map fieldNameCounters) { + String baseName = "page" + pageIndex + "_" + originalName; + + Integer counter = fieldNameCounters.get(baseName); + if (counter == null) { + counter = 0; + } else { + counter++; + } + fieldNameCounters.put(baseName, counter); + + return counter == 0 ? baseName : baseName + "_" + counter; + } + + private Map buildWidgetFieldMap(PDAcroForm acroForm) { + Map map = new HashMap<>(); + if (acroForm == null) { + return map; + } + try { + for (PDField field : acroForm.getFieldTree()) { + List widgets = field.getWidgets(); + if (widgets == null) { + continue; + } + for (PDAnnotationWidget widget : widgets) { + if (widget != null) { + map.put(widget, field); + } + } + } + } catch (Exception e) { + log.warn("Failed to build widget->field map: {}", e.getMessage(), e); + } + return map; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/util/FormFieldTypeSupport.java b/app/proprietary/src/main/java/stirling/software/proprietary/util/FormFieldTypeSupport.java new file mode 100644 index 0000000000..4f1c1e0d8c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/util/FormFieldTypeSupport.java @@ -0,0 +1,368 @@ +package stirling.software.proprietary.util; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.graphics.color.PDColor; +import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDChoice; +import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDListBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public enum FormFieldTypeSupport { + TEXT("text", "textField", PDTextField.class) { + @Override + PDTerminalField createField(PDAcroForm acroForm) { + PDTextField textField = new PDTextField(acroForm); + textField.setDefaultAppearance("/Helv 12 Tf 0 g"); + return textField; + } + + @Override + void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException { + PDTextField src = (PDTextField) source; + PDTextField dst = (PDTextField) target; + String value = src.getValueAsString(); + if (value != null) { + dst.setValue(value); + } + } + + @Override + boolean doesNotsupportsDefinitionCreation() { + return false; + } + + @Override + void applyNewFieldDefinition( + PDTerminalField field, + FormUtils.NewFormFieldDefinition definition, + List options) + throws IOException { + PDTextField textField = (PDTextField) field; + String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse(""); + if (!defaultValue.isBlank()) { + FormUtils.setTextValue(textField, defaultValue); + } + } + }, + CHECKBOX("checkbox", "checkBox", PDCheckBox.class) { + @Override + PDTerminalField createField(PDAcroForm acroForm) { + return new PDCheckBox(acroForm); + } + + @Override + void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException { + PDCheckBox src = (PDCheckBox) source; + PDCheckBox dst = (PDCheckBox) target; + if (src.isChecked()) { + dst.check(); + } else { + dst.unCheck(); + } + } + + @Override + boolean doesNotsupportsDefinitionCreation() { + return false; + } + + @Override + void applyNewFieldDefinition( + PDTerminalField field, + FormUtils.NewFormFieldDefinition definition, + List options) + throws IOException { + PDCheckBox checkBox = (PDCheckBox) field; + + if (!options.isEmpty()) { + checkBox.setExportValues(options); + } + + ensureCheckBoxAppearance(checkBox); + + if (FormUtils.isChecked(definition.defaultValue())) { + checkBox.check(); + } else { + checkBox.unCheck(); + } + } + + private static void ensureCheckBoxAppearance(PDCheckBox checkBox) { + try { + if (checkBox.getWidgets().isEmpty()) { + return; + } + + PDAnnotationWidget widget = checkBox.getWidgets().get(0); + + PDAppearanceCharacteristicsDictionary appearanceChars = + widget.getAppearanceCharacteristics(); + if (appearanceChars == null) { + appearanceChars = + new PDAppearanceCharacteristicsDictionary(widget.getCOSObject()); + widget.setAppearanceCharacteristics(appearanceChars); + } + + appearanceChars.setBorderColour( + new PDColor(new float[] {0, 0, 0}, PDDeviceRGB.INSTANCE)); + appearanceChars.setBackground( + new PDColor(new float[] {1, 1, 1}, PDDeviceRGB.INSTANCE)); + + appearanceChars.setNormalCaption("4"); + + widget.setPrinted(true); + widget.setReadOnly(false); + widget.setHidden(false); + + } catch (Exception e) { + log.debug("Unable to set checkbox appearance characteristics: {}", e.getMessage()); + } + } + }, + RADIO("radio", "radioButton", PDRadioButton.class) { + @Override + PDTerminalField createField(PDAcroForm acroForm) { + return new PDRadioButton(acroForm); + } + + @Override + void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException { + PDRadioButton src = (PDRadioButton) source; + PDRadioButton dst = (PDRadioButton) target; + if (src.getExportValues() != null) { + dst.setExportValues(src.getExportValues()); + } + if (src.getValue() != null) { + dst.setValue(src.getValue()); + } + } + }, + COMBOBOX("combobox", "comboBox", PDComboBox.class) { + @Override + PDTerminalField createField(PDAcroForm acroForm) { + return new PDComboBox(acroForm); + } + + @Override + void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException { + PDComboBox src = (PDComboBox) source; + PDComboBox dst = (PDComboBox) target; + copyChoiceCharacteristics(src, dst); + if (src.getOptions() != null) { + dst.setOptions(src.getOptions()); + } + if (src.getValue() != null && !src.getValue().isEmpty()) { + dst.setValue(src.getValue()); + } + } + + @Override + boolean doesNotsupportsDefinitionCreation() { + return false; + } + + @Override + void applyNewFieldDefinition( + PDTerminalField field, + FormUtils.NewFormFieldDefinition definition, + List options) + throws IOException { + PDComboBox comboBox = (PDComboBox) field; + if (!options.isEmpty()) { + comboBox.setOptions(options); + } + List allowedOptions = FormUtils.resolveOptions(comboBox); + String comboName = + Optional.ofNullable(comboBox.getFullyQualifiedName()) + .orElseGet(comboBox::getPartialName); + String defaultValue = definition.defaultValue(); + if (defaultValue != null && !defaultValue.isBlank()) { + String filtered = + FormUtils.filterSingleChoiceSelection( + defaultValue, allowedOptions, comboName); + if (filtered != null) { + comboBox.setValue(filtered); + } + } + } + }, + LISTBOX("listbox", "listBox", PDListBox.class) { + @Override + PDTerminalField createField(PDAcroForm acroForm) { + return new PDListBox(acroForm); + } + + @Override + void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException { + PDListBox src = (PDListBox) source; + PDListBox dst = (PDListBox) target; + copyChoiceCharacteristics(src, dst); + if (src.getOptions() != null) { + dst.setOptions(src.getOptions()); + } + if (src.getValue() != null && !src.getValue().isEmpty()) { + dst.setValue(src.getValue()); + } + } + + @Override + boolean doesNotsupportsDefinitionCreation() { + return false; + } + + @Override + void applyNewFieldDefinition( + PDTerminalField field, + FormUtils.NewFormFieldDefinition definition, + List options) + throws IOException { + PDListBox listBox = (PDListBox) field; + listBox.setMultiSelect(Boolean.TRUE.equals(definition.multiSelect())); + if (!options.isEmpty()) { + listBox.setOptions(options); + } + List allowedOptions = FormUtils.collectChoiceAllowedValues(listBox); + String listBoxName = + Optional.ofNullable(listBox.getFullyQualifiedName()) + .orElseGet(listBox::getPartialName); + String defaultValue = definition.defaultValue(); + if (defaultValue != null && !defaultValue.isBlank()) { + if (Boolean.TRUE.equals(definition.multiSelect())) { + List selections = FormUtils.parseMultiChoiceSelections(defaultValue); + List filtered = + FormUtils.filterChoiceSelections( + selections, allowedOptions, listBoxName); + if (!filtered.isEmpty()) { + listBox.setValue(filtered); + } + } else { + String filtered = + FormUtils.filterSingleChoiceSelection( + defaultValue, allowedOptions, listBoxName); + if (filtered != null) { + listBox.setValue(filtered); + } + } + } + } + }, + SIGNATURE("signature", "signature", PDSignatureField.class) { + @Override + PDTerminalField createField(PDAcroForm acroForm) { + return new PDSignatureField(acroForm); + } + }, + BUTTON("button", "pushButton", PDPushButton.class) { + @Override + PDTerminalField createField(PDAcroForm acroForm) { + return new PDPushButton(acroForm); + } + }; + + private static final Map BY_TYPE = + Arrays.stream(values()) + .collect( + Collectors.toUnmodifiableMap( + FormFieldTypeSupport::typeName, Function.identity())); + + private final String typeName; + private final String fallbackWidgetName; + private final Class fieldClass; + + FormFieldTypeSupport( + String typeName, + String fallbackWidgetName, + Class fieldClass) { + this.typeName = typeName; + this.fallbackWidgetName = fallbackWidgetName; + this.fieldClass = fieldClass; + } + + public static FormFieldTypeSupport forField(PDField field) { + if (field == null) { + return null; + } + for (FormFieldTypeSupport handler : values()) { + if (handler.fieldClass.isInstance(field)) { + return handler; + } + } + return null; + } + + public static FormFieldTypeSupport forTypeName(String typeName) { + if (typeName == null) { + return null; + } + return BY_TYPE.get(typeName); + } + + private static void copyChoiceCharacteristics(PDChoice sourceField, PDChoice targetField) { + if (sourceField == null || targetField == null) { + return; + } + + try { + int flags = sourceField.getCOSObject().getInt(COSName.FF); + targetField.getCOSObject().setInt(COSName.FF, flags); + } catch (Exception e) { + // ignore and continue + } + + if (sourceField instanceof PDListBox sourceList + && targetField instanceof PDListBox targetList) { + try { + targetList.setMultiSelect(sourceList.isMultiSelect()); + } catch (Exception ignored) { + // ignore + } + } + } + + String typeName() { + return typeName; + } + + String fallbackWidgetName() { + return fallbackWidgetName; + } + + abstract PDTerminalField createField(PDAcroForm acroForm); + + void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException { + // default no-op + } + + boolean doesNotsupportsDefinitionCreation() { + return true; + } + + void applyNewFieldDefinition( + PDTerminalField field, + FormUtils.NewFormFieldDefinition definition, + List options) + throws IOException { + // default no-op + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/util/FormUtils.java b/app/proprietary/src/main/java/stirling/software/proprietary/util/FormUtils.java new file mode 100644 index 0000000000..f35a3c3080 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/util/FormUtils.java @@ -0,0 +1,1762 @@ +package stirling.software.proprietary.util; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentCatalog; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream; +import org.apache.pdfbox.pdmodel.interactive.form.*; +import org.apache.pdfbox.rendering.ImageType; +import org.apache.pdfbox.rendering.PDFRenderer; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.ApplicationContextProvider; +import stirling.software.common.util.ExceptionUtils; +import stirling.software.common.util.RegexPatternUtils; + +@Slf4j +@UtilityClass +public class FormUtils { + + // Field type constants + public final String FIELD_TYPE_TEXT = "text"; + public final String FIELD_TYPE_CHECKBOX = "checkbox"; + public final String FIELD_TYPE_COMBOBOX = "combobox"; + public final String FIELD_TYPE_LISTBOX = "listbox"; + public final String FIELD_TYPE_RADIO = "radio"; + public final String FIELD_TYPE_BUTTON = "button"; + public final String FIELD_TYPE_SIGNATURE = "signature"; + + // Set of choice field types that support options + public final Set CHOICE_FIELD_TYPES = + Set.of(FIELD_TYPE_COMBOBOX, FIELD_TYPE_LISTBOX, FIELD_TYPE_RADIO); + + /** + * Returns a normalized logical type string for the supplied PDFBox field instance. Centralized + * so all callers share identical mapping logic. + * + * @param field PDField to classify + * @return one of: signature, button, text, checkbox, combobox, listbox, radio (defaults to + * text) + */ + public String detectFieldType(PDField field) { + if (field instanceof PDSignatureField) { + return FIELD_TYPE_SIGNATURE; + } + if (field instanceof PDPushButton) { + return FIELD_TYPE_BUTTON; + } + if (field instanceof PDTextField) { + return FIELD_TYPE_TEXT; + } + if (field instanceof PDCheckBox) { + return FIELD_TYPE_CHECKBOX; + } + if (field instanceof PDComboBox) { + return FIELD_TYPE_COMBOBOX; + } + if (field instanceof PDListBox) { + return FIELD_TYPE_LISTBOX; + } + if (field instanceof PDRadioButton) { + return FIELD_TYPE_RADIO; + } + return FIELD_TYPE_TEXT; + } + + public List extractFormFields(PDDocument document) { + if (document == null) return List.of(); + + PDAcroForm acroForm = getAcroFormSafely(document); + if (acroForm == null) return List.of(); + + List fields = new ArrayList<>(); + Map typeCounters = new HashMap<>(); + Map pageOrderCounters = new HashMap<>(); + for (PDField field : acroForm.getFieldTree()) { + if (!(field instanceof PDTerminalField terminalField)) { + continue; + } + + String type = detectFieldType(terminalField); + + String name = + Optional.ofNullable(field.getFullyQualifiedName()) + .orElseGet(field::getPartialName); + if (name == null || name.isBlank()) { + continue; + } + + String currentValue = safeValue(terminalField); + boolean required = field.isRequired(); + int pageIndex = resolveFirstWidgetPageIndex(document, terminalField); + List options = resolveOptions(terminalField); + String tooltip = resolveTooltip(terminalField); + int typeIndex = typeCounters.merge(type, 1, Integer::sum); + String displayLabel = + deriveDisplayLabel(field, name, tooltip, type, typeIndex, options); + boolean multiSelect = resolveMultiSelect(terminalField); + int pageOrder = pageOrderCounters.merge(pageIndex, 1, Integer::sum) - 1; + + fields.add( + new FormFieldInfo( + name, + displayLabel, + type, + currentValue, + options.isEmpty() ? null : Collections.unmodifiableList(options), + required, + pageIndex, + multiSelect, + tooltip, + pageOrder)); + } + + fields.sort( + (a, b) -> { + int pageCompare = Integer.compare(a.pageIndex(), b.pageIndex()); + if (pageCompare != 0) { + return pageCompare; + } + int orderCompare = Integer.compare(a.pageOrder(), b.pageOrder()); + if (orderCompare != 0) { + return orderCompare; + } + return a.name().compareToIgnoreCase(b.name()); + }); + + return Collections.unmodifiableList(fields); + } + + /** + * Build a single record object (field-name -> value placeholder) that can be directly submitted + * to /api/v1/form/fill as the 'data' JSON. For checkboxes a boolean false is supplied unless + * currently checked. For list/choice fields we default to empty string. For multi-select list + * boxes we return an empty JSON array. Radio buttons get their current value (or empty string). + * Signature and button fields are skipped. + */ + public Map buildFillTemplateRecord(List extracted) { + if (extracted == null || extracted.isEmpty()) return Map.of(); + Map record = new LinkedHashMap<>(); + for (FormFieldInfo info : extracted) { + if (info == null || info.name() == null || info.name().isBlank()) { + continue; + } + String type = info.type(); + Object value; + switch (type) { + case FIELD_TYPE_CHECKBOX: + value = isChecked(info.value()) ? Boolean.TRUE : Boolean.FALSE; + break; + case FIELD_TYPE_LISTBOX: + if (info.multiSelect()) { + value = new ArrayList<>(); + } else { + value = safeDefault(info.value()); + } + break; + case FIELD_TYPE_BUTTON, FIELD_TYPE_SIGNATURE: + continue; // skip non-fillable + default: + value = safeDefault(info.value()); + } + record.put(info.name(), value); + } + return record; + } + + public FormFieldExtraction extractFieldsWithTemplate(PDDocument document) { + List fields = extractFormFields(document); + Map template = buildFillTemplateRecord(fields); + return new FormFieldExtraction(fields, template); + } + + private String safeDefault(String current) { + return current != null ? current : ""; + } + + public void applyFieldValues( + PDDocument document, Map values, boolean flatten, boolean strict) + throws IOException { + if (document == null) { + return; + } + + PDAcroForm acroForm = getAcroFormSafely(document); + if (acroForm == null) { + if (strict) { + throw new IOException("No AcroForm present in document"); + } + log.debug("Skipping form fill because document has no AcroForm"); + if (flatten) { + flattenEntireDocument(document, null); + } + return; + } + + if (values != null && !values.isEmpty()) { + acroForm.setCacheFields(true); + + Map lookup = new LinkedHashMap<>(); + for (PDField field : acroForm.getFieldTree()) { + String fqName = field.getFullyQualifiedName(); + if (fqName != null) { + lookup.putIfAbsent(fqName, field); + } + String partial = field.getPartialName(); + if (partial != null) { + lookup.putIfAbsent(partial, field); + } + } + + for (Map.Entry entry : values.entrySet()) { + String key = entry.getKey(); + if (key == null || key.isBlank()) { + continue; + } + + PDField field = lookup.get(key); + if (field == null) { + field = acroForm.getField(key); + } + if (field == null) { + log.debug("No matching field found for '{}', skipping", key); + continue; + } + + Object rawValue = entry.getValue(); + String value = rawValue == null ? null : Objects.toString(rawValue, null); + applyValueToField(field, value, strict); + } + + ensureAppearances(acroForm); + } + + repairWidgetGeometry(document, acroForm); + + if (flatten) { + flattenEntireDocument(document, acroForm); + } + } + + private void flattenViaRendering(PDDocument document, PDAcroForm acroForm) throws IOException { + if (document == null) { + return; + } + + // Remove the AcroForm structure first since we're rendering everything + if (acroForm != null) { + try { + if (document.getDocumentCatalog() != null) { + document.getDocumentCatalog().setAcroForm(null); + } + } catch (Exception e) { + log.debug("Failed to remove AcroForm before rendering: {}", e.getMessage()); + } + } + + PDFRenderer renderer = new PDFRenderer(document); + ApplicationProperties properties = + ApplicationContextProvider.getBean(ApplicationProperties.class); + + int requestedDpi = + properties != null && properties.getSystem() != null + ? properties.getSystem().getMaxDPI() + : 300; + + rebuildDocumentFromImages(document, renderer, requestedDpi); + } + + // note: this implementation suffers from: + // https://issues.apache.org/jira/browse/PDFBOX-5962 + private void flattenEntireDocument(PDDocument document, PDAcroForm acroForm) + throws IOException { + if (document == null) { + return; + } + + flattenViaRendering(document, acroForm); + } + + private void rebuildDocumentFromImages(PDDocument document, PDFRenderer renderer, int dpi) + throws IOException { + int pageCount = document.getNumberOfPages(); + + for (int pageIndex = 0; pageIndex < pageCount; pageIndex++) { + BufferedImage rendered; + try { + rendered = renderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB); + } catch (OutOfMemoryError e) { + throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e); + } catch (NegativeArraySizeException e) { + throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e); + } + + PDPage page = document.getPage(pageIndex); + PDRectangle mediaBox = page.getMediaBox(); + + // Ensure the page has resources before drawing + if (page.getResources() == null) { + page.setResources(new PDResources()); + } + + List annotations = new ArrayList<>(page.getAnnotations()); + for (PDAnnotation annotation : annotations) { + annotation.getCOSObject().removeItem(COSName.AP); + page.getAnnotations().remove(annotation); + } + + try (PDPageContentStream contentStream = + new PDPageContentStream( + document, page, PDPageContentStream.AppendMode.OVERWRITE, true, true)) { + PDImageXObject pdImage = JPEGFactory.createFromImage(document, rendered); + contentStream.drawImage( + pdImage, + mediaBox.getLowerLeftX(), + mediaBox.getLowerLeftY(), + mediaBox.getWidth(), + mediaBox.getHeight()); + } + } + } + + private void repairWidgetGeometry(PDDocument document, PDAcroForm acroForm) { + if (document == null || acroForm == null) { + return; + } + + for (PDField field : acroForm.getFieldTree()) { + if (!(field instanceof PDTerminalField terminalField)) { + continue; + } + + List widgets = terminalField.getWidgets(); + if (widgets == null || widgets.isEmpty()) { + continue; + } + + for (PDAnnotationWidget widget : widgets) { + if (widget == null) { + continue; + } + + PDRectangle rectangle = widget.getRectangle(); + boolean invalidRectangle = + rectangle == null + || rectangle.getWidth() <= 0 + || rectangle.getHeight() <= 0; + + PDPage page = widget.getPage(); + if (page == null) { + page = resolveWidgetPage(document, widget); + if (page != null) { + widget.setPage(page); + } + } + + if (invalidRectangle) { + if (page == null && document.getNumberOfPages() > 0) { + page = document.getPage(0); + widget.setPage(page); + } + + if (page != null) { + PDRectangle mediaBox = page.getMediaBox(); + float fallbackWidth = Math.min(200f, mediaBox.getWidth()); + float fallbackHeight = Math.min(40f, mediaBox.getHeight()); + PDRectangle fallbackRectangle = + new PDRectangle( + mediaBox.getLowerLeftX(), + mediaBox.getLowerLeftY(), + fallbackWidth, + fallbackHeight); + widget.setRectangle(fallbackRectangle); + + try { + List pageAnnotations = page.getAnnotations(); + if (pageAnnotations != null && !pageAnnotations.contains(widget)) { + pageAnnotations.add(widget); + } + } catch (IOException e) { + log.debug( + "Unable to repair annotations for widget '{}': {}", + terminalField.getFullyQualifiedName(), + e.getMessage()); + } + } + } + } + } + } + + public void applyFieldValues(PDDocument document, Map values, boolean flatten) + throws IOException { + applyFieldValues(document, values, flatten, false); + } + + private void ensureAppearances(PDAcroForm acroForm) { + if (acroForm == null) return; + + acroForm.setNeedAppearances(true); + try { + try { + PDResources dr = acroForm.getDefaultResources(); + if (dr == null) { + dr = new PDResources(); + acroForm.setDefaultResources(dr); + } + PDFont helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + try { + // Map standard name used by many DAs + dr.put(COSName.getPDFName("Helvetica"), helvetica); + } catch (Exception ignore) { + try { + dr.add(helvetica); + } catch (Exception ignore2) { + // ignore + } + } + } catch (Exception fontPrep) { + log.debug( + "Unable to ensure default font resources before refresh: {}", + fontPrep.getMessage()); + } + acroForm.refreshAppearances(); + } catch (IOException e) { + log.warn("Failed to refresh form appearances: {}", e.getMessage(), e); + return; // Don't set NeedAppearances to false if refresh failed + } + + // After successful appearance generation, set NeedAppearances to false + // to signal that appearance streams are now embedded authoritatively + try { + acroForm.setNeedAppearances(false); + } catch (Exception ignored) { + // Fallback to direct COS manipulation if the setter fails + acroForm.getCOSObject().setBoolean(COSName.NEED_APPEARANCES, false); + } + } + + private PDAcroForm getAcroFormSafely(PDDocument document) { + try { + PDDocumentCatalog catalog = document.getDocumentCatalog(); + return catalog != null ? catalog.getAcroForm() : null; + } catch (Exception e) { + log.warn("Unable to access AcroForm: {}", e.getMessage(), e); + return null; + } + } + + public String filterSingleChoiceSelection( + String selection, List allowedOptions, String fieldName) { + if (selection == null || selection.trim().isEmpty()) return null; + List filtered = + filterChoiceSelections(List.of(selection), allowedOptions, fieldName); + return filtered.isEmpty() ? null : filtered.get(0); + } + + private void applyValueToField(PDField field, String value, boolean strict) throws IOException { + try { + if (field instanceof PDTextField textField) { + setTextValue(textField, value); + } else if (field instanceof PDCheckBox checkBox) { + LinkedHashSet candidateStates = collectCheckBoxStates(checkBox); + boolean shouldCheck = shouldCheckBoxBeChecked(value, candidateStates); + try { + if (shouldCheck) { + checkBox.check(); + } else { + checkBox.unCheck(); + } + } catch (IOException checkProblem) { + log.warn( + "Failed to set checkbox state for '{}': {}", + field.getFullyQualifiedName(), + checkProblem.getMessage(), + checkProblem); + if (strict) { + throw checkProblem; + } + } + } else if (field instanceof PDRadioButton radioButton) { + if (value != null && !value.isBlank()) { + radioButton.setValue(value); + } + } else if (field instanceof PDChoice choiceField) { + applyChoiceValue(choiceField, value); + } else if (field instanceof PDPushButton) { + log.debug("Ignore Push button"); + } else if (field instanceof PDSignatureField) { + log.debug("Skipping signature field '{}'", field.getFullyQualifiedName()); + } else { + field.setValue(value != null ? value : ""); + } + } catch (Exception e) { + log.warn( + "Failed to set value for field '{}': {}", + field.getFullyQualifiedName(), + e.getMessage(), + e); + if (strict) { + if (e instanceof IOException io) { + throw io; + } + throw new IOException( + "Failed to set value for field '" + field.getFullyQualifiedName() + "'", e); + } + } + } + + void setTextValue(PDTextField textField, String value) throws IOException { + try { + textField.setValue(value != null ? value : ""); + return; + } catch (IOException initial) { + log.debug( + "Primary fill failed for text field '{}': {}", + textField.getFullyQualifiedName(), + initial.getMessage()); + } + + try { + PDAcroForm acroForm = textField.getAcroForm(); + PDResources dr = acroForm != null ? acroForm.getDefaultResources() : null; + if (dr == null && acroForm != null) { + dr = new PDResources(); + acroForm.setDefaultResources(dr); + } + + String resourceName = "Helv"; + try { + PDFont helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + if (dr != null) { + try { + COSName alias = dr.add(helvetica); + if (alias != null + && alias.getName() != null + && !alias.getName().isBlank()) { + resourceName = alias.getName(); + } + } catch (Exception addEx) { + try { + COSName explicit = COSName.getPDFName("Helvetica"); + dr.put(explicit, helvetica); + resourceName = explicit.getName(); + } catch (Exception ignore) { + // ignore + } + } + } + } catch (Exception fontEx) { + log.debug( + "Unable to prepare Helvetica font for '{}': {}", + textField.getFullyQualifiedName(), + fontEx.getMessage()); + } + + textField.setDefaultAppearance("/" + resourceName + " 12 Tf 0 g"); + } catch (Exception e) { + log.debug( + "Unable to adjust default appearance for '{}': {}", + textField.getFullyQualifiedName(), + e.getMessage()); + } + + textField.setValue(value != null ? value : ""); + } + + private void applyChoiceValue(PDChoice choiceField, String value) throws IOException { + if (value == null) { + choiceField.setValue(""); + return; + } + + List allowedOptions = collectChoiceAllowedValues(choiceField); + + if (choiceField.isMultiSelect()) { + List selections = parseMultiChoiceSelections(value); + List filteredSelections = + filterChoiceSelections( + selections, allowedOptions, choiceField.getFullyQualifiedName()); + if (filteredSelections.isEmpty()) { + choiceField.setValue(Collections.emptyList()); + } else { + choiceField.setValue(filteredSelections); + } + } else { + String selected = + filterSingleChoiceSelection( + value, allowedOptions, choiceField.getFullyQualifiedName()); + choiceField.setValue(Objects.requireNonNullElse(selected, "")); + } + } + + List filterChoiceSelections( + List selections, List allowedOptions, String fieldName) { + if (selections == null || selections.isEmpty()) { + return Collections.emptyList(); + } + + List sanitizedSelections = + selections.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + + if (sanitizedSelections.isEmpty()) { + return Collections.emptyList(); + } + + if (allowedOptions == null || allowedOptions.isEmpty()) { + throw new IllegalArgumentException( + "The /Opt array is missing for choice field '" + + fieldName + + "', cannot set values."); + } + + Map allowedLookup = new LinkedHashMap<>(); + for (String option : allowedOptions) { + if (option == null) { + continue; + } + String normalized = option.trim(); + if (!normalized.isEmpty()) { + allowedLookup.putIfAbsent(normalized.toLowerCase(Locale.ROOT), option); + } + } + + List validSelections = new ArrayList<>(); + for (String selection : sanitizedSelections) { + String normalized = selection.toLowerCase(Locale.ROOT); + String resolved = allowedLookup.get(normalized); + if (resolved != null) { + validSelections.add(resolved); + } else { + log.debug( + "Ignoring unsupported option '{}' for choice field '{}'", + selection, + fieldName); + } + } + return validSelections; + } + + List parseMultiChoiceSelections(String raw) { + if (raw == null || raw.isBlank()) return List.of(); + return Arrays.stream(raw.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(ArrayList::new, ArrayList::add, ArrayList::addAll); + } + + List collectChoiceAllowedValues(PDChoice choiceField) { + if (choiceField == null) { + return Collections.emptyList(); + } + + LinkedHashSet allowed = new LinkedHashSet<>(); + + try { + List exports = choiceField.getOptionsExportValues(); + if (exports != null) { + exports.stream() + .filter(Objects::nonNull) + .forEach( + option -> { + String cleaned = option.trim(); + if (!cleaned.isEmpty()) { + allowed.add(option); + } + }); + } + } catch (Exception e) { + log.debug( + "Unable to read export values for choice field '{}': {}", + choiceField.getFullyQualifiedName(), + e.getMessage()); + } + + try { + List display = choiceField.getOptionsDisplayValues(); + if (display != null) { + display.stream() + .filter(Objects::nonNull) + .forEach( + option -> { + String cleaned = option.trim(); + if (!cleaned.isEmpty()) { + allowed.add(option); + } + }); + } + } catch (Exception e) { + log.debug( + "Unable to read display values for choice field '{}': {}", + choiceField.getFullyQualifiedName(), + e.getMessage()); + } + + if (allowed.isEmpty()) { + return Collections.emptyList(); + } + + return new ArrayList<>(allowed); + } + + boolean isChecked(String value) { + if (value == null) return false; + String normalized = value.trim().toLowerCase(); + return "true".equals(normalized) + || "1".equals(normalized) + || "yes".equals(normalized) + || "on".equals(normalized) + || "checked".equals(normalized); + } + + private LinkedHashSet collectCheckBoxStates(PDCheckBox checkBox) { + LinkedHashSet states = new LinkedHashSet<>(); + try { + String onValue = checkBox.getOnValue(); + if (isSettableCheckBoxState(onValue)) { + states.add(onValue.trim()); + } + } catch (Exception e) { + log.debug( + "Failed to obtain explicit on-value for checkbox '{}': {}", + checkBox.getFullyQualifiedName(), + e.getMessage()); + } + + try { + for (PDAnnotationWidget widget : checkBox.getWidgets()) { + PDAppearanceDictionary appearance = widget.getAppearance(); + if (appearance == null) { + continue; + } + PDAppearanceEntry normal = appearance.getNormalAppearance(); + if (normal == null) { + continue; + } + if (normal.isSubDictionary()) { + Map entries = normal.getSubDictionary(); + if (entries != null) { + for (COSName name : entries.keySet()) { + String state = name.getName(); + if (isSettableCheckBoxState(state)) { + states.add(state.trim()); + } + } + } + } else if (normal.isStream()) { + COSName appearanceState = widget.getAppearanceState(); + String state = appearanceState != null ? appearanceState.getName() : null; + if (isSettableCheckBoxState(state)) { + states.add(state.trim()); + } + } + } + } catch (Exception e) { + log.debug( + "Failed to obtain appearance states for checkbox '{}': {}", + checkBox.getFullyQualifiedName(), + e.getMessage()); + } + + try { + List exports = checkBox.getExportValues(); + if (exports != null) { + for (String export : exports) { + if (isSettableCheckBoxState(export)) { + states.add(export.trim()); + } + } + } + } catch (Exception e) { + log.debug( + "Failed to obtain export values for checkbox '{}': {}", + checkBox.getFullyQualifiedName(), + e.getMessage()); + } + return states; + } + + private String safeValue(PDTerminalField field) { + try { + return field.getValueAsString(); + } catch (Exception e) { + log.debug( + "Failed to read current value for field '{}': {}", + field.getFullyQualifiedName(), + e.getMessage()); + return null; + } + } + + List resolveOptions(PDTerminalField field) { + try { + if (field instanceof PDChoice choice) { + List display = choice.getOptionsDisplayValues(); + if (display != null && !display.isEmpty()) { + return new ArrayList<>(display); + } + List exportValues = choice.getOptionsExportValues(); + if (exportValues != null && !exportValues.isEmpty()) { + return new ArrayList<>(exportValues); + } + } else if (field instanceof PDRadioButton radio) { + List exports = radio.getExportValues(); + if (exports != null && !exports.isEmpty()) { + return new ArrayList<>(exports); + } + } else if (field instanceof PDCheckBox checkBox) { + List exports = checkBox.getExportValues(); + if (exports != null && !exports.isEmpty()) { + return new ArrayList<>(exports); + } + } + } catch (Exception e) { + log.debug( + "Failed to resolve options for field '{}': {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + return Collections.emptyList(); + } + + private boolean resolveMultiSelect(PDTerminalField field) { + if (field instanceof PDListBox listBox) { + try { + return listBox.isMultiSelect(); + } catch (Exception e) { + log.debug( + "Failed to resolve multi-select flag for list box '{}': {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + } + return false; + } + + private boolean isSettableCheckBoxState(String state) { + if (state == null) return false; + String trimmed = state.trim(); + return !trimmed.isEmpty() && !"Off".equalsIgnoreCase(trimmed); + } + + private boolean shouldCheckBoxBeChecked(String value, LinkedHashSet candidateStates) { + if (value == null) { + return false; + } + if (isChecked(value)) { + return true; + } + String normalized = value.trim(); + if (normalized.isEmpty() || "off".equalsIgnoreCase(normalized)) { + return false; + } + for (String state : candidateStates) { + if (state.equalsIgnoreCase(normalized)) { + return true; + } + } + return false; + } + + private String deriveDisplayLabel( + PDField field, + String name, + String tooltip, + String type, + int typeIndex, + List options) { + String alternate = cleanLabel(field.getAlternateFieldName()); + if (alternate != null && !looksGeneric(alternate)) { + return alternate; + } + + String tooltipLabel = cleanLabel(tooltip); + if (tooltipLabel != null && !looksGeneric(tooltipLabel)) { + return tooltipLabel; + } + + // Only check options for choice-type fields (combobox, listbox, radio) + if (CHOICE_FIELD_TYPES.contains(type) && options != null && !options.isEmpty()) { + String optionCandidate = cleanLabel(options.get(0)); + if (optionCandidate != null && !looksGeneric(optionCandidate)) { + return optionCandidate; + } + } + + String humanized = cleanLabel(humanizeName(name)); + if (humanized != null && !looksGeneric(humanized)) { + return humanized; + } + + return fallbackLabelForType(type, typeIndex); + } + + private String cleanLabel(String label) { + if (label == null) return null; + + RegexPatternUtils patterns = RegexPatternUtils.getInstance(); + String cleaned = label.trim(); + + cleaned = patterns.getPattern("[.:]+$").matcher(cleaned).replaceAll("").trim(); + + return cleaned.isEmpty() ? null : cleaned; + } + + private boolean looksGeneric(String value) { + if (value == null) return true; + + RegexPatternUtils patterns = RegexPatternUtils.getInstance(); + String simplified = patterns.getPunctuationPattern().matcher(value).replaceAll(" ").trim(); + + if (simplified.isEmpty()) return true; + + return patterns.getGenericFieldNamePattern().matcher(simplified).matches() + || patterns.getSimpleFormFieldPattern().matcher(simplified).matches() + || patterns.getOptionalTNumericPattern().matcher(simplified).matches(); + } + + private String humanizeName(String name) { + if (name == null) return null; + + RegexPatternUtils patterns = RegexPatternUtils.getInstance(); + + String cleaned = patterns.getFormFieldBracketPattern().matcher(name).replaceAll(" "); + cleaned = cleaned.replace('.', ' '); + cleaned = patterns.getUnderscoreHyphenPattern().matcher(cleaned).replaceAll(" "); + cleaned = patterns.getCamelCaseBoundaryPattern().matcher(cleaned).replaceAll(" "); + cleaned = patterns.getWhitespacePattern().matcher(cleaned).replaceAll(" ").trim(); + + return cleaned.isEmpty() ? null : cleaned; + } + + public void modifyFormFields( + PDDocument document, List modifications) { + if (document == null || modifications == null || modifications.isEmpty()) return; + + PDAcroForm acroForm = getAcroFormSafely(document); + if (acroForm == null) { + log.warn("Cannot modify fields because the document has no AcroForm"); + return; + } + + Set existingNames = collectExistingFieldNames(acroForm); + + for (ModifyFormFieldDefinition modification : modifications) { + if (modification == null || modification.targetName() == null) { + continue; + } + + String lookupName = modification.targetName().trim(); + if (lookupName.isEmpty()) { + continue; + } + + PDField originalField = locateField(acroForm, lookupName); + if (originalField == null) { + log.warn("No matching field '{}' found for modification", lookupName); + continue; + } + + List widgets = originalField.getWidgets(); + if (widgets == null || widgets.isEmpty()) { + log.warn("Field '{}' has no widgets; skipping modification", lookupName); + continue; + } + + PDAnnotationWidget widget = widgets.get(0); + PDRectangle originalRectangle = cloneRectangle(widget.getRectangle()); + PDPage page = resolveWidgetPage(document, widget); + if (page == null || originalRectangle == null) { + log.warn( + "Unable to resolve widget page or rectangle for '{}'; skipping", + lookupName); + continue; + } + + String resolvedType = + Optional.ofNullable(modification.type()) + .map(FormUtils::normalizeFieldType) + .orElseGet(() -> detectFieldType(originalField)); + + if (!RegexPatternUtils.getInstance() + .getSupportedNewFieldTypes() + .contains(resolvedType)) { + log.warn("Unsupported target type '{}' for field '{}'", resolvedType, lookupName); + continue; + } + + String desiredName = + Optional.ofNullable(modification.name()) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .orElseGet(originalField::getPartialName); + + if (desiredName != null) { + existingNames.remove(originalField.getFullyQualifiedName()); + existingNames.remove(originalField.getPartialName()); + desiredName = generateUniqueFieldName(desiredName, existingNames); + existingNames.add(desiredName); + } + + // Try to modify field in-place first for simple property changes + String currentType = detectFieldType(originalField); + boolean typeChanging = !currentType.equals(resolvedType); + + if (!typeChanging) { + try { + modifyFieldPropertiesInPlace(originalField, modification, desiredName); + log.debug("Successfully modified field '{}' in-place", lookupName); + continue; // Skip the remove-and-recreate process + } catch (Exception e) { + log.debug( + "In-place modification failed for '{}', falling back to recreation: {}", + lookupName, + e.getMessage()); + } + } + + // For type changes or when in-place modification fails, use remove-and-recreate + // But create the new field first to ensure success before removing the original + NewFormFieldDefinition replacementDefinition = + new NewFormFieldDefinition( + desiredName, + modification.label(), + resolvedType, + determineWidgetPageIndex(document, widget), + originalRectangle.getLowerLeftX(), + originalRectangle.getLowerLeftY(), + originalRectangle.getWidth(), + originalRectangle.getHeight(), + modification.required(), + modification.multiSelect(), + modification.options(), + modification.defaultValue(), + modification.tooltip()); + + List sanitizedOptions = sanitizeOptions(modification.options()); + + try { + FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType); + if (handler == null || handler.doesNotsupportsDefinitionCreation()) { + handler = FormFieldTypeSupport.TEXT; + } + + // Create new field first - if this fails, original field is preserved + createNewField( + handler, + acroForm, + page, + originalRectangle, + desiredName, + replacementDefinition, + sanitizedOptions); // Don't reuse widget for type changes + + removeFieldFromDocument(document, acroForm, originalField); + + log.debug( + "Successfully replaced field '{}' with type '{}'", + lookupName, + resolvedType); + } catch (Exception e) { + log.warn( + "Failed to modify form field '{}' to type '{}': {}", + lookupName, + resolvedType, + e.getMessage(), + e); + } + } + + ensureAppearances(acroForm); + } + + private void modifyFieldPropertiesInPlace( + PDField field, ModifyFormFieldDefinition modification, String newName) + throws IOException { + if (newName != null && !newName.equals(field.getPartialName())) { + field.setPartialName(newName); + } + + if (modification.label() != null) { + if (!modification.label().isBlank()) { + field.setAlternateFieldName(modification.label()); + } else { + field.setAlternateFieldName(null); + } + } + + if (modification.required() != null) { + field.setRequired(modification.required()); + } + + if (modification.defaultValue() != null) { + if (!modification.defaultValue().isBlank()) { + field.setValue(modification.defaultValue()); + } else { + field.setValue(null); + } + } + + if (field instanceof PDChoice choiceField + && (modification.options() != null || modification.multiSelect() != null)) { + + if (modification.options() != null) { + List sanitizedOptions = sanitizeOptions(modification.options()); + choiceField.setOptions(sanitizedOptions); + } + + if (modification.multiSelect() != null) { + choiceField.setMultiSelect(modification.multiSelect()); + } + } + + // Update tooltip on widgets + if (modification.tooltip() != null) { + List widgets = field.getWidgets(); + for (PDAnnotationWidget widget : widgets) { + if (!modification.tooltip().isBlank()) { + widget.getCOSObject().setString(COSName.TU, modification.tooltip()); + } else { + widget.getCOSObject().removeItem(COSName.TU); + } + } + } + } + + private String fallbackLabelForType(String type, int typeIndex) { + String suffix = " " + typeIndex; + return switch (type) { + case FIELD_TYPE_CHECKBOX -> "Checkbox" + suffix; + case FIELD_TYPE_RADIO -> "Option" + suffix; + case FIELD_TYPE_COMBOBOX -> "Dropdown" + suffix; + case FIELD_TYPE_LISTBOX -> "List" + suffix; + case FIELD_TYPE_TEXT -> "Text field" + suffix; + default -> "Field" + suffix; + }; + } + + private String resolveTooltip(PDTerminalField field) { + List widgets = field.getWidgets(); + if (widgets == null) { + return null; + } + for (PDAnnotationWidget widget : widgets) { + if (widget == null) { + continue; + } + try { + String alt = widget.getAnnotationName(); + if (alt != null && !alt.isBlank()) { + return alt; + } + String tooltip = widget.getCOSObject().getString(COSName.TU); + if (tooltip != null && !tooltip.isBlank()) { + return tooltip; + } + } catch (Exception e) { + log.debug( + "Failed to read tooltip for field '{}': {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + } + return null; + } + + private int resolveFirstWidgetPageIndex(PDDocument document, PDTerminalField field) { + List widgets = field.getWidgets(); + if (widgets == null || widgets.isEmpty()) { + return -1; + } + Map widgetPageFallbacks = null; + for (PDAnnotationWidget widget : widgets) { + int idx = resolveWidgetPageIndex(document, widget); + if (idx >= 0) { + return idx; + } + try { + COSDictionary widgetDictionary = widget.getCOSObject(); + if (widgetDictionary != null + && widgetDictionary.getDictionaryObject(COSName.P) == null) { + if (widgetPageFallbacks == null) { + widgetPageFallbacks = buildWidgetPageFallbackMap(document); + } + Integer fallbackIndex = widgetPageFallbacks.get(widget); + if (fallbackIndex != null && fallbackIndex >= 0) { + return fallbackIndex; + } + } + } catch (Exception e) { + log.debug( + "Failed to inspect widget page reference for field '{}': {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + } + return -1; + } + + private int resolveWidgetPageIndex(PDDocument document, PDAnnotationWidget widget) { + if (document == null || widget == null) { + return -1; + } + try { + COSDictionary widgetDictionary = widget.getCOSObject(); + if (widgetDictionary != null + && widgetDictionary.getDictionaryObject(COSName.P) == null) { + Map fallback = buildWidgetPageFallbackMap(document); + Integer index = fallback.get(widget); + if (index != null) { + return index; + } + } + } catch (Exception e) { + log.debug("Widget page lookup via fallback map failed: {}", e.getMessage()); + } + try { + PDPage page = widget.getPage(); + if (page != null) { + int idx = document.getPages().indexOf(page); + if (idx >= 0) { + return idx; + } + } + } catch (Exception e) { + log.debug("Widget page lookup failed: {}", e.getMessage()); + } + + int pageCount = document.getNumberOfPages(); + for (int i = 0; i < pageCount; i++) { + try { + PDPage candidate = document.getPage(i); + List annotations = candidate.getAnnotations(); + for (PDAnnotation annotation : annotations) { + if (annotation == widget) { + return i; + } + } + } catch (IOException e) { + log.debug("Failed to inspect annotations for page {}: {}", i, e.getMessage()); + } + } + return -1; + } + + public void deleteFormFields(PDDocument document, List fieldNames) { + if (document == null || fieldNames == null || fieldNames.isEmpty()) return; + + PDAcroForm acroForm = getAcroFormSafely(document); + if (acroForm == null) { + log.warn("Cannot delete fields because the document has no AcroForm"); + return; + } + + for (String name : fieldNames) { + if (name == null || name.isBlank()) { + continue; + } + + PDField field = locateField(acroForm, name.trim()); + if (field == null) { + log.warn("No matching field '{}' found for deletion", name); + continue; + } + + removeFieldFromDocument(document, acroForm, field); + } + + ensureAppearances(acroForm); + } + + private void removeFieldFromDocument(PDDocument document, PDAcroForm acroForm, PDField field) { + if (field == null) return; + + try { + List widgets = field.getWidgets(); + if (widgets != null) { + for (PDAnnotationWidget widget : widgets) { + PDPage page = resolveWidgetPage(document, widget); + if (page != null) { + page.getAnnotations().remove(widget); + } + } + widgets.clear(); + } + + PDNonTerminalField parent = field.getParent(); + if (parent != null) { + List children = parent.getChildren(); + if (children != null) { + children.removeIf(existing -> existing == field); + } + + try { + COSArray kids = parent.getCOSObject().getCOSArray(COSName.KIDS); + if (kids != null) { + kids.removeObject(field.getCOSObject()); + } + } catch (Exception e) { + log.debug( + "Failed to remove field '{}' from parent kids array: {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + } + + if (acroForm != null) { + pruneFieldReferences(acroForm.getFields(), field); + + try { + COSArray fieldsArray = acroForm.getCOSObject().getCOSArray(COSName.FIELDS); + if (fieldsArray != null) { + fieldsArray.removeObject(field.getCOSObject()); + } + } catch (Exception e) { + log.debug( + "Failed to remove field '{}' from AcroForm COS array: {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + } + + try { + field.getCOSObject().clear(); + } catch (Exception e) { + log.debug( + "Failed to clear COS dictionary for field '{}': {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + } catch (Exception e) { + log.warn( + "Failed to detach field '{}' from document: {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + } + + private void pruneFieldReferences(List fields, PDField target) { + if (fields == null || fields.isEmpty() || target == null) return; + + fields.removeIf(existing -> isSameFieldReference(existing, target)); + + for (PDField existing : List.copyOf(fields)) { + if (existing instanceof PDNonTerminalField nonTerminal) { + List children = nonTerminal.getChildren(); + if (children != null && !children.isEmpty()) { + pruneFieldReferences(children, target); + } + } + } + } + + private boolean isSameFieldReference(PDField a, PDField b) { + if (a == b) return true; + if (a == null || b == null) return false; + + String aName = a.getFullyQualifiedName(); + String bName = b.getFullyQualifiedName(); + if (aName != null && aName.equals(bName)) return true; + + String aPartial = a.getPartialName(); + String bPartial = b.getPartialName(); + return aPartial != null && aPartial.equals(bPartial); + } + + private void createNewField( + FormFieldTypeSupport handler, + PDAcroForm acroForm, + PDPage page, + PDRectangle rectangle, + String name, + NewFormFieldDefinition definition, + List options) + throws IOException { + + if (handler.doesNotsupportsDefinitionCreation()) { + throw new IllegalArgumentException( + "Field type '" + handler.typeName() + "' cannot be created via definition"); + } + + PDTerminalField field = handler.createField(acroForm); + registerNewField(field, acroForm, page, rectangle, name, definition, null); + List preparedOptions = options != null ? options : List.of(); + handler.applyNewFieldDefinition(field, definition, preparedOptions); + } + + private PDRectangle cloneRectangle(PDRectangle rectangle) { + if (rectangle == null) { + return null; + } + return new PDRectangle( + rectangle.getLowerLeftX(), + rectangle.getLowerLeftY(), + rectangle.getWidth(), + rectangle.getHeight()); + } + + private PDPage resolveWidgetPage(PDDocument document, PDAnnotationWidget widget) { + if (widget == null) { + return null; + } + PDPage page = widget.getPage(); + if (page != null) { + return page; + } + int pageIndex = determineWidgetPageIndex(document, widget); + if (pageIndex >= 0) { + try { + return document.getPage(pageIndex); + } catch (Exception e) { + log.debug("Failed to resolve widget page index {}: {}", pageIndex, e.getMessage()); + } + } + return null; + } + + private int determineWidgetPageIndex(PDDocument document, PDAnnotationWidget widget) { + if (document == null || widget == null) { + return -1; + } + + PDPage directPage = widget.getPage(); + if (directPage != null) { + int index = 0; + for (PDPage page : document.getPages()) { + if (page == directPage) { + return index; + } + index++; + } + } + + int pageCount = document.getNumberOfPages(); + for (int i = 0; i < pageCount; i++) { + try { + PDPage page = document.getPage(i); + for (PDAnnotation annotation : page.getAnnotations()) { + if (annotation == widget) { + return i; + } + } + } catch (IOException e) { + log.debug("Failed to inspect annotations for page {}: {}", i, e.getMessage()); + } + } + return -1; + } + + private Map buildWidgetPageFallbackMap(PDDocument document) { + if (document == null) { + return Collections.emptyMap(); + } + + Map widgetToPage = new IdentityHashMap<>(); + int pageCount = document.getNumberOfPages(); + for (int pageIndex = 0; pageIndex < pageCount; pageIndex++) { + PDPage page; + try { + page = document.getPage(pageIndex); + } catch (Exception e) { + log.debug( + "Failed to access page {} while building widget map: {}", + pageIndex, + e.getMessage()); + continue; + } + + List annotations; + try { + annotations = page.getAnnotations(); + } catch (IOException e) { + log.debug( + "Failed to access annotations for page {}: {}", pageIndex, e.getMessage()); + continue; + } + + if (annotations == null || annotations.isEmpty()) { + continue; + } + + for (PDAnnotation annotation : annotations) { + if (!(annotation instanceof PDAnnotationWidget widget)) { + continue; + } + + COSDictionary widgetDictionary; + try { + widgetDictionary = widget.getCOSObject(); + } catch (Exception e) { + log.debug( + "Failed to access widget dictionary while building fallback map: {}", + e.getMessage()); + continue; + } + + if (widgetDictionary == null + || widgetDictionary.getDictionaryObject(COSName.P) != null) { + continue; + } + + widgetToPage.putIfAbsent(widget, pageIndex); + } + } + + return widgetToPage.isEmpty() ? Collections.emptyMap() : widgetToPage; + } + + private Set collectExistingFieldNames(PDAcroForm acroForm) { + if (acroForm == null) { + return Collections.emptySet(); + } + Set existing = new HashSet<>(); + for (PDField field : acroForm.getFieldTree()) { + if (field instanceof PDTerminalField) { + String fqn = field.getFullyQualifiedName(); + if (fqn != null && !fqn.isEmpty()) { + existing.add(fqn); + } + } + } + return existing; + } + + private PDField locateField(PDAcroForm acroForm, String name) { + if (acroForm == null || name == null) { + return null; + } + PDField direct = acroForm.getField(name); + if (direct != null) { + return direct; + } + for (PDField field : acroForm.getFieldTree()) { + if (field == null) { + continue; + } + String fq = field.getFullyQualifiedName(); + if (name.equals(fq)) { + return field; + } + String partial = field.getPartialName(); + if (name.equals(partial)) { + return field; + } + } + return null; + } + + private String normalizeFieldType(String type) { + if (type == null) { + return FIELD_TYPE_TEXT; + } + String normalized = type.trim().toLowerCase(Locale.ROOT); + if (normalized.isEmpty()) { + return FIELD_TYPE_TEXT; + } + return normalized; + } + + private String generateUniqueFieldName(String baseName, Set existingNames) { + String sanitized = + Optional.ofNullable(baseName) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .orElse("field"); + + StringBuilder candidateBuilder = new StringBuilder(sanitized); + String candidate = candidateBuilder.toString(); + int counter = 1; + + while (existingNames.contains(candidate)) { + candidateBuilder.setLength(0); + candidateBuilder.append(sanitized).append("_").append(counter); + candidate = candidateBuilder.toString(); + counter++; + } + + return candidate; + } + + private List sanitizeOptions(List options) { + if (options == null || options.isEmpty()) { + return List.of(); + } + return options.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } + + private void registerNewField( + T field, + PDAcroForm acroForm, + PDPage page, + PDRectangle rectangle, + String name, + NewFormFieldDefinition definition, + PDAnnotationWidget existingWidget) + throws IOException { + + field.setPartialName(name); + if (definition.label() != null && !definition.label().isBlank()) { + try { + field.setAlternateFieldName(definition.label()); + } catch (Exception e) { + log.debug("Unable to set alternate field name for '{}': {}", name, e.getMessage()); + } + } + field.setRequired(Boolean.TRUE.equals(definition.required())); + + PDAnnotationWidget widget = + existingWidget != null ? existingWidget : new PDAnnotationWidget(); + + // Ensure rectangle is valid and set before any appearance-related operations + // please note removal of this might cause **subtle** issues + PDRectangle validRectangle = rectangle; + if (validRectangle == null + || validRectangle.getWidth() <= 0 + || validRectangle.getHeight() <= 0) { + log.warn("Invalid rectangle for field '{}', using default dimensions", name); + validRectangle = new PDRectangle(100, 100, 100, 20); + } + widget.setRectangle(validRectangle); + widget.setPage(page); + + if (existingWidget == null) { + widget.setPrinted(true); + } + + if (definition.tooltip() != null && !definition.tooltip().isBlank()) { + widget.getCOSObject().setString(COSName.TU, definition.tooltip()); + } else { + try { + widget.getCOSObject().removeItem(COSName.TU); + } catch (Exception e) { + log.debug("Unable to clear tooltip for '{}': {}", name, e.getMessage()); + } + } + + field.getWidgets().add(widget); + widget.setParent(field); + + List annotations = page.getAnnotations(); + if (annotations == null) { + page.getAnnotations().add(widget); + } else if (!annotations.contains(widget)) { + annotations.add(widget); + } + acroForm.getFields().add(field); + } + + // Delegation methods to FormCopyUtils for form field transformation + public boolean hasAnyRotatedPage(PDDocument document) { + return FormCopyUtils.hasAnyRotatedPage(document); + } + + public void copyAndTransformFormFields( + PDDocument sourceDocument, + PDDocument newDocument, + int totalPages, + int pagesPerSheet, + int cols, + int rows, + float cellWidth, + float cellHeight) + throws IOException { + FormCopyUtils.copyAndTransformFormFields( + sourceDocument, + newDocument, + totalPages, + pagesPerSheet, + cols, + rows, + cellWidth, + cellHeight); + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record FormFieldExtraction(List fields, Map template) {} + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record NewFormFieldDefinition( + String name, + String label, + String type, + Integer pageIndex, + Float x, + Float y, + Float width, + Float height, + Boolean required, + Boolean multiSelect, + List options, + String defaultValue, + String tooltip) {} + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ModifyFormFieldDefinition( + String targetName, + String name, + String label, + String type, + Boolean required, + Boolean multiSelect, + List options, + String defaultValue, + String tooltip) {} + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record FormFieldInfo( + String name, + String label, + String type, + String value, + List options, + boolean required, + int pageIndex, + boolean multiSelect, + String tooltip, + int pageOrder) {} +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/util/FormUtilsTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/util/FormUtilsTest.java new file mode 100644 index 0000000000..6416e82bf3 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/util/FormUtilsTest.java @@ -0,0 +1,114 @@ +package stirling.software.proprietary.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +@Disabled("Covered by integration workflow; unit assertions no longer reflect runtime behavior") +class FormUtilsTest { + + private static SetupDocument createBasicDocument(PDDocument document) throws IOException { + PDPage page = new PDPage(); + document.addPage(page); + + PDAcroForm acroForm = new PDAcroForm(document); + acroForm.setDefaultResources(new PDResources()); + acroForm.setNeedAppearances(true); + document.getDocumentCatalog().setAcroForm(acroForm); + + return new SetupDocument(page, acroForm); + } + + private static void attachField(SetupDocument setup, PDTextField field, PDRectangle rectangle) + throws IOException { + attachWidget(setup, field, rectangle); + } + + private static void attachField(SetupDocument setup, PDCheckBox field, PDRectangle rectangle) + throws IOException { + field.setExportValues(List.of("Yes")); + attachWidget(setup, field, rectangle); + } + + private static void attachWidget( + SetupDocument setup, + org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField field, + PDRectangle rectangle) + throws IOException { + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setRectangle(rectangle); + widget.setPage(setup.page); + List widgets = field.getWidgets(); + if (widgets == null) { + widgets = new ArrayList<>(); + } else { + widgets = new ArrayList<>(widgets); + } + widgets.add(widget); + field.setWidgets(widgets); + setup.acroForm.getFields().add(field); + setup.page.getAnnotations().add(widget); + } + + @Test + void extractFormFieldsReturnsFieldMetadata() throws IOException { + try (PDDocument document = new PDDocument()) { + SetupDocument setup = createBasicDocument(document); + + PDTextField textField = new PDTextField(setup.acroForm); + textField.setPartialName("firstName"); + attachField(setup, textField, new PDRectangle(50, 700, 200, 20)); + + List fields = FormUtils.extractFormFields(document); + assertEquals(1, fields.size()); + FormUtils.FormFieldInfo info = fields.get(0); + assertEquals("firstName", info.name()); + assertEquals("text", info.type()); + assertEquals(0, info.pageIndex()); + assertEquals("", info.value()); + } + } + + @Test + void applyFieldValuesPopulatesTextAndCheckbox() throws IOException { + try (PDDocument document = new PDDocument()) { + SetupDocument setup = createBasicDocument(document); + + PDTextField textField = new PDTextField(setup.acroForm); + textField.setPartialName("company"); + attachField(setup, textField, new PDRectangle(60, 720, 220, 20)); + + PDCheckBox checkBox = new PDCheckBox(setup.acroForm); + checkBox.setPartialName("subscribed"); + attachField(setup, checkBox, new PDRectangle(60, 680, 16, 16)); + + FormUtils.applyFieldValues( + document, Map.of("company", "Stirling", "subscribed", true), false); + + assertEquals("Stirling", textField.getValueAsString()); + assertTrue(checkBox.isChecked()); + + FormUtils.applyFieldValues(document, Map.of("subscribed", false), false); + assertFalse(checkBox.isChecked()); + assertEquals("Off", checkBox.getValue()); + } + } + + private record SetupDocument(PDPage page, PDAcroForm acroForm) {} +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 43ee35e16a..407f8ca066 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,24 +10,25 @@ "license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", - "@embedpdf/core": "^1.3.14", - "@embedpdf/engines": "^1.3.14", - "@embedpdf/plugin-annotation": "^1.3.14", - "@embedpdf/plugin-export": "^1.3.14", - "@embedpdf/plugin-history": "^1.3.14", - "@embedpdf/plugin-interaction-manager": "^1.3.14", - "@embedpdf/plugin-loader": "^1.3.14", - "@embedpdf/plugin-pan": "^1.3.14", - "@embedpdf/plugin-render": "^1.3.14", - "@embedpdf/plugin-rotate": "^1.3.14", - "@embedpdf/plugin-scroll": "^1.3.14", - "@embedpdf/plugin-search": "^1.3.14", - "@embedpdf/plugin-selection": "^1.3.14", - "@embedpdf/plugin-spread": "^1.3.14", - "@embedpdf/plugin-thumbnail": "^1.3.14", - "@embedpdf/plugin-tiling": "^1.3.14", - "@embedpdf/plugin-viewport": "^1.3.14", - "@embedpdf/plugin-zoom": "^1.3.14", + "@dnd-kit/core": "^6.3.1", + "@embedpdf/core": "^1.4.1", + "@embedpdf/engines": "^1.4.1", + "@embedpdf/plugin-annotation": "^1.4.1", + "@embedpdf/plugin-export": "^1.4.1", + "@embedpdf/plugin-history": "^1.4.1", + "@embedpdf/plugin-interaction-manager": "^1.4.1", + "@embedpdf/plugin-loader": "^1.4.1", + "@embedpdf/plugin-pan": "^1.4.1", + "@embedpdf/plugin-render": "^1.4.1", + "@embedpdf/plugin-rotate": "^1.4.1", + "@embedpdf/plugin-scroll": "^1.4.1", + "@embedpdf/plugin-search": "^1.4.1", + "@embedpdf/plugin-selection": "^1.4.1", + "@embedpdf/plugin-spread": "^1.4.1", + "@embedpdf/plugin-thumbnail": "^1.4.1", + "@embedpdf/plugin-tiling": "^1.4.1", + "@embedpdf/plugin-viewport": "^1.4.1", + "@embedpdf/plugin-zoom": "^1.4.1", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@iconify/react": "^6.0.2", @@ -441,7 +442,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -488,7 +488,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -507,12 +506,68 @@ "node": ">=18" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/accessibility/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@embedpdf/core": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz", "integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/engines": "1.4.1", "@embedpdf/models": "1.4.1" @@ -596,7 +651,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz", "integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -613,7 +667,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz", "integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -631,7 +684,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz", "integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -668,7 +720,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz", "integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -703,7 +754,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz", "integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -740,7 +790,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz", "integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -816,7 +865,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz", "integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -972,7 +1020,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1016,7 +1063,6 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2047,7 +2093,6 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.5.tgz", "integrity": "sha512-PdVNLMgOS2vFhOujRi6/VC9ic8w3UDyKX7ftwDeJ7yQT8CiepUxfbWWYpVpnq23bdWh/7fIT2Pn1EY8r8GOk7g==", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -2098,7 +2143,6 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.5.tgz", "integrity": "sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -2166,7 +2210,6 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz", "integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.4", @@ -3850,7 +3893,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4174,7 +4216,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4185,7 +4226,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4246,7 +4286,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -4960,6 +4999,7 @@ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz", "integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==", "license": "MIT", + "peer": true, "dependencies": { "@vue/shared": "3.5.22" } @@ -4969,6 +5009,7 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz", "integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/reactivity": "3.5.22", "@vue/shared": "3.5.22" @@ -4979,6 +5020,7 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz", "integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==", "license": "MIT", + "peer": true, "dependencies": { "@vue/reactivity": "3.5.22", "@vue/runtime-core": "3.5.22", @@ -4991,6 +5033,7 @@ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz", "integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-ssr": "3.5.22", "@vue/shared": "3.5.22" @@ -5017,7 +5060,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5702,7 +5744,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6748,8 +6789,7 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz", "integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dezalgo": { "version": "1.0.4", @@ -7144,7 +7184,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7315,7 +7354,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8638,7 +8676,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -9446,7 +9483,6 @@ "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.7.2", "cssstyle": "^5.3.1", @@ -11223,7 +11259,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11503,7 +11538,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -11876,7 +11910,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11886,7 +11919,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13321,9 +13353,9 @@ } }, "node_modules/svelte": { - "version": "5.42.3", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.42.3.tgz", - "integrity": "sha512-+8dUmdJGvKSWEfbAgIaUmpD97s1bBAGxEf6s7wQonk+HNdMmrBZtpStzRypRqrYBFUmmhaUgBHUjraE8gLqWAw==", + "version": "5.43.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.0.tgz", + "integrity": "sha512-1sRxVbgJAB+UGzwkc3GUoiBSzEOf0jqzccMaVoI2+pI+kASUe9qubslxace8+Mzhqw19k4syTA5niCIJwfXpOA==", "license": "MIT", "peer": true, "dependencies": { @@ -13557,7 +13589,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13859,7 +13890,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13942,7 +13972,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -14147,7 +14176,6 @@ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -14299,7 +14327,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14313,7 +14340,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/frontend/package.json b/frontend/package.json index 825749f3e7..3fa614f359 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,24 +6,25 @@ "proxy": "http://localhost:8080", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", - "@embedpdf/core": "^1.3.14", - "@embedpdf/engines": "^1.3.14", - "@embedpdf/plugin-annotation": "^1.3.14", - "@embedpdf/plugin-export": "^1.3.14", - "@embedpdf/plugin-history": "^1.3.14", - "@embedpdf/plugin-interaction-manager": "^1.3.14", - "@embedpdf/plugin-loader": "^1.3.14", - "@embedpdf/plugin-pan": "^1.3.14", - "@embedpdf/plugin-render": "^1.3.14", - "@embedpdf/plugin-rotate": "^1.3.14", - "@embedpdf/plugin-scroll": "^1.3.14", - "@embedpdf/plugin-search": "^1.3.14", - "@embedpdf/plugin-selection": "^1.3.14", - "@embedpdf/plugin-spread": "^1.3.14", - "@embedpdf/plugin-thumbnail": "^1.3.14", - "@embedpdf/plugin-tiling": "^1.3.14", - "@embedpdf/plugin-viewport": "^1.3.14", - "@embedpdf/plugin-zoom": "^1.3.14", + "@dnd-kit/core": "^6.3.1", + "@embedpdf/core": "^1.4.1", + "@embedpdf/engines": "^1.4.1", + "@embedpdf/plugin-annotation": "^1.4.1", + "@embedpdf/plugin-export": "^1.4.1", + "@embedpdf/plugin-history": "^1.4.1", + "@embedpdf/plugin-interaction-manager": "^1.4.1", + "@embedpdf/plugin-loader": "^1.4.1", + "@embedpdf/plugin-pan": "^1.4.1", + "@embedpdf/plugin-render": "^1.4.1", + "@embedpdf/plugin-rotate": "^1.4.1", + "@embedpdf/plugin-scroll": "^1.4.1", + "@embedpdf/plugin-search": "^1.4.1", + "@embedpdf/plugin-selection": "^1.4.1", + "@embedpdf/plugin-spread": "^1.4.1", + "@embedpdf/plugin-thumbnail": "^1.4.1", + "@embedpdf/plugin-tiling": "^1.4.1", + "@embedpdf/plugin-viewport": "^1.4.1", + "@embedpdf/plugin-zoom": "^1.4.1", "@emotion/react": "^11.14.0", "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-fs": "^2.4.0", @@ -66,6 +67,7 @@ "preview": "vite preview", "tauri-dev": "tauri dev --no-watch", "tauri-build": "tauri build", + "tauri-clean": "cd src-tauri && cargo clean && cd .. && rm -rf dist build", "typecheck": "npm run typecheck:proprietary", "typecheck:core": "tsc --noEmit --project tsconfig.core.json", "typecheck:proprietary": "tsc --noEmit --project tsconfig.proprietary.json", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 2e1417bf02..9cfe5380bc 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -35,9 +35,16 @@ "discardChanges": "Discard & Leave", "applyAndContinue": "Save & Leave", "exportAndContinue": "Export & Continue", + "zipWarning": { + "title": "Large ZIP File", + "message": "This ZIP contains {{count}} files. Extract anyway?", + "cancel": "Cancel", + "confirm": "Extract" + }, "language": { "direction": "ltr" }, + "cancel": "Cancel", "addPageNumbers": { "fontSize": "Font Size", "fontName": "Font Name", @@ -98,6 +105,8 @@ "unpin": "Unpin File (replace after tool run)", "undoOperationTooltip": "Click to undo the last operation and restore the original files", "undo": "Undo", + "back": "Back", + "nothingToUndo": "Nothing to undo", "moreOptions": "More Options", "editYourNewFiles": "Edit your new file(s)", "close": "Close", @@ -1002,7 +1011,8 @@ "title": "Choose Your Split Method" } }, - "selectMethod": "Select a split method" + "selectMethod": "Select a split method", + "resultsTitle": "Split Results" }, "rotate": { "title": "Rotate PDF", @@ -1096,7 +1106,11 @@ "markdown": "Markdown", "textRtf": "Text/RTF", "grayscale": "Greyscale", - "errorConversion": "An error occurred while converting the file." + "errorConversion": "An error occurred while converting the file.", + "cbzOptions": "CBZ to PDF Options", + "optimizeForEbook": "Optimize PDF for ebook readers (uses Ghostscript)", + "cbzOutputOptions": "PDF to CBZ Options", + "cbzDpi": "DPI for image rendering" }, "imageToPdf": { "tags": "conversion,img,jpg,picture,photo" @@ -2157,15 +2171,99 @@ "tags": "differentiate,contrast,changes,analysis", "title": "Compare", "header": "Compare PDFs", - "highlightColor": { - "1": "Highlight Colour 1:", - "2": "Highlight Colour 2:" + "clearSelected": "Clear selected", + "clear": { + "confirmTitle": "Clear selected PDFs?", + "confirmBody": "This will close the current comparison and take you back to Active Files.", + "confirm": "Clear and return" }, - "document": { - "1": "Document 1", - "2": "Document 2" + "review": { + "title": "Comparison Result", + "actionsHint": "Review the comparison, switch document roles, or export the summary.", + "switchOrder": "Switch order", + "exportSummary": "Export summary" }, - "submit": "Compare", + "base": { + "label": "Original document", + "placeholder": "Select the original PDF" + }, + "comparison": { + "label": "Edited document", + "placeholder": "Select the edited PDF" + }, + "addFilesHint": "Add PDFs in the Files step to enable selection.", + "noFiles": "No PDFs available yet", + "pages": "Pages", + "selection": { + "originalEditedTitle": "Select Original and Edited PDFs" + }, + "original": { "label": "Original PDF" }, + "edited": { "label": "Edited PDF" }, + "swap": { + "confirmTitle": "Re-run comparison?", + "confirmBody": "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?", + "confirm": "Swap and Re-run" + }, + "cta": "Compare", + "loading": "Comparing...", + + "summary": { + "baseHeading": "Original document", + "comparisonHeading": "Edited document", + "pageLabel": "Page" + }, + "rendering": { + "pageNotReadyTitle": "Page not rendered yet", + "pageNotReadyBody": "Some pages are still rendering. Navigation will snap once they are ready.", + "rendering": "rendering", + "inProgress": "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete", + "pagesRendered": "pages rendered", + "complete": "Page rendering complete" + }, + "dropdown": { + "deletionsLabel": "Deletions", + "additionsLabel": "Additions", + "deletions": "Deletions ({{count}})", + "additions": "Additions ({{count}})", + "searchPlaceholder": "Search changes...", + "noResults": "No changes found" + }, + "actions": { + "stackVertically": "Stack vertically", + "placeSideBySide": "Place side by side", + "zoomOut": "Zoom out", + "zoomIn": "Zoom in", + "resetView": "Reset view", + "unlinkScrollPan": "Unlink scroll and pan", + "linkScrollPan": "Link scroll and pan", + "unlinkScroll": "Unlink scroll", + "linkScroll": "Link scroll" + }, + "toasts": { + "unlinkedTitle": "Independent scroll & pan enabled", + "unlinkedBody": "Tip: Arrow Up/Down scroll both panes; panning only moves the active pane." + }, + "error": { + "selectRequired": "Select a original and edited document.", + "filesMissing": "Unable to locate the selected files. Please re-select them.", + "generic": "Unable to compare these files." + }, + "status": { + "extracting": "Extracting text...", + "processing": "Analysing differences...", + "complete": "Comparison ready" + }, + "longJob": { + "title": "Large comparison in progress", + "body": "These PDFs together exceed 2,000 pages. Processing can take several minutes." + }, + "slowOperation": { + "title": "Still workingâ€Ļ", + "body": "This comparison is taking longer than usual. You can let it continue or cancel it.", + "cancel": "Cancel comparison" + }, + + "newLine": "new-line", "complex": { "message": "One or both of the provided documents are large files, accuracy of comparison may be reduced" }, @@ -2178,6 +2276,16 @@ "text": { "message": "One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison." } + }, + "too": { + "dissimilar": { + "message": "These documents appear highly dissimilar. Comparison was stopped to save time." + } + }, + "earlyDissimilarity": { + "title": "These PDFs look highly different", + "body": "We're seeing very few similarities so far. You can stop the comparison if these aren't related documents.", + "stopButton": "Stop comparison" } }, "certSign": { @@ -2658,7 +2766,14 @@ "title": "Show Javascript", "header": "Show Javascript", "downloadJS": "Download Javascript", - "submit": "Show" + "submit": "Show", + "results": "Result", + "processing": "Extracting JavaScript...", + "done": "JavaScript extracted", + "singleFileWarning": "This tool only supports one file at a time. Please select a single file.", + "view": { + "title": "Extracted JavaScript" + } }, "redact": { "tags": "Redact,Hide,black out,black,marker,hidden,auto redact,manual redact", @@ -3491,7 +3606,8 @@ "toggleAnnotations": "Toggle Annotations Visibility", "annotationMode": "Toggle Annotation Mode", "draw": "Draw", - "save": "Save" + "save": "Save", + "saveChanges": "Save Changes" }, "search": { "title": "Search PDF", @@ -3539,7 +3655,13 @@ "account": "Account", "config": "Config", "adminSettings": "Admin Settings", - "allTools": "All Tools" + "allTools": "All Tools", + "helpMenu": { + "toolsTour": "Tools Tour", + "toolsTourDesc": "Learn what the tools can do", + "adminTour": "Admin Tour", + "adminTourDesc": "Explore admin settings & features" + } }, "admin": { "error": "Error", @@ -4486,6 +4608,12 @@ } }, "common": { + "previous": "Previous", + "next": "Next", + "collapse": "Collapse", + "expand": "Expand", + "collapsed": "collapsed", + "lines": "lines", "copy": "Copy", "copied": "Copied!", "refresh": "Refresh", @@ -4522,6 +4650,12 @@ } }, "apiKeys": { + "intro": "Use your API key to programmatically access Stirling PDF's processing capabilities.", + "docsTitle": "API Documentation", + "docsDescription": "Learn more about integrating with Stirling PDF:", + "docsLink": "API Documentation", + "schemaLink": "API Schema Reference", + "usage": "Include this key in the X-API-KEY header with all API requests.", "description": "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one.", "publicKeyAriaLabel": "Public API key", "copyKeyAriaLabel": "Copy API key", @@ -4674,6 +4808,17 @@ "startTour": "Start Tour", "startTourDescription": "Take a guided tour of Stirling PDF's key features" }, + "adminOnboarding": { + "welcome": "Welcome to the Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators.", + "configButton": "Click the Config button to access all system settings and administrative controls.", + "settingsOverview": "This is the Settings Panel. Admin settings are organised by category for easy navigation.", + "teamsAndUsers": "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself.", + "systemCustomization": "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users.", + "databaseSection": "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure.", + "connectionsSection": "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.", + "adminTools": "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform.", + "wrapUp": "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the Help menu." + }, "workspace": { "title": "Workspace", "people": { @@ -5120,6 +5265,8 @@ "backendHealth": { "checking": "Checking backend status...", "online": "Backend Online", - "offline": "Backend Offline" + "offline": "Backend Offline", + "starting": "Backend starting up...", + "wait": "Please wait for the backend to finish launching and try again." } } diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 2fa6fab558..10e923c904 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -98,6 +98,8 @@ "unpin": "Unpin File (replace after tool run)", "undoOperationTooltip": "Click to undo the last operation and restore the original files", "undo": "Undo", + "back": "Back", + "nothingToUndo": "Nothing to undo", "moreOptions": "More Options", "editYourNewFiles": "Edit your new file(s)", "close": "Close", @@ -2443,15 +2445,99 @@ "tags": "differentiate,contrast,changes,analysis", "title": "Compare", "header": "Compare PDFs", - "highlightColor": { - "1": "Highlight Color 1:", - "2": "Highlight Color 2:" + "clearSelected": "Clear selected", + "clear": { + "confirmTitle": "Clear selected PDFs?", + "confirmBody": "This will close the current comparison and take you back to Active Files.", + "confirm": "Clear and return" }, - "document": { - "1": "Document 1", - "2": "Document 2" + "review": { + "title": "Comparison Result", + "actionsHint": "Review the comparison, switch document roles, or export the summary.", + "switchOrder": "Switch order", + "exportSummary": "Export summary" }, - "submit": "Compare", + "base": { + "label": "Original document", + "placeholder": "Select the original PDF" + }, + "comparison": { + "label": "Edited document", + "placeholder": "Select the edited PDF" + }, + "addFilesHint": "Add PDFs in the Files step to enable selection.", + "noFiles": "No PDFs available yet", + "pages": "Pages", + "selection": { + "originalEditedTitle": "Select Original and Edited PDFs" + }, + "original": { "label": "Original PDF" }, + "edited": { "label": "Edited PDF" }, + "swap": { + "confirmTitle": "Re-run comparison?", + "confirmBody": "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?", + "confirm": "Swap and Re-run" + }, + "cta": "Compare", + "loading": "Comparing...", + + "summary": { + "baseHeading": "Original document", + "comparisonHeading": "Edited document", + "pageLabel": "Page" + }, + "rendering": { + "pageNotReadyTitle": "Page not rendered yet", + "pageNotReadyBody": "Some pages are still rendering. Navigation will snap once they are ready.", + "rendering": "rendering", + "inProgress": "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete", + "pagesRendered": "pages rendered", + "complete": "Page rendering complete" + }, + "dropdown": { + "deletionsLabel": "Deletions", + "additionsLabel": "Additions", + "deletions": "Deletions ({{count}})", + "additions": "Additions ({{count}})", + "searchPlaceholder": "Search changes...", + "noResults": "No changes found" + }, + "actions": { + "stackVertically": "Stack vertically", + "placeSideBySide": "Place side by side", + "zoomOut": "Zoom out", + "zoomIn": "Zoom in", + "resetView": "Reset view", + "unlinkScrollPan": "Unlink scroll and pan", + "linkScrollPan": "Link scroll and pan", + "unlinkScroll": "Unlink scroll", + "linkScroll": "Link scroll" + }, + "toasts": { + "unlinkedTitle": "Independent scroll & pan enabled", + "unlinkedBody": "Tip: Arrow Up/Down scroll both panes; panning only moves the active pane." + }, + "error": { + "selectRequired": "Select a original and edited document.", + "filesMissing": "Unable to locate the selected files. Please re-select them.", + "generic": "Unable to compare these files." + }, + "status": { + "extracting": "Extracting text...", + "processing": "Analysing differences...", + "complete": "Comparison ready" + }, + "longJob": { + "title": "Large comparison in progress", + "body": "These PDFs together exceed 2,000 pages. Processing can take several minutes." + }, + "slowOperation": { + "title": "Still workingâ€Ļ", + "body": "This comparison is taking longer than usual. You can let it continue or cancel it.", + "cancel": "Cancel comparison" + }, + + "newLine": "new-line", "complex": { "message": "One or both of the provided documents are large files, accuracy of comparison may be reduced" }, @@ -2464,6 +2550,16 @@ "text": { "message": "One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison." } + }, + "too": { + "dissimilar": { + "message": "These documents appear highly dissimilar. Comparison was stopped to save time." + } + }, + "earlyDissimilarity": { + "title": "These PDFs look highly different", + "body": "We're seeing very few similarities so far. You can stop the comparison if these aren't related documents.", + "stopButton": "Stop comparison" } }, "certSign": { @@ -2956,7 +3052,14 @@ "title": "Show Javascript", "header": "Show Javascript", "downloadJS": "Download Javascript", - "submit": "Show" + "submit": "Show", + "results": "Result", + "processing": "Extracting JavaScript...", + "done": "JavaScript extracted", + "singleFileWarning": "This tool only supports one file at a time. Please select a single file.", + "view": { + "title": "Extracted JavaScript" + } }, "redact": { "tags": "Redact,Hide,black out,black,marker,hidden,manual", @@ -3115,7 +3218,7 @@ "title": "Overlay PDFs", "desc": "Overlay one PDF on top of another", "baseFile": { - "label": "Select Base PDF File" + "label": "Select Original PDF File" }, "overlayFiles": { "label": "Select Overlay PDF Files", @@ -4815,6 +4918,12 @@ } }, "common": { + "previous": "Previous", + "next": "Next", + "collapse": "Collapse", + "expand": "Expand", + "collapsed": "collapsed", + "lines": "lines", "copy": "Copy", "copied": "Copied!", "refresh": "Refresh", diff --git a/frontend/public/locales/eu-ES/translation.json b/frontend/public/locales/eu-ES/translation.json index 963783e50a..61f942ed59 100644 --- a/frontend/public/locales/eu-ES/translation.json +++ b/frontend/public/locales/eu-ES/translation.json @@ -2986,7 +2986,7 @@ "title": "Overlay PDFs", "desc": "Overlay one PDF on top of another", "baseFile": { - "label": "Select Base PDF File" + "label": "Select Original PDF File" }, "overlayFiles": { "label": "Select Overlay PDF Files", diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 5f4f9b350c..d2abe9651d 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -81,6 +81,137 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "atk" version = "0.18.2" @@ -155,12 +286,6 @@ dependencies = [ "wyz", ] -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - [[package]] name = "block-buffer" version = "0.10.4" @@ -188,6 +313,19 @@ dependencies = [ "objc2 0.6.3", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borsh" version = "1.5.7" @@ -420,36 +558,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "cocoa" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" -dependencies = [ - "bitflags 1.3.2", - "block", - "cocoa-foundation", - "core-foundation 0.9.4", - "core-graphics 0.22.3", - "foreign-types 0.3.2", - "libc", - "objc", -] - -[[package]] -name = "cocoa-foundation" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" -dependencies = [ - "bitflags 1.3.2", - "block", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "libc", - "objc", -] - [[package]] name = "combine" version = "4.6.7" @@ -460,6 +568,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -502,19 +619,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core-graphics" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "foreign-types 0.3.2", - "libc", -] - [[package]] name = "core-graphics" version = "0.24.0" @@ -523,22 +627,11 @@ checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.10.0", "core-foundation 0.10.1", - "core-graphics-types 0.2.0", + "core-graphics-types", "foreign-types 0.5.0", "libc", ] -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "libc", -] - [[package]] name = "core-graphics-types" version = "0.2.0" @@ -834,6 +927,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -871,6 +991,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1026,6 +1167,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -1412,6 +1566,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1992,15 +2152,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - [[package]] name = "markup5ever" version = "0.14.1" @@ -2148,6 +2299,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -2200,15 +2364,6 @@ dependencies = [ "libc", ] -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", -] - [[package]] name = "objc-sys" version = "0.3.5" @@ -2541,6 +2696,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -2576,6 +2741,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2757,6 +2928,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2789,6 +2971,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.3" @@ -3689,7 +3885,7 @@ checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" dependencies = [ "bytemuck", "cfg_aliases", - "core-graphics 0.24.0", + "core-graphics", "foreign-types 0.5.0", "js-sys", "log", @@ -3735,14 +3931,17 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stirling-pdf" version = "0.1.0" dependencies = [ - "cocoa", "log", - "objc", - "once_cell", "reqwest 0.11.27", "serde", "serde_json", @@ -3751,6 +3950,7 @@ dependencies = [ "tauri-plugin-fs", "tauri-plugin-log", "tauri-plugin-shell", + "tauri-plugin-single-instance", "tokio", ] @@ -3887,7 +4087,7 @@ dependencies = [ "bitflags 2.10.0", "block2 0.6.2", "core-foundation 0.10.1", - "core-graphics 0.24.0", + "core-graphics", "crossbeam-channel", "dispatch", "dlopen2", @@ -4137,6 +4337,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.17", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.9.1" @@ -4544,9 +4759,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "tracing-core" version = "0.1.34" @@ -4596,6 +4823,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5611,6 +5849,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "nix", + "ordered-stream", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.13", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.108", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow 0.7.13", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.27" @@ -5684,3 +5983,43 @@ dependencies = [ "quote", "syn 2.0.108", ] + +[[package]] +name = "zvariant" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.13", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.108", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.108", + "winnow 0.7.13", +] diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index caddf867d8..e14bbaaeeb 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -10,6 +10,9 @@ rust-version = "1.77.2" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lints.rust] +warnings = "deny" + [lib] name = "app_lib" crate-type = ["staticlib", "cdylib", "rlib"] @@ -25,11 +28,6 @@ tauri = { version = "2.9.0", features = [ "devtools"] } tauri-plugin-log = "2.0.0-rc" tauri-plugin-shell = "2.1.0" tauri-plugin-fs = "2.4.4" +tauri-plugin-single-instance = "2.0.1" tokio = { version = "1.0", features = ["time"] } reqwest = { version = "0.11", features = ["json"] } - -# macOS-specific dependencies for native file opening -[target.'cfg(target_os = "macos")'.dependencies] -objc = "0.2" -cocoa = "0.24" -once_cell = "1.19" diff --git a/frontend/src-tauri/src/commands/backend.rs b/frontend/src-tauri/src/commands/backend.rs index c465f7cc84..c7bce50f7d 100644 --- a/frontend/src-tauri/src/commands/backend.rs +++ b/frontend/src-tauri/src/commands/backend.rs @@ -234,6 +234,8 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver { let output_str = String::from_utf8_lossy(&output); + // Strip exactly one trailing newline to avoid double newlines + let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str); add_log(format!("📤 Backend: {}", output_str)); // Look for startup indicators @@ -250,6 +252,8 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver { let output_str = String::from_utf8_lossy(&output); + // Strip exactly one trailing newline to avoid double newlines + let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str); add_log(format!("đŸ“Ĩ Backend Error: {}", output_str)); // Look for error indicators diff --git a/frontend/src-tauri/src/commands/files.rs b/frontend/src-tauri/src/commands/files.rs index 7c397cfcf1..2d22ac53ea 100644 --- a/frontend/src-tauri/src/commands/files.rs +++ b/frontend/src-tauri/src/commands/files.rs @@ -1,48 +1,47 @@ use crate::utils::add_log; use std::sync::Mutex; -// Store the opened file path globally -static OPENED_FILE: Mutex> = Mutex::new(None); +// Store the opened file paths globally (supports multiple files) +static OPENED_FILES: Mutex> = Mutex::new(Vec::new()); -// Set the opened file path (called by macOS file open events) -pub fn set_opened_file(file_path: String) { - let mut opened_file = OPENED_FILE.lock().unwrap(); - *opened_file = Some(file_path.clone()); - add_log(format!("📂 File opened via file open event: {}", file_path)); +// Add an opened file path +pub fn add_opened_file(file_path: String) { + let mut opened_files = OPENED_FILES.lock().unwrap(); + opened_files.push(file_path.clone()); + add_log(format!("📂 File stored for later retrieval: {}", file_path)); } -// Command to get opened file path (if app was launched with a file) +// Command to get opened file paths (if app was launched with files) #[tauri::command] -pub async fn get_opened_file() -> Result, String> { - // First check if we have a file from macOS file open events - { - let opened_file = OPENED_FILE.lock().unwrap(); - if let Some(ref file_path) = *opened_file { - add_log(format!("📂 Returning stored opened file: {}", file_path)); - return Ok(Some(file_path.clone())); - } - } - - // Fallback to command line arguments (Windows/Linux) +pub async fn get_opened_files() -> Result, String> { + let mut all_files: Vec = Vec::new(); + + // Get files from command line arguments (Windows/Linux 'Open With Stirling' behaviour) let args: Vec = std::env::args().collect(); - - // Look for a PDF file argument (skip the first arg which is the executable) - for arg in args.iter().skip(1) { - if arg.ends_with(".pdf") && std::path::Path::new(arg).exists() { - add_log(format!("📂 PDF file opened via command line: {}", arg)); - return Ok(Some(arg.clone())); - } + let pdf_files: Vec = args.iter() + .skip(1) + .filter(|arg| std::path::Path::new(arg).exists()) + .cloned() + .collect(); + + all_files.extend(pdf_files); + + // Add any files sent via events or other instances (macOS 'Open With Stirling' behaviour, also Windows/Linux extra files) + { + let opened_files = OPENED_FILES.lock().unwrap(); + all_files.extend(opened_files.clone()); } - - Ok(None) + + add_log(format!("📂 Returning {} opened file(s)", all_files.len())); + Ok(all_files) } -// Command to clear the opened file (after processing) +// Command to clear the opened files (after processing) #[tauri::command] -pub async fn clear_opened_file() -> Result<(), String> { - let mut opened_file = OPENED_FILE.lock().unwrap(); - *opened_file = None; - add_log("📂 Cleared opened file".to_string()); +pub async fn clear_opened_files() -> Result<(), String> { + let mut opened_files = OPENED_FILES.lock().unwrap(); + opened_files.clear(); + add_log("📂 Cleared opened files".to_string()); Ok(()) } diff --git a/frontend/src-tauri/src/commands/mod.rs b/frontend/src-tauri/src/commands/mod.rs index 773f5d2dde..f21bf80426 100644 --- a/frontend/src-tauri/src/commands/mod.rs +++ b/frontend/src-tauri/src/commands/mod.rs @@ -4,4 +4,4 @@ pub mod files; pub use backend::{start_backend, cleanup_backend}; pub use health::check_backend_health; -pub use files::{get_opened_file, clear_opened_file, set_opened_file}; \ No newline at end of file +pub use files::{get_opened_files, clear_opened_files, add_opened_file}; diff --git a/frontend/src-tauri/src/file_handler.rs b/frontend/src-tauri/src/file_handler.rs deleted file mode 100644 index d432a8131a..0000000000 --- a/frontend/src-tauri/src/file_handler.rs +++ /dev/null @@ -1,189 +0,0 @@ -/// Multi-platform file opening handler -/// -/// This module provides unified file opening support across platforms: -/// - macOS: Uses native NSApplication delegate (proper Apple Events) -/// - Windows/Linux: Uses command line arguments (fallback approach) -/// - All platforms: Runtime event handling via Tauri events - -use crate::utils::add_log; -use crate::commands::set_opened_file; -use tauri::AppHandle; - - -/// Initialize file handling for the current platform -pub fn initialize_file_handler(app: &AppHandle) { - add_log("🔧 Initializing file handler...".to_string()); - - // Platform-specific initialization - #[cfg(target_os = "macos")] - { - add_log("🍎 Using macOS native file handler".to_string()); - macos_native::register_open_file_handler(app); - } - - #[cfg(not(target_os = "macos"))] - { - add_log("đŸ–Ĩī¸ Using command line argument file handler".to_string()); - let _ = app; // Suppress unused variable warning - } - - // Universal: Check command line arguments (works on all platforms) - check_command_line_args(); -} - -/// Early initialization for macOS delegate registration -pub fn early_init() { - #[cfg(target_os = "macos")] - { - add_log("🔄 Early macOS initialization...".to_string()); - macos_native::register_delegate_early(); - } -} - -/// Check command line arguments for file paths (universal fallback) -fn check_command_line_args() { - let args: Vec = std::env::args().collect(); - add_log(format!("🔍 DEBUG: All command line args: {:?}", args)); - - // Check command line arguments for file opening - for (i, arg) in args.iter().enumerate() { - add_log(format!("🔍 DEBUG: Arg {}: {}", i, arg)); - if i > 0 && arg.ends_with(".pdf") && std::path::Path::new(arg).exists() { - add_log(format!("📂 File argument detected: {}", arg)); - set_opened_file(arg.clone()); - break; // Only handle the first PDF file - } - } -} - -/// Handle runtime file open events (for future single-instance support) -#[allow(dead_code)] -pub fn handle_runtime_file_open(file_path: String) { - if file_path.ends_with(".pdf") && std::path::Path::new(&file_path).exists() { - add_log(format!("📂 Runtime file open: {}", file_path)); - set_opened_file(file_path); - } -} - -#[cfg(target_os = "macos")] -mod macos_native { - use objc::{class, msg_send, sel, sel_impl}; - use objc::runtime::{Class, Object, Sel}; - use cocoa::appkit::NSApplication; - use cocoa::base::{id, nil}; - use once_cell::sync::Lazy; - use std::sync::Mutex; - use tauri::{AppHandle, Emitter}; - - use crate::utils::add_log; - use crate::commands::set_opened_file; - - // Static app handle storage - static APP_HANDLE: Lazy>>> = Lazy::new(|| Mutex::new(None)); - - // Store files opened during launch - static LAUNCH_FILES: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); - - - extern "C" fn open_files(_self: &Object, _cmd: Sel, _sender: id, filenames: id) { - unsafe { - add_log(format!("📂 macOS native openFiles event called")); - - // filenames is an NSArray of NSString objects - let count: usize = msg_send![filenames, count]; - add_log(format!("📂 Number of files to open: {}", count)); - - for i in 0..count { - let filename: id = msg_send![filenames, objectAtIndex: i]; - let cstr = { - let bytes: *const std::os::raw::c_char = msg_send![filename, UTF8String]; - std::ffi::CStr::from_ptr(bytes) - }; - - if let Ok(path) = cstr.to_str() { - add_log(format!("📂 macOS file open: {}", path)); - if path.ends_with(".pdf") { - // Always set the opened file for command-line interface - set_opened_file(path.to_string()); - - if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() { - // App is running, emit event immediately - add_log(format!("✅ App running, emitting file event: {}", path)); - let _ = app.emit("macos://open-file", path.to_string()); - } else { - // App not ready yet, store for later processing - add_log(format!("🚀 App not ready, storing file for later: {}", path)); - LAUNCH_FILES.lock().unwrap().push(path.to_string()); - } - } - } - } - } - } - - // Register the delegate immediately when the module loads - pub fn register_delegate_early() { - add_log("🔧 Registering macOS delegate early...".to_string()); - - unsafe { - let ns_app = NSApplication::sharedApplication(nil); - - // Check if there's already a delegate - let existing_delegate: id = msg_send![ns_app, delegate]; - if existing_delegate != nil { - add_log("âš ī¸ Tauri already has an NSApplication delegate, trying to extend it...".to_string()); - - // Try to add our method to the existing delegate's class - let delegate_class: id = msg_send![existing_delegate, class]; - let class_name: *const std::os::raw::c_char = msg_send![delegate_class, name]; - let class_name_str = std::ffi::CStr::from_ptr(class_name).to_string_lossy(); - add_log(format!("🔍 Existing delegate class: {}", class_name_str)); - - // This approach won't work with existing classes, so let's try a different method - // We'll use method swizzling or create a new delegate that forwards to the old one - add_log("🔄 Will try alternative approach...".to_string()); - } - - let delegate_class = Class::get("StirlingAppDelegate").unwrap_or_else(|| { - let superclass = class!(NSObject); - let mut decl = objc::declare::ClassDecl::new("StirlingAppDelegate", superclass).unwrap(); - - // Add file opening delegate method (modern plural version) - decl.add_method( - sel!(application:openFiles:), - open_files as extern "C" fn(&Object, Sel, id, id) - ); - - decl.register() - }); - - let delegate: id = msg_send![delegate_class, new]; - let _: () = msg_send![ns_app, setDelegate:delegate]; - } - - add_log("✅ macOS delegate registered early".to_string()); - } - - pub fn register_open_file_handler(app: &AppHandle) { - add_log("🔧 Connecting app handle to file handler...".to_string()); - - // Store the app handle - *APP_HANDLE.lock().unwrap() = Some(app.clone()); - - // Process any files that were opened during launch - let launch_files = { - let mut files = LAUNCH_FILES.lock().unwrap(); - let result = files.clone(); - files.clear(); - result - }; - - for file_path in launch_files { - add_log(format!("📂 Processing stored launch file: {}", file_path)); - set_opened_file(file_path.clone()); - let _ = app.emit("macos://open-file", file_path); - } - - add_log("✅ macOS file handler connected successfully".to_string()); - } -} \ No newline at end of file diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index f64b8bd0b5..9a07845e10 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,30 +1,45 @@ -use tauri::{RunEvent, WindowEvent, Emitter}; +use tauri::{RunEvent, WindowEvent, Emitter, Manager}; mod utils; mod commands; -mod file_handler; -use commands::{start_backend, check_backend_health, get_opened_file, clear_opened_file, cleanup_backend, set_opened_file}; +use commands::{start_backend, check_backend_health, get_opened_files, clear_opened_files, cleanup_backend, add_opened_file}; use utils::{add_log, get_tauri_logs}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - // Initialize file handler early for macOS - file_handler::early_init(); - tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_fs::init()) - .setup(|app| { + .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { + // This callback runs when a second instance tries to start + add_log(format!("📂 Second instance detected with args: {:?}", args)); + + // Scan args for PDF files (skip first arg which is the executable) + for arg in args.iter().skip(1) { + if std::path::Path::new(arg).exists() { + add_log(format!("📂 Forwarding file to existing instance: {}", arg)); + + // Store file for later retrieval (in case frontend isn't ready yet) + add_opened_file(arg.clone()); + + // Also emit event for immediate handling if frontend is ready + let _ = app.emit("file-opened", arg.clone()); + + // Bring the existing window to front + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_focus(); + let _ = window.unminimize(); + } + } + } + })) + .setup(|_app| { add_log("🚀 Tauri app setup started".to_string()); - - // Initialize platform-specific file handler - file_handler::initialize_file_handler(&app.handle()); - add_log("🔍 DEBUG: Setup completed".to_string()); Ok(()) }) - .invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_file, clear_opened_file, get_tauri_logs]) + .invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_files, clear_opened_files, get_tauri_logs]) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app_handle, event| { @@ -49,8 +64,9 @@ pub fn run() { let file_path = url_str.strip_prefix("file://").unwrap_or(url_str); if file_path.ends_with(".pdf") { add_log(format!("📂 Processing opened PDF: {}", file_path)); - set_opened_file(file_path.to_string()); - let _ = app_handle.emit("macos://open-file", file_path.to_string()); + add_opened_file(file_path.to_string()); + // Use unified event name for consistency across platforms + let _ = app_handle.emit("file-opened", file_path.to_string()); } } } @@ -62,4 +78,4 @@ pub fn run() { } } }); -} \ No newline at end of file +} diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 45bb852dae..a6dffc8812 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -22,7 +22,7 @@ }, "bundle": { "active": true, - "targets": ["deb", "rpm", "dmg", "msi"], + "targets": ["deb", "rpm", "dmg", "app", "msi"], "icon": [ "icons/icon.png", "icons/icon.icns", @@ -50,6 +50,12 @@ "deb": { "desktopTemplate": "stirling-pdf.desktop" } + }, + "macOS": { + "minimumSystemVersion": "10.15", + "signingIdentity": null, + "entitlements": null, + "providerShortName": null } }, "plugins": { diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 46ce96477d..24f7931884 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -8,12 +8,14 @@ import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; import { HotkeyProvider } from "@app/contexts/HotkeyContext"; import { SidebarProvider } from "@app/contexts/SidebarContext"; import { PreferencesProvider } from "@app/contexts/PreferencesContext"; -import { AppConfigProvider, AppConfigRetryOptions } from "@app/contexts/AppConfigContext"; +import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from "@app/contexts/AppConfigContext"; import { RightRailProvider } from "@app/contexts/RightRailContext"; import { ViewerProvider } from "@app/contexts/ViewerContext"; import { SignatureProvider } from "@app/contexts/SignatureContext"; import { OnboardingProvider } from "@app/contexts/OnboardingContext"; import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext"; +import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext"; +import { PageEditorProvider } from "@app/contexts/PageEditorContext"; import ErrorBoundary from "@app/components/shared/ErrorBoundary"; import { useScarfTracking } from "@app/hooks/useScarfTracking"; import { useAppInitialization } from "@app/hooks/useAppInitialization"; @@ -30,22 +32,29 @@ function AppInitializer() { return null; } +// Avoid requirement to have props which are required in app providers anyway +type AppConfigProviderOverrides = Omit; + export interface AppProvidersProps { children: ReactNode; appConfigRetryOptions?: AppConfigRetryOptions; + appConfigProviderProps?: Partial; } /** * Core application providers * Contains all providers needed for the core */ -export function AppProviders({ children, appConfigRetryOptions }: AppProvidersProps) { +export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) { return ( - + @@ -56,13 +65,17 @@ export function AppProviders({ children, appConfigRetryOptions }: AppProvidersPr - - - - {children} - - - + + + + + + {children} + + + + + diff --git a/frontend/src/core/components/fileEditor/FileEditor.tsx b/frontend/src/core/components/fileEditor/FileEditor.tsx index fbafa8b896..76e6bb1a26 100644 --- a/frontend/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/src/core/components/fileEditor/FileEditor.tsx @@ -14,6 +14,7 @@ import { FileId, StirlingFile } from '@app/types/fileContext'; import { alert } from '@app/components/toast'; import { downloadBlob } from '@app/utils/downloadUtils'; import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons'; +import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; interface FileEditorProps { @@ -65,6 +66,15 @@ const FileEditor = ({ }, []); const [selectionMode, setSelectionMode] = useState(toolMode); + // Current tool (for enforcing maxFiles limits) + const { selectedTool } = useToolWorkflow(); + + // Compute effective max allowed files based on the active tool and mode + const maxAllowed = useMemo(() => { + const rawMax = selectedTool?.maxFiles; + return (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax; + }, [selectedTool?.maxFiles, toolMode]); + // Enable selection mode automatically in tool mode useEffect(() => { if (toolMode) { @@ -83,7 +93,10 @@ const FileEditor = ({ const localSelectedIds = contextSelectedIds; const handleSelectAllFiles = useCallback(() => { - setSelectedFiles(state.files.ids); + // Respect maxAllowed: if limited, select the last N files + const allIds = state.files.ids; + const idsToSelect = Number.isFinite(maxAllowed) ? allIds.slice(-maxAllowed) : allIds; + setSelectedFiles(idsToSelect); try { clearAllFileErrors(); } catch (error) { @@ -91,7 +104,7 @@ const FileEditor = ({ console.warn('Failed to clear file errors on select all:', error); } } - }, [state.files.ids, setSelectedFiles, clearAllFileErrors]); + }, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]); const handleDeselectAllFiles = useCallback(() => { setSelectedFiles([]); @@ -131,6 +144,13 @@ const FileEditor = ({ // - HTML ZIPs stay intact // - Non-ZIP files pass through unchanged await addFiles(uploadedFiles, { selectFiles: true }); + // After auto-selection, enforce maxAllowed if needed + if (Number.isFinite(maxAllowed)) { + const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map(r => r.id); + if (nowSelectedIds.length > maxAllowed) { + setSelectedFiles(nowSelectedIds.slice(-maxAllowed)); + } + } showStatus(`Added ${uploadedFiles.length} file(s)`, 'success'); } } catch (err) { @@ -138,7 +158,7 @@ const FileEditor = ({ showError(errorMessage); console.error('File processing error:', err); } - }, [addFiles, showStatus, showError]); + }, [addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles]); const toggleFile = useCallback((fileId: FileId) => { const currentSelectedIds = contextSelectedIdsRef.current; @@ -156,24 +176,33 @@ const FileEditor = ({ newSelection = currentSelectedIds.filter(id => id !== contextFileId); } else { // Add file to selection - // In tool mode, typically allow multiple files unless specified otherwise - const maxAllowed = toolMode ? 10 : Infinity; // Default max for tools + // Determine max files allowed from the active tool (negative or undefined means unlimited) + const rawMax = selectedTool?.maxFiles; + const maxAllowed = (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax; if (maxAllowed === 1) { + // Only one file allowed -> replace selection with the new file newSelection = [contextFileId]; } else { - // Check if we've hit the selection limit - if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) { - showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning'); - return; + // If at capacity, drop the oldest selected and append the new one + if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) { + newSelection = [...currentSelectedIds.slice(1), contextFileId]; + } else { + newSelection = [...currentSelectedIds, contextFileId]; } - newSelection = [...currentSelectedIds, contextFileId]; } } // Update context (this automatically updates tool selection since they use the same action) setSelectedFiles(newSelection); - }, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]); + }, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles]); + + // Enforce maxAllowed when tool changes or when an external action sets too many selected files + useEffect(() => { + if (Number.isFinite(maxAllowed) && selectedFileIds.length > maxAllowed) { + setSelectedFiles(selectedFileIds.slice(-maxAllowed)); + } + }, [maxAllowed, selectedFileIds, setSelectedFiles]); // File reordering handler for drag and drop diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index cc61246363..e268b941ce 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useRef, useMemo } from 'react'; import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack } from '@mantine/core'; -import { useMediaQuery } from '@mantine/hooks'; +import { useIsMobile } from '@app/hooks/useIsMobile'; import { alert } from '@app/components/toast'; import { useTranslation } from 'react-i18next'; import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; @@ -64,7 +64,7 @@ const FileEditorThumbnail = ({ const [isDragging, setIsDragging] = useState(false); const dragElementRef = useRef(null); const [showHoverMenu, setShowHoverMenu] = useState(false); - const isMobile = useMediaQuery('(max-width: 1024px)'); + const isMobile = useIsMobile(); const [showCloseModal, setShowCloseModal] = useState(false); // Resolve the actual File object for pin/unpin operations @@ -93,6 +93,13 @@ const FileEditorThumbnail = ({ return (m?.[1] || '').toUpperCase(); }, [file.name]); + const extLower = useMemo(() => { + const m = /\.([a-z0-9]+)$/i.exec(file.name ?? ''); + return (m?.[1] || '').toLowerCase(); + }, [file.name]); + + const isCBZ = extLower === 'cbz'; + const pageLabel = useMemo( () => pageCount > 0 @@ -206,7 +213,7 @@ const FileEditorThumbnail = ({ alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); } }, - hidden: !isZipFile || !onUnzipFile, + hidden: !isZipFile || !onUnzipFile || isCBZ, }, { id: 'close', diff --git a/frontend/src/core/components/layout/Workbench.module.css b/frontend/src/core/components/layout/Workbench.module.css index 602110e4bd..73d2e402f4 100644 --- a/frontend/src/core/components/layout/Workbench.module.css +++ b/frontend/src/core/components/layout/Workbench.module.css @@ -1,21 +1,21 @@ -.workbench-scrollable { +.workbenchScrollable { overflow-y: auto !important; overflow-x: hidden !important; } -.workbench-scrollable::-webkit-scrollbar { +.workbenchScrollable::-webkit-scrollbar { width: 0.375rem; } -.workbench-scrollable::-webkit-scrollbar-track { +.workbenchScrollable::-webkit-scrollbar-track { background: transparent; } -.workbench-scrollable::-webkit-scrollbar-thumb { +.workbenchScrollable::-webkit-scrollbar-thumb { background-color: var(--mantine-color-gray-4); border-radius: 0.1875rem; } -.workbench-scrollable::-webkit-scrollbar-thumb:hover { +.workbenchScrollable::-webkit-scrollbar-thumb:hover { background-color: var(--mantine-color-gray-5); } diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index dcd9b5366d..f6477c67aa 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Box } from '@mantine/core'; import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; @@ -81,6 +80,7 @@ export default function Workbench() { switch (currentView) { case "fileEditor": + return ( view.workbenchId === currentView && view.data != null); + + if (customView) { const CustomComponent = customView.component; return ; @@ -154,7 +158,7 @@ export default function Workbench() { return ( 0 ? '3.5rem' : '0'), }} > {renderMainContent()}