Przeglądaj źródła

support exporting note as PDF file

TODO: Currently the exported PDF does not have the outline which is
needed to fix via third-party utils.
Le Tan 8 lat temu
rodzic
commit
0131569c02

+ 1 - 1
src/dialog/vinsertimagedialog.cpp

@@ -94,7 +94,7 @@ void VInsertImageDialog::handleBrowseBtnClicked()
     static QString lastPath = QDir::homePath();
     QString filePath = QFileDialog::getOpenFileName(this, tr("Select The Image To Be Inserted"),
                                                     lastPath, tr("Images (*.png *.xpm *.jpg *.bmp *.gif)"));
-    if (filePath.isNull() || filePath.isEmpty()) {
+    if (filePath.isEmpty()) {
         return;
     }
 

+ 6 - 1
src/resources/hoedown.js

@@ -49,13 +49,18 @@ var updateHtml = function(html) {
         }
     }
 
+    // If you add new logics after handling MathJax, please pay attention to
+    // finishLoading logic.
     // MathJax may be not loaded for now.
     if (VEnableMathjax && (typeof MathJax != "undefined")) {
         try {
-            MathJax.Hub.Queue(["Typeset", MathJax.Hub, placeholder]);
+            MathJax.Hub.Queue(["Typeset", MathJax.Hub, placeholder, finishLoading]);
         } catch (err) {
             content.setLog("err: " + err);
+            finishLoading();
         }
+    } else {
+        finishLoading();
     }
 };
 

+ 7 - 1
src/resources/markdown-it.js

@@ -169,12 +169,18 @@ var updateText = function(text) {
     handleToc(needToc);
     insertImageCaption();
     renderMermaid('lang-mermaid');
+
+    // If you add new logics after handling MathJax, please pay attention to
+    // finishLoading logic.
     if (VEnableMathjax) {
         try {
-            MathJax.Hub.Queue(["Typeset", MathJax.Hub, placeholder]);
+            MathJax.Hub.Queue(["Typeset", MathJax.Hub, placeholder, finishLoading]);
         } catch (err) {
             content.setLog("err: " + err);
+            finishLoading();
         }
+    } else {
+        finishLoading();
     }
 };
 

+ 6 - 0
src/resources/markdown_template.js

@@ -245,3 +245,9 @@ var insertImageCaption = function() {
         img.insertAdjacentElement('afterend', captionDiv);
     }
 }
+
+// The renderer specific code should call this function once thay have finished
+// loading the page.
+var finishLoading = function() {
+    content.finishLoading();
+};

+ 7 - 1
src/resources/marked.js

@@ -127,12 +127,18 @@ var updateText = function(text) {
     handleToc(needToc);
     insertImageCaption();
     renderMermaid('lang-mermaid');
+
+    // If you add new logics after handling MathJax, please pay attention to
+    // finishLoading logic.
     if (VEnableMathjax) {
         try {
-            MathJax.Hub.Queue(["Typeset", MathJax.Hub, placeholder]);
+            MathJax.Hub.Queue(["Typeset", MathJax.Hub, placeholder, finishLoading]);
         } catch (err) {
             content.setLog("err: " + err);
+            finishLoading();
         }
+    } else {
+        finishLoading();
     }
 };
 

+ 7 - 1
src/resources/showdown.js

@@ -151,12 +151,18 @@ var updateText = function(text) {
     insertImageCaption();
     highlightCodeBlocks(document, VEnableMermaid);
     renderMermaid('language-mermaid');
+
+    // If you add new logics after handling MathJax, please pay attention to
+    // finishLoading logic.
     if (VEnableMathjax) {
         try {
-            MathJax.Hub.Queue(["Typeset", MathJax.Hub, placeholder]);
+            MathJax.Hub.Queue(["Typeset", MathJax.Hub, placeholder, finishLoading]);
         } catch (err) {
             content.setLog("err: " + err);
+            finishLoading();
         }
+    } else {
+        finishLoading();
     }
 };
 

+ 4 - 2
src/src.pro

@@ -61,7 +61,8 @@ SOURCES += main.cpp\
     vorphanfile.cpp \
     vcodeblockhighlighthelper.cpp \
     vwebview.cpp \
-    vimagepreviewer.cpp
+    vimagepreviewer.cpp \
+    vexporter.cpp
 
 HEADERS  += vmainwindow.h \
     vdirectorytree.h \
@@ -109,7 +110,8 @@ HEADERS  += vmainwindow.h \
     vorphanfile.h \
     vcodeblockhighlighthelper.h \
     vwebview.h \
-    vimagepreviewer.h
+    vimagepreviewer.h \
+    vexporter.h
 
 RESOURCES += \
     vnote.qrc \

+ 14 - 0
src/utils/vutils.cpp

@@ -16,6 +16,7 @@
 #include <cmath>
 #include <QLocale>
 #include <QPushButton>
+#include <QElapsedTimer>
 
 #include "vfile.h"
 
@@ -441,3 +442,16 @@ QString VUtils::getLocale()
     }
     return locale;
 }
+
+void VUtils::sleepWait(int p_milliseconds)
+{
+    if (p_milliseconds <= 0) {
+        return;
+    }
+
+    QElapsedTimer t;
+    t.start();
+    while (t.elapsed() < p_milliseconds) {
+        QCoreApplication::processEvents();
+    }
+}

+ 2 - 0
src/utils/vutils.h

@@ -92,6 +92,8 @@ public:
     static QChar keyToChar(int p_key);
     static QString getLocale();
 
+    static void sleepWait(int p_milliseconds);
+
     // Regular expression for image link.
     // ![image title]( http://github.com/tamlok/vnote.jpg "alt \" text" )
     // Captured texts (need to be trimmed):

+ 4 - 0
src/vconstants.h

@@ -14,4 +14,8 @@ static const qreal c_webZoomFactorMax = 5;
 static const qreal c_webZoomFactorMin = 0.25;
 
 static const int c_tabSequenceBase = 1;
+
+// HTML and JS.
+static const QString c_htmlJSHolder = "JS_PLACE_HOLDER";
+static const QString c_htmlExtraHolder = "<!-- EXTRA_PLACE_HOLDER -->";
 #endif

+ 14 - 1
src/vdocument.cpp

@@ -9,7 +9,9 @@ VDocument::VDocument(const VFile *v_file, QObject *p_parent)
 
 void VDocument::updateText()
 {
-    emit textChanged(m_file->getContent());
+    if (m_file) {
+        emit textChanged(m_file->getContent());
+    }
 }
 
 void VDocument::setToc(const QString &toc)
@@ -74,3 +76,14 @@ void VDocument::noticeReadyToHighlightText()
 {
     emit readyToHighlightText();
 }
+
+void VDocument::setFile(const VFile *p_file)
+{
+    m_file = p_file;
+}
+
+void VDocument::finishLoading()
+{
+    qDebug() << "Web side finished loading";
+    emit loadFinished();
+}

+ 7 - 0
src/vdocument.h

@@ -14,6 +14,7 @@ class VDocument : public QObject
     Q_PROPERTY(QString html MEMBER m_html NOTIFY htmlChanged)
 
 public:
+    // @p_file could be NULL.
     VDocument(const VFile *p_file, QObject *p_parent = 0);
     QString getToc();
     void scrollToAnchor(const QString &anchor);
@@ -22,6 +23,8 @@ public:
     // Use p_id to identify the result.
     void highlightTextAsync(const QString &p_text, int p_id, int p_timeStamp);
 
+    void setFile(const VFile *p_file);
+
 public slots:
     // Will be called in the HTML side
     void setToc(const QString &toc);
@@ -32,6 +35,9 @@ public slots:
     void highlightTextCB(const QString &p_html, int p_id, int p_timeStamp);
     void noticeReadyToHighlightText();
 
+    // Page is finished loading.
+    void finishLoading();
+
 signals:
     void textChanged(const QString &text);
     void tocChanged(const QString &toc);
@@ -43,6 +49,7 @@ signals:
     void requestHighlightText(const QString &p_text, int p_id, int p_timeStamp);
     void textHighlighted(const QString &p_html, int p_id, int p_timeStamp);
     void readyToHighlightText();
+    void loadFinished();
 
 private:
     QString m_toc;

+ 11 - 35
src/vedittab.cpp

@@ -155,25 +155,14 @@ void VEditTab::scrollPreviewToHeader(int p_outlineIndex)
 void VEditTab::previewByConverter()
 {
     VMarkdownConverter mdConverter;
-    const QString &content = m_file->getContent();
-    QString html = mdConverter.generateHtml(content, vconfig.getMarkdownExtensions());
-    QRegularExpression tocExp("<p>\\[TOC\\]<\\/p>", QRegularExpression::CaseInsensitiveOption);
-    QString toc = mdConverter.generateToc(content, vconfig.getMarkdownExtensions());
-    processHoedownToc(toc);
-    html.replace(tocExp, toc);
+    QString toc;
+    QString html = mdConverter.generateHtml(m_file->getContent(),
+                                            vconfig.getMarkdownExtensions(),
+                                            toc);
     document.setHtml(html);
     updateTocFromHtml(toc);
 }
 
-void VEditTab::processHoedownToc(QString &p_toc)
-{
-    // Hoedown will add '\n'.
-    p_toc.replace("\n", "");
-    // Hoedown will translate `_` in title to `<em>`.
-    p_toc.replace("<em>", "_");
-    p_toc.replace("</em>", "_");
-}
-
 void VEditTab::showFileEditMode()
 {
     if (!m_file->isModifiable()) {
@@ -294,8 +283,8 @@ void VEditTab::discardAndRead()
 
 void VEditTab::setupMarkdownPreview()
 {
-    const QString jsHolder("JS_PLACE_HOLDER");
-    const QString extraHolder("<!-- EXTRA_PLACE_HOLDER -->");
+    const QString &jsHolder = c_htmlJSHolder;
+    const QString &extraHolder = c_htmlExtraHolder;
 
     webPreviewer = new VWebView(m_file, this);
     connect(webPreviewer, &VWebView::editNote,
@@ -373,24 +362,7 @@ void VEditTab::setupMarkdownPreview()
         htmlTemplate.replace(extraHolder, extraFile);
     }
 
-    // Need to judge the path: Url, local file, resource file.
-    QUrl baseUrl;
-    QString basePath = m_file->retriveBasePath();
-    QFileInfo pathInfo(basePath);
-    if (pathInfo.exists()) {
-        if (pathInfo.isNativePath()) {
-            // Local file.
-            baseUrl = QUrl::fromLocalFile(basePath + QDir::separator());
-        } else {
-            // Resource file.
-            baseUrl = QUrl("qrc" + basePath + QDir::separator());
-        }
-    } else {
-        // Url.
-        baseUrl = QUrl(basePath + QDir::separator());
-    }
-
-    webPreviewer->setHtml(htmlTemplate, baseUrl);
+    webPreviewer->setHtml(htmlTemplate, m_file->getBaseUrl());
     addWidget(webPreviewer);
 }
 
@@ -733,3 +705,7 @@ VWebView *VEditTab::getWebViewer() const
     return webPreviewer;
 }
 
+MarkdownConverterType VEditTab::getMarkdownConverterType() const
+{
+    return mdConverterType;
+}

+ 2 - 1
src/vedittab.h

@@ -51,6 +51,8 @@ public:
 
     VWebView *getWebViewer() const;
 
+    MarkdownConverterType getMarkdownConverterType() const;
+
 public slots:
     // Enter edit mode
     void editFile();
@@ -82,7 +84,6 @@ private:
     void showFileEditMode();
     void setupMarkdownPreview();
     void previewByConverter();
-    void processHoedownToc(QString &p_toc);
     inline bool isChild(QObject *obj);
     void parseTocUl(QXmlStreamReader &xml, QVector<VHeader> &headers, int level);
     void parseTocLi(QXmlStreamReader &xml, QVector<VHeader> &headers, int level);

+ 404 - 0
src/vexporter.cpp

@@ -0,0 +1,404 @@
+#include "vexporter.h"
+
+#include <QtWidgets>
+#include <QFileInfo>
+#include <QDir>
+#include <QWebChannel>
+#include <QDebug>
+#include <QVBoxLayout>
+#include <QShowEvent>
+
+#ifndef QT_NO_PRINTER
+#include <QPrinter>
+#include <QPageSetupDialog>
+#endif
+
+#include "vconfigmanager.h"
+#include "utils/vutils.h"
+#include "vfile.h"
+#include "vwebview.h"
+#include "vpreviewpage.h"
+#include "vconstants.h"
+#include "vnote.h"
+#include "vmarkdownconverter.h"
+
+extern VConfigManager vconfig;
+
+QString VExporter::s_defaultPathDir = QDir::homePath();
+
+VExporter::VExporter(MarkdownConverterType p_mdType, QWidget *p_parent)
+    : QDialog(p_parent), m_document(NULL, this), m_mdType(p_mdType),
+      m_file(NULL), m_type(ExportType::PDF), m_source(ExportSource::Invalid),
+      m_webReady(false), m_state(ExportState::Idle),
+      m_pageLayout(QPageLayout(QPageSize(QPageSize::A4), QPageLayout::Portrait, QMarginsF(0.0, 0.0, 0.0, 0.0)))
+{
+    setupUI();
+}
+
+void VExporter::setupUI()
+{
+    setupMarkdownViewer();
+
+    m_infoLabel = new QLabel();
+    m_infoLabel->setWordWrap(true);
+
+    // Target file path.
+    QLabel *pathLabel = new QLabel(tr("Target &path:"));
+    m_pathEdit = new QLineEdit();
+    pathLabel->setBuddy(m_pathEdit);
+    m_browseBtn = new QPushButton(tr("&Browse"));
+    connect(m_browseBtn, &QPushButton::clicked,
+            this, &VExporter::handleBrowseBtnClicked);
+
+    // Page layout.
+    QLabel *layoutLabel = new QLabel(tr("Page layout:"));
+    m_layoutLabel = new QLabel();
+    m_layoutBtn = new QPushButton(tr("&Settings"));
+
+#ifndef QT_NO_PRINTER
+    connect(m_layoutBtn, &QPushButton::clicked,
+            this, &VExporter::handleLayoutBtnClicked);
+#else
+    m_layoutBtn->hide();
+#endif
+
+    // Progress.
+    m_proLabel = new QLabel(this);
+    m_proBar = new QProgressBar(this);
+
+    // Ok is the default button.
+    m_btnBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
+    connect(m_btnBox, &QDialogButtonBox::accepted, this, &VExporter::startExport);
+    connect(m_btnBox, &QDialogButtonBox::rejected, this, &VExporter::cancelExport);
+
+    QPushButton *okBtn = m_btnBox->button(QDialogButtonBox::Ok);
+    m_pathEdit->setMinimumWidth(okBtn->sizeHint().width() * 3);
+
+    QGridLayout *mainLayout = new QGridLayout();
+    mainLayout->addWidget(m_webViewer, 0, 0, 1, 3);
+    mainLayout->addWidget(m_infoLabel, 1, 0, 1, 3);
+    mainLayout->addWidget(pathLabel, 2, 0);
+    mainLayout->addWidget(m_pathEdit, 2, 1);
+    mainLayout->addWidget(m_browseBtn, 2, 2);
+    mainLayout->addWidget(layoutLabel, 3, 0);
+    mainLayout->addWidget(m_layoutLabel, 3, 1);
+    mainLayout->addWidget(m_layoutBtn, 3, 2);
+    mainLayout->addWidget(m_proLabel, 4, 1, 1, 2);
+    mainLayout->addWidget(m_proBar, 5, 1, 1, 2);
+    mainLayout->addWidget(m_btnBox, 6, 1, 1, 2);
+
+    // Only use VWebView to do the conversion.
+    m_webViewer->hide();
+
+    m_proLabel->hide();
+    m_proBar->hide();
+
+    setLayout(mainLayout);
+    mainLayout->setSizeConstraint(QLayout::SetFixedSize);
+    setWindowTitle(tr("Export Note"));
+
+    updatePageLayoutLabel();
+}
+
+static QString exportTypeStr(ExportType p_type)
+{
+    if (p_type == ExportType::PDF) {
+        return "PDF";
+    } else {
+        return "HTML";
+    }
+}
+
+void VExporter::handleBrowseBtnClicked()
+{
+    QFileInfo fi(getFilePath());
+    QString fileType = m_type == ExportType::PDF ?
+                       tr("Portable Document Format (*.pdf)") :
+                       tr("WebPage, Complete (*.html)");
+    QString path = QFileDialog::getSaveFileName(this, tr("Export As"),
+                                                fi.absolutePath(),
+                                                fileType);
+    if (path.isEmpty()) {
+        return;
+    }
+
+    setFilePath(path);
+    s_defaultPathDir = VUtils::basePathFromPath(path);
+}
+
+void VExporter::handleLayoutBtnClicked()
+{
+#ifndef QT_NO_PRINTER
+    QPrinter printer;
+    printer.setPageLayout(m_pageLayout);
+
+    QPageSetupDialog dlg(&printer, this);
+    if (dlg.exec() != QDialog::Accepted) {
+        return;
+    }
+
+    m_pageLayout.setPageSize(printer.pageLayout().pageSize());
+    m_pageLayout.setOrientation(printer.pageLayout().orientation());
+
+    updatePageLayoutLabel();
+#endif
+}
+
+void VExporter::updatePageLayoutLabel()
+{
+    m_layoutLabel->setText(QString("%1, %2").arg(m_pageLayout.pageSize().name())
+                                            .arg(m_pageLayout.orientation() == QPageLayout::Portrait ?
+                                                 tr("Portrait") : tr("Landscape")));
+}
+
+QString VExporter::getFilePath() const
+{
+    return QDir::cleanPath(m_pathEdit->text());
+}
+
+void VExporter::setFilePath(const QString &p_path)
+{
+    m_pathEdit->setText(QDir::toNativeSeparators(p_path));
+}
+
+void VExporter::exportNote(VFile *p_file, ExportType p_type)
+{
+    m_file = p_file;
+    m_type = p_type;
+    m_source = ExportSource::Note;
+
+    if (!m_file || m_file->getDocType() != DocType::Markdown) {
+        // Do not support non-Markdown note now.
+        m_btnBox->button(QDialogButtonBox::Ok)->setEnabled(false);
+        return;
+    }
+
+    m_infoLabel->setText(tr("Export note <span style=\"%1\">%2</span> as %3.")
+                            .arg(vconfig.c_dataTextStyle)
+                            .arg(m_file->getName())
+                            .arg(exportTypeStr(p_type)));
+
+    setWindowTitle(tr("Export As %1").arg(exportTypeStr(p_type)));
+
+    setFilePath(QDir(s_defaultPathDir).filePath(QFileInfo(p_file->retrivePath()).baseName() +
+                                                "." + exportTypeStr(p_type).toLower()));
+}
+
+void VExporter::setupMarkdownViewer()
+{
+    m_webViewer = new VWebView(NULL, this);
+    VPreviewPage *page = new VPreviewPage(this);
+    m_webViewer->setPage(page);
+
+    QWebChannel *channel = new QWebChannel(this);
+    channel->registerObject(QStringLiteral("content"), &m_document);
+    page->setWebChannel(channel);
+
+    connect(&m_document, &VDocument::loadFinished,
+            this, &VExporter::readyToExport);
+
+    QString jsFile, extraFile;
+    switch (m_mdType) {
+    case MarkdownConverterType::Marked:
+        jsFile = "qrc" + VNote::c_markedJsFile;
+        extraFile = "<script src=\"qrc" + VNote::c_markedExtraFile + "\"></script>\n";
+        break;
+
+    case MarkdownConverterType::Hoedown:
+        jsFile = "qrc" + VNote::c_hoedownJsFile;
+        // Use Marked to highlight code blocks.
+        extraFile = "<script src=\"qrc" + VNote::c_markedExtraFile + "\"></script>\n";
+        break;
+
+    case MarkdownConverterType::MarkdownIt:
+        jsFile = "qrc" + VNote::c_markdownitJsFile;
+        extraFile = "<script src=\"qrc" + VNote::c_markdownitExtraFile + "\"></script>\n" +
+                    "<script src=\"qrc" + VNote::c_markdownitAnchorExtraFile + "\"></script>\n" +
+                    "<script src=\"qrc" + VNote::c_markdownitTaskListExtraFile + "\"></script>\n";
+        break;
+
+    case MarkdownConverterType::Showdown:
+        jsFile = "qrc" + VNote::c_showdownJsFile;
+        extraFile = "<script src=\"qrc" + VNote::c_showdownExtraFile + "\"></script>\n" +
+                    "<script src=\"qrc" + VNote::c_showdownAnchorExtraFile + "\"></script>\n";
+
+        break;
+
+    default:
+        Q_ASSERT(false);
+    }
+
+    if (vconfig.getEnableMermaid()) {
+        extraFile += "<link rel=\"stylesheet\" type=\"text/css\" href=\"qrc" + VNote::c_mermaidCssFile +
+                     "\"/>\n" + "<script src=\"qrc" + VNote::c_mermaidApiJsFile + "\"></script>\n" +
+                     "<script>var VEnableMermaid = true;</script>\n";
+    }
+
+    if (vconfig.getEnableMathjax()) {
+        extraFile += "<script type=\"text/x-mathjax-config\">"
+                     "MathJax.Hub.Config({\n"
+                     "                    tex2jax: {inlineMath: [['$','$'], ['\\\\(','\\\\)']]},\n"
+                     "                    showProcessingMessages: false,\n"
+                     "                    messageStyle: \"none\"});\n"
+                     "</script>\n"
+                     "<script type=\"text/javascript\" async src=\"" + VNote::c_mathjaxJsFile + "\"></script>\n" +
+                     "<script>var VEnableMathjax = true;</script>\n";
+    }
+
+    if (vconfig.getEnableImageCaption()) {
+        extraFile += "<script>var VEnableImageCaption = true;</script>\n";
+    }
+
+    m_htmlTemplate = VNote::s_markdownTemplatePDF;
+    m_htmlTemplate.replace(c_htmlJSHolder, jsFile);
+    if (!extraFile.isEmpty()) {
+        m_htmlTemplate.replace(c_htmlExtraHolder, extraFile);
+    }
+}
+
+void VExporter::updateWebViewer(VFile *p_file)
+{
+    m_document.setFile(p_file);
+
+    // Need to generate HTML using Hoedown.
+    if (m_mdType == MarkdownConverterType::Hoedown) {
+        VMarkdownConverter mdConverter;
+        QString toc;
+        QString html = mdConverter.generateHtml(p_file->getContent(),
+                                                vconfig.getMarkdownExtensions(),
+                                                toc);
+        m_document.setHtml(html);
+    }
+
+    m_webViewer->setHtml(m_htmlTemplate, p_file->getBaseUrl());
+}
+
+void VExporter::readyToExport()
+{
+    Q_ASSERT(!m_webReady);
+    m_webReady = true;
+}
+
+void VExporter::startExport()
+{
+    enableUserInput(false);
+    V_ASSERT(m_state == ExportState::Idle);
+    m_state = ExportState::Busy;
+
+    if (m_source == ExportSource::Note) {
+        V_ASSERT(m_file);
+        bool isOpened = m_file->isOpened();
+        if (!isOpened && !m_file->open()) {
+            goto exit;
+        }
+
+        m_webReady = false;
+        updateWebViewer(m_file);
+
+        // Update progress info.
+        m_proLabel->setText(tr("Exporting %1").arg(m_file->getName()));
+        m_proBar->setMinimum(0);
+        m_proBar->setMaximum(100);
+        m_proBar->reset();
+        m_proLabel->show();
+        m_proBar->show();
+
+        while (!m_webReady) {
+            VUtils::sleepWait(100);
+            if (m_proBar->value() < 70) {
+                m_proBar->setValue(m_proBar->value() + 1);
+            }
+
+            if (m_state == ExportState::Cancelled) {
+                goto exit;
+            }
+        }
+
+        // Wait to ensure Web side is really ready.
+        VUtils::sleepWait(200);
+
+        if (m_state == ExportState::Cancelled) {
+            goto exit;
+        }
+
+        m_proBar->setValue(80);
+
+        exportToPDF(m_webViewer, getFilePath(), m_pageLayout);
+
+        m_proBar->setValue(100);
+
+        m_webReady = false;
+
+        if (!isOpened) {
+            m_file->close();
+        }
+    }
+
+exit:
+    m_proLabel->setText("");
+    m_proBar->reset();
+    m_proLabel->hide();
+    m_proBar->hide();
+    enableUserInput(true);
+
+    if (m_state == ExportState::Cancelled) {
+        reject();
+    } else {
+        accept();
+    }
+
+    m_state = ExportState::Idle;
+}
+
+void VExporter::cancelExport()
+{
+    if (m_state == ExportState::Idle) {
+        reject();
+    } else {
+        m_state = ExportState::Cancelled;
+    }
+}
+
+void VExporter::exportToPDF(VWebView *p_webViewer, const QString &p_filePath,
+                            const QPageLayout &p_layout)
+{
+    int pdfPrinted = 0;
+    p_webViewer->page()->printToPdf([&, this](const QByteArray &p_result) {
+        if (p_result.isEmpty() || this->m_state == ExportState::Cancelled) {
+            pdfPrinted = -1;
+            return;
+        }
+
+        V_ASSERT(!p_filePath.isEmpty());
+
+        QFile file(p_filePath);
+
+        if (!file.open(QFile::WriteOnly)) {
+            pdfPrinted = -1;
+            return;
+        }
+
+        file.write(p_result.data(), p_result.size());
+        file.close();
+
+        pdfPrinted = 1;
+    }, p_layout);
+
+    while (pdfPrinted == 0) {
+        VUtils::sleepWait(100);
+
+        if (m_state == ExportState::Cancelled) {
+            break;
+        }
+    }
+
+    qDebug() << "export to PDF" << p_filePath << "state" << pdfPrinted;
+}
+
+void VExporter::enableUserInput(bool p_enabled)
+{
+    m_btnBox->button(QDialogButtonBox::Ok)->setEnabled(p_enabled);
+    m_pathEdit->setEnabled(p_enabled);
+    m_browseBtn->setEnabled(p_enabled);
+    m_layoutBtn->setEnabled(p_enabled);
+}

+ 101 - 0
src/vexporter.h

@@ -0,0 +1,101 @@
+#ifndef VEXPORTER_H
+#define VEXPORTER_H
+
+#include <QDialog>
+#include <QPageLayout>
+#include <QString>
+#include "vconfigmanager.h"
+#include "vdocument.h"
+
+class VWebView;
+class VFile;
+class QLineEdit;
+class QLabel;
+class QDialogButtonBox;
+class QPushButton;
+class QProgressBar;
+
+enum class ExportType
+{
+    PDF = 0,
+    HTML
+};
+
+enum class ExportSource
+{
+    Note = 0,
+    Directory,
+    Notebook,
+    Invalid
+};
+
+enum class ExportState
+{
+    Idle = 0,
+    Cancelled,
+    Busy
+};
+
+class VExporter : public QDialog
+{
+    Q_OBJECT
+public:
+    explicit VExporter(MarkdownConverterType p_mdType = MarkdownIt, QWidget *p_parent = 0);
+
+    void exportNote(VFile *p_file, ExportType p_type);
+
+private slots:
+    void handleBrowseBtnClicked();
+    void handleLayoutBtnClicked();
+    void startExport();
+    void cancelExport();
+
+private:
+    void setupUI();
+
+    // Init m_webViewer, m_document, and m_htmlTemplate.
+    void setupMarkdownViewer();
+
+    void updatePageLayoutLabel();
+
+    void setFilePath(const QString &p_path);
+
+    QString getFilePath() const;
+
+    void updateWebViewer(VFile *p_file);
+
+    void readyToExport();
+
+    void enableUserInput(bool p_enabled);
+
+    void exportToPDF(VWebView *p_webViewer, const QString &p_filePath, const QPageLayout &p_layout);
+
+    VWebView *m_webViewer;
+    VDocument m_document;
+    MarkdownConverterType m_mdType;
+    QString m_htmlTemplate;
+    VFile *m_file;
+    ExportType m_type;
+    ExportSource m_source;
+    bool m_webReady;
+
+    ExportState m_state;
+
+    QLabel *m_infoLabel;
+    QLineEdit *m_pathEdit;
+    QPushButton *m_browseBtn;
+    QLabel *m_layoutLabel;
+    QPushButton *m_layoutBtn;
+    QDialogButtonBox *m_btnBox;
+
+    // Progress label and bar.
+    QLabel *m_proLabel;
+    QProgressBar *m_proBar;
+
+    QPageLayout m_pageLayout;
+
+    // The default directory.
+    static QString s_defaultPathDir;
+};
+
+#endif // VEXPORTER_H

+ 23 - 0
src/vfile.cpp

@@ -3,6 +3,7 @@
 #include <QDir>
 #include <QDebug>
 #include <QTextEdit>
+#include <QFileInfo>
 #include "utils/vutils.h"
 
 VFile::VFile(const QString &p_name, QObject *p_parent,
@@ -214,3 +215,25 @@ bool VFile::isInternalImageFolder(const QString &p_path) const
 {
     return VUtils::basePathFromPath(p_path) == getDirectory()->retrivePath();
 }
+
+QUrl VFile::getBaseUrl() const
+{
+    // Need to judge the path: Url, local file, resource file.
+    QUrl baseUrl;
+    QString basePath = retriveBasePath();
+    QFileInfo pathInfo(basePath);
+    if (pathInfo.exists()) {
+        if (pathInfo.isNativePath()) {
+            // Local file.
+            baseUrl = QUrl::fromLocalFile(basePath + QDir::separator());
+        } else {
+            // Resource file.
+            baseUrl = QUrl("qrc" + basePath + QDir::separator());
+        }
+    } else {
+        // Url.
+        baseUrl = QUrl(basePath + QDir::separator());
+    }
+
+    return baseUrl;
+}

+ 4 - 0
src/vfile.h

@@ -3,6 +3,7 @@
 
 #include <QObject>
 #include <QString>
+#include <QUrl>
 #include "vdirectory.h"
 #include "vconstants.h"
 
@@ -40,6 +41,9 @@ public:
     bool isOpened() const;
     FileType getType() const;
 
+    // Return the base URL for this file when loaded in VWebView.
+    QUrl getBaseUrl() const;
+
     // Whether the directory @p_path is an internal image folder of this file.
     // It is true only when the folder is in the same directory as the parent
     // directory of this file.

+ 24 - 0
src/vmainwindow.cpp

@@ -18,6 +18,7 @@
 #include "vcaptain.h"
 #include "vedittab.h"
 #include "vwebview.h"
+#include "vexporter.h"
 
 extern VConfigManager vconfig;
 
@@ -453,6 +454,18 @@ void VMainWindow::initFileMenu()
 
     fileMenu->addSeparator();
 
+    // Export as PDF.
+    m_exportAsPDFAct = new QAction(QIcon(":/resources/icons/export_pdf.svg"),
+                                tr("Export As &PDF"), this);
+    m_exportAsPDFAct->setToolTip(tr("Export current note as PDF file"));
+    connect(m_exportAsPDFAct, &QAction::triggered,
+            this, &VMainWindow::exportAsPDF);
+    m_exportAsPDFAct->setEnabled(false);
+
+    fileMenu->addAction(m_exportAsPDFAct);
+
+    fileMenu->addSeparator();
+
     // Print.
     m_printAct = new QAction(QIcon(":/resources/icons/print.svg"),
                              tr("&Print"), this);
@@ -1016,6 +1029,7 @@ void VMainWindow::updateActionStateFromTabStatusChange(const VFile *p_file,
                                                        bool p_editMode)
 {
     m_printAct->setEnabled(p_file && p_file->getDocType() == DocType::Markdown);
+    m_exportAsPDFAct->setEnabled(p_file && p_file->getDocType() == DocType::Markdown);
 
     editNoteAct->setVisible(p_file && p_file->isModifiable() && !p_editMode);
     discardExitAct->setVisible(p_file && p_editMode);
@@ -1393,3 +1407,13 @@ void VMainWindow::printNote()
     }
 }
 
+void VMainWindow::exportAsPDF()
+{
+    V_ASSERT(m_curTab);
+    V_ASSERT(m_curFile);
+
+    VExporter exporter(m_curTab->getMarkdownConverterType(), this);
+    exporter.exportNote(m_curFile, ExportType::PDF);
+    exporter.exec();
+}
+

+ 2 - 0
src/vmainwindow.h

@@ -81,6 +81,7 @@ private slots:
     void enableImageConstraint(bool p_checked);
     void enableImageCaption(bool p_checked);
     void printNote();
+    void exportAsPDF();
 
 protected:
     void closeEvent(QCloseEvent *event) Q_DECL_OVERRIDE;
@@ -155,6 +156,7 @@ private:
     QAction *expandViewAct;
     QAction *m_importNoteAct;
     QAction *m_printAct;
+    QAction *m_exportAsPDFAct;
 
     QAction *m_insertImageAct;
     QAction *m_findReplaceAct;

+ 28 - 0
src/vmarkdownconverter.cpp

@@ -1,4 +1,5 @@
 #include "vmarkdownconverter.h"
+#include <QRegularExpression>
 
 VMarkdownConverter::VMarkdownConverter()
 {
@@ -36,11 +37,35 @@ QString VMarkdownConverter::generateHtml(const QString &markdown, hoedown_extens
     return html;
 }
 
+QString VMarkdownConverter::generateHtml(const QString &markdown, hoedown_extensions options, QString &toc)
+{
+    if (markdown.isEmpty()) {
+        return QString();
+    }
+
+    QString html = generateHtml(markdown, options);
+    QRegularExpression tocExp("<p>\\[TOC\\]<\\/p>", QRegularExpression::CaseInsensitiveOption);
+    toc = generateToc(markdown, options);
+    html.replace(tocExp, toc);
+
+    return html;
+}
+
+static void processToc(QString &p_toc)
+{
+    // Hoedown will add '\n'.
+    p_toc.replace("\n", "");
+    // Hoedown will translate `_` in title to `<em>`.
+    p_toc.replace("<em>", "_");
+    p_toc.replace("</em>", "_");
+}
+
 QString VMarkdownConverter::generateToc(const QString &markdown, hoedown_extensions options)
 {
     if (markdown.isEmpty()) {
         return QString();
     }
+
     hoedown_document *document = hoedown_document_new(tocRenderer, options, nestingLevel);
     QByteArray data = markdown.toUtf8();
     hoedown_buffer *outBuf = hoedown_buffer_new(16);
@@ -48,5 +73,8 @@ QString VMarkdownConverter::generateToc(const QString &markdown, hoedown_extensi
     hoedown_document_free(document);
     QString toc = QString::fromUtf8(hoedown_buffer_cstr(outBuf));
     hoedown_buffer_free(outBuf);
+
+    processToc(toc);
+
     return toc;
 }

+ 4 - 1
src/vmarkdownconverter.h

@@ -14,10 +14,13 @@ public:
     VMarkdownConverter();
     ~VMarkdownConverter();
 
-    QString generateHtml(const QString &markdown, hoedown_extensions options);
+    QString generateHtml(const QString &markdown, hoedown_extensions options, QString &toc);
+
     QString generateToc(const QString &markdown, hoedown_extensions options);
 
 private:
+    QString generateHtml(const QString &markdown, hoedown_extensions options);
+
     // VMarkdownDocument *generateDocument(const QString &markdown);
     hoedown_html_flags hoedownHtmlFlags;
     int nestingLevel;

+ 11 - 2
src/vnote.cpp

@@ -16,6 +16,7 @@
 extern VConfigManager vconfig;
 
 QString VNote::s_markdownTemplate;
+QString VNote::s_markdownTemplatePDF;
 
 const QString VNote::c_hoedownJsFile = ":/resources/hoedown.js";
 const QString VNote::c_markedJsFile = ":/resources/marked.js";
@@ -170,14 +171,22 @@ void VNote::updateTemplate()
         cssStyle += "img { max-width: 100% !important; height: auto !important; }\n";
     }
 
-    QString styleHolder("<!-- BACKGROUND_PLACE_HOLDER -->");
-    QString cssHolder("CSS_PLACE_HOLDER");
+    const QString styleHolder("<!-- BACKGROUND_PLACE_HOLDER -->");
+    const QString cssHolder("CSS_PLACE_HOLDER");
 
     s_markdownTemplate = VUtils::readFileFromDisk(c_markdownTemplatePath);
     s_markdownTemplate.replace(cssHolder, vconfig.getTemplateCssUrl());
+
+    s_markdownTemplatePDF = s_markdownTemplate;
+
     if (!cssStyle.isEmpty()) {
         s_markdownTemplate.replace(styleHolder, cssStyle);
     }
+
+    // Shoudl not display scrollbar in PDF.
+    cssStyle += "pre code { white-space: pre-wrap !important; "
+                           "word-break: break-all !important; }\n";
+    s_markdownTemplatePDF.replace(styleHolder, cssStyle);
 }
 
 const QVector<VNotebook *> &VNote::getNotebooks() const

+ 1 - 0
src/vnote.h

@@ -28,6 +28,7 @@ public:
     void initTemplate();
 
     static QString s_markdownTemplate;
+    static QString s_markdownTemplatePDF;
 
     // Hoedown
     static const QString c_hoedownJsFile;

+ 1 - 1
src/vwebview.cpp

@@ -20,7 +20,7 @@ void VWebView::contextMenuEvent(QContextMenuEvent *p_event)
 
     const QList<QAction *> actions = menu->actions();
 
-    if (!hasSelection() && m_file->isModifiable()) {
+    if (!hasSelection() && m_file && m_file->isModifiable()) {
         QAction *editAct= new QAction(QIcon(":/resources/icons/edit_note.svg"),
                                           tr("&Edit"), this);
         editAct->setToolTip(tr("Edit current note"));

+ 1 - 0
src/vwebview.h

@@ -9,6 +9,7 @@ class VWebView : public QWebEngineView
 {
     Q_OBJECT
 public:
+    // @p_file could be NULL.
     explicit VWebView(VFile *p_file, QWidget *p_parent = Q_NULLPTR);
 
 signals: