Skip to content

Commit dbba8f9

Browse files
KomediruzeckiRokt33r
authored andcommitted
Refactor export note to PDF
Refactor code to not use any static HTML file (and thus no javascript, safer for XSS) Refactor code to be more readable Reorder menu items for md, html and pdf Refactor includeFrontMatter fetching Add support for mathml rendering in PDF export (fix escapes) Add support for proper header and footer in PDF - Include front matter option in Markdown - Export section to get header and footer
1 parent 52f41d1 commit dbba8f9

File tree

5 files changed

+237
-232
lines changed

5 files changed

+237
-232
lines changed

src/components/organisms/NotePageToolbar.tsx

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { values, isTagNameValid } from '../../lib/db/utils'
2121
import {
2222
exportNoteAsHtmlFile,
2323
exportNoteAsMarkdownFile,
24-
exportNoteAsPdfFile
24+
exportNoteAsPdfFile,
2525
} from '../../lib/exports'
2626
import { usePreferences } from '../../lib/preferences'
2727
import { usePreviewStyle } from '../../lib/preview'
@@ -179,12 +179,6 @@ const NotePageToolbar = ({
179179
}
180180
openContextMenu({
181181
menuItems: [
182-
{
183-
type: 'normal',
184-
label: 'HTML export',
185-
click: async () =>
186-
await exportNoteAsHtmlFile(note, preferences, pushMessage, previewStyle),
187-
},
188182
{
189183
type: 'normal',
190184
label: 'Markdown export',
@@ -193,11 +187,31 @@ const NotePageToolbar = ({
193187
includeFrontMatter: preferences['markdown.includeFrontMatter'],
194188
}),
195189
},
190+
{
191+
type: 'normal',
192+
label: 'HTML export',
193+
click: async () =>
194+
await exportNoteAsHtmlFile(
195+
note,
196+
preferences,
197+
pushMessage,
198+
previewStyle
199+
),
200+
},
196201
{
197202
type: 'normal',
198203
label: 'PDF export',
199204
click: async () =>
200-
await exportNoteAsPdfFile(note, preferences, pushMessage, previewStyle),
205+
await exportNoteAsPdfFile(
206+
note,
207+
preferences,
208+
pushMessage,
209+
{
210+
includeFrontMatter:
211+
preferences['markdown.includeFrontMatter'],
212+
},
213+
previewStyle
214+
),
201215
},
202216
],
203217
})

src/electron/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
BrowserWindow,
44
BrowserWindowConstructorOptions,
55
Menu,
6+
protocol,
67
} from 'electron'
78
import path from 'path'
89
import url from 'url'
@@ -102,6 +103,11 @@ app.on('activate', () => {
102103

103104
// create main BrowserWindow when electron is ready
104105
app.on('ready', () => {
106+
/* This file protocol registration will be needed from v9.x.x for PDF export feature */
107+
protocol.registerFileProtocol('file', (request, callback) => {
108+
const pathname = decodeURI(request.url.replace('file:///', ''))
109+
callback(pathname)
110+
})
105111
mainWindow = createMainWindow()
106112

107113
app.on('open-url', (_event, url) => {

src/lib/exports.ts

Lines changed: 153 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,23 @@ import React from 'react'
1919
import remarkEmoji from 'remark-emoji'
2020
import rehypeReact from 'rehype-react'
2121
import CodeFence from '../components/atoms/markdown/CodeFence'
22-
import {getGlobalCss, selectTheme } from './styled/styleUtil'
22+
import { getGlobalCss, selectTheme } from './styled/styleUtil'
2323

2424
const sanitizeNoteName = function (rawNoteName: string): string {
2525
return filenamify(rawNoteName.toLowerCase().replace(/\s+/g, '-'))
2626
}
2727

28+
const getFrontMatter = (note: NoteDoc): string => {
29+
return [
30+
'---',
31+
`title: "${note.title}"`,
32+
`tags: "${note.tags.join()}"`,
33+
'---',
34+
'',
35+
'',
36+
].join('\n')
37+
}
38+
2839
const sanitizeSchema = mergeDeepRight(gh, {
2940
attributes: { '*': ['className'] },
3041
})
@@ -33,7 +44,7 @@ export const exportNoteAsHtmlFile = async (
3344
note: NoteDoc,
3445
preferences: Preferences,
3546
pushMessage: (context: any) => any,
36-
previewStyle?: string,
47+
previewStyle?: string
3748
): Promise<void> => {
3849
await unified()
3950
.use(remarkParse)
@@ -88,18 +99,7 @@ export const exportNoteAsMarkdownFile = async (
8899
return
89100
}
90101
let content = file.toString().trim() + '\n'
91-
if (includeFrontMatter) {
92-
content =
93-
[
94-
'---',
95-
`title: "${note.title}"`,
96-
`tags: "${note.tags.join()}"`,
97-
'---',
98-
'',
99-
'',
100-
].join('\n') + content
101-
}
102-
102+
content += includeFrontMatter ? getFrontMatter(note) : ''
103103
downloadString(
104104
content,
105105
`${sanitizeNoteName(note.title)}.md`,
@@ -118,120 +118,180 @@ const schema = mergeDeepRight(gh, {
118118
},
119119
})
120120

121-
const getCssLinks = (preferences : Preferences) => {
122-
let cssHrefs : string[] = []
123-
const app = window.require('electron').remote.app;
121+
const fetchCorrectMdThemeName = (theme: string) => {
122+
return theme === 'solarized-dark' ? 'solarized' : theme
123+
}
124+
125+
const getCssLinks = (preferences: Preferences) => {
126+
const cssHrefs: string[] = []
127+
const app = window.require('electron').remote.app
124128
const isProd = app.isPackaged
125-
const parentPathTheme = app.getAppPath() + ((isProd === true) ? "/compiled/app" : "/../node_modules")
126-
let editorTheme = preferences['editor.theme']
127-
let markdownCodeBlockTheme = preferences['markdown.codeBlockTheme']
128-
if (editorTheme === 'solarized-dark') {
129-
editorTheme = 'solarized'
130-
}
129+
const pathPrefix = 'file://' + app.getAppPath()
130+
const parentPathTheme =
131+
pathPrefix + (isProd === true ? '/compiled/app' : '/../node_modules')
132+
const editorTheme = fetchCorrectMdThemeName(preferences['editor.theme'])
133+
const markdownCodeBlockTheme = fetchCorrectMdThemeName(
134+
preferences['markdown.codeBlockTheme']
135+
)
136+
131137
const editorThemePath = `${parentPathTheme}/codemirror/theme/${editorTheme}.css`
132138
cssHrefs.push(editorThemePath)
133139
if (editorTheme !== markdownCodeBlockTheme) {
134140
if (markdownCodeBlockTheme) {
135-
if (markdownCodeBlockTheme === 'solarized-dark') {
136-
markdownCodeBlockTheme = 'solarized'
137-
}
138141
const markdownCodeBlockThemePath = `${parentPathTheme}/codemirror/theme/${markdownCodeBlockTheme}.css`
139142
cssHrefs.push(markdownCodeBlockThemePath)
140143
}
141144
}
142145
return cssHrefs
143146
}
144147

148+
const cssStyleLinkGenerator = (href: string) =>
149+
`<link rel="stylesheet" href="${href}" type="text/css"/>`
150+
151+
const getPrintStyle = () => `
152+
<style media="print">
153+
pre code {
154+
white-space: pre-wrap;
155+
}
156+
</style>
157+
`
158+
159+
const generatePrintToPdfHTML = (
160+
markdownHTML: string | Uint8Array,
161+
preferences: Preferences,
162+
previewStyle?: string
163+
) => {
164+
const cssHrefs: string[] = getCssLinks(preferences)
165+
const generalThemeName = preferences['general.theme']
166+
const cssLinks = cssHrefs
167+
.map((href) => cssStyleLinkGenerator(href))
168+
.join('\n')
169+
const appThemeCss = getGlobalCss(selectTheme(generalThemeName))
170+
const previewStyleCssEl = previewStyle ? `<style>${previewStyle}</style>` : ''
171+
const appThemeCssEl = appThemeCss ? `<style>${appThemeCss}</style>` : ''
172+
173+
return `<!DOCTYPE html>
174+
<html lang="en">
175+
<head>
176+
<meta charset="UTF-8" />
177+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"/>
178+
<!-- Preview styles -->
179+
${appThemeCssEl}
180+
${previewStyleCssEl}
181+
182+
<!-- Link tag styles -->
183+
${cssStyleLinkGenerator(
184+
'https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css'
185+
)}
186+
${cssLinks}
187+
188+
<!-- Print Styles -->
189+
${getPrintStyle()}
190+
</head>
191+
<body>
192+
<div class="${generalThemeName}">
193+
${markdownHTML}
194+
</div>
195+
</body>
196+
</html>
197+
`
198+
}
199+
145200
export const exportNoteAsPdfFile = async (
146201
note: NoteDoc,
147202
preferences: Preferences,
148203
pushMessage: (context: any) => any,
149-
previewStyle?: string,
204+
{ includeFrontMatter }: { includeFrontMatter: boolean },
205+
previewStyle?: string
150206
): Promise<void> => {
151207
await unified()
152208
.use(remarkParse)
153-
.use(remarkStringify)
209+
.use(remarkEmoji, { emoticon: false })
210+
.use([remarkRehype, { allowDangerousHTML: true }])
211+
.use(rehypeRaw)
212+
.use(rehypeSanitize, schema)
213+
.use(remarkMath)
214+
.use(rehypeCodeMirror, {
215+
ignoreMissing: true,
216+
theme: preferences['markdown.codeBlockTheme'],
217+
})
218+
.use(rehypeKatex, { output: 'htmlAndMathml' })
219+
.use(rehypeReact, {
220+
createElement: React.createElement,
221+
components: {
222+
pre: CodeFence,
223+
},
224+
})
225+
.use(rehypeStringify)
154226
.process(note.content, (err, file) => {
155227
if (err != null) {
156228
pushMessage({
157229
title: 'Note processing failed',
158230
description: 'Please check markdown syntax and try again later.',
159231
})
232+
return
160233
}
161-
let content = file.toString().trim() + '\n'
162-
const markdownProcessor = unified()
163-
.use(remarkParse)
164-
.use(remarkEmoji, { emoticon: false })
165-
.use([remarkRehype, { allowDangerousHTML: true }])
166-
.use(rehypeRaw)
167-
.use(rehypeSanitize, schema)
168-
.use(remarkMath)
169-
.use(rehypeCodeMirror, {
170-
ignoreMissing: true,
171-
theme: preferences['markdown.codeBlockTheme'],
172-
})
173-
.use(rehypeKatex)
174-
.use(rehypeReact, {
175-
createElement: React.createElement,
176-
components: {
177-
pre: CodeFence,
178-
},
179-
})
180-
.use(rehypeStringify)
181-
182-
// Process note-markdown content into react string
183-
let resultObj = markdownProcessor.processSync(content)
184234

185-
// Create new window (hidden)
235+
const stringifiedMdContent = file.toString().trim() + '\n'
186236
const { BrowserWindow } = window.require('electron').remote
187-
const app = window.require('electron').remote.app;
188-
const ipcMain = window.require('electron').remote.ipcMain;
189-
const isProd = app.isPackaged === true
190-
const parentPathHTML = app.getAppPath() + ((isProd === true) ? "/compiled/app/static" : "/../static")
191237
const windowOptions = {
192-
webPreferences: { nodeIntegration: true, webSecurity: false },
193-
show: false
238+
webPreferences: {
239+
nodeIntegration: true,
240+
webSecurity: false,
241+
javascript: false,
242+
},
243+
show: false,
194244
}
195245
const win = new BrowserWindow(windowOptions)
196-
197-
// Load HTML for rendering react string for markdown content created earlier
198-
win.loadFile(`${parentPathHTML}/render_md_to_pdf.html`)
246+
const htmlStr = generatePrintToPdfHTML(
247+
stringifiedMdContent,
248+
preferences,
249+
previewStyle
250+
)
251+
const encodedStr = encodeURIComponent(htmlStr)
252+
win.loadURL('data:text/html;charset=UTF-8,' + encodedStr)
199253
win.webContents.on('did-finish-load', function () {
200-
// Fetch needed CSS styles
201-
const generalThemeName = preferences['general.theme']
202-
const appThemeCss = getGlobalCss(selectTheme(generalThemeName))
203-
let cssHrefs: string[] = getCssLinks(preferences)
204-
if (previewStyle) {
205-
win.webContents.insertCSS(previewStyle)
206-
win.webContents.insertCSS(appThemeCss)
254+
// Enable when newer version of electron is available
255+
const tagsStr =
256+
note.tags.length > 0 ? `, tags: [${note.tags.join(' ')}]` : ''
257+
const headerFooter: Record<string, string> = {
258+
title: `${note.title}${tagsStr}`,
259+
url: `file://${sanitizeNoteName(note.title)}.pdf`,
207260
}
208-
// Do not show the window while exporting (for debugging purposes only)
209-
// win.show()
210-
setTimeout(() => {
211-
// Send message to window to render the markdown content with the applied css and theme class
212-
win.webContents.send('render-markdown-to-pdf', resultObj.contents, cssHrefs, generalThemeName)
213-
}, 500)
214-
})
215-
216-
// When PDF rendered, notify me (doing this only once removes it for further messages)
217-
// this way no need to remove it manually after receving the message
218-
// another click on PDF export would once again bind the current note markdown to HTML rendered page
219-
ipcMain.once('pdf-notify-export-data', (_: object, data: string, error: any) => {
220-
if (data && !error) {
221-
// We got the PDF offer user to save it
222-
const pdfName = `${sanitizeNoteName(note.title)}.pdf`
223-
const pdfBlob = new Blob([data], {
224-
type: "application/pdf" // application/octet-stream
261+
const printOpts = {
262+
// Needed for codemirorr themes (backgrounds)
263+
printBackground: true,
264+
// Enable margins if header footer is printed
265+
// No margins 1, default margins 0, 2 - minimum margins
266+
marginsType: includeFrontMatter ? 0 : 1,
267+
pageSize: 'A4', // This could be chosen by user,
268+
headerFooter: includeFrontMatter ? headerFooter : undefined,
269+
}
270+
win.webContents
271+
.printToPDF(printOpts)
272+
.then((data) => {
273+
if (data) {
274+
// We got the PDF - offer the user to save it
275+
const pdfName = `${sanitizeNoteName(note.title)}.pdf`
276+
const pdfBlob = new Blob([data], {
277+
type: 'application/pdf', // application/octet-stream
278+
})
279+
downloadBlob(pdfBlob, pdfName)
280+
} else {
281+
pushMessage({
282+
title: 'PDF export failed',
283+
description: 'Please try again later. Reason: Unknown',
284+
})
285+
}
286+
// Destroy window (not shown but disposes it)
287+
win.destroy()
225288
})
226-
downloadBlob(pdfBlob, pdfName)
227-
} else {
228-
pushMessage({
229-
title: 'PDF export failed',
230-
description: 'Please try again later.' + " Error: " + JSON.stringify(error),
289+
.catch((err) => {
290+
pushMessage({
291+
title: 'PDF export failed',
292+
description: 'Please try again later.' + (err ? err : 'Unknown'),
293+
})
231294
})
232-
}
233-
// Close window (it's hidden anyway, but dispose it)
234-
win.close()
235295
})
236296
return
237297
})

0 commit comments

Comments
 (0)