From b3ced063ef7323fc1daeb92b249bbea47a15188f Mon Sep 17 00:00:00 2001 From: "A. Molzer" <5550310+197g@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:01:40 +0100 Subject: [PATCH] Write tag filter based on tag kind/'Level' --- src/encoder/tag_filter.rs | 212 ++++++++++++++++++++++++++++++-------- 1 file changed, 169 insertions(+), 43 deletions(-) diff --git a/src/encoder/tag_filter.rs b/src/encoder/tag_filter.rs index a010ed7..23cec3f 100644 --- a/src/encoder/tag_filter.rs +++ b/src/encoder/tag_filter.rs @@ -1,23 +1,68 @@ +use crate::tags::{PhotometricInterpretation, PlanarConfiguration}; + /// A filter of tags copied in /// [`DirectoryEncoder::write_metadata_from`][`super::DirectoryEncoder::write_metadata_from`]. pub struct TagFilter { - unknown: bool, + filter_level: FilterLevel, } impl TagFilter { /// Return a filter that allows all tags from metadata subdirectories. pub fn allow_all_in_subdirectories() -> &'static TagFilter { - static INSTANCE: TagFilter = TagFilter { unknown: true }; + static INSTANCE: TagFilter = TagFilter { + filter_level: FilterLevel::All, + }; &INSTANCE } /// Return a filter that has an explicit allow list of tags, for each of a known list of /// metadata subdirectories. - pub fn allow_known() -> &'static TagFilter { - static INSTANCE: TagFilter = TagFilter { unknown: false }; + pub fn only_known_tags() -> &'static TagFilter { + static INSTANCE: TagFilter = TagFilter { + filter_level: FilterLevel::Known, + }; &INSTANCE } + /// Return a filter that attempts to remove identifying tags. This is stricter than + /// [`Self::only_known_tags`] and excludes many tags except those describing the image data + /// layout. + /// + /// **Note that it comes with *no warranty*.** although this will remove tags with prejudice. + pub fn attempt_at_privacy() -> &'static TagFilter { + static INSTANCE: TagFilter = TagFilter { + filter_level: FilterLevel::Privacy, + }; + &INSTANCE + } + + /// FIXME: take a configuration by which to filter out complete metadata kinds. + /// FIXME: when we iterate over a whole directory of tags, this iteration is more expensive + /// than necessary. Tags within a directory should be ordered. We can thus perform a binary set + /// join which is more efficient than checking the individual filter on each. Does not matter + /// much in most cases though. + pub(crate) fn filter_primary(&self, tag: u16) -> Level { + let as_exif = self.filter_primary_exif(tag); + + if !matches!(as_exif, Level::Unknown) { + return as_exif; + } + + let as_gps = self.filter_primary_gps(tag); + + if !matches!(as_gps, Level::Unknown) { + return as_gps; + } + + let as_icc = self.filter_icc(tag); + + if !matches!(as_icc, Level::Unknown) { + return as_icc; + } + + Level::Unknown + } + /// As per EXIF V3.0 tags, Primary Image (0th IFD). pub(crate) fn filter_primary_exif(&self, tag: u16) -> Level { match tag { @@ -25,20 +70,26 @@ impl TagFilter { 0x0100..=0x0103 => Level::SampleLayout, // photometric interpretation 0x0106 => Level::SampleLayout, - 0x010e..=0x0110 => Level::Basic, + // data tied to the camera and customized description, potentially PI. + 0x010f..=0x0110 => Level::Value, // strip offsets 0x0111 => Level::SampleLayout, - 0x0112 => Level::Basic, + // orientation + 0x0112 => Level::Value, // strip byte counts, samples 0x0115..=0x0117 => Level::SampleLayout, - 0x011a..=0x011b => Level::Basic, + // pixel dimensions (ratio). Should be generic enough. + 0x011a..=0x011b => Level::Value, // planar configuration 0x011c => Level::Photometric(Photometric::Planar), - 0x0128 => Level::Basic, + // units for 0x011a,0x011b + 0x0128 => Level::Value, // transfer function 0x012d => Level::Photometric(Photometric::All), - 0x0131..=0x0132 => Level::Basic, - 0x013b => Level::Basic, + // file data time, software pipeline. I'm wary of privacy implications. + 0x0131..=0x0132 => Level::Value, + // artist, clearly PI unless you explicitly wish it to be attributed. + 0x013b => Level::Value, // Whitepoint, Chromaticities 0x013e..=0x013f => Level::Photometric(Photometric::All), // The table lists these as Not allowed for anything but we may have a choice here for @@ -46,7 +97,8 @@ impl TagFilter { 0x0201..=0x0202 => Level::Special(Special::JpegThumbnail), 0x0211..=0x0213 => Level::Photometric(Photometric::YCbCr), 0x0214 => Level::Photometric(Photometric::All), - 0x8298 => Level::Basic, + // Copyright holder (not same as artist). + 0x8298 => Level::Value, 0x8769 => Level::Ifd(SubifdKind::ExifPrivate), _ => Level::Unknown, } @@ -61,54 +113,57 @@ impl TagFilter { pub(crate) fn filter_icc(&self, tag: u16) -> Level { match tag { - 0x8ff3 => Level::Basic, + 0x8ff3 => Level::Value, _ => Level::Unknown, } } pub(crate) fn filter_exif_private(&self, tag: u16) -> Level { match tag { - 0x829a => Level::Basic, - 0x829d => Level::Basic, - 0x8822 => Level::Basic, - 0x8824 => Level::Basic, - 0x8827 => Level::Basic, - 0x8828 => Level::Basic, - 0x8830..=0x8835 => Level::Basic, - 0x9000 => Level::Basic, - 0x9003..=0x9004 => Level::Basic, - 0x9010..=0x9012 => Level::Basic, + // Maybe this is not personal identifying but some equipment may be very specifically + // known through these. And it's not critical to displaying it correctly. + 0x829a => Level::Value, + 0x829d => Level::Value, + 0x8822 => Level::Value, + 0x8824 => Level::Value, + 0x8827 => Level::Value, + // Opto-electric coefficients + 0x8828 => Level::Value, + 0x8830..=0x8835 => Level::Value, + 0x9000 => Level::Value, + 0x9003..=0x9004 => Level::Value, + 0x9010..=0x9012 => Level::Value, // Not allowed for uncompressed data! 0x9101..=0x9102 => Level::Special(Special::CompressedExclusive), - 0x9201..=0x9209 => Level::Basic, - 0x920a => Level::Basic, - 0x9214 => Level::Basic, - 0x927c => Level::Basic, - 0x9286 => Level::Basic, - 0x9290..=0x9292 => Level::Basic, - 0x9400..=0x9405 => Level::Basic, - 0xa000 => Level::Basic, + 0x9201..=0x9209 => Level::Value, + 0x920a => Level::Value, + 0x9214 => Level::Value, + 0x927c => Level::Value, + 0x9286 => Level::Value, + 0x9290..=0x9292 => Level::Value, + 0x9400..=0x9405 => Level::Value, + 0xa000 => Level::Value, 0xa001 => Level::Photometric(Photometric::All), 0xa002..=0xa003 => Level::Special(Special::CompressedExclusive), - 0xa004 => Level::Basic, + 0xa004 => Level::Value, 0xa005 => Level::Ifd(SubifdKind::Interoperability), - 0xa20b..=0xa20c => Level::Basic, - 0xa20e..=0xa210 => Level::Basic, - 0xa214..=0xa215 => Level::Basic, - 0xa217 => Level::Basic, - 0xa300..=0xa302 => Level::Basic, - 0xa401..=0xa40c => Level::Basic, - 0xa420 => Level::Basic, - 0xa430..=0xa43c => Level::Basic, - 0xa460..=0xa462 => Level::Basic, + 0xa20b..=0xa20c => Level::Value, + 0xa20e..=0xa210 => Level::Value, + 0xa214..=0xa215 => Level::Value, + 0xa217 => Level::Value, + 0xa300..=0xa302 => Level::Value, + 0xa401..=0xa40c => Level::Value, + 0xa420 => Level::Value, + 0xa430..=0xa43c => Level::Value, + 0xa460..=0xa462 => Level::Value, 0xa500 => Level::Photometric(Photometric::All), _ => Level::Unknown, } } - fn filter_gps_private(&self, tag: u16) -> Level { + pub(crate) fn filter_gps_private(&self, tag: u16) -> Level { match tag { - 0x0000..=0x001f => Level::Basic, + 0x0000..=0x001f => Level::Value, _ => Level::Unknown, } } @@ -130,6 +185,61 @@ impl TagFilter { pub(crate) fn filter_secondary_gps(&self, tag: u16) -> Level { self.filter_primary_gps(tag) } + + pub(crate) fn should_keep_on_level( + &self, + level: Level, + target: &super::EncodeMetadataApplicability, + ) -> Choice { + match level { + Level::Unknown if matches!(self.filter_level, FilterLevel::All) => Choice::Ok, + Level::SampleLayout => Choice::Ok, + Level::Ifd(subifd_kind) => Choice::Descend(subifd_kind), + Level::Photometric(photometric) => { + match (photometric, target.planar, target.photometric) { + (Photometric::All, _, _) => Choice::Ok, + (Photometric::Planar, Some(PlanarConfiguration::Planar), _) => Choice::Ok, + (Photometric::Planar, None, _) + if matches!(self.filter_level, FilterLevel::All) => + { + Choice::Ok + } + (Photometric::YCbCr, _, Some(PhotometricInterpretation::YCbCr)) => Choice::Ok, + (Photometric::YCbCr, _, None) + if matches!(self.filter_level, FilterLevel::All) => + { + Choice::Ok + } + _ => Choice::Discard, + } + } + Level::Value if matches!(self.filter_level, FilterLevel::Privacy) => Choice::Discard, + Level::Value => Choice::Ok, + Level::Special(Special::JpegThumbnail) + if matches!(self.filter_level, FilterLevel::Privacy) => + { + // Contains arbitrary unverified other data, so, no, we do not heed backward + // compatibility of a tag that should no longer be in active use if possible. + Choice::Discard + } + Level::Special(special) => Choice::Special(special), + _ => Choice::Discard, + } + } +} + +pub(crate) enum Choice { + Ok, + Descend(SubifdKind), + Discard, + Special(Special), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FilterLevel { + All, + Known, + Privacy, } /// The level of support for a value. @@ -138,12 +248,27 @@ pub(crate) enum Level { Unknown, /// Value describes the image data layout, which we will not want to write into the encoder's /// directory where the image data is written later. + /// + /// These are treated as privacy preserving. SampleLayout, /// Value is an IFD and must be copied by making a subdirectory. + /// + /// These are treated as privacy preserving, independent of the tags within the subdirectory + /// which may not be. (For instance the presence of an empty directory is fine but we will omit + /// a GPS anyways). Ifd(SubifdKind), + /// Value is related to photometric interpretation and should not be copied when the target + /// does not match the expected interpretation. + /// + /// These are *generally* treated as privacy preserving. Photometric(Photometric), - Basic, + /// This is a value we know of. + /// FIXME: we do not differentiate by personal identifiable information and otherwise which is + /// quite important for some variations of copying metadata. + Value, /// We recognize the tag but it can not be copied by itself through normal means. + /// + /// Privacy treatment depends on the tag. Special(Special), } @@ -165,6 +290,7 @@ pub(crate) enum SubifdKind { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Special { + /// Thumbnail which we regard as PI since we do not deep-clean the JPEG structure. JpegThumbnail, /// May only occur in compressed (JPEG APP segments) data. CompressedExclusive,