package application import ( "bytes" "context" "encoding/base64" "fmt" "image" "image/color" "image/png" "math" "os" "strconv" "strings" "time" "img_infinite_canvas/internal/domain/design" "golang.org/x/image/font" "golang.org/x/image/font/basicfont" "golang.org/x/image/font/gofont/gobold" "golang.org/x/image/font/gofont/goregular" "golang.org/x/image/font/opentype" "golang.org/x/image/math/fixed" ) const maxMergedLayerEdge = 4096 type layerMergeBounds struct { X float64 Y float64 Width float64 Height float64 } func (s *DesignService) MergeLayers(ctx context.Context, projectID string, nodeIDs []string, title string) (design.Project, error) { project, err := s.repo.Get(ctx, strings.TrimSpace(projectID)) if err != nil { return design.Project{}, err } selected, err := selectedNodesForLayerMerge(project.Nodes, nodeIDs) if err != nil { return design.Project{}, err } bounds := boundsForLayerMerge(selected) placeholderID := newID() threadID := newID() placeholder := newMergePlaceholderNode(placeholderID, bounds, title) selectedSet := make(map[string]struct{}, len(selected)) topSelectedIndex := -1 for _, selectedNode := range selected { selectedSet[selectedNode.ID] = struct{}{} } for index, node := range project.Nodes { if _, ok := selectedSet[node.ID]; ok { topSelectedIndex = index } } nextNodes := make([]design.Node, 0, len(project.Nodes)+1) inserted := false for index, node := range project.Nodes { nextNodes = append(nextNodes, node) if !inserted && index == topSelectedIndex { nextNodes = append(nextNodes, placeholder) inserted = true } } if !inserted { nextNodes = append(nextNodes, placeholder) } now := s.now() project.Nodes = nextNodes project.Connections = nil project.Status = design.StatusExploring project.UpdatedAt = now project.LastThreadID = threadID inputArgs := design.GeneratorTaskInputArgs{ AspectRatio: aspectRatioLabel(int64(math.Round(bounds.Width)), int64(math.Round(bounds.Height))), Image: layerMergeSourceImages(selected), Prompt: "Merge selected canvas layers into one flattened image", Resolution: resolutionLabel(fmt.Sprintf("%dx%d", int(math.Round(bounds.Width)), int(math.Round(bounds.Height)))), } project.Messages = append(project.Messages, design.Message{ ID: newID(), Role: "assistant", Type: "assistant", Title: "合并图层中", Content: "正在按画布层级把选中的图片、文字和形状合成为一张新图。", ThreadID: threadID, ActionID: threadID, StepID: newID(), Name: "canvas_action", ToolHint: "canvas_action", Status: "running", CreatedAt: now, }, generatorTaskMetadataMessage(threadID, "canvas/merge-layers", inputArgs, generatorTaskPrice(inputArgs, "canvas/merge-layers"), now.Add(time.Millisecond))) project = s.refreshCanvasSnapshot(project, now) if err := s.repo.Save(ctx, project); err != nil { return design.Project{}, err } s.startLayerMergeGeneration(project.ID, project.UserID, threadID, placeholderID, selected, title) return project, nil } func (s *DesignService) startLayerMergeGeneration(projectID string, userID string, threadID string, placeholderID string, sourceNodes []design.Node, title string) { s.enqueueOrRunJob(design.Job{ Kind: design.JobLayerMergeGeneration, UserID: userID, ProjectID: projectID, ThreadID: threadID, PlaceholderID: placeholderID, SourceNodes: sourceNodes, Title: title, }, func(ctx context.Context) error { if err := s.completeLayerMergeGeneration(ctx, projectID, threadID, placeholderID, sourceNodes, title); err != nil { return s.markLayerMergeFailed(detachedUserContext(ctx), projectID, threadID, placeholderID, sourceNodes, err) } return nil }) } func layerMergeSourceImages(nodes []design.Node) []string { images := make([]string, 0, len(nodes)) for _, node := range nodes { if node.Type == design.NodeTypeImage && strings.TrimSpace(node.Content) != "" { images = append(images, node.Content) } } return images } func (s *DesignService) completeLayerMergeGeneration(ctx context.Context, projectID string, threadID string, placeholderID string, sourceNodes []design.Node, title string) error { mergedNode, err := s.composeMergedLayer(ctx, projectID, sourceNodes, title) if err != nil { return err } mergedNode.ID = placeholderID project, err := s.repo.Get(ctx, projectID) if err != nil { return err } index := findNodeIndex(project.Nodes, placeholderID) if index < 0 { return design.ErrNotFound } now := s.now() sourceSet := make(map[string]struct{}, len(sourceNodes)) for _, sourceNode := range sourceNodes { sourceSet[sourceNode.ID] = struct{}{} } nextNodes := make([]design.Node, 0, len(project.Nodes)-len(sourceSet)) for _, node := range project.Nodes { if _, ok := sourceSet[node.ID]; ok { continue } if node.ID == placeholderID { nextNodes = append(nextNodes, mergedNode) continue } nextNodes = append(nextNodes, node) } project.Nodes = nextNodes project.Connections = nil project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusReady) project.UpdatedAt = now project.Messages = append(project.Messages, design.Message{ ID: newID(), Role: "assistant", Type: "assistant", Title: "合并图层完成", Content: "已按画布层级合并选中图层,并回写为一张新图片。", ThreadID: threadID, ActionID: threadID, StepID: newID(), Name: "canvas_action", ToolHint: "canvas_action", Status: "success", CreatedAt: now, }) project = s.refreshCanvasSnapshot(project, now) if err := s.repo.Save(ctx, project); err != nil { return err } s.enqueueAssetDeletion(deletedCanvasAssetURLs(sourceNodes, project.Nodes, project.Messages)) return nil } func (s *DesignService) markLayerMergeFailed(ctx context.Context, projectID string, threadID string, placeholderID string, sourceNodes []design.Node, failure error) error { project, err := s.repo.Get(ctx, projectID) if err != nil { return err } project.Nodes = removeNodeByID(project.Nodes, placeholderID) now := s.now() project.Status = projectStatusAfterCanvasTask(project.Nodes, design.StatusFailed) project.UpdatedAt = now project.Messages = append(project.Messages, design.Message{ ID: newID(), Role: "error", Type: "error", Title: "合并图层失败", Content: failure.Error(), ThreadID: threadID, ActionID: threadID, StepID: newID(), Name: "canvas_action", ToolHint: "canvas_action", Status: "failed", CreatedAt: now, }) project = s.refreshCanvasSnapshot(project, now) return s.repo.Save(ctx, project) } func newMergePlaceholderNode(id string, bounds layerMergeBounds, title string) design.Node { title = strings.TrimSpace(title) if title == "" { title = "Merged Layer" } return design.Node{ ID: id, Type: design.NodeTypeImage, Title: title, X: bounds.X, Y: bounds.Y, Width: bounds.Width, Height: bounds.Height, Tone: "merge-layer", Status: "generating", LayerRole: "merge-layer", } } func removeNodeByID(nodes []design.Node, nodeID string) []design.Node { next := make([]design.Node, 0, len(nodes)) for _, node := range nodes { if node.ID != nodeID { next = append(next, node) } } return next } func selectedNodesForLayerMerge(nodes []design.Node, nodeIDs []string) ([]design.Node, error) { requested := make(map[string]struct{}, len(nodeIDs)) for _, nodeID := range nodeIDs { nodeID = strings.TrimSpace(nodeID) if nodeID != "" { requested[nodeID] = struct{}{} } } if len(requested) < 2 { return nil, fmt.Errorf("%w: merge layers requires at least two layers", design.ErrInvalidInput) } selected := make([]design.Node, 0, len(requested)) for _, node := range nodes { if _, ok := requested[node.ID]; ok { selected = append(selected, node) } } if len(selected) != len(requested) { return nil, fmt.Errorf("%w: selected layer was not found", design.ErrInvalidInput) } if len(selected) < 2 { return nil, fmt.Errorf("%w: merge layers requires at least two layers", design.ErrInvalidInput) } for _, node := range selected { if node.Status == "generating" || node.Status == "error" { return nil, fmt.Errorf("%w: cannot merge unfinished layers", design.ErrInvalidInput) } switch node.Type { case design.NodeTypeImage: if strings.TrimSpace(node.Content) == "" || !isGeneratedImageContent(node.Content) { return nil, fmt.Errorf("%w: image layer has no raster content", design.ErrInvalidInput) } case design.NodeTypeFrame, design.NodeTypeText: continue default: return nil, fmt.Errorf("%w: merge layers currently supports image, text and frame layers", design.ErrInvalidInput) } } return selected, nil } func (s *DesignService) composeMergedLayer(ctx context.Context, projectID string, nodes []design.Node, title string) (design.Node, error) { bounds := boundsForLayerMerge(nodes) width, height, scale := mergedLayerOutputSize(bounds) canvas := image.NewNRGBA(image.Rect(0, 0, width, height)) for _, node := range nodes { switch node.Type { case design.NodeTypeImage: if err := drawImageLayer(ctx, canvas, node, bounds, scale); err != nil { return design.Node{}, err } case design.NodeTypeFrame: drawFrameLayer(canvas, node, bounds, scale) case design.NodeTypeText: drawTextLayer(canvas, node, bounds, scale) } } hasTransparency := mergedLayerHasTransparency(canvas) var output bytes.Buffer if err := png.Encode(&output, canvas); err != nil { return design.Node{}, err } content := "data:image/png;base64," + base64.StdEncoding.EncodeToString(output.Bytes()) if s.assets != nil { data, contentType, err := encodeRasterAssetAsWebP(output.Bytes(), "image/png") if err != nil { return design.Node{}, err } object, err := s.assets.PutObject(ctx, design.AssetObjectRequest{ FileName: fmt.Sprintf("%s-%s-merged.png", projectID, newID()), ContentType: contentType, Data: data, }) if err != nil { return design.Node{}, err } if strings.TrimSpace(object.PublicURL) != "" { content = object.PublicURL } } title = strings.TrimSpace(title) if title == "" { title = "Merged Layer" } tone := "natural" if hasTransparency { tone = "transparent-image" } return design.Node{ ID: newID(), Type: design.NodeTypeImage, Title: title, X: bounds.X, Y: bounds.Y, Width: bounds.Width, Height: bounds.Height, Content: content, Tone: tone, Status: "success", }, nil } func mergedLayerHasTransparency(img *image.NRGBA) bool { bounds := img.Bounds() for y := bounds.Min.Y; y < bounds.Max.Y; y++ { offset := img.PixOffset(bounds.Min.X, y) for x := bounds.Min.X; x < bounds.Max.X; x++ { if img.Pix[offset+3] < 255 { return true } offset += 4 } } return false } func boundsForLayerMerge(nodes []design.Node) layerMergeBounds { left := math.Inf(1) top := math.Inf(1) right := math.Inf(-1) bottom := math.Inf(-1) for _, node := range nodes { left = math.Min(left, node.X) top = math.Min(top, node.Y) right = math.Max(right, node.X+node.Width) bottom = math.Max(bottom, node.Y+node.Height) } if !isFinite(left) || !isFinite(top) || !isFinite(right) || !isFinite(bottom) || right <= left || bottom <= top { return layerMergeBounds{Width: 1, Height: 1} } return layerMergeBounds{X: left, Y: top, Width: right - left, Height: bottom - top} } func mergedLayerOutputSize(bounds layerMergeBounds) (int, int, float64) { scale := 1.0 longEdge := math.Max(bounds.Width, bounds.Height) if longEdge > maxMergedLayerEdge { scale = maxMergedLayerEdge / longEdge } width := int(math.Max(1, math.Ceil(bounds.Width*scale))) height := int(math.Max(1, math.Ceil(bounds.Height*scale))) return width, height, scale } func drawImageLayer(ctx context.Context, dst *image.NRGBA, node design.Node, bounds layerMergeBounds, scale float64) error { data, _, err := loadImageContent(ctx, node.Content) if err != nil { return err } src, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return fmt.Errorf("%w: unsupported image layer format", design.ErrInvalidInput) } drawSampledLayer(dst, src, node, bounds, scale) return nil } func drawSampledLayer(dst *image.NRGBA, src image.Image, node design.Node, bounds layerMergeBounds, scale float64) { dstX := (node.X - bounds.X) * scale dstY := (node.Y - bounds.Y) * scale dstWidth := math.Max(1, node.Width*scale) dstHeight := math.Max(1, node.Height*scale) centerX := dstX + dstWidth/2 centerY := dstY + dstHeight/2 angle := node.Rotation * math.Pi / 180 sinAngle := math.Sin(angle) cosAngle := math.Cos(angle) minX, minY, maxX, maxY := rotatedLayerPixelBounds(dst.Bounds(), dstX, dstY, dstWidth, dstHeight, centerX, centerY, sinAngle, cosAngle) opacity := normalizedNodeOpacity(node.Opacity) srcBounds := src.Bounds() srcWidth := srcBounds.Dx() srcHeight := srcBounds.Dy() for y := minY; y < maxY; y++ { for x := minX; x < maxX; x++ { localX, localY := inverseLayerPoint(float64(x)+0.5, float64(y)+0.5, centerX, centerY, sinAngle, cosAngle) localX -= dstX localY -= dstY if localX < 0 || localY < 0 || localX >= dstWidth || localY >= dstHeight { continue } u := localX / dstWidth v := localY / dstHeight if node.FlipX { u = 1 - u } if node.FlipY { v = 1 - v } blendNRGBA(dst, x, y, sampleLayerColor(src, srcBounds, srcWidth, srcHeight, u, v), opacity) } } } func sampleLayerColor(src image.Image, srcBounds image.Rectangle, srcWidth int, srcHeight int, u float64, v float64) color.NRGBA { if srcWidth <= 1 || srcHeight <= 1 { maxX := srcWidth - 1 maxY := srcHeight - 1 if maxX < 0 { maxX = 0 } if maxY < 0 { maxY = 0 } srcX := srcBounds.Min.X + clampInt(int(math.Round(u*float64(maxX))), 0, maxX) srcY := srcBounds.Min.Y + clampInt(int(math.Round(v*float64(maxY))), 0, maxY) return color.NRGBAModel.Convert(src.At(srcX, srcY)).(color.NRGBA) } u = clampFloat(u, 0, 1) v = clampFloat(v, 0, 1) fx := clampFloat(u*float64(srcWidth)-0.5, 0, float64(srcWidth-1)) fy := clampFloat(v*float64(srcHeight)-0.5, 0, float64(srcHeight-1)) x0 := int(math.Floor(fx)) y0 := int(math.Floor(fy)) x1 := clampInt(x0+1, 0, srcWidth-1) y1 := clampInt(y0+1, 0, srcHeight-1) tx := fx - float64(x0) ty := fy - float64(y0) c00 := color.NRGBAModel.Convert(src.At(srcBounds.Min.X+x0, srcBounds.Min.Y+y0)).(color.NRGBA) c10 := color.NRGBAModel.Convert(src.At(srcBounds.Min.X+x1, srcBounds.Min.Y+y0)).(color.NRGBA) c01 := color.NRGBAModel.Convert(src.At(srcBounds.Min.X+x0, srcBounds.Min.Y+y1)).(color.NRGBA) c11 := color.NRGBAModel.Convert(src.At(srcBounds.Min.X+x1, srcBounds.Min.Y+y1)).(color.NRGBA) top := lerpColor(c00, c10, tx) bottom := lerpColor(c01, c11, tx) return lerpColor(top, bottom, ty) } func lerpColor(a color.NRGBA, b color.NRGBA, t float64) color.NRGBA { return color.NRGBA{ R: uint8(math.Round(float64(a.R) + (float64(b.R)-float64(a.R))*t)), G: uint8(math.Round(float64(a.G) + (float64(b.G)-float64(a.G))*t)), B: uint8(math.Round(float64(a.B) + (float64(b.B)-float64(a.B))*t)), A: uint8(math.Round(float64(a.A) + (float64(b.A)-float64(a.A))*t)), } } func drawFrameLayer(dst *image.NRGBA, node design.Node, bounds layerMergeBounds, scale float64) { x0 := int(math.Floor((node.X - bounds.X) * scale)) y0 := int(math.Floor((node.Y - bounds.Y) * scale)) x1 := int(math.Ceil((node.X - bounds.X + node.Width) * scale)) y1 := int(math.Ceil((node.Y - bounds.Y + node.Height) * scale)) rect := dst.Bounds().Intersect(image.Rect(x0, y0, x1, y1)) if rect.Empty() { return } opacity := normalizedNodeOpacity(node.Opacity) if fill, ok := parseCanvasColor(node.FillColor); ok { fill.A = uint8(float64(fill.A) * opacity) for y := rect.Min.Y; y < rect.Max.Y; y++ { for x := rect.Min.X; x < rect.Max.X; x++ { blendNRGBA(dst, x, y, fill, 1) } } } stroke, ok := parseCanvasColor(node.StrokeColor) strokeWidth := int(math.Round(node.StrokeWidth * scale)) if !ok || strokeWidth <= 0 { return } stroke.A = uint8(float64(stroke.A) * opacity) for y := rect.Min.Y; y < rect.Max.Y; y++ { for x := rect.Min.X; x < rect.Max.X; x++ { if x < x0+strokeWidth || x >= x1-strokeWidth || y < y0+strokeWidth || y >= y1-strokeWidth { blendNRGBA(dst, x, y, stroke, 1) } } } } func drawTextLayer(dst *image.NRGBA, node design.Node, bounds layerMergeBounds, scale float64) { text := strings.TrimRight(strings.ReplaceAll(node.Content, "\r\n", "\n"), "\n") if strings.TrimSpace(text) == "" { return } width := int(math.Max(1, math.Ceil(node.Width*scale))) height := int(math.Max(1, math.Ceil(node.Height*scale))) src := image.NewNRGBA(image.Rect(0, 0, width, height)) fontSize := node.FontSize if fontSize <= 0 { fontSize = math.Max(14, math.Min(node.Height*0.68, 128)) } face := newLayerMergeTextFace(node.FontFamily, node.FontWeight, fontSize*scale) defer closeFontFace(face) textColor, ok := parseCanvasColor(node.Color) if !ok { textColor = color.NRGBA{R: 17, G: 24, B: 39, A: 255} } lineHeight := node.LineHeight if lineHeight <= 0 { lineHeight = 1.08 } lines := strings.Split(text, "\n") metrics := face.Metrics() ascent := float64(metrics.Ascent) / 64 descent := float64(metrics.Descent) / 64 lineAdvance := math.Max(ascent+descent, fontSize*scale*lineHeight) totalHeight := lineAdvance * float64(len(lines)) startY := (float64(height)-totalHeight)/2 + ascent drawer := &font.Drawer{ Dst: src, Src: image.NewUniform(textColor), Face: face, } for index, line := range lines { measure := float64(drawer.MeasureString(line)) / 64 x := 0.0 switch strings.ToLower(strings.TrimSpace(node.TextAlign)) { case "right": x = float64(width) - measure case "center", "": x = (float64(width) - measure) / 2 } y := startY + float64(index)*lineAdvance drawTextString(src, drawer, line, x, y, node) } drawSampledLayer(dst, src, node, bounds, scale) } func drawTextString(dst *image.NRGBA, drawer *font.Drawer, text string, x float64, y float64, node design.Node) { strokeWidth := int(math.Round(node.StrokeWidth)) if strokeWidth > 0 { strokeColor, ok := parseCanvasColor(node.StrokeColor) if !ok { strokeColor = color.NRGBA{A: 255} } original := drawer.Src drawer.Src = image.NewUniform(strokeColor) for oy := -strokeWidth; oy <= strokeWidth; oy++ { for ox := -strokeWidth; ox <= strokeWidth; ox++ { if ox == 0 && oy == 0 { continue } drawer.Dot = fixed.P(int(math.Round(x))+ox, int(math.Round(y))+oy) drawer.DrawString(text) } } drawer.Src = original } repeats := 1 if isBoldTextWeight(node.FontWeight) { repeats = 2 } for i := 0; i < repeats; i++ { drawer.Dot = fixed.P(int(math.Round(x))+i, int(math.Round(y))) drawer.DrawString(text) } } func newLayerMergeTextFace(fontFamily string, fontWeight string, size float64) font.Face { if size <= 0 { size = 16 } for _, path := range layerMergeFontCandidates(fontFamily) { data, err := os.ReadFile(path) if err != nil { continue } parsed, err := opentype.Parse(data) if err != nil { continue } face, err := opentype.NewFace(parsed, &opentype.FaceOptions{ Size: size, DPI: 72, Hinting: font.HintingFull, }) if err == nil { return face } } fontData := goregular.TTF if isBoldTextWeight(fontWeight) { fontData = gobold.TTF } parsed, err := opentype.Parse(fontData) if err == nil { face, err := opentype.NewFace(parsed, &opentype.FaceOptions{ Size: size, DPI: 72, Hinting: font.HintingFull, }) if err == nil { return face } } return basicfont.Face7x13 } func closeFontFace(face font.Face) { if closer, ok := face.(interface{ Close() error }); ok { _ = closer.Close() } } func layerMergeFontCandidates(fontFamily string) []string { fontFamily = strings.ToLower(fontFamily) var candidates []string if strings.Contains(fontFamily, "arial") { candidates = append(candidates, "/System/Library/Fonts/Supplemental/Arial.ttf") } if strings.Contains(fontFamily, "helvetica") { candidates = append(candidates, "/System/Library/Fonts/Supplemental/Helvetica.ttf") } candidates = append(candidates, "/System/Library/Fonts/Supplemental/Arial Unicode.ttf", "/System/Library/Fonts/Supplemental/Arial.ttf", "/System/Library/Fonts/Supplemental/Helvetica.ttf", "/Library/Fonts/Arial Unicode.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", ) return candidates } func isBoldTextWeight(weight string) bool { weight = strings.ToLower(strings.TrimSpace(weight)) if strings.Contains(weight, "bold") || strings.Contains(weight, "black") || strings.Contains(weight, "heavy") { return true } value, err := strconv.Atoi(weight) return err == nil && value >= 600 } func rotatedLayerPixelBounds(bounds image.Rectangle, x float64, y float64, width float64, height float64, centerX float64, centerY float64, sinAngle float64, cosAngle float64) (int, int, int, int) { points := [4][2]float64{ {x, y}, {x + width, y}, {x + width, y + height}, {x, y + height}, } minX := math.Inf(1) minY := math.Inf(1) maxX := math.Inf(-1) maxY := math.Inf(-1) for _, point := range points { px, py := rotateLayerPoint(point[0], point[1], centerX, centerY, sinAngle, cosAngle) minX = math.Min(minX, px) minY = math.Min(minY, py) maxX = math.Max(maxX, px) maxY = math.Max(maxY, py) } return clampInt(int(math.Floor(minX)), bounds.Min.X, bounds.Max.X), clampInt(int(math.Floor(minY)), bounds.Min.Y, bounds.Max.Y), clampInt(int(math.Ceil(maxX)), bounds.Min.X, bounds.Max.X), clampInt(int(math.Ceil(maxY)), bounds.Min.Y, bounds.Max.Y) } func rotateLayerPoint(x float64, y float64, centerX float64, centerY float64, sinAngle float64, cosAngle float64) (float64, float64) { dx := x - centerX dy := y - centerY return centerX + dx*cosAngle - dy*sinAngle, centerY + dx*sinAngle + dy*cosAngle } func inverseLayerPoint(x float64, y float64, centerX float64, centerY float64, sinAngle float64, cosAngle float64) (float64, float64) { dx := x - centerX dy := y - centerY return centerX + dx*cosAngle + dy*sinAngle, centerY - dx*sinAngle + dy*cosAngle } func blendNRGBA(dst *image.NRGBA, x int, y int, src color.NRGBA, opacity float64) { srcAlpha := (float64(src.A) / 255) * opacity if srcAlpha <= 0 { return } dstColor := dst.NRGBAAt(x, y) dstAlpha := float64(dstColor.A) / 255 outAlpha := srcAlpha + dstAlpha*(1-srcAlpha) if outAlpha <= 0 { dst.SetNRGBA(x, y, color.NRGBA{}) return } outR := (float64(src.R)*srcAlpha + float64(dstColor.R)*dstAlpha*(1-srcAlpha)) / outAlpha outG := (float64(src.G)*srcAlpha + float64(dstColor.G)*dstAlpha*(1-srcAlpha)) / outAlpha outB := (float64(src.B)*srcAlpha + float64(dstColor.B)*dstAlpha*(1-srcAlpha)) / outAlpha dst.SetNRGBA(x, y, color.NRGBA{ R: uint8(math.Round(clampFloat(outR, 0, 255))), G: uint8(math.Round(clampFloat(outG, 0, 255))), B: uint8(math.Round(clampFloat(outB, 0, 255))), A: uint8(math.Round(outAlpha * 255)), }) } func normalizedNodeOpacity(opacity float64) float64 { if opacity <= 0 || opacity > 1 { return 1 } return opacity } func parseCanvasColor(value string) (color.NRGBA, bool) { value = strings.TrimSpace(strings.ToLower(value)) if value == "" || value == "transparent" { return color.NRGBA{}, false } if strings.HasPrefix(value, "#") { return parseHexCanvasColor(strings.TrimPrefix(value, "#")) } if strings.HasPrefix(value, "rgba(") && strings.HasSuffix(value, ")") { return parseRGBACanvasColor(strings.TrimSuffix(strings.TrimPrefix(value, "rgba("), ")")) } if strings.HasPrefix(value, "rgb(") && strings.HasSuffix(value, ")") { return parseRGBCanvasColor(strings.TrimSuffix(strings.TrimPrefix(value, "rgb("), ")")) } return color.NRGBA{}, false } func parseHexCanvasColor(value string) (color.NRGBA, bool) { if len(value) == 3 { value = strings.Repeat(value[0:1], 2) + strings.Repeat(value[1:2], 2) + strings.Repeat(value[2:3], 2) } if len(value) != 6 { return color.NRGBA{}, false } parsed, err := strconv.ParseUint(value, 16, 32) if err != nil { return color.NRGBA{}, false } return color.NRGBA{R: uint8(parsed >> 16), G: uint8(parsed >> 8), B: uint8(parsed), A: 255}, true } func parseRGBCanvasColor(value string) (color.NRGBA, bool) { parts := strings.Split(value, ",") if len(parts) != 3 { return color.NRGBA{}, false } r, okR := parseCanvasColorByte(parts[0]) g, okG := parseCanvasColorByte(parts[1]) b, okB := parseCanvasColorByte(parts[2]) if !okR || !okG || !okB { return color.NRGBA{}, false } return color.NRGBA{R: r, G: g, B: b, A: 255}, true } func parseRGBACanvasColor(value string) (color.NRGBA, bool) { parts := strings.Split(value, ",") if len(parts) != 4 { return color.NRGBA{}, false } r, okR := parseCanvasColorByte(parts[0]) g, okG := parseCanvasColorByte(parts[1]) b, okB := parseCanvasColorByte(parts[2]) alpha, err := strconv.ParseFloat(strings.TrimSpace(parts[3]), 64) if !okR || !okG || !okB || err != nil { return color.NRGBA{}, false } return color.NRGBA{R: r, G: g, B: b, A: uint8(math.Round(clampFloat(alpha, 0, 1) * 255))}, true } func parseCanvasColorByte(value string) (uint8, bool) { parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) if err != nil { return 0, false } return uint8(math.Round(clampFloat(parsed, 0, 255))), true } func isFinite(value float64) bool { return !math.IsInf(value, 0) && !math.IsNaN(value) }