diff --git a/src/pixie.nim b/src/pixie.nim index fbdc2e9..c35bb23 100644 --- a/src/pixie.nim +++ b/src/pixie.nim @@ -1,14 +1,18 @@ import bumpy, chroma, flatty/binny, os, pixie/blends, pixie/common, pixie/fileformats/bmp, pixie/fileformats/gif, pixie/fileformats/jpg, - pixie/fileformats/png, pixie/fileformats/svg, pixie/images, pixie/masks, - pixie/paints, pixie/paths, vmath + pixie/fileformats/png, pixie/fileformats/svg, pixie/fonts, pixie/images, + pixie/masks, pixie/paints, pixie/paths, vmath -export blends, bumpy, chroma, common, images, masks, paints, paths, vmath +export blends, bumpy, chroma, common, fonts, images, masks, paints, paths, vmath type FileFormat* = enum ffPng, ffBmp, ffJpg, ffGif +proc readFont*(filePath: string): Font = + ## Loads a font from a file. + parseOtf(readFile(filePath)) + converter autoStraightAlpha*(c: ColorRGBX): ColorRGBA {.inline.} = ## Convert a paremultiplied alpha RGBA to a straight alpha RGBA. c.rgba() diff --git a/src/pixie/demo.nim b/src/pixie/demo.nim index 58b4f4e..fae1dbb 100644 --- a/src/pixie/demo.nim +++ b/src/pixie/demo.nim @@ -1,7 +1,6 @@ -import staticglfw except Image -import opengl, pixie -export pixie -export staticglfw except Image +import opengl, pixie, staticglfw except Image + +export pixie, staticglfw except Image var screen* = newImage(800, 600) diff --git a/src/pixie/fontformats/opentype.nim b/src/pixie/fontformats/opentype.nim new file mode 100644 index 0000000..209f2fd --- /dev/null +++ b/src/pixie/fontformats/opentype.nim @@ -0,0 +1,808 @@ +import flatty/binny, math, pixie/common, pixie/paths, tables, unicode, vmath + +## See https://docs.microsoft.com/en-us/typography/opentype/spec/ + +export tables + +type + EncodingRecord* = object + platformID*: uint16 + encodingID*: uint16 + offset*: uint32 + + CmapTable* = ref object + version*: uint16 + numTables*: uint16 + encodingRecords*: seq[EncodingRecord] + runeToGlyphId*: Table[Rune, uint16] + + HeadTable* = ref object + majorVersion*: uint16 + minorVersion*: uint16 + fontRevision*: float32 + checkSumAdjustment*: uint32 + magicNumber*: uint32 + flags*: uint16 + unitsPerEm*: uint16 + created*: float64 + modified*: float64 + xMin*: int16 + yMin*: int16 + xMax*: int16 + yMax*: int16 + macStyle*: uint16 + lowestRecPPEM*: uint16 + fontDirectionHint*: int16 + indexToLocFormat*: int16 + glyphDataFormat*: int16 + + HheaTable* = ref object + majorVersion*: uint16 + minorVersion*: uint16 + ascender*: int16 + descender*: int16 + lineGap*: int16 + advanceWidthMax*: uint16 + minLeftSideBearing*: int16 + minRightSideBearing*: int16 + xMaxExtent*: int16 + caretSlopeRise*: int16 + caretSlopeRun*: int16 + caretOffset*: int16 + metricDataFormat*: int16 + numberOfHMetrics*: uint16 + + MaxpTable* = ref object + version*: float32 + numGlyphs*: uint16 + maxPoints*: uint16 + maxContours*: uint16 + maxCompositePoints*: uint16 + maxCompositeContours*: uint16 + maxZones*: uint16 + maxTwilightPoints*: uint16 + maxStorage*: uint16 + maxFunctionDefs*: uint16 + maxInstructionDefs*: uint16 + maxStackElements*: uint16 + maxSizeOfInstructions*: uint16 + maxComponentElements*: uint16 + maxComponentDepth*: uint16 + + LongHorMetricRecord* = object + advanceWidth*: uint16 + leftSideBearing*: int16 + + HmtxTable* = ref object + hMetrics*: seq[LongHorMetricRecord] + leftSideBearings*: seq[int16] + + NameRecord* = object + platformID*: uint16 + encodingID*: uint16 + languageID*: uint16 + nameID*: uint16 + length*: uint16 + offset*: uint16 + + NameTable* = ref object + format*: uint16 + count*: uint16 + stringOffset*: uint16 + nameRecords*: seq[NameRecord] + + OS2Table* = ref object + version*: uint16 + xAvgCharWidth*: int16 + usWeightClass*: uint16 + usWidthClass*: uint16 + fsType*: uint16 + ySubscriptXSize*: int16 + ySubscriptYSize*: int16 + ySubscriptXOffset*: int16 + ySubscriptYOffset*: int16 + ySuperscriptXSize*: int16 + ySuperscriptYSize*: int16 + ySuperscriptXOffset*: int16 + ySuperscriptYOffset*: int16 + yStrikeoutSize*: int16 + yStrikeoutPosition*: int16 + sFamilyClass*: int16 + panose*: array[10, uint8] + ulUnicodeRange1*: uint32 + ulUnicodeRange2*: uint32 + ulUnicodeRange3*: uint32 + ulUnicodeRange4*: uint32 + achVendID*: string + fsSelection*: uint16 + usFirstCharIndex*: uint16 + usLastCharIndex*: uint16 + sTypoAscender*: int16 + sTypoDescender*: int16 + sTypoLineGap*: int16 + usWinAscent*: uint16 + usWinDescent*: uint16 + ulCodePageRange1*: uint32 + ulCodePageRange2*: uint32 + sxHeight*: int16 + sCapHeight*: int16 + usDefaultChar*: uint16 + usBreakChar*: uint16 + usMaxContext*: uint16 + usLowerOpticalPointSize*: uint16 + usUpperOpticalPointSize*: uint16 + + LocaTable* = ref object + offsets*: seq[uint32] + + GlyfTable* = ref object + offsets*: seq[uint32] + + TableRecord* = object + tag*: string + checksum*: uint32 + offset*: uint32 + length*: uint32 + + OpenType* = ref object + buf*: string + version*: uint32 + numTables*: uint16 + searchRange*: uint16 + entrySelector*: uint16 + rangeShift*: uint16 + tableRecords*: Table[string, TableRecord] + cmap*: CmapTable + head*: HeadTable + hhea*: HheaTable + maxp*: MaxpTable + hmtx*: HmtxTable + name*: NameTable + os2*: OS2Table + loca*: LocaTable + glyf*: GlyfTable + +proc eofCheck(buf: string, readTo: int) {.inline.} = + if readTo > buf.len: + raise newException(PixieError, "Unexpected error reading font data, EOF") + +proc failUnsupported() = + raise newException(PixieError, "Unsupported font data") + +proc readUint16Seq(buf: string, offset, len: int): seq[uint16] = + result = newSeq[uint16](len) + for i in 0 ..< len: + result[i] = buf.readUint16(offset + i * 2).swap() + +proc readFixed32(buf: string, offset: int): float32 = + ## Packed 32-bit value with major and minor version numbers. + ceil(buf.readInt32(offset).swap().float32 / 65536.0 * 100000.0) / 100000.0 + +proc readFixed16(buf: string, offset: int): float32 = + ## Reads 16-bit signed fixed number with the low 14 bits of fraction (2.14). + buf.readInt16(offset).swap().float32 / 16384.0 + +proc readLongDateTime(buf: string, offset: int): float64 = + ## Date and time represented in number of seconds since 12:00 midnight, + ## January 1, 1904, UTC. + buf.readInt64(offset).swap().float64 - 2082844800 + +proc parseCmapTable(buf: string, offset: int): CmapTable = + var i = offset + buf.eofCheck(i + 4) + + result = CmapTable() + result.version = buf.readUint16(i + 0).swap() + result.numTables = buf.readUint16(i + 2).swap() + i += 4 + + for j in 0 ..< result.numTables.int: + buf.eofCheck(i + 8) + + var encodingRecord: EncodingRecord + encodingRecord.platformID = buf.readUint16(i + 0).swap() + encodingRecord.encodingID = buf.readUint16(i + 2).swap() + encodingRecord.offset = buf.readUint32(i + 4).swap() + i += 8 + + if encodingRecord.platformID == 3: + # Windows + var i = offset + encodingRecord.offset.int + buf.eofCheck(i + 2) + + let format = buf.readUint16(i + 0).swap() + if format == 4: + type Format4 = object + format: uint16 + length: uint16 + language: uint16 + segCountX2: uint16 + searchRange: uint16 + entrySelector: uint16 + rangeShift: uint16 + endCodes: seq[uint16] + reservedPad: uint16 + startCodes: seq[uint16] + idDeltas: seq[uint16] + idRangeOffsets: seq[uint16] + + buf.eofCheck(i + 14) + + var subTable: Format4 + subTable.format = format + subTable.length = buf.readUint16(i + 2).swap() + subTable.language = buf.readUint16(i + 4).swap() + subTable.segCountX2 = buf.readUint16(i + 6).swap() + let segCount = (subtable.segCountX2 div 2).int + subTable.searchRange = buf.readUint16(i + 8).swap() + subTable.entrySelector = buf.readUint16(i + 10).swap() + subTable.rangeShift = buf.readUint16(i + 12).swap() + i += 14 + + buf.eofCheck(i + 2 + 4 * segCount * 2) + + subTable.endCodes = buf.readUint16Seq(i, segCount) + i += segCount * 2 + subTable.reservedPad = buf.readUint16(i + 0).swap() + i += 2 + subTable.startCodes = buf.readUint16Seq(i, segCount) + i += segCount * 2 + subTable.idDeltas = buf.readUint16Seq(i, segCount) + i += segCount * 2 + let idRangeOffsetPos = i + subTable.idRangeOffsets = buf.readUint16Seq(i, segCount) + i += segCount * 2 + + for k in 0 ..< segCount: + let + endCode = subTable.endCodes[k] + startCode = subTable.startCodes[k] + idDelta = subTable.idDeltas[k].int + idRangeOffset = subTable.idRangeOffsets[k].int + for c in startCode .. endCode: + var glyphId: int + if idRangeOffset != 0: + var glyphIdOffset = idRangeOffsetPos + k * 2 + glyphIdOffset += idRangeOffset + glyphIdOffset += (c - startCode).int * 2 + buf.eofCheck(glyphIdOffset + 2) + glyphId = buf.readUint16(glyphIdOffset).swap().int + if glyphId != 0: + glyphId = (glyphId + idDelta) and 0xFFFF + else: + glyphId = (c.int + idDelta) and 0xFFFF + + if c != 65535: + result.runeToGlyphId[Rune(c)] = glyphId.uint16 + else: + # TODO implement other Windows encodingIDs + discard + else: + # TODO implement other cmap platformIDs + discard + +proc parseHeadTable(buf: string, offset: int): HeadTable = + buf.eofCheck(offset + 54) + + result = HeadTable() + result.majorVersion = buf.readUint16(offset + 0).swap() + if result.majorVersion != 1: + failUnsupported() + result.minorVersion = buf.readUint16(offset + 2).swap() + if result.minorVersion != 0: + failUnsupported() + result.fontRevision = buf.readFixed32(offset + 4) + result.checkSumAdjustment = buf.readUint32(offset + 8).swap() + result.magicNumber = buf.readUint32(offset + 12).swap() + result.flags = buf.readUint16(offset + 16).swap() + result.unitsPerEm = buf.readUint16(offset + 18).swap() + result.created = buf.readLongDateTime(offset + 20) + result.modified = buf.readLongDateTime(offset + 28) + result.xMin = buf.readInt16(offset + 36).swap() + result.yMin = buf.readInt16(offset + 38).swap() + result.xMax = buf.readInt16(offset + 40).swap() + result.yMax = buf.readInt16(offset + 42).swap() + result.macStyle = buf.readUint16(offset + 44).swap() + result.lowestRecPPEM = buf.readUint16(offset + 46).swap() + result.fontDirectionHint = buf.readInt16(offset + 48).swap() + result.indexToLocFormat = buf.readInt16(offset + 50).swap() + result.glyphDataFormat = buf.readInt16(offset + 52).swap() + if result.glyphDataFormat != 0: + failUnsupported() + +proc parseHheaTable(buf: string, offset: int): HheaTable = + buf.eofCheck(offset + 36) + + result = HheaTable() + result.majorVersion = buf.readUint16(offset + 0).swap() + if result.majorVersion != 1: + failUnsupported() + result.minorVersion = buf.readUint16(offset + 2).swap() + if result.minorVersion != 0: + failUnsupported() + result.ascender = buf.readInt16(offset + 4).swap() + result.descender = buf.readInt16(offset + 6).swap() + result.lineGap = buf.readInt16(offset + 8).swap() + result.advanceWidthMax = buf.readUint16(offset + 10).swap() + result.minLeftSideBearing = buf.readInt16(offset + 12).swap() + result.minRightSideBearing = buf.readInt16(offset + 14).swap() + result.xMaxExtent = buf.readInt16(offset + 16).swap() + result.caretSlopeRise = buf.readInt16(offset + 18).swap() + result.caretSlopeRun = buf.readInt16(offset + 20).swap() + result.caretOffset = buf.readInt16(offset + 22).swap() + discard buf.readUint16(offset + 24).swap() # Reserved, discard + discard buf.readUint16(offset + 26).swap() # Reserved, discard + discard buf.readUint16(offset + 28).swap() # Reserved, discard + discard buf.readUint16(offset + 30).swap() # Reserved, discard + result.metricDataFormat = buf.readInt16(offset + 32).swap() + if result.metricDataFormat != 0: + failUnsupported() + result.numberOfHMetrics = buf.readUint16(offset + 34).swap() + +proc parseMaxpTable(buf: string, offset: int): MaxpTable = + buf.eofCheck(offset + 32) + + result = MaxpTable() + result.version = buf.readFixed32(offset + 0) + if result.version != 1.0: + failUnsupported() + result.numGlyphs = buf.readUint16(offset + 4).swap() + result.maxPoints = buf.readUint16(offset + 6).swap() + result.maxContours = buf.readUint16(offset + 8).swap() + result.maxCompositePoints = buf.readUint16(offset + 10).swap() + result.maxCompositeContours = buf.readUint16(offset + 12).swap() + result.maxZones = buf.readUint16(offset + 14).swap() + result.maxTwilightPoints = buf.readUint16(offset + 16).swap() + result.maxStorage = buf.readUint16(offset + 18).swap() + result.maxFunctionDefs = buf.readUint16(offset + 20).swap() + result.maxInstructionDefs = buf.readUint16(offset + 22).swap() + result.maxStackElements = buf.readUint16(offset + 24).swap() + result.maxSizeOfInstructions = buf.readUint16(offset + 26).swap() + result.maxComponentElements = buf.readUint16(offset + 28).swap() + result.maxComponentDepth = buf.readUint16(offset + 30).swap() + +proc parseHmtxTable( + buf: string, offset: int, hhea: HheaTable, maxp: MaxpTable +): HmtxTable = + var i = offset + + let + hMetricsSize = hhea.numberOfHMetrics.int * 4 + leftSideBearingsSize = (maxp.numGlyphs - hhea.numberOfHMetrics).int * 2 + + buf.eofCheck(i + hMetricsSize + leftSideBearingsSize) + + result = HmtxTable() + for glyph in 0 ..< maxp.numGlyphs.int: + if glyph < hhea.numberOfHMetrics.int: + var record = LongHorMetricRecord() + record.advanceWidth = buf.readUint16(i + 0).swap() + record.leftSideBearing = buf.readInt16(i + 2).swap() + result.hMetrics.add(record) + i += 4 + else: + result.leftSideBearings.add(buf.readInt16(i).swap()) + i += 2 + +proc parseNameTable(buf: string, offset: int): NameTable = + var i = offset + + buf.eofCheck(i + 6) + + result = NameTable() + result.format = buf.readUint16(i + 0).swap() + if result.format != 0: + failUnsupported() + result.count = buf.readUint16(i + 2).swap() + result.stringOffset = buf.readUint16(i + 4).swap() + + i += 6 + + buf.eofCheck(i + result.count.int * 12) + + for j in 0 ..< result.count.int: + var record: NameRecord + record.platformID = buf.readUint16(i + 0).swap() + record.encodingID = buf.readUint16(i + 2).swap() + record.languageID = buf.readUint16(i + 4).swap() + record.nameID = buf.readUint16(i + 6).swap() + record.length = buf.readUint16(i + 8).swap() + record.offset = buf.readUint16(i + 10).swap() + i += 12 + +proc parseOS2Table(buf: string, offset: int): OS2Table = + var i = offset + + buf.eofCheck(i + 78) + + result = OS2Table() + result.version = buf.readUint16(i + 0).swap() + result.xAvgCharWidth = buf.readInt16(i + 2).swap() + result.usWeightClass = buf.readUint16(i + 4).swap() + result.usWidthClass = buf.readUint16(i + 6).swap() + result.fsType = buf.readUint16(i + 8).swap() + result.ySubscriptXSize = buf.readInt16(i + 10).swap() + result.ySubscriptYSize = buf.readInt16(i + 12).swap() + result.ySubscriptXOffset = buf.readInt16(i + 14).swap() + result.ySubscriptYOffset = buf.readInt16(i + 16).swap() + result.ySuperscriptXSize = buf.readInt16(i + 18).swap() + result.ySuperscriptYSize = buf.readInt16(i + 20).swap() + result.ySuperscriptXOffset = buf.readInt16(i + 22).swap() + result.ySuperscriptYOffset = buf.readInt16(i + 24).swap() + result.yStrikeoutSize = buf.readInt16(i + 26).swap() + result.yStrikeoutPosition = buf.readInt16(i + 28).swap() + result.sFamilyClass = buf.readInt16(i + 30).swap() + i += 32 + for i in 0 ..< 10: + result.panose[i] = buf.readUint8(i + i) + i += 10 + result.ulUnicodeRange1 = buf.readUint32(i + 0).swap() + result.ulUnicodeRange2 = buf.readUint32(i + 4).swap() + result.ulUnicodeRange3 = buf.readUint32(i + 8).swap() + result.ulUnicodeRange4 = buf.readUint32(i + 12).swap() + result.achVendID = buf.readStr(i + 16, 4) + result.fsSelection = buf.readUint16(i + 20).swap() + result.usFirstCharIndex = buf.readUint16(i + 22).swap() + result.usLastCharIndex = buf.readUint16(i + 24).swap() + result.sTypoAscender = buf.readInt16(i + 26).swap() + result.sTypoDescender = buf.readInt16(i + 28).swap() + result.sTypoLineGap = buf.readInt16(i + 30).swap() + result.usWinAscent = buf.readUint16(i + 32).swap() + result.usWinDescent = buf.readUint16(i + 34).swap() + i += 36 + + if result.version >= 1.uint16: + buf.eofCheck(i + 8) + result.ulCodePageRange1 = buf.readUint32(i + 0).swap() + result.ulCodePageRange2 = buf.readUint32(i + 4).swap() + i += 8 + + if result.version >= 2.uint16: + buf.eofCheck(i + 10) + result.sxHeight = buf.readInt16(i + 0).swap() + result.sCapHeight = buf.readInt16(i + 2).swap() + result.usDefaultChar = buf.readUint16(i + 4).swap() + result.usBreakChar = buf.readUint16(i + 6).swap() + result.usMaxContext = buf.readUint16(i + 8).swap() + i += 10 + + if result.version >= 5.uint16: + buf.eofCheck(i + 4) + result.usLowerOpticalPointSize = buf.readUint16(i + 0).swap() + result.usUpperOpticalPointSize = buf.readUint16(i + 2).swap() + i += 4 + +proc parseLocaTable( + buf: string, offset: int, head: HeadTable, maxp: MaxpTable +): LocaTable = + var i = offset + + result = LocaTable() + if head.indexToLocFormat == 0: + # uint16 + buf.eofCheck(i + maxp.numGlyphs.int * 2) + for _ in 0 ..< maxp.numGlyphs.int: + result.offsets.add(buf.readUint16(i).swap().uint32 * 2) + i += 2 + else: + # uint32 + buf.eofCheck(i + maxp.numGlyphs.int * 4) + for _ in 0 ..< maxp.numGlyphs.int: + result.offsets.add(buf.readUint32(i).swap()) + i += 4 + +proc parseGlyfTable(buf: string, offset: int, loca: LocaTable): GlyfTable = + result = GlyfTable() + result.offsets.setLen(loca.offsets.len) + for glyphId in 0 ..< loca.offsets.len: + result.offsets[glyphId] = offset.uint32 + loca.offsets[glyphId] + +proc getGlyphId*(opentype: OpenType, rune: Rune): int = + if rune in opentype.cmap.runeToGlyphId: + result = opentype.cmap.runeToGlyphId[rune].int + else: + discard # Index 0 is the "missing character" glyph + +proc parseGlyph(opentype: OpenType, glyphId: int): Path + +proc parseGlyphPath(buf: string, offset, numberOfContours: int): Path = + if numberOfContours < 0: + raise newException(PixieError, "Glyph numberOfContours must be >= 0") + if numberOfContours == 0: + return + + var i = offset + + buf.eofCheck(i + 2 * numberOfContours + 2) + + let endPtsOfContours = buf.readUint16Seq(i, numberOfContours) + i += 2 * numberOfContours + + let instructionLength = buf.readUint16(i + 0).swap().int + i += 2 + + buf.eofCheck(instructionLength) + + # let instructions = buf.readUint8Seq(i, instructionLength) + i += instructionLength + + let + numPoints = endPtsOfContours[^1].int + 1 + flags = block: + var + flags: seq[uint8] + point = 0 + while point < numPoints: + buf.eofCheck(i + 1) + let flag = buf.readUint8(i) + flags.add(flag) + i += 1 + point += 1 + + if (flag and 0b1000) != 0: # REPEAT_FLAG + buf.eofCheck(i + 1) + let repeatCount = buf.readUint8(i).int + i += 1 + for j in 0 ..< repeatCount: + flags.add(flag) + point += 1 + flags + + type TtfCoordinate = object + x*: float32 + y*: float32 + isOnCurve*: bool + + var points = newSeq[TtfCoordinate](numPoints) + + var prevX = 0 + for point, flag in flags: + var x: int + if (flag and 0b10) != 0: + buf.eofCheck(i + 1) + x = buf.readUint8(i).int + i += 1 + if (flag and 0b10000) == 0: + x = -x + else: + if (flag and 0b10000) != 0: + x = 0 + else: + buf.eofCheck(i + 2) + x = buf.readInt16(i).swap().int + i += 2 + prevX += x + points[point].x = prevX.float32 + points[point].isOnCurve = (flag and 1) != 0 + + var prevY = 0 + for point, flag in flags: + var y: int + if (flag and 0b100) != 0: + buf.eofCheck(i + 1) + y = buf.readUint8(i).int + i += 1 + if (flag and 0b100000) == 0: + y = -y + else: + if (flag and 0b100000) != 0: + y = 0 + else: + buf.eofCheck(i + 2) + y = buf.readInt16(i).swap().int + i += 2 + prevY += y + points[point].y = prevY.float32 + + var + contours: seq[seq[TtfCoordinate]] + startIdx = 0 + for endIdx in endPtsOfContours: + contours.add(points[startIdx .. endIdx.int]) + startIdx = endIdx.int + 1 + + for contour in contours: + var prev, curr, next: TtfCoordinate + curr = contour[^1] + next = contour[0] + + if curr.isOnCurve: + result.moveTo(curr.x, curr.y) + else: + if next.isOnCurve: + result.moveTo(next.x, next.y) + else: + result.moveTo((curr.x + next.x) / 2, (curr.y + next.y) / 2) + + for point in 0 ..< contour.len: + prev = curr + curr = next + next = contour[(point + 1) mod contour.len] + + if curr.isOnCurve: + result.lineTo(curr.x, curr.y) + else: + var next2 = next + if not next.isOnCurve: + next2 = TtfCoordinate( + x: (curr.x + next.x) / 2, + y: (curr.y + next.y) / 2 + ) + + result.quadraticCurveTo(curr.x, curr.y, next2.x, next2.y) + + result.closePath() + +proc parseCompositeGlyph(opentype: OpenType, offset: int): Path = + var + i = offset + moreComponents = true + while moreComponents: + opentype.buf.eofCheck(i + 4) + + let flags = opentype.buf.readUint16(i + 0).swap() + + i += 2 + + type TtfComponent = object + glyphId: uint16 + xScale: float32 + scale01: float32 + scale10: float32 + yScale: float32 + dx: float32 + dy: float32 + matchedPoints: array[2, int] + + var component = TtfComponent() + component.glyphId = opentype.buf.readUint16(i + 0).swap() + component.xScale = 1 + component.yScale = 1 + + i += 2 + + if (flags and 1) != 0: # The arguments are uint16 + opentype.buf.eofCheck(i + 4) + if (flags and 0b10) != 0: # The arguments are offets + component.dx = opentype.buf.readInt16(i + 0).swap().float32 + component.dy = opentype.buf.readInt16(i + 2).swap().float32 + else: # The arguments are matched points + component.matchedPoints = [ + opentype.buf.readUint16(i + 0).swap().int, + opentype.buf.readUint16(i + 2).swap().int + ] + i += 4 + else: # The arguments are uint8 + opentype.buf.eofCheck(i + 2) + if (flags and 0b10) != 0: # Arguments are offsets + component.dx = opentype.buf.readInt8(i + 0).float32 + component.dy = opentype.buf.readInt8(i + 1).float32 + else: # The arguments are matched points + component.matchedPoints = [ + opentype.buf.readInt8(i + 0).int, + opentype.buf.readInt8(i + 1).int + ] + i += 2 + + # TODO: ROUND_XY_TO_GRID + + if (flags and 0b1000) != 0: # WE_HAVE_A_SCALE + opentype.buf.eofCheck(i + 2) + component.xScale = opentype.buf.readFixed16(i + 0) + component.yScale = component.xScale + i += 2 + elif (flags and 0b1000000) != 0: # WE_HAVE_AN_X_AND_Y_SCALE + opentype.buf.eofCheck(i + 4) + component.xScale = opentype.buf.readFixed16(i + 0) + component.yScale = opentype.buf.readFixed16(i + 2) + i += 4 + elif (flags and 0b10000000) != 0: # WE_HAVE_A_TWO_BY_TWO + opentype.buf.eofCheck(i + 8) + component.xScale = opentype.buf.readFixed16(i + 0) + component.scale10 = opentype.buf.readFixed16(i + 2) + component.scale01 = opentype.buf.readFixed16(i + 4) + component.yScale = opentype.buf.readFixed16(i + 6) + i += 8 + + # if (flags and 0b100000000) != 0: # WE_HAVE_INSTRUCTIONS + # discard + # elif (flags and 0b1000000000) != 0: # USE_MY_METRICS + # discard + # elif (flags and 0b10000000000) != 0: # OVERLAP_COMPOUND + # discard + # elif (flags and 0b100000000000) != 0: # SCALED_COMPONENT_OFFSET + # discard + # elif (flags and 0b1000000000000) != 0: # UNSCALED_COMPONENT_OFFSET + # discard + + var subPath = opentype.parseGlyph(component.glyphId.int) + subPath.transform(mat3( + component.xScale, component.scale10, 0.0, + component.scale01, component.yScale, 0.0, + component.dx, component.dy, 1.0 + )) + + result.commands.add(subPath.commands) + + moreComponents = (flags and 0b100000) != 0 + +proc parseGlyph(opentype: OpenType, glyphId: int): Path = + if glyphId < 0 or glyphId >= opentype.glyf.offsets.len: + raise newException(PixieError, "Invalid glyph ID " & $glyphId) + + let glyphOffset = opentype.glyf.offsets[glyphId] + + if glyphId.int + 1 < opentype.glyf.offsets.len and + glyphOffset == opentype.glyf.offsets[glyphId + 1]: + # Empty glyph + return + + var i = glyphOffset.int + opentype.buf.eofCheck(i + 10) + + let + numberOfContours = opentype.buf.readInt16(i + 0).swap().int + # xMin = opentype.buf.readInt16(i + 2).swap() + # yMin = opentype.buf.readInt16(i + 4).swap() + # xMax = opentype.buf.readInt16(i + 6).swap() + # yMax = opentype.buf.readInt16(i + 8).swap() + + i += 10 + + if numberOfContours < 0: + opentype.parseCompositeGlyph(i) + else: + parseGlyphPath(opentype.buf, i, numberOfContours) + +proc parseGlyph*(opentype: OpenType, rune: Rune): Path = + opentype.parseGlyph(opentype.getGlyphId(rune)) + +proc parseOpenType*(buf: string): OpenType = + result = OpenType() + result.buf = buf + + var i: int + + buf.eofCheck(i + 12) + + result.version = buf.readUint32(i + 0).swap() + result.numTables = buf.readUint16(i + 4).swap() + result.searchRange = buf.readUint16(i + 6).swap() + result.entrySelector = buf.readUint16(i + 8).swap() + result.rangeShift = buf.readUint16(i + 10).swap() + + i += 12 + + buf.eofCheck(i + result.numTables.int * 16) + + for j in 0 ..< result.numTables.int: + var tableRecord: TableRecord + tableRecord.tag = buf.readStr(i + 0, 4) + tableRecord.checksum = buf.readUint32(i + 4).swap() + tableRecord.offset = buf.readUint32(i + 8).swap() + tableRecord.length = buf.readUint32(i + 12).swap() + result.tableRecords[tableRecord.tag] = tableRecord + i += 16 + + const requiredTables = [ + "cmap", "head", "hhea", "hmtx", "maxp", "name", "OS/2", "loca", "glyf" + ] + for table in requiredTables: + if table notin result.tableRecords: + raise newException(PixieError, "Missing required font table " & table) + + result.cmap = parseCmapTable(buf, result.tableRecords["cmap"].offset.int) + result.head = parseHeadTable(buf, result.tableRecords["head"].offset.int) + result.hhea = parseHheaTable(buf, result.tableRecords["hhea"].offset.int) + result.maxp = parseMaxpTable(buf, result.tableRecords["maxp"].offset.int) + result.hmtx = parseHmtxTable( + buf, result.tableRecords["hmtx"].offset.int, result.hhea, result.maxp + ) + result.name = parseNameTable(buf, result.tableRecords["name"].offset.int) + result.os2 = parseOS2Table(buf, result.tableRecords["OS/2"].offset.int) + result.loca = parseLocaTable( + buf, result.tableRecords["loca"].offset.int, result.head, result.maxp + ) + result.glyf = parseGlyfTable( + buf, result.tableRecords["glyf"].offset.int, result.loca + ) diff --git a/src/pixie/fonts.nim b/src/pixie/fonts.nim new file mode 100644 index 0000000..af56b89 --- /dev/null +++ b/src/pixie/fonts.nim @@ -0,0 +1,133 @@ +import pixie/fontformats/opentype, pixie/paths, unicode, vmath + +const AutoLineHeight* = -1.float32 + +type + Font* = ref object + opentype: OpenType + glyphPaths: Table[Rune, Path] + size*: float32 ## Font size in pixels. + lineHeight*: float32 ## The line height in pixels or AutoLineHeight for the font's default line height. + + HAlignMode* = enum + haLeft + haCenter + haRight + + VAlignMode* = enum + vaTop + vaMiddle + vaBottom + + TextCase* = enum + tcNormal + tcUpper + tcLower + tcTitle + # tcSmallCaps + # tcSmallCapsForced + +proc ascent*(font: Font): float32 {.inline.} = + ## The font ascender value in font units. + font.opentype.hhea.ascender.float32 + +proc descent*(font: Font): float32 {.inline.} = + ## The font descender value in font units. + font.opentype.hhea.descender.float32 + +proc lineGap*(font: Font): float32 {.inline.} = + ## The font line gap value in font units. + font.opentype.hhea.lineGap.float32 + +proc getGlyphPath*(font: Font, rune: Rune): Path = + if rune notin font.glyphPaths: + font.glyphPaths[rune] = font.opentype.parseGlyph(rune) + font.glyphPaths[rune].transform(scale(vec2(1, -1))) + font.glyphPaths[rune] + +proc getGlyphAdvance*(font: Font, rune: Rune): float32 = + let glyphId = font.opentype.getGlyphId(rune) + if glyphId < font.opentype.hmtx.hMetrics.len: + font.opentype.hmtx.hMetrics[glyphId].advanceWidth.float32 + else: + font.opentype.hmtx.hMetrics[^1].advanceWidth.float32 + +proc scale*(font: Font): float32 = + ## The scale factor to transform font units into pixels. + font.size / font.opentype.head.unitsPerEm.float32 + +proc defaultLineHeight*(font: Font): float32 = + ## The default line height in pixels for the current font size. + round((font.ascent + abs(font.descent) + font.lineGap) * font.scale) + +proc convertTextCase(runes: var seq[Rune], textCase: TextCase) = + case textCase: + of tcNormal: + discard + of tcUpper: + for rune in runes.mitems: + rune = rune.toUpper() + of tcLower: + for rune in runes.mitems: + rune = rune.toLower() + of tcTitle: + for rune in runes.mitems: + rune = rune.toTitle() + +proc canWrap(rune: Rune): bool = + rune == Rune(32) or rune.isWhiteSpace() + +proc typeset*( + font: Font, + text: string, + bounds = vec2(0, 0), + hAlign = haLeft, + vAlign = vaTop, + textCase = tcNormal +): seq[Path] = + var runes = toRunes(text) + runes.convertTextCase(textCase) + + let lineHeight = + if font.lineheight >= 0: + font.lineheight + else: + font.defaultLineHeight + + var + positions = newSeq[Vec2](runes.len) + at: Vec2 + prevCanWrap: int + at.y = round(font.ascent * font.scale) + at.y += (lineheight - font.defaultLineHeight) / 2 + for i, rune in runes: + if rune.canWrap(): + prevCanWrap = i + + let advance = font.getGlyphAdvance(rune) * font.scale + if bounds.x > 0 and at.x + advance > bounds.x: # Wrap to new line + at.x = 0 + at.y += lineHeight + + # Go back and wrap glyphs after the wrap index down to the next line + if prevCanWrap > 0 and prevCanWrap != i: + for j in prevCanWrap + 1 ..< i: + positions[j] = at + at.x += font.getGlyphAdvance(runes[j]) * font.scale + + positions[i] = at + at.x += advance + + for i, rune in runes: + var path = font.getGlyphPath(rune) + path.transform(translate(positions[i]) * scale(vec2(font.scale))) + result.add(path) + +proc parseOtf*(buf: string): Font = + result = Font() + result.opentype = parseOpenType(buf) + result.size = 12 + result.lineHeight = AutoLineHeight + +proc parseTtf*(buf: string): Font = + parseOtf(buf) diff --git a/tests/fonts/Aclonica-Regular.ttf b/tests/fonts/Aclonica-Regular.ttf new file mode 100644 index 0000000..bbe191d Binary files /dev/null and b/tests/fonts/Aclonica-Regular.ttf differ diff --git a/tests/fonts/Roboto-Regular.ttf b/tests/fonts/Roboto-Regular.ttf new file mode 100644 index 0000000..2c32a98 Binary files /dev/null and b/tests/fonts/Roboto-Regular.ttf differ diff --git a/tests/fonts/Ubuntu-Regular.ttf b/tests/fonts/Ubuntu-Regular.ttf new file mode 100644 index 0000000..2001d6e Binary files /dev/null and b/tests/fonts/Ubuntu-Regular.ttf differ diff --git a/tests/fonts/diffs/basic1.png b/tests/fonts/diffs/basic1.png new file mode 100644 index 0000000..166be27 Binary files /dev/null and b/tests/fonts/diffs/basic1.png differ diff --git a/tests/fonts/diffs/basic2.png b/tests/fonts/diffs/basic2.png new file mode 100644 index 0000000..156d305 Binary files /dev/null and b/tests/fonts/diffs/basic2.png differ diff --git a/tests/fonts/diffs/basic3.png b/tests/fonts/diffs/basic3.png new file mode 100644 index 0000000..591b834 Binary files /dev/null and b/tests/fonts/diffs/basic3.png differ diff --git a/tests/fonts/diffs/basic4.png b/tests/fonts/diffs/basic4.png new file mode 100644 index 0000000..0677ddc Binary files /dev/null and b/tests/fonts/diffs/basic4.png differ diff --git a/tests/fonts/diffs/basic5.png b/tests/fonts/diffs/basic5.png new file mode 100644 index 0000000..8559094 Binary files /dev/null and b/tests/fonts/diffs/basic5.png differ diff --git a/tests/fonts/diffs/basic6.png b/tests/fonts/diffs/basic6.png new file mode 100644 index 0000000..88724da Binary files /dev/null and b/tests/fonts/diffs/basic6.png differ diff --git a/tests/fonts/diffs/basic7.png b/tests/fonts/diffs/basic7.png new file mode 100644 index 0000000..1d66db5 Binary files /dev/null and b/tests/fonts/diffs/basic7.png differ diff --git a/tests/fonts/diffs/basic8.png b/tests/fonts/diffs/basic8.png new file mode 100644 index 0000000..87b0733 Binary files /dev/null and b/tests/fonts/diffs/basic8.png differ diff --git a/tests/fonts/diffs/basic9.png b/tests/fonts/diffs/basic9.png new file mode 100644 index 0000000..351f6f5 Binary files /dev/null and b/tests/fonts/diffs/basic9.png differ diff --git a/tests/fonts/masters/basic1.png b/tests/fonts/masters/basic1.png new file mode 100644 index 0000000..b5a34a7 Binary files /dev/null and b/tests/fonts/masters/basic1.png differ diff --git a/tests/fonts/masters/basic2.png b/tests/fonts/masters/basic2.png new file mode 100644 index 0000000..2510d23 Binary files /dev/null and b/tests/fonts/masters/basic2.png differ diff --git a/tests/fonts/masters/basic3.png b/tests/fonts/masters/basic3.png new file mode 100644 index 0000000..db8492d Binary files /dev/null and b/tests/fonts/masters/basic3.png differ diff --git a/tests/fonts/masters/basic4.png b/tests/fonts/masters/basic4.png new file mode 100644 index 0000000..c1111f7 Binary files /dev/null and b/tests/fonts/masters/basic4.png differ diff --git a/tests/fonts/masters/basic5.png b/tests/fonts/masters/basic5.png new file mode 100644 index 0000000..0b3a14a Binary files /dev/null and b/tests/fonts/masters/basic5.png differ diff --git a/tests/fonts/masters/basic6.png b/tests/fonts/masters/basic6.png new file mode 100644 index 0000000..9337c98 Binary files /dev/null and b/tests/fonts/masters/basic6.png differ diff --git a/tests/fonts/masters/basic7.png b/tests/fonts/masters/basic7.png new file mode 100644 index 0000000..c652d44 Binary files /dev/null and b/tests/fonts/masters/basic7.png differ diff --git a/tests/fonts/masters/basic8.png b/tests/fonts/masters/basic8.png new file mode 100644 index 0000000..3823985 Binary files /dev/null and b/tests/fonts/masters/basic8.png differ diff --git a/tests/fonts/masters/basic9.png b/tests/fonts/masters/basic9.png new file mode 100644 index 0000000..eb8b15b Binary files /dev/null and b/tests/fonts/masters/basic9.png differ diff --git a/tests/fonts/rendered/basic1.png b/tests/fonts/rendered/basic1.png new file mode 100644 index 0000000..4230703 Binary files /dev/null and b/tests/fonts/rendered/basic1.png differ diff --git a/tests/fonts/rendered/basic2.png b/tests/fonts/rendered/basic2.png new file mode 100644 index 0000000..63dc474 Binary files /dev/null and b/tests/fonts/rendered/basic2.png differ diff --git a/tests/fonts/rendered/basic3.png b/tests/fonts/rendered/basic3.png new file mode 100644 index 0000000..e6483d7 Binary files /dev/null and b/tests/fonts/rendered/basic3.png differ diff --git a/tests/fonts/rendered/basic4.png b/tests/fonts/rendered/basic4.png new file mode 100644 index 0000000..bda0122 Binary files /dev/null and b/tests/fonts/rendered/basic4.png differ diff --git a/tests/fonts/rendered/basic5.png b/tests/fonts/rendered/basic5.png new file mode 100644 index 0000000..93728bb Binary files /dev/null and b/tests/fonts/rendered/basic5.png differ diff --git a/tests/fonts/rendered/basic6.png b/tests/fonts/rendered/basic6.png new file mode 100644 index 0000000..51e6a5f Binary files /dev/null and b/tests/fonts/rendered/basic6.png differ diff --git a/tests/fonts/rendered/basic7.png b/tests/fonts/rendered/basic7.png new file mode 100644 index 0000000..32ed6e4 Binary files /dev/null and b/tests/fonts/rendered/basic7.png differ diff --git a/tests/fonts/rendered/basic8.png b/tests/fonts/rendered/basic8.png new file mode 100644 index 0000000..a8755f4 Binary files /dev/null and b/tests/fonts/rendered/basic8.png differ diff --git a/tests/fonts/rendered/basic9.png b/tests/fonts/rendered/basic9.png new file mode 100644 index 0000000..82b465c Binary files /dev/null and b/tests/fonts/rendered/basic9.png differ diff --git a/tests/test_fonts.nim b/tests/test_fonts.nim new file mode 100644 index 0000000..ec099db --- /dev/null +++ b/tests/test_fonts.nim @@ -0,0 +1,154 @@ +import pixie, strformat + +proc doDiff(rendered: Image, name: string) = + let + master = readImage(&"tests/fonts/masters/{name}.png") + (_, diffImage) = diff(master, rendered) + rendered.writeFile(&"tests/fonts/rendered/{name}.png") + diffImage.writeFile(&"tests/fonts/diffs/{name}.png") + +block: + let font = readFont("tests/fonts/Roboto-Regular.ttf") + font.size = 72 + + let + image = newImage(200, 100) + layout = font.typeset("asdf") + + image.fill(rgba(255, 255, 255, 255)) + + for path in layout: + image.fillPath(path, rgba(0, 0, 0, 255)) + + doDiff(image, "basic1") + +block: + let font = readFont("tests/fonts/Roboto-Regular.ttf") + font.size = 72 + + let + image = newImage(200, 100) + layout = font.typeset("A cow") + + image.fill(rgba(255, 255, 255, 255)) + + for path in layout: + image.fillPath(path, rgba(0, 0, 0, 255)) + + doDiff(image, "basic2") + +block: + let font = readFont("tests/fonts/Roboto-Regular.ttf") + font.size = 24 + + let + image = newImage(200, 100) + layout = font.typeset("A bit of text HERE") + + image.fill(rgba(255, 255, 255, 255)) + + for path in layout: + image.fillPath(path, rgba(0, 0, 0, 255)) + + doDiff(image, "basic3") + +block: + let font = readFont("tests/fonts/Roboto-Regular.ttf") + font.size = 24 + font.lineHeight = 100 + + let + image = newImage(200, 100) + layout = font.typeset("Line height") + + image.fill(rgba(255, 255, 255, 255)) + + for path in layout: + image.fillPath(path, rgba(0, 0, 0, 255)) + + doDiff(image, "basic4") + +block: + let font = readFont("tests/fonts/Ubuntu-Regular.ttf") + font.size = 24 + + let + image = newImage(200, 100) + layout = font.typeset("Another font") + + image.fill(rgba(255, 255, 255, 255)) + + for path in layout: + image.fillPath(path, rgba(0, 0, 0, 255)) + + doDiff(image, "basic5") + +block: + let font = readFont("tests/fonts/Aclonica-Regular.ttf") + font.size = 24 + + let + image = newImage(200, 100) + layout = font.typeset("Different font") + + image.fill(rgba(255, 255, 255, 255)) + + for path in layout: + image.fillPath(path, rgba(0, 0, 0, 255)) + + doDiff(image, "basic6") + +block: + let font = readFont("tests/fonts/Roboto-Regular.ttf") + font.size = 24 + + let + image = newImage(200, 100) + layout1 = font.typeset("First line") + layout2 = font.typeset("Second line") + + image.fill(rgba(255, 255, 255, 255)) + + for path in layout1: + image.fillPath(path, rgba(0, 0, 0, 255)) + + for path in layout2: + image.fillPath(path, rgba(0, 0, 0, 255), vec2(0, font.defaultLineHeight)) + + doDiff(image, "basic7") + +block: + let font = readFont("tests/fonts/Roboto-Regular.ttf") + font.size = 24 + + let + image = newImage(200, 100) + layout = font.typeset( + "Wrapping text to new line", + bounds = vec2(200, 0) + ) + + image.fill(rgba(255, 255, 255, 255)) + + for path in layout: + image.fillPath(path, rgba(0, 0, 0, 255)) + + doDiff(image, "basic8") + +block: + let font = readFont("tests/fonts/Roboto-Regular.ttf") + font.size = 24 + + let + image = newImage(200, 100) + layout = font.typeset( + "Supercalifragilisticexpialidocious", + bounds = vec2(200, 0) + ) + + image.fill(rgba(255, 255, 255, 255)) + + for path in layout: + image.fillPath(path, rgba(0, 0, 0, 255)) + + doDiff(image, "basic9")