mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
feat(crop): add support for selective page cropping
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<Integer> 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",
|
||||
|
||||
@@ -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<Resource> 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<Resource> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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<string | null>(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;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Stack gap="md">
|
||||
<CropPageSelection
|
||||
value={parameters.pageNumbers ?? "all"}
|
||||
onChange={(value) => onParameterChange("pageNumbers", value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<CropCoordinateInputs
|
||||
cropArea={parameters.cropArea}
|
||||
onCoordinateChange={handleCoordinateChange}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Stack, TextInput } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PageSelectionSyntaxHint from "@app/components/shared/PageSelectionSyntaxHint";
|
||||
import { validatePageNumbers } from "@app/utils/pageSelection";
|
||||
|
||||
interface CropPageSelectionProps {
|
||||
value: string;
|
||||
onChange: (value: string) => 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 (
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
label={t("crop.pageNumbers.label", "Pages to Crop")}
|
||||
description={t(
|
||||
"crop.pageNumbers.description",
|
||||
"Crop the selected area on specific pages, or 'all' for every page.",
|
||||
)}
|
||||
value={value}
|
||||
onChange={(event) => 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}
|
||||
/>
|
||||
<PageSelectionSyntaxHint input={value} variant="compact" />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CropPageSelection;
|
||||
@@ -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 (
|
||||
<Stack gap="md" data-tour="crop-settings">
|
||||
{/* Pages to crop */}
|
||||
<CropPageSelection
|
||||
value={parameters.parameters.pageNumbers}
|
||||
onChange={(value) => parameters.updateParameter("pageNumbers", value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Auto-Crop Checkbox */}
|
||||
<Checkbox
|
||||
label={t("crop.autoCrop", "Auto-crop whitespace")}
|
||||
|
||||
@@ -20,6 +20,13 @@ describe("crop mappers", () => {
|
||||
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({
|
||||
|
||||
@@ -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<CropParameters> => ({
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<CropParameters> & {
|
||||
@@ -50,7 +54,13 @@ export type CropParametersHook = BaseParametersHook<CropParameters> & {
|
||||
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 => {
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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<number> {
|
||||
const set = new Set<number>();
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user