// 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 * as base64js from 'base64-js'; import * as glmatrix from 'gl-matrix'; import * as _ from 'lodash'; import * as opentype from "opentype.js"; import {Metrics} from 'opentype.js'; import {B_QUAD_SIZE, PathfinderMeshData} from "./meshes"; import {assert, lerp, panic, UINT32_MAX, UINT32_SIZE, unwrapNull} 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; } 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 PathfinderFont { readonly opentypeFont: opentype.Font; readonly data: ArrayBuffer; private metricsCache: Metrics[]; constructor(data: ArrayBuffer) { this.data = data; this.opentypeFont = opentype.parse(data); if (!this.opentypeFont.isSupported()) panic("Unsupported font!"); this.metricsCache = []; } metricsForGlyph(glyphID: number): Metrics | null { if (this.metricsCache[glyphID] == null) this.metricsCache[glyphID] = this.opentypeFont.glyphs.get(glyphID).getMetrics(); return this.metricsCache[glyphID]; } } export class TextRun { readonly glyphIDs: number[]; advances: number[]; readonly origin: number[]; private readonly font: PathfinderFont; constructor(text: number[] | string, origin: number[], font: PathfinderFont) { if (typeof(text) === 'string') { this.glyphIDs = font.opentypeFont .stringToGlyphs(text) .map(glyph => (glyph as any).index); } else { this.glyphIDs = text; } this.origin = origin; this.advances = []; this.font = font; } layout() { this.advances = []; let currentX = 0; for (const glyphID of this.glyphIDs) { this.advances.push(currentX); currentX += this.font.opentypeFont.glyphs.get(glyphID).advanceWidth; } } calculatePixelOriginForGlyphAt(index: number, pixelsPerUnit: number, hint: Hint): glmatrix.vec2 { const textGlyphOrigin = glmatrix.vec2.clone(this.origin); textGlyphOrigin[0] += this.advances[index]; glmatrix.vec2.scale(textGlyphOrigin, textGlyphOrigin, pixelsPerUnit); return textGlyphOrigin; } pixelRectForGlyphAt(index: number, pixelsPerUnit: number, hint: Hint, subpixelGranularity: number): glmatrix.vec4 { const metrics = unwrapNull(this.font.metricsForGlyph(this.glyphIDs[index])); const textGlyphOrigin = this.calculatePixelOriginForGlyphAt(index, pixelsPerUnit, hint); textGlyphOrigin[0] *= subpixelGranularity; glmatrix.vec2.round(textGlyphOrigin, textGlyphOrigin); textGlyphOrigin[0] /= subpixelGranularity; return calculatePixelRectForGlyph(metrics, textGlyphOrigin, pixelsPerUnit, hint); } subpixelForGlyphAt(index: number, pixelsPerUnit: number, hint: Hint, subpixelGranularity: number): number { const textGlyphOrigin = this.calculatePixelOriginForGlyphAt(index, pixelsPerUnit, hint)[0]; return Math.abs(Math.round(textGlyphOrigin * subpixelGranularity) % subpixelGranularity); } get measure(): number { const lastGlyphID = _.last(this.glyphIDs), lastAdvance = _.last(this.advances); if (lastGlyphID == null || lastAdvance == null) return 0.0; return lastAdvance + this.font.opentypeFont.glyphs.get(lastGlyphID).advanceWidth; } } export class TextFrame { readonly runs: TextRun[]; readonly origin: glmatrix.vec3; private readonly font: PathfinderFont; constructor(runs: TextRun[], font: PathfinderFont) { this.runs = runs; this.origin = glmatrix.vec3.create(); this.font = font; } expandMeshes(meshes: PathfinderMeshData, glyphIDs: number[]): ExpandedMeshData { const pathIDs = []; for (const textRun of this.runs) { for (const glyphID of textRun.glyphIDs) { if (glyphID === 0) continue; const pathID = _.sortedIndexOf(glyphIDs, glyphID); pathIDs.push(pathID + 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.opentypeFont.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 totalGlyphCount(): number { return _.sumBy(this.runs, run => run.glyphIDs.length); } get allGlyphIDs(): number[] { const glyphIDs = []; for (const run of this.runs) glyphIDs.push(...run.glyphIDs); return glyphIDs; } } /// Stores one copy of each glyph. export class GlyphStore { readonly font: PathfinderFont; readonly glyphIDs: number[]; constructor(font: PathfinderFont, glyphIDs: number[]) { this.font = font; this.glyphIDs = glyphIDs; } 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.font.data)), }, fontIndex: 0, glyphs: this.glyphIDs.map(id => ({ id: id, transform: [1, 0, 0, 1, 0, 0] })), pointSize: this.font.opentypeFont.unitsPerEm, }; // Make the request. return window.fetch(PARTITION_FONT_ENDPOINT_URI, { body: JSON.stringify(request), headers: {'Content-Type': 'application/json'}, method: 'POST', }).then(response => response.text()).then(responseText => { const response = JSON.parse(responseText); if (!('Ok' in response)) panic(`Failed to partition the font: ${response.Err}`); return { meshes: new PathfinderMeshData(response.Ok.pathData), time: response.Ok.time, }; }); } indexOfGlyphWithID(glyphID: number): number | null { const index = _.sortedIndexOf(this.glyphIDs, glyphID); return index >= 0 ? index : null; } } export class SimpleTextLayout { readonly textFrame: TextFrame; constructor(font: PathfinderFont, text: string) { const lineHeight = font.opentypeFont.lineHeight(); const textRuns: TextRun[] = text.split("\n").map((line, lineNumber) => { return new TextRun(line, [0.0, -lineHeight * lineNumber], font); }); this.textFrame = new TextFrame(textRuns, font); } layoutRuns() { this.textFrame.runs.forEach(textRun => textRun.layout()); } } export class Hint { readonly xHeight: number; readonly hintedXHeight: number; readonly stemHeight: number; readonly hintedStemHeight: number; private useHinting: boolean; constructor(font: PathfinderFont, pixelsPerUnit: number, useHinting: boolean) { this.useHinting = useHinting; const os2Table = font.opentypeFont.tables.os2; this.xHeight = os2Table.sxHeight != null ? os2Table.sxHeight : 0; this.stemHeight = os2Table.sCapHeight != null ? os2Table.sCapHeight : 0; if (!useHinting) { this.hintedXHeight = this.xHeight; this.hintedStemHeight = this.stemHeight; } else { this.hintedXHeight = Math.ceil(Math.ceil(this.xHeight * pixelsPerUnit) / pixelsPerUnit); this.hintedStemHeight = Math.ceil(Math.ceil(this.stemHeight * pixelsPerUnit) / pixelsPerUnit); } } /// NB: This must match `hintPosition()` in `common.inc.glsl`. hintPosition(position: glmatrix.vec2): glmatrix.vec2 { if (!this.useHinting) return position; if (position[1] >= this.stemHeight) { const y = position[1] - this.stemHeight + this.hintedStemHeight; return glmatrix.vec2.clone([position[0], y]); } if (position[1] >= this.xHeight) { const y = lerp(this.hintedXHeight, this.hintedStemHeight, (position[1] - this.xHeight) / (this.stemHeight - this.xHeight)); return glmatrix.vec2.clone([position[0], y]); } if (position[1] >= 0.0) { const y = lerp(0.0, this.hintedXHeight, position[1] / this.xHeight); return glmatrix.vec2.clone([position[0], y]); } return position; } } export function calculatePixelXMin(metrics: Metrics, pixelsPerUnit: number): number { return Math.floor(metrics.xMin * pixelsPerUnit); } export function calculatePixelDescent(metrics: Metrics, pixelsPerUnit: number): number { return Math.ceil(-metrics.yMin * pixelsPerUnit); } function calculateSubpixelMetricsForGlyph(metrics: Metrics, pixelsPerUnit: number, hint: Hint): PixelMetrics { const top = hint.hintPosition(glmatrix.vec2.fromValues(0, metrics.yMax))[1]; return { ascent: top * pixelsPerUnit, descent: metrics.yMin * pixelsPerUnit, left: metrics.xMin * pixelsPerUnit, right: metrics.xMax * pixelsPerUnit, }; } export function calculatePixelRectForGlyph(metrics: Metrics, subpixelOrigin: glmatrix.vec2, pixelsPerUnit: number, hint: Hint): glmatrix.vec4 { const pixelMetrics = calculateSubpixelMetricsForGlyph(metrics, pixelsPerUnit, hint); return glmatrix.vec4.clone([Math.floor(subpixelOrigin[0] + pixelMetrics.left), Math.floor(subpixelOrigin[1] + pixelMetrics.descent), Math.ceil(subpixelOrigin[0] + pixelMetrics.right), Math.ceil(subpixelOrigin[1] + pixelMetrics.ascent)]); }