diff --git a/app/components/three-preview.hbs b/app/components/three-preview.hbs index 0c68f35..46ef6ab 100644 --- a/app/components/three-preview.hbs +++ b/app/components/three-preview.hbs @@ -1,10 +1,6 @@ -{{yield (hash - renderer=(component 'three-preview/renderer' - mesh=@mesh - parentSize=@parentSize - nearCamera=@nearCamera +{{yield + (hash + renderer=(component 'three-preview/renderer' mesh=@mesh nearCamera=@nearCamera) + size=(component 'three-preview/size' mesh=@mesh) ) - size=(component 'three-preview/size' - mesh=@mesh - ) -)}} +}} \ No newline at end of file diff --git a/app/components/three-preview/renderer.hbs b/app/components/three-preview/renderer.hbs index 88e643e..e4ddcc3 100644 --- a/app/components/three-preview/renderer.hbs +++ b/app/components/three-preview/renderer.hbs @@ -1,5 +1,2 @@ - + \ No newline at end of file diff --git a/app/modifiers/three-renderer.ts b/app/modifiers/three-renderer.ts index d8ecacc..1ff74da 100644 --- a/app/modifiers/three-renderer.ts +++ b/app/modifiers/three-renderer.ts @@ -13,7 +13,6 @@ const { } = config; type namedArgs = { - parentSize?: boolean; nearCamera?: boolean; }; @@ -79,11 +78,8 @@ export default class ThreeRendererModifier extends Modifier this.renderFrame()); - const { offsetWidth: width, offsetHeight: height } = this.namedArgs.parentSize - ? this.canvas?.parentElement ?? this.canvas - : this.canvas; + const { offsetWidth: width, offsetHeight: height } = this.canvas?.parentElement ?? this.canvas; if (this.rendererSize.width !== width || this.rendererSize.height !== height) { this.renderer.setSize(width, height); @@ -201,12 +195,8 @@ export default class ThreeRendererModifier extends Modifier; @@ -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..dba9827 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: { @@ -31,12 +34,12 @@ export default class GeneratorRoute extends Route { // No custom font via QP model.customFont = undefined; - model.fontName = textMakerDefault.fontName; - model.variantName = textMakerDefault.variantName; } // 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..48c1fc9 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,47 +70,48 @@ 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, - size: number, + glyphPath: SVGPathSegment[], xOffset: number, yOffset: number, + isCFFFont: boolean = false, ): 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; - // 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] + xOffset, command.values[1] + yOffset); + path2D.moveTo(command.values[0] + xOffset, command.values[1] + yOffset); break; case 'Z': path.closePath(); path2D.closePath(); // With CCF font Detect path/hole can be done only at the end with all path... - if (outlinesFormat === 'cff') { + if (isCFFFont) { paths.push(path); paths2D.push(path2D); } else { @@ -119,24 +124,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] + xOffset, command.values[1] + yOffset); + path2D.lineTo(command.values[0] + xOffset, command.values[1] + yOffset); break; case 'C': path.bezierCurveTo( - ...coord(command.x1, command.y1), - ...coord(command.x2, command.y2), - ...coord(command.x, command.y), + command.values[0] + xOffset, + command.values[1] + yOffset, + command.values[2] + xOffset, + command.values[3] + yOffset, + command.values[4] + xOffset, + command.values[5] + yOffset, ); path2D.bezierCurveTo( - ...coord(command.x1, command.y1), - ...coord(command.x2, command.y2), - ...coord(command.x, command.y), + command.values[0] + xOffset, + command.values[1] + yOffset, + command.values[2] + xOffset, + command.values[3] + yOffset, + command.values[4] + xOffset, + command.values[5] + yOffset, ); 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] + xOffset, + command.values[1] + yOffset, + command.values[2] + xOffset, + command.values[3] + yOffset, + ); + path2D.quadraticCurveTo( + command.values[0] + xOffset, + command.values[1] + yOffset, + command.values[2] + xOffset, + command.values[3] + yOffset, + ); break; } } @@ -144,7 +165,7 @@ export default class TextMakerService extends Service { // https://github.com/opentypejs/opentype.js/issues/347 // if "cff" : subpath B contained by outermost subpath A is a cutout ... // if "truetype" : solid shapes are defined clockwise (CW) and holes are defined counterclockwise (CCW) - if (outlinesFormat === 'cff') { + if (isCFFFont) { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); @@ -203,7 +224,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 +280,49 @@ export default class TextMakerService extends Service { const vAlignment = params.vAlignment !== undefined ? params.vAlignment : textMakerDefault.vAlignment; - const scale = (1 / font.unitsPerEm) * 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 }, }; + // https://harfbuzz.github.io/harfbuzz-hb-font.html (see hb_font_set_scale) + font.font.setScale(size, size); + 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; + + const emptyGlyph = lineText.glyphs[info.g].length === 0; - lineMaxX = x + glyphBounds.x2 * scale; - const glyphHeight = (glyphBounds.y2 - glyphBounds.y1) * scale; + const glyphBounds = this.getSVGPathSegmentsBoundingBox(lineText.glyphs[info.g]); + const glyphHeight = glyphBounds.y2 - glyphBounds.y1; - const minY = Math.min(glyphBounds.y1, glyphBounds.y2) * scale; - const maxY = Math.max(glyphBounds.y1, glyphBounds.y2) * scale; + const minY = emptyGlyph ? 0 : Math.min(glyphBounds.y1, glyphBounds.y2); + const maxY = emptyGlyph ? 0 : Math.max(glyphBounds.y1, glyphBounds.y2); - 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, }); - 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.max.x = Math.max(bounds.max.x, x + glyphBounds.x2 * scale); - bounds.max.y = Math.max(bounds.max.y, y - dy + glyphBounds.y2 * scale); + lineMaxX = x + glyphBounds.x2; + + bounds.min.x = Math.min(bounds.min.x, x + glyphBounds.x1); + bounds.min.y = Math.min(bounds.min.y, y - oy + glyphBounds.y1); + bounds.max.x = Math.max(bounds.max.x, x + glyphBounds.x2); + bounds.max.y = Math.max(bounds.max.y, y - oy + glyphBounds.y2); + + 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,16 @@ export default class TextMakerService extends Service { glyphShapes.push( this.glyphToShapes( - font.outlinesFormat, - glyph, - size, + lineText.glyphs[info.g], 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 +424,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/app/templates/app/generator.hbs b/app/templates/app/generator.hbs index ed5903a..3039999 100644 --- a/app/templates/app/generator.hbs +++ b/app/templates/app/generator.hbs @@ -75,7 +75,7 @@ - +
diff --git a/package.json b/package.json index 341ed42..8d84355 100644 --- a/package.json +++ b/package.json @@ -129,8 +129,8 @@ }, "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", "three": "^0.137.4" }, 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/tests/helpers/load-font.ts b/tests/helpers/load-font.ts index def7e6b..34e9781 100644 --- a/tests/helpers/load-font.ts +++ b/tests/helpers/load-font.ts @@ -1,9 +1,6 @@ import fonts from 'text2stl/tests/fixtures/fonts'; -import * as opentype from 'opentype.js'; -export default async function (name: string): Promise { +export default async function (name: string): Promise { const res = await fetch(fonts[name]); - const fontData = await res.arrayBuffer(); - - return opentype.parse(fontData); + return await res.arrayBuffer(); } diff --git a/tests/helpers/mock-font-manager.ts b/tests/helpers/mock-font-manager.ts index 8b5bc08..925933f 100644 --- a/tests/helpers/mock-font-manager.ts +++ b/tests/helpers/mock-font-manager.ts @@ -1,26 +1,33 @@ +import Service, { inject as service } from '@ember/service'; +import loadFont from 'text2stl/tests/helpers/load-font'; + import type Owner from '@ember/owner'; +import type HarfbuzzService from 'text2stl/services/harfbuzz'; -import Service from '@ember/service'; -import loadFont from 'text2stl/tests/helpers/load-font'; +class FakeFontManager extends Service { + @service declare harfbuzz: HarfbuzzService; + + fontList = new Map(); + + availableFontCategories = ['mock']; + availableFontScript = ['mock']; + + async loadFont() { + this.fontList.set('mock', { family: 'mock', category: 'mock', variants: ['regular'] }); + } + + async fetchFont(name: string) { + const buffer = await loadFont(name); + + const blob = this.harfbuzz.hb.createBlob(buffer); + const face = this.harfbuzz.hb.createFace(blob, 0); + return { + font: this.harfbuzz.hb.createFont(face), + face, + }; + } +} export default function (owner: Owner) { - owner.register( - 'service:font-manager', - class extends Service { - fontList = new Map(); - - availableFontCategories = ['mock']; - availableFontScript = ['mock']; - - constructor() { - super(); - this.fontList.set('mock', { family: 'mock', category: 'mock', variants: ['regular'] }); - } - async loadFont() {} - - async fetchFont(name: string) { - return loadFont(name); - } - }, - ); + owner.register('service:font-manager', FakeFontManager); } diff --git a/tests/integration/components/three-preview/renderer-test.ts b/tests/integration/components/three-preview/renderer-test.ts index 4b4db63..5630692 100644 --- a/tests/integration/components/three-preview/renderer-test.ts +++ b/tests/integration/components/three-preview/renderer-test.ts @@ -1,35 +1,22 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; +import { find, render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { Mesh, BoxGeometry } from 'three'; module('Integration | Component | three-preview/renderer', function (hooks) { setupRenderingTest(hooks); - for (const { parentSize, title } of [ - { parentSize: true, title: 'with parentSize true' }, - { parentSize: false, title: 'with parentSize false' }, - ]) { - test(`it handle parentSize attribute [${title}]`, async function (assert) { - this.set('parentSize', parentSize); - this.set('mesh', new Mesh(new BoxGeometry(12.12, 34.07, 56.42))); + test(`it render canvas at parent size`, async function (assert) { + this.set('mesh', new Mesh(new BoxGeometry(12.12, 34.07, 56.42))); - await render(hbs`
- -
`); + await render(hbs`
+ +
`); - if (parentSize) { - assert - .dom('[data-test-renderer] canvas') - .hasAttribute('width', '200', 'Canvas width is adapted to parent size') - .hasAttribute('height', '100', 'Canvas height is adapted to parent size'); - } else { - assert - .dom('[data-test-renderer] canvas') - .hasAttribute('width', '1024', 'Canvas width is 1024') - .hasAttribute('height', '768', 'Canvas height is 768'); - } - }); - } + const canvas = find('[data-test-renderer] canvas') as HTMLCanvasElement; + + assert.strictEqual(canvas.offsetWidth, 200, 'Canvas width is adapted to parent size'); + assert.strictEqual(canvas.offsetHeight, 100, 'Canvas height is adapted to parent size'); + }); }); diff --git a/tests/unit/controllers/app/generator-test.ts b/tests/unit/controllers/app/generator-test.ts index ba5365a..5ffc35c 100644 --- a/tests/unit/controllers/app/generator-test.ts +++ b/tests/unit/controllers/app/generator-test.ts @@ -2,34 +2,29 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import waitUntil from '@ember/test-helpers/wait-until'; import mockTextSettings from 'text2stl/tests/helpers/mock-text-maker-settings'; -import { Font } from 'opentype.js'; import { Mesh } from 'three'; import type { TextMakerParameters } from 'text2stl/services/text-maker'; -import type { Variant } from 'text2stl/services/font-manager'; +import type { Variant, FaceAndFont } from 'text2stl/services/font-manager'; import type GeneratorController from 'text2stl/controllers/app/generator'; -function mockFont(name: string, variant: Variant) { - return new Font({ - familyName: `mocked_${name}_${variant}`, - styleName: 'mocked', - unitsPerEm: 16, - ascender: 1, - descender: -1, - glyphs: [], - }); +function mockFont(name: string, variant: Variant | undefined) { + return { + font: `mocked_font_${name}_${variant ?? 'nope'}`, + face: `mocked_face_${name}_${variant ?? 'nope'}`, + } as unknown as FaceAndFont; } module('Unit | Controller | app/generator', function (hooks) { setupTest(hooks); test('it handles font change & font loading (google font)', async function (assert) { - assert.expect(3); + assert.expect(4); const controller = this.owner.lookup('controller:app/generator') as GeneratorController; this.owner.lookup('service:font-manager').fetchFont = async function ( name: string, - variant: Variant, + variant: Variant | undefined, ) { assert.strictEqual(name, controller.model.fontName, 'it requires correct fontName'); assert.strictEqual(variant, controller.model.variantName, 'it requires correct variantName'); @@ -44,14 +39,19 @@ module('Unit | Controller | app/generator', function (hooks) { // Wait for the font to be load await waitUntil(() => controller.font.isResolved); assert.strictEqual( - controller.font.value?.names.fontFamily['en'], - 'mocked_my-font_500italic', + controller.font.value?.font as unknown as string, + 'mocked_font_my-font_500italic', + 'Font was load on model', + ); + assert.strictEqual( + controller.font.value?.face as unknown as string, + 'mocked_face_my-font_500italic', 'Font was load on model', ); }); test('it handles font change & font loading (custom font)', async function (assert) { - assert.expect(2); + assert.expect(3); this.owner.lookup('service:font-manager').loadCustomFont = async function (file: Blob) { const filename = await new Response(file).text(); assert.strictEqual(filename, 'my-font.file', 'font file is passed to font-manager'); @@ -69,8 +69,13 @@ module('Unit | Controller | app/generator', function (hooks) { // Wait for the font to be load await waitUntil(() => controller.font.isResolved); assert.strictEqual( - controller.font.value?.names.fontFamily['en'], - 'mocked_my-font.file_100', + controller.font.value?.font as unknown as string, + 'mocked_font_my-font.file_100', + 'Font was load on model', + ); + assert.strictEqual( + controller.font.value?.face as unknown as string, + 'mocked_face_my-font.file_100', 'Font was load on model', ); }); @@ -95,7 +100,7 @@ module('Unit | Controller | app/generator', function (hooks) { this.owner.lookup('service:text-maker').generateMesh = function ( settings: TextMakerParameters, - font: Font, + font: FaceAndFont, ) { assert.strictEqual(settings, model, 'it generate mesh with model settings'); assert.strictEqual(font, mockedFont, 'it generate mesh with fetched font'); diff --git a/tests/unit/services/font-manager-test.ts b/tests/unit/services/font-manager-test.ts index 4607707..9b641ee 100644 --- a/tests/unit/services/font-manager-test.ts +++ b/tests/unit/services/font-manager-test.ts @@ -1,8 +1,7 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; -import * as opentype from 'opentype.js'; import type FontManagerService from 'text2stl/services/font-manager'; -import type { Variant } from 'text2stl/services/font-manager'; +import type { Variant, FaceAndFont } from 'text2stl/services/font-manager'; const mockedFontList = new Map(); @@ -64,16 +63,12 @@ module('Unit | Service | font-manager', function (hooks) { } as unknown as Response; }; - service.opentype = { - parse(buffer: Response): opentype.Font { - assert.strictEqual( - `${buffer}`, - 'fetched-array-buffer', - 'font is parsed with fetch result', - ); - return 'parsed-font' as unknown as opentype.Font; - }, - } as typeof opentype; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + service.openHBFont = (buffer: ArrayBuffer): FaceAndFont => { + assert.strictEqual(`${buffer}`, 'fetched-array-buffer', 'font is parsed with fetch result'); + return 'parsed-font' as unknown as FaceAndFont; + }; const font = await service.fetchFont(fontName, variantName); assert.strictEqual(`${font}`, 'parsed-font', 'correct font is returned'); @@ -120,13 +115,12 @@ module('Unit | Service | font-manager', function (hooks) { } as Response; }; - service.opentype = { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - parse(_: unknown): opentype.Font { - parseDone(); - return 'a-parsed-font' as unknown as opentype.Font; - }, - } as typeof opentype; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + service.openHBFont = (): FaceAndFont => { + parseDone(); + return 'a-parsed-font' as unknown as FaceAndFont; + }; let font = await service.fetchFont('font1'); assert.strictEqual(`${font}`, 'a-parsed-font', 'Font is returned'); @@ -146,13 +140,16 @@ module('Unit | Service | font-manager', function (hooks) { }, }; - service.opentype = { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - parse(buffer: unknown): opentype.Font { - assert.strictEqual(buffer, 'a_great_boeuf-er', 'blob buffer is passed to opentype.parse'); - return 'a-parsed-font' as unknown as opentype.Font; - }, - } as typeof opentype; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + service.openHBFont = (buffer: ArrayBuffer): FaceAndFont => { + assert.strictEqual( + buffer as unknown as string, + 'a_great_boeuf-er', + 'blob buffer is passed to opentype.parse', + ); + return 'a-parsed-font' as unknown as FaceAndFont; + }; const font = await service.loadCustomFont(mockedBlob as unknown as Blob); assert.strictEqual(`${font}`, 'a-parsed-font', 'Font is returned'); 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 b3bceff..0565616 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" @@ -9671,14 +9676,6 @@ onetime@^5.1.0, onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" -opentype.js@^1.3.3: - version "1.3.4" - resolved "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz#1c0e72e46288473cc4a4c6a2dc60fd7fe6020d77" - integrity sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw== - dependencies: - string.prototype.codepointat "^0.2.1" - tiny-inflate "^1.0.3" - opter@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/opter/-/opter-1.1.0.tgz#2598aebb60b3f1a7322af1097086f4e798c84a76" @@ -11521,11 +11518,6 @@ string-width@^2.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^4.0.0" -string.prototype.codepointat@^0.2.1: - version "0.2.1" - resolved "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz#004ad44c8afc727527b108cd462b4d971cd469bc" - integrity sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg== - string.prototype.matchall@^4.0.5, string.prototype.matchall@^4.0.6: version "4.0.10" resolved "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz#a1553eb532221d4180c51581d6072cd65d1ee100" @@ -11987,11 +11979,6 @@ tiny-glob@0.2.9: globalyzer "0.1.0" globrex "^0.1.2" -tiny-inflate@^1.0.3: - version "1.0.3" - resolved "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" - integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw== - tiny-lr@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/tiny-lr/-/tiny-lr-2.0.0.tgz#863659d7ce1ed201a117d8197d7f8b9a27bdc085"