From 31f7abd28490ee65a7a12585b8c3175f85864ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= Date: Sun, 30 Aug 2026 14:24:12 +0200 Subject: [PATCH] feat(crop): add support for selective page cropping --- .../SPDF/controller/api/CropController.java | 23 ++++ .../SPDF/model/api/general/CropPdfForm.java | 22 +++- .../controller/api/CropControllerTest.java | 113 ++++++++++++++++++ .../model/api/general/CropPdfFormTest.java | 59 +++++++++ engine/src/stirling/models/tool_models.py | 4 + .../public/locales/en-US/translation.toml | 6 + .../shared/PageSelectionSyntaxHint.tsx | 6 +- .../tools/crop/CropAutomationSettings.tsx | 6 + .../tools/crop/CropPageSelection.tsx | 51 ++++++++ .../components/tools/crop/CropSettings.tsx | 8 ++ .../hooks/tools/crop/useCropOperation.test.ts | 7 ++ .../core/hooks/tools/crop/useCropOperation.ts | 2 + .../tools/crop/useCropParameters.test.ts | 62 ++++++++++ .../hooks/tools/crop/useCropParameters.ts | 12 +- .../editor/src/core/types/toolApiTypes.ts | 4 + .../bulkselection/parseSelection.test.ts | 9 ++ .../utils/bulkselection/parseSelection.ts | 15 ++- testing/cucumber/features/general_new.feature | 39 ++++++ .../features/steps/step_definitions.py | 17 +++ 19 files changed, 456 insertions(+), 9 deletions(-) create mode 100644 frontend/editor/src/core/components/tools/crop/CropPageSelection.tsx create mode 100644 frontend/editor/src/core/hooks/tools/crop/useCropParameters.test.ts diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java index 571bb42914..da8ca999c3 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java @@ -2,6 +2,7 @@ package stirling.software.SPDF.controller.api; import java.awt.image.BufferedImage; import java.io.IOException; +import java.util.BitSet; import java.util.List; import org.apache.pdfbox.multipdf.LayerUtility; @@ -129,6 +130,12 @@ public class CropController { return endpointConfiguration.isGroupEnabled("Ghostscript"); } + private static BitSet pageSelection(CropPdfForm request, PDDocument document) { + BitSet selected = new BitSet(document.getNumberOfPages()); + request.getPageNumbersList(document, false).forEach(selected::set); + return selected; + } + @AutoJobPostMapping( value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, @@ -169,8 +176,14 @@ public class CropController { PDFRenderer renderer = new PDFRenderer(sourceDocument); renderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage LayerUtility layerUtility = new LayerUtility(newDocument); + BitSet pagesToCrop = pageSelection(request, sourceDocument); for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) { + if (!pagesToCrop.get(i)) { + newDocument.importPage(sourceDocument.getPage(i)); + continue; + } + PDPage sourcePage = sourceDocument.getPage(i); PDRectangle mediaBox = sourcePage.getMediaBox(); @@ -222,8 +235,14 @@ public class CropController { pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) { int totalPages = sourceDocument.getNumberOfPages(); LayerUtility layerUtility = new LayerUtility(newDocument); + BitSet pagesToCrop = pageSelection(request, sourceDocument); for (int i = 0; i < totalPages; i++) { + if (!pagesToCrop.get(i)) { + newDocument.importPage(sourceDocument.getPage(i)); + continue; + } + PDPage sourcePage = sourceDocument.getPage(i); // Create a new page with the size of the source page @@ -276,7 +295,11 @@ public class CropController { TempFile tempOutputFile = null; try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) { + BitSet pagesToCrop = pageSelection(request, sourceDocument); for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) { + if (!pagesToCrop.get(i)) { + continue; + } PDPage page = sourceDocument.getPage(i); PDRectangle cropBox = new PDRectangle( diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/general/CropPdfForm.java b/app/core/src/main/java/stirling/software/SPDF/model/api/general/CropPdfForm.java index 16d10b9427..75e710cdce 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/general/CropPdfForm.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/general/CropPdfForm.java @@ -1,15 +1,33 @@ package stirling.software.SPDF.model.api.general; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; + +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; -import stirling.software.common.model.api.PDFFile; +import stirling.software.SPDF.model.api.PDFWithPageNums; +import stirling.software.common.util.GeneralUtils; @Data @EqualsAndHashCode(callSuper = true) -public class CropPdfForm extends PDFFile { +public class CropPdfForm extends PDFWithPageNums { + + // Legacy clients omit pageNumbers; keep them cropping every page rather than + // falling back to parsePageList's single-first-page default. + @Override + @Hidden + public List getPageNumbersList(PDDocument doc, boolean oneBased) { + String pageNumbers = getPageNumbers(); + return GeneralUtils.parsePageList( + (pageNumbers == null || pageNumbers.isBlank()) ? "all" : pageNumbers, + doc.getNumberOfPages(), + oneBased); + } @Schema( description = "The x-coordinate of the top-left corner of the crop area", diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/CropControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/CropControllerTest.java index 9ef8669295..d7afdee26c 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/CropControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/CropControllerTest.java @@ -103,6 +103,11 @@ class CropControllerTest { return this; } + CropRequestBuilder withPageNumbers(String pageNumbers) { + form.setPageNumbers(pageNumbers); + return this; + } + CropPdfForm build() { return form; } @@ -120,6 +125,32 @@ class CropControllerTest { return createPdf(filename, PDRectangle.LETTER, content); } + MockMultipartFile createMultiPagePdf(String filename, int pages) throws IOException { + Path testPdfPath = tempDir.resolve(filename); + + try (PDDocument doc = new PDDocument()) { + for (int i = 0; i < pages; i++) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + try (PDPageContentStream contentStream = new PDPageContentStream(doc, page)) { + contentStream.beginText(); + contentStream.setFont(HELVETICA, 12); + contentStream.newLineAtOffset(50, 50); + contentStream.showText("Page " + (i + 1)); + contentStream.endText(); + } + } + + doc.save(testPdfPath.toFile()); + } + + return new MockMultipartFile( + "fileInput", + filename, + MediaType.APPLICATION_PDF_VALUE, + Files.readAllBytes(testPdfPath)); + } + MockMultipartFile createPdfWithSize(String filename, PDRectangle size) throws IOException { return createPdf(filename, size, null); } @@ -713,4 +744,86 @@ class CropControllerTest { verify(newDocument, times(1)).close(); } } + + @Nested + @DisplayName("Page Selection") + @Tag("integration") + class SinglePageCropTests { + + @Test + @DisplayName("Only the selected page is cropped; the rest keep their original size") + void shouldCropOnlySelectedPage() throws IOException { + MockMultipartFile testFile = pdfFactory.createMultiPagePdf("multi.pdf", 3); + CropPdfForm request = + new CropRequestBuilder() + .withFile(testFile) + .withCoordinates(50f, 50f, 300f, 400f) + .withPageNumbers("2") + .withRemoveDataOutsideCrop(false) + .withAutoCrop(false) + .build(); + + try (PDDocument sourceDoc = Loader.loadPDF(testFile.getBytes()); + PDDocument newDoc = new PDDocument()) { + when(pdfDocumentFactory.load(request)).thenReturn(sourceDoc); + when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc)) + .thenReturn(newDoc); + + ResponseEntity response = cropController.cropPdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + try (PDDocument result = Loader.loadPDF(drainBody(response))) { + assertThat(result.getNumberOfPages()).isEqualTo(3); + + assertThat(result.getPage(0).getMediaBox().getWidth()) + .isEqualTo(PDRectangle.LETTER.getWidth()); + assertThat(result.getPage(0).getMediaBox().getHeight()) + .isEqualTo(PDRectangle.LETTER.getHeight()); + + assertThat(result.getPage(1).getMediaBox().getWidth()) + .isCloseTo(300f, within(0.01f)); + assertThat(result.getPage(1).getMediaBox().getHeight()) + .isCloseTo(400f, within(0.01f)); + + assertThat(result.getPage(2).getMediaBox().getWidth()) + .isEqualTo(PDRectangle.LETTER.getWidth()); + assertThat(result.getPage(2).getMediaBox().getHeight()) + .isEqualTo(PDRectangle.LETTER.getHeight()); + } + } + } + + @Test + @DisplayName("Legacy clients that omit pageNumbers still crop every page") + void shouldCropAllPagesWhenPageNumbersOmitted() throws IOException { + MockMultipartFile testFile = pdfFactory.createMultiPagePdf("multi.pdf", 3); + CropPdfForm request = + new CropRequestBuilder() + .withFile(testFile) + .withCoordinates(50f, 50f, 300f, 400f) + .withRemoveDataOutsideCrop(false) + .withAutoCrop(false) + .build(); + + try (PDDocument sourceDoc = Loader.loadPDF(testFile.getBytes()); + PDDocument newDoc = new PDDocument()) { + when(pdfDocumentFactory.load(request)).thenReturn(sourceDoc); + when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc)) + .thenReturn(newDoc); + + ResponseEntity response = cropController.cropPdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + try (PDDocument result = Loader.loadPDF(drainBody(response))) { + assertThat(result.getNumberOfPages()).isEqualTo(3); + for (int i = 0; i < result.getNumberOfPages(); i++) { + assertThat(result.getPage(i).getMediaBox().getWidth()) + .isCloseTo(300f, within(0.01f)); + assertThat(result.getPage(i).getMediaBox().getHeight()) + .isCloseTo(400f, within(0.01f)); + } + } + } + } + } } diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/general/CropPdfFormTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/general/CropPdfFormTest.java index ad95b0def0..2cb2ce12bc 100644 --- a/app/core/src/test/java/stirling/software/SPDF/model/api/general/CropPdfFormTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/general/CropPdfFormTest.java @@ -2,6 +2,11 @@ package stirling.software.SPDF.model.api.general; import static org.assertj.core.api.Assertions.assertThat; +import java.io.IOException; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -16,6 +21,14 @@ class CropPdfFormTest { "fileInput", "in.pdf", "application/pdf", new byte[] {1, 2, 3}); } + private static PDDocument threePageDoc() throws IOException { + PDDocument doc = new PDDocument(); + for (int i = 0; i < 3; i++) { + doc.addPage(new PDPage(PDRectangle.LETTER)); + } + return doc; + } + @Nested @DisplayName("defaults") class Defaults { @@ -61,6 +74,52 @@ class CropPdfFormTest { } } + @Nested + @DisplayName("page selection") + class PageSelection { + + @Test + @DisplayName("null pageNumbers falls back to every page") + void nullPageNumbersMeansAllPages() throws IOException { + try (PDDocument doc = threePageDoc()) { + CropPdfForm form = new CropPdfForm(); + assertThat(form.getPageNumbers()).isNull(); + assertThat(form.getPageNumbersList(doc, false)).containsExactly(0, 1, 2); + } + } + + @Test + @DisplayName("blank pageNumbers falls back to every page") + void blankPageNumbersMeansAllPages() throws IOException { + try (PDDocument doc = threePageDoc()) { + CropPdfForm form = new CropPdfForm(); + form.setPageNumbers(" "); + assertThat(form.getPageNumbersList(doc, false)).containsExactly(0, 1, 2); + } + } + + @Test + @DisplayName("'all' expands to every page") + void allExpandsToEveryPage() throws IOException { + try (PDDocument doc = threePageDoc()) { + CropPdfForm form = new CropPdfForm(); + form.setPageNumbers("all"); + assertThat(form.getPageNumbersList(doc, false)).containsExactly(0, 1, 2); + } + } + + @Test + @DisplayName("a single page number selects only that zero-based index") + void singlePageSelectsOneIndex() throws IOException { + try (PDDocument doc = threePageDoc()) { + CropPdfForm form = new CropPdfForm(); + form.setPageNumbers("2"); + assertThat(form.getPageNumbersList(doc, false)).containsExactly(1); + assertThat(form.getPageNumbersList(doc, true)).containsExactly(2); + } + } + } + @Nested @DisplayName("equals/hashCode/toString") class Equality { diff --git a/engine/src/stirling/models/tool_models.py b/engine/src/stirling/models/tool_models.py index dff977a7f7..cca20c20e1 100644 --- a/engine/src/stirling/models/tool_models.py +++ b/engine/src/stirling/models/tool_models.py @@ -372,6 +372,10 @@ class CompressPdfParams(ApiModel): class CropParams(ApiModel): auto_crop: bool | None = Field(None, description="Enable auto-crop to detect and remove white space") height: float | None = Field(None, description="The height of the crop area") + page_numbers: str = Field( + "all", + description="The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5')", + ) remove_data_outside_crop: bool | None = Field( None, description="Whether to remove text outside the crop area (keeps images)" ) diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index baee16cd02..07e73a7938 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3847,6 +3847,12 @@ label = "Y Position" [crop.error] failed = "Failed to crop PDF" +[crop.pageNumbers] +description = "Crop the selected area on specific pages, or 'all' for every page." +error = "Invalid page selection. Use e.g. 1, 3, 5-8 or all." +label = "Pages to Crop" +placeholder = "e.g. 1, 3, 5-8 or all" + [crop.preview] title = "Crop Area Selection" diff --git a/frontend/editor/src/core/components/shared/PageSelectionSyntaxHint.tsx b/frontend/editor/src/core/components/shared/PageSelectionSyntaxHint.tsx index 47cda328d8..c3b7559f2a 100644 --- a/frontend/editor/src/core/components/shared/PageSelectionSyntaxHint.tsx +++ b/frontend/editor/src/core/components/shared/PageSelectionSyntaxHint.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Text } from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css"; import { parseSelectionWithDiagnostics } from "@app/utils/bulkselection/parseSelection"; @@ -21,9 +22,10 @@ const PageSelectionSyntaxHint = ({ }: PageSelectionSyntaxHintProps) => { const [syntaxError, setSyntaxError] = useState(null); const { t } = useTranslation(); + const [debouncedInput] = useDebouncedValue(input, 300); useEffect(() => { - const text = (input || "").trim(); + const text = (debouncedInput || "").trim(); if (!text) { setSyntaxError(null); return; @@ -50,7 +52,7 @@ const PageSelectionSyntaxHint = ({ ), ); } - }, [input, maxPages]); + }, [debouncedInput, maxPages]); if (!syntaxError) return null; diff --git a/frontend/editor/src/core/components/tools/crop/CropAutomationSettings.tsx b/frontend/editor/src/core/components/tools/crop/CropAutomationSettings.tsx index 54539ce1b3..fd82e90a83 100644 --- a/frontend/editor/src/core/components/tools/crop/CropAutomationSettings.tsx +++ b/frontend/editor/src/core/components/tools/crop/CropAutomationSettings.tsx @@ -9,6 +9,7 @@ import { Stack } from "@mantine/core"; import { CropParameters } from "@app/hooks/tools/crop/useCropParameters"; import { Rectangle } from "@app/utils/cropCoordinates"; import CropCoordinateInputs from "@app/components/tools/crop/CropCoordinateInputs"; +import CropPageSelection from "@app/components/tools/crop/CropPageSelection"; interface CropAutomationSettingsProps { parameters: CropParameters; @@ -38,6 +39,11 @@ const CropAutomationSettings = ({ return ( + onParameterChange("pageNumbers", value)} + disabled={disabled} + /> void; + disabled?: boolean; +} + +const CropPageSelection = ({ + value, + onChange, + disabled = false, +}: CropPageSelectionProps) => { + const { t } = useTranslation(); + const [debouncedValue] = useDebouncedValue(value, 300); + + const trimmed = (debouncedValue ?? "").trim(); + const error = + trimmed.length > 0 && !validatePageNumbers(trimmed) + ? t( + "crop.pageNumbers.error", + "Invalid page selection. Use e.g. 1, 3, 5-8 or all.", + ) + : undefined; + + return ( + + onChange(event.currentTarget.value)} + placeholder={t("crop.pageNumbers.placeholder", "e.g. 1, 3, 5-8 or all")} + error={error} + spellCheck={false} + autoComplete="off" + disabled={disabled} + /> + + + ); +}; + +export default CropPageSelection; diff --git a/frontend/editor/src/core/components/tools/crop/CropSettings.tsx b/frontend/editor/src/core/components/tools/crop/CropSettings.tsx index 0229613959..c54b91745e 100644 --- a/frontend/editor/src/core/components/tools/crop/CropSettings.tsx +++ b/frontend/editor/src/core/components/tools/crop/CropSettings.tsx @@ -10,6 +10,7 @@ import { } from "@app/hooks/tools/shared/useViewScopedFiles"; import CropAreaSelector from "@app/components/tools/crop/CropAreaSelector"; import CropCoordinateInputs from "@app/components/tools/crop/CropCoordinateInputs"; +import CropPageSelection from "@app/components/tools/crop/CropPageSelection"; import { DEFAULT_CROP_AREA } from "@app/constants/cropConstants"; import { PAGE_SIZES } from "@app/constants/pageSizeConstants"; import { @@ -157,6 +158,13 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { return ( + {/* Pages to crop */} + parameters.updateParameter("pageNumbers", value)} + disabled={disabled} + /> + {/* Auto-Crop Checkbox */} { cropArea: { x: 10, y: 20, width: 300, height: 400 }, }, }, + { + label: "single page selection", + overrides: { + pageNumbers: "3", + cropArea: { x: 5, y: 5, width: 100, height: 200 }, + }, + }, ])("round-trips backend params ($label)", ({ overrides }) => { const api = cropToApiParams({ ...defaultParameters, ...overrides }); const roundTripped = cropToApiParams({ diff --git a/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts b/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts index 287980ec03..7274b45720 100644 --- a/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts +++ b/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts @@ -25,6 +25,7 @@ type CropApiParams = ToolApiParams[typeof ENDPOINT]; export const cropToApiParams = (parameters: CropParameters): CropApiParams => { const apiParams: CropApiParams = { autoCrop: parameters.autoCrop, + pageNumbers: parameters.pageNumbers, }; if (!parameters.autoCrop) { @@ -44,6 +45,7 @@ export const cropFromApiParams = ( apiParams: CropApiParams, ): Partial => ({ autoCrop: apiParams.autoCrop ?? defaultParameters.autoCrop, + pageNumbers: apiParams.pageNumbers ?? defaultParameters.pageNumbers, cropArea: { x: apiParams.x ?? DEFAULT_CROP_AREA.x, y: apiParams.y ?? DEFAULT_CROP_AREA.y, diff --git a/frontend/editor/src/core/hooks/tools/crop/useCropParameters.test.ts b/frontend/editor/src/core/hooks/tools/crop/useCropParameters.test.ts new file mode 100644 index 0000000000..e1e784b116 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/crop/useCropParameters.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "vitest"; +import { + defaultParameters, + validateCropParameters, +} from "@app/hooks/tools/crop/useCropParameters"; + +describe("validateCropParameters", () => { + test("accepts 'all' and valid ranges with a valid crop area", () => { + expect( + validateCropParameters({ + ...defaultParameters, + pageNumbers: "all", + }), + ).toBe(true); + expect( + validateCropParameters({ + ...defaultParameters, + pageNumbers: "1,3,5-8", + }), + ).toBe(true); + }); + + test("rejects invalid page syntax", () => { + expect( + validateCropParameters({ + ...defaultParameters, + pageNumbers: "abc", + }), + ).toBe(false); + expect( + validateCropParameters({ + ...defaultParameters, + pageNumbers: "2--3", + }), + ).toBe(false); + expect( + validateCropParameters({ + ...defaultParameters, + pageNumbers: "", + }), + ).toBe(false); + }); + + test("accepts open-range syntax", () => { + expect( + validateCropParameters({ + ...defaultParameters, + pageNumbers: "2-", + }), + ).toBe(true); + }); + + test("still validates the crop area independently of pages", () => { + expect( + validateCropParameters({ + ...defaultParameters, + pageNumbers: "all", + cropArea: { x: 0, y: 0, width: 0, height: 100 }, + }), + ).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts b/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts index 62e7ede14b..a2ad942181 100644 --- a/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts +++ b/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts @@ -14,15 +14,19 @@ import { isRectangle, } from "@app/utils/cropCoordinates"; import { DEFAULT_CROP_AREA } from "@app/constants/cropConstants"; +import { validatePageNumbers } from "@app/utils/pageSelection"; export interface CropParameters extends BaseParameters { cropArea: Rectangle; autoCrop: boolean; + /** Pages to crop, e.g. "3", "1,3,5-8" or "all" */ + pageNumbers: string; } export const defaultParameters: CropParameters = { cropArea: DEFAULT_CROP_AREA, autoCrop: false, + pageNumbers: "all", }; export type CropParametersHook = BaseParametersHook & { @@ -50,7 +54,13 @@ export type CropParametersHook = BaseParametersHook & { export function validateCropParameters(params: CropParameters): boolean { const rect = params.cropArea; // Basic validation - coordinates and dimensions must be positive - return rect.x >= 0 && rect.y >= 0 && rect.width > 0 && rect.height > 0; + return ( + rect.x >= 0 && + rect.y >= 0 && + rect.width > 0 && + rect.height > 0 && + validatePageNumbers(params.pageNumbers) + ); } export const useCropParameters = (): CropParametersHook => { diff --git a/frontend/editor/src/core/types/toolApiTypes.ts b/frontend/editor/src/core/types/toolApiTypes.ts index d980029624..9f1243eef8 100644 --- a/frontend/editor/src/core/types/toolApiTypes.ts +++ b/frontend/editor/src/core/types/toolApiTypes.ts @@ -407,6 +407,10 @@ export interface CropPdfForm { * The height of the crop area */ height?: number; + /** + * The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5') + */ + pageNumbers?: string; /** * Whether to remove text outside the crop area (keeps images) */ diff --git a/frontend/editor/src/core/utils/bulkselection/parseSelection.test.ts b/frontend/editor/src/core/utils/bulkselection/parseSelection.test.ts index 29c7dfb014..a7ebf48c97 100644 --- a/frontend/editor/src/core/utils/bulkselection/parseSelection.test.ts +++ b/frontend/editor/src/core/utils/bulkselection/parseSelection.test.ts @@ -28,6 +28,15 @@ describe("parseSelection", () => { expect(parseSelection("odd", 10)).toEqual([1, 3, 5, 7, 9]); }); + it("6b) supports all keyword (case-insensitive)", () => { + expect(parseSelection("all", 5)).toEqual([1, 2, 3, 4, 5]); + expect(parseSelection("ALL", 3)).toEqual([1, 2, 3]); + }); + + it("6c) all in combination selects the full range", () => { + expect(parseSelection("all & even", 6)).toEqual([2, 4, 6]); + }); + it("7) supports 2n progression", () => { expect(parseSelection("2n", 12)).toEqual([2, 4, 6, 8, 10, 12]); }); diff --git a/frontend/editor/src/core/utils/bulkselection/parseSelection.ts b/frontend/editor/src/core/utils/bulkselection/parseSelection.ts index ed8b356651..f8d848cf88 100644 --- a/frontend/editor/src/core/utils/bulkselection/parseSelection.ts +++ b/frontend/editor/src/core/utils/bulkselection/parseSelection.ts @@ -10,7 +10,7 @@ primary := "(" expression ")" | range | progression | keyword | number range := number "-" number // inclusive progression := k ["*"] "n" (("+" | "-") c)? // k >= 1, c any integer, n starts at 0 - keyword := "even" | "odd" + keyword := "even" | "odd" | "all" number := digits (>= 1) Precedence: "!" (NOT) > "&"/"and" (AND) > "," "|" "or" (OR) @@ -206,11 +206,12 @@ class ExpressionParser { return inner; } - // Keywords: even / odd + // Keywords: even / odd / all const keyword = this.tryReadKeyword(); if (keyword) { if (keyword === "even") return this.buildEven(); if (keyword === "odd") return this.buildOdd(); + if (keyword === "all") return this.buildAll(); } // Progression: k n ( +/- c )? @@ -275,12 +276,18 @@ class ExpressionParser { return this.buildProgression(2, -1); } - private tryReadKeyword(): "even" | "odd" | null { + private buildAll(): Set { + const set = new Set(); + for (let i = 1; i <= this.max; i++) set.add(i); + return set; + } + + private tryReadKeyword(): "even" | "odd" | "all" | null { const start = this.idx; const word = this.readWord(); if (!word) return null; const lower = word.toLowerCase(); - if (lower === "even" || lower === "odd") { + if (lower === "even" || lower === "odd" || lower === "all") { return lower; } // Not a keyword; rewind diff --git a/testing/cucumber/features/general_new.feature b/testing/cucumber/features/general_new.feature index cd25b09650..6d0b31c4c6 100644 --- a/testing/cucumber/features/general_new.feature +++ b/testing/cucumber/features/general_new.feature @@ -155,6 +155,45 @@ Feature: General PDF Operations API Validation And the response PDF should contain 1 pages + @crop @positive + Scenario: crop only the selected page and keep the others + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages + And the request data includes + | parameter | value | + | x | 0 | + | y | 0 | + | width | 50 | + | height | 50 | + | pageNumbers | 2 | + When I send the API request to the endpoint "/api/v1/general/crop" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response PDF should contain 3 pages + And the response PDF page 1 should have width 612 and height 792 + And the response PDF page 2 should have width 50 and height 50 + And the response PDF page 3 should have width 612 and height 792 + + + @crop @positive + Scenario: legacy requests without pageNumbers crop every page + Given I generate a PDF file as "fileInput" + And the pdf contains 3 pages + And the request data includes + | parameter | value | + | x | 0 | + | y | 0 | + | width | 50 | + | height | 50 | + When I send the API request to the endpoint "/api/v1/general/crop" + Then the response content type should be "application/pdf" + And the response status code should be 200 + And the response PDF should contain 3 pages + And the response PDF page 1 should have width 50 and height 50 + And the response PDF page 2 should have width 50 and height 50 + And the response PDF page 3 should have width 50 and height 50 + + @pdf-to-single-page @positive Scenario: pdf-to-single-page combines all pages into one long page Given I generate a PDF file as "fileInput" diff --git a/testing/cucumber/features/steps/step_definitions.py b/testing/cucumber/features/steps/step_definitions.py index 5abefa049a..2d3284bdb7 100644 --- a/testing/cucumber/features/steps/step_definitions.py +++ b/testing/cucumber/features/steps/step_definitions.py @@ -763,6 +763,23 @@ def step_check_response_pdf_page_count(context, page_count): ), f"Expected {page_count} pages but got {actual_page_count} pages" +@then( + "the response PDF page {page_number:d} should have width {width:d} and height {height:d}" +) +def step_check_response_pdf_page_size(context, page_number, width, height): + response_file = io.BytesIO(context.response.content) + reader = PdfReader(io.BytesIO(response_file.getvalue())) + page = reader.pages[page_number - 1] + actual_width = float(page.mediabox.width) + actual_height = float(page.mediabox.height) + assert ( + abs(actual_width - width) < 0.5 + ), f"Expected page {page_number} width {width} but got {actual_width}" + assert ( + abs(actual_height - height) < 0.5 + ), f"Expected page {page_number} height {height} but got {actual_height}" + + @then("the response ZIP should contain {file_count:d} files") def step_check_response_zip_file_count(context, file_count): response_file = io.BytesIO(context.response.content)