Replace TextDrawer with more capable system
This commit is contained in:
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.
|
||||
}
|
||||
Reference in New Issue
Block a user