@bindtty/text 0.1.0-alpha.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 ADDED
@@ -0,0 +1,17 @@
1
+ # @bindtty/text
2
+
3
+ Plain terminal text measurement and wrapping utilities for BindTTY.
4
+
5
+ **现行规范**:[doc/specs/DISPLAY_WIDTH.md](../../doc/specs/DISPLAY_WIDTH.md)
6
+
7
+ MVP scope:
8
+
9
+ - Plain text only.
10
+ - Display-width-aware measurement via `string-width`.
11
+ - Grapheme segmentation via `Intl.Segmenter`, with code point fallback.
12
+ - CJK, common emoji, and combining mark measurement.
13
+ - Display-column wrap, hard wrap, truncate, and slice helpers.
14
+ - Wrap and hard-wrap preserve whole graphemes; if one grapheme is wider than the target width, the produced line may be wider than the target.
15
+ - Truncate helpers keep output display width within the target width.
16
+ - No embedded ANSI escape support.
17
+ - Complex ZWJ emoji sequence behavior follows the package display-width oracle and is not font/terminal perfect.
@@ -0,0 +1,9 @@
1
+ export { layoutText, normalizeWrapMode, readTextWrapMode } from "./layout.js";
2
+ export { measureText } from "./measure.js";
3
+ export { truncateEnd, truncateMiddle, truncateStart } from "./truncate.js";
4
+ export { segmentText } from "./segment.js";
5
+ export { sliceTextByWidth } from "./slice.js";
6
+ export { hardWrapLine, wordWrapLine } from "./wrap.js";
7
+ export { measureTextWidth } from "./width.js";
8
+ export type { PublicTextWrapMode, TextLayout, TextLayoutOptions, TextMeasure, TextSegment, TextWrapMode } from "./types.js";
9
+ export { TEXT_WRAP_MODES } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { layoutText, normalizeWrapMode, readTextWrapMode } from "./layout.js";
2
+ export { measureText } from "./measure.js";
3
+ export { truncateEnd, truncateMiddle, truncateStart } from "./truncate.js";
4
+ export { segmentText } from "./segment.js";
5
+ export { sliceTextByWidth } from "./slice.js";
6
+ export { hardWrapLine, wordWrapLine } from "./wrap.js";
7
+ export { measureTextWidth } from "./width.js";
8
+ export { TEXT_WRAP_MODES } from "./types.js";
@@ -0,0 +1,4 @@
1
+ import { type TextLayout, type TextLayoutOptions, type TextWrapMode } from "./types.js";
2
+ export declare function layoutText(text: string, options?: TextLayoutOptions): TextLayout;
3
+ export declare function normalizeWrapMode(wrap: TextLayoutOptions["wrap"]): TextWrapMode;
4
+ export declare function readTextWrapMode(wrap: unknown): TextWrapMode;
package/dist/layout.js ADDED
@@ -0,0 +1,91 @@
1
+ import { measureTextWidth } from "./width.js";
2
+ import { TEXT_WRAP_MODES } from "./types.js";
3
+ import { truncateEnd, truncateMiddle, truncateStart } from "./truncate.js";
4
+ import { hardWrapLine, wordWrapLine } from "./wrap.js";
5
+ const layoutCache = new Map();
6
+ export function layoutText(text, options = {}) {
7
+ const wrap = normalizeWrapMode(options.wrap);
8
+ const width = normalizeWidth(options.width);
9
+ const cacheKey = `${text}\0${width ?? "auto"}\0${wrap}`;
10
+ const cached = layoutCache.get(cacheKey);
11
+ if (cached) {
12
+ return cached;
13
+ }
14
+ const lines = createLines(text, wrap, width);
15
+ const layout = {
16
+ width: lines.reduce((maxWidth, line) => Math.max(maxWidth, measureTextWidth(line)), 0),
17
+ height: lines.length,
18
+ lines
19
+ };
20
+ layoutCache.set(cacheKey, layout);
21
+ return layout;
22
+ }
23
+ export function normalizeWrapMode(wrap) {
24
+ if (wrap === null || wrap === undefined) {
25
+ return "legacy";
26
+ }
27
+ if (isTextWrapMode(wrap)) {
28
+ return wrap;
29
+ }
30
+ throw new Error(`Unsupported text wrap mode: ${String(wrap)}`);
31
+ }
32
+ export function readTextWrapMode(wrap) {
33
+ if (wrap === null || wrap === undefined) {
34
+ return "legacy";
35
+ }
36
+ if (isTextWrapMode(wrap)) {
37
+ return wrap;
38
+ }
39
+ throw new Error(`Unsupported text wrap mode: ${String(wrap)}`);
40
+ }
41
+ function isTextWrapMode(value) {
42
+ return (typeof value === "string" &&
43
+ TEXT_WRAP_MODES.includes(value));
44
+ }
45
+ function normalizeWidth(width) {
46
+ if (width === undefined || !Number.isFinite(width)) {
47
+ return undefined;
48
+ }
49
+ return Math.max(0, Math.floor(width));
50
+ }
51
+ function createLines(text, wrap, width) {
52
+ if (text === "" || width === 0) {
53
+ return [];
54
+ }
55
+ switch (wrap) {
56
+ case "legacy":
57
+ return firstLine(text);
58
+ case "none":
59
+ return splitLines(text);
60
+ case "wrap":
61
+ return wrapLines(text, width, wordWrapLine);
62
+ case "hard":
63
+ return wrapLines(text, width, hardWrapLine);
64
+ case "truncate-end":
65
+ return truncateLines(text, width, truncateEnd);
66
+ case "truncate-middle":
67
+ return truncateLines(text, width, truncateMiddle);
68
+ case "truncate-start":
69
+ return truncateLines(text, width, truncateStart);
70
+ }
71
+ }
72
+ function firstLine(text) {
73
+ const line = text.split("\n", 1)[0] ?? "";
74
+ return line === "" ? [] : [line];
75
+ }
76
+ function splitLines(text) {
77
+ return text.split("\n");
78
+ }
79
+ function wrapLines(text, width, wrapLine) {
80
+ if (width === undefined) {
81
+ return splitLines(text);
82
+ }
83
+ return splitLines(text).flatMap((line) => wrapLine(line, width));
84
+ }
85
+ function truncateLines(text, width, truncate) {
86
+ const lines = splitLines(text);
87
+ if (width === undefined) {
88
+ return lines;
89
+ }
90
+ return lines.map((line) => truncate(line, width));
91
+ }
@@ -0,0 +1,2 @@
1
+ export declare function splitExplicitLines(text: string): string[];
2
+ export declare function firstExplicitLine(text: string): string;
package/dist/lines.js ADDED
@@ -0,0 +1,6 @@
1
+ export function splitExplicitLines(text) {
2
+ return text === "" ? [] : text.split("\n");
3
+ }
4
+ export function firstExplicitLine(text) {
5
+ return text.split("\n", 1)[0] ?? "";
6
+ }
@@ -0,0 +1,2 @@
1
+ import type { TextMeasure } from "./types.js";
2
+ export declare function measureText(text: string): TextMeasure;
@@ -0,0 +1,16 @@
1
+ import { measureTextWidth } from "./width.js";
2
+ const measureCache = new Map();
3
+ export function measureText(text) {
4
+ const cached = measureCache.get(text);
5
+ if (cached) {
6
+ return cached;
7
+ }
8
+ const lines = text.split("\n");
9
+ const width = lines.reduce((maxWidth, line) => Math.max(maxWidth, measureTextWidth(line)), 0);
10
+ const measure = {
11
+ width,
12
+ height: text === "" ? 0 : lines.length
13
+ };
14
+ measureCache.set(text, measure);
15
+ return measure;
16
+ }
@@ -0,0 +1,3 @@
1
+ import type { TextSegment } from "./types.js";
2
+ export declare function segmentText(text: string): TextSegment[];
3
+ export declare function measureSegmentsWidth(segments: readonly TextSegment[]): number;
@@ -0,0 +1,34 @@
1
+ import stringWidth from "string-width";
2
+ export function segmentText(text) {
3
+ if (text === "") {
4
+ return [];
5
+ }
6
+ return splitGraphemes(text).map((segment) => ({
7
+ text: segment,
8
+ width: normalizeSegmentWidth(stringWidth(segment))
9
+ }));
10
+ }
11
+ export function measureSegmentsWidth(segments) {
12
+ return segments.reduce((width, segment) => width + segment.width, 0);
13
+ }
14
+ function splitGraphemes(text) {
15
+ const Segmenter = readSegmenter();
16
+ if (Segmenter) {
17
+ return Array.from(new Segmenter("en", { granularity: "grapheme" }).segment(text), (part) => part.segment);
18
+ }
19
+ return Array.from(text);
20
+ }
21
+ function readSegmenter() {
22
+ const segmenter = Intl
23
+ .Segmenter;
24
+ return typeof segmenter === "function" ? segmenter : null;
25
+ }
26
+ function normalizeSegmentWidth(width) {
27
+ if (!Number.isFinite(width) || width <= 0) {
28
+ return 0;
29
+ }
30
+ if (width >= 2) {
31
+ return 2;
32
+ }
33
+ return 1;
34
+ }
@@ -0,0 +1 @@
1
+ export declare function sliceTextByWidth(text: string, startColumn: number, endColumn: number): string;
package/dist/slice.js ADDED
@@ -0,0 +1,26 @@
1
+ import { segmentText } from "./segment.js";
2
+ export function sliceTextByWidth(text, startColumn, endColumn) {
3
+ const start = normalizeColumn(startColumn);
4
+ const end = normalizeColumn(endColumn);
5
+ if (end <= start || text === "") {
6
+ return "";
7
+ }
8
+ let cursor = 0;
9
+ let output = "";
10
+ for (const segment of segmentText(text)) {
11
+ const nextCursor = cursor + segment.width;
12
+ if (segment.width > 0 &&
13
+ cursor >= start &&
14
+ nextCursor <= end) {
15
+ output += segment.text;
16
+ }
17
+ cursor = nextCursor;
18
+ }
19
+ return output;
20
+ }
21
+ function normalizeColumn(value) {
22
+ if (!Number.isFinite(value)) {
23
+ return 0;
24
+ }
25
+ return Math.max(0, Math.floor(value));
26
+ }
@@ -0,0 +1,3 @@
1
+ export declare function truncateEnd(text: string, width: number): string;
2
+ export declare function truncateStart(text: string, width: number): string;
3
+ export declare function truncateMiddle(text: string, width: number): string;
@@ -0,0 +1,85 @@
1
+ import { measureSegmentsWidth, segmentText } from "./segment.js";
2
+ const ELLIPSIS = "…";
3
+ const ELLIPSIS_WIDTH = measureTextWidthBySegments(ELLIPSIS);
4
+ export function truncateEnd(text, width) {
5
+ if (fits(text, width)) {
6
+ return text;
7
+ }
8
+ if (width <= 0) {
9
+ return "";
10
+ }
11
+ if (width === 1) {
12
+ return ELLIPSIS;
13
+ }
14
+ return takeStartByWidth(text, width - ELLIPSIS_WIDTH) + ELLIPSIS;
15
+ }
16
+ export function truncateStart(text, width) {
17
+ if (fits(text, width)) {
18
+ return text;
19
+ }
20
+ if (width <= 0) {
21
+ return "";
22
+ }
23
+ if (width === 1) {
24
+ return ELLIPSIS;
25
+ }
26
+ return ELLIPSIS + takeEndByWidth(text, width - ELLIPSIS_WIDTH);
27
+ }
28
+ export function truncateMiddle(text, width) {
29
+ if (fits(text, width)) {
30
+ return text;
31
+ }
32
+ if (width <= 0) {
33
+ return "";
34
+ }
35
+ if (width === 1) {
36
+ return ELLIPSIS;
37
+ }
38
+ const available = width - ELLIPSIS_WIDTH;
39
+ const left = Math.ceil(available / 2);
40
+ const right = Math.floor(available / 2);
41
+ return takeStartByWidth(text, left) + ELLIPSIS + takeEndByWidth(text, right);
42
+ }
43
+ function fits(text, width) {
44
+ return measureTextWidthBySegments(text) <= Math.max(0, width);
45
+ }
46
+ function takeStartByWidth(text, width) {
47
+ let output = "";
48
+ let currentWidth = 0;
49
+ for (const segment of segmentText(text)) {
50
+ if (segment.width === 0) {
51
+ output += segment.text;
52
+ continue;
53
+ }
54
+ if (currentWidth + segment.width > width) {
55
+ break;
56
+ }
57
+ output += segment.text;
58
+ currentWidth += segment.width;
59
+ }
60
+ return output;
61
+ }
62
+ function takeEndByWidth(text, width) {
63
+ const segments = segmentText(text);
64
+ const output = [];
65
+ let currentWidth = 0;
66
+ for (let index = segments.length - 1; index >= 0; index -= 1) {
67
+ const segment = segments[index];
68
+ if (!segment) {
69
+ continue;
70
+ }
71
+ if (segment.width === 0) {
72
+ output.unshift(segment.text);
73
+ continue;
74
+ }
75
+ if (currentWidth + segment.width > width) {
76
+ break;
77
+ }
78
+ output.unshift(segment.text);
79
+ currentWidth += segment.width;
80
+ }
81
+ return output.join("");
82
+ }
83
+ function measureTextWidthBySegments(text) {
84
+ return measureSegmentsWidth(segmentText(text));
85
+ }
@@ -0,0 +1,20 @@
1
+ export type TextWrapMode = "legacy" | "none" | "wrap" | "hard" | "truncate-end" | "truncate-middle" | "truncate-start";
2
+ export type PublicTextWrapMode = Exclude<TextWrapMode, "legacy">;
3
+ export interface TextMeasure {
4
+ width: number;
5
+ height: number;
6
+ }
7
+ export interface TextLayoutOptions {
8
+ width?: number;
9
+ wrap?: TextWrapMode | PublicTextWrapMode | null | undefined;
10
+ }
11
+ export interface TextLayout {
12
+ width: number;
13
+ height: number;
14
+ lines: string[];
15
+ }
16
+ export interface TextSegment {
17
+ text: string;
18
+ width: 0 | 1 | 2;
19
+ }
20
+ export declare const TEXT_WRAP_MODES: readonly ["legacy", "none", "wrap", "hard", "truncate-end", "truncate-middle", "truncate-start"];
package/dist/types.js ADDED
@@ -0,0 +1,9 @@
1
+ export const TEXT_WRAP_MODES = [
2
+ "legacy",
3
+ "none",
4
+ "wrap",
5
+ "hard",
6
+ "truncate-end",
7
+ "truncate-middle",
8
+ "truncate-start"
9
+ ];
@@ -0,0 +1 @@
1
+ export declare function measureTextWidth(text: string): number;
package/dist/width.js ADDED
@@ -0,0 +1,4 @@
1
+ import { measureSegmentsWidth, segmentText } from "./segment.js";
2
+ export function measureTextWidth(text) {
3
+ return measureSegmentsWidth(segmentText(text));
4
+ }
package/dist/wrap.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare function hardWrapLine(line: string, width: number): string[];
2
+ export declare function wordWrapLine(line: string, width: number): string[];
package/dist/wrap.js ADDED
@@ -0,0 +1,73 @@
1
+ import { measureTextWidth } from "./width.js";
2
+ import { segmentText } from "./segment.js";
3
+ export function hardWrapLine(line, width) {
4
+ if (width <= 0 || line === "") {
5
+ return line === "" ? [""] : [];
6
+ }
7
+ const lines = [];
8
+ let current = "";
9
+ let currentWidth = 0;
10
+ for (const segment of segmentText(line)) {
11
+ if (segment.width === 0) {
12
+ current += segment.text;
13
+ continue;
14
+ }
15
+ if (current !== "" && currentWidth + segment.width > width) {
16
+ lines.push(current);
17
+ current = "";
18
+ currentWidth = 0;
19
+ }
20
+ if (segment.width > width) {
21
+ if (current !== "") {
22
+ lines.push(current);
23
+ current = "";
24
+ currentWidth = 0;
25
+ }
26
+ lines.push(segment.text);
27
+ continue;
28
+ }
29
+ current += segment.text;
30
+ currentWidth += segment.width;
31
+ }
32
+ if (current !== "" || lines.length === 0) {
33
+ lines.push(current);
34
+ }
35
+ return lines;
36
+ }
37
+ export function wordWrapLine(line, width) {
38
+ if (width <= 0) {
39
+ return [];
40
+ }
41
+ if (line === "") {
42
+ return [""];
43
+ }
44
+ const tokens = line.match(/\S+\s*/g) ?? [line];
45
+ const lines = [];
46
+ let current = "";
47
+ for (const token of tokens) {
48
+ if (measureTextWidth(token.trimEnd()) > width) {
49
+ if (current !== "") {
50
+ lines.push(trimTrailingSpaces(current));
51
+ current = "";
52
+ }
53
+ lines.push(...hardWrapLine(token.trimEnd(), width));
54
+ const trailingSpaces = token.match(/\s+$/)?.[0] ?? "";
55
+ current = trailingSpaces.length > 0 ? trailingSpaces : "";
56
+ continue;
57
+ }
58
+ if (current !== "" &&
59
+ measureTextWidth(current + token.trimEnd()) > width) {
60
+ lines.push(trimTrailingSpaces(current));
61
+ current = token;
62
+ continue;
63
+ }
64
+ current += token;
65
+ }
66
+ if (current !== "" || lines.length === 0) {
67
+ lines.push(trimTrailingSpaces(current));
68
+ }
69
+ return lines;
70
+ }
71
+ function trimTrailingSpaces(value) {
72
+ return value.replace(/\s+$/g, "");
73
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@bindtty/text",
3
+ "version": "0.1.0-alpha.1",
4
+ "type": "module",
5
+ "description": "Plain terminal text measurement and wrapping utilities for BindTTY.",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "prepublishOnly": "npm run build",
21
+ "test": "npm run build && tsc -p test/tsconfig.json && node --test test/dist/*.test.js"
22
+ },
23
+ "dependencies": {
24
+ "string-width": "7.2.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^22.0.0",
28
+ "typescript": "^5.5.0"
29
+ },
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/lithdoo/BindTTY.git",
40
+ "directory": "packages/text"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/lithdoo/BindTTY/issues"
44
+ },
45
+ "homepage": "https://github.com/lithdoo/BindTTY/tree/main/packages/text#readme"
46
+ }