Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,12 @@ Satori uses the same Flexbox [layout engine](https://yogalayout.com) as React Na
<tr><td><code>justifyContent</code></td><td>Supported</td><td></td></tr>
<tr><td><code>gap</code></td><td>Supported</td><td></td></tr>

<tr><td rowspan="5">Font</td></tr>
<tr><td rowspan="6">Font</td></tr>
<tr><td><code>fontFamily</code></td><td>Supported</td><td></td></tr>
<tr><td><code>fontSize</code></td><td>Supported</td><td></td></tr>
<tr><td><code>fontWeight</code></td><td>Supported</td><td></td></tr>
<tr><td><code>fontStyle</code></td><td>Supported</td><td></td></tr>
<tr><td><code>fontFeatureSettings</code></td><td>Supported via HarfBuzz text shaping. Enables OpenType features like ligatures, small caps, stylistic sets, etc.</td><td></td></tr>

<tr><td rowspan="13">Text</td></tr>
<tr><td><code>tabSize</code></td><td>Supported</td><td></td></tr>
Expand Down Expand Up @@ -307,9 +308,22 @@ Note:

### Language and Typography

Advanced typography features such as kerning, ligatures and other OpenType features are not currently supported.
**OpenType Features**: Satori now supports advanced typography features via HarfBuzz text shaping! Use the `font-feature-settings` CSS property to enable OpenType features such as:
- Ligatures (`liga`, `dlig`, `hlig`)
- Small caps (`smcp`, `c2sc`)
- Stylistic sets (`ss01`-`ss20`)
- Contextual alternates (`calt`)
- Swashes (`swsh`, `cswh`)
- And many more OpenType features

RTL languages are not supported either.
Example:
```jsx
<div style={{ fontFeatureSettings: '"smcp" 1, "liga" 0' }}>
This Text Uses Small Caps
</div>
```

**Note**: RTL (right-to-left) languages are not yet fully supported.

#### Fonts

Expand Down
40 changes: 40 additions & 0 deletions analyze-usage.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { readFileSync } from 'fs'
import opentype from '@shuding/opentype.js'

const fontData = readFileSync('./test/assets/Roboto-Regular.ttf')
const arrayBuffer = fontData.buffer.slice(fontData.byteOffset, fontData.byteOffset + fontData.byteLength)

const font = opentype.parse(arrayBuffer, { lowMemory: true })

console.log('=== What opentype.js provides that HarfBuzz may not ===\n')

console.log('1. Font Metadata:')
console.log(' - unitsPerEm:', font.unitsPerEm)
console.log(' - ascender:', font.ascender)
console.log(' - descender:', font.descender)
console.log(' - names.fontFamily:', font.names?.fontFamily)
console.log(' - names.fontSubfamily:', font.names?.fontSubfamily)

console.log('\n2. Font Tables (OS/2):')
console.log(' - usWeightClass:', font.tables?.os2?.usWeightClass)
console.log(' - sTypoAscender:', font.tables?.os2?.sTypoAscender)
console.log(' - sTypoDescender:', font.tables?.os2?.sTypoDescender)
console.log(' - sTypoLineGap:', font.tables?.os2?.sTypoLineGap)

console.log('\n3. Character to Glyph:')
const testChars = ['A', 'fi', '你', '🎉']
for (const char of testChars) {
const glyphIndex = font.charToGlyphIndex(char)
console.log(` - "${char}" -> glyph ${glyphIndex}`)
}

console.log('\n4. Glyph Object:')
const glyphA = font.glyphs.get(font.charToGlyphIndex('A'))
console.log(' - Glyph object keys:', Object.keys(glyphA).slice(0, 10))
console.log(' - Has path:', !!glyphA.path)
console.log(' - Has getPath method:', typeof glyphA.getPath === 'function')

console.log('\n5. What else:')
console.log(' - getAdvanceWidth:', typeof font.getAdvanceWidth === 'function')
console.log(' - stringToGlyphs:', typeof font.stringToGlyphs === 'function')
console.log(' - forEachGlyph:', typeof font.forEachGlyph === 'function')
44 changes: 44 additions & 0 deletions compare-paths.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { readFileSync } from 'fs'
import opentype from '@shuding/opentype.js'

const hb = await import('harfbuzzjs').then(m => m.default)

// Load font with both systems
const fontData = readFileSync('./test/assets/Roboto-Regular.ttf')
const arrayBuffer = fontData.buffer.slice(fontData.byteOffset, fontData.byteOffset + fontData.byteLength)

const otFont = opentype.parse(arrayBuffer, { lowMemory: true })

const blob = hb.createBlob(new Uint8Array(arrayBuffer))
const face = hb.createFace(blob, 0)
const hbFont = hb.createFont(face)
hbFont.setScale(otFont.unitsPerEm, otFont.unitsPerEm)

// Shape with HarfBuzz
const buffer = hb.createBuffer()
buffer.addText('t')
buffer.guessSegmentProperties()
hb.shape(hbFont, buffer)
const shaped = buffer.json(hbFont)

const hbPath = hbFont.glyphToPath(shaped[0].g)
const otGlyph = otFont.glyphs.get(shaped[0].g)
const otPath = otGlyph.getPath(0, 0, 100, {}).toPathData(1)

console.log('HarfBuzz path:')
console.log(hbPath)
console.log('\nOpenType.js path:')
console.log(otPath)

console.log('\n=== Parse OS/2 table for weight ===')
const os2Table = face.reference_table('OS/2')
if (os2Table && os2Table.length >= 6) {
const view = new DataView(os2Table.buffer, os2Table.byteOffset, os2Table.byteLength)
const usWeightClass = view.getUint16(4)
console.log('Weight from OS/2:', usWeightClass)
}

buffer.destroy()
hbFont.destroy()
face.destroy()
blob.destroy()
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@
"devDependencies": {
"@resvg/resvg-js": "^2.1.0",
"@types/node": "^16",
"@types/opentype.js": "^1.3.3",
"@types/react": "^17.0.38",
"@typescript-eslint/eslint-plugin": "^5.40.0",
"@typescript-eslint/parser": "^5.40.0",
Expand Down Expand Up @@ -112,6 +111,7 @@
"css-to-react-native": "^3.0.0",
"emoji-regex-xs": "^2.0.1",
"escape-html": "^1.0.3",
"harfbuzzjs": "^0.10.0",
"linebreak": "^1.1.0",
"parse-css-color": "^0.2.1",
"postcss-value-parser": "^4.2.0",
Expand Down
14 changes: 7 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

106 changes: 99 additions & 7 deletions src/font.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
*/
import opentype from '@shuding/opentype.js'
import { Locale, locales, isValidLocale } from './language.js'
import {
isHarfBuzzInitialized,
shapeText,
parseFontFeatureSettings,
type FontFeatures,
type ShapedGlyph,
} from './harfbuzz.js'

export type Weight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
export type WeightName = 'normal' | 'bold'
Expand Down Expand Up @@ -38,6 +45,7 @@ export type FontEngine = {
style: {
fontSize: number
letterSpacing: number
fontFeatureSettings?: string
}
) => number
getSVG: (
Expand All @@ -47,6 +55,7 @@ export type FontEngine = {
top: number
left: number
letterSpacing: number
fontFeatureSettings?: string
},
band?: SkipInkBand
) => { path: string; boxes: GlyphBox[] }
Expand Down Expand Up @@ -355,17 +364,24 @@ export default class FontLoader {
if (cachedParsedFont.has(data)) {
font = cachedParsedFont.get(data)
} else {
font = opentype.parse(
// Buffer to ArrayBuffer.
// Convert Buffer to ArrayBuffer if needed
const arrayBuffer =
'buffer' in data
? data.buffer.slice(
data.byteOffset,
data.byteOffset + data.byteLength
)
: data,
: data

font = opentype.parse(
arrayBuffer,
// @ts-ignore
{ lowMemory: true }
)

// Store the raw font data for HarfBuzz
;(font as any)._rawFontData = arrayBuffer

// Modify the `charToGlyphIndex` method, so we can know which char is
// being mapped to which glyph.
const originalCharToGlyphIndex = font.charToGlyphIndex
Expand Down Expand Up @@ -670,12 +686,37 @@ export default class FontLoader {
{
fontSize,
letterSpacing = 0,
fontFeatureSettings,
}: {
fontSize: number
letterSpacing: number
fontFeatureSettings?: string
}
) {
const font = resolveFont(content)

// Use HarfBuzz for all text shaping if initialized
if (isHarfBuzzInitialized()) {
const features = fontFeatureSettings
? parseFontFeatureSettings(fontFeatureSettings)
: {}

const shaped = shapeText(font, content, { features })

// Sum up advance widths from shaped glyphs
let width = 0
for (const glyph of shaped) {
width += glyph.ax
}

// Convert from font units to pixels and add letter spacing
const pixelWidth = (width / font.unitsPerEm) * fontSize
const spacingWidth = letterSpacing * (content.length - 1)

return pixelWidth + spacingWidth
}

// Use opentype.js only if HarfBuzz not available
const unpatch = this.patchFontFallbackResolver(font, resolveFont)

try {
Expand All @@ -695,22 +736,73 @@ export default class FontLoader {
top,
left,
letterSpacing = 0,
fontFeatureSettings,
}: {
fontSize: number
top: number
left: number
letterSpacing: number
fontFeatureSettings?: string
},
band?: SkipInkBand
): { path: string; boxes: GlyphBox[] } {
const font = resolveFont(content)
const unpatch = this.patchFontFallbackResolver(font, resolveFont)

try {
if (fontSize === 0) {
return { path: '', boxes: [] }
if (fontSize === 0) {
return { path: '', boxes: [] }
}

// Use HarfBuzz for all text shaping if initialized
if (isHarfBuzzInitialized()) {
const features = fontFeatureSettings
? parseFontFeatureSettings(fontFeatureSettings)
: {}

const shaped = shapeText(font, content.replace(/\n/g, ''), { features })

const fullPath = new opentype.Path()
const boxes: GlyphBox[] = []
const scale = fontSize / font.unitsPerEm

let cursorX = left
let cursorY = top

// Process shaped glyphs
for (const shapedGlyph of shaped) {
// Get the glyph from opentype.js by ID
const glyph = font.glyphs.get(shapedGlyph.g)

if (glyph && glyph.path) {
// Calculate glyph position
const gX = cursorX + shapedGlyph.dx * scale
const gY = cursorY + shapedGlyph.dy * scale

// Get the glyph path and transform it
const glyphPath = glyph.getPath(gX, gY, fontSize, {})

// Compute band boxes for text decoration skip-ink
const bandBoxes = band ? computeBandBox(glyphPath.commands, band) : []
if (bandBoxes.length) {
boxes.push(...bandBoxes)
}

fullPath.extend(glyphPath)
}

// Advance cursor by the shaped advance + letter spacing
cursorX += shapedGlyph.ax * scale + letterSpacing
}

return {
path: fullPath.toPathData(1),
boxes,
}
}

// Use opentype.js only if HarfBuzz not available
const unpatch = this.patchFontFallbackResolver(font, resolveFont)

try {
const fullPath = new opentype.Path()
const boxes: GlyphBox[] = []

Expand Down
1 change: 1 addition & 0 deletions src/handler/expand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ type MainStyle = {
fontFamily: string | string[]
fontWeight: FontWeight
fontStyle: FontStyle
fontFeatureSettings: string

borderTopWidth: number
borderLeftWidth: number
Expand Down
1 change: 1 addition & 0 deletions src/handler/inheritable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const list = new Set([
'fontSize',
'fontStyle',
'fontWeight',
'fontFeatureSettings',
'letterSpacing',
'lineHeight',
'textAlign',
Expand Down
Loading
Loading