From a790b04547980a01ae55284ef2c39e73e182f897 Mon Sep 17 00:00:00 2001 From: zsviczian Date: Sat, 18 Jan 2025 11:37:58 +0100 Subject: [PATCH] initial implementation --- package-lock.json | 132 ++++++++++++++++++- package.json | 4 +- rollup.config.js | 2 + src/shared/Dialogs/ExportDialog.ts | 186 +++++++++++++++++++++----- src/utils/exportUtils.ts | 204 +++++++++++++++++++++++++++++ src/utils/fileUtils.ts | 11 ++ src/view/ExcalidrawView.ts | 48 +++++++ styles.css | 18 +++ 8 files changed, 569 insertions(+), 36 deletions(-) create mode 100644 src/utils/exportUtils.ts diff --git a/package-lock.json b/package-lock.json index 34fc513..75aa8a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.2.5", "license": "MIT", "dependencies": { + "@cantoo/pdf-lib": "^2.2.4", "@popperjs/core": "^2.11.8", "@zsviczian/colormaster": "^1.2.2", "@zsviczian/excalidraw": "0.17.6-26", @@ -41,6 +42,7 @@ "@excalidraw/prettier-config": "^1.0.2", "@rollup/plugin-babel": "^6.0.3", "@rollup/plugin-commonjs": "^26.0.1", + "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-replace": "^5.0.2", "@rollup/plugin-typescript": "^12.1.2", @@ -1882,6 +1884,19 @@ "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz", "integrity": "sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==" }, + "node_modules/@cantoo/pdf-lib": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.2.4.tgz", + "integrity": "sha512-69bECXjl0QVMrBTEsPZDPFmhoyLGwXKiJI5JWPgBnAx0kS5+7v76rNCeYMTbDmZaPgvdIViMvTaz2HVCe8u1fw==", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "color": "^4.2.3", + "crypto-js": "^4.2.0", + "node-html-better-parser": "^1.4.0", + "pako": "^1.0.11" + } + }, "node_modules/@codemirror/commands": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.0.tgz", @@ -2346,6 +2361,22 @@ "node": ">= 8" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2775,6 +2806,26 @@ } } }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/plugin-node-resolve": { "version": "15.2.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz", @@ -3796,6 +3847,18 @@ "node": ">=6" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -3808,8 +3871,32 @@ "node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "devOptional": true + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/colord": { "version": "2.9.3", @@ -3922,6 +4009,11 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "node_modules/css-declaration-sorter": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.2.0.tgz", @@ -5501,6 +5593,21 @@ "node": ">= 0.4" } }, + "node_modules/html-entities": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz", + "integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ] + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -5595,6 +5702,11 @@ "loose-envify": "^1.0.0" } }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -6745,6 +6857,14 @@ "dev": true, "peer": true }, + "node_modules/node-html-better-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.4.7.tgz", + "integrity": "sha512-cJcBhlrn432XtbzPmzxxMLsHeSUnE5qFQtVbVlHXvUGt2ccqc0/wBsB43CFv39BIkLkP5rrPUN5Hg52CnheH+A==", + "dependencies": { + "html-entities": "^2.3.2" + } + }, "node_modules/node-releases": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", @@ -8317,6 +8437,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", diff --git a/package.json b/package.json index b66b0fb..65bdfb8 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "react-dom": "^18.2.0", "roughjs": "^4.5.2", "woff2sfnt-sfnt2woff": "^1.0.0", - "es6-promise-pool": "2.5.0" + "es6-promise-pool": "2.5.0", + "@cantoo/pdf-lib": "^2.2.4" }, "devDependencies": { "jsesc": "^3.0.2", @@ -59,6 +60,7 @@ "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-replace": "^5.0.2", "@rollup/plugin-typescript": "^12.1.2", + "@rollup/plugin-json": "^6.1.0", "@types/chroma-js": "^2.4.0", "@types/js-beautify": "^1.14.0", "@types/js-yaml": "^4.0.9", diff --git a/rollup.config.js b/rollup.config.js index f1cc137..b28fc02 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -11,6 +11,7 @@ import postprocess from '@zsviczian/rollup-plugin-postprocess'; import cssnano from 'cssnano'; import jsesc from 'jsesc'; import { minify } from 'uglify-js'; +import json from '@rollup/plugin-json'; // Load environment variables import dotenv from 'dotenv'; @@ -130,6 +131,7 @@ const BASE_CONFIG = { const getRollupPlugins = (tsconfig, ...plugins) => [ typescript2(tsconfig), + json(), replace({ preventAssignment: true, "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV), diff --git a/src/shared/Dialogs/ExportDialog.ts b/src/shared/Dialogs/ExportDialog.ts index 6c5770f..5006f69 100644 --- a/src/shared/Dialogs/ExportDialog.ts +++ b/src/shared/Dialogs/ExportDialog.ts @@ -6,6 +6,7 @@ import { ExcalidrawAutomate } from "src/shared/ExcalidrawAutomate"; import ExcalidrawView from "src/view/ExcalidrawView"; import ExcalidrawPlugin from "src/core/main"; import { fragWithHTML, getExportPadding, getExportTheme, getPNGScale, getWithBackground, shouldEmbedScene } from "src/utils/utils"; +import { PageOrientation, PageSize, STANDARD_PAGE_SIZES } from "src/utils/exportUtils"; export class ExportDialog extends Modal { private ea: ExcalidrawAutomate; @@ -27,11 +28,17 @@ export class ExportDialog extends Modal { public embedScene: boolean; public exportSelectedOnly: boolean; public saveToVault: boolean; + public pageSize: PageSize = "A4"; + public pageOrientation: PageOrientation = "portrait"; + private activeTab: "image" | "pdf" = "image"; + private contentContainer: HTMLDivElement; + private buttonContainer: HTMLDivElement; constructor( private plugin: ExcalidrawPlugin, private view: ExcalidrawView, private file: TFile, + ) { super(plugin.app); this.ea = getEA(this.view); @@ -73,11 +80,81 @@ export class ExportDialog extends Modal { } async createForm() { - let scaleSetting:Setting; - let paddingSetting: Setting; + if(DEVICE.isDesktop) { + // Create tab container + const tabContainer = this.contentEl.createDiv("nav-buttons-container"); + const imageTab = tabContainer.createEl("button", { + text: "Image", + cls: `nav-button ${this.activeTab === "image" ? "is-active" : ""}` + }); - this.contentEl.createEl("h1",{text: "Image settings"}); - this.contentEl.createEl("p",{text: "Transparency only affects PNGs. Excalidraw files can only be exported outside the Vault. PNGs copied to clipboard may not include the scene."}) + + const pdfTab = tabContainer.createEl("button", { + text: "PDF", + cls: `nav-button ${this.activeTab === "pdf" ? "is-active" : ""}` + }); + + // Tab click handlers + imageTab.onclick = () => { + this.activeTab = "image"; + imageTab.addClass("is-active"); + pdfTab.removeClass("is-active"); + this.renderContent(); + }; + + pdfTab.onclick = () => { + this.activeTab = "pdf"; + pdfTab.addClass("is-active"); + imageTab.removeClass("is-active"); + this.renderContent(); + }; + } + + // Create content container + this.contentContainer = this.contentEl.createDiv(); + this.buttonContainer = this.contentEl.createDiv({cls: "excalidraw-prompt-buttons-div"}); + + this.renderContent(); + } + + private createSaveSettingsDropdown() { + new Setting(this.contentContainer) + .setName("Save settings?") + .addDropdown(dropdown => + dropdown + .addOption("save", "Save these settings as the preset for this image") + .addOption("one-time", "These are one-time settings") + .setValue(this.saveSettings ? "save" : "one-time") + .onChange(value => { + this.saveSettings = value === "save"; + }) + ); + } + + private renderContent() { + this.contentContainer.empty(); + this.buttonContainer.empty(); + + // Always show save settings dropdown + this.createSaveSettingsDropdown(); + + if (this.activeTab === "image") { + this.createImageSettings(); + this.createExportSettings(); + this.createImageButtons(); + } else { + this.createImageSettings(); + this.createPDFSettings(); + this.createPDFButton(); + } + } + + private createImageSettings() { + let scaleSetting:Setting; + let paddingSetting: Setting; + + this.contentContainer.createEl("h1",{text: "Image settings"}); + this.contentContainer.createEl("p",{text: "Transparency only affects PNGs. Excalidraw files can only be exported outside the Vault. PNGs copied to clipboard may not include the scene."}) const size = ():DocumentFragment => { const width = Math.round(this.scale*this.boundingBox.width + this.padding*2); @@ -89,7 +166,7 @@ export class ExportDialog extends Modal { return fragWithHTML(`Current image padding is ${this.padding}`); } - paddingSetting = new Setting(this.contentEl) + paddingSetting = new Setting(this.contentContainer) .setName("Image padding") .setDesc(padding()) .addSlider(slider => { @@ -103,8 +180,8 @@ export class ExportDialog extends Modal { }) }) - scaleSetting = new Setting(this.contentEl) - .setName("PNG Scale") + scaleSetting = new Setting(this.contentContainer) + .setName("Scale") .setDesc(size()) .addSlider(slider => slider @@ -116,7 +193,7 @@ export class ExportDialog extends Modal { }) ) - new Setting(this.contentEl) + new Setting(this.contentContainer) .setName("Export theme") .addDropdown(dropdown => dropdown @@ -128,8 +205,8 @@ export class ExportDialog extends Modal { }) ) - new Setting(this.contentEl) - .setName("Background color") + new Setting(this.contentContainer) + .setName("Use scene background color") .addDropdown(dropdown => dropdown .addOption("transparent","Transparent") @@ -139,22 +216,24 @@ export class ExportDialog extends Modal { this.transparent = value === "transparent"; }) ) - - new Setting(this.contentEl) - .setName("Save or one-time settings?") + + this.selectedOnlySetting = new Setting(this.contentContainer) + .setName("The scene or just selected elements?") .addDropdown(dropdown => dropdown - .addOption("save","Save these settings as the preset for this image") - .addOption("one-time","These are one-time settings") - .setValue(this.saveSettings?"save":"one-time") + .addOption("all","Entire scene") + .addOption("selected","Selected elements") + .setValue(this.exportSelectedOnly?"selected":"all") .onChange(value => { - this.saveSettings = value === "save"; + this.exportSelectedOnly = value === "selected"; }) - ) + ); + } - this.contentEl.createEl("h1",{text:"Export settings"}); + private createExportSettings() { + this.contentContainer.createEl("h1",{text:"Export settings"}); - new Setting(this.contentEl) + new Setting(this.contentContainer) .setName("Embed the Excalidraw scene in the exported file?") .addDropdown(dropdown => dropdown @@ -167,7 +246,7 @@ export class ExportDialog extends Modal { ) if(DEVICE.isDesktop) { - new Setting(this.contentEl) + new Setting(this.contentContainer) .setName("Where to save the image?") .addDropdown(dropdown => dropdown @@ -179,46 +258,87 @@ export class ExportDialog extends Modal { }) ) } + } - this.selectedOnlySetting = new Setting(this.contentEl) - .setName("Export entire scene or just selected elements?") + private createPDFSettings() { + if (!DEVICE.isDesktop) return; + + this.contentContainer.createEl("h1", { text: "PDF settings" }); + + const pageSizeOptions: Record = Object.keys(STANDARD_PAGE_SIZES) + .reduce((acc, key) => ({ + ...acc, + [key]: key + }), {}); + + new Setting(this.contentContainer) + .setName("Page size") .addDropdown(dropdown => dropdown - .addOption("all","Export entire scene") - .addOption("selected","Export selected elements") - .setValue(this.exportSelectedOnly?"selected":"all") + .addOptions(pageSizeOptions) + .setValue(this.pageSize) .onChange(value => { - this.exportSelectedOnly = value === "selected"; + this.pageSize = value as PageSize; }) - ) + ); + new Setting(this.contentContainer) + .setName("Page orientation") + .addDropdown(dropdown => + dropdown + .addOptions({ + "portrait": "Portrait", + "landscape": "Landscape" + }) + .setValue(this.pageOrientation) + .onChange(value => { + this.pageOrientation = value as PageOrientation; + }) + ); + } - const div = this.contentEl.createDiv({cls: "excalidraw-prompt-buttons-div"}); - const bPNG = div.createEl("button", { text: "PNG to File", cls: "excalidraw-prompt-button"}); + private createImageButtons() { + const bPNG = this.buttonContainer.createEl("button", { text: "PNG > File", cls: "excalidraw-prompt-button"}); bPNG.onclick = () => { this.saveToVault ? this.view.savePNG(this.view.getScene(this.hasSelectedElements && this.exportSelectedOnly)) : this.view.exportPNG(this.embedScene,this.hasSelectedElements && this.exportSelectedOnly); this.close(); }; - const bSVG = div.createEl("button", { text: "SVG to File", cls: "excalidraw-prompt-button" }); + const bSVG = this.buttonContainer.createEl("button", { text: "SVG > File", cls: "excalidraw-prompt-button" }); bSVG.onclick = () => { this.saveToVault ? this.view.saveSVG(this.view.getScene(this.hasSelectedElements && this.exportSelectedOnly)) : this.view.exportSVG(this.embedScene,this.hasSelectedElements && this.exportSelectedOnly); this.close(); }; - const bExcalidraw = div.createEl("button", { text: "Excalidraw", cls: "excalidraw-prompt-button" }); + const bExcalidraw = this.buttonContainer.createEl("button", { text: "Excalidraw", cls: "excalidraw-prompt-button" }); bExcalidraw.onclick = () => { this.view.exportExcalidraw(this.hasSelectedElements && this.exportSelectedOnly); this.close(); }; if(DEVICE.isDesktop) { - const bPNGClipboard = div.createEl("button", { text: "PNG to Clipboard", cls: "excalidraw-prompt-button" }); + const bPNGClipboard = this.buttonContainer.createEl("button", { text: "PNG > Clipboard", cls: "excalidraw-prompt-button" }); bPNGClipboard.onclick = () => { this.view.exportPNGToClipboard(this.embedScene, this.hasSelectedElements && this.exportSelectedOnly); this.close(); }; } } + + private createPDFButton() { + if (!DEVICE.isDesktop) return; + const bPDF = this.buttonContainer.createEl("button", { + text: "PDF", + cls: "excalidraw-prompt-button" + }); + bPDF.onclick = () => { + this.view.exportPDF( + this.hasSelectedElements && this.exportSelectedOnly, + this.pageSize, + this.pageOrientation + ); + this.close(); + }; + } } diff --git a/src/utils/exportUtils.ts b/src/utils/exportUtils.ts new file mode 100644 index 0000000..0e37fc0 --- /dev/null +++ b/src/utils/exportUtils.ts @@ -0,0 +1,204 @@ +import { PDFDocument, rgb } from '@cantoo/pdf-lib'; +import { getEA } from 'src/core'; +import { svgToBase64 } from './utils'; + +interface PDFExportScale { + fitToPage: boolean; + zoom?: number; +} + +interface PDFPageProperties { + dimensions?: {width: number; height: number}; + backgroundColor?: string; + margin?: { + left: number; + right: number; + top: number; + bottom: number; + }; +} + +interface PageDimensions { + width: number; + height: number; +} + +export type PageOrientation = "portrait" | "landscape"; + +// All dimensions in points (pt) +export const STANDARD_PAGE_SIZES = { + "A0": { width: 2383.94, height: 3370.39 }, + "A1": { width: 1683.78, height: 2383.94 }, + "A2": { width: 1190.55, height: 1683.78 }, + "A3": { width: 841.89, height: 1190.55 }, + "A4": { width: 595.28, height: 841.89 }, + "A5": { width: 419.53, height: 595.28 }, + "Letter": { width: 612, height: 792 }, + "Legal": { width: 612, height: 1008 }, + "Tabloid": { width: 792, height: 1224 }, +} as const; + +export type PageSize = keyof typeof STANDARD_PAGE_SIZES; + +export function getPageDimensions(pageSize: PageSize, orientation: PageOrientation): PageDimensions { + const dimensions = STANDARD_PAGE_SIZES[pageSize]; + return orientation === "portrait" + ? { width: dimensions.width, height: dimensions.height } + : { width: dimensions.height, height: dimensions.width }; +} + +const DEFAULT_MARGIN = 20; // 20pt margins + +interface SVGDimensions { + width: number; + height: number; + x: number; + y: number; +} + +function calculateDimensions( + svgWidth: number, + svgHeight: number, + pageDim: PageDimensions, + margin: PDFPageProperties['margin'], + scale: PDFExportScale +): SVGDimensions { + const availableWidth = pageDim.width - (margin?.left || 0) - (margin?.right || 0); + const availableHeight = pageDim.height - (margin?.top || 0) - (margin?.bottom || 0); + + let finalWidth, finalHeight; + if (scale.fitToPage) { + const ratio = Math.min(availableWidth / svgWidth, availableHeight / svgHeight); + finalWidth = svgWidth * ratio; + finalHeight = svgHeight * ratio; + } else { + finalWidth = svgWidth * (scale.zoom || 1); + finalHeight = svgHeight * (scale.zoom || 1); + } + + const x = (margin?.left || 0) + (availableWidth - finalWidth) / 2; + const y = pageDim.height - (margin?.top || 0) - finalHeight; + + return { width: finalWidth, height: finalHeight, x, y }; +} + +async function addSVGToPage( + pdfDoc: PDFDocument, + svg: SVGSVGElement, + dimensions: SVGDimensions, + pageDim: PageDimensions, + backgroundColor?: string +) { + const page = pdfDoc.addPage([pageDim.width, pageDim.height]); + + if (backgroundColor && backgroundColor !== '#ffffff') { + const { r, g, b } = hexToRGB(backgroundColor); + page.drawRectangle({ + x: 0, + y: 0, + width: pageDim.width, + height: pageDim.height, + color: rgb(r/255, g/255, b/255), + }); + } + + const svgImage = await pdfDoc.embedSvg(svg.outerHTML); + + page.drawSvg(svgImage, { + x: dimensions.x, + y: dimensions.y, + width: dimensions.width, + height: dimensions.height, + }); + + return page; +} + +export async function exportToPDF({ + SVG, + scale = { fitToPage: true, zoom: 1 }, + pageProps = {} +}: { + SVG: SVGSVGElement[]; + scale?: PDFExportScale; + pageProps?: PDFPageProperties; +}): Promise { + const margin = pageProps.margin ?? { + left: DEFAULT_MARGIN, + right: DEFAULT_MARGIN, + top: DEFAULT_MARGIN, + bottom: DEFAULT_MARGIN + }; + + const pageDim = pageProps.dimensions ?? getPageDimensions("A4", "portrait"); + const pdfDoc = await PDFDocument.create(); + + for (const svg of SVG) { + const svgWidth = parseFloat(svg.getAttribute('width') || '0'); + const svgHeight = parseFloat(svg.getAttribute('height') || '0'); + + const dimensions = calculateDimensions(svgWidth, svgHeight, pageDim, margin, scale); + + if (!scale.fitToPage && (dimensions.width > pageDim.width || dimensions.height > pageDim.height)) { + // Split oversized SVG into pages + const maxWidth = pageDim.width - margin.left - margin.right; + const maxHeight = pageDim.height - margin.top - margin.bottom; + const splitSVGs = splitSVGIntoPages(svg, maxWidth, maxHeight); + + for (const splitSvg of splitSVGs) { + const splitDimensions = calculateDimensions( + parseFloat(splitSvg.getAttribute('width') || '0'), + parseFloat(splitSvg.getAttribute('height') || '0'), + pageDim, + margin, + { fitToPage: true } + ); + await addSVGToPage(pdfDoc, splitSvg, splitDimensions, pageDim, pageProps.backgroundColor); + } + } else { + await addSVGToPage(pdfDoc, svg, dimensions, pageDim, pageProps.backgroundColor); + } + } + + return pdfDoc.save(); +} + +function hexToRGB(hex: string): { r: number; g: number; b: number } { + const ea = getEA(); + const color = ea.getCM(hex); + if (color) { + return { r: color.red, g: color.green, b: color.blue }; + } + return {r: 255, g: 255, b: 255}; +} + +// Helper function to split SVG into pages if needed +function splitSVGIntoPages( + svg: SVGSVGElement, + maxWidth: number, + maxHeight: number +): SVGSVGElement[] { + const width = parseFloat(svg.getAttribute('width') || '0'); + const height = parseFloat(svg.getAttribute('height') || '0'); + + if (width <= maxWidth && height <= maxHeight) { + return [svg]; + } + + const pages: SVGSVGElement[] = []; + const cols = Math.ceil(width / maxWidth); + const rows = Math.ceil(height / maxHeight); + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const viewBox = `${col * maxWidth} ${row * maxHeight} ${maxWidth} ${maxHeight}`; + const clonedSvg = svg.cloneNode(true) as SVGSVGElement; + clonedSvg.setAttribute('viewBox', viewBox); + clonedSvg.setAttribute('width', String(maxWidth)); + clonedSvg.setAttribute('height', String(maxHeight)); + pages.push(clonedSvg); + } + } + + return pages; +} \ No newline at end of file diff --git a/src/utils/fileUtils.ts b/src/utils/fileUtils.ts index e4e80b2..d81d5b3 100644 --- a/src/utils/fileUtils.ts +++ b/src/utils/fileUtils.ts @@ -290,6 +290,17 @@ export const blobToBase64 = async (blob: Blob): Promise => { return btoa(binary); } +export const arrayBufferToBase64 = (arrayBuffer: ArrayBuffer): string => { + const bytes = new Uint8Array(arrayBuffer); + let binary = ''; + + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + + return btoa(binary); +}; + export const getPDFDoc = async (f: TFile): Promise => { if(typeof window.pdfjsLib === "undefined") await loadPdfJs(); return await window.pdfjsLib.getDocument(EXCALIDRAW_PLUGIN.app.vault.getResourcePath(f)).promise; diff --git a/src/view/ExcalidrawView.ts b/src/view/ExcalidrawView.ts index a3ad7a5..368cebd 100644 --- a/src/view/ExcalidrawView.ts +++ b/src/view/ExcalidrawView.ts @@ -76,6 +76,7 @@ import { getExcalidrawMarkdownHeaderSection, } from "../shared/ExcalidrawData"; import { + arrayBufferToBase64, checkAndCreateFolder, download, getDataURLFromURL, @@ -149,6 +150,7 @@ import { getPDFCropRect } from "../utils/PDFUtils"; import { Position, ViewSemaphores } from "../types/excalidrawViewTypes"; import { DropManager } from "./managers/DropManager"; import { ImageInfo } from "src/types/excalidrawAutomateTypes"; +import { exportToPDF, getPageDimensions, PageOrientation, PageSize } from "src/utils/exportUtils"; const EMBEDDABLE_SEMAPHORE_TIMEOUT = 2000; const PREVENT_RELOAD_TIMEOUT = 2000; @@ -555,6 +557,52 @@ export default class ExcalidrawView extends TextFileView implements HoverParent{ ); } + public async exportPDF( + selectedOnly?: boolean, + pageSize: PageSize = "A4", + orientation: PageOrientation = "portrait" + ): Promise { + if (!this.excalidrawAPI || !this.file) { + return; + } + + const svg = await this.svg( + this.getScene(selectedOnly), + undefined, + false, + true + ); + + if (!svg) { + return; + } + + const pdfArrayBuffer = await exportToPDF({ + SVG: [svg], + scale: { fitToPage: true }, + pageProps: { + dimensions: getPageDimensions(pageSize, orientation), + backgroundColor: this.exportDialog.transparent ? undefined : "#ffffff", + margin: { + top: 20, + left: 20, + right: 20, + bottom: 20 + } + } + }); + + if (!pdfArrayBuffer) { + return; + } + + download( + "data:application/pdf;base64", + arrayBufferToBase64(pdfArrayBuffer), + `${this.file.basename}.pdf` + ); + } + public async png(scene: any, theme?:string, embedScene?: boolean): Promise { (process.env.NODE_ENV === 'development') && DEBUGGING && debug(this.png, "ExcalidrawView.png", scene, theme, embedScene); const ed = this.exportDialog; diff --git a/styles.css b/styles.css index 3851b33..59cf2ff 100644 --- a/styles.css +++ b/styles.css @@ -672,3 +672,21 @@ textarea.excalidraw-wysiwyg, .excalidraw input { background-color: var(--background-modifier-hover); color: var(--text-accent); } + +.excalidraw-release .nav-buttons-container { + display: flex; + margin-bottom: 20px; + border-bottom: 2px solid var(--background-modifier-border); +} + +.excalidraw-release .nav-button { + padding: 8px 16px; + border: none; + background: transparent; + cursor: pointer; +} + +.excalidraw-release .nav-button.is-active { + border-bottom: 2px solid var(--interactive-accent); + margin-bottom: -2px; +} \ No newline at end of file