Compare commits

...

21 Commits

Author SHA1 Message Date
4c42b12cc1 Have the test example stress the flow algorithm a bit more 2024-09-19 21:22:21 -04:00
6efe40efc2 Sort methods of TypeSetter alphabetically 2024-09-19 21:10:02 -04:00
17385c4c9a Add RunesWithNull iterator 2024-09-19 21:08:14 -04:00
e38cac8e3b Benchmark text flow 2024-09-19 21:05:18 -04:00
90b2e49664 Fix doc comment on TypeSetter.Runes 2024-09-19 10:43:50 -04:00
2ae07af710 Add little TODO 2024-09-19 10:43:13 -04:00
ce21b34f86 Fix LayoutBoundsSpace 2024-09-19 10:42:48 -04:00
ff8f86e034 5 is the appears to be the sweet spot for average token len 2024-09-19 09:40:00 -04:00
288a1fb9ef Fix token slice size estimation to massively reduce waste 2024-09-19 09:36:01 -04:00
a91816df6c Benchmarks report token waste 2024-09-19 09:33:14 -04:00
aa486fe660 Report waste as a fraction 2024-09-19 09:20:44 -04:00
cde84b8756 Benchmarks report len, cap, and waste of runes slice 2024-09-19 09:18:11 -04:00
013b121d46 Made Chinese lorem ipsum same # of runes as Latin lorem ipsum 2024-09-19 09:06:29 -04:00
85c48461c7 Add parsing benchmarks for latin and chinese text 2024-09-19 09:03:09 -04:00
0342e25456 LayoutBounds can have a negative start
This causes the LayoutBounds of center-aligned, left-aligned text
to be more accurate
2024-09-19 08:49:49 -04:00
f0adca5c37 Minimum size is calculated from extents instead of vice versa 2024-09-19 08:31:21 -04:00
56024caaf5 Fix LayoutBounds calculation
More work is needed for LayoutBoundsSpace
2024-09-19 08:07:39 -04:00
5171cbac16 Fix the memory problem 2024-09-18 23:48:21 -04:00
300c28853d Add another memory test just to be sure 2024-09-18 23:37:57 -04:00
6fabfd9fd0 Test whether tokens reference the same memory as runes 2024-09-18 23:28:27 -04:00
6b6e485aca Add documentation on what the DrawBounds colors mean 2024-09-18 22:54:25 -04:00
7 changed files with 297 additions and 78 deletions

View File

@ -50,7 +50,10 @@ func Draw (destination draw.Image, setter *TypeSetter, offset fixed.Point26_6, c
}
// DrawBounds draws the LayoutBounds, MinimumSize, and LayoutBoundsSpace of a
// TypeSetter to the given image.
// TypeSetter to the given image using these colors:
// - Red: LayoutBounds
// - Green: MinimumSize
// - Blue: LayoutBoundsSpace
func DrawBounds (destination draw.Image, setter *TypeSetter, offset fixed.Point26_6) {
blue := color.RGBA { B: 255, A: 255 }
red := color.RGBA { R: 255, A: 255 }

View File

@ -94,6 +94,8 @@ func main () {
drawText(img, &setter, image.Rect(256, 0, 512, 256).Add(image.Pt(1536, 512)).Inset(4))
setter.SetAlign(typeset.AlignEven, typeset.AlignEnd)
drawText(img, &setter, image.Rect( 0, 256, 256, 512).Add(image.Pt(1536, 512)).Inset(4))
setter.SetAlign(typeset.AlignEnd, typeset.AlignMiddle)
drawText(img, &setter, image.Rect(256, 256, 512, 512).Add(image.Pt(1536, 512)).Inset(4))
file, err := os.Create("output.png")
if err != nil { panic(err) }
@ -156,7 +158,7 @@ the sea welcomes me.
This is a very long line that will probably get cut off.`
const haikuAlt = `An ocean voyage.
As waves break over the bow,
As waves break over the bow,
the sea welcomes me.
This is a short ending`

94
flow.go
View File

@ -18,27 +18,51 @@ func reflow (
metrics := face.Metrics()
var dot fixed.Point26_6
lineStart := 0
lineEnd := 0
lastWord := 0
lastToken := 0
nLines := 0
const notSeen = -1
firstWord := notSeen
lineStart := 0
lineEnd := 0
lastWord := 0
lastNonLineBreak := notSeen
nLines := 0
firstLine := true
newline := func () {
newline := func (wrapped bool) {
// if the line isn't empty
if lineStart != lineEnd {
// align line
alignLine (
tokens[lineStart:lineEnd],
size.X, xAlign, lineEnd == len(tokens))
// calculate extents
lastWordTok := tokens[lastWord]
lastTokenTok := tokens[lastToken]
lineMax := lastWordTok.position.X + lastWordTok.width
lineMaxSpace := lastTokenTok.position.X + lastTokenTok.width
if lineMax > minimumSize.X { minimumSize.X = lineMax }
if lineMaxSpace > extentsSpace.Max.X { extentsSpace.Max.X = lineMaxSpace }
var lineMin, lineMinSpace, lineMax, lineMaxSpace fixed.Int26_6
lineMinSpace = tokens[lineStart].position.X
if firstWord == notSeen {
lineMin = lineMinSpace
} else {
lineMin = tokens[firstWord].position.X
}
lastWordTok := tokens[lastWord]
if lastWordTok.kind == tokenKindWord {
// the line had a word in it
lineMax = lastWordTok.position.X + lastWordTok.width
}
if wrapped || lastNonLineBreak == notSeen {
lineMaxSpace = lineMax
} else {
lastTokenTok := tokens[lastNonLineBreak]
lineMaxSpace = lastTokenTok.position.X + lastTokenTok.width
}
// println(lineMax.String(), lineMaxSpace.String())
if lineMin < extents.Min.X || firstLine { extents.Min.X = lineMin }
if lineMinSpace < extentsSpace.Min.X || firstLine { extentsSpace.Min.X = lineMinSpace }
if lineMax > extents.Max.X { extents.Max.X = lineMax }
if lineMaxSpace > extentsSpace.Max.X { extentsSpace.Max.X = lineMaxSpace }
firstLine = false
}
// update dot
@ -46,8 +70,10 @@ func reflow (
dot.X = 0
// update indices, counts
lineStart = lineEnd
lastWord = lineEnd
lineStart = lineEnd
lastWord = lineEnd
lastNonLineBreak = notSeen
firstWord = notSeen
nLines ++
}
@ -57,15 +83,20 @@ func reflow (
for index, token := range tokens {
lineEnd = index
updateIndices := func () {
lastToken = index
if token.kind != tokenKindLineBreak {
lastNonLineBreak = index
}
if token.kind == tokenKindWord {
lastWord = index
if firstWord == notSeen {
firstWord = index
}
}
}
// demarcate lines
if sawLineBreak {
newline()
newline(false)
sawLineBreak = false
}
if token.kind == tokenKindLineBreak {
@ -78,7 +109,7 @@ func reflow (
token.kind == tokenKindWord &&
dot.X + token.width > size.X
if needWrap {
newline()
newline(true)
}
updateIndices()
tokens[index].position = dot
@ -86,23 +117,40 @@ func reflow (
}
}
lineEnd ++ // make lineEnd equal to len(tokens)
newline()
newline(false)
minimumSize.Y = metrics.Height * fixed.Int26_6(nLines) + metrics.Descent
// second, vertical alignment pass
alignLinesVertically(tokens, size.Y, minimumSize.Y, yAlign)
// calculate extents
extentsOffset := fixed.Point26_6 { Y: metrics.Ascent - tokens[0].position.Y }
extents.Max.X = minimumSize.X
extentsVerticalOffset := fixed.Point26_6 { Y: metrics.Ascent - tokens[0].position.Y }
extents.Max.Y = dot.Y + metrics.Descent
extentsSpace.Max.Y = dot.Y + metrics.Descent
extents = extents.Sub(extentsOffset)
extentsSpace = extentsSpace.Sub(extentsOffset)
minimumSize.X = fixedRectDx(extents)
minimumSize.Y = fixedRectDy(extents)
extents = extents.Sub(extentsVerticalOffset)
extentsSpace = extentsSpace.Sub(extentsVerticalOffset)
return
}
func fixedRectDx (rect fixed.Rectangle26_6) fixed.Int26_6 {
return rect.Max.X - rect.Min.X
}
func fixedRectDy (rect fixed.Rectangle26_6) fixed.Int26_6 {
return rect.Max.Y - rect.Min.Y
}
func calculateLineExtents (
firstWord, firstToken, lastWord, lastToken token,
) (
lineMin, lineMinSpace, lineMax, lineMaxSpace fixed.Int26_6,
) {
return
}
func alignLinesVertically (tokens []token, height, contentHeight fixed.Int26_6, align Align) {
if len(tokens) == 0 { return }
if align == AlignStart { return }

38
flow_test.go Normal file
View File

@ -0,0 +1,38 @@
package typeset
import "testing"
import "golang.org/x/image/math/fixed"
import "golang.org/x/image/font/basicfont"
func BenchmarkFlowLipsumLeftTop (benchmark *testing.B) {
benchmarkFlow(benchmark, false, AlignStart, AlignStart)
}
func BenchmarkFlowLipsumWrapLeftTop (benchmark *testing.B) {
benchmarkFlow(benchmark, true, AlignStart, AlignStart)
}
func BenchmarkFlowLipsumCenterTop (benchmark *testing.B) {
benchmarkFlow(benchmark, false, AlignMiddle, AlignStart)
}
func BenchmarkFlowLipsumWrapCenterTop (benchmark *testing.B) {
benchmarkFlow(benchmark, true, AlignMiddle, AlignStart)
}
func BenchmarkFlowLipsumWrapJustifyTop (benchmark *testing.B) {
benchmarkFlow(benchmark, true, AlignEven, AlignStart)
}
func benchmarkFlow (benchmark *testing.B, wrap bool, xAlign, yAlign Align) {
_, tokens := parseString(lipsumLt)
benchmark.ReportAllocs()
benchmark.ResetTimer()
for i := 0; i < benchmark.N; i ++ {
reflow (
tokens,
basicfont.Face7x13,
fixed.P(256, 256),
wrap, xAlign, yAlign)
}
}

View File

@ -5,14 +5,25 @@ import "unicode"
// TODO perhaps follow https://unicode.org/reports/tr14/
func parseString (text string) ([]runeLayout, []token) {
// TODO find an optimal size for both of these to minimize allocs. will
// require some testing.
runes := make([]runeLayout, 0, len(text) * 2 / 3)
tokens := make([]token, 0, len(text) / 4)
// alloc initial rune slice
runes := make([]runeLayout, 0, len(text) * 2 / 3)
// build the rune slice
// we need to do this before parsing into tokens, because otherwise
// a realloc will occur in the middle of it and the tokens at the start
// will be referencing old memory
for _, run := range text {
runes = append(runes, runeLayout {
run: run,
})
}
// alloc initial token slice
tokens := make([]token, 0, len(runes) / 5)
var index int
var startingIndex int
var run rune
var runl runeLayout
var lastRune rune
var tok token
@ -31,19 +42,16 @@ func parseString (text string) ([]runeLayout, []token) {
}
}
for index, run = range text {
runes = append(runes, runeLayout {
run: run,
})
// parse tokens
for index, runl = range runes {
switch {
case run == '\r':
case runl.run == '\r':
tokenBoundary()
// we don't know the token type yet. if next rune is a
// \n then this is a CRLF line break. if not, this is
// just a word.
case run == '\n':
case runl.run == '\n':
if lastRune == '\r' {
// continue the \r to make a CRLF line break
tok.kind = tokenKindLineBreak
@ -52,16 +60,16 @@ func parseString (text string) ([]runeLayout, []token) {
tok.kind = tokenKindLineBreak
}
case run == '\t':
case runl.run == '\t':
mustBeInToken(tokenKindTab)
case unicode.IsSpace(run):
case unicode.IsSpace(runl.run):
mustBeInToken(tokenKindSpace)
default:
mustBeInToken(tokenKindWord)
}
lastRune = run
lastRune = runl.run
}
index ++ // make index equal to len([]rune(text))

View File

@ -121,6 +121,104 @@ func TestParseString (test *testing.T) {
logTokens(test, correctTokens)
test.FailNow()
}
// TODO: ensure runeLayout slices in the tokens reference the same
// memory as the complete runes slice
test.Logf("changing first rune from %c to x", runes[0].run)
runes[0].run = 'x'
test.Logf("first rune is now %c", runes[0].run)
tokenRune := tokens[0].runes[0].run
if tokenRune != 'x' {
test.Fatalf (
"tokens does not reference the same memory as runes after changing runes: %c, %c",
runes[0].run, tokenRune)
}
runeIndex := 0
for tokenIndex, token := range tokens {
tokenRunePtr := &token.runes[0]
runePtr := &runes[runeIndex]
if runePtr != tokenRunePtr {
test.Fatalf (
"tokens[%d] does not reference runes[%d]: %p, %p",
tokenIndex, runeIndex, tokenRunePtr, runePtr)
}
runeIndex += len(token.runes)
}
}
func BenchmarkParseStringLatin (benchmark *testing.B) {
benchmark.ReportAllocs()
var rmeanLen, rmeanCap int
var tmeanLen, tmeanCap int
for i := 0; i < benchmark.N; i ++ {
runes, tokens := parseString(lipsumLt)
rmeanLen += len(runes)
rmeanCap += cap(runes)
tmeanLen += len(tokens)
tmeanCap += cap(tokens)
}
rmeanLen /= benchmark.N
rmeanCap /= benchmark.N
tmeanLen /= benchmark.N
tmeanCap /= benchmark.N
benchmark.ReportMetric(float64(rmeanCap) / float64(rmeanLen), "rune-waste")
benchmark.ReportMetric(float64(tmeanCap) / float64(tmeanLen), "token-waste")
}
func BenchmarkParseStringChinese (benchmark *testing.B) {
benchmark.ReportAllocs()
var rmeanLen, rmeanCap int
var tmeanLen, tmeanCap int
for i := 0; i < benchmark.N; i ++ {
runes, tokens := parseString(lipsumCn)
rmeanLen += len(runes)
rmeanCap += cap(runes)
tmeanLen += len(tokens)
tmeanCap += cap(tokens)
}
rmeanLen /= benchmark.N
rmeanCap /= benchmark.N
tmeanLen /= benchmark.N
tmeanCap /= benchmark.N
benchmark.ReportMetric(float64(rmeanCap) / float64(rmeanLen), "rune-waste")
benchmark.ReportMetric(float64(tmeanCap) / float64(tmeanLen), "token-waste")
}
const lipsumLt =
`Voluptatem impedit id id facilis et. Sit eligendi aspernatur dicta vitae ipsa officia enim harum. Occaecati quod harum quos temporibus officiis provident enim neque. Odio totam ducimus commodi quis minima ea.
Ut delectus quis a rem consectetur laudantium hic sequi. Vel sunt neque nisi excepturi id sit id ut. Dolores expedita et odio. Quibusdam sed et quam nostrum. Sed perspiciatis voluptatibus et.
Omnis qui tempore corrupti alias ut repellendus est. A officiis molestias perspiciatis ut dolores nihil. Ut officiis hic quo aut aut dolorum. Modi at molestiae praesentium ea eveniet aut porro.
Similique facere cum amet nesciunt dolorem nemo. Rerum temporibus iure maiores. Facere quam nihil quia debitis nihil est officia aliquam. Magnam aut alias consectetur. Velit cumque eligendi assumenda magni ratione. Est dolorem modi a unde.
Illo reprehenderit est sunt quaerat cum nihil non. Quia nihil placeat qui ex hic molestiae eligendi. Asperiores optio et nobis et.`
const lipsumCn =
`这很容易并且妨碍快乐让我们很难为这些人选择上述生活的职责他们被这样的事实蒙蔽了双眼他们现在不提供办公室我讨厌我们给小孩子们带来所有的好处
作为被选中的人他将跟随这里的赞美或者除非他们被排除在外否则他们不是意想不到的痛苦和仇恨但对某些人来说以及我们自己但让我们看看其中的乐趣和
当时所有腐败的人都必须被击退办公室的麻烦被视为无痛至于这里的服务无论是哪里还是让人心疼但目前的麻烦将会发生或持续下去
当没有人知道其中的痛苦时也要做同样的事情事物的时代确实更加伟大无所作为因为你不欠任何东西这是一些责任这将是伟大的或其他方面无论他选择什么他都必须非常小心有一种痛从何而来
他责怪他们在什么都没有的情况下才问因为没有人喜欢从这里选择麻烦对我们来说这是一个更艰难的选择
这很容易并且妨碍快乐让我们很难为这些人选择上述生活的职责他们被这样的事实蒙蔽了双眼他们现在不提供办公室我讨厌我们给小孩子们带来所有的好处
作为被选中的人他将跟随这里的赞美或者除非他们被排除在外否则他们不是意想不到的痛苦和仇恨但对某些人来说以及我们自己但让我们看看其中的乐趣和
当时所有腐败的人都必须被击退办公室的麻烦被视为无痛至于这里的服务无论是哪里还是让人心疼但目前的麻烦将会发生或持续下去
当没有人知道其中的痛苦时也要做同样的事情事物的时代确实更加伟大无所作为因为你不欠任何东西这是一些责任这将是伟大的或其他方面无论他选择什么他都必须非常小心有一种痛从何而来
他责怪他们在什么都没有的情况下才问因为没有人喜欢从这里选择麻烦对我们来说这是一个更艰难的选择
这很容易并且妨碍快乐让我们很难为这些人选择上述生活的职责他们被这样的事实蒙蔽了双眼他们现在不提供办公室我讨厌我们给小孩子们带来所有的好处
作为被选中的人他将跟随这里的赞美或者除非他们被排除在外否则他们不是意想不到的痛苦和仇恨但对某些人来说以及我们自己但让我们看看其中的乐趣和
当时所有腐败的人都必须被击退办公室的麻烦被视为无痛至于这里的服务无论是哪里还是让人心疼但目前的麻烦将会发生或持续下去
当没有人知道其中的痛苦时也要做同样的事情事物的`

View File

@ -47,6 +47,7 @@ func (tok token) String () string {
tok.position.X, tok.position.Y, tok.width)
}
// TODO: perhaps rename this to just "glyph"
type runeLayout struct {
x fixed.Int26_6
run rune
@ -88,13 +89,13 @@ type TypeSetter struct {
layoutBoundsSpace fixed.Rectangle26_6
}
// Runes returns an iterator for all runes in the TypeSetter, and thier positions.
// Runes returns an iterator for all runes in the TypeSetter, and their positions.
func (this *TypeSetter) Runes () RuneIter {
this.needFlow()
return func (yield func (fixed.Point26_6, rune) bool) {
for _, token := range this.tokens {
for _, runl := range token.runes {
pos := token.position
for _, tok := range this.tokens {
for _, runl := range tok.runes {
pos := tok.position
pos.X += runl.x
if !yield(pos, runl.run) { return }
}
@ -102,6 +103,27 @@ func (this *TypeSetter) Runes () RuneIter {
}
}
// RunesWithNull returns an iterator for all runes in the TypeSetter, plus an
// additional null rune at the end. This is useful for calculating the positions
// of things.
func (this *TypeSetter) RunesWithNull () RuneIter {
this.needFlow()
return func (yield func (fixed.Point26_6, rune) bool) {
var tok token
for _, tok = range this.tokens {
for _, runl := range tok.runes {
pos := tok.position
pos.X += runl.x
if !yield(pos, runl.run) { return }
}
}
pos := tok.position
pos.X += tok.width
yield(pos, 0)
}
}
// Em returns the width of one emspace according to the typesetter's typeface,
// which is the width of the capital letter 'M'.
func (this *TypeSetter) Em () fixed.Int26_6 {
@ -110,12 +132,9 @@ func (this *TypeSetter) Em () fixed.Int26_6 {
return width
}
// MinimumSize returns the minimum width and height needed to display text. If
// wrapping is enabled, this method will return { X: Em(), Y: 0 }.
func (this *TypeSetter) MinimumSize () fixed.Point26_6 {
if this.wrap { return fixed.Point26_6{ X: this.Em(), Y: 0 } }
this.needFlow()
return this.minimumSize
// Face returns the font face as set by SetFace.
func (this *TypeSetter) Face () font.Face {
return this.face
}
// LayoutBounds returns the semantic bounding box of the text. The origin point
@ -133,11 +152,19 @@ func (this *TypeSetter) LayoutBoundsSpace () fixed.Rectangle26_6 {
return this.layoutBoundsSpace
}
// MinimumSize returns the minimum width and height needed to display text. If
// wrapping is enabled, this method will return { X: Em(), Y: 0 }.
func (this *TypeSetter) MinimumSize () fixed.Point26_6 {
if this.wrap { return fixed.Point26_6{ X: this.Em(), Y: 0 } }
this.needFlow()
return this.minimumSize
}
// PositionAt returns the position of the rune at the specified index.
func (this *TypeSetter) PositionAt (index int) fixed.Point26_6 {
idx := 0
var position fixed.Point26_6
this.Runes()(func (pos fixed.Point26_6, run rune) bool {
this.RunesWithNull()(func (pos fixed.Point26_6, run rune) bool {
if index == idx {
position = pos
return false
@ -148,11 +175,19 @@ func (this *TypeSetter) PositionAt (index int) fixed.Point26_6 {
return position
}
// SetText sets the text of the TypeSetter.
func (this *TypeSetter) SetText (text string) {
if this.text == text { return }
this.text = text
this.invalidate(validationLevelTokens)
// SetAlign sets the horizontal and vertical alignment of the text.
func (this *TypeSetter) SetAlign (x, y Align) {
if this.xAlign == x && this.yAlign == y { return }
this.xAlign = x
this.yAlign = y
this.invalidate(validationLevelFlow)
}
// SetFace sets the font face the text will be laid out according to.
func (this *TypeSetter) SetFace (face font.Face) {
if this.face == face { return }
this.face = face
this.invalidate(validationLevelMeasurement)
}
// SetSize sets the width and height of the TypeSetter.
@ -162,6 +197,13 @@ func (this *TypeSetter) SetSize (size fixed.Point26_6) {
this.invalidate(validationLevelFlow)
}
// SetText sets the text of the TypeSetter.
func (this *TypeSetter) SetText (text string) {
if this.text == text { return }
this.text = text
this.invalidate(validationLevelTokens)
}
// SetWrap sets whether the text will wrap to the width specified by SetSize.
func (this *TypeSetter) SetWrap (wrap bool) {
if this.wrap == wrap { return }
@ -169,26 +211,6 @@ func (this *TypeSetter) SetWrap (wrap bool) {
this.invalidate(validationLevelFlow)
}
// SetAlign sets the horizontal and vertical alignment of the text.
func (this *TypeSetter) SetAlign (x, y Align) {
if this.xAlign == x && this.yAlign == y { return }
this.xAlign = x
this.yAlign = y
this.invalidate(validationLevelFlow)
}
// Face returns the font face as set by SetFace.
func (this *TypeSetter) Face () font.Face {
return this.face
}
// SetFace sets the font face the text will be laid out according to.
func (this *TypeSetter) SetFace (face font.Face) {
if this.face == face { return }
this.face = face
this.invalidate(validationLevelMeasurement)
}
func (this *TypeSetter) needTokens () {
if this.valid(validationLevelTokens) { return }
this.runes, this.tokens = parseString(this.text)