23 Commits

Author SHA1 Message Date
37554dd719 Add measurement stage 2024-09-10 11:20:08 -04:00
569defdb36 Add parsing stage 2024-09-10 11:19:57 -04:00
0c9d50ebcd Remove setter.go 2024-09-10 11:19:31 -04:00
943fc57080 Remove old files from repository root 2024-09-10 11:18:13 -04:00
0592fe32b6 Add .editorconfig because why not 2024-09-10 11:17:52 -04:00
56cf7e3fb8 Update README.md 2024-09-10 11:17:29 -04:00
650ecf0c2e Back up old files 2024-09-10 11:17:10 -04:00
aa00b93bd3 The new alignment method works 2024-09-06 01:22:51 -04:00
8a22afe95a Restructure alignment process 2024-09-06 01:00:28 -04:00
ba1438b700 SetTabWidth now takes in an Int26_6, and doesn't use emspaces 2024-09-06 00:33:51 -04:00
2aa1d355ec Add SetTabWidth to Setter (non-functional currently) 2024-09-06 00:30:15 -04:00
6e3e288628 Change ReccomendedHeightFor -> RecommendedHeight
Closes #1
2024-09-06 00:23:44 -04:00
f2da861f1b Fix README.md 2024-09-06 00:23:36 -04:00
0beef86c58 TypeSetter.forInternal resets last known rune bound on new line
Closes #5
2024-09-03 21:17:29 -04:00
6a60458484 Create another For iterator that leaves out the fake null rune
Apparently it was intended behavior. Closes #4
2024-09-03 18:38:01 -04:00
021dd288b6 Drawer now draws tofu
Closes #2
2024-09-03 17:59:07 -04:00
b0e80ce961 DoWord adds a RuneLayout for invalid characters
Defaults to an emspace for the width. Progress on #2
2024-09-03 17:04:57 -04:00
a1bd411e43 Fix bug where vertically aligned text would creep downward 2024-05-05 02:35:25 -04:00
a54d40b52c Add godoc badge to README.md 2024-05-05 06:14:39 +00:00
8d9e0e1340 TypeSetter can now do vertical text alignment yay 2023-09-15 15:54:46 -04:00
92cb318972 make pumkin alllll better! 2023-09-15 15:22:44 -04:00
14deec24f5 API changes to support future functionality 2023-09-15 01:55:57 -04:00
388a113a01 Text bounds are now returned correctly 2023-09-14 20:28:32 -04:00
10 changed files with 641 additions and 153 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 8
charset = utf-8
[*.md]
indent_style = space
indent_size = 2

View File

@@ -1,6 +1,21 @@
# typeset
Typeset provides utilities for text layout, wrapping, and rendering.
[![Go Reference](https://pkg.go.dev/badge/git.tebibyte.media/tomo/typeset.svg)](https://pkg.go.dev/git.tebibyte.media/tomo/typeset)
The state of a text layout is stored in a TypeSetter, and it can be drawn to any
image.Image using a Drawer which "extends" TypeSetter.
Typeset provides utilities for text layout, wrapping, and rendering. It is
designed to avoid redundant work and minimize memory allocations wherever
posible in situations where the bounds of a section of text may change
frequently and its content semi-frequently. Text layout is performed by the
TypeSetter struct, which operates in a three-phase process:
1. Tokenization
2. Measurement
3. Layout, alignment
The results of these phases are memoized. When the state of the TypeSetter is
queried, it will run through only the required phases before returning a value.
The contents of a TypeSetter can be drawn onto any draw.Image using the Draw
function included within this package, but it is entirely possible to create a
custom draw function that iterates over TypeSetter.Runes that uses some other
method of drawing that's faster than five gazillion virtual method calls.

View File

@@ -1,52 +0,0 @@
package typeset
import "image"
import "unicode"
import "image/draw"
import "image/color"
import "golang.org/x/image/math/fixed"
// Drawer is an extended TypeSetter that is able to draw text. Much like
// TypeSetter, It has no constructor and its zero value can be used safely.
type Drawer struct { TypeSetter }
// Draw draws the drawer's text onto the specified canvas at the given offset.
func (drawer Drawer) Draw (
destination draw.Image,
color color.Color,
offset image.Point,
) (
updatedRegion image.Rectangle,
) {
source := image.NewUniform(color)
drawer.For (func (
index int,
char rune,
position fixed.Point26_6,
) bool {
destinationRectangle,
mask, maskPoint, _, ok := drawer.face.Glyph (
fixed.P (
offset.X + position.X.Round(),
offset.Y + position.Y.Round()),
char)
if !ok || unicode.IsSpace(char) || char == 0 {
return true
}
// 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 true
})
return
}

45
measure.go Normal file
View File

@@ -0,0 +1,45 @@
package typeset
import "golang.org/x/image/font"
import "golang.org/x/image/math/fixed"
func measure (tokens []token, face font.Face) {
var lastRune rune
for index, token := range tokens {
var x fixed.Int26_6
for index, runl := range token.runes {
advance, ok := face.GlyphAdvance(runl.run)
if !ok { advance = tofuAdvance(face) }
advance += face.Kern(lastRune, runl.run)
runl.x = x
x += advance
lastRune = runl.run
token.runes[index] = runl
}
token.width = x
tokens[index] = token
}
}
const tofuStandinRune = 'M'
const fallbackTofuAdvance = 16
const fallbackTofuWidth = 14
const fallbackTofuAscend = 16
func tofuAdvance (face font.Face) fixed.Int26_6 {
if advance, ok := face.GlyphAdvance(tofuStandinRune); ok {
return advance
} else {
return fallbackTofuAdvance
}
}
func tofuBounds (face font.Face) (fixed.Rectangle26_6, fixed.Int26_6) {
if bounds, advance, ok := face.GlyphBounds(tofuStandinRune); ok {
return bounds, advance
} else {
return fixed.R(0, -fallbackTofuAscend, fallbackTofuWidth, 0),
fallbackTofuAdvance
}
}

70
measure_test.go Normal file
View File

@@ -0,0 +1,70 @@
package typeset
import "testing"
import "golang.org/x/image/math/fixed"
import "golang.org/x/image/font/basicfont"
const basicfontFace7x13advance = 7
func tkw (kind tokenKind, value string, width fixed.Int26_6) token {
tok := tk(kind, value)
tok.width = width
for index, runl := range tok.runes {
runl.x = fixed.I(basicfontFace7x13advance * index)
tok.runes[index] = runl
}
return tok
}
func TestMeasure (test *testing.T) {
// ---- processing ----
tokens := []token {
tk(tokenKindWord, "hello"),
tk(tokenKindSpace, " "),
tk(tokenKindWord, "\rworld!"),
tk(tokenKindLineBreak, "\n"),
tk(tokenKindWord, "foo"),
tk(tokenKindLineBreak, "\n"),
tk(tokenKindLineBreak, "\r\n"),
tk(tokenKindWord, "bar"),
tk(tokenKindTab, "\t"),
tk(tokenKindWord, "baz"),
tk(tokenKindTab, "\t\t"),
tk(tokenKindWord, "something"),
}
measure(tokens, basicfont.Face7x13)
// ---- correct data ----
correctTokens := []token {
tkw(tokenKindWord, "hello", fixed.I(35)),
tkw(tokenKindSpace, " ", fixed.I( 7)),
tkw(tokenKindWord, "\rworld!", fixed.I(49)),
tkw(tokenKindLineBreak, "\n", fixed.I( 7)),
tkw(tokenKindWord, "foo", fixed.I(21)),
tkw(tokenKindLineBreak, "\n", fixed.I( 7)),
tkw(tokenKindLineBreak, "\r\n", fixed.I(14)),
tkw(tokenKindWord, "bar", fixed.I(21)),
tkw(tokenKindTab, "\t", fixed.I( 7)),
tkw(tokenKindWord, "baz", fixed.I(21)),
tkw(tokenKindTab, "\t\t", fixed.I(14)),
tkw(tokenKindWord, "something", fixed.I(63)),
}
// ---- testing ----
if len(tokens) != len(correctTokens) {
test.Logf("len(tokens) != len(correctTokens): %d, %d", len(tokens), len(correctTokens))
test.Log("GOT")
logTokens(test, tokens)
test.Log("CORRECT")
logTokens(test, correctTokens)
test.FailNow()
}
if !compareTokens(tokens, correctTokens) {
test.Log("tokens != correctTokens:")
test.Log("GOT")
logTokens(test, tokens)
test.Log("CORRECT")
logTokens(test, correctTokens)
test.FailNow()
}
}

84
old/drawer.go Normal file
View File

@@ -0,0 +1,84 @@
package typeset
import "image"
import "unicode"
import "image/draw"
import "image/color"
import "golang.org/x/image/math/fixed"
// Drawer is an extended TypeSetter that is able to draw text. Much like
// TypeSetter, It has no constructor and its zero value can be used safely.
type Drawer struct { TypeSetter }
// Draw draws the drawer's text onto the specified canvas at the given offset.
func (drawer Drawer) Draw (
destination draw.Image,
col color.Color,
offset image.Point,
) (
updatedRegion image.Rectangle,
) {
source := image.NewUniform(col)
drawer.ForRunes (func (
index int,
char rune,
position fixed.Point26_6,
) bool {
// leave empty space for space characters
if unicode.IsSpace(char) {
return true
}
dot := fixed.P (
offset.X + position.X.Round(),
offset.Y + position.Y.Round())
destinationRectangle,
mask, maskPoint, _, ok := drawer.face.Glyph(dot, char)
// tofu
if !ok {
drawer.drawTofu(char, destination, col, dot)
return true
}
// 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 true
})
return
}
func (drawer Drawer) drawTofu (
char rune,
destination draw.Image,
col color.Color,
position fixed.Point26_6,
) {
bounds, _ := tofuBounds(drawer.face)
rectBounds := image.Rect (
bounds.Min.X.Round(),
bounds.Min.Y.Round(),
bounds.Max.X.Round(),
bounds.Max.Y.Round()).Add(image.Pt(
position.X.Round(),
position.Y.Round()))
for x := rectBounds.Min.X; x < rectBounds.Max.X; x ++ {
destination.Set(x, rectBounds.Min.Y, col)
}
for y := rectBounds.Min.Y; y < rectBounds.Max.Y; y ++ {
destination.Set(rectBounds.Min.X, y, col)
destination.Set(rectBounds.Max.X - 1, y, col)
}
for x := rectBounds.Min.X; x < rectBounds.Max.X; x ++ {
destination.Set(x, rectBounds.Max.Y - 1, col)
}
}

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
@@ -59,13 +58,16 @@ func DoWord (text []rune, face font.Face) (word WordLayout, remaining []rune) {
// consume and process the rune
remaining = remaining[1:]
_, advance, ok := face.GlyphBounds(char)
if !ok { continue }
word.Runes = append (word.Runes, RuneLayout {
advance, ok := face.GlyphAdvance(char)
if !ok {
advance = tofuAdvance(face)
}
runeLayout := RuneLayout {
X: x,
Width: advance,
Rune: char,
})
}
word.Runes = append(word.Runes, runeLayout)
// advance
if gettingSpace {
@@ -111,20 +113,18 @@ 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 { }
isFirstWord := true
for {
// process one word
word, remainingFromWord := DoWord(remaining, face)
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
}
@@ -133,7 +133,6 @@ func DoLine (text []rune, face font.Face, wrap bool, maxWidth fixed.Int26_6) (li
// if the word actually has contents, add it
if word.Runes != nil {
lastWord = word
line.Words = append(line.Words, word)
}
@@ -149,9 +148,13 @@ 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
// TODO: just have RecommendedHeight want aligned layout?
if len(line.Words) > 0 {
lastWord := line.Words[len(line.Words) - 1]
line.ContentWidth = x - lastWord.SpaceAfter
line.SpaceAfter = lastWord.SpaceAfter
}
return
}
@@ -168,30 +171,45 @@ func (line *LineLayout) Length () int {
// Align aligns the text in the line according to the specified alignment
// method.
func (line *LineLayout) Align (align Align) {
func (line *LineLayout) Align (align Align, tabWidth fixed.Int26_6) {
if len(line.Words) == 0 { return }
if align == AlignEven {
line.justify(tabWidth)
} else {
line.contract(tabWidth)
var leftOffset fixed.Int26_6
if align == AlignMiddle {
leftOffset = (line.Width - line.ContentWidth) / 2
} else if align == AlignEnd {
leftOffset = line.Width - line.ContentWidth
}
if align == AlignJustify {
line.justify()
return
}
leftOffset := -line.Words[0].X
if align == AlignCenter {
leftOffset += (line.Width - line.ContentWidth) / 2
} else if align == AlignRight {
leftOffset += line.Width - line.ContentWidth
}
for index := range line.Words {
line.Words[index].X += leftOffset
for index := range line.Words {
line.Words[index].X += leftOffset
}
}
}
func (line *LineLayout) justify () {
if len(line.Words) < 2 {
line.Align(AlignLeft)
// assume line has content > 0
func (line *LineLayout) contract (tabWidth fixed.Int26_6) {
x := fixed.Int26_6(0)
for index, word := range line.Words {
word.X = x
x += word.Width
x += word.SpaceAfter
line.Words[index] = word
}
lastWord := line.Words[len(line.Words) - 1]
line.ContentWidth = lastWord.X + lastWord.Width
line.SpaceAfter = lastWord.SpaceAfter
}
// assume line has content > 0
func (line *LineLayout) justify (tabWidth fixed.Int26_6) {
if len(line.Words) <= 1 {
line.Align(AlignStart, tabWidth)
return
}
@@ -202,11 +220,31 @@ 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
x += spacePerWord + word.Width
}
}
func tabStop (x, tabWidth fixed.Int26_6, delta int) fixed.Int26_6 {
return fixed.I((tabWidth * 64 / x).Floor() + delta).Mul(tabWidth)
}
func tofuAdvance (face font.Face) fixed.Int26_6 {
if advance, ok := face.GlyphAdvance('M'); ok {
return advance
} else {
return 16
}
}
func tofuBounds (face font.Face) (fixed.Rectangle26_6, fixed.Int26_6) {
if bounds, advance, ok := face.GlyphBounds('M'); ok {
return bounds, advance
} else {
return fixed.R(0, -16, 14, 0), 16
}
}

View File

@@ -14,11 +14,11 @@ 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
tabWidth fixed.Int26_6
minWidth fixed.Int26_6
layoutBounds image.Rectangle
@@ -34,14 +34,15 @@ func (setter *TypeSetter) needLayout () {
setter.layoutBounds = image.Rectangle { }
setter.layoutBoundsSpace = image.Rectangle { }
setter.minWidth = 0
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)
// function to add line and update bounds statistics
addLine := func (line LineLayout) {
line.Y = y
y += metrics.Height
@@ -55,11 +56,11 @@ func (setter *TypeSetter) needLayout () {
setter.lines = append(setter.lines, line)
}
// process every line
// 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.maxWidth))
fixed.I(setter.width))
remaining = remainingFromLine
addLine(line)
}
@@ -71,23 +72,22 @@ func (setter *TypeSetter) needLayout () {
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 = setter.maxWidth
setter.layoutBoundsSpace.Max.X = setter.maxWidth
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
}
@@ -97,15 +97,73 @@ 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 align == AlignJustify {
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 = AlignLeft }
if except { align = AlignStart }
}
setter.lines[index].Align(align)
// align line
setter.lines[index].Align(align, setter.tabWidth)
}
}
// 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()
}
}
@@ -117,10 +175,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.
@@ -138,22 +197,31 @@ 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
}
// SetTabWidth sets the distance between tab stops.
func (setter *TypeSetter) SetTabWidth (tabWidth fixed.Int26_6) {
if setter.tabWidth == tabWidth { return }
setter.layoutClean = false
setter.alignClean = false
setter.tabWidth = tabWidth
}
// Em returns the width of one emspace according to the typesetter's font, which
@@ -170,15 +238,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.
@@ -201,8 +268,18 @@ type RuneIterator func (
)
// For calls the specified iterator for every rune in the typesetter. If the
// iterator returns false, the loop will immediately stop.
// iterator returns false, the loop will immediately stop. This method will
// insert a fake null rune at the end.
func (setter *TypeSetter) For (iterator RuneIterator) {
setter.forInternal(iterator, true)
}
// ForRunes is like For, but leaves out the fake null rune.
func (setter *TypeSetter) ForRunes (iterator RuneIterator) {
setter.forInternal(iterator, false)
}
func (setter *TypeSetter) forInternal (iterator RuneIterator, fakeNull bool) {
setter.needAlignedLayout()
index := 0
@@ -213,7 +290,7 @@ func (setter *TypeSetter) For (iterator RuneIterator) {
for _, word := range line.Words {
for _, char := range word.Runes {
lastCharRightBound = word.X + char.X + char.Width
keepGoing := iterator (index, char.Rune, fixed.Point26_6 {
keepGoing := iterator(index, char.Rune, fixed.Point26_6 {
X: word.X + char.X,
Y: line.Y,
})
@@ -222,21 +299,24 @@ func (setter *TypeSetter) For (iterator RuneIterator) {
}}
if line.BreakAfter {
keepGoing := iterator (index, '\n', fixed.Point26_6 {
keepGoing := iterator(index, '\n', fixed.Point26_6 {
X: lastCharRightBound,
Y: line.Y,
})
if !keepGoing { return }
index ++
lastCharRightBound = fixed.Int26_6(0)
}
}
keepGoing := iterator (index, '\000', fixed.Point26_6 {
X: lastCharRightBound,
Y: lastLineY,
})
if !keepGoing { return }
index ++
if fakeNull {
keepGoing := iterator (index, '\000', fixed.Point26_6 {
X: lastCharRightBound,
Y: lastLineY,
})
if !keepGoing { return }
index ++
}
}
// AtPosition returns the index of the rune at the specified position.
@@ -315,10 +395,10 @@ func (setter *TypeSetter) MinimumSize () image.Point {
return image.Pt(width.Round(), height.Round())
}
// 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
// RecommendedHeight returns the reccomended max height if the text were to have
// its maximum width set to the given width. This does not alter the
// typesetter's state.
func (setter *TypeSetter) ReccomendedHeightFor (width int) (height int) {
func (setter *TypeSetter) RecommendedHeight (width int) (height int) {
setter.needLayout()
if setter.lines == nil { return }

70
parse.go Normal file
View File

@@ -0,0 +1,70 @@
package typeset
import "unicode"
// TODO perhaps follow https://unicode.org/reports/tr14/
func parseString (text string) ([]runeLayout, []token) {
// TODO find an optimal size for both of these to minimize allocs. will
// require some testing.
runes := make([]runeLayout, 0, len(text) * 2 / 3)
tokens := make([]token, 0, len(text) / 4)
var index int
var startingIndex int
var run rune
var lastRune rune
var tok token
tokenBoundary := func () {
if startingIndex != index {
tok.runes = runes[startingIndex:index]
startingIndex = index
tokens = append(tokens, tok)
}
tok = token { }
}
mustBeInToken := func (kind tokenKind) {
if tok.kind != kind {
tokenBoundary()
tok.kind = kind
}
}
for index, run = range text {
runes = append(runes, runeLayout {
run: run,
})
switch {
case run == '\r':
tokenBoundary()
// we don't know the token type yet. if next rune is a
// \n then this is a CRLF line break. if not, this is
// just a word.
case run == '\n':
if lastRune == '\r' {
// continue the \r to make a CRLF line break
tok.kind = tokenKindLineBreak
} else {
tokenBoundary()
tok.kind = tokenKindLineBreak
}
case run == '\t':
mustBeInToken(tokenKindTab)
case unicode.IsSpace(run):
mustBeInToken(tokenKindSpace)
default:
mustBeInToken(tokenKindWord)
}
lastRune = run
}
index ++ // make index equal to len([]rune(text))
tokenBoundary()
return runes, tokens
}

126
parse_test.go Normal file
View File

@@ -0,0 +1,126 @@
package typeset
import "slices"
import "testing"
func rl (run rune) runeLayout { return runeLayout { run: run } }
func tk (kind tokenKind, value string) token {
tok := token {
kind: kind,
}
runeValue := []rune(value)
tok.runes = make([]runeLayout, len(runeValue))
for index, run := range runeValue {
tok.runes[index] = rl(run)
}
return tok
}
func compareTokens (got, correct []token) bool {
for index, tok := range got {
correctTok := correct[index]
isCorrect :=
correctTok.kind == tok.kind &&
correctTok.width == tok.width &&
slices.Equal(correctTok.runes, tok.runes)
if !isCorrect { return false }
}
return true
}
func logTokens (test *testing.T, tokens []token) {
for _, token := range tokens {
test.Logf("- %-40v | %v", token, token.runes)
}
}
func TestParseString (test *testing.T) {
// ---- processing ----
runes, tokens := parseString("hello \rworld!\nfoo\n\r\nbar\tbaz\t\tsomething")
// ---- correct data ----
correctRunes := []runeLayout {
rl('h'),
rl('e'),
rl('l'),
rl('l'),
rl('o'),
rl(' '),
rl('\r'),
rl('w'),
rl('o'),
rl('r'),
rl('l'),
rl('d'),
rl('!'),
rl('\n'),
rl('f'),
rl('o'),
rl('o'),
rl('\n'),
rl('\r'),
rl('\n'),
rl('b'),
rl('a'),
rl('r'),
rl('\t'),
rl('b'),
rl('a'),
rl('z'),
rl('\t'),
rl('\t'),
rl('s'),
rl('o'),
rl('m'),
rl('e'),
rl('t'),
rl('h'),
rl('i'),
rl('n'),
rl('g'),
}
correctTokens := []token {
tk(tokenKindWord, "hello"),
tk(tokenKindSpace, " "),
tk(tokenKindWord, "\rworld!"),
tk(tokenKindLineBreak, "\n"),
tk(tokenKindWord, "foo"),
tk(tokenKindLineBreak, "\n"),
tk(tokenKindLineBreak, "\r\n"),
tk(tokenKindWord, "bar"),
tk(tokenKindTab, "\t"),
tk(tokenKindWord, "baz"),
tk(tokenKindTab, "\t\t"),
tk(tokenKindWord, "something"),
}
// ---- testing ----
if len(runes) != len(correctRunes) {
test.Logf("len(runes) != len(correctRunes): %d, %d", len(runes), len(correctRunes))
test.Log(runes)
test.Log(correctRunes)
test.FailNow()
}
if !slices.Equal(runes, correctRunes) {
test.Log("runes != correctRunes:")
test.Log(runes)
test.Log(correctRunes)
test.FailNow()
}
if len(tokens) != len(correctTokens) {
test.Logf("len(tokens) != len(correctTokens): %d, %d", len(tokens), len(correctTokens))
test.Log("GOT")
logTokens(test, tokens)
test.Log("CORRECT")
logTokens(test, correctTokens)
test.FailNow()
}
if !compareTokens(tokens, correctTokens) {
test.Log("tokens != correctTokens:")
test.Log("GOT")
logTokens(test, tokens)
test.Log("CORRECT")
logTokens(test, correctTokens)
test.FailNow()
}
// TODO: ensure runeLayout slices in the tokens reference the same
// memory as the complete runes slice
}