diff --git a/README.md b/README.md index f8004d8..85c360f 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,8 @@ 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` | +| **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 | | **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/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/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)); 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);