font/opentype,text,widget{,/material}: [API] support bitmap glyph rendering

This commit supports rendering opentype glyphs containing bitmap data instead of
color data. In order to support returning the shaped bitmap glyphs from the Shaper's
Shape() method, it has gained a second return parameter, an op.CallOp. Adding
that CallOp immediately after or immediately before painting the returned path
will display the bitmap glyphs.

The consequences of supporting colored glyphs forced changes upon the widget APIs
for widgets that display text. Previously text always had a fixed paint material,
so we could rely upon the caller setting the material (e.g. adding a paint.ColorOp)
before painting the glyphs and everything would work. Now that we display image-
based glyphs, we end up changing the painting material to an image midway through
displaying text. This is an awkward consequence of how we currently manage the
painting material, and to work around it widgets now accept an op.CallOp that
is expected to set the proper paint material. Text widgets will use that op.CallOp
before painting text (or other paint operations) to ensure that they are painting
with the proper materials.

This, in turn, changed the APIs for laying out widget.Editor, widget.Label, and
widget.Selectable, and eliminated the need for them to accept a callback (the
callback was only really to set the colors). Dropping that callback function
allowed me to consolidate widget.Label to only need one exported Layout method,
and allowed me to unexport the PaintText, PaintCaret, and PaintSelection methods
from widget.Editor and widget.Selectable. Those methods are useless in the public
API now that they don't need to be invoked after applying a color operation.

Callers of the raw text shaper API will need to make the following changes:

- Where before you used:

	var ops *op.Ops // Assume we have an operation list.
	var shaper *text.Shaper // Assume we have a shaper.
	var col color.NRGBA // Assume we have a text color.
	var glyphs []text.Glyph // Assume we have already filled a slice of glyphs.

	shape := shaper.Shape(glyphs)
	paint.FillShape(ops, col, clip.Outline{Path:shape}.Op())

- Now you should do:

	shape, call := shaper.Shape(glyphs)
	paint.FillShape(ops, col, clip.Outline{Path:shape}.Op())
	call.Add(ops)

Callers of the widget.{Label,Selectable,Editor} APIs will need to make the
following changes:

- Where before you used:

	var gtx layout.Context // Assume we have an operation list.
	var shaper *text.Shaper // Assume we have a shaper.
	var textCol color.NRGBA // Assume we have a text color.
	var selectCol color.NRGBA // Assume we have a selection color.
	var ed widget.Editor // Assume we have an editor.
	var sel widget.Selectable // Assume we have a selectable.

	// Lay out an editor.
	ed.Layout(gtx, shaper, text.Font{}, unit.Sp(30), func(layout.Context) layout.Dimensions {
		// Paint the editor.
	})
	// Lay out a selectable.
	sel.Layout(gtx, shaper, text.Font{}, unit.Sp(30), func(layout.Context) layout.Dimensions {
		// Paint the selectable.
	})
	// Lay out an interactive label.
	widget.Label{}.LayoutSelectable(gtx, shaper, text.Font{}, unit.Sp(30), "hello", func(layout.Context) layout.Dimensions {
		// Paint the label.
	})
	// Lay out a non-interactive label.
	widget.Label{}.Layout(gtx, shaper, text.Font{}, unit.Sp(30), "hello")

- Now you should do:

	// Capture setting the text paint material in a macro.
	textColMacro := op.Record(gtx.Ops)
	paint.ColorOp{Color: textCol}.Add(gtx.Ops)
	textMaterial := textColMacro.Stop()
	// Capture setting the selection paint material in a macro.
	selectColMacro := op.Record(gtx.Ops)
	paint.ColorOp{Color: selectCol}.Add(gtx.Ops)
	selectMaterial := selectColMacro.Stop()

	// Lay out an editor.
	ed.Layout(gtx, shaper, text.Font{}, unit.Sp(30), textMaterial, selectMaterial)
	// Lay out a selectable.
	sel.Layout(gtx, shaper, text.Font{}, unit.Sp(30), textMaterial, selectMaterial)
	// Lay out a label (no difference between interactive and non-interactive)
	widget.Label{}.Layout(gtx, shaper, text.Font{}, unit.Sp(30), "hello", textMaterial, selectMaterial)

Callers of the material package API do not need to make any changes.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit is contained in:
Chris Waldon
2023-03-07 13:02:05 -05:00
committed by Elias Naur
parent 47d25c1394
commit 6ab3ff40a6
16 changed files with 358 additions and 264 deletions
+69 -3
View File
@@ -3,6 +3,8 @@
package text
import (
"bytes"
"image"
"io"
"sort"
@@ -19,6 +21,7 @@ import (
"gioui.org/io/system"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
)
// document holds a collection of shaped lines and alignment information for
@@ -504,12 +507,13 @@ func alignWidth(minWidth int, lines []line) int {
return minWidth
}
// Shape converts the provided glyphs into a path.
func (s *shaperImpl) Shape(ops *op.Ops, gs []Glyph) clip.PathSpec {
// Shape converts the provided glyphs into a path. The path will enclose the forms
// of all vector glyphs.
func (s *shaperImpl) Shape(pathOps *op.Ops, gs []Glyph) clip.PathSpec {
var lastPos f32.Point
var x fixed.Int26_6
var builder clip.Path
builder.Begin(ops)
builder.Begin(pathOps)
for i, g := range gs {
if i == 0 {
x = g.X
@@ -570,6 +574,68 @@ func (s *shaperImpl) Shape(ops *op.Ops, gs []Glyph) clip.PathSpec {
return builder.End()
}
// Bitmaps returns an op.CallOp that will display all bitmap glyphs within gs.
// The positioning of the bitmaps uses the same logic as Shape(), so the returned
// CallOp can be added at the same offset as the path data returned by Shape()
// and will align correctly.
func (s *shaperImpl) Bitmaps(ops *op.Ops, gs []Glyph) op.CallOp {
var x fixed.Int26_6
bitmapMacro := op.Record(ops)
for i, g := range gs {
if i == 0 {
x = g.X
}
_, faceIdx, gid := splitGlyphID(g.ID)
face := s.orderer.faceFor(faceIdx)
glyphData := face.GlyphData(gid)
switch glyphData := glyphData.(type) {
case api.GlyphBitmap:
var imgOp paint.ImageOp
var imgSize image.Point
var img image.Image
switch glyphData.Format {
case api.PNG, api.JPG, api.TIFF:
img, _, _ = image.Decode(bytes.NewReader(glyphData.Data))
case api.BlackAndWhite:
// This is a complex family of uncompressed bitmaps that don't seem to be
// very common in practice. We can try adding support later if needed.
fallthrough
default:
// Unknown format.
continue
}
imgOp = paint.NewImageOp(img)
imgSize = img.Bounds().Size()
off := op.Offset(image.Point{
X: ((g.X - x) - g.Offset.X).Round(),
Y: g.Offset.Y.Round() - g.Ascent.Round(),
}).Push(ops)
cl := clip.Rect{Max: imgSize}.Push(ops)
glyphSize := image.Rectangle{
Min: image.Point{
X: g.Bounds.Min.X.Round(),
Y: g.Bounds.Min.Y.Round(),
},
Max: image.Point{
X: g.Bounds.Max.X.Round(),
Y: g.Bounds.Max.Y.Round(),
},
}.Size()
aff := op.Affine(f32.Affine2D{}.Scale(f32.Point{}, f32.Point{
X: float32(glyphSize.X) / float32(imgSize.X),
Y: float32(glyphSize.Y) / float32(imgSize.Y),
})).Push(ops)
imgOp.Add(ops)
paint.PaintOp{}.Add(ops)
aff.Pop()
cl.Pop()
off.Pop()
}
}
return bitmapMacro.Stop()
}
// langConfig describes the language and writing system of a body of text.
type langConfig struct {
// Language the text is written in.
+103 -105
View File
@@ -5,74 +5,50 @@ package text
import (
"encoding/binary"
"hash/maphash"
"image"
"gioui.org/io/system"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"golang.org/x/image/math/fixed"
)
type layoutCache struct {
m map[layoutKey]*layoutElem
head, tail *layoutElem
// entry holds a single key-value pair for an LRU cache.
type entry[K comparable, V any] struct {
next, prev *entry[K, V]
key K
v V
}
type pathCache struct {
seed maphash.Seed
m map[uint64]*path
head, tail *path
// lru is a generic least-recently-used cache.
type lru[K comparable, V any] struct {
m map[K]*entry[K, V]
head, tail *entry[K, V]
}
type layoutElem struct {
next, prev *layoutElem
key layoutKey
layout document
}
type path struct {
next, prev *path
key uint64
val clip.PathSpec
glyphs []glyphInfo
}
type glyphInfo struct {
ID GlyphID
X fixed.Int26_6
}
type layoutKey struct {
ppem fixed.Int26_6
maxWidth, minWidth int
maxLines int
str string
locale system.Locale
font Font
}
type pathKey struct {
gidHash uint64
}
const maxSize = 1000
func (l *layoutCache) Get(k layoutKey) (document, bool) {
// Get fetches the value associated with the given key, if any.
func (l *lru[K, V]) Get(k K) (V, bool) {
if lt, ok := l.m[k]; ok {
l.remove(lt)
l.insert(lt)
return lt.layout, true
return lt.v, true
}
return document{}, false
var v V
return v, false
}
func (l *layoutCache) Put(k layoutKey, lt document) {
// Put inserts the given value with the given key, evicting old
// cache entries if necessary.
func (l *lru[K, V]) Put(k K, v V) {
if l.m == nil {
l.m = make(map[layoutKey]*layoutElem)
l.head = new(layoutElem)
l.tail = new(layoutElem)
l.m = make(map[K]*entry[K, V])
l.head = new(entry[K, V])
l.tail = new(entry[K, V])
l.head.prev = l.tail
l.tail.next = l.head
}
val := &layoutElem{key: k, layout: lt}
val := &entry[K, V]{key: k, v: v}
l.m[k] = val
l.insert(val)
if len(l.m) > maxSize {
@@ -82,21 +58,42 @@ func (l *layoutCache) Put(k layoutKey, lt document) {
}
}
func (l *layoutCache) remove(lt *layoutElem) {
lt.next.prev = lt.prev
lt.prev.next = lt.next
// remove cuts e out of the lru linked list.
func (l *lru[K, V]) remove(e *entry[K, V]) {
e.next.prev = e.prev
e.prev.next = e.next
}
func (l *layoutCache) insert(lt *layoutElem) {
lt.next = l.head
lt.prev = l.head.prev
lt.prev.next = lt
lt.next.prev = lt
// insert adds e to the lru linked list.
func (l *lru[K, V]) insert(e *entry[K, V]) {
e.next = l.head
e.prev = l.head.prev
e.prev.next = e
e.next.prev = e
}
type bitmapCache = lru[GlyphID, bitmap]
type bitmap struct {
img paint.ImageOp
size image.Point
}
type layoutCache = lru[layoutKey, document]
type glyphValue[V any] struct {
v V
glyphs []glyphInfo
}
type glyphLRU[V any] struct {
seed maphash.Seed
cache lru[uint64, glyphValue[V]]
}
// hashGlyphs computes a hash key based on the ID and X offset of
// every glyph in the slice.
func (c *pathCache) hashGlyphs(gs []Glyph) uint64 {
func (c *glyphLRU[V]) hashGlyphs(gs []Glyph) uint64 {
if c.seed == (maphash.Seed{}) {
c.seed = maphash.MakeSeed()
}
@@ -118,6 +115,55 @@ func (c *pathCache) hashGlyphs(gs []Glyph) uint64 {
return sum
}
func (c *glyphLRU[V]) Get(key uint64, gs []Glyph) (V, bool) {
if v, ok := c.cache.Get(key); ok && gidsEqual(v.glyphs, gs) {
return v.v, true
}
var v V
return v, false
}
func (c *glyphLRU[V]) Put(key uint64, glyphs []Glyph, v V) {
gids := make([]glyphInfo, len(glyphs))
firstX := fixed.I(0)
for i, glyph := range glyphs {
if i == 0 {
firstX = glyph.X
}
// Cache glyph X offsets relative to the first glyph.
gids[i] = glyphInfo{ID: glyph.ID, X: glyph.X - firstX}
}
val := glyphValue[V]{
glyphs: gids,
v: v,
}
c.cache.Put(key, val)
}
type pathCache = glyphLRU[clip.PathSpec]
type bitmapShapeCache = glyphLRU[op.CallOp]
type glyphInfo struct {
ID GlyphID
X fixed.Int26_6
}
type layoutKey struct {
ppem fixed.Int26_6
maxWidth, minWidth int
maxLines int
str string
locale system.Locale
font Font
}
type pathKey struct {
gidHash uint64
}
const maxSize = 1000
func gidsEqual(a []glyphInfo, glyphs []Glyph) bool {
if len(a) != len(glyphs) {
return false
@@ -134,51 +180,3 @@ func gidsEqual(a []glyphInfo, glyphs []Glyph) bool {
}
return true
}
func (c *pathCache) Get(key uint64, gs []Glyph) (clip.PathSpec, bool) {
if v, ok := c.m[key]; ok && gidsEqual(v.glyphs, gs) {
c.remove(v)
c.insert(v)
return v.val, true
}
return clip.PathSpec{}, false
}
func (c *pathCache) Put(key uint64, glyphs []Glyph, v clip.PathSpec) {
if c.m == nil {
c.m = make(map[uint64]*path)
c.head = new(path)
c.tail = new(path)
c.head.prev = c.tail
c.tail.next = c.head
}
gids := make([]glyphInfo, len(glyphs))
firstX := fixed.I(0)
for i, glyph := range glyphs {
if i == 0 {
firstX = glyph.X
}
// Cache glyph X offsets relative to the first glyph.
gids[i] = glyphInfo{ID: glyph.ID, X: glyph.X - firstX}
}
val := &path{key: key, val: v, glyphs: gids}
c.m[key] = val
c.insert(val)
if len(c.m) > maxSize {
oldest := c.tail.next
c.remove(oldest)
delete(c.m, oldest.key)
}
}
func (c *pathCache) remove(v *path) {
v.next.prev = v.prev
v.prev.next = v.next
}
func (c *pathCache) insert(v *path) {
v.next = c.head
v.prev = c.head.prev
v.prev.next = v
v.next.prev = v
}
+26 -9
View File
@@ -146,10 +146,11 @@ type GlyphID uint64
// Shaper converts strings of text into glyphs that can be displayed.
type Shaper struct {
shaper shaperImpl
pathCache pathCache
layoutCache layoutCache
paragraph []rune
shaper shaperImpl
pathCache pathCache
bitmapShapeCache bitmapShapeCache
layoutCache layoutCache
paragraph []rune
reader strings.Reader
@@ -440,17 +441,33 @@ func splitGlyphID(g GlyphID) (fixed.Int26_6, int, font.GID) {
return ppem, faceIdx, gid
}
// Shape converts a slice of glyphs into a path describing their collective
// shape. All glyphs are expected to be from a single line of text (their
// Y offsets are ignored).
// Shape converts the provided glyphs into a path. The path will enclose the forms
// of all vector glyphs.
// All glyphs are expected to be from a single line of text (their Y offsets are ignored).
func (l *Shaper) Shape(gs []Glyph) clip.PathSpec {
key := l.pathCache.hashGlyphs(gs)
shape, ok := l.pathCache.Get(key, gs)
if ok {
return shape
}
ops := new(op.Ops)
shape = l.shaper.Shape(ops, gs)
pathOps := new(op.Ops)
shape = l.shaper.Shape(pathOps, gs)
l.pathCache.Put(key, gs, shape)
return shape
}
// Bitmaps extracts bitmap glyphs from the provided slice and creates an op.CallOp to present
// them. The returned op.CallOp will align correctly with the return value of Shape() for the
// same gs slice.
// All glyphs are expected to be from a single line of text (their Y offsets are ignored).
func (l *Shaper) Bitmaps(gs []Glyph) op.CallOp {
key := l.bitmapShapeCache.hashGlyphs(gs)
call, ok := l.bitmapShapeCache.Get(key, gs)
if ok {
return call
}
callOps := new(op.Ops)
call = l.shaper.Bitmaps(callOps, gs)
l.bitmapShapeCache.Put(key, gs, call)
return call
}