Export with options (#1511)

* Export with options

* Fix function names and add documentation

* Narrow scrollbar
This commit is contained in:
Felix Häusler 2019-10-25 03:03:33 +02:00 committed by Ran Luo
parent e18ad566d5
commit e5dc8f1540
21 changed files with 1203 additions and 82 deletions

View File

@ -51,7 +51,7 @@ const rendererConfig = {
}
},
{
test: /(theme\-chalk(?:\/|\\)index|katex|github\-markdown|prism[\-a-z]*|\.theme)\.css$/,
test: /(theme\-chalk(?:\/|\\)index|katex|github\-markdown|prism[\-a-z]*|\.theme|headerFooterStyle)\.css$/,
use: [
'to-string-loader',
'css-loader'
@ -59,7 +59,7 @@ const rendererConfig = {
},
{
test: /\.css$/,
exclude: /(theme\-chalk(?:\/|\\)index|katex|github\-markdown|prism[\-a-z]*|\.theme)\.css$/,
exclude: /(theme\-chalk(?:\/|\\)index|katex|github\-markdown|prism[\-a-z]*|\.theme|headerFooterStyle)\.css$/,
use: [
proMode ? MiniCssExtractPlugin.loader : 'style-loader',
{ loader: 'css-loader', options: { importLoaders: 1 } },

View File

@ -1,4 +1,4 @@
import fs from 'fs'
import fs from 'fs-extra'
import path from 'path'
import { BrowserWindow, dialog, ipcMain, shell } from 'electron'
import log from 'electron-log'
@ -14,32 +14,53 @@ import pandoc from '../../utils/pandoc'
// the renderer should communicate only with the editor window for file relevant stuff.
// E.g. "mt::save-tabs" --> "mt::window-save-tabs$wid:<windowId>"
const getPdfPageOptions = options => {
if (!options) {
return {}
}
const { pageSize, pageSizeWidth, pageSizeHeight, isLandscape } = options
if (pageSize === 'custom' && pageSizeWidth && pageSizeHeight) {
return {
// Note: mm to microns
pageSize: { height: pageSizeHeight * 1000, width: pageSizeWidth * 1000 },
landscape: !!isLandscape
}
} else {
return { pageSize, landscape: !!isLandscape }
}
}
// Handle the export response from renderer process.
const handleResponseForExport = async (e, { type, content, pathname, markdown }) => {
const handleResponseForExport = async (e, { type, content, pathname, title, pageOptions }) => {
const win = BrowserWindow.fromWebContents(e.sender)
const extension = EXTENSION_HASN[type]
const dirname = pathname ? path.dirname(pathname) : getPath('documents')
let nakedFilename = getRecommendTitleFromMarkdownString(markdown)
let nakedFilename = title
if (!nakedFilename) {
nakedFilename = pathname ? path.basename(pathname, '.md') : 'Untitled'
}
const defaultPath = path.join(dirname, `${nakedFilename}${extension}`)
const defaultPath = path.join(dirname, `${nakedFilename}${extension}`)
const { filePath, canceled } = await dialog.showSaveDialog(win, {
defaultPath
})
if (filePath && !canceled) {
let data = content
try {
if (!content && type === 'pdf') {
data = await win.webContents.printToPDF({ printBackground: true })
if (type === 'pdf') {
const options = { printBackground: true }
Object.assign(options, getPdfPageOptions(pageOptions))
const data = await win.webContents.printToPDF(options)
removePrintServiceFromWindow(win)
await writeFile(filePath, data, extension, {})
} else {
if (!content) {
throw new Error('No HTML content found.')
}
await writeFile(filePath, content, extension, 'utf8')
}
if (data) {
await writeFile(filePath, data, extension)
win.webContents.send('AGANI::export-success', { type, filePath })
}
} catch (err) {
log.error(err)
const ERROR_MSG = err.message || `Error happened when export ${filePath}`
@ -399,7 +420,7 @@ ipcMain.on('AGANI::format-link-click', (e, { data, dirname }) => {
// --- menu -------------------------------------
export const exportFile = (win, type) => {
win.webContents.send('AGANI::export', { type })
win.webContents.send('mt::show-export-dialog', type)
}
export const importFile = async win => {
@ -423,7 +444,16 @@ export const importFile = async win => {
}
export const print = win => {
win.webContents.send('AGANI::print')
// See GH#749, Electron#16085 and Electron#17523.
dialog.showMessageBox(win, {
type: 'info',
buttons: ['OK'],
defaultId: 0,
noLink: true,
message: 'Printing doesn\'t work',
detail: 'Printing is disabled due to an Electron upstream issue. Please export the document as PDF and print the PDF file. We apologize for the inconvenience!'
})
// win.webContents.send('mt::show-export-dialog', 'print')
}
export const openFile = async win => {

View File

@ -0,0 +1,99 @@
:root {
--footerHeaderBorderColor: #1c1c1c;
}
table.page-container,
table.page-container > thead,
table.page-container > tfoot,
table.page-container > tbody,
table.page-container > thead > tr,
table.page-container > thead > tr > th,
table.page-container > thead > tr > td,
table.page-container > tbody > tr,
table.page-container > tbody > tr > th,
table.page-container > tbody > tr > td,
table.page-container > tfoot > tr,
table.page-container > tfoot > tr > th,
table.page-container > tfoot > tr > td {
position: relative !important;
margin: 0 !important;
padding: 0 !important;
}
.page-header .hf-container,
.page-footer-fake .hf-container,
.page-footer .hf-container {
display: flex;
justify-content: space-between;
font-size: 0.75em;
font-weight: 400;
}
.page-header {
display: table-header-group;
}
.page-header .hf-container {
margin-bottom: 16px;
}
.page-header.styled .hf-container {
padding-bottom: 1px;
border-bottom: 1px solid var(--footerHeaderBorderColor);
}
.page-header .hf-container > div {
flex: 1;
max-height: 100px;
overflow: hidden;
}
.page-header .header-content-left {
text-align: left;
margin-right: 4px;
}
.page-header .header-content {
text-align: center;
}
.page-header .header-content-right {
text-align: right;
margin-left: 4px;
}
.page-header.single .header-content-left,
.page-header.single .header-content-right {
display: none;
}
.page-footer-fake {
display: table-footer-group;
}
.page-footer-fake .hf-container {
margin-top: 16px;
visibility: hidden;
}
.page-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
}
.page-footer.styled .hf-container {
padding-top: 1px;
border-top: 1px solid var(--footerHeaderBorderColor);
}
.page-footer .hf-container > div {
flex: 1;
white-space: nowrap;
overflow: hidden;
}
.page-footer .footer-content-left {
text-align: left;
margin-right: 14px;
}
.page-footer .footer-content {
text-align: center;
}
.page-footer .footer-content-right {
text-align: right;
margin-left: 14px;
}
.page-footer.single .footer-content-left,
.page-footer.single .footer-content-right {
display: none;
}

View File

@ -273,7 +273,6 @@ export const MUYA_DEFAULT_OPTION = {
// 'mermaid': `graph LR;\nYou-->|Mark Text|Me;`
// }
export const isInElectron = window && window.process && window.process.type === 'renderer'
export const isOsx = window && window.navigator && /Mac/.test(window.navigator.platform)
export const isWin = window && window.navigator.userAgent && /win32|wow32|win64|wow64/i.test(window.navigator.userAgent)
// http[s] (domain or IPv4 or localhost or IPv6) [port] /not-white-space

View File

@ -145,9 +145,9 @@ class Muya {
return this.contentState.history.clearHistory()
}
exportStyledHTML (title = '', printOptimization = false, extraCss = '') {
exportStyledHTML (options) {
const { markdown } = this
return new ExportHtml(markdown, this).generate(title, printOptimization, extraCss)
return new ExportHtml(markdown, this).generate(options)
}
exportHtml () {

View File

@ -5,6 +5,7 @@ import loadRenderer from '../renderers'
import githubMarkdownCss from 'github-markdown-css/github-markdown.css'
import highlightCss from 'prismjs/themes/prism.css'
import katexCss from 'katex/dist/katex.css'
import footerHeaderCss from '../assets/styles/headerFooterStyle.css'
import { EXPORT_DOMPURIFY_CONFIG } from '../config'
import { sanitize, unescapeHtml } from '../utils'
import { validEmoji } from '../ui/emojis'
@ -131,16 +132,20 @@ class ExportHtml {
},
mathRenderer: this.mathRenderer
})
html = sanitize(html, EXPORT_DOMPURIFY_CONFIG)
const exportContainer = this.exportContainer = document.createElement('div')
exportContainer.classList.add('ag-render-container')
exportContainer.innerHTML = html
document.body.appendChild(exportContainer)
// render only render the light theme of mermaid and diragram...
await this.renderMermaid()
await this.renderDiagram()
let result = exportContainer.innerHTML
exportContainer.remove()
// hack to add arrow marker to output html
const pathes = document.querySelectorAll('path[id^=raphael-marker-]')
const def = '<defs style="-webkit-tap-highlight-color: rgba(0, 0, 0, 0);">'
@ -151,6 +156,7 @@ class ExportHtml {
}
return `${def}${str}`
})
this.exportContainer = null
return result
}
@ -158,20 +164,25 @@ class ExportHtml {
/**
* Get HTML with style
*
* @param {*} title Page title
* @param {*} printOptimization Optimize HTML and CSS for printing
* @param {*} options Document options
*/
async generate (title = '', printOptimization = false, extraCss = '') {
async generate (options) {
const { printOptimization } = options
// WORKAROUND: Hide Prism.js style when exporting or printing. Otherwise the background color is white in the dark theme.
const highlightCssStyle = printOptimization ? `@media print { ${highlightCss} }` : highlightCss
const html = await this.renderHtml()
const html = this._prepareHtml(await this.renderHtml(), options)
const katexCssStyle = this.mathRendererCalled ? katexCss : ''
this.mathRendererCalled = false
// `extraCss` may changed in the mean time.
const { title, extraCss } = options
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title}</title>
<title>${sanitize(title, EXPORT_DOMPURIFY_CONFIG)}</title>
<style>
${githubMarkdownCss}
</style>
@ -189,6 +200,28 @@ class ExportHtml {
margin: 0 auto;
padding: 45px;
}
@media not print {
.markdown-body {
padding: 45px;
}
@media (max-width: 767px) {
.markdown-body {
padding: 15px;
}
}
}
.hf-container {
color: #24292e;
line-height: 1.3;
}
.markdown-body .highlight pre,
.markdown-body pre {
white-space: pre-wrap;
}
.markdown-body table {
display: table;
}
@ -202,12 +235,6 @@ class ExportHtml {
margin-top: 0;
display: inline-block;
}
@media (max-width: 767px) {
.markdown-body {
padding: 15px;
}
}
.markdown-body ol ol,
.markdown-body ul ol {
list-style-type: decimal;
@ -222,12 +249,105 @@ class ExportHtml {
<style>${extraCss}</style>
</head>
<body>
<article class="markdown-body">
${html}
</article>
</body>
</html>`
}
/**
* @private
*
* @param {string} html The converted HTML text.
* @param {*} options The export options.
*/
_prepareHtml (html, options) {
const { header, footer } = options
const appendHeaderFooter = !!header || !!footer
if (!appendHeaderFooter) {
return createMarkdownArticle(html)
}
if (!options.extraCss) {
options.extraCss = footerHeaderCss
} else {
options.extraCss = footerHeaderCss + options.extraCss
}
let output = HF_TABLE_START
if (header) {
output += createTableHeader(options)
}
if (footer) {
output += HF_TABLE_FOOTER()
output = createRealFooter(options) + output
}
output = output + createTableBody(html) + HF_TABLE_END
return sanitize(output, EXPORT_DOMPURIFY_CONFIG)
}
}
// Variables and function to generate the header and footer.
const HF_TABLE_START = '<table class="page-container">'
const createTableBody = html => {
return `<tbody><tr><td>
<div class="main-container">
${createMarkdownArticle(html)}
</div>
</td></tr></tbody>`
}
const HF_TABLE_END = '</table>'
/// The header at is shown at the top.
const createTableHeader = options => {
const { header, headerFooterStyled } = options
const { type, left, center, right } = header
let headerClass = type === 1 ? 'single' : ''
headerClass += getHeaderFooterStyledClass(headerFooterStyled)
return `<thead class="page-header ${headerClass}"><tr><th>
<div class="hf-container">
<div class="header-content-left">${left}</div>
<div class="header-content">${center}</div>
<div class="header-content-right">${right}</div>
</div>
</th></tr></thead>`
}
/// Fake footer to reserve space.
const HF_TABLE_FOOTER = `<tfoot class="page-footer-fake"><tr><td>
<div class="hf-container">
&nbsp;
</div>
</td></tr></tfoot>`
/// The real footer at is shown at the bottom.
const createRealFooter = options => {
const { footer, headerFooterStyled } = options
const { type, left, center, right } = footer
let footerClass = type === 1 ? 'single' : ''
footerClass += getHeaderFooterStyledClass(headerFooterStyled)
return `<div class="page-footer ${footerClass}">
<div class="hf-container">
<div class="footer-content-left">${left}</div>
<div class="footer-content">${center}</div>
<div class="footer-content-right">${right}</div>
</div>
</div>`
}
/// Generate the mardown article HTML.
const createMarkdownArticle = html => {
return `<article class="markdown-body">${html}</article>`
}
/// Return the class whether a header/footer should be styled.
const getHeaderFooterStyledClass = value => {
if (value === undefined) {
// Prefer theme settings.
return ''
}
return !value ? ' simple' : ' styled'
}
export default ExportHtml

View File

@ -1,6 +1,7 @@
// DOTO: Don't use Node API in editor folder, remove `path` @jocs
import createDOMPurify from 'dompurify'
import { isInElectron, URL_REG } from '../config'
import { URL_REG } from '../config'
const { sanitize: runSanitize } = createDOMPurify(window)
const ID_PREFIX = 'ag-'
let id = 0
@ -263,7 +264,7 @@ export const getImageInfo = (src, baseUrl = window.DIRNAME) => {
if (imageExtension) {
const isAbsoluteLocal = /^(?:\/|\\\\|[a-zA-Z]:\\).+/.test(src)
if (isUrl || (!isAbsoluteLocal && !baseUrl)) {
if (!isUrl && !baseUrl && isInElectron) {
if (!isUrl && !baseUrl) {
console.warn('"baseUrl" is not defined!')
}
@ -271,14 +272,13 @@ export const getImageInfo = (src, baseUrl = window.DIRNAME) => {
isUnknownType: false,
src
}
} else if (isInElectron) {
} else {
// Correct relative path on desktop. If we resolve a absolute path "path.resolve" doesn't do anything.
return {
isUnknownType: false,
src: 'file://' + require('path').resolve(baseUrl, src)
}
}
// else: Forbid the request due absolute or relative path in browser
} else if (isUrl && !imageExtension) {
// Assume it's a valid image and make a http request later
return {
@ -367,8 +367,7 @@ export const mixins = (constructor, ...object) => {
}
export const sanitize = (html, options) => {
const DOMPurify = createDOMPurify(window)
return DOMPurify.sanitize(escapeInBlockHtml(html), options)
return runSanitize(escapeInBlockHtml(html), options)
}
export const getParagraphReference = (ele, id) => {

View File

@ -26,7 +26,8 @@ body article.print-container {
height: auto !important;
}
body > div {
body > div,
body > svg {
display: none !important;
}

View File

@ -0,0 +1,97 @@
/** Academic **/
/* DejaVu Serif --> 11pt */
/* Don't use "Noto Color Emoji" because it will result in PDF files with multiple MB and weird looking emojis. */
.hf-container,
.markdown-body {
font-family: Georgia,Palatino,"Palatino Linotype","Times New Roman","DejaVu Serif",serif,"Apple Color Emoji","Segoe UI Emoji";
font-size: 12pt;
line-height: 1.3;
color: #212121;
}
:root {
--footerHeaderBorderColor: #1c1c1c;
}
.hf-container {
font-size: 0.75em;
}
.page-header:not(.simple) .hf-container {
padding-bottom: 2px;
border-bottom: 1px solid var(--footerHeaderBorderColor);
}
.page-footer:not(.simple) .hf-container {
padding-top: 2px;
border-top: 1px solid var(--footerHeaderBorderColor);
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
font-weight: 600;
color: #282828;
}
.markdown-body h1 {
font-size: 1.8em;
}
.markdown-body h2 {
font-size: 1.6em;
}
.markdown-body h3 {
font-size: 1.4em;
}
.markdown-body h4,
.markdown-body h5 {
font-size: 1.2em;
}
.markdown-body h6 {
font-size: 1em;
}
.markdown-body h1,
.markdown-body h2 {
border-bottom: none;
padding-bottom: 0;
}
.markdown-body a {
color: #212121;
text-decoration: underline;
}
.markdown-body hr {
background-color: #b0b0b0;
height: 2px;
}
.markdown-body blockquote {
border: none;
color: #282828;
padding: 0 1em;
margin-left: 20px;
}
.markdown-body .highlight pre,
.markdown-body pre {
background-color: #f6f8fa;
border: 1px solid #e5e5e5;
border-radius: 0;
}
.markdown-body table td,
.markdown-body table th {
border: 1px solid #1a1a1a;
padding: 2px 6px;
}
.markdown-body table tr {
background: none;
border-top: 1px solid #1a1a1a;
text-align: left;
}
.markdown-body table tr:nth-child(2n) {
background: none;
}

View File

@ -0,0 +1,66 @@
/** Liber **/
/* Don't use "Noto Color Emoji" because it will result in PDF files with multiple MB and weird looking emojis. */
.hf-container,
.markdown-body {
font-family: "Source Sans Pro","Segoe UI","Helvetica Neue","Open Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji";
font-size: 16px;
line-height: 1.3;
color: #212121;
}
:root {
--footerHeaderBorderColor: #1c1c1c;
}
.hf-container {
font-size: 0.75em;
}
.page-header:not(.simple) .hf-container {
padding-bottom: 2px;
border-bottom: 1px solid var(--footerHeaderBorderColor);
}
.page-footer:not(.simple) .hf-container {
padding-top: 2px;
border-top: 1px solid var(--footerHeaderBorderColor);
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
font-weight: 600;
color: #282828;
}
.markdown-body h1 {
font-size: 1.8em;
}
.markdown-body h2 {
font-size: 1.6em;
}
.markdown-body h3 {
font-size: 1.4em;
}
.markdown-body h4,
.markdown-body h5 {
font-size: 1.2em;
}
.markdown-body h6 {
font-size: 1em;
font-weight: 400;
}
.markdown-body h1,
.markdown-body h2 {
border-bottom: none;
padding-bottom: 0;
}
.markdown-body .highlight pre,
.markdown-body pre {
background-color: #f6f8fa;
border: 1px solid #e5e5e5;
border-radius: 0;
}

View File

@ -100,6 +100,7 @@ import { offsetToWordCursor, validateLineCursor, SpellChecker } from '@/spellche
import { isOsx, animatedScrollTo } from '@/util'
import { moveImageToFolder, uploadImage } from '@/util/fileSystem'
import { guessClipboardFilePath } from '@/util/clipboard'
import { getCssForOptions } from '@/util/pdf'
import { addCommonStyle, setEditorWidth } from '@/util/theme'
import 'muya/themes/default.css'
@ -114,9 +115,6 @@ export default {
Search
},
props: {
filename: {
type: String
},
markdown: String,
cursor: Object,
textDirection: {
@ -534,7 +532,6 @@ export default {
bus.$on('deleteParagraph', this.handleParagraph)
bus.$on('insertParagraph', this.handleInsertParagraph)
bus.$on('scroll-to-header', this.scrollToHeader)
bus.$on('print', this.handlePrint)
bus.$on('screenshot-captured', this.handleScreenShot)
bus.$on('switch-spellchecker-language', this.switchSpellcheckLanguage)
@ -878,27 +875,60 @@ export default {
this.scrollToHighlight()
},
async handlePrint () {
// generate styled HTML with empty title and optimized for printing
const html = await this.editor.exportStyledHTML('', true)
this.printer.renderMarkdown(html, true)
this.$store.dispatch('PRINT_RESPONSE')
},
async handleExport (type) {
const markdown = this.editor.getMarkdown()
switch (type) {
case 'styledHtml': {
const content = await this.editor.exportStyledHTML(this.filename)
this.$store.dispatch('EXPORT', { type, content, markdown })
break
async handleExport (options) {
const {
type,
header,
footer,
headerFooterStyled,
htmlTitle
} = options
if (!/^pdf|print|styledHtml$/.test(type)) {
throw new Error(`Invalid type to export: "${type}".`)
}
const extraCss = getCssForOptions(options)
switch (type) {
case 'styledHtml': {
const content = await this.editor.exportStyledHTML({
title: htmlTitle || '',
printOptimization: false,
extraCss
})
this.$store.dispatch('EXPORT', { type, content })
break
}
case 'pdf': {
// generate styled HTML with empty title and optimized for printing
const html = await this.editor.exportStyledHTML('', true)
// NOTE: We need to set page size via Electron.
const { pageSize, pageSizeWidth, pageSizeHeight, isLandscape } = options
const pageOptions = {
pageSize, pageSizeWidth, pageSizeHeight, isLandscape
}
const html = await this.editor.exportStyledHTML({
title: '',
printOptimization: true,
extraCss,
header,
footer,
headerFooterStyled
})
this.printer.renderMarkdown(html, true)
this.$store.dispatch('EXPORT', { type, markdown })
this.$store.dispatch('EXPORT', { type, pageOptions })
break
}
case 'print': {
// NOTE: Print doesn't support page size or orientation.
const html = await this.editor.exportStyledHTML({
title: '',
printOptimization: true,
extraCss,
header,
footer,
headerFooterStyled
})
this.printer.renderMarkdown(html, true)
this.$store.dispatch('PRINT_RESPONSE')
break
}
}
@ -1021,7 +1051,6 @@ export default {
bus.$off('deleteParagraph', this.handleParagraph)
bus.$off('insertParagraph', this.handleInsertParagraph)
bus.$off('scroll-to-header', this.scrollToHeader)
bus.$off('print', this.handlePrint)
bus.$off('screenshot-captured', this.handleScreenShot)
bus.$off('switch-spellchecker-language', this.switchSpellcheckLanguage)

View File

@ -5,7 +5,6 @@
<tabs v-show="showTabBar"></tabs>
<div class="container">
<editor
:fileanme="filename"
:markdown="markdown"
:cursor="cursor"
:text-direction="textDirection"
@ -30,9 +29,6 @@ import TabNotifications from './notifications.vue'
export default {
props: {
filename: {
type: String
},
markdown: {
type: String,
required: true

View File

@ -0,0 +1,61 @@
export const pageSizeList = [
{
label: 'A3 (297mm x 420mm)',
value: 'A3'
}, {
label: 'A4 (210mm x 297mm)',
value: 'A4'
}, {
label: 'A5 (148mm x 210mm)',
value: 'A5'
}, {
label: 'US Legal (8.5" x 13")',
value: 'Legal'
}, {
label: 'US Letter (8.5" x 11")',
value: 'Letter'
}, {
label: 'Tabloid (17" x 11")',
value: 'Tabloid'
}, {
label: 'Custom',
value: 'custom'
}
]
export const headerFooterTypes = [
{
label: 'None',
value: 0
}, {
label: 'Single cell',
value: 1
}, {
label: 'Three cells',
value: 2
}
]
export const headerFooterStyles = [
{
label: 'Default',
value: 0
}, {
label: 'Simple',
value: 1
}, {
label: 'Styled',
value: 2
}
]
export const exportThemeList = [{
label: 'Academic',
value: 'academic'
}, {
label: 'GitHub (Default)',
value: 'default'
}, {
label: 'Liber',
value: 'liber'
}]

View File

@ -0,0 +1,474 @@
<template>
<div class="print-settings-dialog">
<el-dialog
:visible.sync="showExportSettingsDialog"
:show-close="false"
:modal="true"
custom-class="ag-dialog-table"
width="500px"
>
<h3>Export Options</h3>
<el-tabs v-model="activeName">
<el-tab-pane label="Info" name="info">
<span class="text">Please customize the page appearance and click on "export" to continue.</span>
</el-tab-pane>
<el-tab-pane label="Page" name="page">
<!-- HTML -->
<div v-if="!isPrintable">
<text-box
description="The page title:"
:input="htmlTitle"
:emitTime="0"
:onChange="value => onSelectChange('htmlTitle', value)"
></text-box>
</div>
<!-- PDF/Print -->
<div v-if="isPrintable">
<div v-if="exportType === 'pdf'">
<cur-select
class="page-size-select"
description="Page size:"
:value="pageSize"
:options="pageSizeList"
:onChange="value => onSelectChange('pageSize', value)"
></cur-select>
<div v-if="pageSize === 'custom'" class="row">
<div>Width/Height in mm:</div>
<el-input-number v-model="pageSizeWidth" size="mini" controls-position="right" :min="100"></el-input-number>
<el-input-number v-model="pageSizeHeight" size="mini" controls-position="right" :min="100"></el-input-number>
</div>
<bool
description="Landscape orientation:"
:bool="isLandscape"
:onChange="value => onSelectChange('isLandscape', value)"
></bool>
</div>
<div class="row">
<div class="description">Page margin in mm:</div>
<div>
<div style="margin-bottom:2px;">Top/Bottom:</div>
<el-input-number v-model="pageMarginTop" size="mini" controls-position="right" :min="0" :max="100"></el-input-number>
<el-input-number v-model="pageMarginBottom" size="mini" controls-position="right" :min="0" :max="100"></el-input-number>
</div>
<div>
<div style="margin-bottom:2px;">Left/Right:</div>
<el-input-number v-model="pageMarginLeft" size="mini" controls-position="right" :min="0" :max="100"></el-input-number>
<el-input-number v-model="pageMarginRight" size="mini" controls-position="right" :min="0" :max="100"></el-input-number>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="Style" name="style">
<bool
description="Overwrite theme font settings:"
:bool="fontSettingsOverwrite"
:onChange="value => onSelectChange('fontSettingsOverwrite', value)"
></bool>
<div v-if="fontSettingsOverwrite">
<font-text-box
description="Font family:"
:value="fontFamily"
:onChange="value => onSelectChange('fontFamily', value)"
></font-text-box>
<range
description="Font size"
:value="fontSize"
:min="8"
:max="32"
unit="px"
:step="1"
:onChange="value => onSelectChange('fontSize', value)"
></range>
<range
description="Line height"
:value="lineHeight"
:min="1.0"
:max="2.0"
:step="0.1"
:onChange="value => onSelectChange('lineHeight', value)"
></range>
</div>
<bool
description="Auto numbering headings:"
:bool="autoNumberingHeadings"
:onChange="value => onSelectChange('autoNumberingHeadings', value)"
></bool>
<bool
description="Show front matter:"
:bool="showFrontMatter"
:onChange="value => onSelectChange('showFrontMatter', value)"
></bool>
</el-tab-pane>
<el-tab-pane label="Theme" name="theme">
<div class="text">You can change the document appearance by choosing a theme or create a handcrafted one.</div>
<!-- TODO(theme): Create "more" link to PDF theme documentation -->
<cur-select
description="Theme:"
:value="theme"
:options="themeList"
:onChange="value => onSelectChange('theme', value)"
></cur-select>
</el-tab-pane>
<el-tab-pane v-if="isPrintable" label="Header & Footer" name="header">
<div class="text">The text appear on all pages if header and/or footer is defined.</div>
<cur-select
description="Header type:"
:value="headerType"
:options="headerFooterTypes"
:onChange="value => onSelectChange('headerType', value)"
></cur-select>
<text-box
v-if="headerType === 2"
description="The left header text:"
:input="headerTextLeft"
:emitTime="0"
:onChange="value => onSelectChange('headerTextLeft', value)"
></text-box>
<text-box
v-if="headerType !== 0"
description="The main header text:"
:input="headerTextCenter"
:emitTime="0"
:onChange="value => onSelectChange('headerTextCenter', value)"
></text-box>
<text-box
v-if="headerType === 2"
description="The right header text:"
:input="headerTextRight"
:emitTime="0"
:onChange="value => onSelectChange('headerTextRight', value)"
></text-box>
<cur-select
description="Footer type:"
:value="footerType"
:options="headerFooterTypes"
:onChange="value => onSelectChange('footerType', value)"
></cur-select>
<text-box
v-if="footerType === 2"
description="The left footer text:"
:input="footerTextLeft"
:emitTime="0"
:onChange="value => onSelectChange('footerTextLeft', value)"
></text-box>
<text-box
v-if="footerType !== 0"
description="The main footer text:"
:input="footerTextCenter"
:emitTime="0"
:onChange="value => onSelectChange('footerTextCenter', value)"
></text-box>
<text-box
v-if="footerType === 2"
description="The right footer text:"
:input="footerTextRight"
:emitTime="0"
:onChange="value => onSelectChange('footerTextRight', value)"
></text-box>
<bool
description="Customize style:"
:bool="headerFooterCustomize"
:onChange="value => onSelectChange('headerFooterCustomize', value)"
></bool>
<div v-if="headerFooterCustomize">
<bool
description="Allow styled header and footer:"
:bool="headerFooterStyled"
:onChange="value => onSelectChange('headerFooterStyled', value)"
></bool>
<range
description="Header and footer font size"
:value="headerFooterFontSize"
:min="8"
:max="20"
unit="px"
:step="1"
:onChange="value => onSelectChange('headerFooterFontSize', value)"
></range>
</div>
</el-tab-pane>
</el-tabs>
<div class="button-controlls">
<button class="button-primary" @click="handleClicked">
Export...
</button>
</div>
</el-dialog>
</div>
</template>
<script>
import { mapState } from 'vuex'
import fs from 'fs-extra'
import path from 'path'
import bus from '../../bus'
import Bool from '@/prefComponents/common/bool'
import CurSelect from '@/prefComponents/common/select'
import FontTextBox from '@/prefComponents/common/fontTextBox'
import Range from '@/prefComponents/common/range'
import TextBox from '@/prefComponents/common/textBox'
import {
pageSizeList,
headerFooterTypes,
headerFooterStyles,
exportThemeList
} from './exportOptions'
export default {
components: {
Bool,
CurSelect,
FontTextBox,
Range,
TextBox
},
data () {
this.exportType = ''
this.themesLoaded = false
this.pageSizeList = pageSizeList
this.headerFooterTypes = headerFooterTypes
this.headerFooterStyles = headerFooterStyles
return {
isPrintable: true,
showExportSettingsDialog: false,
activeName: 'info',
htmlTitle: '',
pageSize: 'A4',
pageSizeWidth: 210,
pageSizeHeight: 297,
isLandscape: false,
pageMarginTop: 20,
pageMarginRight: 15,
pageMarginBottom: 20,
pageMarginLeft: 15,
fontSettingsOverwrite: false,
fontFamily: 'Default',
fontSize: 14,
lineHeight: 1.5,
autoNumberingHeadings: false,
showFrontMatter: false,
theme: 'default',
themeList: exportThemeList,
headerType: 0,
headerTextLeft: '',
headerTextCenter: '',
headerTextRight: '',
footerType: 0,
footerTextLeft: '',
footerTextCenter: '',
footerTextRight: '',
headerFooterCustomize: false,
headerFooterStyled: true,
headerFooterFontSize: 12
}
},
computed: {
...mapState({
})
},
created () {
bus.$on('showExportDialog', this.showDialog)
},
beforeDestroy () {
bus.$off('showExportDialog', this.showDialog)
},
methods: {
showDialog (type) {
this.exportType = type
this.isPrintable = type !== 'styledHtml'
if (!this.isPrintable && (this.activeName === 'header' || this.activeName === 'page')) {
this.activeName = 'info'
}
this.showExportSettingsDialog = true
bus.$emit('editor-blur')
if (!this.themesLoaded) {
this.themesLoaded = true
this.loadThemesFromDisk()
}
},
handleClicked () {
const {
exportType,
isPrintable,
htmlTitle,
pageSize,
pageSizeWidth,
pageSizeHeight,
isLandscape,
pageMarginTop,
pageMarginRight,
pageMarginBottom,
pageMarginLeft,
fontSettingsOverwrite,
fontFamily,
fontSize,
lineHeight,
autoNumberingHeadings,
showFrontMatter,
theme,
headerType,
headerTextLeft,
headerTextCenter,
headerTextRight,
footerType,
footerTextLeft,
footerTextCenter,
footerTextRight,
headerFooterCustomize,
headerFooterStyled,
headerFooterFontSize
} = this
const options = {
type: exportType,
pageSize,
pageSizeWidth,
pageSizeHeight,
isLandscape,
pageMarginTop,
pageMarginRight,
pageMarginBottom,
pageMarginLeft,
autoNumberingHeadings,
showFrontMatter,
theme: theme === 'default' ? null : theme
}
if (!isPrintable) {
options.htmlTitle = htmlTitle
}
if (fontSettingsOverwrite) {
Object.assign(options, {
fontSize,
lineHeight,
fontFamily: fontFamily === 'Default' ? null : fontFamily
})
}
if (headerType !== 0) {
Object.assign(options, {
header: {
type: headerType,
left: headerTextLeft,
center: headerTextCenter,
right: headerTextRight
}
})
}
if (footerType !== 0) {
Object.assign(options, {
footer: {
type: footerType,
left: footerTextLeft,
center: footerTextCenter,
right: footerTextRight
}
})
}
if (headerFooterCustomize) {
Object.assign(options, {
headerFooterStyled,
headerFooterFontSize
})
}
this.showExportSettingsDialog = false
bus.$emit('export', options)
},
onSelectChange (key, value) {
this[key] = value
},
loadThemesFromDisk () {
const { userDataPath } = global.marktext.paths
const themeDir = path.join(userDataPath, 'themes/export')
// Search for dictionaries on filesystem.
if (fs.existsSync(themeDir) && fs.lstatSync(themeDir).isDirectory()) {
fs.readdirSync(themeDir).forEach(async filename => {
const fullname = path.join(themeDir, filename)
if (/.+\.css$/i.test(filename) && fs.lstatSync(fullname).isFile()) {
try {
const content = await fs.readFile(fullname, 'utf8')
// Match comment with theme name in first line only.
const match = content.match(/^(?:\/\*+[ \t]*([A-z0-9 -]+)[ \t]*(?:\*+\/|[\n\r])?)/)
let label
if (match && match[1]) {
label = match[1]
} else {
label = filename
}
this.themeList.push({
value: filename,
label
})
} catch (e) {
console.error('loadThemesFromDisk failed:', e)
}
}
})
}
}
}
}
</script>
<style scoped>
.print-settings-dialog {
user-select: none;
}
.row {
margin-bottom: 8px;
}
.description {
margin-bottom: 10px;
white-space: pre-wrap;
word-break: break-word;
}
.text {
white-space: pre-wrap;
word-break: break-word;
}
.button-controlls {
margin-top: 8px;
text-align: right;
}
.el-tab-pane section:first-child {
margin-top: 0;
}
</style>
<style>
.print-settings-dialog #pane-header .pref-text-box-item .el-input {
width: 90% !important;
}
.print-settings-dialog .el-dialog__body {
padding: 0 20px 20px 20px;
}
.print-settings-dialog .pref-select-item .el-select {
width: 240px;
}
.print-settings-dialog .el-tabs__content {
max-height: 350px;
overflow-x: hidden;
overflow-y: auto;
}
.print-settings-dialog .el-tabs__content::-webkit-scrollbar:vertical {
width: 5px;
}
</style>

View File

@ -20,7 +20,6 @@
<editor-with-tabs
v-if="hasCurrentFile && init"
:markdown="markdown"
:filename="filename"
:cursor="cursor"
:source-code="sourceCode"
:show-tab-bar="showTabBar"
@ -29,6 +28,7 @@
></editor-with-tabs>
<aidou></aidou>
<about-dialog></about-dialog>
<export-setting-dialog></export-setting-dialog>
<rename></rename>
<tweet></tweet>
<import-modal></import-modal>
@ -44,6 +44,7 @@ import TitleBar from '@/components/titleBar'
import SideBar from '@/components/sideBar'
import Aidou from '@/components/aidou/aidou'
import AboutDialog from '@/components/about'
import ExportSettingDialog from '@/components/exportSettings'
import Rename from '@/components/rename'
import Tweet from '@/components/tweet'
import ImportModal from '@/components/import'
@ -61,6 +62,7 @@ export default {
TitleBar,
SideBar,
AboutDialog,
ExportSettingDialog,
Rename,
Tweet,
ImportModal
@ -118,7 +120,7 @@ export default {
// module: listenForMain
dispatch('LISTEN_FOR_EDIT')
dispatch('LISTEN_FOR_VIEW')
dispatch('LISTEN_FOR_ABOUT_DIALOG')
dispatch('LISTEN_FOR_SHOW_DIALOG')
dispatch('LISTEN_FOR_PARAGRAPH_INLINE_STYLE')
// module: project
dispatch('LISTEN_FOR_UPDATE_PROJECT')
@ -136,7 +138,6 @@ export default {
dispatch('LISTEN_FOR_SET_PATHNAME')
dispatch('LISTEN_FOR_BOOTSTRAP_WINDOW')
dispatch('LISTEN_FOR_SAVE_CLOSE')
dispatch('LISTEN_FOR_EXPORT_PRINT')
dispatch('LISTEN_FOR_RENAME')
dispatch('LINTEN_FOR_SET_LINE_ENDING')
dispatch('LISTEN_FOR_NEW_TAB')

View File

@ -43,9 +43,15 @@ export default {
type: String,
default: ''
},
emitTime: {
type: Number,
default: 800
},
regexValidator: {
type: RegExp,
default: /(.*?)/
default () {
return /(.*?)/
}
}
},
watch: {
@ -71,11 +77,17 @@ export default {
clearTimeout(this.inputTimer)
}
const { emitTime } = this
if (emitTime === 0) {
this.onChange(value)
return
}
// Setting delay a little bit higher to prevent continuously file writes when typing.
this.inputTimer = setTimeout(() => {
this.inputTimer = null
this.onChange(value)
}, 800)
}, emitTime)
}
}
}

View File

@ -99,7 +99,7 @@
<separator></separator>
<text-box
description="Defines the maximum editor area width. An empty string or suffixes of ch (characters), px (pixels) or % (percentage) are allowed."
:bool="editorLineWidth"
:input="editorLineWidth"
:regexValidator="/^(?:$|[0-9]+(?:ch|px|%)$)/"
defaultValue="Default value from current theme"
:onChange="value => onSelectChange('editorLineWidth', value)"

View File

@ -11,7 +11,6 @@ class MarkdownPrint {
this.clearup()
const printContainer = document.createElement('article')
printContainer.classList.add('print-container')
printContainer.classList.add('markdown-body')
this.container = printContainer
printContainer.innerHTML = html

View File

@ -947,20 +947,39 @@ const actions = {
ipcRenderer.send('mt::update-format-menu', windowId, formats)
},
// listen for export from main process
LISTEN_FOR_EXPORT_PRINT ({ commit, state }) {
ipcRenderer.on('AGANI::export', (e, { type }) => {
bus.$emit('export', type)
})
ipcRenderer.on('AGANI::print', e => {
bus.$emit('print')
})
},
EXPORT ({ commit, state }, { type, content, markdown }) {
EXPORT ({ state }, { type, content, pageOptions }) {
if (!hasKeys(state.currentFile)) return
// Extract title from TOC buffer.
let title = ''
const { listToc } = state
if (listToc && listToc.length > 0) {
let headerRef = listToc[0]
// The main title should be at the beginning of the document.
const len = Math.min(listToc.length, 6)
for (let i = 1; i < len; ++i) {
if (headerRef.lvl === 1) {
break
}
const header = listToc[i]
if (headerRef.lvl > header.lvl) {
headerRef = header
}
}
title = headerRef.content
}
const { filename, pathname } = state.currentFile
ipcRenderer.send('AGANI::response-export', { type, content, filename, pathname, markdown })
ipcRenderer.send('AGANI::response-export', {
type,
title,
content,
filename,
pathname,
pageOptions
})
},
LINTEN_FOR_EXPORT_SUCCESS ({ commit }) {

View File

@ -21,10 +21,13 @@ const actions = {
})
},
LISTEN_FOR_ABOUT_DIALOG ({ commit }) {
LISTEN_FOR_SHOW_DIALOG ({ commit }) {
ipcRenderer.on('AGANI::about-dialog', e => {
bus.$emit('aboutDialog')
})
ipcRenderer.on('mt::show-export-dialog', (e, type) => {
bus.$emit('showExportDialog', type)
})
},
LISTEN_FOR_PARAGRAPH_INLINE_STYLE ({ commit }) {

116
src/renderer/util/pdf.js Normal file
View File

@ -0,0 +1,116 @@
import fs from 'fs-extra'
import path from 'path'
import createDOMPurify from 'dompurify'
import { isFile } from 'common/filesystem'
import academicTheme from '@/assets/themes/export/academic.theme.css'
import liberTheme from '@/assets/themes/export/liber.theme.css'
const { sanitize } = createDOMPurify(window)
export const getCssForOptions = options => {
const {
type,
pageMarginTop,
pageMarginRight,
pageMarginBottom,
pageMarginLeft,
fontFamily,
fontSize,
lineHeight,
autoNumberingHeadings,
showFrontMatter,
theme,
headerFooterFontSize
} = options
const isPrintable = type !== 'styledHtml'
let output = ''
if (isPrintable) {
output += `@media print{@page{
margin: ${pageMarginTop}mm ${pageMarginRight}mm ${pageMarginBottom}mm ${pageMarginLeft}mm;}`
}
// Font options
output += '.markdown-body{'
if (fontFamily) {
output += `font-family:"${fontFamily}",${FALLBACK_FONT_FAMILIES};`
output = `.hf-container{font-family:"${fontFamily}",${FALLBACK_FONT_FAMILIES};}${output}`
}
if (fontSize) {
output += `font-size:${fontSize}px;`
}
if (lineHeight) {
output += `line-height:${lineHeight};`
}
output += '}'
// Auto numbering headings via CSS
if (autoNumberingHeadings) {
output += autoNumberingHeadingsCss
}
// Hide front matter
if (!showFrontMatter) {
output += 'pre.front-matter{display:none!important;}'
}
if (theme) {
if (theme === 'academic') {
output += academicTheme
} else if (theme === 'liber') {
output += liberTheme
} else {
// Read theme from disk
const { userDataPath } = global.marktext.paths
const themePath = path.join(userDataPath, 'themes/export', theme)
if (isFile(themePath)) {
try {
const themeCSS = fs.readFileSync(themePath, 'utf8')
output += themeCSS
} catch (_) {
// No-op
}
}
}
}
if (headerFooterFontSize) {
output += `.page-header .hf-container,
.page-footer-fake .hf-container,
.page-footer .hf-container {
font-size: ${headerFooterFontSize}px;
}`
}
if (isPrintable) {
// Close @page
output += '}'
}
return sanitize(output, EXPORT_DOMPURIFY_CONFIG)
}
const EXPORT_DOMPURIFY_CONFIG = {
FORBID_ATTR: ['contenteditable'],
ALLOW_DATA_ATTR: false,
USE_PROFILES: {
html: true,
svg: true,
svgFilters: true,
mathMl: true
}
}
// Don't use "Noto Color Emoji" because it will result in PDF files with multiple MB and weird looking emojis.
const FALLBACK_FONT_FAMILIES = '"Open Sans","Segoe UI","Helvetica Neue",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"'
const autoNumberingHeadingsCss = `body {counter-reset: h2}
h2 {counter-reset: h3}
h3 {counter-reset: h4}
h4 {counter-reset: h5}
h5 {counter-reset: h6}
h2:before {counter-increment: h2; content: counter(h2) ". "}
h3:before {counter-increment: h3; content: counter(h2) "." counter(h3) ". "}
h4:before {counter-increment: h4; content: counter(h2) "." counter(h3) "." counter(h4) ". "}
h5:before {counter-increment: h5; content: counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) ". "}
h6:before {counter-increment: h6; content: counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) "." counter(h6) ". "}
h2.nocount:before, h3.nocount:before, h4.nocount:before, h5.nocount:before, h6.nocount:before { content: ""; counter-increment: none }`