feat(crop): add support for selective page cropping

This commit is contained in:
Balázs Szücs
2026-08-30 14:24:12 +02:00
parent 34694c6f5e
commit 31f7abd284
19 changed files with 456 additions and 9 deletions
@@ -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)