Skip to content

Commit

Permalink
feat: extract emojis in text & use appropriate font to generate & int…
Browse files Browse the repository at this point in the history
…egrate them into final mesh
  • Loading branch information
romgere committed May 31, 2024
1 parent bb2c706 commit 999a81d
Show file tree
Hide file tree
Showing 15 changed files with 339 additions and 69 deletions.
59 changes: 59 additions & 0 deletions app/misc/extract-emoji.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
export type StringPart = {
type: 'text' | 'emoji';
value: string;
};

export default function extractEmoji(str: string) {
const emojiRE = /(\p{EPres}|\p{ExtPict})+/gu;

const extraction: StringPart[] = [];
const execList: RegExpExecArray[] = [];
let exec: RegExpExecArray | null = null;

while ((exec = emojiRE.exec(str))) {
execList.push(exec);
}

// No emoji at all
if (!execList.length) {
extraction.push({
type: 'text',
value: str,
});
} else {
if (execList[0].index) {
extraction.push({
type: 'text',
value: str.substring(0, execList[0].index),
});
}

for (const [i, e] of execList.entries()) {
extraction.push({
type: 'emoji',
value: e[0],
});

const eLength = e[0].length; // Emoji lenght
const eEndIdx = e.index + eLength; // Emoji end index in string
const nextEmojiIdx = execList[i + 1]?.index ?? 0; // Next emoji index

// Handle text between current emoji & next one
if (nextEmojiIdx > eEndIdx) {
extraction.push({
type: 'text',
value: str.substring(eEndIdx, execList[i + 1].index),
});
}
// Handle text between last emoji & end of string
else if (i === execList.length - 1 && eEndIdx < str.length) {
extraction.push({
type: 'text',
value: str.substring(eEndIdx, str.length),
});
}
}
}

return extraction;
}
3 changes: 2 additions & 1 deletion app/routes/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ export default class AppRoute extends Route {
async model({ locale }: { locale: string }) {
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();
this.fontManager.loadFont();
this.fontManager.loadEmojiFont();
}

afterModel() {
Expand Down
3 changes: 3 additions & 0 deletions app/routes/app/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export default class GeneratorRoute extends Route {

// Ensure font list is fully load
await this.fontManager.loadFont();
// Ensure emoji font is fully load
await this.fontManager.loadEmojiFont();

// Ensure harfbuzzJS is fully load (WASM loaded & lib instance created)
await this.harfbuzz.loadWASM();

Expand Down
27 changes: 27 additions & 0 deletions app/services/font-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const {

const FONT_BASE_URL = 'https://fonts.googleapis.com/css';
const LIST_BASE_URL = 'https://www.googleapis.com/webfonts/v1/webfonts';
const EMOJI_FONT_FILE = '/NotoEmoji-Regular.ttf';

type GoogleFontApiResponse = {
items: {
Expand Down Expand Up @@ -132,6 +133,16 @@ export default class FontManagerService extends Service {

fontCache: Record<string, FaceAndFont> = {};

_emojiFont?: FaceAndFont;

get emojiFont() {
if (!this._emojiFont) {
throw 'Emoji font not loaded, please call loadEmojiFont() first.';
}

return this._emojiFont;
}

// For easy mock
fetch(input: RequestInfo, init?: RequestInit | undefined): Promise<Response> {
return fetch(input, init);
Expand Down Expand Up @@ -266,6 +277,22 @@ export default class FontManagerService extends Service {
return this.openHBFont(fontAsBuffer);
}

private async _loadEmojiFont() {
await this.harfbuzz.loadWASM();
const res = await this.fetch(EMOJI_FONT_FILE);
const fontData = await res.arrayBuffer();
this._emojiFont = this.openHBFont(fontData);
}

loadEmojiFontPromise: undefined | Promise<void> = undefined;
async loadEmojiFont() {
if (!this.loadEmojiFontPromise) {
this.loadEmojiFontPromise = this._loadEmojiFont();
}

await this.loadEmojiFontPromise;
}

private chunk<T>(array: T[], chunkSize: number) {
const length = Math.ceil(array.length / chunkSize);
const chunks = new Array(length).fill(0);
Expand Down
151 changes: 86 additions & 65 deletions app/services/text-maker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { mergeBufferGeometries } from 'text2stl/misc/threejs/BufferGeometryUtils
import config from 'text2stl/config/environment';
import { generateSupportShape } from 'text2stl/misc/support-shape-generation';
import { inject as service } from '@ember/service';
import extractEmoji from 'text2stl/misc/extract-emoji';

import type HarfbuzzService from 'text2stl/services/harfbuzz';
import type FontManagerService from 'text2stl/services/font-manager';
import type { SVGPathSegment, HBFont, BufferContent } from 'harfbuzzjs/hbjs';
import type { FaceAndFont } from 'text2stl/services/font-manager';

Expand Down Expand Up @@ -79,6 +81,11 @@ type LineInfo = {

export default class TextMakerService extends Service {
@service declare harfbuzz: HarfbuzzService;
@service declare fontManager: FontManagerService;

get emojiFont() {
return this.fontManager.emojiFont;
}

private glyphToShapes(
glyphPath: SVGPathSegment[],
Expand Down Expand Up @@ -224,24 +231,31 @@ export default class TextMakerService extends Service {
return mergeBufferGeometries(geometries.flat());
}

private generateTextLineInfo(text: string, font: HBFont): LineInfo {
const buffer = this.harfbuzz.hb.createBuffer();
buffer.addText(text);
buffer.guessSegmentProperties();
private generateTextLineInfo(text: string, font: HBFont): LineInfo[] {
const stringParts = extractEmoji(text);
const lineInfo: LineInfo[] = [];

this.harfbuzz.hb.shape(font, buffer);
const result = buffer.json();
for (const part of stringParts) {
const buffer = this.harfbuzz.hb.createBuffer();
buffer.addText(part.value);
buffer.guessSegmentProperties();

return {
buffer: result,
glyphs: result.reduce<Record<number, SVGPathSegment[]>>(function (acc, e) {
if (!acc[e.g]) {
acc[e.g] = font.glyphToJson(e.g);
}
this.harfbuzz.hb.shape(part.type === 'text' ? font : this.emojiFont.font, buffer);
const result = buffer.json();

return acc;
}, {}),
};
lineInfo.push({
buffer: result,
glyphs: result.reduce<Record<number, SVGPathSegment[]>>((acc, e) => {
if (!acc[e.g]) {
acc[e.g] = (part.type === 'text' ? font : this.emojiFont.font).glyphToJson(e.g);
}

return acc;
}, {}),
});
}

return lineInfo;
}

private getSVGPathSegmentsBoundingBox(path: SVGPathSegment[]) {
Expand Down Expand Up @@ -294,6 +308,7 @@ export default class TextMakerService extends Service {

// https://harfbuzz.github.io/harfbuzz-hb-font.html (see hb_font_set_scale)
font.font.setScale(size, size);
this.emojiFont.font.setScale(size, size);

const lines = text.split('\n').map((s) => s.trimEnd());
let oy = 0; // Last x offset where to start drawing glyph
Expand All @@ -302,43 +317,46 @@ export default class TextMakerService extends Service {
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 linesInfos) {
for (const lineParts of linesInfos) {
let ox = 0; // Last x offset where to start drawing glyph
let lineMaxX = 0;
const lineMinMaxY = { minY: Number.MAX_SAFE_INTEGER, maxY: -Number.MAX_SAFE_INTEGER };
const lineGlyphInfos: { height: number; maxY: number; minY: number }[] = [];

// 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;
// Iterate through single line parts (text or emoji parts)
for (const lineText of lineParts) {
// 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;
const emptyGlyph = lineText.glyphs[info.g].length === 0;

const glyphBounds = this.getSVGPathSegmentsBoundingBox(lineText.glyphs[info.g]);
const glyphHeight = glyphBounds.y2 - glyphBounds.y1;
const glyphBounds = this.getSVGPathSegmentsBoundingBox(lineText.glyphs[info.g]);
const glyphHeight = glyphBounds.y2 - glyphBounds.y1;

const minY = emptyGlyph ? 0 : Math.min(glyphBounds.y1, glyphBounds.y2);
const maxY = emptyGlyph ? 0 : Math.max(glyphBounds.y1, glyphBounds.y2);
const minY = emptyGlyph ? 0 : Math.min(glyphBounds.y1, glyphBounds.y2);
const maxY = emptyGlyph ? 0 : Math.max(glyphBounds.y1, glyphBounds.y2);

lineMinMaxY.maxY = Math.max(lineMinMaxY.maxY, maxY);
lineMinMaxY.minY = Math.min(lineMinMaxY.minY, minY);
lineMinMaxY.maxY = Math.max(lineMinMaxY.maxY, maxY);
lineMinMaxY.minY = Math.min(lineMinMaxY.minY, minY);

lineGlyphInfos.push({
height: glyphHeight,
maxY,
minY,
});
lineGlyphInfos.push({
height: glyphHeight,
maxY,
minY,
});

lineMaxX = x + glyphBounds.x2;
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);
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;
});
ox += spacing + info.ax;
});
}

oy += size + vSpacing;

Expand Down Expand Up @@ -366,37 +384,40 @@ export default class TextMakerService extends Service {
// 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];
const lineParts = 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
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 = linesMinMaxY[lineIndex];
const glyphInfo = linesGlyphInfos[lineIndex][glyphIndex];

if (vAlignment === 'bottom' && lineMaxY.minY !== glyphInfo.minY) {
y += lineMaxY.minY - glyphInfo.minY;
} else if (vAlignment === 'top' && lineMaxY.maxY !== glyphInfo.maxY) {
y += lineMaxY.maxY - glyphInfo.maxY;
// Iterate through single line parts (text or emoji parts)
for (const lineText of lineParts) {
// Iterate on text char to generate a Geometry for each
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 = linesMinMaxY[lineIndex];
const glyphInfo = linesGlyphInfos[lineIndex][glyphIndex];

if (vAlignment === 'bottom' && lineMaxY.minY !== glyphInfo.minY) {
y += lineMaxY.minY - glyphInfo.minY;
} else if (vAlignment === 'top' && lineMaxY.maxY !== glyphInfo.maxY) {
y += lineMaxY.maxY - glyphInfo.maxY;
}
}
}

glyphShapes.push(
this.glyphToShapes(
lineText.glyphs[info.g],
x, // x offset
y - oy, // y offset
),
);
ox += spacing + info.ax;
glyphIndex++;
});
glyphShapes.push(
this.glyphToShapes(
lineText.glyphs[info.g],
x, // x offset
y - oy, // y offset
),
);
ox += spacing + info.ax;
glyphIndex++;
});
}

oy += size + vSpacing;
}
Expand Down
2 changes: 1 addition & 1 deletion config/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ module.exports = function (environment) {
textMakerDefault: {
fontName: 'Roboto',
variantName: 'regular',
text: 'Bienvenue !',
text: '🤍Hello !🔥',
size: 45,
height: 10,
spacing: 2,
Expand Down
Binary file added public/NotoEmoji-Regular.ttf
Binary file not shown.
1 change: 1 addition & 0 deletions tests/acceptance/_tests-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ const testsSettingsOverride: Array<TextMakerSettingsParameters> = [
},
// Various font
{
text: 'multi\nline\nwith custom font',
fontName: 'chango',
},
].map((settings) => {
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/fonts/NotoEmoji-Regular.ts

Large diffs are not rendered by default.

Binary file added tests/fixtures/fonts/NotoEmoji-Regular.ttf
Binary file not shown.
3 changes: 3 additions & 0 deletions tests/fixtures/fonts/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
/* eslint-disable camelcase */
import chango from './chango';
import open_sans from './open_sans';
import NotoEmojiRegular from './NotoEmoji-Regular';

// base64 -i font.ttf -o tmp.txt
export default {
chango,
open_sans,
Roboto: open_sans, // Default, for test purpose
'NotoEmoji-Regular': NotoEmojiRegular,
} as Record<string, string>;
Empty file added tests/fixtures/fonts/tmp.txt
Empty file.
Loading

0 comments on commit 999a81d

Please sign in to comment.