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

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
OUNZAR Aymane
2026-03-24 17:27:56 +00:00
committed by GitHub
co-authored by Copilot
parent 8bbfbd63d7
commit a1f03c844b
17 changed files with 1094 additions and 78 deletions
@@ -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.")
}
]
};
};