├── CHANGELOG.md ├── logos ├── logo-box-builtby.png └── logo-box-madefor.png ├── package.json ├── test.html ├── index.js └── README.md /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.0 (2023-07-05) 4 | 5 | Initial release. 6 | -------------------------------------------------------------------------------- /logos/logo-box-builtby.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms/scale/main/logos/logo-box-builtby.png -------------------------------------------------------------------------------- /logos/logo-box-madefor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms/scale/main/logos/logo-box-madefor.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apostrophecms/scale", 3 | "version": "1.0.0", 4 | "description": "Scale an image file in the browser before uploading it to your server", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/apostrophecms/scale.git" 12 | }, 13 | "keywords": [ 14 | "scale", 15 | "image", 16 | "image", 17 | "scale" 18 | ], 19 | "type": "module", 20 | "author": "Apostrophe Technologies", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/apostrophecms/scale/issues" 24 | }, 25 | "homepage": "https://github.com/apostrophecms/scale#readme" 26 | } 27 | -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | Image Scale Example 8 | 13 | 14 | 15 | 16 | 17 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export default async (file, { maxWidth, maxHeight, fallback, type }) => { 2 | const img = new Image(); 3 | const url = URL.createObjectURL(file); 4 | const ready = new Promise(resolve => { 5 | img.addEventListener('load', e => resolve()); 6 | img.addEventListener('error', e => reject()); 7 | }); 8 | img.setAttribute('src', url); 9 | await ready; 10 | const newType = resolveType(type, file.type); 11 | if ((newType === file.type) && (!maxWidth || (img.width <= maxWidth)) && (!maxHeight || (img.height <= maxHeight))) { 12 | return file; 13 | } 14 | const canvas = document.createElement('canvas'); 15 | const context = canvas.getContext('2d', { alpha: true }); 16 | let newWidth = img.width; 17 | let newHeight = img.height; 18 | if (maxWidth) { 19 | if (newWidth > maxWidth) { 20 | newWidth = maxWidth; 21 | newHeight = img.height * maxWidth / img.width; 22 | } 23 | } 24 | if (maxHeight) { 25 | if (newHeight > maxHeight) { 26 | newHeight = maxHeight; 27 | newWidth = img.width * maxHeight / img.height; 28 | } 29 | } 30 | canvas.width = newWidth; 31 | canvas.height = newHeight; 32 | context.drawImage(img, 0, 0, newWidth, newHeight); 33 | const blob = await new Promise(resolve => { 34 | canvas.toBlob(blob => { 35 | if (blob) { 36 | return resolve(blob); 37 | } 38 | if (!fallback) { 39 | return reject(new Error('Could not convert canvas to blob')); 40 | } 41 | // Fall back to the original, possibly too large or 42 | // unsuitably typed file 43 | return resolve(file); 44 | }, resolveType(type, file.type)); 45 | }); 46 | return blob; 47 | }; 48 | 49 | function resolveType(resolver, type) { 50 | if (!resolver) { 51 | return type; 52 | } 53 | if ((typeof resolver) === 'string') { 54 | return resolver; 55 | } 56 | if ((typeof resolver) === 'object') { 57 | const newType = Object.hasOwn(resolver, type) && resolver[type]; 58 | if (!newType) { 59 | noType(type); 60 | } 61 | } 62 | if ((typeof resolver) !== 'function') { 63 | throw new Error('If specified, "type" must be a string, an object or a function'); 64 | } 65 | const newType = resolver(type); 66 | if (!newType) { 67 | noType(type); 68 | } 69 | } 70 | 71 | function noType() { 72 | throw new Error(`The type ${type} cannot be resolved to a new type`); 73 | } 74 | 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `@apostrophecms/scale` 2 | 3 | 4 | 5 | ## Purpose 6 | 7 | Resizing 16-megapixel images on the server side can easily DOS your server 8 | (Denial Of Service). This module scales images appropriately in the browser 9 | before uploading them to your server. 10 | 11 | ## Installation 12 | 13 | ```bash 14 | npm install @apostrophecms/scale 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```javascript 20 | import scale from '@apostrophecms/scale'; 21 | 22 | // See test.html for sample markup 23 | const input = document.querySelector('#file-input'); 24 | input.addEventListener('change', async e => { 25 | let file = input.files[0]; 26 | 27 | // Limit the maximum size 28 | file = await scale(file, { 29 | maxWidth: 1600, 30 | maxHeight: 1600 31 | }); 32 | 33 | // Upload as multipart/form-data just like always 34 | const body = new FormData(); 35 | body.append('file', file); 36 | const response = await fetch('/upload', { 37 | method: 'POST', 38 | body 39 | }); 40 | }); 41 | ``` 42 | 43 | **The aspect ratio always stays the same.** There is no cropping, letterboxing or stretching. All we care about here is reducing file size by reducing overall dimensions. 44 | 45 | By default, the content type stays the same (`image/jpeg` stays JPEG, `image/png` stays PNG, etc). 46 | 47 | That's it! You're good to go. 48 | 49 | ## Fancy options 50 | 51 | ### Changing the file's content type 52 | 53 | If you want, you can turn everything into a WebP file (depending on browser support, you may get PNG as a fallback): 54 | 55 | ```javascript 56 | file = await scale(file, { 57 | maxWidth: 1600, 58 | maxHeight: 1600, 59 | type: 'image/webp' 60 | }); 61 | ``` 62 | 63 | Or, specify a mapping from type names to new type names: 64 | 65 | ```javascript 66 | file = await scale(file, { 67 | maxWidth: 1600, 68 | maxHeight: 1600, 69 | type: { 70 | 'image/gif': 'image/png', 71 | 'image/webp': 'image/png', 72 | 'image/png': 'image/png', 73 | 'image/jpeg': 'image/jpeg', 74 | } 75 | }); 76 | ``` 77 | 78 | Or, pass your own function: 79 | 80 | ```javascript 81 | file = await scale(file, { 82 | maxWidth: 1600, 83 | maxHeight: 1600, 84 | type(name) => (name === 'image/gif') ? 'image/png' : name 85 | }); 86 | ``` 87 | 88 | ### Falling back to the original file 89 | 90 | If you want, you can let the browser pass the original file in cases where scaling somehow fails: 91 | 92 | ```javascript 93 | file = await scale(file, { 94 | maxWidth: 1600, 95 | maxHeight: 1600, 96 | fallback: true 97 | }); 98 | ``` 99 | 100 | Otherwise an error is thrown in this situation. 101 | 102 | ## Previewing the image 103 | 104 | ```javascript 105 | file = await scale(file, { 106 | maxWidth: 1600, 107 | maxHeight: 1600 108 | }); 109 | 110 | const img = document.querySelector('#my-img-element'); 111 | img.setAttribute('src', URL.createObjectURL(file)); 112 | ``` 113 | 114 | `URL.createObjectURL` can turn the returned object into a suitable URL for use with `img src` or `style: background-image`. 115 | 116 | ## "What about the server side?" 117 | 118 | That depends entirely on your language and framework of choice. If you're using 119 | Node.js, check out [multiparty](https://www.npmjs.com/package/multiparty) and 120 | [sharp](https://sharp.pixelplumbing.com/). Remember, you can never trust the 121 | browser, so using a library like `sharp` to validate the images is still 122 | important. 123 | --------------------------------------------------------------------------------