p5
Version:
[](https://www.npmjs.com/package/p5)
1,603 lines • 52.9 kB
JavaScript
import { textCoreConstants } from './textCore.js';
import { t as CENTER, aa as RIGHT, s as RADIUS } from '../constants-DwbuOBz3.js';
import { UnicodeRange } from '@japont/unicode-range';
import { unicodeRanges } from './unicodeRanges.js';
import { Vector } from '../math/p5.Vector.js';
import Typr from './lib/Typr.js';
import { createFromCommands } from '@davepagurek/bezier-path';
import '../p5.Renderer-N-APumjv.js';
import '../creating_reading-Be7_6X4p.js';
import 'colorjs.io/fn';
import '../color/color_spaces/hsb.js';
import '../image/filters.js';
import '../shape/custom_shapes.js';
import '../core/States.js';
import '../io/utilities.js';
import 'pako';
/**
* @module Typography
*/
const pathArgCounts = { M: 2, L: 2, C: 6, Q: 4 };
const validFontTypes = ['ttf', 'otf', 'woff'];//, 'woff2'];
const validFontTypesRe = new RegExp(`\\.(${validFontTypes.join('|')})`, 'i');
const extractFontNameRe = new RegExp(`([^/]+)(\\.(?:${validFontTypes.join('|')}))`, 'i');
const invalidFontError = 'Sorry, only TTF, OTF and WOFF files are supported.'; // and WOFF2
const fontFaceVariations = ['weight', 'stretch', 'style'];
class Font {
constructor(p, fontFace, name, path, data) {
if (!(fontFace instanceof FontFace)) {
throw Error('FontFace is required');
}
this._pInst = p;
this.name = name;
this.path = path;
this.data = data;
this.face = fontFace;
}
/**
* Checks whether a font has glyph point data and
* can thus be used for textToPoints(), WEBGL mode, etc.
* @private
*/
static hasGlyphData(textFont) {
let { font } = textFont;
return typeof font === 'object' && typeof font.data !== 'undefined';
}
fontBounds(str, x, y, width, height, options) {
({ width, height, options } = this._parseArgs(width, height, options));
let renderer = options?.graphics?._renderer || this._pInst._renderer;
if (!renderer) throw Error('p5 or graphics required for fontBounds()');
return renderer.fontBounds(str, x, y, width, height);
}
textBounds(str, x, y, width, height, options) {
({ width, height, options } = this._parseArgs(width, height, options));
let renderer = options?.graphics?._renderer || this._pInst._renderer;
if (!renderer) throw Error('p5 or graphics required for fontBounds()');
return renderer.textBounds(str, x, y, width, height);
}
/**
* Returns a flat array of path commands that describe the outlines of a string of text.
*
* Each command is represented as an array of the form `[type, ...coords]`, where:
* - `type` is one of `'M'`, `'L'`, `'Q'`, `'C'`, or `'Z'`,
* - `coords` are the numeric values needed for that command.
*
* `'M'` indicates a "move to" (starting a new contour),
* `'L'` a line segment,
* `'Q'` a quadratic bezier,
* `'C'` a cubic bezier, and
* `'Z'` closes the current path.
*
* The first two parameters, `x` and `y`, specify the baseline origin for the text.
* Optionally, you can provide a `width` and `height` for text wrapping; if you don't need
* wrapping, you can omit them and directly pass `options` as the fourth parameter.
*
* @param {String} str The text to convert into path commands.
* @param {Number} x x‐coordinate of the text baseline.
* @param {Number} y y‐coordinate of the text baseline.
* @param {Number} [width] Optional width for text wrapping.
* @param {Number} [height] Optional height for text wrapping.
* @return {Array<Array>} A flat array of path commands.
*
* @example
* let font;
*
* async function setup() {
* font = await loadFont('assets/inconsolata.otf');
* createCanvas(200, 200);
* background(220);
* noLoop();
* }
*
* function draw() {
* background(220);
* stroke(0);
* noFill();
* textSize(60);
*
* // Get path commands for "Hello" (drawn at baseline x=50, y=100):
* const pathCommands = font.textToPaths('Hello', 30, 110);
*
* beginShape();
* for (let i = 0; i < pathCommands.length; i++) {
* const cmd = pathCommands[i];
* const type = cmd[0];
*
* switch (type) {
* case 'M': {
* // Move to (start a new contour)
* const x = cmd[1];
* const y = cmd[2];
* endContour(); // In case we were already drawing
* beginContour();
* vertex(x, y);
* break;
* }
* case 'L': {
* // Line to
* const x = cmd[1];
* const y = cmd[2];
* vertex(x, y);
* break;
* }
* case 'Q': {
* // Quadratic bezier
* const cx = cmd[1];
* const cy = cmd[2];
* const x = cmd[3];
* const y = cmd[4];
* bezierOrder(2);
* bezierVertex(cx, cy);
* bezierVertex(x, y);
* break;
* }
* case 'C': {
* // Cubic bezier
* const cx1 = cmd[1];
* const cy1 = cmd[2];
* const cx2 = cmd[3];
* const cy2 = cmd[4];
* const x = cmd[5];
* const y = cmd[6];
* bezierOrder(3);
* bezierVertex(cx1, cy1);
* bezierVertex(cx2, cy2);
* bezierVertex(x, y);
* break;
* }
* case 'Z': {
* // Close path
* endContour(CLOSE);
* beginContour();
* break;
* }
* }
* }
* endContour();
* endShape();
* }
*/
textToPaths(str, x, y, width, height, options) {
({ width, height, options } = this._parseArgs(width, height, options));
if (!this.data) {
throw Error('No font data available for "' + this.name
+ '"\nTry downloading a local copy of the font file');
}
// lineate and get glyphs/paths for each line
let lines = this._lineateAndPathify(str, x, y, width, height, options);
// flatten into a single array containing all the glyphs
let glyphs = lines.map(o => o.glyphs).flat();
// flatten into a single array with all the path commands
return glyphs.map(g => g.path.commands).flat();
}
/**
* Returns an array of points outlining a string of text written using the
* font.
*
* Each point object in the array has three properties that describe the
* point's location and orientation, called its path angle. For example,
* `{ x: 10, y: 20, alpha: 450 }`.
*
* The first parameter, `str`, is a string of text. The second and third
* parameters, `x` and `y`, are the text's position. By default, they set the
* coordinates of the bounding box's bottom-left corner. See
* <a href="#/p5/textAlign">textAlign()</a> for more ways to align text.
*
* The fourth parameter, `options`, is also optional. `font.textToPoints()`
* expects an object with the following properties:
*
* `sampleFactor` is the ratio of the text's path length to the number of
* samples. It defaults to 0.1. Higher values produce more points along the
* path and are more precise.
*
* `simplifyThreshold` removes collinear points if it's set to a number other
* than 0. The value represents the threshold angle in radians to use when determining
* whether two edges are collinear.
*
* @param {String} str string of text.
* @param {Number} x x-coordinate of the text.
* @param {Number} y y-coordinate of the text.
* @param {Object} [options] Configuration:
* @param {Number} [options.sampleFactor=0.1] The ratio of the text's path length to the number of samples.
* @param {Number} [options.simplifyThreshold=0] A minmum angle in radian sbetween two segments. Segments with a shallower angle will be merged.
* @return {Array<Object>} array of point objects, each with `x`, `y`, and `alpha` (path angle) properties.
*
* @example
* let font;
*
* async function setup() {
* createCanvas(100, 100);
* font = await loadFont('assets/inconsolata.otf');
*
* background(200);
* textSize(35);
*
* // Get the point array.
* let points = font.textToPoints('p5*js', 6, 60, { sampleFactor: 0.5 });
*
* // Draw a dot at each point.
* for (let p of points) {
* point(p.x, p.y);
* }
*
* describe('A set of black dots outlining the text "p5*js" on a gray background.');
* }
*/
textToPoints(str, x, y, width, height, options) {
// By segmenting per contour, pointAtLength becomes much faster
const contourPoints = this.textToContours(
str,
x, y,
width, height,
options
);
return contourPoints.reduce((acc, next) => {
acc.push(...next);
return acc;
}, []);
}
/**
* Returns an array of arrays of points outlining a string of text written using the
* font. Each array represents a contour, so the letter O will have two outer arrays:
* one for the outer edge of the shape, and one for the inner edge of the hole.
*
* Each point object in a contour array has three properties that describe the
* point's location and orientation, called its path angle. For example,
* `{ x: 10, y: 20, alpha: 450 }`.
*
* The first parameter, `str`, is a string of text. The second and third
* parameters, `x` and `y`, are the text's position. By default, they set the
* coordinates of the bounding box's bottom-left corner. See
* <a href="#/p5/textAlign">textAlign()</a> for more ways to align text.
*
* The fourth parameter, `options`, is also optional. `font.textToContours()`
* expects an object with the following properties:
*
* `sampleFactor` is the ratio of the text's path length to the number of
* samples. It defaults to 0.1. Higher values produce more points along the
* path and are more precise.
*
* `simplifyThreshold` removes collinear points if it's set to a number other
* than 0. The value represents the threshold angle in radians to use when determining
* whether two edges are collinear.
*
* @param {String} str string of text.
* @param {Number} x x-coordinate of the text.
* @param {Number} y y-coordinate of the text.
* @param {Object} [options] Configuration options:
* @param {Number} [options.sampleFactor=0.1] The ratio of the text's path length to the number of samples.
* @param {Number} [options.simplifyThreshold=0] A minmum angle in radians between two segments. Segments with a shallower angle will be merged.
* @return {Array<Array<Object>>} array of point objects, each with `x`, `y`, and `alpha` (path angle) properties.
*
* @example
* let font;
*
* async function setup() {
* createCanvas(100, 100);
* font = await loadFont('/assets/inconsolata.otf');
* }
*
* function draw() {
* background(200);
* textAlign(CENTER, CENTER);
* textSize(30);
*
* // Get the point array.
* let contours = font.textToContours('p5*js', width/2, height/2, { sampleFactor: 0.5 });
*
* beginShape();
* for (const pts of contours) {
* beginContour();
* for (const pt of pts) {
* vertex(pt.x + 5*sin(pt.y*0.1 + millis()*0.01), pt.y);
* }
* endContour(CLOSE);
* }
* endShape();
*
* describe('The text p5*js wobbling over time');
* }
*/
textToContours(str, x = 0, y = 0, width, height, options) {
({ width, height, options } = this._parseArgs(width, height, options));
const cmds = this.textToPaths(str, x, y, width, height, options);
const cmdContours = [];
for (const cmd of cmds) {
if (cmd[0] === 'M') {
cmdContours.push([]);
}
cmdContours[cmdContours.length - 1].push(cmd);
}
return cmdContours.map(commands => pathToPoints(commands, options, this));
}
/**
*
* Converts text into a 3D model that can be rendered in WebGL mode.
*
* This method transforms flat text into extruded 3D geometry, allowing
* for dynamic effects like depth, warping, and custom shading.
*
* It works by taking the outlines (contours) of each character in the
* provided text string and constructing a 3D shape from them.
*
* Once your 3D text is ready, you can rotate it in 3D space using <a href="#/p5/orbitControl">orbitControl()</a>
* — just click and drag with your mouse to see it from all angles!
*
* Use the extrude slider to give your letters depth: slide it up, and your
* flat text turns into a solid, multi-dimensional object.
*
* You can also choose from various fonts such as "Anton", "Montserrat", or "Source Serif",
* much like selecting fancy fonts in a word processor,
*
* The generated model (a Geometry object) can be manipulated further—rotated, scaled,
* or styled with shaders—to create engaging, interactive visual art.
*
* The `options` parameter is also optional. `font.textToModel()` expects an object
* with the following properties:
*
* `extrude` is the depth to extrude the text. It defaults to 0. A value of 0 produces
* flat text; higher values create thicker, 3D models.
*
* `sampleFactor` is a factor controlling the level of detail for the text contours.
* It defaults to 1. Higher values result in smoother curves.
*
* @param {String} str The text string to convert into a 3D model.
* @param {Number} x The x-coordinate for the starting position of the text.
* @param {Number} y The y-coordinate for the starting position of the text.
* @param {Number} width Maximum width of the text block (wraps text if exceeded).
* @param {Number} height Maximum height of the text block.
* @param {Object} [options] Configuration options for the 3D text:
* @param {Number} [options.extrude=0] The depth to extrude the text. A value of 0 produces
* flat text; higher values create thicker, 3D models.
* @param {Number} [options.sampleFactor=1] A factor controlling the level of detail for the text contours.
* Higher values result in smoother curves.
* @return {p5.Geometry} A geometry object representing the 3D model of the text.
*
* @example
* let font;
* let geom;
*
* async function setup() {
* createCanvas(200, 200, WEBGL);
* font = await loadFont('https://fonts.gstatic.com/s/anton/v25/1Ptgg87LROyAm0K08i4gS7lu.ttf');
*
* geom = font.textToModel("Hello", 50, 0, { sampleFactor: 2 });
* geom.clearColors();
* geom.normalize();
* }
*
* function draw() {
* background(255);
* orbitControl();
* fill("red");
* strokeWeight(4);
* scale(min(width, height) / 300);
* model(geom);
* describe('A red non-extruded "Hello" in Anton on white canvas, rotatable via mouse.');
* }
*
* @example
* let font;
* let geom;
*
* async function setup() {
* createCanvas(200, 200, WEBGL);
*
* // Alternative fonts:
* // Anton: 'https://fonts.gstatic.com/s/anton/v25/1Ptgg87LROyAm0K08i4gS7lu.ttf'
* // Montserrat: 'https://fonts.gstatic.com/s/montserrat/v29/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtr6Ew-Y3tcoqK5.ttf'
* // Source Serif: 'https://fonts.gstatic.com/s/sourceserif4/v8/vEFy2_tTDB4M7-auWDN0ahZJW3IX2ih5nk3AucvUHf6OAVIJmeUDygwjihdqrhxXD-wGvjU.ttf'
*
* // Using Source Serif for this example:
* font = await loadFont('https://fonts.gstatic.com/s/sourceserif4/v8/vEFy2_tTDB4M7-auWDN0ahZJW3IX2ih5nk3AucvUHf6OAVIJmeUDygwjihdqrhxXD-wGvjU.ttf');
*
* geom = font.textToModel("Hello", 50, 0, { sampleFactor: 2, extrude: 5 });
* geom.clearColors();
* geom.normalize();
* }
*
* function draw() {
* background(255);
* orbitControl();
* fill("red");
* strokeWeight(4);
* scale(min(width, height) / 300);
* model(geom);
* describe('3D red extruded "Hello" in Source Serif on white, rotatable via mouse.');
* }
*
* @example
* let geom;
* let activeFont;
* let artShader;
* let lineShader;
*
* // Define parameters as simple variables
* let words = 'HELLO';
* let warp = 1;
* let extrude = 5;
* let palette = ["#ffe03d", "#fe4830", "#d33033", "#6d358a", "#1c509e", "#00953c"];
*
* async function setup() {
* createCanvas(200, 200, WEBGL);
*
* // Using Anton as the default font for this example:
*
* // Alternative fonts:
* // Anton: 'https://fonts.gstatic.com/s/anton/v25/1Ptgg87LROyAm0K08i4gS7lu.ttf'
* // Montserrat: 'https://fonts.gstatic.com/s/montserrat/v29/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtr6Ew-Y3tcoqK5.ttf'
* // Source Serif: 'https://fonts.gstatic.com/s/sourceserif4/v8/vEFy2_tTDB4M7-auWDN0ahZJW3IX2ih5nk3AucvUHf6OAVIJmeUDygwjihdqrhxXD-wGvjU.ttf'
* activeFont = await loadFont('https://fonts.gstatic.com/s/anton/v25/1Ptgg87LROyAm0K08i4gS7lu.ttf');
*
* geom = activeFont.textToModel(words, 0, 50, { sampleFactor: 2, extrude });
* geom.clearColors();
* geom.normalize();
*
* artShader = baseMaterialShader().modify({
* uniforms: {
* 'float time': () => millis(),
* 'float warp': () => warp,
* 'float numColors': () => palette.length,
* 'vec3[6] colors': () => palette.flatMap((c) => [red(c)/255, green(c)/255, blue(c)/255]),
* },
* vertexDeclarations: 'out vec3 vPos;',
* fragmentDeclarations: 'in vec3 vPos;',
* 'Vertex getObjectInputs': `(Vertex inputs) {
* vPos = inputs.position;
* inputs.position.x += 5. * warp * sin(inputs.position.y * 0.1 + time * 0.001) / (1. + warp);
* inputs.position.y += 5. * warp * sin(inputs.position.x * 0.1 + time * 0.0009) / (1. + warp);
* return inputs;
* }`,
* 'vec4 getFinalColor': `(vec4 _c) {
* float x = vPos.x * 0.005;
* float a = floor(fract(x) * numColors);
* float b = a == numColors - 1. ? 0. : a + 1.;
* float t = fract(x * numColors);
* vec3 c = mix(colors[int(a)], colors[int(b)], t);
* return vec4(c, 1.);
* }`
* });
*
* lineShader = baseStrokeShader().modify({
* uniforms: {
* 'float time': () => millis(),
* 'float warp': () => warp,
* },
* 'StrokeVertex getObjectInputs': `(StrokeVertex inputs) {
* inputs.position.x += 5. * warp * sin(inputs.position.y * 0.1 + time * 0.001) / (1. + warp);
* inputs.position.y += 5. * warp * sin(inputs.position.x * 0.1 + time * 0.0009) / (1. + warp);
* return inputs;
* }`,
* });
* }
*
* function draw() {
* background(255);
* orbitControl();
* shader(artShader);
* strokeShader(lineShader);
* strokeWeight(4);
* scale(min(width, height) / 210);
* model(geom);
* describe('3D wavy with different color sets "Hello" in Anton on white canvas, rotatable via mouse.');
* }
*/
textToModel(str, x, y, width, height, options) {
({ width, height, options } = this._parseArgs(width, height, options));
const extrude = options?.extrude || 0;
let contours = this.textToContours(str, x, y, width, height, options);
if (!contours || contours.length === 0) {
return new p5.Geometry();
}
// Step 2: build base flat geometry - single shape
const geom = this._pInst.buildGeometry(() => {
const prevValidateFaces = this._pInst._renderer._validateFaces;
this._pInst._renderer._validateFaces = true;
this._pInst.beginShape();
for (const contour of contours) {
this._pInst.beginContour();
for (const pt of contour) {
this._pInst.vertex(pt.x, pt.y, 0);
}
this._pInst.endContour(this._pInst.CLOSE);
}
this._pInst.endShape(this._pInst.CLOSE);
this._pInst._renderer._validateFaces = prevValidateFaces;
});
if (extrude === 0) {
return geom;
}
// The tessellation process creates separate vertices for each triangle,
// even when they share the same position. We need to deduplicate them
// to find which faces are actually connected, so we can identify the
// outer edges for extrusion.
const vertexIndices = {};
const vertexId = v => `${v.x.toFixed(6)}-${v.y.toFixed(6)}-${v.z.toFixed(6)}`;
const newVertices = [];
const newVertexIndex = [];
for (const v of geom.vertices) {
const id = vertexId(v);
if (!(id in vertexIndices)) {
const index = newVertices.length;
vertexIndices[id] = index;
newVertices.push(v.copy());
}
newVertexIndex.push(vertexIndices[id]);
}
// Remap faces to use deduplicated vertices
const newFaces = geom.faces.map(f => f.map(i => newVertexIndex[i]));
//Find outer edges (edges that appear in only one face)
const seen = {};
for (const face of newFaces) {
for (let off = 0; off < face.length; off++) {
const a = face[off];
const b = face[(off + 1) % face.length];
const id = `${Math.min(a, b)}-${Math.max(a, b)}`;
if (!seen[id]) seen[id] = [];
seen[id].push([a, b]);
}
}
const validEdges = [];
for (const key in seen) {
if (seen[key].length === 1) {
validEdges.push(seen[key][0]);
}
}
// Step 5: Create extruded geometry
const extruded = this._pInst.buildGeometry(() => {});
const half = extrude * 0.5;
extruded.vertices = [];
extruded.faces = [];
extruded.edges = []; // INITIALIZE EDGES ARRAY
// Add side face vertices (separate for each edge for flat shading)
for (const [a, b] of validEdges) {
const vA = newVertices[a];
const vB = newVertices[b];
// Skip if vertices are too close (degenerate edge)
// We only need to check the perimeter edge length since the other edge
// is the extrude direction, which is always > 0 for extruded geometry
const edgeVector = new Vector(vB.x - vA.x, vB.y - vA.y, vB.z - vA.z);
const extrudeVector = new Vector(0, 0, extrude);
const crossProduct = Vector.cross(edgeVector, extrudeVector);
const dist = edgeVector.mag();
if (crossProduct.mag() < 0.0001 || dist < 0.0001) continue;
// Front face vertices
const frontA = extruded.vertices.length;
extruded.vertices.push(new Vector(vA.x, vA.y, vA.z + half));
const frontB = extruded.vertices.length;
extruded.vertices.push(new Vector(vB.x, vB.y, vB.z + half));
const backA = extruded.vertices.length;
extruded.vertices.push(new Vector(vA.x, vA.y, vA.z - half));
const backB = extruded.vertices.length;
extruded.vertices.push(new Vector(vB.x, vB.y, vB.z - half));
extruded.faces.push([frontA, backA, backB]);
extruded.faces.push([frontA, backB, frontB]);
extruded.edges.push([frontA, frontB]);
extruded.edges.push([backA, backB]);
extruded.edges.push([frontA, backA]);
extruded.edges.push([frontB, backB]);
}
// Add front face (with unshared vertices for flat shading)
const frontVertexOffset = extruded.vertices.length;
for (const v of newVertices) {
extruded.vertices.push(new Vector(v.x, v.y, v.z + half));
}
for (const face of newFaces) {
if (face.length < 3) continue;
const mappedFace = face.map(i => i + frontVertexOffset);
extruded.faces.push(mappedFace);
// ADD EDGES FOR FRONT FACE
for (let i = 0; i < mappedFace.length; i++) {
const nextIndex = (i + 1) % mappedFace.length;
extruded.edges.push([mappedFace[i], mappedFace[nextIndex]]);
}
}
// Add back face (reversed winding order)
const backVertexOffset = extruded.vertices.length;
for (const v of newVertices) {
extruded.vertices.push(new Vector(v.x, v.y, v.z - half));
}
for (const face of newFaces) {
if (face.length < 3) continue;
const mappedFace = [...face].reverse().map(i => i + backVertexOffset);
extruded.faces.push(mappedFace);
// ADD EDGES FOR BACK FACE
for (let i = 0; i < mappedFace.length; i++) {
const nextIndex = (i + 1) % mappedFace.length;
extruded.edges.push([mappedFace[i], mappedFace[nextIndex]]);
}
}
extruded.computeNormals();
return extruded;
}
variations() {
let vars = {};
if (this.data) {
let axes = this.face?.axes;
if (axes) {
axes.forEach(ax => {
vars[ax.tag] = ax.value;
});
}
}
fontFaceVariations.forEach(v => {
let val = this.face[v];
if (val !== 'normal') {
vars[v] = vars[v] || val;
}
});
return vars;
}
metadata() {
let meta = this.data?.name || {};
for (let p in this.face) {
if (!/^load/.test(p)) {
meta[p] = meta[p] || this.face[p];
}
}
return meta;
}
static async list(log = false) { // tmp
if (log) {
console.log('There are', document.fonts.size, 'font-faces\n');
let loaded = 0;
for (let fontFace of document.fonts.values()) {
console.log('FontFace: {');
for (let property in fontFace) {
console.log(' ' + property + ': ' + fontFace[property]);
}
console.log('}\n');
if (fontFace.status === 'loaded') {
loaded++;
}
}
console.log(loaded + ' loaded');
}
return await Array.from(document.fonts);
}
/////////////////////////////// HELPERS ////////////////////////////////
/*
Returns an array of line objects, each containing { text, x, y, glyphs: [ {g, path} ] }
*/
_lineateAndPathify(str, x, y, width, height, options = {}) {
let renderer = options?.graphics?._renderer || this._pInst._renderer;
renderer.push();
renderer.textFont(this);
// lineate and compute bounds for the text
let { lines, bounds } = renderer._computeBounds
(textCoreConstants._FONT_BOUNDS, str, x, y, width, height,
{ ignoreRectMode: true, ...options });
// compute positions for each of the lines
lines = this._position(renderer, lines, bounds, width, height);
// convert lines to paths
let uPE = this.data?.head?.unitsPerEm || 1000;
let scale = renderer.states.textSize / uPE;
const axs = this._currentAxes(renderer);
let pathsForLine = lines.map(l => this._lineToGlyphs(l, { scale, axs }));
renderer.pop();
return pathsForLine;
}
_currentAxes(renderer) {
let axs;
if ((this.data?.fvar?.length ?? 0) > 0) {
const fontAxes = this.data.fvar[0];
axs = fontAxes.map(([tag, minVal, defaultVal, maxVal, flags, name]) => {
if (!renderer) return defaultVal;
if (tag === 'wght') {
return renderer.states.fontWeight;
} else if (tag === 'wdth') {
// TODO: map from keywords (normal, ultra-condensed, etc) to values
// return renderer.states.fontStretch
return 100;
} else if (renderer.textCanvas().style.fontVariationSettings) {
const match = new RegExp(`\\b${tag}\s+(\d+)`)
.exec(renderer.textCanvas().style.fontVariationSettings);
if (match) {
return parseInt(match[1]);
} else {
return defaultVal;
}
} else {
return defaultVal;
}
});
}
return axs;
}
_textToPathPoints(str, x, y, width, height, options) {
({ width, height, options } = this._parseArgs(width, height, options));
// lineate and get the points for each line
let cmds = this.textToPaths(str, x, y, width, height, options);
// divide line-segments with intermediate points
const subdivide = (pts, pt1, pt2, md) => {
if (fn.dist(pt1.x, pt1.y, pt2.x, pt2.y) > md) {
let middle = { x: (pt1.x + pt2.x) / 2, y: (pt1.y + pt2.y) / 2 };
pts.push(middle);
subdivide(pts, pt1, middle, md);
subdivide(pts, middle, pt2, md);
}
};
// a point for each path-command plus line subdivisions
let pts = [];
let { textSize } = this._pInst._renderer.states;
let maxDist = (textSize / this.data.head.unitsPerEm) * 500;
for (let i = 0; i < cmds.length; i++) {
let { type, data: d } = cmds[i];
if (type !== 'Z') {
let pt = { x: d[d.length - 2], y: d[d.length - 1] };
if (type === 'L' && pts.length && !options?.nodivide > 0) {
subdivide(pts, pts[pts.length - 1], pt, maxDist);
}
pts.push(pt);
}
}
return pts;
}
_parseArgs(width, height, options = {}) {
if (typeof width === 'object') {
options = width;
width = height = undefined;
}
else if (typeof height === 'object') {
options = height;
height = undefined;
}
return { width, height, options };
}
_position(renderer, lines, bounds, width, height) {
let { textAlign, textLeading, textSize } = renderer.states;
let metrics = this._measureTextDefault(renderer, 'X');
let ascent = metrics.fontBoundingBoxAscent;
let coordify = (text, i) => {
let x = bounds.x;
let y = bounds.y + (i * textLeading) + ascent;
let lineWidth = renderer._fontWidthSingle(text);
if (textAlign === CENTER) {
x += (bounds.w - lineWidth) / 2;
}
else if (textAlign === RIGHT) {
x += (bounds.w - lineWidth);
}
if (typeof width !== 'undefined') {
switch (renderer.states.rectMode) {
case CENTER:
x -= width / 2;
y -= height / 2;
break;
case RADIUS:
x -= width;
y -= height;
break;
}
}
return { text, x, y };
};
return lines.map(coordify);
}
_lineToGlyphs(line, { scale = 1, axs } = {}) {
if (!this.data) {
throw Error('No font data available for "' + this.name
+ '"\nTry downloading a local copy of the font file');
}
let glyphShapes = Typr.U.shape(this.data, line.text, { axs });
line.glyphShapes = glyphShapes;
line.glyphs = this._shapeToPaths(glyphShapes, line, { scale, axs });
return line;
}
_positionGlyphs(text, options) {
let renderer = options?.graphics?._renderer || this._pInst._renderer;
const axs = this._currentAxes(renderer);
const glyphShapes = Typr.U.shape(this.data, text, { axs });
const positionedGlyphs = [];
let x = 0;
for (const glyph of glyphShapes) {
positionedGlyphs.push({ x, index: glyph.g, shape: glyph });
x += glyph.ax;
}
return positionedGlyphs;
}
_singleShapeToPath(shape, {
scale = 1,
x = 0,
y = 0,
lineX = 0,
lineY = 0,
axs
} = {}) {
let font = this.data;
let crdIdx = 0;
let { g, ax, ay, dx, dy } = shape;
let { crds, cmds } = Typr.U.glyphToPath(font, g, true, axs);
// can get simple points for each glyph here, but we don't need them ?
let glyph = { /*g: line.text[i], points: [],*/ path: { commands: [] } };
for (let j = 0; j < cmds.length; j++) {
let type = cmds[j], command = [type];
if (type in pathArgCounts) {
let argCount = pathArgCounts[type];
for (let k = 0; k < argCount; k += 2) {
let gx = crds[k + crdIdx] + x + dx;
let gy = crds[k + crdIdx + 1] + y + dy;
let fx = lineX + gx * scale;
let fy = lineY + gy * -scale;
command.push(fx);
command.push(fy);
/*if (k === argCount - 2) {
glyph.points.push({ x: fx, y: fy });
}*/
}
crdIdx += argCount;
}
glyph.path.commands.push(command);
}
return { glyph, ax, ay };
}
_shapeToPaths(glyphs, line, { scale = 1, axs } = {}) {
let x = 0, y = 0, paths = [];
if (glyphs.length !== line.text.length) {
throw Error('Invalid shape data');
}
// iterate over the glyphs, converting each to a glyph object
// with a path property containing an array of commands
for (let i = 0; i < glyphs.length; i++) {
const { glyph, ax, ay } = this._singleShapeToPath(glyphs[i], {
scale,
x,
y,
lineX: line.x,
lineY: line.y,
axs
});
paths.push(glyph);
x += ax; y += ay;
}
return paths;
}
_measureTextDefault(renderer, str) {
let { textAlign, textBaseline } = renderer.states;
let ctx = renderer.textDrawingContext();
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
let metrics = ctx.measureText(str);
ctx.textAlign = textAlign;
ctx.textBaseline = textBaseline;
return metrics;
}
drawPaths(ctx, commands, opts) { // for debugging
ctx.strokeStyle = opts?.stroke || ctx.strokeStyle;
ctx.fillStyle = opts?.fill || ctx.fillStyle;
ctx.beginPath();
commands.forEach(([type, ...data]) => {
if (type === 'M') {
ctx.moveTo(...data);
} else if (type === 'L') {
ctx.lineTo(...data);
} else if (type === 'C') {
ctx.bezierCurveTo(...data);
} else if (type === 'Q') {
ctx.quadraticCurveTo(...data);
} else if (type === 'Z') {
ctx.closePath();
}
});
if (opts?.fill) ctx.fill();
if (opts?.stroke) ctx.stroke();
}
_pathsToCommands(paths, scale) {
let commands = [];
for (let i = 0; i < paths.length; i++) {
let pathData = paths[i];
let { x, y, path } = pathData;
let { crds, cmds } = path;
// iterate over the path, storing each non-control point
for (let c = 0, j = 0; j < cmds.length; j++) {
let cmd = cmds[j], obj = { type: cmd, data: [] };
if (cmd === 'M' || cmd === 'L') {
obj.data.push(x + crds[c] * scale, y + crds[c + 1] * -scale);
c += 2;
}
else if (cmd === 'C') {
for (let i = 0; i < 6; i += 2) {
obj.data.push(
x + crds[c + i] * scale, y + crds[c + i + 1] * -scale
);
}
c += 6;
}
else if (cmd === 'Q') {
for (let i = 0; i < 4; i += 2) {
obj.data.push(
x + crds[c + i] * scale, y + crds[c + i + 1] * -scale
);
}
c += 4;
}
commands.push(obj);
}
}
return commands;
}
}
async function create(pInst, name, path, descriptors, rawFont) {
let face = createFontFace(name, path, descriptors, rawFont);
// load if we need to
if (face.status !== 'loaded') await face.load();
// add it to the document
document.fonts.add(face);
// ensure the font is ready to be rendered
await document.fonts.ready;
// return a new p5.Font
return new Font(pInst, face, name, path, rawFont);
}
function sanitizeFontName(name) {
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) {
name = "'" + String(name).replace(/'/g, "\\'") + "'";
}
return name;
}
function createFontFace(name, path, descriptors, rawFont) {
name = sanitizeFontName(name);
let fontArg = rawFont?._compressedData ?? rawFont?._data;
if (!fontArg) {
if (!validFontTypesRe.test(path)) {
throw Error(invalidFontError);
}
if (!path.startsWith('url(')) {
path = 'url(' + path + ')';
}
fontArg = path;
}
if ((rawFont?.fvar?.length ?? 0) > 0) {
descriptors = descriptors || {};
for (const [
tag, minVal, defaultVal, maxVal, flags, name
] of rawFont.fvar[0]) {
if (tag === 'wght') {
descriptors.weight = `${minVal} ${maxVal}`;
} else if (tag === 'wdth') {
descriptors.stretch = `${minVal}% ${maxVal}%`;
}
// TODO add other descriptors
}
}
// create/return the FontFace object
let face = new FontFace(name, fontArg, descriptors);
if (face.status === 'error') {
throw Error('Failed to create FontFace for "' + name + '"');
}
return face;
}
function extractFontName(font, path) {
let result, meta = font?.name;
// use the metadata if we have it
if (meta) {
if (meta.fullName) {
return meta.fullName;
}
if (meta.familyName) {
result = meta.familyName;
}
}
if (!result) {
// if not, try to extract the name from the path
let matches = extractFontNameRe.exec(path);
if (matches && matches.length >= 3) {
result = matches[1];
}
else {
// give up and return the full path
result = path;
}
}
// replace spaces with underscores
if (result.includes(' ')) {
result = result.replace(/ /g, '_');
}
return result;
}
function pathToPoints(cmds, options, font) {
const parseOpts = (options, defaults) => {
if (typeof options !== 'object') {
options = defaults;
} else {
for (const key in defaults) {
if (typeof options[key] === 'undefined') {
options[key] = defaults[key];
}
}
}
return options;
};
const at = (v, i) => {
const s = v.length;
return v[i < 0 ? i % s + s : i % s];
};
const simplify = (pts, angle) => {
angle = angle || 0;
let num = 0;
for (let i = pts.length - 1; pts.length > 3 && i >= 0; --i) {
if (collinear(at(pts, i - 1), at(pts, i), at(pts, i + 1), angle)) {
pts.splice(i % pts.length, 1); // Remove middle point
num++;
}
}
return num;
};
const collinear = (a, b, c, thresholdAngle) => {
if (!thresholdAngle) {
return areaTriangle(a, b, c) === 0;
}
if (typeof collinear.tmpPoint1 === 'undefined') {
collinear.tmpPoint1 = [];
collinear.tmpPoint2 = [];
}
const ab = collinear.tmpPoint1,
bc = collinear.tmpPoint2;
ab.x = b.x - a.x;
ab.y = b.y - a.y;
bc.x = c.x - b.x;
bc.y = c.y - b.y;
const dot = ab.x * bc.x + ab.y * bc.y,
magA = Math.sqrt(ab.x * ab.x + ab.y * ab.y),
magB = Math.sqrt(bc.x * bc.x + bc.y * bc.y),
angle = Math.acos(dot / (magA * magB));
return angle < thresholdAngle;
};
const areaTriangle = (a, b, c) => {
return (b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1]);
};
const path = createFromCommands(arrayCommandsToObjects(cmds));
let opts = parseOpts(options, {
sampleFactor: 0.1,
simplifyThreshold: 0
});
const totalPoints = Math.max(
1,
Math.ceil(path.getTotalLength() * opts.sampleFactor)
);
let points = [];
const mode = font._pInst.angleMode();
const DEGREES = font._pInst.DEGREES;
for (let i = 0; i < totalPoints; i++) {
const length = path.getTotalLength() * (
totalPoints === 1
? 0
: (i / (totalPoints - 1))
);
points.push({
...path.getPointAtLength(length),
get angle() {
const angle = path.getAngleAtLength(length);
if (mode === DEGREES) {
return angle * 180 / Math.PI;
} else {
return angle;
}
},
// For backwards compatibility
get alpha() {
return this.angle;
}
});
}
if (opts.simplifyThreshold) {
simplify(points, opts.simplifyThreshold);
}
return points;
}
function unquote(name) {
// Unquote name from CSS
if ((name.startsWith('"') || name.startsWith("'")) && name.at(0) === name.at(-1)) {
return name.slice(1, -1).replace(/\/(['"])/g, '$1');
}
return name;
}
function parseCreateArgs(...args/*path, name, onSuccess, onError*/) {
// parse the path
let path = args.shift();
if (typeof path !== 'string' || path.length === 0) {
p5._friendlyError(invalidFontError, 'p5.loadFont'); // ?
}
// parse the name
let name;
if (typeof args[0] === 'string') {
name = args.shift();
}
// get the callbacks/descriptors if any
let success, error, options;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (typeof arg === 'function') {
if (!success) {
success = arg;
} else {
error = arg;
}
}
else if (typeof arg === 'object') {
options = arg;
}
}
return { path, name, success, error, options };
}
function font(p5, fn) {
/**
* A class to describe fonts. Create through <a href="#/p5/loadFont">`loadFont()`</a>.
*
* @class p5.Font
*/
p5.Font = Font;
/**
* @private
*/
fn.parseFontData = async function(pathOrData) {
// load the raw font bytes
let result = pathOrData instanceof Uint8Array
? pathOrData
: await fn.loadBytes(pathOrData);
//console.log('result:', result);
if (!result) {
throw Error('Failed to load font data');
}
// parse the font data
let fonts = Typr.parse(result);
// TODO: generate descriptors from font in the future
if (fonts.length === 0 || fonts[0].cmap === undefined) {
throw Error('parsing font data');
}
return fonts[0];
};
/**
* Loads a font and creates a <a href="#/p5.Font">p5.Font</a> object.
* `loadFont()` can load fonts in either .otf or .ttf format. Loaded fonts can
* be used to style text on the canvas and in HTML elements.
*
* The first parameter, `path`, is the path to a font file.
* Paths to local files should be relative. For example,
* `'assets/inconsolata.otf'`. The Inconsolata font used in the following
* examples can be downloaded for free
* <a href="https://www.fontsquirrel.com/fonts/inconsolata" target="_blank">here</a>.
* Paths to remote files should be URLs. For example,
* `'https://example.com/inconsolata.otf'`. URLs may be blocked due to browser
* security.
*
* In 2D mode, `path` can take on a few other forms. It could be a path to a CSS file,
* such as one from <a href="https://fonts.google.com/">Google Fonts.</a> It could also
* be a string with a <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face">CSS `@font-face` declaration.</a>
*
* The second parameter, `successCallback`, is optional. If a function is
* passed, it will be called once the font has loaded. The callback function
* may use the new <a href="#/p5.Font">p5.Font</a> object if needed.
*
* The third parameter, `failureCallback`, is also optional. If a function is
* passed, it will be called if the font fails to load. The callback function
* may use the error
* <a href="https://developer.mozilla.org/en-US/docs/Web/API/Event" target="_blank">Event</a>
* object if needed.
*
* Fonts can take time to load. `await` the result of `loadFont()` in
* <a href="#/p5/setup">setup()</a> before using the result.
*
* @method loadFont
* @for p5
* @param {String} path path of the font or CSS file to be loaded, or a CSS `@font-face` string.
* @param {String} [name] An alias that can be used for this font in `textFont()`. Defaults to the name in the font's metadata.
* @param {Object} [options] An optional object with extra CSS font face descriptors, or p5.js font settings.
* @param {String|Array<String>} [options.sets] (Experimental) An optional string of list of strings with Unicode character set names that should be included. When a CSS file is used as the font, it may contain multiple font files. The font best matching the requested character sets will be picked.
* @param {Function} [successCallback] function called with the
* <a href="#/p5.Font">p5.Font</a> object after it
* loads.
* @param {Function} [failureCallback] function called with the error
* <a href="https://developer.mozilla.org/en-US/docs/Web/API/Event" target="_blank">Event</a>
* object if the font fails to load.
* @return {Promise<p5.Font>} <a href="#/p5.Font">p5.Font</a> object.
* @example
* let font;
*
* async function setup() {
* createCanvas(100, 100);
* font = await loadFont('assets/inconsolata.otf');
* fill('deeppink');
* textFont(font);
* textSize(36);
* text('p5*js', 10, 50);
*
* describe('The text "p5*js" written in pink on a white background.');
* }
*
* @example
* function setup() {
* createCanvas(100, 100);
* loadFont('assets/inconsolata.otf', font => {
* fill('deeppink');
* textFont(font);
* textSize(36);
* text('p5*js', 10, 50);
*
* describe('The text "p5*js" written in pink on a white background.');
* });
* }
*
* @example
* function setup() {
* createCanvas(100, 100);
* loadFont('assets/inconsolata.otf', success, failure);
* }
*
* function success(font) {
* fill('deeppink');
* textFont(font);
* textSize(36);
* text('p5*js', 10, 50);
*
* describe('The text "p5*js" written in pink on a white background.');
* }
*
* function failure(event) {
* console.error('Oops!', event);
* }
*
* @example
* async function setup() {
* createCanvas(100, 100);
* await loadFont('assets/inconsolata.otf');
* let p = createP('p5*js');
* p.style('color', 'deeppink');
* p.style('font-family', 'Inconsolata');
* p.style('font-size', '36px');
* p.position(10, 50);
*
* describe('The text "p5*js" written in pink on a white background.');
* }
*
* @example
* // META:norender
* // Some other forms of loading fonts:
* loadFont("https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap");
*
* loadFont('@font-face { font-family: "Bricolage Grotesque", serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-variation-settings: "wdth" 100; }');
*/
/**
* @method loadFont
* @for p5
* @param {String} path path of the font to be loaded.
* @param {Function} [successCallback] function called with the
* <a href="#/p5.Font">p5.Font</a> object after it
* loads.
* @param {Function} [failureCallback] function called with the error
* <a href="https://developer.mozilla.org/en-US/docs/Web/API/Event" target="_blank">Event</a>
* object if the font fails to load.
* @returns {Promise<p5.Font>} The font.
*/
fn.loadFont = async function (...args) {
/*path, name, onSuccess, onError, descriptors*/
let {
path,
name,
success,
error,
options: { sets, ...descriptors } = {}
} = parseCreateArgs(...args);
let isCSS = path.includes('@font-face');
if (!isCSS) {
let info;
try {
info = await fetch(path, { method: 'HEAD' });
} catch (e) {
// Sometimes files fail when requested with HEAD. Fallback to a
// regular GET. It loads more data, but at least then it's cached
// for the likely case when we have to fetch the whole thing.
info = await fetch(path);
}
const isCSSFile = info.headers.get('content-type')?.startsWith('text/css');
if (isCSSFile) {
isCSS = true;
path = await fetch(path).then(res => res.text());
}
}
if (isCSS) {
const stylesheet = new CSSStyleSheet();
await stylesheet.replace(path);
const possibleFonts = [];
for (const rule of stylesheet.cssRules) {
if (rule instanceof CSSFontFaceRule) {
const style = rule.style;
let name = unquote(style.getPropertyValue('font-family'));
const src = style.getPropertyValue('src');
const fontDescriptors = { ...(descriptors || {}) };
for (const key of style) {
if (key === 'font-family' || key === 'src') continue;
const camelCaseKey = key
.replace(/^font-/, '')
.split('-')
.map((v, i) => i === 0 ? v : `${v[0].toUpperCase()}${v.slice(1)}`)
.join('');
fontDescriptors[camelCaseKey] = style.getPropertyValue(key);
}
possibleFonts.push({
name,
src,
fontDescriptors,
loadWithData: async () => {
let fontData;
try {
const urlMatch = /url\(([^\)]+)\)/.exec(src);
if (urlMatch) {
let url = urlMatch[1];
if (/^['"]/.exec(url) && url.at(0) === url.at(-1)) {
url = url.slice(1, -1);
}
fontData = await fn.parseFontData(url);
}
} catch (_e) {}
return create(this, name, src, fontDescriptors, fontData);
},
loadWithoutData: () => create(this, name, src, fontDescriptors)
});
}
}
// TODO: handle multiple font faces?
sets = sets || ['latin']; // Default to latin for now if omitted
const requestedGroups = (sets instanceof Array ? sets : [sets])
.map(s => s.toLowerCase());
// Grab thr named groups with names that include the requested keywords
const requestedCategories = unicodeRanges
.filter(r => requestedGroups.some(
g => r.category.includes(g) &&
// Only include extended character sets if specifically requested
r.category.includes('ext') === g.includes('ext')
));
const requestedRanges = new Set(
UnicodeRange.parse(
requestedCategories.map(c => `U+${c.hexrange[0]}-${c.hexrange[1]}`)
)
);
let closestRangeOverlap = 0;
let closestDescriptorOverlap = 0;
let closestMatch = undefined;
for (const font of possibleFonts) {
if (!font.fontDescriptors.unicodeRange) continue;
const fontRange = new Set(
UnicodeRange.parse(
font.fontDescriptors.unicodeRange.split(/,\s*/g)
)
);
const rangeOverlap = [...fontRange.values()]
.filter(v => requestedRanges.has(v))
.length;
const targetDescriptors = {
// Default to normal style at regular weight
style: 'normal',
weight: 400,
// Override from anything else passed in
...descriptors
};
const descriptorOverlap = Object.keys(font.fontDescriptors)
.filter(k => font.fontDescriptors[k] === targetDescriptors[k])
.length;
if (
descriptorOverlap > closestDescriptorOverlap ||
(
descriptorOverlap === closestDescriptorOverlap &&
rangeOverlap >= closestRangeOverlap
)
) {
closestDescriptorOverlap = descriptorOverlap;
closestRangeOverlap = rangeOverlap;
closestMatch = font;
}
}
const picked = (closestMatch || possibleFonts.at(-1));
for (const font of possibleFonts) {
if (font !== picked) {
// Load without parsing data with Typr so that it still can be accessed
// via regular CSS by name
font.loadWithoutData();
}
}
return picked?.loadWithData();
}
let pfont;
try {
const fontData = await fn.parseFontData(path);
// make sure we have a valid name
name = name || extractFontName(fontData, path);
// create a FontFace object and pass it to the p5.Font constructor
pfont = await create(this, name, path, descriptors, fontData);
} catch (err) {
// failed to parse the font, load it as a simple FontFace
let ident = name || path
.substring(path.lastIndexOf('/') + 1)
.replace(/\.[^/.]+$/, '');
console.warn(`WARN: No glyph data for '${ident}', retrying as FontFace`);
try {
// create a FontFace object and pass it to p5.Font
pfont = await create(this, ident, path, descriptors);
}
catch (err) {
if (error) return error(err);
throw err;
}
}
const cb = () => {
if (success) return success(pfont);
return pfont;
};
return this._internal ? this._internal(cb) : cb();
};
}
// Convert arrays to named objects
const arrayCommandsToObjects = commands => commands.map(command => {
const type = command[0];
switch (type) {
case 'Z': {
return { type };
}
case 'M':
case 'L': {
const [, x, y] = command;
return { type, x, y };
}
case 'Q': {
const [, x1, y1, x, y] = command;
return { type, x1, y1, x, y };
}
case 'C': {
const [, x1, y1, x2, y2, x, y] = command;
return { type, x1, y1, x2, y2, x, y };
}
default: {
throw new Error(`Unexpected path command: ${type}`);
}
}
});
if (typeof p5 !== 'undefined') {
font(p5, p5.prototype);
}
export { Font, sanitizeFontName as _sanitizeFontName, arrayCommandsToObjects, font as default };