// pathfinder/client/src/text.ts // // Copyright © 2017 The Pathfinder Project Developers. // // Licensed under the Apache License, Version 2.0 or the MIT license // , at your // option. This file may not be copied, modified, or distributed // except according to those terms. import {Font, Metrics} from 'opentype.js'; import * as base64js from 'base64-js'; import * as glmatrix from 'gl-matrix'; import * as _ from 'lodash'; import * as opentype from "opentype.js"; import {B_QUAD_SIZE, PathfinderMeshData} from "./meshes"; import {UINT32_SIZE, UINT32_MAX, assert, panic} from "./utils"; export const BUILTIN_FONT_URI: string = "/otf/demo"; const PARTITION_FONT_ENDPOINT_URI: string = "/partition-font"; export class TextRun { constructor(text: string | Glyph[], origin: number[], font: Font, createGlyph: CreateGlyphFn) { if (typeof(text) === 'string') text = font.stringToGlyphs(text).map(createGlyph); this.glyphs = text; this.origin = origin; } layout() { let currentX = this.origin[0]; for (const glyph of this.glyphs) { glyph.origin = glmatrix.vec2.fromValues(currentX, this.origin[1]); currentX += glyph.advanceWidth; } } readonly glyphs: Glyph[]; readonly origin: number[]; } export interface ExpandedMeshData { meshes: PathfinderMeshData; } type CreateGlyphFn = (glyph: opentype.Glyph) => Glyph; export interface PixelMetrics { left: number; right: number; ascent: number; descent: number; } opentype.Font.prototype.isSupported = function() { return (this as any).supported; } opentype.Font.prototype.lineHeight = function() { const os2Table = this.tables.os2; return os2Table.sTypoAscender - os2Table.sTypoDescender + os2Table.sTypoLineGap; }; export class GlyphStorage { constructor(fontData: ArrayBuffer, textRuns: TextRun[], createGlyph: CreateGlyphFn, font?: Font) { if (font == null) { font = opentype.parse(fontData); assert(font.isSupported(), "The font type is unsupported!"); } this.fontData = fontData; this.textRuns = textRuns; this.font = font; // Determine all glyphs potentially needed. this.uniqueGlyphs = _.flatMap(this.textRuns, textRun => textRun.glyphs); this.uniqueGlyphs.sort((a, b) => a.index - b.index); this.uniqueGlyphs = _.sortedUniqBy(this.uniqueGlyphs, glyph => glyph.index); } partition(): Promise { // Build the partitioning request to the server. // // FIXME(pcwalton): If this is a builtin font, don't resend it to the server! const request = { face: { Custom: base64js.fromByteArray(new Uint8Array(this.fontData)), }, fontIndex: 0, glyphs: this.uniqueGlyphs.map(glyph => { const metrics = glyph.metrics; return { id: glyph.index, transform: [1, 0, 0, 1, 0, 0], }; }), pointSize: this.font.unitsPerEm, }; // Make the request. return window.fetch(PARTITION_FONT_ENDPOINT_URI, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(request), }).then(response => response.text()).then(responseText => { const response = JSON.parse(responseText); if (!('Ok' in response)) panic("Failed to partition the font!"); return new PathfinderMeshData(response.Ok.pathData); }); } expandMeshes(meshes: PathfinderMeshData): ExpandedMeshData { const bQuads = _.chunk(new Uint32Array(meshes.bQuads), B_QUAD_SIZE / UINT32_SIZE); const bVertexPositions = new Float32Array(meshes.bVertexPositions); const bVertexPathIDs = new Uint16Array(meshes.bVertexPathIDs); const bVertexLoopBlinnData = new Uint32Array(meshes.bVertexLoopBlinnData); const expandedBQuads: number[] = []; const expandedBVertexPositions: number[] = []; const expandedBVertexPathIDs: number[] = []; const expandedBVertexLoopBlinnData: number[] = []; const expandedCoverInteriorIndices: number[] = []; const expandedCoverCurveIndices: number[] = []; let textGlyphIndex = 0; for (const textRun of this.textRuns) { for (const textGlyph of textRun.glyphs) { const uniqueGlyphIndex = _.sortedIndexBy(this.uniqueGlyphs, textGlyph, 'index'); if (uniqueGlyphIndex < 0) continue; const firstBVertexIndex = _.sortedIndex(bVertexPathIDs, uniqueGlyphIndex + 1); if (firstBVertexIndex < 0) continue; // Copy over vertices. let bVertexIndex = firstBVertexIndex; const firstExpandedBVertexIndex = expandedBVertexPathIDs.length; while (bVertexIndex < bVertexPathIDs.length && bVertexPathIDs[bVertexIndex] === uniqueGlyphIndex + 1) { expandedBVertexPositions.push(bVertexPositions[bVertexIndex * 2 + 0], bVertexPositions[bVertexIndex * 2 + 1]); expandedBVertexPathIDs.push(textGlyphIndex + 1); expandedBVertexLoopBlinnData.push(bVertexLoopBlinnData[bVertexIndex]); bVertexIndex++; } // Copy over indices. copyIndices(expandedCoverInteriorIndices, new Uint32Array(meshes.coverInteriorIndices), firstExpandedBVertexIndex, firstBVertexIndex, bVertexIndex); copyIndices(expandedCoverCurveIndices, new Uint32Array(meshes.coverCurveIndices), firstExpandedBVertexIndex, firstBVertexIndex, bVertexIndex); // Copy over B-quads. let firstBQuadIndex = _.findIndex(bQuads, bQuad => bVertexPathIDs[bQuad[0]] == uniqueGlyphIndex + 1); if (firstBQuadIndex < 0) firstBQuadIndex = bQuads.length; const indexDelta = firstExpandedBVertexIndex - firstBVertexIndex; for (let bQuadIndex = firstBQuadIndex; bQuadIndex < bQuads.length; bQuadIndex++) { const bQuad = bQuads[bQuadIndex]; if (bVertexPathIDs[bQuad[0]] !== uniqueGlyphIndex + 1) break; for (let indexIndex = 0; indexIndex < B_QUAD_SIZE / UINT32_SIZE; indexIndex++) { const srcIndex = bQuad[indexIndex]; if (srcIndex === UINT32_MAX) expandedBQuads.push(srcIndex); else expandedBQuads.push(srcIndex + indexDelta); } } textGlyphIndex++; } } return { meshes: new PathfinderMeshData({ bQuads: new Uint32Array(expandedBQuads).buffer as ArrayBuffer, bVertexPositions: new Float32Array(expandedBVertexPositions).buffer as ArrayBuffer, bVertexPathIDs: new Uint16Array(expandedBVertexPathIDs).buffer as ArrayBuffer, bVertexLoopBlinnData: new Uint32Array(expandedBVertexLoopBlinnData).buffer as ArrayBuffer, coverInteriorIndices: new Uint32Array(expandedCoverInteriorIndices).buffer as ArrayBuffer, coverCurveIndices: new Uint32Array(expandedCoverCurveIndices).buffer as ArrayBuffer, edgeUpperCurveIndices: new ArrayBuffer(0), edgeUpperLineIndices: new ArrayBuffer(0), edgeLowerCurveIndices: new ArrayBuffer(0), edgeLowerLineIndices: new ArrayBuffer(0), }) } } layoutRuns() { this.textRuns.forEach(textRun => textRun.layout()); } get allGlyphs(): Glyph[] { return _.flatMap(this.textRuns, textRun => textRun.glyphs); } readonly fontData: ArrayBuffer; readonly font: Font; readonly textRuns: TextRun[]; readonly uniqueGlyphs: Glyph[]; } export class TextLayout { constructor(fontData: ArrayBuffer, text: string, createGlyph: CreateGlyphFn) { const font = opentype.parse(fontData); assert(font.isSupported(), "The font type is unsupported!"); const os2Table = font.tables.os2; const lineHeight = os2Table.sTypoAscender - os2Table.sTypoDescender + os2Table.sTypoLineGap; this.textRuns = text.split("\n").map((line, lineNumber) => { return new TextRun(line, [0.0, -lineHeight * lineNumber], font, createGlyph); }); this.glyphStorage = new GlyphStorage(fontData, this.textRuns, createGlyph, font); } layoutRuns() { this.textRuns.forEach(textRun => textRun.layout()); } get allGlyphs(): Glyph[] { return _.flatMap(this.textRuns, textRun => textRun.glyphs); } readonly textRuns: TextRun[]; readonly glyphStorage: GlyphStorage; } export abstract class PathfinderGlyph { constructor(glyph: opentype.Glyph) { this.opentypeGlyph = glyph; this._metrics = null; this.origin = glmatrix.vec2.create(); } get index(): number { return (this.opentypeGlyph as any).index; } get metrics(): opentype.Metrics { if (this._metrics == null) this._metrics = this.opentypeGlyph.getMetrics(); return this._metrics; } get advanceWidth(): number { return this.opentypeGlyph.advanceWidth; } pixelOrigin(pixelsPerUnit: number): glmatrix.vec2 { const origin = glmatrix.vec2.create(); glmatrix.vec2.scale(origin, this.origin, pixelsPerUnit); return origin; } setPixelOrigin(pixelOrigin: glmatrix.vec2, pixelsPerUnit: number): void { glmatrix.vec2.scale(this.origin, pixelOrigin, 1.0 / pixelsPerUnit); } setPixelLowerLeft(pixelLowerLeft: glmatrix.vec2, pixelsPerUnit: number): void { const pixelMetrics = this.pixelMetrics(pixelsPerUnit); const pixelOrigin = glmatrix.vec2.fromValues(pixelLowerLeft[0], pixelLowerLeft[1] + pixelMetrics.descent); this.setPixelOrigin(pixelOrigin, pixelsPerUnit); } protected pixelMetrics(pixelsPerUnit: number): PixelMetrics { const metrics = this.metrics; return { left: Math.floor(metrics.xMin * pixelsPerUnit), right: Math.ceil(metrics.xMax * pixelsPerUnit), ascent: Math.ceil(metrics.yMax * pixelsPerUnit), descent: Math.ceil(-metrics.yMin * pixelsPerUnit), }; } pixelRect(pixelsPerUnit: number): glmatrix.vec4 { const pixelMetrics = this.pixelMetrics(pixelsPerUnit); const textGlyphOrigin = glmatrix.vec2.clone(this.origin); glmatrix.vec2.scale(textGlyphOrigin, textGlyphOrigin, pixelsPerUnit); glmatrix.vec2.round(textGlyphOrigin, textGlyphOrigin); return glmatrix.vec4.fromValues(textGlyphOrigin[0], textGlyphOrigin[1] - pixelMetrics.descent, textGlyphOrigin[0] + pixelMetrics.right, textGlyphOrigin[1] + pixelMetrics.ascent); } readonly opentypeGlyph: opentype.Glyph; private _metrics: Metrics | null; /// In font units, relative to (0, 0). origin: glmatrix.vec2; } function copyIndices(destIndices: number[], srcIndices: Uint32Array, firstExpandedIndex: number, firstIndex: number, lastIndex: number) { // FIXME(pcwalton): Use binary search instead of linear search. const indexDelta = firstExpandedIndex - firstIndex; let indexIndex = _.findIndex(srcIndices, srcIndex => srcIndex >= firstIndex && srcIndex < lastIndex); if (indexIndex < 0) return; while (indexIndex < srcIndices.length) { const index = srcIndices[indexIndex]; if (index < firstIndex || index >= lastIndex) break; destIndices.push(index + indexDelta); indexIndex++; } }