From b18637f7d0f3c9a3ceb78c72c01574b3285c48cb Mon Sep 17 00:00:00 2001 From: zsviczian Date: Sun, 19 Jan 2025 08:35:01 +0100 Subject: [PATCH] pdf export fitToPage fix number of pages --- src/core/settings.ts | 2 +- src/lang/locale/en.ts | 6 + src/shared/Dialogs/ExportDialog.ts | 2 +- .../Dialogs/PDFExportSettingsComponent.ts | 16 ++- src/shared/ExcalidrawAutomate.ts | 6 +- src/utils/exportUtils.ts | 129 ++++++++++-------- src/view/ExcalidrawView.ts | 7 +- 7 files changed, 101 insertions(+), 67 deletions(-) diff --git a/src/core/settings.ts b/src/core/settings.ts index 85d42739..266bae1f 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -502,7 +502,7 @@ export const DEFAULT_SETTINGS: ExcalidrawSettings = { pdfSettings: { pageSize: "A4", pageOrientation: "portrait", - fitToPage: true, + fitToPage: 1, paperColor: "white", customPaperColor: "#ffffff", alignment: "center", diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts index 6633e502..0da654d1 100644 --- a/src/lang/locale/en.ts +++ b/src/lang/locale/en.ts @@ -1050,6 +1050,12 @@ FILENAME_HEAD: "Filename", EXPORTDIALOG_ORIENTATION_LANDSCAPE: "Landscape", EXPORTDIALOG_PDF_FIT_TO_PAGE: "Page Fitting", EXPORTDIALOG_PDF_FIT_OPTION: "Fit to page", + EXPORTDIALOG_PDF_FIT_2_OPTION: "Fit to 2-pages", + EXPORTDIALOG_PDF_FIT_4_OPTION: "Fit to 4-pages", + EXPORTDIALOG_PDF_FIT_6_OPTION: "Fit to 6-pages", + EXPORTDIALOG_PDF_FIT_8_OPTION: "Fit to 8-pages", + EXPORTDIALOG_PDF_FIT_12_OPTION: "Fit to 12-pages", + EXPORTDIALOG_PDF_FIT_16_OPTION: "Fit to 16-pages", EXPORTDIALOG_PDF_SCALE_OPTION: "Use image scale (may span multiple pages)", EXPORTDIALOG_PDF_PAPER_COLOR: "Paper Color", EXPORTDIALOG_PDF_PAPER_WHITE: "White", diff --git a/src/shared/Dialogs/ExportDialog.ts b/src/shared/Dialogs/ExportDialog.ts index 1b2ad69e..90cbfcd1 100644 --- a/src/shared/Dialogs/ExportDialog.ts +++ b/src/shared/Dialogs/ExportDialog.ts @@ -38,7 +38,7 @@ export class ExportDialog extends Modal { private contentContainer: HTMLDivElement; private buttonContainerRow1: HTMLDivElement; private buttonContainerRow2: HTMLDivElement; - public fitToPage: boolean = true; + public fitToPage: number = 1; public paperColor: "white" | "scene" | "custom" = "white"; public customPaperColor: string = "#ffffff"; public alignment: PDFPageAlignment = "center"; diff --git a/src/shared/Dialogs/PDFExportSettingsComponent.ts b/src/shared/Dialogs/PDFExportSettingsComponent.ts index c75acb5c..6d31171e 100644 --- a/src/shared/Dialogs/PDFExportSettingsComponent.ts +++ b/src/shared/Dialogs/PDFExportSettingsComponent.ts @@ -5,7 +5,7 @@ import { t } from "src/lang/helpers"; export interface PDFExportSettings { pageSize: PageSize; pageOrientation: PageOrientation; - fitToPage: boolean; + fitToPage: number; paperColor: "white" | "scene" | "custom"; customPaperColor: string; alignment: PDFPageAlignment; @@ -60,12 +60,20 @@ export class PDFExportSettingsComponent { .addDropdown(dropdown => dropdown .addOptions({ + "scale": t("EXPORTDIALOG_PDF_SCALE_OPTION"), "fit": t("EXPORTDIALOG_PDF_FIT_OPTION"), - "scale": t("EXPORTDIALOG_PDF_SCALE_OPTION") + "fit-2": t("EXPORTDIALOG_PDF_FIT_2_OPTION"), + "fit-4": t("EXPORTDIALOG_PDF_FIT_4_OPTION"), + "fit-6": t("EXPORTDIALOG_PDF_FIT_6_OPTION"), + "fit-8": t("EXPORTDIALOG_PDF_FIT_8_OPTION"), + "fit-12": t("EXPORTDIALOG_PDF_FIT_12_OPTION"), + "fit-16": t("EXPORTDIALOG_PDF_FIT_16_OPTION") }) - .setValue(this.settings.fitToPage ? "fit" : "scale") + .setValue(this.settings.fitToPage === 1 ? "fit" : + (typeof this.settings.fitToPage === "number" ? `fit-${this.settings.fitToPage}` : "scale")) .onChange(value => { - this.settings.fitToPage = value === "fit"; + this.settings.fitToPage = value === "scale" ? 0 : + (value === "fit" ? 1 : parseInt(value.split("-")[1])); this.update(); }) ); diff --git a/src/shared/ExcalidrawAutomate.ts b/src/shared/ExcalidrawAutomate.ts index 1693ba55..2d49e7d4 100644 --- a/src/shared/ExcalidrawAutomate.ts +++ b/src/shared/ExcalidrawAutomate.ts @@ -955,14 +955,14 @@ export class ExcalidrawAutomate { * * @param {Object} params - The parameters for creating the PDF. * @param {SVGSVGElement[]} params.SVG - An array of SVG elements to be included in the PDF. - * @param {PDFExportScale} [params.scale={ fitToPage: true, zoom: 1 }] - The scaling options for the SVG elements. + * @param {PDFExportScale} [params.scale={ fitToPage: 1, zoom: 1 }] - The scaling options for the SVG elements. * @param {PDFPageProperties} [params.pageProps] - The properties for the PDF pages. * @returns {Promise} - A promise that resolves to an ArrayBuffer containing the PDF data. * * @example * const pdfData = await createToPDF({ * SVG: [svgElement1, svgElement2], - * scale: { fitToPage: true }, + * scale: { fitToPage: 1 }, * pageProps: { * dimensions: { width: 595.28, height: 841.89 }, * backgroundColor: "#ffffff", @@ -973,7 +973,7 @@ export class ExcalidrawAutomate { */ async createPDF({ SVG, - scale = { fitToPage: true, zoom: 1 }, + scale = { fitToPage: 1, zoom: 1 }, pageProps, }: { SVG: SVGSVGElement[]; diff --git a/src/utils/exportUtils.ts b/src/utils/exportUtils.ts index 4aa33cae..194bbc95 100644 --- a/src/utils/exportUtils.ts +++ b/src/utils/exportUtils.ts @@ -1,12 +1,15 @@ import { PDFDocument, rgb } from '@cantoo/pdf-lib'; import { getEA } from 'src/core'; +const SVG_DPI = 300; +const PDF_DPI = 72; +const SVG_TO_PDF_SCALE = PDF_DPI / SVG_DPI; export type PDFPageAlignment = "center" | "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"; export type PDFPageMarginString = "none" | "tiny" | "normal"; export interface PDFExportScale { - fitToPage: boolean; + fitToPage: number; // 0 means use zoom, >1 means fit to that many pages exactly zoom?: number; } @@ -106,6 +109,17 @@ function calculatePosition( return {x, y}; } +function getNumberOfPages( + width: number, + height: number, + availableWidth: number, + availableHeight: number +): number { + const cols = Math.ceil(width / availableWidth); + const rows = Math.ceil(height / availableHeight); + return cols * rows; +} + function calculateDimensions( svgWidth: number, svgHeight: number, @@ -114,17 +128,46 @@ function calculateDimensions( scale: PDFExportScale, alignment: PDFPageAlignment ): SVGDimensions[] { + const pdfWidth = svgWidth * SVG_TO_PDF_SCALE; + const pdfHeight = svgHeight * SVG_TO_PDF_SCALE; const availableWidth = pageDim.width - margin.left - margin.right; const availableHeight = pageDim.height - margin.top - margin.bottom; - let finalWidth: number; - let finalHeight: number; + // If fitToPage is specified, find optimal zoom using binary search + if (scale.fitToPage > 0) { + let low = 0; + let high = 100; // Start with a reasonable upper bound + let bestZoom = 1; + const tolerance = 0.000001; + + while (high - low > tolerance) { + const mid = (low + high) / 2; + const scaledWidth = pdfWidth * mid; + const scaledHeight = pdfHeight * mid; + const pages = getNumberOfPages(scaledWidth, scaledHeight, availableWidth, availableHeight); + + if (pages > scale.fitToPage) { + high = mid; + } else { + bestZoom = mid; + low = mid; + } + } - if (scale.fitToPage) { - const ratio = Math.min(availableWidth / svgWidth, availableHeight / svgHeight); - finalWidth = svgWidth * ratio; - finalHeight = svgHeight * ratio; - + // Apply a small reduction to prevent floating-point issues + scale.zoom = Math.round(bestZoom * 0.99999 * 1000000) / 1000000; + } + + // Now handle as regular scale mode + const finalWidth = Math.round(pdfWidth * (scale.zoom || 1) * 1000) / 1000; + const finalHeight = Math.round(pdfHeight * (scale.zoom || 1) * 1000) / 1000; + + // Round the available dimensions as well for consistent comparison + const roundedAvailableWidth = Math.round(availableWidth * 1000) / 1000; + const roundedAvailableHeight = Math.round(availableHeight * 1000) / 1000; + + if (finalWidth <= roundedAvailableWidth && finalHeight <= roundedAvailableHeight) { + // Content fits on one page const position = calculatePosition( finalWidth, finalHeight, @@ -133,7 +176,7 @@ function calculateDimensions( margin, alignment, ); - + return [{ width: finalWidth, height: finalHeight, @@ -141,56 +184,37 @@ function calculateDimensions( y: position.y }]; } else { - // Scale mode - may need multiple pages - finalWidth = svgWidth * (scale.zoom || 1); - finalHeight = svgHeight * (scale.zoom || 1); - - if (finalWidth <= availableWidth && finalHeight <= availableHeight) { - // Content fits on one page - const position = calculatePosition( - finalWidth, - finalHeight, - pageDim.width, - pageDim.height, - margin, - alignment, - ); - - return [{ - width: finalWidth, - height: finalHeight, - x: position.x, - y: position.y - }]; - } else { - // Content needs to be tiled across multiple pages - const dimensions: SVGDimensions[] = []; - const cols = Math.ceil(finalWidth / availableWidth); - const rows = Math.ceil(finalHeight / availableHeight); - - for (let row = 0; row < rows; row++) { - for (let col = 0; col < cols; col++) { - const tileWidth = Math.min(availableWidth, finalWidth - col * availableWidth); - const tileHeight = Math.min(availableHeight, finalHeight - row * availableHeight); - - // Calculate y coordinate following the same logic as single-page rendering - // We start from the bottom margin and work our way up - //const y = margin.bottom + row * availableHeight; + // Content needs to be tiled across multiple pages + const dimensions: SVGDimensions[] = []; + // Calculate exact number of needed columns and rows + const cols = Math.ceil(finalWidth / roundedAvailableWidth); + const rows = Math.ceil(finalHeight / roundedAvailableHeight); + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + // Calculate remaining width and height for this tile + const remainingWidth = finalWidth - col * roundedAvailableWidth; + const remainingHeight = finalHeight - row * roundedAvailableHeight; + + // Only create tile if there's actual content to show + if (remainingWidth > 0 && remainingHeight > 0) { + const tileWidth = Math.min(roundedAvailableWidth, remainingWidth); + const tileHeight = Math.min(roundedAvailableHeight, remainingHeight); dimensions.push({ width: tileWidth, height: tileHeight, x: margin.left, y: margin.top, - sourceX: col * availableWidth / (scale.zoom || 1), - sourceY: row * availableHeight / (scale.zoom || 1), - sourceWidth: tileWidth / (scale.zoom || 1), - sourceHeight: tileHeight / (scale.zoom || 1) + sourceX: (col * roundedAvailableWidth) / ((scale.zoom || 1) * SVG_TO_PDF_SCALE), + sourceY: (row * roundedAvailableHeight) / ((scale.zoom || 1) * SVG_TO_PDF_SCALE), + sourceWidth: tileWidth / ((scale.zoom || 1) * SVG_TO_PDF_SCALE), + sourceHeight: tileHeight / ((scale.zoom || 1) * SVG_TO_PDF_SCALE) }); } } - return dimensions; } + return dimensions; } } @@ -292,18 +316,15 @@ async function preprocessSVGForPDFLib(svg: SVGSVGElement): Promise