This repository has been archived on 2023-08-08. You can view files and clone it, but cannot push or open issues or pull requests.
tomo-old/artist/text.go
Sasha Koshka 34bf3038ac Replaced tomo.Image with tomo.Canvas and tomo.Pattern
This is the first step in transitioning the API over to the new
design. The new tomo.Canvas interface gives drawing functions
direct access to data buffers and eliminates overhead associated
with calling functions for every pixel.

The entire artist package will be remade around this.
2023-01-14 01:54:57 -05:00

283 lines
7.5 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"
type characterLayout struct {
x int
character rune
}
type wordLayout struct {
position image.Point
width 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 {
text string
runes []rune
face font.Face
width int
height int
align Align
wrap bool
cut bool
layout []wordLayout
layoutClean bool
layoutBounds image.Rectangle
}
// SetText sets the text of the text drawer.
func (drawer *TextDrawer) SetText (text string) {
if drawer.text == text { return }
drawer.text = text
drawer.runes = []rune(text)
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 tomo.Canvas,
source Pattern,
offset image.Point,
) (
updatedRegion image.Rectangle,
) {
if !drawer.layoutClean { drawer.recalculate() }
// TODO: reimplement a version of draw mask that takes in a pattern
// 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)
// if !ok { continue }
// FIXME: clip destination rectangle if we are on the cusp of
// the maximum height.
// draw.DrawMask (
// destination,
// destinationRectangle,
// source, 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
}
func (drawer *TextDrawer) recalculate () {
drawer.layoutClean = true
drawer.layout = nil
drawer.layoutBounds = image.Rectangle { }
if drawer.runes == nil { return }
if drawer.face == nil { return }
metrics := drawer.face.Metrics()
dot := fixed.Point26_6 { 0, 0 }
index := 0
horizontalExtent := 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 := fixed.Int26_6(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,
})
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 += metrics.Height.Round()
word.position.X = 0
dot.Y += metrics.Height
dot.X = wordWidth
}
// add the word to the layout
drawer.layout = append(drawer.layout, word)
// skip over whitespace, going onto a new line if there is a
// newline character
for index < len(drawer.runes) && unicode.IsSpace(drawer.runes[index]) {
character := drawer.runes[index]
if character == '\n' {
dot.Y += metrics.Height
dot.X = 0
previousCharacter = character
index ++
} else {
_, advance, ok := drawer.face.GlyphBounds(character)
index ++
if !ok { continue }
dot.X += advance
if previousCharacter >= 0 {
dot.X += drawer.face.Kern (
previousCharacter,
character)
}
previousCharacter = character
}
}
// 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 - metrics.Ascent - 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
}
}
if drawer.wrap {
drawer.layoutBounds.Max.X = drawer.width
println("aaa")
} else {
drawer.layoutBounds.Max.X = horizontalExtent
}
if drawer.cut {
drawer.layoutBounds.Min.Y = 0 - metrics.Ascent.Round()
drawer.layoutBounds.Max.Y = drawer.height - metrics.Ascent.Round()
} else {
drawer.layoutBounds.Min.Y = 0 - metrics.Ascent.Round()
drawer.layoutBounds.Max.Y = dot.Y.Round() + 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.
}