Skip to content

Commit b2127fe

Browse files
authored
feat: Add HarfBuzz text shaping (#735)
Adds HarfBuzz-backed shaping for OpenType features and complex scripts while keeping Satori's published bundle close to its previous size by loading the patched HarfBuzz runtime as an external dependency. Measurement and SVG generation now share shaped run metrics, including kerning, ligatures, WOFF conversion, and font fallback boundaries. The PR also adds focused image coverage for OpenType features, complex-script shaping, mixed-font letter spacing, and browser consumer bundling. Full Unicode bidirectional layout remains explicitly unsupported.
1 parent de6db71 commit b2127fe

107 files changed

Lines changed: 2518 additions & 392 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,11 +215,12 @@ Satori uses the same Flexbox [layout engine](https://yogalayout.com) as React Na
215215
<tr><td><code>justifyContent</code></td><td>Supported</td><td></td></tr>
216216
<tr><td><code>gap</code></td><td>Supported</td><td></td></tr>
217217

218-
<tr><td rowspan="5">Font</td></tr>
218+
<tr><td rowspan="6">Font</td></tr>
219219
<tr><td><code>fontFamily</code></td><td>Supported</td><td></td></tr>
220220
<tr><td><code>fontSize</code></td><td>Supported</td><td></td></tr>
221221
<tr><td><code>fontWeight</code></td><td>Supported</td><td></td></tr>
222222
<tr><td><code>fontStyle</code></td><td>Supported</td><td></td></tr>
223+
<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>
223224

224225
<tr><td rowspan="13">Text</td></tr>
225226
<tr><td><code>tabSize</code></td><td>Supported</td><td></td></tr>
@@ -346,9 +347,24 @@ Note:
346347

347348
### Language and Typography
348349

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

351-
RTL languages are not supported either.
358+
Example:
359+
```jsx
360+
<div style={{ fontFeatureSettings: '"smcp" 1, "liga" 0' }}>
361+
This Text Uses Small Caps
362+
</div>
363+
```
364+
365+
HarfBuzz also improves glyph shaping for complex scripts such as Arabic. Full
366+
Unicode bidirectional layout is not yet supported, so mixed LTR and RTL text
367+
may not follow browser ordering.
352368

353369
#### Fonts
354370

package.json

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,6 @@
100100
"devDependencies": {
101101
"@resvg/resvg-js": "^2.1.0",
102102
"@types/node": "^16",
103-
"@types/opentype.js": "^1.3.3",
104103
"@types/react": "^17.0.38",
105104
"@typescript-eslint/eslint-plugin": "^5.40.0",
106105
"@typescript-eslint/parser": "^5.40.0",
@@ -126,12 +125,14 @@
126125
},
127126
"dependencies": {
128127
"@shuding/opentype.js": "1.4.0-beta.0",
128+
"fflate": "0.7.3",
129129
"css-background-parser": "^0.1.0",
130130
"css-box-shadow": "1.0.0-3",
131131
"css-gradient-parser": "^0.0.17",
132132
"css-to-react-native": "^3.0.0",
133133
"emoji-regex-xs": "^2.0.1",
134134
"escape-html": "^1.0.3",
135+
"harfbuzzjs": "0.10.0",
135136
"linebreak": "^1.1.0",
136137
"parse-css-color": "^0.2.1",
137138
"postcss-value-parser": "^4.2.0",
@@ -140,10 +141,5 @@
140141
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
141142
"engines": {
142143
"node": ">=16"
143-
},
144-
"pnpm": {
145-
"patchedDependencies": {
146-
"yoga-layout@3.2.1": "patches/yoga-layout@3.2.1.patch"
147-
}
148144
}
149145
}

patches/harfbuzzjs@0.10.0.patch

Lines changed: 8 additions & 0 deletions
Large diffs are not rendered by default.

playground/cards/playground-data.ts

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,6 @@ export type Tabs = {
33
}
44

55
const playgroundTabs: Tabs = {
6-
helloworld: `<div
7-
style={{
8-
height: '100%',
9-
width: '100%',
10-
display: 'flex',
11-
flexDirection: 'column',
12-
alignItems: 'center',
13-
justifyContent: 'center',
14-
backgroundColor: '#fff',
15-
fontSize: 32,
16-
fontWeight: 600,
17-
}}
18-
>
19-
<svg
20-
width="75"
21-
viewBox="0 0 75 65"
22-
fill="#000"
23-
style={{ margin: '0 75px' }}
24-
>
25-
<path d="M37.59.25l36.95 64H.64l36.95-64z"></path>
26-
</svg>
27-
<div style={{ marginTop: 40 }}>Hello, World</div>
28-
</div>
29-
`,
306
Vercel: `<div
317
style={{
328
height: '100%',
@@ -69,7 +45,7 @@ const playgroundTabs: Tabs = {
6945
whiteSpace: 'pre-wrap',
7046
}}
7147
>
72-
<b>Vercel Edge Network</b>
48+
<b>Agentic Infrastructure</b>
7349
</div>
7450
</div>
7551
`,
@@ -312,7 +288,7 @@ const playgroundTabs: Tabs = {
312288
</div>
313289
</div>
314290
)
315-
}
291+
}
316292
`,
317293
}
318294

playground/next-env.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3+
import './.next/dev/types/routes.d.ts'
4+
import './.next/dev/types/root-params.d.ts'
35

46
// NOTE: This file should not be edited
5-
// see https://nextjs.org/docs/basic-features/typescript for more information.
7+
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

playground/package.json

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,20 @@
1010
"start": "next start"
1111
},
1212
"engines": {
13-
"node": ">=20"
13+
"node": ">=20.9.0"
1414
},
1515
"dependencies": {
1616
"@babel/runtime": "^7.19.0",
1717
"@monaco-editor/react": "^4.4.5",
1818
"@resvg/resvg-wasm": "^2.3.1",
1919
"blob-stream": "^0.1.3",
20-
"copy-to-clipboard": "^3.3.2",
21-
"fflate": "^0.7.3",
22-
"intl-segmenter-polyfill": "^0.4.4",
23-
"js-base64": "^3.7.2",
24-
"next": "^12.2.5",
20+
"copy-to-clipboard": "^4.0.2",
21+
"fflate": "^0.8.3",
22+
"js-base64": "^3.9.3",
23+
"next": "^16.3.1",
2524
"pdfkit": "^0.13.0",
26-
"react": "^17.0.2",
27-
"react-dom": "^17.0.2",
25+
"react": "^19.2.8",
26+
"react-dom": "^19.2.8",
2827
"react-hot-toast": "^2.3.0",
2928
"react-live": "^2.4.1",
3029
"react-resizable-panels": "^0.0.30",

playground/pages/api/font.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { NextRequest } from 'next/server'
22
import { FontDetector, languageFontMap } from '../../utils/font'
33

44
export const config = {
5-
runtime: 'experimental-edge',
5+
runtime: 'edge',
66
}
77

88
const detector = new FontDetector()

playground/pages/index.tsx

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,18 @@ import { Base64 } from 'js-base64'
1212
import PDFDocument from 'pdfkit/js/pdfkit.standalone'
1313
import SVGtoPDF from 'svg-to-pdfkit'
1414
import blobStream from 'blob-stream'
15-
import { createIntlSegmenterPolyfill } from 'intl-segmenter-polyfill'
1615
import { Panel, PanelGroup } from 'react-resizable-panels'
1716

1817
import { loadEmoji, getIconCode, apis } from '../utils/twemoji'
1918
import Introduction from '../components/introduction'
2019
import PanelResizeHandle from '../components/panel-resize-handle'
2120
import { languageFontMap } from '../utils/font'
2221

23-
import playgroundTabs, { Tabs } from '../cards/playground-data'
22+
import playgroundTabs, { Tabs as TTabs } from '../cards/playground-data'
2423
import previewTabs from '../cards/preview-tabs'
2524

2625
const cardNames = Object.keys(playgroundTabs)
27-
const editedCards: Tabs = { ...playgroundTabs }
26+
const editedCards: TTabs = { ...playgroundTabs }
2827

2928
async function init() {
3029
if (typeof window === 'undefined') return []
@@ -41,16 +40,7 @@ async function init() {
4140
fetch('/material-icons-base-400-normal.woff').then((res) =>
4241
res.arrayBuffer()
4342
),
44-
!globalThis.Intl || !globalThis.Intl.Segmenter
45-
? createIntlSegmenterPolyfill(
46-
fetch(
47-
new URL(
48-
'intl-segmenter-polyfill/dist/break_iterator.wasm',
49-
import.meta.url
50-
)
51-
)
52-
)
53-
: null,
43+
null,
5444
]))
5545

5646
if (Segmenter) {
@@ -928,7 +918,7 @@ function ResetCode({ activeCard }: { activeCard: string }) {
928918
const decoded = JSON.parse(decompressedData)
929919
card = decoded.code
930920
overrideOptions = decoded.options
931-
tab = decoded.tab || 'helloworld'
921+
tab = decoded.tab || 'Vercel'
932922
} catch (e) {
933923
card = decompressedData
934924
}
@@ -956,7 +946,7 @@ function ResetCode({ activeCard }: { activeCard: string }) {
956946
}
957947

958948
export default function Playground() {
959-
const [activeCard, setActiveCard] = useState<string>('helloworld')
949+
const [activeCard, setActiveCard] = useState<string>('Vercel')
960950
const [showIntroduction, setShowIntroduction] = useState(false)
961951
const [isMobileView, setIsMobileView] = useState(false)
962952

playground/tsconfig.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
"incremental": true,
1111
"esModuleInterop": true,
1212
"module": "esnext",
13-
"moduleResolution": "node",
13+
"moduleResolution": "bundler",
1414
"resolveJsonModule": true,
1515
"isolatedModules": true,
16-
"jsx": "preserve"
16+
"jsx": "react-jsx"
1717
},
1818
"include": ["decs.d.ts", "**/*.ts", "**/*.tsx"],
1919
"exclude": ["node_modules"]

0 commit comments

Comments
 (0)