Merge pull request #13835 from MitulDavid/image-processing

feat: Image cropping and optimization
This commit is contained in:
mergify[bot] 2021-08-10 05:16:08 +00:00 committed by GitHub
commit afd69729de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 329 additions and 22 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

View file

@ -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');
});
});

View file

@ -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({

View file

@ -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):

View file

@ -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."))

View file

@ -699,4 +699,7 @@
<path d="M7.971 8.259a1.305 1.305 0 100-2.61 1.305 1.305 0 000 2.61z"></path>
</g>
</symbol>
<symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" id="icon-crop">
<path d="M14.88,11.63H4.33V1.12m7.34,10.51v3.25M6,4.37h5.64V10M1.13,4.37h3.2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 92 KiB

View file

@ -28,6 +28,7 @@
{{ file.file_obj.size | file_size }}
</span>
</div>
<label v-if="is_optimizable" class="optimize-checkbox"><input type="checkbox" :checked="optimize" @change="$emit('toggle_optimize')">Optimize</label>
</div>
<div class="file-actions">
<ProgressRing
@ -40,7 +41,10 @@
/>
<div v-if="uploaded" v-html="frappe.utils.icon('solid-success', 'lg')"></div>
<div v-if="file.failed" v-html="frappe.utils.icon('solid-red', 'lg')"></div>
<button v-if="!uploaded && !file.uploading" class="btn" @click="$emit('remove')" v-html="frappe.utils.icon('delete', 'md')"></button>
<div class="file-action-buttons">
<button v-if="is_cropable" class="btn btn-crop muted" @click="$emit('toggle_image_cropper')" v-html="frappe.utils.icon('crop', 'md')"></button>
<button v-if="!uploaded && !file.uploading" class="btn muted" @click="$emit('remove')" v-html="frappe.utils.icon('delete', 'md')"></button>
</div>
</div>
</div>
</template>
@ -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;
}
</style>

View file

@ -46,7 +46,7 @@
</svg>
<div class="mt-1">{{ __('Library') }}</div>
</button>
<button class="btn btn-file-upload" @click="show_web_link = true">
<button class="btn btn-file-upload" v-if="allow_web_link" @click="show_web_link = true">
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="15" cy="15" r="15" fill="#ECAC4B"/>
<path d="M12.0469 17.9543L17.9558 12.0454" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
@ -79,13 +79,15 @@
</div>
</div>
<div class="file-preview-area" v-show="files.length && !show_file_browser && !show_web_link">
<div class="file-preview-container">
<div class="file-preview-container" v-if="!show_image_cropper">
<FilePreview
v-for="(file, i) in files"
:key="file.name"
:file="file"
@remove="remove_file(file)"
@toggle_private="file.private = !file.private"
@toggle_optimize="file.optimize = !file.optimize"
@toggle_image_cropper="toggle_image_cropper(i)"
/>
</div>
<div class="flex align-center" v-if="show_upload_button && currently_uploading === -1">
@ -105,6 +107,13 @@
</div>
</div>
</div>
<ImageCropper
v-if="show_image_cropper"
:file="files[crop_image_with_index]"
:attach_doc_image="attach_doc_image"
@toggle_image_cropper="toggle_image_cropper(-1)"
@upload_after_crop="trigger_upload=true"
/>
<FileBrowser
ref="file_browser"
v-if="show_file_browser && !disable_file_browser"
@ -123,6 +132,7 @@ import FilePreview from './FilePreview.vue';
import FileBrowser from './FileBrowser.vue';
import WebLink from './WebLink.vue';
import GoogleDrivePicker from '../../integrations/google_drive_picker';
import ImageCropper from './ImageCropper.vue';
export default {
name: 'FileUploader',
@ -164,6 +174,9 @@ export default {
allowed_file_types: [] // ['image/*', 'video/*', '.jpg', '.gif', '.pdf']
})
},
attach_doc_image: {
default: false
},
upload_notes: {
default: null // "Images or video, upto 2MB"
}
@ -171,7 +184,8 @@ export default {
components: {
FilePreview,
FileBrowser,
WebLink
WebLink,
ImageCropper
},
data() {
return {
@ -180,7 +194,12 @@ export default {
currently_uploading: -1,
show_file_browser: false,
show_web_link: false,
show_image_cropper: false,
crop_image_with_index: -1,
trigger_upload: false,
hide_dialog_footer: false,
allow_take_photo: false,
allow_web_link: true,
google_drive_settings: {
enabled: false
}
@ -234,6 +253,11 @@ export default {
remove_file(file) {
this.files = this.files.filter(f => f !== file);
},
toggle_image_cropper(index) {
this.crop_image_with_index = this.show_image_cropper ? -1 : index;
this.hide_dialog_footer = !this.show_image_cropper;
this.show_image_cropper = !this.show_image_cropper;
},
toggle_all_private() {
let flag;
let private_values = this.files.filter(file => file.private);
@ -257,6 +281,9 @@ export default {
let is_image = file.type.startsWith('image');
return {
file_obj: file,
cropper_file: file,
crop_box_data: null,
optimize: this.attach_doc_image ? true : false,
name: file.name,
doc: null,
progress: 0,
@ -267,6 +294,9 @@ export default {
}
});
this.files = this.files.concat(files);
if(this.files.length != 0 && this.attach_doc_image) {
this.toggle_image_cropper(0);
}
},
check_restrictions(file) {
let { max_file_size, allowed_file_types } = this.restrictions;
@ -447,6 +477,15 @@ export default {
form_data.append('method', this.method);
}
if (file.optimize) {
form_data.append('optimize', true);
}
if (this.attach_doc_image) {
form_data.append('max_width', 200);
form_data.append('max_height', 200);
}
xhr.send(form_data);
});
},

View file

@ -0,0 +1,80 @@
<template>
<div>
<div>
<img ref="image" :src="src" :alt="file.name"/>
</div>
<br/>
<div class="image-cropper-actions">
<button class="btn btn-sm margin-right" v-if="!attach_doc_image" @click="$emit('toggle_image_cropper')">Back</button>
<button class="btn btn-primary btn-sm margin-right" @click="crop_image" v-html="crop_button_text"></button>
</div>
</div>
</template>
<script>
import Cropper from "cropperjs";
export default {
name: "ImageCropper",
props: ["file", "attach_doc_image"],
data() {
return {
src: null,
cropper: null,
image: null
};
},
mounted() {
if (window.FileReader) {
let fr = new FileReader();
fr.onload = () => (this.src = fr.result);
fr.readAsDataURL(this.file.cropper_file);
}
aspect_ratio = this.attach_doc_image ? 1 : NaN;
crop_box = this.file.crop_box_data;
this.image = this.$refs.image;
this.image.onload = () => {
this.cropper = new Cropper(this.image, {
zoomable: false,
scalable: false,
viewMode: 1,
data: crop_box,
aspectRatio: aspect_ratio
});
};
},
computed: {
crop_button_text() {
return this.attach_doc_image ? "Upload" : "Crop";
}
},
methods: {
crop_image() {
this.file.crop_box_data = this.cropper.getData();
const canvas = this.cropper.getCroppedCanvas();
const file_type = this.file.file_obj.type;
canvas.toBlob(blob => {
var cropped_file_obj = new File([blob], this.file.name, {
type: blob.type
});
this.file.file_obj = cropped_file_obj;
this.$emit("toggle_image_cropper");
if(this.attach_doc_image) {
this.$emit("upload_after_crop");
}
}, file_type);
}
}
};
</script>
<style>
img {
display: block;
max-width: 100%;
max-height: 600px;
}
.image-cropper-actions {
display: flex;
justify-content: flex-end;
}
</style>

View file

@ -15,6 +15,7 @@ export default class FileUploader {
allow_multiple,
as_dataurl,
disable_file_browser,
attach_doc_image,
frm
} = {}) {
@ -26,6 +27,10 @@ export default class FileUploader {
this.wrapper = wrapper.get ? wrapper.get(0) : wrapper;
}
if (attach_doc_image) {
restrictions.allowed_file_types = ['.jpg', '.jpeg', '.png'];
}
this.$fileuploader = new Vue({
el: this.wrapper,
render: h => h(FileUploaderComponent, {
@ -42,6 +47,7 @@ export default class FileUploader {
allow_multiple,
as_dataurl,
disable_file_browser,
attach_doc_image,
}
})
});
@ -55,6 +61,20 @@ export default class FileUploader {
}
}, { deep: true });
this.uploader.$watch('trigger_upload', (trigger_upload) => {
if (trigger_upload) {
this.upload_files();
}
});
this.uploader.$watch('hide_dialog_footer', (hide_dialog_footer) => {
if (hide_dialog_footer) {
this.dialog && this.dialog.footer.addClass('hide');
} else {
this.dialog && this.dialog.footer.removeClass('hide');
}
});
if (files && files.length) {
this.uploader.add_files(files);
}

View file

@ -4,8 +4,13 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro
this.$input = $('<button class="btn btn-default btn-sm btn-attach">')
.html(__("Attach"))
.prependTo(me.input_area)
.on("click", function() {
me.on_attach_click();
.on({
click: function() {
me.on_attach_click();
},
attach_doc_image: function() {
me.on_attach_doc_image();
}
});
this.$value = $(
`<div class="attached-file flex justify-between align-center">
@ -54,6 +59,11 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro
this.set_upload_options();
this.file_uploader = new frappe.ui.FileUploader(this.upload_options);
}
on_attach_doc_image() {
this.set_upload_options();
this.upload_options["attach_doc_image"] = true;
this.file_uploader = new frappe.ui.FileUploader(this.upload_options);
}
set_upload_options() {
let options = {
allow_multiple: false,

View file

@ -83,7 +83,7 @@ frappe.ui.form.setup_user_image_event = function(frm) {
if(!field.$input) {
field.make_input();
}
field.$input.trigger('click');
field.$input.trigger('attach_doc_image');
} else {
/// on remove event for a sidebar image wrapper remove attach file.
frm.attachments.remove_attachment_by_filename(frm.doc[frm.meta.image_field], function() {

View file

@ -1,4 +1,5 @@
@import "../common/form.scss";
@import '~cropperjs/dist/cropper.min';
.form-section, .form-dashboard-section {
margin: 0px;

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

View file

@ -9,8 +9,9 @@ from frappe.utils import ceil, floor
from frappe.utils.data import validate_python_code
from PIL import Image
from frappe.utils.image import strip_exif_data
from frappe.utils.image import strip_exif_data, optimize_image
import io
from mimetypes import guess_type
class TestFilters(unittest.TestCase):
def test_simple_dict(self):
@ -190,6 +191,19 @@ class TestImage(unittest.TestCase):
self.assertEqual(new_image._getexif(), None)
self.assertNotEqual(original_image._getexif(), new_image._getexif())
def test_optimize_image(self):
image_file_path = "../apps/frappe/frappe/tests/data/sample_image_for_optimization.jpg"
content_type = guess_type(image_file_path)[0]
original_content = io.open(image_file_path, mode='rb').read()
optimized_content = optimize_image(original_content, content_type, max_width=500, max_height=500)
optimized_image = Image.open(io.BytesIO(optimized_content))
width, height = optimized_image.size
self.assertLessEqual(width, 500)
self.assertLessEqual(height, 500)
self.assertLess(len(optimized_content), len(original_content))
class TestPythonExpressions(unittest.TestCase):
def test_validation_for_good_python_expression(self):
@ -215,4 +229,4 @@ class TestPythonExpressions(unittest.TestCase):
"oops = forgot_equals",
]
for expr in invalid_expressions:
self.assertRaises(frappe.ValidationError, validate_python_code, expr)
self.assertRaises(frappe.ValidationError, validate_python_code, expr)

View file

@ -11,7 +11,7 @@ from frappe import _
from frappe import conf
from copy import copy
from urllib.parse import unquote
from frappe.utils.image import optimize_image
class MaxFileSizeReachedError(frappe.ValidationError):
pass
@ -386,6 +386,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]
@ -394,7 +403,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
@ -405,7 +413,7 @@ def extract_images_from_html(doc, content):
name = doc.reference_name
# TODO fix this
file_url = save_file(filename, content, doctype, name, decode=True).get("file_url")
file_url = save_file(filename, content, doctype, name, decode=False).get("file_url")
if not frappe.flags.has_dataurl:
frappe.flags.has_dataurl = True

View file

@ -1,6 +1,8 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
import os
from PIL import Image
import io
def resize_images(path, maxdim=700):
from PIL import Image
@ -26,9 +28,6 @@ def strip_exif_data(content, content_type):
Bytes: Stripped image content
"""
from PIL import Image
import io
original_image = Image.open(io.BytesIO(content))
output = io.BytesIO()
@ -38,4 +37,19 @@ def strip_exif_data(content, content_type):
content = output.getvalue()
return content
return content
def optimize_image(content, content_type, max_width=1920, max_height=1080, optimize=True, quality=85):
if content_type == 'image/svg+xml':
return content
image = Image.open(io.BytesIO(content))
image_format = content_type.split('/')[1]
size = max_width, max_height
image.thumbnail(size, Image.LANCZOS)
output = io.BytesIO()
image.save(output, format=image_format, optimize=optimize, quality=quality, save_all=True if image_format=='gif' else None)
optimized_content = output.getvalue()
return optimized_content if len(optimized_content) < len(content) else content

View file

@ -27,6 +27,7 @@
"bootstrap": "4.5.0",
"cliui": "^7.0.4",
"cookie": "^0.4.0",
"cropperjs": "^1.5.12",
"cssnano": "^5.0.0",
"driver.js": "^0.9.8",
"express": "^4.17.1",

View file

@ -1680,6 +1680,11 @@ cosmiconfig@^7.0.0:
path-type "^4.0.0"
yaml "^1.10.0"
cropperjs@^1.5.12:
version "1.5.12"
resolved "https://registry.yarnpkg.com/cropperjs/-/cropperjs-1.5.12.tgz#d9c0db2bfb8c0d769d51739e8f916bbc44e10f50"
integrity sha512-re7UdjE5UnwdrovyhNzZ6gathI4Rs3KGCBSc8HCIjUo5hO42CtzyblmWLj6QWVw7huHyDMfpKxhiO2II77nhDw==
cross-spawn@7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"