feat: add AppConfig INI-based configuration system

Implements a zero-dependency INI parser for application settings with
[batch], [conversion], and [quality] sections. Includes AppConfig::load(),
write_default(), output_format(), and parsed_extensions() helpers, along
with a documented config.ini example file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christoph K.
2026-03-14 09:41:07 +01:00
parent 65b411b23d
commit 344c22b6e3
3 changed files with 411 additions and 0 deletions

210
src/config/AppConfig.cpp Normal file
View File

@@ -0,0 +1,210 @@
#include "AppConfig.h"
#include "../converter/pipeline/Error.h"
#include <algorithm>
#include <cctype>
#include <format>
#include <fstream>
#include <sstream>
#include <string>
namespace photoconv {
// ─────────────────────────────────────────────────────────────────────────────
// Internal helpers
// ─────────────────────────────────────────────────────────────────────────────
namespace {
/** Strip leading/trailing ASCII whitespace from a string (in-place). */
void trim(std::string& s) {
const auto not_space = [](unsigned char c) { return !std::isspace(c); };
s.erase(s.begin(), std::find_if(s.begin(), s.end(), not_space));
s.erase(std::find_if(s.rbegin(), s.rend(), not_space).base(), s.end());
}
/** Convert a string to lowercase (in-place). */
void to_lower(std::string& s) {
std::ranges::transform(s, s.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
}
/**
* @brief Parse a "true/false/yes/no/1/0" string to bool.
*
* @param v Trimmed, lowercase value string.
* @return true for "true", "yes", "1"; false otherwise.
*/
[[nodiscard]] bool parse_bool(const std::string& v) {
return v == "true" || v == "yes" || v == "1";
}
} // anonymous namespace
// ─────────────────────────────────────────────────────────────────────────────
// AppConfig::load
// ─────────────────────────────────────────────────────────────────────────────
std::expected<AppConfig, Error> AppConfig::load(const std::filesystem::path& path) {
if (!std::filesystem::exists(path)) {
return std::unexpected(make_error(
ErrorCode::FileNotFound,
std::format("Config file not found: {}", path.string())));
}
std::ifstream file{path};
if (!file.is_open()) {
return std::unexpected(make_error(
ErrorCode::FileReadError,
std::format("Cannot open config file: {}", path.string())));
}
AppConfig cfg{};
std::string current_section;
std::string line;
int line_number = 0;
while (std::getline(file, line)) {
++line_number;
// Remove inline comments
if (const auto comment_pos = line.find_first_of("#;");
comment_pos != std::string::npos) {
line.erase(comment_pos);
}
trim(line);
if (line.empty()) continue;
// Section header: [section_name]
if (line.front() == '[' && line.back() == ']') {
current_section = line.substr(1, line.size() - 2);
trim(current_section);
to_lower(current_section);
continue;
}
// Key=Value pair
const auto eq_pos = line.find('=');
if (eq_pos == std::string::npos) {
// Malformed line skip silently (non-fatal)
continue;
}
std::string key = line.substr(0, eq_pos);
std::string value = line.substr(eq_pos + 1);
trim(key);
trim(value);
to_lower(key);
// Dispatch by section
if (current_section == "batch") {
if (key == "input_dir") cfg.batch.input_dir = value;
else if (key == "output_dir") cfg.batch.output_dir = value;
else if (key == "recursive") cfg.batch.recursive = parse_bool(value);
else if (key == "file_extensions") cfg.batch.file_extensions = value;
}
else if (current_section == "conversion") {
if (key == "film_type") cfg.conversion.film_type = value;
else if (key == "output_format") cfg.conversion.output_format = value;
else if (key == "output_bit_depth") cfg.conversion.output_bit_depth = std::stoi(value);
else if (key == "auto_crop") cfg.conversion.auto_crop = parse_bool(value);
else if (key == "sharpen") cfg.conversion.sharpen = parse_bool(value);
else if (key == "invert") cfg.conversion.invert = parse_bool(value);
}
else if (current_section == "quality") {
if (key == "jpeg_quality") cfg.quality.jpeg_quality = std::stoi(value);
else if (key == "sharpen_strength") cfg.quality.sharpen_strength = std::stod(value);
}
// Unknown sections are ignored silently.
}
return cfg;
}
// ─────────────────────────────────────────────────────────────────────────────
// AppConfig::output_format
// ─────────────────────────────────────────────────────────────────────────────
OutputFormat AppConfig::output_format() const noexcept {
const auto& fmt = conversion.output_format;
if (fmt == "png8") return OutputFormat::PNG_8bit;
if (fmt == "tiff16") return OutputFormat::TIFF_16bit;
if (fmt == "jpg" || fmt == "jpeg") return OutputFormat::JPEG;
return OutputFormat::PNG_16bit; // default / "png16"
}
// ─────────────────────────────────────────────────────────────────────────────
// AppConfig::parsed_extensions
// ─────────────────────────────────────────────────────────────────────────────
std::vector<std::string> AppConfig::parsed_extensions() const {
std::vector<std::string> result;
std::istringstream ss{batch.file_extensions};
std::string token;
while (std::getline(ss, token, ',')) {
trim(token);
if (token.empty()) continue;
// Ensure the extension starts with a dot
if (token.front() != '.') token = '.' + token;
to_lower(token);
result.push_back(std::move(token));
}
return result;
}
// ─────────────────────────────────────────────────────────────────────────────
// AppConfig::write_default
// ─────────────────────────────────────────────────────────────────────────────
std::expected<void, Error> AppConfig::write_default(const std::filesystem::path& path) {
std::filesystem::create_directories(path.parent_path());
std::ofstream file{path};
if (!file.is_open()) {
return std::unexpected(make_error(
ErrorCode::OutputWriteFailed,
std::format("Cannot create config file: {}", path.string())));
}
file <<
"# photo-converter configuration\n"
"# Lines starting with # or ; are comments.\n"
"\n"
"[batch]\n"
"# Directory containing input images\n"
"input_dir = .\n"
"# Directory where converted images are written\n"
"output_dir = output\n"
"# Scan input_dir recursively (true/false)\n"
"recursive = false\n"
"# Comma-separated list of file extensions to process\n"
"file_extensions = arw,cr2,cr3,nef,dng,orf,rw2,raf,pef,jpg,jpeg,png,tif,tiff\n"
"\n"
"[conversion]\n"
"# Film type: auto | c41 | bw\n"
"film_type = auto\n"
"# Output format: png16 | png8 | tiff16 | jpg\n"
"output_format = png16\n"
"# Output bit depth (8 or 16)\n"
"output_bit_depth = 16\n"
"# Automatically detect and crop film frame\n"
"auto_crop = true\n"
"# Apply unsharp-mask sharpening\n"
"sharpen = true\n"
"# Invert negative to positive\n"
"invert = true\n"
"\n"
"[quality]\n"
"# JPEG output quality [0-100]\n"
"jpeg_quality = 95\n"
"# Unsharp-mask strength [0.0-1.0]\n"
"sharpen_strength = 0.5\n";
return {};
}
} // namespace photoconv

150
src/config/AppConfig.h Normal file
View File

@@ -0,0 +1,150 @@
#pragma once
#include "../converter/output/OutputWriter.h"
#include <expected>
#include <filesystem>
#include <string>
#include <vector>
namespace photoconv {
// Forward-declare Error so we don't pull in the whole Error.h transitively
struct Error;
/**
* @brief Batch processing configuration.
*
* Controls which files are processed and where the results go.
*/
struct BatchConfig {
/// Source directory scanned for images.
std::filesystem::path input_dir{"."};
/// Destination directory for converted images.
std::filesystem::path output_dir{"output"};
/// Whether to scan subdirectories recursively.
bool recursive{false};
/// Comma-separated list of extensions to process, e.g. "arw,cr2,nef".
std::string file_extensions{"arw,cr2,nef,dng,jpg,png"};
};
/**
* @brief Conversion parameters controlling the pipeline behaviour.
*/
struct ConversionConfig {
/**
* @brief Film type override.
*
* "auto" lets NegativeDetector decide.
* "c41" forces C-41 colour negative processing.
* "bw" forces black-and-white negative processing.
*/
std::string film_type{"auto"};
/**
* @brief Output file format.
*
* Accepted values: "png16", "png8", "tiff16", "jpg".
*/
std::string output_format{"png16"};
/// Output bit depth (8 or 16).
int output_bit_depth{16};
/// Enable automatic frame cropping.
bool auto_crop{true};
/// Enable unsharp-mask sharpening.
bool sharpen{true};
/// Enable negative-to-positive inversion.
bool invert{true};
};
/**
* @brief Quality tuning parameters.
*/
struct QualityConfig {
/// JPEG quality [0, 100]; only used when output_format is "jpg".
int jpeg_quality{95};
/// Unsharp mask strength [0.0, 1.0]; maps to CropProcessor::kSharpenStrength.
double sharpen_strength{0.5};
};
/**
* @brief Aggregated application configuration.
*
* Loaded from an INI-style configuration file.
*
* File format example:
* @code
* [batch]
* input_dir = /home/user/scans
* output_dir = /home/user/output
* recursive = true
* file_extensions = arw,cr2,nef,jpg,png
*
* [conversion]
* film_type = auto
* output_format = png16
* output_bit_depth = 16
* auto_crop = true
* sharpen = true
* invert = true
*
* [quality]
* jpeg_quality = 95
* sharpen_strength = 0.5
* @endcode
*/
struct AppConfig {
BatchConfig batch;
ConversionConfig conversion;
QualityConfig quality;
/**
* @brief Load configuration from an INI-style file.
*
* Comments (#, ;) and blank lines are ignored.
* Keys not present in the file retain their default values.
*
* @param path Path to the configuration file.
* @return Parsed AppConfig on success, or Error on I/O or parse failure.
*/
[[nodiscard]] static std::expected<AppConfig, Error> load(
const std::filesystem::path& path);
/**
* @brief Derive an OutputFormat enum value from conversion.output_format.
*
* @return Corresponding OutputFormat, or OutputFormat::PNG_16bit as fallback.
*/
[[nodiscard]] OutputFormat output_format() const noexcept;
/**
* @brief Build a list of file extensions that should be processed.
*
* Extensions are returned in lowercase with a leading dot, e.g. ".arw".
*
* @return Vector of dot-prefixed, lowercased extension strings.
*/
[[nodiscard]] std::vector<std::string> parsed_extensions() const;
/**
* @brief Write default configuration to a file.
*
* Creates the file (and any missing parent directories) and writes
* a documented INI with all default values.
*
* @param path Destination path.
* @return Error on I/O failure.
*/
[[nodiscard]] static std::expected<void, Error> write_default(
const std::filesystem::path& path);
};
} // namespace photoconv