From 7650f9e76caebe5884b24876ad15be0f6f24f44b Mon Sep 17 00:00:00 2001 From: Michael Behrens Date: Sun, 5 Jul 2026 11:50:03 +0200 Subject: [PATCH 01/10] Add cli --- src/CMakeLists.txt | 1 + src/cli.cpp | 269 +++++++++++++++++++++++++++++++++++++++++++++ src/cli.h | 10 ++ src/main.cpp | 6 + 4 files changed, 286 insertions(+) create mode 100644 src/cli.cpp create mode 100644 src/cli.h 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..40d90ebec --- /dev/null +++ b/src/cli.cpp @@ -0,0 +1,269 @@ +#include "cli.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "core/map.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 QStringList& creation_options) +{ + 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; + + for (const auto& opt : creation_options) + { + auto eq = opt.indexOf(QLatin1Char('=')); + if (eq > 0) + exporter->setOption(opt.left(eq), opt.mid(eq + 1)); + } + + return exporter->doExport(); +} + + +#ifdef QT_PRINTSUPPORT_LIB + +enum class PrinterOutputType { Pdf, Image }; + +bool exportViaMapPrinter(const QString& output_path, Map& map, PrinterOutputType output_type) +{ + auto extent = map.calculateExtent(); + if (extent.isEmpty()) + { + fprintf(stderr, "Error: map has no extent\n"); + return false; + } + + if (output_type == PrinterOutputType::Pdf) + { + MapPrinter printer(map, nullptr); + printer.setTarget(MapPrinter::pdfTarget()); + printer.setPrintArea(extent); + printer.setCustomPageSize(extent.size()); + printer.setResolution(300); + + auto qprinter = printer.makePrinter(); + if (!qprinter) + { + fprintf(stderr, "Error: failed to create PDF printer.\n"); + return false; + } + + QSizeF paper_mm = printer.getPrintAreaPaperSize(); + + 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(paper_mm, QPrinter::Millimeter); + qprinter->setResolution(300); + + QPainter painter(qprinter.get()); + printer.drawPage(&painter, printer.getPrintArea()); + painter.end(); + return true; + } + else + { + // Follow PrintWidget::exportToImage: use imageTarget for raster output. + MapPrinter printer(map, nullptr); + printer.setTarget(MapPrinter::imageTarget()); + printer.setPrintArea(extent); + // setPrintArea for imageTarget auto-sets custom page size to extent * scale_adjustment. + printer.setResolution(300); + + QSizeF mm_size = printer.getPrintAreaPaperSize(); + qreal pixel_per_mm = printer.getOptions().resolution / 25.4; + 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)) + { + 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 various formats")); + + QCommandLineOption output_option( + QStringList{QStringLiteral("o"), QStringLiteral("output")}, + QStringLiteral("Output file path."), + QStringLiteral("path")); + parser.addOption(output_option); + + QCommandLineOption format_option( + QStringList{QStringLiteral("of"), QStringLiteral("output-format")}, + QStringLiteral("Output format ID (e.g. PDF, OCD12, OGR-export-DXF)."), + QStringLiteral("id")); + parser.addOption(format_option); + + QCommandLineOption creation_option( + QStringLiteral("creation-option"), + QStringLiteral("Format-specific creation option in KEY=VALUE format."), + QStringLiteral("option")); + parser.addOption(creation_option); + + QCommandLineOption input_option( + QStringList{QStringLiteral("i"), QStringLiteral("input")}, + QStringLiteral("Input map file."), + QStringLiteral("path")); + parser.addOption(input_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); + QStringList creation_options = parser.values(creation_option); + + Map map; + if (!map.loadFrom(input_path)) + { + fprintf(stderr, "Error: failed to load map from '%s'\n", qPrintable(input_path)); + return 1; + } + + // When no explicit format is given, prefer MapPrinter for PDF and image formats. + if (format_id.isEmpty()) + { +#ifdef QT_PRINTSUPPORT_LIB + auto ext = QFileInfo(output_path).suffix().toLower(); + if (ext == QStringLiteral("pdf")) + { + if (exportViaMapPrinter(output_path, map, PrinterOutputType::Pdf)) + return 0; + fprintf(stderr, "Error: PDF export failed.\n"); + return 1; + } + else if (ext == QStringLiteral("png") || ext == QStringLiteral("jpg") + || ext == QStringLiteral("jpeg") || ext == QStringLiteral("tif") + || ext == QStringLiteral("tiff") || ext == QStringLiteral("bmp")) + { + if (exportViaMapPrinter(output_path, map, PrinterOutputType::Image)) + return 0; + fprintf(stderr, "Error: image export failed.\n"); + return 1; + } +#endif + } + + if (exportViaFileFormat(output_path, map, format_id, creation_options)) + 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. Available: export\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); + + fprintf(stderr, "Error: unknown subcommand '%s'. Available: export\n", qPrintable(subcommand)); + return 1; +} + + +} // namespace OpenOrienteering diff --git a/src/cli.h b/src/cli.h new file mode 100644 index 000000000..4f51ce6a8 --- /dev/null +++ b/src/cli.h @@ -0,0 +1,10 @@ +#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..f81257a7f 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" @@ -189,6 +191,10 @@ int main(int argc, char** argv) // Initialize static things like the file format registry. doStaticInitializations(); + // Detect cli arg before creating the window + if (argc > 1 && std::strcmp(argv[1], "--cli") == 0) + return OpenOrienteering::execCli(argc, argv); + // Some style settings (in particular the menu item font) are not // applied correctly before the app runs. So we postpone these steps // via the event loop. From d3d7710743f4d1dee5496f079781f346cd36d721 Mon Sep 17 00:00:00 2001 From: Michael Behrens Date: Sun, 5 Jul 2026 12:49:26 +0200 Subject: [PATCH 02/10] Refactor export --- src/cli.cpp | 120 ++++++++++++++++++++++++++-------------------------- 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/src/cli.cpp b/src/cli.cpp index 40d90ebec..ab37b8a7d 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -58,9 +58,7 @@ bool exportViaFileFormat(const QString& output_path, const Map& map, const QStri #ifdef QT_PRINTSUPPORT_LIB -enum class PrinterOutputType { Pdf, Image }; - -bool exportViaMapPrinter(const QString& output_path, Map& map, PrinterOutputType output_type) +bool exportViaPdf(const QString& output_path, Map& map) { auto extent = map.calculateExtent(); if (extent.isEmpty()) @@ -69,71 +67,73 @@ bool exportViaMapPrinter(const QString& output_path, Map& map, PrinterOutputType return false; } - if (output_type == PrinterOutputType::Pdf) + MapPrinter printer(map, nullptr); + printer.setTarget(MapPrinter::pdfTarget()); + printer.setPrintArea(extent); + printer.setCustomPageSize(extent.size()); + printer.setResolution(300); + + auto qprinter = printer.makePrinter(); + if (!qprinter) { - MapPrinter printer(map, nullptr); - printer.setTarget(MapPrinter::pdfTarget()); - printer.setPrintArea(extent); - printer.setCustomPageSize(extent.size()); - printer.setResolution(300); - - auto qprinter = printer.makePrinter(); - if (!qprinter) - { - fprintf(stderr, "Error: failed to create PDF printer.\n"); - return false; - } + fprintf(stderr, "Error: failed to create PDF printer.\n"); + return false; + } - QSizeF paper_mm = printer.getPrintAreaPaperSize(); + qprinter->setOutputFileName(output_path); - 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); - // setOutputFileName may reset the page size, re-apply it - qprinter->setFullPage(true); - qprinter->setPageMargins(QMarginsF(0, 0, 0, 0), QPageLayout::Millimeter); - qprinter->setPaperSize(paper_mm, QPrinter::Millimeter); - qprinter->setResolution(300); + QPainter painter(qprinter.get()); + printer.drawPage(&painter, printer.getPrintArea()); + painter.end(); + return true; +} - QPainter painter(qprinter.get()); - printer.drawPage(&painter, printer.getPrintArea()); - painter.end(); - return true; + +bool exportViaImage(const QString& output_path, Map& map) +{ + auto extent = map.calculateExtent(); + if (extent.isEmpty()) + { + fprintf(stderr, "Error: map has no extent\n"); + return false; } - else + + MapPrinter printer(map, nullptr); + printer.setTarget(MapPrinter::imageTarget()); + printer.setPrintArea(extent); + printer.setResolution(300); + + 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()) { - // Follow PrintWidget::exportToImage: use imageTarget for raster output. - MapPrinter printer(map, nullptr); - printer.setTarget(MapPrinter::imageTarget()); - printer.setPrintArea(extent); - // setPrintArea for imageTarget auto-sets custom page size to extent * scale_adjustment. - printer.setResolution(300); - - QSizeF mm_size = printer.getPrintAreaPaperSize(); - qreal pixel_per_mm = printer.getOptions().resolution / 25.4; - 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)); + 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(); + QPainter p(&image); + printer.drawPage(&p, printer.getPrintArea(), &image); + p.end(); - if (!image.save(output_path)) - { - fprintf(stderr, "Error: failed to save image to '%s'\n", qPrintable(output_path)); - return false; - } - return true; + if (!image.save(output_path)) + { + fprintf(stderr, "Error: failed to save image to '%s'\n", qPrintable(output_path)); + return false; } + return true; } #endif @@ -217,7 +217,7 @@ int runExport(const QStringList& sub_args) auto ext = QFileInfo(output_path).suffix().toLower(); if (ext == QStringLiteral("pdf")) { - if (exportViaMapPrinter(output_path, map, PrinterOutputType::Pdf)) + if (exportViaPdf(output_path, map)) return 0; fprintf(stderr, "Error: PDF export failed.\n"); return 1; @@ -226,7 +226,7 @@ int runExport(const QStringList& sub_args) || ext == QStringLiteral("jpeg") || ext == QStringLiteral("tif") || ext == QStringLiteral("tiff") || ext == QStringLiteral("bmp")) { - if (exportViaMapPrinter(output_path, map, PrinterOutputType::Image)) + if (exportViaImage(output_path, map)) return 0; fprintf(stderr, "Error: image export failed.\n"); return 1; From 79bbcc19d9d8f7b9177be8ba4d2cd28479cad15a Mon Sep 17 00:00:00 2001 From: Michael Behrens Date: Sun, 5 Jul 2026 13:00:26 +0200 Subject: [PATCH 03/10] Full map option --- src/cli.cpp | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/cli.cpp b/src/cli.cpp index ab37b8a7d..cac2e2597 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -58,10 +58,9 @@ bool exportViaFileFormat(const QString& output_path, const Map& map, const QStri #ifdef QT_PRINTSUPPORT_LIB -bool exportViaPdf(const QString& output_path, Map& map) +bool exportViaPdf(const QString& output_path, Map& map, const QRectF& print_area) { - auto extent = map.calculateExtent(); - if (extent.isEmpty()) + if (print_area.isEmpty()) { fprintf(stderr, "Error: map has no extent\n"); return false; @@ -69,8 +68,8 @@ bool exportViaPdf(const QString& output_path, Map& map) MapPrinter printer(map, nullptr); printer.setTarget(MapPrinter::pdfTarget()); - printer.setPrintArea(extent); - printer.setCustomPageSize(extent.size()); + printer.setPrintArea(print_area); + printer.setCustomPageSize(print_area.size()); printer.setResolution(300); auto qprinter = printer.makePrinter(); @@ -94,10 +93,9 @@ bool exportViaPdf(const QString& output_path, Map& map) } -bool exportViaImage(const QString& output_path, Map& map) +bool exportViaImage(const QString& output_path, Map& map, const QRectF& print_area) { - auto extent = map.calculateExtent(); - if (extent.isEmpty()) + if (print_area.isEmpty()) { fprintf(stderr, "Error: map has no extent\n"); return false; @@ -105,7 +103,7 @@ bool exportViaImage(const QString& output_path, Map& map) MapPrinter printer(map, nullptr); printer.setTarget(MapPrinter::imageTarget()); - printer.setPrintArea(extent); + printer.setPrintArea(print_area); printer.setResolution(300); auto const& options = printer.getOptions(); @@ -168,6 +166,9 @@ int runExport(const QStringList& sub_args) QStringLiteral("path")); parser.addOption(input_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); + parser.addHelpOption(); // QCommandLineParser requires the program name as first argument @@ -210,6 +211,15 @@ int runExport(const QStringList& sub_args) 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; + } + // When no explicit format is given, prefer MapPrinter for PDF and image formats. if (format_id.isEmpty()) { @@ -217,7 +227,7 @@ int runExport(const QStringList& sub_args) auto ext = QFileInfo(output_path).suffix().toLower(); if (ext == QStringLiteral("pdf")) { - if (exportViaPdf(output_path, map)) + if (exportViaPdf(output_path, map, print_area)) return 0; fprintf(stderr, "Error: PDF export failed.\n"); return 1; @@ -226,7 +236,7 @@ int runExport(const QStringList& sub_args) || ext == QStringLiteral("jpeg") || ext == QStringLiteral("tif") || ext == QStringLiteral("tiff") || ext == QStringLiteral("bmp")) { - if (exportViaImage(output_path, map)) + if (exportViaImage(output_path, map, print_area)) return 0; fprintf(stderr, "Error: image export failed.\n"); return 1; From 1d4d2fd20d0a1112e6a0edc13320006ae1d3f9df Mon Sep 17 00:00:00 2001 From: Michael Behrens Date: Sun, 5 Jul 2026 13:57:45 +0200 Subject: [PATCH 04/10] Invoke before checking if oom is already running --- src/main.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index f81257a7f..f896c818e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -127,6 +127,15 @@ 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. + if (argc > 1 && std::strcmp(argv[1], "--cli") == 0) + { + doStaticInitializations(); + 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. @@ -191,10 +200,6 @@ int main(int argc, char** argv) // Initialize static things like the file format registry. doStaticInitializations(); - // Detect cli arg before creating the window - if (argc > 1 && std::strcmp(argv[1], "--cli") == 0) - return OpenOrienteering::execCli(argc, argv); - // Some style settings (in particular the menu item font) are not // applied correctly before the app runs. So we postpone these steps // via the event loop. From 0732a27d4723fda24d5ef031a1d6c5410e2c3bb9 Mon Sep 17 00:00:00 2001 From: Michael Behrens Date: Sun, 5 Jul 2026 23:13:21 +0200 Subject: [PATCH 05/10] Change on what export and convert actually do, cleanup --- src/cli.cpp | 179 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 128 insertions(+), 51 deletions(-) diff --git a/src/cli.cpp b/src/cli.cpp index cac2e2597..9c9d2b9bc 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -1,7 +1,6 @@ #include "cli.h" #include -#include #include #include @@ -28,7 +27,7 @@ namespace OpenOrienteering { namespace { -bool exportViaFileFormat(const QString& output_path, const Map& map, const QString& format_id, const QStringList& creation_options) +bool exportViaFileFormat(const QString& output_path, const Map& map, const QString& format_id) { const FileFormat* format = nullptr; if (!format_id.isEmpty()) @@ -45,20 +44,13 @@ bool exportViaFileFormat(const QString& output_path, const Map& map, const QStri if (!exporter) return false; - for (const auto& opt : creation_options) - { - auto eq = opt.indexOf(QLatin1Char('=')); - if (eq > 0) - exporter->setOption(opt.left(eq), opt.mid(eq + 1)); - } - return exporter->doExport(); } #ifdef QT_PRINTSUPPORT_LIB -bool exportViaPdf(const QString& output_path, Map& map, const QRectF& print_area) +bool exportViaPdf(const QString& output_path, Map& map, const QRectF& print_area, int dpi = 300) { if (print_area.isEmpty()) { @@ -70,7 +62,7 @@ bool exportViaPdf(const QString& output_path, Map& map, const QRectF& print_area printer.setTarget(MapPrinter::pdfTarget()); printer.setPrintArea(print_area); printer.setCustomPageSize(print_area.size()); - printer.setResolution(300); + printer.setResolution(dpi); auto qprinter = printer.makePrinter(); if (!qprinter) @@ -93,7 +85,7 @@ bool exportViaPdf(const QString& output_path, Map& map, const QRectF& print_area } -bool exportViaImage(const QString& output_path, Map& map, const QRectF& print_area) +bool exportViaImage(const QString& output_path, Map& map, const QRectF& print_area, int dpi = 300, const char* image_format = nullptr) { if (print_area.isEmpty()) { @@ -104,7 +96,7 @@ bool exportViaImage(const QString& output_path, Map& map, const QRectF& print_ar MapPrinter printer(map, nullptr); printer.setTarget(MapPrinter::imageTarget()); printer.setPrintArea(print_area); - printer.setResolution(300); + printer.setResolution(dpi); auto const& options = printer.getOptions(); qreal pixel_per_mm = options.resolution / 25.4; @@ -126,7 +118,7 @@ bool exportViaImage(const QString& output_path, Map& map, const QRectF& print_ar printer.drawPage(&p, printer.getPrintArea(), &image); p.end(); - if (!image.save(output_path)) + if (!image.save(output_path, image_format)) { fprintf(stderr, "Error: failed to save image to '%s'\n", qPrintable(output_path)); return false; @@ -140,7 +132,13 @@ bool exportViaImage(const QString& output_path, Map& map, const QRectF& print_ar int runExport(const QStringList& sub_args) { QCommandLineParser parser; - parser.setApplicationDescription(QStringLiteral("Export map to various formats")); + 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")}, @@ -149,26 +147,21 @@ int runExport(const QStringList& sub_args) parser.addOption(output_option); QCommandLineOption format_option( - QStringList{QStringLiteral("of"), QStringLiteral("output-format")}, - QStringLiteral("Output format ID (e.g. PDF, OCD12, OGR-export-DXF)."), + QStringLiteral("output-format"), + QStringLiteral("Output format ID (e.g. pdf, png, OGR-export-DXF)."), QStringLiteral("id")); parser.addOption(format_option); - QCommandLineOption creation_option( - QStringLiteral("creation-option"), - QStringLiteral("Format-specific creation option in KEY=VALUE format."), - QStringLiteral("option")); - parser.addOption(creation_option); - - QCommandLineOption input_option( - QStringList{QStringLiteral("i"), QStringLiteral("input")}, - QStringLiteral("Input map file."), - QStringLiteral("path")); - parser.addOption(input_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 @@ -202,7 +195,8 @@ int runExport(const QStringList& sub_args) QString output_path = parser.value(output_option); QString format_id = parser.value(format_option); - QStringList creation_options = parser.values(creation_option); + + int dpi = parser.value(dpi_option).toInt(); Map map; if (!map.loadFrom(input_path)) @@ -220,37 +214,117 @@ int runExport(const QStringList& sub_args) return 1; } - // When no explicit format is given, prefer MapPrinter for PDF and image formats. - if (format_id.isEmpty()) - { + // 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 - auto ext = QFileInfo(output_path).suffix().toLower(); - if (ext == QStringLiteral("pdf")) - { - if (exportViaPdf(output_path, map, print_area)) - return 0; - fprintf(stderr, "Error: PDF export failed.\n"); - return 1; - } - else if (ext == QStringLiteral("png") || ext == QStringLiteral("jpg") - || ext == QStringLiteral("jpeg") || ext == QStringLiteral("tif") - || ext == QStringLiteral("tiff") || ext == QStringLiteral("bmp")) + if (format_key == QStringLiteral("pdf")) + { + if (exportViaPdf(output_path, map, print_area, dpi)) + return 0; + fprintf(stderr, "Error: PDF export failed.\n"); + return 1; + } + + static const QStringList image_formats = { + QStringLiteral("png"), QStringLiteral("jpg"), QStringLiteral("jpeg"), + QStringLiteral("tif"), QStringLiteral("tiff"), QStringLiteral("bmp") + }; + if (image_formats.contains(format_key)) + { + // 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()) { - if (exportViaImage(output_path, map, print_area)) - return 0; - fprintf(stderr, "Error: image export failed.\n"); - return 1; + 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, creation_options)) + if (exportViaFileFormat(output_path, map, format_id)) return 0; fprintf(stderr, "Error: no exporter found for '%s'.\n", qPrintable(output_path)); return 1; } + } // namespace @@ -258,7 +332,7 @@ int execCli(int argc, char** argv) { if (argc < 3) { - fprintf(stderr, "Error: no subcommand specified. Available: export\n"); + fprintf(stderr, "Error: no subcommand specified.\n"); return 1; } @@ -271,7 +345,10 @@ int execCli(int argc, char** argv) if (subcommand == QLatin1String("export")) return runExport(sub_args); - fprintf(stderr, "Error: unknown subcommand '%s'. Available: export\n", qPrintable(subcommand)); + if (subcommand == QLatin1String("convert")) + return runConvert(sub_args); + + fprintf(stderr, "Error: unknown subcommand '%s'. Available: export, convert\n", qPrintable(subcommand)); return 1; } From c685768f100eabb52fb2f5f69c76aca4cebac936 Mon Sep 17 00:00:00 2001 From: Michael Behrens Date: Sun, 5 Jul 2026 23:14:29 +0200 Subject: [PATCH 06/10] Add cli tests --- test/CMakeLists.txt | 1 + test/cli_t.cpp | 388 ++++++++++++++++++++++++++++++++++++++++++++ test/cli_t.h | 55 +++++++ 3 files changed, 444 insertions(+) create mode 100644 test/cli_t.cpp create mode 100644 test/cli_t.h diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index b1471a2f1..c2a0ad321 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -190,6 +190,7 @@ add_unit_test(util_t ../src/util/util add_system_test(coord_xml_t MANUAL) # System tests +add_system_test(cli_t) 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..7c811268a --- /dev/null +++ b/test/cli_t.cpp @@ -0,0 +1,388 @@ +/* + * Copyright 2022 Kai Pastor + * + * 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 "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); +} + + +QTEST_MAIN(CliTest) diff --git a/test/cli_t.h b/test/cli_t.h new file mode 100644 index 000000000..57451b44d --- /dev/null +++ b/test/cli_t.h @@ -0,0 +1,55 @@ +/* + * Copyright 2022 Kai Pastor + * + * 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 From cbec8e3bae7cfa8bf73d4e9e59af392d786e952b Mon Sep 17 00:00:00 2001 From: Michael Behrens Date: Sun, 5 Jul 2026 23:41:51 +0200 Subject: [PATCH 07/10] Add/Fix Copyright --- src/cli.cpp | 19 +++++++++++++++++++ src/cli.h | 19 +++++++++++++++++++ test/cli_t.cpp | 2 +- test/cli_t.h | 2 +- 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/cli.cpp b/src/cli.cpp index 9c9d2b9bc..75c580f1e 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -1,3 +1,22 @@ +/* + * 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 diff --git a/src/cli.h b/src/cli.h index 4f51ce6a8..f939a4d87 100644 --- a/src/cli.h +++ b/src/cli.h @@ -1,3 +1,22 @@ +/* + * 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 diff --git a/test/cli_t.cpp b/test/cli_t.cpp index 7c811268a..6d7e4f507 100644 --- a/test/cli_t.cpp +++ b/test/cli_t.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2022 Kai Pastor + * Copyright 2026 Michael Behrens * * This file is part of OpenOrienteering. * diff --git a/test/cli_t.h b/test/cli_t.h index 57451b44d..e191d7997 100644 --- a/test/cli_t.h +++ b/test/cli_t.h @@ -1,5 +1,5 @@ /* - * Copyright 2022 Kai Pastor + * Copyright 2026 Michael Behrens * * This file is part of OpenOrienteering. * From e0971eed4f3910757ee2c7eaeba9e75db13c7cb2 Mon Sep 17 00:00:00 2001 From: Michael Behrens Date: Sun, 5 Jul 2026 23:42:50 +0200 Subject: [PATCH 08/10] Use tabs instead of spaces Why is there not .clang-format :( --- test/cli_t.cpp | 94 +++++++++++++++++++++++++------------------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/test/cli_t.cpp b/test/cli_t.cpp index 6d7e4f507..4c10c4347 100644 --- a/test/cli_t.cpp +++ b/test/cli_t.cpp @@ -64,8 +64,8 @@ void CliTest::initTestCase() // Register a Qt search path for test data QDir::addSearchPath(QStringLiteral("testdata"), - QDir(QString::fromUtf8(MAPPER_TEST_SOURCE_DIR)) - .absoluteFilePath(QStringLiteral("data"))); + QDir(QString::fromUtf8(MAPPER_TEST_SOURCE_DIR)) + .absoluteFilePath(QStringLiteral("data"))); } @@ -112,10 +112,10 @@ void CliTest::testExportPdf() auto output_bytes = output.toLocal8Bit(); int result = execCliFrom({ - "mapper", "--cli", "export", - "-i", input_bytes.constData(), - "-o", output_bytes.constData(), - "--full-map" + "mapper", "--cli", "export", + "-i", input_bytes.constData(), + "-o", output_bytes.constData(), + "--full-map" }); QCOMPARE(result, 0); QVERIFY(QFile::exists(output)); @@ -136,10 +136,10 @@ void CliTest::testExportPng() auto output_bytes = output.toLocal8Bit(); int result = execCliFrom({ - "mapper", "--cli", "export", - "-i", input_bytes.constData(), - "-o", output_bytes.constData(), - "--full-map" + "mapper", "--cli", "export", + "-i", input_bytes.constData(), + "-o", output_bytes.constData(), + "--full-map" }); QCOMPARE(result, 0); QVERIFY(QFile::exists(output)); @@ -158,10 +158,10 @@ void CliTest::testExportFullMapFlag() auto output_bytes = output.toLocal8Bit(); int result = execCliFrom({ - "mapper", "--cli", "export", - "-i", input_bytes.constData(), - "-o", output_bytes.constData(), - "--full-map" + "mapper", "--cli", "export", + "-i", input_bytes.constData(), + "-o", output_bytes.constData(), + "--full-map" }); QCOMPARE(result, 0); QVERIFY(QFile::exists(output)); @@ -200,9 +200,9 @@ void CliTest::testConvertOmapToXmap() auto output_bytes = output.toLocal8Bit(); int result = execCliFrom({ - "mapper", "--cli", "convert", - "-i", input_bytes.constData(), - "-o", output_bytes.constData() + "mapper", "--cli", "convert", + "-i", input_bytes.constData(), + "-o", output_bytes.constData() }); QCOMPARE(result, 0); QVERIFY(QFile::exists(output)); @@ -225,17 +225,17 @@ void CliTest::testConvertXmapToOmap() auto omap_bytes = omap.toLocal8Bit(); int r1 = execCliFrom({ - "mapper", "--cli", "convert", - "-i", input_bytes.constData(), - "-o", xmap_bytes.constData() + "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() + "mapper", "--cli", "convert", + "-i", xmap_bytes.constData(), + "-o", omap_bytes.constData() }); QCOMPARE(r2, 0); QVERIFY(QFile::exists(omap)); @@ -256,9 +256,9 @@ void CliTest::testConvertOmapToOcd() auto output_bytes = output.toLocal8Bit(); int result = execCliFrom({ - "mapper", "--cli", "convert", - "-i", input_bytes.constData(), - "-o", output_bytes.constData() + "mapper", "--cli", "convert", + "-i", input_bytes.constData(), + "-o", output_bytes.constData() }); QCOMPARE(result, 0); QVERIFY(QFile::exists(output)); @@ -279,10 +279,10 @@ void CliTest::testConvertWithFormatId() auto output_bytes = output.toLocal8Bit(); int result = execCliFrom({ - "mapper", "--cli", "convert", - "-i", input_bytes.constData(), - "-o", output_bytes.constData(), - "--output-format", "XML" + "mapper", "--cli", "convert", + "-i", input_bytes.constData(), + "-o", output_bytes.constData(), + "--output-format", "XML" }); QCOMPARE(result, 0); QVERIFY(QFile::exists(output)); @@ -304,11 +304,11 @@ void CliTest::testExportFormatIdOverridesExtension() auto output_bytes = output.toLocal8Bit(); int result = execCliFrom({ - "mapper", "--cli", "export", - "-i", input_bytes.constData(), - "-o", output_bytes.constData(), - "--output-format", "png", - "--full-map" + "mapper", "--cli", "export", + "-i", input_bytes.constData(), + "-o", output_bytes.constData(), + "--output-format", "png", + "--full-map" }); QCOMPARE(result, 0); QVERIFY(QFile::exists(output)); @@ -330,10 +330,10 @@ void CliTest::testConvertFormatIdOverridesExtension() auto output_bytes = output.toLocal8Bit(); int result = execCliFrom({ - "mapper", "--cli", "convert", - "-i", input_bytes.constData(), - "-o", output_bytes.constData(), - "--output-format", "XML" + "mapper", "--cli", "convert", + "-i", input_bytes.constData(), + "-o", output_bytes.constData(), + "--output-format", "XML" }); QCOMPARE(result, 0); QVERIFY(QFile::exists(output)); @@ -354,10 +354,10 @@ void CliTest::testExportRejectsNativeFormatId() auto output_bytes = output.toLocal8Bit(); int result = execCliFrom({ - "mapper", "--cli", "export", - "-i", input_bytes.constData(), - "-o", output_bytes.constData(), - "--output-format", "XML" + "mapper", "--cli", "export", + "-i", input_bytes.constData(), + "-o", output_bytes.constData(), + "--output-format", "XML" }); QCOMPARE(result, 1); } @@ -376,10 +376,10 @@ void CliTest::testConvertRejectsUnknownFormatId() auto output_bytes = output.toLocal8Bit(); int result = execCliFrom({ - "mapper", "--cli", "convert", - "-i", input_bytes.constData(), - "-o", output_bytes.constData(), - "--output-format", "nonexistingformat" + "mapper", "--cli", "convert", + "-i", input_bytes.constData(), + "-o", output_bytes.constData(), + "--output-format", "nonexistingformat" }); QCOMPARE(result, 1); } From 8f079a9c3f96f68a8b7796dbd76933c6c27750a3 Mon Sep 17 00:00:00 2001 From: Michael Behrens Date: Mon, 6 Jul 2026 00:30:11 +0200 Subject: [PATCH 09/10] Improve image file format detection --- src/cli.cpp | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/cli.cpp b/src/cli.cpp index 75c580f1e..79ca1cb14 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -246,11 +247,7 @@ int runExport(const QStringList& sub_args) return 1; } - static const QStringList image_formats = { - QStringLiteral("png"), QStringLiteral("jpg"), QStringLiteral("jpeg"), - QStringLiteral("tif"), QStringLiteral("tiff"), QStringLiteral("bmp") - }; - if (image_formats.contains(format_key)) + 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. From 35cf3d715876a46f48f42e2f7445b61383702f43 Mon Sep 17 00:00:00 2001 From: Michael Behrens Date: Mon, 6 Jul 2026 00:45:12 +0200 Subject: [PATCH 10/10] Fix ci tests - QPA_PLATFORM on Linux - include `core/map_printer.h` for Android build --- src/cli.cpp | 57 ++++++++++++++++++++++++++++----------------- src/main.cpp | 19 ++++++++++++++- test/CMakeLists.txt | 4 +++- test/cli_t.cpp | 14 ++++++++--- 4 files changed, 67 insertions(+), 27 deletions(-) diff --git a/src/cli.cpp b/src/cli.cpp index 79ca1cb14..03789cf37 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -30,6 +30,7 @@ #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" @@ -161,25 +162,25 @@ int runExport(const QStringList& sub_args) parser.addOption(input_option); QCommandLineOption output_option( - QStringList{QStringLiteral("o"), QStringLiteral("output")}, - QStringLiteral("Output file path."), - QStringLiteral("path")); + 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, OGR-export-DXF)."), - QStringLiteral("id")); + 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")); + QStringLiteral("dpi"), + QStringLiteral("Output resolution in DPI (default: 300)."), + QStringLiteral("dpi"), + QStringLiteral("300")); parser.addOption(dpi_option); parser.addHelpOption(); @@ -226,8 +227,8 @@ int runExport(const QStringList& sub_args) } QRectF print_area = parser.isSet(full_map_option) - ? map.calculateExtent() - : map.printerConfig().print_area; + ? map.calculateExtent() + : map.printerConfig().print_area; if (print_area.isEmpty()) { fprintf(stderr, "Error: map has no content in the selected print area.\n"); @@ -275,22 +276,22 @@ int runConvert(const QStringList& sub_args) parser.setApplicationDescription(QStringLiteral("Convert between orienteering map formats")); QCommandLineOption input_option( - QStringList{QStringLiteral("i"), QStringLiteral("input")}, - QStringLiteral("Input map file."), - QStringLiteral("path")); + 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")); + 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{}); + QStringLiteral("output-format"), + QStringLiteral("Output format ID (e.g. XML, OCD, OCD12)."), + QStringLiteral("id"), + QString{}); parser.addOption(format_option); parser.addHelpOption(); @@ -364,6 +365,18 @@ int execCli(int argc, char** argv) 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; } diff --git a/src/main.cpp b/src/main.cpp index f896c818e..03266979d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -129,9 +129,26 @@ int main(int argc, char** argv) { // Detect cli arg early, before creating any QApplication, // so that cli works even when oom is already running. - if (argc > 1 && std::strcmp(argv[1], "--cli") == 0) + 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); } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index c2a0ad321..6b33c60af 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -190,7 +190,9 @@ add_unit_test(util_t ../src/util/util add_system_test(coord_xml_t MANUAL) # System tests -add_system_test(cli_t) +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 index 4c10c4347..803ec2041 100644 --- a/test/cli_t.cpp +++ b/test/cli_t.cpp @@ -25,6 +25,7 @@ #include #include +#include #include #include #include @@ -383,6 +384,13 @@ void CliTest::testConvertRejectsUnknownFormatId() }); QCOMPARE(result, 1); } - - -QTEST_MAIN(CliTest) +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); +}