PrintToVeraeTimeStamp/internal/pdfcert/fallback.go
George Lambert 3140e6c76f
Some checks failed
windows-msi / full-msi-unsigned (push) Has been cancelled
feat: PUBLISH-DIGESTS.md + commented Authenticode CI paths + EmbedBrandFonts TTF
windows-msi-sign.yml.example; gofpdf register when EmbedBrandFonts and fonts present.
2026-10-14 01:24:11 -04:00

312 lines
10 KiB
Go

package pdfcert
import (
"bytes"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/statuslang"
"github.com/phpdave11/gofpdf"
qrcode "github.com/skip2/go-qrcode"
)
// brandTitleFamily is the gofpdf family name when a brand TTF/OTF registers.
const brandTitleFamily = "VeraeBrand"
// 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 faces by default (Times title, Helvetica body).
// When EmbedBrandFonts is on and BrandFontsDir has a .ttf/.otf, attempt
// gofpdf AddUTF8FontFromBytes for the title; on failure log and keep Times.
func buildFallbackGofpdf(in Input, docName, created, scope, merkle, lookup string) ([]byte, error) {
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
titleFamily := tryRegisterBrandTitleFont(pdf, in)
// 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 — brand face when registered; else Times core serif stand-in
pdf.SetTextColor(inkR, inkG, inkB)
if titleFamily != "" {
pdf.SetFont(titleFamily, "B", 22)
} else {
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
}
// tryRegisterBrandTitleFont attempts gofpdf UTF-8 embed of a licensed brand
// serif when EmbedBrandFonts is on and BrandFontsDir has .ttf/.otf.
// Returns brandTitleFamily on success, "" on skip/failure (caller keeps Times).
// Never errors the PDF build — registration failure is a logged no-op.
func tryRegisterBrandTitleFont(pdf *gofpdf.Fpdf, in Input) string {
if pdf == nil || !EmbedBrandFontsEnabled(in) {
return ""
}
dir := BrandFontsDir()
face := findBrandFaceFile(dir)
if face == "" {
log.Printf("pdfcert: EmbedBrandFonts on but no .ttf/.otf in %q — keeping Times", dir)
return ""
}
ext := strings.ToLower(filepath.Ext(face))
// gofpdf AddUTF8FontFromBytes parses TrueType tables; OTF-CFF often fails.
b, err := os.ReadFile(face)
if err != nil {
log.Printf("pdfcert: brand font read %s: %v — keeping Times", face, err)
return ""
}
pdf.AddUTF8FontFromBytes(brandTitleFamily, "B", b)
if err := pdf.Error(); err != nil {
log.Printf("pdfcert: gofpdf could not register brand font %s (%s): %v — keeping Times", face, ext, err)
pdf.ClearError()
return ""
}
// Regular style alias so SetFont(family, "", …) would also work if needed.
pdf.AddUTF8FontFromBytes(brandTitleFamily, "", b)
if err := pdf.Error(); err != nil {
pdf.ClearError()
// Bold alone is enough for the title.
}
log.Printf("pdfcert: registered brand title font from %s", face)
return brandTitleFamily
}
// 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
}