Some checks failed
windows-msi / full-msi-unsigned (push) Has been cancelled
Signtool verify/print sign recipe without secrets; PDF brand embed opt-in only.
268 lines
8.8 KiB
Go
268 lines
8.8 KiB
Go
package pdfcert
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/statuslang"
|
|
"github.com/phpdave11/gofpdf"
|
|
qrcode "github.com/skip2/go-qrcode"
|
|
)
|
|
|
|
// Brand tokens (CERT-01 / BrandingBook): plum, gold, ink, muted, ok.
|
|
const (
|
|
plumR, plumG, plumB = 83, 27, 89 // #531B59
|
|
goldR, goldG, goldB = 196, 168, 90 // #C4A85A
|
|
goldDR, goldDG, goldDB = 110, 87, 23 // #6E5717
|
|
inkR, inkG, inkB = 29, 21, 52 // #1D1534
|
|
mutedR, mutedG, mutedB = 94, 84, 112 // #5E5470
|
|
okR, okG, okB = 14, 107, 58 // #0E6B3A
|
|
okBgR, okBgG, okBgB = 227, 245, 234 // #E3F5EA
|
|
okBdR, okBdG, okBdB = 185, 226, 200 // #B9E2C8
|
|
linkR, linkG, linkB = 59, 42, 140 // #3B2A8C info
|
|
)
|
|
|
|
// Minimal fallback when Node/jsPDF is unavailable.
|
|
//
|
|
// Fonts: PDF core / library faces only (Times title, Helvetica body).
|
|
// EmbedBrandFonts defaults false (see EmbedBrandFontsEnabled). Do not claim
|
|
// Fraunces embed — no AddFont/TTF path unless EmbedBrandFonts is on and a
|
|
// licensed Fraunces file is wired later (OPEN-ISSUES #7).
|
|
func buildFallbackGofpdf(in Input, docName, created, scope, merkle, lookup string) ([]byte, error) {
|
|
// Opt-in brand embed is not implemented yet; keep core fonts when false (default).
|
|
_ = EmbedBrandFontsEnabled(in)
|
|
pdf := gofpdf.New("P", "pt", "Letter", "")
|
|
pdf.SetCompression(false)
|
|
pdf.SetMargins(48, 48, 48)
|
|
pdf.SetAutoPageBreak(false, 0)
|
|
pdf.AddPage()
|
|
pageW, pageH := pdf.GetPageSize()
|
|
margin := 48.0
|
|
|
|
// CERT-01 double frame: plum outer, gold inner
|
|
pdf.SetDrawColor(plumR, plumG, plumB)
|
|
pdf.SetLineWidth(1.5)
|
|
pdf.Rect(28, 28, pageW-56, pageH-56, "D")
|
|
pdf.SetDrawColor(goldR, goldG, goldB)
|
|
pdf.SetLineWidth(0.5)
|
|
pdf.Rect(34, 34, pageW-68, pageH-68, "D")
|
|
|
|
if p, cleanup, err := materializeLogo(); err == nil {
|
|
pdf.ImageOptions(p, margin, 48, 120, 36, false, gofpdf.ImageOptions{ImageType: "PNG", ReadDpi: true}, 0, "")
|
|
if cleanup != nil {
|
|
defer cleanup()
|
|
}
|
|
}
|
|
|
|
// Gold-d eyebrow (BrandingBook §12) — Helvetica core font (not Fraunces)
|
|
pdf.SetTextColor(goldDR, goldDG, goldDB)
|
|
pdf.SetFont("Helvetica", "B", 9)
|
|
pdf.SetXY(margin, 92)
|
|
pdf.CellFormat(pageW-margin*2, 11, "TIMESTAMP CERTIFICATE", "", 0, "C", false, 0, "")
|
|
|
|
// Title — Times core serif stand-in for CERT-01 Fraunces (no embed unless present)
|
|
pdf.SetTextColor(inkR, inkG, inkB)
|
|
pdf.SetFont("Times", "B", 22)
|
|
pdf.SetXY(margin, 106)
|
|
pdf.CellFormat(pageW-margin*2, 24, "Verae Timestamp Certificate", "", 0, "C", false, 0, "")
|
|
|
|
pdf.SetTextColor(mutedR, mutedG, mutedB)
|
|
pdf.SetFont("Helvetica", "", 10)
|
|
pdf.SetXY(margin, 132)
|
|
pdf.CellFormat(pageW-margin*2, 12, "Proof that this exact document existed at the time below,", "", 0, "C", false, 0, "")
|
|
pdf.SetXY(margin, 144)
|
|
pdf.CellFormat(pageW-margin*2, 12, "recorded on the "+statuslang.ChainName+" (digests only)", "", 0, "C", false, 0, "")
|
|
|
|
// Short centered gold rule (CERT-01)
|
|
ruleW := 120.0
|
|
pdf.SetDrawColor(goldR, goldG, goldB)
|
|
pdf.SetLineWidth(2)
|
|
pdf.Line(pageW/2-ruleW/2, 162, pageW/2+ruleW/2, 162)
|
|
|
|
// FINAL-only status banner (receipt-final / statuslang wording)
|
|
bannerY := 174.0
|
|
bannerH := 40.0
|
|
pdf.SetFillColor(okBgR, okBgG, okBgB)
|
|
pdf.SetDrawColor(okBdR, okBdG, okBdB)
|
|
pdf.SetLineWidth(1.5)
|
|
pdf.Rect(margin, bannerY, pageW-margin*2, bannerH, "FD")
|
|
pdf.SetTextColor(okR, okG, okB)
|
|
pdf.SetFont("Helvetica", "B", 12)
|
|
pdf.SetXY(margin+12, bannerY+8)
|
|
pdf.Cell(pageW-margin*2-24, 14, finalBannerTitle(merkle))
|
|
pdf.SetFont("Helvetica", "", 9)
|
|
pdf.SetXY(margin+12, bannerY+24)
|
|
pdf.Cell(pageW-margin*2-24, 12, statuslang.EvidenceNote)
|
|
|
|
if created == "" {
|
|
created = "—"
|
|
}
|
|
mh, b3, b3mh := ResolveDigests(in)
|
|
fields := [][2]string{
|
|
{"Document", docName},
|
|
}
|
|
if in.ByteLength > 0 {
|
|
fields = append(fields, [2]string{"Document size", fmt.Sprintf("%d bytes", in.ByteLength)})
|
|
}
|
|
// CERT-01: digest lines only when data is available (never invent).
|
|
if mh != "" {
|
|
fields = append(fields, [2]string{"Content multihash", mh})
|
|
}
|
|
if strings.TrimSpace(in.SHA256) != "" {
|
|
fields = append(fields, [2]string{"SHA-256 (hex)", in.SHA256})
|
|
}
|
|
if b3mh != "" {
|
|
fields = append(fields, [2]string{"BLAKE3 multihash", b3mh})
|
|
}
|
|
if b3 != "" {
|
|
fields = append(fields, [2]string{"BLAKE3 (hex)", b3})
|
|
}
|
|
fields = append(fields,
|
|
[2]string{"Registration scope", scope},
|
|
[2]string{"Original timestamp (UTC)", created},
|
|
[2]string{"VeraeTime receipt (CID)", orDash(in.ReceiptID)},
|
|
[2]string{"Merkle / block", merkle},
|
|
[2]string{"Network", statuslang.ChainName + " (" + statuslang.NetworkName + ")"},
|
|
[2]string{"Blockchain explorer", statuslang.ExplorerURL},
|
|
[2]string{"Public lookup URL", lookup},
|
|
)
|
|
|
|
isHTTP := func(s string) bool {
|
|
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "http://") ||
|
|
strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "https://")
|
|
}
|
|
|
|
y := bannerY + bannerH + 18
|
|
for _, fv := range fields {
|
|
pdf.SetFont("Helvetica", "B", 9)
|
|
pdf.SetTextColor(mutedR, mutedG, mutedB)
|
|
pdf.SetXY(margin, y)
|
|
pdf.Cell(pageW-margin*2, 11, fv[0])
|
|
y += 12
|
|
pdf.SetFont("Helvetica", "", 10)
|
|
linkURL := ""
|
|
if isHTTP(fv[1]) {
|
|
linkURL = strings.TrimSpace(fv[1])
|
|
pdf.SetTextColor(linkR, linkG, linkB)
|
|
} else {
|
|
pdf.SetTextColor(inkR, inkG, inkB)
|
|
}
|
|
for _, ln := range pdf.SplitLines([]byte(fv[1]), pageW-margin*2-10) {
|
|
pdf.SetXY(margin, y)
|
|
if linkURL != "" {
|
|
pdf.CellFormat(pageW-margin*2-10, 12, string(ln), "", 0, "L", false, 0, linkURL)
|
|
} else {
|
|
pdf.Cell(pageW-margin*2-10, 12, string(ln))
|
|
}
|
|
y += 12
|
|
}
|
|
y += 8
|
|
if y > pageH-220 {
|
|
break
|
|
}
|
|
}
|
|
|
|
if qrPNG, err := qrcode.Encode(lookup, qrcode.Medium, 280); err == nil {
|
|
if qrFile, qclean, qerr := writeTempPNG(qrPNG); qerr == nil {
|
|
qrSize := 110.0
|
|
qrX := pageW - margin - qrSize
|
|
qrY := pageH - margin - qrSize - 48
|
|
pdf.ImageOptions(qrFile, qrX, qrY, qrSize, qrSize, false, gofpdf.ImageOptions{ImageType: "PNG"}, 0, "")
|
|
pdf.LinkString(qrX, qrY, qrSize, qrSize, lookup)
|
|
pdf.SetFont("Helvetica", "B", 8)
|
|
pdf.SetTextColor(linkR, linkG, linkB)
|
|
pdf.SetXY(qrX, qrY+qrSize+4)
|
|
pdf.CellFormat(qrSize, 10, "Scan to verify", "", 0, "C", false, 0, lookup)
|
|
if qclean != nil {
|
|
defer qclean()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Final-only legal + digests-only (CERT-01 footer / BrandingBook)
|
|
pdf.SetFont("Helvetica", "", 8)
|
|
pdf.SetTextColor(mutedR, mutedG, mutedB)
|
|
pdf.SetXY(margin, pageH-72)
|
|
pdf.MultiCell(pageW-margin*2-130, 10, "Only the document's SHA-256 digest was submitted; the file was not uploaded. This certificate is final receipt evidence for the digest above. A jobId or pending hold is not proof. Anyone can verify this receipt for free — no account needed.", "", "L", false)
|
|
pdf.SetFont("Helvetica", "B", 9)
|
|
pdf.SetTextColor(plumR, plumG, plumB)
|
|
pdf.SetXY(margin, pageH-36)
|
|
pdf.Cell(55, 12, "Verae LLC · ")
|
|
pdf.SetTextColor(linkR, linkG, linkB)
|
|
pdf.CellFormat(40, 12, "verae.ai", "", 0, "L", false, 0, "https://verae.ai")
|
|
pdf.SetTextColor(plumR, plumG, plumB)
|
|
pdf.Cell(12, 12, " · ")
|
|
pdf.SetTextColor(linkR, linkG, linkB)
|
|
pdf.CellFormat(90, 12, "explorer.verae.ai", "", 0, "L", false, 0, "https://explorer.verae.ai/")
|
|
|
|
var buf bytes.Buffer
|
|
if err := pdf.Output(&buf); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// finalBannerTitle uses statuslang.Label("final", block) — BrandingBook chip text.
|
|
// Certificates are issued only for evidence states (IsEvidence); hold ≠ evidence.
|
|
func finalBannerTitle(merkle string) string {
|
|
s := strings.TrimSpace(merkle)
|
|
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
|
return statuslang.Label("final", n)
|
|
}
|
|
// Block unknown: BrandingBook #N placeholder (still final / evidence wording).
|
|
return statuslang.Label("final")
|
|
}
|
|
|
|
func orDash(s string) string {
|
|
if s == "" {
|
|
return "—"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func materializeLogo() (path string, cleanup func(), err error) {
|
|
// Prefer tree-local assets, then walk for go.mod (packaging / tests).
|
|
var candidates []string
|
|
candidates = append(candidates, filepath.Join("assets", "VeraeFullLogo.png"))
|
|
if wd, e := os.Getwd(); e == nil {
|
|
dir := wd
|
|
for i := 0; i < 8; i++ {
|
|
candidates = append(candidates, filepath.Join(dir, "assets", "VeraeFullLogo.png"))
|
|
if st, se := os.Stat(filepath.Join(dir, "go.mod")); se == nil && !st.IsDir() {
|
|
break
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
break
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
for _, c := range candidates {
|
|
if st, e := os.Stat(c); e == nil && st.Size() > 0 {
|
|
return c, nil, nil
|
|
}
|
|
}
|
|
if len(embeddedLogo) == 0 {
|
|
return "", nil, fmt.Errorf("logo not found")
|
|
}
|
|
return writeTempPNG(embeddedLogo)
|
|
}
|
|
|
|
func writeTempPNG(b []byte) (path string, cleanup func(), err error) {
|
|
f, err := os.CreateTemp("", "verae-png-*.png")
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
if _, err := f.Write(b); err != nil {
|
|
f.Close()
|
|
os.Remove(f.Name())
|
|
return "", nil, err
|
|
}
|
|
f.Close()
|
|
return f.Name(), func() { _ = os.Remove(f.Name()) }, nil
|
|
}
|