Enhance multi-page PDF layout with advanced customization options (#397, #3655) (#5859)

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
OUNZAR Aymane
2026-03-24 17:27:56 +00:00
committed by GitHub
co-authored by Copilot
parent 8bbfbd63d7
commit a1f03c844b
17 changed files with 1094 additions and 78 deletions
@@ -48,25 +48,144 @@ public class MultiPageLayoutController {
public ResponseEntity<byte[]> mergeMultiplePagesIntoOne(
@ModelAttribute MergeMultiplePagesRequest request) throws IOException {
int pagesPerSheet = request.getPagesPerSheet();
MultipartFile file = request.getFileInput();
boolean addBorder = Boolean.TRUE.equals(request.getAddBorder());
int MAX_PAGES = 100000;
int MAX_COLS = 300;
int MAX_ROWS = 300;
if (pagesPerSheet != 2
&& pagesPerSheet != 3
&& pagesPerSheet != (int) Math.sqrt(pagesPerSheet) * Math.sqrt(pagesPerSheet)) {
String mode = request.getMode();
if (mode == null || mode.trim().isEmpty()) {
mode = "DEFAULT";
}
int rows;
int cols;
int pagesPerSheet;
switch (mode) {
case "DEFAULT":
pagesPerSheet = request.getPagesPerSheet();
if (pagesPerSheet != 2
&& pagesPerSheet
!= (int) Math.sqrt(pagesPerSheet) * Math.sqrt(pagesPerSheet)) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"pagesPerSheet",
"must be 2 or a perfect square");
}
cols = pagesPerSheet == 2 ? pagesPerSheet : (int) Math.sqrt(pagesPerSheet);
rows = pagesPerSheet == 2 ? 1 : (int) Math.sqrt(pagesPerSheet);
break;
case "CUSTOM":
rows = request.getRows();
cols = request.getCols();
if (rows <= 0 || cols <= 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"rows and cols",
"only strictly positive values are allowed");
}
pagesPerSheet = cols * rows;
break;
default:
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"mode",
"only 'DEFAULT' and 'CUSTOM' are supported");
}
if (pagesPerSheet > MAX_PAGES) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid {0} format: {1}",
"pagesPerSheet",
"must be less than " + MAX_PAGES);
}
if (cols > MAX_COLS) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid {0} format: {1}",
"cols",
"must be less than " + MAX_COLS);
}
if (rows > MAX_ROWS) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid {0} format: {1}",
"rows",
"must be less than " + MAX_ROWS);
}
String orientation = request.getOrientation();
if (orientation == null || orientation.trim().isEmpty()) {
orientation = "PORTRAIT";
}
if (!"PORTRAIT".equals(orientation) && !"LANDSCAPE".equals(orientation)) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"pagesPerSheet",
"must be 2, 3 or a perfect square");
"orientation",
"only 'PORTRAIT' and 'LANDSCAPE' are supported");
}
int cols =
pagesPerSheet == 2 || pagesPerSheet == 3
? pagesPerSheet
: (int) Math.sqrt(pagesPerSheet);
int rows = pagesPerSheet == 2 || pagesPerSheet == 3 ? 1 : (int) Math.sqrt(pagesPerSheet);
String arrangement = request.getArrangement();
if (arrangement == null || arrangement.trim().isEmpty()) {
arrangement = "BY_ROWS";
}
if (!"BY_ROWS".equals(arrangement) && !"BY_COLUMNS".equals(arrangement)) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"arrangement",
"only 'BY_ROWS' and 'BY_COLUMNS' are supported");
}
String readingDirection = request.getReadingDirection();
if (readingDirection == null || readingDirection.trim().isEmpty()) {
readingDirection = "LTR";
}
if (!"LTR".equals(readingDirection) && !"RTL".equals(readingDirection)) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"readingDirection",
"only 'LTR' and 'RTL' are supported");
}
boolean addBorder = Boolean.TRUE.equals(request.getAddBorder());
int topMargin = request.getTopMargin();
int bottomMargin = request.getBottomMargin();
int leftMargin = request.getLeftMargin();
int rightMargin = request.getRightMargin();
int innerMargin = request.getInnerMargin();
if (topMargin < 0
|| bottomMargin < 0
|| leftMargin < 0
|| rightMargin < 0
|| innerMargin < 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"Margins",
"only positive values are allowed");
}
int borderWidth = request.getBorderWidth() == 0 ? 1 : request.getBorderWidth();
if (addBorder && borderWidth <= 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"borderWidth",
"only strictly positive values are allowed when addBorder is true");
}
MultipartFile file = request.getFileInput();
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
try (PDDocument newDocument =
@@ -74,16 +193,53 @@ public class MultiPageLayoutController {
int totalPages = sourceDocument.getNumberOfPages();
LayerUtility layerUtility = new LayerUtility(newDocument);
// Margin between page and content:
float pageWidth =
"PORTRAIT".equals(orientation)
? PDRectangle.A4.getWidth()
: PDRectangle.A4.getHeight();
float pageHeight =
"PORTRAIT".equals(orientation)
? PDRectangle.A4.getHeight()
: PDRectangle.A4.getWidth();
// Calculate cell dimensions once (all output pages are A4) - declare outside try
// blocks
float cellWidth = PDRectangle.A4.getWidth() / cols;
float cellHeight = PDRectangle.A4.getHeight() / rows;
float cellWidth = (pageWidth - leftMargin - rightMargin) / cols;
float cellHeight = (pageHeight - topMargin - bottomMargin) / rows;
// Validate that outer margins and grid configuration yield positive cell size
if (cellWidth <= 0 || cellHeight <= 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"margin/layout configuration",
"Invalid margin or layout configuration: resulting cell size is non-positive. "
+ "Please reduce outer margins or adjust rows/columns.");
}
float innerWidth = cellWidth - 2 * innerMargin;
float innerHeight = cellHeight - 2 * innerMargin;
// Validate that inner margin fits within each cell
if (innerWidth <= 0 || innerHeight <= 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"inner margin",
"Invalid inner margin: resulting inner content area is non-positive. "
+ "Please reduce inner margin or adjust outer margins/layout.");
}
// Process pages in groups of pagesPerSheet, creating a new page and content stream
// for each group
for (int i = 0; i < totalPages; i += pagesPerSheet) {
// Create a new output page for each group of pagesPerSheet
PDPage newPage = new PDPage(PDRectangle.A4);
// Create a new A4 landscape rectangle that we use when orientation is landscape
PDRectangle a4Landscape =
new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth());
PDPage newPage =
"PORTRAIT".equals(orientation)
? new PDPage(PDRectangle.A4)
: new PDPage(a4Landscape);
newDocument.addPage(newPage);
// Use try-with-resources for each content stream to ensure proper cleanup
@@ -95,30 +251,52 @@ public class MultiPageLayoutController {
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
float borderThickness = 1.5f; // Specify border thickness as required
contentStream.setLineWidth(borderThickness);
contentStream.setStrokingColor(Color.BLACK);
if (addBorder) {
contentStream.setLineWidth(borderWidth);
contentStream.setStrokingColor(Color.BLACK);
}
// Process all pages in this group
for (int j = 0; j < pagesPerSheet && (i + j) < totalPages; j++) {
int pageIndex = i + j;
PDPage sourcePage = sourceDocument.getPage(pageIndex);
PDRectangle rect = sourcePage.getMediaBox();
float scaleWidth = cellWidth / rect.getWidth();
float scaleHeight = cellHeight / rect.getHeight();
float scaleWidth = innerWidth / rect.getWidth();
float scaleHeight = innerHeight / rect.getHeight();
float scale = Math.min(scaleWidth, scaleHeight);
int adjustedPageIndex = j % pagesPerSheet;
int rowIndex = adjustedPageIndex / cols;
int colIndex = adjustedPageIndex % cols;
int rowIndex;
int colIndex;
if ("BY_ROWS".equals(arrangement)) {
rowIndex = adjustedPageIndex / cols;
if ("LTR".equals(readingDirection)) {
colIndex = adjustedPageIndex % cols;
} else {
colIndex = cols - 1 - (adjustedPageIndex % cols);
}
} else {
rowIndex = adjustedPageIndex % rows;
if ("LTR".equals(readingDirection)) {
colIndex = adjustedPageIndex / rows;
} else {
colIndex = cols - 1 - (adjustedPageIndex / rows);
}
}
float x =
colIndex * cellWidth
+ (cellWidth - rect.getWidth() * scale) / 2;
leftMargin
+ colIndex * cellWidth
+ innerMargin
+ (innerWidth - rect.getWidth() * scale) / 2;
float y =
newPage.getMediaBox().getHeight()
- topMargin
- ((rowIndex + 1) * cellHeight
- (cellHeight - rect.getHeight() * scale) / 2);
- innerMargin
- (innerHeight - rect.getHeight() * scale) / 2);
contentStream.saveGraphicsState();
contentStream.transform(Matrix.getTranslateInstance(x, y));
@@ -132,11 +310,8 @@ public class MultiPageLayoutController {
if (addBorder) {
// Draw border around each page
float borderX = colIndex * cellWidth;
float borderY =
newPage.getMediaBox().getHeight()
- (rowIndex + 1) * cellHeight;
contentStream.addRect(borderX, borderY, cellWidth, cellHeight);
contentStream.addRect(
x, y, rect.getWidth() * scale, rect.getHeight() * scale);
contentStream.stroke();
}
}
@@ -145,7 +320,7 @@ public class MultiPageLayoutController {
// If any source page is rotated, skip form copying/transformation entirely
boolean hasRotation = GeneralFormCopyUtils.hasAnyRotatedPage(sourceDocument);
if (hasRotation) {
if (hasRotation || "LANDSCAPE".equals(orientation)) {
log.info("Source document has rotated pages; skipping form field copying.");
} else {
try {
@@ -11,13 +11,110 @@ import stirling.software.common.model.api.PDFFile;
@EqualsAndHashCode(callSuper = true)
public class MergeMultiplePagesRequest extends PDFFile {
@Schema(
description =
"Input mode: DEFAULT uses pagesPerSheet; CUSTOM uses explicit cols x rows.",
requiredMode = Schema.RequiredMode.REQUIRED,
type = "string",
defaultValue = "DEFAULT",
allowableValues = {"DEFAULT", "CUSTOM"})
private String mode;
@Schema(
description = "The number of pages to fit onto a single sheet in the output PDF.",
type = "integer",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"2", "3", "4", "9", "16"})
allowableValues = {"2", "4", "9", "16"})
private int pagesPerSheet = 2;
@Schema(
description =
"The arrangement of pages on the sheet: BY_ROWS fills pages row by row, while BY_COLUMNS fills pages column by column.",
type = "string",
defaultValue = "BY_ROWS",
allowableValues = {"BY_ROWS", "BY_COLUMNS"})
private String arrangement;
@Schema(
description =
"The direction in which pages are arranged on the sheet: LTR (left-to-right) or RTL (right-to-left).",
type = "string",
defaultValue = "LTR",
allowableValues = {"LTR", "RTL"})
private String readingDirection;
@Schema(
description = "Number of rows",
type = "number",
defaultValue = "1",
maximum = "300",
minimum = "1",
example = "3")
private int rows;
@Schema(
description = "Number of columns",
type = "number",
defaultValue = "2",
maximum = "300",
minimum = "1",
example = "2")
private int cols;
@Schema(
description = "The orientation of the output PDF pages",
type = "string",
defaultValue = "PORTRAIT",
allowableValues = {"PORTRAIT", "LANDSCAPE"})
private String orientation;
@Schema(
description = "Inner margin (in points) to apply around each page when merging",
type = "number",
defaultValue = "0",
minimum = "0",
example = "200")
private int innerMargin;
@Schema(
description = "Top margin (in points) to apply to the output pages when merging",
type = "number",
defaultValue = "0",
minimum = "0",
example = "200")
private int topMargin;
@Schema(
description = "Bottom margin (in points) to apply to the output pages when merging",
type = "number",
defaultValue = "0",
minimum = "0",
example = "200")
private int bottomMargin;
@Schema(
description = "Left margin (in points) to apply to the output pages when merging",
type = "number",
defaultValue = "0",
minimum = "0",
example = "200")
private int leftMargin;
@Schema(
description = "Right margin (in points) to apply to the output pages when merging",
type = "number",
defaultValue = "0",
minimum = "0",
example = "200")
private int rightMargin;
@Schema(
description = "Border width (in points) to apply around each page when merging",
type = "number",
defaultValue = "1",
minimum = "0",
example = "2")
private int borderWidth;
@Schema(description = "Boolean for if you wish to add border around the pages")
private Boolean addBorder;
}
@@ -106,7 +106,9 @@ class MultiPageLayoutControllerTest {
.thenReturn(target);
MergeMultiplePagesRequest req = new MergeMultiplePagesRequest();
req.setPagesPerSheet(3);
req.setMode("CUSTOM");
req.setCols(3);
req.setRows(1);
req.setAddBorder(Boolean.TRUE);
req.setFileInput(fileNoExt);
+112 -1
View File
@@ -4511,21 +4511,132 @@ title = "Extract Pages"
[pageLayout]
addBorder = "Add Borders"
borderWidth = "Border Thickness"
bottom = "Bottom Margin"
cols = "Columns"
header = "Multi Page Layout"
innerMargin = "Inner Margin"
left = "Left Margin"
pagesPerSheet = "Pages per sheet:"
right = "Right Margin"
rows = "Rows"
submit = "Submit"
tags = "merge,composite,single-view,organize"
title = "Multi Page Layout"
top = "Top Margin"
[pageLayout.mode]
default = "Default"
custom = "Custom"
label = "Mode:"
[pageLayout.arrangement]
byColumns = "By Columns"
byRows = "By Rows"
label = "Page arrangement:"
[pageLayout.desc]
16 = "Place 16 pages on a single sheet (4 × 4 grid)."
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)."
[pageLayout.error]
failed = "An error occurred while creating the multi-page layout."
outerVerticalMarginsTooLarge = "Top/Bottom margins are too large for this page size."
outerHorizontalMarginsTooLarge = "Left/Right margins are too large for this page size."
innerMarginTooLarge = "Inner margin is too large for the selected layout."
[pageLayout.orientation]
landscape = "Landscape"
portrait = "Portrait"
label = "Orientation:"
[pageLayout.readingDirection]
ltr = "Left to Right"
rtl = "Right to Left"
label = "Reading Direction:"
[pageLayout.tooltip.header]
title = "Page Layout Guide"
[pageLayout.tooltip.overview]
title = "What is Page Layout?"
text = "Fit multiple pages onto a single sheet for handouts or to save paper."
[pageLayout.tooltip.mode]
title = "Mode"
text = "Choose how the grid is configured:"
bullet1 = "Default: Pick a preset and the grid is calculated automatically."
bullet2 = "Custom: Set rows and columns manually."
[pageLayout.tooltip.pagesPerSheet]
title = "Pages per Sheet (Default Mode)"
text = "Choose how many pages per sheet (e.g. 4 → 2×2, 9 → 3×3)."
[pageLayout.tooltip.rowsCols]
title = "Rows & Columns (Custom Mode)"
text = "Set exact grid dimensions. Total pages per sheet = rows × columns."
[pageLayout.tooltip.orientation]
title = "Orientation"
text = "Sets the output sheet orientation:"
bullet1 = "Portrait: Taller than wide."
bullet2 = "Landscape: Wider than tall."
[pageLayout.tooltip.arrangement]
title = "Page Arrangement"
text = "Controls the order pages fill the grid:"
bullet1 = "By Rows: Fill row by row (left-to-right or right-to-left)."
bullet2 = "By Columns: Fill top-to-bottom, column by column."
[pageLayout.tooltip.readingDirection]
title = "Reading Direction"
text = "Controls the horizontal order of pages:"
bullet1 = "LTR: Left to right."
bullet2 = "RTL: Right to left."
[pageLayout.tooltip.addBorder]
title = "Add Borders"
text = "Draws border lines around each page cell for cutting guides or visual separation."
[pageLayout.marginsBorders.tooltip.header]
title = "Margins and Borders"
[pageLayout.marginsBorders.tooltip.margins]
title = "Margins"
text = "Use top, bottom, left, and right margins to control spacing around the full sheet output."
[pageLayout.marginsBorders.tooltip.innerMargin]
title = "Inner Margin"
text = "Inner margin adds spacing between cells in the page grid to improve separation and readability."
[pageLayout.marginsBorders.tooltip.borders]
title = "Add Borders"
text = "Enable borders to draw lines around each placed page. This can help visual separation or trimming."
[pageLayout.marginsBorders.tooltip.borderWidth]
title = "Border Thickness"
text = "Border thickness is only applied when borders are enabled. Higher values produce thicker lines."
[pageLayout.advanced.tooltip.header]
title = "Advanced Layout Options"
[pageLayout.advanced.tooltip.orientation]
title = "Orientation"
text = "Choose the final sheet direction. Portrait works better for tall content, while landscape fits wider layouts."
[pageLayout.advanced.tooltip.arrangement]
title = "Page Arrangement"
text = "Controls whether pages fill the grid row-by-row or column-by-column."
bullet1 = "By Rows: Fill each row first."
bullet2 = "By Columns: Fill each column first."
[pageLayout.advanced.tooltip.readingDirection]
title = "Reading Direction"
text = "Sets horizontal ordering in the grid, useful for left-to-right and right-to-left document conventions."
bullet1 = "LTR: Left to right order."
bullet2 = "RTL: Right to left order."
[pageRemover]
header = "PDF Page remover"
@@ -0,0 +1,72 @@
import { PageLayoutParameters } from '@app/hooks/tools/pageLayout/usePageLayoutParameters';
import { computeBoxes } from '@app/components/tools/pageLayout/utils/computeBoxes';
export default function LayoutPreview({
parameters
}: {
parameters: PageLayoutParameters
}) {
const sheet = {
x: 0,
y: 0,
width: parameters.orientation === "LANDSCAPE" ? 297 : 210,
height: parameters.orientation === "LANDSCAPE" ? 210 : 297
}
const boxes = computeBoxes(sheet, parameters);
const aspectRatio = sheet.width / sheet.height;
return (
<svg
viewBox={`${sheet.x} ${sheet.y} ${sheet.width} ${sheet.height}`}
preserveAspectRatio="xMidYMid meet"
style={{
width: parameters.orientation === "PORTRAIT" ? "50%" : `${50 * aspectRatio}%`,
aspectRatio: `${aspectRatio}`,
display: 'block',
margin: '0 auto',
padding: '10px',
}}>
<rect
x={sheet.x}
y={sheet.y}
width={sheet.width}
height={sheet.height}
rx={4}
ry={4}
fill="white"
stroke="black"
strokeWidth={2}
/>
{boxes.map(box => (
<g key={box.label}>
<rect
x={box.x}
y={box.y}
width={box.width}
height={box.height}
rx={3}
ry={3}
fill="#bfdbfe"
stroke={parameters.addBorder ? "#3b82f6" : "none"}
strokeWidth={2}
/>
<text
x={box.x + box.width / 2}
y={box.y + box.height / 2}
textAnchor="middle"
dominantBaseline="middle"
fontSize={Math.min(box.width, box.height) * 0.6}
fill="#1d4ed8"
fontFamily="Arial, sans-serif"
fontWeight="bold"
>
{box.label}
</text>
</g>))
}
</svg>
)
}
@@ -0,0 +1,75 @@
import { Divider, Select, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PageLayoutParameters } from '@app/hooks/tools/pageLayout/usePageLayoutParameters';
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
export default function PageLayoutAdvancedSettings({
parameters,
onParameterChange,
disabled,
}: {
parameters: PageLayoutParameters;
onParameterChange: <K extends keyof PageLayoutParameters>(
key: K,
value: PageLayoutParameters[K]
) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
return (
<Stack gap="sm">
<Select
label={t('pageLayout.orientation.label', 'Orientation:')}
data={[
{ value: 'PORTRAIT', label: t('pageLayout.orientation.portrait', 'Portrait') },
{ value: 'LANDSCAPE', label: t('pageLayout.orientation.landscape', 'Landscape') },
]}
value={String(parameters.orientation)}
onChange={(v) => {
if (v === "PORTRAIT" || v === "LANDSCAPE") {
onParameterChange('orientation', v)
}
}}
disabled={disabled}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_AUTOMATE_DROPDOWN }}
/>
<Divider />
<Select
label={t('pageLayout.arrangement.label', 'Page arrangement:')}
data={[
{ value: 'BY_ROWS', label: t('pageLayout.arrangement.byRows', 'By Rows') },
{ value: 'BY_COLUMNS', label: t('pageLayout.arrangement.byColumns', 'By Columns') },
]}
value={String(parameters.arrangement)}
onChange={(v) => {
if (v === "BY_COLUMNS" || v === "BY_ROWS") {
onParameterChange('arrangement', v)
}
}}
disabled={disabled}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_AUTOMATE_DROPDOWN }}
/>
<Select
label={t('pageLayout.readingDirection.label', 'Reading Direction:')}
data={[
{ value: 'LTR', label: t('pageLayout.readingDirection.ltr', 'Left to Right') },
{ value: 'RTL', label: t('pageLayout.readingDirection.rtl', 'Right to Left') },
]}
value={String(parameters.readingDirection)}
onChange={(v) => {
if (v === "LTR" || v === "RTL") {
onParameterChange('readingDirection', v)
}
}}
disabled={disabled}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_AUTOMATE_DROPDOWN }}
/>
</Stack>
);
}
@@ -0,0 +1,134 @@
import { Divider, Stack, NumberInput, Switch } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PageLayoutParameters } from '@app/hooks/tools/pageLayout/usePageLayoutParameters';
export default function PageLayoutMarginsBordersSettings({
parameters,
onParameterChange,
disabled,
}: {
parameters: PageLayoutParameters;
onParameterChange: <K extends keyof PageLayoutParameters>(
key: K,
value: PageLayoutParameters[K]
) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
const cols = parameters.mode === "DEFAULT" ? Math.ceil(Math.sqrt(parameters.pagesPerSheet)) : parameters.cols;
const rows = parameters.mode === "DEFAULT" ? Math.ceil(parameters.pagesPerSheet / cols) : parameters.rows;
const left = parameters.leftMargin ?? 0;
const right = parameters.rightMargin ?? 0;
const top = parameters.topMargin ?? 0;
const bottom = parameters.bottomMargin ?? 0;
const inner = parameters.innerMargin ?? 0;
const pageWidth = parameters.orientation === "PORTRAIT"
? 595.28 // A4 width in points
: 841.89 ; // A4 height in points
const pageHeight = parameters.orientation === "PORTRAIT"
? 841.89 // A4 height in points
: 595.28; // A4 width in points
const cellWidth = (pageWidth - left - right) / cols;
const cellHeight = (pageHeight - top - bottom) / rows;
const innerWidth = cellWidth - 2 * inner;
const innerHeight = cellHeight - 2 * inner;
const invalidOuterWidth = left + right >= pageWidth;
const invalidOuterHeight = top + bottom >= pageHeight;
const invalidInnerSize = (innerWidth <= 0 || innerHeight <= 0) && inner > 0;
const outerHeightError = invalidOuterHeight
? t('pageLayout.error.outerVerticalMarginsTooLarge', 'Top/Bottom margins are too large for this page size.')
: undefined;
const outerWidthError = invalidOuterWidth
? t('pageLayout.error.outerHorizontalMarginsTooLarge', 'Left/Right margins are too large for this page size.')
: undefined;
const innerError = invalidInnerSize
? t('pageLayout.error.innerMarginTooLarge', 'Inner margin is too large for the selected layout.')
: undefined;
return (
<Stack gap="sm">
<NumberInput
label={t('pageLayout.top', 'Top Margin')}
placeholder="Enter top margin"
value={parameters.topMargin}
onChange={(v) => onParameterChange('topMargin', Number(v))}
min={0}
disabled={disabled}
style={{ flex: 1 }}
error={outerHeightError}
/>
<NumberInput
label={t('pageLayout.bottom', 'Bottom Margin')}
placeholder="Enter bottom margin"
value={parameters.bottomMargin}
onChange={(v) => onParameterChange('bottomMargin', Number(v))}
min={0}
disabled={disabled}
style={{ flex: 1 }}
error={outerHeightError}
/>
<NumberInput
label={t('pageLayout.left', 'Left Margin')}
placeholder="Enter left margin"
value={parameters.leftMargin}
onChange={(v) => onParameterChange('leftMargin', Number(v))}
min={0}
disabled={disabled}
error={outerWidthError}
/>
<NumberInput
label={t('pageLayout.right', 'Right Margin')}
placeholder="Enter right margin"
value={parameters.rightMargin}
onChange={(v) => onParameterChange('rightMargin', Number(v))}
min={0}
disabled={disabled}
style={{ flex: 1 }}
error={outerWidthError}
/>
<NumberInput
label={t('pageLayout.innerMargin', 'Inner Margin')}
placeholder="Enter inner margin"
value={parameters.innerMargin}
onChange={(v) => onParameterChange('innerMargin', Number(v))}
min={0}
disabled={disabled}
style={{ flex: 1 }}
error={innerError}
/>
<Divider />
<Switch
checked={parameters.addBorder}
onChange={(e) => onParameterChange('addBorder', e.currentTarget.checked)}
label={t('pageLayout.addBorder', 'Add Borders')}
disabled={disabled}
/>
{parameters.addBorder && (
<NumberInput
label={t('pageLayout.borderWidth', 'Border Thickness')}
placeholder="Enter border thickness"
value={parameters.borderWidth}
onChange={(v) => onParameterChange('borderWidth', Number(v))}
min={1}
disabled={disabled}
style={{ flex: 1 }}
/>
)}
</Stack>
);
}
@@ -0,0 +1,18 @@
import { PageLayoutParameters } from '@app/hooks/tools/pageLayout/usePageLayoutParameters';
import LayoutPreview from '@app/components/tools/pageLayout/LayoutPreview';
import { Stack } from '@mantine/core';
export default function PageLayoutPreview({
parameters,
}: {
parameters: PageLayoutParameters;
}) {
return (
<Stack gap="sm">
<LayoutPreview parameters={parameters} />
</Stack>
);
}
@@ -1,8 +1,9 @@
import { Divider, Select, Stack, Switch } from '@mantine/core';
import { Select, Stack, NumberInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PageLayoutParameters } from '@app/hooks/tools/pageLayout/usePageLayoutParameters';
import { getPagesPerSheetOptions } from '@app/components/tools/pageLayout/constants';
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
import ButtonSelector from '@app/components/shared/ButtonSelector';
export default function PageLayoutSettings({
parameters,
@@ -18,44 +19,75 @@ export default function PageLayoutSettings({
}) {
const { t } = useTranslation();
const options = getPagesPerSheetOptions(t);
const selected = options.find((o) => o.value === parameters.pagesPerSheet) || options[0];
const pagesPerSheetOptions = getPagesPerSheetOptions(t);
const selectedPagesPerSheetOption = pagesPerSheetOptions.find((o) => o.value === parameters.pagesPerSheet) || pagesPerSheetOptions[0];
return (
<Stack gap="sm">
<Select
label={t('pageLayout.pagesPerSheet', 'Pages per sheet:')}
data={options.map(o => ({ value: String(o.value), label: o.label }))}
value={String(parameters.pagesPerSheet)}
onChange={(v) => onParameterChange('pagesPerSheet', Number(v))}
disabled={disabled}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_AUTOMATE_DROPDOWN }}
/>
{selected && (
<div
style={{
backgroundColor: 'var(--information-text-bg)',
color: 'var(--information-text-color)',
padding: '8px 12px',
borderRadius: '8px',
marginTop: '4px',
fontSize: '0.75rem',
textAlign: 'center',
}}
>
{selected.description}
</div>
)}
<Divider />
<Switch
checked={parameters.addBorder}
onChange={(e) => onParameterChange('addBorder', e.currentTarget.checked)}
label={t('pageLayout.addBorder', 'Add Borders')}
<ButtonSelector
label={t('pageLayout.mode.label', 'Mode:')}
options={[
{value: "DEFAULT", label: t('pageLayout.mode.default', 'Default')},
{value: "CUSTOM", label: t('pageLayout.mode.custom', 'Custom')}
]}
value={String(parameters.mode)}
onChange={(v) => {
if (v === "CUSTOM" || v === "DEFAULT") {
onParameterChange('mode', v)
}
}}
disabled={disabled}
/>
{parameters.mode === "DEFAULT" && <>
<Select
label={t('pageLayout.pagesPerSheet', 'Pages per sheet:')}
data={pagesPerSheetOptions.map(o => ({ value: String(o.value), label: o.label }))}
value={String(parameters.pagesPerSheet)}
onChange={(v) => onParameterChange('pagesPerSheet', Number(v))}
allowDeselect={false}
disabled={disabled}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_AUTOMATE_DROPDOWN }}
/>
{selectedPagesPerSheetOption && (
<div
style={{
backgroundColor: 'var(--information-text-bg)',
color: 'var(--information-text-color)',
padding: '8px 12px',
borderRadius: '8px',
marginTop: '4px',
fontSize: '0.75rem',
textAlign: 'center',
}}
>
{selectedPagesPerSheetOption.description}
</div>
)}
</>}
{parameters.mode === "CUSTOM" && <>
<NumberInput
label={t('pageLayout.rows', 'Rows')}
placeholder="Enter rows"
value={parameters.rows}
onChange={(v) => onParameterChange('rows', Number(v))}
min={1}
disabled={disabled}
style={{ flex: 1 }}
/>
<NumberInput
label={t('pageLayout.cols', 'Columns')}
placeholder="Enter columns"
value={parameters.cols}
onChange={(v) => onParameterChange('cols', Number(v))}
min={1}
disabled={disabled}
style={{ flex: 1 }}
/>
</>}
</Stack>
);
}
@@ -12,11 +12,6 @@ export const getPagesPerSheetOptions = (t: TFunction): PagesPerSheetOption[] =>
label: '2',
description: t('pageLayout.desc.2', 'Place 2 pages side-by-side on a single sheet.')
},
{
value: 3,
label: '3',
description: t('pageLayout.desc.3', 'Place 3 pages on a single sheet in a single row.')
},
{
value: 4,
label: '4',
@@ -0,0 +1,62 @@
import { PageLayoutParameters } from "@app/hooks/tools/pageLayout/usePageLayoutParameters";
export type Box = {
x: number;
y: number;
width: number;
height: number;
label: number;
}
export type Sheet = {
x: number;
y: number;
width: number;
height: number;
}
export function computeBoxes(sheet: Sheet, parameters: PageLayoutParameters): Box[] {
const { mode, arrangement, readingDirection } = parameters;
const cols = mode === "DEFAULT" ? Math.ceil(Math.sqrt(parameters.pagesPerSheet)) : parameters.cols;
const rows = mode === "DEFAULT" ? Math.ceil(parameters.pagesPerSheet / cols) : parameters.rows;
const pagesPerSheet = mode === "DEFAULT" ? parameters.pagesPerSheet : cols * rows;
const boxes: Box[] = [];
const cellWidth = sheet.width / cols;
const cellHeight = sheet.height / rows;
const margin = Math.min(cellWidth, cellHeight) * 0.1;
for (let i = 0; i < pagesPerSheet; i++) {
let rowIndex, colIndex;
if ( arrangement === "BY_ROWS" ) {
rowIndex = Math.floor(i / cols);
if ( readingDirection === "LTR" ) {
colIndex = i % cols;
} else {
colIndex = cols - 1 - (i % cols);
}
} else {
rowIndex = i % rows;
if ( readingDirection === "LTR" ) {
colIndex = Math.floor(i / rows);
} else {
colIndex = cols - 1 - Math.floor(i / rows);
}
}
boxes.push({
x: sheet.x + colIndex * cellWidth + margin,
y: sheet.y + rowIndex * cellHeight + margin,
width: cellWidth - margin * 2,
height: cellHeight - margin * 2,
label: i + 1
});
}
return boxes;
}
@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '@app/types/tips';
export const usePageLayoutAdvancedTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t('pageLayout.advanced.tooltip.header.title', 'Advanced Layout Options'),
},
tips: [
{
title: t('pageLayout.advanced.tooltip.orientation.title', 'Orientation'),
description: t(
'pageLayout.advanced.tooltip.orientation.text',
'Choose the final sheet direction. Portrait works better for tall content, while landscape fits wider layouts.'
),
},
{
title: t('pageLayout.advanced.tooltip.arrangement.title', 'Page Arrangement'),
description: t(
'pageLayout.advanced.tooltip.arrangement.text',
'Controls whether pages fill the grid row-by-row or column-by-column.'
),
bullets: [
t('pageLayout.advanced.tooltip.arrangement.bullet1', 'By Rows: Fill each row first.'),
t('pageLayout.advanced.tooltip.arrangement.bullet2', 'By Columns: Fill each column first.'),
],
},
{
title: t('pageLayout.advanced.tooltip.readingDirection.title', 'Reading Direction'),
description: t(
'pageLayout.advanced.tooltip.readingDirection.text',
'Sets horizontal ordering in the grid, useful for left-to-right and right-to-left document conventions.'
),
bullets: [
t('pageLayout.advanced.tooltip.readingDirection.bullet1', 'LTR: Left to right order.'),
t('pageLayout.advanced.tooltip.readingDirection.bullet2', 'RTL: Right to left order.'),
],
},
],
};
};
@@ -0,0 +1,42 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '@app/types/tips';
export const usePageLayoutMarginsBordersTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t('pageLayout.marginsBorders.tooltip.header.title', 'Margins and Borders'),
},
tips: [
{
title: t('pageLayout.marginsBorders.tooltip.margins.title', 'Margins'),
description: t(
'pageLayout.marginsBorders.tooltip.margins.text',
'Use top, bottom, left, and right margins to control spacing around the full sheet output.'
),
},
{
title: t('pageLayout.marginsBorders.tooltip.innerMargin.title', 'Inner Margin'),
description: t(
'pageLayout.marginsBorders.tooltip.innerMargin.text',
'Inner margin adds spacing between cells in the page grid to improve separation and readability.'
),
},
{
title: t('pageLayout.marginsBorders.tooltip.borders.title', 'Add Borders'),
description: t(
'pageLayout.marginsBorders.tooltip.borders.text',
'Enable borders to draw lines around each placed page. This can help visual separation or trimming.'
),
},
{
title: t('pageLayout.marginsBorders.tooltip.borderWidth.title', 'Border Thickness'),
description: t(
'pageLayout.marginsBorders.tooltip.borderWidth.text',
'Border thickness is only applied when borders are enabled. Higher values produce thicker lines.'
),
},
],
};
};
@@ -0,0 +1,34 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '@app/types/tips';
export const usePageLayoutTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("pageLayout.tooltip.header.title", "Page Layout Guide")
},
tips: [
{
title: t("pageLayout.tooltip.overview.title", "What is Page Layout?"),
description: t("pageLayout.tooltip.overview.text", "Fit multiple pages onto a single sheet for handouts or to save paper.")
},
{
title: t("pageLayout.tooltip.mode.title", "Mode"),
description: t("pageLayout.tooltip.mode.text", "Choose how the grid is configured:"),
bullets: [
t("pageLayout.tooltip.mode.bullet1", "Default: Pick a preset and the grid is calculated automatically."),
t("pageLayout.tooltip.mode.bullet2", "Custom: Set rows and columns manually.")
]
},
{
title: t("pageLayout.tooltip.pagesPerSheet.title", "Pages per Sheet (Default Mode)"),
description: t("pageLayout.tooltip.pagesPerSheet.text", "Choose how many pages per sheet (e.g. 4 → 2×2, 9 → 3×3).")
},
{
title: t("pageLayout.tooltip.rowsCols.title", "Rows & Columns (Custom Mode)"),
description: t("pageLayout.tooltip.rowsCols.text", "Set exact grid dimensions. Total pages per sheet = rows × columns.")
}
]
};
};
@@ -6,8 +6,20 @@ import { PageLayoutParameters, defaultParameters } from '@app/hooks/tools/pageLa
export const buildPageLayoutFormData = (parameters: PageLayoutParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
formData.append('mode', String(parameters.mode));
formData.append('pagesPerSheet', String(parameters.pagesPerSheet));
formData.append('rows', String(parameters.rows));
formData.append('cols', String(parameters.cols));
formData.append('orientation', String(parameters.orientation));
formData.append('arrangement', String(parameters.arrangement));
formData.append('readingDirection', String(parameters.readingDirection));
formData.append('innerMargin', String(parameters.innerMargin ?? 0));
formData.append('topMargin', String(parameters.topMargin ?? 0));
formData.append('bottomMargin', String(parameters.bottomMargin ?? 0));
formData.append('leftMargin', String(parameters.leftMargin ?? 0));
formData.append('rightMargin', String(parameters.rightMargin ?? 0));
formData.append('addBorder', String(parameters.addBorder));
formData.append('borderWidth', String(parameters.borderWidth ?? 1));
return formData;
};
@@ -2,13 +2,37 @@ import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface PageLayoutParameters extends BaseParameters {
mode: 'DEFAULT' | 'CUSTOM';
pagesPerSheet: number;
rows: number;
cols: number;
orientation: 'PORTRAIT' | 'LANDSCAPE';
arrangement: 'BY_COLUMNS' | 'BY_ROWS';
readingDirection: 'LTR' | 'RTL';
innerMargin?: number;
topMargin?: number;
bottomMargin?: number;
leftMargin?: number;
rightMargin?: number;
addBorder: boolean;
borderWidth?: number;
}
export const defaultParameters: PageLayoutParameters = {
mode: 'DEFAULT',
pagesPerSheet: 4,
rows: 1,
cols: 1,
orientation: 'PORTRAIT',
arrangement: 'BY_COLUMNS',
readingDirection: 'LTR',
addBorder: false,
innerMargin: 0,
topMargin: 0,
bottomMargin: 0,
leftMargin: 0,
rightMargin: 0,
borderWidth: 1,
};
export type PageLayoutParametersHook = BaseParametersHook<PageLayoutParameters>;
@@ -17,6 +41,36 @@ export const usePageLayoutParameters = (): PageLayoutParametersHook => {
return useBaseParameters<PageLayoutParameters>({
defaultParameters,
endpointName: 'multi-page-layout',
validateFn: (params) => {
const cols = params.mode === 'DEFAULT'
? Math.ceil(Math.sqrt(params.pagesPerSheet))
: params.cols;
const rows = params.mode === 'DEFAULT'
? Math.ceil(params.pagesPerSheet / cols)
: params.rows;
if (cols <= 0 || rows <= 0) return false;
const pageWidth = params.orientation === 'PORTRAIT' ? 595.28 : 841.89;
const pageHeight = params.orientation === 'PORTRAIT' ? 841.89 : 595.28;
const left = params.leftMargin ?? 0;
const right = params.rightMargin ?? 0;
const top = params.topMargin ?? 0;
const bottom = params.bottomMargin ?? 0;
const inner = params.innerMargin ?? 0;
// Reject impossible outer margins first.
if (left + right >= pageWidth) return false;
if (top + bottom >= pageHeight) return false;
const cellWidth = (pageWidth - left - right) / cols;
const cellHeight = (pageHeight - top - bottom) / rows;
const innerWidth = cellWidth - 2 * inner;
const innerHeight = cellHeight - 2 * inner;
return innerWidth > 0 && innerHeight > 0;
},
});
};
+61 -3
View File
@@ -1,10 +1,24 @@
import { useTranslation } from 'react-i18next';
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool';
import { useAccordionSteps } from '@app/hooks/tools/shared/useAccordionSteps';
import { BaseToolProps, ToolComponent } from '@app/types/tool';
import { usePageLayoutParameters } from '@app/hooks/tools/pageLayout/usePageLayoutParameters';
import { usePageLayoutOperation } from '@app/hooks/tools/pageLayout/usePageLayoutOperation';
import PageLayoutSettings from '@app/components/tools/pageLayout/PageLayoutSettings';
import PageLayoutAdvancedSettings from '@app/components/tools/pageLayout/PageLayoutAdvancedSettings';
import PageLayoutMarginsBordersSettings from '@app/components/tools/pageLayout/PageLayoutMarginsBordersSettings';
import { usePageLayoutTips } from '@app/components/tooltips/PageLayout/usePageLayoutTips';
import { usePageLayoutAdvancedTips } from '@app/components/tooltips/PageLayout/usePageLayoutAdvancedTips';
import { usePageLayoutMarginsBordersTips } from '@app/components/tooltips/PageLayout/usePageLayoutMarginsBordersTips';
import PageLayoutPreview from '@app/components/tools/pageLayout/PageLayoutPreview';
enum PageLayoutStep {
NONE = 'none',
LAYOUT = 'layout',
ADVANCED = 'advanced',
MARGINS_BORDERS = 'marginsBorders',
}
const PageLayout = (props: BaseToolProps) => {
const { t } = useTranslation();
@@ -16,16 +30,34 @@ const PageLayout = (props: BaseToolProps) => {
props
);
const pageLayoutTips = usePageLayoutTips();
const pageLayoutAdvancedTips = usePageLayoutAdvancedTips();
const pageLayoutMarginsBordersTips = usePageLayoutMarginsBordersTips();
const accordion = useAccordionSteps<PageLayoutStep>({
noneValue: PageLayoutStep.NONE,
initialStep: PageLayoutStep.LAYOUT,
stateConditions: {
hasFiles: base.hasFiles,
hasResults: base.hasResults,
},
afterResults: base.handleSettingsReset,
});
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
preview: (
<PageLayoutPreview parameters={base.params.parameters} />
),
steps: [
{
title: 'Settings',
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
title: 'Layout settings',
isCollapsed: accordion.getCollapsedState(PageLayoutStep.LAYOUT),
onCollapsedClick: () => accordion.handleStepToggle(PageLayoutStep.LAYOUT),
tooltip: pageLayoutTips,
content: (
<PageLayoutSettings
parameters={base.params.parameters}
@@ -34,6 +66,32 @@ const PageLayout = (props: BaseToolProps) => {
/>
),
},
{
title: 'Advanced settings',
isCollapsed: accordion.getCollapsedState(PageLayoutStep.ADVANCED),
onCollapsedClick: () => accordion.handleStepToggle(PageLayoutStep.ADVANCED),
tooltip: pageLayoutAdvancedTips,
content: (
<PageLayoutAdvancedSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
{
title: 'Margins and borders',
isCollapsed: accordion.getCollapsedState(PageLayoutStep.MARGINS_BORDERS),
onCollapsedClick: () => accordion.handleStepToggle(PageLayoutStep.MARGINS_BORDERS),
tooltip: pageLayoutMarginsBordersTips,
content: (
<PageLayoutMarginsBordersSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
executeButton: {
text: t('pageLayout.submit', 'Create Layout'),