Browse Source

Export: support custom export

Le Tan 4 years ago
parent
commit
8326d3c702

+ 1 - 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";

+ 40 - 28
src/core/htmltemplatehelper.cpp

@@ -15,8 +15,6 @@ 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")
@@ -35,6 +33,7 @@ QString WebGlobalOptions::toJavascriptObject() const
            + QString("bodyHeight: %1,\n").arg(m_bodyHeight)
            + QString("transformSvgToPngEnabled: %1,\n").arg(Utils::boolToString(m_transformSvgToPngEnabled))
            + QString("mathJaxScale: %1,\n").arg(m_mathJaxScale)
+           + QString("removeCodeToolBarEnabled: %1,\n").arg(Utils::boolToString(m_removeCodeToolBarEnabled))
            + QString("sectionNumberBaseLevel: %1\n").arg(m_sectionNumberBaseLevel)
            + QStringLiteral("}");
 }
@@ -59,7 +58,7 @@ static void fillGlobalStyles(QString &p_template, const WebResource &p_resource,
     styles += p_additionalStyles;
 
     if (!styles.isEmpty()) {
-        p_template.replace(c_globalStylesPlaceholder, styles);
+        p_template.replace("/* VX_GLOBAL_STYLES_PLACEHOLDER */", styles);
     }
 }
 
@@ -175,22 +174,15 @@ void HtmlTemplateHelper::updateMarkdownViewerTemplate(const MarkdownEditorConfig
 
     s_markdownViewerTemplate.m_revision = p_config.revision();
 
+    Paras paras;
     const auto &themeMgr = VNoteX::getInst().getThemeMgr();
-    s_markdownViewerTemplate.m_template =
-        generateMarkdownViewerTemplate(p_config,
-                                       themeMgr.getFile(Theme::File::WebStyleSheet),
-                                       themeMgr.getFile(Theme::File::HighlightStyleSheet));
+    paras.m_webStyleSheetFile = themeMgr.getFile(Theme::File::WebStyleSheet);
+    paras.m_highlightStyleSheetFile = themeMgr.getFile(Theme::File::HighlightStyleSheet);
+
+    s_markdownViewerTemplate.m_template = generateMarkdownViewerTemplate(p_config, paras);
 }
 
-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)
+QString HtmlTemplateHelper::generateMarkdownViewerTemplate(const MarkdownEditorConfig &p_config, const Paras &p_paras)
 {
     const auto &viewerResource = p_config.getViewerResource();
     const auto templateFile = ConfigMgr::getInst().getUserOrAppFile(viewerResource.m_template);
@@ -198,7 +190,7 @@ QString HtmlTemplateHelper::generateMarkdownViewerTemplate(const MarkdownEditorC
 
     fillGlobalStyles(htmlTemplate, viewerResource, "");
 
-    fillThemeStyles(htmlTemplate, p_webStyleSheetFile, p_highlightStyleSheetFile);
+    fillThemeStyles(htmlTemplate, p_paras.m_webStyleSheetFile, p_paras.m_highlightStyleSheetFile);
 
     {
         WebGlobalOptions opts;
@@ -212,12 +204,13 @@ QString HtmlTemplateHelper::generateMarkdownViewerTemplate(const MarkdownEditorC
         opts.m_autoBreakEnabled = p_config.getAutoBreakEnabled();
         opts.m_linkifyEnabled = p_config.getLinkifyEnabled();
         opts.m_indentFirstLineEnabled = p_config.getIndentFirstLineEnabled();
-        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;
+        opts.m_transparentBackgroundEnabled = p_paras.m_transparentBackgroundEnabled;
+        opts.m_scrollable = p_paras.m_scrollable;
+        opts.m_bodyWidth = p_paras.m_bodyWidth;
+        opts.m_bodyHeight = p_paras.m_bodyHeight;
+        opts.m_transformSvgToPngEnabled = p_paras.m_transformSvgToPngEnabled;
+        opts.m_mathJaxScale = p_paras.m_mathJaxScale;
+        opts.m_removeCodeToolBarEnabled = p_paras.m_removeCodeToolBarEnabled;
         fillGlobalOptions(htmlTemplate, opts);
     }
 
@@ -235,17 +228,36 @@ QString HtmlTemplateHelper::generateExportTemplate(const MarkdownEditorConfig &p
 
     fillGlobalStyles(htmlTemplate, exportResource, "");
 
-    // Outline panel.
-    for (auto &ele : exportResource.m_resources) {
+    fillOutlinePanel(htmlTemplate, exportResource, p_addOutlinePanel);
+
+    fillResourcesByContent(htmlTemplate, exportResource);
+
+    return htmlTemplate;
+}
+
+void HtmlTemplateHelper::fillOutlinePanel(QString &p_template, WebResource &p_exportResource, bool p_addOutlinePanel)
+{
+    for (auto &ele : p_exportResource.m_resources) {
         if (ele.m_name == QStringLiteral("outline")) {
             ele.m_enabled = p_addOutlinePanel;
             break;
         }
     }
 
-    fillResourcesByContent(htmlTemplate, exportResource);
-
-    return htmlTemplate;
+    // Remove static content to make the page clean.
+    if (!p_addOutlinePanel) {
+        int startIdx = p_template.indexOf("<!-- VX_OUTLINE_PANEL_START -->");
+        QString endMark("<!-- VX_OUTLINE_PANEL_END -->");
+        int endIdx = p_template.lastIndexOf(endMark);
+        Q_ASSERT(startIdx > -1 && endIdx > startIdx);
+        p_template.remove(startIdx, endIdx + endMark.size() - startIdx);
+
+        startIdx = p_template.indexOf("<!-- VX_OUTLINE_BUTTON_START -->");
+        endMark = "<!-- VX_OUTLINE_BUTTON_END -->";
+        endIdx = p_template.lastIndexOf(endMark);
+        Q_ASSERT(startIdx > -1 && endIdx > startIdx);
+        p_template.remove(startIdx, endIdx + endMark.size() - startIdx);
+    }
 }
 
 void HtmlTemplateHelper::fillTitle(QString &p_template, const QString &p_title)

+ 28 - 9
src/core/htmltemplatehelper.h

@@ -6,6 +6,7 @@
 namespace vnotex
 {
     class MarkdownEditorConfig;
+    struct WebResource;
 
     // Global options to be passed to Web side at the very beginning.
     struct WebGlobalOptions
@@ -48,6 +49,9 @@ namespace vnotex
         // wkhtmltopdf will make the MathJax formula too small.
         qreal m_mathJaxScale = -1;
 
+        // Whether remove the tool bar of code blocks added by Prism.js.
+        bool m_removeCodeToolBarEnabled = false;
+
         QString toJavascriptObject() const;
     };
 
@@ -55,20 +59,33 @@ namespace vnotex
     class HtmlTemplateHelper
     {
     public:
+        struct Paras
+        {
+            QString m_webStyleSheetFile;
+
+            QString m_highlightStyleSheetFile;
+
+            bool m_transparentBackgroundEnabled = false;
+
+            bool m_scrollable = true;
+
+            int m_bodyWidth = -1;
+
+            int m_bodyHeight = -1;
+
+            bool m_transformSvgToPngEnabled = false;
+
+            qreal m_mathJaxScale = -1;
+
+            bool m_removeCodeToolBarEnabled = false;
+        };
+
         HtmlTemplateHelper() = delete;
 
         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 generateMarkdownViewerTemplate(const MarkdownEditorConfig &p_config, const Paras &p_paras);
 
         static QString generateExportTemplate(const MarkdownEditorConfig &p_config,
                                               bool p_addOutlinePanel);
@@ -83,6 +100,8 @@ namespace vnotex
 
         static void fillBodyClassList(QString &p_template, const QString &p_classList);
 
+        static void fillOutlinePanel(QString &p_template, WebResource &p_exportResource, bool p_addOutlinePanel);
+
     private:
         struct Template
         {

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

@@ -413,3 +413,22 @@ bool Node::checkExists()
     }
     return after;
 }
+
+QList<QSharedPointer<File>> Node::collectFiles()
+{
+    QList<QSharedPointer<File>> files;
+
+    load();
+
+    if (hasContent()) {
+        files.append(getContentFile());
+    }
+
+    if (isContainer()) {
+        for (const auto &child : m_children) {
+            files.append(child->collectFiles());
+        }
+    }
+
+    return files;
+}

+ 3 - 0
src/core/notebook/node.h

@@ -179,6 +179,9 @@ namespace vnotex
 
         void sortChildren(const QVector<int> &p_beforeIdx, const QVector<int> &p_afterIdx);
 
+        // Get content files recursively.
+        QList<QSharedPointer<File>> collectFiles();
+
         static bool isAncestor(const Node *p_ancestor, const Node *p_child);
 
     protected:

+ 17 - 0
src/core/notebook/notebook.cpp

@@ -361,3 +361,20 @@ QJsonObject Notebook::getExtraConfig(const QString &p_key) const
     const auto &configs = getExtraConfigs();
     return configs.value(p_key).toObject();
 }
+
+QList<QSharedPointer<File>> Notebook::collectFiles()
+{
+    QList<QSharedPointer<File>> files;
+
+    auto rootNode = getRootNode();
+
+    const auto &children = rootNode->getChildrenRef();
+    for (const auto &child : children) {
+        if (child->getUse() != Node::Use::Normal) {
+            continue;
+        }
+        files.append(child->collectFiles());
+    }
+
+    return files;
+}

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

@@ -16,6 +16,7 @@ namespace vnotex
     class IVersionController;
     class INotebookConfigMgr;
     struct NodeParameters;
+    class File;
 
     // Base class of notebook.
     class Notebook : public QObject
@@ -140,6 +141,9 @@ namespace vnotex
         QJsonObject getExtraConfig(const QString &p_key) const;
         virtual void setExtraConfig(const QString &p_key, const QJsonObject &p_obj) = 0;
 
+        // Get content files recursively.
+        QList<QSharedPointer<File>> collectFiles();
+
         static const QString c_defaultAttachmentFolder;
 
         static const QString c_defaultImageFolder;

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

@@ -48,6 +48,7 @@ QString VXNode::fetchAbsolutePath() const
 
 QSharedPointer<File> VXNode::getContentFile()
 {
+    Q_ASSERT(hasContent());
     // We should not keep the shared ptr of VXNodeFile, or there is a cyclic ref.
     return QSharedPointer<VXNodeFile>::create(sharedFromThis().dynamicCast<VXNode>());
 }

+ 40 - 2
src/core/sessionconfig.cpp

@@ -76,7 +76,7 @@ void SessionConfig::init()
 
     loadStateAndGeometry(sessionJobj);
 
-    m_exportOption.fromJson(sessionJobj[QStringLiteral("export_option")].toObject());
+    loadExportOption(sessionJobj);
 
     m_searchOption.fromJson(sessionJobj[QStringLiteral("search_option")].toObject());
 
@@ -206,7 +206,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();
+    obj[QStringLiteral("export")] = saveExportOption();
     obj[QStringLiteral("search_option")] = m_searchOption.toJson();
     writeByteArray(obj, QStringLiteral("viewarea_session"), m_viewAreaSession);
     writeByteArray(obj, QStringLiteral("notebook_explorer_session"), m_notebookExplorerSession);
@@ -325,6 +325,16 @@ void SessionConfig::setExportOption(const ExportOption &p_option)
     updateConfig(m_exportOption, p_option, this);
 }
 
+const QVector<ExportCustomOption> &SessionConfig::getCustomExportOptions() const
+{
+    return m_customExportOptions;
+}
+
+void SessionConfig::setCustomExportOptions(const QVector<ExportCustomOption> &p_options)
+{
+    updateConfig(m_customExportOptions, p_options, this);
+}
+
 const SearchOption &SessionConfig::getSearchOption() const
 {
     return m_searchOption;
@@ -444,3 +454,31 @@ QJsonArray SessionConfig::saveHistory() const
     }
     return arr;
 }
+
+void SessionConfig::loadExportOption(const QJsonObject &p_session)
+{
+    auto exportObj = p_session[QStringLiteral("export")].toObject();
+
+    m_exportOption.fromJson(exportObj[QStringLiteral("export_option")].toObject());
+
+    auto customArr = exportObj[QStringLiteral("custom_options")].toArray();
+    m_customExportOptions.resize(customArr.size());
+    for (int i = 0; i < customArr.size(); ++i) {
+        m_customExportOptions[i].fromJson(customArr[i].toObject());
+    }
+}
+
+QJsonObject SessionConfig::saveExportOption() const
+{
+    QJsonObject obj;
+
+    obj[QStringLiteral("export_option")] = m_exportOption.toJson();
+
+    QJsonArray customArr;
+    for (int i = 0; i < m_customExportOptions.size(); ++i) {
+        customArr.push_back(m_customExportOptions[i].toJson());
+    }
+    obj[QStringLiteral("custom_options")] = customArr;
+
+    return obj;
+}

+ 9 - 0
src/core/sessionconfig.h

@@ -107,6 +107,9 @@ namespace vnotex
         const ExportOption &getExportOption() const;
         void setExportOption(const ExportOption &p_option);
 
+        const QVector<ExportCustomOption> &getCustomExportOptions() const;
+        void setCustomExportOptions(const QVector<ExportCustomOption> &p_options);
+
         const SearchOption &getSearchOption() const;
         void setSearchOption(const SearchOption &p_option);
 
@@ -151,6 +154,10 @@ namespace vnotex
 
         QJsonArray saveHistory() const;
 
+        void loadExportOption(const QJsonObject &p_session);
+
+        QJsonObject saveExportOption() const;
+
         QString m_newNotebookDefaultRootFolderPath;
 
         // Use root folder to identify a notebook uniquely.
@@ -173,6 +180,8 @@ namespace vnotex
 
         ExportOption m_exportOption;
 
+        QVector<ExportCustomOption> m_customExportOptions;
+
         SearchOption m_searchOption;
 
         QByteArray m_viewAreaSession;

+ 14 - 0
src/data/extra/web/js/prism.js

@@ -86,10 +86,24 @@ class PrismRenderer extends VxWorker {
             p_containerNode.classList.add('line-numbers');
 
             Prism.highlightAllUnder(p_containerNode, false /* async or not */);
+
+            // Remove the toolbar.
+            if (window.vxOptions.removeCodeToolBarEnabled) {
+                this.removeToolBar(p_containerNode);
+            }
         }
 
         this.finishWork();
     }
+
+    removeToolBar(p_containerNode) {
+        // Static list.
+        let toolBarNodes = p_containerNode.querySelectorAll('div.code-toolbar > div.toolbar');
+        for (let i = 0; i < toolBarNodes.length; ++i) {
+            toolBarNodes[i].outerHTML = '';
+            delete toolBarNodes[i];
+        }
+    }
 }
 
 window.vnotex.registerWorker(new PrismRenderer());

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

@@ -27,19 +27,24 @@
 <body class="<!-- VX_BODY_CLASS_LIST_PLACEHOLDER -->">
     <div class="container-fluid">
     <div class="row flex-xl-nowrap">
+        <!-- VX_OUTLINE_PANEL_START -->
         <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>
+        <!-- VX_OUTLINE_PANEL_END -->
+
         <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>
 
+    <!-- VX_OUTLINE_BUTTON_START -->
     <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>
+    <!-- VX_OUTLINE_BUTTON_END -->
 </body>
 </html>

+ 41 - 0
src/export/exportdata.cpp

@@ -83,6 +83,45 @@ bool ExportPdfOption::operator==(const ExportPdfOption &p_other) const
            && m_wkhtmltopdfArgs == p_other.m_wkhtmltopdfArgs;
 }
 
+QJsonObject ExportCustomOption::toJson() const
+{
+    QJsonObject obj;
+    obj["name"] = m_name;
+    obj["target_suffix"] = m_targetSuffix;
+    obj["command"] = m_command;
+    obj["use_html_input"] = m_useHtmlInput;
+    obj["all_in_one"] = m_allInOne;
+    obj["target_page_scrollable"] = m_targetPageScrollable;
+    obj["resource_path_separator"] = m_resourcePathSeparator;
+    return obj;
+}
+
+void ExportCustomOption::fromJson(const QJsonObject &p_obj)
+{
+    if (p_obj.isEmpty()) {
+        return;
+    }
+
+    m_name = p_obj["name"].toString();
+    m_targetSuffix = p_obj["target_suffix"].toString();
+    m_command = p_obj["command"].toString();
+    m_useHtmlInput = p_obj["use_html_input"].toBool();
+    m_allInOne = p_obj["all_in_one"].toBool();
+    m_targetPageScrollable = p_obj["target_page_scrollable"].toBool();
+    m_resourcePathSeparator = p_obj["resource_path_separator"].toString();
+}
+
+bool ExportCustomOption::operator==(const ExportCustomOption &p_other) const
+{
+    return m_name == p_other.m_name
+           && m_useHtmlInput == p_other.m_useHtmlInput
+           && m_targetSuffix == p_other.m_targetSuffix
+           && m_command == p_other.m_command
+           && m_allInOne == p_other.m_allInOne
+           && m_targetPageScrollable == p_other.m_targetPageScrollable
+           && m_resourcePathSeparator == p_other.m_resourcePathSeparator;
+}
+
 QJsonObject ExportOption::toJson() const
 {
     QJsonObject obj;
@@ -93,6 +132,7 @@ QJsonObject ExportOption::toJson() const
     obj["export_attachments"] = m_exportAttachments;
     obj["html_option"] = m_htmlOption.toJson();
     obj["pdf_option"] = m_pdfOption.toJson();
+    obj["custom_export"] = m_customExport;
     return obj;
 }
 
@@ -131,6 +171,7 @@ void ExportOption::fromJson(const QJsonObject &p_obj)
     m_exportAttachments = p_obj["export_attachments"].toBool();
     m_htmlOption.fromJson(p_obj["html_option"].toObject());
     m_pdfOption.fromJson(p_obj["pdf_option"].toObject());
+    m_customExport = p_obj["custom_export"].toString();
 }
 
 bool ExportOption::operator==(const ExportOption &p_other) const

+ 37 - 0
src/export/exportdata.h

@@ -69,6 +69,34 @@ namespace vnotex
         QString m_wkhtmltopdfArgs;
     };
 
+    struct ExportCustomOption
+    {
+        QJsonObject toJson() const;
+        void fromJson(const QJsonObject &p_obj);
+
+        bool operator==(const ExportCustomOption &p_other) const;
+
+        QString m_name;
+
+        QString m_targetSuffix;
+
+        QString m_command;
+
+        bool m_useHtmlInput = true;
+
+        bool m_allInOne = false;
+
+        // Whether the page of target format is scrollable.
+        bool m_targetPageScrollable = false;
+
+        // The default value here follows the rules of Pandoc.
+#if defined(Q_OS_WIN)
+        QString m_resourcePathSeparator = ";";
+#else
+        QString m_resourcePathSeparator = ":";
+#endif
+    };
+
     struct ExportOption
     {
         QJsonObject toJson() const;
@@ -95,6 +123,15 @@ namespace vnotex
         ExportHtmlOption m_htmlOption;
 
         ExportPdfOption m_pdfOption;
+
+        QString m_customExport;
+
+        // Following fields are used in runtime only.
+        ExportCustomOption *m_customOption = nullptr;
+
+        bool m_transformSvgToPngEnabled = false;
+
+        bool m_removeCodeToolBarEnabled = true;
     };
 
     inline QString exportFormatString(ExportFormat p_format)

+ 248 - 27
src/export/exporter.cpp

@@ -8,9 +8,12 @@
 #include <buffer/buffer.h>
 #include <core/file.h>
 #include <utils/fileutils.h>
+#include <utils/utils.h>
 #include <utils/pathutils.h>
+#include <utils/processutils.h>
 #include <utils/contentmediautils.h>
 #include "webviewexporter.h"
+#include <core/exception.h>
 
 using namespace vnotex;
 
@@ -125,10 +128,13 @@ QString Exporter::doExport(const ExportOption &p_option, Node *p_note)
     return outputFile;
 }
 
-QString Exporter::doExportAllInOne(const ExportOption &p_option, Node *p_folder)
+QString Exporter::doExportPdfAllInOne(const ExportOption &p_option, Notebook *p_notebook, Node *p_folder)
 {
+    Q_ASSERT((p_notebook || p_folder) && !(p_notebook && p_folder));
+
     // Make path.
-    const auto outputFolder = makeOutputFolder(p_option.m_outputDir, p_folder->getName());
+    const auto name = p_notebook ? tr("notebook_%1").arg(p_notebook->getName()) : p_folder->getName();
+    const auto outputFolder = makeOutputFolder(p_option.m_outputDir, name);
     if (outputFolder.isEmpty()) {
         emit logRequested(tr("Failed to create output folder under (%1).").arg(p_option.m_outputDir));
         return QString();
@@ -143,7 +149,15 @@ QString Exporter::doExportAllInOne(const ExportOption &p_option, Node *p_folder)
 
     auto tmpOption(getExportOptionForIntermediateHtml(p_option, tmpDir.path()));
 
-    auto htmlFiles = doExport(tmpOption, tmpDir.path(), p_folder);
+    QStringList htmlFiles;
+    if (p_notebook) {
+        htmlFiles = doExportNotebook(tmpOption, tmpDir.path(), p_notebook);
+    } else {
+        htmlFiles = doExport(tmpOption, tmpDir.path(), p_folder);
+    }
+
+    cleanUpWebViewExporter();
+
     if (htmlFiles.isEmpty()) {
         return QString();
     }
@@ -152,8 +166,6 @@ QString Exporter::doExportAllInOne(const ExportOption &p_option, Node *p_folder)
         return QString();
     }
 
-    cleanUpWebViewExporter();
-
     auto fileName = FileUtils::generateFileNameWithSequence(outputFolder,
                                                             tr("all_in_one_export"),
                                                             "pdf");
@@ -166,40 +178,78 @@ QString Exporter::doExportAllInOne(const ExportOption &p_option, Node *p_folder)
     return QString();
 }
 
-QString Exporter::doExportAllInOne(const ExportOption &p_option, Notebook *p_notebook)
+QString Exporter::doExportCustomAllInOne(const ExportOption &p_option, Notebook *p_notebook, Node *p_folder)
 {
+    Q_ASSERT((p_notebook || p_folder) && !(p_notebook && p_folder));
+
     // Make path.
-    const auto outputFolder = makeOutputFolder(p_option.m_outputDir, tr("notebook_%1").arg(p_notebook->getName()));
+    const auto name = p_notebook ? tr("notebook_%1").arg(p_notebook->getName()) : p_folder->getName();
+    const auto outputFolder = makeOutputFolder(p_option.m_outputDir, name);
     if (outputFolder.isEmpty()) {
         emit logRequested(tr("Failed to create output folder under (%1).").arg(p_option.m_outputDir));
         return QString();
     }
 
-    // Export to HTML to a tmp dir first.
+    QStringList inputFiles;
+    QStringList resourcePaths;
+
     QTemporaryDir tmpDir;
-    if (!tmpDir.isValid()) {
-        emit logRequested(tr("Failed to create temporary dir to hold HTML files."));
-        return QString();
-    }
+    if (p_option.m_customOption->m_useHtmlInput) {
+        // Export to HTML to a tmp dir first.
+        if (!tmpDir.isValid()) {
+            emit logRequested(tr("Failed to create temporary dir to hold HTML files."));
+            return QString();
+        }
 
-    auto tmpOption(getExportOptionForIntermediateHtml(p_option, tmpDir.path()));
+        auto tmpOption(getExportOptionForIntermediateHtml(p_option, tmpDir.path()));
 
-    auto htmlFiles = doExportNotebook(tmpOption, tmpDir.path(), p_notebook);
-    if (htmlFiles.isEmpty()) {
-        return QString();
+        QStringList htmlFiles;
+        if (p_notebook) {
+            htmlFiles = doExportNotebook(tmpOption, tmpDir.path(), p_notebook);
+        } else {
+            htmlFiles = doExport(tmpOption, tmpDir.path(), p_folder);
+        }
+
+        cleanUpWebViewExporter();
+
+        if (htmlFiles.isEmpty()) {
+            return QString();
+        }
+
+        if (checkAskedToStop()) {
+            return QString();
+        }
+
+        inputFiles = htmlFiles;
+        for (const auto &file : htmlFiles) {
+            resourcePaths << PathUtils::parentDirPath(file);
+        }
+    } else {
+        // Collect source files.
+        if (p_notebook) {
+            collectFiles(p_notebook->collectFiles(), inputFiles, resourcePaths);
+        } else {
+            collectFiles(p_folder->collectFiles(), inputFiles, resourcePaths);
+        }
+
+        if (checkAskedToStop()) {
+            return QString();
+        }
     }
 
-    if (checkAskedToStop()) {
+    if (inputFiles.isEmpty()) {
         return QString();
     }
 
-    cleanUpWebViewExporter();
-
     auto fileName = FileUtils::generateFileNameWithSequence(outputFolder,
                                                             tr("all_in_one_export"),
-                                                            "pdf");
+                                                            p_option.m_customOption->m_targetSuffix);
     auto destFilePath = PathUtils::concatenateFilePath(outputFolder, fileName);
-    if (getWebViewExporter(p_option)->htmlToPdfViaWkhtmltopdf(p_option.m_pdfOption, htmlFiles, destFilePath)) {
+    bool success = doExportCustom(p_option,
+                                  inputFiles,
+                                  resourcePaths,
+                                  destFilePath);
+    if (success) {
         emit logRequested(tr("Exported to (%1).").arg(destFilePath));
         return destFilePath;
     }
@@ -213,8 +263,16 @@ QStringList Exporter::doExportFolder(const ExportOption &p_option, Node *p_folde
 
     QStringList outputFiles;
 
-    if (p_option.m_targetFormat == ExportFormat::PDF && p_option.m_pdfOption.m_useWkhtmltopdf && p_option.m_pdfOption.m_allInOne) {
-        auto file = doExportAllInOne(p_option, p_folder);
+    if (p_option.m_targetFormat == ExportFormat::PDF
+        && p_option.m_pdfOption.m_useWkhtmltopdf
+        && p_option.m_pdfOption.m_allInOne) {
+        auto file = doExportPdfAllInOne(p_option, nullptr, p_folder);
+        if (!file.isEmpty()) {
+            outputFiles << file;
+        }
+    } else if (p_option.m_targetFormat == ExportFormat::Custom
+               && p_option.m_customOption->m_allInOne) {
+        auto file = doExportCustomAllInOne(p_option, nullptr, p_folder);
         if (!file.isEmpty()) {
             outputFiles << file;
         }
@@ -240,7 +298,15 @@ QStringList Exporter::doExport(const ExportOption &p_option, const QString &p_ou
         return outputFiles;
     }
 
-    p_folder->load();
+    try {
+        p_folder->load();
+    } catch (Exception &p_e) {
+        QString msg = tr("Failed to load node (%1) (%2).").arg(p_folder->fetchPath(), p_e.what());
+        qWarning() << msg;
+        emit logRequested(msg);
+        return outputFiles;
+    }
+
     const auto &children = p_folder->getChildrenRef();
     emit progressUpdated(0, children.size());
     for (int i = 0; i < children.size(); ++i) {
@@ -282,6 +348,10 @@ QString Exporter::doExport(const ExportOption &p_option, const QString &p_output
         outputFile = doExportPdf(p_option, p_outputDir, p_file);
         break;
 
+    case ExportFormat::Custom:
+        outputFile = doExportCustom(p_option, p_outputDir, p_file);
+        break;
+
     default:
         emit logRequested(tr("Unknown target format %1.").arg(exportFormatString(p_option.m_targetFormat)));
         break;
@@ -302,8 +372,16 @@ QStringList Exporter::doExport(const ExportOption &p_option, Notebook *p_noteboo
 
     QStringList outputFiles;
 
-    if (p_option.m_targetFormat == ExportFormat::PDF && p_option.m_pdfOption.m_useWkhtmltopdf && p_option.m_pdfOption.m_allInOne) {
-        auto file = doExportAllInOne(p_option, p_notebook);
+    if (p_option.m_targetFormat == ExportFormat::PDF
+        && p_option.m_pdfOption.m_useWkhtmltopdf
+        && p_option.m_pdfOption.m_allInOne) {
+        auto file = doExportPdfAllInOne(p_option, p_notebook, nullptr);
+        if (!file.isEmpty()) {
+            outputFiles << file;
+        }
+    } else if (p_option.m_targetFormat == ExportFormat::Custom
+               && p_option.m_customOption->m_allInOne) {
+        auto file = doExportCustomAllInOne(p_option, p_notebook, nullptr);
         if (!file.isEmpty()) {
             outputFiles << file;
         }
@@ -454,15 +532,158 @@ QString Exporter::doExportPdf(const ExportOption &p_option, const QString &p_out
     return outputFile;
 }
 
+QString Exporter::doExportCustom(const ExportOption &p_option, const QString &p_outputDir, const File *p_file)
+{
+    Q_ASSERT(p_option.m_customOption);
+    QStringList inputFiles;
+    QStringList resourcePaths;
+
+    QTemporaryDir tmpDir;
+    if (p_option.m_customOption->m_useHtmlInput) {
+        // Export to HTML to a tmp dir first.
+        if (!tmpDir.isValid()) {
+            emit logRequested(tr("Failed to create temporary dir to hold HTML files."));
+            return QString();
+        }
+
+        auto tmpOption(getExportOptionForIntermediateHtml(p_option, tmpDir.path()));
+        auto htmlFile = doExport(tmpOption, tmpDir.path(), p_file);
+        if (htmlFile.isEmpty()) {
+            return QString();
+        }
+
+        if (checkAskedToStop()) {
+            return QString();
+        }
+
+        cleanUpWebViewExporter();
+
+        inputFiles << htmlFile;
+        resourcePaths << PathUtils::parentDirPath(htmlFile);
+    } else {
+        inputFiles << p_file->getContentPath();
+        resourcePaths << p_file->getResourcePath();
+    }
+
+    auto fileName = FileUtils::generateFileNameWithSequence(p_outputDir,
+                                                            QFileInfo(p_file->getName()).completeBaseName(),
+                                                            p_option.m_customOption->m_targetSuffix);
+    auto destFilePath = PathUtils::concatenateFilePath(p_outputDir, fileName);
+
+    bool success = doExportCustom(p_option,
+                                  inputFiles,
+                                  resourcePaths,
+                                  destFilePath);
+    if (success) {
+        // Copy attachments if available.
+        if (p_option.m_exportAttachments) {
+            exportAttachments(p_file->getNode(), p_file->getFilePath(), p_outputDir, destFilePath);
+        }
+
+        return destFilePath;
+    }
+
+    return QString();
+}
+
 ExportOption Exporter::getExportOptionForIntermediateHtml(const ExportOption &p_option, const QString &p_outputDir)
 {
     ExportOption tmpOption(p_option);
+    tmpOption.m_exportAttachments = false;
     tmpOption.m_targetFormat = ExportFormat::HTML;
+    tmpOption.m_transformSvgToPngEnabled = true;
+    tmpOption.m_removeCodeToolBarEnabled = true;
+
     tmpOption.m_htmlOption.m_embedStyles = true;
     tmpOption.m_htmlOption.m_completePage = true;
     tmpOption.m_htmlOption.m_embedImages = false;
+    tmpOption.m_htmlOption.m_useMimeHtmlFormat = false;
+    tmpOption.m_htmlOption.m_addOutlinePanel = false;
     tmpOption.m_htmlOption.m_scrollable = false;
+    if (p_option.m_targetFormat == ExportFormat::Custom && p_option.m_customOption->m_targetPageScrollable) {
+        tmpOption.m_htmlOption.m_scrollable = true;
+    }
     tmpOption.m_outputDir = p_outputDir;
-
     return tmpOption;
 }
+
+bool Exporter::doExportCustom(const ExportOption &p_option,
+                              const QStringList &p_files,
+                              const QStringList &p_resourcePaths,
+                              const QString &p_filePath)
+{
+    const auto cmd = evaluateCommand(p_option,
+                                     p_files,
+                                     p_resourcePaths,
+                                     p_filePath);
+
+    emit logRequested(tr("Custom command: %1").arg(cmd));
+    qDebug() << "custom export" << cmd;
+
+    auto state = ProcessUtils::start(cmd,
+                                     [this](const QString &msg) {
+                                         emit logRequested(msg);
+                                     },
+                                     m_askedToStop);
+
+    return state == ProcessUtils::Succeeded;
+}
+
+QString Exporter::evaluateCommand(const ExportOption &p_option,
+                                  const QStringList &p_files,
+                                  const QStringList &p_resourcePaths,
+                                  const QString &p_filePath)
+{
+    auto cmd(p_option.m_customOption->m_command);
+
+    QString inputs;
+    for (int i = 0; i < p_files.size(); ++i) {
+        if (i > 0) {
+            inputs += " ";
+        }
+
+        inputs += getQuotedPath(p_files[i]);
+    }
+
+    QString resourcePath;
+    for (int i = 0; i < p_resourcePaths.size(); ++i) {
+        bool duplicated = false;
+        for (int j = 0; j < i; ++j) {
+            if (p_resourcePaths[j] == p_resourcePaths[i]) {
+                // Deduplicate.
+                duplicated = true;
+                break;
+            }
+        }
+
+        if (duplicated) {
+            continue;
+        }
+
+        if (i > 0) {
+            resourcePath += p_option.m_customOption->m_resourcePathSeparator;
+        }
+
+        resourcePath += getQuotedPath(p_resourcePaths[i]);
+    }
+
+    cmd.replace("%1", inputs);
+    cmd.replace("%2", resourcePath);
+    cmd.replace("%3", getQuotedPath(p_option.m_renderingStyleFile));
+    cmd.replace("%4", getQuotedPath(p_option.m_syntaxHighlightStyleFile));
+    cmd.replace("%5", getQuotedPath(p_filePath));
+    return cmd;
+}
+
+QString Exporter::getQuotedPath(const QString &p_path)
+{
+    return QString("\"%1\"").arg(QDir::toNativeSeparators(p_path));
+}
+
+void Exporter::collectFiles(const QList<QSharedPointer<File>> &p_files, QStringList &p_inputFiles, QStringList &p_resourcePaths)
+{
+    for (const auto &file : p_files) {
+        p_inputFiles << file->getContentPath();
+        p_resourcePaths << file->getResourcePath();
+    }
+}

+ 19 - 2
src/export/exporter.h

@@ -50,9 +50,17 @@ namespace vnotex
 
         QString doExportPdf(const ExportOption &p_option, const QString &p_outputDir, const File *p_file);
 
-        QString doExportAllInOne(const ExportOption &p_option, Node *p_folder);
+        QString doExportCustom(const ExportOption &p_option, const QString &p_outputDir, const File *p_file);
 
-        QString doExportAllInOne(const ExportOption &p_option, Notebook *p_notebook);
+        bool doExportCustom(const ExportOption &p_option,
+                            const QStringList &p_files,
+                            const QStringList &p_resourcePaths,
+                            const QString &p_filePath);
+
+        // Export @p_notebook or @p_folder. @p_folder will be considered only when @p_notebook is null.
+        QString doExportPdfAllInOne(const ExportOption &p_option, Notebook *p_notebook, Node *p_folder);
+
+        QString doExportCustomAllInOne(const ExportOption &p_option, Notebook *p_notebook, Node *p_folder);
 
         void exportAttachments(Node *p_node,
                                const QString &p_srcFilePath,
@@ -71,6 +79,15 @@ namespace vnotex
 
         static ExportOption getExportOptionForIntermediateHtml(const ExportOption &p_option, const QString &p_outputDir);
 
+        static QString evaluateCommand(const ExportOption &p_option,
+                                       const QStringList &p_files,
+                                       const QStringList &p_resourcePaths,
+                                       const QString &p_filePath);
+
+        static QString getQuotedPath(const QString &p_path);
+
+        static void collectFiles(const QList<QSharedPointer<File>> &p_files, QStringList &p_inputFiles, QStringList &p_resourcePaths);
+
         // Managed by QObject.
         WebViewExporter *m_webViewExporter = nullptr;
 

+ 18 - 10
src/export/webviewexporter.cpp

@@ -248,6 +248,8 @@ QSize WebViewExporter::pageLayoutSize(const QPageLayout &p_layout) const
 void WebViewExporter::prepare(const ExportOption &p_option)
 {
     Q_ASSERT(!m_viewer && !m_exportOngoing);
+    Q_ASSERT(p_option.m_targetFormat == ExportFormat::PDF || p_option.m_targetFormat == ExportFormat::HTML);
+
     {
         // Adapter will be managed by MarkdownViewer.
         auto adapter = new MarkdownViewerAdapter(this);
@@ -265,7 +267,8 @@ void WebViewExporter::prepare(const ExportOption &p_option)
 
     bool scrollable = true;
     if (p_option.m_targetFormat == ExportFormat::PDF
-        || (p_option.m_targetFormat == ExportFormat::HTML && !p_option.m_htmlOption.m_scrollable)) {
+        || (p_option.m_targetFormat == ExportFormat::HTML && !p_option.m_htmlOption.m_scrollable)
+        || (p_option.m_targetFormat == ExportFormat::Custom && !p_option.m_customOption->m_targetPageScrollable)) {
         scrollable = false;
     }
 
@@ -276,16 +279,21 @@ void WebViewExporter::prepare(const ExportOption &p_option)
         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);
+
+    HtmlTemplateHelper::Paras paras;
+    paras.m_webStyleSheetFile = p_option.m_renderingStyleFile;
+    paras.m_highlightStyleSheetFile = p_option.m_syntaxHighlightStyleFile;
+    paras.m_transparentBackgroundEnabled = p_option.m_useTransparentBg;
+    paras.m_scrollable = scrollable;
+    paras.m_bodyWidth = pageBodySize.width();
+    paras.m_bodyHeight = pageBodySize.height();
+    paras.m_transformSvgToPngEnabled = p_option.m_transformSvgToPngEnabled;
+    paras.m_mathJaxScale = useWkhtmltopdf ? 2.5 : -1;
+    paras.m_removeCodeToolBarEnabled = p_option.m_removeCodeToolBarEnabled;
+
+    m_htmlTemplate = HtmlTemplateHelper::generateMarkdownViewerTemplate(config, paras);
 
     {
         const bool addOutlinePanel = p_option.m_targetFormat == ExportFormat::HTML && p_option.m_htmlOption.m_addOutlinePanel;

+ 20 - 5
src/utils/processutils.cpp

@@ -128,16 +128,31 @@ ProcessUtils::State ProcessUtils::start(const QString &p_program,
 {
     QProcess proc;
     proc.start(p_program, p_args);
+    return handleProcess(&proc, p_logger, p_askedToStop);
+}
+
+ProcessUtils::State ProcessUtils::start(const QString &p_command,
+                                        const std::function<void(const QString &)> &p_logger,
+                                        const bool &p_askedToStop)
+{
+    QProcess proc;
+    proc.start(p_command);
+    return handleProcess(&proc, p_logger, p_askedToStop);
+}
 
-    if (!proc.waitForStarted()) {
+ProcessUtils::State ProcessUtils::handleProcess(QProcess *p_process,
+                                                const std::function<void(const QString &)> &p_logger,
+                                                const bool &p_askedToStop)
+{
+    if (!p_process->waitForStarted()) {
         return State::FailedToStart;
     }
 
-    while (proc.state() != QProcess::NotRunning) {
+    while (p_process->state() != QProcess::NotRunning) {
         Utils::sleepWait(100);
 
-        auto outBa = proc.readAllStandardOutput();
-        auto errBa = proc.readAllStandardError();
+        auto outBa = p_process->readAllStandardOutput();
+        auto errBa = p_process->readAllStandardError();
         QString msg;
         if (!outBa.isEmpty()) {
             msg += QString::fromLocal8Bit(outBa);
@@ -154,7 +169,7 @@ ProcessUtils::State ProcessUtils::start(const QString &p_program,
         }
     }
 
-    return proc.exitStatus() == QProcess::NormalExit ? State::Succeeded : State::Crashed;
+    return p_process->exitStatus() == QProcess::NormalExit ? State::Succeeded : State::Crashed;
 }
 
 void ProcessUtils::startDetached(const QString &p_command)

+ 8 - 0
src/utils/processutils.h

@@ -35,6 +35,10 @@ namespace vnotex
                            const std::function<void(const QString &)> &p_logger,
                            const bool &p_askedToStop);
 
+        static State start(const QString &p_command,
+                           const std::function<void(const QString &)> &p_logger,
+                           const bool &p_askedToStop);
+
         static void startDetached(const QString &p_command);
 
         // Copied from QProcess code.
@@ -48,6 +52,10 @@ namespace vnotex
                                    int &p_exitCodeOnSuccess,
                                    QByteArray &p_stdOut,
                                    QByteArray &p_stdErr);
+
+        static State handleProcess(QProcess *p_process,
+                                   const std::function<void(const QString &)> &p_logger,
+                                   const bool &p_askedToStop);
     };
 }
 

+ 270 - 2
src/widgets/dialogs/exportdialog.cpp

@@ -18,6 +18,7 @@
 #include <QPrinter>
 #include <QPageSetupDialog>
 #include <QPageLayout>
+#include <QInputDialog>
 
 #include <notebook/notebook.h>
 #include <notebook/node.h>
@@ -33,6 +34,7 @@
 #include <utils/clipboardutils.h>
 #include <export/exporter.h>
 #include <widgets/locationinputwithbrowsebutton.h>
+#include <widgets/messageboxhelper.h>
 
 using namespace vnotex;
 
@@ -154,6 +156,10 @@ QGroupBox *ExportDialog::setupTargetGroup(QWidget *p_parent)
                         settings = AdvancedSettings::PDF;
                         break;
 
+                    case static_cast<int>(ExportFormat::Custom):
+                        settings = AdvancedSettings::Custom;
+                        break;
+
                     default:
                         break;
                     }
@@ -283,7 +289,9 @@ QString ExportDialog::getOutputDir() const
 void ExportDialog::initOptions()
 {
     // Read it from config.
-    m_option = ConfigMgr::getInst().getSessionConfig().getExportOption();
+    const auto &sessionConfig = ConfigMgr::getInst().getSessionConfig();
+    m_option = sessionConfig.getExportOption();
+    m_customOptions = sessionConfig.getCustomExportOptions();
 
     const auto &theme = VNoteX::getInst().getThemeMgr().getCurrentTheme();
     m_option.m_renderingStyleFile = theme.getFile(Theme::File::WebStyleSheet);
@@ -292,6 +300,10 @@ void ExportDialog::initOptions()
     if (m_option.m_outputDir.isEmpty()) {
         m_option.m_outputDir = getDefaultOutputDir();
     }
+
+    if (findCustomOption(m_option.m_customExport) == -1) {
+        m_option.m_customExport = m_customOptions.isEmpty() ? QString() : m_customOptions[0].m_name;
+    }
 }
 
 void ExportDialog::restoreFields(const ExportOption &p_option)
@@ -351,6 +363,10 @@ void ExportDialog::saveFields(ExportOption &p_option)
     if (m_advancedSettings[AdvancedSettings::PDF]) {
         saveFields(p_option.m_pdfOption);
     }
+
+    if (m_advancedSettings[AdvancedSettings::Custom]) {
+        saveCustomFields(p_option);
+    }
 }
 
 void ExportDialog::startExport()
@@ -417,6 +433,17 @@ int ExportDialog::doExport(ExportOption p_option)
             appendLog(tr("Please specify a valid wkhtmltopdf executable file (%1)").arg(wkExePath));
             return 0;
         }
+
+        p_option.m_transformSvgToPngEnabled = true;
+        p_option.m_removeCodeToolBarEnabled = true;
+    } else if (p_option.m_targetFormat == ExportFormat::Custom) {
+        int optIdx = findCustomOption(p_option.m_customExport);
+        if (optIdx == -1) {
+            appendLog(tr("Please specify a valid scheme"));
+            return 0;
+        }
+
+        p_option.m_customOption = &m_customOptions[optIdx];
     }
 
     int exportedFilesCount = 0;
@@ -562,6 +589,10 @@ void ExportDialog::showAdvancedSettings(AdvancedSettings p_settings)
         widget = getPdfAdvancedSettings();
         break;
 
+    case AdvancedSettings::Custom:
+        widget = getCustomAdvancedSettings();
+        break;
+
     default:
         break;
     }
@@ -640,7 +671,7 @@ QWidget *ExportDialog::getPdfAdvancedSettings()
         }
 
         {
-            m_allInOneCheckBox = WidgetsFactory::createCheckBox(tr("All In One"), widget);
+            m_allInOneCheckBox = WidgetsFactory::createCheckBox(tr("All-In-One"), widget);
             m_allInOneCheckBox->setToolTip(tr("Export all source files into one file"));
             connect(m_useWkhtmltopdfCheckBox, &QCheckBox::stateChanged,
                     this, [this](int p_state) {
@@ -686,6 +717,103 @@ QWidget *ExportDialog::getPdfAdvancedSettings()
     return m_advancedSettings[AdvancedSettings::PDF];
 }
 
+QWidget *ExportDialog::getCustomAdvancedSettings()
+{
+    if (!m_advancedSettings[AdvancedSettings::Custom]) {
+        QWidget *widget = new QWidget(m_advancedGroupBox);
+        auto layout = WidgetsFactory::createFormLayout(widget);
+        layout->setContentsMargins(0, 0, 0, 0);
+
+        {
+            auto schemeLayout = new QHBoxLayout();
+            layout->addRow(tr("Scheme:"), schemeLayout);
+
+            m_customExportComboBox = WidgetsFactory::createComboBox(widget);
+            m_customExportComboBox->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+            schemeLayout->addWidget(m_customExportComboBox, 1);
+
+            auto addBtn = new QPushButton(tr("New"), widget);
+            addBtn->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred);
+            connect(addBtn, &QPushButton::clicked,
+                    this, &ExportDialog::addCustomExportScheme);
+            schemeLayout->addWidget(addBtn);
+
+            auto delBtn = new QPushButton(tr("Delete"), widget);
+            delBtn->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred);
+            connect(delBtn, &QPushButton::clicked,
+                    this, &ExportDialog::removeCustomExportScheme);
+            schemeLayout->addWidget(delBtn);
+        }
+
+        {
+            m_targetSuffixLineEdit = WidgetsFactory::createLineEdit(widget);
+            m_targetSuffixLineEdit->setToolTip(tr("Suffix of the target file like docs/pdf/epub"));
+            m_targetSuffixLineEdit->setEnabled(false);
+            layout->addRow(tr("Target file suffix:"), m_targetSuffixLineEdit);
+        }
+
+        {
+            m_resourcePathSeparatorLineEdit = WidgetsFactory::createLineEdit(widget);
+            m_resourcePathSeparatorLineEdit->setToolTip(tr("Separator used to concatenate resource folder paths"));
+            m_resourcePathSeparatorLineEdit->setEnabled(false);
+            layout->addRow(tr("Resource path separator:"), m_resourcePathSeparatorLineEdit);
+        }
+
+        {
+            m_useHtmlInputCheckBox = WidgetsFactory::createCheckBox(tr("Use HTML format as input"), widget);
+            m_useHtmlInputCheckBox->setToolTip(tr("Convert to HTMl format first as the input of the custom export command"));
+            m_useHtmlInputCheckBox->setEnabled(false);
+            layout->addRow(m_useHtmlInputCheckBox);
+        }
+
+        {
+            m_allInOneCheckBox = WidgetsFactory::createCheckBox(tr("All-In-One"), widget);
+            m_allInOneCheckBox->setToolTip(tr("Export all source files into one file"));
+            m_allInOneCheckBox->setEnabled(false);
+            layout->addRow(m_allInOneCheckBox);
+        }
+
+        {
+            m_targetPageScrollableCheckBox = WidgetsFactory::createCheckBox(tr("Target page scrollable"), widget);
+            m_targetPageScrollableCheckBox->setToolTip(tr("Whether the page of the target file is scrollable"));
+            m_targetPageScrollableCheckBox->setEnabled(false);
+            layout->addRow(m_targetPageScrollableCheckBox);
+        }
+
+        {
+            auto usage = tr("%1: List of input files.\n"
+                            "%2: List of paths to search for images and other resources.\n"
+                            "%3: Path of rendering CSS style sheet.\n"
+                            "%4: Path of syntax highlighting CSS style sheet.\n"
+                            "%5: Path of output file.\n");
+            layout->addRow(tr("Command usage:"), new QLabel(usage, widget));
+        }
+
+        {
+            m_commandTextEdit = WidgetsFactory::createPlainTextEdit(widget);
+#if defined(Q_OS_WIN)
+            m_commandTextEdit->setPlaceholderText("pandoc.exe --resource-path=.;%2 --css=%3 --css=%4 -s -o %5 %1");
+#else
+            m_commandTextEdit->setPlaceholderText("pandoc --resource-path=.:%2 --css=%3 --css=%4 -s -o %5 %1");
+#endif
+            m_commandTextEdit->setMaximumHeight(m_commandTextEdit->minimumSizeHint().height());
+            m_commandTextEdit->setEnabled(false);
+            layout->addRow(tr("Command:"), m_commandTextEdit);
+        }
+
+        connect(m_customExportComboBox, QOverload<int>::of(&QComboBox::currentIndexChanged),
+                this, &ExportDialog::customExportCurrentSchemeChanged);
+
+        m_advancedGroupBox->layout()->addWidget(widget);
+
+        m_advancedSettings[AdvancedSettings::Custom] = widget;
+
+        restoreCustomFields(m_option);
+    }
+
+    return m_advancedSettings[AdvancedSettings::Custom];
+}
+
 void ExportDialog::restoreFields(const ExportPdfOption &p_option)
 {
     m_pageLayout = p_option.m_layout;
@@ -708,6 +836,42 @@ void ExportDialog::saveFields(ExportPdfOption &p_option)
     p_option.m_wkhtmltopdfArgs = m_wkhtmltopdfArgsLineEdit->text();
 }
 
+void ExportDialog::restoreCustomFields(const ExportOption &p_option)
+{
+    m_customExportComboBox->clear();
+    int curIndex = -1;
+    for (int i = 0; i < m_customOptions.size(); ++i) {
+        m_customExportComboBox->addItem(m_customOptions[i].m_name, m_customOptions[i].m_name);
+        if (m_customOptions[i].m_name == p_option.m_customExport) {
+            curIndex = i;
+        }
+    }
+    m_customExportComboBox->setCurrentIndex(curIndex);
+}
+
+void ExportDialog::saveCustomFields(ExportOption &p_option)
+{
+    p_option.m_customExport = m_customExportComboBox->currentData().toString();
+
+    int idx = findCustomOption(p_option.m_customExport);
+    if (idx > -1) {
+        auto &opt = m_customOptions[idx];
+        opt.m_targetSuffix = m_targetSuffixLineEdit->text();
+        opt.m_resourcePathSeparator = m_resourcePathSeparatorLineEdit->text();
+        opt.m_useHtmlInput = m_useHtmlInputCheckBox->isChecked();
+        opt.m_allInOne = m_allInOneCheckBox->isChecked();
+        opt.m_targetPageScrollable = m_targetPageScrollableCheckBox->isChecked();
+
+        opt.m_command = m_commandTextEdit->toPlainText().trimmed();
+        int lineIdx = opt.m_command.indexOf(QLatin1Char('\n'));
+        if (lineIdx > -1) {
+            opt.m_command = opt.m_command.left(lineIdx);
+        }
+
+        ConfigMgr::getInst().getSessionConfig().setCustomExportOptions(m_customOptions);
+    }
+}
+
 void ExportDialog::updatePageLayoutButtonLabel()
 {
     Q_ASSERT(m_pageLayout);
@@ -715,3 +879,107 @@ void ExportDialog::updatePageLayoutButtonLabel()
         QString("%1, %2").arg(m_pageLayout->pageSize().name(),
                               m_pageLayout->orientation() == QPageLayout::Portrait ? tr("Portrait") : tr("Landscape")));
 }
+
+int ExportDialog::findCustomOption(const QString &p_name) const
+{
+    if (p_name.isEmpty()) {
+        return -1;
+    }
+
+    for (int i = 0; i < m_customOptions.size(); ++i) {
+        if (m_customOptions[i].m_name == p_name) {
+            return i;
+        }
+    }
+
+    return -1;
+}
+
+void ExportDialog::addCustomExportScheme()
+{
+    QString name;
+    while (true) {
+        name = QInputDialog::getText(this, tr("New Custom Export Scheme"), tr("Scheme name:"));
+        if (name.isEmpty()) {
+            return;
+        }
+
+        if (findCustomOption(name) != -1) {
+            MessageBoxHelper::notify(MessageBoxHelper::Warning,
+                                     tr("Name conflicts with existing scheme."),
+                                     this);
+        } else {
+            break;
+        }
+    }
+
+    // Based on current scheme.
+    ExportCustomOption newOption;
+
+    {
+        int curIndex = findCustomOption(m_customExportComboBox->currentData().toString());
+        if (curIndex > -1) {
+            newOption = m_customOptions[curIndex];
+        }
+    }
+
+    newOption.m_name = name;
+    m_customOptions.append(newOption);
+    ConfigMgr::getInst().getSessionConfig().setCustomExportOptions(m_customOptions);
+
+    // Add it to combo box.
+    m_customExportComboBox->addItem(name, name);
+    m_customExportComboBox->setCurrentIndex(m_customExportComboBox->findData(name));
+}
+
+void ExportDialog::removeCustomExportScheme()
+{
+    auto name = m_customExportComboBox->currentData().toString();
+    if (name.isEmpty()) {
+        return;
+    }
+
+    int ret = MessageBoxHelper::questionOkCancel(MessageBoxHelper::Warning,
+                                                 tr("Delete scheme (%1)?").arg(name),
+                                                 QString(),
+                                                 QString(),
+                                                 this);
+    if (ret != QMessageBox::Ok) {
+        return;
+    }
+
+    int idx = findCustomOption(name);
+    Q_ASSERT(idx > -1);
+    m_customOptions.remove(idx);
+    ConfigMgr::getInst().getSessionConfig().setCustomExportOptions(m_customOptions);
+
+    m_customExportComboBox->removeItem(m_customExportComboBox->currentIndex());
+}
+
+void ExportDialog::customExportCurrentSchemeChanged(int p_comboIdx)
+{
+    const bool enabled = p_comboIdx >= 0;
+    m_targetSuffixLineEdit->setEnabled(enabled);
+    m_resourcePathSeparatorLineEdit->setEnabled(enabled);
+    m_useHtmlInputCheckBox->setEnabled(enabled);
+    m_allInOneCheckBox->setEnabled(enabled);
+    m_targetPageScrollableCheckBox->setEnabled(enabled);
+    m_commandTextEdit->setEnabled(enabled);
+
+    if (p_comboIdx < 0) {
+        m_option.m_customExport.clear();
+        return;
+    }
+
+    auto name = m_customExportComboBox->currentData().toString();
+    m_option.m_customExport = name;
+    int curIndex = findCustomOption(name);
+    Q_ASSERT(curIndex > -1);
+    const auto &opt = m_customOptions[curIndex];
+    m_targetSuffixLineEdit->setText(opt.m_targetSuffix);
+    m_resourcePathSeparatorLineEdit->setText(opt.m_resourcePathSeparator);
+    m_useHtmlInputCheckBox->setChecked(opt.m_useHtmlInput);
+    m_allInOneCheckBox->setChecked(opt.m_allInOne);
+    m_targetPageScrollableCheckBox->setChecked(opt.m_targetPageScrollable);
+    m_commandTextEdit->setPlainText(opt.m_command);
+}

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

@@ -43,12 +43,15 @@ namespace vnotex
 
         void appendLog(const QString &p_log);
 
+        void customExportCurrentSchemeChanged(int p_comboIdx);
+
     private:
         enum AdvancedSettings
         {
             General,
             HTML,
             PDF,
+            Custom,
             Max
         };
 
@@ -66,6 +69,8 @@ namespace vnotex
 
         QWidget *getPdfAdvancedSettings();
 
+        QWidget *getCustomAdvancedSettings();
+
         void showAdvancedSettings(AdvancedSettings p_settings);
 
         void setupButtonBox();
@@ -86,6 +91,10 @@ namespace vnotex
 
         void saveFields(ExportPdfOption &p_option);
 
+        void restoreCustomFields(const ExportOption &p_option);
+
+        void saveCustomFields(ExportOption &p_option);
+
         void startExport();
 
         void updateUIOnExport();
@@ -99,6 +108,12 @@ namespace vnotex
 
         void updatePageLayoutButtonLabel();
 
+        int findCustomOption(const QString &p_name) const;
+
+        void addCustomExportScheme();
+
+        void removeCustomExportScheme();
+
         // Managed by QObject.
         Exporter *m_exporter = nullptr;
 
@@ -170,7 +185,24 @@ namespace vnotex
 
         QSharedPointer<QPageLayout> m_pageLayout;
 
+        // Custom settings.
+        QComboBox *m_customExportComboBox = nullptr;
+
+        QLineEdit *m_targetSuffixLineEdit = nullptr;
+
+        QLineEdit *m_resourcePathSeparatorLineEdit = nullptr;
+
+        QCheckBox *m_useHtmlInputCheckBox = nullptr;
+
+        QCheckBox *m_customAllInOneCheckBox = nullptr;
+
+        QCheckBox *m_targetPageScrollableCheckBox = nullptr;
+
+        QPlainTextEdit *m_commandTextEdit = nullptr;
+
         ExportOption m_option;
+
+        QVector<ExportCustomOption> m_customOptions;
     };
 }
 

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

@@ -52,7 +52,7 @@ void ImportFolderUtils::importFolderContentsByLegacyConfig(Notebook *p_notebook,
     try {
         config = LegacyNotebookUtils::getFolderConfig(rootDir.absolutePath());
     } catch (Exception &p_e) {
-        Utils::appendMsg(p_errMsg, ImportFolderUtilsTranslate::tr("Failed to read folder config (%1).").arg(rootDir.absolutePath()));
+        Utils::appendMsg(p_errMsg, ImportFolderUtilsTranslate::tr("Failed to read folder config (%1) (%2).").arg(rootDir.absolutePath(), p_e.what()));
         return;
     }
 

+ 1 - 0
src/widgets/widgetsfactory.cpp

@@ -109,5 +109,6 @@ QPlainTextEdit *WidgetsFactory::createPlainTextConsole(QWidget *p_parent)
 QPlainTextEdit *WidgetsFactory::createPlainTextEdit(QWidget *p_parent)
 {
     auto edit = new QPlainTextEdit(p_parent);
+    edit->setProperty("ConsoleTextEdit", true);
     return edit;
 }