commit 80d0fc828d45aaaa3e6f2d74a11c07ba06937bab Author: Sven Slootweg Date: Sun Mar 5 16:48:20 2017 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f028e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/node_modules/ +/lib/ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..096746c --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +/node_modules/ \ No newline at end of file diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..e57c4f2 --- /dev/null +++ b/gulpfile.js @@ -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']); diff --git a/index.js b/index.js new file mode 100644 index 0000000..bf63049 --- /dev/null +++ b/index.js @@ -0,0 +1 @@ +module.exports = require("./lib"); diff --git a/package.json b/package.json new file mode 100644 index 0000000..94b464b --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..37aa087 --- /dev/null +++ b/src/index.js @@ -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; +} diff --git a/src/layout.js b/src/layout.js new file mode 100644 index 0000000..cb021e7 --- /dev/null +++ b/src/layout.js @@ -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 { + + } +}; diff --git a/src/parser/find-alignment.js b/src/parser/find-alignment.js new file mode 100644 index 0000000..5bf748f --- /dev/null +++ b/src/parser/find-alignment.js @@ -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; + } + } +}; diff --git a/src/parser/from-tree.js b/src/parser/from-tree.js new file mode 100644 index 0000000..0b780c2 --- /dev/null +++ b/src/parser/from-tree.js @@ -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, []); +} diff --git a/src/parser/generate-style.js b/src/parser/generate-style.js new file mode 100644 index 0000000..c01b052 --- /dev/null +++ b/src/parser/generate-style.js @@ -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; +} diff --git a/src/parser/tag-parser.pegjs b/src/parser/tag-parser.pegjs new file mode 100644 index 0000000..8f65095 --- /dev/null +++ b/src/parser/tag-parser.pegjs @@ -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)}; + }*/ diff --git a/src/parser/to-lines.js b/src/parser/to-lines.js new file mode 100644 index 0000000..ecdbc93 --- /dev/null +++ b/src/parser/to-lines.js @@ -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; +}; diff --git a/src/render/measure-text.js b/src/render/measure-text.js new file mode 100644 index 0000000..a524898 --- /dev/null +++ b/src/render/measure-text.js @@ -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, + }); +} diff --git a/src/render/set-text-styles.js b/src/render/set-text-styles.js new file mode 100644 index 0000000..59e45e2 --- /dev/null +++ b/src/render/set-text-styles.js @@ -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; + } +} diff --git a/test/parser.js b/test/parser.js new file mode 100644 index 0000000..f9bd07a --- /dev/null +++ b/test/parser.js @@ -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}));