Fix pipeline order, add selective metadata stripping, rename case/regex

- Move watermark step after compress in processing pipeline to match
  design doc order (resize, adjustments, convert, compress, metadata,
  watermark, rename)
- Implement selective EXIF metadata stripping for Privacy and Custom
  modes using little_exif tag filtering (GPS, camera, software,
  timestamps, copyright categories)
- Add case conversion support to rename (none/lower/upper/title)
- Add regex find-and-replace on original filenames
- Wire case and regex controls in rename step UI to JobConfig
- Add regex crate dependency to pixstrip-core
This commit is contained in:
2026-03-06 18:12:18 +02:00
parent 5104d66aaf
commit a666fbad05
10 changed files with 208 additions and 17 deletions

View File

@@ -356,11 +356,6 @@ impl PipelineExecutor {
}
}
// Watermark (after resize so watermark is at correct scale)
if let Some(ref config) = job.watermark {
img = apply_watermark(img, config)?;
}
// Determine output format
let output_format = if let Some(ref convert) = job.convert {
let input_fmt = source.original_format.unwrap_or(ImageFormat::Jpeg);
@@ -401,9 +396,13 @@ impl PipelineExecutor {
let dims = Some((img.width(), img.height()));
let original_ext = source.path.extension()
.and_then(|e| e.to_str());
// Apply regex on the stem before template expansion
let working_stem = crate::operations::rename::apply_regex_replace(
stem, &rename.regex_find, &rename.regex_replace,
);
let new_name = crate::operations::rename::apply_template_full(
template,
stem,
&working_stem,
ext,
rename.counter_start + index as u32,
dims,
@@ -411,6 +410,17 @@ impl PipelineExecutor {
Some(&source.path),
None,
);
// Apply case conversion to the final name (without extension)
let new_name = if rename.case_mode > 0 {
if let Some(dot_pos) = new_name.rfind('.') {
let (name_part, ext_part) = new_name.split_at(dot_pos);
format!("{}{}", crate::operations::rename::apply_case_conversion(name_part, rename.case_mode), ext_part)
} else {
crate::operations::rename::apply_case_conversion(&new_name, rename.case_mode)
}
} else {
new_name
};
job.output_dir.join(new_name)
} else {
let new_name = rename.apply_simple(stem, ext, index as u32 + 1);
@@ -444,16 +454,31 @@ impl PipelineExecutor {
std::fs::create_dir_all(parent).map_err(PixstripError::Io)?;
}
// Watermark (after compress settings determined, before encode)
if let Some(ref config) = job.watermark {
img = apply_watermark(img, config)?;
}
// Encode and save
encoder.encode_to_file(&img, &output_path, output_format, quality)?;
// Metadata stripping: re-encoding through the image crate naturally
// strips all EXIF/metadata. No additional action is needed for
// StripAll, Privacy, or Custom modes. KeepAll mode would require
// copying EXIF tags back from the source file using little_exif.
// Metadata handling: re-encoding strips all EXIF by default.
// KeepAll: copy everything back from source.
// Privacy/Custom: copy metadata back, then selectively strip certain tags.
// StripAll: do nothing (already stripped by re-encoding).
if let Some(ref meta_config) = job.metadata {
if matches!(meta_config, crate::operations::MetadataConfig::KeepAll) {
copy_metadata_from_source(&source.path, &output_path);
match meta_config {
crate::operations::MetadataConfig::KeepAll => {
copy_metadata_from_source(&source.path, &output_path);
}
crate::operations::MetadataConfig::StripAll => {
// Already stripped by re-encoding - nothing to do
}
_ => {
// Privacy or Custom: copy all metadata back, then strip unwanted tags
copy_metadata_from_source(&source.path, &output_path);
strip_selective_metadata(&output_path, meta_config);
}
}
}
@@ -509,3 +534,67 @@ fn copy_metadata_from_source(source: &std::path::Path, output: &std::path::Path)
};
let _: std::result::Result<(), std::io::Error> = metadata.write_to_file(output);
}
fn strip_selective_metadata(
path: &std::path::Path,
config: &crate::operations::MetadataConfig,
) {
use little_exif::exif_tag::ExifTag;
use little_exif::metadata::Metadata;
// Read the metadata we just wrote back
let Ok(source_meta) = Metadata::new_from_path(path) else {
return;
};
// Build a set of tag IDs to strip
let mut strip_ids: Vec<u16> = Vec::new();
if config.should_strip_gps() {
// GPSInfo pointer (0x8825) - removing it strips the GPS sub-IFD reference
strip_ids.push(ExifTag::GPSInfo(Vec::new()).as_u16());
}
if config.should_strip_camera() {
strip_ids.push(ExifTag::Make(String::new()).as_u16());
strip_ids.push(ExifTag::Model(String::new()).as_u16());
strip_ids.push(ExifTag::LensModel(String::new()).as_u16());
strip_ids.push(ExifTag::LensMake(String::new()).as_u16());
strip_ids.push(ExifTag::SerialNumber(String::new()).as_u16());
strip_ids.push(ExifTag::LensSerialNumber(String::new()).as_u16());
strip_ids.push(ExifTag::LensInfo(Vec::new()).as_u16());
}
if config.should_strip_software() {
strip_ids.push(ExifTag::Software(String::new()).as_u16());
strip_ids.push(ExifTag::MakerNote(Vec::new()).as_u16());
}
if config.should_strip_timestamps() {
strip_ids.push(ExifTag::ModifyDate(String::new()).as_u16());
strip_ids.push(ExifTag::DateTimeOriginal(String::new()).as_u16());
strip_ids.push(ExifTag::CreateDate(String::new()).as_u16());
strip_ids.push(ExifTag::SubSecTime(String::new()).as_u16());
strip_ids.push(ExifTag::SubSecTimeOriginal(String::new()).as_u16());
strip_ids.push(ExifTag::SubSecTimeDigitized(String::new()).as_u16());
strip_ids.push(ExifTag::OffsetTime(String::new()).as_u16());
strip_ids.push(ExifTag::OffsetTimeOriginal(String::new()).as_u16());
strip_ids.push(ExifTag::OffsetTimeDigitized(String::new()).as_u16());
}
if config.should_strip_copyright() {
strip_ids.push(ExifTag::Copyright(String::new()).as_u16());
strip_ids.push(ExifTag::Artist(String::new()).as_u16());
strip_ids.push(ExifTag::OwnerName(String::new()).as_u16());
}
// Build new metadata with only the tags we want to keep
let mut new_meta = Metadata::new();
for tag in source_meta.data() {
if !strip_ids.contains(&tag.as_u16()) {
new_meta.set_tag(tag.clone());
}
}
let _: std::result::Result<(), std::io::Error> = new_meta.write_to_file(path);
}

View File

@@ -279,6 +279,10 @@ pub struct RenameConfig {
pub counter_start: u32,
pub counter_padding: u32,
pub template: Option<String>,
/// 0=none, 1=lowercase, 2=uppercase, 3=title case
pub case_mode: u32,
pub regex_find: String,
pub regex_replace: String,
}
impl RenameConfig {
@@ -290,18 +294,23 @@ impl RenameConfig {
width = self.counter_padding as usize
);
// Apply regex find-and-replace on the original name
let working_name = rename::apply_regex_replace(original_name, &self.regex_find, &self.regex_replace);
let mut name = String::new();
if !self.prefix.is_empty() {
name.push_str(&self.prefix);
}
name.push_str(original_name);
name.push_str(&working_name);
if !self.suffix.is_empty() {
name.push_str(&self.suffix);
}
name.push('_');
name.push_str(&counter_str);
name.push('.');
name.push_str(extension);
name
// Apply case conversion
let name = rename::apply_case_conversion(&name, self.case_mode);
format!("{}.{}", name, extension)
}
}

View File

@@ -149,6 +149,43 @@ fn is_leap(year: u64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
/// Apply case conversion to a filename (without extension)
/// case_mode: 0=none, 1=lowercase, 2=uppercase, 3=title case
pub fn apply_case_conversion(name: &str, case_mode: u32) -> String {
match case_mode {
1 => name.to_lowercase(),
2 => name.to_uppercase(),
3 => {
// Title case: capitalize first letter of each word (split on _ - space)
name.split(|c: char| c == '_' || c == '-' || c == ' ')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => {
let upper: String = first.to_uppercase().collect();
upper + &chars.as_str().to_lowercase()
}
None => String::new(),
}
})
.collect::<Vec<_>>()
.join("_")
}
_ => name.to_string(),
}
}
/// Apply regex find-and-replace on a filename
pub fn apply_regex_replace(name: &str, find: &str, replace: &str) -> String {
if find.is_empty() {
return name.to_string();
}
match regex::Regex::new(find) {
Ok(re) => re.replace_all(name, replace).into_owned(),
Err(_) => name.to_string(),
}
}
pub fn resolve_collision(path: &Path) -> PathBuf {
if !path.exists() {
return path.to_path_buf();

View File

@@ -120,6 +120,9 @@ impl Preset {
counter_start: 1,
counter_padding: 3,
template: None,
case_mode: 0,
regex_find: String::new(),
regex_replace: String::new(),
}),
}
}
@@ -177,6 +180,9 @@ impl Preset {
counter_start: 1,
counter_padding: 4,
template: Some("{exif_date}_{name}_{counter:4}".into()),
case_mode: 0,
regex_find: String::new(),
regex_replace: String::new(),
}),
}
}