Initial commit
commit
80d0fc828d
@ -0,0 +1,2 @@
|
|||||||
|
/node_modules/
|
||||||
|
/lib/
|
@ -0,0 +1 @@
|
|||||||
|
/node_modules/
|
@ -0,0 +1,32 @@
|
|||||||
|
var gulp = require('gulp');
|
||||||
|
|
||||||
|
var presetES2015 = require("@joepie91/gulp-preset-es2015");
|
||||||
|
var presetPegjs = require("@joepie91/gulp-preset-pegjs")
|
||||||
|
|
||||||
|
var sources = {
|
||||||
|
babel: ["src/**/*.js"],
|
||||||
|
pegjs: ["src/**/*.pegjs"]
|
||||||
|
}
|
||||||
|
|
||||||
|
gulp.task('babel', function() {
|
||||||
|
return gulp.src(sources.babel)
|
||||||
|
.pipe(presetES2015({
|
||||||
|
basePath: __dirname
|
||||||
|
}))
|
||||||
|
.pipe(gulp.dest("lib/"));
|
||||||
|
});
|
||||||
|
|
||||||
|
gulp.task('pegjs', function() {
|
||||||
|
return gulp.src(sources.pegjs)
|
||||||
|
.pipe(presetPegjs({
|
||||||
|
basePath: __dirname
|
||||||
|
}))
|
||||||
|
.pipe(gulp.dest("lib/"));
|
||||||
|
})
|
||||||
|
|
||||||
|
gulp.task('watch', function () {
|
||||||
|
gulp.watch(sources.babel, ['babel']);
|
||||||
|
gulp.watch(sources.pegjs, ['pegjs']);
|
||||||
|
});
|
||||||
|
|
||||||
|
gulp.task('default', ['pegjs', 'babel', 'watch']);
|
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "canvassed-text",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Text shape for `canvassed`",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git@git.cryto.net:joepie91/canvassed-text.git"
|
||||||
|
},
|
||||||
|
"author": "Sven Slootweg",
|
||||||
|
"license": "WTFPL",
|
||||||
|
"devDependencies": {
|
||||||
|
"@joepie91/gulp-preset-es2015": "^1.0.1",
|
||||||
|
"@joepie91/gulp-preset-pegjs": "^1.0.0",
|
||||||
|
"babel-preset-es2015": "^6.22.0",
|
||||||
|
"chalk": "^1.1.3",
|
||||||
|
"default-value": "^1.0.0",
|
||||||
|
"gulp": "^3.9.1",
|
||||||
|
"pad": "^1.1.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"default-value": "^1.0.0",
|
||||||
|
"measure-font": "^1.0.0",
|
||||||
|
"memoizee": "^0.4.3",
|
||||||
|
"object.pick": "^1.2.0"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,104 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const canvassed = require("canvassed");
|
||||||
|
const objectPick = require("object.pick");
|
||||||
|
|
||||||
|
const tagParser = require("./tag-parser");
|
||||||
|
const layout = require("./layout");
|
||||||
|
const fromTree = require("./text-components/from-tree");
|
||||||
|
const setTextStyles = require("./render/set-text-styles");
|
||||||
|
const measureText = require("./render/measure-text");
|
||||||
|
|
||||||
|
function getTextProperties(item) {
|
||||||
|
return objectPick(item, ["fontSize", "fontFamily", "fontStyle", "fontWeight"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function createTextShape(options) {
|
||||||
|
let textObject = canvassed.createObject(Object.assign({
|
||||||
|
_layout: null,
|
||||||
|
_lines: null,
|
||||||
|
type: "text",
|
||||||
|
cacheBustingProperties: ["fillColor", "strokeColor", "strokeWidth", "text", "fontSize", "fontFamily", "fontStyle", "fontWeight"],
|
||||||
|
sizeBustingProperties: ["text", "fontSize", "fontFamily", "fontStyle", "fontWeight"],
|
||||||
|
fillColor: "red",
|
||||||
|
strokeColor: "red",
|
||||||
|
strokeWidth: 0,
|
||||||
|
fontFamily: "sans-serif",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "normal",
|
||||||
|
lineHeight: 1.16,
|
||||||
|
onRender: function onRender(context) {
|
||||||
|
if (this.tags === true) {
|
||||||
|
let currentLineOffset = 0;
|
||||||
|
let currentComponentOffset = 0;
|
||||||
|
|
||||||
|
this._layout.forEach((line) => {
|
||||||
|
line.items.forEach((item) => {
|
||||||
|
setTextStyles(context, item.style);
|
||||||
|
context.fillText(item.text, currentComponentOffset, currentLineOffset - line.lineMinAscender);
|
||||||
|
currentComponentOffset += item.measurements.width;
|
||||||
|
});
|
||||||
|
|
||||||
|
currentLineOffset += line.lineHeight * this.lineHeight;
|
||||||
|
currentComponentOffset = 0;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let currentLineOffset = 0;
|
||||||
|
|
||||||
|
setTextStyles(context, getTextProperties(this));
|
||||||
|
console.log(this._lines, this.renderWidth, this.renderHeight);
|
||||||
|
this._lines.forEach((line) => {
|
||||||
|
context.fillText(line.text, 0, currentLineOffset - line.lineMinAscender);
|
||||||
|
currentLineOffset += line.measurements.height * this.lineHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRecalculateSize: function onRecalculateSize() {
|
||||||
|
if (this.tags === true) {
|
||||||
|
this._layout = layout(this.text, {
|
||||||
|
classes: options.classes,
|
||||||
|
defaultStyle: getTextProperties(this)
|
||||||
|
});
|
||||||
|
|
||||||
|
let combinedLineHeight = this._layout.reduce((total, line, i) => {
|
||||||
|
if (i !== this._layout.length - 1) {
|
||||||
|
return total + (line.lineHeight * this.lineHeight);
|
||||||
|
} else {
|
||||||
|
/* The last line doesn't get a lineHeight multiplier... */
|
||||||
|
return total + line.lineHeight;
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
height: Math.ceil(combinedLineHeight),
|
||||||
|
width: Math.ceil(Math.max.apply(null, this._layout.map(line => line.lineWidth)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._lines = this.text.split("\n").map((line) => {
|
||||||
|
return {
|
||||||
|
text: line,
|
||||||
|
measurements: measureText(line, getTextProperties(this))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("line", this._lines)
|
||||||
|
|
||||||
|
function sum(values) {
|
||||||
|
return values.reduce((total, value) => {
|
||||||
|
return total + value;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let multipliedLineHeights = this._lines.slice(0, -1).map(line => line.measurements.height * this.lineHeight);
|
||||||
|
let combinedLineHeight = sum(multipliedLineHeights) + this._lines[this._lines.length - 1].measurements.height;
|
||||||
|
|
||||||
|
return {
|
||||||
|
height: Math.ceil(combinedLineHeight),
|
||||||
|
width: Math.ceil(Math.max.apply(null, this._lines.map(line => line.measurements.width)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, options));
|
||||||
|
|
||||||
|
return textObject;
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const componentsToLines = require("./parser/to-lines");
|
||||||
|
const findAlignment = require("./parser/find-alignment");
|
||||||
|
const generateStyle = require("./parser/generate-style");
|
||||||
|
const fromTree = require("./parser/from-tree");
|
||||||
|
const tagParser = require("./parser/tag-parser");
|
||||||
|
const measureText = require("./render/measure-text");
|
||||||
|
|
||||||
|
module.exports = function layoutFormattedText(text, options) {
|
||||||
|
if (options.tags === true) {
|
||||||
|
let tree = tagParser.parse(text);
|
||||||
|
|
||||||
|
let lines = componentsToLines(fromTree(tree)).map((lineItems) => {
|
||||||
|
let measuredItems = lineItems.map((item) => {
|
||||||
|
let generatedStyle = generateStyle(item, {
|
||||||
|
classes: options.classes,
|
||||||
|
defaultStyle: options.defaultStyle
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({
|
||||||
|
measurements: measureText(item.text, generatedStyle),
|
||||||
|
style: generatedStyle
|
||||||
|
}, item);
|
||||||
|
});
|
||||||
|
|
||||||
|
let combinedWidth = measuredItems.reduce((total, item) => {
|
||||||
|
return total + item.measurements.width;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
let lineHeight = Math.max.apply(null, measuredItems.map(item => item.measurements.height));
|
||||||
|
let lineMinAscender = Math.min.apply(null, measuredItems.map(item => item.measurements.ascender));
|
||||||
|
let lineMaxDescender = Math.max.apply(null, measuredItems.map(item => item.measurements.descender));
|
||||||
|
|
||||||
|
return {
|
||||||
|
alignment: (lineItems.length > 0) ? findAlignment(lineItems[0]) : null,
|
||||||
|
items: measuredItems,
|
||||||
|
height: lineHeight,
|
||||||
|
width: combinedWidth,
|
||||||
|
minAscender: lineMinAscender,
|
||||||
|
maxDescender: lineMaxDescender
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
@ -0,0 +1,17 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = function findAlignment(item) {
|
||||||
|
if (item.tags == null) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
let alignmentTags = item.tags.filter(tag => tag.type === "alignment");
|
||||||
|
|
||||||
|
if (alignmentTags.length > 0) {
|
||||||
|
/* We always want the last-specified alignment tag. */
|
||||||
|
// FIXME: Throw an error upon encountering nested alignment tags...
|
||||||
|
return alignmentTags[alignmentTags.length - 1].alignment;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
@ -0,0 +1,28 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = function splitTextComponents(treeList) {
|
||||||
|
function flatten(treeList, tagStack) {
|
||||||
|
return treeList.reduce((all, item) => {
|
||||||
|
if (item.type === "text") {
|
||||||
|
let splitItems = item.text.split("\n").reduce((items, line) => {
|
||||||
|
return items.concat([{
|
||||||
|
type: "text",
|
||||||
|
text: line,
|
||||||
|
tags: tagStack
|
||||||
|
}, {
|
||||||
|
type: "newline"
|
||||||
|
}]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* Remove the last newline, because it's not delimiting anything. */
|
||||||
|
splitItems.pop();
|
||||||
|
|
||||||
|
return all.concat(splitItems);
|
||||||
|
} else {
|
||||||
|
return all.concat(flatten(item.contents, tagStack.concat([item])));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
return flatten(treeList, []);
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = function generateItemStyle(item, options) {
|
||||||
|
let style = Object.assign({}, options.defaultStyle);
|
||||||
|
|
||||||
|
item.tags.forEach((tag) => {
|
||||||
|
if (tag.type === "class") {
|
||||||
|
tag.classNames.forEach((className) => {
|
||||||
|
if (options.classes[className] == null) {
|
||||||
|
throw new Error(`Encountered unrecognized class ${className} in text`);
|
||||||
|
} else {
|
||||||
|
Object.assign(style, options.classes[className]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return style;
|
||||||
|
}
|
@ -0,0 +1,129 @@
|
|||||||
|
{
|
||||||
|
function filterTerminators(values) {
|
||||||
|
return values.map((value) => {
|
||||||
|
return value[1];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseText(contents) {
|
||||||
|
/* To make the closing-tag parsing work, we interrupt a 'text' block
|
||||||
|
* every time we encounter a brace. Unfortunately, for non-closing-tag
|
||||||
|
* braces, this means things get broken up into consecutive text items.
|
||||||
|
* This function stitches consecutive text items back together. */
|
||||||
|
|
||||||
|
let textBuffer = "";
|
||||||
|
let newContents = [];
|
||||||
|
|
||||||
|
function completeTextItem() {
|
||||||
|
if (textBuffer.length > 0) {
|
||||||
|
newContents.push({
|
||||||
|
type: "text",
|
||||||
|
text: textBuffer
|
||||||
|
});
|
||||||
|
|
||||||
|
textBuffer = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contents.forEach((item) => {
|
||||||
|
if (item.type === "text") {
|
||||||
|
textBuffer += item.text;
|
||||||
|
} else {
|
||||||
|
completeTextItem();
|
||||||
|
newContents.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
completeTextItem();
|
||||||
|
|
||||||
|
return newContents;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processContents(contents) {
|
||||||
|
return collapseText(filterTerminators(contents));
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineDelimitedItems(firstItem, nextItems, nextItemIndex = 1) {
|
||||||
|
return [firstItem].concat(nextItems.map((item) => {
|
||||||
|
return item[nextItemIndex];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start
|
||||||
|
= contents:thing* {
|
||||||
|
return collapseText(contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
text
|
||||||
|
= firstCharacter:. otherCharacters:[^{]* {
|
||||||
|
return {type: "text", text: firstCharacter + otherCharacters.join("")};
|
||||||
|
}
|
||||||
|
|
||||||
|
thing
|
||||||
|
= classTag
|
||||||
|
/*/ colorTag
|
||||||
|
/ boldTag
|
||||||
|
/ italicTag
|
||||||
|
/ weightTag
|
||||||
|
/ fontTag
|
||||||
|
/ sizeTag*/
|
||||||
|
/ alignmentTag
|
||||||
|
/ text
|
||||||
|
|
||||||
|
tagParameter
|
||||||
|
= characters:[^}:]+ { return characters.join(""); }
|
||||||
|
|
||||||
|
alignmentParameter
|
||||||
|
= "left"
|
||||||
|
/ "center"
|
||||||
|
/ "right"
|
||||||
|
/ "justify"
|
||||||
|
|
||||||
|
weightParameter
|
||||||
|
= "bold"
|
||||||
|
/ "normal"
|
||||||
|
/ [0-9]+
|
||||||
|
|
||||||
|
classParameter
|
||||||
|
= characters:[^}:,]+ { return characters.join(""); }
|
||||||
|
|
||||||
|
classTag
|
||||||
|
= "{class:" firstClassName:classParameter nextClassNames:("," classParameter)* "}" contents:(!"{/class}" thing)* "{/class}" {
|
||||||
|
return {type: "class", classNames: combineDelimitedItems(firstClassName, nextClassNames), contents: processContents(contents)};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*colorTag
|
||||||
|
= "{color:" color:tagParameter "}" contents:(!"{/color}" thing)* "{/color}" {
|
||||||
|
return {type: "color", color: color, contents: processContents(contents)};
|
||||||
|
}
|
||||||
|
|
||||||
|
fontTag
|
||||||
|
= "{font:" font:tagParameter "}" contents:(!"{/font}" thing)* "{/font}" {
|
||||||
|
return {type: "font", font: font, contents: processContents(contents)};
|
||||||
|
}
|
||||||
|
|
||||||
|
sizeTag
|
||||||
|
= "{size:" size:tagParameter "}" contents:(!"{/size}" thing)* "{/size}" {
|
||||||
|
return {type: "size", size: size, contents: processContents(contents)};
|
||||||
|
}*/
|
||||||
|
|
||||||
|
alignmentTag
|
||||||
|
= "{align:" alignment:alignmentParameter "}" contents:(!"{/align}" thing)* "{/align}" {
|
||||||
|
return {type: "alignment", alignment: alignment, contents: processContents(contents)};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*weightTag
|
||||||
|
= "{weight:" weight:tagParameter "}" contents:(!"{/weight}" thing)* "{/weight}" {
|
||||||
|
return {type: "weight", weight: weight, contents: processContents(contents)};
|
||||||
|
}
|
||||||
|
|
||||||
|
boldTag
|
||||||
|
= "{bold}" contents:(!"{/bold}" thing)* "{/bold}" {
|
||||||
|
return {type: "bold", contents: processContents(contents)};
|
||||||
|
}
|
||||||
|
|
||||||
|
italicTag
|
||||||
|
= "{italic}" contents:(!"{/italic}" thing)* "{/italic}" {
|
||||||
|
return {type: "italic", contents: processContents(contents)};
|
||||||
|
}*/
|
@ -0,0 +1,36 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const findAlignment = require("../tags/find-alignment");
|
||||||
|
|
||||||
|
module.exports = function componentsToLines(items) {
|
||||||
|
let currentLineItems = [];
|
||||||
|
let lines = [];
|
||||||
|
let lastAlignment = null;
|
||||||
|
|
||||||
|
function closeLine() {
|
||||||
|
lines.push(currentLineItems);
|
||||||
|
currentLineItems = [];
|
||||||
|
lastAlignment = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.type === "newline") {
|
||||||
|
closeLine();
|
||||||
|
} else {
|
||||||
|
let itemAlignment = findAlignment(item);
|
||||||
|
|
||||||
|
if (itemAlignment !== lastAlignment && currentLineItems.length > 0) {
|
||||||
|
/* We consider an alignment tag to be a 'block-level' element, therefore if it's
|
||||||
|
* encountered as anything but the first element, it automatically results in a
|
||||||
|
* new line. */
|
||||||
|
closeLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLineItems.push(item);
|
||||||
|
lastAlignment = itemAlignment;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
closeLine();
|
||||||
|
return lines;
|
||||||
|
};
|
@ -0,0 +1,33 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const memoizee = require("memoizee");
|
||||||
|
|
||||||
|
const setTextStyles = require("./set-text-styles");
|
||||||
|
//const measureFont = memoizee(require("./measure-font"));
|
||||||
|
const measureFont = memoizee(require("measure-font"));
|
||||||
|
|
||||||
|
module.exports = function measureText(text, options) {
|
||||||
|
let offscreenCanvas = document.createElement("canvas");
|
||||||
|
let context = offscreenCanvas.getContext("2d");
|
||||||
|
|
||||||
|
setTextStyles(context, options);
|
||||||
|
|
||||||
|
let fontMeasurements = measureFont(options.fontFamily);
|
||||||
|
|
||||||
|
return Object.assign(context.measureText(text), {
|
||||||
|
/* FIXME: The following is a dirty hack until the Canvas v5 API is
|
||||||
|
* widely supported in major browsers. The 1.13 multiplier comes
|
||||||
|
* from the fabric.js source code - don't ask me why it's 1.13.
|
||||||
|
* See also:
|
||||||
|
* https://kangax.github.io/jstests/canvas-v5/
|
||||||
|
* https://lists.w3.org/Archives/Public/public-whatwg-archive/2012Mar/0269.htm
|
||||||
|
*/
|
||||||
|
//height: options.fontSize * 1.13,
|
||||||
|
height: options.fontSize * (fontMeasurements.descender - fontMeasurements.topBounding),
|
||||||
|
ascender: options.fontSize * fontMeasurements.ascender,
|
||||||
|
descender: options.fontSize * fontMeasurements.descender,
|
||||||
|
capHeight: options.fontSize * fontMeasurements.capHeight,
|
||||||
|
median: options.fontSize * fontMeasurements.median,
|
||||||
|
topBounding: options.fontSize * fontMeasurements.topBounding,
|
||||||
|
});
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const canvassed = require("canvassed");
|
||||||
|
const defaultValue = require("default-value");
|
||||||
|
|
||||||
|
module.exports = function setTextStyles(context, options) {
|
||||||
|
canvassed.validateSync(options, {
|
||||||
|
fontFamily: "required",
|
||||||
|
fontSize: "required"
|
||||||
|
});
|
||||||
|
|
||||||
|
let fontSegments = [
|
||||||
|
options.fontStyle,
|
||||||
|
options.fontVariant,
|
||||||
|
options.fontWeight,
|
||||||
|
`${options.fontSize}px`, // FIXME: Other units?
|
||||||
|
options.fontFamily
|
||||||
|
];
|
||||||
|
|
||||||
|
context.font = fontSegments.filter(segment => (segment != null)).join(" ");
|
||||||
|
context.textBaseline = defaultValue(options.textBaseline, "alphabetic");
|
||||||
|
|
||||||
|
if (options.isStroke) {
|
||||||
|
context.strokeStyle = options.color;
|
||||||
|
} else {
|
||||||
|
context.fillStyle = options.color;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const pegjs = require("pegjs");
|
||||||
|
const util = require("util");
|
||||||
|
const fs = require("fs");
|
||||||
|
const createBetterPegTracer = require("better-peg-tracer");
|
||||||
|
|
||||||
|
//let toParse = "hello {bold}world {italic}{color:red}people{/color}{/italic}{/bold}!";
|
||||||
|
|
||||||
|
let toParse = `
|
||||||
|
foo{{{}}
|
||||||
|
{{align:center}bar
|
||||||
|
qux {class:test1,test2}baz{/class}{/align}}
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
let parser = pegjs.buildParser(fs.readFileSync("./src/parser/tag-parser.pegjs").toString(), {
|
||||||
|
trace: true
|
||||||
|
});
|
||||||
|
|
||||||
|
let results = parser.parse(toParse, {
|
||||||
|
tracer: createBetterPegTracer(toParse)
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n\n");
|
||||||
|
console.log(util.inspect(results, {colors: true, depth: null}));
|
Loading…
Reference in New Issue