From f0411fc5b5d851d6bbbdc19a7354c0f245e7aea7 Mon Sep 17 00:00:00 2001 From: Andrey Sidorov Date: Sun, 4 Oct 2026 20:06:18 +1100 Subject: [PATCH 1/2] feat: setCursor(windowId, name) shows a CSS cursor over a window's client area, where the class cursor was the arrow everywhere and react-x11 asked for a cursor nothing answered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A name maps to the system's cursors — the link hand, the I-beam, the resize arrows, wait, not-allowed — and `none` hides it. The window answers WM_SETCURSOR over its client area with the cursor last set, and shows it at once where the pointer is already there, since the move that changed what is under it has been answered by then. react-x11 calls it for every window and every pane's host, and called it into nothing: no link in a document ever showed the hand on Windows. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/win32.cc | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f8004d8..8fb03b7 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ Roughly by area: | --- | --- | | **drawing** | 36 `ctx*` verbs on a Direct2D device context: paths, arcs, rounded rects, clips (axis-aligned and layered), gradients, shadows, images, `getImageData`/`putImageData`, `drawSurface` | | **text** | DirectWrite layouts with per-span formatting, line metrics, hit testing and carets; font matching, enumeration and loading; glyph runs (`fontHandle`, `fontGlyphForCodepoint`, `fontGlyphAdvances`, `ctxDrawGlyphs`); a layout's coverage without a surface (`layoutCoverage`); variable font axes | -| **windows** | create, show, move, resize, title, popups, transparency, DPI, `scrollRegion`, states (maximized, minimized, fullscreen, above, focused), `windowPixels` | +| **windows** | create, show, move, resize, title, cursor, popups, transparency, DPI, `scrollRegion`, states (maximized, minimized, fullscreen, above, focused), `windowPixels` | | **input** | pointer with all five buttons and capture, wheel, keyboard through `ToUnicodeEx`, activation, IME composition through IMM32 with the preedit left to the renderer | | **drag and drop** | an `IDropTarget` on every window that wants one and `DoDragDrop` out of one, over OLE, with the drop effect decided in JS | | **accessibility** | a UI Automation provider, answered from a mirror of the tree that JS pushes — so a screen reader never waits on a React commit | diff --git a/src/win32.cc b/src/win32.cc index dbe9248..da64b77 100644 --- a/src/win32.cc +++ b/src/win32.cc @@ -87,6 +87,10 @@ struct Window { bool fullscreen = false; LONG_PTR framedStyle = 0; RECT framedRect = {}; + // The cursor shown over the client area: what `setCursor` last named, and + // null for `none`. Read and written on the UI thread alone — WM_SETCURSOR + // reads it, and `setCursor` writes it from a posted command. + HCURSOR cursor = ::LoadCursorW(nullptr, IDC_ARROW); }; std::mutex g_mutex; @@ -315,6 +319,16 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lpara if (window->popup) return MA_NOACTIVATE; break; + // The class cursor is the arrow, and Windows puts it back on every move + // over the client area unless the window answers this. Over the frame — + // a resize edge, the caption — the system's own answer stands. + case WM_SETCURSOR: + if (LOWORD(lparam) == HTCLIENT) { + ::SetCursor(window->cursor); + return TRUE; + } + break; + case WM_MOUSEMOVE: { // Windows sends no "the pointer left" message unless it is asked, once, // per entry. Without it a control keeps its :hover after the pointer has @@ -1239,6 +1253,54 @@ Napi::Value SetTitle(const Napi::CallbackInfo& info) { return info.Env().Undefined(); } +// A CSS cursor name as one of the system's cursors — the names react-x11 +// sets, which are ntk's (X11's cursor font) and CSS's. A name with no shape +// of its own here is the arrow, as an unknown `cursor` is in a browser. The +// hands are one: Windows has the link hand and no open or closed one. +LPCWSTR CursorShape(const std::string& name) { + static const std::map shapes = { + {"pointer", IDC_HAND}, {"hand", IDC_HAND}, + {"grab", IDC_HAND}, {"grabbing", IDC_HAND}, + {"text", IDC_IBEAM}, {"vertical-text", IDC_IBEAM}, + {"wait", IDC_WAIT}, {"progress", IDC_APPSTARTING}, + {"move", IDC_SIZEALL}, {"all-scroll", IDC_SIZEALL}, + {"crosshair", IDC_CROSS}, {"cell", IDC_CROSS}, + {"help", IDC_HELP}, {"not-allowed", IDC_NO}, + {"no-drop", IDC_NO}, {"ew-resize", IDC_SIZEWE}, + {"col-resize", IDC_SIZEWE}, {"e-resize", IDC_SIZEWE}, + {"w-resize", IDC_SIZEWE}, {"ns-resize", IDC_SIZENS}, + {"row-resize", IDC_SIZENS}, {"n-resize", IDC_SIZENS}, + {"s-resize", IDC_SIZENS}, {"nwse-resize", IDC_SIZENWSE}, + {"nw-resize", IDC_SIZENWSE}, {"se-resize", IDC_SIZENWSE}, + {"nesw-resize", IDC_SIZENESW}, {"ne-resize", IDC_SIZENESW}, + {"sw-resize", IDC_SIZENESW}, + }; + auto it = shapes.find(name); + return it == shapes.end() ? IDC_ARROW : it->second; +} + +// setCursor(windowId, name): the cursor over the window's client area, by its +// CSS name, `none` for none. Shown at once where the pointer is already over +// the client area — the move that changed what is under it has been answered +// by then — and from then on on every WM_SETCURSOR. +Napi::Value SetCursorShape(const Napi::CallbackInfo& info) { + const int id = info[0].As().Int32Value(); + if (!LookupWindow(id)) return info.Env().Undefined(); + const std::string name = info.Length() > 1 && info[1].IsString() + ? info[1].As().Utf8Value() + : std::string("default"); + PostCommand([id, name]() { + Window* window = LookupWindow(id); + if (!window || !window->hwnd) return; + window->cursor = name == "none" ? nullptr : ::LoadCursorW(nullptr, CursorShape(name)); + POINT at; + if (!::GetCursorPos(&at) || ::WindowFromPoint(at) != window->hwnd) return; + const LRESULT hit = ::SendMessageW(window->hwnd, WM_NCHITTEST, 0, MAKELPARAM(at.x, at.y)); + if (hit == HTCLIENT) ::SetCursor(window->cursor); + }); + return info.Env().Undefined(); +} + Napi::Value ResizeWindow(const Napi::CallbackInfo& info) { Window* window = LookupWindow(info[0].As().Int32Value()); if (!window) return info.Env().Undefined(); @@ -1427,6 +1489,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("commit", Napi::Function::New(env, Commit)); exports.Set("resize", Napi::Function::New(env, Resize)); exports.Set("setTitle", Napi::Function::New(env, SetTitle)); + exports.Set("setCursor", Napi::Function::New(env, SetCursorShape)); exports.Set("resizeWindow", Napi::Function::New(env, ResizeWindow)); exports.Set("moveWindow", Napi::Function::New(env, MoveWindow)); exports.Set("windowState", Napi::Function::New(env, WindowState)); From c2b38b791217d64e060cd1e28b4cbfaf3798175c Mon Sep 17 00:00:00 2001 From: Andrey Sidorov Date: Sun, 4 Oct 2026 20:06:18 +1100 Subject: [PATCH 2/2] =?UTF-8?q?feat(text):=20a=20GDI=20face=20name=20reach?= =?UTF-8?q?es=20its=20face=20(`fontFamilyOf`),=20a=20span=20and=20a=20face?= =?UTF-8?q?=20take=20a=20width,=20a=20span=20its=20letter=20spacing=20and?= =?UTF-8?q?=20OpenType=20features,=20and=20a=20face's=20metrics=20carry=20?= =?UTF-8?q?its=20x-height=20and=20cap=20height=20=E2=80=94=20where=20Arial?= =?UTF-8?q?=20Black=20and=20Segoe=20UI=20Light=20fell=20back=20to=20the=20?= =?UTF-8?q?default=20face,=20`letter-spacing`=20was=20set=20tight=20and=20?= =?UTF-8?q?an=20`ex`=20had=20no=20face=20to=20be=20measured=20in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `fontFamilyOf(name)`: a name the system collection has no family by, looked up as GDI looks a face name up (`CreateFontFromLOGFONT`), and answered as the family DirectWrite files the face under and the face's weight, width and slope. DirectWrite groups faces by those three, so the families a Windows user knows by name — Arial Black, Arial Narrow, Segoe UI Light, Franklin Gothic Medium — are faces of others here, and every other Windows program, Chrome among them, finds them. - A width (`stretch`, 1–9) on `layoutCreate`'s options and spans, and as the fifth argument of `fontMetrics` and `fontHandle`: what reaches a narrow face. - `letterSpacing` on a span, through `SetCharacterSpacing`, and `features` by tag, through an `IDWriteTypography`. - `xHeight` and `capHeight` from `fontMetrics`, what CSS's `ex` and `cap` are measured in. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/text.cc | 146 +++++++++++++++++++++++++++++++++++++++++++++++++-- test/text.js | 49 +++++++++++++++++ 3 files changed, 191 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8fb03b7..85c360f 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Roughly by area: | | | | --- | --- | | **drawing** | 36 `ctx*` verbs on a Direct2D device context: paths, arcs, rounded rects, clips (axis-aligned and layered), gradients, shadows, images, `getImageData`/`putImageData`, `drawSurface` | -| **text** | DirectWrite layouts with per-span formatting, line metrics, hit testing and carets; font matching, enumeration and loading; glyph runs (`fontHandle`, `fontGlyphForCodepoint`, `fontGlyphAdvances`, `ctxDrawGlyphs`); a layout's coverage without a surface (`layoutCoverage`); variable font axes | +| **text** | DirectWrite layouts with per-span formatting, line metrics, hit testing and carets; font matching — a GDI face name such as Arial Black reached through its family, weight and width (`fontFamilyOf`) — enumeration and loading; glyph runs (`fontHandle`, `fontGlyphForCodepoint`, `fontGlyphAdvances`, `ctxDrawGlyphs`); a layout's coverage without a surface (`layoutCoverage`); variable font axes | | **windows** | create, show, move, resize, title, cursor, popups, transparency, DPI, `scrollRegion`, states (maximized, minimized, fullscreen, above, focused), `windowPixels` | | **input** | pointer with all five buttons and capture, wheel, keyboard through `ToUnicodeEx`, activation, IME composition through IMM32 with the preedit left to the renderer | | **drag and drop** | an `IDropTarget` on every window that wants one and `DoDragDrop` out of one, over OLE, with the drop effect decided in JS | diff --git a/src/text.cc b/src/text.cc index 07e864c..1b5c47c 100644 --- a/src/text.cc +++ b/src/text.cc @@ -63,6 +63,19 @@ DWRITE_FONT_WEIGHT WeightOf(int weight) { return static_cast(std::clamp(weight, 1, 999)); } +// DirectWrite's nine widths, 1 (ultra-condensed) to 9 (ultra-expanded), with +// 5 normal — CSS's `font-stretch` keywords in the same order. +DWRITE_FONT_STRETCH StretchOf(int stretch) { + return static_cast(std::clamp(stretch, 1, 9)); +} + +// An optional width argument: absent, or not a number, is normal. +DWRITE_FONT_STRETCH StretchArg(const Napi::CallbackInfo& info, size_t at) { + return info.Length() > at && info[at].IsNumber() + ? StretchOf(info[at].As().Int32Value()) + : DWRITE_FONT_STRETCH_NORMAL; +} + // The generic families, resolved the way docs/windows.md names them. Segoe UI // is the system face on every supported build; Segoe UI Variable on Windows 11 // carries an optical-size axis the way SF does. @@ -483,6 +496,47 @@ bool ApplyAxes(IDWriteTextLayout* layout, const Napi::Value& value, return SUCCEEDED(hr); } +// A span's OpenType features, `{ smcp: 1, liga: 0 }`: a tag and a value each +// — 1 on, 0 off, or an alternate's index — as CSS's `font-feature-settings` +// writes them, and as react-x11's other two engines take them. The shaper's +// own defaults stay on but where a tag here says otherwise. +void ApplyFeatures(IDWriteTextLayout* layout, const Napi::Value& value, + DWRITE_TEXT_RANGE range) { + if (!value.IsObject()) return; + const Napi::Object features = value.As(); + IDWriteTypography* typography = nullptr; + if (FAILED(g_dwrite->CreateTypography(&typography)) || !typography) return; + const Napi::Array tags = features.GetPropertyNames(); + UINT32 added = 0; + for (uint32_t i = 0; i < tags.Length(); i++) { + const std::string tag = tags.Get(i).As().Utf8Value(); + if (tag.size() != 4) continue; + const Napi::Value raw = features.Get(tags.Get(i)); + if (!raw.IsNumber()) continue; + DWRITE_FONT_FEATURE feature = {}; + feature.nameTag = static_cast( + DWRITE_MAKE_OPENTYPE_TAG(tag[0], tag[1], tag[2], tag[3])); + feature.parameter = raw.As().Uint32Value(); + if (SUCCEEDED(typography->AddFontFeature(feature))) added++; + } + if (added) layout->SetTypography(typography, range); + typography->Release(); +} + +// A span's letter spacing, CSS's: that many pixels after each character, +// the last of the span included, as Blink spaces a run. +void ApplySpacing(IDWriteTextLayout* layout, float spacing, DWRITE_TEXT_RANGE range) { + if (spacing == 0 || !std::isfinite(spacing)) return; + IDWriteTextLayout1* layout1 = nullptr; + if (FAILED(layout->QueryInterface(__uuidof(IDWriteTextLayout1), + reinterpret_cast(&layout1))) || + !layout1) { + return; + } + layout1->SetCharacterSpacing(0, spacing, 0, range); + layout1->Release(); +} + Napi::Value LayoutCreate(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); if (!g_dwrite) { @@ -502,6 +556,10 @@ Napi::Value LayoutCreate(const Napi::CallbackInfo& info) { const int weight = Given(options, "weight") ? options.Get("weight").As().Int32Value() : 400; const bool italic = Given(options, "italic") && options.Get("italic").ToBoolean().Value(); + const DWRITE_FONT_STRETCH stretch = + Given(options, "stretch") + ? StretchOf(options.Get("stretch").As().Int32Value()) + : DWRITE_FONT_STRETCH_NORMAL; const bool rtl = Given(options, "rtl") && options.Get("rtl").ToBoolean().Value(); float maxWidth = Given(options, "maxWidth") ? static_cast(options.Get("maxWidth").As().DoubleValue()) @@ -515,8 +573,8 @@ Napi::Value LayoutCreate(const Napi::CallbackInfo& info) { IDWriteTextFormat* format = nullptr; HRESULT hr = g_dwrite->CreateTextFormat( family.c_str(), CollectionFor(family), WeightOf(weight), - italic ? DWRITE_FONT_STYLE_ITALIC : DWRITE_FONT_STYLE_NORMAL, - DWRITE_FONT_STRETCH_NORMAL, size, L"", &format); + italic ? DWRITE_FONT_STYLE_ITALIC : DWRITE_FONT_STYLE_NORMAL, stretch, size, L"", + &format); if (FAILED(hr) || !format) { Napi::Error::New(env, "layoutCreate: CreateTextFormat failed") .ThrowAsJavaScriptException(); @@ -596,6 +654,16 @@ Napi::Value LayoutCreate(const Napi::CallbackInfo& info) { : DWRITE_FONT_STYLE_NORMAL, range); } + if (Given(span, "stretch")) { + layout->SetFontStretch(StretchOf(span.Get("stretch").As().Int32Value()), + range); + } + if (Given(span, "letterSpacing")) { + ApplySpacing(layout, + static_cast(span.Get("letterSpacing").As().DoubleValue()), + range); + } + if (Given(span, "features")) ApplyFeatures(layout, span.Get("features"), range); if (Given(span, "variations")) { ApplyAxes(layout, span.Get("variations"), range); } @@ -1016,7 +1084,7 @@ Napi::Value FontMetrics(const Napi::CallbackInfo& info) { IDWriteFont* font = nullptr; fontFamily->GetFirstMatchingFont( WeightOf(info.Length() > 2 ? info[2].As().Int32Value() : 400), - DWRITE_FONT_STRETCH_NORMAL, + StretchArg(info, 4), info.Length() > 3 && info[3].ToBoolean().Value() ? DWRITE_FONT_STYLE_ITALIC : DWRITE_FONT_STYLE_NORMAL, &font); @@ -1034,6 +1102,11 @@ Napi::Value FontMetrics(const Napi::CallbackInfo& info) { out.Set("lineGap", Napi::Number::New(env, metrics.lineGap * scale)); out.Set("height", Napi::Number::New( env, (metrics.ascent + metrics.descent + metrics.lineGap) * scale)); + // The x-height and the cap height, which CSS's `ex` and `cap` units are + // measured in. DirectWrite synthesizes an x-height for a face whose OS/2 + // table states none, as Blink's own fallback does. + out.Set("xHeight", Napi::Number::New(env, metrics.xHeight * scale)); + out.Set("capHeight", Napi::Number::New(env, metrics.capHeight * scale)); out.Set("underlinePosition", Napi::Number::New(env, metrics.underlinePosition * scale)); out.Set("underlineThickness", @@ -1061,6 +1134,67 @@ Napi::Value FontExists(const Napi::CallbackInfo& info) { return Napi::Boolean::New(env, exists == TRUE); } +// fontFamilyOf(name) -> { family, weight, stretch, italic } | null: a name the +// system collection has no family by, looked up as GDI looks a face name up. +// +// DirectWrite groups faces by weight, width and slope, so the families a +// Windows user knows by name — "Arial Black", "Arial Narrow", "Segoe UI +// Light", "Franklin Gothic Medium" — are faces of "Arial", "Segoe UI" and +// "Franklin Gothic" here, and `fontExists` says no to each. Every other +// Windows program finds them, Chrome among them, and a page that names one +// fell back to the default face. This answers the family DirectWrite files +// the face under and the face's own weight, width and slope, which together +// reach it. Null for a name GDI has no face by either. +Napi::Value FontFamilyOf(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (!g_dwrite || info.Length() < 1 || !info[0].IsString()) return env.Null(); + const std::wstring wanted = Wide(info[0]); + if (wanted.empty() || wanted.size() >= LF_FACESIZE) return env.Null(); + + IDWriteGdiInterop* interop = nullptr; + if (FAILED(g_dwrite->GetGdiInterop(&interop)) || !interop) return env.Null(); + LOGFONTW logfont = {}; + wcsncpy_s(logfont.lfFaceName, wanted.c_str(), LF_FACESIZE - 1); + logfont.lfWeight = FW_NORMAL; + logfont.lfCharSet = DEFAULT_CHARSET; + IDWriteFont* font = nullptr; + const HRESULT hr = interop->CreateFontFromLOGFONT(&logfont, &font); + interop->Release(); + if (FAILED(hr) || !font) return env.Null(); + + IDWriteFontFamily* family = nullptr; + IDWriteLocalizedStrings* names = nullptr; + std::wstring name; + if (SUCCEEDED(font->GetFontFamily(&family)) && family && + SUCCEEDED(family->GetFamilyNames(&names)) && names) { + UINT32 index = 0; + BOOL found = FALSE; + names->FindLocaleName(L"en-us", &index, &found); + if (!found) index = 0; + UINT32 length = 0; + if (SUCCEEDED(names->GetStringLength(index, &length))) { + name.resize(length + 1); + names->GetString(index, name.data(), length + 1); + name.resize(length); + } + } + if (names) names->Release(); + if (family) family->Release(); + const DWRITE_FONT_WEIGHT weight = font->GetWeight(); + const DWRITE_FONT_STRETCH stretch = font->GetStretch(); + const DWRITE_FONT_STYLE style = font->GetStyle(); + font->Release(); + if (name.empty()) return env.Null(); + + Napi::Object out = Napi::Object::New(env); + out.Set("family", Napi::String::New(env, reinterpret_cast(name.c_str()), + name.size())); + out.Set("weight", Napi::Number::New(env, static_cast(weight))); + out.Set("stretch", Napi::Number::New(env, static_cast(stretch))); + out.Set("italic", Napi::Boolean::New(env, style != DWRITE_FONT_STYLE_NORMAL)); + return out; +} + // The path a face was loaded from, where it came from a file at all. A font // supplied as bytes has no path and the catalogue leaves it out, which is the // same answer fontconfig gives for a memory face. @@ -1250,10 +1384,11 @@ Napi::Value FontHandle(const Napi::CallbackInfo& info) { const float size = static_cast(info[1].As().DoubleValue()); const int weight = info.Length() > 2 ? info[2].As().Int32Value() : 400; const bool italic = info.Length() > 3 && info[3].ToBoolean().Value(); + const DWRITE_FONT_STRETCH stretch = StretchArg(info, 4); if (!(size > 0)) return Napi::Number::New(env, 0); wchar_t key[64] = {}; - swprintf(key, 64, L"|%d|%d|%d", weight, italic ? 1 : 0, + swprintf(key, 64, L"|%d|%d|%d|%d", weight, italic ? 1 : 0, static_cast(stretch), static_cast(size * 64)); const std::wstring cacheKey = family + key; auto cached = g_glyphFontIds.find(cacheKey); @@ -1288,7 +1423,7 @@ Napi::Value FontHandle(const Napi::CallbackInfo& info) { IDWriteFont* font = nullptr; fontFamily->GetFirstMatchingFont( - WeightOf(weight), DWRITE_FONT_STRETCH_NORMAL, + WeightOf(weight), stretch, italic ? DWRITE_FONT_STYLE_ITALIC : DWRITE_FONT_STYLE_NORMAL, &font); fontFamily->Release(); if (!font) return Napi::Number::New(env, 0); @@ -1462,6 +1597,7 @@ void InitTextExports(Napi::Env env, Napi::Object exports) { set("layoutCoverage", LayoutCoverage); set("fontMetrics", FontMetrics); set("fontExists", FontExists); + set("fontFamilyOf", FontFamilyOf); set("listFonts", ListFonts); set("fontLoad", FontLoad); set("fontHandle", FontHandle); diff --git a/test/text.js b/test/text.js index 8150438..b95cb17 100644 --- a/test/text.js +++ b/test/text.js @@ -44,6 +44,55 @@ assert.ok(fontMetrics.ascent > 0 && fontMetrics.descent > 0, 'no face metrics'); assert.strictEqual(win32.fontExists('Segoe UI'), true); assert.strictEqual(win32.fontExists('No Such Font At All'), false); +// What `ex` and `cap` are measured in. +assert.ok( + fontMetrics.xHeight > 0 && fontMetrics.xHeight < fontMetrics.ascent, + `no x-height: ${fontMetrics.xHeight}`, +); +assert.ok( + fontMetrics.capHeight > fontMetrics.xHeight, + `no cap height: ${fontMetrics.capHeight}`, +); + +// A face GDI knows by a name DirectWrite files under another family: Segoe +// UI Semibold is Segoe UI at 600 here, and no family of its own. Segoe UI +// ships with every supported build, and its Semibold face with it. +assert.strictEqual(win32.fontExists('Segoe UI Semibold'), false); +const semibold = win32.fontFamilyOf('Segoe UI Semibold'); +console.log('Segoe UI Semibold:', semibold); +assert.strictEqual(semibold?.family, 'Segoe UI'); +assert.strictEqual(semibold.weight, 600); +assert.strictEqual(semibold.stretch, 5); +assert.strictEqual(semibold.italic, false); +assert.strictEqual(win32.fontFamilyOf('No Such Font At All'), null); + +// A width reaches a face by it, and is part of what a handle is cached on. +const normalHandle = win32.fontHandle('Segoe UI', 16, 400, false); +const narrowHandle = win32.fontHandle('Segoe UI', 16, 400, false, 3); +assert.ok(normalHandle > 0 && narrowHandle > 0, 'no glyph handles'); +assert.strictEqual(win32.fontHandle('Segoe UI', 16, 400, false, 5), normalHandle); +const condensed = win32.layoutMetrics( + win32.layoutCreate('Narrow', { family: 'Segoe UI', size: 16 }, [ + { start: 0, length: 6, stretch: 3 }, + ]), +); +assert.ok(condensed.width > 0, 'a span with a width laid out nothing'); + +// Letter spacing: two pixels after each of five characters is ten pixels +// wider, as CSS's `letter-spacing` sets a run. +const widthOf = (spans) => + win32.layoutMetrics(win32.layoutCreate('Hello', { family: 'Segoe UI', size: 16 }, spans)) + .width; +const tight = widthOf([{ start: 0, length: 5 }]); +const spaced = widthOf([{ start: 0, length: 5, letterSpacing: 2 }]); +console.log('letter spacing:', tight, '->', spaced); +assert.ok(Math.abs(spaced - tight - 10) <= 1, `spaced by ${spaced - tight}, not 10`); +// OpenType features by tag: a layout with them is a layout +assert.ok( + widthOf([{ start: 0, length: 5, features: { smcp: 1, liga: 0, 'bad!!': 1 } }]) > 0, + 'a span with features laid out nothing', +); + // --- drawing through the verb table ---------------------------------------- const surface = win32.createSurface(200, 100, 1);