~sbinet/star-tex

63602d0909fbe4e1589c1906c6424d51f5e4e239 — Sebastien Binet 4 months ago 25a01eb
font/afm: first import

Fixes #15.

Signed-off-by: Sebastien Binet <s@sbinet.org>
A font/afm/afm.go => font/afm/afm.go +40 -0
@@ 0,0 1,40 @@
// Copyright ©2021 The star-tex Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// Package afm implements a decoder for AFM (Adobe Font Metrics) files.
//
// See:
//  - https://adobe-type-tools.github.io/font-tech-notes/pdfs/5004.AFM_Spec.pdf
//
// for more informations.
package afm // import "star-tex.org/x/tex/font/afm"

import (
	"star-tex.org/x/tex/font/fixed"
)

type trackKern struct {
	degree    int
	minPtSize fixed.Int16_16
	minKern   fixed.Int16_16
	maxPtSize fixed.Int16_16
	maxKern   fixed.Int16_16
}

type kernPair struct {
	n1 string         // name of the first character of this pair.
	n2 string         // name of the second character of this pair.
	x  fixed.Int16_16 // x component of the kerning vector.
	y  fixed.Int16_16 // y component of the kerning vector.
}

type composite struct {
	name  string
	parts []part
}

type part struct {
	name string
	x, y fixed.Int16_16 // (x,y) displacement from the origin.
}

A font/afm/font.go => font/afm/font.go +150 -0
@@ 0,0 1,150 @@
// Copyright ©2021 The star-tex Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package afm

import (
	"fmt"
	"io"

	"star-tex.org/x/tex/font/fixed"
)

type direction struct {
	// underlinePosition is the distance from the baseline for centering
	// underlining strokes.
	underlinePosition fixed.Int16_16

	// underlineThickness is the stroke width for underlining.
	underlineThickness fixed.Int16_16

	// italicAngle is the angle (in degrees counter-clockwise from the vertical)
	// of the dominant vertical stroke of the font.
	italicAngle fixed.Int16_16

	// charWidth is the width vector of this font's program characters.
	charWidth charWidth

	// isFixedPitch indicates whether the program is a fixed pitch (monospace) font.
	isFixedPitch bool
}

type charWidth struct {
	x fixed.Int16_16 // x component of the width vector of a font's program characters.
	y fixed.Int16_16 // y component of the width vector of a font's program characters.
}

type charMetric struct {
	// c is the decimal value of default character code.
	// c is -1 if the character is not encoded.
	c int

	// name is the PostScript name of this character.
	name string

	// w0 is the character width vector for writing direction 0.
	w0 charWidth

	// w1 is the character width vector for writing direction 1.
	w1 charWidth

	// vvector holds the components of a vector from origin 0 to origin 1.
	// origin 0 is the origin for writing direction 0.
	// origin 1 is the origin for writing direction 1.
	vv [2]fixed.Int16_16

	// bbox is the character bounding box.
	bbox bbox

	// ligs is a ligature sequence.
	ligs []lig
}

type bbox struct {
	llx, lly fixed.Int16_16
	urx, ury fixed.Int16_16
}

// lig is a ligature.
type lig struct {
	// succ is the name of the successor
	succ string
	// name is the name of the composite ligature, consisting
	// of the current character and the successor.
	name string
}

// Font is an Adobe Font metrics.
type Font struct {
	// metricsSets defines the writing direction.
	// 0: direction 0 only.
	// 1: direction 1 only.
	// 2: both directions.
	metricsSets int

	fontName   string // fontName is the name of the font program as presented to the PostScript language 'findfont' operator.
	fullName   string // fullName is the full text name of the font.
	familyName string // familyName is the name of the typeface family to which the font belongs.
	weight     string // weight is the weight of the font (ex: Regular, Bold, Light).
	bbox       bbox   // bbox is the font bounding box.
	version    string // version is the font program version identifier.
	notice     string // notice contains the font name trademark or copyright notice.

	// encodingScheme specifies the default encoding vector used for this font
	// program (ex: AdobeStandardEncoding, JIS12-88-CFEncoding, ...)
	// Special font program might state FontSpecific.
	encodingScheme string
	mappingScheme  int
	escChar        int
	characterSet   string // characterSet describes the character set (glyph complement) of this font program.
	characters     int    // characters describes the number of characters defined in this font program.
	isBaseFont     bool   // isBaseFont indicates whether this font is a base font program.

	// vvector holds the components of a vector from origin 0 to origin 1.
	// origin 0 is the origin for writing direction 0.
	// origin 1 is the origin for writing direction 1.
	// vvector is required when metricsSet is 2.
	vvector [2]fixed.Int16_16

	isFixedV  bool // isFixedV indicates whether vvector is the same for every character in this font.
	isCIDFont bool // isCIDFont indicates whether the font is a CID-keyed font.

	capHeight fixed.Int16_16 // capHeight is usually the y-value of the top of the capital 'H'.
	xHeight   fixed.Int16_16 // xHeight is typically the y-value of the top of the lowercase 'x'.
	ascender  fixed.Int16_16 // ascender is usually the y-value of the top of the lowercase 'd'.
	descender fixed.Int16_16 // descender is typically the y-value of the bottom of the lowercase 'p'.
	stdHW     fixed.Int16_16 // stdHW specifies the dominant width of horizontal stems.
	stdVW     fixed.Int16_16 // stdVW specifies the dominant width of vertical stems.

	blendAxisTypes       []string
	blendDesignPositions [][]fixed.Int16_16
	blendDesignMap       [][][]fixed.Int16_16
	weightVector         []fixed.Int16_16

	direction   [3]direction
	charMetrics []charMetric
	composites  []composite

	tkerns []trackKern
	pkerns []kernPair
}

func newFont() Font {
	return Font{
		isBaseFont: true,
	}
}

// Parse parses an AFM file.
func Parse(r io.Reader) (Font, error) {
	var (
		fnt = newFont()
		p   = newParser(r)
	)
	err := p.parse(&fnt)
	if err != nil {
		return fnt, fmt.Errorf("could not parse AFM file: %w", err)
	}
	return fnt, nil
}

A font/afm/font_test.go => font/afm/font_test.go +79 -0
@@ 0,0 1,79 @@
// Copyright ©2021 The star-tex Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package afm

import (
	"math"
	"os"
	"testing"

	"star-tex.org/x/tex/font/fixed"
	"star-tex.org/x/tex/kpath"
)

func TestParseCMR10(t *testing.T) {
	ktx := kpath.New()
	name, err := ktx.Find("cmr10.afm")
	if err != nil {
		t.Fatalf("could not find cmr10.afm file: %+v", err)
	}

	f, err := ktx.Open(name)
	if err != nil {
		t.Fatalf("could not open afm: %+v", err)
	}
	defer f.Close()

	fnt, err := Parse(f)
	if err != nil {
		t.Fatalf("could not parse afm: %+v", err)
	}

	if fnt.ascender != fixed.I16_16(694) {
		t.Fatalf("invalid ascender: %v", fnt.ascender)
	}
}

func TestParse(t *testing.T) {
	for _, tc := range []string{
		"testdata/fake-vertical.afm",
		"testdata/times-with-composites.afm",
	} {
		t.Run(tc, func(t *testing.T) {
			f, err := os.Open(tc)
			if err != nil {
				t.Fatalf("could not open AFM test file: %+v", err)
			}
			defer f.Close()

			_, err = Parse(f)
			if err != nil {
				t.Fatalf("could not parse AFM test file: %+v", err)
			}
		})
	}
}

func TestInt16_16(t *testing.T) {
	const tol = 1e-5
	for _, tc := range []struct {
		str  string
		want float64
	}{
		{"0", 0},
		{"1.0", 1},
		{"1.2", 1.2},
		{"+1.2", +1.2},
		{"-1.2", -1.2},
	} {
		t.Run("", func(t *testing.T) {
			v := fixedFrom(tc.str)
			got := v.Float64()
			if diff := math.Abs(got - tc.want); diff > tol {
				t.Fatalf("invalid 16:16 value: got=%v, want=%v (diff=%e)", got, tc.want, diff)
			}
		})
	}
}

A font/afm/parser.go => font/afm/parser.go +496 -0
@@ 0,0 1,496 @@
// Copyright ©2021 The star-tex Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package afm

import (
	"bufio"
	"fmt"
	"io"
	"log"
	"strconv"
	"strings"

	"star-tex.org/x/tex/font/fixed"
)

type parser struct {
	s    *bufio.Scanner
	err  error
	line int
	toks []string
}

func newParser(r io.Reader) *parser {
	return &parser{s: bufio.NewScanner(r)}
}

//TODO(sbinet): "BlendAxisTypes"
//TODO(sbinet): "BlendDesignPositions"
//TODO(sbinet): "BlendDesignMap"
//TODO(sbinet): "WeightVector"

func (p *parser) parse(fnt *Font) error {
loop:
	for p.scan() {
		switch p.toks[0] {
		case "StartFontMetrics":
			// ok.
		case "MetricsSets":
			fnt.metricsSets = p.readInt(1)
		case "Comment":
			// ignore.
		case "FontName":
			fnt.fontName = p.readStr(1)
		case "FullName":
			fnt.fullName = p.readStr(1)
		case "FamilyName":
			fnt.familyName = p.readStr(1)
		case "Weight":
			fnt.weight = p.readStr(1)
		case "FontBBox":
			fnt.bbox.llx = p.readFixed(1)
			fnt.bbox.lly = p.readFixed(2)
			fnt.bbox.urx = p.readFixed(3)
			fnt.bbox.ury = p.readFixed(4)
		case "Version":
			fnt.version = p.readStr(1)
		case "Notice":
			fnt.notice = p.readStr(1)
		case "EncodingScheme":
			fnt.encodingScheme = p.readStr(1)
		case "MappingScheme":
			fnt.mappingScheme = p.readInt(1)
		case "EscChar":
			fnt.escChar = p.readInt(1)
		case "CharacterSet":
			fnt.characterSet = p.readStr(1)
		case "Characters":
			fnt.characters = p.readInt(1)
		case "IsBaseFont":
			fnt.isBaseFont = p.readBool(1)
		case "VVector":
			fnt.vvector[0] = p.readFixed(1)
			fnt.vvector[1] = p.readFixed(2)
		case "IsFixedV":
			fnt.isFixedV = p.readBool(1)
		case "IsCIDFont":
			fnt.isCIDFont = p.readBool(1)
		case "CapHeight":
			fnt.capHeight = p.readFixed(1)
		case "XHeight":
			fnt.xHeight = p.readFixed(1)
		case "Ascender":
			fnt.ascender = p.readFixed(1)
		case "Descender":
			fnt.descender = p.readFixed(1)
		case "StdHW":
			fnt.stdHW = p.readFixed(1)
		case "StdVW":
			fnt.stdVW = p.readFixed(1)

		case "UnderlinePosition":
			fnt.direction[0].underlinePosition = p.readFixed(1)
		case "UnderlineThickness":
			fnt.direction[0].underlineThickness = p.readFixed(1)
		case "ItalicAngle":
			fnt.direction[0].italicAngle = p.readFixed(1)
		case "CharWidth":
			fnt.direction[0].charWidth.x = p.readFixed(1)
			fnt.direction[0].charWidth.y = p.readFixed(2)
		case "IsFixedPitch":
			fnt.direction[0].isFixedPitch = p.readBool(1)

		case "StartDirection":
			i := p.readInt(1)
			err := p.parseDirection(&fnt.direction[i])
			if err != nil {
				return fmt.Errorf("could not scan AFM Direction section: %w", err)
			}

		case "StartAxis":
			err := p.parseAxis(fnt)
			if err != nil {
				return fmt.Errorf("could not scan AFM Axis section: %w", err)
			}

		case "StartCharMetrics":
			err := p.parseCharMetrics(fnt, p.readInt(1))
			if err != nil {
				return fmt.Errorf("could not scan AFM CharMetrics section: %w", err)
			}
		case "StartKernData":
			err := p.parseKernData(fnt)
			if err != nil {
				return fmt.Errorf("could not scan AFM KernData section: %w", err)
			}
		case "StartComposites":
			err := p.parseComposites(fnt, p.readInt(1))
			if err != nil {
				return fmt.Errorf("could not scan AFM Composites section: %w", err)
			}
		case "EndFontMetrics":
			break loop
		default:
			log.Printf("invalid FontMetrics token %q (line=%d)", p.toks[0], p.line)
		}
	}

	if p.err != nil {
		return fmt.Errorf("could not parse AFM file: %w", p.err)
	}

	if err := p.s.Err(); err != nil {
		return fmt.Errorf("could not parse AFM file: %w", p.err)
	}

	return nil
}

func (p *parser) parseDirection(dir *direction) error {
	for p.scan() {
		switch p.toks[0] {
		case "EndDirection":
			return nil
		case "Comment":
			// ignore.
		case "UnderlinePosition":
			dir.underlinePosition = p.readFixed(1)
		case "UnderlineThickness":
			dir.underlineThickness = p.readFixed(1)
		case "ItalicAngle":
			dir.italicAngle = p.readFixed(1)
		case "CharWidth":
			dir.charWidth.x = p.readFixed(1)
			dir.charWidth.y = p.readFixed(2)
		case "IsFixedPitch":
			dir.isFixedPitch = p.readBool(1)
		default:
			return fmt.Errorf("invalid Direction token %q", p.toks[0])
		}
	}
	return p.err
}

func (p *parser) parseAxis(fnt *Font) error {
	// FIXME(sbinet)
	if true {
		return fmt.Errorf("Axis section not supported")
	}

	for p.scan() {
		switch p.toks[0] {
		case "EndAxis":
			return nil
		case "Comment":
			// ignore.
		default:
			return fmt.Errorf("invalid Axis token %q", p.toks[0])
		}
	}
	return p.err
}

func (p *parser) parseCharMetrics(fnt *Font, n int) error {
	fnt.charMetrics = make([]charMetric, 0, n)
	for p.scan() {
		switch p.toks[0] {
		case "EndCharMetrics":
			return nil
		case "Comment":
			// ignore.
		case "C", "CH",
			"WX", "W0X", "W1X",
			"WY", "W0Y", "W1Y",
			"W", "W0", "W1",
			"VV",
			"N",
			"B",
			"L":
			err := p.parseCharMetric(fnt)
			if err != nil {
				return fmt.Errorf("could not parse CharMetric entry: %w", err)
			}
		default:
			return fmt.Errorf("invalid CharMetrics token %q", p.toks[0])
		}
	}
	return p.err
}

func (p *parser) parseCharMetric(fnt *Font) error {
	ch := charMetric{c: -1}
	for _, v := range strings.Split(p.s.Text(), ";") {
		v = strings.TrimSpace(v)
		if v == "" {
			continue
		}
		p.toks = strings.Fields(v)
		switch p.toks[0] {
		case "C":
			ch.c = p.readInt(1)
		case "CH":
			ch.c = p.readHex(1)
		case "WX", "W0X":
			ch.w0.x = p.readFixed(1)
			ch.w0.y = 0
		case "W1X":
			ch.w1.x = p.readFixed(1)
			ch.w1.y = 0
		case "WY", "W0Y":
			ch.w0.x = 0
			ch.w0.y = p.readFixed(1)
		case "W1Y":
			ch.w1.x = 0
			ch.w1.y = p.readFixed(1)
		case "W", "W0":
			ch.w0.x = p.readFixed(1)
			ch.w0.y = p.readFixed(2)
		case "W1":
			ch.w1.x = p.readFixed(1)
			ch.w1.y = p.readFixed(2)
		case "VV":
			ch.vv[0] = p.readFixed(1)
			ch.vv[1] = p.readFixed(2)
		case "N":
			ch.name = p.readStr(1)
		case "B":
			ch.bbox.llx = p.readFixed(1)
			ch.bbox.lly = p.readFixed(2)
			ch.bbox.urx = p.readFixed(3)
			ch.bbox.ury = p.readFixed(4)
		case "L":
			ch.ligs = append(ch.ligs, lig{
				succ: p.readStr(1),
				name: p.readStr(2),
			})
		}
	}
	fnt.charMetrics = append(fnt.charMetrics, ch)
	return p.err
}

func (p *parser) parseKernData(fnt *Font) error {
	for p.scan() {
		switch p.toks[0] {
		case "EndKernData":
			return nil
		case "Comment":
			// ignore.
		case "StartKernPairs", "StartKernPairs0":
			err := p.parseKernPairs(fnt, p.readInt(1))
			if err != nil {
				return fmt.Errorf("could not scan AFM KernPairs section: %w", err)
			}
		case "StartKernPairs1":
			return fmt.Errorf("KernPairs in direction 1 not supported")
		case "StartTrackKern":
			err := p.parseTrackKern(fnt, p.readInt(1))
			if err != nil {
				return fmt.Errorf("could not scan AFM KernPairs section: %w", err)
			}
		default:
			return fmt.Errorf("invalid KernData token %q", p.toks[0])
		}
	}
	return p.err
}

func (p *parser) parseKernPairs(fnt *Font, n int) error {
	fnt.pkerns = make([]kernPair, 0, n)
	for p.scan() {
		switch p.toks[0] {
		case "EndKernPairs":
			return nil
		case "Comment":
			// ignore.
		case "KP":
			fnt.pkerns = append(fnt.pkerns, kernPair{
				n1: p.readStr(1),
				n2: p.readStr(2),
				x:  p.readFixed(3),
				y:  p.readFixed(4),
			})
		case "KPH":
			fnt.pkerns = append(fnt.pkerns, kernPair{
				n1: string(rune(p.readHex(1))),
				n2: string(rune(p.readHex(2))),
				x:  p.readFixed(3),
				y:  p.readFixed(4),
			})
		case "KPX":
			fnt.pkerns = append(fnt.pkerns, kernPair{
				n1: p.readStr(1),
				n2: p.readStr(2),
				x:  p.readFixed(3),
				y:  0,
			})
		case "KPY":
			fnt.pkerns = append(fnt.pkerns, kernPair{
				n1: p.readStr(1),
				n2: p.readStr(2),
				x:  0,
				y:  p.readFixed(3),
			})
		default:
			return fmt.Errorf("invalid KernPairs token %q", p.toks[0])
		}
	}
	return p.err
}

func (p *parser) parseTrackKern(fnt *Font, n int) error {
	fnt.tkerns = make([]trackKern, 0, n)
	for p.scan() {
		switch p.toks[0] {
		case "EndTrackKern":
			return nil
		case "Comment":
			// ignore.
		case "TrackKern":
			fnt.tkerns = append(fnt.tkerns, trackKern{
				degree:    p.readInt(1),
				minPtSize: p.readFixed(2),
				minKern:   p.readFixed(3),
				maxPtSize: p.readFixed(4),
				maxKern:   p.readFixed(5),
			})
		default:
			return fmt.Errorf("invalid TrackKern token %q", p.toks[0])
		}
	}
	return p.err
}

func (p *parser) parseComposites(fnt *Font, n int) error {
	fnt.composites = make([]composite, 0, n)
	for p.scan() {
		switch p.toks[0] {
		case "EndComposites":
			return nil
		case "Comment":
			// ignore.
		case "CC":
			err := p.parseComposite(fnt)
			if err != nil {
				return fmt.Errorf("could not parse Composite: %w", err)
			}
		default:
			return fmt.Errorf("invalid Composite token %q", p.toks[0])
		}
	}
	return p.err
}

func (p *parser) parseComposite(fnt *Font) error {
	var comp composite
	for _, v := range strings.Split(p.s.Text(), ";") {
		v = strings.TrimSpace(v)
		if v == "" {
			continue
		}
		p.toks = strings.Fields(v)
		switch p.toks[0] {
		case "CC":
			comp.name = p.readStr(1)
			comp.parts = make([]part, 0, p.readInt(2))
		case "PCC":
			comp.parts = append(comp.parts, part{
				name: p.readStr(1),
				x:    p.readFixed(2),
				y:    p.readFixed(3),
			})
		}
	}
	return p.err
}

func (p *parser) scan() bool {
	if p.err != nil {
		return false
	}
	p.line++
	ok := p.s.Scan()
	p.toks = strings.Fields(strings.TrimSpace(p.s.Text()))
	if ok && len(p.toks) == 0 {
		// skip empty lines.
		return p.scan()
	}
	p.err = p.s.Err()
	return ok
}

func (p *parser) readStr(i int) string {
	if len(p.toks) <= i {
		return ""
	}
	return p.toks[i]
}

func (p *parser) readInt(i int) int {
	if len(p.toks) <= i {
		return 0
	}
	return atoi(p.toks[i])
}

func (p *parser) readHex(i int) int {
	if len(p.toks) <= i {
		return 0
	}
	return atoHex(p.toks[i])
}

func (p *parser) readBool(i int) bool {
	if len(p.toks) <= i {
		return false
	}
	return atob(p.toks[i])
}

func (p *parser) readFixed(i int) fixed.Int16_16 {
	if len(p.toks) <= i {
		return 0
	}
	return fixedFrom(p.toks[i])
}

func fixedFrom(v string) fixed.Int16_16 {
	if strings.Contains(v, ",") {
		v = strings.Replace(v, ",", ".", 1)
	}
	o, err := fixed.ParseInt16_16(v)
	if err != nil {
		panic(err)
	}
	return o
}

func atoi(s string) int {
	v, err := strconv.Atoi(s)
	if err != nil {
		panic(err)
	}
	return v
}

func atoHex(s string) int {
	s = strings.Trim(s, "<>")
	hex, err := strconv.ParseInt(s, 16, 32)
	if err != nil {
		panic(err)
	}
	return int(hex)
}

func atob(s string) bool {
	switch s {
	case "true":
		return true
	case "false":
		return false
	default:
		panic(fmt.Errorf("invalid boolean value %q", s))
	}
}

A font/afm/testdata/fake-vertical.afm => font/afm/testdata/fake-vertical.afm +24 -0
@@ 0,0 1,24 @@
StartFontMetrics 3.0
MetricsSets 1
FontName Fake Vertical Font
IsBaseFont false
Characters 3
FontBBox -500 -1185 500 23
EncodingScheme JIS12-88-CFEncoding
MappingScheme 2
FullName Fake Vertical Font
FamilyName FakeVertical
Weight Light
Version 001.001
Notice Burn
StartDirection 1
ItalicAngle 0
CharWidth 0 -1000
EndDirection
StartCharMetrics 3
CH <2121> ; B 0 0 0 0 ;
CH <2122> ; B 211 -337 435 -81 ;
CH <747E> ; B 0 0 0 0 ;
EndCharMetrics
EndFontMetrics


A font/afm/testdata/times-with-composites.afm => font/afm/testdata/times-with-composites.afm +50 -0
@@ 0,0 1,50 @@
StartFontMetrics 2.0
Comment Copyright (c) 1985, 1987, 1989, 1990 Adobe Systems Incorporated.  All Rights Reserved.
Comment Creation Date: Tue Mar 20 13:14:56 1990
Comment UniqueID 28427
Comment VMusage 32912 39804
FontName Times-Italic
FullName Times Italic
FamilyName Times
Weight Medium
ItalicAngle -15.5
IsFixedPitch false
FontBBox -169 -217 1010 883
UnderlinePosition -100
UnderlineThickness 50
Version 001.007
Notice Copyright (c) 1985, 1987, 1989, 1990 Adobe Systems Incorporated.  All Rights Reserved.Times is a trademark of Linotype AG and/or its subsidiaries.
EncodingScheme AdobeStandardEncoding
CapHeight 653
XHeight 441
Ascender 683
Descender -205
StartCharMetrics 2
Comment comments are allowed
C 32 ; WX 250 ; N space ; B 0 0 0 0 ;
C -1 ; WX 750 ; N onehalf ; B 34 -10 749 676 ;
EndCharMetrics
StartKernData
StartKernPairs 3
Comment comments are allowed

KPX A y -55
KPX A w -55

KPX z e 0
EndKernPairs

StartTrackKern 1
Comment comments are allowed
TrackKern -3 6 -.1 72 -3.78
EndTrackKern

EndKernData
StartComposites 4
Comment comments are allowed
CC Aacute 2 ; PCC A 0 0 ; PCC acute 139 212 ;
CC Acircumflex 2 ; PCC A 0 0 ; PCC circumflex 144 212 ;
CC ydieresis 2 ; PCC y 0 0 ; PCC dieresis 36 0 ;
CC zcaron 2 ; PCC z 0 0 ; PCC caron 8 0 ;
EndComposites
EndFontMetrics