diff --git a/src/pixie/fontformats/opentype.nim b/src/pixie/fontformats/opentype.nim index 537257a6..e300400a 100644 --- a/src/pixie/fontformats/opentype.nim +++ b/src/pixie/fontformats/opentype.nim @@ -1,4 +1,4 @@ -import flatty/binny, flatty/encode, math, ../common, ../paths, sets, +import chroma, flatty/binny, flatty/encode, math, ../common, ../paths, sets, strutils, tables, unicode, vmath ## See https://docs.microsoft.com/en-us/typography/opentype/spec/ @@ -368,6 +368,60 @@ type charIndex: seq[(int, int)] isCID: bool + ColrLayer* = object + glyphId*: uint16 + paletteIndex*: uint16 + + ColrTable* = ref object + ## Layered color glyphs (COLR version 0 records). + version*: uint16 + baseGlyphs*: Table[uint16, seq[ColrLayer]] + + CpalTable* = ref object + ## Color palettes used by the COLR table. + numPaletteEntries*: uint16 + palettes*: seq[seq[ColorRGBA]] + + BitmapGlyphRecord = object + imageFormat: uint16 + dataOffset: int ## Absolute offset of the glyph data block in the buffer. + dataLen: int + hasBigMetrics: bool + width, height: int + bearingX, bearingY: int + + BitmapStrike = object + ppem: int + glyphs: Table[uint16, BitmapGlyphRecord] + + CblcTable* = ref object + ## Color bitmap (PNG) glyph index, data lives in the CBDT table. + strikes: seq[BitmapStrike] + + SbixStrike = object + ppem: int + offset: int ## Absolute offset of the strike in the buffer. + glyphDataOffsets: seq[uint32] + + SbixTable* = ref object + ## Apple-style color bitmap (PNG) glyphs. + strikes: seq[SbixStrike] + + ColorGlyphLayer* = object + ## A single layer of a COLR color glyph. + path*: Path ## Layer outline in font units, y-flipped like getGlyphPath. + color*: ColorRGBA + useTextColor*: bool ## Layer uses the text fill color instead of a palette color. + + BitmapGlyph* = object + ## An embedded color bitmap glyph (CBDT or sbix). + png*: string ## Raw PNG file data. + ppem*: float32 ## Pixels per em of the bitmap strike. + xOffset*: float32 ## Origin to bitmap left edge, in strike pixels. + yOffset*: float32 ## Baseline to bitmap top edge (positive up), in strike + ## pixels. If yOffsetIsBottom, baseline to bottom edge. + yOffsetIsBottom*: bool + OpenType* = ref object buf*: string version*: uint32 @@ -389,7 +443,12 @@ type gpos*: GposTable post*: PostTable cff*: CFFTable + colr*: ColrTable + cpal*: CpalTable + cblc*: CblcTable + sbix*: SbixTable glyphPaths: Table[Rune, Path] + colorGlyphLayers: Table[Rune, seq[ColorGlyphLayer]] when defined(release): {.push checks: off.} @@ -450,8 +509,8 @@ proc parseCmapTable(buf: string, offset: int): CmapTable = encodingRecord.offset = buf.readUint32(i + 4).swap() i += 8 - if encodingRecord.platformID == 3: - # Windows + if encodingRecord.platformID == 3 or encodingRecord.platformID == 0: + # Windows and Unicode platforms, the subtable formats are the same. var i = offset + encodingRecord.offset.int buf.eofCheck(i + 2) @@ -2175,6 +2234,228 @@ proc parsePostTable(buf: string, offset: int): PostTable = result.underlineThickness = buf.readInt16(offset + 10).swap() result.isFixedPitch = buf.readUint32(offset + 12).swap() +proc parseColrTable(buf: string, offset: int): ColrTable = + ## https://learn.microsoft.com/en-us/typography/opentype/spec/colr + ## Parses the version 0 base glyph and layer records. These are also + ## present in version 1 tables as a fallback for the v1-only features. + buf.eofCheck(offset + 14) + + result = ColrTable() + result.version = buf.readUint16(offset + 0).swap() + + let + numBaseGlyphRecords = buf.readUint16(offset + 2).swap().int + baseGlyphRecordsOffset = buf.readUint32(offset + 4).swap().int + layerRecordsOffset = buf.readUint32(offset + 8).swap().int + numLayerRecords = buf.readUint16(offset + 12).swap().int + + var i = offset + layerRecordsOffset + buf.eofCheck(i + numLayerRecords * 4) + + var layers = newSeq[ColrLayer](numLayerRecords) + for j in 0 ..< numLayerRecords: + layers[j].glyphId = buf.readUint16(i + 0).swap() + layers[j].paletteIndex = buf.readUint16(i + 2).swap() + i += 4 + + i = offset + baseGlyphRecordsOffset + buf.eofCheck(i + numBaseGlyphRecords * 6) + + for j in 0 ..< numBaseGlyphRecords: + let + glyphId = buf.readUint16(i + 0).swap() + firstLayerIndex = buf.readUint16(i + 2).swap().int + numLayers = buf.readUint16(i + 4).swap().int + if firstLayerIndex + numLayers > layers.len: + failUnsupported("COLR layer index") + if numLayers > 0: + result.baseGlyphs[glyphId] = + layers[firstLayerIndex ..< firstLayerIndex + numLayers] + i += 6 + +proc parseCpalTable(buf: string, offset: int): CpalTable = + ## https://learn.microsoft.com/en-us/typography/opentype/spec/cpal + buf.eofCheck(offset + 12) + + result = CpalTable() + result.numPaletteEntries = buf.readUint16(offset + 2).swap() + + let + numPalettes = buf.readUint16(offset + 4).swap().int + numColorRecords = buf.readUint16(offset + 6).swap().int + colorRecordsArrayOffset = buf.readUint32(offset + 8).swap().int + + buf.eofCheck(offset + 12 + numPalettes * 2) + buf.eofCheck(offset + colorRecordsArrayOffset + numColorRecords * 4) + + for j in 0 ..< numPalettes: + let firstColorIndex = buf.readUint16(offset + 12 + j * 2).swap().int + if firstColorIndex + result.numPaletteEntries.int > numColorRecords: + failUnsupported("CPAL color index") + var + palette = newSeq[ColorRGBA](result.numPaletteEntries.int) + i = offset + colorRecordsArrayOffset + firstColorIndex * 4 + for k in 0 ..< result.numPaletteEntries.int: + # Color records are stored as BGRA. + palette[k] = rgba( + buf.readUint8(i + 2), + buf.readUint8(i + 1), + buf.readUint8(i + 0), + buf.readUint8(i + 3) + ) + i += 4 + result.palettes.add(palette) + +proc readBigMetrics( + buf: string, offset: int, record: var BitmapGlyphRecord +) = + ## Reads the first half of BigGlyphMetrics (the horizontal metrics). + buf.eofCheck(offset + 8) + record.hasBigMetrics = true + record.height = buf.readUint8(offset + 0).int + record.width = buf.readUint8(offset + 1).int + record.bearingX = buf.readInt8(offset + 2).int + record.bearingY = buf.readInt8(offset + 3).int + +proc parseCbdtIndexSubTable( + buf: string, + strike: var BitmapStrike, + subTableOffset, cbdtOffset, firstGlyphIndex, lastGlyphIndex: int +) = + buf.eofCheck(subTableOffset + 8) + + let + indexFormat = buf.readUint16(subTableOffset + 0).swap() + imageFormat = buf.readUint16(subTableOffset + 2).swap() + imageDataOffset = buf.readUint32(subTableOffset + 4).swap().int + + if imageFormat notin [17.uint16, 18, 19]: + # Only the PNG image formats are supported, skip anything else. + return + + let numGlyphsInRange = lastGlyphIndex - firstGlyphIndex + 1 + + case indexFormat: + of 1, 3: # Offsets array, uint32 or uint16 + let entrySize = if indexFormat == 1: 4 else: 2 + var i = subTableOffset + 8 + buf.eofCheck(i + (numGlyphsInRange + 1) * entrySize) + for j in 0 ..< numGlyphsInRange: + let (o1, o2) = + if indexFormat == 1: + (buf.readUint32(i + j * 4).swap().int, + buf.readUint32(i + j * 4 + 4).swap().int) + else: + (buf.readUint16(i + j * 2).swap().int, + buf.readUint16(i + j * 2 + 2).swap().int) + if o2 > o1: + strike.glyphs[(firstGlyphIndex + j).uint16] = BitmapGlyphRecord( + imageFormat: imageFormat, + dataOffset: cbdtOffset + imageDataOffset + o1, + dataLen: o2 - o1 + ) + of 2: # All glyphs have the same data size and metrics + buf.eofCheck(subTableOffset + 12) + let imageSize = buf.readUint32(subTableOffset + 8).swap().int + var record = BitmapGlyphRecord(imageFormat: imageFormat, dataLen: imageSize) + buf.readBigMetrics(subTableOffset + 12, record) + for j in 0 ..< numGlyphsInRange: + record.dataOffset = cbdtOffset + imageDataOffset + j * imageSize + strike.glyphs[(firstGlyphIndex + j).uint16] = record + of 4: # Sparse glyph id and offset pairs + buf.eofCheck(subTableOffset + 12) + let numGlyphs = buf.readUint32(subTableOffset + 8).swap().int + var i = subTableOffset + 12 + buf.eofCheck(i + (numGlyphs + 1) * 4) + for j in 0 ..< numGlyphs: + let + glyphId = buf.readUint16(i + j * 4).swap() + o1 = buf.readUint16(i + j * 4 + 2).swap().int + o2 = buf.readUint16(i + j * 4 + 6).swap().int + if o2 > o1: + strike.glyphs[glyphId] = BitmapGlyphRecord( + imageFormat: imageFormat, + dataOffset: cbdtOffset + imageDataOffset + o1, + dataLen: o2 - o1 + ) + of 5: # Sparse glyph ids, same data size and metrics + buf.eofCheck(subTableOffset + 24) + let imageSize = buf.readUint32(subTableOffset + 8).swap().int + var record = BitmapGlyphRecord(imageFormat: imageFormat, dataLen: imageSize) + buf.readBigMetrics(subTableOffset + 12, record) + let numGlyphs = buf.readUint32(subTableOffset + 20).swap().int + var i = subTableOffset + 24 + buf.eofCheck(i + numGlyphs * 2) + for j in 0 ..< numGlyphs: + record.dataOffset = cbdtOffset + imageDataOffset + j * imageSize + strike.glyphs[buf.readUint16(i + j * 2).swap()] = record + else: + discard + +proc parseCblcTable(buf: string, cblcOffset, cbdtOffset: int): CblcTable = + ## https://learn.microsoft.com/en-us/typography/opentype/spec/cblc + buf.eofCheck(cblcOffset + 8) + + let numSizes = buf.readUint32(cblcOffset + 4).swap().int + + result = CblcTable() + + var sizeOffset = cblcOffset + 8 + buf.eofCheck(sizeOffset + numSizes * 48) + + for s in 0 ..< numSizes: + let + indexSubTableArrayOffset = buf.readUint32(sizeOffset + 0).swap().int + numberOfIndexSubTables = buf.readUint32(sizeOffset + 8).swap().int + ppemX = buf.readUint8(sizeOffset + 44).int + + var strike = BitmapStrike(ppem: ppemX) + + var arrayOffset = cblcOffset + indexSubTableArrayOffset + buf.eofCheck(arrayOffset + numberOfIndexSubTables * 8) + for t in 0 ..< numberOfIndexSubTables: + let + firstGlyphIndex = buf.readUint16(arrayOffset + 0).swap().int + lastGlyphIndex = buf.readUint16(arrayOffset + 2).swap().int + additionalOffset = buf.readUint32(arrayOffset + 4).swap().int + if lastGlyphIndex >= firstGlyphIndex: + buf.parseCbdtIndexSubTable( + strike, + cblcOffset + indexSubTableArrayOffset + additionalOffset, + cbdtOffset, + firstGlyphIndex, + lastGlyphIndex + ) + arrayOffset += 8 + + if strike.glyphs.len > 0: + result.strikes.add(strike) + + sizeOffset += 48 + +proc parseSbixTable(buf: string, offset, numGlyphs: int): SbixTable = + ## https://learn.microsoft.com/en-us/typography/opentype/spec/sbix + buf.eofCheck(offset + 8) + + let numStrikes = buf.readUint32(offset + 4).swap().int + + result = SbixTable() + + buf.eofCheck(offset + 8 + numStrikes * 4) + + for s in 0 ..< numStrikes: + let strikeOffset = + offset + buf.readUint32(offset + 8 + s * 4).swap().int + buf.eofCheck(strikeOffset + 4 + (numGlyphs + 1) * 4) + var strike = SbixStrike( + ppem: buf.readUint16(strikeOffset + 0).swap().int, + offset: strikeOffset + ) + strike.glyphDataOffsets = newSeq[uint32](numGlyphs + 1) + for j in 0 .. numGlyphs: + strike.glyphDataOffsets[j] = buf.readUint32(strikeOffset + 4 + j * 4).swap() + result.strikes.add(strike) + proc getGlyphId(opentype: OpenType, rune: Rune): uint16 = result = opentype.cmap.runeToGlyphId.getOrDefault(rune, 0) @@ -2456,14 +2737,20 @@ proc parseCffGlyph(opentype: OpenType, glyphId: uint16): Path = charstring = opentype.buf[a ..< b] return cff.parseCFFCharstring(charstring, glyphId.int) -proc parseGlyph(opentype: OpenType, rune: Rune): Path {.inline.} = +proc parseGlyphById(opentype: OpenType, glyphId: uint16): Path = if opentype.glyf != nil: - opentype.parseGlyfGlyph(opentype.getGlyphId(rune)) + opentype.parseGlyfGlyph(glyphId) elif opentype.cff != nil: - opentype.parseCffGlyph(opentype.getGlyphId(rune)) + opentype.parseCffGlyph(glyphId) + elif opentype.cblc != nil or opentype.sbix != nil: + # Bitmap-only fonts have no glyph outlines. + newPath() else: raise newException(PixieError, "Invalid glyph storage") +proc parseGlyph(opentype: OpenType, rune: Rune): Path {.inline.} = + opentype.parseGlyphById(opentype.getGlyphId(rune)) + proc getGlyphPath*( opentype: OpenType, rune: Rune ): Path {.raises: [PixieError].} = @@ -2473,6 +2760,177 @@ proc getGlyphPath*( opentype.glyphPaths[rune] = path opentype.glyphPaths.getOrDefault(rune, nil) # Never actually returns nil +proc getColorGlyphLayers*( + opentype: OpenType, rune: Rune +): seq[ColorGlyphLayer] {.raises: [PixieError].} = + ## The COLR color layers for the rune, or an empty seq if the rune has no + ## layered color glyph. Layer paths are in font units, y-flipped like + ## getGlyphPath. + if opentype.colr == nil or opentype.cpal == nil or + opentype.cpal.palettes.len == 0: + return + if rune in opentype.colorGlyphLayers: + return opentype.colorGlyphLayers.getOrDefault(rune, @[]) + + let glyphId = opentype.getGlyphId(rune) + if glyphId in opentype.colr.baseGlyphs: + let palette = opentype.cpal.palettes[0] + for colrLayer in opentype.colr.baseGlyphs.getOrDefault(glyphId, @[]): + var layer = ColorGlyphLayer() + if colrLayer.paletteIndex == 0xFFFF: + layer.useTextColor = true + elif colrLayer.paletteIndex.int < palette.len: + layer.color = palette[colrLayer.paletteIndex.int] + else: + continue + layer.path = opentype.parseGlyphById(colrLayer.glyphId) + layer.path.transform(scale(vec2(1, -1))) + result.add(layer) + + opentype.colorGlyphLayers[rune] = result + +proc sbixGlyph( + opentype: OpenType, + strike: SbixStrike, + glyphId: uint16, + bitmap: var BitmapGlyph, + recursionDepth = 0 +): bool = + ## Reads a glyph record from an sbix strike, following "dupe" records. + if glyphId.int + 1 >= strike.glyphDataOffsets.len: + return false + let + o1 = strike.glyphDataOffsets[glyphId].int + o2 = strike.glyphDataOffsets[glyphId + 1].int + if o2 - o1 <= 8: # No glyph data, header alone is 8 bytes + return false + + let dataOffset = strike.offset + o1 + opentype.buf.eofCheck(strike.offset + o2) + + let graphicType = opentype.buf.readStr(dataOffset + 4, 4) + if graphicType == "dupe": + if recursionDepth > 4 or o2 - o1 < 10: + return false + let dupeGlyphId = opentype.buf.readUint16(dataOffset + 8).swap() + return opentype.sbixGlyph(strike, dupeGlyphId, bitmap, recursionDepth + 1) + if graphicType != "png ": + return false + + bitmap.png = opentype.buf[dataOffset + 8 ..< strike.offset + o2] + bitmap.ppem = strike.ppem.float32 + bitmap.xOffset = opentype.buf.readInt16(dataOffset + 0).swap().float32 + bitmap.yOffset = opentype.buf.readInt16(dataOffset + 2).swap().float32 + bitmap.yOffsetIsBottom = true + true + +proc getBitmapGlyph*( + opentype: OpenType, rune: Rune, sizePx: float32, bitmap: var BitmapGlyph +): bool {.raises: [PixieError].} = + ## Looks up an embedded color bitmap (PNG) glyph for the rune, picking the + ## best strike for the target pixel size. Returns false if the rune has no + ## bitmap glyph. + if rune notin opentype.cmap.runeToGlyphId: + return false + let glyphId = opentype.getGlyphId(rune) + + if opentype.cblc != nil: + # Pick the smallest strike >= sizePx, otherwise the largest one. + var best = -1 + for i, strike in opentype.cblc.strikes: + if glyphId notin strike.glyphs: + continue + if best == -1: + best = i + continue + let bestPpem = opentype.cblc.strikes[best].ppem + if bestPpem.float32 < sizePx: + if strike.ppem > bestPpem: + best = i + elif strike.ppem.float32 >= sizePx and strike.ppem < bestPpem: + best = i + if best >= 0: + let + strike = opentype.cblc.strikes[best] + record = strike.glyphs.getOrDefault(glyphId, BitmapGlyphRecord()) + var + pngOffset: int + pngLen: int + case record.imageFormat: + of 17: # Small glyph metrics, then PNG data + opentype.buf.eofCheck(record.dataOffset + 9) + bitmap.xOffset = opentype.buf.readInt8(record.dataOffset + 2).float32 + bitmap.yOffset = opentype.buf.readInt8(record.dataOffset + 3).float32 + pngLen = opentype.buf.readUint32(record.dataOffset + 5).swap().int + pngOffset = record.dataOffset + 9 + of 18: # Big glyph metrics, then PNG data + opentype.buf.eofCheck(record.dataOffset + 12) + bitmap.xOffset = opentype.buf.readInt8(record.dataOffset + 2).float32 + bitmap.yOffset = opentype.buf.readInt8(record.dataOffset + 3).float32 + pngLen = opentype.buf.readUint32(record.dataOffset + 8).swap().int + pngOffset = record.dataOffset + 12 + of 19: # Metrics in the CBLC index subtable, only PNG data here + if not record.hasBigMetrics: + return false + opentype.buf.eofCheck(record.dataOffset + 4) + bitmap.xOffset = record.bearingX.float32 + bitmap.yOffset = record.bearingY.float32 + pngLen = opentype.buf.readUint32(record.dataOffset + 0).swap().int + pngOffset = record.dataOffset + 4 + else: + return false + opentype.buf.eofCheck(pngOffset + pngLen) + bitmap.png = opentype.buf[pngOffset ..< pngOffset + pngLen] + bitmap.ppem = strike.ppem.float32 + bitmap.yOffsetIsBottom = false + return true + + if opentype.sbix != nil: + var best = -1 + for i, strike in opentype.sbix.strikes: + var candidate: BitmapGlyph + if not opentype.sbixGlyph(strike, glyphId, candidate): + continue + if best == -1: + best = i + continue + let bestPpem = opentype.sbix.strikes[best].ppem + if bestPpem.float32 < sizePx: + if strike.ppem > bestPpem: + best = i + elif strike.ppem.float32 >= sizePx and strike.ppem < bestPpem: + best = i + if best >= 0: + return opentype.sbixGlyph(opentype.sbix.strikes[best], glyphId, bitmap) + + false + +proc hasColorGlyph*(opentype: OpenType, rune: Rune): bool {.raises: [].} = + ## Returns true if the rune has a color (emoji) glyph. + if rune notin opentype.cmap.runeToGlyphId: + return false + let glyphId = opentype.getGlyphId(rune) + + if opentype.colr != nil and opentype.cpal != nil and + opentype.cpal.palettes.len > 0 and glyphId in opentype.colr.baseGlyphs: + return true + + if opentype.cblc != nil: + for strike in opentype.cblc.strikes: + if glyphId in strike.glyphs: + return true + + if opentype.sbix != nil: + for strike in opentype.sbix.strikes: + if glyphId.int + 1 < strike.glyphDataOffsets.len: + let + o1 = strike.glyphDataOffsets[glyphId].int + o2 = strike.glyphDataOffsets[glyphId + 1].int + if o2 - o1 > 8: + return true + + false + proc getLeftSideBearing*(opentype: OpenType, rune: Rune): float32 {.raises: [].} = let glyphId = opentype.getGlyphId(rune).int if glyphId < opentype.hmtx.hMetrics.len: @@ -2577,6 +3035,22 @@ proc parseOpenType*(buf: string, startLoc = 0): OpenType {.raises: [PixieError]. result.name = parseNameTable(buf, result.tableRecords["name"].offset.int) result.os2 = parseOS2Table(buf, result.tableRecords["OS/2"].offset.int) + if "COLR" in result.tableRecords and "CPAL" in result.tableRecords: + result.colr = parseColrTable(buf, result.tableRecords["COLR"].offset.int) + result.cpal = parseCpalTable(buf, result.tableRecords["CPAL"].offset.int) + + if "CBLC" in result.tableRecords and "CBDT" in result.tableRecords: + result.cblc = parseCblcTable( + buf, + result.tableRecords["CBLC"].offset.int, + result.tableRecords["CBDT"].offset.int + ) + + if "sbix" in result.tableRecords: + result.sbix = parseSbixTable( + buf, result.tableRecords["sbix"].offset.int, result.maxp.numGlyphs.int + ) + if "loca" in result.tableRecords and "glyf" in result.tableRecords: result.loca = parseLocaTable( buf, result.tableRecords["loca"].offset.int, result.head, result.maxp @@ -2586,7 +3060,8 @@ proc parseOpenType*(buf: string, startLoc = 0): OpenType {.raises: [PixieError]. elif "CFF " in result.tableRecords: result.cff = parseCFFTable(buf, result.tableRecords["CFF "].offset.int, result.maxp) - else: + elif result.cblc == nil and result.sbix == nil: + # Bitmap-only color fonts (e.g. Noto Color Emoji) have no outlines. failUnsupported("glyph outlines") if "kern" in result.tableRecords: diff --git a/src/pixie/fonts.nim b/src/pixie/fonts.nim index 7c3f09b4..bd3e82b9 100644 --- a/src/pixie/fonts.nim +++ b/src/pixie/fonts.nim @@ -1,4 +1,4 @@ -import bumpy, chroma, common, os, fontformats/opentype, +import bumpy, chroma, common, os, fileformats/png, fontformats/opentype, fontformats/svgfont, images, paints, paths, strutils, unicode, vmath @@ -13,6 +13,7 @@ type svgFont: SvgFont filePath*: string fallbacks*: seq[Typeface] + bitmapGlyphCache: Table[(Rune, int), Image] ## (rune, ppem) -> decoded PNG Font* = ref object typeface*: Typeface @@ -118,6 +119,10 @@ proc hasGlyph*(typeface: Typeface, rune: Rune): bool {.inline.} = else: typeface.svgFont.hasGlyph(rune) +proc hasColorGlyph*(typeface: Typeface, rune: Rune): bool {.inline, raises: [].} = + ## Returns if there is a color (emoji) glyph for this rune. + typeface.opentype != nil and typeface.opentype.hasColorGlyph(rune) + proc fallbackTypeface*(typeface: Typeface, rune: Rune): Typeface = ## Looks through fallback typefaces to find one that has the glyph. if typeface.hasGlyph(rune): @@ -253,6 +258,13 @@ proc convertTextCase(runes: var seq[Rune], textCase: TextCase) = proc canWrap(rune: Rune): bool {.inline.} = rune == Rune(32) or rune.isWhiteSpace() +proc isFormatRune(rune: Rune): bool {.inline.} = + ## Zero-width format runes that should not produce a glyph: zero-width + ## joiner and variation selectors (commonly used in emoji sequences). + rune.uint32 == 0x200D or + rune.uint32 in 0xFE00.uint32 .. 0xFE0F.uint32 or + rune.uint32 in 0xE0100.uint32 .. 0xE01EF.uint32 + proc typeset*( spans: openarray[Span], bounds = vec2(0, 0), @@ -278,8 +290,11 @@ proc typeset*( runes: seq[Rune] while i < span.text.len: fastRuneAt(span.text, i, rune, true) - # Ignore control runes (0 - 31) except LF for now - if rune.uint32 >= SP.uint32 or rune.uint32 == LF.uint32: + # Ignore control runes (0 - 31) except LF for now. + # Also ignore zero-width format runes (ZWJ, variation selectors) so + # emoji sequences degrade gracefully into their individual emoji. + if (rune.uint32 >= SP.uint32 or rune.uint32 == LF.uint32) and + not rune.isFormatRune(): runes.add(rune) if runes.len > 0: @@ -529,8 +544,15 @@ proc computePaths(arrangement: Arrangement): seq[Path] = strikeoutPosition = font.typeface.strikeoutPosition * font.scale for runeIndex in start .. stop: let + rune = arrangement.runes[runeIndex] position = arrangement.positions[runeIndex] - path = font.typeface.getGlyphPath(arrangement.runes[runeIndex]) + runeTypeface = font.typeface.fallbackTypeface(rune) + path = + if runeTypeface != nil and runeTypeface.hasColorGlyph(rune): + # Color glyphs are drawn separately, see drawColorGlyphs. + newPath() + else: + font.typeface.getGlyphPath(rune) path.transform( translate(position) * scale(vec2(font.scale)) @@ -563,6 +585,63 @@ proc computePaths(arrangement: Arrangement): seq[Path] = spanPath.addPath(path) result.add(spanPath) +proc drawColorGlyphs( + target: Image, + arrangement: Arrangement, + spanIndex: int, + transform: Mat3 +) {.raises: [PixieError].} = + ## Draws the color (emoji) glyphs of a span onto the target image. + let + font = arrangement.fonts[spanIndex] + (start, stop) = arrangement.spans[spanIndex] + for runeIndex in start .. stop: + let + rune = arrangement.runes[runeIndex] + typeface = font.typeface.fallbackTypeface(rune) + if typeface == nil or not typeface.hasColorGlyph(rune): + continue + + let + position = arrangement.positions[runeIndex] + opentype = typeface.opentype + + let layers = opentype.getColorGlyphLayers(rune) + if layers.len > 0: + # COLR layered vector glyph. + let layerScale = font.size / typeface.scale + for layer in layers: + let path = newPath() + path.addPath(layer.path) + path.transform(translate(position) * scale(vec2(layerScale))) + let paint = newPaint(SolidPaint) + paint.color = + if layer.useTextColor: + font.paint.color + else: + layer.color.color + target.fillPath(path, paint, transform) + continue + + # Embedded PNG bitmap glyph (CBDT or sbix). + var bitmap: BitmapGlyph + if opentype.getBitmapGlyph(rune, font.size, bitmap): + let key = (rune, bitmap.ppem.int) + if key notin typeface.bitmapGlyphCache: + typeface.bitmapGlyphCache[key] = newImage(decodePng(bitmap.png)) + let + image = typeface.bitmapGlyphCache.getOrDefault(key, nil) + bitmapScale = font.size / bitmap.ppem + top = + if bitmap.yOffsetIsBottom: + bitmap.yOffset + image.height.float32 + else: + bitmap.yOffset + offset = position + vec2(bitmap.xOffset, -top) * bitmapScale + target.draw( + image, transform * translate(offset) * scale(vec2(bitmapScale)) + ) + proc textUber( target: Image, arrangement: Arrangement, @@ -594,6 +673,7 @@ proc textUber( let font = arrangement.fonts[spanIndex] for paint in font.paints: target.fillPath(path, paint, transform) + target.drawColorGlyphs(arrangement, spanIndex, transform) proc computeBounds*( arrangement: Arrangement, diff --git a/tests/fonts/EmojiCbdt.ttf b/tests/fonts/EmojiCbdt.ttf new file mode 100644 index 00000000..182f5855 Binary files /dev/null and b/tests/fonts/EmojiCbdt.ttf differ diff --git a/tests/fonts/EmojiColr.ttf b/tests/fonts/EmojiColr.ttf new file mode 100644 index 00000000..368fdfa1 Binary files /dev/null and b/tests/fonts/EmojiColr.ttf differ diff --git a/tests/fonts/EmojiSbix.ttf b/tests/fonts/EmojiSbix.ttf new file mode 100644 index 00000000..0202c782 Binary files /dev/null and b/tests/fonts/EmojiSbix.ttf differ diff --git a/tests/fonts/TwemojiMozilla-subset.ttf b/tests/fonts/TwemojiMozilla-subset.ttf new file mode 100644 index 00000000..b2be7cc4 Binary files /dev/null and b/tests/fonts/TwemojiMozilla-subset.ttf differ diff --git a/tests/fonts/generate_emoji_fonts.py b/tests/fonts/generate_emoji_fonts.py new file mode 100644 index 00000000..a38106b8 --- /dev/null +++ b/tests/fonts/generate_emoji_fonts.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 +"""Generates the tiny color emoji test fonts used by test_fonts.nim. + +Each font maps U+1F600 (grinning face), U+2764 (heavy black heart), +U+2B50 (star), U+1F319 (crescent moon) and U+2600 (sun) and stores the +color glyphs in a different OpenType color format: + + EmojiColr.ttf - COLR/CPAL layered vector glyphs + EmojiCbdt.ttf - CBDT/CBLC embedded PNG bitmaps (no glyf table, like + Noto Color Emoji) + EmojiSbix.ttf - sbix embedded PNG bitmaps (like Apple Color Emoji) + +tests/fonts/TwemojiMozilla-subset.ttf is a real-world COLRv0 color emoji +font (Twemoji Mozilla, CC-BY 4.0 Twitter emoji graphics). It is produced +from https://github.com/mozilla/twemoji-colr/releases (Twemoji.Mozilla.ttf, +v0.7.0) with: + + pyftsubset Twemoji.Mozilla.ttf \ + --output-file=tests/fonts/TwemojiMozilla-subset.ttf \ + --unicodes="1F600,1F602,2764,FE0F,1F44D,1F389,1F30D,1F355,1F680,2B50,\ +1F319,2600,1F436,1F431,1F98A,1F354,26BD,1F3B8,1F4A1,2705,1F525" \ + --no-layout-closure + +Requires: pip install fonttools pillow +""" + +import math +import struct + +from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen +from fontTools.ttLib import newTable +from fontTools.ttLib.tables.sbixGlyph import Glyph as SbixGlyph +from fontTools.ttLib.tables.sbixStrike import Strike as SbixStrike +from fontTools.ttLib.tables.DefaultTable import DefaultTable +from PIL import Image, ImageDraw + +UPM = 1000 +ASCENT = 800 +DESCENT = -200 +PPEM = 64 + +EMOJI_NAMES = ["smiley", "heart", "star", "moon", "sun"] +CMAP = { + 0x1F600: "smiley", + 0x2764: "heart", + 0x2B50: "star", + 0x1F319: "moon", + 0x2600: "sun", +} + + +def circle(pen, cx, cy, r, clockwise=True): + # A circle approximated with four TrueType quadratic arcs. Clockwise by + # default like rect() and heart_shape(), so that overlapping contours + # within a glyph add up instead of cancelling under non-zero filling. + # A counterclockwise circle subtracts (cuts a hole) instead. + pen.moveTo((cx + r, cy)) + if clockwise: + pen.qCurveTo((cx + r, cy - r), (cx, cy - r)) + pen.qCurveTo((cx - r, cy - r), (cx - r, cy)) + pen.qCurveTo((cx - r, cy + r), (cx, cy + r)) + pen.qCurveTo((cx + r, cy + r), (cx + r, cy)) + else: + pen.qCurveTo((cx + r, cy + r), (cx, cy + r)) + pen.qCurveTo((cx - r, cy + r), (cx - r, cy)) + pen.qCurveTo((cx - r, cy - r), (cx, cy - r)) + pen.qCurveTo((cx + r, cy - r), (cx + r, cy)) + pen.closePath() + + +def star_points(cx, cy, r): + pts = [] + for i in range(10): + a = math.pi / 2 + i * math.pi / 5 + rad = r if i % 2 == 0 else r * 0.4 + pts.append((cx + rad * math.cos(a), cy + rad * math.sin(a))) + return pts + + +def star_shape(pen, cx, cy, r): + pts = [(round(x), round(y)) for x, y in reversed(star_points(cx, cy, r))] + pen.moveTo(pts[0]) + for pt in pts[1:]: + pen.lineTo(pt) + pen.closePath() + + +def moon_shape(pen, cx, cy, r): + # A crescent as a single contour: the left half of a circle closed with + # an inward arc. Two overlapping circles cannot express a crescent under + # non-zero winding (the cutter fills wherever it leaves the outer circle). + top = (cx, cy + r) + bottom = (cx, cy - r) + pen.moveTo(top) + pen.qCurveTo((cx - r, cy + r), (cx - r, cy)) + pen.qCurveTo((cx - r, cy - r), bottom) + pen.qCurveTo((cx - r * 0.35, cy - r * 0.6), (cx - r * 0.35, cy)) + pen.qCurveTo((cx - r * 0.35, cy + r * 0.6), top) + pen.closePath() + + +def sun_rays_shape(pen, cx, cy, r1, r2): + # Eight triangular rays pointing outward. + for i in range(8): + a = i * math.pi / 4 + tip = (cx + r2 * math.cos(a), cy + r2 * math.sin(a)) + base1 = (cx + r1 * math.cos(a - 0.2), cy + r1 * math.sin(a - 0.2)) + base2 = (cx + r1 * math.cos(a + 0.2), cy + r1 * math.sin(a + 0.2)) + pen.moveTo((round(tip[0]), round(tip[1]))) + pen.lineTo((round(base1[0]), round(base1[1]))) + pen.lineTo((round(base2[0]), round(base2[1]))) + pen.closePath() + + +def rect(pen, x, y, w, h): + pen.moveTo((x, y)) + pen.lineTo((x, y + h)) + pen.lineTo((x + w, y + h)) + pen.lineTo((x + w, y)) + pen.closePath() + + +def heart_shape(pen, cx, cy, s): + # A simple heart from a triangle and two circles. + pen.moveTo((cx - s, cy + s * 0.35)) + pen.lineTo((cx + s, cy + s * 0.35)) + pen.lineTo((cx, cy - s)) + pen.closePath() + circle(pen, cx - s * 0.48, cy + s * 0.35, s * 0.52) + circle(pen, cx + s * 0.48, cy + s * 0.35, s * 0.52) + + +def build_glyph(draw_func): + pen = TTGlyphPen(None) + draw_func(pen) + return pen.glyph() + + +def empty_glyph(): + return TTGlyphPen(None).glyph() + + +def base_font(fb_glyphs): + """Builds the common font scaffolding shared by all three test fonts.""" + glyph_order = [".notdef"] + list(fb_glyphs.keys()) + fb = FontBuilder(UPM) + fb.setupGlyphOrder(glyph_order) + fb.setupCharacterMap(CMAP) + glyphs = {".notdef": empty_glyph()} + glyphs.update(fb_glyphs) + fb.setupGlyf(glyphs) + metrics = {} + for name in glyph_order: + advance = UPM if name != ".notdef" else 500 + metrics[name] = (advance, 0) + fb.setupHorizontalMetrics(metrics) + fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT) + fb.setupOS2(sTypoAscender=ASCENT, sTypoDescender=DESCENT, + usWinAscent=ASCENT, usWinDescent=-DESCENT) + fb.setupPost() + return fb + + +def name_font(fb, family): + fb.setupNameTable({"familyName": family, "styleName": "Regular"}) + + +# ---------------------------------------------------------------- COLR/CPAL + +def build_colr(): + s = 300 # smiley radius / heart size + cx, cy = 500, 300 + + glyphs = { + # Fallback monochrome outlines for the base glyphs. + "smiley": build_glyph(lambda p: circle(p, cx, cy, s)), + "heart": build_glyph(lambda p: heart_shape(p, cx, cy, s * 0.8)), + "star": build_glyph(lambda p: star_shape(p, cx, cy, s)), + "moon": build_glyph(lambda p: moon_shape(p, cx, cy, s * 0.9)), + "sun": build_glyph(lambda p: circle(p, cx, cy, s * 0.55)), + # Color layers. + "smiley_face": build_glyph(lambda p: circle(p, cx, cy, s)), + "smiley_eyes": build_glyph(lambda p: ( + circle(p, cx - 120, cy + 90, 50), circle(p, cx + 120, cy + 90, 50))), + "smiley_mouth": build_glyph(lambda p: rect(p, cx - 150, cy - 160, 300, 80)), + "heart_fill": build_glyph(lambda p: heart_shape(p, cx, cy, s * 0.8)), + "heart_accent": build_glyph(lambda p: rect(p, cx - 60, cy - 290, 120, 60)), + "star_fill": build_glyph(lambda p: star_shape(p, cx, cy, s)), + "moon_fill": build_glyph(lambda p: moon_shape(p, cx, cy, s * 0.9)), + "sun_rays": build_glyph(lambda p: sun_rays_shape(p, cx, cy, s * 0.7, s)), + "sun_disk": build_glyph(lambda p: circle(p, cx, cy, s * 0.55)), + } + + fb = base_font(glyphs) + name_font(fb, "Emoji Colr Test") + + fb.setupCOLR({ + "smiley": [("smiley_face", 0), ("smiley_eyes", 1), ("smiley_mouth", 2)], + # The 0xFFFF palette index means "use the text color". + "heart": [("heart_fill", 3), ("heart_accent", 0xFFFF)], + "star": [("star_fill", 4)], + "moon": [("moon_fill", 5)], + "sun": [("sun_rays", 6), ("sun_disk", 0)], + }) + fb.setupCPAL([[ + (1.00, 0.80, 0.10, 1.0), # 0 yellow face + (0.25, 0.18, 0.10, 1.0), # 1 dark eyes + (0.75, 0.15, 0.15, 1.0), # 2 red mouth + (0.90, 0.20, 0.35, 1.0), # 3 heart pink + (1.00, 0.72, 0.05, 1.0), # 4 star gold + (0.95, 0.90, 0.55, 1.0), # 5 moon cream + (0.98, 0.55, 0.10, 1.0), # 6 sun ray orange + ]]) + + fb.save("tests/fonts/EmojiColr.ttf") + + +# ------------------------------------------------------------------ bitmaps + +def smiley_png(size): + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + m = size * 0.04 + d.ellipse([m, m, size - m, size - m], fill=(255, 204, 26, 255)) + r = size * 0.07 + for ex in (size * 0.32, size * 0.68): + d.ellipse([ex - r, size * 0.34 - r, ex + r, size * 0.34 + r], + fill=(64, 46, 26, 255)) + d.arc([size * 0.25, size * 0.35, size * 0.75, size * 0.78], + 20, 160, fill=(64, 46, 26, 255), width=max(2, size // 16)) + return img + + +def heart_png(size): + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + s = size + d.polygon([(s * 0.08, s * 0.38), (s * 0.92, s * 0.38), (s * 0.5, s * 0.95)], + fill=(230, 50, 90, 255)) + d.ellipse([s * 0.04, s * 0.08, s * 0.5, s * 0.54], fill=(230, 50, 90, 255)) + d.ellipse([s * 0.5, s * 0.08, s * 0.96, s * 0.54], fill=(230, 50, 90, 255)) + return img + + +def star_png(size): + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + # star_points works in y-up coordinates, flip y for the bitmap. + pts = [(x, size - y) for x, y in + star_points(size / 2, size / 2, size * 0.48)] + d.polygon(pts, fill=(255, 184, 13, 255)) + return img + + +def moon_png(size): + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + m = size * 0.05 + d.ellipse([m, m, size - m, size - m], fill=(242, 230, 140, 255)) + # ImageDraw writes raw pixel values, so a transparent fill cuts a hole. + o = size * 0.3 + d.ellipse([m + o, m - o * 0.4, size - m + o, size - m - o * 0.4], + fill=(0, 0, 0, 0)) + return img + + +def sun_png(size): + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + c = size / 2 + for i in range(8): + a = i * math.pi / 4 + tip = (c + size * 0.48 * math.cos(a), c + size * 0.48 * math.sin(a)) + base1 = (c + size * 0.3 * math.cos(a - 0.25), + c + size * 0.3 * math.sin(a - 0.25)) + base2 = (c + size * 0.3 * math.cos(a + 0.25), + c + size * 0.3 * math.sin(a + 0.25)) + d.polygon([tip, base1, base2], fill=(250, 140, 26, 255)) + r = size * 0.28 + d.ellipse([c - r, c - r, c + r, c + r], fill=(255, 204, 26, 255)) + return img + + +PNG_DRAWERS = { + "smiley": smiley_png, + "heart": heart_png, + "star": star_png, + "moon": moon_png, + "sun": sun_png, +} + + +def png_bytes(img): + import io + buf = io.BytesIO() + img.save(buf, "PNG") + return buf.getvalue() + + +def build_cbdt(): + glyphs = {name: empty_glyph() for name in EMOJI_NAMES} + fb = base_font(glyphs) + name_font(fb, "Emoji Cbdt Test") + font = fb.font + + pngs = [png_bytes(PNG_DRAWERS[name](PPEM)) for name in EMOJI_NAMES] + + # CBDT: header, then per glyph image format 17 data + # (small glyph metrics + png length + png data). + bearing_y = int(PPEM * 0.85) + cbdt = struct.pack(">HH", 3, 0) + offsets = [] # relative to the start of the glyph data area + pos = 0 + blocks = b"" + for png in pngs: + offsets.append(pos) + block = struct.pack(">BBbbB", PPEM, PPEM, 0, bearing_y, PPEM) + block += struct.pack(">I", len(png)) + png + blocks += block + pos += len(block) + offsets.append(pos) + cbdt += blocks + + # CBLC: one strike covering glyphs 1..len(EMOJI_NAMES), index format 1, + # image format 17. + last_gid = len(EMOJI_NAMES) + line_metrics = struct.pack(">bbBbbbbbbbbb", + bearing_y, bearing_y - PPEM, PPEM, + 0, 1, 0, 0, 0, 0, 0, 0, 0) + index_subtable = struct.pack(">HHI", 1, 17, 4) # format 1, png, after header + index_subtable += b"".join(struct.pack(">I", o) for o in offsets) + subtable_array = struct.pack(">HHI", 1, last_gid, 8) # after the array + index_tables_size = len(subtable_array) + len(index_subtable) + bitmap_size = struct.pack(">IIII", 56, index_tables_size, 1, 0) + bitmap_size += line_metrics + line_metrics + bitmap_size += struct.pack(">HHBBBb", 1, last_gid, PPEM, PPEM, 32, 1) + assert len(bitmap_size) == 48 + cblc = struct.pack(">HHI", 3, 0, 1) + bitmap_size + subtable_array + index_subtable + + for tag, data in (("CBDT", cbdt), ("CBLC", cblc)): + table = DefaultTable(tag) + table.data = data + font[tag] = table + + # Bitmap-only font: drop the outlines like real CBDT emoji fonts do. + font.recalcBBoxes = False + font["maxp"].tableVersion = 0x00005000 + del font["glyf"] + del font["loca"] + + fb.save("tests/fonts/EmojiCbdt.ttf") + + +def build_sbix(): + glyphs = {name: empty_glyph() for name in EMOJI_NAMES} + fb = base_font(glyphs) + name_font(fb, "Emoji Sbix Test") + font = fb.font + + sbix = newTable("sbix") + sbix.version = 1 + sbix.flags = 1 + strike = SbixStrike(ppem=PPEM, resolution=72) + descent_px = int(PPEM * 0.15) + for name in EMOJI_NAMES: + glyph = SbixGlyph(glyphName=name, graphicType="png ", + imageData=png_bytes(PNG_DRAWERS[name](PPEM)), + originOffsetX=0, + originOffsetY=-descent_px) + strike.glyphs[name] = glyph + sbix.strikes = {PPEM: strike} + font["sbix"] = sbix + + fb.save("tests/fonts/EmojiSbix.ttf") + + +if __name__ == "__main__": + build_colr() + build_cbdt() + build_sbix() + print("Wrote tests/fonts/EmojiColr.ttf, EmojiCbdt.ttf, EmojiSbix.ttf") diff --git a/tests/fonts/masters/emoji_cbdt.png b/tests/fonts/masters/emoji_cbdt.png new file mode 100644 index 00000000..f0f13d5d Binary files /dev/null and b/tests/fonts/masters/emoji_cbdt.png differ diff --git a/tests/fonts/masters/emoji_colr.png b/tests/fonts/masters/emoji_colr.png new file mode 100644 index 00000000..3b7b98ba Binary files /dev/null and b/tests/fonts/masters/emoji_colr.png differ diff --git a/tests/fonts/masters/emoji_fallback.png b/tests/fonts/masters/emoji_fallback.png new file mode 100644 index 00000000..4175f5eb Binary files /dev/null and b/tests/fonts/masters/emoji_fallback.png differ diff --git a/tests/fonts/masters/emoji_real.png b/tests/fonts/masters/emoji_real.png new file mode 100644 index 00000000..afbb4244 Binary files /dev/null and b/tests/fonts/masters/emoji_real.png differ diff --git a/tests/fonts/masters/emoji_sbix.png b/tests/fonts/masters/emoji_sbix.png new file mode 100644 index 00000000..6b44b00a Binary files /dev/null and b/tests/fonts/masters/emoji_sbix.png differ diff --git a/tests/fonts/masters/emoji_scaled.png b/tests/fonts/masters/emoji_scaled.png new file mode 100644 index 00000000..ff3e6d61 Binary files /dev/null and b/tests/fonts/masters/emoji_scaled.png differ diff --git a/tests/fonts/masters/emoji_ubuntu.png b/tests/fonts/masters/emoji_ubuntu.png new file mode 100644 index 00000000..e6dc53ca Binary files /dev/null and b/tests/fonts/masters/emoji_ubuntu.png differ diff --git a/tests/test_fonts.nim b/tests/test_fonts.nim index debaaa78..40187d50 100644 --- a/tests/test_fonts.nim +++ b/tests/test_fonts.nim @@ -1246,6 +1246,98 @@ block: for i, typeface in typefaces: echo i, ": ", typeface.name +block: # Color emoji, COLR/CPAL layered vector glyphs + var font = readFont("tests/fonts/EmojiColr.ttf") + font.size = 64 + let image = newImage(400, 100) + image.fill(rgba(255, 255, 255, 255)) + image.fillText(font, "πŸ˜€β€β­πŸŒ™β˜€") + image.xray("tests/fonts/masters/emoji_colr.png") + +block: # Color emoji, CBDT/CBLC embedded PNG bitmaps (no outlines at all) + var font = readFont("tests/fonts/EmojiCbdt.ttf") + font.size = 64 + let image = newImage(400, 100) + image.fill(rgba(255, 255, 255, 255)) + image.fillText(font, "πŸ˜€β€β­πŸŒ™β˜€") + image.xray("tests/fonts/masters/emoji_cbdt.png") + +block: # Color emoji, sbix embedded PNG bitmaps + var font = readFont("tests/fonts/EmojiSbix.ttf") + font.size = 64 + let image = newImage(400, 100) + image.fill(rgba(255, 255, 255, 255)) + image.fillText(font, "πŸ˜€β€β­πŸŒ™β˜€") + image.xray("tests/fonts/masters/emoji_sbix.png") + +block: # Color emoji at a size that does not match the bitmap strike + let image = newImage(460, 80) + image.fill(rgba(255, 255, 255, 255)) + var x: float32 + for file in ["EmojiColr.ttf", "EmojiCbdt.ttf", "EmojiSbix.ttf"]: + var font = readFont("tests/fonts/" & file) + font.size = 40 + image.fillText(font, "πŸ˜€β€β­", translate(vec2(x, 10))) + x += 140 + image.xray("tests/fonts/masters/emoji_scaled.png") + +block: # Color emoji from a fallback typeface, mixed with regular text + let + typeface = readTypeface("tests/fonts/Roboto-Regular_1.ttf") + emojiTypeface = readTypeface("tests/fonts/EmojiColr.ttf") + typeface.fallbacks.add(emojiTypeface) + var font = newFont(typeface) + font.size = 36 + let image = newImage(380, 60) + image.fill(rgba(255, 255, 255, 255)) + image.fillText(font, "Hey πŸ˜€ you ❀⭐!") + image.xray("tests/fonts/masters/emoji_fallback.png") + +block: # Real-world color emoji, no fallbacks: a subset of Twemoji Mozilla + # (COLRv0/CPAL), see generate_emoji_fonts.py for how it is produced. + var font = readFont("tests/fonts/TwemojiMozilla-subset.ttf") + font.size = 48 + let image = newImage(520, 130) + image.fill(rgba(255, 255, 255, 255)) + image.fillText(font, "πŸ˜€πŸ˜‚β€οΈπŸ‘πŸŽ‰πŸŒπŸ•πŸš€β­πŸŒ™\nβ˜€οΈπŸΆπŸ±πŸ¦ŠπŸ”βš½πŸŽΈπŸ’‘βœ…πŸ”₯") + image.xray("tests/fonts/masters/emoji_real.png") + +block: # Emoji from each color font as a fallback for Ubuntu text + var spans: seq[Span] + for file in ["EmojiColr.ttf", "EmojiCbdt.ttf", "EmojiSbix.ttf"]: + let typeface = readTypeface("tests/fonts/Ubuntu-Regular_1.ttf") + typeface.fallbacks.add(readTypeface("tests/fonts/" & file)) + var font = newFont(typeface) + font.size = 32 + spans.add(newSpan("Ubuntu πŸ˜€β­πŸŒ™β˜€β€ " & file & "\n", font)) + let image = newImage(480, 160) + image.fill(rgba(255, 255, 255, 255)) + image.fillText(typeset(spans)) + image.xray("tests/fonts/masters/emoji_ubuntu.png") + +block: # Variation selectors and zero-width joiners are invisible + var font = readFont("tests/fonts/EmojiColr.ttf") + font.size = 64 + # U+2764 alone and U+2764 U+FE0F must lay out identically. + doAssert font.layoutBounds("❀") == font.layoutBounds("❀️") + # A ZWJ sequence degrades into the individual emoji, no tofu in between. + doAssert font.layoutBounds("πŸ˜€β€πŸ˜€") == font.layoutBounds("πŸ˜€πŸ˜€") + +block: # hasColorGlyph + for file in ["EmojiColr.ttf", "EmojiCbdt.ttf", "EmojiSbix.ttf"]: + let typeface = readTypeface("tests/fonts/" & file) + for rune in [0x1F600, 0x2764, 0x2B50, 0x1F319, 0x2600]: + doAssert typeface.hasColorGlyph(Rune(rune)) + doAssert not typeface.hasColorGlyph(Rune('A'.ord)) + doAssert not readTypeface( + "tests/fonts/Roboto-Regular_1.ttf").hasColorGlyph(Rune(0x1F600)) + +block: # Stroking emoji must not crash, color glyphs are skipped + var font = readFont("tests/fonts/EmojiCbdt.ttf") + font.size = 64 + let image = newImage(280, 100) + image.strokeText(font, "πŸ˜€β€") + when defined(windows): block: let files = @[