Brush up the go code

This commit is contained in:
2022-04-19 21:22:59 +02:00
parent b88208ba3d
commit 63875a6f59
7 changed files with 161 additions and 46 deletions

View File

@@ -1,9 +1,18 @@
.PHONY: help
help: ## Lists all commands available
# Snippet comes from: https://gist.github.com/prwhite/8168133#gistcomment-3785627
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[$$()% 0-9a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
.PHONY: build
build: ## Build the main covergen CLI for the local platform
go build -o covergen ./cmd/covergen
.PHONY: wasm
wasm: ## Generate the main WASM blob
GOOS=js GOARCH=wasm go build -o covergen.wasm ./cmd/wasm/main.go
.PHONY: build-cross-clis
build-cross-clis:
build-cross-clis: ## Builds the CLIs for all supported platforms
rm -rf ./dist && mkdir ./dist
GOOS=linux GOARCH=amd64 go build -o dist/covergen.linux-amd64 ./cmd/covergen
GOOS=darwin GOARCH=amd64 go build -o dist/covergen.darwin-amd64 ./cmd/covergen
.PHONY: wasm
wasm:
GOOS=js GOARCH=wasm go build -o covergen.wasm ./cmd/wasm/main.go

View File

@@ -2,19 +2,46 @@
Just generates some magic PDF invoice covers using straight up dark magic.
## Just be lazy
## Building it
Go see it in action and download it from [https://s3.blacknova.io/covergen/index.html](https://s3.blacknova.io/covergen/index.html)
```shell
# Builds the CLI
make build
## Just build it
```
$ go build -o covergen ./cmd/covergen
# Builds the WASM blob
make wasm
```
## It has wasm!
## Custom font notes
```
$ make wasm
The PDF uses the font `Mark`, which isn't a commonly available font.
For this reason they have been vendored into the repository in `pkg/covergen/fonts` together with
the `cp1252` character map (used in most PDFs because UTF-8 hasn't been invented yet in the PDF world).
For usage in `fpdf` they need to be encoded, which is done throught he `makefont` tool provided by `fpdf`, see
[https://github.com/go-pdf/fpdf#nonstandard-fonts](https://github.com/go-pdf/fpdf#nonstandard-fonts).
The section linked will also provide instructions on how to use it, but for this specific repo, the call to regenerate
or add more fonts is:
```shell
makefont --embed --enc=pkg/covergen/fonts/cp1252.map --dst=pkg/covergen/font_embed pkg/covergen/fonts/*.ttf
```
To get the `makefont` binary, clone the `go-fpdf/fpdf` repo and run `go build` in the `makefont` directory.
## A note on WASM
An interesting architectural note about how WASM binaries are run in browsers is that you should not see it
as a shell command 'exec'-ing out into a new binary, but rather as two processes living side by side.
The calling a method exposed by WASM behaves more like doing IPC between the two processes.
You can sort of see this happening in `cmd/wasm/main.go` where the `main` function is actually used.
It registers the two exposed commands to the `global` scope (in browsers: `window`) before just deadlocking
by waiting on an anonymous channel.
The most important note you should take from that is that the WASM binary **can crash**.
If it crashes, it doesn't just 'boot itself' again.
And the JS -> WASM boundary is *very*, ***very*** unsafe.
Like 'do the smallest thing wrong and the program crashes' level of unsafe.
No, type safety won't help you, you're literally playing with the equivalent of void pointers.
Even panic recovery (present in the two exposed methods) doesn't protect against all of it.
I think where I'm going is just a strong warning to be *very* careful at that boundary.
Don't trust any `js.Value`. Parse it thoroughly and quickly into golang-specified types.

View File

@@ -13,6 +13,7 @@ import (
"covergen/pkg/covergen"
)
// List of colors used for picking a (random) color in the cli
var colors = []string{"red", "blue", "yellow", "green"}
var colorMap = map[string]covergen.Color{
"red": {255, 85, 0},
@@ -69,6 +70,7 @@ func main() {
chosenColor = colorMap[*color]
}
// Newlines in CLI arguments are given escaped. Unescape them to proper newlines
*customer = strings.ReplaceAll(*customer, "\\n", "\n")
settings := covergen.CoverSettings{
Number: *number,
@@ -77,6 +79,7 @@ func main() {
HLColor: chosenColor,
}
// If output2 (back page) is not set, generate a combined PDF
if *output2 == "" {
pdf, err := covergen.GenerateInvoice(settings)
@@ -88,6 +91,7 @@ func main() {
log.Fatal().Err(err).Msg("Failed to write invoice to disk")
}
} else {
// Otherwise, generate the front and back separately
front, err := covergen.GenerateFrontCover(settings)
if err != nil {
log.Fatal().Err(err).Msg("Failed to render front")

View File

@@ -5,6 +5,7 @@ import (
"net/http"
)
// Super simple development webserver
func main() {
err := http.ListenAndServe(":9090", http.FileServer(http.Dir("assets")))
if err != nil {

View File

@@ -12,6 +12,7 @@ import (
"covergen/pkg/covergen"
)
// jsmap is just a type alias that corresponds to the ES5 Record<string, any>, or just a plain object {}
type jsmap = map[string]interface{}
type covergenArgs struct {
@@ -21,6 +22,8 @@ type covergenArgs struct {
HLColor string `jsref:"hlColor"`
}
// parseHexColor takes a CSS hex color (#RRGGBB or the shorthand #RGB) and parses out the red, green, and blue components
// into a covergen.Color
func parseHexColor(s string) (c covergen.Color, err error) {
switch len(s) {
case 7:
@@ -33,11 +36,12 @@ func parseHexColor(s string) (c covergen.Color, err error) {
c.B *= 17
default:
err = fmt.Errorf("invalid length, must be 7 or 4")
}
return
}
// settingsFromValue tries to parse the WASM boundary-crossing arguments blob into a golang-native settings object
func settingsFromValue(arg js.Value) (*covergen.CoverSettings, error) {
settings := covergenArgs{
NumberPrefix: "offerte",
@@ -71,6 +75,7 @@ func settingsFromValue(arg js.Value) (*covergen.CoverSettings, error) {
}, nil
}
// pdfToJs takes the pdf object and outputs it into a Uint8Array and passes that back to JS.
func pdfToJs(pdf *fpdf.Fpdf) (js.Value, error) {
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
@@ -83,10 +88,11 @@ func pdfToJs(pdf *fpdf.Fpdf) (js.Value, error) {
return ta, nil
}
// generateCover is the JS exposed entrypoint to generate a 2 page pdf with both the front and back cover
func generateCover(this js.Value, args []js.Value) interface{} {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered", r)
fmt.Printf("recovered from critical error: %+v", r)
}
}()
@@ -119,7 +125,7 @@ func generateCover(this js.Value, args []js.Value) interface{} {
func generateSplitCover(this js.Value, args []js.Value) interface{} {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered", r)
fmt.Printf("recovered from critical error: %+v", r)
}
}()

View File

@@ -2,20 +2,24 @@ package covergen
import "github.com/go-pdf/fpdf"
// Color represents a simple RGB color
type Color struct {
R uint8
G uint8
B uint8
}
// Text sets the text color of the given PDF to the called-on color instance
func (c Color) Text(pdf *fpdf.Fpdf) {
pdf.SetTextColor(int(c.R), int(c.G), int(c.B))
}
// Draw sets the draw color of the given PDF to the called-on color instance
func (c Color) Draw(pdf *fpdf.Fpdf) {
pdf.SetDrawColor(int(c.R), int(c.G), int(c.B))
}
// Fill sets the fill color of the given PDF to the called-on color instance
func (c Color) Fill(pdf *fpdf.Fpdf) {
pdf.SetFillColor(int(c.R), int(c.G), int(c.B))
}

View File

@@ -8,16 +8,32 @@ import (
"github.com/go-pdf/fpdf"
)
// page margins
const margin = 20
const miwebbLogoName = "miwebbLogo"
const miwebbLogoPath = "miwebb.white.png"
const logoScale = 0.6
const gridXTicks = 10
const gridYTicks = 6
const gridMargin = 5
const lineHeight = 1.2
const maxFontSize = 100
// constants for the logo
const (
// pdf internal name to reference the logo
logoName = "miwebbLogo"
// this should match
logoPath = "miwebb.white.png"
// Scale-up / down factor for the logo
logoScale = 0.6
)
// constants for the grid
const (
// Number of lines on the X axis of the grid behind the customer name
gridXTicks = 10
// Number of lines on the Y axis of the grid
gridYTicks = 6
// Internal padding for the text inside the grid, in mm
gridPadding = 5
// default line height used for the grid
lineHeight = 1.2
// maximum font size for the grid, in pt
maxFontSize = 100
)
var bgColor = Color{25, 25, 25}
var textColor = Color{255, 255, 255}
@@ -27,12 +43,18 @@ var gridNoduleColor = Color{255, 255, 255}
//go:embed miwebb.white.png font_embed
var fs embed.FS
const fontsEmbedPrefix = "font_embed/"
const (
fontsEmbedPrefix = "font_embed/"
fontLight = "Mark Light"
fontMedium = "Mark Medium"
)
// Copied from fPDF's source, can't get it dynamically
// constant for mm is just to ensure you don't change it without noting the scale factor that depends on it
const scaleUnit = "mm"
const scaleFactor = 72.0 / 25.4
const (
// Copied from FPDF's source, can't get it dynamically
// constant for mm is just to ensure you don't change it without noting the scale factor that depends on it
scaleUnit = "mm"
scaleFactor = 72.0 / 25.4
)
type CoverSettings struct {
Number string
@@ -42,12 +64,16 @@ type CoverSettings struct {
HLColor Color
}
// GenerateInvoice generates a fpdf.Fpdf that contains two pages, the first being the front page, (see GenerateFrontCover)
// and the second page being the closing page (see GenerateBackCover)
func GenerateInvoice(settings CoverSettings) (*fpdf.Fpdf, error) {
pdf, err := GenerateFrontCover(settings)
if err != nil {
return nil, err
}
// Generating the back cover from the front cover is just 'drawing the customer on a new page'
// as all the other things are automatically done using the page setup handlers
pdf.AddPage()
drawCustomerName(pdf, settings)
@@ -58,6 +84,8 @@ func GenerateInvoice(settings CoverSettings) (*fpdf.Fpdf, error) {
return pdf, nil
}
// GenerateFrontCover generates a fpdf.Fpdf representing the front cover of the invoice, containing the customer name,
// the link to the MiWebb website, and the invoice number.
func GenerateFrontCover(settings CoverSettings) (*fpdf.Fpdf, error) {
pdf, err := generateBaseInvoice(settings)
if err != nil {
@@ -74,6 +102,7 @@ func GenerateFrontCover(settings CoverSettings) (*fpdf.Fpdf, error) {
return pdf, nil
}
// GenerateBackCover generates a very plain cover containing just the customer name
func GenerateBackCover(settings CoverSettings) (*fpdf.Fpdf, error) {
pdf, err := generateBaseInvoice(settings)
if err != nil {
@@ -83,6 +112,12 @@ func GenerateBackCover(settings CoverSettings) (*fpdf.Fpdf, error) {
return pdf, err
}
// generateBaseInvoice takes care of setting up the initial fpdf.Fpdf instance by:
// - registering all the fonts,
// - registering the logo
// - setting up the page settings (margins)
// - setting up the header func that renders the logo on every page
// After the initialization it will also render the customer name on the first page
func generateBaseInvoice(settings CoverSettings) (*fpdf.Fpdf, error) {
pdf := fpdf.New("P", scaleUnit, "A4", "")
@@ -92,7 +127,7 @@ func generateBaseInvoice(settings CoverSettings) (*fpdf.Fpdf, error) {
if err := addEmbeddedFont(pdf, "Mark Light", "", "Mark-Light"); err != nil {
return nil, fmt.Errorf("error adding font: %w", err)
}
miwebbLogo, err := addEmbeddedLogo(pdf, miwebbLogoName, "miwebb.white.png")
logo, err := addEmbeddedLogo(pdf, logoName, logoPath)
if err != nil {
return nil, fmt.Errorf("error adding logo: %w", err)
}
@@ -102,9 +137,11 @@ func generateBaseInvoice(settings CoverSettings) (*fpdf.Fpdf, error) {
pdf.SetAutoPageBreak(false, 0)
pdf.SetHeaderFunc(func() {
// Fills the entire page with the background color
bgColor.Fill(pdf)
pdf.Rect(0, 0, pageWidth, pageHeight, "F")
pdf.Image(miwebbLogoName, margin-5, margin, miwebbLogo.Width()*logoScale, miwebbLogo.Height()*logoScale, false, "", 0, "")
// Draws the MiWebb logo
pdf.Image(logoName, margin-5, margin, logo.Width()*logoScale, logo.Height()*logoScale, false, "", 0, "")
})
pdf.AddPage()
@@ -117,11 +154,14 @@ func generateBaseInvoice(settings CoverSettings) (*fpdf.Fpdf, error) {
return pdf, nil
}
// drawInvoiceNumber does what it says on the tin, and draws the number prefix and number on the invoice in the bottom-left corner
// this moves the cursor to the top right corner of the text
func drawInvoiceNumber(pdf *fpdf.Fpdf, settings CoverSettings) {
_, pageHeight := pdf.GetPageSize()
// Bottom left corner of the page, given margin
pdf.MoveTo(margin, pageHeight-margin)
pdf.SetFont("Mark Light", "", 9)
pdf.SetFont(fontLight, "", 9)
textColor.Text(pdf)
pdf.TransformBegin()
@@ -130,37 +170,44 @@ func drawInvoiceNumber(pdf *fpdf.Fpdf, settings CoverSettings) {
pdf.CellFormat(0, 1, invoiceText, "", 0, "LT", false, 0, "")
pdf.TransformEnd()
// Calculate where the text has gone, so we can figure out the position of the line behind it
_, h := pdf.GetFontSize()
// X is 'margin + half text height'
// X is 'margin + half text height' (so centered on the line height)
x := float64(margin) + h/2
// Y is 'where the text ended' + some gap
// Y is 'where the text ended' + some gap (vertically)
y := pageHeight - margin - pdf.GetStringWidth(invoiceText) - 5
textColor.Draw(pdf)
pdf.SetLineWidth(0.01)
// the 10 here is the length of the line segment, chosen arbitrarily
pdf.Line(x, y, x, y-10)
}
// drawMiwebbLink draws a link to the MiWebb site in the bottom right corner of the page
func drawMiwebbLink(pdf *fpdf.Fpdf) {
pageWidth, pageHeight := pdf.GetPageSize()
pdf.MoveTo(pageWidth-margin, pageHeight-margin)
pdf.SetFont("Mark Light", "", 9)
pdf.SetFont(fontLight, "", 9)
textColor.Text(pdf)
pdf.CellFormat(0, 0, "WWW.MIWEBB.COM", "", 0, "RB", false, 0, "https://www.miwebb.com")
}
// drawCustomerName renders the customer name and the grid underneath it, fully centered on the page
func drawCustomerName(pdf *fpdf.Fpdf, settings CoverSettings) {
ml, _, mr, _ := pdf.GetMargins()
pw, ph := pdf.GetPageSize()
// Calculate the width and height of the grid, as we'll need it to position it
width := pw - ml - mr
// ticks are square, so the height is fully determined by the width of each grid segment
height := (width/gridXTicks - 1) * gridYTicks
text := strings.ToUpper(settings.CustomerName)
// Draw the grid with a weird gray color
gridColor.Draw(pdf)
pdf.SetLineWidth(0.01)
// Calculates the top left corner of the grid
gridX, gridY := ml, (ph/2)-(height/2)
drawGrid(pdf, gridX, gridY, width, height, gridXTicks, gridYTicks)
@@ -172,22 +219,25 @@ func drawCustomerName(pdf *fpdf.Fpdf, settings CoverSettings) {
1.2, gridNoduleColor,
0.25, settings.HLColor)
pdf.SetFont("Mark Medium", "", 10)
pdf.SetFont(fontMedium, "", 10)
// Estimate font size, and calculate line height immediately after
pdf.SetFontSize(estimateFontSize(pdf, text, width, height, gridMargin))
_, sz := pdf.GetFontSize()
sz *= lineHeight
pdf.SetFontSize(estimateFontSize(pdf, text, width, height, gridPadding))
_, fontHeight := pdf.GetFontSize()
fontHeight *= lineHeight
_, textHeight := stringSize(pdf, text, sz, 0)
// Determine the height of the full string (multiline aware)
_, textHeight := stringSize(pdf, text, fontHeight, 0)
// Centered vertically:
// x = <gridX> + <margin>
// y = <gridY> + <gridH>/2 - textHeight/2
pdf.MoveTo(ml+gridMargin, ph/2-(textHeight/2))
pdf.MoveTo(ml+gridPadding, ph/2-(textHeight/2))
textColor.Text(pdf)
pdf.MultiCell(width-2*gridMargin, sz, text, "", "ML", false)
// finally, we can draw the text
pdf.MultiCell(width-2*gridPadding, fontHeight, text, "", "ML", false)
}
// drawGrid ... draws a grid at the given x,y with given width and height,
// xs ticks on the x-axis, and ys ticks on the y-axis.
func drawGrid(pdf *fpdf.Fpdf, x, y, w, h float64, xs, ys int) {
// Horizontal lines
for i := 0; i < ys; i++ {
@@ -195,13 +245,14 @@ func drawGrid(pdf *fpdf.Fpdf, x, y, w, h float64, xs, ys int) {
pdf.Line(x, yOff, x+w, yOff)
}
// Vertical line(pw/2)-(width/2)s
// Vertical lines
for i := 0; i < xs; i++ {
xOff := x + (w/float64(xs-1))*float64(i)
pdf.Line(xOff, y, xOff, y+h)
}
}
// drawNoduleLine draws the colored line on the right side of the grid, with little squares at each end
func drawNoduleLine(pdf *fpdf.Fpdf, x1, y1, x2, y2, squareSize float64, squareColor Color, lineWidth float64, lineColor Color) {
lineColor.Draw(pdf)
pdf.SetLineWidth(lineWidth)
@@ -212,14 +263,20 @@ func drawNoduleLine(pdf *fpdf.Fpdf, x1, y1, x2, y2, squareSize float64, squareCo
pdf.Rect(x2-(squareSize/2), y2-(squareSize/2), squareSize, squareSize, "F")
}
// estimateFontSize attempts to determine the optimal font size for a given piece of text in a given container (with margin)
// this works by progressively making the font size larger and larger (up to maxFontSize) and calculating both the
// width and height at each iteration.
// It returns the biggest font size that still fits in the container (as in, 1pt larger will no longer fit, unless
// the returned font size is equal to maxFontSize)
func estimateFontSize(pdf *fpdf.Fpdf, text string, containerWidth, containerHeight, margin float64) float64 {
// Margin is on both sides haha, but I add an extra just for 'safety'
// Margin is on both sides haha, but I add an extra just for 'safety' on the right side
maxWidth := containerWidth - 3*margin
maxHeight := containerHeight - 3*margin
// Probably smallest we can start with
for fontSize := 10.; fontSize <= maxFontSize; fontSize += 1 {
width, height := stringSize(pdf, text, (fontSize/scaleFactor)*lineHeight, fontSize)
// Return the last font size if this one overflows
if width >= maxWidth || height >= maxHeight {
return fontSize - 1
}
@@ -228,6 +285,9 @@ func estimateFontSize(pdf *fpdf.Fpdf, text string, containerWidth, containerHeig
return maxFontSize
}
// stringSize builds on fpdf's `GetStringSymbolWidth` to return both the width of the largest line (split on `\n`)
// and the height of all lines put on top of each other.
// this effectively calculates the bounding box of the given text fragment
func stringSize(pdf *fpdf.Fpdf, text string, lineHeight, fontSizePt float64) (width, height float64) {
if fontSizePt == 0 {
fontSizePt, _ = pdf.GetFontSize()
@@ -245,6 +305,8 @@ func stringSize(pdf *fpdf.Fpdf, text string, lineHeight, fontSizePt float64) (wi
return
}
// addEmbeddedFont takes a reference a `go:embed`ed font and embeds it into the pdf so people
// will actually see the font if they don't magically have the specific font installed
func addEmbeddedFont(pdf *fpdf.Fpdf, family, style, path string) error {
jsonData, err := fs.ReadFile(fontsEmbedPrefix + path + ".json")
if err != nil {
@@ -263,6 +325,8 @@ func addEmbeddedFont(pdf *fpdf.Fpdf, family, style, path string) error {
return nil
}
// addEmbeddedLogo reads a `go:embed`ed image and adds it to the pdf, returning the handle for you to use
// to add it to the page
func addEmbeddedLogo(pdf *fpdf.Fpdf, name, path string) (*fpdf.ImageInfoType, error) {
reader, err := fs.Open(path)
if err != nil {