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