From 344c22b6e3a757bd8cc705b30b11973d3d2bb550 Mon Sep 17 00:00:00 2001 From: "Christoph K." Date: Sat, 14 Mar 2026 09:41:07 +0100 Subject: [PATCH] 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 --- config.ini | 51 ++++++++++ src/config/AppConfig.cpp | 210 +++++++++++++++++++++++++++++++++++++++ src/config/AppConfig.h | 150 ++++++++++++++++++++++++++++ 3 files changed, 411 insertions(+) create mode 100644 config.ini create mode 100644 src/config/AppConfig.cpp create mode 100644 src/config/AppConfig.h diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..12d6c3c --- /dev/null +++ b/config.ini @@ -0,0 +1,51 @@ +# photo-converter configuration +# Lines starting with # or ; are comments. +# Copy this file and adjust paths before running in batch mode: +# photo-converter --batch --config config.ini + +[batch] +# Directory containing input images (RAW or standard formats) +input_dir = import + +# Directory where converted images are written +output_dir = output + +# Scan input_dir recursively into subdirectories (true/false) +recursive = false + +# Comma-separated list of file extensions to process (case-insensitive) +file_extensions = arw,cr2,cr3,nef,dng,orf,rw2,raf,pef,jpg,jpeg,png,tif,tiff + +[conversion] +# Film type detection strategy: +# auto – NegativeDetector analyses the histogram and orange mask +# c41 – force C-41 colour negative processing +# bw – force B&W negative processing +film_type = auto + +# Output format: +# png16 – 16-bit PNG (lossless, archival quality) +# png8 – 8-bit PNG (lossless, smaller files) +# tiff16 – 16-bit TIFF (lossless, for professional editing) +# jpg – 8-bit JPEG (lossy, smallest files) +output_format = png16 + +# Output bit depth (8 or 16); ignored when format is jpeg +output_bit_depth = 16 + +# Automatically detect and crop the film frame (true/false) +auto_crop = true + +# Apply unsharp-mask sharpening (true/false) +sharpen = true + +# Invert negative to positive (true/false) +# Set to false if input is already a positive (slide/print) +invert = true + +[quality] +# JPEG output quality [0-100]; only used when output_format = jpg +jpeg_quality = 95 + +# Unsharp-mask strength [0.0-1.0] +sharpen_strength = 0.5 diff --git a/src/config/AppConfig.cpp b/src/config/AppConfig.cpp new file mode 100644 index 0000000..11d7fa9 --- /dev/null +++ b/src/config/AppConfig.cpp @@ -0,0 +1,210 @@ +#include "AppConfig.h" +#include "../converter/pipeline/Error.h" + +#include +#include +#include +#include +#include +#include + +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(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::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 AppConfig::parsed_extensions() const { + std::vector 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 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 diff --git a/src/config/AppConfig.h b/src/config/AppConfig.h new file mode 100644 index 0000000..5631109 --- /dev/null +++ b/src/config/AppConfig.h @@ -0,0 +1,150 @@ +#pragma once + +#include "../converter/output/OutputWriter.h" + +#include +#include +#include +#include + +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 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 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 write_default( + const std::filesystem::path& path); +}; + +} // namespace photoconv