First Commit, Functional and Ready for NPM
50
.gitignore
vendored
Normal file
|
@ -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
|
67
README.md
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
# Blabber-Comic
|
||||||
|
|
||||||
|
## Sample
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
|
BIN
assets/backgrounds/1.png
Normal file
After Width: | Height: | Size: 36 KiB |
BIN
assets/backgrounds/2.png
Normal file
After Width: | Height: | Size: 61 KiB |
BIN
assets/backgrounds/3.png
Normal file
After Width: | Height: | Size: 80 KiB |
BIN
assets/characters/1.png
Normal file
After Width: | Height: | Size: 7.8 KiB |
BIN
assets/characters/10.png
Normal file
After Width: | Height: | Size: 7.3 KiB |
BIN
assets/characters/2.png
Normal file
After Width: | Height: | Size: 8.5 KiB |
BIN
assets/characters/3.png
Normal file
After Width: | Height: | Size: 7.5 KiB |
BIN
assets/characters/4.png
Normal file
After Width: | Height: | Size: 7.6 KiB |
BIN
assets/characters/5.png
Normal file
After Width: | Height: | Size: 8.4 KiB |
BIN
assets/characters/6.png
Normal file
After Width: | Height: | Size: 6.5 KiB |
BIN
assets/characters/7.png
Normal file
After Width: | Height: | Size: 9.6 KiB |
BIN
assets/characters/8.png
Normal file
After Width: | Height: | Size: 7.7 KiB |
BIN
assets/characters/9.png
Normal file
After Width: | Height: | Size: 7.3 KiB |
BIN
comic.png
Normal file
After Width: | Height: | Size: 154 KiB |
108
helpers/canvas.js
Normal file
|
@ -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
|
||||||
|
};
|
26
helpers/files.js
Normal file
|
@ -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
|
||||||
|
};
|
39
helpers/parse.js
Normal file
|
@ -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
|
||||||
|
};
|
96
index.js
Normal file
|
@ -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;
|
23
package.json
Normal file
|
@ -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 <sharpshark28@gmail.com> (http://joewroten.com/)",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"canvas-prebuilt": "^1.6.0",
|
||||||
|
"fs": "0.0.1-security",
|
||||||
|
"lodash": "^4.16.6"
|
||||||
|
}
|
||||||
|
}
|
38
test.js
Normal file
|
@ -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;
|
||||||
|
});
|