mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
+207
-32
@@ -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 {
|
||||
|
||||
+99
-2
@@ -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;
|
||||
}
|
||||
|
||||
+3
-1
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user