From 3c0ac814f94c981fc69c0cdc5a4ff1f1412e38e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mestres=20J=C3=A9r=C3=B4me?= Date: Mon, 11 Mar 2024 08:14:24 +0100 Subject: [PATCH] plop --- app/routes/app.ts | 8 +- app/routes/app/generator.ts | 9 +- app/services/font-manager.ts | 33 +++-- app/services/text-maker.ts | 245 ++++++++++++++++++++++++---------- app/templates/application.hbs | 3 +- package.json | 2 + yarn.lock | 10 ++ 7 files changed, 227 insertions(+), 83 deletions(-) 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..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/text-maker.ts b/app/services/text-maker.ts index 6b7adca..ac2aacb 100644 --- a/app/services/text-maker.ts +++ b/app/services/text-maker.ts @@ -1,9 +1,14 @@ 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 paper from 'paper'; + +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 +71,78 @@ type MultipleGlyphDef = { }; }; +type LineInfo = { + // glyphs shape indexed by Glyph ID + glyphs: Record; + // Line composition () + buffer: BufferContent; +}; + +const canvas = document.getElementById('myCanvas') as HTMLCanvasElement; + // Create an empty project and a view for the canvas: + paper.setup(canvas); + 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; + // see https://stackoverflow.com/questions/3742479/how-to-cut-a-hole-in-an-svg-rectangle#comment72845893_11878784 + // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/fill-rule + // https://github.com/mrdoob/three.js/issues/16950 + // Voir : http://paperjs.org/reference/pathitem/#create-pathData + // tools : + // https://opentype.js.org/glyph-inspector.html + // https://yqnn.github.io/svg-path-editor/ + + // Try to play with something like ; + // const p1 = new paper.Path(path) + // const p2 = new paper.Path(hole) + // p1.fillRule = 'evenodd' + // p2.fillRule = 'evenodd' + // p1.subtract(p2).pathData + // p1.exclude(p2).pathData + // p1.subtract(p2, { trace: false }).pathData + // p1.exclude(p2, { trace: false }).pathData + + ; + const pathData = glyphPath.map((p) => `${p.type}${p.values.join(' ')}`).join(''); + const test = paper.PathItem.create(pathData); + console.log('pathData', pathData); + + const toto = new paper.CompoundPath(pathData); + debugger; // 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 +155,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 +196,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'); @@ -190,7 +242,7 @@ export default class TextMakerService extends Service { const geometry = new THREE.ExtrudeGeometry(shapes, { depth, - bevelEnabled: true, + bevelEnabled: false, bevelThickness: 0, bevelSize: 0, bevelOffset: 0, @@ -203,7 +255,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 +311,47 @@ 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; - 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 = Math.min(glyphBounds.y1, glyphBounds.y2); + const maxY = 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 +359,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 +391,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 +418,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 +453,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/application.hbs b/app/templates/application.hbs index 7f75e24..a59c76d 100644 --- a/app/templates/application.hbs +++ b/app/templates/application.hbs @@ -17,4 +17,5 @@ {{outlet}} - \ No newline at end of file + + diff --git a/package.json b/package.json index 341ed42..539be29 100644 --- a/package.json +++ b/package.json @@ -129,8 +129,10 @@ }, "dependencies": { "@esri/calcite-components": "^1.9.1", + "harfbuzzjs": "^0.3.4", "matter-js": "^0.17.1", "opentype.js": "^1.3.3", + "paper": "^0.12.17", "poly-decomp": "^0.3.0", "three": "^0.137.4" }, diff --git a/yarn.lock b/yarn.lock index b3bceff..bbe1bae 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" @@ -9879,6 +9884,11 @@ pako@~1.0.5: resolved "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== +paper@^0.12.17: + version "0.12.17" + resolved "https://registry.yarnpkg.com/paper/-/paper-0.12.17.tgz#57215615f3db5a32826ab60d3e08bc7953614052" + integrity sha512-oCe+e1C2w8hKIcGoAqUjD0GGxGPv+itrRXlEFUmp3H8tY/NTnHOkYgpJFPGw6OJ8Q1Wa6+RgzlY7Dx/2WWHtkA== + parallel-transform@^1.1.0: version "1.2.0" resolved "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.2.0.tgz#9049ca37d6cb2182c3b1d2c720be94d14a5814fc"