@altano/satori-fit-text 0.0.1 → 0.1.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 CHANGED
@@ -1,6 +1,8 @@
1
1
  # satori-fit-text
2
2
 
3
- Uses [Satori](https://github.com/vercel/satori) to calculate the largest text font size that will fit a bounding box.
3
+ Calculate the largest text font size that will fit a bounding box, without a web browser. Works anywhere [Satori](https://github.com/vercel/satori) does, e.g. Node.js, web browser, Vercel edge runtime, etc.
4
+
5
+ ![Demo](./assets/demo.gif)
4
6
 
5
7
  ## Installation
6
8
 
@@ -45,8 +47,10 @@ Because this measures the maximum font size using Satori, the ideal use of this
45
47
 
46
48
  ## Implementation
47
49
 
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/).
50
+ The library tries various font sizes until it finds the ideal one that works. Each iteration is tested by generating an SVG with Satori and calculating the bounding box of the SVG.
49
51
 
50
52
  ## Performance
51
53
 
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.
54
+ 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 improve performance by restricting the search space by reducing the difference between `minFontSize` and `maxFontSize`, which reduces the number of guesses.
55
+
56
+ If you're doing something like producing Open Graph cards then you probably don't need to worry about performance.
Binary file
@@ -0,0 +1,42 @@
1
+ import {
2
+ TextMeasurer,
3
+ getWidthAdjustedForInlineBleed,
4
+ log_default
5
+ } from "./chunk-JIONCYY3.js";
6
+
7
+ // src/TextMeasurer/BrowserTextMeasurer.ts
8
+ var BrowserTextMeasurer = class extends TextMeasurer {
9
+ #getSvgElement(svgText) {
10
+ const div = document.createElement("div");
11
+ div.style.position = "fixed";
12
+ div.innerHTML = svgText;
13
+ const svg = div.firstElementChild;
14
+ if (svg == null || !(svg instanceof SVGSVGElement)) {
15
+ throw new Error(`Child should be svg element`);
16
+ }
17
+ return svg;
18
+ }
19
+ async getDimensions(fontSize) {
20
+ const svgText = await this.generateSvg(fontSize);
21
+ const svg = this.#getSvgElement(svgText);
22
+ try {
23
+ document.body.appendChild(svg);
24
+ const { width, height } = svg.getBBox();
25
+ const widthAdjusted = getWidthAdjustedForInlineBleed(svg, width);
26
+ log_default({
27
+ fontSize,
28
+ width,
29
+ widthAdjusted,
30
+ height,
31
+ widthFits: widthAdjusted <= this.maxWidth,
32
+ heightFits: height <= this.maxHeight
33
+ });
34
+ return { width: widthAdjusted, height };
35
+ } finally {
36
+ svg.parentElement?.removeChild?.(svg);
37
+ }
38
+ }
39
+ };
40
+ export {
41
+ BrowserTextMeasurer as default
42
+ };
@@ -0,0 +1,44 @@
1
+ import {
2
+ TextMeasurer,
3
+ getWidthAdjustedForInlineBleed,
4
+ log_default
5
+ } from "./chunk-JIONCYY3.js";
6
+
7
+ // src/TextMeasurer/HeadlessTextMeasurer.ts
8
+ import { SVG, registerWindow } from "@svgdotjs/svg.js";
9
+ import { createSVGWindow } from "svgdom";
10
+ var HeadlessTextMeasurer = class extends TextMeasurer {
11
+ #canvas;
12
+ constructor(text, font, maxWidth, maxHeight, lineHeight) {
13
+ super(text, font, maxWidth, maxHeight, lineHeight);
14
+ const window = createSVGWindow();
15
+ const document = window.document;
16
+ registerWindow(window, document);
17
+ this.#canvas = SVG(document.documentElement);
18
+ }
19
+ async getDimensions(fontSize) {
20
+ await this.#setFontSize(fontSize);
21
+ const { width, height } = this.#canvas.bbox();
22
+ const widthAdjusted = getWidthAdjustedForInlineBleed(
23
+ this.#canvas.node,
24
+ width
25
+ );
26
+ log_default({
27
+ fontSize,
28
+ width,
29
+ widthAdjusted,
30
+ height,
31
+ widthFits: widthAdjusted <= this.maxWidth,
32
+ heightFits: height <= this.maxHeight
33
+ });
34
+ return { width: widthAdjusted, height };
35
+ }
36
+ async #setFontSize(fontSize) {
37
+ const svgText = await this.generateSvg(fontSize);
38
+ this.#canvas.clear();
39
+ this.#canvas.svg(svgText);
40
+ }
41
+ };
42
+ export {
43
+ HeadlessTextMeasurer as default
44
+ };
@@ -0,0 +1,6 @@
1
+ import { TextMeasurer, type Dimensions } from "./TextMeasurer";
2
+ export default class BrowserTextMeasurer extends TextMeasurer {
3
+ #private;
4
+ getDimensions(fontSize: number): Promise<Dimensions>;
5
+ }
6
+ //# sourceMappingURL=BrowserTextMeasurer.d.ts.map
@@ -0,0 +1,8 @@
1
+ import type { Font } from "../types";
2
+ import { TextMeasurer, type Dimensions } from "./TextMeasurer";
3
+ export default class HeadlessTextMeasurer extends TextMeasurer {
4
+ #private;
5
+ constructor(text: string, font: Font, maxWidth: number, maxHeight: number, lineHeight: number | "normal");
6
+ getDimensions(fontSize: number): Promise<Dimensions>;
7
+ }
8
+ //# sourceMappingURL=HeadlessTextMeasurer.d.ts.map
@@ -0,0 +1,15 @@
1
+ import type { Font } from "../types";
2
+ export type Dimensions = Pick<DOMRect, "width" | "height">;
3
+ export declare abstract class TextMeasurer {
4
+ #private;
5
+ protected text: string;
6
+ protected font: Font;
7
+ protected maxWidth: number;
8
+ protected maxHeight: number;
9
+ protected lineHeight: number | "normal";
10
+ constructor(text: string, font: Font, maxWidth: number, maxHeight: number, lineHeight: number | "normal");
11
+ abstract getDimensions(fontSize: number): Promise<Dimensions>;
12
+ doesSizeFit(fontSize: number): Promise<boolean>;
13
+ protected generateSvg(fontSize: number): Promise<string>;
14
+ }
15
+ //# sourceMappingURL=TextMeasurer.d.ts.map
@@ -0,0 +1,3 @@
1
+ import type { TextMeasurer } from "./TextMeasurer";
2
+ export declare function getTextMeasurer(...args: ConstructorParameters<typeof TextMeasurer>): Promise<TextMeasurer>;
3
+ //# sourceMappingURL=getTextMeasurer.d.ts.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Satori will sometimes render text slightly outside the div on the left (e.g.
3
+ * x:-2). Having text slightly outside the box and drawing a border means we
4
+ * will have a bounding box on the svg that is slightly larger than the actual
5
+ * text size (by the amount of bleed).
6
+ *
7
+ * For example, if text starts at -2 and the div's bounding box is x:0 to
8
+ * x:1200, the svg's bounding box will have width=1202px. And that won't
9
+ * represent the actual width of the text.
10
+ *
11
+ * Let's account for this by just reducing the bounding box width by the amount
12
+ * the text is bleeding outside the left or right and trust that Satori isn't
13
+ * both letting the text bleed AND filling too much space.
14
+ */
15
+ export default function getWidthAdjustedForInlineBleed(svg: SVGSVGElement, svgWidth: number): number;
16
+ //# sourceMappingURL=getWidthAdjustedForInlineBleed.d.ts.map
@@ -0,0 +1,91 @@
1
+ // src/log.ts
2
+ import debug from "debug";
3
+ var log = debug("satori-text-fit");
4
+ var log_default = log;
5
+
6
+ // src/TextMeasurer/TextMeasurer.ts
7
+ import satori from "satori";
8
+ var TextMeasurer = class {
9
+ constructor(text, font, maxWidth, maxHeight, lineHeight) {
10
+ this.text = text;
11
+ this.font = font;
12
+ this.maxWidth = maxWidth;
13
+ this.maxHeight = maxHeight;
14
+ this.lineHeight = lineHeight;
15
+ this.#fonts = [this.font];
16
+ }
17
+ #fonts;
18
+ async doesSizeFit(fontSize) {
19
+ const { width, height } = await this.getDimensions(fontSize);
20
+ return width <= this.maxWidth && height <= this.maxHeight;
21
+ }
22
+ async generateSvg(fontSize) {
23
+ const node = {
24
+ type: "div",
25
+ props: {
26
+ style: {
27
+ all: "initial",
28
+ display: "flex",
29
+ flexDirection: "column",
30
+ width: "100%",
31
+ height: "100%",
32
+ margin: 0,
33
+ padding: 0
34
+ },
35
+ children: [
36
+ {
37
+ type: "div",
38
+ props: {
39
+ style: {
40
+ // Static styles
41
+ margin: 0,
42
+ padding: 0,
43
+ boxSizing: "border-box",
44
+ // We render a border so that we measure the true dimensions of the text.
45
+ // Without one, the bounding box might not include significant whitespace.
46
+ // For example, if the last line of text has no descenders, the bounding box
47
+ // would only extend to the baseline of the last line. With a border, the
48
+ // bounding box will include all the whitespace between the baseline and the
49
+ // descent of the text.
50
+ border: "solid red 1px",
51
+ // Dynamic styles based on input
52
+ fontSize,
53
+ fontFamily: this.font.name,
54
+ maxWidth: this.maxWidth,
55
+ lineHeight: this.lineHeight
56
+ },
57
+ children: this.text
58
+ }
59
+ }
60
+ ]
61
+ }
62
+ };
63
+ return satori(node, {
64
+ fonts: this.#fonts,
65
+ width: this.maxWidth * 2,
66
+ height: this.maxHeight * 2
67
+ });
68
+ }
69
+ };
70
+
71
+ // src/TextMeasurer/getWidthAdjustedForInlineBleed.ts
72
+ function getWidthAdjustedForInlineBleed(svg, svgWidth) {
73
+ let smallestXValue = 0;
74
+ let largestYValue = svgWidth;
75
+ for (const child of svg.children) {
76
+ if ("getBBox" in child && typeof child["getBBox"] === "function") {
77
+ const { x, y } = child.getBBox();
78
+ smallestXValue = Math.min(smallestXValue, x);
79
+ largestYValue = Math.max(largestYValue, y);
80
+ }
81
+ }
82
+ const xAdjustment = Math.ceil(Math.abs(smallestXValue));
83
+ const yAdjustment = largestYValue <= svgWidth ? 0 : Math.ceil(largestYValue) - svgWidth;
84
+ return svgWidth - xAdjustment - yAdjustment;
85
+ }
86
+
87
+ export {
88
+ log_default,
89
+ TextMeasurer,
90
+ getWidthAdjustedForInlineBleed
91
+ };
package/dist/index.js CHANGED
@@ -1,90 +1,13 @@
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));
1
+ // src/TextMeasurer/getTextMeasurer.ts
2
+ async function getTextMeasurer(...args) {
3
+ if (typeof window !== "undefined") {
4
+ const Measurer = await import("./BrowserTextMeasurer-TCSGQHDB.js");
5
+ return new Measurer.default(...args);
15
6
  } else {
16
- walkJsxTree(children, callback);
7
+ const Measurer = await import("./HeadlessTextMeasurer-46CHWL7H.js");
8
+ return new Measurer.default(...args);
17
9
  }
18
10
  }
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
11
 
89
12
  // src/fitText.ts
90
13
  async function findLargestUsableFontSize({
@@ -100,7 +23,7 @@ async function findLargestUsableFontSize({
100
23
  if (maxTries < 2) {
101
24
  throw new Error("`maxTries` must be >= 2");
102
25
  }
103
- const textMeasurer = new TextMeasurer(
26
+ const textMeasurer = await getTextMeasurer(
104
27
  text,
105
28
  font,
106
29
  maxWidth,
package/dist/log.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import debug from "debug";
2
+ declare const log: debug.Debugger;
3
+ export default log;
4
+ //# sourceMappingURL=log.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@altano/satori-fit-text",
3
- "version": "0.0.1",
3
+ "version": "0.1.1",
4
4
  "description": "Fit text to a bounding box in Node or the browser",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -19,14 +19,22 @@
19
19
  },
20
20
  "homepage": "https://github.com/altano/npm-packages/tree/main/packages/satori-fit-text",
21
21
  "files": [
22
+ "assets/",
22
23
  "dist/**/*.js",
23
24
  "dist/**/*.d.ts"
24
25
  ],
25
26
  "publishConfig": {
26
27
  "access": "public"
27
28
  },
29
+ "keywords": [
30
+ "typography",
31
+ "satori",
32
+ "opengraph",
33
+ "fit-text"
34
+ ],
28
35
  "dependencies": {
29
36
  "@svgdotjs/svg.js": "^3.2.0",
37
+ "debug": "^4.3.4",
30
38
  "he": "^1.2.0",
31
39
  "satori": "^0.10.3",
32
40
  "satori-html": "^0.3.2",
@@ -34,10 +42,12 @@
34
42
  },
35
43
  "devDependencies": {
36
44
  "@resvg/resvg-js": "^2.4.1",
45
+ "@types/debug": "^4.1.8",
37
46
  "@types/he": "^1.2.0",
38
47
  "@types/jest-image-snapshot": "^6.2.0",
39
48
  "@types/react": "^18.2.21",
40
49
  "@types/react-dom": "^18.2.7",
50
+ "@types/svgdom": "^0.1.0",
41
51
  "@vitest/coverage-v8": "^0.34.3",
42
52
  "@vitest/ui": "^0.30.1",
43
53
  "jest-image-snapshot": "^6.2.0",
@@ -48,8 +58,9 @@
48
58
  "typescript": "^5.1.6",
49
59
  "vite": "^4.4.9",
50
60
  "vitest": "^0.34.3",
51
- "@altano/assets": "^0.0.0",
52
- "@altano/tsconfig": "0.0.0"
61
+ "@altano/assets": "0.0.0",
62
+ "@altano/tsconfig": "0.0.0",
63
+ "@altano/tiny-async-pool": "1.0.2"
53
64
  },
54
65
  "scripts": {
55
66
  "build": "tsup",
@@ -1,12 +0,0 @@
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
@@ -1,4 +0,0 @@
1
- import { html as htmlToVNode } from "satori-html";
2
- export type VNode = ReturnType<typeof htmlToVNode>;
3
- export default function getSatoriFriendlyVNode(html: string): VNode;
4
- //# sourceMappingURL=getSatoriFriendlyVNode.d.ts.map