390 lines
10 KiB
Go
390 lines
10 KiB
Go
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.
|
|
}
|