This commit is contained in:
Anthony Stirling 2025-09-20 20:40:12 +01:00
parent 7d3c5faacd
commit 9d5674f0ca
7 changed files with 518 additions and 240 deletions

View File

@ -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();
}
}
}

View File

@ -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;
}

View File

@ -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."
}

View File

@ -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>
);

View File

@ -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")
]
}
]

View File

@ -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;
};

View File

@ -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;
},
});
};