From 6847effb9b768b10e85aae2c552c93fefa93c30a Mon Sep 17 00:00:00 2001 From: Nigel Tao Date: Sat, 18 Mar 2017 13:10:39 +1100 Subject: [PATCH] font/sfnt: support font collections (.ttc and .otc files). Also add tests for Apple proprietary fonts. Change-Id: I5ce8efa2397bb01c5255d956a77c955ba1383105 Reviewed-on: https://go-review.googlesource.com/38272 Reviewed-by: David Crawshaw --- font/sfnt/proprietary_test.go | 131 +++++++++++++++++++++++++---- font/sfnt/sfnt.go | 150 +++++++++++++++++++++++++++------- 2 files changed, 237 insertions(+), 44 deletions(-) diff --git a/font/sfnt/proprietary_test.go b/font/sfnt/proprietary_test.go index 9137fea..8948d5d 100644 --- a/font/sfnt/proprietary_test.go +++ b/font/sfnt/proprietary_test.go @@ -19,14 +19,14 @@ End User License Agreement (EULA) and a CAB format decoder. These tests assume that such fonts have already been installed. You may need to specify the directories for these fonts: -go test golang.org/x/image/font/sfnt -args -proprietary -adobeDir=/foo/bar/aFonts -microsoftDir=/foo/bar/mFonts +go test golang.org/x/image/font/sfnt -args -proprietary -adobeDir=$HOME/fonts/adobe -appleDir=$HOME/fonts/apple -microsoftDir=$HOME/fonts/microsoft To only run those tests for the Microsoft fonts: -go test golang.org/x/image/font/sfnt -test.run=ProprietaryMicrosoft -args -proprietary +go test golang.org/x/image/font/sfnt -test.run=ProprietaryMicrosoft -args -proprietary etc */ -// TODO: add Apple system fonts? Google fonts (Droid? Noto?)? Emoji fonts? +// TODO: add Google fonts (Droid? Noto?)? Emoji fonts? // TODO: enable Apple/Microsoft tests by default on Darwin/Windows? @@ -35,6 +35,8 @@ import ( "flag" "io/ioutil" "path/filepath" + "strconv" + "strings" "testing" "golang.org/x/image/font" @@ -60,6 +62,16 @@ var ( "directory name for the Adobe proprietary fonts", ) + appleDir = flag.String( + "appleDir", + // This needs to be set explicitly. These fonts come with macOS, which + // is widely available but not freely available. + // + // On a Mac, set this to "/System/Library/Fonts/". + "", + "directory name for the Apple proprietary fonts", + ) + microsoftDir = flag.String( "microsoftDir", "/usr/share/fonts/truetype/msttcorefonts", @@ -87,10 +99,26 @@ func TestProprietaryAdobeSourceSansProTTF(t *testing.T) { testProprietary(t, "adobe", "SourceSansPro-Regular.ttf", 1800, 54) } +func TestProprietaryAppleAppleSymbols(t *testing.T) { + testProprietary(t, "apple", "Apple Symbols.ttf", 4600, -1) +} + +func TestProprietaryAppleHiragino0(t *testing.T) { + testProprietary(t, "apple", "ヒラギノ角ゴシック W0.ttc?0", 9000, 6) +} + +func TestProprietaryAppleHiragino1(t *testing.T) { + testProprietary(t, "apple", "ヒラギノ角ゴシック W0.ttc?1", 9000, 6) +} + func TestProprietaryMicrosoftArial(t *testing.T) { testProprietary(t, "microsoft", "Arial.ttf", 1200, -1) } +func TestProprietaryMicrosoftArialAsACollection(t *testing.T) { + testProprietary(t, "microsoft", "Arial.ttf?0", 1200, -1) +} + func TestProprietaryMicrosoftComicSansMS(t *testing.T) { testProprietary(t, "microsoft", "Comic_Sans_MS.ttf", 550, -1) } @@ -117,27 +145,55 @@ func testProprietary(t *testing.T, proprietor, filename string, minNumGlyphs, fi t.Skip("skipping proprietary font test") } - file, err := []byte(nil), error(nil) + basename, fontIndex, err := filename, -1, error(nil) + if i := strings.IndexByte(filename, '?'); i >= 0 { + fontIndex, err = strconv.Atoi(filename[i+1:]) + if err != nil { + t.Fatalf("could not parse collection font index from filename %q", filename) + } + basename = filename[:i] + } + + dir := "" switch proprietor { case "adobe": - file, err = ioutil.ReadFile(filepath.Join(*adobeDir, filename)) - if err != nil { - t.Fatalf("%v\nPerhaps you need to set the -adobeDir=%v flag?", err, *adobeDir) - } + dir = *adobeDir + case "apple": + dir = *appleDir case "microsoft": - file, err = ioutil.ReadFile(filepath.Join(*microsoftDir, filename)) - if err != nil { - t.Fatalf("%v\nPerhaps you need to set the -microsoftDir=%v flag?", err, *microsoftDir) - } + dir = *microsoftDir default: panic("unreachable") } - f, err := Parse(file) + file, err := ioutil.ReadFile(filepath.Join(dir, basename)) if err != nil { - t.Fatalf("Parse: %v", err) + t.Fatalf("%v\nPerhaps you need to set the -%sDir flag?", err, proprietor) } - ppem := fixed.Int26_6(f.UnitsPerEm()) qualifiedFilename := proprietor + "/" + filename + + f := (*Font)(nil) + if fontIndex >= 0 { + c, err := ParseCollection(file) + if err != nil { + t.Fatalf("ParseCollection: %v", err) + } + if want, ok := proprietaryNumFonts[qualifiedFilename]; ok { + if got := c.NumFonts(); got != want { + t.Fatalf("NumFonts: got %d, want %d", got, want) + } + } + f, err = c.Font(fontIndex) + if err != nil { + t.Fatalf("Font: %v", err) + } + } else { + f, err = Parse(file) + if err != nil { + t.Fatalf("Parse: %v", err) + } + } + + ppem := fixed.Int26_6(f.UnitsPerEm()) var buf Buffer // Some of the tests below, such as which glyph index a particular rune @@ -147,7 +203,7 @@ func testProprietary(t *testing.T, proprietor, filename string, minNumGlyphs, fi // message, but don't automatically fail (i.e. dont' call t.Fatalf). gotVersion, err := f.Name(&buf, NameIDVersion) if err != nil { - t.Fatalf("Name: %v", err) + t.Fatalf("Name(Version): %v", err) } wantVersion := proprietaryVersions[qualifiedFilename] if gotVersion != wantVersion { @@ -155,6 +211,15 @@ func testProprietary(t *testing.T, proprietor, filename string, minNumGlyphs, fi "\ngot %q\nwant %q", gotVersion, wantVersion) } + gotFull, err := f.Name(&buf, NameIDFull) + if err != nil { + t.Fatalf("Name(Full): %v", err) + } + wantFull := proprietaryFullNames[qualifiedFilename] + if gotFull != wantFull { + t.Fatalf("Name(Full):\ngot %q\nwant %q", gotFull, wantFull) + } + numGlyphs := f.NumGlyphs() if numGlyphs < minNumGlyphs { t.Fatalf("NumGlyphs: got %d, want at least %d", numGlyphs, minNumGlyphs) @@ -231,6 +296,15 @@ kernLoop: } } +// proprietaryNumFonts holds the expected number of fonts in each collection, +// or 1 for a single font. It is not necessarily an exhaustive list of all +// proprietary fonts tested. +var proprietaryNumFonts = map[string]int{ + "apple/ヒラギノ角ゴシック W0.ttc?0": 2, + "apple/ヒラギノ角ゴシック W0.ttc?1": 2, + "microsoft/Arial.ttf?0": 1, +} + // proprietaryVersions holds the expected version string of each proprietary // font tested. If third parties such as Adobe or Microsoft update their fonts, // and the tests subsequently fail, these versions should be updated too. @@ -245,12 +319,37 @@ var proprietaryVersions = map[string]string{ "adobe/SourceSansPro-Regular.otf": "Version 2.020;PS 2.0;hotconv 1.0.86;makeotf.lib2.5.63406", "adobe/SourceSansPro-Regular.ttf": "Version 2.020;PS 2.000;hotconv 1.0.86;makeotf.lib2.5.63406", + "apple/Apple Symbols.ttf": "12.0d3e10", + "apple/ヒラギノ角ゴシック W0.ttc?0": "11.0d7e1", + "apple/ヒラギノ角ゴシック W0.ttc?1": "11.0d7e1", + "microsoft/Arial.ttf": "Version 2.82", + "microsoft/Arial.ttf?0": "Version 2.82", "microsoft/Comic_Sans_MS.ttf": "Version 2.10", "microsoft/Times_New_Roman.ttf": "Version 2.82", "microsoft/Webdings.ttf": "Version 1.03", } +// proprietaryFullNames holds the expected full name of each proprietary font +// tested. +var proprietaryFullNames = map[string]string{ + "adobe/SourceCodePro-Regular.otf": "Source Code Pro", + "adobe/SourceCodePro-Regular.ttf": "Source Code Pro", + "adobe/SourceHanSansSC-Regular.otf": "Source Han Sans SC Regular", + "adobe/SourceSansPro-Regular.otf": "Source Sans Pro", + "adobe/SourceSansPro-Regular.ttf": "Source Sans Pro", + + "apple/Apple Symbols.ttf": "Apple Symbols", + "apple/ヒラギノ角ゴシック W0.ttc?0": "Hiragino Sans W0", + "apple/ヒラギノ角ゴシック W0.ttc?1": ".Hiragino Kaku Gothic Interface W0", + + "microsoft/Arial.ttf": "Arial", + "microsoft/Arial.ttf?0": "Arial", + "microsoft/Comic_Sans_MS.ttf": "Comic Sans MS", + "microsoft/Times_New_Roman.ttf": "Times New Roman", + "microsoft/Webdings.ttf": "Webdings", +} + // proprietaryGlyphIndexTestCases hold a sample of each font's rune to glyph // index cmap. The numerical values can be verified by running the ttx tool. var proprietaryGlyphIndexTestCases = map[string]map[rune]GlyphIndex{ diff --git a/font/sfnt/sfnt.go b/font/sfnt/sfnt.go index e7c6670..32122bf 100644 --- a/font/sfnt/sfnt.go +++ b/font/sfnt/sfnt.go @@ -49,6 +49,7 @@ const ( maxCompoundStackSize = 64 maxGlyphDataLength = 64 * 1024 maxHintBits = 256 + maxNumFonts = 256 maxNumTables = 256 maxRealNumberStrLen = 64 // Maximum length in bytes of the "-123.456E-7" representation. @@ -61,22 +62,24 @@ var ( // ErrNotFound indicates that the requested value was not found. ErrNotFound = errors.New("sfnt: not found") - errInvalidBounds = errors.New("sfnt: invalid bounds") - errInvalidCFFTable = errors.New("sfnt: invalid CFF table") - errInvalidCmapTable = errors.New("sfnt: invalid cmap table") - errInvalidGlyphData = errors.New("sfnt: invalid glyph data") - errInvalidHeadTable = errors.New("sfnt: invalid head table") - errInvalidKernTable = errors.New("sfnt: invalid kern table") - errInvalidLocaTable = errors.New("sfnt: invalid loca table") - errInvalidLocationData = errors.New("sfnt: invalid location data") - errInvalidMaxpTable = errors.New("sfnt: invalid maxp table") - errInvalidNameTable = errors.New("sfnt: invalid name table") - errInvalidPostTable = errors.New("sfnt: invalid post table") - errInvalidSourceData = errors.New("sfnt: invalid source data") - errInvalidTableOffset = errors.New("sfnt: invalid table offset") - errInvalidTableTagOrder = errors.New("sfnt: invalid table tag order") - errInvalidUCS2String = errors.New("sfnt: invalid UCS-2 string") - errInvalidVersion = errors.New("sfnt: invalid version") + errInvalidBounds = errors.New("sfnt: invalid bounds") + errInvalidCFFTable = errors.New("sfnt: invalid CFF table") + errInvalidCmapTable = errors.New("sfnt: invalid cmap table") + errInvalidFont = errors.New("sfnt: invalid font") + errInvalidFontCollection = errors.New("sfnt: invalid font collection") + errInvalidGlyphData = errors.New("sfnt: invalid glyph data") + errInvalidHeadTable = errors.New("sfnt: invalid head table") + errInvalidKernTable = errors.New("sfnt: invalid kern table") + errInvalidLocaTable = errors.New("sfnt: invalid loca table") + errInvalidLocationData = errors.New("sfnt: invalid location data") + errInvalidMaxpTable = errors.New("sfnt: invalid maxp table") + errInvalidNameTable = errors.New("sfnt: invalid name table") + errInvalidPostTable = errors.New("sfnt: invalid post table") + errInvalidSingleFont = errors.New("sfnt: invalid single font (data is a font collection)") + errInvalidSourceData = errors.New("sfnt: invalid source data") + errInvalidTableOffset = errors.New("sfnt: invalid table offset") + errInvalidTableTagOrder = errors.New("sfnt: invalid table tag order") + errInvalidUCS2String = errors.New("sfnt: invalid UCS-2 string") errUnsupportedCFFVersion = errors.New("sfnt: unsupported CFF version") errUnsupportedCmapEncodings = errors.New("sfnt: unsupported cmap encodings") @@ -85,6 +88,7 @@ var ( errUnsupportedKernTable = errors.New("sfnt: unsupported kern table") errUnsupportedRealNumberEncoding = errors.New("sfnt: unsupported real number encoding") errUnsupportedNumberOfCmapSegments = errors.New("sfnt: unsupported number of cmap segments") + errUnsupportedNumberOfFonts = errors.New("sfnt: unsupported number of fonts") errUnsupportedNumberOfHints = errors.New("sfnt: unsupported number of hints") errUnsupportedNumberOfTables = errors.New("sfnt: unsupported number of tables") errUnsupportedPlatformEncoding = errors.New("sfnt: unsupported platform encoding") @@ -256,19 +260,105 @@ type table struct { offset, length uint32 } -// Parse parses an SFNT font from a []byte data source. -func Parse(src []byte) (*Font, error) { - f := &Font{src: source{b: src}} - if err := f.initialize(); err != nil { +// ParseCollection parses an SFNT font collection, such as TTC or OTC data, +// from a []byte data source. +// +// If passed data for a single font, a TTF or OTF instead of a TTC or OTC, it +// will return a collection containing 1 font. +func ParseCollection(src []byte) (*Collection, error) { + c := &Collection{src: source{b: src}} + if err := c.initialize(); err != nil { + return nil, err + } + return c, nil +} + +// ParseCollectionReaderAt parses an SFNT collection, such as TTC or OTC data, +// from an io.ReaderAt data source. +// +// If passed data for a single font, a TTF or OTF instead of a TTC or OTC, it +// will return a collection containing 1 font. +func ParseCollectionReaderAt(src io.ReaderAt) (*Collection, error) { + c := &Collection{src: source{r: src}} + if err := c.initialize(); err != nil { + return nil, err + } + return c, nil +} + +// Collection is a collection of one or more fonts. +// +// All of the Collection methods are safe to call concurrently. +type Collection struct { + src source + offsets []uint32 +} + +// NumFonts returns the number of fonts in the collection. +func (c *Collection) NumFonts() int { return len(c.offsets) } + +func (c *Collection) initialize() error { + // The https://www.microsoft.com/typography/otspec/otff.htm "Font + // Collections" section describes the TTC Header. + buf, err := c.src.view(nil, 0, 12) + if err != nil { + return err + } + // These cases match the switch statement in Font.initializeTables. + switch u32(buf) { + default: + return errInvalidFontCollection + case 0x00010000, 0x4f54544f: + // Try parsing it as a single font instead of a collection. + c.offsets = []uint32{0} + case 0x74746366: // "ttcf". + numFonts := u32(buf[8:]) + if numFonts == 0 || numFonts > maxNumFonts { + return errUnsupportedNumberOfFonts + } + buf, err = c.src.view(nil, 12, int(4*numFonts)) + if err != nil { + return err + } + c.offsets = make([]uint32, numFonts) + for i := range c.offsets { + o := u32(buf[4*i:]) + if o > maxTableOffset { + return errUnsupportedTableOffsetLength + } + c.offsets[i] = o + } + } + return nil +} + +// Font returns the i'th font in the collection. +func (c *Collection) Font(i int) (*Font, error) { + if i < 0 || len(c.offsets) <= i { + return nil, ErrNotFound + } + f := &Font{src: c.src} + if err := f.initialize(int(c.offsets[i])); err != nil { return nil, err } return f, nil } -// ParseReaderAt parses an SFNT font from an io.ReaderAt data source. +// Parse parses an SFNT font, such as TTF or OTF data, from a []byte data +// source. +func Parse(src []byte) (*Font, error) { + f := &Font{src: source{b: src}} + if err := f.initialize(0); err != nil { + return nil, err + } + return f, nil +} + +// ParseReaderAt parses an SFNT font, such as TTF or OTF data, from an +// io.ReaderAt data source. func ParseReaderAt(src io.ReaderAt) (*Font, error) { f := &Font{src: source{r: src}} - if err := f.initialize(); err != nil { + if err := f.initialize(0); err != nil { return nil, err } return f, nil @@ -362,11 +452,11 @@ func (f *Font) NumGlyphs() int { return len(f.cached.locations) - 1 } // UnitsPerEm returns the number of units per em for f. func (f *Font) UnitsPerEm() Units { return f.cached.unitsPerEm } -func (f *Font) initialize() error { +func (f *Font) initialize(offset int) error { if !f.src.valid() { return errInvalidSourceData } - buf, isPostScript, err := f.initializeTables(nil) + buf, isPostScript, err := f.initializeTables(offset) if err != nil { return err } @@ -412,21 +502,25 @@ func (f *Font) initialize() error { return nil } -func (f *Font) initializeTables(buf []byte) (buf1 []byte, isPostScript bool, err error) { +func (f *Font) initializeTables(offset int) (buf1 []byte, isPostScript bool, err error) { // https://www.microsoft.com/typography/otspec/otff.htm "Organization of an // OpenType Font" says that "The OpenType font starts with the Offset // Table", which is 12 bytes. - buf, err = f.src.view(buf, 0, 12) + buf, err := f.src.view(nil, offset, 12) if err != nil { return nil, false, err } + // When updating the cases in this switch statement, also update the + // Collection.initialize method. switch u32(buf) { default: - return nil, false, errInvalidVersion + return nil, false, errInvalidFont case 0x00010000: // No-op. case 0x4f54544f: // "OTTO". isPostScript = true + case 0x74746366: // "ttcf". + return nil, false, errInvalidSingleFont } numTables := int(u16(buf[4:])) if numTables > maxNumTables { @@ -435,7 +529,7 @@ func (f *Font) initializeTables(buf []byte) (buf1 []byte, isPostScript bool, err // "The Offset Table is followed immediately by the Table Record entries... // sorted in ascending order by tag", 16 bytes each. - buf, err = f.src.view(buf, 12, 16*numTables) + buf, err = f.src.view(buf, offset+12, 16*numTables) if err != nil { return nil, false, err }