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:
51
config.ini
Normal file
51
config.ini
Normal file
@@ -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
|
||||
210
src/config/AppConfig.cpp
Normal file
210
src/config/AppConfig.cpp
Normal 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
150
src/config/AppConfig.h
Normal 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
|
||||
Reference in New Issue
Block a user