Skip to content

Commit 52f41d1

Browse files
KomediruzeckiRokt33r
authored andcommitted
Add export note to PDF (#243)
Add export note markdown to PDF in note page toolbar Add render_md_to_pdf.html file for rendering markdown to HTML first Add logic for printing to PDF via webContents.printToPdf Refactor note title sanitization to shared function
1 parent e02db39 commit 52f41d1

File tree

4 files changed

+329
-11
lines changed

4 files changed

+329
-11
lines changed

src/components/organisms/NotePageToolbar.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ import { values, isTagNameValid } from '../../lib/db/utils'
2121
import {
2222
exportNoteAsHtmlFile,
2323
exportNoteAsMarkdownFile,
24+
exportNoteAsPdfFile
2425
} from '../../lib/exports'
2526
import { usePreferences } from '../../lib/preferences'
2627
import { usePreviewStyle } from '../../lib/preview'
2728
import { useTranslation } from 'react-i18next'
2829
import { useDb } from '../../lib/db'
2930
import { useRouteParams } from '../../lib/routeParams'
3031
import { useAnalytics, analyticsEvents } from '../../lib/analytics'
32+
import { useToast } from '../../lib/toast'
3133
import TopbarSwitchSelector from '../atoms/TopbarSwitchSelector'
3234
import { openContextMenu } from '../../lib/electronOnly'
3335

@@ -76,6 +78,7 @@ const NotePageToolbar = ({
7678
const { previewStyle } = usePreviewStyle()
7779
const { generalStatus } = useGeneralStatus()
7880
const { noteViewMode, preferredEditingViewMode } = generalStatus
81+
const { pushMessage } = useToast()
7982

8083
const storageId = storage.id
8184
const storageName = storage.name
@@ -180,20 +183,26 @@ const NotePageToolbar = ({
180183
type: 'normal',
181184
label: 'HTML export',
182185
click: async () =>
183-
await exportNoteAsHtmlFile(note, preferences, previewStyle),
186+
await exportNoteAsHtmlFile(note, preferences, pushMessage, previewStyle),
184187
},
185188
{
186189
type: 'normal',
187190
label: 'Markdown export',
188191
click: async () =>
189-
await exportNoteAsMarkdownFile(note, {
192+
await exportNoteAsMarkdownFile(note, pushMessage, {
190193
includeFrontMatter: preferences['markdown.includeFrontMatter'],
191194
}),
192195
},
196+
{
197+
type: 'normal',
198+
label: 'PDF export',
199+
click: async () =>
200+
await exportNoteAsPdfFile(note, preferences, pushMessage, previewStyle),
201+
},
193202
],
194203
})
195204
},
196-
[note, preferences, previewStyle]
205+
[note, preferences, previewStyle, pushMessage]
197206
)
198207

199208
const routeParams = useRouteParams()

src/lib/exports.ts

Lines changed: 151 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,19 @@ import rehypeKatex from 'rehype-katex'
1111
import { mergeDeepRight } from 'ramda'
1212
import gh from 'hast-util-sanitize/lib/github.json'
1313
import { rehypeCodeMirror } from './../components/atoms/MarkdownPreviewer'
14-
import { downloadString } from './download'
14+
import { downloadBlob, downloadString } from './download'
1515
import { NoteDoc } from './db/types'
1616
import { Preferences } from './preferences'
1717
import { filenamify } from './string'
18+
import React from 'react'
19+
import remarkEmoji from 'remark-emoji'
20+
import rehypeReact from 'rehype-react'
21+
import CodeFence from '../components/atoms/markdown/CodeFence'
22+
import {getGlobalCss, selectTheme } from './styled/styleUtil'
23+
24+
const sanitizeNoteName = function (rawNoteName: string): string {
25+
return filenamify(rawNoteName.toLowerCase().replace(/\s+/g, '-'))
26+
}
1827

1928
const sanitizeSchema = mergeDeepRight(gh, {
2029
attributes: { '*': ['className'] },
@@ -23,7 +32,8 @@ const sanitizeSchema = mergeDeepRight(gh, {
2332
export const exportNoteAsHtmlFile = async (
2433
note: NoteDoc,
2534
preferences: Preferences,
26-
previewStyle?: string
35+
pushMessage: (context: any) => any,
36+
previewStyle?: string,
2737
): Promise<void> => {
2838
await unified()
2939
.use(remarkParse)
@@ -45,14 +55,16 @@ export const exportNoteAsHtmlFile = async (
4555
.use(rehypeKatex)
4656
.process(note.content, (err, file) => {
4757
if (err != null) {
48-
/* TODO: Toast error */
49-
console.error(err)
58+
pushMessage({
59+
title: 'Note processing failed',
60+
description: 'Please check markdown syntax and try again later.',
61+
})
5062
return
5163
}
5264

5365
downloadString(
5466
file.toString(),
55-
`${filenamify(note.title.toLowerCase().replace(/\s+/g, '-'))}.html`,
67+
`${sanitizeNoteName(note.title)}.html`,
5668
'text/html'
5769
)
5870
return
@@ -61,15 +73,18 @@ export const exportNoteAsHtmlFile = async (
6173

6274
export const exportNoteAsMarkdownFile = async (
6375
note: NoteDoc,
76+
pushMessage: (context: any) => any,
6477
{ includeFrontMatter }: { includeFrontMatter: boolean }
6578
): Promise<void> => {
6679
await unified()
6780
.use(remarkParse)
6881
.use(remarkStringify)
6982
.process(note.content, (err, file) => {
7083
if (err != null) {
71-
/* TODO: Toast error */
72-
console.error(err)
84+
pushMessage({
85+
title: 'Note processing failed',
86+
description: 'Please check markdown syntax and try again later.',
87+
})
7388
return
7489
}
7590
let content = file.toString().trim() + '\n'
@@ -87,10 +102,138 @@ export const exportNoteAsMarkdownFile = async (
87102

88103
downloadString(
89104
content,
90-
`${filenamify(note.title.toLowerCase().replace(/\s+/g, '-'))}.md`,
105+
`${sanitizeNoteName(note.title)}.md`,
91106
'text/markdown'
92107
)
93108
return
94109
})
95110
return
96111
}
112+
113+
const schema = mergeDeepRight(gh, {
114+
attributes: {
115+
'*': [...gh.attributes['*'], 'className', 'align'],
116+
input: [...gh.attributes.input, 'checked'],
117+
pre: ['dataRaw'],
118+
},
119+
})
120+
121+
const getCssLinks = (preferences : Preferences) => {
122+
let cssHrefs : string[] = []
123+
const app = window.require('electron').remote.app;
124+
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+
}
131+
const editorThemePath = `${parentPathTheme}/codemirror/theme/${editorTheme}.css`
132+
cssHrefs.push(editorThemePath)
133+
if (editorTheme !== markdownCodeBlockTheme) {
134+
if (markdownCodeBlockTheme) {
135+
if (markdownCodeBlockTheme === 'solarized-dark') {
136+
markdownCodeBlockTheme = 'solarized'
137+
}
138+
const markdownCodeBlockThemePath = `${parentPathTheme}/codemirror/theme/${markdownCodeBlockTheme}.css`
139+
cssHrefs.push(markdownCodeBlockThemePath)
140+
}
141+
}
142+
return cssHrefs
143+
}
144+
145+
export const exportNoteAsPdfFile = async (
146+
note: NoteDoc,
147+
preferences: Preferences,
148+
pushMessage: (context: any) => any,
149+
previewStyle?: string,
150+
): Promise<void> => {
151+
await unified()
152+
.use(remarkParse)
153+
.use(remarkStringify)
154+
.process(note.content, (err, file) => {
155+
if (err != null) {
156+
pushMessage({
157+
title: 'Note processing failed',
158+
description: 'Please check markdown syntax and try again later.',
159+
})
160+
}
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)
184+
185+
// Create new window (hidden)
186+
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")
191+
const windowOptions = {
192+
webPreferences: { nodeIntegration: true, webSecurity: false },
193+
show: false
194+
}
195+
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`)
199+
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)
207+
}
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
225+
})
226+
downloadBlob(pdfBlob, pdfName)
227+
} else {
228+
pushMessage({
229+
title: 'PDF export failed',
230+
description: 'Please try again later.' + " Error: " + JSON.stringify(error),
231+
})
232+
}
233+
// Close window (it's hidden anyway, but dispose it)
234+
win.close()
235+
})
236+
return
237+
})
238+
return
239+
}

src/lib/styled/styleUtil.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { BaseTheme } from './BaseTheme'
2+
import { lightTheme } from '../../themes/light'
3+
import { legacyTheme } from '.'
4+
import { darkTheme } from '../../themes/dark'
5+
import { sepiaTheme } from '../../themes/sepia'
6+
import { solarizedDarkTheme } from '../../themes/solarizedDark'
7+
8+
export const getGlobalCss = (theme: BaseTheme) => `body {
9+
margin: 10px;
10+
background-color: ${theme.backgroundColor};
11+
color: ${theme.textColor};
12+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Fira sans', Roboto, Helvetica,
13+
Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
14+
font-size: 15px;
15+
font-weight: 400;
16+
}
17+
18+
* {
19+
box-sizing: border-box;
20+
scrollbar-color: rgba(0, 0, 0, 0.12) #efe8d6; /* scrollbar style for firefox */
21+
}
22+
23+
*:focus {
24+
outline: none;
25+
}
26+
27+
input, button {
28+
font-size: 15px;
29+
}
30+
31+
h1,h2,h3,h4,h5,h6 {
32+
font-weight: 500;
33+
}
34+
35+
b, strong {
36+
font-weight: 700;
37+
}
38+
39+
button,
40+
input {
41+
padding: 0;
42+
outline: none;
43+
}
44+
45+
a {
46+
color: inherit;
47+
}
48+
49+
th,
50+
td {
51+
background-color: ${theme.backgroundColor};
52+
}
53+
54+
/* total width */
55+
::-webkit-scrollbar {
56+
background-color: transparent;
57+
width:12px;
58+
}
59+
60+
/* background of the scrollbar except button or resizer */
61+
::-webkit-scrollbar-track {
62+
background-color: ${theme.scrollBarTrackColor};
63+
}
64+
65+
/* scrollbar itself */
66+
::-webkit-scrollbar-thumb {
67+
background-color: ${theme.scrollBarThumbColor};
68+
}
69+
70+
/* set button(top and bottom of the scrollbar) */
71+
::-webkit-scrollbar-button {
72+
display: none
73+
}
74+
`
75+
76+
export function selectTheme(theme: string): BaseTheme {
77+
switch (theme) {
78+
case 'legacy':
79+
return legacyTheme
80+
case 'light':
81+
return lightTheme
82+
case 'sepia':
83+
return sepiaTheme
84+
case 'solarizedDark':
85+
return solarizedDarkTheme
86+
case 'dark':
87+
default:
88+
return darkTheme
89+
}
90+
};

0 commit comments

Comments
 (0)