// 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 interface ExpandedMeshData { meshes: PathfinderMeshData; } export interface PartitionResult { meshes: PathfinderMeshData, time: number, } 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 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; } } get measure(): number { const lastGlyph = _.last(this.glyphs); return lastGlyph == null ? 0.0 : lastGlyph.origin[0] + lastGlyph.advanceWidth; } readonly glyphs: Glyph[]; readonly origin: number[]; } export class TextFrame { constructor(runs: TextRun[], font: Font) { this.runs = runs; this.font = font; } expandMeshes(uniqueGlyphs: Glyph[], meshes: PathfinderMeshData): ExpandedMeshData { const pathIDs = []; for (const textRun of this.runs) { for (const textGlyph of textRun.glyphs) { const uniqueGlyphIndex = _.sortedIndexBy(uniqueGlyphs, textGlyph, 'index'); if (uniqueGlyphIndex >= 0) pathIDs.push(uniqueGlyphIndex + 1); } } return { meshes: meshes.expand(pathIDs), }; } get bounds(): glmatrix.vec4 { if (this.runs.length === 0) return glmatrix.vec4.create(); const upperLeft = glmatrix.vec2.clone(this.runs[0].origin); const lowerRight = glmatrix.vec2.clone(_.last(this.runs)!.origin); const lowerLeft = glmatrix.vec2.clone([upperLeft[0], lowerRight[1]]); const upperRight = glmatrix.vec2.clone([lowerRight[0], upperLeft[1]]); const lineHeight = this.font.lineHeight(); lowerLeft[1] -= lineHeight; upperRight[1] += lineHeight * 2.0; upperRight[0] = _.defaultTo(_.max(this.runs.map(run => run.measure)), 0.0); return glmatrix.vec4.clone([lowerLeft[0], lowerLeft[1], upperRight[0], upperRight[1]]); } get allGlyphs(): Glyph[] { return _.flatMap(this.runs, run => run.glyphs); } readonly runs: TextRun[]; readonly origin: glmatrix.vec3; private readonly font: Font; } export class GlyphStorage { constructor(fontData: ArrayBuffer, glyphs: Glyph[], font?: Font) { if (font == null) { font = opentype.parse(fontData); assert(font.isSupported(), "The font type is unsupported!"); } this.fontData = fontData; this.font = font; // Determine all glyphs potentially needed. this.uniqueGlyphs = 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 { meshes: new PathfinderMeshData(response.Ok.pathData), time: response.Ok.time, }; }); } readonly fontData: ArrayBuffer; readonly font: Font; readonly uniqueGlyphs: Glyph[]; } export class TextFrameGlyphStorage extends GlyphStorage { constructor(fontData: ArrayBuffer, textFrames: TextFrame[], font?: Font) { const allGlyphs = _.flatMap(textFrames, textRun => textRun.allGlyphs); super(fontData, allGlyphs, font); this.textFrames = textFrames; } expandMeshes(meshes: PathfinderMeshData): ExpandedMeshData[] { return this.textFrames.map(textFrame => textFrame.expandMeshes(this.uniqueGlyphs, meshes)); } layoutRuns() { for (const textFrame of this.textFrames) textFrame.runs.forEach(textRun => textRun.layout()); } get allGlyphs(): Glyph[] { return _.flatMap(this.textFrames, textRun => textRun.allGlyphs); } readonly textFrames: TextFrame[]; } export class SimpleTextLayout { constructor(fontData: ArrayBuffer, text: string, createGlyph: CreateGlyphFn) { const font = opentype.parse(fontData); assert(font.isSupported(), "The font type is unsupported!"); const lineHeight = font.lineHeight(); const textRuns: TextRun[] = text.split("\n").map((line, lineNumber) => { return new TextRun(line, [0.0, -lineHeight * lineNumber], font, createGlyph); }); this.textFrame = new TextFrame(textRuns, font); this.glyphStorage = new TextFrameGlyphStorage(fontData, [this.textFrame], font); } layoutRuns() { this.textFrame.runs.forEach(textRun => textRun.layout()); } get allGlyphs(): Glyph[] { return this.textFrame.allGlyphs; } readonly textFrame: TextFrame; readonly glyphStorage: TextFrameGlyphStorage; } 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); } private calculatePixelXMin(pixelsPerUnit: number): number { return Math.floor(this.metrics.xMin * pixelsPerUnit); } private calculatePixelDescent(pixelsPerUnit: number): number { return Math.ceil(-this.metrics.yMin * pixelsPerUnit); } setPixelLowerLeft(pixelLowerLeft: glmatrix.vec2, pixelsPerUnit: number): void { const pixelXMin = this.calculatePixelXMin(pixelsPerUnit); const pixelDescent = this.calculatePixelDescent(pixelsPerUnit); const pixelOrigin = glmatrix.vec2.fromValues(pixelLowerLeft[0] - pixelXMin, pixelLowerLeft[1] + pixelDescent); this.setPixelOrigin(pixelOrigin, pixelsPerUnit); } protected pixelMetrics(hint: Hint, pixelsPerUnit: number): PixelMetrics { const metrics = this.metrics; const top = hint.hintPosition(glmatrix.vec2.fromValues(0, metrics.yMax))[1]; return { left: this.calculatePixelXMin(pixelsPerUnit), right: Math.ceil(metrics.xMax * pixelsPerUnit), ascent: Math.ceil(top * pixelsPerUnit), descent: this.calculatePixelDescent(pixelsPerUnit), }; } calculatePixelOrigin(hint: Hint, pixelsPerUnit: number): glmatrix.vec2 { const textGlyphOrigin = glmatrix.vec2.clone(this.origin); glmatrix.vec2.scale(textGlyphOrigin, textGlyphOrigin, pixelsPerUnit); return textGlyphOrigin; } pixelRect(hint: Hint, pixelsPerUnit: number): glmatrix.vec4 { const pixelMetrics = this.pixelMetrics(hint, pixelsPerUnit); const textGlyphOrigin = this.calculatePixelOrigin(hint, pixelsPerUnit); glmatrix.vec2.round(textGlyphOrigin, textGlyphOrigin); return glmatrix.vec4.fromValues(textGlyphOrigin[0] + pixelMetrics.left, 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; } export class Hint { constructor(font: Font, pixelsPerUnit: number, useHinting: boolean) { this.useHinting = useHinting; const os2Table = font.tables.os2; this.xHeight = os2Table.sxHeight != null ? os2Table.sxHeight : 0; if (!useHinting) { this.hintedXHeight = this.xHeight; } else { this.hintedXHeight = Math.ceil(Math.ceil(this.xHeight * pixelsPerUnit) / pixelsPerUnit); } } hintPosition(position: glmatrix.vec2): glmatrix.vec2 { if (!this.useHinting) return position; if (position[1] < 0.0) return position; if (position[1] >= this.hintedXHeight) { return glmatrix.vec2.fromValues(position[0], position[1] - this.xHeight + this.hintedXHeight); } return glmatrix.vec2.fromValues(position[0], position[1] / this.xHeight * this.hintedXHeight); } readonly xHeight: number; readonly hintedXHeight: number; private useHinting: boolean; }