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

399 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
whitespace []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)
if !ok { 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 }
}
for _, character := range word.whitespace {
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 ++
}
for _, character := range word.whitespace {
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.whitespace = append(word.whitespace, 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.whitespace = append (
lastWord.whitespace,
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.
}