Commit 9a8f9631 authored by Jim Nelson's avatar Jim Nelson

Save inline images displayed via MIME Content-ID: Closes #7475

Some reorganization of how data: URIs are assembled and injected
into the document.

Also, mild improvement to the GMime bindings.
parent 935b90d3
......@@ -804,19 +804,19 @@ namespace GMime {
[CCode (cname = "g_mime_part_get_best_content_encoding")]
public GMime.ContentEncoding get_best_content_encoding (GMime.EncodingConstraint constraint);
[CCode (cname = "g_mime_part_get_content_description")]
public unowned string get_content_description ();
public unowned string? get_content_description ();
[CCode (cname = "g_mime_part_get_content_encoding")]
public GMime.ContentEncoding get_content_encoding ();
[CCode (cname = "g_mime_part_get_content_id")]
public unowned string get_content_id ();
public unowned string? get_content_id ();
[CCode (cname = "g_mime_part_get_content_location")]
public unowned string get_content_location ();
public unowned string? get_content_location ();
[CCode (cname = "g_mime_part_get_content_md5")]
public unowned string get_content_md5 ();
public unowned string? get_content_md5 ();
[CCode (cname = "g_mime_part_get_content_object")]
public unowned GMime.DataWrapper get_content_object ();
public unowned GMime.DataWrapper? get_content_object ();
[CCode (cname = "g_mime_part_get_filename")]
public unowned string get_filename ();
public unowned string? get_filename ();
[CCode (cname = "g_mime_part_set_content_description")]
public void set_content_description (string description);
[CCode (cname = "g_mime_part_set_content_encoding")]
......
......@@ -11,7 +11,13 @@ g_mime_object_get_content_type_parameter nullable="1"
g_mime_object_to_string transfer_ownership="1"
g_mime_param_next name="get_next"
g_mime_parser_construct_message nullable="1"
g_mime_part_get_content_description nullable="1"
g_mime_part_get_content_location nullable="1"
g_mime_part_get_content_id nullable="1"
g_mime_part_get_content_md5 nullable="1"
g_mime_part_get_content_object nullable="1"
g_mime_part_get_content_part nullable="1"
g_mime_part_get_filename nullable="1"
g_mime_signer_next name="get_next"
g_mime_stream_mem_new_with_buffer.buffer is_array="1" array_length_pos="1.0" type_name="uint8[]"
g_mime_stream_mem_new_with_buffer.len hidden="1"
......
......@@ -1492,12 +1492,13 @@ public class GearyController : Geary.BaseObject {
}
}
private void on_save_buffer_to_file(string filename, Geary.Memory.Buffer buffer) {
private void on_save_buffer_to_file(string? filename, Geary.Memory.Buffer buffer) {
Gtk.FileChooserDialog dialog = new Gtk.FileChooserDialog(null, main_window, Gtk.FileChooserAction.SAVE,
Stock._CANCEL, Gtk.ResponseType.CANCEL, Stock._SAVE, Gtk.ResponseType.ACCEPT, null);
if (last_save_directory != null)
dialog.set_current_folder(last_save_directory.get_path());
dialog.set_current_name(filename);
if (!Geary.String.is_empty(filename))
dialog.set_current_name(filename);
dialog.set_do_overwrite_confirmation(true);
dialog.confirm_overwrite.connect(on_confirm_overwrite);
dialog.set_create_folders(true);
......
......@@ -400,3 +400,50 @@ public string resolve_nesting(string text, string[] values) {
}
}
// Returns a URI suitable for an IMG SRC attribute (or elsewhere, potentially) that is the
// memory buffer unpacked into a Base-64 encoded data: URI
public string assemble_data_uri(string mimetype, Geary.Memory.Buffer buffer) {
// attempt to use UnownedBytesBuffer to avoid memcpying a potentially huge buffer only to
// free it when the encoding operation is completed
string base64;
Geary.Memory.UnownedBytesBuffer? unowned_bytes = buffer as Geary.Memory.UnownedBytesBuffer;
if (unowned_bytes != null)
base64 = Base64.encode(unowned_bytes.to_unowned_uint8_array());
else
base64 = Base64.encode(buffer.get_uint8_array());
return "data:%s;base64,%s".printf(mimetype, base64);
}
// Turns the data: URI created by assemble_data_uri() back into its components. The returned
// buffer is decoded.
//
// TODO: Return mimetype
public bool dissasemble_data_uri(string uri, out Geary.Memory.Buffer? buffer) {
buffer = null;
if (!uri.has_prefix("data:"))
return false;
// count from semicolon past encoding type specifier
int start_index = uri.index_of(";");
if (start_index <= 0)
return false;
// watch for string termination to avoid overflow
int base64_len = "base64,".length;
for (int ctr = 0; ctr < base64_len; ctr++) {
if (uri[start_index++] == Geary.String.EOS)
return false;
}
// avoid a memory copy of the substring by manually calculating the start address
uint8[] bytes = Base64.decode((string) (((char *) uri) + start_index));
// transfer ownership of the byte array directly to the Buffer; this prevents an
// unnecessary copy
buffer = new Geary.Memory.ByteBuffer.take((owned) bytes, bytes.length);
return true;
}
......@@ -23,7 +23,7 @@ public class ConversationViewer : Gtk.Box {
private const string MESSAGE_CONTAINER_ID = "message_container";
private const string SELECTION_COUNTER_ID = "multiple_messages";
private const string SPINNER_ID = "spinner";
private const string REPLACED_IMAGE_CLASS = "replaced_inline_image";
private const string DATA_IMAGE_CLASS = "data_inline_image";
private enum SearchState {
// Search/find states.
......@@ -94,7 +94,7 @@ public class ConversationViewer : Gtk.Box {
public signal void save_attachments(Gee.List<Geary.Attachment> attachment);
// Fired when the user wants to save an image buffer to disk
public signal void save_buffer_to_file(string filename, Geary.Memory.Buffer buffer);
public signal void save_buffer_to_file(string? filename, Geary.Memory.Buffer buffer);
// Fired when the user clicks the edit draft button.
public signal void edit_draft(Geary.Email message);
......@@ -563,7 +563,7 @@ public class ConversationViewer : Gtk.Box {
bind_event(web_view, ".email .compressed_note", "click", (Callback) on_body_toggle_clicked, this);
bind_event(web_view, ".attachment_container .attachment", "click", (Callback) on_attachment_clicked, this);
bind_event(web_view, ".attachment_container .attachment", "contextmenu", (Callback) on_attachment_menu, this);
bind_event(web_view, "." + REPLACED_IMAGE_CLASS, "contextmenu", (Callback) on_replaced_image_menu, this);
bind_event(web_view, "." + DATA_IMAGE_CLASS, "contextmenu", (Callback) on_data_image_menu, this);
bind_event(web_view, ".remote_images .show_images", "click", (Callback) on_show_images, this);
bind_event(web_view, ".remote_images .show_from", "click", (Callback) on_show_images_from, this);
bind_event(web_view, ".remote_images .close_show_images", "click", (Callback) on_close_show_images, this);
......@@ -693,46 +693,7 @@ public class ConversationViewer : Gtk.Box {
return null;
return "<img alt=\"%s\" class=\"%s\" src=\"%s\" />".printf(
filename, REPLACED_IMAGE_CLASS, assemble_replaced_image_uri(mimetype, buffer));
}
private static string assemble_replaced_image_uri(string mimetype, Geary.Memory.Buffer buffer) {
// attempt to use UnownedBytesBuffer to avoid memcpying a potentially huge buffer only to
// free it when the encoding operation is completed
string base64;
Geary.Memory.UnownedBytesBuffer? unowned_bytes = buffer as Geary.Memory.UnownedBytesBuffer;
if (unowned_bytes != null)
base64 = Base64.encode(unowned_bytes.to_unowned_uint8_array());
else
base64 = Base64.encode(buffer.get_uint8_array());
return "data:%s;base64,%s".printf(mimetype, base64);
}
// Turns the data: URI created by assemble_replaced_image_uri() back into its components. The
// returned buffer is decoded.
//
// TODO: return mimetype
private static bool dissasemble_replaced_image_uri(string uri, out Geary.Memory.Buffer? buffer) {
buffer = null;
if (!uri.has_prefix("data:"))
return false;
// count from semicolon past encoding type specifier
int start_index = uri.index_of(";");
if (start_index <= 0)
return false;
start_index += "base64,".length;
// avoid a memory copy of the substring by manually calculating the start address
uint8[] bytes = Base64.decode((string) (((char *) uri) + start_index));
// transfer ownership of the byte array directly to the Buffer; this prevents an
// unnecessary copy
buffer = new Geary.Memory.ByteBuffer.take((owned) bytes, bytes.length);
return true;
filename, DATA_IMAGE_CLASS, assemble_data_uri(mimetype, buffer));
}
private void unhide_last_email() {
......@@ -1328,17 +1289,17 @@ public class ConversationViewer : Gtk.Box {
conversation_viewer.show_attachment_menu(email, attachment);
}
private static void on_replaced_image_menu(WebKit.DOM.Element element, WebKit.DOM.Event event,
private static void on_data_image_menu(WebKit.DOM.Element element, WebKit.DOM.Event event,
ConversationViewer conversation_viewer) {
event.stop_propagation();
Geary.Memory.Buffer? buffer;
if (!dissasemble_replaced_image_uri(element.get_attribute("src"), out buffer))
if (!dissasemble_data_uri(element.get_attribute("src"), out buffer))
return;
string filename = element.get_attribute("alt");
string? filename = element.get_attribute("alt");
if (buffer != null && buffer.size > 0 && !Geary.String.is_empty(filename))
if (buffer != null && buffer.size > 0)
conversation_viewer.show_replaced_image_menu(filename, buffer);
}
......@@ -1448,7 +1409,7 @@ public class ConversationViewer : Gtk.Box {
return menu;
}
private void show_replaced_image_menu(string filename, Geary.Memory.Buffer buffer) {
private void show_replaced_image_menu(string? filename, Geary.Memory.Buffer buffer) {
image_menu = new Gtk.Menu();
image_menu.selection_done.connect(() => {
image_menu = null;
......@@ -1634,16 +1595,27 @@ public class ConversationViewer : Gtk.Box {
continue;
} else if (src.has_prefix("cid:")) {
string mime_id = src.substring(4);
string? filename = message.get_content_filename_by_mime_id(mime_id);
Geary.Memory.Buffer image_content = message.get_content_by_mime_id(mime_id);
uint8[] image_data = image_content.get_uint8_array();
Geary.Memory.UnownedBytesBuffer? unowned_buffer =
image_content as Geary.Memory.UnownedBytesBuffer;
// Get the content type.
bool uncertain_content_type;
string mimetype = ContentType.get_mime_type(ContentType.guess(null, image_data,
out uncertain_content_type));
// Then set the source to a data url.
web_view.set_data_url(img, mimetype, image_data);
string guess;
if (unowned_buffer != null)
guess = ContentType.guess(null, unowned_buffer.to_unowned_uint8_array(), null);
else
guess = ContentType.guess(null, image_content.get_uint8_array(), null);
string mimetype = ContentType.get_mime_type(guess);
// Replace the SRC to a data URIm the class to a known label for the popup menu,
// and the ALT to its filename, if supplied
img.set_attribute("src", assemble_data_uri(mimetype, image_content));
img.set_attribute("class", DATA_IMAGE_CLASS);
if (!Geary.String.is_empty(filename))
img.set_attribute("alt", filename);
} else if (!src.has_prefix("data:")) {
remote_images = true;
}
......@@ -1838,7 +1810,8 @@ public class ConversationViewer : Gtk.Box {
// Set the image preview and insert it into the container.
WebKit.DOM.HTMLImageElement img =
Util.DOM.select(attachment_table, ".preview img") as WebKit.DOM.HTMLImageElement;
web_view.set_image_src(img, attachment.mime_type, attachment.file.get_path(), ATTACHMENT_PREVIEW_SIZE);
web_view.set_attachment_src(img, attachment.mime_type, attachment.file.get_path(),
ATTACHMENT_PREVIEW_SIZE);
attachment_container.append_child(attachment_table);
}
......
......@@ -202,21 +202,27 @@ public class ConversationWebView : WebKit.WebView {
private void set_icon_src(string selector, string icon_name) {
try {
// Load icon.
uint8[] icon_content = null;
uint8[]? icon_content = null;
Gdk.Pixbuf? pixbuf = IconFactory.instance.load_symbolic_colored(icon_name, 16);
if (pixbuf != null)
pixbuf.save_to_buffer(out icon_content, "png"); // Load as PNG.
if (icon_content == null || icon_content.length == 0)
return;
Geary.Memory.ByteBuffer buffer = new Geary.Memory.ByteBuffer.take((owned) icon_content,
icon_content.length);
// Then set the source to a data url.
WebKit.DOM.HTMLImageElement img = Util.DOM.select(get_dom_document(), selector)
as WebKit.DOM.HTMLImageElement;
set_data_url(img, "image/png", icon_content);
img.set_attribute("src", assemble_data_uri("image/png", buffer));
} catch (Error error) {
warning("Failed to load icon '%s': %s", icon_name, error.message);
}
}
public void set_image_src(WebKit.DOM.HTMLImageElement img, string mime_type, string filename,
public void set_attachment_src(WebKit.DOM.HTMLImageElement img, string mime_type, string filename,
int maxwidth, int maxheight = -1) {
if( maxheight == -1 ){
maxheight = maxwidth;
......@@ -248,17 +254,14 @@ public class ConversationWebView : WebKit.WebView {
}
// Then set the source to a data url.
set_data_url(img, icon_mime_type, content);
Geary.Memory.Buffer buffer = new Geary.Memory.ByteBuffer.take((owned) content,
content.length);
img.set_attribute("src", assemble_data_uri(icon_mime_type, buffer));
} catch (Error error) {
warning("Failed to load image '%s': %s", filename, error.message);
}
}
public void set_data_url(WebKit.DOM.HTMLImageElement img, string mime_type, uint8[] content)
throws Error {
img.set_attribute("src", "data:%s;base64,%s".printf(mime_type, Base64.encode(content)));
}
private bool on_navigation_policy_decision_requested(WebKit.WebFrame frame,
WebKit.NetworkRequest request, WebKit.WebNavigationAction navigation_action,
WebKit.WebPolicyDecision policy_decision) {
......
......@@ -5,6 +5,13 @@
*/
public class Geary.RFC822.Message : BaseObject {
/**
* This delegate is an optional parameter to the body constructers that allows callers
* to process arbitrary non-text, inline MIME parts.
*/
public delegate string? InlinePartReplacer(string filename, string mimetype,
Geary.Memory.Buffer buffer);
private const string DEFAULT_ENCODING = "UTF8";
private const string HEADER_IN_REPLY_TO = "In-Reply-To";
......@@ -419,13 +426,6 @@ public class Geary.RFC822.Message : BaseObject {
return message_to_memory_buffer(true, dotstuffed);
}
/**
* This delegate is an optional parameter to the body constructers that allows callers
* to process arbitrary non-text, inline MIME parts.
*/
public delegate string? InlinePartReplacer(string filename, string mimetype,
Geary.Memory.Buffer buffer);
/**
* This method is the main utility method used by the other body constructors. It calls itself
* recursively via the last argument ("node").
......@@ -605,13 +605,20 @@ public class Geary.RFC822.Message : BaseObject {
public Memory.Buffer get_content_by_mime_id(string mime_id) throws RFC822Error {
GMime.Part? part = find_mime_part_by_mime_id(message.get_mime_part(), mime_id);
if (part == null) {
throw new RFC822Error.NOT_FOUND("Could not find a MIME part with content-id %s",
mime_id);
}
if (part == null)
throw new RFC822Error.NOT_FOUND("Could not find a MIME part with Content-ID %s", mime_id);
return mime_part_to_memory_buffer(part);
}
public string? get_content_filename_by_mime_id(string mime_id) throws RFC822Error {
GMime.Part? part = find_mime_part_by_mime_id(message.get_mime_part(), mime_id);
if (part == null)
throw new RFC822Error.NOT_FOUND("Could not find a MIME part with Content-ID %s", mime_id);
return part.get_filename();
}
private GMime.Part? find_mime_part_by_mime_id(GMime.Object root, string mime_id) {
// If this is a multipart container, check each of its children.
if (root is GMime.Multipart) {
......
......@@ -203,7 +203,7 @@ body:not(.nohide) .email.hide .header_container .avatar {
margin-right: -0.67em;
}
.email .replaced_inline_image {
.email .data_inline_image {
max-width: 100%;
display: block;
margin-top: 1em;
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment