Le Tan пре 4 година
родитељ
комит
04a57f4f8d
100 измењених фајлова са 3882 додато и 275 уклоњено
  1. 5 0
      src/core/buffer/buffer.cpp
  2. 4 0
      src/core/buffer/buffer.h
  3. 3 0
      src/core/buffer/bufferprovider.h
  4. 5 0
      src/core/buffer/filebufferprovider.cpp
  5. 2 0
      src/core/buffer/filebufferprovider.h
  6. 21 10
      src/core/buffer/filetypehelper.cpp
  7. 14 14
      src/core/buffer/filetypehelper.h
  8. 9 3
      src/core/buffer/nodebufferprovider.cpp
  9. 5 1
      src/core/buffer/nodebufferprovider.h
  10. 9 6
      src/core/buffermgr.cpp
  11. 28 1
      src/core/configmgr.cpp
  12. 6 0
      src/core/configmgr.h
  13. 1 1
      src/core/core.pri
  14. 1 0
      src/core/coreconfig.h
  15. 1 0
      src/core/externalfile.cpp
  16. 1 1
      src/core/file.cpp
  17. 2 2
      src/core/file.h
  18. 130 25
      src/core/htmltemplatehelper.cpp
  19. 41 0
      src/core/htmltemplatehelper.h
  20. 33 1
      src/core/markdowneditorconfig.cpp
  21. 13 6
      src/core/markdowneditorconfig.h
  22. 4 0
      src/core/notebook/node.cpp
  23. 1 0
      src/core/notebook/vxnodefile.cpp
  24. 0 2
      src/core/notebookconfigmgr/notebookconfigmgr.pri
  25. 5 5
      src/core/notebookconfigmgr/vxnotebookconfigmgr.cpp
  26. 24 12
      src/core/sessionconfig.cpp
  27. 9 2
      src/core/sessionconfig.h
  28. 6 1
      src/core/theme.cpp
  29. 2 0
      src/core/theme.h
  30. 38 0
      src/core/thememgr.cpp
  31. 10 1
      src/core/thememgr.h
  32. 2 0
      src/core/vnotex.cpp
  33. 2 0
      src/core/vnotex.h
  34. 10 5
      src/core/webresource.h
  35. 25 2
      src/data/core/vnotex.json
  36. 5 1
      src/data/extra/extra.qrc
  37. 11 3
      src/data/extra/themes/moonlight/interface.qss
  38. 1 1
      src/data/extra/themes/moonlight/palette.json
  39. 3 3
      src/data/extra/themes/native/interface.qss
  40. 2 2
      src/data/extra/themes/pure/highlight.css
  41. 11 3
      src/data/extra/themes/pure/interface.qss
  42. 2 2
      src/data/extra/themes/pure/palette.json
  43. 5 5
      src/data/extra/themes/pure/text-editor.theme
  44. 2 2
      src/data/extra/themes/pure/web.css
  45. 3 0
      src/data/extra/web/css/exportglobalstyles.css
  46. 27 3
      src/data/extra/web/css/globalstyles.css
  47. 205 0
      src/data/extra/web/css/outline.css
  48. 2 1
      src/data/extra/web/js/graphviz.js
  49. 0 22
      src/data/extra/web/js/markdownit.js
  50. 4 0
      src/data/extra/web/js/markdownviewer.js
  51. 2 1
      src/data/extra/web/js/mathjax.js
  52. 241 0
      src/data/extra/web/js/outline.js
  53. 2 1
      src/data/extra/web/js/plantuml.js
  54. 51 0
      src/data/extra/web/js/utils.js
  55. 53 7
      src/data/extra/web/js/vnotex.js
  56. 45 0
      src/data/extra/web/markdown-export-template.html
  57. 0 0
      src/data/extra/web/markdown-viewer-template.html
  58. 11 0
      src/export/export.pri
  59. 126 0
      src/export/exportdata.cpp
  60. 114 0
      src/export/exportdata.h
  61. 312 0
      src/export/exporter.cpp
  62. 70 0
      src/export/exporter.h
  63. 568 0
      src/export/webviewexporter.cpp
  64. 110 0
      src/export/webviewexporter.h
  65. 2 0
      src/src.pro
  66. 68 55
      src/utils/contentmediautils.cpp
  67. 12 8
      src/utils/contentmediautils.h
  68. 1 1
      src/utils/fileutils.h
  69. 6 0
      src/utils/htmlutils.cpp
  70. 2 0
      src/utils/htmlutils.h
  71. 158 0
      src/utils/processutils.cpp
  72. 52 0
      src/utils/processutils.h
  73. 0 10
      src/utils/textutils.cpp
  74. 0 3
      src/utils/textutils.h
  75. 6 0
      src/utils/utils.pri
  76. 103 0
      src/utils/webutils.cpp
  77. 24 0
      src/utils/webutils.h
  78. 0 13
      src/utils/widgetutils.cpp
  79. 0 3
      src/utils/widgetutils.h
  80. 34 4
      src/widgets/dialogs/dialog.cpp
  81. 11 2
      src/widgets/dialogs/dialog.h
  82. 679 0
      src/widgets/dialogs/exportdialog.cpp
  83. 171 0
      src/widgets/dialogs/exportdialog.h
  84. 1 1
      src/widgets/dialogs/filepropertiesdialog.cpp
  85. 1 1
      src/widgets/dialogs/folderfilesfilterwidget.cpp
  86. 1 1
      src/widgets/dialogs/linkinsertdialog.cpp
  87. 2 2
      src/widgets/dialogs/nodeinfowidget.cpp
  88. 2 2
      src/widgets/dialogs/notebookinfowidget.cpp
  89. 5 0
      src/widgets/dialogs/scrolldialog.cpp
  90. 4 2
      src/widgets/dialogs/scrolldialog.h
  91. 1 1
      src/widgets/dialogs/settings/appearancepage.cpp
  92. 1 1
      src/widgets/dialogs/settings/editorpage.cpp
  93. 1 1
      src/widgets/dialogs/settings/generalpage.cpp
  94. 3 3
      src/widgets/dialogs/settings/markdowneditorpage.cpp
  95. 1 1
      src/widgets/dialogs/settings/texteditorpage.cpp
  96. 2 1
      src/widgets/editors/markdowneditor.cpp
  97. 28 0
      src/widgets/editors/markdownvieweradapter.cpp
  98. 19 0
      src/widgets/editors/markdownvieweradapter.h
  99. 19 1
      src/widgets/mainwindow.cpp
  100. 2 0
      src/widgets/mainwindow.h

+ 5 - 0
src/core/buffer/buffer.cpp

@@ -562,3 +562,8 @@ Buffer::StateFlags Buffer::state() const
 {
     return m_state;
 }
+
+QSharedPointer<File> Buffer::getFile() const
+{
+    return m_provider->getFile();
+}

+ 4 - 0
src/core/buffer/buffer.h

@@ -18,6 +18,7 @@ namespace vnotex
     class ViewWindow;
     struct FileOpenParameters;
     class BufferProvider;
+    class File;
 
     struct BufferParameters
     {
@@ -83,6 +84,9 @@ namespace vnotex
         // Get the base path to resolve resources.
         QString getResourcePath() const;
 
+        // Return nullptr if not available.
+        QSharedPointer<File> getFile() const;
+
         ID getID() const;
 
         // Get buffer content.

+ 3 - 0
src/core/buffer/bufferprovider.h

@@ -74,6 +74,9 @@ namespace vnotex
 
         virtual bool isReadOnly() const = 0;
 
+        // Return nullptr if not available.
+        virtual QSharedPointer<File> getFile() const = 0;
+
     protected:
         virtual QDateTime getLastModifiedFromFile() const;
 

+ 5 - 0
src/core/buffer/filebufferprovider.cpp

@@ -178,3 +178,8 @@ bool FileBufferProvider::isReadOnly() const
 {
     return m_readOnly;
 }
+
+QSharedPointer<File> FileBufferProvider::getFile() const
+{
+    return m_file;
+}

+ 2 - 0
src/core/buffer/filebufferprovider.h

@@ -65,6 +65,8 @@ namespace vnotex
 
         bool isReadOnly() const Q_DECL_OVERRIDE;
 
+        QSharedPointer<File> getFile() const Q_DECL_OVERRIDE;
+
     private:
         QSharedPointer<File> m_file;
 

+ 21 - 10
src/core/buffer/filetypehelper.cpp

@@ -8,6 +8,16 @@
 
 using namespace vnotex;
 
+QString FileType::preferredSuffix() const
+{
+    return m_suffixes.isEmpty() ? QString() : m_suffixes.first();
+}
+
+bool FileType::isMarkdown() const
+{
+    return m_type == Type::Markdown;
+}
+
 FileTypeHelper::FileTypeHelper()
 {
     setupBuiltInTypes();
@@ -21,7 +31,7 @@ void FileTypeHelper::setupBuiltInTypes()
 {
     {
         FileType type;
-        type.m_type = Type::Markdown;
+        type.m_type = FileType::Markdown;
         type.m_displayName = Buffer::tr("Markdown");
         type.m_typeName = QStringLiteral("Markdown");
         type.m_suffixes << QStringLiteral("md")
@@ -33,7 +43,7 @@ void FileTypeHelper::setupBuiltInTypes()
 
     {
         FileType type;
-        type.m_type = Type::Text;
+        type.m_type = FileType::Text;
         type.m_typeName = QStringLiteral("Text");
         type.m_displayName = Buffer::tr("Text");
         type.m_suffixes << QStringLiteral("txt") << QStringLiteral("text") << QStringLiteral("log");
@@ -42,7 +52,7 @@ void FileTypeHelper::setupBuiltInTypes()
 
     {
         FileType type;
-        type.m_type = Type::Others;
+        type.m_type = FileType::Others;
         type.m_typeName = QStringLiteral("Others");
         type.m_displayName = Buffer::tr("Others");
         m_fileTypes.push_back(type);
@@ -62,10 +72,10 @@ const FileType &FileTypeHelper::getFileType(const QString &p_filePath) const
 
     // Treat all unknown text files as plain text files.
     if (FileUtils::isText(p_filePath)) {
-        return m_fileTypes[Type::Text];
+        return m_fileTypes[FileType::Text];
     }
 
-    return m_fileTypes[Type::Others];
+    return m_fileTypes[FileType::Others];
 }
 
 const FileType &FileTypeHelper::getFileTypeBySuffix(const QString &p_suffix) const
@@ -74,7 +84,7 @@ const FileType &FileTypeHelper::getFileTypeBySuffix(const QString &p_suffix) con
     if (it != m_suffixTypeMap.end()) {
         return m_fileTypes.at(it.value());
     } else {
-        return m_fileTypes[Type::Others];
+        return m_fileTypes[FileType::Others];
     }
 }
 
@@ -97,8 +107,9 @@ const QVector<FileType> &FileTypeHelper::getAllFileTypes() const
     return m_fileTypes;
 }
 
-const FileType &FileTypeHelper::getFileType(Type p_type) const
+const FileType &FileTypeHelper::getFileType(int p_type) const
 {
+    Q_ASSERT(p_type < m_fileTypes.size());
     return m_fileTypes[p_type];
 }
 
@@ -108,9 +119,9 @@ const FileTypeHelper &FileTypeHelper::getInst()
     return helper;
 }
 
-bool FileTypeHelper::checkFileType(const QString &p_filePath, Type p_type) const
+bool FileTypeHelper::checkFileType(const QString &p_filePath, int p_type) const
 {
-    return getFileType(p_filePath).m_type == static_cast<int>(p_type);
+    return getFileType(p_filePath).m_type == p_type;
 }
 
 const FileType &FileTypeHelper::getFileTypeByName(const QString &p_typeName) const
@@ -122,5 +133,5 @@ const FileType &FileTypeHelper::getFileTypeByName(const QString &p_typeName) con
     }
 
     Q_ASSERT(false);
-    return m_fileTypes[Type::Others];
+    return m_fileTypes[FileType::Others];
 }

+ 14 - 14
src/core/buffer/filetypehelper.h

@@ -10,7 +10,15 @@ namespace vnotex
     class FileType
     {
     public:
-        // FileTypeHelper::Type.
+        // There may be other types after Others.
+        enum Type
+        {
+            Markdown = 0,
+            Text,
+            Others
+        };
+
+        // Type.
         int m_type = -1;
 
         QString m_typeName;
@@ -19,25 +27,17 @@ namespace vnotex
 
         QStringList m_suffixes;
 
-        QString preferredSuffix() const
-        {
-            return m_suffixes.isEmpty() ? QString() : m_suffixes.first();
-        }
+        QString preferredSuffix() const;
+
+        bool isMarkdown() const;
     };
 
     class FileTypeHelper
     {
     public:
-        enum Type
-        {
-            Markdown = 0,
-            Text,
-            Others
-        };
-
         const FileType &getFileType(const QString &p_filePath) const;
 
-        const FileType &getFileType(Type p_type) const;
+        const FileType &getFileType(int p_type) const;
 
         const FileType &getFileTypeByName(const QString &p_typeName) const;
 
@@ -45,7 +45,7 @@ namespace vnotex
 
         const QVector<FileType> &getAllFileTypes() const;
 
-        bool checkFileType(const QString &p_filePath, Type p_type) const;
+        bool checkFileType(const QString &p_filePath, int p_type) const;
 
         static const FileTypeHelper &getInst();
 

+ 9 - 3
src/core/buffer/nodebufferprovider.cpp

@@ -8,12 +8,13 @@
 
 using namespace vnotex;
 
-NodeBufferProvider::NodeBufferProvider(const QSharedPointer<Node> &p_node, QObject *p_parent)
+NodeBufferProvider::NodeBufferProvider(const QSharedPointer<Node> &p_node,
+                                       const QSharedPointer<File> &p_file,
+                                       QObject *p_parent)
     : BufferProvider(p_parent),
       m_node(p_node),
-      m_nodeFile(p_node->getContentFile())
+      m_nodeFile(p_file)
 {
-    Q_ASSERT(m_nodeFile);
 }
 
 Buffer::ProviderType NodeBufferProvider::getType() const
@@ -156,3 +157,8 @@ bool NodeBufferProvider::isReadOnly() const
 {
     return m_node->isReadOnly();
 }
+
+QSharedPointer<File> NodeBufferProvider::getFile() const
+{
+    return m_nodeFile;
+}

+ 5 - 1
src/core/buffer/nodebufferprovider.h

@@ -15,7 +15,9 @@ namespace vnotex
     {
         Q_OBJECT
     public:
-        NodeBufferProvider(const QSharedPointer<Node> &p_node, QObject *p_parent = nullptr);
+        NodeBufferProvider(const QSharedPointer<Node> &p_node,
+                           const QSharedPointer<File> &p_file,
+                           QObject *p_parent = nullptr);
 
         Buffer::ProviderType getType() const Q_DECL_OVERRIDE;
 
@@ -65,6 +67,8 @@ namespace vnotex
 
         bool isReadOnly() const Q_DECL_OVERRIDE;
 
+        QSharedPointer<File> getFile() const Q_DECL_OVERRIDE;
+
     private:
         QSharedPointer<Node> m_node;
 

+ 9 - 6
src/core/buffermgr.cpp

@@ -42,11 +42,11 @@ void BufferMgr::initBufferServer()
 
     // Markdown.
     auto markdownFactory = QSharedPointer<MarkdownBufferFactory>::create();
-    m_bufferServer->registerItem(helper.getFileType(FileTypeHelper::Markdown).m_typeName, markdownFactory);
+    m_bufferServer->registerItem(helper.getFileType(FileType::Markdown).m_typeName, markdownFactory);
 
     // Text.
     auto textFactory = QSharedPointer<TextBufferFactory>::create();
-    m_bufferServer->registerItem(helper.getFileType(FileTypeHelper::Text).m_typeName, textFactory);
+    m_bufferServer->registerItem(helper.getFileType(FileType::Text).m_typeName, textFactory);
 }
 
 void BufferMgr::open(Node *p_node, const QSharedPointer<FileOpenParameters> &p_paras)
@@ -62,7 +62,9 @@ void BufferMgr::open(Node *p_node, const QSharedPointer<FileOpenParameters> &p_p
     auto buffer = findBuffer(p_node);
     if (!buffer) {
         auto nodePath = p_node->fetchAbsolutePath();
-        auto fileType = FileTypeHelper::getInst().getFileType(nodePath).m_typeName;
+        auto nodeFile = p_node->getContentFile();
+        Q_ASSERT(nodeFile);
+        auto fileType = nodeFile->getContentType().m_typeName;
         auto factory = m_bufferServer->getItem(fileType);
         if (!factory) {
             // No factory to open this file type.
@@ -72,7 +74,7 @@ void BufferMgr::open(Node *p_node, const QSharedPointer<FileOpenParameters> &p_p
         }
 
         BufferParameters paras;
-        paras.m_provider.reset(new NodeBufferProvider(p_node->sharedFromThis()));
+        paras.m_provider.reset(new NodeBufferProvider(p_node->sharedFromThis(), nodeFile));
         buffer = factory->createBuffer(paras, this);
         addBuffer(buffer);
     }
@@ -114,7 +116,8 @@ void BufferMgr::open(const QString &p_filePath, const QSharedPointer<FileOpenPar
     auto buffer = findBuffer(p_filePath);
     if (!buffer) {
         // Open it as external file.
-        auto fileType = FileTypeHelper::getInst().getFileType(p_filePath).m_typeName;
+        auto externalFile = QSharedPointer<ExternalFile>::create(p_filePath);
+        auto fileType = externalFile->getContentType().m_typeName;
         auto factory = m_bufferServer->getItem(fileType);
         if (!factory) {
             // No factory to open this file type.
@@ -124,7 +127,7 @@ void BufferMgr::open(const QString &p_filePath, const QSharedPointer<FileOpenPar
         }
 
         BufferParameters paras;
-        paras.m_provider.reset(new FileBufferProvider(QSharedPointer<ExternalFile>::create(p_filePath),
+        paras.m_provider.reset(new FileBufferProvider(externalFile,
                                                       p_paras->m_nodeAttachedTo,
                                                       p_paras->m_readOnly));
         buffer = factory->createBuffer(paras, this);

+ 28 - 1
src/core/configmgr.cpp

@@ -24,7 +24,7 @@
 using namespace vnotex;
 
 #ifndef QT_NO_DEBUG
-// #define VX_DEBUG_WEB
+#define VX_DEBUG_WEB
 #endif
 
 const QString ConfigMgr::c_orgName = "VNote";
@@ -320,6 +320,18 @@ QString ConfigMgr::getUserThemeFolder() const
     return folderPath;
 }
 
+QString ConfigMgr::getAppWebStylesFolder() const
+{
+    return PathUtils::concatenateFilePath(m_appConfigFolderPath, QStringLiteral("web-styles"));
+}
+
+QString ConfigMgr::getUserWebStylesFolder() const
+{
+    auto folderPath = PathUtils::concatenateFilePath(m_userConfigFolderPath, QStringLiteral("web-styles"));
+    QDir().mkpath(folderPath);
+    return folderPath;
+}
+
 QString ConfigMgr::getAppDocsFolder() const
 {
     return PathUtils::concatenateFilePath(m_appConfigFolderPath, QStringLiteral("docs"));
@@ -406,3 +418,18 @@ QString ConfigMgr::getApplicationDirPath()
 {
     return PathUtils::parentDirPath(getApplicationFilePath());
 }
+
+QString ConfigMgr::getDocumentOrHomePath()
+{
+    static QString docHomePath;
+    if (docHomePath.isEmpty()) {
+        QStringList folders = QStandardPaths::standardLocations(QStandardPaths::DocumentsLocation);
+        if (folders.isEmpty()) {
+            docHomePath = QDir::homePath();
+        } else {
+            docHomePath = folders[0];
+        }
+    }
+
+    return docHomePath;
+}

+ 6 - 0
src/core/configmgr.h

@@ -77,6 +77,10 @@ namespace vnotex
 
         QString getUserThemeFolder() const;
 
+        QString getAppWebStylesFolder() const;
+
+        QString getUserWebStylesFolder() const;
+
         QString getAppDocsFolder() const;
 
         QString getUserDocsFolder() const;
@@ -98,6 +102,8 @@ namespace vnotex
 
         static QString getApplicationDirPath();
 
+        static QString getDocumentOrHomePath();
+
         static const QString c_orgName;
 
         static const QString c_appName;

+ 1 - 1
src/core/core.pri

@@ -32,7 +32,6 @@ SOURCES += \
     $$PWD/widgetconfig.cpp
 
 HEADERS += \
-    $$PWD/ViewerResource.h \
     $$PWD/buffermgr.h \
     $$PWD/configmgr.h \
     $$PWD/coreconfig.h \
@@ -58,4 +57,5 @@ HEADERS += \
     $$PWD/theme.h \
     $$PWD/sessionconfig.h \
     $$PWD/clipboarddata.h \
+    $$PWD/webresource.h \
     $$PWD/widgetconfig.h

+ 1 - 0
src/core/coreconfig.h

@@ -31,6 +31,7 @@ namespace vnotex
             DistributeSplits,
             RemoveSplitAndWorkspace,
             NewWorkspace,
+            Export,
             MaxShortcut
         };
         Q_ENUM(Shortcut)

+ 1 - 0
src/core/externalfile.cpp

@@ -8,6 +8,7 @@ using namespace vnotex;
 ExternalFile::ExternalFile(const QString &p_filePath)
     : c_filePath(p_filePath)
 {
+    setContentType(FileTypeHelper::getInst().getFileType(c_filePath).m_type);
 }
 
 QString ExternalFile::read() const

+ 1 - 1
src/core/file.cpp

@@ -7,7 +7,7 @@ const FileType &File::getContentType() const
     return FileTypeHelper::getInst().getFileType(m_contentType);
 }
 
-void File::setContentType(FileTypeHelper::Type p_type)
+void File::setContentType(int p_type)
 {
     m_contentType = p_type;
 }

+ 2 - 2
src/core/file.h

@@ -60,10 +60,10 @@ namespace vnotex
         const FileType &getContentType() const;
 
     protected:
-        void setContentType(FileTypeHelper::Type p_type);
+        void setContentType(int p_type);
 
     private:
-        FileTypeHelper::Type m_contentType = FileTypeHelper::Others;
+        int m_contentType = FileType::Others;
     };
 }
 

+ 130 - 25
src/core/htmltemplatehelper.cpp

@@ -7,6 +7,7 @@
 #include <utils/utils.h>
 #include <utils/fileutils.h>
 #include <utils/pathutils.h>
+#include <utils/htmlutils.h>
 #include <core/thememgr.h>
 #include <core/vnotex.h>
 
@@ -14,6 +15,8 @@ using namespace vnotex;
 
 HtmlTemplateHelper::Template HtmlTemplateHelper::s_markdownViewerTemplate;
 
+static const QString c_globalStylesPlaceholder = "/* VX_GLOBAL_STYLES_PLACEHOLDER */";
+
 QString WebGlobalOptions::toJavascriptObject() const
 {
     return QStringLiteral("window.vxOptions = {\n")
@@ -26,21 +29,22 @@ QString WebGlobalOptions::toJavascriptObject() const
            + QString("linkifyEnabled: %1,\n").arg(Utils::boolToString(m_linkifyEnabled))
            + QString("indentFirstLineEnabled: %1,\n").arg(Utils::boolToString(m_indentFirstLineEnabled))
            + QString("sectionNumberEnabled: %1,\n").arg(Utils::boolToString(m_sectionNumberEnabled))
+           + QString("transparentBackgroundEnabled: %1,\n").arg(Utils::boolToString(m_transparentBackgroundEnabled))
+           + QString("scrollable: %1,\n").arg(Utils::boolToString(m_scrollable))
+           + QString("bodyWidth: %1,\n").arg(m_bodyWidth)
+           + QString("bodyHeight: %1,\n").arg(m_bodyHeight)
+           + QString("transformSvgToPngEnabled: %1,\n").arg(Utils::boolToString(m_transformSvgToPngEnabled))
+           + QString("mathJaxScale: %1,\n").arg(m_mathJaxScale)
            + QString("sectionNumberBaseLevel: %1\n").arg(m_sectionNumberBaseLevel)
            + QStringLiteral("}");
 }
 
-static bool isGlobalStyles(const ViewerResource::Resource &p_resource)
-{
-    return p_resource.m_name == QStringLiteral("global_styles");
-}
-
 // Read "global_styles" from resource and fill the holder with the content.
-static void fillGlobalStyles(QString &p_template, const ViewerResource &p_resource)
+static void fillGlobalStyles(QString &p_template, const WebResource &p_resource, const QString &p_additionalStyles)
 {
     QString styles;
     for (const auto &ele : p_resource.m_resources) {
-        if (isGlobalStyles(ele)) {
+        if (ele.isGlobal()) {
             if (ele.m_enabled) {
                 for (const auto &style : ele.m_styles) {
                     // Read the style file content.
@@ -52,9 +56,10 @@ static void fillGlobalStyles(QString &p_template, const ViewerResource &p_resour
         }
     }
 
+    styles += p_additionalStyles;
+
     if (!styles.isEmpty()) {
-        p_template.replace(QStringLiteral("/* VX_GLOBAL_STYLES_PLACEHOLDER */"),
-                           styles);
+        p_template.replace(c_globalStylesPlaceholder, styles);
     }
 }
 
@@ -76,13 +81,11 @@ static QString fillScriptTag(const QString &p_scriptFile)
     return QString("<script type=\"text/javascript\" src=\"%1\"></script>\n").arg(url.toString());
 }
 
-static void fillThemeStyles(QString &p_template)
+static void fillThemeStyles(QString &p_template, const QString &p_webStyleSheetFile, const QString &p_highlightStyleSheetFile)
 {
     QString styles;
-    const auto &themeMgr = VNoteX::getInst().getThemeMgr();
-
-    styles += fillStyleTag(themeMgr.getFile(Theme::File::WebStyleSheet));
-    styles += fillStyleTag(themeMgr.getFile(Theme::File::HighlightStyleSheet));
+    styles += fillStyleTag(p_webStyleSheetFile);
+    styles += fillStyleTag(p_highlightStyleSheetFile);
 
     if (!styles.isEmpty()) {
         p_template.replace(QStringLiteral("<!-- VX_THEME_STYLES_PLACEHOLDER -->"),
@@ -97,13 +100,13 @@ static void fillGlobalOptions(QString &p_template, const WebGlobalOptions &p_opt
 }
 
 // Read all other resources in @p_resource and fill the holder with proper resource path.
-static void fillResources(QString &p_template, const ViewerResource &p_resource)
+static void fillResources(QString &p_template, const WebResource &p_resource)
 {
     QString styles;
     QString scripts;
 
     for (const auto &ele : p_resource.m_resources) {
-        if (ele.m_enabled && !isGlobalStyles(ele)) {
+        if (ele.m_enabled && !ele.isGlobal()) {
             // Styles.
             for (const auto &style : ele.m_styles) {
                 auto styleFile = ConfigMgr::getInst().getUserOrAppFile(style);
@@ -129,6 +132,36 @@ static void fillResources(QString &p_template, const ViewerResource &p_resource)
     }
 }
 
+static void fillResourcesByContent(QString &p_template, const WebResource &p_resource)
+{
+    QString styles;
+    QString scripts;
+
+    for (const auto &ele : p_resource.m_resources) {
+        if (ele.m_enabled && !ele.isGlobal()) {
+            // Styles.
+            for (const auto &style : ele.m_styles) {
+                auto styleFile = ConfigMgr::getInst().getUserOrAppFile(style);
+                styles += FileUtils::readTextFile(styleFile);
+            }
+
+            // Scripts.
+            for (const auto &script : ele.m_scripts) {
+                auto scriptFile = ConfigMgr::getInst().getUserOrAppFile(script);
+                scripts += FileUtils::readTextFile(scriptFile);
+            }
+        }
+    }
+
+    if (!styles.isEmpty()) {
+        p_template.replace(QStringLiteral("/* VX_STYLES_PLACEHOLDER */"), styles);
+    }
+
+    if (!scripts.isEmpty()) {
+        p_template.replace(QStringLiteral("/* VX_SCRIPTS_PLACEHOLDER */"), scripts);
+    }
+}
+
 const QString &HtmlTemplateHelper::getMarkdownViewerTemplate()
 {
     return s_markdownViewerTemplate.m_template;
@@ -142,16 +175,30 @@ void HtmlTemplateHelper::updateMarkdownViewerTemplate(const MarkdownEditorConfig
 
     s_markdownViewerTemplate.m_revision = p_config.revision();
 
-    const auto &viewerResource = p_config.getViewerResource();
+    const auto &themeMgr = VNoteX::getInst().getThemeMgr();
+    s_markdownViewerTemplate.m_template =
+        generateMarkdownViewerTemplate(p_config,
+                                       themeMgr.getFile(Theme::File::WebStyleSheet),
+                                       themeMgr.getFile(Theme::File::HighlightStyleSheet));
+}
 
-    {
-        auto templateFile = ConfigMgr::getInst().getUserOrAppFile(viewerResource.m_template);
-        s_markdownViewerTemplate.m_template = FileUtils::readTextFile(templateFile);
-    }
+QString HtmlTemplateHelper::generateMarkdownViewerTemplate(const MarkdownEditorConfig &p_config,
+                                                           const QString &p_webStyleSheetFile,
+                                                           const QString &p_highlightStyleSheetFile,
+                                                           bool p_useTransparentBg,
+                                                           bool p_scrollable,
+                                                           int p_bodyWidth,
+                                                           int p_bodyHeight,
+                                                           bool p_transformSvgToPng,
+                                                           qreal p_mathJaxScale)
+{
+    const auto &viewerResource = p_config.getViewerResource();
+    const auto templateFile = ConfigMgr::getInst().getUserOrAppFile(viewerResource.m_template);
+    auto htmlTemplate = FileUtils::readTextFile(templateFile);
 
-    fillGlobalStyles(s_markdownViewerTemplate.m_template, viewerResource);
+    fillGlobalStyles(htmlTemplate, viewerResource, "");
 
-    fillThemeStyles(s_markdownViewerTemplate.m_template);
+    fillThemeStyles(htmlTemplate, p_webStyleSheetFile, p_highlightStyleSheetFile);
 
     {
         WebGlobalOptions opts;
@@ -165,8 +212,66 @@ void HtmlTemplateHelper::updateMarkdownViewerTemplate(const MarkdownEditorConfig
         opts.m_autoBreakEnabled = p_config.getAutoBreakEnabled();
         opts.m_linkifyEnabled = p_config.getLinkifyEnabled();
         opts.m_indentFirstLineEnabled = p_config.getIndentFirstLineEnabled();
-        fillGlobalOptions(s_markdownViewerTemplate.m_template, opts);
+        opts.m_transparentBackgroundEnabled = p_useTransparentBg;
+        opts.m_scrollable = p_scrollable;
+        opts.m_bodyWidth = p_bodyWidth;
+        opts.m_bodyHeight = p_bodyHeight;
+        opts.m_transformSvgToPngEnabled = p_transformSvgToPng;
+        opts.m_mathJaxScale = p_mathJaxScale;
+        fillGlobalOptions(htmlTemplate, opts);
     }
 
-    fillResources(s_markdownViewerTemplate.m_template, viewerResource);
+    fillResources(htmlTemplate, viewerResource);
+
+    return htmlTemplate;
+}
+
+QString HtmlTemplateHelper::generateExportTemplate(const MarkdownEditorConfig &p_config,
+                                                   bool p_addOutlinePanel)
+{
+    auto exportResource = p_config.getExportResource();
+    const auto templateFile = ConfigMgr::getInst().getUserOrAppFile(exportResource.m_template);
+    auto htmlTemplate = FileUtils::readTextFile(templateFile);
+
+    fillGlobalStyles(htmlTemplate, exportResource, "");
+
+    // Outline panel.
+    for (auto &ele : exportResource.m_resources) {
+        if (ele.m_name == QStringLiteral("outline")) {
+            ele.m_enabled = p_addOutlinePanel;
+            break;
+        }
+    }
+
+    fillResourcesByContent(htmlTemplate, exportResource);
+
+    return htmlTemplate;
+}
+
+void HtmlTemplateHelper::fillTitle(QString &p_template, const QString &p_title)
+{
+    if (!p_title.isEmpty()) {
+        p_template.replace("<!-- VX_TITLE_PLACEHOLDER -->",
+                           QString("<title>%1</title>").arg(HtmlUtils::escapeHtml(p_title)));
+    }
+}
+
+void HtmlTemplateHelper::fillStyleContent(QString &p_template, const QString &p_styles)
+{
+    p_template.replace("/* VX_STYLES_CONTENT_PLACEHOLDER */", p_styles);
+}
+
+void HtmlTemplateHelper::fillHeadContent(QString &p_template, const QString &p_head)
+{
+    p_template.replace("<!-- VX_HEAD_PLACEHOLDER -->", p_head);
+}
+
+void HtmlTemplateHelper::fillContent(QString &p_template, const QString &p_content)
+{
+    p_template.replace("<!-- VX_CONTENT_PLACEHOLDER -->", p_content);
+}
+
+void HtmlTemplateHelper::fillBodyClassList(QString &p_template, const QString &p_classList)
+{
+    p_template.replace("<!-- VX_BODY_CLASS_LIST_PLACEHOLDER -->", p_classList);
 }

+ 41 - 0
src/core/htmltemplatehelper.h

@@ -30,6 +30,24 @@ namespace vnotex
 
         bool m_indentFirstLineEnabled = false;
 
+        // Force to use transparent background.
+        bool m_transparentBackgroundEnabled = false;
+
+        // Whether the content elements are scrollable. Like PDF, it is false.
+        bool m_scrollable = true;
+
+        int m_bodyWidth = -1;
+
+        int m_bodyHeight = -1;
+
+        // Whether transform inlie SVG to PNG.
+        // For wkhtmltopdf converter, it could not render some inline SVG correctly.
+        // This is just a hint not mandatory. For now, PlantUML and Graphviz needs this.
+        bool m_transformSvgToPngEnabled = false;
+
+        // wkhtmltopdf will make the MathJax formula too small.
+        qreal m_mathJaxScale = -1;
+
         QString toJavascriptObject() const;
     };
 
@@ -42,6 +60,29 @@ namespace vnotex
         static const QString &getMarkdownViewerTemplate();
         static void updateMarkdownViewerTemplate(const MarkdownEditorConfig &p_config);
 
+        static QString generateMarkdownViewerTemplate(const MarkdownEditorConfig &p_config,
+                                                      const QString &p_webStyleSheetFile,
+                                                      const QString &p_highlightStyleSheetFile,
+                                                      bool p_useTransparentBg = false,
+                                                      bool p_scrollable = true,
+                                                      int p_bodyWidth = -1,
+                                                      int p_bodyHeight = -1,
+                                                      bool p_transformSvgToPng = false,
+                                                      qreal p_mathJaxScale = -1);
+
+        static QString generateExportTemplate(const MarkdownEditorConfig &p_config,
+                                              bool p_addOutlinePanel);
+
+        static void fillTitle(QString &p_template, const QString &p_title);
+
+        static void fillStyleContent(QString &p_template, const QString &p_styles);
+
+        static void fillHeadContent(QString &p_template, const QString &p_head);
+
+        static void fillContent(QString &p_template, const QString &p_content);
+
+        static void fillBodyClassList(QString &p_template, const QString &p_classList);
+
     private:
         struct Template
         {

+ 33 - 1
src/core/markdowneditorconfig.cpp

@@ -27,6 +27,7 @@ void MarkdownEditorConfig::init(const QJsonObject &p_app, const QJsonObject &p_u
     const auto userObj = p_user.value(m_sessionName).toObject();
 
     loadViewerResource(appObj, userObj);
+    loadExportResource(appObj, userObj);
 
     m_webPlantUml = READBOOL(QStringLiteral("web_plantuml"));
     m_webGraphviz = READBOOL(QStringLiteral("web_graphviz"));
@@ -58,6 +59,7 @@ QJsonObject MarkdownEditorConfig::toJson() const
 {
     QJsonObject obj;
     obj[QStringLiteral("viewer_resource")] = saveViewerResource();
+    obj[QStringLiteral("export_resource")] = saveExportResource();
     obj[QStringLiteral("web_plantuml")] = m_webPlantUml;
     obj[QStringLiteral("web_graphviz")] = m_webGraphviz;
     obj[QStringLiteral("prepend_dot_in_relative_link")] = m_prependDotInRelativeLink;
@@ -122,11 +124,41 @@ QJsonObject MarkdownEditorConfig::saveViewerResource() const
     return m_viewerResource.toJson();
 }
 
-const ViewerResource &MarkdownEditorConfig::getViewerResource() const
+void MarkdownEditorConfig::loadExportResource(const QJsonObject &p_app, const QJsonObject &p_user)
+{
+    const QString name(QStringLiteral("export_resource"));
+
+    if (MainConfig::isVersionChanged()) {
+        bool needOverride = p_app[QStringLiteral("override_viewer_resource")].toBool();
+        if (needOverride) {
+            qInfo() << "override \"viewer_resource\" in user configuration due to version change";
+            m_exportResource.init(p_app[name].toObject());
+            return;
+        }
+    }
+
+    if (p_user.contains(name)) {
+        m_exportResource.init(p_user[name].toObject());
+    } else {
+        m_exportResource.init(p_app[name].toObject());
+    }
+}
+
+QJsonObject MarkdownEditorConfig::saveExportResource() const
+{
+    return m_exportResource.toJson();
+}
+
+const WebResource &MarkdownEditorConfig::getViewerResource() const
 {
     return m_viewerResource;
 }
 
+const WebResource &MarkdownEditorConfig::getExportResource() const
+{
+    return m_exportResource;
+}
+
 bool MarkdownEditorConfig::getWebPlantUml() const
 {
     return m_webPlantUml;

+ 13 - 6
src/core/markdowneditorconfig.h

@@ -3,7 +3,7 @@
 
 #include "iconfig.h"
 
-#include "viewerresource.h"
+#include "webresource.h"
 
 #include <QSharedPointer>
 #include <QVector>
@@ -38,15 +38,14 @@ namespace vnotex
 
         QJsonObject toJson() const Q_DECL_OVERRIDE;
 
-        void loadViewerResource(const QJsonObject &p_app, const QJsonObject &p_user);
-        QJsonObject saveViewerResource() const;
-
         int revision() const Q_DECL_OVERRIDE;
 
         TextEditorConfig &getTextEditorConfig();
         const TextEditorConfig &getTextEditorConfig() const;
 
-        const ViewerResource &getViewerResource() const;
+        const WebResource &getViewerResource() const;
+
+        const WebResource &getExportResource() const;
 
         bool getWebPlantUml() const;
 
@@ -107,9 +106,17 @@ namespace vnotex
         QString sectionNumberStyleToString(SectionNumberStyle p_style) const;
         SectionNumberStyle stringToSectionNumberStyle(const QString &p_str) const;
 
+        void loadViewerResource(const QJsonObject &p_app, const QJsonObject &p_user);
+        QJsonObject saveViewerResource() const;
+
+        void loadExportResource(const QJsonObject &p_app, const QJsonObject &p_user);
+        QJsonObject saveExportResource() const;
+
         QSharedPointer<TextEditorConfig> m_textEditorConfig;
 
-        ViewerResource m_viewerResource;
+        WebResource m_viewerResource;
+
+        WebResource m_exportResource;
 
         // Whether use javascript or external program to render PlantUML.
         bool m_webPlantUml = true;

+ 4 - 0
src/core/notebook/node.cpp

@@ -263,6 +263,10 @@ QDir Node::toDir() const
 
 void Node::load()
 {
+    if (isLoaded()) {
+        return;
+    }
+
     getConfigMgr()->loadNode(this);
 }
 

+ 1 - 0
src/core/notebook/vxnodefile.cpp

@@ -15,6 +15,7 @@ VXNodeFile::VXNodeFile(const QSharedPointer<VXNode> &p_node)
     : m_node(p_node)
 {
     Q_ASSERT(m_node && m_node->hasContent());
+    setContentType(FileTypeHelper::getInst().getFileType(getContentPath()).m_type);
 }
 
 QString VXNodeFile::read() const

+ 0 - 2
src/core/notebookconfigmgr/notebookconfigmgr.pri

@@ -1,5 +1,4 @@
 SOURCES += \
-    $$PWD/nodecontentmediautils.cpp \
     $$PWD/vxnotebookconfigmgr.cpp \
     $$PWD/vxnotebookconfigmgrfactory.cpp \
     $$PWD/inotebookconfigmgr.cpp \
@@ -8,7 +7,6 @@ SOURCES += \
 
 HEADERS += \
     $$PWD/inotebookconfigmgr.h \
-    $$PWD/nodecontentmediautils.h \
     $$PWD/vxnotebookconfigmgr.h \
     $$PWD/inotebookconfigmgrfactory.h \
     $$PWD/vxnotebookconfigmgrfactory.h \

+ 5 - 5
src/core/notebookconfigmgr/vxnotebookconfigmgr.cpp

@@ -14,7 +14,7 @@
 #include <utils/pathutils.h>
 #include <exception.h>
 
-#include "nodecontentmediautils.h"
+#include <utils/contentmediautils.h>
 
 using namespace vnotex;
 
@@ -581,13 +581,13 @@ QSharedPointer<Node> VXNotebookConfigMgr::copyFileNodeAsChildOf(const QSharedPoi
     getBackend()->copyFile(srcFilePath, destFilePath);
 
     // Copy media files fetched from content.
-    NodeContentMediaUtils::copyMediaFiles(p_src.data(), getBackend().data(), destFilePath);
+    ContentMediaUtils::copyMediaFiles(p_src.data(), getBackend().data(), destFilePath);
 
     // Copy attachment folder. Rename attachment folder if conflicts.
     QString attachmentFolder = p_src->getAttachmentFolder();
     if (!attachmentFolder.isEmpty()) {
         auto destAttachmentFolderPath = fetchNodeAttachmentFolder(destFilePath, attachmentFolder);
-        NodeContentMediaUtils::copyAttachment(p_src.data(), getBackend().data(), destFilePath, destAttachmentFolderPath);
+        ContentMediaUtils::copyAttachment(p_src.data(), getBackend().data(), destFilePath, destAttachmentFolderPath);
     }
 
     // Create a file node.
@@ -690,7 +690,7 @@ void VXNotebookConfigMgr::removeFilesOfNode(Node *p_node, bool p_force)
         }
 
         // Delete media files fetched from content.
-        NodeContentMediaUtils::removeMediaFiles(p_node);
+        ContentMediaUtils::removeMediaFiles(p_node);
 
         // Delete node file itself.
         auto filePath = p_node->fetchPath();
@@ -787,7 +787,7 @@ QSharedPointer<Node> VXNotebookConfigMgr::copyFileAsChildOf(const QString &p_src
     getBackend()->copyFile(p_srcPath, destFilePath);
 
     // Copy media files fetched from content.
-    NodeContentMediaUtils::copyMediaFiles(p_srcPath, getBackend().data(), destFilePath);
+    ContentMediaUtils::copyMediaFiles(p_srcPath, getBackend().data(), destFilePath);
 
     // Create a file node.
     auto currentTime = QDateTime::currentDateTimeUtc();

+ 24 - 12
src/core/sessionconfig.cpp

@@ -55,9 +55,13 @@ void SessionConfig::init()
 
     loadCore(sessionJobj);
 
+    loadStateAndGeometry(sessionJobj);
+
     if (MainConfig::isVersionChanged()) {
         doVersionSpecificOverride();
     }
+
+    m_exportOption.fromJson(sessionJobj[QStringLiteral("export_option")].toObject());
 }
 
 void SessionConfig::loadCore(const QJsonObject &p_session)
@@ -172,6 +176,7 @@ QJsonObject SessionConfig::toJson() const
     obj[QStringLiteral("core")] = saveCore();
     obj[QStringLiteral("notebooks")] = saveNotebooks();
     obj[QStringLiteral("state_geometry")] = saveStateAndGeometry();
+    obj[QStringLiteral("export_option")] = m_exportOption.toJson();
     return obj;
 }
 
@@ -185,22 +190,12 @@ QJsonObject SessionConfig::saveStateAndGeometry() const
 
 SessionConfig::MainWindowStateGeometry SessionConfig::getMainWindowStateGeometry() const
 {
-    auto sessionSettings = getMgr()->getSettings(ConfigMgr::Source::Session);
-    const auto &sessionJobj = sessionSettings->getJson();
-    const auto obj = sessionJobj.value(QStringLiteral("state_geometry")).toObject();
-
-    MainWindowStateGeometry sg;
-    sg.m_mainState = readByteArray(obj, QStringLiteral("main_window_state"));
-    sg.m_mainGeometry = readByteArray(obj, QStringLiteral("main_window_geometry"));
-
-    return sg;
+    return m_mainWindowStateGeometry;
 }
 
 void SessionConfig::setMainWindowStateGeometry(const SessionConfig::MainWindowStateGeometry &p_state)
 {
-    m_mainWindowStateGeometry = p_state;
-    ++m_revision;
-    writeToSettings();
+    updateConfig(m_mainWindowStateGeometry, p_state, this);
 }
 
 SessionConfig::OpenGL SessionConfig::getOpenGLAtBootstrap()
@@ -283,3 +278,20 @@ void SessionConfig::doVersionSpecificOverride()
     // In a new version, we may want to change one value by force.
     // SHOULD set the in memory variable only, or will override the notebook list.
 }
+
+const ExportOption &SessionConfig::getExportOption() const
+{
+    return m_exportOption;
+}
+
+void SessionConfig::setExportOption(const ExportOption &p_option)
+{
+    updateConfig(m_exportOption, p_option, this);
+}
+
+void SessionConfig::loadStateAndGeometry(const QJsonObject &p_session)
+{
+    const auto obj = p_session.value(QStringLiteral("state_geometry")).toObject();
+    m_mainWindowStateGeometry.m_mainState = readByteArray(obj, QStringLiteral("main_window_state"));
+    m_mainWindowStateGeometry.m_mainGeometry = readByteArray(obj, QStringLiteral("main_window_geometry"));
+}

+ 9 - 2
src/core/sessionconfig.h

@@ -6,6 +6,8 @@
 #include <QString>
 #include <QVector>
 
+#include <export/exportdata.h>
+
 namespace vnotex
 {
     class SessionConfig : public IConfig
@@ -82,6 +84,9 @@ namespace vnotex
         int getMinimizeToSystemTray() const;
         void setMinimizeToSystemTray(bool p_enabled);
 
+        const ExportOption &getExportOption() const;
+        void setExportOption(const ExportOption &p_option);
+
     private:
         void loadCore(const QJsonObject &p_session);
 
@@ -91,6 +96,8 @@ namespace vnotex
 
         QJsonArray saveNotebooks() const;
 
+        void loadStateAndGeometry(const QJsonObject &p_session);
+
         QJsonObject saveStateAndGeometry() const;
 
         void doVersionSpecificOverride();
@@ -102,8 +109,6 @@ namespace vnotex
 
         QVector<SessionConfig::NotebookItem> m_notebooks;
 
-        // Used to store newly-set state and geometry, since there is no need to store the read-in
-        // data all the time.
         MainWindowStateGeometry m_mainWindowStateGeometry;
 
         OpenGL m_openGL = OpenGL::None;
@@ -116,6 +121,8 @@ namespace vnotex
         // 0 for disabling minimizing to system tray;
         // 1 for enabling minimizing to system tray.
         int m_minimizeToSystemTray = -1;
+
+        ExportOption m_exportOption;
     };
 } // ns vnotex
 

+ 6 - 1
src/core/theme.cpp

@@ -368,7 +368,12 @@ bool Theme::isRef(const QString &p_str)
 
 QString Theme::getFile(File p_fileType) const
 {
-    QDir dir(m_themeFolderPath);
+    return getFile(m_themeFolderPath, p_fileType);
+}
+
+QString Theme::getFile(const QString &p_themeFolder, File p_fileType)
+{
+    QDir dir(p_themeFolder);
     if (dir.exists(getFileName(p_fileType))) {
         return dir.filePath(getFileName(p_fileType));
     } else if (p_fileType == File::MarkdownEditorStyle) {

+ 2 - 0
src/core/theme.h

@@ -54,6 +54,8 @@ namespace vnotex
 
         static QPixmap getCover(const QString &p_folder);
 
+        static QString getFile(const QString &p_themeFolder, File p_fileType);
+
     private:
         struct Metadata
         {

+ 38 - 0
src/core/thememgr.cpp

@@ -15,6 +15,8 @@ using namespace vnotex;
 
 QStringList ThemeMgr::s_searchPaths;
 
+QStringList ThemeMgr::s_webStylesSearchPaths;
+
 ThemeMgr::ThemeMgr(const QString &p_currentThemeName, QObject *p_parent)
     : QObject(p_parent)
 {
@@ -203,3 +205,39 @@ void ThemeMgr::refresh()
 {
     loadAvailableThemes();
 }
+
+void ThemeMgr::addWebStylesSearchPath(const QString &p_path)
+{
+    s_webStylesSearchPaths << p_path;
+}
+
+QVector<QPair<QString, QString>> ThemeMgr::getWebStyles() const
+{
+    QVector<QPair<QString, QString>> styles;
+
+    // From themes.
+    for (const auto &th : m_themes) {
+        auto filePath = Theme::getFile(th.m_folderPath, Theme::File::WebStyleSheet);
+        if (!filePath.isEmpty()) {
+            styles.push_back(qMakePair(tr("[Theme] %1 %2").arg(th.m_displayName, PathUtils::fileName(filePath)),
+                                       filePath));
+        }
+
+        filePath = Theme::getFile(th.m_folderPath, Theme::File::HighlightStyleSheet);
+        if (!filePath.isEmpty()) {
+            styles.push_back(qMakePair(tr("[Theme] %1 %2").arg(th.m_displayName, PathUtils::fileName(filePath)),
+                                       filePath));
+        }
+    }
+
+    // From search paths.
+    for (const auto &pa : s_webStylesSearchPaths) {
+        QDir dir(pa);
+        auto styleFiles = dir.entryList({"*.css"}, QDir::Files);
+        for (const auto &file : styleFiles) {
+            styles.push_back(qMakePair(file, dir.filePath(file)));
+        }
+    }
+
+    return styles;
+}

+ 10 - 1
src/core/thememgr.h

@@ -64,10 +64,16 @@ namespace vnotex
         // Won't affect current theme since we do not support changing theme real time for now.
         void refresh();
 
+        // Return all web stylesheets available, including those from themes and web styles search paths.
+        // <DisplayName, FilePath>.
+        QVector<QPair<QString, QString>> getWebStyles() const;
+
         static void addSearchPath(const QString &p_path);
 
         static void addSyntaxHighlightingSearchPaths(const QStringList &p_paths);
 
+        static void addWebStylesSearchPath(const QString &p_path);
+
     private:
         void loadAvailableThemes();
 
@@ -89,8 +95,11 @@ namespace vnotex
         // Set at runtime, not from the theme config.
         QColor m_baseBackground;
 
-        // List of path to search for themes.
+        // List of paths to search for themes.
         static QStringList s_searchPaths;
+
+        // List of paths to search for CSS styles, including CSS syntax highlighting styles.
+        static QStringList s_webStylesSearchPaths;
     };
 } // ns vnotex
 

+ 2 - 0
src/core/vnotex.cpp

@@ -46,6 +46,8 @@ void VNoteX::initThemeMgr()
     ThemeMgr::addSyntaxHighlightingSearchPaths(
         QStringList() << configMgr.getUserSyntaxHighlightingFolder()
                       << configMgr.getAppSyntaxHighlightingFolder());
+    ThemeMgr::addWebStylesSearchPath(configMgr.getAppWebStylesFolder());
+    ThemeMgr::addWebStylesSearchPath(configMgr.getUserWebStylesFolder());
     m_themeMgr = new ThemeMgr(configMgr.getCoreConfig().getTheme(), this);
 }
 

+ 2 - 0
src/core/vnotex.h

@@ -97,6 +97,8 @@ namespace vnotex
         // Requested to locate node in explorer.
         void locateNodeRequested(Node *p_node);
 
+        void exportRequested();
+
     private:
         explicit VNoteX(QObject *p_parent = nullptr);
 

+ 10 - 5
src/core/viewerresource.h → src/core/webresource.h

@@ -1,5 +1,5 @@
-#ifndef VIEWERRESOURCE_H
-#define VIEWERRESOURCE_H
+#ifndef WEBRESOURCE_H
+#define WEBRESOURCE_H
 
 #include <QJsonObject>
 #include <QJsonArray>
@@ -8,8 +8,8 @@
 
 namespace vnotex
 {
-    // Resource for Web viewer.
-    struct ViewerResource
+    // Resource for Web.
+    struct WebResource
     {
         struct Resource
         {
@@ -51,6 +51,11 @@ namespace vnotex
                 return obj;
             }
 
+            bool isGlobal() const
+            {
+                return m_name == QStringLiteral("global_styles");
+            }
+
             QString m_name;
 
             bool m_enabled = true;
@@ -96,4 +101,4 @@ namespace vnotex
 
 }
 
-#endif // VIEWERRESOURCE_H
+#endif // WEBRESOURCE_H

+ 25 - 2
src/data/core/vnotex.json

@@ -24,7 +24,8 @@
             "MaximizeSplit" : "Ctrl+G, Shift+\\",
             "DistributeSplits" : "Ctrl+G, =",
             "RemoveSplitAndWorkspace" : "Ctrl+G, R",
-            "NewWorkspace" : "Ctrl+G, N"
+            "NewWorkspace" : "Ctrl+G, N",
+            "Export" : "Ctrl+G, T"
         },
         "toolbar_icon_size" : 16
     },
@@ -83,7 +84,7 @@
         "markdown_editor" : {
             "override_viewer_resource" : true,
             "viewer_resource" : {
-                "template" : "web/markdownviewertemplate.html",
+                "template" : "web/markdown-viewer-template.html",
                 "resources" : [
                     {
                         "name" : "global_styles",
@@ -217,6 +218,28 @@
                     }
                 ]
             },
+            "export_resource" : {
+                "template" : "web/markdown-export-template.html",
+                "resources" : [
+                    {
+                        "name" : "global_styles",
+                        "enabled" : true,
+                        "styles" : [
+                            "web/css/exportglobalstyles.css"
+                        ]
+                    },
+                    {
+                        "name" : "outline",
+                        "enabled" : true,
+                        "styles" : [
+                            "web/css/outline.css"
+                        ],
+                        "scripts" : [
+                            "web/js/outline.js"
+                        ]
+                    }
+                ]
+            },
             "//comment" : "Whether use javascript or external program to render PlantUML",
             "web_plantuml" : true,
             "//comment" : "Whether use javascript or external program to render Graphviz",

+ 5 - 1
src/data/extra/extra.qrc

@@ -8,10 +8,13 @@
         <file>docs/zh_CN/about_vnotex.txt</file>
         <file>docs/zh_CN/shortcuts.md</file>
         <file>docs/zh_CN/markdown_guide.md</file>
-        <file>web/markdownviewertemplate.html</file>
+        <file>web/markdown-viewer-template.html</file>
+        <file>web/markdown-export-template.html</file>
         <file>web/css/globalstyles.css</file>
         <file>web/css/markdownit.css</file>
         <file>web/css/imageviewer.css</file>
+        <file>web/css/outline.css</file>
+        <file>web/css/exportglobalstyles.css</file>
         <file>web/js/qwebchannel.js</file>
         <file>web/js/eventemitter.js</file>
         <file>web/js/utils.js</file>
@@ -26,6 +29,7 @@
         <file>web/js/imageviewer.js</file>
         <file>web/js/easyaccess.js</file>
         <file>web/js/crosscopy.js</file>
+        <file>web/js/outline.js</file>
         <file>web/js/markdown-it/markdown-it-container.min.js</file>
         <file>web/js/markdown-it/markdown-it-emoji.min.js</file>
         <file>web/js/markdown-it/markdown-it-footnote.min.js</file>

+ 11 - 3
src/data/extra/themes/moonlight/interface.qss

@@ -20,15 +20,15 @@ QWidget[DialogCentralWidget="true"] {
 
 /* All widgets */
 *[State="info"] {
-    border: 2px solid @widgets#qwidget#info#border;
+    border: 1px solid @widgets#qwidget#info#border;
 }
 
 *[State="warning"] {
-    border: 2px solid @widgets#qwidget#warning#border;
+    border: 1px solid @widgets#qwidget#warning#border;
 }
 
 *[State="error"] {
-    border: 2px solid @widgets#qwidget#error#border;
+    border: 1px solid @widgets#qwidget#error#border;
 }
 
 /* QAbstractScrollArea */
@@ -430,6 +430,14 @@ QLineEdit:disabled {
     color: @widgets#qlineedit#disabled#fg;
 }
 
+/* QPlainTextEdit */
+QPlainTextEdit[ConsoleTextEdit="true"] {
+    color: @widgets#qlineedit#fg;
+    background-color: @widgets#qlineedit#bg;
+    selection-color: @widgets#qlineedit#selection#fg;
+    selection-background-color: @widgets#qlineedit#selection#bg;
+}
+
 /* QTabWidget */
 QTabWidget {
     border: none;

+ 1 - 1
src/data/extra/themes/moonlight/palette.json

@@ -44,7 +44,7 @@
         "bg2_9" : "#919cd8",
         "fg10" : "#b71c1c",
         "fg11" : "#ab5683",
-        "fg12" : "#283593",
+        "fg12" : "#5768c4",
         "fg13" : "#b42b1f",
         "fg15_3" : "#4f5666",
         "fg15_4" : "#60697c",

+ 3 - 3
src/data/extra/themes/native/interface.qss

@@ -10,15 +10,15 @@
 
 /* All widgets */
 *[State="info"] {
-    border: 2px solid @base#info#fg;
+    border: 1px solid @base#info#fg;
 }
 
 *[State="warning"] {
-    border: 2px solid @base#warning#fg;
+    border: 1px solid @base#warning#fg;
 }
 
 *[State="error"] {
-    border: 2px solid @base#error#fg;
+    border: 1px solid @base#error#fg;
 }
 
 /* ToolBox */

+ 2 - 2
src/data/extra/themes/pure/highlight.css

@@ -33,14 +33,14 @@ pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selectio
 code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection {
     text-shadow: none;
     background-color: #1976d2;
-    color: #f1f1f1;
+    color: #f5f5f5;
 }
 
 pre[class*="language-"]::selection, pre[class*="language-"] ::selection,
 code[class*="language-"]::selection, code[class*="language-"] ::selection {
     text-shadow: none;
     background-color: #1976d2;
-    color: #f1f1f1;
+    color: #f5f5f5;
 }
 
 @media print {

+ 11 - 3
src/data/extra/themes/pure/interface.qss

@@ -20,15 +20,15 @@ QWidget[DialogCentralWidget="true"] {
 
 /* All widgets */
 *[State="info"] {
-    border: 2px solid @widgets#qwidget#info#border;
+    border: 1px solid @widgets#qwidget#info#border;
 }
 
 *[State="warning"] {
-    border: 2px solid @widgets#qwidget#warning#border;
+    border: 1px solid @widgets#qwidget#warning#border;
 }
 
 *[State="error"] {
-    border: 2px solid @widgets#qwidget#error#border;
+    border: 1px solid @widgets#qwidget#error#border;
 }
 
 /* QAbstractScrollArea */
@@ -430,6 +430,14 @@ QLineEdit:disabled {
     color: @widgets#qlineedit#disabled#fg;
 }
 
+/* QPlainTextEdit */
+QPlainTextEdit[ConsoleTextEdit="true"] {
+    color: @widgets#qlineedit#fg;
+    background-color: @widgets#qlineedit#bg;
+    selection-color: @widgets#qlineedit#selection#fg;
+    selection-background-color: @widgets#qlineedit#selection#bg;
+}
+
 /* QTabWidget */
 QTabWidget {
     border: none;

+ 2 - 2
src/data/extra/themes/pure/palette.json

@@ -20,7 +20,7 @@
         "bg3_4" : "#dadada",
         "bg3_41" : "#e0e0e0",
         "bg3_5" : "#eaeaea",
-        "bg3_6" : "#f1f1f1",
+        "bg3_6" : "#f5f5f5",
         "fg3_5" : "#222222",
         "fg3_6" : "#646464",
         "fg3_7" : "#7a7a7a",
@@ -33,7 +33,7 @@
         "bg2_7" : "#e5f3f1",
         "fg10" : "#b71c1c",
         "fg11" : "#ab5683",
-        "fg12" : "#283593",
+        "fg12" : "#007b6e",
         "fg13" : "#b42b1f",
         "fg15_3" : "#b0b0b0",
         "fg15_4" : "#7a7a7a",

+ 5 - 5
src/data/extra/themes/pure/text-editor.theme

@@ -10,8 +10,8 @@
             "font-family" : "YaHei Consolas Hybrid, Consolas, Monaco, Andale Mono, Monospace, Courier New",
             "font-size" : 12,
             "text-color" : "#222222",
-            "background-color" : "#f1f1f1",
-            "selected-text-color" : "#f1f1f1",
+            "background-color" : "#f5f5f5",
+            "selected-text-color" : "#f5f5f5",
             "selected-background-color" : "#1976d2"
         },
         "CursorLine" : {
@@ -30,7 +30,7 @@
         },
         "IndicatorsBorder" : {
             "text-color" : "#aaaaaa",
-            "background-color" : "#ededed"
+            "background-color" : "#f1f1f1"
         },
         "CurrentLineNumber" : {
             "text-color" : "#222222"
@@ -70,8 +70,8 @@
             "font-family" : "冬青黑体, YaHei Consolas Hybrid, Microsoft YaHei, 微软雅黑, Microsoft YaHei UI, WenQuanYi Micro Hei, 文泉驿雅黑, Dengxian, 等线体, STXihei, 华文细黑, Liberation Sans, Droid Sans, NSimSun, 新宋体, SimSun, 宋体, Verdana, Helvetica, sans-serif, Tahoma, Arial, Geneva, Georgia, Times New Roman",
             "font-size" : 12,
             "text-color" : "#222222",
-            "background-color" : "#f1f1f1",
-            "selected-text-color" : "#f1f1f1",
+            "background-color" : "#f5f5f5",
+            "selected-text-color" : "#f5f5f5",
             "selected-background-color" : "#1976d2"
         }
     },

+ 2 - 2
src/data/extra/themes/pure/web.css

@@ -4,7 +4,7 @@ body {
     color: #222222;
     line-height: 1.5;
     padding: 15px;
-    background-color: #f1f1f1;
+    background-color: #f5f5f5;
     font-size: 16px;
 }
 
@@ -202,7 +202,7 @@ div.vx-plantuml-graph {
 
 ::selection {
   background-color: #1976d2;
-  color: #f1f1f1;
+  color: #f5f5f5;
 }
 
 ::-webkit-scrollbar {

+ 3 - 0
src/data/extra/web/css/exportglobalstyles.css

@@ -0,0 +1,3 @@
+div.code-toolbar > div.toolbar {
+    display: none;
+}

+ 27 - 3
src/data/extra/web/css/globalstyles.css

@@ -73,9 +73,13 @@
     content: counter(section1) "." counter(section2) "." counter(section3) "." counter(section4) "." counter(section5) "." counter(section6) ". ";
 }
 
-#vx-content.vx-constrain-image-width img {
-    max-width: 100%;
-    height: auto;
+#vx-content.vx-constrain-image-width img,
+#vx-content.vx-constrain-image-width div.vx-plantuml-graph > svg,
+#vx-content.vx-constrain-image-width div.vx-mermaid-graph,
+#vx-content.vx-constrain-image-width div.vx-flowchartjs-graph,
+#vx-content.vx-constrain-image-width div.vx-wavedrom-graph {
+    max-width: 100% !important;
+    height: auto !important;
 }
 
 /* Table of Contents */
@@ -133,3 +137,23 @@
 #vx-content.vx-indent-first-line p {
     text-indent: 2em;
 }
+
+body.vx-transparent-background {
+    background-color: transparent !important;
+}
+
+#vx-content.vx-nonscrollable pre {
+    white-space: pre-wrap !important;
+    word-break: break-all !important;
+    overflow: hidden !important;
+}
+
+#vx-content.vx-nonscrollable pre code {
+    white-space: pre-wrap !important;
+    word-break: break-all !important;
+}
+
+#vx-content.vx-nonscrollable code,
+#vx-content.vx-nonscrollable a {
+    word-break: break-all !important;
+}

+ 205 - 0
src/data/extra/web/css/outline.css

@@ -0,0 +1,205 @@
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+}
+
+.container-fluid {
+    width: 100%;
+    padding-right: 15px;
+    padding-left: 15px;
+    margin-right: auto;
+    margin-left: auto;
+}
+
+.col, .col-1, .col-10, .col-11, .col-12, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-auto, .col-lg, .col-lg-1, .col-lg-10, .col-lg-11, .col-lg-12, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-auto, .col-md, .col-md-1, .col-md-10, .col-md-11, .col-md-12, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-auto, .col-sm, .col-sm-1, .col-sm-10, .col-sm-11, .col-sm-12, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-auto, .col-xl, .col-xl-1, .col-xl-10, .col-xl-11, .col-xl-12, .col-xl-2, .col-xl-3, .col-xl-4, .col-xl-5, .col-xl-6, .col-xl-7, .col-xl-8, .col-xl-9, .col-xl-auto {
+    position: relative;
+    width: 100%;
+    min-height: 1px;
+    padding-right: 15px;
+    padding-left: 15px;
+}
+
+.col-12 {
+    -webkit-box-flex: 0;
+    -ms-flex: 0 0 100%;
+    flex: 0 0 100%;
+    max-width: 100%;
+}
+
+@media (min-width: 768px) {
+    .col-md-3 {
+        -webkit-box-flex: 0;
+        -ms-flex: 0 0 25%;
+        flex: 0 0 25%;
+        max-width: 25%;
+    }
+}
+
+@media (min-width: 768px) {
+    .col-md-9 {
+        -webkit-box-flex: 0;
+        -ms-flex: 0 0 75%;
+        flex: 0 0 75%;
+        max-width: 75%;
+    }
+}
+
+@media (min-width: 1200px) {
+    .col-xl-2 {
+        -webkit-box-flex: 0;
+        -ms-flex: 0 0 16.666667%;
+        flex: 0 0 16.666667%;
+        max-width: 16.666667%;
+    }
+}
+
+@media (min-width: 1200px) {
+    .col-xl-10 {
+        -webkit-box-flex: 0;
+        -ms-flex: 0 0 83.333333%;
+        flex: 0 0 83.333333%;
+        max-width: 83.333333%;
+    }
+}
+
+@media (min-width: 768px) {
+    .pt-md-3, .py-md-3 {
+        padding-top: 1rem!important;
+    }
+}
+
+@media (min-width: 768px) {
+    .pb-md-3, .py-md-3 {
+        padding-bottom: 1rem!important;
+    }
+}
+
+@media (min-width: 768px) {
+    .pl-md-5, .px-md-5 {
+        padding-left: 3rem!important;
+    }
+}
+
+.d-none {
+    display: none!important;
+}
+
+@media (min-width: 1200px) {
+    .d-xl-block {
+        display: block!important;
+    }
+}
+
+@media (min-width: 768px) {
+    .d-md-block {
+        display: block!important;
+    }
+}
+
+.bd-content {
+    -webkit-box-ordinal-group: 1;
+    -ms-flex-order: 0;
+    order: 0;
+}
+
+.bd-toc {
+    position: -webkit-sticky;
+    position: sticky;
+    top: 4rem;
+    height: calc(100vh - 10rem);
+    overflow-y: auto;
+}
+
+.bd-toc {
+    -webkit-box-ordinal-group: 2;
+    -ms-flex-order: 1;
+    order: 1;
+    padding-top: 1.5rem;
+    padding-bottom: 1.5rem;
+    font-size: .875rem;
+}
+
+.section-nav {
+    padding-left: 0;
+}
+
+.section-nav ul {
+    font-size: .875rem;
+    list-style-type: none;
+}
+
+.section-nav li {
+    font-size: .875rem;
+}
+
+.section-nav a {
+    color: inherit !important;
+}
+
+.row {
+    display: -webkit-box;
+    display: -ms-flexbox;
+    display: flex;
+    -ms-flex-wrap: wrap;
+    flex-wrap: wrap;
+    margin-right: -15px;
+    margin-left: -15px;
+}
+
+@media (min-width: 1200px) {
+    .flex-xl-nowrap {
+        flex-wrap: nowrap !important;
+    }
+}
+
+#floating-button {
+    width: 2.5rem;
+    height: 2.5rem;
+    border-radius: 50%;
+    background: #00897B;
+    position: fixed;
+    top: .5rem;
+    right: .5rem;
+    cursor: pointer;
+    box-shadow: 0px 2px 5px #666;
+}
+
+#floating-button .more {
+    color: #F5F5F5;
+    position: absolute;
+    top: 0;
+    display: block;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    text-align: center;
+    padding: 0;
+    margin: 0;
+    line-height: 2.5rem;
+    font-size: 2rem;
+    font-family: 'monospace';
+    font-weight: 300;
+}
+
+.hide-none {
+    display: none !important;
+}
+
+.col-expand {
+    -webkit-box-flex: 0;
+    -ms-flex: 0 0 100% !important;
+    flex: 0 0 100% !important;
+    max-width: 100% !important;
+    padding-right: 3rem !important;
+}
+
+.outline-bold {
+    font-weight: bolder !important;
+}
+
+@media print {
+    #floating-button {
+        display: none !important;
+    }
+}

+ 2 - 1
src/data/extra/web/js/graphviz.js

@@ -19,7 +19,8 @@ class Graphviz extends GraphRenderer {
     registerInternal() {
         this.vnotex.on('basicMarkdownRendered', () => {
             this.reset();
-            this.renderCodeNodes(this.vnotex.contentContainer, 'svg');
+            this.renderCodeNodes(this.vnotex.contentContainer,
+                                 window.vxOptions.transformSvgToPngEnabled ? 'png' : 'svg');
         });
 
         this.vnotex.getWorker('markdownit').addLangsToSkipHighlight(this.langs);

+ 0 - 22
src/data/extra/web/js/markdownit.js

@@ -240,10 +240,6 @@ class MarkdownIt extends VxWorker {
     }
 
     registerInternal() {
-        this.vnotex.on('ready', () => {
-            this.setConstrainImageWidthEnabled(window.vxOptions.constrainImageWidthEnabled);
-            this.setIndentFirstLineEnabled(window.vxOptions.indentFirstLineEnabled);
-        });
         this.vnotex.on('markdownTextUpdated', (p_text) => {
             this.render(this.vnotex.contentContainer,
                         p_text,
@@ -251,24 +247,6 @@ class MarkdownIt extends VxWorker {
         });
     }
 
-    setConstrainImageWidthEnabled(p_enabled) {
-        let constrainClass = 'vx-constrain-image-width';
-        if (p_enabled) {
-            this.vnotex.contentContainer.classList.add(constrainClass);
-        } else {
-            this.vnotex.contentContainer.classList.remove(constrainClass);
-        }
-    }
-
-    setIndentFirstLineEnabled(p_enabled) {
-        let constrainClass = 'vx-indent-first-line';
-        if (p_enabled) {
-            this.vnotex.contentContainer.classList.add(constrainClass);
-        } else {
-            this.vnotex.contentContainer.classList.remove(constrainClass);
-        }
-    }
-
     // Render Markdown @p_text to HTML in @p_node.
     // @p_finishCbStr will be called after finishing loading new content nodes.
     // This could prevent Mermaid Gantt from negative width error.

+ 4 - 0
src/data/extra/web/js/markdownviewer.js

@@ -43,6 +43,10 @@ new QWebChannel(qt.webChannelTransport,
             window.vnotex.findText(p_text, p_options);
         });
 
+        adapter.contentRequested.connect(function() {
+            window.vnotex.saveContent();
+        });
+
         console.log('QWebChannel has been set up');
         if (window.vnotex.initialized) {
             window.vnotex.kickOffMarkdown();

+ 2 - 1
src/data/extra/web/js/mathjax.js

@@ -18,7 +18,8 @@ window.MathJax = {
     },
     svg: {
         // Make SVG self-contained.
-        fontCache: 'local'
+        fontCache: 'local',
+        scale: window.vxOptions.mathJaxScale > 0 ? window.vxOptions.mathJaxScale : 1
     }
 };
 

+ 241 - 0
src/data/extra/web/js/outline.js

@@ -0,0 +1,241 @@
+var toc = [];
+
+var setVisible = function(node, visible) {
+    var cl = 'hide-none';
+    if (visible) {
+        node.classList.remove(cl);
+    } else {
+        node.classList.add(cl);
+    }
+};
+
+var isVisible = function(node) {
+    var cl = 'hide-none';
+    return !node.classList.contains(cl);
+};
+
+var setPostContentExpanded = function(node, expanded) {
+    var cl = 'col-expand';
+    if (expanded) {
+        node.classList.add(cl);
+    } else {
+        node.classList.remove(cl);
+    }
+};
+
+var setOutlinePanelVisible = function(visible) {
+    var outlinePanel = document.getElementById('outline-panel');
+    var postContent = document.getElementById('post-content');
+
+    setVisible(outlinePanel, visible);
+    setPostContentExpanded(postContent, !visible);
+};
+
+var isOutlinePanelVisible = function() {
+    var outlinePanel = document.getElementById('outline-panel');
+    return isVisible(outlinePanel);
+};
+
+window.addEventListener('load', function() {
+    var outlinePanel = document.getElementById('outline-panel');
+    outlinePanel.style.display = 'initial';
+
+    var floatingContainer = document.getElementById('container-floating');
+    floatingContainer.style.display = 'initial';
+
+    var outlineContent = document.getElementById('outline-content');
+    var postContent = document.getElementById('post-content');
+
+    // Escape @text to Html.
+    var escapeHtml = function(text) {
+        var map = {
+            '&': '&amp;',
+            '<': '&lt;',
+            '>': '&gt;',
+            '"': '&quot;',
+            "'": '&#039;'
+        };
+
+        return text.replace(/[&<>"']/g, function(m) { return map[m]; });
+    }
+
+    // Fetch the outline.
+    var headers = postContent.querySelectorAll("h1, h2, h3, h4, h5, h6");
+    toc = [];
+    for (var i = 0; i < headers.length; ++i) {
+        var header = headers[i];
+
+        toc.push({
+            level: parseInt(header.tagName.substr(1)),
+            anchor: header.id,
+            title: escapeHtml(header.textContent)
+        });
+    }
+
+    if (toc.length == 0) {
+        setOutlinePanelVisible(false);
+        setVisible(floatingContainer, false);
+        return;
+    }
+
+    var baseLevel = baseLevelOfToc(toc);
+    var tocTree = tocToTree(toPerfectToc(toc, baseLevel), baseLevel);
+
+    outlineContent.innerHTML = tocTree;
+    setOutlinePanelVisible(true);
+    setVisible(floatingContainer, true);
+});
+
+// Return the topest level of @toc, starting from 1.
+var baseLevelOfToc = function(p_toc) {
+    var level = -1;
+    for (i in p_toc) {
+        if (level == -1) {
+            level = p_toc[i].level;
+        } else if (level > p_toc[i].level) {
+            level = p_toc[i].level;
+        }
+    }
+
+    if (level == -1) {
+        level = 1;
+    }
+
+    return level;
+};
+
+// Handle wrong title levels, such as '#' followed by '###'
+var toPerfectToc = function(p_toc, p_baseLevel) {
+    var i;
+    var curLevel = p_baseLevel - 1;
+    var perfToc = [];
+    for (i in p_toc) {
+        var item = p_toc[i];
+
+        // Insert empty header.
+        while (item.level > curLevel + 1) {
+            curLevel += 1;
+            var tmp = { level: curLevel,
+                        anchor: '',
+                        title: '[EMPTY]'
+                      };
+            perfToc.push(tmp);
+        }
+
+        perfToc.push(item);
+        curLevel = item.level;
+    }
+
+    return perfToc;
+};
+
+var itemToHtml = function(item) {
+    return '<a href="#' + item.anchor + '" data="' + item.anchor + '">' + item.title + '</a>';
+};
+
+// Turn a perfect toc to a tree using <ul>
+var tocToTree = function(p_toc, p_baseLevel) {
+    var i;
+    var front = '<li>';
+    var ending = ['</li>'];
+    var curLevel = p_baseLevel;
+    for (i in p_toc) {
+        var item = p_toc[i];
+        if (item.level == curLevel) {
+            front += '</li>';
+            front += '<li>';
+            front += itemToHtml(item);
+        } else if (item.level > curLevel) {
+            // assert(item.level - curLevel == 1)
+            front += '<ul>';
+            ending.push('</ul>');
+            front += '<li>';
+            front += itemToHtml(item);
+            ending.push('</li>');
+            curLevel = item.level;
+        } else {
+            while (item.level < curLevel) {
+                var ele = ending.pop();
+                front += ele;
+                if (ele == '</ul>') {
+                    curLevel--;
+                }
+            }
+            front += '</li>';
+            front += '<li>';
+            front += itemToHtml(item);
+        }
+    }
+    while (ending.length > 0) {
+        front += ending.pop();
+    }
+    front = front.replace("<li></li>", "");
+    front = '<ul>' + front + '</ul>';
+    return front;
+};
+
+var toggleMore = function() {
+    if (toc.length == 0) {
+        return;
+    }
+
+    var p = document.getElementById('floating-more');
+    if (isOutlinePanelVisible()) {
+        p.textContent = '<';
+        setOutlinePanelVisible(false);
+    } else {
+        p.textContent = '>';
+        setOutlinePanelVisible(true);
+    }
+};
+
+window.addEventListener('scroll', function() {
+    if (toc.length == 0 || !isOutlinePanelVisible()) {
+        return;
+    }
+
+    var postContent = document.getElementById('post-content');
+    var scrollTop = document.documentElement.scrollTop
+                    || document.body.scrollTop
+                    || window.pageYOffset;
+    var eles = postContent.querySelectorAll("h1, h2, h3, h4, h5, h6");
+
+    if (eles.length == 0) {
+        return;
+    }
+
+    var idx = -1;
+    var biaScrollTop = scrollTop + 50;
+    for (var i = 0; i < eles.length; ++i) {
+        if (biaScrollTop >= eles[i].offsetTop) {
+            idx = i;
+        } else {
+            break;
+        }
+    }
+
+    var header = '';
+    if (idx != -1) {
+        header = eles[idx].id;
+    }
+
+    highlightItemOnlyInOutline(header);
+});
+
+var highlightItemOnlyInOutline = function(id) {
+    var cl = 'outline-bold';
+    var outlineContent = document.getElementById('outline-content');
+    var eles = outlineContent.querySelectorAll("a");
+    var target = null;
+    for (var i = 0; i < eles.length; ++i) {
+        var ele = eles[i];
+        if (ele.getAttribute('data') == id) {
+            target = ele;
+            ele.classList.add(cl);
+        } else {
+            ele.classList.remove(cl);
+        }
+    }
+
+    // TODO: scroll target into view within the outline panel scroll area.
+};

+ 2 - 1
src/data/extra/web/js/plantuml.js

@@ -19,7 +19,8 @@ class PlantUml extends GraphRenderer {
     registerInternal() {
         this.vnotex.on('basicMarkdownRendered', () => {
             this.reset();
-            this.renderCodeNodes(this.vnotex.contentContainer, 'svg');
+            this.renderCodeNodes(this.vnotex.contentContainer,
+                                 window.vxOptions.transformSvgToPngEnabled ? 'png' : 'svg');
         });
 
         this.vnotex.getWorker('markdownit').addLangsToSkipHighlight(this.langs);

+ 51 - 0
src/data/extra/web/js/utils.js

@@ -113,4 +113,55 @@ class Utils {
     static headingSequenceRegExp() {
         return /^\d{1,3}(?:\.\d+)*\. /;
     }
+
+    static fetchStyleContent() {
+        let styles = "";
+        for (let styleIdx = 0; styleIdx < document.styleSheets.length; ++styleIdx) {
+            let styleSheet = document.styleSheets[styleIdx];
+            if (styleSheet.cssRules) {
+                let baseUrl = null;
+                if (styleSheet.href) {
+                    let scheme = Utils.getUrlScheme(styleSheet.href);
+                    // We only translate local resources.
+                    if (scheme === 'file' || scheme === 'qrc') {
+                        baseUrl = styleSheet.href.substr(0, styleSheet.href.lastIndexOf('/'));
+                    }
+                }
+
+                for (let ruleIdx = 0; ruleIdx < styleSheet.cssRules.length; ++ruleIdx) {
+                    let css = styleSheet.cssRules[ruleIdx].cssText;
+                    if (baseUrl) {
+                        // Try to replace the url() with absolute path.
+                        css = Utils.translateCssUrlToAbsolute(baseUrl, css);
+                    }
+
+                    styles = styles + css + "\n";
+                }
+            }
+        }
+
+        return styles;
+    }
+
+    static translateCssUrlToAbsolute(p_baseUrl, p_css) {
+        let replaceCssUrl = function(baseUrl, match, p1, offset, str) {
+            if (Utils.getUrlScheme(p1)) {
+                return match;
+            }
+
+            let url = baseUrl + '/' + p1;
+            return "url(\"" + url + "\");";
+        };
+
+        return p_css.replace(/\burl\(\"([^\"\)]+)\"\);/g, replaceCssUrl.bind(undefined, p_baseUrl));
+    }
+
+    static getUrlScheme(p_url) {
+        let idx = p_url.indexOf(':');
+        if (idx > -1) {
+            return p_url.substr(0, idx);
+        } else {
+            return null;
+        }
+    }
 }

+ 53 - 7
src/data/extra/web/js/vnotex.js

@@ -60,6 +60,18 @@ class VNoteX extends EventEmitter {
                 this.sectionNumberBaseLevel = 3;
             }
 
+            this.setContentContainerOption('vx-constrain-image-width',
+                                           window.vxOptions.constrainImageWidthEnabled || !window.vxOptions.scrollable);
+            this.setContentContainerOption('vx-indent-first-line',
+                                           window.vxOptions.indentFirstLineEnabled);
+            this.setBodyOption('vx-transparent-background',
+                               window.vxOptions.transparentBackgroundEnabled);
+            this.setContentContainerOption('vx-nonscrollable',
+                                           !window.vxOptions.scrollable);
+
+            this.setBodySize(window.vxOptions.bodyWidth, window.vxOptions.bodyHeight);
+            document.body.style.height = '800';
+
             this.initialized = true;
 
             // Signal out.
@@ -68,6 +80,22 @@ class VNoteX extends EventEmitter {
         });
     }
 
+    setContentContainerOption(p_class, p_enabled) {
+        if (p_enabled) {
+            this.contentContainer.classList.add(p_class);
+        } else {
+            this.contentContainer.classList.remove(p_class);
+        }
+    }
+
+    setBodyOption(p_class, p_enabled) {
+        if (p_enabled) {
+            document.body.classList.add(p_class);
+        } else {
+            document.body.classList.remove(p_class);
+        }
+    }
+
     registerWorker(p_worker) {
         this.workers.set(p_worker.name, p_worker);
 
@@ -79,6 +107,7 @@ class VNoteX extends EventEmitter {
         if (this.numOfOngoingWorkers == 0) {
             // Signal out anyway.
             this.emit('fullMarkdownRendered');
+            window.vxMarkdownAdapter.setWorkFinished();
 
             // Check pending work.
             if (this.pendingData.text) {
@@ -211,13 +240,8 @@ class VNoteX extends EventEmitter {
     setSectionNumberEnabled(p_enabled) {
         let sectionClass = 'vx-section-number';
         let sectionLevelClass = 'vx-section-number-' + this.sectionNumberBaseLevel;
-        if (p_enabled) {
-            this.contentContainer.classList.add(sectionClass);
-            this.contentContainer.classList.add(sectionLevelClass);
-        } else {
-            this.contentContainer.classList.remove(sectionClass);
-            this.contentContainer.classList.remove(sectionLevelClass);
-        }
+        this.setContentContainerOption(sectionClass, p_enabled);
+        this.setContentContainerOption(sectionLevelClass, p_enabled);
     }
 
     scroll(p_up) {
@@ -261,6 +285,28 @@ class VNoteX extends EventEmitter {
         window.vxMarkdownAdapter.setFindText(p_text, p_totalMatches, p_currentMatchIndex);
     }
 
+    saveContent() {
+        if (!this.initialized) {
+            console.warn('saveContent() called before initialization');
+            window.vxMarkdownAdapter.setSavedContent('', '', '');
+            return;
+        }
+        window.vxMarkdownAdapter.setSavedContent("",
+                                                 Utils.fetchStyleContent(),
+                                                 this.contentContainer.outerHTML,
+                                                 document.body.classList.value);
+    }
+
+    setBodySize(p_width, p_height) {
+        if (p_width > 0) {
+            document.body.style.width = p_width + 'px';
+        }
+
+        if (p_height > 0) {
+            document.body.style.height = p_height + 'px';
+        }
+    }
+
     static detectOS() {
         let osName="Unknown OS";
         if (navigator.appVersion.indexOf("Win")!=-1) {

+ 45 - 0
src/data/extra/web/markdown-export-template.html

@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="generator" content="VNote">
+
+    <!-- VX_TITLE_PLACEHOLDER -->
+
+    <style type="text/css">
+    /* VX_GLOBAL_STYLES_PLACEHOLDER */
+    </style>
+
+    <style type="text/css">
+    /* VX_STYLES_PLACEHOLDER */
+
+    /* VX_STYLES_CONTENT_PLACEHOLDER */
+    </style>
+
+    <script type="text/javascript">
+        /* VX_SCRIPTS_PLACEHOLDER */
+    </script>
+
+    <!-- VX_SCRIPTS_PLACEHOLDER -->
+
+    <!-- VX_HEAD_PLACEHOLDER -->
+</head>
+<body class="<!-- VX_BODY_CLASS_LIST_PLACEHOLDER -->">
+    <div class="container-fluid">
+    <div class="row flex-xl-nowrap">
+        <div id="outline-panel" style="display:none;" class="d-none d-md-block d-xl-block col-md-3 col-xl-2 bd-toc">
+            <div id="outline-content" class="section-nav"></div>
+        </div>
+        <div id="post-content" class="col-12 col-md-9 col-xl-10 py-md-3 pl-md-5 bd-content">
+        <!-- VX_CONTENT_PLACEHOLDER -->
+        </div>
+    </div>
+    </div>
+
+    <div id="container-floating" style="display:none;" class="d-none d-md-block d-xl-block">
+        <div id="floating-button" onclick="toggleMore()">
+            <p id="floating-more" class="more">&gt;</p>
+        </div>
+    </div>
+</body>
+</html>

+ 0 - 0
src/data/extra/web/markdownviewertemplate.html → src/data/extra/web/markdown-viewer-template.html


+ 11 - 0
src/export/export.pri

@@ -0,0 +1,11 @@
+QT += widgets
+
+SOURCES += \
+    $$PWD/exportdata.cpp \
+    $$PWD/exporter.cpp \
+    $$PWD/webviewexporter.cpp
+
+HEADERS += \
+    $$PWD/exportdata.h \
+    $$PWD/exporter.h \
+    $$PWD/webviewexporter.h

+ 126 - 0
src/export/exportdata.cpp

@@ -0,0 +1,126 @@
+#include "exportdata.h"
+
+#include <QPageLayout>
+#include <QJsonObject>
+#include <QJsonArray>
+
+using namespace vnotex;
+
+QJsonObject ExportHtmlOption::toJson() const
+{
+    QJsonObject obj;
+    obj["embed_styles"] = m_embedStyles;
+    obj["complete_page"] = m_completePage;
+    obj["embed_images"] = m_embedImages;
+    obj["use_mime_html_format"] = m_useMimeHtmlFormat;
+    obj["add_outline_panel"] = m_addOutlinePanel;
+    return obj;
+}
+
+void ExportHtmlOption::fromJson(const QJsonObject &p_obj)
+{
+    if (p_obj.isEmpty()) {
+        return;
+    }
+
+    m_embedStyles = p_obj["embed_styles"].toBool();
+    m_completePage = p_obj["complete_page"].toBool();
+    m_embedImages = p_obj["embed_images"].toBool();
+    m_useMimeHtmlFormat = p_obj["use_mime_html_format"].toBool();
+    m_addOutlinePanel = p_obj["add_outline_panel"].toBool();
+}
+
+bool ExportHtmlOption::operator==(const ExportHtmlOption &p_other) const
+{
+    return m_embedStyles == p_other.m_embedStyles
+           && m_completePage == p_other.m_completePage
+           && m_embedImages == p_other.m_embedImages
+           && m_useMimeHtmlFormat == p_other.m_useMimeHtmlFormat
+           && m_addOutlinePanel == p_other.m_addOutlinePanel;
+}
+
+ExportPdfOption::ExportPdfOption()
+    : m_layout(new QPageLayout(QPageSize(QPageSize::A4),
+                               QPageLayout::Portrait,
+                               QMarginsF(10, 16, 10, 10),
+                               QPageLayout::Millimeter))
+{
+}
+
+QJsonObject ExportPdfOption::toJson() const
+{
+    QJsonObject obj;
+    obj["add_table_of_contents"] = m_addTableOfContents;
+    obj["use_wkhtmltopdf"] = m_useWkhtmltopdf;
+    obj["wkhtmltopdf_exe_path"] = m_wkhtmltopdfExePath;
+    obj["wkhtmltopdf_args"] = m_wkhtmltopdfArgs;
+    return obj;
+}
+
+void ExportPdfOption::fromJson(const QJsonObject &p_obj)
+{
+    if (p_obj.isEmpty()) {
+        return;
+    }
+
+    m_addTableOfContents = p_obj["add_table_of_contents"].toBool();
+    m_useWkhtmltopdf = p_obj["use_wkhtmltopdf"].toBool();
+    m_wkhtmltopdfExePath = p_obj["wkhtmltopdf_exe_path"].toString();
+    m_wkhtmltopdfArgs = p_obj["wkhtmltopdf_args"].toString();
+}
+
+bool ExportPdfOption::operator==(const ExportPdfOption &p_other) const
+{
+    return m_addTableOfContents == p_other.m_addTableOfContents
+           && m_useWkhtmltopdf == p_other.m_useWkhtmltopdf
+           && m_wkhtmltopdfExePath == p_other.m_wkhtmltopdfExePath
+           && m_wkhtmltopdfArgs == p_other.m_wkhtmltopdfArgs;
+}
+
+QJsonObject ExportOption::toJson() const
+{
+    QJsonObject obj;
+    obj["use_transparent_bg"] = m_useTransparentBg;
+    obj["output_dir"] = m_outputDir;
+    obj["recursive"] = m_recursive;
+    obj["export_attachments"] = m_exportAttachments;
+    obj["html_option"] = m_htmlOption.toJson();
+    obj["pdf_option"] = m_pdfOption.toJson();
+    return obj;
+}
+
+void ExportOption::fromJson(const QJsonObject &p_obj)
+{
+    if (p_obj.isEmpty()) {
+        return;
+    }
+
+    m_useTransparentBg = p_obj["use_transparent_bg"].toBool();
+    m_outputDir = p_obj["output_dir"].toString();
+    m_recursive = p_obj["recursive"].toBool();
+    m_exportAttachments = p_obj["export_attachments"].toBool();
+    m_htmlOption.fromJson(p_obj["html_option"].toObject());
+    m_pdfOption.fromJson(p_obj["pdf_option"].toObject());
+}
+
+bool ExportOption::operator==(const ExportOption &p_other) const
+{
+    bool ret = m_useTransparentBg == p_other.m_useTransparentBg
+               && m_outputDir == p_other.m_outputDir
+               && m_recursive == p_other.m_recursive
+               && m_exportAttachments == p_other.m_exportAttachments;
+
+    if (!ret) {
+        return false;
+    }
+
+    if (!(m_htmlOption == p_other.m_htmlOption)) {
+        return false;
+    }
+
+    if (!(m_pdfOption == p_other.m_pdfOption)) {
+        return false;
+    }
+
+    return true;
+}

+ 114 - 0
src/export/exportdata.h

@@ -0,0 +1,114 @@
+#ifndef EXPORTDATA_H
+#define EXPORTDATA_H
+
+#include <QSharedPointer>
+
+class QPageLayout;
+
+namespace vnotex
+{
+    enum class ExportSource
+    {
+        CurrentBuffer = 0,
+        CurrentFolder,
+        CurrentNotebook
+    };
+
+    enum class ExportFormat
+    {
+        Markdown = 0,
+        HTML,
+        PDF,
+        Custom
+    };
+
+    struct ExportHtmlOption
+    {
+        QJsonObject toJson() const;
+        void fromJson(const QJsonObject &p_obj);
+
+        bool operator==(const ExportHtmlOption &p_other) const;
+
+        bool m_embedStyles = true;
+
+        bool m_completePage = true;
+
+        bool m_embedImages = true;
+
+        bool m_useMimeHtmlFormat = false;
+
+        // Whether add outline panel.
+        bool m_addOutlinePanel = true;
+    };
+
+    struct ExportPdfOption
+    {
+        ExportPdfOption();
+
+        QJsonObject toJson() const;
+        void fromJson(const QJsonObject &p_obj);
+
+        bool operator==(const ExportPdfOption &p_other) const;
+
+        QSharedPointer<QPageLayout> m_layout;
+
+        // Add TOC at the front to complement the missing of outline.
+        bool m_addTableOfContents = false;
+
+        bool m_useWkhtmltopdf = false;
+
+        QString m_wkhtmltopdfExePath;
+
+        QString m_wkhtmltopdfArgs;
+    };
+
+    struct ExportOption
+    {
+        QJsonObject toJson() const;
+        void fromJson(const QJsonObject &p_obj);
+
+        bool operator==(const ExportOption &p_other) const;
+
+        ExportSource m_source = ExportSource::CurrentBuffer;
+
+        ExportFormat m_targetFormat = ExportFormat::HTML;
+
+        bool m_useTransparentBg = true;
+
+        QString m_renderingStyleFile;
+
+        QString m_syntaxHighlightStyleFile;
+
+        QString m_outputDir;
+
+        bool m_recursive = true;
+
+        bool m_exportAttachments = true;
+
+        ExportHtmlOption m_htmlOption;
+
+        ExportPdfOption m_pdfOption;
+    };
+
+    inline QString exportFormatString(ExportFormat p_format)
+    {
+        switch (p_format)
+        {
+        case ExportFormat::Markdown:
+            return QStringLiteral("Markdown");
+
+        case ExportFormat::HTML:
+            return QStringLiteral("HTML");
+
+        case ExportFormat::PDF:
+            return QStringLiteral("PDF");
+
+        case ExportFormat::Custom:
+            return QStringLiteral("Custom");
+        }
+
+        return QStringLiteral("Unknown");
+    }
+}
+
+#endif // EXPORTDATA_H

+ 312 - 0
src/export/exporter.cpp

@@ -0,0 +1,312 @@
+#include "exporter.h"
+
+#include <QWidget>
+
+#include <notebook/notebook.h>
+#include <notebook/node.h>
+#include <buffer/buffer.h>
+#include <core/file.h>
+#include <utils/fileutils.h>
+#include <utils/pathutils.h>
+#include <utils/contentmediautils.h>
+#include "webviewexporter.h"
+
+using namespace vnotex;
+
+Exporter::Exporter(QWidget *p_parent)
+    : QObject(p_parent)
+{
+}
+
+QString Exporter::doExport(const ExportOption &p_option, Buffer *p_buffer)
+{
+    m_askedToStop = false;
+
+    QString outputFile;
+    auto file = p_buffer->getFile();
+    if (!file) {
+        emit logRequested(tr("Skipped buffer (%1) without file base.").arg(p_buffer->getName()));
+        return outputFile;
+    }
+
+    // Make sure output folder exists.
+    if (!QDir().mkpath(p_option.m_outputDir)) {
+        emit logRequested(tr("Failed to create output folder %1.").arg(p_option.m_outputDir));
+        return outputFile;
+    }
+
+    outputFile = doExport(p_option, p_option.m_outputDir, file.data());
+
+    cleanUp();
+
+    return outputFile;
+}
+
+QString Exporter::doExportMarkdown(const ExportOption &p_option, const QString &p_outputDir, const File *p_file)
+{
+    QString outputFile;
+    if (!p_file->getContentType().isMarkdown()) {
+        emit logRequested(tr("Format %1 is not supported to export as Markdown.").arg(p_file->getContentType().m_displayName));
+        return outputFile;
+    }
+
+    // Export it to a folder with the same name.
+    auto name = FileUtils::generateFileNameWithSequence(p_outputDir, p_file->getName(), "");
+    auto outputFolder = PathUtils::concatenateFilePath(p_outputDir, name);
+    QDir outDir(outputFolder);
+    if (!outDir.mkpath(outputFolder)) {
+        emit logRequested(tr("Failed to create output folder %1.").arg(outputFolder));
+        return outputFile;
+    }
+
+    // Copy source file itself.
+    const auto srcFilePath = p_file->getFilePath();
+    auto destFilePath = outDir.filePath(p_file->getName());
+    FileUtils::copyFile(srcFilePath, destFilePath, false);
+    outputFile = destFilePath;
+
+    ContentMediaUtils::copyMediaFiles(p_file, destFilePath);
+
+    // Copy attachments if available.
+    if (p_option.m_exportAttachments) {
+        exportAttachments(p_file->getNode(), srcFilePath, outputFolder, destFilePath);
+    }
+
+    return outputFile;
+}
+
+void Exporter::exportAttachments(Node *p_node,
+                                 const QString &p_srcFilePath,
+                                 const QString &p_outputFolder,
+                                 const QString &p_destFilePath)
+{
+    const auto &attachmentFolder = p_node->getAttachmentFolder();
+    if (!attachmentFolder.isEmpty()) {
+        auto relativePath = PathUtils::relativePath(PathUtils::parentDirPath(p_srcFilePath),
+                                                    p_node->fetchAttachmentFolderPath());
+        auto destAttachmentFolderPath = QDir(p_outputFolder).filePath(relativePath);
+        destAttachmentFolderPath = FileUtils::renameIfExistsCaseInsensitive(destAttachmentFolderPath);
+        ContentMediaUtils::copyAttachment(p_node, nullptr, p_destFilePath, destAttachmentFolderPath);
+    }
+}
+
+QStringList Exporter::doExport(const ExportOption &p_option, Node *p_folder)
+{
+    m_askedToStop = false;
+
+    auto outputFiles = doExport(p_option, p_option.m_outputDir, p_folder);
+
+    cleanUp();
+
+    return outputFiles;
+}
+
+QStringList Exporter::doExport(const ExportOption &p_option, const QString &p_outputDir, Node *p_folder)
+{
+    Q_ASSERT(p_folder->isContainer());
+
+    QStringList outputFiles;
+
+    // Make path.
+    auto name = FileUtils::generateFileNameWithSequence(p_outputDir, p_folder->getName());
+    auto outputFolder = PathUtils::concatenateFilePath(p_outputDir, name);
+    if (!QDir().mkpath(outputFolder)) {
+        emit logRequested(tr("Failed to create output folder %1.").arg(outputFolder));
+        return outputFiles;
+    }
+
+    p_folder->load();
+    const auto &children = p_folder->getChildren();
+    emit progressUpdated(0, children.size());
+    for (int i = 0; i < children.size(); ++i) {
+        if (checkAskedToStop()) {
+            break;
+        }
+
+        const auto &child = children[i];
+        if (child->hasContent()) {
+            auto outputFile = doExport(p_option, outputFolder, child->getContentFile().data());
+            if (!outputFile.isEmpty()) {
+                outputFiles << outputFile;
+            }
+        }
+        if (p_option.m_recursive && child->isContainer() && child->getUse() == Node::Use::Normal) {
+            outputFiles.append(doExport(p_option, outputFolder, child.data()));
+        }
+
+        emit progressUpdated(i + 1, children.size());
+    }
+
+    return outputFiles;
+}
+
+QString Exporter::doExport(const ExportOption &p_option, const QString &p_outputDir, const File *p_file)
+{
+    QString outputFile;
+
+    switch (p_option.m_targetFormat) {
+    case ExportFormat::Markdown:
+        outputFile = doExportMarkdown(p_option, p_outputDir, p_file);
+        break;
+
+    case ExportFormat::HTML:
+        outputFile = doExportHtml(p_option, p_outputDir, p_file);
+        break;
+
+    case ExportFormat::PDF:
+        outputFile = doExportPdf(p_option, p_outputDir, p_file);
+        break;
+
+    default:
+        emit logRequested(tr("Unknown target format %1.").arg(exportFormatString(p_option.m_targetFormat)));
+        break;
+    }
+
+    if (!outputFile.isEmpty()) {
+        emit logRequested(tr("File (%1) exported to (%2)").arg(p_file->getFilePath(), outputFile));
+    } else {
+        emit logRequested(tr("Failed to export file (%1)").arg(p_file->getFilePath()));
+    }
+
+    return outputFile;
+}
+
+QStringList Exporter::doExport(const ExportOption &p_option, Notebook *p_notebook)
+{
+    m_askedToStop = false;
+
+    QStringList outputFiles;
+
+    // Make path.
+    auto name = FileUtils::generateFileNameWithSequence(p_option.m_outputDir,
+                                                        tr("notebook_%1").arg(p_notebook->getName()));
+    auto outputFolder = PathUtils::concatenateFilePath(p_option.m_outputDir, name);
+    if (!QDir().mkpath(outputFolder)) {
+        emit logRequested(tr("Failed to create output folder %1.").arg(outputFolder));
+        return outputFiles;
+    }
+
+    auto rootNode = p_notebook->getRootNode();
+    Q_ASSERT(rootNode->isLoaded());
+
+    const auto &children = rootNode->getChildren();
+    emit progressUpdated(0, children.size());
+    for (int i = 0; i < children.size(); ++i) {
+        if (checkAskedToStop()) {
+            break;
+        }
+
+        const auto &child = children[i];
+        if (child->hasContent()) {
+            auto outputFile = doExport(p_option, outputFolder, child->getContentFile().data());
+            if (!outputFile.isEmpty()) {
+                outputFiles << outputFile;
+            }
+        }
+        if (child->isContainer() && child->getUse() == Node::Use::Normal) {
+            outputFiles.append(doExport(p_option, outputFolder, child.data()));
+        }
+
+        emit progressUpdated(i + 1, children.size());
+    }
+
+    cleanUp();
+
+    return outputFiles;
+}
+
+QString Exporter::doExportHtml(const ExportOption &p_option, const QString &p_outputDir, const File *p_file)
+{
+    QString outputFile;
+    if (!p_file->getContentType().isMarkdown()) {
+        emit logRequested(tr("Format %1 is not supported to export as HTML.").arg(p_file->getContentType().m_displayName));
+        return outputFile;
+    }
+
+    QString suffix = p_option.m_htmlOption.m_useMimeHtmlFormat ? QStringLiteral("mht") : QStringLiteral("html");
+    auto fileName = FileUtils::generateFileNameWithSequence(p_outputDir,
+                                                            QFileInfo(p_file->getName()).completeBaseName(),
+                                                            suffix);
+    auto destFilePath = PathUtils::concatenateFilePath(p_outputDir, fileName);
+
+    bool success = getWebViewExporter(p_option)->doExport(p_option, p_file, destFilePath);
+    if (success) {
+        outputFile = destFilePath;
+
+        // Copy attachments if available.
+        if (p_option.m_exportAttachments) {
+            exportAttachments(p_file->getNode(), p_file->getFilePath(), p_outputDir, destFilePath);
+        }
+    }
+    return outputFile;
+}
+
+WebViewExporter *Exporter::getWebViewExporter(const ExportOption &p_option)
+{
+    if (!m_webViewExporter) {
+        m_webViewExporter = new WebViewExporter(static_cast<QWidget *>(parent()));
+        connect(m_webViewExporter, &WebViewExporter::logRequested,
+                this, &Exporter::logRequested);
+        m_webViewExporter->prepare(p_option);
+    }
+
+    return m_webViewExporter;
+}
+
+void Exporter::cleanUpWebViewExporter()
+{
+    if (m_webViewExporter) {
+        m_webViewExporter->clear();
+        delete m_webViewExporter;
+        m_webViewExporter = nullptr;
+    }
+}
+
+void Exporter::cleanUp()
+{
+    cleanUpWebViewExporter();
+}
+
+void Exporter::stop()
+{
+    m_askedToStop = true;
+
+    if (m_webViewExporter) {
+        m_webViewExporter->stop();
+    }
+}
+
+bool Exporter::checkAskedToStop() const
+{
+    if (m_askedToStop) {
+        emit const_cast<Exporter *>(this)->logRequested(tr("Asked to stop. Aborting."));
+        return true;
+    }
+
+    return false;
+}
+
+QString Exporter::doExportPdf(const ExportOption &p_option, const QString &p_outputDir, const File *p_file)
+{
+    QString outputFile;
+    if (!p_file->getContentType().isMarkdown()) {
+        emit logRequested(tr("Format %1 is not supported to export as PDF.").arg(p_file->getContentType().m_displayName));
+        return outputFile;
+    }
+
+    auto fileName = FileUtils::generateFileNameWithSequence(p_outputDir,
+                                                            QFileInfo(p_file->getName()).completeBaseName(),
+                                                            "pdf");
+    auto destFilePath = PathUtils::concatenateFilePath(p_outputDir, fileName);
+
+    bool success = getWebViewExporter(p_option)->doExport(p_option, p_file, destFilePath);
+    if (success) {
+        outputFile = destFilePath;
+
+        // Copy attachments if available.
+        if (p_option.m_exportAttachments) {
+            exportAttachments(p_file->getNode(), p_file->getFilePath(), p_outputDir, destFilePath);
+        }
+    }
+    return outputFile;
+}

+ 70 - 0
src/export/exporter.h

@@ -0,0 +1,70 @@
+#ifndef EXPORTER_H
+#define EXPORTER_H
+
+#include <QObject>
+#include <QStringList>
+
+#include "exportdata.h"
+
+namespace vnotex
+{
+    class Notebook;
+    class Node;
+    class Buffer;
+    class File;
+    class WebViewExporter;
+
+    class Exporter : public QObject
+    {
+        Q_OBJECT
+    public:
+        // We need the QWidget as parent.
+        explicit Exporter(QWidget *p_parent);
+
+        // Return exported output file.
+        QString doExport(const ExportOption &p_option, Buffer *p_buffer);
+
+        // Return exported output files.
+        QStringList doExport(const ExportOption &p_option, Node *p_folder);
+
+        QStringList doExport(const ExportOption &p_option, Notebook *p_notebook);
+
+        void stop();
+
+    signals:
+        void progressUpdated(int p_val, int p_maximum);
+
+        void logRequested(const QString &p_log);
+
+    private:
+        QStringList doExport(const ExportOption &p_option, const QString &p_outputDir, Node *p_folder);
+
+        QString doExport(const ExportOption &p_option, const QString &p_outputDir, const File *p_file);
+
+        QString doExportMarkdown(const ExportOption &p_option, const QString &p_outputDir, const File *p_file);
+
+        QString doExportHtml(const ExportOption &p_option, const QString &p_outputDir, const File *p_file);
+
+        QString doExportPdf(const ExportOption &p_option, const QString &p_outputDir, const File *p_file);
+
+        void exportAttachments(Node *p_node,
+                               const QString &p_srcFilePath,
+                               const QString &p_outputFolder,
+                               const QString &p_destFilePath);
+
+        WebViewExporter *getWebViewExporter(const ExportOption &p_option);
+
+        void cleanUpWebViewExporter();
+
+        void cleanUp();
+
+        bool checkAskedToStop() const;
+
+        // Managed by QObject.
+        WebViewExporter *m_webViewExporter = nullptr;
+
+        bool m_askedToStop = false;
+    };
+}
+
+#endif // EXPORTER_H

+ 568 - 0
src/export/webviewexporter.cpp

@@ -0,0 +1,568 @@
+#include "webviewexporter.h"
+
+#include <QWidget>
+#include <QWebEnginePage>
+#include <QFileInfo>
+#include <QTemporaryDir>
+#include <QProcess>
+
+#include <widgets/editors/markdownviewer.h>
+#include <widgets/editors/editormarkdownvieweradapter.h>
+#include <core/editorconfig.h>
+#include <core/markdowneditorconfig.h>
+#include <core/configmgr.h>
+#include <core/htmltemplatehelper.h>
+#include <utils/utils.h>
+#include <utils/pathutils.h>
+#include <utils/fileutils.h>
+#include <utils/webutils.h>
+#include <utils/processutils.h>
+#include <core/file.h>
+
+using namespace vnotex;
+
+static const QString c_imgRegExp = "<img ([^>]*)src=\"(?!data:)([^\"]+)\"([^>]*)>";
+
+WebViewExporter::WebViewExporter(QWidget *p_parent)
+    : QObject(p_parent)
+{
+}
+
+WebViewExporter::~WebViewExporter()
+{
+    clear();
+}
+
+void WebViewExporter::clear()
+{
+    m_askedToStop = false;
+
+    delete m_viewer;
+    m_viewer = nullptr;
+
+    m_htmlTemplate.clear();
+    m_exportHtmlTemplate.clear();
+
+    m_exportOngoing = false;
+}
+
+bool WebViewExporter::doExport(const ExportOption &p_option,
+                               const File *p_file,
+                               const QString &p_outputFile)
+{
+    bool ret = false;
+    m_askedToStop = false;
+
+    Q_ASSERT(p_file->getContentType().isMarkdown());
+
+    Q_ASSERT(!m_exportOngoing);
+    m_exportOngoing = true;
+
+    m_webViewStates = WebViewState::Started;
+
+    auto baseUrl = PathUtils::pathToUrl(p_file->getContentPath());
+    m_viewer->setHtml(m_htmlTemplate, baseUrl);
+
+    auto textContent = p_file->read();
+    if (p_option.m_targetFormat == ExportFormat::PDF
+        && p_option.m_pdfOption.m_addTableOfContents
+        && !p_option.m_pdfOption.m_useWkhtmltopdf) {
+        // Add `[TOC]` at the beginning.
+        m_viewer->adapter()->setText("[TOC]\n\n" + textContent);
+    } else {
+        m_viewer->adapter()->setText(textContent);
+    }
+
+    while (!isWebViewReady()) {
+        Utils::sleepWait(100);
+
+        if (m_askedToStop) {
+            goto exit_export;
+        }
+
+        if (isWebViewFailed()) {
+            qWarning() << "WebView failed when exporting" << p_file->getFilePath();
+            goto exit_export;
+        }
+    }
+
+    qDebug() << "WebView is ready";
+
+    // Add extra wait to make sure Web side is really ready.
+    Utils::sleepWait(200);
+
+    switch (p_option.m_targetFormat) {
+    case ExportFormat::HTML:
+        // TODO: not supported yet.
+        Q_ASSERT(!p_option.m_htmlOption.m_useMimeHtmlFormat);
+        ret = doExportHtml(p_option.m_htmlOption, p_outputFile, baseUrl);
+        break;
+
+    case ExportFormat::PDF:
+        if (p_option.m_pdfOption.m_useWkhtmltopdf) {
+            ret = doExportWkhtmltopdf(p_option.m_pdfOption, p_outputFile, baseUrl);
+        } else {
+            ret = doExportPdf(p_option.m_pdfOption, p_outputFile);
+        }
+        break;
+
+    default:
+        break;
+    }
+
+exit_export:
+    m_exportOngoing = false;
+    return ret;
+}
+
+void WebViewExporter::stop()
+{
+    m_askedToStop = true;
+}
+
+bool WebViewExporter::isWebViewReady() const
+{
+    return m_webViewStates == (WebViewState::LoadFinished | WebViewState::WorkFinished);
+}
+
+bool WebViewExporter::isWebViewFailed() const
+{
+    return m_webViewStates & WebViewState::Failed;
+}
+
+bool WebViewExporter::doExportHtml(const ExportHtmlOption &p_htmlOption,
+                                   const QString &p_outputFile,
+                                   const QUrl &p_baseUrl)
+{
+    ExportState state = ExportState::Busy;
+
+    connect(m_viewer->adapter(), &MarkdownViewerAdapter::contentReady,
+            this, [&, this](const QString &p_headContent,
+                            const QString &p_styleContent,
+                            const QString &p_content,
+                            const QString &p_bodyClassList) {
+                qDebug() << "doExportHtml contentReady";
+                // Maybe unnecessary. Just to avoid duplicated signal connections.
+                disconnect(m_viewer->adapter(), &MarkdownViewerAdapter::contentReady, this, 0);
+
+                if (p_content.isEmpty() || m_askedToStop) {
+                    state = ExportState::Failed;
+                    return;
+                }
+
+                if (!writeHtmlFile(p_outputFile,
+                                   p_baseUrl,
+                                   p_headContent,
+                                   p_styleContent,
+                                   p_content,
+                                   p_bodyClassList,
+                                   p_htmlOption.m_embedStyles,
+                                   p_htmlOption.m_completePage,
+                                   p_htmlOption.m_embedImages)) {
+                    state = ExportState::Failed;
+                    return;
+                }
+
+                state = ExportState::Finished;
+            });
+
+    m_viewer->adapter()->saveContent();
+
+    while (state == ExportState::Busy) {
+        Utils::sleepWait(100);
+
+        if (m_askedToStop) {
+            break;
+        }
+    }
+
+    return state == ExportState::Finished;
+}
+
+bool WebViewExporter::writeHtmlFile(const QString &p_file,
+                                    const QUrl &p_baseUrl,
+                                    const QString &p_headContent,
+                                    QString p_styleContent,
+                                    const QString &p_content,
+                                    const QString &p_bodyClassList,
+                                    bool p_embedStyles,
+                                    bool p_completePage,
+                                    bool p_embedImages)
+{
+    const auto baseName = QFileInfo(p_file).completeBaseName();
+    auto title = QString("%1 - %2").arg(baseName, ConfigMgr::c_appName);
+    const QString resourceFolderName = baseName + "_files";
+    auto resourceFolder = PathUtils::concatenateFilePath(PathUtils::parentDirPath(p_file), resourceFolderName);
+
+    qDebug() << "HTML files folder" << resourceFolder;
+
+    auto htmlContent = m_exportHtmlTemplate;
+    HtmlTemplateHelper::fillTitle(htmlContent, title);
+
+    if (!p_styleContent.isEmpty() && p_embedStyles) {
+        embedStyleResources(p_styleContent);
+        HtmlTemplateHelper::fillStyleContent(htmlContent, p_styleContent);
+    }
+
+    if (!p_headContent.isEmpty()) {
+        HtmlTemplateHelper::fillHeadContent(htmlContent, p_headContent);
+    }
+
+    if (p_completePage) {
+        QString content(p_content);
+        if (p_embedImages) {
+            embedBodyResources(p_baseUrl, content);
+        } else {
+            fixBodyResources(p_baseUrl, resourceFolder, content);
+        }
+
+        HtmlTemplateHelper::fillContent(htmlContent, content);
+    } else {
+        HtmlTemplateHelper::fillContent(htmlContent, p_content);
+    }
+
+    if (!p_bodyClassList.isEmpty()) {
+        HtmlTemplateHelper::fillBodyClassList(htmlContent, p_bodyClassList);
+    }
+
+    FileUtils::writeFile(p_file, htmlContent);
+
+    // Delete empty resource folder.
+    QDir dir(resourceFolder);
+    if (dir.exists() && dir.isEmpty()) {
+        dir.cdUp();
+        dir.rmdir(resourceFolderName);
+    }
+
+    return true;
+}
+
+QSize WebViewExporter::pageLayoutSize(const QPageLayout &p_layout) const
+{
+    Q_ASSERT(m_viewer);
+    auto rect = p_layout.paintRect(QPageLayout::Inch);
+    return QSize(rect.width() * m_viewer->logicalDpiX(), rect.height() * m_viewer->logicalDpiY());
+}
+
+void WebViewExporter::prepare(const ExportOption &p_option)
+{
+    Q_ASSERT(!m_viewer && !m_exportOngoing);
+    {
+        // Adapter will be managed by MarkdownViewer.
+        auto adapter = new MarkdownViewerAdapter(this);
+        m_viewer = new MarkdownViewer(adapter, QColor(), 1, static_cast<QWidget *>(parent()));
+        m_viewer->hide();
+        connect(m_viewer->page(), &QWebEnginePage::loadFinished,
+                this, [this]() {
+                    m_webViewStates |= WebViewState::LoadFinished;
+                });
+        connect(adapter, &MarkdownViewerAdapter::workFinished,
+                this, [this]() {
+                    m_webViewStates |= WebViewState::WorkFinished;
+                });
+    }
+
+    const bool scrollable = p_option.m_targetFormat != ExportFormat::PDF;
+    const auto &config = ConfigMgr::getInst().getEditorConfig().getMarkdownEditorConfig();
+    bool useWkhtmltopdf = false;
+    QSize pageBodySize(1024, 768);
+    if (p_option.m_targetFormat == ExportFormat::PDF) {
+        useWkhtmltopdf = p_option.m_pdfOption.m_useWkhtmltopdf;
+        pageBodySize = pageLayoutSize(*(p_option.m_pdfOption.m_layout));
+    }
+    qDebug() << "export page body size" << pageBodySize;
+    m_htmlTemplate = HtmlTemplateHelper::generateMarkdownViewerTemplate(config,
+                                                                        p_option.m_renderingStyleFile,
+                                                                        p_option.m_syntaxHighlightStyleFile,
+                                                                        p_option.m_useTransparentBg,
+                                                                        scrollable,
+                                                                        pageBodySize.width(),
+                                                                        pageBodySize.height(),
+                                                                        useWkhtmltopdf,
+                                                                        useWkhtmltopdf ? 2.5 : -1);
+
+    {
+        const bool addOutlinePanel = p_option.m_targetFormat == ExportFormat::HTML && p_option.m_htmlOption.m_addOutlinePanel;
+        m_exportHtmlTemplate = HtmlTemplateHelper::generateExportTemplate(config, addOutlinePanel);
+    }
+
+    if (useWkhtmltopdf) {
+        prepareWkhtmltopdfArguments(p_option.m_pdfOption);
+    }
+}
+
+static QString marginToStrMM(qreal p_margin)
+{
+    return QString("%1mm").arg(p_margin);
+}
+
+void WebViewExporter::prepareWkhtmltopdfArguments(const ExportPdfOption &p_pdfOption)
+{
+    m_wkhtmltopdfArgs.clear();
+
+    // Page layout.
+    {
+        const auto &layout = p_pdfOption.m_layout;
+        m_wkhtmltopdfArgs << "--page-size" << layout->pageSize().key();
+        m_wkhtmltopdfArgs << "--orientation"
+                 << (layout->orientation() == QPageLayout::Portrait ? "Portrait" : "Landscape");
+
+        const auto marginsMM = layout->margins(QPageLayout::Millimeter);
+        m_wkhtmltopdfArgs << "--margin-bottom" << marginToStrMM(marginsMM.bottom());
+        m_wkhtmltopdfArgs << "--margin-left" << marginToStrMM(marginsMM.left());
+        m_wkhtmltopdfArgs << "--margin-right" << marginToStrMM(marginsMM.right());
+        m_wkhtmltopdfArgs << "--margin-top" << marginToStrMM(marginsMM.top());
+
+        // Footer.
+        m_wkhtmltopdfArgs << "--footer-right" << "[page]"
+                          << "--footer-spacing" << QString::number(marginsMM.bottom() / 3, 'f', 2);
+    }
+
+    m_wkhtmltopdfArgs << "--encoding" << "utf-8";
+
+    // Delay 10 seconds for MathJax.
+    m_wkhtmltopdfArgs << "--javascript-delay" << "5000";
+
+    m_wkhtmltopdfArgs << "--enable-local-file-access";
+
+    // Append additional global option.
+    if (!p_pdfOption.m_wkhtmltopdfArgs.isEmpty()) {
+        m_wkhtmltopdfArgs.append(ProcessUtils::parseCombinedArgString(p_pdfOption.m_wkhtmltopdfArgs));
+    }
+
+    // Must be put after the global object options.
+    if (p_pdfOption.m_addTableOfContents) {
+        m_wkhtmltopdfArgs << "toc" << "--toc-text-size-shrink" << "1.0";
+    }
+}
+
+bool WebViewExporter::embedStyleResources(QString &p_html) const
+{
+    bool altered = false;
+    QRegExp reg("\\burl\\(\"((file|qrc):[^\"\\)]+)\"\\);");
+
+    int pos = 0;
+    while (pos < p_html.size()) {
+        int idx = p_html.indexOf(reg, pos);
+        if (idx == -1) {
+            break;
+        }
+
+        QString dataURI = WebUtils::toDataUri(QUrl(reg.cap(1)), false);
+        if (dataURI.isEmpty()) {
+            pos = idx + reg.matchedLength();
+        } else {
+            // Replace the url string in html.
+            QString newUrl = QString("url('%1');").arg(dataURI);
+            p_html.replace(idx, reg.matchedLength(), newUrl);
+            pos = idx + newUrl.size();
+            altered = true;
+        }
+    }
+
+    return altered;
+}
+
+bool WebViewExporter::embedBodyResources(const QUrl &p_baseUrl, QString &p_html)
+{
+    bool altered = false;
+    if (p_baseUrl.isEmpty()) {
+        return altered;
+    }
+
+    QRegExp reg(c_imgRegExp);
+
+    int pos = 0;
+    while (pos < p_html.size()) {
+        int idx = p_html.indexOf(reg, pos);
+        if (idx == -1) {
+            break;
+        }
+
+        if (reg.cap(2).isEmpty()) {
+            pos = idx + reg.matchedLength();
+            continue;
+        }
+
+        QUrl srcUrl(p_baseUrl.resolved(reg.cap(2)));
+        const auto dataURI = WebUtils::toDataUri(srcUrl, true);
+        if (dataURI.isEmpty()) {
+            pos = idx + reg.matchedLength();
+        } else {
+            // Replace the url string in html.
+            QString newUrl = QString("<img %1src='%2'%3>").arg(reg.cap(1), dataURI, reg.cap(3));
+            p_html.replace(idx, reg.matchedLength(), newUrl);
+            pos = idx + newUrl.size();
+            altered = true;
+        }
+    }
+
+    return altered;
+}
+
+static QString getResourceRelativePath(const QString &p_file)
+{
+    int idx = p_file.lastIndexOf('/');
+    int idx2 = p_file.lastIndexOf('/', idx - 1);
+    Q_ASSERT(idx > 0 && idx2 < idx);
+    return "." + p_file.mid(idx2);
+}
+
+bool WebViewExporter::fixBodyResources(const QUrl &p_baseUrl,
+                                       const QString &p_folder,
+                                       QString &p_html)
+{
+    bool altered = false;
+    if (p_baseUrl.isEmpty()) {
+        return altered;
+    }
+
+    QRegExp reg(c_imgRegExp);
+
+    int pos = 0;
+    while (pos < p_html.size()) {
+        int idx = p_html.indexOf(reg, pos);
+        if (idx == -1) {
+            break;
+        }
+
+        if (reg.cap(2).isEmpty()) {
+            pos = idx + reg.matchedLength();
+            continue;
+        }
+
+        QUrl srcUrl(p_baseUrl.resolved(reg.cap(2)));
+        QString targetFile = WebUtils::copyResource(srcUrl, p_folder);
+        if (targetFile.isEmpty()) {
+            pos = idx + reg.matchedLength();
+        } else {
+            // Replace the url string in html.
+            QString newUrl = QString("<img %1src=\"%2\"%3>").arg(reg.cap(1), getResourceRelativePath(targetFile), reg.cap(3));
+            p_html.replace(idx, reg.matchedLength(), newUrl);
+            pos = idx + newUrl.size();
+            altered = true;
+        }
+    }
+
+    return altered;
+}
+
+bool WebViewExporter::doExportPdf(const ExportPdfOption &p_pdfOption, const QString &p_outputFile)
+{
+    ExportState state = ExportState::Busy;
+
+    m_viewer->page()->printToPdf([&, this](const QByteArray &p_result) {
+        qDebug() << "doExportPdf printToPdf ready";
+        if (p_result.isEmpty() || m_askedToStop) {
+            state = ExportState::Failed;
+            return;
+        }
+
+        Q_ASSERT(!p_outputFile.isEmpty());
+
+        FileUtils::writeFile(p_outputFile, p_result);
+
+        state = ExportState::Finished;
+    }, *p_pdfOption.m_layout);
+
+    while (state == ExportState::Busy) {
+        Utils::sleepWait(100);
+
+        if (m_askedToStop) {
+            break;
+        }
+    }
+
+    return state == ExportState::Finished;
+}
+
+bool WebViewExporter::doExportWkhtmltopdf(const ExportPdfOption &p_pdfOption, const QString &p_outputFile, const QUrl &p_baseUrl)
+{
+    ExportState state = ExportState::Busy;
+
+    connect(m_viewer->adapter(), &MarkdownViewerAdapter::contentReady,
+            this, [&, this](const QString &p_headContent,
+                            const QString &p_styleContent,
+                            const QString &p_content,
+                            const QString &p_bodyClassList) {
+                qDebug() << "doExportWkhtmltopdf contentReady";
+                // Maybe unnecessary. Just to avoid duplicated signal connections.
+                disconnect(m_viewer->adapter(), &MarkdownViewerAdapter::contentReady, this, 0);
+
+                if (p_content.isEmpty() || m_askedToStop) {
+                    state = ExportState::Failed;
+                    return;
+                }
+
+                // Save HTML to a temp dir.
+                QTemporaryDir tmpDir;
+                if (!tmpDir.isValid()) {
+                    state = ExportState::Failed;
+                    return;
+                }
+
+                auto tmpHtmlFile = tmpDir.filePath("vnote_export_tmp.html");
+                if (!writeHtmlFile(tmpHtmlFile,
+                                   p_baseUrl,
+                                   p_headContent,
+                                   p_styleContent,
+                                   p_content,
+                                   p_bodyClassList,
+                                   true,
+                                   true,
+                                   false)) {
+                    state = ExportState::Failed;
+                    return;
+                }
+
+                // Convert HTML to PDF via wkhtmltopdf.
+                if (doWkhtmltopdf(p_pdfOption, QStringList() << tmpHtmlFile, p_outputFile)) {
+                    state = ExportState::Finished;
+                } else {
+                    state = ExportState::Failed;
+                }
+            });
+
+    m_viewer->adapter()->saveContent();
+
+    while (state == ExportState::Busy) {
+        Utils::sleepWait(100);
+
+        if (m_askedToStop) {
+            break;
+        }
+    }
+
+    return state == ExportState::Finished;
+}
+
+bool WebViewExporter::doWkhtmltopdf(const ExportPdfOption &p_pdfOption, const QStringList &p_htmlFiles, const QString &p_outputFile)
+{
+    // Note: system's locale settings (Language for non-Unicode programs) is important to wkhtmltopdf.
+    // Input file could be encoded via QUrl::fromLocalFile(p_htmlFile).toString(QUrl::EncodeUnicode) to
+    // handle non-ASCII path.
+
+    QStringList args(m_wkhtmltopdfArgs);
+
+    // Prepare the args.
+    for (auto const &file : p_htmlFiles) {
+        args << QDir::toNativeSeparators(file);
+    }
+
+    args << QDir::toNativeSeparators(p_outputFile);
+
+    return startProcess(p_pdfOption.m_wkhtmltopdfExePath, args);
+}
+
+bool WebViewExporter::startProcess(const QString &p_program, const QStringList &p_args)
+{
+    emit logRequested(p_program + " " + ProcessUtils::combineArgString(p_args));
+
+    auto ret = ProcessUtils::start(p_program,
+                                   p_args,
+                                   [this](const QString &p_log) {
+                                       emit logRequested(p_log);
+                                   },
+                                   m_askedToStop);
+    return ret == ProcessUtils::State::Succeeded;
+}

+ 110 - 0
src/export/webviewexporter.h

@@ -0,0 +1,110 @@
+#ifndef WEBVIEWEXPORTER_H
+#define WEBVIEWEXPORTER_H
+
+#include <QObject>
+
+#include "exportdata.h"
+
+class QWidget;
+
+namespace vnotex
+{
+    class File;
+    class MarkdownViewer;
+
+    class WebViewExporter : public QObject
+    {
+        Q_OBJECT
+    public:
+        enum WebViewState
+        {
+            Started = 0,
+            LoadFinished = 0x1,
+            WorkFinished = 0x2,
+            Failed = 0x4
+        };
+        Q_DECLARE_FLAGS(WebViewStates, WebViewState);
+
+        // We need QWidget as parent.
+        explicit WebViewExporter(QWidget *p_parent);
+
+        ~WebViewExporter();
+
+        bool doExport(const ExportOption &p_option,
+                      const File *p_file,
+                      const QString &p_outputFile);
+
+        void prepare(const ExportOption &p_option);
+
+        // Release resources after one batch of export.
+        void clear();
+
+        void stop();
+
+    signals:
+        void logRequested(const QString &p_log);
+
+    private:
+        enum class ExportState
+        {
+            Busy = 0,
+            Finished,
+            Failed
+        };
+
+        bool isWebViewReady() const;
+
+        bool isWebViewFailed() const;
+
+        bool doExportHtml(const ExportHtmlOption &p_htmlOption,
+                          const QString &p_outputFile,
+                          const QUrl &p_baseUrl);
+
+        bool writeHtmlFile(const QString &p_file,
+                           const QUrl &p_baseUrl,
+                           const QString &p_headContent,
+                           QString p_styleContent,
+                           const QString &p_content,
+                           const QString &p_bodyClassList,
+                           bool p_embedStyles,
+                           bool p_completePage,
+                           bool p_embedImages);
+
+        bool embedStyleResources(QString &p_html) const;
+
+        bool embedBodyResources(const QUrl &p_baseUrl, QString &p_html);
+
+        bool fixBodyResources(const QUrl &p_baseUrl, const QString &p_folder, QString &p_html);
+
+        bool doExportPdf(const ExportPdfOption &p_pdfOption, const QString &p_outputFile);
+
+        bool doExportWkhtmltopdf(const ExportPdfOption &p_pdfOption, const QString &p_outputFile, const QUrl &p_baseUrl);
+
+        QSize pageLayoutSize(const QPageLayout &p_layout) const;
+
+        bool doWkhtmltopdf(const ExportPdfOption &p_pdfOption, const QStringList &p_htmlFiles, const QString &p_outputFile);
+
+        void prepareWkhtmltopdfArguments(const ExportPdfOption &p_pdfOption);
+
+        bool startProcess(const QString &p_program, const QStringList &p_args);
+
+        bool m_askedToStop = false;
+
+        bool m_exportOngoing = false;
+
+        WebViewStates m_webViewStates = WebViewState::Started;
+
+        // Managed by QObject.
+        MarkdownViewer *m_viewer = nullptr;
+
+        QString m_htmlTemplate;
+
+        QString m_exportHtmlTemplate;
+
+        QStringList m_wkhtmltopdfArgs;
+    };
+}
+
+Q_DECLARE_OPERATORS_FOR_FLAGS(vnotex::WebViewExporter::WebViewStates)
+
+#endif // WEBVIEWEXPORTER_H

+ 2 - 0
src/src.pro

@@ -42,6 +42,8 @@ include($$LIBS_FOLDER/vtextedit/src/libs/syntax-highlighting/syntax-highlighting
 
 include($$PWD/utils/utils.pri)
 
+include($$PWD/export/export.pri)
+
 include($$PWD/core/core.pri)
 
 include($$PWD/widgets/widgets.pri)

+ 68 - 55
src/core/notebookconfigmgr/nodecontentmediautils.cpp → src/utils/contentmediautils.cpp

@@ -1,4 +1,4 @@
-#include "nodecontentmediautils.h"
+#include "contentmediautils.h"
 
 #include <QDebug>
 #include <QSet>
@@ -15,46 +15,53 @@
 
 #include <utils/pathutils.h>
 #include <utils/fileutils.h>
+#include <core/file.h>
 
 using namespace vnotex;
 
-void NodeContentMediaUtils::copyMediaFiles(const Node *p_node,
-                                           INotebookBackend *p_backend,
-                                           const QString &p_destFilePath)
+void ContentMediaUtils::copyMediaFiles(Node *p_node,
+                                       INotebookBackend *p_backend,
+                                       const QString &p_destFilePath)
 {
     Q_ASSERT(p_node->hasContent());
-    /*
-    const auto &fileType = FileTypeHelper::getInst().getFileType(p_node->fetchAbsolutePath());
-    if (fileType.m_type == FileTypeHelper::Markdown) {
-        copyMarkdownMediaFiles(p_node->read(),
-                               PathUtils::parentDirPath(p_node->fetchContentPath()),
+    auto file = p_node->getContentFile();
+    if (file->getContentType().isMarkdown()) {
+        copyMarkdownMediaFiles(file->read(),
+                               PathUtils::parentDirPath(file->getContentPath()),
                                p_backend,
                                p_destFilePath);
     }
-    */
 }
 
-void NodeContentMediaUtils::copyMediaFiles(const QString &p_filePath,
-                                           INotebookBackend *p_backend,
-                                           const QString &p_destFilePath)
+void ContentMediaUtils::copyMediaFiles(const QString &p_filePath,
+                                       INotebookBackend *p_backend,
+                                       const QString &p_destFilePath)
 {
-    /*
     const auto &fileType = FileTypeHelper::getInst().getFileType(p_filePath);
-    if (fileType.m_type == FileTypeHelper::Markdown) {
+    if (fileType.isMarkdown()) {
         copyMarkdownMediaFiles(FileUtils::readTextFile(p_filePath),
                                PathUtils::parentDirPath(p_filePath),
                                p_backend,
                                p_destFilePath);
     }
-    */
 }
 
-void NodeContentMediaUtils::copyMarkdownMediaFiles(const QString &p_content,
-                                                   const QString &p_basePath,
-                                                   INotebookBackend *p_backend,
-                                                   const QString &p_destFilePath)
+void ContentMediaUtils::copyMediaFiles(const File *p_file,
+                                       const QString &p_destFilePath)
+{
+    if (p_file->getContentType().isMarkdown()) {
+        copyMarkdownMediaFiles(p_file->read(),
+                               p_file->getResourcePath(),
+                               nullptr,
+                               p_destFilePath);
+    }
+}
+
+void ContentMediaUtils::copyMarkdownMediaFiles(const QString &p_content,
+                                               const QString &p_basePath,
+                                               INotebookBackend *p_backend,
+                                               const QString &p_destFilePath)
 {
-    /*
     auto content = p_content;
 
     // Images.
@@ -82,19 +89,20 @@ void NodeContentMediaUtils::copyMarkdownMediaFiles(const QString &p_content,
         handledImages.insert(link.m_path);
 
         if (!QFileInfo::exists(link.m_path)) {
-            qWarning() << "Image of Markdown file does not exist" << link.m_path << link.m_urlInLink;
+            qWarning() << "image of Markdown file does not exist" << link.m_path << link.m_urlInLink;
             continue;
         }
 
         // Get the relative path of the image and apply it to the dest file path.
         const auto oldDestFilePath = destDir.filePath(link.m_urlInLink);
         destDir.mkpath(PathUtils::parentDirPath(oldDestFilePath));
-        auto destFilePath = p_backend->renameIfExistsCaseInsensitive(oldDestFilePath);
+        auto destFilePath = p_backend ? p_backend->renameIfExistsCaseInsensitive(oldDestFilePath)
+                                      : FileUtils::renameIfExistsCaseInsensitive(oldDestFilePath);
         if (oldDestFilePath != destFilePath) {
             // Rename happens.
             const auto oldFileName = PathUtils::fileName(oldDestFilePath);
             const auto newFileName = PathUtils::fileName(destFilePath);
-            qWarning() << QString("Image name conflicts when copy. Renamed from (%1) to (%2)").arg(oldFileName, newFileName);
+            qWarning() << QString("image name conflicts when copy. Renamed from (%1) to (%2)").arg(oldFileName, newFileName);
 
             // Update the text content.
             auto newUrlInLink(link.m_urlInLink);
@@ -106,38 +114,41 @@ void NodeContentMediaUtils::copyMarkdownMediaFiles(const QString &p_content,
             renamedImages.insert(link.m_path, newUrlInLink);
         }
 
-        p_backend->copyFile(link.m_path, destFilePath);
+        if (p_backend) {
+            p_backend->copyFile(link.m_path, destFilePath);
+        } else {
+            FileUtils::copyFile(link.m_path, destFilePath);
+        }
     }
 
     if (!renamedImages.isEmpty()) {
-        p_backend->writeFile(p_destFilePath, content);
+        if (p_backend) {
+            p_backend->writeFile(p_destFilePath, content);
+        } else {
+            FileUtils::writeFile(p_destFilePath, content);
+        }
     }
-    */
 }
 
-void NodeContentMediaUtils::removeMediaFiles(const Node *p_node)
+void ContentMediaUtils::removeMediaFiles(Node *p_node)
 {
-    /*
-    Q_ASSERT(p_node->getType() == Node::Type::File);
-    const auto &fileType = FileTypeHelper::getInst().getFileType(p_node->fetchAbsolutePath());
-    if (fileType.m_type == FileTypeHelper::Markdown) {
-        removeMarkdownMediaFiles(p_node);
+    Q_ASSERT(p_node->hasContent());
+    auto file = p_node->getContentFile();
+    if (file->getContentType().isMarkdown()) {
+        removeMarkdownMediaFiles(file.data(), p_node->getBackend());
     }
-    */
 }
 
-void NodeContentMediaUtils::removeMarkdownMediaFiles(const Node *p_node)
+void ContentMediaUtils::removeMarkdownMediaFiles(const File *p_file, INotebookBackend *p_backend)
 {
-    /*
-    auto content = p_node->read();
+    auto content = p_file->read();
 
     // Images.
     const auto images =
         vte::MarkdownUtils::fetchImagesFromMarkdownText(content,
-                                                        PathUtils::parentDirPath(p_node->fetchContentPath()),
+                                                        p_file->getResourcePath(),
                                                         vte::MarkdownLink::TypeFlag::LocalRelativeInternal);
 
-    auto backend = p_node->getBackend();
     QSet<QString> handledImages;
     for (const auto &link : images) {
         if (handledImages.contains(link.m_path)) {
@@ -150,40 +161,42 @@ void NodeContentMediaUtils::removeMarkdownMediaFiles(const Node *p_node)
             qWarning() << "Image of Markdown file does not exist" << link.m_path << link.m_urlInLink;
             continue;
         }
-        backend->removeFile(link.m_path);
+        p_backend->removeFile(link.m_path);
     }
-    */
 }
 
-void NodeContentMediaUtils::copyAttachment(Node *p_node,
-                                           INotebookBackend *p_backend,
-                                           const QString &p_destFilePath,
-                                           const QString &p_destAttachmentFolderPath)
+void ContentMediaUtils::copyAttachment(Node *p_node,
+                                       INotebookBackend *p_backend,
+                                       const QString &p_destFilePath,
+                                       const QString &p_destAttachmentFolderPath)
 {
-    /*
-    Q_ASSERT(p_node->getType() == Node::Type::File);
+    Q_ASSERT(p_node->hasContent());
     Q_ASSERT(!p_node->getAttachmentFolder().isEmpty());
 
     // Copy the whole folder.
     const auto srcAttachmentFolderPath = p_node->fetchAttachmentFolderPath();
-    p_backend->copyDir(srcAttachmentFolderPath, p_destAttachmentFolderPath);
+    if (p_backend) {
+        p_backend->copyDir(srcAttachmentFolderPath, p_destAttachmentFolderPath);
+    } else {
+        FileUtils::copyDir(srcAttachmentFolderPath, p_destAttachmentFolderPath);
+    }
 
     // Check if we need to modify links in content.
+    // FIXME: check the whole relative path.
     if (p_node->getAttachmentFolder() == PathUtils::dirName(p_destAttachmentFolderPath)) {
         return;
     }
 
-    const auto &fileType = FileTypeHelper::getInst().getFileType(p_node->fetchAbsolutePath());
-    if (fileType.m_type == FileTypeHelper::Markdown) {
+    auto file = p_node->getContentFile();
+    if (file->getContentType().isMarkdown()) {
         fixMarkdownLinks(srcAttachmentFolderPath, p_backend, p_destFilePath, p_destAttachmentFolderPath);
     }
-    */
 }
 
-void NodeContentMediaUtils::fixMarkdownLinks(const QString &p_srcFolderPath,
-                                             INotebookBackend *p_backend,
-                                             const QString &p_destFilePath,
-                                             const QString &p_destFolderPath)
+void ContentMediaUtils::fixMarkdownLinks(const QString &p_srcFolderPath,
+                                         INotebookBackend *p_backend,
+                                         const QString &p_destFilePath,
+                                         const QString &p_destFolderPath)
 {
     // TODO.
     Q_UNUSED(p_srcFolderPath);

+ 12 - 8
src/core/notebookconfigmgr/nodecontentmediautils.h → src/utils/contentmediautils.h

@@ -1,5 +1,5 @@
-#ifndef NODECONTENTMEDIAUTILS_H
-#define NODECONTENTMEDIAUTILS_H
+#ifndef CONTENTMEDIAUTILS_H
+#define CONTENTMEDIAUTILS_H
 
 #include <QString>
 
@@ -7,16 +7,17 @@ namespace vnotex
 {
     class INotebookBackend;
     class Node;
+    class File;
 
     // Utils to operate on the media files from node's content.
-    class NodeContentMediaUtils
+    class ContentMediaUtils
     {
     public:
-        NodeContentMediaUtils() = delete;
+        ContentMediaUtils() = delete;
 
         // Fetch media files from @p_node and copy them to dest folder.
         // @p_destFilePath: @p_node has been copied to @p_destFilePath.
-        static void copyMediaFiles(const Node *p_node,
+        static void copyMediaFiles(Node *p_node,
                                    INotebookBackend *p_backend,
                                    const QString &p_destFilePath);
 
@@ -25,7 +26,10 @@ namespace vnotex
                                    INotebookBackend *p_backend,
                                    const QString &p_destFilePath);
 
-        static void removeMediaFiles(const Node *p_node);
+        static void copyMediaFiles(const File *p_file,
+                                   const QString &p_destFilePath);
+
+        static void removeMediaFiles(Node *p_node);
 
         // Copy attachment folder.
         static void copyAttachment(Node *p_node,
@@ -39,7 +43,7 @@ namespace vnotex
                                            INotebookBackend *p_backend,
                                            const QString &p_destFilePath);
 
-        static void removeMarkdownMediaFiles(const Node *p_node);
+        static void removeMarkdownMediaFiles(const File *p_file, INotebookBackend *p_backend);
 
         // Fix local relative internal links locating in @p_srcFolderPath.
         static void fixMarkdownLinks(const QString &p_srcFolderPath,
@@ -49,4 +53,4 @@ namespace vnotex
     };
 }
 
-#endif // NODECONTENTMEDIAUTILS_H
+#endif // CONTENTMEDIAUTILS_H

+ 1 - 1
src/utils/fileutils.h

@@ -63,7 +63,7 @@ namespace vnotex
 
         static QString generateFileNameWithSequence(const QString &p_folderPath,
                                                     const QString &p_baseName,
-                                                    const QString &p_suffix);
+                                                    const QString &p_suffix = QString());
 
         static QTemporaryFile *createTemporaryFile(const QString &p_suffix);
 

+ 6 - 0
src/utils/htmlutils.cpp

@@ -10,3 +10,9 @@ bool HtmlUtils::hasOnlyImgTag(const QString &p_html)
     QRegExp reg(QStringLiteral("<(?:p|span|div) "));
     return !p_html.contains(reg);
 }
+
+QString HtmlUtils::escapeHtml(QString p_text)
+{
+    p_text.replace(">", "&gt;").replace("<", "&lt;").replace("&", "&amp;");
+    return p_text;
+}

+ 2 - 0
src/utils/htmlutils.h

@@ -11,6 +11,8 @@ namespace vnotex
         HtmlUtils() = delete;
 
         static bool hasOnlyImgTag(const QString &p_html);
+
+        static QString escapeHtml(QString p_text);
     };
 }
 

+ 158 - 0
src/utils/processutils.cpp

@@ -0,0 +1,158 @@
+#include "processutils.h"
+
+#include <QProcess>
+#include <QScopedPointer>
+#include <QDebug>
+
+#include "utils.h"
+
+using namespace vnotex;
+
+ProcessUtils::State ProcessUtils::start(const QString &p_program,
+                                        const QStringList &p_args,
+                                        const QByteArray &p_stdIn,
+                                        int &p_exitCodeOnSuccess,
+                                        QByteArray &p_stdOut,
+                                        QByteArray &p_stdErr)
+{
+    QScopedPointer<QProcess> proc(new QProcess());
+    proc->start(p_program, p_args);
+    return handleProcess(proc.data(), p_stdIn, p_exitCodeOnSuccess, p_stdOut, p_stdErr);
+}
+
+ProcessUtils::State ProcessUtils::handleProcess(QProcess *p_process,
+                                                const QByteArray &p_stdIn,
+                                                int &p_exitCodeOnSuccess,
+                                                QByteArray &p_stdOut,
+                                                QByteArray &p_stdErr)
+{
+    if (!p_process->waitForStarted()) {
+        return State::FailedToStart;
+    }
+
+    if (!p_stdIn.isEmpty()) {
+        if (p_process->write(p_stdIn) == -1) {
+            p_process->closeWriteChannel();
+            qWarning() << "failed to write to stdin of QProcess" << p_process->errorString();
+            return State::FailedToWrite;
+        } else {
+            p_process->closeWriteChannel();
+        }
+    }
+
+    p_process->waitForFinished();
+
+    State state = State::Succeeded;
+    if (p_process->exitStatus() == QProcess::CrashExit) {
+        state = State::Crashed;
+    } else {
+        p_exitCodeOnSuccess = p_process->exitCode();
+    }
+
+    p_stdOut = p_process->readAllStandardOutput();
+    p_stdErr = p_process->readAllStandardError();
+    return state;
+}
+
+QStringList ProcessUtils::parseCombinedArgString(const QString &p_args)
+{
+    QStringList args;
+    QString tmp;
+    int quoteCount = 0;
+    bool inQuote = false;
+
+    // Handle quoting.
+    // Tokens can be surrounded by double quotes "hello world".
+    // Three consecutive double quotes represent the quote character itself.
+    for (int i = 0; i < p_args.size(); ++i) {
+        if (p_args.at(i) == QLatin1Char('"')) {
+            ++quoteCount;
+            if (quoteCount == 3) {
+                // Third consecutive quote.
+                quoteCount = 0;
+                tmp += p_args.at(i);
+            }
+
+            continue;
+        }
+
+        if (quoteCount) {
+            if (quoteCount == 1) {
+                inQuote = !inQuote;
+            }
+
+            quoteCount = 0;
+        }
+
+        if (!inQuote && p_args.at(i).isSpace()) {
+            if (!tmp.isEmpty()) {
+                args += tmp;
+                tmp.clear();
+            }
+        } else {
+            tmp += p_args.at(i);
+        }
+    }
+
+    if (!tmp.isEmpty()) {
+        args += tmp;
+    }
+
+    return args;
+}
+
+QString ProcessUtils::combineArgString(const QStringList &p_args)
+{
+    QString argStr;
+    for (const auto &arg : p_args) {
+        QString tmp(arg);
+        tmp.replace("\"", "\"\"\"");
+        if (tmp.contains(' ')) {
+            tmp = '"' + tmp + '"';
+        }
+
+        if (argStr.isEmpty()) {
+            argStr = tmp;
+        } else {
+            argStr = argStr + ' ' + tmp;
+        }
+    }
+
+    return argStr;
+}
+
+ProcessUtils::State ProcessUtils::start(const QString &p_program,
+                                        const QStringList &p_args,
+                                        const std::function<void(const QString &)> &p_logger,
+                                        const bool &p_askedToStop)
+{
+    QProcess proc;
+    proc.start(p_program, p_args);
+
+    if (!proc.waitForStarted()) {
+        return State::FailedToStart;
+    }
+
+    while (proc.state() != QProcess::NotRunning) {
+        Utils::sleepWait(100);
+
+        auto outBa = proc.readAllStandardOutput();
+        auto errBa = proc.readAllStandardError();
+        QString msg;
+        if (!outBa.isEmpty()) {
+            msg += QString::fromLocal8Bit(outBa);
+        }
+        if (!errBa.isEmpty()) {
+            msg += QString::fromLocal8Bit(errBa);
+        }
+        if (!msg.isEmpty()) {
+            p_logger(msg);
+        }
+
+        if (p_askedToStop) {
+            break;
+        }
+    }
+
+    return proc.exitStatus() == QProcess::NormalExit ? State::Succeeded : State::Crashed;
+}

+ 52 - 0
src/utils/processutils.h

@@ -0,0 +1,52 @@
+#ifndef PROCESSUTILS_H
+#define PROCESSUTILS_H
+
+#include <functional>
+
+#include <QStringList>
+#include <QByteArray>
+
+class QProcess;
+
+namespace vnotex
+{
+    class ProcessUtils
+    {
+    public:
+        enum State
+        {
+            Succeeded,
+            Crashed,
+            FailedToStart,
+            FailedToWrite
+        };
+
+        ProcessUtils() = delete;
+
+        static State start(const QString &p_program,
+                           const QStringList &p_args,
+                           const QByteArray &p_stdIn,
+                           int &p_exitCodeOnSuccess,
+                           QByteArray &p_stdOut,
+                           QByteArray &p_stdErr);
+
+        static State start(const QString &p_program,
+                           const QStringList &p_args,
+                           const std::function<void(const QString &)> &p_logger,
+                           const bool &p_askedToStop);
+
+        // Copied from QProcess code.
+        static QStringList parseCombinedArgString(const QString &p_args);
+
+        static QString combineArgString(const QStringList &p_args);
+
+    private:
+        static State handleProcess(QProcess *p_process,
+                                   const QByteArray &p_stdIn,
+                                   int &p_exitCodeOnSuccess,
+                                   QByteArray &p_stdOut,
+                                   QByteArray &p_stdErr);
+    };
+}
+
+#endif // PROCESSUTILS_H

+ 0 - 10
src/utils/textutils.cpp

@@ -75,13 +75,3 @@ QString TextUtils::unindentTextMultiLines(const QString &p_text)
 
     return res;
 }
-
-QString TextUtils::purifyUrl(const QString &p_url)
-{
-    int idx = p_url.indexOf('?');
-    if (idx > -1) {
-        return p_url.left(idx);
-    }
-
-    return p_url;
-}

+ 0 - 3
src/utils/textutils.h

@@ -20,9 +20,6 @@ namespace vnotex
 
         // Unindent multi-lines text according to the indentation of the first line.
         static QString unindentTextMultiLines(const QString &p_text);
-
-        // Remove query in the url (?xxx).
-        static QString purifyUrl(const QString &p_url);
     };
 }
 

+ 6 - 0
src/utils/utils.pri

@@ -1,25 +1,31 @@
 QT += widgets svg
 
 SOURCES += \
+    $$PWD/contentmediautils.cpp \
     $$PWD/docsutils.cpp \
     $$PWD/htmlutils.cpp \
     $$PWD/pathutils.cpp \
+    $$PWD/processutils.cpp \
     $$PWD/textutils.cpp \
     $$PWD/urldragdroputils.cpp \
     $$PWD/utils.cpp \
     $$PWD/fileutils.cpp \
     $$PWD/iconutils.cpp \
+    $$PWD/webutils.cpp \
     $$PWD/widgetutils.cpp \
     $$PWD/clipboardutils.cpp
 
 HEADERS += \
+    $$PWD/contentmediautils.h \
     $$PWD/docsutils.h \
     $$PWD/htmlutils.h \
     $$PWD/pathutils.h \
+    $$PWD/processutils.h \
     $$PWD/textutils.h \
     $$PWD/urldragdroputils.h \
     $$PWD/utils.h \
     $$PWD/fileutils.h \
     $$PWD/iconutils.h \
+    $$PWD/webutils.h \
     $$PWD/widgetutils.h \
     $$PWD/clipboardutils.h

+ 103 - 0
src/utils/webutils.cpp

@@ -0,0 +1,103 @@
+#include "webutils.h"
+
+#include <QUrl>
+#include <QFileInfo>
+#include <QImageReader>
+
+#include "fileutils.h"
+#include "pathutils.h"
+#include <vtextedit/networkutils.h>
+#include <core/exception.h>
+
+using namespace vnotex;
+
+QString WebUtils::purifyUrl(const QString &p_url)
+{
+    int idx = p_url.indexOf('?');
+    if (idx > -1) {
+        return p_url.left(idx);
+    }
+
+    return p_url;
+}
+
+QString WebUtils::toDataUri(const QUrl &p_url, bool p_keepTitle)
+{
+    QString uri;
+    Q_ASSERT(!p_url.isRelative());
+    QString file = p_url.isLocalFile() ? p_url.toLocalFile() : p_url.toString();
+    const auto filePath = purifyUrl(file);
+    const QFileInfo finfo(filePath);
+    const QString suffix(finfo.suffix().toLower());
+    if (!QImageReader::supportedImageFormats().contains(suffix.toLatin1())) {
+        return uri;
+    }
+
+    QByteArray data;
+    if (p_url.scheme() == "https" || p_url.scheme() == "http") {
+        // Download it.
+        data = vte::Downloader::download(p_url);
+    } else if (finfo.exists()) {
+        data = FileUtils::readFile(filePath);
+    }
+
+    if (data.isEmpty()) {
+        return uri;
+    }
+
+    if (suffix == "svg") {
+        uri = QString("data:image/svg+xml;utf8,%1").arg(QString::fromUtf8(data));
+        uri.replace('\r', "").replace('\n', "");
+
+        // Using unescaped '#' characters in a data URI body is deprecated and
+        // will be removed in M68, around July 2018. Please use '%23' instead.
+        uri.replace("#", "%23");
+
+        // Escape "'" to avoid conflict with src='...' attribute.
+        uri.replace("'", "%27");
+
+        if (!p_keepTitle) {
+            // Remove <title>...</title>.
+            QRegExp reg("<title>.*</title>", Qt::CaseInsensitive);
+            uri.remove(reg);
+        }
+    } else {
+        uri = QString("data:image/%1;base64,%2").arg(suffix, QString::fromUtf8(data.toBase64()));
+    }
+
+    return uri;
+}
+
+QString WebUtils::copyResource(const QUrl &p_url, const QString &p_folder)
+{
+    Q_ASSERT(!p_url.isRelative());
+
+    QDir dir(p_folder);
+    if (!dir.exists()) {
+        dir.mkpath(p_folder);
+    }
+
+    QString file = p_url.isLocalFile() ? p_url.toLocalFile() : p_url.toString();
+    QFileInfo finfo(file);
+    auto fileName = FileUtils::generateFileNameWithSequence(p_folder, finfo.completeBaseName(), finfo.suffix());
+    QString targetFile = dir.absoluteFilePath(fileName);
+
+    bool succ = true;
+    try {
+        if (p_url.scheme() == "https" || p_url.scheme() == "http") {
+            // Download it.
+            auto data = vte::Downloader::download(p_url);
+            if (!data.isEmpty()) {
+                FileUtils::writeFile(targetFile, data);
+            }
+        } else if (finfo.exists()) {
+            // Do a copy.
+            FileUtils::copyFile(file, targetFile, false);
+        }
+    } catch (Exception &p_e) {
+        Q_UNUSED(p_e);
+        succ = false;
+    }
+
+    return succ ? targetFile : QString();
+}

+ 24 - 0
src/utils/webutils.h

@@ -0,0 +1,24 @@
+#ifndef WEBUTILS_H
+#define WEBUTILS_H
+
+#include <QString>
+
+class QUrl;
+
+namespace vnotex
+{
+    class WebUtils
+    {
+    public:
+        WebUtils() = delete;
+
+        // Remove query in the url (?xxx).
+        static QString purifyUrl(const QString &p_url);
+
+        static QString toDataUri(const QUrl &p_url, bool p_keepTitle);
+
+        static QString copyResource(const QUrl &p_url, const QString &p_folder);
+    };
+}
+
+#endif // WEBUTILS_H

+ 0 - 13
src/utils/widgetutils.cpp

@@ -21,7 +21,6 @@
 #include <QFontDatabase>
 #include <QMenu>
 #include <QDebug>
-#include <QFormLayout>
 #include <QLineEdit>
 
 using namespace vnotex;
@@ -356,18 +355,6 @@ void WidgetUtils::insertActionAfter(QMenu *p_menu, QAction *p_after, QAction *p_
     }
 }
 
-QFormLayout *WidgetUtils::createFormLayout(QWidget *p_parent)
-{
-    auto layout = new QFormLayout(p_parent);
-
-#if defined(Q_OS_MACOS)
-    layout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
-    layout->setFormAlignment(Qt::AlignLeft | Qt::AlignTop);
-#endif
-
-    return layout;
-}
-
 void WidgetUtils::selectBaseName(QLineEdit *p_lineEdit)
 {
     auto text = p_lineEdit->text();

+ 0 - 3
src/utils/widgetutils.h

@@ -17,7 +17,6 @@ class QScrollArea;
 class QListView;
 class QMenu;
 class QShortcut;
-class QFormLayout;
 class QLineEdit;
 
 namespace vnotex
@@ -79,8 +78,6 @@ namespace vnotex
 
         static void insertActionAfter(QMenu *p_menu, QAction *p_after, QAction *p_action);
 
-        static QFormLayout *createFormLayout(QWidget *p_parent = nullptr);
-
         // Select the base name part of the line edit content.
         static void selectBaseName(QLineEdit *p_lineEdit);
 

+ 34 - 4
src/widgets/dialogs/dialog.cpp

@@ -12,6 +12,7 @@
 
 #include <utils/widgetutils.h>
 #include "../propertydefs.h"
+#include "../widgetsfactory.h"
 
 using namespace vnotex;
 
@@ -29,6 +30,11 @@ void Dialog::setCentralWidget(QWidget *p_widget)
     m_layout->addWidget(m_centralWidget);
 }
 
+void Dialog::addBottomWidget(QWidget *p_widget)
+{
+    m_layout->insertWidget(m_layout->indexOf(m_centralWidget) + 1, p_widget);
+}
+
 void Dialog::setDialogButtonBox(QDialogButtonBox::StandardButtons p_buttons,
                                 QDialogButtonBox::StandardButton p_defaultButton)
 {
@@ -38,7 +44,8 @@ void Dialog::setDialogButtonBox(QDialogButtonBox::StandardButtons p_buttons,
         m_dialogButtonBox = new QDialogButtonBox(p_buttons, this);
         connect(m_dialogButtonBox, &QDialogButtonBox::accepted,
                 this, &Dialog::acceptedButtonClicked);
-        connect(m_dialogButtonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
+        connect(m_dialogButtonBox, &QDialogButtonBox::rejected,
+                this, &Dialog::rejectedButtonClicked);
         connect(m_dialogButtonBox, &QDialogButtonBox::clicked,
                 this, [this](QAbstractButton *p_button) {
                     switch (m_dialogButtonBox->buttonRole(p_button)) {
@@ -80,13 +87,13 @@ void Dialog::setInformationText(const QString &p_text, InformationLevel p_level)
             return;
         }
 
-        m_infoTextEdit = new QPlainTextEdit(this);
-        m_infoTextEdit->setReadOnly(true);
+        m_infoTextEdit = WidgetsFactory::createPlainTextConsole(this);
         m_infoTextEdit->setMaximumHeight(m_infoTextEdit->minimumSizeHint().height());
-        m_layout->insertWidget(1, m_infoTextEdit);
+        m_layout->insertWidget(m_layout->count() - 1, m_infoTextEdit);
     }
 
     m_infoTextEdit->setPlainText(p_text);
+    m_infoTextEdit->ensureCursorVisible();
 
     const bool visible = !p_text.isEmpty();
     const bool needResize = visible != m_infoTextEdit->isVisible();
@@ -114,11 +121,34 @@ void Dialog::setInformationText(const QString &p_text, InformationLevel p_level)
     }
 }
 
+void Dialog::appendInformationText(const QString &p_text)
+{
+    if (!m_infoTextEdit) {
+        setInformationText(p_text);
+    } else {
+        m_infoTextEdit->appendPlainText(p_text);
+        m_infoTextEdit->moveCursor(QTextCursor::End);
+        m_infoTextEdit->ensureCursorVisible();
+    }
+}
+
+void Dialog::clearInformationText()
+{
+    if (m_infoTextEdit) {
+        m_infoTextEdit->clear();
+    }
+}
+
 void Dialog::acceptedButtonClicked()
 {
     QDialog::accept();
 }
 
+void Dialog::rejectedButtonClicked()
+{
+    QDialog::reject();
+}
+
 void Dialog::resetButtonClicked()
 {
 }

+ 11 - 2
src/widgets/dialogs/dialog.h

@@ -15,8 +15,6 @@ namespace vnotex
     public:
         explicit Dialog(QWidget *p_parent = nullptr, Qt::WindowFlags p_flags = Qt::WindowFlags());
 
-        virtual void setCentralWidget(QWidget *p_widget);
-
         void setDialogButtonBox(QDialogButtonBox::StandardButtons p_buttons,
                                 QDialogButtonBox::StandardButton p_defaultButton = QDialogButtonBox::NoButton);
 
@@ -31,6 +29,10 @@ namespace vnotex
 
         void setInformationText(const QString &p_text, InformationLevel p_level = InformationLevel::Info);
 
+        void appendInformationText(const QString &p_text);
+
+        void clearInformationText();
+
         void setButtonEnabled(QDialogButtonBox::StandardButton p_button, bool p_enabled);
 
         // Dialog has completed but just stay the GUI to let user know information.
@@ -41,10 +43,17 @@ namespace vnotex
     protected:
         virtual void acceptedButtonClicked();
 
+        virtual void rejectedButtonClicked();
+
         virtual void resetButtonClicked();
 
         virtual void appliedButtonClicked();
 
+        virtual void setCentralWidget(QWidget *p_widget);
+
+        // Add @p_widget below the central widget.
+        virtual void addBottomWidget(QWidget *p_widget);
+
         QBoxLayout *m_layout = nullptr;
 
         QWidget *m_centralWidget = nullptr;

+ 679 - 0
src/widgets/dialogs/exportdialog.cpp

@@ -0,0 +1,679 @@
+#include "exportdialog.h"
+
+#include <QGroupBox>
+#include <QFormLayout>
+#include <QVBoxLayout>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QComboBox>
+#include <QPushButton>
+#include <QCheckBox>
+#include <QLineEdit>
+#include <QProgressBar>
+#include <QFileInfo>
+#include <QFileDialog>
+#include <QUrl>
+#include <QPlainTextEdit>
+#include <QCoreApplication>
+#include <QPrinter>
+#include <QPageSetupDialog>
+#include <QPageLayout>
+
+#include <notebook/notebook.h>
+#include <notebook/node.h>
+#include <buffer/buffer.h>
+#include <widgets/widgetsfactory.h>
+#include <core/thememgr.h>
+#include <core/configmgr.h>
+#include <core/sessionconfig.h>
+#include <core/vnotex.h>
+#include <utils/widgetutils.h>
+#include <utils/fileutils.h>
+#include <utils/pathutils.h>
+#include <utils/clipboardutils.h>
+#include <export/exporter.h>
+
+using namespace vnotex;
+
+ExportDialog::ExportDialog(Notebook *p_notebook,
+                           Node *p_folder,
+                           Buffer *p_buffer,
+                           QWidget *p_parent)
+    : ScrollDialog(p_parent),
+      m_notebook(p_notebook),
+      m_folder(p_folder),
+      m_buffer(p_buffer)
+{
+    setupUI();
+
+    initOptions();
+
+    restoreFields(m_option);
+
+    connect(this, &QDialog::finished,
+            this, [this]() {
+                saveFields(m_option);
+                ConfigMgr::getInst().getSessionConfig().setExportOption(m_option);
+            });
+}
+
+void ExportDialog::setupUI()
+{
+    auto widget = new QWidget(this);
+    setCentralWidget(widget);
+
+    auto mainLayout = new QVBoxLayout(widget);
+
+    auto sourceBox = setupSourceGroup(widget);
+    mainLayout->addWidget(sourceBox);
+
+    auto targetBox = setupTargetGroup(widget);
+    mainLayout->addWidget(targetBox);
+
+    m_advancedGroupBox = setupAdvancedGroup(widget);
+    mainLayout->addWidget(m_advancedGroupBox);
+
+    m_progressBar = new QProgressBar(widget);
+    m_progressBar->setRange(0, 0);
+    m_progressBar->hide();
+    addBottomWidget(m_progressBar);
+
+    setupButtonBox();
+
+    setWindowTitle(tr("Export"));
+}
+
+QGroupBox *ExportDialog::setupSourceGroup(QWidget *p_parent)
+{
+    auto box = new QGroupBox(tr("Source"), p_parent);
+    auto layout = WidgetsFactory::createFormLayout(box);
+
+    {
+        m_sourceComboBox = WidgetsFactory::createComboBox(box);
+        if (m_buffer) {
+            m_sourceComboBox->addItem(tr("Current Buffer (%1)").arg(m_buffer->getName()),
+                                      static_cast<int>(ExportSource::CurrentBuffer));
+        }
+        if (m_folder && m_folder->isContainer()) {
+            m_sourceComboBox->addItem(tr("Current Folder (%1)").arg(m_folder->getName()),
+                                      static_cast<int>(ExportSource::CurrentFolder));
+        }
+        if (m_notebook) {
+            m_sourceComboBox->addItem(tr("Current Notebook (%1)").arg(m_notebook->getName()),
+                                      static_cast<int>(ExportSource::CurrentNotebook));
+        }
+        layout->addRow(tr("Source:"), m_sourceComboBox);
+    }
+
+    {
+        // TODO: Source format filtering.
+    }
+
+    return box;
+}
+
+QString ExportDialog::getDefaultOutputDir() const
+{
+    return PathUtils::concatenateFilePath(ConfigMgr::getDocumentOrHomePath(), tr("vnote_exports"));
+}
+
+QGroupBox *ExportDialog::setupTargetGroup(QWidget *p_parent)
+{
+    auto box = new QGroupBox(tr("Target"), p_parent);
+    auto layout = WidgetsFactory::createFormLayout(box);
+
+    {
+        m_targetFormatComboBox = WidgetsFactory::createComboBox(box);
+        m_targetFormatComboBox->addItem(tr("Markdown"),
+                                        static_cast<int>(ExportFormat::Markdown));
+        m_targetFormatComboBox->addItem(tr("HTML"),
+                                        static_cast<int>(ExportFormat::HTML));
+        m_targetFormatComboBox->addItem(tr("PDF"),
+                                        static_cast<int>(ExportFormat::PDF));
+        m_targetFormatComboBox->addItem(tr("Custom"),
+                                        static_cast<int>(ExportFormat::Custom));
+        connect(m_targetFormatComboBox, QOverload<int>::of(&QComboBox::currentIndexChanged),
+                this, [this]() {
+                    AdvancedSettings settings = AdvancedSettings::Max;
+                    int format = m_targetFormatComboBox->currentData().toInt();
+                    switch (format) {
+                    case ExportFormat::HTML:
+                        settings = AdvancedSettings::HTML;
+                        break;
+
+                    case ExportFormat::PDF:
+                        settings = AdvancedSettings::PDF;
+                        break;
+
+                    default:
+                        break;
+                    }
+                    showAdvancedSettings(settings);
+                });
+        layout->addRow(tr("Format:"), m_targetFormatComboBox);
+    }
+
+    {
+        m_transparentBgCheckBox = WidgetsFactory::createCheckBox(tr("Use transparent background"), box);
+        layout->addRow(m_transparentBgCheckBox);
+    }
+
+    {
+        const auto webStyles = VNoteX::getInst().getThemeMgr().getWebStyles();
+
+        m_renderingStyleComboBox = WidgetsFactory::createComboBox(box);
+        layout->addRow(tr("Rendering style:"), m_renderingStyleComboBox);
+        for (const auto &pa : webStyles) {
+            m_renderingStyleComboBox->addItem(pa.first, pa.second);
+        }
+
+        m_syntaxHighlightStyleComboBox = WidgetsFactory::createComboBox(box);
+        layout->addRow(tr("Syntax highlighting style:"), m_syntaxHighlightStyleComboBox);
+        for (const auto &pa : webStyles) {
+            m_syntaxHighlightStyleComboBox->addItem(pa.first, pa.second);
+        }
+    }
+
+    {
+        auto outputLayout = new QHBoxLayout();
+
+        m_outputDirLineEdit = WidgetsFactory::createLineEdit(box);
+        outputLayout->addWidget(m_outputDirLineEdit);
+
+        auto browseBtn = new QPushButton(tr("Browse"), box);
+        outputLayout->addWidget(browseBtn);
+        connect(browseBtn, &QPushButton::clicked,
+                this, [this]() {
+                    QString initPath = getOutputDir();
+                    if (!QFileInfo::exists(initPath)) {
+                        initPath = getDefaultOutputDir();
+                    }
+
+                    QString dirPath = QFileDialog::getExistingDirectory(this,
+                                                                        tr("Select Export Output Directory"),
+                                                                        initPath,
+                                                                        QFileDialog::ShowDirsOnly
+                                                                        | QFileDialog::DontResolveSymlinks);
+
+                    if (!dirPath.isEmpty()) {
+                        m_outputDirLineEdit->setText(dirPath);
+                    }
+                });
+
+        layout->addRow(tr("Output directory:"), outputLayout);
+    }
+
+    return box;
+}
+
+QGroupBox *ExportDialog::setupAdvancedGroup(QWidget *p_parent)
+{
+    auto box = new QGroupBox(tr("Advanced"), p_parent);
+    auto layout = new QVBoxLayout(box);
+
+    m_advancedSettings.resize(AdvancedSettings::Max);
+
+    m_advancedSettings[AdvancedSettings::General] = setupGeneralAdvancedSettings(box);
+    layout->addWidget(m_advancedSettings[AdvancedSettings::General]);
+
+    return box;
+}
+
+QWidget *ExportDialog::setupGeneralAdvancedSettings(QWidget *p_parent)
+{
+    QWidget *widget = new QWidget(p_parent);
+    auto layout = WidgetsFactory::createFormLayout(widget);
+    layout->setContentsMargins(0, 0, 0, 0);
+
+    {
+        m_recursiveCheckBox = WidgetsFactory::createCheckBox(tr("Process sub-folders"), widget);
+        layout->addRow(m_recursiveCheckBox);
+    }
+
+    {
+        m_exportAttachmentsCheckBox = WidgetsFactory::createCheckBox(tr("Export attachments"), widget);
+        layout->addRow(m_exportAttachmentsCheckBox);
+    }
+
+    return widget;
+}
+
+void ExportDialog::setupButtonBox()
+{
+    setDialogButtonBox(QDialogButtonBox::Close);
+
+    auto box = getDialogButtonBox();
+
+    m_exportBtn = box->addButton(tr("Export"), QDialogButtonBox::ActionRole);
+    connect(m_exportBtn, &QPushButton::clicked,
+            this, &ExportDialog::startExport);
+
+    m_openDirBtn = box->addButton(tr("Open Directory"), QDialogButtonBox::ActionRole);
+    connect(m_openDirBtn, &QPushButton::clicked,
+            this, [this]() {
+                auto dir = getOutputDir();
+                if (!dir.isEmpty()) {
+                    WidgetUtils::openUrlByDesktop(QUrl::fromLocalFile(dir));
+                }
+            });
+
+    m_copyContentBtn = box->addButton(tr("Copy Content"), QDialogButtonBox::ActionRole);
+    m_copyContentBtn->setToolTip(tr("Copy exported file content"));
+    m_copyContentBtn->setEnabled(false);
+    connect(m_copyContentBtn, &QPushButton::clicked,
+            this, [this]() {
+                if (m_exportedFile.isEmpty()) {
+                    return;
+                }
+
+                const auto content = FileUtils::readTextFile(m_exportedFile);
+                if (!content.isEmpty()) {
+                    ClipboardUtils::setTextToClipboard(content);
+                }
+            });
+}
+
+QString ExportDialog::getOutputDir() const
+{
+    return m_outputDirLineEdit->text();
+}
+
+void ExportDialog::initOptions()
+{
+    // Read it from config.
+    m_option = ConfigMgr::getInst().getSessionConfig().getExportOption();
+
+    const auto &theme = VNoteX::getInst().getThemeMgr().getCurrentTheme();
+    m_option.m_renderingStyleFile = theme.getFile(Theme::File::WebStyleSheet);
+    m_option.m_syntaxHighlightStyleFile = theme.getFile(Theme::File::HighlightStyleSheet);
+
+    if (m_option.m_outputDir.isEmpty()) {
+        m_option.m_outputDir = getDefaultOutputDir();
+    }
+}
+
+void ExportDialog::restoreFields(const ExportOption &p_option)
+{
+    {
+        int idx = m_sourceComboBox->findData(static_cast<int>(p_option.m_source));
+        if (idx != -1) {
+            m_sourceComboBox->setCurrentIndex(idx);
+        }
+    }
+
+    {
+        int idx = m_targetFormatComboBox->findData(static_cast<int>(p_option.m_targetFormat));
+        if (idx != -1) {
+            m_targetFormatComboBox->setCurrentIndex(idx);
+        }
+    }
+
+    m_transparentBgCheckBox->setChecked(p_option.m_useTransparentBg);
+
+    {
+        int idx = m_renderingStyleComboBox->findData(p_option.m_renderingStyleFile);
+        if (idx != -1) {
+            m_renderingStyleComboBox->setCurrentIndex(idx);
+        }
+    }
+
+    {
+        int idx = m_syntaxHighlightStyleComboBox->findData(p_option.m_syntaxHighlightStyleFile);
+        if (idx != -1) {
+            m_syntaxHighlightStyleComboBox->setCurrentIndex(idx);
+        }
+    }
+
+    m_outputDirLineEdit->setText(p_option.m_outputDir);
+
+    m_recursiveCheckBox->setChecked(p_option.m_recursive);
+
+    m_exportAttachmentsCheckBox->setChecked(p_option.m_exportAttachments);
+}
+
+void ExportDialog::saveFields(ExportOption &p_option)
+{
+    p_option.m_source = static_cast<ExportSource>(m_sourceComboBox->currentData().toInt());
+    p_option.m_targetFormat = static_cast<ExportFormat>(m_targetFormatComboBox->currentData().toInt());
+    p_option.m_useTransparentBg = m_transparentBgCheckBox->isChecked();
+    p_option.m_renderingStyleFile = m_renderingStyleComboBox->currentData().toString();
+    p_option.m_syntaxHighlightStyleFile = m_syntaxHighlightStyleComboBox->currentData().toString();
+    p_option.m_outputDir = getOutputDir();
+    p_option.m_recursive = m_recursiveCheckBox->isChecked();
+    p_option.m_exportAttachments = m_exportAttachmentsCheckBox->isChecked();
+
+    if (m_advancedSettings[AdvancedSettings::HTML]) {
+        saveFields(p_option.m_htmlOption);
+    }
+
+    if (m_advancedSettings[AdvancedSettings::PDF]) {
+        saveFields(p_option.m_pdfOption);
+    }
+}
+
+void ExportDialog::startExport()
+{
+    if (m_exportOngoing) {
+        return;
+    }
+
+    // On start.
+    {
+        m_exportedFile.clear();
+        m_exportOngoing = true;
+        updateUIOnExport();
+    }
+
+    saveFields(m_option);
+
+    int ret = doExport(m_option);
+    appendLog(tr("%n file(s) exported", "", ret));
+
+    // On end.
+    {
+        m_exportOngoing = false;
+        updateUIOnExport();
+    }
+}
+
+void ExportDialog::rejectedButtonClicked()
+{
+    if (m_exportOngoing) {
+        // Just cancel the export.
+        appendLog(tr("Cancelling the export."));
+        m_exporter->stop();
+    } else {
+        Dialog::rejectedButtonClicked();
+    }
+}
+
+void ExportDialog::appendLog(const QString &p_log)
+{
+    appendInformationText(">>> " + p_log);
+    QCoreApplication::sendPostedEvents();
+}
+
+void ExportDialog::updateUIOnExport()
+{
+    m_exportBtn->setEnabled(!m_exportOngoing);
+    if (m_exportOngoing) {
+        m_progressBar->setMaximum(0);
+        m_progressBar->show();
+    } else {
+        m_progressBar->hide();
+    }
+    m_copyContentBtn->setEnabled(!m_exportedFile.isEmpty());
+}
+
+int ExportDialog::doExport(ExportOption p_option)
+{
+    // TODO: Check ExportOption.
+
+    int exportedFilesCount = 0;
+
+    switch (p_option.m_source) {
+    case ExportSource::CurrentBuffer:
+    {
+        Q_ASSERT(m_buffer);
+        const auto outputFile = getExporter()->doExport(p_option, m_buffer);
+        exportedFilesCount = outputFile.isEmpty() ? 0 : 1;
+        if (exportedFilesCount == 1 && p_option.m_targetFormat == ExportFormat::HTML) {
+            m_exportedFile = outputFile;
+        }
+        break;
+    }
+
+    case ExportSource::CurrentFolder:
+    {
+        Q_ASSERT(m_folder);
+        const auto outputFiles = getExporter()->doExport(p_option, m_folder);
+        exportedFilesCount = outputFiles.size();
+        break;
+    }
+
+    case ExportSource::CurrentNotebook:
+    {
+        Q_ASSERT(m_notebook);
+        const auto outputFiles = getExporter()->doExport(p_option, m_notebook);
+        exportedFilesCount = outputFiles.size();
+        break;
+    }
+    }
+
+    return exportedFilesCount;
+}
+
+Exporter *ExportDialog::getExporter()
+{
+    if (!m_exporter) {
+        m_exporter = new Exporter(this);
+        connect(m_exporter, &Exporter::progressUpdated,
+                this, &ExportDialog::updateProgress);
+        connect(m_exporter, &Exporter::logRequested,
+                this, &ExportDialog::appendLog);
+    }
+    return m_exporter;
+}
+
+void ExportDialog::updateProgress(int p_val, int p_maximum)
+{
+    m_progressBar->setMaximum(p_maximum);
+    m_progressBar->setValue(p_val);
+}
+
+QWidget *ExportDialog::getHtmlAdvancedSettings()
+{
+    if (!m_advancedSettings[AdvancedSettings::HTML]) {
+        // Setup HTML advanced settings.
+        QWidget *widget = new QWidget(m_advancedGroupBox);
+        auto layout = WidgetsFactory::createFormLayout(widget);
+        layout->setContentsMargins(0, 0, 0, 0);
+
+        {
+            m_embedStylesCheckBox = WidgetsFactory::createCheckBox(tr("Embed styles"), widget);
+            layout->addRow(m_embedStylesCheckBox);
+        }
+
+        {
+            m_embedImagesCheckBox = WidgetsFactory::createCheckBox(tr("Embed images"), widget);
+            layout->addRow(m_embedImagesCheckBox);
+        }
+
+        {
+            m_completePageCheckBox = WidgetsFactory::createCheckBox(tr("Complete page"), widget);
+            m_completePageCheckBox->setToolTip(tr("Export the whole page along with images which may change the links structure"));
+            connect(m_completePageCheckBox, &QCheckBox::stateChanged,
+                    this, [this](int p_state) {
+                        bool checked = p_state == Qt::Checked;
+                        m_embedImagesCheckBox->setEnabled(checked);
+                    });
+            layout->addRow(m_completePageCheckBox);
+        }
+
+        {
+            m_useMimeHtmlFormatCheckBox = WidgetsFactory::createCheckBox(tr("Mime HTML format"), widget);
+            connect(m_useMimeHtmlFormatCheckBox, &QCheckBox::stateChanged,
+                    this, [this](int p_state) {
+                        bool checked = p_state == Qt::Checked;
+                        m_embedStylesCheckBox->setEnabled(!checked);
+                        m_completePageCheckBox->setEnabled(!checked);
+                    });
+            // TODO: do not support MHTML for now.
+            m_useMimeHtmlFormatCheckBox->setEnabled(false);
+            layout->addRow(m_useMimeHtmlFormatCheckBox);
+        }
+
+        {
+            m_addOutlinePanelCheckBox = WidgetsFactory::createCheckBox(tr("Add outline panel"), widget);
+            layout->addRow(m_addOutlinePanelCheckBox);
+        }
+
+        m_advancedGroupBox->layout()->addWidget(widget);
+
+        m_advancedSettings[AdvancedSettings::HTML] = widget;
+
+        restoreFields(m_option.m_htmlOption);
+    }
+
+    return m_advancedSettings[AdvancedSettings::HTML];
+}
+
+void ExportDialog::showAdvancedSettings(AdvancedSettings p_settings)
+{
+    for (int i = AdvancedSettings::General + 1; i < m_advancedSettings.size(); ++i) {
+        if (m_advancedSettings[i]) {
+            m_advancedSettings[i]->hide();
+        }
+    }
+
+    QWidget *widget = nullptr;
+    switch (p_settings) {
+    case AdvancedSettings::HTML:
+        widget = getHtmlAdvancedSettings();
+        break;
+
+    case AdvancedSettings::PDF:
+        widget = getPdfAdvancedSettings();
+        break;
+
+    default:
+        break;
+    }
+
+    if (widget) {
+        widget->show();
+    }
+}
+
+void ExportDialog::restoreFields(const ExportHtmlOption &p_option)
+{
+    m_embedStylesCheckBox->setChecked(p_option.m_embedStyles);
+    m_embedImagesCheckBox->setChecked(p_option.m_embedImages);
+    m_completePageCheckBox->setChecked(p_option.m_completePage);
+    m_useMimeHtmlFormatCheckBox->setChecked(p_option.m_useMimeHtmlFormat);
+    m_addOutlinePanelCheckBox->setChecked(p_option.m_addOutlinePanel);
+}
+
+void ExportDialog::saveFields(ExportHtmlOption &p_option)
+{
+    p_option.m_embedStyles = m_embedStylesCheckBox->isChecked();
+    p_option.m_embedImages = m_embedImagesCheckBox->isChecked();
+    p_option.m_completePage = m_completePageCheckBox->isChecked();
+    p_option.m_useMimeHtmlFormat = m_useMimeHtmlFormatCheckBox->isChecked();
+    p_option.m_addOutlinePanel = m_addOutlinePanelCheckBox->isChecked();
+}
+
+QWidget *ExportDialog::getPdfAdvancedSettings()
+{
+    if (!m_advancedSettings[AdvancedSettings::PDF]) {
+        QWidget *widget = new QWidget(m_advancedGroupBox);
+        auto layout = WidgetsFactory::createFormLayout(widget);
+        layout->setContentsMargins(0, 0, 0, 0);
+
+        {
+            m_pageLayoutBtn = new QPushButton(tr("Settings"), widget);
+            connect(m_pageLayoutBtn, &QPushButton::clicked,
+                    this, [this]() {
+                        QPrinter printer;
+                        printer.setPageLayout(*m_pageLayout);
+
+                        QPageSetupDialog dlg(&printer, this);
+                        if (dlg.exec() != QDialog::Accepted) {
+                            return;
+                        }
+
+                        m_pageLayout->setUnits(QPageLayout::Millimeter);
+                        m_pageLayout->setPageSize(printer.pageLayout().pageSize());
+                        m_pageLayout->setMargins(printer.pageLayout().margins(QPageLayout::Millimeter));
+                        m_pageLayout->setOrientation(printer.pageLayout().orientation());
+
+                        updatePageLayoutButtonLabel();
+                    });
+            layout->addRow(tr("Page layout:"), m_pageLayoutBtn);
+        }
+
+        {
+            m_addTableOfContentsCheckBox = WidgetsFactory::createCheckBox(tr("Add Table-Of-Contents"), widget);
+            layout->addRow(m_addTableOfContentsCheckBox);
+        }
+
+        {
+            auto useLayout = new QHBoxLayout();
+
+            m_useWkhtmltopdfCheckBox = WidgetsFactory::createCheckBox(tr("Use wkhtmltopdf"), widget);
+            useLayout->addWidget(m_useWkhtmltopdfCheckBox);
+
+            auto downloadBtn = new QPushButton(tr("Download"), widget);
+            connect(downloadBtn, &QPushButton::clicked,
+                    this, []() {
+                        WidgetUtils::openUrlByDesktop(QUrl("https://wkhtmltopdf.org/downloads.html"));
+                    });
+            useLayout->addWidget(downloadBtn);
+
+            layout->addRow(useLayout);
+        }
+
+        {
+            auto pathLayout = new QHBoxLayout();
+
+            m_wkhtmltopdfExePathLineEdit = WidgetsFactory::createLineEdit(widget);
+            pathLayout->addWidget(m_wkhtmltopdfExePathLineEdit);
+
+            auto browseBtn = new QPushButton(tr("Browse"), widget);
+            pathLayout->addWidget(browseBtn);
+            connect(browseBtn, &QPushButton::clicked,
+                    this, [this]() {
+                        QString filePath = QFileDialog::getOpenFileName(this,
+                                                                        tr("Select wkhtmltopdf Executable"),
+                                                                        QCoreApplication::applicationDirPath());
+
+                        if (!filePath.isEmpty()) {
+                            m_wkhtmltopdfExePathLineEdit->setText(filePath);
+                        }
+                    });
+
+            layout->addRow(tr("Wkhtmltopdf path:"), pathLayout);
+        }
+
+        {
+            m_wkhtmltopdfArgsLineEdit = WidgetsFactory::createLineEdit(widget);
+            layout->addRow(tr("Wkhtmltopdf arguments:"), m_wkhtmltopdfArgsLineEdit);
+        }
+
+        m_advancedGroupBox->layout()->addWidget(widget);
+
+        m_advancedSettings[AdvancedSettings::PDF] = widget;
+
+        restoreFields(m_option.m_pdfOption);
+    }
+
+    return m_advancedSettings[AdvancedSettings::PDF];
+}
+
+void ExportDialog::restoreFields(const ExportPdfOption &p_option)
+{
+    m_pageLayout = p_option.m_layout;
+    updatePageLayoutButtonLabel();
+
+    m_addTableOfContentsCheckBox->setChecked(p_option.m_addTableOfContents);
+    m_useWkhtmltopdfCheckBox->setChecked(p_option.m_useWkhtmltopdf);
+    m_wkhtmltopdfExePathLineEdit->setText(p_option.m_wkhtmltopdfExePath);
+    m_wkhtmltopdfArgsLineEdit->setText(p_option.m_wkhtmltopdfArgs);
+}
+
+void ExportDialog::saveFields(ExportPdfOption &p_option)
+{
+    p_option.m_layout = m_pageLayout;
+    p_option.m_addTableOfContents = m_addTableOfContentsCheckBox->isChecked();
+    p_option.m_useWkhtmltopdf = m_useWkhtmltopdfCheckBox->isChecked();
+    p_option.m_wkhtmltopdfExePath = m_wkhtmltopdfExePathLineEdit->text();
+    p_option.m_wkhtmltopdfArgs = m_wkhtmltopdfArgsLineEdit->text();
+}
+
+void ExportDialog::updatePageLayoutButtonLabel()
+{
+    Q_ASSERT(m_pageLayout);
+    m_pageLayoutBtn->setText(
+        QString("%1, %2").arg(m_pageLayout->pageSize().name(),
+                              m_pageLayout->orientation() == QPageLayout::Portrait ? tr("Portrait") : tr("Landscape")));
+}

+ 171 - 0
src/widgets/dialogs/exportdialog.h

@@ -0,0 +1,171 @@
+#ifndef EXPORTDIALOG_H
+#define EXPORTDIALOG_H
+
+#include "scrolldialog.h"
+
+#include <QSharedPointer>
+
+#include <export/exportdata.h>
+
+class QGroupBox;
+class QPushButton;
+class QComboBox;
+class QCheckBox;
+class QLineEdit;
+class QProgressBar;
+class QPlainTextEdit;
+class QPageLayout;
+
+namespace vnotex
+{
+    class Notebook;
+    class Node;
+    class Buffer;
+    class Exporter;
+
+    class ExportDialog : public ScrollDialog
+    {
+        Q_OBJECT
+    public:
+        // Current notebook/folder/buffer.
+        ExportDialog(Notebook *p_notebook,
+                     Node *p_folder,
+                     Buffer *p_buffer,
+                     QWidget *p_parent = nullptr);
+
+    protected:
+        void rejectedButtonClicked() Q_DECL_OVERRIDE;
+
+    private slots:
+        void updateProgress(int p_val, int p_maximum);
+
+        void appendLog(const QString &p_log);
+
+    private:
+        enum AdvancedSettings
+        {
+            General,
+            HTML,
+            PDF,
+            Max
+        };
+
+        void setupUI();
+
+        QGroupBox *setupSourceGroup(QWidget *p_parent);
+
+        QGroupBox *setupTargetGroup(QWidget *p_parent);
+
+        QGroupBox *setupAdvancedGroup(QWidget *p_parent);
+
+        QWidget *setupGeneralAdvancedSettings(QWidget *p_parent);
+
+        QWidget *getHtmlAdvancedSettings();
+
+        QWidget *getPdfAdvancedSettings();
+
+        void showAdvancedSettings(AdvancedSettings p_settings);
+
+        void setupButtonBox();
+
+        QString getOutputDir() const;
+
+        void initOptions();
+
+        void restoreFields(const ExportOption &p_option);
+
+        void saveFields(ExportOption &p_option);
+
+        void restoreFields(const ExportHtmlOption &p_option);
+
+        void saveFields(ExportHtmlOption &p_option);
+
+        void restoreFields(const ExportPdfOption &p_option);
+
+        void saveFields(ExportPdfOption &p_option);
+
+        void startExport();
+
+        void updateUIOnExport();
+
+        // Return exported files count.
+        int doExport(ExportOption p_option);
+
+        Exporter *getExporter();
+
+        QString getDefaultOutputDir() const;
+
+        void updatePageLayoutButtonLabel();
+
+        // Managed by QObject.
+        Exporter *m_exporter = nullptr;
+
+        Notebook *m_notebook = nullptr;
+
+        Node *m_folder = nullptr;
+
+        Buffer *m_buffer = nullptr;
+
+        // Last exported single file.
+        QString m_exportedFile;
+
+        bool m_exportOngoing = false;
+
+        QPushButton *m_exportBtn = nullptr;
+
+        QPushButton *m_openDirBtn = nullptr;
+
+        QPushButton *m_copyContentBtn = nullptr;
+
+        QComboBox *m_sourceComboBox = nullptr;
+
+        QComboBox *m_targetFormatComboBox = nullptr;
+
+        QCheckBox *m_transparentBgCheckBox = nullptr;
+
+        QComboBox *m_renderingStyleComboBox = nullptr;
+
+        QComboBox *m_syntaxHighlightStyleComboBox = nullptr;
+
+        QLineEdit *m_outputDirLineEdit = nullptr;
+
+        QProgressBar *m_progressBar = nullptr;
+
+        QGroupBox *m_advancedGroupBox = nullptr;
+
+        QVector<QWidget *> m_advancedSettings;
+
+        // General settings.
+        QCheckBox *m_recursiveCheckBox = nullptr;
+
+        QCheckBox *m_exportAttachmentsCheckBox = nullptr;
+
+        // HTML settings.
+        QCheckBox *m_embedStylesCheckBox = nullptr;
+
+        QCheckBox *m_embedImagesCheckBox = nullptr;
+
+        QCheckBox *m_completePageCheckBox = nullptr;
+
+        QCheckBox *m_useMimeHtmlFormatCheckBox = nullptr;
+
+        QCheckBox *m_addOutlinePanelCheckBox = nullptr;
+
+        // PDF settings.
+        QPushButton *m_pageLayoutBtn = nullptr;
+
+        QCheckBox *m_addTableOfContentsCheckBox = nullptr;
+
+        QCheckBox *m_useWkhtmltopdfCheckBox = nullptr;
+
+        QLineEdit *m_wkhtmltopdfExePathLineEdit = nullptr;
+
+        QLineEdit *m_wkhtmltopdfArgsLineEdit = nullptr;
+
+        QSharedPointer<QPageLayout> m_pageLayout;
+
+        ExportOption m_option;
+    };
+}
+
+#endif // EXPORTDIALOG_H

+ 1 - 1
src/widgets/dialogs/filepropertiesdialog.cpp

@@ -30,7 +30,7 @@ void FilePropertiesDialog::setupUI()
     auto widget = new QWidget(this);
     setCentralWidget(widget);
 
-    auto mainLayout = WidgetUtils::createFormLayout(widget);
+    auto mainLayout = WidgetsFactory::createFormLayout(widget);
     mainLayout->setContentsMargins(0, 0, 0, 0);
 
     const QFileInfo info(m_path);

+ 1 - 1
src/widgets/dialogs/folderfilesfilterwidget.cpp

@@ -35,7 +35,7 @@ FolderFilesFilterWidget::FolderFilesFilterWidget(QWidget *p_parent)
 
 void FolderFilesFilterWidget::setupUI()
 {
-    auto mainLayout = WidgetUtils::createFormLayout(this);
+    auto mainLayout = WidgetsFactory::createFormLayout(this);
     mainLayout->setContentsMargins(0, 0, 0, 0);
 
     {

+ 1 - 1
src/widgets/dialogs/linkinsertdialog.cpp

@@ -30,7 +30,7 @@ void LinkInsertDialog::setupUI(const QString &p_title,
     auto mainWidget = new QWidget(this);
     setCentralWidget(mainWidget);
 
-    auto mainLayout = WidgetUtils::createFormLayout(mainWidget);
+    auto mainLayout = WidgetsFactory::createFormLayout(mainWidget);
 
     m_linkTextEdit = WidgetsFactory::createLineEdit(p_linkText, mainWidget);
     mainLayout->addRow(tr("&Text:"), m_linkTextEdit);

+ 2 - 2
src/widgets/dialogs/nodeinfowidget.cpp

@@ -36,7 +36,7 @@ void NodeInfoWidget::setupUI(const Node *p_parentNode, Node::Flags p_newNodeFlag
     const bool createMode = m_mode == Mode::Create;
     const bool isNote = p_newNodeFlags & Node::Flag::Content;
 
-    m_mainLayout = WidgetUtils::createFormLayout(this);
+    m_mainLayout = WidgetsFactory::createFormLayout(this);
 
     m_mainLayout->addRow(tr("Notebook:"),
                          new QLabel(p_parentNode->getNotebook()->getName(), this));
@@ -84,7 +84,7 @@ void NodeInfoWidget::setupNameLineEdit(QWidget *p_parent)
                         const auto &fileType = FileTypeHelper::getInst().getFileTypeBySuffix(suffix);
                         typeName = fileType.m_typeName;
                     } else {
-                        typeName = FileTypeHelper::getInst().getFileType(FileTypeHelper::Others).m_typeName;
+                        typeName = FileTypeHelper::getInst().getFileType(FileType::Others).m_typeName;
                     }
 
                     int idx = m_fileTypeComboBox->findData(typeName);

+ 2 - 2
src/widgets/dialogs/notebookinfowidget.cpp

@@ -44,7 +44,7 @@ void NotebookInfoWidget::setupUI()
 QGroupBox *NotebookInfoWidget::setupBasicInfoGroupBox(QWidget *p_parent)
 {
     auto box = new QGroupBox(tr("Basic Information"), p_parent);
-    auto mainLayout = WidgetUtils::createFormLayout(box);
+    auto mainLayout = WidgetsFactory::createFormLayout(box);
 
     {
         setupNotebookTypeComboBox(box);
@@ -131,7 +131,7 @@ QLayout *NotebookInfoWidget::setupNotebookRootFolderPath(QWidget *p_parent)
 QGroupBox *NotebookInfoWidget::setupAdvancedInfoGroupBox(QWidget *p_parent)
 {
     auto box = new QGroupBox(tr("Advanced Information"), p_parent);
-    auto mainLayout = WidgetUtils::createFormLayout(box);
+    auto mainLayout = WidgetsFactory::createFormLayout(box);
 
     {
         setupConfigMgrComboBox(box);

+ 5 - 0
src/widgets/dialogs/scrolldialog.cpp

@@ -33,6 +33,11 @@ void ScrollDialog::setCentralWidget(QWidget *p_widget)
     m_scrollArea->setWidget(p_widget);
 }
 
+void ScrollDialog::addBottomWidget(QWidget *p_widget)
+{
+    m_layout->insertWidget(m_layout->indexOf(m_scrollArea) + 1, p_widget);
+}
+
 void ScrollDialog::showEvent(QShowEvent *p_event)
 {
     QDialog::showEvent(p_event);

+ 4 - 2
src/widgets/dialogs/scrolldialog.h

@@ -13,11 +13,13 @@ namespace vnotex
     public:
         ScrollDialog(QWidget *p_parent = nullptr, Qt::WindowFlags p_flags = Qt::WindowFlags());
 
-        void setCentralWidget(QWidget *p_widget) Q_DECL_OVERRIDE;
-
         void resizeToHideScrollBarLater(bool p_vertical, bool p_horizontal);
 
     protected:
+        void setCentralWidget(QWidget *p_widget) Q_DECL_OVERRIDE;
+
+        void addBottomWidget(QWidget *p_widget) Q_DECL_OVERRIDE;
+
         void showEvent(QShowEvent *p_event) Q_DECL_OVERRIDE;
 
     private:

+ 1 - 1
src/widgets/dialogs/settings/appearancepage.cpp

@@ -20,7 +20,7 @@ AppearancePage::AppearancePage(QWidget *p_parent)
 
 void AppearancePage::setupUI()
 {
-    auto mainLayout = WidgetUtils::createFormLayout(this);
+    auto mainLayout = WidgetsFactory::createFormLayout(this);
 
     {
         const QString label(tr("System title bar"));

+ 1 - 1
src/widgets/dialogs/settings/editorpage.cpp

@@ -20,7 +20,7 @@ EditorPage::EditorPage(QWidget *p_parent)
 
 void EditorPage::setupUI()
 {
-    auto mainLayout = WidgetUtils::createFormLayout(this);
+    auto mainLayout = WidgetsFactory::createFormLayout(this);
 
     {
         m_autoSavePolicyComboBox = WidgetsFactory::createComboBox(this);

+ 1 - 1
src/widgets/dialogs/settings/generalpage.cpp

@@ -20,7 +20,7 @@ GeneralPage::GeneralPage(QWidget *p_parent)
 
 void GeneralPage::setupUI()
 {
-    auto mainLayout = WidgetUtils::createFormLayout(this);
+    auto mainLayout = WidgetsFactory::createFormLayout(this);
 
     {
         m_localeComboBox = WidgetsFactory::createComboBox(this);

+ 3 - 3
src/widgets/dialogs/settings/markdowneditorpage.cpp

@@ -125,7 +125,7 @@ QString MarkdownEditorPage::title() const
 QGroupBox *MarkdownEditorPage::setupReadGroup()
 {
     auto box = new QGroupBox(tr("Read"), this);
-    auto layout = WidgetUtils::createFormLayout(box);
+    auto layout = WidgetsFactory::createFormLayout(box);
 
     {
         const QString label(tr("Constrain image width"));
@@ -197,7 +197,7 @@ QGroupBox *MarkdownEditorPage::setupReadGroup()
 QGroupBox *MarkdownEditorPage::setupEditGroup()
 {
     auto box = new QGroupBox(tr("Edit"), this);
-    auto layout = WidgetUtils::createFormLayout(box);
+    auto layout = WidgetsFactory::createFormLayout(box);
 
     {
         const QString label(tr("Insert file name as title"));
@@ -245,7 +245,7 @@ QGroupBox *MarkdownEditorPage::setupEditGroup()
 QGroupBox *MarkdownEditorPage::setupGeneralGroup()
 {
     auto box = new QGroupBox(tr("General"), this);
-    auto layout = WidgetUtils::createFormLayout(box);
+    auto layout = WidgetsFactory::createFormLayout(box);
 
     {
         auto sectionLayout = new QHBoxLayout();

+ 1 - 1
src/widgets/dialogs/settings/texteditorpage.cpp

@@ -23,7 +23,7 @@ TextEditorPage::TextEditorPage(QWidget *p_parent)
 
 void TextEditorPage::setupUI()
 {
-    auto mainLayout = WidgetUtils::createFormLayout(this);
+    auto mainLayout = WidgetsFactory::createFormLayout(this);
 
     {
         m_lineNumberComboBox = WidgetsFactory::createComboBox(this);

+ 2 - 1
src/widgets/editors/markdowneditor.cpp

@@ -36,6 +36,7 @@
 #include <utils/htmlutils.h>
 #include <utils/widgetutils.h>
 #include <utils/textutils.h>
+#include <utils/webutils.h>
 #include <core/exception.h>
 #include <core/markdowneditorconfig.h>
 #include <core/texteditorconfig.h>
@@ -1075,7 +1076,7 @@ void MarkdownEditor::fetchImagesToLocalAndReplace(QString &p_text)
 
         // Only handle absolute file path or network path.
         QString srcImagePath;
-        QFileInfo info(TextUtils::purifyUrl(imageUrl));
+        QFileInfo info(WebUtils::purifyUrl(imageUrl));
 
         // For network image.
         QScopedPointer<QTemporaryFile> tmpFile;

+ 28 - 0
src/widgets/editors/markdownvieweradapter.cpp

@@ -94,6 +94,16 @@ void MarkdownViewerAdapter::setText(int p_revision,
     }
 }
 
+void MarkdownViewerAdapter::setText(const QString &p_text)
+{
+    m_revision = 0;
+    if (m_viewerReady) {
+        emit textUpdated(p_text);
+    } else {
+        m_pendingData.reset(new MarkdownData(p_text, -1, ""));
+    }
+}
+
 void MarkdownViewerAdapter::setReady(bool p_ready)
 {
     if (m_viewerReady == p_ready) {
@@ -328,3 +338,21 @@ void MarkdownViewerAdapter::setFindText(const QString &p_text, int p_totalMatche
 {
     emit findTextReady(p_text, p_totalMatches, p_currentMatchIndex);
 }
+
+void MarkdownViewerAdapter::setWorkFinished()
+{
+    emit workFinished();
+}
+
+void MarkdownViewerAdapter::saveContent()
+{
+    emit contentRequested();
+}
+
+void MarkdownViewerAdapter::setSavedContent(const QString &p_headContent,
+                                            const QString &p_styleContent,
+                                            const QString &p_content,
+                                            const QString &p_bodyClassList)
+{
+    emit contentReady(p_headContent, p_styleContent, p_content, p_bodyClassList);
+}

+ 19 - 0
src/widgets/editors/markdownvieweradapter.h

@@ -100,6 +100,8 @@ namespace vnotex
                      const QString &p_text,
                      int p_lineNumber);
 
+        void setText(const QString &p_text);
+
         void scrollToPosition(const Position &p_pos);
 
         int getTopLineNumber() const;
@@ -119,10 +121,14 @@ namespace vnotex
 
         void findText(const QString &p_text, FindOptions p_options);
 
+        void saveContent();
+
         // Functions to be called from web side.
     public slots:
         void setReady(bool p_ready);
 
+        void setWorkFinished();
+
         // The line number at the top.
         void setTopLineNumber(int p_lineNumber);
 
@@ -161,6 +167,8 @@ namespace vnotex
 
         void setFindText(const QString &p_text, int p_totalMatches, int p_currentMatchIndex);
 
+        void setSavedContent(const QString &p_headContent, const QString &p_styleContent, const QString &p_content, const QString &p_bodyClassList);
+
         // Signals to be connected at web side.
     signals:
         // Current Markdown text is updated.
@@ -194,6 +202,9 @@ namespace vnotex
 
         void findTextRequested(const QString &p_text, const QJsonObject &p_options);
 
+        // Request to get the whole HTML content.
+        void contentRequested();
+
     // Signals to be connected at cpp side.
     signals:
         void graphPreviewDataReady(const PreviewData &p_data);
@@ -202,6 +213,9 @@ namespace vnotex
 
         void viewerReady();
 
+        // All rendering work has finished.
+        void workFinished();
+
         void headingsChanged();
 
         void currentHeadingChanged();
@@ -216,6 +230,11 @@ namespace vnotex
 
         void findTextReady(const QString &p_text, int p_totalMatches, int p_currentMatchIndex);
 
+        void contentReady(const QString &p_headContent,
+                          const QString &p_styleContent,
+                          const QString &p_content,
+                          const QString &p_bodyClassList);
+
     private:
         void scrollToLine(int p_lineNumber);
 

+ 19 - 1
src/widgets/mainwindow.cpp

@@ -29,7 +29,7 @@
 #include <core/coreconfig.h>
 #include <core/events.h>
 #include <core/fileopenparameters.h>
-#include <widgets/dialogs/scrolldialog.h>
+#include <widgets/dialogs/exportdialog.h>
 #include "viewwindow.h"
 #include "outlineviewer.h"
 #include <utils/widgetutils.h>
@@ -57,6 +57,9 @@ MainWindow::MainWindow(QWidget *p_parent)
     // Note that no user interaction is possible in this state.
     connect(qApp, &QCoreApplication::aboutToQuit,
             this, &MainWindow::closeOnQuit);
+
+    connect(&VNoteX::getInst(), &VNoteX::exportRequested,
+            this, &MainWindow::exportNotes);
 }
 
 MainWindow::~MainWindow()
@@ -582,3 +585,18 @@ void MainWindow::updateTabBarStyle()
         tabBar->setDrawBase(false);
     }
 }
+
+void MainWindow::exportNotes()
+{
+    auto currentNotebook = m_notebookExplorer->currentNotebook().data();
+    auto viewWindow = m_viewArea->getCurrentViewWindow();
+    auto folderNode = m_notebookExplorer->currentExploredFolderNode();
+    if (folderNode && (folderNode->isRoot() || currentNotebook->isRecycleBinNode(folderNode))) {
+        folderNode = nullptr;
+    }
+    ExportDialog dialog(currentNotebook,
+                        folderNode,
+                        viewWindow ? viewWindow->getBuffer() : nullptr,
+                        this);
+    dialog.exec();
+}

+ 2 - 0
src/widgets/mainwindow.h

@@ -72,6 +72,8 @@ namespace vnotex
 
         void updateTabBarStyle();
 
+        void exportNotes();
+
     private:
         // Index in m_docks.
         enum DockIndex

Неке датотеке нису приказане због велике количине промена