From e25abae05161a87aaa5235a50a2bb9f4276ed547 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 7 Sep 2026 09:10:00 +0200 Subject: [PATCH] paginate: mark the boundaries inside avoid boxes once, not per boundary per page allowed scanned every break-inside: avoid group for every candidate boundary on every page; a print stylesheet that sets break-inside: avoid on table rows (Wikipedia's does) turns a long table into thousands of groups, and a page of the countries table took 13.7 s to paginate, the Go article 3.9 s. blockedBoundaries now sweeps the groups once with a binary search each and a difference array, so a boundary's verdict is a lookup. Same breaks (the answer key and every synthetic case pass); 10 000 avoid rows paginate in the test's budget. Co-Authored-By: Claude Fable 5.1 --- paginate/paginate.go | 48 ++++++++++++++++++++++++++++++--------- paginate/paginate_test.go | 30 ++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/paginate/paginate.go b/paginate/paginate.go index 1b60476..83ad3c2 100644 --- a/paginate/paginate.go +++ b/paginate/paginate.go @@ -72,6 +72,7 @@ func Paginate(root *layout.Box, o Options) []float64 { if len(atoms) == 0 { return nil } + blocked := blockedBoundaries(atoms, groups, o.PageHeight) var tops []float64 pageTop := 0.0 pageH := o.PageHeight @@ -102,7 +103,7 @@ func Paginate(root *layout.Box, o Options) []float64 { case j < i: cut = i + 1 // the first atom alone overflows: let it, and cut after it default: - cut = choose(atoms, groups, i, j+1, pageH) + cut = choose(atoms, blocked, i, j+1) } if cut >= len(atoms) { return tops @@ -194,16 +195,47 @@ func collect(root *layout.Box) ([]*atom, []group) { return atoms, groups } +// blockedBoundaries marks, once for the document, every boundary b (a cut +// before atoms[b]) that falls strictly inside a break-inside: avoid box +// small enough to be kept on a page. One sweep over the groups with a +// binary search each, so a table of ten thousand rows each asking not to +// be cut costs a log factor rather than a scan per boundary per page — the +// per-boundary scan made a Wikipedia table page take thirteen seconds. +func blockedBoundaries(atoms []*atom, groups []group, pageH float64) []bool { + blocked := make([]bool, len(atoms)+1) + diff := make([]int, len(atoms)+2) + for _, g := range groups { + if g.bottom-g.top > pageH+epsilon { + continue // cannot be kept whole: its boundaries are fair game + } + // atoms whose top is strictly inside (g.top, g.bottom) + lo := sort.Search(len(atoms), func(i int) bool { return atoms[i].top > g.top+epsilon }) + hi := sort.Search(len(atoms), func(i int) bool { return atoms[i].top >= g.bottom-epsilon }) + if lo < hi { + diff[lo]++ + diff[hi]-- + } + } + depth := 0 + for b := range blocked { + if b < len(diff) { + depth += diff[b] + } + blocked[b] = depth > 0 + } + return blocked +} + // choose picks the boundary to cut at on a page holding atoms[i:max] // (max is the first atom that does not fit, always a real atom here): the // largest b in (i, max] allowed by the constraints; when none is, the // orphans/widows rule is dropped and the search repeated; when still none // is, the avoid rules are dropped too and the page is simply filled — the // spec's own order of relaxation. -func choose(atoms []*atom, groups []group, i, max int, pageH float64) int { +func choose(atoms []*atom, blocked []bool, i, max int) int { for tier := 0; tier < 2; tier++ { for b := max; b > i; b-- { - if allowed(atoms, groups, b, pageH, tier) { + if allowed(atoms, blocked, b, tier) { return b } } @@ -213,17 +245,11 @@ func choose(atoms []*atom, groups []group, i, max int, pageH float64) int { // allowed reports whether cutting before atoms[b] respects the constraints // still in force at tier: 0 all of them, 1 without orphans/widows. -func allowed(atoms []*atom, groups []group, b int, pageH float64, tier int) bool { +func allowed(atoms []*atom, blocked []bool, b int, tier int) bool { prev, next := atoms[b-1], atoms[b] - if prev.avoidAfter || next.avoidBefore { + if prev.avoidAfter || next.avoidBefore || blocked[b] { return false } - y := next.top - for _, g := range groups { - if g.bottom-g.top <= pageH+epsilon && g.top < y-epsilon && y < g.bottom-epsilon { - return false // inside an avoid box that could be kept whole - } - } if tier < 1 && prev.block != nil && prev.block == next.block { st := prev.block.Style orphans, widows := 2, 2 diff --git a/paginate/paginate_test.go b/paginate/paginate_test.go index a7e8204..a42ea40 100644 --- a/paginate/paginate_test.go +++ b/paginate/paginate_test.go @@ -5,6 +5,7 @@ package paginate import ( "testing" + "time" "github.com/go-webengine/engine/css" "github.com/go-webengine/engine/dom" @@ -309,3 +310,32 @@ func TestWrapperRowIsDescendedIntoPlainRowIsOneAtom(t *testing.T) { t.Errorf("plain row: breaks %v, want [20] (the row overflows whole, the cut is after it)", got) } } + +// Ten thousand rows each asking not to be cut inside (a large table whose +// print stylesheet sets break-inside: avoid on tr, as Wikipedia's does) must +// paginate in well under a second: the blocked boundaries are computed once. +func TestManyAvoidGroupsPaginateFast(t *testing.T) { + const rows = 10000 + kids := make([]*layout.Box, rows) + for i := range kids { + kids[i] = &layout.Box{Node: &dom.Node{Type: dom.Element, Tag: "tr"}, Style: &css.Style{BreakInside: css.BreakInsideAvoid}, Y: float64(i) * 20, H: 20} + } + r := root(&layout.Box{Style: &css.Style{}, Y: 0, H: rows * 20, Children: kids}) + start := time.Now() + got := Breaks(r, 1000) + if d := time.Since(start); d > 2*time.Second { + t.Fatalf("pagination took %v", d) + } + if len(got) != rows*20/1000-1 { + t.Fatalf("breaks: %d, want %d", len(got), rows*20/1000-1) + } + // A group that straddles the page end with room to move: the cut lands + // before it, and a group taller than a page is cut through. + tall := &layout.Box{Style: &css.Style{BreakInside: css.BreakInsideAvoid, Orphans: 1, Widows: 1}, Y: 0, H: 3000} + for i := 0; i < 150; i++ { + tall.Lines = append(tall.Lines, &layout.LineBox{Y: float64(i) * 20, H: 20}) + } + if got := Breaks(root(tall), 1000); len(got) != 2 { + t.Fatalf("tall avoid box: breaks %v, want two plain cuts", got) + } +}