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;
|
||||
});
|