Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 240 additions & 0 deletions coloremoji.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
package gofpdf

import (
"fmt"
"strings"
)

// ColorEmojiRenderer handles rendering of color emoji glyphs
type ColorEmojiRenderer struct {
utf8File *utf8FontFile
unitsPerEm int
}

// NewColorEmojiRenderer creates a new color emoji renderer
func NewColorEmojiRenderer(utf8File *utf8FontFile) *ColorEmojiRenderer {
return &ColorEmojiRenderer{
utf8File: utf8File,
unitsPerEm: utf8File.GetUnitsPerEm(),
}
}

// RenderColorGlyph renders a color glyph to PDF path operators
// x, y are the position in PDF user units
// fontSize is the font size in points
// k is the scale factor (points per user unit)
func (r *ColorEmojiRenderer) RenderColorGlyph(glyphID uint16, x, y, fontSize, k float64) string {
if r.utf8File == nil || !r.utf8File.HasColorGlyphs() {
return ""
}

layers := r.utf8File.GetColorGlyphLayers(glyphID)
if layers == nil {
return ""
}

var result strings.Builder
scale := fontSize / float64(r.unitsPerEm)

// Render layers back-to-front (first layer is bottom)
for _, layer := range layers {
color := r.utf8File.GetPaletteColor(layer.PaletteIndex)
outline := r.utf8File.ParseGlyphOutline(layer.GlyphID)

if outline == nil || len(outline.Contours) == 0 {
continue
}

// Save graphics state
result.WriteString("q ")

// Set fill color (RGB normalized to 0-1)
if color.A < 255 {
// Handle transparency
alpha := float64(color.A) / 255.0
result.WriteString(fmt.Sprintf("%.3f %.3f %.3f rg ",
float64(color.R)/255.0,
float64(color.G)/255.0,
float64(color.B)/255.0))
// Note: Full alpha support requires ExtGState which is more complex
// For now we just use the RGB values
_ = alpha
} else {
result.WriteString(fmt.Sprintf("%.3f %.3f %.3f rg ",
float64(color.R)/255.0,
float64(color.G)/255.0,
float64(color.B)/255.0))
}

// Convert outline to PDF path
pathStr := glyphOutlineToPDFPath(outline, x, y, scale, k)
result.WriteString(pathStr)

// Fill the path
result.WriteString("f ")

// Restore graphics state
result.WriteString("Q ")
}

return result.String()
}

// glyphOutlineToPDFPath converts a glyph outline to PDF path operators
// x, y: position in user units
// scale: fontSize / unitsPerEm
// k: points per user unit
func glyphOutlineToPDFPath(outline *GlyphOutline, x, y, scale, k float64) string {
if outline == nil || len(outline.Contours) == 0 {
return ""
}

var result strings.Builder

for _, contour := range outline.Contours {
if len(contour) < 2 {
continue
}

pathOps := contourToPDFOps(contour, x, y, scale, k)
result.WriteString(pathOps)
}

return result.String()
}

// contourToPDFOps converts a single contour to PDF path operators
func contourToPDFOps(contour GlyphContour, baseX, baseY, scale, k float64) string {
if len(contour) < 2 {
return ""
}

var result strings.Builder

// Helper to transform coordinates
transform := func(pt GlyphPoint) (float64, float64) {
// Apply scale and flip Y for PDF coordinate system
px := baseX + pt.X*scale
py := baseY + pt.Y*scale // Y is bottom-up in PDF
// Convert to points
return px * k, py * k
}

// Find the first on-curve point or create one
startIdx := -1
for i, pt := range contour {
if pt.OnCurve {
startIdx = i
break
}
}

// If no on-curve point, start with midpoint between first two off-curve points
if startIdx == -1 {
// All points are off-curve, create implicit on-curve point
p0 := contour[0]
p1 := contour[1]
midX := (p0.X + p1.X) / 2
midY := (p0.Y + p1.Y) / 2
px, py := transform(GlyphPoint{X: midX, Y: midY, OnCurve: true})
result.WriteString(fmt.Sprintf("%.2f %.2f m ", px, py))
startIdx = 0
} else {
// Move to first on-curve point
px, py := transform(contour[startIdx])
result.WriteString(fmt.Sprintf("%.2f %.2f m ", px, py))
}

// Process remaining points
n := len(contour)
i := (startIdx + 1) % n
for count := 0; count < n; count++ {
curr := contour[i]
next := contour[(i+1)%n]

if curr.OnCurve {
// Line to on-curve point
px, py := transform(curr)
result.WriteString(fmt.Sprintf("%.2f %.2f l ", px, py))
} else {
// Quadratic bezier - we have an off-curve control point
// TrueType uses quadratic beziers, PDF uses cubic
// Convert quadratic to cubic:
// C1 = P0 + 2/3 * (P1 - P0)
// C2 = P2 + 2/3 * (P1 - P2)

// Get the previous point (start of curve)
prevIdx := (i - 1 + n) % n
prev := contour[prevIdx]

// If previous point is also off-curve, use implicit midpoint
var p0X, p0Y float64
if !prev.OnCurve {
p0X = (prev.X + curr.X) / 2
p0Y = (prev.Y + curr.Y) / 2
} else {
p0X = prev.X
p0Y = prev.Y
}

// Get the end point
var p2X, p2Y float64
if next.OnCurve {
p2X = next.X
p2Y = next.Y
} else {
// Implicit midpoint between two off-curve points
p2X = (curr.X + next.X) / 2
p2Y = (curr.Y + next.Y) / 2
}

// Control point (off-curve)
p1X := curr.X
p1Y := curr.Y

// Convert quadratic to cubic bezier control points
c1X := p0X + 2.0/3.0*(p1X-p0X)
c1Y := p0Y + 2.0/3.0*(p1Y-p0Y)
c2X := p2X + 2.0/3.0*(p1X-p2X)
c2Y := p2Y + 2.0/3.0*(p1Y-p2Y)

// Transform all points
c1px, c1py := transform(GlyphPoint{X: c1X, Y: c1Y})
c2px, c2py := transform(GlyphPoint{X: c2X, Y: c2Y})
epx, epy := transform(GlyphPoint{X: p2X, Y: p2Y})

result.WriteString(fmt.Sprintf("%.2f %.2f %.2f %.2f %.2f %.2f c ",
c1px, c1py, c2px, c2py, epx, epy))

// If next point is on-curve and not the implicit midpoint, skip it
// as we've already used it as the endpoint
if next.OnCurve {
i = (i + 1) % n
count++
}
}

i = (i + 1) % n
}

// Close the path
result.WriteString("h ")

return result.String()
}

// IsColorGlyph checks if a glyph ID has color layers
func (r *ColorEmojiRenderer) IsColorGlyph(glyphID uint16) bool {
if r.utf8File == nil || !r.utf8File.HasColorGlyphs() {
return false
}
return r.utf8File.GetColorGlyphLayers(glyphID) != nil
}

// GetGlyphLayers returns the color layers for a glyph
func (r *ColorEmojiRenderer) GetGlyphLayers(glyphID uint16) []LayerRecord {
if r.utf8File == nil {
return nil
}
return r.utf8File.GetColorGlyphLayers(glyphID)
}
147 changes: 147 additions & 0 deletions coloremoji_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package gofpdf

import (
"os"
"testing"
)

func TestCOLRCPALParsing(t *testing.T) {
// Test with a font that doesn't have COLR/CPAL tables
fontPath := "font/calligra.ttf"
fontData, err := os.ReadFile(fontPath)
if err != nil {
t.Skipf("Font file not found: %s", fontPath)
return
}

reader := fileReader{readerPosition: 0, array: fontData}
utf8File := newUTF8Font(&reader)
err = utf8File.parseFile()
if err != nil {
t.Fatalf("Failed to parse font: %v", err)
}

// Calligra should not have color glyphs
if utf8File.HasColorGlyphs() {
t.Error("Expected calligra font to not have color glyphs")
}
}

func TestGlyphOutlineParsing(t *testing.T) {
// Test glyph outline parsing with a regular font
fontPath := "font/calligra.ttf"
fontData, err := os.ReadFile(fontPath)
if err != nil {
t.Skipf("Font file not found: %s", fontPath)
return
}

reader := fileReader{readerPosition: 0, array: fontData}
utf8File := newUTF8Font(&reader)
err = utf8File.parseFile()
if err != nil {
t.Fatalf("Failed to parse font: %v", err)
}

// We need to initialize symbolPosition by calling GenerateCutFont or similar
// For now, just check that the method doesn't panic
utf8File.fileReader.readerPosition = 0
utf8File.symbolPosition = make([]int, 0)
utf8File.charSymbolDictionary = make(map[int]int)
utf8File.tableDescriptions = make(map[string]*tableDescription)
utf8File.outTablesData = make(map[string][]byte)
utf8File.skip(4)
utf8File.generateTableDescriptions()

// Parse loca table to get symbol positions
utf8File.SeekTable("head")
utf8File.skip(50)
locaFormat := utf8File.readUint16()

utf8File.SeekTable("maxp")
utf8File.skip(4)
numSymbols := utf8File.readUint16()

utf8File.parseLOCATable(locaFormat, numSymbols)

// Try to parse a glyph outline
outline := utf8File.ParseGlyphOutline(0)
if outline == nil {
t.Log("Glyph 0 has no outline (may be .notdef)")
}

// Test with a higher glyph ID that likely has an outline
outline = utf8File.ParseGlyphOutline(10)
if outline != nil && len(outline.Contours) > 0 {
t.Logf("Glyph 10 has %d contours", len(outline.Contours))
}
}

func TestColorEmojiAPI(t *testing.T) {
pdf := New("P", "mm", "A4", "font")
pdf.AddPage()
pdf.AddUTF8Font("DejaVu", "", "DejaVuSansCondensed.ttf")
pdf.SetFont("DejaVu", "", 14)

// Test the API methods exist and work
pdf.SetColorEmojiEnabled(true)
if pdf.colorEmojiEnabled != true {
t.Error("Expected colorEmojiEnabled to be true")
}

// DejaVu doesn't have color glyphs, so HasColorEmoji should return false
if pdf.HasColorEmoji() {
t.Error("Expected HasColorEmoji to return false for DejaVu font")
}

pdf.SetColorEmojiEnabled(false)
if pdf.colorEmojiEnabled != false {
t.Error("Expected colorEmojiEnabled to be false")
}
}

func TestGlyphOutlineTransforms(t *testing.T) {
// Create a simple test outline
outline := &GlyphOutline{
Bounds: [4]int16{0, 0, 100, 100},
Contours: []GlyphContour{
{
{X: 0, Y: 0, OnCurve: true},
{X: 100, Y: 0, OnCurve: true},
{X: 100, Y: 100, OnCurve: true},
{X: 0, Y: 100, OnCurve: true},
},
},
}

// Test scale
scaled := ScaleOutline(outline, 2.0)
if scaled.Contours[0][1].X != 200 {
t.Errorf("Expected scaled X to be 200, got %f", scaled.Contours[0][1].X)
}

// Test translate
translated := TranslateOutline(outline, 50, 50)
if translated.Contours[0][0].X != 50 || translated.Contours[0][0].Y != 50 {
t.Errorf("Expected translated point to be (50, 50), got (%f, %f)",
translated.Contours[0][0].X, translated.Contours[0][0].Y)
}

// Test Y flip
flipped := FlipY(outline)
if flipped.Contours[0][2].Y != -100 {
t.Errorf("Expected flipped Y to be -100, got %f", flipped.Contours[0][2].Y)
}
}

func TestColorRecordParsing(t *testing.T) {
// Test that GetPaletteColor returns a default color when no CPAL table exists
reader := fileReader{readerPosition: 0, array: []byte{}}
utf8File := newUTF8Font(&reader)

color := utf8File.GetPaletteColor(0)
if color.R != 0 || color.G != 0 || color.B != 0 || color.A != 255 {
t.Errorf("Expected default color (0,0,0,255), got (%d,%d,%d,%d)",
color.R, color.G, color.B, color.A)
}
}
Loading