mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-22 19:46:39 +00:00
booklet
This commit is contained in:
parent
7d3c5faacd
commit
9d5674f0ca
@ -49,28 +49,37 @@ public class BookletImpositionController {
|
||||
@ModelAttribute BookletImpositionRequest request) throws IOException {
|
||||
|
||||
MultipartFile file = request.getFileInput();
|
||||
String bookletType = request.getBookletType();
|
||||
int pagesPerSheet = request.getPagesPerSheet();
|
||||
boolean addBorder = Boolean.TRUE.equals(request.getAddBorder());
|
||||
String pageOrientation = request.getPageOrientation();
|
||||
String spineLocation =
|
||||
request.getSpineLocation() != null ? request.getSpineLocation() : "LEFT";
|
||||
boolean addGutter = Boolean.TRUE.equals(request.getAddGutter());
|
||||
float gutterSize = request.getGutterSize();
|
||||
boolean doubleSided = Boolean.TRUE.equals(request.getDoubleSided());
|
||||
String duplexPass = request.getDuplexPass() != null ? request.getDuplexPass() : "BOTH";
|
||||
boolean flipOnShortEdge = Boolean.TRUE.equals(request.getFlipOnShortEdge());
|
||||
|
||||
// Validate pages per sheet for booklet
|
||||
if (pagesPerSheet != 2 && pagesPerSheet != 4) {
|
||||
// Validate pages per sheet for booklet - only 2-up landscape is proper booklet
|
||||
if (pagesPerSheet != 2) {
|
||||
throw new IllegalArgumentException(
|
||||
"pagesPerSheet must be 2 or 4 for booklet imposition");
|
||||
"Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up feature.");
|
||||
}
|
||||
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
|
||||
// Step 1: Reorder pages for booklet (reusing logic from RearrangePagesPDFController)
|
||||
List<Integer> bookletOrder = getBookletPageOrder(bookletType, totalPages);
|
||||
|
||||
// Step 2: Create new document with multi-page layout (reusing logic from
|
||||
// MultiPageLayoutController)
|
||||
// Create proper booklet with signature-based page ordering
|
||||
PDDocument newDocument =
|
||||
createBookletWithLayout(
|
||||
sourceDocument, bookletOrder, pagesPerSheet, addBorder, pageOrientation);
|
||||
createSaddleBooklet(
|
||||
sourceDocument,
|
||||
totalPages,
|
||||
addBorder,
|
||||
spineLocation,
|
||||
addGutter,
|
||||
gutterSize,
|
||||
doubleSided,
|
||||
duplexPass,
|
||||
flipOnShortEdge);
|
||||
|
||||
sourceDocument.close();
|
||||
|
||||
@ -85,124 +94,231 @@ public class BookletImpositionController {
|
||||
+ "_booklet.pdf");
|
||||
}
|
||||
|
||||
// Reused logic from RearrangePagesPDFController
|
||||
private List<Integer> getBookletPageOrder(String bookletType, int totalPages) {
|
||||
if ("SIDE_STITCH_BOOKLET".equals(bookletType)) {
|
||||
return sideStitchBookletSort(totalPages);
|
||||
private static int padToMultipleOf4(int n) {
|
||||
return (n + 3) / 4 * 4;
|
||||
}
|
||||
|
||||
private static class Side {
|
||||
final int left, right;
|
||||
final boolean isBack;
|
||||
|
||||
Side(int left, int right, boolean isBack) {
|
||||
this.left = left;
|
||||
this.right = right;
|
||||
this.isBack = isBack;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Side> saddleStitchSides(
|
||||
int totalPagesOriginal,
|
||||
boolean doubleSided,
|
||||
String duplexPass,
|
||||
boolean flipOnShortEdge) {
|
||||
int N = padToMultipleOf4(totalPagesOriginal);
|
||||
List<Side> out = new ArrayList<>();
|
||||
int sheets = N / 4;
|
||||
|
||||
for (int s = 0; s < sheets; s++) {
|
||||
int a = N - 1 - (s * 2); // left, front
|
||||
int b = (s * 2); // right, front
|
||||
int c = (s * 2) + 1; // left, back
|
||||
int d = N - 2 - (s * 2); // right, back
|
||||
|
||||
// clamp to -1 (blank) if >= totalPagesOriginal
|
||||
a = (a < totalPagesOriginal) ? a : -1;
|
||||
b = (b < totalPagesOriginal) ? b : -1;
|
||||
c = (c < totalPagesOriginal) ? c : -1;
|
||||
d = (d < totalPagesOriginal) ? d : -1;
|
||||
|
||||
// Handle duplex pass selection
|
||||
boolean includeFront = "BOTH".equals(duplexPass) || "FIRST".equals(duplexPass);
|
||||
boolean includeBack = "BOTH".equals(duplexPass) || "SECOND".equals(duplexPass);
|
||||
|
||||
if (includeFront) {
|
||||
out.add(new Side(a, b, false)); // front side
|
||||
}
|
||||
|
||||
if (includeBack) {
|
||||
// For short-edge duplex, swap back-side left/right
|
||||
// Note: flipOnShortEdge is ignored in manual duplex mode since users physically
|
||||
// flip the stack
|
||||
if (doubleSided && flipOnShortEdge) {
|
||||
out.add(new Side(d, c, true)); // swapped back side (automatic duplex only)
|
||||
} else {
|
||||
return bookletSort(totalPages);
|
||||
out.add(new Side(c, d, true)); // normal back side
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<Integer> bookletSort(int totalPages) {
|
||||
List<Integer> newPageOrder = new ArrayList<>();
|
||||
for (int i = 0; i < totalPages / 2; i++) {
|
||||
newPageOrder.add(i);
|
||||
newPageOrder.add(totalPages - i - 1);
|
||||
}
|
||||
return newPageOrder;
|
||||
}
|
||||
|
||||
private List<Integer> sideStitchBookletSort(int totalPages) {
|
||||
List<Integer> newPageOrder = new ArrayList<>();
|
||||
for (int i = 0; i < (totalPages + 3) / 4; i++) {
|
||||
int begin = i * 4;
|
||||
newPageOrder.add(Math.min(begin + 3, totalPages - 1));
|
||||
newPageOrder.add(Math.min(begin, totalPages - 1));
|
||||
newPageOrder.add(Math.min(begin + 1, totalPages - 1));
|
||||
newPageOrder.add(Math.min(begin + 2, totalPages - 1));
|
||||
}
|
||||
return newPageOrder;
|
||||
}
|
||||
|
||||
// Reused and adapted logic from MultiPageLayoutController
|
||||
private PDDocument createBookletWithLayout(
|
||||
PDDocument sourceDocument,
|
||||
List<Integer> pageOrder,
|
||||
int pagesPerSheet,
|
||||
private PDDocument createSaddleBooklet(
|
||||
PDDocument src,
|
||||
int totalPages,
|
||||
boolean addBorder,
|
||||
String pageOrientation)
|
||||
String spineLocation,
|
||||
boolean addGutter,
|
||||
float gutterSize,
|
||||
boolean doubleSided,
|
||||
String duplexPass,
|
||||
boolean flipOnShortEdge)
|
||||
throws IOException {
|
||||
|
||||
PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
PDDocument dst = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(src);
|
||||
|
||||
int cols = pagesPerSheet == 2 ? 2 : 2; // 2x1 for 2 pages, 2x2 for 4 pages
|
||||
int rows = pagesPerSheet == 2 ? 1 : 2;
|
||||
// Derive paper size from source document's first page CropBox
|
||||
PDRectangle srcBox = src.getPage(0).getCropBox();
|
||||
PDRectangle portraitPaper = new PDRectangle(srcBox.getWidth(), srcBox.getHeight());
|
||||
// Force landscape for booklet (Acrobat booklet uses landscape paper to fold to portrait)
|
||||
PDRectangle pageSize = new PDRectangle(portraitPaper.getHeight(), portraitPaper.getWidth());
|
||||
|
||||
int currentPageIndex = 0;
|
||||
int totalOrderedPages = pageOrder.size();
|
||||
// Validate and clamp gutter size
|
||||
if (gutterSize < 0) gutterSize = 0;
|
||||
if (gutterSize >= pageSize.getWidth() / 2f) gutterSize = pageSize.getWidth() / 2f - 1f;
|
||||
|
||||
while (currentPageIndex < totalOrderedPages) {
|
||||
// Use landscape orientation for booklets (A4 landscape -> A5 portrait when folded)
|
||||
PDRectangle pageSize =
|
||||
"LANDSCAPE".equals(pageOrientation)
|
||||
? new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth())
|
||||
: PDRectangle.A4;
|
||||
PDPage newPage = new PDPage(pageSize);
|
||||
newDocument.addPage(newPage);
|
||||
List<Side> sides = saddleStitchSides(totalPages, doubleSided, duplexPass, flipOnShortEdge);
|
||||
|
||||
float cellWidth = newPage.getMediaBox().getWidth() / cols;
|
||||
float cellHeight = newPage.getMediaBox().getHeight() / rows;
|
||||
for (Side side : sides) {
|
||||
PDPage out = new PDPage(pageSize);
|
||||
dst.addPage(out);
|
||||
|
||||
PDPageContentStream contentStream =
|
||||
float cellW = pageSize.getWidth() / 2f;
|
||||
float cellH = pageSize.getHeight();
|
||||
|
||||
// For RIGHT spine (RTL), swap left/right placements
|
||||
boolean rtl = "RIGHT".equalsIgnoreCase(spineLocation);
|
||||
int leftCol = rtl ? 1 : 0;
|
||||
int rightCol = rtl ? 0 : 1;
|
||||
|
||||
// Apply gutter margins with centered gap option
|
||||
float g = addGutter ? gutterSize : 0f;
|
||||
float leftCellX = leftCol * cellW + (g / 2f);
|
||||
float rightCellX = rightCol * cellW - (g / 2f);
|
||||
float leftCellW = cellW - (g / 2f);
|
||||
float rightCellW = cellW - (g / 2f);
|
||||
|
||||
// Create LayerUtility once per page for efficiency
|
||||
LayerUtility layerUtility = new LayerUtility(dst);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(
|
||||
newDocument,
|
||||
newPage,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true);
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
dst, out, PDPageContentStream.AppendMode.APPEND, true, true)) {
|
||||
|
||||
if (addBorder) {
|
||||
contentStream.setLineWidth(1.5f);
|
||||
contentStream.setStrokingColor(Color.BLACK);
|
||||
cs.setLineWidth(1.5f);
|
||||
cs.setStrokingColor(Color.BLACK);
|
||||
}
|
||||
|
||||
// Place pages on the current sheet
|
||||
for (int sheetPosition = 0;
|
||||
sheetPosition < pagesPerSheet && currentPageIndex < totalOrderedPages;
|
||||
sheetPosition++) {
|
||||
int sourcePageIndex = pageOrder.get(currentPageIndex);
|
||||
PDPage sourcePage = sourceDocument.getPage(sourcePageIndex);
|
||||
PDRectangle rect = sourcePage.getMediaBox();
|
||||
// draw left cell
|
||||
drawCell(
|
||||
src,
|
||||
dst,
|
||||
cs,
|
||||
layerUtility,
|
||||
side.left,
|
||||
leftCellX,
|
||||
0f,
|
||||
leftCellW,
|
||||
cellH,
|
||||
addBorder);
|
||||
// draw right cell
|
||||
drawCell(
|
||||
src,
|
||||
dst,
|
||||
cs,
|
||||
layerUtility,
|
||||
side.right,
|
||||
rightCellX,
|
||||
0f,
|
||||
rightCellW,
|
||||
cellH,
|
||||
addBorder);
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
float scaleWidth = cellWidth / rect.getWidth();
|
||||
float scaleHeight = cellHeight / rect.getHeight();
|
||||
float scale = Math.min(scaleWidth, scaleHeight);
|
||||
|
||||
int rowIndex = sheetPosition / cols;
|
||||
int colIndex = sheetPosition % cols;
|
||||
|
||||
float x = colIndex * cellWidth + (cellWidth - rect.getWidth() * scale) / 2;
|
||||
float y =
|
||||
newPage.getMediaBox().getHeight()
|
||||
- ((rowIndex + 1) * cellHeight
|
||||
- (cellHeight - rect.getHeight() * scale) / 2);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(x, y));
|
||||
contentStream.transform(Matrix.getScaleInstance(scale, scale));
|
||||
|
||||
PDFormXObject formXObject =
|
||||
layerUtility.importPageAsForm(sourceDocument, sourcePageIndex);
|
||||
contentStream.drawForm(formXObject);
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
private void drawCell(
|
||||
PDDocument src,
|
||||
PDDocument dst,
|
||||
PDPageContentStream cs,
|
||||
LayerUtility layerUtility,
|
||||
int pageIndex,
|
||||
float cellX,
|
||||
float cellY,
|
||||
float cellW,
|
||||
float cellH,
|
||||
boolean addBorder)
|
||||
throws IOException {
|
||||
|
||||
if (pageIndex < 0) {
|
||||
// Draw border for blank cell if needed
|
||||
if (addBorder) {
|
||||
float borderX = colIndex * cellWidth;
|
||||
float borderY = newPage.getMediaBox().getHeight() - (rowIndex + 1) * cellHeight;
|
||||
contentStream.addRect(borderX, borderY, cellWidth, cellHeight);
|
||||
contentStream.stroke();
|
||||
cs.addRect(cellX, cellY, cellW, cellH);
|
||||
cs.stroke();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
currentPageIndex++;
|
||||
PDPage srcPage = src.getPage(pageIndex);
|
||||
PDRectangle r = srcPage.getCropBox(); // Use CropBox instead of MediaBox
|
||||
int rot = (srcPage.getRotation() + 360) % 360;
|
||||
|
||||
// Calculate scale factors, accounting for rotation
|
||||
float sx = cellW / r.getWidth();
|
||||
float sy = cellH / r.getHeight();
|
||||
float s = Math.min(sx, sy);
|
||||
|
||||
// If rotated 90/270 degrees, swap dimensions for fitting
|
||||
if (rot == 90 || rot == 270) {
|
||||
sx = cellW / r.getHeight();
|
||||
sy = cellH / r.getWidth();
|
||||
s = Math.min(sx, sy);
|
||||
}
|
||||
|
||||
contentStream.close();
|
||||
float drawnW = (rot == 90 || rot == 270) ? r.getHeight() * s : r.getWidth() * s;
|
||||
float drawnH = (rot == 90 || rot == 270) ? r.getWidth() * s : r.getHeight() * s;
|
||||
|
||||
// Center in cell, accounting for CropBox offset
|
||||
float tx = cellX + (cellW - drawnW) / 2f - r.getLowerLeftX() * s;
|
||||
float ty = cellY + (cellH - drawnH) / 2f - r.getLowerLeftY() * s;
|
||||
|
||||
cs.saveGraphicsState();
|
||||
cs.transform(Matrix.getTranslateInstance(tx, ty));
|
||||
cs.transform(Matrix.getScaleInstance(s, s));
|
||||
|
||||
// Apply rotation if needed (rotate about origin), then translate to keep in cell
|
||||
switch (rot) {
|
||||
case 90:
|
||||
cs.transform(Matrix.getRotateInstance(Math.PI / 2, 0, 0));
|
||||
// After 90° CCW, the content spans x in [-r.getHeight(), 0] and y in [0,
|
||||
// r.getWidth()]
|
||||
cs.transform(Matrix.getTranslateInstance(0, -r.getWidth()));
|
||||
break;
|
||||
case 180:
|
||||
cs.transform(Matrix.getRotateInstance(Math.PI, 0, 0));
|
||||
cs.transform(Matrix.getTranslateInstance(-r.getWidth(), -r.getHeight()));
|
||||
break;
|
||||
case 270:
|
||||
cs.transform(Matrix.getRotateInstance(3 * Math.PI / 2, 0, 0));
|
||||
// After 270° CCW, the content spans x in [0, r.getHeight()] and y in
|
||||
// [-r.getWidth(), 0]
|
||||
cs.transform(Matrix.getTranslateInstance(-r.getHeight(), 0));
|
||||
break;
|
||||
default:
|
||||
// 0°: no-op
|
||||
}
|
||||
|
||||
return newDocument;
|
||||
// Reuse LayerUtility passed from caller
|
||||
PDFormXObject form = layerUtility.importPageAsForm(src, pageIndex);
|
||||
cs.drawForm(form);
|
||||
|
||||
cs.restoreGraphicsState();
|
||||
|
||||
// Draw border on top of form to ensure visibility
|
||||
if (addBorder) {
|
||||
cs.addRect(cellX, cellY, cellW, cellH);
|
||||
cs.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -12,29 +12,43 @@ import stirling.software.common.model.api.PDFFile;
|
||||
public class BookletImpositionRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description = "The booklet type to create.",
|
||||
type = "string",
|
||||
defaultValue = "BOOKLET",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
allowableValues = {"BOOKLET", "SIDE_STITCH_BOOKLET"})
|
||||
private String bookletType = "BOOKLET";
|
||||
|
||||
@Schema(
|
||||
description = "The number of pages to fit onto a single sheet in the output PDF.",
|
||||
description =
|
||||
"The number of pages per side for booklet printing (always 2 for proper booklet).",
|
||||
type = "number",
|
||||
defaultValue = "2",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
allowableValues = {"2", "4"})
|
||||
allowableValues = {"2"})
|
||||
private int pagesPerSheet = 2;
|
||||
|
||||
@Schema(description = "Boolean for if you wish to add border around the pages")
|
||||
private Boolean addBorder = false;
|
||||
|
||||
@Schema(
|
||||
description = "The page orientation for the output booklet sheets.",
|
||||
description = "The spine location for the booklet.",
|
||||
type = "string",
|
||||
defaultValue = "LANDSCAPE",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
allowableValues = {"LANDSCAPE", "PORTRAIT"})
|
||||
private String pageOrientation = "LANDSCAPE";
|
||||
defaultValue = "LEFT",
|
||||
allowableValues = {"LEFT", "RIGHT"})
|
||||
private String spineLocation = "LEFT";
|
||||
|
||||
@Schema(description = "Add gutter margin (inner margin for binding)")
|
||||
private Boolean addGutter = false;
|
||||
|
||||
@Schema(
|
||||
description = "Gutter margin size in points (used when addGutter is true)",
|
||||
type = "number",
|
||||
defaultValue = "12")
|
||||
private float gutterSize = 12f;
|
||||
|
||||
@Schema(description = "Generate both front and back sides (double-sided printing)")
|
||||
private Boolean doubleSided = true;
|
||||
|
||||
@Schema(
|
||||
description = "For manual duplex: which pass to generate",
|
||||
type = "string",
|
||||
defaultValue = "BOTH",
|
||||
allowableValues = {"BOTH", "FIRST", "SECOND"})
|
||||
private String duplexPass = "BOTH";
|
||||
|
||||
@Schema(description = "Flip back sides for short-edge duplex printing (default is long-edge)")
|
||||
private Boolean flipOnShortEdge = false;
|
||||
}
|
||||
|
@ -1936,59 +1936,90 @@
|
||||
"title": "Booklet Imposition",
|
||||
"header": "Booklet Imposition",
|
||||
"submit": "Create Booklet",
|
||||
"bookletType": {
|
||||
"label": "Style",
|
||||
"standard": "Standard",
|
||||
"sideStitch": "Side Stitch",
|
||||
"standardDesc": "Standard for saddle-stitched binding (fold in half)",
|
||||
"sideStitchDesc": "Side stitch for binding along the edge"
|
||||
"spineLocation": {
|
||||
"label": "Spine Location",
|
||||
"left": "Left (Standard)",
|
||||
"right": "Right (RTL)"
|
||||
},
|
||||
"pagesPerSheet": {
|
||||
"label": "Pages Per Sheet",
|
||||
"two": "2 Pages",
|
||||
"four": "4 Pages",
|
||||
"twoDesc": "Two pages side by side on each sheet (most common)",
|
||||
"fourDesc": "Four pages arranged in a 2x2 grid on each sheet"
|
||||
"doubleSided": {
|
||||
"label": "Double-sided printing",
|
||||
"tooltip": "Creates both front and back sides for proper booklet printing"
|
||||
},
|
||||
"pageOrientation": {
|
||||
"label": "Page Orientation",
|
||||
"landscape": "Landscape",
|
||||
"portrait": "Portrait",
|
||||
"landscapeDesc": "A4 landscape → A5 portrait when folded (recommended, standard booklet size)",
|
||||
"portraitDesc": "A4 portrait → A6 when folded (smaller booklet)"
|
||||
"manualDuplex": {
|
||||
"title": "Manual Duplex Mode",
|
||||
"instructions": "For printers without automatic duplex. You'll need to run this twice:"
|
||||
},
|
||||
"duplexPass": {
|
||||
"label": "Print Pass",
|
||||
"first": "1st Pass",
|
||||
"second": "2nd Pass",
|
||||
"firstInstructions": "Prints front sides → stack face-down → run again with 2nd Pass",
|
||||
"secondInstructions": "Load printed stack face-down → prints back sides"
|
||||
},
|
||||
"rtlBinding": {
|
||||
"label": "Right-to-left binding",
|
||||
"tooltip": "For Arabic, Hebrew, or other right-to-left languages"
|
||||
},
|
||||
"addBorder": {
|
||||
"label": "Add borders around pages",
|
||||
"tooltip": "Adds borders around each page section to help with cutting and alignment",
|
||||
"description": "Helpful for cutting and alignment when printing"
|
||||
"tooltip": "Adds borders around each page section to help with cutting and alignment"
|
||||
},
|
||||
"addGutter": {
|
||||
"label": "Add gutter margin",
|
||||
"tooltip": "Adds inner margin space for binding"
|
||||
},
|
||||
"gutterSize": {
|
||||
"label": "Gutter size (points)"
|
||||
},
|
||||
"flipOnShortEdge": {
|
||||
"label": "Flip on short edge (automatic duplex only)",
|
||||
"tooltip": "Enable for short-edge duplex printing (automatic duplex only - ignored in manual mode)",
|
||||
"manualNote": "Not needed in manual mode - you flip the stack yourself"
|
||||
},
|
||||
"advanced": {
|
||||
"toggle": "Advanced Options"
|
||||
},
|
||||
"paperSizeNote": "Paper size is automatically derived from your first page.",
|
||||
"tooltip": {
|
||||
"title": "Booklet Imposition Guide",
|
||||
"overview": {
|
||||
"header": {
|
||||
"title": "Booklet Creation Guide"
|
||||
},
|
||||
"description": {
|
||||
"title": "What is Booklet Imposition?",
|
||||
"description": "Arranges PDF pages in the correct order for booklet printing. Pages are reordered so that when printed and folded, they appear in sequence.",
|
||||
"bullet1": "Creates printable booklets from regular PDFs",
|
||||
"bullet2": "Handles page ordering for folding",
|
||||
"bullet3": "Supports saddle-stitch and side-stitch binding"
|
||||
"text": "Creates professional booklets by arranging pages in the correct printing order. Your PDF pages are placed 2-up on landscape sheets so when folded and bound, they read in proper sequence like a real book."
|
||||
},
|
||||
"bookletTypes": {
|
||||
"title": "Booklet Types",
|
||||
"standard": "Standard: Saddle-stitched binding (staples along fold)",
|
||||
"sideStitch": "Side-Stitch: Binding along edge (spiral, ring, perfect)"
|
||||
"example": {
|
||||
"title": "Example: 8-Page Booklet",
|
||||
"text": "Your 8-page document becomes 2 sheets:",
|
||||
"bullet1": "Sheet 1 Front: Pages 8, 1 | Back: Pages 2, 7",
|
||||
"bullet2": "Sheet 2 Front: Pages 6, 3 | Back: Pages 4, 5",
|
||||
"bullet3": "When folded & stacked: Reads 1→2→3→4→5→6→7→8"
|
||||
},
|
||||
"pagesPerSheet": {
|
||||
"title": "Pages Per Sheet",
|
||||
"two": "2 Pages: Standard layout (most common)",
|
||||
"four": "4 Pages: Compact layout"
|
||||
"printing": {
|
||||
"title": "How to Print & Assemble",
|
||||
"text": "Follow these steps for perfect booklets:",
|
||||
"bullet1": "Print double-sided with 'Flip on long edge'",
|
||||
"bullet2": "Stack sheets in order, fold in half",
|
||||
"bullet3": "Staple or bind along the folded spine",
|
||||
"bullet4": "For short-edge printers: Enable 'Flip on short edge' option"
|
||||
},
|
||||
"orientation": {
|
||||
"title": "Page Orientation",
|
||||
"landscape": "Landscape: A4 → A5 booklet (recommended)",
|
||||
"portrait": "Portrait: A4 → A6 booklet (compact)"
|
||||
"manualDuplex": {
|
||||
"title": "Manual Duplex (Single-sided Printers)",
|
||||
"text": "For printers without automatic duplex:",
|
||||
"bullet1": "Turn OFF 'Double-sided printing'",
|
||||
"bullet2": "Select '1st Pass' → Print → Stack face-down",
|
||||
"bullet3": "Select '2nd Pass' → Load stack → Print backs",
|
||||
"bullet4": "Fold and assemble as normal"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced Options",
|
||||
"text": "Fine-tune your booklet:",
|
||||
"bullet1": "Right-to-Left Binding: For Arabic, Hebrew, or RTL languages",
|
||||
"bullet2": "Borders: Shows cut lines for trimming",
|
||||
"bullet3": "Gutter Margin: Adds space for binding/stapling",
|
||||
"bullet4": "Short-edge Flip: Only for automatic duplex printers"
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while creating the booklet imposition."
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Stack, Text, Divider } from "@mantine/core";
|
||||
import { Stack, Text, Divider, Collapse, Button, NumberInput } from "@mantine/core";
|
||||
import { BookletImpositionParameters } from "../../../hooks/tools/bookletImposition/useBookletImpositionParameters";
|
||||
import ButtonSelector from "../../shared/ButtonSelector";
|
||||
|
||||
@ -12,61 +12,98 @@ interface BookletImpositionSettingsProps {
|
||||
|
||||
const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = false }: BookletImpositionSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Divider ml='-md'></Divider>
|
||||
|
||||
{/* Booklet Type */}
|
||||
|
||||
{/* Double Sided */}
|
||||
<Stack gap="sm">
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
|
||||
title={t('bookletImposition.doubleSided.tooltip', 'Creates both front and back sides for proper booklet printing')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.doubleSided}
|
||||
onChange={(e) => {
|
||||
const isDoubleSided = e.target.checked;
|
||||
onParameterChange('doubleSided', isDoubleSided);
|
||||
// Reset to BOTH when turning double-sided back on
|
||||
if (isDoubleSided) {
|
||||
onParameterChange('duplexPass', 'BOTH');
|
||||
} else {
|
||||
// Default to FIRST pass when going to manual duplex
|
||||
onParameterChange('duplexPass', 'FIRST');
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text size="sm">{t('bookletImposition.doubleSided.label', 'Double-sided printing')}</Text>
|
||||
</label>
|
||||
|
||||
{/* Manual Duplex Pass Selection - only show when double-sided is OFF */}
|
||||
{!parameters.doubleSided && (
|
||||
<Stack gap="xs" ml="lg">
|
||||
<Text size="sm" fw={500} c="orange">
|
||||
{t('bookletImposition.manualDuplex.title', 'Manual Duplex Mode')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('bookletImposition.manualDuplex.instructions', 'For printers without automatic duplex. You\'ll need to run this twice:')}
|
||||
</Text>
|
||||
|
||||
<ButtonSelector
|
||||
label={t('bookletImposition.bookletType.label', 'Booklet Type')}
|
||||
value={parameters.bookletType}
|
||||
onChange={(value) => onParameterChange('bookletType', value)}
|
||||
label={t('bookletImposition.duplexPass.label', 'Print Pass')}
|
||||
value={parameters.duplexPass}
|
||||
onChange={(value) => onParameterChange('duplexPass', value)}
|
||||
options={[
|
||||
{ value: 'BOOKLET', label: t('bookletImposition.bookletType.standard', 'Standard Booklet') },
|
||||
{ value: 'SIDE_STITCH_BOOKLET', label: t('bookletImposition.bookletType.sideStitch', 'Side-Stitch Booklet') }
|
||||
{ value: 'FIRST', label: t('bookletImposition.duplexPass.first', '1st Pass') },
|
||||
{ value: 'SECOND', label: t('bookletImposition.duplexPass.second', '2nd Pass') }
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Text size="xs" c="blue" fs="italic">
|
||||
{parameters.duplexPass === 'FIRST'
|
||||
? t('bookletImposition.duplexPass.firstInstructions', 'Prints front sides → stack face-down → run again with 2nd Pass')
|
||||
: t('bookletImposition.duplexPass.secondInstructions', 'Load printed stack face-down → prints back sides')
|
||||
}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Pages Per Sheet */}
|
||||
{/* Advanced Options */}
|
||||
<Stack gap="sm">
|
||||
<ButtonSelector
|
||||
label={t('bookletImposition.pagesPerSheet.label', 'Pages Per Sheet')}
|
||||
value={parameters.pagesPerSheet}
|
||||
onChange={(value) => onParameterChange('pagesPerSheet', value)}
|
||||
options={[
|
||||
{ value: 2, label: t('bookletImposition.pagesPerSheet.two', '2 Pages') },
|
||||
{ value: 4, label: t('bookletImposition.pagesPerSheet.four', '4 Pages') }
|
||||
]}
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setAdvancedOpen(!advancedOpen)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('bookletImposition.advanced.toggle', 'Advanced Options')} {advancedOpen ? '▲' : '▼'}
|
||||
</Button>
|
||||
|
||||
<Collapse in={advancedOpen}>
|
||||
<Stack gap="md" mt="md">
|
||||
{/* Right-to-Left Binding */}
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
|
||||
title={t('bookletImposition.rtlBinding.tooltip', 'For Arabic, Hebrew, or other right-to-left languages')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.spineLocation === 'RIGHT'}
|
||||
onChange={(e) => onParameterChange('spineLocation', e.target.checked ? 'RIGHT' : 'LEFT')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Page Orientation */}
|
||||
<Stack gap="sm">
|
||||
<ButtonSelector
|
||||
label={t('bookletImposition.pageOrientation.label', 'Page Orientation')}
|
||||
value={parameters.pageOrientation}
|
||||
onChange={(value) => onParameterChange('pageOrientation', value)}
|
||||
options={[
|
||||
{ value: 'LANDSCAPE', label: t('bookletImposition.pageOrientation.landscape', 'Landscape') },
|
||||
{ value: 'PORTRAIT', label: t('bookletImposition.pageOrientation.portrait', 'Portrait') }
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
<Text size="sm">{t('bookletImposition.rtlBinding.label', 'Right-to-left binding')}</Text>
|
||||
</label>
|
||||
|
||||
{/* Add Border Option */}
|
||||
<Stack gap="sm">
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
|
||||
title={t('bookletImposition.addBorder.tooltip', 'Adds borders around each page section to help with cutting and alignment')}
|
||||
@ -79,6 +116,61 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f
|
||||
/>
|
||||
<Text size="sm">{t('bookletImposition.addBorder.label', 'Add borders around pages')}</Text>
|
||||
</label>
|
||||
|
||||
{/* Gutter Margin */}
|
||||
<Stack gap="xs">
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
|
||||
title={t('bookletImposition.addGutter.tooltip', 'Adds inner margin space for binding')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.addGutter}
|
||||
onChange={(e) => onParameterChange('addGutter', e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text size="sm">{t('bookletImposition.addGutter.label', 'Add gutter margin')}</Text>
|
||||
</label>
|
||||
|
||||
{parameters.addGutter && (
|
||||
<NumberInput
|
||||
label={t('bookletImposition.gutterSize.label', 'Gutter size (points)')}
|
||||
value={parameters.gutterSize}
|
||||
onChange={(value) => onParameterChange('gutterSize', value || 12)}
|
||||
min={6}
|
||||
max={72}
|
||||
step={6}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Flip on Short Edge */}
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
|
||||
title={!parameters.doubleSided
|
||||
? t('bookletImposition.flipOnShortEdge.manualNote', 'Not needed in manual mode - you flip the stack yourself')
|
||||
: t('bookletImposition.flipOnShortEdge.tooltip', 'Enable for short-edge duplex printing (automatic duplex only - ignored in manual mode)')
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.flipOnShortEdge}
|
||||
onChange={(e) => onParameterChange('flipOnShortEdge', e.target.checked)}
|
||||
disabled={disabled || !parameters.doubleSided}
|
||||
/>
|
||||
<Text size="sm" c={!parameters.doubleSided ? "dimmed" : undefined}>
|
||||
{t('bookletImposition.flipOnShortEdge.label', 'Flip on short edge')}
|
||||
</Text>
|
||||
</label>
|
||||
|
||||
{/* Paper Size Note */}
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
{t('bookletImposition.paperSizeNote', 'Paper size is automatically derived from your first page.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
@ -6,37 +6,50 @@ export const useBookletImpositionTips = (): TooltipContent => {
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("bookletImposition.tooltip.title", "Booklet Imposition Guide")
|
||||
title: t("bookletImposition.tooltip.header.title", "Booklet Creation Guide")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("bookletImposition.tooltip.overview.title", "What is Booklet Imposition?"),
|
||||
description: t("bookletImposition.tooltip.overview.description", "Arranges PDF pages in the correct order for booklet printing. Pages are reordered so that when printed and folded, they appear in sequence."),
|
||||
title: t("bookletImposition.tooltip.description.title", "What is Booklet Imposition?"),
|
||||
description: t("bookletImposition.tooltip.description.text", "Creates professional booklets by arranging pages in the correct printing order. Your PDF pages are placed 2-up on landscape sheets so when folded and bound, they read in proper sequence like a real book.")
|
||||
},
|
||||
{
|
||||
title: t("bookletImposition.tooltip.example.title", "Example: 8-Page Booklet"),
|
||||
description: t("bookletImposition.tooltip.example.text", "Your 8-page document becomes 2 sheets:"),
|
||||
bullets: [
|
||||
t("bookletImposition.tooltip.overview.bullet1", "Creates printable booklets from regular PDFs"),
|
||||
t("bookletImposition.tooltip.overview.bullet2", "Handles page ordering for folding"),
|
||||
t("bookletImposition.tooltip.overview.bullet3", "Supports saddle-stitch and side-stitch binding")
|
||||
t("bookletImposition.tooltip.example.bullet1", "Sheet 1 Front: Pages 8, 1 | Back: Pages 2, 7"),
|
||||
t("bookletImposition.tooltip.example.bullet2", "Sheet 2 Front: Pages 6, 3 | Back: Pages 4, 5"),
|
||||
t("bookletImposition.tooltip.example.bullet3", "When folded & stacked: Reads 1→2→3→4→5→6→7→8")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("bookletImposition.tooltip.bookletTypes.title", "Booklet Types"),
|
||||
title: t("bookletImposition.tooltip.printing.title", "How to Print & Assemble"),
|
||||
description: t("bookletImposition.tooltip.printing.text", "Follow these steps for perfect booklets:"),
|
||||
bullets: [
|
||||
t("bookletImposition.tooltip.bookletTypes.standard", "Standard: Saddle-stitched binding (staples along fold)"),
|
||||
t("bookletImposition.tooltip.bookletTypes.sideStitch", "Side-Stitch: Binding along edge (spiral, ring, perfect)")
|
||||
t("bookletImposition.tooltip.printing.bullet1", "Print double-sided with 'Flip on long edge'"),
|
||||
t("bookletImposition.tooltip.printing.bullet2", "Stack sheets in order, fold in half"),
|
||||
t("bookletImposition.tooltip.printing.bullet3", "Staple or bind along the folded spine"),
|
||||
t("bookletImposition.tooltip.printing.bullet4", "For short-edge printers: Enable 'Flip on short edge' option")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("bookletImposition.tooltip.pagesPerSheet.title", "Pages Per Sheet"),
|
||||
title: t("bookletImposition.tooltip.manualDuplex.title", "Manual Duplex (Single-sided Printers)"),
|
||||
description: t("bookletImposition.tooltip.manualDuplex.text", "For printers without automatic duplex:"),
|
||||
bullets: [
|
||||
t("bookletImposition.tooltip.pagesPerSheet.two", "2 Pages: Standard layout (most common)"),
|
||||
t("bookletImposition.tooltip.pagesPerSheet.four", "4 Pages: Compact layout")
|
||||
t("bookletImposition.tooltip.manualDuplex.bullet1", "Turn OFF 'Double-sided printing'"),
|
||||
t("bookletImposition.tooltip.manualDuplex.bullet2", "Select '1st Pass' → Print → Stack face-down"),
|
||||
t("bookletImposition.tooltip.manualDuplex.bullet3", "Select '2nd Pass' → Load stack → Print backs"),
|
||||
t("bookletImposition.tooltip.manualDuplex.bullet4", "Fold and assemble as normal")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("bookletImposition.tooltip.orientation.title", "Page Orientation"),
|
||||
title: t("bookletImposition.tooltip.advanced.title", "Advanced Options"),
|
||||
description: t("bookletImposition.tooltip.advanced.text", "Fine-tune your booklet:"),
|
||||
bullets: [
|
||||
t("bookletImposition.tooltip.orientation.landscape", "Landscape: A4 → A5 booklet (recommended)"),
|
||||
t("bookletImposition.tooltip.orientation.portrait", "Portrait: A4 → A6 booklet (compact)")
|
||||
t("bookletImposition.tooltip.advanced.bullet1", "Right-to-Left Binding: For Arabic, Hebrew, or RTL languages"),
|
||||
t("bookletImposition.tooltip.advanced.bullet2", "Borders: Shows cut lines for trimming"),
|
||||
t("bookletImposition.tooltip.advanced.bullet3", "Gutter Margin: Adds space for binding/stapling"),
|
||||
t("bookletImposition.tooltip.advanced.bullet4", "Short-edge Flip: Only for automatic duplex printers")
|
||||
]
|
||||
}
|
||||
]
|
||||
|
@ -7,10 +7,14 @@ import { BookletImpositionParameters, defaultParameters } from './useBookletImpo
|
||||
export const buildBookletImpositionFormData = (parameters: BookletImpositionParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
formData.append("bookletType", parameters.bookletType);
|
||||
formData.append("pagesPerSheet", parameters.pagesPerSheet.toString());
|
||||
formData.append("addBorder", parameters.addBorder.toString());
|
||||
formData.append("pageOrientation", parameters.pageOrientation);
|
||||
formData.append("spineLocation", parameters.spineLocation);
|
||||
formData.append("addGutter", parameters.addGutter.toString());
|
||||
formData.append("gutterSize", parameters.gutterSize.toString());
|
||||
formData.append("doubleSided", parameters.doubleSided.toString());
|
||||
formData.append("duplexPass", parameters.duplexPass);
|
||||
formData.append("flipOnShortEdge", parameters.flipOnShortEdge.toString());
|
||||
return formData;
|
||||
};
|
||||
|
||||
|
@ -2,17 +2,25 @@ import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface BookletImpositionParameters extends BaseParameters {
|
||||
bookletType: 'BOOKLET' | 'SIDE_STITCH_BOOKLET';
|
||||
pagesPerSheet: 2 | 4;
|
||||
pagesPerSheet: 2;
|
||||
addBorder: boolean;
|
||||
pageOrientation: 'LANDSCAPE' | 'PORTRAIT';
|
||||
spineLocation: 'LEFT' | 'RIGHT';
|
||||
addGutter: boolean;
|
||||
gutterSize: number;
|
||||
doubleSided: boolean;
|
||||
duplexPass: 'BOTH' | 'FIRST' | 'SECOND';
|
||||
flipOnShortEdge: boolean;
|
||||
}
|
||||
|
||||
export const defaultParameters: BookletImpositionParameters = {
|
||||
bookletType: 'BOOKLET',
|
||||
pagesPerSheet: 2,
|
||||
addBorder: false,
|
||||
pageOrientation: 'LANDSCAPE',
|
||||
spineLocation: 'LEFT',
|
||||
addGutter: false,
|
||||
gutterSize: 12,
|
||||
doubleSided: true,
|
||||
duplexPass: 'BOTH',
|
||||
flipOnShortEdge: false,
|
||||
};
|
||||
|
||||
export type BookletImpositionParametersHook = BaseParametersHook<BookletImpositionParameters>;
|
||||
@ -22,7 +30,7 @@ export const useBookletImpositionParameters = (): BookletImpositionParametersHoo
|
||||
defaultParameters,
|
||||
endpointName: 'booklet-imposition',
|
||||
validateFn: (params) => {
|
||||
return params.pagesPerSheet === 2 || params.pagesPerSheet === 4;
|
||||
return params.pagesPerSheet === 2;
|
||||
},
|
||||
});
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user