Skip to content

Commit

Permalink
feat(string_width): add stringWidth for TTY text layout
Browse files Browse the repository at this point in the history
  • Loading branch information
lionel-rowe committed Apr 1, 2023
1 parent 87f4a8b commit e3795a5
Show file tree
Hide file tree
Showing 12 changed files with 517 additions and 3 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
/crypto/_wasm/target
/encoding/varint/_wasm/target
deno.lock
/string_width/testdata/unicode_width_crate/target
6 changes: 3 additions & 3 deletions _tools/check_doc_imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ const EXCLUDED_PATHS = [
const ROOT = new URL("../", import.meta.url);
const ROOT_LENGTH = ROOT.pathname.slice(0, -1).length;

const RX_JSDOC_COMMENT = /\*\*[^*]*\*+(?:[^/*][^*]*\*+)*/mg;
const RX_JSDOC_REMOVE_LEADING_ASTERISK = /^\s*\* ?/gm;
const RX_CODE_BLOCK = /`{3}([\w]*)\n([\S\s]+?)\n`{3}/gm;
export const RX_JSDOC_COMMENT = /\*\*[^*]*\*+(?:[^/*][^*]*\*+)*/gm;
export const RX_JSDOC_REMOVE_LEADING_ASTERISK = /^\s*\* ?/gm;
export const RX_CODE_BLOCK = /`{3}([\w]*)\n([\S\s]+?)\n`{3}/gm;

let shouldFail = false;
let countChecked = 0;
Expand Down
75 changes: 75 additions & 0 deletions string_width/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# string_width

Get the expected physical column width of a string in TTY-like environments.

Combines a TypeScript port of the
[unicode-width Rust crate](https://github.com/unicode-rs/unicode-width/), which
looks up the nominal width of Unicode characters, with a port of
[chalk/strip-ansi](https://github.com/chalk/strip-ansi), which strips out ANSI
escape sequences.

## Usage

```ts
import { stringWidth } from "https://deno.land/std@$STD_VERSION/string_width/string_width.ts";
import { assertEquals } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts";

assertEquals(stringWidth("hello world"), 11);
assertEquals(stringWidth("\x1b[36mCYAN\x1b[0m"), 4);
assertEquals(stringWidth("天地玄黃宇宙洪荒"), 16);
assertEquals(stringWidth("fullwidth_text"), 28);
assertEquals(
stringWidth("\x1B]8;;https://deno.land\x07Deno 🦕\x1B]8;;\x07"),
7,
);
```

## Examples

### Drawing line art in the terminal

```ts
import { stringWidth } from "https://deno.land/std@$STD_VERSION/string_width/string_width.ts";
import { assertEquals } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts";

function drawRect(str: string) {
const width = stringWidth(str);
const line = "-".repeat(width + 4);

return `${line}\n| ${str} |\n${line}`;
}

assertEquals(
drawRect("abc"),
`
-------
| abc |
-------
`.trim(),
);

assertEquals(
drawRect("🦕"),
`
------
| 🦕 |
------
`.trim(),
);
```

### Calculating console line wrap

```ts
import { stringWidth } from "https://deno.land/std@$STD_VERSION/string_width/string_width.ts";
import { assertEquals } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts";

function numPhysicalConsoleLines(str: string) {
const { columns } = Deno.consoleSize();
return Math.ceil(stringWidth(str) / columns);
}

// assuming `Deno.consoleSize().columns` is 120...
assertEquals(numPhysicalConsoleLines("a".repeat(100)), 1);
assertEquals(numPhysicalConsoleLines("".repeat(100)), 2);
```
18 changes: 18 additions & 0 deletions string_width/_data.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"CRATE_VERSION": "0.1.10",
"UNICODE_VERSION": "15.0.0",
"tables": [
{
"d": "AAECAwQFBgcICQoLDA0OAw8DDwkQCRESERIA",
"r": "AQEBAgEBAQEBAQEBAQEBBwEHAVABBwcBBwF4"
},
{
"d": "AAECAwQFBgcGCAYJCgsMDQ4PEAYREhMUBhUWFxgZGhscHR4fICEiIyIkJSYnKCkqJSssLS4vMDEyMzQ1Njc4OToGOzwKBj0GPj9AQUIGQwZEBkVGR0hJSktMTQZOBgoGT1BRUlNUVVZXWFkGWgZbBlxdXl1fYGFiY2RlZmdoBmlqBmsGAQZsBm1uO29wcXI7czt0dXZ3OwY7eHkGent8Bn0Gfn+AgYKDhIWGBoc7iAZdO4kGiosGAXGMBo0GjgaPBpAGkQaSBpMGlJUGlpcGmJmam5ydnp+gLgahLKIGo6SlpganqKmqqwasBq0Grq8GsLGyswa0BrUGtre4Brm6uwZHvAa9vga/wME7wjvDxAbFO8bHO8gGyQbKywbMzQbOBs/Q0QbSBr8GvgbT1AbUBtUG1gbXBtjZ2tsG3N0G3t/g4eLjO+Tl5ufoO+k76gbrBuztOwbu7/AGO+XxCgYKCwZd8g==",
"r": "AQEBAQEBAQEBAQEBAQEBAQEBAQMBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQECBQEOAQEBAQEBAQEBAwEBAQEBAQEBAQIBAwEIAQEBAQEBAQEBAQEBAQIBAQEBAQEBAQEBAQEBAQEBDQEBBQEBAQEBAgEBAwEBAQEBAQEBAQEBbQHaAQEFAQEBBAECAQEBAQEBAQEBAwGuASFkCAELAQEBAQEBAQEHAQMBAQEaAQIBCAEFAQEBAQEBAQEBAQEBAQEBAQEBAQECAQEBAQIBAQEBAQEBAwEDAQEBAQEBAQUBAQEBAQEBBAEBAVIBAdkBARABAQFfARMBAYoBBAEBBQEmAUkBAQcBAQIBHgEBARUBAQEBAQUBAQcBDwEBARoBAgEBAQEBAQECAQEBAQEBAQEBAQEBAQEBAQMBBAEBAgEBAQEUfwEBAQIDAXj/AQ=="
},
{
"d": "AFUVAF3Xd3X/93//VXVVV9VX9V91f1/31X93XVXdVdVV9dVV/VVX1X9X/131VfXVVXV3V1VdVV1V1/1dV1X/3VUAVf3/3/9fVf3/3/9fVV1V/11VFQBQVQEAEEEQVQBQVQBAVFUVAFVUVQUAEAAUBFBVFVFVAEBVBQBUVRUAVVFVBRAAAVBVAVVQVQBVBQBAVUVUAQBUUQEAVQVVUVVUAVRVUVUFVUVBVVRBFRRQUVVQUVUBEFRRVQVVBQBRVRQBVFVRVUFVBVVFVVRVUVVUVQRUBQRQVUFVBVVFVVBVBVVQVRVUAVRVUVUFVVFVRVUFRFVRAEBVFQBAVVEAVFUAQFVQVRFRVQEAQAAEVQEAAQBUVUVVAQQAQVVQBVRVAVRVRUFVUVVRVaoAVQFVBVRVBVUFVQVVEABQVUUBAFVRVRUAVUFVUVVAFVRVRVUBVRUUVUUAQEQBAFQVABRVAEBVAFUEQFRFVRUAVVBVBVAQUFVFUBFQVQAFVUAABABUUVVUUFUVANd/X3//BUD3XdV1VQAEAFVXVdX9V1VXVQBUVdVdVdV1VX111VXVV9V//1X/X1VdVf9fVV9VdVdV1VX31dfVXXX9193/d1X/VV9VV3VVX//1VfVVXVVdVdVVdVWlVWlVqVaWVf/f/1X/Vf/1X1Xf/19V9VVf9df1X1X1X1XVVWlVfV31VVpVd1V3VapV33/fVZVVlVX1WVWlVelV+v/v//7/31Xv/6/77/tVWaVVVlVdVWaVmlX1/1WpVVZVlVWVVlVW+V9VFVBVAKqaqlWqWlWqVaoKoKpqqapqgapVqaqpqmqqVapqqv+qVqpqVRVAAFBVBVVQVUUVVUFVVFVQVQBQVRVVBQBQVRUAUFWqVkBVFQVQVVFVAUBBVRVVVFVUVQQUVAVRVVBVRVVRVFFVqlVFVQCqWlUAqmqqaqpVqlZVqmpVAV1VUVVUVQVAVQFBVQBVQBVVQVUAVRVUVQFVBQBUVQVQVVFVAEBVFFRVFVBVFUBBUUVVUVVAVRUAAQBUVRVVUFUFAEBVARRVFVAEVUVVFQBAVVRVBQBUAFRVAAVEVUVVFQBEFQRVBVBVEFRVUFUVAEARVFUVUQAQVQEFEABVFQBBVRVEFVUABVVUVQEAQFUVABRAVRVVAUABVQUAQFBVAEAAEFUFAAUABEFVAUBFEAAQVVARVRVUVVBVBUBVRFVUFQBQVQBUVQBAVRVVFUBVqlRVWlWqVapaVapWVaqpqmmqalVlVWpZVapVqlVBAFUAUABAVRVQVRUAQAEAVQVQVQVUVQBAFQBUVVFVVFUVAAEAVQBAABQAEARAVUVVAFUAQFUAQFVWVZVV/39V/1//X1X/76uq6v9XVWpVqlWqVlVaVapaVapWVamqmqqmqlWqapWqVapWqmqmqpaqWlWVaqpVZVVpVVZVlapVqlpVVmqpVapVlVZVqlZVqlVWVapqqpqqVapWqlZVqpqqWlWlqlWqVlWqVlVRVQD/Xw==",
"r": "CBcBCAEBAQEBAQEBAQECAQEBAQEBAQEBAQEBAQMBAQECAQEBAQEBAQEBAQEBBAEBGAEDAQwBAwEIAQEBAQEBAQgcCAEDAQEBAQEDAQEBDQEDEAELAQEBEQEKAQEBDgEBAgIBAQoBBQQBCAEBAQEBAQEHAQEHBgEWAQIBDQECAgEFAQECAgEKAQ0BAQIKAQ0BDQEBAQEBAQEBAgEHAQ4BAQEBAQQBBgEBDgEBAQEBAQcBAQIBAQEBBAEFAQEBDgEBAQEBAQECAQcBDwECAQwCDQEBAQEBAQECAQgBAQEEAQcBDQEBAQEBAQQBBwERAQEBARYBAQECAQEBGAECAQIBARIBBgEBDQECAQEBAQECAQgBAQEZAQEBAgYBAQEDAQECAQEBAQMBCBgIBwEMAQEGAQcBBwEQAQEBAQEBAgIBCgEBDQEIAQ0BAQEBAQEBBgEBDgEBAQEBAQEBAgEMBwEMAQwBAQEBCQECAwEHAQEBAQ0BAQEBDgIBBgEDAQEBAQEBAQMBAQEBAgEBAQEBAQEBCAEBAgEBAQEBAQkBCAgBAwECAQEBAgEBAQkBAQEBAwECAQMBAQIBBwEFAQEDAQYBAQEBAgEBAQEBAQEBAQECAgEDAQECBAIDAgIBBQEEAQEBAwEPAQEBCyIBCAEJAwQBAQIBAQEBAgECAQEBAQMBAQEBAwEBAQEBAQEBAQgBAQMDAgEBAwEEAQIBAQEBBAEBAQEBAQECAQEBAQEBAQEBAQEHAQQBAwEBAQcBAgUBBgECAQYBAQwBAQEUAQELCAYBFgMFAQYDAQoBAQMBARQBAQkBAQoBBgEVAwsBCgIPAQ0BGQEBAgEHARQBAwIBBgEBAQUBBgQBAgEJAQEBBQECAQMHAQELAQECCQEQAQECAgECAQsBDAEBAQEBCgEBAQsBAQEECQ4BCAQCAQEECAEEAQEFCAEPAQEEAQEPAQgBFAEBAQEBAQEKAQEJAQ8BEAEBEwEBAQIBCwEBDgENAwEKAQEBAQELAQEBAQECAQwBCAEBAQEBDgEDAQwBAQECAQEXAQEBAQEHAgEBBQEIAQEBAQEQAgEBBQEUAQEBAQEbAQEBAQEGARQBAQEBARkBAQEBCQEBAQEQAQIBDwEBARQBAQEBBwEBAQkBAQEBAQECAQEBCwECAQEVAQEBAQQBBQEBAQEOAQEBAQEBEgEBFgEBAgEMAQEBAQ8BAQMBFgEBDgEBBQEPAQETAQECAQMOAgUBCgIBGQEBAQEIAQMBBwEBAwECEwgBAQcLAQUBFwEBAQEDAQEBBwEBBAEBDg0BAQwBAQEDAQQBAQEDBAEBBAEBAQEBEAEPAQgBAQsBAQ4BEQEMAgEBBwEOAQEHAQEBAQQBBAEDCwECAQEBAwEBBggBAgEBAREBBQMKAQEBAwQCEQEBHgEPAQIBAQYEAQYBAwEUAQUMAQEBAQEBAQECAQEBAgEIAwEBBgsBAgEODAMBAgEBCwEBAQEBAwECAQECAQEBBwgPAQ=="
}
]
}
86 changes: 86 additions & 0 deletions string_width/_scripts/gen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env -S deno run --allow-net --allow-read --allow-write
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { assertEquals } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts";
import { fromFileUrl } from "https://deno.land/std@$STD_VERSION/path/mod.ts";
import * as toml from "https://deno.land/std@$STD_VERSION/toml/mod.ts";

// To update for parity with a new version of unicode-width crate, bump the
// version number here, re-run this script to update the data, run
// `cargo build` from `string_width/testdata/unicode_width_crate`, and then
// re-run the tests.
const CRATE_VERSION = "0.1.10";

const rs = await (await fetch(
`https://raw.githubusercontent.com/unicode-rs/unicode-width/v${CRATE_VERSION}/src/tables.rs`,
)).text();

function runLengthEncode(arr: number[]) {
const data: number[] = [];
const runLengths: number[] = [];

let prev: symbol | number = Symbol("none");

for (const x of arr) {
if (x === prev) {
++runLengths[runLengths.length - 1];
} else {
prev = x;
data.push(x);
runLengths.push(1);
}
}

return {
d: btoa(String.fromCharCode(...data)),
r: btoa(String.fromCharCode(...runLengths)),
};
}

const data = {
CRATE_VERSION,
UNICODE_VERSION: rs
.match(
/pub const UNICODE_VERSION: \(u8, u8, u8\) = \((\d+), (\d+), (\d+)\);/,
)!
.slice(1)
.join("."),
tables: [] as ReturnType<typeof runLengthEncode>[],
};

for (
const [/* full match */, n, len, content] of [
...rs.matchAll(
/static TABLES_(\d): \[u8; (\d+)\] = \[([^\]]*)\]/g,
),
]
) {
const table = [...content.matchAll(/\w+/g)].flatMap(Number);
assertEquals(table.length, Number(len));

data.tables[Number(n)] = runLengthEncode(table);
}

assertEquals(data.UNICODE_VERSION.split(".").length, 3);
assertEquals(data.tables.length, 3);

const cargoPath = fromFileUrl(
import.meta.resolve(
"https://deno.land/std@$STD_VERSION/string_width/testdata/unicode_width_crate/Cargo.toml",
),
);
const cargo = toml.parse(await Deno.readTextFile(cargoPath)) as {
dependencies: Record<string, string>;
};
cargo.dependencies["unicode-width"] = CRATE_VERSION;

await Deno.writeTextFile(cargoPath, toml.stringify(cargo).trimStart());

await Deno.writeTextFile(
fromFileUrl(
import.meta.resolve(
"https://deno.land/std@$STD_VERSION/string_width/_data.json",
),
),
JSON.stringify(data, null, 2) + "\n",
);
10 changes: 10 additions & 0 deletions string_width/_strip_ansi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
// Ported from chalk/strip-ansi, Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com). MIT License.

export function stripAnsi(str: string) {
return str.replaceAll(
// deno-lint-ignore no-control-regex
/[\x1B\x9B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d\/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d\/#&.:=?%@~_]*)*)?\x07)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g,
"",
);
}
54 changes: 54 additions & 0 deletions string_width/_unicode_width.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
// Ported from unicode_width rust crate, Copyright (c) 2015 The Rust Project Developers. MIT license.

import data from "https://deno.land/std@$STD_VERSION/string_width/_data.json" assert {
type: "json",
};

const runLengthDecode = ({ d, r }: { d: string; r: string }) => {
const data = atob(d);
const runLengths = atob(r);
let out = "";

for (const [i, ch] of [...runLengths].entries()) {
out += data[i].repeat(ch.codePointAt(0)!);
}

return Uint8Array.from([...out].map((x) => x.codePointAt(0)!));
};

let tables: Uint8Array[] | null = null;
function lookupWidth(cp: number) {
if (!tables) tables = data.tables.map(runLengthDecode);

const t1Offset = tables[0][(cp >> 13) & 0xff];
const t2Offset = tables[1][128 * t1Offset + ((cp >> 6) & 0x7f)];
const packedWidths = tables[2][16 * t2Offset + ((cp >> 2) & 0xf)];

const width = (packedWidths >> (2 * (cp & 0b11))) & 0b11;

return width === 3 ? 1 : width;
}

const cache = new Map<string, number | null>();
function charWidth(ch: string) {
if (cache.has(ch)) return cache.get(ch)!;

const cp = ch.codePointAt(0)!;
let v: number | null = null;

if (cp < 0x7f) {
v = cp >= 0x20 ? 1 : cp === 0 ? 0 : null;
} else if (cp >= 0xa0) {
v = lookupWidth(cp);
} else {
v = null;
}

cache.set(ch, v);
return v;
}

export function unicodeWidth(str: string) {
return [...str].map((ch) => charWidth(ch) ?? 0).reduce((a, b) => a + b, 0);
}
23 changes: 23 additions & 0 deletions string_width/string_width.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.

import { stripAnsi } from "https://deno.land/std@$STD_VERSION/string_width/_strip_ansi.ts";
import { unicodeWidth } from "https://deno.land/std@$STD_VERSION/string_width/_unicode_width.ts";

/**
* Get the expected physical column width of a string in TTY-like environments.
*
* @example
* ```ts
* import { stringWidth } from "https://deno.land/std@$STD_VERSION/string_width/string_width.ts";
* import { assertEquals } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts";
*
* assertEquals(stringWidth("hello world"), 11);
* assertEquals(stringWidth("\x1b[36mCYAN\x1b[0m"), 4);
* assertEquals(stringWidth("天地玄黃宇宙洪荒"), 16);
* assertEquals(stringWidth("fullwidth_text"), 28);
* assertEquals(stringWidth("\x1B]8;;https://deno.land\x07Deno 🦕\x1B]8;;\x07"), 7);
* ```
*/
export function stringWidth(str: string) {
return unicodeWidth(stripAnsi(str));
}
Loading

0 comments on commit e3795a5

Please sign in to comment.