comicgen
Version:
Add comics to your website or app with an API. Choose a character, angle, emotion and pose. Embed on your page.
228 lines (217 loc) • 6.66 kB
JavaScript
/* eslint-env node */
const path = require("path");
const fontkit = require("fontkit");
const fontRoot = path.join(__dirname, "..", "fonts");
// Map Google Fonts to the main font file to use.
// Keep this in sync with speechbubbles/index.json
let fonts = {
"Architects Daughter": path.join(fontRoot, "Architects_Daughter-400-latin1.woff2"),
"News Cycle": path.join(fontRoot, "News_Cycle-400-latin-ext1.woff2"),
"Indie Flower": path.join(fontRoot, "Indie_Flower-400-latin1.woff2"),
"Amatic SC": path.join(fontRoot, "Amatic_SC-400-latin-ext4.woff2"),
Schoolbell: path.join(fontRoot, "Schoolbell-400-latin1.woff2"),
"Just Another Hand": path.join(fontRoot, "Just_Another_Hand-400-latin1.woff2"),
Neucha: path.join(fontRoot, "Neucha-400-latin2.woff2"),
Handlee: path.join(fontRoot, "Handlee-400-latin1.woff2"),
Roboto: path.join(fontRoot, "Roboto-400-latin7.woff2"),
Inconsolata: path.join(fontRoot, "Inconsolata-400-latin3.woff2"),
};
// text-anchor uses start/middle/end but users prefer align=left/center/right. Map these
let alignMap = {
left: "start",
center: "middle",
right: "end",
};
function width(word, font) {
let width = 0;
font.layout(word).glyphs.forEach((g) => (width += g._metrics.advanceWidth));
return width;
}
function wrap(sentence, maxWidth, font) {
let lines = [];
let line = [];
let words = sentence.split(/\s+/);
for (let start = 0, word = 0; word < words.length; ) {
let w = width(words[word] + " ", font);
if (start > 0 && start + w > maxWidth) {
start = 0;
lines.push(line.join(" "));
line = [];
} else {
line.push(words[word]);
start += w;
word += 1;
}
}
lines.push(line.join(" "));
return lines;
}
let dx = {
start: 0,
middle: 0.5,
end: 1,
};
let attr_map = {
text: {
x: "x",
y: "y",
"font-size": "font-size",
"font-family": "font-family",
"font-weight": "font-weight",
"font-fill": "fill",
},
path: {
x: "x",
y: "y",
width: "width",
height: "height",
fill: "fill",
stroke: "stroke",
},
};
function speechshape({ x = 0, y = 0, width, height, pointerx, pointery, fill = "#fff", stroke = "#000", rough = 0 }) {
// Convert to numbers
[x, y, width, height, pointerx, pointery, rough] = [+x, +y, +width, +height, +pointerx, +pointery, +rough];
// Create main speech shape
const paths = [
[x, y],
[x + width, y],
[x + width, y + height],
[x, y + height],
[x, y],
];
// Add pointer arc if present
const dx = pointerx - (x + width / 2);
const dy = pointery - (y + height / 2);
let insertion;
let p1, p3;
let p2 = [pointerx, pointery];
// TODO: Write logic manually, then generalize
if (Math.abs(dy) > Math.abs(dx)) {
// If pointer is on top
if (dy < 0) {
insertion = 1; // insert at top
// if pointer is on left
if (dx < 0) {
p1 = [x + (width / 6) * 1, y];
p3 = [x + (width / 6) * 2, y];
} else if (dx >= 0) {
p1 = [x + (width / 6) * 4, y];
p3 = [x + (width / 6) * 5, y];
}
} else if (dy > 0) {
insertion = 3; // insert at bottom
// if pointer is on left
if (dx < 0) {
p1 = [x + (width / 6) * 2, y + height];
p3 = [x + (width / 6) * 1, y + height];
} else if (dx >= 0) {
p1 = [x + (width / 6) * 5, y + height];
p3 = [x + (width / 6) * 4, y + height];
}
}
} else {
// If P2 is on left, insertion point is on left
if (dx < 0) {
insertion = 4; // insert at left
// if pointer is on top
if (dy < 0) {
p1 = [x, y + (height / 6) * 2];
p3 = [x, y + (height / 6) * 1];
} else if (dy >= 0) {
p1 = [x, y + (height / 6) * 5];
p3 = [x, y + (height / 6) * 4];
}
} else if (dx > 0) {
insertion = 2; // insert at right
// if pointer is on top
if (dy < 0) {
p1 = [x + width, y + (height / 6) * 1];
p3 = [x + width, y + (height / 6) * 2];
} else if (dy >= 0) {
p1 = [x + width, y + (height / 6) * 4];
p3 = [x + width, y + (height / 6) * 5];
}
}
}
if (dx || dy) paths.splice(insertion, 0, p1, p2, p3);
// Jitter the points with radius "r" to convert them into rough lines
function jitter(point) {
return `${point[0] + Math.round((Math.random() - 0.5) * 2 * rough)},${
point[1] + Math.round((Math.random() - 0.5) * 2 * rough)
}`;
}
let pathstr = [];
paths.forEach((p, i) =>
pathstr.push(
i > 0 ? `M${jitter(paths[i - 1])} L${jitter(p)} M${jitter(paths[i - 1])} L${jitter(p)}` : `M${jitter(p)}`,
),
);
return `<path d="${pathstr.join(" ")}" fill="${fill}" stroke="${stroke}"></path>`;
}
function speechbubble(options) {
/*
options = {
text: 'Hello'
x: 30,
y: 40,
width: 300,
height: 400,
fill: 'white',
align: 'middle',
'font-fill': 'red',
'font-family': 'Consolas',
'font-size': 15,
'font-weight': 'bold',
'line-height': 1.3,
pointerx: 130, pointery: 50,
padding: 5,
rough: 2.5
}
*/
options = Object.assign(
{
x: 0,
y: 0,
width: 400,
height: 200,
padding: 5,
"font-family": Object.keys(fonts)[0],
"font-size": 16,
"line-height": 1.5,
align: "middle",
rough: 2.5,
},
options,
);
if (!fonts[options["font-family"]]) options["font-family"] = Object.keys(fonts)[0];
let font = fonts[options["font-family"]];
let fontSize = +options["font-size"];
let lineHeight = +options["font-size"] * +options["line-height"];
let padding = +options["padding"];
let width = options.width - 2 * padding;
let maxWidth = (width * font.unitsPerEm) / fontSize;
let align = alignMap[options.align] || options.align;
// Get the attributes of <text...> and <path...> into attrs.text and attrs.path
let attrs = {
text: [`text-anchor="${align}"`, `dx="${(dx[align] || 0) * width}"`],
path: [],
};
for (let type of Object.keys(attrs))
for (let [attr, target] of Object.entries(attr_map[type]))
if (options[attr]) attrs[type].push(`${target}="${options[attr]}"`);
// Render the text
let text = wrap(options.text, maxWidth, font)
.map(
(line, i) => `<text transform="translate(${padding},${padding})"
font-size="${fontSize}" dy="${(i + 1) * lineHeight}" ${attrs.text.join(" ")}>${line}</text>`,
)
.join("\n");
// Render the path
return speechshape(options) + text;
}
// Load downloaded fonts
for (let [fontName, fontPath] of Object.entries(fonts)) {
fonts[fontName] = fontkit.openSync(fontPath);
}
module.exports = speechbubble;