@altano/satori-fit-text 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/dist/TextMeasurer.d.ts +12 -0
- package/dist/fitText.d.ts +43 -0
- package/dist/getSatoriFriendlyVNode.d.ts +4 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +129 -0
- package/dist/types.d.ts +3 -0
- package/package.json +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# satori-fit-text
|
|
2
|
+
|
|
3
|
+
Uses [Satori](https://github.com/vercel/satori) to calculate the largest text font size that will fit a bounding box.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
# Using NPM
|
|
9
|
+
npm install @altano/satori-fit-text
|
|
10
|
+
# Using Yarn
|
|
11
|
+
yarn add @altano/satori-fit-text
|
|
12
|
+
# Using PNPM
|
|
13
|
+
pnpm add @altano/satori-fit-text
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Example Usage
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { findLargestUsableFontSize, type Font } from "@altano/satori-fit-text";
|
|
20
|
+
|
|
21
|
+
async function getInter(): Promise<Font> {
|
|
22
|
+
const interSemiBoldBuffer = await fetch(`https://rsms.me/inter/font-files/Inter-SemiBold.woff`).then((res) => res.arrayBuffer());
|
|
23
|
+
|
|
24
|
+
const font = {
|
|
25
|
+
name: "Inter",
|
|
26
|
+
data: interSemiBoldBuffer,
|
|
27
|
+
weight: 600,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const largestUsableFontSize = await findLargestUsableFontSize({
|
|
32
|
+
lineHeight: 1,
|
|
33
|
+
font: await getFont(),
|
|
34
|
+
text: "Some text I want to be as big as possible",
|
|
35
|
+
maxWidth: 1136,
|
|
36
|
+
maxHeight: 429,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
console.log(largestUsableFontSize);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Potential Uses
|
|
43
|
+
|
|
44
|
+
Because this measures the maximum font size using Satori, the ideal use of this library is to determine the largest possible font size in something like an [Open Graph card](https://ogp.me/). If you dynamically generate Open Graph cards with text (e.g. the title of your blog post) you may want the text to fill the card's area as best as possible without overflowing. This library can help you achieve that.
|
|
45
|
+
|
|
46
|
+
## Implementation
|
|
47
|
+
|
|
48
|
+
The library tries various font sizes until it finds the ideal one that works. Each iteration is tested by generating an SVG with Satori. The SVG dimensions are found using [SVG.js])(https://svgjs.dev/docs/3.0/).
|
|
49
|
+
|
|
50
|
+
## Performance
|
|
51
|
+
|
|
52
|
+
The font size search is a binary search between 1 and 1000 with `O(log maxFontSize-minFontSize)` runtime, or a default of `O(log 1000)`. You can restrict the search space (by reducing the difference between `minFontSize` or `maxFontSize`) to reduce the number of guesses and improve performance.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Font } from "./types";
|
|
2
|
+
export declare class TextMeasurer {
|
|
3
|
+
#private;
|
|
4
|
+
private text;
|
|
5
|
+
private font;
|
|
6
|
+
private maxWidth;
|
|
7
|
+
private maxHeight;
|
|
8
|
+
private lineHeight;
|
|
9
|
+
constructor(text: string, font: Font, maxWidth: number, maxHeight: number, lineHeight: number | "normal");
|
|
10
|
+
doesSizeFit(fontSize: number): Promise<boolean>;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=TextMeasurer.d.ts.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Font } from "./types";
|
|
2
|
+
export type FindOptions = {
|
|
3
|
+
/**
|
|
4
|
+
* The text you want to fit
|
|
5
|
+
*/
|
|
6
|
+
text: string;
|
|
7
|
+
/**
|
|
8
|
+
* The font of your text. Options are the same as [those taken by
|
|
9
|
+
* Satori](https://github.com/vercel/satori#fonts), but only one font and not
|
|
10
|
+
* an array of fonts is accepted here.
|
|
11
|
+
*/
|
|
12
|
+
font: Font;
|
|
13
|
+
/**
|
|
14
|
+
* The max width the text must be contained in
|
|
15
|
+
*/
|
|
16
|
+
maxWidth: number;
|
|
17
|
+
/**
|
|
18
|
+
* The max height the text must be contained in
|
|
19
|
+
*/
|
|
20
|
+
maxHeight: number;
|
|
21
|
+
/**
|
|
22
|
+
* Optional: the CSS line height factor to use, e.g. 1.1
|
|
23
|
+
*/
|
|
24
|
+
lineHeight?: number | "normal";
|
|
25
|
+
/**
|
|
26
|
+
* The minimum font size that should be returned, even if smaller fonts fit
|
|
27
|
+
*/
|
|
28
|
+
minFontSize?: number;
|
|
29
|
+
/**
|
|
30
|
+
* The maximum font size that should be returned, even if larger fonts fit
|
|
31
|
+
*/
|
|
32
|
+
maxFontSize?: number;
|
|
33
|
+
/**
|
|
34
|
+
* The maximum number of font sizes to try before giving up. Defaults to 1000.
|
|
35
|
+
*/
|
|
36
|
+
maxTries?: number;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Given text, dimensions, and some options, returns the largest font size that
|
|
40
|
+
* would keep the text contained within the dimensions.
|
|
41
|
+
*/
|
|
42
|
+
export declare function findLargestUsableFontSize({ text, font, maxWidth, maxHeight, lineHeight, minFontSize, maxFontSize, maxTries, }: FindOptions): Promise<number>;
|
|
43
|
+
//# sourceMappingURL=fitText.d.ts.map
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// src/TextMeasurer.ts
|
|
2
|
+
import { SVG, registerWindow } from "@svgdotjs/svg.js";
|
|
3
|
+
import satori from "satori";
|
|
4
|
+
|
|
5
|
+
// src/getSatoriFriendlyVNode.ts
|
|
6
|
+
import { html as htmlToVNode } from "satori-html";
|
|
7
|
+
import he from "he";
|
|
8
|
+
function walkJsxTree(vnode, callback) {
|
|
9
|
+
callback(vnode);
|
|
10
|
+
const { children } = vnode.props;
|
|
11
|
+
if (children == null || typeof children === "string") {
|
|
12
|
+
return;
|
|
13
|
+
} else if (Array.isArray(children)) {
|
|
14
|
+
children.forEach((child) => walkJsxTree(child, callback));
|
|
15
|
+
} else {
|
|
16
|
+
walkJsxTree(children, callback);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function patchJsxTreeForSatori(vnode) {
|
|
20
|
+
walkJsxTree(vnode, (vnode2) => {
|
|
21
|
+
const { style } = vnode2.props;
|
|
22
|
+
if (style != null && typeof style["lineHeight"] === "string" && style["lineHeight"] != "") {
|
|
23
|
+
const num = Number(style["lineHeight"]);
|
|
24
|
+
if (!isNaN(num)) {
|
|
25
|
+
style["lineHeight"] = num;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function getSatoriFriendlyVNode(html) {
|
|
31
|
+
const htmlWithDecodedHtmlEntities = he.decode(html);
|
|
32
|
+
const vnode = htmlToVNode(htmlWithDecodedHtmlEntities);
|
|
33
|
+
patchJsxTreeForSatori(vnode);
|
|
34
|
+
return vnode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/TextMeasurer.ts
|
|
38
|
+
import { createSVGWindow } from "svgdom";
|
|
39
|
+
var TextMeasurer = class {
|
|
40
|
+
constructor(text, font, maxWidth, maxHeight, lineHeight) {
|
|
41
|
+
this.text = text;
|
|
42
|
+
this.font = font;
|
|
43
|
+
this.maxWidth = maxWidth;
|
|
44
|
+
this.maxHeight = maxHeight;
|
|
45
|
+
this.lineHeight = lineHeight;
|
|
46
|
+
const window = createSVGWindow();
|
|
47
|
+
const document = window.document;
|
|
48
|
+
registerWindow(window, document);
|
|
49
|
+
this.#canvas = SVG(document.documentElement);
|
|
50
|
+
}
|
|
51
|
+
#canvas;
|
|
52
|
+
async doesSizeFit(fontSize) {
|
|
53
|
+
await this.#setFontSize(fontSize);
|
|
54
|
+
const { width, height } = this.#canvas.bbox();
|
|
55
|
+
return width <= this.maxWidth && height <= this.maxHeight;
|
|
56
|
+
}
|
|
57
|
+
async #generateSvg(fontSize) {
|
|
58
|
+
const html = `
|
|
59
|
+
<div style="
|
|
60
|
+
margin: 0;
|
|
61
|
+
padding: 0;
|
|
62
|
+
font-size: ${fontSize}px;
|
|
63
|
+
max-width: ${this.maxWidth}px;
|
|
64
|
+
line-height: ${this.lineHeight};
|
|
65
|
+
border: solid red 1px;
|
|
66
|
+
">
|
|
67
|
+
${this.text}
|
|
68
|
+
</div>
|
|
69
|
+
`.trim();
|
|
70
|
+
const vnode = getSatoriFriendlyVNode(html);
|
|
71
|
+
return satori(vnode, {
|
|
72
|
+
fonts: [
|
|
73
|
+
{
|
|
74
|
+
name: this.font.name,
|
|
75
|
+
data: this.font.data
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
width: this.maxWidth * 2,
|
|
79
|
+
height: this.maxHeight * 2
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
async #setFontSize(fontSize) {
|
|
83
|
+
const svgText = await this.#generateSvg(fontSize);
|
|
84
|
+
this.#canvas.clear();
|
|
85
|
+
this.#canvas.svg(svgText);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// src/fitText.ts
|
|
90
|
+
async function findLargestUsableFontSize({
|
|
91
|
+
text,
|
|
92
|
+
font,
|
|
93
|
+
maxWidth,
|
|
94
|
+
maxHeight,
|
|
95
|
+
lineHeight = "normal",
|
|
96
|
+
minFontSize = 1,
|
|
97
|
+
maxFontSize = 1e3,
|
|
98
|
+
maxTries = 1e3
|
|
99
|
+
}) {
|
|
100
|
+
if (maxTries < 2) {
|
|
101
|
+
throw new Error("`maxTries` must be >= 2");
|
|
102
|
+
}
|
|
103
|
+
const textMeasurer = new TextMeasurer(
|
|
104
|
+
text,
|
|
105
|
+
font,
|
|
106
|
+
maxWidth,
|
|
107
|
+
maxHeight,
|
|
108
|
+
lineHeight
|
|
109
|
+
);
|
|
110
|
+
for (let tries = 1, lower = minFontSize, upper = maxFontSize; tries < maxTries; ++tries) {
|
|
111
|
+
const searchCutoffThreshold = Math.min(1, lower);
|
|
112
|
+
if (upper - lower <= searchCutoffThreshold) {
|
|
113
|
+
return Math.floor(lower);
|
|
114
|
+
}
|
|
115
|
+
const sizeCandidate = Math.floor(lower + (upper - lower) / 2);
|
|
116
|
+
const doesFit = await textMeasurer.doesSizeFit(sizeCandidate);
|
|
117
|
+
if (doesFit) {
|
|
118
|
+
lower = sizeCandidate;
|
|
119
|
+
} else {
|
|
120
|
+
upper = sizeCandidate;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Could not find a valid font size in ${maxTries - 1} attempts. Giving up. Please try again after constraining the search space, e.g. by restricting the min/max font size difference.`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
export {
|
|
128
|
+
findLargestUsableFontSize
|
|
129
|
+
};
|
package/dist/types.d.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@altano/satori-fit-text",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Fit text to a bounding box in Node or the browser",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"exports": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"author": {
|
|
10
|
+
"name": "Alan Norbauer",
|
|
11
|
+
"email": "altano@gmail.com",
|
|
12
|
+
"url": "https://alan.norbauer.com"
|
|
13
|
+
},
|
|
14
|
+
"license": "ISC",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/altano/npm-packages",
|
|
18
|
+
"directory": "packages/satori-fit-text"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/altano/npm-packages/tree/main/packages/satori-fit-text",
|
|
21
|
+
"files": [
|
|
22
|
+
"dist/**/*.js",
|
|
23
|
+
"dist/**/*.d.ts"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@svgdotjs/svg.js": "^3.2.0",
|
|
30
|
+
"he": "^1.2.0",
|
|
31
|
+
"satori": "^0.10.3",
|
|
32
|
+
"satori-html": "^0.3.2",
|
|
33
|
+
"svgdom": "^0.1.14"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@resvg/resvg-js": "^2.4.1",
|
|
37
|
+
"@types/he": "^1.2.0",
|
|
38
|
+
"@types/jest-image-snapshot": "^6.2.0",
|
|
39
|
+
"@types/react": "^18.2.21",
|
|
40
|
+
"@types/react-dom": "^18.2.7",
|
|
41
|
+
"@vitest/coverage-v8": "^0.34.3",
|
|
42
|
+
"@vitest/ui": "^0.30.1",
|
|
43
|
+
"jest-image-snapshot": "^6.2.0",
|
|
44
|
+
"npm-run-all": "^4.1.5",
|
|
45
|
+
"react": "^18",
|
|
46
|
+
"react-dom": "^18",
|
|
47
|
+
"tsup": "^7.2.0",
|
|
48
|
+
"typescript": "^5.1.6",
|
|
49
|
+
"vite": "^4.4.9",
|
|
50
|
+
"vitest": "^0.34.3",
|
|
51
|
+
"@altano/assets": "^0.0.0",
|
|
52
|
+
"@altano/tsconfig": "0.0.0"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "tsup",
|
|
56
|
+
"build:types": "tsc --emitDeclarationOnly --declaration",
|
|
57
|
+
"dev": "tsup --watch",
|
|
58
|
+
"lint": "eslint src/**/*.ts*",
|
|
59
|
+
"lint:timing": "TIMING=1 eslint src/**/*.ts*",
|
|
60
|
+
"lint:fix": "TIMING=1 eslint src/**/*.ts* --fix",
|
|
61
|
+
"format": "prettier --write src",
|
|
62
|
+
"clean": "rm -rf .turbo && rm -rf .tsbuildinfo && rm -rf node_modules && rm -rf dist",
|
|
63
|
+
"test": "run-s test:unit test:benchmark",
|
|
64
|
+
"test:unit": "vitest --run",
|
|
65
|
+
"test:benchmark": "vitest --run bench",
|
|
66
|
+
"test:watch": "vitest tests/"
|
|
67
|
+
}
|
|
68
|
+
}
|