diff --git a/FIDELITY.md b/FIDELITY.md
index 951ac61..8b2c481 100644
--- a/FIDELITY.md
+++ b/FIDELITY.md
@@ -18,6 +18,17 @@ The committed PNGs under `testdata/renders/` back every claim here. Reproduce
them with the commands at the bottom. The measured-vs-Chrome numbers live in
[`bench/REPORT.md`](bench/REPORT.md).
+## 2026-09-28 (round 143) — `word-break`/`overflow-wrap` were entirely unimplemented, so an unbreakable long token always overflowed its container instead of wrapping — CONFIRMED real, independent usage on THREE corpus pages, one of them (developer.mozilla.org) setting it on `html` itself (engine#243)
+
+The user chose a new method for this round: rather than sweeping a corpus for JS API usage or auditing this project's own "Known gaps" for staleness (round 142's method, now exhausted), a systematic CSS-property audit — checking this engine's actual property support against a structured reference. Delegated the bulk of the cross-referencing to a background agent, which reported two candidates surviving verification against real corpus CSS: `columns`/`column-count` (a genuinely new multi-column layout mode, only one confirmed trigger — pkg.go.dev's own file list — and comparable in scope to this session's other deliberately-deferred larger features, so left as a documented future gap rather than attempted this round) and `word-break`/`overflow-wrap` (three independent triggers, no new layout algorithm needed — implemented this round).
+
+- **Independently re-verified every claim before acting**: confirmed via `grep` that neither property nor any backing `Style` field existed anywhere in `css`/`layout`/`paint`; re-fetched all three cited pages' live CSS directly (not trusting the agent's quotes) — pkg.go.dev/net/http's `.UnitFiles-fileList{columns:12.5rem 5;...word-break:break-all}` and `.UnitDirectories-pathCell{...word-break:break-all}`/`.UnitDirectories td{...word-break:break-word}`, news.ycombinator.com's `.title a{word-break:break-word}` (every story headline link), and developer.mozilla.org's own `html{...overflow-wrap:break-word;...}` (inherited page-wide, confirmed via its actual served `styles-global.css`).
+- **Scoped to exactly the one evidenced effect, not the full spec surface**: both properties, and `word-break:break-word`'s deprecated alias, now OR into one `css.Style.BreaksOverlongWords()` check (`WordBreakAll`/`OverflowWrapAnywhere` are separate fields, not collapsed into one at parse time, so an unrelated `word-break:normal` on one declaration can never silently cancel an independent `overflow-wrap:break-word` from another — a real cascade-correctness bug the obvious single-field design would have had). Only the one visible effect every confirmed trigger actually needs is modelled: a single unbreakable "word" wider than its own line splits at a rune boundary instead of overflowing. NOT modelled, disclosed: `break-all`'s stronger spec behaviour of breaking eagerly even when a word already fits elsewhere (no evidenced trigger, and a much larger blast radius on ordinary greedy-wrap line counts); `overflow-wrap:anywhere`'s distinct intrinsic-sizing effect; `word-break:keep-all` (this engine has no CJK line-breaking to suppress in the first place); and splitting WITHIN a glued run (`152,3†`-shaped, engine#149) rather than a single lone word — no confirmed trigger for that combination either.
+- **Fixed** (`css/value.go`/`css/parse.go`: new inherited `WordBreakAll`/`OverflowWrapAnywhere` fields, wired through `inheritFrom` and the explicit-`inherit` handler like every other inherited property; `layout/layout.go`'s new `splitOverlongWord`, tried in `layoutInline`'s main loop right before `forceOne`'s own unconditional overflow, on exactly the same leading item `forceOne` would otherwise place whole).
+- **A test bug caught along the way, not shipped**: an initial `TestWordBreakAllDoesNotSplitAGluedRun` wrongly assumed a glued run (`152,3†`-shaped) that must overflow stays together on one line — it does not: `forceOne` places only the run's FIRST item (it has never called `glueRun` at all), splitting the run at its own glue seam, a genuine PRE-EXISTING inconsistency with `glueRun`'s own documented invariant. Confirmed unrelated to this round (the identical split reproduces with no word-break/overflow-wrap property set anywhere) and NOT fixed here — no confirmed real-world page trigger for an unbreakable glued run wider than its own container was found; the test now pins the actual current behaviour instead of asserting something false.
+- **Two new test files' worth of coverage**: `css/parse_test.go` (`TestApplyWordBreak`/`TestApplyOverflowWrap`/`TestBreaksOverlongWords` — every value, the deprecated alias, `unset`, the explicit `inherit` keyword, and `inheritFrom`) and `layout/layout_test.go` (six tests using the existing `fakeMeasurer`'s exact 10px-per-rune convention: the unaffected default, both properties independently reaching the same split, no effect on already-fitting text, inheritance to a descendant, the single-rune-cannot-split edge, and the glued-run non-split above). Git-stash-confirmed: reverting `css/parse.go`/`css/value.go`/`layout/layout.go` alone (keeping the new tests) fails to even COMPILE (`s.WordBreakAll undefined`) — the strongest class of regression signal.
+- **Simplified `splitOverlongWord` after the coverage floor caught unreachable defensive code**: an initial version defensively skipped a leading `LineBreak` item and guarded an empty `items` slice, mirroring `forceOne`'s own symmetric checks — but `layoutInline` only ever calls it once `wrapOneLine` has already confirmed `items[0]` itself is a non-break item that does not fit, making both checks provably dead code. `layout`'s 100% coverage floor caught this immediately (87% on the new function); removed rather than tested around, per this session's own standing rule against validating conditions that cannot occur.
+- **Bench confirmed a real, directly-attributable win on the ONE page whose own trigger applies site-wide**: developer.mozilla.org's own SSIM rose 0.6028→0.6369 (pixdiff 18.10%→13.60%) — this round's single largest movement, and exactly the page whose `overflow-wrap:break-word` sits on `html` itself, reaching every long unbreakable token anywhere in its prose and code samples, not just one narrow widget. pkg.go.dev stayed exactly flat (0.71557, unchanged) — checked directly rather than assumed: a live re-render confirmed its own Source Files list has no filename long enough to need splitting on this specific page, and its `.UnitFiles-fileList`'s OTHER property, `columns:12.5rem 5`, is the still-unimplemented, separate gap actually keeping that list single-column (documented above, not this round's fix). news.ycombinator.com's small -0.0023 and react.dev's -0.0019 are both within the same live-content noise band already established across many rounds for these two frequently-changing pages (neither's own current front-page/homepage content combines a long-enough bare token with a narrow-enough container to exercise this fix either way). github.com/golang/go (-0.00012), tailwindcss.com (+0.0008) and caniuse.com (+0.0030) are ordinary rounding/live-fetch noise; example.com, en.wikipedia.org and go.dev/blog byte-identical.
## 2026-09-28 (round 142) — two of this engine's OWN "Known gaps" bullets were stale, one of them contradicted by a bullet 130 lines below it in the SAME section — no code change, documentation only (engine#242)
Continuing round 141's method (check this engine's own "Known gaps" claims against the current code rather than trust them), swept the remainder of the section for the same pattern. Three dependency-drift leads came up empty first: `dop251/goja`'s ES2018 `for await...of` gap (upstream issue #498 confirmed still open; the one newer available goja commit only adds unrelated fixes) and the Go Mono comma-glyph bug (re-reproduced live, still happens exactly as documented at 10-14px; neither `go-opentype/fonts` nor `go-opentype/opentype` has landed anything touching glyph outlines or rasterisation since round 64 found it) are both still accurate. `github.com/srwiley/oksvg`'s pinned commit IS its own upstream's latest — genuinely unmaintained, nothing to bump to.
@@ -5092,6 +5103,7 @@ columns.
## Known gaps (updated 2026-08-30 — see the note above on why this drifted)
+- **Multi-column layout (`columns`/`column-count`/`column-width`) is entirely unimplemented — no `layout/` code lays content out into multiple text columns at all.** Found round 143 (2026-09-28) via a systematic CSS-property audit rather than corpus-sweeping: confirmed live on pkg.go.dev/net/http itself, `.UnitFiles-fileList{columns:12.5rem 5;...}` on the package's own "Source Files" `
` — real Chrome lays its ~29 filenames out in 5 narrow columns side by side; this engine stacks them in one long single column instead (directly re-verified via a live re-render this same round, alongside the unrelated `word-break` fix — see the round-143 log entry above). Not attempted this round: a real multi-column implementation needs a genuinely new layout algorithm (splitting content into N columns, balancing column heights, handling a break mid-content) — comparable in scope to this session's other deliberately-deferred larger features (Shadow DOM, the reskin-orphaned-node architecture), not a narrow bug fix alongside `word-break`'s much smaller change. Revisit as its own round.
- **react.dev's own hydration fails with a real React error (#418, "the initial UI does not match what was rendered on the server"), which cascades into a SECOND, internal React crash during its own unmount/remount recovery and unmounts the whole app to nothing — this engine's own empty-page guard then falls back to the pre-hydration SSR skeleton, the long-documented "reskin gap."** Found live (round 82, 2026-09-22): this is a NEW, more precisely characterised failure mode, distinct from round 71's `ChunkLoadError` theory — confirmed unrelated by fixing round 71's own flagged root cause (`setTimeout` discarding its requested delay, fixed this same round) and re-checking: the live JS log was byte-for-byte identical before and after that fix. One plausible theory (a `prefers-color-scheme` client/server mismatch via `matchMedia`) was checked and ruled out — this engine already deliberately resolves that query to match react.dev's own dark-theme strategy. Minified production React strips the hydration-mismatch diff that would name the exact divergent DOM node, so finding the actual cause needs a materially larger investigation than fits in one round; not chased further, not fixed here.
- **goja (this engine's JS runtime) does not support ES2018 `for await (... of ...)` asynchronous-iteration syntax.** Found live on tailwindcss.com (round 77, 2026-09-21): a real Next.js chunk shipped as a plain classic script (correctly recognised as such by this engine — no type-attribute mishandling involved) fails to compile at all because its own code legitimately uses `for await` inside a real `async function`. Isolated to the exact byte via the real fetched chunk, and reproduced from scratch with a minimal, from-nothing repro containing only the `for await` construct. Confirmed as a genuine, currently-open upstream gap (dop251/goja#498, "Upgrade to latest version of ECMAScript 2021/2022") rather than a local misconfiguration — there is no JS parser in this repo to patch; forking or patching goja itself is outside this engine's own scope. Not fixed, not fixable here.
- **`display:inline-flex`/`inline-grid`/`inline-table` lose their "inline"
diff --git a/bench/REPORT.md b/bench/REPORT.md
index f4b0d22..67e59af 100644
--- a/bench/REPORT.md
+++ b/bench/REPORT.md
@@ -2,7 +2,7 @@
-**Generated:** 2026-09-28 12:22 UTC
+**Generated:** 2026-09-28 13:24 UTC
**Viewport:** 1024×768, device-scale 1
**Timing:** median of N=5 runs after 1 warmup, wall-clock incl. network fetch
**Chrome:** `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`
@@ -11,16 +11,16 @@
| URL | SSIM | pixdiff % | webengine ms | chrome ms | speed× | region | status |
|-----|-----:|----------:|-------------:|----------:|-------:|:------:|:------:|
-| example.com/ | 0.954 | 1.5 | 46.6 | 1292.8 | 27.76 | 1024×768 | ok |
-| en.wikipedia.org/wiki/Go_(programming_language) | 0.436 | 21.3 | 2749.9 | 3775.9 | 1.37 | 1024×2500 | ok |
-| pkg.go.dev/net/http | 0.716 | 11.3 | 5326.6 | 3582.9 | 0.67 | 1024×2500 | ok |
-| go.dev/blog/ | 0.689 | 16.8 | 4264.1 | 1860.1 | 0.44 | 1024×1439 | ok |
-| react.dev/ | 0.726 | 32.9 | 2037.2 | 2033.2 | 1.00 | 1024×2500 | ok |
-| news.ycombinator.com/ | 0.606 | 14.2 | 1222.1 | 1989.3 | 1.63 | 1024×1077 | ok |
-| developer.mozilla.org/en-US/docs/Web/CSS | 0.603 | 18.1 | 1115.4 | 1547.7 | 1.39 | 1009×2500 | ok |
-| github.com/golang/go | 0.638 | 14.2 | 3081.3 | 1989.4 | 0.65 | 1024×2491 | ok |
-| tailwindcss.com/ | 0.739 | 11.3 | 7667.0 | 1716.3 | 0.22 | 1024×2500 | ok |
-| caniuse.com/ | 0.655 | 18.3 | 7474.2 | 5225.8 | 0.70 | 1024×1740 | ok |
+| example.com/ | 0.954 | 1.5 | 46.7 | 1346.0 | 28.84 | 1024×768 | ok |
+| en.wikipedia.org/wiki/Go_(programming_language) | 0.436 | 21.3 | 2852.8 | 4123.9 | 1.45 | 1024×2500 | ok |
+| pkg.go.dev/net/http | 0.716 | 11.3 | 5499.0 | 3650.6 | 0.66 | 1024×2500 | ok |
+| go.dev/blog/ | 0.689 | 16.8 | 4265.3 | 2044.6 | 0.48 | 1024×1439 | ok |
+| react.dev/ | 0.725 | 33.0 | 2395.1 | 2198.0 | 0.92 | 1024×2500 | ok |
+| news.ycombinator.com/ | 0.604 | 14.3 | 1186.8 | 2092.8 | 1.76 | 1024×1077 | ok |
+| developer.mozilla.org/en-US/docs/Web/CSS | 0.637 | 13.6 | 1097.6 | 1669.7 | 1.52 | 1009×2500 | ok |
+| github.com/golang/go | 0.638 | 14.2 | 3336.3 | 2177.4 | 0.65 | 1024×2491 | ok |
+| tailwindcss.com/ | 0.740 | 11.2 | 7736.3 | 1805.0 | 0.23 | 1024×2500 | ok |
+| caniuse.com/ | 0.658 | 18.1 | 4829.5 | 3306.0 | 0.68 | 1024×1740 | ok |
Speed× is `chrome_ms / webengine_ms`: >1 means webengine is faster.
diff --git a/bench/out/caniuse.com.png b/bench/out/caniuse.com.png
index 969d4a7..8cc745f 100644
Binary files a/bench/out/caniuse.com.png and b/bench/out/caniuse.com.png differ
diff --git a/bench/out/developer.mozilla.org_en-US_docs_Web_CSS.png b/bench/out/developer.mozilla.org_en-US_docs_Web_CSS.png
index 15991b3..16de242 100644
Binary files a/bench/out/developer.mozilla.org_en-US_docs_Web_CSS.png and b/bench/out/developer.mozilla.org_en-US_docs_Web_CSS.png differ
diff --git a/bench/out/github.com_golang_go.png b/bench/out/github.com_golang_go.png
index e0b9060..2c1a423 100644
Binary files a/bench/out/github.com_golang_go.png and b/bench/out/github.com_golang_go.png differ
diff --git a/bench/out/news.ycombinator.com.png b/bench/out/news.ycombinator.com.png
index f106fab..bef9d6c 100644
Binary files a/bench/out/news.ycombinator.com.png and b/bench/out/news.ycombinator.com.png differ
diff --git a/bench/out/react.dev.png b/bench/out/react.dev.png
index 52bc7b3..95663d7 100644
Binary files a/bench/out/react.dev.png and b/bench/out/react.dev.png differ
diff --git a/bench/out/tailwindcss.com.png b/bench/out/tailwindcss.com.png
index 90eb90a..1068cae 100644
Binary files a/bench/out/tailwindcss.com.png and b/bench/out/tailwindcss.com.png differ
diff --git a/bench/results.json b/bench/results.json
index 0767b60..ab14d54 100644
--- a/bench/results.json
+++ b/bench/results.json
@@ -1,9 +1,9 @@
[
{
"url": "https://example.com/",
- "webengine_ms": 46.575,
- "chrome_ms": 1292.756,
- "speed_ratio": 27.756435856146002,
+ "webengine_ms": 46.667,
+ "chrome_ms": 1345.998,
+ "speed_ratio": 28.842608267083808,
"ssim": 0.9541451179596087,
"pixdiff_pct": 1.53350830078125,
"region_w": 1024,
@@ -14,9 +14,9 @@
},
{
"url": "https://en.wikipedia.org/wiki/Go_(programming_language)",
- "webengine_ms": 2749.907,
- "chrome_ms": 3775.87,
- "speed_ratio": 1.3730900717733363,
+ "webengine_ms": 2852.814,
+ "chrome_ms": 4123.914,
+ "speed_ratio": 1.4455600680591163,
"ssim": 0.43620978650197445,
"pixdiff_pct": 21.2776171875,
"region_w": 1024,
@@ -27,9 +27,9 @@
},
{
"url": "https://pkg.go.dev/net/http",
- "webengine_ms": 5326.601,
- "chrome_ms": 3582.92,
- "speed_ratio": 0.6726465901988905,
+ "webengine_ms": 5499.012,
+ "chrome_ms": 3650.607,
+ "speed_ratio": 0.6638659817436297,
"ssim": 0.7155686319990039,
"pixdiff_pct": 11.3370703125,
"region_w": 1024,
@@ -40,9 +40,9 @@
},
{
"url": "https://go.dev/blog/",
- "webengine_ms": 4264.126,
- "chrome_ms": 1860.149,
- "speed_ratio": 0.43623218450861906,
+ "webengine_ms": 4265.321,
+ "chrome_ms": 2044.595,
+ "speed_ratio": 0.4793531366103513,
"ssim": 0.688936244874563,
"pixdiff_pct": 16.781062695448227,
"region_w": 1024,
@@ -53,11 +53,11 @@
},
{
"url": "https://react.dev/",
- "webengine_ms": 2037.17,
- "chrome_ms": 2033.175,
- "speed_ratio": 0.998038946185149,
- "ssim": 0.7264293856170049,
- "pixdiff_pct": 32.9206640625,
+ "webengine_ms": 2395.07,
+ "chrome_ms": 2197.996,
+ "speed_ratio": 0.9177168099470997,
+ "ssim": 0.72456508425769,
+ "pixdiff_pct": 32.97015625,
"region_w": 1024,
"region_h": 2500,
"montage": "out/react.dev.png",
@@ -66,11 +66,11 @@
},
{
"url": "https://news.ycombinator.com/",
- "webengine_ms": 1222.051,
- "chrome_ms": 1989.256,
- "speed_ratio": 1.627801131049359,
- "ssim": 0.6064343869625353,
- "pixdiff_pct": 14.161153667595173,
+ "webengine_ms": 1186.776,
+ "chrome_ms": 2092.841,
+ "speed_ratio": 1.7634675793915615,
+ "ssim": 0.6041222981256633,
+ "pixdiff_pct": 14.254820247214484,
"region_w": 1024,
"region_h": 1077,
"montage": "out/news.ycombinator.com.png",
@@ -79,11 +79,11 @@
},
{
"url": "https://developer.mozilla.org/en-US/docs/Web/CSS",
- "webengine_ms": 1115.404,
- "chrome_ms": 1547.717,
- "speed_ratio": 1.387584229570631,
- "ssim": 0.6028225690863096,
- "pixdiff_pct": 18.10097125867195,
+ "webengine_ms": 1097.583,
+ "chrome_ms": 1669.748,
+ "speed_ratio": 1.5212954282272957,
+ "ssim": 0.6369356196501483,
+ "pixdiff_pct": 13.601189296333002,
"region_w": 1009,
"region_h": 2500,
"montage": "out/developer.mozilla.org_en-US_docs_Web_CSS.png",
@@ -92,11 +92,11 @@
},
{
"url": "https://github.com/golang/go",
- "webengine_ms": 3081.304,
- "chrome_ms": 1989.418,
- "speed_ratio": 0.6456415855105501,
- "ssim": 0.6380408123751875,
- "pixdiff_pct": 14.242640693998393,
+ "webengine_ms": 3336.274,
+ "chrome_ms": 2177.437,
+ "speed_ratio": 0.6526553274701059,
+ "ssim": 0.6379212290663879,
+ "pixdiff_pct": 14.246757075471697,
"region_w": 1024,
"region_h": 2491,
"montage": "out/github.com_golang_go.png",
@@ -105,11 +105,11 @@
},
{
"url": "https://tailwindcss.com/",
- "webengine_ms": 7667.014,
- "chrome_ms": 1716.28,
- "speed_ratio": 0.22385246720561616,
- "ssim": 0.739270644528742,
- "pixdiff_pct": 11.284296874999999,
+ "webengine_ms": 7736.263,
+ "chrome_ms": 1804.972,
+ "speed_ratio": 0.23331316424997445,
+ "ssim": 0.7400666328938399,
+ "pixdiff_pct": 11.239375,
"region_w": 1024,
"region_h": 2500,
"montage": "out/tailwindcss.com.png",
@@ -118,11 +118,11 @@
},
{
"url": "https://caniuse.com/",
- "webengine_ms": 7474.199,
- "chrome_ms": 5225.83,
- "speed_ratio": 0.6991826147524304,
- "ssim": 0.6554154883161695,
- "pixdiff_pct": 18.3160470545977,
+ "webengine_ms": 4829.49,
+ "chrome_ms": 3305.954,
+ "speed_ratio": 0.6845348059525955,
+ "ssim": 0.658432581792879,
+ "pixdiff_pct": 18.102830908764368,
"region_w": 1024,
"region_h": 1740,
"montage": "out/caniuse.com.png",
diff --git a/css/parse.go b/css/parse.go
index 39a60ec..864c16f 100644
--- a/css/parse.go
+++ b/css/parse.go
@@ -959,6 +959,33 @@ func (s *Style) apply(d Declaration, emRef float64, parent *Style) {
s.TextWrapNowrap = n
s.TextWrapBalance = b
}
+ case "word-break":
+ if lv == "unset" { // unset is inherit for an inherited property
+ s.inheritProperty(d.Property, parent)
+ } else {
+ switch lv {
+ case "break-all":
+ s.WordBreakAll = true
+ case "break-word":
+ // Deprecated alias for `normal` + `overflow-wrap:anywhere` —
+ // see OverflowWrapAnywhere's own doc comment.
+ s.WordBreakAll = false
+ s.OverflowWrapAnywhere = true
+ case "normal", "keep-all":
+ s.WordBreakAll = false
+ }
+ }
+ case "overflow-wrap", "word-wrap":
+ if lv == "unset" { // unset is inherit for an inherited property
+ s.inheritProperty(d.Property, parent)
+ } else {
+ switch lv {
+ case "break-word", "anywhere":
+ s.OverflowWrapAnywhere = true
+ case "normal":
+ s.OverflowWrapAnywhere = false
+ }
+ }
case "-webkit-line-clamp", "line-clamp":
// Not inherited, so "unset"/"initial"/"none" all just reset to 0 (no
// clamp) — no inheritProperty special case needed, unlike the
@@ -1349,6 +1376,10 @@ func (s *Style) inheritProperty(prop string, parent *Style) {
case "text-wrap":
s.TextWrapBalance = parent.TextWrapBalance
s.TextWrapNowrap = parent.TextWrapNowrap
+ case "word-break":
+ s.WordBreakAll = parent.WordBreakAll
+ case "overflow-wrap", "word-wrap":
+ s.OverflowWrapAnywhere = parent.OverflowWrapAnywhere
// The break properties are not inherited by default, but an explicit
// `inherit` still copies the parent's computed value, per CSS Cascade.
case "break-before", "page-break-before":
diff --git a/css/parse_test.go b/css/parse_test.go
index fb906d2..0546b20 100644
--- a/css/parse_test.go
+++ b/css/parse_test.go
@@ -1179,3 +1179,132 @@ func TestApplyBorderSpacing(t *testing.T) {
t.Errorf("border-spacing:inherit = (%v, %v), want parent's (4, 7)", s2.BorderSpacingH, s2.BorderSpacingV)
}
}
+
+func TestApplyWordBreak(t *testing.T) {
+ s := initialStyle()
+ apply := func(v string) { s.apply(Declaration{Property: "word-break", Value: v}, 16, nil) }
+
+ if s.WordBreakAll {
+ t.Error("initialStyle().WordBreakAll = true, want false (initial value is normal)")
+ }
+ apply("break-all")
+ if !s.WordBreakAll {
+ t.Error("word-break:break-all left WordBreakAll false")
+ }
+ apply("normal")
+ if s.WordBreakAll {
+ t.Error("word-break:normal left WordBreakAll true")
+ }
+ apply("break-all")
+ apply("keep-all")
+ if s.WordBreakAll {
+ t.Error("word-break:keep-all left WordBreakAll true (not modelled, but must still reset like normal)")
+ }
+ // The deprecated break-word alias sets OverflowWrapAnywhere, not
+ // WordBreakAll — see OverflowWrapAnywhere's own doc comment.
+ apply("break-all")
+ apply("break-word")
+ if s.WordBreakAll {
+ t.Error("word-break:break-word left WordBreakAll true, want false (it is overflow-wrap's alias, not break-all's)")
+ }
+ if !s.OverflowWrapAnywhere {
+ t.Error("word-break:break-word did not set OverflowWrapAnywhere")
+ }
+
+ // An unrecognised token is invalid CSS and must leave the value UNCHANGED.
+ s.WordBreakAll = true
+ apply("not-a-real-value")
+ if !s.WordBreakAll {
+ t.Error("word-break:not-a-real-value (invalid) reset WordBreakAll, want unchanged")
+ }
+
+ // Inherited, per spec.
+ parent := initialStyle()
+ parent.WordBreakAll = true
+ child := inheritFrom(parent)
+ if !child.WordBreakAll {
+ t.Error("inheritFrom(parent).WordBreakAll = false, want inherited true")
+ }
+
+ // unset re-inherits the parent's value; so does the explicit inherit keyword.
+ s2 := initialStyle()
+ s2.apply(Declaration{Property: "word-break", Value: "unset"}, 16, &parent)
+ if !s2.WordBreakAll {
+ t.Error("word-break:unset did not inherit true from parent")
+ }
+ s3 := initialStyle()
+ s3.apply(Declaration{Property: "word-break", Value: "inherit"}, 16, &parent)
+ if !s3.WordBreakAll {
+ t.Error("word-break:inherit did not inherit true from parent")
+ }
+}
+
+func TestApplyOverflowWrap(t *testing.T) {
+ s := initialStyle()
+ apply := func(prop, v string) { s.apply(Declaration{Property: prop, Value: v}, 16, nil) }
+
+ if s.OverflowWrapAnywhere {
+ t.Error("initialStyle().OverflowWrapAnywhere = true, want false (initial value is normal)")
+ }
+ apply("overflow-wrap", "break-word")
+ if !s.OverflowWrapAnywhere {
+ t.Error("overflow-wrap:break-word left OverflowWrapAnywhere false")
+ }
+ apply("overflow-wrap", "normal")
+ if s.OverflowWrapAnywhere {
+ t.Error("overflow-wrap:normal left OverflowWrapAnywhere true")
+ }
+ apply("overflow-wrap", "anywhere")
+ if !s.OverflowWrapAnywhere {
+ t.Error("overflow-wrap:anywhere left OverflowWrapAnywhere false")
+ }
+
+ // word-wrap is the legacy alias for the exact same property.
+ s.OverflowWrapAnywhere = false
+ apply("word-wrap", "break-word")
+ if !s.OverflowWrapAnywhere {
+ t.Error("word-wrap:break-word (legacy alias) left OverflowWrapAnywhere false")
+ }
+
+ // An unrecognised token is invalid CSS and must leave the value UNCHANGED.
+ apply("overflow-wrap", "not-a-real-value")
+ if !s.OverflowWrapAnywhere {
+ t.Error("overflow-wrap:not-a-real-value (invalid) reset OverflowWrapAnywhere, want unchanged")
+ }
+
+ // Inherited, per spec.
+ parent := initialStyle()
+ parent.OverflowWrapAnywhere = true
+ child := inheritFrom(parent)
+ if !child.OverflowWrapAnywhere {
+ t.Error("inheritFrom(parent).OverflowWrapAnywhere = false, want inherited true")
+ }
+
+ // unset re-inherits the parent's value; so does the explicit inherit
+ // keyword, through either property name.
+ s2 := initialStyle()
+ s2.apply(Declaration{Property: "overflow-wrap", Value: "unset"}, 16, &parent)
+ if !s2.OverflowWrapAnywhere {
+ t.Error("overflow-wrap:unset did not inherit true from parent")
+ }
+ s3 := initialStyle()
+ s3.apply(Declaration{Property: "word-wrap", Value: "inherit"}, 16, &parent)
+ if !s3.OverflowWrapAnywhere {
+ t.Error("word-wrap:inherit did not inherit true from parent")
+ }
+}
+
+func TestBreaksOverlongWords(t *testing.T) {
+ s := initialStyle()
+ if s.BreaksOverlongWords() {
+ t.Error("initialStyle().BreaksOverlongWords() = true, want false")
+ }
+ s.WordBreakAll = true
+ if !s.BreaksOverlongWords() {
+ t.Error("WordBreakAll alone did not make BreaksOverlongWords true")
+ }
+ s.WordBreakAll, s.OverflowWrapAnywhere = false, true
+ if !s.BreaksOverlongWords() {
+ t.Error("OverflowWrapAnywhere alone did not make BreaksOverlongWords true")
+ }
+}
diff --git a/css/value.go b/css/value.go
index 3ebf28b..84c6523 100644
--- a/css/value.go
+++ b/css/value.go
@@ -1010,6 +1010,58 @@ type Style struct {
// nowrap}` on its code-toolbar filename labels.
TextWrapNowrap bool
+ // WordBreakAll is `word-break: break-all`, INHERITED, initial false. Per
+ // spec it makes any character boundary a break opportunity for a line
+ // that would otherwise overflow — this engine models only the ONE
+ // visible effect that has a confirmed real-world trigger: a single
+ // unbreakable "word" (one InlineItem — see appendWords) WIDER than the
+ // whole line gets split at a rune boundary instead of overflowing its
+ // container (layoutInline's splitOverlongWord). The stronger, more
+ // aggressive part of the real spec — breaking mid-word EAGERLY even when
+ // the word would otherwise have fit on its own line, purely for more
+ // even line lengths — is not modelled: no corpus page's own confirmed
+ // trigger (see OverflowWrapAnywhere below) needs it, and it would also
+ // change ordinary greedy-wrap line counts for text that already fits,
+ // a much larger blast radius for an unevidenced refinement. `keep-all`
+ // (suppress even CJK's own default inter-character breaks) is not
+ // modelled either — this engine does not implement CJK line-breaking in
+ // the first place, so there is nothing for it to suppress.
+ //
+ // See the Style method BreaksOverlongWords, which OR-combines this with
+ // OverflowWrapAnywhere: the two properties are independent (either one
+ // requests the same one effect this engine models), so they are kept as
+ // separate fields rather than one shared boolean — collapsing them at
+ // parse time would let an unrelated `word-break: normal` on a LATER
+ // declaration silently cancel an EARLIER `overflow-wrap: break-word`'s
+ // own effect, which real CSS's cascade would not do (they are different
+ // properties; each keeps its own computed value independently).
+ WordBreakAll bool
+
+ // OverflowWrapAnywhere is `overflow-wrap`/`word-wrap: break-word` (the
+ // unprefixed and legacy-alias property names for the same property) or
+ // `anywhere`, and also the deprecated `word-break: break-word` value
+ // (per spec, an alias for `word-break: normal; overflow-wrap: anywhere`,
+ // so it sets THIS field, not WordBreakAll). INHERITED, initial false.
+ // The spec distinguishes `break-word` (breaks only as an overflow-
+ // avoidance last resort, like this engine's one modelled effect) from
+ // `anywhere` (also affects min-/max-content intrinsic-size calculations,
+ // letting a container shrink narrower than its longest unbreakable
+ // word) — that intrinsic-sizing distinction is not modelled, since
+ // every confirmed real trigger (see BreaksOverlongWords) only needs the
+ // line-breaking effect the two values already share.
+ //
+ // Confirmed live: developer.mozilla.org sets `overflow-wrap:break-word`
+ // on `html` itself (inherited page-wide); pkg.go.dev's own package-doc
+ // pages set `word-break:break-all` on `.UnitFiles-fileList` (a `` of
+ // long bare filenames with no natural break points) and `word-break:
+ // break-word` on `.UnitDirectories td` (a subdirectory-listing table
+ // cell); news.ycombinator.com sets `word-break:break-word` on `.title a`
+ // (every story headline link, which occasionally IS a long bare URL).
+ // Before this, a real production page like any of these would overflow
+ // its own container with unbroken text exactly where a real browser
+ // wraps mid-word instead.
+ OverflowWrapAnywhere bool
+
// LineClamp is `-webkit-line-clamp`/`line-clamp`'s value: the maximum
// number of lines an element's inline content renders before being cut
// off, 0 meaning "not set" (unclamped — the CSS `none` keyword resolves
@@ -1120,6 +1172,13 @@ func (h LineHeight) Resolve(fontSize float64) (float64, bool) {
// Bold reports whether the weight renders as bold.
func (s *Style) Bold() bool { return s.FontWeight >= 600 }
+// BreaksOverlongWords reports whether a single unbreakable "word" wider than
+// its own line may be split at a rune boundary instead of overflowing — see
+// WordBreakAll and OverflowWrapAnywhere's own doc comments for exactly which
+// property values request this and why they are two independent fields
+// rather than one.
+func (s *Style) BreaksOverlongWords() bool { return s.WordBreakAll || s.OverflowWrapAnywhere }
+
// initialStyle is the root's starting style (CSS initial values for the
// inherited properties, block display for the viewport root).
func initialStyle() Style {
@@ -1229,6 +1288,9 @@ func inheritFrom(parent Style) Style {
TabSize: parent.TabSize,
TextWrapBalance: parent.TextWrapBalance,
TextWrapNowrap: parent.TextWrapNowrap,
+
+ WordBreakAll: parent.WordBreakAll, // inherited
+ OverflowWrapAnywhere: parent.OverflowWrapAnywhere, // inherited
}
}
diff --git a/layout/layout.go b/layout/layout.go
index 38a33a8..47938cb 100644
--- a/layout/layout.go
+++ b/layout/layout.go
@@ -1538,6 +1538,13 @@ func (l *layouter) layoutInline(items []*InlineItem, st *css.Style, cx, cw, y fl
cursor = ny
continue
}
+ if head, tail, ok := l.splitOverlongWord(rest, avail); ok {
+ // rest[0] is guaranteed to be the non-fitting item here: a
+ // leading LineBreak would already have made wrapOneLine
+ // return consumed==1 above, never 0.
+ rest = append([]*InlineItem{head, tail}, rest[1:]...)
+ continue
+ }
line, consumed = forceOne(rest)
}
rest = rest[consumed:]
@@ -1615,6 +1622,74 @@ func forceOne(items []*InlineItem) (*LineBox, int) {
return line, i
}
+// splitOverlongWord attempts to split items[0] — always a non-break item that
+// does not fit maxW even alone, the same one forceOne would otherwise place
+// whole; layoutInline only ever calls this right before falling through to
+// forceOne, after wrapOneLine has already returned consumed==0, which only
+// happens once it has confirmed items[0] itself is not a LineBreak and does
+// not fit (see wrapOneLine's own "len(line.Items) == 0 && runW > maxW"
+// check) — into a prefix that fits within maxW and a suffix that continues
+// as its own item, when that item's own style requests it
+// (css.Style.BreaksOverlongWords — word-break:break-all, or overflow-wrap/
+// word-wrap:break-word/anywhere).
+//
+// It returns ok=false — leaving the caller to fall through to forceOne's
+// ordinary overflow — when items[0] is not a splittable text item at all (an
+// image, a form control, a nested box), when it is part of a glued run of
+// more than one item (see glueRun's own doc comment: splitting WITHIN a run
+// like `152,3†` has no confirmed real-world trigger, unlike a
+// single long "word" — the evidenced case this models), when its style does
+// not request this, or when it has fewer than two runes (nothing to split
+// off).
+//
+// The returned head keeps at least one rune even when maxW is smaller than
+// that rune's own width, guaranteeing forward progress: the caller's loop
+// would otherwise retry the same unsplit item forever. head keeps the
+// original item's own leading edge (SpaceBefore, and any padLead from an
+// enclosing decorated inline ancestor's own left border/padding — see
+// InlineItem.padLead); tail is a fresh continuation with no leading space of
+// its own (SpaceBefore 0, matching glueRun's convention that zero means "no
+// break opportunity before this item") and takes the original item's
+// TRAILING edge (padTrail) instead, since the synthetic split point itself is
+// not a real decorated-ancestor boundary and reserves no edge space at all.
+// Both share the original item's decor chain, style, node and vertical
+// metrics unchanged — only Text/Width/padLead/padTrail differ between them.
+func (l *layouter) splitOverlongWord(items []*InlineItem, maxW float64) (head, tail *InlineItem, ok bool) {
+ it := items[0]
+ if it.Text == "" || it.Style == nil || !it.Style.BreaksOverlongWords() {
+ return nil, nil, false
+ }
+ if j, _ := glueRun(items, 0); j != 1 {
+ return nil, nil, false
+ }
+ runes := []rune(it.Text)
+ if len(runes) < 2 {
+ return nil, nil, false
+ }
+ st := it.Style
+ measure := func(s string) float64 {
+ return l.m.Measure(s, st.FontFamily, st.FontSize, st.FontWeight, st.Italic) + letterSpacingWidth(s, st)
+ }
+ n := len(runes)
+ for n > 1 && measure(string(runes[:n])) > maxW {
+ n--
+ }
+ headText, tailText := string(runes[:n]), string(runes[n:])
+ head = &InlineItem{
+ Text: headText, Style: st, Node: it.Node,
+ Width: measure(headText), SpaceBefore: it.SpaceBefore,
+ Ascent: it.Ascent, LineHeight: it.LineHeight, BaselineShift: it.BaselineShift,
+ decor: it.decor, padLead: it.padLead,
+ }
+ tail = &InlineItem{
+ Text: tailText, Style: st, Node: it.Node,
+ Width: measure(tailText), SpaceBefore: 0,
+ Ascent: it.Ascent, LineHeight: it.LineHeight, BaselineShift: it.BaselineShift,
+ decor: it.decor, padTrail: it.padTrail,
+ }
+ return head, tail, true
+}
+
// truncateLineWithEllipsis replaces line's items with a single text item
// ending in "…", trimmed to fit cw, when the line's total width overflows it
// — the real-world effect of `text-overflow:ellipsis` combined with
diff --git a/layout/layout_test.go b/layout/layout_test.go
index 710bc7f..92bfe74 100644
--- a/layout/layout_test.go
+++ b/layout/layout_test.go
@@ -238,6 +238,132 @@ func TestPreDoesNotWrap(t *testing.T) {
}
}
+// TestWordBreakNormalDoesNotSplit guards the DEFAULT, unchanged behaviour
+// splitOverlongWord must leave alone: with no word-break/overflow-wrap set at
+// all, a single unbreakable "word" wider than its own container still
+// overflows as one whole line, exactly as it always has (see
+// TestWrapItemsOverflowWord's identical claim at WrapItems' own pure level).
+func TestWordBreakNormalDoesNotSplit(t *testing.T) {
+ src := `aaaaaaaaaa
`
+ div := findBox(layoutHTML(t, src, 1024), "div")
+ if len(div.Lines) != 1 {
+ t.Fatalf("expected 1 overflowing line, got %d", len(div.Lines))
+ }
+ if lineText(div.Lines[0]) != "aaaaaaaaaa" {
+ t.Errorf("line = %q, want the whole unsplit word", lineText(div.Lines[0]))
+ }
+}
+
+// TestWordBreakAllSplitsOverlongWord is the go.dev/blog... no — the
+// pkg.go.dev/news.ycombinator.com/developer.mozilla.org confirmed real
+// trigger (round 143): a container narrower than its own longest unbreakable
+// word now splits it at a rune boundary instead of overflowing. With
+// fakeMeasurer's 10px-per-rune and a 30px container, "aaaaaaaaaa" (10 runes)
+// must split into 3-rune chunks (30px each, exactly filling the line) with a
+// final 1-rune remainder — 4 lines, not 1 overflowing one.
+func TestWordBreakAllSplitsOverlongWord(t *testing.T) {
+ src := `aaaaaaaaaa
`
+ div := findBox(layoutHTML(t, src, 1024), "div")
+ if len(div.Lines) != 4 {
+ t.Fatalf("expected 4 lines, got %d: %v", len(div.Lines), linesText(div.Lines))
+ }
+ want := []string{"aaa", "aaa", "aaa", "a"}
+ for i, w := range want {
+ if lineText(div.Lines[i]) != w {
+ t.Errorf("line%d = %q, want %q", i, lineText(div.Lines[i]), w)
+ }
+ }
+}
+
+// TestOverflowWrapBreakWordSplitsOverlongWord confirms overflow-wrap reaches
+// the exact same splitting code path as word-break:break-all (both OR into
+// css.Style.BreaksOverlongWords) — the confirmed real trigger on
+// developer.mozilla.org, which sets this on `html` itself.
+func TestOverflowWrapBreakWordSplitsOverlongWord(t *testing.T) {
+ src := `aaaaaaaaaa
`
+ div := findBox(layoutHTML(t, src, 1024), "div")
+ if len(div.Lines) != 4 {
+ t.Fatalf("expected 4 lines, got %d: %v", len(div.Lines), linesText(div.Lines))
+ }
+ want := []string{"aaa", "aaa", "aaa", "a"}
+ for i, w := range want {
+ if lineText(div.Lines[i]) != w {
+ t.Errorf("line%d = %q, want %q", i, lineText(div.Lines[i]), w)
+ }
+ }
+}
+
+// TestWordBreakAllDoesNotAffectWordsThatAlreadyFit confirms this engine only
+// models the evidenced "avoid overflow" effect, not break-all's stronger
+// spec behaviour of breaking EAGERLY even when a word already fits on its
+// own line (see WordBreakAll's own doc comment) — ordinary short content is
+// completely unaffected.
+func TestWordBreakAllDoesNotAffectWordsThatAlreadyFit(t *testing.T) {
+ src := `a b c d
`
+ div := findBox(layoutHTML(t, src, 1024), "div")
+ if len(div.Lines) != 2 {
+ t.Fatalf("expected 2 lines (ordinary greedy wrap, unaffected by break-all), got %d: %v", len(div.Lines), linesText(div.Lines))
+ }
+ if lineText(div.Lines[0]) != "a b" || lineText(div.Lines[1]) != "c d" {
+ t.Errorf("lines = %q / %q, want %q / %q", lineText(div.Lines[0]), lineText(div.Lines[1]), "a b", "c d")
+ }
+}
+
+// TestWordBreakAllSingleRuneCannotSplit covers splitOverlongWord's own
+// "fewer than two runes" decline: a single-character item too wide for its
+// own container (here, a 5px container against fakeMeasurer's 10px-per-rune)
+// has nothing to split off, so it falls through to forceOne and overflows as
+// one whole (single-item) line, same as with no word-break set at all.
+func TestWordBreakAllSingleRuneCannotSplit(t *testing.T) {
+ src := `a
`
+ div := findBox(layoutHTML(t, src, 1024), "div")
+ if len(div.Lines) != 1 || lineText(div.Lines[0]) != "a" {
+ t.Fatalf("expected 1 overflowing line %q, got %d: %v", "a", len(div.Lines), linesText(div.Lines))
+ }
+}
+
+// TestWordBreakAllInherits confirms WordBreakAll reaches a descendant text
+// node through ordinary CSS inheritance (it is not, itself, testing anything
+// about splitOverlongWord beyond that the style actually arrives there) —
+// set on a div, read from a nested span's own overlong word.
+func TestWordBreakAllInherits(t *testing.T) {
+ src := `aaaaaaaaaa
`
+ div := findBox(layoutHTML(t, src, 1024), "div")
+ if len(div.Lines) != 4 {
+ t.Fatalf("expected 4 lines (inherited word-break:break-all), got %d: %v", len(div.Lines), linesText(div.Lines))
+ }
+}
+
+// TestWordBreakAllDoesNotSplitAGluedRun exercises splitOverlongWord's own
+// disclosed scope limit — a glued run of more than one item (here, text
+// immediately followed by a same-element-boundary ``, engine#149's own
+// `152,3†` shape) is never split WITHIN the run; splitOverlongWord
+// declines (its glueRun check returns ok=false) and layoutInline falls
+// through to forceOne exactly as it would with no word-break set at all.
+//
+// That fallback's own result is a genuine, PRE-EXISTING inconsistency this
+// round found by accident and is NOT fixing: forceOne places only the run's
+// FIRST item (see forceOne's own doc comment — it has never called glueRun
+// at all), so the run ends up split at the glue seam onto two lines here,
+// even though glueRun's own doc comment calls that exact seam unsplittable.
+// Confirmed unrelated to this round's own change: the identical two-line
+// split reproduces with no word-break/overflow-wrap property set anywhere.
+// No confirmed real-world page trigger for THIS specific combination (an
+// unbreakable glued run wider than its own container) was found, so it is
+// disclosed here rather than chased — this test pins the actual current
+// behaviour so a future round fixing forceOne itself has a red test to turn
+// green, rather than silently asserting something false about today's code.
+func TestWordBreakAllDoesNotSplitAGluedRun(t *testing.T) {
+ src := `aaaaaaaaaax
`
+ div := findBox(layoutHTML(t, src, 1024), "div")
+ if len(div.Lines) != 2 {
+ t.Fatalf("expected 2 lines (splitOverlongWord declines a glued run; forceOne's own pre-existing, unrelated behaviour then splits it at the glue seam instead), got %d: %v", len(div.Lines), linesText(div.Lines))
+ }
+ if lineText(div.Lines[0]) != "aaaaaaaaaa" || lineText(div.Lines[1]) != "x" {
+ t.Errorf("lines = %v, want [aaaaaaaaaa] [x]", linesText(div.Lines))
+ }
+}
+
func TestDisplayNoneSkipped(t *testing.T) {
src := `HIDDENy
`
items := firstLineItems(findBox(layoutHTML(t, src, 1024), "div"))
diff --git a/layout/linebreak_test.go b/layout/linebreak_test.go
index ac65007..25dfcfe 100644
--- a/layout/linebreak_test.go
+++ b/layout/linebreak_test.go
@@ -31,6 +31,15 @@ func lineText(l *LineBox) string {
return strings.Join(parts, " ")
}
+// linesText is lineText over a whole line list, for compact failure messages.
+func linesText(lines []*LineBox) []string {
+ out := make([]string, len(lines))
+ for i, l := range lines {
+ out[i] = lineText(l)
+ }
+ return out
+}
+
func TestWrapItemsEmpty(t *testing.T) {
if got := WrapItems(nil, 100); got != nil {
t.Errorf("empty = %v", got)