package zxchainsdk import ( "bytes" "context" "errors" "fmt" "image" "image/color" "image/jpeg" "image/png" "io" "net/http" "net/url" "os" "path" "strings" "time" "github.com/fogleman/gg" "github.com/skip2/go-qrcode" xdraw "golang.org/x/image/draw" ) const ( defaultCertificateBackgroundURL = "https://popiart-public-1313913486.cos.ap-guangzhou.myqcloud.com/media/2026/0511/83034.png" defaultCertificateLogoURL = "https://popiart-public-1313913486.cos.ap-guangzhou.myqcloud.com/media/2026/0511/83035.png" defaultCertificateBucketBaseURL = "https://popi-certificate-1386008232.cos.ap-guangzhou.myqcloud.com" ) var ( ErrMissingCertificateNo = errors.New("missing certificate no") ErrMissingVerifyURL = errors.New("missing verify url") ) // HashCertificateRequest 定义 Popi Hash 存证证书生成请求。 type HashCertificateRequest struct { Title string CertNo string UserName string UserID string WorkName string TaskID string CompletedAt string EvidenceID string TxID string BlockHeight string FinalHash string EvidenceHash string TrustedAt string VerifyURL string WorkPreviewURL string WorkPreviewBytes []byte BackgroundURL string BackgroundBytes []byte LogoURL string LogoBytes []byte FontPath string HTTPClient *http.Client BucketBaseURL string } // HashCertificateResult 保存生成后的证书 PNG 和建议上传路径。 type HashCertificateResult struct { CertNo string VerifyURL string PNG []byte CertificateImageKey string CertificateImageURL string } // GenerateHashCertificate 按 Figma 模板合成 Popi Hash 存证证书 PNG。 func GenerateHashCertificate(ctx context.Context, req HashCertificateRequest) (*HashCertificateResult, error) { if req.CertNo == "" { return nil, ErrMissingCertificateNo } if req.VerifyURL == "" { return nil, ErrMissingVerifyURL } if req.Title == "" { req.Title = "数字作品区块链存证证明" } if req.BackgroundURL == "" && len(req.BackgroundBytes) == 0 { req.BackgroundURL = defaultCertificateBackgroundURL } if req.LogoURL == "" && len(req.LogoBytes) == 0 { req.LogoURL = defaultCertificateLogoURL } if req.BucketBaseURL == "" { req.BucketBaseURL = defaultCertificateBucketBaseURL } background, err := loadCertificateImage(ctx, req.HTTPClient, req.BackgroundURL, req.BackgroundBytes) if err != nil { return nil, fmt.Errorf("load certificate background: %w", err) } logo, err := loadCertificateImage(ctx, req.HTTPClient, req.LogoURL, req.LogoBytes) if err != nil { return nil, fmt.Errorf("load certificate logo: %w", err) } var workPreview image.Image if req.WorkPreviewURL != "" || len(req.WorkPreviewBytes) > 0 { workPreview, err = loadCertificateImage(ctx, req.HTTPClient, req.WorkPreviewURL, req.WorkPreviewBytes) if err != nil { return nil, fmt.Errorf("load work preview: %w", err) } } qr, err := newQRCodeImage(req.VerifyURL, 184) if err != nil { return nil, fmt.Errorf("generate qr code: %w", err) } fontPath, err := resolveCertificateFont(req.FontPath) if err != nil { return nil, err } dc := gg.NewContext(1369, 1920) dc.DrawImage(resizeImage(background, 1369, 1920), 0, 0) dc.DrawImage(resizeImage(logo, 304, 60), 533, 470) drawTitle(dc, fontPath, req.Title) drawCertNo(dc, fontPath, req.CertNo) drawCertificateText(dc, fontPath, req) drawWorkPreview(dc, workPreview) dc.DrawImage(resizeImage(qr, 184, 184), 593, 1493) drawQRCodeText(dc, fontPath) drawDisclaimer(dc, fontPath) var buf bytes.Buffer if err := png.Encode(&buf, dc.Image()); err != nil { return nil, err } imageKey := certificateImageObjectKey(req.EvidenceID, req.CertNo) return &HashCertificateResult{ CertNo: req.CertNo, VerifyURL: req.VerifyURL, PNG: buf.Bytes(), CertificateImageKey: imageKey, CertificateImageURL: joinBucketURL(req.BucketBaseURL, imageKey), }, nil } func drawTitle(dc *gg.Context, fontPath, title string) { _ = dc.LoadFontFace(fontPath, 85) dc.SetHexColor("#000000") dc.DrawStringAnchored(title, 684.5, 550, 0.5, 1) } func drawCertNo(dc *gg.Context, fontPath, certNo string) { gradient := gg.NewLinearGradient(331.5, 670, 1037.5, 670) gradient.AddColorStop(0, color.White) gradient.AddColorStop(0.15, mustHexColor("#eed595")) gradient.AddColorStop(0.50142, mustHexColor("#9c6b3c")) gradient.AddColorStop(0.85, mustHexColor("#eed595")) gradient.AddColorStop(1, color.White) dc.SetFillStyle(gradient) dc.DrawRectangle(331.5, 670, 706, 60) dc.Fill() _ = dc.LoadFontFace(fontPath, 25) dc.SetHexColor("#ffffff") dc.DrawStringAnchored("编号:"+certNo, 684.5, 700, 0.5, 0.5) } func drawCertificateText(dc *gg.Context, fontPath string, req HashCertificateRequest) { subjectLines := []certificateLine{ {Text: "一、存证主体", Medium: true}, {Text: "申请用户名称:" + req.UserName}, {Text: "用户ID:" + req.UserID}, } drawLines(dc, fontPath, subjectLines, 143, 796, 1083, 49) summaryLines := []certificateLine{ {Text: "二、存证信息摘要", Medium: true}, {Text: "作品名称:" + req.WorkName}, {Text: "生成任务ID:" + req.TaskID}, {Text: "作品完成时间:" + req.CompletedAt}, {Text: "存证ID:" + req.EvidenceID}, {Text: `区块链交易ID:"` + req.TxID + `"`}, {Text: "区块高度:" + req.BlockHeight}, {Text: `最终产物 Hash:"` + req.FinalHash + `"`}, {Text: `证据包 Hash:"` + req.EvidenceHash + `"`}, {Text: "可信存证时间:" + req.TrustedAt}, } drawLines(dc, fontPath, summaryLines, 143, 963, 1083, 49) } type certificateLine struct { Text string Medium bool } func drawLines(dc *gg.Context, fontPath string, lines []certificateLine, x, y, maxWidth, lineStep float64) { for i, line := range lines { size := fitFontSize(dc, fontPath, line.Text, 25, 18, maxWidth) _ = dc.LoadFontFace(fontPath, size) dc.SetHexColor("#000000") dc.DrawStringAnchored(line.Text, x, y+float64(i)*lineStep, 0, 1) } } func drawWorkPreview(dc *gg.Context, preview image.Image) { dc.Push() dc.RotateAbout(gg.Radians(4.33), 959, 950) gradient := gg.NewLinearGradient(727, 823, 1190, 1078) gradient.AddColorStop(0.10985, mustHexColor("#eed594")) gradient.AddColorStop(0.89015, mustHexColor("#b78e5a")) dc.SetFillStyle(gradient) dc.DrawRectangle(735, 825, 463, 255) dc.Fill() dc.Pop() dc.SetHexColor("#ffffff") dc.DrawRectangle(717, 813, 488, 273) dc.Fill() if preview != nil { fitted := containImage(preview, 488, 273) dc.DrawImageAnchored(fitted, 961, 950, 0.5, 0.5) } dc.SetHexColor("#eed594") dc.SetLineWidth(2) dc.DrawRectangle(717, 813, 488, 273) dc.Stroke() } func drawQRCodeText(dc *gg.Context, fontPath string) { _ = dc.LoadFontFace(fontPath, 25) dc.SetHexColor("#000000") dc.DrawStringAnchored("扫码查看在线证书", 684.5, 1715, 0.5, 1) } func drawDisclaimer(dc *gg.Context, fontPath string) { _ = dc.LoadFontFace(fontPath, 18) dc.SetHexColor("#999999") text := "*本凭证为电子数据存证凭证,不等同于官方著作权登记证书。作品原创性、素材合法性及第三方授权责任由申请用户承担。" dc.DrawStringAnchored(text, 194, 1853.5, 0, 0.5) } func fitFontSize(dc *gg.Context, fontPath, text string, preferred, minimum, maxWidth float64) float64 { for size := preferred; size >= minimum; size-- { if err := dc.LoadFontFace(fontPath, size); err != nil { return preferred } width, _ := dc.MeasureString(text) if width <= maxWidth { return size } } return minimum } func newQRCodeImage(content string, size int) (image.Image, error) { qr, err := qrcode.New(content, qrcode.Medium) if err != nil { return nil, err } qr.DisableBorder = true return qr.Image(size), nil } func loadCertificateImage(ctx context.Context, client *http.Client, imageURL string, inline []byte) (image.Image, error) { var data []byte var err error if len(inline) > 0 { data = inline } else { if imageURL == "" { return nil, errors.New("missing image url") } data, err = fetchBytes(ctx, client, imageURL) if err != nil { return nil, err } } img, format, err := image.Decode(bytes.NewReader(data)) if err != nil { return nil, err } if format == "jpeg" { return img, nil } return img, nil } func fetchBytes(ctx context.Context, client *http.Client, rawURL string) ([]byte, error) { if client == nil { client = &http.Client{Timeout: 20 * time.Second} } req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, err } resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("unexpected status %d", resp.StatusCode) } return io.ReadAll(resp.Body) } func resizeImage(src image.Image, width, height int) image.Image { dst := image.NewRGBA(image.Rect(0, 0, width, height)) xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), xdraw.Over, nil) return dst } func containImage(src image.Image, boxWidth, boxHeight int) image.Image { bounds := src.Bounds() sourceWidth := bounds.Dx() sourceHeight := bounds.Dy() if sourceWidth == 0 || sourceHeight == 0 { return image.NewRGBA(image.Rect(0, 0, boxWidth, boxHeight)) } scale := min(float64(boxWidth)/float64(sourceWidth), float64(boxHeight)/float64(sourceHeight)) width := max(1, int(float64(sourceWidth)*scale)) height := max(1, int(float64(sourceHeight)*scale)) dst := image.NewRGBA(image.Rect(0, 0, boxWidth, boxHeight)) x := (boxWidth - width) / 2 y := (boxHeight - height) / 2 target := image.Rect(x, y, x+width, y+height) xdraw.CatmullRom.Scale(dst, target, src, bounds, xdraw.Over, nil) return dst } func resolveCertificateFont(explicitPath string) (string, error) { if explicitPath != "" { if _, err := os.Stat(explicitPath); err != nil { return "", err } if _, err := gg.LoadFontFace(explicitPath, 25); err != nil { return "", fmt.Errorf("load certificate font %s: %w", explicitPath, err) } return explicitPath, nil } candidates := []string{ "/System/Library/Fonts/Supplemental/Arial Unicode.ttf", "/System/Library/Fonts/Supplemental/Arial.ttf", "/System/Library/Fonts/Supplemental/Microsoft Sans Serif.ttf", "/System/Library/Fonts/STHeiti Medium.ttc", "/System/Library/Fonts/Hiragino Sans GB.ttc", "/Library/Fonts/Arial Unicode.ttf", "/usr/share/fonts/opentype/source-han-sans/SourceHanSansCN-Regular.otf", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.otf", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", } for _, candidate := range candidates { if _, err := os.Stat(candidate); err != nil { continue } if _, err := gg.LoadFontFace(candidate, 25); err == nil { return candidate, nil } } return "", errors.New("missing certificate font, set HashCertificateRequest.FontPath to a Chinese TTF/TTC font") } func certificateImageObjectKey(evidenceID, certNo string) string { dirName := evidenceID if dirName == "" { dirName = certNo } dirName = strings.Trim(dirName, "/") if dirName == "" { dirName = "unknown" } return path.Join("certificates", dirName, "certificate.png") } func joinBucketURL(bucketBaseURL, objectKey string) string { if bucketBaseURL == "" || objectKey == "" { return "" } escaped := strings.TrimLeft(objectKey, "/") u, err := url.Parse(strings.TrimRight(bucketBaseURL, "/")) if err != nil { return strings.TrimRight(bucketBaseURL, "/") + "/" + escaped } u.Path = path.Join(u.Path, escaped) return u.String() } func mustHexColor(hex string) color.Color { c, err := parseHexColor(hex) if err != nil { panic(err) } return c } func parseHexColor(hex string) (color.Color, error) { hex = strings.TrimPrefix(hex, "#") if len(hex) != 6 { return nil, fmt.Errorf("invalid hex color %q", hex) } var r, g, b uint8 if _, err := fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b); err != nil { return nil, err } return color.RGBA{R: r, G: g, B: b, A: 255}, nil } func init() { image.RegisterFormat("jpeg", "\xff\xd8", jpeg.Decode, jpeg.DecodeConfig) image.RegisterFormat("png", "\x89PNG\r\n\x1a\n", png.Decode, png.DecodeConfig) }