diff --git a/cypress/fixtures/sample_image.jpg b/cypress/fixtures/sample_image.jpg new file mode 100644 index 0000000000..6322b65e33 Binary files /dev/null and b/cypress/fixtures/sample_image.jpg differ diff --git a/cypress/integration/file_uploader.js b/cypress/integration/file_uploader.js index 2f457983de..e1e232c058 100644 --- a/cypress/integration/file_uploader.js +++ b/cypress/integration/file_uploader.js @@ -54,4 +54,24 @@ context('FileUploader', () => { .should('have.property', 'file_url', 'https://github.com'); cy.get('.modal:visible').should('not.exist'); }); + + it('should allow cropping and optimization for valid images', () => { + open_upload_dialog(); + + cy.get_open_dialog().find('.file-upload-area').attachFile('sample_image.jpg', { + subjectType: 'drag-n-drop', + }); + + cy.get_open_dialog().find('.file-name').should('contain', 'sample_image.jpg'); + cy.get_open_dialog().find('.btn-crop').first().click(); + cy.get_open_dialog().find('.image-cropper-actions > .btn-primary').should('contain', 'Crop'); + cy.get_open_dialog().find('.image-cropper-actions > .btn-primary').click(); + cy.get_open_dialog().find('.optimize-checkbox').first().should('contain', 'Optimize'); + cy.get_open_dialog().find('.optimize-checkbox').first().click(); + + cy.intercept('POST', '/api/method/upload_file').as('upload_file'); + cy.get_open_dialog().find('.btn-modal-primary').click(); + cy.wait('@upload_file').its('response.statusCode').should('eq', 200); + cy.get('.modal:visible').should('not.exist'); + }); }); diff --git a/frappe/core/doctype/file/file.js b/frappe/core/doctype/file/file.js index 6d77cb91ad..bc0cc17553 100644 --- a/frappe/core/doctype/file/file.js +++ b/frappe/core/doctype/file/file.js @@ -23,6 +23,25 @@ frappe.ui.form.on("File", "refresh", function(frm) { wrapper.empty(); } + var is_raster_image = (/\.(gif|jpg|jpeg|tiff|png)$/i).test(frm.doc.file_url); + var is_optimizable = !frm.doc.is_folder && is_raster_image && frm.doc.file_size > 0; + + if (is_optimizable) { + frm.add_custom_button(__("Optimize"), function() { + frappe.show_alert(__("Optimizing image...")); + frappe.call({ + method: "frappe.core.doctype.file.file.optimize_saved_image", + args: { + doc_name: frm.doc.name, + }, + callback: function() { + frappe.show_alert(__("Image optimized")); + frappe.set_route("List", "File"); + } + }); + }); + } + if(frm.doc.file_name && frm.doc.file_name.split('.').splice(-1)[0]==='zip') { frm.add_custom_button(__('Unzip'), function() { frappe.call({ diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 95c33879e6..e79b2bd761 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -28,7 +28,7 @@ import frappe from frappe import _, conf from frappe.model.document import Document from frappe.utils import call_hook_method, cint, cstr, encode, get_files_path, get_hook_method, random_string, strip -from frappe.utils.image import strip_exif_data +from frappe.utils.image import strip_exif_data, optimize_image class MaxFileSizeReachedError(frappe.ValidationError): pass @@ -879,6 +879,15 @@ def extract_images_from_html(doc, content): data = match.group(1) data = data.split("data:")[1] headers, content = data.split(",") + mtype = headers.split(";")[0] + + if isinstance(content, str): + content = content.encode("utf-8") + if b"," in content: + content = content.split(b",")[1] + content = base64.b64decode(content) + + content = optimize_image(content, mtype) if "filename=" in headers: filename = headers.split("filename=")[-1] @@ -887,7 +896,6 @@ def extract_images_from_html(doc, content): if not isinstance(filename, str): filename = str(filename, 'utf-8') else: - mtype = headers.split(";")[0] filename = get_random_filename(content_type=mtype) doctype = doc.parenttype if doc.parent else doc.doctype @@ -899,7 +907,7 @@ def extract_images_from_html(doc, content): "attached_to_doctype": doctype, "attached_to_name": name, "content": content, - "decode": True + "decode": False }) _file.save(ignore_permissions=True) file_url = _file.file_url @@ -932,6 +940,22 @@ def unzip_file(name): files = file_obj.unzip() return len(files) +@frappe.whitelist() +def optimize_saved_image(doc_name): + file_doc = frappe.get_doc('File', doc_name) + content = file_doc.get_content() + content_type = mimetypes.guess_type(file_doc.file_name)[0] + + optimized_content = optimize_image(content, content_type) + + file_path = get_files_path(is_private=file_doc.is_private) + file_path = os.path.join(file_path.encode('utf-8'), file_doc.file_name.encode('utf-8')) + with open(file_path, 'wb+') as f: + f.write(optimized_content) + + file_doc.file_size = len(optimized_content) + file_doc.content_hash = get_content_hash(optimized_content) + file_doc.save() @frappe.whitelist() def get_attached_images(doctype, names): diff --git a/frappe/handler.py b/frappe/handler.py index 9d32b0acee..2e9fb7b454 100755 --- a/frappe/handler.py +++ b/frappe/handler.py @@ -10,6 +10,8 @@ from frappe.utils import cint from frappe import _, is_whitelisted from frappe.utils.response import build_response from frappe.utils.csvutils import build_csv_response +from frappe.utils.image import optimize_image +from mimetypes import guess_type from frappe.core.doctype.server_script.server_script_utils import run_server_script_api @@ -145,6 +147,7 @@ def upload_file(): folder = frappe.form_dict.folder or 'Home' method = frappe.form_dict.method filename = frappe.form_dict.file_name + optimize = frappe.form_dict.optimize content = None if 'file' in files: @@ -152,12 +155,23 @@ def upload_file(): content = file.stream.read() filename = file.filename + content_type = guess_type(filename)[0] + if optimize and content_type.startswith("image/"): + args = { + "content": content, + "content_type": content_type + } + if frappe.form_dict.max_width: + args["max_width"] = int(frappe.form_dict.max_width) + if frappe.form_dict.max_height: + args["max_height"] = int(frappe.form_dict.max_height) + content = optimize_image(**args) + frappe.local.uploaded_file = content frappe.local.uploaded_filename = filename if not file_url and (frappe.session.user == "Guest" or (user and not user.has_desk_access())): - import mimetypes - filetype = mimetypes.guess_type(filename)[0] + filetype = guess_type(filename)[0] if filetype not in ALLOWED_MIMETYPES: frappe.throw(_("You can only upload JPG, PNG, PDF, or Microsoft documents.")) diff --git a/frappe/public/icons/timeless/symbol-defs.svg b/frappe/public/icons/timeless/symbol-defs.svg index 7845811627..6108daa938 100644 --- a/frappe/public/icons/timeless/symbol-defs.svg +++ b/frappe/public/icons/timeless/symbol-defs.svg @@ -699,4 +699,7 @@ + + + diff --git a/frappe/public/js/frappe/file_uploader/FilePreview.vue b/frappe/public/js/frappe/file_uploader/FilePreview.vue index cca7dfde2a..43dbacb17d 100644 --- a/frappe/public/js/frappe/file_uploader/FilePreview.vue +++ b/frappe/public/js/frappe/file_uploader/FilePreview.vue @@ -28,6 +28,7 @@ {{ file.file_obj.size | file_size }} +
- +
+ + +
@@ -55,7 +59,8 @@ export default { }, data() { return { - src: null + src: null, + optimize: this.file.optimize } }, mounted() { @@ -89,6 +94,14 @@ export default { is_image() { return this.file.file_obj.type.startsWith('image'); }, + is_optimizable() { + let is_svg = this.file.file_obj.type == 'image/svg+xml'; + return this.is_image && !is_svg; + }, + is_cropable() { + let croppable_types = ['image/jpeg', 'image/png']; + return !this.uploaded && !this.file.uploading && croppable_types.includes(this.file.file_obj.type); + }, progress() { let value = Math.round((this.file.progress * 100) / this.file.total); if (isNaN(value)) { @@ -173,4 +186,26 @@ export default { padding: var(--padding-xs); box-shadow: none; } + +.file-action-buttons { + display: flex; + justify-content: flex-end; +} + +.muted { + opacity: 0.5; + transition: 0.3s; +} + +.muted:hover { + opacity: 1; +} + +.optimize-checkbox { + font-size: var(--text-sm); + color: var(--text-light); + display: flex; + align-items: center; + padding-top: 0.25rem; +} diff --git a/frappe/public/js/frappe/file_uploader/FileUploader.vue b/frappe/public/js/frappe/file_uploader/FileUploader.vue index 06f9275711..90aa545941 100644 --- a/frappe/public/js/frappe/file_uploader/FileUploader.vue +++ b/frappe/public/js/frappe/file_uploader/FileUploader.vue @@ -46,7 +46,7 @@
{{ __('Library') }}
-