Merge pull request #13835 from MitulDavid/image-processing
feat: Image cropping and optimization
This commit is contained in:
commit
afd69729de
19 changed files with 329 additions and 22 deletions
BIN
cypress/fixtures/sample_image.jpg
Normal file
BIN
cypress/fixtures/sample_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."))
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
|
|
|
|||
80
frappe/public/js/frappe/file_uploader/ImageCropper.vue
Normal file
80
frappe/public/js/frappe/file_uploader/ImageCropper.vue
Normal 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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
@import "../common/form.scss";
|
||||
@import '~cropperjs/dist/cropper.min';
|
||||
|
||||
.form-section, .form-dashboard-section {
|
||||
margin: 0px;
|
||||
|
|
|
|||
BIN
frappe/tests/data/sample_image_for_optimization.jpg
Normal file
BIN
frappe/tests/data/sample_image_for_optimization.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue