From 3950dce32fe33e14d9e8c49af8694d89fc1f0491 Mon Sep 17 00:00:00 2001 From: Sven Slootweg Date: Sun, 5 Mar 2017 14:18:48 +0100 Subject: [PATCH] Initial commit; 1.0.0 --- .gitignore | 2 + .npmignore | 1 + README.md | 98 +++++++++++++++++++++++++++++++++++++++++++ gulpfile.js | 18 ++++++++ index.js | 3 ++ package.json | 34 +++++++++++++++ src/create-canvas.js | 8 ++++ src/draw-character.js | 10 +++++ src/find-edge.js | 42 +++++++++++++++++++ src/get-image-data.js | 6 +++ src/index.js | 55 ++++++++++++++++++++++++ src/reset-canvas.js | 7 ++++ src/scan-row.js | 15 +++++++ 13 files changed, 299 insertions(+) create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 README.md create mode 100644 gulpfile.js create mode 100644 index.js create mode 100644 package.json create mode 100644 src/create-canvas.js create mode 100644 src/draw-character.js create mode 100644 src/find-edge.js create mode 100644 src/get-image-data.js create mode 100644 src/index.js create mode 100644 src/reset-canvas.js create mode 100644 src/scan-row.js 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/README.md b/README.md new file mode 100644 index 0000000..80c245e --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# measure-font + +Measures the dimensions and baseline offsets of a font in the browser. Written as a workaround for lacking `measureText`/`TextMetrics` support in browsers. + +## Caveats + +* This only works in a browser environment, or some other kind of environment that implements the Canvas API. Support for the following is required: + * `document.createElement` + * `context.getImageData` +* I'm not an expert at either fonts or text rendering. This implementation may very well be wrong in some way, even if it worked for my purposes. Patches welcome! +* If you are using Web Fonts, make sure that all the fonts have loaded __before__ trying to measure them. Otherwise, your measurements will be wrong. + +## License + +[WTFPL](http://www.wtfpl.net/txt/copying/) or [CC0](https://creativecommons.org/publicdomain/zero/1.0/), whichever you prefer. A donation and/or attribution are appreciated, but not required. + +## Donate + +Maintaining open-source projects takes a lot of time, and the more donations I receive, the more time I can dedicate to open-source. If this module is useful to you, consider [making a donation](http://cryto.net/~joepie91/donate.html)! + +You can donate using Bitcoin, PayPal, Flattr, cash-in-mail, SEPA transfers, and pretty much anything else. Thank you! + +## Contributing + +Pull requests welcome. Please make sure your modifications are in line with the overall code style, and ensure that you're editing the files in `src/`, not those in `lib/`. + +Build tool of choice is `gulp`; simply run `gulp` while developing, and it will watch for changes. + +Be aware that by making a pull request, you agree to release your modifications under the licenses stated above. + +## Measuring precision + +This library accepts two options that control the precision of the measurements: `fontSize` and `tolerance`. These are __not__ required to be set, and you do __not__ need to re-measure the font for every font size you wish to use - all measurements are expressed in multipliers, that you can apply to any font size to get the right offsets for that font size. See the "Using the measurements" section for more details. + +Fonts are measured by rendering a number of reference characters on an invisible canvas. The `fontSize` option is used to determine what size the characters should be rendered at - the larger the `fontSize`, the more accurate the edge detection will be (leading to a more accurate measurement). In practice, the default font size of `20` is usually sufficient. + +The invisible canvas needs to have a certain width and height, large enough to fit the measured +characters. You can control the size of this canvas using the `tolerance` parameter - both the width and the height of the canvas will be `fontSize * tolerance` pixels large. The default tolerance of `6` (3 units in each direction) should be more than sufficient for most cases, but if you're dealing with very strangely sized fonts, you may want to increase this value. + +It's __not recommended__ to reduce the `tolerance` below the default value of `6`, unless you have very stringent performance requirements and have thoroughly tested and verified that your `tolerance` setting works correctly. Setting the `tolerance` too low will result in measurements silently failing, yielding incorrect results. Often, the better solution to performance issues is to simply [memoize](https://www.npmjs.com/package/memoizee) the measurement results. + +## Using the measurements + +All measurements are *multipliers*, and are *relative to the alphabetic baseline*. That means that if you have an `ascender` measurement of `-0.75`, you would calculate the tallest possible ascender in a string at a `40px` font size as follows: + +```js +tallestPossibleAscender = measurements.ascender * 40; +``` + +This would yield a `tallestPossibleAscender` of `-30` - in other words, the tallest possible ascender reaches up to `baselineHeight - 30`. + +Typically, only the `descender` is a positive number (since it ends up below the baseline), and all the other measurements are negative numbers (since they end up above the baseline). + +## Usage + +A simple example: + +```javascript +const measureFont = require("measure-font"); + +let sansSerifMeasurements = measureFont("sans-serif"); +console.log(sansSerifMeasurements); // {descender: 0.15, ascender: -0.75, capHeight: -0.7, median: -0.55, topBounding: -0.75} +``` + +With a custom precision option: + +```javascript +const measureFont = require("measure-font"); + +let sansSerifMeasurements = measureFont("sans-serif", { + fontSize: 40 +}); + +console.log(sansSerifMeasurements); // {descender: 0.15, ascender: -0.75, capHeight: -0.7, median: -0.55, topBounding: -0.75} +``` + +## API + +### measureFont(fontName, [options]) + +Measures the specified font. + +* __fontName__: The (CSS) name of the font family you wish to measure. +* __options__: *Optional.* See the "Measuring precision" section above for more details. + * __fontSize__: The font size (in `px`) to measure at. + * __tolerance__: The size multiplier for the measuring canvas. + +Returns an object containing measurements. All measurements are *multipliers*, relative to the alphabetic baseline. + +* __descender__: The offset of the lowest descender. +* __ascender__: The offset of the highest ascender. +* __capHeight__: The offset of the cap height. +* __median__: The offset of the mean line (usually equals the x-height). +* __topBounding__: The offset of the top bounding line, ie. the tallest a regular character could be (this can extend beyond the highest ascender). + +Reference image (from [Wikipedia](https://en.wikipedia.org/wiki/File:Typography_Line_Terms.svg)): + +![Font dimension visualization](https://upload.wikimedia.org/wikipedia/commons/3/39/Typography_Line_Terms.svg) diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..1769501 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,18 @@ +var gulp = require("gulp"); +var presetES2015 = require("@joepie91/gulp-preset-es2015"); + +var source = ["src/**/*.js"] + +gulp.task('babel', function() { + return gulp.src(source) + .pipe(presetES2015({ + basePath: __dirname + })) + .pipe(gulp.dest("lib/")); +}); + +gulp.task("watch", function () { + gulp.watch(source, ["babel"]); +}); + +gulp.task("default", ["babel", "watch"]); \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..507257b --- /dev/null +++ b/index.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require("./lib"); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..0b31411 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "measure-font", + "version": "1.0.0", + "description": "Measures the dimensions and baseline offsets of a font in the browser", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "gulp": "gulp" + }, + "repository": { + "type": "git", + "url": "http://git.cryto.net/joepie91/measure-font.git" + }, + "keywords": [ + "fonts", + "canvas", + "measuring", + "measurements", + "TextMetrics", + "measureText", + "workaround", + "text" + ], + "author": "Sven Slootweg", + "license": "WTFPL", + "dependencies": { + "default-value": "^1.0.0" + }, + "devDependencies": { + "@joepie91/gulp-preset-es2015": "^1.0.1", + "babel-preset-es2015": "^6.6.0", + "gulp": "^3.9.1" + } +} diff --git a/src/create-canvas.js b/src/create-canvas.js new file mode 100644 index 0000000..fec8b78 --- /dev/null +++ b/src/create-canvas.js @@ -0,0 +1,8 @@ +'use strict'; + +module.exports = function createCanvas(size) { + let canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + return canvas; +}; diff --git a/src/draw-character.js b/src/draw-character.js new file mode 100644 index 0000000..a07c7e6 --- /dev/null +++ b/src/draw-character.js @@ -0,0 +1,10 @@ +'use strict'; + +module.exports = function drawCharacter(canvas, character, fontFamily, fontSize) { + let context = canvas.getContext("2d"); + context.textAlign = "center"; + context.textBaseline = "alphabetic"; + context.font = `${fontSize}px '${fontFamily}'`; + context.fillStyle = "white"; + context.fillText(character, canvas.width / 2, canvas.height / 2); +}; diff --git a/src/find-edge.js b/src/find-edge.js new file mode 100644 index 0000000..1a4ca7e --- /dev/null +++ b/src/find-edge.js @@ -0,0 +1,42 @@ +'use strict'; + +const getImageData = require("./get-image-data"); +const scanRow = require("./scan-row"); + +function findEdge(canvas, firstRow, lastRow, step) { + let imageData = getImageData(canvas).data; + let valuesPerRow = canvas.width * 4; + let hitEnd = false; + + if (step === 0) { + throw new Error("Step cannot be 0"); + } + + let row = firstRow; + + while(!hitEnd) { + let highestValue = scanRow(imageData, (row * valuesPerRow), canvas.width); + + /* 240 is a somewhat randomly picked value to deal with anti-aliasing. */ + if (highestValue > 240) { + return row; + } + + row += step; + + if (step > 0) { + hitEnd = (row > lastRow); + } else if (step < 0) { + hitEnd = (row < lastRow); + } + } +} + +module.exports = { + lowest: function findLowestEdge(canvas) { + return findEdge(canvas, canvas.height - 1, 0, -1); + }, + highest: function findHighestEdge(canvas) { + return findEdge(canvas, 0, canvas.height - 1, 1); + } +}; diff --git a/src/get-image-data.js b/src/get-image-data.js new file mode 100644 index 0000000..722656c --- /dev/null +++ b/src/get-image-data.js @@ -0,0 +1,6 @@ +'use strict'; + +module.exports = function getImageData(canvas) { + let context = canvas.getContext("2d"); + return context.getImageData(0, 0, canvas.width, canvas.height); +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..56e1911 --- /dev/null +++ b/src/index.js @@ -0,0 +1,55 @@ +'use strict'; + +const defaultValue = require("default-value"); + +const findEdge = require("./find-edge"); +const drawCharacter = require("./draw-character"); +const resetCanvas = require("./reset-canvas"); +const createCanvas = require("./create-canvas"); + +module.exports = function measureFont(fontFamily, options = {}) { + let fontSize = defaultValue(options.fontSize, 20); + let canvasSize = defaultValue(options.tolerance, 6) * fontSize; + + let descenderCharacters = ["g", "j", "p", "q", "y"]; + let ascenderCharacters = ["h", "d", "t", "l"]; + let capHeightCharacters = ["H", "I", "T"]; + let medianCharacters = ["x", "v", "w", "z"]; + let topBoundingCharacters = ["O", "A", "8", "#", "%", "^", "!", "/", "|", "]"]; + + let testingCanvas = createCanvas(canvasSize); + + function getLowest(characters) { + resetCanvas(testingCanvas); + + characters.forEach((character) => { + drawCharacter(testingCanvas, character, fontFamily, fontSize); + }); + + return findEdge.lowest(testingCanvas); + } + + function getHighest(characters) { + resetCanvas(testingCanvas); + + characters.forEach((character) => { + drawCharacter(testingCanvas, character, fontFamily, fontSize); + }); + + return findEdge.highest(testingCanvas); + } + + let lowestDescenderPoint = getLowest(descenderCharacters) - (testingCanvas.height / 2); + let highestAscenderPoint = (testingCanvas.height / 2) - getHighest(ascenderCharacters); + let highestCapHeightPoint = (testingCanvas.height / 2) - getHighest(capHeightCharacters); + let highestMedianPoint = (testingCanvas.height / 2) - getHighest(medianCharacters); + let highestTopBoundingPoint = (testingCanvas.height / 2) - getHighest(topBoundingCharacters); + + return { + descender: lowestDescenderPoint / fontSize, + ascender: -highestAscenderPoint / fontSize, + capHeight: -highestCapHeightPoint / fontSize, + median: -highestMedianPoint / fontSize, + topBounding: -highestTopBoundingPoint / fontSize + } +} diff --git a/src/reset-canvas.js b/src/reset-canvas.js new file mode 100644 index 0000000..43367b5 --- /dev/null +++ b/src/reset-canvas.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = function resetCanvas(canvas) { + let context = canvas.getContext("2d"); + context.fillStyle = "black"; + context.fillRect(0, 0, canvas.width, canvas.height); +}; diff --git a/src/scan-row.js b/src/scan-row.js new file mode 100644 index 0000000..ee0f72d --- /dev/null +++ b/src/scan-row.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = function scanRow(imageData, offset, length) { + let highestValue = 0; + + for (let column = 0; column < length; column += 1) { + let pixelValue = imageData[offset + (column * 4)]; + + if (pixelValue > highestValue) { + highestValue = pixelValue; + } + } + + return highestValue; +};