/*
* This file is part of the Companion project
* Copyright (c) 2018 Bitfocus AS
* Authors: William Viker <william@bitfocus.io>, Håkon Nessjøen <haakon@bitfocus.io>
*
* This program is free software.
* You should have received a copy of the MIT licence as well as the Bitfocus
* Individual Contributor License Agreement for companion along with
* this program.
*
* You can be released from the requirements of the license by purchasing
* a commercial license. Buying such a license is mandatory as soon as you
* develop commercial activities involving the Companion software without
* disclosing the source code of your own applications.
*
*/
// Super primitive drawing library!
var debug = require('debug')('lib/Graphics/Image');
var fs = require('fs');
var PNG = require('pngjs').PNG;
var font = require('./Font')();
class Image {
constructor(width, height) {
/* Defaults for custom images from modules */
if (width === undefined) {
width = 72;
}
if (height === undefined) {
height = 58;
}
this.lastUpdate = Date.now();
this.width = width;
this.height = height;
this.canvas = [];
this.lastBackgroundColor = Image.rgb(0,0,0);
for (var y = 0; y < this.height; y++) {
var buf = Buffer.alloc(this.width * 3); // * 3 for RGB.
this.canvas.push(buf);
}
}
//-----------
// Static methods are for backwards compatibility ... needs to be revisited
//-----------
static rgb(r, g, b) {
return (
((r & 0xff) << 16) |
((g & 0xff) << 8) |
(b & 0xff)
);
}
static rgbRev(dec) {
dec = Math.round(dec);
return {
r: (dec & 0xff0000) >> 16,
g: (dec & 0x00ff00) >> 8,
b: (dec & 0x0000ff)
};
}
static argb(a, r, g, b) {
return (
a * 0x1000000 + rgb(r,g,b) // bitwise doesn't work because JS bitwise is working with 32bit signed int
);
}
//-----------
backgroundColor(backgroundColor) {
this.lastBackgroundColor = backgroundColor;
for (var y = 0; y < this.height; y++) {
for (var x = 0; x < this.width; x++) {
this.pixel(x, y, backgroundColor);
}
}
return true;
}
boxFilled(x1, y1, x2, y2, color) {
for (var y = y1; y <= y2; y++) {
for (var x = x1; x <= x2; x++) {
this.pixel(x, y, color);
}
}
return true;
}
boxLine(x1, y1, x2, y2, color) {
for (var y = y1; y <= y2; y++) {
var line = this.canvas[y];
// draw horizontal lines
if (y == y1 || y == y2) {
for (var x = x1; x <= x2; x++) {
this.pixel(x, y, color);
}
}
// draw vertical lines
if (y > y1 || y < y2) {
this.pixel(x1, y, color);
this.pixel(x2, y, color);
}
}
return true;
}
buffer() {
return Buffer.concat(this.canvas);
}
bufferAndTime() {
return { updated: this.lastUpdate, buffer: this.buffer() };
}
/*
Draws aligned text in an boxed area.
int x: bounding box top left horizontal value
int y: bounding box top left vertical value
int w: bounding box width
int h: bounding box height
string text: the text to drawBank
rgb-array color: color of the text
string fontindex: index of font, either 'icon' or something else
int spacing: how much space should be between letters, leave undefined for spacing of font
int size: font size multiplier
string halign: horizontal alignment left, center, right
string valign: vertical alignment top, center, bottom
bool dummy: don't actually draw anything if true, just return if the text fits
returns true if text fits
*/
drawAlignedText(x=0, y=0, w=72, h=72, text, color, fontindex='', spacing, size=1, halign='center', valign='center', dummy=false) {
var textFits = true;
var double;
var lineheight;
var linespacing;
var charspacing;
if (size === 2) {
double = true;
}
else {
double = false;
}
if (fontindex === 'auto') {
fontindex = 0;
for (let checksize of [44, 30, 24, 18, 14]) {
if (this.drawAlignedText(x, y, w, h, text, color, checksize, spacing, size, halign, valign, true) === true) {
fontindex = checksize;
break;
}
}
}
lineheight = font[fontindex].lineheight;
linespacing = font[fontindex].linespacing;
if (spacing !== undefined) {
charspacing = spacing;
}
else {
charspacing = font[fontindex].charspacing;
}
var displayText = text.trim(); // remove leading and trailing spaces for display
var xSize = this.drawTextLine(0, 0, displayText, color, fontindex, charspacing, double, true);
// breakup text in pieces
var breakPos = [0];
var lastBreakPos = 0;
var lineWidth = 0;
for (var i = 0; i < displayText.length; i++) {
if (displayText.charCodeAt(i) === 32 || displayText.charCodeAt(i) === 45 || displayText.charCodeAt(i) === 95 || displayText.charCodeAt(i) === 58 || displayText.charCodeAt(i) === 126 ) {
lastBreakPos = i; // remember the latest position where break is possible
}
// Support \n as line breaker
if (displayText.substr(i, 2) === '\\n') {
lastBreakPos = i;
displayText = displayText.slice(0, lastBreakPos) + displayText.slice(lastBreakPos + 2);
i--;
breakPos.push(lastBreakPos);
lastBreakPos = 0;
}
if (this.drawTextLine(0, 0, displayText.slice(breakPos[breakPos.length - 1], i + 1), color, fontindex, charspacing, double, true) > w) {
if (lastBreakPos > 0) { // if line is getting too long and there was a good wrap position, wrap it at that position
if (displayText.charCodeAt(lastBreakPos) === 32) { // if the break position was a space we want to get rid of it
displayText = displayText.slice(0, lastBreakPos) + displayText.slice(lastBreakPos + 1);
i--;
}
else {
if ((i - lastBreakPos) > 0) { // if we can afford we want to have the breaking character in the top line, otherwise it gets wrapped (ugly, but better for space usage)
lastBreakPos += 1;
}
}
breakPos.push(lastBreakPos);
lastBreakPos = 0;
}
else {
breakPos.push(i); // if there has been no good break position, just wrap it anyway
lastBreakPos = 0;
}
}
}
breakPos.push(displayText.length);
var lines = breakPos.length -1;
if ((lines * lineheight * (double ? 2 : 1) + (lines-1) * linespacing * (double ? 2 : 1)) > h) {
lines = parseInt((h + linespacing * (double ? 2 : 1)) / ((lineheight + linespacing) * (double ? 2 : 1)) );
textFits = false;
}
if (lines === 0) {
return true;
}
if (dummy !== true) {
for (var line = 1; line <= lines; line++) {
xSize = this.drawTextLine(0, 0, displayText.slice(breakPos[line - 1], breakPos[line]), color, fontindex, charspacing, double, true);
var xStart, yStart;
switch (halign) {
case 'left':
xStart = x;
break;
case 'center':
xStart = x + parseInt((w - xSize) / 2);
break;
case 'right':
xStart = x + w - xSize;
break;
}
switch (valign) {
case 'top':
yStart = y + (line - 1) * (lineheight + linespacing) * (double ? 2 : 1);
break;
case 'center':
yStart = y + parseInt((h - (lines * lineheight * (double ? 2 : 1) + (lines - 1) * linespacing * (double ? 2 : 1))) / 2) + (line - 1) * (lineheight + linespacing) * (double ? 2 : 1);
break;
case 'bottom':
yStart = y + h - (lines - line + 1) * (lineheight + linespacing) * (double ? 2 : 1);
break;
}
var linetext = displayText.slice(breakPos[line - 1], breakPos[line]);
this.drawTextLine(xStart, yStart, linetext, color, fontindex, charspacing, double);
}
}
return textFits;
}
drawBorder(depth=0, color) {
if (depth > 0) {
if ( depth * 2 < this.width ) {
for (var x = 0; x < depth; x++) {
this.verticalLine(x, color);
}
for (var x = this.width - depth; x < this.width; x++) {
this.verticalLine(x, color);
}
}
else {
for (var x = 0; x < this.width; x++) {
this.verticalLine(x, color);
}
}
if ( depth * 2 < this.height ) {
for (var y = 0; y < depth; y++) {
this.horizontalLine(y, color);
}
for (var y = this.height - depth; y < this.height; y++) {
this.horizontalLine(y, color);
}
}
else {
for (var y = 0; y < this.height; y++) {
this.horizontalLine(y, color);
}
}
return true;
}
else {
return false;
}
}
drawCenterText(x,y,text,color,fontindex,spacing, double) {
/*
DEPRECATED
This Function doesn't work with UTF16 text.
Use drawAlignedText instead
*/
if (text == undefined || text == '') {
return 0;
}
var xCenter = x;
var maxWidth = x * 2; // maximum line width is only correct if text center position is left of image center
if (maxWidth > this.width) { // correction of line width if text center is right of image center
maxWidth = (this.width - x) * 2;
}
var displayText = text.trim(); // remove leading and trailing spaces for display
// do we have more then one line?
var xSize = this.drawText(0, 0, displayText, color, fontindex, spacing, double, true)
if (xSize > maxWidth) {
// breakup text in pieces
//const breakChars = '\s-~,';
var breakPos = [0];
var lastBreakPos = 0;
var lineWidth = 0;
for (var i = 0; i< displayText.length; i++) {
if (displayText.charCodeAt(i) == 32 || displayText.charCodeAt(i) == 45 || displayText.charCodeAt(i) == 95 || displayText.charCodeAt(i) == 58 || displayText.charCodeAt(i) == 126 ) {
lastBreakPos = i; // remember the latest position where break is possible
}
if (this.drawText(0, 0, displayText.substr(breakPos[breakPos.length - 1], i + 1), color, fontindex, spacing, double, true) > maxWidth) {
if (lastBreakPos > 0) { // if line is getting too long and there was a good wrap position, wrap it at that position
if (displayText.charCodeAt(lastBreakPos) == 32) { // if the break position was a space we want to get rid of it
displayText = displayText.slice(0, lastBreakPos) + displayText.slice(lastBreakPos + 1);
}
else {
if (i - lastBreakPos > 0) { // if we can afford we want to have the breaking charakter in the top line, otherwise it gets wrapped (ugly, but better for space usage)
lastBreakPos += 1;
}
}
breakPos.push(lastBreakPos);
}
else {
breakPos.push(i - 1); // if there has been no good break position, just wrap it anyway
lastBreakPos = 0;
}
}
}
breakPos.push(displayText.length);
for (var lines = 1; lines < breakPos.length; lines++) {
xSize = this.drawText(0, 0, displayText.substr(breakPos[lines - 1], breakPos[lines]), color, fontindex, spacing, double, true);
var xStart = parseInt(xCenter - xSize / 2 );
var yStart = y - parseInt(((breakPos.length - 1) * (double ? 14 : 7) + (breakPos.length - 2) * (double ? 4 : 2) ) / 2) + (lines - 1) * (double ? 18 : 9);
this.drawText(xStart, yStart, displayText.substr(breakPos[lines - 1], breakPos[lines]), color, fontindex, spacing, double);
}
}
else {
// just draw one line
var xStart = parseInt(xCenter - xSize / 2);
return this.drawText(xStart, y - (double ? 7 : 4), displayText, color, fontindex, spacing, double);
}
}
drawChar(x, y, char, color, fontindex, double, dummy) {
if (double === undefined) {
double = false;
}
if (char === undefined) {
return 0;
}
// dummy is for not drawing any actual pixels. just calculate the font size
if (dummy === undefined) {
dummy = false;
}
if (char == 32 || char == 160) { // return blanks for space
return 2;
}
if (font[fontindex] === undefined) {
return 0;
}
if ( char >= 0xD800 && char <= 0xDBFF ) { // most likely a lead surrogate of an UTF16 pair
return 0;
}
if (font[fontindex][char] === undefined) {
debug("trying to draw a character that doesnt exist in the font:", char, String.fromCharCode(parseInt(char)) );
return 0;
}
var gfx = font[fontindex][char];
var maxX = 0;
for (var pixel in gfx) {
if (double == true) {
if ((gfx[pixel][0] + 1) * 2 > maxX) {
maxX = (gfx[pixel][0] + 1) * 2;
}
if (dummy == false) {
for (var len = 0; len < gfx[pixel][2]; len++) {
this.pixel(x + (gfx[pixel][0] * 2), y + (gfx[pixel][1] * 2 + len * 2), color);
this.pixel(x + (gfx[pixel][0] * 2) + 1, y + (gfx[pixel][1] * 2 + len * 2), color);
this.pixel(x + (gfx[pixel][0] * 2), y + (gfx[pixel][1] * 2) + len * 2 + 1, color);
this.pixel(x + (gfx[pixel][0] * 2) + 1, y + (gfx[pixel][1] * 2) + len * 2 + 1, color);
}
}
}
else {
if (gfx[pixel][0] + 1 > maxX) {
maxX = gfx[pixel][0] + 1;
}
if (dummy == false) {
for (var len = 0; len < gfx[pixel][2]; len++) {
this.pixel(x + gfx[pixel][0], y + gfx[pixel][1] + len, color);
}
}
}
}
return maxX;
}
drawCornerTriangle(depth=0, color, halign='left', valign='top') {
if (depth > 0 && (halign == 'left' || halign == 'right') && (valign == 'top' || valign == 'bottom')) {
var maxY = (depth > this.height ? this.height : depth);
for (var y = 0; y < maxY; y++) {
var trueY = (valign == 'bottom' ? this.height - 1 - y : y);
for (var x = 0; (x < (depth - y) && x < this.width); x++) {
var trueX = (halign == 'right' ? this.width - 1 - x : x);
this.pixel(trueX, trueY, color);
}
}
return true;
}
else {
return false;
}
}
drawFromPNG(file, xStart, yStart) {
var data, png;
try {
data = fs.readFileSync(file);
png = this.drawFromPNGdata(data, xStart, yStart);
}
catch (e) {
debug("Error opening image file: " + file, e);
return;
}
}
drawFromPNGdata(data, xStart=0, yStart=0, width=72, height=58, halign='center', valign='center') {
var data, png;
var xouter, xinnner, youter, yinner, wouter, houter;
if (xStart + width > this.width) {
width = this.width - xStart;
}
if (yStart + height > this.height) {
height = this.height - yStart;
}
png = PNG.sync.read(data);
if (png.width > width) { //image is broader than drawing pane
switch (halign) {
case 'left':
xouter = 0;
xinner = 0;
wouter = width;
break;
case 'center':
xouter = 0;
xinner = Math.round((png.width - width) / 2, 0);
wouter = width;
break;
case 'right':
xouter = 0;
xinner = png.width - width;
wouter = width;
break;
}
}
else { // image is narrower than drawing pane
switch (halign) {
case 'left':
xouter = 0;
xinner = 0;
wouter = png.width;
break;
case 'center':
xouter = Math.round((width - png.width)/2 , 0);
xinner = 0;
wouter = png.width;
break;
case 'right':
xouter = width - png.width;
xinner = 0;
wouter = png.width;
break;
}
}
if (png.height > height) { // image is taller than drawing pane
switch (valign) {
case 'top':
youter = 0;
yinner = 0;
houter = height;
break;
case 'center':
youter = 0;
yinner = Math.round((png.height - height) / 2, 0);
houter = height;
break;
case 'bottom':
youter = 0;
yinner = png.height - height;
houter = height;
break;
}
}
else {
switch (valign) { // image is smaller than drawing pane
case 'top':
youter = 0;
yinner = 0;
houter = png.height;
break;
case 'center':
youter = Math.round((height - png.height) / 2, 0);
yinner = 0;
houter = png.height;
break;
case 'bottom':
youter = height - png.height;
yinner = 0;
houter = png.height;
break;
}
}
for (var y = 0; y < houter; y++) {
for (var x = 0; x < wouter; x++) {
var idx = (png.width * (y + yinner) + x + xinner) << 2;
var r = png.data[idx];
var g = png.data[idx + 1];
var b = png.data[idx + 2];
var a = png.data[idx + 3];
if (png.data[idx + 3] > 0) {
if (png.data[idx + 3] === 256) {
this.pixel(xStart + xouter + x, yStart + youter + y, Image.rgb(r, g, b));
}
else {
this.pixel(xStart + xouter + x, yStart + youter + y, Image.argb(a, r, g, b));
}
}
}
}
}
drawLetter(x, y, letter, color, fontindex, double, dummy) {
/*
DEPRECATED
This Function doesn't work with UTF16 chars
Use drawChar instead
*/
if (double === undefined) {
double = false;
}
if (letter === undefined || ((letter.length > 1 || letter.length == 0) && letter == 'icon')) {
return 0;
}
// dummy is for not drawing any actual pixels. just calculate the font size
if (dummy === undefined) {
dummy = false;
}
var num;
if (fontindex !== 'icon') {
num = letter.charCodeAt(0);
}
else {
num = letter;
}
return this.drawChar(x, y, num, color, fontindex, double, dummy);
}
/**
* drawPixeBuffer(x, y, width, height, buffer[, type])
*
* Buffer can be either a buffer, or base64 encoded string.
* Type can be set to either 'buffer' or 'base64' according to your input data.
* Width and height is information about your buffer, not scaling.
*
* The buffer data is expected to be RGB or ARGB data, 1 byte per color,
* horizontally. Top left is first three bytes.
*/
drawPixelBuffer(x, y, width, height, buffer, type) {
if (type === undefined && typeof buffer == 'object' && buffer instanceof Buffer) {
type = 'buffer';
}
else if (type === undefined && typeof buffer == 'string') {
type = 'base64';
}
if (type === 'base64') {
buffer = Buffer.from(buffer, 'base64');
}
if (buffer.length < width * height * 3) {
throw new Error('Pixelbuffer of ' + buffer.length + ' bytes is less than expected ' + (width * height * 3) + ' bytes');
return;
}
if (buffer.length == width * height * 4) { // ARGB
var counter = 0;
for (var y2 = 0; y2 < height; ++y2) {
for (var x2 = 0; x2 < width; ++x2) {
var color = buffer.readUInt32BE(counter);
this.pixel(x + x2, y + y2, color);
counter += 4;
}
}
}
else if (buffer.length == width * height * 3) { // RGB
var counter = 0;
for (var y2 = 0; y2 < height; ++y2) {
for (var x2 = 0; x2 < width; ++x2) {
var color = buffer.readUIntBE(counter, 3);
this.pixel(x + x2, y + y2, color);
counter += 3;
}
}
}
else {
throw new Error('Pixelbuffer for a ' + width + 'x' + height + ' image should be either ' + (width * height * 3) + ' or ' + (width * height * 4) + ' bytes big. Not ' + buffer.length);
}
}
drawText(x, y, text, color, fontindex, spacing, double, dummy) {
/*
DEPRECATED
This function has several problems
Use drawTextLine or drawAlignedText instead
*/
if (text === undefined || text.length == 0) {
return 0;
}
if (spacing === undefined) {
spacing = 2;
}
if (double === undefined) {
double = false;
}
var chars = text.split("");
var max_x = 0;
var x_pos = 0;
var y_pos = y;
var just_wrapped = true;
for (var i in chars) {
if ((x + x_pos > this.width - (double ? 16 : 8)) && dummy != true ) {
x_pos = 0;
y_pos += (double ? 18 : 9);
just_wrapped = true;
}
if (!(chars[i] == " " && just_wrapped == true)) {
x_pos += (double ? 4 : 2) + this.drawLetter(x + x_pos, y_pos, chars[i], color, fontindex, double, dummy);
}
just_wrapped = false;
if (x_pos > max_x) {
max_x = x_pos;
}
}
return max_x;
}
drawTextLine(x, y, text, color, fontindex, spacing, double, dummy) {
if (text === undefined || text.length == 0) {
return 0;
}
if (spacing === undefined) {
spacing = 2;
}
if (double === undefined) {
double = false;
}
var chars = text.split("");
var max_x = 0;
var x_pos = 0;
var y_pos = y;
for (var i = 0; i< chars.length; i++) {
var charcode = chars[i].charCodeAt(0);
if ( charcode >= 0xD800 && charcode <= 0xDBFF && chars.length-1 > i ) { // check if this is the start of a surrogate pair
var lead = charcode;
var tail = chars[i + 1].charCodeAt(0);
if (tail >= 0xDC00 && tail <= 0xDFFF) { // low surrogate
charcode = (lead - 0xD800) * 0x400 + tail - 0xDC00 + 0x10000;
i++;
}
}
var width = this.drawChar(x + x_pos, y_pos, charcode, color, fontindex, double, dummy);
x_pos += width ? width : 0;
if (i < chars.length - 1) {
x_pos += (width ? (double ? spacing * 2 : spacing) : 0);
}
if (x_pos > max_x) {
max_x = x_pos;
}
}
return max_x;
}
horizontalLine(y, color) {
for (var x = 0; x < this.width; x++) {
this.pixel(x, y, color);
}
return true;
}
pixel(x, y, color) {
if (x >= this.width) {
return;
}
if (y >= this.height) {
return;
}
var line = this.canvas[y];
if (color <= 0xffffff) {
line.writeUIntBE(color & 0xffffff, x * 3, 3);
}
else {
var alpha = Math.floor(color / 0x1000000) / 0xff;
var oldr = line.readUInt8(x*3);
var oldg = line.readUInt8(x*3+1);
var oldb = line.readUInt8(x*3+2);
var newr = (color >> 16) & 0xff;
var newg = (color >> 8) & 0xff;
var newb = color & 0xff;
line.writeUIntBE(rgb(oldr * (1 - alpha) + newr * alpha , oldg * (1 - alpha) + newg * alpha , oldb * (1 - alpha) + newb * alpha), x * 3, 3);
}
this.lastUpdate = Date.now();
return true;
}
toBase64() {
return Buffer.concat(this.canvas).toString('base64');
}
verticalLine(x, color) {
for (var y = 0; y < this.height; y++) {
this.pixel(x, y, color);
}
return true;
}
}
exports = module.exports = Image;