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
48 changes: 37 additions & 11 deletions paginate/paginate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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
Expand Down
30 changes: 30 additions & 0 deletions paginate/paginate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package paginate

import (
"testing"
"time"

"github.com/go-webengine/engine/css"
"github.com/go-webengine/engine/dom"
Expand Down Expand Up @@ -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)
}
}