Files
root 07aa33d30e feat(image): preserve transparency for alpha images end to end
Skip WebP re-encoding for PNG/AVIF/BMP uploads and any bitmap that
actually contains alpha, tag transparency-capable canvas uploads with
the transparent-image tone, and detect alpha in merged layers so the
composed node keeps a transparent tone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 01:54:32 +08:00

821 lines
25 KiB
Go

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)
}