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