diff --git a/manifest-beta.json b/manifest-beta.json index afc0af4..7c55b98 100644 --- a/manifest-beta.json +++ b/manifest-beta.json @@ -1,7 +1,7 @@ { "id": "obsidian-excalidraw-plugin", "name": "Excalidraw", - "version": "2.8.0-beta-1", + "version": "2.8.0-beta-2", "minAppVersion": "1.1.6", "description": "An Obsidian plugin to edit and view Excalidraw drawings", "author": "Zsolt Viczian", diff --git a/src/core/settings.ts b/src/core/settings.ts index 266bae1..fbb4f5a 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -506,7 +506,8 @@ export const DEFAULT_SETTINGS: ExcalidrawSettings = { paperColor: "white", customPaperColor: "#ffffff", alignment: "center", - margin: "normal" + margin: "normal", + exportDPI: 300, }, }; diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts index 0da654d..d569f0c 100644 --- a/src/lang/locale/en.ts +++ b/src/lang/locale/en.ts @@ -1048,6 +1048,7 @@ FILENAME_HEAD: "Filename", EXPORTDIALOG_PAGE_ORIENTATION: "Orientation", EXPORTDIALOG_ORIENTATION_PORTRAIT: "Portrait", EXPORTDIALOG_ORIENTATION_LANDSCAPE: "Landscape", + EXPORTDIALOG_PDF_DPI: "Image quality [DPI]", EXPORTDIALOG_PDF_FIT_TO_PAGE: "Page Fitting", EXPORTDIALOG_PDF_FIT_OPTION: "Fit to page", EXPORTDIALOG_PDF_FIT_2_OPTION: "Fit to 2-pages", @@ -1085,4 +1086,8 @@ FILENAME_HEAD: "Filename", EXPORTDIALOG_SVGTOCLIPBOARD : "SVG to Clipboard", EXPORTDIALOG_PDF: "Export PDF", EXPORTDIALOG_PDFTOVAULT: "PDF to Vault", + + EXPORTDIALOG_PDF_PROGRESS_NOTICE: "Exporting page", + EXPORTDIALOG_PDF_PROGRESS_IMAGE: "of image", + EXPORTDIALOG_PDF_PROGRESS_DONE: "Export complete", }; diff --git a/src/shared/Dialogs/ExportDialog.ts b/src/shared/Dialogs/ExportDialog.ts index 90cbfcd..031f72c 100644 --- a/src/shared/Dialogs/ExportDialog.ts +++ b/src/shared/Dialogs/ExportDialog.ts @@ -43,6 +43,7 @@ export class ExportDialog extends Modal { public customPaperColor: string = "#ffffff"; public alignment: PDFPageAlignment = "center"; public margin: PDFPageMarginString = "normal"; + public exportDPI: number = 300; constructor( private plugin: ExcalidrawPlugin, @@ -68,6 +69,7 @@ export class ExportDialog extends Modal { this.customPaperColor = plugin.settings.pdfSettings.customPaperColor; this.alignment = plugin.settings.pdfSettings.alignment; this.margin = plugin.settings.pdfSettings.margin; + this.exportDPI = plugin.settings.pdfSettings.exportDPI ?? 300; this.saveSettings = false; } @@ -276,7 +278,8 @@ export class ExportDialog extends Modal { paperColor: this.paperColor, customPaperColor: this.customPaperColor, alignment: this.alignment, - margin: this.margin + margin: this.margin, + exportDPI: this.exportDPI, }; new PDFExportSettingsComponent( @@ -290,6 +293,7 @@ export class ExportDialog extends Modal { this.customPaperColor = pdfSettings.customPaperColor; this.alignment = pdfSettings.alignment; this.margin = pdfSettings.margin; + this.exportDPI = pdfSettings.exportDPI ?? 300; } ).render(); } @@ -378,7 +382,8 @@ export class ExportDialog extends Modal { paperColor: this.paperColor, customPaperColor: this.customPaperColor, alignment: this.alignment, - margin: this.margin + margin: this.margin, + exportDPI: this.exportDPI, }; await this.plugin.saveSettings(); new Notice(t("EXPORTDIALOG_SAVE_CONFIRMATION")); diff --git a/src/shared/Dialogs/PDFExportSettingsComponent.ts b/src/shared/Dialogs/PDFExportSettingsComponent.ts index 6d31171..88ff716 100644 --- a/src/shared/Dialogs/PDFExportSettingsComponent.ts +++ b/src/shared/Dialogs/PDFExportSettingsComponent.ts @@ -10,6 +10,7 @@ export interface PDFExportSettings { customPaperColor: string; alignment: PDFPageAlignment; margin: PDFPageMarginString; + exportDPI: number; } export class PDFExportSettingsComponent { @@ -55,6 +56,23 @@ export class PDFExportSettingsComponent { }) ); + new Setting(this.contentEl) + .setName(t("EXPORTDIALOG_PDF_DPI")) + .addDropdown(dropdown => + dropdown + .addOptions({ + "150": "150", + "300": "300", + "600": "600", + "1200": "1200" + }) + .setValue(`${this.settings.exportDPI}`) + .onChange(value => { + this.settings.exportDPI = parseInt(value); + this.update(); + }) + ); + new Setting(this.contentEl) .setName(t("EXPORTDIALOG_PDF_FIT_TO_PAGE")) .addDropdown(dropdown => diff --git a/src/shared/Dialogs/SuggesterInfo.ts b/src/shared/Dialogs/SuggesterInfo.ts index aa692dd..96051fb 100644 --- a/src/shared/Dialogs/SuggesterInfo.ts +++ b/src/shared/Dialogs/SuggesterInfo.ts @@ -245,6 +245,7 @@ export const EXCALIDRAW_AUTOMATE_INFO: SuggesterInfo[] = [ "@property {string} [backgroundColor] - The background color of the PDF pages.\n" + "@property {PDFMargin} margin - The margins of the PDF pages.\n" + "@property {PDFPageAlignment} alignment - The alignment of the SVG on the PDF pages.\n" + + "@property {number} exportDPI - The DPI of the exported PDF (150/300/600/1200).\n" + "\n" + "@example\n" + "const pdfData = await createPDF({\n" + @@ -255,6 +256,7 @@ export const EXCALIDRAW_AUTOMATE_INFO: SuggesterInfo[] = [ " backgroundColor: \"#ffffff\",\n" + " margin: { left: 20, right: 20, top: 20, bottom: 20 },\n" + " alignment: \"center\"\n" + + " exportDPI: 300\n" + " }\n" + "});", }, diff --git a/src/shared/ExcalidrawAutomate.ts b/src/shared/ExcalidrawAutomate.ts index 2d49e7d..85f164f 100644 --- a/src/shared/ExcalidrawAutomate.ts +++ b/src/shared/ExcalidrawAutomate.ts @@ -967,7 +967,8 @@ export class ExcalidrawAutomate { * dimensions: { width: 595.28, height: 841.89 }, * backgroundColor: "#ffffff", * margin: { left: 20, right: 20, top: 20, bottom: 20 }, - * alignment: "center" + * alignment: "center", + * exportDPI: 300, * } * }); */ @@ -984,6 +985,7 @@ export class ExcalidrawAutomate { pageProps = { alignment: this.plugin.settings.pdfSettings.alignment, margin: getMarginValue(this.plugin.settings.pdfSettings.margin), + exportDPI: this.plugin.settings.pdfSettings.exportDPI ?? 300, }; } diff --git a/src/utils/exportUtils.ts b/src/utils/exportUtils.ts index 194bbc9..0bf3aa5 100644 --- a/src/utils/exportUtils.ts +++ b/src/utils/exportUtils.ts @@ -1,9 +1,10 @@ import { PDFDocument, rgb } from '@cantoo/pdf-lib'; +import exp from 'constants'; +import { Notice } from 'obsidian'; import { getEA } from 'src/core'; +import { t } from 'src/lang/helpers'; -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"; @@ -25,6 +26,7 @@ export interface PDFPageProperties { backgroundColor?: string; margin: PDFMargin; alignment: PDFPageAlignment; + exportDPI: number; } export interface PageDimensions { @@ -126,10 +128,12 @@ function calculateDimensions( pageDim: PageDimensions, margin: PDFPageProperties['margin'], scale: PDFExportScale, - alignment: PDFPageAlignment + alignment: PDFPageAlignment, + exportDPI: number, ): SVGDimensions[] { - const pdfWidth = svgWidth * SVG_TO_PDF_SCALE; - const pdfHeight = svgHeight * SVG_TO_PDF_SCALE; + const svg_to_pdf_scale = PDF_DPI / exportDPI; + 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; @@ -206,10 +210,10 @@ function calculateDimensions( height: tileHeight, x: margin.left, y: margin.top, - 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) + 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) }); } } @@ -223,12 +227,12 @@ async function addSVGToPage( svg: SVGSVGElement, dimensions: SVGDimensions, pageDim: PageDimensions, - backgroundColor?: string + pageProps: PDFPageProperties ) { const page = pdfDoc.addPage([pageDim.width, pageDim.height]); - if (backgroundColor && backgroundColor !== '#ffffff') { - const { r, g, b } = hexToRGB(backgroundColor); + if (pageProps.backgroundColor && pageProps.backgroundColor !== '#ffffff') { + const { r, g, b } = hexToRGB(pageProps.backgroundColor); page.drawRectangle({ x: 0, y: 0, @@ -238,23 +242,20 @@ async function addSVGToPage( }); } - // Clone and modify SVG for tiling if needed - let svgToEmbed = svg; - if (dimensions.sourceX !== undefined) { - svgToEmbed = svg.cloneNode(true) as SVGSVGElement; - const viewBox = `${dimensions.sourceX} ${dimensions.sourceY} ${dimensions.sourceWidth} ${dimensions.sourceHeight}`; - svgToEmbed.setAttribute('viewBox', viewBox); - svgToEmbed.setAttribute('width', String(dimensions.sourceWidth)); - svgToEmbed.setAttribute('height', String(dimensions.sourceHeight)); - } - - svgToEmbed = await preprocessSVGForPDFLib(svgToEmbed); - const svgImage = await pdfDoc.embedSvg(svgToEmbed.outerHTML); + // Render SVG to canvas with specified DPI + const canvas = await renderSVGToCanvas(svg, dimensions, pageProps.exportDPI); + + // Convert canvas to PNG + const pngData = canvas.toDataURL('image/png'); + + // Embed the PNG in the PDF + const image = await pdfDoc.embedPng(pngData); // Adjust y-coordinate to account for PDF coordinate system - const adjustedY = pageDim.height - dimensions.y; + const adjustedY = pageDim.height - dimensions.y - dimensions.height; - page.drawSvg(svgImage, { + // Draw the image + page.drawImage(image, { x: dimensions.x, y: adjustedY, width: dimensions.width, @@ -264,62 +265,45 @@ async function addSVGToPage( return page; } -async function convertImageToDataURL(src: string): Promise { - return new Promise((resolve, reject) => { - const img = new Image(); - img.crossOrigin = "anonymous"; - img.onload = () => { - const canvas = document.createElement('canvas'); - canvas.width = img.width; - canvas.height = img.height; - const ctx = canvas.getContext('2d'); - ctx.drawImage(img, 0, 0); - resolve(canvas.toDataURL('image/png')); - }; - img.onerror = () => reject(new Error('Failed to load image')); - img.src = src; - }); -} - -async function preprocessSVGForPDFLib(svg: SVGSVGElement): Promise { - const clone = svg.cloneNode(true) as SVGSVGElement; +async function renderSVGToCanvas( + svg: SVGSVGElement, + dimensions: SVGDimensions, + exportDPI: number = 300, +): Promise { + const canvas = document.createElement('canvas'); + const scale = exportDPI / PDF_DPI; - // Convert all images to PNG format - const images = clone.querySelectorAll('image'); - await Promise.all(Array.from(images).map(async (img) => { - const href = img.getAttribute('href') || img.getAttribute('xlink:href'); - if (href && href.startsWith('data:image/')) { - try { - const pngDataUrl = await convertImageToDataURL(href); - img.setAttribute('href', pngDataUrl); - if (img.hasAttribute('xlink:href')) { - img.setAttribute('xlink:href', pngDataUrl); - } - } catch (error) { - console.error('Failed to convert image:', error); - } - } - })); + canvas.width = dimensions.width * scale; + canvas.height = dimensions.height * scale; - // Convert symbols with images - const symbols = clone.querySelectorAll('symbol'); - for (const symbol of Array.from(symbols)) { - const image = symbol.querySelector('image'); - if (image) { - const uses = clone.querySelectorAll(`use[href="#${symbol.id}"]`); - for (const use of Array.from(uses)) { - const newImage = image.cloneNode(true) as SVGImageElement; - newImage.setAttribute('width', use.getAttribute('width') || '100%'); - newImage.setAttribute('height', use.getAttribute('height') || '100%'); - newImage.setAttribute('x', use.getAttribute('x') || '0'); - newImage.setAttribute('y', use.getAttribute('y') || '0'); - use.parentNode.replaceChild(newImage, use); - } - } - symbol.remove(); + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Failed to get canvas context'); + + let svgToRender = svg; + if (dimensions.sourceX !== undefined) { + svgToRender = svg.cloneNode(true) as SVGSVGElement; + const viewBox = `${dimensions.sourceX} ${dimensions.sourceY} ${dimensions.sourceWidth} ${dimensions.sourceHeight}`; + svgToRender.setAttribute('viewBox', viewBox); + svgToRender.setAttribute('width', String(dimensions.sourceWidth)); + svgToRender.setAttribute('height', String(dimensions.sourceHeight)); } - return clone; + const svgBlob = new Blob([svgToRender.outerHTML], { type: 'image/svg+xml;charset=utf-8' }); + const blobUrl = URL.createObjectURL(svgBlob); + + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + URL.revokeObjectURL(blobUrl); + resolve(canvas); + }; + img.onerror = () => { + URL.revokeObjectURL(blobUrl); + reject(new Error('Failed to load SVG')); + }; + img.src = blobUrl; + }); } export async function exportToPDF({ @@ -331,8 +315,15 @@ export async function exportToPDF({ scale: PDFExportScale; pageProps: PDFPageProperties; }): Promise { + const pdfDoc = await PDFDocument.create(); + const msg = t('EXPORTDIALOG_PDF_PROGRESS_NOTICE'); + const imgmsg = t('EXPORTDIALOG_PDF_PROGRESS_IMAGE'); + + let notice = new Notice(msg, 0); + + let j=1; for (const svg of SVG) { const svgWidth = parseFloat(svg.getAttribute('width') || '0'); const svgHeight = parseFloat(svg.getAttribute('height') || '0'); @@ -343,14 +334,30 @@ export async function exportToPDF({ pageProps.dimensions, pageProps.margin, scale, - pageProps.alignment + pageProps.alignment, + pageProps.exportDPI ); + let i=1; for (const dim of dimensions) { - await addSVGToPage(pdfDoc, svg, dim, pageProps.dimensions, pageProps.backgroundColor); + //@ts-ignore + if(notice.containerEl.parentElement) { + notice.setMessage(`${msg} ${i++}/${dimensions.length}${SVG.length>1?` ${imgmsg} ${j}`:""}`); + } else { + notice = new Notice(`${msg} ${i++}/${dimensions.length}${SVG.length>1?` ${imgmsg} ${j}`:""}`, 0); + } + await addSVGToPage(pdfDoc, svg, dim, pageProps.dimensions, pageProps); } + j++; } + //@ts-ignore + if(notice.containerEl.parentElement) { + notice.setMessage(t('EXPORTDIALOG_PDF_PROGRESS_DONE')); + setTimeout(() => notice.hide(), 4000); + } else { + new Notice(t('EXPORTDIALOG_PDF_PROGRESS_DONE')); + } return pdfDoc.save(); } diff --git a/src/view/ExcalidrawView.ts b/src/view/ExcalidrawView.ts index 7f9bde0..e2e46aa 100644 --- a/src/view/ExcalidrawView.ts +++ b/src/view/ExcalidrawView.ts @@ -598,6 +598,7 @@ export default class ExcalidrawView extends TextFileView implements HoverParent{ backgroundColor: this.exportDialog.getPaperColor(), margin: getMarginValue(this.exportDialog.margin), alignment: this.exportDialog.alignment, + exportDPI: this.exportDialog.exportDPI, } });