diff --git a/README.md b/README.md index fd020e69..c468da61 100644 --- a/README.md +++ b/README.md @@ -192,12 +192,17 @@ Create a Path that represents the given text. * `x`: Horizontal position of the beginning of the text. (default: `0`) * `y`: Vertical position of the *baseline* of the text. (default: `0`) * `fontSize`: Size of the text in pixels (default: `72`). +* `options`: _{GlyphRenderOptions}_ passed to each glyph, see below -Options is an optional object containing: +Options is an optional _{GlyphRenderOptions}_ object containing: +* `script`: script used to determine which features to apply (default: `"DFLT"` or `"latn"`) +* `language`: language system used to determine which features to apply (default: `"dflt"`) * `kerning`: if true takes kerning information into account (default: `true`) * `features`: an object with [OpenType feature tags](https://docs.microsoft.com/en-us/typography/opentype/spec/featuretags) as keys, and a boolean value to enable each feature. Currently only ligature features `"liga"` and `"rlig"` are supported (default: `true`). * `hinting`: if true uses TrueType font hinting if available (default: `false`). +* `colorFormat`: the format colors are converted to for rendering (default: `"hexa"`). Can be `"rgb"`/`"rgba"` for `rgb()`/`rgba()` output, `"hex"`/`"hexa"` for 6/8 digit hex colors, or `"hsl"`/`"hsla"` for `hsl()`/`hsla()` output. `"bgra"` outputs an object with r, g, b, a keys (r/g/b from 0-255, a from 0-1). `"raw"` outputs an integer as used in the CPAL table. +* `fill`: font color, the color used to render each glyph (default: `"black"`) _**Note:** there is also `Font.getPaths()` with the same arguments, which returns a list of Paths._ @@ -207,6 +212,7 @@ Create a Path that represents the given text. * `x`: Horizontal position of the beginning of the text. (default: `0`) * `y`: Vertical position of the *baseline* of the text. (default: `0`) * `fontSize`: Size of the text in pixels (default: `72`). +* `options`: _{GlyphRenderOptions}_ passed to each glyph, see `Font.getPath()` Options is an optional object containing: * `kerning`: if `true`, takes kerning information into account (default: `true`) @@ -244,7 +250,101 @@ bounding box than its advance width. This corresponds to `canvas2dContext.measureText(text).width` * `fontSize`: Size of the text in pixels (default: `72`). -* `options`: See `Font.getPath()` +* `options`: _{GlyphRenderOptions}_, see `Font.getPath()` + +#### The `Font.palettes` object (`PaletteManager`) + +This allows to manage the palettes and colors in the CPAL table, without having to modify the table manually. + +###### `Font.palettes.add(colors)` +Add a new palette. +* `colors`: (optional) colors to add to the palette, differences to existing palettes will be filled with the defaultValue. + +###### `Font.palettes.delete(paletteIndex)` +Deletes a palette by its zero-based index +* `paletteIndex`: zero-based palette index + +###### `Font.palettes.deleteColor(colorIndex, replacementIndex)` +Deletes a specific color index in all palettes and updates all layers using that color with the color currently held in the replacement index +* `colorIndex`: index of the color that should be deleted +* `replacementIndex`: index (according to the palette before deletion) of the color to replace in layers using the color to be to deleted + +###### `Font.palettes.cpal()` +Returns the font's cpal table, or false if it does not exist. Used internally. + +###### `Font.palettes.ensureCPAL(colors)` +Mainly used internally. Makes sure that the CPAL table exists or is populated with default values. +* `colors`: (optional) colors to populate on creation +returns `true` if it was created, `false` if it already existed. + +###### `Font.palettes.extend(num)` +Extend all existing palettes and the numPaletteEntries value by a number of color slots +* `num`: number of additional color slots to add to all palettes + +###### `Font.palettes.fillPalette(palette, colors, colorCount)` +Fills a set of palette colors (from a palette index, or a provided array of CPAL color values) with a set of colors, falling back to the default color value, until a given count. *It does not modify the existing palette, returning a new array instead!* Use `Font.palettes.setColor()` instead if needed. +* `palette`: palette index or an Array of CPAL color values to fill the palette with, the rest will be filled with the default color +* `colors`: array of color values to fill the palette with, in a format supported as an output of `colorFormat` in _{GlyphRenderOptions}_, see `Font.getPath()`. CSS color names are also supported in browser context. +* `colorCount`: Number of colors to fill the palette with, defaults to the value of the numPaletteEntries field + +###### `Font.palettes.getAll(colorFormat)` +Returns an array of arrays of color values for each palette, optionally in a specified color format +* `colorFormat`: (optional) See _{GlyphRenderOptions}_ at `Font.getPath()`, (default: `"hexa"`) + +###### `Font.palettes.getColor(index, paletteIndex, colorFormat)` +Get a specific palette by its zero-based index +* `index`: zero-based index of the color in the palette +* `paletteIndex`: zero-based palette index (default: 0) +* `colorFormat`: (optional) See _{GlyphRenderOptions}_ at `Font.getPath()`, (default: `"hexa"`) + +###### `Font.palettes.get(paletteIndex, colorFormat)` +Get a specific palette by its zero-based index +* `paletteIndex`: zero-based palette index +* `colorFormat`: (optional) See _{GlyphRenderOptions}_ at `Font.getPath()`, (default: `"hexa"`) + +###### `Font.palettes.setColor(index, colors, paletteIndex)` +Set one or more colors on a specific palette by its zero-based index +* `index`: zero-based color index to start filling from +* `color`: color value or array of color values in a color notation supported as an output of `colorFormat` in _{GlyphRenderOptions}_, see `Font.getPath()`. CSS color names are also supported in browser context. +* `paletteIndex`: zero-based palette index (default: 0) + +###### `Font.palettes.toCPALcolor(color)` +Converts a color value string to a CPAL integer color value +* `color`: string in a color notation supported as an output of `colorFormat` in _{GlyphRenderOptions}_, see `Font.getPath()`. CSS color names are also supported in browser context. + + +##### The `Font.layers` object (`LayerManager`) + +This allows to manage the color glyph layers in the COLR table, without having to modify the table manually. + +###### `Font.layers.add(glyphIndex, layers, position)` +Adds one or more layers to a glyph, at the end or at a specific position. +* `glyphIndex`: glyph index to add the layer(s) to. +* `layers`: layer object {glyph, paletteIndex}/{glyphID, paletteIndex} or array of layer objects. +* `position`: position to insert the layers at (will default to adding at the end). + +###### `Font.layers.ensureCOLR()` +Mainly used internally. Ensures that the COLR table exists and is populated with default values. + +###### `Font.layers.get(glyphIndex)` +Gets the layers for a specific glyph +* `glyphIndex` +Returns an array of `{glyph, paletteIndex}` layer objects. + +###### `Font.layers.remove(glyphIndex, start, end = start)` +Removes one or more layers from a glyph. +* `glyphIndex`: glyph index to remove the layer(s) from +* `start`: index to remove the layer at +* `end`: (optional) if provided, removes all layers from start index to (and including) end index + +###### `Font.layers.setPaletteIndex(glyphIndex, layerIndex, paletteIndex)` +Sets a color glyph layer's paletteIndex property to a new index +* `glyphIndex`: glyph in the font by zero-based glyph index +* `layerIndex`: layer in the glyph by zero-based layer index +* `paletteIndex`: new color to set for the layer by zero-based index in any palette + +###### `Font.layers.updateColrTable(glyphIndex, layers)` +Mainly used internally. Updates the colr table, adding a baseGlyphRecord if needed, ensuring that it's inserted at the correct position, updating numLayers, and adjusting firstLayerIndex values for all baseGlyphRecords according to any deletions or insertions. #### The Glyph object A Glyph is an individual mark that often corresponds to a character. Some glyphs, such as ligatures, are a combination of many characters. Glyphs are the basic building blocks of a font. @@ -259,24 +359,28 @@ A Glyph is an individual mark that often corresponds to a character. Some glyphs * `xMin`, `yMin`, `xMax`, `yMax`: The bounding box of the glyph. * `path`: The raw, unscaled path of the glyph. -##### `Glyph.getPath(x, y, fontSize)` +##### `Glyph.getPath(x, y, fontSize, options, font)` Get a scaled glyph Path object for use on a drawing context. * `x`: Horizontal position of the glyph. (default: `0`) * `y`: Vertical position of the *baseline* of the glyph. (default: `0`) * `fontSize`: Font size in pixels (default: `72`). +* `options`: _{GlyphRenderOptions}_, see `Font.getPath()` +* `font`: a font object, needed for rendering COLR/CPAL fonts to get the layers and colors ##### `Glyph.getBoundingBox()` Calculate the minimum bounding box for the unscaled path of the given glyph. Returns an `opentype.BoundingBox` object that contains `x1`/`y1`/`x2`/`y2`. If the glyph has no points (e.g. a space character), all coordinates will be zero. -##### `Glyph.draw(ctx, x, y, fontSize)` +##### `Glyph.draw(ctx, x, y, fontSize, options, font)` Draw the glyph on the given context. * `ctx`: The drawing context. * `x`: Horizontal position of the glyph. (default: `0`) * `y`: Vertical position of the *baseline* of the glyph. (default: `0`) * `fontSize`: Font size, in pixels (default: `72`). +* `options`: _{GlyphRenderOptions}_, see `Font.getPath()` +* `font`: a font object, needed for rendering COLR/CPAL fonts to get the layers and colors -##### `Glyph.drawPoints(ctx, x, y, fontSize)` +##### `Glyph.drawPoints(ctx, x, y, fontSize, options, font)` Draw the points of the glyph on the given context. On-curve points will be drawn in blue, off-curve points will be drawn in red. The arguments are the same as `Glyph.draw()`. @@ -291,6 +395,9 @@ The arguments are the same as `Glyph.draw()`. ##### `Glyph.toPathData(options)`, `Glyph.toDOMElement(options)`, `Glyph.toSVG(options)`, `Glyph.fromSVG(pathData, options)`, These are currently only wrapper functions for their counterparts on Path objects (see documentation there), but may be extended in the future to pass on Glyph data for automatic calculation. +##### `Glyph.getLayers(font)` +Gets the color glyph layers for this glyph from the specified font's COLR/CPAL tables + ### The Path object Once you have a path through `Font.getPath()` or `Glyph.getPath()`, you can use it. diff --git a/docs/examples/creating-fonts.html b/docs/examples/creating-fonts.html index 88948f3b..78677eba 100644 --- a/docs/examples/creating-fonts.html +++ b/docs/examples/creating-fonts.html @@ -139,7 +139,7 @@

var x = 50; var y = 120; var fontSize = 72; - glyph.draw(ctx, x, y, fontSize); + glyph.draw(ctx, x, y, fontSize, {}, font2); glyph.drawPoints(ctx, x, y, fontSize); glyph.drawMetrics(ctx, x, y, fontSize); } diff --git a/docs/examples/reading-writing.html b/docs/examples/reading-writing.html index d7c27d9b..5e56b0b0 100644 --- a/docs/examples/reading-writing.html +++ b/docs/examples/reading-writing.html @@ -71,7 +71,7 @@

const x = 50; const y = 120; const fontSize = 72; - glyph.draw(ctx, x, y, fontSize); + glyph.draw(ctx, x, y, fontSize, {}, font2); glyph.drawPoints(ctx, x, y, fontSize); glyph.drawMetrics(ctx, x, y, fontSize); } diff --git a/docs/font-inspector.html b/docs/font-inspector.html index 52e1ea14..b515dd5e 100644 --- a/docs/font-inspector.html +++ b/docs/font-inspector.html @@ -76,10 +76,17 @@
Undefined
+ + +
Undefined

+ after modifying window.font + +
+

Free Software

opentype.js is available on GitHub under the MIT License.

@@ -127,7 +134,7 @@

Free Software

var previewCanvas = document.getElementById('preview'); var previewCtx = previewCanvas.getContext("2d"); previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height); - font.draw(previewCtx, textToRender, 0, 32, fontSize, { + var options = { kerning: true, features: [ /** @@ -137,7 +144,9 @@

Free Software

{ script: 'arab', tags: ['init', 'medi', 'fina', 'rlig'] }, { script: 'latn', tags: ['liga', 'rlig'] } ] - }); + }; + options = Object.assign({}, font.defaultRenderOptions, options); + font.draw(previewCtx, textToRender, 0, 32, fontSize, options); } function showErrorMessage(message) { @@ -193,6 +202,9 @@

Free Software

function displayFontData(font) { var html, tablename, table, property, value; + // reset all lists first + document.querySelectorAll('#font-data dl').forEach( dl => dl.innerHTML = '
Undefined
' ); + for (tablename in font.tables) { table = font.tables[tablename]; if (tablename == 'name') { @@ -231,6 +243,21 @@

Free Software

element.innerHTML = '
' + Object.keys(font.kerningPairs).length + ' Pairs
' + JSON.stringify(font.kerningPairs) + '
'; } } + + if(font.tables.cpal) { + let markup = ' '; + const cpal = document.getElementById("cpal-table"); + const dt = cpal.querySelector('dt'); + if ( dt ) { + const palettes = font.palettes.getAll('hexa'); + if ( palettes.length ) { + cpal.innerHTML += palettes.map((palette, idx) => + `
↪ Palette ${idx}
`+ + palette.map(color => ``).join('')+ + `
`).join(''); + } + } + } } function onFontLoaded(font) { @@ -239,6 +266,8 @@

Free Software

displayFontData(font); } +document.getElementById('update').addEventListener('click', () => displayFontData(window.font)); + var tableHeaders = document.getElementById('font-data').getElementsByTagName('h3'); for(var i = tableHeaders.length; i--; ) { tableHeaders[i].addEventListener('click', function(e) { diff --git a/docs/glyph-inspector.html b/docs/glyph-inspector.html index 60732306..c44680fc 100644 --- a/docs/glyph-inspector.html +++ b/docs/glyph-inspector.html @@ -32,6 +32,11 @@

Glyph Inspector


+ Glyphs
@@ -47,6 +52,10 @@

Glyph Inspector


+ after modifying window.font + +
+

Free Software

opentype.js is available on GitHub under the MIT License.

@@ -60,6 +69,8 @@

Free Software

diff --git a/docs/index.html b/docs/index.html index ed92e27e..2cadad0f 100755 --- a/docs/index.html +++ b/docs/index.html @@ -46,8 +46,8 @@

opentype.js

- - + +
@@ -81,6 +81,13 @@

Free Software

function doSnap(path) { + const layers = path._layers; + if ( layers && layers.length ) { + for( let l = 0; l < layers.length; l++ ) { + doSnap(layers[l]); + } + return; + } // Round a value to the nearest "step". const snap = (v, distance, strength) => (v * (1.0 - strength)) + (strength * Math.round(v / distance) * distance); const strength = +form.snapStrength.value / 100.0; @@ -117,6 +124,7 @@

Free Software

rlig: form.ligatures.checked } }; + options = Object.assign({}, font.defaultRenderOptions, options); previewCtx.clearRect(0, 0, 940, 300); const fontSize = +form.fontsize.value; font.draw(previewCtx, textToRender, 0, 200, fontSize, options); @@ -189,7 +197,7 @@

Free Software

for (let i = 0; i < amount; i++) { const glyph = font.glyphs.get(i); const ctx = createGlyphCanvas(glyph, 150); - glyph.draw(ctx, x, y, fontSize); + glyph.draw(ctx, x, y, fontSize, {}, font); glyph.drawPoints(ctx, x, y, fontSize); glyph.drawMetrics(ctx, x, y, fontSize); } diff --git a/docs/site.css b/docs/site.css index 07693892..0d2d90a4 100644 --- a/docs/site.css +++ b/docs/site.css @@ -203,11 +203,13 @@ a { #font-data dt { float: left; + clear: both; } #font-data dd { margin-left: 12em; word-break: break-all; + min-height: 1.4em; max-height: 100px; overflow-y: auto; } @@ -230,10 +232,16 @@ a { /* Glyph Inspector */ +#pagination { + display: flex; + flex-wrap: wrap; +} + #pagination span { margin: 0 0.3em; color: #505050; cursor: pointer; + white-space: nowrap; } #pagination span.page-selected { @@ -278,16 +286,13 @@ canvas.item:hover { display: flex; flex-flow: column nowrap; height: 500px; + width: 400px; + overflow-y: auto; } #glyph-data dl { margin: 0; } #glyph-data dt { float: left; } #glyph-data dd { margin-left: 12em; } -#glyph-contours { - flex: 1 1 0; - overflow: auto; -} - #glyph-data pre { font-size: 11px; } pre:not(.contour) { margin: 0; } pre.contour { margin: 0 0 1em 2em; border-bottom: solid 1px #a0a0a0; } @@ -297,3 +302,74 @@ span.offcurve { color: red; } .disabled { color: gray; } + +.color-swatches { + display: flex; + flex-wrap: wrap; + gap: 0.2em; +} + +.color-swatch { + display: inline-block; + width: 1em; + height: 1em; + border: 1px solid #000; + padding: 0; + text-align: center; + line-height: 105%; + cursor: pointer; +} + +button.color-swatch { + border: 0; +} + +.color-swatch dialog { + position: absolute; + overflow: visible; + padding: 0; +} + +.color-swatch dialog > :first-child { + padding: 1em; + cursor: auto; +} + +.color-swatch dialog::backdrop { + background: transparent; +} + +.color-swatch dialog input[type="color"] { + padding: 0; + margin: -2px 5px 0 0; + border: 0; + block-size: 2em; + vertical-align: middle; +} + +.color-swatches button.color-swatch { + +} + +.color-swatch dialog::before { + content: ''; + position: absolute; + top: -8px; /* Adjust as necessary to place the arrow outside the dialog. */ + left: -2px; /* Adjust based on where you want the arrow in relation to the left side. */ + border-left: 7px solid transparent; /* Adjust size to match the desired arrow size. */ + border-right: 7px solid transparent; /* Adjust size to match the desired arrow size. */ + border-bottom: 7px solid black; /* Arrow color and size. Adjust the size as necessary. */ +} + +#palettes dd { + margin-inline-start: 0.5em; +} + +#palettes label { + margin-right: 0; +} + +#layers canvas.item:hover { + cursor: default; + background-color: transparent; +} \ No newline at end of file diff --git a/src/font.js b/src/font.js index b232ff19..e0963a5d 100644 --- a/src/font.js +++ b/src/font.js @@ -6,6 +6,8 @@ import { DefaultEncoding } from './encoding.js'; import glyphset from './glyphset.js'; import Position from './position.js'; import Substitution from './substitution.js'; +import { PaletteManager } from './palettes.js'; +import { LayerManager } from './layers.js'; import { isBrowser, checkArgument } from './util.js'; import HintingTrueType from './hintingtt.js'; import Bidi from './bidi.js'; @@ -138,6 +140,8 @@ function Font(options) { this.position = new Position(this); this.substitution = new Substitution(this); this.tables = this.tables || {}; + this.palettes = new PaletteManager(this); + this.layers = new LayerManager(this); // needed for low memory mode only. this._push = null; @@ -326,6 +330,9 @@ Font.prototype.getKerningValue = function(leftGlyph, rightGlyph) { * @property {boolean} [kerning=true] - whether to include kerning values * @property {object} [features] - OpenType Layout feature tags. Used to enable or disable the features of the given script/language system. * See https://www.microsoft.com/typography/otspec/featuretags.htm + * @property {boolean} [hinting=false] - whether to apply font hinting to the outlines + * @property {integer} [usePalette=0] For COLR/CPAL fonts, the zero-based index of the color palette to use. (Use `Font.palettes.get()` to get the available palettes) + * @property {integer} [drawLayers=true] For COLR/CPAL fonts, this can be turned to false in order to draw the fallback glyphs instead */ Font.prototype.defaultRenderOptions = { kerning: true, @@ -337,7 +344,10 @@ Font.prototype.defaultRenderOptions = { { script: 'arab', tags: ['init', 'medi', 'fina', 'rlig'] }, { script: 'latn', tags: ['liga', 'rlig'] }, { script: 'thai', tags: ['liga', 'rlig', 'ccmp'] }, - ] + ], + hinting: false, + usePalette: 0, + drawLayers: true, }; /** @@ -397,7 +407,9 @@ Font.prototype.forEachGlyph = function(text, x, y, fontSize, options, callback) * @return {opentype.Path} */ Font.prototype.getPath = function(text, x, y, fontSize, options) { + options = Object.assign({}, this.defaultRenderOptions, options); const fullPath = new Path(); + fullPath._layers = []; applyPaintType(this, fullPath, fontSize); if (fullPath.stroke) { const scale = 1 / (fullPath.unitsPerEm || 1000) * fontSize; @@ -405,6 +417,16 @@ Font.prototype.getPath = function(text, x, y, fontSize, options) { } this.forEachGlyph(text, x, y, fontSize, options, function(glyph, gX, gY, gFontSize) { const glyphPath = glyph.getPath(gX, gY, gFontSize, options, this); + if ( options.drawLayers ) { + const layers = glyphPath._layers; + if ( layers && layers.length ) { + for(let l = 0; l < layers.length; l++) { + const layer = layers[l]; + fullPath._layers.push(layer); + } + return; + } + } fullPath.extend(glyphPath); }); return fullPath; @@ -420,6 +442,7 @@ Font.prototype.getPath = function(text, x, y, fontSize, options) { * @return {opentype.Path[]} */ Font.prototype.getPaths = function(text, x, y, fontSize, options) { + options = Object.assign({}, this.defaultRenderOptions, options); const glyphPaths = []; this.forEachGlyph(text, x, y, fontSize, options, function(glyph, gX, gY, gFontSize) { const glyphPath = glyph.getPath(gX, gY, gFontSize, options, this); @@ -445,6 +468,7 @@ Font.prototype.getPaths = function(text, x, y, fontSize, options) { * @return advance width */ Font.prototype.getAdvanceWidth = function(text, fontSize, options) { + options = Object.assign({}, this.defaultRenderOptions, options); return this.forEachGlyph(text, 0, 0, fontSize, options, function() {}); }; @@ -458,7 +482,8 @@ Font.prototype.getAdvanceWidth = function(text, fontSize, options) { * @param {GlyphRenderOptions=} options */ Font.prototype.draw = function(ctx, text, x, y, fontSize, options) { - this.getPath(text, x, y, fontSize, options).draw(ctx); + const path = this.getPath(text, x, y, fontSize, options); + path.draw(ctx); }; /** @@ -472,8 +497,9 @@ Font.prototype.draw = function(ctx, text, x, y, fontSize, options) { * @param {GlyphRenderOptions=} options */ Font.prototype.drawPoints = function(ctx, text, x, y, fontSize, options) { + options = Object.assign({}, this.defaultRenderOptions, options); this.forEachGlyph(text, x, y, fontSize, options, function(glyph, gX, gY, gFontSize) { - glyph.drawPoints(ctx, gX, gY, gFontSize); + glyph.drawPoints(ctx, gX, gY, gFontSize, options, this); }); }; @@ -490,6 +516,7 @@ Font.prototype.drawPoints = function(ctx, text, x, y, fontSize, options) { * @param {GlyphRenderOptions=} options */ Font.prototype.drawMetrics = function(ctx, text, x, y, fontSize, options) { + options = Object.assign({}, this.defaultRenderOptions, options); this.forEachGlyph(text, x, y, fontSize, options, function(glyph, gX, gY, gFontSize) { glyph.drawMetrics(ctx, gX, gY, gFontSize); }); @@ -515,6 +542,7 @@ Font.prototype.validate = function() { function assert(predicate, message) { if (!predicate) { + console.warn(`[opentype.js] ${message}`); warnings.push(message); } } @@ -534,6 +562,21 @@ Font.prototype.validate = function() { // Dimension information assert(this.unitsPerEm > 0, 'No unitsPerEm specified.'); + + if (this.tables.colr) { + const baseGlyphs = this.tables.colr.baseGlyphRecords; + let previousID = -1; + for(let b = 0; b < baseGlyphs.length; b++) { + const currentGlyphID = baseGlyphs[b].glyphID; + assert(previousID < baseGlyphs[b].glyphID, `baseGlyphs must be sorted by GlyphID in ascending order, but glyphID ${currentGlyphID} comes after ${previousID}`); + if (previousID > baseGlyphs[b].glyphID) { + break; + } + previousID = currentGlyphID; + } + } + + return warnings; }; /** diff --git a/src/glyph.js b/src/glyph.js index ff38f6b3..4fb96030 100644 --- a/src/glyph.js +++ b/src/glyph.js @@ -3,6 +3,7 @@ import check from './check.js'; import draw from './draw.js'; import Path from './path.js'; +import { getPaletteColor, formatColor } from './tables/cpal.js'; // import glyf from './tables/glyf' Can't be imported here, because it's a circular dependency function getPathDefinition(glyph, path) { @@ -132,17 +133,17 @@ Glyph.prototype.getBoundingBox = function() { * @param {number} [x=0] - Horizontal position of the beginning of the text. * @param {number} [y=0] - Vertical position of the *baseline* of the text. * @param {number} [fontSize=72] - Font size in pixels. We scale the glyph units by `1 / unitsPerEm * fontSize`. - * @param {Object=} options - xScale, yScale to stretch the glyph. - * @param {opentype.Font} if hinting is to be used, the font + * @param {GlyphRenderOptions=} options - xScale, yScale to stretch the glyph. + * @param {opentype.Font} font if hinting is to be used, or CPAL/COLR needs to be rendered, the font * @return {opentype.Path} */ Glyph.prototype.getPath = function(x, y, fontSize, options, font) { x = x !== undefined ? x : 0; y = y !== undefined ? y : 0; fontSize = fontSize !== undefined ? fontSize : 72; + options = Object.assign({}, font && font.defaultRenderOptions, options); let commands; let hPoints; - if (!options) options = { }; let xScale = options.xScale; let yScale = options.yScale; const scale = 1 / (this.path.unitsPerEm || 1000) * fontSize; @@ -167,9 +168,29 @@ Glyph.prototype.getPath = function(x, y, fontSize, options, font) { if (xScale === undefined) xScale = scale; if (yScale === undefined) yScale = scale; } - + const p = new Path(); - p.fill = this.path.fill; + if ( options.drawLayers ) { + const layers = this.getLayers(font); + if ( layers && layers.length ) { + p._layers = []; + for ( let i = 0; i < layers.length; i += 1 ) { + const layer = layers[i]; + let color = getPaletteColor(font, layer.paletteIndex, options.usePalette); + + if ( color === 'currentColor' ) { + color = options.fill || 'black'; + } else { + color = formatColor(color, options.colorFormat || 'rgba'); + } + options = Object.assign({}, options, {fill: color}); + p._layers.push(this.getPath.call(layer.glyph, x, y, fontSize, options, font)); + } + return p; + } + } + + p.fill = options.fill || this.path.fill; p.stroke = this.path.stroke; p.strokeWidth = this.path.strokeWidth * scale; for (let i = 0; i < commands.length; i += 1) { @@ -185,7 +206,7 @@ Glyph.prototype.getPath = function(x, y, fontSize, options, font) { p.curveTo(x + (cmd.x1 * xScale), y + (-cmd.y1 * yScale), x + (cmd.x2 * xScale), y + (-cmd.y2 * yScale), x + (cmd.x * xScale), y + (-cmd.y * yScale)); - } else if (cmd.type === 'Z') { + } else if (cmd.type === 'Z' && p.stroke && p.strokeWidth) { p.closePath(); } } @@ -193,6 +214,18 @@ Glyph.prototype.getPath = function(x, y, fontSize, options, font) { return p; }; +/** + * + * @param {opentype.Font} font + * @returns {Array} + */ +Glyph.prototype.getLayers = function(font) { + if(!font) { + throw Error('The font object is required to read the colr/cpal tables in order to get the layers.'); + } + return font.layers.get(this.index); +}; + /** * Split the glyph into contours. * This function is here for backwards compatibility, and to @@ -280,9 +313,12 @@ Glyph.prototype.getMetrics = function() { * @param {number} [y=0] - Vertical position of the *baseline* of the text. * @param {number} [fontSize=72] - Font size in pixels. We scale the glyph units by `1 / unitsPerEm * fontSize`. * @param {Object=} options - xScale, yScale to stretch the glyph. + * @param {opentype.Font} font - xScale, yScale to stretch the glyph. */ -Glyph.prototype.draw = function(ctx, x, y, fontSize, options) { - this.getPath(x, y, fontSize, options).draw(ctx); +Glyph.prototype.draw = function(ctx, x, y, fontSize, options, font) { + options = Object.assign({}, font.defaultRenderOptions, options); + const path = this.getPath(x, y, fontSize, options, font); + path.draw(ctx); }; /** @@ -292,16 +328,30 @@ Glyph.prototype.draw = function(ctx, x, y, fontSize, options) { * @param {number} [x=0] - Horizontal position of the beginning of the text. * @param {number} [y=0] - Vertical position of the *baseline* of the text. * @param {number} [fontSize=72] - Font size in pixels. We scale the glyph units by `1 / unitsPerEm * fontSize`. + * @param {GlyphRenderOptions=} options + * @param {opentype.Font} font - used to get the default render options, may be needed for variable fonts in the future */ -Glyph.prototype.drawPoints = function(ctx, x, y, fontSize) { +Glyph.prototype.drawPoints = function(ctx, x, y, fontSize, options, font) { + options = Object.assign({}, font && font.defaultRenderOptions, options); + if ( options.drawLayers ) { + const layers = this.getLayers(font); + if ( layers && layers.length ) { + for ( let l = 0; l < layers.length; l += 1 ) { + // prevent endless loop: ignore layers with own glyph id + if(layers[l].glyph.index !== this.index) { + this.drawPoints.call(layers[l].glyph, ctx, x, y, fontSize); + } + } + return; + } + } + function drawCircles(l, x, y, scale) { ctx.beginPath(); for (let j = 0; j < l.length; j += 1) { ctx.moveTo(x + (l[j].x * scale), y + (l[j].y * scale)); ctx.arc(x + (l[j].x * scale), y + (l[j].y * scale), 2, 0, Math.PI * 2, false); } - - ctx.closePath(); ctx.fill(); } diff --git a/src/glyphset.js b/src/glyphset.js index f4acc393..8691dcc8 100644 --- a/src/glyphset.js +++ b/src/glyphset.js @@ -141,7 +141,7 @@ function ttfGlyphLoader(font, index, parseGlyph, data, position, buildPath) { defineDependentProperty(glyph, 'xMax', '_xMax'); defineDependentProperty(glyph, 'yMin', '_yMin'); defineDependentProperty(glyph, 'yMax', '_yMax'); - + return glyph; }; } diff --git a/src/layers.js b/src/layers.js new file mode 100644 index 00000000..1796304a --- /dev/null +++ b/src/layers.js @@ -0,0 +1,223 @@ +import { binarySearch, binarySearchIndex, binarySearchInsert } from './util.js'; + +export class LayerManager { + // private properties don't work with reify + // @TODO: refactor once we migrated to ES6 modules, see https://github.com/opentypejs/opentype.js/pull/579 + // #font = null; + + constructor(font) { + this.font = font; + } + + /** + * Mainly used internally. Ensures that the COLR table exists and is populated with default values + * @returns the LayerManager's font instance for chaining + */ + ensureCOLR() { + if (!this.font.tables.colr) { + this.font.tables.colr = { + version: 0, + baseGlyphRecords: [], + layerRecords: [], + }; + } + + return this.font; + } + + /** + * Gets the layers for a specific glyph + * @param {integer} glyphIndex + * @returns {Array} array of layer objects {glyph, paletteIndex} + */ + get(glyphIndex) { + const font = this.font; + const layers = []; + const colr = font.tables.colr; + const cpal = font.tables.cpal; + /** ignore colr table if no cpal table is present + * @see https://learn.microsoft.com/en-us/typography/opentype/spec/colr#:~:text=If%20the%20COLR%20table%20is%20present%20in%20a%20font%20but%20no%20CPAL%20table%20exists,%20then%20the%20COLR%20table%20is%20ignored. + */ + if ( ! colr || ! cpal ) { + return layers; + } + + const baseGlyph = binarySearch(colr.baseGlyphRecords, 'glyphID', glyphIndex); + + if ( ! baseGlyph ) { + return layers; + } + + const firstIndex = baseGlyph.firstLayerIndex; + const numLayers = baseGlyph.numLayers; + + for( let l = 0; l < numLayers; l++ ) { + const layer = colr.layerRecords[firstIndex + l]; + layers.push({ + glyph: font.glyphs.get(layer.glyphID), + paletteIndex: layer.paletteIndex, + }); + } + + return layers; + } + + /** + * Adds one or more layers to a glyph, at the end or at a specific position. + * @param {integer} glyphIndex glyph index to add the layer(s) to. + * @param {Array|Object} layers layer object {glyph, paletteIndex}/{glyphID, paletteIndex} or array of layer objects. + * @param {integer?} position position to insert the layers at (will default to adding at the end). + */ + add(glyphIndex, layers, position) { + // Get the current layers for the glyph. + const currentLayers = this.get(glyphIndex); + + // Normalize layers to an array. + layers = Array.isArray(layers) ? layers : [layers]; + + // Determine the insertion position. If not specified, append to the end. + if (position === undefined || position === Infinity || position > currentLayers.length) { + position = currentLayers.length; + } else if (position < 0) { + position = (currentLayers.length + 1) + (position % (currentLayers.length + 1)); + if (position >= currentLayers.length + 1) { + position -= (currentLayers.length + 1); + } + } + + // Build a new layers array with the additional layer(s) inserted. + const newLayers = []; + for (let i = 0; i < position; i++) { + const glyphID = Number.isInteger(currentLayers[i].glyph) ? currentLayers[i].glyph : currentLayers[i].glyph.index; + newLayers.push({ + glyphID, + paletteIndex: currentLayers[i].paletteIndex, + }); + } + for (const layer of layers) { + const glyphID = Number.isInteger(layer.glyph) ? layer.glyph : layer.glyph.index; + newLayers.push({ + glyphID, + paletteIndex: layer.paletteIndex, + }); + } + for (let i = position; i < currentLayers.length; i++) { + const glyphID = Number.isInteger(currentLayers[i].glyph) ? currentLayers[i].glyph : currentLayers[i].glyph.index; + newLayers.push({ + glyphID, + paletteIndex: currentLayers[i].paletteIndex, + }); + } + + // Update the COLR table with the new layers array. + this.updateColrTable(glyphIndex, newLayers); + } + + /** + * Sets a color glyph layer's paletteIndex property to a new index + * @param {integer} glyphIndex glyph in the font by zero-based glyph index + * @param {integer} layerIndex layer in the glyph by zero-based layer index + * @param {integer} paletteIndex new color to set for the layer by zero-based index in any palette + */ + setPaletteIndex(glyphIndex, layerIndex, paletteIndex) { + let layers = this.get(glyphIndex); + if (layers[layerIndex]) { + layers = layers.map((layer, index) => ({ + glyphID: layer.glyph.index, + paletteIndex: index === layerIndex ? paletteIndex : layer.paletteIndex, + })); + + this.updateColrTable(glyphIndex, layers); + } else { + console.error('Invalid layer index'); + } + } + + /** + * Removes one or more layers from a glyph. + * @param {integer} glyphIndex glyph index to remove the layer(s) from + * @param {integer} start index to remove the layer at + * @param {integer?} end (optional) if provided, removes all layers from start index to (and including) end index + */ + remove(glyphIndex, start, end = start) { + // Get the current layers for the glyph. + let currentLayers = this.get(glyphIndex); + + // Convert to the expected format for updateColrTable if necessary. + currentLayers = currentLayers.map(layer => ({ + glyphID: layer.glyph.index, + paletteIndex: layer.paletteIndex, + })); + + // Directly remove the specified range from the currentLayers array. + // Splice modifies the array in place and removes elements between start and end indices. + currentLayers.splice(start, end - start + 1); + + // Update the COLR table with the modified layers array. + this.updateColrTable(glyphIndex, currentLayers); + } + + /** + * Mainly used internally. Mainly used internally. Updates the colr table, adding a baseGlyphRecord if needed, + * ensuring that it's inserted at the correct position, updating numLayers, and adjusting firstLayerIndex values + * for all baseGlyphRecords according to any deletions or insertions. + * @param {integer} glyphIndex + * @param {Array} layers array of layer objects {glyphID, paletteIndex} + */ + updateColrTable(glyphIndex, layers) { + // Ensure the COLR table exists with the correct structure + this.ensureCOLR(); + + const font = this.font; + const colr = font.tables.colr; + + // Use binarySearchIndex to find the index of the baseGlyphRecord + let index = binarySearchIndex(colr.baseGlyphRecords, 'glyphID', glyphIndex); + const addBaseGlyph = index === -1; + + // If baseGlyphRecord doesn't exist, create and insert one at the correct position + if (addBaseGlyph) { + const newBaseGlyphRecord = { glyphID: glyphIndex, firstLayerIndex: colr.layerRecords.length, numLayers: 0 }; + index = binarySearchInsert(colr.baseGlyphRecords, 'glyphID', newBaseGlyphRecord); + } + + const baseGlyphRecord = colr.baseGlyphRecords[index]; + + const originalNumLayers = baseGlyphRecord.numLayers; + const newNumLayers = layers.length; + const layerDiff = newNumLayers - originalNumLayers; + + // Adjust the layer records accordingly + if (layerDiff > 0) { + // Add new layers + const newLayers = layers.slice(originalNumLayers).map(layer => ({ + glyphID: layer.glyphID, + paletteIndex: layer.paletteIndex, + })); + colr.layerRecords.splice(baseGlyphRecord.firstLayerIndex + originalNumLayers, 0, ...newLayers); + } else if (layerDiff < 0) { + // Remove excess layers + colr.layerRecords.splice(baseGlyphRecord.firstLayerIndex + newNumLayers, -layerDiff); + } + + // Update existing layers + for (let i = 0; i < Math.min(originalNumLayers, newNumLayers); i++) { + colr.layerRecords[baseGlyphRecord.firstLayerIndex + i] = { + glyphID: layers[i].glyphID, + paletteIndex: layers[i].paletteIndex, + }; + } + + // Update the numLayers for the baseGlyphRecord + baseGlyphRecord.numLayers = newNumLayers; + + // Adjust firstLayerIndex for baseGlyphRecords + if (layerDiff !== 0) { + for (let i = 0; i < colr.baseGlyphRecords.length; i++) { + const sibling = colr.baseGlyphRecords[i]; + if (i === index || sibling.firstLayerIndex < baseGlyphRecord.firstLayerIndex) continue; + colr.baseGlyphRecords[i].firstLayerIndex += layerDiff; + } + } + } +} diff --git a/src/opentype.js b/src/opentype.js index 0fd00854..26761276 100644 --- a/src/opentype.js +++ b/src/opentype.js @@ -35,6 +35,7 @@ import os2 from './tables/os2.js'; import post from './tables/post.js'; import meta from './tables/meta.js'; import gasp from './tables/gasp.js'; +import { PaletteManager } from './palettes.js'; /** * The opentype library. * @namespace opentype @@ -474,6 +475,8 @@ function parseBuffer(buffer, opt={}) { font.tables.meta = meta.parse(metaTable.data, metaTable.offset); font.metas = font.tables.meta; } + + font.palettes = new PaletteManager(font); return font; } diff --git a/src/palettes.js b/src/palettes.js new file mode 100644 index 00000000..e1e2fec7 --- /dev/null +++ b/src/palettes.js @@ -0,0 +1,306 @@ +import { getPaletteColor, parseColor, formatColor } from './tables/cpal.js'; + +/** + * @exports opentype.PaletteManager + * @class + * @param {opentype.Font} + */ +export class PaletteManager { + // private properties don't work with reify + // @TODO: refactor once we migrated to ES6 modules, see https://github.com/opentypejs/opentype.js/pull/579 + // #font = null; + + /** + * @type {integer} CPAL color used to (pre)fill unset colors in a palette. + * Format 0xBBGGRRAA + */ + // defaultValue = 0x000000FF; + + /** + * + * @param {opentype.Font} font + */ + constructor(font) { + /** + * @type {integer} CPAL color used to (pre)fill unset colors in a palette. + * Format 0xBBGGRRAA + */ + this.defaultValue = 0x000000FF; + this.font = font; + } + + /** + * Returns the font's cpal table object if present + * @returns {Object} + */ + cpal() { + if (this.font.tables && this.font.tables.cpal) { + return this.font.tables.cpal; + } + return false; + } + + /** + * Returns an array of arrays of color values for each palette, optionally in a specified color format + * @param {string} colorFormat + * @returns {Array} + */ + getAll(colorFormat) { + const palettes = []; + const cpal = this.cpal(); + if (!cpal) return palettes; + + for(let i = 0; i < cpal.colorRecordIndices.length; i++) { + const startIndex = cpal.colorRecordIndices[i]; + const paletteColors = []; + for(let j = startIndex; j < startIndex + cpal.numPaletteEntries; j++) { + paletteColors.push(formatColor(cpal.colorRecords[j], colorFormat || 'hexa')); + } + palettes.push(paletteColors); + } + + return palettes; + } + + /** + * Converts a color value string or array of color value strings to CPAL integer color value(s) + * @param {string|Array} color + * @returns {integer} + */ + toCPALcolor(color) { + if (Array.isArray(color)) { + return color.map((color) => parseColor(color, 'raw')); + } + + return parseColor(color, 'raw'); + } + + /** + * Fills a set of palette colors (from palette index, or a provided array of CPAL color values) with a set of colors, falling back to the default color value, until a given count + * @param {Array|integer} palette Palette index integer or Array of colors to be filled + * @param {Array} colors Colors to fill the palette with + * @param {integer} _colorCount Number of colors to fill the palette with, defaults to the value of the numPaletteEntries field. Used internally by extend() and shouldn't be set manually + * @returns + */ + fillPalette(palette, colors = [], _colorCount = this.cpal().numPaletteEntries) { + palette = Number.isInteger(palette) ? this.get(palette, 'raw') : palette; + return Object.assign(Array(_colorCount).fill(this.defaultValue), this.toCPALcolor(palette).concat(this.toCPALcolor(colors))); + } + + /** + * Extend existing palettes and numPaletteEntries by a number of color slots + * @param {integer} num number of additional color slots to add to all palettes + */ + extend(num) { + if(this.ensureCPAL(Array(num).fill(this.defaultValue))) { + return; + } + const cpal = this.cpal(); + + const newCount = cpal.numPaletteEntries + num; + + const palettes = this.getAll() + .map(palette => this.fillPalette(palette, [], newCount)); + + cpal.numPaletteEntries = newCount; + cpal.colorRecords = this.toCPALcolor(palettes.flat()); + this.updateIndices(); + } + + /** + * Get a specific palette by its zero-based index + * @param {integer} paletteIndex + * @param {string} [colorFormat='hexa'] + * @returns {Array} + */ + get(paletteIndex, colorFormat = 'hexa') { + return this.getAll(colorFormat)[paletteIndex] || null; + } + + /** + * Get a color from a specific palette by its zero-based index + * @param {integer} index + * @param {integer} paletteIndex + * @param {string} [colorFormat ='hexa'] + * @returns + */ + getColor(index, paletteIndex = 0, colorFormat = 'hexa') { + return getPaletteColor(this.font, index, paletteIndex, colorFormat); + } + + /** + * Set one or more colors on a specific palette by its zero-based index + * @param {integer} index zero-based color index to start filling from + * @param {string|integer|Array} color color value or array of color values + * @param {integer} paletteIndex + * @returns + */ + setColor(index, colors, paletteIndex = 0) { + index = parseInt(index); + paletteIndex = parseInt(paletteIndex); + let palettes = this.getAll('raw'); + let palette = palettes[paletteIndex]; + if (!palette) { + throw Error(`paletteIndex ${paletteIndex} out of range`); + } + + const cpal = this.cpal(); + const colorCount = cpal.numPaletteEntries; + + if (!Array.isArray(colors)) { + colors = [colors]; + } + + if (colors.length + index > colorCount) { + this.extend(colors.length + index - colorCount); + palettes = this.getAll('raw'); + palette = palettes[paletteIndex]; + } + + for(let i = 0; i < colors.length; i++) { + palette[i + index] = this.toCPALcolor(colors[i]); + } + cpal.colorRecords = palettes.flat(); + this.updateIndices(); + } + + /** + * Add a new palette. + * @param {Array} colors (optional) colors to add to the palette, differences to existing palettes will be filled with the defaultValue. + * @returns + */ + add(colors) { + if (this.ensureCPAL(colors)) { + return; + } + + const cpal = this.cpal(); + const colorCount = cpal.numPaletteEntries; + if (colors && colors.length) { + colors = this.toCPALcolor(colors); + if (colors.length > colorCount) { + this.extend(colors.length - colorCount); + } else if (colors.length < colorCount) { + colors = this.fillPalette(colors); + } + cpal.colorRecordIndices.push(cpal.colorRecords.length); + cpal.colorRecords.push(...colors); + } else { + cpal.colorRecordIndices.push(cpal.colorRecords.length); + cpal.colorRecords.push(...Array(colorCount).fill(this.defaultValue)); + } + } + + /** + * deletes a palette by its zero-based index + * @param {integer} paletteIndex + */ + delete(paletteIndex) { + const palettes = this.getAll('raw'); + delete palettes[paletteIndex]; + const cpal = this.cpal(); + cpal.colorRecordIndices.pop(); + cpal.colorRecords = palettes.flat(); + } + + /** + * Deletes a specific color index in all palettes and updates all layers using that color with the replacement index + * @param {integer} colorIndex index of the color that should be deleted + * @param {integer} replacementIndex index (according to the palette before deletion) of the color to replace in layers using the color to be to deleted + */ + deleteColor(colorIndex, replacementIndex) { + if(colorIndex === replacementIndex) { + throw Error('replacementIndex cannot be the same as colorIndex'); + } + const cpal = this.cpal(); + const palettes = this.getAll('raw'); + const updatedPalettes = []; + if (replacementIndex > cpal.numPaletteEntries - 1) { + throw Error(`Replacement index out of range: numPaletteEntries after deletion: ${cpal.numPaletteEntries - 1}, replacementIndex: ${replacementIndex})`); + } + + // Remove color from all palettes + for (let i = 0; i < palettes.length; i++) { + const palette = palettes[i]; + const updatedPalette = palette.filter((color, index) => index !== colorIndex); + updatedPalettes.push(updatedPalette); + } + + // Update paletteIndex in layerRecords of the COLR table + const colrTable = this.font.tables.colr; + if (colrTable) { + const layerRecords = colrTable.layerRecords; + + // Adjust paletteIndex in layerRecords + for (let i = 0; i < layerRecords.length; i++) { + const currentIndex = layerRecords[i].paletteIndex; + if (currentIndex > colorIndex) { + // If the current index is after the deleted color, adjust it accordingly + const shiftAmount = 1; // We're removing one color from each palette + layerRecords[i].paletteIndex -= shiftAmount; + } else if (currentIndex === colorIndex) { + // Calculate replacement index shift + let replacementShift = 0; + for (let j = 0; j < palettes.length; j++) { + if (replacementIndex > colorIndex && replacementIndex <= colorIndex + palettes[j].length) { + replacementShift++; + break; + } + } + // Replace deleted color index with adjusted replacement + layerRecords[i].paletteIndex = replacementIndex - replacementShift; + } + } + + // Reconstruct the COLR table + this.font.tables.colr = { + ...colrTable, + layerRecords: layerRecords, + }; + } + + // Update CPAL table with the modified palettes + const flattenedPalettes = updatedPalettes.flat(); + for (let i = 0; i < palettes.length; i++) { + cpal.colorRecordIndices[i] -= i; + } + cpal.numPaletteEntries = Math.max(0, cpal.numPaletteEntries - 1); + cpal.colorRecords = this.toCPALcolor(flattenedPalettes); + } + + /** + * Makes sure that the CPAL table exists and is populated with default values. + * @param {Array} colors (optional) colors to populate on creation + * @returns {Boolean} true if it was created, false if it already existed. + */ + ensureCPAL(colors) { + if (!this.cpal()) { + if (!colors || !colors.length) { + colors = [this.defaultValue]; + } else { + colors = this.toCPALcolor(colors); + } + + this.font.tables.cpal = { + version: 0, + numPaletteEntries: colors.length, + colorRecords: colors, + colorRecordIndices: [0] + }; + return true; + } + return false; + } + + /** + * Mainly used internally. Recalculates the colorRecordIndices array based on the numPaletteEntries and number of palettes + */ + updateIndices() { + const cpal = this.cpal(); + const paletteCount = Math.ceil(cpal.colorRecords.length/cpal.numPaletteEntries); + cpal.colorRecordIndices = []; + for(let i = 0; i < paletteCount; i++) { + cpal.colorRecordIndices.push(i * cpal.numPaletteEntries); + } + } +} \ No newline at end of file diff --git a/src/path.js b/src/path.js index 02f9bdff..b29c3d0d 100644 --- a/src/path.js +++ b/src/path.js @@ -14,6 +14,8 @@ function Path() { this.fill = 'black'; this.stroke = null; this.strokeWidth = 1; + // the _layer property is only set on computed paths during glyph rendering + // this._layers = []; } const decimalRoundingCache = {}; @@ -503,6 +505,14 @@ Path.prototype.getBoundingBox = function() { * @param {CanvasRenderingContext2D} ctx - A 2D drawing context. */ Path.prototype.draw = function(ctx) { + const layers = this._layers; + if ( layers && layers.length ) { + for ( let l = 0; l < layers.length; l++ ) { + this.draw.call(layers[l], ctx); + } + return; + } + ctx.beginPath(); for (let i = 0; i < this.commands.length; i += 1) { const cmd = this.commands[i]; @@ -514,7 +524,7 @@ Path.prototype.draw = function(ctx) { ctx.bezierCurveTo(cmd.x1, cmd.y1, cmd.x2, cmd.y2, cmd.x, cmd.y); } else if (cmd.type === 'Q') { ctx.quadraticCurveTo(cmd.x1, cmd.y1, cmd.x, cmd.y); - } else if (cmd.type === 'Z') { + } else if (cmd.type === 'Z' && this.stroke && this.strokeWidth) { ctx.closePath(); } } @@ -623,6 +633,13 @@ Path.prototype.toPathData = function(options) { * @return {string} */ Path.prototype.toSVG = function(options, pathData) { + if (this._layers && this._layers.length) { + /** @TODO: implement SVG output for colr fonts + * Is there a standardized way? + * @see https://github.com/unicode-org/text-rendering-tests/issues/95 + */ + console.warn('toSVG() does not support colr font layers yet'); + } if (!pathData) { pathData = this.toPathData(options); } @@ -652,6 +669,13 @@ Path.prototype.toSVG = function(options, pathData) { * @return {SVGPathElement} */ Path.prototype.toDOMElement = function(options, pathData) { + if(this._layers && this._layers.length) { + /** @TODO: implement SVG output for colr fonts + * Is there a standardized way? + * @see https://github.com/unicode-org/text-rendering-tests/issues/95 + */ + console.warn('toDOMElement() does not support colr font layers yet'); + } if (!pathData) { pathData = this.toPathData(options); } diff --git a/src/tables/colr.js b/src/tables/colr.js index 0b09d38c..b1391425 100644 --- a/src/tables/colr.js +++ b/src/tables/colr.js @@ -8,7 +8,12 @@ import table from '../table.js'; function parseColrTable(data, start) { const p = new Parser(data, start); const version = p.parseUShort(); - check.argument(version === 0x0000, 'Only COLRv0 supported.'); + /** + * @see https://learn.microsoft.com/en-us/typography/opentype/spec/colr#mixing-version-0-and-version-1-formats + */ + if(version !== 0x0000) { + console.warn('Only COLRv0 is currently fully supported. A subset of color glyphs might be available in this font if provided in the v0 format.'); + } const numBaseGlyphRecords = p.parseUShort(); const baseGlyphRecordsOffset = p.parseOffset32(); const layerRecordsOffset = p.parseOffset32(); diff --git a/src/tables/cpal.js b/src/tables/cpal.js index edcd7e4e..0a9f99f9 100644 --- a/src/tables/cpal.js +++ b/src/tables/cpal.js @@ -11,6 +11,9 @@ import table from '../table.js'; function parseCpalTable(data, start) { const p = new Parser(data, start); const version = p.parseShort(); + if(version !== 0x0000) { + console.warn('Only CPALv0 is currently fully supported.'); + } const numPaletteEntries = p.parseShort(); const numPalettes = p.parseShort(); const numColorRecords = p.parseShort(); @@ -18,11 +21,14 @@ function parseCpalTable(data, start) { const colorRecordIndices = p.parseUShortList(numPalettes); p.relativeOffset = colorRecordsArrayOffset; const colorRecords = p.parseULongList(numColorRecords); + + p.relativeOffset = colorRecordsArrayOffset; + return { version, numPaletteEntries, colorRecords, - colorRecordIndices, + colorRecordIndices }; } @@ -44,5 +50,263 @@ function makeCpalTable({ version = 0, numPaletteEntries = 0, colorRecords = [], ]); } -export default { parse: parseCpalTable, make: makeCpalTable }; +function parseCPALColor(bgra) { + var b = (bgra & 0xFF000000) >> 24; + var g = (bgra & 0x00FF0000) >> 16; + var r = (bgra & 0x0000FF00) >> 8; + var a = bgra & 0x000000FF; + + // Adjust for sign extension if negative + b = (b + 0x100) & 0xFF; + g = (g + 0x100) & 0xFF; + r = (r + 0x100) & 0xFF; + a = ((a + 0x100) & 0xFF) / 255; + + return { b, g, r, a }; +} + +function getPaletteColor(font, index, palette = 0, colorFormat = 'hexa') { + if (index == 0xFFFF) { + return 'currentColor'; + } + + const cpalTable = font && font.tables && font.tables.cpal; + if (!cpalTable) return 'currentColor'; + + if (palette > cpalTable.colorRecordIndices.length - 1) { + throw new Error(`Palette index out of range (colorRecordIndices.length: ${cpalTable.colorRecordIndices.length}, index: ${index})`); + } + + if (index > cpalTable.numPaletteEntries) { + throw new Error(`Color index out of range (numPaletteEntries: ${cpalTable.numPaletteEntries}, index: ${index})`); + } + + const lookupIndex = cpalTable.colorRecordIndices[palette] + index; + if (lookupIndex > cpalTable.colorRecords) { + throw new Error(`Color index out of range (colorRecords.length: ${cpalTable.colorRecords.length}, lookupIndex: ${lookupIndex})`); + } + + const color = parseCPALColor(cpalTable.colorRecords[lookupIndex]); + if(colorFormat === 'bgra') { + return color; + } + return formatColor(color, colorFormat); +} + +function toHex(d) { + return ('0' + parseInt(d).toString(16)).slice(-2); +} + +function rgbToHSL(bgra) { + const r = bgra.r/255; + const g = bgra.g/255; + const b = bgra.b/255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + let h, s, l = (max + min) / 2; + + if (max === min) { + h = s = 0; + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + h /= 6; + } + + return { + h: h * 360, + s: s * 100, + l: l * 100 + }; +} + +function hslToRGB(hsla) { + let { h, s, l, a } = hsla; + h = h % 360; + s /= 100; + l /= 100; + + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs((h / 60) % 2 - 1)); + const m = l - c / 2; + + let r = 0, g = 0, b = 0; + + if (0 <= h && h < 60) { + r = c; g = x; b = 0; + } else if (60 <= h && h < 120) { + r = x; g = c; b = 0; + } else if (120 <= h && h < 180) { + r = 0; g = c; b = x; + } else if (180 <= h && h < 240) { + r = 0; g = x; b = c; + } else if (240 <= h && h < 300) { + r = x; g = 0; b = c; + } else if (300 <= h && h <= 360) { + r = c; g = 0; b = x; + } + + return { + r: Math.round((r + m) * 255), + g: Math.round((g + m) * 255), + b: Math.round((b + m) * 255), + a + }; +} + +function bgraToRaw(color) { + return parseInt(`0x${toHex(color.b)}${toHex(color.g)}${toHex(color.r)}${toHex(color.a * 255)}`, 16); +} + +function parseColor(color, targetFormat = 'hexa') { + const returnRaw = (targetFormat == 'raw' || targetFormat == 'cpal'); + const isRaw = Number.isInteger(color); + let validFormat = true; + if ( + ( + isRaw && returnRaw + ) || + color === 'currentColor' + ) { + return color; + } else if (typeof color === 'object') { + if (targetFormat == 'bgra') { + return color; + } + if (returnRaw) { + return bgraToRaw(color); + } + } else if(!isRaw && /^#([a-f0-9]{3}|[a-f0-9]{4}|[a-f0-9]{6}|[a-f0-9]{8})$/i.test(color.trim())) { + color = color.trim().substring(1); + switch(color.length) { + case 3: + color = { + r: parseInt(color[0].repeat(2), 16), + g: parseInt(color[1].repeat(2), 16), + b: parseInt(color[2].repeat(2), 16), + a: 1 + }; + break; + case 4: + color = { + r: parseInt(color[0].repeat(2), 16), + g: parseInt(color[1].repeat(2), 16), + b: parseInt(color[2].repeat(2), 16), + a: parseInt(color[3].repeat(2), 16) / 255 + }; + break; + case 6: + color = { + r: parseInt(color[0] + color[1], 16), + g: parseInt(color[2] + color[3], 16), + b: parseInt(color[4] + color[5], 16), + a: 1 + }; + break; + case 8: + color = { + r: parseInt(color[0] + color[1], 16), + g: parseInt(color[2] + color[3], 16), + b: parseInt(color[4] + color[5], 16), + a: parseInt(color[6] + color[7], 16) / 255, + }; + break; + } + + if(targetFormat == 'bgra') { + return color; + } + } else if(globalThis.window && globalThis.window.HTMLCanvasElement && /^[a-z]+$/i.test(color)) { + // assume CSS color name (only works in browser context!) + const ctx = document.createElement('canvas').getContext('2d'); + ctx.fillStyle = color; + // may sometimes return rgba() notation, so we need to use formatColor() + const detectedColor = formatColor(ctx.fillStyle, 'hexa'); + // invalid values will return black, so if that wasn't the input, it's an invalid color name + if (detectedColor === '#000000ff' && color.toLowerCase() !== 'black') { + validFormat = false; + } else { + color = detectedColor; + } + } else { + color = color.trim(); + const rgbaRegex = /rgba?\(\s*(?:(\d*\.\d+)(%?)|(\d+)(%?))\s*(?:,|\s*)\s*(?:(\d*\.\d+)(%?)|(\d+)(%?))\s*(?:,|\s*)\s*(?:(\d*\.\d+)(%?)|(\d+)(%?))\s*(?:(?:,|\s|\/)\s*(?:(0*(?:\.\d+)?()|0*1(?:\.0+)?())|(?:\.\d+)|(\d+)(%)|(\d*\.\d+)(%)))?\s*\)/; + if (rgbaRegex.test(color)) { + const matches = color.match(rgbaRegex).filter((i) => typeof i !== 'undefined'); + color = { + r: Math.round(parseFloat(matches[1]) / (matches[2] ? 100/255 : 1)), + g: Math.round(parseFloat(matches[3]) / (matches[4] ? 100/255 : 1)), + b: Math.round(parseFloat(matches[5]) / (matches[6] ? 100/255 : 1)), + a: !matches[7] ? 1 : (parseFloat(matches[7]) / (matches[8] ? 100 : 1)) + }; + } else { + const hslaRegex = /hsla?\(\s*(?:(\d*\.\d+|\d+)(deg|turn|))\s*(?:,|\s*)\s*(?:(\d*\.\d+)%?|(\d+)%?)\s*(?:,|\s*)\s*(?:(\d*\.\d+)%?|(\d+)%?)\s*(?:(?:,|\s|\/)\s*(?:(0*(?:\.\d+)?()|0*1(?:\.0+)?())|(?:\.\d+)|(\d+)(%)|(\d*\.\d+)(%)))?\s*\)/; + if (hslaRegex.test(color)) { + const matches = color.match(hslaRegex).filter((i) => typeof i !== 'undefined'); + color = hslToRGB({ + h: parseFloat(matches[1]) * (matches[2] === 'turn' ? 360 : 1), + s: parseFloat(matches[3]), + l: parseFloat(matches[4]), + a: !matches[5] ? 1 : parseFloat(matches[5]) / (matches[6] ? 100 : 1) + }); + } else { + validFormat = false; + } + } + } + + if (!validFormat) { + throw new Error(`Invalid color format: ${color}`); + } + + return formatColor(color, targetFormat); +} + +function formatColor(bgra, format = 'hexa') { + if (bgra === 'currentColor') return bgra; + + if (Number.isInteger(bgra)) { + if (format == 'raw' || format == 'cpal') { + return bgra; + } + bgra = parseCPALColor(bgra); + } else if(typeof bgra !== 'object') { + bgra = parseColor(bgra, 'bgra'); + } + + let hsl = ['hsl', 'hsla'].includes(format) ? rgbToHSL(bgra) : null; + + switch(format) { + case 'rgba': + return `rgba(${bgra.r}, ${bgra.g}, ${bgra.b}, ${parseFloat(bgra.a.toFixed(3))})`; + case 'rgb': + return `rgb(${bgra.r}, ${bgra.g}, ${bgra.b})`; + case 'hex': + case 'hex6': + case 'hex-6': + return `#${toHex(bgra.r)}${toHex(bgra.g)}${toHex(bgra.b)}`; + case 'hexa': + case 'hex8': + case 'hex-8': + return `#${toHex(bgra.r)}${toHex(bgra.g)}${toHex(bgra.b)}${toHex(bgra.a * 255)}`; + case 'hsl': + return `hsl(${hsl.h.toFixed(2)}, ${hsl.s.toFixed(2)}%, ${hsl.l.toFixed(2)}%)`; + case 'hsla': + return `hsla(${hsl.h.toFixed(2)}, ${hsl.s.toFixed(2)}%, ${hsl.l.toFixed(2)}%, ${parseFloat(bgra.a.toFixed(3))})`; + case 'bgra': + return bgra; + case 'raw': + case 'cpal': + return bgraToRaw(bgra); + default: + throw new Error('Unknown color format: ' + format); + } +} + +export default { parse: parseCpalTable, make: makeCpalTable, getPaletteColor, parseColor, formatColor }; +export { parseCpalTable, makeCpalTable, getPaletteColor, parseColor, formatColor }; diff --git a/src/types.js b/src/types.js index d8e5e637..1ded9b37 100644 --- a/src/types.js +++ b/src/types.js @@ -70,9 +70,9 @@ sizeOf.CHAR = constant(1); * @returns {Array} */ encode.CHARARRAY = function(v) { - if (typeof v === 'undefined') { + if (v === null || typeof v === 'undefined') { v = ''; - console.warn('Undefined CHARARRAY encountered and treated as an empty string. This is probably caused by a missing glyph name.'); + console.warn('CHARARRAY with undefined or null value encountered and treated as an empty string. This is probably caused by a missing glyph name.'); } const b = []; for (let i = 0; i < v.length; i += 1) { diff --git a/src/util.js b/src/util.js index 0757513f..87e1e2bb 100644 --- a/src/util.js +++ b/src/util.js @@ -39,4 +39,55 @@ function objectsEqual(obj1, obj2) { return arraysEqual(val1, val2) && arraysEqual(keys1, keys2); } -export { isBrowser, isNode, checkArgument, arraysEqual, objectsEqual }; +// perform a binary search on an array of objects for a specific key and value +// the array MUST already be sorted by the key in ascending order +// this is way faster than Array.prototype.find() +function binarySearch(array, key, value) { + let low = 0, high = array.length - 1; + let result = null; + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const record = array[mid]; + const recordValue = record[key]; + if (recordValue < value) { + low = mid + 1; + } else if (recordValue > value) { + high = mid - 1; + } else { + result = record; + break; + } + } + return result; +} + +function binarySearchIndex(array, key, value) { + let low = 0, high = array.length - 1; + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const element = array[mid]; + if (element[key] < value) { + low = mid + 1; + } else if (element[key] > value) { + high = mid - 1; + } else { + return mid; // Element found + } + } + return -1; // Element not found +} + +function binarySearchInsert(array, key, value) { + let low = 0, high = array.length; + const compare = (a, b) => a[key] - b[key]; + while (low < high) { + const mid = (low + high) >>> 1; + if (compare(array[mid], value) < 0) low = mid + 1; + else high = mid; + } + array.splice(low, 0, value); + + return low; +} + +export { isBrowser, isNode, checkArgument, arraysEqual, objectsEqual, binarySearch, binarySearchIndex, binarySearchInsert }; diff --git a/test/font.js b/test/font.js index e15217b8..4ed4ab61 100644 --- a/test/font.js +++ b/test/font.js @@ -2,6 +2,7 @@ import assert from 'assert'; import { Font, Glyph, Path, parse } from '../src/opentype.js'; import glyphset from '../src/glyphset.js'; import { readFileSync } from 'fs'; +import util from './testutil.js'; const loadSync = (url, opt) => parse(readFileSync(url), opt); describe('font.js', function() { @@ -222,4 +223,38 @@ describe('glyphset.js', function() { } }); }); + + + describe('drawing', function() { + const emojiFont = loadSync('./test/fonts/OpenMojiCOLRv0-subset.otf'); + + it('draws layers', function() { + let contextLogs = []; + const ctx = util.createMockObject(contextLogs); + emojiFont.getPath('🌈🔳', 0, 0, 12).draw(ctx); + const expectedColors = [ + 'rgba(234, 90, 71, 1)', + 'rgba(244, 170, 65, 1)', + 'rgba(252, 234, 43, 1)', + 'rgba(177, 204, 51, 1)', + 'rgba(146, 211, 245, 1)', + 'rgba(179, 153, 200, 1)', + 'rgba(0, 0, 0, 1)', + 'rgba(0, 0, 0, 1)', + 'rgba(0, 0, 0, 1)', + 'rgba(0, 0, 0, 1)', + 'rgba(0, 0, 0, 1)', + 'rgba(0, 0, 0, 1)', + 'rgba(0, 0, 0, 1)', + 'rgba(255, 255, 255, 1)', + 'rgba(63, 63, 63, 1)', + 'rgba(0, 0, 0, 1)', + 'rgba(0, 0, 0, 1)' + ]; + const fillLogs = contextLogs + .filter(log => log.property === 'fillStyle') + .map(log => log.value); + assert.deepEqual(fillLogs, expectedColors); + }); + }); }); \ No newline at end of file diff --git a/test/fonts/LICENSE b/test/fonts/LICENSE index 1aae1d60..4fad27ee 100644 --- a/test/fonts/LICENSE +++ b/test/fonts/LICENSE @@ -26,6 +26,11 @@ Jomhuria-Regular.ttf SIL Open Font License, Version 1.1. https://www.fontsquirrel.com/license/jomhuria +OpenMojiCOLORv0-subset.ttf + All emojis designed by OpenMoji – the open-source emoji and icon project. + Creative Commons Share Alike License 4.0 (CC BY-SA 4.0) + https://creativecommons.org/licenses/by-sa/4.0/ + Roboto-Black.ttf Font data copyright Google 2012 Apache License Version 2.0, January 2004 diff --git a/test/fonts/OpenMojiCOLRv0-subset.otf b/test/fonts/OpenMojiCOLRv0-subset.otf new file mode 100644 index 00000000..b39c7e14 Binary files /dev/null and b/test/fonts/OpenMojiCOLRv0-subset.otf differ diff --git a/test/glyph.js b/test/glyph.js index 2525779d..748c2d26 100644 --- a/test/glyph.js +++ b/test/glyph.js @@ -1,9 +1,13 @@ import assert from 'assert'; import { parse, Glyph, Path } from '../src/opentype.js'; import { readFileSync } from 'fs'; +import util from './testutil.js'; const loadSync = (url, opt) => parse(readFileSync(url), opt); +const emojiFont = loadSync('./test/fonts/OpenMojiCOLRv0-subset.otf'); + describe('glyph.js', function() { + describe('lazy loading', function() { let font; let glyph; @@ -32,6 +36,12 @@ describe('glyph.js', function() { it('lazily loads numberOfContours', function() { assert.equal(glyph.numberOfContours, 2); }); + + it('lazily loads COLR layers on paths', function() { + const layers = emojiFont.glyphs.get(138).getLayers(emojiFont); + assert.equal(Array.isArray(layers), true); + assert.equal(layers.length, 4); + }); }); describe('bounding box', function() { @@ -106,7 +116,9 @@ describe('glyph.js', function() { svgMarkup, '' ); + // we can't test toDOMElement() in node context! + // @TODO: we'll be able to by leveraging the new mock functionality in testutil.js const trianglePathUp = 'M318 230L182 230L250 93Z'; const trianglePathDown = 'M318 320L182 320L250 457Z'; @@ -145,6 +157,58 @@ describe('glyph.js', function() { assert.deepEqual(glyph.toPathData(), 'M91 284L440 284L440 342L91 342ZM236 487L236 138L294 138L294 487Z'); }); }); + + describe('color glyph drawing/rendering', function() { + it('draws and renders layers correctly', function() { + let contextLogs = []; + const ctx = util.createMockObject(contextLogs, undefined/*, { consoleLog: 'ctx' }*/); + emojiFont.glyphs.get(138).draw(ctx, 0, 0, 12, {}, emojiFont); + const expectedProps = [ + 'beginPath', 'moveTo', 'lineTo', 'lineTo', 'lineTo', 'lineTo', 'fillStyle', 'fill', + 'beginPath', 'moveTo', 'lineTo', 'lineTo', 'lineTo', 'lineTo', 'fillStyle', 'fill', + 'beginPath', 'moveTo', 'lineTo', 'lineTo', 'lineTo', 'lineTo', 'fillStyle', 'fill', + 'beginPath', 'moveTo', 'lineTo', 'bezierCurveTo', 'lineTo', 'lineTo', 'bezierCurveTo', + 'lineTo', 'lineTo', 'bezierCurveTo', 'lineTo', 'lineTo', 'bezierCurveTo', 'lineTo', + 'moveTo', 'lineTo', 'lineTo', 'lineTo', 'lineTo', 'fillStyle', 'fill', + + ]; + assert.deepEqual(contextLogs.map(log => log.property), expectedProps); + assert.deepEqual(contextLogs[6], { property: 'fillStyle', value: 'rgba(241, 179, 28, 1)' }); + assert.deepEqual(contextLogs[14], { property: 'fillStyle', value: 'rgba(210, 47, 39, 1)' }); + assert.deepEqual(contextLogs[22], { property: 'fillStyle', value: 'rgba(0, 0, 0, 1)' }); + assert.deepEqual(contextLogs[43], { property: 'fillStyle', value: 'rgba(0, 0, 0, 1)' }); + const layerPath = emojiFont.glyphs.get(3540).getPath(0, 0, 72, {colorFormat: 'hexa'}, emojiFont)._layers[0]; + const layerGlyphPath = emojiFont.glyphs.get(21090).getPath(0, 0, 72, {}, emojiFont); + assert.deepEqual(layerPath.commands, layerGlyphPath.commands); + assert.deepEqual(layerPath.fill, '#3f3f3fff'); + }); + + it('does not draw layers when options.drawLayers = false', function() { + let contextLogs = []; + const ctx = util.createMockObject(contextLogs, undefined/*, { consoleLog: 'ctx' }*/); + emojiFont.glyphs.get(138).draw(ctx, 0, 0, 12, { drawLayers: false }, emojiFont); + const expectedProps = [ + 'beginPath', 'moveTo', 'lineTo', 'lineTo', 'fillStyle', 'fill', + ]; + assert.deepEqual(contextLogs.map(log => log.property), expectedProps); + }); + + it('reflects color and palette changes', function() { + let path = emojiFont.glyphs.get(929).getPath(0, 0, 12, {}, emojiFont); + emojiFont.palettes.add(emojiFont.palettes.get(0).reverse()); + assert.equal(path._layers.length, 8); + path = emojiFont.glyphs.get(929).getPath(0, 0, 12, {usePalette: 1, colorFormat: 'hexa'}, emojiFont); + assert.deepEqual(path._layers.map(p => p.fill), [ + '#c19a65ff', '#00000099', '#61b2e4ff', + ].concat(Array(5).fill('#fadcbcff'))); + emojiFont.layers.setPaletteIndex(929, 2, 25); + path = emojiFont.glyphs.get(929).getPath(0, 0, 12, {usePalette: 1, colorFormat: 'hexa'}, emojiFont); + assert.deepEqual(path._layers[2].fill, '#5c9e31ff'); + emojiFont.palettes.setColor(25, '#ff000099', 1); + path = emojiFont.glyphs.get(929).getPath(0, 0, 12, {usePalette: 1, colorFormat: 'hexa'}, emojiFont); + assert.deepEqual(path._layers[2].fill, '#ff000099'); + }); + }); }); describe('glyph.js on low memory mode', function() { @@ -178,6 +242,12 @@ describe('glyph.js on low memory mode', function() { it('lazily loads numberOfContours', function() { assert.equal(glyph.numberOfContours, 2); }); + + it('lazily loads COLR layers on paths', function() { + const layers = emojiFont.glyphs.get(138).getLayers(emojiFont); + assert.equal(Array.isArray(layers), true); + assert.equal(layers.length, 4); + }); }); describe('bounding box', function() { diff --git a/test/layers.js b/test/layers.js new file mode 100644 index 00000000..61f5976a --- /dev/null +++ b/test/layers.js @@ -0,0 +1,145 @@ +import assert from 'assert'; +import Font from '../src/font.js'; +import Glyph from '../src/glyph.js'; +import Path from '../src/path.js'; +// import { parse } from '../src/js'; +// import { PaletteManager } from '../src/palettes.js'; +// import { readFileSync } from 'fs'; +// const loadSync = (url, opt) => parse(readFileSync(url), opt); + +describe('layers.js', function() { + // creating the layers from an existing colr table + // is already sufficiently tested via palettes.js + + const trianglePath = new Path(); + trianglePath.moveTo(0, 0); + trianglePath.lineTo(125, 125); + trianglePath.lineTo(250, 0); + trianglePath.close(); + const colorGlyph = new Glyph({index: 0, advanceWidth: 250, yMax: 800, path: trianglePath}); + const squarePath = new Path(); + squarePath.moveTo(25, 50); + squarePath.lineTo(25, 250); + squarePath.lineTo(75, 250); + squarePath.lineTo(125, 250); + squarePath.lineTo(175, 250); + squarePath.lineTo(225, 250); + squarePath.lineTo(225, 50); + squarePath.lineTo(25, 50); + squarePath.close(); + const colorGlyphLayer1 = new Glyph({index: 1, path: squarePath, advanceWidth: 250}); + + const trianglePath2 = new Path(); + trianglePath2.moveTo(0, 250); + trianglePath2.lineTo(250, 250); + trianglePath2.lineTo(125, 125); + trianglePath2.close(); + + const colorGlyphLayer2 = new Glyph({index: 2, path: trianglePath2, advanceWidth: 250}); + const colorGlyphLayer3 = new Glyph({index: 3, path: trianglePath, advanceWidth: 250}); + + const colorGlyph2 = new Glyph({index: 4, advanceWidth: 250}); + + const font = new Font({ + familyName: 'MyFont', + styleName: 'Medium', + unitsPerEm: 1000, + ascender: 800, + descender: -200, + glyphs: [colorGlyph, colorGlyphLayer1, colorGlyphLayer2, colorGlyphLayer3, colorGlyph2] + }); + font.palettes.add(['#ffaa00','#99cc00','#12345678']); + + + it('adds layers to a glyph', function() { + font.layers.add(4, {glyph: colorGlyphLayer1, paletteIndex: 0}); + font.layers.add(4, {glyph: colorGlyphLayer3, paletteIndex: 1}, 0); + + assert.deepEqual(font.tables.colr.baseGlyphRecords, [ + { glyphID: 4, firstLayerIndex: 0, numLayers: 2 }, + ]); + + assert.deepEqual(font.tables.colr.layerRecords, [ + { glyphID: 3, paletteIndex: 1 }, + { glyphID: 1, paletteIndex: 0 }, + ]); + + font.layers.add(0, [{glyph: colorGlyphLayer1, paletteIndex: 1},{glyph: colorGlyphLayer2, paletteIndex: 0}]); + + assert.deepEqual(font.tables.colr.baseGlyphRecords, [ + { glyphID: 0, firstLayerIndex: 2, numLayers: 2}, + { glyphID: 4, firstLayerIndex: 0, numLayers: 2 }, + ]); + + assert.deepEqual(font.tables.colr.layerRecords, [ + { glyphID: 3, paletteIndex: 1 }, + { glyphID: 1, paletteIndex: 0 }, + { glyphID: 1, paletteIndex: 1 }, + { glyphID: 2, paletteIndex: 0 } + ]); + + font.layers.add(0, [{glyph: colorGlyphLayer3, paletteIndex: 2}]); + + assert.deepEqual(font.tables.colr.baseGlyphRecords, [ + { glyphID: 0, firstLayerIndex: 2, numLayers: 3 }, + { glyphID: 4, firstLayerIndex: 0, numLayers: 2 }, + ]); + + assert.deepEqual(font.tables.colr.layerRecords, [ + { "glyphID": 3, "paletteIndex": 1 }, + { "glyphID": 1, "paletteIndex": 0 }, + { "glyphID": 1, "paletteIndex": 1 }, + { "glyphID": 2, "paletteIndex": 0 }, + { "glyphID": 3, "paletteIndex": 2 }, + ]); + + font.layers.add(4, [{glyph: colorGlyphLayer3, paletteIndex: 2}], 1); + + assert.deepEqual(font.tables.colr.baseGlyphRecords, [ + { glyphID: 0, firstLayerIndex: 3, numLayers: 3 }, + { glyphID: 4, firstLayerIndex: 0, numLayers: 3 }, + ]); + + assert.deepEqual(font.tables.colr.layerRecords, [ + { "glyphID": 3, "paletteIndex": 1 }, + { "glyphID": 3, "paletteIndex": 2 }, + { "glyphID": 1, "paletteIndex": 0 }, + { "glyphID": 1, "paletteIndex": 1 }, + { "glyphID": 2, "paletteIndex": 0 }, + { "glyphID": 3, "paletteIndex": 2 }, + ]); + + }); + + it('removes layers from a glyph', function() { + font.layers.remove(4, 1, 2); + + assert.deepEqual(font.tables.colr.baseGlyphRecords, [ + { glyphID: 0, firstLayerIndex: 1, numLayers: 3 }, + { glyphID: 4, firstLayerIndex: 0, numLayers: 1 }, + ]); + + assert.deepEqual(font.tables.colr.layerRecords, [ + { "glyphID": 3, "paletteIndex": 1 }, + { "glyphID": 1, "paletteIndex": 1 }, + { "glyphID": 2, "paletteIndex": 0 }, + { "glyphID": 3, "paletteIndex": 2 }, + ]); + + }); + + it('sets a layer\'s paletteIndex', function() { + assert.deepEqual(font.layers.get(4)[0].paletteIndex, 1); + font.layers.setPaletteIndex(4, 0, 2); + assert.deepEqual(font.layers.get(4)[0].paletteIndex, 2); + font.layers.setPaletteIndex(0, 1, 1); + assert.deepEqual(font.tables.colr.layerRecords, [ + { "glyphID": 3, "paletteIndex": 2 }, + { "glyphID": 1, "paletteIndex": 1 }, + { "glyphID": 2, "paletteIndex": 1 }, + { "glyphID": 3, "paletteIndex": 2 }, + ]); + + }); + +}); \ No newline at end of file diff --git a/test/palettes.js b/test/palettes.js new file mode 100644 index 00000000..d05eeac8 --- /dev/null +++ b/test/palettes.js @@ -0,0 +1,128 @@ +import assert from 'assert'; +import { parse } from '../src/opentype.js'; +import { PaletteManager } from '../src/palettes.js'; +import { enableMockCanvas } from './testutil.js'; +import { readFileSync } from 'fs'; +const loadSync = (url, opt) => parse(readFileSync(url), opt); + +describe('palettes.js', function() { + const emojiFont = loadSync('./test/fonts/OpenMojiCOLRv0-subset.otf'); + const palettes = emojiFont.palettes.getAll(); + const palettesBGRA = emojiFont.palettes.getAll('bgra'); + + it('returns all palettes', function() { + assert.equal(Array.isArray(palettes), true); + assert.equal(palettes.length, 1); + assert.equal(palettes[0].length, 35); + assert.deepEqual(palettes[0], [ + '#00000040', '#00000066', '#00000099', '#000000ff', '#186648ff', '#1e50a0ff', '#352318ff', '#3f3f3f80', '#3f3f3fff', '#5c9e31ff', + '#61b2e4ff', '#6a462fff', '#781e32ff', '#8967aaff', '#92d3f5ff', '#9b9b9aff', '#9b9b9aff', '#a57939ff', '#b1cc33ff', '#b399c8ff', + '#c19a65ff', '#d0cfce80', '#d0cfceff', '#d22f27ff', '#debb90ff', '#e27022ff', '#e67a94ff', '#ea5a47ff', '#f1b31c66', '#f1b31cff', + '#f4aa41ff', '#fadcbcff', '#fcea2bff', '#ffa7c0ff', '#ffffffff' + ]); + assert.deepEqual(palettesBGRA[0][0], {r: 0, g:0, b: 0, a: 0.25098039215686274}); + }); + + // color parsing and conversion is tested in tables/cpal.js + it('converts color to integer', function() { + assert.equal(emojiFont.palettes.toCPALcolor('#12345678'), 0x56341278); + }); + + it('adds palettes', function() { + emojiFont.palettes.add(); + let newPalettes = emojiFont.palettes.getAll(); + assert.equal(newPalettes.length, 2); + const firstPalette = emojiFont.palettes.get(0, 'raw'); + const secondPalette = emojiFont.palettes.get(1, 'raw'); + assert.equal(firstPalette.length, 35); + assert.equal(secondPalette.length, 35); + assert.equal(secondPalette[0], emojiFont.palettes.defaultValue); + assert.deepEqual(emojiFont.tables.cpal.colorRecordIndices, [0,35]); + + emojiFont.palettes.add(['#ffaa00', '#99cc0048']); + newPalettes = emojiFont.palettes.getAll(); + assert.equal(newPalettes.length, 3); + assert.equal(emojiFont.palettes.getColor(0, 2, 'raw'), 0x00aaffff); + assert.equal(emojiFont.palettes.getColor(1, 2, 'hexa'), '#99cc0048'); + assert.equal(emojiFont.palettes.getColor(3, 2, 'raw'), emojiFont.palettes.defaultValue); + assert.deepEqual(emojiFont.tables.cpal.colorRecordIndices, [0,35,70]); + }); + + it('deletes palettes', function() { + const paletteCount = emojiFont.palettes.getAll().length; + emojiFont.palettes.delete(2); + assert.equal(emojiFont.palettes.getAll().length, paletteCount - 1); + assert.deepEqual(emojiFont.tables.cpal.colorRecordIndices, [0,35]); + }); + + it('extends palettes', function() { + emojiFont.palettes.extend(2); + const firstPalette = emojiFont.palettes.get(0); + const secondPalette = emojiFont.palettes.get(1); + assert.equal(firstPalette.length, 37); + assert.equal(secondPalette.length, 37); + const lastColor = emojiFont.palettes.toCPALcolor(firstPalette[firstPalette.length-1]); + const secondLastColor = emojiFont.palettes.toCPALcolor(firstPalette[firstPalette.length-1]); + assert.equal(lastColor, emojiFont.palettes.defaultValue); + assert.equal(secondLastColor, emojiFont.palettes.defaultValue); + assert.deepEqual(emojiFont.tables.cpal.colorRecordIndices, [0,37]); + }); + + it('sets a color', function() { + emojiFont.palettes.setColor(9, '#987654'); + emojiFont.palettes.setColor(7, '#87654321', 1); + assert.equal(emojiFont.palettes.getColor(9, 0, 'hexa'), '#987654ff'); + assert.equal(emojiFont.palettes.getColor(7, 1, 'hexa'), '#87654321'); + }); + + it('sets multiple colors at index and extends if necessary', function() { + enableMockCanvas(); + assert(emojiFont.tables.cpal.numPaletteEntries, 37); + emojiFont.palettes.setColor(2, ['red','orange','yellow']); + assert(emojiFont.tables.cpal.numPaletteEntries, 37); + const expectedSecondPalette = Array(37).fill('#000000ff'); + expectedSecondPalette[7] = '#87654321'; + const expectedPaletteColors = [ + [ + '#00000040', '#00000066', '#ff0000ff', '#ffa500ff', '#ffff00ff', '#1e50a0ff', '#352318ff', '#3f3f3f80', '#3f3f3fff', '#987654ff', + '#61b2e4ff', '#6a462fff', '#781e32ff', '#8967aaff', '#92d3f5ff', '#9b9b9aff', '#9b9b9aff', '#a57939ff', '#b1cc33ff', '#b399c8ff', + '#c19a65ff', '#d0cfce80', '#d0cfceff', '#d22f27ff', '#debb90ff', '#e27022ff', '#e67a94ff', '#ea5a47ff', '#f1b31c66', '#f1b31cff', + '#f4aa41ff', '#fadcbcff', '#fcea2bff', '#ffa7c0ff', '#ffffffff', '#000000ff', '#000000ff' + + ], + expectedSecondPalette + ]; + assert.deepEqual(emojiFont.palettes.getAll(), expectedPaletteColors); + emojiFont.palettes.setColor(36, ['blue','green','purple', 'indigo'], 1); + assert.equal(emojiFont.tables.cpal.numPaletteEntries, 40); + assert.deepEqual(emojiFont.tables.cpal.colorRecordIndices, [0, 40]); + expectedPaletteColors[0] = expectedPaletteColors[0].concat(Array(3).fill('#000000ff')); + expectedPaletteColors[1] = expectedPaletteColors[1].slice(0,-1).concat('#0000ffff','#008000ff', '#800080ff', '#4b0082ff'); + assert.deepEqual(emojiFont.palettes.getAll(), expectedPaletteColors); + }); + + it('deletes a color and sets the replacement value correctly', function() { + const glyph = emojiFont.glyphs.get(48); + let layers = glyph.getLayers(emojiFont); + assert.equal(layers[0].paletteIndex, 22); + emojiFont.palettes.deleteColor(22, 27); + assert.equal(emojiFont.tables.cpal.numPaletteEntries, 39); + layers = glyph.getLayers(emojiFont); + assert.equal(layers[0].paletteIndex, 26); + }); + + it('ensures that the CPAL table exists', function() { + const mockFont = {tables:{}}; + const pm = new PaletteManager(mockFont); + const colors = ['#ffaa00', '#99cc0048']; + + pm.add(colors); + assert.deepEqual(mockFont.tables.cpal.colorRecords, colors.map(pm.toCPALcolor)); + assert.deepEqual(mockFont.tables.cpal.colorRecordIndices, [0]); + + delete mockFont.tables.cpal; + pm.extend(48); + assert.deepEqual(mockFont.tables.cpal.colorRecords, Array(48).fill(pm.defaultValue)); + assert.deepEqual(mockFont.tables.cpal.colorRecordIndices, [0]); + }); +}); \ No newline at end of file diff --git a/test/tables/cpal.js b/test/tables/cpal.js index 05aca19d..094d3034 100644 --- a/test/tables/cpal.js +++ b/test/tables/cpal.js @@ -1,16 +1,27 @@ import assert from 'assert'; -import { hex, unhex } from '../testutil.js'; -import cpal from '../../src/tables/cpal.js'; +import { hex, unhex, enableMockCanvas } from '../testutil.js'; +import cpal, { parseColor } from '../../src/tables/cpal.js'; +import Font from '../../src/font.js'; describe('tables/cpal.js', function() { - const data = '00 00 00 02 00 03 00 04 00 00 00 12 00 00 00 01 00 02 ' + + const data = '00 00 00 02 00 02 00 04 00 00 00 10 00 00 00 02 ' + '88 66 BB AA 00 11 22 33 12 34 56 78 DE AD BE EF'; const obj = { version: 0, numPaletteEntries: 2, colorRecords: [0x8866BBAA, 0x00112233, 0x12345678, 0xDEADBEEF], - colorRecordIndices: [0, 1, 2], + colorRecordIndices: [0, 2], }; + const font = new Font({ + familyName: 'test', + styleName: 'Regular', + unitsPerEm: 1000, + ascender: 800, + descender: -200, + tables: { + cpal: obj + } + }); it('can parse cpal table', function() { assert.deepStrictEqual(obj, cpal.parse(unhex(data), 0)); @@ -21,4 +32,140 @@ describe('tables/cpal.js', function() { cpal.parse(unhex(hexString), 0); assert.deepStrictEqual(data, hexString); }); + + const colors = [ + cpal.getPaletteColor(font, 0), + cpal.getPaletteColor(font, 1), + cpal.getPaletteColor(font, 0, 1), + cpal.getPaletteColor(font, 1, 1), + ]; + + it('correctly parses color values', function() { + const expectedColors = [ + '#bb6688aa', + '#22110033', + '#56341278', + '#beaddeef', + ]; + assert.deepStrictEqual(colors, expectedColors); + + enableMockCanvas(); + + const convertedColors = [ + parseColor(0x12345678, 'raw'), + parseColor(0x12345678, 'cpal'), + parseColor('currentColor'), + parseColor('#ffaa00', 'raw'), + parseColor('#ffaa00', 'bgra'), + parseColor('#ffaa0033', 'raw'), + parseColor('#ffaa0033', 'bgra'), + parseColor('#DeadBeef', 'hexa'), + parseColor('#123', 'raw'), + parseColor('#123', 'bgra'), + parseColor('#123', 'hexa'), + parseColor('#1234', 'bgra'), + parseColor('#1234', 'hexa'), + parseColor('rgb(17, 34 ,51)', 'hexa'), + parseColor('rgb(17,34,51,0.267 )', 'hexa'), + parseColor('rgba(17,34 ,51)', 'hexa'), + parseColor('rgba( 17,34,51, 0.267)', 'hexa'), + parseColor('rgb( 17,34,51, .267)', 'hexa'), + parseColor(' rgba(17 , 34,51,.267) ', 'hexa'), + parseColor('rgba(17,34,51,0) ', 'hexa'), + parseColor('rgb(17 34 51)', 'hexa'), + parseColor('rgb(17 34 51 0.267 )', 'hexa'), + parseColor('rgba(17 34 51 )', 'hexa'), + parseColor('rgba( 17 34 51 0.267)', 'hexa'), + parseColor('rgb( 17 34 51 .267)', 'hexa'), + parseColor(' rgba(17 34 51 .267) ', 'hexa'), + parseColor('rgba(17 34 51 0) ', 'hexa'), + parseColor('rgba(17 34 51 26.7%) ', 'hexa'), + parseColor('rgba(17 34 51 / 26.7%) ', 'hexa'), + parseColor('rgba(17 34 51 / 0.267) ', 'hexa'), + parseColor('rgba(17 34 51 / .267) ', 'hexa'), + parseColor('rgba(6.66% 13.33% 20% / .267) ', 'hexa'), + + parseColor('hsl( 260.82, 42.61%, 77.45%)', 'hexa'), + parseColor('hsla(260.82, 42.61%, 77.45%, 0.9373)', 'raw'), + parseColor('background: hsl(0.3turn 48% 48%);', 'rgb'), + + parseColor('rebeccapurple', 'hex'), + ]; + + const expectedConvertedColors = [ + 0x12345678, + 0x12345678, + 'currentColor', + 0x00aaffff, + { r: 255, g: 170, b: 0, a: 1 }, + 0x00aaff33, + { r: 255, g: 170, b: 0, a: 0.2 }, + '#deadbeef', + 0x332211FF, + { r: 17, g: 34, b: 51, a: 1 }, + '#112233ff', + { r: 17, g: 34, b: 51, a: 0.26666666666666666 }, + '#11223344', + '#112233ff', + '#11223344', + '#112233ff', + '#11223344', + '#11223344', + '#11223344', + '#11223300', + '#112233ff', + '#11223344', + '#112233ff', + '#11223344', + '#11223344', + '#11223344', + '#11223300', + '#11223344', + '#11223344', + '#11223344', + '#11223344', + '#11223344', + + '#beaddeff', + obj.colorRecords[3], + 'rgb(87, 181, 64)', + + '#663399', + ]; + + assert.deepEqual(convertedColors, expectedConvertedColors); + }); + + it('correctly formats color values', function() { + const formattedColors = [ + cpal.formatColor(colors[0]), + cpal.formatColor(colors[0], 'rgba'), + cpal.formatColor(colors[0], 'bgra'), + cpal.formatColor(colors[1], 'hex'), + cpal.formatColor(colors[1], 'hexa'), + cpal.formatColor(colors[1], 'raw'), + cpal.formatColor(colors[2], 'hexa'), + cpal.formatColor(colors[3], 'hsl'), + cpal.formatColor(colors[3], 'hsla'), + ]; + const expectedColors = [ + '#bb6688aa', + 'rgba(187, 102, 136, 0.667)', + {b: 136, g: 102, r: 187, a: 0.6666666666666666}, + '#221100', + '#22110033', + 0x00112233, + '#56341278', + 'hsl(260.82, 42.61%, 77.45%)', + 'hsla(260.82, 42.61%, 77.45%, 0.937)', + ]; + + assert.deepEqual(formattedColors, expectedColors); + }); + + it('correctly detects the special palette index for current text color', function() { + assert.deepStrictEqual(cpal.getPaletteColor(font, 0xFFFF), 'currentColor'); + assert.deepStrictEqual(cpal.formatColor(cpal.getPaletteColor(font, 0xFFFF)), 'currentColor'); + assert.deepStrictEqual(cpal.formatColor(cpal.getPaletteColor(font, 0xFFFF), 'hsla'), 'currentColor'); + }); }); diff --git a/test/testutil.js b/test/testutil.js index 57c9f0c3..c9bcca83 100644 --- a/test/testutil.js +++ b/test/testutil.js @@ -27,4 +27,126 @@ function unhexArray(str) { return Array.prototype.slice.call(new Uint8Array(unhex(str).buffer)); } -export { hex, unhex, unhexArray }; +// Function to create a logging handler for any mock object +function createDynamicMockHandler(logsArray, options = {}) { + const { logPropertyAccess = true, logMethodCalls = true, consoleLog = false } = options; + + const optionalLog = (data) => { + if (!consoleLog) return; + console.log(`[${consoleLog}] ${JSON.stringify(data)}`); + }; + + return { + get(target, prop, receiver) { + // Skip logging for symbols to prevent unintended logging of internal properties + if (typeof prop === 'symbol') { + return Reflect.get(target, prop, receiver); + } + + const actualValue = Reflect.get(target, prop, receiver); + const isFunction = typeof actualValue === 'function'; + + // If the accessed property is a function, return a wrapper to catch invocations + if (isFunction || !Reflect.has(target, prop)) { + return new Proxy(function() {}, { + apply: function(targetFn, thisArg, argumentsList) { + // Log method calls with arguments if enabled + if (logMethodCalls) { + const data = { property: prop, arguments: argumentsList }; + logsArray.push(data); + optionalLog(data); + } + // If it's a function, invoke the original function + if (isFunction) { + return Reflect.apply(actualValue, receiver, argumentsList); + } + // For non-existent functions, there's nothing to invoke, so return undefined + return undefined; + } + }); + } else if (logPropertyAccess) { + // Immediately log non-function property access if logging is enabled + const data = { property: prop, value: actualValue }; + logsArray.push(data); + optionalLog(data); + return actualValue; + } + + return actualValue; + }, + set(target, prop, value) { + // Log property set operations with the value if enabled + if (logPropertyAccess) { + const data = { property: prop, value: value }; + logsArray.push(data); + optionalLog(data); + } + return Reflect.set(target, prop, value); + } + }; +} + +function createMockObject(logsArray, baseObject = {}, options = {}) { + const handler = createDynamicMockHandler(logsArray, options); + return new Proxy(baseObject, handler); +} + +function enableMockCanvas() { + if (!global.window) { + global.window = {}; + } + if (!global.document) { + global.document = {}; + } + + window.HTMLCanvasElement = { + getContext: function () { + let _fillStyle; + + return { + get fillStyle() { + return _fillStyle; + }, + set fillStyle(value) { + const colorMap = { + 'aliceblue':'#f0f8ff','antiquewhite':'#faebd7','aqua':'#00ffff','aquamarine':'#7fffd4','azure':'#f0ffff', + 'beige':'#f5f5dc','bisque':'#ffe4c4','black':'#000000','blanchedalmond':'#ffebcd','blue':'#0000ff','blueviolet':'#8a2be2','brown':'#a52a2a','burlywood':'#deb887', + 'cadetblue':'#5f9ea0','chartreuse':'#7fff00','chocolate':'#d2691e','coral':'#ff7f50','cornflowerblue':'#6495ed','cornsilk':'#fff8dc','crimson':'#dc143c','cyan':'#00ffff', + 'darkblue':'#00008b','darkcyan':'#008b8b','darkgoldenrod':'#b8860b','darkgray':'#a9a9a9','darkgreen':'#006400','darkkhaki':'#bdb76b','darkmagenta':'#8b008b','darkolivegreen':'#556b2f', + 'darkorange':'#ff8c00','darkorchid':'#9932cc','darkred':'#8b0000','darksalmon':'#e9967a','darkseagreen':'#8fbc8f','darkslateblue':'#483d8b','darkslategray':'#2f4f4f','darkturquoise':'#00ced1', + 'darkviolet':'#9400d3','deeppink':'#ff1493','deepskyblue':'#00bfff','dimgray':'#696969','dodgerblue':'#1e90ff', + 'firebrick':'#b22222','floralwhite':'#fffaf0','forestgreen':'#228b22','fuchsia':'#ff00ff', + 'gainsboro':'#dcdcdc','ghostwhite':'#f8f8ff','gold':'#ffd700','goldenrod':'#daa520','gray':'#808080','green':'#008000','greenyellow':'#adff2f', + 'honeydew':'#f0fff0','hotpink':'#ff69b4', + 'indianred ':'#cd5c5c','indigo':'#4b0082','ivory':'#fffff0','khaki':'#f0e68c', + 'lavender':'#e6e6fa','lavenderblush':'#fff0f5','lawngreen':'#7cfc00','lemonchiffon':'#fffacd','lightblue':'#add8e6','lightcoral':'#f08080','lightcyan':'#e0ffff','lightgoldenrodyellow':'#fafad2', + 'lightgrey':'#d3d3d3','lightgreen':'#90ee90','lightpink':'#ffb6c1','lightsalmon':'#ffa07a','lightseagreen':'#20b2aa','lightskyblue':'#87cefa','lightslategray':'#778899','lightsteelblue':'#b0c4de', + 'lightyellow':'#ffffe0','lime':'#00ff00','limegreen':'#32cd32','linen':'#faf0e6', + 'magenta':'#ff00ff','maroon':'#800000','mediumaquamarine':'#66cdaa','mediumblue':'#0000cd','mediumorchid':'#ba55d3','mediumpurple':'#9370d8','mediumseagreen':'#3cb371','mediumslateblue':'#7b68ee', + 'mediumspringgreen':'#00fa9a','mediumturquoise':'#48d1cc','mediumvioletred':'#c71585','midnightblue':'#191970','mintcream':'#f5fffa','mistyrose':'#ffe4e1','moccasin':'#ffe4b5', + 'navajowhite':'#ffdead','navy':'#000080', + 'oldlace':'#fdf5e6','olive':'#808000','olivedrab':'#6b8e23','orange':'#ffa500','orangered':'#ff4500','orchid':'#da70d6', + 'palegoldenrod':'#eee8aa','palegreen':'#98fb98','paleturquoise':'#afeeee','palevioletred':'#d87093','papayawhip':'#ffefd5','peachpuff':'#ffdab9','peru':'#cd853f','pink':'#ffc0cb','plum':'#dda0dd','powderblue':'#b0e0e6','purple':'#800080', + 'rebeccapurple':'#663399','red':'#ff0000','rosybrown':'#bc8f8f','royalblue':'#4169e1', + 'saddlebrown':'#8b4513','salmon':'#fa8072','sandybrown':'#f4a460','seagreen':'#2e8b57','seashell':'#fff5ee','sienna':'#a0522d','silver':'#c0c0c0','skyblue':'#87ceeb','slateblue':'#6a5acd','slategray':'#708090','snow':'#fffafa','springgreen':'#00ff7f','steelblue':'#4682b4', + 'tan':'#d2b48c','teal':'#008080','thistle':'#d8bfd8','tomato':'#ff6347','turquoise':'#40e0d0', + 'violet':'#ee82ee', + 'wheat':'#f5deb3','white':'#ffffff','whitesmoke':'#f5f5f5', + 'yellow':'#ffff00','yellowgreen':'#9acd32' + }; + + if (typeof colorMap[value.toLowerCase()] !== 'undefined') { + _fillStyle = colorMap[value.toLowerCase()]; + } else { + _fillStyle = '#000000'; + } + }, + }; + } + }; + + global.document.createElement = () => window.HTMLCanvasElement; + window.document = global.document; +} + +export { hex, unhex, unhexArray, createMockObject, enableMockCanvas };