11 Commits

Author SHA1 Message Date
89c23a8947 Add test example 2024-09-10 15:52:56 -04:00
0cb6e28542 Add drawing functions 2024-09-10 15:52:46 -04:00
5dee53b8a9 Add incomplete TypeSetter struct 2024-09-10 15:52:33 -04:00
9c7732c95b Add incomplete reflow stage 2024-09-10 15:52:18 -04:00
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
14 changed files with 1028 additions and 3 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

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
output.png

View File

@@ -2,7 +2,20 @@
[![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.
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:
The state of a text layout is stored in a TypeSetter, and it can be drawn to any
draw.Image using a Drawer which "extends" TypeSetter.
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.

103
draw.go Normal file
View File

@@ -0,0 +1,103 @@
package typeset
import "image"
import "unicode"
import "image/draw"
import "image/color"
import "golang.org/x/image/font"
import "golang.org/x/image/math/fixed"
// Draw draws the contents of a TypeSetter to an image at the given offset. It
// returns a rectangle containing all pixels in the image that were updated.
func Draw (destination draw.Image, setter *TypeSetter, offset fixed.Point26_6, col color.Color) image.Rectangle {
source := image.NewUniform(col)
face := setter.Face()
var updatedRegion image.Rectangle
bounds := destination.Bounds()
setter.Runes()(func (position fixed.Point26_6, run rune) bool {
// leave empty space for space characters
if unicode.IsSpace(run) {
return true
}
dot := offset.Add(position)
destinationRectangle, mask, maskPoint, _, ok := face.Glyph(dot, run)
if ok {
// don't bother drawing runes that are out of bounds
if destinationRectangle.Min.Y > bounds.Max.Y { return false }
if destinationRectangle.Intersect(bounds).Empty() { return true }
// draw rune
draw.DrawMask (
destination,
destinationRectangle,
source, image.Point { },
mask, maskPoint,
draw.Over)
} else {
// draw tofu
drawTofu(run, destination, dot, face, col)
}
updatedRegion = updatedRegion.Union(destinationRectangle)
return true
})
return updatedRegion
}
// DrawBounds draws the LayoutBounds, MinimumSize, and LayoutBoundsSpace of a
// TypeSetter to the given image.
func DrawBounds (destination draw.Image, setter *TypeSetter, offset fixed.Point26_6) {
blue := color.RGBA { B: 255, A: 255 }
red := color.RGBA { R: 255, A: 255 }
green := color.RGBA { G: 255, A: 255 }
layoutBoundsSpace := setter.LayoutBoundsSpace()
layoutBounds := setter.LayoutBounds()
minimum := setter.MinimumSize()
minimumRect := roundRect(fixed.Rectangle26_6 { Max: minimum }.Add(offset).Add(layoutBounds.Min))
drawRectangleOutline(destination, minimumRect, green)
drawRectangleOutline(destination, roundRect(layoutBoundsSpace.Add(offset)), blue)
drawRectangleOutline(destination, roundRect(layoutBounds.Add(offset)), red)
}
func drawTofu (
char rune,
destination draw.Image,
position fixed.Point26_6,
face font.Face,
col color.Color,
) {
bounds, _ := tofuBounds(face)
rectBounds := roundRect(bounds).Add(image.Pt (
position.X.Round(),
position.Y.Round()))
drawRectangleOutline(destination, rectBounds, col)
}
func drawRectangleOutline (destination draw.Image, bounds image.Rectangle, col color.Color) {
for x := bounds.Min.X; x < bounds.Max.X; x ++ {
destination.Set(x, bounds.Min.Y, col)
}
for y := bounds.Min.Y; y < bounds.Max.Y; y ++ {
destination.Set(bounds.Min.X, y, col)
destination.Set(bounds.Max.X - 1, y, col)
}
for x := bounds.Min.X; x < bounds.Max.X; x ++ {
destination.Set(x, bounds.Max.Y - 1, col)
}
}
func roundRect (rectangle fixed.Rectangle26_6) image.Rectangle {
return image.Rect (
rectangle.Min.X.Round(),
rectangle.Min.Y.Round(),
rectangle.Max.X.Round(),
rectangle.Max.Y.Round())
}

162
examples/test/main.go Normal file
View File

@@ -0,0 +1,162 @@
// Example test demonstrates a variety of ways that TypeSetter can arrange text.
package main
import "os"
import "image"
import "image/png"
import "image/draw"
import "image/color"
import "golang.org/x/image/math/fixed"
import "git.tebibyte.media/tomo/typeset"
import "golang.org/x/image/font/basicfont"
func main () {
img := image.NewRGBA(image.Rect(0, 0, 2048, 1024))
setter := typeset.TypeSetter { }
setter.SetWrap(true)
setter.SetFace(basicfont.Face7x13)
setter.SetText(lipsum)
setter.SetAlign(typeset.AlignStart, typeset.AlignStart)
drawText(img, &setter, image.Rect( 0, 0, 256, 256).Inset(4))
setter.SetAlign(typeset.AlignMiddle, typeset.AlignStart)
drawText(img, &setter, image.Rect(256, 0, 512, 256).Inset(4))
setter.SetAlign(typeset.AlignEnd, typeset.AlignStart)
drawText(img, &setter, image.Rect( 0, 256, 256, 512).Inset(4))
setter.SetAlign(typeset.AlignEven, typeset.AlignStart)
drawText(img, &setter, image.Rect(256, 256, 512, 512).Inset(4))
setter.SetText(dimple)
setter.SetAlign(typeset.AlignStart, typeset.AlignStart)
drawText(img, &setter, image.Rect( 0, 0, 256, 256).Add(image.Pt(512, 0)).Inset(4))
setter.SetAlign(typeset.AlignMiddle, typeset.AlignStart)
drawText(img, &setter, image.Rect(256, 0, 512, 256).Add(image.Pt(512, 0)).Inset(4))
setter.SetAlign(typeset.AlignEnd, typeset.AlignStart)
drawText(img, &setter, image.Rect( 0, 256, 256, 512).Add(image.Pt(512, 0)).Inset(4))
setter.SetAlign(typeset.AlignEven, typeset.AlignStart)
drawText(img, &setter, image.Rect(256, 256, 512, 512).Add(image.Pt(512, 0)).Inset(4))
setter.SetAlign(typeset.AlignStart, typeset.AlignMiddle)
drawText(img, &setter, image.Rect( 0, 0, 256, 256).Add(image.Pt(0, 512)).Inset(4))
setter.SetAlign(typeset.AlignMiddle, typeset.AlignMiddle)
drawText(img, &setter, image.Rect(256, 0, 512, 256).Add(image.Pt(0, 512)).Inset(4))
setter.SetAlign(typeset.AlignEnd, typeset.AlignMiddle)
drawText(img, &setter, image.Rect( 0, 256, 256, 512).Add(image.Pt(0, 512)).Inset(4))
setter.SetAlign(typeset.AlignEven, typeset.AlignMiddle)
drawText(img, &setter, image.Rect(256, 256, 512, 512).Add(image.Pt(0, 512)).Inset(4))
setter.SetAlign(typeset.AlignStart, typeset.AlignEnd)
drawText(img, &setter, image.Rect( 0, 0, 256, 256).Add(image.Pt(512, 512)).Inset(4))
setter.SetAlign(typeset.AlignMiddle, typeset.AlignEnd)
drawText(img, &setter, image.Rect(256, 0, 512, 256).Add(image.Pt(512, 512)).Inset(4))
setter.SetAlign(typeset.AlignEnd, typeset.AlignEnd)
drawText(img, &setter, image.Rect( 0, 256, 256, 512).Add(image.Pt(512, 512)).Inset(4))
setter.SetAlign(typeset.AlignEven, typeset.AlignEnd)
drawText(img, &setter, image.Rect(256, 256, 512, 512).Add(image.Pt(512, 512)).Inset(4))
setter.SetText(haiku)
setter.SetWrap(false)
setter.SetAlign(typeset.AlignStart, typeset.AlignStart)
drawText(img, &setter, image.Rect( 0, 0, 256, 256).Add(image.Pt(1024, 0)).Inset(4))
setter.SetAlign(typeset.AlignMiddle, typeset.AlignStart)
drawText(img, &setter, image.Rect(256, 0, 512, 256).Add(image.Pt(1024, 0)).Inset(4))
setter.SetAlign(typeset.AlignEnd, typeset.AlignStart)
drawText(img, &setter, image.Rect( 0, 256, 256, 512).Add(image.Pt(1024, 0)).Inset(4))
setter.SetAlign(typeset.AlignEven, typeset.AlignStart)
drawText(img, &setter, image.Rect(256, 256, 512, 512).Add(image.Pt(1024, 0)).Inset(4))
setter.SetAlign(typeset.AlignStart, typeset.AlignMiddle)
drawText(img, &setter, image.Rect( 0, 0, 256, 256).Add(image.Pt(1536, 0)).Inset(4))
setter.SetAlign(typeset.AlignMiddle, typeset.AlignMiddle)
drawText(img, &setter, image.Rect(256, 0, 512, 256).Add(image.Pt(1536, 0)).Inset(4))
setter.SetAlign(typeset.AlignEnd, typeset.AlignMiddle)
drawText(img, &setter, image.Rect( 0, 256, 256, 512).Add(image.Pt(1536, 0)).Inset(4))
setter.SetAlign(typeset.AlignEven, typeset.AlignMiddle)
drawText(img, &setter, image.Rect(256, 256, 512, 512).Add(image.Pt(1536, 0)).Inset(4))
setter.SetAlign(typeset.AlignStart, typeset.AlignEnd)
drawText(img, &setter, image.Rect( 0, 0, 256, 256).Add(image.Pt(1024, 512)).Inset(4))
setter.SetAlign(typeset.AlignMiddle, typeset.AlignEnd)
drawText(img, &setter, image.Rect(256, 0, 512, 256).Add(image.Pt(1024, 512)).Inset(4))
setter.SetAlign(typeset.AlignEnd, typeset.AlignEnd)
drawText(img, &setter, image.Rect( 0, 256, 256, 512).Add(image.Pt(1024, 512)).Inset(4))
setter.SetAlign(typeset.AlignEven, typeset.AlignEnd)
drawText(img, &setter, image.Rect(256, 256, 512, 512).Add(image.Pt(1024, 512)).Inset(4))
setter.SetText(haikuAlt)
setter.SetAlign(typeset.AlignEven, typeset.AlignStart)
drawText(img, &setter, image.Rect( 0, 0, 256, 256).Add(image.Pt(1536, 512)).Inset(4))
setter.SetAlign(typeset.AlignEven, typeset.AlignMiddle)
drawText(img, &setter, image.Rect(256, 0, 512, 256).Add(image.Pt(1536, 512)).Inset(4))
setter.SetAlign(typeset.AlignEven, typeset.AlignEnd)
drawText(img, &setter, image.Rect( 0, 256, 256, 512).Add(image.Pt(1536, 512)).Inset(4))
file, err := os.Create("output.png")
if err != nil { panic(err) }
defer file.Close()
err = png.Encode(file, img)
if err != nil { panic(err) }
}
type subDrawImage interface {
draw.Image
SubImage (image.Rectangle) image.Image
}
func drawText (destination subDrawImage, setter *typeset.TypeSetter, bounds image.Rectangle) {
whiteRectangle(destination, bounds)
subImage := destination.SubImage(bounds).(draw.Image)
metrics := setter.Face().Metrics()
bounds = bounds.Inset(16)
grayRectangle(destination, bounds)
size := fixed.P(bounds.Dx(), bounds.Dy())
setter.SetSize(size)
offset := fixed.Point26_6 {
X: fixed.I(bounds.Min.X),
Y: metrics.Ascent + fixed.I(bounds.Min.Y),
}
// typeset.Draw(destination, setter, offset, color.Black)
typeset.DrawBounds(subImage, setter, offset)
typeset.Draw(subImage, setter, offset, color.Black)
}
func whiteRectangle (destination draw.Image, rect image.Rectangle) {
draw.Over.Draw(destination, rect, image.NewUniform(color.RGBA { R: 150, G: 150, B: 150, A: 255 }), image.Pt(0, 0))
}
func grayRectangle (destination draw.Image, rect image.Rectangle) {
draw.Over.Draw(destination, rect, image.NewUniform(color.RGBA { R: 200, G: 200, B: 200, A: 255 }), image.Pt(0, 0))
}
const lipsum = `Eum officia beatae harum. Rem aut praesentium possimus dignissimos ea sed. Recusandae sint rerum ut. Qui delectus rerum ut ut. Nobis non veritatis consequatur quia explicabo id. Et aut qui reiciendis esse voluptatem.
Eaque rem incidunt porro unde quia expedita quia. Deleniti repellat modi placeat. Et beatae aut voluptatem. Veritatis perspiciatis et aperiam sit modi sequi.
Accusantium et fugit expedita consequatur incidunt explicabo ea voluptatibus. Debitis consectetur veniam ut et esse aspernatur. Quas occaecati explicabo consequuntur. Quae dolorem ea harum ut tempora. Corporis ducimus et voluptatem. Corporis distinctio quia velit accusantium sunt omnis.
Libero blanditiis aut aut exercitationem modi. Eum corporis quam facere. Perferendis sit nulla et qui repellat eaque neque in. Expedita quidem similique sunt delectus similique non assumenda.
Hic rerum earum sapiente et itaque harum. Itaque amet voluptatem aliquid. Et qui excepturi animi voluptatem debitis necessitatibus atque animi. Nemo voluptates delectus quisquam non. Ipsam error voluptas similique dolores odit quos.`
const dimple = `I have been trying to remember this for a while
I could never place it
My brain kept saying Dimple when I tried to remember. Ever closer yet unobtainable. Censored, even. It did not want me to remember the dumple...`
const haiku = `An ocean voyage.
As waves break over the bow,
the sea welcomes me.
This is a very long line that will probably get cut off.`
const haikuAlt = `An ocean voyage.
As waves break over the bow,
the sea welcomes me.
This is a short ending`

191
flow.go Normal file
View File

@@ -0,0 +1,191 @@
package typeset
import "golang.org/x/image/font"
import "golang.org/x/image/math/fixed"
// TODO perhaps follow https://unicode.org/reports/tr14/
func reflow (
tokens []token,
face font.Face, size fixed.Point26_6,
wrap bool, xAlign, yAlign Align,
) (
extents, extentsSpace fixed.Rectangle26_6,
minimumSize fixed.Point26_6,
) {
if len(tokens) == 0 { return }
metrics := face.Metrics()
var dot fixed.Point26_6
lineStart := 0
lineEnd := 0
lastWord := 0
lastToken := 0
nLines := 0
newline := func () {
// if the line isn't empty
if lineStart != lineEnd {
// align line
alignLine (
tokens[lineStart:lineEnd],
size.X, xAlign, lineEnd == len(tokens))
// calculate extents
lastWordTok := tokens[lastWord]
lastTokenTok := tokens[lastToken]
lineMax := lastWordTok.position.X + lastWordTok.width
lineMaxSpace := lastTokenTok.position.X + lastTokenTok.width
if lineMax > minimumSize.X { minimumSize.X = lineMax }
if lineMaxSpace > extentsSpace.Max.X { extentsSpace.Max.X = lineMaxSpace }
}
// update dot
dot.Y += metrics.Height
dot.X = 0
// update indices, counts
lineStart = lineEnd
lastWord = lineEnd
nLines ++
}
// for each line, arrange and align while calculating effective
// bounds/extents
sawLineBreak := false
for index, token := range tokens {
lineEnd = index
updateIndices := func () {
lastToken = index
if token.kind == tokenKindWord {
lastWord = index
}
}
// demarcate lines
if sawLineBreak {
newline()
sawLineBreak = false
}
if token.kind == tokenKindLineBreak {
updateIndices()
tokens[index].position = dot
sawLineBreak = true
} else {
needWrap :=
wrap &&
token.kind == tokenKindWord &&
dot.X + token.width > size.X
if needWrap {
newline()
}
updateIndices()
tokens[index].position = dot
dot.X += token.width
}
}
lineEnd ++ // make lineEnd equal to len(tokens)
newline()
minimumSize.Y = metrics.Height * fixed.Int26_6(nLines) + metrics.Descent
// second, vertical alignment pass
alignLinesVertically(tokens, size.Y, minimumSize.Y, yAlign)
// calculate extents
extentsOffset := fixed.Point26_6 { Y: metrics.Ascent - tokens[0].position.Y }
extents.Max.X = minimumSize.X
extents.Max.Y = dot.Y + metrics.Descent
extentsSpace.Max.Y = dot.Y + metrics.Descent
extents = extents.Sub(extentsOffset)
extentsSpace = extentsSpace.Sub(extentsOffset)
return
}
func alignLinesVertically (tokens []token, height, contentHeight fixed.Int26_6, align Align) {
if len(tokens) == 0 { return }
if align == AlignStart { return }
var topOffset fixed.Int26_6
switch align {
case AlignMiddle: topOffset = (height - contentHeight) / 2
case AlignEnd, AlignEven: topOffset = height - contentHeight
}
for index := range tokens {
tokens[index].position.Y += topOffset
}
}
func alignLine (tokens []token, width fixed.Int26_6, align Align, atEnd bool) {
if len(tokens) == 0 { return }
if align == AlignStart { return }
if align == AlignEven {
alignLineJustify(tokens, width, atEnd)
return
}
var leftOffset fixed.Int26_6
contentWidth := lineContentWidth(tokens)
switch align {
case AlignMiddle: leftOffset = (width - contentWidth) / 2
case AlignEnd: leftOffset = width - contentWidth
}
for index := range tokens {
tokens[index].position.X += leftOffset
}
}
func alignLineJustify (tokens []token, width fixed.Int26_6, atEnd bool) {
cantJustify :=
len(tokens) < 2 ||
atEnd ||
tokens[len(tokens) - 1].kind == tokenKindLineBreak
if cantJustify {
alignLine(tokens, width, AlignStart, atEnd)
return
}
contentWidth, wordCount := lineContentWordWidth(tokens)
spaceCount := wordCount - 1
if spaceCount == 0 { return }
spacePerWord := (width - contentWidth) / fixed.Int26_6(spaceCount)
var x fixed.Int26_6
for index, token := range tokens {
if token.kind == tokenKindWord {
tokens[index].position.X = x
x += spacePerWord + token.width
} else {
tokens[index].position.X = x
}
}
}
func lineContentWordWidth (tokens []token) (fixed.Int26_6, int) {
var width fixed.Int26_6
var count int
for _, token := range tokens {
if token.kind == tokenKindWord {
width += token.width
count ++
}
}
return width, count
}
func lineContentWidth (tokens []token) fixed.Int26_6 {
var width, spaceWidth fixed.Int26_6
for _, token := range tokens {
if token.kind == tokenKindWord {
width += spaceWidth + token.width
spaceWidth = 0
} else {
spaceWidth = token.width
}
}
return width
}

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()
}
}

View File

@@ -149,6 +149,7 @@ func DoLine (text []rune, face font.Face, wrap bool, width fixed.Int26_6) (line
// set the width of the line's content.
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
@@ -228,6 +229,10 @@ func (line *LineLayout) justify (tabWidth fixed.Int26_6) {
}
}
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

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
}

227
typesetter.go Normal file
View File

@@ -0,0 +1,227 @@
package typeset
import "fmt"
import "strconv"
import "golang.org/x/image/font"
import "golang.org/x/image/math/fixed"
type validationLevel int; const (
validationLevelNone validationLevel = iota
validationLevelTokens
validationLevelMeasurement
validationLevelFlow
)
type tokenKind int; const (
tokenKindWord tokenKind = iota // contains everything that isn't:
tokenKindSpace // only unicode space runes, except \r or \n
tokenKindTab // only \t runes
tokenKindLineBreak // either "\n", or "\r\n"
)
func (kind tokenKind) String () string {
switch kind {
case tokenKindWord: return "Word"
case tokenKindSpace: return "Space"
case tokenKindTab: return "Tab"
case tokenKindLineBreak: return "LineBreak"
}
return fmt.Sprintf("typeset.tokenKind(%d)", kind)
}
type token struct {
kind tokenKind
width fixed.Int26_6
position fixed.Point26_6
runes []runeLayout
}
func (tok token) String () string {
str := ""
for _, runl := range tok.runes {
str += string(runl.run)
}
return fmt.Sprintf (
"%v:%v{%v,%v-%v}",
tok.kind, strconv.Quote(str),
tok.position.X, tok.position.Y, tok.width)
}
type runeLayout struct {
x fixed.Int26_6
run rune
}
func (run runeLayout) String () string {
return fmt.Sprintf("%s-{%v}", strconv.Quote(string([]rune { run.run })), run.x)
}
// RuneIter is an iterator that iterates over positioned runes.
type RuneIter func (yield func(fixed.Point26_6, rune) bool)
// Align specifies a text alignment method.
type Align int; const (
// X | Y
AlignStart Align = iota // left | top
AlignMiddle // center | center
AlignEnd // right | bottom
AlignEven // justified | (unsupported)
)
// TypeSetter manages text, and can perform layout operations on it. It
// automatically avoids performing redundant work. It has no constructor and its
// zero value can be used safely, but it must not be copied after first use.
type TypeSetter struct {
text string
runes []runeLayout
tokens []token
validationLevel validationLevel
xAlign, yAlign Align
face font.Face
size fixed.Point26_6 // width, height
wrap bool
minimumSize fixed.Point26_6
layoutBounds fixed.Rectangle26_6
layoutBoundsSpace fixed.Rectangle26_6
}
// Runes returns an iterator for all runes in the TypeSetter, and thier positions.
func (this *TypeSetter) Runes () RuneIter {
this.needFlow()
return func (yield func (fixed.Point26_6, rune) bool) {
for _, token := range this.tokens {
for _, runl := range token.runes {
pos := token.position
pos.X += runl.x
if !yield(pos, runl.run) { return }
}
}
}
}
// Em returns the width of one emspace according to the typesetter's typeface,
// which is the width of the capital letter 'M'.
func (this *TypeSetter) Em () fixed.Int26_6 {
if this.face == nil { return 0 }
width, _ := this.face.GlyphAdvance('M')
return width
}
// MinimumSize returns the minimum width and height needed to display text. If
// wrapping is enabled, this method will return { X: Em(), Y: 0 }.
func (this *TypeSetter) MinimumSize () fixed.Point26_6 {
if this.wrap { return fixed.Point26_6{ X: this.Em(), Y: 0 } }
this.needFlow()
return this.minimumSize
}
// LayoutBounds returns the semantic bounding box of the text. The origin point
// (0, 0) of the rectangle corresponds to the origin of the first line's
// baseline.
func (this *TypeSetter) LayoutBounds () fixed.Rectangle26_6 {
this.needFlow()
return this.layoutBounds
}
// LayoutBoundsSpace is like LayoutBounds, but it also takes into account the
// trailing whitespace at the end of each line (if it exists).
func (this *TypeSetter) LayoutBoundsSpace () fixed.Rectangle26_6 {
this.needFlow()
return this.layoutBoundsSpace
}
// PositionAt returns the position of the rune at the specified index.
func (this *TypeSetter) PositionAt (index int) fixed.Point26_6 {
idx := 0
var position fixed.Point26_6
this.Runes()(func (pos fixed.Point26_6, run rune) bool {
if index == idx {
position = pos
return false
}
idx ++
return true
})
return position
}
// SetText sets the text of the TypeSetter.
func (this *TypeSetter) SetText (text string) {
if this.text == text { return }
this.text = text
this.invalidate(validationLevelTokens)
}
// SetSize sets the width and height of the TypeSetter.
func (this *TypeSetter) SetSize (size fixed.Point26_6) {
if this.size == size { return }
this.size = size
this.invalidate(validationLevelFlow)
}
// SetWrap sets whether the text will wrap to the width specified by SetSize.
func (this *TypeSetter) SetWrap (wrap bool) {
if this.wrap == wrap { return }
this.wrap = wrap
this.invalidate(validationLevelFlow)
}
// SetAlign sets the horizontal and vertical alignment of the text.
func (this *TypeSetter) SetAlign (x, y Align) {
if this.xAlign == x && this.yAlign == y { return }
this.xAlign = x
this.yAlign = y
this.invalidate(validationLevelFlow)
}
// Face returns the font face as set by SetFace.
func (this *TypeSetter) Face () font.Face {
return this.face
}
// SetFace sets the font face the text will be laid out according to.
func (this *TypeSetter) SetFace (face font.Face) {
if this.face == face { return }
this.face = face
this.invalidate(validationLevelMeasurement)
}
func (this *TypeSetter) needTokens () {
if this.valid(validationLevelTokens) { return }
this.runes, this.tokens = parseString(this.text)
this.validate(validationLevelTokens)
}
func (this *TypeSetter) needMeasurement () {
if this.valid(validationLevelMeasurement) { return }
this.needTokens()
measure(this.tokens, this.face)
this.validate(validationLevelMeasurement)
}
func (this *TypeSetter) needFlow () {
if this.valid(validationLevelFlow) { return }
this.needMeasurement()
this.layoutBounds, this.layoutBoundsSpace, this.minimumSize = reflow (
this.tokens,
this.face, this.size,
this.wrap, this.xAlign, this.yAlign)
this.validate(validationLevelFlow)
}
func (this *TypeSetter) validate (level validationLevel) {
this.validationLevel = level
}
func (this *TypeSetter) invalidate (level validationLevel) {
if this.valid(level) {
this.validationLevel = level - 1
}
}
func (this *TypeSetter) valid (level validationLevel) bool {
return this.validationLevel >= level
}