diff --git a/Makefile b/Makefile index ca0ac89..9cd91e5 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index b9be4c1..c92f3fb 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file diff --git a/cmd/covergen/main.go b/cmd/covergen/main.go index a634021..e5041d2 100644 --- a/cmd/covergen/main.go +++ b/cmd/covergen/main.go @@ -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") diff --git a/cmd/server/main.go b/cmd/server/main.go index b2923ae..d4cf10c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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 { diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index 1810f31..ca500a4 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -12,6 +12,7 @@ import ( "covergen/pkg/covergen" ) +// jsmap is just a type alias that corresponds to the ES5 Record, 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) } }() diff --git a/pkg/covergen/color.go b/pkg/covergen/color.go index 0bb27bc..5d39d5d 100644 --- a/pkg/covergen/color.go +++ b/pkg/covergen/color.go @@ -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)) } diff --git a/pkg/covergen/render.go b/pkg/covergen/render.go index 1250df5..a37b01b 100644 --- a/pkg/covergen/render.go +++ b/pkg/covergen/render.go @@ -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 = + // y = + /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 {