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