From 2cb86db101c08a527f7228614fc2e565a59b67c1 Mon Sep 17 00:00:00 2001 From: Andrey Sidorov Date: Sun, 4 Oct 2026 21:24:34 +1100 Subject: [PATCH] =?UTF-8?q?feat(text):=20`justify:=20true`=20justifies=20a?= =?UTF-8?q?=20layout's=20lines=20as=20DirectWrite=20does,=20`layoutMetrics?= =?UTF-8?q?(handle,=20{=20minWidth:=20false=20})`=20leaves=20the=20min-con?= =?UTF-8?q?tent=20width=20to=20`layoutMinWidth`,=20and=20`textFeatures()`?= =?UTF-8?q?=20says=20what=20a=20layout=20takes=20=E2=80=94=20where=20a=20c?= =?UTF-8?q?aller=20justified=20each=20paragraph=20by=20spacing=20its=20wor?= =?UTF-8?q?ds=20itself,=20in=20three=20layouts,=20and=20every=20layout=20r?= =?UTF-8?q?an=20the=20line=20breaker=20a=20second=20time=20for=20a=20width?= =?UTF-8?q?=20nobody=20asked=20for?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/text.cc | 47 +++++++++++++++++++++++++++++++++++++++++++---- test/text.js | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/text.cc b/src/text.cc index 1b5c47c..e4d2b4c 100644 --- a/src/text.cc +++ b/src/text.cc @@ -587,6 +587,14 @@ Napi::Value LayoutCreate(const Napi::CallbackInfo& info) { : align == "right" ? DWRITE_TEXT_ALIGNMENT_TRAILING : DWRITE_TEXT_ALIGNMENT_LEADING); } + // `justify: true`: every line but a paragraph's last set to fill the + // width at its word separators, after it is broken — DirectWrite's own + // justification, from the shaping the layout already did, where a caller + // that spaced the words itself laid the paragraph out three times over. + // The line before a newline ends a paragraph and is left as it is. + if (Given(options, "justify") && options.Get("justify").ToBoolean().Value() && !unbounded) { + format->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_JUSTIFIED); + } format->SetReadingDirection(rtl ? DWRITE_READING_DIRECTION_RIGHT_TO_LEFT : DWRITE_READING_DIRECTION_LEFT_TO_RIGHT); if (Given(options, "lineHeight")) { @@ -697,15 +705,22 @@ Napi::Value LayoutMetrics(const Napi::CallbackInfo& info) { DWRITE_TEXT_METRICS metrics = {}; entry->layout->GetMetrics(&metrics); - float minWidth = 0; - entry->layout->DetermineMinWidth(&minWidth); - Napi::Object out = Napi::Object::New(env); // Whole device pixels, rounded up, at the same boundary the CoreText engine // rounds: a fractional measure is what makes a yoga layout blow up. out.Set("width", Napi::Number::New(env, std::ceil(metrics.width))); out.Set("height", Napi::Number::New(env, std::ceil(metrics.height))); - out.Set("minWidth", Napi::Number::New(env, std::ceil(minWidth))); + // `{ minWidth: false }` leaves the min-content width to `layoutMinWidth`: + // DetermineMinWidth runs the line breaker over the whole text again, and a + // caller that lays a paragraph out to draw it never asks. + const bool wantsMin = !(info.Length() > 1 && info[1].IsObject() && + info[1].As().Has("minWidth") && + !info[1].As().Get("minWidth").ToBoolean().Value()); + if (wantsMin) { + float minWidth = 0; + entry->layout->DetermineMinWidth(&minWidth); + out.Set("minWidth", Napi::Number::New(env, std::ceil(minWidth))); + } out.Set("lineCount", Napi::Number::New(env, metrics.lineCount)); std::vector lines(metrics.lineCount); @@ -1049,6 +1064,28 @@ Napi::Value LayoutCaret(const Napi::CallbackInfo& info) { return out; } +// layoutMinWidth(handle) -> the min-content width, in whole pixels rounded up: +// what `layoutMetrics` answers as `minWidth` unless asked not to. +Napi::Value LayoutMinWidth(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + TextLayout* entry = LayoutFor(info[0].As().Int32Value()); + if (!entry) return env.Null(); + float minWidth = 0; + entry->layout->DetermineMinWidth(&minWidth); + return Napi::Number::New(env, std::ceil(minWidth)); +} + +// textFeatures() -> what this bridge's layouts take that an older one's do +// not, for a caller to ask rather than to know a version: `justify`, and +// `minWidth: false` on `layoutMetrics` with `layoutMinWidth` beside it. +Napi::Value TextFeatures(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::Object out = Napi::Object::New(env); + out.Set("justify", Napi::Boolean::New(env, true)); + out.Set("lazyMinWidth", Napi::Boolean::New(env, true)); + return out; +} + Napi::Value LayoutRelease(const Napi::CallbackInfo& info) { TextLayout* entry = LayoutFor(info[0].As().Int32Value()); if (!entry) return info.Env().Undefined(); @@ -1588,6 +1625,8 @@ void InitTextExports(Napi::Env env, Napi::Object exports) { set("layoutCreate", LayoutCreate); set("layoutMetrics", LayoutMetrics); set("layoutRelease", LayoutRelease); + set("layoutMinWidth", LayoutMinWidth); + set("textFeatures", TextFeatures); set("layoutIndexAt", LayoutIndexAt); set("layoutCaret", LayoutCaret); // These two names are BackendContext2D's, not ours: it calls them on the diff --git a/test/text.js b/test/text.js index b95cb17..ceb122a 100644 --- a/test/text.js +++ b/test/text.js @@ -93,6 +93,45 @@ assert.ok( 'a span with features laid out nothing', ); +// --- what a layout takes ---------------------------------------------------- + +const features = win32.textFeatures(); +assert.strictEqual(features.justify, true); +assert.strictEqual(features.lazyMinWidth, true); + +// Justified: every line but the paragraph's last fills the width, the last +// is left as it is. +const prose = + 'The quick brown fox jumps over the lazy dog, again and again, until the ' + + 'paragraph runs over several lines of a narrow column.'; +const linesOf = (justify) => + win32.layoutMetrics( + win32.layoutCreate(prose, { family: 'Segoe UI', size: 14, maxWidth: 160, justify }), + ).lines; +const ragged = linesOf(false); +const justified = linesOf(true); +assert.ok(justified.length > 2, 'the column did not wrap'); +assert.strictEqual(justified.length, ragged.length, 'justifying moved a break'); +// (a line's width here takes in the space it wrapped at) +for (const line of justified.slice(0, -1)) { + assert.ok(line.width >= 159, `a justified line ${line.width} wide, short of 160`); +} +assert.ok( + ragged.slice(0, -1).some((line) => line.width < 159), + 'no ragged line fell short, so the test shows nothing', +); +assert.ok( + Math.abs(justified.at(-1).width - ragged.at(-1).width) <= 0.5, + 'the last line was justified too', +); + +// The min-content width on request: left out of the metrics when asked, +// and the same number from `layoutMinWidth`. +const lazy = win32.layoutCreate(prose, { family: 'Segoe UI', size: 14, maxWidth: 160 }); +assert.strictEqual(win32.layoutMetrics(lazy, { minWidth: false }).minWidth, undefined); +assert.strictEqual(win32.layoutMinWidth(lazy), win32.layoutMetrics(lazy).minWidth); +assert.ok(win32.layoutMinWidth(lazy) > 0); + // --- drawing through the verb table ---------------------------------------- const surface = win32.createSurface(200, 100, 1);