Skip to content

Commit b65c5ea

Browse files
committed
Add Feature object
Add Feature object with toString()/fromString() and use it in shape() instead of passing comma-separated feature string. Fixes #201
1 parent f992a55 commit b65c5ea

7 files changed

Lines changed: 189 additions & 25 deletions

File tree

MIGRATING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ When you need the raw HarfBuzz JSON output (e.g. to capture all fields including
6868
Affected APIs:
6969

7070
- `Buffer.serialize` now takes a single options object: `{ font, start, end, format, flags }` (all optional). The previous positional signature is gone.
71+
- `shape` and `shapeWithTrace` now take `Feature[]` instead of a comma-separated string. Use the new `Feature` class (e.g. `new Feature("liga", 0)` or `Feature.fromString("-liga")`).
7172
- `Buffer.addText` / `Buffer.addCodePoints`: `itemLength` accepts `undefined` (or omission), instead of `null`.
7273
- `Face.getFeatureNameIds`: returns `undefined` on failure, instead of `null`.
7374
- `Font.glyphHOrigin` / `glyphVOrigin` / `glyphExtents` / `glyphFromName`: return `undefined` on failure, instead of `null`.

examples/harfbuzz.example.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ function example(hb, fontBlob, text) {
1010
buffer.addText(text || "abc");
1111
buffer.guessSegmentProperties();
1212
// buffer.setDirection(hb.Direction.LTR); // optional as can be set by guessSegmentProperties also
13-
hb.shape(font, buffer); // features are not supported yet
13+
hb.shape(font, buffer);
1414
var result = buffer.getGlyphInfosAndPositions();
1515

1616
// returns glyphs paths, totally optional

harfbuzz.symbols

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ _hb_ot_tag_to_script
8484
_hb_ot_var_get_axis_infos
8585
_hb_script_from_string
8686
_hb_feature_from_string
87+
_hb_feature_to_string
8788
_hb_language_from_string
8889
_hb_language_to_string
8990
_hb_set_create

src/feature.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import {
2+
Module,
3+
exports,
4+
hb_tag,
5+
hb_untag,
6+
string_to_ascii_ptr,
7+
utf8_ptr_to_string,
8+
} from "./helpers";
9+
10+
/**
11+
* A {@link https://harfbuzz.github.io/harfbuzz-hb-common.html#hb-feature-t | HarfBuzz feature}.
12+
*
13+
* The structure that holds information about requested feature application.
14+
* The feature will be applied with the given value to all glyphs which are in
15+
* clusters between {@link Feature.start} (inclusive) and {@link Feature.end}
16+
* (exclusive). Setting `start` to `0` and `end` to `0xffffffff` specifies that
17+
* the feature always applies to the entire buffer.
18+
*/
19+
export class Feature {
20+
/**
21+
* Special setting for {@link Feature.start} to apply the feature from the
22+
* start of the buffer.
23+
*/
24+
static readonly GLOBAL_START = 0;
25+
26+
/**
27+
* Special setting for {@link Feature.end} to apply the feature from to the
28+
* end of the buffer.
29+
*/
30+
static readonly GLOBAL_END = 0xffffffff;
31+
32+
/** The tag of the feature. */
33+
tag: string;
34+
/**
35+
* The value of the feature. `0` disables the feature, non-zero (usually `1`)
36+
* enables the feature. For features implemented as lookup type 3 (like
37+
* `salt`) the value is a one-based index into the alternates.
38+
*/
39+
value: number;
40+
/** The cluster to start applying this feature setting (inclusive). */
41+
start: number;
42+
/** The cluster to end applying this feature setting (exclusive). */
43+
end: number;
44+
45+
constructor(
46+
tag: string,
47+
value: number = 1,
48+
start: number = Feature.GLOBAL_START,
49+
end: number = Feature.GLOBAL_END,
50+
) {
51+
this.tag = tag;
52+
this.value = value;
53+
this.start = start;
54+
this.end = end;
55+
}
56+
57+
/**
58+
* Parses a string into a Feature.
59+
*
60+
* The format for specifying feature strings follows. All valid CSS
61+
* font-feature-settings values other than `normal` and the global values are
62+
* also accepted, though not documented below. CSS string escapes are not
63+
* supported.
64+
*
65+
* The range indices refer to the positions between Unicode characters. The
66+
* position before the first character is always 0.
67+
*
68+
* The format is Python-esque. Here is how it all works:
69+
*
70+
* | Syntax | Value | Start | End | Meaning |
71+
* | ------------- | ----- | ----- | --- | -------------------------------- |
72+
* | `kern` | 1 | 0 | ∞ | Turn feature on |
73+
* | `+kern` | 1 | 0 | ∞ | Turn feature on |
74+
* | `-kern` | 0 | 0 | ∞ | Turn feature off |
75+
* | `kern=0` | 0 | 0 | ∞ | Turn feature off |
76+
* | `kern=1` | 1 | 0 | ∞ | Turn feature on |
77+
* | `aalt=2` | 2 | 0 | ∞ | Choose 2nd alternate |
78+
* | `kern[]` | 1 | 0 | ∞ | Turn feature on |
79+
* | `kern[:]` | 1 | 0 | ∞ | Turn feature on |
80+
* | `kern[5:]` | 1 | 5 | ∞ | Turn feature on, partial |
81+
* | `kern[:5]` | 1 | 0 | 5 | Turn feature on, partial |
82+
* | `kern[3:5]` | 1 | 3 | 5 | Turn feature on, range |
83+
* | `kern[3]` | 1 | 3 | 3+1 | Turn feature on, single char |
84+
* | `aalt[3:5]=2` | 2 | 3 | 5 | Turn 2nd alternate on for range |
85+
*
86+
* @param str The string to parse.
87+
* @returns A Feature, or undefined if the string is not a valid feature.
88+
*/
89+
static fromString(str: string): Feature | undefined {
90+
const sp = Module.stackSave();
91+
const featurePtr = Module.stackAlloc(16);
92+
const strPtr = string_to_ascii_ptr(str);
93+
let feature: Feature | undefined;
94+
if (exports.hb_feature_from_string(strPtr.ptr, -1, featurePtr)) {
95+
feature = new Feature(
96+
hb_untag(Module.HEAPU32[featurePtr / 4]),
97+
Module.HEAPU32[featurePtr / 4 + 1],
98+
Module.HEAPU32[featurePtr / 4 + 2],
99+
Module.HEAPU32[featurePtr / 4 + 3],
100+
);
101+
}
102+
strPtr.free();
103+
Module.stackRestore(sp);
104+
return feature;
105+
}
106+
107+
/**
108+
* Converts the feature to a string in the format understood by
109+
* {@link Feature.fromString}.
110+
*
111+
* Note that the feature value will be omitted if it is `1`, but the string
112+
* won't include any whitespace.
113+
*
114+
* @returns The feature string.
115+
*/
116+
toString(): string {
117+
const sp = Module.stackSave();
118+
const featurePtr = Module.stackAlloc(16);
119+
this.writeTo(featurePtr);
120+
const bufLen = 128;
121+
const bufPtr = Module.stackAlloc(bufLen);
122+
exports.hb_feature_to_string(featurePtr, bufPtr, bufLen);
123+
const result = utf8_ptr_to_string(bufPtr);
124+
Module.stackRestore(sp);
125+
return result;
126+
}
127+
128+
/** @internal Write this feature into the given hb_feature_t pointer. */
129+
writeTo(ptr: number): void {
130+
Module.HEAPU32[ptr / 4] = hb_tag(this.tag);
131+
Module.HEAPU32[ptr / 4 + 1] = this.value;
132+
Module.HEAPU32[ptr / 4 + 2] = this.start;
133+
Module.HEAPU32[ptr / 4 + 3] = this.end;
134+
}
135+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from "./face";
77
export * from "./font";
88
export * from "./font-funcs";
99
export * from "./buffer";
10+
export * from "./feature";
1011
export * from "./shape";
1112

1213
init(await createHarfBuzz());

src/shape.ts

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ import {
44
hb_tag,
55
hb_untag,
66
utf8_ptr_to_string,
7-
string_to_ascii_ptr,
87
language_to_string,
98
type ValueOf,
109
} from "./helpers";
1110
import type { TraceEntry } from "./types";
1211
import type { Font } from "./font";
12+
import type { Feature } from "./feature";
1313
import {
1414
Buffer,
1515
BufferContentType,
@@ -33,30 +33,20 @@ export type TracePhase = ValueOf<typeof TracePhase>;
3333
* @param font The Font to shape with.
3434
* @param buffer The Buffer containing text to shape, suitably prepared
3535
* (text added, segment properties set).
36-
* @param features A string of comma-separated OpenType features to apply.
36+
* @param features An array of {@link Feature} values to apply.
3737
*/
38-
export function shape(font: Font, buffer: Buffer, features?: string): void {
38+
export function shape(font: Font, buffer: Buffer, features?: Feature[]): void {
39+
const featuresLen = features?.length ?? 0;
40+
const sp = Module.stackSave();
3941
let featuresPtr = 0;
40-
let featuresLen = 0;
41-
if (features) {
42-
const featureList = features.split(",");
43-
featuresPtr = exports.malloc(16 * featureList.length);
44-
featureList.forEach((feature) => {
45-
const str = string_to_ascii_ptr(feature);
46-
if (
47-
exports.hb_feature_from_string(
48-
str.ptr,
49-
-1,
50-
featuresPtr + featuresLen * 16,
51-
)
52-
)
53-
featuresLen++;
54-
str.free();
42+
if (featuresLen) {
43+
featuresPtr = Module.stackAlloc(16 * featuresLen);
44+
features!.forEach((feature, i) => {
45+
feature.writeTo(featuresPtr + i * 16);
5546
});
5647
}
57-
5848
exports.hb_shape(font.ptr, buffer.ptr, featuresPtr, featuresLen);
59-
if (featuresPtr) exports.free(featuresPtr);
49+
Module.stackRestore(sp);
6050
}
6151

6252
/**
@@ -67,15 +57,15 @@ export function shape(font: Font, buffer: Buffer, features?: string): void {
6757
*
6858
* @param font The Font to shape with.
6959
* @param buffer The Buffer containing text to shape, suitably prepared.
70-
* @param features A string of comma-separated OpenType features to apply.
60+
* @param features An array of {@link Feature} values to apply.
7161
* @param stop_at A lookup ID at which to terminate shaping.
7262
* @param stop_phase The {@link TracePhase} at which to stop shaping.
7363
* @returns An array of trace entries, each with a message, serialized glyphs, and phase info.
7464
*/
7565
export function shapeWithTrace(
7666
font: Font,
7767
buffer: Buffer,
78-
features: string,
68+
features: Feature[],
7969
stop_at: number,
8070
stop_phase: TracePhase,
8171
): TraceEntry[] {

test/index.test.js

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1080,6 +1080,42 @@ describe("Buffer", function () {
10801080
});
10811081
});
10821082

1083+
describe("Feature", function () {
1084+
it("fromString parses simple tags", function () {
1085+
expect(hb.Feature.fromString("liga")).to.deep.equal(
1086+
new hb.Feature("liga", 1, 0, 0xffffffff),
1087+
);
1088+
expect(hb.Feature.fromString("-kern")).to.deep.equal(
1089+
new hb.Feature("kern", 0, 0, 0xffffffff),
1090+
);
1091+
});
1092+
1093+
it("fromString parses values and ranges", function () {
1094+
expect(hb.Feature.fromString("salt=2")).to.deep.equal(
1095+
new hb.Feature("salt", 2, 0, 0xffffffff),
1096+
);
1097+
expect(hb.Feature.fromString("aalt[3:5]=2")).to.deep.equal(
1098+
new hb.Feature("aalt", 2, 3, 5),
1099+
);
1100+
});
1101+
1102+
it("fromString returns undefined for invalid input", function () {
1103+
expect(hb.Feature.fromString("not a feature")).to.equal(undefined);
1104+
});
1105+
1106+
it("toString round-trips canonical forms", function () {
1107+
for (const str of ["liga", "-kern", "salt=2", "aalt[3:5]=2"]) {
1108+
expect(hb.Feature.fromString(str).toString()).to.equal(str);
1109+
}
1110+
});
1111+
1112+
it("toString uses default range and value", function () {
1113+
expect(new hb.Feature("liga").toString()).to.equal("liga");
1114+
expect(new hb.Feature("kern", 0).toString()).to.equal("-kern");
1115+
expect(new hb.Feature("salt", 2).toString()).to.equal("salt=2");
1116+
});
1117+
});
1118+
10831119
describe("shape", function () {
10841120
it("shape Latin string", function () {
10851121
let blob = new hb.Blob(
@@ -1267,7 +1303,7 @@ describe("shape", function () {
12671303
const result = hb.shapeWithTrace(
12681304
font,
12691305
buffer,
1270-
"",
1306+
[],
12711307
0,
12721308
hb.TracePhase.DONT_STOP,
12731309
);
@@ -1304,7 +1340,7 @@ describe("shape", function () {
13041340
const result = hb.shapeWithTrace(
13051341
font,
13061342
buffer,
1307-
"-liga,-kern",
1343+
[new hb.Feature("liga", 0), new hb.Feature("kern", 0)],
13081344
0,
13091345
hb.TracePhase.DONT_STOP,
13101346
);

0 commit comments

Comments
 (0)