Replace TextDrawer with more capable system

This commit is contained in:
2023-02-15 18:17:17 -05:00
parent 0c22977693
commit ae551c47ea
6 changed files with 608 additions and 389 deletions

3
textdraw/doc.go Normal file
View 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
View 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
View 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
View 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
View 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()
}