diff --git a/app/routes/app.ts b/app/routes/app.ts index d855e1c..73e6d6f 100644 --- a/app/routes/app.ts +++ b/app/routes/app.ts @@ -1,10 +1,10 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; -import type IntlService from 'ember-intl/services/intl'; -import type FontManagerService from 'text2stl/services/font-manager'; - import { Registry as Services } from '@ember/service'; +import type IntlService from 'ember-intl/services/intl'; +import type FontManagerService from 'text2stl/services/font-manager'; +import type HarfbuzzService from 'text2stl/services/harfbuzz'; import type RouterService from '@ember/routing/router-service'; type Transition = ReturnType; @@ -13,6 +13,7 @@ export default class AppRoute extends Route { @service declare intl: IntlService; @service declare router: Services['router']; @service declare fontManager: FontManagerService; + @service declare harfbuzz: HarfbuzzService; constructor(props: object | undefined) { super(props); @@ -23,6 +24,7 @@ export default class AppRoute extends Route { this.intl.locale = locale === 'en-us' ? locale : [locale, 'en-us']; // No await here, let's the loading happen & await for it in generator route this.fontManager.loadFont(); + this.harfbuzz.loadWASM(); } afterModel() { diff --git a/app/routes/app/generator.ts b/app/routes/app/generator.ts index d850295..50b5323 100644 --- a/app/routes/app/generator.ts +++ b/app/routes/app/generator.ts @@ -1,15 +1,18 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; -import type FontManagerService from 'text2stl/services/font-manager'; import TextMakerSettings from 'text2stl/models/text-maker-settings'; import config from 'text2stl/config/environment'; +import type FontManagerService from 'text2stl/services/font-manager'; +import type HarfbuzzService from 'text2stl/services/harfbuzz'; + const { APP: { textMakerDefault }, } = config; export default class GeneratorRoute extends Route { @service declare fontManager: FontManagerService; + @service declare harfbuzz: HarfbuzzService; queryParams = { modelSettings: { @@ -37,6 +40,8 @@ export default class GeneratorRoute extends Route { // Ensure font list is fully load await this.fontManager.loadFont(); + // Ensure harfbuzzJS is fully load (WASM loaded & lib instance created) + await this.harfbuzz.loadWASM(); return model; } diff --git a/app/services/font-manager.ts b/app/services/font-manager.ts index 65bc884..0f62dc5 100644 --- a/app/services/font-manager.ts +++ b/app/services/font-manager.ts @@ -1,6 +1,9 @@ import Service from '@ember/service'; -import * as opentype from 'opentype.js'; import config from 'text2stl/config/environment'; +import { inject as service } from '@ember/service'; + +import type HarfbuzzService from 'text2stl/services/harfbuzz'; +import type { HBFont, HBFace } from 'harfbuzzjs/hbjs'; export type Category = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'; export type Script = @@ -81,7 +84,14 @@ type GoogleFontApiResponse = { }[]; }; +export interface FaceAndFont { + font: HBFont; + face: HBFace; +} + export default class FontManagerService extends Service { + @service declare harfbuzz: HarfbuzzService; + availableFontScript: Script[] = [ 'arabic', 'bengali', @@ -120,9 +130,7 @@ export default class FontManagerService extends Service { fontList: Map = new Map(); - fontCache: Record = {}; - - opentype = opentype; // For easy mock + fontCache: Record = {}; // For easy mock fetch(input: RequestInfo, init?: RequestInit | undefined): Promise { @@ -216,7 +224,16 @@ export default class FontManagerService extends Service { document.adoptedStyleSheets.push(await stylesheet.replace(style)); } - async fetchFont(fontName: string, variantName?: Variant): Promise { + private openHBFont(buffer: ArrayBuffer): FaceAndFont { + const blob = this.harfbuzz.hb.createBlob(buffer); + const face = this.harfbuzz.hb.createFace(blob, 0); + return { + font: this.harfbuzz.hb.createFont(face), + face, + }; + } + + async fetchFont(fontName: string, variantName?: Variant): Promise { const font = this.fontList.get(fontName); if (!font) { throw `Unknown font name ${fontName}`; @@ -238,15 +255,15 @@ export default class FontManagerService extends Service { if (!this.fontCache[cacheName]) { const res = await this.fetch(url.replace('http:', 'https:')); const fontData = await res.arrayBuffer(); - this.fontCache[cacheName] = this.opentype.parse(fontData); + this.fontCache[cacheName] = this.openHBFont(fontData); } return this.fontCache[cacheName]; } - async loadCustomFont(fontTTFFile: Blob): Promise { + async loadCustomFont(fontTTFFile: Blob): Promise { const fontAsBuffer = await fontTTFFile.arrayBuffer(); - return this.opentype.parse(fontAsBuffer); + return this.openHBFont(fontAsBuffer); } private chunk(array: T[], chunkSize: number) { diff --git a/app/services/harfbuzz.ts b/app/services/harfbuzz.ts new file mode 100644 index 0000000..10f8d1f --- /dev/null +++ b/app/services/harfbuzz.ts @@ -0,0 +1,28 @@ +import Service from '@ember/service'; +import hb from 'harfbuzzjs/hbjs'; +import type { HBInstance } from 'harfbuzzjs/hbjs'; + +export default class HarfbuzzService extends Service { + declare hb: HBInstance; + + loadWASMPromise: undefined | Promise = undefined; + + async loadWASM() { + if (!this.loadWASMPromise) { + this.loadWASMPromise = this._loadWASM(); + } + + await this.loadWASMPromise; + } + + async _loadWASM() { + const result = await WebAssembly.instantiateStreaming(fetch('/hb.wasm')); + this.hb = hb(result.instance); + } +} + +declare module '@ember/service' { + interface Registry { + harfbuzz: HarfbuzzService; + } +} diff --git a/app/services/text-maker.ts b/app/services/text-maker.ts index 6b7adca..74b157a 100644 --- a/app/services/text-maker.ts +++ b/app/services/text-maker.ts @@ -1,9 +1,13 @@ import Service from '@ember/service'; -import * as opentype from 'opentype.js'; import * as THREE from 'three'; import { mergeBufferGeometries } from 'text2stl/utils/BufferGeometryUtils'; import config from 'text2stl/config/environment'; import { generateSupportShape } from 'text2stl/misc/support-shape-generation'; +import { inject as service } from '@ember/service'; + +import type HarfbuzzService from 'text2stl/services/harfbuzz'; +import type { SVGPathSegment, HBFont, BufferContent } from 'harfbuzzjs/hbjs'; +import type { FaceAndFont } from 'text2stl/services/font-manager'; const { APP: { @@ -66,40 +70,44 @@ type MultipleGlyphDef = { }; }; +type LineInfo = { + // glyphs shape indexed by Glyph ID + glyphs: Record; + // Line composition () + buffer: BufferContent; +}; + export default class TextMakerService extends Service { + @service declare harfbuzz: HarfbuzzService; + private glyphToShapes( outlinesFormat: string, - glyph: opentype.Glyph, + glyphPath: SVGPathSegment[], size: number, xOffset: number, yOffset: number, ): SingleGlyphDef { - // Font x/y origin to Three x/y origin - const coord = function (x: number, y: number): [number, number] { - return [x, 0 - y]; - }; - let paths: THREE.Path[] = []; const holes: THREE.Path[] = []; let path = new THREE.Path(); - const pathCommands = glyph.getPath(xOffset, 0 - yOffset, size).commands; + // const pathCommands = glyph.getPath(xOffset, 0 - yOffset, size).commands; // Following is only to manage "cff" font & detect hole shape const paths2D: Path2D[] = []; let path2D = new Path2D(); // https://github.com/opentypejs/opentype.js#path-commands - for (let i = 0; i < pathCommands.length; i++) { - const command = pathCommands[i]; + for (let i = 0; i < glyphPath.length; i++) { + const command = glyphPath[i]; switch (command.type) { case 'M': path = new THREE.Path(); path2D = new Path2D(); - path.moveTo(...coord(command.x, command.y)); - path2D.moveTo(...coord(command.x, command.y)); + path.moveTo(command.values[0], command.values[1]); + path2D.moveTo(command.values[0], command.values[1]); break; case 'Z': path.closePath(); @@ -119,24 +127,40 @@ export default class TextMakerService extends Service { break; case 'L': - path.lineTo(...coord(command.x, command.y)); - path2D.lineTo(...coord(command.x, command.y)); + path.lineTo(command.values[0], command.values[1]); + path2D.lineTo(command.values[0], command.values[1]); break; case 'C': path.bezierCurveTo( - ...coord(command.x1, command.y1), - ...coord(command.x2, command.y2), - ...coord(command.x, command.y), + command.values[0], + command.values[1], + command.values[2], + command.values[3], + command.values[4], + command.values[5], ); path2D.bezierCurveTo( - ...coord(command.x1, command.y1), - ...coord(command.x2, command.y2), - ...coord(command.x, command.y), + command.values[0], + command.values[1], + command.values[2], + command.values[3], + command.values[4], + command.values[5], ); break; case 'Q': - path.quadraticCurveTo(...coord(command.x1, command.y1), ...coord(command.x, command.y)); - path2D.quadraticCurveTo(...coord(command.x1, command.y1), ...coord(command.x, command.y)); + path.quadraticCurveTo( + command.values[0], + command.values[1], + command.values[2], + command.values[3], + ); + path2D.quadraticCurveTo( + command.values[0], + command.values[1], + command.values[2], + command.values[3], + ); break; } } @@ -203,7 +227,52 @@ export default class TextMakerService extends Service { return mergeBufferGeometries(geometries.flat()); } - private stringToGlyhpsDef(params: TextMakerParameters, font: opentype.Font): MultipleGlyphDef { + private generateTextLineInfo(text: string, font: HBFont): LineInfo { + const buffer = this.harfbuzz.hb.createBuffer(); + buffer.addText(text); + buffer.guessSegmentProperties(); + + this.harfbuzz.hb.shape(font, buffer); + const result = buffer.json(); + + return { + buffer: result, + glyphs: result.reduce>(function (acc, e) { + if (!acc[e.g]) { + acc[e.g] = font.glyphToJson(e.g); + } + + return acc; + }, {}), + }; + } + + private getSVGPathSegmentsBoundingBox(path: SVGPathSegment[]) { + const bound = { + x1: Number.MAX_SAFE_INTEGER, + x2: 0, + y1: Number.MAX_SAFE_INTEGER, + y2: 0, + }; + + for (const p of path) { + const xCoords = p.values.filter((_v, idx) => !(idx % 2)); + const yCoords = p.values.filter((_v, idx) => idx % 2); + + for (const x of xCoords) { + bound.x1 = Math.min(bound.x1, x); + bound.x2 = Math.max(bound.x2, x); + } + for (const y of yCoords) { + bound.y1 = Math.min(bound.y1, y); + bound.y2 = Math.max(bound.y2, y); + } + } + + return bound; + } + + private stringToGlyhpsDef(params: TextMakerParameters, font: FaceAndFont): MultipleGlyphDef { const text = params.text || textMakerDefault.text; const size = params.size !== undefined && params.size >= 0 ? params.size : textMakerDefault.size; @@ -214,41 +283,46 @@ export default class TextMakerService extends Service { const vAlignment = params.vAlignment !== undefined ? params.vAlignment : textMakerDefault.vAlignment; - const scale = (1 / font.unitsPerEm) * size; + const scale = (1 / font.face.upem) * size; const glyphShapes: SingleGlyphDef[] = []; - // to handle alignment - const linesWidth: number[] = []; - const linesMaxY: { maxY: number; minY: number }[] = []; - const linesGlyphInfos: Array> = []; + + const linesWidth: number[] = []; // to handle horizontal alignment + const linesMinMaxY: { maxY: number; minY: number }[] = []; // to handle vertical alignment + const linesGlyphInfos: Array> = []; // to handle vertical alignment (move each glyph according to line MinMaxY) + + // bounds of all text const bounds = { min: { x: Number.MAX_SAFE_INTEGER, y: Number.MAX_SAFE_INTEGER }, max: { x: 0, y: 0 }, }; const lines = text.split('\n').map((s) => s.trimEnd()); - let dy = 0; + let oy = 0; // Last x offset where to start drawing glyph + + // Generate info for each line of text + const linesInfos = lines.map((text) => this.generateTextLineInfo(text, font.font)); // Iterate a first time on all lines to calculate line width (text align) - for (const lineText of lines) { - let dx = 0; + for (const lineText of linesInfos) { + let ox = 0; // Last x offset where to start drawing glyph let lineMaxX = 0; - const lineMaxY = { minY: Number.MAX_SAFE_INTEGER, maxY: -Number.MAX_SAFE_INTEGER }; + const lineMinMaxY = { minY: Number.MAX_SAFE_INTEGER, maxY: -Number.MAX_SAFE_INTEGER }; const lineGlyphInfos: { height: number; maxY: number; minY: number }[] = []; - font.forEachGlyph(lineText, 0, 0, size, undefined, (glyph, x, y) => { - x += dx; - dx += spacing; - const glyphBounds = glyph.getBoundingBox(); + // Iterate through line "element" (single char or "complex element", see https://github.com/romgere/text2stl/issues/100) + lineText.buffer.forEach((info) => { + const x = ox + info.dx; + const y = info.dy; - lineMaxX = x + glyphBounds.x2 * scale; + const glyphBounds = this.getSVGPathSegmentsBoundingBox(lineText.glyphs[info.g]); const glyphHeight = (glyphBounds.y2 - glyphBounds.y1) * scale; const minY = Math.min(glyphBounds.y1, glyphBounds.y2) * scale; const maxY = Math.max(glyphBounds.y1, glyphBounds.y2) * scale; - lineMaxY.maxY = Math.max(lineMaxY.maxY, maxY); - lineMaxY.minY = Math.min(lineMaxY.minY, minY); + lineMinMaxY.maxY = Math.max(lineMinMaxY.maxY, maxY); + lineMinMaxY.minY = Math.min(lineMinMaxY.minY, minY); lineGlyphInfos.push({ height: glyphHeight, @@ -256,17 +330,21 @@ export default class TextMakerService extends Service { minY, }); + lineMaxX = x + glyphBounds.x2 * scale; + bounds.min.x = Math.min(bounds.min.x, x + glyphBounds.x1 * scale); - bounds.min.y = Math.min(bounds.min.y, y - dy + glyphBounds.y1 * scale); + bounds.min.y = Math.min(bounds.min.y, y - oy + glyphBounds.y1 * scale); bounds.max.x = Math.max(bounds.max.x, x + glyphBounds.x2 * scale); - bounds.max.y = Math.max(bounds.max.y, y - dy + glyphBounds.y2 * scale); + bounds.max.y = Math.max(bounds.max.y, y - oy + glyphBounds.y2 * scale); + + ox += spacing + info.ax; }); - dy += size + vSpacing; + oy += size + vSpacing; // Keep this for each line to handle alignment linesWidth.push(lineMaxX); - linesMaxY.push(lineMaxY); + linesMinMaxY.push(lineMinMaxY); linesGlyphInfos.push(lineGlyphInfos); } @@ -284,18 +362,22 @@ export default class TextMakerService extends Service { }); } - dy = 0; - for (const lineIndex in lines) { - const lineText = lines[lineIndex]; - let dx = 0; + oy = 0; + // Iterate second time on line to actually "render" glyph (aligned according to info from previous iteration) + // for (const lineIndex in lines) { + for (const lineIndex in linesInfos) { + const lineText = linesInfos[lineIndex]; + let ox = 0; // Last x offset where to start drawing glyph let glyphIndex = 0; // Iterate on text char to generate a Geometry for each - font.forEachGlyph(lineText, 0, 0, size, undefined, (glyph, x, y) => { - x += dx + linesAlignOffset[lineIndex]; + lineText.buffer.forEach((info) => { + // font.forEachGlyph(lineText, 0, 0, size, undefined, (glyph, x, y) => { + const x = ox + info.dx + linesAlignOffset[lineIndex]; + let y = info.dy; if (vAlignment !== 'default') { - const lineMaxY = linesMaxY[lineIndex]; + const lineMaxY = linesMinMaxY[lineIndex]; const glyphInfo = linesGlyphInfos[lineIndex][glyphIndex]; if (vAlignment === 'bottom' && lineMaxY.minY !== glyphInfo.minY) { @@ -307,18 +389,18 @@ export default class TextMakerService extends Service { glyphShapes.push( this.glyphToShapes( - font.outlinesFormat, - glyph, + 'unknow', // TODO: find how to determinte this : font.outlinesFormat, + lineText.glyphs[info.g], size, x, // x offset - y - dy, // y offset + y - oy, // y offset ), ); - dx += spacing; + ox += spacing + info.ax; glyphIndex++; }); - dy += size + vSpacing; + oy += size + vSpacing; } return { @@ -344,7 +426,7 @@ export default class TextMakerService extends Service { ); } - generateMesh(params: TextMakerParameters, font: opentype.Font): THREE.Mesh { + generateMesh(params: TextMakerParameters, font: FaceAndFont): THREE.Mesh { const type = params.type || ModelType.TextOnly; const textDepth = diff --git a/package.json b/package.json index 341ed42..a05c302 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ }, "dependencies": { "@esri/calcite-components": "^1.9.1", + "harfbuzzjs": "^0.3.4", "matter-js": "^0.17.1", "opentype.js": "^1.3.3", "poly-decomp": "^0.3.0", diff --git a/public/hb.wasm b/public/hb.wasm new file mode 100644 index 0000000..4584e53 Binary files /dev/null and b/public/hb.wasm differ diff --git a/types/harfbuzzjs.d.ts b/types/harfbuzzjs.d.ts new file mode 100644 index 0000000..fa41ac8 --- /dev/null +++ b/types/harfbuzzjs.d.ts @@ -0,0 +1,68 @@ +declare module 'harfbuzzjs/hbjs' { + type Direction = 'ltr' | 'rtl' | 'ttb' | 'btt'; + type BufferFlag = + | 'BOT' + | 'EOT' + | 'PRESERVE_DEFAULT_IGNORABLES' + | 'REMOVE_DEFAULT_IGNORABLES' + | 'DO_NOT_INSERT_DOTTED_CIRCLE' + | 'PRODUCE_UNSAFE_TO_CONCAT'; + + type HBBlob = unknown; + + export interface HBFace { + upem: number; //units per em + reference_table(table: string): Uint8Array; + getAxisInfos(): Record; + collectUnicodes(): Uint32Array; + destroy(): void; + } + + export interface SVGPathSegment { + type: 'M' | 'L' | 'Q' | 'C' | 'Z'; + values: number[]; + } + + export type BufferContent = { + g: number; //The glyph ID + cl: number; //The cluster ID + ax: number; //Advance width (width to advance after this glyph is painted) + ay: number; //Advance height (height to advance after this glyph is painted) + dx: number; //X displacement (adjustment in X dimension when painting this glyph) + dy: number; //Y displacement (adjustment in Y dimension when painting this glyph) + flags: number; // Glyph flags like `HB_GLYPH_FLAG_UNSAFE_TO_BREAK` (0x1) + }[]; + + export interface HBFont { + glyphName(glyphId: number): string; + glyphToPath(glyphId: number): string; + glyphToJson(glyphId: number): SVGPathSegment[]; + setScale(xScale: number, yScale: number): void; + setVariations(variations: Record): void; + destroy(): void; + } + + interface HBBuffer { + addText(text: string): void; + guessSegmentProperties(): void; + setDirection(dir: Direction): void; + setFlags(flags: BufferFlag[]): void; + setLanguage(language: string): void; + setScript(script: string): void; + setClusterLevel(level: number): void; + json(): BufferContent; + destroy(): void; + } + + export interface HBInstance { + createBlob(buffer: ArrayBuffer): HBBlob; + createFace(blob: HBBlob, fontIndex: number): HBFace; + createFont(face: HBFace): HBFont; + createBuffer(): HBBuffer; + shape(font: HBFont, buffer: HBBuffer): void; + } + + const hb: (instance: WebAssembly.Instance) => HBInstance; + + export default hb; +} diff --git a/yarn.lock b/yarn.lock index 10a1283..1b8b051 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7432,6 +7432,11 @@ hard-rejection@^2.1.0: resolved "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883" integrity sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA== +harfbuzzjs@^0.3.4: + version "0.3.4" + resolved "https://registry.npmjs.org/harfbuzzjs/-/harfbuzzjs-0.3.4.tgz#ded565faf5ea70c88e7164f1ac33caf6619d0d1b" + integrity sha512-0TH7j8TIqCJB6RVpcJ7IyhPpHRq1JlyBiSOcNRAWpuN6S1HVSBmNtdt+G0jND1Y2qLnh70nXZ2R2zYqO57y92Q== + has-ansi@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"