Commit bfe16670 authored by Jim Nelson's avatar Jim Nelson

#284: File/Export w/ scaling.

parent c0c45ca7
......@@ -497,6 +497,14 @@ public class AppWindow : Gtk.Window {
collection_page.refresh();
}
public void set_busy_cursor() {
window.set_cursor(new Gdk.Cursor(Gdk.CursorType.WATCH));
}
public void set_normal_cursor() {
window.set_cursor(new Gdk.Cursor(Gdk.CursorType.ARROW));
}
public void batch_import_complete(SortedList<int64?> imported_photos) {
debug("Processing imported photos to create events ...");
......
......@@ -64,7 +64,8 @@ public class CollectionPage : CheckerboardPage {
// TODO: Mark fields for translation
private const Gtk.ActionEntry[] ACTIONS = {
{ "FileMenu", null, "_File", null, null, null },
{ "FileMenu", null, "_File", null, null, on_file_menu },
{ "Export", null, "_Export", "<Ctrl>E", "Export selected photos to disk", on_export },
{ "EditMenu", null, "_Edit", null, null, on_edit_menu },
{ "SelectAll", Gtk.STOCK_SELECT_ALL, "Select _All", "<Ctrl>A", "Select all the photos in the library", on_select_all },
......@@ -134,8 +135,8 @@ public class CollectionPage : CheckerboardPage {
toolbar.insert(separator, -1);
// thumbnail size slider
slider = new Gtk.HScale.with_range(0, scaleToSlider(Thumbnail.MAX_SCALE), 1);
slider.set_value(scaleToSlider(scale));
slider = new Gtk.HScale.with_range(0, scale_to_slider(Thumbnail.MAX_SCALE), 1);
slider.set_value(scale_to_slider(scale));
slider.value_changed += on_slider_changed;
slider.set_draw_value(false);
......@@ -365,11 +366,11 @@ public class CollectionPage : CheckerboardPage {
return scale;
}
public void set_thumb_size(int newScale) {
assert(newScale >= Thumbnail.MIN_SCALE);
assert(newScale <= Thumbnail.MAX_SCALE);
public void set_thumb_size(int new_scale) {
assert(new_scale >= Thumbnail.MIN_SCALE);
assert(new_scale <= Thumbnail.MAX_SCALE);
scale = newScale;
scale = new_scale;
foreach (LayoutItem item in get_items())
((Thumbnail) item).resize(scale);
......@@ -412,6 +413,73 @@ public class CollectionPage : CheckerboardPage {
return false;
}
private void on_file_menu() {
set_item_sensitive("/CollectionMenuBar/FileMenu/Export", get_selected_count() > 0);
}
private void on_export() {
Gee.ArrayList<Photo> export_list = new Gee.ArrayList<Photo>();
foreach (LayoutItem item in get_selected())
export_list.add(((Thumbnail) item).get_photo());
if (export_list.size == 0)
return;
ExportDialog export_dialog = new ExportDialog(export_list.size);
int scale;
ScaleConstraint constraint;
Jpeg.Quality quality;
if (!export_dialog.execute(out scale, out constraint, out quality))
return;
// handle the single-photo case
if (export_list.size == 1) {
Photo photo = export_list.get(0);
File save_as = ExportUI.choose_file(photo.get_file());
if (save_as == null)
return;
spin_event_loop();
try {
photo.export(save_as, scale, constraint, quality);
} catch (Error err) {
AppWindow.error_message("Unable to export photo %s: %s".printf(
photo.get_file().get_path(), err.message));
}
return;
}
// multiple photos
File export_dir = ExportUI.choose_dir();
if (export_dir == null)
return;
AppWindow.get_instance().set_busy_cursor();
foreach (Photo photo in export_list) {
File save_as = export_dir.get_child(photo.get_file().get_basename());
if (save_as.query_exists(null)) {
if (!ExportUI.query_overwrite(save_as))
continue;
}
spin_event_loop();
try {
photo.export(save_as, scale, constraint, quality);
} catch (Error err) {
AppWindow.error_message("Unable to export photo %s: %s".printf(save_as.get_path(),
err.message));
}
}
AppWindow.get_instance().set_normal_cursor();
}
private void on_edit_menu() {
set_item_sensitive("/CollectionMenuBar/EditMenu/SelectAll", get_count() > 0);
......@@ -446,12 +514,12 @@ public class CollectionPage : CheckerboardPage {
private void on_increase_size() {
increase_thumb_size();
slider.set_value(scaleToSlider(scale));
slider.set_value(scale_to_slider(scale));
}
private void on_decrease_size() {
decrease_thumb_size();
slider.set_value(scaleToSlider(scale));
slider.set_value(scale_to_slider(scale));
}
private void on_remove() {
......@@ -531,14 +599,14 @@ public class CollectionPage : CheckerboardPage {
refresh();
}
private double scaleToSlider(int value) {
private static double scale_to_slider(int value) {
assert(value >= Thumbnail.MIN_SCALE);
assert(value <= Thumbnail.MAX_SCALE);
return (double) ((value - Thumbnail.MIN_SCALE) / SLIDER_STEPPING);
}
private int sliderToScale(double value) {
private static int slider_to_scale(double value) {
int res = ((int) (value * SLIDER_STEPPING)) + Thumbnail.MIN_SCALE;
assert(res >= Thumbnail.MIN_SCALE);
......@@ -548,7 +616,7 @@ public class CollectionPage : CheckerboardPage {
}
private void on_slider_changed() {
set_thumb_size(sliderToScale(slider.get_value()));
set_thumb_size(slider_to_scale(slider.get_value()));
}
private override bool on_ctrl_pressed(Gdk.EventKey event) {
......
public enum ScaleConstraint {
ORIGINAL,
DIMENSIONS,
WIDTH,
HEIGHT;
public string? to_string() {
switch (this) {
case ORIGINAL:
return "Original size";
case DIMENSIONS:
return "Width or height";
case WIDTH:
return "Width";
case HEIGHT:
return "Height";
}
warn_if_reached();
return null;
}
}
public struct Dimensions {
public int width;
public int height;
......@@ -36,12 +63,9 @@ public struct Dimensions {
int diff_width = width - scale;
int diff_height = height - scale;
Dimensions scaled = Dimensions();
if (diff_width == diff_height) {
// square image -- unlikely -- but this is the easy case
scaled.width = scale;
scaled.height = scale;
return Dimensions(scale, scale);
} else if (diff_width <= 0) {
if (diff_height <= 0) {
// if both dimensions are less than the scaled size, return as-is
......@@ -49,23 +73,15 @@ public struct Dimensions {
}
// height needs to be scaled down, so it determines the ratio
double ratio = (double) scale / (double) height;
scaled.width = (int) Math.round((double) width * ratio);
scaled.height = scale;
return get_scaled_by_height(scale);
} else if (diff_width > diff_height) {
// width is greater, so it's the determining factor
// (this case is true even when diff_height is negative)
scaled.width = scale;
double ratio = (double) scale / (double) width;
scaled.height = (int) Math.round((double) height * ratio);
return get_scaled_by_width(scale);
} else {
// height is the determining factor
double ratio = (double) scale / (double) height;
scaled.width = (int) Math.round((double) width * ratio);
scaled.height = scale;
return get_scaled_by_height(scale);
}
return scaled;
}
public Dimensions get_scaled_proportional(Dimensions viewport) {
......@@ -99,5 +115,37 @@ public struct Dimensions {
return scaled_rect;
}
public Dimensions get_scaled_by_width(int scale) {
double ratio = (double) scale / (double) width;
return Dimensions(scale, (int) Math.round((double) height * ratio));
}
public Dimensions get_scaled_by_height(int scale) {
double ratio = (double) scale / (double) height;
return Dimensions((int) Math.round((double) width * ratio), scale);
}
public Dimensions get_scaled_by_constraint(int scale, ScaleConstraint constraint) {
switch (constraint) {
case ScaleConstraint.ORIGINAL:
return Dimensions(width, height);
case ScaleConstraint.DIMENSIONS:
return (width >= height) ? get_scaled_by_width(scale) : get_scaled_by_height(scale);
case ScaleConstraint.WIDTH:
return get_scaled_by_width(scale);
case ScaleConstraint.HEIGHT:
return get_scaled_by_height(scale);
}
error("Bad constraint: %d", (int) constraint);
return Dimensions();
}
}
......@@ -21,6 +21,23 @@ namespace Exif {
return null;
}
public int remove_all_tags(Data data, Exif.Tag tag) {
int count = 0;
for (int ctr = 0; ctr < (int) Exif.Ifd.COUNT; ctr++) {
Exif.Content content = data.ifd[ctr];
assert(content != null);
Exif.Entry entry = content.get_entry(tag);
if (entry == null)
continue;
content.remove_entry(entry);
count++;
}
return count;
}
public bool convert_datetime(string datetime, out time_t timestamp) {
Time tm = Time();
int count = datetime.scanf("%d:%d:%d %d:%d:%d", &tm.year, &tm.month, &tm.day, &tm.hour,
......@@ -229,6 +246,31 @@ public class PhotoExif {
return Exif.convert_datetime(datetime, out timestamp);
}
public int remove_all_tags(Exif.Tag tag) {
update();
if (exif == null)
return 0;
return Exif.remove_all_tags(exif, tag);
}
public bool remove_thumbnail() {
update();
if (exif == null)
return false;
if (exif.data == null)
return false;
free(exif.data);
exif.data = null;
exif.size = 0;
return true;
}
private void update() {
if (no_exif)
return;
......
namespace ExportUI {
private static File current_export_dir = null;
public File? choose_file(File current_file) {
if (current_export_dir == null)
current_export_dir = File.new_for_path(Environment.get_home_dir());
Gtk.FileChooserDialog chooser = new Gtk.FileChooserDialog("Export Photo",
AppWindow.get_instance(), Gtk.FileChooserAction.SAVE, Gtk.STOCK_CANCEL,
Gtk.ResponseType.CANCEL, Gtk.STOCK_SAVE, Gtk.ResponseType.ACCEPT, null);
chooser.set_do_overwrite_confirmation(true);
chooser.set_current_folder(current_export_dir.get_path());
chooser.set_current_name(current_file.get_basename());
File file = null;
if (chooser.run() == Gtk.ResponseType.ACCEPT) {
file = File.new_for_path(chooser.get_filename());
current_export_dir = file.get_parent();
}
chooser.destroy();
return file;
}
public File? choose_dir() {
if (current_export_dir == null)
current_export_dir = File.new_for_path(Environment.get_home_dir());
Gtk.FileChooserDialog chooser = new Gtk.FileChooserDialog("Export Photos to Directory",
AppWindow.get_instance(), Gtk.FileChooserAction.SELECT_FOLDER, Gtk.STOCK_CANCEL,
Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT, null);
chooser.set_current_folder(current_export_dir.get_path());
File dir = null;
if (chooser.run() == Gtk.ResponseType.ACCEPT) {
dir = File.new_for_path(chooser.get_filename());
current_export_dir = dir;
}
chooser.destroy();
return dir;
}
public bool query_overwrite(File file) {
Gtk.MessageDialog dialog = new Gtk.MessageDialog(AppWindow.get_instance(), Gtk.DialogFlags.MODAL,
Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO, "%s already exists. Overwrite?",
file.get_path());
dialog.title = "Export Photos to Directory";
bool yes = (dialog.run() == Gtk.ResponseType.YES);
dialog.destroy();
return yes;
}
}
public class ExportDialog : Gtk.Dialog {
public static const ScaleConstraint[] CONSTRAINT_ARRAY = { ScaleConstraint.ORIGINAL,
ScaleConstraint.DIMENSIONS, ScaleConstraint.WIDTH, ScaleConstraint.HEIGHT };
public static const Jpeg.Quality[] QUALITY_ARRAY = { Jpeg.Quality.LOW, Jpeg.Quality.MEDIUM,
Jpeg.Quality.HIGH, Jpeg.Quality.MAXIMUM };
private static ScaleConstraint current_constraint = ScaleConstraint.DIMENSIONS;
private static Jpeg.Quality current_quality = Jpeg.Quality.HIGH;
private static int current_scale = 1200;
private Gtk.Table table = new Gtk.Table(0, 0, false);
private Gtk.ComboBox quality_combo;
private Gtk.ComboBox constraint_combo;
private Gtk.Entry pixels_entry;
private Gtk.Widget ok_button;
private bool in_insert = false;
public ExportDialog(int count) {
title = "Export Photo%s".printf(count > 1 ? "s" : "");
has_separator = false;
allow_grow = false;
// prepare controls
quality_combo = new Gtk.ComboBox.text();
int ctr = 0;
foreach (Jpeg.Quality quality in QUALITY_ARRAY) {
quality_combo.append_text(quality.to_string());
if (quality == current_quality)
quality_combo.set_active(ctr);
ctr++;
}
constraint_combo = new Gtk.ComboBox.text();
ctr = 0;
foreach (ScaleConstraint constraint in CONSTRAINT_ARRAY) {
constraint_combo.append_text(constraint.to_string());
if (constraint == current_constraint)
constraint_combo.set_active(ctr);
ctr++;
}
pixels_entry = new Gtk.Entry();
pixels_entry.set_max_length(6);
pixels_entry.set_text("%d".printf(current_scale));
// register after preparation to avoid signals during init
constraint_combo.changed += on_constraint_changed;
pixels_entry.changed += on_pixels_changed;
pixels_entry.insert_text += on_pixels_insert_text;
// layout controls
add_label("Quality", 0, 0);
add_control(quality_combo, 1, 0);
add_label("Scaling constraint", 0, 1);
add_control(constraint_combo, 1, 1);
Gtk.HBox pixels_box = new Gtk.HBox(false, 0);
pixels_box.pack_start(pixels_entry, true, true, 0);
pixels_box.pack_end(new Gtk.Label(" pixels"), false, false, 0);
add_control(pixels_box, 1, 2);
((Gtk.VBox) get_content_area()).add(table);
// add buttons to action area
add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL);
ok_button = add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK);
if (current_constraint == ScaleConstraint.ORIGINAL) {
pixels_entry.sensitive = false;
quality_combo.sensitive = false;
}
}
public bool execute(out int scale, out ScaleConstraint constraint, out Jpeg.Quality quality) {
show_all();
bool ok = (run() == Gtk.ResponseType.OK);
if (ok) {
int index = constraint_combo.get_active();
assert(index >= 0);
constraint = CONSTRAINT_ARRAY[index];
current_constraint = constraint;
scale = pixels_entry.get_text().to_int();
if (constraint != ScaleConstraint.ORIGINAL)
assert(scale > 0);
current_scale = scale;
index = quality_combo.get_active();
assert(index >= 0);
quality = QUALITY_ARRAY[index];
current_quality = quality;
}
destroy();
return ok;
}
private void add_label(string text, int x, int y) {
Gtk.Alignment left_aligned = new Gtk.Alignment(0.0f, 0.5f, 0, 0);
left_aligned.add(new Gtk.Label(text));
table.attach(left_aligned, x, x + 1, y, y + 1, Gtk.AttachOptions.FILL, Gtk.AttachOptions.FILL,
10, 5);
}
private void add_control(Gtk.Widget widget, int x, int y) {
Gtk.Alignment left_aligned = new Gtk.Alignment(0, 0.5f, 0, 0);
left_aligned.add(widget);
table.attach(left_aligned, x, x + 1, y, y + 1, Gtk.AttachOptions.FILL, Gtk.AttachOptions.FILL,
10, 5);
}
private void on_constraint_changed() {
bool original = (CONSTRAINT_ARRAY[constraint_combo.get_active()] == ScaleConstraint.ORIGINAL);
pixels_entry.sensitive = !original;
quality_combo.sensitive = !original;
if (original)
ok_button.sensitive = true;
else
on_pixels_changed();
}
private void on_pixels_changed() {
ok_button.sensitive = (pixels_entry.get_text_length() > 0) && (pixels_entry.get_text().to_int() > 0);
}
private void on_pixels_insert_text(string text, int length, void *position) {
// This is necessary because SignalHandler.block_by_func() is not properly bound
if (in_insert)
return;
in_insert = true;
if (length == -1)
length = (int) text.length;
// only permit numeric text
string buffer = new string();
string new_text = new string();
for (int ctr = 0; ctr < length; ctr++) {
if (text[ctr].isdigit()) {
text[ctr].to_utf8(buffer);
new_text += buffer;
}
}
if (new_text.length > 0)
pixels_entry.insert_text(new_text, (int) new_text.length, position);
Signal.stop_emission_by_name(pixels_entry, "insert-text");
in_insert = false;
}
}
......@@ -25,7 +25,8 @@ SRC_FILES = \
Photo.vala \
Orientation.vala \
util.vala \
BatchImport.vala
BatchImport.vala \
ExportDialog.vala
VAPI_FILES = \
libexif.vapi \
......
......@@ -4,7 +4,8 @@ public class Photo : Object {
public static const int EXCEPTION_ORIENTATION = 1 << 0;
public static const int EXCEPTION_CROP = 1 << 1;
public static const string EXPORT_JPEG_QUALITY = "90";
public static const Jpeg.Quality EXPORT_JPEG_QUALITY = Jpeg.Quality.HIGH;
public static const Gdk.InterpType EXPORT_INTERP = Gdk.InterpType.BILINEAR;
private static Gee.HashMap<int64?, Photo> photo_map = null;
private static PhotoTable photo_table = new PhotoTable();
......@@ -186,7 +187,7 @@ public class Photo : Object {
orientation = orientation.perform(rotation);
photo_table.set_orientation(photo_id, orientation);
altered();
// because rotations are (a) common and available everywhere in the app, (b) the user expects
......@@ -296,8 +297,8 @@ public class Photo : Object {
pixbuf = cached_raw;
} else {
File file = get_file();
debug("Loading full photo %s", file.get_path());
debug("Loading raw photo %s", file.get_path());
pixbuf = new Gdk.Pixbuf.from_file(file.get_path());
// stash for next time
......@@ -344,49 +345,86 @@ public class Photo : Object {
exportable_dir = exportable_dir.get_child("%02u".printf(tm.day));
}
if (!exportable_dir.query_exists(null))
exportable_dir.make_directory_with_parents(null);
File exportable_file = exportable_dir.get_child(original_file.get_basename());
return exportable_file;
return exportable_dir.get_child(original_file.get_basename());
}
private void copy_exported_exif(PhotoExif source, PhotoExif dest, Orientation orientation,
Dimensions dim) throws Error {
if (!source.has_exif())
return;
dest.set_exif(source.get_exif());
dest.set_dimensions(dim);
dest.set_orientation(orientation);
dest.remove_all_tags(Exif.Tag.RELATED_IMAGE_WIDTH);
dest.remove_all_tags(Exif.Tag.RELATED_IMAGE_LENGTH);
dest.remove_thumbnail();
dest.commit();
}
// Returns the file of a file appropriate for export. The file should NOT be deleted once
// it's been used for whatever purpose.
// Returns a file appropriate for export. The file should NOT be deleted once it's been used.
//
// TODO: Lossless transformations, especially for mere rotations of JFIF files.
public File generate_exportable() throws Error {
if (!has_transformations())
return get_file();
File file = generate_exportable_file();
if (file.query_exists(null))
return file;
File dest_file = generate_exportable_file();
if (dest_file.query_exists(null))
return dest_file;
PhotoExif original = new PhotoExif(get_file());
// generate_exportable_file only generates a filename; create directory if necessary
File dest_dir = dest_file.get_parent();
if (!dest_dir.query_exists(null))
dest_dir.make_directory_with_parents(null);
File original_file = get_file();
PhotoExif original_exif = new PhotoExif(get_file());
// if only rotated, only need to copy and modify the EXIF
if (!photo_table.has_transformations(photo_id) && original.has_exif()) {
get_file().copy(file, FileCopyFlags.OVERWRITE, null, null);
if (!photo_table.has_transformations(photo_id) && original_exif.has_exif()) {
original_file.copy(dest_file, FileCopyFlags.OVERWRITE, null, null);
PhotoExif exportable = new PhotoExif(file);
exportable.set_orientation(photo_table.get_orientation(photo_id));
exportable.commit();
PhotoExif dest_exif = new PhotoExif(dest_file);
dest_exif.set_orientation(photo_table.get_orientation(photo_id));
dest_exif.commit();
} else {
Gdk.Pixbuf pixbuf = get_pixbuf();
pixbuf.save(file.get_path(), "jpeg", "quality", EXPORT_JPEG_QUALITY);
pixbuf.save(dest_file.get_path(), "jpeg", "quality", EXPORT_JPEG_QUALITY.get_pct_text());
copy_exported_exif(original_exif, new PhotoExif(dest_file), Orientation.TOP_LEFT,
Dimensions.for_pixbuf(pixbuf));
}
return dest_file;
}
// Writes a file appropriate for export meeting the specified parameters.
//
// TODO: Lossless transformations, especially for mere rotations of JFIF files.
public void export(File dest_file, int scale, ScaleConstraint constraint,
Jpeg.Quality quality) throws Error {
if (constraint == ScaleConstraint.ORIGINAL) {
// generate a raw exportable file and copy that
File exportable = generate_exportable();
exportable.copy(dest_file, FileCopyFlags.OVERWRITE | FileCopyFlags.ALL_METADATA,
null, null);
if (original.has_exif()) {
PhotoExif exportable = new PhotoExif(file);
exportable.set_exif(original.get_exif());
exportable.set_dimensions(Dimensions.for_pixbuf(pixbuf));
exportable.set_orientation(Orientation.TOP_LEFT);
exportable.commit();
}
return;
}
return file;
Gdk.Pixbuf pixbuf = get_pixbuf();
Dimensions dim = Dimensions.for_pixbuf(pixbuf);
Dimensions scaled = dim.get_scaled_by_constraint(scale, constraint);
// only scale if necessary ... although scale_simple probably catches this, it's an easy
// check to avoid image loss
if (dim.width != scaled.width || dim.height != scaled.height)
pixbuf = pixbuf.scale_simple(scaled.width, scaled.height, EXPORT_INTERP);
pixbuf.save(dest_file.get_path(), "jpeg", "quality", quality.get_pct_text());
copy_exported_exif(new PhotoExif(get_file()), new PhotoExif(dest_file), Orientation.TOP_LEFT,
scaled);
}
// Returns unscaled thumbnail with all modifications applied applicable to the scale
......@@ -412,6 +450,9 @@ public class Photo : Object {
}
private void photo_altered() {
altered();
// load transformed image for thumbnail generation
Gdk.Pixbuf pixbuf = null;
try {
pixbuf = get_pixbuf();
......@@ -421,7 +462,6 @@ public class Photo : Object {
ThumbnailCache.import(photo_id, pixbuf, true);
altered();
thumbnail_altered();
}
......
......@@ -115,6 +115,7 @@ public class PhotoPage : Page {
// TODO: Mark fields for translation
private const Gtk.ActionEntry[] ACTIONS = {
{ "FileMenu", null, "_File", null, null, null },
{ "Export", null, "_Export", "<Ctrl>E", "Export photo to disk", on_export },
{ "ViewMenu", null, "_View", null, null, null },
{ "ReturnToPage", null, "_Return to Collection", "Escape", null, on_return_to_collection },
......@@ -357,6 +358,26 @@ public class PhotoPage : Page {
AppWindow.get_instance().switch_to_page(controller);
}
private void on_export() {
ExportDialog export_dialog = new ExportDialog(1);