07aa33d30e
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>
821 lines
25 KiB
Go
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)
|
|
}
|