commit 583dcba4d20d586fb67ad1a4468b13ba9bb78e0e Author: sharpshark28 Date: Sun Nov 13 20:20:37 2016 -0600 First Commit, Functional and Ready for NPM diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1902d43 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# OSX Junk +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..f162bbc --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Blabber-Comic + +## Sample + +![Preview](/comic.png') + +## How it works + +Powered by Node and a node-canvas a comic can automatically be generated from a json array of users/text and some characters/backgrounds to be chosen at random. + +## How to use it + +### Generate base64Data + +```javascript +const blabbercomic = require('blabber-comic'); +let messages = [] // Array of messages... + +blabbercomic(messages).then(response => { + console.log('Generated comic as base64 data', response); +}).catch(error => { + throw error; +}); +``` + +### Save as file with `fs` + +Example included in project. Clone then run `npm run test`. + +```javascript +const blabbercomic = require('blabber-comic'); +const fs = require('fs'); +let messages = [] // Array of messages... + +blabbercomic(messages).then(response => { + let base64Data = response.replace(/^data:image\/png;base64,/, ''); + + fs.writeFile('./storage/comics/comic.png', base64Data, 'base64', error => { + if (error) console.error('Uhoh...', error); + else console.log('Saved file as `comic.png`'); + }); +}).catch(error => { + throw error; +}); +``` + +### Customizing characters and backgrounds + +```javascript +const blabbercomic = require('blabber-comic'); +let backgrounds = ['./assets/backgrounds/1.png', './assets/backgrounds/2.png']; +let characters = ['./assets/characters/1.png', './assets/characters/2.png', './assets/characters/3.png']; // Provide at least 3 +let comicSize = 400; // in px square + +let messages = [] // Array of messages... +let config = { backgrounds, characters, comicSize }; + +blabbercomic(messages, config); +``` + +--- + +## Special thanks to: + +* [node-canvas](https://github.com/Automattic/node-canvas) by Cairo +* avatars by Iulia Ardeleanu from the Noun Project +* backgrounds by Olga Libby from Subtle Patterns diff --git a/assets/backgrounds/1.png b/assets/backgrounds/1.png new file mode 100644 index 0000000..19dccca Binary files /dev/null and b/assets/backgrounds/1.png differ diff --git a/assets/backgrounds/2.png b/assets/backgrounds/2.png new file mode 100644 index 0000000..7aae797 Binary files /dev/null and b/assets/backgrounds/2.png differ diff --git a/assets/backgrounds/3.png b/assets/backgrounds/3.png new file mode 100644 index 0000000..d242da2 Binary files /dev/null and b/assets/backgrounds/3.png differ diff --git a/assets/characters/1.png b/assets/characters/1.png new file mode 100644 index 0000000..e72fa23 Binary files /dev/null and b/assets/characters/1.png differ diff --git a/assets/characters/10.png b/assets/characters/10.png new file mode 100644 index 0000000..67a6813 Binary files /dev/null and b/assets/characters/10.png differ diff --git a/assets/characters/2.png b/assets/characters/2.png new file mode 100644 index 0000000..6cbe0eb Binary files /dev/null and b/assets/characters/2.png differ diff --git a/assets/characters/3.png b/assets/characters/3.png new file mode 100644 index 0000000..e62dbb6 Binary files /dev/null and b/assets/characters/3.png differ diff --git a/assets/characters/4.png b/assets/characters/4.png new file mode 100644 index 0000000..b005ed8 Binary files /dev/null and b/assets/characters/4.png differ diff --git a/assets/characters/5.png b/assets/characters/5.png new file mode 100644 index 0000000..7c46f7f Binary files /dev/null and b/assets/characters/5.png differ diff --git a/assets/characters/6.png b/assets/characters/6.png new file mode 100644 index 0000000..8596457 Binary files /dev/null and b/assets/characters/6.png differ diff --git a/assets/characters/7.png b/assets/characters/7.png new file mode 100644 index 0000000..952282d Binary files /dev/null and b/assets/characters/7.png differ diff --git a/assets/characters/8.png b/assets/characters/8.png new file mode 100644 index 0000000..588b82a Binary files /dev/null and b/assets/characters/8.png differ diff --git a/assets/characters/9.png b/assets/characters/9.png new file mode 100644 index 0000000..9de445f Binary files /dev/null and b/assets/characters/9.png differ diff --git a/comic.png b/comic.png new file mode 100644 index 0000000..f56a11b Binary files /dev/null and b/comic.png differ diff --git a/helpers/canvas.js b/helpers/canvas.js new file mode 100644 index 0000000..486e884 --- /dev/null +++ b/helpers/canvas.js @@ -0,0 +1,108 @@ +function fillTextWrapped(ctx, text, x, y, maxWidth, lineHeight, borderWidth) { + // Borrowed with love from http://stackoverflow.com/questions/2936112/text-wrap-in-a-canvas-element + let words = text.split(' '); + let line = ''; + let bubblePadding = 10; + let bubbleBorder = 1; + + maxWidth -= bubblePadding * 2; + + // White BG + ctx.fillStyle = 'white'; + ctx.fillRect( + x, + y, + maxWidth + (bubblePadding * 2), + lineHeight + ); + // Top border + ctx.fillStyle = 'black'; + ctx.fillRect( + x, + y, + maxWidth + (bubblePadding * 2), + bubbleBorder + ); + + for(var n = 0; n < words.length; n++) { + var testLine = line + words[n] + ' '; + var metrics = ctx.measureText(testLine); + var testWidth = metrics.width; + if (testWidth > maxWidth && n > 0) { + // White BG + ctx.fillStyle = 'white'; + ctx.fillRect( + x, + y + bubblePadding, + maxWidth + (bubblePadding * 2), + lineHeight + bubblePadding + ); + // Side borders + ctx.fillStyle = 'black'; + ctx.fillRect( // Left + x, + y, + bubbleBorder, + lineHeight + ); + ctx.fillRect( // Right + x + maxWidth + (bubblePadding * 2) - bubbleBorder, + y, + bubbleBorder, + lineHeight + (bubblePadding * 2) + ); + // Text + ctx.fillText( + line, + x + bubblePadding, + y + bubblePadding + ); + line = words[n] + ' '; + y += lineHeight; + } + else { + line = testLine; + } + } + // White BG + ctx.fillStyle = 'white'; + ctx.fillRect( + x, + y + bubblePadding, + maxWidth + (bubblePadding * 2), + lineHeight + bubblePadding + ); + // Side and Bottom Borders + ctx.fillStyle = 'black'; + ctx.fillRect( // Bottom + x, + y + lineHeight *2, + maxWidth + (bubblePadding * 2), + bubbleBorder + ); + ctx.fillRect( // Right + x, + y, + bubbleBorder, + lineHeight + (bubblePadding * 2) + ); + ctx.fillRect( // Right + x + maxWidth + (bubblePadding * 2) - bubbleBorder, + y, + bubbleBorder, + lineHeight + (bubblePadding * 2) + ); + + // Text + ctx.fillText( + line, + x + bubblePadding, + y + bubblePadding + ); + + return y; // Useful for knowing how far down text rendered +} + +module.exports = { + fillTextWrapped +}; diff --git a/helpers/files.js b/helpers/files.js new file mode 100644 index 0000000..8e89956 --- /dev/null +++ b/helpers/files.js @@ -0,0 +1,26 @@ +const fs = require('fs'); +const Canvas = require('canvas-prebuilt'); + +function isImage(fileString) { + let ext = fileString.slice(fileString.indexOf('.')); + return ['.png', '.jpg'].indexOf(ext) > -1; +} + +function readImageFromPath(path) { + return new Promise(resolve => { + fs.readFile(path, (err, loadedImage) => { + if (err) throw err; + let img = new Canvas.Image; + img.src = loadedImage; + + resolve(img); + }); + }).catch(error => { + throw error; + }); +} + +module.exports = { + isImage, + readImageFromPath +}; diff --git a/helpers/parse.js b/helpers/parse.js new file mode 100644 index 0000000..e75bb17 --- /dev/null +++ b/helpers/parse.js @@ -0,0 +1,39 @@ +const _ = require('lodash'); + +/** + * Parsing Linear Messages to a comic friendly format + * Expects: Array of messages, containing the text and the user. + * Returns: Array of panels, containing messages, containing the text and the user. + */ +function parseMessages(messages) { + let structuredMessages = [[]]; + let usersInCurrentPanel = []; + + messages.forEach(message => { + let isUserInCurrentPanel = usersInCurrentPanel.indexOf(message.user) > -1; + + if (isUserInCurrentPanel) { + structuredMessages.push([message]); // New panel + usersInCurrentPanel = [message.user]; // Reset + } else { + structuredMessages[structuredMessages.length - 1] + .push(message); // Add to current panel + usersInCurrentPanel.push(message.user); // Save user in current panel + } + }); + return structuredMessages; +} + +function parseCharacters(messages, config) { + let users = messages.map(message => message.user); + let uniqueUsers = _.uniq(users); + let characterImages = _.sampleSize(config.assetCharacters, uniqueUsers.length); + return uniqueUsers.map((user, index) => { + return {user, background: characterImages[index]}; + }); +} + +module.exports = { + parseMessages, + parseCharacters +}; diff --git a/index.js b/index.js new file mode 100644 index 0000000..14114d6 --- /dev/null +++ b/index.js @@ -0,0 +1,96 @@ +const fs = require('fs'); +const _ = require('lodash'); +const Canvas = require('canvas-prebuilt'); + +const { parseMessages, parseCharacters } = require('./helpers/parse.js'); +const { fillTextWrapped } = require('./helpers/canvas.js'); +const { isImage, readImageFromPath } = require('./helpers/files.js'); + +const maxCharacters = 3; +const assetBackgrounds = fs.readdirSync(__dirname + '/assets/backgrounds') + .filter(isImage) + .map(path => __dirname + '/assets/backgrounds/' + path); +const assetCharacters = fs.readdirSync(__dirname + '/assets/characters') + .filter(isImage) + .map(path => __dirname + '/assets/characters/' + path); +const defaultConfig = { + assetBackgrounds, + assetCharacters, + font: 'Impact', + textColor: '#000000', + borderColor: '#CCCCCC', + borderWidth: 20, // px + comicPaneSize: 500 // px square +}; + +const generate = function(messages, config = {}) { + if (!messages) { + console.error('Please supply messages...'); + } + + config = _.assign(defaultConfig, config); + let comicPaneSize = config.comicPaneSize; + + let panels = parseMessages(messages); + let characters = parseCharacters(messages, config); + + let imageBackground = readImageFromPath(_.sample(config.assetBackgrounds)); + let imagesCharacters = characters.map(character => readImageFromPath(character.background)); + let imagesToLoad = [imageBackground].concat(imagesCharacters); + + let canvas = new Canvas(panels.length * comicPaneSize + (config.borderWidth * (panels.length + 1)), comicPaneSize + (config.borderWidth * 2)); + let ctx = canvas.getContext('2d'); + + return new Promise((resolve, reject) => { + return Promise.all(imagesToLoad).then(loadedImages => { + let loadedBackgroundImage = loadedImages[0]; + let loadedCharacterImages = loadedImages.slice(1); + + let characterSize = 150; // px square + let fontsize = 20; // px + + ctx.font = fontsize + 'px ' + config.font; + ctx.textBaseline = 'top'; + + let paneLeftOffset = config.borderWidth; + panels.forEach((panel, panelIndex) => { + ctx.drawImage(loadedBackgroundImage, paneLeftOffset, config.borderWidth); + ctx.fillStyle = config.borderColor; + ctx.fillRect(paneLeftOffset - config.borderWidth, 0, config.borderWidth, canvas.height); + + characters.forEach((character, index) => { + let alignToBottom = comicPaneSize - characterSize + config.borderWidth; + let characterLeftOffset = paneLeftOffset + ((comicPaneSize / maxCharacters) * index); + + ctx.drawImage(loadedCharacterImages[index], characterLeftOffset, alignToBottom); + ctx.fillStyle = config.textColor; + fillTextWrapped(ctx, character.user, characterLeftOffset, alignToBottom - (fontsize * 2), comicPaneSize / maxCharacters, fontsize); + }); + + let previousMessageHeight = 0; + panel.forEach((message, messageIndex) => { + let characterIndex = _.findIndex(characters, {user: message.user}); + let top = (previousMessageHeight + (fontsize * 1.5) * messageIndex) + config.borderWidth; + let left = paneLeftOffset + ((comicPaneSize / maxCharacters) * characterIndex); + + ctx.fillStyle = config.textColor; + previousMessageHeight = fillTextWrapped(ctx, message.text, left, top, comicPaneSize / maxCharacters, fontsize); + }); + + paneLeftOffset += comicPaneSize + config.borderWidth; + }); + + // Comic Borders + ctx.fillStyle = config.borderColor; + ctx.fillRect(0, 0, canvas.width, config.borderWidth); + ctx.fillRect(0, canvas.height - config.borderWidth, canvas.width, config.borderWidth); + ctx.fillRect(canvas.width - config.borderWidth, 0, config.borderWidth, canvas.height); + + resolve(canvas.toDataURL()); + }); + }).catch(error => { + throw error; + }); +} + +module.exports = generate; diff --git a/package.json b/package.json new file mode 100644 index 0000000..fc7e324 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "blabber-comic", + "version": "1.0.0", + "description": "Converts chat logs to comics.", + "main": "index.js", + "scripts": { + "test": "node test.js" + }, + "keywords": [ + "comic", + "generate", + "node", + "canvas", + "chat" + ], + "author": "Joe Wroten (http://joewroten.com/)", + "license": "ISC", + "dependencies": { + "canvas-prebuilt": "^1.6.0", + "fs": "0.0.1-security", + "lodash": "^4.16.6" + } +} diff --git a/test.js b/test.js new file mode 100644 index 0000000..bb34be9 --- /dev/null +++ b/test.js @@ -0,0 +1,38 @@ +const fs = require('fs'); +const generate = require('./index.js'); + +const exampleMessages = [ + { + user: 'Jade', + text: 'So, two people walk into a bar' + }, { + user: 'Sam', + text: 'No, don\'t', + }, { + user: 'Jade', + text: 'The bartender says, why the long face?' + }, { + user: 'Sam', + text: '...', + }, { + user: 'Kit', + text: 'I can tell this is gonna get worse' + }, { + user: 'Jade', + text: 'To get to the other side!' + }, { + user: 'Kit', + text: '...WHY DO YOU DO THIS?', + }, +]; + +generate(exampleMessages).then(response => { + let base64Data = response.replace(/^data:image\/png;base64,/, ''); + + fs.writeFile('./comic.png', base64Data, 'base64', error => { + if (error) console.error('Uhoh...', error); + else console.log('Saved file as `comic.png`'); + }); +}).catch(error => { + throw error; +});