feat(print/pdf): basic support for margins

This commit is contained in:
Elian Doran
2026-04-15 09:41:45 +03:00
parent 307536b70f
commit 9e4a5c892e
8 changed files with 174 additions and 18 deletions

View File

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

View File

@@ -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: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
"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 <code>default</code>, <code>none</code>, <code>minimum</code>, or custom values as <code>top,right,bottom,left</code> 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",

View File

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

View File

@@ -268,7 +268,8 @@ const ATTR_HELP: Record<string, Record<string, string>> = {
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"),

View File

@@ -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<string>();
@@ -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<ReturnType<typeof setTimeout>>();
@@ -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<ReturnType<typeof setTimeout>>();
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}
/>
</OptionsRow>
<OptionsRow name="margins" label={t("print_preview.margins")} stacked>
<select
class="form-select form-select-sm"
value={marginPreset}
onChange={(e) => handleMarginPresetChange((e.target as HTMLSelectElement).value)}
disabled={loading}
>
<option value="default">{t("print_preview.margins_default")}</option>
<option value="none">{t("print_preview.margins_none")}</option>
<option value="minimum">{t("print_preview.margins_minimum")}</option>
<option value="custom">{t("print_preview.margins_custom")}</option>
</select>
</OptionsRow>
{marginPreset === "custom" && (
<MarginEditor margins={customMargins} onChange={handleCustomMarginChange} disabled={loading} />
)}
</OptionsSection>
</div>
@@ -192,3 +266,47 @@ export default function PrintPreviewDialog() {
</Modal>
);
}
function MarginEditor({ margins, onChange, disabled }: {
margins: CustomMargins;
onChange: (side: keyof CustomMargins, value: number) => void;
disabled: boolean;
}) {
const spinnerStyle = { width: "130px" };
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "4px", padding: "8px 0" }}>
<MarginSpinner label={t("print_preview.margin_top")} value={margins.top} onChange={(v) => onChange("top", v)} disabled={disabled} style={spinnerStyle} />
<div style={{ display: "flex", gap: "24px", alignItems: "center" }}>
<MarginSpinner label={t("print_preview.margin_left")} value={margins.left} onChange={(v) => onChange("left", v)} disabled={disabled} style={spinnerStyle} />
<MarginSpinner label={t("print_preview.margin_right")} value={margins.right} onChange={(v) => onChange("right", v)} disabled={disabled} style={spinnerStyle} />
</div>
<MarginSpinner label={t("print_preview.margin_bottom")} value={margins.bottom} onChange={(v) => onChange("bottom", v)} disabled={disabled} style={spinnerStyle} />
</div>
);
}
function MarginSpinner({ label, value, onChange, disabled, style }: {
label: string;
value: number;
onChange: (value: number) => void;
disabled: boolean;
style?: Record<string, string>;
}) {
return (
<div class="input-group input-group-sm" style={style}>
<input
type="number"
class="form-control form-control-sm"
title={label}
aria-label={label}
value={value}
min={0}
step={1}
onChange={(e) => onChange((e.target as HTMLInputElement).valueAsNumber || 0)}
disabled={disabled}
/>
<span class="input-group-text">mm</span>
</div>
);
}

View File

@@ -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: `<div></div>`,
footerTemplate: `
<div class="pageNumber" style="width: 100%; text-align: center; font-size: 10pt;">
@@ -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: `<div></div>`,
footerTemplate: `
<div class="pageNumber" style="width: 100%; text-align: center; font-size: 10pt;">

View File

@@ -64,6 +64,7 @@ type Labels = {
printLandscape: boolean;
printPageSize: string;
printScale: string;
printMargins: string;
// Note-type specific
webViewSrc: string;

View File

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