├── .npmignore ├── .gitignore ├── img ├── src1.jpg ├── src2.jpg ├── src3.jpg ├── src4.jpg ├── src5.jpg ├── src6.jpg ├── result_no_spacing.png └── result_with_spacing.png ├── examples ├── create-collage.js └── collage-with-text.js ├── package.json ├── LICENSE ├── test.js ├── README.md └── index.js /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | img 3 | test.js -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .DS_Store 4 | myFile 5 | -------------------------------------------------------------------------------- /img/src1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/classdojo/photo-collage/HEAD/img/src1.jpg -------------------------------------------------------------------------------- /img/src2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/classdojo/photo-collage/HEAD/img/src2.jpg -------------------------------------------------------------------------------- /img/src3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/classdojo/photo-collage/HEAD/img/src3.jpg -------------------------------------------------------------------------------- /img/src4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/classdojo/photo-collage/HEAD/img/src4.jpg -------------------------------------------------------------------------------- /img/src5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/classdojo/photo-collage/HEAD/img/src5.jpg -------------------------------------------------------------------------------- /img/src6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/classdojo/photo-collage/HEAD/img/src6.jpg -------------------------------------------------------------------------------- /img/result_no_spacing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/classdojo/photo-collage/HEAD/img/result_no_spacing.png -------------------------------------------------------------------------------- /img/result_with_spacing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/classdojo/photo-collage/HEAD/img/result_with_spacing.png -------------------------------------------------------------------------------- /examples/create-collage.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const args = process.argv.slice(2); 4 | if (!args[0]) return console.log("ERR: No unitIdentifier"); 5 | 6 | 7 | const createCollage = require("../index"); 8 | const fs = require("fs"); 9 | 10 | const identifier = args[0]; 11 | let options = fs.readFileSync("/tmp/" + identifier + ".json").toString(); 12 | options = JSON.parse(options); 13 | 14 | createCollage(options).then((canvas) => { 15 | const src = canvas.jpegStream(); 16 | const dest = fs.createWriteStream(options.destination); 17 | src.pipe(dest); 18 | }); 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "photo-collage", 3 | "version": "1.1.0", 4 | "description": "Turns an array of images into a photo collage", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha test.js --timeout 10000" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+ssh://git@github.com/classdojo/photo-collage.git" 12 | }, 13 | "keywords": [ 14 | "photo", 15 | "image", 16 | "mosaic", 17 | "collage", 18 | "canvas", 19 | "tile" 20 | ], 21 | "author": "Peter Hayes", 22 | "license": "ISC", 23 | "bugs": { 24 | "url": "https://github.com/classdojo/photo-collage/issues" 25 | }, 26 | "homepage": "https://github.com/classdojo/photo-collage#readme", 27 | "dependencies": { 28 | "bluebird": "^3.3.5", 29 | "canvas": "^1.3.15", 30 | "request": "^2.72.0" 31 | }, 32 | "devDependencies": { 33 | "buffer-equal": "^1.0.0", 34 | "chai": "^3.5.0", 35 | "chai-as-promised": "^5.3.0", 36 | "mocha": "^2.4.5" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 ClassDojo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const fs = require("fs"); 4 | const path = require("path"); 5 | const bufferEqual = require("buffer-equal"); 6 | const createCollage = require("./index"); 7 | 8 | require("chai") 9 | .use(require("chai-as-promised")) 10 | .should(); 11 | 12 | // Test a variety of source types - file name, buffer, uri. 13 | const sources = [ 14 | "./img/src1.jpg", 15 | "img/src2.jpg", 16 | path.join(__dirname, "img/src3.jpg"), 17 | fs.readFileSync("img/src4.jpg"), 18 | "http://github.com/classdojo/photo-collage/blob/master/img/src5.jpg?raw=true", 19 | "https://github.com/classdojo/photo-collage/blob/master/img/src6.jpg?raw=true", 20 | ]; 21 | 22 | it("2x3 collage with no spacing matches reference", () => { 23 | const options = { 24 | sources: sources, 25 | width: 3, 26 | height: 2, 27 | imageWidth: 350, 28 | imageHeight: 250, 29 | }; 30 | 31 | return createCollage(options) 32 | .then((canvas) => canvas.toBuffer()) 33 | .then((buffer) => bufferEqual(buffer, fs.readFileSync("./img/result_no_spacing.png"))) 34 | .should.eventually.equal(true); 35 | }); 36 | 37 | it("2x3 collage with spacing matches reference", () => { 38 | const options = { 39 | sources: sources, 40 | width: 3, 41 | height: 2, 42 | imageWidth: 350, 43 | imageHeight: 250, 44 | backgroundColor: "#f00", 45 | spacing: 2, 46 | }; 47 | 48 | return createCollage(options) 49 | .then((canvas) => canvas.toBuffer()) 50 | .then((buffer) => bufferEqual(buffer, fs.readFileSync("./img/result_with_spacing.png"))) 51 | .should.eventually.equal(true); 52 | }); -------------------------------------------------------------------------------- /examples/collage-with-text.js: -------------------------------------------------------------------------------- 1 | const createCollage = require("../index"); 2 | const fs = require("fs"); 3 | 4 | const options = { 5 | sources: [ 6 | "./img/src1.jpg", // source can be a relative file path 7 | "./img/src2.jpg", // source can be a relative file path 8 | "./img/src3.jpg", // source can be a relative file path 9 | "./img/src4.jpg", // source can be a relative file path 10 | "./img/src5.jpg", // source can be a relative file path 11 | ], 12 | width: 3, // number of images per row 13 | height: 2, // number of images per column 14 | imageWidth: 350, // width of each image 15 | imageHeight: 250, // height of each image 16 | backgroundColor: "#cccccc", // optional, defaults to #eeeeee. 17 | spacing: 2, // optional: pixels between each image 18 | lines: [ 19 | {font: "", color: "", text: "Sometimes we want to find out when a single one time event has"}, 20 | {font: "", color: "", text: "Sometimes we want to find out when a single one time event has"}, 21 | {font: "", color: "", text: "Sometimes we want to find out when a single one time event has"}, 22 | {font: "", color: "", text: "Sometimes we want to find out when a single one time event has"}, 23 | {font: "", color: "", text: "Sometimes we want to find out when a single one time event has"}, 24 | ], 25 | //text: "Sometimes we want to find out when a single one time event has finished. For example - a stream is done. For this we can use new Promise. Note that this option should be considered only if automatic conversion isn't possible.Note that promises model a single value through time, they only resolve once - so while they're a good fit for a single event, they are not recommended for multiple event APIs." 26 | //textStyle: {color: "#fff", fontSize: 20, font: "Arial", height: 300} 27 | // we can use either lines or text 28 | }; 29 | 30 | createCollage(options) 31 | .then((canvas) => { 32 | const src = canvas.jpegStream(); 33 | const dest = fs.createWriteStream("myFile"); 34 | src.pipe(dest); 35 | }); 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # photo-collage 2 | Combines several images into a photo collage. 3 | 4 | ## Example 5 | 6 | #### Source files 7 | ![Source file 1](https://github.com/classdojo/photo-collage/blob/master/img/src1.jpg?raw=true) 8 | ![Source file 2](https://github.com/classdojo/photo-collage/blob/master/img/src2.jpg?raw=true) 9 | ![Source file 3](https://github.com/classdojo/photo-collage/blob/master/img/src3.jpg?raw=true) 10 | ![Source file 4](https://github.com/classdojo/photo-collage/blob/master/img/src4.jpg?raw=true) 11 | ![Source file 5](https://github.com/classdojo/photo-collage/blob/master/img/src5.jpg?raw=true) 12 | ![Source file 6](https://github.com/classdojo/photo-collage/blob/master/img/src6.jpg?raw=true) 13 | 14 | #### Result 15 | ![Result](https://github.com/classdojo/photo-collage/blob/master/img/result_no_spacing.png?raw=true) 16 | 17 | ## Installation 18 | `npm install --save photo-collage` 19 | 20 | 21 | This library depends on `node-canvas`, which may require additional setup. See [their installation page](https://github.com/Automattic/node-canvas/wiki/_pages) for details. 22 | 23 | ## Usage 24 | The following example creates a 2x3 collage from a variety of image sources. 25 | ```js 26 | const createCollage = require("photo-collage"); 27 | 28 | const options = { 29 | sources: [ 30 | imageBufferObject, // source can be a buffer of jpg/png data 31 | canvasObject, // source can be a canvas object 32 | "http://myurl.com/image.jpg", // source can be a url string 33 | "https://myurl.com/image.jpg", // https/ftp is ok too. 34 | "./localfile.png", // source can be a relative file path 35 | "~/photos/file.png" // source can be an absolute file path 36 | ], 37 | width: 3, // number of images per row 38 | height: 2, // number of images per column 39 | imageWidth: 350, // width of each image 40 | imageHeight: 250, // height of each image 41 | // backgroundColor: "#cccccc", // optional, defaults to #eeeeee. 42 | spacing: 2, // optional: pixels between each image 43 | lines: [ 44 | {font: "", color: "", text: "Sometimes we want to find out when a single one time event has"}, 45 | {font: "", color: "", text: "Sometimes we want to find out when a single one time event has"}, 46 | {font: "", color: "", text: "Sometimes we want to find out when a single one time event has"}, 47 | {font: "", color: "", text: "Sometimes we want to find out when a single one time event has"}, 48 | {font: "", color: "", text: "Sometimes we want to find out when a single one time event has"}, 49 | ], 50 | //text: "Sometimes we want to find out when a single one time event has finished. For example - a stream is done. For this we can use new Promise. Note that this option should be considered only if automatic conversion isn't possible.Note that promises model a single value through time, they only resolve once - so while they're a good fit for a single event, they are not recommended for multiple event APIs." 51 | //textStyle: {color: "#fff", fontSize: 20, font: "Arial", height: 300} 52 | // we can use either lines or text (text will be warped) 53 | }; 54 | 55 | createCollage(options) 56 | .then((canvas) => { 57 | const src = canvas.jpegStream(); 58 | const dest = fs.createWriteStream("myFile"); 59 | src.pipe(dest); 60 | }); 61 | ``` 62 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Promise = require("bluebird"); 4 | const request = require("request"); 5 | const Canvas = require("canvas"); 6 | const fs = Promise.promisifyAll(require("fs")); 7 | 8 | function downloadPhoto (uri) { 9 | return new Promise((resolve, reject) => { 10 | let data; 11 | 12 | const stream = request(uri); 13 | stream.on("data", (chunk) => data = data ? Buffer.concat([data, chunk]) : chunk); 14 | stream.on("error", reject); 15 | stream.on("end", () => resolve(data)); 16 | }); 17 | } 18 | 19 | function getPhoto (src) { 20 | if (src instanceof Buffer) { 21 | return src; 22 | } else if (typeof src === "string") { 23 | if (/^http/.test(src) || /^ftp/.test(src)) { 24 | return downloadPhoto(src) 25 | .catch(() => {throw new Error(`Could not download url source: ${src}`);}); 26 | } else { 27 | return fs.readFileAsync(src) 28 | .catch(() => {throw new Error(`Could not load file source: ${src}`);}); 29 | } 30 | } else if (src instanceof Canvas) { 31 | return src.toBuffer(); 32 | } else { 33 | throw new Error(`Unsupported source type: ${src}`); 34 | } 35 | } 36 | 37 | function wrapText(context, text, x, y, maxWidth, lineHeight) { 38 | var words = text.split(' '); 39 | var line = ''; 40 | let initialY = y; 41 | 42 | for(var n = 0; n < words.length; n++) { 43 | var testLine = line + words[n] + ' '; 44 | var metrics = context.measureText(testLine); 45 | var testWidth = metrics.width; 46 | if (testWidth > maxWidth && n > 0) { 47 | context.fillText(line, x, y); 48 | line = words[n] + ' '; 49 | y += lineHeight; 50 | } 51 | else { 52 | line = testLine; 53 | } 54 | } 55 | context.fillText(line, x, y); 56 | return y - initialY + lineHeight; // height used 57 | } 58 | 59 | const PARAMS = [ 60 | {field: "sources", required: true}, 61 | {field: "width", required: true}, 62 | {field: "height", required: true}, 63 | {field: "imageWidth", required: true}, 64 | {field: "imageHeight", required: true}, 65 | {field: "spacing", default: 0}, 66 | {field: "backgroundColor", default: "#eeeeee"}, 67 | {field: "lines", default: []}, 68 | {field: "textStyle", default: {}}, 69 | ]; 70 | 71 | module.exports = function (options) { 72 | if (Array.isArray(options)) { 73 | options = {sources: options}; 74 | } 75 | 76 | PARAMS.forEach((param) => { 77 | if (options[param.field]) { 78 | return; 79 | } else if (param.default != null) { 80 | options[param.field] = param.default; 81 | } else if (param.required) { 82 | throw new Error(`Missing required option: ${param.field}`); 83 | } 84 | }); 85 | 86 | const headerHeight = (options.header || {}).height || 0; 87 | const canvasWidth = options.width * options.imageWidth + (options.width - 1) * (options.spacing); 88 | const canvasHeight = headerHeight + options.height * options.imageHeight + (options.height - 1) * (options.spacing) + (options.textStyle.height || 200); 89 | const canvas = new Canvas(canvasWidth, canvasHeight); 90 | 91 | const ctx = canvas.getContext("2d"); 92 | ctx.fillStyle = options.backgroundColor; 93 | ctx.fillRect(0, 0, canvasWidth, canvasHeight); 94 | const sources = options.sources; 95 | let maxImages = options.width * options.height; 96 | if ((options.header || {}).image) { 97 | maxImages += 1; 98 | sources.unshift(options.header.image); 99 | } 100 | 101 | return Promise 102 | .map(sources, getPhoto) 103 | .each((photoBuffer, i) => { 104 | if (i >= maxImages) return; 105 | 106 | const img = new Canvas.Image(); 107 | img.src = photoBuffer; 108 | 109 | if ((options.header || {}).image) { // only for header 110 | if (!i) { // first time 111 | ctx.drawImage(img, 0, 0, canvasWidth, options.header.height); 112 | return; 113 | } 114 | i -= 1; 115 | } 116 | 117 | const x = (i % options.width) * (options.imageWidth + options.spacing); 118 | const y = Math.floor(i / options.width) * (options.imageHeight + options.spacing); 119 | ctx.drawImage(img, x, y + headerHeight, options.imageWidth, options.imageHeight); 120 | }) 121 | .then(() => { 122 | if (options.text) { 123 | ctx.font = (options.textStyle.fontSize || "20") + "px " + (options.textStyle.font || "Helvetica"); 124 | wrapText(ctx, options.text, 10, canvasHeight - (options.textStyle.height || 200) + 50, canvasWidth - 10, (options.textStyle.fontSize || 20) * 1.2); 125 | } 126 | else { 127 | let curHeight = 150; 128 | options.lines.map((line) => { 129 | ctx.font = line.font || "20px Helvetica"; 130 | ctx.fillStyle = line.color || "#333333"; 131 | const heightUsed = wrapText(ctx, line.text, 10, canvasHeight - curHeight, canvasWidth - 10, (parseInt(line.font) || 20) * 1.2); 132 | curHeight -= heightUsed; 133 | }); 134 | } 135 | }) 136 | .return(canvas); 137 | }; 138 | --------------------------------------------------------------------------------