diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json
index f6f8d828c..20c3590e8 100644
--- a/frontend/public/locales/en-GB/translation.json
+++ b/frontend/public/locales/en-GB/translation.json
@@ -13,7 +13,19 @@
"dismiss": "Maybe later"
},
"fullscreen": {
- "showDetails": "Show Details"
+ "showDetails": "Show Details",
+ "comingSoon": "Coming soon:",
+ "favorite": "Add to favourites",
+ "favorites": "Favourites",
+ "heading": "All tools (fullscreen view)",
+ "noResults": "Try adjusting your search or toggle descriptions to find what you need.",
+ "recommended": "Recommended",
+ "unfavorite": "Remove from favourites"
+ },
+ "placeholder": "Choose a tool to get started",
+ "toggle": {
+ "fullscreen": "Switch to fullscreen mode",
+ "sidebar": "Switch to sidebar mode"
}
},
"unsavedChanges": "You have unsaved changes to your PDF.",
@@ -56,7 +68,7 @@
"preview": "Position Selection",
"previewDisclaimer": "Preview is approximate. Final output may vary due to PDF font metrics."
},
- "pageSelectionPrompt": "Specify which pages to add numbers to. Examples: \"1,3,5\" for specific pages, \"1-5\" for ranges, \"2n\" for even pages, or leave blank for all pages.",
+ "pageSelectionPrompt": "Custom Page Selection (Enter a comma-separated list of page numbers 1,5,6 or Functions like 2n+1)",
"startingNumberTooltip": "The first number to display. Subsequent pages will increment from this number.",
"marginTooltip": "Distance between the page number and the edge of the page.",
"fontSizeTooltip": "Size of the page number text in points. Larger numbers create bigger text.",
@@ -72,7 +84,6 @@
"uploadLimitExceededPlural": "are too large. Maximum allowed size is",
"processTimeWarning": "Warning: This process can take up to a minute depending on file-size",
"pageOrderPrompt": "Custom Page Order (Enter a comma-separated list of page numbers or Functions like 2n+1) :",
- "pageSelectionPrompt": "Custom Page Selection (Enter a comma-separated list of page numbers 1,5,6 or Functions like 2n+1) :",
"goToPage": "Go",
"true": "True",
"false": "False",
@@ -104,7 +115,9 @@
"uploadFiles": "Upload Files",
"addFiles": "Add files",
"selectFromWorkbench": "Select files from the workbench or ",
- "selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or "
+ "selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or ",
+ "created": "Created",
+ "size": "File Size"
},
"noFavourites": "No favourites added",
"downloadComplete": "Download Complete",
@@ -289,7 +302,13 @@
"autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.",
"autoUnzipFileLimit": "Auto-unzip file limit",
"autoUnzipFileLimitDescription": "Maximum number of files to extract from ZIP",
- "autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs."
+ "autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.",
+ "defaultToolPickerMode": "Default tool picker mode",
+ "defaultToolPickerModeDescription": "Choose whether the tool picker opens in fullscreen or sidebar by default",
+ "mode": {
+ "fullscreen": "Fullscreen",
+ "sidebar": "Sidebar"
+ }
},
"hotkeys": {
"title": "Keyboard Shortcuts",
@@ -306,7 +325,8 @@
"change": "Change shortcut",
"reset": "Reset",
"shortcut": "Shortcut",
- "noShortcut": "No shortcut set"
+ "noShortcut": "No shortcut set",
+ "searchPlaceholder": "Search tools..."
}
},
"changeCreds": {
@@ -665,9 +685,9 @@
"title": "Manage Certificates",
"desc": "Import, export, or delete digital certificate files used for signing PDFs."
},
- "read": {
- "tags": "view,open,display",
- "title": "Read",
+ "read": {
+ "tags": "view,open,display",
+ "title": "Read",
"desc": "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration."
},
"reorganizePages": {
@@ -724,6 +744,20 @@
"tags": "workflow,sequence,automation",
"title": "Automate",
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
+ },
+ "mobile": {
+ "brandAlt": "Stirling PDF logo",
+ "openFiles": "Open files",
+ "swipeHint": "Swipe left or right to switch views",
+ "tools": "Tools",
+ "toolsSlide": "Tool selection panel",
+ "viewSwitcher": "Switch workspace view",
+ "workbenchSlide": "Workspace panel",
+ "workspace": "Workspace"
+ },
+ "overlay-pdfs": {
+ "desc": "Overlay one PDF on top of another",
+ "title": "Overlay PDFs"
}
},
"landing": {
@@ -909,13 +943,50 @@
"bullet1": "Bookmark Level: Which level to split on (1=top level)",
"bullet2": "Include Metadata: Preserve document properties",
"bullet3": "Allow Duplicates: Handle repeated bookmark names"
+ },
+ "byDocCount": {
+ "bullet1": "Enter the number of output files you want",
+ "bullet2": "Pages are distributed as evenly as possible",
+ "bullet3": "Useful when you need a specific number of files",
+ "text": "Create a specific number of output files by evenly distributing pages across them.",
+ "title": "Split by Document Count"
+ },
+ "byPageCount": {
+ "bullet1": "Enter the number of pages per output file",
+ "bullet2": "Last file may have fewer pages if not evenly divisible",
+ "bullet3": "Useful for batch processing workflows",
+ "text": "Create multiple PDFs with a specific number of pages each. Perfect for creating uniform document chunks.",
+ "title": "Split by Page Count"
+ },
+ "byPageDivider": {
+ "bullet1": "Print divider sheets from the download link",
+ "bullet2": "Insert divider sheets between your documents",
+ "bullet3": "Scan all documents together as one PDF",
+ "bullet4": "Upload - divider pages are automatically detected and removed",
+ "bullet5": "Enable Duplex Mode if scanning both sides of divider sheets",
+ "text": "Automatically split scanned documents using physical divider sheets with QR codes. Perfect for processing multiple documents scanned together.",
+ "title": "Split by Page Divider"
}
- }
+ },
+ "methodSelection": {
+ "tooltip": {
+ "bullet1": "Click on a method card to select it",
+ "bullet2": "Hover over each card to see a quick description",
+ "bullet3": "The settings step will appear after you select a method",
+ "bullet4": "You can change methods at any time before processing",
+ "header": {
+ "text": "Choose how you want to split your PDF document. Each method is optimized for different use cases and document types.",
+ "title": "Split Method Selection"
+ },
+ "title": "Choose Your Split Method"
+ }
+ },
+ "selectMethod": "Select a split method"
},
"rotate": {
"title": "Rotate PDF",
"submit": "Apply Rotation",
- "selectRotation": "Select Rotation Angle (Clockwise)",
+ "selectRotation": "Select Rotation Angle (Clockwise)",
"error": {
"failed": "An error occurred while rotating the PDF."
},
@@ -1003,7 +1074,8 @@
"imagesExt": "Images (JPG, PNG, etc.)",
"markdown": "Markdown",
"textRtf": "Text/RTF",
- "grayscale": "Greyscale"
+ "grayscale": "Greyscale",
+ "errorConversion": "An error occurred while converting the file."
},
"imageToPdf": {
"tags": "conversion,img,jpg,picture,photo"
@@ -1030,7 +1102,7 @@
"header": "PDF Page Organiser",
"submit": "Rearrange Pages",
"mode": {
- "_value": "Organization mode",
+ "_value": "Organisation mode",
"1": "Custom Page Order",
"2": "Reverse Order",
"3": "Duplex Sort",
@@ -1041,7 +1113,20 @@
"8": "Remove Last",
"9": "Remove First and Last",
"10": "Odd-Even Merge",
- "11": "Duplicate all pages"
+ "11": "Duplicate all pages",
+ "desc": {
+ "BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).",
+ "CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
+ "DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).",
+ "DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).",
+ "ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.",
+ "ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.",
+ "REMOVE_FIRST": "Remove the first page from the document.",
+ "REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document.",
+ "REMOVE_LAST": "Remove the last page from the document.",
+ "REVERSE_ORDER": "Flip the document so the last page becomes first and so on.",
+ "SIDE_STITCH_BOOKLET_SORT": "Arrange pages for side‑stitch booklet printing (optimized for binding on the side)."
+ }
},
"desc": {
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
@@ -1107,7 +1192,9 @@
"opacity": "Opacity (%)",
"spacing": {
"horizontal": "Horizontal Spacing",
- "vertical": "Vertical Spacing"
+ "vertical": "Vertical Spacing",
+ "height": "Height Spacing",
+ "width": "Width Spacing"
},
"convertToImage": "Flatten PDF pages to images"
},
@@ -1250,6 +1337,10 @@
"bullet4": "Best for sensitive or copyrighted content"
}
}
+ },
+ "type": {
+ "1": "Text",
+ "2": "Image"
}
},
"permissions": {
@@ -1363,6 +1454,38 @@
},
"examples": {
"title": "Examples"
+ },
+ "complex": {
+ "bullet1": "1,3-5,8,2n → pages 1, 3–5, 8, plus evens",
+ "bullet2": "10-,2n-1 → from page 10 to end + odd pages",
+ "description": "Mix different types.",
+ "title": "Complex Combinations"
+ },
+ "description": "Choose which pages to use for the operation. Supports single pages, ranges, formulas, and the all keyword.",
+ "individual": {
+ "bullet1": "1,3,5 → selects pages 1, 3, 5",
+ "bullet2": "2,7,12 → selects pages 2, 7, 12",
+ "description": "Enter numbers separated by commas.",
+ "title": "Individual Pages"
+ },
+ "mathematical": {
+ "bullet1": "2n → all even pages (2, 4, 6…)",
+ "bullet2": "2n-1 → all odd pages (1, 3, 5…)",
+ "bullet3": "3n → every 3rd page (3, 6, 9…)",
+ "bullet4": "4n-1 → pages 3, 7, 11, 15…",
+ "description": "Use n in formulas for patterns.",
+ "title": "Mathematical Functions"
+ },
+ "ranges": {
+ "bullet1": "3-6 → selects pages 3–6",
+ "bullet2": "10-15 → selects pages 10–15",
+ "bullet3": "5- → selects pages 5 to end",
+ "description": "Use - for consecutive pages.",
+ "title": "Page Ranges"
+ },
+ "special": {
+ "bullet1": "all → selects all pages",
+ "title": "Special Keywords"
}
}
},
@@ -1672,6 +1795,9 @@
"text": "Post-processes the final PDF by removing OCR artefacts and optimising the text layer for better readability and smaller file size."
}
}
+ },
+ "error": {
+ "failed": "OCR operation failed"
}
},
"extractImages": {
@@ -1792,8 +1918,14 @@
"title": "Sign",
"header": "Sign PDFs",
"upload": "Upload Image",
- "draw": "Draw Signature",
- "text": "Text Input",
+ "draw": {
+ "title": "Draw your signature",
+ "clear": "Clear"
+ },
+ "text": {
+ "name": "Signer Name",
+ "placeholder": "Enter your full name"
+ },
"clear": "Clear",
"add": "Add",
"saved": "Saved Signatures",
@@ -1822,19 +1954,11 @@
"image": "Image",
"text": "Text"
},
- "draw": {
- "title": "Draw your signature",
- "clear": "Clear"
- },
"image": {
"label": "Upload signature image",
"placeholder": "Select image file",
"hint": "Upload a PNG or JPG image of your signature"
},
- "text": {
- "name": "Signer Name",
- "placeholder": "Enter your full name"
- },
"instructions": {
"title": "How to add signature",
"canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
@@ -1961,7 +2085,13 @@
"bullet3": "Can be disabled to reduce output file size"
}
},
- "submit": "Remove blank pages"
+ "submit": "Remove blank pages",
+ "error": {
+ "failed": "Failed to remove blank pages"
+ },
+ "results": {
+ "title": "Removed Blank Pages"
+ }
},
"removeAnnotations": {
"tags": "comments,highlight,notes,markup,remove",
@@ -2063,7 +2193,12 @@
"bullet3": "Choose which page to place the signature",
"bullet4": "Optional logo can be included"
}
- }
+ },
+ "invisible": "Invisible",
+ "options": {
+ "title": "Signature Details"
+ },
+ "visible": "Visible"
},
"sign": {
"submit": "Sign PDF",
@@ -2124,7 +2259,22 @@
"text": "Convert your file to a Java keystore (.jks) with keytool, then pick JKS."
}
}
- }
+ },
+ "chooseCertificate": "Choose Certificate File",
+ "chooseJksFile": "Choose JKS File",
+ "chooseP12File": "Choose PKCS12 File",
+ "choosePfxFile": "Choose PFX File",
+ "choosePrivateKey": "Choose Private Key File",
+ "location": "Location",
+ "logoTitle": "Logo",
+ "name": "Name",
+ "noLogo": "No Logo",
+ "pageNumber": "Page Number",
+ "password": "Certificate Password",
+ "passwordOptional": "Leave empty if no password",
+ "reason": "Reason",
+ "serverCertMessage": "Using server certificate - no files or password required",
+ "showLogo": "Show Logo"
},
"removeCertSign": {
"tags": "authenticate,PEM,P12,official,decrypt",
@@ -2150,7 +2300,17 @@
"header": "Multi Page Layout",
"pagesPerSheet": "Pages per sheet:",
"addBorder": "Add Borders",
- "submit": "Submit"
+ "submit": "Submit",
+ "desc": {
+ "2": "Place 2 pages side-by-side on a single sheet.",
+ "3": "Place 3 pages on a single sheet in a single row.",
+ "4": "Place 4 pages on a single sheet (2 × 2 grid).",
+ "9": "Place 9 pages on a single sheet (3 × 3 grid).",
+ "16": "Place 16 pages on a single sheet (4 × 4 grid)."
+ },
+ "error": {
+ "failed": "An error occurred while creating the multi-page layout."
+ }
},
"bookletImposition": {
"tags": "booklet,imposition,printing,binding,folding,signature",
@@ -2336,10 +2496,22 @@
"reset": "Reset to full PDF",
"coordinates": {
"title": "Position and Size",
- "x": "X Position",
- "y": "Y Position",
- "width": "Width",
- "height": "Height"
+ "x": {
+ "label": "X Position",
+ "desc": "Left edge (points)"
+ },
+ "y": {
+ "label": "Y Position",
+ "desc": "Bottom edge (points)"
+ },
+ "width": {
+ "label": "Width",
+ "desc": "Crop width (points)"
+ },
+ "height": {
+ "label": "Height",
+ "desc": "Crop height (points)"
+ }
},
"error": {
"invalidArea": "Crop area extends beyond PDF boundaries",
@@ -2357,6 +2529,10 @@
},
"results": {
"title": "Crop Results"
+ },
+ "automation": {
+ "info": "Enter crop coordinates in PDF points. Origin (0,0) is at bottom-left. These values will be applied to all PDFs processed in this automation.",
+ "reference": "Reference: A4 page is 595.28 × 841.89 points (210mm × 297mm). 1 inch = 72 points."
}
},
"autoSplitPDF": {
@@ -2586,7 +2762,8 @@
"counts": {
"label": "Overlay Counts (for Fixed Repeat Mode)",
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)",
- "item": "Count for file"
+ "item": "Count for file",
+ "noFiles": "Add overlay files to configure counts"
},
"position": {
"label": "Select Overlay Position",
@@ -2627,6 +2804,9 @@
"title": "Counts (Fixed Repeat only)",
"text": "Provide a positive number for each overlay file showing how many pages to take before moving to the next. Required when mode is Fixed Repeat."
}
+ },
+ "error": {
+ "failed": "An error occurred while overlaying PDFs."
}
},
"split-by-sections": {
@@ -2662,7 +2842,18 @@
"customMargin": "Custom Margin",
"customColor": "Custom Text Colour",
"submit": "Submit",
- "noStampSelected": "No stamp selected. Return to Step 1."
+ "noStampSelected": "No stamp selected. Return to Step 1.",
+ "customPosition": "Drag the stamp to the desired location in the preview window.",
+ "error": {
+ "failed": "An error occurred while adding stamp to the PDF."
+ },
+ "imageSize": "Image Size",
+ "margin": "Margin",
+ "positionAndFormatting": "Position & Formatting",
+ "quickPosition": "Select a position on the page to place the stamp.",
+ "results": {
+ "title": "Stamp Results"
+ }
},
"removeImagePdf": {
"tags": "Remove Image,Page operations,Back end,server side"
@@ -2680,7 +2871,8 @@
"status": {
"_value": "Status",
"valid": "Valid",
- "invalid": "Invalid"
+ "invalid": "Invalid",
+ "complete": "Validation complete"
},
"signer": "Signer",
"date": "Date",
@@ -2707,16 +2899,71 @@
"version": "Version",
"keyUsage": "Key Usage",
"selfSigned": "Self-Signed",
- "bits": "bits"
+ "bits": "bits",
+ "details": "Certificate Details"
},
"signature": {
"info": "Signature Information",
"_value": "Signature",
"mathValid": "Signature is mathematically valid BUT:"
},
- "selectCustomCert": "Custom Certificate File X.509 (Optional)"
+ "selectCustomCert": "Custom Certificate File X.509 (Optional)",
+ "downloadCsv": "Download CSV",
+ "downloadJson": "Download JSON",
+ "downloadPdf": "Download PDF Report",
+ "downloadType": {
+ "csv": "CSV",
+ "json": "JSON",
+ "pdf": "PDF"
+ },
+ "error": {
+ "allFailed": "Unable to validate the selected files.",
+ "partial": "Some files could not be validated.",
+ "reportGeneration": "Could not generate the PDF report. JSON and CSV are available.",
+ "unexpected": "Unexpected error during validation."
+ },
+ "finalizing": "Preparing downloads...",
+ "issue": {
+ "certExpired": "Certificate expired",
+ "certRevocationUnknown": "Certificate revocation status unknown",
+ "certRevoked": "Certificate revoked",
+ "chainInvalid": "Certificate chain invalid",
+ "signatureInvalid": "Signature cryptographic check failed",
+ "trustInvalid": "Certificate not trusted"
+ },
+ "noResults": "Run the validation to generate a report.",
+ "noSignaturesShort": "No signatures",
+ "processing": "Validating signatures...",
+ "report": {
+ "continued": "Continued",
+ "downloads": "Downloads",
+ "entryLabel": "Signature Summary",
+ "fields": {
+ "created": "Created",
+ "fileSize": "File Size",
+ "signatureCount": "Total Signatures",
+ "signatureDate": "Signature Date"
+ },
+ "filesEvaluated": "{{count}} files evaluated",
+ "footer": "Validated via Stirling PDF",
+ "generatedAt": "Generated",
+ "noPdf": "PDF report will be available after a successful validation.",
+ "page": "Page",
+ "shortTitle": "Signature Summary",
+ "signatureCountLabel": "{{count}} signatures",
+ "signaturesFound": "{{count}} signatures detected",
+ "signaturesValid": "{{count}} fully valid",
+ "title": "Signature Validation Report"
+ },
+ "settings": {
+ "certHint": "Upload a trusted X.509 certificate to validate against a custom trust source.",
+ "title": "Validation Settings"
+ },
+ "signatureDate": "Signature Date",
+ "totalSignatures": "Total Signatures"
},
"replaceColor": {
+ "tags": "Replace Colour,Page operations,Back end,server side",
"labels": {
"settings": "Settings",
"colourOperation": "Colour operation"
@@ -2757,9 +3004,6 @@
"failed": "An error occurred while processing the colour replacement."
}
},
- "replaceColor": {
- "tags": "Replace Colour,Page operations,Back end,server side"
- },
"login": {
"title": "Sign in",
"header": "Sign in",
@@ -2875,7 +3119,19 @@
"contrast": "Contrast:",
"brightness": "Brightness:",
"saturation": "Saturation:",
- "download": "Download"
+ "download": "Download",
+ "adjustColors": "Adjust Colors",
+ "blue": "Blue",
+ "confirm": "Confirm",
+ "error": {
+ "failed": "Failed to adjust colors/contrast"
+ },
+ "green": "Green",
+ "noPreview": "Select a PDF to preview",
+ "red": "Red",
+ "results": {
+ "title": "Adjusted PDF"
+ }
},
"compress": {
"title": "Compress",
@@ -3022,7 +3278,13 @@
"title": "Remove image",
"header": "Remove image",
"removeImage": "Remove image",
- "submit": "Remove image"
+ "submit": "Remove image",
+ "error": {
+ "failed": "Failed to remove images from the PDF."
+ },
+ "results": {
+ "title": "Remove Images Results"
+ }
},
"splitByChapters": {
"title": "Split PDF by Chapters",
@@ -3167,7 +3429,9 @@
},
"search": {
"title": "Search PDF",
- "placeholder": "Enter search term..."
+ "placeholder": "Enter search term...",
+ "noResults": "No results found",
+ "searching": "Searching..."
},
"guestBanner": {
"title": "You're using Stirling PDF as a guest!",
@@ -3289,7 +3553,17 @@
"selectedCount": "{{count}} selected",
"download": "Download",
"delete": "Delete",
- "unsupported": "Unsupported"
+ "unsupported": "Unsupported",
+ "addToUpload": "Add to Upload",
+ "deleteAll": "Delete All",
+ "loadingFiles": "Loading files...",
+ "noFiles": "No files available",
+ "noFilesFound": "No files found matching your search",
+ "openInPageEditor": "Open in Page Editor",
+ "showAll": "Show All",
+ "sortByDate": "Sort by Date",
+ "sortByName": "Sort by Name",
+ "sortBySize": "Sort by Size"
},
"storage": {
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",
@@ -3544,16 +3818,6 @@
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
}
},
- "viewer": {
- "firstPage": "First Page",
- "lastPage": "Last Page",
- "previousPage": "Previous Page",
- "nextPage": "Next Page",
- "zoomIn": "Zoom In",
- "zoomOut": "Zoom Out",
- "singlePageView": "Single Page View",
- "dualPageView": "Dual Page View"
- },
"common": {
"copy": "Copy",
"copied": "Copied!",
@@ -3562,7 +3826,8 @@
"remaining": "remaining",
"used": "used",
"available": "available",
- "cancel": "Cancel"
+ "cancel": "Cancel",
+ "preview": "Preview"
},
"config": {
"account": {
@@ -3620,8 +3885,89 @@
"submit": "Add Attachments",
"results": {
"title": "Attachment Results"
+ },
+ "error": {
+ "failed": "Add attachments operation failed"
}
},
"termsAndConditions": "Terms & Conditions",
- "logOut": "Log out"
+ "logOut": "Log out",
+ "addAttachments": {
+ "error": {
+ "failed": "An error occurred while adding attachments to the PDF."
+ }
+ },
+ "autoRename": {
+ "description": "This tool will automatically rename PDF files based on their content. It analyzes the document to find the most suitable title from the text."
+ },
+ "customPosition": "Custom Position",
+ "details": "Details",
+ "downloadUnavailable": "Download unavailable for this item",
+ "invalidUndoData": "Cannot undo: invalid operation data",
+ "margin": {
+ "large": "Large",
+ "medium": "Medium",
+ "small": "Small",
+ "xLarge": "Extra Large"
+ },
+ "noFilesToUndo": "Cannot undo: no files were processed in the last operation",
+ "noOperationToUndo": "No operation to undo",
+ "noValidFiles": "No valid files to process",
+ "operationCancelled": "Operation cancelled",
+ "pageEdit": {
+ "deselectAll": "Select None",
+ "selectAll": "Select All"
+ },
+ "quickPosition": "Quick Position",
+ "reorganizePages": {
+ "error": {
+ "failed": "Failed to reorganize pages"
+ },
+ "results": {
+ "title": "Pages Reorganized"
+ },
+ "settings": {
+ "title": "Settings"
+ },
+ "submit": "Reorganize Pages"
+ },
+ "replace-color": {
+ "options": {
+ "fill": "Fill colour",
+ "gradient": "Gradient"
+ },
+ "previewOverlayOpacity": "Preview overlay opacity",
+ "previewOverlayTransparency": "Preview overlay transparency",
+ "previewOverlayVisibility": "Show preview overlay",
+ "selectText": {
+ "1": "Replace or invert colour options",
+ "2": "Default (preset high contrast colours)",
+ "3": "Custom (choose your own colours)",
+ "4": "Full invert (invert all colours)",
+ "5": "High contrast color options",
+ "6": "White text on black background",
+ "7": "Black text on white background",
+ "8": "Yellow text on black background",
+ "9": "Green text on black background",
+ "10": "Choose text Color",
+ "11": "Choose background Color",
+ "12": "Choose start colour",
+ "13": "Choose end colour"
+ },
+ "submit": "Replace",
+ "title": "Replace-Invert-Color"
+ },
+ "size": "Size",
+ "submit": "Submit",
+ "success": "Success",
+ "tools": {
+ "noSearchResults": "No tools found",
+ "noTools": "No tools available"
+ },
+ "undoDataMismatch": "Cannot undo: operation data is corrupted",
+ "undoFailed": "Failed to undo operation",
+ "undoQuotaError": "Cannot undo: insufficient storage space",
+ "undoStorageError": "Undo completed but some files could not be saved to storage",
+ "undoSuccess": "Operation undone successfully",
+ "unsupported": "Unsupported"
}
diff --git a/frontend/src/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx b/frontend/src/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx
index be79f4e46..2907ff498 100644
--- a/frontend/src/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx
+++ b/frontend/src/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx
@@ -43,7 +43,7 @@ const AddPageNumbersPositionSettings = ({
{t('addPageNumbers.pagesAndStarting', 'Pages & Starting Number')}
-
+
onCoordinateChange('x', value)}
@@ -47,7 +47,7 @@ const CropCoordinateInputs = ({
size={showAutomationInfo ? "sm" : "xs"}
/>
onCoordinateChange('y', value)}
@@ -62,7 +62,7 @@ const CropCoordinateInputs = ({
onCoordinateChange('width', value)}
@@ -74,7 +74,7 @@ const CropCoordinateInputs = ({
size={showAutomationInfo ? "sm" : "xs"}
/>
onCoordinateChange('height', value)}
diff --git a/frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts b/frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts
index e2a101a1c..45eb8e8dd 100644
--- a/frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts
+++ b/frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts
@@ -33,6 +33,6 @@ export const useReplaceColorOperation = () => {
return useToolOperation({
...replaceColorOperationConfig,
- getErrorMessage: createStandardErrorHandler(t('replaceColor.error.failed', 'An error occurred while processing the color replacement.'))
+ getErrorMessage: createStandardErrorHandler(t('replaceColor.error.failed', 'An error occurred while processing the colour replacement.'))
});
-};
\ No newline at end of file
+};
diff --git a/frontend/src/tests/missingTranslations.test.ts b/frontend/src/tests/missingTranslations.test.ts
new file mode 100644
index 000000000..908ab1504
--- /dev/null
+++ b/frontend/src/tests/missingTranslations.test.ts
@@ -0,0 +1,178 @@
+import fs from 'fs';
+import path from 'path';
+import ts from 'typescript';
+import { describe, expect, test } from 'vitest';
+
+const REPO_ROOT = path.join(__dirname, '../../../')
+const SRC_ROOT = path.join(__dirname, '..');
+const EN_GB_FILE = path.join(__dirname, '../../public/locales/en-GB/translation.json');
+
+const IGNORED_DIRS = new Set([
+ 'tests',
+ '__mocks__',
+]);
+const IGNORED_FILE_PATTERNS = [
+ /\.d\.ts$/,
+ /\.test\./,
+ /\.spec\./,
+ /\.stories\./,
+];
+const IGNORED_KEYS = new Set([
+ // If the script has found a false-positive that shouldn't be in the translations, include it here
+]);
+
+type FoundKey = {
+ key: string;
+ file: string;
+ line: number;
+ column: number;
+};
+
+const flattenKeys = (node: unknown, prefix = '', acc = new Set()): Set => {
+ if (!node || typeof node !== 'object' || Array.isArray(node)) {
+ if (prefix) {
+ acc.add(prefix);
+ }
+ return acc;
+ }
+
+ for (const [childKey, value] of Object.entries(node as Record)) {
+ const next = prefix ? `${prefix}.${childKey}` : childKey;
+ flattenKeys(value, next, acc);
+ }
+
+ return acc;
+};
+
+const listSourceFiles = (): string[] => {
+ const files = ts.sys.readDirectory(SRC_ROOT, ['.ts', '.tsx', '.js', '.jsx'], undefined, [
+ '**/*',
+ ]);
+
+ return files
+ .filter((file) => !file.split(path.sep).some((segment) => IGNORED_DIRS.has(segment)))
+ .filter((file) => !IGNORED_FILE_PATTERNS.some((re) => re.test(file)));
+};
+
+const getScriptKind = (file: string): ts.ScriptKind => {
+ if (file.endsWith('.tsx')) {
+ return ts.ScriptKind.TSX;
+ }
+
+ if (file.endsWith('.ts')) {
+ return ts.ScriptKind.TS;
+ }
+
+ if (file.endsWith('.jsx')) {
+ return ts.ScriptKind.JSX;
+ }
+
+ return ts.ScriptKind.JS;
+};
+
+/**
+ * Find all of the static first keys for translation functions that we can.
+ * Ignores dynamic strings because we can't know what the actual translation key will be.
+ */
+const extractKeys = (file: string): FoundKey[] => {
+ const code = fs.readFileSync(file, 'utf8');
+ const sourceFile = ts.createSourceFile(
+ file,
+ code,
+ ts.ScriptTarget.Latest,
+ true,
+ getScriptKind(file),
+ );
+
+ const found: FoundKey[] = [];
+
+ const record = (node: ts.Node, key: string) => {
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
+ found.push({ key, file, line: line + 1, column: character + 1 });
+ };
+
+ const visit = (node: ts.Node) => {
+ if (ts.isCallExpression(node)) {
+ const callee = node.expression;
+ const arg = node.arguments.at(0);
+
+ const isT =
+ (ts.isIdentifier(callee) && callee.text === 't') ||
+ (ts.isPropertyAccessExpression(callee) && callee.name.text === 't');
+
+ if (isT && arg && (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg))) {
+ record(arg, arg.text);
+ }
+ }
+
+ if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
+ for (const attr of node.attributes.properties) {
+ if (
+ !ts.isJsxAttribute(attr) ||
+ attr.name.getText(sourceFile) !== 'i18nKey' ||
+ !attr.initializer
+ ) {
+ continue;
+ }
+
+ const init = attr.initializer;
+
+ if (ts.isStringLiteral(init)) {
+ record(init, init.text);
+ continue;
+ }
+
+ if (
+ ts.isJsxExpression(init) &&
+ init.expression &&
+ ts.isStringLiteral(init.expression)
+ ) {
+ record(init.expression, init.expression.text);
+ }
+ }
+ }
+
+ ts.forEachChild(node, visit);
+ };
+
+ ts.forEachChild(sourceFile, visit);
+ return found;
+};
+
+describe('Missing translation coverage', () => {
+ test('fails if any en-GB translation key used in source is missing', () => {
+ expect(fs.existsSync(EN_GB_FILE)).toBe(true);
+
+ const localeContent = fs.readFileSync(EN_GB_FILE, 'utf8');
+ const enGb = JSON.parse(localeContent);
+ const availableKeys = flattenKeys(enGb);
+
+ const usedKeys = listSourceFiles()
+ .flatMap(extractKeys)
+ .filter(({ key }) => !IGNORED_KEYS.has(key));
+ expect(usedKeys.length).toBeGreaterThan(100); // Sanity check
+
+ const missingKeys = usedKeys.filter(({ key }) => !availableKeys.has(key));
+
+ const annotations = missingKeys.map(({ key, file, line, column }) => {
+ const workspaceRelativeRaw = path.relative(REPO_ROOT, file);
+ const workspaceRelativeFile = workspaceRelativeRaw.replace(/\\/g, '/');
+
+ return {
+ key,
+ file: workspaceRelativeFile,
+ line,
+ column,
+ };
+ });
+
+ // Output errors in GitHub Annotations format so they appear tagged in the code in CI
+ for (const { key, file, line, column } of annotations) {
+ process.stderr.write(
+ `::error file=${file},line=${line},col=${column}::Missing en-GB translation for ${key}\n`,
+ );
+ }
+
+ expect(missingKeys).toEqual([]);
+ });
+});