diff options
Diffstat (limited to 'src/photos/PhotoMetadata.vala')
-rw-r--r-- | src/photos/PhotoMetadata.vala | 1169 |
1 files changed, 1169 insertions, 0 deletions
diff --git a/src/photos/PhotoMetadata.vala b/src/photos/PhotoMetadata.vala new file mode 100644 index 0000000..37804bf --- /dev/null +++ b/src/photos/PhotoMetadata.vala @@ -0,0 +1,1169 @@ +/* Copyright 2010-2014 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +// +// PhotoMetadata +// +// PhotoMetadata is a wrapper class around gexiv2. The reasoning for this is (a) to facilitiate +// interface changes to meet Shotwell's requirements without needing modifications of the library +// itself, and (b) some requirements for this class (i.e. obtaining raw metadata) is not available +// in gexiv2, and so must be done by hand. +// +// Although it's perceived that Exiv2 will remain Shotwell's metadata library of choice, this +// may change in the future, and so this wrapper helps with that as well. +// +// There is no expectation of thread-safety in this class (yet). +// +// Tags come from Exiv2's naming scheme: +// http://www.exiv2.org/metadata.html +// + +public enum MetadataDomain { + UNKNOWN, + EXIF, + XMP, + IPTC +} + +public class HierarchicalKeywordField { + public string field_name; + public string path_separator; + public bool wants_leading_separator; + public bool is_writeable; + + public HierarchicalKeywordField(string field_name, string path_separator, + bool wants_leading_separator, bool is_writeable) { + this.field_name = field_name; + this.path_separator = path_separator; + this.wants_leading_separator = wants_leading_separator; + this.is_writeable = is_writeable; + } +} + +public abstract class PhotoPreview { + private string name; + private Dimensions dimensions; + private uint32 size; + private string mime_type; + private string extension; + + public PhotoPreview(string name, Dimensions dimensions, uint32 size, string mime_type, string extension) { + this.name = name; + this.dimensions = dimensions; + this.size = size; + this.mime_type = mime_type; + this.extension = extension; + } + + public string get_name() { + return name; + } + + public Dimensions get_pixel_dimensions() { + return dimensions; + } + + public uint32 get_size() { + return size; + } + + public string get_mime_type() { + return mime_type; + } + + public string get_extension() { + return extension; + } + + public abstract uint8[] flatten() throws Error; + + public virtual Gdk.Pixbuf? get_pixbuf() throws Error { + uint8[] flattened = flatten(); + + // Need to create from stream or file for decode ... catch decode error and return null, + // different from an I/O error causing the problem + try { + return new Gdk.Pixbuf.from_stream(new MemoryInputStream.from_data(flattened, null), + null); + } catch (Error err) { + warning("Unable to decode thumbnail for %s: %s", name, err.message); + + return null; + } + } +} + +public class PhotoMetadata : MediaMetadata { + public enum SetOption { + ALL_DOMAINS, + ONLY_IF_DOMAIN_PRESENT, + AT_LEAST_DEFAULT_DOMAIN + } + + private const PrepareInputTextOptions PREPARE_STRING_OPTIONS = + PrepareInputTextOptions.INVALID_IS_NULL + | PrepareInputTextOptions.EMPTY_IS_NULL + | PrepareInputTextOptions.STRIP + | PrepareInputTextOptions.STRIP_CRLF + | PrepareInputTextOptions.NORMALIZE + | PrepareInputTextOptions.VALIDATE; + + private class InternalPhotoPreview : PhotoPreview { + public PhotoMetadata owner; + public uint number; + + public InternalPhotoPreview(PhotoMetadata owner, string name, uint number, + GExiv2.PreviewProperties props) { + base (name, Dimensions((int) props.get_width(), (int) props.get_height()), + props.get_size(), props.get_mime_type(), props.get_extension()); + + this.owner = owner; + this.number = number; + } + + public override uint8[] flatten() throws Error { + unowned GExiv2.PreviewProperties?[] props = owner.exiv2.get_preview_properties(); + assert(props != null && props.length > number); + + return owner.exiv2.get_preview_image(props[number]).get_data(); + } + } + + private GExiv2.Metadata exiv2 = new GExiv2.Metadata(); + private Exif.Data? exif = null; + string source_name = "<uninitialized>"; + + public PhotoMetadata() { + } + + public override void read_from_file(File file) throws Error { + exiv2 = new GExiv2.Metadata(); + exif = null; + + exiv2.open_path(file.get_path()); + exif = Exif.Data.new_from_file(file.get_path()); + source_name = file.get_basename(); + } + + public void write_to_file(File file) throws Error { + exiv2.save_file(file.get_path()); + } + + public void read_from_buffer(uint8[] buffer, int length = 0) throws Error { + if (length <= 0) + length = buffer.length; + + assert(buffer.length >= length); + + exiv2 = new GExiv2.Metadata(); + exif = null; + + exiv2.open_buf(buffer, length); + exif = Exif.Data.new_from_data(buffer, length); + source_name = "<memory buffer %d bytes>".printf(length); + } + + public void read_from_app1_segment(uint8[] buffer, int length = 0) throws Error { + if (length <= 0) + length = buffer.length; + + assert(buffer.length >= length); + + exiv2 = new GExiv2.Metadata(); + exif = null; + + exiv2.from_app1_segment(buffer, length); + exif = Exif.Data.new_from_data(buffer, length); + source_name = "<app1 segment %d bytes>".printf(length); + } + + public static MetadataDomain get_tag_domain(string tag) { + if (GExiv2.Metadata.is_exif_tag(tag)) + return MetadataDomain.EXIF; + + if (GExiv2.Metadata.is_xmp_tag(tag)) + return MetadataDomain.XMP; + + if (GExiv2.Metadata.is_iptc_tag(tag)) + return MetadataDomain.IPTC; + + return MetadataDomain.UNKNOWN; + } + + public bool has_domain(MetadataDomain domain) { + switch (domain) { + case MetadataDomain.EXIF: + return exiv2.has_exif(); + + case MetadataDomain.XMP: + return exiv2.has_xmp(); + + case MetadataDomain.IPTC: + return exiv2.has_iptc(); + + case MetadataDomain.UNKNOWN: + default: + return false; + } + } + + public bool has_exif() { + return has_domain(MetadataDomain.EXIF); + } + + public bool has_xmp() { + return has_domain(MetadataDomain.XMP); + } + + public bool has_iptc() { + return has_domain(MetadataDomain.IPTC); + } + + public bool can_write_to_domain(MetadataDomain domain) { + switch (domain) { + case MetadataDomain.EXIF: + return exiv2.get_supports_exif(); + + case MetadataDomain.XMP: + return exiv2.get_supports_xmp(); + + case MetadataDomain.IPTC: + return exiv2.get_supports_iptc(); + + case MetadataDomain.UNKNOWN: + default: + return false; + } + } + + public bool can_write_exif() { + return can_write_to_domain(MetadataDomain.EXIF); + } + + public bool can_write_xmp() { + return can_write_to_domain(MetadataDomain.XMP); + } + + public bool can_write_iptc() { + return can_write_to_domain(MetadataDomain.IPTC); + } + + public bool has_tag(string tag) { + return exiv2.has_tag(tag); + } + + private Gee.Set<string> create_string_set(owned CompareDataFunc<string>? compare_func) { + // ternary doesn't work here + if (compare_func == null) + return new Gee.HashSet<string>(); + else + return new Gee.TreeSet<string>((owned) compare_func); + } + + public Gee.Collection<string>? get_tags(MetadataDomain domain, + owned CompareDataFunc<string>? compare_func = null) { + string[] tags = null; + switch (domain) { + case MetadataDomain.EXIF: + tags = exiv2.get_exif_tags(); + break; + + case MetadataDomain.XMP: + tags = exiv2.get_xmp_tags(); + break; + + case MetadataDomain.IPTC: + tags = exiv2.get_iptc_tags(); + break; + } + + if (tags == null || tags.length == 0) + return null; + + Gee.Collection<string> collection = create_string_set((owned) compare_func); + foreach (string tag in tags) + collection.add(tag); + + return collection; + } + + public Gee.Collection<string> get_all_tags( + owned CompareDataFunc<string>? compare_func = null) { + Gee.Collection<string> all_tags = create_string_set((owned) compare_func); + + Gee.Collection<string>? exif_tags = get_tags(MetadataDomain.EXIF); + if (exif_tags != null && exif_tags.size > 0) + all_tags.add_all(exif_tags); + + Gee.Collection<string>? xmp_tags = get_tags(MetadataDomain.XMP); + if (xmp_tags != null && xmp_tags.size > 0) + all_tags.add_all(xmp_tags); + + Gee.Collection<string>? iptc_tags = get_tags(MetadataDomain.IPTC); + if (iptc_tags != null && iptc_tags.size > 0) + all_tags.add_all(iptc_tags); + + return all_tags.size > 0 ? all_tags : null; + } + + public string? get_tag_label(string tag) { + return GExiv2.Metadata.get_tag_label(tag); + } + + public string? get_tag_description(string tag) { + return GExiv2.Metadata.get_tag_description(tag); + } + + public string? get_string(string tag, PrepareInputTextOptions options = PREPARE_STRING_OPTIONS) { + return prepare_input_text(exiv2.get_tag_string(tag), options, DEFAULT_USER_TEXT_INPUT_LENGTH); + } + + public string? get_string_interpreted(string tag, PrepareInputTextOptions options = PREPARE_STRING_OPTIONS) { + return prepare_input_text(exiv2.get_tag_interpreted_string(tag), options, DEFAULT_USER_TEXT_INPUT_LENGTH); + } + + public string? get_first_string(string[] tags) { + foreach (string tag in tags) { + string? value = get_string(tag); + if (value != null) + return value; + } + + return null; + } + + public string? get_first_string_interpreted(string[] tags) { + foreach (string tag in tags) { + string? value = get_string_interpreted(tag); + if (value != null) + return value; + } + + return null; + } + + // Returns a List that has been filtered through a Set, so no duplicates will be returned. + // + // NOTE: get_tag_multiple() in gexiv2 currently does not work with EXIF tags (as EXIF can + // never return a list of strings). It will quietly return NULL if attempted. Until fixed + // (there or here), don't use this function to access EXIF. See: + // http://trac.yorba.org/ticket/2966 + public Gee.List<string>? get_string_multiple(string tag) { + string[] values = exiv2.get_tag_multiple(tag); + if (values == null || values.length == 0) + return null; + + Gee.List<string> list = new Gee.ArrayList<string>(); + + Gee.HashSet<string> collection = new Gee.HashSet<string>(); + foreach (string value in values) { + string? prepped = prepare_input_text(value, PREPARE_STRING_OPTIONS, + DEFAULT_USER_TEXT_INPUT_LENGTH); + + if (prepped != null && !collection.contains(prepped)) { + list.add(prepped); + collection.add(prepped); + } + } + + return list.size > 0 ? list : null; + } + + // Returns a List that has been filtered through a Set, so no duplicates will be found. + // + // NOTE: get_tag_multiple() in gexiv2 currently does not work with EXIF tags (as EXIF can + // never return a list of strings). It will quietly return NULL if attempted. Until fixed + // (there or here), don't use this function to access EXIF. See: + // http://trac.yorba.org/ticket/2966 + public Gee.List<string>? get_first_string_multiple(string[] tags) { + foreach (string tag in tags) { + Gee.List<string>? values = get_string_multiple(tag); + if (values != null && values.size > 0) + return values; + } + + return null; + } + + public void set_string(string tag, string value, PrepareInputTextOptions options = PREPARE_STRING_OPTIONS) { + string? prepped = prepare_input_text(value, options, DEFAULT_USER_TEXT_INPUT_LENGTH); + if (prepped == null) { + warning("Not setting tag %s to string %s: invalid UTF-8", tag, value); + + return; + } + + if (!exiv2.set_tag_string(tag, prepped)) + warning("Unable to set tag %s to string %s from source %s", tag, value, source_name); + } + + private delegate void SetGenericValue(string tag); + + private void set_all_generic(string[] tags, SetOption option, SetGenericValue setter) { + bool written = false; + foreach (string tag in tags) { + if (option == SetOption.ALL_DOMAINS || has_domain(get_tag_domain(tag))) { + setter(tag); + written = true; + } + } + + if (option == SetOption.AT_LEAST_DEFAULT_DOMAIN && !written && tags.length > 0) { + MetadataDomain default_domain = get_tag_domain(tags[0]); + + // write at least the first one, as it's the default + setter(tags[0]); + + // write the remainder, if they are of the same domain + for (int ctr = 1; ctr < tags.length; ctr++) { + if (get_tag_domain(tags[ctr]) == default_domain) + setter(tags[ctr]); + } + } + } + + public void set_all_string(string[] tags, string value, SetOption option) { + set_all_generic(tags, option, (tag) => { set_string(tag, value); }); + } + + public void set_string_multiple(string tag, Gee.Collection<string> collection) { + string[] values = new string[0]; + foreach (string value in collection) { + string? prepped = prepare_input_text(value, PREPARE_STRING_OPTIONS,-1); + if (prepped != null) + values += prepped; + else + warning("Unable to set string %s to %s: invalid UTF-8", value, tag); + } + + if (values.length == 0) + return; + + // append a null pointer to the end of the string array -- this is a necessary + // workaround for http://trac.yorba.org/ticket/3264. See also + // http://trac.yorba.org/ticket/3257, which describes the user-visible behavior + // seen in the Flickr Connector as a result of the former bug. + values += null; + + if (!exiv2.set_tag_multiple(tag, values)) + warning("Unable to set %d strings to tag %s from source %s", values.length, tag, source_name); + } + + public void set_all_string_multiple(string[] tags, Gee.Collection<string> values, SetOption option) { + set_all_generic(tags, option, (tag) => { set_string_multiple(tag, values); }); + } + + public bool get_long(string tag, out long value) { + if (!has_tag(tag)) { + value = 0; + + return false; + } + + value = exiv2.get_tag_long(tag); + + return true; + } + + public bool get_first_long(string[] tags, out long value) { + foreach (string tag in tags) { + if (get_long(tag, out value)) + return true; + } + + value = 0; + + return false; + } + + public void set_long(string tag, long value) { + if (!exiv2.set_tag_long(tag, value)) + warning("Unable to set tag %s to long %ld from source %s", tag, value, source_name); + } + + public void set_all_long(string[] tags, long value, SetOption option) { + set_all_generic(tags, option, (tag) => { set_long(tag, value); }); + } + + public bool get_rational(string tag, out MetadataRational rational) { + int numerator, denominator; + bool result = exiv2.get_exif_tag_rational(tag, out numerator, out denominator); + + rational = MetadataRational(numerator, denominator); + + return result; + } + + public bool get_first_rational(string[] tags, out MetadataRational rational) { + foreach (string tag in tags) { + if (get_rational(tag, out rational)) + return true; + } + + rational = MetadataRational(0, 0); + + return false; + } + + public void set_rational(string tag, MetadataRational rational) { + if (!exiv2.set_exif_tag_rational(tag, rational.numerator, rational.denominator)) { + warning("Unable to set tag %s to rational %s from source %s", tag, rational.to_string(), + source_name); + } + } + + public void set_all_rational(string[] tags, MetadataRational rational, SetOption option) { + set_all_generic(tags, option, (tag) => { set_rational(tag, rational); }); + } + + public MetadataDateTime? get_date_time(string tag) { + string? value = get_string(tag); + if (value == null) + return null; + + try { + switch (get_tag_domain(tag)) { + case MetadataDomain.XMP: + return new MetadataDateTime.from_xmp(value); + + // TODO: IPTC date/time support (which is tricky here, because date/time values + // are stored in separate tags) + case MetadataDomain.IPTC: + return null; + + case MetadataDomain.EXIF: + default: + return new MetadataDateTime.from_exif(value); + } + } catch (Error err) { + warning("Unable to read date/time %s from source %s: %s", tag, source_name, err.message); + + return null; + } + } + + public MetadataDateTime? get_first_date_time(string[] tags) { + foreach (string tag in tags) { + MetadataDateTime? date_time = get_date_time(tag); + if (date_time != null) + return date_time; + } + + return null; + } + + public void set_date_time(string tag, MetadataDateTime date_time) { + switch (get_tag_domain(tag)) { + case MetadataDomain.EXIF: + set_string(tag, date_time.get_exif_label()); + break; + + case MetadataDomain.XMP: + set_string(tag, date_time.get_xmp_label()); + break; + + // TODO: Support IPTC date/time (which are stored in separate tags) + case MetadataDomain.IPTC: + default: + warning("Cannot set date/time for %s from source %s: unsupported metadata domain %s", tag, + source_name, get_tag_domain(tag).to_string()); + break; + } + } + + public void set_all_date_time(string[] tags, MetadataDateTime date_time, SetOption option) { + set_all_generic(tags, option, (tag) => { set_date_time(tag, date_time); }); + } + + // Returns raw bytes of EXIF metadata, including signature and optionally the preview (if present). + public uint8[]? flatten_exif(bool include_preview) { + if (exif == null) + return null; + + // save thumbnail to strip if no attachments requested (so it can be added back and + // deallocated automatically) + uchar *thumbnail = exif.data; + uint thumbnail_size = exif.size; + if (!include_preview) { + exif.data = null; + exif.size = 0; + } + + uint8[]? flattened = null; + + // save the struct to a buffer and copy into a Vala-friendly one + uchar *saved_data = null; + uint saved_size = 0; + exif.save_data(&saved_data, &saved_size); + if (saved_size > 0 && saved_data != null) { + flattened = new uint8[saved_size]; + Memory.copy(flattened, saved_data, saved_size); + + Exif.Mem.new_default().free(saved_data); + } + + // restore thumbnail (this works in either case) + exif.data = thumbnail; + exif.size = thumbnail_size; + + return flattened; + } + + // Returns raw bytes of EXIF preview, if present + public uint8[]? flatten_exif_preview() { + uchar[] buffer; + return exiv2.get_exif_thumbnail(out buffer) ? buffer : null; + } + + public uint get_preview_count() { + unowned GExiv2.PreviewProperties?[] props = exiv2.get_preview_properties(); + + return (props != null) ? props.length : 0; + } + + // Previews are sorted from smallest to largest (width x height) + public PhotoPreview? get_preview(uint number) { + unowned GExiv2.PreviewProperties?[] props = exiv2.get_preview_properties(); + if (props == null || props.length <= number) + return null; + + return new InternalPhotoPreview(this, source_name, number, props[number]); + } + + public void remove_exif_thumbnail() { + exiv2.erase_exif_thumbnail(); + if (exif != null) { + Exif.Mem.new_default().free(exif.data); + exif.data = null; + exif.size = 0; + } + } + + public void remove_tag(string tag) { + exiv2.clear_tag(tag); + } + + public void remove_tags(string[] tags) { + foreach (string tag in tags) + remove_tag(tag); + } + + public void clear_domain(MetadataDomain domain) { + switch (domain) { + case MetadataDomain.EXIF: + exiv2.clear_exif(); + break; + + case MetadataDomain.XMP: + exiv2.clear_xmp(); + break; + + case MetadataDomain.IPTC: + exiv2.clear_iptc(); + break; + } + } + + public void clear() { + exiv2.clear(); + } + + private static string[] DATE_TIME_TAGS = { + "Exif.Image.DateTime", + "Xmp.tiff.DateTime", + "Xmp.xmp.ModifyDate" + }; + + public MetadataDateTime? get_modification_date_time() { + return get_first_date_time(DATE_TIME_TAGS); + } + + public void set_modification_date_time(MetadataDateTime? date_time, + SetOption option = SetOption.ALL_DOMAINS) { + if (date_time != null) + set_all_date_time(DATE_TIME_TAGS, date_time, option); + else + remove_tags(DATE_TIME_TAGS); + } + + private static string[] EXPOSURE_DATE_TIME_TAGS = { + "Exif.Photo.DateTimeOriginal", + "Xmp.exif.DateTimeOriginal", + "Xmp.xmp.CreateDate", + "Exif.Photo.DateTimeDigitized", + "Xmp.exif.DateTimeDigitized", + "Exif.Image.DateTime" + }; + + public MetadataDateTime? get_exposure_date_time() { + return get_first_date_time(EXPOSURE_DATE_TIME_TAGS); + } + + public void set_exposure_date_time(MetadataDateTime? date_time, + SetOption option = SetOption.ALL_DOMAINS) { + if (date_time != null) + set_all_date_time(EXPOSURE_DATE_TIME_TAGS, date_time, option); + else + remove_tags(EXPOSURE_DATE_TIME_TAGS); + } + + private static string[] DIGITIZED_DATE_TIME_TAGS = { + "Exif.Photo.DateTimeDigitized", + "Xmp.exif.DateTimeDigitized" + }; + + public MetadataDateTime? get_digitized_date_time() { + return get_first_date_time(DIGITIZED_DATE_TIME_TAGS); + } + + public void set_digitized_date_time(MetadataDateTime? date_time, + SetOption option = SetOption.ALL_DOMAINS) { + if (date_time != null) + set_all_date_time(DIGITIZED_DATE_TIME_TAGS, date_time, option); + else + remove_tags(DIGITIZED_DATE_TIME_TAGS); + } + + public override MetadataDateTime? get_creation_date_time() { + MetadataDateTime? creation = get_exposure_date_time(); + if (creation == null) + creation = get_digitized_date_time(); + + return creation; + } + + private static string[] WIDTH_TAGS = { + "Exif.Photo.PixelXDimension", + "Xmp.exif.PixelXDimension", + "Xmp.tiff.ImageWidth", + "Xmp.exif.PixelXDimension" + }; + + public static string[] HEIGHT_TAGS = { + "Exif.Photo.PixelYDimension", + "Xmp.exif.PixelYDimension", + "Xmp.tiff.ImageHeight", + "Xmp.exif.PixelYDimension" + }; + + public Dimensions? get_pixel_dimensions() { + // walk the tag arrays concurrently, returning the dimensions of the first found pair + assert(WIDTH_TAGS.length == HEIGHT_TAGS.length); + for (int ctr = 0; ctr < WIDTH_TAGS.length; ctr++) { + // Can't turn this into a single if statement with an || bailing out due to this bug: + // https://bugzilla.gnome.org/show_bug.cgi?id=565385 + long width; + if (!get_long(WIDTH_TAGS[ctr], out width)) + continue; + + long height; + if (!get_long(HEIGHT_TAGS[ctr], out height)) + continue; + + return Dimensions((int) width, (int) height); + } + + return null; + } + + public void set_pixel_dimensions(Dimensions? dim, SetOption option = SetOption.ALL_DOMAINS) { + if (dim != null) { + set_all_long(WIDTH_TAGS, dim.width, option); + set_all_long(HEIGHT_TAGS, dim.height, option); + } else { + remove_tags(WIDTH_TAGS); + remove_tags(HEIGHT_TAGS); + } + } + + // + // A note regarding titles and descriptions: + // + // iPhoto stores its title in Iptc.Application2.ObjectName and its description in + // Iptc.Application2.Caption. Most others use .Caption for the title and another + // (sometimes) appropriate tag for the description. And there's general confusion about + // whether Exif.Image.ImageDescription is a description (which is what the tag name + // suggests) or a title (which is what the specification states). + // See: http://trac.yorba.org/wiki/PhotoTags + // + // Hence, the following logic tries to do the right thing in most of these cases. If + // the iPhoto title tag is detected, it and the iPhoto description tag are used. Otherwise, + // the title/description are searched out from a list of standard tags. + // + // Exif.Image.ImageDescription seems to be abused, both in that iPhoto uses it as a multiline + // description and that some cameras insert their make & model information there (IN ALL CAPS, + // to really rub it in). We are ignoring the field until a compelling reason to support it + // is found. + // + + private const string IPHOTO_TITLE_TAG = "Iptc.Application2.ObjectName"; + + private static string[] STANDARD_TITLE_TAGS = { + "Iptc.Application2.Caption", + "Xmp.dc.title", + "Iptc.Application2.Headline", + "Xmp.photoshop.Headline" + }; + + public override string? get_title() { + // using get_string_multiple()/get_first_string_multiple() because it's possible for + // multiple strings to be specified in XMP for different language codes, and want to + // retrieve only the first one (other get_string variants will return ugly strings like + // + // lang="x-default" Xyzzy + // + // but get_string_multiple will return a list of titles w/o language information + Gee.List<string>? titles = has_tag(IPHOTO_TITLE_TAG) + ? get_string_multiple(IPHOTO_TITLE_TAG) + : get_first_string_multiple(STANDARD_TITLE_TAGS); + + // use the first string every time (assume it's default) + // TODO: We could get a list of all titles by their lang="<iso code>" and attempt to find + // the right one for the user's locale, but this does not seem to be a normal use case + string? title = (titles != null && titles.size > 0) ? titles[0] : null; + + // strip out leading and trailing whitespace + if (title != null) + title = title.strip(); + + // check for \n and \r to prevent multiline titles, which have been spotted in the wild + return (!is_string_empty(title) && !title.contains("\n") && !title.contains("\r")) ? + title : null; + } + + public void set_title(string? title, SetOption option = SetOption.ALL_DOMAINS) { + if (!is_string_empty(title)) { + if (has_tag(IPHOTO_TITLE_TAG)) + set_string(IPHOTO_TITLE_TAG, title); + else + set_all_string(STANDARD_TITLE_TAGS, title, option); + } else { + remove_tags(STANDARD_TITLE_TAGS); + } + } + + public override string? get_comment() { + return get_string_interpreted("Exif.Photo.UserComment", PrepareInputTextOptions.DEFAULT & ~PrepareInputTextOptions.STRIP_CRLF); + } + + public void set_comment(string? comment) { + if (!is_string_empty(comment)) + set_string("Exif.Photo.UserComment", comment, PrepareInputTextOptions.DEFAULT & ~PrepareInputTextOptions.STRIP_CRLF); + else + remove_tag("Exif.Photo.UserComment"); + } + + private static string[] KEYWORD_TAGS = { + "Xmp.dc.subject", + "Iptc.Application2.Keywords" + }; + + private static HierarchicalKeywordField[] HIERARCHICAL_KEYWORD_TAGS = { + // Xmp.lr.hierarchicalSubject should be writeable but isn't due to this bug + // in libexiv2: http://dev.exiv2.org/issues/784 + new HierarchicalKeywordField("Xmp.lr.hierarchicalSubject", "|", false, false), + new HierarchicalKeywordField("Xmp.digiKam.TagsList", "/", false, true), + new HierarchicalKeywordField("Xmp.MicrosoftPhoto.LastKeywordXMP", "/", false, true) + }; + + public Gee.Set<string>? get_keywords(owned CompareDataFunc<string>? compare_func = null) { + Gee.Set<string> keywords = null; + foreach (string tag in KEYWORD_TAGS) { + Gee.Collection<string>? values = get_string_multiple(tag); + if (values != null && values.size > 0) { + if (keywords == null) + keywords = create_string_set((owned) compare_func); + + foreach (string current_value in values) + keywords.add(HierarchicalTagUtilities.make_flat_tag_safe(current_value)); + } + } + + return (keywords != null && keywords.size > 0) ? keywords : null; + } + + private void internal_set_hierarchical_keywords(HierarchicalTagIndex? index) { + foreach (HierarchicalKeywordField current_field in HIERARCHICAL_KEYWORD_TAGS) + remove_tag(current_field.field_name); + + if (index == null) + return; + + foreach (HierarchicalKeywordField current_field in HIERARCHICAL_KEYWORD_TAGS) { + if (!current_field.is_writeable) + continue; + + Gee.Set<string> writeable_set = new Gee.TreeSet<string>(); + + foreach (string current_path in index.get_all_paths()) { + string writeable_path = current_path.replace(Tag.PATH_SEPARATOR_STRING, + current_field.path_separator); + if (!current_field.wants_leading_separator) + writeable_path = writeable_path.substring(1); + + writeable_set.add(writeable_path); + } + + set_string_multiple(current_field.field_name, writeable_set); + } + } + + public void set_keywords(Gee.Collection<string>? keywords, SetOption option = SetOption.ALL_DOMAINS) { + HierarchicalTagIndex htag_index = new HierarchicalTagIndex(); + Gee.Set<string> flat_keywords = new Gee.TreeSet<string>(); + + if (keywords != null) { + foreach (string keyword in keywords) { + if (keyword.has_prefix(Tag.PATH_SEPARATOR_STRING)) { + Gee.Collection<string> path_components = + HierarchicalTagUtilities.enumerate_path_components(keyword); + foreach (string component in path_components) + htag_index.add_path(component, keyword); + } else { + flat_keywords.add(keyword); + } + } + + flat_keywords.add_all(htag_index.get_all_tags()); + } + + if (keywords != null) { + set_all_string_multiple(KEYWORD_TAGS, flat_keywords, option); + internal_set_hierarchical_keywords(htag_index); + } else { + remove_tags(KEYWORD_TAGS); + internal_set_hierarchical_keywords(null); + } + } + + public bool has_hierarchical_keywords() { + foreach (HierarchicalKeywordField field in HIERARCHICAL_KEYWORD_TAGS) { + Gee.Collection<string>? values = get_string_multiple(field.field_name); + + if (values != null && values.size > 0) + return true; + } + + return false; + } + + public Gee.Set<string> get_hierarchical_keywords() { + assert(has_hierarchical_keywords()); + + Gee.Set<string> h_keywords = create_string_set(null); + + foreach (HierarchicalKeywordField field in HIERARCHICAL_KEYWORD_TAGS) { + Gee.Collection<string>? values = get_string_multiple(field.field_name); + + if (values == null || values.size < 1) + continue; + + foreach (string current_value in values) { + string? canonicalized = HierarchicalTagUtilities.canonicalize(current_value, + field.path_separator); + + if (canonicalized != null) + h_keywords.add(canonicalized); + } + } + + return h_keywords; + } + + public bool has_orientation() { + return exiv2.get_orientation() == GExiv2.Orientation.UNSPECIFIED; + } + + // If not present, returns Orientation.TOP_LEFT. + public Orientation get_orientation() { + // GExiv2.Orientation is the same value-wise as Orientation, with one exception: + // GExiv2.Orientation.UNSPECIFIED must be handled + GExiv2.Orientation orientation = exiv2.get_orientation(); + if (orientation == GExiv2.Orientation.UNSPECIFIED || orientation < Orientation.MIN || + orientation > Orientation.MAX) + return Orientation.TOP_LEFT; + else + return (Orientation) orientation; + } + + public void set_orientation(Orientation orientation) { + // GExiv2.Orientation is the same value-wise as Orientation + exiv2.set_orientation((GExiv2.Orientation) orientation); + } + + public bool get_gps(out double longitude, out string long_ref, out double latitude, out string lat_ref, + out double altitude) { + if (!exiv2.get_gps_info(out longitude, out latitude, out altitude)) { + long_ref = null; + lat_ref = null; + + return false; + } + + long_ref = get_string("Exif.GPSInfo.GPSLongitudeRef"); + lat_ref = get_string("Exif.GPSInfo.GPSLatitudeRef"); + + return true; + } + + public bool get_exposure(out MetadataRational exposure) { + return get_rational("Exif.Photo.ExposureTime", out exposure); + } + + public string? get_exposure_string() { + MetadataRational exposure_time; + if (!get_rational("Exif.Photo.ExposureTime", out exposure_time)) + return null; + + if (!exposure_time.is_valid()) + return null; + + return get_string_interpreted("Exif.Photo.ExposureTime"); + } + + public bool get_iso(out long iso) { + bool fetched_ok = get_long("Exif.Photo.ISOSpeedRatings", out iso); + + if (fetched_ok == false) + return false; + + // lower boundary is original (ca. 1935) Kodachrome speed, the lowest ISO rated film ever + // manufactured; upper boundary is 4 x fastest high-speed digital camera speeds + if ((iso < 6) || (iso > 409600)) + return false; + + return true; + } + + public string? get_iso_string() { + long iso; + if (!get_iso(out iso)) + return null; + + return get_string_interpreted("Exif.Photo.ISOSpeedRatings"); + } + + public bool get_aperture(out MetadataRational aperture) { + return get_rational("Exif.Photo.FNumber", out aperture); + } + + public string? get_aperture_string(bool pango_formatted = false) { + MetadataRational aperture; + if (!get_aperture(out aperture)) + return null; + + double aperture_value = ((double) aperture.numerator) / ((double) aperture.denominator); + aperture_value = ((int) (aperture_value * 10.0)) / 10.0; + + return (pango_formatted ? "<i>f</i>/" : "f/") + + ((aperture_value % 1 == 0) ? "%.0f" : "%.1f").printf(aperture_value); + } + + public string? get_camera_make() { + return get_string_interpreted("Exif.Image.Make"); + } + + public string? get_camera_model() { + return get_string_interpreted("Exif.Image.Model"); + } + + public bool get_flash(out long flash) { + // Exif.Image.Flash does not work for some reason + return get_long("Exif.Photo.Flash", out flash); + } + + public string? get_flash_string() { + // Exif.Image.Flash does not work for some reason + return get_string_interpreted("Exif.Photo.Flash"); + } + + public bool get_focal_length(out MetadataRational focal_length) { + return get_rational("Exif.Photo.FocalLength", out focal_length); + } + + public string? get_focal_length_string() { + return get_string_interpreted("Exif.Photo.FocalLength"); + } + + private static string[] ARTIST_TAGS = { + "Exif.Image.Artist", + "Exif.Canon.OwnerName" // Custom tag used by Canon DSLR cameras + }; + + public string? get_artist() { + return get_first_string_interpreted(ARTIST_TAGS); + } + + public string? get_copyright() { + return get_string_interpreted("Exif.Image.Copyright"); + } + + public string? get_software() { + return get_string_interpreted("Exif.Image.Software"); + } + + public void set_software(string software, string version) { + // always set this one, even if EXIF not present + set_string("Exif.Image.Software", "%s %s".printf(software, version)); + + if (has_iptc()) { + set_string("Iptc.Application2.Program", software); + set_string("Iptc.Application2.ProgramVersion", version); + } + } + + public void remove_software() { + remove_tag("Exif.Image.Software"); + remove_tag("Iptc.Application2.Program"); + remove_tag("Iptc.Application2.ProgramVersion"); + } + + public string? get_exposure_bias() { + return get_string_interpreted("Exif.Photo.ExposureBiasValue"); + } + + private static string[] RATING_TAGS = { + "Xmp.xmp.Rating", + "Iptc.Application2.Urgency", + "Xmp.photoshop.Urgency", + "Exif.Image.Rating" + }; + + public Rating get_rating() { + string? rating_string = get_first_string(RATING_TAGS); + if(rating_string != null) + return Rating.unserialize(int.parse(rating_string)); + + rating_string = get_string("Exif.Image.RatingPercent"); + if(rating_string == null) { + return Rating.UNRATED; + } + + int int_percent_rating = int.parse(rating_string); + for(int i = 5; i >= 0; --i) { + if(int_percent_rating >= Resources.rating_thresholds[i]) + return Rating.unserialize(i); + } + return Rating.unserialize(-1); + } + + // Among photo managers, Xmp.xmp.Rating tends to be the standard way to represent ratings. + // Other photo managers, notably F-Spot, take hints from Urgency fields about what the rating + // of an imported photo should be, and we have decided to do as well. Xmp.xmp.Rating is the only + // field we've seen photo manages export ratings to, while Urgency fields seem to have a fundamentally + // different meaning. See http://trac.yorba.org/wiki/PhotoTags#Rating for more information. + public void set_rating(Rating rating) { + int int_rating = rating.serialize(); + set_string("Xmp.xmp.Rating", int_rating.to_string()); + set_string("Exif.Image.Rating", int_rating.to_string()); + + if( 0 <= int_rating ) + set_string("Exif.Image.RatingPercent", Resources.rating_thresholds[int_rating].to_string()); + else // in this case we _know_ int_rating is -1 + set_string("Exif.Image.RatingPercent", int_rating.to_string()); + } +} + |