@roku-sdk/text-layout 0.0.0-stage → 2.19.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/LICENSE +21 -0
- package/README.md +30 -2
- package/package.json +41 -3
- package/src/__tests__/api-surface.test.ts +29 -0
- package/src/__tests__/label-layout.test.ts +306 -0
- package/src/__tests__/utf8-isolation.test.ts +23 -0
- package/src/break-search.ts +56 -0
- package/src/bytes.ts +98 -0
- package/src/ellipsize.ts +67 -0
- package/src/index.ts +23 -0
- package/src/layout.ts +203 -0
- package/src/types.ts +26 -0
- package/src/utf8.ts +11 -0
- package/src/wrap-text.ts +120 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Roku, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @roku-sdk/text-layout
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A pure TypeScript port of the Roku firmware label layout: line breaking, word wrapping and ellipsis placement. It has no dependencies and does no font work itself. You supply a width function, and the package decides where lines break and where text is truncated.
|
|
4
|
+
|
|
5
|
+
## Width injection
|
|
6
|
+
|
|
7
|
+
Layout never measures glyphs. `LabelMetrics.width` returns the rendered width of a string in device pixels, and `lineHeight` and `defaultLineSpacing` come from the font and theme. The lower-level functions take a `WidthFn` over UTF-8 bytes. Any measurer works, a font engine or a lookup table.
|
|
8
|
+
|
|
9
|
+
## Example
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { layoutLabel, scalesFor } from "@roku-sdk/text-layout";
|
|
13
|
+
|
|
14
|
+
// Synthetic measurer: every UTF-8 byte is 10 px wide.
|
|
15
|
+
const metrics = {
|
|
16
|
+
width: (text: string) => new TextEncoder().encode(text).length * 10,
|
|
17
|
+
lineHeight: 30,
|
|
18
|
+
defaultLineSpacing: 12
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const layout = layoutLabel(
|
|
22
|
+
{ text: "The quick brown fox jumps over the lazy dog", width: 200, wrap: true, maxLines: 2 },
|
|
23
|
+
metrics,
|
|
24
|
+
scalesFor("fhd", "fhd")
|
|
25
|
+
);
|
|
26
|
+
console.log(layout.lines, layout.isTextEllipsized);
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## UTF-8
|
|
30
|
+
|
|
31
|
+
UTF-8 encoding and decoding live in `src/utf8.ts`, the only file that uses `TextEncoder` and `TextDecoder`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roku-sdk/text-layout",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "2.19.1",
|
|
4
|
+
"description": "Pure TypeScript port of the Roku firmware label line-breaking, wrapping and ellipsis rules, driven by an injected width function",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"npm": ">=8.5.0",
|
|
16
|
+
"node": ">=24.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@jest/globals": "^30.4.1",
|
|
20
|
+
"jest": "^30.4.1",
|
|
21
|
+
"typescript": "^5.9.3",
|
|
22
|
+
"@roku-sdk/dev-tools": "2.19.1"
|
|
23
|
+
},
|
|
24
|
+
"author": "Roku Inc",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://gitlab.com/rokudev/rsg-sdk.git",
|
|
29
|
+
"directory": "packages/text-layout"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://gitlab.com/rokudev/rsg-sdk/-/tree/main/packages/text-layout#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://gitlab.com/rokudev/rsg-sdk/-/issues"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "jest --passWithNoTests",
|
|
40
|
+
"type-check": "tsc --noEmit",
|
|
41
|
+
"clean": "rm -rf dist",
|
|
42
|
+
"lint": "eslint --no-error-on-unmatched-pattern \"src/**/*.{ts,tsx,mjs,cjs,js}\""
|
|
43
|
+
}
|
|
6
44
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { expect, test } from "@jest/globals";
|
|
5
|
+
|
|
6
|
+
import * as api from "../index";
|
|
7
|
+
|
|
8
|
+
const NAMES = [
|
|
9
|
+
"decodeUtf8",
|
|
10
|
+
"defaultLineSpacing",
|
|
11
|
+
"drawnLines",
|
|
12
|
+
"ellipsizeText",
|
|
13
|
+
"encodeUtf8",
|
|
14
|
+
"findCharBoundary",
|
|
15
|
+
"findDelimiterBreak",
|
|
16
|
+
"findWordBreak",
|
|
17
|
+
"isWordBreak",
|
|
18
|
+
"layoutLabel",
|
|
19
|
+
"layoutLabelInFont",
|
|
20
|
+
"lineHeightFromExtents",
|
|
21
|
+
"normalizeWrapText",
|
|
22
|
+
"scalesFor",
|
|
23
|
+
"wrapText"
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
test("root export is exactly the public layout surface", () => {
|
|
27
|
+
expect(Object.keys(api).sort()).toEqual(NAMES);
|
|
28
|
+
for (const name of NAMES) expect(typeof (api as Record<string, unknown>)[name]).toBe("function");
|
|
29
|
+
});
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//
|
|
4
|
+
// Pure unit tests for the label layout port. Widths come from a SYNTHETIC measurer (10 px per UTF-8 byte), so
|
|
5
|
+
// the tests pin the firmware's line-breaking and ellipsis rules without fonts.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "@jest/globals";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
defaultLineSpacing,
|
|
11
|
+
drawnLines,
|
|
12
|
+
encodeUtf8,
|
|
13
|
+
findDelimiterBreak,
|
|
14
|
+
type LabelProps,
|
|
15
|
+
layoutLabel,
|
|
16
|
+
layoutLabelInFont,
|
|
17
|
+
lineHeightFromExtents,
|
|
18
|
+
scalesFor
|
|
19
|
+
} from "../index";
|
|
20
|
+
|
|
21
|
+
const metrics = {
|
|
22
|
+
width: (text: string): number => new TextEncoder().encode(text).length * 10,
|
|
23
|
+
lineHeight: 26,
|
|
24
|
+
defaultLineSpacing: 8
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function layout(props: LabelProps, scales = scalesFor("hd", "hd")) {
|
|
28
|
+
return layoutLabel(props, metrics, scales);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("line breaking", () => {
|
|
32
|
+
test("a candidate fits when its whole substring is <= the width", () => {
|
|
33
|
+
expect(layout({ text: "aaa bbb ccc", width: 70 }).lines).toEqual(["aaa bbb", "ccc"]);
|
|
34
|
+
expect(layout({ text: "aaa bbb ccc", width: 69 }).lines).toEqual(["aaa", "bbb", "ccc"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("every space is a break candidate; spaces before the break stay on the line and are measured", () => {
|
|
38
|
+
expect(layout({ text: "aa bb", width: 40 }).lines).toEqual(["aa ", "bb"]);
|
|
39
|
+
expect(layout({ text: "aa bb", width: 39 }).lines).toEqual(["aa ", "bb"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a hyphen stays on the first line", () => {
|
|
43
|
+
expect(layout({ text: "well-known fox", width: 60 }).lines).toEqual(["well-", "known", "fox"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("trailing whitespace is dropped, CR LF becomes LF, a tab becomes four spaces", () => {
|
|
47
|
+
expect(layout({ text: "a\r\nb\tc ", width: 400 }).lines).toEqual(["a", "b c"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("leading spaces after a newline are kept; blank lines count", () => {
|
|
51
|
+
expect(layout({ text: "a\n b", width: 400 }).lines).toEqual(["a", " b"]);
|
|
52
|
+
expect(layout({ text: "a\n\nb", width: 400 }).numLines).toBe(3);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a word wider than the box is split by characters", () => {
|
|
56
|
+
expect(layout({ text: "abcdefgh", width: 30 }).lines).toEqual(["abc", "def", "gh"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("the character search assumes the whole remainder minus one byte does not fit", () => {
|
|
60
|
+
// One glyph per line: the last line is lost to the empty "cannot display one character" line.
|
|
61
|
+
expect(layout({ text: "abc", width: 10 }).lines).toEqual(["a", ""]);
|
|
62
|
+
expect(layout({ text: "abcd", width: 19 }).lines).toEqual(["a", "b", ""]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a box narrower than one glyph yields a single empty line", () => {
|
|
66
|
+
expect(layout({ text: "abc", width: 5 }).lines).toEqual([""]);
|
|
67
|
+
expect(layout({ text: "ab", width: 5 }).lines).toEqual([""]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("the character search works on UTF-8 bytes and never splits a character", () => {
|
|
71
|
+
expect(layout({ text: "ééé", width: 25 }).lines).toEqual(["é", "é", "é"]);
|
|
72
|
+
// The last character is two bytes, so the "all but the last byte" bound does not cost a line.
|
|
73
|
+
expect(layout({ text: "éé", width: 20 }).lines).toEqual(["é", "é"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("wordBreakChars add break characters that stay on the first line", () => {
|
|
77
|
+
expect(layout({ text: "aa/bb/cc", width: 50, wordBreakChars: "/" }).lines).toEqual(["aa/", "bb/cc"]);
|
|
78
|
+
expect(layout({ text: "aa/bb/cc", width: 50 }).lines).toEqual(["aa/bb", "/cc"]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("ellipsis", () => {
|
|
83
|
+
test("a single line is clipped so that width(clip) + width(ellipsis) <= width", () => {
|
|
84
|
+
const r = layout({ text: "abcdefghij", width: 65, wrap: false });
|
|
85
|
+
expect(r.lines).toEqual(["abc..."]);
|
|
86
|
+
expect(r.isTextEllipsized).toBe(true);
|
|
87
|
+
expect(r.numLines).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("an ellipsis wider than the box returns an empty string and is not flagged", () => {
|
|
91
|
+
const r = layout({ text: "abcdefghij", width: 20, wrap: false });
|
|
92
|
+
expect(r.lines).toEqual([""]);
|
|
93
|
+
expect(r.isTextEllipsized).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("text that fits is untouched; a single line stops at the first newline", () => {
|
|
97
|
+
expect(layout({ text: "abc", width: 30, wrap: false }).isTextEllipsized).toBe(false);
|
|
98
|
+
expect(layout({ text: "abc\ndef", width: 30, wrap: false })).toMatchObject({
|
|
99
|
+
lines: ["abc"],
|
|
100
|
+
isTextEllipsized: false
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("maxLines ellipsizes the last allowed line from the remaining text", () => {
|
|
105
|
+
expect(layout({ text: "aaa bbb ccc ddd", width: 40, maxLines: 2 })).toMatchObject({
|
|
106
|
+
numLines: 2,
|
|
107
|
+
isTextEllipsized: true,
|
|
108
|
+
lines: ["aaa", "b..."]
|
|
109
|
+
});
|
|
110
|
+
expect(layout({ text: "aaa", width: 40, maxLines: 1 })).toMatchObject({ numLines: 1, isTextEllipsized: false });
|
|
111
|
+
expect(layout({ text: "aaa bbb", width: 40, maxLines: 5 })).toMatchObject({
|
|
112
|
+
numLines: 2,
|
|
113
|
+
isTextEllipsized: false
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("options: custom ellipsis, ellipsizeOnBoundary, leadingEllipsis, truncateOnDelimiter", () => {
|
|
118
|
+
expect(layout({ text: "abcdefghij", width: 60, wrap: false, ellipsisText: "~" }).lines).toEqual(["abcde~"]);
|
|
119
|
+
expect(layout({ text: "abc defg hij", width: 100, wrap: false, ellipsizeOnBoundary: true }).lines).toEqual([
|
|
120
|
+
"abc ..."
|
|
121
|
+
]);
|
|
122
|
+
expect(layout({ text: "abcdefghij", width: 65, wrap: false, leadingEllipsis: true }).lines).toEqual(["...hij"]);
|
|
123
|
+
expect(layout({ text: "ab cd ef gh", width: 60, wrap: false, truncateOnDelimiter: " " }).lines).toEqual([
|
|
124
|
+
"ab cd"
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("degenerate input", () => {
|
|
130
|
+
test("empty, blank and whitespace-only text is one empty-looking line", () => {
|
|
131
|
+
expect(layout({ text: "", width: 100 })).toMatchObject({ numLines: 1, lines: [""], isTextEllipsized: false });
|
|
132
|
+
expect(layout({ text: " ", width: 100 }).numLines).toBe(1);
|
|
133
|
+
expect(layout({ text: "\n", width: 100 }).numLines).toBe(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("a lone carriage return is a line break", () => {
|
|
137
|
+
expect(layout({ text: "a\rb", width: 100 }).lines).toEqual(["a", "b"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("width 0 with wrap draws nothing: wrapText returns without producing a line", () => {
|
|
141
|
+
expect(layout({ text: "abc def\nghi", width: 0 })).toMatchObject({
|
|
142
|
+
numLines: null,
|
|
143
|
+
lines: [],
|
|
144
|
+
isTextEllipsized: false
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("width 0 without wrap is an auto-width single line: the first line whole, never ellipsized", () => {
|
|
149
|
+
expect(layout({ text: "abc def\nghi", width: 0, wrap: false })).toMatchObject({
|
|
150
|
+
numLines: null,
|
|
151
|
+
lines: ["abc def"],
|
|
152
|
+
isTextEllipsized: false
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a box narrower than one pixel still reports one line", () => {
|
|
157
|
+
expect(layout({ text: "abc def", width: 0.5 }).numLines).toBe(1);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("a long text finishes quickly", () => {
|
|
161
|
+
const words = Array.from({ length: 3000 }, (_, i) => `word${i}`).join(" ");
|
|
162
|
+
const started = Date.now();
|
|
163
|
+
expect(layout({ text: words, width: 300 }).numLines).toBeGreaterThan(900);
|
|
164
|
+
expect(Date.now() - started).toBeLessThan(3000);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("height", () => {
|
|
169
|
+
test("an explicit height allows floor((height + spacing) / (lineHeight + spacing)) lines, the last ellipsized", () => {
|
|
170
|
+
expect(layout({ text: "aaa bbb ccc ddd eee", width: 40, height: 70 })).toMatchObject({
|
|
171
|
+
numLines: 2,
|
|
172
|
+
isTextEllipsized: true,
|
|
173
|
+
lines: ["aaa", "b..."]
|
|
174
|
+
});
|
|
175
|
+
expect(layout({ text: "aaa bbb ccc ddd", width: 40, height: 52, lineSpacing: 0 }).numLines).toBe(2);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("a height smaller than one line still reports one ellipsized line", () => {
|
|
179
|
+
expect(layout({ text: "aaa bbb ccc", width: 40, height: 25 })).toMatchObject({
|
|
180
|
+
numLines: 1,
|
|
181
|
+
isTextEllipsized: true,
|
|
182
|
+
lines: ["a..."]
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("numLines sets the height; auto height follows the wrapped lines", () => {
|
|
187
|
+
expect(layout({ text: "aaa bbb ccc ddd", width: 40, numLines: 2 })).toMatchObject({
|
|
188
|
+
numLines: 2,
|
|
189
|
+
isTextEllipsized: true,
|
|
190
|
+
height: 60
|
|
191
|
+
});
|
|
192
|
+
expect(layout({ text: "aaa bbb ccc", width: 40 }).height).toBe(94);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("maxLines clamps the height to a whole number, as the firmware's int maxHeight does", () => {
|
|
196
|
+
// 2 * 26 + 1 * 6.5 = 58.5 is truncated to 58, so even the first line no longer fits above 58.5.
|
|
197
|
+
expect(layout({ text: "aaa bbb ccc ddd", width: 40, maxLines: 2, lineSpacing: 6.5 })).toMatchObject({
|
|
198
|
+
numLines: 1,
|
|
199
|
+
isTextEllipsized: true,
|
|
200
|
+
lines: ["a..."],
|
|
201
|
+
height: 58
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("displayPartialLines wraps the whole text and never ellipsizes", () => {
|
|
206
|
+
expect(layout({ text: "aaa bbb ccc ddd", width: 40, displayPartialLines: true })).toMatchObject({
|
|
207
|
+
numLines: 4,
|
|
208
|
+
isTextEllipsized: false
|
|
209
|
+
});
|
|
210
|
+
expect(layout({ text: "aaa bbb ccc ddd", width: 40, displayPartialLines: true, height: 60 })).toMatchObject({
|
|
211
|
+
numLines: 2,
|
|
212
|
+
isTextEllipsized: false
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("drawn lines", () => {
|
|
218
|
+
test("every computed line is drawn when the label is as tall as its lines", () => {
|
|
219
|
+
const props = { text: "aaa bbb ccc", width: 40 };
|
|
220
|
+
expect(drawnLines(layout(props), props)).toEqual(["aaa", "bbb", "ccc"]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("a line that does not fit the height is not drawn; a height below one line draws nothing", () => {
|
|
224
|
+
const props = { text: "aaa bbb ccc ddd eee", width: 40, height: 70 };
|
|
225
|
+
expect(drawnLines(layout(props), props)).toEqual(["aaa", "b..."]);
|
|
226
|
+
const tiny = { text: "aaa bbb ccc", width: 40, height: 25 };
|
|
227
|
+
expect(drawnLines(layout(tiny), tiny)).toEqual([]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("displayPartialLines draws nothing without an explicit height", () => {
|
|
231
|
+
const noHeight = { text: "aaa bbb ccc ddd", width: 40, displayPartialLines: true };
|
|
232
|
+
expect(drawnLines(layout(noHeight), noHeight)).toEqual([]);
|
|
233
|
+
const withHeight = { ...noHeight, height: 60 };
|
|
234
|
+
expect(drawnLines(layout(withHeight), withHeight)).toEqual(["aaa", "bbb"]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("a single line is always drawn", () => {
|
|
238
|
+
const props = { text: "abcdefghij", width: 65, wrap: false };
|
|
239
|
+
expect(drawnLines(layout(props), props)).toEqual(["abc..."]);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("resolutions", () => {
|
|
244
|
+
test("the scale factors are float32 and swap with the app/device pair", () => {
|
|
245
|
+
expect(scalesFor("hd", "hd")).toEqual({ autoScale: 1, autoScaleInv: 1 });
|
|
246
|
+
expect(scalesFor("hd", "fhd")).toEqual({ autoScale: 1.5, autoScaleInv: Math.fround(2 / 3) });
|
|
247
|
+
expect(scalesFor("fhd", "hd")).toEqual({ autoScale: Math.fround(2 / 3), autoScaleInv: 1.5 });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("wrapping compares device pixels with the box width scaled to device pixels", () => {
|
|
251
|
+
// hd app on a 1080p device: width 47 is 70.5 device px, so a 70 px line fits.
|
|
252
|
+
expect(layout({ text: "aaa bbb ccc", width: 47 }, scalesFor("hd", "fhd")).lines).toEqual(["aaa bbb", "ccc"]);
|
|
253
|
+
expect(layout({ text: "aaa bbb ccc", width: 46 }, scalesFor("hd", "fhd")).lines).toEqual(["aaa", "bbb", "ccc"]);
|
|
254
|
+
// fhd app on a 720p device: width 105 is 70 device px.
|
|
255
|
+
expect(layout({ text: "aaa bbb ccc", width: 105 }, scalesFor("fhd", "hd")).lines).toEqual(["aaa bbb", "ccc"]);
|
|
256
|
+
expect(layout({ text: "aaa bbb ccc", width: 104 }, scalesFor("fhd", "hd")).lines).toEqual([
|
|
257
|
+
"aaa",
|
|
258
|
+
"bbb",
|
|
259
|
+
"ccc"
|
|
260
|
+
]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("default line spacing follows the app resolution; line height comes from the font extents", () => {
|
|
264
|
+
expect(defaultLineSpacing("hd")).toBe(8);
|
|
265
|
+
expect(defaultLineSpacing("fhd")).toBe(12);
|
|
266
|
+
expect(lineHeightFromExtents(19.203, 4.797)).toBe(26);
|
|
267
|
+
expect(lineHeightFromExtents(24, 6)).toBe(31);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("byte semantics", () => {
|
|
272
|
+
test("findDelimiterBreak searches bytes, not characters", () => {
|
|
273
|
+
const text = encodeUtf8("★☆ Yo chann");
|
|
274
|
+
const cfg = {
|
|
275
|
+
wordBreakChars: new Uint8Array(),
|
|
276
|
+
ellipsisText: encodeUtf8("..."),
|
|
277
|
+
ellipsizeOnBoundary: false,
|
|
278
|
+
truncateOnDelimiter: encodeUtf8(" "),
|
|
279
|
+
leadingEllipsis: false
|
|
280
|
+
};
|
|
281
|
+
// "★☆" is six bytes, so the first space is at byte 6, not character 2.
|
|
282
|
+
expect(findDelimiterBreak(text, 0, 3, text.length - 1, cfg)).toBe(6);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("a probe cut inside a character measures without the partial character", () => {
|
|
286
|
+
// The first break sets the line-length hint to 4, so the second paragraph is probed at byte 24, which is
|
|
287
|
+
// the lead byte of "é". The device fits it; a stray replacement character would make the probe too wide.
|
|
288
|
+
const metrics = {
|
|
289
|
+
width: (text: string) => [...text].reduce((sum, ch) => sum + (ch === "\uFFFD" ? 200 : 10), 0),
|
|
290
|
+
lineHeight: 20,
|
|
291
|
+
defaultLineSpacing: 0
|
|
292
|
+
};
|
|
293
|
+
const second = `${"b".repeat(23)}é${"c".repeat(10)}`;
|
|
294
|
+
const result = layoutLabel({ text: `aaaa ${second}\nz`, width: 360 }, metrics, scalesFor("fhd", "fhd"));
|
|
295
|
+
expect(result.lines).toEqual(["aaaa", second, "z"]);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("layoutLabelInFont", () => {
|
|
300
|
+
test("derives line height from extents and shapes at the given resolutions", () => {
|
|
301
|
+
const font = { width: (t: string) => t.length * 10, ascender: 10, descender: 3 };
|
|
302
|
+
const result = layoutLabelInFont({ text: "abcd efgh ijkl", width: 90 }, font, "fhd", "fhd");
|
|
303
|
+
expect(result.lines).toEqual(["abcd efgh", "ijkl"]);
|
|
304
|
+
expect(result.lineHeight).toBe(14);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
import { expect, test } from "@jest/globals";
|
|
8
|
+
|
|
9
|
+
const srcDir = join(__dirname, "..");
|
|
10
|
+
|
|
11
|
+
function sourceFiles(dir: string): string[] {
|
|
12
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
|
13
|
+
if (entry.isDirectory()) return entry.name === "__tests__" ? [] : sourceFiles(join(dir, entry.name));
|
|
14
|
+
return entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts") ? [join(dir, entry.name)] : [];
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("only utf8.ts touches TextEncoder or TextDecoder", () => {
|
|
19
|
+
const users = sourceFiles(srcDir)
|
|
20
|
+
.filter(file => /TextEncoder|TextDecoder/.test(readFileSync(file, "utf8")))
|
|
21
|
+
.map(file => file.slice(srcDir.length + 1));
|
|
22
|
+
expect(users).toEqual(["utf8.ts"]);
|
|
23
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { at, findBytes, isSpace, reverseFindBytes } from "./bytes";
|
|
5
|
+
import type { BreakConfig } from "./types";
|
|
6
|
+
|
|
7
|
+
const HYPHEN = 45;
|
|
8
|
+
|
|
9
|
+
/** `LabelBase::isWordBreak`: whitespace, `-`, or any byte of `wordBreakChars`. */
|
|
10
|
+
export const isWordBreak = (c: number, cfg: BreakConfig): boolean =>
|
|
11
|
+
isSpace(c) || c === HYPHEN || cfg.wordBreakChars.includes(c);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `LabelBase::findWordBreak`. Returns a break position between `min` and `max` closest to `pos`
|
|
15
|
+
* (forward first, then backward), or -1. A non-space break character stays on the first line, so the
|
|
16
|
+
* position is one past it. May return `max + 1`: the firmware does not guard that case.
|
|
17
|
+
*/
|
|
18
|
+
export function findWordBreak(text: Uint8Array, min: number, pos: number, max: number, cfg: BreakConfig): number {
|
|
19
|
+
const startPos = pos;
|
|
20
|
+
|
|
21
|
+
while (!isWordBreak(at(text, pos), cfg) && pos < max) pos++;
|
|
22
|
+
if (isWordBreak(at(text, pos), cfg) && !isSpace(at(text, pos))) pos++;
|
|
23
|
+
|
|
24
|
+
if (pos === max) {
|
|
25
|
+
pos = startPos - 1;
|
|
26
|
+
if (pos < min) pos = min;
|
|
27
|
+
while (pos > min && !isWordBreak(at(text, pos), cfg)) pos--;
|
|
28
|
+
if (isWordBreak(at(text, pos), cfg) && !isSpace(at(text, pos))) pos++;
|
|
29
|
+
if (pos === min) return -1;
|
|
30
|
+
}
|
|
31
|
+
return pos;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `LabelBase::findCharBoundary`: a UTF-8 character boundary near `mid`, or -1. */
|
|
35
|
+
export function findCharBoundary(text: Uint8Array, min: number, mid: number, max: number): number {
|
|
36
|
+
while ((at(text, mid) & 0xc0) === 0x80 && mid !== min) mid--;
|
|
37
|
+
|
|
38
|
+
if (min === mid) {
|
|
39
|
+
mid = 1 + ((max + min) >> 1);
|
|
40
|
+
while (mid !== max && (at(text, mid) & 0xc0) === 0x80) mid++;
|
|
41
|
+
if (mid === max) return -1;
|
|
42
|
+
}
|
|
43
|
+
return mid;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `LabelBase::findDelimiterBreak`: byte search for the `truncateOnDelimiter` string, or -1. */
|
|
47
|
+
export function findDelimiterBreak(text: Uint8Array, min: number, pos: number, max: number, cfg: BreakConfig): number {
|
|
48
|
+
const startPos = pos;
|
|
49
|
+
let found = findBytes(text, cfg.truncateOnDelimiter, pos);
|
|
50
|
+
|
|
51
|
+
if (found < 0 || found >= max) {
|
|
52
|
+
found = reverseFindBytes(text, cfg.truncateOnDelimiter, startPos - 1);
|
|
53
|
+
if (found < 0 || found <= min) return -1;
|
|
54
|
+
}
|
|
55
|
+
return found;
|
|
56
|
+
}
|
package/src/bytes.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
// UTF-8 byte helpers mirroring the C locale `isspace` and the `std::string` operations the firmware label code uses.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Drops a UTF-8 sequence that the end of `bytes` cuts short. The device measures a string cut inside a character
|
|
8
|
+
* without that character; decoding it as-is would add a replacement glyph, which is far wider.
|
|
9
|
+
*/
|
|
10
|
+
export function withoutPartialTail(bytes: Uint8Array): Uint8Array {
|
|
11
|
+
let start = bytes.length;
|
|
12
|
+
// Walk back over at most three continuation bytes to the lead byte of the final character.
|
|
13
|
+
while (start > 0 && bytes.length - start < 3 && (bytes[start - 1] & 0xc0) === 0x80) start--;
|
|
14
|
+
if (start === 0) return bytes;
|
|
15
|
+
const lead = bytes[start - 1];
|
|
16
|
+
const expected = lead >= 0xf0 ? 4 : lead >= 0xe0 ? 3 : lead >= 0xc0 ? 2 : 1;
|
|
17
|
+
return bytes.length - (start - 1) < expected ? bytes.slice(0, start - 1) : bytes;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const NEWLINE = 10;
|
|
21
|
+
const CARRIAGE_RETURN = 13;
|
|
22
|
+
const SPACE = 32;
|
|
23
|
+
const TAB = 9;
|
|
24
|
+
|
|
25
|
+
/** C locale isspace: space, \t, \n, \v, \f, \r. */
|
|
26
|
+
export const isSpace = (c: number): boolean => c === SPACE || (c >= TAB && c <= CARRIAGE_RETURN);
|
|
27
|
+
|
|
28
|
+
/** Space that is not a line terminator. */
|
|
29
|
+
export const isSpaceNotNewline = (c: number): boolean => isSpace(c) && c !== CARRIAGE_RETURN && c !== NEWLINE;
|
|
30
|
+
|
|
31
|
+
/** `std::string::at`: throws where the firmware throws `std::out_of_range`. */
|
|
32
|
+
export function at(bytes: Uint8Array, index: number): number {
|
|
33
|
+
if (index < 0 || index >= bytes.length) throw new RangeError(`out_of_range: ${index} of ${bytes.length}`);
|
|
34
|
+
return bytes[index];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
38
|
+
const out = new Uint8Array(a.length + b.length);
|
|
39
|
+
out.set(a);
|
|
40
|
+
out.set(b, a.length);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function trimTrailingWhitespace(bytes: Uint8Array): Uint8Array {
|
|
45
|
+
let end = bytes.length;
|
|
46
|
+
while (end > 0 && isSpace(bytes[end - 1])) end--;
|
|
47
|
+
return bytes.slice(0, end);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function trimLeadingWhitespace(bytes: Uint8Array): Uint8Array {
|
|
51
|
+
let start = 0;
|
|
52
|
+
while (start < bytes.length && isSpace(bytes[start])) start++;
|
|
53
|
+
return bytes.slice(start);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `find_first_of("\r\n")`, or -1. */
|
|
57
|
+
export function firstLineBreak(bytes: Uint8Array): number {
|
|
58
|
+
for (let i = 0; i < bytes.length; i++) if (bytes[i] === CARRIAGE_RETURN || bytes[i] === NEWLINE) return i;
|
|
59
|
+
return -1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `std::string::find(needle, from)`, or -1. */
|
|
63
|
+
export function findBytes(haystack: Uint8Array, needle: Uint8Array, from: number): number {
|
|
64
|
+
for (let i = Math.max(0, from); i + needle.length <= haystack.length; i++) {
|
|
65
|
+
if (matchesAt(haystack, needle, i)) return i;
|
|
66
|
+
}
|
|
67
|
+
return -1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `std::string::rfind(needle, from)`; a negative `from` behaves as `npos` (search from the end), or -1. */
|
|
71
|
+
export function reverseFindBytes(haystack: Uint8Array, needle: Uint8Array, from: number): number {
|
|
72
|
+
const last = haystack.length - needle.length;
|
|
73
|
+
for (let i = from < 0 ? last : Math.min(from, last); i >= 0; i--) {
|
|
74
|
+
if (matchesAt(haystack, needle, i)) return i;
|
|
75
|
+
}
|
|
76
|
+
return -1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function matchesAt(haystack: Uint8Array, needle: Uint8Array, at: number): boolean {
|
|
80
|
+
for (let j = 0; j < needle.length; j++) if (haystack[at + j] !== needle[j]) return false;
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The firmware's wrap preprocessing: trailing whitespace of the whole text is dropped,
|
|
86
|
+
* CR LF becomes LF, and every tab becomes four spaces. Inner spaces are not collapsed.
|
|
87
|
+
*/
|
|
88
|
+
export function normalizeWrapText(text: Uint8Array): Uint8Array {
|
|
89
|
+
const trimmed = trimTrailingWhitespace(text);
|
|
90
|
+
const out: number[] = [];
|
|
91
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
92
|
+
const c = trimmed[i];
|
|
93
|
+
if (c === CARRIAGE_RETURN && trimmed[i + 1] === NEWLINE) continue;
|
|
94
|
+
if (c === TAB) out.push(SPACE, SPACE, SPACE, SPACE);
|
|
95
|
+
else out.push(c);
|
|
96
|
+
}
|
|
97
|
+
return Uint8Array.from(out);
|
|
98
|
+
}
|
package/src/ellipsize.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { findCharBoundary, findDelimiterBreak, findWordBreak } from "./break-search";
|
|
5
|
+
import { concat, firstLineBreak, trimLeadingWhitespace, trimTrailingWhitespace } from "./bytes";
|
|
6
|
+
import type { BreakConfig, LayoutEnv } from "./types";
|
|
7
|
+
|
|
8
|
+
const fround = Math.fround;
|
|
9
|
+
|
|
10
|
+
export interface EllipsizeResult {
|
|
11
|
+
text: Uint8Array;
|
|
12
|
+
ellipsized: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `LabelBase::ellipsizeText`. `width` is the box width in **app** pixels (a float32 in the firmware);
|
|
17
|
+
* font widths are device pixels scaled back to app pixels, so the comparison is additive
|
|
18
|
+
* (`width(clipped) + width(ellipsis) <= width`), with no kerning between the clip and the dots.
|
|
19
|
+
*/
|
|
20
|
+
export function ellipsizeText(input: Uint8Array, width: number, cfg: BreakConfig, env: LayoutEnv): EllipsizeResult {
|
|
21
|
+
let text: Uint8Array = input.slice();
|
|
22
|
+
const lineBreak = firstLineBreak(text);
|
|
23
|
+
if (lineBreak >= 0) text = text.slice(0, lineBreak);
|
|
24
|
+
if (!cfg.leadingEllipsis) text = trimTrailingWhitespace(text);
|
|
25
|
+
|
|
26
|
+
const appWidth = (bytes: Uint8Array): number => fround(env.width(bytes) * env.autoScaleInv);
|
|
27
|
+
|
|
28
|
+
if (appWidth(text) <= width) {
|
|
29
|
+
if (lineBreak < 0) return { text, ellipsized: false };
|
|
30
|
+
const withEllipsis = concat(text, cfg.ellipsisText);
|
|
31
|
+
if (appWidth(withEllipsis) <= width) return { text: withEllipsis, ellipsized: true };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const truncate = cfg.truncateOnDelimiter.length > 0;
|
|
35
|
+
const ellipsisWidth = truncate ? 0 : appWidth(cfg.ellipsisText);
|
|
36
|
+
if (ellipsisWidth > width) return { text: new Uint8Array(), ellipsized: false };
|
|
37
|
+
|
|
38
|
+
let max = text.length - 1;
|
|
39
|
+
let min = 0;
|
|
40
|
+
while (min + 1 < max) {
|
|
41
|
+
let mid = (max + min) >> 1;
|
|
42
|
+
if (truncate) {
|
|
43
|
+
mid = findDelimiterBreak(text, min, mid, max, cfg);
|
|
44
|
+
if (mid < 0) break;
|
|
45
|
+
} else if (cfg.ellipsizeOnBoundary) {
|
|
46
|
+
mid = findWordBreak(text, min, mid, max, cfg);
|
|
47
|
+
if (mid < 0) break;
|
|
48
|
+
} else {
|
|
49
|
+
mid = findCharBoundary(text, min, mid, max);
|
|
50
|
+
if (mid < 0) break;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const clipped = cfg.leadingEllipsis ? text.slice(mid) : text.slice(0, mid);
|
|
54
|
+
const fits = fround(appWidth(clipped) + ellipsisWidth) <= width;
|
|
55
|
+
if (fits) {
|
|
56
|
+
if (cfg.leadingEllipsis) max = mid;
|
|
57
|
+
else min = mid;
|
|
58
|
+
} else if (cfg.leadingEllipsis) min = mid;
|
|
59
|
+
else max = mid;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (truncate) return { text: text.slice(0, min), ellipsized: true };
|
|
63
|
+
if (cfg.leadingEllipsis) {
|
|
64
|
+
return { text: concat(cfg.ellipsisText, trimLeadingWhitespace(text.slice(max))), ellipsized: true };
|
|
65
|
+
}
|
|
66
|
+
return { text: concat(trimTrailingWhitespace(text.slice(0, min)), cfg.ellipsisText), ellipsized: true };
|
|
67
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
export { findCharBoundary, findDelimiterBreak, findWordBreak, isWordBreak } from "./break-search";
|
|
5
|
+
export { normalizeWrapText } from "./bytes";
|
|
6
|
+
export { type EllipsizeResult, ellipsizeText } from "./ellipsize";
|
|
7
|
+
export type { BreakConfig, LayoutEnv, WidthFn } from "./types";
|
|
8
|
+
export { type WrapParams, type WrapResult, wrapText } from "./wrap-text";
|
|
9
|
+
export {
|
|
10
|
+
defaultLineSpacing,
|
|
11
|
+
drawnLines,
|
|
12
|
+
type LabelFont,
|
|
13
|
+
type LabelLayout,
|
|
14
|
+
type LabelMetrics,
|
|
15
|
+
type LabelProps,
|
|
16
|
+
layoutLabel,
|
|
17
|
+
layoutLabelInFont,
|
|
18
|
+
lineHeightFromExtents,
|
|
19
|
+
type ResolutionScales,
|
|
20
|
+
scalesFor,
|
|
21
|
+
type UiResolution
|
|
22
|
+
} from "./layout";
|
|
23
|
+
export { decodeUtf8, encodeUtf8 } from "./utf8";
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { firstLineBreak, withoutPartialTail } from "./bytes";
|
|
5
|
+
import { ellipsizeText } from "./ellipsize";
|
|
6
|
+
import type { BreakConfig, LayoutEnv } from "./types";
|
|
7
|
+
import { decodeUtf8, encodeUtf8 } from "./utf8";
|
|
8
|
+
import { wrapText } from "./wrap-text";
|
|
9
|
+
|
|
10
|
+
const fround = Math.fround;
|
|
11
|
+
|
|
12
|
+
export type UiResolution = "hd" | "fhd";
|
|
13
|
+
|
|
14
|
+
/** The `Label` properties that affect line breaking and ellipsis. Widths and heights are in app pixels. */
|
|
15
|
+
export interface LabelProps {
|
|
16
|
+
text: string;
|
|
17
|
+
width: number;
|
|
18
|
+
wrap?: boolean;
|
|
19
|
+
height?: number;
|
|
20
|
+
numLines?: number;
|
|
21
|
+
maxLines?: number;
|
|
22
|
+
lineSpacing?: number;
|
|
23
|
+
wordBreakChars?: string;
|
|
24
|
+
ellipsisText?: string;
|
|
25
|
+
ellipsizeOnBoundary?: boolean;
|
|
26
|
+
truncateOnDelimiter?: string;
|
|
27
|
+
displayPartialLines?: boolean;
|
|
28
|
+
leadingEllipsis?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Per-font inputs of a layout: widths at the **device** resolution and the line height at the **app** resolution. */
|
|
32
|
+
export interface LabelMetrics {
|
|
33
|
+
/** `getOneLineWidth` at the device resolution, whole pixels. */
|
|
34
|
+
width: (text: string) => number;
|
|
35
|
+
/** `getLineHeight` of the font at the app resolution. */
|
|
36
|
+
lineHeight: number;
|
|
37
|
+
/** The theme's `DefaultLabelSpacing` at the app resolution. */
|
|
38
|
+
defaultLineSpacing: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LabelLayout {
|
|
42
|
+
/** `numWrappedLines`; null when the label does not wrap (a single line is drawn). */
|
|
43
|
+
numLines: number | null;
|
|
44
|
+
isTextEllipsized: boolean;
|
|
45
|
+
/** The lines the firmware computed, ellipsis included. Not all of them are drawn (see `displayPartialLines`). */
|
|
46
|
+
lines: string[];
|
|
47
|
+
/** `mHeight` in app pixels. */
|
|
48
|
+
height: number;
|
|
49
|
+
lineHeight: number;
|
|
50
|
+
lineSpacing: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ResolutionScales {
|
|
54
|
+
autoScale: number;
|
|
55
|
+
autoScaleInv: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `Scene::resolutionAutoScaleFactor` and its inverse for an app designed for `app` on a device showing `device`. */
|
|
59
|
+
export function scalesFor(app: UiResolution, device: UiResolution): ResolutionScales {
|
|
60
|
+
if (app === device) return { autoScale: 1, autoScaleInv: 1 };
|
|
61
|
+
if (app === "hd") return { autoScale: fround(1080 / 720), autoScaleInv: fround(720 / 1080) };
|
|
62
|
+
return { autoScale: fround(720 / 1080), autoScaleInv: fround(1080 / 720) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `GenevaTheme.xml` `DefaultLabelSpacing`: 8 for hd, 12 for fhd, in app pixels. */
|
|
66
|
+
export const defaultLineSpacing = (app: UiResolution): number => (app === "fhd" ? 12 : 8);
|
|
67
|
+
|
|
68
|
+
/** The firmware's line height from the font's hhea extents, in pixels: `ceil(ascender) + ceil(descender) + 1`. */
|
|
69
|
+
export const lineHeightFromExtents = (ascender: number, descender: number): number =>
|
|
70
|
+
Math.ceil(ascender) + Math.ceil(descender) + 1;
|
|
71
|
+
|
|
72
|
+
const FLOAT_MAX = Number.POSITIVE_INFINITY;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The line-breaking and ellipsis part of `Label::updateTextLayout` / `Label::renderSingle`.
|
|
76
|
+
* Throws a RangeError where the firmware throws `std::out_of_range`.
|
|
77
|
+
*/
|
|
78
|
+
export function layoutLabel(props: LabelProps, metrics: LabelMetrics, scales: ResolutionScales): LabelLayout {
|
|
79
|
+
const env: LayoutEnv = {
|
|
80
|
+
width: bytes => metrics.width(decodeUtf8(withoutPartialTail(bytes))),
|
|
81
|
+
autoScale: scales.autoScale,
|
|
82
|
+
autoScaleInv: scales.autoScaleInv
|
|
83
|
+
};
|
|
84
|
+
const cfg: BreakConfig = {
|
|
85
|
+
wordBreakChars: encodeUtf8(props.wordBreakChars ?? ""),
|
|
86
|
+
ellipsisText: encodeUtf8(props.ellipsisText || (props.ellipsizeOnBoundary ? " ..." : "...")),
|
|
87
|
+
ellipsizeOnBoundary: !!props.ellipsizeOnBoundary,
|
|
88
|
+
truncateOnDelimiter: encodeUtf8(props.truncateOnDelimiter ?? ""),
|
|
89
|
+
leadingEllipsis: !!props.leadingEllipsis
|
|
90
|
+
};
|
|
91
|
+
const width = fround(props.width);
|
|
92
|
+
const lineHeight = metrics.lineHeight;
|
|
93
|
+
const lineSpacing = props.lineSpacing ?? metrics.defaultLineSpacing;
|
|
94
|
+
const raw = encodeUtf8(props.text);
|
|
95
|
+
|
|
96
|
+
if (props.wrap === false || width <= 0) {
|
|
97
|
+
let display = raw;
|
|
98
|
+
const lineBreak = firstLineBreak(display);
|
|
99
|
+
if (lineBreak >= 0) display = display.slice(0, lineBreak);
|
|
100
|
+
if (width <= 0) {
|
|
101
|
+
// An auto-width label (width 0) draws its first line whole and never ellipsizes, but only when it does
|
|
102
|
+
// not wrap: wrapText returns at once for a width of 0, so a wrapping label has no lines and draws nothing.
|
|
103
|
+
return {
|
|
104
|
+
numLines: null,
|
|
105
|
+
isTextEllipsized: false,
|
|
106
|
+
lines: props.wrap === false ? [decodeUtf8(display)] : [],
|
|
107
|
+
height: lineHeight,
|
|
108
|
+
lineHeight,
|
|
109
|
+
lineSpacing
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const single = ellipsizeText(display, width, cfg, env);
|
|
113
|
+
return {
|
|
114
|
+
numLines: null,
|
|
115
|
+
isTextEllipsized: single.ellipsized,
|
|
116
|
+
lines: [decodeUtf8(single.text)],
|
|
117
|
+
height: lineHeight,
|
|
118
|
+
lineHeight,
|
|
119
|
+
lineSpacing
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const explicitHeight = (props.height ?? 0) > 0;
|
|
124
|
+
const numLines = props.numLines ?? 0;
|
|
125
|
+
const autoHeight = !explicitHeight && numLines <= 0;
|
|
126
|
+
let height = explicitHeight
|
|
127
|
+
? fround(props.height as number)
|
|
128
|
+
: numLines > 0
|
|
129
|
+
? numLines * lineHeight + (numLines - 1) * lineSpacing
|
|
130
|
+
: FLOAT_MAX;
|
|
131
|
+
|
|
132
|
+
const wrapParams = () => ({
|
|
133
|
+
width,
|
|
134
|
+
height,
|
|
135
|
+
lineHeight,
|
|
136
|
+
lineSpacing,
|
|
137
|
+
displayPartialLines: !!props.displayPartialLines
|
|
138
|
+
});
|
|
139
|
+
let result = wrapText(raw, wrapParams(), cfg, env);
|
|
140
|
+
|
|
141
|
+
if (autoHeight) {
|
|
142
|
+
height = result.lines.length * (lineHeight + lineSpacing) - lineSpacing;
|
|
143
|
+
const maxLines = props.maxLines ?? 0;
|
|
144
|
+
if (maxLines > 0) {
|
|
145
|
+
// `int maxHeight`, with a float spacing term: the sum is truncated toward zero.
|
|
146
|
+
const maxHeight = Math.trunc(fround(maxLines * lineHeight + fround((maxLines - 1) * lineSpacing)));
|
|
147
|
+
if (height > maxHeight) {
|
|
148
|
+
height = maxHeight;
|
|
149
|
+
result = wrapText(raw, wrapParams(), cfg, env);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
numLines: result.lines.length,
|
|
156
|
+
isTextEllipsized: result.ellipsized,
|
|
157
|
+
lines: result.lines.map(decodeUtf8),
|
|
158
|
+
height,
|
|
159
|
+
lineHeight,
|
|
160
|
+
lineSpacing
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A font as a layout sees it: widths at the device resolution, extents at the app resolution. */
|
|
165
|
+
export interface LabelFont {
|
|
166
|
+
width: (text: string) => number;
|
|
167
|
+
ascender: number;
|
|
168
|
+
descender: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function layoutLabelInFont(
|
|
172
|
+
props: LabelProps,
|
|
173
|
+
font: LabelFont,
|
|
174
|
+
appResolution: UiResolution,
|
|
175
|
+
deviceResolution: UiResolution
|
|
176
|
+
): LabelLayout {
|
|
177
|
+
return layoutLabel(
|
|
178
|
+
props,
|
|
179
|
+
{
|
|
180
|
+
width: font.width,
|
|
181
|
+
lineHeight: lineHeightFromExtents(font.ascender, font.descender),
|
|
182
|
+
defaultLineSpacing: defaultLineSpacing(appResolution)
|
|
183
|
+
},
|
|
184
|
+
scalesFor(appResolution, deviceResolution)
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The lines a wrapping label actually draws. `numWrappedLines` counts every computed line, but a line is drawn only
|
|
190
|
+
* if it fits the label height (with `displayPartialLines`, if it starts inside it), and `displayPartialLines`
|
|
191
|
+
* without an explicit height draws nothing at all (observed on the device).
|
|
192
|
+
*/
|
|
193
|
+
export function drawnLines(
|
|
194
|
+
layout: LabelLayout,
|
|
195
|
+
props: Pick<LabelProps, "wrap" | "displayPartialLines" | "height">
|
|
196
|
+
): string[] {
|
|
197
|
+
if (props.wrap === false || layout.numLines === null) return layout.lines;
|
|
198
|
+
if (props.displayPartialLines && !((props.height ?? 0) > 0)) return [];
|
|
199
|
+
const pitch = layout.lineHeight + layout.lineSpacing;
|
|
200
|
+
return layout.lines.filter((_, i) =>
|
|
201
|
+
props.displayPartialLines ? i * pitch <= layout.height : i * pitch + layout.lineHeight <= layout.height
|
|
202
|
+
);
|
|
203
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/** Width of a UTF-8 byte string in whole device pixels, as `GfxFont::getOneLineWidth` returns it. */
|
|
5
|
+
export type WidthFn = (bytes: Uint8Array) => number;
|
|
6
|
+
|
|
7
|
+
/** What the break-search code needs from a label's properties. */
|
|
8
|
+
export interface BreakConfig {
|
|
9
|
+
/** `wordBreakChars` as UTF-8 bytes; the firmware compares single bytes. */
|
|
10
|
+
wordBreakChars: Uint8Array;
|
|
11
|
+
/** The resolved ellipsis, already defaulted (`"..."`, or `" ..."` when `ellipsizeOnBoundary`). */
|
|
12
|
+
ellipsisText: Uint8Array;
|
|
13
|
+
ellipsizeOnBoundary: boolean;
|
|
14
|
+
/** Empty when `truncateOnDelimiter` is not set. */
|
|
15
|
+
truncateOnDelimiter: Uint8Array;
|
|
16
|
+
leadingEllipsis: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Measurement environment: font widths at device resolution plus the app/device scale factors. */
|
|
20
|
+
export interface LayoutEnv {
|
|
21
|
+
width: WidthFn;
|
|
22
|
+
/** `RESOLUTION_AUTO_SCALE_FACTOR`: app pixels to device pixels (1, 1.5 or 2/3). */
|
|
23
|
+
autoScale: number;
|
|
24
|
+
/** `RESOLUTION_AUTO_SCALE_FACTOR_INV`: device pixels to app pixels. */
|
|
25
|
+
autoScaleInv: number;
|
|
26
|
+
}
|
package/src/utf8.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
// The only place that touches TextEncoder / TextDecoder, so a host without them can swap this file.
|
|
5
|
+
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
const decoder = new TextDecoder("utf-8");
|
|
8
|
+
|
|
9
|
+
export const encodeUtf8 = (text: string): Uint8Array => encoder.encode(text);
|
|
10
|
+
|
|
11
|
+
export const decodeUtf8 = (bytes: Uint8Array): string => decoder.decode(bytes);
|
package/src/wrap-text.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { findCharBoundary, findWordBreak } from "./break-search";
|
|
5
|
+
import { at, firstLineBreak, isSpaceNotNewline, normalizeWrapText } from "./bytes";
|
|
6
|
+
import { ellipsizeText } from "./ellipsize";
|
|
7
|
+
import type { BreakConfig, LayoutEnv } from "./types";
|
|
8
|
+
|
|
9
|
+
const fround = Math.fround;
|
|
10
|
+
|
|
11
|
+
export interface WrapParams {
|
|
12
|
+
/** Box width in app pixels. Must be greater than zero. */
|
|
13
|
+
width: number;
|
|
14
|
+
/** `mHeight` in app pixels: `Number.POSITIVE_INFINITY` stands for the firmware's `FLOAT_MAX`. */
|
|
15
|
+
height: number;
|
|
16
|
+
lineHeight: number;
|
|
17
|
+
lineSpacing: number;
|
|
18
|
+
displayPartialLines: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface WrapResult {
|
|
22
|
+
lines: Uint8Array[];
|
|
23
|
+
ellipsized: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `Label::wrapText`, verbatim: per line, a hinted binary search over break positions, each candidate measured as
|
|
28
|
+
* a whole substring; a character-level search when no break position fits; and the last line the height allows is
|
|
29
|
+
* ellipsized. Includes the firmware's `max = len - 1` assumption and the "cannot even display one character" exit.
|
|
30
|
+
*/
|
|
31
|
+
export function wrapText(raw: Uint8Array, params: WrapParams, cfg: BreakConfig, env: LayoutEnv): WrapResult {
|
|
32
|
+
const { width, height: boxHeight, lineHeight, lineSpacing, displayPartialLines } = params;
|
|
33
|
+
let remaining = normalizeWrapText(raw);
|
|
34
|
+
const scaledWidth = env.autoScale !== 1 ? fround(env.autoScale * width) : width;
|
|
35
|
+
|
|
36
|
+
const lines: Uint8Array[] = [];
|
|
37
|
+
let height = 0;
|
|
38
|
+
let lastNumChars = 100;
|
|
39
|
+
let ellipsized = false;
|
|
40
|
+
|
|
41
|
+
for (;;) {
|
|
42
|
+
if (displayPartialLines) {
|
|
43
|
+
if (height > boxHeight) break;
|
|
44
|
+
} else if (fround(fround(height + 2 * lineHeight) + lineSpacing) > boxHeight) {
|
|
45
|
+
const last = ellipsizeText(remaining, width, cfg, env);
|
|
46
|
+
lines.push(last.text);
|
|
47
|
+
ellipsized = last.ellipsized;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const newlinePos = firstLineBreak(remaining);
|
|
52
|
+
if (newlinePos < 0 && env.width(remaining) <= scaledWidth) {
|
|
53
|
+
lines.push(remaining);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const numChars = remaining.length;
|
|
58
|
+
if (numChars === 0) break;
|
|
59
|
+
|
|
60
|
+
let max = numChars - 1;
|
|
61
|
+
let min = 0;
|
|
62
|
+
|
|
63
|
+
if (newlinePos >= 0) {
|
|
64
|
+
max = newlinePos;
|
|
65
|
+
const upToNewline = remaining.slice(0, newlinePos);
|
|
66
|
+
const lineTest = lastNumChars + 20;
|
|
67
|
+
let skipTest = false;
|
|
68
|
+
if (newlinePos > lineTest && env.width(upToNewline.slice(0, lineTest)) > scaledWidth) skipTest = true;
|
|
69
|
+
if (!skipTest && env.width(upToNewline) <= scaledWidth) min = max;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const origMax = max;
|
|
73
|
+
let testNum = 0;
|
|
74
|
+
while (min + 1 < max) {
|
|
75
|
+
let mid: number;
|
|
76
|
+
if (testNum === 0) {
|
|
77
|
+
mid = lastNumChars + 15;
|
|
78
|
+
if (mid < min || mid > max) mid = (max + min) >> 1;
|
|
79
|
+
testNum++;
|
|
80
|
+
} else if (testNum === 1) {
|
|
81
|
+
mid = lastNumChars - 15;
|
|
82
|
+
if (mid < min || mid > max) mid = (max + min) >> 1;
|
|
83
|
+
testNum++;
|
|
84
|
+
} else {
|
|
85
|
+
mid = (max + min) >> 1;
|
|
86
|
+
}
|
|
87
|
+
mid = findWordBreak(remaining, min, mid, max, cfg);
|
|
88
|
+
if (mid < 0) break;
|
|
89
|
+
|
|
90
|
+
if (env.width(remaining.slice(0, mid)) <= scaledWidth) min = mid;
|
|
91
|
+
else max = mid;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (min === 0) {
|
|
95
|
+
while (min + 1 < max) {
|
|
96
|
+
const mid = findCharBoundary(remaining, min, (max + min) >> 1, max);
|
|
97
|
+
if (mid < 0) break;
|
|
98
|
+
if (env.width(remaining.slice(0, mid)) <= scaledWidth) min = mid;
|
|
99
|
+
else max = mid;
|
|
100
|
+
}
|
|
101
|
+
} else if (min !== origMax) {
|
|
102
|
+
lastNumChars = min;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
lines.push(remaining.slice(0, min));
|
|
106
|
+
|
|
107
|
+
const lastChar = at(remaining, min);
|
|
108
|
+
if (lastChar === 10 || lastChar === 13) {
|
|
109
|
+
min++;
|
|
110
|
+
} else {
|
|
111
|
+
while (min < numChars && isSpaceNotNewline(remaining[min])) min++;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (min === 0) break;
|
|
115
|
+
remaining = remaining.slice(min);
|
|
116
|
+
height = fround(height + lineHeight + lineSpacing);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { lines, ellipsized };
|
|
120
|
+
}
|