Files
gio/text/gotext_test.go
T
Chris Waldon b7d126e24c font/{gofont,opentype},text,widget{,/material}: [API] add font fallback and bidi support
This commit restructures the entire text shaping stack to enable lines of shaped text to
have non-homogeneous properties like which font face they belong to and which direction
a segment of text is going.

The text package now provides a concrete type text.Shaper which can be used to convert
strings into sequences of renderable text.Glyphs. At a high level, the API is used
like this:

    // Prepare some fonts.
    var collection []text.FontFace
    // Make a shaper with those fonts loaded.
    shaper := text.NewShaper(collection)
    // Shape a string.
    shaper.LayoutString(text.Parameters{
		PxPerEm: fixed.I(12),
    }, 0, 100, system.Locale{}, "Hello")
    // Iterate the glyphs from that string.
    for glyph, ok := shaper.NextGlyph(); ok; glyph, ok = shaper.NextGlyph() {
    	// Convert the glyph data into a path. In real uses, convert batches of glyphs
    	// rather than single glyphs to reduce the number of individual paths and offsets
    	// required to display your text.
    	shape := shaper.Shape([]text.Glyph{glyph})
    	// Offset the glyph to the position it declares within its fields. This will
    	// automatically handle correct bidirectional text glyph positioning.
    	offset := op.Offset(image.Pt(glyph.X.Floor(), int(glyph.Y))).Push(gtx.Ops)
    	// Create a clip area from the shape of the glyph.
    	area := clip.Outline{Path: shape}.Push(gtx.Ops)
    	// Paint whatever the current color is within the glyph's shape.
    	paint.PaintOp{}.Add(gtx.Ops)
    	area.Pop()
        offset.Pop()
    }

This API will transparently handle both font fallback (choosing appropriate fonts
from those loaded when the primary font doesn't contain a required glyph) and
bidirectional text (mixed left-to-right and right-to-left text). Glyphs are
iterated in order of the input runes, not their visual order, but proper use
of the provided offsets will ensure that text always displays correctly.

Thanks to Elias Naur for suggesting this glyph iterator strategy. It let us cut
through a lot of accumulated complexity from trying to match our old text APIs,
meaning that this change actually is a net negative change in lines of code.

This commit consumes the upstream github.com/go-text/typesetting/shaping API
now that my prior work is merged there, removing the need for the font/opentype/internal
package entirely.

As part of my efforts, I fuzzed both the low-level text shaping stack and the
editor widget extensively. I've committed regression tests found that way into
the appropriate testdata files to ensure the fuzzer re-checks them.

Fixes: https://todo.sr.ht/~eliasnaur/gio/425
Fixes: https://todo.sr.ht/~eliasnaur/gio/211
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2022-12-13 22:06:57 -06:00

651 lines
19 KiB
Go

package text
import (
"math"
"reflect"
"testing"
nsareg "eliasnaur.com/font/noto/sans/arabic/regular"
"github.com/go-text/typesetting/shaping"
"golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/math/fixed"
"gioui.org/font/opentype"
"gioui.org/io/system"
)
var english = system.Locale{
Language: "EN",
Direction: system.LTR,
}
var arabic = system.Locale{
Language: "AR",
Direction: system.RTL,
}
func testShaper(faces ...Face) *shaperImpl {
shaper := shaperImpl{}
for _, face := range faces {
shaper.Load(FontFace{Face: face})
}
return &shaper
}
func TestEmptyString(t *testing.T) {
ppem := fixed.I(200)
ltrFace, _ := opentype.Parse(goregular.TTF)
shaper := testShaper(ltrFace)
lines := shaper.LayoutRunes(Parameters{PxPerEm: ppem}, 0, 2000, english, []rune{})
if len(lines.lines) == 0 {
t.Fatalf("Layout returned no lines for empty string; expected 1")
}
l := lines.lines[0]
exp := fixed.Rectangle26_6{
Min: fixed.Point26_6{
Y: fixed.Int26_6(-12094),
},
Max: fixed.Point26_6{
Y: fixed.Int26_6(2700),
},
}
if got := l.bounds; got != exp {
t.Errorf("got bounds %+v for empty string; expected %+v", got, exp)
}
}
// TestNewlineSynthesis ensures that the shaper correctly inserts synthetic glyphs
// representing newline runes.
func TestNewlineSynthesis(t *testing.T) {
ppem := fixed.I(10)
ltrFace, _ := opentype.Parse(goregular.TTF)
rtlFace, _ := opentype.Parse(nsareg.TTF)
shaper := testShaper(ltrFace, rtlFace)
type testcase struct {
name string
locale system.Locale
txt string
}
for _, tc := range []testcase{
{
name: "ltr bidi newline in rtl segment",
locale: english,
txt: "The quick سماء שלום لا fox تمط שלום\n",
},
{
name: "ltr bidi newline in ltr segment",
locale: english,
txt: "The quick سماء שלום لا fox\n",
},
{
name: "rtl bidi newline in ltr segment",
locale: arabic,
txt: "الحب سماء brown привет fox تمط jumps\n",
},
{
name: "rtl bidi newline in rtl segment",
locale: arabic,
txt: "الحب سماء brown привет fox تمط\n",
},
} {
t.Run(tc.name, func(t *testing.T) {
doc := shaper.LayoutRunes(Parameters{PxPerEm: ppem}, 0, 200, tc.locale, []rune(tc.txt))
for lineIdx, line := range doc.lines {
lastRunIdx := len(line.runs) - 1
lastRun := line.runs[lastRunIdx]
lastGlyphIdx := len(lastRun.Glyphs) - 1
if lastRun.Direction.Progression() == system.TowardOrigin {
lastGlyphIdx = 0
}
glyph := lastRun.Glyphs[lastGlyphIdx]
if glyph.glyphCount != 0 {
t.Errorf("expected synthetic newline on line %d, run %d, glyph %d", lineIdx, lastRunIdx, lastGlyphIdx)
}
for runIdx, run := range line.runs {
for glyphIdx, glyph := range run.Glyphs {
if runIdx == lastRunIdx && glyphIdx == lastGlyphIdx {
continue
}
if glyph.glyphCount == 0 {
t.Errorf("found invalid synthetic newline on line %d, run %d, glyph %d", lineIdx, runIdx, glyphIdx)
}
}
}
}
if t.Failed() {
printLinePositioning(t, doc.lines, nil)
}
})
}
}
// simpleGlyph returns a simple square glyph with the provided cluster
// value.
func simpleGlyph(cluster int) shaping.Glyph {
return complexGlyph(cluster, 1, 1)
}
// ligatureGlyph returns a simple square glyph with the provided cluster
// value and number of runes.
func ligatureGlyph(cluster, runes int) shaping.Glyph {
return complexGlyph(cluster, runes, 1)
}
// expansionGlyph returns a simple square glyph with the provided cluster
// value and number of glyphs.
func expansionGlyph(cluster, glyphs int) shaping.Glyph {
return complexGlyph(cluster, 1, glyphs)
}
// complexGlyph returns a simple square glyph with the provided cluster
// value, number of associated runes, and number of glyphs in the cluster.
func complexGlyph(cluster, runes, glyphs int) shaping.Glyph {
return shaping.Glyph{
Width: fixed.I(10),
Height: fixed.I(10),
XAdvance: fixed.I(10),
YAdvance: fixed.I(10),
YBearing: fixed.I(10),
ClusterIndex: cluster,
GlyphCount: glyphs,
RuneCount: runes,
}
}
// makeTestText creates a simple and complex(bidi) sample of shaped text at the given
// font size and wrapped to the given line width. The runeLimit, if nonzero,
// truncates the sample text to ensure shorter output for expensive tests.
func makeTestText(shaper *shaperImpl, primaryDir system.TextDirection, fontSize, lineWidth, runeLimit int) (simpleSample, complexSample []shaping.Line) {
ltrFace, _ := opentype.Parse(goregular.TTF)
rtlFace, _ := opentype.Parse(nsareg.TTF)
if shaper == nil {
shaper = testShaper(ltrFace, rtlFace)
}
ltrSource := "The quick brown fox jumps over the lazy dog."
rtlSource := "الحب سماء لا تمط غير الأحلام"
// bidiSource is crafted to contain multiple consecutive RTL runs (by
// changing scripts within the RTL).
bidiSource := "The quick سماء שלום لا fox تمط שלום غير the lazy dog."
// bidi2Source is crafted to contain multiple consecutive LTR runs (by
// changing scripts within the LTR).
bidi2Source := "الحب سماء brown привет fox تمط jumps привет over غير الأحلام"
locale := english
simpleSource := ltrSource
complexSource := bidiSource
if primaryDir == system.RTL {
simpleSource = rtlSource
complexSource = bidi2Source
locale = arabic
}
if runeLimit != 0 {
simpleRunes := []rune(simpleSource)
complexRunes := []rune(complexSource)
if runeLimit < len(simpleRunes) {
ltrSource = string(simpleRunes[:runeLimit])
}
if runeLimit < len(complexRunes) {
rtlSource = string(complexRunes[:runeLimit])
}
}
simpleText := shaper.shapeAndWrapText(shaper.orderer.sortedFacesForStyle(Font{}), fixed.I(fontSize), lineWidth, locale, []rune(simpleSource))
complexText := shaper.shapeAndWrapText(shaper.orderer.sortedFacesForStyle(Font{}), fixed.I(fontSize), lineWidth, locale, []rune(complexSource))
shaper = testShaper(rtlFace, ltrFace)
return simpleText, complexText
}
func fixedAbs(a fixed.Int26_6) fixed.Int26_6 {
if a < 0 {
a = -a
}
return a
}
func TestToLine(t *testing.T) {
ltrFace, _ := opentype.Parse(goregular.TTF)
rtlFace, _ := opentype.Parse(nsareg.TTF)
shaper := testShaper(ltrFace, rtlFace)
ltr, bidi := makeTestText(shaper, system.LTR, 16, 100, 0)
rtl, bidi2 := makeTestText(shaper, system.RTL, 16, 100, 0)
_, bidiWide := makeTestText(shaper, system.LTR, 16, 200, 0)
_, bidi2Wide := makeTestText(shaper, system.RTL, 16, 200, 0)
type testcase struct {
name string
lines []shaping.Line
// Dominant text direction.
dir system.TextDirection
}
for _, tc := range []testcase{
{
name: "ltr",
lines: ltr,
dir: system.LTR,
},
{
name: "rtl",
lines: rtl,
dir: system.RTL,
},
{
name: "bidi",
lines: bidi,
dir: system.LTR,
},
{
name: "bidi2",
lines: bidi2,
dir: system.RTL,
},
{
name: "bidi_wide",
lines: bidiWide,
dir: system.LTR,
},
{
name: "bidi2_wide",
lines: bidi2Wide,
dir: system.RTL,
},
} {
t.Run(tc.name, func(t *testing.T) {
// We expect:
// - Line dimensions to be populated.
// - Line direction to be populated.
// - Runs to be ordered from lowest runes first.
// - Runs to have widths matching the input.
// - Runs to have the same total number of glyphs/runes as the input.
runesSeen := Range{}
shaper := testShaper(ltrFace, rtlFace)
for i, input := range tc.lines {
seenRun := make([]bool, len(input))
inputLowestRuneOffset := math.MaxInt
totalInputGlyphs := 0
totalInputRunes := 0
for _, run := range input {
if run.Runes.Offset < inputLowestRuneOffset {
inputLowestRuneOffset = run.Runes.Offset
}
totalInputGlyphs += len(run.Glyphs)
totalInputRunes += run.Runes.Count
}
output := toLine(&shaper.orderer, input, tc.dir)
if output.bounds.Min == (fixed.Point26_6{}) {
t.Errorf("line %d: Bounds.Min not populated", i)
}
if output.bounds.Max == (fixed.Point26_6{}) {
t.Errorf("line %d: Bounds.Max not populated", i)
}
if output.direction != tc.dir {
t.Errorf("line %d: expected direction %v, got %v", i, tc.dir, output.direction)
}
totalRunWidth := fixed.I(0)
totalLineGlyphs := 0
totalLineRunes := 0
for k, run := range output.runs {
seenRun[run.VisualPosition] = true
if output.visualOrder[run.VisualPosition] != k {
t.Errorf("line %d, run %d: run.VisualPosition=%d, but line.VisualOrder[%d]=%d(should be %d)", i, k, run.VisualPosition, run.VisualPosition, output.visualOrder[run.VisualPosition], k)
}
if run.Runes.Offset != totalLineRunes {
t.Errorf("line %d, run %d: expected Runes.Offset to be %d, got %d", i, k, totalLineRunes, run.Runes.Offset)
}
runGlyphCount := len(run.Glyphs)
if inputGlyphs := len(input[k].Glyphs); runGlyphCount != inputGlyphs {
t.Errorf("line %d, run %d: expected %d glyphs, found %d", i, k, inputGlyphs, runGlyphCount)
}
runRuneCount := 0
currentCluster := -1
for _, g := range run.Glyphs {
if g.clusterIndex != currentCluster {
runRuneCount += g.runeCount
currentCluster = g.clusterIndex
}
}
if run.Runes.Count != runRuneCount {
t.Errorf("line %d, run %d: expected %d runes, counted %d", i, k, run.Runes.Count, runRuneCount)
}
runesSeen.Count += run.Runes.Count
totalRunWidth += fixedAbs(run.Advance)
totalLineGlyphs += len(run.Glyphs)
totalLineRunes += run.Runes.Count
}
if output.runeCount != totalInputRunes {
t.Errorf("line %d: input had %d runes, only counted %d", i, totalInputRunes, output.runeCount)
}
if totalLineGlyphs != totalInputGlyphs {
t.Errorf("line %d: input had %d glyphs, only counted %d", i, totalInputRunes, totalLineGlyphs)
}
if totalRunWidth != output.width {
t.Errorf("line %d: expected width %d, got %d", i, totalRunWidth, output.width)
}
for runIndex, seen := range seenRun {
if !seen {
t.Errorf("line %d, run %d missing from runs VisualPosition fields", i, runIndex)
}
}
}
lastLine := tc.lines[len(tc.lines)-1]
maxRunes := 0
for _, run := range lastLine {
if run.Runes.Count+run.Runes.Offset > maxRunes {
maxRunes = run.Runes.Count + run.Runes.Offset
}
}
if runesSeen.Count != maxRunes {
t.Errorf("input covered %d runes, output only covers %d", maxRunes, runesSeen.Count)
}
})
}
}
func TestComputeVisualOrder(t *testing.T) {
type testcase struct {
name string
input line
expectedVisualOrder []int
}
for _, tc := range []testcase{
{
name: "ltr",
input: line{
direction: system.LTR,
runs: []runLayout{
{Direction: system.LTR},
{Direction: system.LTR},
{Direction: system.LTR},
},
},
expectedVisualOrder: []int{0, 1, 2},
},
{
name: "rtl",
input: line{
direction: system.RTL,
runs: []runLayout{
{Direction: system.RTL},
{Direction: system.RTL},
{Direction: system.RTL},
},
},
expectedVisualOrder: []int{2, 1, 0},
},
{
name: "bidi-ltr",
input: line{
direction: system.LTR,
runs: []runLayout{
{Direction: system.LTR},
{Direction: system.RTL},
{Direction: system.RTL},
{Direction: system.RTL},
{Direction: system.LTR},
},
},
expectedVisualOrder: []int{0, 3, 2, 1, 4},
},
{
name: "bidi-ltr-complex",
input: line{
direction: system.LTR,
runs: []runLayout{
{Direction: system.RTL},
{Direction: system.RTL},
{Direction: system.LTR},
{Direction: system.RTL},
{Direction: system.RTL},
{Direction: system.LTR},
{Direction: system.RTL},
{Direction: system.RTL},
{Direction: system.LTR},
{Direction: system.RTL},
{Direction: system.RTL},
},
},
expectedVisualOrder: []int{1, 0, 2, 4, 3, 5, 7, 6, 8, 10, 9},
},
{
name: "bidi-rtl",
input: line{
direction: system.RTL,
runs: []runLayout{
{Direction: system.RTL},
{Direction: system.LTR},
{Direction: system.LTR},
{Direction: system.LTR},
{Direction: system.RTL},
},
},
expectedVisualOrder: []int{4, 1, 2, 3, 0},
},
{
name: "bidi-rtl-complex",
input: line{
direction: system.RTL,
runs: []runLayout{
{Direction: system.LTR},
{Direction: system.LTR},
{Direction: system.RTL},
{Direction: system.LTR},
{Direction: system.LTR},
{Direction: system.RTL},
{Direction: system.LTR},
{Direction: system.LTR},
{Direction: system.RTL},
{Direction: system.LTR},
{Direction: system.LTR},
},
},
expectedVisualOrder: []int{9, 10, 8, 6, 7, 5, 3, 4, 2, 0, 1},
},
} {
t.Run(tc.name, func(t *testing.T) {
computeVisualOrder(&tc.input)
if !reflect.DeepEqual(tc.input.visualOrder, tc.expectedVisualOrder) {
t.Errorf("expected visual order %v, got %v", tc.expectedVisualOrder, tc.input.visualOrder)
}
for i, visualIndex := range tc.input.visualOrder {
if pos := tc.input.runs[visualIndex].VisualPosition; pos != i {
t.Errorf("line.VisualOrder[%d]=%d, but line.Runs[%d].VisualPosition=%d", i, visualIndex, visualIndex, pos)
}
}
})
}
}
func FuzzLayout(f *testing.F) {
ltrFace, _ := opentype.Parse(goregular.TTF)
rtlFace, _ := opentype.Parse(nsareg.TTF)
f.Add("د عرمثال dstي met لم aqل جدmوpمg lرe dرd لو عل ميrةsdiduntut lab renنيتذدagلaaiua.ئPocttأior رادرsاي mيrbلmnonaيdتد ماةعcلخ.", true, uint8(10), uint16(200))
shaper := testShaper(ltrFace, rtlFace)
f.Fuzz(func(t *testing.T, txt string, rtl bool, fontSize uint8, width uint16) {
locale := system.Locale{
Direction: system.LTR,
}
if rtl {
locale.Direction = system.RTL
}
if fontSize < 1 {
fontSize = 1
}
lines := shaper.LayoutRunes(Parameters{PxPerEm: fixed.I(int(fontSize))}, 0, int(width), locale, []rune(txt))
validateLines(t, lines.lines, len([]rune(txt)))
})
}
func validateLines(t *testing.T, lines []line, expectedRuneCount int) {
t.Helper()
runesSeen := 0
for i, line := range lines {
if line.bounds.Min == (fixed.Point26_6{}) {
t.Errorf("line %d: Bounds.Min not populated", i)
}
if line.bounds.Max == (fixed.Point26_6{}) {
t.Errorf("line %d: Bounds.Max not populated", i)
}
totalRunWidth := fixed.I(0)
totalLineGlyphs := 0
lineRunesSeen := 0
for k, run := range line.runs {
if line.visualOrder[run.VisualPosition] != k {
t.Errorf("line %d, run %d: run.VisualPosition=%d, but line.VisualOrder[%d]=%d(should be %d)", i, k, run.VisualPosition, run.VisualPosition, line.visualOrder[run.VisualPosition], k)
}
if run.Runes.Offset != lineRunesSeen {
t.Errorf("line %d, run %d: expected Runes.Offset to be %d, got %d", i, k, lineRunesSeen, run.Runes.Offset)
}
runRuneCount := 0
currentCluster := -1
for _, g := range run.Glyphs {
if g.clusterIndex != currentCluster {
runRuneCount += g.runeCount
currentCluster = g.clusterIndex
}
}
if run.Runes.Count != runRuneCount {
t.Errorf("line %d, run %d: expected %d runes, counted %d", i, k, run.Runes.Count, runRuneCount)
}
lineRunesSeen += run.Runes.Count
totalRunWidth += fixedAbs(run.Advance)
totalLineGlyphs += len(run.Glyphs)
}
if totalRunWidth != line.width {
t.Errorf("line %d: expected width %d, got %d", i, line.width, totalRunWidth)
}
runesSeen += lineRunesSeen
}
if runesSeen != expectedRuneCount {
t.Errorf("input covered %d runes, output only covers %d", expectedRuneCount, runesSeen)
}
}
// TestTextAppend ensures that appending two texts together correctly updates the new lines'
// y offsets.
func TestTextAppend(t *testing.T) {
ltrFace, _ := opentype.Parse(goregular.TTF)
rtlFace, _ := opentype.Parse(nsareg.TTF)
shaper := testShaper(ltrFace, rtlFace)
text1 := shaper.LayoutString(Parameters{
PxPerEm: fixed.I(14),
}, 0, 200, english, "د عرمثال dstي met لم aqل جدmوpمg lرe dرd لو عل ميrةsdiduntut lab renنيتذدagلaaiua.ئPocttأior رادرsاي mيrbلmnonaيdتد ماةعcلخ.")
text2 := shaper.LayoutString(Parameters{
PxPerEm: fixed.I(14),
}, 0, 200, english, "د عرمثال dstي met لم aqل جدmوpمg lرe dرd لو عل ميrةsdiduntut lab renنيتذدagلaaiua.ئPocttأior رادرsاي mيrbلmnonaيdتد ماةعcلخ.")
text1.append(text2)
curY := math.MinInt
for lineNum, line := range text1.lines {
yOff := line.yOffset
if yOff <= curY {
t.Errorf("lines[%d] has y offset %d, <= to previous %d", lineNum, yOff, curY)
}
curY = yOff
}
}
func TestClosestFontByWeight(t *testing.T) {
const (
testTF1 Typeface = "MockFace"
testTF2 Typeface = "TestFace"
testTF3 Typeface = "AnotherFace"
)
fonts := []Font{
{Typeface: testTF1, Style: Regular, Weight: Normal},
{Typeface: testTF1, Style: Regular, Weight: Light},
{Typeface: testTF1, Style: Regular, Weight: Bold},
{Typeface: testTF1, Style: Italic, Weight: Thin},
}
weightOnlyTests := []struct {
Lookup Weight
Expected Weight
}{
// Test for existing weights.
{Lookup: Normal, Expected: Normal},
{Lookup: Light, Expected: Light},
{Lookup: Bold, Expected: Bold},
// Test for missing weights.
{Lookup: Thin, Expected: Light},
{Lookup: ExtraLight, Expected: Light},
{Lookup: Medium, Expected: Normal},
{Lookup: SemiBold, Expected: Bold},
{Lookup: ExtraBlack, Expected: Bold},
}
for _, test := range weightOnlyTests {
got, ok := closestFont(Font{Typeface: testTF1, Weight: test.Lookup}, fonts)
if !ok {
t.Errorf("expected closest font for %v to exist", test.Lookup)
}
if got.Weight != test.Expected {
t.Errorf("got weight %v, expected %v", got.Weight, test.Expected)
}
}
fonts = []Font{
{Typeface: testTF1, Style: Regular, Weight: Light},
{Typeface: testTF1, Style: Regular, Weight: Bold},
{Typeface: testTF1, Style: Italic, Weight: Normal},
{Typeface: testTF3, Style: Italic, Weight: Bold},
}
otherTests := []struct {
Lookup Font
Expected Font
ExpectedToFail bool
}{
// Test for existing fonts.
{
Lookup: Font{Typeface: testTF1, Weight: Light},
Expected: Font{Typeface: testTF1, Style: Regular, Weight: Light},
},
{
Lookup: Font{Typeface: testTF1, Style: Italic, Weight: Normal},
Expected: Font{Typeface: testTF1, Style: Italic, Weight: Normal},
},
// Test for missing fonts.
{
Lookup: Font{Typeface: testTF1, Weight: Normal},
Expected: Font{Typeface: testTF1, Style: Regular, Weight: Light},
},
{
Lookup: Font{Typeface: testTF3, Style: Italic, Weight: Normal},
Expected: Font{Typeface: testTF3, Style: Italic, Weight: Bold},
},
{
Lookup: Font{Typeface: testTF1, Style: Italic, Weight: Thin},
Expected: Font{Typeface: testTF1, Style: Italic, Weight: Normal},
},
{
Lookup: Font{Typeface: testTF1, Style: Italic, Weight: Bold},
Expected: Font{Typeface: testTF1, Style: Italic, Weight: Normal},
},
{
Lookup: Font{Typeface: testTF2, Weight: Normal},
ExpectedToFail: true,
},
{
Lookup: Font{Typeface: testTF2, Style: Italic, Weight: Normal},
ExpectedToFail: true,
},
}
for _, test := range otherTests {
got, ok := closestFont(test.Lookup, fonts)
if test.ExpectedToFail {
if ok {
t.Errorf("expected closest font for %v to not exist", test.Lookup)
} else {
continue
}
}
if !ok {
t.Errorf("expected closest font for %v to exist", test.Lookup)
}
if got != test.Expected {
t.Errorf("got %v, expected %v", got, test.Expected)
}
}
}