diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 10c595c63..0c4f9c23f 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -85,6 +85,7 @@ qt5_add_resources(Mapper_RESOURCES_RCC ${Mapper_RESOURCES} OPTIONS -no-compress)
# (To be used by Mapper executable and by system tests.)
set(Mapper_Common_SRCS
+ cli.cpp
global.cpp
mapper_resource.cpp
settings.cpp
diff --git a/src/cli.cpp b/src/cli.cpp
new file mode 100644
index 000000000..03789cf37
--- /dev/null
+++ b/src/cli.cpp
@@ -0,0 +1,385 @@
+/*
+ * Copyright 2026 Michael Behrens
+ *
+ * This file is part of OpenOrienteering.
+ *
+ * OpenOrienteering is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * OpenOrienteering is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with OpenOrienteering. If not, see .
+ */
+
+#include "cli.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "core/map.h"
+#include "core/map_printer.h"
+#include "fileformats/file_format.h"
+#include "fileformats/file_format_registry.h"
+#include "fileformats/file_import_export.h"
+
+#ifdef QT_PRINTSUPPORT_LIB
+#include
+#include
+#include
+#include "core/map_printer.h"
+#endif
+
+
+namespace OpenOrienteering {
+
+namespace {
+
+
+bool exportViaFileFormat(const QString& output_path, const Map& map, const QString& format_id)
+{
+ const FileFormat* format = nullptr;
+ if (!format_id.isEmpty())
+ format = FileFormats.findFormat(format_id.toLatin1().constData());
+ else
+ format = FileFormats.findFormatForFilename(output_path, &FileFormat::supportsWriting);
+
+ if (!format)
+ {
+ return false;
+ }
+
+ auto exporter = format->makeExporter(output_path, &map, nullptr);
+ if (!exporter)
+ return false;
+
+ return exporter->doExport();
+}
+
+
+#ifdef QT_PRINTSUPPORT_LIB
+
+bool exportViaPdf(const QString& output_path, Map& map, const QRectF& print_area, int dpi = 300)
+{
+ if (print_area.isEmpty())
+ {
+ fprintf(stderr, "Error: map has no extent\n");
+ return false;
+ }
+
+ MapPrinter printer(map, nullptr);
+ printer.setTarget(MapPrinter::pdfTarget());
+ printer.setPrintArea(print_area);
+ printer.setCustomPageSize(print_area.size());
+ printer.setResolution(dpi);
+
+ auto qprinter = printer.makePrinter();
+ if (!qprinter)
+ {
+ fprintf(stderr, "Error: failed to create PDF printer.\n");
+ return false;
+ }
+
+ qprinter->setOutputFileName(output_path);
+
+ // setOutputFileName may reset the page size, re-apply it
+ qprinter->setFullPage(true);
+ qprinter->setPageMargins(QMarginsF(0, 0, 0, 0), QPageLayout::Millimeter);
+ qprinter->setPaperSize(printer.getPrintAreaPaperSize(), QPrinter::Millimeter);
+
+ QPainter painter(qprinter.get());
+ printer.drawPage(&painter, printer.getPrintArea());
+ painter.end();
+ return true;
+}
+
+
+bool exportViaImage(const QString& output_path, Map& map, const QRectF& print_area, int dpi = 300, const char* image_format = nullptr)
+{
+ if (print_area.isEmpty())
+ {
+ fprintf(stderr, "Error: map has no extent\n");
+ return false;
+ }
+
+ MapPrinter printer(map, nullptr);
+ printer.setTarget(MapPrinter::imageTarget());
+ printer.setPrintArea(print_area);
+ printer.setResolution(dpi);
+
+ auto const& options = printer.getOptions();
+ qreal pixel_per_mm = options.resolution / 25.4;
+ QSizeF mm_size = printer.getPrintAreaPaperSize();
+ int w = qMax(1, qRound(mm_size.width() * pixel_per_mm));
+ int h = qMax(1, qRound(mm_size.height() * pixel_per_mm));
+
+ QImage image(w, h, QImage::Format_ARGB32_Premultiplied);
+ if (image.isNull())
+ {
+ fprintf(stderr, "Error: not enough memory for image of size %dx%d\n", w, h);
+ return false;
+ }
+ image.fill(Qt::white);
+ image.setDotsPerMeterX(qRound(pixel_per_mm * 1000));
+ image.setDotsPerMeterY(qRound(pixel_per_mm * 1000));
+
+ QPainter p(&image);
+ printer.drawPage(&p, printer.getPrintArea(), &image);
+ p.end();
+
+ if (!image.save(output_path, image_format))
+ {
+ fprintf(stderr, "Error: failed to save image to '%s'\n", qPrintable(output_path));
+ return false;
+ }
+ return true;
+}
+
+#endif
+
+
+int runExport(const QStringList& sub_args)
+{
+ QCommandLineParser parser;
+ parser.setApplicationDescription(QStringLiteral("Export map to printable and GIS formats"));
+
+ QCommandLineOption input_option(
+ QStringList{QStringLiteral("i"), QStringLiteral("input")},
+ QStringLiteral("Input map file."),
+ QStringLiteral("path"));
+ parser.addOption(input_option);
+
+ QCommandLineOption output_option(
+ QStringList{QStringLiteral("o"), QStringLiteral("output")},
+ QStringLiteral("Output file path."),
+ QStringLiteral("path"));
+ parser.addOption(output_option);
+
+ QCommandLineOption format_option(
+ QStringLiteral("output-format"),
+ QStringLiteral("Output format ID (e.g. pdf, png, jpg)."),
+ QStringLiteral("id"));
+ parser.addOption(format_option);
+
+ QCommandLineOption full_map_option(QStringLiteral("full-map"), QStringLiteral("Export the full map extent instead of the saved print area."));
+ parser.addOption(full_map_option);
+
+ QCommandLineOption dpi_option(
+ QStringLiteral("dpi"),
+ QStringLiteral("Output resolution in DPI (default: 300)."),
+ QStringLiteral("dpi"),
+ QStringLiteral("300"));
+ parser.addOption(dpi_option);
+
+ parser.addHelpOption();
+
+ // QCommandLineParser requires the program name as first argument
+ QStringList cli_args = {QStringLiteral("mapper")};
+ cli_args.append(sub_args);
+
+ if (!parser.parse(cli_args))
+ {
+ fprintf(stderr, "Error: %s\n", qPrintable(parser.errorText()));
+ return 1;
+ }
+
+ if (parser.isSet(QStringLiteral("help")))
+ {
+ fprintf(stderr, "%s", qPrintable(parser.helpText()));
+ return 0;
+ }
+
+ if (!parser.isSet(input_option))
+ {
+ fprintf(stderr, "Error: --input / -i is required.\n");
+ return 1;
+ }
+ QString input_path = parser.value(input_option);
+
+ if (!parser.isSet(output_option))
+ {
+ fprintf(stderr, "Error: --output / -o is required.\n");
+ return 1;
+ }
+ QString output_path = parser.value(output_option);
+
+ QString format_id = parser.value(format_option);
+
+ int dpi = parser.value(dpi_option).toInt();
+
+ Map map;
+ if (!map.loadFrom(input_path))
+ {
+ fprintf(stderr, "Error: failed to load map from '%s'\n", qPrintable(input_path));
+ return 1;
+ }
+
+ QRectF print_area = parser.isSet(full_map_option)
+ ? map.calculateExtent()
+ : map.printerConfig().print_area;
+ if (print_area.isEmpty())
+ {
+ fprintf(stderr, "Error: map has no content in the selected print area.\n");
+ return 1;
+ }
+
+ // Determine the format key: use format_id if set, otherwise the file extension
+ QString format_key = format_id.isEmpty() ? QFileInfo(output_path).suffix().toLower() : format_id.toLower();
+
+ // Try MapPrinter for PDF and image formats (always, regardless of format_id)
+#ifdef QT_PRINTSUPPORT_LIB
+ if (format_key == QStringLiteral("pdf"))
+ {
+ if (exportViaPdf(output_path, map, print_area, dpi))
+ return 0;
+ fprintf(stderr, "Error: PDF export failed.\n");
+ return 1;
+ }
+
+ if (QImageWriter::supportedImageFormats().contains(format_key.toLatin1()))
+ {
+ // Pass explicit format when format_id is set, so QImage::save
+ // uses the requested format regardless of file extension.
+ QByteArray format_key_latin;
+ const char* img_fmt = nullptr;
+ if (!format_id.isEmpty())
+ {
+ format_key_latin = format_key.toLatin1();
+ img_fmt = format_key_latin.constData();
+ }
+ if (exportViaImage(output_path, map, print_area, dpi, img_fmt))
+ return 0;
+ fprintf(stderr, "Error: image export failed.\n");
+ return 1;
+ }
+#endif
+
+ fprintf(stderr, "Error: no exporter found for '%s'.\n", qPrintable(output_path));
+ return 1;
+}
+
+int runConvert(const QStringList& sub_args)
+{
+ QCommandLineParser parser;
+ parser.setApplicationDescription(QStringLiteral("Convert between orienteering map formats"));
+
+ QCommandLineOption input_option(
+ QStringList{QStringLiteral("i"), QStringLiteral("input")},
+ QStringLiteral("Input map file."),
+ QStringLiteral("path"));
+ parser.addOption(input_option);
+
+ QCommandLineOption output_option(
+ QStringList{QStringLiteral("o"), QStringLiteral("output")},
+ QStringLiteral("Output file path."),
+ QStringLiteral("path"));
+ parser.addOption(output_option);
+
+ QCommandLineOption format_option(
+ QStringLiteral("output-format"),
+ QStringLiteral("Output format ID (e.g. XML, OCD, OCD12)."),
+ QStringLiteral("id"),
+ QString{});
+ parser.addOption(format_option);
+
+ parser.addHelpOption();
+
+ QStringList cli_args = {QStringLiteral("mapper")};
+ cli_args.append(sub_args);
+
+ if (!parser.parse(cli_args))
+ {
+ fprintf(stderr, "Error: %s\n", qPrintable(parser.errorText()));
+ return 1;
+ }
+
+ if (parser.isSet(QStringLiteral("help")))
+ {
+ fprintf(stderr, "%s", qPrintable(parser.helpText()));
+ return 0;
+ }
+
+ if (!parser.isSet(input_option))
+ {
+ fprintf(stderr, "Error: --input / -i is required.\n");
+ return 1;
+ }
+ QString input_path = parser.value(input_option);
+
+ if (!parser.isSet(output_option))
+ {
+ fprintf(stderr, "Error: --output / -o is required.\n");
+ return 1;
+ }
+ QString output_path = parser.value(output_option);
+
+ QString format_id = parser.value(format_option);
+
+ Map map;
+ if (!map.loadFrom(input_path))
+ {
+ fprintf(stderr, "Error: failed to load map from '%s'\n", qPrintable(input_path));
+ return 1;
+ }
+
+ if (exportViaFileFormat(output_path, map, format_id))
+ return 0;
+
+ fprintf(stderr, "Error: no exporter found for '%s'.\n", qPrintable(output_path));
+ return 1;
+}
+
+
+} // namespace
+
+
+int execCli(int argc, char** argv)
+{
+ if (argc < 3)
+ {
+ fprintf(stderr, "Error: no subcommand specified.\n");
+ return 1;
+ }
+
+ QString subcommand = QString::fromLocal8Bit(argv[2]);
+
+ QStringList sub_args;
+ for (int i = 3; i < argc; ++i)
+ sub_args << QString::fromLocal8Bit(argv[i]);
+
+ if (subcommand == QLatin1String("export"))
+ return runExport(sub_args);
+
+ if (subcommand == QLatin1String("convert"))
+ return runConvert(sub_args);
+
+ if (subcommand == QLatin1String("help") || subcommand == QLatin1String("--help"))
+ {
+ fprintf(stderr,
+ "Usage: %s --cli [options]\n\n"
+ "Available subcommands:\n"
+ " export Export map to printable and image formats\n"
+ " convert Convert between orienteering map formats\n"
+ " help Show this help\n",
+ argv[0]);
+ return 0;
+ }
+
+ fprintf(stderr, "Error: unknown subcommand '%s'. Available: export, convert\n", qPrintable(subcommand));
+ return 1;
+}
+
+
+} // namespace OpenOrienteering
diff --git a/src/cli.h b/src/cli.h
new file mode 100644
index 000000000..f939a4d87
--- /dev/null
+++ b/src/cli.h
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2026 Michael Behrens
+ *
+ * This file is part of OpenOrienteering.
+ *
+ * OpenOrienteering is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * OpenOrienteering is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with OpenOrienteering. If not, see .
+ */
+
+#ifndef OPENORIENTEERING_CLI_H
+#define OPENORIENTEERING_CLI_H
+
+namespace OpenOrienteering {
+
+int execCli(int argc, char** argv);
+
+} // namespace OpenOrienteering
+
+#endif
diff --git a/src/main.cpp b/src/main.cpp
index 19221c5d7..03266979d 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -20,6 +20,7 @@
#include
+#include
#include
#include
// IWYU pragma: no_include
@@ -51,6 +52,7 @@
#include "global.h"
#include "mapper_config.h"
#include "mapper_resource.h"
+#include "cli.h"
#include "gui/home_screen_controller.h"
#include "gui/main_window.h"
#include "gui/widgets/mapper_proxystyle.h"
@@ -125,6 +127,32 @@ void resetActivationWindow(QtSingleApplication& app)
int main(int argc, char** argv)
{
+ // Detect cli arg early, before creating any QApplication,
+ // so that cli works even when oom is already running.
+ int cli_pos = -1;
+ for (int i = 1; i < argc; ++i)
+ {
+ if (std::strcmp(argv[i], "--cli") == 0)
+ {
+ cli_pos = i;
+ break;
+ }
+ }
+
+ if (cli_pos > 0)
+ {
+ doStaticInitializations();
+ // On Linux, the cli may run without a display server (CI, headless).
+ // Default to offscreen rendering when no platform is explicitly set.
+ // A QGuiApplication is needed for pdf/image export.
+#if defined(Q_OS_LINUX)
+ if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM"))
+ qputenv("QT_QPA_PLATFORM", "offscreen");
+#endif
+ QGuiApplication cli_app(argc, argv);
+ return OpenOrienteering::execCli(argc, argv);
+ }
+
#ifdef MAPPER_USE_QTSINGLEAPPLICATION
// Create single-instance application.
// Use "oo-mapper" instead of the executable as identifier, in case we launch from different paths.
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index b1471a2f1..6b33c60af 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -190,6 +190,9 @@ add_unit_test(util_t ../src/util/util
add_system_test(coord_xml_t MANUAL)
# System tests
+if(NOT ANDROID)
+ add_system_test(cli_t)
+endif()
add_system_test(file_format_t)
add_system_test(duplicate_equals_t)
add_system_test(map_t)
diff --git a/test/cli_t.cpp b/test/cli_t.cpp
new file mode 100644
index 000000000..803ec2041
--- /dev/null
+++ b/test/cli_t.cpp
@@ -0,0 +1,396 @@
+/*
+ * Copyright 2026 Michael Behrens
+ *
+ * This file is part of OpenOrienteering.
+ *
+ * OpenOrienteering is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * OpenOrienteering is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with OpenOrienteering. If not, see .
+ */
+
+
+#include "cli_t.h"
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "cli.h"
+#include "global.h"
+#include "test_config.h"
+
+using namespace OpenOrienteering;
+
+
+namespace {
+
+/**
+ * Builds a mutable argv array from string literals and calls execCli.
+ *
+ * The caller must ensure the arguments remain valid for the duration of
+ * the call (they do when using string literals).
+ */
+int execCliFrom(std::initializer_list args)
+{
+ std::vector argv;
+ argv.reserve(args.size());
+ for (auto a : args)
+ argv.push_back(const_cast(a));
+ return execCli(int(argv.size()), argv.data());
+}
+
+} // namespace
+
+
+void CliTest::initTestCase()
+{
+ doStaticInitializations();
+
+ // Register a Qt search path for test data
+ QDir::addSearchPath(QStringLiteral("testdata"),
+ QDir(QString::fromUtf8(MAPPER_TEST_SOURCE_DIR))
+ .absoluteFilePath(QStringLiteral("data")));
+}
+
+
+void CliTest::testNoSubcommand()
+{
+ QCOMPARE(execCliFrom({"mapper", "--cli"}), 1);
+}
+
+
+void CliTest::testNonexistingSubcommand()
+{
+ QCOMPARE(execCliFrom({"mapper", "--cli", "nonexistingsubcommand"}), 1);
+}
+
+
+void CliTest::testExportMissingInput()
+{
+ QCOMPARE(execCliFrom({"mapper", "--cli", "export", "-o", "out.pdf"}), 1);
+}
+
+
+void CliTest::testExportMissingOutput()
+{
+ QCOMPARE(execCliFrom({"mapper", "--cli", "export", "-i", "in.omap"}), 1);
+}
+
+
+void CliTest::testExportInvalidInput()
+{
+ QCOMPARE(execCliFrom({"mapper", "--cli", "export", "-i", "nonexistent.omap", "-o", "out.pdf"}), 1);
+}
+
+
+void CliTest::testExportPdf()
+{
+ QTemporaryDir temp_dir;
+ QVERIFY(temp_dir.isValid());
+
+ auto input = QString::fromUtf8("testdata:/barrier.omap");
+ QVERIFY(QFileInfo::exists(input));
+
+ QString output = temp_dir.path() + QStringLiteral("/output.pdf");
+ auto input_bytes = input.toLocal8Bit();
+ auto output_bytes = output.toLocal8Bit();
+
+ int result = execCliFrom({
+ "mapper", "--cli", "export",
+ "-i", input_bytes.constData(),
+ "-o", output_bytes.constData(),
+ "--full-map"
+ });
+ QCOMPARE(result, 0);
+ QVERIFY(QFile::exists(output));
+ QVERIFY(QFileInfo(output).size() > 0);
+}
+
+
+void CliTest::testExportPng()
+{
+ QTemporaryDir temp_dir;
+ QVERIFY(temp_dir.isValid());
+
+ auto input = QString::fromUtf8("testdata:/barrier.omap");
+ QVERIFY(QFileInfo::exists(input));
+
+ QString output = temp_dir.path() + QStringLiteral("/output.png");
+ auto input_bytes = input.toLocal8Bit();
+ auto output_bytes = output.toLocal8Bit();
+
+ int result = execCliFrom({
+ "mapper", "--cli", "export",
+ "-i", input_bytes.constData(),
+ "-o", output_bytes.constData(),
+ "--full-map"
+ });
+ QCOMPARE(result, 0);
+ QVERIFY(QFile::exists(output));
+ QVERIFY(QFileInfo(output).size() > 0);
+}
+
+
+void CliTest::testExportFullMapFlag()
+{
+ QTemporaryDir temp_dir;
+ QVERIFY(temp_dir.isValid());
+
+ auto input = QString::fromUtf8("testdata:/barrier.omap");
+ QString output = temp_dir.path() + QStringLiteral("/output.pdf");
+ auto input_bytes = input.toLocal8Bit();
+ auto output_bytes = output.toLocal8Bit();
+
+ int result = execCliFrom({
+ "mapper", "--cli", "export",
+ "-i", input_bytes.constData(),
+ "-o", output_bytes.constData(),
+ "--full-map"
+ });
+ QCOMPARE(result, 0);
+ QVERIFY(QFile::exists(output));
+ QVERIFY(QFileInfo(output).size() > 0);
+}
+
+
+void CliTest::testConvertMissingInput()
+{
+ QCOMPARE(execCliFrom({"mapper", "--cli", "convert", "-o", "out.omap"}), 1);
+}
+
+
+void CliTest::testConvertMissingOutput()
+{
+ QCOMPARE(execCliFrom({"mapper", "--cli", "convert", "-i", "in.omap"}), 1);
+}
+
+
+void CliTest::testConvertInvalidInput()
+{
+ QCOMPARE(execCliFrom({"mapper", "--cli", "convert", "-i", "nonexistent.omap", "-o", "out.omap"}), 1);
+}
+
+
+void CliTest::testConvertOmapToXmap()
+{
+ QTemporaryDir temp_dir;
+ QVERIFY(temp_dir.isValid());
+
+ auto input = QString::fromUtf8("testdata:/barrier.omap");
+ QVERIFY(QFileInfo::exists(input));
+
+ QString output = temp_dir.path() + QStringLiteral("/output.xmap");
+ auto input_bytes = input.toLocal8Bit();
+ auto output_bytes = output.toLocal8Bit();
+
+ int result = execCliFrom({
+ "mapper", "--cli", "convert",
+ "-i", input_bytes.constData(),
+ "-o", output_bytes.constData()
+ });
+ QCOMPARE(result, 0);
+ QVERIFY(QFile::exists(output));
+ QVERIFY(QFileInfo(output).size() > 0);
+}
+
+
+void CliTest::testConvertXmapToOmap()
+{
+ QTemporaryDir temp_dir;
+ QVERIFY(temp_dir.isValid());
+
+ auto input = QString::fromUtf8("testdata:/barrier.omap");
+ QVERIFY(QFileInfo::exists(input));
+
+ QString xmap = temp_dir.path() + QStringLiteral("/intermediate.xmap");
+ QString omap = temp_dir.path() + QStringLiteral("/output.omap");
+ auto input_bytes = input.toLocal8Bit();
+ auto xmap_bytes = xmap.toLocal8Bit();
+ auto omap_bytes = omap.toLocal8Bit();
+
+ int r1 = execCliFrom({
+ "mapper", "--cli", "convert",
+ "-i", input_bytes.constData(),
+ "-o", xmap_bytes.constData()
+ });
+ QCOMPARE(r1, 0);
+ QVERIFY(QFile::exists(xmap));
+
+ int r2 = execCliFrom({
+ "mapper", "--cli", "convert",
+ "-i", xmap_bytes.constData(),
+ "-o", omap_bytes.constData()
+ });
+ QCOMPARE(r2, 0);
+ QVERIFY(QFile::exists(omap));
+ QVERIFY(QFileInfo(omap).size() > 0);
+}
+
+
+void CliTest::testConvertOmapToOcd()
+{
+ QTemporaryDir temp_dir;
+ QVERIFY(temp_dir.isValid());
+
+ auto input = QString::fromUtf8("testdata:/barrier.omap");
+ QVERIFY(QFileInfo::exists(input));
+
+ QString output = temp_dir.path() + QStringLiteral("/output.ocd");
+ auto input_bytes = input.toLocal8Bit();
+ auto output_bytes = output.toLocal8Bit();
+
+ int result = execCliFrom({
+ "mapper", "--cli", "convert",
+ "-i", input_bytes.constData(),
+ "-o", output_bytes.constData()
+ });
+ QCOMPARE(result, 0);
+ QVERIFY(QFile::exists(output));
+ QVERIFY(QFileInfo(output).size() > 0);
+}
+
+
+void CliTest::testConvertWithFormatId()
+{
+ QTemporaryDir temp_dir;
+ QVERIFY(temp_dir.isValid());
+
+ auto input = QString::fromUtf8("testdata:/barrier.omap");
+ QVERIFY(QFileInfo::exists(input));
+
+ QString output = temp_dir.path() + QStringLiteral("/output.xmap");
+ auto input_bytes = input.toLocal8Bit();
+ auto output_bytes = output.toLocal8Bit();
+
+ int result = execCliFrom({
+ "mapper", "--cli", "convert",
+ "-i", input_bytes.constData(),
+ "-o", output_bytes.constData(),
+ "--output-format", "XML"
+ });
+ QCOMPARE(result, 0);
+ QVERIFY(QFile::exists(output));
+ QVERIFY(QFileInfo(output).size() > 0);
+}
+
+
+void CliTest::testExportFormatIdOverridesExtension()
+{
+ QTemporaryDir temp_dir;
+ QVERIFY(temp_dir.isValid());
+
+ auto input = QString::fromUtf8("testdata:/barrier.omap");
+ QVERIFY(QFileInfo::exists(input));
+
+ // Write a PNG to a file named .pdf
+ QString output = temp_dir.path() + QStringLiteral("/output.pdf");
+ auto input_bytes = input.toLocal8Bit();
+ auto output_bytes = output.toLocal8Bit();
+
+ int result = execCliFrom({
+ "mapper", "--cli", "export",
+ "-i", input_bytes.constData(),
+ "-o", output_bytes.constData(),
+ "--output-format", "png",
+ "--full-map"
+ });
+ QCOMPARE(result, 0);
+ QVERIFY(QFile::exists(output));
+ QVERIFY(QFileInfo(output).size() > 0);
+}
+
+
+void CliTest::testConvertFormatIdOverridesExtension()
+{
+ QTemporaryDir temp_dir;
+ QVERIFY(temp_dir.isValid());
+
+ auto input = QString::fromUtf8("testdata:/barrier.omap");
+ QVERIFY(QFileInfo::exists(input));
+
+ // Write XML format to a non-native file extension
+ QString output = temp_dir.path() + QStringLiteral("/output.foo");
+ auto input_bytes = input.toLocal8Bit();
+ auto output_bytes = output.toLocal8Bit();
+
+ int result = execCliFrom({
+ "mapper", "--cli", "convert",
+ "-i", input_bytes.constData(),
+ "-o", output_bytes.constData(),
+ "--output-format", "XML"
+ });
+ QCOMPARE(result, 0);
+ QVERIFY(QFile::exists(output));
+ QVERIFY(QFileInfo(output).size() > 0);
+}
+
+
+void CliTest::testExportRejectsNativeFormatId()
+{
+ QTemporaryDir temp_dir;
+ QVERIFY(temp_dir.isValid());
+
+ auto input = QString::fromUtf8("testdata:/barrier.omap");
+ QVERIFY(QFileInfo::exists(input));
+
+ QString output = temp_dir.path() + QStringLiteral("/output.foo");
+ auto input_bytes = input.toLocal8Bit();
+ auto output_bytes = output.toLocal8Bit();
+
+ int result = execCliFrom({
+ "mapper", "--cli", "export",
+ "-i", input_bytes.constData(),
+ "-o", output_bytes.constData(),
+ "--output-format", "XML"
+ });
+ QCOMPARE(result, 1);
+}
+
+
+void CliTest::testConvertRejectsUnknownFormatId()
+{
+ QTemporaryDir temp_dir;
+ QVERIFY(temp_dir.isValid());
+
+ auto input = QString::fromUtf8("testdata:/barrier.omap");
+ QVERIFY(QFileInfo::exists(input));
+
+ QString output = temp_dir.path() + QStringLiteral("/output.omap");
+ auto input_bytes = input.toLocal8Bit();
+ auto output_bytes = output.toLocal8Bit();
+
+ int result = execCliFrom({
+ "mapper", "--cli", "convert",
+ "-i", input_bytes.constData(),
+ "-o", output_bytes.constData(),
+ "--output-format", "nonexistingformat"
+ });
+ QCOMPARE(result, 1);
+}
+int main(int argc, char** argv)
+{
+#ifdef Q_OS_LINUX
+ // Offscreen avoids failing when no display server is available
+ qputenv("QT_QPA_PLATFORM", "offscreen");
+#endif
+ QApplication app(argc, argv);
+ CliTest tc;
+ return QTest::qExec(&tc, argc, argv);
+}
diff --git a/test/cli_t.h b/test/cli_t.h
new file mode 100644
index 000000000..e191d7997
--- /dev/null
+++ b/test/cli_t.h
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2026 Michael Behrens
+ *
+ * This file is part of OpenOrienteering.
+ *
+ * OpenOrienteering is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * OpenOrienteering is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with OpenOrienteering. If not, see .
+ */
+
+#ifndef OPENORIENTEERING_CLI_T_H
+#define OPENORIENTEERING_CLI_T_H
+
+#include
+
+
+class CliTest : public QObject
+{
+Q_OBJECT
+
+private slots:
+ void initTestCase();
+
+ void testNoSubcommand();
+ void testNonexistingSubcommand();
+ void testExportMissingInput();
+ void testExportMissingOutput();
+ void testExportInvalidInput();
+ void testExportPdf();
+ void testExportPng();
+ void testExportFullMapFlag();
+
+ void testConvertMissingInput();
+ void testConvertMissingOutput();
+ void testConvertInvalidInput();
+ void testConvertOmapToXmap();
+ void testConvertXmapToOmap();
+ void testConvertOmapToOcd();
+ void testConvertWithFormatId();
+ void testExportFormatIdOverridesExtension();
+ void testConvertFormatIdOverridesExtension();
+ void testExportRejectsNativeFormatId();
+ void testConvertRejectsUnknownFormatId();
+};
+
+#endif