You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
476 lines
13 KiB
476 lines
13 KiB
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://popi-certificate-1386008232.cos.ap-guangzhou.myqcloud.com/65654.png"
|
|
defaultCertificateLogoURL = "https://popi-certificate-1386008232.cos.ap-guangzhou.myqcloud.com/84428.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.BucketBaseURL == "" {
|
|
req.BucketBaseURL = defaultCertificateBucketBaseURL
|
|
}
|
|
|
|
var err error
|
|
backgroundURL := req.BackgroundURL
|
|
if backgroundURL == "" && len(req.BackgroundBytes) == 0 {
|
|
backgroundURL = defaultCertificateBackgroundURL
|
|
}
|
|
background, err := loadCertificateImage(ctx, req.HTTPClient, backgroundURL, req.BackgroundBytes)
|
|
if err != nil {
|
|
if req.BackgroundURL != "" || len(req.BackgroundBytes) > 0 {
|
|
return nil, fmt.Errorf("load certificate background: %w", err)
|
|
}
|
|
background = defaultCertificateBackgroundImage()
|
|
}
|
|
|
|
logoURL := req.LogoURL
|
|
if logoURL == "" && len(req.LogoBytes) == 0 {
|
|
logoURL = defaultCertificateLogoURL
|
|
}
|
|
logo, err := loadCertificateImage(ctx, req.HTTPClient, logoURL, req.LogoBytes)
|
|
if err != nil {
|
|
if req.LogoURL != "" || len(req.LogoBytes) > 0 {
|
|
return nil, fmt.Errorf("load certificate logo: %w", err)
|
|
}
|
|
logo = defaultCertificateLogoImage()
|
|
}
|
|
|
|
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 defaultCertificateBackgroundImage() image.Image {
|
|
dc := gg.NewContext(1369, 1920)
|
|
dc.SetHexColor("#fffdf8")
|
|
dc.Clear()
|
|
|
|
gradient := gg.NewLinearGradient(0, 0, 1369, 1920)
|
|
gradient.AddColorStop(0, mustHexColor("#ffffff"))
|
|
gradient.AddColorStop(0.55, mustHexColor("#fff7e7"))
|
|
gradient.AddColorStop(1, mustHexColor("#f4e4c5"))
|
|
dc.SetFillStyle(gradient)
|
|
dc.DrawRectangle(0, 0, 1369, 1920)
|
|
dc.Fill()
|
|
|
|
dc.SetHexColor("#d7b56d")
|
|
dc.SetLineWidth(4)
|
|
dc.DrawRectangle(60, 60, 1249, 1800)
|
|
dc.Stroke()
|
|
|
|
dc.SetHexColor("#eed594")
|
|
dc.SetLineWidth(1)
|
|
dc.DrawRectangle(82, 82, 1205, 1756)
|
|
dc.Stroke()
|
|
|
|
return dc.Image()
|
|
}
|
|
|
|
func defaultCertificateLogoImage() image.Image {
|
|
dc := gg.NewContext(304, 60)
|
|
dc.SetRGBA(0, 0, 0, 0)
|
|
dc.Clear()
|
|
dc.SetHexColor("#111111")
|
|
dc.DrawCircle(32, 30, 24)
|
|
dc.Fill()
|
|
dc.SetHexColor("#eed594")
|
|
dc.DrawCircle(32, 30, 13)
|
|
dc.Fill()
|
|
dc.SetHexColor("#111111")
|
|
dc.DrawCircle(32, 30, 6)
|
|
dc.Fill()
|
|
dc.SetHexColor("#111111")
|
|
dc.DrawRoundedRectangle(74, 16, 170, 28, 14)
|
|
dc.Fill()
|
|
return dc.Image()
|
|
}
|
|
|
|
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)
|
|
}
|
|
|