From 9e4a5c892e8f6dcd0f76c23e852317b48d38bb79 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 15 Apr 2026 09:41:45 +0300 Subject: [PATCH] feat(print/pdf): basic support for margins --- apps/client/src/print.css | 4 - .../src/translations/en/translation.json | 12 +- apps/client/src/widgets/NoteDetail.tsx | 3 +- .../attribute_widgets/attribute_detail.ts | 3 +- .../src/widgets/dialogs/print_preview.tsx | 132 +++++++++++++++++- apps/server/src/services/window.ts | 36 ++++- packages/commons/src/lib/attribute_names.ts | 1 + .../commons/src/lib/builtin_attributes.ts | 1 + 8 files changed, 174 insertions(+), 18 deletions(-) diff --git a/apps/client/src/print.css b/apps/client/src/print.css index 7d551fe0ff..aa9f49a1a2 100644 --- a/apps/client/src/print.css +++ b/apps/client/src/print.css @@ -12,10 +12,6 @@ body { color: black; } -@page { - margin: 2cm; -} - .note-list-widget.full-height, .note-list-widget.full-height .note-list-widget-content { height: unset !important; diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 0f74650dd1..ea2b4ff5e1 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -455,6 +455,7 @@ "print_landscape": "When exporting to PDF, changes the orientation of the page to landscape instead of portrait.", "print_page_size": "When exporting to PDF, changes the size of the page. Supported values: A0, A1, A2, A3, A4, A5, A6, Legal, Letter, Tabloid, Ledger.", "print_scale": "When exporting to PDF, changes the scale of the rendered content. Values range from 0.1 (10%) to 2 (200%), default is 1 (100%).", + "print_margins": "When exporting to PDF, sets page margins. Use default, none, minimum, or custom values as top,right,bottom,left in millimeters.", "color_type": "Color" }, "attribute_editor": { @@ -2328,7 +2329,16 @@ "portrait": "Portrait", "landscape": "Landscape", "page_size": "Page size", - "scale": "Scale" + "scale": "Scale", + "margins": "Margins", + "margins_default": "Default", + "margins_none": "None", + "margins_minimum": "Minimum", + "margins_custom": "Custom", + "margin_top": "Top", + "margin_right": "Right", + "margin_bottom": "Bottom", + "margin_left": "Left" }, "pdf": { "attachments_one": "{{count}} attachment", diff --git a/apps/client/src/widgets/NoteDetail.tsx b/apps/client/src/widgets/NoteDetail.tsx index ab8254ba29..f537eedfec 100644 --- a/apps/client/src/widgets/NoteDetail.tsx +++ b/apps/client/src/widgets/NoteDetail.tsx @@ -229,7 +229,8 @@ export default function NoteDetail() { notePath: noteContext.notePath, pageSize: note.getAttributeValue("label", "printPageSize") ?? "Letter", landscape: note.hasAttribute("label", "printLandscape"), - scale: parseFloat(note.getAttributeValue("label", "printScale") ?? "1") || 1 + scale: parseFloat(note.getAttributeValue("label", "printScale") ?? "1") || 1, + margins: note.getAttributeValue("label", "printMargins") ?? "default" }); }); diff --git a/apps/client/src/widgets/attribute_widgets/attribute_detail.ts b/apps/client/src/widgets/attribute_widgets/attribute_detail.ts index aed675ecf0..af88d65d11 100644 --- a/apps/client/src/widgets/attribute_widgets/attribute_detail.ts +++ b/apps/client/src/widgets/attribute_widgets/attribute_detail.ts @@ -268,7 +268,8 @@ const ATTR_HELP: Record> = { hideHighlightWidget: t("attribute_detail.hide_highlight_widget"), printLandscape: t("attribute_detail.print_landscape"), printPageSize: t("attribute_detail.print_page_size"), - printScale: t("attribute_detail.print_scale") + printScale: t("attribute_detail.print_scale"), + printMargins: t("attribute_detail.print_margins") }, relation: { runOnNoteCreation: t("attribute_detail.run_on_note_creation"), diff --git a/apps/client/src/widgets/dialogs/print_preview.tsx b/apps/client/src/widgets/dialogs/print_preview.tsx index acffc0652e..74d5ddbf51 100644 --- a/apps/client/src/widgets/dialogs/print_preview.tsx +++ b/apps/client/src/widgets/dialogs/print_preview.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "preact/hooks"; +import { useCallback, useMemo, useRef, useState } from "preact/hooks"; import FNote from "../../entities/fnote"; import { t } from "../../services/i18n"; @@ -13,6 +13,33 @@ import OptionsRow from "../type_widgets/options/components/OptionsRow"; import OptionsSection from "../type_widgets/options/components/OptionsSection"; const PAGE_SIZES = ["A0", "A1", "A2", "A3", "A4", "A5", "A6", "Legal", "Letter", "Tabloid", "Ledger"] as const; +const MARGIN_PRESETS = ["default", "none", "minimum"] as const; +type MarginPreset = typeof MARGIN_PRESETS[number]; + +interface CustomMargins { + top: number; + right: number; + bottom: number; + left: number; +} + +function parseMarginValue(value: string): { preset: MarginPreset | "custom"; custom: CustomMargins } { + if (MARGIN_PRESETS.includes(value as MarginPreset)) { + return { preset: value as MarginPreset, custom: { top: 10, right: 10, bottom: 10, left: 10 } }; + } + + const parts = value.split(",").map(Number); + if (parts.length === 4 && parts.every((n) => !isNaN(n))) { + return { preset: "custom", custom: { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] } }; + } + + return { preset: "default", custom: { top: 10, right: 10, bottom: 10, left: 10 } }; +} + +function serializeMargins(preset: MarginPreset | "custom", custom: CustomMargins): string { + if (preset !== "custom") return preset; + return `${custom.top},${custom.right},${custom.bottom},${custom.left}`; +} export interface PrintPreviewData { pdfBuffer: Uint8Array; @@ -20,6 +47,13 @@ export interface PrintPreviewData { notePath: string; } +interface PreviewOpts { + landscape: boolean; + pageSize: string; + scale: number; + margins: string; +} + export default function PrintPreviewDialog() { const [shown, setShown] = useState(false); const [pdfUrl, setPdfUrl] = useState(); @@ -32,11 +66,12 @@ export default function PrintPreviewDialog() { const [pageSize, setPageSize] = useNoteLabelWithDefault(note, "printPageSize", "Letter"); const [scaleStr, setScaleStr] = useNoteLabelWithDefault(note, "printScale", "1"); const scale = parseFloat(scaleStr) || 1; + const [marginsStr, setMarginsStr] = useNoteLabelWithDefault(note, "printMargins", "default"); + const { preset: marginPreset, custom: customMargins } = useMemo(() => parseMarginValue(marginsStr), [marginsStr]); const updatePreview = useCallback((buffer: Uint8Array) => { bufferRef.current = buffer; - // Revoke old URL before creating new one. if (pdfUrl) { URL.revokeObjectURL(pdfUrl); } @@ -77,13 +112,13 @@ export default function PrintPreviewDialog() { function handleOrientationChange(newLandscape: boolean) { if (newLandscape === landscape) return; setLandscape(newLandscape); - regeneratePreview({ landscape: newLandscape, pageSize, scale }); + regeneratePreview({ landscape: newLandscape, pageSize, scale, margins: marginsStr }); } function handlePageSizeChange(newPageSize: string) { if (newPageSize === pageSize) return; setPageSize(newPageSize); - regeneratePreview({ landscape, pageSize: newPageSize, scale }); + regeneratePreview({ landscape, pageSize: newPageSize, scale, margins: marginsStr }); } const scaleDebounceRef = useRef>(); @@ -94,11 +129,31 @@ export default function PrintPreviewDialog() { clearTimeout(scaleDebounceRef.current); scaleDebounceRef.current = setTimeout(() => { - regeneratePreview({ landscape, pageSize, scale: clamped }); + regeneratePreview({ landscape, pageSize, scale: clamped, margins: marginsStr }); }, 500); } - function regeneratePreview(opts: { landscape: boolean; pageSize: string; scale: number }) { + function handleMarginPresetChange(newPreset: string) { + if (newPreset === marginPreset) return; + const newValue = serializeMargins(newPreset as MarginPreset | "custom", customMargins); + setMarginsStr(newValue); + regeneratePreview({ landscape, pageSize, scale, margins: newValue }); + } + + const marginDebounceRef = useRef>(); + + function handleCustomMarginChange(side: keyof CustomMargins, value: number) { + const newCustom = { ...customMargins, [side]: Math.max(0, value) }; + const newValue = serializeMargins("custom", newCustom); + setMarginsStr(newValue); + + clearTimeout(marginDebounceRef.current); + marginDebounceRef.current = setTimeout(() => { + regeneratePreview({ landscape, pageSize, scale, margins: newValue }); + }, 500); + } + + function regeneratePreview(opts: PreviewOpts) { if (!isElectron()) return; setLoading(true); @@ -114,7 +169,8 @@ export default function PrintPreviewDialog() { notePath: notePathRef.current, pageSize: opts.pageSize, landscape: opts.landscape, - scale: opts.scale + scale: opts.scale, + margins: opts.margins }); } @@ -178,6 +234,24 @@ export default function PrintPreviewDialog() { onChange={handleScaleChange} /> + + + + + + {marginPreset === "custom" && ( + + )} @@ -192,3 +266,47 @@ export default function PrintPreviewDialog() { ); } + +function MarginEditor({ margins, onChange, disabled }: { + margins: CustomMargins; + onChange: (side: keyof CustomMargins, value: number) => void; + disabled: boolean; +}) { + const spinnerStyle = { width: "130px" }; + + return ( +
+ onChange("top", v)} disabled={disabled} style={spinnerStyle} /> +
+ onChange("left", v)} disabled={disabled} style={spinnerStyle} /> + onChange("right", v)} disabled={disabled} style={spinnerStyle} /> +
+ onChange("bottom", v)} disabled={disabled} style={spinnerStyle} /> +
+ ); +} + +function MarginSpinner({ label, value, onChange, disabled, style }: { + label: string; + value: number; + onChange: (value: number) => void; + disabled: boolean; + style?: Record; +}) { + return ( +
+ onChange((e.target as HTMLInputElement).valueAsNumber || 0)} + disabled={disabled} + /> + mm +
+ ); +} diff --git a/apps/server/src/services/window.ts b/apps/server/src/services/window.ts index de39b08594..e20e721c6d 100644 --- a/apps/server/src/services/window.ts +++ b/apps/server/src/services/window.ts @@ -87,6 +87,29 @@ interface ExportAsPdfOpts { landscape: boolean; pageSize: "A0" | "A1" | "A2" | "A3" | "A4" | "A5" | "A6" | "Legal" | "Letter" | "Tabloid" | "Ledger"; scale: number; + margins: string; +} + +/** Parses the printMargins attribute. Preset values map to Electron margin types. + * Custom values are stored as "top,right,bottom,left" in mm and converted to inches for Electron. */ +function parseMargins(margins: string): Electron.Margins | undefined { + if (!margins || margins === "default") return { marginType: "default" }; + if (margins === "none") return { marginType: "none" }; + if (margins === "minimum") return { marginType: "printableArea" }; + + const parts = margins.split(",").map(Number); + if (parts.length === 4 && parts.every((n) => !isNaN(n))) { + const mmToInches = (mm: number) => mm / 25.4; + return { + marginType: "custom", + top: mmToInches(parts[0]), + right: mmToInches(parts[1]), + bottom: mmToInches(parts[2]), + left: mmToInches(parts[3]) + }; + } + + return { marginType: "default" }; } electron.ipcMain.on("print-note", async (e, { notePath }: PrintOpts) => { @@ -108,7 +131,7 @@ electron.ipcMain.on("print-note", async (e, { notePath }: PrintOpts) => { } }); -electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pageSize, scale }: ExportAsPdfOpts) => { +electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pageSize, scale, margins }: ExportAsPdfOpts) => { try { const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "exporting_pdf"); @@ -130,10 +153,14 @@ electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pag landscape, pageSize, scale, + margins: parseMargins(margins), generateDocumentOutline: true, generateTaggedPDF: true, printBackground: true, - displayHeaderFooter: true, + // displayHeaderFooter forces Chromium to use fixed default margins + // (to make room for the header/footer), overriding our `margins` setting. + // Only enable it when the user hasn't customized margins. + displayHeaderFooter: !margins || margins === "default", headerTemplate: `
`, footerTemplate: `
@@ -170,7 +197,7 @@ electron.ipcMain.on("export-as-pdf", async (e, { title, notePath, landscape, pag } }); -electron.ipcMain.on("export-as-pdf-preview", async (e, { notePath, landscape, pageSize, scale }: ExportAsPdfOpts) => { +electron.ipcMain.on("export-as-pdf-preview", async (e, { notePath, landscape, pageSize, scale, margins }: ExportAsPdfOpts) => { try { const { browserWindow, printReport } = await getBrowserWindowForPrinting(e, notePath, "exporting_pdf"); @@ -179,10 +206,11 @@ electron.ipcMain.on("export-as-pdf-preview", async (e, { notePath, landscape, pa landscape, pageSize, scale, + margins: parseMargins(margins), generateDocumentOutline: true, generateTaggedPDF: true, printBackground: true, - displayHeaderFooter: true, + displayHeaderFooter: !margins || margins === "default", headerTemplate: `
`, footerTemplate: `
diff --git a/packages/commons/src/lib/attribute_names.ts b/packages/commons/src/lib/attribute_names.ts index 0442031831..93c99dc3ee 100644 --- a/packages/commons/src/lib/attribute_names.ts +++ b/packages/commons/src/lib/attribute_names.ts @@ -64,6 +64,7 @@ type Labels = { printLandscape: boolean; printPageSize: string; printScale: string; + printMargins: string; // Note-type specific webViewSrc: string; diff --git a/packages/commons/src/lib/builtin_attributes.ts b/packages/commons/src/lib/builtin_attributes.ts index 0ef66064ec..0c23632a6c 100644 --- a/packages/commons/src/lib/builtin_attributes.ts +++ b/packages/commons/src/lib/builtin_attributes.ts @@ -86,6 +86,7 @@ export default [ { type: "label", name: "printLandscape" }, { type: "label", name: "printPageSize" }, { type: "label", name: "printScale" }, + { type: "label", name: "printMargins" }, // relation names { type: "relation", name: "internalLink" },