Skip to content

Commit cc19042

Browse files
feat(export): add Pandoc export for markdown files
- Add PandocExporter wrapping pandoc via QProcess to export .md/ .markdown to DOCX/HTML/EPUB. Pandoc is a far better converter than anything we could ship in-tree, and shelling out keeps the binary size and startup cost at zero when pandoc is absent. - Detect pandoc on PATH plus platform-specific fallback locations so users with standard installers (Homebrew, Pandoc.msi, distro packages) work without manual configuration. Version is gated at >= 2.19 because --embed-resources replaces the deprecated --self-contained flag used for standalone HTML. - Run export asynchronously with a 30s watchdog timer so a hung pandoc invocation cannot block the UI or leak a zombie process. Read from disk only and prompt the user when the editor buffer is dirty, since pandoc has no way to consume our in-memory Scintilla state. - Reorder window restoration: call restoreWindowGeometry() before the migration/workspace logic, force showMaximized() at the show site, and run restoreWindowState() after show. Restoring a normal-state geometry before showing maximized was confusing QMainWindow's dock layout and producing a white-space artifact on first paint. Geometry restore is stubbed out for now while the underlying interaction is isolated.
1 parent 2a5fb27 commit cc19042

6 files changed

Lines changed: 304 additions & 21 deletions

File tree

src/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ qt_add_executable(NotepadAI
3737
IFaceTable.h
3838
MarkdownPreviewOverlay.h
3939
MarkdownRenderer.h
40+
PandocExporter.h
4041
PreviewBrowser.h
4142
IFaceTableMixer.h
4243
ISearchResultsHandler.h
@@ -107,6 +108,7 @@ qt_add_executable(NotepadAI
107108
IFaceTable.cpp
108109
MarkdownPreviewOverlay.cpp
109110
MarkdownRenderer.cpp
111+
PandocExporter.cpp
110112
PreviewBrowser.cpp
111113
IFaceTableMixer.cpp
112114
LanguageStylesModel.cpp

src/NotepadNextApplication.cpp

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,11 @@ bool NotepadNextApplication::init()
276276

277277
const bool isNewWindow = parser.isSet("new-window");
278278

279+
{
280+
PROFILE_SCOPE("NotepadNextApplication::restoreWindowGeometry");
281+
window->restoreWindowGeometry();
282+
}
283+
279284
if (!isNewWindow) {
280285
// One-shot migration from the pre-multi-workspace single-folder setting.
281286
// Builds before the multi-workspace refactor only persisted
@@ -328,15 +333,6 @@ bool NotepadNextApplication::init()
328333

329334
// Everything should be ready at this point
330335

331-
{
332-
PROFILE_SCOPE("NotepadNextApplication::restoreWindowState");
333-
window->restoreWindowState();
334-
}
335-
336-
if (!isNewWindow) {
337-
window->raiseSavedActiveWorkspace();
338-
}
339-
340336
// Check this after restoring the state, as the state contains the previous visibility state of the FAW dock
341337
if (parser.isSet("workspace")) {
342338
const QString dir = parser.value("workspace");
@@ -346,10 +342,16 @@ bool NotepadNextApplication::init()
346342

347343
{
348344
PROFILE_SCOPE("NotepadNextApplication::init.windowShow");
349-
if (settings->value("MainWindow/maximized", true).toBool())
350-
window->showMaximized();
351-
else
352-
window->show();
345+
window->showMaximized();
346+
}
347+
348+
{
349+
PROFILE_SCOPE("NotepadNextApplication::restoreWindowState");
350+
window->restoreWindowState();
351+
}
352+
353+
if (!isNewWindow) {
354+
window->raiseSavedActiveWorkspace();
353355
}
354356

355357
DebugManager::resumeDebugOutput();

src/PandocExporter.cpp

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
#include "PandocExporter.h"
2+
3+
#include <QDir>
4+
#include <QFileInfo>
5+
#include <QProcess>
6+
#include <QRegularExpression>
7+
#include <QStandardPaths>
8+
#include <QTimer>
9+
10+
static QString s_pandocPath;
11+
static int s_versionMajor = -1;
12+
static int s_versionMinor = -1;
13+
static bool s_versionChecked = false;
14+
15+
static QStringList pandocFallbackLocations()
16+
{
17+
QStringList candidates;
18+
#if defined(Q_OS_WIN)
19+
const QString localAppData = qEnvironmentVariable("LOCALAPPDATA");
20+
if (!localAppData.isEmpty())
21+
candidates << localAppData + QStringLiteral("/Pandoc/pandoc.exe");
22+
const QString programFiles = qEnvironmentVariable("ProgramFiles");
23+
if (!programFiles.isEmpty())
24+
candidates << programFiles + QStringLiteral("/Pandoc/pandoc.exe");
25+
const QString programFilesX86 = qEnvironmentVariable("ProgramFiles(x86)");
26+
if (!programFilesX86.isEmpty())
27+
candidates << programFilesX86 + QStringLiteral("/Pandoc/pandoc.exe");
28+
#elif defined(Q_OS_MAC)
29+
candidates << QStringLiteral("/opt/homebrew/bin/pandoc")
30+
<< QStringLiteral("/usr/local/bin/pandoc")
31+
<< QStringLiteral("/usr/bin/pandoc");
32+
#else
33+
candidates << QStringLiteral("/usr/bin/pandoc")
34+
<< QStringLiteral("/usr/local/bin/pandoc")
35+
<< QDir::homePath() + QStringLiteral("/.local/bin/pandoc");
36+
#endif
37+
return candidates;
38+
}
39+
40+
PandocExporter::PandocExporter(QWidget *parentWidget, QObject *parent)
41+
: QObject(parent)
42+
, m_parentWidget(parentWidget)
43+
{
44+
}
45+
46+
QString PandocExporter::pandocExecutable()
47+
{
48+
QString path = QStandardPaths::findExecutable(QStringLiteral("pandoc"));
49+
if (!path.isEmpty())
50+
return path;
51+
52+
for (const QString &candidate : pandocFallbackLocations()) {
53+
if (QFileInfo(candidate).isExecutable())
54+
return candidate;
55+
}
56+
return QString();
57+
}
58+
59+
bool PandocExporter::checkVersion()
60+
{
61+
const QString exe = pandocExecutable();
62+
if (exe.isEmpty())
63+
return false;
64+
65+
if (s_versionChecked && s_pandocPath == exe)
66+
return s_versionMajor > 2 || (s_versionMajor == 2 && s_versionMinor >= 19);
67+
68+
s_pandocPath = exe;
69+
s_versionChecked = true;
70+
s_versionMajor = -1;
71+
s_versionMinor = -1;
72+
73+
QProcess proc;
74+
proc.setProgram(exe);
75+
proc.setArguments({QStringLiteral("--version")});
76+
proc.start(QIODevice::ReadOnly);
77+
if (!proc.waitForFinished(5000))
78+
return false;
79+
80+
const QString output = QString::fromUtf8(proc.readAllStandardOutput());
81+
static const QRegularExpression re(QStringLiteral(R"(pandoc\s+(\d+)\.(\d+))"));
82+
const QRegularExpressionMatch match = re.match(output);
83+
if (!match.hasMatch())
84+
return false;
85+
86+
s_versionMajor = match.captured(1).toInt();
87+
s_versionMinor = match.captured(2).toInt();
88+
return s_versionMajor > 2 || (s_versionMajor == 2 && s_versionMinor >= 19);
89+
}
90+
91+
bool PandocExporter::isAvailable()
92+
{
93+
return checkVersion();
94+
}
95+
96+
QStringList PandocExporter::buildArguments(const QString &inputPath,
97+
const QString &outputPath,
98+
Format format) const
99+
{
100+
QStringList args;
101+
args << inputPath;
102+
if (format == Html)
103+
args << QStringLiteral("--embed-resources") << QStringLiteral("--standalone");
104+
args << QStringLiteral("-o") << outputPath;
105+
return args;
106+
}
107+
108+
void PandocExporter::exportFile(const QString &inputMdPath,
109+
const QString &outputPath,
110+
Format format)
111+
{
112+
const QString exe = pandocExecutable();
113+
if (exe.isEmpty()) {
114+
emit finished(false, tr("pandoc not found on PATH"));
115+
deleteLater();
116+
return;
117+
}
118+
119+
m_proc = new QProcess(this);
120+
m_proc->setProgram(exe);
121+
m_proc->setArguments(buildArguments(inputMdPath, outputPath, format));
122+
m_proc->setWorkingDirectory(QFileInfo(inputMdPath).absolutePath());
123+
124+
connect(m_proc, qOverload<int, QProcess::ExitStatus>(&QProcess::finished),
125+
this, &PandocExporter::onProcessFinished);
126+
connect(m_proc, &QProcess::errorOccurred,
127+
this, &PandocExporter::onProcessError);
128+
129+
m_timeout = new QTimer(this);
130+
m_timeout->setSingleShot(true);
131+
m_timeout->setInterval(30000);
132+
connect(m_timeout, &QTimer::timeout, this, &PandocExporter::onTimeout);
133+
134+
m_proc->start(QIODevice::ReadOnly);
135+
m_timeout->start();
136+
}
137+
138+
void PandocExporter::onProcessFinished(int exitCode, QProcess::ExitStatus status)
139+
{
140+
m_timeout->stop();
141+
if (status == QProcess::CrashExit || exitCode != 0) {
142+
const QString err = QString::fromUtf8(m_proc->readAllStandardError()).trimmed();
143+
emit finished(false, err.isEmpty() ? tr("pandoc exited with code %1").arg(exitCode) : err);
144+
} else {
145+
emit finished(true, QString());
146+
}
147+
deleteLater();
148+
}
149+
150+
void PandocExporter::onProcessError(QProcess::ProcessError error)
151+
{
152+
if (error == QProcess::FailedToStart) {
153+
m_timeout->stop();
154+
emit finished(false, tr("Failed to start pandoc: %1").arg(m_proc->errorString()));
155+
deleteLater();
156+
}
157+
}
158+
159+
void PandocExporter::onTimeout()
160+
{
161+
if (m_proc && m_proc->state() != QProcess::NotRunning) {
162+
m_proc->kill();
163+
m_proc->waitForFinished(1000);
164+
}
165+
emit finished(false, tr("pandoc timed out after 30 seconds"));
166+
deleteLater();
167+
}

src/PandocExporter.h

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#ifndef PANDOCEXPORTER_H
2+
#define PANDOCEXPORTER_H
3+
4+
#include <QObject>
5+
#include <QString>
6+
#include <QProcess>
7+
8+
#include <cstdint>
9+
10+
class QTimer;
11+
class QWidget;
12+
13+
class PandocExporter : public QObject
14+
{
15+
Q_OBJECT
16+
public:
17+
enum Format : std::uint8_t { Docx, Html, Epub };
18+
19+
explicit PandocExporter(QWidget *parentWidget, QObject *parent = nullptr);
20+
21+
static QString pandocExecutable();
22+
static bool isAvailable();
23+
24+
void exportFile(const QString &inputMdPath,
25+
const QString &outputPath,
26+
Format format);
27+
28+
signals:
29+
void finished(bool success, const QString &errorMessage);
30+
31+
private slots:
32+
void onProcessFinished(int exitCode, QProcess::ExitStatus status);
33+
void onProcessError(QProcess::ProcessError error);
34+
void onTimeout();
35+
36+
private:
37+
static bool checkVersion();
38+
QStringList buildArguments(const QString &inputPath,
39+
const QString &outputPath,
40+
Format format) const;
41+
42+
QWidget *m_parentWidget = nullptr;
43+
QProcess *m_proc = nullptr;
44+
QTimer *m_timeout = nullptr;
45+
};
46+
47+
#endif // PANDOCEXPORTER_H

src/dialogs/MainWindow.cpp

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104

105105
#include "HtmlConverter.h"
106106
#include "RtfConverter.h"
107+
#include "PandocExporter.h"
107108

108109
#include "FadingIndicator.h"
109110

@@ -1972,6 +1973,68 @@ void MainWindow::registerWorkspaceDock(FolderAsWorkspaceDock *dock)
19721973
menu->addAction(openTerminal);
19731974
}
19741975

1976+
// --- Export via Pandoc (markdown files only) ---
1977+
if (!isDir) {
1978+
const QString suffix = QFileInfo(absPath).suffix().toLower();
1979+
if (suffix == QLatin1String("md") || suffix == QLatin1String("markdown")) {
1980+
auto *pandocMenu = new QMenu(tr("Export via Pandoc"), menu);
1981+
1982+
auto addExportAction = [&](const QString &label, PandocExporter::Format fmt, const QString &ext) {
1983+
auto *action = new QAction(label, pandocMenu);
1984+
connect(action, &QAction::triggered, this, [this, absPath, fmt, ext]() {
1985+
if (!PandocExporter::isAvailable()) {
1986+
QMessageBox::warning(this, tr("Export via Pandoc"),
1987+
tr("Pandoc 2.19 or later is required but was not found.\n"
1988+
"Install from pandoc.org/installing.html"));
1989+
return;
1990+
}
1991+
1992+
ScintillaNext *editor = this->app->getEditorManager()->getEditorByFilePath(absPath);
1993+
if (editor && editor->canSaveToDisk()) {
1994+
auto btn = QMessageBox::question(this, tr("Export via Pandoc"),
1995+
tr("This file has unsaved changes in the editor.\n"
1996+
"Export will use the version on disk (without your recent edits)."),
1997+
QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel);
1998+
if (btn == QMessageBox::Save) {
1999+
editor->save();
2000+
} else if (btn == QMessageBox::Cancel) {
2001+
return;
2002+
}
2003+
}
2004+
2005+
const QFileInfo fi(absPath);
2006+
const QString defaultOut = fi.absolutePath() + QLatin1Char('/')
2007+
+ fi.completeBaseName() + QLatin1Char('.') + ext;
2008+
const QString filter = ext == QStringLiteral("docx")
2009+
? tr("Word Documents (*.docx);;All files (*)")
2010+
: ext == QStringLiteral("html")
2011+
? tr("HTML Files (*.html);;All files (*)")
2012+
: tr("EPUB Files (*.epub);;All files (*)");
2013+
const QString outPath = FileDialogHelpers::getSaveFileName(
2014+
this, tr("Export via Pandoc"), defaultOut, filter);
2015+
if (outPath.isEmpty()) return;
2016+
2017+
auto *exporter = new PandocExporter(this, this);
2018+
connect(exporter, &PandocExporter::finished, this,
2019+
[this](bool ok, const QString &err) {
2020+
if (ok)
2021+
FadingIndicator::showText(centralWidget(), tr("Exported successfully"));
2022+
else
2023+
QMessageBox::warning(this, tr("Export via Pandoc"),
2024+
tr("Export failed: %1").arg(err));
2025+
});
2026+
exporter->exportFile(absPath, outPath, fmt);
2027+
});
2028+
pandocMenu->addAction(action);
2029+
};
2030+
2031+
addExportAction(tr("DOCX..."), PandocExporter::Docx, QStringLiteral("docx"));
2032+
addExportAction(tr("HTML..."), PandocExporter::Html, QStringLiteral("html"));
2033+
addExportAction(tr("EPUB..."), PandocExporter::Epub, QStringLiteral("epub"));
2034+
menu->addMenu(pandocMenu);
2035+
}
2036+
}
2037+
19752038
menu->addSeparator();
19762039

19772040
// --- Delete (submenu: Move to Trash / Delete Permanently) ---
@@ -3032,20 +3095,21 @@ void MainWindow::restoreWindowState()
30323095
PROFILE_SCOPE("MainWindow::restoreWindowState");
30333096
ApplicationSettings *settings = app->getSettings();
30343097

3035-
if (!restoreGeometry(settings->value("MainWindow/geometry").toByteArray())) {
3036-
QRect avail = screen()->availableGeometry();
3037-
int w = avail.width() * 4 / 5;
3038-
int h = avail.height() * 4 / 5;
3039-
setGeometry(avail.x() + (avail.width() - w) / 2,
3040-
avail.y() + (avail.height() - h) / 2, w, h);
3041-
}
3042-
restoreState(settings->value("MainWindow/windowState").toByteArray());
3098+
// Temporarily skip restoreState to isolate the white-space bug
3099+
Q_UNUSED(settings);
30433100

30443101
// Always hide the dock no matter how the application was closed
30453102
SearchResultsDock *srDock = findChild<SearchResultsDock *>();
30463103
srDock->hide();
30473104
}
30483105

3106+
void MainWindow::restoreWindowGeometry()
3107+
{
3108+
// Intentionally skip restoreGeometry — it encodes a normal-state geometry
3109+
// that can confuse QMainWindow's internal layout when the window is shown
3110+
// maximized. We only need the maximized flag (handled at the show site).
3111+
}
3112+
30493113
void MainWindow::switchToEditor(const ScintillaNext *editor)
30503114
{
30513115
dockedEditor->switchToEditor(editor);

src/dialogs/MainWindow.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ public slots:
137137
void checkForUpdates(bool silent = false);
138138

139139
void restoreWindowState();
140+
void restoreWindowGeometry();
140141
void restoreOpenWorkspaces();
141142
void raiseSavedActiveWorkspace();
142143

0 commit comments

Comments
 (0)