From 44f77264bdcbe6929e7d300ebb9ae00be8984151 Mon Sep 17 00:00:00 2001 From: marclaliberte Date: Sat, 10 Oct 2026 11:32:46 -0400 Subject: [PATCH 1/2] fix(export): embed keywords, rating and label as XMP Exports dropped RapidRAW tags, so other apps saw no keywords (#1887). Write an XMP packet with dc:subject keywords (user: prefix stripped, colour tags excluded), xmp:Rating and xmp:Label into JPEG, PNG and WebP exports when "Save with Metadata" is on, reading tags from the image's own sidecar so virtual copies keep theirs. When the source is a non-RAW image with embedded XMP (e.g. DJI GPano panoramas, #1728), merge into that packet instead of discarding it, dropping properties the export makes stale (tiff:Orientation, pixel dimensions, crs: develop settings, extended-XMP pointer). Source XMP is skipped when "Remove GPS Data" is on since it can carry location. libwebp's lossy output is a simple-format file that little_exif cannot convert, so WebP exports silently lost all EXIF; convert it to the extended format first. Show the metadata options for PNG and WebP too. Co-Authored-By: Claude Opus 5.5 --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/exif_processing.rs | 327 ++++++++++++++++++++- src-tauri/src/export_processing.rs | 13 +- src-tauri/src/file_management.rs | 47 +-- src/components/panel/right/ExportPanel.tsx | 2 +- 6 files changed, 367 insertions(+), 24 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f1bc931dc2..a8114f733c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -11,6 +11,7 @@ dependencies = [ "blake3", "bytemuck", "chrono", + "crc32fast", "fern", "filetime", "futures", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index af61552dc4..f33099dacf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -43,6 +43,7 @@ tauri-plugin-os = "2.3.2" log = "0.4" fern = "0.7" little_exif = "0.6.23" +crc32fast = "1.5.0" chrono = "0.4" tokenizers = { version = "0.23.1", default-features = false, features = ["onig"] } once_cell = "1.21" diff --git a/src-tauri/src/exif_processing.rs b/src-tauri/src/exif_processing.rs index cea2ba4d2c..1a835a215b 100644 --- a/src-tauri/src/exif_processing.rs +++ b/src-tauri/src/exif_processing.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::fs; use std::io::{BufReader, Cursor}; @@ -5,15 +6,21 @@ use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; use std::time::Duration; +use crate::file_management; use crate::formats::is_raw_file; -use crate::image_processing::ImageMetadata; +use crate::image_processing::{ImageFlag, ImageMetadata}; +use crate::tagging::{COLOR_TAG_PREFIX, USER_TAG_PREFIX}; use chrono::{DateTime, Local, LocalResult, NaiveDateTime, TimeZone, Utc}; use exif::{Exif, In, Value}; +use image::ImageDecoder; use little_exif::exif_tag::ExifTag; use little_exif::filetype::FileExtension; use little_exif::ifd::ExifTagGroup; use little_exif::metadata::Metadata; use little_exif::rational::{iR64, uR64}; +use quick_xml::escape::{escape, unescape}; +use quick_xml::events::{BytesStart, BytesText, Event}; +use quick_xml::{Reader, Writer}; use rawler::decoders::RawMetadata; use rawler::lens::LensDescription; use serde::{Deserialize, Serialize}; @@ -1228,12 +1235,34 @@ pub fn write_image_with_metadata( output_format: &str, keep_metadata: bool, strip_gps: bool, + xmp: &ExportXmp, ) -> Result<(), String> { // FIXME: temporary solution until I find a way to write metadata to TIFF if !keep_metadata || output_format.to_lowercase() == "tiff" { return Ok(()); } + if output_format.eq_ignore_ascii_case("webp") { + convert_webp_to_extended(image_bytes); + } + write_exif_metadata(image_bytes, original_path_str, output_format, strip_gps)?; + embed_xmp( + image_bytes, + original_path_str, + output_format, + strip_gps, + xmp, + ); + + Ok(()) +} + +fn write_exif_metadata( + image_bytes: &mut Vec, + original_path_str: &str, + output_format: &str, + strip_gps: bool, +) -> Result<(), String> { let original_path = Path::new(original_path_str); if !original_path.exists() { return Ok(()); @@ -1564,6 +1593,302 @@ pub fn write_image_with_metadata( Ok(()) } +pub struct ExportXmp { + keywords: Vec, + rating: Option, + label: Option, +} + +impl ExportXmp { + pub fn from_metadata(metadata: &ImageMetadata) -> Self { + let tags = metadata.tags.as_deref().unwrap_or_default(); + let mut keywords = Vec::new(); + for tag in tags.iter().filter(|t| !t.starts_with(COLOR_TAG_PREFIX)) { + push_keyword( + &mut keywords, + tag.strip_prefix(USER_TAG_PREFIX).unwrap_or(tag), + ); + } + Self { + keywords, + rating: (metadata.rating > 0 || metadata.flag == Some(ImageFlag::Reject)) + .then(|| file_management::xmp_rating(metadata)), + label: file_management::xmp_label(tags), + } + } +} + +fn push_keyword(keywords: &mut Vec, keyword: &str) { + let keyword = keyword.trim(); + if !keyword.is_empty() && !keywords.iter().any(|k| k == keyword) { + keywords.push(keyword.to_string()); + } +} + +fn embed_xmp( + image_bytes: &mut Vec, + original_path_str: &str, + output_format: &str, + strip_gps: bool, + xmp: &ExportXmp, +) { + let source_xmp = if strip_gps || is_raw_file(original_path_str) { + None + } else { + read_source_xmp(Path::new(original_path_str)) + }; + let Some(packet) = build_export_xmp(xmp, source_xmp.as_deref()) else { + return; + }; + + match output_format.to_lowercase().as_str() { + "jpg" | "jpeg" => embed_jpeg_xmp(image_bytes, &packet), + "png" => embed_png_xmp(image_bytes, &packet), + "webp" => embed_webp_xmp(image_bytes, &packet), + _ => {} + } +} + +fn read_source_xmp(path: &Path) -> Option { + let mut decoder = image::ImageReader::open(path) + .ok()? + .with_guessed_format() + .ok()? + .into_decoder() + .ok()?; + String::from_utf8(decoder.xmp_metadata().ok()??).ok() +} + +fn build_export_xmp(xmp: &ExportXmp, source_xmp: Option<&str>) -> Option { + let mut keywords = xmp.keywords.clone(); + for tag in source_xmp + .map(file_management::extract_xmp_tags) + .unwrap_or_default() + { + push_keyword( + &mut keywords, + &unescape(&tag).unwrap_or(Cow::Borrowed(&tag)), + ); + } + + let description = export_xmp_description(xmp, &keywords); + if let Some(merged) = source_xmp.and_then(|source| merge_source_xmp(source, &description, xmp)) + { + return Some(merged); + } + if description.is_empty() { + return None; + } + + Some(format!( + "\n\ +\n \ +\n\ +{} \n\ +\n\ +", + description + )) +} + +fn export_xmp_description(xmp: &ExportXmp, keywords: &[String]) -> String { + let mut properties = String::new(); + if let Some(rating) = &xmp.rating { + properties.push_str(&format!(" {}\n", rating)); + } + if let Some(label) = &xmp.label { + properties.push_str(&format!( + " {}\n", + escape(label.as_str()) + )); + } + if !keywords.is_empty() { + properties.push_str(" \n \n"); + for keyword in keywords { + properties.push_str(&format!( + " {}\n", + escape(keyword.as_str()) + )); + } + properties.push_str(" \n \n"); + } + if properties.is_empty() { + return properties; + } + + format!( + " \n{} \n", + properties + ) +} + +fn is_replaced_xmp_property(name: &[u8], xmp: &ExportXmp) -> bool { + name.starts_with(b"crs:") + || matches!( + name, + b"dc:subject" + | b"tiff:Orientation" + | b"tiff:ImageWidth" + | b"tiff:ImageLength" + | b"exif:PixelXDimension" + | b"exif:PixelYDimension" + | b"xmpNote:HasExtendedXMP" + ) + || (xmp.rating.is_some() && name == b"xmp:Rating") + || (xmp.label.is_some() && name == b"xmp:Label") +} + +fn without_replaced_attributes(element: &BytesStart, xmp: &ExportXmp) -> BytesStart<'static> { + let mut kept = BytesStart::new(String::from_utf8_lossy(element.name().as_ref()).into_owned()); + for attribute in element.attributes().with_checks(false).flatten() { + if !is_replaced_xmp_property(attribute.key.as_ref(), xmp) { + kept.push_attribute(attribute); + } + } + kept +} + +fn merge_source_xmp(source: &str, description: &str, xmp: &ExportXmp) -> Option { + let mut reader = Reader::from_str(source); + let mut writer = Writer::new(Vec::new()); + let mut inserted = false; + + loop { + match reader.read_event().ok()? { + Event::Eof => break, + Event::Start(e) if is_replaced_xmp_property(e.name().as_ref(), xmp) => { + reader.read_to_end(e.name()).ok()?; + } + Event::Empty(e) if is_replaced_xmp_property(e.name().as_ref(), xmp) => {} + Event::Start(e) => writer + .write_event(Event::Start(without_replaced_attributes(&e, xmp))) + .ok()?, + Event::Empty(e) => writer + .write_event(Event::Empty(without_replaced_attributes(&e, xmp))) + .ok()?, + Event::End(e) if e.name().as_ref() == b"rdf:RDF" => { + writer + .write_event(Event::Text(BytesText::from_escaped(description))) + .ok()?; + writer.write_event(Event::End(e)).ok()?; + inserted = true; + } + event => writer.write_event(event).ok()?, + } + } + + if !inserted { + return None; + } + String::from_utf8(writer.into_inner()).ok() +} + +fn embed_jpeg_xmp(image_bytes: &mut Vec, packet: &str) { + const XMP_NAMESPACE: &[u8] = b"http://ns.adobe.com/xap/1.0/\0"; + + if !image_bytes.starts_with(&[0xFF, 0xD8]) { + return; + } + let Ok(segment_len) = u16::try_from(2 + XMP_NAMESPACE.len() + packet.len()) else { + log::warn!("Skipping XMP metadata: packet exceeds JPEG segment size"); + return; + }; + + let mut insert_at = 2; + while insert_at + 4 <= image_bytes.len() + && image_bytes[insert_at] == 0xFF + && (0xE0..=0xEF).contains(&image_bytes[insert_at + 1]) + { + let len = u16::from_be_bytes([image_bytes[insert_at + 2], image_bytes[insert_at + 3]]); + insert_at += 2 + len as usize; + } + if insert_at > image_bytes.len() { + return; + } + + let mut segment = Vec::with_capacity(2 + segment_len as usize); + segment.extend_from_slice(&[0xFF, 0xE1]); + segment.extend_from_slice(&segment_len.to_be_bytes()); + segment.extend_from_slice(XMP_NAMESPACE); + segment.extend_from_slice(packet.as_bytes()); + image_bytes.splice(insert_at..insert_at, segment); +} + +fn embed_png_xmp(image_bytes: &mut Vec, packet: &str) { + const IHDR_END: usize = 33; + + if image_bytes.len() < IHDR_END || &image_bytes[12..16] != b"IHDR" { + return; + } + + let mut data = b"XML:com.adobe.xmp\0\0\0\0\0".to_vec(); + data.extend_from_slice(packet.as_bytes()); + let mut crc = crc32fast::Hasher::new(); + crc.update(b"iTXt"); + crc.update(&data); + + let mut chunk = Vec::with_capacity(data.len() + 12); + chunk.extend_from_slice(&(data.len() as u32).to_be_bytes()); + chunk.extend_from_slice(b"iTXt"); + chunk.extend_from_slice(&data); + chunk.extend_from_slice(&crc.finalize().to_be_bytes()); + image_bytes.splice(IHDR_END..IHDR_END, chunk); +} + +fn embed_webp_xmp(image_bytes: &mut Vec, packet: &str) { + const VP8X_FLAGS: usize = 20; + const XMP_FLAG: u8 = 0x04; + + if image_bytes.len() <= VP8X_FLAGS + || &image_bytes[0..4] != b"RIFF" + || &image_bytes[12..16] != b"VP8X" + { + return; + } + + image_bytes[VP8X_FLAGS] |= XMP_FLAG; + image_bytes.extend_from_slice(b"XMP "); + image_bytes.extend_from_slice(&(packet.len() as u32).to_le_bytes()); + image_bytes.extend_from_slice(packet.as_bytes()); + if !packet.len().is_multiple_of(2) { + image_bytes.push(0); + } + update_riff_size(image_bytes); +} + +fn convert_webp_to_extended(image_bytes: &mut Vec) { + const VP8_FRAME: usize = 20; + + if image_bytes.len() < VP8_FRAME + 10 + || &image_bytes[0..4] != b"RIFF" + || &image_bytes[12..16] != b"VP8 " + || image_bytes[VP8_FRAME + 3..VP8_FRAME + 6] != [0x9D, 0x01, 0x2A] + { + return; + } + + let dimension = |offset: usize| { + u32::from(u16::from_le_bytes([image_bytes[offset], image_bytes[offset + 1]]) & 0x3FFF) + .saturating_sub(1) + }; + let width = dimension(VP8_FRAME + 6); + let height = dimension(VP8_FRAME + 8); + + let mut vp8x = Vec::with_capacity(18); + vp8x.extend_from_slice(b"VP8X"); + vp8x.extend_from_slice(&10u32.to_le_bytes()); + vp8x.extend_from_slice(&[0; 4]); + vp8x.extend_from_slice(&width.to_le_bytes()[..3]); + vp8x.extend_from_slice(&height.to_le_bytes()[..3]); + image_bytes.splice(12..12, vp8x); + update_riff_size(image_bytes); +} + +fn update_riff_size(image_bytes: &mut [u8]) { + let riff_size = (image_bytes.len() - 8) as u32; + image_bytes[4..8].copy_from_slice(&riff_size.to_le_bytes()); +} + pub fn get_primary_sidecar_path(image_path: &Path) -> PathBuf { let mut filename = image_path.file_name().unwrap_or_default().to_os_string(); filename.push(".rrdata"); diff --git a/src-tauri/src/export_processing.rs b/src-tauri/src/export_processing.rs index f1b44bfbe6..336692d263 100644 --- a/src-tauri/src/export_processing.rs +++ b/src-tauri/src/export_processing.rs @@ -1056,6 +1056,7 @@ fn save_image_with_metadata( image: &DynamicImage, output_path: &std::path::Path, source_path_str: &str, + export_xmp: &exif_processing::ExportXmp, export_settings: &ExportSettings, ) -> Result<(), String> { let extension = output_path @@ -1077,6 +1078,7 @@ fn save_image_with_metadata( &extension, export_settings.keep_metadata, export_settings.strip_gps, + export_xmp, )?; #[cfg(target_os = "android")] @@ -1270,6 +1272,7 @@ fn export_masks_for_image( export_settings: &ExportSettings, output_path_obj: &std::path::Path, source_path_str: &str, + export_xmp: &exif_processing::ExportXmp, context: &Arc, state: &tauri::State, is_raw: bool, @@ -1372,6 +1375,7 @@ fn export_masks_for_image( &with_options, &mask_image_path, source_path_str, + export_xmp, export_settings, )?; ensure_export_not_cancelled(cancellation_token)?; @@ -1601,6 +1605,9 @@ pub(crate) async fn export_images_impl( ExportAdjustmentsMode::GlobalOverride(_) => false, }; + let sidecar = crate::exif_processing::load_sidecar(&sidecar_path); + let export_xmp = crate::exif_processing::ExportXmp::from_metadata(&sidecar); + let mut js_adjustments = match &adjustments_mode { ExportAdjustmentsMode::UseSidecars { active_adjustments, .. @@ -1609,10 +1616,10 @@ pub(crate) async fn export_images_impl( if let Some(adj) = active_adjustments { adj.clone() } else { - crate::exif_processing::load_sidecar(&sidecar_path).adjustments + sidecar.adjustments } } else { - crate::exif_processing::load_sidecar(&sidecar_path).adjustments + sidecar.adjustments } } ExportAdjustmentsMode::GlobalOverride(adj) => adj.clone(), @@ -1815,6 +1822,7 @@ pub(crate) async fn export_images_impl( &final_image, &output_path, &source_path_str, + &export_xmp, &export_settings, )?; ensure_export_not_cancelled(&cancellation_token_clone)?; @@ -1831,6 +1839,7 @@ pub(crate) async fn export_images_impl( &export_settings, &output_path, &source_path_str, + &export_xmp, &context_clone, &state, is_raw, diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 16e0f8b1ac..1aebc39dea 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -4148,6 +4148,27 @@ pub fn extract_xmp_rating(content: &str) -> Option { const XMP_REJECTED_RATING: i8 = -1; +pub fn xmp_rating(metadata: &ImageMetadata) -> String { + if metadata.flag == Some(ImageFlag::Reject) { + XMP_REJECTED_RATING.to_string() + } else { + metadata.rating.to_string() + } +} + +pub fn xmp_label(tags: &[String]) -> Option { + tags.iter() + .rev() + .find_map(|t| t.strip_prefix(COLOR_TAG_PREFIX)) + .map(|color| { + let mut c = color.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } + }) +} + pub fn extract_xmp_label(content: &str) -> Option { if let Some(idx) = content.find("xmp:Label=\"") { let start = idx + 11; @@ -4286,11 +4307,7 @@ pub fn sync_metadata_to_xmp(source_path: &Path, metadata: &ImageMetadata, create if let Some(xmp_file) = actual_xmp && let Ok(mut content) = fs::read_to_string(&xmp_file) { - let rating_str = if metadata.flag == Some(ImageFlag::Reject) { - XMP_REJECTED_RATING.to_string() - } else { - metadata.rating.to_string() - }; + let rating_str = xmp_rating(metadata); let re_rating_attr = regex!(r#"xmp:Rating\s*=\s*"[^"]*""#); let re_rating_tag = regex!(r#"[^<]*"#); @@ -4308,21 +4325,11 @@ pub fn sync_metadata_to_xmp(source_path: &Path, metadata: &ImageMetadata, create } let current_tags = metadata.tags.clone().unwrap_or_default(); - let mut label = None; - let mut normal_tags = Vec::new(); - - for t in current_tags { - if let Some(color) = t.strip_prefix(COLOR_TAG_PREFIX) { - let mut c = color.chars(); - let cap_color = match c.next() { - None => String::new(), - Some(f) => f.to_uppercase().collect::() + c.as_str(), - }; - label = Some(cap_color); - } else { - normal_tags.push(t); - } - } + let label = xmp_label(¤t_tags); + let normal_tags: Vec = current_tags + .into_iter() + .filter(|t| !t.starts_with(COLOR_TAG_PREFIX)) + .collect(); if let Some(lbl) = label { let re_label_attr = regex!(r#"xmp:Label\s*=\s*"[^"]*""#); diff --git a/src/components/panel/right/ExportPanel.tsx b/src/components/panel/right/ExportPanel.tsx index 47c029417d..fd336d0f81 100644 --- a/src/components/panel/right/ExportPanel.tsx +++ b/src/components/panel/right/ExportPanel.tsx @@ -966,7 +966,7 @@ export default function ExportPanel({ )} - {fileFormat == FileFormats.Jpeg && ( + {[FileFormats.Jpeg, FileFormats.Png, FileFormats.Webp].includes(fileFormat as FileFormats) && (
Date: Sun, 11 Oct 2026 01:10:46 -0400 Subject: [PATCH 2/2] refactor(export): resolve output format once and share subject bag Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/exif_processing.rs | 57 ++++++++++++++------------------ src-tauri/src/file_management.rs | 15 ++++++--- 2 files changed, 34 insertions(+), 38 deletions(-) diff --git a/src-tauri/src/exif_processing.rs b/src-tauri/src/exif_processing.rs index 1a835a215b..9824d6916d 100644 --- a/src-tauri/src/exif_processing.rs +++ b/src-tauri/src/exif_processing.rs @@ -1242,17 +1242,21 @@ pub fn write_image_with_metadata( return Ok(()); } - if output_format.eq_ignore_ascii_case("webp") { + let file_type = match output_format.to_lowercase().as_str() { + "jpg" | "jpeg" => FileExtension::JPEG, + "png" => FileExtension::PNG { + as_zTXt_chunk: true, + }, + "tiff" => FileExtension::TIFF, + "webp" => FileExtension::WEBP, + _ => return Ok(()), + }; + + if file_type == FileExtension::WEBP { convert_webp_to_extended(image_bytes); } - write_exif_metadata(image_bytes, original_path_str, output_format, strip_gps)?; - embed_xmp( - image_bytes, - original_path_str, - output_format, - strip_gps, - xmp, - ); + write_exif_metadata(image_bytes, original_path_str, file_type, strip_gps)?; + embed_xmp(image_bytes, original_path_str, file_type, strip_gps, xmp); Ok(()) } @@ -1260,7 +1264,7 @@ pub fn write_image_with_metadata( fn write_exif_metadata( image_bytes: &mut Vec, original_path_str: &str, - output_format: &str, + file_type: FileExtension, strip_gps: bool, ) -> Result<(), String> { let original_path = Path::new(original_path_str); @@ -1278,16 +1282,6 @@ fn write_exif_metadata( return Ok(()); } - let file_type = match output_format.to_lowercase().as_str() { - "jpg" | "jpeg" => FileExtension::JPEG, - "png" => FileExtension::PNG { - as_zTXt_chunk: true, - }, - "tiff" => FileExtension::TIFF, - "webp" => FileExtension::WEBP, - _ => return Ok(()), - }; - let mut metadata = Metadata::new(); let full_exif_copied = !is_raw_file(original_path_str) @@ -1628,7 +1622,7 @@ fn push_keyword(keywords: &mut Vec, keyword: &str) { fn embed_xmp( image_bytes: &mut Vec, original_path_str: &str, - output_format: &str, + file_type: FileExtension, strip_gps: bool, xmp: &ExportXmp, ) { @@ -1641,10 +1635,10 @@ fn embed_xmp( return; }; - match output_format.to_lowercase().as_str() { - "jpg" | "jpeg" => embed_jpeg_xmp(image_bytes, &packet), - "png" => embed_png_xmp(image_bytes, &packet), - "webp" => embed_webp_xmp(image_bytes, &packet), + match file_type { + FileExtension::JPEG => embed_jpeg_xmp(image_bytes, &packet), + FileExtension::PNG { .. } => embed_png_xmp(image_bytes, &packet), + FileExtension::WEBP => embed_webp_xmp(image_bytes, &packet), _ => {} } } @@ -1703,14 +1697,11 @@ fn export_xmp_description(xmp: &ExportXmp, keywords: &[String]) -> String { )); } if !keywords.is_empty() { - properties.push_str(" \n \n"); - for keyword in keywords { - properties.push_str(&format!( - " {}\n", - escape(keyword.as_str()) - )); - } - properties.push_str(" \n \n"); + let escaped: Vec> = keywords.iter().map(|k| escape(k.as_str())).collect(); + properties.push_str(&format!( + " {}\n", + file_management::xmp_subject_bag(&escaped) + )); } if properties.is_empty() { return properties; diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 1aebc39dea..55124705bf 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -4169,6 +4169,15 @@ pub fn xmp_label(tags: &[String]) -> Option { }) } +pub fn xmp_subject_bag>(items: &[S]) -> String { + let mut bag = String::from("\n \n"); + for item in items { + bag.push_str(&format!(" {}\n", item.as_ref())); + } + bag.push_str(" \n "); + bag +} + pub fn extract_xmp_label(content: &str) -> Option { if let Some(idx) = content.find("xmp:Label=\"") { let start = idx + 11; @@ -4358,11 +4367,7 @@ pub fn sync_metadata_to_xmp(source_path: &Path, metadata: &ImageMetadata, create if normal_tags.is_empty() { content = re_subject.replace_all(&content, "").to_string(); } else { - let mut bag = String::from("\n \n"); - for t in normal_tags { - bag.push_str(&format!(" {}\n", t)); - } - bag.push_str(" \n "); + let bag = xmp_subject_bag(&normal_tags); if re_subject.is_match(&content) { content = re_subject.replace(&content, bag).to_string();