git » firstones » next » tree

[next] / svg.go

package main

import (
	"fmt"
	"io"
	"math"
	"strings"
)

// SVG type, to make it easier to avoid mixing it up with normal strings.
type SVG string

func SVGf(format string, args ...interface{}) SVG {
	out := fmt.Sprintf(format, args...)

	// Paranoid check: we should not have any more '<' or '>' than
	// the ones in the format string.
	// This should be the case because we already filter what goes into the
	// args, but it is better to be safe.
	for _, c := range []string{"<", ">"} {
		fq := strings.Count(format, c)
		oq := strings.Count(out, c)
		if fq != oq {
			panicf("SVGf: unsafe for %q: format %q had %d, output %q had %d",
				c, format, fq, out, oq)
		}
	}

	return SVG(out)
}

const svgNL = SVG("\n")

func SVGfn(format string, args ...interface{}) SVG {
	return SVGf(format, args...) + svgNL
}

// Indent the SVG by n spaces, for readability.
func indent(svg SVG, n int) SVG {
	sp := strings.Repeat(" ", n)
	lines := strings.Split(string(svg), "\n")
	for i, line := range lines {
		if line != "" {
			lines[i] = sp + line
		}
	}
	return SVG(strings.Join(lines, "\n"))
}

func move(dx, dy int, svg SVG) SVG {
	return SVGfn(`<g transform="translate(%d %d)">`, dx, dy) +
		indent(svg, 2) + SVG("</g>\n")
}

func movef(dx, dy float64, svg SVG) SVG {
	return SVGfn(`<g transform="translate(%g %g)">`, dx, dy) +
		indent(svg, 2) + SVG("</g>\n")
}

func rotate(angle int, svg SVG) SVG {
	return SVGfn(`<g transform="rotate(%d)">`, angle) +
		indent(svg, 2) + SVG("</g>\n")
}

func color(color string, svg SVG) SVG {
	return SVGfn(
		`<g color="%s" stroke="%s" stroke-width="0.5">`, color, color) +
		indent(svg, 2) + SVG("</g>\n")
}

func vertLine(l int) SVG {
	return SVGf(
		`<line x1="0" y1="0" x2="0" y2="%d" />`, l)
}

func svgHeader(width, height int) SVG {
	return SVGfn(`<svg
  version="1.1"
  viewBox="0 0 %d %d"
  width="%dmm" height="%dmm"
  xmlns="http://www.w3.org/2000/svg">
`, width, height, width, height)
}

func svgGrid(width, height int) SVG {
	s := "<!-- Grid for debugging -->\n"
	s += `<g stroke-width="0.1">` + "\n"
	for i := 0; i <= width; i += 5 {
		stroke := "#eee"
		if i%10 == 0 {
			stroke = "#ccc"
		}
		s += fmt.Sprintf(
			`<line x1="%d" y1="0" x2="%d" y2="100%%" stroke="%s"/> `,
			i, i, stroke)
		s += "\n"
	}
	for i := 0; i <= height; i += 5 {
		stroke := "#eee"
		if i%10 == 0 {
			stroke = "#ccc"
		}
		s += fmt.Sprintf(
			`<line x1="0" y1="%d" x2="100%%" y2="%d" stroke="%s"/>`, i, i, stroke)
	}
	s += "</g> <!-- End of grid -->\n"
	return SVG(s)
}

// Write all the known glyphs in a <defs> section, so they can be referred to
// individually. This makes the SVG more readable.
func writeDefs(w io.Writer) {
	fmt.Fprintln(w, `<defs>`)
	// We write in alphabetical order, so that the SVG output is reproducible.
	for _, name := range glyphNames {
		g := allGlyphs[name]
		fmt.Fprintf(w, "<!-- %s -->\n", name)
		fmt.Fprintln(w, string(g.svgDef))
		fmt.Fprintln(w)
	}
	fmt.Fprintln(w, `</defs>`)
}

func syllableToSVG(syllable Syllable) SVG {
	svg := SVGf("<g> <!-- Syllable: %v -->\n", syllable)
	height := 0

	// Was the previous glyph a connector?
	prevConnector := false
	for _, glyph := range syllable {
		if !glyph.connector && !prevConnector {
			// If the glyph is not a connector, and the previous one was not a
			// connector either, we need to draw a vertical line to connect it
			// to the previous glyph (or the word branch).
			svg += color("orange",
				move(0, height,
					vertLine(3)),
			)
			height += 3
		}

		svg += color("orange",
			move(0, height,
				glyph.svg),
		)
		svg += svgNL

		height += glyph.height
		prevConnector = glyph.connector
	}

	svg += SVGf("</g> <!-- End of syllable %v -->\n", syllable)
	return svg
}

const (
	// How much space we leave between each syllable?
	// This has to be > than the maximum width of a glyph.
	syllableSpacing = 20

	// How much space we leave between each word?
	wordSpacing = 10

	// How much space we leave at the top?
	// This is so elements on the border don't end up getting chopped.
	topMargin = 5
)

type WordLine struct {
	nsyllables int
	angle      int // Angle in degrees.
	svg        SVG
}

// offsetFor returns the X and Y offsets for the i-th syllable in the word line.
func (wl WordLine) offsetFor(i int) (x, y float64) {
	// Where in the line should the i-th syllable start?
	// We divide the line into nsyllables+1 segments.
	totalLength := wl.nsyllables * syllableSpacing
	segmentLength := float64(totalLength) / float64(wl.nsyllables+1)

	// Angle in radiants.
	rad := float64(wl.angle) * math.Pi / 180

	// X and Y offsets for the i-th syllable.
	x = -math.Cos(rad) * segmentLength * float64(i+1)
	y = -math.Sin(rad) * segmentLength * float64(i+1)
	return
}

func (wl WordLine) LenX() float64 {
	// The word line begins at X=0 and ends at
	// X=-(nsyllables * syllableSpacing) BUT we have to account for the angle.
	totalLength := wl.nsyllables * syllableSpacing
	rad := float64(wl.angle) * math.Pi / 180
	return math.Cos(rad) * float64(totalLength)
}

func wordLineSVG(n int) WordLine {
	// Draw the slanted line for the word branch.
	// n is how many syllables we will have, and determines the length of
	// the line.
	wl := WordLine{
		nsyllables: n,

		// Angles in published media:
		//  - Official PDF: 23°, 29.5°, 24.5°, 24°
		//  - "Happy new year": 30°, 27.5°
		//  - "April fools": 23°
		angle: -12,
	}

	wl.svg = SVG("<g> <!-- Word line -->\n")

	// The line sits at Y=0.
	// X goes from -(n * syllableSpacing) to 0: because we draw the text
	// backwards, this makes it easier to position the line.

	// Line.
	startX := -n * syllableSpacing
	s := SVGfn(`<line x1="%d" y1="0" x2="0" y2="0" />`, startX)

	// Dots indicating start and end.
	s += SVGfn(`<circle cx="%d" cy="0" r="0.5" fill="currentcolor" />`, startX)
	s += SVGfn(`<circle cx="0" cy="0" r="0.5" fill="currentcolor" />`)

	wl.svg += rotate(wl.angle, s)

	wl.svg += SVG("</g> <!-- End of word line -->\n")
	return wl
}

func wordsWidthHeight(words []Word) (int, int) {
	// Calculate how wide and tall the words will be in the SVG.
	// Doesn't have to be super accurate (and isn't due to the slanting), it's
	// used to size the general canvas, and compute the starting position.
	width := 0
	height := 0
	maxSyllables := 0
	for _, word := range words {
		if len(word) > maxSyllables {
			maxSyllables = len(word)
		}

		width += syllableSpacing * len(word)
		width += wordSpacing

		for _, syllable := range word {
			sh := 0
			prevC := false
			for _, glyph := range syllable {
				sh += glyph.height
				if !glyph.connector && !prevC {
					// Connector line.
					sh += 3
				}
				prevC = glyph.connector
			}
			if sh > height {
				height = sh
			}
		}
	}

	// We start at Y=topMargin, so we add that to the height.
	height += topMargin

	// Leave some margin for the rotation.  This is not accurate, but works
	// for reasonable syllable lengths.
	height += 5 * maxSyllables

	return width, height
}

func wordsToSVG(words []string) (SVG, int, int, error) {
	for _, word := range words {
		// The word is user-provided. Check it doesn't contain any problematic
		// characters that would cause issues in the SVG.
		if err := isSafeForSVG(word); err != nil {
			return SVG(""), 0, 0, fmt.Errorf(
				"word %q is not safe for SVG: %v", word, err)
		}
	}

	svg := SVGfn("<!-- Words: %v -->", words)

	// wordsG contains the words, as syllables of Glyphs.
	wordsG := []Word{}
	for _, word := range words {
		wordG, err := smartWordToGlyphs(word)
		if err != nil {
			return svg, 0, 0, fmt.Errorf(
				"error converting %q to glyphs: %v", word, err)
		}

		if len(wordG) == 0 {
			// Skip empty words, this can happen with words that contain just
			// "-" or "/".
			continue
		}

		wordsG = append(wordsG, wordG)
	}

	// The language is right to left, so we compute the total width, and start
	// there (+ some margin) and go backwards.
	width, height := wordsWidthHeight(wordsG)
	x := float64(width)

	for _, wordG := range wordsG {
		svg += SVGfn("<!-- Glyphs for %v -->", wordG)

		wl := wordLineSVG(len(wordG))
		wsvg := wl.svg
		for i, syllable := range wordG {
			offx, offy := wl.offsetFor(i)
			wsvg += movef(offx, offy,
				syllableToSVG(syllable))
		}

		svg += movef(x, topMargin,
			color("orange", wsvg))

		x -= wl.LenX()
		x -= wordSpacing
	}

	return svg, width + wordSpacing, height, nil
}

func isSafeForSVG(word string) error {
	// For our use cases, we don't expect any punctuation characters other
	// than '/' to separate syllables.
	// And in particular we don't want to allow any characters that could
	// alter the SVG structure, like '<' or '>'.
	// Note that are characters that still could cause errors later on, but
	// they wouldn't be problematic to include in an SVG.
	for _, r := range word {
		if r == '<' || r == '>' || r == '&' ||
			r == '"' || r == '\\' {
			return fmt.Errorf("unsafe character %q", r)
		}
	}
	return nil
}