git » firstones » next » tree

[next] / glyphs.go

package main

import (
	"bufio"
	"embed"
	"encoding/xml"
	"fmt"
	"io"
	"maps"
	"slices"
	"strconv"
	"strings"
)

// # Glyph definitions
//
// Each glyph is stored in a separate SVG file in the "glyphs" directory.
// The name of the file is the name of the glyph, e.g. "fEEt.svg" for the
// "fEEt" glyph.
// The names of the glyphs follow the official PDF.
//
// # Glyph geometry
//
// On the X axis, glyphs are centered on X=0 (so they have some parts on
// X<0). They have different widths, but the max is 16.
// On the Y axis, glyphs _begin_ at Y=0, and flow downwards (so they don't
// have anything on Y<0). They have different heights, but the max is 16.
//
// TODO: review the min/max, maybe we want easier numbers?
//
// That allows us to assume that the initial connection point for all glyphs
// is on their (0,0). And the end point is on (0, $height).

//go:embed glyphs/*.svg
var glyphsFS embed.FS

type Glyph struct {
	name      string // e.g. "fEEt"
	svgDef    SVG    // The full SVG definition.
	svg       SVG    // The SVG referencing this glyph.
	height    int
	connector bool
}

type Syllable []Glyph

func (s Syllable) String() string {
	names := make([]string, 0, len(s))
	for _, g := range s {
		names = append(names, g.name)
	}
	return strings.Join(names, "-")
}

type Word []Syllable

func (word Word) String() string {
	syS := make([]string, 0, len(word))
	for _, syllable := range word {
		syS = append(syS, syllable.String())
	}
	return strings.Join(syS, "/")
}

func (g Glyph) String() string {
	return "[" + g.name + "]"
}

var allGlyphs = map[string]Glyph{}

// Sorted list of glyph names.
var glyphNames = []string{}

func getGlyph(name string) (Glyph, error) {
	g, ok := allGlyphs[name]
	if !ok {
		return g, fmt.Errorf("Unknown glyph %q", name)
	}
	return g, nil
}

func mustGetGlyph(name string) Glyph {
	g, err := getGlyph(name)
	if err != nil {
		panic(err)
	}
	return g
}

// phonemesToGlyphs converts a slice of phonemes into a slice of Glyphs.
// Phonemes is a string with the individual phonemes separated by "-".
// The "/" phoneme is used to indicate a new syllable.
// This returns a slice of syllables (as Glyphs).
func phonemesToGlyphs(phonemes string) (Word, error) {
	word := Word{}

	// We want to support a variety of ways to handle the "/" end-of-syllable
	// marker:
	//   - SH-fEEt-/-R-All
	//   - SH-fEEt/R-All
	//   - SH-fEEt/-R-All
	//   - SH-fEEt-/R-All
	//
	// Also cases like T//T or T/-/T should be handled well.
	for _, syllableS := range strings.Split(phonemes, "/") {
		syllable := Syllable{}
		for _, phoneme := range strings.Split(syllableS, "-") {
			if phoneme == "" {
				// This can happen on cases like "T-/-T", or "T//T".
				continue
			}

			g, err := getGlyph(phoneme)
			if err != nil {
				return nil, err
			}
			syllable = append(syllable, g)
		}

		if len(syllable) > 0 {
			word = append(word, syllable)
		}
	}

	return word, nil
}

func init() {
	// We load all the glyphs from the embedded filesystem.
	des, err := glyphsFS.ReadDir("glyphs")
	if err != nil {
		panic(err)
	}

	for _, de := range des {
		name := strings.TrimSuffix(de.Name(), ".svg")
		content, err := glyphsFS.ReadFile("glyphs/" + de.Name())
		if err != nil {
			panic(err)
		}

		// We extract some information from the SVG definition itself.
		var (
			// The element ID.
			id     string
			height int  // Height, stored in _fo_height.
			conn   bool // Is this a connector? In _fo_connector.
		)

		firstElem, err := extractFirstElement(string(content))
		if err != nil {
			panicf("%s extractFirstElement: %v", de.Name(), err)
		}
		for _, attr := range firstElem.Attr {
			switch attr.Name.Local {
			case "id":
				id = strings.TrimPrefix(attr.Value, "glyph:")
				if id != name {
					panicf("%s id does not match name '%s'", de.Name(), id)
				}
			case "_fo_height":
				height, err = strconv.Atoi(attr.Value)
				if err != nil {
					panicf("%s _fo_height: %v", de.Name(), err)
				}
			case "_fo_connector":
				conn, err = strconv.ParseBool(attr.Value)
				if err != nil {
					panicf("%s _fo_connector: %v", de.Name(), err)
				}

			}
		}

		allGlyphs[name] = Glyph{
			name:   name,
			svgDef: SVG(string(content)),
			svg: SVGf(
				`<use href="#glyph:%s" />`, name),
			height:    height,
			connector: conn,
		}
	}

	glyphNames = slices.Sorted(maps.Keys(allGlyphs))
}

func extractFirstElement(svgDef string) (xml.StartElement, error) {
	dec := xml.NewDecoder(strings.NewReader(svgDef))
	tok, err := dec.Token()
	return tok.(xml.StartElement), err
}

func dumpGlyphs(w io.Writer) {
	buf := bufio.NewWriter(w)
	buf.WriteString(string(svgHeader(80, 210)))
	buf.WriteString(string(svgGrid(80, 210)))
	writeDefs(buf)

	// Start at (10, 10) and move through the glyphs row by row.
	x, y := 10, 10

	// Names taken from the official PDF, sorted as they appear there.
	// "_" is used to put a new line in the SVG, to match the official PDF.
	names := []string{
		"B", "CH", "D", "DH", "_",
		"F", "G", "H", "J", "_",
		"K", "L", "M", "N", "_",
		"NG", "P", "R", "S", "_",
		"SH", "T", "TH", "V", "_",
		"W", "Z", "ZH", "_",
		"sAd", "All", "sAy", "_",
		"pEt", "fEEt", "lIt", "I", "_",
		"gOOd", "tOO", "gO", "_",
		"hOUse", "fUn", "bOY", "Yes",
	}

	for _, name := range names {
		if name == "_" {
			// Move to the next row, print a horizontal line to separate.
			x = 10
			y += 20
			fmt.Fprintf(buf,
				`<line x1="0" y1="%d" x2="100" y2="%d" `+
					`stroke="black" stroke-width="0.5" />`+"\n",
				y-6, y-6)
			continue
		}

		g := mustGetGlyph(name)

		s := SVGfn(
			`<text x="-2" y="-3" font-size="2" fill="black" `+
				`font-family="sans-serif">%s`,
			g.name)
		conn := ""
		if g.connector {
			conn = ", 🔗"
		}
		s += SVGfn(`<tspan font-size="1.5">(%d%s)</tspan>`, g.height, conn)
		s += SVGfn(`</text>`)

		// The glyph.
		s += color("orange", g.svg)
		s = move(x, y, s)
		buf.WriteString(string(s) + "\n")

		// Little dot marking the top of the glyph, to validate shape.
		fmt.Fprintf(buf,
			`<circle cx="%d" cy="%d" r="0.2" `+
				`fill="darkorange" />`+"\n",
			x, y)

		// Little dot marking the bottom of the glyph, to validate height.
		fmt.Fprintf(buf,
			`<circle cx="%d" cy="%d" r="0.2" `+
				`fill="darkorange" />`+"\n",
			x, y+g.height)

		x += syllableSpacing
	}

	buf.WriteString("</svg>\n")
	buf.Flush()
}