Replace TextDrawer with more capable system
This commit is contained in:
parent
0c22977693
commit
ae551c47ea
389
artist/text.go
389
artist/text.go
@ -1,389 +0,0 @@
|
||||
package artist
|
||||
|
||||
// import "fmt"
|
||||
import "image"
|
||||
import "unicode"
|
||||
import "image/draw"
|
||||
import "golang.org/x/image/font"
|
||||
import "golang.org/x/image/math/fixed"
|
||||
import "git.tebibyte.media/sashakoshka/tomo/canvas"
|
||||
|
||||
type characterLayout struct {
|
||||
x int
|
||||
width int
|
||||
character rune
|
||||
}
|
||||
|
||||
type wordLayout struct {
|
||||
position image.Point
|
||||
width int
|
||||
spaceAfter int
|
||||
breaksAfter int
|
||||
text []characterLayout
|
||||
}
|
||||
|
||||
// Align specifies a text alignment method.
|
||||
type Align int
|
||||
|
||||
const (
|
||||
// AlignLeft aligns the start of each line to the beginning point
|
||||
// of each dot.
|
||||
AlignLeft Align = iota
|
||||
AlignRight
|
||||
AlignCenter
|
||||
AlignJustify
|
||||
)
|
||||
|
||||
// TextDrawer is a struct that is capable of efficient rendering of wrapped
|
||||
// text, and calculating text bounds. It avoids doing redundant work
|
||||
// automatically.
|
||||
type TextDrawer struct {
|
||||
runes []rune
|
||||
face font.Face
|
||||
width int
|
||||
height int
|
||||
align Align
|
||||
wrap bool
|
||||
cut bool
|
||||
metrics font.Metrics
|
||||
|
||||
layout []wordLayout
|
||||
layoutClean bool
|
||||
layoutBounds image.Rectangle
|
||||
}
|
||||
|
||||
// SetText sets the text of the text drawer.
|
||||
func (drawer *TextDrawer) SetText (runes []rune) {
|
||||
// if drawer.runes == runes { return }
|
||||
drawer.runes = runes
|
||||
drawer.layoutClean = false
|
||||
}
|
||||
|
||||
// SetFace sets the font face of the text drawer.
|
||||
func (drawer *TextDrawer) SetFace (face font.Face) {
|
||||
if drawer.face == face { return }
|
||||
drawer.face = face
|
||||
drawer.layoutClean = false
|
||||
}
|
||||
|
||||
// SetMaxWidth sets a maximum width for the text drawer, and recalculates the
|
||||
// layout if needed. If zero is given, there will be no width limit and the text
|
||||
// will not wrap.
|
||||
func (drawer *TextDrawer) SetMaxWidth (width int) {
|
||||
if drawer.width == width { return }
|
||||
drawer.width = width
|
||||
drawer.wrap = width != 0
|
||||
drawer.layoutClean = false
|
||||
}
|
||||
|
||||
// SetMaxHeight sets a maximum height for the text drawer. Lines that are
|
||||
// entirely below this height will not be drawn, and lines that are on the cusp
|
||||
// of this maximum height will be clipped at the point that they cross it.
|
||||
func (drawer *TextDrawer) SetMaxHeight (height int) {
|
||||
if drawer.height == height { return }
|
||||
drawer.height = height
|
||||
drawer.cut = height != 0
|
||||
drawer.layoutClean = false
|
||||
}
|
||||
|
||||
// SetAlignment specifies how the drawer should align its text. For this to have
|
||||
// an effect, a maximum width must have been set.
|
||||
func (drawer *TextDrawer) SetAlignment (align Align) {
|
||||
if drawer.align == align { return }
|
||||
drawer.align = align
|
||||
drawer.layoutClean = false
|
||||
}
|
||||
|
||||
// Draw draws the drawer's text onto the specified canvas at the given offset.
|
||||
func (drawer *TextDrawer) Draw (
|
||||
destination canvas.Canvas,
|
||||
source Pattern,
|
||||
offset image.Point,
|
||||
) (
|
||||
updatedRegion image.Rectangle,
|
||||
) {
|
||||
wrappedSource := WrappedPattern {
|
||||
Pattern: source,
|
||||
Width: 0,
|
||||
Height: 0, // TODO: choose a better width and height
|
||||
}
|
||||
|
||||
if !drawer.layoutClean { drawer.recalculate() }
|
||||
// TODO: reimplement a version of draw mask that takes in a pattern and
|
||||
// only draws to a tomo.Canvas.
|
||||
for _, word := range drawer.layout {
|
||||
for _, character := range word.text {
|
||||
destinationRectangle,
|
||||
mask, maskPoint, _, ok := drawer.face.Glyph (
|
||||
fixed.P (
|
||||
offset.X + word.position.X + character.x,
|
||||
offset.Y + word.position.Y),
|
||||
character.character)
|
||||
invalid :=
|
||||
!ok ||
|
||||
unicode.IsSpace(character.character) ||
|
||||
character.character == 0
|
||||
if invalid { continue }
|
||||
|
||||
// FIXME:? clip destination rectangle if we are on the cusp of
|
||||
// the maximum height.
|
||||
|
||||
draw.DrawMask (
|
||||
destination,
|
||||
destinationRectangle,
|
||||
wrappedSource, image.Point { },
|
||||
mask, maskPoint,
|
||||
draw.Over)
|
||||
|
||||
updatedRegion = updatedRegion.Union(destinationRectangle)
|
||||
}}
|
||||
return
|
||||
}
|
||||
|
||||
// LayoutBounds returns a semantic bounding box for text to be used to determine
|
||||
// an offset for drawing. If a maximum width or height has been set, those will
|
||||
// be used as the width and height of the bounds respectively. The origin point
|
||||
// (0, 0) of the returned bounds will be equivalent to the baseline at the start
|
||||
// of the first line. As such, the minimum of the bounds will be negative.
|
||||
func (drawer *TextDrawer) LayoutBounds () (bounds image.Rectangle) {
|
||||
if !drawer.layoutClean { drawer.recalculate() }
|
||||
bounds = drawer.layoutBounds
|
||||
return
|
||||
}
|
||||
|
||||
// Em returns the width of an emspace.
|
||||
func (drawer *TextDrawer) Em () (width fixed.Int26_6) {
|
||||
if drawer.face == nil { return }
|
||||
width, _ = drawer.face.GlyphAdvance('M')
|
||||
return
|
||||
}
|
||||
|
||||
// LineHeight returns the height of one line.
|
||||
func (drawer *TextDrawer) LineHeight () (height fixed.Int26_6) {
|
||||
if drawer.face == nil { return }
|
||||
metrics := drawer.face.Metrics()
|
||||
height = metrics.Height
|
||||
return
|
||||
}
|
||||
|
||||
// ReccomendedHeightFor returns the reccomended max height if the text were to
|
||||
// have its maximum width set to the given width. This does not alter the
|
||||
// drawer's state.
|
||||
func (drawer *TextDrawer) ReccomendedHeightFor (width int) (height int) {
|
||||
if drawer.face == nil { return }
|
||||
if !drawer.layoutClean { drawer.recalculate() }
|
||||
metrics := drawer.face.Metrics()
|
||||
dot := fixed.Point26_6 { 0, metrics.Height }
|
||||
for _, word := range drawer.layout {
|
||||
if word.width + dot.X.Round() > width {
|
||||
dot.Y += metrics.Height
|
||||
dot.X = 0
|
||||
}
|
||||
dot.X += fixed.I(word.width + word.spaceAfter)
|
||||
if word.breaksAfter > 0 {
|
||||
dot.Y += fixed.I(word.breaksAfter).Mul(metrics.Height)
|
||||
dot.X = 0
|
||||
}
|
||||
}
|
||||
|
||||
return dot.Y.Round()
|
||||
}
|
||||
|
||||
// PositionOf returns the position of the character at the specified index
|
||||
// relative to the baseline.
|
||||
func (drawer *TextDrawer) PositionOf (index int) (position image.Point) {
|
||||
if !drawer.layoutClean { drawer.recalculate() }
|
||||
index ++
|
||||
for _, word := range drawer.layout {
|
||||
position = word.position
|
||||
for _, character := range word.text {
|
||||
index --
|
||||
position.X = word.position.X + character.x
|
||||
if index < 1 { return }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// AtPosition returns the index at the specified position relative to the
|
||||
// baseline.
|
||||
func (drawer *TextDrawer) AtPosition (position image.Point) (index int) {
|
||||
cursor := 0
|
||||
if !drawer.layoutClean { drawer.recalculate() }
|
||||
for _, word := range drawer.layout {
|
||||
for _, character := range word.text {
|
||||
bounds := drawer.boundsOfChar(character).Add(word.position)
|
||||
if position.In(bounds) {
|
||||
return cursor
|
||||
}
|
||||
cursor ++
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (drawer *TextDrawer) boundsOfChar (char characterLayout) (image.Rectangle) {
|
||||
return image.Rect (
|
||||
char.x, 0,
|
||||
char.x + char.width,
|
||||
drawer.metrics.Height.Ceil()).
|
||||
Sub(image.Pt(0, drawer.metrics.Descent.Round()))
|
||||
}
|
||||
|
||||
// Length returns the amount of runes in the drawer's text.
|
||||
func (drawer *TextDrawer) Length () (length int) {
|
||||
return len(drawer.runes)
|
||||
}
|
||||
|
||||
func (drawer *TextDrawer) recalculate () {
|
||||
drawer.layoutClean = true
|
||||
drawer.layout = nil
|
||||
drawer.layoutBounds = image.Rectangle { }
|
||||
if drawer.runes == nil { return }
|
||||
if drawer.face == nil { return }
|
||||
|
||||
drawer.metrics = drawer.face.Metrics()
|
||||
dot := fixed.Point26_6 { 0, 0 }
|
||||
index := 0
|
||||
horizontalExtent := 0
|
||||
currentCharacterX := fixed.Int26_6(0)
|
||||
|
||||
previousCharacter := rune(-1)
|
||||
for index < len(drawer.runes) {
|
||||
word := wordLayout { }
|
||||
word.position.X = dot.X.Round()
|
||||
word.position.Y = dot.Y.Round()
|
||||
|
||||
// process a word
|
||||
currentCharacterX = 0
|
||||
wordWidth := fixed.Int26_6(0)
|
||||
for index < len(drawer.runes) && !unicode.IsSpace(drawer.runes[index]) {
|
||||
character := drawer.runes[index]
|
||||
_, advance, ok := drawer.face.GlyphBounds(character)
|
||||
index ++
|
||||
if !ok { continue }
|
||||
|
||||
word.text = append(word.text, characterLayout {
|
||||
x: currentCharacterX.Round(),
|
||||
character: character,
|
||||
width: advance.Ceil(),
|
||||
})
|
||||
|
||||
dot.X += advance
|
||||
wordWidth += advance
|
||||
currentCharacterX += advance
|
||||
if dot.X.Round () > horizontalExtent {
|
||||
horizontalExtent = dot.X.Round()
|
||||
}
|
||||
if previousCharacter >= 0 {
|
||||
dot.X += drawer.face.Kern (
|
||||
previousCharacter,
|
||||
character)
|
||||
}
|
||||
previousCharacter = character
|
||||
}
|
||||
word.width = wordWidth.Round()
|
||||
|
||||
// detect if the word that was just processed goes out of
|
||||
// bounds, and if it does, wrap it
|
||||
if drawer.wrap &&
|
||||
word.width + word.position.X > drawer.width &&
|
||||
word.position.X > 0 {
|
||||
|
||||
word.position.Y += drawer.metrics.Height.Round()
|
||||
word.position.X = 0
|
||||
dot.Y += drawer.metrics.Height
|
||||
dot.X = wordWidth
|
||||
}
|
||||
|
||||
// process whitespace, going onto a new line if there is a
|
||||
// newline character
|
||||
spaceWidth := fixed.Int26_6(0)
|
||||
for index < len(drawer.runes) && unicode.IsSpace(drawer.runes[index]) {
|
||||
character := drawer.runes[index]
|
||||
_, advance, ok := drawer.face.GlyphBounds(character)
|
||||
index ++
|
||||
if !ok { continue }
|
||||
word.text = append(word.text, characterLayout {
|
||||
x: currentCharacterX.Round(),
|
||||
character: character,
|
||||
width: advance.Ceil(),
|
||||
})
|
||||
spaceWidth += advance
|
||||
currentCharacterX += advance
|
||||
|
||||
if character == '\n' {
|
||||
dot.Y += drawer.metrics.Height
|
||||
dot.X = 0
|
||||
word.breaksAfter ++
|
||||
break
|
||||
} else {
|
||||
dot.X += advance
|
||||
if previousCharacter >= 0 {
|
||||
dot.X += drawer.face.Kern (
|
||||
previousCharacter,
|
||||
character)
|
||||
}
|
||||
}
|
||||
previousCharacter = character
|
||||
}
|
||||
word.spaceAfter = spaceWidth.Round()
|
||||
|
||||
// add the word to the layout
|
||||
drawer.layout = append(drawer.layout, word)
|
||||
|
||||
// if there is a set maximum height, and we have crossed it,
|
||||
// stop processing more words. and remove any words that have
|
||||
// also crossed the line.
|
||||
if
|
||||
drawer.cut && (
|
||||
dot.Y - drawer.metrics.Ascent -
|
||||
drawer.metrics.Descent).Round() >
|
||||
drawer.height {
|
||||
|
||||
for
|
||||
index := len(drawer.layout) - 1;
|
||||
index >= 0; index -- {
|
||||
|
||||
if drawer.layout[index].position.Y < dot.Y.Round() {
|
||||
break
|
||||
}
|
||||
drawer.layout = drawer.layout[:index]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// add a little null to the last character
|
||||
if len(drawer.layout) > 0 {
|
||||
lastWord := &drawer.layout[len(drawer.layout) - 1]
|
||||
lastWord.text = append (
|
||||
lastWord.text,
|
||||
characterLayout {
|
||||
x: currentCharacterX.Round(),
|
||||
})
|
||||
}
|
||||
|
||||
if drawer.wrap {
|
||||
drawer.layoutBounds.Max.X = drawer.width
|
||||
} else {
|
||||
drawer.layoutBounds.Max.X = horizontalExtent
|
||||
}
|
||||
|
||||
if drawer.cut {
|
||||
drawer.layoutBounds.Min.Y = 0 - drawer.metrics.Ascent.Round()
|
||||
drawer.layoutBounds.Max.Y =
|
||||
drawer.height -
|
||||
drawer.metrics.Ascent.Round()
|
||||
} else {
|
||||
drawer.layoutBounds.Min.Y = 0 - drawer.metrics.Ascent.Round()
|
||||
drawer.layoutBounds.Max.Y =
|
||||
dot.Y.Round() +
|
||||
drawer.metrics.Descent.Round()
|
||||
}
|
||||
|
||||
// TODO:
|
||||
// for each line, calculate the bounds as if the words are left aligned,
|
||||
// and then at the end of the process go through each line and re-align
|
||||
// everything. this will make the process far simpler.
|
||||
}
|
3
textdraw/doc.go
Normal file
3
textdraw/doc.go
Normal file
@ -0,0 +1,3 @@
|
||||
// Package textdraw implements utilities for drawing text, as well as performing
|
||||
// text wrapping and bounds calculation.
|
||||
package textdraw
|
57
textdraw/drawer.go
Normal file
57
textdraw/drawer.go
Normal file
@ -0,0 +1,57 @@
|
||||
package textdraw
|
||||
|
||||
import "image"
|
||||
import "unicode"
|
||||
import "image/draw"
|
||||
import "golang.org/x/image/math/fixed"
|
||||
import "git.tebibyte.media/sashakoshka/tomo/canvas"
|
||||
import "git.tebibyte.media/sashakoshka/tomo/artist"
|
||||
|
||||
// Drawer is an extended TypeSetter that is able to draw text. Much like
|
||||
// TypeSetter, It has no constructor and its zero value can be used safely.
|
||||
type Drawer struct { TypeSetter }
|
||||
|
||||
// Draw draws the drawer's text onto the specified canvas at the given offset.
|
||||
func (drawer Drawer) Draw (
|
||||
destination canvas.Canvas,
|
||||
source artist.Pattern,
|
||||
offset image.Point,
|
||||
) (
|
||||
updatedRegion image.Rectangle,
|
||||
) {
|
||||
wrappedSource := artist.WrappedPattern {
|
||||
Pattern: source,
|
||||
Width: 0,
|
||||
Height: 0, // TODO: choose a better width and height
|
||||
}
|
||||
|
||||
drawer.For (func (
|
||||
index int,
|
||||
char rune,
|
||||
position fixed.Point26_6,
|
||||
) bool {
|
||||
destinationRectangle,
|
||||
mask, maskPoint, _, ok := drawer.face.Glyph (
|
||||
fixed.P (
|
||||
offset.X + position.X.Round(),
|
||||
offset.Y + position.Y.Round()),
|
||||
char)
|
||||
if !ok || unicode.IsSpace(char) || char == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// FIXME:? clip destination rectangle if we are on the cusp of
|
||||
// the maximum height.
|
||||
|
||||
draw.DrawMask (
|
||||
destination,
|
||||
destinationRectangle,
|
||||
wrappedSource, image.Point { },
|
||||
mask, maskPoint,
|
||||
draw.Over)
|
||||
|
||||
updatedRegion = updatedRegion.Union(destinationRectangle)
|
||||
return true
|
||||
})
|
||||
return
|
||||
}
|
168
textdraw/layout.go
Normal file
168
textdraw/layout.go
Normal file
@ -0,0 +1,168 @@
|
||||
package textdraw
|
||||
|
||||
import "unicode"
|
||||
import "golang.org/x/image/font"
|
||||
import "golang.org/x/image/math/fixed"
|
||||
|
||||
// Align specifies a text alignment method.
|
||||
type Align int
|
||||
|
||||
const (
|
||||
// AlignLeft aligns the start of each line to the beginning point
|
||||
// of each dot.
|
||||
AlignLeft Align = iota
|
||||
AlignRight
|
||||
AlignCenter
|
||||
AlignJustify
|
||||
)
|
||||
|
||||
// RuneLayout contains layout information for a single rune relative to its
|
||||
// word.
|
||||
type RuneLayout struct {
|
||||
X fixed.Int26_6
|
||||
Width fixed.Int26_6
|
||||
Rune rune
|
||||
}
|
||||
|
||||
// WordLayout contains layout information for a single word relative to its
|
||||
// line.
|
||||
type WordLayout struct {
|
||||
X fixed.Int26_6
|
||||
Width fixed.Int26_6
|
||||
SpaceAfter fixed.Int26_6
|
||||
Runes []RuneLayout
|
||||
}
|
||||
|
||||
// DoWord consumes exactly one word from the given string, and produces a word
|
||||
// layout according to the given font. It returns the remaining text as well.
|
||||
func DoWord (text []rune, face font.Face) (word WordLayout, remaining []rune) {
|
||||
remaining = text
|
||||
gettingSpace := false
|
||||
x := fixed.Int26_6(0)
|
||||
lastRune := rune(-1)
|
||||
for _, char := range text {
|
||||
// if we run into a line break, we must break out immediately
|
||||
// because it is not DoWord's job to handle that.
|
||||
if char == '\n' { break }
|
||||
|
||||
// if we suddenly run into spaces, and then run into a word
|
||||
// again, we must break out immediately.
|
||||
if unicode.IsSpace(char) {
|
||||
gettingSpace = true
|
||||
} else if gettingSpace {
|
||||
break
|
||||
}
|
||||
|
||||
// apply kerning
|
||||
if lastRune >= 0 { x += face.Kern(lastRune, char) }
|
||||
lastRune = char
|
||||
|
||||
// consume and process the rune
|
||||
remaining = remaining[1:]
|
||||
_, advance, ok := face.GlyphBounds(char)
|
||||
if !ok { continue }
|
||||
word.Runes = append (word.Runes, RuneLayout {
|
||||
X: x,
|
||||
Width: advance,
|
||||
Rune: char,
|
||||
})
|
||||
|
||||
// advance
|
||||
if gettingSpace {
|
||||
word.SpaceAfter += advance
|
||||
} else {
|
||||
word.Width += advance
|
||||
}
|
||||
x += advance
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// LastRune returns the last rune in the word.
|
||||
func (word WordLayout) LastRune () rune {
|
||||
if word.Runes == nil {
|
||||
return -1
|
||||
} else {
|
||||
return word.Runes[len(word.Runes) - 1].Rune
|
||||
}
|
||||
}
|
||||
|
||||
// FirstRune returns the last rune in the word.
|
||||
func (word WordLayout) FirstRune () rune {
|
||||
if word.Runes == nil {
|
||||
return -1
|
||||
} else {
|
||||
return word.Runes[0].Rune
|
||||
}
|
||||
}
|
||||
|
||||
// LineLayout contains layout information for a single line.
|
||||
type LineLayout struct {
|
||||
Y fixed.Int26_6
|
||||
Width fixed.Int26_6
|
||||
SpaceAfter fixed.Int26_6
|
||||
Words []WordLayout
|
||||
BreakAfter bool
|
||||
}
|
||||
|
||||
// DoLine consumes exactly one line from the given string, and produces a line
|
||||
// layout according to the given font. It returns the remaining text as well. If
|
||||
// maxWidth is greater than zero, this function will stop processing words once
|
||||
// the limit is crossed. The word which would have crossed over the limit will
|
||||
// not be processed.
|
||||
func DoLine (text []rune, face font.Face, maxWidth fixed.Int26_6) (line LineLayout, remaining []rune) {
|
||||
remaining = text
|
||||
x := fixed.Int26_6(0)
|
||||
lastRune := rune(-1)
|
||||
lastWord := WordLayout { }
|
||||
for {
|
||||
// process one word
|
||||
word, remainingFromWord := DoWord(remaining, face)
|
||||
|
||||
// apply kerning and position. yeah, its unlikely that a letter
|
||||
// will have kerning with a whitespace character. but like, what
|
||||
// if, you know?
|
||||
if lastRune >= 0 && word.FirstRune() >= 0 {
|
||||
x += face.Kern(lastRune, word.FirstRune())
|
||||
}
|
||||
lastRune = word.LastRune()
|
||||
word.X = x
|
||||
x += word.Width
|
||||
|
||||
// if we have gone over the maximum width, stop processing
|
||||
// words (if maxWidth is even specified)
|
||||
if maxWidth > 0 && x > maxWidth { break }
|
||||
|
||||
remaining = remainingFromWord
|
||||
|
||||
// if the word actually has contents, add it
|
||||
if word.Runes != nil {
|
||||
lastWord = word
|
||||
line.Words = append(line.Words, word)
|
||||
}
|
||||
|
||||
// if we have hit the end of the line, stop processing words
|
||||
if len(remaining) == 0 { break }
|
||||
if remaining[0] == '\n' {
|
||||
line.BreakAfter = true
|
||||
remaining = remaining[1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// set the line's width. this is subject to be overridden by the
|
||||
// TypeSetter to match the longest line.
|
||||
if maxWidth > 0 {
|
||||
line.Width = maxWidth
|
||||
} else {
|
||||
line.Width = lastWord.X + lastWord.Width
|
||||
line.SpaceAfter = lastWord.SpaceAfter
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Align aligns the text in the line according to the specified alignment
|
||||
// method.
|
||||
func (line *LineLayout) Align (align Align) {
|
||||
// TODO
|
||||
}
|
88
textdraw/layout_test.go
Normal file
88
textdraw/layout_test.go
Normal file
@ -0,0 +1,88 @@
|
||||
package textdraw
|
||||
|
||||
import "testing"
|
||||
import "golang.org/x/image/math/fixed"
|
||||
import "git.tebibyte.media/sashakoshka/tomo/defaultfont"
|
||||
|
||||
func TestDoWord (test *testing.T) {
|
||||
text := []rune("The quick brown fox")
|
||||
word, remaining := DoWord(text, defaultfont.FaceRegular)
|
||||
|
||||
expect := "quick brown fox"
|
||||
if string(remaining) != expect {
|
||||
test.Fatalf (
|
||||
`text: "%s", remaining: "%s" expected: "%s"`,
|
||||
string(text), string(remaining), expect)
|
||||
}
|
||||
|
||||
if len(word.Runes) != 4 {
|
||||
test.Fatalf(`wrong rune length %d`, len(word.Runes))
|
||||
}
|
||||
|
||||
if word.FirstRune() != 'T' {
|
||||
test.Fatalf(`wrong first rune %s`, string(word.FirstRune()))
|
||||
}
|
||||
|
||||
if word.LastRune() != ' ' {
|
||||
test.Fatalf(`wrong last rune %s`, string(word.FirstRune()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoLine (test *testing.T) {
|
||||
// case 1
|
||||
text := []rune("The quick brown fox\njumped over the lazy dog")
|
||||
line, remaining := DoLine(text, defaultfont.FaceRegular, 0)
|
||||
|
||||
expect := "jumped over the lazy dog"
|
||||
if string(remaining) != expect {
|
||||
test.Fatalf (
|
||||
`text: "%s", remaining: "%s" expected: "%s"`,
|
||||
string(text), string(remaining), expect)
|
||||
}
|
||||
|
||||
if len(line.Words) != 4 {
|
||||
test.Fatalf(`wrong word count %d`, len(line.Words))
|
||||
}
|
||||
|
||||
if !line.BreakAfter {
|
||||
test.Fatalf(`did not set BreakAfter to true`)
|
||||
}
|
||||
|
||||
// case 2
|
||||
text = []rune("jumped over the lazy dog")
|
||||
line, remaining = DoLine(text, defaultfont.FaceRegular, 0)
|
||||
|
||||
expect = ""
|
||||
if string(remaining) != expect {
|
||||
test.Fatalf (
|
||||
`text: "%s", remaining: "%s" expected: "%s"`,
|
||||
string(text), string(remaining), expect)
|
||||
}
|
||||
|
||||
if len(line.Words) != 5 {
|
||||
test.Fatalf(`wrong word count %d`, len(line.Words))
|
||||
}
|
||||
|
||||
if line.BreakAfter {
|
||||
test.Fatalf(`did not set BreakAfter to false`)
|
||||
}
|
||||
|
||||
// case 3
|
||||
text = []rune("jumped over the lazy dog")
|
||||
line, remaining = DoLine(text, defaultfont.FaceRegular, fixed.I(7 * 12))
|
||||
|
||||
expect = "the lazy dog"
|
||||
if string(remaining) != expect {
|
||||
test.Fatalf (
|
||||
`text: "%s", remaining: "%s" expected: "%s"`,
|
||||
string(text), string(remaining), expect)
|
||||
}
|
||||
|
||||
if len(line.Words) != 2 {
|
||||
test.Fatalf(`wrong word count %d`, len(line.Words))
|
||||
}
|
||||
|
||||
if line.BreakAfter {
|
||||
test.Fatalf(`did not set BreakAfter to false`)
|
||||
}
|
||||
}
|
292
textdraw/setter.go
Normal file
292
textdraw/setter.go
Normal file
@ -0,0 +1,292 @@
|
||||
package textdraw
|
||||
|
||||
import "image"
|
||||
import "golang.org/x/image/font"
|
||||
import "golang.org/x/image/math/fixed"
|
||||
|
||||
// TypeSetter manages several lines of text, and can perform layout operations
|
||||
// on them. It automatically avoids performing redundant work. It has no
|
||||
// constructor and its zero value can be used safely.
|
||||
type TypeSetter struct {
|
||||
lines []LineLayout
|
||||
text []rune
|
||||
|
||||
layoutClean bool
|
||||
alignClean bool
|
||||
|
||||
align Align
|
||||
face font.Face
|
||||
maxWidth int
|
||||
maxHeight int
|
||||
|
||||
layoutBounds image.Rectangle
|
||||
layoutBoundsSpace image.Rectangle
|
||||
}
|
||||
|
||||
func (setter *TypeSetter) needLayout () {
|
||||
if setter.layoutClean { return }
|
||||
setter.layoutClean = true
|
||||
setter.alignClean = false
|
||||
|
||||
// we need to have a font and some text to do anything
|
||||
setter.lines = nil
|
||||
setter.layoutBounds = image.Rectangle { }
|
||||
setter.layoutBoundsSpace = image.Rectangle { }
|
||||
if len(setter.text) == 0 { return }
|
||||
if setter.face == nil { return }
|
||||
|
||||
horizontalExtent := fixed.Int26_6(0)
|
||||
horizontalExtentSpace := fixed.Int26_6(0)
|
||||
metrics := setter.face.Metrics()
|
||||
remaining := setter.text
|
||||
y := fixed.Int26_6(0)
|
||||
maxY := fixed.I(setter.maxHeight) + metrics.Height
|
||||
for len(remaining) > 0 && y < maxY {
|
||||
// process one line
|
||||
line, remainingFromLine := DoLine (
|
||||
remaining, setter.face, fixed.I(setter.maxWidth))
|
||||
remaining = remainingFromLine
|
||||
|
||||
// add the line
|
||||
line.Y = y
|
||||
y += metrics.Height
|
||||
if line.Width > horizontalExtent {
|
||||
horizontalExtent = line.Width
|
||||
}
|
||||
lineWidthSpace := line.Width + line.SpaceAfter
|
||||
if lineWidthSpace > horizontalExtentSpace {
|
||||
horizontalExtentSpace = lineWidthSpace
|
||||
}
|
||||
setter.lines = append(setter.lines, line)
|
||||
}
|
||||
|
||||
// set all line widths to horizontalExtent if we don't have a specified
|
||||
// maximum width
|
||||
if setter.maxWidth == 0 {
|
||||
for index := range setter.lines {
|
||||
setter.lines[index].Width = horizontalExtent
|
||||
}
|
||||
setter.layoutBounds.Max.X = horizontalExtent.Round()
|
||||
setter.layoutBoundsSpace.Max.X = horizontalExtentSpace.Round()
|
||||
} else {
|
||||
setter.layoutBounds.Max.X = setter.maxWidth
|
||||
setter.layoutBoundsSpace.Max.X = setter.maxWidth
|
||||
}
|
||||
|
||||
if setter.maxHeight == 0 {
|
||||
setter.layoutBounds.Min.Y = -metrics.Ascent.Round()
|
||||
setter.layoutBounds.Max.Y =
|
||||
y.Round() +
|
||||
metrics.Descent.Round()
|
||||
} else {
|
||||
setter.layoutBounds.Min.Y = -metrics.Ascent.Round()
|
||||
setter.layoutBounds.Max.Y =
|
||||
setter.maxHeight -
|
||||
metrics.Ascent.Round()
|
||||
}
|
||||
setter.layoutBoundsSpace.Min.Y = setter.layoutBounds.Min.Y
|
||||
setter.layoutBoundsSpace.Max.Y = setter.layoutBounds.Max.Y
|
||||
}
|
||||
|
||||
func (setter *TypeSetter) needAlignedLayout () {
|
||||
if setter.alignClean && setter.layoutClean { return }
|
||||
setter.needLayout()
|
||||
setter.alignClean = true
|
||||
|
||||
for index := range setter.lines {
|
||||
setter.lines[index].Align(setter.align)
|
||||
}
|
||||
}
|
||||
|
||||
// SetAlign sets the alignment method of the typesetter.
|
||||
func (setter *TypeSetter) SetAlign (align Align) {
|
||||
if setter.align == align { return }
|
||||
setter.alignClean = false
|
||||
setter.align = align
|
||||
}
|
||||
|
||||
// SetText sets the text content of the typesetter.
|
||||
func (setter *TypeSetter) SetText (text []rune) {
|
||||
setter.layoutClean = false
|
||||
setter.alignClean = false
|
||||
setter.text = text
|
||||
}
|
||||
|
||||
// SetFace sets the font face of the typesetter.
|
||||
func (setter *TypeSetter) SetFace (face font.Face) {
|
||||
if setter.face == face { return }
|
||||
setter.layoutClean = false
|
||||
setter.alignClean = false
|
||||
setter.face = face
|
||||
}
|
||||
|
||||
// SetMaxWidth sets the maximum width of the typesetter. If the maximum width
|
||||
// is greater than zero, the text will wrap to that width. If the maximum width
|
||||
// is zero, the text will not wrap and instead extend as far as it needs to.
|
||||
func (setter *TypeSetter) SetMaxWidth (width int) {
|
||||
if setter.maxWidth == width { return }
|
||||
setter.layoutClean = false
|
||||
setter.alignClean = false
|
||||
setter.maxWidth = width
|
||||
}
|
||||
|
||||
// SetMaxHeight sets the maximum height of the typesetter. If the maximum height
|
||||
// is greater than zero, no lines will be laid out past that point. If the
|
||||
// maximum height is zero, the text's maximum height will not be constrained.
|
||||
func (setter *TypeSetter) SetMaxHeight (heignt int) {
|
||||
if setter.maxHeight == heignt { return }
|
||||
setter.layoutClean = false
|
||||
setter.alignClean = false
|
||||
setter.maxHeight = heignt
|
||||
}
|
||||
|
||||
// Em returns the width of one emspace according to the typesetter's font, which
|
||||
// is the width of the capital letter 'M'.
|
||||
func (setter *TypeSetter) Em () (width fixed.Int26_6) {
|
||||
if setter.face == nil { return 0 }
|
||||
width, _ = setter.face.GlyphAdvance('M')
|
||||
return
|
||||
}
|
||||
|
||||
// LineHeight returns the height of one line according to the typesetter's font.
|
||||
func (setter *TypeSetter) LineHeight () fixed.Int26_6 {
|
||||
if setter.face == nil { return 0 }
|
||||
return setter.face.Metrics().Height
|
||||
}
|
||||
|
||||
// MaxWidth returns the maximum width of the typesetter as set by SetMaxWidth.
|
||||
func (setter *TypeSetter) MaxWidth () int {
|
||||
return setter.maxWidth
|
||||
}
|
||||
|
||||
// MaxHeight returns the maximum height of the typesetter as set by
|
||||
// SetMaxHeight.
|
||||
func (setter *TypeSetter) MaxHeight () int {
|
||||
return setter.maxHeight
|
||||
}
|
||||
|
||||
// Face returns the TypeSetter's font face as set by SetFace.
|
||||
func (setter *TypeSetter) Face () font.Face {
|
||||
return setter.face
|
||||
}
|
||||
|
||||
// RuneIterator is a function that can iterate accross a typesetter's runes.
|
||||
type RuneIterator func (
|
||||
index int,
|
||||
char rune,
|
||||
position fixed.Point26_6,
|
||||
) (
|
||||
keepGoing bool,
|
||||
)
|
||||
|
||||
// For calls the specified iterator for every rune in the typesetter. If the
|
||||
// iterator returns false, the loop will immediately stop.
|
||||
func (setter *TypeSetter) For (iterator RuneIterator) {
|
||||
setter.needAlignedLayout()
|
||||
|
||||
index := 0
|
||||
for _, line := range setter.lines {
|
||||
for _, word := range line.Words {
|
||||
for _, char := range word.Runes {
|
||||
iterator (index, char.Rune, fixed.Point26_6 {
|
||||
X: word.X + char.X,
|
||||
Y: line.Y,
|
||||
})
|
||||
index ++
|
||||
}}
|
||||
if line.BreakAfter { index ++ }
|
||||
}
|
||||
}
|
||||
|
||||
// AtPosition returns the index of the rune at the specified position.
|
||||
func (setter *TypeSetter) AtPosition (position fixed.Point26_6) (index int) {
|
||||
setter.needAlignedLayout()
|
||||
|
||||
if setter.lines == nil { return }
|
||||
if setter.face == nil { return }
|
||||
metrics := setter.face.Metrics()
|
||||
|
||||
// find the first line who's bottom bound is greater than position.Y. if
|
||||
// we haven't found it, then dont set the line variable (defaults to the
|
||||
// last line)
|
||||
line := setter.lines[len(setter.lines) - 1]
|
||||
for _, curLine := range setter.lines {
|
||||
for _, curWord := range curLine.Words {
|
||||
index += len(curWord.Runes)
|
||||
}
|
||||
if curLine.Y + metrics.Descent > position.Y {
|
||||
line = curLine
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if line.Words == nil { return }
|
||||
|
||||
// find the first rune who's right bound is greater than position.X.
|
||||
for _, curWord := range line.Words {
|
||||
for _, curChar := range curWord.Runes {
|
||||
if curWord.X + curChar.X + curChar.Width > position.X {
|
||||
break
|
||||
}
|
||||
index ++
|
||||
}
|
||||
if line.BreakAfter { index ++ }
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// PositionAt returns the position of the rune at the specified index.
|
||||
func (setter *TypeSetter) PositionAt (index int) (position fixed.Point26_6) {
|
||||
setter.needAlignedLayout()
|
||||
|
||||
setter.For (func (i int, r rune, p fixed.Point26_6) bool {
|
||||
position = p
|
||||
return i < index
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// LayoutBounds returns the semantic bounding box of the text. The origin point
|
||||
// (0, 0) of the rectangle corresponds to the origin of the first line's
|
||||
// baseline.
|
||||
func (setter *TypeSetter) LayoutBounds () (image.Rectangle) {
|
||||
setter.needLayout()
|
||||
return setter.layoutBounds
|
||||
|
||||
}
|
||||
|
||||
// LayoutBoundsSpace is like LayoutBounds, but it also takes into account the
|
||||
// trailing whitespace at the end of each line (if it exists).
|
||||
func (setter *TypeSetter) LayoutBoundsSpace () (image.Rectangle) {
|
||||
setter.needLayout()
|
||||
return setter.layoutBoundsSpace
|
||||
}
|
||||
|
||||
// ReccomendedHeightFor returns the reccomended max height if the text were to
|
||||
// have its maximum width set to the given width. This does not alter the
|
||||
// typesetter's state.
|
||||
func (setter *TypeSetter) ReccomendedHeightFor (width int) (height int) {
|
||||
setter.needLayout()
|
||||
|
||||
if setter.lines == nil { return }
|
||||
if setter.face == nil { return }
|
||||
|
||||
metrics := setter.face.Metrics()
|
||||
dot := fixed.Point26_6 { 0, metrics.Height }
|
||||
for _, line := range setter.lines {
|
||||
for _, word := range line.Words {
|
||||
if word.Width + dot.X > fixed.I(width) {
|
||||
dot.Y += metrics.Height
|
||||
dot.X = 0
|
||||
}
|
||||
dot.X += word.Width + word.SpaceAfter
|
||||
}
|
||||
if line.BreakAfter {
|
||||
dot.Y += metrics.Height
|
||||
dot.X = 0
|
||||
}
|
||||
}
|
||||
|
||||
return dot.Y.Round()
|
||||
}
|
Reference in New Issue
Block a user