9 Commits

3 changed files with 163 additions and 88 deletions

View File

@@ -1,5 +1,7 @@
# typeset
[![Go Reference](https://pkg.go.dev/badge/git.tebibyte.media/tomo/typeset.svg)](https://pkg.go.dev/git.tebibyte.media/tomo/typeset)
Typeset provides utilities for text layout, wrapping, and rendering.
The state of a text layout is stored in a TypeSetter, and it can be drawn to any

View File

@@ -8,12 +8,11 @@ import "golang.org/x/image/math/fixed"
type Align int
const (
// AlignLeft aligns the start of each line to the beginning point
// of each dot.
AlignLeft Align = iota
AlignRight
AlignCenter
AlignJustify
// X | Y
AlignStart Align = iota // left | top
AlignMiddle // center | center
AlignEnd // right | bottom
AlignEven // justified | evenly spaced
)
// RuneLayout contains layout information for a single rune relative to its
@@ -111,7 +110,7 @@ type LineLayout struct {
// wrap is set to true, this function will stop processing words once maxWidth
// is crossed. The word which would have crossed over the limit will not be
// processed.
func DoLine (text []rune, face font.Face, wrap bool, maxWidth fixed.Int26_6) (line LineLayout, remaining []rune) {
func DoLine (text []rune, face font.Face, wrap bool, width fixed.Int26_6) (line LineLayout, remaining []rune) {
remaining = text
x := fixed.Int26_6(0)
lastWord := WordLayout { }
@@ -122,9 +121,9 @@ func DoLine (text []rune, face font.Face, wrap bool, maxWidth fixed.Int26_6) (li
word.X = x
x += word.Width
// if we have gone over the maximum width, stop processing
// if we have gone over the preferred width, stop processing
// words (if wrap is enabled)
if !isFirstWord && wrap && x > maxWidth {
if !isFirstWord && wrap && x > width {
break
}
@@ -149,27 +148,38 @@ func DoLine (text []rune, face font.Face, wrap bool, maxWidth fixed.Int26_6) (li
}
// set the width of the line's content.
line.Width = maxWidth
line.ContentWidth = lastWord.X + lastWord.Width
line.SpaceAfter = lastWord.SpaceAfter
line.Width = width
line.SpaceAfter = lastWord.SpaceAfter
return
}
// Length returns the amount of runes within the line, including the trailing
// line break if it exists.
func (line *LineLayout) Length () int {
lineSize := 0
for _, word := range line.Words {
lineSize += len(word.Runes)
}
if line.BreakAfter { lineSize ++ }
return lineSize
}
// Align aligns the text in the line according to the specified alignment
// method.
func (line *LineLayout) Align (align Align) {
if len(line.Words) == 0 { return }
if align == AlignJustify {
if align == AlignEven {
line.justify()
return
}
leftOffset := -line.Words[0].X
if align == AlignCenter {
if align == AlignMiddle {
leftOffset += (line.Width - line.ContentWidth) / 2
} else if align == AlignRight {
} else if align == AlignEnd {
leftOffset += line.Width - line.ContentWidth
}
@@ -180,7 +190,7 @@ func (line *LineLayout) Align (align Align) {
func (line *LineLayout) justify () {
if len(line.Words) < 2 {
line.Align(AlignLeft)
line.Align(AlignStart)
return
}
@@ -191,8 +201,8 @@ func (line *LineLayout) justify () {
trueContentWidth += word.Width
}
spaceCount := fixed.Int26_6(len(line.Words) - 1)
spacePerWord := (line.Width - trueContentWidth) / spaceCount
spaceCount := len(line.Words) - 1
spacePerWord := (line.Width - trueContentWidth) / fixed.Int26_6(spaceCount)
x := fixed.Int26_6(0)
for index, word := range line.Words {
line.Words[index].X = x

203
setter.go
View File

@@ -14,11 +14,10 @@ type TypeSetter struct {
layoutClean bool
alignClean bool
align Align
face font.Face
maxWidth int
maxHeight int
wrap bool
hAlign, vAlign Align
face font.Face
width, height int
wrap bool
minWidth fixed.Int26_6
layoutBounds image.Rectangle
@@ -30,28 +29,20 @@ func (setter *TypeSetter) needLayout () {
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 { }
setter.minWidth = 0
if len(setter.text) == 0 { return }
if setter.face == nil { 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)
metrics := setter.face.Metrics()
remaining := setter.text
y := fixed.Int26_6(0)
for len(remaining) > 0 {
// process one line
line, remainingFromLine := DoLine (
remaining, setter.face, setter.wrap,
fixed.I(setter.maxWidth))
remaining = remainingFromLine
// add the line
// function to add line and update bounds statistics
addLine := func (line LineLayout) {
line.Y = y
y += metrics.Height
if line.ContentWidth > horizontalExtent {
@@ -63,23 +54,39 @@ func (setter *TypeSetter) needLayout () {
}
setter.lines = append(setter.lines, line)
}
setter.minWidth = horizontalExtentSpace
setter.layoutBounds.Max.X = setter.maxWidth
setter.layoutBoundsSpace.Max.X = setter.maxWidth
// process every line until there are no more remaining runes
for len(remaining) > 0 {
line, remainingFromLine := DoLine (
remaining, setter.face, setter.wrap,
fixed.I(setter.width))
remaining = remainingFromLine
addLine(line)
}
// if there were no lines processed or the last line has a break after
// it, add a blank line at the end
needBlankLine :=
len(setter.lines) == 0 ||
setter.lines[len(setter.lines) - 1].BreakAfter
if needBlankLine { addLine(LineLayout { }) }
// if we are wrapping text, the width must be the user-set width
if setter.wrap {
horizontalExtent = fixed.I(setter.width)
horizontalExtentSpace = fixed.I(setter.width)
}
// calculate layout boundaries
setter.minWidth = horizontalExtentSpace
setter.layoutBounds.Max.X = horizontalExtent.Round()
setter.layoutBoundsSpace.Max.X = horizontalExtentSpace.Round()
y -= metrics.Height
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.layoutBounds.Min.Y = -metrics.Ascent.Round()
setter.layoutBounds.Max.Y =
y.Round() +
metrics.Descent.Round()
setter.layoutBoundsSpace.Min.Y = setter.layoutBounds.Min.Y
setter.layoutBoundsSpace.Max.Y = setter.layoutBounds.Max.Y
}
@@ -89,15 +96,76 @@ func (setter *TypeSetter) needAlignedLayout () {
setter.needLayout()
setter.alignClean = true
setter.alignHorizontally()
setter.alignVertically()
}
// should only be called from within setter.needAlignedLayout
func (setter *TypeSetter) alignHorizontally () {
if len(setter.lines) == 0 { return }
for index := range setter.lines {
align := setter.align
if index == len(setter.lines) - 1 && align == AlignJustify {
align = AlignLeft
align := setter.hAlign
// if the horizontal align is even, align lines with breaks
// after them to the left anyways
if align == AlignEven {
except :=
index == len(setter.lines) - 1 ||
setter.lines[index].BreakAfter
if except { align = AlignStart }
}
// align line
setter.lines[index].Align(align)
}
}
// should only be called from within setter.needAlignedLayout
func (setter *TypeSetter) alignVertically () {
if setter.height == 0 { return }
if len(setter.lines) == 0 { return }
if setter.vAlign == AlignEven {
setter.justifyVertically()
return
}
// determine how much to shift lines
topOffset := fixed.I(0)
contentHeight := setter.layoutBoundsSpace.Dy()
if setter.vAlign == AlignMiddle {
topOffset += fixed.I((setter.height - contentHeight) / 2)
} else if setter.vAlign == AlignEnd {
topOffset += fixed.I(setter.height - contentHeight)
}
// we may be re-aligning already aligned text. if the text is shifted
// away from the origin, account for that.
if len(setter.lines) > 0 {
topOffset -= setter.lines[0].Y
}
// shift lines
for index := range setter.lines {
setter.lines[index].Y += topOffset
}
}
// should only be called from within setter.alignVertically
func (setter *TypeSetter) justifyVertically () {
spaceCount := len(setter.lines) - 1
contentHeight := setter.layoutBoundsSpace.Dy()
spacePerLine :=
fixed.Int26_6(setter.height - contentHeight) /
fixed.Int26_6(spaceCount)
y := fixed.Int26_6(0)
for index := range setter.lines {
setter.lines[index].Y = y
y += spacePerLine + setter.LineHeight()
}
}
// SetWrap sets whether or not the text wraps around and forms new lines.
func (setter *TypeSetter) SetWrap (wrap bool) {
if setter.wrap == wrap { return }
@@ -106,10 +174,11 @@ func (setter *TypeSetter) SetWrap (wrap bool) {
}
// SetAlign sets the alignment method of the typesetter.
func (setter *TypeSetter) SetAlign (align Align) {
if setter.align == align { return }
func (setter *TypeSetter) SetAlign (horizontal, vertical Align) {
if setter.hAlign == horizontal && setter.vAlign == vertical { return }
setter.alignClean = false
setter.align = align
setter.hAlign = horizontal
setter.vAlign = vertical
}
// SetText sets the text content of the typesetter.
@@ -127,22 +196,23 @@ func (setter *TypeSetter) SetFace (face font.Face) {
setter.face = face
}
// SetMaxWidth sets the maximum width of the typesetter.
func (setter *TypeSetter) SetMaxWidth (width int) {
if setter.maxWidth == width { return }
// SetWidth sets the width of the typesetter. Text will still be able
// to overflow outside of this width if wrapping is disabled.
func (setter *TypeSetter) SetWidth (width int) {
if setter.width == width { return }
setter.layoutClean = false
setter.alignClean = false
setter.maxWidth = width
setter.width = 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 }
// SetHeight sets the height of the typesetter. If the height is greater than
// zero, no lines will be laid out past it. If the height is zero, the text's
// maximum height will not be constrained.
func (setter *TypeSetter) SetHeight (heignt int) {
if setter.height == heignt { return }
setter.layoutClean = false
setter.alignClean = false
setter.maxHeight = heignt
setter.height = heignt
}
// Em returns the width of one emspace according to the typesetter's font, which
@@ -159,15 +229,14 @@ func (setter *TypeSetter) LineHeight () fixed.Int26_6 {
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
// Width returns the height of the typesetter as set by SetWidth.
func (setter *TypeSetter) Width () int {
return setter.width
}
// MaxHeight returns the maximum height of the typesetter as set by
// SetMaxHeight.
func (setter *TypeSetter) MaxHeight () int {
return setter.maxHeight
// Height returns the height of the typesetter as set by SetHeight.
func (setter *TypeSetter) Height () int {
return setter.height
}
// Face returns the TypeSetter's font face as set by SetFace.
@@ -238,27 +307,21 @@ func (setter *TypeSetter) AtPosition (position fixed.Point26_6) (index int) {
// 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)
metrics := setter.face.Metrics()
line := setter.lines[len(setter.lines) - 1]
lineSize := 0
for _, curLine := range setter.lines {
for _, curWord := range curLine.Words {
lineSize += len(curWord.Runes)
}
if curLine.BreakAfter { lineSize ++ }
index += lineSize
metrics := setter.face.Metrics()
lastLine := setter.lines[len(setter.lines) - 1]
for _, curLine := range setter.lines {
if curLine.Y + metrics.Descent > position.Y {
line = curLine
lastLine = curLine
break
}
index += curLine.Length()
}
index -= lineSize
if line.Words == nil { return }
if lastLine.Words == nil { return }
// find the first rune who's right bound is greater than position.X.
for _, curWord := range line.Words {
for _, curWord := range lastLine.Words {
for _, curChar := range curWord.Runes {
x := curWord.X + curChar.X + curChar.Width
if x > position.X { goto foundRune }