Initial commit: img-infinite-canvas AI design workbench MVP
Moteva-style AI design workbench replica. Home prompt creates a project; the project page provides an infinite canvas with node drag, zoom/pan, a design chat panel, history replay, image asset upload, and project save/regenerate. - Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage; sky-valley/pi agent runtime adapter. - Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n, shadcn/ui components, auth (OTP/Turnstile/Google/WeChat). - Deploy: Docker Compose and k3s manifests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,801 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
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: "natural",
|
||||
Status: "success",
|
||||
}, nil
|
||||
}
|
||||
|
||||
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 := u * float64(srcWidth-1)
|
||||
fy := v * 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)
|
||||
}
|
||||
Reference in New Issue
Block a user