Compare commits
11 Commits
main
...
89c23a8947
| Author | SHA1 | Date | |
|---|---|---|---|
| 89c23a8947 | |||
| 0cb6e28542 | |||
| 5dee53b8a9 | |||
| 9c7732c95b | |||
| 37554dd719 | |||
| 569defdb36 | |||
| 0c9d50ebcd | |||
| 943fc57080 | |||
| 0592fe32b6 | |||
| 56cf7e3fb8 | |||
| 650ecf0c2e |
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
output.png
|
||||
19
README.md
19
README.md
@@ -2,7 +2,20 @@
|
||||
|
||||
[](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
103
draw.go
Normal 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
162
examples/test/main.go
Normal 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
191
flow.go
Normal 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
45
measure.go
Normal 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
70
measure_test.go
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
70
parse.go
Normal 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
126
parse_test.go
Normal 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
227
typesetter.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user