skia-canvas
Version:
A multi-threaded, GPU-accelerated, Canvas API for Node
399 lines (342 loc) • 12.7 kB
JavaScript
//
// Parsers for properties that take CSS-style strings as values
//
// -- Font & Variant --------------------------------------------------------------------
// https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant
// https://www.w3.org/TR/css-fonts-3/#font-size-prop
var splitBy = require('string-split-by'),
m, cache = {font:{}, variant:{}};
const styleRE = /^(normal|italic|oblique)$/,
smallcapsRE = /^(normal|small-caps)$/,
stretchRE = /^(normal|(semi-|extra-|ultra-)?(condensed|expanded))$/,
namedSizeRE = /(?:xx?-)?small|smaller|medium|larger|(?:xx?-)?large|normal/,
numSizeRE = /^(\-?[\d\.]+)(px|pt|pc|in|cm|mm|%|em|ex|ch|rem|q)/,
namedWeightRE = /^(normal|bold(er)?|lighter)$/,
numWeightRE = /^(1000|\d{1,3})$/,
parameterizedRE = /([\w\-]+)\((.*?)\)/,
unquote = s => s.replace(/^(['"])(.*?)\1$/, "$2"),
isSize = s => namedSizeRE.test(s) || numSizeRE.test(s),
isWeight = s => namedWeightRE.test(s) || numWeightRE.test(s);
function parseFont(str){
if (cache.font[str]===undefined){
try{
if (typeof str !== 'string') throw new Error('Font specification must be a string')
if (!str) throw new Error('Font specification cannot be an empty string')
let font = {style:'normal', variant:'normal', weight:'normal', stretch:'normal'},
value = str.replace(/\s*\/\*s/, "/"),
tokens = splitBy(value, /\s+/),
token;
while (token = tokens.shift()) {
let match = styleRE.test(token) ? 'style'
: smallcapsRE.test(token) ? 'variant'
: stretchRE.test(token) ? 'stretch'
: isWeight(token) ? 'weight'
: isSize(token) ? 'size'
: null;
switch (match){
case "style":
case "variant":
case "stretch":
case "weight":
font[match] = token
break;
case "size":
// size is the pivot point between the style fields and the family name stack,
// so start processing what's been collected
let [emSize, leading] = splitBy(token, '/'),
size = parseSize(emSize),
lineHeight = leading ? parseSize(leading.replace(/(\d)$/, '$1em'), size) : undefined,
weight = parseWeight(font.weight),
family = splitBy(tokens.join(' '), /\s*,\s*/).map(unquote),
features = font.variant=='small-caps' ? {on:['smcp', 'onum']} : {},
{style, stretch, variant} = font;
// make sure all the numeric fields have legitimate values
let invalid = !isFinite(size) ? `font size "${emSize}"`
: !isFinite(lineHeight) && lineHeight!==undefined ? `line height "${leading}"`
: !isFinite(weight) ? `font weight "${font.weight}"`
: family.length==0 ? `font family "${tokens.join(', ')}"`
: false;
if (!invalid){
// include a re-stringified version of the decoded/absified values
return cache.font[str] = Object.assign(font, {
size, lineHeight, weight, family, features,
canonical:[
style,
(variant !== style) && variant,
([variant, style].indexOf(weight) == -1) && weight,
([variant, style, weight].indexOf(stretch) == -1) && stretch,
`${size}px${isFinite(lineHeight) ? `/${lineHeight}px`: ''}`,
family.map(nm => nm.match(/\s/) ? `"${nm}"` : nm).join(", ")
].filter(Boolean).join(' ')
})
}
throw new Error(`Invalid ${invalid}`)
default:
throw new Error(`Unrecognized font attribute "${token}"`)
}
}
throw new Error('Could not find a font size value')
} catch(e) {
// console.warn(Object.assign(e, {name:"Warning"}))
cache.font[str] = null
}
}
return cache.font[str]
}
function parseSize(str, emSize=16){
if (m = numSizeRE.exec(str)){
let [size, unit] = [parseFloat(m[1]), m[2]]
return size * (unit == 'px' ? 1
: unit == 'pt' ? 1 / 0.75
: unit == '%' ? emSize / 100
: unit == 'pc' ? 16
: unit == 'in' ? 96
: unit == 'cm' ? 96.0 / 2.54
: unit == 'mm' ? 96.0 / 25.4
: unit == 'q' ? 96 / 25.4 / 4
: unit.match('r?em') ? emSize
: NaN )
}
if (m = namedSizeRE.exec(str)){
return emSize * (sizeMap[m[0]] || 1.0)
}
return NaN
}
function parseFlexibleSize(str){
if (m = numSizeRE.exec(str)){
let [size, unit] = [parseFloat(m[1]), m[2]],
px = size * (unit == 'px' ? 1
: unit == 'pt' ? 1 / 0.75
: unit == 'pc' ? 16
: unit == 'in' ? 96
: unit == 'cm' ? 96.0 / 2.54
: unit == 'mm' ? 96.0 / 25.4
: unit == 'q' ? 96 / 25.4 / 4
: NaN )
return {size, unit, px}
}
return null
}
function parseStretch(str){
return (m = stretchRE.exec(str)) ? m[0] : undefined
}
function parseWeight(str){
return (m = numWeightRE.exec(str)) ? parseInt(m[0]) || NaN
: (m = namedWeightRE.exec(str)) ? weightMap[m[0]]
: NaN
}
function parseVariant(str){
if (cache.variant[str]===undefined){
let variants = [],
features = {on:[], off:[]};
for (let token of splitBy(str, /\s+/)){
if (token == 'normal'){
return {variants:[token], features:{on:[], off:[]}}
}else if (token in featureMap){
featureMap[token].forEach(feat => {
if (feat[0] == '-') features.off.push(feat.slice(1))
else features.on.push(feat)
})
variants.push(token);
}else if (m = parameterizedRE.exec(token)){
let subPattern = alternatesMap[m[1]],
subValue = Math.max(0, Math.min(99, parseInt(m[2], 10))),
[feat, val] = subPattern.replace(/##/, subValue < 10 ? '0'+subValue : subValue)
.replace(/#/, Math.min(9, subValue)).split(' ');
if (typeof val=='undefined') features.on.push(feat)
else features[feat] = parseInt(val, 10)
variants.push(`${m[1]}(${subValue})`)
}else{
throw new Error(`Invalid font variant "${token}"`)
}
}
cache.variant[str] = {variant:variants.join(' '), features:features};
}
return cache.variant[str];
}
function parseTextDecoration(str){
let style = 'solid',
line = 'none',
color = 'currentColor',
inherit = 'auto',
thickness,
_val
str = (typeof str=='string' ? str : '').trim().replace(/\s+/, ' ')
for (const token of str.split(' ')){
if (token.match(/solid|double|dotted|dashed|wavy/)) style = token
else if (token.match(/none|initial|revert(-layer)?|unset/)) line = "none"
else if (token.match(/underline|overline|line-through/)) line = token
else if (_val = parseFlexibleSize(token)) thickness = _val
else if (token.match(/auto|from-font/)) inherit = token
else color = token
}
return { style, line, color, thickness, inherit, str }
}
// -- Window Types -----------------------------------------------------------------------
let cursorTypes = [
"default", "none", "context-menu", "help", "pointer", "progress", "wait", "cell", "crosshair",
"text", "vertical-text", "alias", "copy", "move", "no-drop", "not-allowed", "grab", "grabbing",
"e-resize", "n-resize", "ne-resize", "nw-resize", "s-resize", "se-resize", "sw-resize", "w-resize",
"ew-resize", "ns-resize", "nesw-resize", "nwse-resize", "col-resize", "row-resize", "all-scroll",
"zoom-in", "zoom-out",
]
function parseCursor(str){
return cursorTypes.includes(str)
}
function parseFit(mode){
return ["none", "contain-x", "contain-y", "contain", "cover", "fill", "scale-down", "resize"].includes(mode)
}
// -- Corner Rounding
// https://github.com/fserb/canvas2D/blob/master/spec/roundrect.md
function parseCornerRadii(r){
r = [r].flat()
.slice(0, 4)
.map(n => n && Object.hasOwn(n, 'x') && Object.hasOwn(n, 'y') ? n : {x:n, y:n})
if (r.some(pt => !Number.isFinite(pt.x) || !Number.isFinite(pt.y))){
return null // silently abort
}else if (r.some(pt => pt.x < 0 || pt.y < 0)){
throw RangeError("Corner radius cannot be negative")
}
return r.length == 1 ? [r[0], r[0], r[0], r[0]]
: r.length == 2 ? [r[0], r[1], r[0], r[1]]
: r.length == 3 ? [r[0], r[1], r[2], r[1]]
: r.length == 4 ? [r[0], r[1], r[2], r[3]]
: [0, 0, 0, 0].map(n => ({x:n, y:n}))
}
// -- Image Filters -----------------------------------------------------------------------
// https://developer.mozilla.org/en-US/docs/Web/CSS/filter
var plainFilterRE = /(blur|hue-rotate|brightness|contrast|grayscale|invert|opacity|saturate|sepia)\((.*?)\)/,
shadowFilterRE = /drop-shadow\((.*)\)/,
percentValueRE = /^(\+|-)?\d+%$/,
angleValueRE = /([\d\.]+)(deg|g?rad|turn)/;
function parseFilter(str){
let filters = {}
let canonical = []
for (var spec of splitBy(str, /\s+/) || []){
if (m = shadowFilterRE.exec(spec)){
let kind = 'drop-shadow',
args = m[1].trim().split(/\s+/),
lengths = args.slice(0,3),
color = args.slice(3).join(' '),
dims = lengths.map(s => parseSize(s)).filter(isFinite);
if (dims.length==3 && !!color){
filters[kind] = [...dims, color]
canonical.push(`${kind}(${lengths.join(' ')} ${color.replace(/ /g,'')})`)
}
}else if (m = plainFilterRE.exec(spec)){
let [kind, arg] = m.slice(1)
let val = kind=='blur' ? parseSize(arg)
: kind=='hue-rotate' ? parseAngle(arg)
: parsePercentage(arg);
if (isFinite(val)){
filters[kind] = val
canonical.push(`${kind}(${arg.trim()})`)
}
}
}
return str.trim() == 'none' ? {canonical:'none', filters}
: canonical.length ? {canonical:canonical.join(' '), filters}
: null
}
function parsePercentage(str){
return percentValueRE.test(str.trim()) ? parseInt(str, 10) / 100
: !isNaN(str) ? parseFloat(str)
: NaN
}
function parseAngle(str){
if (m = angleValueRE.exec(str.trim())){
let [amt, unit] = [parseFloat(m[1]), m[2]]
return unit== 'deg' ? amt
: unit== 'rad' ? 360 * amt / (2 * Math.PI)
: unit=='grad' ? 360 * amt / 400
: unit=='turn' ? 360 * amt
: NaN
}
}
//
// Font attribute keywords & corresponding values
//
const weightMap = {
"lighter":300,
"normal":400,
"bold":700,
"bolder":800
}
const sizeMap = {
"xx-small":3/5,
"x-small":3/4,
"small":8/9,
"smaller":8/9,
"large":6/5,
"larger":6/5,
"x-large":3/2,
"xx-large":2/1,
"normal": 1.2 // special case for lineHeight
}
const featureMap = {
"normal": [],
// font-variant-ligatures
"common-ligatures": ["liga", "clig"],
"no-common-ligatures": ["-liga", "-clig"],
"discretionary-ligatures": ["dlig"],
"no-discretionary-ligatures": ["-dlig"],
"historical-ligatures": ["hlig"],
"no-historical-ligatures": ["-hlig"],
"contextual": ["calt"],
"no-contextual": ["-calt"],
// font-variant-position
"super": ["sups"],
"sub": ["subs"],
// font-variant-caps
"small-caps": ["smcp"],
"all-small-caps": ["c2sc", "smcp"],
"petite-caps": ["pcap"],
"all-petite-caps": ["c2pc", "pcap"],
"unicase": ["unic"],
"titling-caps": ["titl"],
// font-variant-numeric
"lining-nums": ["lnum"],
"oldstyle-nums": ["onum"],
"proportional-nums": ["pnum"],
"tabular-nums": ["tnum"],
"diagonal-fractions": ["frac"],
"stacked-fractions": ["afrc"],
"ordinal": ["ordn"],
"slashed-zero": ["zero"],
// font-variant-east-asian
"jis78": ["jp78"],
"jis83": ["jp83"],
"jis90": ["jp90"],
"jis04": ["jp04"],
"simplified": ["smpl"],
"traditional": ["trad"],
"full-width": ["fwid"],
"proportional-width": ["pwid"],
"ruby": ["ruby"],
// font-variant-alternates (non-parameterized)
"historical-forms": ["hist"],
}
const alternatesMap = {
"stylistic": "salt #",
"styleset": "ss##",
"character-variant": "cv##",
"swash": "swsh #",
"ornaments": "ornm #",
"annotation": "nalt #",
}
module.exports = {
// used by context
font:parseFont,
variant:parseVariant,
size:parseSize,
spacing:parseFlexibleSize,
stretch:parseStretch,
decoration:parseTextDecoration,
filter:parseFilter,
// path & context
radii:parseCornerRadii,
// gui
cursor:parseCursor,
fit:parseFit,
}