Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
146 changes: 141 additions & 5 deletions src/text.cc
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ DWRITE_FONT_WEIGHT WeightOf(int weight) {
return static_cast<DWRITE_FONT_WEIGHT>(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<DWRITE_FONT_STRETCH>(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<Napi::Number>().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.
Expand Down Expand Up @@ -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<Napi::Object>();
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<Napi::String>().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_FONT_FEATURE_TAG>(
DWRITE_MAKE_OPENTYPE_TAG(tag[0], tag[1], tag[2], tag[3]));
feature.parameter = raw.As<Napi::Number>().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<void**>(&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) {
Expand All @@ -502,6 +556,10 @@ Napi::Value LayoutCreate(const Napi::CallbackInfo& info) {
const int weight =
Given(options, "weight") ? options.Get("weight").As<Napi::Number>().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<Napi::Number>().Int32Value())
: DWRITE_FONT_STRETCH_NORMAL;
const bool rtl = Given(options, "rtl") && options.Get("rtl").ToBoolean().Value();
float maxWidth = Given(options, "maxWidth")
? static_cast<float>(options.Get("maxWidth").As<Napi::Number>().DoubleValue())
Expand All @@ -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();
Expand Down Expand Up @@ -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<Napi::Number>().Int32Value()),
range);
}
if (Given(span, "letterSpacing")) {
ApplySpacing(layout,
static_cast<float>(span.Get("letterSpacing").As<Napi::Number>().DoubleValue()),
range);
}
if (Given(span, "features")) ApplyFeatures(layout, span.Get("features"), range);
if (Given(span, "variations")) {
ApplyAxes(layout, span.Get("variations"), range);
}
Expand Down Expand Up @@ -1016,7 +1084,7 @@ Napi::Value FontMetrics(const Napi::CallbackInfo& info) {
IDWriteFont* font = nullptr;
fontFamily->GetFirstMatchingFont(
WeightOf(info.Length() > 2 ? info[2].As<Napi::Number>().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);
Expand All @@ -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",
Expand Down Expand Up @@ -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<const char16_t*>(name.c_str()),
name.size()));
out.Set("weight", Napi::Number::New(env, static_cast<int>(weight)));
out.Set("stretch", Napi::Number::New(env, static_cast<int>(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.
Expand Down Expand Up @@ -1250,10 +1384,11 @@ Napi::Value FontHandle(const Napi::CallbackInfo& info) {
const float size = static_cast<float>(info[1].As<Napi::Number>().DoubleValue());
const int weight = info.Length() > 2 ? info[2].As<Napi::Number>().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<int>(stretch),
static_cast<int>(size * 64));
const std::wstring cacheKey = family + key;
auto cached = g_glyphFontIds.find(cacheKey);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
63 changes: 63 additions & 0 deletions src/win32.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<std::string, LPCWSTR> 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<Napi::Number>().Int32Value();
if (!LookupWindow(id)) return info.Env().Undefined();
const std::string name = info.Length() > 1 && info[1].IsString()
? info[1].As<Napi::String>().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<Napi::Number>().Int32Value());
if (!window) return info.Env().Undefined();
Expand Down Expand Up @@ -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));
Expand Down
49 changes: 49 additions & 0 deletions test/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading