diff --git a/lib/context2d.js b/lib/context2d.js index 1857f2c34..b0ee81d50 100644 --- a/lib/context2d.js +++ b/lib/context2d.js @@ -16,16 +16,14 @@ var canvas = require('./bindings') , CanvasPattern = canvas.CanvasPattern , ImageData = canvas.ImageData; -var parseCssFont = require('parse-css-font'); - -var unitsCss = require('units-css'); - /** * Export `Context2d` as the module. */ var Context2d = exports = module.exports = Context2d; +var parseFont = exports.parseFont = require('./parse-font.js'); + /** * Cache color string RGBA values. */ @@ -38,62 +36,6 @@ var cache = {}; var baselines = ['alphabetic', 'top', 'bottom', 'middle', 'ideographic', 'hanging']; -/** - * Parse font `str`. - * - * @param {String} str - * @return {Object} - * @api private - */ - -var parseFont = exports.parseFont = function(str) { - var parsedFont; - - // Try to parse the font string using parse-css-font. - // It will throw an exception if it fails. - try { - parsedFont = parseCssFont(str); - } - catch (e) { - // Invalid - return; - } - - // Cached - if (cache[str]) return cache[str]; - - // Parse size into value and unit using units-css - var size = unitsCss.parse(parsedFont.size); - - // TODO: dpi - // TODO: remaining unit conversion - switch (size.unit) { - case 'pt': - size.value /= .75; - break; - case 'in': - size.value *= 96; - break; - case 'mm': - size.value *= 96.0 / 25.4; - break; - case 'cm': - size.value *= 96.0 / 2.54; - break; - } - - // Populate font object - var font = { - weight: parsedFont.weight, - style: parsedFont.style, - size: size.value, - unit: size.unit, - family: parsedFont.family[0] - }; - - return cache[str] = font; -}; - /** * Enable or disable image smoothing. * diff --git a/lib/parse-font.js b/lib/parse-font.js new file mode 100644 index 000000000..cf91b6e61 --- /dev/null +++ b/lib/parse-font.js @@ -0,0 +1,102 @@ +'use strict' + +/** + * Font RegExp helpers. + */ + +const weights = 'bold|bolder|lighter|[1-9]00' + , styles = 'italic|oblique' + , variants = 'small-caps' + , stretches = 'ultra-condensed|extra-condensed|condensed|semi-condensed|semi-expanded|expanded|extra-expanded|ultra-expanded' + , units = 'px|pt|pc|in|cm|mm|%|em|ex|ch|rem|q' + , string = '\'([^\']+)\'|"([^"]+)"|[\\w-]+' + +// [ [ <‘font-style’> || || <‘font-weight’> || <‘font-stretch’> ]? +// <‘font-size’> [ / <‘line-height’> ]? <‘font-family’> ] +// https://drafts.csswg.org/css-fonts-3/#font-prop +const weightRe = new RegExp(`(${weights}) +`, 'i') +const styleRe = new RegExp(`(${styles}) +`, 'i') +const variantRe = new RegExp(`(${variants}) +`, 'i') +const stretchRe = new RegExp(`(${stretches}) +`, 'i') +const sizeFamilyRe = new RegExp( + '([\\d\\.]+)(' + units + ') *' + + '((?:' + string + ')( *, *(?:' + string + '))*)') + +/** + * Cache font parsing. + */ + +const cache = {} + +const defaultHeight = 16 // pt, common browser default + +/** + * Parse font `str`. + * + * @param {String} str + * @return {Object} Parsed font. `size` is in device units. `unit` is the unit + * appearing in the input string. + * @api private + */ + +module.exports = function (str) { + // Cached + if (cache[str]) return cache[str] + + // Try for required properties first. + const sizeFamily = sizeFamilyRe.exec(str) + if (!sizeFamily) return // invalid + + // Default values and required properties + const font = { + weight: 'normal', + style: 'normal', + stretch: 'normal', + variant: 'normal', + size: parseFloat(sizeFamily[1]), + unit: sizeFamily[2], + family: sizeFamily[3].replace(/["']/g, '').replace(/ *, */g, ',') + } + + // Optional, unordered properties. + let weight, style, variant, stretch + // Stop search at `sizeFamily.index` + let substr = str.substring(0, sizeFamily.index) + if ((weight = weightRe.exec(substr))) font.weight = weight[1] + if ((style = styleRe.exec(substr))) font.style = style[1] + if ((variant = variantRe.exec(substr))) font.variant = variant[1] + if ((stretch = stretchRe.exec(substr))) font.stretch = stretch[1] + + // Convert to device units. (`font.unit` is the original unit) + // TODO: ch, ex + switch (font.unit) { + case 'pt': + font.size /= 0.75 + break + case 'pc': + font.size *= 16 + break + case 'in': + font.size *= 96 + break + case 'cm': + font.size *= 96.0 / 2.54 + break + case 'mm': + font.size *= 96.0 / 25.4 + break + case '%': + // TODO disabled because existing unit tests assume 100 + // font.size *= defaultHeight / 100 / 0.75 + break + case 'em': + case 'rem': + font.size *= defaultHeight / 0.75 + break + case 'q': + font.size *= 96 / 25.4 / 4 + break + } + + return (cache[str] = font) +} diff --git a/package.json b/package.json index 9147fa674..01d644f9a 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,7 @@ "test-server": "node test/server.js" }, "dependencies": { - "nan": "^2.4.0", - "parse-css-font": "^2.0.2", - "units-css": "^0.4.0" + "nan": "^2.4.0" }, "devDependencies": { "body-parser": "^1.13.3", diff --git a/test/canvas.test.js b/test/canvas.test.js index f29945900..bce102da5 100644 --- a/test/canvas.test.js +++ b/test/canvas.test.js @@ -27,59 +27,69 @@ describe('Canvas', function () { it('.parseFont()', function () { var tests = [ - '20px Arial' - , { size: 20, unit: 'px', family: 'Arial' } - , '20pt Arial' - , { size: 26.666666666666668, unit: 'pt', family: 'Arial' } - , '20.5pt Arial' - , { size: 27.333333333333332, unit: 'pt', family: 'Arial' } - , '20% Arial' - , { size: 20, unit: '%', family: 'Arial' } - , '20mm Arial' - , { size: 75.59055118110237, unit: 'mm', family: 'Arial' } - , '20px serif' - , { size: 20, unit: 'px', family: 'serif' } - , '20px sans-serif' - , { size: 20, unit: 'px', family: 'sans-serif' } - , '20px monospace' - , { size: 20, unit: 'px', family: 'monospace' } - , '50px Arial, sans-serif' - , { size: 50, unit: 'px', family: 'Arial' } - , 'bold italic 50px Arial, sans-serif' - , { style: 'italic', weight: 'bold', size: 50, unit: 'px', family: 'Arial' } - , '50px Helvetica , Arial, sans-serif' - , { size: 50, unit: 'px', family: 'Helvetica' } - , '50px "Helvetica Neue", sans-serif' - , { size: 50, unit: 'px', family: 'Helvetica Neue' } - , '50px "Helvetica Neue", "foo bar baz" , sans-serif' - , { size: 50, unit: 'px', family: 'Helvetica Neue' } - , "50px 'Helvetica Neue'" - , { size: 50, unit: 'px', family: 'Helvetica Neue' } - , 'italic 20px Arial' - , { size: 20, unit: 'px', style: 'italic', family: 'Arial' } - , 'oblique 20px Arial' - , { size: 20, unit: 'px', style: 'oblique', family: 'Arial' } - , 'normal 20px Arial' - , { size: 20, unit: 'px', style: 'normal', family: 'Arial' } - , '300 20px Arial' - , { size: 20, unit: 'px', weight: '300', family: 'Arial' } - , '800 20px Arial' - , { size: 20, unit: 'px', weight: '800', family: 'Arial' } - , 'bolder 20px Arial' - , { size: 20, unit: 'px', weight: 'bolder', family: 'Arial' } - , 'lighter 20px Arial' - , { size: 20, unit: 'px', weight: 'lighter', family: 'Arial' } - ]; + '20px Arial' + , { size: 20, unit: 'px', family: 'Arial' } + , '20pt Arial' + , { size: 26.666666666666668, unit: 'pt', family: 'Arial' } + , '20.5pt Arial' + , { size: 27.333333333333332, unit: 'pt', family: 'Arial' } + , '20% Arial' + , { size: 20, unit: '%', family: 'Arial' } // TODO I think this is a bad assertion - ZB 23-Jul-2017 + , '20mm Arial' + , { size: 75.59055118110237, unit: 'mm', family: 'Arial' } + , '20px serif' + , { size: 20, unit: 'px', family: 'serif' } + , '20px sans-serif' + , { size: 20, unit: 'px', family: 'sans-serif' } + , '20px monospace' + , { size: 20, unit: 'px', family: 'monospace' } + , '50px Arial, sans-serif' + , { size: 50, unit: 'px', family: 'Arial,sans-serif' } + , 'bold italic 50px Arial, sans-serif' + , { style: 'italic', weight: 'bold', size: 50, unit: 'px', family: 'Arial,sans-serif' } + , '50px Helvetica , Arial, sans-serif' + , { size: 50, unit: 'px', family: 'Helvetica,Arial,sans-serif' } + , '50px "Helvetica Neue", sans-serif' + , { size: 50, unit: 'px', family: 'Helvetica Neue,sans-serif' } + , '50px "Helvetica Neue", "foo bar baz" , sans-serif' + , { size: 50, unit: 'px', family: 'Helvetica Neue,foo bar baz,sans-serif' } + , "50px 'Helvetica Neue'" + , { size: 50, unit: 'px', family: 'Helvetica Neue' } + , 'italic 20px Arial' + , { size: 20, unit: 'px', style: 'italic', family: 'Arial' } + , 'oblique 20px Arial' + , { size: 20, unit: 'px', style: 'oblique', family: 'Arial' } + , 'normal 20px Arial' + , { size: 20, unit: 'px', style: 'normal', family: 'Arial' } + , '300 20px Arial' + , { size: 20, unit: 'px', weight: '300', family: 'Arial' } + , '800 20px Arial' + , { size: 20, unit: 'px', weight: '800', family: 'Arial' } + , 'bolder 20px Arial' + , { size: 20, unit: 'px', weight: 'bolder', family: 'Arial' } + , 'lighter 20px Arial' + , { size: 20, unit: 'px', weight: 'lighter', family: 'Arial' } + , 'normal normal normal 16px Impact' + , { size: 16, unit: 'px', weight: 'normal', family: 'Impact', style: 'normal', variant: 'normal' } + , 'italic small-caps bolder 16px cursive' + , { size: 16, unit: 'px', style: 'italic', variant: 'small-caps', weight: 'bolder', family: 'cursive' } + , '20px "new century schoolbook", serif' + , { size: 20, unit: 'px', family: 'new century schoolbook,serif' } + , '20px "Arial bold 300"' // synthetic case with weight keyword inside family + , { size: 20, unit: 'px', family: 'Arial bold 300', variant: 'normal' } + ]; for (var i = 0, len = tests.length; i < len; ++i) { var str = tests[i++] - , obj = tests[i] + , expected = tests[i] , actual = parseFont(str); - if (!obj.style) obj.style = 'normal'; - if (!obj.weight) obj.weight = 'normal'; + if (!expected.style) expected.style = 'normal'; + if (!expected.weight) expected.weight = 'normal'; + if (!expected.stretch) expected.stretch = 'normal'; + if (!expected.variant) expected.variant = 'normal'; - assert.deepEqual(obj, actual); + assert.deepEqual(actual, expected, 'Failed to parse: ' + str); } });